Compare commits

..

17 Commits

Author SHA1 Message Date
张成
2786202212 1 2025-12-29 17:02:53 +08:00
张成
517a320627 1 2025-12-29 15:27:55 +08:00
张成
eed08a30fb 1 2025-12-29 15:00:21 +08:00
张成
1d02702e96 1 2025-12-29 14:18:41 +08:00
张成
547f1eaec2 1 2025-12-29 14:16:23 +08:00
张成
43382668a3 1 2025-12-27 17:40:19 +08:00
张成
6e38ba6b38 1 2025-12-27 17:39:14 +08:00
张成
b17d08ffa8 1 2025-12-26 14:26:04 +08:00
张成
54644dbb72 1 2025-12-26 14:22:33 +08:00
张成
1d8d2ea6e8 1 2025-12-26 14:01:47 +08:00
张成
3f4acc5e1d 1 2025-12-26 13:49:07 +08:00
张成
69f2f87f4b 1 2025-12-26 13:44:57 +08:00
张成
0cfff98edf 1 2025-12-26 13:39:27 +08:00
张成
77789446f3 1 2025-12-26 13:30:20 +08:00
张成
2530f25b86 1 2025-12-26 13:26:11 +08:00
张成
6efd77d2b5 1 2025-12-26 13:12:53 +08:00
张成
6253abc617 1 2025-12-26 11:38:41 +08:00
35 changed files with 3037 additions and 2042 deletions

3
.gitignore vendored
View File

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

View File

@@ -0,0 +1,529 @@
# 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,36 @@
-- 在用户管理菜单下添加"价格套餐管理"菜单项
-- 参考其他菜单项的配置格式
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 (
'价格套餐管理', -- 菜单名称
120, -- parent_id: 用户管理菜单的ID
0, -- model_id
0, -- form_id
'md-pricetags', -- icon: 价格标签图标
'pricing_plans', -- path: 路由路径
'system/pricing_plans.vue', -- component: 组件路径(已在 component-map.js 中定义)
'system/pricing_plans_server.js', -- api_path: API 服务文件路径
1, -- is_show_menu: 1=显示在菜单栏
1, -- is_show: 1=启用
'页面', -- type: 页面类型
3, -- sort: 排序(可根据实际情况调整)
NOW(), -- create_time: 创建时间
NOW(), -- last_modify_time: 最后修改时间
0 -- is_delete: 0=未删除
);

View File

