Compare commits

...

2 Commits

Author SHA1 Message Date
张成
4990790726 1 2025-12-27 20:19:54 +08:00
张成
43f7884e52 1 2025-12-27 20:14:40 +08:00
14 changed files with 1821 additions and 21 deletions

View File

@@ -10,7 +10,8 @@
"Bash(mkdir:*)", "Bash(mkdir:*)",
"Bash(findstr:*)", "Bash(findstr:*)",
"Bash(cat:*)", "Bash(cat:*)",
"Bash(npm run restart:*)" "Bash(npm run restart:*)",
"Bash(ls:*)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

View File

@@ -0,0 +1,35 @@
-- 添加"AI调用记录"菜单项到系统设置菜单下
INSERT INTO sys_menu (
name,
parent_id,
model_id,
form_id,
icon,
path,
component,
api_path,
is_show_menu,
is_show,
type,
sort,
create_time,
last_modify_time,
is_delete
) VALUES (
'AI调用记录', -- 菜单名称
0, -- parent_id: 系统设置菜单的ID根据实际情况调整
0, -- model_id
0, -- form_id
'md-analytics', -- icon: 分析图标
'ai_call_records', -- path: 路由路径
'system/ai_call_records.vue', -- component: 组件路径(已在 component-map.js 中定义)
'system/ai_call_records_server.js', -- api_path: API 服务文件路径
1, -- is_show_menu: 1=显示在菜单栏
1, -- is_show: 1=启用
'页面', -- type: 页面类型
10, -- sort: 排序(可根据实际情况调整)
NOW(), -- create_time: 创建时间
NOW(), -- last_modify_time: 最后修改时间
0 -- is_delete: 0=未删除
);

View File

@@ -0,0 +1,32 @@
-- 创建 AI 调用记录表
-- 用于记录所有 AI API 调用的详细信息和 Token 使用情况
CREATE TABLE `ai_call_records` (
`id` INT(11) NOT NULL AUTO_INCREMENT COMMENT '记录ID',
`user_id` INT(11) NULL DEFAULT NULL COMMENT '用户ID如果是用户触发的调用',
`sn_code` VARCHAR(50) NULL DEFAULT NULL COMMENT '设备序列号',
`service_type` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '服务类型chat, completion, embedding等',
`model_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT 'AI模型名称gpt-4, gpt-3.5-turbo等',
`prompt_tokens` INT(11) NOT NULL DEFAULT 0 COMMENT '输入Token数量',
`completion_tokens` INT(11) NOT NULL DEFAULT 0 COMMENT '输出Token数量',
`total_tokens` INT(11) NOT NULL DEFAULT 0 COMMENT '总Token数量',
`request_content` TEXT NULL COMMENT '请求内容用户输入的prompt',
`response_content` TEXT NULL COMMENT '响应内容AI返回的结果',
`cost_amount` DECIMAL(10,4) NULL DEFAULT NULL COMMENT '本次调用费用(元)',
`status` VARCHAR(20) NOT NULL DEFAULT 'success' COMMENT '调用状态success=成功, failed=失败, timeout=超时)',
`error_message` TEXT NULL COMMENT '错误信息(如果调用失败)',
`response_time` INT(11) NULL DEFAULT NULL COMMENT '响应时间(毫秒)',
`api_provider` VARCHAR(50) NOT NULL DEFAULT 'openai' COMMENT 'API提供商openai, azure, anthropic等',
`business_type` VARCHAR(50) NULL DEFAULT NULL COMMENT '业务类型job_filter, chat, resume_optimization等',
`reference_id` INT(11) NULL DEFAULT NULL COMMENT '关联业务ID如job_posting_id, chat_record_id等',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`is_delete` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否删除1=已删除0=未删除)',
PRIMARY KEY (`id`),
INDEX `idx_user_id` (`user_id`),
INDEX `idx_sn_code` (`sn_code`),
INDEX `idx_service_type` (`service_type`),
INDEX `idx_status` (`status`),
INDEX `idx_create_time` (`create_time`),
INDEX `idx_business_type` (`business_type`),
INDEX `idx_reference_id` (`reference_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI调用记录表';

View File

@@ -0,0 +1,59 @@
/**
* AI调用记录 API 服务
*/
class AiCallRecordsServer {
/**
* 分页查询AI调用记录
* @param {Object} param - 查询参数
* @param {Object} param.seachOption - 搜索条件
* @param {Object} param.pageOption - 分页选项
* @returns {Promise}
*/
page(param) {
return window.framework.http.post('/ai_call_records/list', param)
}
/**
* 获取AI调用记录详情
* @param {Number|String} id - 记录ID
* @returns {Promise}
*/
getById(id) {
return window.framework.http.get('/ai_call_records/detail', { id })
}
/**
* 获取统计数据
* @param {Object} param - 查询参数
* @param {Number} param.user_id - 用户ID
* @param {String} param.sn_code - 设备序列号
* @param {String} param.business_type - 业务类型
* @param {String} param.start_date - 开始日期
* @param {String} param.end_date - 结束日期
* @returns {Promise}
*/
getStats(param) {
return window.framework.http.post('/ai_call_records/stats', param)
}
/**
* 删除AI调用记录
* @param {Object} row - 记录数据包含id
* @returns {Promise}
*/
del(row) {
return window.framework.http.post('/ai_call_records/delete', { id: row.id })
}
/**
* 批量删除AI调用记录
* @param {Array} ids - 记录ID数组
* @returns {Promise}
*/
batchDelete(ids) {
return window.framework.http.post('/ai_call_records/batch_delete', { ids })
}
}
export default new AiCallRecordsServer()

View File

@@ -22,6 +22,7 @@ import SystemConfig from '@/views/system/system_config.vue'
import Version from '@/views/system/version.vue' import Version from '@/views/system/version.vue'
import JobTypes from '@/views/work/job_types.vue' import JobTypes from '@/views/work/job_types.vue'
import PricingPlans from '@/views/system/pricing_plans.vue' import PricingPlans from '@/views/system/pricing_plans.vue'
import AiCallRecords from '@/views/system/ai_call_records.vue'
// 首页模块 // 首页模块
import HomeIndex from '@/views/home/index.vue' import HomeIndex from '@/views/home/index.vue'
@@ -55,10 +56,9 @@ const componentMap = {
'system/version': Version, 'system/version': Version,
'work/job_types': JobTypes, 'work/job_types': JobTypes,
'system/pricing_plans': PricingPlans, 'system/pricing_plans': PricingPlans,
'system/pricing_plans.vue': PricingPlans, 'system/ai_call_records': AiCallRecords,
'home/index': HomeIndex, 'home/index': HomeIndex,
} }
export default componentMap export default componentMap

View File

