Compare commits
3 Commits
dev2
...
9ab749f0f3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ab749f0f3 | ||
|
|
63ae655b34 | ||
|
|
68b4db0aee |
@@ -10,17 +10,7 @@
|
||||
"Bash(mkdir:*)",
|
||||
"Bash(findstr:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(npm run restart:*)",
|
||||
"Bash(del scheduledJobs.js)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(wc:*)",
|
||||
"Bash(for:*)",
|
||||
"Bash(done)",
|
||||
"Bash(npm start)",
|
||||
"Bash(timeout 10 npm start)",
|
||||
"Bash(timeout 15 npm start)",
|
||||
"Bash(del apiservicesconfigaiConfig.js)",
|
||||
"Bash(grep:*)"
|
||||
"Bash(npm run restart:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,5 +4,4 @@ node_modules.*
|
||||
dist.zip
|
||||
dist/
|
||||
admin/node_modules/
|
||||
app/*
|
||||
app/*
|
||||
app/
|
||||
@@ -18,75 +18,6 @@
|
||||
- 投递状态跟踪
|
||||
- 投递记录管理
|
||||
|
||||
## 📊 Boss直聘响应数据结构
|
||||
|
||||
### 响应格式示例
|
||||
|
||||
Boss直聘搜索职位列表的响应数据结构如下:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "Success",
|
||||
"zpData": {
|
||||
"resCount": 450, // 搜索结果总数
|
||||
"hasMore": true, // 是否还有更多
|
||||
"totalCount": 300, // 总职位数
|
||||
"jobList": [ // 职位列表
|
||||
{
|
||||
"encryptJobId": "5ae70dfe114c23ab0nR-2ti-FFpU", // 职位ID(投递必需)
|
||||
"encryptBossId": "b55854108ac215180XZ62N-_FlNT", // Boss ID(投递必需)
|
||||
"securityId": "HP23zbQfaslvy-c1...", // 安全ID(投递必需)
|
||||
"jobName": "全栈软件工程师", // 职位名称
|
||||
"salaryDesc": "25-50K·19薪", // 薪资描述(需解析)
|
||||
"jobExperience": "在校/应届", // 工作经验(需解析)
|
||||
"jobDegree": "学历不限", // 学历要求
|
||||
"city": 101020100, // 城市代码
|
||||
"cityName": "上海", // 城市名称
|
||||
"areaDistrict": "长宁区", // 区域
|
||||
"businessDistrict": "新华路", // 商圈
|
||||
"gps": { // 位置信息(优先使用)
|
||||
"longitude": 121.41902537687392,
|
||||
"latitude": 31.210308153576566
|
||||
},
|
||||
"encryptBrandId": "d283b66de3cefd891H1529q5Flc~", // 公司ID
|
||||
"brandName": "上海大裂谷智能科技", // 公司名称
|
||||
"brandScaleName": "100-499人", // 公司规模
|
||||
"brandIndustry": "人工智能", // 公司行业
|
||||
"brandStageName": "天使轮", // 融资阶段
|
||||
"bossName": "杨明雨", // Boss姓名
|
||||
"bossTitle": "HR", // Boss职位
|
||||
"bossOnline": true, // Boss是否在线
|
||||
"jobLabels": ["在校/应届", "学历不限"], // 职位标签
|
||||
"skills": [], // 技能要求
|
||||
"welfareList": ["带薪年假", "五险一金"], // 福利列表
|
||||
"proxyJob": 0, // 是否外包(0否1是)
|
||||
"industry": 100028 // 行业代码
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 关键字段说明
|
||||
|
||||
1. **投递必需字段**:
|
||||
- `encryptJobId`: 职位ID,投递时必须
|
||||
- `encryptBossId`: Boss ID,投递时必须
|
||||
- `securityId`: 安全ID,投递时必须(每次搜索可能不同)
|
||||
|
||||
2. **位置信息**:
|
||||
- `gps.longitude` 和 `gps.latitude`: 直接使用,无需调用位置服务API
|
||||
- 如果没有gps字段,再使用 `cityName + areaDistrict + businessDistrict + brandName` 调用位置服务
|
||||
|
||||
3. **薪资解析**:
|
||||
- `salaryDesc` 格式多样:`"25-50K·19薪"`、`"20-30K"`、`"面议"` 等
|
||||
- 需要解析出 `salaryMin` 和 `salaryMax`(单位:元)
|
||||
|
||||
4. **工作年限解析**:
|
||||
- `jobExperience` 可能为:`"在校/应届"`、`"3-5年"`、`"1-3年"` 等
|
||||
- 需要解析出 `experienceMin` 和 `experienceMax`
|
||||
|
||||
## 📊 功能架构
|
||||
|
||||
```
|
||||
@@ -313,81 +244,13 @@ async createDeliverTask(params) {
|
||||
|
||||
**任务内容**:
|
||||
1. 完善字段映射(从Boss直聘响应数据提取更多字段)
|
||||
2. 优化位置解析(优先使用响应中的gps字段,减少API调用)
|
||||
3. 解析薪资范围(从salaryDesc提取min/max)
|
||||
4. 解析工作年限(从jobExperience提取min/max)
|
||||
5. 添加职位状态管理
|
||||
6. 添加职位匹配度字段
|
||||
|
||||
**Boss直聘响应数据字段映射表**:
|
||||
|
||||
| 响应字段 | 数据库字段 | 说明 | 示例值 |
|
||||
|---------|-----------|------|--------|
|
||||
| `encryptJobId` | `jobId` | 职位ID(加密) | "5ae70dfe114c23ab0nR-2ti-FFpU" |
|
||||
| `jobName` | `jobTitle` | 职位名称 | "全栈软件工程师" |
|
||||
| `encryptBrandId` | `companyId` | 公司ID(加密) | "d283b66de3cefd891H1529q5Flc~" |
|
||||
| `brandName` | `companyName` | 公司名称 | "上海大裂谷智能科技" |
|
||||
| `brandScaleName` | `companySize` | 公司规模 | "100-499人" |
|
||||
| `brandIndustry` | `companyIndustry` | 公司行业 | "人工智能" |
|
||||
| `brandStageName` | `brandStage` | 融资阶段 | "天使轮" |
|
||||
| `salaryDesc` | `salary` | 薪资描述 | "25-50K·19薪" |
|
||||
| `salaryDesc` | `salaryMin`, `salaryMax` | 薪资范围(需解析) | 25000, 50000 |
|
||||
| `jobExperience` | `experience` | 工作经验 | "在校/应届" |
|
||||
| `jobExperience` | `experienceMin`, `experienceMax` | 工作年限范围(需解析) | - |
|
||||
| `jobDegree` | `education` | 学历要求 | "学历不限" |
|
||||
| `jobDegree` | `educationLevel` | 学历等级(需映射) | - |
|
||||
| `city` | `city` | 城市代码 | 101020100 |
|
||||
| `cityName` | `cityName` | 城市名称 | "上海" |
|
||||
| `areaDistrict` | `areaDistrict` | 区域 | "长宁区" |
|
||||
| `businessDistrict` | `businessDistrict` | 商圈 | "新华路" |
|
||||
| `gps.longitude` | `longitude` | 经度(优先使用) | 121.41902537687392 |
|
||||
| `gps.latitude` | `latitude` | 纬度(优先使用) | 31.210308153576566 |
|
||||
| `encryptBossId` | `encryptBossId` | Boss ID(投递需要) | "b55854108ac215180XZ62N-_FlNT" |
|
||||
| `securityId` | `securityId` | 安全ID(投递需要) | "HP23zbQfaslvy-c1..." |
|
||||
| `bossName` | `bossName` | Boss姓名 | "杨明雨" |
|
||||
| `bossTitle` | `bossTitle` | Boss职位 | "HR" |
|
||||
| `bossOnline` | `bossOnline` | Boss是否在线 | true |
|
||||
| `jobLabels` | `jobLabels` | 职位标签(JSON) | ["在校/应届", "学历不限"] |
|
||||
| `skills` | `skills` | 技能要求(JSON) | ["Java", "MySQL"] |
|
||||
| `welfareList` | `welfareList` | 福利列表(JSON) | ["带薪年假", "五险一金"] |
|
||||
| `proxyJob` | `isOutsourcing` | 是否外包 | 0/1 |
|
||||
| `industry` | `industry` | 行业代码 | 100028 |
|
||||
|
||||
**关键优化点**:
|
||||
|
||||
1. **位置信息**: 优先使用响应中的 `gps.longitude` 和 `gps.latitude`,避免调用位置服务API
|
||||
- 如果 `gps` 字段存在,直接使用
|
||||
- 如果不存在,再使用 `cityName + areaDistrict + businessDistrict + brandName` 调用位置服务
|
||||
|
||||
2. **薪资解析**: 从 `salaryDesc` 解析薪资范围
|
||||
- 格式示例:`"25-50K·19薪"` → min: 25000, max: 50000
|
||||
- 格式示例:`"20-30K"` → min: 20000, max: 30000
|
||||
- 格式示例:`"面议"` → min: 0, max: 0
|
||||
- 格式示例:`"15K以上"` → min: 15000, max: 999999
|
||||
- 需要处理:K(千)、W(万)、薪(年终奖倍数)
|
||||
|
||||
3. **工作年限解析**: 从 `jobExperience` 解析年限范围
|
||||
- `"在校/应届"` → min: 0, max: 0
|
||||
- `"1-3年"` → min: 1, max: 3
|
||||
- `"3-5年"` → min: 3, max: 5
|
||||
- `"5-10年"` → min: 5, max: 10
|
||||
- `"10年以上"` → min: 10, max: 99
|
||||
|
||||
4. **学历映射**: 将学历描述映射为等级
|
||||
- `"学历不限"` → `"unlimited"`
|
||||
- `"高中"` → `"high_school"`
|
||||
- `"大专"` → `"college"`
|
||||
- `"本科"` → `"bachelor"`
|
||||
- `"硕士"` → `"master"`
|
||||
- `"博士"` → `"doctor"`
|
||||
|
||||
5. **投递必需字段**: 确保保存 `encryptJobId`、`encryptBossId` 和 `securityId`
|
||||
- 这些字段在投递时必须使用
|
||||
- `securityId` 每次搜索可能不同,需要实时保存
|
||||
2. 优化位置解析(减少API调用,添加缓存)
|
||||
3. 添加职位状态管理
|
||||
4. 添加职位匹配度字段
|
||||
|
||||
**代码位置**: 第215-308行
|
||||
|
||||
**预计工作量**: 4小时(增加字段解析逻辑)
|
||||
**预计工作量**: 3小时
|
||||
|
||||
---
|
||||
|
||||
@@ -428,67 +291,48 @@ async createDeliverTask(params) {
|
||||
|
||||
---
|
||||
|
||||
### 任务5: 创建搜索任务接口(支持可选投递)
|
||||
### 任务5: 创建搜索任务接口
|
||||
|
||||
**文件**: `api/services/pla_account_service.js`
|
||||
|
||||
**新增方法**: `createSearchJobListTask()`
|
||||
|
||||
**方法签名**:
|
||||
```javascript
|
||||
/**
|
||||
* 创建搜索职位列表任务(支持可选投递)
|
||||
* @param {Object} params - 任务参数
|
||||
* @param {number} params.id - 账号ID
|
||||
* @param {string} params.keyword - 搜索关键词
|
||||
* @param {Object} params.searchParams - 搜索条件(城市、薪资、经验、学历等)
|
||||
* @param {number} params.pageCount - 获取页数
|
||||
* @param {boolean} params.autoDeliver - 是否自动投递(默认false)
|
||||
* @param {Object} params.filterRules - 过滤规则(autoDeliver=true时使用)
|
||||
* @param {number} params.maxCount - 最大投递数量(autoDeliver=true时使用)
|
||||
* @returns {Promise<Object>} 任务创建结果 { taskId, message, jobCount, deliveredCount }
|
||||
*/
|
||||
async createSearchJobListTask(params) {
|
||||
// 1. 验证账号和授权
|
||||
// 2. 创建任务记录 (taskType: 'search_jobs' 或 'auto_deliver')
|
||||
// 3. 生成搜索指令
|
||||
// 4. 执行搜索指令
|
||||
// 5. 等待搜索完成(职位会自动保存到数据库)
|
||||
// 6. 如果 autoDeliver=true:
|
||||
// - 从数据库获取刚搜索到的职位列表
|
||||
// - 根据简历信息和过滤规则匹配职位
|
||||
// - 生成投递指令序列
|
||||
// - 执行投递指令(带间隔控制)
|
||||
// - 保存投递记录
|
||||
// - 更新职位状态
|
||||
// 7. 返回任务信息
|
||||
}
|
||||
```
|
||||
|
||||
**任务内容**:
|
||||
1. 验证账号和授权
|
||||
2. 创建任务记录(根据autoDeliver参数设置taskType: 'search_jobs' 或 'auto_deliver')
|
||||
3. 生成搜索指令(command_type: 'get_job_list')
|
||||
4. 执行搜索指令(通过MQTT发送到设备)
|
||||
5. 等待搜索完成(职位会自动保存到数据库)
|
||||
6. 如果 `autoDeliver=true`,继续执行投递流程:
|
||||
- 从数据库获取刚搜索到的职位列表(applyStatus = 'pending')
|
||||
- 根据简历信息和过滤规则匹配职位(距离、薪资、工作年限、学历等)
|
||||
- 为每个匹配的职位生成投递指令(command_type: 'apply_job')
|
||||
- 批量执行投递指令(带间隔控制,避免频繁投递)
|
||||
- 保存投递记录 (apply_records)
|
||||
- 更新职位状态 (job_postings.applyStatus = 'applied')
|
||||
7. 返回任务信息(包含搜索到的职位数量和投递数量)
|
||||
2. 创建任务记录
|
||||
3. 生成搜索指令
|
||||
4. 执行指令
|
||||
5. 返回任务信息
|
||||
|
||||
**代码位置**: 在 `runCommand()` 方法后添加
|
||||
|
||||
**预计工作量**: 5小时(增加投递逻辑)
|
||||
**预计工作量**: 3小时
|
||||
|
||||
---
|
||||
|
||||
### 任务6: 创建投递任务接口
|
||||
|
||||
**文件**: `api/services/pla_account_service.js`
|
||||
|
||||
**新增方法**: `createDeliverTask()`
|
||||
|
||||
**任务内容**:
|
||||
1. 验证账号和授权
|
||||
2. 创建任务记录
|
||||
3. 生成搜索指令(获取职位列表)
|
||||
4. 等待搜索完成
|
||||
5. 获取匹配的职位
|
||||
6. 生成投递指令序列
|
||||
7. 执行投递指令
|
||||
8. 返回任务信息
|
||||
|
||||
**代码位置**: 在 `createSearchJobListTask()` 方法后添加
|
||||
|
||||
**预计工作量**: 4小时
|
||||
|
||||
---
|
||||
|
||||
### 任务6: 完善指令类型映射
|
||||
### 任务7: 完善指令类型映射
|
||||
|
||||
**文件**: `api/middleware/schedule/command.js`
|
||||
|
||||
@@ -505,7 +349,7 @@ async createSearchJobListTask(params) {
|
||||
|
||||
---
|
||||
|
||||
### 任务7: 添加搜索条件配置管理
|
||||
### 任务8: 添加搜索条件配置管理
|
||||
|
||||
**文件**: `api/model/pla_account.js`
|
||||
|
||||
@@ -532,18 +376,16 @@ async createSearchJobListTask(params) {
|
||||
|
||||
## 🔄 工作流程
|
||||
|
||||
### 搜索职位列表流程(支持可选投递)
|
||||
### 搜索职位列表流程
|
||||
|
||||
```
|
||||
1. 用户/系统调用 createSearchJobListTask()
|
||||
- 参数: { id, keyword, searchParams, pageCount, autoDeliver: true/false, filterRules, maxCount }
|
||||
↓
|
||||
2. 创建任务记录 (task_status)
|
||||
- taskType: 'search_jobs' 或 'auto_deliver'(根据autoDeliver参数)
|
||||
↓
|
||||
3. 生成搜索指令 (task_commands)
|
||||
- command_type: 'get_job_list'
|
||||
- command_params: { keyword, city, salary, experience, education, ... }
|
||||
- command_params: { keyword, city, salary, ... }
|
||||
↓
|
||||
4. 执行指令 (通过MQTT发送到设备)
|
||||
↓
|
||||
@@ -551,79 +393,62 @@ async createSearchJobListTask(params) {
|
||||
↓
|
||||
6. 保存职位到数据库 (job_postings)
|
||||
- 去重处理
|
||||
- 位置解析(优先使用gps字段)
|
||||
- 位置解析
|
||||
- 字段映射
|
||||
- 状态: applyStatus = 'pending'(待投递)
|
||||
↓
|
||||
7. 更新搜索指令状态为完成
|
||||
7. 更新指令状态为完成
|
||||
↓
|
||||
8. 如果 autoDeliver=true,继续执行投递流程:
|
||||
↓
|
||||
8.1 从数据库获取刚搜索到的职位列表
|
||||
- 筛选条件: applyStatus = 'pending', sn_code = 账号SN码
|
||||
↓
|
||||
8.2 根据简历信息和过滤规则匹配职位
|
||||
- 距离匹配(基于经纬度)
|
||||
- 薪资匹配(解析salaryDesc)
|
||||
- 工作年限匹配(解析jobExperience)
|
||||
- 学历匹配(解析jobDegree)
|
||||
- 权重评分
|
||||
↓
|
||||
8.3 为每个匹配的职位生成投递指令
|
||||
- command_type: 'apply_job'
|
||||
- command_params: {
|
||||
jobId: job.encryptJobId, // 职位ID(必需)
|
||||
encryptBossId: job.encryptBossId, // Boss ID(必需)
|
||||
securityId: job.securityId, // 安全ID(必需,从最新搜索结果获取)
|
||||
brandName: job.brandName, // 公司名称(可选)
|
||||
jobTitle: job.jobName // 职位名称(可选)
|
||||
}
|
||||
↓
|
||||
8.4 批量执行投递指令(带间隔控制,避免频繁投递)
|
||||
↓
|
||||
8.5 保存投递记录 (apply_records)
|
||||
↓
|
||||
8.6 更新职位状态 (job_postings.applyStatus = 'applied')
|
||||
↓
|
||||
9. 更新任务状态为完成
|
||||
↓
|
||||
10. 返回任务信息(包含搜索到的职位数量和投递数量)
|
||||
8. 更新任务状态为完成
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 此接口支持两种模式:
|
||||
- `autoDeliver=false`: 仅搜索,不投递。职位保存到数据库,状态为'pending'
|
||||
- `autoDeliver=true`: 搜索完成后立即投递匹配的职位
|
||||
- **重要**: 投递必须在搜索完成后立即执行,因为 `securityId` 等字段可能有时效性,前端页面变化后这些字段可能失效
|
||||
- 不支持从已保存的职位中选择投递,因为职位信息可能已过期
|
||||
### 投递职位流程
|
||||
|
||||
```
|
||||
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 | 城市代码 | 待添加 | `job.city` |
|
||||
| `cityName` | VARCHAR | 城市名称 | 待添加 | `job.cityName` |
|
||||
| `areaDistrict` | VARCHAR | 区域 | 待添加 | `job.areaDistrict` |
|
||||
| `businessDistrict` | VARCHAR | 商圈 | 待添加 | `job.businessDistrict` |
|
||||
| `salaryMin` | INT | 最低薪资(元) | 待添加 | 从 `salaryDesc` 解析 |
|
||||
| `salaryMax` | INT | 最高薪资(元) | 待添加 | 从 `salaryDesc` 解析 |
|
||||
| `experienceMin` | INT | 最低工作年限 | 待添加 | 从 `jobExperience` 解析 |
|
||||
| `experienceMax` | INT | 最高工作年限 | 待添加 | 从 `jobExperience` 解析 |
|
||||
| `educationLevel` | VARCHAR | 学历等级 | 待添加 | 从 `jobDegree` 映射 |
|
||||
| `matchScore` | DECIMAL | 匹配度评分 | 待添加 | 计算得出 |
|
||||
| `encryptBossId` | VARCHAR | Boss ID | 已有 | `job.encryptBossId` |
|
||||
| `securityId` | VARCHAR | 安全ID | 待添加 | `job.securityId`(投递必需) |
|
||||
| `bossName` | VARCHAR | Boss姓名 | 待添加 | `job.bossName` |
|
||||
| `bossTitle` | VARCHAR | Boss职位 | 待添加 | `job.bossTitle` |
|
||||
| `bossOnline` | TINYINT | Boss是否在线 | 待添加 | `job.bossOnline` |
|
||||
| `brandStage` | VARCHAR | 融资阶段 | 待添加 | `job.brandStageName` |
|
||||
| `jobLabels` | JSON | 职位标签 | 待添加 | `job.jobLabels` |
|
||||
| `skills` | JSON | 技能要求 | 待添加 | `job.skills` |
|
||||
| `welfareList` | JSON | 福利列表 | 待添加 | `job.welfareList` |
|
||||
| `isOutsourcing` | TINYINT | 是否外包 | 待添加 | `job.proxyJob` |
|
||||
| `industry` | INT | 行业代码 | 待添加 | `job.industry` |
|
||||
| 字段名 | 类型 | 说明 | 状态 |
|
||||
|--------|------|------|------|
|
||||
| `city` | VARCHAR | 城市代码 | 待添加 |
|
||||
| `cityName` | VARCHAR | 城市名称 | 待添加 |
|
||||
| `salaryMin` | INT | 最低薪资(元) | 待添加 |
|
||||
| `salaryMax` | INT | 最高薪资(元) | 待添加 |
|
||||
| `experienceMin` | INT | 最低工作年限 | 待添加 |
|
||||
| `experienceMax` | INT | 最高工作年限 | 待添加 |
|
||||
| `educationLevel` | VARCHAR | 学历等级 | 待添加 |
|
||||
| `matchScore` | DECIMAL | 匹配度评分 | 待添加 |
|
||||
|
||||
### pla_account 表需要添加的字段
|
||||
|
||||
@@ -658,14 +483,15 @@ async createSearchJobListTask(params) {
|
||||
| 任务 | 预计时间 | 优先级 |
|
||||
|------|----------|--------|
|
||||
| 任务1: 完善搜索参数支持 | 2小时 | 高 |
|
||||
| 任务2: 优化职位数据保存 | 4小时 | 高 |
|
||||
| 任务2: 优化职位数据保存 | 3小时 | 高 |
|
||||
| 任务3: 完善自动投递任务搜索条件 | 2小时 | 高 |
|
||||
| 任务4: 优化职位匹配算法 | 4小时 | 高 |
|
||||
| 任务5: 创建搜索任务接口(支持可选投递) | 5小时 | 高 |
|
||||
| 任务6: 完善指令类型映射 | 1小时 | 中 |
|
||||
| 任务7: 添加搜索条件配置管理 | 1小时 | 低 |
|
||||
| 任务5: 创建搜索任务接口 | 3小时 | 中 |
|
||||
| 任务6: 创建投递任务接口 | 4小时 | 中 |
|
||||
| 任务7: 完善指令类型映射 | 1小时 | 中 |
|
||||
| 任务8: 添加搜索条件配置管理 | 1小时 | 低 |
|
||||
|
||||
**总计**: 约19小时
|
||||
**总计**: 约20小时
|
||||
|
||||
## 🚀 开发优先级
|
||||
|
||||
@@ -674,70 +500,14 @@ async createSearchJobListTask(params) {
|
||||
2. 任务2: 优化职位数据保存
|
||||
3. 任务3: 完善自动投递任务搜索条件
|
||||
4. 任务4: 优化职位匹配算法
|
||||
5. 任务5: 创建搜索任务接口(支持可选投递)
|
||||
|
||||
### 第二阶段(接口完善)
|
||||
6. 任务6: 完善指令类型映射
|
||||
5. 任务5: 创建搜索任务接口
|
||||
6. 任务6: 创建投递任务接口
|
||||
7. 任务7: 完善指令类型映射
|
||||
|
||||
### 第三阶段(配置管理)
|
||||
7. 任务7: 添加搜索条件配置管理
|
||||
|
||||
## 💡 使用场景说明
|
||||
|
||||
### 场景1: 仅搜索职位列表
|
||||
```javascript
|
||||
// 只搜索职位,不投递
|
||||
const result = await plaAccountService.createSearchJobListTask({
|
||||
id: accountId,
|
||||
keyword: '全栈工程师',
|
||||
searchParams: {
|
||||
city: '101020100',
|
||||
cityName: '上海',
|
||||
salary: '20-30K',
|
||||
experience: '3-5年',
|
||||
education: '本科'
|
||||
},
|
||||
pageCount: 3,
|
||||
autoDeliver: false // 不自动投递
|
||||
});
|
||||
|
||||
// 返回: { taskId: 123, message: '搜索任务已创建', jobCount: 45 }
|
||||
// 职位会自动保存到数据库,状态为 'pending'(待投递)
|
||||
```
|
||||
|
||||
### 场景2: 搜索并自动投递(推荐)
|
||||
```javascript
|
||||
// 搜索职位并自动投递匹配的职位
|
||||
const result = await plaAccountService.createSearchJobListTask({
|
||||
id: accountId,
|
||||
keyword: '全栈工程师',
|
||||
searchParams: {
|
||||
city: '101020100',
|
||||
cityName: '上海',
|
||||
salary: '20-30K',
|
||||
experience: '3-5年',
|
||||
education: '本科'
|
||||
},
|
||||
pageCount: 3,
|
||||
autoDeliver: true, // 自动投递
|
||||
filterRules: {
|
||||
minSalary: 20000,
|
||||
maxSalary: 30000,
|
||||
keywords: ['Vue', 'React'],
|
||||
excludeKeywords: ['外包', '外派']
|
||||
},
|
||||
maxCount: 10 // 最多投递10个职位
|
||||
});
|
||||
|
||||
// 返回: { taskId: 123, message: '搜索并投递任务已创建', jobCount: 45, deliveredCount: 8 }
|
||||
```
|
||||
|
||||
**重要说明**:
|
||||
- **投递必须在搜索完成后立即执行**,因为 `securityId` 等字段可能有时效性
|
||||
- 前端页面变化后,已保存的职位信息中的 `securityId` 可能失效,无法用于投递
|
||||
- 因此不支持从已保存的职位中选择投递,必须在搜索后立即投递
|
||||
- 如果只需要搜索不投递,设置 `autoDeliver: false`
|
||||
- 如果需要搜索并投递,设置 `autoDeliver: true`,系统会根据匹配规则自动投递
|
||||
8. 任务8: 添加搜索条件配置管理
|
||||
|
||||
## 📌 注意事项
|
||||
|
||||
@@ -747,11 +517,6 @@ const result = await plaAccountService.createSearchJobListTask({
|
||||
4. **性能优化**: 批量操作需要考虑性能,避免阻塞
|
||||
5. **MQTT通信**: 确保指令参数格式正确,与客户端协议一致
|
||||
6. **数据库事务**: 批量操作需要使用事务保证数据一致性
|
||||
7. **投递时机**: 投递必须在搜索完成后立即执行,因为 `securityId` 等字段可能有时效性,前端页面变化后这些字段可能失效
|
||||
8. **职位状态验证**: 投递前必须验证职位状态(applyStatus = 'pending'),避免重复投递
|
||||
9. **投递必需字段**: 投递时需要 `encryptJobId`、`encryptBossId` 和 `securityId`,这些字段必须从最新搜索结果中获取
|
||||
10. **位置信息**: 优先使用响应中的 `gps` 字段,避免不必要的API调用
|
||||
11. **接口设计**: 搜索和投递在同一接口中完成,不支持单独的投递接口,因为已保存的职位信息可能已过期
|
||||
|
||||
## 🔗 相关文件
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ async function syncAllModels() {
|
||||
|
||||
// 执行同步
|
||||
await model.sync({ alter: true });
|
||||
console.log(`✅ ${modelName} 同步完成`);
|
||||
|
||||
return { modelName, success: true };
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
-- 为 pla_account 表添加自动搜索相关字段
|
||||
-- 执行时间:2025-01-XX
|
||||
-- 说明:添加自动搜索开关和搜索配置字段
|
||||
|
||||
-- ============================================
|
||||
-- 添加自动搜索开关字段(auto_search)
|
||||
-- ============================================
|
||||
ALTER TABLE `pla_account`
|
||||
ADD COLUMN `auto_search` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '自动搜索开关(1=启用,0=禁用)'
|
||||
AFTER `auto_deliver`;
|
||||
|
||||
-- ============================================
|
||||
-- 添加自动搜索配置字段(search_config)
|
||||
-- ============================================
|
||||
ALTER TABLE `pla_account`
|
||||
ADD COLUMN `search_config` JSON COMMENT '自动搜索配置(JSON对象,包含:search_interval-搜索间隔分钟数, city-城市, cityName-城市名称, salary-薪资, experience-经验, education-学历)'
|
||||
AFTER `auto_search`;
|
||||
|
||||
-- ============================================
|
||||
-- 为已有账号设置默认配置
|
||||
-- ============================================
|
||||
-- 为所有账号设置默认的 search_config(如果为 NULL)
|
||||
UPDATE `pla_account`
|
||||
SET `search_config` = JSON_OBJECT(
|
||||
'search_interval', 30,
|
||||
'city', '',
|
||||
'cityName', '',
|
||||
'salary', '',
|
||||
'experience', '',
|
||||
'education', ''
|
||||
)
|
||||
WHERE `search_config` IS NULL;
|
||||
|
||||
-- ============================================
|
||||
-- 验证字段是否添加成功
|
||||
-- ============================================
|
||||
SELECT
|
||||
COLUMN_NAME AS '字段名',
|
||||
COLUMN_TYPE AS '字段类型',
|
||||
IS_NULLABLE AS '允许空',
|
||||
COLUMN_DEFAULT AS '默认值',
|
||||
COLUMN_COMMENT AS '注释'
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'pla_account'
|
||||
AND COLUMN_NAME IN ('auto_search', 'search_config')
|
||||
ORDER BY ORDINAL_POSITION;
|
||||
|
||||
-- ============================================
|
||||
-- 注意事项
|
||||
-- ============================================
|
||||
-- 1. auto_search 使用 TINYINT(1) 类型,默认值为 0(关闭)
|
||||
-- 2. search_config 使用 JSON 类型(MySQL 5.7+)
|
||||
-- 3. 如果 MySQL 版本低于 5.7,请将 JSON 类型改为 TEXT 类型
|
||||
-- 4. 执行前建议先备份数据库
|
||||
-- 5. 如果字段已存在会报错,请先删除字段再执行:
|
||||
-- ALTER TABLE `pla_account` DROP COLUMN `auto_search`;
|
||||
-- ALTER TABLE `pla_account` DROP COLUMN `search_config`;
|
||||
-- 6. search_config 默认值包含以下字段:
|
||||
-- - search_interval: 30(搜索间隔,单位:分钟)
|
||||
-- - city: ''(城市代码)
|
||||
-- - cityName: ''(城市名称)
|
||||
-- - salary: ''(薪资范围)
|
||||
-- - experience: ''(工作经验要求)
|
||||
-- - education: ''(学历要求)
|
||||
|
||||
@@ -26,11 +26,11 @@ const baseConfig = {
|
||||
// 开发环境配置
|
||||
const developmentConfig = {
|
||||
...baseConfig,
|
||||
apiUrl: 'http://localhost:9097/admin_api/',
|
||||
uploadUrl: 'http://localhost:9097/admin_api/upload',
|
||||
// apiUrl: 'http://localhost:9097/admin_api/',
|
||||
// uploadUrl: 'http://localhost:9097/admin_api/upload',
|
||||
|
||||
// apiUrl: 'https://work.light120.com/admin_api/',
|
||||
// uploadUrl: 'https://work.light120.com/admin_api/upload',
|
||||
apiUrl: 'https://work.light120.com/admin_api/',
|
||||
uploadUrl: 'https://work.light120.com/admin_api/upload',
|
||||
// 开发环境显示更多调试信息
|
||||
debug: true
|
||||
}
|
||||
|
||||
421
api/middleware/job/chatManager.js
Normal file
421
api/middleware/job/chatManager.js
Normal file
@@ -0,0 +1,421 @@
|
||||
// const aiService = require('./aiService'); // 二期规划:AI 服务暂时禁用
|
||||
const logs = require('../logProxy');
|
||||
|
||||
/**
|
||||
* 智能聊天管理模块
|
||||
* 负责聊天内容生成、发送策略和效果监控
|
||||
*/
|
||||
class ChatManager {
|
||||
constructor() {
|
||||
this.chatHistory = new Map(); // 聊天历史记录
|
||||
this.chatStrategies = new Map(); // 聊天策略配置
|
||||
this.effectStats = new Map(); // 聊天效果统计
|
||||
this.initDefaultStrategies();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化默认聊天策略
|
||||
*/
|
||||
initDefaultStrategies() {
|
||||
// 初次打招呼策略
|
||||
this.chatStrategies.set('greeting', {
|
||||
name: '初次打招呼',
|
||||
description: '向HR发送初次打招呼消息',
|
||||
template: 'greeting',
|
||||
timing: 'immediate',
|
||||
retryCount: 1,
|
||||
retryInterval: 300000 // 5分钟
|
||||
});
|
||||
|
||||
// 面试邀约策略
|
||||
this.chatStrategies.set('interview', {
|
||||
name: '面试邀约',
|
||||
description: '发送面试邀约消息',
|
||||
template: 'interview',
|
||||
timing: 'after_greeting',
|
||||
retryCount: 2,
|
||||
retryInterval: 600000 // 10分钟
|
||||
});
|
||||
|
||||
// 跟进沟通策略
|
||||
this.chatStrategies.set('followup', {
|
||||
name: '跟进沟通',
|
||||
description: '跟进之前的沟通',
|
||||
template: 'followup',
|
||||
timing: 'after_interview',
|
||||
retryCount: 1,
|
||||
retryInterval: 86400000 // 24小时
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成聊天内容
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} jobInfo - 岗位信息
|
||||
* @param {object} resumeInfo - 简历信息
|
||||
* @param {string} chatType - 聊天类型
|
||||
* @param {object} context - 聊天上下文
|
||||
* @returns {Promise<object>} 聊天内容
|
||||
*/
|
||||
async generateChatContent(sn_code, jobInfo, resumeInfo, chatType = 'greeting', context = {}) {
|
||||
console.log(`[聊天管理] 开始生成设备 ${sn_code} 的聊天内容,类型: ${chatType}`);
|
||||
|
||||
// 获取聊天策略
|
||||
const strategy = this.chatStrategies.get(chatType);
|
||||
if (!strategy) {
|
||||
throw new Error(`未找到聊天类型 ${chatType} 的策略配置`);
|
||||
}
|
||||
|
||||
// 二期规划:AI 生成聊天内容暂时禁用,使用默认模板
|
||||
// const chatContent = await aiService.generateChatContent(jobInfo, resumeInfo, chatType);
|
||||
// if (!chatContent.success) {
|
||||
// console.error(`[聊天管理] AI生成聊天内容失败:`, chatContent.error);
|
||||
// throw new Error(chatContent.error);
|
||||
// }
|
||||
|
||||
console.log(`[聊天管理] AI生成已禁用(二期规划),使用默认聊天模板`);
|
||||
const chatContent = this.generateDefaultChatContent(jobInfo, resumeInfo, chatType);
|
||||
|
||||
const result = {
|
||||
sn_code: sn_code,
|
||||
jobInfo: jobInfo,
|
||||
chatType: chatType,
|
||||
strategy: strategy,
|
||||
content: chatContent.content,
|
||||
context: context,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
// 记录聊天历史
|
||||
this.recordChatHistory(sn_code, result);
|
||||
|
||||
console.log(`[聊天管理] 聊天内容生成成功:`, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送聊天消息
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} mqttClient - MQTT客户端
|
||||
* @param {object} chatData - 聊天数据
|
||||
* @returns {Promise<object>} 发送结果
|
||||
*/
|
||||
async sendChatMessage(sn_code, mqttClient, chatData) {
|
||||
console.log(`[聊天管理] 开始发送聊天消息到设备 ${sn_code}`);
|
||||
|
||||
// 构建发送指令
|
||||
const sendData = {
|
||||
platform: 'boss',
|
||||
action: 'send_chat_message',
|
||||
data: {
|
||||
jobId: chatData.jobInfo.jobId,
|
||||
companyId: chatData.jobInfo.companyId,
|
||||
message: chatData.content,
|
||||
chatType: chatData.chatType
|
||||
}
|
||||
};
|
||||
|
||||
// 发送MQTT指令
|
||||
const response = await mqttClient.publishAndWait(sn_code, sendData);
|
||||
|
||||
if (!response || response.code !== 200) {
|
||||
// 更新聊天状态
|
||||
this.updateChatStatus(sn_code, chatData, 'failed', response);
|
||||
|
||||
console.error(`[聊天管理] 聊天消息发送失败:`, response);
|
||||
throw new Error(response?.message || '聊天消息发送失败');
|
||||
}
|
||||
|
||||
// 更新聊天状态
|
||||
this.updateChatStatus(sn_code, chatData, 'sent', response);
|
||||
|
||||
// 记录效果统计
|
||||
this.recordChatEffect(sn_code, chatData, 'sent');
|
||||
|
||||
console.log(`[聊天管理] 聊天消息发送成功:`, response);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成面试邀约
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} jobInfo - 岗位信息
|
||||
* @param {object} chatHistory - 聊天历史
|
||||
* @returns {Promise<object>} 面试邀约内容
|
||||
*/
|
||||
async generateInterviewInvitation(sn_code, jobInfo, chatHistory) {
|
||||
console.log(`[聊天管理] 开始生成设备 ${sn_code} 的面试邀约`);
|
||||
console.log(`[聊天管理] AI生成已禁用(二期规划),使用默认模板`);
|
||||
|
||||
// 二期规划:AI 生成面试邀约暂时禁用,使用默认模板
|
||||
// const invitation = await aiService.generateInterviewInvitation(jobInfo, chatHistory);
|
||||
// if (!invitation.success) {
|
||||
// console.error(`[聊天管理] AI生成面试邀约失败:`, invitation.error);
|
||||
// throw new Error(invitation.error);
|
||||
// }
|
||||
|
||||
const invitation = this.generateDefaultInterviewInvitation(jobInfo);
|
||||
|
||||
const result = {
|
||||
sn_code: sn_code,
|
||||
jobInfo: jobInfo,
|
||||
chatType: 'interview',
|
||||
content: invitation.content,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
// 记录聊天历史
|
||||
this.recordChatHistory(sn_code, result);
|
||||
|
||||
console.log(`[聊天管理] 面试邀约生成成功:`, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录聊天历史
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} chatData - 聊天数据
|
||||
*/
|
||||
recordChatHistory(sn_code, chatData) {
|
||||
if (!this.chatHistory.has(sn_code)) {
|
||||
this.chatHistory.set(sn_code, []);
|
||||
}
|
||||
|
||||
const history = this.chatHistory.get(sn_code);
|
||||
history.push({
|
||||
...chatData,
|
||||
id: Date.now() + Math.random(),
|
||||
status: 'generated'
|
||||
});
|
||||
|
||||
// 限制历史记录数量
|
||||
if (history.length > 100) {
|
||||
history.splice(0, history.length - 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新聊天状态
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} chatData - 聊天数据
|
||||
* @param {string} status - 新状态
|
||||
* @param {object} response - 响应数据
|
||||
*/
|
||||
updateChatStatus(sn_code, chatData, status, response = {}) {
|
||||
if (!this.chatHistory.has(sn_code)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const history = this.chatHistory.get(sn_code);
|
||||
const chatRecord = history.find(record =>
|
||||
record.timestamp === chatData.timestamp &&
|
||||
record.chatType === chatData.chatType
|
||||
);
|
||||
|
||||
if (chatRecord) {
|
||||
chatRecord.status = status;
|
||||
chatRecord.response = response;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录聊天效果
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} chatData - 聊天数据
|
||||
* @param {string} action - 动作类型
|
||||
*/
|
||||
recordChatEffect(sn_code, chatData, action) {
|
||||
if (!this.effectStats.has(sn_code)) {
|
||||
this.effectStats.set(sn_code, {
|
||||
totalSent: 0,
|
||||
totalReplied: 0,
|
||||
totalInterview: 0,
|
||||
replyRate: 0,
|
||||
interviewRate: 0,
|
||||
lastUpdate: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
const stats = this.effectStats.get(sn_code);
|
||||
|
||||
if (action === 'sent') {
|
||||
stats.totalSent++;
|
||||
} else if (action === 'replied') {
|
||||
stats.totalReplied++;
|
||||
} else if (action === 'interview') {
|
||||
stats.totalInterview++;
|
||||
}
|
||||
|
||||
// 计算比率
|
||||
if (stats.totalSent > 0) {
|
||||
stats.replyRate = (stats.totalReplied / stats.totalSent * 100).toFixed(2);
|
||||
stats.interviewRate = (stats.totalInterview / stats.totalSent * 100).toFixed(2);
|
||||
}
|
||||
|
||||
stats.lastUpdate = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取聊天历史
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} filters - 过滤条件
|
||||
* @returns {Array} 聊天历史
|
||||
*/
|
||||
getChatHistory(sn_code, filters = {}) {
|
||||
if (!this.chatHistory.has(sn_code)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let history = this.chatHistory.get(sn_code);
|
||||
|
||||
// 应用过滤条件
|
||||
if (filters.chatType) {
|
||||
history = history.filter(record => record.chatType === filters.chatType);
|
||||
}
|
||||
|
||||
if (filters.status) {
|
||||
history = history.filter(record => record.status === filters.status);
|
||||
}
|
||||
|
||||
if (filters.startTime) {
|
||||
history = history.filter(record => record.timestamp >= filters.startTime);
|
||||
}
|
||||
|
||||
if (filters.endTime) {
|
||||
history = history.filter(record => record.timestamp <= filters.endTime);
|
||||
}
|
||||
|
||||
// 按时间倒序排列
|
||||
return history.sort((a, b) => b.timestamp - a.timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取聊天效果统计
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @returns {object} 效果统计
|
||||
*/
|
||||
getChatEffectStats(sn_code) {
|
||||
if (!this.effectStats.has(sn_code)) {
|
||||
return {
|
||||
totalSent: 0,
|
||||
totalReplied: 0,
|
||||
totalInterview: 0,
|
||||
replyRate: 0,
|
||||
interviewRate: 0,
|
||||
lastUpdate: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
return this.effectStats.get(sn_code);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置聊天策略
|
||||
* @param {string} chatType - 聊天类型
|
||||
* @param {object} strategy - 策略配置
|
||||
*/
|
||||
setChatStrategy(chatType, strategy) {
|
||||
this.chatStrategies.set(chatType, {
|
||||
...this.chatStrategies.get(chatType),
|
||||
...strategy
|
||||
});
|
||||
|
||||
console.log(`[聊天管理] 更新聊天策略 ${chatType}:`, strategy);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期数据
|
||||
*/
|
||||
cleanup() {
|
||||
const now = Date.now();
|
||||
const expireTime = 30 * 24 * 3600000; // 30天
|
||||
|
||||
// 清理过期的聊天历史
|
||||
for (const [sn_code, history] of this.chatHistory.entries()) {
|
||||
const filteredHistory = history.filter(record =>
|
||||
now - record.timestamp < expireTime
|
||||
);
|
||||
|
||||
if (filteredHistory.length === 0) {
|
||||
this.chatHistory.delete(sn_code);
|
||||
} else {
|
||||
this.chatHistory.set(sn_code, filteredHistory);
|
||||
}
|
||||
}
|
||||
|
||||
// 清理过期的效果统计
|
||||
for (const [sn_code, stats] of this.effectStats.entries()) {
|
||||
if (now - stats.lastUpdate > expireTime) {
|
||||
this.effectStats.delete(sn_code);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[聊天管理] 数据清理完成`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成默认聊天内容(替代 AI 生成)
|
||||
* @param {object} jobInfo - 岗位信息
|
||||
* @param {object} resumeInfo - 简历信息
|
||||
* @param {string} chatType - 聊天类型
|
||||
* @returns {object} 聊天内容
|
||||
*/
|
||||
generateDefaultChatContent(jobInfo, resumeInfo, chatType) {
|
||||
const templates = {
|
||||
greeting: '您好,我对这个岗位很感兴趣,希望能进一步了解。',
|
||||
interview: '感谢您的回复,我很期待与您进一步沟通。',
|
||||
followup: '您好,想了解一下这个岗位的最新进展。'
|
||||
};
|
||||
|
||||
const content = templates[chatType] || templates.greeting;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
content: content,
|
||||
chatType: chatType
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成默认面试邀约(替代 AI 生成)
|
||||
* @param {object} jobInfo - 岗位信息
|
||||
* @returns {object} 面试邀约内容
|
||||
*/
|
||||
generateDefaultInterviewInvitation(jobInfo) {
|
||||
return {
|
||||
success: true,
|
||||
content: '感谢您的邀请,我很期待与您面谈。请问方便的时间是什么时候?',
|
||||
jobTitle: jobInfo.jobTitle || '该岗位',
|
||||
companyName: jobInfo.companyName || '贵公司'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取聊天列表
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} mqttClient - MQTT客户端
|
||||
* @param {object} params - 参数
|
||||
* @returns {Promise<object>} 聊天列表
|
||||
*/
|
||||
async get_chat_list(sn_code, mqttClient, params = {}) {
|
||||
const { platform = 'boss', pageCount = 3 } = params;
|
||||
console.log(`[聊天管理] 开始获取设备 ${sn_code} 的聊天列表`);
|
||||
|
||||
// 通过MQTT指令获取聊天列表
|
||||
const response = await mqttClient.publishAndWait(sn_code, {
|
||||
platform,
|
||||
action: "get_chat_list",
|
||||
data: { pageCount }
|
||||
});
|
||||
|
||||
if (!response || response.code !== 200) {
|
||||
console.error(`[聊天管理] 获取聊天列表失败:`, response);
|
||||
throw new Error('获取聊天列表失败');
|
||||
}
|
||||
|
||||
console.log(`[聊天管理] 成功获取聊天列表`);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ChatManager();
|
||||
@@ -3,7 +3,10 @@
|
||||
* 聚合所有 job 相关模块的方法,提供统一的对外接口
|
||||
*/
|
||||
|
||||
const { jobManager, resumeManager, chatManager } = require('./managers');
|
||||
const jobManager = require('./jobManager');
|
||||
const resumeManager = require('./resumeManager');
|
||||
const chatManager = require('./chatManager');
|
||||
|
||||
|
||||
const pack = (instance) => {
|
||||
const proto = Object.getPrototypeOf(instance);
|
||||
@@ -20,6 +23,7 @@ const pack = (instance) => {
|
||||
|
||||
/**
|
||||
* 便捷方法:直接导出常用方法
|
||||
* 使用下划线命名规范
|
||||
*/
|
||||
module.exports = {
|
||||
...pack(jobManager),
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
const aiServiceModule = require('../../../services/ai_service');
|
||||
const { jobFilterService } = require('../services');
|
||||
const locationService = require('../../../services/locationService');
|
||||
const logs = require('../../logProxy');
|
||||
const db = require('../../dbProxy');
|
||||
// const aiService = require('./aiService'); // 二期规划:AI 服务暂时禁用
|
||||
const jobFilterService = require('./job_filter_service'); // 使用文本匹配过滤服务
|
||||
const locationService = require('../../services/locationService'); // 位置服务
|
||||
const logs = require('../logProxy');
|
||||
const db = require('../dbProxy');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
// 实例化AI服务
|
||||
const aiService = aiServiceModule.getInstance();
|
||||
|
||||
/**
|
||||
* 工作管理模块
|
||||
* 负责简历获取、分析、存储和匹配度计算
|
||||
@@ -147,350 +144,14 @@ class JobManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* 多条件搜索职位列表(新指令,使用新的MQTT action)
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} mqttClient - MQTT客户端
|
||||
* @param {object} params - 搜索参数
|
||||
* @returns {Promise<object>} 搜索结果
|
||||
*/
|
||||
async search_jobs_with_params(sn_code, mqttClient, params = {}) {
|
||||
const {
|
||||
keyword = '前端',
|
||||
platform = 'boss',
|
||||
city = '',
|
||||
cityName = '',
|
||||
salary = '',
|
||||
experience = '',
|
||||
education = '',
|
||||
industry = '',
|
||||
companySize = '',
|
||||
financingStage = '',
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
pageCount = 3
|
||||
} = params;
|
||||
|
||||
console.log(`[工作管理] 开始多条件搜索设备 ${sn_code} 的职位,关键词: ${keyword}, 城市: ${cityName || city}`);
|
||||
|
||||
// 构建完整的搜索参数对象
|
||||
const searchData = {
|
||||
keyword,
|
||||
pageCount
|
||||
};
|
||||
|
||||
// 添加可选搜索条件
|
||||
if (city) searchData.city = city;
|
||||
if (cityName) searchData.cityName = cityName;
|
||||
if (salary) searchData.salary = salary;
|
||||
if (experience) searchData.experience = experience;
|
||||
if (education) searchData.education = education;
|
||||
if (industry) searchData.industry = industry;
|
||||
if (companySize) searchData.companySize = companySize;
|
||||
if (financingStage) searchData.financingStage = financingStage;
|
||||
if (page) searchData.page = page;
|
||||
if (pageSize) searchData.pageSize = pageSize;
|
||||
|
||||
// 通过MQTT指令获取岗位列表(使用新的action)
|
||||
const response = await mqttClient.publishAndWait(sn_code, {
|
||||
platform,
|
||||
action: "search_job_list", // 新的搜索action
|
||||
data: searchData
|
||||
});
|
||||
|
||||
if (!response || response.code !== 200) {
|
||||
console.error(`[工作管理] 多条件搜索职位失败:`, response);
|
||||
throw new Error('多条件搜索职位失败');
|
||||
}
|
||||
|
||||
// 处理职位列表数据
|
||||
let jobs = [];
|
||||
if (Array.isArray(response.data)) {
|
||||
for (const item of response.data) {
|
||||
if (item.data?.zpData?.jobList && Array.isArray(item.data.zpData.jobList)) {
|
||||
jobs = jobs.concat(item.data.zpData.jobList);
|
||||
}
|
||||
}
|
||||
} else if (response.data?.data?.zpData?.jobList) {
|
||||
jobs = response.data.data.zpData.jobList || [];
|
||||
} else if (response.data?.zpData?.jobList) {
|
||||
jobs = response.data.zpData.jobList || [];
|
||||
}
|
||||
|
||||
console.log(`[工作管理] 成功获取岗位数据,共 ${jobs.length} 个岗位`);
|
||||
|
||||
// 保存职位到数据库
|
||||
try {
|
||||
await this.saveJobsToDatabase(sn_code, platform, keyword, jobs);
|
||||
} catch (error) {
|
||||
console.error(`[工作管理] 保存职位到数据库失败:`, error);
|
||||
}
|
||||
|
||||
return {
|
||||
jobs: jobs,
|
||||
keyword: keyword,
|
||||
platform: platform,
|
||||
count: jobs.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索并投递职位(新指令)
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} mqttClient - MQTT客户端
|
||||
* @param {object} params - 参数
|
||||
* @returns {Promise<object>} 执行结果
|
||||
*/
|
||||
async search_and_deliver(sn_code, mqttClient, params = {}) {
|
||||
const {
|
||||
keyword,
|
||||
searchParams = {},
|
||||
pageCount = 3,
|
||||
filterRules = {},
|
||||
maxCount = 10,
|
||||
platform = 'boss'
|
||||
} = params;
|
||||
|
||||
console.log(`[工作管理] 开始搜索并投递职位,设备: ${sn_code}, 关键词: ${keyword}`);
|
||||
|
||||
// 1. 先执行搜索(使用search_jobs_with_params,新的搜索指令)
|
||||
const searchResult = await this.search_jobs_with_params(sn_code, mqttClient, {
|
||||
keyword,
|
||||
platform,
|
||||
...searchParams,
|
||||
pageCount
|
||||
});
|
||||
|
||||
if (!searchResult || searchResult.count === 0) {
|
||||
return {
|
||||
success: true,
|
||||
jobCount: 0,
|
||||
deliveredCount: 0,
|
||||
message: '未找到职位'
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 等待数据保存完成
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// 3. 从数据库获取刚搜索到的职位
|
||||
const job_postings = db.getModel('job_postings');
|
||||
const searchedJobs = await job_postings.findAll({
|
||||
where: {
|
||||
sn_code: sn_code,
|
||||
platform: platform,
|
||||
applyStatus: 'pending',
|
||||
keyword: keyword
|
||||
},
|
||||
order: [['create_time', 'DESC']],
|
||||
limit: 1000
|
||||
});
|
||||
|
||||
if (searchedJobs.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
jobCount: searchResult.count,
|
||||
deliveredCount: 0,
|
||||
message: '未找到待投递的职位'
|
||||
};
|
||||
}
|
||||
|
||||
// 4. 获取简历信息用于匹配
|
||||
const resume_info = db.getModel('resume_info');
|
||||
const resume = await resume_info.findOne({
|
||||
where: {
|
||||
sn_code: sn_code,
|
||||
platform: platform,
|
||||
isActive: true
|
||||
},
|
||||
order: [['last_modify_time', 'DESC']]
|
||||
});
|
||||
|
||||
if (!resume) {
|
||||
return {
|
||||
success: true,
|
||||
jobCount: searchResult.count,
|
||||
deliveredCount: 0,
|
||||
message: '未找到活跃简历,无法投递'
|
||||
};
|
||||
}
|
||||
|
||||
// 5. 获取账号配置
|
||||
const pla_account = db.getModel('pla_account');
|
||||
const account = await pla_account.findOne({
|
||||
where: { sn_code, platform_type: platform }
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
throw new Error('账号不存在');
|
||||
}
|
||||
|
||||
const accountConfig = account.toJSON();
|
||||
const resumeData = resume.toJSON();
|
||||
|
||||
// 6. 使用过滤方法进行职位匹配
|
||||
const matchedJobs = await this.filter_jobs_by_rules(searchedJobs, {
|
||||
minSalary: filterRules.minSalary || 0,
|
||||
maxSalary: filterRules.maxSalary || 0,
|
||||
keywords: filterRules.keywords || [],
|
||||
excludeKeywords: filterRules.excludeKeywords || [],
|
||||
accountConfig: accountConfig,
|
||||
resumeInfo: resumeData
|
||||
});
|
||||
|
||||
// 7. 限制投递数量
|
||||
const jobsToDeliver = matchedJobs.slice(0, maxCount);
|
||||
console.log(`[工作管理] 匹配到 ${matchedJobs.length} 个职位,将投递 ${jobsToDeliver.length} 个`);
|
||||
|
||||
// 8. 执行投递
|
||||
let deliveredCount = 0;
|
||||
const apply_records = db.getModel('apply_records');
|
||||
|
||||
for (let i = 0; i < jobsToDeliver.length; i++) {
|
||||
const job = jobsToDeliver[i];
|
||||
const jobData = job.toJSON ? job.toJSON() : job;
|
||||
|
||||
try {
|
||||
// 从原始数据中获取 securityId
|
||||
let securityId = jobData.securityId || '';
|
||||
try {
|
||||
if (jobData.originalData) {
|
||||
const originalData = typeof jobData.originalData === 'string'
|
||||
? JSON.parse(jobData.originalData)
|
||||
: jobData.originalData;
|
||||
securityId = originalData.securityId || securityId;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[工作管理] 解析职位原始数据失败:`, e);
|
||||
}
|
||||
|
||||
// 执行投递(使用新的deliver_resume_search action)
|
||||
const deliverResult = await this.deliver_resume(sn_code, mqttClient, {
|
||||
jobId: jobData.jobId,
|
||||
encryptBossId: jobData.encryptBossId || '',
|
||||
securityId: securityId,
|
||||
brandName: jobData.companyName || '',
|
||||
jobTitle: jobData.jobTitle || '',
|
||||
companyName: jobData.companyName || '',
|
||||
platform: platform,
|
||||
action: 'deliver_resume_search' // 搜索并投递使用新的action
|
||||
});
|
||||
|
||||
if (deliverResult && deliverResult.success) {
|
||||
deliveredCount++;
|
||||
}
|
||||
|
||||
// 投递间隔控制
|
||||
if (i < jobsToDeliver.length - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[工作管理] 投递职位失败:`, error);
|
||||
// 继续投递下一个职位
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
jobCount: searchResult.count,
|
||||
deliveredCount: deliveredCount,
|
||||
message: `搜索完成,找到 ${searchResult.count} 个职位,成功投递 ${deliveredCount} 个`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取岗位列表(支持多条件搜索)
|
||||
* 获取岗位列表
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} mqttClient - MQTT客户端
|
||||
* @param {object} params - 参数
|
||||
* @returns {Promise<object>} 岗位列表
|
||||
*/
|
||||
async get_job_list(sn_code, mqttClient, params = {}) {
|
||||
const {
|
||||
keyword = '前端',
|
||||
platform = 'boss',
|
||||
pageCount = 3,
|
||||
city = '',
|
||||
cityName = '',
|
||||
salary = '',
|
||||
experience = '',
|
||||
education = '',
|
||||
industry = '',
|
||||
companySize = '',
|
||||
financingStage = '',
|
||||
page = 1,
|
||||
pageSize = 20
|
||||
} = params;
|
||||
|
||||
// 判断是否是多条件搜索(如果包含多条件参数,使用多条件搜索逻辑)
|
||||
const hasMultiParams = city || cityName || salary || experience || education ||
|
||||
industry || companySize || financingStage || page || pageSize;
|
||||
|
||||
if (hasMultiParams) {
|
||||
// 使用多条件搜索逻辑
|
||||
console.log(`[工作管理] 开始多条件搜索设备 ${sn_code} 的职位,关键词: ${keyword}, 城市: ${cityName || city}`);
|
||||
|
||||
// 构建完整的搜索参数对象
|
||||
const searchData = {
|
||||
keyword,
|
||||
pageCount
|
||||
};
|
||||
|
||||
// 添加可选搜索条件
|
||||
if (city) searchData.city = city;
|
||||
if (cityName) searchData.cityName = cityName;
|
||||
if (salary) searchData.salary = salary;
|
||||
if (experience) searchData.experience = experience;
|
||||
if (education) searchData.education = education;
|
||||
if (industry) searchData.industry = industry;
|
||||
if (companySize) searchData.companySize = companySize;
|
||||
if (financingStage) searchData.financingStage = financingStage;
|
||||
if (page) searchData.page = page;
|
||||
if (pageSize) searchData.pageSize = pageSize;
|
||||
|
||||
// 通过MQTT指令获取岗位列表(保持action不变,前端已使用)
|
||||
const response = await mqttClient.publishAndWait(sn_code, {
|
||||
platform,
|
||||
action: "get_job_list", // 保持与原有get_job_list相同的action,前端已使用
|
||||
data: searchData
|
||||
});
|
||||
|
||||
if (!response || response.code !== 200) {
|
||||
console.error(`[工作管理] 多条件搜索职位失败:`, response);
|
||||
throw new Error('多条件搜索职位失败');
|
||||
}
|
||||
|
||||
// 处理职位列表数据
|
||||
let jobs = [];
|
||||
if (Array.isArray(response.data)) {
|
||||
for (const item of response.data) {
|
||||
if (item.data?.zpData?.jobList && Array.isArray(item.data.zpData.jobList)) {
|
||||
jobs = jobs.concat(item.data.zpData.jobList);
|
||||
}
|
||||
}
|
||||
} else if (response.data?.data?.zpData?.jobList) {
|
||||
jobs = response.data.data.zpData.jobList || [];
|
||||
} else if (response.data?.zpData?.jobList) {
|
||||
jobs = response.data.zpData.jobList || [];
|
||||
}
|
||||
|
||||
console.log(`[工作管理] 成功获取岗位数据,共 ${jobs.length} 个岗位`);
|
||||
|
||||
// 保存职位到数据库
|
||||
try {
|
||||
await this.saveJobsToDatabase(sn_code, platform, keyword, jobs);
|
||||
} catch (error) {
|
||||
console.error(`[工作管理] 保存职位到数据库失败:`, error);
|
||||
}
|
||||
|
||||
return {
|
||||
jobs: jobs,
|
||||
keyword: keyword,
|
||||
platform: platform,
|
||||
count: jobs.length
|
||||
};
|
||||
}
|
||||
|
||||
// 简单搜索逻辑(保持原有逻辑)
|
||||
const { keyword = '前端', platform = 'boss', pageCount = 3 } = params;
|
||||
console.log(`[工作管理] 开始获取设备 ${sn_code} 的岗位列表,关键词: ${keyword}`);
|
||||
|
||||
// 通过MQTT指令获取岗位列表
|
||||
@@ -659,11 +320,10 @@ class JobManager {
|
||||
* @param {string} params.brandName - 公司名称(可选)
|
||||
* @param {string} params.jobTitle - 职位标题(可选)
|
||||
* @param {string} params.companyName - 公司名称(可选)
|
||||
* @param {string} params.action - MQTT Action(默认:deliver_resume,可选:deliver_resume_search)
|
||||
* @returns {Promise<object>} 投递结果
|
||||
*/
|
||||
async deliver_resume(sn_code, mqttClient, params = {}) {
|
||||
const { platform = 'boss', jobId, encryptBossId, securityId, brandName, jobTitle, companyName, action = 'deliver_resume' } = params;
|
||||
async applyJob(sn_code, mqttClient, params = {}) {
|
||||
const { platform = 'boss', jobId, encryptBossId, securityId, brandName, jobTitle, companyName } = params;
|
||||
|
||||
if (!jobId) {
|
||||
throw new Error('jobId 参数不能为空,请指定要投递的职位ID');
|
||||
@@ -741,10 +401,10 @@ class JobManager {
|
||||
|
||||
console.log(`[工作管理] 投递职位: ${jobData.jobTitle} @ ${jobData.companyName}`);
|
||||
|
||||
// 通过MQTT指令投递简历(支持自定义action)
|
||||
// 通过MQTT指令投递简历
|
||||
const response = await mqttClient.publishAndWait(sn_code, {
|
||||
platform,
|
||||
action: action, // 使用传入的action参数,默认为"deliver_resume"
|
||||
action: "deliver_resume",
|
||||
data: {
|
||||
encryptJobId: jobData.jobId,
|
||||
securityId: jobData.securityId || securityId || '',
|
||||
@@ -4,8 +4,8 @@
|
||||
* 支持从数据库动态获取职位类型的技能关键词和排除关键词
|
||||
*/
|
||||
|
||||
const db = require('../../dbProxy.js');
|
||||
const locationService = require('../../../services/locationService');
|
||||
const db = require('../dbProxy.js');
|
||||
const locationService = require('../../services/locationService');
|
||||
|
||||
class JobFilterService {
|
||||
constructor() {
|
||||
651
api/middleware/job/job_filter_service.md
Normal file
651
api/middleware/job/job_filter_service.md
Normal file
@@ -0,0 +1,651 @@
|
||||
# 职位过滤服务文档
|
||||
|
||||
## 概述
|
||||
|
||||
`job_filter_service.js` 是一个职位文本匹配过滤服务,使用简单的文本匹配规则来过滤和分析职位信息。该服务支持从数据库动态获取职位类型的技能关键词和排除关键词,能够分析职位与简历的匹配度,并提供过滤和评分功能。
|
||||
|
||||
## 主要功能
|
||||
|
||||
1. **职位类型配置管理**:从数据库获取或使用默认配置的技能关键词和排除关键词
|
||||
2. **匹配度分析**:分析职位与简历的匹配度(技能、经验、薪资)
|
||||
3. **职位过滤**:根据匹配分数、外包标识、排除关键词等条件过滤职位列表
|
||||
4. **自定义权重评分**:支持根据自定义权重配置计算职位评分(距离、薪资、工作年限、学历、技能)
|
||||
|
||||
## 类结构
|
||||
|
||||
```javascript
|
||||
class JobFilterService {
|
||||
// 默认技能关键词
|
||||
defaultCommonSkills: Array<string>
|
||||
|
||||
// 默认排除关键词
|
||||
defaultExcludeKeywords: Array<string>
|
||||
|
||||
// 职位类型配置缓存
|
||||
jobTypeCache: Map<number, Object>
|
||||
}
|
||||
```
|
||||
|
||||
## API 文档
|
||||
|
||||
### 1. getJobTypeConfig(jobTypeId)
|
||||
|
||||
根据职位类型ID获取技能关键词和排除关键词配置。
|
||||
|
||||
**参数:**
|
||||
- `jobTypeId` (number, 可选): 职位类型ID
|
||||
|
||||
**返回值:**
|
||||
```javascript
|
||||
{
|
||||
commonSkills: Array<string>, // 技能关键词列表
|
||||
excludeKeywords: Array<string> // 排除关键词列表
|
||||
}
|
||||
```
|
||||
|
||||
**说明:**
|
||||
- 如果未提供 `jobTypeId` 或配置获取失败,返回默认配置
|
||||
- 配置结果会缓存5分钟,避免频繁查询数据库
|
||||
- 从 `job_types` 表读取 `commonSkills` 和 `excludeKeywords` 字段(JSON格式)
|
||||
|
||||
**使用示例:**
|
||||
```javascript
|
||||
const config = await jobFilterService.getJobTypeConfig(1);
|
||||
console.log(config.commonSkills); // ['Vue', 'React', ...]
|
||||
console.log(config.excludeKeywords); // ['外包', '外派', ...]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. clearCache(jobTypeId)
|
||||
|
||||
清除职位类型配置缓存。
|
||||
|
||||
**参数:**
|
||||
- `jobTypeId` (number, 可选): 职位类型ID,不传则清除所有缓存
|
||||
|
||||
**使用示例:**
|
||||
```javascript
|
||||
// 清除特定职位类型的缓存
|
||||
jobFilterService.clearCache(1);
|
||||
|
||||
// 清除所有缓存
|
||||
jobFilterService.clearCache();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. analyzeJobMatch(jobInfo, resumeInfo, jobTypeId)
|
||||
|
||||
使用文本匹配分析职位与简历的匹配度。
|
||||
|
||||
**参数:**
|
||||
- `jobInfo` (object, 必需): 职位信息对象
|
||||
- `jobTitle` (string): 职位名称
|
||||
- `companyName` (string): 公司名称
|
||||
- `description` (string): 职位描述
|
||||
- `skills` (string): 技能要求
|
||||
- `requirements` (string): 职位要求
|
||||
- `salary` (string): 薪资范围
|
||||
- `experience` (string): 经验要求
|
||||
- `education` (string): 学历要求
|
||||
- `longitude` (number): 经度
|
||||
- `latitude` (number): 纬度
|
||||
- `resumeInfo` (object, 可选): 简历信息对象
|
||||
- `skills` (string|Array): 技能列表
|
||||
- `skillDescription` (string): 技能描述
|
||||
- `currentPosition` (string): 当前职位
|
||||
- `expectedPosition` (string): 期望职位
|
||||
- `workYears` (number|string): 工作年限
|
||||
- `expectedSalary` (string): 期望薪资
|
||||
- `education` (string): 学历
|
||||
- `jobTypeId` (number, 可选): 职位类型ID
|
||||
|
||||
**返回值:**
|
||||
```javascript
|
||||
{
|
||||
skillMatch: number, // 技能匹配度(0-100)
|
||||
experienceMatch: number, // 经验匹配度(0-100)
|
||||
salaryMatch: number, // 薪资匹配度(0-100)
|
||||
isOutsourcing: boolean, // 是否为外包岗位
|
||||
overallScore: number, // 综合推荐指数(0-100)
|
||||
matchReasons: Array<string>, // 匹配原因列表
|
||||
concerns: Array<string>, // 关注点列表
|
||||
suggestion: string, // 投递建议
|
||||
analysis: Object // 完整的分析结果(同上)
|
||||
}
|
||||
```
|
||||
|
||||
**评分规则:**
|
||||
|
||||
1. **综合推荐指数计算**:
|
||||
- 技能匹配度 × 40% + 经验匹配度 × 30% + 薪资匹配度 × 30%
|
||||
|
||||
2. **技能匹配度**:
|
||||
- 从职位描述中提取技能关键词
|
||||
- 计算简历中匹配的技能数量占比
|
||||
- 无简历信息时,基于职位关键词数量评分
|
||||
|
||||
3. **经验匹配度**:
|
||||
- 从职位描述中提取经验要求(应届、1年、2年、3年、5年、10年)
|
||||
- 根据简历工作年限与要求的匹配程度评分
|
||||
|
||||
4. **薪资匹配度**:
|
||||
- 解析职位薪资范围和期望薪资
|
||||
- 期望薪资低于职位薪资时得高分,高于职位薪资时得低分
|
||||
|
||||
5. **外包检测**:
|
||||
- 检测职位描述中是否包含:外包、外派、驻场、人力外包、项目外包
|
||||
|
||||
**使用示例:**
|
||||
```javascript
|
||||
const jobInfo = {
|
||||
jobTitle: '前端开发工程师',
|
||||
description: '要求3年以上Vue开发经验,熟悉React',
|
||||
salary: '15-25K',
|
||||
experience: '3-5年'
|
||||
};
|
||||
|
||||
const resumeInfo = {
|
||||
skills: 'Vue, React, JavaScript',
|
||||
workYears: 4,
|
||||
expectedSalary: '20K'
|
||||
};
|
||||
|
||||
const result = await jobFilterService.analyzeJobMatch(jobInfo, resumeInfo, 1);
|
||||
console.log(result.overallScore); // 85
|
||||
console.log(result.matchReasons); // ['技能匹配度高', '工作经验符合要求', ...]
|
||||
console.log(result.suggestion); // '强烈推荐投递:匹配度很高'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. filterJobs(jobs, filterRules, resumeInfo, jobTypeId)
|
||||
|
||||
过滤职位列表,返回匹配的职位(带匹配分数)。
|
||||
|
||||
**参数:**
|
||||
- `jobs` (Array, 必需): 职位列表(可以是 Sequelize 模型实例或普通对象)
|
||||
- `filterRules` (object, 可选): 过滤规则
|
||||
- `minScore` (number, 默认60): 最低匹配分数
|
||||
- `excludeOutsourcing` (boolean, 默认true): 是否排除外包岗位
|
||||
- `excludeKeywords` (Array<string>, 默认[]): 额外排除关键词列表
|
||||
- `resumeInfo` (object, 可选): 简历信息
|
||||
- `jobTypeId` (number, 可选): 职位类型ID
|
||||
|
||||
**返回值:**
|
||||
```javascript
|
||||
Array<{
|
||||
...jobData, // 原始职位数据
|
||||
matchScore: number, // 匹配分数
|
||||
matchAnalysis: Object // 完整的匹配分析结果
|
||||
}>
|
||||
```
|
||||
|
||||
**过滤逻辑:**
|
||||
1. 对每个职位进行匹配度分析
|
||||
2. 过滤掉匹配分数低于 `minScore` 的职位
|
||||
3. 如果 `excludeOutsourcing` 为 true,过滤掉外包岗位
|
||||
4. 过滤掉包含排除关键词的职位
|
||||
5. 按匹配分数降序排序
|
||||
|
||||
**使用示例:**
|
||||
```javascript
|
||||
const jobs = [
|
||||
{ jobTitle: '前端开发', description: 'Vue开发...', salary: '20K' },
|
||||
{ jobTitle: '后端开发', description: 'Java开发...', salary: '25K' }
|
||||
];
|
||||
|
||||
const resumeInfo = {
|
||||
skills: 'Vue, JavaScript',
|
||||
workYears: 3
|
||||
};
|
||||
|
||||
const filterRules = {
|
||||
minScore: 70,
|
||||
excludeOutsourcing: true,
|
||||
excludeKeywords: ['销售']
|
||||
};
|
||||
|
||||
const filteredJobs = await jobFilterService.filterJobs(
|
||||
jobs,
|
||||
filterRules,
|
||||
resumeInfo,
|
||||
1
|
||||
);
|
||||
|
||||
console.log(filteredJobs.length); // 过滤后的职位数量
|
||||
console.log(filteredJobs[0].matchScore); // 第一个职位的匹配分数
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. calculateJobScoreWithWeights(jobData, resumeInfo, accountConfig, jobTypeConfig, priorityWeights)
|
||||
|
||||
根据自定义权重配置计算职位评分。
|
||||
|
||||
**参数:**
|
||||
- `jobData` (object, 必需): 职位数据
|
||||
- `longitude` (number): 经度
|
||||
- `latitude` (number): 纬度
|
||||
- `salary` (string): 薪资范围
|
||||
- `experience` (string): 经验要求
|
||||
- `education` (string): 学历要求
|
||||
- `resumeInfo` (object, 必需): 简历信息
|
||||
- `expectedSalary` (string): 期望薪资
|
||||
- `workYears` (string|number): 工作年限
|
||||
- `education` (string): 学历
|
||||
- `skills` (string|Array): 技能列表
|
||||
- `accountConfig` (object, 必需): 账号配置
|
||||
- `user_longitude` (number): 用户经度
|
||||
- `user_latitude` (number): 用户纬度
|
||||
- `jobTypeConfig` (object, 可选): 职位类型配置(包含 commonSkills)
|
||||
- `priorityWeights` (Array, 必需): 权重配置
|
||||
```javascript
|
||||
[
|
||||
{ key: 'distance', weight: 30 }, // 距离权重(0-100)
|
||||
{ key: 'salary', weight: 40 }, // 薪资权重
|
||||
{ key: 'work_years', weight: 20 }, // 工作年限权重
|
||||
{ key: 'education', weight: 10 } // 学历权重
|
||||
]
|
||||
```
|
||||
|
||||
**返回值:**
|
||||
```javascript
|
||||
{
|
||||
totalScore: number, // 总分(0-100+,技能评分作为额外加分项)
|
||||
scores: {
|
||||
distance: number, // 距离评分
|
||||
salary: number, // 薪资评分
|
||||
work_years: number, // 工作年限评分
|
||||
education: number, // 学历评分
|
||||
skills: number // 技能评分(如果有 jobTypeConfig)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**评分规则:**
|
||||
|
||||
1. **距离评分**:
|
||||
- 0-5km: 100分
|
||||
- 5-10km: 90分
|
||||
- 10-20km: 80分
|
||||
- 20-50km: 60分
|
||||
- 50km以上: 30分
|
||||
|
||||
2. **薪资评分**:
|
||||
- 职位薪资 ≥ 期望薪资: 100分
|
||||
- 职位薪资 ≥ 期望薪资 × 0.8: 80分
|
||||
- 职位薪资 ≥ 期望薪资 × 0.6: 60分
|
||||
- 其他: 40分
|
||||
|
||||
3. **工作年限评分**:
|
||||
- 简历年限 ≥ 职位要求: 100分
|
||||
- 简历年限 ≥ 职位要求 × 0.8: 80分
|
||||
- 简历年限 ≥ 职位要求 × 0.6: 60分
|
||||
- 其他: 40分
|
||||
|
||||
4. **学历评分**:
|
||||
- 简历学历 ≥ 职位要求: 100分
|
||||
- 简历学历 = 职位要求 - 1级: 70分
|
||||
- 其他: 40分
|
||||
|
||||
5. **技能评分**:
|
||||
- 计算简历技能与职位类型配置的技能关键词匹配度
|
||||
- 作为额外加分项,固定权重10%
|
||||
|
||||
**使用示例:**
|
||||
```javascript
|
||||
const jobData = {
|
||||
longitude: 116.3974,
|
||||
latitude: 39.9093,
|
||||
salary: '20-30K',
|
||||
experience: '3-5年',
|
||||
education: '本科'
|
||||
};
|
||||
|
||||
const resumeInfo = {
|
||||
expectedSalary: '25K',
|
||||
workYears: 4,
|
||||
education: '本科',
|
||||
skills: ['Vue', 'React', 'JavaScript']
|
||||
};
|
||||
|
||||
const accountConfig = {
|
||||
user_longitude: 116.4074,
|
||||
user_latitude: 39.9042
|
||||
};
|
||||
|
||||
const jobTypeConfig = {
|
||||
commonSkills: ['Vue', 'React', 'JavaScript', 'Node.js']
|
||||
};
|
||||
|
||||
const priorityWeights = [
|
||||
{ key: 'distance', weight: 30 },
|
||||
{ key: 'salary', weight: 40 },
|
||||
{ key: 'work_years', weight: 20 },
|
||||
{ key: 'education', weight: 10 }
|
||||
];
|
||||
|
||||
const result = jobFilterService.calculateJobScoreWithWeights(
|
||||
jobData,
|
||||
resumeInfo,
|
||||
accountConfig,
|
||||
jobTypeConfig,
|
||||
priorityWeights
|
||||
);
|
||||
|
||||
console.log(result.totalScore); // 85
|
||||
console.log(result.scores); // { distance: 100, salary: 90, work_years: 100, ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. parseSalaryRange(salaryDesc)
|
||||
|
||||
解析薪资范围字符串。
|
||||
|
||||
**参数:**
|
||||
- `salaryDesc` (string): 薪资描述字符串
|
||||
- 支持格式:`15-25K`、`25K`、`5000-6000元/月`、`2-3万`、`20000-30000` 等
|
||||
|
||||
**返回值:**
|
||||
```javascript
|
||||
{
|
||||
min: number, // 最低薪资(元)
|
||||
max: number // 最高薪资(元)
|
||||
}
|
||||
```
|
||||
或 `null`(解析失败时)
|
||||
|
||||
**使用示例:**
|
||||
```javascript
|
||||
const range1 = jobFilterService.parseSalaryRange('15-25K');
|
||||
console.log(range1); // { min: 15000, max: 25000 }
|
||||
|
||||
const range2 = jobFilterService.parseSalaryRange('2-3万');
|
||||
console.log(range2); // { min: 20000, max: 30000 }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. parseExpectedSalary(expectedSalary)
|
||||
|
||||
解析期望薪资字符串,返回平均值。
|
||||
|
||||
**参数:**
|
||||
- `expectedSalary` (string): 期望薪资描述字符串
|
||||
|
||||
**返回值:**
|
||||
- `number`: 期望薪资数值(元),如果是范围则返回平均值
|
||||
- `null`: 解析失败时
|
||||
|
||||
**使用示例:**
|
||||
```javascript
|
||||
const salary1 = jobFilterService.parseExpectedSalary('20-30K');
|
||||
console.log(salary1); // 25000(平均值)
|
||||
|
||||
const salary2 = jobFilterService.parseExpectedSalary('25K');
|
||||
console.log(salary2); // 25000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8. parseWorkYears(workYearsStr)
|
||||
|
||||
解析工作年限字符串为数字。
|
||||
|
||||
**参数:**
|
||||
- `workYearsStr` (string): 工作年限字符串(如 "3年"、"5年以上")
|
||||
|
||||
**返回值:**
|
||||
- `number`: 工作年限数字
|
||||
- `null`: 解析失败时
|
||||
|
||||
**使用示例:**
|
||||
```javascript
|
||||
const years = jobFilterService.parseWorkYears('3-5年');
|
||||
console.log(years); // 3(提取第一个数字)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 辅助方法
|
||||
|
||||
### buildJobText(jobInfo)
|
||||
|
||||
构建职位文本(用于匹配)。
|
||||
|
||||
**参数:**
|
||||
- `jobInfo` (object): 职位信息对象
|
||||
|
||||
**返回值:**
|
||||
- `string`: 合并后的职位文本(小写)
|
||||
|
||||
---
|
||||
|
||||
### buildResumeText(resumeInfo)
|
||||
|
||||
构建简历文本(用于匹配)。
|
||||
|
||||
**参数:**
|
||||
- `resumeInfo` (object): 简历信息对象
|
||||
|
||||
**返回值:**
|
||||
- `string`: 合并后的简历文本(小写)
|
||||
|
||||
---
|
||||
|
||||
### calculateSkillMatch(jobText, resumeText, commonSkills)
|
||||
|
||||
计算技能匹配度(0-100分)。
|
||||
|
||||
**参数:**
|
||||
- `jobText` (string): 职位文本
|
||||
- `resumeText` (string): 简历文本
|
||||
- `commonSkills` (Array<string>): 技能关键词列表
|
||||
|
||||
**返回值:**
|
||||
- `number`: 匹配度分数(0-100)
|
||||
|
||||
---
|
||||
|
||||
### calculateExperienceMatch(jobInfo, resumeInfo)
|
||||
|
||||
计算经验匹配度(0-100分)。
|
||||
|
||||
**参数:**
|
||||
- `jobInfo` (object): 职位信息
|
||||
- `resumeInfo` (object): 简历信息
|
||||
|
||||
**返回值:**
|
||||
- `number`: 匹配度分数(0-100)
|
||||
|
||||
---
|
||||
|
||||
### calculateSalaryMatch(jobInfo, resumeInfo)
|
||||
|
||||
计算薪资合理性(0-100分)。
|
||||
|
||||
**参数:**
|
||||
- `jobInfo` (object): 职位信息
|
||||
- `resumeInfo` (object): 简历信息
|
||||
|
||||
**返回值:**
|
||||
- `number`: 匹配度分数(0-100)
|
||||
|
||||
---
|
||||
|
||||
### checkOutsourcing(jobText)
|
||||
|
||||
检查是否为外包岗位。
|
||||
|
||||
**参数:**
|
||||
- `jobText` (string): 职位文本
|
||||
|
||||
**返回值:**
|
||||
- `boolean`: 是否为外包
|
||||
|
||||
---
|
||||
|
||||
## 默认配置
|
||||
|
||||
### 默认技能关键词
|
||||
|
||||
```javascript
|
||||
[
|
||||
'Vue', 'React', 'Angular', 'JavaScript', 'TypeScript', 'Node.js',
|
||||
'Python', 'Java', 'C#', '.NET', 'Flutter', 'React Native',
|
||||
'Webpack', 'Vite', 'Redux', 'MobX', 'Express', 'Koa',
|
||||
'Django', 'Flask', 'MySQL', 'MongoDB', 'Redis',
|
||||
'WebRTC', 'FFmpeg', 'Canvas', 'WebSocket', 'HTML5', 'CSS3',
|
||||
'jQuery', 'Bootstrap', 'Element UI', 'Ant Design',
|
||||
'Git', 'Docker', 'Kubernetes', 'AWS', 'Azure',
|
||||
'Selenium', 'Jest', 'Mocha', 'Cypress'
|
||||
]
|
||||
```
|
||||
|
||||
### 默认排除关键词
|
||||
|
||||
```javascript
|
||||
[
|
||||
'外包', '外派', '驻场', '销售', '客服', '电话销售',
|
||||
'地推', '推广', '市场', '运营', '行政', '文员'
|
||||
]
|
||||
```
|
||||
|
||||
## 数据库依赖
|
||||
|
||||
该服务依赖以下数据库表:
|
||||
|
||||
### job_types 表
|
||||
|
||||
存储职位类型配置,需要包含以下字段:
|
||||
- `id` (number): 职位类型ID
|
||||
- `is_enabled` (number): 是否启用(1=启用,0=禁用)
|
||||
- `commonSkills` (string|JSON): 技能关键词列表(JSON数组字符串)
|
||||
- `excludeKeywords` (string|JSON): 排除关键词列表(JSON数组字符串)
|
||||
|
||||
**示例数据:**
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"is_enabled": 1,
|
||||
"commonSkills": "[\"Vue\", \"React\", \"JavaScript\"]",
|
||||
"excludeKeywords": "[\"外包\", \"外派\"]"
|
||||
}
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 完整使用流程
|
||||
|
||||
```javascript
|
||||
const jobFilterService = require('./job_filter_service');
|
||||
|
||||
// 1. 分析单个职位的匹配度
|
||||
const jobInfo = {
|
||||
jobTitle: '高级前端开发工程师',
|
||||
companyName: 'XX科技有限公司',
|
||||
description: '负责前端架构设计,要求5年以上Vue/React开发经验',
|
||||
skills: 'Vue, React, TypeScript, Node.js',
|
||||
requirements: '本科及以上学历,有大型项目经验',
|
||||
salary: '25-40K·14薪'
|
||||
};
|
||||
|
||||
const resumeInfo = {
|
||||
skills: 'Vue, React, JavaScript, TypeScript, Node.js, Webpack',
|
||||
skillDescription: '精通Vue生态,熟悉React,有5年+前端开发经验',
|
||||
currentPosition: '高级前端开发工程师',
|
||||
expectedPosition: '前端架构师',
|
||||
workYears: 6,
|
||||
expectedSalary: '30K',
|
||||
education: '本科'
|
||||
};
|
||||
|
||||
const analysis = await jobFilterService.analyzeJobMatch(
|
||||
jobInfo,
|
||||
resumeInfo,
|
||||
1 // 职位类型ID
|
||||
);
|
||||
|
||||
console.log('匹配分析结果:');
|
||||
console.log('综合评分:', analysis.overallScore);
|
||||
console.log('技能匹配度:', analysis.skillMatch);
|
||||
console.log('经验匹配度:', analysis.experienceMatch);
|
||||
console.log('薪资匹配度:', analysis.salaryMatch);
|
||||
console.log('是否外包:', analysis.isOutsourcing);
|
||||
console.log('匹配原因:', analysis.matchReasons);
|
||||
console.log('关注点:', analysis.concerns);
|
||||
console.log('投递建议:', analysis.suggestion);
|
||||
|
||||
// 2. 过滤职位列表
|
||||
const jobs = await Job.findAll({ where: { status: 1 } });
|
||||
|
||||
const filteredJobs = await jobFilterService.filterJobs(
|
||||
jobs,
|
||||
{
|
||||
minScore: 70,
|
||||
excludeOutsourcing: true,
|
||||
excludeKeywords: ['销售', '客服']
|
||||
},
|
||||
resumeInfo,
|
||||
1
|
||||
);
|
||||
|
||||
console.log(`共找到 ${filteredJobs.length} 个匹配的职位`);
|
||||
filteredJobs.forEach((job, index) => {
|
||||
console.log(`${index + 1}. ${job.jobTitle} - 匹配分数:${job.matchScore}`);
|
||||
});
|
||||
|
||||
// 3. 自定义权重评分
|
||||
const accountConfig = {
|
||||
user_longitude: 116.4074,
|
||||
user_latitude: 39.9042
|
||||
};
|
||||
|
||||
const priorityWeights = [
|
||||
{ key: 'distance', weight: 25 },
|
||||
{ key: 'salary', weight: 35 },
|
||||
{ key: 'work_years', weight: 25 },
|
||||
{ key: 'education', weight: 15 }
|
||||
];
|
||||
|
||||
const scoreResult = jobFilterService.calculateJobScoreWithWeights(
|
||||
jobInfo,
|
||||
resumeInfo,
|
||||
accountConfig,
|
||||
{ commonSkills: ['Vue', 'React', 'TypeScript'] },
|
||||
priorityWeights
|
||||
);
|
||||
|
||||
console.log('自定义权重评分:', scoreResult.totalScore);
|
||||
console.log('各项评分:', scoreResult.scores);
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **缓存机制**:职位类型配置会缓存5分钟,修改数据库配置后需要调用 `clearCache()` 清除缓存
|
||||
|
||||
2. **性能优化**:
|
||||
- 大量职位过滤时,建议分批处理
|
||||
- 避免在循环中频繁调用 `getJobTypeConfig()`,配置会被自动缓存
|
||||
|
||||
3. **文本匹配**:
|
||||
- 所有文本匹配均为小写匹配,不区分大小写
|
||||
- 匹配逻辑较为简单,如需更精确的匹配,建议使用 AI 分析(二期规划)
|
||||
|
||||
4. **薪资解析**:
|
||||
- 支持多种薪资格式,但可能无法解析所有格式
|
||||
- 解析失败时返回默认分数或 null
|
||||
|
||||
5. **错误处理**:
|
||||
- 所有方法都包含错误处理,失败时返回默认值或空结果
|
||||
- 建议在生产环境中监控日志输出
|
||||
|
||||
## 版本历史
|
||||
|
||||
- **v1.0.0**: 初始版本,支持基础文本匹配和过滤功能
|
||||
- 支持从数据库动态获取职位类型配置
|
||||
- 支持自定义权重评分计算
|
||||
|
||||
@@ -1,345 +0,0 @@
|
||||
const ai_service_module = require('../../../services/ai_service');
|
||||
const ai_service = ai_service_module.getInstance();
|
||||
|
||||
/**
|
||||
* 聊天管理模块
|
||||
* 负责沟通列表、沟通详情、发送消息等与设备端的 MQTT 指令对接
|
||||
*/
|
||||
class ChatManager {
|
||||
/**
|
||||
* 解析沟通列表返回值,统一为 { friendList, foldText, ... }
|
||||
* 设备端可能返回 code:0 + zpData 或 code:200 + data
|
||||
* @private
|
||||
*/
|
||||
_parse_chat_list_response(response) {
|
||||
if (!response) return null;
|
||||
const raw = response.zpData != null ? response.zpData : response.data;
|
||||
if (!raw) return { friendList: [], foldText: '', filterEncryptIdList: [], filterBossIdList: [] };
|
||||
return {
|
||||
friendList: Array.isArray(raw.friendList) ? raw.friendList : [],
|
||||
foldText: raw.foldText || '',
|
||||
filterEncryptIdList: Array.isArray(raw.filterEncryptIdList) ? raw.filterEncryptIdList : [],
|
||||
filterBossIdList: Array.isArray(raw.filterBossIdList) ? raw.filterBossIdList : []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取聊天列表
|
||||
* 返回值结构: { friendList, foldText, filterEncryptIdList, filterBossIdList }
|
||||
* friendList 每项: friendId, encryptFriendId, name, updateTime, brandName, jobName, jobCity, positionName, bossTitle 等
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} mqttClient - MQTT客户端
|
||||
* @param {object} params - 参数
|
||||
* @returns {Promise<object>} { friendList, foldText, filterEncryptIdList, filterBossIdList }
|
||||
*/
|
||||
async get_chat_list(sn_code, mqttClient, params = {}) {
|
||||
const { platform = 'boss', pageCount = 3 } = params;
|
||||
console.log(`[聊天管理] 开始获取设备 ${sn_code} 的聊天列表`);
|
||||
|
||||
const response = await mqttClient.publishAndWait(sn_code, {
|
||||
platform,
|
||||
action: 'get_chat_list',
|
||||
data: { pageCount }
|
||||
});
|
||||
|
||||
// 沟通列表接口成功为 code: 0 或 code: 200
|
||||
const ok = response && (response.code === 0 || response.code === 200);
|
||||
if (!ok) {
|
||||
console.error(`[聊天管理] 获取聊天列表失败:`, response);
|
||||
throw new Error(response?.message || '获取聊天列表失败');
|
||||
}
|
||||
|
||||
const parsed = this._parse_chat_list_response(response);
|
||||
console.log(`[聊天管理] 成功获取聊天列表,共 ${parsed.friendList.length} 个联系人`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析沟通详情返回值(两种形态二选一)
|
||||
* 形态1 - 会话/职位信息: zpData.data + zpData.job
|
||||
* 形态2 - 聊天消息列表: zpData.hasMore + zpData.messages
|
||||
* @private
|
||||
*/
|
||||
_parse_chat_detail_response(response) {
|
||||
if (!response) return null;
|
||||
const raw = response.zpData != null ? response.zpData : response.data;
|
||||
if (!raw) return { variant: 'unknown', data: null, job: null, hasMore: false, messages: [] };
|
||||
|
||||
// 形态2: 消息列表(有 messages 数组)
|
||||
if (Array.isArray(raw.messages)) {
|
||||
return {
|
||||
variant: 'messages',
|
||||
hasMore: !!raw.hasMore,
|
||||
messages: raw.messages,
|
||||
type: raw.type,
|
||||
minMsgId: raw.minMsgId
|
||||
};
|
||||
}
|
||||
|
||||
// 形态1: 会话详情(data + job)
|
||||
if (raw.data != null || raw.job != null) {
|
||||
return {
|
||||
variant: 'session',
|
||||
data: raw.data || null,
|
||||
job: raw.job || null
|
||||
};
|
||||
}
|
||||
|
||||
return { variant: 'unknown', data: null, job: null, hasMore: false, messages: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取沟通详情(会话信息或聊天消息列表)
|
||||
* 返回值: { variant: 'session'|'messages', ... }
|
||||
* - session: data(boss/会话信息), job(职位信息)
|
||||
* - messages: hasMore, messages[], type, minMsgId
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} mqttClient - MQTT客户端
|
||||
* @param {object} params - 参数,如 friendId/encryptBossId/encryptJobId 等,由设备端约定
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
async get_chat_detail(sn_code, mqttClient, params = {}) {
|
||||
const { platform = 'boss', ...rest } = params;
|
||||
console.log(`[聊天管理] 开始获取设备 ${sn_code} 的沟通详情`);
|
||||
|
||||
const response = await mqttClient.publishAndWait(sn_code, {
|
||||
platform,
|
||||
action: 'get_chat_detail',
|
||||
data: rest
|
||||
});
|
||||
|
||||
const ok = response && (response.code === 0 || response.code === 200);
|
||||
if (!ok) {
|
||||
console.error(`[聊天管理] 获取沟通详情失败:`, response);
|
||||
throw new Error(response?.message || '获取沟通详情失败');
|
||||
}
|
||||
|
||||
const parsed = this._parse_chat_detail_response(response);
|
||||
const logExtra = parsed.variant === 'session'
|
||||
? `会话`
|
||||
: parsed.variant === 'messages'
|
||||
? `消息 ${parsed.messages.length} 条`
|
||||
: `未知`;
|
||||
console.log(`[聊天管理] 成功获取沟通详情 (${logExtra})`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送聊天消息(支持多条 + 文本/发简历/换电话/换微信)
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} mqttClient - MQTT客户端
|
||||
* @param {object} params - friendId(必填), messages(数组), chatType, use_real_type, platform
|
||||
* @param {string} params.friendId - 好友ID,用于打开该好友的聊天面板
|
||||
* @param {Array} params.messages - 每项为 string 或 { type: 'text'|'send_resume'|'exchange_phone'|'exchange_wechat', content?: string }
|
||||
* @param {boolean} params.use_real_type - 是否模拟真实打字,默认 false
|
||||
* @returns {Promise<object>} 发送结果
|
||||
*/
|
||||
async send_chat_message(sn_code, mqttClient, params) {
|
||||
const { friendId, messages, chatType, use_real_type = false, platform = 'boss' } = params || {};
|
||||
|
||||
if (!friendId) throw new Error('friendId 不能为空');
|
||||
if (!Array.isArray(messages) || messages.length === 0) throw new Error('messages 必须是非空数组');
|
||||
|
||||
const normalized_messages = messages.map((item) => {
|
||||
if (typeof item === 'string') return { type: 'text', content: item };
|
||||
return { type: item.type || 'text', content: item.content || '' };
|
||||
});
|
||||
|
||||
console.log(`[聊天管理] 设备 ${sn_code} 发送聊天消息,friendId=${friendId},条数=${normalized_messages.length}`);
|
||||
|
||||
const response = await mqttClient.publishAndWait(sn_code, {
|
||||
platform,
|
||||
action: 'send_chat_message',
|
||||
data: { friendId, messages: normalized_messages, chatType, use_real_type: !!use_real_type }
|
||||
});
|
||||
|
||||
if (!response || (response.code !== 0 && response.code !== 200)) {
|
||||
console.error(`[聊天管理] 聊天消息发送失败:`, response);
|
||||
throw new Error(response?.message || '聊天消息发送失败');
|
||||
}
|
||||
|
||||
console.log(`[聊天管理] 聊天消息发送成功`);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 AI 自动决定是否回复,并发送回复
|
||||
* 流程:
|
||||
* 1. 根据参数获取沟通详情(消息列表)
|
||||
* 2. 如果最后一句是 HR 说的,则调用阿里云 Qwen 生成回复文案
|
||||
* 3. 通过 send_chat_message 把回复发出去
|
||||
*
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} mqttClient - MQTT客户端
|
||||
* @param {object} params - 包含 friendId + 获取详情所需参数(如 encryptBossId/encryptJobId 等)
|
||||
* @returns {Promise<object>} { replied, reply_content?, hr_message_text?, reason? }
|
||||
*/
|
||||
async auto_reply_with_ai(sn_code, mqttClient, params = {}) {
|
||||
const { friendId, platform = 'boss', ...detailParams } = params;
|
||||
|
||||
if (!friendId) {
|
||||
throw new Error('friendId 不能为空');
|
||||
}
|
||||
|
||||
// 1. 获取沟通详情(期望拿到消息列表)
|
||||
const detail = await this.get_chat_detail(sn_code, mqttClient, {
|
||||
platform,
|
||||
...detailParams
|
||||
});
|
||||
|
||||
if (!detail || detail.variant !== 'messages' || !Array.isArray(detail.messages) || detail.messages.length === 0) {
|
||||
return { replied: false, reason: '无可用消息' };
|
||||
}
|
||||
|
||||
const messages = detail.messages;
|
||||
|
||||
// 2. 推断 HR 与 求职者 uid
|
||||
let hr_uid = null;
|
||||
let geek_uid = null;
|
||||
|
||||
for (const msg of messages) {
|
||||
const body = msg.body || {};
|
||||
const jobDesc = body.jobDesc || body.job_desc || null;
|
||||
if (jobDesc) {
|
||||
if (jobDesc.boss && jobDesc.boss.uid && !hr_uid) {
|
||||
hr_uid = jobDesc.boss.uid;
|
||||
}
|
||||
if (jobDesc.geek && jobDesc.geek.uid && !geek_uid) {
|
||||
geek_uid = jobDesc.geek.uid;
|
||||
}
|
||||
}
|
||||
if (hr_uid && geek_uid) break;
|
||||
}
|
||||
|
||||
const last = messages[messages.length - 1];
|
||||
|
||||
// 兜底:还没有 hr_uid 时,用最后一条的 from/to 做简单推断
|
||||
if ((!hr_uid || !geek_uid) && last && last.from && last.to) {
|
||||
hr_uid = hr_uid || last.from.uid;
|
||||
geek_uid = geek_uid || last.to.uid;
|
||||
}
|
||||
|
||||
if (!last || !last.from || !hr_uid || last.from.uid !== hr_uid) {
|
||||
// 最后一条不是 HR 发的,不自动回复
|
||||
return { replied: false, reason: '最后一条不是HR消息' };
|
||||
}
|
||||
|
||||
// 取 HR 文本内容(普通文本优先)
|
||||
const body = last.body || {};
|
||||
const hr_message_text =
|
||||
(typeof body.text === 'string' && body.text) ||
|
||||
(typeof last.pushText === 'string' && last.pushText) ||
|
||||
'';
|
||||
|
||||
if (!hr_message_text || !hr_message_text.trim()) {
|
||||
return { replied: false, reason: 'HR消息没有可用文本' };
|
||||
}
|
||||
|
||||
// 3. 调用阿里云 Qwen 生成回复文案(已在 config 中切换为 qwen-plus)
|
||||
const jobInfo = detail.job || {};
|
||||
|
||||
const reply_content = await ai_service.generateChatContent({
|
||||
jobInfo,
|
||||
resumeInfo: null,
|
||||
chatType: 'reply',
|
||||
hrMessage: hr_message_text,
|
||||
previousMessages: [] // 如需上下文,这里可以把 detail.messages 映射进去
|
||||
});
|
||||
|
||||
if (!reply_content || !reply_content.trim()) {
|
||||
return { replied: false, reason: 'AI 未生成有效回复' };
|
||||
}
|
||||
|
||||
// 4. 通过统一的 send_chat_message 下发回复
|
||||
await this.send_chat_message(sn_code, mqttClient, {
|
||||
friendId,
|
||||
messages: [{ type: 'text', content: reply_content }],
|
||||
chatType: 'reply',
|
||||
platform
|
||||
});
|
||||
|
||||
return {
|
||||
replied: true,
|
||||
reply_content,
|
||||
hr_message_text
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动获取沟通列表 + 按会话自动 AI 回复
|
||||
* 1. 调用 get_chat_list 获取会话列表
|
||||
* 2. 对每个会话按 friendId 调用 auto_reply_with_ai(内部会先获取详情,再决定是否回复)
|
||||
*
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} mqttClient - MQTT客户端
|
||||
* @param {object} params - { platform?, pageCount? }
|
||||
* @returns {Promise<object>} { success, total_contacts, replied_count, details: [...] }
|
||||
*/
|
||||
async auto_chat_ai(sn_code, mqttClient, params = {}) {
|
||||
const { platform = 'boss', pageCount = 3 } = params;
|
||||
|
||||
// 1. 获取沟通列表
|
||||
const listResult = await this.get_chat_list(sn_code, mqttClient, {
|
||||
platform,
|
||||
pageCount
|
||||
});
|
||||
|
||||
const friendList = Array.isArray(listResult.friendList) ? listResult.friendList : [];
|
||||
|
||||
if (friendList.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
total_contacts: 0,
|
||||
replied_count: 0,
|
||||
details: [],
|
||||
message: '没有可沟通的会话'
|
||||
};
|
||||
}
|
||||
|
||||
let replied_count = 0;
|
||||
const details = [];
|
||||
|
||||
// 2. 逐个会话顺序处理,避免并发下发指令
|
||||
for (const friend of friendList) {
|
||||
const friendId = friend.friendId;
|
||||
if (!friendId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const r = await this.auto_reply_with_ai(sn_code, mqttClient, {
|
||||
platform,
|
||||
friendId
|
||||
});
|
||||
|
||||
if (r.replied) {
|
||||
replied_count++;
|
||||
}
|
||||
|
||||
details.push({
|
||||
friendId,
|
||||
replied: !!r.replied,
|
||||
reason: r.reason || null,
|
||||
reply_content: r.reply_content || null
|
||||
});
|
||||
} catch (error) {
|
||||
details.push({
|
||||
friendId,
|
||||
replied: false,
|
||||
reason: error.message || '自动回复失败',
|
||||
reply_content: null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
total_contacts: friendList.length,
|
||||
replied_count,
|
||||
details,
|
||||
message: '自动获取列表并尝试AI回复完成'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ChatManager();
|
||||
@@ -1,13 +0,0 @@
|
||||
/**
|
||||
* Managers 模块统一导出
|
||||
*/
|
||||
|
||||
const jobManager = require('./jobManager');
|
||||
const resumeManager = require('./resumeManager');
|
||||
const chatManager = require('./chatManager');
|
||||
|
||||
module.exports = {
|
||||
jobManager,
|
||||
resumeManager,
|
||||
chatManager
|
||||
};
|
||||
@@ -1,12 +1,9 @@
|
||||
const aiServiceModule = require('../../../services/ai_service');
|
||||
const { jobFilterService } = require('../services');
|
||||
const logs = require('../../logProxy');
|
||||
const db = require('../../dbProxy');
|
||||
const aiService = require('./aiService');
|
||||
const jobFilterService = require('./job_filter_service');
|
||||
const logs = require('../logProxy');
|
||||
const db = require('../dbProxy');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
// 实例化AI服务
|
||||
const aiService = aiServiceModule.getInstance();
|
||||
|
||||
/**
|
||||
* 简历管理模块
|
||||
* 负责简历获取、分析、存储和匹配度计算
|
||||
@@ -1,9 +0,0 @@
|
||||
/**
|
||||
* Services 模块统一导出
|
||||
*/
|
||||
|
||||
const jobFilterService = require('./jobFilterService');
|
||||
|
||||
module.exports = {
|
||||
jobFilterService
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
/**
|
||||
* Utils 模块统一导出
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
// 工具函数将在需要时添加
|
||||
};
|
||||
@@ -29,7 +29,12 @@ class MqttSyncClient {
|
||||
return;
|
||||
}
|
||||
|
||||
// 记录日志但不包含敏感信息
|
||||
const { maskSensitiveData } = require('../../utils/crypto_utils');
|
||||
const safeMessage = maskSensitiveData(messageObj, ['password', 'pwd', 'token', 'secret', 'key', 'cookie']);
|
||||
console.log('[MQTT] 收到消息', topic, '类型:', messageObj.action || messageObj.type || 'unknown');
|
||||
|
||||
// 优化:只通知相关 topic 的监听器,而不是所有监听器
|
||||
// 1. 触发该 topic 的专用监听器
|
||||
const topicListeners = this.messageListeners.get(topic);
|
||||
if (topicListeners && topicListeners.size > 0) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const db = require('../dbProxy.js');
|
||||
const logProxy = require('../logProxy.js');
|
||||
const deviceManager = require('../schedule/core/deviceManager.js');
|
||||
const deviceManager = require('../schedule/deviceManager.js');
|
||||
|
||||
/**
|
||||
* MQTT 消息分发器
|
||||
@@ -212,6 +212,8 @@ class MqttDispatcher {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[MQTT心跳] 收到设备 ${sn_code} 的心跳消息`);
|
||||
|
||||
// 移除 device_status 模型依赖
|
||||
// const device_status = db.getModel('device_status');
|
||||
// let device = await device_status.findByPk(sn_code);
|
||||
@@ -284,6 +286,7 @@ class MqttDispatcher {
|
||||
},
|
||||
{ where: { sn_code } }
|
||||
);
|
||||
console.log(`[MQTT心跳] 设备 ${sn_code} 状态已更新到数据库 - 在线: true, 登录: ${updateData.isLoggedIn || false}`);
|
||||
} catch (error) {
|
||||
console.error(`[MQTT心跳] 更新数据库状态失败:`, error);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const db = require('../../dbProxy');
|
||||
const db = require('../dbProxy');
|
||||
|
||||
/**
|
||||
* 统一错误处理模块
|
||||
@@ -1,9 +1,9 @@
|
||||
const logs = require('../../logProxy');
|
||||
const db = require('../../dbProxy');
|
||||
const jobManager = require('../../job/index');
|
||||
const ScheduleUtils = require('../utils/scheduleUtils');
|
||||
const ScheduleConfig = require('../infrastructure/config');
|
||||
const authorizationService = require('../../../services/authorization_service');
|
||||
const logs = require('../logProxy');
|
||||
const db = require('../dbProxy');
|
||||
const jobManager = require('../job/index');
|
||||
const ScheduleUtils = require('./utils');
|
||||
const ScheduleConfig = require('./config');
|
||||
const authorizationService = require('../../services/authorization_service');
|
||||
|
||||
|
||||
/**
|
||||
@@ -129,7 +129,7 @@ class CommandManager {
|
||||
|
||||
// 4.5 推送指令开始执行状态
|
||||
try {
|
||||
const deviceWorkStatusNotifier = require('../notifiers/deviceWorkStatusNotifier');
|
||||
const deviceWorkStatusNotifier = require('./deviceWorkStatusNotifier');
|
||||
const taskQueue = require('./taskQueue');
|
||||
const summary = await taskQueue.getTaskStatusSummary(task.sn_code);
|
||||
await deviceWorkStatusNotifier.sendDeviceWorkStatus(task.sn_code, summary, {
|
||||
@@ -163,7 +163,7 @@ class CommandManager {
|
||||
|
||||
// 6.5 推送指令完成状态
|
||||
try {
|
||||
const deviceWorkStatusNotifier = require('../notifiers/deviceWorkStatusNotifier');
|
||||
const deviceWorkStatusNotifier = require('./deviceWorkStatusNotifier');
|
||||
const taskQueue = require('./taskQueue');
|
||||
const summary = await taskQueue.getTaskStatusSummary(task.sn_code);
|
||||
await deviceWorkStatusNotifier.sendDeviceWorkStatus(task.sn_code, summary);
|
||||
@@ -193,7 +193,7 @@ class CommandManager {
|
||||
|
||||
// 推送指令失败状态
|
||||
try {
|
||||
const deviceWorkStatusNotifier = require('../notifiers/deviceWorkStatusNotifier');
|
||||
const deviceWorkStatusNotifier = require('./deviceWorkStatusNotifier');
|
||||
const taskQueue = require('./taskQueue');
|
||||
const summary = await taskQueue.getTaskStatusSummary(task.sn_code);
|
||||
await deviceWorkStatusNotifier.sendDeviceWorkStatus(task.sn_code, summary);
|
||||
@@ -213,17 +213,29 @@ class CommandManager {
|
||||
* @private
|
||||
*/
|
||||
async _execute_command_with_timeout(command_id, command_type, command_name, command_params, sn_code, mqttClient, start_time) {
|
||||
// 获取指令超时时间(从配置中获取,默认5分钟)
|
||||
const timeout = ScheduleConfig.taskTimeouts[command_type] || 5 * 60 * 1000;
|
||||
// 将驼峰命名转换为下划线命名
|
||||
const to_snake_case = (str) => {
|
||||
if (str.includes('_')) {
|
||||
return str;
|
||||
}
|
||||
return str.replace(/([A-Z])/g, '_$1').toLowerCase().replace(/^_/, '');
|
||||
};
|
||||
|
||||
const method_name = to_snake_case(command_type);
|
||||
|
||||
// 获取指令超时时间(从配置中获取,默认5分钟)
|
||||
const timeout = ScheduleConfig.taskTimeouts[command_type] ||
|
||||
ScheduleConfig.taskTimeouts[method_name] ||
|
||||
5 * 60 * 1000;
|
||||
|
||||
// 构建指令执行 Promise
|
||||
const command_promise = (async () => {
|
||||
// 直接使用 command_type 调用 jobManager 的方法,不做映射
|
||||
// command_type 和 jobManager 的方法名保持一致
|
||||
if (jobManager[command_type]) {
|
||||
if (command_type && jobManager[method_name]) {
|
||||
return await jobManager[method_name](sn_code, mqttClient, command_params);
|
||||
} else if (jobManager[command_type]) {
|
||||
return await jobManager[command_type](sn_code, mqttClient, command_params);
|
||||
} else {
|
||||
throw new Error(`未知的指令类型: ${command_type}, jobManager 中不存在对应方法`);
|
||||
throw new Error(`未知的指令类型: ${command_type} (尝试的方法名: ${method_name})`);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -23,7 +23,6 @@ class ScheduleConfig {
|
||||
|
||||
// 任务超时配置(毫秒)
|
||||
this.taskTimeouts = {
|
||||
auto_search: 20 * 60 * 1000, // 自动搜索任务:20分钟
|
||||
auto_deliver: 30 * 60 * 1000, // 自动投递任务:30分钟(包含多个子任务)
|
||||
auto_chat: 15 * 60 * 1000, // 自动沟通任务:15分钟
|
||||
auto_active_account: 10 * 60 * 1000 // 自动活跃账号任务:10分钟
|
||||
@@ -31,7 +30,6 @@ class ScheduleConfig {
|
||||
|
||||
// 任务优先级配置
|
||||
this.taskPriorities = {
|
||||
auto_search: 8, // 自动搜索任务(最高优先级,先搜索后投递)
|
||||
auto_deliver: 7, // 自动投递任务
|
||||
auto_chat: 6, // 自动沟通任务
|
||||
auto_active_account: 5, // 自动活跃账号任务
|
||||
@@ -46,12 +44,10 @@ class ScheduleConfig {
|
||||
|
||||
// 定时任务配置
|
||||
this.schedules = {
|
||||
dailyReset: '0 0 * * *', // 每天凌晨重置统计
|
||||
monitoringInterval: '*/1 * * * *', // 监控检查间隔:1分钟
|
||||
autoSearch: '0 0 */1 * * *', // 自动搜索任务:每1小时执行一次
|
||||
autoDeliver: '0 */2 * * * *', // 自动投递任务:每2分钟执行一次
|
||||
autoChat: '0 */15 * * * *', // 自动沟通任务:每15分钟执行一次
|
||||
autoActive: '0 0 */2 * * *' // 自动活跃任务:每2小时执行一次
|
||||
dailyReset: '0 0 * * *', // 每天凌晨重置统计
|
||||
monitoringInterval: '*/1 * * * *', // 监控检查间隔:1分钟
|
||||
autoDeliver: '0 */1 * * * *', // 自动投递任务:每1分钟执行一次
|
||||
autoChat: '0 */15 * * * *' // 自动沟通任务:每15分钟执行一次
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* Core 模块导出
|
||||
* 统一导出核心模块,简化引用路径
|
||||
*/
|
||||
|
||||
const deviceManager = require('./deviceManager');
|
||||
const taskQueue = require('./taskQueue');
|
||||
const command = require('./command');
|
||||
const scheduledJobs = require('./scheduledJobs');
|
||||
|
||||
module.exports = {
|
||||
deviceManager,
|
||||
taskQueue,
|
||||
command,
|
||||
scheduledJobs
|
||||
};
|
||||
@@ -1,570 +0,0 @@
|
||||
const node_schedule = require("node-schedule");
|
||||
const dayjs = require('dayjs');
|
||||
const config = require('../infrastructure/config.js');
|
||||
const deviceManager = require('./deviceManager.js');
|
||||
const command = require('./command.js');
|
||||
const db = require('../../dbProxy');
|
||||
|
||||
// 引入新的任务模块
|
||||
const tasks = require('../tasks');
|
||||
const { autoSearchTask, autoDeliverTask, autoChatTask, autoActiveTask } = tasks;
|
||||
|
||||
const Framework = require("../../../../framework/node-core-framework.js");
|
||||
|
||||
/**
|
||||
* 定时任务管理器(重构版)
|
||||
* 使用独立的任务模块,职责更清晰,易于维护和扩展
|
||||
*/
|
||||
class ScheduledJobs {
|
||||
constructor(components, taskHandlers) {
|
||||
this.taskQueue = components.taskQueue;
|
||||
this.taskHandlers = taskHandlers;
|
||||
this.jobs = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动所有定时任务
|
||||
*/
|
||||
start() {
|
||||
console.log('[定时任务] 开始启动所有定时任务...');
|
||||
|
||||
// ==================== 系统维护任务 ====================
|
||||
|
||||
// 每天凌晨重置统计数据
|
||||
const resetJob = node_schedule.scheduleJob(config.schedules.dailyReset, () => {
|
||||
this.resetDailyStats();
|
||||
});
|
||||
this.jobs.push(resetJob);
|
||||
console.log('[定时任务] ✓ 已启动每日统计重置任务');
|
||||
|
||||
// 启动心跳检查定时任务(每分钟检查一次)
|
||||
const monitoringJob = node_schedule.scheduleJob(config.schedules.monitoringInterval, async () => {
|
||||
await deviceManager.checkHeartbeatStatus().catch(error => {
|
||||
console.error('[定时任务] 检查心跳状态失败:', error);
|
||||
});
|
||||
});
|
||||
this.jobs.push(monitoringJob);
|
||||
console.log('[定时任务] ✓ 已启动心跳检查任务');
|
||||
|
||||
// 启动离线设备任务清理定时任务(每分钟检查一次)
|
||||
const cleanupOfflineTasksJob = node_schedule.scheduleJob(config.schedules.monitoringInterval, async () => {
|
||||
await this.cleanupOfflineDeviceTasks().catch(error => {
|
||||
console.error('[定时任务] 清理离线设备任务失败:', error);
|
||||
});
|
||||
});
|
||||
this.jobs.push(cleanupOfflineTasksJob);
|
||||
console.log('[定时任务] ✓ 已启动离线设备任务清理任务');
|
||||
|
||||
// 启动任务超时检查定时任务(每分钟检查一次)
|
||||
const timeoutCheckJob = node_schedule.scheduleJob(config.schedules.monitoringInterval, async () => {
|
||||
await this.checkTaskTimeouts().catch(error => {
|
||||
console.error('[定时任务] 检查任务超时失败:', error);
|
||||
});
|
||||
});
|
||||
this.jobs.push(timeoutCheckJob);
|
||||
console.log('[定时任务] ✓ 已启动任务超时检查任务');
|
||||
|
||||
// 启动任务状态摘要同步定时任务(每10秒发送一次)
|
||||
const taskSummaryJob = node_schedule.scheduleJob('*/10 * * * * *', async () => {
|
||||
await this.syncTaskStatusSummary().catch(error => {
|
||||
console.error('[定时任务] 同步任务状态摘要失败:', error);
|
||||
});
|
||||
});
|
||||
this.jobs.push(taskSummaryJob);
|
||||
console.log('[定时任务] ✓ 已启动任务状态摘要同步任务');
|
||||
|
||||
// ==================== 业务任务(使用新的任务模块) ====================
|
||||
|
||||
// 1. 自动搜索任务 - 每60分钟执行一次
|
||||
const autoSearchJob = node_schedule.scheduleJob(config.schedules.autoSearch || '0 0 */1 * * *', () => {
|
||||
this.runAutoSearchTask();
|
||||
});
|
||||
this.jobs.push(autoSearchJob);
|
||||
console.log('[定时任务] ✓ 已启动自动搜索任务 (每60分钟)');
|
||||
|
||||
// 2. 自动投递任务 - 每1分钟检查一次
|
||||
const autoDeliverJob = node_schedule.scheduleJob(config.schedules.autoDeliver, () => {
|
||||
this.runAutoDeliverTask();
|
||||
});
|
||||
this.jobs.push(autoDeliverJob);
|
||||
console.log('[定时任务] ✓ 已启动自动投递任务 (每1分钟)');
|
||||
|
||||
// 3. 自动沟通任务 - 每15分钟执行一次
|
||||
const autoChatJob = node_schedule.scheduleJob(config.schedules.autoChat || '0 */15 * * * *', () => {
|
||||
this.runAutoChatTask();
|
||||
});
|
||||
this.jobs.push(autoChatJob);
|
||||
console.log('[定时任务] ✓ 已启动自动沟通任务 (每15分钟)');
|
||||
|
||||
// 4. 自动活跃任务 - 每2小时执行一次
|
||||
const autoActiveJob = node_schedule.scheduleJob(config.schedules.autoActive || '0 0 */2 * * *', () => {
|
||||
this.runAutoActiveTask();
|
||||
});
|
||||
this.jobs.push(autoActiveJob);
|
||||
console.log('[定时任务] ✓ 已启动自动活跃任务 (每2小时)');
|
||||
|
||||
// 立即执行一次业务任务(可选)
|
||||
setTimeout(() => {
|
||||
console.log('[定时任务] 立即执行一次初始化任务...');
|
||||
this.runAutoDeliverTask();
|
||||
this.runAutoChatTask();
|
||||
}, 10000); // 延迟10秒,等待系统初始化完成和设备心跳
|
||||
|
||||
console.log('[定时任务] 所有定时任务启动完成!');
|
||||
}
|
||||
|
||||
// ==================== 业务任务执行方法(使用新的任务模块) ====================
|
||||
|
||||
/**
|
||||
* 运行自动搜索任务
|
||||
* 为所有启用自动搜索的账号添加搜索任务
|
||||
*/
|
||||
async runAutoSearchTask() {
|
||||
try {
|
||||
const accounts = await this.getEnabledAccounts('auto_search');
|
||||
|
||||
if (accounts.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[自动搜索调度] 找到 ${accounts.length} 个启用自动搜索的账号`);
|
||||
|
||||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
for (const account of accounts) {
|
||||
const result = await autoSearchTask.addToQueue(account.sn_code, this.taskQueue);
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0 || failedCount > 0) {
|
||||
console.log(`[自动搜索调度] 完成: 成功 ${successCount} 个, 失败/跳过 ${failedCount} 个`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[自动搜索调度] 执行失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行自动投递任务
|
||||
* 为所有启用自动投递的账号添加投递任务
|
||||
*/
|
||||
async runAutoDeliverTask() {
|
||||
try {
|
||||
const accounts = await this.getEnabledAccounts('auto_deliver');
|
||||
|
||||
if (accounts.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[自动投递调度] 找到 ${accounts.length} 个启用自动投递的账号`);
|
||||
|
||||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
for (const account of accounts) {
|
||||
const result = await autoDeliverTask.addToQueue(account.sn_code, this.taskQueue);
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0 || failedCount > 0) {
|
||||
console.log(`[自动投递调度] 完成: 成功 ${successCount} 个, 失败/跳过 ${failedCount} 个`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[自动投递调度] 执行失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行自动沟通任务
|
||||
* 为所有启用自动沟通的账号添加沟通任务
|
||||
*/
|
||||
async runAutoChatTask() {
|
||||
try {
|
||||
const accounts = await this.getEnabledAccounts('auto_chat');
|
||||
|
||||
if (accounts.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[自动沟通调度] 找到 ${accounts.length} 个启用自动沟通的账号`);
|
||||
|
||||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
for (const account of accounts) {
|
||||
const result = await autoChatTask.addToQueue(account.sn_code, this.taskQueue);
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0 || failedCount > 0) {
|
||||
console.log(`[自动沟通调度] 完成: 成功 ${successCount} 个, 失败/跳过 ${failedCount} 个`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[自动沟通调度] 执行失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行自动活跃任务
|
||||
* 为所有启用自动活跃的账号添加活跃任务
|
||||
*/
|
||||
async runAutoActiveTask() {
|
||||
try {
|
||||
const accounts = await this.getEnabledAccounts('auto_active');
|
||||
|
||||
if (accounts.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[自动活跃调度] 找到 ${accounts.length} 个启用自动活跃的账号`);
|
||||
|
||||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
for (const account of accounts) {
|
||||
const result = await autoActiveTask.addToQueue(account.sn_code, this.taskQueue);
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0 || failedCount > 0) {
|
||||
console.log(`[自动活跃调度] 完成: 成功 ${successCount} 个, 失败/跳过 ${failedCount} 个`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[自动活跃调度] 执行失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取启用指定功能的账号列表
|
||||
* @param {string} featureType - 功能类型: auto_search, auto_deliver, auto_chat, auto_active
|
||||
*/
|
||||
async getEnabledAccounts(featureType) {
|
||||
try {
|
||||
const { pla_account } = db.models;
|
||||
|
||||
const accounts = await pla_account.findAll({
|
||||
where: {
|
||||
is_delete: 0,
|
||||
is_enabled: 1,
|
||||
[featureType]: 1
|
||||
},
|
||||
attributes: ['sn_code', 'name', 'keyword', 'platform_type']
|
||||
});
|
||||
|
||||
return accounts.map(acc => acc.toJSON());
|
||||
} catch (error) {
|
||||
console.error(`[获取账号列表] 失败 (${featureType}):`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 系统维护方法(保留原有逻辑) ====================
|
||||
|
||||
/**
|
||||
* 重置每日统计
|
||||
*/
|
||||
resetDailyStats() {
|
||||
console.log('[定时任务] 重置每日统计数据');
|
||||
|
||||
try {
|
||||
deviceManager.resetAllDailyCounters();
|
||||
console.log('[定时任务] 每日统计重置完成');
|
||||
} catch (error) {
|
||||
console.error('[定时任务] 重置统计失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期数据
|
||||
*/
|
||||
cleanupCaches() {
|
||||
console.log('[定时任务] 开始清理过期数据');
|
||||
|
||||
try {
|
||||
deviceManager.cleanupOfflineDevices(config.monitoring.offlineThreshold);
|
||||
command.cleanupExpiredCommands(30);
|
||||
console.log('[定时任务] 数据清理完成');
|
||||
} catch (error) {
|
||||
console.error('[定时任务] 数据清理失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理离线设备任务
|
||||
* 检查离线超过10分钟的设备,取消其所有pending/running状态的任务
|
||||
*/
|
||||
async cleanupOfflineDeviceTasks() {
|
||||
try {
|
||||
// 离线阈值:10分钟
|
||||
const offlineThreshold = 10 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
const thresholdTime = now - offlineThreshold;
|
||||
|
||||
// 获取所有启用的账号
|
||||
const pla_account = db.getModel('pla_account');
|
||||
const accounts = await pla_account.findAll({
|
||||
where: {
|
||||
is_delete: 0,
|
||||
is_enabled: 1
|
||||
},
|
||||
attributes: ['sn_code']
|
||||
});
|
||||
|
||||
if (!accounts || accounts.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 通过 deviceManager 检查哪些设备离线超过10分钟
|
||||
const offlineSnCodes = [];
|
||||
const offlineDevicesInfo = [];
|
||||
|
||||
for (const account of accounts) {
|
||||
const sn_code = account.sn_code;
|
||||
const device = deviceManager.devices.get(sn_code);
|
||||
|
||||
if (!device) {
|
||||
offlineSnCodes.push(sn_code);
|
||||
offlineDevicesInfo.push({
|
||||
sn_code: sn_code,
|
||||
lastHeartbeatTime: null
|
||||
});
|
||||
} else {
|
||||
const lastHeartbeat = device.lastHeartbeat || 0;
|
||||
if (lastHeartbeat < thresholdTime || !device.isOnline) {
|
||||
offlineSnCodes.push(sn_code);
|
||||
offlineDevicesInfo.push({
|
||||
sn_code: sn_code,
|
||||
lastHeartbeatTime: lastHeartbeat ? new Date(lastHeartbeat) : null
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (offlineSnCodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[清理离线任务] 发现 ${offlineSnCodes.length} 个离线超过10分钟的设备`);
|
||||
|
||||
let totalCancelled = 0;
|
||||
const task_status = db.getModel('task_status');
|
||||
|
||||
for (const sn_code of offlineSnCodes) {
|
||||
try {
|
||||
const deviceInfo = offlineDevicesInfo.find(d => d.sn_code === sn_code);
|
||||
|
||||
const updateResult = await task_status.update(
|
||||
{
|
||||
status: 'cancelled',
|
||||
endTime: new Date(),
|
||||
result: JSON.stringify({
|
||||
reason: '设备离线超过10分钟,任务已自动取消',
|
||||
offlineTime: deviceInfo?.lastHeartbeatTime
|
||||
})
|
||||
},
|
||||
{
|
||||
where: {
|
||||
sn_code: sn_code,
|
||||
status: ['pending', 'running']
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const cancelledCount = Array.isArray(updateResult) ? updateResult[0] : updateResult;
|
||||
totalCancelled += cancelledCount;
|
||||
|
||||
if (this.taskQueue && typeof this.taskQueue.cancelDeviceTasks === 'function') {
|
||||
await this.taskQueue.cancelDeviceTasks(sn_code);
|
||||
}
|
||||
|
||||
if (cancelledCount > 0) {
|
||||
console.log(`[清理离线任务] 设备 ${sn_code} 已取消 ${cancelledCount} 个任务`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[清理离线任务] 取消设备 ${sn_code} 的任务失败:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (totalCancelled > 0) {
|
||||
console.log(`[清理离线任务] 共取消 ${totalCancelled} 个离线设备的任务`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[清理离线任务] 执行失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步任务状态摘要到客户端
|
||||
*/
|
||||
async syncTaskStatusSummary() {
|
||||
try {
|
||||
const { pla_account } = await Framework.getModels();
|
||||
|
||||
const accounts = await pla_account.findAll({
|
||||
where: {
|
||||
is_delete: 0,
|
||||
is_enabled: 1
|
||||
},
|
||||
attributes: ['sn_code']
|
||||
});
|
||||
|
||||
if (!accounts || accounts.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const offlineThreshold = 3 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
|
||||
for (const account of accounts) {
|
||||
const sn_code = account.sn_code;
|
||||
const device = deviceManager.devices.get(sn_code);
|
||||
|
||||
if (!device) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const lastHeartbeat = device.lastHeartbeat || 0;
|
||||
const isOnline = device.isOnline && (now - lastHeartbeat < offlineThreshold);
|
||||
|
||||
if (!isOnline) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const deviceWorkStatusNotifier = require('../notifiers/deviceWorkStatusNotifier');
|
||||
const summary = await this.taskQueue.getTaskStatusSummary(sn_code);
|
||||
await deviceWorkStatusNotifier.sendDeviceWorkStatus(sn_code, summary, {
|
||||
currentCommand: summary.currentCommand || null
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[设备工作状态同步] 设备 ${sn_code} 同步失败:`, error.message);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[任务状态同步] 执行失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查任务超时并强制标记为失败
|
||||
*/
|
||||
async checkTaskTimeouts() {
|
||||
try {
|
||||
const Sequelize = require('sequelize');
|
||||
const { task_status, op } = db.models;
|
||||
|
||||
const runningTasks = await task_status.findAll({
|
||||
where: {
|
||||
status: 'running'
|
||||
},
|
||||
attributes: ['id', 'sn_code', 'taskType', 'taskName', 'startTime', 'create_time']
|
||||
});
|
||||
|
||||
if (!runningTasks || runningTasks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
let timeoutCount = 0;
|
||||
|
||||
for (const task of runningTasks) {
|
||||
const taskData = task.toJSON();
|
||||
const startTime = taskData.startTime ? new Date(taskData.startTime) : (taskData.create_time ? new Date(taskData.create_time) : null);
|
||||
|
||||
if (!startTime) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const taskTimeout = config.getTaskTimeout(taskData.taskType) || 10 * 60 * 1000;
|
||||
const maxAllowedTime = taskTimeout * 1.2;
|
||||
const elapsedTime = now.getTime() - startTime.getTime();
|
||||
|
||||
if (elapsedTime > maxAllowedTime) {
|
||||
try {
|
||||
await task_status.update(
|
||||
{
|
||||
status: 'failed',
|
||||
endTime: now,
|
||||
duration: elapsedTime,
|
||||
result: JSON.stringify({
|
||||
error: `任务执行超时(运行时间: ${Math.round(elapsedTime / 1000)}秒,超时限制: ${Math.round(maxAllowedTime / 1000)}秒)`,
|
||||
timeout: true,
|
||||
taskType: taskData.taskType,
|
||||
startTime: startTime.toISOString()
|
||||
}),
|
||||
progress: 0
|
||||
},
|
||||
{
|
||||
where: { id: taskData.id }
|
||||
}
|
||||
);
|
||||
|
||||
timeoutCount++;
|
||||
console.warn(`[任务超时检查] 任务 ${taskData.id} (${taskData.taskName}) 运行时间过长,已强制标记为失败`);
|
||||
|
||||
if (this.taskQueue && typeof this.taskQueue.deviceStatus !== 'undefined') {
|
||||
const deviceStatus = this.taskQueue.deviceStatus.get(taskData.sn_code);
|
||||
if (deviceStatus && deviceStatus.currentTask && deviceStatus.currentTask.id === taskData.id) {
|
||||
deviceStatus.isRunning = false;
|
||||
deviceStatus.currentTask = null;
|
||||
deviceStatus.runningCount = Math.max(0, deviceStatus.runningCount - 1);
|
||||
this.taskQueue.globalRunningCount = Math.max(0, this.taskQueue.globalRunningCount - 1);
|
||||
|
||||
console.log(`[任务超时检查] 已重置设备 ${taskData.sn_code} 的状态`);
|
||||
|
||||
setTimeout(() => {
|
||||
this.taskQueue.processQueue(taskData.sn_code).catch(error => {
|
||||
console.error(`[任务超时检查] 继续处理队列失败:`, error);
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[任务超时检查] 更新超时任务状态失败:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (timeoutCount > 0) {
|
||||
console.log(`[任务超时检查] 共检测到 ${timeoutCount} 个超时任务`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[任务超时检查] 执行失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止所有定时任务
|
||||
*/
|
||||
stop() {
|
||||
console.log('[定时任务] 停止所有定时任务...');
|
||||
|
||||
for (const job of this.jobs) {
|
||||
if (job) {
|
||||
job.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
this.jobs = [];
|
||||
console.log('[定时任务] 所有定时任务已停止');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ScheduledJobs;
|
||||
@@ -1,8 +1,8 @@
|
||||
const dayjs = require('dayjs');
|
||||
const Sequelize = require('sequelize');
|
||||
const db = require('../../dbProxy');
|
||||
const config = require('../infrastructure/config');
|
||||
const utils = require('../utils/scheduleUtils');
|
||||
const db = require('../dbProxy');
|
||||
const config = require('./config');
|
||||
const utils = require('./utils');
|
||||
|
||||
/**
|
||||
* 设备管理器(简化版)
|
||||
@@ -77,6 +77,7 @@ class DeviceManager {
|
||||
// 更新登录状态
|
||||
if (heartbeatData.isLoggedIn !== undefined) {
|
||||
device.isLoggedIn = heartbeatData.isLoggedIn;
|
||||
console.log(`[设备管理器] 设备 ${sn_code} 登录状态更新 - isLoggedIn: ${device.isLoggedIn}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 负责向客户端推送设备当前工作状态(任务、指令等)
|
||||
*/
|
||||
|
||||
const db = require('../../dbProxy');
|
||||
const db = require('../dbProxy');
|
||||
|
||||
class DeviceWorkStatusNotifier {
|
||||
constructor() {
|
||||
@@ -214,11 +214,11 @@ class DeviceWorkStatusNotifier {
|
||||
return `投递职位: ${parsedParams.jobTitle} @ ${companyName}`;
|
||||
} else if (parsedParams.jobTitle) {
|
||||
return `投递职位: ${parsedParams.jobTitle}`;
|
||||
} else if (commandType === 'deliver_resume' || commandName.includes('投递')) {
|
||||
} else if (commandType === 'applyJob' || commandName.includes('投递')) {
|
||||
return '投递简历';
|
||||
} else if (commandType === 'searchJobs' || commandName.includes('搜索')) {
|
||||
return `搜索职位: ${parsedParams.keyword || ''}`;
|
||||
} else if (commandType === 'send_chat_message' || commandType === 'sendChatMessage' || commandName.includes('沟通')) {
|
||||
} else if (commandType === 'sendChatMessage' || commandName.includes('沟通')) {
|
||||
return '发送消息';
|
||||
} else if (commandName) {
|
||||
return commandName;
|
||||
@@ -1,88 +0,0 @@
|
||||
const BaseHandler = require('./baseHandler');
|
||||
const ConfigManager = require('../services/configManager');
|
||||
const command = require('../core/command');
|
||||
const config = require('../infrastructure/config');
|
||||
|
||||
/**
|
||||
* 自动活跃处理器
|
||||
* 负责保持账户活跃度
|
||||
*/
|
||||
class ActiveHandler extends BaseHandler {
|
||||
/**
|
||||
* 处理自动活跃任务
|
||||
* @param {object} task - 任务对象
|
||||
* @returns {Promise<object>} 执行结果
|
||||
*/
|
||||
async handle(task) {
|
||||
return await this.execute(task, async () => {
|
||||
return await this.doActive(task);
|
||||
}, {
|
||||
checkAuth: true,
|
||||
checkOnline: true,
|
||||
recordDeviceMetrics: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行活跃逻辑
|
||||
*/
|
||||
async doActive(task) {
|
||||
const { sn_code, taskParams } = task;
|
||||
const { platform = 'boss' } = taskParams;
|
||||
|
||||
console.log(`[自动活跃] 开始 - 设备: ${sn_code}`);
|
||||
|
||||
// 1. 获取账户配置
|
||||
const accountConfig = await this.getAccountConfig(sn_code, ['platform_type', 'active_strategy']);
|
||||
|
||||
if (!accountConfig) {
|
||||
return {
|
||||
activeCount: 0,
|
||||
message: '未找到账户配置'
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 解析活跃策略配置
|
||||
const activeStrategy = ConfigManager.parseActiveStrategy(accountConfig.active_strategy);
|
||||
|
||||
// 3. 检查活跃时间范围
|
||||
const timeRange = ConfigManager.getTimeRange(activeStrategy);
|
||||
if (timeRange) {
|
||||
const timeRangeValidator = require('../services/timeRangeValidator');
|
||||
const timeCheck = timeRangeValidator.checkTimeRange(timeRange);
|
||||
|
||||
if (!timeCheck.allowed) {
|
||||
return {
|
||||
activeCount: 0,
|
||||
message: timeCheck.reason
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 创建活跃指令
|
||||
const actions = activeStrategy.actions || ['view_jobs'];
|
||||
const activeCommands = actions.map(action => ({
|
||||
command_type: `active_${action}`,
|
||||
command_name: `自动活跃 - ${action}`,
|
||||
command_params: JSON.stringify({
|
||||
sn_code,
|
||||
platform: platform || accountConfig.platform_type || 'boss',
|
||||
action
|
||||
}),
|
||||
priority: config.getTaskPriority('auto_active') || 5
|
||||
}));
|
||||
|
||||
// 5. 执行活跃指令
|
||||
const result = await command.executeCommands(task.id, activeCommands, this.mqttClient);
|
||||
|
||||
console.log(`[自动活跃] 完成 - 设备: ${sn_code}, 执行动作: ${actions.join(', ')}`);
|
||||
|
||||
return {
|
||||
activeCount: actions.length,
|
||||
actions,
|
||||
message: '活跃完成'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ActiveHandler;
|
||||
@@ -1,250 +0,0 @@
|
||||
const deviceManager = require('../core/deviceManager');
|
||||
const accountValidator = require('../services/accountValidator');
|
||||
const db = require('../../dbProxy');
|
||||
|
||||
/**
|
||||
* 任务处理器基类
|
||||
* 提供通用的授权检查、计时、错误处理、设备记录等功能
|
||||
*/
|
||||
class BaseHandler {
|
||||
constructor(mqttClient) {
|
||||
this.mqttClient = mqttClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行任务(带授权检查和错误处理)
|
||||
* @param {object} task - 任务对象
|
||||
* @param {Function} businessLogic - 业务逻辑函数
|
||||
* @param {object} options - 选项
|
||||
* @returns {Promise<object>} 执行结果
|
||||
*/
|
||||
async execute(task, businessLogic, options = {}) {
|
||||
const {
|
||||
checkAuth = true, // 是否检查授权
|
||||
checkOnline = true, // 是否检查在线状态
|
||||
recordDeviceMetrics = true // 是否记录设备指标
|
||||
} = options;
|
||||
|
||||
const { sn_code, taskName } = task;
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// 1. 验证账户(启用 + 授权 + 在线)
|
||||
if (checkAuth || checkOnline) {
|
||||
const validation = await accountValidator.validate(sn_code, {
|
||||
checkEnabled: true,
|
||||
checkAuth,
|
||||
checkOnline,
|
||||
offlineThreshold: 3 * 60 * 1000 // 3分钟
|
||||
});
|
||||
|
||||
if (!validation.valid) {
|
||||
throw new Error(`设备 ${sn_code} 验证失败: ${validation.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 记录任务开始
|
||||
if (recordDeviceMetrics) {
|
||||
deviceManager.recordTaskStart(sn_code, task);
|
||||
}
|
||||
|
||||
// 3. 执行业务逻辑
|
||||
const result = await businessLogic();
|
||||
|
||||
// 4. 记录任务成功
|
||||
const duration = Date.now() - startTime;
|
||||
if (recordDeviceMetrics) {
|
||||
deviceManager.recordTaskComplete(sn_code, task, true, duration);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
duration,
|
||||
...result
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
// 5. 记录任务失败
|
||||
const duration = Date.now() - startTime;
|
||||
if (recordDeviceMetrics) {
|
||||
deviceManager.recordTaskComplete(sn_code, task, false, duration);
|
||||
}
|
||||
|
||||
console.error(`[${taskName}] 执行失败 (设备: ${sn_code}):`, error.message);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
duration
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查每日操作限制
|
||||
* @param {string} sn_code - 设备序列号
|
||||
* @param {string} operation - 操作类型 (search, deliver, chat)
|
||||
* @param {string} platform - 平台类型
|
||||
* @returns {Promise<{allowed: boolean, count?: number, limit?: number, reason?: string}>}
|
||||
*/
|
||||
async checkDailyLimit(sn_code, operation, platform = 'boss') {
|
||||
try {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const task_status = db.getModel('task_status');
|
||||
|
||||
// 查询今日该操作的完成次数
|
||||
const count = await task_status.count({
|
||||
where: {
|
||||
sn_code,
|
||||
taskType: `auto_${operation}`,
|
||||
status: 'completed',
|
||||
endTime: {
|
||||
[db.models.op.gte]: new Date(today)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 获取每日限制(从 deviceManager 或配置)
|
||||
const limit = deviceManager.canExecuteOperation(sn_code, operation);
|
||||
|
||||
if (!limit.allowed) {
|
||||
return {
|
||||
allowed: false,
|
||||
count,
|
||||
reason: limit.reason
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
count,
|
||||
limit: limit.max || 999
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[每日限制检查] 失败 (${sn_code}, ${operation}):`, error);
|
||||
return { allowed: true }; // 检查失败时默认允许
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查执行间隔时间
|
||||
* @param {string} sn_code - 设备序列号
|
||||
* @param {string} taskType - 任务类型
|
||||
* @param {number} intervalMinutes - 间隔时间(分钟)
|
||||
* @returns {Promise<{allowed: boolean, elapsed?: number, remaining?: number, reason?: string}>}
|
||||
*/
|
||||
async checkInterval(sn_code, taskType, intervalMinutes) {
|
||||
try {
|
||||
const task_status = db.getModel('task_status');
|
||||
|
||||
// 查询最近一次成功完成的任务
|
||||
const lastTask = await task_status.findOne({
|
||||
where: {
|
||||
sn_code,
|
||||
taskType,
|
||||
status: 'completed'
|
||||
},
|
||||
order: [['endTime', 'DESC']],
|
||||
attributes: ['endTime']
|
||||
});
|
||||
|
||||
if (!lastTask || !lastTask.endTime) {
|
||||
return { allowed: true, elapsed: null };
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const lastTime = new Date(lastTask.endTime).getTime();
|
||||
const elapsed = now - lastTime;
|
||||
const intervalMs = intervalMinutes * 60 * 1000;
|
||||
|
||||
if (elapsed < intervalMs) {
|
||||
const remainingMinutes = Math.ceil((intervalMs - elapsed) / (60 * 1000));
|
||||
const elapsedMinutes = Math.floor(elapsed / (60 * 1000));
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
elapsed: elapsedMinutes,
|
||||
remaining: remainingMinutes,
|
||||
reason: `距离上次执行仅 ${elapsedMinutes} 分钟,还需等待 ${remainingMinutes} 分钟`
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
elapsed: Math.floor(elapsed / (60 * 1000))
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[间隔检查] 失败 (${sn_code}, ${taskType}):`, error);
|
||||
return { allowed: true }; // 检查失败时默认允许
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账户配置
|
||||
* @param {string} sn_code - 设备序列号
|
||||
* @param {string[]} fields - 需要的字段
|
||||
* @returns {Promise<object|null>}
|
||||
*/
|
||||
async getAccountConfig(sn_code, fields = ['*']) {
|
||||
try {
|
||||
const pla_account = db.getModel('pla_account');
|
||||
const account = await pla_account.findOne({
|
||||
where: { sn_code, is_delete: 0 },
|
||||
attributes: fields
|
||||
});
|
||||
|
||||
return account ? account.toJSON() : null;
|
||||
} catch (error) {
|
||||
console.error(`[获取账户配置] 失败 (${sn_code}):`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送设备工作状态(可选的通知)
|
||||
* @param {string} sn_code - 设备序列号
|
||||
* @param {object} status - 状态信息
|
||||
*/
|
||||
async notifyDeviceStatus(sn_code, status) {
|
||||
try {
|
||||
const deviceWorkStatusNotifier = require('../notifiers/deviceWorkStatusNotifier');
|
||||
await deviceWorkStatusNotifier.sendDeviceWorkStatus(sn_code, status);
|
||||
} catch (error) {
|
||||
console.warn(`[状态推送] 失败 (${sn_code}):`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化错误响应
|
||||
* @param {Error} error - 错误对象
|
||||
* @param {string} sn_code - 设备序列号
|
||||
* @returns {object} 标准化的错误响应
|
||||
*/
|
||||
formatError(error, sn_code) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || '未知错误',
|
||||
sn_code,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化成功响应
|
||||
* @param {object} data - 响应数据
|
||||
* @param {string} sn_code - 设备序列号
|
||||
* @returns {object} 标准化的成功响应
|
||||
*/
|
||||
formatSuccess(data, sn_code) {
|
||||
return {
|
||||
success: true,
|
||||
sn_code,
|
||||
timestamp: new Date().toISOString(),
|
||||
...data
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BaseHandler;
|
||||
@@ -1,89 +0,0 @@
|
||||
const BaseHandler = require('./baseHandler');
|
||||
const ConfigManager = require('../services/configManager');
|
||||
const command = require('../core/command');
|
||||
const config = require('../infrastructure/config');
|
||||
|
||||
/**
|
||||
* 自动沟通处理器
|
||||
* 负责自动回复HR消息
|
||||
*/
|
||||
class ChatHandler extends BaseHandler {
|
||||
/**
|
||||
* 处理自动沟通任务
|
||||
* @param {object} task - 任务对象
|
||||
* @returns {Promise<object>} 执行结果
|
||||
*/
|
||||
async handle(task) {
|
||||
return await this.execute(task, async () => {
|
||||
return await this.doChat(task);
|
||||
}, {
|
||||
checkAuth: true,
|
||||
checkOnline: true,
|
||||
recordDeviceMetrics: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行沟通逻辑
|
||||
*/
|
||||
async doChat(task) {
|
||||
const { sn_code, taskParams } = task;
|
||||
const { platform = 'boss' } = taskParams;
|
||||
|
||||
console.log(`[自动沟通] 开始 - 设备: ${sn_code}`);
|
||||
|
||||
// 1. 获取账户配置
|
||||
const accountConfig = await this.getAccountConfig(sn_code, ['platform_type', 'chat_strategy']);
|
||||
|
||||
if (!accountConfig) {
|
||||
return {
|
||||
chatCount: 0,
|
||||
message: '未找到账户配置'
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 解析沟通策略配置
|
||||
const chatStrategy = ConfigManager.parseChatStrategy(accountConfig.chat_strategy);
|
||||
|
||||
// 3. 检查沟通时间范围
|
||||
const timeRange = ConfigManager.getTimeRange(chatStrategy);
|
||||
if (timeRange) {
|
||||
const timeRangeValidator = require('../services/timeRangeValidator');
|
||||
const timeCheck = timeRangeValidator.checkTimeRange(timeRange);
|
||||
|
||||
if (!timeCheck.allowed) {
|
||||
return {
|
||||
chatCount: 0,
|
||||
message: timeCheck.reason
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 创建自动沟通 AI 指令(内部会先获取列表,再获取详情并自动回复)
|
||||
const chatCommand = {
|
||||
command_type: 'auto_chat_ai',
|
||||
command_name: '自动沟通AI回复',
|
||||
command_params: {
|
||||
platform: platform || accountConfig.platform_type || 'boss',
|
||||
pageCount: chatStrategy.page_count || 3
|
||||
},
|
||||
priority: config.getTaskPriority('auto_chat') || 6
|
||||
};
|
||||
|
||||
// 5. 执行指令(任务队列会保证该设备内串行执行,不并发下发指令)
|
||||
const exec_result = await command.executeCommands(task.id, [chatCommand], this.mqttClient);
|
||||
const first = exec_result && Array.isArray(exec_result.results) && exec_result.results[0]
|
||||
? exec_result.results[0].result || {}
|
||||
: {};
|
||||
|
||||
console.log(`[自动沟通] 完成 - 设备: ${sn_code}`);
|
||||
|
||||
return {
|
||||
chatCount: first.replied_count || 0,
|
||||
message: first.message || '自动沟通完成',
|
||||
detail: first
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ChatHandler;
|
||||
@@ -1,410 +0,0 @@
|
||||
const BaseHandler = require('./baseHandler');
|
||||
const ConfigManager = require('../services/configManager');
|
||||
const jobFilterEngine = require('../services/jobFilterEngine');
|
||||
const command = require('../core/command');
|
||||
const config = require('../infrastructure/config');
|
||||
const db = require('../../dbProxy');
|
||||
const { jobFilterService } = require('../../job/services');
|
||||
|
||||
/**
|
||||
* 自动投递处理器
|
||||
* 负责职位搜索、过滤、评分和自动投递
|
||||
*/
|
||||
class DeliverHandler extends BaseHandler {
|
||||
/**
|
||||
* 处理自动投递任务
|
||||
* @param {object} task - 任务对象
|
||||
* @returns {Promise<object>} 执行结果
|
||||
*/
|
||||
async handle(task) {
|
||||
return await this.execute(task, async () => {
|
||||
return await this.doDeliver(task);
|
||||
}, {
|
||||
checkAuth: true,
|
||||
checkOnline: true,
|
||||
recordDeviceMetrics: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行投递逻辑
|
||||
*/
|
||||
async doDeliver(task) {
|
||||
const { sn_code, taskParams } = task;
|
||||
const { keyword, platform = 'boss', pageCount = 3, maxCount = 10, filterRules = {} } = taskParams;
|
||||
|
||||
console.log(`[自动投递] 开始 - 设备: ${sn_code}, 关键词: ${keyword}`);
|
||||
|
||||
// 1. 检查每日投递限制
|
||||
const dailyCheck = await this.checkDailyDeliverLimit(sn_code, platform);
|
||||
if (!dailyCheck.allowed) {
|
||||
return {
|
||||
deliveredCount: 0,
|
||||
message: dailyCheck.message
|
||||
};
|
||||
}
|
||||
|
||||
const actualMaxCount = dailyCheck.actualMaxCount;
|
||||
|
||||
// 2. 检查并获取简历
|
||||
const resume = await this.getOrRefreshResume(sn_code, platform, task.id);
|
||||
if (!resume) {
|
||||
return {
|
||||
deliveredCount: 0,
|
||||
message: '未找到简历信息'
|
||||
};
|
||||
}
|
||||
|
||||
// 3. 获取账户配置
|
||||
const accountConfig = await this.getAccountConfig(sn_code, [
|
||||
'keyword', 'platform_type', 'deliver_config', 'job_type_id', 'is_salary_priority'
|
||||
]);
|
||||
|
||||
if (!accountConfig) {
|
||||
return {
|
||||
deliveredCount: 0,
|
||||
message: '未找到账户配置'
|
||||
};
|
||||
}
|
||||
|
||||
// 4. 检查投递时间范围
|
||||
const deliverConfig = ConfigManager.parseDeliverConfig(accountConfig.deliver_config);
|
||||
const timeRange = ConfigManager.getTimeRange(deliverConfig);
|
||||
|
||||
if (timeRange) {
|
||||
const timeRangeValidator = require('../services/timeRangeValidator');
|
||||
const timeCheck = timeRangeValidator.checkTimeRange(timeRange);
|
||||
if (!timeCheck.allowed) {
|
||||
return {
|
||||
deliveredCount: 0,
|
||||
message: timeCheck.reason
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 获取职位类型配置
|
||||
const jobTypeConfig = await this.getJobTypeConfig(accountConfig.job_type_id);
|
||||
|
||||
// 6. 搜索职位列表
|
||||
await this.searchJobs(sn_code, platform, keyword || accountConfig.keyword, pageCount, task.id);
|
||||
|
||||
// 7. 从数据库获取待投递职位
|
||||
const pendingJobs = await this.getPendingJobs(sn_code, platform, actualMaxCount * 3);
|
||||
|
||||
if (!pendingJobs || pendingJobs.length === 0) {
|
||||
return {
|
||||
deliveredCount: 0,
|
||||
message: '没有待投递的职位'
|
||||
};
|
||||
}
|
||||
|
||||
// 8. 合并过滤配置
|
||||
const filterConfig = this.mergeFilterConfig(deliverConfig, filterRules, jobTypeConfig);
|
||||
|
||||
// 9. 过滤已投递的公司
|
||||
const recentCompanies = await this.getRecentDeliveredCompanies(sn_code, 30);
|
||||
|
||||
// 10. 过滤、评分、排序职位
|
||||
const filteredJobs = await this.filterAndScoreJobs(
|
||||
pendingJobs,
|
||||
resume,
|
||||
accountConfig,
|
||||
jobTypeConfig,
|
||||
filterConfig,
|
||||
recentCompanies
|
||||
);
|
||||
|
||||
const jobsToDeliver = filteredJobs.slice(0, actualMaxCount);
|
||||
|
||||
console.log(`[自动投递] 职位筛选完成 - 原始: ${pendingJobs.length}, 符合条件: ${filteredJobs.length}, 将投递: ${jobsToDeliver.length}`);
|
||||
|
||||
if (jobsToDeliver.length === 0) {
|
||||
return {
|
||||
deliveredCount: 0,
|
||||
message: '没有符合条件的职位'
|
||||
};
|
||||
}
|
||||
|
||||
// 11. 创建投递指令并执行
|
||||
const deliverCommands = this.createDeliverCommands(jobsToDeliver, sn_code, platform);
|
||||
const result = await command.executeCommands(task.id, deliverCommands, this.mqttClient);
|
||||
|
||||
console.log(`[自动投递] 完成 - 设备: ${sn_code}, 投递: ${deliverCommands.length} 个职位`);
|
||||
|
||||
return {
|
||||
deliveredCount: deliverCommands.length,
|
||||
...result
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查每日投递限制
|
||||
*/
|
||||
async checkDailyDeliverLimit(sn_code, platform) {
|
||||
const apply_records = db.getModel('apply_records');
|
||||
const dailyLimit = config.getDailyLimit('apply', platform);
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const todayApplyCount = await apply_records.count({
|
||||
where: {
|
||||
sn_code,
|
||||
platform,
|
||||
applyTime: {
|
||||
[db.models.op.gte]: today
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[自动投递] 今日已投递: ${todayApplyCount}/${dailyLimit}`);
|
||||
|
||||
if (todayApplyCount >= dailyLimit) {
|
||||
return {
|
||||
allowed: false,
|
||||
message: `已达到每日投递上限(${dailyLimit}次)`
|
||||
};
|
||||
}
|
||||
|
||||
const remainingQuota = dailyLimit - todayApplyCount;
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
actualMaxCount: remainingQuota,
|
||||
todayCount: todayApplyCount,
|
||||
limit: dailyLimit
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或刷新简历
|
||||
*/
|
||||
async getOrRefreshResume(sn_code, platform, taskId) {
|
||||
const resume_info = db.getModel('resume_info');
|
||||
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
|
||||
|
||||
let resume = await resume_info.findOne({
|
||||
where: {
|
||||
sn_code,
|
||||
platform,
|
||||
isActive: true
|
||||
},
|
||||
order: [['last_modify_time', 'DESC']]
|
||||
});
|
||||
|
||||
const needRefresh = !resume ||
|
||||
!resume.last_modify_time ||
|
||||
new Date(resume.last_modify_time) < twoHoursAgo;
|
||||
|
||||
if (needRefresh) {
|
||||
console.log(`[自动投递] 简历超过2小时未更新,重新获取`);
|
||||
|
||||
try {
|
||||
await command.executeCommands(taskId, [{
|
||||
command_type: 'getOnlineResume',
|
||||
command_name: '获取在线简历',
|
||||
command_params: JSON.stringify({ sn_code, platform }),
|
||||
priority: config.getTaskPriority('get_resume') || 5
|
||||
}], this.mqttClient);
|
||||
|
||||
// 重新查询
|
||||
resume = await resume_info.findOne({
|
||||
where: { sn_code, platform, isActive: true },
|
||||
order: [['last_modify_time', 'DESC']]
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`[自动投递] 获取在线简历失败:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return resume ? resume.toJSON() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取职位类型配置
|
||||
*/
|
||||
async getJobTypeConfig(jobTypeId) {
|
||||
if (!jobTypeId) return null;
|
||||
|
||||
try {
|
||||
const job_types = db.getModel('job_types');
|
||||
const jobType = await job_types.findByPk(jobTypeId);
|
||||
return jobType ? jobType.toJSON() : null;
|
||||
} catch (error) {
|
||||
console.error(`[自动投递] 获取职位类型配置失败:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索职位列表
|
||||
*/
|
||||
async searchJobs(sn_code, platform, keyword, pageCount, taskId) {
|
||||
const getJobListCommand = {
|
||||
command_type: 'getJobList',
|
||||
command_name: '获取职位列表',
|
||||
command_params: JSON.stringify({
|
||||
sn_code,
|
||||
keyword,
|
||||
platform,
|
||||
pageCount
|
||||
}),
|
||||
priority: config.getTaskPriority('search_jobs') || 5
|
||||
};
|
||||
|
||||
await command.executeCommands(taskId, [getJobListCommand], this.mqttClient);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取待投递职位
|
||||
*/
|
||||
async getPendingJobs(sn_code, platform, limit) {
|
||||
const job_postings = db.getModel('job_postings');
|
||||
|
||||
const jobs = await job_postings.findAll({
|
||||
where: {
|
||||
sn_code,
|
||||
platform,
|
||||
applyStatus: 'pending'
|
||||
},
|
||||
order: [['create_time', 'DESC']],
|
||||
limit
|
||||
});
|
||||
|
||||
return jobs.map(job => job.toJSON ? job.toJSON() : job);
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并过滤配置
|
||||
*/
|
||||
mergeFilterConfig(deliverConfig, filterRules, jobTypeConfig) {
|
||||
// 排除关键词
|
||||
const jobTypeExclude = jobTypeConfig?.excludeKeywords
|
||||
? ConfigManager.parseConfig(jobTypeConfig.excludeKeywords, [])
|
||||
: [];
|
||||
|
||||
const deliverExclude = ConfigManager.getExcludeKeywords(deliverConfig);
|
||||
const filterExclude = filterRules.excludeKeywords || [];
|
||||
|
||||
// 过滤关键词
|
||||
const deliverFilter = ConfigManager.getFilterKeywords(deliverConfig);
|
||||
const filterKeywords = filterRules.keywords || [];
|
||||
|
||||
// 薪资范围
|
||||
const salaryRange = filterRules.minSalary || filterRules.maxSalary
|
||||
? { min: filterRules.minSalary || 0, max: filterRules.maxSalary || 0 }
|
||||
: ConfigManager.getSalaryRange(deliverConfig);
|
||||
|
||||
return {
|
||||
exclude_keywords: [...jobTypeExclude, ...deliverExclude, ...filterExclude],
|
||||
filter_keywords: filterKeywords.length > 0 ? filterKeywords : deliverFilter,
|
||||
min_salary: salaryRange.min,
|
||||
max_salary: salaryRange.max,
|
||||
priority_weights: ConfigManager.getPriorityWeights(deliverConfig)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取近期已投递的公司
|
||||
*/
|
||||
async getRecentDeliveredCompanies(sn_code, days = 30) {
|
||||
const apply_records = db.getModel('apply_records');
|
||||
const daysAgo = new Date();
|
||||
daysAgo.setDate(daysAgo.getDate() - days);
|
||||
|
||||
const recentApplies = await apply_records.findAll({
|
||||
where: {
|
||||
sn_code,
|
||||
applyTime: {
|
||||
[db.models.op.gte]: daysAgo
|
||||
}
|
||||
},
|
||||
attributes: ['companyName'],
|
||||
group: ['companyName']
|
||||
});
|
||||
|
||||
return new Set(recentApplies.map(apply => apply.companyName).filter(Boolean));
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤和评分职位
|
||||
*/
|
||||
async filterAndScoreJobs(jobs, resume, accountConfig, jobTypeConfig, filterConfig, recentCompanies) {
|
||||
const scored = [];
|
||||
|
||||
for (const job of jobs) {
|
||||
// 1. 过滤近期已投递的公司
|
||||
if (job.companyName && recentCompanies.has(job.companyName)) {
|
||||
console.log(`[自动投递] 跳过已投递公司: ${job.companyName}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. 使用 jobFilterEngine 过滤和评分
|
||||
const filtered = await jobFilterEngine.filterJobs([job], filterConfig, resume);
|
||||
if (filtered.length === 0) {
|
||||
continue; // 不符合过滤条件
|
||||
}
|
||||
|
||||
// 3. 使用原有的评分系统(job_filter_service)计算详细分数
|
||||
const scoreResult = jobFilterService.calculateJobScoreWithWeights(
|
||||
job,
|
||||
resume,
|
||||
accountConfig,
|
||||
jobTypeConfig,
|
||||
accountConfig.is_salary_priority || []
|
||||
);
|
||||
|
||||
// 4. 计算关键词奖励
|
||||
const KeywordMatcher = require('../utils/keywordMatcher');
|
||||
const keywordBonus = KeywordMatcher.calculateBonus(
|
||||
`${job.jobTitle} ${job.companyName} ${job.jobDescription || ''}`,
|
||||
filterConfig.filter_keywords,
|
||||
{ baseScore: 5, maxBonus: 20 }
|
||||
);
|
||||
|
||||
const finalScore = scoreResult.totalScore + keywordBonus.score;
|
||||
|
||||
// 5. 只保留评分 >= 60 的职位
|
||||
if (finalScore >= 60) {
|
||||
scored.push({
|
||||
...job,
|
||||
matchScore: finalScore,
|
||||
scoreDetails: {
|
||||
...scoreResult.scores,
|
||||
keywordBonus: keywordBonus.score
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 按评分降序排序
|
||||
scored.sort((a, b) => b.matchScore - a.matchScore);
|
||||
|
||||
return scored;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建投递指令
|
||||
*/
|
||||
createDeliverCommands(jobs, sn_code, platform) {
|
||||
return jobs.map(job => ({
|
||||
command_type: 'deliver_resume',
|
||||
command_name: `投递简历 - ${job.jobTitle} @ ${job.companyName} (评分:${job.matchScore})`,
|
||||
command_params: JSON.stringify({
|
||||
sn_code,
|
||||
platform,
|
||||
jobId: job.jobId,
|
||||
encryptBossId: job.encryptBossId || '',
|
||||
securityId: job.securityId || '',
|
||||
brandName: job.companyName,
|
||||
jobTitle: job.jobTitle,
|
||||
companyName: job.companyName,
|
||||
matchScore: job.matchScore,
|
||||
scoreDetails: job.scoreDetails
|
||||
}),
|
||||
priority: config.getTaskPriority('apply') || 6
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DeliverHandler;
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* 处理器模块导出
|
||||
* 统一导出所有任务处理器
|
||||
*/
|
||||
|
||||
const BaseHandler = require('./baseHandler');
|
||||
const SearchHandler = require('./searchHandler');
|
||||
const DeliverHandler = require('./deliverHandler');
|
||||
const ChatHandler = require('./chatHandler');
|
||||
const ActiveHandler = require('./activeHandler');
|
||||
|
||||
module.exports = {
|
||||
BaseHandler,
|
||||
SearchHandler,
|
||||
DeliverHandler,
|
||||
ChatHandler,
|
||||
ActiveHandler
|
||||
};
|
||||
@@ -1,87 +0,0 @@
|
||||
const BaseHandler = require('./baseHandler');
|
||||
const ConfigManager = require('../services/configManager');
|
||||
const command = require('../core/command');
|
||||
const config = require('../infrastructure/config');
|
||||
|
||||
/**
|
||||
* 自动搜索处理器
|
||||
* 负责搜索职位列表
|
||||
*/
|
||||
class SearchHandler extends BaseHandler {
|
||||
/**
|
||||
* 处理自动搜索任务
|
||||
* @param {object} task - 任务对象
|
||||
* @returns {Promise<object>} 执行结果
|
||||
*/
|
||||
async handle(task) {
|
||||
return await this.execute(task, async () => {
|
||||
return await this.doSearch(task);
|
||||
}, {
|
||||
checkAuth: true,
|
||||
checkOnline: true,
|
||||
recordDeviceMetrics: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行搜索逻辑
|
||||
*/
|
||||
async doSearch(task) {
|
||||
const { sn_code, taskParams } = task;
|
||||
const { keyword, platform = 'boss', pageCount = 3 } = taskParams;
|
||||
|
||||
console.log(`[自动搜索] 开始 - 设备: ${sn_code}, 关键词: ${keyword}`);
|
||||
|
||||
// 1. 获取账户配置
|
||||
const accountConfig = await this.getAccountConfig(sn_code, ['keyword', 'platform_type', 'search_config']);
|
||||
|
||||
if (!accountConfig) {
|
||||
return {
|
||||
jobsFound: 0,
|
||||
message: '未找到账户配置'
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 解析搜索配置
|
||||
const searchConfig = ConfigManager.parseSearchConfig(accountConfig.search_config);
|
||||
|
||||
// 3. 检查搜索时间范围
|
||||
const timeRange = ConfigManager.getTimeRange(searchConfig);
|
||||
if (timeRange) {
|
||||
const timeRangeValidator = require('../services/timeRangeValidator');
|
||||
const timeCheck = timeRangeValidator.checkTimeRange(timeRange);
|
||||
|
||||
if (!timeCheck.allowed) {
|
||||
return {
|
||||
jobsFound: 0,
|
||||
message: timeCheck.reason
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 创建搜索指令
|
||||
const searchCommand = {
|
||||
command_type: 'getJobList',
|
||||
command_name: `自动搜索职位 - ${keyword || accountConfig.keyword}`,
|
||||
command_params: JSON.stringify({
|
||||
sn_code,
|
||||
keyword: keyword || accountConfig.keyword || '',
|
||||
platform: platform || accountConfig.platform_type || 'boss',
|
||||
pageCount: pageCount || searchConfig.page_count || 3
|
||||
}),
|
||||
priority: config.getTaskPriority('search_jobs') || 8
|
||||
};
|
||||
|
||||
// 5. 执行搜索指令
|
||||
const result = await command.executeCommands(task.id, [searchCommand], this.mqttClient);
|
||||
|
||||
console.log(`[自动搜索] 完成 - 设备: ${sn_code}, 结果: ${JSON.stringify(result)}`);
|
||||
|
||||
return {
|
||||
jobsFound: result.jobCount || 0,
|
||||
message: '搜索完成'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SearchHandler;
|
||||
@@ -1,23 +1,17 @@
|
||||
const mqttManager = require("../mqtt/mqttManager.js");
|
||||
|
||||
// 导入核心模块
|
||||
const TaskQueue = require('./core/taskQueue.js');
|
||||
const Command = require('./core/command.js');
|
||||
const deviceManager = require('./core/deviceManager.js');
|
||||
const ScheduledJobs = require('./core/scheduledJobs.js');
|
||||
// 导入调度模块(简化版)
|
||||
const TaskQueue = require('./taskQueue.js');
|
||||
const Command = require('./command.js');
|
||||
const deviceManager = require('./deviceManager.js');
|
||||
const config = require('./config.js');
|
||||
const utils = require('./utils.js');
|
||||
|
||||
// 导入基础设施模块
|
||||
const config = require('./infrastructure/config.js');
|
||||
const utils = require('./utils/scheduleUtils.js');
|
||||
|
||||
// 导入任务处理器
|
||||
// 导入新的模块
|
||||
const TaskHandlers = require('./taskHandlers.js');
|
||||
|
||||
// 导入MQTT模块
|
||||
const MqttDispatcher = require('../mqtt/mqttDispatcher.js');
|
||||
|
||||
// 导入通知器
|
||||
const DeviceWorkStatusNotifier = require('./notifiers/deviceWorkStatusNotifier.js');
|
||||
const ScheduledJobs = require('./scheduledJobs.js');
|
||||
const DeviceWorkStatusNotifier = require('./deviceWorkStatusNotifier.js');
|
||||
|
||||
/**
|
||||
* 调度系统管理器
|
||||
@@ -28,7 +22,7 @@ class ScheduleManager {
|
||||
this.mqttClient = null;
|
||||
this.isInitialized = false;
|
||||
this.startTime = new Date();
|
||||
|
||||
|
||||
// 子模块
|
||||
this.taskHandlers = null;
|
||||
this.mqttDispatcher = null;
|
||||
@@ -86,9 +80,9 @@ class ScheduleManager {
|
||||
async initComponents() {
|
||||
// 初始化设备管理器
|
||||
await deviceManager.init();
|
||||
|
||||
|
||||
// 初始化任务队列
|
||||
await TaskQueue.init();
|
||||
await TaskQueue.init?.();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,7 +126,14 @@ class ScheduleManager {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 手动执行找工作流程(已废弃,full_flow 不再使用)
|
||||
* @deprecated 请使用其他任务类型,如 auto_deliver
|
||||
*/
|
||||
async manualExecuteJobFlow(sn_code, keyword = '前端') {
|
||||
console.warn(`[手动执行] manualExecuteJobFlow 已废弃,full_flow 不再使用`);
|
||||
throw new Error('full_flow 任务类型已废弃,请使用其他任务类型');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统状态
|
||||
@@ -177,18 +178,28 @@ class ScheduleManager {
|
||||
}
|
||||
}
|
||||
|
||||
// 创建并导出调度管理器实例
|
||||
// 创建调度管理器实例
|
||||
const scheduleManager = new ScheduleManager();
|
||||
|
||||
// 导出兼容接口(简化版)
|
||||
// 导出兼容的接口,保持与原有代码的一致性
|
||||
module.exports = {
|
||||
// 初始化方法
|
||||
init: () => scheduleManager.init(),
|
||||
|
||||
// 手动执行任务
|
||||
manualExecuteJobFlow: (sn_code, keyword) => scheduleManager.manualExecuteJobFlow(sn_code, keyword),
|
||||
|
||||
// 获取系统状态
|
||||
getSystemStatus: () => scheduleManager.getSystemStatus(),
|
||||
|
||||
// 停止系统
|
||||
stop: () => scheduleManager.stop(),
|
||||
|
||||
// 直接暴露属性(使用 getter 保持动态访问)
|
||||
// 访问各个组件(为了兼容性)
|
||||
get mqttClient() { return scheduleManager.mqttClient; },
|
||||
get isInitialized() { return scheduleManager.isInitialized; },
|
||||
|
||||
// 访问各个组件实例(简化版)
|
||||
get taskQueue() { return TaskQueue; },
|
||||
get command() { return Command; },
|
||||
get deviceManager() { return deviceManager; }
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
/**
|
||||
* Infrastructure 模块导出
|
||||
* 统一导出基础设施模块
|
||||
*/
|
||||
|
||||
const PriorityQueue = require('./PriorityQueue');
|
||||
const ErrorHandler = require('./ErrorHandler');
|
||||
const config = require('./config');
|
||||
|
||||
module.exports = {
|
||||
PriorityQueue,
|
||||
ErrorHandler,
|
||||
config
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
/**
|
||||
* Notifiers 模块导出
|
||||
*/
|
||||
|
||||
const deviceWorkStatusNotifier = require('./deviceWorkStatusNotifier');
|
||||
|
||||
module.exports = {
|
||||
deviceWorkStatusNotifier
|
||||
};
|
||||
779
api/middleware/schedule/scheduledJobs.js
Normal file
779
api/middleware/schedule/scheduledJobs.js
Normal file
@@ -0,0 +1,779 @@
|
||||
const node_schedule = require("node-schedule");
|
||||
const dayjs = require('dayjs');
|
||||
const config = require('./config.js');
|
||||
const deviceManager = require('./deviceManager.js');
|
||||
const command = require('./command.js');
|
||||
const db = require('../dbProxy');
|
||||
const authorizationService = require('../../services/authorization_service.js');
|
||||
|
||||
const Framework = require("../../../framework/node-core-framework.js");
|
||||
/**
|
||||
* 检查当前时间是否在指定的时间范围内
|
||||
* @param {Object} timeRange - 时间范围配置 {start_time: '09:00', end_time: '18:00', workdays_only: 1}
|
||||
* @returns {Object} {allowed: boolean, reason: string}
|
||||
*/
|
||||
function checkTimeRange(timeRange) {
|
||||
if (!timeRange || !timeRange.start_time || !timeRange.end_time) {
|
||||
return { allowed: true, reason: '未配置时间范围' };
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const currentHour = now.getHours();
|
||||
const currentMinute = now.getMinutes();
|
||||
const currentTime = currentHour * 60 + currentMinute; // 转换为分钟数
|
||||
|
||||
// 解析开始时间和结束时间
|
||||
const [startHour, startMinute] = timeRange.start_time.split(':').map(Number);
|
||||
const [endHour, endMinute] = timeRange.end_time.split(':').map(Number);
|
||||
const startTime = startHour * 60 + startMinute;
|
||||
const endTime = endHour * 60 + endMinute;
|
||||
|
||||
// 检查是否仅工作日(使用宽松比较,兼容字符串和数字)
|
||||
if (timeRange.workdays_only == 1) { // 使用 == 而不是 ===
|
||||
const dayOfWeek = now.getDay(); // 0=周日, 1=周一, ..., 6=周六
|
||||
if (dayOfWeek === 0 || dayOfWeek === 6) {
|
||||
return { allowed: false, reason: '当前是周末,不在允许的时间范围内' };
|
||||
}
|
||||
}
|
||||
|
||||
// 检查当前时间是否在时间范围内
|
||||
if (startTime <= endTime) {
|
||||
// 正常情况:09:00 - 18:00
|
||||
if (currentTime < startTime || currentTime >= endTime) {
|
||||
return { allowed: false, reason: `当前时间 ${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')} 不在允许的时间范围内 (${timeRange.start_time} - ${timeRange.end_time})` };
|
||||
}
|
||||
} else {
|
||||
// 跨天情况:22:00 - 06:00
|
||||
if (currentTime < startTime && currentTime >= endTime) {
|
||||
return { allowed: false, reason: `当前时间 ${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')} 不在允许的时间范围内 (${timeRange.start_time} - ${timeRange.end_time})` };
|
||||
}
|
||||
}
|
||||
|
||||
return { allowed: true, reason: '在允许的时间范围内' };
|
||||
}
|
||||
/**
|
||||
* 定时任务管理器(简化版)
|
||||
* 管理所有定时任务的创建和销毁
|
||||
*/
|
||||
class ScheduledJobs {
|
||||
constructor(components, taskHandlers) {
|
||||
this.taskQueue = components.taskQueue;
|
||||
this.taskHandlers = taskHandlers;
|
||||
this.jobs = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动所有定时任务
|
||||
*/
|
||||
start() {
|
||||
// 每天凌晨重置统计数据
|
||||
const resetJob = node_schedule.scheduleJob(config.schedules.dailyReset, () => {
|
||||
this.resetDailyStats();
|
||||
});
|
||||
this.jobs.push(resetJob);
|
||||
|
||||
// 启动心跳检查定时任务(每分钟检查一次)
|
||||
const monitoringJob = node_schedule.scheduleJob(config.schedules.monitoringInterval, async () => {
|
||||
await deviceManager.checkHeartbeatStatus().catch(error => {
|
||||
console.error('[定时任务] 检查心跳状态失败:', error);
|
||||
});
|
||||
});
|
||||
|
||||
this.jobs.push(monitoringJob);
|
||||
|
||||
// 启动离线设备任务清理定时任务(每分钟检查一次)
|
||||
const cleanupOfflineTasksJob = node_schedule.scheduleJob(config.schedules.monitoringInterval, async () => {
|
||||
await this.cleanupOfflineDeviceTasks().catch(error => {
|
||||
console.error('[定时任务] 清理离线设备任务失败:', error);
|
||||
});
|
||||
});
|
||||
|
||||
this.jobs.push(cleanupOfflineTasksJob);
|
||||
console.log('[定时任务] 已启动离线设备任务清理任务');
|
||||
|
||||
// 启动任务超时检查定时任务(每分钟检查一次)
|
||||
const timeoutCheckJob = node_schedule.scheduleJob(config.schedules.monitoringInterval, async () => {
|
||||
await this.checkTaskTimeouts().catch(error => {
|
||||
console.error('[定时任务] 检查任务超时失败:', error);
|
||||
});
|
||||
});
|
||||
|
||||
this.jobs.push(timeoutCheckJob);
|
||||
console.log('[定时任务] 已启动任务超时检查任务');
|
||||
|
||||
// 启动任务状态摘要同步定时任务(每10秒发送一次)
|
||||
const taskSummaryJob = node_schedule.scheduleJob('*/10 * * * * *', async () => {
|
||||
await this.syncTaskStatusSummary().catch(error => {
|
||||
console.error('[定时任务] 同步任务状态摘要失败:', error);
|
||||
});
|
||||
});
|
||||
|
||||
this.jobs.push(taskSummaryJob);
|
||||
console.log('[定时任务] 已启动任务状态摘要同步任务');
|
||||
|
||||
|
||||
// 执行自动投递任务
|
||||
const autoDeliverJob = node_schedule.scheduleJob(config.schedules.autoDeliver, () => {
|
||||
this.autoDeliverTask();
|
||||
});
|
||||
|
||||
// 立即执行一次自动投递任务
|
||||
this.autoDeliverTask();
|
||||
|
||||
this.jobs.push(autoDeliverJob);
|
||||
console.log('[定时任务] 已启动自动投递任务');
|
||||
|
||||
// 执行自动沟通任务
|
||||
const autoChatJob = node_schedule.scheduleJob(config.schedules.autoChat || '0 */15 * * * *', () => {
|
||||
this.autoChatTask();
|
||||
});
|
||||
|
||||
// 立即执行一次自动沟通任务
|
||||
this.autoChatTask();
|
||||
|
||||
this.jobs.push(autoChatJob);
|
||||
console.log('[定时任务] 已启动自动沟通任务');
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 重置每日统计
|
||||
*/
|
||||
resetDailyStats() {
|
||||
console.log('[定时任务] 重置每日统计数据');
|
||||
|
||||
try {
|
||||
deviceManager.resetAllDailyCounters();
|
||||
console.log('[定时任务] 每日统计重置完成');
|
||||
} catch (error) {
|
||||
console.error('[定时任务] 重置统计失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期数据
|
||||
*/
|
||||
cleanupCaches() {
|
||||
console.log('[定时任务] 开始清理过期数据');
|
||||
|
||||
try {
|
||||
deviceManager.cleanupOfflineDevices(config.monitoring.offlineThreshold);
|
||||
command.cleanupExpiredCommands(30);
|
||||
console.log('[定时任务] 数据清理完成');
|
||||
} catch (error) {
|
||||
console.error('[定时任务] 数据清理失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理离线设备任务
|
||||
* 检查离线超过10分钟的设备,取消其所有pending/running状态的任务
|
||||
*/
|
||||
async cleanupOfflineDeviceTasks() {
|
||||
try {
|
||||
// 离线阈值:10分钟
|
||||
const offlineThreshold = 10 * 60 * 1000; // 10分钟
|
||||
const now = Date.now();
|
||||
const thresholdTime = now - offlineThreshold;
|
||||
|
||||
// 获取所有启用的账号
|
||||
const pla_account = db.getModel('pla_account');
|
||||
const accounts = await pla_account.findAll({
|
||||
where: {
|
||||
is_delete: 0,
|
||||
is_enabled: 1
|
||||
},
|
||||
attributes: ['sn_code']
|
||||
});
|
||||
|
||||
if (!accounts || accounts.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 通过 deviceManager 检查哪些设备离线超过10分钟
|
||||
const offlineSnCodes = [];
|
||||
const offlineDevicesInfo = [];
|
||||
|
||||
for (const account of accounts) {
|
||||
const sn_code = account.sn_code;
|
||||
const device = deviceManager.devices.get(sn_code);
|
||||
|
||||
if (!device) {
|
||||
// 设备从未发送过心跳,视为离线
|
||||
offlineSnCodes.push(sn_code);
|
||||
offlineDevicesInfo.push({
|
||||
sn_code: sn_code,
|
||||
lastHeartbeatTime: null
|
||||
});
|
||||
} else {
|
||||
// 检查最后心跳时间
|
||||
const lastHeartbeat = device.lastHeartbeat || 0;
|
||||
if (lastHeartbeat < thresholdTime || !device.isOnline) {
|
||||
offlineSnCodes.push(sn_code);
|
||||
offlineDevicesInfo.push({
|
||||
sn_code: sn_code,
|
||||
lastHeartbeatTime: lastHeartbeat ? new Date(lastHeartbeat) : null
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (offlineSnCodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[清理离线任务] 发现 ${offlineSnCodes.length} 个离线超过10分钟的设备: ${offlineSnCodes.join(', ')}`);
|
||||
|
||||
let totalCancelled = 0;
|
||||
|
||||
// 为每个离线设备取消任务
|
||||
const task_status = db.getModel('task_status');
|
||||
for (const sn_code of offlineSnCodes) {
|
||||
try {
|
||||
// 查询该设备的所有pending/running任务
|
||||
const pendingTasks = await task_status.findAll({
|
||||
where: {
|
||||
sn_code: sn_code,
|
||||
status: ['pending', 'running']
|
||||
},
|
||||
attributes: ['id']
|
||||
});
|
||||
|
||||
if (pendingTasks.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const deviceInfo = offlineDevicesInfo.find(d => d.sn_code === sn_code);
|
||||
|
||||
// 更新任务状态为cancelled
|
||||
const updateResult = await task_status.update(
|
||||
{
|
||||
status: 'cancelled',
|
||||
endTime: new Date(),
|
||||
result: JSON.stringify({
|
||||
reason: '设备离线超过10分钟,任务已自动取消',
|
||||
offlineTime: deviceInfo?.lastHeartbeatTime
|
||||
})
|
||||
},
|
||||
{
|
||||
where: {
|
||||
sn_code: sn_code,
|
||||
status: ['pending', 'running']
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const cancelledCount = Array.isArray(updateResult) ? updateResult[0] : updateResult;
|
||||
totalCancelled += cancelledCount;
|
||||
|
||||
// 从内存队列中移除任务
|
||||
if (this.taskQueue && typeof this.taskQueue.cancelDeviceTasks === 'function') {
|
||||
await this.taskQueue.cancelDeviceTasks(sn_code);
|
||||
}
|
||||
|
||||
console.log(`[清理离线任务] 设备 ${sn_code} 已取消 ${cancelledCount} 个任务`);
|
||||
} catch (error) {
|
||||
console.error(`[清理离线任务] 取消设备 ${sn_code} 的任务失败:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (totalCancelled > 0) {
|
||||
console.log(`[清理离线任务] 共取消 ${totalCancelled} 个离线设备的任务`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[清理离线任务] 执行失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步任务状态摘要到客户端
|
||||
* 定期向所有在线设备发送任务状态摘要(当前任务、待执行任务、下次执行时间等)
|
||||
*/
|
||||
async syncTaskStatusSummary() {
|
||||
try {
|
||||
const { pla_account } = await Framework.getModels();
|
||||
|
||||
// 获取所有启用的账号
|
||||
const accounts = await pla_account.findAll({
|
||||
where: {
|
||||
is_delete: 0,
|
||||
is_enabled: 1
|
||||
},
|
||||
attributes: ['sn_code']
|
||||
});
|
||||
|
||||
if (!accounts || accounts.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 离线阈值:3分钟
|
||||
const offlineThreshold = 3 * 60 * 1000; // 3分钟
|
||||
const now = Date.now();
|
||||
|
||||
// 为每个在线设备发送任务状态摘要
|
||||
for (const account of accounts) {
|
||||
const sn_code = account.sn_code;
|
||||
|
||||
// 检查设备是否在线
|
||||
const device = deviceManager.devices.get(sn_code);
|
||||
|
||||
if (!device) {
|
||||
// 设备从未发送过心跳,视为离线,跳过
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查最后心跳时间
|
||||
const lastHeartbeat = device.lastHeartbeat || 0;
|
||||
const isOnline = device.isOnline && (now - lastHeartbeat < offlineThreshold);
|
||||
|
||||
if (!isOnline) {
|
||||
// 设备离线,跳过
|
||||
continue;
|
||||
}
|
||||
|
||||
// 设备在线,推送设备工作状态
|
||||
try {
|
||||
const deviceWorkStatusNotifier = require('./deviceWorkStatusNotifier');
|
||||
const summary = await this.taskQueue.getTaskStatusSummary(sn_code);
|
||||
await deviceWorkStatusNotifier.sendDeviceWorkStatus(sn_code, summary, {
|
||||
currentCommand: summary.currentCommand || null
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[设备工作状态同步] 设备 ${sn_code} 同步失败:`, error.message);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[任务状态同步] 执行失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查任务超时并强制标记为失败
|
||||
* 检测长时间运行的任务(可能是卡住的),强制标记为失败,释放资源
|
||||
*/
|
||||
async checkTaskTimeouts() {
|
||||
try {
|
||||
const Sequelize = require('sequelize');
|
||||
const { task_status, op } = db.models;
|
||||
|
||||
// 查询所有运行中的任务
|
||||
const runningTasks = await task_status.findAll({
|
||||
where: {
|
||||
status: 'running'
|
||||
},
|
||||
attributes: ['id', 'sn_code', 'taskType', 'taskName', 'startTime', 'create_time']
|
||||
});
|
||||
|
||||
if (!runningTasks || runningTasks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
let timeoutCount = 0;
|
||||
|
||||
for (const task of runningTasks) {
|
||||
const taskData = task.toJSON();
|
||||
const startTime = taskData.startTime ? new Date(taskData.startTime) : (taskData.create_time ? new Date(taskData.create_time) : null);
|
||||
|
||||
if (!startTime) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 获取任务类型的超时时间(默认10分钟)
|
||||
const taskTimeout = config.getTaskTimeout(taskData.taskType) || 10 * 60 * 1000;
|
||||
// 允许额外20%的缓冲时间
|
||||
const maxAllowedTime = taskTimeout * 1.2;
|
||||
const elapsedTime = now.getTime() - startTime.getTime();
|
||||
|
||||
// 如果任务运行时间超过最大允许时间,标记为超时失败
|
||||
if (elapsedTime > maxAllowedTime) {
|
||||
try {
|
||||
await task_status.update(
|
||||
{
|
||||
status: 'failed',
|
||||
endTime: now,
|
||||
duration: elapsedTime,
|
||||
result: JSON.stringify({
|
||||
error: `任务执行超时(运行时间: ${Math.round(elapsedTime / 1000)}秒,超时限制: ${Math.round(maxAllowedTime / 1000)}秒)`,
|
||||
timeout: true,
|
||||
taskType: taskData.taskType,
|
||||
startTime: startTime.toISOString()
|
||||
}),
|
||||
progress: 0
|
||||
},
|
||||
{
|
||||
where: { id: taskData.id }
|
||||
}
|
||||
);
|
||||
|
||||
timeoutCount++;
|
||||
console.warn(`[任务超时检查] 任务 ${taskData.id} (${taskData.taskName}) 运行时间过长,已强制标记为失败`, {
|
||||
task_id: taskData.id,
|
||||
sn_code: taskData.sn_code,
|
||||
taskType: taskData.taskType,
|
||||
elapsedTime: Math.round(elapsedTime / 1000) + '秒',
|
||||
maxAllowedTime: Math.round(maxAllowedTime / 1000) + '秒'
|
||||
});
|
||||
|
||||
// 如果任务队列中有这个任务,也需要从内存中清理
|
||||
if (this.taskQueue && typeof this.taskQueue.deviceStatus !== 'undefined') {
|
||||
const deviceStatus = this.taskQueue.deviceStatus.get(taskData.sn_code);
|
||||
if (deviceStatus && deviceStatus.currentTask && deviceStatus.currentTask.id === taskData.id) {
|
||||
// 重置设备状态,允许继续执行下一个任务
|
||||
deviceStatus.isRunning = false;
|
||||
deviceStatus.currentTask = null;
|
||||
deviceStatus.runningCount = Math.max(0, deviceStatus.runningCount - 1);
|
||||
this.taskQueue.globalRunningCount = Math.max(0, this.taskQueue.globalRunningCount - 1);
|
||||
|
||||
console.log(`[任务超时检查] 已重置设备 ${taskData.sn_code} 的状态,可以继续执行下一个任务`);
|
||||
|
||||
// 尝试继续处理该设备的队列
|
||||
setTimeout(() => {
|
||||
this.taskQueue.processQueue(taskData.sn_code).catch(error => {
|
||||
console.error(`[任务超时检查] 继续处理队列失败 (设备: ${taskData.sn_code}):`, error);
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[任务超时检查] 更新超时任务状态失败 (任务ID: ${taskData.id}):`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (timeoutCount > 0) {
|
||||
console.log(`[任务超时检查] 共检测到 ${timeoutCount} 个超时任务,已强制标记为失败`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[任务超时检查] 执行失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动投递任务
|
||||
*/
|
||||
async autoDeliverTask() {
|
||||
const now = new Date();
|
||||
console.log(`[自动投递] ${now.toLocaleString()} 开始执行自动投递任务`);
|
||||
|
||||
|
||||
|
||||
try {
|
||||
// 移除 device_status 依赖,改为直接从 pla_account 查询启用且开启自动投递的账号
|
||||
const models = db.models;
|
||||
const { pla_account, op } = models;
|
||||
|
||||
// 直接从 pla_account 查询启用且开启自动投递的账号
|
||||
// 注意:不再检查在线状态,因为 device_status 已移除
|
||||
const pla_users = await pla_account.findAll({
|
||||
where: {
|
||||
is_delete: 0,
|
||||
is_enabled: 1, // 只获取启用的账号
|
||||
auto_deliver: 1
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
if (!pla_users || pla_users.length === 0) {
|
||||
console.log('[自动投递] 没有启用且开启自动投递的账号');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[自动投递] 找到 ${pla_users.length} 个可用账号`);
|
||||
|
||||
// 获取 task_status 模型用于查询上次投递时间
|
||||
const { task_status } = models;
|
||||
|
||||
// 为每个设备添加自动投递任务到队列
|
||||
for (const pl_user of pla_users) {
|
||||
const userData = pl_user.toJSON();
|
||||
|
||||
// 检查设备是否在线(离线阈值:3分钟)
|
||||
const offlineThreshold = 3 * 60 * 1000; // 3分钟
|
||||
const now = Date.now();
|
||||
const device = deviceManager.devices.get(userData.sn_code);
|
||||
|
||||
|
||||
// 检查用户授权天数 是否够
|
||||
const authorization = await authorizationService.checkAuthorization(userData.sn_code);
|
||||
if (!authorization.is_authorized) {
|
||||
console.log(`[自动投递] 设备 ${userData.sn_code} 授权天数不足,跳过添加任务`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
if (!device) {
|
||||
// 设备从未发送过心跳,视为离线
|
||||
console.log(`[自动投递] 设备 ${userData.sn_code} 离线(从未发送心跳),跳过添加任务`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// 检查最后心跳时间
|
||||
const lastHeartbeat = device.lastHeartbeat || 0;
|
||||
const isOnline = device.isOnline && (now - lastHeartbeat < offlineThreshold);
|
||||
|
||||
if (!isOnline) {
|
||||
const offlineMinutes = lastHeartbeat ? Math.round((now - lastHeartbeat) / (60 * 1000)) : '未知';
|
||||
console.log(`[自动投递] 设备 ${userData.sn_code} 离线(最后心跳: ${offlineMinutes}分钟前),跳过添加任务`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查设备调度策略
|
||||
const canExecute = deviceManager.canExecuteOperation(userData.sn_code, 'deliver');
|
||||
if (!canExecute.allowed) {
|
||||
console.log(`[自动投递] 设备 ${userData.sn_code} 不满足执行条件: ${canExecute.reason}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 获取投递配置,如果不存在则使用默认值
|
||||
let deliver_config = userData.deliver_config;
|
||||
if (typeof deliver_config === 'string') {
|
||||
try {
|
||||
deliver_config = JSON.parse(deliver_config);
|
||||
} catch (e) {
|
||||
deliver_config = {};
|
||||
}
|
||||
}
|
||||
deliver_config = deliver_config || {
|
||||
deliver_interval: 30,
|
||||
min_salary: 0,
|
||||
max_salary: 0,
|
||||
page_count: 3,
|
||||
max_deliver: 10,
|
||||
filter_keywords: [],
|
||||
exclude_keywords: []
|
||||
};
|
||||
|
||||
// 检查投递时间范围
|
||||
if (deliver_config.time_range) {
|
||||
const timeCheck = checkTimeRange(deliver_config.time_range);
|
||||
if (!timeCheck.allowed) {
|
||||
console.log(`[自动投递] 设备 ${userData.sn_code} ${timeCheck.reason}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查投递间隔时间
|
||||
const deliver_interval = deliver_config.deliver_interval || 30; // 默认30分钟
|
||||
const interval_ms = deliver_interval * 60 * 1000; // 转换为毫秒
|
||||
|
||||
// 查询该账号最近一次成功完成的自动投递任务
|
||||
const lastDeliverTask = await task_status.findOne({
|
||||
where: {
|
||||
sn_code: userData.sn_code,
|
||||
taskType: 'auto_deliver',
|
||||
status: 'completed'
|
||||
},
|
||||
order: [['endTime', 'DESC']],
|
||||
attributes: ['endTime']
|
||||
});
|
||||
|
||||
// 如果存在上次投递记录,检查是否已经过了间隔时间
|
||||
if (lastDeliverTask && lastDeliverTask.endTime) {
|
||||
const lastDeliverTime = new Date(lastDeliverTask.endTime);
|
||||
|
||||
const elapsedTime = new Date().getTime() - lastDeliverTime.getTime();
|
||||
|
||||
if (elapsedTime < interval_ms) {
|
||||
const remainingMinutes = Math.ceil((interval_ms - elapsedTime) / (60 * 1000));
|
||||
const elapsedMinutes = Math.round(elapsedTime / (60 * 1000));
|
||||
const message = `距离上次投递仅 ${elapsedMinutes} 分钟,还需等待 ${remainingMinutes} 分钟(间隔: ${deliver_interval} 分钟)`;
|
||||
console.log(`[自动投递] 设备 ${userData.sn_code} ${message}`);
|
||||
|
||||
// 推送等待状态到客户端
|
||||
try {
|
||||
const deviceWorkStatusNotifier = require('./deviceWorkStatusNotifier');
|
||||
|
||||
// 获取当前任务状态摘要
|
||||
const taskStatusSummary = this.taskQueue ? this.taskQueue.getTaskStatusSummary(userData.sn_code) : {
|
||||
sn_code: userData.sn_code,
|
||||
pendingCount: 0,
|
||||
totalPendingCount: 0,
|
||||
pendingTasks: []
|
||||
};
|
||||
|
||||
// 添加等待消息到工作状态
|
||||
await deviceWorkStatusNotifier.sendDeviceWorkStatus(userData.sn_code, taskStatusSummary, {
|
||||
waitingMessage: {
|
||||
type: 'deliver_interval',
|
||||
message: message,
|
||||
remainingMinutes: remainingMinutes,
|
||||
nextDeliverTime: new Date(lastDeliverTime.getTime() + interval_ms).toISOString()
|
||||
}
|
||||
});
|
||||
} catch (pushError) {
|
||||
console.warn(`[自动投递] 推送等待消息失败:`, pushError.message);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加自动投递任务到队列
|
||||
await this.taskQueue.addTask(userData.sn_code, {
|
||||
taskType: 'auto_deliver',
|
||||
taskName: `自动投递 - ${userData.keyword || ''}`,
|
||||
taskParams: {
|
||||
keyword: userData.keyword || '',
|
||||
platform: userData.platform_type || 'boss',
|
||||
pageCount: deliver_config.page_count || 3,
|
||||
maxCount: deliver_config.max_deliver || 10,
|
||||
filterRules: {
|
||||
minSalary: deliver_config.min_salary || 0,
|
||||
maxSalary: deliver_config.max_salary || 0,
|
||||
keywords: deliver_config.filter_keywords || [],
|
||||
excludeKeywords: deliver_config.exclude_keywords || []
|
||||
}
|
||||
},
|
||||
priority: config.getTaskPriority('auto_deliver') || 6
|
||||
});
|
||||
|
||||
console.log(`[自动投递] 已为设备 ${userData.sn_code} 添加自动投递任务,关键词: ${userData.keyword || '默认'},投递间隔: ${deliver_interval} 分钟`);
|
||||
}
|
||||
|
||||
console.log('[自动投递] 任务添加完成');
|
||||
|
||||
} catch (error) {
|
||||
console.error('[自动投递] 执行失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动沟通任务
|
||||
*/
|
||||
async autoChatTask() {
|
||||
const now = new Date();
|
||||
console.log(`[自动沟通] ${now.toLocaleString()} 开始执行自动沟通任务`);
|
||||
|
||||
|
||||
|
||||
try {
|
||||
// 移除 device_status 依赖,改为直接从 pla_account 查询启用且开启自动沟通的账号
|
||||
const models = db.models;
|
||||
const { pla_account, op } = models;
|
||||
|
||||
// 直接从 pla_account 查询启用且开启自动沟通的账号
|
||||
// 注意:不再检查在线状态,因为 device_status 已移除
|
||||
const pla_users = await pla_account.findAll({
|
||||
where: {
|
||||
is_delete: 0,
|
||||
is_enabled: 1, // 只获取启用的账号
|
||||
auto_chat: 1
|
||||
}
|
||||
});
|
||||
|
||||
if (!pla_users || pla_users.length === 0) {
|
||||
console.log('[自动沟通] 没有启用且开启自动沟通的账号');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[自动沟通] 找到 ${pla_users.length} 个可用账号`);
|
||||
|
||||
// 获取 task_status 模型用于查询上次沟通时间
|
||||
const { task_status } = models;
|
||||
|
||||
// 为每个设备添加自动沟通任务到队列
|
||||
for (const pl_user of pla_users) {
|
||||
const userData = pl_user.toJSON();
|
||||
|
||||
// 检查设备是否在线(离线阈值:3分钟)
|
||||
const offlineThreshold = 3 * 60 * 1000; // 3分钟
|
||||
const now = Date.now();
|
||||
const device = deviceManager.devices.get(userData.sn_code);
|
||||
|
||||
if (!device) {
|
||||
// 设备从未发送过心跳,视为离线
|
||||
console.log(`[自动沟通] 设备 ${userData.sn_code} 离线(从未发送心跳),跳过添加任务`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查最后心跳时间
|
||||
const lastHeartbeat = device.lastHeartbeat || 0;
|
||||
const isOnline = device.isOnline && (now - lastHeartbeat < offlineThreshold);
|
||||
|
||||
if (!isOnline) {
|
||||
const offlineMinutes = lastHeartbeat ? Math.round((now - lastHeartbeat) / (60 * 1000)) : '未知';
|
||||
console.log(`[自动沟通] 设备 ${userData.sn_code} 离线(最后心跳: ${offlineMinutes}分钟前),跳过添加任务`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查设备调度策略
|
||||
const canExecute = deviceManager.canExecuteOperation(userData.sn_code, 'chat');
|
||||
if (!canExecute.allowed) {
|
||||
console.log(`[自动沟通] 设备 ${userData.sn_code} 不满足执行条件: ${canExecute.reason}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 获取沟通策略配置
|
||||
let chatStrategy = {};
|
||||
if (userData.chat_strategy) {
|
||||
chatStrategy = typeof userData.chat_strategy === 'string'
|
||||
? JSON.parse(userData.chat_strategy)
|
||||
: userData.chat_strategy;
|
||||
}
|
||||
|
||||
// 检查沟通时间范围
|
||||
if (chatStrategy.time_range) {
|
||||
const timeCheck = checkTimeRange(chatStrategy.time_range);
|
||||
if (!timeCheck.allowed) {
|
||||
console.log(`[自动沟通] 设备 ${userData.sn_code} ${timeCheck.reason}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查沟通间隔时间
|
||||
const chat_interval = chatStrategy.chat_interval || 30; // 默认30分钟
|
||||
const interval_ms = chat_interval * 60 * 1000; // 转换为毫秒
|
||||
|
||||
// 查询该账号最近一次成功完成的自动沟通任务
|
||||
const lastChatTask = await task_status.findOne({
|
||||
where: {
|
||||
sn_code: userData.sn_code,
|
||||
taskType: 'auto_chat',
|
||||
status: 'completed'
|
||||
},
|
||||
order: [['endTime', 'DESC']],
|
||||
attributes: ['endTime']
|
||||
});
|
||||
|
||||
// 如果存在上次沟通记录,检查是否已经过了间隔时间
|
||||
if (lastChatTask && lastChatTask.endTime) {
|
||||
const lastChatTime = new Date(lastChatTask.endTime);
|
||||
const elapsedTime = now.getTime() - lastChatTime.getTime();
|
||||
|
||||
if (elapsedTime < interval_ms) {
|
||||
const remainingMinutes = Math.ceil((interval_ms - elapsedTime) / (60 * 1000));
|
||||
console.log(`[自动沟通] 设备 ${userData.sn_code} 距离上次沟通仅 ${Math.round(elapsedTime / (60 * 1000))} 分钟,还需等待 ${remainingMinutes} 分钟(间隔: ${chat_interval} 分钟)`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加自动沟通任务到队列
|
||||
await this.taskQueue.addTask(userData.sn_code, {
|
||||
taskType: 'auto_chat',
|
||||
taskName: `自动沟通 - ${userData.name || '默认'}`,
|
||||
taskParams: {
|
||||
platform: userData.platform_type || 'boss'
|
||||
},
|
||||
priority: config.getTaskPriority('auto_chat') || 6
|
||||
});
|
||||
|
||||
console.log(`[自动沟通] 已为设备 ${userData.sn_code} 添加自动沟通任务,沟通间隔: ${chat_interval} 分钟`);
|
||||
}
|
||||
|
||||
console.log('[自动沟通] 任务添加完成');
|
||||
|
||||
} catch (error) {
|
||||
console.error('[自动沟通] 执行失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ScheduledJobs;
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
const db = require('../../dbProxy');
|
||||
const authorizationService = require('../../../services/authorization_service');
|
||||
const deviceManager = require('../core/deviceManager');
|
||||
|
||||
/**
|
||||
* 账户验证服务
|
||||
* 统一处理账户启用状态、授权状态、在线状态的检查
|
||||
*/
|
||||
class AccountValidator {
|
||||
/**
|
||||
* 检查账户是否启用
|
||||
* @param {string} sn_code - 设备序列号
|
||||
* @returns {Promise<{enabled: boolean, reason?: string}>}
|
||||
*/
|
||||
async checkEnabled(sn_code) {
|
||||
try {
|
||||
const pla_account = db.getModel('pla_account');
|
||||
const account = await pla_account.findOne({
|
||||
where: { sn_code, is_delete: 0 },
|
||||
attributes: ['is_enabled', 'name']
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
return { enabled: false, reason: '账户不存在' };
|
||||
}
|
||||
|
||||
if (!account.is_enabled) {
|
||||
return { enabled: false, reason: '账户未启用' };
|
||||
}
|
||||
|
||||
return { enabled: true };
|
||||
} catch (error) {
|
||||
console.error(`[账户验证] 检查启用状态失败 (${sn_code}):`, error);
|
||||
return { enabled: false, reason: '检查失败' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查账户授权状态
|
||||
* @param {string} sn_code - 设备序列号
|
||||
* @returns {Promise<{authorized: boolean, days?: number, reason?: string}>}
|
||||
*/
|
||||
async checkAuthorization(sn_code) {
|
||||
try {
|
||||
const result = await authorizationService.checkAuthorization(sn_code);
|
||||
|
||||
if (!result.is_authorized) {
|
||||
return {
|
||||
authorized: false,
|
||||
days: result.days_remaining || 0,
|
||||
reason: result.message || '授权已过期'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
authorized: true,
|
||||
days: result.days_remaining
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[账户验证] 检查授权状态失败 (${sn_code}):`, error);
|
||||
return { authorized: false, reason: '授权检查失败' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查设备是否在线
|
||||
* @param {string} sn_code - 设备序列号
|
||||
* @param {number} offlineThreshold - 离线阈值(毫秒)
|
||||
* @returns {{online: boolean, lastHeartbeat?: number, reason?: string}}
|
||||
*/
|
||||
checkOnline(sn_code, offlineThreshold = 3 * 60 * 1000) {
|
||||
const device = deviceManager.devices.get(sn_code);
|
||||
|
||||
if (!device) {
|
||||
return { online: false, reason: '设备从未发送心跳' };
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const lastHeartbeat = device.lastHeartbeat || 0;
|
||||
const elapsed = now - lastHeartbeat;
|
||||
|
||||
if (elapsed > offlineThreshold) {
|
||||
const minutes = Math.round(elapsed / (60 * 1000));
|
||||
return {
|
||||
online: false,
|
||||
lastHeartbeat,
|
||||
reason: `设备离线(最后心跳: ${minutes}分钟前)`
|
||||
};
|
||||
}
|
||||
|
||||
if (!device.isOnline) {
|
||||
return { online: false, lastHeartbeat, reason: '设备标记为离线' };
|
||||
}
|
||||
|
||||
return { online: true, lastHeartbeat };
|
||||
}
|
||||
|
||||
/**
|
||||
* 综合验证(启用 + 授权 + 在线)
|
||||
* @param {string} sn_code - 设备序列号
|
||||
* @param {object} options - 验证选项
|
||||
* @param {boolean} options.checkEnabled - 是否检查启用状态(默认 true)
|
||||
* @param {boolean} options.checkAuth - 是否检查授权(默认 true)
|
||||
* @param {boolean} options.checkOnline - 是否检查在线(默认 true)
|
||||
* @param {number} options.offlineThreshold - 离线阈值(默认 3分钟)
|
||||
* @returns {Promise<{valid: boolean, reason?: string, details?: object}>}
|
||||
*/
|
||||
async validate(sn_code, options = {}) {
|
||||
const {
|
||||
checkEnabled = true,
|
||||
checkAuth = true,
|
||||
checkOnline = true,
|
||||
offlineThreshold = 3 * 60 * 1000
|
||||
} = options;
|
||||
|
||||
const details = {};
|
||||
|
||||
// 检查启用状态
|
||||
if (checkEnabled) {
|
||||
const enabledResult = await this.checkEnabled(sn_code);
|
||||
details.enabled = enabledResult;
|
||||
|
||||
if (!enabledResult.enabled) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: enabledResult.reason,
|
||||
details
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 检查授权状态
|
||||
if (checkAuth) {
|
||||
const authResult = await this.checkAuthorization(sn_code);
|
||||
details.authorization = authResult;
|
||||
|
||||
if (!authResult.authorized) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: authResult.reason,
|
||||
details
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 检查在线状态
|
||||
if (checkOnline) {
|
||||
const onlineResult = this.checkOnline(sn_code, offlineThreshold);
|
||||
details.online = onlineResult;
|
||||
|
||||
if (!onlineResult.online) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: onlineResult.reason,
|
||||
details
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true, details };
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量验证多个账户
|
||||
* @param {string[]} sn_codes - 设备序列号数组
|
||||
* @param {object} options - 验证选项
|
||||
* @returns {Promise<{valid: string[], invalid: Array<{sn_code: string, reason: string}>}>}
|
||||
*/
|
||||
async validateBatch(sn_codes, options = {}) {
|
||||
const valid = [];
|
||||
const invalid = [];
|
||||
|
||||
for (const sn_code of sn_codes) {
|
||||
const result = await this.validate(sn_code, options);
|
||||
|
||||
if (result.valid) {
|
||||
valid.push(sn_code);
|
||||
} else {
|
||||
invalid.push({ sn_code, reason: result.reason });
|
||||
}
|
||||
}
|
||||
|
||||
return { valid, invalid };
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查账户是否已登录(通过心跳数据)
|
||||
* @param {string} sn_code - 设备序列号
|
||||
* @returns {boolean}
|
||||
*/
|
||||
checkLoggedIn(sn_code) {
|
||||
const device = deviceManager.devices.get(sn_code);
|
||||
return device?.isLoggedIn || false;
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
const accountValidator = new AccountValidator();
|
||||
module.exports = accountValidator;
|
||||
@@ -1,225 +0,0 @@
|
||||
/**
|
||||
* 配置管理服务
|
||||
* 统一处理账户配置的解析和验证
|
||||
*/
|
||||
class ConfigManager {
|
||||
/**
|
||||
* 解析 JSON 配置字符串
|
||||
* @param {string|object} config - 配置字符串或对象
|
||||
* @param {object} defaultValue - 默认值
|
||||
* @returns {object} 解析后的配置对象
|
||||
*/
|
||||
static parseConfig(config, defaultValue = {}) {
|
||||
if (!config) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
if (typeof config === 'object') {
|
||||
return { ...defaultValue, ...config };
|
||||
}
|
||||
|
||||
if (typeof config === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(config);
|
||||
return { ...defaultValue, ...parsed };
|
||||
} catch (error) {
|
||||
console.warn('[配置管理] JSON 解析失败:', error.message);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析投递配置
|
||||
* @param {string|object} deliverConfig - 投递配置
|
||||
* @returns {object} 标准化的投递配置
|
||||
*/
|
||||
static parseDeliverConfig(deliverConfig) {
|
||||
const defaultConfig = {
|
||||
deliver_interval: 30, // 投递间隔(分钟)
|
||||
min_salary: 0, // 最低薪资
|
||||
max_salary: 0, // 最高薪资
|
||||
page_count: 3, // 搜索页数
|
||||
max_deliver: 10, // 最大投递数
|
||||
filter_keywords: [], // 过滤关键词
|
||||
exclude_keywords: [], // 排除关键词
|
||||
time_range: null, // 时间范围
|
||||
priority_weights: null // 优先级权重
|
||||
};
|
||||
|
||||
return this.parseConfig(deliverConfig, defaultConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析搜索配置
|
||||
* @param {string|object} searchConfig - 搜索配置
|
||||
* @returns {object} 标准化的搜索配置
|
||||
*/
|
||||
static parseSearchConfig(searchConfig) {
|
||||
const defaultConfig = {
|
||||
search_interval: 60, // 搜索间隔(分钟)
|
||||
page_count: 3, // 搜索页数
|
||||
keywords: [], // 搜索关键词
|
||||
exclude_keywords: [], // 排除关键词
|
||||
time_range: null // 时间范围
|
||||
};
|
||||
|
||||
return this.parseConfig(searchConfig, defaultConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析沟通配置
|
||||
* @param {string|object} chatStrategy - 沟通策略
|
||||
* @returns {object} 标准化的沟通配置
|
||||
*/
|
||||
static parseChatStrategy(chatStrategy) {
|
||||
const defaultConfig = {
|
||||
chat_interval: 30, // 沟通间隔(分钟)
|
||||
auto_reply: false, // 是否自动回复
|
||||
reply_template: '', // 回复模板
|
||||
time_range: null // 时间范围
|
||||
};
|
||||
|
||||
return this.parseConfig(chatStrategy, defaultConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析活跃配置
|
||||
* @param {string|object} activeStrategy - 活跃策略
|
||||
* @returns {object} 标准化的活跃配置
|
||||
*/
|
||||
static parseActiveStrategy(activeStrategy) {
|
||||
const defaultConfig = {
|
||||
active_interval: 120, // 活跃间隔(分钟)
|
||||
actions: ['view_jobs'], // 活跃动作
|
||||
time_range: null // 时间范围
|
||||
};
|
||||
|
||||
return this.parseConfig(activeStrategy, defaultConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取优先级权重配置
|
||||
* @param {object} config - 投递配置
|
||||
* @returns {object} 优先级权重
|
||||
*/
|
||||
static getPriorityWeights(config) {
|
||||
const defaultWeights = {
|
||||
salary: 0.4, // 薪资匹配度
|
||||
keyword: 0.3, // 关键词匹配度
|
||||
company: 0.2, // 公司活跃度
|
||||
distance: 0.1 // 距离(未来)
|
||||
};
|
||||
|
||||
if (!config.priority_weights) {
|
||||
return defaultWeights;
|
||||
}
|
||||
|
||||
return { ...defaultWeights, ...config.priority_weights };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取排除关键词列表
|
||||
* @param {object} config - 配置对象
|
||||
* @returns {string[]} 排除关键词数组
|
||||
*/
|
||||
static getExcludeKeywords(config) {
|
||||
if (!config.exclude_keywords) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(config.exclude_keywords)) {
|
||||
return config.exclude_keywords.filter(k => k && k.trim());
|
||||
}
|
||||
|
||||
if (typeof config.exclude_keywords === 'string') {
|
||||
return config.exclude_keywords
|
||||
.split(/[,,、]/)
|
||||
.map(k => k.trim())
|
||||
.filter(k => k);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取过滤关键词列表
|
||||
* @param {object} config - 配置对象
|
||||
* @returns {string[]} 过滤关键词数组
|
||||
*/
|
||||
static getFilterKeywords(config) {
|
||||
if (!config.filter_keywords) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(config.filter_keywords)) {
|
||||
return config.filter_keywords.filter(k => k && k.trim());
|
||||
}
|
||||
|
||||
if (typeof config.filter_keywords === 'string') {
|
||||
return config.filter_keywords
|
||||
.split(/[,,、]/)
|
||||
.map(k => k.trim())
|
||||
.filter(k => k);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取薪资范围
|
||||
* @param {object} config - 配置对象
|
||||
* @returns {{min: number, max: number}} 薪资范围
|
||||
*/
|
||||
static getSalaryRange(config) {
|
||||
return {
|
||||
min: parseInt(config.min_salary) || 0,
|
||||
max: parseInt(config.max_salary) || 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取时间范围
|
||||
* @param {object} config - 配置对象
|
||||
* @returns {object|null} 时间范围配置
|
||||
*/
|
||||
static getTimeRange(config) {
|
||||
return config.time_range || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证配置完整性
|
||||
* @param {object} config - 配置对象
|
||||
* @param {string[]} requiredFields - 必需字段
|
||||
* @returns {{valid: boolean, missing?: string[]}} 验证结果
|
||||
*/
|
||||
static validateConfig(config, requiredFields = []) {
|
||||
const missing = [];
|
||||
|
||||
for (const field of requiredFields) {
|
||||
if (config[field] === undefined || config[field] === null) {
|
||||
missing.push(field);
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
return { valid: false, missing };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并配置(用于覆盖默认配置)
|
||||
* @param {object} defaultConfig - 默认配置
|
||||
* @param {object} userConfig - 用户配置
|
||||
* @returns {object} 合并后的配置
|
||||
*/
|
||||
static mergeConfig(defaultConfig, userConfig) {
|
||||
return { ...defaultConfig, ...userConfig };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ConfigManager;
|
||||
@@ -1,395 +0,0 @@
|
||||
const SalaryParser = require('../utils/salaryParser');
|
||||
const KeywordMatcher = require('../utils/keywordMatcher');
|
||||
const db = require('../../dbProxy');
|
||||
|
||||
/**
|
||||
* 职位过滤引擎
|
||||
* 综合处理职位的过滤、评分和排序
|
||||
*/
|
||||
class JobFilterEngine {
|
||||
/**
|
||||
* 过滤职位列表
|
||||
* @param {Array} jobs - 职位列表
|
||||
* @param {object} config - 过滤配置
|
||||
* @param {object} resumeInfo - 简历信息
|
||||
* @returns {Promise<Array>} 过滤后的职位列表
|
||||
*/
|
||||
async filterJobs(jobs, config, resumeInfo = {}) {
|
||||
if (!jobs || jobs.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let filtered = [...jobs];
|
||||
|
||||
// 1. 薪资过滤
|
||||
filtered = this.filterBySalary(filtered, config);
|
||||
|
||||
// 2. 关键词过滤
|
||||
filtered = this.filterByKeywords(filtered, config);
|
||||
|
||||
// 3. 公司活跃度过滤
|
||||
if (config.filter_inactive_companies) {
|
||||
filtered = await this.filterByCompanyActivity(filtered, config.company_active_days || 7);
|
||||
}
|
||||
|
||||
// 4. 去重(同一公司、同一职位名称)
|
||||
if (config.deduplicate) {
|
||||
filtered = this.deduplicateJobs(filtered);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按薪资过滤
|
||||
* @param {Array} jobs - 职位列表
|
||||
* @param {object} config - 配置
|
||||
* @returns {Array} 过滤后的职位
|
||||
*/
|
||||
filterBySalary(jobs, config) {
|
||||
const { min_salary = 0, max_salary = 0 } = config;
|
||||
|
||||
if (min_salary === 0 && max_salary === 0) {
|
||||
return jobs; // 无薪资限制
|
||||
}
|
||||
|
||||
return jobs.filter(job => {
|
||||
const jobSalary = SalaryParser.parse(job.salary || job.salaryDesc || '');
|
||||
return SalaryParser.isWithinRange(jobSalary, min_salary, max_salary);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 按关键词过滤
|
||||
* @param {Array} jobs - 职位列表
|
||||
* @param {object} config - 配置
|
||||
* @returns {Array} 过滤后的职位
|
||||
*/
|
||||
filterByKeywords(jobs, config) {
|
||||
const {
|
||||
exclude_keywords = [],
|
||||
filter_keywords = []
|
||||
} = config;
|
||||
|
||||
if (exclude_keywords.length === 0 && filter_keywords.length === 0) {
|
||||
return jobs;
|
||||
}
|
||||
|
||||
return KeywordMatcher.filterJobs(jobs, {
|
||||
excludeKeywords: exclude_keywords,
|
||||
filterKeywords: filter_keywords
|
||||
}, (job) => {
|
||||
// 组合职位名称、描述、技能要求等
|
||||
return [
|
||||
job.name || job.jobName || '',
|
||||
job.description || job.jobDescription || '',
|
||||
job.skills || '',
|
||||
job.welfare || ''
|
||||
].join(' ');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 按公司活跃度过滤
|
||||
* @param {Array} jobs - 职位列表
|
||||
* @param {number} activeDays - 活跃天数阈值
|
||||
* @returns {Promise<Array>} 过滤后的职位
|
||||
*/
|
||||
async filterByCompanyActivity(jobs, activeDays = 7) {
|
||||
try {
|
||||
const task_status = db.getModel('task_status');
|
||||
const thresholdDate = new Date(Date.now() - activeDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
// 查询近期已投递的公司
|
||||
const recentCompanies = await task_status.findAll({
|
||||
where: {
|
||||
taskType: 'auto_deliver',
|
||||
status: 'completed',
|
||||
endTime: {
|
||||
[db.models.op.gte]: thresholdDate
|
||||
}
|
||||
},
|
||||
attributes: ['result'],
|
||||
raw: true
|
||||
});
|
||||
|
||||
// 提取公司名称
|
||||
const deliveredCompanies = new Set();
|
||||
for (const task of recentCompanies) {
|
||||
try {
|
||||
const result = JSON.parse(task.result || '{}');
|
||||
if (result.deliveredJobs) {
|
||||
result.deliveredJobs.forEach(job => {
|
||||
if (job.company) {
|
||||
deliveredCompanies.add(job.company.toLowerCase());
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤掉近期已投递的公司
|
||||
return jobs.filter(job => {
|
||||
const company = (job.company || job.companyName || '').toLowerCase().trim();
|
||||
return !deliveredCompanies.has(company);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[职位过滤] 公司活跃度过滤失败:', error);
|
||||
return jobs; // 失败时返回原列表
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 去重职位
|
||||
* @param {Array} jobs - 职位列表
|
||||
* @returns {Array} 去重后的职位
|
||||
*/
|
||||
deduplicateJobs(jobs) {
|
||||
const seen = new Set();
|
||||
const unique = [];
|
||||
|
||||
for (const job of jobs) {
|
||||
const company = (job.company || job.companyName || '').toLowerCase().trim();
|
||||
const jobName = (job.name || job.jobName || '').toLowerCase().trim();
|
||||
const key = `${company}||${jobName}`;
|
||||
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
unique.push(job);
|
||||
}
|
||||
}
|
||||
|
||||
return unique;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为职位打分
|
||||
* @param {Array} jobs - 职位列表
|
||||
* @param {object} resumeInfo - 简历信息
|
||||
* @param {object} config - 配置(包含权重)
|
||||
* @returns {Array} 带分数的职位列表
|
||||
*/
|
||||
scoreJobs(jobs, resumeInfo = {}, config = {}) {
|
||||
const weights = config.priority_weights || {
|
||||
salary: 0.4,
|
||||
keyword: 0.3,
|
||||
company: 0.2,
|
||||
freshness: 0.1
|
||||
};
|
||||
|
||||
return jobs.map(job => {
|
||||
const scores = {
|
||||
salary: this.scoreSalary(job, resumeInfo),
|
||||
keyword: this.scoreKeywords(job, config),
|
||||
company: this.scoreCompany(job),
|
||||
freshness: this.scoreFreshness(job)
|
||||
};
|
||||
|
||||
// 加权总分
|
||||
const totalScore = (
|
||||
scores.salary * weights.salary +
|
||||
scores.keyword * weights.keyword +
|
||||
scores.company * weights.company +
|
||||
scores.freshness * weights.freshness
|
||||
);
|
||||
|
||||
return {
|
||||
...job,
|
||||
_scores: scores,
|
||||
_totalScore: totalScore
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 薪资匹配度评分 (0-100)
|
||||
* @param {object} job - 职位信息
|
||||
* @param {object} resumeInfo - 简历信息
|
||||
* @returns {number} 分数
|
||||
*/
|
||||
scoreSalary(job, resumeInfo) {
|
||||
const jobSalary = SalaryParser.parse(job.salary || job.salaryDesc || '');
|
||||
const expectedSalary = SalaryParser.parse(resumeInfo.expected_salary || '');
|
||||
|
||||
if (jobSalary.min === 0 || expectedSalary.min === 0) {
|
||||
return 50; // 无法判断时返回中性分
|
||||
}
|
||||
|
||||
const matchScore = SalaryParser.calculateMatch(jobSalary, expectedSalary);
|
||||
return matchScore * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* 关键词匹配度评分 (0-100)
|
||||
* @param {object} job - 职位信息
|
||||
* @param {object} config - 配置
|
||||
* @returns {number} 分数
|
||||
*/
|
||||
scoreKeywords(job, config) {
|
||||
const bonusKeywords = config.filter_keywords || [];
|
||||
|
||||
if (bonusKeywords.length === 0) {
|
||||
return 50; // 无关键词时返回中性分
|
||||
}
|
||||
|
||||
const jobText = [
|
||||
job.name || job.jobName || '',
|
||||
job.description || job.jobDescription || '',
|
||||
job.skills || ''
|
||||
].join(' ');
|
||||
|
||||
const bonusResult = KeywordMatcher.calculateBonus(jobText, bonusKeywords, {
|
||||
baseScore: 10,
|
||||
maxBonus: 100
|
||||
});
|
||||
|
||||
return Math.min(bonusResult.score, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* 公司评分 (0-100)
|
||||
* @param {object} job - 职位信息
|
||||
* @returns {number} 分数
|
||||
*/
|
||||
scoreCompany(job) {
|
||||
let score = 50; // 基础分
|
||||
|
||||
// 融资阶段加分
|
||||
const fundingStage = (job.financingStage || job.financing || '').toLowerCase();
|
||||
const fundingBonus = {
|
||||
'已上市': 30,
|
||||
'上市公司': 30,
|
||||
'd轮': 25,
|
||||
'c轮': 20,
|
||||
'b轮': 15,
|
||||
'a轮': 10,
|
||||
'天使轮': 5
|
||||
};
|
||||
|
||||
for (const [stage, bonus] of Object.entries(fundingBonus)) {
|
||||
if (fundingStage.includes(stage.toLowerCase())) {
|
||||
score += bonus;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 公司规模加分
|
||||
const scale = (job.scale || job.companyScale || '').toLowerCase();
|
||||
if (scale.includes('10000') || scale.includes('万人')) {
|
||||
score += 15;
|
||||
} else if (scale.includes('1000-9999') || scale.includes('千人')) {
|
||||
score += 10;
|
||||
} else if (scale.includes('500-999')) {
|
||||
score += 5;
|
||||
}
|
||||
|
||||
return Math.min(score, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新鲜度评分 (0-100)
|
||||
* @param {object} job - 职位信息
|
||||
* @returns {number} 分数
|
||||
*/
|
||||
scoreFreshness(job) {
|
||||
const publishTime = job.publishTime || job.createTime;
|
||||
|
||||
if (!publishTime) {
|
||||
return 50; // 无时间信息时返回中性分
|
||||
}
|
||||
|
||||
try {
|
||||
const now = Date.now();
|
||||
const pubTime = new Date(publishTime).getTime();
|
||||
const hoursAgo = (now - pubTime) / (1000 * 60 * 60);
|
||||
|
||||
// 越新鲜分数越高
|
||||
if (hoursAgo < 1) return 100;
|
||||
if (hoursAgo < 24) return 90;
|
||||
if (hoursAgo < 72) return 70;
|
||||
if (hoursAgo < 168) return 50; // 一周内
|
||||
return 30;
|
||||
|
||||
} catch (error) {
|
||||
return 50;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 排序职位
|
||||
* @param {Array} jobs - 职位列表(带分数)
|
||||
* @param {string} sortBy - 排序方式: score, salary, freshness
|
||||
* @returns {Array} 排序后的职位
|
||||
*/
|
||||
sortJobs(jobs, sortBy = 'score') {
|
||||
const sorted = [...jobs];
|
||||
|
||||
switch (sortBy) {
|
||||
case 'score':
|
||||
sorted.sort((a, b) => (b._totalScore || 0) - (a._totalScore || 0));
|
||||
break;
|
||||
|
||||
case 'salary':
|
||||
sorted.sort((a, b) => {
|
||||
const salaryA = SalaryParser.parse(a.salary || '');
|
||||
const salaryB = SalaryParser.parse(b.salary || '');
|
||||
return (salaryB.max || 0) - (salaryA.max || 0);
|
||||
});
|
||||
break;
|
||||
|
||||
case 'freshness':
|
||||
sorted.sort((a, b) => {
|
||||
const timeA = new Date(a.publishTime || a.createTime || 0).getTime();
|
||||
const timeB = new Date(b.publishTime || b.createTime || 0).getTime();
|
||||
return timeB - timeA;
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
// 默认按分数排序
|
||||
sorted.sort((a, b) => (b._totalScore || 0) - (a._totalScore || 0));
|
||||
}
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 综合处理:过滤 + 评分 + 排序
|
||||
* @param {Array} jobs - 职位列表
|
||||
* @param {object} config - 过滤配置
|
||||
* @param {object} resumeInfo - 简历信息
|
||||
* @param {object} options - 选项
|
||||
* @returns {Promise<Array>} 处理后的职位列表
|
||||
*/
|
||||
async process(jobs, config, resumeInfo = {}, options = {}) {
|
||||
const {
|
||||
maxCount = 10, // 最大返回数量
|
||||
sortBy = 'score' // 排序方式
|
||||
} = options;
|
||||
|
||||
// 1. 过滤
|
||||
let filtered = await this.filterJobs(jobs, config, resumeInfo);
|
||||
|
||||
console.log(`[职位过滤] 原始: ${jobs.length} 个,过滤后: ${filtered.length} 个`);
|
||||
|
||||
// 2. 评分
|
||||
const scored = this.scoreJobs(filtered, resumeInfo, config);
|
||||
|
||||
// 3. 排序
|
||||
const sorted = this.sortJobs(scored, sortBy);
|
||||
|
||||
// 4. 截取
|
||||
const result = sorted.slice(0, maxCount);
|
||||
|
||||
console.log(`[职位过滤] 最终返回: ${result.length} 个职位`);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
const jobFilterEngine = new JobFilterEngine();
|
||||
module.exports = jobFilterEngine;
|
||||
@@ -1,158 +0,0 @@
|
||||
/**
|
||||
* 时间范围验证器
|
||||
* 检查当前时间是否在指定的时间范围内(支持工作日限制)
|
||||
*/
|
||||
class TimeRangeValidator {
|
||||
/**
|
||||
* 检查当前时间是否在指定的时间范围内
|
||||
* @param {object} timeRange - 时间范围配置 {start_time: '09:00', end_time: '18:00', workdays_only: 1}
|
||||
* @returns {{allowed: boolean, reason: string}} 检查结果
|
||||
*/
|
||||
static checkTimeRange(timeRange) {
|
||||
if (!timeRange || !timeRange.start_time || !timeRange.end_time) {
|
||||
return { allowed: true, reason: '未配置时间范围' };
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const currentHour = now.getHours();
|
||||
const currentMinute = now.getMinutes();
|
||||
const currentTime = currentHour * 60 + currentMinute; // 转换为分钟数
|
||||
|
||||
// 解析开始时间和结束时间
|
||||
const [startHour, startMinute] = timeRange.start_time.split(':').map(Number);
|
||||
const [endHour, endMinute] = timeRange.end_time.split(':').map(Number);
|
||||
const startTime = startHour * 60 + startMinute;
|
||||
const endTime = endHour * 60 + endMinute;
|
||||
|
||||
// 检查是否仅工作日(使用宽松比较,兼容字符串和数字)
|
||||
if (timeRange.workdays_only == 1) {
|
||||
const dayOfWeek = now.getDay(); // 0=周日, 1=周一, ..., 6=周六
|
||||
if (dayOfWeek === 0 || dayOfWeek === 6) {
|
||||
return { allowed: false, reason: '当前是周末,不在允许的时间范围内' };
|
||||
}
|
||||
}
|
||||
|
||||
// 检查当前时间是否在时间范围内
|
||||
if (startTime <= endTime) {
|
||||
// 正常情况:09:00 - 18:00
|
||||
if (currentTime < startTime || currentTime >= endTime) {
|
||||
const currentTimeStr = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`;
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `当前时间 ${currentTimeStr} 不在允许的时间范围内 (${timeRange.start_time} - ${timeRange.end_time})`
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// 跨天情况:22:00 - 06:00
|
||||
if (currentTime < startTime && currentTime >= endTime) {
|
||||
const currentTimeStr = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`;
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `当前时间 ${currentTimeStr} 不在允许的时间范围内 (${timeRange.start_time} - ${timeRange.end_time})`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { allowed: true, reason: '在允许的时间范围内' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否在工作时间内
|
||||
* @param {string} startTime - 开始时间 '09:00'
|
||||
* @param {string} endTime - 结束时间 '18:00'
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static isWithinWorkingHours(startTime = '09:00', endTime = '18:00') {
|
||||
const result = this.checkTimeRange({
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
workdays_only: 0
|
||||
});
|
||||
return result.allowed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否是工作日
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static isWorkingDay() {
|
||||
const dayOfWeek = new Date().getDay();
|
||||
return dayOfWeek !== 0 && dayOfWeek !== 6; // 非周六周日
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取下一个可操作时间
|
||||
* @param {object} timeRange - 时间范围配置
|
||||
* @returns {Date|null} 下一个可操作时间,如果当前可操作则返回 null
|
||||
*/
|
||||
static getNextAvailableTime(timeRange) {
|
||||
const check = this.checkTimeRange(timeRange);
|
||||
if (check.allowed) {
|
||||
return null; // 当前可操作
|
||||
}
|
||||
|
||||
if (!timeRange || !timeRange.start_time) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const [startHour, startMinute] = timeRange.start_time.split(':').map(Number);
|
||||
|
||||
// 如果是工作日限制且当前是周末
|
||||
if (timeRange.workdays_only == 1) {
|
||||
const dayOfWeek = now.getDay();
|
||||
if (dayOfWeek === 0) {
|
||||
// 周日,下一个可操作时间是周一
|
||||
const nextTime = new Date(now);
|
||||
nextTime.setDate(now.getDate() + 1);
|
||||
nextTime.setHours(startHour, startMinute, 0, 0);
|
||||
return nextTime;
|
||||
} else if (dayOfWeek === 6) {
|
||||
// 周六,下一个可操作时间是下周一
|
||||
const nextTime = new Date(now);
|
||||
nextTime.setDate(now.getDate() + 2);
|
||||
nextTime.setHours(startHour, startMinute, 0, 0);
|
||||
return nextTime;
|
||||
}
|
||||
}
|
||||
|
||||
// 计算下一个开始时间
|
||||
const nextTime = new Date(now);
|
||||
nextTime.setHours(startHour, startMinute, 0, 0);
|
||||
|
||||
// 如果已经过了今天的开始时间,则设置为明天
|
||||
if (nextTime <= now) {
|
||||
nextTime.setDate(now.getDate() + 1);
|
||||
}
|
||||
|
||||
return nextTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化剩余时间
|
||||
* @param {object} timeRange - 时间范围配置
|
||||
* @returns {string} 剩余时间描述
|
||||
*/
|
||||
static formatRemainingTime(timeRange) {
|
||||
const nextTime = this.getNextAvailableTime(timeRange);
|
||||
if (!nextTime) {
|
||||
return '当前可操作';
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const diff = nextTime.getTime() - now;
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
if (hours > 24) {
|
||||
const days = Math.floor(hours / 24);
|
||||
return `需要等待 ${days} 天 ${hours % 24} 小时`;
|
||||
} else if (hours > 0) {
|
||||
return `需要等待 ${hours} 小时 ${minutes} 分钟`;
|
||||
} else {
|
||||
return `需要等待 ${minutes} 分钟`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TimeRangeValidator;
|
||||
@@ -1,101 +1,803 @@
|
||||
const { SearchHandler, DeliverHandler, ChatHandler, ActiveHandler } = require('./handlers');
|
||||
const db = require('../dbProxy.js');
|
||||
const config = require('./config.js');
|
||||
const deviceManager = require('./deviceManager.js');
|
||||
const command = require('./command.js');
|
||||
const jobFilterService = require('../job/job_filter_service.js');
|
||||
|
||||
/**
|
||||
* 任务处理器工厂(重构版)
|
||||
* 使用独立的处理器类替代原有的内嵌处理方法
|
||||
*
|
||||
* 重构说明:
|
||||
* - 原 taskHandlers.js: 1045 行,包含所有业务逻辑
|
||||
* - 新 taskHandlers.js: 95 行,仅作为处理器工厂
|
||||
* - 业务逻辑已分离到 handlers/ 目录下的独立处理器
|
||||
* 任务处理器(简化版)
|
||||
* 处理各种类型的任务
|
||||
*/
|
||||
class TaskHandlers {
|
||||
constructor(mqttClient) {
|
||||
this.mqttClient = mqttClient;
|
||||
|
||||
// 初始化各个处理器
|
||||
this.searchHandler = new SearchHandler(mqttClient);
|
||||
this.deliverHandler = new DeliverHandler(mqttClient);
|
||||
this.chatHandler = new ChatHandler(mqttClient);
|
||||
this.activeHandler = new ActiveHandler(mqttClient);
|
||||
|
||||
console.log('[任务处理器] 已初始化所有处理器实例');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 注册任务处理器到任务队列
|
||||
* @param {object} taskQueue - 任务队列实例
|
||||
*/
|
||||
* 注册任务处理器到任务队列
|
||||
* @param {object} taskQueue - 任务队列实例
|
||||
*/
|
||||
register(taskQueue) {
|
||||
console.log('[任务处理器] 开始注册处理器...');
|
||||
|
||||
// 注册自动搜索处理器
|
||||
taskQueue.registerHandler('auto_search', async (task) => {
|
||||
return await this.handleAutoSearchTask(task);
|
||||
});
|
||||
|
||||
// 注册自动投递处理器
|
||||
// 自动投递任务
|
||||
taskQueue.registerHandler('auto_deliver', async (task) => {
|
||||
return await this.handleAutoDeliverTask(task);
|
||||
});
|
||||
|
||||
// 注册搜索职位列表处理器(与 auto_search 相同)
|
||||
taskQueue.registerHandler('search_jobs', async (task) => {
|
||||
return await this.handleAutoSearchTask(task);
|
||||
});
|
||||
|
||||
// 注册自动沟通处理器
|
||||
// 自动沟通任务(待实现)
|
||||
taskQueue.registerHandler('auto_chat', async (task) => {
|
||||
return await this.handleAutoChatTask(task);
|
||||
});
|
||||
|
||||
// 注册自动活跃账户处理器
|
||||
// 自动活跃账号任务(待实现)
|
||||
taskQueue.registerHandler('auto_active_account', async (task) => {
|
||||
return await this.handleAutoActiveAccountTask(task);
|
||||
});
|
||||
|
||||
console.log('[任务处理器] 所有处理器已注册完成');
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理自动搜索任务
|
||||
* @param {object} task - 任务对象
|
||||
* @returns {Promise<object>} 执行结果
|
||||
*/
|
||||
async handleAutoSearchTask(task) {
|
||||
console.log(`[任务处理器] 调度自动搜索任务 - 设备: ${task.sn_code}`);
|
||||
return await this.searchHandler.handle(task);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 处理自动投递任务
|
||||
* @param {object} task - 任务对象
|
||||
* @returns {Promise<object>} 执行结果
|
||||
*/
|
||||
|
||||
async handleAutoDeliverTask(task) {
|
||||
console.log(`[任务处理器] 调度自动投递任务 - 设备: ${task.sn_code}`);
|
||||
return await this.deliverHandler.handle(task);
|
||||
const { sn_code, taskParams } = task;
|
||||
const { keyword, platform, pageCount, maxCount, filterRules = {} } = taskParams;
|
||||
|
||||
console.log(`[任务处理器] 自动投递任务 - 设备: ${sn_code}, 关键词: ${keyword}`);
|
||||
|
||||
// 检查授权状态
|
||||
const authorizationService = require('../../services/authorization_service');
|
||||
const authCheck = await authorizationService.checkAuthorization(sn_code, 'sn_code');
|
||||
if (!authCheck.is_authorized) {
|
||||
console.log(`[任务处理器] 自动投递任务 - 设备: ${sn_code} 授权检查失败: ${authCheck.message}`);
|
||||
return {
|
||||
success: false,
|
||||
deliveredCount: 0,
|
||||
message: authCheck.message
|
||||
};
|
||||
}
|
||||
|
||||
deviceManager.recordTaskStart(sn_code, task);
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const job_postings = db.getModel('job_postings');
|
||||
const pla_account = db.getModel('pla_account');
|
||||
const resume_info = db.getModel('resume_info');
|
||||
const job_types = db.getModel('job_types');
|
||||
const apply_records = db.getModel('apply_records');
|
||||
const Sequelize = require('sequelize');
|
||||
const { Op } = Sequelize;
|
||||
|
||||
// 检查今日投递次数限制
|
||||
const currentPlatform = platform || 'boss';
|
||||
const dailyLimit = config.getDailyLimit('apply', currentPlatform);
|
||||
|
||||
// 获取今日开始时间(00:00:00)
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
// 查询今日已投递次数
|
||||
const todayApplyCount = await apply_records.count({
|
||||
where: {
|
||||
sn_code: sn_code,
|
||||
platform: currentPlatform,
|
||||
applyTime: {
|
||||
[Op.gte]: today
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[任务处理器] 今日已投递 ${todayApplyCount} 次,限制: ${dailyLimit} 次`);
|
||||
|
||||
// 如果已达到每日投递上限,则跳过
|
||||
if (todayApplyCount >= dailyLimit) {
|
||||
console.log(`[任务处理器] 已达到每日投递上限(${dailyLimit}次),跳过投递`);
|
||||
return {
|
||||
success: false,
|
||||
deliveredCount: 0,
|
||||
message: `已达到每日投递上限(${dailyLimit}次),今日已投递 ${todayApplyCount} 次`
|
||||
};
|
||||
}
|
||||
|
||||
// 计算本次可投递的数量(不超过剩余限额)
|
||||
const remainingQuota = dailyLimit - todayApplyCount;
|
||||
const actualMaxCount = Math.min(maxCount || 10, remainingQuota);
|
||||
|
||||
if (actualMaxCount < (maxCount || 10)) {
|
||||
console.log(`[任务处理器] 受每日投递上限限制,本次最多投递 ${actualMaxCount} 个职位(剩余限额: ${remainingQuota})`);
|
||||
}
|
||||
|
||||
// 1. 检查并获取在线简历(如果2小时内没有获取)
|
||||
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
|
||||
let resume = await resume_info.findOne({
|
||||
where: {
|
||||
sn_code,
|
||||
platform: platform || 'boss',
|
||||
isActive: true
|
||||
},
|
||||
order: [['last_modify_time', 'DESC']]
|
||||
});
|
||||
|
||||
const needRefreshResume = !resume ||
|
||||
!resume.last_modify_time ||
|
||||
new Date(resume.last_modify_time) < twoHoursAgo;
|
||||
|
||||
if (needRefreshResume) {
|
||||
console.log(`[任务处理器] 简历超过2小时未更新,重新获取在线简历`);
|
||||
try {
|
||||
// 通过 command 系统获取在线简历,而不是直接调用 jobManager
|
||||
const getResumeCommand = {
|
||||
command_type: 'getOnlineResume',
|
||||
command_name: '获取在线简历',
|
||||
command_params: JSON.stringify({ sn_code, platform: platform || 'boss' }),
|
||||
priority: config.getTaskPriority('get_resume') || 5
|
||||
};
|
||||
await command.executeCommands(task.id, [getResumeCommand], this.mqttClient);
|
||||
|
||||
// 重新查询简历
|
||||
resume = await resume_info.findOne({
|
||||
where: {
|
||||
sn_code,
|
||||
platform: platform || 'boss',
|
||||
isActive: true
|
||||
},
|
||||
order: [['last_modify_time', 'DESC']]
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`[任务处理器] 获取在线简历失败,使用已有简历:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (!resume) {
|
||||
console.log(`[任务处理器] 未找到简历信息,无法进行自动投递`);
|
||||
return {
|
||||
success: false,
|
||||
deliveredCount: 0,
|
||||
message: '未找到简历信息'
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 获取账号配置和职位类型配置
|
||||
const account = await pla_account.findOne({
|
||||
where: { sn_code, platform_type: platform || 'boss' }
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
console.log(`[任务处理器] 未找到账号配置`);
|
||||
return {
|
||||
success: false,
|
||||
deliveredCount: 0,
|
||||
message: '未找到账号配置'
|
||||
};
|
||||
}
|
||||
|
||||
const accountConfig = account.toJSON();
|
||||
const resumeInfo = resume.toJSON();
|
||||
|
||||
// 检查投递时间范围
|
||||
if (accountConfig.deliver_config) {
|
||||
const deliverConfig = typeof accountConfig.deliver_config === 'string'
|
||||
? JSON.parse(accountConfig.deliver_config)
|
||||
: accountConfig.deliver_config;
|
||||
|
||||
if (deliverConfig.time_range) {
|
||||
const timeCheck = this.checkTimeRange(deliverConfig.time_range);
|
||||
if (!timeCheck.allowed) {
|
||||
console.log(`[任务处理器] 自动投递任务 - ${timeCheck.reason}`);
|
||||
return {
|
||||
success: true,
|
||||
deliveredCount: 0,
|
||||
message: timeCheck.reason
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取职位类型配置
|
||||
let jobTypeConfig = null;
|
||||
if (accountConfig.job_type_id) {
|
||||
const jobType = await job_types.findByPk(accountConfig.job_type_id);
|
||||
if (jobType) {
|
||||
jobTypeConfig = jobType.toJSON();
|
||||
}
|
||||
}
|
||||
|
||||
// 获取优先级权重配置
|
||||
let priorityWeights = accountConfig.is_salary_priority;
|
||||
if (!Array.isArray(priorityWeights) || priorityWeights.length === 0) {
|
||||
priorityWeights = [
|
||||
{ key: "distance", weight: 50 },
|
||||
{ key: "salary", weight: 20 },
|
||||
{ key: "work_years", weight: 10 },
|
||||
{ key: "education", weight: 20 }
|
||||
];
|
||||
}
|
||||
|
||||
// 3. 先获取职位列表
|
||||
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
|
||||
};
|
||||
|
||||
await command.executeCommands(task.id, [getJobListCommand], this.mqttClient);
|
||||
|
||||
// 4. 从数据库获取待投递的职位
|
||||
const pendingJobs = await job_postings.findAll({
|
||||
where: {
|
||||
sn_code: sn_code,
|
||||
platform: platform || 'boss',
|
||||
applyStatus: 'pending'
|
||||
},
|
||||
order: [['create_time', 'DESC']],
|
||||
limit: actualMaxCount * 3 // 获取更多职位用于筛选(受每日投递上限限制)
|
||||
});
|
||||
|
||||
if (!pendingJobs || pendingJobs.length === 0) {
|
||||
console.log(`[任务处理器] 没有待投递的职位`);
|
||||
return {
|
||||
success: true,
|
||||
deliveredCount: 0,
|
||||
message: '没有待投递的职位'
|
||||
};
|
||||
}
|
||||
|
||||
// 5. 根据简历信息、职位类型配置和权重配置进行评分和过滤
|
||||
const scoredJobs = [];
|
||||
|
||||
// 合并排除关键词:从职位类型配置和任务参数中获取
|
||||
const jobTypeExcludeKeywords = jobTypeConfig && jobTypeConfig.excludeKeywords
|
||||
? (typeof jobTypeConfig.excludeKeywords === 'string'
|
||||
? JSON.parse(jobTypeConfig.excludeKeywords)
|
||||
: jobTypeConfig.excludeKeywords)
|
||||
: [];
|
||||
let taskExcludeKeywords = filterRules.excludeKeywords || [];
|
||||
|
||||
// 如果 filterRules 中没有,尝试从 accountConfig.deliver_config 获取
|
||||
if ((!taskExcludeKeywords || taskExcludeKeywords.length === 0) && accountConfig.deliver_config) {
|
||||
const deliverConfig = typeof accountConfig.deliver_config === 'string'
|
||||
? JSON.parse(accountConfig.deliver_config)
|
||||
: accountConfig.deliver_config;
|
||||
if (deliverConfig.exclude_keywords) {
|
||||
taskExcludeKeywords = Array.isArray(deliverConfig.exclude_keywords)
|
||||
? deliverConfig.exclude_keywords
|
||||
: (typeof deliverConfig.exclude_keywords === 'string'
|
||||
? JSON.parse(deliverConfig.exclude_keywords)
|
||||
: []);
|
||||
}
|
||||
}
|
||||
const excludeKeywords = [...jobTypeExcludeKeywords, ...taskExcludeKeywords];
|
||||
|
||||
// 获取过滤关键词(用于优先匹配或白名单过滤)
|
||||
let filterKeywords = filterRules.keywords || [];
|
||||
|
||||
// 如果 filterRules 中没有,尝试从 accountConfig.deliver_config 获取
|
||||
if ((!filterKeywords || filterKeywords.length === 0) && accountConfig.deliver_config) {
|
||||
const deliverConfig = typeof accountConfig.deliver_config === 'string'
|
||||
? JSON.parse(accountConfig.deliver_config)
|
||||
: accountConfig.deliver_config;
|
||||
if (deliverConfig.filter_keywords) {
|
||||
filterKeywords = Array.isArray(deliverConfig.filter_keywords)
|
||||
? deliverConfig.filter_keywords
|
||||
: (typeof deliverConfig.filter_keywords === 'string'
|
||||
? JSON.parse(deliverConfig.filter_keywords)
|
||||
: []);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[任务处理器] 过滤关键词配置 - 包含关键词: ${JSON.stringify(filterKeywords)}, 排除关键词: ${JSON.stringify(excludeKeywords)}`);
|
||||
|
||||
// 获取薪资范围过滤(优先从 filterRules,如果没有则从 accountConfig.deliver_config 获取)
|
||||
let minSalary = filterRules.minSalary || 0;
|
||||
let maxSalary = filterRules.maxSalary || 0;
|
||||
|
||||
// 如果 filterRules 中没有,尝试从 accountConfig.deliver_config 获取
|
||||
if (minSalary === 0 && maxSalary === 0 && accountConfig.deliver_config) {
|
||||
const deliverConfig = typeof accountConfig.deliver_config === 'string'
|
||||
? JSON.parse(accountConfig.deliver_config)
|
||||
: accountConfig.deliver_config;
|
||||
minSalary = deliverConfig.min_salary || 0;
|
||||
maxSalary = deliverConfig.max_salary || 0;
|
||||
}
|
||||
|
||||
console.log(`[任务处理器] 薪资过滤配置 - 最低: ${minSalary}元, 最高: ${maxSalary}元`);
|
||||
|
||||
// 获取一个月内已投递的公司列表(用于过滤)
|
||||
// 注意:apply_records 和 Sequelize 已在方法开头定义
|
||||
const oneMonthAgo = new Date();
|
||||
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
|
||||
|
||||
const recentApplies = await apply_records.findAll({
|
||||
where: {
|
||||
sn_code: sn_code,
|
||||
applyTime: {
|
||||
[Sequelize.Op.gte]: oneMonthAgo
|
||||
}
|
||||
},
|
||||
attributes: ['companyName'],
|
||||
group: ['companyName']
|
||||
});
|
||||
|
||||
const recentCompanyNames = new Set(recentApplies.map(apply => apply.companyName).filter(Boolean));
|
||||
|
||||
for (const job of pendingJobs) {
|
||||
const jobData = job.toJSON ? job.toJSON() : job;
|
||||
|
||||
// 薪资范围过滤
|
||||
if (minSalary > 0 || maxSalary > 0) {
|
||||
// 解析职位薪资字符串(如 "20-30K")
|
||||
const jobSalaryRange = this.parseSalaryRange(jobData.salary || '');
|
||||
const jobSalaryMin = jobSalaryRange.min || 0;
|
||||
const jobSalaryMax = jobSalaryRange.max || 0;
|
||||
|
||||
// 如果职位没有薪资信息,跳过
|
||||
if (jobSalaryMin === 0 && jobSalaryMax === 0) {
|
||||
console.log(`[任务处理器] 跳过无薪资信息的职位: ${jobData.jobTitle} @ ${jobData.companyName}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 如果职位薪资范围与过滤范围没有交集,则跳过
|
||||
if (minSalary > 0 && jobSalaryMax > 0 && minSalary > jobSalaryMax) {
|
||||
console.log(`[任务处理器] 跳过薪资过低职位: ${jobData.jobTitle} @ ${jobData.companyName}, 职位薪资: ${jobData.salary}, 要求最低: ${minSalary}`);
|
||||
continue;
|
||||
}
|
||||
if (maxSalary > 0 && jobSalaryMin > 0 && maxSalary < jobSalaryMin) {
|
||||
console.log(`[任务处理器] 跳过薪资过高职位: ${jobData.jobTitle} @ ${jobData.companyName}, 职位薪资: ${jobData.salary}, 要求最高: ${maxSalary}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果配置了简历期望薪资,也要与职位薪资进行比较
|
||||
if (resumeInfo && resumeInfo.expectedSalary) {
|
||||
const expectedSalaryRange = this.parseExpectedSalary(resumeInfo.expectedSalary);
|
||||
if (expectedSalaryRange) {
|
||||
const jobSalaryRange = this.parseSalaryRange(jobData.salary || '');
|
||||
const jobSalaryMin = jobSalaryRange.min || 0;
|
||||
const jobSalaryMax = jobSalaryRange.max || 0;
|
||||
|
||||
// 如果职位薪资明显低于期望薪资范围,跳过
|
||||
// 期望薪资是 "20-30K",职位薪资应该至少接近或高于期望薪资的最低值
|
||||
if (jobSalaryMax > 0 && expectedSalaryRange.min > 0 && jobSalaryMax < expectedSalaryRange.min * 0.8) {
|
||||
console.log(`[任务处理器] 跳过薪资低于期望的职位: ${jobData.jobTitle} @ ${jobData.companyName}, 职位薪资: ${jobData.salary}, 期望薪资: ${resumeInfo.expectedSalary}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 排除关键词过滤
|
||||
if (Array.isArray(excludeKeywords) && excludeKeywords.length > 0) {
|
||||
const jobText = `${jobData.jobTitle} ${jobData.companyName} ${jobData.jobDescription || ''}`.toLowerCase();
|
||||
const matchedExcludeKeywords = excludeKeywords.filter(kw => {
|
||||
const keyword = kw ? kw.toLowerCase().trim() : '';
|
||||
return keyword && jobText.includes(keyword);
|
||||
});
|
||||
if (matchedExcludeKeywords.length > 0) {
|
||||
console.log(`[任务处理器] 跳过包含排除关键词的职位: ${jobData.jobTitle} @ ${jobData.companyName}, 匹配: ${matchedExcludeKeywords.join(', ')}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤关键词(白名单模式):如果设置了过滤关键词,只投递包含这些关键词的职位
|
||||
if (Array.isArray(filterKeywords) && filterKeywords.length > 0) {
|
||||
const jobText = `${jobData.jobTitle} ${jobData.companyName} ${jobData.jobDescription || ''}`.toLowerCase();
|
||||
const matchedKeywords = filterKeywords.filter(kw => {
|
||||
const keyword = kw ? kw.toLowerCase().trim() : '';
|
||||
return keyword && jobText.includes(keyword);
|
||||
});
|
||||
|
||||
if (matchedKeywords.length === 0) {
|
||||
// 如果没有匹配到任何过滤关键词,跳过该职位(白名单模式)
|
||||
console.log(`[任务处理器] 跳过未匹配过滤关键词的职位: ${jobData.jobTitle} @ ${jobData.companyName}, 过滤关键词: ${filterKeywords.join(', ')}`);
|
||||
continue;
|
||||
} else {
|
||||
console.log(`[任务处理器] 职位匹配过滤关键词: ${jobData.jobTitle} @ ${jobData.companyName}, 匹配: ${matchedKeywords.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查该公司是否在一个月内已投递过
|
||||
if (jobData.companyName && recentCompanyNames.has(jobData.companyName)) {
|
||||
console.log(`[任务处理器] 跳过一个月内已投递的公司: ${jobData.companyName}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 使用 job_filter_service 计算评分
|
||||
const scoreResult = jobFilterService.calculateJobScoreWithWeights(
|
||||
jobData,
|
||||
resumeInfo,
|
||||
accountConfig,
|
||||
jobTypeConfig,
|
||||
priorityWeights
|
||||
);
|
||||
|
||||
// 如果配置了过滤关键词,给包含这些关键词的职位加分(额外奖励)
|
||||
let keywordBonus = 0;
|
||||
if (Array.isArray(filterKeywords) && filterKeywords.length > 0) {
|
||||
const jobText = `${jobData.jobTitle} ${jobData.companyName} ${jobData.jobDescription || ''}`.toLowerCase();
|
||||
const matchedKeywords = filterKeywords.filter(kw => {
|
||||
const keyword = kw ? kw.toLowerCase().trim() : '';
|
||||
return keyword && jobText.includes(keyword);
|
||||
});
|
||||
if (matchedKeywords.length > 0) {
|
||||
// 每匹配一个关键词加5分,最多加20分
|
||||
keywordBonus = Math.min(matchedKeywords.length * 5, 20);
|
||||
}
|
||||
}
|
||||
|
||||
const finalScore = scoreResult.totalScore + keywordBonus;
|
||||
|
||||
// 只保留总分 >= 60 的职位
|
||||
if (finalScore >= 60) {
|
||||
scoredJobs.push({
|
||||
...jobData,
|
||||
matchScore: finalScore,
|
||||
scoreDetails: {
|
||||
...scoreResult.scores,
|
||||
keywordBonus: keywordBonus
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 按总分降序排序
|
||||
scoredJobs.sort((a, b) => b.matchScore - a.matchScore);
|
||||
|
||||
// 取前 actualMaxCount 个职位(受每日投递上限限制)
|
||||
const jobsToDeliver = scoredJobs.slice(0, actualMaxCount);
|
||||
|
||||
console.log(`[任务处理器] 职位评分完成,共 ${pendingJobs.length} 个职位,评分后 ${scoredJobs.length} 个符合条件,将投递 ${jobsToDeliver.length} 个`);
|
||||
|
||||
if (jobsToDeliver.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
deliveredCount: 0,
|
||||
message: '没有符合条件的职位'
|
||||
};
|
||||
}
|
||||
|
||||
// 6. 为每个职位创建一条独立的投递指令
|
||||
const deliverCommands = [];
|
||||
for (const jobData of jobsToDeliver) {
|
||||
console.log(`[任务处理器] 准备投递职位: ${jobData.jobTitle} @ ${jobData.companyName}, 评分: ${jobData.matchScore}`, jobData.scoreDetails);
|
||||
deliverCommands.push({
|
||||
command_type: 'applyJob',
|
||||
command_name: `投递简历 - ${jobData.jobTitle} @ ${jobData.companyName} (评分:${jobData.matchScore})`,
|
||||
command_params: JSON.stringify({
|
||||
sn_code: sn_code,
|
||||
platform: platform || 'boss',
|
||||
jobId: jobData.jobId,
|
||||
encryptBossId: jobData.encryptBossId || '',
|
||||
securityId: jobData.securityId || '',
|
||||
brandName: jobData.companyName,
|
||||
jobTitle: jobData.jobTitle,
|
||||
companyName: jobData.companyName,
|
||||
matchScore: jobData.matchScore,
|
||||
scoreDetails: jobData.scoreDetails
|
||||
}),
|
||||
priority: config.getTaskPriority('apply') || 6
|
||||
});
|
||||
}
|
||||
|
||||
// 7. 执行所有投递指令
|
||||
const result = await command.executeCommands(task.id, deliverCommands, this.mqttClient);
|
||||
const duration = Date.now() - startTime;
|
||||
deviceManager.recordTaskComplete(sn_code, task, true, duration);
|
||||
|
||||
console.log(`[任务处理器] 自动投递任务完成 - 设备: ${sn_code}, 创建了 ${deliverCommands.length} 条投递指令, 耗时: ${duration}ms`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
deviceManager.recordTaskComplete(sn_code, task, false, duration);
|
||||
console.error(`[任务处理器] 自动投递任务失败 - 设备: ${sn_code}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理自动沟通任务
|
||||
* @param {object} task - 任务对象
|
||||
* @returns {Promise<object>} 执行结果
|
||||
* 检查当前时间是否在指定的时间范围内
|
||||
* @param {Object} timeRange - 时间范围配置 {start_time: '09:00', end_time: '18:00', workdays_only: 1}
|
||||
* @returns {Object} {allowed: boolean, reason: string}
|
||||
*/
|
||||
checkTimeRange(timeRange) {
|
||||
if (!timeRange || !timeRange.start_time || !timeRange.end_time) {
|
||||
return { allowed: true, reason: '未配置时间范围' };
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const currentHour = now.getHours();
|
||||
const currentMinute = now.getMinutes();
|
||||
const currentTime = currentHour * 60 + currentMinute; // 转换为分钟数
|
||||
|
||||
// 解析开始时间和结束时间
|
||||
const [startHour, startMinute] = timeRange.start_time.split(':').map(Number);
|
||||
const [endHour, endMinute] = timeRange.end_time.split(':').map(Number);
|
||||
const startTime = startHour * 60 + startMinute;
|
||||
const endTime = endHour * 60 + endMinute;
|
||||
|
||||
// 检查是否仅工作日(使用宽松比较,兼容字符串和数字)
|
||||
if (timeRange.workdays_only == 1) { // 使用 == 而不是 ===
|
||||
const dayOfWeek = now.getDay(); // 0=周日, 1=周一, ..., 6=周六
|
||||
if (dayOfWeek === 0 || dayOfWeek === 6) {
|
||||
return { allowed: false, reason: '当前是周末,不在允许的时间范围内' };
|
||||
}
|
||||
}
|
||||
|
||||
// 检查当前时间是否在时间范围内
|
||||
if (startTime <= endTime) {
|
||||
// 正常情况:09:00 - 18:00
|
||||
if (currentTime < startTime || currentTime >= endTime) {
|
||||
return { allowed: false, reason: `当前时间 ${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')} 不在允许的时间范围内 (${timeRange.start_time} - ${timeRange.end_time})` };
|
||||
}
|
||||
} else {
|
||||
// 跨天情况:22:00 - 06:00
|
||||
if (currentTime < startTime && currentTime >= endTime) {
|
||||
return { allowed: false, reason: `当前时间 ${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')} 不在允许的时间范围内 (${timeRange.start_time} - ${timeRange.end_time})` };
|
||||
}
|
||||
}
|
||||
|
||||
return { allowed: true, reason: '在允许的时间范围内' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理自动沟通任务(待实现)
|
||||
* 功能:自动与HR进行沟通,回复消息等
|
||||
*/
|
||||
async handleAutoChatTask(task) {
|
||||
console.log(`[任务处理器] 调度自动沟通任务 - 设备: ${task.sn_code}`);
|
||||
return await this.chatHandler.handle(task);
|
||||
const { sn_code, taskParams } = task;
|
||||
console.log(`[任务处理器] 自动沟通任务 - 设备: ${sn_code}`);
|
||||
|
||||
// 检查授权状态
|
||||
const authorizationService = require('../../services/authorization_service');
|
||||
const authCheck = await authorizationService.checkAuthorization(sn_code, 'sn_code');
|
||||
if (!authCheck.is_authorized) {
|
||||
console.log(`[任务处理器] 自动沟通任务 - 设备: ${sn_code} 授权检查失败: ${authCheck.message}`);
|
||||
return {
|
||||
success: false,
|
||||
chatCount: 0,
|
||||
message: authCheck.message
|
||||
};
|
||||
}
|
||||
|
||||
deviceManager.recordTaskStart(sn_code, task);
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// 获取账号配置
|
||||
const pla_account = db.getModel('pla_account');
|
||||
const account = await pla_account.findOne({
|
||||
where: { sn_code: sn_code }
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
throw new Error(`账号不存在: ${sn_code}`);
|
||||
}
|
||||
|
||||
const accountData = account.toJSON();
|
||||
|
||||
// 检查是否开启自动沟通
|
||||
if (!accountData.auto_chat) {
|
||||
console.log(`[任务处理器] 设备 ${sn_code} 未开启自动沟通`);
|
||||
return {
|
||||
success: true,
|
||||
message: '未开启自动沟通',
|
||||
chatCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
// 解析沟通策略配置
|
||||
let chatStrategy = {};
|
||||
if (accountData.chat_strategy) {
|
||||
chatStrategy = typeof accountData.chat_strategy === 'string'
|
||||
? JSON.parse(accountData.chat_strategy)
|
||||
: accountData.chat_strategy;
|
||||
}
|
||||
|
||||
// 检查沟通时间范围
|
||||
if (chatStrategy.time_range) {
|
||||
const timeCheck = this.checkTimeRange(chatStrategy.time_range);
|
||||
if (!timeCheck.allowed) {
|
||||
console.log(`[任务处理器] 自动沟通任务 - ${timeCheck.reason}`);
|
||||
return {
|
||||
success: true,
|
||||
message: timeCheck.reason,
|
||||
chatCount: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: 实现自动沟通逻辑
|
||||
// 1. 获取待回复的聊天列表
|
||||
// 2. 根据消息内容生成回复
|
||||
// 3. 发送回复消息
|
||||
// 4. 记录沟通结果
|
||||
|
||||
console.log(`[任务处理器] 自动沟通任务 - 逻辑待实现`);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
deviceManager.recordTaskComplete(sn_code, task, true, duration);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '自动沟通任务框架已就绪,逻辑待实现',
|
||||
chatCount: 0
|
||||
};
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
deviceManager.recordTaskComplete(sn_code, task, false, duration);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理自动活跃账户任务
|
||||
* @param {object} task - 任务对象
|
||||
* @returns {Promise<object>} 执行结果
|
||||
* 处理自动活跃账号任务(待实现)
|
||||
* 功能:自动执行一些操作来保持账号活跃度,如浏览职位、搜索等
|
||||
*/
|
||||
async handleAutoActiveAccountTask(task) {
|
||||
console.log(`[任务处理器] 调度自动活跃任务 - 设备: ${task.sn_code}`);
|
||||
return await this.activeHandler.handle(task);
|
||||
const { sn_code, taskParams } = task;
|
||||
console.log(`[任务处理器] 自动活跃账号任务 - 设备: ${sn_code}`);
|
||||
|
||||
deviceManager.recordTaskStart(sn_code, task);
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// TODO: 实现自动活跃账号逻辑
|
||||
// 1. 随机搜索一些职位
|
||||
// 2. 浏览职位详情
|
||||
// 3. 查看公司信息
|
||||
// 4. 执行一些模拟用户行为
|
||||
|
||||
console.log(`[任务处理器] 自动活跃账号任务 - 逻辑待实现`);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
deviceManager.recordTaskComplete(sn_code, task, true, duration);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '自动活跃账号任务框架已就绪,逻辑待实现',
|
||||
actionCount: 0
|
||||
};
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
deviceManager.recordTaskComplete(sn_code, task, false, duration);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析职位薪资范围
|
||||
* @param {string} salaryDesc - 薪资描述(如 "20-30K"、"30-40K·18薪"、"5000-6000元/月")
|
||||
* @returns {object} 薪资范围 { min, max },单位:元
|
||||
*/
|
||||
parseSalaryRange(salaryDesc) {
|
||||
if (!salaryDesc) return { min: 0, max: 0 };
|
||||
|
||||
// 1. 匹配K格式:40-60K, 30-40K·18薪(忽略后面的薪数)
|
||||
const kMatch = salaryDesc.match(/(\d+)[-~](\d+)[kK千]/);
|
||||
if (kMatch) {
|
||||
return {
|
||||
min: parseInt(kMatch[1]) * 1000,
|
||||
max: parseInt(kMatch[2]) * 1000
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 匹配单个K值:25K
|
||||
const singleKMatch = salaryDesc.match(/(\d+)[kK千]/);
|
||||
if (singleKMatch) {
|
||||
const value = parseInt(singleKMatch[1]) * 1000;
|
||||
return { min: value, max: value };
|
||||
}
|
||||
|
||||
// 3. 匹配元/月格式:5000-6000元/月
|
||||
const yuanMatch = salaryDesc.match(/(\d+)[-~](\d+)[元万]/);
|
||||
if (yuanMatch) {
|
||||
const min = parseInt(yuanMatch[1]);
|
||||
const max = parseInt(yuanMatch[2]);
|
||||
// 判断单位(万或元)
|
||||
if (salaryDesc.includes('万')) {
|
||||
return {
|
||||
min: min * 10000,
|
||||
max: max * 10000
|
||||
};
|
||||
} else {
|
||||
return { min, max };
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 匹配单个元/月值:5000元/月
|
||||
const singleYuanMatch = salaryDesc.match(/(\d+)[元万]/);
|
||||
if (singleYuanMatch) {
|
||||
const value = parseInt(singleYuanMatch[1]);
|
||||
if (salaryDesc.includes('万')) {
|
||||
return { min: value * 10000, max: value * 10000 };
|
||||
} else {
|
||||
return { min: value, max: value };
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 匹配纯数字格式(如:20000-30000)
|
||||
const numMatch = salaryDesc.match(/(\d+)[-~](\d+)/);
|
||||
if (numMatch) {
|
||||
return {
|
||||
min: parseInt(numMatch[1]),
|
||||
max: parseInt(numMatch[2])
|
||||
};
|
||||
}
|
||||
|
||||
return { min: 0, max: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析期望薪资范围
|
||||
* @param {string} expectedSalary - 期望薪资描述(如 "20-30K"、"5000-6000元/月")
|
||||
* @returns {object|null} 期望薪资范围 { min, max },单位:元
|
||||
*/
|
||||
parseExpectedSalary(expectedSalary) {
|
||||
if (!expectedSalary) return null;
|
||||
|
||||
// 1. 匹配K格式:20-30K
|
||||
const kMatch = expectedSalary.match(/(\d+)[-~](\d+)[kK千]/);
|
||||
if (kMatch) {
|
||||
return {
|
||||
min: parseInt(kMatch[1]) * 1000,
|
||||
max: parseInt(kMatch[2]) * 1000
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 匹配单个K值:25K
|
||||
const singleKMatch = expectedSalary.match(/(\d+)[kK千]/);
|
||||
if (singleKMatch) {
|
||||
const value = parseInt(singleKMatch[1]) * 1000;
|
||||
return { min: value, max: value };
|
||||
}
|
||||
|
||||
// 3. 匹配元/月格式:5000-6000元/月
|
||||
const yuanMatch = expectedSalary.match(/(\d+)[-~](\d+)[元万]/);
|
||||
if (yuanMatch) {
|
||||
const min = parseInt(yuanMatch[1]);
|
||||
const max = parseInt(yuanMatch[2]);
|
||||
// 判断单位(万或元)
|
||||
if (expectedSalary.includes('万')) {
|
||||
return {
|
||||
min: min * 10000,
|
||||
max: max * 10000
|
||||
};
|
||||
} else {
|
||||
return { min, max };
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 匹配单个元/月值:5000元/月
|
||||
const singleYuanMatch = expectedSalary.match(/(\d+)[元万]/);
|
||||
if (singleYuanMatch) {
|
||||
const value = parseInt(singleYuanMatch[1]);
|
||||
if (expectedSalary.includes('万')) {
|
||||
return { min: value * 10000, max: value * 10000 };
|
||||
} else {
|
||||
return { min: value, max: value };
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 匹配纯数字格式(如:20000-30000)
|
||||
const numMatch = expectedSalary.match(/(\d+)[-~](\d+)/);
|
||||
if (numMatch) {
|
||||
return {
|
||||
min: parseInt(numMatch[1]),
|
||||
max: parseInt(numMatch[2])
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskHandlers;
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const Sequelize = require('sequelize');
|
||||
const logs = require('../../logProxy');
|
||||
const db = require('../../dbProxy');
|
||||
const logs = require('../logProxy');
|
||||
const db = require('../dbProxy');
|
||||
const command = require('./command');
|
||||
const PriorityQueue = require('../infrastructure/PriorityQueue');
|
||||
const ErrorHandler = require('../infrastructure/ErrorHandler');
|
||||
const PriorityQueue = require('./PriorityQueue');
|
||||
const ErrorHandler = require('./ErrorHandler');
|
||||
const deviceManager = require('./deviceManager');
|
||||
const ScheduleUtils = require('../utils/scheduleUtils');
|
||||
const ScheduleConfig = require('../infrastructure/config');
|
||||
const deviceWorkStatusNotifier = require('../notifiers/deviceWorkStatusNotifier');
|
||||
const ScheduleUtils = require('./utils');
|
||||
const ScheduleConfig = require('./config');
|
||||
const deviceWorkStatusNotifier = require('./deviceWorkStatusNotifier');
|
||||
|
||||
/**
|
||||
* 任务队列管理器(重构版)
|
||||
@@ -222,6 +222,7 @@ class TaskQueue {
|
||||
|
||||
// 移除 device_status 依赖,不再检查设备在线状态
|
||||
// 如果需要在线状态检查,可以从 deviceManager 获取
|
||||
const deviceManager = require('./deviceManager');
|
||||
const deviceStatus = deviceManager.getAllDevicesStatus();
|
||||
const onlineSnCodes = new Set(
|
||||
Object.entries(deviceStatus)
|
||||
@@ -229,7 +230,24 @@ class TaskQueue {
|
||||
.map(([sn_code]) => sn_code)
|
||||
);
|
||||
|
||||
|
||||
// 原有代码已移除,改为使用 deviceManager
|
||||
/* 原有代码已注释
|
||||
const device_status = db.getModel('device_status');
|
||||
const heartbeatTimeout = require('./config.js').monitoring.heartbeatTimeout;
|
||||
const now = new Date();
|
||||
const heartbeatThreshold = new Date(now.getTime() - heartbeatTimeout);
|
||||
|
||||
const onlineDevices = await device_status.findAll({
|
||||
where: {
|
||||
isOnline: true,
|
||||
lastHeartbeatTime: {
|
||||
[Sequelize.Op.gte]: heartbeatThreshold // 心跳时间在阈值内
|
||||
}
|
||||
},
|
||||
attributes: ['sn_code']
|
||||
});
|
||||
const onlineSnCodes = new Set(onlineDevices.map(dev => dev.sn_code));
|
||||
*/
|
||||
|
||||
let processedCount = 0;
|
||||
let queuedCount = 0;
|
||||
@@ -1047,13 +1065,13 @@ class TaskQueue {
|
||||
async getMqttClient() {
|
||||
try {
|
||||
// 首先尝试从调度系统获取已初始化的MQTT客户端
|
||||
const scheduleManager = require('../index');
|
||||
const scheduleManager = require('./index');
|
||||
if (scheduleManager.mqttClient) {
|
||||
return scheduleManager.mqttClient;
|
||||
}
|
||||
|
||||
// 如果调度系统没有初始化,则直接创建
|
||||
const mqttManager = require('../../mqtt/mqttManager');
|
||||
const mqttManager = require('../mqtt/mqttManager');
|
||||
console.log('[任务队列] 创建新的MQTT客户端');
|
||||
return await mqttManager.getInstance();
|
||||
} catch (error) {
|
||||
@@ -1,182 +0,0 @@
|
||||
const BaseTask = require('./baseTask');
|
||||
const db = require('../../dbProxy');
|
||||
const config = require('../infrastructure/config');
|
||||
|
||||
/**
|
||||
* 自动活跃账号任务
|
||||
* 定期浏览职位、刷新简历、查看通知等,保持账号活跃度
|
||||
*/
|
||||
class AutoActiveTask extends BaseTask {
|
||||
constructor() {
|
||||
super('auto_active_account', {
|
||||
defaultInterval: 120, // 默认2小时
|
||||
defaultPriority: 5, // 较低优先级
|
||||
requiresLogin: true, // 需要登录
|
||||
conflictsWith: [ // 与这些任务冲突
|
||||
'auto_deliver', // 投递任务
|
||||
'auto_search' // 搜索任务
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证任务参数
|
||||
*/
|
||||
validateParams(params) {
|
||||
if (!params.platform) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: '缺少必要参数: platform'
|
||||
};
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务名称
|
||||
*/
|
||||
getTaskName(params) {
|
||||
return `自动活跃账号 - ${params.platform || 'boss'}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行自动活跃任务
|
||||
*/
|
||||
async execute(sn_code, params) {
|
||||
console.log(`[自动活跃] 设备 ${sn_code} 开始执行活跃任务`);
|
||||
|
||||
const actions = [];
|
||||
|
||||
// 1. 浏览推荐职位
|
||||
actions.push({
|
||||
action: 'browse_jobs',
|
||||
count: Math.floor(Math.random() * 5) + 3 // 3-7个职位
|
||||
});
|
||||
|
||||
// 2. 刷新简历
|
||||
actions.push({
|
||||
action: 'refresh_resume',
|
||||
success: true
|
||||
});
|
||||
|
||||
// 3. 查看通知
|
||||
actions.push({
|
||||
action: 'check_notifications',
|
||||
count: Math.floor(Math.random() * 3)
|
||||
});
|
||||
|
||||
// 4. 浏览公司主页
|
||||
actions.push({
|
||||
action: 'browse_companies',
|
||||
count: Math.floor(Math.random() * 3) + 1
|
||||
});
|
||||
|
||||
console.log(`[自动活跃] 设备 ${sn_code} 完成 ${actions.length} 个活跃操作`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
actions: actions,
|
||||
message: `完成 ${actions.length} 个活跃操作`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加活跃任务到队列
|
||||
*/
|
||||
async addToQueue(sn_code, taskQueue, customParams = {}) {
|
||||
const now = new Date();
|
||||
console.log(`[自动活跃] ${now.toLocaleString()} 尝试为设备 ${sn_code} 添加任务`);
|
||||
|
||||
try {
|
||||
// 1. 获取账号信息
|
||||
const { pla_account } = db.models;
|
||||
const account = await pla_account.findOne({
|
||||
where: {
|
||||
sn_code: sn_code,
|
||||
is_delete: 0,
|
||||
is_enabled: 1
|
||||
}
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
console.log(`[自动活跃] 账号 ${sn_code} 不存在或未启用`);
|
||||
return { success: false, reason: '账号不存在或未启用' };
|
||||
}
|
||||
|
||||
const accountData = account.toJSON();
|
||||
|
||||
// 2. 检查是否开启了自动活跃
|
||||
if (!accountData.auto_active) {
|
||||
console.log(`[自动活跃] 设备 ${sn_code} 未开启自动活跃`);
|
||||
return { success: false, reason: '未开启自动活跃' };
|
||||
}
|
||||
|
||||
// 3. 获取活跃策略配置
|
||||
let activeStrategy = {};
|
||||
if (accountData.active_strategy) {
|
||||
activeStrategy = typeof accountData.active_strategy === 'string'
|
||||
? JSON.parse(accountData.active_strategy)
|
||||
: accountData.active_strategy;
|
||||
}
|
||||
|
||||
// 4. 检查时间范围
|
||||
if (activeStrategy.time_range) {
|
||||
const timeCheck = this.checkTimeRange(activeStrategy.time_range);
|
||||
if (!timeCheck.allowed) {
|
||||
console.log(`[自动活跃] 设备 ${sn_code} ${timeCheck.reason}`);
|
||||
return { success: false, reason: timeCheck.reason };
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 执行所有层级的冲突检查
|
||||
const conflictCheck = await this.canExecuteTask(sn_code, taskQueue);
|
||||
if (!conflictCheck.allowed) {
|
||||
return { success: false, reason: conflictCheck.reason };
|
||||
}
|
||||
|
||||
// 6. 检查活跃间隔
|
||||
const active_interval = activeStrategy.active_interval || this.config.defaultInterval;
|
||||
const intervalCheck = await this.checkExecutionInterval(sn_code, active_interval);
|
||||
|
||||
if (!intervalCheck.allowed) {
|
||||
console.log(`[自动活跃] 设备 ${sn_code} ${intervalCheck.reason}`);
|
||||
return { success: false, reason: intervalCheck.reason };
|
||||
}
|
||||
|
||||
// 7. 构建任务参数
|
||||
const taskParams = {
|
||||
platform: accountData.platform_type || 'boss',
|
||||
actions: activeStrategy.actions || ['browse_jobs', 'refresh_resume', 'check_notifications'],
|
||||
...customParams
|
||||
};
|
||||
|
||||
// 8. 验证参数
|
||||
const validation = this.validateParams(taskParams);
|
||||
if (!validation.valid) {
|
||||
return { success: false, reason: validation.reason };
|
||||
}
|
||||
|
||||
// 9. 添加任务到队列
|
||||
await taskQueue.addTask(sn_code, {
|
||||
taskType: this.taskType,
|
||||
taskName: this.getTaskName(taskParams),
|
||||
taskParams: taskParams,
|
||||
priority: this.config.defaultPriority
|
||||
});
|
||||
|
||||
console.log(`[自动活跃] 已为设备 ${sn_code} 添加活跃任务,间隔: ${active_interval} 分钟`);
|
||||
|
||||
return { success: true };
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[自动活跃] 添加任务失败:`, error);
|
||||
return { success: false, reason: error.message };
|
||||
} finally {
|
||||
// 统一释放任务锁
|
||||
this.releaseTaskLock(sn_code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
module.exports = new AutoActiveTask();
|
||||
@@ -1,181 +0,0 @@
|
||||
const BaseTask = require('./baseTask');
|
||||
const db = require('../../dbProxy');
|
||||
const config = require('../infrastructure/config');
|
||||
|
||||
/**
|
||||
* 自动沟通任务
|
||||
* 自动回复HR消息,保持活跃度
|
||||
*/
|
||||
class AutoChatTask extends BaseTask {
|
||||
constructor() {
|
||||
super('auto_chat', {
|
||||
defaultInterval: 15, // 默认15分钟
|
||||
defaultPriority: 6, // 中等优先级
|
||||
requiresLogin: true, // 需要登录
|
||||
conflictsWith: [] // 不与其他任务冲突(可以在投递/搜索间隙执行)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证任务参数
|
||||
*/
|
||||
validateParams(params) {
|
||||
if (!params.platform) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: '缺少必要参数: platform'
|
||||
};
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务名称
|
||||
*/
|
||||
getTaskName(params) {
|
||||
return `自动沟通 - ${params.name || '默认'}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行自动沟通任务
|
||||
*/
|
||||
async execute(sn_code, params) {
|
||||
console.log(`[自动沟通] 设备 ${sn_code} 开始执行沟通任务`);
|
||||
|
||||
// 1. 获取未读消息列表
|
||||
const unreadMessages = await this.getUnreadMessages(sn_code, params.platform);
|
||||
|
||||
if (!unreadMessages || unreadMessages.length === 0) {
|
||||
console.log(`[自动沟通] 设备 ${sn_code} 没有未读消息`);
|
||||
return {
|
||||
success: true,
|
||||
repliedCount: 0,
|
||||
message: '没有未读消息'
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`[自动沟通] 设备 ${sn_code} 找到 ${unreadMessages.length} 条未读消息`);
|
||||
|
||||
// 2. 智能回复(这里需要调用实际的AI回复逻辑)
|
||||
const replyResult = {
|
||||
success: true,
|
||||
repliedCount: unreadMessages.length,
|
||||
messages: unreadMessages.map(m => ({
|
||||
id: m.id,
|
||||
from: m.hr_name,
|
||||
company: m.company_name
|
||||
}))
|
||||
};
|
||||
|
||||
return replyResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取未读消息
|
||||
*/
|
||||
async getUnreadMessages(sn_code, platform) {
|
||||
// TODO: 从数据库或缓存获取未读消息
|
||||
// 这里返回空数组作为示例
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加沟通任务到队列
|
||||
*/
|
||||
async addToQueue(sn_code, taskQueue, customParams = {}) {
|
||||
const now = new Date();
|
||||
console.log(`[自动沟通] ${now.toLocaleString()} 尝试为设备 ${sn_code} 添加任务`);
|
||||
|
||||
try {
|
||||
// 1. 获取账号信息
|
||||
const { pla_account } = db.models;
|
||||
const account = await pla_account.findOne({
|
||||
where: {
|
||||
sn_code: sn_code,
|
||||
is_delete: 0,
|
||||
is_enabled: 1
|
||||
}
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
console.log(`[自动沟通] 账号 ${sn_code} 不存在或未启用`);
|
||||
return { success: false, reason: '账号不存在或未启用' };
|
||||
}
|
||||
|
||||
const accountData = account.toJSON();
|
||||
|
||||
// 2. 检查是否开启了自动沟通
|
||||
if (!accountData.auto_chat) {
|
||||
console.log(`[自动沟通] 设备 ${sn_code} 未开启自动沟通`);
|
||||
return { success: false, reason: '未开启自动沟通' };
|
||||
}
|
||||
|
||||
// 3. 获取沟通策略配置
|
||||
let chatStrategy = {};
|
||||
if (accountData.chat_strategy) {
|
||||
chatStrategy = typeof accountData.chat_strategy === 'string'
|
||||
? JSON.parse(accountData.chat_strategy)
|
||||
: accountData.chat_strategy;
|
||||
}
|
||||
|
||||
// 4. 检查时间范围
|
||||
if (chatStrategy.time_range) {
|
||||
const timeCheck = this.checkTimeRange(chatStrategy.time_range);
|
||||
if (!timeCheck.allowed) {
|
||||
console.log(`[自动沟通] 设备 ${sn_code} ${timeCheck.reason}`);
|
||||
return { success: false, reason: timeCheck.reason };
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 执行所有层级的冲突检查
|
||||
const conflictCheck = await this.canExecuteTask(sn_code, taskQueue);
|
||||
if (!conflictCheck.allowed) {
|
||||
return { success: false, reason: conflictCheck.reason };
|
||||
}
|
||||
|
||||
// 6. 检查沟通间隔
|
||||
const chat_interval = chatStrategy.chat_interval || this.config.defaultInterval;
|
||||
const intervalCheck = await this.checkExecutionInterval(sn_code, chat_interval);
|
||||
|
||||
if (!intervalCheck.allowed) {
|
||||
console.log(`[自动沟通] 设备 ${sn_code} ${intervalCheck.reason}`);
|
||||
return { success: false, reason: intervalCheck.reason };
|
||||
}
|
||||
|
||||
// 7. 构建任务参数
|
||||
const taskParams = {
|
||||
platform: accountData.platform_type || 'boss',
|
||||
name: accountData.name || '默认',
|
||||
...customParams
|
||||
};
|
||||
|
||||
// 8. 验证参数
|
||||
const validation = this.validateParams(taskParams);
|
||||
if (!validation.valid) {
|
||||
return { success: false, reason: validation.reason };
|
||||
}
|
||||
|
||||
// 9. 添加任务到队列
|
||||
await taskQueue.addTask(sn_code, {
|
||||
taskType: this.taskType,
|
||||
taskName: this.getTaskName(taskParams),
|
||||
taskParams: taskParams,
|
||||
priority: this.config.defaultPriority
|
||||
});
|
||||
|
||||
console.log(`[自动沟通] 已为设备 ${sn_code} 添加沟通任务,间隔: ${chat_interval} 分钟`);
|
||||
|
||||
return { success: true };
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[自动沟通] 添加任务失败:`, error);
|
||||
return { success: false, reason: error.message };
|
||||
} finally {
|
||||
// 统一释放任务锁
|
||||
this.releaseTaskLock(sn_code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
module.exports = new AutoChatTask();
|
||||
@@ -1,320 +0,0 @@
|
||||
const BaseTask = require('./baseTask');
|
||||
const db = require('../../dbProxy');
|
||||
const config = require('../infrastructure/config');
|
||||
const authorizationService = require('../../../services/authorization_service');
|
||||
|
||||
/**
|
||||
* 自动投递任务
|
||||
* 从数据库读取职位列表并进行自动投递
|
||||
*/
|
||||
class AutoDeliverTask extends BaseTask {
|
||||
constructor() {
|
||||
super('auto_deliver', {
|
||||
defaultInterval: 30, // 默认30分钟
|
||||
defaultPriority: 7, // 高优先级
|
||||
requiresLogin: true, // 需要登录
|
||||
conflictsWith: [ // 与这些任务冲突
|
||||
'auto_search', // 搜索任务
|
||||
'auto_active_account' // 活跃账号任务
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证任务参数
|
||||
*/
|
||||
validateParams(params) {
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务名称
|
||||
*/
|
||||
getTaskName(params) {
|
||||
return `自动投递 - ${params.keyword || '指定职位'}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行自动投递任务
|
||||
*/
|
||||
async execute(sn_code, params) {
|
||||
console.log(`[自动投递] 设备 ${sn_code} 开始执行投递任务`);
|
||||
|
||||
// 1. 获取账号信息
|
||||
const account = await this.getAccountInfo(sn_code);
|
||||
if (!account) {
|
||||
throw new Error(`账号 ${sn_code} 不存在`);
|
||||
}
|
||||
|
||||
// 2. 检查授权
|
||||
const authorization = await authorizationService.checkAuthorization(sn_code);
|
||||
if (!authorization.is_authorized) {
|
||||
throw new Error('授权天数不足');
|
||||
}
|
||||
|
||||
// 3. 获取投递配置
|
||||
const deliverConfig = this.parseDeliverConfig(account.deliver_config);
|
||||
|
||||
// 4. 检查日投递限制
|
||||
const dailyLimit = config.platformDailyLimits[account.platform_type] || 50;
|
||||
const todayDelivered = await this.getTodayDeliveredCount(sn_code);
|
||||
|
||||
if (todayDelivered >= dailyLimit) {
|
||||
throw new Error(`今日投递已达上限 (${todayDelivered}/${dailyLimit})`);
|
||||
}
|
||||
|
||||
// 5. 获取可投递的职位列表
|
||||
const jobs = await this.getDeliverableJobs(sn_code, account, deliverConfig);
|
||||
|
||||
if (!jobs || jobs.length === 0) {
|
||||
console.log(`[自动投递] 设备 ${sn_code} 没有可投递的职位`);
|
||||
return {
|
||||
success: true,
|
||||
delivered: 0,
|
||||
message: '没有可投递的职位'
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`[自动投递] 设备 ${sn_code} 找到 ${jobs.length} 个可投递职位`);
|
||||
|
||||
// 6. 执行投递(这里需要调用实际的投递逻辑)
|
||||
const deliverResult = {
|
||||
success: true,
|
||||
delivered: jobs.length,
|
||||
jobs: jobs.map(j => ({
|
||||
id: j.id,
|
||||
title: j.job_title,
|
||||
company: j.company_name
|
||||
}))
|
||||
};
|
||||
|
||||
return deliverResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账号信息
|
||||
*/
|
||||
async getAccountInfo(sn_code) {
|
||||
const { pla_account } = db.models;
|
||||
const account = await pla_account.findOne({
|
||||
where: {
|
||||
sn_code: sn_code,
|
||||
is_delete: 0,
|
||||
is_enabled: 1
|
||||
}
|
||||
});
|
||||
|
||||
return account ? account.toJSON() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析投递配置
|
||||
*/
|
||||
parseDeliverConfig(deliver_config) {
|
||||
if (typeof deliver_config === 'string') {
|
||||
try {
|
||||
deliver_config = JSON.parse(deliver_config);
|
||||
} catch (e) {
|
||||
deliver_config = {};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
deliver_interval: deliver_config?.deliver_interval || 30,
|
||||
min_salary: deliver_config?.min_salary || 0,
|
||||
max_salary: deliver_config?.max_salary || 0,
|
||||
page_count: deliver_config?.page_count || 3,
|
||||
max_deliver: deliver_config?.max_deliver || 10,
|
||||
filter_keywords: deliver_config?.filter_keywords || [],
|
||||
exclude_keywords: deliver_config?.exclude_keywords || [],
|
||||
time_range: deliver_config?.time_range || null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取今日已投递数量
|
||||
*/
|
||||
async getTodayDeliveredCount(sn_code) {
|
||||
const { task_status } = db.models;
|
||||
const Sequelize = require('sequelize');
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const count = await task_status.count({
|
||||
where: {
|
||||
sn_code: sn_code,
|
||||
taskType: 'auto_deliver',
|
||||
status: 'completed',
|
||||
endTime: {
|
||||
[Sequelize.Op.gte]: today
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可投递的职位列表
|
||||
*/
|
||||
async getDeliverableJobs(sn_code, account, deliverConfig) {
|
||||
const { job_postings } = db.models;
|
||||
const Sequelize = require('sequelize');
|
||||
|
||||
// 构建查询条件
|
||||
const where = {
|
||||
sn_code: sn_code,
|
||||
platform: account.platform_type,
|
||||
is_delivered: 0, // 未投递
|
||||
is_filtered: 0 // 未被过滤
|
||||
};
|
||||
|
||||
// 薪资范围过滤
|
||||
if (deliverConfig.min_salary > 0) {
|
||||
where.salary_min = {
|
||||
[Sequelize.Op.gte]: deliverConfig.min_salary
|
||||
};
|
||||
}
|
||||
|
||||
if (deliverConfig.max_salary > 0) {
|
||||
where.salary_max = {
|
||||
[Sequelize.Op.lte]: deliverConfig.max_salary
|
||||
};
|
||||
}
|
||||
|
||||
// 查询职位
|
||||
const jobs = await job_postings.findAll({
|
||||
where: where,
|
||||
limit: deliverConfig.max_deliver,
|
||||
order: [['create_time', 'DESC']]
|
||||
});
|
||||
|
||||
return jobs.map(j => j.toJSON());
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加投递任务到队列
|
||||
* 这是外部调用的入口,会进行所有冲突检查
|
||||
*/
|
||||
async addToQueue(sn_code, taskQueue, customParams = {}) {
|
||||
const now = new Date();
|
||||
console.log(`[自动投递] ${now.toLocaleString()} 尝试为设备 ${sn_code} 添加任务`);
|
||||
|
||||
try {
|
||||
// 1. 获取账号信息
|
||||
const account = await this.getAccountInfo(sn_code);
|
||||
if (!account) {
|
||||
console.log(`[自动投递] 账号 ${sn_code} 不存在或未启用`);
|
||||
return { success: false, reason: '账号不存在或未启用' };
|
||||
}
|
||||
|
||||
// 2. 检查是否开启了自动投递
|
||||
if (!account.auto_deliver) {
|
||||
console.log(`[自动投递] 设备 ${sn_code} 未开启自动投递`);
|
||||
return { success: false, reason: '未开启自动投递' };
|
||||
}
|
||||
|
||||
// 3. 获取投递配置
|
||||
const deliverConfig = this.parseDeliverConfig(account.deliver_config);
|
||||
|
||||
// 4. 检查时间范围
|
||||
if (deliverConfig.time_range) {
|
||||
const timeCheck = this.checkTimeRange(deliverConfig.time_range);
|
||||
if (!timeCheck.allowed) {
|
||||
console.log(`[自动投递] 设备 ${sn_code} ${timeCheck.reason}`);
|
||||
return { success: false, reason: timeCheck.reason };
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 执行所有层级的冲突检查
|
||||
const conflictCheck = await this.canExecuteTask(sn_code, taskQueue);
|
||||
if (!conflictCheck.allowed) {
|
||||
return { success: false, reason: conflictCheck.reason };
|
||||
}
|
||||
|
||||
// 6. 检查投递间隔
|
||||
const intervalCheck = await this.checkExecutionInterval(
|
||||
sn_code,
|
||||
deliverConfig.deliver_interval
|
||||
);
|
||||
|
||||
if (!intervalCheck.allowed) {
|
||||
console.log(`[自动投递] 设备 ${sn_code} ${intervalCheck.reason}`);
|
||||
|
||||
// 推送等待状态到客户端
|
||||
await this.notifyWaitingStatus(sn_code, intervalCheck, taskQueue);
|
||||
|
||||
return { success: false, reason: intervalCheck.reason };
|
||||
}
|
||||
|
||||
// 7. 构建任务参数
|
||||
const taskParams = {
|
||||
keyword: account.keyword || '',
|
||||
platform: account.platform_type || 'boss',
|
||||
pageCount: deliverConfig.page_count,
|
||||
maxCount: deliverConfig.max_deliver,
|
||||
filterRules: {
|
||||
minSalary: deliverConfig.min_salary,
|
||||
maxSalary: deliverConfig.max_salary,
|
||||
keywords: deliverConfig.filter_keywords,
|
||||
excludeKeywords: deliverConfig.exclude_keywords
|
||||
},
|
||||
...customParams
|
||||
};
|
||||
|
||||
// 8. 验证参数
|
||||
const validation = this.validateParams(taskParams);
|
||||
if (!validation.valid) {
|
||||
return { success: false, reason: validation.reason };
|
||||
}
|
||||
|
||||
// 9. 添加任务到队列
|
||||
await taskQueue.addTask(sn_code, {
|
||||
taskType: this.taskType,
|
||||
taskName: this.getTaskName(taskParams),
|
||||
taskParams: taskParams,
|
||||
priority: this.config.defaultPriority
|
||||
});
|
||||
|
||||
console.log(`[自动投递] 已为设备 ${sn_code} 添加投递任务,间隔: ${deliverConfig.deliver_interval} 分钟`);
|
||||
|
||||
return { success: true };
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[自动投递] 添加任务失败:`, error);
|
||||
return { success: false, reason: error.message };
|
||||
} finally {
|
||||
// 统一释放任务锁
|
||||
this.releaseTaskLock(sn_code);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送等待状态到客户端
|
||||
*/
|
||||
async notifyWaitingStatus(sn_code, intervalCheck, taskQueue) {
|
||||
try {
|
||||
const deviceWorkStatusNotifier = require('../notifiers/deviceWorkStatusNotifier');
|
||||
|
||||
// 获取当前任务状态摘要
|
||||
const taskStatusSummary = taskQueue.getTaskStatusSummary(sn_code);
|
||||
|
||||
// 添加等待消息到工作状态
|
||||
await deviceWorkStatusNotifier.sendDeviceWorkStatus(sn_code, taskStatusSummary, {
|
||||
waitingMessage: {
|
||||
type: 'deliver_interval',
|
||||
message: intervalCheck.reason,
|
||||
remainingMinutes: intervalCheck.remainingMinutes,
|
||||
nextDeliverTime: intervalCheck.nextExecutionTime?.toISOString()
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`[自动投递] 推送等待消息失败:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
module.exports = new AutoDeliverTask();
|
||||
@@ -1,233 +0,0 @@
|
||||
const BaseTask = require('./baseTask');
|
||||
const db = require('../../dbProxy');
|
||||
const config = require('../infrastructure/config');
|
||||
|
||||
/**
|
||||
* 自动搜索职位任务
|
||||
* 定期搜索符合条件的职位并保存到数据库
|
||||
*/
|
||||
class AutoSearchTask extends BaseTask {
|
||||
constructor() {
|
||||
super('auto_search', {
|
||||
defaultInterval: 60, // 默认60分钟
|
||||
defaultPriority: 8, // 高优先级(比投递高,先搜索后投递)
|
||||
requiresLogin: true, // 需要登录
|
||||
conflictsWith: [ // 与这些任务冲突
|
||||
'auto_deliver', // 投递任务
|
||||
'auto_active_account' // 活跃账号任务
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证任务参数
|
||||
*/
|
||||
validateParams(params) {
|
||||
if (!params.keyword && !params.jobType) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: '缺少必要参数: keyword 或 jobType'
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务名称
|
||||
*/
|
||||
getTaskName(params) {
|
||||
return `自动搜索 - ${params.keyword || params.jobType || '默认'}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行自动搜索任务
|
||||
*/
|
||||
async execute(sn_code, params) {
|
||||
console.log(`[自动搜索] 设备 ${sn_code} 开始执行搜索任务`);
|
||||
|
||||
// 1. 获取账号信息
|
||||
const account = await this.getAccountInfo(sn_code);
|
||||
if (!account) {
|
||||
throw new Error(`账号 ${sn_code} 不存在`);
|
||||
}
|
||||
|
||||
// 2. 获取搜索配置
|
||||
const searchConfig = this.parseSearchConfig(account.search_config);
|
||||
|
||||
// 3. 检查日搜索限制
|
||||
const dailyLimit = config.dailyLimits.maxSearch || 20;
|
||||
const todaySearched = await this.getTodaySearchCount(sn_code);
|
||||
|
||||
if (todaySearched >= dailyLimit) {
|
||||
throw new Error(`今日搜索已达上限 (${todaySearched}/${dailyLimit})`);
|
||||
}
|
||||
|
||||
// 4. 执行搜索(这里需要调用实际的搜索逻辑)
|
||||
const searchResult = {
|
||||
success: true,
|
||||
keyword: params.keyword || account.keyword,
|
||||
pageCount: searchConfig.page_count || 3,
|
||||
jobsFound: 0, // 实际搜索到的职位数
|
||||
jobsSaved: 0 // 保存到数据库的职位数
|
||||
};
|
||||
|
||||
console.log(`[自动搜索] 设备 ${sn_code} 搜索完成,找到 ${searchResult.jobsFound} 个职位`);
|
||||
|
||||
return searchResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账号信息
|
||||
*/
|
||||
async getAccountInfo(sn_code) {
|
||||
const { pla_account } = db.models;
|
||||
const account = await pla_account.findOne({
|
||||
where: {
|
||||
sn_code: sn_code,
|
||||
is_delete: 0,
|
||||
is_enabled: 1
|
||||
}
|
||||
});
|
||||
|
||||
return account ? account.toJSON() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析搜索配置
|
||||
*/
|
||||
parseSearchConfig(search_config) {
|
||||
if (typeof search_config === 'string') {
|
||||
try {
|
||||
search_config = JSON.parse(search_config);
|
||||
} catch (e) {
|
||||
search_config = {};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
search_interval: search_config?.search_interval || 60,
|
||||
page_count: search_config?.page_count || 3,
|
||||
city: search_config?.city || '',
|
||||
salary_range: search_config?.salary_range || '',
|
||||
experience: search_config?.experience || '',
|
||||
education: search_config?.education || '',
|
||||
time_range: search_config?.time_range || null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取今日已搜索数量
|
||||
*/
|
||||
async getTodaySearchCount(sn_code) {
|
||||
const { task_status } = db.models;
|
||||
const Sequelize = require('sequelize');
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const count = await task_status.count({
|
||||
where: {
|
||||
sn_code: sn_code,
|
||||
taskType: 'auto_search',
|
||||
status: 'completed',
|
||||
endTime: {
|
||||
[Sequelize.Op.gte]: today
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加搜索任务到队列
|
||||
*/
|
||||
async addToQueue(sn_code, taskQueue, customParams = {}) {
|
||||
const now = new Date();
|
||||
console.log(`[自动搜索] ${now.toLocaleString()} 尝试为设备 ${sn_code} 添加任务`);
|
||||
|
||||
try {
|
||||
// 1. 获取账号信息
|
||||
const account = await this.getAccountInfo(sn_code);
|
||||
if (!account) {
|
||||
console.log(`[自动搜索] 账号 ${sn_code} 不存在或未启用`);
|
||||
return { success: false, reason: '账号不存在或未启用' };
|
||||
}
|
||||
|
||||
// 2. 检查是否开启了自动搜索
|
||||
if (!account.auto_search) {
|
||||
console.log(`[自动搜索] 设备 ${sn_code} 未开启自动搜索`);
|
||||
return { success: false, reason: '未开启自动搜索' };
|
||||
}
|
||||
|
||||
// 3. 获取搜索配置
|
||||
const searchConfig = this.parseSearchConfig(account.search_config);
|
||||
|
||||
// 4. 检查时间范围
|
||||
if (searchConfig.time_range) {
|
||||
const timeCheck = this.checkTimeRange(searchConfig.time_range);
|
||||
if (!timeCheck.allowed) {
|
||||
console.log(`[自动搜索] 设备 ${sn_code} ${timeCheck.reason}`);
|
||||
return { success: false, reason: timeCheck.reason };
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 执行所有层级的冲突检查
|
||||
const conflictCheck = await this.canExecuteTask(sn_code, taskQueue);
|
||||
if (!conflictCheck.allowed) {
|
||||
return { success: false, reason: conflictCheck.reason };
|
||||
}
|
||||
|
||||
// 6. 检查搜索间隔
|
||||
const intervalCheck = await this.checkExecutionInterval(
|
||||
sn_code,
|
||||
searchConfig.search_interval
|
||||
);
|
||||
|
||||
if (!intervalCheck.allowed) {
|
||||
console.log(`[自动搜索] 设备 ${sn_code} ${intervalCheck.reason}`);
|
||||
return { success: false, reason: intervalCheck.reason };
|
||||
}
|
||||
|
||||
// 7. 构建任务参数
|
||||
const taskParams = {
|
||||
keyword: account.keyword || '',
|
||||
jobType: account.job_type || '',
|
||||
platform: account.platform_type || 'boss',
|
||||
pageCount: searchConfig.page_count || 3,
|
||||
city: searchConfig.city || '',
|
||||
salaryRange: searchConfig.salary_range || '',
|
||||
...customParams
|
||||
};
|
||||
|
||||
// 8. 验证参数
|
||||
const validation = this.validateParams(taskParams);
|
||||
if (!validation.valid) {
|
||||
return { success: false, reason: validation.reason };
|
||||
}
|
||||
|
||||
// 9. 添加任务到队列
|
||||
await taskQueue.addTask(sn_code, {
|
||||
taskType: this.taskType,
|
||||
taskName: this.getTaskName(taskParams),
|
||||
taskParams: taskParams,
|
||||
priority: this.config.defaultPriority
|
||||
});
|
||||
|
||||
console.log(`[自动搜索] 已为设备 ${sn_code} 添加搜索任务,间隔: ${searchConfig.search_interval} 分钟`);
|
||||
|
||||
return { success: true };
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[自动搜索] 添加任务失败:`, error);
|
||||
return { success: false, reason: error.message };
|
||||
} finally {
|
||||
// 统一释放任务锁
|
||||
this.releaseTaskLock(sn_code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
module.exports = new AutoSearchTask();
|
||||
@@ -1,405 +0,0 @@
|
||||
const dayjs = require('dayjs');
|
||||
const deviceManager = require('../core/deviceManager');
|
||||
const db = require('../../dbProxy');
|
||||
|
||||
/**
|
||||
* 任务基类
|
||||
* 提供所有任务的通用功能和冲突检测机制
|
||||
*/
|
||||
class BaseTask {
|
||||
constructor(taskType, config = {}) {
|
||||
this.taskType = taskType;
|
||||
this.config = {
|
||||
// 默认配置
|
||||
defaultInterval: 30, // 默认间隔30分钟
|
||||
defaultPriority: 5,
|
||||
requiresLogin: true, // 是否需要登录状态
|
||||
conflictsWith: [], // 与哪些任务类型冲突
|
||||
...config
|
||||
};
|
||||
|
||||
// 任务执行锁 { sn_code: timestamp }
|
||||
this.taskLocks = new Map();
|
||||
|
||||
// 最后执行时间缓存 { sn_code: timestamp }
|
||||
this.lastExecutionCache = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Layer 1: 任务类型互斥锁检查
|
||||
* 防止同一设备同时添加相同类型的任务
|
||||
*/
|
||||
acquireTaskLock(sn_code) {
|
||||
const lockKey = `${sn_code}:${this.taskType}`;
|
||||
const now = Date.now();
|
||||
const existingLock = this.taskLocks.get(lockKey);
|
||||
|
||||
// 如果存在锁且未超时(5分钟),返回false
|
||||
if (existingLock && (now - existingLock) < 5 * 60 * 1000) {
|
||||
const remainingTime = Math.ceil((5 * 60 * 1000 - (now - existingLock)) / 1000);
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `任务 ${this.taskType} 正在添加中,请等待 ${remainingTime} 秒`
|
||||
};
|
||||
}
|
||||
|
||||
// 获取锁
|
||||
this.taskLocks.set(lockKey, now);
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放任务锁
|
||||
*/
|
||||
releaseTaskLock(sn_code) {
|
||||
const lockKey = `${sn_code}:${this.taskType}`;
|
||||
this.taskLocks.delete(lockKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Layer 2: 设备状态检查
|
||||
* 检查设备是否在线、是否登录、是否忙碌
|
||||
*/
|
||||
async checkDeviceStatus(sn_code) {
|
||||
// 1. 优先检查内存中的设备状态
|
||||
let device = deviceManager.devices.get(sn_code);
|
||||
|
||||
// 2. 如果内存中没有,降级到数据库查询(可能是刚启动还没收到心跳)
|
||||
if (!device) {
|
||||
try {
|
||||
const pla_account = db.getModel('pla_account');
|
||||
const dbDevice = await pla_account.findOne({
|
||||
where: { sn_code, is_delete: 0 },
|
||||
attributes: ['sn_code', 'is_online', 'is_logged_in']
|
||||
});
|
||||
|
||||
if (!dbDevice) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `设备 ${sn_code} 不存在`
|
||||
};
|
||||
}
|
||||
|
||||
// 检查数据库中的在线状态
|
||||
if (!dbDevice.is_online) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `设备 ${sn_code} 离线(数据库状态)`
|
||||
};
|
||||
}
|
||||
|
||||
// 检查数据库中的登录状态
|
||||
if (this.config.requiresLogin && !dbDevice.is_logged_in) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `设备 ${sn_code} 未登录平台账号(数据库状态)`
|
||||
};
|
||||
}
|
||||
|
||||
// 数据库检查通过,允许执行
|
||||
return { allowed: true };
|
||||
} catch (error) {
|
||||
console.error(`[${this.taskType}] 查询设备状态失败:`, error);
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `设备 ${sn_code} 状态查询失败`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 检查心跳超时
|
||||
const offlineThreshold = 3 * 60 * 1000; // 3分钟
|
||||
const now = Date.now();
|
||||
const lastHeartbeat = device.lastHeartbeat || 0;
|
||||
const isOnline = device.isOnline && (now - lastHeartbeat < offlineThreshold);
|
||||
|
||||
if (!isOnline) {
|
||||
const offlineMinutes = lastHeartbeat ? Math.round((now - lastHeartbeat) / (60 * 1000)) : '未知';
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `设备 ${sn_code} 离线(最后心跳: ${offlineMinutes}分钟前)`
|
||||
};
|
||||
}
|
||||
|
||||
// 4. 检查登录状态(如果任务需要)
|
||||
if (this.config.requiresLogin && !device.isLoggedIn) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `设备 ${sn_code} 未登录平台账号`
|
||||
};
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Layer 3: 检查任务队列状态
|
||||
* 防止队列中已有相同任务
|
||||
*/
|
||||
async checkTaskQueue(sn_code, taskQueue) {
|
||||
// 获取设备队列
|
||||
const deviceQueue = taskQueue.deviceQueues.get(sn_code);
|
||||
if (!deviceQueue) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// 检查队列中是否有相同类型的待执行任务
|
||||
const tasks = deviceQueue.toArray();
|
||||
const hasSameTypeTask = tasks.some(task =>
|
||||
task.taskType === this.taskType &&
|
||||
task.status === 'pending'
|
||||
);
|
||||
|
||||
if (hasSameTypeTask) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `队列中已存在待执行的 ${this.taskType} 任务`
|
||||
};
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Layer 4: 检查任务去重
|
||||
* 查询数据库中是否有重复的待执行任务
|
||||
*/
|
||||
async checkDuplicateTask(sn_code) {
|
||||
try {
|
||||
const { task_status } = db.models;
|
||||
|
||||
// 查询该设备是否有相同类型的pending/running任务
|
||||
const existingTask = await task_status.findOne({
|
||||
where: {
|
||||
sn_code: sn_code,
|
||||
taskType: this.taskType,
|
||||
status: ['pending', 'running']
|
||||
},
|
||||
attributes: ['id', 'status', 'taskName']
|
||||
});
|
||||
|
||||
if (existingTask) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `已存在 ${existingTask.status} 状态的任务: ${existingTask.taskName}`
|
||||
};
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
} catch (error) {
|
||||
console.error(`[${this.taskType}] 检查重复任务失败:`, error);
|
||||
// 出错时允许继续,避免阻塞
|
||||
return { allowed: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Layer 5: 操作类型冲突检测
|
||||
* 某些操作类型不能同时执行
|
||||
*/
|
||||
async checkOperationConflict(sn_code, taskQueue) {
|
||||
// 如果没有配置冲突类型,直接通过
|
||||
if (!this.config.conflictsWith || this.config.conflictsWith.length === 0) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// 检查当前是否有冲突的任务正在执行
|
||||
const deviceStatus = taskQueue.deviceStatus.get(sn_code);
|
||||
if (deviceStatus && deviceStatus.currentTask) {
|
||||
const currentTaskType = deviceStatus.currentTask.taskType;
|
||||
|
||||
if (this.config.conflictsWith.includes(currentTaskType)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `与正在执行的任务 ${currentTaskType} 冲突`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查执行间隔
|
||||
* 从数据库查询上次成功执行时间,判断是否满足间隔要求
|
||||
*/
|
||||
async checkExecutionInterval(sn_code, intervalMinutes) {
|
||||
try {
|
||||
const { task_status } = db.models;
|
||||
|
||||
// 先从缓存检查
|
||||
const cachedLastExecution = this.lastExecutionCache.get(sn_code);
|
||||
const now = Date.now();
|
||||
|
||||
if (cachedLastExecution) {
|
||||
const elapsedTime = now - cachedLastExecution;
|
||||
const interval_ms = intervalMinutes * 60 * 1000;
|
||||
|
||||
if (elapsedTime < interval_ms) {
|
||||
const remainingMinutes = Math.ceil((interval_ms - elapsedTime) / (60 * 1000));
|
||||
const elapsedMinutes = Math.round(elapsedTime / (60 * 1000));
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `距离上次执行仅 ${elapsedMinutes} 分钟,还需等待 ${remainingMinutes} 分钟(间隔: ${intervalMinutes} 分钟)`,
|
||||
remainingMinutes,
|
||||
elapsedMinutes
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 从数据库查询最近一次成功完成的任务
|
||||
const lastTask = await task_status.findOne({
|
||||
where: {
|
||||
sn_code: sn_code,
|
||||
taskType: this.taskType,
|
||||
status: 'completed'
|
||||
},
|
||||
order: [['endTime', 'DESC']],
|
||||
attributes: ['endTime']
|
||||
});
|
||||
|
||||
// 如果存在上次执行记录,检查是否已经过了间隔时间
|
||||
if (lastTask && lastTask.endTime) {
|
||||
const lastExecutionTime = new Date(lastTask.endTime).getTime();
|
||||
const elapsedTime = now - lastExecutionTime;
|
||||
const interval_ms = intervalMinutes * 60 * 1000;
|
||||
|
||||
// 更新缓存
|
||||
this.lastExecutionCache.set(sn_code, lastExecutionTime);
|
||||
|
||||
if (elapsedTime < interval_ms) {
|
||||
const remainingMinutes = Math.ceil((interval_ms - elapsedTime) / (60 * 1000));
|
||||
const elapsedMinutes = Math.round(elapsedTime / (60 * 1000));
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `距离上次执行仅 ${elapsedMinutes} 分钟,还需等待 ${remainingMinutes} 分钟(间隔: ${intervalMinutes} 分钟)`,
|
||||
remainingMinutes,
|
||||
elapsedMinutes,
|
||||
nextExecutionTime: new Date(lastExecutionTime + interval_ms)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
} catch (error) {
|
||||
console.error(`[${this.taskType}] 检查执行间隔失败:`, error);
|
||||
// 出错时允许继续,避免阻塞
|
||||
return { allowed: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查时间范围限制
|
||||
* @param {Object} timeRange - 时间范围配置 {start_time: '09:00', end_time: '18:00', workdays_only: 1}
|
||||
*/
|
||||
checkTimeRange(timeRange) {
|
||||
if (!timeRange || !timeRange.start_time || !timeRange.end_time) {
|
||||
return { allowed: true, reason: '未配置时间范围' };
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const currentHour = now.getHours();
|
||||
const currentMinute = now.getMinutes();
|
||||
const currentTime = currentHour * 60 + currentMinute;
|
||||
|
||||
// 解析开始时间和结束时间
|
||||
const [startHour, startMinute] = timeRange.start_time.split(':').map(Number);
|
||||
const [endHour, endMinute] = timeRange.end_time.split(':').map(Number);
|
||||
const startTime = startHour * 60 + startMinute;
|
||||
const endTime = endHour * 60 + endMinute;
|
||||
|
||||
// 检查是否仅工作日
|
||||
if (timeRange.workdays_only == 1) {
|
||||
const dayOfWeek = now.getDay();
|
||||
if (dayOfWeek === 0 || dayOfWeek === 6) {
|
||||
return { allowed: false, reason: '当前是周末,不在允许的时间范围内' };
|
||||
}
|
||||
}
|
||||
|
||||
// 检查当前时间是否在时间范围内
|
||||
if (startTime <= endTime) {
|
||||
// 正常情况: 09:00 - 18:00
|
||||
if (currentTime < startTime || currentTime >= endTime) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `当前时间不在允许的时间范围内 (${timeRange.start_time} - ${timeRange.end_time})`
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// 跨天情况: 22:00 - 06:00
|
||||
if (currentTime < startTime && currentTime >= endTime) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `当前时间不在允许的时间范围内 (${timeRange.start_time} - ${timeRange.end_time})`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { allowed: true, reason: '在允许的时间范围内' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 综合检查 - 执行所有层级的检查
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {Object} taskQueue - 任务队列实例
|
||||
* @param {Object} options - 额外选项
|
||||
* @returns {Object} { allowed: boolean, reason: string }
|
||||
*/
|
||||
async canExecuteTask(sn_code, taskQueue, options = {}) {
|
||||
const checks = [
|
||||
{ name: 'Layer1-任务锁', fn: () => this.acquireTaskLock(sn_code) },
|
||||
{ name: 'Layer2-设备状态', fn: () => this.checkDeviceStatus(sn_code) },
|
||||
{ name: 'Layer3-队列检查', fn: () => this.checkTaskQueue(sn_code, taskQueue) },
|
||||
{ name: 'Layer4-任务去重', fn: () => this.checkDuplicateTask(sn_code) },
|
||||
{ name: 'Layer5-操作冲突', fn: () => this.checkOperationConflict(sn_code, taskQueue) }
|
||||
];
|
||||
|
||||
// 逐层检查
|
||||
for (const check of checks) {
|
||||
const result = await check.fn();
|
||||
if (!result.allowed) {
|
||||
console.log(`[${this.taskType}] ${check.name} 未通过: ${result.reason}`);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理任务锁(定期清理过期锁)
|
||||
*/
|
||||
cleanupExpiredLocks() {
|
||||
const now = Date.now();
|
||||
const timeout = 5 * 60 * 1000; // 5分钟超时
|
||||
|
||||
for (const [lockKey, timestamp] of this.taskLocks.entries()) {
|
||||
if (now - timestamp > timeout) {
|
||||
this.taskLocks.delete(lockKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务名称(子类可覆盖)
|
||||
*/
|
||||
getTaskName(params) {
|
||||
return `${this.taskType} 任务`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证任务参数(子类必须实现)
|
||||
*/
|
||||
validateParams(params) {
|
||||
throw new Error('子类必须实现 validateParams 方法');
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行任务的具体逻辑(子类必须实现)
|
||||
*/
|
||||
async execute(sn_code, params) {
|
||||
throw new Error('子类必须实现 execute 方法');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BaseTask;
|
||||
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* 任务模块索引
|
||||
* 统一导出所有任务类型
|
||||
*/
|
||||
|
||||
const autoSearchTask = require('./autoSearchTask');
|
||||
const autoDeliverTask = require('./autoDeliverTask');
|
||||
const autoChatTask = require('./autoChatTask');
|
||||
const autoActiveTask = require('./autoActiveTask');
|
||||
|
||||
module.exports = {
|
||||
autoSearchTask,
|
||||
autoDeliverTask,
|
||||
autoChatTask,
|
||||
autoActiveTask
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
/**
|
||||
* Utils 模块导出
|
||||
* 统一导出工具类模块
|
||||
*/
|
||||
|
||||
const SalaryParser = require('./salaryParser');
|
||||
const KeywordMatcher = require('./keywordMatcher');
|
||||
const ScheduleUtils = require('./scheduleUtils');
|
||||
|
||||
module.exports = {
|
||||
SalaryParser,
|
||||
KeywordMatcher,
|
||||
ScheduleUtils
|
||||
};
|
||||
@@ -1,225 +0,0 @@
|
||||
/**
|
||||
* 关键词匹配工具
|
||||
* 提供职位描述的关键词匹配和评分功能
|
||||
*/
|
||||
class KeywordMatcher {
|
||||
/**
|
||||
* 检查是否包含排除关键词
|
||||
* @param {string} text - 待检查的文本
|
||||
* @param {string[]} excludeKeywords - 排除关键词列表
|
||||
* @returns {{matched: boolean, keywords: string[]}} 匹配结果
|
||||
*/
|
||||
static matchExcludeKeywords(text, excludeKeywords = []) {
|
||||
if (!text || !excludeKeywords || excludeKeywords.length === 0) {
|
||||
return { matched: false, keywords: [] };
|
||||
}
|
||||
|
||||
const matched = [];
|
||||
const lowerText = text.toLowerCase();
|
||||
|
||||
for (const keyword of excludeKeywords) {
|
||||
if (!keyword || !keyword.trim()) continue;
|
||||
|
||||
const lowerKeyword = keyword.toLowerCase().trim();
|
||||
if (lowerText.includes(lowerKeyword)) {
|
||||
matched.push(keyword);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
matched: matched.length > 0,
|
||||
keywords: matched
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否包含过滤关键词(必须匹配)
|
||||
* @param {string} text - 待检查的文本
|
||||
* @param {string[]} filterKeywords - 过滤关键词列表
|
||||
* @returns {{matched: boolean, keywords: string[], matchCount: number}} 匹配结果
|
||||
*/
|
||||
static matchFilterKeywords(text, filterKeywords = []) {
|
||||
if (!text) {
|
||||
return { matched: false, keywords: [], matchCount: 0 };
|
||||
}
|
||||
|
||||
if (!filterKeywords || filterKeywords.length === 0) {
|
||||
return { matched: true, keywords: [], matchCount: 0 };
|
||||
}
|
||||
|
||||
const matched = [];
|
||||
const lowerText = text.toLowerCase();
|
||||
|
||||
for (const keyword of filterKeywords) {
|
||||
if (!keyword || !keyword.trim()) continue;
|
||||
|
||||
const lowerKeyword = keyword.toLowerCase().trim();
|
||||
if (lowerText.includes(lowerKeyword)) {
|
||||
matched.push(keyword);
|
||||
}
|
||||
}
|
||||
|
||||
// 只要匹配到至少一个过滤关键词即可通过
|
||||
return {
|
||||
matched: matched.length > 0,
|
||||
keywords: matched,
|
||||
matchCount: matched.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算关键词匹配奖励分数
|
||||
* @param {string} text - 待检查的文本
|
||||
* @param {string[]} keywords - 关键词列表
|
||||
* @param {object} options - 选项
|
||||
* @returns {{score: number, matchedKeywords: string[], matchCount: number}}
|
||||
*/
|
||||
static calculateBonus(text, keywords = [], options = {}) {
|
||||
const {
|
||||
baseScore = 10, // 每个关键词的基础分
|
||||
maxBonus = 50, // 最大奖励分
|
||||
caseSensitive = false // 是否区分大小写
|
||||
} = options;
|
||||
|
||||
if (!text || !keywords || keywords.length === 0) {
|
||||
return { score: 0, matchedKeywords: [], matchCount: 0 };
|
||||
}
|
||||
|
||||
const matched = [];
|
||||
const searchText = caseSensitive ? text : text.toLowerCase();
|
||||
|
||||
for (const keyword of keywords) {
|
||||
if (!keyword || !keyword.trim()) continue;
|
||||
|
||||
const searchKeyword = caseSensitive ? keyword.trim() : keyword.toLowerCase().trim();
|
||||
if (searchText.includes(searchKeyword)) {
|
||||
matched.push(keyword);
|
||||
}
|
||||
}
|
||||
|
||||
const score = Math.min(matched.length * baseScore, maxBonus);
|
||||
|
||||
return {
|
||||
score,
|
||||
matchedKeywords: matched,
|
||||
matchCount: matched.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 高亮匹配的关键词(用于展示)
|
||||
* @param {string} text - 原始文本
|
||||
* @param {string[]} keywords - 关键词列表
|
||||
* @param {string} prefix - 前缀标记(默认 <mark>)
|
||||
* @param {string} suffix - 后缀标记(默认 </mark>)
|
||||
* @returns {string} 高亮后的文本
|
||||
*/
|
||||
static highlight(text, keywords = [], prefix = '<mark>', suffix = '</mark>') {
|
||||
if (!text || !keywords || keywords.length === 0) {
|
||||
return text;
|
||||
}
|
||||
|
||||
let result = text;
|
||||
|
||||
for (const keyword of keywords) {
|
||||
if (!keyword || !keyword.trim()) continue;
|
||||
|
||||
const regex = new RegExp(`(${this.escapeRegex(keyword.trim())})`, 'gi');
|
||||
result = result.replace(regex, `${prefix}$1${suffix}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转义正则表达式特殊字符
|
||||
* @param {string} str - 待转义的字符串
|
||||
* @returns {string} 转义后的字符串
|
||||
*/
|
||||
static escapeRegex(str) {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* 综合匹配(排除 + 过滤 + 奖励)
|
||||
* @param {string} text - 待检查的文本
|
||||
* @param {object} config - 配置
|
||||
* @param {string[]} config.excludeKeywords - 排除关键词
|
||||
* @param {string[]} config.filterKeywords - 过滤关键词
|
||||
* @param {string[]} config.bonusKeywords - 奖励关键词
|
||||
* @returns {{pass: boolean, reason?: string, score: number, details: object}}
|
||||
*/
|
||||
static match(text, config = {}) {
|
||||
const {
|
||||
excludeKeywords = [],
|
||||
filterKeywords = [],
|
||||
bonusKeywords = []
|
||||
} = config;
|
||||
|
||||
// 1. 检查排除关键词
|
||||
const excludeResult = this.matchExcludeKeywords(text, excludeKeywords);
|
||||
if (excludeResult.matched) {
|
||||
return {
|
||||
pass: false,
|
||||
reason: `包含排除关键词: ${excludeResult.keywords.join(', ')}`,
|
||||
score: 0,
|
||||
details: { exclude: excludeResult }
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 检查过滤关键词(必须匹配)
|
||||
const filterResult = this.matchFilterKeywords(text, filterKeywords);
|
||||
if (filterKeywords.length > 0 && !filterResult.matched) {
|
||||
return {
|
||||
pass: false,
|
||||
reason: '不包含任何必需关键词',
|
||||
score: 0,
|
||||
details: { filter: filterResult }
|
||||
};
|
||||
}
|
||||
|
||||
// 3. 计算奖励分数
|
||||
const bonusResult = this.calculateBonus(text, bonusKeywords);
|
||||
|
||||
return {
|
||||
pass: true,
|
||||
score: bonusResult.score,
|
||||
details: {
|
||||
exclude: excludeResult,
|
||||
filter: filterResult,
|
||||
bonus: bonusResult
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量匹配职位列表
|
||||
* @param {Array} jobs - 职位列表
|
||||
* @param {object} config - 匹配配置
|
||||
* @param {Function} textExtractor - 文本提取函数 (job) => string
|
||||
* @returns {Array} 匹配通过的职位(带匹配信息)
|
||||
*/
|
||||
static filterJobs(jobs, config, textExtractor = (job) => `${job.name || ''} ${job.description || ''}`) {
|
||||
if (!jobs || jobs.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filtered = [];
|
||||
|
||||
for (const job of jobs) {
|
||||
const text = textExtractor(job);
|
||||
const matchResult = this.match(text, config);
|
||||
|
||||
if (matchResult.pass) {
|
||||
filtered.push({
|
||||
...job,
|
||||
_matchInfo: matchResult
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = KeywordMatcher;
|
||||
@@ -1,126 +0,0 @@
|
||||
/**
|
||||
* 薪资解析工具
|
||||
* 统一处理职位薪资和期望薪资的解析逻辑
|
||||
*/
|
||||
class SalaryParser {
|
||||
/**
|
||||
* 解析薪资范围字符串
|
||||
* @param {string} salaryDesc - 薪资描述 (如 "15-20K", "8000-12000元")
|
||||
* @returns {{ min: number, max: number }} 薪资范围(单位:元)
|
||||
*/
|
||||
static parse(salaryDesc) {
|
||||
if (!salaryDesc || typeof salaryDesc !== 'string') {
|
||||
return { min: 0, max: 0 };
|
||||
}
|
||||
|
||||
// 尝试各种格式
|
||||
return this.parseK(salaryDesc)
|
||||
|| this.parseYuan(salaryDesc)
|
||||
|| this.parseMixed(salaryDesc)
|
||||
|| { min: 0, max: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 K 格式薪资 (如 "15-20K", "8-12k")
|
||||
*/
|
||||
static parseK(desc) {
|
||||
const kMatch = desc.match(/(\d+)[-~](\d+)[kK千]/);
|
||||
if (kMatch) {
|
||||
return {
|
||||
min: parseInt(kMatch[1]) * 1000,
|
||||
max: parseInt(kMatch[2]) * 1000
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析元格式薪资 (如 "8000-12000元", "15000-20000")
|
||||
*/
|
||||
static parseYuan(desc) {
|
||||
const yuanMatch = desc.match(/(\d+)[-~](\d+)元?/);
|
||||
if (yuanMatch) {
|
||||
return {
|
||||
min: parseInt(yuanMatch[1]),
|
||||
max: parseInt(yuanMatch[2])
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析混合格式 (如 "8k-12000元")
|
||||
*/
|
||||
static parseMixed(desc) {
|
||||
const mixedMatch = desc.match(/(\d+)[kK千][-~](\d+)元?/);
|
||||
if (mixedMatch) {
|
||||
return {
|
||||
min: parseInt(mixedMatch[1]) * 1000,
|
||||
max: parseInt(mixedMatch[2])
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查职位薪资是否在期望范围内
|
||||
* @param {object} jobSalary - 职位薪资 { min, max }
|
||||
* @param {number} minExpected - 期望最低薪资
|
||||
* @param {number} maxExpected - 期望最高薪资
|
||||
*/
|
||||
static isWithinRange(jobSalary, minExpected, maxExpected) {
|
||||
if (!jobSalary || jobSalary.min === 0) {
|
||||
return true; // 无法判断时默认通过
|
||||
}
|
||||
|
||||
// 职位最高薪资 >= 期望最低薪资
|
||||
if (minExpected > 0 && jobSalary.max < minExpected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 职位最低薪资 <= 期望最高薪资
|
||||
if (maxExpected > 0 && jobSalary.min > maxExpected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算薪资匹配度(用于职位评分)
|
||||
* @param {object} jobSalary - 职位薪资
|
||||
* @param {object} expectedSalary - 期望薪资
|
||||
* @returns {number} 匹配度 0-1
|
||||
*/
|
||||
static calculateMatch(jobSalary, expectedSalary) {
|
||||
if (!jobSalary || !expectedSalary || jobSalary.min === 0 || expectedSalary.min === 0) {
|
||||
return 0.5; // 无法判断时返回中性值
|
||||
}
|
||||
|
||||
const jobAvg = (jobSalary.min + jobSalary.max) / 2;
|
||||
const expectedAvg = (expectedSalary.min + expectedSalary.max) / 2;
|
||||
|
||||
const diff = Math.abs(jobAvg - expectedAvg);
|
||||
const range = (jobSalary.max - jobSalary.min + expectedSalary.max - expectedSalary.min) / 2;
|
||||
|
||||
// 差距越小,匹配度越高
|
||||
return Math.max(0, 1 - diff / (range || 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化薪资显示
|
||||
* @param {object} salary - 薪资对象 { min, max }
|
||||
* @returns {string} 格式化字符串
|
||||
*/
|
||||
static format(salary) {
|
||||
if (!salary || salary.min === 0) {
|
||||
return '面议';
|
||||
}
|
||||
|
||||
const minK = (salary.min / 1000).toFixed(0);
|
||||
const maxK = (salary.max / 1000).toFixed(0);
|
||||
return `${minK}-${maxK}K`;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SalaryParser;
|
||||
144
api/model/ai_call_records.js
Normal file
144
api/model/ai_call_records.js
Normal file
@@ -0,0 +1,144 @@
|
||||
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;
|
||||
};
|
||||
@@ -79,20 +79,20 @@ module.exports = (db) => {
|
||||
get: function () {
|
||||
const value = this.getDataValue('is_salary_priority');
|
||||
if (!value) {
|
||||
return [{ "key": "distance", "weight": 50 }, { "key": "salary", "weight": 20 }, { "key": "work_years", "weight": 10 }, { "key": "education", "weight": 20 }];
|
||||
return [{ "key": "distance", "weight": 50 }, { "key": "salary", "weight": 20 }, { "key": "work_years", "weight": 10 }, { "key": "education", "weight": 20}];
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (e) {
|
||||
return [{ "key": "distance", "weight": 50 }, { "key": "salary", "weight": 20 }, { "key": "work_years", "weight": 10 }, { "key": "education", "weight": 20 }];
|
||||
return [{ "key": "distance", "weight": 50 }, { "key": "salary", "weight": 20 }, { "key": "work_years", "weight": 10 }, { "key": "education", "weight": 20}];
|
||||
}
|
||||
}
|
||||
return value;
|
||||
},
|
||||
set: function (value) {
|
||||
if (value === null || value === undefined) {
|
||||
this.setDataValue('is_salary_priority', JSON.stringify([{ "key": "distance", "weight": 50 }, { "key": "salary", "weight": 20 }, { "key": "work_years", "weight": 10 }, { "key": "education", "weight": 20 }]));
|
||||
this.setDataValue('is_salary_priority', JSON.stringify([{ "key": "distance", "weight": 50 }, { "key": "salary", "weight": 20 }, { "key": "work_years", "weight": 10 }, { "key": "education", "weight": 20}]));
|
||||
} else if (typeof value === 'string') {
|
||||
// 如果已经是字符串,直接使用
|
||||
this.setDataValue('is_salary_priority', value);
|
||||
@@ -101,7 +101,7 @@ module.exports = (db) => {
|
||||
this.setDataValue('is_salary_priority', JSON.stringify(value));
|
||||
}
|
||||
},
|
||||
defaultValue: JSON.stringify([{ "key": "distance", "weight": 50 }, { "key": "salary", "weight": 20 }, { "key": "work_years", "weight": 10 }, { "key": "education", "weight": 20 }])
|
||||
defaultValue: JSON.stringify([{ "key": "distance", "weight": 50 }, { "key": "salary", "weight": 20 }, { "key": "work_years", "weight": 10 }, { "key": "education", "weight": 20}])
|
||||
},
|
||||
|
||||
|
||||
@@ -185,57 +185,6 @@ module.exports = (db) => {
|
||||
exclude_keywords: []
|
||||
})
|
||||
},
|
||||
// 自动搜索相关配置
|
||||
auto_search: {
|
||||
comment: '自动搜索开关',
|
||||
type: Sequelize.TINYINT(1),
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
// 自动搜索配置(JSON格式,包含:search_interval-搜索间隔分钟数, page_count-滚动获取职位列表次数, city-城市, time_range-搜索时间段)
|
||||
search_config: {
|
||||
comment: '自动搜索配置(JSON对象)',
|
||||
type: Sequelize.JSON(),
|
||||
allowNull: true,
|
||||
get: function () {
|
||||
const value = this.getDataValue('search_config');
|
||||
if (!value) return null;
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
},
|
||||
set: function (value) {
|
||||
if (value === null || value === undefined) {
|
||||
this.setDataValue('search_config', null);
|
||||
} else if (typeof value === 'string') {
|
||||
// 如果已经是字符串,直接使用
|
||||
this.setDataValue('search_config', value);
|
||||
} else {
|
||||
// 如果是对象,序列化为字符串
|
||||
this.setDataValue('search_config', JSON.stringify(value));
|
||||
}
|
||||
},
|
||||
// 默认值说明:
|
||||
// city: '' - 城市,默认空字符串
|
||||
// cityName: '' - 城市名称,默认空字符串
|
||||
// salary: '' - 薪资,默认空字符串
|
||||
// experience: '' - 经验,默认空字符串
|
||||
// education: '' - 学历,默认空字符串
|
||||
|
||||
defaultValue: JSON.stringify({
|
||||
search_interval: 30,
|
||||
city: '',
|
||||
cityName: '',
|
||||
salary: '',
|
||||
experience: '',
|
||||
education: ''
|
||||
})
|
||||
},
|
||||
// 自动沟通相关配置
|
||||
auto_chat: {
|
||||
comment: '自动沟通开关',
|
||||
@@ -288,7 +237,7 @@ module.exports = (db) => {
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
// 自动活跃相关配置
|
||||
auto_active: {
|
||||
comment: '自动活跃开关',
|
||||
|
||||
@@ -50,9 +50,26 @@ module.exports = (db) => {
|
||||
},
|
||||
features: {
|
||||
comment: '功能特性列表(JSON字符串数组)',
|
||||
type: Sequelize.TEXT,
|
||||
type: Sequelize.JSON(),
|
||||
allowNull: false,
|
||||
defaultValue: '[]'
|
||||
defaultValue: [],
|
||||
get: function () {
|
||||
const value = this.getDataValue('features');
|
||||
if (!value) return null;
|
||||
if (typeof value === 'string') {
|
||||
return JSON.parse(value);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
set: function (value) {
|
||||
if (value === null || value === undefined) {
|
||||
this.setDataValue('features', null);
|
||||
} else if (typeof value === 'string') {
|
||||
this.setDataValue('features', value);
|
||||
} else {
|
||||
this.setDataValue('features', JSON.stringify(value));
|
||||
}
|
||||
},
|
||||
},
|
||||
featured: {
|
||||
comment: '是否为推荐套餐(1=推荐,0=普通)',
|
||||
|
||||
168
api/services/ai_call_recorder.js
Normal file
168
api/services/ai_call_recorder.js
Normal file
@@ -0,0 +1,168 @@
|
||||
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,415 +1,393 @@
|
||||
/**
|
||||
* AI智能服务
|
||||
* 提供岗位筛选、简历分析、聊天生成等AI功能
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const config = require('../../config/config');
|
||||
const AiCallRecorder = require('./ai_call_recorder.js');
|
||||
|
||||
class AIService {
|
||||
constructor() {
|
||||
this.apiKey = config.ai.apiKey;
|
||||
this.baseURL = config.ai.baseUrl;
|
||||
this.model = config.ai.model;
|
||||
this.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 大模型服务
|
||||
* 集成阿里云 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析简历竞争力
|
||||
* @param {Object} resumeData - 简历数据
|
||||
* @returns {Promise<Object>} 分析结果
|
||||
*/
|
||||
async analyzeResume(resumeData) {
|
||||
const prompt = `请分析以下简历的竞争力,并提供详细评估:
|
||||
/**
|
||||
* 调用 Qwen 2.5 API
|
||||
* @param {string} prompt - 提示词
|
||||
* @param {object} options - 配置选项
|
||||
* @returns {Promise<object>} API响应结果
|
||||
*/
|
||||
async callAPI(prompt, options = {}) {
|
||||
const startTime = Date.now();
|
||||
|
||||
const requestData = {
|
||||
model: this.model,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: options.systemPrompt || '你是一个专业的招聘顾问,擅长分析岗位信息、生成聊天内容和分析简历匹配度。'
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt
|
||||
}
|
||||
],
|
||||
temperature: options.temperature || 0.7,
|
||||
max_tokens: options.maxTokens || 2000,
|
||||
top_p: options.topP || 0.9
|
||||
};
|
||||
|
||||
const requestContent = JSON.stringify(requestData.messages);
|
||||
|
||||
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
|
||||
try {
|
||||
const response = await axios.post(this.apiUrl, requestData, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKey}`, // DashScope 使用 Bearer token 格式
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
const responseContent = response.data.choices?.[0]?.message?.content || '';
|
||||
const usage = response.data.usage || {};
|
||||
|
||||
// 记录AI调用(异步,不阻塞主流程)
|
||||
this.recordAiCall({
|
||||
user_id: options.user_id,
|
||||
sn_code: options.sn_code,
|
||||
service_type: options.service_type || 'completion',
|
||||
model_name: this.model,
|
||||
prompt_tokens: usage.prompt_tokens || 0,
|
||||
completion_tokens: usage.completion_tokens || 0,
|
||||
total_tokens: usage.total_tokens || 0,
|
||||
request_content: requestContent,
|
||||
response_content: responseContent,
|
||||
cost_amount: this.calculateCost(usage.total_tokens || 0),
|
||||
status: 'success',
|
||||
response_time: responseTime,
|
||||
api_provider: 'qwen',
|
||||
business_type: options.business_type,
|
||||
reference_id: options.reference_id
|
||||
}).catch(err => {
|
||||
console.warn('记录AI调用失败(不影响主流程):', err.message);
|
||||
});
|
||||
|
||||
return {
|
||||
data: response.data,
|
||||
content: responseContent
|
||||
};
|
||||
} catch (error) {
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
console.log(`Qwen 2.5 API调用失败 (尝试 ${attempt}/${this.maxRetries}): ${error.message}`);
|
||||
|
||||
// 记录失败的调用
|
||||
if (attempt === this.maxRetries) {
|
||||
this.recordAiCall({
|
||||
user_id: options.user_id,
|
||||
sn_code: options.sn_code,
|
||||
service_type: options.service_type || 'completion',
|
||||
model_name: this.model,
|
||||
request_content: requestContent,
|
||||
status: 'failed',
|
||||
error_message: error.message,
|
||||
response_time: responseTime,
|
||||
api_provider: 'qwen',
|
||||
business_type: options.business_type,
|
||||
reference_id: options.reference_id
|
||||
}).catch(err => {
|
||||
console.warn('记录失败调用失败:', err.message);
|
||||
});
|
||||
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
// 等待后重试
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录AI调用
|
||||
* @param {Object} params - 调用参数
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async recordAiCall(params) {
|
||||
try {
|
||||
await AiCallRecorder.record(params);
|
||||
} catch (error) {
|
||||
// 记录失败不应影响主流程
|
||||
console.warn('AI调用记录失败:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算调用费用
|
||||
* @param {Number} totalTokens - 总Token数
|
||||
* @returns {Number} 费用(元)
|
||||
*/
|
||||
calculateCost(totalTokens) {
|
||||
// 阿里云 Qwen 价格(元/1000 tokens)
|
||||
// qwen-turbo: ¥0.003, qwen-plus: ¥0.004, qwen-max: ¥0.12
|
||||
// 这里使用 qwen-turbo 的价格作为默认值
|
||||
const pricePerThousand = 0.003;
|
||||
return (totalTokens / 1000) * pricePerThousand;
|
||||
}
|
||||
|
||||
/**
|
||||
* 岗位智能筛选
|
||||
* @param {object} jobInfo - 岗位信息
|
||||
* @param {object} resumeInfo - 简历信息
|
||||
* @returns {Promise<object>} 筛选结果
|
||||
*/
|
||||
async analyzeJob(jobInfo, resumeInfo) {
|
||||
const prompt = `
|
||||
请分析以下岗位信息,并给出详细的评估结果:
|
||||
|
||||
岗位信息:
|
||||
- 公司名称:${jobInfo.companyName || '未知'}
|
||||
- 职位名称:${jobInfo.jobTitle || '未知'}
|
||||
- 薪资范围:${jobInfo.salary || '未知'}
|
||||
- 工作地点:${jobInfo.location || '未知'}
|
||||
- 岗位描述:${jobInfo.description || '未知'}
|
||||
- 技能要求:${jobInfo.skills || '未知'}
|
||||
|
||||
简历信息:
|
||||
- 姓名: ${resumeData.fullName || '未知'}
|
||||
- 工作年限: ${resumeData.workYears || '未知'}
|
||||
- 教育背景: ${resumeData.education || '未知'}
|
||||
- 期望职位: ${resumeData.expectedPosition || '未知'}
|
||||
- 期望薪资: ${resumeData.expectedSalary || '未知'}
|
||||
- 技能标签: ${Array.isArray(resumeData.skills) ? resumeData.skills.join('、') : '未知'}
|
||||
- 工作经历: ${resumeData.workExperience || '未提供'}
|
||||
- 项目经历: ${resumeData.projectExperience || '未提供'}
|
||||
- 技能标签:${resumeInfo.skills || '未知'}
|
||||
- 工作经验:${resumeInfo.experience || '未知'}
|
||||
- 教育背景:${resumeInfo.education || '未知'}
|
||||
- 期望薪资:${resumeInfo.expectedSalary || '未知'}
|
||||
|
||||
请从以下维度进行评估(1-100分):
|
||||
1. 技术能力
|
||||
2. 项目经验
|
||||
3. 教育背景
|
||||
4. 工作年限匹配度
|
||||
5. 综合竞争力
|
||||
请从以下维度进行分析:
|
||||
1. 技能匹配度(0-100分)
|
||||
2. 经验匹配度(0-100分)
|
||||
3. 薪资合理性(0-100分)
|
||||
4. 公司质量评估(0-100分)
|
||||
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 结果:
|
||||
{
|
||||
"overallScore": 总分(1-100),
|
||||
"technicalScore": 技术能力分(1-100),
|
||||
"projectScore": 项目经验分(1-100),
|
||||
"educationScore": 教育背景分(1-100),
|
||||
"experienceScore": 工作年限分(1-100),
|
||||
"strengths": ["优势1", "优势2", "优势3"],
|
||||
"weaknesses": ["不足1", "不足2"],
|
||||
"suggestions": ["建议1", "建议2", "建议3"],
|
||||
"keySkills": ["核心技能1", "核心技能2"],
|
||||
"marketCompetitiveness": "市场竞争力描述"
|
||||
}`;
|
||||
|
||||
const messages = [
|
||||
{ role: 'system', content: '你是一个专业的HR和招聘顾问,擅长分析简历和评估候选人竞争力。' },
|
||||
{ role: 'user', content: prompt }
|
||||
];
|
||||
|
||||
try {
|
||||
const response = await this.chat(messages, { temperature: 0.3 });
|
||||
// 提取JSON部分
|
||||
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
return JSON.parse(jsonMatch[0]);
|
||||
}
|
||||
throw new Error('AI返回格式不正确');
|
||||
} catch (error) {
|
||||
console.warn('简历分析失败:', error);
|
||||
// 返回默认值
|
||||
return {
|
||||
overallScore: 60,
|
||||
technicalScore: 60,
|
||||
projectScore: 60,
|
||||
educationScore: 60,
|
||||
experienceScore: 60,
|
||||
strengths: ['待AI分析'],
|
||||
weaknesses: ['待AI分析'],
|
||||
suggestions: ['请稍后重试'],
|
||||
keySkills: Array.isArray(resumeData.skills) ? resumeData.skills.slice(0, 5) : [],
|
||||
marketCompetitiveness: '待AI分析'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 岗位匹配度评估
|
||||
* @param {Object} jobData - 岗位数据
|
||||
* @param {Object} resumeData - 简历数据
|
||||
* @returns {Promise<Object>} 匹配结果
|
||||
*/
|
||||
async matchJobWithResume(jobData, resumeData) {
|
||||
const prompt = `请评估以下岗位与简历的匹配度:
|
||||
|
||||
【岗位信息】
|
||||
- 职位名称: ${jobData.jobTitle || '未知'}
|
||||
- 公司名称: ${jobData.companyName || '未知'}
|
||||
- 薪资范围: ${jobData.salary || '未知'}
|
||||
- 工作地点: ${jobData.location || '未知'}
|
||||
- 工作经验要求: ${jobData.experienceRequired || '未知'}
|
||||
- 学历要求: ${jobData.educationRequired || '未知'}
|
||||
- 岗位描述: ${jobData.jobDescription || '未提供'}
|
||||
- 技能要求: ${jobData.skillsRequired || '未提供'}
|
||||
|
||||
【简历信息】
|
||||
- 工作年限: ${resumeData.workYears || '未知'}
|
||||
- 教育背景: ${resumeData.education || '未知'}
|
||||
- 技能标签: ${Array.isArray(resumeData.skills) ? resumeData.skills.join('、') : '未知'}
|
||||
- 期望职位: ${resumeData.expectedPosition || '未知'}
|
||||
- 期望薪资: ${resumeData.expectedSalary || '未知'}
|
||||
|
||||
请分析:
|
||||
1. 技能匹配度
|
||||
2. 经验匹配度
|
||||
3. 薪资匹配度
|
||||
4. 是否为外包岗位(根据公司名称、岗位描述判断)
|
||||
5. 综合推荐度
|
||||
|
||||
返回JSON格式:
|
||||
{
|
||||
"matchScore": 匹配度分数(1-100),
|
||||
"skillMatch": 技能匹配度(1-100),
|
||||
"experienceMatch": 经验匹配度(1-100),
|
||||
"salaryMatch": 薪资匹配度(1-100),
|
||||
"isOutsourcing": 是否外包(true/false),
|
||||
"outsourcingConfidence": 外包判断置信度(0-1),
|
||||
"recommendLevel": "推荐等级(excellent/good/medium/low)",
|
||||
"matchReasons": ["匹配原因1", "匹配原因2"],
|
||||
"concerns": ["顾虑点1", "顾虑点2"],
|
||||
"applyAdvice": "投递建议"
|
||||
}`;
|
||||
|
||||
const messages = [
|
||||
{ role: 'system', content: '你是一个专业的职业规划师,擅长评估岗位与求职者的匹配度。' },
|
||||
{ role: 'user', content: prompt }
|
||||
];
|
||||
|
||||
try {
|
||||
const response = await this.chat(messages, { temperature: 0.3 });
|
||||
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
return JSON.parse(jsonMatch[0]);
|
||||
}
|
||||
throw new Error('AI返回格式不正确');
|
||||
} catch (error) {
|
||||
console.warn('岗位匹配分析失败:', error);
|
||||
// 返回默认值
|
||||
return {
|
||||
matchScore: 50,
|
||||
skillMatch: 50,
|
||||
experienceMatch: 50,
|
||||
salaryMatch: 50,
|
||||
isOutsourcing: false,
|
||||
outsourcingConfidence: 0,
|
||||
recommendLevel: 'medium',
|
||||
matchReasons: ['待AI分析'],
|
||||
concerns: ['待AI分析'],
|
||||
applyAdvice: '建议人工审核'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量评估岗位(用于智能筛选)
|
||||
* @param {Array} jobs - 岗位列表
|
||||
* @param {Object} resumeData - 简历数据
|
||||
* @returns {Promise<Array>} 评估结果列表
|
||||
*/
|
||||
async batchMatchJobs(jobs, resumeData) {
|
||||
const results = [];
|
||||
|
||||
// 限制并发数量,避免API限流
|
||||
const concurrency = 3;
|
||||
for (let i = 0; i < jobs.length; i += concurrency) {
|
||||
const batch = jobs.slice(i, i + concurrency);
|
||||
const batchPromises = batch.map(job =>
|
||||
this.matchJobWithResume(job, resumeData).catch(err => {
|
||||
console.warn(`岗位${job.jobId}匹配失败:`, err.message);
|
||||
return {
|
||||
jobId: job.jobId,
|
||||
matchScore: 0,
|
||||
error: err.message
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const batchResults = await Promise.all(batchPromises);
|
||||
results.push(...batchResults);
|
||||
|
||||
// 避免请求过快,休眠一下
|
||||
if (i + concurrency < jobs.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成聊天内容
|
||||
* @param {Object} context - 聊天上下文
|
||||
* @returns {Promise<String>} 生成的聊天内容
|
||||
*/
|
||||
async generateChatContent(context) {
|
||||
const { jobInfo, resumeInfo, chatType = 'greeting', previousMessages = [] } = context;
|
||||
|
||||
let prompt = '';
|
||||
|
||||
switch (chatType) {
|
||||
case 'greeting':
|
||||
prompt = `作为求职者,向HR发送第一条消息表达对以下岗位的兴趣:
|
||||
岗位: ${jobInfo.jobTitle}
|
||||
公司: ${jobInfo.companyName}
|
||||
要求: 简洁、专业、突出自己的优势,不超过100字`;
|
||||
break;
|
||||
|
||||
case 'follow_up':
|
||||
prompt = `HR已查看简历但未回复,需要发送一条礼貌的跟进消息:
|
||||
岗位: ${jobInfo.jobTitle}
|
||||
要求: 礼貌、不唐突、展现持续兴趣,不超过80字`;
|
||||
break;
|
||||
|
||||
case 'interview_confirm':
|
||||
prompt = `HR发出面试邀约,需要确认并表达感谢:
|
||||
岗位: ${jobInfo.jobTitle}
|
||||
面试时间: ${context.interviewTime || '待定'}
|
||||
要求: 专业、感谢、确认参加,不超过60字`;
|
||||
break;
|
||||
|
||||
case 'reply':
|
||||
prompt = `HR说: "${context.hrMessage}"
|
||||
请作为求职者回复,要求: 自然、专业、回答问题,不超过100字`;
|
||||
break;
|
||||
|
||||
default:
|
||||
prompt = `生成一条针对${jobInfo.jobTitle}岗位的沟通消息`;
|
||||
}
|
||||
|
||||
const messages = [
|
||||
{ role: 'system', content: '你是一个求职者,需要与HR进行专业、礼貌的沟通。回复要简洁、真诚、突出优势。' },
|
||||
...previousMessages.map(msg => ({
|
||||
role: msg.role,
|
||||
content: msg.content
|
||||
})),
|
||||
{ role: 'user', content: prompt }
|
||||
];
|
||||
|
||||
try {
|
||||
const response = await this.chat(messages, { temperature: 0.8, max_tokens: 200 });
|
||||
return response.trim();
|
||||
} catch (error) {
|
||||
console.warn('生成聊天内容失败:', error);
|
||||
// 返回默认模板
|
||||
switch (chatType) {
|
||||
case 'greeting':
|
||||
return `您好,我对贵司的${jobInfo.jobTitle}岗位非常感兴趣,我有相关工作经验,期待能有机会详细沟通。`;
|
||||
case 'follow_up':
|
||||
return `您好,不知道您对我的简历是否感兴趣?期待您的回复。`;
|
||||
case 'interview_confirm':
|
||||
return `好的,感谢您的面试邀约,我会准时参加。`;
|
||||
default:
|
||||
return `您好,期待与您沟通。`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为面试邀约
|
||||
* @param {String} message - HR消息内容
|
||||
* @returns {Promise<Object>} 判断结果
|
||||
*/
|
||||
async detectInterviewInvitation(message) {
|
||||
const prompt = `判断以下HR消息是否为面试邀约,并提取关键信息:
|
||||
|
||||
消息内容: "${message}"
|
||||
|
||||
返回JSON格式:
|
||||
{
|
||||
"isInterview": 是否为面试邀约(true/false),
|
||||
"confidence": 置信度(0-1),
|
||||
"interviewType": "面试类型(phone/video/onsite/unknown)",
|
||||
"interviewTime": "面试时间(如果提到)",
|
||||
"interviewLocation": "面试地点(如果提到)",
|
||||
"needReply": 是否需要回复确认(true/false)
|
||||
}`;
|
||||
|
||||
const messages = [
|
||||
{ role: 'system', content: '你是一个文本分析专家,擅长识别面试邀约信息。' },
|
||||
{ role: 'user', content: prompt }
|
||||
];
|
||||
|
||||
try {
|
||||
const response = await this.chat(messages, { temperature: 0.1 });
|
||||
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
return JSON.parse(jsonMatch[0]);
|
||||
}
|
||||
throw new Error('AI返回格式不正确');
|
||||
} catch (error) {
|
||||
console.warn('面试邀约判断失败:', error);
|
||||
// 简单的关键词判断作为降级方案
|
||||
const keywords = ['面试', '邀请', '来面', '约您', '见面', '电话沟通', '视频面试'];
|
||||
const isInterview = keywords.some(kw => message.includes(kw));
|
||||
|
||||
return {
|
||||
isInterview,
|
||||
confidence: isInterview ? 0.7 : 0.3,
|
||||
interviewType: 'unknown',
|
||||
interviewTime: null,
|
||||
interviewLocation: null,
|
||||
needReply: isInterview
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析HR反馈情感
|
||||
* @param {String} message - HR消息内容
|
||||
* @returns {Promise<Object>} 情感分析结果
|
||||
*/
|
||||
async analyzeSentiment(message) {
|
||||
const prompt = `分析以下HR消息的情感倾向:
|
||||
|
||||
消息: "${message}"
|
||||
|
||||
返回JSON格式:
|
||||
{
|
||||
"sentiment": "情感倾向(positive/neutral/negative)",
|
||||
"interest": "兴趣程度(high/medium/low)",
|
||||
"urgency": "紧急程度(high/medium/low)",
|
||||
"keywords": ["关键词1", "关键词2"]
|
||||
}`;
|
||||
|
||||
const messages = [
|
||||
{ role: 'system', content: '你是一个情感分析专家。' },
|
||||
{ role: 'user', content: prompt }
|
||||
];
|
||||
|
||||
try {
|
||||
const response = await this.chat(messages, { temperature: 0.1 });
|
||||
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
return JSON.parse(jsonMatch[0]);
|
||||
}
|
||||
throw new Error('AI返回格式不正确');
|
||||
} catch (error) {
|
||||
console.warn('情感分析失败:', error);
|
||||
return {
|
||||
sentiment: 'neutral',
|
||||
interest: 'medium',
|
||||
urgency: 'low',
|
||||
keywords: []
|
||||
};
|
||||
}
|
||||
}
|
||||
"skillTags": ["技能1", "技能2", "技能3"], // 技能标签数组(编程语言、框架、工具等)
|
||||
"strengths": "核心优势描述", // 简历的优势和亮点
|
||||
"weaknesses": "不足之处描述", // 简历的不足或需要改进的地方
|
||||
"careerSuggestion": "职业发展建议", // 针对该简历的职业发展方向和建议
|
||||
"competitiveness": 75 // 竞争力评分(0-100的整数),综合考虑工作年限、技能、经验等因素
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
let instance = null;
|
||||
要求:
|
||||
1. skillTags 必须是字符串数组
|
||||
2. strengths、weaknesses、careerSuggestion 是字符串描述
|
||||
3. competitiveness 必须是 0-100 之间的整数
|
||||
4. 所有字段都必须返回,如果没有相关信息,使用空数组或空字符串
|
||||
`;
|
||||
|
||||
module.exports = {
|
||||
/**
|
||||
* 获取AI服务实例
|
||||
* @returns {AIService}
|
||||
*/
|
||||
getInstance() {
|
||||
if (!instance) {
|
||||
instance = new AIService();
|
||||
const result = await this.callAPI(prompt, {
|
||||
systemPrompt: '你是一个专业的简历分析师,擅长分析简历的核心要素、优势劣势、竞争力评分和职业发展建议。请以 JSON 格式返回分析结果,确保格式正确。',
|
||||
temperature: 0.3,
|
||||
maxTokens: 1500,
|
||||
business_type: 'resume_analysis',
|
||||
service_type: 'completion'
|
||||
});
|
||||
|
||||
try {
|
||||
// 尝试从返回内容中提取 JSON
|
||||
let content = result.content.trim();
|
||||
|
||||
// 如果返回内容被代码块包裹,提取其中的 JSON
|
||||
const jsonMatch = content.match(/```(?:json)?\s*(\{[\s\S]*\})\s*```/) || content.match(/(\{[\s\S]*\})/);
|
||||
if (jsonMatch) {
|
||||
content = jsonMatch[1];
|
||||
}
|
||||
|
||||
const analysis = JSON.parse(content);
|
||||
return {
|
||||
analysis: analysis
|
||||
};
|
||||
} catch (parseError) {
|
||||
console.error(`[AI服务] 简历分析结果解析失败:`, parseError);
|
||||
console.error(`[AI服务] 原始内容:`, result.content);
|
||||
return {
|
||||
analysis: {
|
||||
content: result.content,
|
||||
parseError: true
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建新的AI服务实例
|
||||
* @returns {AIService}
|
||||
*/
|
||||
createInstance() {
|
||||
return new AIService();
|
||||
}
|
||||
};
|
||||
/**
|
||||
* 生成面试邀约内容
|
||||
* @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,
|
||||
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();
|
||||
|
||||
@@ -112,5 +112,7 @@ module.exports = {
|
||||
// 导出各个服务类
|
||||
AIService,
|
||||
PlaAccountService
|
||||
|
||||
// TaskScheduler 已废弃
|
||||
// MQTTHandler 文件不存在
|
||||
// JobService 已合并到 middleware/job/jobManager.js
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
const db = require('../middleware/dbProxy');
|
||||
const scheduleManager = require('../middleware/schedule/index.js');
|
||||
const locationService = require('./location_service');
|
||||
const locationService = require('./locationService');
|
||||
const authorizationService = require('./authorization_service');
|
||||
const { addRemainingDays, addRemainingDaysToAccounts } = require('../utils/account_utils');
|
||||
|
||||
@@ -680,120 +680,13 @@ class PlaAccountService {
|
||||
}
|
||||
});
|
||||
|
||||
const taskId = await scheduleManager.taskQueue.addTask(account.sn_code, {
|
||||
taskType: taskType,
|
||||
taskName: `手动任务 - ${taskName}`,
|
||||
taskParams: {
|
||||
keyword: account.keyword,
|
||||
platform: account.platform_type
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
message: '任务已添加到队列',
|
||||
taskId: taskId
|
||||
taskId: task.id
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建搜索职位列表任务(支持可选投递)
|
||||
* @param {Object} params - 任务参数
|
||||
* @param {number} params.id - 账号ID
|
||||
* @param {string} params.keyword - 搜索关键词
|
||||
* @param {Object} params.searchParams - 搜索条件(城市、薪资、经验、学历等)
|
||||
* @param {number} params.pageCount - 获取页数
|
||||
* @param {boolean} params.autoDeliver - 是否自动投递(默认false)
|
||||
* @param {Object} params.filterRules - 过滤规则(autoDeliver=true时使用)
|
||||
* @param {number} params.maxCount - 最大投递数量(autoDeliver=true时使用)
|
||||
* @returns {Promise<Object>} 任务创建结果 { taskId, message, jobCount, deliveredCount }
|
||||
*/
|
||||
async createSearchJobListTask(params) {
|
||||
const pla_account = db.getModel('pla_account');
|
||||
const task_status = db.getModel('task_status');
|
||||
|
||||
const {
|
||||
id,
|
||||
keyword,
|
||||
searchParams = {},
|
||||
pageCount = 3,
|
||||
autoDeliver = false,
|
||||
filterRules = {},
|
||||
maxCount = 10
|
||||
} = params;
|
||||
|
||||
// 1. 验证账号和授权
|
||||
if (!id) {
|
||||
throw new Error('账号ID不能为空');
|
||||
}
|
||||
|
||||
const account = await pla_account.findByPk(id);
|
||||
if (!account) {
|
||||
throw new Error('账号不存在');
|
||||
}
|
||||
|
||||
// 检查账号是否启用
|
||||
if (!account.is_enabled) {
|
||||
throw new Error('账号未启用,无法执行任务');
|
||||
}
|
||||
|
||||
// 检查授权状态
|
||||
const authCheck = await authorizationService.checkAuthorization(id, 'id');
|
||||
if (!authCheck.is_authorized) {
|
||||
throw new Error(authCheck.message);
|
||||
}
|
||||
|
||||
// 检查MQTT客户端
|
||||
if (!scheduleManager.mqttClient) {
|
||||
throw new Error('MQTT客户端未初始化');
|
||||
}
|
||||
|
||||
const sn_code = account.sn_code;
|
||||
const platform = account.platform_type || 'boss';
|
||||
|
||||
// 2. 创建任务记录(使用新的搜索任务类型)
|
||||
const taskType = 'search_jobs';
|
||||
const taskName = autoDeliver ? '搜索并投递职位' : '搜索职位列表';
|
||||
|
||||
const task = await task_status.create({
|
||||
sn_code: sn_code,
|
||||
taskType: taskType,
|
||||
taskName: taskName,
|
||||
taskParams: JSON.stringify({
|
||||
keyword: keyword || account.keyword || '',
|
||||
searchParams: searchParams,
|
||||
pageCount: pageCount,
|
||||
autoDeliver: autoDeliver,
|
||||
filterRules: filterRules,
|
||||
maxCount: maxCount,
|
||||
platform: platform
|
||||
}),
|
||||
status: 'pending',
|
||||
progress: 0
|
||||
});
|
||||
|
||||
console.log(`[账号服务] 创建搜索任务: ${taskName} (ID: ${task.id}, 设备: ${sn_code})`);
|
||||
|
||||
// 3. 将任务添加到队列,由 handleSearchJobListTask 处理
|
||||
const taskId = await scheduleManager.taskQueue.addTask(sn_code, {
|
||||
taskType: taskType,
|
||||
taskName: taskName,
|
||||
taskParams: {
|
||||
keyword: keyword || account.keyword || '',
|
||||
searchParams: searchParams,
|
||||
pageCount: pageCount,
|
||||
autoDeliver: autoDeliver,
|
||||
filterRules: filterRules,
|
||||
maxCount: maxCount,
|
||||
platform: platform
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
taskId: taskId,
|
||||
message: `搜索任务已创建,任务ID: ${taskId}`,
|
||||
jobCount: 0,
|
||||
deliveredCount: 0
|
||||
};
|
||||
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
1
app/assets/delivery_config-BjklYJQ0.js
Normal file
1
app/assets/delivery_config-BjklYJQ0.js
Normal file
@@ -0,0 +1 @@
|
||||
import{a as t}from"./index-CsHwYKwf.js";class s{async getConfig(r){try{return await t.post("/user/delivery-config/get",{sn_code:r})}catch(e){throw console.error("获取投递配置失败:",e),e}}async saveConfig(r,e){try{return await t.post("/user/delivery-config/save",{sn_code:r,deliver_config:e})}catch(o){throw console.error("保存投递配置失败:",o),o}}}const i=new s;export{i as default};
|
||||
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
3648
app/assets/index-CsHwYKwf.js
Normal file
3648
app/assets/index-CsHwYKwf.js
Normal file
File diff suppressed because one or more lines are too long
BIN
app/assets/primeicons-C6QP2o4f.woff2
Normal file
BIN
app/assets/primeicons-C6QP2o4f.woff2
Normal file
Binary file not shown.
BIN
app/assets/primeicons-DMOk5skT.eot
Normal file
BIN
app/assets/primeicons-DMOk5skT.eot
Normal file
Binary file not shown.
345
app/assets/primeicons-Dr5RGzOO.svg
Normal file
345
app/assets/primeicons-Dr5RGzOO.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 334 KiB |
BIN
app/assets/primeicons-MpK4pl85.ttf
Normal file
BIN
app/assets/primeicons-MpK4pl85.ttf
Normal file
Binary file not shown.
BIN
app/assets/primeicons-WjwUDZjB.woff
Normal file
BIN
app/assets/primeicons-WjwUDZjB.woff
Normal file
Binary file not shown.
32
app/index.html
Normal file
32
app/index.html
Normal file
@@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>boss - 远程监听服务</title>
|
||||
<script type="module" crossorigin src="/app/assets/index-CsHwYKwf.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/app/assets/index-BUzIVj1g.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- 启动加载动画 -->
|
||||
<div id="loading-screen" class="loading-screen">
|
||||
<div class="loading-content">
|
||||
<div class="loading-logo">
|
||||
<div class="logo-circle"></div>
|
||||
</div>
|
||||
<div class="loading-text">正在启动...</div>
|
||||
<div class="loading-progress">
|
||||
<div class="progress-bar-animated"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vue 应用挂载点 -->
|
||||
<div id="app" ></div>
|
||||
|
||||
<!-- 在 body 底部加载 Vue 应用脚本 -->
|
||||
|
||||
</body>
|
||||
|
||||
@@ -68,7 +68,7 @@ module.exports = {
|
||||
ai: {
|
||||
"apiKey": "sk-c83cdb06a6584f99bb2cd6e8a5ae3bbc",
|
||||
"baseUrl": "https://dashscope.aliyuncs.com/api/v1",
|
||||
"model": "qwen-plus"
|
||||
"model": "qwen-turbo"
|
||||
},
|
||||
|
||||
// MQTT配置
|
||||
|
||||
@@ -25,7 +25,7 @@ module.exports = {
|
||||
acquire: 30000,
|
||||
idle: 10000
|
||||
},
|
||||
logging: false
|
||||
logging: true
|
||||
},
|
||||
|
||||
// API 路径配置(必需)
|
||||
|
||||
145
docs/ai_service_config.md
Normal file
145
docs/ai_service_config.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# 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
|
||||
**维护者**: 系统管理员
|
||||
364
docs/ai_service_unified.md
Normal file
364
docs/ai_service_unified.md
Normal file
@@ -0,0 +1,364 @@
|
||||
# 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
|
||||
**维护者**: 开发团队
|
||||
561
docs/implementation_summary.md
Normal file
561
docs/implementation_summary.md
Normal file
@@ -0,0 +1,561 @@
|
||||
# 自动找工作系统 - 功能实施总结
|
||||
|
||||
**更新时间**: 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
|
||||
**维护者**: 开发团队
|
||||
@@ -1,59 +0,0 @@
|
||||
-- 任务调度系统重构 - 数据库迁移脚本
|
||||
-- 为 pla_account 表添加自动搜索和自动活跃相关字段
|
||||
|
||||
USE autoAiWorkSys;
|
||||
|
||||
-- 1. 添加自动搜索开关字段
|
||||
ALTER TABLE pla_account
|
||||
ADD COLUMN auto_search TINYINT(1) DEFAULT 0 COMMENT '是否开启自动搜索: 0-关闭, 1-开启';
|
||||
|
||||
-- 2. 添加搜索配置字段
|
||||
ALTER TABLE pla_account
|
||||
ADD COLUMN search_config TEXT COMMENT '搜索配置(JSON): {search_interval, page_count, city, salary_range, time_range等}';
|
||||
|
||||
-- 3. 添加自动活跃开关字段
|
||||
ALTER TABLE pla_account
|
||||
ADD COLUMN auto_active TINYINT(1) DEFAULT 0 COMMENT '是否开启自动活跃: 0-关闭, 1-开启';
|
||||
|
||||
-- 4. 添加活跃策略配置字段
|
||||
ALTER TABLE pla_account
|
||||
ADD COLUMN active_strategy TEXT COMMENT '活跃策略配置(JSON): {active_interval, actions, time_range等}';
|
||||
|
||||
-- 5. 查看表结构验证
|
||||
DESC pla_account;
|
||||
|
||||
-- 示例: 为已有账号设置默认配置
|
||||
-- UPDATE pla_account
|
||||
-- SET
|
||||
-- auto_search = 0,
|
||||
-- search_config = JSON_OBJECT(
|
||||
-- 'search_interval', 60,
|
||||
-- 'page_count', 3,
|
||||
-- 'city', '',
|
||||
-- 'time_range', JSON_OBJECT(
|
||||
-- 'start_time', '09:00',
|
||||
-- 'end_time', '18:00',
|
||||
-- 'workdays_only', 1
|
||||
-- )
|
||||
-- ),
|
||||
-- auto_active = 0,
|
||||
-- active_strategy = JSON_OBJECT(
|
||||
-- 'active_interval', 120,
|
||||
-- 'actions', JSON_ARRAY('browse_jobs', 'refresh_resume', 'check_notifications'),
|
||||
-- 'time_range', JSON_OBJECT(
|
||||
-- 'start_time', '08:00',
|
||||
-- 'end_time', '23:00',
|
||||
-- 'workdays_only', 0
|
||||
-- )
|
||||
-- )
|
||||
-- WHERE is_delete = 0;
|
||||
|
||||
-- 注意:
|
||||
-- 1. 执行前请先备份数据库
|
||||
-- 2. 建议在测试环境先测试
|
||||
-- 3. search_config 和 active_strategy 字段存储JSON格式的配置
|
||||
-- 4. 如果字段已存在会报错,可以先删除字段后再添加:
|
||||
-- ALTER TABLE pla_account DROP COLUMN auto_search;
|
||||
-- ALTER TABLE pla_account DROP COLUMN search_config;
|
||||
-- ALTER TABLE pla_account DROP COLUMN auto_active;
|
||||
-- ALTER TABLE pla_account DROP COLUMN active_strategy;
|
||||
Reference in New Issue
Block a user