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
25 changed files with 2005 additions and 686 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": []

3
.gitignore vendored
View File

@@ -3,5 +3,4 @@ logs/
node_modules.* node_modules.*
dist.zip dist.zip
dist/ dist/
admin/node_modules/ admin/node_modules/
app/

View File

@@ -1,529 +0,0 @@
# Boss直聘搜索列表和投递功能开发规划
## 📋 功能概述
基于Boss直聘Web端职位搜索页面`https://www.zhipin.com/web/geek/jobs`),完善搜索列表获取和职位投递功能,包括服务端任务创建、指令生成和完整流程实现。
## 🎯 目标功能
### 1. 搜索列表功能
- 支持多条件搜索(关键词、城市、薪资、经验、学历等)
- 支持分页获取职位列表
- 自动保存职位到数据库
- 支持职位去重和更新
### 2. 投递功能
- 单个职位投递
- 批量职位投递
- 投递状态跟踪
- 投递记录管理
## 📊 功能架构
```
用户/系统触发
创建任务 (task_status)
生成指令序列 (task_commands)
执行指令 (通过MQTT发送到设备)
设备执行并返回结果
保存数据到数据库
更新任务和指令状态
```
## 🔧 技术实现
### 一、搜索列表功能完善
#### 1.1 指令参数扩展
**文件**: `api/middleware/job/jobManager.js`
**方法**: `get_job_list()`
**需要支持的参数**:
```javascript
{
keyword: '全栈工程师', // 搜索关键词
city: '101020100', // 城市代码(上海)
cityName: '上海', // 城市名称
salary: '20-30K', // 薪资范围
experience: '3-5年', // 工作经验
education: '本科', // 学历要求
industry: '互联网', // 公司行业
companySize: '100-499人', // 公司规模
financingStage: 'B轮', // 融资阶段
page: 1, // 页码
pageSize: 20, // 每页数量
pageCount: 3 // 获取页数(用于批量获取)
}
```
#### 1.2 任务创建接口
**文件**: `api/services/pla_account_service.js`
**新增方法**: `createSearchJobListTask()`
```javascript
/**
* 创建搜索职位列表任务
* @param {Object} params - 任务参数
* @param {number} params.id - 账号ID
* @param {string} params.keyword - 搜索关键词
* @param {string} params.city - 城市代码
* @param {Object} params.searchParams - 搜索条件(薪资、经验、学历等)
* @param {number} params.pageCount - 获取页数
* @returns {Promise<Object>} 任务创建结果
*/
async createSearchJobListTask(params) {
// 1. 验证账号和授权
// 2. 创建任务记录
// 3. 生成搜索指令
// 4. 执行指令
// 5. 返回任务ID
}
```
#### 1.3 指令生成逻辑
**文件**: `api/middleware/schedule/taskHandlers.js`
**需要完善**: `handleAutoDeliverTask()` 中的搜索指令生成
**当前实现**:
```javascript
const getJobListCommand = {
command_type: 'getJobList',
command_name: '获取职位列表',
command_params: JSON.stringify({
sn_code: sn_code,
keyword: keyword || accountConfig.keyword || '',
platform: platform || 'boss',
pageCount: pageCount || 3
}),
priority: config.getTaskPriority('search_jobs') || 5
};
```
**需要扩展为**:
```javascript
const getJobListCommand = {
command_type: 'get_job_list', // 统一使用下划线命名
command_name: '获取职位列表',
command_params: JSON.stringify({
sn_code: sn_code,
platform: platform || 'boss',
keyword: keyword || accountConfig.keyword || '',
city: city || accountConfig.city || '101020100', // 默认上海
cityName: cityName || accountConfig.cityName || '上海',
salary: searchParams?.salary || '',
experience: searchParams?.experience || '',
education: searchParams?.education || '',
industry: searchParams?.industry || '',
companySize: searchParams?.companySize || '',
financingStage: searchParams?.financingStage || '',
page: 1,
pageSize: 20,
pageCount: pageCount || 3
}),
priority: config.getTaskPriority('get_job_list') || 5,
sequence: 1
};
```
#### 1.4 职位数据保存优化
**文件**: `api/middleware/job/jobManager.js`
**方法**: `saveJobsToDatabase()`
**需要完善**:
- 支持更多字段映射从Boss直聘响应数据
- 优化位置解析逻辑
- 支持职位状态更新(已投递、已查看等)
- 添加职位匹配度计算
### 二、投递功能完善
#### 2.1 单个职位投递
**文件**: `api/middleware/job/jobManager.js`
**方法**: `applyJob()`
**当前状态**: ✅ 已实现基础功能
**需要完善**:
- 支持更多投递参数(期望薪资、求职信等)
- 优化错误处理
- 添加投递前检查(是否已投递、是否满足条件等)
#### 2.2 批量职位投递任务
**文件**: `api/middleware/schedule/taskHandlers.js`
**方法**: `handleAutoDeliverTask()`
**当前状态**: ✅ 已实现基础功能
**需要完善**:
1. **搜索条件完善**
- 支持城市、薪资、经验、学历等筛选条件
- 从账号配置中读取默认搜索条件
- 支持任务参数覆盖账号配置
2. **职位匹配算法优化**
- 完善距离计算(基于经纬度)
- 完善薪资匹配(解析薪资范围字符串)
- 完善工作年限匹配
- 完善学历匹配
- 完善权重评分系统
3. **投递指令生成**
- 为每个匹配的职位生成投递指令
- 支持批量投递(一次任务投递多个职位)
- 添加投递间隔控制(避免频繁投递)
#### 2.3 投递任务创建接口
**文件**: `api/services/pla_account_service.js`
**新增方法**: `createDeliverTask()`
```javascript
/**
* 创建投递任务
* @param {Object} params - 任务参数
* @param {number} params.id - 账号ID
* @param {string} params.keyword - 搜索关键词
* @param {Object} params.searchParams - 搜索条件
* @param {Object} params.filterRules - 过滤规则
* @param {number} params.maxCount - 最大投递数量
* @returns {Promise<Object>} 任务创建结果
*/
async createDeliverTask(params) {
// 1. 验证账号和授权
// 2. 创建任务记录
// 3. 生成搜索指令(获取职位列表)
// 4. 生成投递指令序列(根据匹配结果)
// 5. 执行任务
// 6. 返回任务ID
}
```
## 📝 具体开发任务
### 任务1: 完善搜索参数支持
**文件**: `api/middleware/job/jobManager.js`
**修改方法**: `get_job_list()`
**任务内容**:
1. 扩展参数支持(城市、薪资、经验、学历等)
2. 构建完整的搜索参数对象
3. 传递给MQTT指令
**代码位置**: 第153-206行
**预计工作量**: 2小时
---
### 任务2: 优化职位数据保存
**文件**: `api/middleware/job/jobManager.js`
**修改方法**: `saveJobsToDatabase()`
**任务内容**:
1. 完善字段映射从Boss直聘响应数据提取更多字段
2. 优化位置解析减少API调用添加缓存
3. 添加职位状态管理
4. 添加职位匹配度字段
**代码位置**: 第215-308行
**预计工作量**: 3小时
---
### 任务3: 完善自动投递任务搜索条件
**文件**: `api/middleware/schedule/taskHandlers.js`
**修改方法**: `handleAutoDeliverTask()`
**任务内容**:
1. 从账号配置读取默认搜索条件
2. 支持任务参数覆盖
3. 构建完整的搜索参数
4. 传递给搜索指令
**代码位置**: 第220-233行
**预计工作量**: 2小时
---
### 任务4: 优化职位匹配算法
**文件**: `api/middleware/schedule/taskHandlers.js`
**修改方法**: `handleAutoDeliverTask()`
**任务内容**:
1. 完善距离计算(使用经纬度计算实际距离)
2. 完善薪资匹配(解析"20-30K"格式,转换为数值范围)
3. 完善工作年限匹配(解析"3-5年"格式)
4. 完善学历匹配(学历等级映射)
5. 优化权重评分计算
**代码位置**: 第255-400行职位评分和过滤逻辑
**预计工作量**: 4小时
---
### 任务5: 创建搜索任务接口
**文件**: `api/services/pla_account_service.js`
**新增方法**: `createSearchJobListTask()`
**任务内容**:
1. 验证账号和授权
2. 创建任务记录
3. 生成搜索指令
4. 执行指令
5. 返回任务信息
**代码位置**: 在 `runCommand()` 方法后添加
**预计工作量**: 3小时
---
### 任务6: 创建投递任务接口
**文件**: `api/services/pla_account_service.js`
**新增方法**: `createDeliverTask()`
**任务内容**:
1. 验证账号和授权
2. 创建任务记录
3. 生成搜索指令(获取职位列表)
4. 等待搜索完成
5. 获取匹配的职位
6. 生成投递指令序列
7. 执行投递指令
8. 返回任务信息
**代码位置**: 在 `createSearchJobListTask()` 方法后添加
**预计工作量**: 4小时
---
### 任务7: 完善指令类型映射
**文件**: `api/middleware/schedule/command.js`
**修改位置**: 指令执行逻辑
**任务内容**:
1. 确保 `get_job_list` 指令类型正确映射到 `jobManager.get_job_list()`
2. 确保 `search_jobs` 指令类型正确映射到 `jobManager.search_jobs()`
3. 确保 `apply_job` 指令类型正确映射到 `jobManager.applyJob()`
**代码位置**: 第150-250行指令执行逻辑
**预计工作量**: 1小时
---
### 任务8: 添加搜索条件配置管理
**文件**: `api/model/pla_account.js`
**任务内容**:
1. 添加搜索条件配置字段(如果不存在)
2. 支持在账号配置中保存默认搜索条件
3. 支持在任务参数中覆盖搜索条件
**相关字段**:
- `search_config` (JSON): 搜索条件配置
```json
{
"city": "101020100",
"cityName": "上海",
"defaultSalary": "20-30K",
"defaultExperience": "3-5年",
"defaultEducation": "本科"
}
```
**预计工作量**: 1小时
---
## 🔄 工作流程
### 搜索职位列表流程
```
1. 用户/系统调用 createSearchJobListTask()
2. 创建任务记录 (task_status)
3. 生成搜索指令 (task_commands)
- command_type: 'get_job_list'
- command_params: { keyword, city, salary, ... }
4. 执行指令 (通过MQTT发送到设备)
5. 设备执行搜索并返回职位列表
6. 保存职位到数据库 (job_postings)
- 去重处理
- 位置解析
- 字段映射
7. 更新指令状态为完成
8. 更新任务状态为完成
```
### 投递职位流程
```
1. 用户/系统调用 createDeliverTask()
2. 创建任务记录 (task_status)
3. 生成搜索指令 (获取职位列表)
- command_type: 'get_job_list'
4. 执行搜索指令
5. 获取职位列表并保存到数据库
6. 根据简历信息和过滤规则匹配职位
- 距离匹配
- 薪资匹配
- 工作年限匹配
- 学历匹配
- 权重评分
7. 为每个匹配的职位生成投递指令
- command_type: 'apply_job'
- command_params: { jobId, encryptBossId, ... }
8. 批量执行投递指令(带间隔控制)
9. 保存投递记录 (apply_records)
10. 更新职位状态 (job_postings.applyStatus)
11. 更新任务状态为完成
```
## 📊 数据库字段说明
### job_postings 表需要完善的字段
| 字段名 | 类型 | 说明 | 状态 |
|--------|------|------|------|
| `city` | VARCHAR | 城市代码 | 待添加 |
| `cityName` | VARCHAR | 城市名称 | 待添加 |
| `salaryMin` | INT | 最低薪资(元) | 待添加 |
| `salaryMax` | INT | 最高薪资(元) | 待添加 |
| `experienceMin` | INT | 最低工作年限 | 待添加 |
| `experienceMax` | INT | 最高工作年限 | 待添加 |
| `educationLevel` | VARCHAR | 学历等级 | 待添加 |
| `matchScore` | DECIMAL | 匹配度评分 | 待添加 |
### pla_account 表需要添加的字段
| 字段名 | 类型 | 说明 | 状态 |
|--------|------|------|------|
| `search_config` | JSON | 搜索条件配置 | 待添加 |
| `city` | VARCHAR | 默认城市代码 | 待添加 |
| `cityName` | VARCHAR | 默认城市名称 | 待添加 |
## 🧪 测试计划
### 单元测试
1. 测试搜索参数构建
2. 测试职位数据保存
3. 测试职位匹配算法
4. 测试投递指令生成
### 集成测试
1. 测试完整搜索流程
2. 测试完整投递流程
3. 测试任务创建和执行
4. 测试MQTT通信
### 功能测试
1. 测试多条件搜索
2. 测试分页获取
3. 测试批量投递
4. 测试错误处理
## 📅 开发时间估算
| 任务 | 预计时间 | 优先级 |
|------|----------|--------|
| 任务1: 完善搜索参数支持 | 2小时 | 高 |
| 任务2: 优化职位数据保存 | 3小时 | 高 |
| 任务3: 完善自动投递任务搜索条件 | 2小时 | 高 |
| 任务4: 优化职位匹配算法 | 4小时 | 高 |
| 任务5: 创建搜索任务接口 | 3小时 | 中 |
| 任务6: 创建投递任务接口 | 4小时 | 中 |
| 任务7: 完善指令类型映射 | 1小时 | 中 |
| 任务8: 添加搜索条件配置管理 | 1小时 | 低 |
**总计**: 约20小时
## 🚀 开发优先级
### 第一阶段(核心功能)
1. 任务1: 完善搜索参数支持
2. 任务2: 优化职位数据保存
3. 任务3: 完善自动投递任务搜索条件
4. 任务4: 优化职位匹配算法
### 第二阶段(接口完善)
5. 任务5: 创建搜索任务接口
6. 任务6: 创建投递任务接口
7. 任务7: 完善指令类型映射
### 第三阶段(配置管理)
8. 任务8: 添加搜索条件配置管理
## 📌 注意事项
1. **命名规范**: 统一使用下划线命名(`get_job_list` 而不是 `getJobList`
2. **错误处理**: 所有方法都需要完善的错误处理和日志记录
3. **数据验证**: 所有输入参数都需要验证
4. **性能优化**: 批量操作需要考虑性能,避免阻塞
5. **MQTT通信**: 确保指令参数格式正确,与客户端协议一致
6. **数据库事务**: 批量操作需要使用事务保证数据一致性
## 🔗 相关文件
- `api/middleware/job/jobManager.js` - 工作管理核心逻辑
- `api/middleware/schedule/taskHandlers.js` - 任务处理器
- `api/middleware/schedule/command.js` - 指令管理器
- `api/services/pla_account_service.js` - 账号服务
- `api/model/job_postings.js` - 职位数据模型
- `api/model/pla_account.js` - 账号数据模型

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

@@ -26,11 +26,11 @@ const baseConfig = {
// 开发环境配置 // 开发环境配置
const developmentConfig = { const developmentConfig = {
...baseConfig, ...baseConfig,
// apiUrl: 'http://localhost:9097/admin_api/', apiUrl: 'http://localhost:9097/admin_api/',
// uploadUrl: 'http://localhost:9097/admin_api/upload', uploadUrl: 'http://localhost:9097/admin_api/upload',
apiUrl: 'https://work.light120.com/admin_api/', // apiUrl: 'https://work.light120.com/admin_api/',
uploadUrl: 'https://work.light120.com/admin_api/upload', // uploadUrl: 'https://work.light120.com/admin_api/upload',
// 开发环境显示更多调试信息 // 开发环境显示更多调试信息
debug: true debug: true
} }

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

@@ -349,12 +349,12 @@ export default {
{ title: '密码', key: 'pwd', com: 'Password', required: true }, { title: '密码', key: 'pwd', com: 'Password', required: true },
], ],
listColumns: [ listColumns: [
{ title: '账户名', key: 'name', minWidth: 120 }, { title: 'ID', key: 'id' },
{ title: '设备SN码', key: 'sn_code', minWidth: 150 }, { title: '账户名', key: 'name' },
{ title: '设备SN码', key: 'sn_code'},
{ {
title: '平台', title: '平台',
key: 'platform_type', key: 'platform_type',
minWidth: 100,
render: (h, params) => { render: (h, params) => {
const platformMap = { const platformMap = {
'boss': { text: 'Boss直聘', color: 'blue' }, 'boss': { text: 'Boss直聘', color: 'blue' },
@@ -364,10 +364,26 @@ export default {
return h('Tag', { props: { color: platform.color } }, platform.text) return h('Tag', { props: { color: platform.color } }, platform.text)
} }
}, },
{ title: '登录名', key: 'login_name'},
{ title: '搜索关键词', key: 'keyword' },
{ title: '用户地址', key: 'user_address' },
{
title: '经纬度',
key: 'location',
render: (h, params) => {
const lon = params.row.user_longitude;
const lat = params.row.user_latitude;
if (lon && lat) {
return h('span', `${lat}, ${lon}`)
}
return h('span', { style: { color: '#999' } }, '未设置')
}
},
{ {
title: '在线状态', title: '在线状态',
key: 'is_online', key: 'is_online',
minWidth: 90,
render: (h, params) => { render: (h, params) => {
return h('Tag', { return h('Tag', {
props: { color: params.row.is_online ? 'success' : 'default' } props: { color: params.row.is_online ? 'success' : 'default' }
@@ -377,7 +393,12 @@ export default {
{ {
title: '自动投递', title: '自动投递',
key: 'auto_deliver', key: 'auto_deliver',
minWidth: 90, com: "Radio",
options: [
{ value: 1, label: '开启' },
{ value: 0, label: '关闭' }
],
render: (h, params) => { render: (h, params) => {
return h('Tag', { return h('Tag', {
props: { color: params.row.auto_deliver ? 'success' : 'default' } props: { color: params.row.auto_deliver ? 'success' : 'default' }
@@ -387,7 +408,11 @@ export default {
{ {
title: '自动沟通', title: '自动沟通',
key: 'auto_chat', key: 'auto_chat',
minWidth: 90, "com": "Radio",
options: [
{ value: 1, label: '开启' },
{ value: 0, label: '关闭' }
],
render: (h, params) => { render: (h, params) => {
return h('Tag', { return h('Tag', {
props: { color: params.row.auto_chat ? 'success' : 'default' } props: { color: params.row.auto_chat ? 'success' : 'default' }
@@ -397,7 +422,7 @@ export default {
{ {
title: '剩余天数', title: '剩余天数',
key: 'remaining_days', key: 'remaining_days',
minWidth: 100,
render: (h, params) => { render: (h, params) => {
const remainingDays = params.row.remaining_days || 0 const remainingDays = params.row.remaining_days || 0
let color = 'success' let color = 'success'
@@ -411,10 +436,52 @@ export default {
}, remainingDays > 0 ? `${remainingDays}` : '已过期') }, remainingDays > 0 ? `${remainingDays}` : '已过期')
} }
}, },
{
title: '授权日期',
key: 'authorization_date',
render: (h, params) => {
if (!params.row.authorization_date) {
return h('span', { style: { color: '#999' } }, '未授权')
}
const date = new Date(params.row.authorization_date)
return h('span', this.formatDate(date))
}
},
{
title: '过期时间',
key: 'expire_date',
render: (h, params) => {
if (!params.row.authorization_date || !params.row.authorization_days) {
return h('span', { style: { color: '#999' } }, '未授权')
}
const authDate = new Date(params.row.authorization_date)
const expireDate = new Date(authDate.getTime() + params.row.authorization_days * 24 * 60 * 60 * 1000)
const remainingDays = params.row.remaining_days || 0
return h('span', {
style: { color: remainingDays <= 0 ? '#ed4014' : remainingDays <= 7 ? '#ff9900' : '#515a6e' }
}, this.formatDate(expireDate))
}
},
{
title: '自动活跃',
key: 'auto_active',
"com": "Radio",
options: [
{ value: 1, label: '开启' },
{ value: 0, label: '关闭' }
],
render: (h, params) => {
return h('Tag', {
props: { color: params.row.auto_active ? 'success' : 'default' }
}, params.row.auto_active ? '开启' : '关闭')
}
},
{ {
title: '启用状态', title: '启用状态',
key: 'is_enabled', key: 'is_enabled',
minWidth: 100,
render: (h, params) => { render: (h, params) => {
return h('i-switch', { return h('i-switch', {
props: { props: {
@@ -429,6 +496,7 @@ export default {
}) })
} }
}, },
{ title: '创建时间', key: 'create_time', },
{ {
title: '操作', title: '操作',
key: 'action', key: 'action',
@@ -572,21 +640,18 @@ export default {
} }
this.query(1) this.query(1)
}, },
async handleSaveSuccess({ data, isEdit } = {}) { async handleSaveSuccess({ data }) {
try { try {
// 如果是新增(来自 editModaldata 只包含必填字段,直接保存 // 如果是新增(来自 editModaldata 只包含必填字段,直接保存
if (data && !data.id && !isEdit) { if (data && !data.id) {
await plaAccountServer.add(data) await plaAccountServer.add(data)
this.$Message.success('保存成功!') this.$Message.success('保存成功!')
} }
// 编辑时由 FloatPanel 组件PlaAccountEdit处理保存这里只刷新列表 // 编辑时由 FloatPanel 组件PlaAccountEdit处理保存这里只刷新列表
// 刷新列表,保持当前页码 this.query(this.gridOption.param.pageOption.page)
this.query(this.gridOption.param.pageOption.page || 1)
} catch (error) { } catch (error) {
console.error('保存失败:', error) console.error('保存失败:', error)
// 优先从 error.response.data.message 获取,然后是 error.message this.$Message.error('保存失败:' + (error.message || '请稍后重试'))
const errorMsg = error.response?.data?.message || error.message || '请稍后重试'
this.$Message.error('保存失败:' + errorMsg)
} }
}, },
// 显示账号详情 // 显示账号详情

View File

@@ -23,10 +23,8 @@
</FormItem> </FormItem>
<FormItem label="平台" prop="platform_type"> <FormItem label="平台" prop="platform_type">
<Select v-model="formData.platform_type" placeholder="请选择平台"> <Select v-model="formData.platform_type" placeholder="请选择平台">
<Option value="boss">Boss直聘</Option> <Option value="1">Boss直聘</Option>
<Option value="liepin">猎聘</Option> <Option value="2">猎聘</Option>
<Option value="zhipin">智联招聘</Option>
<Option value="58">58同城</Option>
</Select> </Select>
</FormItem> </FormItem>
<FormItem label="登录名" prop="login_name"> <FormItem label="登录名" prop="login_name">
@@ -547,8 +545,7 @@ export default {
this.$Message.success('保存成功!') this.$Message.success('保存成功!')
this.$refs.floatPanel.hide() this.$refs.floatPanel.hide()
// 触发保存成功事件,通知父组件刷新列表 this.$emit('on-save')
this.$emit('on-save', { isEdit: this.isEdit, data: saveData })
} catch (error) { } catch (error) {
console.error('保存失败:', error) console.error('保存失败:', error)
this.$Message.error('保存失败:' + (error.message || '请稍后重试')) this.$Message.error('保存失败:' + (error.message || '请稍后重试'))

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

@@ -28,7 +28,7 @@
<tables :columns="listColumns" :value="gridOption.data" :pageOption="gridOption.param.pageOption" <tables :columns="listColumns" :value="gridOption.data" :pageOption="gridOption.param.pageOption"
@changePage="query"></tables> @changePage="query"></tables>
</div> </div>
<editModal ref="editModal" :columns="editColumns" :rules="gridOption.rules" > <editModal ref="editModal" :columns="editColumns" :rules="gridOption.rules" @on-save="handleSaveSuccess">
</editModal> </editModal>
</div> </div>
</template> </template>
@@ -41,7 +41,7 @@ export default {
let rules = {} let rules = {}
rules["name"] = [{ required: true, message: '请填写套餐名称', trigger: 'blur' }] rules["name"] = [{ required: true, message: '请填写套餐名称', trigger: 'blur' }]
rules["duration"] = [{ required: true, message: '请填写时长描述', trigger: 'blur' }] rules["duration"] = [{ required: true, message: '请填写时长描述', trigger: 'blur' }]
rules["days"] = [{ required: true, type: 'number', message: '请填写天数', trigger: 'blur' }] rules["days"] = [{ required: true, message: '请填写天数', trigger: 'blur' }]
rules["price"] = [{ required: true, message: '请填写价格', trigger: 'blur' }] rules["price"] = [{ required: true, message: '请填写价格', trigger: 'blur' }]
return { return {
@@ -204,7 +204,6 @@ export default {
title: '是否推荐', title: '是否推荐',
key: 'featured', key: 'featured',
com: 'Radio', com: 'Radio',
required: true,
options: [ options: [
{ value: 1, label: '推荐' }, { value: 1, label: '推荐' },
{ value: 0, label: '普通' } { value: 0, label: '普通' }
@@ -214,14 +213,13 @@ export default {
title: '是否启用', title: '是否启用',
key: 'is_active', key: 'is_active',
com: 'Radio', com: 'Radio',
required: true,
options: [ options: [
{ value: 1, label: '启用' }, { value: 1, label: '启用' },
{ value: 0, label: '禁用' } { value: 0, label: '禁用' }
] ]
}, },
{ {
title: '排序', title: '排序顺序',
key: 'sort_order', key: 'sort_order',
type: 'number', type: 'number',
required: false, required: false,
@@ -276,8 +274,7 @@ export default {
this.query(1) this.query(1)
}, },
showAddWarp() { showAddWarp() {
this.$refs.editModal.show({
let editRow={
name: '', name: '',
duration: '', duration: '',
days: 0, days: 0,
@@ -289,8 +286,7 @@ export default {
featured: 0, featured: 0,
is_active: 1, is_active: 1,
sort_order: 0 sort_order: 0
} })
this.$refs.editModal.show(editRow)
}, },
edit(row) { edit(row) {
// 解析 JSON 字段 // 解析 JSON 字段
@@ -308,7 +304,7 @@ export default {
features = JSON.stringify(features, null, 2) features = JSON.stringify(features, null, 2)
} }
let editRow={ this.$refs.editModal.editShow({
id: row.id, id: row.id,
name: row.name, name: row.name,
duration: row.duration || '', duration: row.duration || '',
@@ -321,11 +317,6 @@ export default {
featured: row.featured, featured: row.featured,
is_active: row.is_active, is_active: row.is_active,
sort_order: row.sort_order || 0 sort_order: row.sort_order || 0
}
this.$refs.editModal.editShow(editRow,(newRow)=>{
debugger
this.handleSaveSuccess(newRow)
}) })
}, },
del(row) { del(row) {

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

@@ -115,18 +115,12 @@ module.exports = {
} }
delete planData.original_price; delete planData.original_price;
if (planData.features) {
planData.features = JSON.parse(planData.features);
}
// 转换 featured 为布尔值 // 转换 featured 为布尔值
planData.featured = planData.featured === 1; planData.featured = planData.featured === 1;
return planData; return planData;
}); });
return ctx.success(pricingPlans); return ctx.success(pricingPlans);
}, },

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

@@ -1 +1 @@
import{a as t}from"./index-CsHwYKwf.js";class s{async getConfig(r){try{return await t.post("/user/delivery-config/get",{sn_code:r})}catch(e){throw console.error("获取投递配置失败:",e),e}}async saveConfig(r,e){try{return await t.post("/user/delivery-config/save",{sn_code:r,deliver_config:e})}catch(o){throw console.error("保存投递配置失败:",o),o}}}const i=new s;export{i as default}; import{a as t}from"./index---wtnUW1.js";class s{async getConfig(r){try{return await t.post("/user/delivery-config/get",{sn_code:r})}catch(e){throw console.error("获取投递配置失败:",e),e}}async saveConfig(r,e){try{return await t.post("/user/delivery-config/save",{sn_code:r,deliver_config:e})}catch(o){throw console.error("保存投递配置失败:",o),o}}}const i=new s;export{i as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -5,8 +5,8 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>boss - 远程监听服务</title> <title>boss - 远程监听服务</title>
<script type="module" crossorigin src="/app/assets/index-CsHwYKwf.js"></script> <script type="module" crossorigin src="/app/assets/index---wtnUW1.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-BUzIVj1g.css"> <link rel="stylesheet" crossorigin href="/app/assets/index-yg6NAGeT.css">
</head> </head>
<body> <body>

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);
});