Compare commits
17 Commits
68b4db0aee
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2786202212 | ||
|
|
517a320627 | ||
|
|
eed08a30fb | ||
|
|
1d02702e96 | ||
|
|
547f1eaec2 | ||
|
|
43382668a3 | ||
|
|
6e38ba6b38 | ||
|
|
b17d08ffa8 | ||
|
|
54644dbb72 | ||
|
|
1d8d2ea6e8 | ||
|
|
3f4acc5e1d | ||
|
|
69f2f87f4b | ||
|
|
0cfff98edf | ||
|
|
77789446f3 | ||
|
|
2530f25b86 | ||
|
|
6efd77d2b5 | ||
|
|
6253abc617 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,4 +3,5 @@ logs/
|
|||||||
node_modules.*
|
node_modules.*
|
||||||
dist.zip
|
dist.zip
|
||||||
dist/
|
dist/
|
||||||
admin/node_modules/
|
admin/node_modules/
|
||||||
|
app/
|
||||||
529
_doc/搜索列表和投递功能开发规划.md
Normal file
529
_doc/搜索列表和投递功能开发规划.md
Normal 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` - 账号数据模型
|
||||||
|
|
||||||
36
_sql/add_pricing_plans_menu.sql
Normal file
36
_sql/add_pricing_plans_menu.sql
Normal 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=未删除
|
||||||
|
);
|
||||||
24
_sql/create_pricing_plans_table.sql
Normal file
24
_sql/create_pricing_plans_table.sql
Normal 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='价格套餐表';
|
||||||
89
_sql/insert_pricing_plans_data.sql
Normal file
89
_sql/insert_pricing_plans_data.sql
Normal 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;
|
||||||
@@ -26,11 +26,11 @@ const baseConfig = {
|
|||||||
// 开发环境配置
|
// 开发环境配置
|
||||||
const developmentConfig = {
|
const developmentConfig = {
|
||||||
...baseConfig,
|
...baseConfig,
|
||||||
apiUrl: 'http://localhost:9097/admin_api/',
|
// apiUrl: 'http://localhost:9097/admin_api/',
|
||||||
uploadUrl: 'http://localhost:9097/admin_api/upload',
|
// uploadUrl: 'http://localhost:9097/admin_api/upload',
|
||||||
|
|
||||||
// apiUrl: 'https://work.light120.com/admin_api/',
|
apiUrl: 'https://work.light120.com/admin_api/',
|
||||||
// uploadUrl: 'https://work.light120.com/admin_api/upload',
|
uploadUrl: 'https://work.light120.com/admin_api/upload',
|
||||||
// 开发环境显示更多调试信息
|
// 开发环境显示更多调试信息
|
||||||
debug: true
|
debug: true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,15 @@ class ResumeInfoServer {
|
|||||||
analyzeWithAI(resumeId) {
|
analyzeWithAI(resumeId) {
|
||||||
return window.framework.http.post('/resume/analyze-with-ai', { 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()
|
export default new ResumeInfoServer()
|
||||||
|
|||||||
54
admin/src/api/system/pricing_plans_server.js
Normal file
54
admin/src/api/system/pricing_plans_server.js
Normal 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()
|
||||||
9
admin/src/framework/node-core-framework.js
Normal file
9
admin/src/framework/node-core-framework.js
Normal file
File diff suppressed because one or more lines are too long
@@ -21,7 +21,7 @@ import TaskStatus from '@/views/task/task_status.vue'
|
|||||||
import SystemConfig from '@/views/system/system_config.vue'
|
import SystemConfig from '@/views/system/system_config.vue'
|
||||||
import Version from '@/views/system/version.vue'
|
import Version from '@/views/system/version.vue'
|
||||||
import JobTypes from '@/views/work/job_types.vue'
|
import JobTypes from '@/views/work/job_types.vue'
|
||||||
import AiCallRecords from '@/views/system/ai_call_records.vue'
|
import PricingPlans from '@/views/system/pricing_plans.vue'
|
||||||
|
|
||||||
// 首页模块
|
// 首页模块
|
||||||
import HomeIndex from '@/views/home/index.vue'
|
import HomeIndex from '@/views/home/index.vue'
|
||||||
@@ -54,7 +54,8 @@ const componentMap = {
|
|||||||
'system/system_config': SystemConfig,
|
'system/system_config': SystemConfig,
|
||||||
'system/version': Version,
|
'system/version': Version,
|
||||||
'work/job_types': JobTypes,
|
'work/job_types': JobTypes,
|
||||||
'system/ai_call_records': AiCallRecords,
|
'system/pricing_plans': PricingPlans,
|
||||||
|
'system/pricing_plans.vue': PricingPlans,
|
||||||
'home/index': HomeIndex,
|
'home/index': HomeIndex,
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -349,12 +349,12 @@ export default {
|
|||||||
{ title: '密码', key: 'pwd', com: 'Password', required: true },
|
{ title: '密码', key: 'pwd', com: 'Password', required: true },
|
||||||
],
|
],
|
||||||
listColumns: [
|
listColumns: [
|
||||||
{ title: 'ID', key: 'id' },
|
{ title: '账户名', key: 'name', minWidth: 120 },
|
||||||
{ title: '账户名', key: 'name' },
|
{ title: '设备SN码', key: 'sn_code', minWidth: 150 },
|
||||||
{ title: '设备SN码', key: 'sn_code'},
|
|
||||||
{
|
{
|
||||||
title: '平台',
|
title: '平台',
|
||||||
key: 'platform_type',
|
key: 'platform_type',
|
||||||
|
minWidth: 100,
|
||||||
render: (h, params) => {
|
render: (h, params) => {
|
||||||
const platformMap = {
|
const platformMap = {
|
||||||
'boss': { text: 'Boss直聘', color: 'blue' },
|
'boss': { text: 'Boss直聘', color: 'blue' },
|
||||||
@@ -364,26 +364,10 @@ export default {
|
|||||||
return h('Tag', { props: { color: platform.color } }, platform.text)
|
return h('Tag', { props: { color: platform.color } }, platform.text)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ title: '登录名', key: 'login_name'},
|
|
||||||
{ title: '搜索关键词', key: 'keyword' },
|
|
||||||
{ title: '用户地址', key: 'user_address' },
|
|
||||||
{
|
|
||||||
title: '经纬度',
|
|
||||||
key: 'location',
|
|
||||||
|
|
||||||
render: (h, params) => {
|
|
||||||
const lon = params.row.user_longitude;
|
|
||||||
const lat = params.row.user_latitude;
|
|
||||||
if (lon && lat) {
|
|
||||||
return h('span', `${lat}, ${lon}`)
|
|
||||||
}
|
|
||||||
return h('span', { style: { color: '#999' } }, '未设置')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '在线状态',
|
title: '在线状态',
|
||||||
key: 'is_online',
|
key: 'is_online',
|
||||||
|
minWidth: 90,
|
||||||
render: (h, params) => {
|
render: (h, params) => {
|
||||||
return h('Tag', {
|
return h('Tag', {
|
||||||
props: { color: params.row.is_online ? 'success' : 'default' }
|
props: { color: params.row.is_online ? 'success' : 'default' }
|
||||||
@@ -393,12 +377,7 @@ export default {
|
|||||||
{
|
{
|
||||||
title: '自动投递',
|
title: '自动投递',
|
||||||
key: 'auto_deliver',
|
key: 'auto_deliver',
|
||||||
com: "Radio",
|
minWidth: 90,
|
||||||
|
|
||||||
options: [
|
|
||||||
{ value: 1, label: '开启' },
|
|
||||||
{ value: 0, label: '关闭' }
|
|
||||||
],
|
|
||||||
render: (h, params) => {
|
render: (h, params) => {
|
||||||
return h('Tag', {
|
return h('Tag', {
|
||||||
props: { color: params.row.auto_deliver ? 'success' : 'default' }
|
props: { color: params.row.auto_deliver ? 'success' : 'default' }
|
||||||
@@ -408,11 +387,7 @@ export default {
|
|||||||
{
|
{
|
||||||
title: '自动沟通',
|
title: '自动沟通',
|
||||||
key: 'auto_chat',
|
key: 'auto_chat',
|
||||||
"com": "Radio",
|
minWidth: 90,
|
||||||
options: [
|
|
||||||
{ value: 1, label: '开启' },
|
|
||||||
{ value: 0, label: '关闭' }
|
|
||||||
],
|
|
||||||
render: (h, params) => {
|
render: (h, params) => {
|
||||||
return h('Tag', {
|
return h('Tag', {
|
||||||
props: { color: params.row.auto_chat ? 'success' : 'default' }
|
props: { color: params.row.auto_chat ? 'success' : 'default' }
|
||||||
@@ -422,7 +397,7 @@ export default {
|
|||||||
{
|
{
|
||||||
title: '剩余天数',
|
title: '剩余天数',
|
||||||
key: 'remaining_days',
|
key: 'remaining_days',
|
||||||
|
minWidth: 100,
|
||||||
render: (h, params) => {
|
render: (h, params) => {
|
||||||
const remainingDays = params.row.remaining_days || 0
|
const remainingDays = params.row.remaining_days || 0
|
||||||
let color = 'success'
|
let color = 'success'
|
||||||
@@ -436,52 +411,10 @@ export default {
|
|||||||
}, remainingDays > 0 ? `${remainingDays} 天` : '已过期')
|
}, remainingDays > 0 ? `${remainingDays} 天` : '已过期')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: '授权日期',
|
|
||||||
key: 'authorization_date',
|
|
||||||
|
|
||||||
render: (h, params) => {
|
|
||||||
if (!params.row.authorization_date) {
|
|
||||||
return h('span', { style: { color: '#999' } }, '未授权')
|
|
||||||
}
|
|
||||||
const date = new Date(params.row.authorization_date)
|
|
||||||
return h('span', this.formatDate(date))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '过期时间',
|
|
||||||
key: 'expire_date',
|
|
||||||
|
|
||||||
render: (h, params) => {
|
|
||||||
if (!params.row.authorization_date || !params.row.authorization_days) {
|
|
||||||
return h('span', { style: { color: '#999' } }, '未授权')
|
|
||||||
}
|
|
||||||
const authDate = new Date(params.row.authorization_date)
|
|
||||||
const expireDate = new Date(authDate.getTime() + params.row.authorization_days * 24 * 60 * 60 * 1000)
|
|
||||||
const remainingDays = params.row.remaining_days || 0
|
|
||||||
return h('span', {
|
|
||||||
style: { color: remainingDays <= 0 ? '#ed4014' : remainingDays <= 7 ? '#ff9900' : '#515a6e' }
|
|
||||||
}, this.formatDate(expireDate))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '自动活跃',
|
|
||||||
key: 'auto_active',
|
|
||||||
"com": "Radio",
|
|
||||||
options: [
|
|
||||||
{ value: 1, label: '开启' },
|
|
||||||
{ value: 0, label: '关闭' }
|
|
||||||
],
|
|
||||||
|
|
||||||
render: (h, params) => {
|
|
||||||
return h('Tag', {
|
|
||||||
props: { color: params.row.auto_active ? 'success' : 'default' }
|
|
||||||
}, params.row.auto_active ? '开启' : '关闭')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '启用状态',
|
title: '启用状态',
|
||||||
key: 'is_enabled',
|
key: 'is_enabled',
|
||||||
|
minWidth: 100,
|
||||||
render: (h, params) => {
|
render: (h, params) => {
|
||||||
return h('i-switch', {
|
return h('i-switch', {
|
||||||
props: {
|
props: {
|
||||||
@@ -496,7 +429,6 @@ export default {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ title: '创建时间', key: 'create_time', },
|
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
key: 'action',
|
key: 'action',
|
||||||
@@ -640,18 +572,21 @@ export default {
|
|||||||
}
|
}
|
||||||
this.query(1)
|
this.query(1)
|
||||||
},
|
},
|
||||||
async handleSaveSuccess({ data }) {
|
async handleSaveSuccess({ data, isEdit } = {}) {
|
||||||
try {
|
try {
|
||||||
// 如果是新增(来自 editModal),data 只包含必填字段,直接保存
|
// 如果是新增(来自 editModal),data 只包含必填字段,直接保存
|
||||||
if (data && !data.id) {
|
if (data && !data.id && !isEdit) {
|
||||||
await plaAccountServer.add(data)
|
await plaAccountServer.add(data)
|
||||||
this.$Message.success('保存成功!')
|
this.$Message.success('保存成功!')
|
||||||
}
|
}
|
||||||
// 编辑时由 FloatPanel 组件(PlaAccountEdit)处理保存,这里只刷新列表
|
// 编辑时由 FloatPanel 组件(PlaAccountEdit)处理保存,这里只刷新列表
|
||||||
this.query(this.gridOption.param.pageOption.page)
|
// 刷新列表,保持当前页码
|
||||||
|
this.query(this.gridOption.param.pageOption.page || 1)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('保存失败:', error)
|
console.error('保存失败:', error)
|
||||||
this.$Message.error('保存失败:' + (error.message || '请稍后重试'))
|
// 优先从 error.response.data.message 获取,然后是 error.message
|
||||||
|
const errorMsg = error.response?.data?.message || error.message || '请稍后重试'
|
||||||
|
this.$Message.error('保存失败:' + errorMsg)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// 显示账号详情
|
// 显示账号详情
|
||||||
|
|||||||
@@ -23,8 +23,10 @@
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
<FormItem label="平台" prop="platform_type">
|
<FormItem label="平台" prop="platform_type">
|
||||||
<Select v-model="formData.platform_type" placeholder="请选择平台">
|
<Select v-model="formData.platform_type" placeholder="请选择平台">
|
||||||
<Option value="1">Boss直聘</Option>
|
<Option value="boss">Boss直聘</Option>
|
||||||
<Option value="2">猎聘</Option>
|
<Option value="liepin">猎聘</Option>
|
||||||
|
<Option value="zhipin">智联招聘</Option>
|
||||||
|
<Option value="58">58同城</Option>
|
||||||
</Select>
|
</Select>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
<FormItem label="登录名" prop="login_name">
|
<FormItem label="登录名" prop="login_name">
|
||||||
@@ -545,7 +547,8 @@ export default {
|
|||||||
|
|
||||||
this.$Message.success('保存成功!')
|
this.$Message.success('保存成功!')
|
||||||
this.$refs.floatPanel.hide()
|
this.$refs.floatPanel.hide()
|
||||||
this.$emit('on-save')
|
// 触发保存成功事件,通知父组件刷新列表
|
||||||
|
this.$emit('on-save', { isEdit: this.isEdit, data: saveData })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('保存失败:', error)
|
console.error('保存失败:', error)
|
||||||
this.$Message.error('保存失败:' + (error.message || '请稍后重试'))
|
this.$Message.error('保存失败:' + (error.message || '请稍后重试'))
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
@back="handleBack"
|
@back="handleBack"
|
||||||
>
|
>
|
||||||
<template #header-right>
|
<template #header-right>
|
||||||
|
<Button type="info" @click="handleSyncOnline" :loading="syncing" style="margin-right: 8px;">同步在线简历</Button>
|
||||||
<Button type="primary" @click="handleAnalyzeAI" :loading="analyzing">AI 分析</Button>
|
<Button type="primary" @click="handleAnalyzeAI" :loading="analyzing">AI 分析</Button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -272,6 +273,7 @@ export default {
|
|||||||
return {
|
return {
|
||||||
loading: false,
|
loading: false,
|
||||||
analyzing: false,
|
analyzing: false,
|
||||||
|
syncing: false,
|
||||||
resumeData: null,
|
resumeData: null,
|
||||||
skillTags: [],
|
skillTags: [],
|
||||||
workExperience: [],
|
workExperience: [],
|
||||||
@@ -317,6 +319,32 @@ export default {
|
|||||||
}
|
}
|
||||||
return field
|
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() {
|
async handleAnalyzeAI() {
|
||||||
if (!this.resumeData || !this.resumeData.resumeId) {
|
if (!this.resumeData || !this.resumeData.resumeId) {
|
||||||
this.$Message.warning('简历ID不存在')
|
this.$Message.warning('简历ID不存在')
|
||||||
|
|||||||
392
admin/src/views/system/pricing_plans.vue
Normal file
392
admin/src/views/system/pricing_plans.vue
Normal 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>
|
||||||
455
api/controller_admin/pricing_plans.js
Normal file
455
api/controller_admin/pricing_plans.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -358,6 +358,100 @@ return ctx.success({ message: '简历删除成功' });
|
|||||||
console.error('AI 分析失败:', error);
|
console.error('AI 分析失败:', error);
|
||||||
return ctx.fail('AI 分析失败: ' + error.message);
|
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 || '未知错误'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,20 @@ module.exports = {
|
|||||||
where.feedbackStatus = seachOption.feedbackStatus;
|
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) {
|
if (seachOption.key && seachOption.value) {
|
||||||
const key = seachOption.key;
|
const key = seachOption.key;
|
||||||
@@ -93,7 +107,7 @@ module.exports = {
|
|||||||
where,
|
where,
|
||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
order: [['applyTime', 'DESC']]
|
order: [['create_time', 'DESC']]
|
||||||
});
|
});
|
||||||
|
|
||||||
return ctx.success({
|
return ctx.success({
|
||||||
@@ -109,7 +123,7 @@ module.exports = {
|
|||||||
* /api/apply/statistics:
|
* /api/apply/statistics:
|
||||||
* get:
|
* get:
|
||||||
* summary: 获取投递统计
|
* summary: 获取投递统计
|
||||||
* description: 根据设备SN码获取投递统计数据(包含今日、本周、本月统计)
|
* description: 根据设备SN码获取投递统计数据(包含今日、本周、本月统计),支持时间范围筛选
|
||||||
* tags: [前端-投递管理]
|
* tags: [前端-投递管理]
|
||||||
* parameters:
|
* parameters:
|
||||||
* - in: query
|
* - in: query
|
||||||
@@ -118,21 +132,50 @@ module.exports = {
|
|||||||
* schema:
|
* schema:
|
||||||
* type: string
|
* type: string
|
||||||
* description: 设备SN码
|
* 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:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: 获取成功
|
* description: 获取成功
|
||||||
*/
|
*/
|
||||||
'GET /apply/statistics': async (ctx) => {
|
'POST /apply/statistics': async (ctx) => {
|
||||||
const models = Framework.getModels();
|
const models = Framework.getModels();
|
||||||
const { apply_records, op } = models;
|
const { apply_records, op, job_postings } = models;
|
||||||
const { sn_code } = ctx.query;
|
const { sn_code, startTime, endTime } = ctx.getBody();
|
||||||
|
console.log(startTime, endTime);
|
||||||
const final_sn_code = sn_code;
|
const final_sn_code = sn_code;
|
||||||
|
|
||||||
if (!final_sn_code) {
|
if (!final_sn_code) {
|
||||||
return ctx.fail('请提供设备SN码');
|
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();
|
const now = new Date();
|
||||||
|
|
||||||
// 今天的开始时间(00:00:00)
|
// 今天的开始时间(00:00:00)
|
||||||
@@ -150,6 +193,8 @@ module.exports = {
|
|||||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
monthStart.setHours(0, 0, 0, 0);
|
monthStart.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const [
|
const [
|
||||||
totalCount,
|
totalCount,
|
||||||
successCount,
|
successCount,
|
||||||
@@ -158,35 +203,44 @@ module.exports = {
|
|||||||
interviewCount,
|
interviewCount,
|
||||||
todayCount,
|
todayCount,
|
||||||
weekCount,
|
weekCount,
|
||||||
monthCount
|
monthCount,
|
||||||
|
totalJobCount
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
// 总计
|
// 总计(如果提供了时间范围,则只统计该范围内的)
|
||||||
apply_records.count({ where: { sn_code: final_sn_code } }),
|
apply_records.count({ where: baseWhere }),
|
||||||
apply_records.count({ where: { sn_code: final_sn_code, applyStatus: 'success' } }),
|
apply_records.count({ where: { ...baseWhere, applyStatus: 'success' } }),
|
||||||
apply_records.count({ where: { sn_code: final_sn_code, applyStatus: 'failed' } }),
|
apply_records.count({ where: { ...baseWhere, applyStatus: 'failed' } }),
|
||||||
apply_records.count({ where: { sn_code: final_sn_code, applyStatus: 'pending' } }),
|
apply_records.count({ where: { ...baseWhere, applyStatus: 'pending' } }),
|
||||||
apply_records.count({ where: { sn_code: final_sn_code, feedbackStatus: 'interview' } }),
|
apply_records.count({ where: { ...baseWhere, feedbackStatus: 'interview' } }),
|
||||||
// 今日
|
|
||||||
apply_records.count({
|
// 今日(如果提供了时间范围,则返回0,否则统计今日)
|
||||||
|
startTime || endTime ? 0 : apply_records.count({
|
||||||
where: {
|
where: {
|
||||||
sn_code: final_sn_code,
|
sn_code: final_sn_code,
|
||||||
applyTime: { [op.gte]: todayStart }
|
create_time: { [op.gte]: todayStart }
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
// 本周
|
// 本周(如果提供了时间范围,则返回0,否则统计本周)
|
||||||
apply_records.count({
|
startTime || endTime ? 0 : apply_records.count({
|
||||||
where: {
|
where: {
|
||||||
sn_code: final_sn_code,
|
sn_code: final_sn_code,
|
||||||
applyTime: { [op.gte]: weekStart }
|
create_time: { [op.gte]: weekStart }
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
// 本月
|
// 本月(如果提供了时间范围,则返回0,否则统计本月)
|
||||||
apply_records.count({
|
startTime || endTime ? 0 : apply_records.count({
|
||||||
where: {
|
where: {
|
||||||
sn_code: final_sn_code,
|
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({
|
return ctx.success({
|
||||||
@@ -198,6 +252,7 @@ module.exports = {
|
|||||||
todayCount,
|
todayCount,
|
||||||
weekCount,
|
weekCount,
|
||||||
monthCount,
|
monthCount,
|
||||||
|
totalJobCount,
|
||||||
successRate: totalCount > 0 ? ((successCount / totalCount) * 100).toFixed(2) : 0,
|
successRate: totalCount > 0 ? ((successCount / totalCount) * 100).toFixed(2) : 0,
|
||||||
interviewRate: totalCount > 0 ? ((interviewCount / 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({
|
const records = await apply_records.findAll({
|
||||||
where: {
|
where: {
|
||||||
sn_code: sn_code,
|
sn_code: sn_code,
|
||||||
applyTime: {
|
create_time: {
|
||||||
[op.gte]: sevenDaysAgo,
|
[op.gte]: sevenDaysAgo,
|
||||||
[op.lte]: today
|
[op.lte]: today
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
attributes: ['applyTime'],
|
attributes: ['create_time'],
|
||||||
raw: true
|
raw: true
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -299,7 +354,7 @@ module.exports = {
|
|||||||
|
|
||||||
// 统计当天的投递数量
|
// 统计当天的投递数量
|
||||||
const count = records.filter(record => {
|
const count = records.filter(record => {
|
||||||
const recordDate = new Date(record.applyTime);
|
const recordDate = new Date(record.create_time);
|
||||||
recordDate.setHours(0, 0, 0, 0);
|
recordDate.setHours(0, 0, 0, 0);
|
||||||
return recordDate.getTime() === date.getTime();
|
return recordDate.getTime() === date.getTime();
|
||||||
}).length;
|
}).length;
|
||||||
|
|||||||
@@ -88,84 +88,47 @@ module.exports = {
|
|||||||
* description: 获取成功
|
* description: 获取成功
|
||||||
*/
|
*/
|
||||||
'GET /config/pricing-plans': async (ctx) => {
|
'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);
|
const models = Framework.getModels();
|
||||||
} catch (error) {
|
const { pricing_plans } = models;
|
||||||
console.error('获取价格套餐失败:', error);
|
|
||||||
return ctx.fail('获取价格套餐失败: ' + error.message);
|
// 查询所有启用且未删除的套餐,按排序顺序返回
|
||||||
}
|
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);
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
307
api/middleware/job/aiService.js
Normal file
307
api/middleware/job/aiService.js
Normal 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();
|
||||||
@@ -54,8 +54,8 @@ class ScheduleManager {
|
|||||||
console.log('[调度管理器] 心跳监听已启动');
|
console.log('[调度管理器] 心跳监听已启动');
|
||||||
|
|
||||||
// 5. 启动定时任务
|
// 5. 启动定时任务
|
||||||
// this.scheduledJobs.start();
|
this.scheduledJobs.start();
|
||||||
// console.log('[调度管理器] 定时任务已启动');
|
console.log('[调度管理器] 定时任务已启动');
|
||||||
|
|
||||||
this.isInitialized = true;
|
this.isInitialized = true;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
};
|
|
||||||
97
api/model/pricing_plans.js
Normal file
97
api/model/pricing_plans.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
@@ -1,393 +1,416 @@
|
|||||||
const axios = require('axios');
|
|
||||||
const config = require('../../config/config');
|
|
||||||
const AiCallRecorder = require('./ai_call_recorder.js');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Qwen 2.5 大模型服务
|
* AI智能服务
|
||||||
* 集成阿里云 DashScope API,提供智能化的岗位筛选、聊天生成、简历分析等功能
|
* 提供岗位筛选、简历分析、聊天生成等AI功能
|
||||||
*/
|
*/
|
||||||
class aiService {
|
|
||||||
constructor() {
|
const axios = require('axios');
|
||||||
this.apiKey = config.ai?.apiKey || process.env.DASHSCOPE_API_KEY;
|
|
||||||
// 使用 DashScope 兼容 OpenAI 格式的接口
|
class AIService {
|
||||||
this.apiUrl = 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions';
|
constructor(config = {}) {
|
||||||
// Qwen 2.5 模型:qwen-turbo(快速)、qwen-plus(增强)、qwen-max(最强)
|
this.apiKey = config.apiKey || process.env.AI_API_KEY || '';
|
||||||
this.model = config.ai?.model || 'qwen-turbo';
|
this.baseURL = config.baseURL || process.env.AI_BASE_URL || 'https://api.deepseek.com';
|
||||||
this.maxRetries = 3;
|
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} resumeData - 简历数据
|
||||||
* @param {object} options - 配置选项
|
* @returns {Promise<Object>} 分析结果
|
||||||
* @returns {Promise<object>} API响应结果
|
*/
|
||||||
*/
|
async analyzeResume(resumeData) {
|
||||||
async callAPI(prompt, options = {}) {
|
const prompt = `请分析以下简历的竞争力,并提供详细评估:
|
||||||
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 || '未知'}
|
|
||||||
|
|
||||||
简历信息:
|
简历信息:
|
||||||
- 技能标签:${resumeInfo.skills || '未知'}
|
- 姓名: ${resumeData.fullName || '未知'}
|
||||||
- 工作经验:${resumeInfo.experience || '未知'}
|
- 工作年限: ${resumeData.workYears || '未知'}
|
||||||
- 教育背景:${resumeInfo.education || '未知'}
|
- 教育背景: ${resumeData.education || '未知'}
|
||||||
- 期望薪资:${resumeInfo.expectedSalary || '未知'}
|
- 期望职位: ${resumeData.expectedPosition || '未知'}
|
||||||
|
- 期望薪资: ${resumeData.expectedSalary || '未知'}
|
||||||
|
- 技能标签: ${Array.isArray(resumeData.skills) ? resumeData.skills.join('、') : '未知'}
|
||||||
|
- 工作经历: ${resumeData.workExperience || '未提供'}
|
||||||
|
- 项目经历: ${resumeData.projectExperience || '未提供'}
|
||||||
|
|
||||||
请从以下维度进行分析:
|
请从以下维度进行评估(1-100分):
|
||||||
1. 技能匹配度(0-100分)
|
1. 技术能力
|
||||||
2. 经验匹配度(0-100分)
|
2. 项目经验
|
||||||
3. 薪资合理性(0-100分)
|
3. 教育背景
|
||||||
4. 公司质量评估(0-100分)
|
4. 工作年限匹配度
|
||||||
5. 是否为外包岗位(是/否)
|
5. 综合竞争力
|
||||||
6. 综合推荐指数(0-100分)
|
|
||||||
7. 详细分析说明
|
|
||||||
8. 投递建议
|
|
||||||
|
|
||||||
请以JSON格式返回结果。
|
返回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 结果:
|
|
||||||
{
|
{
|
||||||
"skillTags": ["技能1", "技能2", "技能3"], // 技能标签数组(编程语言、框架、工具等)
|
"overallScore": 总分(1-100),
|
||||||
"strengths": "核心优势描述", // 简历的优势和亮点
|
"technicalScore": 技术能力分(1-100),
|
||||||
"weaknesses": "不足之处描述", // 简历的不足或需要改进的地方
|
"projectScore": 项目经验分(1-100),
|
||||||
"careerSuggestion": "职业发展建议", // 针对该简历的职业发展方向和建议
|
"educationScore": 教育背景分(1-100),
|
||||||
"competitiveness": 75 // 竞争力评分(0-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 必须是字符串数组
|
let instance = null;
|
||||||
2. strengths、weaknesses、careerSuggestion 是字符串描述
|
|
||||||
3. competitiveness 必须是 0-100 之间的整数
|
|
||||||
4. 所有字段都必须返回,如果没有相关信息,使用空数组或空字符串
|
|
||||||
`;
|
|
||||||
|
|
||||||
const result = await this.callAPI(prompt, {
|
module.exports = {
|
||||||
systemPrompt: '你是一个专业的简历分析师,擅长分析简历的核心要素、优势劣势、竞争力评分和职业发展建议。请以 JSON 格式返回分析结果,确保格式正确。',
|
/**
|
||||||
temperature: 0.3,
|
* 获取AI服务实例
|
||||||
maxTokens: 1500,
|
* @param {Object} config - 配置选项
|
||||||
business_type: 'resume_analysis',
|
* @returns {AIService}
|
||||||
service_type: 'completion'
|
*/
|
||||||
});
|
getInstance(config) {
|
||||||
|
if (!instance) {
|
||||||
try {
|
instance = new AIService(config);
|
||||||
// 尝试从返回内容中提取 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
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return instance;
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成面试邀约内容
|
* 创建新的AI服务实例
|
||||||
* @param {object} jobInfo - 岗位信息
|
* @param {Object} config - 配置选项
|
||||||
* @param {object} chatHistory - 聊天历史
|
* @returns {AIService}
|
||||||
* @returns {Promise<object>} 面试邀约内容
|
*/
|
||||||
*/
|
createInstance(config) {
|
||||||
async generateInterviewInvitation(jobInfo, chatHistory) {
|
return new AIService(config);
|
||||||
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,
|
|
||||||
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();
|
|
||||||
|
|||||||
@@ -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
1
app/assets/index-BUzIVj1g.css
Normal file
1
app/assets/index-BUzIVj1g.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,8 +5,8 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>boss - 远程监听服务</title>
|
<title>boss - 远程监听服务</title>
|
||||||
<script type="module" crossorigin src="/app/assets/index-BEa_v6Fs.js"></script>
|
<script type="module" crossorigin src="/app/assets/index-CsHwYKwf.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/app/assets/index-BHUtbpCz.css">
|
<link rel="stylesheet" crossorigin href="/app/assets/index-BUzIVj1g.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ module.exports = {
|
|||||||
acquire: 30000,
|
acquire: 30000,
|
||||||
idle: 10000
|
idle: 10000
|
||||||
},
|
},
|
||||||
logging: false
|
logging: true
|
||||||
},
|
},
|
||||||
|
|
||||||
// API 路径配置(必需)
|
// API 路径配置(必需)
|
||||||
|
|||||||
@@ -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 并保存到环境变量
|
|
||||||
|
|
||||||
### 故障排查
|
|
||||||
|
|
||||||
#### 问题 1:Authentication 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
|
|
||||||
**维护者**: 系统管理员
|
|
||||||
@@ -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
|
|
||||||
**维护者**: 开发团队
|
|
||||||
@@ -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` - 后台管理API(5个端点)
|
|
||||||
- `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` - 后台管理API(5个端点)
|
|
||||||
- `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
|
|
||||||
**维护者**: 开发团队
|
|
||||||
138
scripts/add_pricing_plans_menu.js
Normal file
138
scripts/add_pricing_plans_menu.js
Normal 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);
|
||||||
|
});
|
||||||
135
scripts/migrate_pricing_plans_data.js
Normal file
135
scripts/migrate_pricing_plans_data.js
Normal 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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user