@@ -0,0 +1,24 @@
-- 创建价格套餐表
-- 用于存储各种价格套餐的配置信息
CREATE TABLE `pricing_plans` (
`id` INT(11) NOT NULL AUTO_INCREMENT COMMENT '价格套餐ID',
`name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '套餐名称(如:体验套餐、月度套餐等)',
`duration` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '时长描述7天、30天、永久',
`days` INT(11) NOT NULL DEFAULT 0 COMMENT '天数(-1表示永久0表示无限制',
`price` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '售价(元)',
`original_price` DECIMAL(10,2) NULL DEFAULT NULL COMMENT '原价(元),可为空表示无原价',
`unit` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '价格单位',
`discount` VARCHAR(50) NULL DEFAULT NULL COMMENT '折扣描述8.3折、超值)',
`features` TEXT NOT NULL COMMENT '功能特性列表JSON字符串数组',
`featured` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为推荐套餐1=推荐0=普通)',
`is_active` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用1=启用0=禁用)',
`sort_order` INT(11) NOT NULL DEFAULT 0 COMMENT '排序顺序(越小越靠前)',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`last_modify_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后修改时间',
`is_delete` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否删除1=已删除0=未删除)',
PRIMARY KEY (`id`),
INDEX `idx_is_active` (`is_active`),
INDEX `idx_is_delete` (`is_delete`),
INDEX `idx_sort_order` (`sort_order`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='价格套餐表';

View File

@@ -0,0 +1,89 @@
-- 插入初始价格套餐数据
-- 基于原有前端接口 /config/pricing-plans 中的硬编码数据
INSERT INTO `pricing_plans` (
`name`,
`duration`,
`days`,
`price`,
`original_price`,
`unit`,
`discount`,
`features`,
`featured`,
`is_active`,
`sort_order`,
`create_time`,
`last_modify_time`,
`is_delete`
) VALUES
(
'体验套餐',
'7天',
7,
28.00,
28.00,
'',
NULL,
'["7天使用权限", "全功能体验", "技术支持"]',
0,
1,
1,
NOW(),
NOW(),
0
),
(
'月度套餐',
'30天',
30,
99.00,
120.00,
'',
'约8.3折',
'["30天使用权限", "全功能使用", "优先技术支持", "性价比最高"]',
1,
1,
2,
NOW(),
NOW(),
0
),
(
'季度套餐',
'90天',
90,
269.00,
360.00,
'',
'7.5折',
'["90天使用权限", "全功能使用", "优先技术支持", "更优惠价格"]',
0,
1,
3,
NOW(),
NOW(),
0
),
(
'终生套餐',
'永久',
-1,
888.00,
NULL,
'',
'超值',
'["永久使用权限", "全功能使用", "终身技术支持", "一次购买,终身使用", "最划算选择"]',
0,
1,
4,
NOW(),
NOW(),
0
);
-- 查询验证插入结果
SELECT id, name, duration, price, original_price, featured, is_active, sort_order
FROM pricing_plans
WHERE is_delete = 0
ORDER BY sort_order ASC;

View File

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

View File

@@ -48,6 +48,15 @@ class ResumeInfoServer {
analyzeWithAI(resumeId) {
return window.framework.http.post('/resume/analyze-with-ai', { resumeId })
}
/**
* 同步在线简历
* @param {String} resumeId - 简历ID
* @returns {Promise}
*/
syncOnline(resumeId) {
return window.framework.http.post('/resume/sync-online', { resumeId })
}
}
export default new ResumeInfoServer()

View File

@@ -0,0 +1,54 @@
/**
* 价格套餐 API 服务
*/
class PricingPlansServer {
/**
* 分页查询价格套餐
* @param {Object} param - 查询参数
* @param {Object} param.seachOption - 搜索条件
* @param {Object} param.pageOption - 分页选项
* @returns {Promise}
*/
page(param) {
return window.framework.http.post('/pricing_plans/list', param)
}
/**
* 获取价格套餐详情
* @param {Number|String} id - 价格套餐ID
* @returns {Promise}
*/
getById(id) {
return window.framework.http.get('/pricing_plans/detail', { id })
}
/**
* 新增价格套餐
* @param {Object} row - 价格套餐数据
* @returns {Promise}
*/
add(row) {
return window.framework.http.post('/pricing_plans/create', row)
}
/**
* 更新价格套餐信息
* @param {Object} row - 价格套餐数据
* @returns {Promise}
*/
update(row) {
return window.framework.http.post('/pricing_plans/update', row)
}
/**
* 删除价格套餐
* @param {Object} row - 价格套餐数据包含id
* @returns {Promise}
*/
del(row) {
return window.framework.http.post('/pricing_plans/delete', { id: row.id })
}
}
export default new PricingPlansServer()

File diff suppressed because one or more lines are too long

View File

@@ -21,7 +21,7 @@ import TaskStatus from '@/views/task/task_status.vue'
import SystemConfig from '@/views/system/system_config.vue'
import Version from '@/views/system/version.vue'
import JobTypes from '@/views/work/job_types.vue'
import AiCallRecords from '@/views/system/ai_call_records.vue'
import PricingPlans from '@/views/system/pricing_plans.vue'
// 首页模块
import HomeIndex from '@/views/home/index.vue'
@@ -54,7 +54,8 @@ const componentMap = {
'system/system_config': SystemConfig,
'system/version': Version,
'work/job_types': JobTypes,
'system/ai_call_records': AiCallRecords,
'system/pricing_plans': PricingPlans,
'system/pricing_plans.vue': PricingPlans,
'home/index': HomeIndex,

View File

@@ -349,12 +349,12 @@ export default {
{ title: '密码', key: 'pwd', com: 'Password', required: true },
],
listColumns: [
{ title: 'ID', key: 'id' },
{ title: '账户名', key: 'name' },
{ title: '设备SN码', key: 'sn_code'},
{ title: '账户名', key: 'name', minWidth: 120 },
{ title: '设备SN码', key: 'sn_code', minWidth: 150 },
{
title: '平台',
key: 'platform_type',
minWidth: 100,
render: (h, params) => {
const platformMap = {
'boss': { text: 'Boss直聘', color: 'blue' },
@@ -364,26 +364,10 @@ export default {
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: '在线状态',
key: 'is_online',
minWidth: 90,
render: (h, params) => {
return h('Tag', {
props: { color: params.row.is_online ? 'success' : 'default' }
@@ -393,12 +377,7 @@ export default {
{
title: '自动投递',
key: 'auto_deliver',
com: "Radio",
options: [
{ value: 1, label: '开启' },
{ value: 0, label: '关闭' }
],
minWidth: 90,
render: (h, params) => {
return h('Tag', {
props: { color: params.row.auto_deliver ? 'success' : 'default' }
@@ -408,11 +387,7 @@ export default {
{
title: '自动沟通',
key: 'auto_chat',
"com": "Radio",
options: [
{ value: 1, label: '开启' },
{ value: 0, label: '关闭' }
],
minWidth: 90,
render: (h, params) => {
return h('Tag', {
props: { color: params.row.auto_chat ? 'success' : 'default' }
@@ -422,7 +397,7 @@ export default {
{
title: '剩余天数',
key: 'remaining_days',
minWidth: 100,
render: (h, params) => {
const remainingDays = params.row.remaining_days || 0
let color = 'success'
@@ -436,52 +411,10 @@ export default {
}, 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: '启用状态',
key: 'is_enabled',
minWidth: 100,
render: (h, params) => {
return h('i-switch', {
props: {
@@ -496,7 +429,6 @@ export default {
})
}
},
{ title: '创建时间', key: 'create_time', },
{
title: '操作',
key: 'action',
@@ -640,18 +572,21 @@ export default {
}
this.query(1)
},
async handleSaveSuccess({ data }) {
async handleSaveSuccess({ data, isEdit } = {}) {
try {
// 如果是新增(来自 editModaldata 只包含必填字段,直接保存
if (data && !data.id) {
if (data && !data.id && !isEdit) {
await plaAccountServer.add(data)
this.$Message.success('保存成功!')
}
// 编辑时由 FloatPanel 组件PlaAccountEdit处理保存这里只刷新列表
this.query(this.gridOption.param.pageOption.page)
// 刷新列表,保持当前页码
this.query(this.gridOption.param.pageOption.page || 1)
} catch (error) {
console.error('保存失败:', error)
this.$Message.error('保存失败:' + (error.message || '请稍后重试'))
// 优先从 error.response.data.message 获取,然后是 error.message
const errorMsg = error.response?.data?.message || error.message || '请稍后重试'
this.$Message.error('保存失败:' + errorMsg)
}
},
// 显示账号详情

View File

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

View File

@@ -8,6 +8,7 @@
@back="handleBack"
>
<template #header-right>
<Button type="info" @click="handleSyncOnline" :loading="syncing" style="margin-right: 8px;">同步在线简历</Button>
<Button type="primary" @click="handleAnalyzeAI" :loading="analyzing">AI 分析</Button>
</template>
@@ -272,6 +273,7 @@ export default {
return {
loading: false,
analyzing: false,
syncing: false,
resumeData: null,
skillTags: [],
workExperience: [],
@@ -317,6 +319,32 @@ export default {
}
return field
},
async handleSyncOnline() {
if (!this.resumeData || !this.resumeData.resumeId) {
this.$Message.warning('简历ID不存在')
return
}
if (!this.resumeData.sn_code) {
this.$Message.warning('该简历未绑定设备,无法同步在线简历')
return
}
this.syncing = true
try {
const res = await resumeInfoServer.syncOnline(this.resumeData.resumeId)
this.$Message.success(res.message || '同步在线简历成功')
// 重新加载数据
await this.loadResumeData(this.resumeData.resumeId)
} catch (error) {
console.error('同步在线简历失败:', error)
// 优先从 error.response.data.message 获取,然后是 error.message
const errorMsg = error.response?.data?.message || error.message || '请稍后重试'
this.$Message.error(errorMsg)
} finally {
this.syncing = false
}
},
async handleAnalyzeAI() {
if (!this.resumeData || !this.resumeData.resumeId) {
this.$Message.warning('简历ID不存在')

View File

@@ -0,0 +1,392 @@
<template>
<div class="content-view">
<div class="table-head-tool">
<Button type="primary" @click="showAddWarp">新增价格套餐</Button>
<Form ref="formInline" :model="gridOption.param.seachOption" inline :label-width="80">
<FormItem :label-width="20" class="flex">
<Select v-model="gridOption.param.seachOption.key" style="width: 120px"
:placeholder="seachTypePlaceholder">
<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" search
placeholder="请输入关键字" @on-search="query(1)" />
</FormItem>
<FormItem label="状态">
<Select v-model="gridOption.param.seachOption.is_active" style="width: 120px" clearable
@on-change="query(1)">
<Option :value="1">启用</Option>
<Option :value="0">禁用</Option>
</Select>
</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"></tables>
</div>
<editModal ref="editModal" :columns="editColumns" :rules="gridOption.rules" >
</editModal>
</div>
</template>
<script>
import pricingPlansServer from '@/api/system/pricing_plans_server.js'
export default {
data() {
let rules = {}
rules["name"] = [{ required: true, message: '请填写套餐名称', trigger: 'blur' }]
rules["duration"] = [{ required: true, message: '请填写时长描述', trigger: 'blur' }]
rules["days"] = [{ required: true, type: 'number', message: '请填写天数', trigger: 'blur' }]
rules["price"] = [{ required: true, message: '请填写价格', trigger: 'blur' }]
return {
seachTypes: [
{ key: 'name', value: '套餐名称' },
{ key: 'duration', value: '时长' }
],
gridOption: {
param: {
seachOption: {
key: 'name',
value: '',
is_active: null
},
pageOption: {
page: 1,
pageSize: 20
}
},
data: [],
rules: rules
},
listColumns: [
{ title: 'ID', key: 'id', minWidth: 60 },
{ title: '套餐名称', key: 'name', minWidth: 120 },
{ title: '时长', key: 'duration', minWidth: 100 },
{ title: '天数', key: 'days', minWidth: 80 },
{
title: '价格',
key: 'price',
minWidth: 100,
render: (h, params) => {
return h('span', `¥${params.row.price}`)
}
},
{
title: '原价',
key: 'original_price',
minWidth: 100,
render: (h, params) => {
return h('span', params.row.original_price ? `¥${params.row.original_price}` : '-')
}
},
{ title: '折扣', key: 'discount', minWidth: 100 },
{
title: '推荐',
key: 'featured',
minWidth: 80,
render: (h, params) => {
return h('Tag', {
props: { color: params.row.featured === 1 ? 'warning' : 'default' }
}, params.row.featured === 1 ? '推荐' : '普通')
}
},
{
title: '状态',
key: 'is_active',
minWidth: 80,
render: (h, params) => {
const status = params.row.is_active === 1
return h('Tag', {
props: { color: status ? 'success' : 'default' }
}, status ? '启用' : '禁用')
}
},
{ title: '排序', key: 'sort_order', minWidth: 80 },
{
title: '操作',
key: 'action',
width: 200,
align: 'center',
render: (h, params) => {
return h('div', [
h('Button', {
props: {
type: 'primary',
size: 'small'
},
style: {
marginRight: '5px'
},
on: {
click: () => {
this.edit(params.row)
}
}
}, '编辑'),
h('Button', {
props: {
type: 'error',
size: 'small'
},
on: {
click: () => {
this.del(params.row)
}
}
}, '删除')
])
}
}
],
editColumns: [
{
title: '套餐名称',
key: 'name',
type: 'input',
required: true
},
{
title: '时长描述',
key: 'duration',
type: 'input',
required: true,
placeholder: '如7天、30天、永久'
},
{
title: '天数',
key: 'days',
type: 'number',
required: true,
tooltip: '-1表示永久0表示无限制'
},
{
title: '价格',
key: 'price',
type: 'number',
required: true
},
{
title: '原价',
key: 'original_price',
type: 'number',
required: false,
placeholder: '可留空,表示无原价'
},
{
title: '单位',
key: 'unit',
type: 'input',
required: false,
defaultValue: '元'
},
{
title: '折扣描述',
key: 'discount',
type: 'input',
required: false,
placeholder: '如8.3折、超值'
},
{
title: '功能特性',
key: 'features',
com: 'TextArea',
required: false,
placeholder: '请输入JSON数组格式例如["功能1", "功能2", "功能3"]',
tooltip: '功能特性列表JSON数组格式'
},
{
title: '是否推荐',
key: 'featured',
com: 'Radio',
required: true,
options: [
{ value: 1, label: '推荐' },
{ value: 0, label: '普通' }
]
},
{
title: '是否启用',
key: 'is_active',
com: 'Radio',
required: true,
options: [
{ value: 1, label: '启用' },
{ value: 0, label: '禁用' }
]
},
{
title: '排序',
key: 'sort_order',
type: 'number',
required: false,
defaultValue: 0,
tooltip: '数字越小越靠前'
}
]
}
},
computed: {
seachTypePlaceholder() {
const item = this.seachTypes.find(item => item.key === this.gridOption.param.seachOption.key)
return item ? `请输入${item.value}` : '请选择'
}
},
mounted() {
this.query(1)
},
methods: {
query(page) {
if (page) {
this.gridOption.param.pageOption.page = page
}
const param = {
pageOption: this.gridOption.param.pageOption,
seachOption: {}
}
if (this.gridOption.param.seachOption.key && this.gridOption.param.seachOption.value) {
param.seachOption[this.gridOption.param.seachOption.key] = this.gridOption.param.seachOption.value
}
if (this.gridOption.param.seachOption.is_active !== null) {
param.seachOption.is_active = this.gridOption.param.seachOption.is_active
}
pricingPlansServer.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: 'name',
value: '',
is_active: null
}
this.query(1)
},
showAddWarp() {
let editRow={
name: '',
duration: '',
days: 0,
price: 0,
original_price: null,
unit: '元',
discount: '',
features: '[]',
featured: 0,
is_active: 1,
sort_order: 0
}
this.$refs.editModal.show(editRow)
},
edit(row) {
// 解析 JSON 字段
let features = row.features || '[]'
// 如果是字符串,尝试解析并格式化
if (typeof features === 'string') {
try {
const parsed = JSON.parse(features)
features = JSON.stringify(parsed, null, 2)
} catch (e) {
// 保持原样
}
} else {
features = JSON.stringify(features, null, 2)
}
let editRow={
id: row.id,
name: row.name,
duration: row.duration || '',
days: row.days,
price: row.price,
original_price: row.original_price,
unit: row.unit || '元',
discount: row.discount || '',
features: features,
featured: row.featured,
is_active: row.is_active,
sort_order: row.sort_order || 0
}
this.$refs.editModal.editShow(editRow,(newRow)=>{
debugger
this.handleSaveSuccess(newRow)
})
},
del(row) {
this.$Modal.confirm({
title: '确认删除',
content: `确定要删除价格套餐"${row.name}"吗?`,
onOk: () => {
pricingPlansServer.del(row).then(res => {
if (res.code === 0) {
this.$Message.success('删除成功')
this.query(this.gridOption.param.pageOption.page)
} else {
this.$Message.error(res.message || '删除失败')
}
}).catch(err => {
this.$Message.error('删除失败:' + (err.message || '未知错误'))
})
}
})
},
handleSaveSuccess(data) {
// 处理 JSON 字段
const formData = { ...data }
// 处理 features
if (formData.features) {
try {
const parsed = typeof formData.features === 'string'
? JSON.parse(formData.features)
: formData.features
if (!Array.isArray(parsed)) {
this.$Message.warning('功能特性必须是数组格式,将使用空数组')
formData.features = []
} else {
formData.features = parsed
}
} catch (e) {
this.$Message.warning('功能特性格式错误,将使用空数组')
formData.features = []
}
}
const apiMethod = formData.id ? pricingPlansServer.update : pricingPlansServer.add
apiMethod(formData).then(res => {
if (res.code === 0) {
this.$Message.success(formData.id ? '更新成功' : '添加成功')
this.$refs.editModal.hide()
this.query(this.gridOption.param.pageOption.page)
} else {
this.$Message.error(res.message || (formData.id ? '更新失败' : '添加失败'))
}
}).catch(err => {
this.$Message.error((formData.id ? '更新失败' : '添加失败') + '' + (err.message || '未知错误'))
})
}
}
}
</script>
<style scoped>
.content-view {
padding: 16px;
}
</style>

View File

@@ -0,0 +1,455 @@
/**
* 价格套餐管理API - 后台管理
* 提供价格套餐的增删改查功能
*/
const Framework = require("../../framework/node-core-framework.js");
module.exports = {
/**
* @swagger
* /admin_api/pricing_plans/list:
* post:
* summary: 获取价格套餐列表
* description: 分页获取所有价格套餐
* tags: [后台-价格套餐管理]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* pageOption:
* type: object
* properties:
* page:
* type: integer
* description: 页码
* pageSize:
* type: integer
* description: 每页数量
* seachOption:
* type: object
* properties:
* key:
* type: string
* description: 搜索字段
* value:
* type: string
* description: 搜索值
* is_active:
* type: integer
* description: 状态筛选1=启用0=禁用)
* responses:
* 200:
* description: 获取成功
*/
'POST /pricing_plans/list': async (ctx) => {
try {
const models = Framework.getModels();
const { pricing_plans, op } = models;
const body = ctx.getBody();
// 获取分页参数
const { limit, offset } = ctx.getPageSize();
// 构建查询条件
const where = { is_delete: 0 };
// 搜索条件
if (body.seachOption) {
const { key, value, is_active } = body.seachOption;
if (value && key) {
if (key === 'name') {
where.name = { [op.like]: `%${value}%` };
} else if (key === 'duration') {
where.duration = { [op.like]: `%${value}%` };
}
}
// 状态筛选
if (is_active !== undefined && is_active !== null && is_active !== '') {
where.is_active = is_active;
}
}
const result = await pricing_plans.findAndCountAll({
where,
limit,
offset,
order: [['sort_order', 'ASC'], ['id', 'ASC']]
});
return ctx.success(result);
} catch (error) {
console.error('获取价格套餐列表失败:', error);
return ctx.fail('获取价格套餐列表失败: ' + error.message);
}
},
/**
* @swagger
* /admin_api/pricing_plans/detail:
* get:
* summary: 获取价格套餐详情
* description: 根据ID获取价格套餐详细信息
* tags: [后台-价格套餐管理]
* parameters:
* - in: query
* name: id
* required: true
* schema:
* type: integer
* description: 价格套餐ID
* responses:
* 200:
* description: 获取成功
*/
'GET /pricing_plans/detail': async (ctx) => {
try {
const models = Framework.getModels();
const { pricing_plans } = models;
const { id } = ctx.getQuery();
if (!id) {
return ctx.fail('价格套餐ID不能为空');
}
const plan = await pricing_plans.findOne({
where: { id, is_delete: 0 }
});
if (!plan) {
return ctx.fail('价格套餐不存在');
}
return ctx.success(plan);
} catch (error) {
console.error('获取价格套餐详情失败:', error);
return ctx.fail('获取价格套餐详情失败: ' + error.message);
}
},
/**
* @swagger
* /admin_api/pricing_plans/create:
* post:
* summary: 创建价格套餐
* description: 创建新的价格套餐
* tags: [后台-价格套餐管理]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - name
* - duration
* - days
* - price
* properties:
* name:
* type: string
* description: 套餐名称
* duration:
* type: string
* description: 时长描述
* days:
* type: integer
* description: 天数(-1表示永久
* price:
* type: number
* description: 售价
* original_price:
* type: number
* description: 原价
* unit:
* type: string
* description: 单位
* discount:
* type: string
* description: 折扣描述
* features:
* type: array
* description: 功能特性列表
* featured:
* type: integer
* description: 是否推荐1=推荐0=普通)
* is_active:
* type: integer
* description: 是否启用1=启用0=禁用)
* sort_order:
* type: integer
* description: 排序顺序
* responses:
* 200:
* description: 创建成功
*/
'POST /pricing_plans/create': async (ctx) => {
try {
const models = Framework.getModels();
const { pricing_plans } = models;
const body = ctx.getBody();
const { name, duration, days, price, original_price, unit, discount, features, featured, is_active, sort_order } = body;
// 验证必填字段
if (!name) {
return ctx.fail('套餐名称不能为空');
}
if (!duration) {
return ctx.fail('时长描述不能为空');
}
if (days === undefined || days === null) {
return ctx.fail('天数不能为空');
}
if (!price && price !== 0) {
return ctx.fail('价格不能为空');
}
// 验证价格
if (price < 0) {
return ctx.fail('价格不能为负数');
}
// 验证天数
if (days < -1) {
return ctx.fail('天数不能小于-1-1表示永久');
}
// 处理 features 字段:转换为 JSON 字符串
let featuresStr = '[]';
if (features) {
try {
if (Array.isArray(features)) {
featuresStr = JSON.stringify(features);
} else if (typeof features === 'string') {
// 验证是否为有效 JSON
const parsed = JSON.parse(features);
if (!Array.isArray(parsed)) {
return ctx.fail('功能特性必须是数组格式');
}
featuresStr = features;
} else {
return ctx.fail('功能特性格式错误');
}
} catch (e) {
return ctx.fail('功能特性JSON格式错误');
}
}
// 创建套餐
const plan = await pricing_plans.create({
name,
duration,
days,
price,
original_price: original_price !== undefined ? original_price : null,
unit: unit || '元',
discount: discount || null,
features: featuresStr,
featured: featured !== undefined ? featured : 0,
is_active: is_active !== undefined ? is_active : 1,
sort_order: sort_order !== undefined ? sort_order : 0,
is_delete: 0,
create_time: new Date(),
last_modify_time: new Date()
});
return ctx.success(plan);
} catch (error) {
console.error('创建价格套餐失败:', error);
return ctx.fail('创建价格套餐失败: ' + error.message);
}
},
/**
* @swagger
* /admin_api/pricing_plans/update:
* post:
* summary: 更新价格套餐
* description: 更新价格套餐信息
* tags: [后台-价格套餐管理]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - id
* properties:
* id:
* type: integer
* description: 价格套餐ID
* name:
* type: string
* description: 套餐名称
* duration:
* type: string
* description: 时长描述
* days:
* type: integer
* description: 天数
* price:
* type: number
* description: 售价
* original_price:
* type: number
* description: 原价
* unit:
* type: string
* description: 单位
* discount:
* type: string
* description: 折扣描述
* features:
* type: array
* description: 功能特性列表
* featured:
* type: integer
* description: 是否推荐
* is_active:
* type: integer
* description: 是否启用
* sort_order:
* type: integer
* description: 排序顺序
* responses:
* 200:
* description: 更新成功
*/
'POST /pricing_plans/update': async (ctx) => {
try {
const models = Framework.getModels();
const { pricing_plans } = models;
const body = ctx.getBody();
const { id, name, duration, days, price, original_price, unit, discount, features, featured, is_active, sort_order } = body;
if (!id) {
return ctx.fail('价格套餐ID不能为空');
}
const plan = await pricing_plans.findOne({
where: { id, is_delete: 0 }
});
if (!plan) {
return ctx.fail('价格套餐不存在');
}
// 构建更新数据
const updateData = {
last_modify_time: new Date()
};
if (name !== undefined) updateData.name = name;
if (duration !== undefined) updateData.duration = duration;
if (days !== undefined) {
if (days < -1) {
return ctx.fail('天数不能小于-1-1表示永久');
}
updateData.days = days;
}
if (price !== undefined) {
if (price < 0) {
return ctx.fail('价格不能为负数');
}
updateData.price = price;
}
if (original_price !== undefined) updateData.original_price = original_price;
if (unit !== undefined) updateData.unit = unit;
if (discount !== undefined) updateData.discount = discount;
if (featured !== undefined) updateData.featured = featured;
if (is_active !== undefined) updateData.is_active = is_active;
if (sort_order !== undefined) updateData.sort_order = sort_order;
// 处理 features 字段
if (features !== undefined) {
try {
if (Array.isArray(features)) {
updateData.features = JSON.stringify(features);
} else if (typeof features === 'string') {
const parsed = JSON.parse(features);
if (!Array.isArray(parsed)) {
return ctx.fail('功能特性必须是数组格式');
}
updateData.features = features;
} else {
return ctx.fail('功能特性格式错误');
}
} catch (e) {
return ctx.fail('功能特性JSON格式错误');
}
}
await pricing_plans.update(updateData, {
where: { id, is_delete: 0 }
});
return ctx.success({ message: '价格套餐更新成功' });
} catch (error) {
console.error('更新价格套餐失败:', error);
return ctx.fail('更新价格套餐失败: ' + error.message);
}
},
/**
* @swagger
* /admin_api/pricing_plans/delete:
* post:
* summary: 删除价格套餐
* description: 软删除指定的价格套餐
* tags: [后台-价格套餐管理]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - id
* properties:
* id:
* type: integer
* description: 价格套餐ID
* responses:
* 200:
* description: 删除成功
*/
'POST /pricing_plans/delete': async (ctx) => {
try {
const models = Framework.getModels();
const { pricing_plans } = models;
const { id } = ctx.getBody();
if (!id) {
return ctx.fail('价格套餐ID不能为空');
}
const plan = await pricing_plans.findOne({
where: { id, is_delete: 0 }
});
if (!plan) {
return ctx.fail('价格套餐不存在');
}
// 软删除
await pricing_plans.update(
{
is_delete: 1,
last_modify_time: new Date()
},
{ where: { id } }
);
return ctx.success({ message: '价格套餐删除成功' });
} catch (error) {
console.error('删除价格套餐失败:', error);
return ctx.fail('删除价格套餐失败: ' + error.message);
}
}
};

View File

@@ -358,6 +358,100 @@ return ctx.success({ message: '简历删除成功' });
console.error('AI 分析失败:', error);
return ctx.fail('AI 分析失败: ' + error.message);
}
},
/**
* @swagger
* /admin_api/resume/sync-online:
* post:
* summary: 同步在线简历
* description: 通过MQTT指令获取用户在线简历并更新到数据库
* tags: [后台-简历管理]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - resumeId
* properties:
* resumeId:
* type: string
* description: 简历ID
* responses:
* 200:
* description: 同步成功
*/
'POST /resume/sync-online': async (ctx) => {
const models = Framework.getModels();
const { resume_info } = models;
const { resumeId } = ctx.getBody();
if (!resumeId) {
return ctx.fail('简历ID不能为空');
}
const resume = await resume_info.findOne({ where: { resumeId } });
if (!resume) {
return ctx.fail('简历不存在');
}
const { sn_code, platform } = resume;
if (!sn_code) {
return ctx.fail('该简历未绑定设备SN码');
}
try {
const scheduleManager = require('../middleware/schedule');
const resumeManager = require('../middleware/job/resumeManager');
// 检查 MQTT 客户端是否已初始化
if (!scheduleManager.mqttClient) {
return ctx.fail('MQTT客户端未初始化请检查调度系统是否正常启动');
}
// 检查设备是否在线
// const deviceManager = require('../middleware/schedule/deviceManager');
// if (!deviceManager.isDeviceOnline(sn_code)) {
// return ctx.fail('设备离线,无法同步在线简历');
// }
// 调用简历管理器获取并保存简历
const resumeData = await resumeManager.get_online_resume(sn_code, scheduleManager.mqttClient, {
platform: platform || 'boss'
});
// 重新获取更新后的简历数据
const updatedResume = await resume_info.findOne({ where: { resumeId } });
if (!updatedResume) {
return ctx.fail('同步成功但未找到更新后的简历记录');
}
const resumeDetail = updatedResume.toJSON();
// 解析 JSON 字段
const jsonFields = ['skills', 'certifications', 'projectExperience', 'workExperience', 'aiSkillTags'];
jsonFields.forEach(field => {
if (resumeDetail[field]) {
try {
resumeDetail[field] = JSON.parse(resumeDetail[field]);
} catch (e) {
console.error(`解析字段 ${field} 失败:`, e);
}
}
});
return ctx.success({
message: '同步在线简历成功',
data: resumeDetail
});
} catch (error) {
console.error('同步在线简历失败:', error);
return ctx.fail('同步在线简历失败: ' + (error.message || '未知错误'));
}
}
};

View File

@@ -71,6 +71,20 @@ module.exports = {
where.feedbackStatus = seachOption.feedbackStatus;
}
// 时间范围筛选
console.log(seachOption.startTime, seachOption.endTime);
if (seachOption.startTime || seachOption.endTime) {
where.create_time = {};
if (seachOption.startTime) {
where.create_time[op.gte] = new Date(seachOption.startTime);
}
if (seachOption.endTime) {
const endTime = new Date(seachOption.endTime);
endTime.setHours(23, 59, 59, 999); // 设置为当天的最后一刻
where.create_time[op.lte] = endTime;
}
}
// 搜索:岗位名称、公司名称
if (seachOption.key && seachOption.value) {
const key = seachOption.key;
@@ -93,7 +107,7 @@ module.exports = {
where,
limit,
offset,
order: [['applyTime', 'DESC']]
order: [['create_time', 'DESC']]
});
return ctx.success({
@@ -109,7 +123,7 @@ module.exports = {
* /api/apply/statistics:
* get:
* summary: 获取投递统计
* description: 根据设备SN码获取投递统计数据包含今日、本周、本月统计
* description: 根据设备SN码获取投递统计数据包含今日、本周、本月统计,支持时间范围筛选
* tags: [前端-投递管理]
* parameters:
* - in: query
@@ -118,21 +132,50 @@ module.exports = {
* schema:
* type: string
* description: 设备SN码
* - in: query
* name: startTime
* schema:
* type: string
* format: date-time
* description: 开始时间(可选)
* - in: query
* name: endTime
* schema:
* type: string
* format: date-time
* description: 结束时间(可选)
* responses:
* 200:
* description: 获取成功
*/
'GET /apply/statistics': async (ctx) => {
'POST /apply/statistics': async (ctx) => {
const models = Framework.getModels();
const { apply_records, op } = models;
const { sn_code } = ctx.query;
const { apply_records, op, job_postings } = models;
const { sn_code, startTime, endTime } = ctx.getBody();
console.log(startTime, endTime);
const final_sn_code = sn_code;
if (!final_sn_code) {
return ctx.fail('请提供设备SN码');
}
// 计算时间范围
// 构建基础查询条件
const baseWhere = { sn_code: final_sn_code };
// 如果提供了时间范围,则添加到查询条件中
if (startTime || endTime) {
baseWhere.create_time = {};
if (startTime) {
baseWhere.create_time[op.gte] = new Date(startTime);
}
if (endTime) {
const endTimeDate = new Date(endTime);
endTimeDate.setHours(23, 59, 59, 999); // 设置为当天的最后一刻
baseWhere.create_time[op.lte] = endTimeDate;
}
}
// 计算时间范围(如果未提供时间范围,则使用默认的今日、本周、本月)
const now = new Date();
// 今天的开始时间00:00:00
@@ -150,6 +193,8 @@ module.exports = {
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
monthStart.setHours(0, 0, 0, 0);
const [
totalCount,
successCount,
@@ -158,35 +203,44 @@ module.exports = {
interviewCount,
todayCount,
weekCount,
monthCount
monthCount,
totalJobCount
] = await Promise.all([
// 总计
apply_records.count({ where: { sn_code: final_sn_code } }),
apply_records.count({ where: { sn_code: final_sn_code, applyStatus: 'success' } }),
apply_records.count({ where: { sn_code: final_sn_code, applyStatus: 'failed' } }),
apply_records.count({ where: { sn_code: final_sn_code, applyStatus: 'pending' } }),
apply_records.count({ where: { sn_code: final_sn_code, feedbackStatus: 'interview' } }),
// 今日
apply_records.count({
// 总计(如果提供了时间范围,则只统计该范围内的)
apply_records.count({ where: baseWhere }),
apply_records.count({ where: { ...baseWhere, applyStatus: 'success' } }),
apply_records.count({ where: { ...baseWhere, applyStatus: 'failed' } }),
apply_records.count({ where: { ...baseWhere, applyStatus: 'pending' } }),
apply_records.count({ where: { ...baseWhere, feedbackStatus: 'interview' } }),
// 今日如果提供了时间范围则返回0否则统计今日
startTime || endTime ? 0 : apply_records.count({
where: {
sn_code: final_sn_code,
applyTime: { [op.gte]: todayStart }
create_time: { [op.gte]: todayStart }
}
}),
// 本周
apply_records.count({
// 本周如果提供了时间范围则返回0否则统计本周
startTime || endTime ? 0 : apply_records.count({
where: {
sn_code: final_sn_code,
applyTime: { [op.gte]: weekStart }
create_time: { [op.gte]: weekStart }
}
}),
// 本月
apply_records.count({
// 本月如果提供了时间范围则返回0否则统计本月
startTime || endTime ? 0 : apply_records.count({
where: {
sn_code: final_sn_code,
applyTime: { [op.gte]: monthStart }
create_time: { [op.gte]: monthStart }
}
})
}),
// 总职位数
job_postings.count({
where: {
sn_code: final_sn_code,
create_time: { [op.gte]: todayStart }
}
}),
]);
return ctx.success({
@@ -198,6 +252,7 @@ module.exports = {
todayCount,
weekCount,
monthCount,
totalJobCount,
successRate: totalCount > 0 ? ((successCount / totalCount) * 100).toFixed(2) : 0,
interviewRate: totalCount > 0 ? ((interviewCount / totalCount) * 100).toFixed(2) : 0
});
@@ -279,12 +334,12 @@ module.exports = {
const records = await apply_records.findAll({
where: {
sn_code: sn_code,
applyTime: {
create_time: {
[op.gte]: sevenDaysAgo,
[op.lte]: today
}
},
attributes: ['applyTime'],
attributes: ['create_time'],
raw: true
});
@@ -299,7 +354,7 @@ module.exports = {
// 统计当天的投递数量
const count = records.filter(record => {
const recordDate = new Date(record.applyTime);
const recordDate = new Date(record.create_time);
recordDate.setHours(0, 0, 0, 0);
return recordDate.getTime() === date.getTime();
}).length;

View File

@@ -88,84 +88,47 @@ module.exports = {
* description: 获取成功
*/
'GET /config/pricing-plans': async (ctx) => {
try {
// 写死4条价格套餐数据
// 价格计算规则2小时 = 1天
const pricingPlans = [
{
id: 1,
name: '体验套餐',
duration: '7天',
days: 7,
price: 28,
originalPrice: 28,
unit: '元',
features: [
'7天使用权限',
'全功能体验',
'技术支持'
],
featured: false
},
{
id: 2,
name: '月度套餐',
duration: '30天',
days: 30,
price: 99,
originalPrice: 120,
unit: '元',
discount: '约8.3折',
features: [
'30天使用权限',
'全功能使用',
'优先技术支持',
'性价比最高'
],
featured: true
},
{
id: 3,
name: '季度套餐',
duration: '90天',
days: 90,
price: 269,
originalPrice: 360,
unit: '元',
discount: '7.5折',
features: [
'90天使用权限',
'全功能使用',
'优先技术支持',
'更优惠价格'
],
featured: false
},
{
id: 4,
name: '终生套餐',
duration: '永久',
days: -1,
price: 888,
originalPrice: null,
unit: '元',
discount: '超值',
features: [
'永久使用权限',
'全功能使用',
'终身技术支持',
'一次购买,终身使用',
'最划算选择'
],
featured: false
}
];
return ctx.success(pricingPlans);
} catch (error) {
console.error('获取价格套餐失败:', error);
return ctx.fail('获取价格套餐失败: ' + error.message);
}
const models = Framework.getModels();
const { pricing_plans } = models;
// 查询所有启用且未删除的套餐,按排序顺序返回
const plans = await pricing_plans.findAll({
where: {
is_active: 1,
is_delete: 0
},
order: [['sort_order', 'ASC'], ['id', 'ASC']],
attributes: [
'id', 'name', 'duration', 'days', 'price',
'original_price', 'unit', 'discount', 'features', 'featured'
]
});
// 转换数据格式以匹配前端期望
const pricingPlans = plans.map(plan => {
const planData = plan.toJSON();
// 重命名字段以匹配前端期望camelCase
if (planData.original_price !== null) {
planData.originalPrice = planData.original_price;
}
delete planData.original_price;
if (planData.features) {
planData.features = JSON.parse(planData.features);
}
// 转换 featured 为布尔值
planData.featured = planData.featured === 1;
return planData;
});
return ctx.success(pricingPlans);
},
/**

View File

@@ -0,0 +1,307 @@
const axios = require('axios');
const config = require('../../../config/config');
const logs = require('../logProxy');
/**
* Qwen 2.5 大模型服务
* 集成阿里云 DashScope API提供智能化的岗位筛选、聊天生成、简历分析等功能
*/
class aiService {
constructor() {
this.apiKey = config.ai?.apiKey || process.env.DASHSCOPE_API_KEY;
// 使用 DashScope 兼容 OpenAI 格式的接口
this.apiUrl = 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions';
// Qwen 2.5 模型qwen-turbo快速、qwen-plus增强、qwen-max最强
this.model = config.ai?.model || 'qwen-turbo';
this.maxRetries = 3;
}
/**
* 调用 Qwen 2.5 API
* @param {string} prompt - 提示词
* @param {object} options - 配置选项
* @returns {Promise<object>} API响应结果
*/
async callAPI(prompt, options = {}) {
const requestData = {
model: this.model,
messages: [
{
role: 'system',
content: options.systemPrompt || '你是一个专业的招聘顾问,擅长分析岗位信息、生成聊天内容和分析简历匹配度。'
},
{
role: 'user',
content: prompt
}
],
temperature: options.temperature || 0.7,
max_tokens: options.maxTokens || 2000,
top_p: options.topP || 0.9
};
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
try {
const response = await axios.post(this.apiUrl, requestData, {
headers: {
'Authorization': `Bearer ${this.apiKey}`, // DashScope 使用 Bearer token 格式
'Content-Type': 'application/json'
},
timeout: 30000
});
return {
data: response.data,
content: response.data.choices?.[0]?.message?.content || ''
};
} catch (error) {
console.log(`Qwen 2.5 API调用失败 (尝试 ${attempt}/${this.maxRetries}): ${error.message}`);
if (attempt === this.maxRetries) {
throw new Error(error.message);
}
// 等待后重试
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
}
}
}
/**
* 岗位智能筛选
* @param {object} jobInfo - 岗位信息
* @param {object} resumeInfo - 简历信息
* @returns {Promise<object>} 筛选结果
*/
async analyzeJob(jobInfo, resumeInfo) {
const prompt = `
请分析以下岗位信息,并给出详细的评估结果:
岗位信息:
- 公司名称:${jobInfo.companyName || '未知'}
- 职位名称:${jobInfo.jobTitle || '未知'}
- 薪资范围:${jobInfo.salary || '未知'}
- 工作地点:${jobInfo.location || '未知'}
- 岗位描述:${jobInfo.description || '未知'}
- 技能要求:${jobInfo.skills || '未知'}
简历信息:
- 技能标签:${resumeInfo.skills || '未知'}
- 工作经验:${resumeInfo.experience || '未知'}
- 教育背景:${resumeInfo.education || '未知'}
- 期望薪资:${resumeInfo.expectedSalary || '未知'}
请从以下维度进行分析:
1. 技能匹配度0-100分
2. 经验匹配度0-100分
3. 薪资合理性0-100分
4. 公司质量评估0-100分
5. 是否为外包岗位(是/否)
6. 综合推荐指数0-100分
7. 详细分析说明
8. 投递建议
请以JSON格式返回结果。
`;
const result = await this.callAPI(prompt, {
systemPrompt: '你是一个专业的招聘分析师,擅长评估岗位与简历的匹配度。请提供客观、专业的分析结果。',
temperature: 0.3
});
try {
// 尝试解析JSON响应
const analysis = JSON.parse(result.content);
return {
analysis: analysis
};
} catch (parseError) {
// 如果解析失败,返回原始内容
return {
analysis: {
content: result.content,
parseError: true
}
};
}
}
/**
* 生成个性化聊天内容
* @param {object} jobInfo - 岗位信息
* @param {object} resumeInfo - 简历信息
* @param {string} chatType - 聊天类型 (greeting/interview/followup)
* @returns {Promise<object>} 聊天内容
*/
async generateChatContent(jobInfo, resumeInfo, chatType = 'greeting') {
const chatTypeMap = {
'greeting': '初次打招呼',
'interview': '面试邀约',
'followup': '跟进沟通'
};
const prompt = `
请为以下场景生成个性化的聊天内容:
聊天类型:${chatTypeMap[chatType] || chatType}
岗位信息:
- 公司名称:${jobInfo.companyName || '未知'}
- 职位名称:${jobInfo.jobTitle || '未知'}
- 技能要求:${jobInfo.skills || '未知'}
简历信息:
- 技能标签:${resumeInfo.skills || '未知'}
- 工作经验:${resumeInfo.experience || '未知'}
- 项目经验:${resumeInfo.projects || '未知'}
要求:
1. 内容要自然、专业、个性化
2. 突出简历与岗位的匹配点
3. 避免过于机械化的表达
4. 长度控制在100-200字
5. 体现求职者的诚意和热情
请直接返回聊天内容,不需要其他格式。
`;
const result = await this.callAPI(prompt, {
systemPrompt: '你是一个专业的招聘沟通专家,擅长生成自然、专业的求职聊天内容。',
temperature: 0.8
});
return result;
}
/**
* 分析简历要素
* @param {string} resumeText - 简历文本内容
* @returns {Promise<object>} 简历分析结果
*/
async analyzeResume(resumeText) {
const prompt = `
请分析以下简历内容,并返回 JSON 格式的分析结果:
简历内容:
${resumeText}
请按以下格式返回 JSON 结果:
{
"skillTags": ["技能1", "技能2", "技能3"], // 技能标签数组(编程语言、框架、工具等)
"strengths": "核心优势描述", // 简历的优势和亮点
"weaknesses": "不足之处描述", // 简历的不足或需要改进的地方
"careerSuggestion": "职业发展建议", // 针对该简历的职业发展方向和建议
"competitiveness": 75 // 竞争力评分0-100的整数综合考虑工作年限、技能、经验等因素
}
要求:
1. skillTags 必须是字符串数组
2. strengths、weaknesses、careerSuggestion 是字符串描述
3. competitiveness 必须是 0-100 之间的整数
4. 所有字段都必须返回,如果没有相关信息,使用空数组或空字符串
`;
const result = await this.callAPI(prompt, {
systemPrompt: '你是一个专业的简历分析师,擅长分析简历的核心要素、优势劣势、竞争力评分和职业发展建议。请以 JSON 格式返回分析结果,确保格式正确。',
temperature: 0.3,
maxTokens: 1500
});
try {
// 尝试从返回内容中提取 JSON
let content = result.content.trim();
// 如果返回内容被代码块包裹,提取其中的 JSON
const jsonMatch = content.match(/```(?:json)?\s*(\{[\s\S]*\})\s*```/) || content.match(/(\{[\s\S]*\})/);
if (jsonMatch) {
content = jsonMatch[1];
}
const analysis = JSON.parse(content);
return {
analysis: analysis
};
} catch (parseError) {
console.error(`[AI服务] 简历分析结果解析失败:`, parseError);
console.error(`[AI服务] 原始内容:`, result.content);
return {
analysis: {
content: result.content,
parseError: true
}
};
}
}
/**
* 生成面试邀约内容
* @param {object} jobInfo - 岗位信息
* @param {object} chatHistory - 聊天历史
* @returns {Promise<object>} 面试邀约内容
*/
async generateInterviewInvitation(jobInfo, chatHistory) {
const prompt = `
请基于以下信息生成面试邀约内容:
岗位信息:
- 公司名称:${jobInfo.companyName || '未知'}
- 职位名称:${jobInfo.jobTitle || '未知'}
- 工作地点:${jobInfo.location || '未知'}
聊天历史:
${chatHistory || '无'}
要求:
1. 表达面试邀约的诚意
2. 提供灵活的时间选择
3. 说明面试形式和地点
4. 体现对候选人的重视
5. 语言自然、专业
请直接返回面试邀约内容。
`;
const result = await this.callAPI(prompt, {
systemPrompt: '你是一个专业的HR擅长生成面试邀约内容。',
temperature: 0.6
});
return result;
}
/**
* 识别外包岗位
* @param {object} jobInfo - 岗位信息
* @returns {Promise<object>} 外包识别结果
*/
async identifyOutsourcingJob(jobInfo) {
const prompt = `
请分析以下岗位信息,判断是否为外包岗位:
岗位信息:
- 公司名称:${jobInfo.companyName || '未知'}
- 职位名称:${jobInfo.jobTitle || '未知'}
- 岗位描述:${jobInfo.description || '未知'}
- 技能要求:${jobInfo.skills || '未知'}
- 工作地点:${jobInfo.location || '未知'}
外包岗位特征:
1. 公司名称包含"外包"、"派遣"、"人力"等关键词
2. 岗位描述提到"项目外包"、"驻场开发"等
3. 技能要求过于宽泛或具体
4. 工作地点频繁变动
5. 薪资结构不明确
请判断是否为外包岗位,并给出详细分析。
`;
const result = await this.callAPI(prompt, {
systemPrompt: '你是一个专业的岗位分析师,擅长识别外包岗位的特征。',
temperature: 0.3
});
return result;
}
}
module.exports = new aiService();

View File

@@ -54,8 +54,8 @@ class ScheduleManager {
console.log('[调度管理器] 心跳监听已启动');
// 5. 启动定时任务
// this.scheduledJobs.start();
// console.log('[调度管理器] 定时任务已启动');
this.scheduledJobs.start();
console.log('[调度管理器] 定时任务已启动');
this.isInitialized = true;

View File

@@ -1,144 +0,0 @@
const Sequelize = require('sequelize');
const { DataTypes } = Sequelize;
module.exports = (sequelize) => {
const AiCallRecords = sequelize.define('ai_call_records', {
id: {
type: DataTypes.INTEGER(11),
allowNull: false,
primaryKey: true,
autoIncrement: true,
comment: '主键ID'
},
user_id: {
type: DataTypes.INTEGER(11),
allowNull: true,
comment: '用户ID'
},
sn_code: {
type: DataTypes.STRING(100),
allowNull: true,
comment: '设备SN码'
},
service_type: {
type: DataTypes.STRING(50),
allowNull: false,
comment: '服务类型chat/completion/embedding'
},
model_name: {
type: DataTypes.STRING(100),
allowNull: false,
comment: '模型名称'
},
prompt_tokens: {
type: DataTypes.INTEGER(11),
allowNull: false,
defaultValue: 0,
comment: '输入Token数'
},
completion_tokens: {
type: DataTypes.INTEGER(11),
allowNull: false,
defaultValue: 0,
comment: '输出Token数'
},
total_tokens: {
type: DataTypes.INTEGER(11),
allowNull: false,
defaultValue: 0,
comment: '总Token数'
},
request_content: {
type: DataTypes.TEXT,
allowNull: true,
comment: '请求内容'
},
response_content: {
type: DataTypes.TEXT,
allowNull: true,
comment: '响应内容'
},
cost_amount: {
type: DataTypes.DECIMAL(10, 4),
allowNull: true,
comment: '费用(元)'
},
status: {
type: DataTypes.STRING(20),
allowNull: false,
defaultValue: 'success',
comment: '状态success/failed'
},
error_message: {
type: DataTypes.TEXT,
allowNull: true,
comment: '错误信息'
},
response_time: {
type: DataTypes.INTEGER(11),
allowNull: true,
comment: '响应时间(毫秒)'
},
api_provider: {
type: DataTypes.STRING(50),
allowNull: true,
defaultValue: 'qwen',
comment: 'API提供商'
},
business_type: {
type: DataTypes.STRING(50),
allowNull: true,
comment: '业务类型'
},
reference_id: {
type: DataTypes.STRING(100),
allowNull: true,
comment: '关联业务ID'
},
create_time: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '创建时间'
},
last_modify_time: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'),
comment: '最后修改时间'
},
is_delete: {
type: DataTypes.TINYINT(1),
allowNull: false,
defaultValue: 0,
comment: '是否删除0-否1-是'
}
}, {
tableName: 'ai_call_records',
timestamps: false,
indexes: [
{
name: 'idx_user_id',
fields: ['user_id']
},
{
name: 'idx_sn_code',
fields: ['sn_code']
},
{
name: 'idx_create_time',
fields: ['create_time']
},
{
name: 'idx_is_delete',
fields: ['is_delete']
},
{
name: 'idx_business_type',
fields: ['business_type']
}
]
});
return AiCallRecords;
};

View File

@@ -0,0 +1,97 @@
const Sequelize = require('sequelize');
/**
* 价格套餐表模型
* 存储各种价格套餐的配置信息,支持管理员在后台配置和管理
*/
module.exports = (db) => {
const pricing_plans = db.define("pricing_plans", {
name: {
comment: '套餐名称(如:体验套餐、月度套餐等)',
type: Sequelize.STRING(100),
allowNull: false,
defaultValue: ''
},
duration: {
comment: '时长描述7天、30天、永久',
type: Sequelize.STRING(50),
allowNull: false,
defaultValue: ''
},
days: {
comment: '天数(-1表示永久0表示无限制',
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0
},
price: {
comment: '售价(元)',
type: Sequelize.DECIMAL(10, 2),
allowNull: false,
defaultValue: 0.00
},
original_price: {
comment: '原价(元),可为空表示无原价',
type: Sequelize.DECIMAL(10, 2),
allowNull: true,
defaultValue: null
},
unit: {
comment: '价格单位',
type: Sequelize.STRING(20),
allowNull: false,
defaultValue: '元'
},
discount: {
comment: '折扣描述8.3折、超值)',
type: Sequelize.STRING(50),
allowNull: true,
defaultValue: null
},
features: {
comment: '功能特性列表JSON字符串数组',
type: Sequelize.TEXT,
allowNull: false,
defaultValue: '[]'
},
featured: {
comment: '是否为推荐套餐1=推荐0=普通)',
type: Sequelize.TINYINT(1),
allowNull: false,
defaultValue: 0
},
is_active: {
comment: '是否启用1=启用0=禁用)',
type: Sequelize.TINYINT(1),
allowNull: false,
defaultValue: 1
},
sort_order: {
comment: '排序顺序(越小越靠前)',
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0
},
}, {
timestamps: false,
indexes: [
{
unique: false,
fields: ['is_active']
},
{
unique: false,
fields: ['is_delete']
},
{
unique: false,
fields: ['sort_order']
}
]
});
// pricing_plans.sync({ force: true });
return pricing_plans;
}

View File

@@ -1,168 +0,0 @@
const Framework = require('node-core-framework');
/**
* AI调用记录服务
* 负责记录所有AI API调用的详细信息
*/
class AiCallRecorder {
/**
* 记录AI调用
* @param {Object} params - 调用参数
* @param {Number} params.user_id - 用户ID
* @param {String} params.sn_code - 设备SN码
* @param {String} params.service_type - 服务类型chat/completion/embedding
* @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 - 状态success/failed
* @param {String} params.error_message - 错误信息
* @param {Number} params.response_time - 响应时间(毫秒)
* @param {String} params.api_provider - API提供商
* @param {String} params.business_type - 业务类型
* @param {String} 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.warn('[AI记录] ai_call_records 模型未加载');
return null;
}
const now = new Date();
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 || 'qwen',
business_type: params.business_type || null,
reference_id: params.reference_id || null,
create_time: now,
last_modify_time: now,
is_delete: 0
});
return record;
} catch (error) {
console.error('[AI记录] 记录失败:', error.message);
throw error;
}
}
/**
* 获取用户Token使用统计
* @param {Number} userId - 用户ID
* @param {Object} options - 查询选项
* @param {Date} options.startDate - 开始日期
* @param {Date} options.endDate - 结束日期
* @returns {Promise<Object>} 统计数据
*/
static async getUserTokenStats(userId, options = {}) {
try {
const models = Framework.getModels();
const { ai_call_records } = models;
const { Op } = Framework.getSequelize();
if (!ai_call_records) {
return null;
}
const where = {
user_id: userId,
is_delete: 0
};
if (options.startDate && options.endDate) {
where.create_time = {
[Op.between]: [options.startDate, options.endDate]
};
}
const stats = await ai_call_records.findOne({
where,
attributes: [
[Framework.getSequelize().fn('COUNT', Framework.getSequelize().col('id')), 'total_calls'],
[Framework.getSequelize().fn('SUM', Framework.getSequelize().col('prompt_tokens')), 'total_prompt_tokens'],
[Framework.getSequelize().fn('SUM', Framework.getSequelize().col('completion_tokens')), 'total_completion_tokens'],
[Framework.getSequelize().fn('SUM', Framework.getSequelize().col('total_tokens')), 'total_tokens'],
[Framework.getSequelize().fn('SUM', Framework.getSequelize().col('cost_amount')), 'total_cost'],
[Framework.getSequelize().fn('AVG', Framework.getSequelize().col('response_time')), 'avg_response_time']
],
raw: true
});
return stats;
} catch (error) {
console.error('[AI记录] 获取用户统计失败:', error.message);
throw error;
}
}
/**
* 获取设备Token使用统计
* @param {String} snCode - 设备SN码
* @param {Object} options - 查询选项
* @param {Date} options.startDate - 开始日期
* @param {Date} options.endDate - 结束日期
* @returns {Promise<Object>} 统计数据
*/
static async getDeviceTokenStats(snCode, options = {}) {
try {
const models = Framework.getModels();
const { ai_call_records } = models;
const { Op } = Framework.getSequelize();
if (!ai_call_records) {
return null;
}
const where = {
sn_code: snCode,
is_delete: 0
};
if (options.startDate && options.endDate) {
where.create_time = {
[Op.between]: [options.startDate, options.endDate]
};
}
const stats = await ai_call_records.findOne({
where,
attributes: [
[Framework.getSequelize().fn('COUNT', Framework.getSequelize().col('id')), 'total_calls'],
[Framework.getSequelize().fn('SUM', Framework.getSequelize().col('prompt_tokens')), 'total_prompt_tokens'],
[Framework.getSequelize().fn('SUM', Framework.getSequelize().col('completion_tokens')), 'total_completion_tokens'],
[Framework.getSequelize().fn('SUM', Framework.getSequelize().col('total_tokens')), 'total_tokens'],
[Framework.getSequelize().fn('SUM', Framework.getSequelize().col('cost_amount')), 'total_cost'],
[Framework.getSequelize().fn('AVG', Framework.getSequelize().col('response_time')), 'avg_response_time']
],
raw: true
});
return stats;
} catch (error) {
console.error('[AI记录] 获取设备统计失败:', error.message);
throw error;
}
}
}
module.exports = AiCallRecorder;

View File

@@ -1,393 +1,416 @@
const axios = require('axios');
const config = require('../../config/config');
const AiCallRecorder = require('./ai_call_recorder.js');
/**
* Qwen 2.5 大模型服务
* 集成阿里云 DashScope API提供智能化的岗位筛选、聊天生成、简历分析等功能
* AI智能服务
* 提供岗位筛选、简历分析、聊天生成等AI功能
*/
class aiService {
constructor() {
this.apiKey = config.ai?.apiKey || process.env.DASHSCOPE_API_KEY;
// 使用 DashScope 兼容 OpenAI 格式的接口
this.apiUrl = 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions';
// Qwen 2.5 模型qwen-turbo快速、qwen-plus增强、qwen-max最强
this.model = config.ai?.model || 'qwen-turbo';
this.maxRetries = 3;
const axios = require('axios');
class AIService {
constructor(config = {}) {
this.apiKey = config.apiKey || process.env.AI_API_KEY || '';
this.baseURL = config.baseURL || process.env.AI_BASE_URL || 'https://api.deepseek.com';
this.model = config.model || 'deepseek-chat';
this.timeout = config.timeout || 30000;
// 创建axios实例
this.client = axios.create({
baseURL: this.baseURL,
timeout: this.timeout,
headers: {
'Content-Type': 'application/json',
'Authorization': `${this.apiKey}`
}
});
}
/**
* 调用AI接口
* @param {Array} messages - 消息数组
* @param {Object} options - 额外选项
* @returns {Promise<String>} AI响应内容
*/
async chat(messages, options = {}) {
try {
const response = await this.client.post('/v1/chat/completions', {
model: this.model,
messages,
temperature: options.temperature || 0.7,
max_tokens: options.max_tokens || 2000,
...options
});
return response.data.choices[0].message.content;
} catch (error) {
console.warn('AI服务调用失败:', error.message);
throw new Error(`AI服务调用失败: ${error.message}`);
}
}
/**
* 调用 Qwen 2.5 API
* @param {string} prompt - 提示词
* @param {object} options - 配置选项
* @returns {Promise<object>} API响应结果
*/
async callAPI(prompt, options = {}) {
const startTime = Date.now();
const requestData = {
model: this.model,
messages: [
{
role: 'system',
content: options.systemPrompt || '你是一个专业的招聘顾问,擅长分析岗位信息、生成聊天内容和分析简历匹配度。'
},
{
role: 'user',
content: prompt
}
],
temperature: options.temperature || 0.7,
max_tokens: options.maxTokens || 2000,
top_p: options.topP || 0.9
};
const requestContent = JSON.stringify(requestData.messages);
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
try {
const response = await axios.post(this.apiUrl, requestData, {
headers: {
'Authorization': `Bearer ${this.apiKey}`, // DashScope 使用 Bearer token 格式
'Content-Type': 'application/json'
},
timeout: 30000
});
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 || 'completion',
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: 'qwen',
business_type: options.business_type,
reference_id: options.reference_id
}).catch(err => {
console.warn('记录AI调用失败不影响主流程:', err.message);
});
return {
data: response.data,
content: responseContent
};
} catch (error) {
const responseTime = Date.now() - startTime;
console.log(`Qwen 2.5 API调用失败 (尝试 ${attempt}/${this.maxRetries}): ${error.message}`);
// 记录失败的调用
if (attempt === this.maxRetries) {
this.recordAiCall({
user_id: options.user_id,
sn_code: options.sn_code,
service_type: options.service_type || 'completion',
model_name: this.model,
request_content: requestContent,
status: 'failed',
error_message: error.message,
response_time: responseTime,
api_provider: 'qwen',
business_type: options.business_type,
reference_id: options.reference_id
}).catch(err => {
console.warn('记录失败调用失败:', err.message);
});
throw new Error(error.message);
}
// 等待后重试
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
}
}
}
/**
* 记录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) {
// 阿里云 Qwen 价格(元/1000 tokens
// qwen-turbo: ¥0.003, qwen-plus: ¥0.004, qwen-max: ¥0.12
// 这里使用 qwen-turbo 的价格作为默认值
const pricePerThousand = 0.003;
return (totalTokens / 1000) * pricePerThousand;
}
/**
* 岗位智能筛选
* @param {object} jobInfo - 岗位信息
* @param {object} resumeInfo - 简历信息
* @returns {Promise<object>} 筛选结果
*/
async analyzeJob(jobInfo, resumeInfo) {
const prompt = `
请分析以下岗位信息,并给出详细的评估结果:
岗位信息:
- 公司名称:${jobInfo.companyName || '未知'}
- 职位名称:${jobInfo.jobTitle || '未知'}
- 薪资范围:${jobInfo.salary || '未知'}
- 工作地点:${jobInfo.location || '未知'}
- 岗位描述:${jobInfo.description || '未知'}
- 技能要求:${jobInfo.skills || '未知'}
/**
* 分析简历竞争力
* @param {Object} resumeData - 简历数据
* @returns {Promise<Object>} 分析结果
*/
async analyzeResume(resumeData) {
const prompt = `请分析以下简历的竞争力,并提供详细评估:
简历信息:
- 技能标签:${resumeInfo.skills || '未知'}
- 工作经验:${resumeInfo.experience || '未知'}
- 教育背景${resumeInfo.education || '未知'}
- 期望薪资:${resumeInfo.expectedSalary || '未知'}
- 姓名: ${resumeData.fullName || '未知'}
- 工作年限: ${resumeData.workYears || '未知'}
- 教育背景: ${resumeData.education || '未知'}
- 期望职位: ${resumeData.expectedPosition || '未知'}
- 期望薪资: ${resumeData.expectedSalary || '未知'}
- 技能标签: ${Array.isArray(resumeData.skills) ? resumeData.skills.join('、') : '未知'}
- 工作经历: ${resumeData.workExperience || '未提供'}
- 项目经历: ${resumeData.projectExperience || '未提供'}
请从以下维度进行分析
1. 技能匹配度0-100分
2. 经验匹配度0-100分
3. 薪资合理性0-100分
4. 公司质量评估0-100分
5. 是否为外包岗位(是/否)
6. 综合推荐指数0-100分
7. 详细分析说明
8. 投递建议
请从以下维度进行评估1-100分
1. 技术能力
2. 项目经验
3. 教育背景
4. 工作年限匹配度
5. 综合竞争力
请以JSON格式返回结果。
`;
const result = await this.callAPI(prompt, {
systemPrompt: '你是一个专业的招聘分析师,擅长评估岗位与简历的匹配度。请提供客观、专业的分析结果。',
temperature: 0.3,
business_type: 'job_analysis',
service_type: 'completion'
});
try {
// 尝试解析JSON响应
const analysis = JSON.parse(result.content);
return {
analysis: analysis
};
} catch (parseError) {
// 如果解析失败,返回原始内容
return {
analysis: {
content: result.content,
parseError: true
}
};
}
}
/**
* 生成个性化聊天内容
* @param {object} jobInfo - 岗位信息
* @param {object} resumeInfo - 简历信息
* @param {string} chatType - 聊天类型 (greeting/interview/followup)
* @returns {Promise<object>} 聊天内容
*/
async generateChatContent(jobInfo, resumeInfo, chatType = 'greeting') {
const chatTypeMap = {
'greeting': '初次打招呼',
'interview': '面试邀约',
'followup': '跟进沟通'
};
const prompt = `
请为以下场景生成个性化的聊天内容:
聊天类型:${chatTypeMap[chatType] || chatType}
岗位信息:
- 公司名称:${jobInfo.companyName || '未知'}
- 职位名称:${jobInfo.jobTitle || '未知'}
- 技能要求:${jobInfo.skills || '未知'}
简历信息:
- 技能标签:${resumeInfo.skills || '未知'}
- 工作经验:${resumeInfo.experience || '未知'}
- 项目经验:${resumeInfo.projects || '未知'}
要求:
1. 内容要自然、专业、个性化
2. 突出简历与岗位的匹配点
3. 避免过于机械化的表达
4. 长度控制在100-200字
5. 体现求职者的诚意和热情
请直接返回聊天内容,不需要其他格式。
`;
const result = await this.callAPI(prompt, {
systemPrompt: '你是一个专业的招聘沟通专家,擅长生成自然、专业的求职聊天内容。',
temperature: 0.8,
business_type: 'chat_generation',
service_type: 'chat',
reference_id: jobInfo.jobId || jobInfo.id
});
return result;
}
/**
* 分析简历要素
* @param {string} resumeText - 简历文本内容
* @returns {Promise<object>} 简历分析结果
*/
async analyzeResume(resumeText) {
const prompt = `
请分析以下简历内容,并返回 JSON 格式的分析结果:
简历内容:
${resumeText}
请按以下格式返回 JSON 结果:
返回JSON格式
{
"skillTags": ["技能1", "技能2", "技能3"], // 技能标签数组(编程语言、框架、工具等)
"strengths": "核心优势描述", // 简历的优势和亮点
"weaknesses": "不足之处描述", // 简历的不足或需要改进的地方
"careerSuggestion": "职业发展建议", // 针对该简历的职业发展方向和建议
"competitiveness": 75 // 竞争力评分0-100的整数综合考虑工作年限、技能、经验等因素
"overallScore": 总分(1-100),
"technicalScore": 技术能力分(1-100),
"projectScore": 项目经验分(1-100),
"educationScore": 教育背景分(1-100),
"experienceScore": 工作年限分(1-100),
"strengths": ["优势1", "优势2", "优势3"],
"weaknesses": ["不足1", "不足2"],
"suggestions": ["建议1", "建议2", "建议3"],
"keySkills": ["核心技能1", "核心技能2"],
"marketCompetitiveness": "市场竞争力描述"
}`;
const messages = [
{ role: 'system', content: '你是一个专业的HR和招聘顾问擅长分析简历和评估候选人竞争力。' },
{ role: 'user', content: prompt }
];
try {
const response = await this.chat(messages, { temperature: 0.3 });
// 提取JSON部分
const jsonMatch = response.match(/\{[\s\S]*\}/);
if (jsonMatch) {
return JSON.parse(jsonMatch[0]);
}
throw new Error('AI返回格式不正确');
} catch (error) {
console.warn('简历分析失败:', error);
// 返回默认值
return {
overallScore: 60,
technicalScore: 60,
projectScore: 60,
educationScore: 60,
experienceScore: 60,
strengths: ['待AI分析'],
weaknesses: ['待AI分析'],
suggestions: ['请稍后重试'],
keySkills: Array.isArray(resumeData.skills) ? resumeData.skills.slice(0, 5) : [],
marketCompetitiveness: '待AI分析'
};
}
}
/**
* 岗位匹配度评估
* @param {Object} jobData - 岗位数据
* @param {Object} resumeData - 简历数据
* @returns {Promise<Object>} 匹配结果
*/
async matchJobWithResume(jobData, resumeData) {
const prompt = `请评估以下岗位与简历的匹配度:
【岗位信息】
- 职位名称: ${jobData.jobTitle || '未知'}
- 公司名称: ${jobData.companyName || '未知'}
- 薪资范围: ${jobData.salary || '未知'}
- 工作地点: ${jobData.location || '未知'}
- 工作经验要求: ${jobData.experienceRequired || '未知'}
- 学历要求: ${jobData.educationRequired || '未知'}
- 岗位描述: ${jobData.jobDescription || '未提供'}
- 技能要求: ${jobData.skillsRequired || '未提供'}
【简历信息】
- 工作年限: ${resumeData.workYears || '未知'}
- 教育背景: ${resumeData.education || '未知'}
- 技能标签: ${Array.isArray(resumeData.skills) ? resumeData.skills.join('、') : '未知'}
- 期望职位: ${resumeData.expectedPosition || '未知'}
- 期望薪资: ${resumeData.expectedSalary || '未知'}
请分析:
1. 技能匹配度
2. 经验匹配度
3. 薪资匹配度
4. 是否为外包岗位(根据公司名称、岗位描述判断)
5. 综合推荐度
返回JSON格式
{
"matchScore": 匹配度分数(1-100),
"skillMatch": 技能匹配度(1-100),
"experienceMatch": 经验匹配度(1-100),
"salaryMatch": 薪资匹配度(1-100),
"isOutsourcing": 是否外包(true/false),
"outsourcingConfidence": 外包判断置信度(0-1),
"recommendLevel": "推荐等级(excellent/good/medium/low)",
"matchReasons": ["匹配原因1", "匹配原因2"],
"concerns": ["顾虑点1", "顾虑点2"],
"applyAdvice": "投递建议"
}`;
const messages = [
{ role: 'system', content: '你是一个专业的职业规划师,擅长评估岗位与求职者的匹配度。' },
{ role: 'user', content: prompt }
];
try {
const response = await this.chat(messages, { temperature: 0.3 });
const jsonMatch = response.match(/\{[\s\S]*\}/);
if (jsonMatch) {
return JSON.parse(jsonMatch[0]);
}
throw new Error('AI返回格式不正确');
} catch (error) {
console.warn('岗位匹配分析失败:', error);
// 返回默认值
return {
matchScore: 50,
skillMatch: 50,
experienceMatch: 50,
salaryMatch: 50,
isOutsourcing: false,
outsourcingConfidence: 0,
recommendLevel: 'medium',
matchReasons: ['待AI分析'],
concerns: ['待AI分析'],
applyAdvice: '建议人工审核'
};
}
}
/**
* 批量评估岗位(用于智能筛选)
* @param {Array} jobs - 岗位列表
* @param {Object} resumeData - 简历数据
* @returns {Promise<Array>} 评估结果列表
*/
async batchMatchJobs(jobs, resumeData) {
const results = [];
// 限制并发数量避免API限流
const concurrency = 3;
for (let i = 0; i < jobs.length; i += concurrency) {
const batch = jobs.slice(i, i + concurrency);
const batchPromises = batch.map(job =>
this.matchJobWithResume(job, resumeData).catch(err => {
console.warn(`岗位${job.jobId}匹配失败:`, err.message);
return {
jobId: job.jobId,
matchScore: 0,
error: err.message
};
})
);
const batchResults = await Promise.all(batchPromises);
results.push(...batchResults);
// 避免请求过快,休眠一下
if (i + concurrency < jobs.length) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
return results;
}
/**
* 生成聊天内容
* @param {Object} context - 聊天上下文
* @returns {Promise<String>} 生成的聊天内容
*/
async generateChatContent(context) {
const { jobInfo, resumeInfo, chatType = 'greeting', previousMessages = [] } = context;
let prompt = '';
switch (chatType) {
case 'greeting':
prompt = `作为求职者向HR发送第一条消息表达对以下岗位的兴趣
岗位: ${jobInfo.jobTitle}
公司: ${jobInfo.companyName}
要求: 简洁、专业、突出自己的优势不超过100字`;
break;
case 'follow_up':
prompt = `HR已查看简历但未回复需要发送一条礼貌的跟进消息
岗位: ${jobInfo.jobTitle}
要求: 礼貌、不唐突、展现持续兴趣不超过80字`;
break;
case 'interview_confirm':
prompt = `HR发出面试邀约需要确认并表达感谢
岗位: ${jobInfo.jobTitle}
面试时间: ${context.interviewTime || '待定'}
要求: 专业、感谢、确认参加不超过60字`;
break;
case 'reply':
prompt = `HR说: "${context.hrMessage}"
请作为求职者回复,要求: 自然、专业、回答问题不超过100字`;
break;
default:
prompt = `生成一条针对${jobInfo.jobTitle}岗位的沟通消息`;
}
const messages = [
{ role: 'system', content: '你是一个求职者需要与HR进行专业、礼貌的沟通。回复要简洁、真诚、突出优势。' },
...previousMessages.map(msg => ({
role: msg.role,
content: msg.content
})),
{ role: 'user', content: prompt }
];
try {
const response = await this.chat(messages, { temperature: 0.8, max_tokens: 200 });
return response.trim();
} catch (error) {
console.warn('生成聊天内容失败:', error);
// 返回默认模板
switch (chatType) {
case 'greeting':
return `您好,我对贵司的${jobInfo.jobTitle}岗位非常感兴趣,我有相关工作经验,期待能有机会详细沟通。`;
case 'follow_up':
return `您好,不知道您对我的简历是否感兴趣?期待您的回复。`;
case 'interview_confirm':
return `好的,感谢您的面试邀约,我会准时参加。`;
default:
return `您好,期待与您沟通。`;
}
}
}
/**
* 判断是否为面试邀约
* @param {String} message - HR消息内容
* @returns {Promise<Object>} 判断结果
*/
async detectInterviewInvitation(message) {
const prompt = `判断以下HR消息是否为面试邀约并提取关键信息
消息内容: "${message}"
返回JSON格式
{
"isInterview": 是否为面试邀约(true/false),
"confidence": 置信度(0-1),
"interviewType": "面试类型(phone/video/onsite/unknown)",
"interviewTime": "面试时间(如果提到)",
"interviewLocation": "面试地点(如果提到)",
"needReply": 是否需要回复确认(true/false)
}`;
const messages = [
{ role: 'system', content: '你是一个文本分析专家,擅长识别面试邀约信息。' },
{ role: 'user', content: prompt }
];
try {
const response = await this.chat(messages, { temperature: 0.1 });
const jsonMatch = response.match(/\{[\s\S]*\}/);
if (jsonMatch) {
return JSON.parse(jsonMatch[0]);
}
throw new Error('AI返回格式不正确');
} catch (error) {
console.warn('面试邀约判断失败:', error);
// 简单的关键词判断作为降级方案
const keywords = ['面试', '邀请', '来面', '约您', '见面', '电话沟通', '视频面试'];
const isInterview = keywords.some(kw => message.includes(kw));
return {
isInterview,
confidence: isInterview ? 0.7 : 0.3,
interviewType: 'unknown',
interviewTime: null,
interviewLocation: null,
needReply: isInterview
};
}
}
/**
* 分析HR反馈情感
* @param {String} message - HR消息内容
* @returns {Promise<Object>} 情感分析结果
*/
async analyzeSentiment(message) {
const prompt = `分析以下HR消息的情感倾向
消息: "${message}"
返回JSON格式
{
"sentiment": "情感倾向(positive/neutral/negative)",
"interest": "兴趣程度(high/medium/low)",
"urgency": "紧急程度(high/medium/low)",
"keywords": ["关键词1", "关键词2"]
}`;
const messages = [
{ role: 'system', content: '你是一个情感分析专家。' },
{ role: 'user', content: prompt }
];
try {
const response = await this.chat(messages, { temperature: 0.1 });
const jsonMatch = response.match(/\{[\s\S]*\}/);
if (jsonMatch) {
return JSON.parse(jsonMatch[0]);
}
throw new Error('AI返回格式不正确');
} catch (error) {
console.warn('情感分析失败:', error);
return {
sentiment: 'neutral',
interest: 'medium',
urgency: 'low',
keywords: []
};
}
}
}
要求:
1. skillTags 必须是字符串数组
2. strengths、weaknesses、careerSuggestion 是字符串描述
3. competitiveness 必须是 0-100 之间的整数
4. 所有字段都必须返回,如果没有相关信息,使用空数组或空字符串
`;
// 导出单例
let instance = null;
const result = await this.callAPI(prompt, {
systemPrompt: '你是一个专业的简历分析师,擅长分析简历的核心要素、优势劣势、竞争力评分和职业发展建议。请以 JSON 格式返回分析结果,确保格式正确。',
temperature: 0.3,
maxTokens: 1500,
business_type: 'resume_analysis',
service_type: 'completion'
});
try {
// 尝试从返回内容中提取 JSON
let content = result.content.trim();
// 如果返回内容被代码块包裹,提取其中的 JSON
const jsonMatch = content.match(/```(?:json)?\s*(\{[\s\S]*\})\s*```/) || content.match(/(\{[\s\S]*\})/);
if (jsonMatch) {
content = jsonMatch[1];
}
const analysis = JSON.parse(content);
return {
analysis: analysis
};
} catch (parseError) {
console.error(`[AI服务] 简历分析结果解析失败:`, parseError);
console.error(`[AI服务] 原始内容:`, result.content);
return {
analysis: {
content: result.content,
parseError: true
}
};
}
module.exports = {
/**
* 获取AI服务实例
* @param {Object} config - 配置选项
* @returns {AIService}
*/
getInstance(config) {
if (!instance) {
instance = new AIService(config);
}
return instance;
},
/**
* 生成面试邀约内容
* @param {object} jobInfo - 岗位信息
* @param {object} chatHistory - 聊天历史
* @returns {Promise<object>} 面试邀约内容
*/
async generateInterviewInvitation(jobInfo, chatHistory) {
const prompt = `
请基于以下信息生成面试邀约内容:
/**
* 创建新的AI服务实例
* @param {Object} config - 配置选项
* @returns {AIService}
*/
createInstance(config) {
return new AIService(config);
}
};
岗位信息:
- 公司名称:${jobInfo.companyName || '未知'}
- 职位名称:${jobInfo.jobTitle || '未知'}
- 工作地点:${jobInfo.location || '未知'}
聊天历史:
${chatHistory || '无'}
要求:
1. 表达面试邀约的诚意
2. 提供灵活的时间选择
3. 说明面试形式和地点
4. 体现对候选人的重视
5. 语言自然、专业
请直接返回面试邀约内容。
`;
const result = await this.callAPI(prompt, {
systemPrompt: '你是一个专业的HR擅长生成面试邀约内容。',
temperature: 0.6,
business_type: 'interview_invitation',
service_type: 'chat'
});
return result;
}
/**
* 识别外包岗位
* @param {object} jobInfo - 岗位信息
* @returns {Promise<object>} 外包识别结果
*/
async identifyOutsourcingJob(jobInfo) {
const prompt = `
请分析以下岗位信息,判断是否为外包岗位:
岗位信息:
- 公司名称:${jobInfo.companyName || '未知'}
- 职位名称:${jobInfo.jobTitle || '未知'}
- 岗位描述:${jobInfo.description || '未知'}
- 技能要求:${jobInfo.skills || '未知'}
- 工作地点:${jobInfo.location || '未知'}
外包岗位特征:
1. 公司名称包含"外包"、"派遣"、"人力"等关键词
2. 岗位描述提到"项目外包"、"驻场开发"等
3. 技能要求过于宽泛或具体
4. 工作地点频繁变动
5. 薪资结构不明确
请判断是否为外包岗位,并给出详细分析。
`;
const result = await this.callAPI(prompt, {
systemPrompt: '你是一个专业的岗位分析师,擅长识别外包岗位的特征。',
temperature: 0.3,
business_type: 'outsourcing_detection',
service_type: 'completion'
});
return result;
}
}
module.exports = new aiService();

View File

@@ -1 +1 @@
import{a as t}from"./index-BEa_v6Fs.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-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};

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 name="viewport" content="width=device-width, initial-scale=1.0">
<title>boss - 远程监听服务</title>
<script type="module" crossorigin src="/app/assets/index-BEa_v6Fs.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-BHUtbpCz.css">
<script type="module" crossorigin src="/app/assets/index-CsHwYKwf.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-BUzIVj1g.css">
</head>
<body>

View File

@@ -25,7 +25,7 @@ module.exports = {
acquire: 30000,
idle: 10000
},
logging: false
logging: true
},
// API 路径配置(必需)

View File

@@ -1,145 +0,0 @@
# AI 服务配置说明
## 环境变量配置
AI 服务需要通过环境变量进行配置,支持阿里云 DashScope API。
### 必需的环境变量
`.env` 文件或系统环境变量中配置以下参数:
```bash
# AI API 密钥(阿里云 DashScope API Key
AI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxx
# AI API 基础 URL阿里云 DashScope 兼容 OpenAI 格式接口)
AI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
# AI 模型名称
# 可选值:
# - qwen-turbo快速推荐日常使用
# - qwen-plus增强平衡性能和成本
# - qwen-max最强高质量输出
# - qwen-long长文本支持超长上下文
AI_MODEL=qwen-turbo
```
### 配置示例
#### 1. 开发环境配置 (.env)
```bash
# 阿里云 DashScope 配置
AI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
AI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
AI_MODEL=qwen-turbo
```
#### 2. 生产环境配置 (.env.production)
```bash
# 阿里云 DashScope 配置(生产环境使用增强版)
AI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
AI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
AI_MODEL=qwen-plus
```
### 阿里云模型对比
| 模型 | 速度 | 质量 | 成本 | 适用场景 |
|------|------|------|------|----------|
| qwen-turbo | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | 日常对话、简单分析 |
| qwen-plus | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | 复杂分析、专业任务 |
| qwen-max | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | 高质量输出、关键任务 |
| qwen-long | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | 长文本处理、文档分析 |
### 代码中使用
```javascript
// 使用默认配置(从环境变量读取)
const AIService = require('./services/ai_service.js');
const aiService = AIService.getInstance();
// 使用自定义配置
const aiService = AIService.createInstance({
apiKey: 'sk-custom-key',
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
model: 'qwen-plus',
timeout: 60000
});
```
### API 认证格式
阿里云 DashScope API 使用标准的 Bearer Token 认证:
```
Authorization: Bearer sk-xxxxxxxxxxxxxxxxxxxxx
```
### 注意事项
1. **API Key 安全**
- 不要将 API Key 硬编码在代码中
- 不要将 `.env` 文件提交到版本控制
- 生产环境使用独立的 API Key
2. **模型选择建议**
- 开发/测试:使用 `qwen-turbo`(成本低)
- 生产环境:使用 `qwen-plus`(性能平衡)
- 关键业务:使用 `qwen-max`(质量最高)
3. **速率限制**
- 注意 API 的 QPM每分钟请求数限制
- 根据套餐调整并发数量
- 实现重试和错误处理机制
4. **成本控制**
- 监控 Token 使用量
- 设置合理的 `max_tokens` 限制
- 定期查看账单和用量统计
### 获取 API Key
1. 访问阿里云控制台https://dashscope.console.aliyun.com/
2. 进入 API-KEY 管理
3. 创建新的 API Key
4. 复制 API Key 并保存到环境变量
### 故障排查
#### 问题 1Authentication Fails
```
错误auth header format should be Bearer sk-...
解决:检查 AI_API_KEY 是否正确配置
```
#### 问题 2连接超时
```
错误timeout of 30000ms exceeded
解决:
1. 检查网络连接
2. 增加 timeout 配置
3. 检查 AI_BASE_URL 是否正确
```
#### 问题 3模型不存在
```
错误model not found
解决:检查 AI_MODEL 配置,确保使用支持的模型名称
```
### 迁移指南
如果之前使用其他 AI 服务(如 DeepSeek迁移步骤
1. 更新环境变量配置
2. 修改 API_BASE_URL
3. 更新模型名称
4. 测试 AI 调用功能
5. 验证响应格式
---
**配置更新时间**: 2025-12-27
**维护者**: 系统管理员

View File

@@ -1,364 +0,0 @@
# AI 服务统一说明
**更新时间**: 2025-12-27
---
## 统一后的 AI 服务架构
系统已完成 AI 服务的统一整理,现在只保留一个标准的 AI 服务实现。
---
## 文件位置
### ✅ 保留的文件(唯一 AI 服务实现)
**核心服务:**
- **`api/services/ai_service.js`** - AI 服务主文件(基于阿里云 Qwen 2.5
- **`api/services/ai_call_recorder.js`** - AI 调用记录服务
**导出管理:**
- **`api/services/index.js`** - 服务统一导出
**数据库层:**
- **`api/model/ai_call_records.js`** - AI 调用记录模型
**后台管理:**
- **`api/controller_admin/ai_call_records.js`** - 后台管理 API
**前端界面:**
- **`admin/src/views/system/ai_call_records.vue`** - 管理界面
- **`admin/src/api/system/ai_call_records_server.js`** - API 服务
### ❌ 已删除的文件
- ~~`api/middleware/job/aiService.js`~~ - 已删除(内容已迁移到 `services/ai_service.js`
---
## 使用方式
### 1. 直接引用(推荐)
```javascript
const aiService = require('./services/ai_service');
// 使用 AI 服务
const result = await aiService.analyzeJob(jobInfo, resumeInfo);
```
### 2. 通过服务管理器
```javascript
const { AIService } = require('./services');
// 使用 AI 服务
const result = await AIService.analyzeJob(jobInfo, resumeInfo);
```
---
## AI 服务功能列表
### 核心方法
| 方法 | 说明 | 业务类型 |
|------|------|---------|
| `callAPI(prompt, options)` | 基础 API 调用 | 自定义 |
| `analyzeJob(jobInfo, resumeInfo)` | 岗位智能筛选 | `job_analysis` |
| `generateChatContent(jobInfo, resumeInfo, chatType)` | 生成个性化聊天 | `chat_generation` |
| `analyzeResume(resumeText)` | 简历分析 | `resume_analysis` |
| `generateInterviewInvitation(jobInfo, chatHistory)` | 生成面试邀约 | `interview_invitation` |
| `identifyOutsourcingJob(jobInfo)` | 识别外包岗位 | `outsourcing_detection` |
### 辅助方法
| 方法 | 说明 |
|------|------|
| `recordAiCall(params)` | 记录 AI 调用 |
| `calculateCost(totalTokens)` | 计算调用费用 |
---
## Token 自动记录
所有通过 `callAPI()` 方法的调用都会自动记录以下信息:
- **Token 使用量**prompt_tokens, completion_tokens, total_tokens
- **成本信息**:基于模型计算的费用
- **性能指标**:响应时间(毫秒)
- **状态跟踪**:成功/失败状态
- **业务关联**business_type, reference_id
- **请求追踪**:完整的请求和响应内容
记录过程是异步非阻塞的,不会影响 AI 调用的主流程。
---
## 配置说明
### 环境变量
`.env` 文件中配置:
```bash
# 阿里云 DashScope API Key
AI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxx
# 模型选择(可选)
AI_MODEL=qwen-turbo # qwen-turbo, qwen-plus, qwen-max, qwen-long
```
### 代码配置
```javascript
// 在 config/config.js 中
module.exports = {
ai: {
apiKey: process.env.AI_API_KEY,
model: process.env.AI_MODEL || 'qwen-turbo'
}
}
```
---
## 模型选择
| 模型 | 速度 | 质量 | 成本 | 价格(元/1000 tokens|
|------|------|------|------|---------------------|
| qwen-turbo | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ¥0.003 |
| qwen-plus | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ¥0.004 |
| qwen-max | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ¥0.12 |
| qwen-long | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | - |
---
## 使用示例
### 示例 1岗位分析
```javascript
const aiService = require('./services/ai_service');
const jobInfo = {
companyName: '阿里巴巴',
jobTitle: 'Node.js 高级工程师',
salary: '30-50K',
location: '杭州',
description: '负责后端服务开发...',
skills: 'Node.js, MySQL, Redis'
};
const resumeInfo = {
skills: 'Node.js, JavaScript, MySQL',
experience: '5年后端开发经验',
education: '本科',
expectedSalary: '35K'
};
const result = await aiService.analyzeJob(jobInfo, resumeInfo);
console.log(result.analysis);
```
### 示例 2生成聊天内容
```javascript
const result = await aiService.generateChatContent(
jobInfo,
resumeInfo,
'greeting' // greeting, interview, followup
);
console.log(result.content);
```
### 示例 3简历分析
```javascript
const resumeText = `
姓名:张三
技能Node.js, React, MySQL
工作经验3年全栈开发
...
`;
const result = await aiService.analyzeResume(resumeText);
console.log(result.analysis);
// {
// skillTags: ['Node.js', 'React', 'MySQL'],
// strengths: '...',
// weaknesses: '...',
// careerSuggestion: '...',
// competitiveness: 75
// }
```
### 示例 4自定义 AI 调用
```javascript
const result = await aiService.callAPI(
'请帮我分析这个岗位的发展前景...',
{
systemPrompt: '你是一个职业规划专家...',
temperature: 0.7,
maxTokens: 2000,
user_id: 123,
business_type: 'career_analysis'
}
);
console.log(result.content);
```
---
## 错误处理
AI 服务内置了重试机制(最多 3 次)和错误处理:
```javascript
try {
const result = await aiService.analyzeJob(jobInfo, resumeInfo);
} catch (error) {
console.error('AI 调用失败:', error.message);
// 错误会自动记录到 ai_call_records 表
}
```
---
## 监控与统计
### 查看调用记录
登录后台管理系统:**系统设置** → **AI调用记录**
### 统计信息
- 总调用次数
- Token 总使用量
- 总费用统计
- 平均响应时间
- 成功率
### 编程方式获取统计
```javascript
const AiCallRecorder = require('./services/ai_call_recorder');
// 获取用户统计
const userStats = await AiCallRecorder.getUserTokenStats(userId, {
startDate: new Date('2025-01-01'),
endDate: new Date('2025-12-31')
});
console.log(userStats);
// {
// total_calls: 100,
// total_prompt_tokens: 5000,
// total_completion_tokens: 3000,
// total_tokens: 8000,
// total_cost: 24.00,
// avg_response_time: 1500
// }
```
---
## 注意事项
### 1. API Key 安全
- ❌ 不要将 API Key 硬编码在代码中
- ❌ 不要将 `.env` 文件提交到版本控制
- ✅ 使用环境变量管理 API Key
- ✅ 生产环境使用独立的 API Key
### 2. 成本控制
- 选择合适的模型(开发用 turbo生产用 plus
- 设置合理的 `maxTokens` 限制
- 监控 Token 使用量
- 定期查看费用统计
### 3. 性能优化
- 重试机制已内置3 次)
- 超时设置为 30 秒
- Token 记录是异步的,不阻塞主流程
### 4. 数据隐私
- 请求和响应内容会完整记录到数据库
- 注意敏感信息的处理
- 定期清理历史记录
---
## 迁移指南
如果你的代码之前引用了 `middleware/job/aiService.js`,请修改为:
```javascript
// ❌ 旧代码
const aiService = require('../middleware/job/aiService');
// ✅ 新代码
const aiService = require('../services/ai_service');
```
功能保持完全一致,只是路径发生了变化。
---
## 故障排查
### 问题 1模型未加载
**错误信息:** `Cannot read property 'findAll' of undefined`
**解决方法:**
1. 确认已执行建表 SQL`_sql/create_ai_call_records_table.sql`
2. 重启 Node.js 服务
3. 检查 `api/model/ai_call_records.js` 是否存在
### 问题 2认证失败
**错误信息:** `auth header format should be Bearer sk-...`
**解决方法:**
1. 检查 `.env` 文件中的 `AI_API_KEY`
2. 确认 API Key 格式正确(以 `sk-` 开头)
3. 验证 API Key 有效性
### 问题 3记录失败
**警告信息:** `记录AI调用失败不影响主流程`
**解决方法:**
1. 检查数据库连接
2. 确认 `ai_call_records` 表存在
3. 查看详细错误日志
### 问题 4费用计算不准确
**解决方法:**
1. 检查 `calculateCost()` 方法中的价格配置
2. 根据实际使用的模型调整价格
3. 定期对账单进行核对
---
## 相关文档
- [AI 服务配置说明](ai_service_config.md) - 详细的环境配置指南
- [功能实施总结](implementation_summary.md) - 完整的功能实施文档
- [API 文档](../api/controller_admin/ai_call_records.js) - 后台 API 接口说明
---
**文档版本**: 1.0
**最后更新**: 2025-12-27
**维护者**: 开发团队

View File

@@ -1,561 +0,0 @@
# 自动找工作系统 - 功能实施总结
**更新时间**: 2025-12-27
---
## 已完成功能概览
本文档总结了最近完成的两个主要功能模块:
1. **价格套餐管理系统**
2. **AI调用记录与Token追踪系统**
---
## 一、价格套餐管理系统
### 功能描述
将原本硬编码在 `api/controller_front/config.js` 中的价格套餐数据迁移到数据库,并提供完整的后台管理界面。
### 实施文件清单
#### 数据库层
-`_sql/create_pricing_plans_table.sql` - 数据表创建脚本
-`_sql/insert_pricing_plans_data.sql` - 初始数据插入脚本
-`api/model/pricing_plans.js` - Sequelize 数据模型
#### 后端API层
-`api/controller_admin/pricing_plans.js` - 后台管理API5个端点
- `POST /pricing_plans/list` - 分页查询
- `GET /pricing_plans/detail` - 获取详情
- `POST /pricing_plans/create` - 创建套餐
- `POST /pricing_plans/update` - 更新套餐
- `POST /pricing_plans/delete` - 删除套餐(软删除)
-`api/controller_front/config.js` (修改第90-136行) - 前端API改为数据库查询
#### 前端层
-`admin/src/api/system/pricing_plans_server.js` - API服务层
-`admin/src/views/system/pricing_plans.vue` - 管理界面组件
-`admin/src/router/component-map.js` (新增映射) - 组件注册
### 数据库表结构
```sql
CREATE TABLE `pricing_plans` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`name` VARCHAR(100) NOT NULL COMMENT '套餐名称',
`duration` VARCHAR(50) NOT NULL COMMENT '时长描述',
`days` INT(11) NOT NULL COMMENT '天数(-1表示永久',
`price` DECIMAL(10,2) NOT NULL COMMENT '价格',
`original_price` DECIMAL(10,2) NULL COMMENT '原价',
`unit` VARCHAR(20) NOT NULL DEFAULT '',
`discount` VARCHAR(50) NULL COMMENT '折扣描述',
`features` TEXT NOT NULL COMMENT '功能列表JSON格式',
`featured` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否推荐',
`is_active` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
`sort_order` INT(11) NOT NULL DEFAULT 0 COMMENT '排序顺序',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`last_modify_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`is_delete` TINYINT(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
### 初始数据
系统预置了4个价格套餐
1. **体验套餐** - 7天 - ¥28
2. **月度套餐** - 30天 - ¥99推荐
3. **季度套餐** - 90天 - ¥269
4. **终生套餐** - 永久 - ¥888
### 菜单位置
**用户管理****价格套餐管理**
### 关键实现细节
#### 前端接口向后兼容
`GET /api/config/pricing-plans` 接口保持原有响应格式:
```javascript
[
{
"id": 1,
"name": "体验套餐",
"duration": "7天",
"days": 7,
"price": 28,
"originalPrice": 28,
"unit": "元",
"features": ["7天使用权限", "全功能体验", "技术支持"],
"featured": false
}
]
```
#### 状态控制逻辑
- 前端API仅返回 `is_active = 1``is_delete = 0` 的套餐
- 按照 `sort_order` ASC, `id` ASC 排序
- Features字段从JSON字符串自动解析为数组
#### 表单组件修复
修复了单选按钮组件使用方式(重要):
```javascript
// ❌ 错误写法
{
title: '是否推荐',
key: 'featured',
type: 'radio', // 错误
options: [...]
}
// ✅ 正确写法
{
title: '是否推荐',
key: 'featured',
com: 'Radio', // 正确
options: [
{ value: 1, label: '推荐' },
{ value: 0, label: '普通' }
]
}
```
---
## 二、AI调用记录与Token追踪系统
### 功能描述
为所有AI调用添加自动记录功能追踪Token使用量、调用成本、响应时间等关键指标并提供后台管理和统计分析界面。
### 实施文件清单
#### 数据库层
-`_sql/create_ai_call_records_table.sql` - 数据表创建脚本
-`api/model/ai_call_records.js` - Sequelize 数据模型
#### 服务层
-`api/services/ai_call_recorder.js` - AI调用记录服务
-`api/services/ai_service.js` - AI服务集成Token记录
#### 后端API层
-`api/controller_admin/ai_call_records.js` - 后台管理API5个端点
- `POST /ai_call_records/list` - 分页查询
- `GET /ai_call_records/detail` - 获取详情
- `GET /ai_call_records/stats` - 统计分析
- `POST /ai_call_records/delete` - 删除记录
- `POST /ai_call_records/batch_delete` - 批量删除
#### 前端层
-`admin/src/api/system/ai_call_records_server.js` - API服务层
-`admin/src/views/system/ai_call_records.vue` - 管理界面组件
-`admin/src/router/component-map.js` (新增映射) - 组件注册
### 数据库表结构
```sql
CREATE TABLE `ai_call_records` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`user_id` INT(11) NULL COMMENT '用户ID',
`sn_code` VARCHAR(100) NULL COMMENT '设备SN码',
`service_type` VARCHAR(50) NOT NULL COMMENT '服务类型chat/completion/embedding',
`model_name` VARCHAR(100) NOT NULL COMMENT '模型名称',
`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 '请求内容',
`response_content` TEXT NULL COMMENT '响应内容',
`cost_amount` DECIMAL(10,4) NULL COMMENT '费用(元)',
`status` VARCHAR(20) NOT NULL DEFAULT 'success' COMMENT '状态success/failed',
`error_message` TEXT NULL COMMENT '错误信息',
`response_time` INT(11) NULL COMMENT '响应时间(毫秒)',
`api_provider` VARCHAR(50) NULL DEFAULT 'qwen' COMMENT 'API提供商',
`business_type` VARCHAR(50) NULL COMMENT '业务类型',
`reference_id` VARCHAR(100) NULL COMMENT '关联业务ID',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`last_modify_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`is_delete` TINYINT(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
### AI服务集成
#### 使用的AI服务
系统使用 **阿里云DashScope API (Qwen 2.5)**而非DeepSeek。
**关键配置:**
```javascript
// api/services/ai_service.js
class aiService {
constructor() {
this.apiKey = config.ai?.apiKey || process.env.DASHSCOPE_API_KEY;
this.apiUrl = 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions';
this.model = config.ai?.model || 'qwen-turbo';
this.maxRetries = 3;
}
}
```
#### 环境变量配置
`.env` 文件中配置:
```bash
AI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxx
AI_MODEL=qwen-turbo # 可选qwen-turbo, qwen-plus, qwen-max, qwen-long
```
详细配置说明见:[docs/ai_service_config.md](ai_service_config.md:1)
#### Token记录集成
AI服务的 `callAPI()` 方法自动记录所有调用:
```javascript
async callAPI(prompt, options = {}) {
const startTime = Date.now();
// ... 调用API ...
// 成功时自动记录Token使用量
this.recordAiCall({
user_id: options.user_id,
sn_code: options.sn_code,
service_type: options.service_type || 'completion',
model_name: this.model,
prompt_tokens: usage.prompt_tokens || 0,
completion_tokens: usage.completion_tokens || 0,
total_tokens: usage.total_tokens || 0,
cost_amount: this.calculateCost(usage.total_tokens || 0),
status: 'success',
response_time: responseTime,
api_provider: 'qwen',
business_type: options.business_type,
reference_id: options.reference_id
}).catch(err => {
console.warn('记录AI调用失败不影响主流程:', err.message);
});
}
```
#### 业务类型分类
系统中的AI调用按业务类型分类
| 业务类型 | 说明 | 调用方法 |
|---------|------|---------|
| `job_analysis` | 岗位分析 | `analyzeJob()` |
| `chat_generation` | 聊天内容生成 | `generateChatContent()` |
| `resume_analysis` | 简历分析 | `analyzeResume()` |
| `interview_invitation` | 面试邀约 | `generateInterviewInvitation()` |
| `outsourcing_detection` | 外包检测 | `identifyOutsourcingJob()` |
#### 成本计算
基于Qwen模型定价
```javascript
calculateCost(totalTokens) {
// qwen-turbo: ¥0.003/1000 tokens
// qwen-plus: ¥0.004/1000 tokens
// qwen-max: ¥0.12/1000 tokens
const pricePerThousand = 0.003;
return (totalTokens / 1000) * pricePerThousand;
}
```
### 管理界面功能
#### 筛选功能
- 按用户ID搜索
- 按设备SN码搜索
- 按业务类型筛选
- 按服务类型筛选
- 按状态筛选
- 按时间范围筛选
#### 统计功能
- 总调用次数
- Token总使用量输入/输出/总计)
- 总费用统计
- 平均响应时间
- 成功率统计
#### 操作功能
- 查看详情(完整请求/响应内容)
- 单条删除
- 批量删除
### 菜单位置
**系统设置****AI调用记录**
---
## 三、关键技术点总结
### 1. 组件注册规范
前端Vue组件必须在 `admin/src/router/component-map.js` 中注册:
```javascript
// 导入组件
import PricingPlans from '@/views/system/pricing_plans.vue'
import AiCallRecords from '@/views/system/ai_call_records.vue'
// 注册映射
const componentMap = {
'system/pricing_plans': PricingPlans,
'system/ai_call_records': AiCallRecords,
}
```
### 2. 表单控件使用规范
使用 `com` 字段而非 `type` 字段:
```javascript
// 单选按钮
{ com: 'Radio', options: [...] }
// 文本输入框
{ com: 'Input' }
// 文本域
{ com: 'TextArea' }
// 数字输入框
{ com: 'InputNumber' }
// 下拉选择
{ com: 'Select', options: [...] }
```
### 3. 数据库软删除模式
所有查询必须包含 `is_delete = 0` 条件:
```javascript
const list = await model.findAll({
where: {
is_delete: 0,
// ... 其他条件
}
});
```
### 4. 时间戳管理
使用手动管理而非Sequelize自动管理
```javascript
// 模型定义
{
timestamps: false, // 禁用自动时间戳
// 手动定义时间字段
create_time: { type: DataTypes.DATE },
last_modify_time: { type: DataTypes.DATE }
}
// 创建时手动设置
const now = new Date();
await model.create({
// ... 其他字段
create_time: now,
last_modify_time: now
});
```
### 5. JSON字段处理
数据库存储为TEXT类型应用层处理JSON序列化
```javascript
// 保存时
const data = {
features: JSON.stringify(['功能1', '功能2'])
};
// 读取时
const features = JSON.parse(record.features || '[]');
```
### 6. 异步记录模式
日志/记录类操作使用异步非阻塞模式:
```javascript
// 使用 .catch() 而非 try-catch避免阻塞主流程
this.recordAiCall(params).catch(err => {
console.warn('记录失败(不影响主流程):', err.message);
});
// 主流程继续执行
return result;
```
---
## 四、部署检查清单
### 数据库层
- [ ] 执行 `create_pricing_plans_table.sql`
- [ ] 执行 `insert_pricing_plans_data.sql`
- [ ] 执行 `create_ai_call_records_table.sql`
- [ ] 验证表创建成功
### 后端层
- [ ] 重启Node.js服务以加载新模型
- [ ] 验证模型加载:`Framework.getModels()`
- [ ] 配置环境变量 `AI_API_KEY``AI_MODEL`
- [ ] 测试后台API端点
### 前端层
- [ ] 重新编译前端代码
- [ ] 验证组件注册成功
- [ ] 刷新浏览器缓存
- [ ] 测试管理界面功能
### 菜单系统
- [ ] 验证"价格套餐管理"菜单显示
- [ ] 验证"AI调用记录"菜单显示
- [ ] 测试菜单跳转功能
### 功能测试
- [ ] 价格套餐CRUD操作
- [ ] 前端API `/config/pricing-plans` 返回数据库数据
- [ ] AI调用自动记录Token
- [ ] AI调用记录管理界面
- [ ] 统计功能准确性
---
## 五、已知问题与注意事项
### 1. 组件热更新
修改组件映射后需要:
- 重启前端开发服务器
- 清除浏览器缓存
- 刷新页面
### 2. AI服务配置
**重要**系统使用阿里云DashScope API不是DeepSeek。
必须配置正确的环境变量:
```bash
AI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxx # 阿里云API Key
AI_MODEL=qwen-turbo # Qwen模型
```
### 3. Token记录异步
Token记录失败不会影响AI调用主流程但会打印警告日志。如果需要确保记录成功应检查数据库连接和表结构。
### 4. 成本计算
当前成本计算使用固定价格qwen-turbo: ¥0.003/1000 tokens如果切换到其他模型需要修改 `calculateCost()` 方法。
### 5. 数据备份
AI调用记录表会快速增长建议
- 定期归档历史数据
- 设置数据保留策略如只保留最近90天
- 建立定期备份机制
---
## 六、文件路径索引
### 价格套餐系统
**数据库**
- `_sql/create_pricing_plans_table.sql`
- `_sql/insert_pricing_plans_data.sql`
**后端**
- `api/model/pricing_plans.js`
- `api/controller_admin/pricing_plans.js`
- `api/controller_front/config.js` (修改第90-136行)
**前端**
- `admin/src/api/system/pricing_plans_server.js`
- `admin/src/views/system/pricing_plans.vue`
### AI调用记录系统
**数据库**
- `_sql/create_ai_call_records_table.sql`
**后端**
- `api/model/ai_call_records.js`
- `api/services/ai_call_recorder.js`
- `api/services/ai_service.js` (完全重写)
- `api/controller_admin/ai_call_records.js`
**前端**
- `admin/src/api/system/ai_call_records_server.js`
- `admin/src/views/system/ai_call_records.vue`
### 公共配置
- `admin/src/router/component-map.js` (新增两个组件映射)
- `config/config.js` (AI配置项)
- `.env` (环境变量)
### 文档
- `docs/ai_service_config.md` (AI服务配置说明)
- `docs/implementation_summary.md` (本文档)
---
## 七、维护建议
### 日常维护
1. **监控Token使用量**
- 定期查看AI调用记录统计
- 关注异常高额调用
- 优化高频调用场景
2. **价格套餐调整**
- 根据市场情况调整价格
- 通过后台界面快速上下架套餐
- 使用排序功能控制显示顺序
3. **数据清理**
- 定期归档或删除历史AI调用记录
- 清理已删除的套餐数据(物理删除)
### 性能优化
1. **数据库索引**
- AI调用记录表按需添加索引user_id, sn_code, create_time
- 定期分析查询性能
2. **缓存策略**
- 考虑对前端API `/config/pricing-plans` 添加缓存
- 缓存有效期建议5-10分钟
3. **日志归档**
- 建立AI调用记录的归档机制
- 超过一定时间的数据转移到历史表
---
**文档版本**: 1.0
**最后更新**: 2025-12-27
**维护者**: 开发团队

View File

@@ -0,0 +1,138 @@
/**
* 添加"价格套餐管理"菜单项到用户管理菜单下
* 执行 SQL 插入操作
*/
const Framework = require('../framework/node-core-framework.js');
const frameworkConfig = require('../config/framework.config.js');
async function addPricingPlansMenu() {
console.log('🔄 开始添加"价格套餐管理"菜单项...\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 [userMenu] = await sequelize.query(
`SELECT id FROM sys_menu WHERE name = '用户管理' AND parent_id = 0 AND is_delete = 0 LIMIT 1`,
{ type: Sequelize.QueryTypes.SELECT }
);
let parentId = 120; // 默认 fallback 值
if (userMenu && userMenu.id) {
parentId = userMenu.id;
console.log(`找到用户管理菜单ID: ${parentId}`);
} else {
console.log(`未找到用户管理菜单,使用默认 parent_id: ${parentId}`);
}
// 检查是否已存在
const [existing] = await sequelize.query(
`SELECT id, name FROM sys_menu WHERE path = 'pricing_plans' 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 (
'价格套餐管理',
${parentId},
0,
0,
'md-pricetags',
'pricing_plans',
'system/pricing_plans.vue',
'system/pricing_plans_server.js',
1,
1,
'页面',
${nextSort},
NOW(),
NOW(),
0
)`,
{ type: Sequelize.QueryTypes.INSERT }
);
console.log('✅ "价格套餐管理"菜单项添加成功!\n');
// 验证插入结果
const [menu] = await sequelize.query(
`SELECT id, name, parent_id, path, component, api_path, sort
FROM sys_menu
WHERE path = 'pricing_plans' 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;
}
}
// 执行添加
addPricingPlansMenu()
.then(() => {
console.log('✨ 操作完成!');
process.exit(0);
})
.catch(error => {
console.error('\n💥 执行失败:', error);
process.exit(1);
});

View File

@@ -0,0 +1,135 @@
/**
* 迁移现有价格套餐数据到数据库
* 将 config.js 中硬编码的 4 个套餐数据导入到 pricing_plans 表
*/
const Framework = require('../framework/node-core-framework.js');
const frameworkConfig = require('../config/framework.config.js');
async function migratePricingPlans() {
console.log('🔄 开始迁移价格套餐数据...\n');
try {
// 初始化框架
console.log('正在初始化框架...');
const framework = await Framework.init(frameworkConfig);
const models = Framework.getModels();
if (!models || !models.pricing_plans) {
throw new Error('无法获取 pricing_plans 模型');
}
const { pricing_plans } = models;
// 检查是否已有数据
const existingCount = await pricing_plans.count({ where: { is_delete: 0 } });
if (existingCount > 0) {
console.log(`⚠️ 已存在 ${existingCount} 条套餐数据,跳过迁移\n`);
return;
}
// 现有的4个套餐数据来自 api/controller_front/config.js
const plans = [
{
name: '体验套餐',
duration: '7天',
days: 7,
price: 28.00,
original_price: 28.00,
unit: '元',
discount: null,
features: JSON.stringify(['7天使用权限', '全功能体验', '技术支持']),
featured: 0,
is_active: 1,
sort_order: 1,
is_delete: 0,
create_time: new Date(),
last_modify_time: new Date()
},
{
name: '月度套餐',
duration: '30天',
days: 30,
price: 99.00,
original_price: 120.00,
unit: '元',
discount: '约8.3折',
features: JSON.stringify(['30天使用权限', '全功能使用', '优先技术支持', '性价比最高']),
featured: 1,
is_active: 1,
sort_order: 2,
is_delete: 0,
create_time: new Date(),
last_modify_time: new Date()
},
{
name: '季度套餐',
duration: '90天',
days: 90,
price: 269.00,
original_price: 360.00,
unit: '元',
discount: '7.5折',
features: JSON.stringify(['90天使用权限', '全功能使用', '优先技术支持', '更优惠价格']),
featured: 0,
is_active: 1,
sort_order: 3,
is_delete: 0,
create_time: new Date(),
last_modify_time: new Date()
},
{
name: '终生套餐',
duration: '永久',
days: -1,
price: 888.00,
original_price: null,
unit: '元',
discount: '超值',
features: JSON.stringify(['永久使用权限', '全功能使用', '终身技术支持', '一次购买,终身使用', '最划算选择']),
featured: 0,
is_active: 1,
sort_order: 4,
is_delete: 0,
create_time: new Date(),
last_modify_time: new Date()
}
];
// 批量插入
await pricing_plans.bulkCreate(plans);
console.log('✅ 成功迁移 4 条价格套餐数据!\n');
// 验证插入结果
const result = await pricing_plans.findAll({
where: { is_delete: 0 },
order: [['sort_order', 'ASC']]
});
console.log('📋 已迁移的套餐:');
result.forEach(plan => {
const planData = plan.toJSON();
console.log(` ${planData.id}. ${planData.name} - ${planData.duration} - ¥${planData.price}`);
if (planData.featured === 1) {
console.log(` [推荐套餐]`);
}
});
console.log('');
} catch (error) {
console.error('❌ 迁移失败:', error.message);
console.error('\n详细错误:', error);
throw error;
}
}
// 执行迁移
migratePricingPlans()
.then(() => {
console.log('✨ 迁移完成!');
process.exit(0);
})
.catch(error => {
console.error('\n💥 执行失败:', error);
process.exit(1);
});