@@ -0,0 +1,402 @@
<template>
<div class="content-view">
<!-- 顶部工具栏 -->
<div class="table-head-tool">
<Button type="error" @click="showBatchDelete" :disabled="selectedIds.length === 0">
批量删除 ({{ selectedIds.length }})
</Button>
<Button type="primary" @click="showStatsModal" class="ml10">查看统计</Button>
</div>
<!-- 搜索表单 -->
<div class="table-head-tool">
<Form ref="formInline" :model="gridOption.param.seachOption" inline :label-width="80">
<FormItem label="筛选条件" :label-width="80">
<Select v-model="gridOption.param.seachOption.key" style="width: 120px">
<Option v-for="item in seachTypes" :value="item.key" :key="item.key">{{ item.value }}</Option>
</Select>
<Input class="ml10" v-model="gridOption.param.seachOption.value" style="width: 200px"
placeholder="请输入关键字" @on-search="query(1)" search />
</FormItem>
<FormItem label="状态">
<Select v-model="gridOption.param.seachOption.status" style="width: 120px" clearable @on-change="query(1)">
<Option value="success">成功</Option>
<Option value="failed">失败</Option>
<Option value="timeout">超时</Option>
</Select>
</FormItem>
<FormItem label="服务类型">
<Select v-model="gridOption.param.seachOption.service_type" style="width: 140px" clearable @on-change="query(1)">
<Option value="chat">聊天</Option>
<Option value="completion">文本生成</Option>
<Option value="embedding">向量化</Option>
</Select>
</FormItem>
<FormItem label="模型">
<Select v-model="gridOption.param.seachOption.model_name" style="width: 150px" clearable @on-change="query(1)">
<Option value="gpt-3.5-turbo">GPT-3.5 Turbo</Option>
<Option value="gpt-4">GPT-4</Option>
<Option value="gpt-4-turbo">GPT-4 Turbo</Option>
</Select>
</FormItem>
<FormItem label="日期范围">
<DatePicker type="daterange" v-model="dateRange" style="width: 220px"
@on-change="handleDateChange" placeholder="选择日期范围" />
</FormItem>
<FormItem>
<Button type="primary" @click="query(1)">查询</Button>
<Button type="default" @click="resetQuery" class="ml10">重置</Button>
</FormItem>
</Form>
</div>
<!-- 数据表格 -->
<div class="table-body">
<tables :columns="listColumns" :value="gridOption.data"
:pageOption="gridOption.param.pageOption"
@changePage="query"
@on-selection-change="handleSelectionChange">
</tables>
</div>
<!-- 详情弹窗 -->
<Modal v-model="detailModal.show" title="AI调用详情" width="800" :footer-hide="true">
<div v-if="detailModal.data" class="detail-content">
<Row :gutter="16">
<Col span="12">
<p><strong>记录ID:</strong> {{ detailModal.data.id }}</p>
<p><strong>用户ID:</strong> {{ detailModal.data.user_id || '-' }}</p>
<p><strong>设备序列号:</strong> {{ detailModal.data.sn_code || '-' }}</p>
<p><strong>服务类型:</strong> {{ detailModal.data.service_type }}</p>
<p><strong>模型名称:</strong> {{ detailModal.data.model_name }}</p>
<p><strong>API提供商:</strong> {{ detailModal.data.api_provider }}</p>
</Col>
<Col span="12">
<p><strong>输入Token:</strong> {{ detailModal.data.prompt_tokens }}</p>
<p><strong>输出Token:</strong> {{ detailModal.data.completion_tokens }}</p>
<p><strong>总Token:</strong> {{ detailModal.data.total_tokens }}</p>
<p><strong>费用:</strong> ¥{{ detailModal.data.cost_amount || 0 }}</p>
<p><strong>响应时间:</strong> {{ detailModal.data.response_time || '-' }}ms</p>
<p><strong>创建时间:</strong> {{ formatDate(detailModal.data.create_time) }}</p>
</Col>
</Row>
<Divider />
<p><strong>请求内容:</strong></p>
<pre class="content-box">{{ detailModal.data.request_content || '无' }}</pre>
<p><strong>响应内容:</strong></p>
<pre class="content-box">{{ detailModal.data.response_content || '无' }}</pre>
<p v-if="detailModal.data.error_message"><strong>错误信息:</strong></p>
<pre v-if="detailModal.data.error_message" class="content-box error">{{ detailModal.data.error_message }}</pre>
</div>
</Modal>
<!-- 统计弹窗 -->
<Modal v-model="statsModal.show" title="Token使用统计" width="700" :footer-hide="true">
<div v-if="statsModal.data" class="stats-content">
<Row :gutter="16">
<Col span="8">
<div class="stat-card">
<p class="stat-label">总调用次数</p>
<p class="stat-value">{{ statsModal.data.total_calls }}</p>
</div>
</Col>
<Col span="8">
<div class="stat-card">
<p class="stat-label">总Token数</p>
<p class="stat-value">{{ statsModal.data.total_tokens.toLocaleString() }}</p>
</div>
</Col>
<Col span="8">
<div class="stat-card">
<p class="stat-label">总费用</p>
<p class="stat-value">¥{{ statsModal.data.total_cost.toFixed(2) }}</p>
</div>
</Col>
</Row>
<Divider />
<p><strong>按模型统计:</strong></p>
<ul class="stats-list">
<li v-for="(value, key) in statsModal.data.by_model" :key="key">
{{ key }}: {{ value.count }}, {{ value.total_tokens.toLocaleString() }} tokens, ¥{{ value.total_cost.toFixed(2) }}
</li>
</ul>
</div>
</Modal>
</div>
</template>
<script>
import aiCallRecordsServer from '@/api/system/ai_call_records_server.js'
export default {
data() {
return {
seachTypes: [
{ key: 'user_id', value: '用户ID' },
{ key: 'sn_code', value: '设备序列号' },
{ key: 'reference_id', value: '业务ID' }
],
gridOption: {
param: {
seachOption: {
key: 'user_id',
value: '',
status: null,
service_type: null,
model_name: null,
start_date: null,
end_date: null
},
pageOption: {
page: 1,
pageSize: 20
}
},
data: []
},
selectedIds: [],
dateRange: [],
detailModal: {
show: false,
data: null
},
statsModal: {
show: false,
data: null
},
listColumns: [
{ type: 'selection', width: 60, align: 'center' },
{ title: 'ID', key: 'id', minWidth: 70 },
{ title: '用户ID', key: 'user_id', minWidth: 80 },
{ title: '设备序列号', key: 'sn_code', minWidth: 120 },
{ title: '服务类型', key: 'service_type', minWidth: 100 },
{ title: '模型', key: 'model_name', minWidth: 140 },
{
title: 'Token使用',
key: 'total_tokens',
minWidth: 150,
render: (h, params) => {
return h('span', `输入:${params.row.prompt_tokens} + 输出:${params.row.completion_tokens} = ${params.row.total_tokens}`)
}
},
{
title: '费用',
key: 'cost_amount',
minWidth: 80,
render: (h, params) => {
return h('span', `¥${(params.row.cost_amount || 0).toFixed(4)}`)
}
},
{
title: '状态',
key: 'status',
minWidth: 80,
render: (h, params) => {
const colorMap = { success: 'success', failed: 'error', timeout: 'warning' }
const textMap = { success: '成功', failed: '失败', timeout: '超时' }
return h('Tag', {
props: { color: colorMap[params.row.status] || 'default' }
}, textMap[params.row.status] || params.row.status)
}
},
{
title: '响应时间',
key: 'response_time',
minWidth: 100,
render: (h, params) => {
return h('span', params.row.response_time ? `${params.row.response_time}ms` : '-')
}
},
{
title: '创建时间',
key: 'create_time',
minWidth: 150,
render: (h, params) => {
return h('span', this.formatDate(params.row.create_time))
}
},
{
title: '操作',
key: 'action',
width: 150,
align: 'center',
render: (h, params) => {
return h('div', [
h('Button', {
props: { type: 'primary', size: 'small' },
style: { marginRight: '5px' },
on: { click: () => this.showDetail(params.row) }
}, '详情'),
h('Button', {
props: { type: 'error', size: 'small' },
on: { click: () => this.del(params.row) }
}, '删除')
])
}
}
]
}
},
mounted() {
this.query(1)
},
methods: {
query(page) {
if (page) {
this.gridOption.param.pageOption.page = page
}
const param = {
pageOption: this.gridOption.param.pageOption,
seachOption: { ...this.gridOption.param.seachOption }
}
aiCallRecordsServer.page(param).then(res => {
if (res.code === 0) {
const data = res.data
this.gridOption.data = data.rows
this.gridOption.param.pageOption.total = data.count || data.total || 0
} else {
this.$Message.error(res.message || '查询失败')
}
}).catch(err => {
this.$Message.error('查询失败:' + (err.message || '未知错误'))
})
},
resetQuery() {
this.gridOption.param.seachOption = {
key: 'user_id',
value: '',
status: null,
service_type: null,
model_name: null,
start_date: null,
end_date: null
}
this.dateRange = []
this.query(1)
},
handleDateChange(dates) {
if (dates && dates.length === 2) {
this.gridOption.param.seachOption.start_date = dates[0]
this.gridOption.param.seachOption.end_date = dates[1]
} else {
this.gridOption.param.seachOption.start_date = null
this.gridOption.param.seachOption.end_date = null
}
},
handleSelectionChange(selection) {
this.selectedIds = selection.map(item => item.id)
},
showDetail(row) {
aiCallRecordsServer.getById(row.id).then(res => {
if (res.code === 0) {
this.detailModal.data = res.data
this.detailModal.show = true
} else {
this.$Message.error(res.message || '获取详情失败')
}
})
},
showStatsModal() {
const param = { ...this.gridOption.param.seachOption }
aiCallRecordsServer.getStats(param).then(res => {
if (res.code === 0) {
this.statsModal.data = res.data
this.statsModal.show = true
} else {
this.$Message.error(res.message || '获取统计失败')
}
})
},
del(row) {
this.$Modal.confirm({
title: '确认删除',
content: `确定要删除这条AI调用记录吗`,
onOk: () => {
aiCallRecordsServer.del(row).then(res => {
if (res.code === 0) {
this.$Message.success('删除成功')
this.query(this.gridOption.param.pageOption.page)
} else {
this.$Message.error(res.message || '删除失败')
}
})
}
})
},
showBatchDelete() {
if (this.selectedIds.length === 0) {
this.$Message.warning('请先选择要删除的记录')
return
}
this.$Modal.confirm({
title: '确认批量删除',
content: `确定要删除选中的 ${this.selectedIds.length} 条记录吗?`,
onOk: () => {
aiCallRecordsServer.batchDelete(this.selectedIds).then(res => {
if (res.code === 0) {
this.$Message.success('批量删除成功')
this.selectedIds = []
this.query(this.gridOption.param.pageOption.page)
} else {
this.$Message.error(res.message || '批量删除失败')
}
})
}
})
},
formatDate(date) {
if (!date) return '-'
const d = new Date(date)
return d.toLocaleString('zh-CN', { hour12: false })
}
}
}
</script>
<style scoped>
.content-view {
padding: 16px;
}
.detail-content p {
margin: 8px 0;
}
.content-box {
background: #f5f5f5;
padding: 12px;
border-radius: 4px;
max-height: 200px;
overflow-y: auto;
white-space: pre-wrap;
word-wrap: break-word;
margin: 8px 0;
}
.content-box.error {
background: #fee;
color: #c00;
}
.stats-content .stat-card {
background: #f5f7fa;
padding: 16px;
border-radius: 4px;
text-align: center;
}
.stat-label {
color: #999;
font-size: 14px;
margin-bottom: 8px;
}
.stat-value {
color: #333;
font-size: 24px;
font-weight: bold;
}
.stats-list {
list-style: none;
padding: 0;
}
.stats-list li {
padding: 8px 0;
border-bottom: 1px solid #eee;
}
</style>

View File

@@ -203,8 +203,7 @@ export default {
{ {
title: '是否推荐', title: '是否推荐',
key: 'featured', key: 'featured',
type: 'radio', com: 'Radio',
required: true,
options: [ options: [
{ value: 1, label: '推荐' }, { value: 1, label: '推荐' },
{ value: 0, label: '普通' } { value: 0, label: '普通' }
@@ -213,8 +212,7 @@ export default {
{ {
title: '是否启用', title: '是否启用',
key: 'is_active', key: 'is_active',
type: 'radio', com: 'Radio',
required: true,
options: [ options: [
{ value: 1, label: '启用' }, { value: 1, label: '启用' },
{ value: 0, label: '禁用' } { value: 0, label: '禁用' }

View File

@@ -0,0 +1,317 @@
/**
* AI调用记录管理API - 后台管理
* 提供AI调用记录的查询和统计功能
*/
const Framework = require("../../framework/node-core-framework.js");
module.exports = {
/**
* @swagger
* /admin_api/ai_call_records/list:
* post:
* summary: 获取AI调用记录列表
* description: 分页获取所有AI调用记录
* tags: [后台-AI调用记录管理]
*/
'POST /ai_call_records/list': async (ctx) => {
try {
const models = Framework.getModels();
const { ai_call_records, op } = models;
const body = ctx.getBody();
// 获取分页参数
const { limit, offset } = ctx.getPageSize();
// 构建查询条件
const where = { is_delete: 0 };
// 搜索条件
if (body.seachOption) {
const { key, value, status, service_type, model_name, api_provider, business_type, user_id, sn_code } = body.seachOption;
// 关键字搜索
if (value && key) {
if (key === 'user_id' || key === 'reference_id') {
where[key] = value;
} else if (key === 'sn_code') {
where.sn_code = { [op.like]: `%${value}%` };
}
}
// 状态筛选
if (status) {
where.status = status;
}
// 服务类型筛选
if (service_type) {
where.service_type = service_type;
}
// 模型名称筛选
if (model_name) {
where.model_name = model_name;
}
// API提供商筛选
if (api_provider) {
where.api_provider = api_provider;
}
// 业务类型筛选
if (business_type) {
where.business_type = business_type;
}
// 用户ID筛选
if (user_id) {
where.user_id = user_id;
}
// 设备序列号筛选
if (sn_code) {
where.sn_code = sn_code;
}
// 日期范围筛选
if (body.seachOption.start_date && body.seachOption.end_date) {
where.create_time = {
[op.between]: [new Date(body.seachOption.start_date), new Date(body.seachOption.end_date)]
};
}
}
const result = await ai_call_records.findAndCountAll({
where,
limit,
offset,
order: [['create_time', 'DESC'], ['id', 'DESC']],
attributes: [
'id', 'user_id', 'sn_code', 'service_type', 'model_name',
'prompt_tokens', 'completion_tokens', 'total_tokens',
'cost_amount', 'status', 'response_time', 'api_provider',
'business_type', 'reference_id', 'create_time'
]
});
return ctx.success(result);
} catch (error) {
console.error('获取AI调用记录列表失败:', error);
return ctx.fail('获取AI调用记录列表失败: ' + error.message);
}
},
/**
* @swagger
* /admin_api/ai_call_records/detail:
* get:
* summary: 获取AI调用记录详情
* description: 根据ID获取AI调用记录详细信息包含请求和响应内容
* tags: [后台-AI调用记录管理]
*/
'GET /ai_call_records/detail': async (ctx) => {
try {
const models = Framework.getModels();
const { ai_call_records } = models;
const { id } = ctx.getQuery();
if (!id) {
return ctx.fail('记录ID不能为空');
}
const record = await ai_call_records.findOne({
where: { id, is_delete: 0 }
});
if (!record) {
return ctx.fail('记录不存在');
}
return ctx.success(record);
} catch (error) {
console.error('获取AI调用记录详情失败:', error);
return ctx.fail('获取AI调用记录详情失败: ' + error.message);
}
},
/**
* @swagger
* /admin_api/ai_call_records/stats:
* post:
* summary: 获取AI调用统计数据
* description: 统计Token使用量、调用次数、费用等
* tags: [后台-AI调用记录管理]
*/
'POST /ai_call_records/stats': async (ctx) => {
try {
const models = Framework.getModels();
const { ai_call_records, op } = models;
const body = ctx.getBody();
const where = { is_delete: 0, status: 'success' };
// 构建查询条件
if (body.user_id) {
where.user_id = body.user_id;
}
if (body.sn_code) {
where.sn_code = body.sn_code;
}
if (body.business_type) {
where.business_type = body.business_type;
}
if (body.start_date && body.end_date) {
where.create_time = {
[op.between]: [new Date(body.start_date), new Date(body.end_date)]
};
}
const records = await ai_call_records.findAll({ where });
// 计算统计数据
const stats = {
total_calls: records.length,
total_prompt_tokens: 0,
total_completion_tokens: 0,
total_tokens: 0,
total_cost: 0,
avg_response_time: 0,
by_model: {},
by_service_type: {},
by_status: { success: 0, failed: 0, timeout: 0 }
};
let totalResponseTime = 0;
let responseTimeCount = 0;
records.forEach(record => {
// Token统计
stats.total_prompt_tokens += record.prompt_tokens || 0;
stats.total_completion_tokens += record.completion_tokens || 0;
stats.total_tokens += record.total_tokens || 0;
stats.total_cost += parseFloat(record.cost_amount || 0);
// 响应时间统计
if (record.response_time) {
totalResponseTime += record.response_time;
responseTimeCount++;
}
// 按模型统计
if (!stats.by_model[record.model_name]) {
stats.by_model[record.model_name] = {
count: 0,
total_tokens: 0,
total_cost: 0
};
}
stats.by_model[record.model_name].count++;
stats.by_model[record.model_name].total_tokens += record.total_tokens || 0;
stats.by_model[record.model_name].total_cost += parseFloat(record.cost_amount || 0);
// 按服务类型统计
if (!stats.by_service_type[record.service_type]) {
stats.by_service_type[record.service_type] = {
count: 0,
total_tokens: 0
};
}
stats.by_service_type[record.service_type].count++;
stats.by_service_type[record.service_type].total_tokens += record.total_tokens || 0;
});
// 计算平均响应时间
if (responseTimeCount > 0) {
stats.avg_response_time = Math.round(totalResponseTime / responseTimeCount);
}
// 查询失败和超时的记录
const failedCount = await ai_call_records.count({
where: { ...where, status: 'failed', is_delete: 0 }
});
const timeoutCount = await ai_call_records.count({
where: { ...where, status: 'timeout', is_delete: 0 }
});
stats.by_status.success = records.length;
stats.by_status.failed = failedCount;
stats.by_status.timeout = timeoutCount;
return ctx.success(stats);
} catch (error) {
console.error('获取AI调用统计失败:', error);
return ctx.fail('获取AI调用统计失败: ' + error.message);
}
},
/**
* @swagger
* /admin_api/ai_call_records/delete:
* post:
* summary: 删除AI调用记录
* description: 软删除指定的AI调用记录
* tags: [后台-AI调用记录管理]
*/
'POST /ai_call_records/delete': async (ctx) => {
try {
const models = Framework.getModels();
const { ai_call_records } = models;
const { id } = ctx.getBody();
if (!id) {
return ctx.fail('记录ID不能为空');
}
const record = await ai_call_records.findOne({
where: { id, is_delete: 0 }
});
if (!record) {
return ctx.fail('记录不存在');
}
// 软删除
await ai_call_records.update(
{ is_delete: 1 },
{ where: { id } }
);
return ctx.success({ message: 'AI调用记录删除成功' });
} catch (error) {
console.error('删除AI调用记录失败:', error);
return ctx.fail('删除AI调用记录失败: ' + error.message);
}
},
/**
* @swagger
* /admin_api/ai_call_records/batch_delete:
* post:
* summary: 批量删除AI调用记录
* description: 批量软删除AI调用记录
* tags: [后台-AI调用记录管理]
*/
'POST /ai_call_records/batch_delete': async (ctx) => {
try {
const models = Framework.getModels();
const { ai_call_records, op } = models;
const { ids } = ctx.getBody();
if (!ids || !Array.isArray(ids) || ids.length === 0) {
return ctx.fail('记录ID列表不能为空');
}
// 批量软删除
await ai_call_records.update(
{ is_delete: 1 },
{ where: { id: { [op.in]: ids } } }
);
return ctx.success({ message: `成功删除 ${ids.length} 条记录` });
} catch (error) {
console.error('批量删除AI调用记录失败:', error);
return ctx.fail('批量删除AI调用记录失败: ' + error.message);
}
}
};

View File

@@ -0,0 +1,142 @@
const Sequelize = require('sequelize');
/**
* AI调用记录表模型
* 记录所有AI API调用的详细信息和Token使用情况
*/
module.exports = (db) => {
const ai_call_records = db.define("ai_call_records", {
user_id: {
comment: '用户ID如果是用户触发的调用',
type: Sequelize.INTEGER,
allowNull: true,
defaultValue: null
},
sn_code: {
comment: '设备序列号',
type: Sequelize.STRING(50),
allowNull: true,
defaultValue: null
},
service_type: {
comment: '服务类型chat, completion, embedding等',
type: Sequelize.STRING(50),
allowNull: false,
defaultValue: ''
},
model_name: {
comment: 'AI模型名称gpt-4, gpt-3.5-turbo等',
type: Sequelize.STRING(100),
allowNull: false,
defaultValue: ''
},
prompt_tokens: {
comment: '输入Token数量',
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0
},
completion_tokens: {
comment: '输出Token数量',
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0
},
total_tokens: {
comment: '总Token数量',
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0
},
request_content: {
comment: '请求内容用户输入的prompt',
type: Sequelize.TEXT,
allowNull: true,
defaultValue: null
},
response_content: {
comment: '响应内容AI返回的结果',
type: Sequelize.TEXT,
allowNull: true,
defaultValue: null
},
cost_amount: {
comment: '本次调用费用(元)',
type: Sequelize.DECIMAL(10, 4),
allowNull: true,
defaultValue: null
},
status: {
comment: '调用状态success=成功, failed=失败, timeout=超时)',
type: Sequelize.STRING(20),
allowNull: false,
defaultValue: 'success'
},
error_message: {
comment: '错误信息(如果调用失败)',
type: Sequelize.TEXT,
allowNull: true,
defaultValue: null
},
response_time: {
comment: '响应时间(毫秒)',
type: Sequelize.INTEGER,
allowNull: true,
defaultValue: null
},
api_provider: {
comment: 'API提供商openai, azure, anthropic等',
type: Sequelize.STRING(50),
allowNull: false,
defaultValue: 'openai'
},
business_type: {
comment: '业务类型job_filter, chat, resume_optimization等',
type: Sequelize.STRING(50),
allowNull: true,
defaultValue: null
},
reference_id: {
comment: '关联业务ID如job_posting_id, chat_record_id等',
type: Sequelize.INTEGER,
allowNull: true,
defaultValue: null
},
}, {
timestamps: false,
indexes: [
{
unique: false,
fields: ['user_id']
},
{
unique: false,
fields: ['sn_code']
},
{
unique: false,
fields: ['service_type']
},
{
unique: false,
fields: ['status']
},
{
unique: false,
fields: ['create_time']
},
{
unique: false,
fields: ['business_type']
},
{
unique: false,
fields: ['reference_id']
}
]
});
// ai_call_records.sync({ force: true });
return ai_call_records;
}

View File

@@ -0,0 +1,174 @@
/**
* AI调用记录工具类
* 用于记录每次AI API调用的详细信息
*/
const Framework = require("../../framework/node-core-framework.js");
class AiCallRecorder {
/**
* 记录AI调用
* @param {Object} params - 调用参数
* @param {Number} params.user_id - 用户ID
* @param {String} params.sn_code - 设备序列号
* @param {String} params.service_type - 服务类型
* @param {String} params.model_name - 模型名称
* @param {Number} params.prompt_tokens - 输入token数
* @param {Number} params.completion_tokens - 输出token数
* @param {Number} params.total_tokens - 总token数
* @param {String} params.request_content - 请求内容
* @param {String} params.response_content - 响应内容
* @param {Number} params.cost_amount - 费用
* @param {String} params.status - 状态
* @param {String} params.error_message - 错误信息
* @param {Number} params.response_time - 响应时间(毫秒)
* @param {String} params.api_provider - API提供商
* @param {String} params.business_type - 业务类型
* @param {Number} params.reference_id - 关联业务ID
* @returns {Promise<Object>} 记录结果
*/
static async record(params) {
try {
const models = Framework.getModels();
const { ai_call_records } = models;
if (!ai_call_records) {
console.error('AI调用记录模型未找到');
return null;
}
const record = await ai_call_records.create({
user_id: params.user_id || null,
sn_code: params.sn_code || null,
service_type: params.service_type || '',
model_name: params.model_name || '',
prompt_tokens: params.prompt_tokens || 0,
completion_tokens: params.completion_tokens || 0,
total_tokens: params.total_tokens || 0,
request_content: params.request_content || null,
response_content: params.response_content || null,
cost_amount: params.cost_amount || null,
status: params.status || 'success',
error_message: params.error_message || null,
response_time: params.response_time || null,
api_provider: params.api_provider || 'openai',
business_type: params.business_type || null,
reference_id: params.reference_id || null,
is_delete: 0,
create_time: new Date()
});
console.log(`AI调用已记录 - ID: ${record.id}, Model: ${params.model_name}, Tokens: ${params.total_tokens}`);
return record;
} catch (error) {
console.error('记录AI调用失败:', error);
return null;
}
}
/**
* 统计用户Token使用量
* @param {Number} user_id - 用户ID
* @param {String} startDate - 开始日期 (可选)
* @param {String} endDate - 结束日期 (可选)
* @returns {Promise<Object>} 统计结果
*/
static async getUserTokenStats(user_id, startDate = null, endDate = null) {
try {
const models = Framework.getModels();
const { ai_call_records, op } = models;
const where = {
user_id,
is_delete: 0,
status: 'success'
};
if (startDate && endDate) {
where.create_time = {
[op.between]: [new Date(startDate), new Date(endDate)]
};
} else if (startDate) {
where.create_time = {
[op.gte]: new Date(startDate)
};
} else if (endDate) {
where.create_time = {
[op.lte]: new Date(endDate)
};
}
const records = await ai_call_records.findAll({ where });
const stats = {
total_calls: records.length,
total_prompt_tokens: 0,
total_completion_tokens: 0,
total_tokens: 0,
total_cost: 0
};
records.forEach(record => {
stats.total_prompt_tokens += record.prompt_tokens || 0;
stats.total_completion_tokens += record.completion_tokens || 0;
stats.total_tokens += record.total_tokens || 0;
stats.total_cost += parseFloat(record.cost_amount || 0);
});
return stats;
} catch (error) {
console.error('统计Token使用量失败:', error);
return null;
}
}
/**
* 统计设备Token使用量
* @param {String} sn_code - 设备序列号
* @param {String} startDate - 开始日期 (可选)
* @param {String} endDate - 结束日期 (可选)
* @returns {Promise<Object>} 统计结果
*/
static async getDeviceTokenStats(sn_code, startDate = null, endDate = null) {
try {
const models = Framework.getModels();
const { ai_call_records, op } = models;
const where = {
sn_code,
is_delete: 0,
status: 'success'
};
if (startDate && endDate) {
where.create_time = {
[op.between]: [new Date(startDate), new Date(endDate)]
};
}
const records = await ai_call_records.findAll({ where });
const stats = {
total_calls: records.length,
total_prompt_tokens: 0,
total_completion_tokens: 0,
total_tokens: 0,
total_cost: 0
};
records.forEach(record => {
stats.total_prompt_tokens += record.prompt_tokens || 0;
stats.total_completion_tokens += record.completion_tokens || 0;
stats.total_tokens += record.total_tokens || 0;
stats.total_cost += parseFloat(record.cost_amount || 0);
});
return stats;
} catch (error) {
console.error('统计设备Token使用量失败:', error);
return null;
}
}
}
module.exports = AiCallRecorder;

View File

@@ -4,6 +4,7 @@
*/ */
const axios = require('axios'); const axios = require('axios');
const AiCallRecorder = require('./ai_call_recorder.js');
class AIService { class AIService {
constructor(config = {}) { constructor(config = {}) {
@@ -30,6 +31,9 @@ class AIService {
* @returns {Promise<String>} AI响应内容 * @returns {Promise<String>} AI响应内容
*/ */
async chat(messages, options = {}) { async chat(messages, options = {}) {
const startTime = Date.now();
const requestContent = JSON.stringify(messages);
try { try {
const response = await this.client.post('/v1/chat/completions', { const response = await this.client.post('/v1/chat/completions', {
model: this.model, model: this.model,
@@ -39,19 +43,90 @@ class AIService {
...options ...options
}); });
return response.data.choices[0].message.content; const responseTime = Date.now() - startTime;
const responseContent = response.data.choices[0].message.content;
const usage = response.data.usage || {};
// 记录AI调用异步不阻塞主流程
this.recordAiCall({
user_id: options.user_id,
sn_code: options.sn_code,
service_type: options.service_type || 'chat',
model_name: this.model,
prompt_tokens: usage.prompt_tokens || 0,
completion_tokens: usage.completion_tokens || 0,
total_tokens: usage.total_tokens || 0,
request_content: requestContent,
response_content: responseContent,
cost_amount: this.calculateCost(usage.total_tokens || 0),
status: 'success',
response_time: responseTime,
api_provider: 'deepseek',
business_type: options.business_type,
reference_id: options.reference_id
}).catch(err => {
console.warn('记录AI调用失败不影响主流程:', err.message);
});
return responseContent;
} catch (error) { } catch (error) {
const responseTime = Date.now() - startTime;
// 记录失败的调用
this.recordAiCall({
user_id: options.user_id,
sn_code: options.sn_code,
service_type: options.service_type || 'chat',
model_name: this.model,
request_content: requestContent,
status: 'failed',
error_message: error.message,
response_time: responseTime,
api_provider: 'deepseek',
business_type: options.business_type,
reference_id: options.reference_id
}).catch(err => {
console.warn('记录失败调用失败:', err.message);
});
console.warn('AI服务调用失败:', error.message); console.warn('AI服务调用失败:', error.message);
throw new Error(`AI服务调用失败: ${error.message}`); throw new Error(`AI服务调用失败: ${error.message}`);
} }
} }
/**
* 记录AI调用
* @param {Object} params - 调用参数
* @returns {Promise}
*/
async recordAiCall(params) {
try {
await AiCallRecorder.record(params);
} catch (error) {
// 记录失败不应影响主流程
console.warn('AI调用记录失败:', error.message);
}
}
/**
* 计算调用费用
* @param {Number} totalTokens - 总Token数
* @returns {Number} 费用(元)
*/
calculateCost(totalTokens) {
// DeepSeek 价格(元/1000 tokens
// 可以根据实际API定价调整
const pricePerThousand = 0.001; // 示例价格
return (totalTokens / 1000) * pricePerThousand;
}
/** /**
* 分析简历竞争力 * 分析简历竞争力
* @param {Object} resumeData - 简历数据 * @param {Object} resumeData - 简历数据
* @param {Object} context - 上下文信息user_id, sn_code等
* @returns {Promise<Object>} 分析结果 * @returns {Promise<Object>} 分析结果
*/ */
async analyzeResume(resumeData) { async analyzeResume(resumeData, context = {}) {
const prompt = `请分析以下简历的竞争力,并提供详细评估: const prompt = `请分析以下简历的竞争力,并提供详细评估:
简历信息: 简历信息:
@@ -91,7 +166,14 @@ class AIService {
]; ];
try { try {
const response = await this.chat(messages, { temperature: 0.3 }); const response = await this.chat(messages, {
temperature: 0.3,
user_id: context.user_id,
sn_code: context.sn_code,
service_type: 'completion',
business_type: 'resume_analysis',
reference_id: resumeData.id || resumeData.resumeId
});
// 提取JSON部分 // 提取JSON部分
const jsonMatch = response.match(/\{[\s\S]*\}/); const jsonMatch = response.match(/\{[\s\S]*\}/);
if (jsonMatch) { if (jsonMatch) {
@@ -120,9 +202,10 @@ class AIService {
* 岗位匹配度评估 * 岗位匹配度评估
* @param {Object} jobData - 岗位数据 * @param {Object} jobData - 岗位数据
* @param {Object} resumeData - 简历数据 * @param {Object} resumeData - 简历数据
* @param {Object} context - 上下文信息user_id, sn_code等
* @returns {Promise<Object>} 匹配结果 * @returns {Promise<Object>} 匹配结果
*/ */
async matchJobWithResume(jobData, resumeData) { async matchJobWithResume(jobData, resumeData, context = {}) {
const prompt = `请评估以下岗位与简历的匹配度: const prompt = `请评估以下岗位与简历的匹配度:
【岗位信息】 【岗位信息】
@@ -169,7 +252,14 @@ class AIService {
]; ];
try { try {
const response = await this.chat(messages, { temperature: 0.3 }); const response = await this.chat(messages, {
temperature: 0.3,
user_id: context.user_id,
sn_code: context.sn_code,
service_type: 'completion',
business_type: 'job_matching',
reference_id: jobData.id || jobData.jobId
});
const jsonMatch = response.match(/\{[\s\S]*\}/); const jsonMatch = response.match(/\{[\s\S]*\}/);
if (jsonMatch) { if (jsonMatch) {
return JSON.parse(jsonMatch[0]); return JSON.parse(jsonMatch[0]);
@@ -197,9 +287,10 @@ class AIService {
* 批量评估岗位(用于智能筛选) * 批量评估岗位(用于智能筛选)
* @param {Array} jobs - 岗位列表 * @param {Array} jobs - 岗位列表
* @param {Object} resumeData - 简历数据 * @param {Object} resumeData - 简历数据
* @param {Object} context - 上下文信息user_id, sn_code等
* @returns {Promise<Array>} 评估结果列表 * @returns {Promise<Array>} 评估结果列表
*/ */
async batchMatchJobs(jobs, resumeData) { async batchMatchJobs(jobs, resumeData, context = {}) {
const results = []; const results = [];
// 限制并发数量避免API限流 // 限制并发数量避免API限流
@@ -207,7 +298,7 @@ class AIService {
for (let i = 0; i < jobs.length; i += concurrency) { for (let i = 0; i < jobs.length; i += concurrency) {
const batch = jobs.slice(i, i + concurrency); const batch = jobs.slice(i, i + concurrency);
const batchPromises = batch.map(job => const batchPromises = batch.map(job =>
this.matchJobWithResume(job, resumeData).catch(err => { this.matchJobWithResume(job, resumeData, context).catch(err => {
console.warn(`岗位${job.jobId}匹配失败:`, err.message); console.warn(`岗位${job.jobId}匹配失败:`, err.message);
return { return {
jobId: job.jobId, jobId: job.jobId,
@@ -231,11 +322,11 @@ class AIService {
/** /**
* 生成聊天内容 * 生成聊天内容
* @param {Object} context - 聊天上下文 * @param {Object} context - 聊天上下文包含jobInfo, resumeInfo, chatType, user_id, sn_code等
* @returns {Promise<String>} 生成的聊天内容 * @returns {Promise<String>} 生成的聊天内容
*/ */
async generateChatContent(context) { async generateChatContent(context) {
const { jobInfo, resumeInfo, chatType = 'greeting', previousMessages = [] } = context; const { jobInfo, resumeInfo, chatType = 'greeting', previousMessages = [], user_id, sn_code } = context;
let prompt = ''; let prompt = '';
@@ -279,7 +370,15 @@ class AIService {
]; ];
try { try {
const response = await this.chat(messages, { temperature: 0.8, max_tokens: 200 }); const response = await this.chat(messages, {
temperature: 0.8,
max_tokens: 200,
user_id,
sn_code,
service_type: 'chat',
business_type: 'chat_generation',
reference_id: jobInfo?.jobId || jobInfo?.id
});
return response.trim(); return response.trim();
} catch (error) { } catch (error) {
console.warn('生成聊天内容失败:', error); console.warn('生成聊天内容失败:', error);
@@ -300,9 +399,10 @@ class AIService {
/** /**
* 判断是否为面试邀约 * 判断是否为面试邀约
* @param {String} message - HR消息内容 * @param {String} message - HR消息内容
* @param {Object} context - 上下文信息user_id, sn_code等
* @returns {Promise<Object>} 判断结果 * @returns {Promise<Object>} 判断结果
*/ */
async detectInterviewInvitation(message) { async detectInterviewInvitation(message, context = {}) {
const prompt = `判断以下HR消息是否为面试邀约并提取关键信息 const prompt = `判断以下HR消息是否为面试邀约并提取关键信息
消息内容: "${message}" 消息内容: "${message}"
@@ -323,7 +423,14 @@ class AIService {
]; ];
try { try {
const response = await this.chat(messages, { temperature: 0.1 }); const response = await this.chat(messages, {
temperature: 0.1,
user_id: context.user_id,
sn_code: context.sn_code,
service_type: 'completion',
business_type: 'interview_detection',
reference_id: context.conversation_id || context.job_id
});
const jsonMatch = response.match(/\{[\s\S]*\}/); const jsonMatch = response.match(/\{[\s\S]*\}/);
if (jsonMatch) { if (jsonMatch) {
return JSON.parse(jsonMatch[0]); return JSON.parse(jsonMatch[0]);
@@ -349,9 +456,10 @@ class AIService {
/** /**
* 分析HR反馈情感 * 分析HR反馈情感
* @param {String} message - HR消息内容 * @param {String} message - HR消息内容
* @param {Object} context - 上下文信息user_id, sn_code等
* @returns {Promise<Object>} 情感分析结果 * @returns {Promise<Object>} 情感分析结果
*/ */
async analyzeSentiment(message) { async analyzeSentiment(message, context = {}) {
const prompt = `分析以下HR消息的情感倾向 const prompt = `分析以下HR消息的情感倾向
消息: "${message}" 消息: "${message}"
@@ -370,7 +478,14 @@ class AIService {
]; ];
try { try {
const response = await this.chat(messages, { temperature: 0.1 }); const response = await this.chat(messages, {
temperature: 0.1,
user_id: context.user_id,
sn_code: context.sn_code,
service_type: 'completion',
business_type: 'sentiment_analysis',
reference_id: context.conversation_id || context.job_id
});
const jsonMatch = response.match(/\{[\s\S]*\}/); const jsonMatch = response.match(/\{[\s\S]*\}/);
if (jsonMatch) { if (jsonMatch) {
return JSON.parse(jsonMatch[0]); return JSON.parse(jsonMatch[0]);

View File

@@ -0,0 +1,77 @@
# AI Call Recorder 目录迁移说明
## 迁移概述
已将 `AiCallRecorder``api/utils/` 目录迁移到 `api/services/` 目录。
## 文件变更
### 移动的文件
- **原路径**: `api/utils/ai_call_recorder.js`
- **新路径**: `api/services/ai_call_recorder.js`
### 删除的文件
- `api/utils/ai_call_recorder_example.js` (示例文件,已删除)
### 更新的引用
#### `api/services/ai_service.js`
```javascript
// 旧引用
const AiCallRecorder = require('../utils/ai_call_recorder.js');
// 新引用
const AiCallRecorder = require('./ai_call_recorder.js');
```
## 迁移原因
1. **更符合架构规范**: `AiCallRecorder` 是一个业务服务类,而非通用工具类
2. **职责明确**: 与 `ai_service.js` 在同一目录,便于管理和维护
3. **依赖关系清晰**: 两个文件紧密配合,放在同一目录更合理
## 文件结构
```
api/
├── services/
│ ├── ai_service.js # AI服务主类
│ └── ai_call_recorder.js # AI调用记录服务类
├── controller_admin/
│ └── ai_call_records.js # 后台管理API
└── model/
└── ai_call_records.js # 数据模型
```
## 验证步骤
执行以下命令验证迁移成功:
```bash
# 1. 检查文件是否存在
ls -la f:/项目/自动找工作/autoAiWorkSys/api/services/ai_call_recorder.js
# 2. 检查旧文件是否已删除
ls -la f:/项目/自动找工作/autoAiWorkSys/api/utils/ai_call* 2>&1
# 3. 搜索所有引用
grep -r "ai_call_recorder" f:/项目/自动找工作/autoAiWorkSys/api/
```
## 影响范围
**无破坏性影响**
- 仅有 `ai_service.js` 引用了此文件
- 引用路径已更新
- 功能无任何变更
## 兼容性
- ✅ 所有现有功能正常
- ✅ 对外接口无变化
- ✅ 数据库操作无影响
---
**迁移完成时间**: 2025-12-27
**操作者**: Claude Code

View File

@@ -0,0 +1,310 @@
# AI Service Token 记录集成总结
## 概述
已成功将 Token 使用记录功能集成到 `ai_service.js` 中的所有 AI 调用方法。所有方法现在都会自动记录:
- Token 使用量(输入、输出、总计)
- 调用费用
- 响应时间
- 请求和响应内容
- 调用状态(成功/失败)
## 集成方法列表
### 1. **chat()** - 核心聊天方法
- **行号**: 33-95
- **集成方式**: 内置 Token 记录逻辑
- **记录时机**:
- 成功调用:记录完整 Token 数据和响应内容
- 失败调用:记录错误信息和失败状态
- **特性**:
- 异步记录,不阻塞主流程
- 自动计算费用(基于 DeepSeek 定价)
- 捕获异常防止记录失败影响业务
### 2. **analyzeResume()** - 简历分析
- **行号**: 129-199
- **参数更新**: 添加 `context = {}` 参数
- **业务类型**: `resume_analysis`
- **服务类型**: `completion`
- **reference_id**: `resumeData.id``resumeData.resumeId`
- **使用示例**:
```javascript
const result = await aiService.analyzeResume(resumeData, {
user_id: 123,
sn_code: 'DEVICE001'
});
```
### 3. **matchJobWithResume()** - 岗位匹配度评估
- **行号**: 208-283
- **参数更新**: 添加 `context = {}` 参数
- **业务类型**: `job_matching`
- **服务类型**: `completion`
- **reference_id**: `jobData.id``jobData.jobId`
- **使用示例**:
```javascript
const matchResult = await aiService.matchJobWithResume(jobData, resumeData, {
user_id: 123,
sn_code: 'DEVICE001'
});
```
### 4. **batchMatchJobs()** - 批量岗位匹配
- **行号**: 293-321
- **参数更新**: 添加 `context = {}` 参数
- **集成方式**: 将 context 传递给 `matchJobWithResume()`
- **特性**:
- 并发控制(每批 3 个)
- 自动重试和错误处理
- 每批之间间隔 1 秒防止 API 限流
- **使用示例**:
```javascript
const results = await aiService.batchMatchJobs(jobs, resumeData, {
user_id: 123,
sn_code: 'DEVICE001'
});
```
### 5. **generateChatContent()** - 生成聊天内容
- **行号**: 328-397
- **参数更新**: context 中提取 `user_id``sn_code`
- **业务类型**: `chat_generation`
- **服务类型**: `chat`
- **reference_id**: `jobInfo.jobId``jobInfo.id`
- **使用示例**:
```javascript
const chatContent = await aiService.generateChatContent({
jobInfo: { jobId: 456, jobTitle: 'Node.js开发' },
resumeInfo: resumeData,
chatType: 'greeting',
user_id: 123,
sn_code: 'DEVICE001'
});
```
### 6. **detectInterviewInvitation()** - 面试邀约检测
- **行号**: 405-454
- **参数更新**: 添加 `context = {}` 参数
- **业务类型**: `interview_detection`
- **服务类型**: `completion`
- **reference_id**: `context.conversation_id``context.job_id`
- **使用示例**:
```javascript
const result = await aiService.detectInterviewInvitation(hrMessage, {
user_id: 123,
sn_code: 'DEVICE001',
conversation_id: 789
});
```
### 7. **analyzeSentiment()** - 情感分析
- **行号**: 462-503
- **参数更新**: 添加 `context = {}` 参数
- **业务类型**: `sentiment_analysis`
- **服务类型**: `completion`
- **reference_id**: `context.conversation_id``context.job_id`
- **使用示例**:
```javascript
const sentiment = await aiService.analyzeSentiment(hrMessage, {
user_id: 123,
sn_code: 'DEVICE001',
job_id: 456
});
```
## 辅助方法
### **recordAiCall()** - 记录 AI 调用
- **行号**: 102-109
- **功能**: 调用 `AiCallRecorder.record()` 记录数据
- **异常处理**: 记录失败不影响主流程,仅输出警告日志
### **calculateCost()** - 计算费用
- **行号**: 116-121
- **定价**: ¥0.001 / 1000 tokensDeepSeek 示例价格)
- **返回**: 费用金额(元)
- **可调整**: 可根据实际 API 定价修改 `pricePerThousand`
## 业务类型分类
| 业务类型 | 说明 | 对应方法 |
|---------|------|---------|
| `resume_analysis` | 简历竞争力分析 | analyzeResume() |
| `job_matching` | 岗位匹配度评估 | matchJobWithResume() |
| `chat_generation` | 聊天内容生成 | generateChatContent() |
| `interview_detection` | 面试邀约检测 | detectInterviewInvitation() |
| `sentiment_analysis` | 情感分析 | analyzeSentiment() |
| `chat` | 通用聊天 | chat()(直接调用) |
## 服务类型分类
| 服务类型 | 说明 |
|---------|------|
| `chat` | 对话式交互 |
| `completion` | 文本生成/分析 |
| `embedding` | 向量化(未使用) |
## Context 参数说明
所有方法支持的 context 参数:
```javascript
{
user_id: Number, // 用户ID必填
sn_code: String, // 设备序列号(可选)
conversation_id: Number, // 会话ID用于聊天相关
job_id: Number, // 岗位ID用于岗位相关
// ... 其他业务字段
}
```
## 向后兼容性
所有 context 参数都是**可选的**(默认值 `{}`),因此:
- ✅ 现有代码无需修改即可继续运行
- ✅ 仅在需要 Token 追踪时传递 context
- ✅ 未传递 context 时,相关字段为 `null`(仍会记录基础信息)
## 数据库记录字段
每次 AI 调用会记录以下信息到 `ai_call_records` 表:
```javascript
{
user_id: Number, // 用户ID
sn_code: String, // 设备序列号
service_type: String, // 服务类型chat/completion/embedding
model_name: String, // 模型名称(如 deepseek-chat
prompt_tokens: Number, // 输入Token数
completion_tokens: Number, // 输出Token数
total_tokens: Number, // 总Token数
request_content: String, // 请求内容JSON字符串
response_content: String, // 响应内容
cost_amount: Decimal, // 费用(元)
status: String, // 状态success/failed/timeout
response_time: Number, // 响应时间(毫秒)
error_message: String, // 错误信息(失败时)
api_provider: String, // API提供商deepseek
business_type: String, // 业务类型
reference_id: Number, // 业务关联ID
create_time: DateTime // 创建时间
}
```
## 使用建议
### 1. **始终传递 user_id**
```javascript
// ✅ 推荐
await aiService.analyzeResume(resumeData, {
user_id: ctx.session.userId,
sn_code: ctx.headers['device-sn']
});
// ❌ 不推荐(无法追踪用户)
await aiService.analyzeResume(resumeData);
```
### 2. **为批量操作传递统一 context**
```javascript
const context = {
user_id: 123,
sn_code: 'DEVICE001'
};
// 所有批量调用都会记录相同的 user_id 和 sn_code
await aiService.batchMatchJobs(jobs, resumeData, context);
```
### 3. **传递业务关联 ID**
```javascript
await aiService.matchJobWithResume(jobData, resumeData, {
user_id: 123,
sn_code: 'DEVICE001'
});
// reference_id 会自动设置为 jobData.id 或 jobData.jobId
await aiService.detectInterviewInvitation(message, {
user_id: 123,
conversation_id: 789 // 手动指定会话ID
});
```
### 4. **监控失败调用**
```javascript
try {
const result = await aiService.analyzeResume(resumeData, context);
} catch (error) {
// 即使调用失败也会记录到数据库status='failed'
console.error('AI调用失败已记录到数据库:', error.message);
}
```
## 性能优化
1. **异步记录**:所有 Token 记录都是异步执行,不会阻塞 AI 调用的返回
2. **错误隔离**:记录失败仅打印警告日志,不会抛出异常
3. **批量优化**`batchMatchJobs()` 使用并发控制,避免 API 限流
## 费用计算
当前定价(可调整):
- **DeepSeek**: ¥0.001 / 1000 tokens
修改定价:编辑 [ai_service.js:119](../api/services/ai_service.js#L119)
```javascript
calculateCost(totalTokens) {
const pricePerThousand = 0.001; // 修改此值
return (totalTokens / 1000) * pricePerThousand;
}
```
## 统计查询示例
在后台管理界面可以查询:
- 按用户统计 Token 使用量
- 按设备统计
- 按业务类型统计
- 按日期范围统计
- 按模型统计费用
访问:后台管理 → 系统设置 → AI调用记录
## 测试验证
执行以下命令测试集成:
```bash
# 测试简历分析
node -e "
const aiService = require('./api/services/ai_service.js').getInstance();
aiService.analyzeResume({
fullName: '张三',
workYears: 3,
education: '本科',
skills: ['Node.js', 'Vue.js']
}, {
user_id: 999,
sn_code: 'TEST001'
}).then(console.log);
"
# 查看数据库记录
mysql -u root -p -e "SELECT id, user_id, business_type, total_tokens, cost_amount, status FROM ai_call_records ORDER BY id DESC LIMIT 5;"
```
## 更新日志
**2025-12-27**
- ✅ 集成 AiCallRecorder 到 ai_service.js
- ✅ 更新所有 AI 方法支持 context 参数
- ✅ 实现自动 Token 记录和费用计算
- ✅ 保持向后兼容性context 为可选参数)
- ✅ 添加异步记录和错误隔离机制
---
**集成完成!** 🎉
所有 AI 调用现在都会自动记录 Token 使用情况,可通过后台管理界面查看详细统计数据。

View File

@@ -0,0 +1,138 @@
/**
* 添加"AI调用记录"菜单项到系统设置菜单下
* 执行 SQL 插入操作
*/
const Framework = require('../framework/node-core-framework.js');
const frameworkConfig = require('../config/framework.config.js');
async function addAiCallRecordsMenu() {
console.log('🔄 开始添加"AI调用记录"菜单项...\n');
try {
// 初始化框架
console.log('正在初始化框架...');
const framework = await Framework.init(frameworkConfig);
const models = Framework.getModels();
if (!models) {
throw new Error('无法获取模型列表');
}
// 从任意模型获取 sequelize 实例
const Sequelize = require('sequelize');
const firstModel = Object.values(models)[0];
if (!firstModel || !firstModel.sequelize) {
throw new Error('无法获取数据库连接');
}
const sequelize = firstModel.sequelize;
// 查找系统设置菜单的ID
const [systemMenu] = await sequelize.query(
`SELECT id FROM sys_menu WHERE (name LIKE '%系统%' OR name LIKE '%设置%') AND parent_id = 0 AND is_delete = 0 LIMIT 1`,
{ type: Sequelize.QueryTypes.SELECT }
);
let parentId = 0; // 默认顶级菜单
if (systemMenu && systemMenu.id) {
parentId = systemMenu.id;
console.log(`找到系统设置菜单ID: ${parentId}`);
} else {
console.log('未找到系统设置菜单,将作为顶级菜单添加');
}
// 检查是否已存在
const [existing] = await sequelize.query(
`SELECT id, name FROM sys_menu WHERE path = 'ai_call_records' AND is_delete = 0`,
{ type: Sequelize.QueryTypes.SELECT }
);
if (existing) {
console.log(`⚠️ 菜单项已存在 (ID: ${existing.id}, 名称: ${existing.name})`);
console.log('✅ 无需重复添加\n');
return;
}
// 获取最大排序值
const [maxSort] = await sequelize.query(
`SELECT MAX(sort) as maxSort FROM sys_menu WHERE parent_id = ${parentId} AND is_delete = 0`,
{ type: Sequelize.QueryTypes.SELECT }
);
const nextSort = (maxSort && maxSort.maxSort ? maxSort.maxSort : 0) + 1;
// 执行插入
await sequelize.query(
`INSERT INTO sys_menu (
name,
parent_id,
model_id,
form_id,
icon,
path,
component,
api_path,
is_show_menu,
is_show,
type,
sort,
create_time,
last_modify_time,
is_delete
) VALUES (
'AI调用记录',
${parentId},
0,
0,
'md-analytics',
'ai_call_records',
'system/ai_call_records.vue',
'system/ai_call_records_server.js',
1,
1,
'页面',
${nextSort},
NOW(),
NOW(),
0
)`,
{ type: Sequelize.QueryTypes.INSERT }
);
console.log('✅ "AI调用记录"菜单项添加成功!\n');
// 验证插入结果
const [menu] = await sequelize.query(
`SELECT id, name, parent_id, path, component, api_path, sort
FROM sys_menu
WHERE path = 'ai_call_records' AND is_delete = 0`,
{ type: Sequelize.QueryTypes.SELECT }
);
if (menu) {
console.log('📋 菜单项详情:');
console.log(` ID: ${menu.id}`);
console.log(` 名称: ${menu.name}`);
console.log(` 父菜单ID: ${menu.parent_id}`);
console.log(` 路由路径: ${menu.path}`);
console.log(` 组件路径: ${menu.component}`);
console.log(` API路径: ${menu.api_path}`);
console.log(` 排序: ${menu.sort}\n`);
}
} catch (error) {
console.error('❌ 添加失败:', error.message);
console.error('\n详细错误:', error);
throw error;
}
}
// 执行添加
addAiCallRecordsMenu()
.then(() => {
console.log('✨ 操作完成!');
process.exit(0);
})
.catch(error => {
console.error('\n💥 执行失败:', error);
process.exit(1);
});