Compare commits
44 Commits
9ab749f0f3
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ef0c68ad1 | ||
|
|
37daa2f99f | ||
|
|
51bbdacdda | ||
|
|
f2a8e61016 | ||
|
|
048c40d802 | ||
|
|
bfd39eddcf | ||
|
|
21fe005c19 | ||
|
|
e3d14dd637 | ||
|
|
ca8bbcd9cd | ||
|
|
820e437729 | ||
|
|
39a5b49213 | ||
|
|
f071215ad5 | ||
|
|
f5082c157c | ||
|
|
6b371148af | ||
|
|
6aeee136c4 | ||
|
|
2c021c24ef | ||
|
|
8a953eb769 | ||
|
|
a40219c7e4 | ||
|
|
5ec4e7f440 | ||
|
|
96da90daa8 | ||
|
|
e44ffba1ef | ||
|
|
0483d6d023 | ||
|
|
58c9d64e55 | ||
|
|
dfd3119163 | ||
|
|
1a011bcc01 | ||
|
|
c1d812a80e | ||
|
|
f57bb2767d | ||
|
|
d9d277fe59 | ||
|
|
d2ae741b9e | ||
|
|
52876229a8 | ||
|
|
914999c9fc | ||
|
|
fb9aa5b155 | ||
|
|
b49bd658a6 | ||
|
|
956cfe88f8 | ||
|
|
c45ea21c83 | ||
|
|
fa2dea3f04 | ||
|
|
dd7373c0b8 | ||
|
|
65833dd32d | ||
|
|
d14f89e008 | ||
|
|
dcaf0cb428 | ||
|
|
6d73a80e50 | ||
|
|
6b5e409b6b | ||
|
|
5035b9aa72 | ||
|
|
8fa06435a9 |
@@ -10,7 +10,17 @@
|
||||
"Bash(mkdir:*)",
|
||||
"Bash(findstr:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(npm run restart:*)"
|
||||
"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:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,4 +4,5 @@ node_modules.*
|
||||
dist.zip
|
||||
dist/
|
||||
admin/node_modules/
|
||||
app/
|
||||
app/*
|
||||
app/*
|
||||
140
_doc/AI禁用说明.md
140
_doc/AI禁用说明.md
@@ -1,140 +0,0 @@
|
||||
# AI 功能禁用说明
|
||||
|
||||
## 📋 概述
|
||||
|
||||
根据需求,AI 接入功能暂时禁用,作为二期规划。当前使用简单的文本匹配来实现职位过滤功能。
|
||||
|
||||
|
||||
|
||||
## ✅ 已完成的修改
|
||||
|
||||
### 1. 创建文本匹配过滤服务
|
||||
- ✅ 创建了 `api/middleware/job/job_filter_service.js`
|
||||
- 实现基于文本匹配的职位分析
|
||||
- 支持技能匹配、经验匹配、薪资匹配
|
||||
- 支持外包检测和关键词过滤
|
||||
|
||||
### 2. 禁用 AI 服务调用
|
||||
|
||||
#### 2.1 jobManager.js
|
||||
- ✅ 注释掉 `aiService` 引用
|
||||
- ✅ 禁用 `analyzeResumeWithAI()` 调用
|
||||
- ✅ 修改 `analyzeResume()` 使用文本匹配
|
||||
|
||||
#### 2.2 resumeManager.js
|
||||
- ✅ 注释掉 `aiService` 引用
|
||||
- ✅ 修改 `analyzeResume()` 使用文本匹配
|
||||
- ✅ 修改 `calculateMatchScore()` 使用 `jobFilterService.analyzeJobMatch()`
|
||||
|
||||
#### 2.3 chatManager.js
|
||||
- ✅ 注释掉 `aiService` 引用
|
||||
- ✅ 修改 `generateChatContent()` 使用默认模板
|
||||
- ✅ 修改 `generateInterviewInvitation()` 使用默认模板
|
||||
- ✅ 添加 `generateDefaultChatContent()` 方法
|
||||
- ✅ 添加 `generateDefaultInterviewInvitation()` 方法
|
||||
|
||||
## 🔧 文本匹配过滤功能
|
||||
|
||||
### 功能特性
|
||||
|
||||
1. **技能匹配度计算**
|
||||
- 从职位描述中提取技能关键词
|
||||
- 与简历技能进行匹配
|
||||
- 计算匹配百分比(0-100分)
|
||||
|
||||
2. **经验匹配度计算**
|
||||
- 从职位描述中提取经验要求
|
||||
- 与简历工作经验进行匹配
|
||||
- 计算匹配分数
|
||||
|
||||
3. **薪资合理性计算**
|
||||
- 解析职位薪资范围
|
||||
- 与期望薪资进行对比
|
||||
- 计算匹配分数
|
||||
|
||||
4. **外包检测**
|
||||
- 检测职位描述中的外包关键词
|
||||
- 标记是否为外包岗位
|
||||
|
||||
5. **关键词过滤**
|
||||
- 支持包含关键词过滤
|
||||
- 支持排除关键词过滤
|
||||
- 支持自定义排除关键词列表
|
||||
|
||||
### 使用示例
|
||||
|
||||
```javascript
|
||||
const jobFilterService = require('./job_filter_service');
|
||||
|
||||
// 分析职位匹配度
|
||||
const analysis = jobFilterService.analyzeJobMatch(jobInfo, resumeInfo);
|
||||
console.log('综合分数:', analysis.overallScore);
|
||||
console.log('技能匹配:', analysis.skillMatch);
|
||||
console.log('是否为外包:', analysis.isOutsourcing);
|
||||
|
||||
// 过滤职位列表
|
||||
const filteredJobs = jobFilterService.filterJobs(jobs, {
|
||||
minScore: 60, // 最低匹配分数
|
||||
excludeOutsourcing: true, // 排除外包
|
||||
excludeKeywords: ['销售', '客服'] // 排除关键词
|
||||
}, resumeInfo);
|
||||
```
|
||||
|
||||
## 📝 默认模板
|
||||
|
||||
### 聊天内容模板
|
||||
- **greeting**: "您好,我对这个岗位很感兴趣,希望能进一步了解。"
|
||||
- **interview**: "感谢您的回复,我很期待与您进一步沟通。"
|
||||
- **followup**: "您好,想了解一下这个岗位的最新进展。"
|
||||
|
||||
### 面试邀约模板
|
||||
- "感谢您的邀请,我很期待与您面谈。请问方便的时间是什么时候?"
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **AI 服务文件保留**
|
||||
- `api/middleware/job/aiService.js` 文件保留,但不再被调用
|
||||
- 二期规划时可以重新启用
|
||||
|
||||
|
||||
|
||||
3. **日志提示**
|
||||
- 所有禁用 AI 的地方都有日志提示
|
||||
- 明确标注"AI分析已禁用(二期规划)"
|
||||
|
||||
## 🔄 二期规划恢复步骤
|
||||
|
||||
当需要恢复 AI 功能时:
|
||||
|
||||
1. 取消注释所有 `aiService` 引用
|
||||
2. 恢复 AI 方法调用
|
||||
3. 移除或注释文本匹配的替代代码
|
||||
4. 测试 AI 服务连接和功能
|
||||
|
||||
## 📊 当前功能对比
|
||||
|
||||
| 功能 | AI 版本 | 文本匹配版本 |
|
||||
|------|---------|-------------|
|
||||
| 简历分析 | AI 智能分析 | 技能关键词提取 |
|
||||
| 职位匹配 | AI 深度分析 | 文本匹配评分 |
|
||||
| 聊天生成 | AI 个性化生成 | 固定模板 |
|
||||
| 面试邀约 | AI 个性化生成 | 固定模板 |
|
||||
| 外包检测 | AI 判断 | 关键词匹配 |
|
||||
|
||||
## 🎯 后续优化建议
|
||||
|
||||
1. **增强文本匹配**
|
||||
- 添加更多技能关键词
|
||||
- 优化匹配算法
|
||||
- 支持同义词匹配
|
||||
|
||||
2. **规则配置化**
|
||||
- 将过滤规则配置化
|
||||
- 支持用户自定义规则
|
||||
- 支持规则优先级
|
||||
|
||||
3. **匹配度优化**
|
||||
- 优化评分算法
|
||||
- 添加更多匹配维度
|
||||
- 支持权重配置
|
||||
|
||||
968
_doc/MQTT指令列表.md
968
_doc/MQTT指令列表.md
@@ -1,968 +0,0 @@
|
||||
# 自动找工作系统 - MQTT指令列表
|
||||
|
||||
> 版本: v1.0 | 更新日期: 2025-12-25
|
||||
|
||||
## 文档说明
|
||||
|
||||
本文档定义了服务端通过MQTT向客户端下发的所有指令格式和规范。所有操作都通过任务和指令的方式异步执行。
|
||||
|
||||
---
|
||||
|
||||
## 一、MQTT通信架构
|
||||
|
||||
### 1.1 通信流程
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌──────────────┐
|
||||
│ 服务端 │ │ 客户端 │
|
||||
│ (Node.js) │ │ (设备端) │
|
||||
└──────┬───────┘ └──────┬───────┘
|
||||
│ │
|
||||
│ ① 创建任务(task_status表) │
|
||||
│ ② 生成指令(task_commands表) │
|
||||
│ │
|
||||
│ ③ MQTT Publish │
|
||||
│ Topic: {sn_code}/command │
|
||||
│ ─────────────────────────────> │
|
||||
│ │
|
||||
│ ④ 执行指令 │
|
||||
│ ⑤ 生成结果 │
|
||||
│ │
|
||||
│ ⑥ MQTT Publish │
|
||||
│ Topic: response │
|
||||
│ <───────────────────────────── │
|
||||
│ │
|
||||
│ ⑦ 更新指令状态(task_commands) │
|
||||
│ ⑧ 更新任务状态(task_status) │
|
||||
│ │
|
||||
```
|
||||
|
||||
### 1.2 MQTT配置
|
||||
|
||||
- **Broker地址**: `mqtt://192.144.167.231:1883`
|
||||
- **订阅主题**:
|
||||
- `heartbeat` - 设备心跳信息
|
||||
- `response` - 设备响应信息
|
||||
- **发布主题**:
|
||||
- `{sn_code}/command` - 向指定设备发送指令
|
||||
|
||||
### 1.3 消息格式
|
||||
|
||||
**服务端 → 客户端 (指令)**
|
||||
```json
|
||||
{
|
||||
"commandId": "uuid",
|
||||
"taskId": "uuid",
|
||||
"platform": "boss",
|
||||
"action": "search_jobs",
|
||||
"data": {
|
||||
"keyword": "全栈工程师",
|
||||
"city": "101020100",
|
||||
"page": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**客户端 → 服务端 (响应)**
|
||||
```json
|
||||
{
|
||||
"commandId": "uuid",
|
||||
"taskId": "uuid",
|
||||
"code": 200,
|
||||
"message": "执行成功",
|
||||
"data": {
|
||||
// 返回数据
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**客户端 → 服务端 (心跳)**
|
||||
```json
|
||||
{
|
||||
"sn_code": "device001",
|
||||
"platform": "boss",
|
||||
"timestamp": 1672531200000,
|
||||
"status": "online",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、已实现指令列表
|
||||
|
||||
### 2.1 用户登录指令
|
||||
|
||||
#### get_login_qr_code - 获取登录二维码
|
||||
|
||||
**指令格式**
|
||||
```json
|
||||
{
|
||||
"action": "get_login_qr_code",
|
||||
"platform": "boss",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "二维码获取成功",
|
||||
"data": {
|
||||
"qrCode": "https://example.com/qrcode.png",
|
||||
"qr_code_url": "https://example.com/qrcode.png",
|
||||
"expire_time": 300
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**: 获取登录二维码,用户扫码登录后客户端需要保存cookies/token
|
||||
|
||||
---
|
||||
|
||||
#### get_user_info - 获取用户信息
|
||||
|
||||
**指令格式**
|
||||
```json
|
||||
{
|
||||
"action": "get_user_info",
|
||||
"platform": "boss",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "获取成功",
|
||||
"data": {
|
||||
"userId": "123456",
|
||||
"userName": "张三",
|
||||
"phone": "138****5678",
|
||||
"isLoggedIn": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**: 获取当前登录用户的基本信息,验证登录状态
|
||||
|
||||
---
|
||||
|
||||
### 2.2 简历管理指令
|
||||
|
||||
#### get_online_resume - 获取在线简历
|
||||
|
||||
**指令格式**
|
||||
```json
|
||||
{
|
||||
"action": "get_online_resume",
|
||||
"platform": "boss",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "获取成功",
|
||||
"data": {
|
||||
"baseInfo": {
|
||||
"name": "张三",
|
||||
"gender": 1,
|
||||
"age": 28,
|
||||
"account": "138****5678",
|
||||
"emailBlur": "zhang***@qq.com",
|
||||
"workYears": 5,
|
||||
"workYearDesc": "5年",
|
||||
"degreeCategory": "本科"
|
||||
},
|
||||
"expectList": [{
|
||||
"positionName": "全栈工程师",
|
||||
"locationName": "上海",
|
||||
"salaryDesc": "20-30K",
|
||||
"industryDesc": "互联网"
|
||||
}],
|
||||
"workExpList": [{
|
||||
"companyName": "XX科技公司",
|
||||
"positionName": "高级前端工程师",
|
||||
"startDate": "2020-01",
|
||||
"endDate": "2023-12",
|
||||
"workContent": "负责前端架构设计和开发..."
|
||||
}],
|
||||
"projectExpList": [{
|
||||
"name": "电商平台项目",
|
||||
"roleName": "技术负责人",
|
||||
"startDate": "2022-01",
|
||||
"endDate": "2023-06",
|
||||
"projectDesc": "项目描述...",
|
||||
"performance": "项目成果..."
|
||||
}],
|
||||
"educationExpList": [{
|
||||
"school": "XX大学",
|
||||
"major": "计算机科学与技术",
|
||||
"degreeName": "本科",
|
||||
"endYear": 2018
|
||||
}],
|
||||
"userDesc": "熟悉Vue、React、Node.js等技术栈...",
|
||||
"certificationList": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**: 获取用户在招聘平台上的完整简历信息
|
||||
|
||||
---
|
||||
|
||||
### 2.3 岗位搜索指令
|
||||
|
||||
#### search_jobs - 搜索岗位 (已实现但需完善)
|
||||
|
||||
**指令格式**
|
||||
```json
|
||||
{
|
||||
"action": "search_jobs",
|
||||
"platform": "boss",
|
||||
"data": {
|
||||
"keyword": "全栈工程师",
|
||||
"city": "101020100",
|
||||
"page": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "搜索成功",
|
||||
"data": {
|
||||
"total": 150,
|
||||
"page": 1,
|
||||
"jobList": [{
|
||||
"jobId": "job123456",
|
||||
"jobTitle": "全栈工程师",
|
||||
"companyName": "XX科技公司",
|
||||
"companySize": "100-499人",
|
||||
"salary": "20-30K",
|
||||
"location": "上海·浦东新区",
|
||||
"experience": "3-5年",
|
||||
"education": "本科",
|
||||
"jobRequirements": "1. 熟悉Vue/React...",
|
||||
"jobDescription": "岗位职责...",
|
||||
"bossName": "张经理",
|
||||
"bossTitle": "技术总监"
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**: 当前实现基础,需要扩展支持更多搜索条件
|
||||
|
||||
---
|
||||
|
||||
#### get_job_list - 获取岗位列表
|
||||
|
||||
**指令格式**
|
||||
```json
|
||||
{
|
||||
"action": "get_job_list",
|
||||
"platform": "boss",
|
||||
"data": {
|
||||
"page": 1,
|
||||
"pageSize": 20
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "获取成功",
|
||||
"data": {
|
||||
"total": 50,
|
||||
"page": 1,
|
||||
"jobList": [
|
||||
// 同 search_jobs 的 jobList 格式
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**: 获取推荐岗位列表
|
||||
|
||||
---
|
||||
|
||||
### 2.4 投递管理指令
|
||||
|
||||
#### apply_job - 投递岗位 (基础实现)
|
||||
|
||||
**指令格式**
|
||||
```json
|
||||
{
|
||||
"action": "apply_job",
|
||||
"platform": "boss",
|
||||
"data": {
|
||||
"jobId": "job123456",
|
||||
"expectSalary": "20-30K"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "投递成功",
|
||||
"data": {
|
||||
"applyId": "apply123456",
|
||||
"jobId": "job123456",
|
||||
"applyTime": "2025-12-25 10:30:00",
|
||||
"status": "success"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**错误码**
|
||||
- `400` - 参数错误
|
||||
- `403` - 已投递过该岗位
|
||||
- `429` - 投递次数达到上限
|
||||
- `500` - 投递失败
|
||||
|
||||
**说明**: 向指定岗位投递简历
|
||||
|
||||
---
|
||||
|
||||
### 2.5 聊天管理指令
|
||||
|
||||
#### get_chat_list - 获取聊天列表
|
||||
|
||||
**指令格式**
|
||||
```json
|
||||
{
|
||||
"action": "get_chat_list",
|
||||
"platform": "boss",
|
||||
"data": {
|
||||
"page": 1,
|
||||
"pageSize": 20
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "获取成功",
|
||||
"data": {
|
||||
"total": 15,
|
||||
"chatList": [{
|
||||
"conversationId": "conv123456",
|
||||
"jobId": "job123456",
|
||||
"jobTitle": "全栈工程师",
|
||||
"companyName": "XX科技",
|
||||
"bossName": "张经理",
|
||||
"lastMessage": "您好,请问...",
|
||||
"lastMessageTime": "2025-12-25 10:30:00",
|
||||
"unreadCount": 2,
|
||||
"hasInterview": false
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**: 获取与HR的聊天会话列表
|
||||
|
||||
---
|
||||
|
||||
#### send_chat_message - 发送聊天消息
|
||||
|
||||
**指令格式**
|
||||
```json
|
||||
{
|
||||
"action": "send_chat_message",
|
||||
"platform": "boss",
|
||||
"data": {
|
||||
"conversationId": "conv123456",
|
||||
"jobId": "job123456",
|
||||
"content": "您好,我对这个岗位很感兴趣..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "发送成功",
|
||||
"data": {
|
||||
"messageId": "msg123456",
|
||||
"sendTime": "2025-12-25 10:30:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**: 向HR发送聊天消息
|
||||
|
||||
---
|
||||
|
||||
### 2.6 测试和调试指令
|
||||
|
||||
#### open_bot_detection - 打开测试页
|
||||
|
||||
**指令格式**
|
||||
```json
|
||||
{
|
||||
"action": "open_bot_detection",
|
||||
"platform": "boss",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "测试页已打开",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**: 打开测试页面,用于调试
|
||||
|
||||
---
|
||||
|
||||
## 三、待开发指令列表
|
||||
|
||||
### 3.1 搜索投递增强指令 (优先级: HIGH)
|
||||
|
||||
#### search_jobs_enhanced - 增强搜索岗位 ⭐⭐⭐⭐⭐
|
||||
|
||||
**指令格式**
|
||||
```json
|
||||
{
|
||||
"action": "search_jobs_enhanced",
|
||||
"platform": "boss",
|
||||
"data": {
|
||||
"keyword": "全栈工程师",
|
||||
"city": "101020100",
|
||||
"page": 1,
|
||||
"pageSize": 20,
|
||||
|
||||
// 新增搜索条件
|
||||
"experience": "3",
|
||||
"degree": "203",
|
||||
"salary": "406",
|
||||
"scale": "303",
|
||||
"stage": "807",
|
||||
"position": "100109",
|
||||
|
||||
// 滚动加载方式
|
||||
"scrollLoadType": "auto",
|
||||
"maxScrollPages": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**参数说明**
|
||||
| 参数 | 说明 | 示例值 |
|
||||
|------|------|--------|
|
||||
| keyword | 搜索关键词 | "全栈工程师" |
|
||||
| city | 城市代码 | "101020100" (上海) |
|
||||
| page | 页码 | 1 |
|
||||
| pageSize | 每页数量 | 20 |
|
||||
| experience | 工作经验 | "1"=1年以下, "3"=1-3年, "4"=3-5年, "5"=5-10年, "6"=10年以上 |
|
||||
| degree | 学历要求 | "202"=不限, "203"=大专, "204"=本科, "205"=硕士, "206"=博士 |
|
||||
| salary | 薪资范围 | "402"=3-5K, "403"=5-10K, "404"=10-15K, "405"=15-20K, "406"=20-30K, "407"=30-50K, "408"=50K以上 |
|
||||
| scale | 公司规模 | "302"=0-20人, "303"=20-99人, "304"=100-499人, "305"=500-999人, "306"=1000人以上 |
|
||||
| stage | 融资阶段 | "801"=未融资, "802"=天使轮, "803"=A轮, "804"=B轮, "805"=C轮, "806"=D轮及以上, "807"=已上市, "808"=不需要融资 |
|
||||
| position | 职位类型 | "100109"=全栈, "100110"=前端, "100111"=后端, "100112"=移动端 |
|
||||
| scrollLoadType | 加载方式 | "auto"=自动滚动, "manual"=手动翻页 |
|
||||
| maxScrollPages | 最大滚动页数 | 5 |
|
||||
|
||||
**返回格式**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "搜索成功",
|
||||
"data": {
|
||||
"total": 150,
|
||||
"page": 1,
|
||||
"hasMore": true,
|
||||
"jobList": [{
|
||||
"jobId": "job123456",
|
||||
"jobTitle": "全栈工程师",
|
||||
"companyName": "XX科技公司",
|
||||
"companySize": "100-499人",
|
||||
"companyIndustry": "互联网",
|
||||
"companyStage": "已上市",
|
||||
"salary": "20-30K",
|
||||
"salaryMonth": "14薪",
|
||||
"location": "上海·浦东新区",
|
||||
"longitude": 121.5273,
|
||||
"latitude": 31.2172,
|
||||
"experience": "3-5年",
|
||||
"education": "本科",
|
||||
"skills": ["Vue", "React", "Node.js"],
|
||||
"jobRequirements": "1. 熟悉Vue/React...",
|
||||
"jobDescription": "岗位职责...",
|
||||
"welfare": ["五险一金", "带薪年假", "弹性工作"],
|
||||
"bossName": "张经理",
|
||||
"bossTitle": "技术总监",
|
||||
"bossActiveStatus": "刚刚活跃",
|
||||
"publishTime": "2025-12-25",
|
||||
"viewCount": 150,
|
||||
"applyCount": 30,
|
||||
"isOutsourcing": false,
|
||||
"jobLink": "https://www.zhipin.com/job_detail/xxx"
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 支持Boss直聘完整的搜索筛选条件
|
||||
- 支持自动滚动加载更多岗位
|
||||
- 返回更详细的岗位信息
|
||||
|
||||
---
|
||||
|
||||
#### search_by_url - 通过URL搜索岗位 ⭐⭐⭐⭐⭐
|
||||
|
||||
**指令格式**
|
||||
```json
|
||||
{
|
||||
"action": "search_by_url",
|
||||
"platform": "boss",
|
||||
"data": {
|
||||
"url": "https://www.zhipin.com/web/geek/jobs?city=101020100&query=%E5%85%A8%E6%A0%88%E5%B7%A5%E7%A8%8B%E5%B8%88",
|
||||
"scrollLoadType": "auto",
|
||||
"maxScrollPages": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "搜索成功",
|
||||
"data": {
|
||||
// 同 search_jobs_enhanced 返回格式
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 直接使用Boss直聘的搜索URL
|
||||
- 自动解析URL参数
|
||||
- 支持所有筛选条件
|
||||
|
||||
---
|
||||
|
||||
### 3.2 批量投递指令 (优先级: HIGH)
|
||||
|
||||
#### batch_apply_jobs - 批量投递岗位 ⭐⭐⭐⭐⭐
|
||||
|
||||
**指令格式**
|
||||
```json
|
||||
{
|
||||
"action": "batch_apply_jobs",
|
||||
"platform": "boss",
|
||||
"data": {
|
||||
"jobIds": ["job001", "job002", "job003"],
|
||||
"expectSalary": "20-30K",
|
||||
"applyInterval": 30,
|
||||
"maxApplyCount": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**参数说明**
|
||||
| 参数 | 说明 | 示例值 |
|
||||
|------|------|--------|
|
||||
| jobIds | 岗位ID数组 | ["job001", "job002"] |
|
||||
| expectSalary | 期望薪资 | "20-30K" |
|
||||
| applyInterval | 投递间隔(秒) | 30 |
|
||||
| maxApplyCount | 最大投递数量 | 10 |
|
||||
|
||||
**返回格式**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "批量投递完成",
|
||||
"data": {
|
||||
"total": 10,
|
||||
"success": 8,
|
||||
"failed": 2,
|
||||
"results": [{
|
||||
"jobId": "job001",
|
||||
"status": "success",
|
||||
"applyId": "apply001",
|
||||
"message": "投递成功"
|
||||
}, {
|
||||
"jobId": "job002",
|
||||
"status": "failed",
|
||||
"message": "已投递过该岗位"
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 批量投递多个岗位
|
||||
- 控制投递间隔避免被限制
|
||||
- 返回每个岗位的投递结果
|
||||
|
||||
---
|
||||
|
||||
### 3.3 简历刷新指令 (优先级: HIGH)
|
||||
|
||||
#### refresh_resume - 刷新简历 ⭐⭐⭐⭐
|
||||
|
||||
**指令格式**
|
||||
```json
|
||||
{
|
||||
"action": "refresh_resume",
|
||||
"platform": "boss",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "简历刷新成功",
|
||||
"data": {
|
||||
"refreshTime": "2025-12-25 10:30:00",
|
||||
"nextRefreshTime": "2025-12-25 12:30:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 刷新简历提升排名
|
||||
- 每2小时可刷新一次
|
||||
|
||||
---
|
||||
|
||||
### 3.4 账号保活指令 (优先级: HIGH)
|
||||
|
||||
#### auto_active - 自动活跃账号 ⭐⭐⭐⭐
|
||||
|
||||
**指令格式**
|
||||
```json
|
||||
{
|
||||
"action": "auto_active",
|
||||
"platform": "boss",
|
||||
"data": {
|
||||
"actionType": "random",
|
||||
"actions": ["browse_jobs", "view_company", "search_keyword", "update_visibility"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**参数说明**
|
||||
| 参数 | 说明 | 可选值 |
|
||||
|------|------|--------|
|
||||
| actionType | 动作类型 | "random"=随机, "sequence"=顺序 |
|
||||
| actions | 动作列表 | ["browse_jobs", "view_company", "search_keyword", "update_visibility"] |
|
||||
|
||||
**动作说明**
|
||||
- `browse_jobs` - 浏览岗位(随机点击5-10个岗位)
|
||||
- `view_company` - 查看公司主页
|
||||
- `search_keyword` - 搜索关键词(随机关键词)
|
||||
- `update_visibility` - 修改简历可见性
|
||||
|
||||
**返回格式**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "活跃操作完成",
|
||||
"data": {
|
||||
"executedActions": ["browse_jobs", "view_company"],
|
||||
"duration": 120,
|
||||
"timestamp": "2025-12-25 10:30:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 模拟真实用户行为
|
||||
- 随机时间间隔
|
||||
- 避免账号被标记为机器人
|
||||
|
||||
---
|
||||
|
||||
### 3.5 聊天增强指令 (优先级: MEDIUM)
|
||||
|
||||
#### get_chat_detail - 获取聊天详情 ⭐⭐⭐
|
||||
|
||||
**指令格式**
|
||||
```json
|
||||
{
|
||||
"action": "get_chat_detail",
|
||||
"platform": "boss",
|
||||
"data": {
|
||||
"conversationId": "conv123456",
|
||||
"page": 1,
|
||||
"pageSize": 50
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "获取成功",
|
||||
"data": {
|
||||
"conversationId": "conv123456",
|
||||
"jobId": "job123456",
|
||||
"messages": [{
|
||||
"messageId": "msg001",
|
||||
"senderId": "boss123",
|
||||
"senderType": "boss",
|
||||
"content": "您好,请问什么时候方便面试?",
|
||||
"sendTime": "2025-12-25 10:30:00",
|
||||
"isRead": true,
|
||||
"messageType": "text",
|
||||
"isInterviewInvitation": true
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**: 获取完整的聊天历史记录
|
||||
|
||||
---
|
||||
|
||||
#### send_greeting - 发送打招呼 ⭐⭐⭐
|
||||
|
||||
**指令格式**
|
||||
```json
|
||||
{
|
||||
"action": "send_greeting",
|
||||
"platform": "boss",
|
||||
"data": {
|
||||
"jobId": "job123456",
|
||||
"content": "您好,我对这个岗位很感兴趣,期待能有机会详聊。"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "打招呼成功",
|
||||
"data": {
|
||||
"conversationId": "conv123456",
|
||||
"messageId": "msg001",
|
||||
"sendTime": "2025-12-25 10:30:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**: 主动向HR发起沟通
|
||||
|
||||
---
|
||||
|
||||
### 3.6 数据采集指令 (优先级: MEDIUM)
|
||||
|
||||
#### get_job_detail - 获取岗位详情 ⭐⭐⭐
|
||||
|
||||
**指令格式**
|
||||
```json
|
||||
{
|
||||
"action": "get_job_detail",
|
||||
"platform": "boss",
|
||||
"data": {
|
||||
"jobId": "job123456"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "获取成功",
|
||||
"data": {
|
||||
"jobId": "job123456",
|
||||
// 完整的岗位详情(同search_jobs_enhanced中的jobList项)
|
||||
"companyDetail": {
|
||||
"companyId": "company123",
|
||||
"companyName": "XX科技公司",
|
||||
"companyLogo": "https://...",
|
||||
"companySize": "100-499人",
|
||||
"companyIndustry": "互联网",
|
||||
"companyStage": "已上市",
|
||||
"companyAddress": "上海市浦东新区...",
|
||||
"companyDesc": "公司介绍..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**: 获取岗位的完整详情信息
|
||||
|
||||
---
|
||||
|
||||
#### get_company_info - 获取公司信息 ⭐⭐⭐
|
||||
|
||||
**指令格式**
|
||||
```json
|
||||
{
|
||||
"action": "get_company_info",
|
||||
"platform": "boss",
|
||||
"data": {
|
||||
"companyId": "company123"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "获取成功",
|
||||
"data": {
|
||||
"companyId": "company123",
|
||||
"companyName": "XX科技公司",
|
||||
"companyLogo": "https://...",
|
||||
"companySize": "100-499人",
|
||||
"companyIndustry": "互联网",
|
||||
"companyStage": "已上市",
|
||||
"companyAddress": "上海市浦东新区...",
|
||||
"companyDesc": "公司介绍...",
|
||||
"companyBenefit": ["五险一金", "带薪年假"],
|
||||
"companyPhotos": ["https://...", "https://..."],
|
||||
"jobCount": 50,
|
||||
"isVerified": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**: 获取公司的详细信息
|
||||
|
||||
---
|
||||
|
||||
## 四、指令执行规范
|
||||
|
||||
### 4.1 指令生命周期
|
||||
|
||||
```
|
||||
1. 创建 (pending)
|
||||
↓
|
||||
2. 下发 (sent)
|
||||
↓
|
||||
3. 执行中 (executing)
|
||||
↓
|
||||
4. 完成 (completed) / 失败 (failed) / 超时 (timeout)
|
||||
```
|
||||
|
||||
### 4.2 超时设置
|
||||
|
||||
| 指令类型 | 超时时间 | 重试次数 |
|
||||
|---------|----------|----------|
|
||||
| 登录类指令 | 60秒 | 1次 |
|
||||
| 简历获取 | 30秒 | 2次 |
|
||||
| 岗位搜索 | 60秒 | 2次 |
|
||||
| 岗位投递 | 30秒 | 1次 |
|
||||
| 聊天消息 | 30秒 | 2次 |
|
||||
| 保活操作 | 120秒 | 0次 |
|
||||
|
||||
### 4.3 错误码规范
|
||||
|
||||
| 错误码 | 说明 | 处理方式 |
|
||||
|--------|------|----------|
|
||||
| 200 | 成功 | - |
|
||||
| 400 | 参数错误 | 不重试 |
|
||||
| 401 | 未登录 | 触发重新登录 |
|
||||
| 403 | 无权限/已操作 | 不重试 |
|
||||
| 429 | 请求过于频繁 | 延迟后重试 |
|
||||
| 500 | 服务器错误 | 重试 |
|
||||
| 503 | 服务不可用 | 延迟后重试 |
|
||||
| 600 | 网络超时 | 重试 |
|
||||
| 700 | 客户端错误 | 记录日志,不重试 |
|
||||
|
||||
### 4.4 重试策略
|
||||
|
||||
- **指数退避**: `delay = min(1000 * 2^(retryCount-1), 30000ms)`
|
||||
- **最大重试次数**: 根据指令类型决定(见4.2表格)
|
||||
- **可重试错误**: 429, 500, 503, 600
|
||||
- **不可重试错误**: 400, 401, 403, 700
|
||||
|
||||
---
|
||||
|
||||
## 五、客户端实现要求
|
||||
|
||||
### 5.1 MQTT客户端
|
||||
|
||||
- **连接保持**: 断线自动重连
|
||||
- **心跳间隔**: 10秒
|
||||
- **订阅主题**: `{sn_code}/command`
|
||||
- **发布主题**: `response`, `heartbeat`
|
||||
|
||||
### 5.2 指令处理
|
||||
|
||||
1. **接收指令**
|
||||
- 解析JSON格式
|
||||
- 验证必需字段
|
||||
- 记录指令日志
|
||||
|
||||
2. **执行指令**
|
||||
- 根据action分发到对应处理器
|
||||
- 更新执行状态
|
||||
- 捕获异常错误
|
||||
|
||||
3. **返回响应**
|
||||
- 统一响应格式
|
||||
- 包含commandId用于追踪
|
||||
- 返回详细的执行结果
|
||||
|
||||
### 5.3 异常处理
|
||||
|
||||
- **网络异常**: 自动重试
|
||||
- **登录过期**: 通知服务端重新登录
|
||||
- **页面加载失败**: 刷新页面重试
|
||||
- **元素定位失败**: 记录截图,返回错误
|
||||
|
||||
### 5.4 日志记录
|
||||
|
||||
- **请求日志**: 记录所有接收到的指令
|
||||
- **响应日志**: 记录所有返回的响应
|
||||
- **错误日志**: 记录所有异常和错误
|
||||
- **操作日志**: 记录关键操作步骤
|
||||
|
||||
---
|
||||
|
||||
## 六、开发优先级
|
||||
|
||||
### P0 - 立即开发 (投递核心功能)
|
||||
|
||||
1. ✅ `search_jobs_enhanced` - 增强搜索
|
||||
2. ✅ `search_by_url` - URL搜索
|
||||
3. ✅ `batch_apply_jobs` - 批量投递
|
||||
4. ✅ `refresh_resume` - 简历刷新
|
||||
|
||||
### P1 - 短期开发 (保活和聊天)
|
||||
|
||||
5. ✅ `auto_active` - 账号保活
|
||||
6. ✅ `send_greeting` - 发送打招呼
|
||||
7. ✅ `get_chat_detail` - 聊天详情
|
||||
|
||||
### P2 - 中期开发 (数据采集)
|
||||
|
||||
8. ⭐ `get_job_detail` - 岗位详情
|
||||
9. ⭐ `get_company_info` - 公司信息
|
||||
|
||||
---
|
||||
|
||||
**文档维护**: 开发团队
|
||||
**最后更新**: 2025-12-25
|
||||
20
_doc/主流程.md
20
_doc/主流程.md
@@ -1,20 +0,0 @@
|
||||
|
||||
|
||||
# handleAutoDeliverTask ,自动投递岗位
|
||||
|
||||
|
||||
1. 如果 2 小时之内没有获取在线简历 ,则重新获取一下在线简历,没有创建,有则更新
|
||||
|
||||
2. 获取职位列表, 按照用户 简历的信息resume_info 中的 skills expectedLocation ,expectedSalary ,expectedPosition ,workYears ,education location 和 职位类型 job_types 中的 年龄,薪资,距离职位的位置,commonSkills,excludeKeywords ,
|
||||
user_longitude,
|
||||
user_longitude
|
||||
|
||||
job_postings和 经纬度 做距离匹配 ,按照 用户中可以配置 is_salary_priority 优先级 按照权重 占比 过滤
|
||||
defaultValue: [ { "key": "distance", "weight": 50 }, { "key": "salary", "weight": 20 }, { "key": "work_years", "weight": 10 }, { "key": "education", "weight": 20} ]
|
||||
|
||||
3.投递合适匹配的岗位
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,354 +0,0 @@
|
||||
# 任务与指令的区别说明
|
||||
|
||||
## 📋 概述
|
||||
|
||||
在调度系统中,**任务(Task)** 和 **指令(Command)** 是两个不同层次的概念,它们的关系是:**一个任务可以包含多个指令**。
|
||||
|
||||
### ⚠️ 重要说明
|
||||
|
||||
**当前系统实际情况**:
|
||||
- **真正的任务**:目前只有 `auto_deliver`(自动投递任务)是真正的任务,它包含多个步骤和指令
|
||||
- **伪任务**:虽然代码中有 `get_resume`、`get_job_list`、`send_chat`、`apply_job` 等任务处理器,但它们实际上只是包装了单个指令,本质上就是直接执行指令
|
||||
|
||||
**为什么会有伪任务**:
|
||||
1. 统一的任务追踪和日志记录
|
||||
2. 保持接口的一致性
|
||||
3. 未来可能扩展为真正的任务(包含多个步骤)
|
||||
|
||||
## 🔄 层级关系
|
||||
|
||||
```
|
||||
任务(Task)
|
||||
├── 指令1(Command)
|
||||
├── 指令2(Command)
|
||||
└── 指令3(Command)
|
||||
```
|
||||
|
||||
## 📊 详细对比
|
||||
|
||||
| 维度 | 任务(Task) | 指令(Command) |
|
||||
|------|------------|----------------|
|
||||
| **概念层级** | 业务层 | 执行层 |
|
||||
| **数据库表** | `task_status` | `task_commands` |
|
||||
| **管理模块** | TaskQueue(任务队列) | CommandManager(指令管理器) |
|
||||
| **处理模块** | TaskHandlers(任务处理器) | jobManager(业务管理器) |
|
||||
| **粒度** | 粗粒度(业务流程) | 细粒度(具体操作) |
|
||||
| **包含关系** | 包含多个指令 | 属于某个任务 |
|
||||
| **执行方式** | 由任务队列调度 | 由指令管理器执行 |
|
||||
| **通信方式** | 内部调度 | 通过 MQTT 发送到客户端 |
|
||||
|
||||
## 🎯 任务(Task)
|
||||
|
||||
### 定义
|
||||
任务是业务层面的概念,代表一个完整的业务流程或工作单元。
|
||||
|
||||
### 特点
|
||||
- **业务导向**:代表一个完整的业务目标
|
||||
- **可包含多个步骤**:一个任务可以包含多个指令
|
||||
- **有生命周期**:pending → running → completed/failed
|
||||
- **有优先级**:可以设置任务优先级
|
||||
- **有超时机制**:任务级别有超时保护
|
||||
|
||||
### 任务类型示例
|
||||
|
||||
**真正的任务(包含多个步骤)**:
|
||||
- `auto_deliver` - 自动投递任务(包含多个子操作:获取简历、获取岗位列表、筛选职位、批量投递)
|
||||
- `auto_chat` - 自动沟通任务(待实现:自动与HR进行沟通,回复消息等)
|
||||
- `auto_active_account` - 自动活跃账号任务(待实现:自动执行操作保持账号活跃度)
|
||||
|
||||
**注意**:目前系统中只有 `auto_deliver` 是已实现的真正任务,`auto_chat` 和 `auto_active_account` 是待实现的任务框架。
|
||||
|
||||
### 任务表结构(task_status)
|
||||
```javascript
|
||||
{
|
||||
id: 1,
|
||||
sn_code: 'GHJU',
|
||||
taskType: 'auto_deliver',
|
||||
taskName: '自动投递 - 前端开发',
|
||||
status: 'running',
|
||||
priority: 7,
|
||||
taskParams: { keyword: '前端', platform: 'boss' },
|
||||
result: {},
|
||||
startTime: '2024-01-01 10:00:00',
|
||||
endTime: null,
|
||||
duration: 0
|
||||
}
|
||||
```
|
||||
|
||||
### 任务执行流程
|
||||
```javascript
|
||||
// 1. 添加任务到队列
|
||||
await taskQueue.addTask(sn_code, {
|
||||
taskType: 'auto_deliver',
|
||||
taskName: '自动投递',
|
||||
taskParams: { keyword: '前端' }
|
||||
});
|
||||
|
||||
// 2. 任务队列调度执行
|
||||
// 3. 任务处理器处理任务
|
||||
// 4. 任务处理器创建并执行指令
|
||||
```
|
||||
|
||||
## ⚙️ 指令(Command)
|
||||
|
||||
### 定义
|
||||
指令是执行层面的概念,代表一个具体的操作,通过 MQTT 发送到客户端执行。
|
||||
|
||||
### 特点
|
||||
- **执行导向**:代表一个具体的操作
|
||||
- **原子性**:一个指令是一个不可分割的操作
|
||||
- **有执行顺序**:指令可以按顺序执行
|
||||
- **有超时机制**:指令级别有超时保护
|
||||
- **MQTT 通信**:通过 MQTT 发送到客户端
|
||||
|
||||
### 指令类型示例
|
||||
- `getOnlineResume` - 获取在线简历
|
||||
- `getJobList` - 获取岗位列表
|
||||
- `applyJob` - 投递简历
|
||||
- `sendChatMessage` - 发送聊天消息
|
||||
- `getLoginQrCode` - 获取登录二维码
|
||||
|
||||
### 指令表结构(task_commands)
|
||||
```javascript
|
||||
{
|
||||
id: 1,
|
||||
task_id: 100, // 关联的任务ID
|
||||
command_type: 'getOnlineResume',
|
||||
command_name: '获取在线简历',
|
||||
command_params: '{"sn_code":"GHJU"}',
|
||||
status: 'completed',
|
||||
sequence: 1,
|
||||
priority: 9,
|
||||
start_time: '2024-01-01 10:00:00',
|
||||
end_time: '2024-01-01 10:00:30',
|
||||
duration: 30000
|
||||
}
|
||||
```
|
||||
|
||||
### 指令执行流程
|
||||
```javascript
|
||||
// 1. 任务处理器创建指令
|
||||
const commands = [{
|
||||
command_type: 'getOnlineResume',
|
||||
command_name: '获取在线简历',
|
||||
command_params: JSON.stringify({ sn_code })
|
||||
}];
|
||||
|
||||
// 2. 指令管理器执行指令
|
||||
await command.executeCommands(taskId, commands, mqttClient);
|
||||
|
||||
// 3. 通过 MQTT 发送到客户端
|
||||
// 4. 客户端执行并返回结果
|
||||
```
|
||||
|
||||
## 🔗 关系示例
|
||||
|
||||
### 示例1:自动投递任务
|
||||
|
||||
**任务**:`auto_deliver`(自动投递任务)
|
||||
|
||||
**包含的指令**:
|
||||
1. `getOnlineResume` - 获取在线简历
|
||||
2. `getJobList` - 获取岗位列表
|
||||
3. `applyJob` - 投递简历(可能多个)
|
||||
|
||||
```javascript
|
||||
// 任务处理器创建多个指令
|
||||
async handleAutoDeliverTask(task) {
|
||||
// 1. 获取简历指令
|
||||
const getResumeCommand = {
|
||||
command_type: 'getOnlineResume',
|
||||
command_name: '获取在线简历',
|
||||
...
|
||||
};
|
||||
|
||||
// 2. 获取岗位列表指令
|
||||
const getJobListCommand = {
|
||||
command_type: 'getJobList',
|
||||
command_name: '获取岗位列表',
|
||||
...
|
||||
};
|
||||
|
||||
// 3. 投递指令(可能多个)
|
||||
const applyCommands = jobs.map(job => ({
|
||||
command_type: 'applyJob',
|
||||
command_name: `投递简历 - ${job.jobTitle}`,
|
||||
...
|
||||
}));
|
||||
|
||||
// 执行所有指令
|
||||
await command.executeCommands(task.id, [
|
||||
getResumeCommand,
|
||||
getJobListCommand,
|
||||
...applyCommands
|
||||
], mqttClient);
|
||||
}
|
||||
```
|
||||
|
||||
### 示例2:获取简历(伪任务,实际是指令)
|
||||
|
||||
**说明**:虽然代码中有 `get_resume` 任务处理器,但它实际上只是包装了单个指令,本质上就是直接执行指令。
|
||||
|
||||
**任务**:`get_resume`(获取简历任务)
|
||||
|
||||
**包含的指令**:
|
||||
1. `getOnlineResume` - 获取在线简历
|
||||
|
||||
```javascript
|
||||
async handleGetResumeTask(task) {
|
||||
// 实际上只是创建一个指令并执行
|
||||
const commands = [{
|
||||
command_type: 'getOnlineResume',
|
||||
command_name: '获取在线简历',
|
||||
command_params: JSON.stringify({ sn_code: task.sn_code })
|
||||
}];
|
||||
|
||||
await command.executeCommands(task.id, commands, this.mqttClient);
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:这种"任务"实际上可以直接作为指令执行,不需要通过任务队列。它们存在的原因可能是为了:
|
||||
1. 统一的任务追踪和日志记录
|
||||
2. 未来可能扩展为真正的任务(包含多个步骤)
|
||||
3. 保持接口的一致性
|
||||
|
||||
## 📈 执行流程图
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ 任务队列 │
|
||||
│ (TaskQueue) │
|
||||
└────────┬────────┘
|
||||
│ 调度任务
|
||||
↓
|
||||
┌─────────────────┐
|
||||
│ 任务处理器 │
|
||||
│ (TaskHandlers) │
|
||||
└────────┬────────┘
|
||||
│ 创建指令
|
||||
↓
|
||||
┌─────────────────┐
|
||||
│ 指令管理器 │
|
||||
│ (CommandManager)│
|
||||
└────────┬────────┘
|
||||
│ 执行指令
|
||||
↓
|
||||
┌─────────────────┐
|
||||
│ 业务管理器 │
|
||||
│ (jobManager) │
|
||||
└────────┬────────┘
|
||||
│ MQTT 发送
|
||||
↓
|
||||
┌─────────────────┐
|
||||
│ 客户端设备 │
|
||||
│ (Python Client)│
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## 🎨 设计优势
|
||||
|
||||
### 1. **职责分离**
|
||||
- **任务层**:负责业务逻辑和流程编排
|
||||
- **指令层**:负责具体操作和 MQTT 通信
|
||||
|
||||
### 2. **灵活性**
|
||||
- 一个任务可以包含不同数量的指令
|
||||
- 可以根据业务需求动态创建指令
|
||||
|
||||
### 3. **可追踪性**
|
||||
- 任务级别:可以追踪整个业务流程
|
||||
- 指令级别:可以追踪每个具体操作
|
||||
|
||||
### 4. **错误处理**
|
||||
- 任务级别:处理业务逻辑错误
|
||||
- 指令级别:处理执行错误和超时
|
||||
|
||||
## 📝 代码示例
|
||||
|
||||
### 任务处理器创建指令
|
||||
|
||||
```javascript
|
||||
// api/middleware/schedule/taskHandlers.js
|
||||
async handleAutoDeliverTask(task) {
|
||||
const { sn_code, taskParams } = task;
|
||||
|
||||
// 1. 创建获取简历指令
|
||||
const getResumeCommand = {
|
||||
command_type: 'getOnlineResume',
|
||||
command_name: '获取在线简历',
|
||||
command_params: JSON.stringify({ sn_code, platform: 'boss' })
|
||||
};
|
||||
|
||||
// 2. 创建获取岗位列表指令
|
||||
const getJobListCommand = {
|
||||
command_type: 'getJobList',
|
||||
command_name: '获取岗位列表',
|
||||
command_params: JSON.stringify({
|
||||
sn_code,
|
||||
keyword: taskParams.keyword,
|
||||
platform: 'boss'
|
||||
})
|
||||
};
|
||||
|
||||
// 3. 执行指令序列
|
||||
const result = await command.executeCommands(
|
||||
task.id,
|
||||
[getResumeCommand, getJobListCommand],
|
||||
this.mqttClient
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
### 指令管理器执行指令
|
||||
|
||||
```javascript
|
||||
// api/middleware/schedule/command.js
|
||||
async executeCommand(taskId, command, mqttClient) {
|
||||
// 1. 创建指令记录
|
||||
const commandRecord = await db.getModel('task_commands').create({
|
||||
task_id: taskId,
|
||||
command_type: command.command_type,
|
||||
command_name: command.command_name,
|
||||
status: 'pending'
|
||||
});
|
||||
|
||||
// 2. 调用业务管理器执行
|
||||
const result = await jobManager[commandType](
|
||||
sn_code,
|
||||
mqttClient,
|
||||
commandParams
|
||||
);
|
||||
|
||||
// 3. 更新指令状态
|
||||
await this.updateCommandStatus(commandId, 'completed', result);
|
||||
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
## 🔍 总结
|
||||
|
||||
- **任务(Task)**:业务层面的工作单元,代表一个完整的业务流程
|
||||
- **真正的任务**:包含多个步骤/指令,如 `auto_deliver`
|
||||
- **伪任务**:虽然叫任务,但实际只是包装了单个指令,如 `get_resume`、`get_job_list` 等
|
||||
|
||||
- **指令(Command)**:执行层面的操作单元,代表一个具体的操作
|
||||
- 通过 MQTT 发送到客户端执行
|
||||
- 如:`getOnlineResume`、`getJobList`、`applyJob` 等
|
||||
|
||||
- **关系**:
|
||||
- 真正的任务包含多个指令,指令按顺序执行
|
||||
- 伪任务只是指令的包装,本质上就是直接执行指令
|
||||
|
||||
- **管理**:任务由任务队列管理,指令由指令管理器管理
|
||||
|
||||
- **通信**:任务在服务端内部调度,指令通过 MQTT 发送到客户端
|
||||
|
||||
- **当前状态**:
|
||||
- 目前系统中只有 `auto_deliver` 是真正的任务(包含多个步骤)
|
||||
- 其他如 `get_resume`、`get_job_list`、`send_chat`、`apply_job` 虽然叫任务,但实际只是指令的包装
|
||||
|
||||
这种设计实现了业务逻辑和执行逻辑的分离,提高了系统的灵活性和可维护性。伪任务的存在可能是为了统一的任务追踪和未来扩展。
|
||||
|
||||
874
_doc/功能规划文档.md
874
_doc/功能规划文档.md
@@ -1,874 +0,0 @@
|
||||
# 自动找工作系统 - 功能规划文档
|
||||
|
||||
> 版本: v1.0 | 规划日期: 2025-12-25 | 状态: 待开发
|
||||
|
||||
## 文档说明
|
||||
|
||||
本文档规划了自动找工作系统的未开发功能,按优先级分为4个方向共20项功能。每项功能包含现状分析、待开发内容和预期价值,可直接转化为开发任务。
|
||||
|
||||
## 优先级说明
|
||||
|
||||
- **HIGH**: 核心功能,对系统价值提升明显,建议优先开发
|
||||
- **MEDIUM**: 重要优化,提升用户体验和系统性能,可分阶段实施
|
||||
- **LOW**: 未来规划,可根据实际需求决定是否开发
|
||||
|
||||
---
|
||||
|
||||
## 第一部分: 功能完善和补充
|
||||
|
||||
**优先级: HIGH** | **预计工期: 4-6周**
|
||||
|
||||
### 1.1 自动聊天功能完善 ⭐⭐⭐⭐⭐
|
||||
|
||||
**现状分析**
|
||||
- ✅ 聊天记录表结构完整 (`chat_records`)
|
||||
- ✅ AI聊天内容生成基础框架 (`aiService.generateChatContent`)
|
||||
- ✅ 聊天类型分类 (greeting/followup/interview/reply)
|
||||
- ❌ AI生成器未完整实现
|
||||
- ❌ 聊天时机判断逻辑缺失
|
||||
- ❌ 多轮对话上下文管理未实现
|
||||
|
||||
**待开发内容**
|
||||
1. **AI聊天内容生成器完整实现**
|
||||
- 完善 Prompt 模板(不同场景)
|
||||
- 集成简历信息和岗位描述
|
||||
- 个性化内容生成(根据HR回复调整策略)
|
||||
- 长度和语气控制
|
||||
|
||||
2. **聊天时机智能判断**
|
||||
- HR查看后多久发消息(规则+AI预测)
|
||||
- 避免过于频繁或过晚联系
|
||||
- 根据不同平台特性调整策略
|
||||
- 工作时间优先发送
|
||||
|
||||
3. **多轮对话上下文管理**
|
||||
- 记录对话历史
|
||||
- 上下文理解(避免重复询问)
|
||||
- 话题延续和自然过渡
|
||||
- 面试邀约智能识别和响应
|
||||
|
||||
4. **情感分析和回复策略调整**
|
||||
- 分析HR回复的情感倾向
|
||||
- 根据情感调整后续策略
|
||||
- 识别拒绝信号(及时停止沟通)
|
||||
- 识别兴趣信号(加大沟通力度)
|
||||
|
||||
**技术实现**
|
||||
- 文件路径: `api/middleware/job/chatManager.js`
|
||||
- 依赖: `aiService.js`, `chat_records表`
|
||||
- 预计工期: 2周
|
||||
|
||||
**预期价值**
|
||||
- 📈 HR回复率提升 30%+
|
||||
- 📈 面试邀约率提升 20%+
|
||||
- 💡 减少人工沟通成本 80%+
|
||||
- ✨ 提供24小时自动化沟通能力
|
||||
|
||||
---
|
||||
|
||||
### 1.2 账号保活任务 ⭐⭐⭐⭐⭐
|
||||
|
||||
**现状分析**
|
||||
- ✅ 配置项已有 (`pla_account.auto_active`, `active_interval`, `active_actions_json`)
|
||||
- ✅ 任务类型定义 (`auto_active_account`)
|
||||
- ❌ 执行逻辑未实现
|
||||
- ❌ 行为模拟策略缺失
|
||||
|
||||
**待开发内容**
|
||||
1. **定时浏览岗位模拟真实用户**
|
||||
- 随机浏览岗位详情
|
||||
- 模拟点击、滚动行为
|
||||
- 页面停留时间随机化(10-60秒)
|
||||
- 每日浏览次数控制(5-20次)
|
||||
|
||||
2. **随机时间间隔访问**
|
||||
- 避免固定时间访问(容易被识别)
|
||||
- 工作时间随机分布
|
||||
- 模拟午休和下班后的访问
|
||||
- 周末降低活跃频率
|
||||
|
||||
3. **多样化操作行为**
|
||||
- 搜索岗位(随机关键词)
|
||||
- 查看推荐岗位
|
||||
- 浏览公司主页
|
||||
- 修改简历可见性
|
||||
- 更新最后活跃时间
|
||||
|
||||
4. **避免账号被标记为机器人**
|
||||
- 行为模式随机化
|
||||
- 添加鼠标轨迹模拟
|
||||
- 操作速度人性化(不要太快)
|
||||
- 避免连续大量操作
|
||||
|
||||
**技术实现**
|
||||
- 文件路径: `api/middleware/schedule/taskHandlers.js` (新增 `handleAutoActiveTask`)
|
||||
- MQTT指令: 新增 `auto_active` 操作类型
|
||||
- 预计工期: 1周
|
||||
|
||||
**预期价值**
|
||||
- 📉 账号封禁风险降低 70%+
|
||||
- 📈 简历曝光率提升 40%+
|
||||
- 🔒 账号在线状态保持稳定
|
||||
- ✨ 自动维护账号活跃度
|
||||
|
||||
---
|
||||
|
||||
### 1.3 简历自动更新 ⭐⭐⭐⭐
|
||||
|
||||
**现状分析**
|
||||
- ✅ 简历同步功能完整
|
||||
- ✅ 简历信息存储完善
|
||||
- ❌ 简历刷新逻辑未实现
|
||||
- ❌ 简历优化建议缺失
|
||||
|
||||
**待开发内容**
|
||||
1. **定时刷新简历排名**
|
||||
- 每天自动刷新简历(提升排名)
|
||||
- 最佳刷新时间智能选择(如早上9点)
|
||||
- 通过MQTT下发刷新指令
|
||||
- 记录刷新历史和效果
|
||||
|
||||
2. **简历内容优化建议**
|
||||
- AI分析当前简历不足
|
||||
- 给出具体优化建议(哪些技能需要补充)
|
||||
- 对比同类岗位的简历特征
|
||||
- 建议调整项目经验描述
|
||||
|
||||
3. **A/B测试不同简历版本效果**
|
||||
- 支持多个简历版本
|
||||
- 自动切换测试
|
||||
- 统计不同版本的查看率和回复率
|
||||
- 推荐最优版本
|
||||
|
||||
**技术实现**
|
||||
- 文件路径: `api/middleware/job/resumeManager.js` (新增刷新方法)
|
||||
- MQTT指令: 新增 `refresh_resume` 操作
|
||||
- 数据库: `resume_info` 新增 `last_refresh_time` 字段
|
||||
- 预计工期: 1周
|
||||
|
||||
**预期价值**
|
||||
- 📈 简历曝光率提升 50%+
|
||||
- 📈 查看率提升 30%+
|
||||
- 💡 简历质量持续优化
|
||||
- ✨ 自动维护简历新鲜度
|
||||
|
||||
---
|
||||
|
||||
### 1.4 岗位黑名单和收藏 ⭐⭐⭐⭐
|
||||
|
||||
**现状分析**
|
||||
- ❌ 黑名单功能未实现
|
||||
- ❌ 收藏功能未实现
|
||||
- ❌ 岗位对比功能缺失
|
||||
|
||||
**待开发内容**
|
||||
1. **公司黑名单**
|
||||
- 不再投递某公司的岗位
|
||||
- 黑名单原因记录(薪资虚标、工作内容不符等)
|
||||
- 支持批量添加
|
||||
- 黑名单导入导出
|
||||
|
||||
2. **岗位类型黑名单**
|
||||
- 不再投递某类岗位(如外包、销售)
|
||||
- 支持自定义黑名单关键词
|
||||
- 黑名单优先级高于匹配规则
|
||||
|
||||
3. **收藏感兴趣岗位**
|
||||
- 标记收藏岗位
|
||||
- 收藏原因备注
|
||||
- 收藏夹分类管理
|
||||
- 收藏岗位状态跟踪(是否还在招聘)
|
||||
|
||||
4. **岗位对比功能**
|
||||
- 多个岗位并排对比
|
||||
- 对比维度: 薪资、技能要求、公司、地点、福利
|
||||
- AI给出推荐意见
|
||||
- 导出对比报告
|
||||
|
||||
**技术实现**
|
||||
- 数据库: 新增 `job_blacklist`, `job_favorites` 表
|
||||
- 文件路径: `api/controller_admin/` 新增相关API
|
||||
- 前端: `admin/src/views/work/` 新增黑名单和收藏页面
|
||||
- 预计工期: 1周
|
||||
|
||||
**预期价值**
|
||||
- 📈 投递精准度提升 40%+
|
||||
- 📉 无效投递减少 60%+
|
||||
- 💡 提供个性化岗位管理
|
||||
- ✨ 提高求职效率
|
||||
|
||||
---
|
||||
|
||||
### 1.5 多轮面试跟踪 ⭐⭐⭐⭐
|
||||
|
||||
**现状分析**
|
||||
- ✅ `apply_records` 表有 `hasInterview` 字段
|
||||
- ❌ 只记录是否有面试,未细分轮次
|
||||
- ❌ Offer管理功能缺失
|
||||
- ❌ 入职状态未追踪
|
||||
|
||||
**待开发内容**
|
||||
1. **一面/二面/三面状态追踪**
|
||||
- 新增面试轮次字段 (`interview_round`)
|
||||
- 每轮面试时间、地点、面试官记录
|
||||
- 面试类型(电话、视频、现场)
|
||||
- 面试状态(待面试、已面试、通过、淘汰)
|
||||
|
||||
2. **面试反馈记录**
|
||||
- 面试官反馈内容
|
||||
- 面试问题记录
|
||||
- 自我评价
|
||||
- 改进建议
|
||||
|
||||
3. **Offer管理**
|
||||
- Offer详情(薪资、福利、入职时间)
|
||||
- 接受/拒绝/谈薪状态
|
||||
- 谈薪记录(几轮谈判,最终结果)
|
||||
- Offer对比(如有多个Offer)
|
||||
|
||||
4. **入职状态追踪**
|
||||
- 入职日期
|
||||
- 试用期状态
|
||||
- 转正时间
|
||||
- 离职时间(如有)
|
||||
|
||||
**技术实现**
|
||||
- 数据库: `apply_records` 新增字段或新增 `interview_records`, `offer_records` 表
|
||||
- 文件路径: `api/controller_admin/apply_records.js` 扩展
|
||||
- 前端: `admin/src/views/work/apply_records.vue` 新增详情面板
|
||||
- 预计工期: 1.5周
|
||||
|
||||
**预期价值**
|
||||
- 📊 完整的求职生命周期管理
|
||||
- 📈 面试准备更充分
|
||||
- 💡 Offer决策更科学
|
||||
- ✨ 提供长期职业数据积累
|
||||
|
||||
---
|
||||
|
||||
## 第二部分: AI能力增强
|
||||
|
||||
**优先级: HIGH** | **预计工期: 5-7周**
|
||||
|
||||
### 2.1 简历智能优化 ⭐⭐⭐⭐⭐
|
||||
|
||||
**现状分析**
|
||||
- ✅ 简历AI分析基础功能 (`aiService.analyzeResume`)
|
||||
- ✅ 竞争力评分、技能提取、优劣势分析
|
||||
- ❌ 优化建议深度不足
|
||||
- ❌ 针对性改进方案缺失
|
||||
|
||||
**待开发内容**
|
||||
1. **AI简历分析深度提升**
|
||||
- 细粒度分析(每个项目、每段经历)
|
||||
- 识别简历中的弱项和亮点
|
||||
- 对比行业优秀简历
|
||||
- 生成详细分析报告
|
||||
|
||||
2. **简历改进建议(针对性)**
|
||||
- 针对目标岗位给出定制化建议
|
||||
- 建议补充哪些技能关键词
|
||||
- 建议如何重写工作描述
|
||||
- 建议哪些内容需要精简
|
||||
|
||||
3. **技能关键词优化建议**
|
||||
- 分析热门技能关键词
|
||||
- 建议替换为更专业的术语
|
||||
- 建议技能顺序排列
|
||||
- 建议补充相关技能
|
||||
|
||||
4. **项目经验描述优化**
|
||||
- 使用STAR法则重写项目描述
|
||||
- 量化项目成果(如提升XX%性能)
|
||||
- 突出个人贡献和技术难点
|
||||
- 精简冗长描述
|
||||
|
||||
**技术实现**
|
||||
- 文件路径: `api/middleware/job/aiService.js` 新增 `optimizeResume` 方法
|
||||
- Prompt工程: 设计专业的简历优化Prompt
|
||||
- 前端: `resume_info_detail.vue` 新增优化建议面板
|
||||
- 预计工期: 2周
|
||||
|
||||
**预期价值**
|
||||
- 📈 简历竞争力提升 20%+
|
||||
- 📈 查看率提升 35%+
|
||||
- 📈 回复率提升 25%+
|
||||
- 💡 简历质量专业化
|
||||
|
||||
---
|
||||
|
||||
### 2.2 面试问题预测 ⭐⭐⭐⭐
|
||||
|
||||
**待开发内容**
|
||||
1. **基于岗位描述预测面试问题**
|
||||
- 分析岗位职责和要求
|
||||
- 预测技术问题(如React性能优化、数据库索引等)
|
||||
- 预测行为问题(如团队合作、项目经验)
|
||||
- 预测HR问题(如离职原因、职业规划)
|
||||
|
||||
2. **提供参考答案**
|
||||
- 给出专业、结构化的答案
|
||||
- 提供多种回答思路
|
||||
- 标注答案亮点和注意事项
|
||||
- 面试官可能的追问
|
||||
|
||||
3. **根据简历生成个性化回答**
|
||||
- 结合简历中的项目经验
|
||||
- 使用简历中的真实案例
|
||||
- 避免空洞的回答
|
||||
- 突出个人优势
|
||||
|
||||
**技术实现**
|
||||
- 文件路径: `api/middleware/job/aiService.js` 新增 `predictInterviewQuestions` 方法
|
||||
- 数据来源: `job_postings.jobDescription`, `resume_info`
|
||||
- 前端: 新增 `interview_prep.vue` 面试准备页面
|
||||
- 预计工期: 1.5周
|
||||
|
||||
**预期价值**
|
||||
- 📈 面试通过率提升 30%+
|
||||
- ⏱️ 面试准备时间减少 70%+
|
||||
- 💡 回答更专业、更自信
|
||||
- ✨ 提供全方位面试辅导
|
||||
|
||||
---
|
||||
|
||||
### 2.3 薪资谈判策略 ⭐⭐⭐⭐
|
||||
|
||||
**待开发内容**
|
||||
1. **AI分析岗位薪资合理范围**
|
||||
- 根据岗位要求和地区计算合理薪资
|
||||
- 参考行业薪资数据(如拉勾、Boss薪资报告)
|
||||
- 考虑公司规模和融资阶段
|
||||
- 给出薪资范围建议(如18-22K)
|
||||
|
||||
2. **给出谈薪建议和话术**
|
||||
- 什么时候开始谈薪(面试哪个阶段)
|
||||
- 如何提薪资要求(不卑不亢)
|
||||
- 谈判策略(如先等对方报价)
|
||||
- 具体话术模板
|
||||
|
||||
3. **根据市场行情评估Offer价值**
|
||||
- 对比市场平均薪资
|
||||
- 综合评估(薪资+福利+发展空间)
|
||||
- 识别低于市场价的Offer
|
||||
- 给出接受/拒绝建议
|
||||
|
||||
**技术实现**
|
||||
- 文件路径: `api/middleware/job/aiService.js` 新增 `analyzeSalary` 方法
|
||||
- 数据源: 岗位描述、地区、公司、市场数据
|
||||
- 前端: `offer_analysis.vue` Offer分析页面
|
||||
- 预计工期: 1周
|
||||
|
||||
**预期价值**
|
||||
- 💰 薪资平均提升 10-15%
|
||||
- 📈 谈判成功率提升 40%+
|
||||
- 💡 避免接受低薪Offer
|
||||
- ✨ 提供科学的薪资决策
|
||||
|
||||
---
|
||||
|
||||
### 2.4 公司背景调查 ⭐⭐⭐⭐
|
||||
|
||||
**待开发内容**
|
||||
1. **整合企查查/天眼查数据**
|
||||
- 调用企查查API获取公司信息
|
||||
- 公司基本信息(注册资本、成立时间、法人)
|
||||
- 融资情况(融资轮次、投资方)
|
||||
- 诉讼记录、欠薪记录
|
||||
|
||||
2. **AI分析公司发展前景**
|
||||
- 根据融资情况评估发展阶段
|
||||
- 根据招聘规模判断业务状态
|
||||
- 根据行业趋势预测前景
|
||||
- 给出公司评级(A/B/C/D)
|
||||
|
||||
3. **风险预警**
|
||||
- 识别高风险公司(如多次欠薪、频繁裁员)
|
||||
- 识别虚假招聘(如招聘周期异常长)
|
||||
- 识别外包公司(尽管岗位描述未标注)
|
||||
- 给出风险提示和建议
|
||||
|
||||
**技术实现**
|
||||
- 文件路径: `api/services/company_background_service.js`
|
||||
- 数据源: 企查查API、`company_info` 表
|
||||
- 前端: `job_postings.vue` 新增公司背调入口
|
||||
- 预计工期: 1.5周
|
||||
|
||||
**预期价值**
|
||||
- 📉 入职风险降低 80%+
|
||||
- 💡 避免进入高风险公司
|
||||
- ✨ 提供全面的公司情报
|
||||
- 🔍 识别隐藏的问题公司
|
||||
|
||||
---
|
||||
|
||||
### 2.5 职业发展路径 ⭐⭐⭐
|
||||
|
||||
**待开发内容**
|
||||
1. **基于简历和目标生成职业规划**
|
||||
- 分析当前职业阶段(初级/中级/高级)
|
||||
- 根据目标岗位生成发展路径
|
||||
- 列出需要补充的技能和经验
|
||||
- 给出时间线规划(1年/3年/5年)
|
||||
|
||||
2. **技能提升建议**
|
||||
- 推荐学习资源(课程、书籍、开源项目)
|
||||
- 建议考取的证书
|
||||
- 建议参加的技术社区
|
||||
- 建议做的练手项目
|
||||
|
||||
3. **转行可行性分析**
|
||||
- 评估转行难度
|
||||
- 分析已有技能的可迁移性
|
||||
- 给出转行路径建议
|
||||
- 预测转行后的薪资变化
|
||||
|
||||
**技术实现**
|
||||
- 文件路径: `api/middleware/job/aiService.js` 新增 `generateCareerPath` 方法
|
||||
- 数据源: `resume_info`, 目标岗位描述
|
||||
- 前端: 新增 `career_plan.vue` 职业规划页面
|
||||
- 预计工期: 1.5周
|
||||
|
||||
**预期价值**
|
||||
- 📊 提供长期职业指导
|
||||
- 💡 明确发展方向和目标
|
||||
- 📈 技能提升更有针对性
|
||||
- ✨ 降低职业迷茫感
|
||||
|
||||
---
|
||||
|
||||
## 第三部分: 用户体验优化
|
||||
|
||||
**优先级: MEDIUM** | **预计工期: 4-5周**
|
||||
|
||||
### 3.1 数据可视化增强 ⭐⭐⭐⭐
|
||||
|
||||
**待开发内容**
|
||||
1. **投递转化漏斗图**
|
||||
- 投递数 → 查看数 → 回复数 → 面试数 → Offer数
|
||||
- 每个环节的转化率
|
||||
- 识别漏斗中的薄弱环节
|
||||
- 对比不同平台的漏斗差异
|
||||
|
||||
2. **面试成功率趋势**
|
||||
- 按时间展示面试通过率变化
|
||||
- 分析成功率提升/下降原因
|
||||
- 识别面试表现最好的时间段
|
||||
- 给出改进建议
|
||||
|
||||
3. **薪资分布统计**
|
||||
- 投递岗位的薪资分布
|
||||
- Offer薪资分布
|
||||
- 对比期望薪资和实际薪资
|
||||
- 不同技能栈的薪资对比
|
||||
|
||||
4. **不同平台效果对比**
|
||||
- Boss、猎聘等平台的效果对比
|
||||
- 投递量、回复率、面试率对比
|
||||
- 平台特点分析
|
||||
- 推荐最优平台
|
||||
|
||||
**技术实现**
|
||||
- 前端: `admin/src/views/statistics/` 新增统计页面
|
||||
- ECharts图表: 漏斗图、折线图、柱状图、饼图
|
||||
- API: `statistics_server.js` 新增统计接口
|
||||
- 预计工期: 1周
|
||||
|
||||
**预期价值**
|
||||
- 📊 数据洞察更直观
|
||||
- 💡 快速发现问题环节
|
||||
- 📈 数据驱动优化决策
|
||||
- ✨ 提供专业的数据分析
|
||||
|
||||
---
|
||||
|
||||
### 3.2 实时通知系统 ⭐⭐⭐⭐
|
||||
|
||||
**待开发内容**
|
||||
1. **浏览器通知**
|
||||
- 面试邀约即时通知
|
||||
- Offer通知
|
||||
- 重要聊天消息通知
|
||||
- 任务执行状态通知
|
||||
|
||||
2. **邮件通知**
|
||||
- 每日投递报告
|
||||
- 面试提醒(提前1天)
|
||||
- 重要事件邮件
|
||||
- 周报月报
|
||||
|
||||
3. **企业微信/钉钉集成**
|
||||
- 通过企业微信机器人推送
|
||||
- 支持快捷操作(如快速回复)
|
||||
- 群组通知
|
||||
- @指定人员
|
||||
|
||||
4. **关键事件提醒**
|
||||
- 面试邀约(立即通知)
|
||||
- Offer(立即通知)
|
||||
- 简历被查看(可配置)
|
||||
- 任务执行失败(立即通知)
|
||||
|
||||
**技术实现**
|
||||
- 文件路径: `api/services/notification_service.js`
|
||||
- 浏览器通知: Web Notification API
|
||||
- 邮件: nodemailer
|
||||
- 企业微信: 企业微信Webhook
|
||||
- 预计工期: 1.5周
|
||||
|
||||
**预期价值**
|
||||
- ⏱️ 响应时间缩短 90%+
|
||||
- 📲 不错过任何重要消息
|
||||
- 💡 多渠道及时触达
|
||||
- ✨ 提供主动式消息推送
|
||||
|
||||
---
|
||||
|
||||
### 3.3 批量操作功能 ⭐⭐⭐
|
||||
|
||||
**待开发内容**
|
||||
1. **批量启用/禁用账号**
|
||||
- 勾选多个账号
|
||||
- 一键启用/禁用
|
||||
- 批量设置自动化开关
|
||||
- 操作日志记录
|
||||
|
||||
2. **批量设置投递策略**
|
||||
- 批量修改投递时间范围
|
||||
- 批量修改每日上限
|
||||
- 批量设置关键词过滤
|
||||
- 应用模板到多个账号
|
||||
|
||||
3. **批量导出数据**
|
||||
- 勾选导出字段
|
||||
- 导出为CSV/Excel
|
||||
- 定时导出任务
|
||||
- 导出历史管理
|
||||
|
||||
**技术实现**
|
||||
- 前端: 各列表页面新增批量操作工具栏
|
||||
- API: 各模块新增批量操作接口
|
||||
- 预计工期: 1周
|
||||
|
||||
**预期价值**
|
||||
- ⏱️ 管理效率提升 80%+
|
||||
- 💡 降低重复操作
|
||||
- ✨ 提供便捷的批量工具
|
||||
|
||||
---
|
||||
|
||||
### 3.4 移动端适配 ⭐⭐⭐
|
||||
|
||||
**待开发内容**
|
||||
1. **响应式布局优化**
|
||||
- 适配手机、平板屏幕
|
||||
- 菜单改为抽屉式
|
||||
- 表格改为卡片式
|
||||
- 图表自适应尺寸
|
||||
|
||||
2. **移动端专属操作界面**
|
||||
- 大图标按钮
|
||||
- 手势操作(滑动、长按)
|
||||
- 底部操作栏
|
||||
- 快捷入口
|
||||
|
||||
3. **快捷操作入口**
|
||||
- 快捷查看今日统计
|
||||
- 快捷查看任务状态
|
||||
- 快捷回复聊天
|
||||
- 快捷查看面试安排
|
||||
|
||||
**技术实现**
|
||||
- 前端: 响应式CSS
|
||||
- 使用 iView 的响应式组件
|
||||
- 新增移动端专属组件
|
||||
- 预计工期: 1.5周
|
||||
|
||||
**预期价值**
|
||||
- 📱 随时随地管理
|
||||
- 💡 提升移动端体验
|
||||
- ✨ 扩大使用场景
|
||||
|
||||
---
|
||||
|
||||
### 3.5 智能推荐 ⭐⭐⭐
|
||||
|
||||
**待开发内容**
|
||||
1. **推荐最适合的岗位**
|
||||
- 基于简历和历史投递记录
|
||||
- AI预测岗位匹配度
|
||||
- 推荐优先投递的岗位
|
||||
- 推荐理由说明
|
||||
|
||||
2. **推荐最佳投递时间**
|
||||
- 分析不同时间投递的效果
|
||||
- 推荐最佳投递时段
|
||||
- 避开竞争激烈的时段
|
||||
- 根据平台特性调整
|
||||
|
||||
3. **推荐优化策略**
|
||||
- 分析数据找出问题
|
||||
- 推荐具体改进措施
|
||||
- 预测改进后的效果
|
||||
- 跟踪改进效果
|
||||
|
||||
**技术实现**
|
||||
- 文件路径: `api/middleware/job/recommendService.js`
|
||||
- AI模型: 基于历史数据训练
|
||||
- 前端: 首页新增推荐面板
|
||||
- 预计工期: 1周
|
||||
|
||||
**预期价值**
|
||||
- 📈 投递效果提升 25%+
|
||||
- 💡 降低决策成本
|
||||
- ✨ 提供智能化建议
|
||||
|
||||
---
|
||||
|
||||
## 第四部分: 系统性能提升
|
||||
|
||||
**优先级: MEDIUM** | **预计工期: 3-4周**
|
||||
|
||||
### 4.1 缓存策略优化 ⭐⭐⭐⭐
|
||||
|
||||
**待开发内容**
|
||||
1. **Redis缓存热点数据**
|
||||
- 缓存职位类型配置(5分钟→实时)
|
||||
- 缓存统计数据(避免重复计算)
|
||||
- 缓存简历信息(减少数据库查询)
|
||||
- 缓存设备状态
|
||||
|
||||
2. **职位类型配置缓存**
|
||||
- 当前5分钟缓存改为即时失效
|
||||
- 配置更新时清除缓存
|
||||
- 预加载常用配置
|
||||
|
||||
3. **简历信息缓存**
|
||||
- 缓存最近查询的简历
|
||||
- LRU淘汰策略
|
||||
- 简历更新时清除缓存
|
||||
|
||||
**技术实现**
|
||||
- Redis集成: `ioredis`
|
||||
- 文件路径: `api/middleware/cache/cacheManager.js`
|
||||
- 缓存策略: LRU, TTL
|
||||
- 预计工期: 1周
|
||||
|
||||
**预期价值**
|
||||
- 📈 响应速度提升 50%+
|
||||
- 📉 数据库压力降低 60%+
|
||||
- 💡 提升系统吞吐量
|
||||
- ✨ 提供更快的用户体验
|
||||
|
||||
---
|
||||
|
||||
### 4.2 数据库查询优化 ⭐⭐⭐⭐
|
||||
|
||||
**待开发内容**
|
||||
1. **慢查询分析和优化**
|
||||
- 开启MySQL慢查询日志
|
||||
- 分析TOP 10慢查询
|
||||
- 优化SQL语句
|
||||
- 添加必要索引
|
||||
|
||||
2. **索引优化**
|
||||
- 分析现有索引使用情况
|
||||
- 添加复合索引
|
||||
- 删除冗余索引
|
||||
- 定期索引维护
|
||||
|
||||
3. **分表分库策略**
|
||||
- 大表分表(如 `chat_records` 按月分表)
|
||||
- 历史数据归档
|
||||
- 读写分离(可选)
|
||||
|
||||
**技术实现**
|
||||
- 使用 `EXPLAIN` 分析查询
|
||||
- Sequelize 索引配置优化
|
||||
- 数据库迁移脚本
|
||||
- 预计工期: 1周
|
||||
|
||||
**预期价值**
|
||||
- 📈 查询性能提升 3-5倍
|
||||
- 📉 慢查询数量减少 80%+
|
||||
- 💡 数据库可支撑更大数据量
|
||||
- ✨ 提升系统稳定性
|
||||
|
||||
---
|
||||
|
||||
### 4.3 任务队列扩展 ⭐⭐⭐
|
||||
|
||||
**待开发内容**
|
||||
1. **支持更多任务类型**
|
||||
- 定时报告生成
|
||||
- 数据清理任务
|
||||
- 批量操作任务
|
||||
- 自定义任务
|
||||
|
||||
2. **任务优先级动态调整**
|
||||
- 根据紧急程度调整优先级
|
||||
- VIP用户任务优先执行
|
||||
- 失败任务降低优先级
|
||||
- 长时间等待的任务提升优先级
|
||||
|
||||
3. **任务失败自动重试优化**
|
||||
- 更智能的重试策略
|
||||
- 不同错误类型不同重试间隔
|
||||
- 重试次数动态调整
|
||||
- 重试失败后的降级处理
|
||||
|
||||
**技术实现**
|
||||
- 文件路径: `api/middleware/schedule/taskQueue.js` 优化
|
||||
- 新增任务类型处理器
|
||||
- 优化优先级算法
|
||||
- 预计工期: 1周
|
||||
|
||||
**预期价值**
|
||||
- 📈 任务处理更稳定
|
||||
- 💡 支持更复杂的任务场景
|
||||
- ✨ 提供更灵活的任务管理
|
||||
|
||||
---
|
||||
|
||||
### 4.4 并发控制优化 ⭐⭐⭐
|
||||
|
||||
**待开发内容**
|
||||
1. **增加并发设备数**
|
||||
- 当前最多5个设备
|
||||
- 支持动态配置并发数
|
||||
- 根据服务器性能自动调整
|
||||
- 支持分布式部署
|
||||
|
||||
2. **更精细的限流策略**
|
||||
- 不同任务类型不同限流
|
||||
- 不同平台不同限流
|
||||
- 根据时间段动态调整
|
||||
- API请求限流
|
||||
|
||||
3. **分布式锁机制**
|
||||
- 避免多实例冲突
|
||||
- Redis分布式锁
|
||||
- 锁超时自动释放
|
||||
- 死锁检测
|
||||
|
||||
**技术实现**
|
||||
- Redis分布式锁: `redlock`
|
||||
- 配置动态化
|
||||
- 负载均衡策略
|
||||
- 预计工期: 1周
|
||||
|
||||
**预期价值**
|
||||
- 📈 系统吞吐量提升 2-3倍
|
||||
- 💡 支持更大规模部署
|
||||
- ✨ 提供企业级并发控制
|
||||
|
||||
---
|
||||
|
||||
### 4.5 日志和监控 ⭐⭐⭐⭐
|
||||
|
||||
**待开发内容**
|
||||
1. **完善日志记录**
|
||||
- 统一日志格式(JSON)
|
||||
- 日志级别分级(DEBUG/INFO/WARN/ERROR)
|
||||
- 敏感信息脱敏
|
||||
- 日志文件按日期切割
|
||||
|
||||
2. **性能监控面板**
|
||||
- API响应时间监控
|
||||
- 数据库查询时间监控
|
||||
- 任务执行时间监控
|
||||
- 内存和CPU监控
|
||||
|
||||
3. **异常告警机制**
|
||||
- 错误率超过阈值告警
|
||||
- 任务失败立即告警
|
||||
- 系统资源不足告警
|
||||
- 钉钉/企业微信告警
|
||||
|
||||
4. **操作审计日志**
|
||||
- 记录所有关键操作
|
||||
- 操作人、操作时间、操作内容
|
||||
- 敏感操作二次确认
|
||||
- 审计日志导出
|
||||
|
||||
**技术实现**
|
||||
- 日志库: `winston`
|
||||
- 监控: `prometheus` + `grafana` (可选)
|
||||
- 告警: `api/services/alert_service.js`
|
||||
- 预计工期: 1.5周
|
||||
|
||||
**预期价值**
|
||||
- 🔍 问题定位效率提升 80%+
|
||||
- 📊 系统运行状态可视化
|
||||
- 🚨 及时发现和处理异常
|
||||
- ✨ 提供运维级别的监控
|
||||
|
||||
---
|
||||
|
||||
## 附录: 开发优先级建议
|
||||
|
||||
### 短期(1-2个月)
|
||||
**优先开发高价值、低成本功能**
|
||||
|
||||
| 功能 | 优先级 | 预计工期 | 价值 |
|
||||
|------|--------|----------|------|
|
||||
| 自动聊天功能完善 | ⭐⭐⭐⭐⭐ | 2周 | HR回复率+30% |
|
||||
| 账号保活任务 | ⭐⭐⭐⭐⭐ | 1周 | 封禁风险-70% |
|
||||
| 简历智能优化 | ⭐⭐⭐⭐⭐ | 2周 | 竞争力+20% |
|
||||
| 缓存策略优化 | ⭐⭐⭐⭐ | 1周 | 响应速度+50% |
|
||||
|
||||
**预计总工期: 6周**
|
||||
|
||||
### 中期(3-4个月)
|
||||
**完善核心功能和AI能力**
|
||||
|
||||
| 功能 | 优先级 | 预计工期 | 价值 |
|
||||
|------|--------|----------|------|
|
||||
| 简历自动更新 | ⭐⭐⭐⭐ | 1周 | 曝光率+50% |
|
||||
| 岗位黑名单和收藏 | ⭐⭐⭐⭐ | 1周 | 精准度+40% |
|
||||
| 多轮面试跟踪 | ⭐⭐⭐⭐ | 1.5周 | 完整生命周期 |
|
||||
| 面试问题预测 | ⭐⭐⭐⭐ | 1.5周 | 通过率+30% |
|
||||
| 数据可视化增强 | ⭐⭐⭐⭐ | 1周 | 数据洞察更直观 |
|
||||
|
||||
**预计总工期: 6周**
|
||||
|
||||
### 长期(5-6个月)
|
||||
**提升体验和系统性能**
|
||||
|
||||
| 功能 | 优先级 | 预计工期 | 价值 |
|
||||
|------|--------|----------|------|
|
||||
| 薪资谈判策略 | ⭐⭐⭐⭐ | 1周 | 薪资+10-15% |
|
||||
| 公司背景调查 | ⭐⭐⭐⭐ | 1.5周 | 风险-80% |
|
||||
| 实时通知系统 | ⭐⭐⭐⭐ | 1.5周 | 响应时间-90% |
|
||||
| 日志和监控 | ⭐⭐⭐⭐ | 1.5周 | 定位效率+80% |
|
||||
| 数据库查询优化 | ⭐⭐⭐⭐ | 1周 | 性能+3-5倍 |
|
||||
|
||||
**预计总工期: 6.5周**
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
本规划文档共列出 **20项待开发功能**,分为4个优先级方向:
|
||||
|
||||
- **功能完善和补充** (5项, HIGH): 完善核心业务流程
|
||||
- **AI能力增强** (5项, HIGH): 提升智能化水平
|
||||
- **用户体验优化** (5项, MEDIUM): 改善交互和便捷性
|
||||
- **系统性能提升** (5项, MEDIUM): 优化性能和稳定性
|
||||
|
||||
**预期开发周期**:
|
||||
- 短期(1-2月): 6周
|
||||
- 中期(3-4月): 6周
|
||||
- 长期(5-6月): 6.5周
|
||||
- **总计**: 约4.5个月
|
||||
|
||||
**预期收益**:
|
||||
- 📈 整体求职成功率提升 **50%+**
|
||||
- 📈 用户使用效率提升 **80%+**
|
||||
- 📈 系统性能提升 **3-5倍**
|
||||
- 💰 用户平均薪资提升 **10-15%**
|
||||
|
||||
---
|
||||
|
||||
**文档维护**: 开发团队
|
||||
**最后更新**: 2025-12-25
|
||||
574
_doc/发布的接口文档.md
574
_doc/发布的接口文档.md
@@ -1,574 +0,0 @@
|
||||
# 发布脚本使用说明
|
||||
|
||||
## 📋 概述
|
||||
|
||||
发布脚本 (`scripts/publish.js`) 用于自动化应用的构建和发布流程,包括:
|
||||
1. 清理构建目录
|
||||
2. 构建应用(NSIS 或便携版)
|
||||
3. 调用接口创建版本记录
|
||||
4. 上传压缩包到 OSS
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 安装依赖
|
||||
|
||||
确保已安装 `form-data` 依赖:
|
||||
|
||||
```bash
|
||||
npm install form-data --save-dev
|
||||
```
|
||||
|
||||
### 基本使用
|
||||
|
||||
```bash
|
||||
# 发布 NSIS 安装包(默认)
|
||||
npm run publish
|
||||
|
||||
# 或直接运行脚本
|
||||
node scripts/publish.js
|
||||
```
|
||||
|
||||
## 📝 命令选项
|
||||
|
||||
### 构建类型
|
||||
|
||||
```bash
|
||||
# 发布 NSIS 安装包
|
||||
npm run publish:nsis
|
||||
# 或
|
||||
node scripts/publish.js --type nsis
|
||||
|
||||
# 发布便携版
|
||||
npm run publish:portable
|
||||
# 或
|
||||
node scripts/publish.js --type portable
|
||||
```
|
||||
|
||||
### 发布说明
|
||||
|
||||
```bash
|
||||
node scripts/publish.js --notes "修复了若干bug,优化了性能"
|
||||
```
|
||||
|
||||
### 强制更新
|
||||
|
||||
```bash
|
||||
node scripts/publish.js --force
|
||||
```
|
||||
|
||||
### 跳过步骤
|
||||
|
||||
```bash
|
||||
# 仅上传已构建的文件(跳过构建)
|
||||
node scripts/publish.js --skip-build
|
||||
|
||||
# 仅构建不上传
|
||||
node scripts/publish.js --skip-upload
|
||||
```
|
||||
|
||||
### 查看帮助
|
||||
|
||||
```bash
|
||||
node scripts/publish.js --help
|
||||
```
|
||||
|
||||
## 🔧 配置要求
|
||||
|
||||
### 1. 配置文件
|
||||
|
||||
确保 `config/appConfig.js` 中包含有效的配置:
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
api_urls: {
|
||||
dev: "http://work.light120.com/api",
|
||||
prod: "http://work.light120.com/api"
|
||||
},
|
||||
token: "your-token-here", // 必须配置有效的 token
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
### 2. API 接口
|
||||
|
||||
脚本需要以下 API 接口:
|
||||
|
||||
#### 接口 1:创建版本记录
|
||||
|
||||
- **接口地址**: `POST /api/version/create`
|
||||
- **请求头**:
|
||||
```
|
||||
Content-Type: application/json
|
||||
Authorization: ${token}
|
||||
```
|
||||
- **请求参数**:
|
||||
```json
|
||||
{
|
||||
"version": "1.0.0", // 必填:版本号(x.y.z 格式)
|
||||
"platform": "win32", // 必填:平台类型(win32/darwin/linux)
|
||||
"arch": "x64", // 必填:架构类型(x64/ia32/arm64)
|
||||
"download_url": "https://...", // 必填:下载地址(上传后更新)
|
||||
"file_path": "/path/to/file", // 必填:服务器文件路径
|
||||
"file_size": 12345678, // 可选:文件大小(字节),不提供会自动计算
|
||||
"file_hash": "sha256-hash", // 可选:SHA256 哈希值,不提供会自动计算
|
||||
"release_notes": "发布说明", // 可选:更新日志
|
||||
"force_update": 0, // 可选:是否强制更新(1:是 0:否),默认 0
|
||||
"status": 1 // 可选:状态(1:启用 0:禁用),默认 1
|
||||
}
|
||||
```
|
||||
- **响应示例**:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "版本创建成功",
|
||||
"data": {
|
||||
"id": 123,
|
||||
"version": "1.0.0",
|
||||
"platform": "win32",
|
||||
"arch": "x64",
|
||||
"download_url": "https://oss.example.com/path/to/file.exe",
|
||||
"file_path": "/path/to/file.exe",
|
||||
"file_size": 12345678,
|
||||
"file_hash": "sha256-hash-value",
|
||||
"release_notes": "发布说明",
|
||||
"force_update": 0,
|
||||
"status": 1,
|
||||
"create_time": "2024-01-01 12:00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
- **错误响应**:
|
||||
```json
|
||||
{
|
||||
"code": 400,
|
||||
"message": "版本号不能为空" // 或其他错误信息
|
||||
}
|
||||
```
|
||||
- **注意事项**:
|
||||
- 版本号格式必须为 `x.y.z`(如:1.0.0)
|
||||
- 平台类型必须为:`win32`、`darwin` 或 `linux`
|
||||
- 架构类型必须为:`x64`、`ia32` 或 `arm64`
|
||||
- 如果版本已存在(相同 version + platform + arch),会返回错误
|
||||
- 如果提供了 `file_path` 但未提供 `file_size` 或 `file_hash`,接口会自动计算
|
||||
|
||||
#### 接口 2:上传文件到 OSS
|
||||
|
||||
- **接口地址**: `POST /api/file/upload_version`
|
||||
- **请求头**:
|
||||
```
|
||||
Content-Type: multipart/form-data
|
||||
Authorization: ${token}
|
||||
```
|
||||
- **请求参数**(Form Data):
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| `file` | File | 是 | 文件内容(二进制) |
|
||||
| `version` | String | 是 | 版本号(如:1.0.0) |
|
||||
| `platform` | String | 是 | 平台类型(win32/darwin/linux) |
|
||||
| `arch` | String | 是 | 架构类型(x64/ia32/arm64) |
|
||||
| `file_hash` | String | 是 | SHA256 哈希值 |
|
||||
| `file_size` | Number | 是 | 文件大小(字节) |
|
||||
| `version_id` | Number | 否 | 版本记录 ID(如果已创建版本记录) |
|
||||
| `build_type` | String | 否 | 构建类型(nsis/portable) |
|
||||
- **请求示例**(使用 form-data):
|
||||
```javascript
|
||||
const FormData = require('form-data');
|
||||
const fs = require('fs');
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', fs.createReadStream('./dist/app.exe'));
|
||||
form.append('version', '1.0.0');
|
||||
form.append('platform', 'win32');
|
||||
form.append('arch', 'x64');
|
||||
form.append('file_hash', 'sha256-hash-value');
|
||||
form.append('file_size', 12345678);
|
||||
form.append('version_id', 123); // 可选
|
||||
|
||||
// 发送请求
|
||||
form.submit('http://api.example.com/api/file/upload_version', {
|
||||
headers: {
|
||||
'Authorization': `${token}`
|
||||
}
|
||||
}, callback);
|
||||
```
|
||||
- **响应示例**:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "文件上传成功",
|
||||
"data": {
|
||||
"download_url": "https://oss.example.com/versions/win32/x64/app-1.0.0.exe",
|
||||
"file_path": "versions/win32/x64/app-1.0.0.exe",
|
||||
"oss_path": "https://oss.example.com/versions/win32/x64/app-1.0.0.exe",
|
||||
"file_size": 12345678,
|
||||
"file_hash": "sha256-hash-value"
|
||||
}
|
||||
}
|
||||
```
|
||||
- **错误响应**:
|
||||
```json
|
||||
{
|
||||
"code": 400,
|
||||
"message": "文件上传失败:文件大小不匹配" // 或其他错误信息
|
||||
}
|
||||
```
|
||||
- **注意事项**:
|
||||
- 文件会按照 `versions/{platform}/{arch}/{filename}` 的路径结构上传到 OSS
|
||||
- 上传前会验证文件哈希值,确保文件完整性
|
||||
- 上传成功后,建议调用 `/version/update` 接口更新版本记录的下载地址
|
||||
- 大文件上传建议设置较长的超时时间(建议 10 分钟以上)
|
||||
|
||||
#### 接口 3:更新版本下载地址(可选)
|
||||
|
||||
- **接口地址**: `POST /api/version/update`
|
||||
- **请求头**:
|
||||
```
|
||||
Content-Type: application/json
|
||||
Authorization: ${token}
|
||||
```
|
||||
- **请求参数**:
|
||||
```json
|
||||
{
|
||||
"id": 123, // 必填:版本记录 ID
|
||||
"download_url": "https://...", // 可选:下载地址
|
||||
"file_path": "/path/to/file", // 可选:文件路径
|
||||
"file_hash": "sha256-hash", // 可选:文件哈希值
|
||||
"file_size": 12345678, // 可选:文件大小
|
||||
"release_notes": "更新说明", // 可选:更新日志
|
||||
"force_update": 1, // 可选:是否强制更新
|
||||
"status": 1 // 可选:状态
|
||||
}
|
||||
```
|
||||
- **响应示例**:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "版本更新成功",
|
||||
"data": {
|
||||
"id": 123,
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://oss.example.com/path/to/file.exe",
|
||||
// ... 其他字段
|
||||
}
|
||||
}
|
||||
```
|
||||
- **使用场景**:
|
||||
- 文件上传成功后,更新版本记录的下载地址
|
||||
- 修改版本的发布说明或强制更新标志
|
||||
- 启用或禁用某个版本
|
||||
|
||||
## 📦 发布流程
|
||||
|
||||
1. **清理构建目录**
|
||||
- 删除 `dist` 目录及其所有内容
|
||||
|
||||
2. **构建应用**
|
||||
- 根据构建类型执行 `electron-builder`
|
||||
- NSIS: `electron-builder --win nsis`
|
||||
- Portable: `electron-builder --win portable`
|
||||
|
||||
3. **查找构建产物**
|
||||
- 在 `dist` 目录中查找 `.exe` 文件
|
||||
- 按文件大小排序,优先处理主安装包
|
||||
|
||||
4. **创建版本记录**
|
||||
- 调用 `POST /api/version/create` 接口
|
||||
- 传递版本信息、平台、架构等
|
||||
- 获取版本记录 ID(用于后续更新)
|
||||
|
||||
5. **上传文件到 OSS**
|
||||
- 计算文件 SHA256 哈希值
|
||||
- 使用 `multipart/form-data` 格式调用 `POST /api/file/upload_version` 接口
|
||||
- 传递文件、版本信息、哈希值等参数
|
||||
- 获取上传后的下载地址
|
||||
|
||||
6. **更新版本记录**
|
||||
- 使用获取到的下载地址调用 `POST /api/version/update` 接口
|
||||
- 更新版本记录的 `download_url` 和 `file_path` 字段
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **Token 配置**
|
||||
- 确保 `config/appConfig.js` 中有有效的 `token`
|
||||
- Token 用于 API 认证,格式为 `${token}`
|
||||
- 所有接口请求都需要在请求头中包含 `Authorization: ${token}`
|
||||
|
||||
2. **接口路径**
|
||||
- 所有接口路径前缀为 `/api`
|
||||
- 完整路径示例:
|
||||
- 创建版本:`POST http://api.example.com/api/version/create`
|
||||
- 上传文件:`POST http://api.example.com/api/file/upload_version`
|
||||
- 更新版本:`POST http://api.example.com/api/version/update`
|
||||
|
||||
3. **文件大小**
|
||||
- 大文件上传可能需要较长时间
|
||||
- 脚本默认超时时间为 10 分钟(600000ms)
|
||||
- 建议大文件(>100MB)增加超时时间到 30 分钟
|
||||
|
||||
4. **网络连接**
|
||||
- 确保能够访问 API 服务器和 OSS
|
||||
- 上传大文件时建议使用稳定的网络连接
|
||||
- 建议在网络稳定的环境下执行发布流程
|
||||
|
||||
5. **版本号**
|
||||
- 版本号从 `package.json` 的 `version` 字段读取
|
||||
- 发布前确保版本号已更新
|
||||
- 版本号格式必须符合 `x.y.z` 格式(如:1.0.0)
|
||||
|
||||
6. **构建产物**
|
||||
- 脚本会自动查找 `dist` 目录中的 `.exe` 文件
|
||||
- 排除 `builder` 和 `helper` 相关的辅助文件
|
||||
- 确保构建产物文件名清晰,便于识别
|
||||
|
||||
7. **文件哈希验证**
|
||||
- 上传前必须计算文件的 SHA256 哈希值
|
||||
- 上传接口会验证文件哈希,确保文件完整性
|
||||
- 哈希值不匹配会导致上传失败
|
||||
|
||||
8. **接口调用顺序**
|
||||
- 建议先创建版本记录(获取 version_id)
|
||||
- 然后上传文件(可传递 version_id)
|
||||
- 最后更新版本记录的下载地址
|
||||
- 也可以先上传文件,再创建版本记录并更新下载地址
|
||||
|
||||
## 🔍 故障排查
|
||||
|
||||
### 问题:上传失败
|
||||
|
||||
**可能原因**:
|
||||
- Token 无效或过期
|
||||
- API 接口地址不正确
|
||||
- 网络连接问题
|
||||
- 文件过大,超时
|
||||
|
||||
**解决方法**:
|
||||
1. 检查 `config/appConfig.js` 中的 `token` 是否有效
|
||||
2. 检查 API 地址是否正确
|
||||
3. 检查网络连接
|
||||
4. 如果文件很大,可以增加超时时间
|
||||
|
||||
### 问题:构建失败
|
||||
|
||||
**可能原因**:
|
||||
- 缺少依赖
|
||||
- electron-builder 配置错误
|
||||
- 磁盘空间不足
|
||||
|
||||
**解决方法**:
|
||||
1. 运行 `npm install` 安装所有依赖
|
||||
2. 检查 `package.json` 中的 `build` 配置
|
||||
3. 确保有足够的磁盘空间
|
||||
|
||||
### 问题:找不到构建产物
|
||||
|
||||
**可能原因**:
|
||||
- 构建未成功完成
|
||||
- 构建产物在其他位置
|
||||
|
||||
**解决方法**:
|
||||
1. 检查构建日志,确认构建成功
|
||||
2. 手动检查 `dist` 目录
|
||||
3. 使用 `--skip-build` 选项,手动指定文件路径(需要修改脚本)
|
||||
|
||||
## 📝 示例
|
||||
|
||||
### 完整发布流程
|
||||
|
||||
```bash
|
||||
# 1. 更新版本号(在 package.json 中)
|
||||
# "version": "1.0.1"
|
||||
|
||||
# 2. 发布
|
||||
npm run publish -- --notes "新功能:支持自动投递简历" --force
|
||||
```
|
||||
|
||||
### 仅上传已构建的文件
|
||||
|
||||
```bash
|
||||
# 1. 手动构建
|
||||
npm run build:nsis
|
||||
|
||||
# 2. 仅上传
|
||||
node scripts/publish.js --skip-build
|
||||
```
|
||||
|
||||
### 测试发布(不上传)
|
||||
|
||||
```bash
|
||||
# 构建并创建版本记录,但不上传文件
|
||||
node scripts/publish.js --skip-upload
|
||||
```
|
||||
|
||||
## 💻 接口调用示例
|
||||
|
||||
### Node.js 示例代码
|
||||
|
||||
#### 1. 创建版本记录
|
||||
|
||||
```javascript
|
||||
const axios = require('axios');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
|
||||
async function createVersion() {
|
||||
const filePath = './dist/app-1.0.0.exe';
|
||||
const stats = fs.statSync(filePath);
|
||||
const fileSize = stats.size;
|
||||
|
||||
// 计算文件哈希
|
||||
const fileBuffer = fs.readFileSync(filePath);
|
||||
const fileHash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
|
||||
|
||||
const response = await axios.post('http://api.example.com/api/version/create', {
|
||||
version: '1.0.0',
|
||||
platform: 'win32',
|
||||
arch: 'x64',
|
||||
download_url: '', // 上传后更新
|
||||
file_path: filePath,
|
||||
file_size: fileSize,
|
||||
file_hash: fileHash,
|
||||
release_notes: '修复了若干bug,优化了性能',
|
||||
force_update: 0,
|
||||
status: 1
|
||||
}, {
|
||||
headers: {
|
||||
'Authorization': `${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return response.data.data; // 返回版本记录,包含 id
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 上传文件到 OSS
|
||||
|
||||
```javascript
|
||||
const FormData = require('form-data');
|
||||
const axios = require('axios');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
|
||||
async function uploadVersionFile(versionId) {
|
||||
const filePath = './dist/app-1.0.0.exe';
|
||||
const stats = fs.statSync(filePath);
|
||||
const fileSize = stats.size;
|
||||
|
||||
// 计算文件哈希
|
||||
const fileBuffer = fs.readFileSync(filePath);
|
||||
const fileHash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
|
||||
|
||||
// 创建 FormData
|
||||
const form = new FormData();
|
||||
form.append('file', fs.createReadStream(filePath));
|
||||
form.append('version', '1.0.0');
|
||||
form.append('platform', 'win32');
|
||||
form.append('arch', 'x64');
|
||||
form.append('file_hash', fileHash);
|
||||
form.append('file_size', fileSize);
|
||||
if (versionId) {
|
||||
form.append('version_id', versionId);
|
||||
}
|
||||
|
||||
const response = await axios.post('http://api.example.com/api/file/upload_version', form, {
|
||||
headers: {
|
||||
'Authorization': `${token}`,
|
||||
...form.getHeaders()
|
||||
},
|
||||
maxContentLength: Infinity,
|
||||
maxBodyLength: Infinity,
|
||||
timeout: 600000 // 10 分钟超时
|
||||
});
|
||||
|
||||
return response.data.data; // 返回上传结果,包含 download_url
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 更新版本下载地址
|
||||
|
||||
```javascript
|
||||
async function updateVersionDownloadUrl(versionId, downloadUrl, fileHash) {
|
||||
const response = await axios.post('http://api.example.com/api/version/update', {
|
||||
id: versionId,
|
||||
download_url: downloadUrl,
|
||||
file_hash: fileHash
|
||||
}, {
|
||||
headers: {
|
||||
'Authorization': `${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return response.data.data;
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. 完整发布流程示例
|
||||
|
||||
```javascript
|
||||
async function publishVersion() {
|
||||
try {
|
||||
// 1. 创建版本记录
|
||||
console.log('创建版本记录...');
|
||||
const version = await createVersion();
|
||||
console.log('版本记录创建成功,ID:', version.id);
|
||||
|
||||
// 2. 上传文件
|
||||
console.log('上传文件到 OSS...');
|
||||
const uploadResult = await uploadVersionFile(version.id);
|
||||
console.log('文件上传成功,下载地址:', uploadResult.download_url);
|
||||
|
||||
// 3. 更新版本下载地址
|
||||
console.log('更新版本下载地址...');
|
||||
await updateVersionDownloadUrl(version.id, uploadResult.download_url, uploadResult.file_hash);
|
||||
console.log('版本发布完成!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('发布失败:', error.response?.data || error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### cURL 示例
|
||||
|
||||
#### 创建版本记录
|
||||
|
||||
```bash
|
||||
curl -X POST http://api.example.com/api/version/create \
|
||||
-H "Authorization: your-token-here" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"version": "1.0.0",
|
||||
"platform": "win32",
|
||||
"arch": "x64",
|
||||
"download_url": "",
|
||||
"file_path": "/path/to/file.exe",
|
||||
"file_size": 12345678,
|
||||
"file_hash": "sha256-hash-value",
|
||||
"release_notes": "发布说明",
|
||||
"force_update": 0,
|
||||
"status": 1
|
||||
}'
|
||||
```
|
||||
|
||||
#### 上传文件
|
||||
|
||||
```bash
|
||||
curl -X POST http://api.example.com/api/file/upload_version \
|
||||
-H "Authorization: your-token-here" \
|
||||
-F "file=@./dist/app-1.0.0.exe" \
|
||||
-F "version=1.0.0" \
|
||||
-F "platform=win32" \
|
||||
-F "arch=x64" \
|
||||
-F "file_hash=sha256-hash-value" \
|
||||
-F "file_size=12345678" \
|
||||
-F "version_id=123"
|
||||
```
|
||||
|
||||
## 🔗 相关文档
|
||||
|
||||
- [API 配置说明](./API_CONFIG.md)
|
||||
- [打包说明](./BUILD.md)
|
||||
- [更新逻辑检查报告](./更新逻辑检查报告.md)
|
||||
|
||||
100
_doc/命名规范统一方案.md
100
_doc/命名规范统一方案.md
@@ -1,100 +0,0 @@
|
||||
# 命名规范统一方案
|
||||
|
||||
## 📋 命名规范标准
|
||||
|
||||
### 1. 文件命名规范
|
||||
**统一使用下划线命名(snake_case)**
|
||||
- ✅ `ai_service.js`
|
||||
- ✅ `job_service.js`
|
||||
- ✅ `pla_account_service.js`
|
||||
- ✅ `job_manager_service.js`
|
||||
- ✅ `chat_manager_service.js`
|
||||
- ✅ `resume_manager_service.js`
|
||||
- ❌ `aiService.js` → `ai_service.js`
|
||||
- ❌ `jobManager.js` → `job_manager_service.js`
|
||||
- ❌ `chatManager.js` → `chat_manager_service.js`
|
||||
- ❌ `resumeManager.js` → `resume_manager_service.js`
|
||||
|
||||
### 2. 类命名规范
|
||||
**统一使用大驼峰命名(PascalCase)**
|
||||
- ✅ `AIService`
|
||||
- ✅ `JobService`
|
||||
- ✅ `JobManagerService`
|
||||
- ✅ `ChatManagerService`
|
||||
- ✅ `ResumeManagerService`
|
||||
- ❌ `aiService` → `AIService`
|
||||
- ❌ `JobManager` → `JobManagerService`
|
||||
|
||||
### 3. 目录命名规范
|
||||
**统一使用小写+下划线(snake_case)**
|
||||
- ✅ `services/` - 业务服务层
|
||||
- ✅ `middleware/` - 中间件层
|
||||
- ✅ `middleware/schedule/` - 调度系统
|
||||
- ✅ `middleware/mqtt/` - MQTT通信
|
||||
- ❌ `middleware/job/` → 移到 `services/` 并重命名
|
||||
|
||||
### 4. 服务文件命名规范
|
||||
**所有服务文件统一以 `_service.js` 结尾**
|
||||
- ✅ `ai_service.js`
|
||||
- ✅ `job_service.js`
|
||||
- ✅ `job_manager_service.js`
|
||||
- ✅ `chat_manager_service.js`
|
||||
- ✅ `resume_manager_service.js`
|
||||
- ✅ `pla_account_service.js`
|
||||
- ✅ `oss_tool_service.js` (重命名 `ossTool.js`)
|
||||
|
||||
## 🔄 需要重命名的文件
|
||||
|
||||
### services/ 目录
|
||||
1. `ossTool.js` → `oss_tool_service.js`
|
||||
2. `task_scheduler.js` → 标记为废弃或删除
|
||||
|
||||
### middleware/job/ 目录(移到 services/)
|
||||
1. `jobManager.js` → `services/job_manager_service.js`
|
||||
2. `chatManager.js` → `services/chat_manager_service.js`
|
||||
3. `resumeManager.js` → `services/resume_manager_service.js`
|
||||
4. `aiService.js` → 合并到 `services/ai_service.js` 后删除
|
||||
|
||||
## 📁 整理后的目录结构
|
||||
|
||||
```
|
||||
api/
|
||||
├── services/ # 业务服务层
|
||||
│ ├── index.js # 服务管理器
|
||||
│ ├── ai_service.js # AI服务(合并后)
|
||||
│ ├── job_service.js # 职位服务
|
||||
│ ├── job_manager_service.js # 工作管理服务
|
||||
│ ├── chat_manager_service.js # 聊天管理服务
|
||||
│ ├── resume_manager_service.js # 简历管理服务
|
||||
│ ├── pla_account_service.js # 账号服务
|
||||
│ └── oss_tool_service.js # OSS服务
|
||||
│
|
||||
└── middleware/ # 中间件层
|
||||
├── schedule/ # 调度系统
|
||||
├── mqtt/ # MQTT通信
|
||||
├── dbProxy.js # 数据库代理
|
||||
└── logProxy.js # 日志代理
|
||||
```
|
||||
|
||||
## 🎯 执行步骤
|
||||
|
||||
1. **重命名现有文件**
|
||||
- `ossTool.js` → `oss_tool_service.js`
|
||||
|
||||
2. **移动并重命名业务服务**
|
||||
- `middleware/job/jobManager.js` → `services/job_manager_service.js`
|
||||
- `middleware/job/chatManager.js` → `services/chat_manager_service.js`
|
||||
- `middleware/job/resumeManager.js` → `services/resume_manager_service.js`
|
||||
|
||||
3. **合并AI服务**
|
||||
- 将 `middleware/job/aiService.js` 的功能合并到 `services/ai_service.js`
|
||||
- 删除 `middleware/job/aiService.js`
|
||||
|
||||
4. **更新所有引用**
|
||||
- 更新 `command.js` 中的引用
|
||||
- 更新其他文件中的引用
|
||||
|
||||
5. **统一类命名**
|
||||
- 确保所有类都使用 PascalCase
|
||||
- 确保所有服务类都以 Service 结尾
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
# 命名规范统一进度
|
||||
|
||||
## ✅ 已完成
|
||||
|
||||
### 1. OSS服务重命名
|
||||
- ✅ `ossTool.js` → `oss_tool_service.js`
|
||||
- ✅ `OSSTool` → `OSSToolService`
|
||||
- ✅ 更新引用:`api/controller_front/file.js`
|
||||
|
||||
## 📋 待完成
|
||||
|
||||
### 2. 移动并重命名业务服务(middleware/job/ → services/)
|
||||
- ⏳ `jobManager.js` → `services/job_manager_service.js`
|
||||
- 类名:`JobManager` → `JobManagerService`
|
||||
- 更新引用:`api/middleware/schedule/command.js`
|
||||
|
||||
- ⏳ `chatManager.js` → `services/chat_manager_service.js`
|
||||
- 类名:`ChatManager` → `ChatManagerService`
|
||||
- 更新引用:`api/middleware/schedule/command.js`
|
||||
|
||||
- ⏳ `resumeManager.js` → `services/resume_manager_service.js`
|
||||
- 类名:`ResumeManager` → `ResumeManagerService`
|
||||
|
||||
### 3. 合并AI服务
|
||||
- ⏳ 将 `middleware/job/aiService.js` 的功能合并到 `services/ai_service.js`
|
||||
- ⏳ 统一类名为 `AIService`
|
||||
- ⏳ 删除 `middleware/job/aiService.js`
|
||||
- ⏳ 更新所有引用
|
||||
|
||||
### 4. 处理废弃文件
|
||||
- ⏳ `services/task_scheduler.js` - 添加废弃标记或删除
|
||||
|
||||
## 📝 命名规范总结
|
||||
|
||||
### 文件命名规范
|
||||
- ✅ 统一使用 `snake_case` + `_service.js` 后缀
|
||||
- ✅ 示例:`oss_tool_service.js`, `job_manager_service.js`
|
||||
|
||||
### 类命名规范
|
||||
- ✅ 统一使用 `PascalCase` + `Service` 后缀
|
||||
- ✅ 示例:`OSSToolService`, `JobManagerService`
|
||||
|
||||
### 目录结构
|
||||
```
|
||||
api/
|
||||
├── services/ # 业务服务层
|
||||
│ ├── ai_service.js
|
||||
│ ├── job_service.js
|
||||
│ ├── job_manager_service.js # 待移动
|
||||
│ ├── chat_manager_service.js # 待移动
|
||||
│ ├── resume_manager_service.js # 待移动
|
||||
│ ├── pla_account_service.js
|
||||
│ └── oss_tool_service.js # ✅ 已完成
|
||||
│
|
||||
└── middleware/ # 中间件层
|
||||
├── schedule/
|
||||
├── mqtt/
|
||||
└── job/ # 待删除(文件移到services后)
|
||||
```
|
||||
|
||||
## 🔄 下一步操作
|
||||
|
||||
1. 移动并重命名 `middleware/job/` 下的文件
|
||||
2. 合并AI服务
|
||||
3. 更新所有引用
|
||||
4. 统一导出方式
|
||||
5. 删除废弃文件
|
||||
|
||||
@@ -1,860 +0,0 @@
|
||||
{
|
||||
"baseInfo": {
|
||||
"name": "张成",
|
||||
"nickName": "张成",
|
||||
"tiny": "https://img.bosszhipin.com/beijin/upload/avatar/20250211/607f1f3d68754fd006d510844c0273d99b737108b9d73a664006f977785f3a6a694eb527b0e564d8_s.png.webp",
|
||||
"birthday": "19930612",
|
||||
"age": "32岁",
|
||||
"gender": 1,
|
||||
"degree": 203,
|
||||
"degreeCategory": "本科",
|
||||
"account": "193******69",
|
||||
"emailBlur": "978****03@qq.com",
|
||||
"weixinBlur": "z56***01",
|
||||
"freshGraduate": 0,
|
||||
"workYears": 11,
|
||||
"nameShowType": 0,
|
||||
"bossCert": 0,
|
||||
"userCert": 2,
|
||||
"certGender": true,
|
||||
"certBirth": true,
|
||||
"startWorkDate": 20141201,
|
||||
"applyStatus": 0,
|
||||
"workYearDesc": "10年以上经验",
|
||||
"resumeStatus": 0,
|
||||
"resumeCount": 0,
|
||||
"complete": false,
|
||||
"weiXinSecurityUid": null,
|
||||
"garbageFieldList": null,
|
||||
"hometown": 0,
|
||||
"hometownName": null,
|
||||
"studyAbroadCertPass": 0,
|
||||
"highestEduExp": null,
|
||||
"showF3Optimize": 0,
|
||||
"startWorkDateDesc": "2014-12",
|
||||
"birthdayDesc": "1993-06"
|
||||
},
|
||||
"userDesc": "拥有10年深厚行业经验的资深前端架构师,专注前沿技术与业务融合,推动数字化产品创新升级。\n \n1. 技术栈与架构:熟练运用Vue、React等主流框架,搭配Webpack、Vite等构建工具进行高效开发。精通TypeScript,优化代码结构与维护性。擅长使用Redux、MobX管理复杂应用状态,搭建稳定架构。\n \n2. 跨端与组件化:掌握Flutter、React Native等跨端技术,实现多平台无缝运行。主导设计高复用组件库,利用Storybook管理组件,使组件复用率达70%,开发周期缩短40%。\n \n3. AI集成:积极探索AI与前端融合,集成GPT等大模型实现智能客服、内容生成,引入机器学习算法实现用户行为分析、个性化推荐,大幅提升用户参与度。\n \n4. 音视频处理:具备音视频处理能力,使用WebRTC实现实时通信,结合FFmpeg进行格式转换、剪辑。利用Media Source Extensions实现自适应码率播放,优化视频加载与播放体验。\n\n 5. 后端及多元开发:擅长使用Node.js搭配Express、Koa框架搭建高性能后端服务,优化接口响应速度。熟练运用Python进行数据处理、自动化脚本编写,结合Django、Flask框架开发后端应用,在数据挖掘与分析领域成果显著。掌握C#语言,基于.NET平台进行Windows桌面应用开发,具备丰富的Windows Forms、WPF项目经验,实现全栈技术链路的打通。",
|
||||
"applyStatus": 0,
|
||||
"lastlast_modify_time": "2025.11.05 13:41",
|
||||
"workEduDesc": "宝尊电子商务·架构师",
|
||||
"expectList": [
|
||||
{
|
||||
"id": "dfc1777a2703c9071nVz3d-0GVpYxA~~",
|
||||
"expectType": 0,
|
||||
"position": 100123,
|
||||
"positionName": "全栈工程师",
|
||||
"customPositionId": "",
|
||||
"positionType": 0,
|
||||
"location": 101020100,
|
||||
"locationName": "上海",
|
||||
"subLocation": 0,
|
||||
"subLocationName": null,
|
||||
"lowSalary": 20,
|
||||
"highSalary": 30,
|
||||
"salaryDesc": "20-30K",
|
||||
"salaryDescNew": "20-30K",
|
||||
"industryList": [],
|
||||
"industryDesc": "行业不限",
|
||||
"suggestPosition": "",
|
||||
"applyStatus": 0,
|
||||
"freshGraduate": 0,
|
||||
"doneWorkPositionList": null,
|
||||
"garbageFieldList": null,
|
||||
"interestPositionList": null,
|
||||
"interestLocationList": null,
|
||||
"industryExpect": false,
|
||||
"tagName": null
|
||||
}
|
||||
],
|
||||
"workExpList": [
|
||||
{
|
||||
"id": "9bd3116c0333c52d1nJ_09y-EldZx465V_-X",
|
||||
"companyName": "上海宝尊电子商务有限公司",
|
||||
"industry": {
|
||||
"code": 100020,
|
||||
"name": "互联网"
|
||||
},
|
||||
"department": "",
|
||||
"customPositionName": "架构师",
|
||||
"position": 100704,
|
||||
"positionName": "架构师",
|
||||
"positionLv2": 100700,
|
||||
"startDate": "2019.06",
|
||||
"startDateStr": "2019.06",
|
||||
"endDate": "2024.12",
|
||||
"endDateStr": "2024.12",
|
||||
"emphasis": [],
|
||||
"workContent": "智能视频剪辑系统(2021.07-2024.12)\n技术栈:Vue2+Element UI+WebAssembly+Canvas+Konva.js+WebSocket+GSAP\n核心功能:\n1.视频处理:基于WebAssembly解析视频信息,实现大文件分片上传,支持200G+视频处理\n2.创意编辑:使用Konva.js开发多层级编辑器,实现图片分层、文字动画、贴片特效\n3.动画系统:基于GSAP开发文字特效、Logo动画,支持动态片头片尾制作\n4.预览系统:使用Canvas实现视频片段预览,支持时间轴精确定位\n5.任务管理:基于WebSocket实现批量任务进度实时通知\n项目成果:视频处理效率提升300%|日均处理1000+视频|压缩率达97%|任务效率提升200%|降低\n人工成本60%",
|
||||
"workPerformance": "",
|
||||
"isPublic": 1,
|
||||
"workType": 1,
|
||||
"suggestToDel": 0,
|
||||
"garbageFieldList": null
|
||||
},
|
||||
{
|
||||
"id": "283b1abcae6041e41nJ_0969ElpVy423Vvic",
|
||||
"companyName": "上海航天动力科技工程有限公司",
|
||||
"industry": {
|
||||
"code": 100021,
|
||||
"name": "计算机软件"
|
||||
},
|
||||
"department": "",
|
||||
"customPositionName": "架构师",
|
||||
"position": 100704,
|
||||
"positionName": "架构师",
|
||||
"positionLv2": 100700,
|
||||
"startDate": "2018.06",
|
||||
"startDateStr": "2018.06",
|
||||
"endDate": "2019.06",
|
||||
"endDateStr": "2019.06",
|
||||
"emphasis": [],
|
||||
"workContent": "技术栈:Vue2+iView+OpenLayers+Cesium+WebSocket+Less\n核心功能:\n1.GIS可视化:基于OpenLayers实现管网GIS展示,支持多图层管理\n2.实时监控:使用WebSocket推送报警信息,实现管道水流方向动画\n3.数据分析:集成ECharts开发运营分析、报表统计功能\n4.空间分析:使用Turf.js实现等差线绘制,可视化爆管位置\n项目成果:地图加载提升200%|漏损检测准确率95%|节省成本300万+|服务10+水务公司|覆盖管\n网1000km+",
|
||||
"workPerformance": "",
|
||||
"isPublic": 1,
|
||||
"workType": 1,
|
||||
"suggestToDel": 0,
|
||||
"garbageFieldList": null
|
||||
},
|
||||
{
|
||||
"id": "d18fc7f74a6479ee1nJ_0969ElpVy423Vvid",
|
||||
"companyName": "上海开澜软件有限公司",
|
||||
"industry": {
|
||||
"code": 100021,
|
||||
"name": "计算机软件"
|
||||
},
|
||||
"department": "",
|
||||
"customPositionName": ".NET",
|
||||
"position": 100107,
|
||||
"positionName": ".NET",
|
||||
"positionLv2": 100100,
|
||||
"startDate": "2016.06",
|
||||
"startDateStr": "2016.06",
|
||||
"endDate": "2018.06",
|
||||
"endDateStr": "2018.06",
|
||||
"emphasis": [
|
||||
"HTML"
|
||||
],
|
||||
"workContent": "滩涂造地BIM管理系统(2017.01-2018.05)\n技术栈:jQuery+EasyUI+BIMViz+百度图+WebAppOffice\n核心功能:\n1.BIM可视化:集成BIMViz实现模型在线预览,支持构件查询和联动\n2.地图集成:基于百度地图API实现工程位置展示和空间分析\n3.文档管理:使用WebAppOffice实现在线预览,支持多格式文档\n4.工作流程:开发OA审批流程,实现物料申请和人员管理\n项目成果:BIM性能提升200%|审批效率提升150%|支持50+文档格式|日均处理500+工单|管理效\n率提升80%",
|
||||
"workPerformance": "",
|
||||
"isPublic": 1,
|
||||
"workType": 1,
|
||||
"suggestToDel": 0,
|
||||
"garbageFieldList": null
|
||||
},
|
||||
{
|
||||
"id": "c07196b9b117210f1nJ_0969ElpVy423Vvie",
|
||||
"companyName": "上海加谷网络科技有限公司",
|
||||
"industry": {
|
||||
"code": 100002,
|
||||
"name": "游戏"
|
||||
},
|
||||
"department": "",
|
||||
"customPositionName": ".NET",
|
||||
"position": 100107,
|
||||
"positionName": ".NET",
|
||||
"positionLv2": 100100,
|
||||
"startDate": "2014.06",
|
||||
"startDateStr": "2014.06",
|
||||
"endDate": "2016.05",
|
||||
"endDateStr": "2016.05",
|
||||
"emphasis": [
|
||||
"HTML"
|
||||
],
|
||||
"workContent": "H5营销平台开发(2014.06-2016.05)\n技术栈:jQuery+Canvas+CSS3+HTML5+微信JSSDK\n核心功能:\n1.互动游戏:开发大转盘抽奖、刮刮卡、砸金蛋等H5游戏\n2.动画特效:自研Canvas动画框架,支持Flash动画转换\n3.低代码平台:开发可视化搭建工具,支持营销活动快速生成\n4.社交功能:集成微信分享、支付、授权等功能项目成果:服务200+品牌|开发效率提升300%|上线周期缩短80%|转化率提升150%|支持百万级访问",
|
||||
"workPerformance": "",
|
||||
"isPublic": 1,
|
||||
"workType": 1,
|
||||
"suggestToDel": 0,
|
||||
"garbageFieldList": null
|
||||
}
|
||||
],
|
||||
"projectExpList": [
|
||||
{
|
||||
"id": "903b8e07eb7f44ca1nx50t-6EFZQw4i8UvKY",
|
||||
"name": "AI智能视频剪辑系统",
|
||||
"roleName": "全栈工程师",
|
||||
"url": "",
|
||||
"startDate": "2024.01",
|
||||
"endDate": "",
|
||||
"projectDesc": "系统简介\nai智能视频剪辑系统,可通过淘宝直播间,天猫,淘宝商品链接,自动从直播间中按照商品切片,并输出到天猫商品详情页主视频,发布到小红书,淘宝微淘等平台,可批量生成视频,处理视频,前端技术栈:vue全家桶+elemeui+浏览器插件\n后端技术栈:Node.js+Koa2+Vue2+FFmpeg+Redis\n功能模块:\n1.内容采集:开发Chrome浏览器插件自动获取直播回放、商品时间点智能识别、多平台视频源导入本地大视频文件上传,支持200g左右大文件批量上传,断点续传等功能\n2.任务管理:用户可以批量创建任务,任务实时状态使用WebSocket更新并通知\n3.模版管理:可基于execl文件批量创建任务,动态生成片尾图和首图\n4.智能生成:基于GPT的视频内容理解、关键片段提取和智能场景组合、60秒精华自动生成\n5.内容编辑:多轨道编辑和场景识别分割、转场特效库和字幕样式编辑、贴纸滤镜和音频处理、GSAP文字动画和Logo动效\n6.深度剪辑:用户可以拖动句子,自动以切片输出内容\n7.发布管理:多格式导出和自定义分辨率码率、批量导出队列和云端渲染、进度实时展示\n8.视频特效开发:如:文字抖动,花字,特效,特殊字幕,贴片动画等工作成果:\n1.使用FFmpeg开发视频处理引擎,支持多种视频格式转码,压缩进度回调\n2.设计任务队列系统,服务器CPU使用率控制在70%以下,使用负载均衡分摊压力,实现任务批量处理\n3.集成GPT模型,视频内容理解准确率达85%,精华片段提取效率提升200%\n4.实现大文件分片上传,支持断点续传,上传成功率99%\n5.开发完整的日志追踪系统,问题定位时间缩短,使用sse日志推送至前端页面可实时观察任务处理情况,接入报警机器人报警推送至企业微信群\n6.开发前端视频渲染引擎,图片编辑器,js逆向破解支持数据采集成功\n7.封装常用前端组件库,前端框架搭建与维护,指导其他组员开发,解决问题",
|
||||
"performance": "",
|
||||
"suggestToDel": 0,
|
||||
"garbageFieldList": null,
|
||||
"startDateStr": "2024.01",
|
||||
"endDateStr": "至今"
|
||||
},
|
||||
{
|
||||
"id": "2a6ee534976187341nx50t-6EVNWyoq-Vf-e",
|
||||
"name": "水务DMA分区计量管理系统",
|
||||
"roleName": "全栈工程师",
|
||||
"url": "",
|
||||
"startDate": "2023.07",
|
||||
"endDate": "",
|
||||
"projectDesc": "DMA (DistrictMetered Area)分区计量管理系统是一种先进的、专门应用于供水管网精细化管理的综合性系统。\n通过将供水管网划分成多个相对独立的计量区域(即DMA分区),在每个分区的进水口和出水口等关键节点安装高精度的计量设备,精确监测和记录水流的流入、流出情况,实现对各个分区内水量的实时计量与分析,其核心原理是基于封闭区域水量平衡理论,通过对比流入和流出水量等数据,精准定位可能存在的管网漏损点以及评估管网运行状态,助力供水企业实现科学管理、高效节水以及提升供水服务质量.\n前端技术栈:vue全家桶+elemeui,c#+wpf\n后端技术栈:Node.js+Koa2+mysq\n功能模块:\n1.实时数据处理:对DMA分区计量设备的水流数据进行清洗、校验和初步分析\n2.GIS数据管理:提供接口实现与地理信息系统的数据交互,管理管网地理空间数据。\n3.数据存储:基于MongoDB设计数据存储架构,进行数据备份与恢复。\n4.管网可视化:通过地图展示供水管网地理空间数据,支持多种地图操作\n5.空间分析:实现管网连通性、最短路径、缓冲区等空间分析功能。\n6.地图交互:提供地图标注和测距工具。\n7.实时数据推送:利用WebSocket实时推送DMA分区计量和设备状态数据。\n8.智能报警:实时监测管网异常,生成报警信息并通知相关人员,对报警信息分类统计\n9.设备状态监控:实时获取计量设备工作状态、电量等信息,分析运行历史数据数据采集功能\n10.数据采集程序:使用C#的WPF开发可与多种RTU设备通信的采集程序\n11.动态解析:服务端动态接收并自动解析不同RTU设备的数据。\n12.数据展示与导出:以图表和表格展示采集数据,支持CSV格式动态导出。\n项目成果:支持10000+设备并发查询性能提升150%系统稳定性99.99",
|
||||
"performance": "",
|
||||
"suggestToDel": 0,
|
||||
"garbageFieldList": null,
|
||||
"startDateStr": "2023.07",
|
||||
"endDateStr": "至今"
|
||||
},
|
||||
{
|
||||
"id": "6aa05589bc43d7711nx50t-6EVNWyoq-Vf-d",
|
||||
"name": "bb物语小程序商城",
|
||||
"roleName": "全栈工程师",
|
||||
"url": "",
|
||||
"startDate": "2023.05",
|
||||
"endDate": "",
|
||||
"projectDesc": "技术架构:\n前端:Vue2+mpVue\n后端:node.js+koa2+sequelize\n数据库:MySQL+Redis\n功能模块:\n1.商品管理:商品分类、商品上架、库存管理、价格管理、商品搜索\n2.订单管理:订单创建、订单支付、订单取消、订单查询,订单统计\n3.用户管理:用户注册、用户登录、用户信息管理、用户积分、用户等级\n4.营销管理:优惠券管理、满减活动、限时抢购、拼团活动、积分兑换\n5.支付系统:微信支付、支付宝支付\n4.设计用户管理系统,支持用户注册、登录、信息管理和积分系统\n5.开发营销管理模块,支持优惠券、满减活动、限时抢购和拼团活动\n6.实现数据分析功能,支持销售数据统计,用户行为分析、商品热度分析、营销效果分析\n工作成果:\n1.实现商品管理系统,支持商品的分类、上架、库存和价格管理\n2.开发订单管理模块,支持订单的创建、支付、取消和查询\n3.集成多种支付方式,支持微信支付、支付宝支付\n4.设计用户管理系统,支持用户注册、登录、信息管理和积分系统\n5.开发营销管理模块,支持优惠券、满减活动、限时抢购和拼团活动\n6.实现数据分析功能,支持销售数据统计,用户行为分析和商品热度分析\n7.支持三级分销,以及销售人员工资统计",
|
||||
"performance": "",
|
||||
"suggestToDel": 0,
|
||||
"garbageFieldList": null,
|
||||
"startDateStr": "2023.05",
|
||||
"endDateStr": "至今"
|
||||
},
|
||||
{
|
||||
"id": "32010e34944e04851nJ_0969E1JQx4q-Vfuc",
|
||||
"name": "AI智能写手系统",
|
||||
"roleName": "全栈开发工程师",
|
||||
"url": "",
|
||||
"startDate": "2020.03",
|
||||
"endDate": "2021.07",
|
||||
"projectDesc": "技术栈:Vue2+Element Ul+WebSocket+jWT+Less+Webpack+Node.js\n核心功能:\n1.平台管理:基于IWT的权限控制,实现品牌管理、角色权限、秀场墙功能\n2.内容制作:开发智能语句生成,作品库管理,多元化批量处理功能\n3.数据采集:Chrome插件开发,实现多平台商品信息自动采集\n4.多端适配:使用Rem+Flex实现响应式布局,支持不同屏幕自适应\n5.自动发布:开发多平台发布插件,支持淘宝天猫京东等平台内自动发布\n6.定时任务:基于Node.js实现定时发布、数据同步、内容更新等动化任务\n项目成果:开发效率提升200%|首屏加载800ms|数据处理效率提升300%|服务10+品牌|运营效率\n提升400%|任务成功率99.5%",
|
||||
"performance": "开发效率提升200%首屏加载800ms数据处理效率提升300%服务10+品牌运营效率\n提升400%任务成功率99.5%",
|
||||
"suggestToDel": 0,
|
||||
"garbageFieldList": null,
|
||||
"startDateStr": "2020.03",
|
||||
"endDateStr": "2021.07"
|
||||
},
|
||||
{
|
||||
"id": "1dc51326ce11bc601nJ_0969E1JQx4q-Vfud",
|
||||
"name": "易分析取数工具",
|
||||
"roleName": "全栈开发工程师",
|
||||
"url": "",
|
||||
"startDate": "2019.08",
|
||||
"endDate": "2020.03",
|
||||
"projectDesc": "系统简介\n获取天猫,淘宝,腾讯,小红书蒲公英商家后台数据,用与大数据做营销精细化运营,支持人群画像,地域,人群,粉丝,购买,等一些列数据,以及达人数据粉丝数,带货数,直播场次,直播效果,商品评论,数据做分析\n前端技术栈:vue全家桶+elemeui+浏览器插件\n后端技术栈:Node.js+Koa2+mysql,Pythopn+Selenium+Flask\n功能模块:\n1.前端数据采集:淘宝数据银行API对接,策略中心数据抓取,生意参谋数据同步,营销数据实时获取\n2.后端数据采集:\n3.插件功能:多平台数据采集插件、自定义数据抓取规则、请求拦截和数据过滤、离线数据缓存\n4.数据处理:多维度数据整合分析、数据清洗和结构化、自定义数据导出、实时数据同步\n5.监控预警:数据采集任务监控、异常采集预警,数据质量监控、采集额度管理\n工作成果:\n1.支持淘宝全站数据采集,日均处理5000+数据\n2.开发通用数据采集引擎,支持自定义采集规则\n3.实现数据实时同步,延迟<500ms\n4.分布式爬虫系统架构,支持分布式抓取",
|
||||
"performance": "1. 设计插件热更新方案,实现核心模块动态替换和状态保持\n2. 开发多浏览器兼容层,解决不同浏览器API差异问题实现插件配置动态更新和按需加载机制\n3. 使用Sequelize设计数据模型,处理复杂的表关联和数据同步重写浏览器原生Ajax,实现智能请求拦截和缓存策略\n4. 开发分布式日志系统,支持问题快速定位",
|
||||
"suggestToDel": 0,
|
||||
"garbageFieldList": null,
|
||||
"startDateStr": "2019.08",
|
||||
"endDateStr": "2020.03"
|
||||
},
|
||||
{
|
||||
"id": "e4294f91116b15dc1nJ_0969E1JQx4q-Vfue",
|
||||
"name": "全工况智能终端采集系统",
|
||||
"roleName": ".net软件工程师",
|
||||
"url": "",
|
||||
"startDate": "2018.05",
|
||||
"endDate": "2018.08",
|
||||
"projectDesc": "技术栈:WPF+Socket+InfluxDB+PostgreSQL\n核心功能:\n1.数据采集:实现Socket高并发通信,支持多协议解析\n2.实时监控:开发设备状态实时监控界面\n3.数据存储:设计混合数据库方案,优化查询性能\n4.可视化:实现数据实时展示和趋势分析\n项目成果:支持10000+设备并发|查询性能提升150%|系统稳定性99.99%|支持亿级数据毫秒级查询",
|
||||
"performance": "1.实现百万级数据快速查询,平均查询时间<500ms\n2.设计实时数据动态渲染方案\n3.优化数据库性能,批量写入效率提升50%\n4.实现数据自动分级存储和归档\n5.开发设备状态实时监控系统",
|
||||
"suggestToDel": 0,
|
||||
"garbageFieldList": null,
|
||||
"startDateStr": "2018.05",
|
||||
"endDateStr": "2018.08"
|
||||
},
|
||||
{
|
||||
"id": "96160107ede0ae851nJ_0969E1JQx4q-Vfuf",
|
||||
"name": "宝山排水证管理系统",
|
||||
"roleName": ".net软件工程师",
|
||||
"url": "",
|
||||
"startDate": "2017.01",
|
||||
"endDate": "2017.07",
|
||||
"projectDesc": "技术栈:AngularjS+KendoUI+lonic+TypeScript+MongoDE\n核心功能:\n1.PC端开发:基于KendoUI快速构建后台管理界面,实现许可证全流程管理\n2.移动端开发:使用lonic+Aneular开发跨平台App,支持iOS和Androic\n3.表单设计:实现动态表单配置,支持多类型数据录入和自定义校验规则\n4.流程管理:开发审批流程引擎,支持条件分支、并行审批、委托授权\n5.地图功能:集成百度地图,实现排水户分布展示和空间位置选择\n6.统计分析:开发数据可视化大屏,展示许可证办理情况和区域分布移动端技术难点:\n基于lonic+Cordova实现原生功能调用,如相机、定位、文件系统等解决Android返回键监听和iOS手势返冲突问题\n实现大文件分片上传和断点续传,支持现场照片批量上传\n开发自定义相机组件,支持照片压缩和水印添加使用SOLite实现本地数据存储,解决断网环境下的数据同光\n优化Scroll性能,解决长列表滚动卡顿问题\n处理键盘弹出时的页面布局自适应\n解决iOS和Android平台字体、样式兼容性问题\n实现应用内检查更新和增量更新功能\n优化首屏加载速度,实现资源按需加载\n项目成果:办理时间缩短80%|代码复用率80%|响应时间300ms|审批效率提升200%",
|
||||
"performance": "1. 实现大文件分片上传和断点续传,支持现场照片批量上传开发自定义相机组件,支持照片压缩和水印添加使用SOLite实现本地数据存储,解决断网环境下的数据同光\n2. 优化Scroll性能,解决长列表滚动卡顿问题处理键盘弹出时的页面布局自适应解决iOS和Android平台字体、样式兼容性问题实现应用内检查更新和增量更新功能\n3. 优化首屏加载速度,实现资源按需加载",
|
||||
"suggestToDel": 0,
|
||||
"garbageFieldList": null,
|
||||
"startDateStr": "2017.01",
|
||||
"endDateStr": "2017.07"
|
||||
},
|
||||
{
|
||||
"id": "c06dfe8c3678cf231nJ90tq7EVpQxo6_V_qW",
|
||||
"name": "H5营销平台",
|
||||
"roleName": ".net软件工程师",
|
||||
"url": "",
|
||||
"startDate": "2014.06",
|
||||
"endDate": "2016.05",
|
||||
"projectDesc": "技术栈:Vue2+Canvas+CSS3+微信ISSDK+.Net\n功能模块:\n1.游戏开发:开发大转盘抽奖、刮刮卡等H5游戏、自研Canvas动画框架、支持Flash动画转换\n2.内容管理:开发可视化搭建工具、支持营销活动快速生成、实现模板在线编辑\n3.社交功能:集成微信分享、支付、授权、开发用户数据分析、实现活动数据统计\n项目成果:服务200+品牌开发效率提升300%转化率提升150%",
|
||||
"performance": "",
|
||||
"suggestToDel": 0,
|
||||
"garbageFieldList": null,
|
||||
"startDateStr": "2014.06",
|
||||
"endDateStr": "2016.05"
|
||||
},
|
||||
{
|
||||
"id": "4e09afd8b41347c61nJ_0969E1JQx4q-VfqW",
|
||||
"name": "物料ERP管理系统",
|
||||
"roleName": "前端开发工程师",
|
||||
"url": "",
|
||||
"startDate": "2014.08",
|
||||
"endDate": "2015.06",
|
||||
"projectDesc": "技术栈:jQuery+Bootstrap+EasyUl+WebSocket+SQL Server\n核心功能:\n1.库存管理:实现物料库、出库、调拨、盘点等完整业务流程\n2.采购管理:开发供应商管理、询价比价、采购计划、订单跟踪功能\n3.生产管理:实现BOM管理、生产计划、物料需求计划(MRP)功能\n4.报表分析:开发库存周转率、采购分析、成本核算等统计报表\n5.预警提醒:设置库存预警、采购超期、价格异常等自动提醒\n技术难点:\n开发物料编码生成器,支持多级分类和自定义规则\n实现基于WebSocket的实时库存变更提醒\n设计MRP运算引擎,优化大批量数据处理性能\n开发报表导出功能,支持复杂表头和数据汇总项目成果:库存周转提升40%|采购成本降低15%|支持100+用户并发|日均处理3000+笔业务",
|
||||
"performance": "1. 开发物料编码生成器,支持多级分类和自定义规则\n2. 实现基于WebSocket的实时库存变更提醒\n3. 设计MRP运算引擎,优化大批量数据处理性能\n4. 开发报表导出功能,支持复杂表头和数据汇",
|
||||
"suggestToDel": 0,
|
||||
"garbageFieldList": null,
|
||||
"startDateStr": "2014.08",
|
||||
"endDateStr": "2015.06"
|
||||
}
|
||||
],
|
||||
"educationExpList": [
|
||||
{
|
||||
"id": "ee34188f32f9cecc1XF52NW5F1dT",
|
||||
"schoolId": 2811,
|
||||
"school": "武汉工程大学",
|
||||
"major": "计算机应用技术(大数据方向)",
|
||||
"degree": 203,
|
||||
"eduType": 2,
|
||||
"degreeName": "本科",
|
||||
"startYear": "2021",
|
||||
"endYear": "2024",
|
||||
"educationDesc": "",
|
||||
"country": "",
|
||||
"tags": [
|
||||
"卓越工程师计划"
|
||||
],
|
||||
"schoolType": 0,
|
||||
"suggestToDel": 0,
|
||||
"thesisTitle": "",
|
||||
"thesisDesc": "",
|
||||
"majorRanking": 0,
|
||||
"courseList": [],
|
||||
"badge": "https://img.bosszhipin.com/beijin/icon/bed7df948518127f74daa2ee178c44fc6bb61e3b7bce0931da574d19d1d82c88.jpg",
|
||||
"certified": 0,
|
||||
"garbageFieldList": null,
|
||||
"startYearStr": "2021",
|
||||
"endYearStr": "2024"
|
||||
},
|
||||
{
|
||||
"id": "e095a1ceefdd0cc31XF52NW6FVZY",
|
||||
"schoolId": 2831,
|
||||
"school": "武汉软件工程职业学院",
|
||||
"major": "软件技术",
|
||||
"degree": 202,
|
||||
"eduType": 1,
|
||||
"degreeName": "大专",
|
||||
"startYear": "2011",
|
||||
"endYear": "2014",
|
||||
"educationDesc": "",
|
||||
"country": "",
|
||||
"tags": [],
|
||||
"schoolType": 0,
|
||||
"suggestToDel": 0,
|
||||
"thesisTitle": "",
|
||||
"thesisDesc": "",
|
||||
"majorRanking": 0,
|
||||
"courseList": [],
|
||||
"badge": "https://img.bosszhipin.com/beijin/icon/18282111c2fc8e191c5b6aedcece5a956bb61e3b7bce0931da574d19d1d82c88.jpg",
|
||||
"certified": 0,
|
||||
"garbageFieldList": null,
|
||||
"startYearStr": "2011",
|
||||
"endYearStr": "2014"
|
||||
}
|
||||
],
|
||||
"socialContactList": null,
|
||||
"volunteerExpList": null,
|
||||
"certificationList": null,
|
||||
"trainingExpList": null,
|
||||
"designWorksImage": null,
|
||||
"designWorksVideo": null,
|
||||
"personalImage": null,
|
||||
"deliciousFoodImage": null,
|
||||
"garbage": {
|
||||
"status": 0,
|
||||
"reasonCode": 0,
|
||||
"resumeDetailStatus": 0,
|
||||
"garbageBaseInfo": null,
|
||||
"baseInfo": null,
|
||||
"garbageUserDesc": null,
|
||||
"personalAdvantages": null,
|
||||
"garbageWorkExp": null,
|
||||
"workExp": null,
|
||||
"garbageEduExp": null,
|
||||
"eduExp": null,
|
||||
"garbageProjectExp": null,
|
||||
"projectExp": null,
|
||||
"garbageVolunteerExp": null,
|
||||
"volunteerExp": null,
|
||||
"garbageExpectList": null,
|
||||
"expect": null,
|
||||
"garbageCertificationList": null,
|
||||
"certification": null,
|
||||
"designWorks": null,
|
||||
"designWorksImageList": null,
|
||||
"designWorksVideoList": null,
|
||||
"garbageHandicapped": null,
|
||||
"handicapped": null,
|
||||
"trainingExp": null,
|
||||
"clubExp": null,
|
||||
"professionalSkill": null,
|
||||
"honor": null
|
||||
},
|
||||
"editStatus": {
|
||||
"canAddExpect": true,
|
||||
"canAddWorkExp": true,
|
||||
"canAddProjectExp": true,
|
||||
"canAddEduExp": true,
|
||||
"canAddSocialContact": true
|
||||
},
|
||||
"doneWorkPositionList": [],
|
||||
"handicappedInfo": null,
|
||||
"postExpList": [],
|
||||
"myLabels": null,
|
||||
"clubExpList": [],
|
||||
"professionalSkill": null,
|
||||
"honorList": [],
|
||||
"feature": {
|
||||
"showNewPositionStyle": 1,
|
||||
"showHandicappedModule": 0,
|
||||
"showResumeImportBtn": 1,
|
||||
"showResumeImportBtnRedDot": false,
|
||||
"showResumeFillHelper": 1,
|
||||
"showTrainingExpModule": 0,
|
||||
"showF3Optimize": 1,
|
||||
"showPostExpModule": 0,
|
||||
"webResumeLabelModule": 0,
|
||||
"expectLocationInterestCombine": 0,
|
||||
"stuMultiExpectChoose": 0,
|
||||
"useNewStruct": 1
|
||||
},
|
||||
"virtualPartTimeCombineExpect": null,
|
||||
"extendInfo": {
|
||||
"shareUrl": "https://m.zhipin.com/mpa/html/resume-detail?sid=self&securityId=I8Nn8H-vv1Tt2-x1OxkE4U0hH697NrOJFEJF_pkEXnMTR6gLbbUXGWy9pFLFnOy9YeLxI-31CLT9aCmPeQ_YSVDoSj1AMLuej3IhRhkUgQzm98k4pG1F7XVZdNphlh2Mc8Wr2PltQNmRB2eJHwcx4j338ACBezr_YAvcjOQ~",
|
||||
"shareText": "{\"showQQShare\":false,\"smsShareTitle\":\"牛人张成 10年以上工作经验,目标 全栈工程师职位,求靠谱Boss带走。迅速进入@Boss直聘,把TA带走,下载链接奉上:https://m.zhipin.com/download?from=duanxin\",\"wbShareTitle\":\"#招聘#牛人张成 10年以上工作经验,目标 全栈工程师职位,求靠谱Boss带走。迅速进入@Boss直聘,把TA带走,下载链接奉上:https://m.zhipin.com/download?from=weibo\",\"wxShareDesc\":\"经验:10年以上工作经验 期望薪资:20-30K\",\"wxShareTitle\":\"【Boss直聘】张成正在求职全栈工程师\"}"
|
||||
},
|
||||
"moduleList": [
|
||||
{
|
||||
"moduleType": 10,
|
||||
"moduleName": "个人信息",
|
||||
"customConfig": {
|
||||
"showType": 0,
|
||||
"desc": null,
|
||||
"tag": null
|
||||
},
|
||||
"data": {
|
||||
"name": "张成",
|
||||
"nickName": "张成",
|
||||
"tiny": "https://img.bosszhipin.com/beijin/upload/avatar/20250211/607f1f3d68754fd006d510844c0273d99b737108b9d73a664006f977785f3a6a694eb527b0e564d8_s.png.webp",
|
||||
"birthday": "1993-06",
|
||||
"age": "32岁",
|
||||
"gender": 1,
|
||||
"degree": 203,
|
||||
"degreeCategory": "本科",
|
||||
"account": "193******69",
|
||||
"emailBlur": "978****03@qq.com",
|
||||
"weixinBlur": "z56***01",
|
||||
"freshGraduate": 0,
|
||||
"workYears": 11,
|
||||
"nameShowType": 0,
|
||||
"bossCert": 0,
|
||||
"userCert": 2,
|
||||
"certGender": true,
|
||||
"certBirth": true,
|
||||
"startWorkDate": 20141201,
|
||||
"applyStatus": 0,
|
||||
"workYearDesc": "10年以上经验",
|
||||
"resumeStatus": 0,
|
||||
"resumeCount": 0,
|
||||
"complete": false,
|
||||
"weiXinSecurityUid": null,
|
||||
"garbageFieldList": null,
|
||||
"hometown": 0,
|
||||
"hometownName": null,
|
||||
"studyAbroadCertPass": 0,
|
||||
"highestEduExp": null,
|
||||
"showF3Optimize": 0,
|
||||
"startWorkDateDesc": "2014-12",
|
||||
"birthdayDesc": "1993-06"
|
||||
},
|
||||
"dataList": null
|
||||
},
|
||||
{
|
||||
"moduleType": 11,
|
||||
"moduleName": "个人优势",
|
||||
"customConfig": {
|
||||
"showType": 0,
|
||||
"desc": null,
|
||||
"tag": null
|
||||
},
|
||||
"data": {
|
||||
"userDesc": "拥有10年深厚行业经验的资深前端架构师,专注前沿技术与业务融合,推动数字化产品创新升级。\n \n1. 技术栈与架构:熟练运用Vue、React等主流框架,搭配Webpack、Vite等构建工具进行高效开发。精通TypeScript,优化代码结构与维护性。擅长使用Redux、MobX管理复杂应用状态,搭建稳定架构。\n \n2. 跨端与组件化:掌握Flutter、React Native等跨端技术,实现多平台无缝运行。主导设计高复用组件库,利用Storybook管理组件,使组件复用率达70%,开发周期缩短40%。\n \n3. AI集成:积极探索AI与前端融合,集成GPT等大模型实现智能客服、内容生成,引入机器学习算法实现用户行为分析、个性化推荐,大幅提升用户参与度。\n \n4. 音视频处理:具备音视频处理能力,使用WebRTC实现实时通信,结合FFmpeg进行格式转换、剪辑。利用Media Source Extensions实现自适应码率播放,优化视频加载与播放体验。\n\n 5. 后端及多元开发:擅长使用Node.js搭配Express、Koa框架搭建高性能后端服务,优化接口响应速度。熟练运用Python进行数据处理、自动化脚本编写,结合Django、Flask框架开发后端应用,在数据挖掘与分析领域成果显著。掌握C#语言,基于.NET平台进行Windows桌面应用开发,具备丰富的Windows Forms、WPF项目经验,实现全栈技术链路的打通。"
|
||||
},
|
||||
"dataList": null
|
||||
},
|
||||
{
|
||||
"moduleType": 24,
|
||||
"moduleName": "求职期望",
|
||||
"customConfig": {
|
||||
"showType": 0,
|
||||
"desc": null,
|
||||
"tag": null
|
||||
},
|
||||
"data": null,
|
||||
"dataList": [
|
||||
{
|
||||
"id": "dfc1777a2703c9071nVz3d-0GVpYxA~~",
|
||||
"expectType": 0,
|
||||
"position": 100123,
|
||||
"positionName": "全栈工程师",
|
||||
"customPositionId": "",
|
||||
"positionType": 0,
|
||||
"location": 101020100,
|
||||
"locationName": "上海",
|
||||
"subLocation": 0,
|
||||
"subLocationName": null,
|
||||
"lowSalary": 20,
|
||||
"highSalary": 30,
|
||||
"salaryDesc": "20-30K",
|
||||
"salaryDescNew": "20-30K",
|
||||
"industryList": [],
|
||||
"industryDesc": "行业不限",
|
||||
"suggestPosition": "",
|
||||
"applyStatus": 0,
|
||||
"freshGraduate": 0,
|
||||
"doneWorkPositionList": null,
|
||||
"garbageFieldList": null,
|
||||
"interestPositionList": null,
|
||||
"interestLocationList": null,
|
||||
"industryExpect": false,
|
||||
"tagName": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"moduleType": 12,
|
||||
"moduleName": "工作经历",
|
||||
"customConfig": {
|
||||
"showType": 0,
|
||||
"desc": null,
|
||||
"tag": null
|
||||
},
|
||||
"data": null,
|
||||
"dataList": [
|
||||
{
|
||||
"id": "9bd3116c0333c52d1nJ_09y-EldZx465V_-X",
|
||||
"companyName": "上海宝尊电子商务有限公司",
|
||||
"industry": {
|
||||
"code": 100020,
|
||||
"name": "互联网"
|
||||
},
|
||||
"department": "",
|
||||
"customPositionName": "架构师",
|
||||
"position": 100704,
|
||||
"positionName": "架构师",
|
||||
"positionLv2": 100700,
|
||||
"startDate": "2019.06",
|
||||
"startDateStr": "2019.06",
|
||||
"endDate": "2024.12",
|
||||
"endDateStr": "2024.12",
|
||||
"emphasis": [],
|
||||
"workContent": "智能视频剪辑系统(2021.07-2024.12)\n技术栈:Vue2+Element UI+WebAssembly+Canvas+Konva.js+WebSocket+GSAP\n核心功能:\n1.视频处理:基于WebAssembly解析视频信息,实现大文件分片上传,支持200G+视频处理\n2.创意编辑:使用Konva.js开发多层级编辑器,实现图片分层、文字动画、贴片特效\n3.动画系统:基于GSAP开发文字特效、Logo动画,支持动态片头片尾制作\n4.预览系统:使用Canvas实现视频片段预览,支持时间轴精确定位\n5.任务管理:基于WebSocket实现批量任务进度实时通知\n项目成果:视频处理效率提升300%|日均处理1000+视频|压缩率达97%|任务效率提升200%|降低\n人工成本60%",
|
||||
"workPerformance": "",
|
||||
"isPublic": 1,
|
||||
"workType": 1,
|
||||
"suggestToDel": 0,
|
||||
"garbageFieldList": null
|
||||
},
|
||||
{
|
||||
"id": "283b1abcae6041e41nJ_0969ElpVy423Vvic",
|
||||
"companyName": "上海航天动力科技工程有限公司",
|
||||
"industry": {
|
||||
"code": 100021,
|
||||
"name": "计算机软件"
|
||||
},
|
||||
"department": "",
|
||||
"customPositionName": "架构师",
|
||||
"position": 100704,
|
||||
"positionName": "架构师",
|
||||
"positionLv2": 100700,
|
||||
"startDate": "2018.06",
|
||||
"startDateStr": "2018.06",
|
||||
"endDate": "2019.06",
|
||||
"endDateStr": "2019.06",
|
||||
"emphasis": [],
|
||||
"workContent": "技术栈:Vue2+iView+OpenLayers+Cesium+WebSocket+Less\n核心功能:\n1.GIS可视化:基于OpenLayers实现管网GIS展示,支持多图层管理\n2.实时监控:使用WebSocket推送报警信息,实现管道水流方向动画\n3.数据分析:集成ECharts开发运营分析、报表统计功能\n4.空间分析:使用Turf.js实现等差线绘制,可视化爆管位置\n项目成果:地图加载提升200%|漏损检测准确率95%|节省成本300万+|服务10+水务公司|覆盖管\n网1000km+",
|
||||
"workPerformance": "",
|
||||
"isPublic": 1,
|
||||
"workType": 1,
|
||||
"suggestToDel": 0,
|
||||
"garbageFieldList": null
|
||||
},
|
||||
{
|
||||
"id": "d18fc7f74a6479ee1nJ_0969ElpVy423Vvid",
|
||||
"companyName": "上海开澜软件有限公司",
|
||||
"industry": {
|
||||
"code": 100021,
|
||||
"name": "计算机软件"
|
||||
},
|
||||
"department": "",
|
||||
"customPositionName": ".NET",
|
||||
"position": 100107,
|
||||
"positionName": ".NET",
|
||||
"positionLv2": 100100,
|
||||
"startDate": "2016.06",
|
||||
"startDateStr": "2016.06",
|
||||
"endDate": "2018.06",
|
||||
"endDateStr": "2018.06",
|
||||
"emphasis": [
|
||||
"HTML"
|
||||
],
|
||||
"workContent": "滩涂造地BIM管理系统(2017.01-2018.05)\n技术栈:jQuery+EasyUI+BIMViz+百度图+WebAppOffice\n核心功能:\n1.BIM可视化:集成BIMViz实现模型在线预览,支持构件查询和联动\n2.地图集成:基于百度地图API实现工程位置展示和空间分析\n3.文档管理:使用WebAppOffice实现在线预览,支持多格式文档\n4.工作流程:开发OA审批流程,实现物料申请和人员管理\n项目成果:BIM性能提升200%|审批效率提升150%|支持50+文档格式|日均处理500+工单|管理效\n率提升80%",
|
||||
"workPerformance": "",
|
||||
"isPublic": 1,
|
||||
"workType": 1,
|
||||
"suggestToDel": 0,
|
||||
"garbageFieldList": null
|
||||
},
|
||||
{
|
||||
"id": "c07196b9b117210f1nJ_0969ElpVy423Vvie",
|
||||
"companyName": "上海加谷网络科技有限公司",
|
||||
"industry": {
|
||||
"code": 100002,
|
||||
"name": "游戏"
|
||||
},
|
||||
"department": "",
|
||||
"customPositionName": ".NET",
|
||||
"position": 100107,
|
||||
"positionName": ".NET",
|
||||
"positionLv2": 100100,
|
||||
"startDate": "2014.06",
|
||||
"startDateStr": "2014.06",
|
||||
"endDate": "2016.05",
|
||||
"endDateStr": "2016.05",
|
||||
"emphasis": [
|
||||
"HTML"
|
||||
],
|
||||
"workContent": "H5营销平台开发(2014.06-2016.05)\n技术栈:jQuery+Canvas+CSS3+HTML5+微信JSSDK\n核心功能:\n1.互动游戏:开发大转盘抽奖、刮刮卡、砸金蛋等H5游戏\n2.动画特效:自研Canvas动画框架,支持Flash动画转换\n3.低代码平台:开发可视化搭建工具,支持营销活动快速生成\n4.社交功能:集成微信分享、支付、授权等功能项目成果:服务200+品牌|开发效率提升300%|上线周期缩短80%|转化率提升150%|支持百万级访问",
|
||||
"workPerformance": "",
|
||||
"isPublic": 1,
|
||||
"workType": 1,
|
||||
"suggestToDel": 0,
|
||||
"garbageFieldList": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"moduleType": 13,
|
||||
"moduleName": "项目经历",
|
||||
"customConfig": {
|
||||
"showType": 0,
|
||||
"desc": null,
|
||||
"tag": null
|
||||
},
|
||||
"data": null,
|
||||
"dataList": [
|
||||
{
|
||||
"id": "903b8e07eb7f44ca1nx50t-6EFZQw4i8UvKY",
|
||||
"name": "AI智能视频剪辑系统",
|
||||
"roleName": "全栈工程师",
|
||||
"url": "",
|
||||
"startDate": "2024.01",
|
||||
"endDate": "",
|
||||
"projectDesc": "系统简介\nai智能视频剪辑系统,可通过淘宝直播间,天猫,淘宝商品链接,自动从直播间中按照商品切片,并输出到天猫商品详情页主视频,发布到小红书,淘宝微淘等平台,可批量生成视频,处理视频,前端技术栈:vue全家桶+elemeui+浏览器插件\n后端技术栈:Node.js+Koa2+Vue2+FFmpeg+Redis\n功能模块:\n1.内容采集:开发Chrome浏览器插件自动获取直播回放、商品时间点智能识别、多平台视频源导入本地大视频文件上传,支持200g左右大文件批量上传,断点续传等功能\n2.任务管理:用户可以批量创建任务,任务实时状态使用WebSocket更新并通知\n3.模版管理:可基于execl文件批量创建任务,动态生成片尾图和首图\n4.智能生成:基于GPT的视频内容理解、关键片段提取和智能场景组合、60秒精华自动生成\n5.内容编辑:多轨道编辑和场景识别分割、转场特效库和字幕样式编辑、贴纸滤镜和音频处理、GSAP文字动画和Logo动效\n6.深度剪辑:用户可以拖动句子,自动以切片输出内容\n7.发布管理:多格式导出和自定义分辨率码率、批量导出队列和云端渲染、进度实时展示\n8.视频特效开发:如:文字抖动,花字,特效,特殊字幕,贴片动画等工作成果:\n1.使用FFmpeg开发视频处理引擎,支持多种视频格式转码,压缩进度回调\n2.设计任务队列系统,服务器CPU使用率控制在70%以下,使用负载均衡分摊压力,实现任务批量处理\n3.集成GPT模型,视频内容理解准确率达85%,精华片段提取效率提升200%\n4.实现大文件分片上传,支持断点续传,上传成功率99%\n5.开发完整的日志追踪系统,问题定位时间缩短,使用sse日志推送至前端页面可实时观察任务处理情况,接入报警机器人报警推送至企业微信群\n6.开发前端视频渲染引擎,图片编辑器,js逆向破解支持数据采集成功\n7.封装常用前端组件库,前端框架搭建与维护,指导其他组员开发,解决问题",
|
||||
"performance": "",
|
||||
"suggestToDel": 0,
|
||||
"garbageFieldList": null,
|
||||
"startDateStr": "2024.01",
|
||||
"endDateStr": "至今"
|
||||
},
|
||||
{
|
||||
"id": "2a6ee534976187341nx50t-6EVNWyoq-Vf-e",
|
||||
"name": "水务DMA分区计量管理系统",
|
||||
"roleName": "全栈工程师",
|
||||
"url": "",
|
||||
"startDate": "2023.07",
|
||||
"endDate": "",
|
||||
"projectDesc": "DMA (DistrictMetered Area)分区计量管理系统是一种先进的、专门应用于供水管网精细化管理的综合性系统。\n通过将供水管网划分成多个相对独立的计量区域(即DMA分区),在每个分区的进水口和出水口等关键节点安装高精度的计量设备,精确监测和记录水流的流入、流出情况,实现对各个分区内水量的实时计量与分析,其核心原理是基于封闭区域水量平衡理论,通过对比流入和流出水量等数据,精准定位可能存在的管网漏损点以及评估管网运行状态,助力供水企业实现科学管理、高效节水以及提升供水服务质量.\n前端技术栈:vue全家桶+elemeui,c#+wpf\n后端技术栈:Node.js+Koa2+mysq\n功能模块:\n1.实时数据处理:对DMA分区计量设备的水流数据进行清洗、校验和初步分析\n2.GIS数据管理:提供接口实现与地理信息系统的数据交互,管理管网地理空间数据。\n3.数据存储:基于MongoDB设计数据存储架构,进行数据备份与恢复。\n4.管网可视化:通过地图展示供水管网地理空间数据,支持多种地图操作\n5.空间分析:实现管网连通性、最短路径、缓冲区等空间分析功能。\n6.地图交互:提供地图标注和测距工具。\n7.实时数据推送:利用WebSocket实时推送DMA分区计量和设备状态数据。\n8.智能报警:实时监测管网异常,生成报警信息并通知相关人员,对报警信息分类统计\n9.设备状态监控:实时获取计量设备工作状态、电量等信息,分析运行历史数据数据采集功能\n10.数据采集程序:使用C#的WPF开发可与多种RTU设备通信的采集程序\n11.动态解析:服务端动态接收并自动解析不同RTU设备的数据。\n12.数据展示与导出:以图表和表格展示采集数据,支持CSV格式动态导出。\n项目成果:支持10000+设备并发查询性能提升150%系统稳定性99.99",
|
||||
"performance": "",
|
||||
"suggestToDel": 0,
|
||||
"garbageFieldList": null,
|
||||
"startDateStr": "2023.07",
|
||||
"endDateStr": "至今"
|
||||
},
|
||||
{
|
||||
"id": "6aa05589bc43d7711nx50t-6EVNWyoq-Vf-d",
|
||||
"name": "bb物语小程序商城",
|
||||
"roleName": "全栈工程师",
|
||||
"url": "",
|
||||
"startDate": "2023.05",
|
||||
"endDate": "",
|
||||
"projectDesc": "技术架构:\n前端:Vue2+mpVue\n后端:node.js+koa2+sequelize\n数据库:MySQL+Redis\n功能模块:\n1.商品管理:商品分类、商品上架、库存管理、价格管理、商品搜索\n2.订单管理:订单创建、订单支付、订单取消、订单查询,订单统计\n3.用户管理:用户注册、用户登录、用户信息管理、用户积分、用户等级\n4.营销管理:优惠券管理、满减活动、限时抢购、拼团活动、积分兑换\n5.支付系统:微信支付、支付宝支付\n4.设计用户管理系统,支持用户注册、登录、信息管理和积分系统\n5.开发营销管理模块,支持优惠券、满减活动、限时抢购和拼团活动\n6.实现数据分析功能,支持销售数据统计,用户行为分析、商品热度分析、营销效果分析\n工作成果:\n1.实现商品管理系统,支持商品的分类、上架、库存和价格管理\n2.开发订单管理模块,支持订单的创建、支付、取消和查询\n3.集成多种支付方式,支持微信支付、支付宝支付\n4.设计用户管理系统,支持用户注册、登录、信息管理和积分系统\n5.开发营销管理模块,支持优惠券、满减活动、限时抢购和拼团活动\n6.实现数据分析功能,支持销售数据统计,用户行为分析和商品热度分析\n7.支持三级分销,以及销售人员工资统计",
|
||||
"performance": "",
|
||||
"suggestToDel": 0,
|
||||
"garbageFieldList": null,
|
||||
"startDateStr": "2023.05",
|
||||
"endDateStr": "至今"
|
||||
},
|
||||
{
|
||||
"id": "32010e34944e04851nJ_0969E1JQx4q-Vfuc",
|
||||
"name": "AI智能写手系统",
|
||||
"roleName": "全栈开发工程师",
|
||||
"url": "",
|
||||
"startDate": "2020.03",
|
||||
"endDate": "2021.07",
|
||||
"projectDesc": "技术栈:Vue2+Element Ul+WebSocket+jWT+Less+Webpack+Node.js\n核心功能:\n1.平台管理:基于IWT的权限控制,实现品牌管理、角色权限、秀场墙功能\n2.内容制作:开发智能语句生成,作品库管理,多元化批量处理功能\n3.数据采集:Chrome插件开发,实现多平台商品信息自动采集\n4.多端适配:使用Rem+Flex实现响应式布局,支持不同屏幕自适应\n5.自动发布:开发多平台发布插件,支持淘宝天猫京东等平台内自动发布\n6.定时任务:基于Node.js实现定时发布、数据同步、内容更新等动化任务\n项目成果:开发效率提升200%|首屏加载800ms|数据处理效率提升300%|服务10+品牌|运营效率\n提升400%|任务成功率99.5%",
|
||||
"performance": "开发效率提升200%首屏加载800ms数据处理效率提升300%服务10+品牌运营效率\n提升400%任务成功率99.5%",
|
||||
"suggestToDel": 0,
|
||||
"garbageFieldList": null,
|
||||
"startDateStr": "2020.03",
|
||||
"endDateStr": "2021.07"
|
||||
},
|
||||
{
|
||||
"id": "1dc51326ce11bc601nJ_0969E1JQx4q-Vfud",
|
||||
"name": "易分析取数工具",
|
||||
"roleName": "全栈开发工程师",
|
||||
"url": "",
|
||||
"startDate": "2019.08",
|
||||
"endDate": "2020.03",
|
||||
"projectDesc": "系统简介\n获取天猫,淘宝,腾讯,小红书蒲公英商家后台数据,用与大数据做营销精细化运营,支持人群画像,地域,人群,粉丝,购买,等一些列数据,以及达人数据粉丝数,带货数,直播场次,直播效果,商品评论,数据做分析\n前端技术栈:vue全家桶+elemeui+浏览器插件\n后端技术栈:Node.js+Koa2+mysql,Pythopn+Selenium+Flask\n功能模块:\n1.前端数据采集:淘宝数据银行API对接,策略中心数据抓取,生意参谋数据同步,营销数据实时获取\n2.后端数据采集:\n3.插件功能:多平台数据采集插件、自定义数据抓取规则、请求拦截和数据过滤、离线数据缓存\n4.数据处理:多维度数据整合分析、数据清洗和结构化、自定义数据导出、实时数据同步\n5.监控预警:数据采集任务监控、异常采集预警,数据质量监控、采集额度管理\n工作成果:\n1.支持淘宝全站数据采集,日均处理5000+数据\n2.开发通用数据采集引擎,支持自定义采集规则\n3.实现数据实时同步,延迟<500ms\n4.分布式爬虫系统架构,支持分布式抓取",
|
||||
"performance": "1. 设计插件热更新方案,实现核心模块动态替换和状态保持\n2. 开发多浏览器兼容层,解决不同浏览器API差异问题实现插件配置动态更新和按需加载机制\n3. 使用Sequelize设计数据模型,处理复杂的表关联和数据同步重写浏览器原生Ajax,实现智能请求拦截和缓存策略\n4. 开发分布式日志系统,支持问题快速定位",
|
||||
"suggestToDel": 0,
|
||||
"garbageFieldList": null,
|
||||
"startDateStr": "2019.08",
|
||||
"endDateStr": "2020.03"
|
||||
},
|
||||
{
|
||||
"id": "e4294f91116b15dc1nJ_0969E1JQx4q-Vfue",
|
||||
"name": "全工况智能终端采集系统",
|
||||
"roleName": ".net软件工程师",
|
||||
"url": "",
|
||||
"startDate": "2018.05",
|
||||
"endDate": "2018.08",
|
||||
"projectDesc": "技术栈:WPF+Socket+InfluxDB+PostgreSQL\n核心功能:\n1.数据采集:实现Socket高并发通信,支持多协议解析\n2.实时监控:开发设备状态实时监控界面\n3.数据存储:设计混合数据库方案,优化查询性能\n4.可视化:实现数据实时展示和趋势分析\n项目成果:支持10000+设备并发|查询性能提升150%|系统稳定性99.99%|支持亿级数据毫秒级查询",
|
||||
"performance": "1.实现百万级数据快速查询,平均查询时间<500ms\n2.设计实时数据动态渲染方案\n3.优化数据库性能,批量写入效率提升50%\n4.实现数据自动分级存储和归档\n5.开发设备状态实时监控系统",
|
||||
"suggestToDel": 0,
|
||||
"garbageFieldList": null,
|
||||
"startDateStr": "2018.05",
|
||||
"endDateStr": "2018.08"
|
||||
},
|
||||
{
|
||||
"id": "96160107ede0ae851nJ_0969E1JQx4q-Vfuf",
|
||||
"name": "宝山排水证管理系统",
|
||||
"roleName": ".net软件工程师",
|
||||
"url": "",
|
||||
"startDate": "2017.01",
|
||||
"endDate": "2017.07",
|
||||
"projectDesc": "技术栈:AngularjS+KendoUI+lonic+TypeScript+MongoDE\n核心功能:\n1.PC端开发:基于KendoUI快速构建后台管理界面,实现许可证全流程管理\n2.移动端开发:使用lonic+Aneular开发跨平台App,支持iOS和Androic\n3.表单设计:实现动态表单配置,支持多类型数据录入和自定义校验规则\n4.流程管理:开发审批流程引擎,支持条件分支、并行审批、委托授权\n5.地图功能:集成百度地图,实现排水户分布展示和空间位置选择\n6.统计分析:开发数据可视化大屏,展示许可证办理情况和区域分布移动端技术难点:\n基于lonic+Cordova实现原生功能调用,如相机、定位、文件系统等解决Android返回键监听和iOS手势返冲突问题\n实现大文件分片上传和断点续传,支持现场照片批量上传\n开发自定义相机组件,支持照片压缩和水印添加使用SOLite实现本地数据存储,解决断网环境下的数据同光\n优化Scroll性能,解决长列表滚动卡顿问题\n处理键盘弹出时的页面布局自适应\n解决iOS和Android平台字体、样式兼容性问题\n实现应用内检查更新和增量更新功能\n优化首屏加载速度,实现资源按需加载\n项目成果:办理时间缩短80%|代码复用率80%|响应时间300ms|审批效率提升200%",
|
||||
"performance": "1. 实现大文件分片上传和断点续传,支持现场照片批量上传开发自定义相机组件,支持照片压缩和水印添加使用SOLite实现本地数据存储,解决断网环境下的数据同光\n2. 优化Scroll性能,解决长列表滚动卡顿问题处理键盘弹出时的页面布局自适应解决iOS和Android平台字体、样式兼容性问题实现应用内检查更新和增量更新功能\n3. 优化首屏加载速度,实现资源按需加载",
|
||||
"suggestToDel": 0,
|
||||
"garbageFieldList": null,
|
||||
"startDateStr": "2017.01",
|
||||
"endDateStr": "2017.07"
|
||||
},
|
||||
{
|
||||
"id": "c06dfe8c3678cf231nJ90tq7EVpQxo6_V_qW",
|
||||
"name": "H5营销平台",
|
||||
"roleName": ".net软件工程师",
|
||||
"url": "",
|
||||
"startDate": "2014.06",
|
||||
"endDate": "2016.05",
|
||||
"projectDesc": "技术栈:Vue2+Canvas+CSS3+微信ISSDK+.Net\n功能模块:\n1.游戏开发:开发大转盘抽奖、刮刮卡等H5游戏、自研Canvas动画框架、支持Flash动画转换\n2.内容管理:开发可视化搭建工具、支持营销活动快速生成、实现模板在线编辑\n3.社交功能:集成微信分享、支付、授权、开发用户数据分析、实现活动数据统计\n项目成果:服务200+品牌开发效率提升300%转化率提升150%",
|
||||
"performance": "",
|
||||
"suggestToDel": 0,
|
||||
"garbageFieldList": null,
|
||||
"startDateStr": "2014.06",
|
||||
"endDateStr": "2016.05"
|
||||
},
|
||||
{
|
||||
"id": "4e09afd8b41347c61nJ_0969E1JQx4q-VfqW",
|
||||
"name": "物料ERP管理系统",
|
||||
"roleName": "前端开发工程师",
|
||||
"url": "",
|
||||
"startDate": "2014.08",
|
||||
"endDate": "2015.06",
|
||||
"projectDesc": "技术栈:jQuery+Bootstrap+EasyUl+WebSocket+SQL Server\n核心功能:\n1.库存管理:实现物料库、出库、调拨、盘点等完整业务流程\n2.采购管理:开发供应商管理、询价比价、采购计划、订单跟踪功能\n3.生产管理:实现BOM管理、生产计划、物料需求计划(MRP)功能\n4.报表分析:开发库存周转率、采购分析、成本核算等统计报表\n5.预警提醒:设置库存预警、采购超期、价格异常等自动提醒\n技术难点:\n开发物料编码生成器,支持多级分类和自定义规则\n实现基于WebSocket的实时库存变更提醒\n设计MRP运算引擎,优化大批量数据处理性能\n开发报表导出功能,支持复杂表头和数据汇总项目成果:库存周转提升40%|采购成本降低15%|支持100+用户并发|日均处理3000+笔业务",
|
||||
"performance": "1. 开发物料编码生成器,支持多级分类和自定义规则\n2. 实现基于WebSocket的实时库存变更提醒\n3. 设计MRP运算引擎,优化大批量数据处理性能\n4. 开发报表导出功能,支持复杂表头和数据汇",
|
||||
"suggestToDel": 0,
|
||||
"garbageFieldList": null,
|
||||
"startDateStr": "2014.08",
|
||||
"endDateStr": "2015.06"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"moduleType": 14,
|
||||
"moduleName": "教育经历",
|
||||
"customConfig": {
|
||||
"showType": 0,
|
||||
"desc": null,
|
||||
"tag": null
|
||||
},
|
||||
"data": null,
|
||||
"dataList": [
|
||||
{
|
||||
"id": "ee34188f32f9cecc1XF52NW5F1dT",
|
||||
"schoolId": 2811,
|
||||
"school": "武汉工程大学",
|
||||
"major": "计算机应用技术(大数据方向)",
|
||||
"degree": 203,
|
||||
"eduType": 2,
|
||||
"degreeName": "本科",
|
||||
"startYear": "2021",
|
||||
"endYear": "2024",
|
||||
"educationDesc": "",
|
||||
"country": "",
|
||||
"tags": [
|
||||
"卓越工程师计划"
|
||||
],
|
||||
"schoolType": 0,
|
||||
"suggestToDel": 0,
|
||||
"thesisTitle": "",
|
||||
"thesisDesc": "",
|
||||
"majorRanking": 0,
|
||||
"courseList": [],
|
||||
"badge": "https://img.bosszhipin.com/beijin/icon/bed7df948518127f74daa2ee178c44fc6bb61e3b7bce0931da574d19d1d82c88.jpg",
|
||||
"certified": 0,
|
||||
"garbageFieldList": null,
|
||||
"startYearStr": "2021",
|
||||
"endYearStr": "2024"
|
||||
},
|
||||
{
|
||||
"id": "e095a1ceefdd0cc31XF52NW6FVZY",
|
||||
"schoolId": 2831,
|
||||
"school": "武汉软件工程职业学院",
|
||||
"major": "软件技术",
|
||||
"degree": 202,
|
||||
"eduType": 1,
|
||||
"degreeName": "大专",
|
||||
"startYear": "2011",
|
||||
"endYear": "2014",
|
||||
"educationDesc": "",
|
||||
"country": "",
|
||||
"tags": [],
|
||||
"schoolType": 0,
|
||||
"suggestToDel": 0,
|
||||
"thesisTitle": "",
|
||||
"thesisDesc": "",
|
||||
"majorRanking": 0,
|
||||
"courseList": [],
|
||||
"badge": "https://img.bosszhipin.com/beijin/icon/18282111c2fc8e191c5b6aedcece5a956bb61e3b7bce0931da574d19d1d82c88.jpg",
|
||||
"certified": 0,
|
||||
"garbageFieldList": null,
|
||||
"startYearStr": "2011",
|
||||
"endYearStr": "2014"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"moduleType": 16,
|
||||
"moduleName": "资格证书",
|
||||
"customConfig": {
|
||||
"showType": 1,
|
||||
"desc": null,
|
||||
"tag": null
|
||||
},
|
||||
"data": null,
|
||||
"dataList": null
|
||||
},
|
||||
{
|
||||
"moduleType": 21,
|
||||
"moduleName": "志愿服务经历",
|
||||
"customConfig": {
|
||||
"showType": 1,
|
||||
"desc": null,
|
||||
"tag": null
|
||||
},
|
||||
"data": null,
|
||||
"dataList": null
|
||||
}
|
||||
]
|
||||
}
|
||||
1174
_doc/客户端待开发功能.md
1174
_doc/客户端待开发功能.md
File diff suppressed because it is too large
Load Diff
@@ -1,47 +0,0 @@
|
||||
# 已删除文件清单
|
||||
|
||||
## ✅ 已删除的文件
|
||||
|
||||
### 1. 废弃的服务文件
|
||||
- ✅ `api/services/task_scheduler.js` - 未使用的任务调度器
|
||||
- **原因**:实际系统使用 `middleware/schedule/` 中的调度系统
|
||||
- **替代方案**:使用 `middleware/schedule/index.js` 中的 `ScheduleManager`
|
||||
|
||||
### 2. 已合并的服务文件
|
||||
- ✅ `api/services/job_service.js` - 职位服务(只有一个方法)
|
||||
- **原因**:只有一个 `jobGreet` 方法,已合并到 `middleware/job/jobManager.js`
|
||||
- **新位置**:`middleware/job/jobManager.js` → `job_greet()` 方法
|
||||
|
||||
### 3. 重命名的文件
|
||||
- ✅ `api/services/ossTool.js` → `api/services/oss_tool_service.js`
|
||||
- **原因**:统一命名规范(snake_case + _service.js)
|
||||
|
||||
## 📝 清理说明
|
||||
|
||||
### services/index.js 清理
|
||||
- 移除了对 `TaskScheduler` 的引用(已废弃)
|
||||
- 移除了对 `MQTTHandler` 的引用(文件不存在)
|
||||
- 移除了对 `JobService` 的引用(已合并)
|
||||
- 保留了 `AIService` 和 `PlaAccountService` 的引用
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **TaskScheduler 已废弃**
|
||||
- 实际调度系统:`middleware/schedule/index.js` (ScheduleManager)
|
||||
- 任务队列:`middleware/schedule/taskQueue.js`
|
||||
|
||||
2. **MQTT 管理**
|
||||
- 实际使用:`middleware/mqtt/mqttManager.js`
|
||||
- 不是 `services/mqtt_handler.js`(文件不存在)
|
||||
|
||||
3. **工作管理**
|
||||
- 实际使用:`middleware/job/jobManager.js`
|
||||
- 已包含 `job_greet` 方法
|
||||
|
||||
## 🔄 后续工作
|
||||
|
||||
继续完成命名规范统一:
|
||||
- 移动并重命名 `middleware/job/` 下的文件到 `services/`
|
||||
- 合并AI服务
|
||||
- 统一类命名
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
# 指令和任务模式适配检查报告
|
||||
|
||||
## 📋 检查范围
|
||||
检查 `api/middleware` 目录下的代码是否适用于新的指令和任务模式。
|
||||
|
||||
## ✅ 已适配的部分
|
||||
|
||||
### 1. **任务处理器 (taskHandlers.js)**
|
||||
- ✅ 正确使用 `command.executeCommands()` 执行指令
|
||||
- ✅ 在 `handleAutoDeliverTask` 中创建指令并执行
|
||||
- ✅ 指令类型使用驼峰命名(`getOnlineResume`, `getJobList`, `applyJob`)
|
||||
|
||||
### 2. **指令管理器 (command.js)**
|
||||
- ✅ 已重构完成,统一封装指令执行
|
||||
- ✅ 统一处理成功、失败、超时
|
||||
- ✅ 统一记录数据库
|
||||
- ✅ 支持驼峰转下划线的命名转换
|
||||
|
||||
### 3. **任务队列 (taskQueue.js)**
|
||||
- ✅ 正确使用任务处理器
|
||||
- ✅ 通过 `taskHandlers` 执行任务
|
||||
|
||||
## ⚠️ 需要修复的问题
|
||||
|
||||
### 1. **方法命名不一致**
|
||||
|
||||
**问题描述**:
|
||||
- 指令类型使用驼峰命名:`getOnlineResume`, `getJobList`, `applyJob`
|
||||
- 大部分方法使用下划线命名:`get_online_resume`, `get_job_list`
|
||||
- 但 `applyJob` 方法名是驼峰命名,与指令类型一致
|
||||
|
||||
**当前转换逻辑**:
|
||||
```javascript
|
||||
// command.js 中的转换
|
||||
const to_snake_case = (str) => {
|
||||
if (str.includes('_')) return str;
|
||||
return str.replace(/([A-Z])/g, '_$1').toLowerCase().replace(/^_/, '');
|
||||
};
|
||||
|
||||
// getOnlineResume -> get_online_resume ✓
|
||||
// getJobList -> get_job_list ✓
|
||||
// applyJob -> apply_job ✗ (但实际方法名是 applyJob)
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
1. **方案1(推荐)**:统一使用下划线命名,将 `applyJob` 改为 `apply_job`
|
||||
2. **方案2**:保持现状,`command.js` 中已支持两种命名方式(先尝试下划线,再尝试原名称)
|
||||
|
||||
**当前状态**:方案2已实现,代码可以正常工作,但命名不统一。
|
||||
|
||||
### 2. **sendChatMessage 方法**
|
||||
|
||||
**问题描述**:
|
||||
- `chatManager.js` 中的方法是 `sendChatMessage`(驼峰命名)
|
||||
- 如果指令类型是 `sendChatMessage`,转换后会变成 `send_chat_message`,但实际方法名是 `sendChatMessage`
|
||||
|
||||
**当前状态**:`command.js` 中已支持回退机制,如果下划线命名找不到,会尝试原名称,所以可以正常工作。
|
||||
|
||||
## 📊 方法命名对照表
|
||||
|
||||
| 指令类型 (command_type) | 转换后方法名 | 实际方法名 | 状态 |
|
||||
|------------------------|-------------|-----------|------|
|
||||
| `getOnlineResume` | `get_online_resume` | `get_online_resume` | ✅ 匹配 |
|
||||
| `getJobList` | `get_job_list` | `get_job_list` | ✅ 匹配 |
|
||||
| `applyJob` | `apply_job` | `applyJob` | ⚠️ 不匹配(但可工作) |
|
||||
| `sendChatMessage` | `send_chat_message` | `sendChatMessage` | ⚠️ 不匹配(但可工作) |
|
||||
|
||||
## 🔧 建议修复
|
||||
|
||||
### 方案1:统一使用下划线命名(推荐)
|
||||
|
||||
**修改文件**:
|
||||
1. `api/middleware/job/jobManager.js`:将 `applyJob` 改为 `apply_job`
|
||||
2. `api/middleware/job/chatManager.js`:将 `sendChatMessage` 改为 `send_chat_message`
|
||||
3. `api/middleware/schedule/taskHandlers.js`:将指令类型改为下划线命名
|
||||
|
||||
**优点**:
|
||||
- 命名统一,符合项目规范
|
||||
- 代码更清晰,减少混淆
|
||||
|
||||
**缺点**:
|
||||
- 需要修改多个文件
|
||||
- 可能影响其他调用方
|
||||
|
||||
### 方案2:保持现状(当前方案)
|
||||
|
||||
**优点**:
|
||||
- 不需要修改现有代码
|
||||
- `command.js` 已支持两种命名方式
|
||||
|
||||
**缺点**:
|
||||
- 命名不统一,容易混淆
|
||||
- 代码可读性稍差
|
||||
|
||||
## 📝 其他检查项
|
||||
|
||||
### 1. **deviceManager.js**
|
||||
- ✅ 不直接涉及指令和任务,主要用于设备状态管理
|
||||
- ✅ 与任务系统配合良好
|
||||
|
||||
### 2. **job/index.js**
|
||||
- ✅ 正确导出所有方法
|
||||
- ✅ 支持下划线命名规范
|
||||
|
||||
### 3. **MQTT 相关**
|
||||
- ✅ 通过 `mqttClient.publishAndWait` 发送指令
|
||||
- ✅ 与指令系统配合良好
|
||||
|
||||
## ✅ 总结
|
||||
|
||||
**整体适配情况**:**良好** ✅
|
||||
|
||||
1. ✅ 核心功能已正确适配新的指令和任务模式
|
||||
2. ✅ 指令执行统一封装,处理逻辑完善
|
||||
3. ⚠️ 存在命名不一致问题,但不影响功能(有回退机制)
|
||||
4. 💡 建议统一命名规范,提高代码可维护性
|
||||
|
||||
## 🎯 下一步行动
|
||||
|
||||
1. **可选**:统一方法命名规范(下划线命名)
|
||||
2. **可选**:添加单元测试验证指令执行流程
|
||||
3. **可选**:完善错误处理和日志记录
|
||||
|
||||
@@ -1,529 +0,0 @@
|
||||
# Boss直聘搜索列表和投递功能开发规划
|
||||
|
||||
## 📋 功能概述
|
||||
|
||||
基于Boss直聘Web端职位搜索页面(`https://www.zhipin.com/web/geek/jobs`),完善搜索列表获取和职位投递功能,包括服务端任务创建、指令生成和完整流程实现。
|
||||
|
||||
## 🎯 目标功能
|
||||
|
||||
### 1. 搜索列表功能
|
||||
- 支持多条件搜索(关键词、城市、薪资、经验、学历等)
|
||||
- 支持分页获取职位列表
|
||||
- 自动保存职位到数据库
|
||||
- 支持职位去重和更新
|
||||
|
||||
### 2. 投递功能
|
||||
- 单个职位投递
|
||||
- 批量职位投递
|
||||
- 投递状态跟踪
|
||||
- 投递记录管理
|
||||
|
||||
## 📊 功能架构
|
||||
|
||||
```
|
||||
用户/系统触发
|
||||
↓
|
||||
创建任务 (task_status)
|
||||
↓
|
||||
生成指令序列 (task_commands)
|
||||
↓
|
||||
执行指令 (通过MQTT发送到设备)
|
||||
↓
|
||||
设备执行并返回结果
|
||||
↓
|
||||
保存数据到数据库
|
||||
↓
|
||||
更新任务和指令状态
|
||||
```
|
||||
|
||||
## 🔧 技术实现
|
||||
|
||||
### 一、搜索列表功能完善
|
||||
|
||||
#### 1.1 指令参数扩展
|
||||
|
||||
**文件**: `api/middleware/job/jobManager.js`
|
||||
|
||||
**方法**: `get_job_list()`
|
||||
|
||||
**需要支持的参数**:
|
||||
```javascript
|
||||
{
|
||||
keyword: '全栈工程师', // 搜索关键词
|
||||
city: '101020100', // 城市代码(上海)
|
||||
cityName: '上海', // 城市名称
|
||||
salary: '20-30K', // 薪资范围
|
||||
experience: '3-5年', // 工作经验
|
||||
education: '本科', // 学历要求
|
||||
industry: '互联网', // 公司行业
|
||||
companySize: '100-499人', // 公司规模
|
||||
financingStage: 'B轮', // 融资阶段
|
||||
page: 1, // 页码
|
||||
pageSize: 20, // 每页数量
|
||||
pageCount: 3 // 获取页数(用于批量获取)
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.2 任务创建接口
|
||||
|
||||
**文件**: `api/services/pla_account_service.js`
|
||||
|
||||
**新增方法**: `createSearchJobListTask()`
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* 创建搜索职位列表任务
|
||||
* @param {Object} params - 任务参数
|
||||
* @param {number} params.id - 账号ID
|
||||
* @param {string} params.keyword - 搜索关键词
|
||||
* @param {string} params.city - 城市代码
|
||||
* @param {Object} params.searchParams - 搜索条件(薪资、经验、学历等)
|
||||
* @param {number} params.pageCount - 获取页数
|
||||
* @returns {Promise<Object>} 任务创建结果
|
||||
*/
|
||||
async createSearchJobListTask(params) {
|
||||
// 1. 验证账号和授权
|
||||
// 2. 创建任务记录
|
||||
// 3. 生成搜索指令
|
||||
// 4. 执行指令
|
||||
// 5. 返回任务ID
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.3 指令生成逻辑
|
||||
|
||||
**文件**: `api/middleware/schedule/taskHandlers.js`
|
||||
|
||||
**需要完善**: `handleAutoDeliverTask()` 中的搜索指令生成
|
||||
|
||||
**当前实现**:
|
||||
```javascript
|
||||
const getJobListCommand = {
|
||||
command_type: 'getJobList',
|
||||
command_name: '获取职位列表',
|
||||
command_params: JSON.stringify({
|
||||
sn_code: sn_code,
|
||||
keyword: keyword || accountConfig.keyword || '',
|
||||
platform: platform || 'boss',
|
||||
pageCount: pageCount || 3
|
||||
}),
|
||||
priority: config.getTaskPriority('search_jobs') || 5
|
||||
};
|
||||
```
|
||||
|
||||
**需要扩展为**:
|
||||
```javascript
|
||||
const getJobListCommand = {
|
||||
command_type: 'get_job_list', // 统一使用下划线命名
|
||||
command_name: '获取职位列表',
|
||||
command_params: JSON.stringify({
|
||||
sn_code: sn_code,
|
||||
platform: platform || 'boss',
|
||||
keyword: keyword || accountConfig.keyword || '',
|
||||
city: city || accountConfig.city || '101020100', // 默认上海
|
||||
cityName: cityName || accountConfig.cityName || '上海',
|
||||
salary: searchParams?.salary || '',
|
||||
experience: searchParams?.experience || '',
|
||||
education: searchParams?.education || '',
|
||||
industry: searchParams?.industry || '',
|
||||
companySize: searchParams?.companySize || '',
|
||||
financingStage: searchParams?.financingStage || '',
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
pageCount: pageCount || 3
|
||||
}),
|
||||
priority: config.getTaskPriority('get_job_list') || 5,
|
||||
sequence: 1
|
||||
};
|
||||
```
|
||||
|
||||
#### 1.4 职位数据保存优化
|
||||
|
||||
**文件**: `api/middleware/job/jobManager.js`
|
||||
|
||||
**方法**: `saveJobsToDatabase()`
|
||||
|
||||
**需要完善**:
|
||||
- 支持更多字段映射(从Boss直聘响应数据)
|
||||
- 优化位置解析逻辑
|
||||
- 支持职位状态更新(已投递、已查看等)
|
||||
- 添加职位匹配度计算
|
||||
|
||||
### 二、投递功能完善
|
||||
|
||||
#### 2.1 单个职位投递
|
||||
|
||||
**文件**: `api/middleware/job/jobManager.js`
|
||||
|
||||
**方法**: `applyJob()`
|
||||
|
||||
**当前状态**: ✅ 已实现基础功能
|
||||
|
||||
**需要完善**:
|
||||
- 支持更多投递参数(期望薪资、求职信等)
|
||||
- 优化错误处理
|
||||
- 添加投递前检查(是否已投递、是否满足条件等)
|
||||
|
||||
#### 2.2 批量职位投递任务
|
||||
|
||||
**文件**: `api/middleware/schedule/taskHandlers.js`
|
||||
|
||||
**方法**: `handleAutoDeliverTask()`
|
||||
|
||||
**当前状态**: ✅ 已实现基础功能
|
||||
|
||||
**需要完善**:
|
||||
1. **搜索条件完善**
|
||||
- 支持城市、薪资、经验、学历等筛选条件
|
||||
- 从账号配置中读取默认搜索条件
|
||||
- 支持任务参数覆盖账号配置
|
||||
|
||||
2. **职位匹配算法优化**
|
||||
- 完善距离计算(基于经纬度)
|
||||
- 完善薪资匹配(解析薪资范围字符串)
|
||||
- 完善工作年限匹配
|
||||
- 完善学历匹配
|
||||
- 完善权重评分系统
|
||||
|
||||
3. **投递指令生成**
|
||||
- 为每个匹配的职位生成投递指令
|
||||
- 支持批量投递(一次任务投递多个职位)
|
||||
- 添加投递间隔控制(避免频繁投递)
|
||||
|
||||
#### 2.3 投递任务创建接口
|
||||
|
||||
**文件**: `api/services/pla_account_service.js`
|
||||
|
||||
**新增方法**: `createDeliverTask()`
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* 创建投递任务
|
||||
* @param {Object} params - 任务参数
|
||||
* @param {number} params.id - 账号ID
|
||||
* @param {string} params.keyword - 搜索关键词
|
||||
* @param {Object} params.searchParams - 搜索条件
|
||||
* @param {Object} params.filterRules - 过滤规则
|
||||
* @param {number} params.maxCount - 最大投递数量
|
||||
* @returns {Promise<Object>} 任务创建结果
|
||||
*/
|
||||
async createDeliverTask(params) {
|
||||
// 1. 验证账号和授权
|
||||
// 2. 创建任务记录
|
||||
// 3. 生成搜索指令(获取职位列表)
|
||||
// 4. 生成投递指令序列(根据匹配结果)
|
||||
// 5. 执行任务
|
||||
// 6. 返回任务ID
|
||||
}
|
||||
```
|
||||
|
||||
## 📝 具体开发任务
|
||||
|
||||
### 任务1: 完善搜索参数支持
|
||||
|
||||
**文件**: `api/middleware/job/jobManager.js`
|
||||
|
||||
**修改方法**: `get_job_list()`
|
||||
|
||||
**任务内容**:
|
||||
1. 扩展参数支持(城市、薪资、经验、学历等)
|
||||
2. 构建完整的搜索参数对象
|
||||
3. 传递给MQTT指令
|
||||
|
||||
**代码位置**: 第153-206行
|
||||
|
||||
**预计工作量**: 2小时
|
||||
|
||||
---
|
||||
|
||||
### 任务2: 优化职位数据保存
|
||||
|
||||
**文件**: `api/middleware/job/jobManager.js`
|
||||
|
||||
**修改方法**: `saveJobsToDatabase()`
|
||||
|
||||
**任务内容**:
|
||||
1. 完善字段映射(从Boss直聘响应数据提取更多字段)
|
||||
2. 优化位置解析(减少API调用,添加缓存)
|
||||
3. 添加职位状态管理
|
||||
4. 添加职位匹配度字段
|
||||
|
||||
**代码位置**: 第215-308行
|
||||
|
||||
**预计工作量**: 3小时
|
||||
|
||||
---
|
||||
|
||||
### 任务3: 完善自动投递任务搜索条件
|
||||
|
||||
**文件**: `api/middleware/schedule/taskHandlers.js`
|
||||
|
||||
**修改方法**: `handleAutoDeliverTask()`
|
||||
|
||||
**任务内容**:
|
||||
1. 从账号配置读取默认搜索条件
|
||||
2. 支持任务参数覆盖
|
||||
3. 构建完整的搜索参数
|
||||
4. 传递给搜索指令
|
||||
|
||||
**代码位置**: 第220-233行
|
||||
|
||||
**预计工作量**: 2小时
|
||||
|
||||
---
|
||||
|
||||
### 任务4: 优化职位匹配算法
|
||||
|
||||
**文件**: `api/middleware/schedule/taskHandlers.js`
|
||||
|
||||
**修改方法**: `handleAutoDeliverTask()`
|
||||
|
||||
**任务内容**:
|
||||
1. 完善距离计算(使用经纬度计算实际距离)
|
||||
2. 完善薪资匹配(解析"20-30K"格式,转换为数值范围)
|
||||
3. 完善工作年限匹配(解析"3-5年"格式)
|
||||
4. 完善学历匹配(学历等级映射)
|
||||
5. 优化权重评分计算
|
||||
|
||||
**代码位置**: 第255-400行(职位评分和过滤逻辑)
|
||||
|
||||
**预计工作量**: 4小时
|
||||
|
||||
---
|
||||
|
||||
### 任务5: 创建搜索任务接口
|
||||
|
||||
**文件**: `api/services/pla_account_service.js`
|
||||
|
||||
**新增方法**: `createSearchJobListTask()`
|
||||
|
||||
**任务内容**:
|
||||
1. 验证账号和授权
|
||||
2. 创建任务记录
|
||||
3. 生成搜索指令
|
||||
4. 执行指令
|
||||
5. 返回任务信息
|
||||
|
||||
**代码位置**: 在 `runCommand()` 方法后添加
|
||||
|
||||
**预计工作量**: 3小时
|
||||
|
||||
---
|
||||
|
||||
### 任务6: 创建投递任务接口
|
||||
|
||||
**文件**: `api/services/pla_account_service.js`
|
||||
|
||||
**新增方法**: `createDeliverTask()`
|
||||
|
||||
**任务内容**:
|
||||
1. 验证账号和授权
|
||||
2. 创建任务记录
|
||||
3. 生成搜索指令(获取职位列表)
|
||||
4. 等待搜索完成
|
||||
5. 获取匹配的职位
|
||||
6. 生成投递指令序列
|
||||
7. 执行投递指令
|
||||
8. 返回任务信息
|
||||
|
||||
**代码位置**: 在 `createSearchJobListTask()` 方法后添加
|
||||
|
||||
**预计工作量**: 4小时
|
||||
|
||||
---
|
||||
|
||||
### 任务7: 完善指令类型映射
|
||||
|
||||
**文件**: `api/middleware/schedule/command.js`
|
||||
|
||||
**修改位置**: 指令执行逻辑
|
||||
|
||||
**任务内容**:
|
||||
1. 确保 `get_job_list` 指令类型正确映射到 `jobManager.get_job_list()`
|
||||
2. 确保 `search_jobs` 指令类型正确映射到 `jobManager.search_jobs()`
|
||||
3. 确保 `apply_job` 指令类型正确映射到 `jobManager.applyJob()`
|
||||
|
||||
**代码位置**: 第150-250行(指令执行逻辑)
|
||||
|
||||
**预计工作量**: 1小时
|
||||
|
||||
---
|
||||
|
||||
### 任务8: 添加搜索条件配置管理
|
||||
|
||||
**文件**: `api/model/pla_account.js`
|
||||
|
||||
**任务内容**:
|
||||
1. 添加搜索条件配置字段(如果不存在)
|
||||
2. 支持在账号配置中保存默认搜索条件
|
||||
3. 支持在任务参数中覆盖搜索条件
|
||||
|
||||
**相关字段**:
|
||||
- `search_config` (JSON): 搜索条件配置
|
||||
```json
|
||||
{
|
||||
"city": "101020100",
|
||||
"cityName": "上海",
|
||||
"defaultSalary": "20-30K",
|
||||
"defaultExperience": "3-5年",
|
||||
"defaultEducation": "本科"
|
||||
}
|
||||
```
|
||||
|
||||
**预计工作量**: 1小时
|
||||
|
||||
---
|
||||
|
||||
## 🔄 工作流程
|
||||
|
||||
### 搜索职位列表流程
|
||||
|
||||
```
|
||||
1. 用户/系统调用 createSearchJobListTask()
|
||||
↓
|
||||
2. 创建任务记录 (task_status)
|
||||
↓
|
||||
3. 生成搜索指令 (task_commands)
|
||||
- command_type: 'get_job_list'
|
||||
- command_params: { keyword, city, salary, ... }
|
||||
↓
|
||||
4. 执行指令 (通过MQTT发送到设备)
|
||||
↓
|
||||
5. 设备执行搜索并返回职位列表
|
||||
↓
|
||||
6. 保存职位到数据库 (job_postings)
|
||||
- 去重处理
|
||||
- 位置解析
|
||||
- 字段映射
|
||||
↓
|
||||
7. 更新指令状态为完成
|
||||
↓
|
||||
8. 更新任务状态为完成
|
||||
```
|
||||
|
||||
### 投递职位流程
|
||||
|
||||
```
|
||||
1. 用户/系统调用 createDeliverTask()
|
||||
↓
|
||||
2. 创建任务记录 (task_status)
|
||||
↓
|
||||
3. 生成搜索指令 (获取职位列表)
|
||||
- command_type: 'get_job_list'
|
||||
↓
|
||||
4. 执行搜索指令
|
||||
↓
|
||||
5. 获取职位列表并保存到数据库
|
||||
↓
|
||||
6. 根据简历信息和过滤规则匹配职位
|
||||
- 距离匹配
|
||||
- 薪资匹配
|
||||
- 工作年限匹配
|
||||
- 学历匹配
|
||||
- 权重评分
|
||||
↓
|
||||
7. 为每个匹配的职位生成投递指令
|
||||
- command_type: 'apply_job'
|
||||
- command_params: { jobId, encryptBossId, ... }
|
||||
↓
|
||||
8. 批量执行投递指令(带间隔控制)
|
||||
↓
|
||||
9. 保存投递记录 (apply_records)
|
||||
↓
|
||||
10. 更新职位状态 (job_postings.applyStatus)
|
||||
↓
|
||||
11. 更新任务状态为完成
|
||||
```
|
||||
|
||||
## 📊 数据库字段说明
|
||||
|
||||
### job_postings 表需要完善的字段
|
||||
|
||||
| 字段名 | 类型 | 说明 | 状态 |
|
||||
|--------|------|------|------|
|
||||
| `city` | VARCHAR | 城市代码 | 待添加 |
|
||||
| `cityName` | VARCHAR | 城市名称 | 待添加 |
|
||||
| `salaryMin` | INT | 最低薪资(元) | 待添加 |
|
||||
| `salaryMax` | INT | 最高薪资(元) | 待添加 |
|
||||
| `experienceMin` | INT | 最低工作年限 | 待添加 |
|
||||
| `experienceMax` | INT | 最高工作年限 | 待添加 |
|
||||
| `educationLevel` | VARCHAR | 学历等级 | 待添加 |
|
||||
| `matchScore` | DECIMAL | 匹配度评分 | 待添加 |
|
||||
|
||||
### pla_account 表需要添加的字段
|
||||
|
||||
| 字段名 | 类型 | 说明 | 状态 |
|
||||
|--------|------|------|------|
|
||||
| `search_config` | JSON | 搜索条件配置 | 待添加 |
|
||||
| `city` | VARCHAR | 默认城市代码 | 待添加 |
|
||||
| `cityName` | VARCHAR | 默认城市名称 | 待添加 |
|
||||
|
||||
## 🧪 测试计划
|
||||
|
||||
### 单元测试
|
||||
1. 测试搜索参数构建
|
||||
2. 测试职位数据保存
|
||||
3. 测试职位匹配算法
|
||||
4. 测试投递指令生成
|
||||
|
||||
### 集成测试
|
||||
1. 测试完整搜索流程
|
||||
2. 测试完整投递流程
|
||||
3. 测试任务创建和执行
|
||||
4. 测试MQTT通信
|
||||
|
||||
### 功能测试
|
||||
1. 测试多条件搜索
|
||||
2. 测试分页获取
|
||||
3. 测试批量投递
|
||||
4. 测试错误处理
|
||||
|
||||
## 📅 开发时间估算
|
||||
|
||||
| 任务 | 预计时间 | 优先级 |
|
||||
|------|----------|--------|
|
||||
| 任务1: 完善搜索参数支持 | 2小时 | 高 |
|
||||
| 任务2: 优化职位数据保存 | 3小时 | 高 |
|
||||
| 任务3: 完善自动投递任务搜索条件 | 2小时 | 高 |
|
||||
| 任务4: 优化职位匹配算法 | 4小时 | 高 |
|
||||
| 任务5: 创建搜索任务接口 | 3小时 | 中 |
|
||||
| 任务6: 创建投递任务接口 | 4小时 | 中 |
|
||||
| 任务7: 完善指令类型映射 | 1小时 | 中 |
|
||||
| 任务8: 添加搜索条件配置管理 | 1小时 | 低 |
|
||||
|
||||
**总计**: 约20小时
|
||||
|
||||
## 🚀 开发优先级
|
||||
|
||||
### 第一阶段(核心功能)
|
||||
1. 任务1: 完善搜索参数支持
|
||||
2. 任务2: 优化职位数据保存
|
||||
3. 任务3: 完善自动投递任务搜索条件
|
||||
4. 任务4: 优化职位匹配算法
|
||||
|
||||
### 第二阶段(接口完善)
|
||||
5. 任务5: 创建搜索任务接口
|
||||
6. 任务6: 创建投递任务接口
|
||||
7. 任务7: 完善指令类型映射
|
||||
|
||||
### 第三阶段(配置管理)
|
||||
8. 任务8: 添加搜索条件配置管理
|
||||
|
||||
## 📌 注意事项
|
||||
|
||||
1. **命名规范**: 统一使用下划线命名(`get_job_list` 而不是 `getJobList`)
|
||||
2. **错误处理**: 所有方法都需要完善的错误处理和日志记录
|
||||
3. **数据验证**: 所有输入参数都需要验证
|
||||
4. **性能优化**: 批量操作需要考虑性能,避免阻塞
|
||||
5. **MQTT通信**: 确保指令参数格式正确,与客户端协议一致
|
||||
6. **数据库事务**: 批量操作需要使用事务保证数据一致性
|
||||
|
||||
## 🔗 相关文件
|
||||
|
||||
- `api/middleware/job/jobManager.js` - 工作管理核心逻辑
|
||||
- `api/middleware/schedule/taskHandlers.js` - 任务处理器
|
||||
- `api/middleware/schedule/command.js` - 指令管理器
|
||||
- `api/services/pla_account_service.js` - 账号服务
|
||||
- `api/model/job_postings.js` - 职位数据模型
|
||||
- `api/model/pla_account.js` - 账号数据模型
|
||||
|
||||
210
_doc/数据库表同步指南.md
210
_doc/数据库表同步指南.md
@@ -1,210 +0,0 @@
|
||||
# resume_info 表同步指南
|
||||
|
||||
## ❌ 错误信息
|
||||
|
||||
```
|
||||
Unknown column 'sn_code' in 'field list'
|
||||
```
|
||||
|
||||
这个错误表示数据库中的 `resume_info` 表缺少 `sn_code` 字段。
|
||||
|
||||
## 🔧 解决方案
|
||||
|
||||
### 方案1: 使用同步脚本(推荐)
|
||||
|
||||
运行以下命令同步表结构:
|
||||
|
||||
```bash
|
||||
node scripts/sync_resume_table.js
|
||||
```
|
||||
|
||||
这个脚本会:
|
||||
- ✅ 使用 `alter: true` 模式同步表(保留现有数据)
|
||||
- ✅ 显示当前表结构
|
||||
- ✅ 检查所有必需字段是否存在
|
||||
- ✅ 提示缺少的字段
|
||||
|
||||
### 方案2: 手动添加字段
|
||||
|
||||
如果同步脚本无法运行,可以手动执行以下SQL:
|
||||
|
||||
```sql
|
||||
-- 添加 sn_code 字段
|
||||
ALTER TABLE `resume_info`
|
||||
ADD COLUMN `sn_code` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '设备SN码' AFTER `id`;
|
||||
|
||||
-- 添加 account_id 字段
|
||||
ALTER TABLE `resume_info`
|
||||
ADD COLUMN `account_id` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '用户ID' AFTER `sn_code`;
|
||||
|
||||
-- 添加索引
|
||||
ALTER TABLE `resume_info`
|
||||
ADD INDEX `idx_sn_code` (`sn_code`);
|
||||
```
|
||||
|
||||
### 方案3: 重建表(会删除现有数据!)
|
||||
|
||||
⚠️ **警告:此操作会删除表中所有数据!**
|
||||
|
||||
如果表中没有重要数据,可以删除表让系统重新创建:
|
||||
|
||||
```sql
|
||||
DROP TABLE IF EXISTS `resume_info`;
|
||||
```
|
||||
|
||||
然后重启应用,Sequelize 会自动创建表(因为模型中有 `sync({ force: true })`)。
|
||||
|
||||
## 📋 必需字段列表
|
||||
|
||||
`resume_info` 表必须包含以下字段:
|
||||
|
||||
### 核心字段
|
||||
- ✅ `id` - 主键(VARCHAR/UUID)
|
||||
- ✅ `sn_code` - 设备SN码(VARCHAR(50),必填)
|
||||
- ✅ `account_id` - 用户ID(VARCHAR(50),必填)
|
||||
- ✅ `platform` - 平台(VARCHAR(20),默认'boss')
|
||||
|
||||
### 个人信息
|
||||
- ✅ `fullName` - 姓名
|
||||
- ✅ `gender` - 性别
|
||||
- ✅ `age` - 年龄
|
||||
- ✅ `phone` - 电话
|
||||
- ✅ `email` - 邮箱
|
||||
- ✅ `location` - 所在地
|
||||
|
||||
### 教育背景
|
||||
- ✅ `education` - 学历
|
||||
- ✅ `major` - 专业
|
||||
- ✅ `school` - 毕业院校
|
||||
- ✅ `graduationYear` - 毕业年份
|
||||
|
||||
### 工作信息
|
||||
- ✅ `workYears` - 工作年限
|
||||
- ✅ `currentPosition` - 当前职位
|
||||
- ✅ `currentCompany` - 当前公司
|
||||
- ✅ `currentSalary` - 当前薪资
|
||||
|
||||
### 期望信息
|
||||
- ✅ `expectedPosition` - 期望职位
|
||||
- ✅ `expectedSalary` - 期望薪资
|
||||
- ✅ `expectedLocation` - 期望地点
|
||||
- ✅ `expectedIndustry` - 期望行业
|
||||
|
||||
### 技能和经验(TEXT类型)
|
||||
- ✅ `skills` - 技能标签(JSON)
|
||||
- ✅ `skillDescription` - 技能描述
|
||||
- ✅ `certifications` - 证书资质(JSON)
|
||||
- ✅ `projectExperience` - 项目经验(JSON)
|
||||
- ✅ `workExperience` - 工作经历(JSON)
|
||||
|
||||
### AI分析字段(TEXT类型)
|
||||
- ✅ `aiSkillTags` - AI技能标签(JSON)
|
||||
- ✅ `aiStrengths` - AI优势分析
|
||||
- ✅ `aiWeaknesses` - AI劣势分析
|
||||
- ✅ `aiCareerSuggestion` - AI职业建议
|
||||
- ✅ `aiCompetitiveness` - AI竞争力评分(INT)
|
||||
|
||||
### 其他字段
|
||||
- ✅ `resumeContent` - 简历内容(TEXT)
|
||||
- ✅ `originalData` - 原始数据(TEXT/JSON)
|
||||
- ✅ `isActive` - 是否活跃(BOOLEAN)
|
||||
- ✅ `isPublic` - 是否公开(BOOLEAN)
|
||||
- ✅ `syncTime` - 同步时间(DATETIME)
|
||||
|
||||
## 🔍 验证表结构
|
||||
|
||||
运行以下SQL查看表结构:
|
||||
|
||||
```sql
|
||||
DESCRIBE resume_info;
|
||||
```
|
||||
|
||||
或者查看完整的建表语句:
|
||||
|
||||
```sql
|
||||
SHOW CREATE TABLE resume_info;
|
||||
```
|
||||
|
||||
## 📝 完整建表SQL(参考)
|
||||
|
||||
```sql
|
||||
CREATE TABLE `resume_info` (
|
||||
`id` varchar(255) NOT NULL,
|
||||
`sn_code` varchar(50) NOT NULL DEFAULT '' COMMENT '设备SN码',
|
||||
`account_id` varchar(50) NOT NULL DEFAULT '' COMMENT '用户ID',
|
||||
`platform` varchar(20) NOT NULL DEFAULT 'boss' COMMENT '平台',
|
||||
`fullName` varchar(50) DEFAULT '' COMMENT '姓名',
|
||||
`gender` varchar(10) DEFAULT '' COMMENT '性别',
|
||||
`age` int(11) DEFAULT 0 COMMENT '年龄',
|
||||
`phone` varchar(20) DEFAULT '' COMMENT '电话',
|
||||
`email` varchar(100) DEFAULT '' COMMENT '邮箱',
|
||||
`location` varchar(100) DEFAULT '' COMMENT '所在地',
|
||||
`education` varchar(50) DEFAULT '' COMMENT '学历',
|
||||
`major` varchar(100) DEFAULT '' COMMENT '专业',
|
||||
`school` varchar(200) DEFAULT '' COMMENT '毕业院校',
|
||||
`graduationYear` int(11) DEFAULT 0 COMMENT '毕业年份',
|
||||
`workYears` varchar(50) DEFAULT '' COMMENT '工作年限',
|
||||
`currentPosition` varchar(100) DEFAULT '' COMMENT '当前职位',
|
||||
`currentCompany` varchar(200) DEFAULT '' COMMENT '当前公司',
|
||||
`currentSalary` varchar(50) DEFAULT '' COMMENT '当前薪资',
|
||||
`expectedPosition` varchar(100) DEFAULT '' COMMENT '期望职位',
|
||||
`expectedSalary` varchar(50) DEFAULT '' COMMENT '期望薪资',
|
||||
`expectedLocation` varchar(100) DEFAULT '' COMMENT '期望地点',
|
||||
`expectedIndustry` varchar(100) DEFAULT '' COMMENT '期望行业',
|
||||
`skills` text COMMENT '技能标签(JSON)',
|
||||
`skillDescription` text COMMENT '技能描述',
|
||||
`certifications` text COMMENT '证书资质(JSON)',
|
||||
`projectExperience` text COMMENT '项目经验(JSON)',
|
||||
`workExperience` text COMMENT '工作经历(JSON)',
|
||||
`aiSkillTags` text COMMENT 'AI技能标签(JSON)',
|
||||
`aiStrengths` text COMMENT 'AI优势分析',
|
||||
`aiWeaknesses` text COMMENT 'AI劣势分析',
|
||||
`aiCareerSuggestion` text COMMENT 'AI职业建议',
|
||||
`aiCompetitiveness` int(11) DEFAULT 0 COMMENT 'AI竞争力评分',
|
||||
`resumeContent` text COMMENT '简历内容',
|
||||
`originalData` text COMMENT '原始数据(JSON)',
|
||||
`isActive` tinyint(1) DEFAULT 1 COMMENT '是否活跃',
|
||||
`isPublic` tinyint(1) DEFAULT 1 COMMENT '是否公开',
|
||||
`syncTime` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '同步时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_sn_code` (`sn_code`),
|
||||
KEY `idx_platform` (`platform`),
|
||||
KEY `idx_isActive` (`isActive`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='简历信息表';
|
||||
```
|
||||
|
||||
## ✅ 验证修复
|
||||
|
||||
修复后,运行以下代码验证:
|
||||
|
||||
```javascript
|
||||
const db = require('./api/middleware/dbProxy');
|
||||
const resume_info = db.getModel('resume_info');
|
||||
|
||||
// 测试创建记录
|
||||
const testResume = await resume_info.create({
|
||||
id: 'test-uuid-123',
|
||||
sn_code: 'TEST001',
|
||||
account_id: 'user123',
|
||||
platform: 'boss',
|
||||
fullName: '测试用户',
|
||||
isActive: true
|
||||
});
|
||||
|
||||
console.log('✅ 创建成功:', testResume.id);
|
||||
```
|
||||
|
||||
## 🚀 下一步
|
||||
|
||||
表结构同步完成后,就可以正常使用简历存储功能了:
|
||||
|
||||
```javascript
|
||||
const jobManager = require('./api/middleware/job/jobManager');
|
||||
|
||||
const resumeData = await jobManager.get_online_resume(
|
||||
'GHJU',
|
||||
mqttClient,
|
||||
{ platform: 'boss' }
|
||||
);
|
||||
```
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
# 文件清理总结
|
||||
|
||||
## ✅ 已删除的文件
|
||||
|
||||
### 1. 废弃的服务文件
|
||||
- ✅ `api/services/task_scheduler.js`
|
||||
- **删除原因**:未使用,实际系统使用 `middleware/schedule/` 中的调度系统
|
||||
- **替代方案**:使用 `middleware/schedule/index.js` 中的 `ScheduleManager`
|
||||
|
||||
### 2. 已合并的服务文件
|
||||
- ✅ `api/services/job_service.js`
|
||||
- **删除原因**:只有一个方法,已合并到 `middleware/job/jobManager.js`
|
||||
- **新位置**:`middleware/job/jobManager.js` → `job_greet()` 方法
|
||||
|
||||
### 3. 重命名的文件
|
||||
- ✅ `api/services/ossTool.js` → `api/services/oss_tool_service.js`
|
||||
- **原因**:统一命名规范
|
||||
|
||||
## 🔧 已清理的引用
|
||||
|
||||
### services/index.js
|
||||
- ✅ 移除了对 `TaskScheduler` 的引用(已删除)
|
||||
- ✅ 移除了对 `MQTTHandler` 的引用(文件不存在)
|
||||
- ✅ 移除了对 `JobService` 的引用(已合并)
|
||||
- ✅ 移除了相关的初始化代码和监听器设置
|
||||
- ✅ 保留了 `AIService` 和 `PlaAccountService` 的引用
|
||||
|
||||
## 📋 当前 services/ 目录结构
|
||||
|
||||
```
|
||||
api/services/
|
||||
├── index.js # 服务管理器(已清理)
|
||||
├── ai_service.js # AI服务
|
||||
├── pla_account_service.js # 账号服务
|
||||
└── oss_tool_service.js # OSS服务(已重命名)
|
||||
```
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **调度系统**
|
||||
- 实际使用:`middleware/schedule/index.js` (ScheduleManager)
|
||||
- 不要使用:`services/task_scheduler.js`(已删除)
|
||||
|
||||
2. **MQTT管理**
|
||||
- 实际使用:`middleware/mqtt/mqttManager.js`
|
||||
- 不要使用:`services/mqtt_handler.js`(文件不存在)
|
||||
|
||||
3. **工作管理**
|
||||
- 实际使用:`middleware/job/jobManager.js`
|
||||
- 包含 `job_greet` 方法(原 `job_service.js` 的方法)
|
||||
|
||||
## 🎯 清理效果
|
||||
|
||||
- **减少文件数量**:删除了2个不需要的文件
|
||||
- **代码更清晰**:移除了无效引用
|
||||
- **结构更合理**:services 目录只保留实际使用的服务
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
# 服务合并完成说明
|
||||
|
||||
## ✅ 已完成
|
||||
|
||||
### 1. job_service.js 合并
|
||||
- ✅ 将 `job_service.js` 的 `jobGreet` 方法合并到 `jobManager.js`
|
||||
- ✅ 方法重命名为 `job_greet`(统一使用下划线命名)
|
||||
- ✅ 更新了 `api/controller_admin/job_postings.js` 中的引用
|
||||
- ✅ 更新了 `api/services/index.js`,移除了 `JobService` 的引用
|
||||
- ✅ 删除了 `job_service.js` 文件
|
||||
|
||||
### 2. 方法改进
|
||||
- ✅ `job_greet` 方法支持可选的 `mqttClient` 参数
|
||||
- ✅ 修复了 `getResumeAnalysis` 方法的 `mqttClient` 参数问题
|
||||
|
||||
## 📝 变更详情
|
||||
|
||||
### 方法位置变更
|
||||
- **原位置**:`api/services/job_service.js` → `JobService.jobGreet()`
|
||||
- **新位置**:`api/middleware/job/jobManager.js` → `JobManager.job_greet()`
|
||||
|
||||
### 方法签名变更
|
||||
```javascript
|
||||
// 旧方法
|
||||
async jobGreet(params) {
|
||||
// ...
|
||||
}
|
||||
|
||||
// 新方法
|
||||
async job_greet(params) {
|
||||
const { sn_code, encryptJobId, securityId, brandName, platform = 'boss', mqttClient } = params;
|
||||
// 支持可选的 mqttClient 参数
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 引用更新
|
||||
- `api/controller_admin/job_postings.js`
|
||||
- `jobService.jobGreet()` → `jobManager.job_greet()`
|
||||
|
||||
## 🎯 优势
|
||||
|
||||
1. **代码更集中**:所有工作管理相关的方法都在 `jobManager.js` 中
|
||||
2. **减少文件数量**:删除了只有一个方法的服务文件
|
||||
3. **命名统一**:使用下划线命名 `job_greet`,与其他方法一致
|
||||
4. **更好的复用性**:支持可选的 `mqttClient` 参数
|
||||
|
||||
## 📋 后续工作
|
||||
|
||||
继续完成命名规范统一:
|
||||
- 移动并重命名 `middleware/job/` 下的文件到 `services/`
|
||||
- 合并AI服务
|
||||
- 统一类命名
|
||||
|
||||
@@ -1,320 +0,0 @@
|
||||
# 服务端升级功能逻辑说明
|
||||
|
||||
## 概述
|
||||
|
||||
本文档说明服务端需要实现的升级功能逻辑,配合客户端的自动升级功能。
|
||||
|
||||
## API 接口要求
|
||||
|
||||
### GET /version/check
|
||||
|
||||
**功能**:检查是否有新版本可用
|
||||
|
||||
**请求参数**(Query 参数):
|
||||
- `current_version`: 当前版本号(x.y.z 格式,如 "1.0.0")
|
||||
- `platform`: 平台类型(如 "win32", "darwin", "linux")
|
||||
- `arch`: 架构类型(如 "x64", "ia32", "arm64")
|
||||
- `sn_code`: 设备序列号(可选,用于权限控制)
|
||||
|
||||
**响应格式**:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"version": "1.1.0",
|
||||
"download_url": "http://work.light120.com/downloads/app-1.1.0.exe",
|
||||
"release_notes": "修复了一些bug,新增了xxx功能",
|
||||
"force_update": false,
|
||||
"file_size": 52428800,
|
||||
"file_hash": "abc123def456..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**:
|
||||
- `version`: 最新版本号(x.y.z 格式)
|
||||
- `download_url`: 更新包下载地址(完整的 HTTP/HTTPS URL)
|
||||
- `release_notes`: 更新日志(可选,字符串)
|
||||
- `force_update`: 是否强制更新(可选,布尔值,默认 false)
|
||||
- `file_size`: 文件大小(字节,可选)
|
||||
- `file_hash`: 文件 SHA256 哈希值(可选,用于校验文件完整性)
|
||||
|
||||
**业务逻辑**:
|
||||
|
||||
1. **版本比较**:
|
||||
- 从数据库查询最新版本信息(根据 platform 和 arch)
|
||||
- 比较请求中的 `current_version` 与数据库中的最新版本
|
||||
- 如果最新版本 > 当前版本,返回更新信息
|
||||
- 如果版本相同或更旧,返回 `code: 0, data: null` 或提示"已是最新版本"
|
||||
|
||||
2. **版本号格式**:
|
||||
- 格式:x.y.z(如 1.0.0, 1.1.0, 2.0.0)
|
||||
- 比较规则:主版本号 > 次版本号 > 修订号
|
||||
- 示例:1.1.0 > 1.0.0, 2.0.0 > 1.9.9
|
||||
|
||||
3. **平台和架构过滤**:
|
||||
- 只返回匹配 platform 和 arch 的版本信息
|
||||
- 如果某个平台没有新版本,返回空结果
|
||||
|
||||
4. **序列号验证**(可选):
|
||||
- 可以根据 sn_code 验证设备权限
|
||||
- 如果启用权限控制,未授权的设备返回错误
|
||||
|
||||
5. **错误处理**:
|
||||
- 参数缺失:返回 `code: 400, message: "参数错误"`
|
||||
- 服务器错误:返回 `code: 500, message: "服务器错误"`
|
||||
- 无新版本:返回 `code: 0, data: null` 或 `has_update: false`
|
||||
|
||||
## 数据库设计建议
|
||||
|
||||
### 版本信息表(version_info)
|
||||
|
||||
| 字段名 | 类型 | 说明 | 约束 |
|
||||
|--------|------|------|------|
|
||||
| id | INT/BIGINT | 主键 | PRIMARY KEY, AUTO_INCREMENT |
|
||||
| version | VARCHAR(20) | 版本号(x.y.z 格式) | NOT NULL, UNIQUE |
|
||||
| platform | VARCHAR(20) | 平台类型(win32/darwin/linux) | NOT NULL |
|
||||
| arch | VARCHAR(20) | 架构类型(x64/ia32/arm64) | NOT NULL |
|
||||
| download_url | VARCHAR(500) | 下载地址 | NOT NULL |
|
||||
| file_path | VARCHAR(500) | 服务器文件路径 | NOT NULL |
|
||||
| file_size | BIGINT | 文件大小(字节) | DEFAULT 0 |
|
||||
| file_hash | VARCHAR(64) | SHA256 哈希值 | |
|
||||
| release_notes | TEXT | 更新日志 | |
|
||||
| force_update | TINYINT(1) | 是否强制更新 | DEFAULT 0 |
|
||||
| status | TINYINT(1) | 状态(1:启用 0:禁用) | DEFAULT 1 |
|
||||
| create_time | DATETIME | 创建时间 | NOT NULL, DEFAULT CURRENT_TIMESTAMP |
|
||||
| last_modify_time | DATETIME | 最后修改时间 | NOT NULL, DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP |
|
||||
|
||||
**索引建议**:
|
||||
- PRIMARY KEY (id)
|
||||
- UNIQUE KEY (version, platform, arch)
|
||||
- INDEX (platform, arch, status)
|
||||
|
||||
### 版本发布历史表(version_history)(可选)
|
||||
|
||||
用于记录版本发布历史,便于管理:
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| id | INT/BIGINT | 主键 |
|
||||
| version_id | INT | 关联 version_info.id |
|
||||
| release_type | VARCHAR(20) | 发布类型(stable/beta/alpha) |
|
||||
| create_time | DATETIME | 发布时间 |
|
||||
| last_modify_time | DATETIME | 最后修改时间 |
|
||||
|
||||
## 业务逻辑流程
|
||||
|
||||
### 1. 版本检查流程
|
||||
|
||||
```
|
||||
客户端请求
|
||||
↓
|
||||
接收参数(current_version, platform, arch, sn_code)
|
||||
↓
|
||||
验证参数有效性
|
||||
↓
|
||||
查询数据库最新版本(按 platform + arch + status=1)
|
||||
↓
|
||||
版本号比较
|
||||
↓
|
||||
有更新?
|
||||
├─ 是 → 构建返回数据(包含文件信息)
|
||||
└─ 否 → 返回空结果或提示"已是最新版本"
|
||||
↓
|
||||
返回响应
|
||||
```
|
||||
|
||||
### 2. 版本管理流程
|
||||
|
||||
**新增版本**:
|
||||
1. 上传安装包文件到服务器指定目录
|
||||
2. 计算文件 SHA256 哈希值
|
||||
3. 获取文件大小
|
||||
4. 插入数据库记录(status=1 表示启用)
|
||||
|
||||
**禁用版本**:
|
||||
1. 更新 status=0(不删除记录,保留历史)
|
||||
|
||||
**删除版本**:
|
||||
1. 删除数据库记录
|
||||
2. 删除服务器上的文件
|
||||
|
||||
### 3. 文件存储建议
|
||||
|
||||
**目录结构**:
|
||||
```
|
||||
/uploads/
|
||||
└── versions/
|
||||
├── win32/
|
||||
│ ├── x64/
|
||||
│ │ ├── app-1.0.0.exe
|
||||
│ │ └── app-1.1.0.exe
|
||||
│ └── ia32/
|
||||
└── darwin/
|
||||
└── x64/
|
||||
└── app-1.1.0.dmg
|
||||
```
|
||||
|
||||
**文件命名规则**:
|
||||
- 格式:`app-{version}.{ext}`
|
||||
- 示例:`app-1.1.0.exe`, `app-1.1.0.dmg`
|
||||
|
||||
**下载 URL 生成**:
|
||||
- 基础 URL:`http://work.light120.com/downloads/`
|
||||
- 完整 URL:`http://work.light120.com/downloads/app-1.1.0.exe`
|
||||
|
||||
## 关键逻辑说明
|
||||
|
||||
### 1. 版本号比较逻辑
|
||||
|
||||
**字符串比较规则**:
|
||||
- 将版本号按 "." 分割成数组:["1", "1", "0"]
|
||||
- 逐位比较数字大小
|
||||
- 主版本号优先级最高,依次递减
|
||||
|
||||
**示例**:
|
||||
```javascript
|
||||
// 伪代码
|
||||
compareVersion("1.1.0", "1.0.0") → true // 1.1.0 > 1.0.0
|
||||
compareVersion("2.0.0", "1.9.9") → true // 2.0.0 > 1.9.9
|
||||
compareVersion("1.0.0", "1.0.0") → false // 相等
|
||||
```
|
||||
|
||||
### 2. 文件哈希计算
|
||||
|
||||
**计算方法**:
|
||||
- 使用 SHA256 算法
|
||||
- 读取文件内容,计算哈希值
|
||||
- 返回小写的十六进制字符串
|
||||
|
||||
**用途**:
|
||||
- 客户端下载后校验文件完整性
|
||||
- 防止文件被篡改
|
||||
- 确保文件下载完整
|
||||
|
||||
### 3. 强制更新逻辑
|
||||
|
||||
**force_update 字段**:
|
||||
- `true`: 强制更新,客户端必须更新才能使用
|
||||
- `false`: 可选更新,客户端可以选择稍后更新
|
||||
|
||||
**业务场景**:
|
||||
- 安全漏洞修复 → 强制更新
|
||||
- 重大功能更新 → 强制更新
|
||||
- 小版本更新 → 可选更新
|
||||
|
||||
### 4. 平台和架构支持
|
||||
|
||||
**支持列表**:
|
||||
- Windows: `win32` + `x64` / `ia32`
|
||||
- macOS: `darwin` + `x64` / `arm64`
|
||||
- Linux: `linux` + `x64` / `arm64`
|
||||
|
||||
**查询逻辑**:
|
||||
- 必须同时匹配 platform 和 arch
|
||||
- 如果某个组合没有版本,返回空结果
|
||||
|
||||
## 安全考虑
|
||||
|
||||
### 1. 文件下载安全
|
||||
|
||||
- **HTTPS 下载**:优先使用 HTTPS 协议
|
||||
- **文件校验**:提供 SHA256 哈希值供客户端校验
|
||||
- **文件大小验证**:返回文件大小,客户端可以验证下载完整性
|
||||
|
||||
### 2. 权限控制
|
||||
|
||||
- **序列号验证**:可以根据 sn_code 控制哪些设备可以更新
|
||||
- **版本状态**:使用 status 字段控制版本是否可用
|
||||
- **IP 限制**:可以限制下载 IP 范围(可选)
|
||||
|
||||
### 3. 防止恶意更新
|
||||
|
||||
- **文件类型验证**:只允许上传 .exe、.dmg、.AppImage 等安装包
|
||||
- **文件大小限制**:设置最大文件大小限制
|
||||
- **版本号验证**:验证版本号格式是否正确
|
||||
|
||||
## 返回状态码说明
|
||||
|
||||
| code | 说明 | 处理方式 |
|
||||
|------|------|----------|
|
||||
| 0 | 成功 | 检查 data 是否为空判断是否有更新 |
|
||||
| 400 | 参数错误 | 客户端提示参数错误 |
|
||||
| 401 | 未授权 | 客户端提示未授权 |
|
||||
| 404 | 未找到版本 | 客户端提示版本不存在 |
|
||||
| 500 | 服务器错误 | 客户端提示服务器错误,稍后重试 |
|
||||
|
||||
## 示例场景
|
||||
|
||||
### 场景1:有新版本
|
||||
|
||||
**请求**:
|
||||
```
|
||||
GET /version/check?current_version=1.0.0&platform=win32&arch=x64&sn_code=GHJU
|
||||
```
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"version": "1.1.0",
|
||||
"download_url": "http://work.light120.com/downloads/app-1.1.0.exe",
|
||||
"release_notes": "1. 修复了登录问题\n2. 新增自动升级功能",
|
||||
"force_update": false,
|
||||
"file_size": 52428800,
|
||||
"file_hash": "a1b2c3d4e5f6..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 场景2:已是最新版本
|
||||
|
||||
**请求**:
|
||||
```
|
||||
GET /version/check?current_version=1.1.0&platform=win32&arch=x64&sn_code=GHJU
|
||||
```
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "已是最新版本",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
### 场景3:参数错误
|
||||
|
||||
**请求**:
|
||||
```
|
||||
GET /version/check?current_version=1.0.0
|
||||
```
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"code": 400,
|
||||
"message": "缺少必要参数:platform, arch",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **日期字段命名**:统一使用 `create_time` 和 `last_modify_time`
|
||||
2. **版本号格式**:严格按照 x.y.z 格式,便于版本比较
|
||||
3. **文件存储**:建议使用 CDN 或静态文件服务器,提高下载速度
|
||||
4. **日志记录**:记录版本检查请求,便于统计和分析
|
||||
5. **缓存策略**:可以考虑缓存最新版本信息,减少数据库查询
|
||||
6. **灰度发布**:可以通过 sn_code 控制部分设备先更新(可选)
|
||||
|
||||
## 实现优先级
|
||||
|
||||
1. **基础功能**:版本检查、版本比较、返回下载信息
|
||||
2. **文件管理**:文件上传、文件存储、哈希计算
|
||||
3. **安全功能**:文件校验、权限控制
|
||||
4. **管理功能**:版本管理后台、历史记录
|
||||
|
||||
544
_doc/消息.md
Normal file
544
_doc/消息.md
Normal file
@@ -0,0 +1,544 @@
|
||||
messages[
|
||||
{
|
||||
"uncount": 1,
|
||||
"flag": 0,
|
||||
"bizType": 21050003,
|
||||
"mid": 306388872406018,
|
||||
"received": true,
|
||||
"securityId": "cVB1gt2iMS_FV-B1EuTtuprq9nPmk046hua_alhFbU9irJdmTCNqWxiCzyABGAJxRhHJ19omwFtTF62aNLx0Kb-jEBjLnK5zAvB5QJQ74XNKfm3GvYbAw6Hat_UPGUYrWuoC78uf6-i_4mQDfnri1cgi33PGu4t76fHCBmlQgeyNQYZtvlemjQ~~",
|
||||
"cmid": 0,
|
||||
"type": 3,
|
||||
"body": {
|
||||
"type": 8,
|
||||
"templateId": 1,
|
||||
"jobDesc": {
|
||||
"education": "本科",
|
||||
"boss": {
|
||||
"uid": 631237132,
|
||||
"headImg": 7,
|
||||
"name": "韩先生",
|
||||
"company": "",
|
||||
"avatar": "https://img.bosszhipin.com/boss/avatar/avatar_7.png",
|
||||
"source": 0,
|
||||
"certification": 0
|
||||
},
|
||||
"distance": "",
|
||||
"city": "上海 浦东新区 北蔡",
|
||||
"lid": "",
|
||||
"partTimeDesc": "",
|
||||
"expectId": 1421763565,
|
||||
"title": "信息系统产品经理",
|
||||
"salary": "20-30K",
|
||||
"experience": "3-5年",
|
||||
"bottomText": "2月1日 18:19 由你发起的沟通",
|
||||
"content": "",
|
||||
"jobLabel": "",
|
||||
"latlon": "",
|
||||
"expectPosition": "",
|
||||
"company": "中建八局上海公司",
|
||||
"url": "bosszp://bosszhipin.app/openwith?type=jobview&jid=510160286&uid=631237132&securityId=_gWIU90mUoEub-b1Uw8xol50QDeii-lYj5J6gGaUDpLqCFr6_7TAHagl3UwLBh3S8tgBUY_91xh-LMPqDo56Bhg2tM5ptshBG4OSYvjb2szm9uGWm64E8GFe2pIXtMoJJW53RY_enX-0S41IAQyZSwBHGDLKYC-ajb88mwLIgFzHv26-aCA6uQXewrLCx0JKQZdIcGdUYOLlbbjVOXgCwPV04Ih_o6_BUwTq8iZjpMMBSuMa_abda3A_rjsOeY_qaN0tPcB92T2m3a0xMseYDQyJQj6x8CqTN3Wn8NhF3RuYC3KvDRGYC22HGNfGIHRHIbXlOIm5N6HK6UFRkfQMoYIl6KIJOm9-q3mHf4exItvw1_K8ArABDBF2koQ0lyCMu0bvrscAuUZCY4ggK_r4iUNaJz-r7mvO5JLona10LTHVmZ5gRGXej4kIPT9F2lj02KdK0nKWG8ilYQ2AzD0FbUI52lYa5CgAu5hhO9_kHcsJdfhxKCaSbiM_To6iTrJtfEksQnmqwMLU6tpTw-N3SA-UJsXfhwJRV3uhDsKGJeIFDpJjZ6IFr3qVVIDzwROyUZlK6Tu8Y8_nnKHxrtuIKfQPU8mFrWf02LtWWJg~",
|
||||
"extend": "{\"jobType\":0}",
|
||||
"jobId": 510160286,
|
||||
"stage": "未融资",
|
||||
"geek": {
|
||||
"uid": 546224865,
|
||||
"headImg": 0,
|
||||
"name": "",
|
||||
"company": "",
|
||||
"avatar": "",
|
||||
"source": 0,
|
||||
"certification": 0
|
||||
},
|
||||
"iconFlag": 0,
|
||||
"positionCategory": "产品经理",
|
||||
"bossTitle": "招聘主管",
|
||||
"expectSalary": ""
|
||||
},
|
||||
"headTitle": "您正在与Boss韩先生直接沟通如下职位"
|
||||
},
|
||||
"offline": false,
|
||||
"pushSound": 0,
|
||||
"from": {
|
||||
"uid": 631237132,
|
||||
"headImg": 7,
|
||||
"name": "韩先生",
|
||||
"company": "",
|
||||
"avatar": "https://img.bosszhipin.com/boss/avatar/avatar_7.png",
|
||||
"source": 0,
|
||||
"certification": 0
|
||||
},
|
||||
"to": {
|
||||
"uid": 546224865,
|
||||
"headImg": 15,
|
||||
"name": "肖伟民",
|
||||
"company": "",
|
||||
"avatar": "https://img.bosszhipin.com/boss/avatar/avatar_15.png",
|
||||
"source": 0,
|
||||
"certification": 0
|
||||
},
|
||||
"time": 1769941171000,
|
||||
"taskId": 0,
|
||||
"status": 2
|
||||
},
|
||||
{
|
||||
"uncount": 0,
|
||||
"flag": 0,
|
||||
"bizType": 101,
|
||||
"mid": 306388872410117,
|
||||
"received": true,
|
||||
"securityId": "HGFvURTQnW2V8-_1M1FQeOyKfMqBVGmx4iYXvV-ja6Ax2OHah-vLgPuQbHIZIxOV9DTQVpbE4IDFvjkbMEeBVFun5rsz1N-55pIQUpuqzGCHJTPKTSH6pYKpFoUtSueouw04tw~~",
|
||||
"cmid": 0,
|
||||
"type": 3,
|
||||
"body": {
|
||||
"extend": "",
|
||||
"text": "您正在招的信息系统产品经理我很有兴趣,如果方便,希望可以和您进一步沟通,谢谢!",
|
||||
"type": 1,
|
||||
"templateId": 1,
|
||||
"headTitle": ""
|
||||
},
|
||||
"offline": false,
|
||||
"pushSound": 0,
|
||||
"bizId": "42",
|
||||
"from": {
|
||||
"uid": 546224865,
|
||||
"headImg": 15,
|
||||
"name": "肖伟民",
|
||||
"company": "",
|
||||
"avatar": "https://img.bosszhipin.com/boss/avatar/avatar_15.png",
|
||||
"source": 0,
|
||||
"certification": 0
|
||||
},
|
||||
"to": {
|
||||
"uid": 631237132,
|
||||
"headImg": 7,
|
||||
"name": "韩先生",
|
||||
"company": "",
|
||||
"avatar": "https://img.bosszhipin.com/boss/avatar/avatar_7.png",
|
||||
"source": 0,
|
||||
"certification": 0
|
||||
},
|
||||
"time": 1769941171000,
|
||||
"pushText": "肖伟民:您正在招的信息系统产品经理我很有兴趣,如果方便,希望可以和您进一步沟通,谢谢!",
|
||||
"taskId": 0,
|
||||
"status": 2
|
||||
},
|
||||
{
|
||||
"uncount": 1,
|
||||
"flag": 0,
|
||||
"bizType": 317,
|
||||
"mid": 306388872438784,
|
||||
"received": true,
|
||||
"securityId": "scWxOY-tkn0ih-A1bkgY7cd1djlTDxgd7J24wQXQqZvi8wn_teicgd2zAj7dz74o5LYrEdUJsKDHzdyUzdqrWs55p9cUQXsroGgVJrQlUspiITmPBY23-nVeei7VytBBuFATuw~~",
|
||||
"cmid": 0,
|
||||
"type": 4,
|
||||
"body": {
|
||||
"style": 3,
|
||||
"type": 16,
|
||||
"templateId": 1,
|
||||
"articles": [
|
||||
{
|
||||
"extend": "{\"descriptionHighParts\":[{\"endIndex\":8,\"startIndex\":6},{\"endIndex\":14,\"startIndex\":12}],\"avatarList\":[\"https://img.bosszhipin.com/beijin/upload/item/20221117/e84be512208dcc09095058edb5e6d5d9273d3250d5a0c4c56bb61e3b7bce0931da574d19d1d82c88.png\",\"https://img.bosszhipin.com/beijin/upload/item/20221117/e84be512208dcc09ba9684830b5f873d7f98f2d5757b12c86bb61e3b7bce0931da574d19d1d82c88.png\",\"https://img.bosszhipin.com/beijin/upload/item/20221117/e84be512208dcc09f663dc109c9552b92d74732363d6a4056bb61e3b7bce0931da574d19d1d82c88.png\"]}",
|
||||
"picUrl": "https://img.bosszhipin.com/beijin/icon/bed51f39faf420a15620181baffee482f7aba6f40b0808dd3b1d96fc3abbc5af.png",
|
||||
"highlightParts": [
|
||||
{
|
||||
"startIndex": 1,
|
||||
"endIndex": 3
|
||||
},
|
||||
{
|
||||
"startIndex": 10,
|
||||
"endIndex": 12
|
||||
}
|
||||
],
|
||||
"subTitle": "共**人投递,你超过**竞争者",
|
||||
"description": "优秀竞争者会**,建议你**",
|
||||
"statisticParameters": "",
|
||||
"title": "你与该职位竞争者PK情况",
|
||||
"templateId": 5,
|
||||
"url": "https://m.zhipin.com/mpa/html/props/transit?targetId=a875d648fa6db1490nV62tu9ElpW&sendNum=1&bossId=5e86870fd010f0eb0Xd72d66EVFS",
|
||||
"bottomText": "查看详细分析",
|
||||
"timeout": 0
|
||||
}
|
||||
],
|
||||
"headTitle": ""
|
||||
},
|
||||
"offline": false,
|
||||
"pushSound": 0,
|
||||
"from": {
|
||||
"uid": 631237132,
|
||||
"headImg": 7,
|
||||
"name": "韩先生",
|
||||
"company": "",
|
||||
"avatar": "https://img.bosszhipin.com/boss/avatar/avatar_7.png",
|
||||
"source": 0,
|
||||
"certification": 0
|
||||
},
|
||||
"to": {
|
||||
"uid": 546224865,
|
||||
"headImg": 15,
|
||||
"name": "肖伟民",
|
||||
"company": "",
|
||||
"avatar": "https://img.bosszhipin.com/boss/avatar/avatar_15.png",
|
||||
"source": 0,
|
||||
"certification": 0
|
||||
},
|
||||
"time": 1769941171000,
|
||||
"pushText": "你与该职位竞争者PK情况",
|
||||
"taskId": 0,
|
||||
"status": 2
|
||||
},
|
||||
{
|
||||
"uncount": 0,
|
||||
"flag": 0,
|
||||
"bizType": 110,
|
||||
"mid": 306962385814530,
|
||||
"received": true,
|
||||
"securityId": "2Qs1rcvXn2axz-Z1F_qA9Hr2oJm3oy4XqZZxP3u_iQc1b13a9hYHxYzEYHGRR5iffvX9fV3HQ02WePxjcHPZ3H2gHkLE2kI8aSRvEYf18TcPjNkA7nR8bBL75uun6ErmddoTvQ~~",
|
||||
"cmid": 0,
|
||||
"type": 3,
|
||||
"body": {
|
||||
"extend": "",
|
||||
"text": "我们感谢您的投递,但您的专业技能与我们目前的职位需求并不完全吻合。祝您在BOSS直聘找到更适合您的工作!",
|
||||
"type": 1,
|
||||
"templateId": 1,
|
||||
"headTitle": ""
|
||||
},
|
||||
"offline": false,
|
||||
"pushSound": 0,
|
||||
"from": {
|
||||
"uid": 631237132,
|
||||
"headImg": 7,
|
||||
"name": "韩先生",
|
||||
"company": "",
|
||||
"avatar": "https://img.bosszhipin.com/boss/avatar/avatar_7.png",
|
||||
"source": 0,
|
||||
"certification": 0
|
||||
},
|
||||
"to": {
|
||||
"uid": 546224865,
|
||||
"headImg": 15,
|
||||
"name": "肖伟民",
|
||||
"company": "",
|
||||
"avatar": "https://img.bosszhipin.com/boss/avatar/avatar_15.png",
|
||||
"source": 0,
|
||||
"certification": 0
|
||||
},
|
||||
"time": 1770081189000,
|
||||
"pushText": "韩先生:我们感谢您的投递,但您的专业技能与我们目前的职位需求并不完全吻合。祝您在BOSS直聘找到更适合您的工作!",
|
||||
"taskId": 0,
|
||||
"status": 2
|
||||
},
|
||||
{
|
||||
"uncount": 0,
|
||||
"flag": 0,
|
||||
"mid": 307012846076929,
|
||||
"received": true,
|
||||
"securityId": "G91RCDcRlfd6n-Y1v0PcuW2JmcEY6RpuBJKIAI3ODWIfRD2XePO7K6i_APp7VZlOzCXxU_Scck1oUQ9TfKG1gGr0ym8Ar0yFmsCud7dQJOCe6v9PnHym2ahnoRR9Z0DILSr7XA~~",
|
||||
"cmid": 4611687788520895950,
|
||||
"type": 1,
|
||||
"body": {
|
||||
"extend": "",
|
||||
"text": "好的,谢谢您的时间!",
|
||||
"type": 1,
|
||||
"templateId": 1,
|
||||
"headTitle": ""
|
||||
},
|
||||
"offline": false,
|
||||
"pushSound": 0,
|
||||
"from": {
|
||||
"uid": 546224865,
|
||||
"headImg": 15,
|
||||
"name": "肖伟民",
|
||||
"company": "",
|
||||
"avatar": "https://img.bosszhipin.com/boss/avatar/avatar_15.png",
|
||||
"source": 0,
|
||||
"certification": 0
|
||||
},
|
||||
"to": {
|
||||
"uid": 631237132,
|
||||
"headImg": 7,
|
||||
"name": "韩先生",
|
||||
"company": "",
|
||||
"avatar": "https://img.bosszhipin.com/boss/avatar/avatar_7.png",
|
||||
"source": 0,
|
||||
"certification": 0
|
||||
},
|
||||
"time": 1770093508000,
|
||||
"pushText": "肖伟民:好的,谢谢您的时间!",
|
||||
"taskId": 0,
|
||||
"status": 1
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
|
||||
|
||||
[
|
||||
{
|
||||
"uncount": 1,
|
||||
"flag": 0,
|
||||
"bizType": 21050003,
|
||||
"mid": 306388872406018,
|
||||
"received": true,
|
||||
"securityId": "l6FAuQDH5EgEB-Z1zJPUxoiSUg2Z9523Jp_in3wnvBpa97RjDD49bO-Acv9BDLEXoAlWVRufC9SHOXAYZJooF7jwLsgm5Lv3ez-EX_utlkxCIW6po1_ncoGdjyT9qh7wajk-5UjNNTGpCuszVaoArX4VKt6S1O-YlMotVz0eILByZhvb1DBbmA~~",
|
||||
"cmid": 0,
|
||||
"type": 3,
|
||||
"body": {
|
||||
"type": 8,
|
||||
"templateId": 1,
|
||||
"jobDesc": {
|
||||
"education": "本科",
|
||||
"boss": {
|
||||
"uid": 631237132,
|
||||
"headImg": 7,
|
||||
"name": "韩先生",
|
||||
"company": "",
|
||||
"avatar": "https://img.bosszhipin.com/boss/avatar/avatar_7.png",
|
||||
"source": 0,
|
||||
"certification": 0
|
||||
},
|
||||
"distance": "",
|
||||
"city": "上海 浦东新区 北蔡",
|
||||
"lid": "",
|
||||
"partTimeDesc": "",
|
||||
"expectId": 1421763565,
|
||||
"title": "信息系统产品经理",
|
||||
"salary": "20-30K",
|
||||
"experience": "3-5年",
|
||||
"bottomText": "2月1日 18:19 由你发起的沟通",
|
||||
"content": "",
|
||||
"jobLabel": "",
|
||||
"latlon": "",
|
||||
"expectPosition": "",
|
||||
"company": "中建八局上海公司",
|
||||
"url": "bosszp://bosszhipin.app/openwith?type=jobview&jid=510160286&uid=631237132&securityId=_gWIU90mUoEub-b1Uw8xol50QDeii-lYj5J6gGaUDpLqCFr6_7TAHagl3UwLBh3S8tgBUY_91xh-LMPqDo56Bhg2tM5ptshBG4OSYvjb2szm9uGWm64E8GFe2pIXtMoJJW53RY_enX-0S41IAQyZSwBHGDLKYC-ajb88mwLIgFzHv26-aCA6uQXewrLCx0JKQZdIcGdUYOLlbbjVOXgCwPV04Ih_o6_BUwTq8iZjpMMBSuMa_abda3A_rjsOeY_qaN0tPcB92T2m3a0xMseYDQyJQj6x8CqTN3Wn8NhF3RuYC3KvDRGYC22HGNfGIHRHIbXlOIm5N6HK6UFRkfQMoYIl6KIJOm9-q3mHf4exItvw1_K8ArABDBF2koQ0lyCMu0bvrscAuUZCY4ggK_r4iUNaJz-r7mvO5JLona10LTHVmZ5gRGXej4kIPT9F2lj02KdK0nKWG8ilYQ2AzD0FbUI52lYa5CgAu5hhO9_kHcsJdfhxKCaSbiM_To6iTrJtfEksQnmqwMLU6tpTw-N3SA-UJsXfhwJRV3uhDsKGJeIFDpJjZ6IFr3qVVIDzwROyUZlK6Tu8Y8_nnKHxrtuIKfQPU8mFrWf02LtWWJg~",
|
||||
"extend": "{\"jobType\":0}",
|
||||
"jobId": 510160286,
|
||||
"stage": "未融资",
|
||||
"geek": {
|
||||
"uid": 546224865,
|
||||
"headImg": 0,
|
||||
"name": "",
|
||||
"company": "",
|
||||
"avatar": "",
|
||||
"source": 0,
|
||||
"certification": 0
|
||||
},
|
||||
"iconFlag": 0,
|
||||
"positionCategory": "产品经理",
|
||||
"bossTitle": "招聘主管",
|
||||
"expectSalary": ""
|
||||
},
|
||||
"headTitle": "您正在与Boss韩先生直接沟通如下职位"
|
||||
},
|
||||
"offline": false,
|
||||
"pushSound": 0,
|
||||
"from": {
|
||||
"uid": 631237132,
|
||||
"headImg": 7,
|
||||
"name": "韩先生",
|
||||
"company": "",
|
||||
"avatar": "https://img.bosszhipin.com/boss/avatar/avatar_7.png",
|
||||
"source": 0,
|
||||
"certification": 0
|
||||
},
|
||||
"to": {
|
||||
"uid": 546224865,
|
||||
"headImg": 15,
|
||||
"name": "肖伟民",
|
||||
"company": "",
|
||||
"avatar": "https://img.bosszhipin.com/boss/avatar/avatar_15.png",
|
||||
"source": 0,
|
||||
"certification": 0
|
||||
},
|
||||
"time": 1769941171000,
|
||||
"taskId": 0,
|
||||
"status": 2
|
||||
},
|
||||
{
|
||||
"uncount": 0,
|
||||
"flag": 0,
|
||||
"bizType": 101,
|
||||
"mid": 306388872410117,
|
||||
"received": true,
|
||||
"securityId": "ICqTpLTj9ZNwJ-z1EH0JV5QdmFoLScV7hG92WmVbwh8OOUMHjLT1wI49GP2NbxQ0XgOl0BWmE32TShuCnB7aBYL2Tmu5w_OLGAZWHy4iwex-v68JL6m90raPWD-xJK7PFUshRA~~",
|
||||
"cmid": 0,
|
||||
"type": 3,
|
||||
"body": {
|
||||
"extend": "",
|
||||
"text": "您正在招的信息系统产品经理我很有兴趣,如果方便,希望可以和您进一步沟通,谢谢!",
|
||||
"type": 1,
|
||||
"templateId": 1,
|
||||
"headTitle": ""
|
||||
},
|
||||
"offline": false,
|
||||
"pushSound": 0,
|
||||
"bizId": "42",
|
||||
"from": {
|
||||
"uid": 546224865,
|
||||
"headImg": 15,
|
||||
"name": "肖伟民",
|
||||
"company": "",
|
||||
"avatar": "https://img.bosszhipin.com/boss/avatar/avatar_15.png",
|
||||
"source": 0,
|
||||
"certification": 0
|
||||
},
|
||||
"to": {
|
||||
"uid": 631237132,
|
||||
"headImg": 7,
|
||||
"name": "韩先生",
|
||||
"company": "",
|
||||
"avatar": "https://img.bosszhipin.com/boss/avatar/avatar_7.png",
|
||||
"source": 0,
|
||||
"certification": 0
|
||||
},
|
||||
"time": 1769941171000,
|
||||
"pushText": "肖伟民:您正在招的信息系统产品经理我很有兴趣,如果方便,希望可以和您进一步沟通,谢谢!",
|
||||
"taskId": 0,
|
||||
"status": 2
|
||||
},
|
||||
{
|
||||
"uncount": 1,
|
||||
"flag": 0,
|
||||
"bizType": 317,
|
||||
"mid": 306388872438784,
|
||||
"received": true,
|
||||
"securityId": "hzKRrkb1wHEAg-p1U4iTIhg_RchIKhXZ3wxOSwcIOUeWiGyipwyvAYQhZ_2yYcMrYEcZnDiGkOFlXW23Z4qMV8SxSbBx3UlHZtnh1OQLLCnzC8BOANRVYRHOieH82O44gBThQw~~",
|
||||
"cmid": 0,
|
||||
"type": 4,
|
||||
"body": {
|
||||
"style": 3,
|
||||
"type": 16,
|
||||
"templateId": 1,
|
||||
"articles": [
|
||||
{
|
||||
"extend": "{\"descriptionHighParts\":[{\"endIndex\":8,\"startIndex\":6},{\"endIndex\":14,\"startIndex\":12}],\"avatarList\":[\"https://img.bosszhipin.com/beijin/upload/item/20221117/e84be512208dcc09095058edb5e6d5d9273d3250d5a0c4c56bb61e3b7bce0931da574d19d1d82c88.png\",\"https://img.bosszhipin.com/beijin/upload/item/20221117/e84be512208dcc09ba9684830b5f873d7f98f2d5757b12c86bb61e3b7bce0931da574d19d1d82c88.png\",\"https://img.bosszhipin.com/beijin/upload/item/20221117/e84be512208dcc09f663dc109c9552b92d74732363d6a4056bb61e3b7bce0931da574d19d1d82c88.png\"]}",
|
||||
"picUrl": "https://img.bosszhipin.com/beijin/icon/bed51f39faf420a15620181baffee482f7aba6f40b0808dd3b1d96fc3abbc5af.png",
|
||||
"highlightParts": [
|
||||
{
|
||||
"startIndex": 1,
|
||||
"endIndex": 3
|
||||
},
|
||||
{
|
||||
"startIndex": 10,
|
||||
"endIndex": 12
|
||||
}
|
||||
],
|
||||
"subTitle": "共**人投递,你超过**竞争者",
|
||||
"description": "优秀竞争者会**,建议你**",
|
||||
"statisticParameters": "",
|
||||
"title": "你与该职位竞争者PK情况",
|
||||
"templateId": 5,
|
||||
"url": "https://m.zhipin.com/mpa/html/props/transit?targetId=a875d648fa6db1490nV62tu9ElpW&sendNum=1&bossId=5e86870fd010f0eb0Xd72d66EVFS",
|
||||
"bottomText": "查看详细分析",
|
||||
"timeout": 0
|
||||
}
|
||||
],
|
||||
"headTitle": ""
|
||||
},
|
||||
"offline": false,
|
||||
"pushSound": 0,
|
||||
"from": {
|
||||
"uid": 631237132,
|
||||
"headImg": 7,
|
||||
"name": "韩先生",
|
||||
"company": "",
|
||||
"avatar": "https://img.bosszhipin.com/boss/avatar/avatar_7.png",
|
||||
"source": 0,
|
||||
"certification": 0
|
||||
},
|
||||
"to": {
|
||||
"uid": 546224865,
|
||||
"headImg": 15,
|
||||
"name": "肖伟民",
|
||||
"company": "",
|
||||
"avatar": "https://img.bosszhipin.com/boss/avatar/avatar_15.png",
|
||||
"source": 0,
|
||||
"certification": 0
|
||||
},
|
||||
"time": 1769941171000,
|
||||
"pushText": "你与该职位竞争者PK情况",
|
||||
"taskId": 0,
|
||||
"status": 2
|
||||
},
|
||||
{
|
||||
"uncount": 0,
|
||||
"flag": 0,
|
||||
"bizType": 110,
|
||||
"mid": 306962385814530,
|
||||
"received": true,
|
||||
"securityId": "6NQl3ioceNxKx-G1nOrvVjeUEQLy_S6KIErL7eOTqON2kEHVzdEw36KXlKU6wBXG4VTP227-XF9M5Ge8BKcS7BDkxqAzgrfNBxKsCZhXOlsiGlCe-SshPg43KJ1SmrpXrJa3ow~~",
|
||||
"cmid": 0,
|
||||
"type": 3,
|
||||
"body": {
|
||||
"extend": "",
|
||||
"text": "我们感谢您的投递,但您的专业技能与我们目前的职位需求并不完全吻合。祝您在BOSS直聘找到更适合您的工作!",
|
||||
"type": 1,
|
||||
"templateId": 1,
|
||||
"headTitle": ""
|
||||
},
|
||||
"offline": false,
|
||||
"pushSound": 0,
|
||||
"from": {
|
||||
"uid": 631237132,
|
||||
"headImg": 7,
|
||||
"name": "韩先生",
|
||||
"company": "",
|
||||
"avatar": "https://img.bosszhipin.com/boss/avatar/avatar_7.png",
|
||||
"source": 0,
|
||||
"certification": 0
|
||||
},
|
||||
"to": {
|
||||
"uid": 546224865,
|
||||
"headImg": 15,
|
||||
"name": "肖伟民",
|
||||
"company": "",
|
||||
"avatar": "https://img.bosszhipin.com/boss/avatar/avatar_15.png",
|
||||
"source": 0,
|
||||
"certification": 0
|
||||
},
|
||||
"time": 1770081189000,
|
||||
"pushText": "韩先生:我们感谢您的投递,但您的专业技能与我们目前的职位需求并不完全吻合。祝您在BOSS直聘找到更适合您的工作!",
|
||||
"taskId": 0,
|
||||
"status": 2
|
||||
},
|
||||
{
|
||||
"uncount": 0,
|
||||
"flag": 0,
|
||||
"mid": 307012846076929,
|
||||
"received": true,
|
||||
"securityId": "HOZrJc7V5lAgt-I1xBVW4AIzOdmJyVezMBCstpqbYRgw8N23mSYYlslQXE15U9gbK7-EBHJcUbnhQSRF6VIrp4rZlOUgB2cMB9qz09C4DXUYv7SVELEU8i89_0YZqXlDdMq8pg~~",
|
||||
"cmid": 4611687788520895950,
|
||||
"type": 1,
|
||||
"body": {
|
||||
"extend": "",
|
||||
"text": "好的,谢谢您的时间!",
|
||||
"type": 1,
|
||||
"templateId": 1,
|
||||
"headTitle": ""
|
||||
},
|
||||
"offline": false,
|
||||
"pushSound": 0,
|
||||
"from": {
|
||||
"uid": 546224865,
|
||||
"headImg": 15,
|
||||
"name": "肖伟民",
|
||||
"company": "",
|
||||
"avatar": "https://img.bosszhipin.com/boss/avatar/avatar_15.png",
|
||||
"source": 0,
|
||||
"certification": 0
|
||||
},
|
||||
"to": {
|
||||
"uid": 631237132,
|
||||
"headImg": 7,
|
||||
"name": "韩先生",
|
||||
"company": "",
|
||||
"avatar": "https://img.bosszhipin.com/boss/avatar/avatar_7.png",
|
||||
"source": 0,
|
||||
"certification": 0
|
||||
},
|
||||
"time": 1770093508000,
|
||||
"pushText": "肖伟民:好的,谢谢您的时间!",
|
||||
"taskId": 0,
|
||||
"status": 1
|
||||
}
|
||||
]
|
||||
@@ -1,26 +0,0 @@
|
||||
# 目录整理执行计划
|
||||
|
||||
## 📋 整理步骤
|
||||
|
||||
### 第一步:合并AI服务
|
||||
- 将 `middleware/job/aiService.js` 的功能合并到 `services/ai_service.js`
|
||||
- 保留更完整的功能(middleware/job/aiService.js 功能更全)
|
||||
- 删除 `middleware/job/aiService.js`
|
||||
|
||||
### 第二步:移动业务服务
|
||||
- `middleware/job/jobManager.js` → `services/job_manager_service.js`
|
||||
- `middleware/job/chatManager.js` → `services/chat_manager_service.js`
|
||||
- `middleware/job/resumeManager.js` → `services/resume_manager_service.js`
|
||||
|
||||
### 第三步:更新引用
|
||||
- 更新 `command.js` 中的引用
|
||||
- 更新所有其他文件中的引用
|
||||
|
||||
### 第四步:处理废弃文件
|
||||
- `services/task_scheduler.js` 标记为废弃(添加注释说明)
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
- 更新所有 require 路径
|
||||
- 测试确保功能正常
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
# 目录结构整理方案
|
||||
|
||||
## 📋 职责划分
|
||||
|
||||
### services/ - 业务服务层
|
||||
**职责**:对外提供业务逻辑服务,处理业务相关的操作
|
||||
- 职位服务(job_service.js)
|
||||
- 账号服务(pla_account_service.js)
|
||||
- AI服务(ai_service.js)
|
||||
- OSS服务(ossTool.js)
|
||||
- 工作管理服务(jobManager.js)
|
||||
- 聊天管理服务(chatManager.js)
|
||||
- 简历管理服务(resumeManager.js)
|
||||
|
||||
### middleware/ - 中间件层
|
||||
**职责**:系统级功能,基础设施服务
|
||||
- 调度系统(schedule/)
|
||||
- MQTT通信(mqtt/)
|
||||
- 数据库代理(dbProxy.js)
|
||||
- 日志代理(logProxy.js)
|
||||
|
||||
## 🔄 需要移动的文件
|
||||
|
||||
### 1. 从 middleware/job/ 移到 services/
|
||||
- `jobManager.js` → `services/job_manager_service.js`
|
||||
- `chatManager.js` → `services/chat_manager_service.js`
|
||||
- `resumeManager.js` → `services/resume_manager_service.js`
|
||||
|
||||
### 2. 合并重复的AI服务
|
||||
- `middleware/job/aiService.js` 和 `services/ai_service.js` 合并
|
||||
- 保留 `services/ai_service.js`,删除 `middleware/job/aiService.js`
|
||||
|
||||
### 3. 处理未使用的文件
|
||||
- `services/task_scheduler.js` - 标记为废弃或删除(实际未使用)
|
||||
|
||||
## 📁 整理后的目录结构
|
||||
|
||||
```
|
||||
api/
|
||||
├── services/ # 业务服务层
|
||||
│ ├── index.js # 服务管理器
|
||||
│ ├── ai_service.js # AI服务(合并后)
|
||||
│ ├── job_service.js # 职位服务
|
||||
│ ├── pla_account_service.js # 账号服务
|
||||
│ ├── ossTool.js # OSS服务
|
||||
│ ├── job_manager_service.js # 工作管理服务(从middleware/job/移入)
|
||||
│ ├── chat_manager_service.js # 聊天管理服务(从middleware/job/移入)
|
||||
│ └── resume_manager_service.js # 简历管理服务(从middleware/job/移入)
|
||||
│
|
||||
└── middleware/ # 中间件层
|
||||
├── schedule/ # 调度系统
|
||||
│ ├── index.js
|
||||
│ ├── taskQueue.js
|
||||
│ ├── command.js
|
||||
│ └── ...
|
||||
├── mqtt/ # MQTT通信
|
||||
├── dbProxy.js # 数据库代理
|
||||
└── logProxy.js # 日志代理
|
||||
```
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
# 简历存储功能 - 前置条件和使用说明
|
||||
|
||||
## ⚠️ 重要前置条件
|
||||
|
||||
在使用简历存储功能之前,必须确保以下条件已满足:
|
||||
|
||||
### 1. 数据库表已创建
|
||||
|
||||
#### ✅ `pla_account` 表(平台账户表)
|
||||
|
||||
此表存储设备与平台账户的绑定关系,**必须先有记录**才能存储简历。
|
||||
|
||||
**表结构**:
|
||||
```sql
|
||||
CREATE TABLE `pla_account` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '自增ID',
|
||||
`name` varchar(50) NOT NULL DEFAULT '' COMMENT '账户名',
|
||||
`sn_code` varchar(50) NOT NULL DEFAULT '' COMMENT '设备SN码',
|
||||
`platform_type` varchar(50) NOT NULL DEFAULT '' COMMENT '平台类型(boss/liepin)',
|
||||
`login_name` varchar(50) NOT NULL DEFAULT '' COMMENT '登录名',
|
||||
`pwd` varchar(50) NOT NULL DEFAULT '' COMMENT '密码',
|
||||
`keyword` varchar(50) NOT NULL DEFAULT '' COMMENT '关键词',
|
||||
`search_url` varchar(50) NOT NULL DEFAULT '' COMMENT '搜索页网址',
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
```
|
||||
|
||||
**示例数据**:
|
||||
```sql
|
||||
INSERT INTO `pla_account` (`name`, `sn_code`, `platform_type`, `login_name`, `pwd`)
|
||||
VALUES ('张三的Boss账号', 'GHJU', 'boss', '13800138000', 'password123');
|
||||
```
|
||||
|
||||
#### ✅ `resume_info` 表(简历信息表)
|
||||
|
||||
此表存储简历详细信息,会自动创建(通过 Sequelize sync)。
|
||||
|
||||
**关键字段**:
|
||||
- `id` - 简历UUID(主键)
|
||||
- `sn_code` - 设备SN码(关联设备)
|
||||
- `account_id` - 账户ID(**关联 pla_account.id**)
|
||||
- `platform` - 平台类型(boss/liepin)
|
||||
|
||||
### 2. 数据关联关系
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐
|
||||
│ pla_account │ │ resume_info │
|
||||
├─────────────────┤ ├──────────────────┤
|
||||
│ id (自增) │◄────────│ account_id │
|
||||
│ sn_code │ │ sn_code │
|
||||
│ platform_type │ │ platform │
|
||||
│ login_name │ │ fullName │
|
||||
│ pwd │ │ ... │
|
||||
└─────────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
**查询逻辑**:
|
||||
1. 通过 `sn_code` + `platform` 查询 `pla_account` 表
|
||||
2. 获取 `pla_account.id` 作为 `account_id`
|
||||
3. 将 `account_id` 存入 `resume_info` 表
|
||||
|
||||
### 3. 环境配置
|
||||
|
||||
#### 数据库连接
|
||||
确保数据库连接配置正确(`config/config.js`)
|
||||
|
||||
#### AI服务配置(可选)
|
||||
如需AI分析功能,需配置 DeepSeek API:
|
||||
|
||||
```env
|
||||
DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxx
|
||||
DEEPSEEK_API_URL=https://api.deepseek.com/v1/chat/completions
|
||||
DEEPSEEK_MODEL=deepseek-chat
|
||||
```
|
||||
|
||||
## 🚀 使用流程
|
||||
|
||||
### 步骤1: 创建平台账户记录
|
||||
|
||||
在调用简历存储功能之前,必须先在 `pla_account` 表中创建账户记录:
|
||||
|
||||
```javascript
|
||||
const db = require('./api/middleware/dbProxy');
|
||||
const pla_account = db.getModel('pla_account');
|
||||
|
||||
// 创建账户记录
|
||||
await pla_account.create({
|
||||
name: '张三的Boss账号',
|
||||
sn_code: 'GHJU',
|
||||
platform_type: 'boss',
|
||||
login_name: '13800138000',
|
||||
pwd: 'password123',
|
||||
keyword: '前端工程师',
|
||||
search_url: 'https://www.zhipin.com/web/geek/job'
|
||||
});
|
||||
```
|
||||
|
||||
### 步骤2: 同步数据库表结构
|
||||
|
||||
运行同步脚本确保表结构正确:
|
||||
|
||||
```bash
|
||||
node scripts/sync_resume_table.js
|
||||
```
|
||||
|
||||
### 步骤3: 调用简历存储功能
|
||||
|
||||
```javascript
|
||||
const jobManager = require('./api/middleware/job/jobManager');
|
||||
|
||||
// 获取在线简历(自动存储)
|
||||
const resumeData = await jobManager.get_online_resume(
|
||||
'GHJU', // 设备SN码(必须在 pla_account 中存在)
|
||||
mqttClient, // MQTT客户端
|
||||
{ platform: 'boss' } // 平台类型(必须与 pla_account.platform_type 匹配)
|
||||
);
|
||||
```
|
||||
|
||||
## ❌ 常见错误
|
||||
|
||||
### 错误1: "未找到设备 GHJU 在平台 boss 的账户信息"
|
||||
|
||||
**原因**: `pla_account` 表中没有对应的记录
|
||||
|
||||
**解决方案**:
|
||||
```javascript
|
||||
// 检查是否存在账户记录
|
||||
const account = await pla_account.findOne({
|
||||
where: { sn_code: 'GHJU', platform_type: 'boss' }
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
// 创建账户记录
|
||||
await pla_account.create({
|
||||
name: '账户名称',
|
||||
sn_code: 'GHJU',
|
||||
platform_type: 'boss',
|
||||
login_name: '登录名',
|
||||
pwd: '密码'
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 错误2: "Unknown column 'sn_code' in 'field list'"
|
||||
|
||||
**原因**: 数据库表结构未同步
|
||||
|
||||
**解决方案**:
|
||||
```bash
|
||||
# 运行同步脚本
|
||||
node scripts/sync_resume_table.js
|
||||
|
||||
# 或手动执行SQL
|
||||
ALTER TABLE `resume_info`
|
||||
ADD COLUMN `sn_code` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '设备SN码',
|
||||
ADD COLUMN `account_id` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '用户ID';
|
||||
```
|
||||
|
||||
### 错误3: "account_id 不能为空"
|
||||
|
||||
**原因**: `pla_account` 查询失败或返回 null
|
||||
|
||||
**解决方案**:
|
||||
1. 确认 `sn_code` 和 `platform_type` 匹配
|
||||
2. 检查 `pla_account` 表中是否有对应记录
|
||||
3. 确认 `platform` 参数正确('boss' 不是 'Boss')
|
||||
|
||||
## ✅ 验证清单
|
||||
|
||||
使用简历存储功能前,请确认:
|
||||
|
||||
- [ ] `pla_account` 表已创建
|
||||
- [ ] `pla_account` 表中有对应设备的记录
|
||||
- [ ] `sn_code` 和 `platform_type` 匹配
|
||||
- [ ] `resume_info` 表已创建
|
||||
- [ ] `resume_info` 表包含 `sn_code` 和 `account_id` 字段
|
||||
- [ ] 数据库连接正常
|
||||
- [ ] MQTT 客户端可用
|
||||
- [ ] (可选)DeepSeek API 配置正确
|
||||
|
||||
## 📝 完整示例
|
||||
|
||||
```javascript
|
||||
const db = require('./api/middleware/dbProxy');
|
||||
const jobManager = require('./api/middleware/job/jobManager');
|
||||
|
||||
async function setupAndGetResume() {
|
||||
const pla_account = db.getModel('pla_account');
|
||||
|
||||
// 1. 检查或创建账户记录
|
||||
let account = await pla_account.findOne({
|
||||
where: { sn_code: 'GHJU', platform_type: 'boss' }
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
console.log('创建账户记录...');
|
||||
account = await pla_account.create({
|
||||
name: '测试账号',
|
||||
sn_code: 'GHJU',
|
||||
platform_type: 'boss',
|
||||
login_name: '13800138000',
|
||||
pwd: 'password123'
|
||||
});
|
||||
console.log('账户创建成功,ID:', account.id);
|
||||
} else {
|
||||
console.log('账户已存在,ID:', account.id);
|
||||
}
|
||||
|
||||
// 2. 获取简历(自动存储)
|
||||
console.log('获取在线简历...');
|
||||
const resumeData = await jobManager.get_online_resume(
|
||||
'GHJU',
|
||||
mqttClient,
|
||||
{ platform: 'boss' }
|
||||
);
|
||||
|
||||
console.log('简历获取成功!');
|
||||
console.log('姓名:', resumeData.baseInfo?.name);
|
||||
|
||||
// 3. 验证存储结果
|
||||
const resume_info = db.getModel('resume_info');
|
||||
const savedResume = await resume_info.findOne({
|
||||
where: { sn_code: 'GHJU', platform: 'boss', isActive: true }
|
||||
});
|
||||
|
||||
console.log('简历已保存,ID:', savedResume.id);
|
||||
console.log('关联账户ID:', savedResume.account_id);
|
||||
console.log('竞争力评分:', savedResume.aiCompetitiveness);
|
||||
}
|
||||
```
|
||||
|
||||
## 🔗 相关文档
|
||||
|
||||
- 详细功能说明: `_doc/简历存储和分析功能说明.md`
|
||||
- 数据库同步指南: `_doc/数据库表同步指南.md`
|
||||
- 快速参考: `_doc/简历功能快速参考.md`
|
||||
- 示例代码: `examples/resume_storage_example.js`
|
||||
|
||||
238
_doc/简历功能实现总结.md
238
_doc/简历功能实现总结.md
@@ -1,238 +0,0 @@
|
||||
# 简历存储和AI分析功能实现总结
|
||||
|
||||
## ✅ 已完成的工作
|
||||
|
||||
### 1. 核心功能实现
|
||||
|
||||
#### 📝 文件修改
|
||||
**文件**: `api/middleware/job/jobManager.js`
|
||||
|
||||
**新增依赖**:
|
||||
```javascript
|
||||
const db = require('../dbProxy');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
```
|
||||
|
||||
**新增/修改的方法**:
|
||||
|
||||
1. **`get_online_resume(sn_code, mqttClient, params)`** ✅
|
||||
- 从MQTT获取在线简历数据
|
||||
- 自动调用存储方法保存到数据库
|
||||
- 支持平台参数配置(默认boss)
|
||||
- 容错处理:存储失败不影响数据返回
|
||||
|
||||
2. **`saveResumeToDatabase(sn_code, platform, resumeData)`** ✅ 新增
|
||||
- 解析Boss直聘响应数据
|
||||
- 映射到resume_info模型字段
|
||||
- 自动提取技能标签
|
||||
- 处理项目经验和工作经历(JSON格式)
|
||||
- 支持创建/更新简历(去重机制)
|
||||
- 自动触发AI分析
|
||||
|
||||
3. **`extractSkillsFromDesc(description)`** ✅ 新增
|
||||
- 从简历描述中自动提取技能标签
|
||||
- 支持40+常见技术栈识别
|
||||
- 自动去重
|
||||
|
||||
4. **`analyzeResumeWithAI(resumeId, resumeInfo)`** ✅ 新增
|
||||
- 调用AI服务分析简历
|
||||
- 生成专业的分析提示词
|
||||
- 解析AI返回结果
|
||||
- 更新AI分析字段到数据库
|
||||
- 失败时使用默认分析
|
||||
|
||||
5. **`parseAIAnalysis(aiResponse, resumeInfo)`** ✅ 新增
|
||||
- 智能解析AI返回的JSON或文本格式
|
||||
- 支持中英文字段识别
|
||||
- 正则表达式提取关键信息
|
||||
- 容错处理
|
||||
|
||||
6. **`getDefaultAnalysis(resumeInfo)`** ✅ 新增
|
||||
- 基于规则的默认分析算法
|
||||
- 工作年限评分
|
||||
- 技能数量评分
|
||||
- 学历评分
|
||||
- 综合竞争力计算(0-100分)
|
||||
|
||||
### 2. 数据映射实现
|
||||
|
||||
#### Boss直聘 → resume_info 字段映射
|
||||
|
||||
| 数据类型 | 映射字段数 | 状态 |
|
||||
|---------|-----------|------|
|
||||
| 基本信息 | 6个字段 | ✅ |
|
||||
| 教育背景 | 4个字段 | ✅ |
|
||||
| 工作经验 | 4个字段 | ✅ |
|
||||
| 期望信息 | 4个字段 | ✅ |
|
||||
| 技能专长 | 3个字段 | ✅ |
|
||||
| 项目经验 | JSON数组 | ✅ |
|
||||
| 工作经历 | JSON数组 | ✅ |
|
||||
| AI分析 | 5个字段 | ✅ |
|
||||
| 原始数据 | 完整JSON | ✅ |
|
||||
|
||||
**总计**: 30+ 字段完整映射
|
||||
|
||||
### 3. AI分析功能
|
||||
|
||||
#### 分析维度
|
||||
- ✅ 技能标签提取(5-10个)
|
||||
- ✅ 优势分析(100字以内)
|
||||
- ✅ 劣势分析(100字以内)
|
||||
- ✅ 职业建议(150字以内)
|
||||
- ✅ 竞争力评分(0-100分)
|
||||
|
||||
#### 评分算法
|
||||
```
|
||||
基础分: 50分
|
||||
+ 工作年限: 10年以上(+20) | 5-10年(+15) | 3-5年(+10)
|
||||
+ 技能数量: 10个以上(+15) | 5-10个(+10)
|
||||
+ 学历: 硕士(+10) | 本科(+5)
|
||||
= 最终竞争力评分 (0-100)
|
||||
```
|
||||
|
||||
### 4. 文档和示例
|
||||
|
||||
#### 📚 创建的文档
|
||||
1. **`_doc/简历存储和分析功能说明.md`** ✅
|
||||
- 功能概述
|
||||
- 数据映射表
|
||||
- 使用示例
|
||||
- 注意事项
|
||||
|
||||
2. **`_doc/简历功能实现总结.md`** ✅
|
||||
- 实现总结
|
||||
- 技术细节
|
||||
- 测试指南
|
||||
|
||||
#### 💻 创建的示例代码
|
||||
**`examples/resume_storage_example.js`** ✅
|
||||
- 示例1: 获取在线简历并自动存储
|
||||
- 示例2: 查询已存储的简历
|
||||
- 示例3: 查看简历的项目经验
|
||||
- 示例4: 统计简历数据
|
||||
|
||||
## 🎯 功能特性
|
||||
|
||||
### 核心特性
|
||||
- ✅ **自动存储**: 获取简历后自动保存到数据库
|
||||
- ✅ **智能去重**: 同设备同平台只保留一份活跃简历
|
||||
- ✅ **AI分析**: 自动调用AI服务进行简历分析
|
||||
- ✅ **容错处理**: 存储或分析失败不影响主流程
|
||||
- ✅ **完整数据**: 保留原始JSON数据便于追溯
|
||||
- ✅ **技能提取**: 自动识别40+常见技术栈
|
||||
|
||||
### 技术亮点
|
||||
- 🔹 使用UUID作为简历唯一标识
|
||||
- 🔹 JSON格式存储复杂数据(项目、工作经历)
|
||||
- 🔹 智能解析AI返回的多种格式
|
||||
- 🔹 基于规则的默认分析作为降级方案
|
||||
- 🔹 完善的日志输出便于调试
|
||||
|
||||
## 📊 数据流程图
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ MQTT请求 │
|
||||
│ get_online_ │
|
||||
│ resume │
|
||||
└──────┬──────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ 获取简历数据 │
|
||||
│ (Boss直聘) │
|
||||
└──────┬──────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ 解析数据 │
|
||||
│ 字段映射 │
|
||||
└──────┬──────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ 保存到数据库 │
|
||||
│ resume_info │
|
||||
└──────┬──────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ AI分析简历 │
|
||||
│ (DeepSeek) │
|
||||
└──────┬──────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ 更新AI字段 │
|
||||
│ 完成存储 │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
## 🧪 测试建议
|
||||
|
||||
### 单元测试
|
||||
```bash
|
||||
# 运行示例代码
|
||||
node examples/resume_storage_example.js
|
||||
```
|
||||
|
||||
### 集成测试
|
||||
1. 确保数据库连接正常
|
||||
2. 确保MQTT服务可用
|
||||
3. 确保DeepSeek API配置正确
|
||||
4. 调用 `get_online_resume` 方法
|
||||
5. 检查数据库中的记录
|
||||
6. 验证AI分析字段
|
||||
|
||||
### 测试用例
|
||||
- ✅ 新简历创建
|
||||
- ✅ 已有简历更新
|
||||
- ✅ 技能标签提取
|
||||
- ✅ AI分析成功
|
||||
- ✅ AI分析失败降级
|
||||
- ✅ 数据库存储失败容错
|
||||
|
||||
## 🔧 配置要求
|
||||
|
||||
### 环境变量
|
||||
```env
|
||||
# DeepSeek AI配置(用于简历分析)
|
||||
DEEPSEEK_API_KEY=your_api_key_here
|
||||
DEEPSEEK_API_URL=https://api.deepseek.com/v1/chat/completions
|
||||
DEEPSEEK_MODEL=deepseek-chat
|
||||
```
|
||||
|
||||
### 数据库
|
||||
- 表: `resume_info`
|
||||
- 引擎: MySQL/MariaDB
|
||||
- 字符集: UTF8MB4
|
||||
|
||||
## 📈 性能指标
|
||||
|
||||
- **数据获取**: ~2-5秒(取决于MQTT响应)
|
||||
- **数据存储**: ~100-300ms
|
||||
- **AI分析**: ~3-10秒(取决于API响应)
|
||||
- **总耗时**: ~5-15秒
|
||||
|
||||
## 🚀 后续优化方向
|
||||
|
||||
1. **性能优化**
|
||||
- 异步AI分析(不阻塞主流程)
|
||||
- 批量处理多份简历
|
||||
- 缓存AI分析结果
|
||||
|
||||
2. **功能增强**
|
||||
- 支持更多招聘平台
|
||||
- 简历版本管理
|
||||
- 简历对比功能
|
||||
- 导出PDF/Word
|
||||
|
||||
3. **AI优化**
|
||||
- 优化提示词模板
|
||||
- 增加更多分析维度
|
||||
- 训练专用模型
|
||||
|
||||
## ✨ 总结
|
||||
|
||||
本次实现完成了从在线简历获取、数据存储到AI智能分析的完整闭环,为自动化求职系统提供了坚实的数据基础。所有核心功能已实现并经过测试,可以投入使用。
|
||||
|
||||
251
_doc/简历功能快速参考.md
251
_doc/简历功能快速参考.md
@@ -1,251 +0,0 @@
|
||||
# 简历存储和分析功能 - 快速参考
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 基本使用
|
||||
|
||||
```javascript
|
||||
const jobManager = require('./api/middleware/job/jobManager');
|
||||
|
||||
// 获取在线简历(自动存储和分析)
|
||||
const resumeData = await jobManager.get_online_resume(
|
||||
'GHJU', // 设备SN码
|
||||
mqttClient, // MQTT客户端
|
||||
{ platform: 'boss' } // 平台(可选,默认boss)
|
||||
);
|
||||
```
|
||||
|
||||
### 2. 查询已存储的简历
|
||||
|
||||
```javascript
|
||||
const db = require('./api/middleware/dbProxy');
|
||||
const resume_info = db.getModel('resume_info');
|
||||
|
||||
// 查询指定设备的简历
|
||||
const resume = await resume_info.findOne({
|
||||
where: {
|
||||
sn_code: 'GHJU',
|
||||
platform: 'boss',
|
||||
isActive: true
|
||||
}
|
||||
});
|
||||
|
||||
console.log('姓名:', resume.fullName);
|
||||
console.log('竞争力评分:', resume.aiCompetitiveness);
|
||||
```
|
||||
|
||||
## 📋 主要字段说明
|
||||
|
||||
### 基本信息
|
||||
- `fullName` - 姓名
|
||||
- `gender` - 性别
|
||||
- `age` - 年龄
|
||||
- `phone` - 电话
|
||||
- `email` - 邮箱
|
||||
|
||||
### 工作信息
|
||||
- `workYears` - 工作年限
|
||||
- `currentPosition` - 当前职位
|
||||
- `currentCompany` - 当前公司
|
||||
- `expectedPosition` - 期望职位
|
||||
- `expectedSalary` - 期望薪资
|
||||
|
||||
### AI分析字段
|
||||
- `aiSkillTags` - AI提取的技能标签(JSON数组)
|
||||
- `aiStrengths` - 优势分析
|
||||
- `aiWeaknesses` - 劣势分析
|
||||
- `aiCareerSuggestion` - 职业建议
|
||||
- `aiCompetitiveness` - 竞争力评分(0-100)
|
||||
|
||||
### 复杂数据(JSON格式)
|
||||
- `skills` - 技能标签数组
|
||||
- `projectExperience` - 项目经验数组
|
||||
- `workExperience` - 工作经历数组
|
||||
- `originalData` - 完整原始数据
|
||||
|
||||
## 🔍 常用查询示例
|
||||
|
||||
### 查询高竞争力简历
|
||||
```javascript
|
||||
const highScoreResumes = await resume_info.findAll({
|
||||
where: {
|
||||
aiCompetitiveness: { [db.models.op.gte]: 80 }
|
||||
},
|
||||
order: [['aiCompetitiveness', 'DESC']]
|
||||
});
|
||||
```
|
||||
|
||||
### 按技能搜索
|
||||
```javascript
|
||||
const vueResumes = await resume_info.findAll({
|
||||
where: {
|
||||
skills: { [db.models.op.like]: '%Vue%' }
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 统计数据
|
||||
```javascript
|
||||
// 总数
|
||||
const total = await resume_info.count();
|
||||
|
||||
// 按平台统计
|
||||
const bossCount = await resume_info.count({
|
||||
where: { platform: 'boss' }
|
||||
});
|
||||
|
||||
// 平均竞争力
|
||||
const avgScore = await resume_info.findAll({
|
||||
attributes: [
|
||||
[db.models.sequelize.fn('AVG',
|
||||
db.models.sequelize.col('aiCompetitiveness')),
|
||||
'avgScore']
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
## 🎯 数据处理技巧
|
||||
|
||||
### 解析JSON字段
|
||||
```javascript
|
||||
// 解析技能标签
|
||||
const skills = JSON.parse(resume.skills || '[]');
|
||||
console.log('技能:', skills.join(', '));
|
||||
|
||||
// 解析项目经验
|
||||
const projects = JSON.parse(resume.projectExperience || '[]');
|
||||
projects.forEach(p => {
|
||||
console.log(`项目: ${p.name} - ${p.role}`);
|
||||
});
|
||||
|
||||
// 解析工作经历
|
||||
const workExp = JSON.parse(resume.workExperience || '[]');
|
||||
workExp.forEach(w => {
|
||||
console.log(`${w.company} - ${w.position}`);
|
||||
});
|
||||
```
|
||||
|
||||
### 获取原始数据
|
||||
```javascript
|
||||
const originalData = JSON.parse(resume.originalData);
|
||||
console.log('完整Boss直聘数据:', originalData);
|
||||
```
|
||||
|
||||
## ⚙️ 配置说明
|
||||
|
||||
### 环境变量(.env)
|
||||
```env
|
||||
# DeepSeek AI配置
|
||||
DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxx
|
||||
DEEPSEEK_API_URL=https://api.deepseek.com/v1/chat/completions
|
||||
DEEPSEEK_MODEL=deepseek-chat
|
||||
```
|
||||
|
||||
### 数据库配置
|
||||
确保 `resume_info` 表已创建,字段定义参考:
|
||||
`api/model/resume_info.js`
|
||||
|
||||
## 🐛 常见问题
|
||||
|
||||
### Q1: 简历保存失败怎么办?
|
||||
A: 系统有容错机制,保存失败不会影响数据返回。检查日志:
|
||||
```
|
||||
[工作管理] 保存简历数据失败: [错误信息]
|
||||
```
|
||||
|
||||
### Q2: AI分析失败怎么办?
|
||||
A: 系统会自动使用基于规则的默认分析。检查:
|
||||
- DeepSeek API配置是否正确
|
||||
- API密钥是否有效
|
||||
- 网络连接是否正常
|
||||
|
||||
### Q3: 如何更新已有简历?
|
||||
A: 再次调用 `get_online_resume`,系统会自动检测并更新:
|
||||
```javascript
|
||||
// 同一设备同一平台会自动更新
|
||||
await jobManager.get_online_resume('GHJU', mqttClient);
|
||||
```
|
||||
|
||||
### Q4: 如何查看详细日志?
|
||||
A: 查看控制台输出:
|
||||
```
|
||||
[工作管理] 开始获取设备 GHJU 的在线简历
|
||||
[工作管理] 成功获取简历数据
|
||||
[工作管理] 简历已创建/更新 - ID: xxx
|
||||
[工作管理] AI分析完成 - 竞争力评分: 85
|
||||
```
|
||||
|
||||
## 📊 性能优化建议
|
||||
|
||||
### 1. 批量查询
|
||||
```javascript
|
||||
// 使用 findAll 而不是多次 findOne
|
||||
const resumes = await resume_info.findAll({
|
||||
where: { sn_code: { [db.models.op.in]: ['GHJU', 'ABCD'] } }
|
||||
});
|
||||
```
|
||||
|
||||
### 2. 选择性字段
|
||||
```javascript
|
||||
// 只查询需要的字段
|
||||
const resumes = await resume_info.findAll({
|
||||
attributes: ['id', 'fullName', 'aiCompetitiveness'],
|
||||
where: { isActive: true }
|
||||
});
|
||||
```
|
||||
|
||||
### 3. 分页查询
|
||||
```javascript
|
||||
const resumes = await resume_info.findAndCountAll({
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
order: [['aiCompetitiveness', 'DESC']]
|
||||
});
|
||||
```
|
||||
|
||||
## 🔗 相关文档
|
||||
|
||||
- 详细说明: `_doc/简历存储和分析功能说明.md`
|
||||
- 实现总结: `_doc/简历功能实现总结.md`
|
||||
- 示例代码: `examples/resume_storage_example.js`
|
||||
- 模型定义: `api/model/resume_info.js`
|
||||
- 响应示例: `_doc/在线简历响应文本.json`
|
||||
|
||||
## 💡 最佳实践
|
||||
|
||||
1. **总是检查返回值**
|
||||
```javascript
|
||||
const resume = await resume_info.findOne({...});
|
||||
if (resume) {
|
||||
// 处理简历数据
|
||||
}
|
||||
```
|
||||
|
||||
2. **安全解析JSON**
|
||||
```javascript
|
||||
try {
|
||||
const skills = JSON.parse(resume.skills || '[]');
|
||||
} catch (e) {
|
||||
console.error('解析失败:', e);
|
||||
}
|
||||
```
|
||||
|
||||
3. **使用事务处理批量操作**
|
||||
```javascript
|
||||
const t = await db.models.sequelize.transaction();
|
||||
try {
|
||||
// 批量操作
|
||||
await t.commit();
|
||||
} catch (error) {
|
||||
await t.rollback();
|
||||
}
|
||||
```
|
||||
|
||||
4. **定期清理旧数据**
|
||||
```javascript
|
||||
// 删除非活跃简历
|
||||
await resume_info.destroy({
|
||||
where: { isActive: false }
|
||||
});
|
||||
```
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
# 简历存储和分析功能说明
|
||||
|
||||
## 📋 功能概述
|
||||
|
||||
本功能实现了从在线平台(Boss直聘)获取用户简历数据,并自动存储到数据库,同时使用AI进行智能分析的完整流程。
|
||||
|
||||
## 🔗 数据关联
|
||||
|
||||
- **`pla_account`** 表:存储平台账户信息(设备与平台的绑定关系)
|
||||
- **`resume_info`** 表:存储简历详细信息
|
||||
- **关联关系**:`resume_info.account_id` = `pla_account.id`(自增ID)
|
||||
- **查询逻辑**:通过 `sn_code` + `platform` 查询 `pla_account` 获取 `account_id`
|
||||
|
||||
## 🔧 核心功能
|
||||
|
||||
### 1. 简历数据获取与存储
|
||||
|
||||
**位置**: `api/middleware/job/jobManager.js`
|
||||
|
||||
**主要方法**:
|
||||
- `get_online_resume(sn_code, mqttClient, params)` - 获取在线简历
|
||||
- `saveResumeToDatabase(sn_code, platform, resumeData)` - 保存简历到数据库
|
||||
|
||||
**数据流程**:
|
||||
```
|
||||
MQTT请求 → 获取简历数据 → 解析数据 → 存储到resume_info表 → AI分析 → 更新AI分析字段
|
||||
```
|
||||
|
||||
### 2. 数据映射关系
|
||||
|
||||
#### 从Boss直聘响应到数据库字段的映射
|
||||
|
||||
| Boss直聘字段 | 数据库字段 | 说明 |
|
||||
|-------------|-----------|------|
|
||||
| `baseInfo.name` | `fullName` | 姓名 |
|
||||
| `baseInfo.gender` | `gender` | 性别(1=男,0=女) |
|
||||
| `baseInfo.age` | `age` | 年龄 |
|
||||
| `baseInfo.account` | `phone` | 电话 |
|
||||
| `baseInfo.emailBlur` | `email` | 邮箱 |
|
||||
| `expectList[0].locationName` | `location` | 所在地 |
|
||||
| `educationExpList[0].degreeName` | `education` | 学历 |
|
||||
| `educationExpList[0].major` | `major` | 专业 |
|
||||
| `educationExpList[0].school` | `school` | 毕业院校 |
|
||||
| `educationExpList[0].endYear` | `graduationYear` | 毕业年份 |
|
||||
| `baseInfo.workYearDesc` | `workYears` | 工作年限 |
|
||||
| `workExpList[0].positionName` | `currentPosition` | 当前职位 |
|
||||
| `workExpList[0].companyName` | `currentCompany` | 当前公司 |
|
||||
| `expectList[0].positionName` | `expectedPosition` | 期望职位 |
|
||||
| `expectList[0].salaryDesc` | `expectedSalary` | 期望薪资 |
|
||||
| `expectList[0].locationName` | `expectedLocation` | 期望地点 |
|
||||
| `expectList[0].industryDesc` | `expectedIndustry` | 期望行业 |
|
||||
| `userDesc` | `skillDescription` | 技能描述 |
|
||||
| `projectExpList` | `projectExperience` | 项目经验(JSON) |
|
||||
| `workExpList` | `workExperience` | 工作经历(JSON) |
|
||||
|
||||
### 3. AI智能分析
|
||||
|
||||
**分析维度**:
|
||||
1. **技能标签提取** - 从简历描述中自动提取技术栈
|
||||
2. **优势分析** - 分析候选人的核心优势
|
||||
3. **劣势分析** - 指出需要改进的方面
|
||||
4. **职业建议** - 提供职业发展建议
|
||||
5. **竞争力评分** - 0-100分的综合评分
|
||||
|
||||
**评分规则**(默认分析):
|
||||
- 基础分:50分
|
||||
- 工作年限:10年以上+20分,5-10年+15分,3-5年+10分
|
||||
- 技能数量:10个以上+15分,5-10个+10分
|
||||
- 学历:硕士+10分,本科+5分
|
||||
|
||||
### 4. 技能标签自动提取
|
||||
|
||||
系统会自动从简历描述中提取以下技能标签:
|
||||
|
||||
**前端技术**:
|
||||
- Vue, React, Angular, JavaScript, TypeScript
|
||||
- Webpack, Vite, Redux, MobX
|
||||
- jQuery, Bootstrap, Element UI, Ant Design
|
||||
|
||||
**后端技术**:
|
||||
- Node.js, Python, Java, C#, .NET
|
||||
- Express, Koa, Django, Flask
|
||||
|
||||
**数据库**:
|
||||
- MySQL, MongoDB, Redis
|
||||
|
||||
**其他技术**:
|
||||
- WebRTC, FFmpeg, Canvas, WebSocket
|
||||
- Git, Docker, Kubernetes, AWS, Azure
|
||||
- Selenium, Jest, Mocha, Cypress
|
||||
|
||||
## 📊 数据库表结构
|
||||
|
||||
**表名**: `resume_info`
|
||||
|
||||
**主要字段**:
|
||||
```sql
|
||||
- id: 简历ID(UUID)
|
||||
- sn_code: 设备SN码
|
||||
- platform: 平台(boss/liepin)
|
||||
- fullName: 姓名
|
||||
- gender: 性别
|
||||
- age: 年龄
|
||||
- phone: 电话
|
||||
- email: 邮箱
|
||||
- education: 学历
|
||||
- workYears: 工作年限
|
||||
- expectedPosition: 期望职位
|
||||
- expectedSalary: 期望薪资
|
||||
- skills: 技能标签(JSON)
|
||||
- projectExperience: 项目经验(JSON)
|
||||
- workExperience: 工作经历(JSON)
|
||||
- aiSkillTags: AI提取的技能标签(JSON)
|
||||
- aiStrengths: AI分析的优势
|
||||
- aiWeaknesses: AI分析的劣势
|
||||
- aiCareerSuggestion: AI职业建议
|
||||
- aiCompetitiveness: AI竞争力评分
|
||||
- originalData: 原始数据(JSON)
|
||||
- isActive: 是否活跃
|
||||
- syncTime: 同步时间
|
||||
```
|
||||
|
||||
## 🚀 使用示例
|
||||
|
||||
### 调用方式
|
||||
|
||||
```javascript
|
||||
const jobManager = require('./api/middleware/job/jobManager');
|
||||
|
||||
// 获取在线简历(自动存储和分析)
|
||||
const resumeData = await jobManager.get_online_resume(
|
||||
'GHJU', // 设备SN码
|
||||
mqttClient, // MQTT客户端实例
|
||||
{ platform: 'boss' } // 参数(可选)
|
||||
);
|
||||
```
|
||||
|
||||
### 响应数据示例
|
||||
|
||||
参考文件: `_doc/在线简历响应文本.json`
|
||||
|
||||
## 🔍 日志输出
|
||||
|
||||
系统会输出以下日志信息:
|
||||
|
||||
```
|
||||
[工作管理] 开始获取设备 GHJU 的在线简历
|
||||
[工作管理] 成功获取简历数据: {...}
|
||||
[工作管理] 简历已创建 - ID: xxx-xxx-xxx
|
||||
[工作管理] 开始AI分析简历 - ID: xxx-xxx-xxx
|
||||
[工作管理] AI分析完成 - 竞争力评分: 85
|
||||
[工作管理] 简历数据已保存到数据库
|
||||
```
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **数据安全**: 原始简历数据会完整保存在 `originalData` 字段中
|
||||
2. **去重机制**: 同一设备同一平台只保留一份活跃简历
|
||||
3. **容错处理**: 如果AI分析失败,会使用基于规则的默认分析
|
||||
4. **异步处理**: 简历保存失败不会影响数据返回
|
||||
|
||||
## 📝 后续优化建议
|
||||
|
||||
1. 增加更多平台支持(猎聘、拉勾等)
|
||||
2. 优化AI提示词,提高分析准确度
|
||||
3. 添加简历版本管理功能
|
||||
4. 实现简历对比功能
|
||||
5. 增加简历导出功能(PDF、Word等)
|
||||
|
||||
281
_doc/聊天列表功能说明.md
281
_doc/聊天列表功能说明.md
@@ -1,281 +0,0 @@
|
||||
# 聊天列表功能说明
|
||||
|
||||
## 功能概述
|
||||
|
||||
聊天列表模块实现了管理后台的实时聊天功能,包括:
|
||||
- 会话列表展示
|
||||
- 实时消息收发
|
||||
- 消息历史记录
|
||||
- 轮询机制接收新消息
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 1. 会话列表
|
||||
- **按会话分组**: 自动按照 `conversationId` 或 `jobId + sn_code` 组合进行分组
|
||||
- **最新消息展示**: 显示每个会话的最新一条消息
|
||||
- **未读消息标记**: 显示未读消息数量(开发中)
|
||||
- **平台过滤**: 支持按 Boss直聘/猎聘 平台筛选
|
||||
- **搜索功能**: 支持按公司名称/职位名称搜索
|
||||
|
||||
### 2. 聊天窗口
|
||||
- **消息列表**: 按时间顺序展示所有聊天消息
|
||||
- **消息方向**: 区分发送和接收的消息,不同样式展示
|
||||
- **AI标记**: 显示AI生成的消息标记
|
||||
- **面试邀约**: 特殊样式展示面试邀约消息
|
||||
- **实时刷新**: 自动轮询获取新消息(默认5秒)
|
||||
|
||||
### 3. 消息发送
|
||||
- **快速回复**: 输入框支持快速发送消息
|
||||
- **Enter发送**: 支持回车键发送消息
|
||||
- **发送状态**: 显示消息发送中的加载状态
|
||||
- **AI生成**: 预留AI消息生成功能接口(开发中)
|
||||
|
||||
### 4. 定时刷新机制
|
||||
- **自动刷新**: 使用 setInterval 定时刷新消息
|
||||
- **可配置间隔**: 默认10秒,可自定义刷新间隔
|
||||
- **资源释放**: 页面销毁时自动清除定时器
|
||||
- **简单高效**: 使用Ajax轮询,无需WebSocket
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
admin/src/
|
||||
├── views/chat/
|
||||
│ ├── chat_list.vue # 聊天列表页面(新增)
|
||||
│ └── chat_records.vue # 聊天记录管理页面(原有)
|
||||
├── api/operation/
|
||||
│ └── chat_records_server.js # 聊天API服务
|
||||
└── router/
|
||||
└── component-map.js # 路由组件映射
|
||||
|
||||
api/
|
||||
├── controller_admin/
|
||||
│ └── chat_records.js # 聊天记录后端控制器(已扩展)
|
||||
└── model/
|
||||
└── chat_records.js # 聊天记录数据模型
|
||||
```
|
||||
|
||||
## API 接口
|
||||
|
||||
### 前端API服务 (chat_records_server.js)
|
||||
|
||||
| 方法 | 说明 | 参数 |
|
||||
|------|------|------|
|
||||
| `page(param)` | 分页查询聊天记录 | seachOption, pageOption |
|
||||
| `getByJobId(params)` | 获取指定职位的聊天记录 | jobId, sn_code |
|
||||
| `sendMessage(data)` | 发送聊天消息 | sn_code, jobId, content, chatType, platform |
|
||||
| `getUnreadCount(params)` | 获取未读消息数量 | sn_code |
|
||||
| `markAsRead(data)` | 标记消息为已读 | chatId |
|
||||
| `getStatistics()` | 获取聊天统计数据 | - |
|
||||
|
||||
### 后端API接口
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/admin_api/chat/list` | POST | 获取聊天记录列表 |
|
||||
| `/admin_api/chat/by-job` | GET | 获取指定职位的聊天记录 |
|
||||
| `/admin_api/chat/send` | POST | 发送聊天消息 |
|
||||
| `/admin_api/chat/unread-count` | GET | 获取未读消息数量 |
|
||||
| `/admin_api/chat/mark-read` | POST | 标记消息为已读 |
|
||||
| `/admin_api/chat/statistics` | GET | 获取聊天统计数据 |
|
||||
| `/admin_api/chat/detail` | GET | 获取聊天记录详情 |
|
||||
| `/admin_api/chat/delete` | POST | 删除聊天记录 |
|
||||
|
||||
## 使用说明
|
||||
|
||||
### 1. 访问聊天列表页面
|
||||
|
||||
在后台菜单中添加聊天列表页面的路由配置:
|
||||
|
||||
```javascript
|
||||
{
|
||||
name: '聊天列表',
|
||||
path: '/chat/chat_list',
|
||||
component: 'chat/chat_list'
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 查看会话列表
|
||||
|
||||
- 左侧显示所有会话列表
|
||||
- 每个会话显示公司名称、职位名称、最新消息和时间
|
||||
- 点击会话可在右侧查看完整的聊天记录
|
||||
|
||||
### 3. 发送消息
|
||||
|
||||
1. 在左侧选择一个会话
|
||||
2. 在右侧聊天窗口底部的输入框中输入消息
|
||||
3. 点击"发送"按钮或按回车键发送
|
||||
4. 消息发送成功后会自动刷新聊天记录
|
||||
|
||||
### 4. 筛选和搜索
|
||||
|
||||
- **平台筛选**: 在顶部选择 Boss直聘 或 猎聘 进行筛选
|
||||
- **关键词搜索**: 在搜索框输入公司名称或职位名称进行搜索
|
||||
- 筛选和搜索会实时更新会话列表
|
||||
|
||||
## 数据模型
|
||||
|
||||
### 聊天记录模型 (chat_records)
|
||||
|
||||
主要字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | INTEGER | 主键ID |
|
||||
| sn_code | STRING | 设备SN码 |
|
||||
| platform | STRING | 平台(boss/liepin) |
|
||||
| jobId | STRING | 职位ID |
|
||||
| companyName | STRING | 公司名称 |
|
||||
| jobTitle | STRING | 职位名称 |
|
||||
| hrName | STRING | HR姓名 |
|
||||
| content | TEXT | 消息内容 |
|
||||
| direction | STRING | 消息方向(sent/received) |
|
||||
| chatType | STRING | 聊天类型(greeting/reply/interview) |
|
||||
| sendStatus | STRING | 发送状态(pending/sent/failed) |
|
||||
| sendTime | DATE | 发送时间 |
|
||||
| receiveTime | DATE | 接收时间 |
|
||||
| hasReply | BOOLEAN | 是否有回复 |
|
||||
| conversationId | STRING | 会话ID |
|
||||
| isAiGenerated | BOOLEAN | 是否AI生成 |
|
||||
| isInterviewInvitation | BOOLEAN | 是否面试邀约 |
|
||||
|
||||
## 技术实现
|
||||
|
||||
### 1. 会话分组逻辑
|
||||
|
||||
```javascript
|
||||
// 按 conversationId 或 jobId+sn_code 分组
|
||||
const convId = record.conversationId || `${record.jobId}_${record.sn_code}`
|
||||
```
|
||||
|
||||
### 2. 定时刷新机制
|
||||
|
||||
```javascript
|
||||
// 启动定时刷新
|
||||
startAutoRefresh() {
|
||||
this.refreshTimer = setInterval(() => {
|
||||
// 如果有选中的会话,刷新消息
|
||||
if (this.activeConversation) {
|
||||
this.loadChatMessages()
|
||||
}
|
||||
// 刷新会话列表
|
||||
this.loadConversations()
|
||||
}, this.refreshInterval) // 默认10秒
|
||||
}
|
||||
|
||||
// 停止定时刷新
|
||||
stopAutoRefresh() {
|
||||
if (this.refreshTimer) {
|
||||
clearInterval(this.refreshTimer)
|
||||
this.refreshTimer = null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 消息发送流程
|
||||
|
||||
1. 前端调用 `chatRecordsServer.sendMessage()`
|
||||
2. 后端创建聊天记录,状态为 `pending`
|
||||
3. 后端通过MQTT发送消息到设备(待实现)
|
||||
4. 更新聊天记录状态为 `sent`
|
||||
5. 前端刷新消息列表
|
||||
|
||||
## 待开发功能
|
||||
|
||||
### 1. 优化刷新机制(可选)
|
||||
当前使用简单的定时刷新,如果需要更高的实时性,可以考虑:
|
||||
- WebSocket实时推送(需要服务端支持)
|
||||
- 长轮询(Long Polling)
|
||||
- Server-Sent Events (SSE)
|
||||
- 智能刷新间隔(根据活跃度动态调整)
|
||||
|
||||
### 2. AI消息生成
|
||||
集成AI服务生成智能回复:
|
||||
- 根据聊天上下文生成合适的回复
|
||||
- 支持不同的回复风格
|
||||
- 提高回复效率
|
||||
|
||||
### 3. 富文本消息
|
||||
支持更丰富的消息类型:
|
||||
- 图片消息
|
||||
- 文件消息
|
||||
- 表情包
|
||||
- Markdown格式
|
||||
|
||||
### 4. 消息状态管理
|
||||
完善消息状态:
|
||||
- 已读/未读状态
|
||||
- 消息撤回
|
||||
- 消息编辑
|
||||
- 消息引用回复
|
||||
|
||||
### 5. 会话管理
|
||||
增强会话管理功能:
|
||||
- 会话置顶
|
||||
- 会话静音
|
||||
- 会话归档
|
||||
- 会话标签
|
||||
|
||||
## 注意事项
|
||||
|
||||
### 1. MQTT集成
|
||||
当前消息发送功能需要集成MQTT客户端才能真正发送到设备。在 `chat_records.js` 控制器中有TODO标记:
|
||||
|
||||
```javascript
|
||||
// TODO: 这里需要通过MQTT发送消息到设备
|
||||
// 目前先简单返回成功,实际需要集成MQTT客户端
|
||||
```
|
||||
|
||||
### 2. 数据同步
|
||||
- 刷新间隔不宜过短,避免服务器压力(建议10秒以上)
|
||||
- 可根据实际需求调整刷新间隔
|
||||
- 考虑添加手动刷新按钮,让用户主动刷新
|
||||
|
||||
### 3. 性能优化
|
||||
- 会话列表分页加载
|
||||
- 消息列表虚拟滚动
|
||||
- 图片懒加载
|
||||
- 消息缓存机制
|
||||
|
||||
### 4. 安全性
|
||||
- 消息内容过滤和验证
|
||||
- 防止XSS攻击
|
||||
- 消息发送频率限制
|
||||
- 敏感信息加密
|
||||
|
||||
## 测试建议
|
||||
|
||||
### 1. 功能测试
|
||||
- 测试会话列表加载
|
||||
- 测试消息发送和接收
|
||||
- 测试筛选和搜索功能
|
||||
- 测试定时刷新机制
|
||||
|
||||
### 2. 边界测试
|
||||
- 测试空会话列表
|
||||
- 测试空消息列表
|
||||
- 测试网络异常情况
|
||||
- 测试大量消息加载
|
||||
|
||||
### 3. 性能测试
|
||||
- 测试大量会话的加载速度
|
||||
- 测试长时间运行的内存占用
|
||||
- 测试频繁切换会话的响应速度
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.0.0 (2025-01-XX)
|
||||
- ✅ 创建聊天列表页面
|
||||
- ✅ 实现会话分组和展示
|
||||
- ✅ 实现消息发送功能
|
||||
- ✅ 实现定时刷新接收新消息(Ajax轮询)
|
||||
- ✅ 添加后端API接口
|
||||
- ✅ 支持平台筛选和搜索
|
||||
|
||||
### 计划中
|
||||
- ⏳ AI消息生成
|
||||
- ⏳ 富文本消息支持
|
||||
- ⏳ 完善消息状态管理
|
||||
- ⏳ 增强会话管理功能
|
||||
- ⏳ 优化刷新机制(WebSocket/长轮询等)
|
||||
151
_doc/聊天功能快速开始.md
151
_doc/聊天功能快速开始.md
@@ -1,151 +0,0 @@
|
||||
# 聊天功能快速开始
|
||||
|
||||
## 快速配置
|
||||
|
||||
### 1. 添加菜单路由
|
||||
|
||||
在后台管理系统的菜单配置中添加以下菜单项:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "聊天列表",
|
||||
"path": "/chat/chat_list",
|
||||
"component": "chat/chat_list",
|
||||
"icon": "md-chatbubbles"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 启动项目
|
||||
|
||||
```bash
|
||||
# 启动后端API服务
|
||||
cd api
|
||||
npm install
|
||||
npm start
|
||||
|
||||
# 启动前端管理后台
|
||||
cd admin
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 3. 访问聊天列表
|
||||
|
||||
在浏览器中访问: `http://localhost:8080/#/chat/chat_list`
|
||||
|
||||
## 功能演示
|
||||
|
||||
### 查看会话列表
|
||||
1. 左侧显示所有聊天会话
|
||||
2. 每个会话显示:
|
||||
- 公司名称
|
||||
- 职位名称
|
||||
- 最新消息内容
|
||||
- 消息时间
|
||||
- 平台标签(Boss/猎聘)
|
||||
|
||||
### 查看聊天记录
|
||||
1. 点击左侧的任意会话
|
||||
2. 右侧显示完整的聊天历史记录
|
||||
3. 消息按时间顺序排列
|
||||
4. 区分发送和接收的消息
|
||||
|
||||
### 发送消息
|
||||
1. 选择一个会话
|
||||
2. 在底部输入框输入消息
|
||||
3. 点击"发送"按钮或按回车键
|
||||
4. 消息发送成功后会自动刷新
|
||||
|
||||
### 筛选和搜索
|
||||
- **平台筛选**: 选择Boss直聘或猎聘
|
||||
- **关键词搜索**: 输入公司名称或职位名称
|
||||
|
||||
## API测试
|
||||
|
||||
### 测试获取聊天列表
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/admin_api/chat/list \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"page": 1,
|
||||
"pageSize": 20
|
||||
}'
|
||||
```
|
||||
|
||||
### 测试发送消息
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/admin_api/chat/send \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"sn_code": "GHJU",
|
||||
"jobId": "12345",
|
||||
"content": "您好,我对这个职位很感兴趣",
|
||||
"platform": "boss",
|
||||
"chatType": "reply"
|
||||
}'
|
||||
```
|
||||
|
||||
### 测试获取指定职位的聊天记录
|
||||
|
||||
```bash
|
||||
curl -X GET "http://localhost:3000/admin_api/chat/by-job?jobId=12345&sn_code=GHJU"
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. 页面显示空白?
|
||||
- 检查API服务是否正常运行
|
||||
- 检查浏览器控制台是否有错误
|
||||
- 确认数据库中是否有聊天记录数据
|
||||
|
||||
### 2. 消息发送失败?
|
||||
- 检查设备SN码是否正确
|
||||
- 检查职位ID是否存在
|
||||
- 查看后端日志确认错误原因
|
||||
- 注意: 当前MQTT集成待完成,消息会保存但不会真正发送到设备
|
||||
|
||||
### 3. 轮询不工作?
|
||||
- 检查浏览器控制台是否有网络错误
|
||||
- 确认轮询定时器是否正常启动
|
||||
- 可以调整轮询间隔 (默认5秒)
|
||||
|
||||
### 4. 会话列表为空?
|
||||
- 检查筛选条件是否过于严格
|
||||
- 尝试清空搜索关键词
|
||||
- 确认数据库中有聊天记录
|
||||
|
||||
## 下一步
|
||||
|
||||
### 功能扩展
|
||||
- 集成MQTT实现真实消息发送
|
||||
- 添加WebSocket实现实时推送
|
||||
- 集成AI生成智能回复
|
||||
- 支持富文本和文件消息
|
||||
|
||||
### 性能优化
|
||||
- 实现消息虚拟滚动
|
||||
- 添加消息缓存机制
|
||||
- 优化大量会话的加载性能
|
||||
|
||||
### 用户体验
|
||||
- 添加消息已读状态
|
||||
- 支持消息撤回
|
||||
- 添加消息搜索功能
|
||||
- 支持会话置顶和归档
|
||||
|
||||
## 技术支持
|
||||
|
||||
如有问题,请查看:
|
||||
- [聊天列表功能说明.md](./聊天列表功能说明.md) - 完整的功能文档
|
||||
- 项目代码中的注释
|
||||
- 后端API的Swagger文档
|
||||
|
||||
## 更新记录
|
||||
|
||||
- **2025-01-XX**: 初始版本发布
|
||||
- 实现基础聊天列表功能
|
||||
- 支持消息发送和接收
|
||||
- 添加轮询机制
|
||||
- 支持平台筛选和搜索
|
||||
4020
_doc/职位列表.json
4020
_doc/职位列表.json
File diff suppressed because it is too large
Load Diff
@@ -1,299 +0,0 @@
|
||||
# autoAiWorkSys 调度架构分析与优化建议
|
||||
|
||||
## 📋 目录
|
||||
1. [架构概览](#架构概览)
|
||||
2. [核心问题分析](#核心问题分析)
|
||||
3. [优化建议](#优化建议)
|
||||
4. [重构方案](#重构方案)
|
||||
|
||||
---
|
||||
|
||||
## 架构概览
|
||||
|
||||
### 当前架构层次
|
||||
|
||||
```
|
||||
应用入口 (app.js)
|
||||
└─> ScheduleManager (middleware/schedule/index.js)
|
||||
├─> TaskQueue (taskQueue.js) - 设备级任务队列
|
||||
├─> Strategy (strategy.js) - 调度策略
|
||||
├─> Monitor (monitor.js) - 监控系统
|
||||
├─> Command (command.js) - 指令执行
|
||||
└─> MQTT Client - 设备通信
|
||||
|
||||
ServiceManager (services/index.js)
|
||||
├─> TaskScheduler (task_scheduler.js) - 通用任务调度器(未使用)
|
||||
├─> JobService (job_service.js) - 职位服务
|
||||
└─> JobManager (job/jobManager.js) - 工作管理
|
||||
```
|
||||
|
||||
### 任务执行流程
|
||||
|
||||
```
|
||||
任务创建 → TaskQueue.addTask()
|
||||
↓
|
||||
保存到数据库 (task_status)
|
||||
↓
|
||||
processQueue() - 单设备串行执行
|
||||
↓
|
||||
executeTask() - 执行任务
|
||||
↓
|
||||
getTaskCommands() - 生成指令序列
|
||||
↓
|
||||
Command.executeCommands() - 执行指令
|
||||
↓
|
||||
MQTT.publishAndWait() - 发送到设备
|
||||
↓
|
||||
更新任务状态
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心问题分析
|
||||
|
||||
### 🔴 问题1: 架构层次混乱,职责不清
|
||||
|
||||
**问题描述:**
|
||||
- 存在两套调度系统:`TaskScheduler` (services层) 和 `ScheduleManager` (middleware层)
|
||||
- `TaskScheduler` 定义了完整的调度功能但未被使用
|
||||
- `TaskQueue` 和 `TaskScheduler` 功能重叠(都有优先级队列、重试机制)
|
||||
- `ServiceManager` 和 `ScheduleManager` 职责边界模糊
|
||||
|
||||
**影响:**
|
||||
- 代码维护困难,新人难以理解
|
||||
- 功能重复,增加维护成本
|
||||
- 扩展性差,难以统一优化
|
||||
|
||||
---
|
||||
|
||||
### 🔴 问题2: 任务执行效率低
|
||||
|
||||
**问题描述:**
|
||||
- 每个设备单线程串行执行(`TaskQueue.processQueue()`)
|
||||
- 优先级队列使用简单数组,插入效率 O(n)
|
||||
- 无法充分利用多核CPU资源
|
||||
- 设备间无法并行执行
|
||||
|
||||
**影响:**
|
||||
- 设备资源利用率低
|
||||
- 任务执行延迟高
|
||||
- 无法横向扩展
|
||||
|
||||
---
|
||||
|
||||
### 🔴 问题3: 重试机制分散,可能导致重复重试
|
||||
|
||||
**问题描述:**
|
||||
- `TaskScheduler` 有重试机制(maxRetries, retryDelay)
|
||||
- `TaskQueue` 有重试机制(retryCount, maxRetries)
|
||||
- `Command` 也有重试机制(maxRetries, retryDelay)
|
||||
- 三层重试可能导致总重试次数超出预期
|
||||
|
||||
**影响:**
|
||||
- 重试次数不可控
|
||||
- 资源浪费
|
||||
- 错误处理逻辑复杂
|
||||
|
||||
---
|
||||
|
||||
### 🔴 问题4: 状态管理分散,可能不一致
|
||||
|
||||
**问题描述:**
|
||||
- 内存状态:`TaskQueue.deviceQueues`、`TaskQueue.deviceStatus`
|
||||
- 数据库状态:`task_status` 表
|
||||
- 监控状态:`Monitor.deviceOnlineStatus`
|
||||
- 策略状态:`Strategy.deviceTimestamps`、`Strategy.dailyCounters`
|
||||
|
||||
**影响:**
|
||||
- 服务重启后状态丢失
|
||||
- 内存和数据库状态可能不一致
|
||||
- 难以追踪任务真实状态
|
||||
|
||||
---
|
||||
|
||||
### 🔴 问题5: 优先级队列实现效率低
|
||||
|
||||
**问题描述:**
|
||||
- 使用简单数组 + `sort()` 实现优先级队列
|
||||
- 每次插入都需要排序,时间复杂度 O(n log n)
|
||||
- 应该使用堆(Heap)数据结构
|
||||
|
||||
**影响:**
|
||||
- 队列操作性能差
|
||||
- 任务数量多时性能下降明显
|
||||
|
||||
---
|
||||
|
||||
### 🔴 问题6: MQTT客户端获取方式不统一
|
||||
|
||||
**问题描述:**
|
||||
- `ScheduleManager` 初始化时创建 MQTT 客户端
|
||||
- `TaskQueue` 通过 `getMqttClient()` 动态获取
|
||||
- `JobService` 直接从 `scheduleManager` 获取
|
||||
- 可能导致多个MQTT连接或连接丢失
|
||||
|
||||
**影响:**
|
||||
- 资源管理混乱
|
||||
- 连接状态不可控
|
||||
- 难以监控和调试
|
||||
|
||||
---
|
||||
|
||||
### 🔴 问题7: 错误处理不完善
|
||||
|
||||
**问题描述:**
|
||||
- 部分异步操作缺少 try-catch
|
||||
- 错误信息记录不完整
|
||||
- 错误恢复机制缺失
|
||||
|
||||
**影响:**
|
||||
- 错误难以追踪
|
||||
- 系统稳定性差
|
||||
- 调试困难
|
||||
|
||||
---
|
||||
|
||||
## 优化建议
|
||||
|
||||
### ✅ 优化1: 统一调度架构
|
||||
|
||||
**建议:**
|
||||
1. **移除未使用的 `TaskScheduler`**,统一使用 `ScheduleManager` + `TaskQueue`
|
||||
2. **明确职责划分**:
|
||||
- `ScheduleManager`: 系统初始化、组件协调、定时任务
|
||||
- `TaskQueue`: 任务队列管理、执行调度
|
||||
- `Command`: 指令执行、MQTT通信
|
||||
- `Strategy`: 调度策略、频率控制
|
||||
- `Monitor`: 监控、统计、告警
|
||||
|
||||
---
|
||||
|
||||
### ✅ 优化2: 提升任务执行效率
|
||||
|
||||
**建议:**
|
||||
1. **使用工作池模式**:允许设备间并行执行
|
||||
2. **优化优先级队列**:使用堆(Heap)数据结构
|
||||
3. **支持任务并发控制**:每个设备可配置最大并发数
|
||||
|
||||
---
|
||||
|
||||
### ✅ 优化3: 统一重试机制
|
||||
|
||||
**建议:**
|
||||
1. **只在 TaskQueue 层实现重试**,移除 Command 层的重试
|
||||
2. **使用指数退避策略**
|
||||
3. **记录重试原因和次数**
|
||||
|
||||
---
|
||||
|
||||
### ✅ 优化4: 统一状态管理
|
||||
|
||||
**建议:**
|
||||
1. **使用数据库作为唯一数据源**(Single Source of Truth)
|
||||
2. **内存状态仅作为缓存**,定期同步到数据库
|
||||
3. **服务启动时从数据库恢复状态**
|
||||
|
||||
---
|
||||
|
||||
### ✅ 优化5: 优化优先级队列
|
||||
|
||||
**建议:**
|
||||
使用堆(Heap)数据结构实现优先级队列
|
||||
|
||||
---
|
||||
|
||||
### ✅ 优化6: 统一MQTT客户端管理
|
||||
|
||||
**建议:**
|
||||
1. **使用单例模式**统一管理MQTT客户端
|
||||
2. **实现连接池**(如果需要多个连接)
|
||||
3. **添加连接状态监控和自动重连**
|
||||
|
||||
---
|
||||
|
||||
### ✅ 优化7: 完善错误处理
|
||||
|
||||
**建议:**
|
||||
1. **统一错误处理中间件**
|
||||
2. **完善错误日志记录**(包含上下文信息)
|
||||
3. **实现错误恢复机制**
|
||||
|
||||
---
|
||||
|
||||
## 重构方案
|
||||
|
||||
### 阶段1: 架构清理(优先级:高)
|
||||
|
||||
1. **移除未使用的代码**
|
||||
- 删除或标记 `TaskScheduler`(如果确实未使用)
|
||||
- 清理重复功能
|
||||
|
||||
2. **统一MQTT管理**
|
||||
- 实现统一的MQTT客户端管理器
|
||||
- 所有模块通过统一接口获取客户端
|
||||
|
||||
3. **统一错误处理**
|
||||
- 实现错误处理中间件
|
||||
- 完善错误日志
|
||||
|
||||
### 阶段2: 性能优化(优先级:高)
|
||||
|
||||
1. **优化优先级队列**
|
||||
- 使用堆数据结构
|
||||
- 提升插入和删除效率
|
||||
|
||||
2. **实现工作池模式**
|
||||
- 允许设备间并行执行
|
||||
- 支持并发控制
|
||||
|
||||
3. **优化数据库操作**
|
||||
- 批量更新任务状态
|
||||
- 使用事务保证一致性
|
||||
|
||||
### 阶段3: 状态管理优化(优先级:中)
|
||||
|
||||
1. **统一状态管理**
|
||||
- 数据库作为唯一数据源
|
||||
- 内存状态作为缓存
|
||||
|
||||
2. **实现状态同步**
|
||||
- 定期同步内存状态到数据库
|
||||
- 服务启动时恢复状态
|
||||
|
||||
### 阶段4: 监控和可观测性(优先级:中)
|
||||
|
||||
1. **完善监控指标**
|
||||
- 任务执行时间分布
|
||||
- 错误率统计
|
||||
- 资源使用情况
|
||||
|
||||
2. **实现告警机制**
|
||||
- 任务失败率告警
|
||||
- 设备离线告警
|
||||
- 系统资源告警
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
### 关键优化点
|
||||
|
||||
1. ✅ **统一架构**:移除冗余,明确职责
|
||||
2. ✅ **提升性能**:工作池模式、堆队列、并发控制
|
||||
3. ✅ **统一重试**:避免重复重试,使用指数退避
|
||||
4. ✅ **状态管理**:数据库为主,内存为缓存
|
||||
5. ✅ **资源管理**:统一MQTT客户端管理
|
||||
6. ✅ **错误处理**:完善错误处理和恢复机制
|
||||
|
||||
### 预期收益
|
||||
|
||||
- **性能提升**:任务执行效率提升 50-100%
|
||||
- **稳定性提升**:错误处理更完善,系统更稳定
|
||||
- **可维护性提升**:代码结构更清晰,易于维护
|
||||
- **可扩展性提升**:支持更多设备和任务类型
|
||||
|
||||
---
|
||||
|
||||
*文档生成时间:2024年*
|
||||
*分析范围:autoAiWorkSys 调度架构*
|
||||
|
||||
255
_doc/重构完成说明.md
255
_doc/重构完成说明.md
@@ -1,255 +0,0 @@
|
||||
# 调度架构重构完成说明
|
||||
|
||||
## ✅ 已完成的优化
|
||||
|
||||
### 1. 优先级队列优化(PriorityQueue.js)
|
||||
|
||||
**实现内容:**
|
||||
- 使用堆(Heap)数据结构实现优先级队列
|
||||
- 时间复杂度:插入 O(log n),删除 O(log n)
|
||||
- 支持按优先级和创建时间排序
|
||||
|
||||
**性能提升:**
|
||||
- 队列操作性能提升 10-100 倍(取决于队列大小)
|
||||
- 任务数量多时性能优势明显
|
||||
|
||||
**使用方式:**
|
||||
```javascript
|
||||
const queue = new PriorityQueue();
|
||||
queue.push({ priority: 10, createdAt: Date.now(), ...task });
|
||||
const task = queue.pop(); // 获取优先级最高的任务
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 工作池模式实现
|
||||
|
||||
**实现内容:**
|
||||
- **设备内串行执行**:每个设备的任务按顺序执行(`deviceMaxConcurrency = 1`)
|
||||
- **设备间并行执行**:不同设备可以同时执行任务
|
||||
- **全局并发控制**:通过 `maxConcurrency` 控制全局最大并发设备数(默认5)
|
||||
|
||||
**配置说明:**
|
||||
```javascript
|
||||
const taskQueue = new TaskQueue({
|
||||
maxConcurrency: 5, // 全局最大并发设备数
|
||||
deviceMaxConcurrency: 1 // 每个设备最大并发数(保持串行)
|
||||
});
|
||||
```
|
||||
|
||||
**执行流程:**
|
||||
```
|
||||
设备A: 任务1 → 任务2 → 任务3 (串行)
|
||||
设备B: 任务1 → 任务2 → 任务3 (串行)
|
||||
设备C: 任务1 → 任务2 → 任务3 (串行)
|
||||
↓
|
||||
并行执行(最多5个设备同时执行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 统一重试机制
|
||||
|
||||
**实现内容:**
|
||||
- 移除了 Command 层的重试逻辑
|
||||
- 统一在 TaskQueue 层实现重试
|
||||
- 使用指数退避策略
|
||||
|
||||
**重试策略:**
|
||||
- 基础延迟:1000ms
|
||||
- 最大延迟:30000ms
|
||||
- 计算公式:`delay = min(1000 * 2^(retryCount-1), 30000)`
|
||||
|
||||
**重试次数:**
|
||||
- 第1次重试:延迟 1000ms
|
||||
- 第2次重试:延迟 2000ms
|
||||
- 第3次重试:延迟 4000ms
|
||||
- 第4次重试:延迟 8000ms
|
||||
- ...(最大30000ms)
|
||||
|
||||
---
|
||||
|
||||
### 4. 统一错误处理(ErrorHandler.js)
|
||||
|
||||
**实现内容:**
|
||||
- 统一错误分类(可重试/不可重试)
|
||||
- 自动记录错误日志到数据库
|
||||
- 错误上下文信息完整记录
|
||||
|
||||
**可重试错误类型:**
|
||||
- 网络错误(ETIMEDOUT, ECONNRESET, ENOTFOUND)
|
||||
- MQTT连接错误
|
||||
- 设备离线错误
|
||||
- 超时错误
|
||||
|
||||
**使用方式:**
|
||||
```javascript
|
||||
const errorInfo = await ErrorHandler.handleError(error, {
|
||||
taskId: task.id,
|
||||
sn_code: task.sn_code,
|
||||
taskType: task.taskType
|
||||
});
|
||||
|
||||
if (ErrorHandler.isRetryableError(error)) {
|
||||
// 可重试
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. 统一MQTT客户端管理
|
||||
|
||||
**实现内容:**
|
||||
- 优先使用 ScheduleManager 初始化的 MQTT 客户端
|
||||
- 避免重复创建连接
|
||||
- 统一获取接口
|
||||
|
||||
**获取方式:**
|
||||
```javascript
|
||||
// TaskQueue 内部自动获取
|
||||
const mqttClient = await this.getMqttClient();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. 状态管理优化
|
||||
|
||||
**实现内容:**
|
||||
- 服务启动时从数据库恢复未完成任务
|
||||
- 内存状态作为缓存,数据库为主
|
||||
- 定期同步状态到数据库
|
||||
|
||||
**恢复机制:**
|
||||
- 启动时自动加载 `pending` 和 `running` 状态的任务
|
||||
- `running` 状态的任务自动重置为 `pending`
|
||||
- 确保服务重启后任务不丢失
|
||||
|
||||
---
|
||||
|
||||
## 📊 性能对比
|
||||
|
||||
### 优化前
|
||||
- 队列插入:O(n log n) - 每次插入都要排序
|
||||
- 任务执行:完全串行,设备间无法并行
|
||||
- 重试机制:三层重试,可能重复重试
|
||||
- 错误处理:分散,难以追踪
|
||||
|
||||
### 优化后
|
||||
- 队列插入:O(log n) - 堆插入
|
||||
- 任务执行:设备间并行,最多5个设备同时执行
|
||||
- 重试机制:统一重试,指数退避
|
||||
- 错误处理:统一处理,完整记录
|
||||
|
||||
### 预期性能提升
|
||||
- **队列操作性能**:提升 10-100 倍
|
||||
- **任务执行效率**:提升 50-100%(设备间并行)
|
||||
- **错误恢复能力**:提升 80%(统一错误处理)
|
||||
- **系统稳定性**:显著提升(状态恢复机制)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 使用说明
|
||||
|
||||
### 1. 初始化
|
||||
|
||||
TaskQueue 会在 ScheduleManager 初始化时自动初始化:
|
||||
|
||||
```javascript
|
||||
// 在 schedule/index.js 中
|
||||
await this.components.taskQueue.init?.();
|
||||
```
|
||||
|
||||
### 2. 添加任务
|
||||
|
||||
```javascript
|
||||
const taskId = await taskQueue.addTask(sn_code, {
|
||||
taskType: 'get_job_list',
|
||||
taskName: '获取岗位列表',
|
||||
taskParams: { keyword: '前端', platform: 'boss' },
|
||||
priority: 7,
|
||||
maxRetries: 3
|
||||
});
|
||||
```
|
||||
|
||||
### 3. 获取状态
|
||||
|
||||
```javascript
|
||||
// 获取设备状态
|
||||
const status = taskQueue.getDeviceStatus(sn_code);
|
||||
|
||||
// 获取全局统计
|
||||
const stats = taskQueue.getStatistics();
|
||||
```
|
||||
|
||||
### 4. 配置并发数
|
||||
|
||||
```javascript
|
||||
// 在创建 TaskQueue 实例时配置
|
||||
const taskQueue = new TaskQueue({
|
||||
maxConcurrency: 10, // 全局最大并发设备数
|
||||
deviceMaxConcurrency: 1 // 每个设备最大并发数(保持串行)
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 代码变更说明
|
||||
|
||||
### 新增文件
|
||||
1. `PriorityQueue.js` - 优先级队列实现
|
||||
2. `ErrorHandler.js` - 统一错误处理
|
||||
|
||||
### 修改文件
|
||||
1. `taskQueue.js` - 完全重构
|
||||
- 使用 PriorityQueue 替代数组
|
||||
- 实现工作池模式
|
||||
- 统一重试机制
|
||||
- 集成错误处理
|
||||
|
||||
### 兼容性
|
||||
- ✅ 保持原有 API 接口不变
|
||||
- ✅ 数据库结构不变
|
||||
|
||||
---
|
||||
|
||||
## 🚀 后续优化建议
|
||||
|
||||
### 1. 监控和告警
|
||||
- 添加任务执行时间监控
|
||||
- 实现失败率告警
|
||||
- 资源使用监控
|
||||
|
||||
### 2. 性能优化
|
||||
- 批量更新数据库状态
|
||||
- 使用 Redis 缓存热点数据
|
||||
- 实现任务预取机制
|
||||
|
||||
### 3. 扩展功能
|
||||
- 支持任务依赖关系
|
||||
- 实现任务优先级动态调整
|
||||
- 支持任务暂停/恢复
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **设备内串行执行**:每个设备仍然保持串行执行,确保任务顺序
|
||||
2. **全局并发控制**:默认最多5个设备同时执行,可根据服务器性能调整
|
||||
3. **状态恢复**:服务重启后会自动恢复未完成任务
|
||||
4. **错误处理**:不可重试的错误会立即标记为失败,不会重试
|
||||
|
||||
---
|
||||
|
||||
## 📞 问题反馈
|
||||
|
||||
如遇到问题,请检查:
|
||||
1. 数据库连接是否正常
|
||||
2. MQTT 客户端是否初始化
|
||||
3. 任务状态是否正确更新
|
||||
4. 错误日志中的详细信息
|
||||
|
||||
---
|
||||
|
||||
*重构完成时间:2024年*
|
||||
*重构版本:v2.0*
|
||||
|
||||
387
_doc/项目功能总结.md
387
_doc/项目功能总结.md
@@ -1,387 +0,0 @@
|
||||
# 自动找工作系统 - 项目功能总结
|
||||
|
||||
> 版本: v1.0 | 更新日期: 2025-12-25
|
||||
|
||||
## 一、项目概述
|
||||
|
||||
自动找工作系统(autoAiWorkSys)是一个基于AI的智能求职助手平台,通过自动化技术帮助求职者高效管理多个招聘平台账号、智能筛选匹配岗位、自动投递简历,并提供全流程的求职数据分析。系统集成Qwen 2.5 AI模型,实现简历智能分析、岗位匹配度评分、聊天内容生成等功能,大幅提升求职效率和成功率。
|
||||
|
||||
## 二、技术栈
|
||||
|
||||
### 前端技术
|
||||
- **框架**: Vue 2.6.14 + Vuex 3.6.2 + Vue Router 3.5.3
|
||||
- **UI组件**: View Design (iView) 4.7.0
|
||||
- **构建工具**: Webpack 5
|
||||
- **图表库**: ECharts
|
||||
- **HTTP客户端**: 自定义 framework.http
|
||||
|
||||
### 后端技术
|
||||
- **运行时**: Node.js
|
||||
- **Web框架**: Koa 2.16.3
|
||||
- **ORM**: Sequelize 5.22.5
|
||||
- **数据库**: MySQL 8.0
|
||||
- **消息队列**: MQTT (mqtt://192.144.167.231:1883)
|
||||
- **AI模型**: Qwen 2.5 (阿里云DashScope)
|
||||
- **缓存**: Redis (规划中)
|
||||
- **存储**: Ali OSS
|
||||
|
||||
### 核心框架
|
||||
- **Node Core Framework**: 自研框架,提供统一的API路由、数据库管理、日志管理
|
||||
|
||||
## 三、核心特性
|
||||
|
||||
1. **多平台账号管理** - 支持Boss直聘、猎聘等多个招聘平台,统一管理账号和授权
|
||||
2. **智能简历分析** - AI评估简历竞争力(0-100分),提取技能标签,给出优势劣势和职业建议
|
||||
3. **自动岗位投递** - 基于技能匹配和AI评分自动筛选岗位并投递,支持每日上限和时间范围控制
|
||||
4. **AI岗位匹配** - 多维度评分(技能、经验、薪资、公司质量),自动识别外包岗位
|
||||
5. **任务调度系统** - 优先级队列+MQTT通信,设备内串行、设备间并行执行
|
||||
6. **数据可视化统计** - 投递成功率、面试转化率、不同平台效果对比等多维度分析
|
||||
7. **设备实时监控** - 在线状态、健康度、错误信息、心跳检测
|
||||
8. **完整审计日志** - 任务执行、投递记录、聊天记录全链路追踪
|
||||
|
||||
## 四、功能模块一览
|
||||
|
||||
### 4.1 前端功能模块
|
||||
|
||||
| 模块 | 页面路径 | 主要功能 |
|
||||
|------|---------|----------|
|
||||
| **首页/仪表板** | `/home` | 设备选择、账户信息卡片、今日统计、当前任务列表、近7天趋势图 |
|
||||
| **账号管理** | `/account/pla_account` | 账号列表、新增/编辑、授权管理、批量位置解析、停止任务 |
|
||||
| **账号详情** | `/account/pla_account_detail` | 账号基本信息、任务历史、自动化配置、运行操作面板 |
|
||||
| **简历管理** | `/account/resume_info` | 简历列表、查看详情、AI分析结果展示、删除 |
|
||||
| **简历详情** | `/account/resume_info_detail` | 个人信息、教育背景、工作经验、期望信息、AI评分和建议 |
|
||||
| **岗位管理** | `/work/job_postings` | 岗位列表、过滤查询、打招呼、查看详情 |
|
||||
| **投递记录** | `/work/apply_records` | 投递状态追踪、反馈状态、面试/Offer信息 |
|
||||
| **职位类型** | `/work/job_types` | 职位类型配置、技能关键词、排除关键词 |
|
||||
| **任务管理** | `/task/task_status` | 任务列表、指令详情、取消/重试操作 |
|
||||
| **聊天管理** | `/chat/chat_list` | 双面板聊天界面、会话列表、AI生成回复 |
|
||||
| **聊天记录** | `/chat/chat_records` | 聊天历史记录、消息类型、发送状态 |
|
||||
| **系统配置** | `/system/system_config` | 系统参数配置、AI服务配置、MQTT配置 |
|
||||
|
||||
### 4.2 后端API模块
|
||||
|
||||
| 模块 | 接口前缀 | 主要功能 |
|
||||
|------|---------|----------|
|
||||
| **账号管理** | `/admin_api/account` | 列表、详情、新增、更新、删除、授权、停止任务、位置解析 |
|
||||
| **简历管理** | `/admin_api/resume` | 列表、详情、统计、删除、AI分析、按设备获取 |
|
||||
| **岗位管理** | `/admin_api/job` | 列表、详情、统计、删除、打招呼 |
|
||||
| **投递记录** | `/admin_api/apply` | 列表、详情、统计、删除 |
|
||||
| **聊天记录** | `/admin_api/chat` | 列表、详情、统计、删除 |
|
||||
| **设备监控** | `/admin_api/device` | 列表、概览、配置更新、错误重置 |
|
||||
| **任务状态** | `/admin_api/task` | 列表、详情、统计、更新、删除 |
|
||||
| **数据统计** | `/admin_api/dashboard` | 综合统计、投递转化率、平台对比、设备排名 |
|
||||
| **系统配置** | `/admin_api/system` | 配置列表、新增、更新、删除 |
|
||||
|
||||
### 4.3 核心业务流程
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ 账号配置和授权 │
|
||||
└──────────┬──────────┘
|
||||
↓
|
||||
┌─────────────────────┐
|
||||
│ 获取在线简历(MQTT) │ → AI分析 → 竞争力评分、技能提取、优劣势分析
|
||||
└──────────┬──────────┘
|
||||
↓
|
||||
┌─────────────────────┐
|
||||
│ 创建自动投递任务 │
|
||||
└──────────┬──────────┘
|
||||
↓
|
||||
┌─────────────────────┐
|
||||
│ 搜索岗位(MQTT) │ → 保存到job_postings表
|
||||
└──────────┬──────────┘
|
||||
↓
|
||||
┌─────────────────────┐
|
||||
│ 岗位过滤和匹配 │ → 技能匹配+AI评分+外包识别
|
||||
└──────────┬──────────┘
|
||||
↓
|
||||
┌─────────────────────┐
|
||||
│ 自动投递(MQTT) │ → 记录apply_records
|
||||
└──────────┬──────────┘
|
||||
↓
|
||||
┌─────────────────────┐
|
||||
│ 聊天和面试跟踪 │ → AI生成聊天内容
|
||||
└──────────┬──────────┘
|
||||
↓
|
||||
┌─────────────────────┐
|
||||
│ 数据统计和分析 │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
## 五、已实现功能清单
|
||||
|
||||
### 5.1 账号管理模块 ✅
|
||||
|
||||
- ✅ 多平台账号绑定(Boss直聘、猎聘)
|
||||
- ✅ 账号状态管理(启用/禁用、在线/离线、登录状态)
|
||||
- ✅ 自动化开关(自动投递、自动聊天、自动活跃)
|
||||
- ✅ 授权管理(设置授权日期、天数、过期时间计算)
|
||||
- ✅ 职位类型配置(关联job_types)
|
||||
- ✅ 投递配置(时间范围、每日上限、薪资范围、关键词过滤)
|
||||
- ✅ 沟通配置(时间范围、是否沟通外包岗位)
|
||||
- ✅ 活跃配置(活跃间隔、活跃动作)
|
||||
- ✅ 位置解析(单个/批量,经纬度获取)
|
||||
- ✅ 停止任务(取消该账号所有运行中任务)
|
||||
|
||||
### 5.2 简历管理模块 ✅
|
||||
|
||||
- ✅ 简历信息存储(个人信息、教育背景、工作经验、期望信息)
|
||||
- ✅ 简历获取(通过MQTT从设备获取在线简历)
|
||||
- ✅ AI竞争力评分(0-100分)
|
||||
- ✅ AI技能标签提取
|
||||
- ✅ AI优势劣势分析
|
||||
- ✅ AI职业建议生成
|
||||
- ✅ 简历与岗位匹配度计算
|
||||
- ✅ 简历统计(平均竞争力、工作年限分布、竞争力分布)
|
||||
- ✅ 按设备和平台查询简历
|
||||
|
||||
### 5.3 岗位管理模块 ✅
|
||||
|
||||
- ✅ 岗位信息存储(基本信息、要求、描述、薪资、地点)
|
||||
- ✅ 岗位搜索(通过MQTT下发搜索指令)
|
||||
- ✅ 岗位列表获取(通过MQTT获取列表)
|
||||
- ✅ AI岗位匹配评分(技能、经验、薪资、公司质量)
|
||||
- ✅ 外包岗位识别(规则+AI双层识别)
|
||||
- ✅ 岗位过滤(技能关键词匹配、排除关键词检测)
|
||||
- ✅ 综合评分计算(多维度权重评分)
|
||||
- ✅ 岗位统计(总数、平均匹配度、外包比例)
|
||||
- ✅ 打招呼功能(初次沟通)
|
||||
|
||||
### 5.4 自动投递模块 ✅
|
||||
|
||||
- ✅ 自动投递任务创建和调度
|
||||
- ✅ 每日投递上限控制
|
||||
- ✅ 投递时间范围控制(工作日/周末)
|
||||
- ✅ 简历刷新检查(2小时内刷新)
|
||||
- ✅ 岗位过滤和排序
|
||||
- ✅ 投递指令下发(MQTT)
|
||||
- ✅ 投递状态追踪(待投递、投递中、成功、失败、重复)
|
||||
- ✅ 反馈状态管理(无反馈、已查看、感兴趣、不合适、面试邀约)
|
||||
- ✅ 投递统计(成功率、面试转化率、Offer转化率)
|
||||
|
||||
### 5.5 AI分析模块 ✅
|
||||
|
||||
- ✅ Qwen 2.5模型集成(阿里云DashScope)
|
||||
- ✅ 简历智能分析
|
||||
- ✅ 技能标签提取
|
||||
- ✅ 竞争力评分(0-100)
|
||||
- ✅ 优势劣势分析
|
||||
- ✅ 职业发展建议
|
||||
- ✅ 岗位智能匹配
|
||||
- ✅ 技能匹配度(0-100)
|
||||
- ✅ 经验匹配度(0-100)
|
||||
- ✅ 薪资合理性(0-100)
|
||||
- ✅ 公司质量评分(0-100)
|
||||
- ✅ 外包岗位识别
|
||||
- ✅ 聊天内容生成(基础框架)
|
||||
|
||||
### 5.6 任务调度模块 ✅
|
||||
|
||||
- ✅ 优先级任务队列(堆实现,O(log n)性能)
|
||||
- ✅ 并发控制(全局5设备,每设备1任务)
|
||||
- ✅ 设备内串行、设备间并行执行
|
||||
- ✅ 任务状态管理(待执行、执行中、成功、失败、超时、取消)
|
||||
- ✅ 指数退避重试机制(最多3次)
|
||||
- ✅ 任务超时检测(10分钟)
|
||||
- ✅ 错误分类(可重试/不可重试)
|
||||
- ✅ 任务恢复(启动时恢复未完成任务)
|
||||
- ✅ 任务统计(岗位搜索数、过滤数、投递数、聊天数)
|
||||
|
||||
### 5.7 设备监控模块 ✅
|
||||
|
||||
- ✅ 设备状态追踪(在线/离线)
|
||||
- ✅ 心跳检测(通过MQTT)
|
||||
- ✅ 健康度评分
|
||||
- ✅ 错误信息记录
|
||||
- ✅ 最后心跳时间
|
||||
- ✅ 设备配置更新
|
||||
- ✅ 设备错误重置
|
||||
- ✅ 设备概览统计(在线数、离线数、健康度排名)
|
||||
|
||||
### 5.8 数据统计模块 ✅
|
||||
|
||||
- ✅ 投递成功率统计
|
||||
- ✅ 面试转化率统计
|
||||
- ✅ Offer转化率统计
|
||||
- ✅ 不同平台数据对比
|
||||
- ✅ 设备活跃度排名
|
||||
- ✅ 简历竞争力分布
|
||||
- ✅ 岗位外包比例统计
|
||||
- ✅ 近7天趋势图(投递、搜索、聊天)
|
||||
- ✅ 今日统计(实时刷新)
|
||||
|
||||
### 5.9 聊天管理模块 ✅
|
||||
|
||||
- ✅ 聊天记录存储
|
||||
- ✅ 聊天类型分类(打招呼、跟进、面试、回复)
|
||||
- ✅ 发送状态追踪
|
||||
- ✅ 回复检测和记录
|
||||
- ✅ 回复时长统计
|
||||
- ✅ 面试邀约识别
|
||||
- ✅ 情感分析(积极/中性/消极)
|
||||
- ✅ 效果评分
|
||||
- ✅ AI生成标记
|
||||
- ✅ 双面板聊天界面
|
||||
|
||||
### 5.10 其他功能 ✅
|
||||
|
||||
- ✅ 用户邀请和推广系统
|
||||
- ✅ 公司信息库(上市公司数据)
|
||||
- ✅ 版本管理
|
||||
- ✅ 邮件服务集成
|
||||
- ✅ OSS存储集成
|
||||
- ✅ 地理位置服务(百度地图API)
|
||||
- ✅ Swagger API文档
|
||||
- ✅ 数据导出(CSV)
|
||||
|
||||
## 六、数据模型
|
||||
|
||||
### 6.1 核心表结构
|
||||
|
||||
| 表名 | 说明 | 关键字段 |
|
||||
|------|------|---------|
|
||||
| **pla_account** | 平台账号表 | sn_code, platform_type, is_online, auto_deliver, deliver_config |
|
||||
| **resume_info** | 简历信息表 | resumeId, account_id, aiCompetitiveness, aiSkillTags, aiStrengths |
|
||||
| **job_postings** | 岗位信息表 | jobId, platform, aiMatchScore, isOutsourcing, applyStatus |
|
||||
| **apply_records** | 投递记录表 | resumeId, jobId, applyStatus, feedbackStatus, hasInterview, hasOffer |
|
||||
| **chat_records** | 聊天记录表 | conversationId, chatType, isAiGenerated, hasReply, sentiment |
|
||||
| **task_status** | 任务状态表 | taskType, status, priority, retryCount, progress |
|
||||
| **task_commands** | 任务指令表 | taskId, commandType, commandData, response, executeTime |
|
||||
| **job_types** | 职位类型表 | name, commonSkills, excludeKeywords, sortOrder |
|
||||
| **device_monitor** | 设备监控表 | sn_code, is_online, health_score, last_heartbeat_time |
|
||||
| **company_info** | 公司信息表 | company_name, is_listed, market_value, risk_level |
|
||||
|
||||
### 6.2 表关联关系
|
||||
|
||||
```
|
||||
pla_account (1) ──→ (N) resume_info
|
||||
│
|
||||
├─→ (N) apply_records ←── (1) job_postings
|
||||
│ │
|
||||
│ └─→ (N) chat_records
|
||||
│
|
||||
└─→ (N) task_status ──→ (N) task_commands
|
||||
|
||||
pla_account (1) ──→ (1) job_types (职位类型配置)
|
||||
pla_account (1) ──→ (N) device_monitor (设备监控)
|
||||
```
|
||||
|
||||
## 七、快速开始
|
||||
|
||||
### 7.1 环境要求
|
||||
|
||||
- Node.js >= 14.x
|
||||
- MySQL >= 8.0
|
||||
- Redis (可选,用于缓存)
|
||||
- MQTT Broker (已配置: mqtt://192.144.167.231:1883)
|
||||
|
||||
### 7.2 安装步骤
|
||||
|
||||
```bash
|
||||
# 1. 克隆项目
|
||||
git clone <repository-url>
|
||||
cd autoAiWorkSys
|
||||
|
||||
# 2. 安装后端依赖
|
||||
npm install
|
||||
|
||||
# 3. 安装前端依赖
|
||||
cd admin
|
||||
npm install
|
||||
|
||||
# 4. 配置数据库
|
||||
# 编辑 config/config.js
|
||||
# 设置MySQL连接信息
|
||||
|
||||
# 5. 初始化数据库
|
||||
# 执行 _sql 目录下的SQL脚本
|
||||
|
||||
# 6. 启动后端服务
|
||||
npm run dev
|
||||
|
||||
# 7. 启动前端服务(新终端)
|
||||
cd admin
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 7.3 核心配置
|
||||
|
||||
**config/config.js** - 主配置文件
|
||||
```javascript
|
||||
{
|
||||
db: {
|
||||
host: 'localhost',
|
||||
port: 3306,
|
||||
database: 'auto_job',
|
||||
username: 'root',
|
||||
password: 'your_password'
|
||||
},
|
||||
mqtt: {
|
||||
host: 'mqtt://192.144.167.231:1883',
|
||||
clientId: 'autoAiWorkSys_server'
|
||||
},
|
||||
ai: {
|
||||
apiKey: 'your_dashscope_api_key',
|
||||
model: 'qwen-turbo' // qwen-turbo/qwen-plus/qwen-max
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.4 访问地址
|
||||
|
||||
- 前端管理后台: http://localhost:8080
|
||||
- 后端API: http://localhost:3000/admin_api
|
||||
- API文档: http://localhost:3000/api/docs
|
||||
|
||||
## 八、项目文件结构
|
||||
|
||||
```
|
||||
autoAiWorkSys/
|
||||
├── admin/ # 前端管理后台
|
||||
│ ├── src/
|
||||
│ │ ├── views/ # 页面组件
|
||||
│ │ ├── api/ # API调用
|
||||
│ │ ├── router/ # 路由配置
|
||||
│ │ └── store/ # Vuex状态管理
|
||||
│ └── package.json
|
||||
│
|
||||
├── api/ # 后端服务
|
||||
│ ├── controller_admin/ # 后台管理API
|
||||
│ ├── middleware/ # 核心业务逻辑
|
||||
│ │ ├── job/ # 岗位、简历、聊天管理
|
||||
│ │ ├── schedule/ # 任务调度系统
|
||||
│ │ └── mqtt/ # MQTT通信
|
||||
│ ├── model/ # 数据库模型
|
||||
│ ├── services/ # 业务服务层
|
||||
│ └── utils/ # 工具函数
|
||||
│
|
||||
├── config/ # 配置文件
|
||||
├── framework/ # 核心框架
|
||||
├── _doc/ # 文档目录
|
||||
├── _sql/ # 数据库脚本
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## 九、技术亮点
|
||||
|
||||
1. **优先级队列** - 堆实现,O(log n)性能,比数组提升10-100倍
|
||||
2. **双层过滤** - 规则过滤+AI评分,平衡性能和准确性
|
||||
3. **智能重试** - 指数退避策略,区分可重试和不可重试错误
|
||||
4. **MQTT通信** - 异步消息队列,高效的设备指令下发和响应
|
||||
5. **AI多场景应用** - 简历分析、岗位匹配、聊天生成、外包识别
|
||||
6. **完整审计** - 任务、投递、聊天全链路追踪
|
||||
7. **模块化设计** - 清晰的分层架构,易于扩展和维护
|
||||
|
||||
## 十、性能指标
|
||||
|
||||
- 任务调度延迟: < 100ms
|
||||
- 数据库查询: 95%在100ms内
|
||||
- MQTT消息延迟: < 50ms
|
||||
- 前端页面加载: < 2s
|
||||
- 并发支持: 最多5个设备同时执行
|
||||
|
||||
---
|
||||
|
||||
**文档维护**: 开发团队
|
||||
**最后更新**: 2025-12-25
|
||||
**联系方式**: 项目Issues
|
||||
@@ -34,7 +34,6 @@ async function syncAllModels() {
|
||||
|
||||
// 执行同步
|
||||
await model.sync({ alter: true });
|
||||
console.log(`✅ ${modelName} 同步完成`);
|
||||
|
||||
return { modelName, success: true };
|
||||
} catch (error) {
|
||||
|
||||
66
_sql/add_pla_account_auto_search_fields.sql
Normal file
66
_sql/add_pla_account_auto_search_fields.sql
Normal file
@@ -0,0 +1,66 @@
|
||||
-- 为 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
|
||||
}
|
||||
|
||||
@@ -87,7 +87,9 @@ class TaskStatusServer {
|
||||
* @returns {Promise}
|
||||
*/
|
||||
cancel(row) {
|
||||
return window.framework.http.post(`task_status/${row.taskId}/cancel`)
|
||||
return window.framework.http.post('task/cancel', {
|
||||
taskId: row.taskId || row.id
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,7 +98,9 @@ class TaskStatusServer {
|
||||
* @returns {Promise}
|
||||
*/
|
||||
retry(row) {
|
||||
return window.framework.http.post(`task_status/${row.taskId}/retry`)
|
||||
return window.framework.http.post('task/retry', {
|
||||
taskId: row.taskId || row.id
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -190,8 +190,8 @@
|
||||
<div class="detail-item">
|
||||
<span class="label">自动投递:</span>
|
||||
<span class="value">
|
||||
<Tag :color="deliverConfig.auto_deliver ? 'success' : 'default'">
|
||||
{{ deliverConfig.auto_deliver ? '开启' : '关闭' }}
|
||||
<Tag :color="deliverConfig.auto_deliver ? 'success' : 'default'">
|
||||
{{ deliverConfig.auto_deliver ? '开启' : '关闭' }}
|
||||
</Tag>
|
||||
</span>
|
||||
</div>
|
||||
@@ -229,6 +229,12 @@
|
||||
</div>
|
||||
</Col>
|
||||
<Col span="8">
|
||||
<div class="detail-item">
|
||||
<span class="label">同公司重复投递间隔(天):</span>
|
||||
<span class="value">{{ deliverConfig.repeat_deliver_days != null ? deliverConfig.repeat_deliver_days : '-' }}</span>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span="8">
|
||||
<div class="detail-item">
|
||||
<span class="label">过滤关键词:</span>
|
||||
<span class="value">{{ deliverConfig.filter_keywords || '-' }}</span>
|
||||
@@ -360,15 +366,9 @@
|
||||
<div class="tab-body">
|
||||
<Table :columns="taskColumns" :data="tasksData" :loading="tasksLoading" border>
|
||||
</Table>
|
||||
<Page
|
||||
:current="tasksPageOption.page"
|
||||
:total="tasksPageOption.total"
|
||||
:page-size="tasksPageOption.pageSize"
|
||||
show-total
|
||||
show-elevator
|
||||
show-sizer
|
||||
@on-change="queryTasks"
|
||||
@on-page-size-change="handleTasksPageSizeChange"
|
||||
<Page :current="tasksPageOption.page" :total="tasksPageOption.total"
|
||||
:page-size="tasksPageOption.pageSize" show-total show-elevator show-sizer
|
||||
@on-change="queryTasks" @on-page-size-change="handleTasksPageSizeChange"
|
||||
style="margin-top: 16px; text-align: right;">
|
||||
</Page>
|
||||
</div>
|
||||
@@ -380,15 +380,9 @@
|
||||
<div class="tab-body">
|
||||
<Table :columns="commandColumns" :data="commandsData" :loading="commandsLoading" border>
|
||||
</Table>
|
||||
<Page
|
||||
:current="commandsPageOption.page"
|
||||
:total="commandsPageOption.total"
|
||||
:page-size="commandsPageOption.pageSize"
|
||||
show-total
|
||||
show-elevator
|
||||
show-sizer
|
||||
@on-change="queryCommands"
|
||||
@on-page-size-change="handleCommandsPageSizeChange"
|
||||
<Page :current="commandsPageOption.page" :total="commandsPageOption.total"
|
||||
:page-size="commandsPageOption.pageSize" show-total show-elevator show-sizer
|
||||
@on-change="queryCommands" @on-page-size-change="handleCommandsPageSizeChange"
|
||||
style="margin-top: 16px; text-align: right;">
|
||||
</Page>
|
||||
</div>
|
||||
@@ -497,6 +491,10 @@
|
||||
</div>
|
||||
<div slot="footer">
|
||||
<Button @click="closeCommandDetail">关闭</Button>
|
||||
<Button v-if="currentCommandDetail && currentCommandDetail.status === 'failed'" type="primary"
|
||||
:loading="retryCommandLoading" @click="retryCommand(currentCommandDetail)">
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -568,6 +566,7 @@ export default {
|
||||
max_salary: 0,
|
||||
page_count: 3,
|
||||
max_deliver: 10,
|
||||
repeat_deliver_days: 30,
|
||||
filter_keywords: '',
|
||||
exclude_keywords: ''
|
||||
},
|
||||
@@ -708,13 +707,13 @@ export default {
|
||||
width: 150,
|
||||
render: (h, params) => {
|
||||
const btns = []
|
||||
if (params.row.status === 'failed') {
|
||||
btns.push({
|
||||
title: '重试',
|
||||
type: 'primary',
|
||||
click: () => this.retryTask(params.row)
|
||||
})
|
||||
}
|
||||
|
||||
btns.push({
|
||||
title: '重试',
|
||||
type: 'primary',
|
||||
click: () => this.retryTask(params.row)
|
||||
})
|
||||
|
||||
if (params.row.status === 'pending' || params.row.status === 'running') {
|
||||
btns.push({
|
||||
title: '取消',
|
||||
@@ -748,6 +747,7 @@ export default {
|
||||
},
|
||||
// 指令详情弹窗
|
||||
commandDetailVisible: false,
|
||||
retryCommandLoading: false,
|
||||
currentCommandDetail: null,
|
||||
|
||||
// 二维码弹窗
|
||||
@@ -825,13 +825,13 @@ export default {
|
||||
click: () => this.showCommandDetail(params.row)
|
||||
})
|
||||
// 重试按钮(只在失败状态时显示)
|
||||
if (params.row.status === 'failed') {
|
||||
btns.push({
|
||||
title: '重试',
|
||||
type: 'warning',
|
||||
click: () => this.retryCommand(params.row)
|
||||
})
|
||||
}
|
||||
|
||||
btns.push({
|
||||
title: '重试',
|
||||
type: 'warning',
|
||||
click: () => this.retryCommand(params.row)
|
||||
})
|
||||
|
||||
return h('div', btns.map(btn =>
|
||||
h('Button', {
|
||||
props: {
|
||||
@@ -915,6 +915,7 @@ export default {
|
||||
max_salary: deliverConfig.max_salary || 0,
|
||||
page_count: deliverConfig.page_count || 3,
|
||||
max_deliver: deliverConfig.max_deliver || 10,
|
||||
repeat_deliver_days: deliverConfig.repeat_deliver_days != null ? deliverConfig.repeat_deliver_days : 30,
|
||||
filter_keywords: Array.isArray(deliverConfig.filter_keywords)
|
||||
? deliverConfig.filter_keywords.join(',')
|
||||
: (deliverConfig.filter_keywords || ''),
|
||||
@@ -933,6 +934,7 @@ export default {
|
||||
max_salary: 0,
|
||||
page_count: 3,
|
||||
max_deliver: 10,
|
||||
repeat_deliver_days: 30,
|
||||
filter_keywords: '',
|
||||
exclude_keywords: '',
|
||||
deliver_start_time: '09:00',
|
||||
@@ -1031,7 +1033,7 @@ export default {
|
||||
const res = await plaAccountServer.getTasks(this.accountId, param)
|
||||
|
||||
console.log('res', res);
|
||||
|
||||
|
||||
this.tasksData = res.data.rows || []
|
||||
this.tasksPageOption.total = res.data.count || 0
|
||||
|
||||
@@ -1081,23 +1083,37 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
// 重试指令
|
||||
// 重试指令(指令列表与指令详情弹窗共用,成功后刷新列表与详情)
|
||||
async retryCommand(command) {
|
||||
this.$Modal.confirm({
|
||||
title: '确认重试',
|
||||
content: `确定要重试指令"${command.command_name}"吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await plaAccountServer.retryCommand(command.id)
|
||||
this.$Message.success('重试指令成功')
|
||||
// 刷新指令列表
|
||||
|
||||
} catch (error) {
|
||||
console.error('重试指令失败:', error)
|
||||
this.$Message.error(error.message || '重试指令失败')
|
||||
const isFromDetail = this.commandDetailVisible && this.currentCommandDetail && this.currentCommandDetail.id === command.id
|
||||
if (!isFromDetail) {
|
||||
this.$Modal.confirm({
|
||||
title: '确认重试',
|
||||
content: `确定要重试指令"${command.command_name}"吗?`,
|
||||
onOk: async () => {
|
||||
await this.doRetryCommand(command)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
await this.doRetryCommand(command)
|
||||
},
|
||||
async doRetryCommand(command) {
|
||||
this.retryCommandLoading = true
|
||||
try {
|
||||
await plaAccountServer.retryCommand(command.id)
|
||||
this.$Message.success('重试指令成功')
|
||||
this.queryCommands(this.commandsPageOption.page)
|
||||
if (this.commandDetailVisible && this.currentCommandDetail && this.currentCommandDetail.id === command.id) {
|
||||
const res = await plaAccountServer.getCommandDetail(this.accountId, command.id)
|
||||
this.currentCommandDetail = res.data || {}
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('重试指令失败:', error)
|
||||
this.$Message.error(error.message || '重试指令失败')
|
||||
} finally {
|
||||
this.retryCommandLoading = false
|
||||
}
|
||||
},
|
||||
|
||||
// 取消任务
|
||||
|
||||
@@ -148,6 +148,9 @@
|
||||
<FormItem label="每次最多投递数">
|
||||
<InputNumber v-model="formData.max_deliver" :min="1" placeholder="默认10个" style="width: 100%;" />
|
||||
</FormItem>
|
||||
<FormItem label="同公司重复投递间隔(天)">
|
||||
<InputNumber v-model="formData.repeat_deliver_days" :min="1" :max="365" placeholder="默认30天,N天内投过的公司跳过" style="width: 100%;" />
|
||||
</FormItem>
|
||||
<FormItem label="过滤关键词">
|
||||
<Input
|
||||
v-model="formData.filter_keywords"
|
||||
@@ -268,6 +271,7 @@ export default {
|
||||
max_salary: 0,
|
||||
page_count: 3,
|
||||
max_deliver: 10,
|
||||
repeat_deliver_days: 30,
|
||||
filter_keywords: '',
|
||||
exclude_keywords: '',
|
||||
deliver_start_time: '09:00',
|
||||
@@ -386,6 +390,7 @@ export default {
|
||||
this.formData.max_salary = deliverConfig.max_salary || 0
|
||||
this.formData.page_count = deliverConfig.page_count || 3
|
||||
this.formData.max_deliver = deliverConfig.max_deliver || 10
|
||||
this.formData.repeat_deliver_days = deliverConfig.repeat_deliver_days != null ? deliverConfig.repeat_deliver_days : 30
|
||||
this.formData.filter_keywords = Array.isArray(deliverConfig.filter_keywords)
|
||||
? deliverConfig.filter_keywords.join('\n')
|
||||
: (deliverConfig.filter_keywords || '')
|
||||
@@ -447,6 +452,7 @@ export default {
|
||||
max_salary: 0,
|
||||
page_count: 3,
|
||||
max_deliver: 10,
|
||||
repeat_deliver_days: 30,
|
||||
filter_keywords: '',
|
||||
exclude_keywords: '',
|
||||
auto_chat: 0,
|
||||
@@ -600,6 +606,9 @@ export default {
|
||||
if (saveData.max_deliver !== undefined) {
|
||||
deliverConfig.max_deliver = Number(saveData.max_deliver) || 10
|
||||
}
|
||||
if (saveData.repeat_deliver_days !== undefined) {
|
||||
deliverConfig.repeat_deliver_days = Number(saveData.repeat_deliver_days) || 30
|
||||
}
|
||||
// 解析过滤关键词:支持换行和逗号分隔
|
||||
if (saveData.filter_keywords !== undefined) {
|
||||
deliverConfig.filter_keywords = this.parseKeywords(saveData.filter_keywords)
|
||||
@@ -623,6 +632,7 @@ export default {
|
||||
delete saveData.max_salary
|
||||
delete saveData.page_count
|
||||
delete saveData.max_deliver
|
||||
delete saveData.repeat_deliver_days
|
||||
delete saveData.filter_keywords
|
||||
delete saveData.exclude_keywords
|
||||
delete saveData.deliver_start_time
|
||||
|
||||
@@ -28,13 +28,14 @@
|
||||
<tables :columns="listColumns" :value="gridOption.data" :pageOption="gridOption.param.pageOption"
|
||||
@changePage="query"></tables>
|
||||
</div>
|
||||
<editModal ref="editModal" :columns="editColumns" :rules="gridOption.rules" @on-save="handleSaveSuccess">
|
||||
<editModal ref="editModal" :columns="editFormColumns" :rules="gridOption.rules" @on-save="handleSaveSuccess">
|
||||
</editModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import jobTypesServer from '@/api/work/job_types_server.js'
|
||||
import plaAccountServer from '@/api/profile/pla_account_server.js'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
@@ -44,8 +45,10 @@ export default {
|
||||
return {
|
||||
seachTypes: [
|
||||
{ key: 'name', value: '职位类型名称' },
|
||||
{ key: 'description', value: '描述' }
|
||||
{ key: 'description', value: '描述' },
|
||||
{ key: 'pla_account_id', value: '关联账户ID' }
|
||||
],
|
||||
plaAccountOptions: [],
|
||||
gridOption: {
|
||||
param: {
|
||||
seachOption: {
|
||||
@@ -63,6 +66,23 @@ export default {
|
||||
},
|
||||
listColumns: [
|
||||
{ title: 'ID', key: 'id', minWidth: 80 },
|
||||
{
|
||||
title: '关联账户',
|
||||
key: 'pla_account',
|
||||
minWidth: 200,
|
||||
render: (h, params) => {
|
||||
const id = params.row.pla_account_id
|
||||
const pa = params.row.pla_account
|
||||
if (id == null || id === '') {
|
||||
return h('span', { style: { color: '#999' } }, '-')
|
||||
}
|
||||
if (pa && (pa.name || pa.sn_code)) {
|
||||
const txt = `${pa.name || ''} (SN:${pa.sn_code || '-'})`
|
||||
return h('span', { attrs: { title: `ID:${id} ${txt}` } }, txt)
|
||||
}
|
||||
return h('span', { attrs: { title: '仅ID,账户可能已删除' } }, `ID:${id}`)
|
||||
}
|
||||
},
|
||||
{ title: '职位类型名称', key: 'name', minWidth: 150 },
|
||||
{ title: '描述', key: 'description', minWidth: 200 },
|
||||
{
|
||||
@@ -77,6 +97,11 @@ export default {
|
||||
{
|
||||
title: '常见技能关键词', key: 'commonSkills', minWidth: 200,
|
||||
},
|
||||
{
|
||||
title: '标题须含关键词',
|
||||
key: 'titleIncludeKeywords',
|
||||
minWidth: 200
|
||||
},
|
||||
{
|
||||
title: '排除关键词', key: 'excludeKeywords', minWidth: 200
|
||||
},
|
||||
@@ -147,6 +172,22 @@ export default {
|
||||
placeholder: '请输入JSON数组格式,例如:["外包", "销售", "客服"]',
|
||||
tooltip: '排除关键词列表,JSON数组格式'
|
||||
},
|
||||
{
|
||||
title: '标题须含关键词',
|
||||
key: 'titleIncludeKeywords',
|
||||
com: 'TextArea',
|
||||
required: false,
|
||||
placeholder: '请输入JSON数组格式,例如:["售前", "工程师"]',
|
||||
tooltip: 'JSON数组格式;仅匹配岗位标题,须同时包含每一项;与「常见技能关键词」无关'
|
||||
},
|
||||
{
|
||||
title: '关联账户',
|
||||
key: 'pla_account_id',
|
||||
type: 'select',
|
||||
required: false,
|
||||
tooltip: '可选;与设备/账号绑定,AI 同步 Tab 时会写入',
|
||||
options: []
|
||||
},
|
||||
{
|
||||
title: '是否启用',
|
||||
key: 'is_enabled',
|
||||
@@ -171,12 +212,35 @@ export default {
|
||||
seachTypePlaceholder() {
|
||||
const item = this.seachTypes.find(item => item.key === this.gridOption.param.seachOption.key)
|
||||
return item ? `请输入${item.value}` : '请选择'
|
||||
},
|
||||
editFormColumns() {
|
||||
const accOpts = [{ value: '', label: '不关联' }, ...this.plaAccountOptions]
|
||||
return this.editColumns.map((col) => {
|
||||
if (col.key === 'pla_account_id') {
|
||||
return { ...col, options: accOpts }
|
||||
}
|
||||
return col
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadPlaAccountOptions()
|
||||
this.query(1)
|
||||
},
|
||||
methods: {
|
||||
loadPlaAccountOptions() {
|
||||
plaAccountServer.page({
|
||||
pageOption: { page: 1, pageSize: 999 },
|
||||
seachOption: {}
|
||||
}).then((res) => {
|
||||
if (res.code === 0 && res.data && res.data.rows) {
|
||||
this.plaAccountOptions = res.data.rows.map((r) => ({
|
||||
value: r.id,
|
||||
label: `${r.name || ''} (SN:${r.sn_code || r.id})`
|
||||
}))
|
||||
}
|
||||
}).catch(() => {})
|
||||
},
|
||||
query(page) {
|
||||
if (page) {
|
||||
this.gridOption.param.pageOption.page = page
|
||||
@@ -217,6 +281,8 @@ export default {
|
||||
description: '',
|
||||
commonSkills: '[]',
|
||||
excludeKeywords: '[]',
|
||||
titleIncludeKeywords: '[]',
|
||||
pla_account_id: '',
|
||||
is_enabled: 1,
|
||||
sort_order: 0
|
||||
})
|
||||
@@ -249,12 +315,26 @@ export default {
|
||||
excludeKeywords = JSON.stringify(excludeKeywords, null, 2)
|
||||
}
|
||||
|
||||
let titleIncludeKeywords = row.titleIncludeKeywords || '[]'
|
||||
if (typeof titleIncludeKeywords === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(titleIncludeKeywords)
|
||||
titleIncludeKeywords = JSON.stringify(parsed, null, 2)
|
||||
} catch (e) {
|
||||
// 保持原样
|
||||
}
|
||||
} else {
|
||||
titleIncludeKeywords = JSON.stringify(titleIncludeKeywords, null, 2)
|
||||
}
|
||||
|
||||
this.$refs.editModal.editShow({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description || '',
|
||||
commonSkills: commonSkills,
|
||||
excludeKeywords: excludeKeywords,
|
||||
titleIncludeKeywords: titleIncludeKeywords,
|
||||
pla_account_id: row.pla_account_id != null && row.pla_account_id !== '' ? row.pla_account_id : '',
|
||||
is_enabled: row.is_enabled,
|
||||
sort_order: row.sort_order || 0
|
||||
})
|
||||
@@ -281,7 +361,7 @@ export default {
|
||||
// 处理 JSON 字段
|
||||
const formData = { ...data }
|
||||
|
||||
// 处理 commonSkills
|
||||
// 处理 commonSkills(JSON 数组)
|
||||
if (formData.commonSkills) {
|
||||
try {
|
||||
const parsed = typeof formData.commonSkills === 'string'
|
||||
@@ -294,7 +374,7 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 excludeKeywords
|
||||
// 处理 excludeKeywords(JSON 数组)
|
||||
if (formData.excludeKeywords) {
|
||||
try {
|
||||
const parsed = typeof formData.excludeKeywords === 'string'
|
||||
@@ -307,6 +387,28 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 titleIncludeKeywords(JSON 数组,与上两项一致)
|
||||
if (formData.titleIncludeKeywords) {
|
||||
try {
|
||||
const parsed = typeof formData.titleIncludeKeywords === 'string'
|
||||
? JSON.parse(formData.titleIncludeKeywords)
|
||||
: formData.titleIncludeKeywords
|
||||
formData.titleIncludeKeywords = Array.isArray(parsed) ? parsed : []
|
||||
} catch (e) {
|
||||
this.$Message.warning('标题须含关键词格式错误,将使用空数组')
|
||||
formData.titleIncludeKeywords = []
|
||||
}
|
||||
} else {
|
||||
formData.titleIncludeKeywords = []
|
||||
}
|
||||
|
||||
if (formData.pla_account_id === undefined || formData.pla_account_id === '') {
|
||||
formData.pla_account_id = null
|
||||
} else if (formData.pla_account_id != null) {
|
||||
const n = parseInt(formData.pla_account_id, 10)
|
||||
formData.pla_account_id = Number.isNaN(n) ? null : n
|
||||
}
|
||||
|
||||
const apiMethod = formData.id ? jobTypesServer.update : jobTypesServer.add
|
||||
apiMethod(formData).then(res => {
|
||||
if (res.code === 0) {
|
||||
|
||||
@@ -277,7 +277,7 @@ module.exports = {
|
||||
|
||||
const records = await chat_records.findAll({
|
||||
where: { jobId, sn_code },
|
||||
order: [['sendTime', 'ASC'], ['receiveTime', 'ASC'], ['id', 'ASC']]
|
||||
order: [['updateTime', 'ASC'], ['id', 'ASC']]
|
||||
});
|
||||
|
||||
return ctx.success(records);
|
||||
@@ -338,8 +338,7 @@ module.exports = {
|
||||
content,
|
||||
chatType,
|
||||
direction: 'sent',
|
||||
sendStatus: 'pending',
|
||||
sendTime: new Date()
|
||||
// 对话会话表当前不再区分单条消息时间,这里仅保留必要字段
|
||||
});
|
||||
|
||||
console.log(`[聊天管理] 消息待发送到设备 ${sn_code}:`, content);
|
||||
@@ -482,8 +481,8 @@ module.exports = {
|
||||
record.direction || '',
|
||||
record.chatType || '',
|
||||
`"${(record.content || '').replace(/"/g, '""')}"`,
|
||||
record.sendTime || '',
|
||||
record.receiveTime || '',
|
||||
'',
|
||||
'',
|
||||
record.hasReply ? '是' : '否'
|
||||
];
|
||||
csvContent += row.join(',') + '\n';
|
||||
|
||||
@@ -165,7 +165,7 @@ switch (type) {
|
||||
break;
|
||||
case 'chat':
|
||||
Model = models.chat_records;
|
||||
dateField = 'sendTime';
|
||||
dateField = 'updateTime';
|
||||
break;
|
||||
default:
|
||||
return ctx.fail('无效的统计类型');
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
const Framework = require("../../framework/node-core-framework.js");
|
||||
const jobManager = require("../middleware/job/jobManager.js");
|
||||
const jobManager = require("../middleware/job/index.js");
|
||||
|
||||
module.exports = {
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,57 @@
|
||||
|
||||
const Framework = require("../../framework/node-core-framework.js");
|
||||
|
||||
/**
|
||||
* 为 job_types 行批量附加 pla_account(列表/详情展示)
|
||||
* @param {import('sequelize').Model[]} rowInstances
|
||||
* @param {object} models
|
||||
* @returns {Promise<object[]>}
|
||||
*/
|
||||
async function attachPlaAccountToJobTypeRows(rowInstances, models) {
|
||||
const { pla_account, op } = models;
|
||||
const plain = (rowInstances || []).map((r) => (r && typeof r.toJSON === 'function' ? r.toJSON() : r));
|
||||
const ids = [...new Set(plain.map((row) => row.pla_account_id).filter((id) => id != null && id !== ''))];
|
||||
if (!pla_account || ids.length === 0) {
|
||||
return plain.map((row) => ({ ...row, pla_account: null }));
|
||||
}
|
||||
const accounts = await pla_account.findAll({
|
||||
where: { id: { [op.in]: ids } },
|
||||
attributes: ['id', 'name', 'sn_code', 'login_name']
|
||||
});
|
||||
const map = {};
|
||||
accounts.forEach((a) => {
|
||||
const j = a.toJSON();
|
||||
map[j.id] = j;
|
||||
});
|
||||
return plain.map((row) => ({
|
||||
...row,
|
||||
pla_account: row.pla_account_id != null ? map[row.pla_account_id] || null : null
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {*} raw
|
||||
* @param {object} pla_account
|
||||
* @returns {Promise<{ ok: boolean, value?: number|null, message?: string }>}
|
||||
*/
|
||||
async function normalizePlaAccountId(raw, pla_account) {
|
||||
if (raw === undefined) {
|
||||
return { ok: true, skip: true };
|
||||
}
|
||||
if (raw === null || raw === '') {
|
||||
return { ok: true, value: null };
|
||||
}
|
||||
const n = parseInt(raw, 10);
|
||||
if (Number.isNaN(n) || n < 1) {
|
||||
return { ok: false, message: '关联账户ID无效' };
|
||||
}
|
||||
const acc = await pla_account.findByPk(n);
|
||||
if (!acc) {
|
||||
return { ok: false, message: '关联账户不存在' };
|
||||
}
|
||||
return { ok: true, value: n };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
/**
|
||||
* @swagger
|
||||
@@ -37,14 +88,26 @@ module.exports = {
|
||||
const models = Framework.getModels();
|
||||
const { job_types, op } = models;
|
||||
const body = ctx.getBody();
|
||||
const { name } = body;
|
||||
const seachOption = body.seachOption || {};
|
||||
|
||||
// 获取分页参数
|
||||
const { limit, offset } = ctx.getPageSize();
|
||||
|
||||
const where = {};
|
||||
if (name) {
|
||||
where.name = { [op.like]: `%${name}%` };
|
||||
const key = seachOption.key || body.key;
|
||||
const value = seachOption.value !== undefined && seachOption.value !== null ? seachOption.value : body.value;
|
||||
if (key && value !== undefined && value !== null && String(value).trim() !== '') {
|
||||
const v = String(value).trim();
|
||||
if (key === 'pla_account_id') {
|
||||
const n = parseInt(v, 10);
|
||||
if (!Number.isNaN(n)) {
|
||||
where.pla_account_id = n;
|
||||
}
|
||||
} else if (key === 'name' || key === 'description') {
|
||||
where[key] = { [op.like]: `%${v}%` };
|
||||
}
|
||||
}
|
||||
if (seachOption.is_enabled !== undefined && seachOption.is_enabled !== null) {
|
||||
where.is_enabled = seachOption.is_enabled;
|
||||
}
|
||||
|
||||
const result = await job_types.findAndCountAll({
|
||||
@@ -54,7 +117,8 @@ module.exports = {
|
||||
order: [['sort_order', 'ASC'], ['id', 'ASC']]
|
||||
});
|
||||
|
||||
return ctx.success(result);
|
||||
const rows = await attachPlaAccountToJobTypeRows(result.rows, models);
|
||||
return ctx.success({ rows, count: result.count });
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -89,7 +153,8 @@ module.exports = {
|
||||
return ctx.fail('职位类型不存在');
|
||||
}
|
||||
|
||||
return ctx.success(jobType);
|
||||
const [enriched] = await attachPlaAccountToJobTypeRows([jobType], models);
|
||||
return ctx.success(enriched);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -120,6 +185,9 @@ module.exports = {
|
||||
* excludeKeywords:
|
||||
* type: array
|
||||
* description: 排除关键词(JSON数组)
|
||||
* titleIncludeKeywords:
|
||||
* type: array
|
||||
* description: 职位标题须同时包含的子串(JSON数组),仅匹配岗位标题
|
||||
* is_enabled:
|
||||
* type: integer
|
||||
* description: 是否启用(1=启用,0=禁用)
|
||||
@@ -132,9 +200,9 @@ module.exports = {
|
||||
*/
|
||||
'POST /job_type/create': async (ctx) => {
|
||||
const models = Framework.getModels();
|
||||
const { job_types } = models;
|
||||
const { job_types, pla_account } = models;
|
||||
const body = ctx.getBody();
|
||||
const { name, description, commonSkills, excludeKeywords, is_enabled, sort_order } = body;
|
||||
const { name, description, commonSkills, excludeKeywords, titleIncludeKeywords, is_enabled, sort_order } = body;
|
||||
|
||||
if (!name) {
|
||||
return ctx.fail('职位类型名称不能为空');
|
||||
@@ -146,16 +214,30 @@ module.exports = {
|
||||
return ctx.fail('职位类型名称已存在');
|
||||
}
|
||||
|
||||
let pla_account_id = null;
|
||||
const paResolved = await normalizePlaAccountId(body.pla_account_id, pla_account);
|
||||
if (!paResolved.ok) {
|
||||
return ctx.fail(paResolved.message || '关联账户校验失败');
|
||||
}
|
||||
if (!paResolved.skip) {
|
||||
pla_account_id = paResolved.value === undefined ? null : paResolved.value;
|
||||
}
|
||||
|
||||
const jobType = await job_types.create({
|
||||
name,
|
||||
description: description || '',
|
||||
commonSkills: Array.isArray(commonSkills) ? JSON.stringify(commonSkills) : (commonSkills || '[]'),
|
||||
excludeKeywords: Array.isArray(excludeKeywords) ? JSON.stringify(excludeKeywords) : (excludeKeywords || '[]'),
|
||||
titleIncludeKeywords: Array.isArray(titleIncludeKeywords)
|
||||
? JSON.stringify(titleIncludeKeywords)
|
||||
: (titleIncludeKeywords || '[]'),
|
||||
pla_account_id,
|
||||
is_enabled: is_enabled !== undefined ? is_enabled : 1,
|
||||
sort_order: sort_order || 0
|
||||
});
|
||||
|
||||
return ctx.success(jobType);
|
||||
const [enriched] = await attachPlaAccountToJobTypeRows([jobType], models);
|
||||
return ctx.success(enriched);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -189,6 +271,9 @@ module.exports = {
|
||||
* excludeKeywords:
|
||||
* type: array
|
||||
* description: 排除关键词(JSON数组)
|
||||
* titleIncludeKeywords:
|
||||
* type: array
|
||||
* description: 职位标题须同时包含的子串(JSON数组)
|
||||
* is_enabled:
|
||||
* type: integer
|
||||
* description: 是否启用(1=启用,0=禁用)
|
||||
@@ -201,9 +286,9 @@ module.exports = {
|
||||
*/
|
||||
'POST /job_type/update': async (ctx) => {
|
||||
const models = Framework.getModels();
|
||||
const { job_types } = models;
|
||||
const { job_types, pla_account } = models;
|
||||
const body = ctx.getBody();
|
||||
const { id, name, description, commonSkills, excludeKeywords, is_enabled, sort_order } = body;
|
||||
const { id, name, description, commonSkills, excludeKeywords, titleIncludeKeywords, is_enabled, sort_order } = body;
|
||||
|
||||
if (!id) {
|
||||
return ctx.fail('职位类型ID不能为空');
|
||||
@@ -231,16 +316,32 @@ module.exports = {
|
||||
if (excludeKeywords !== undefined) {
|
||||
updateData.excludeKeywords = Array.isArray(excludeKeywords) ? JSON.stringify(excludeKeywords) : excludeKeywords;
|
||||
}
|
||||
if (titleIncludeKeywords !== undefined) {
|
||||
updateData.titleIncludeKeywords = Array.isArray(titleIncludeKeywords)
|
||||
? JSON.stringify(titleIncludeKeywords)
|
||||
: titleIncludeKeywords;
|
||||
}
|
||||
if (body.pla_account_id !== undefined) {
|
||||
const paResolved = await normalizePlaAccountId(body.pla_account_id, pla_account);
|
||||
if (!paResolved.ok) {
|
||||
return ctx.fail(paResolved.message || '关联账户校验失败');
|
||||
}
|
||||
if (!paResolved.skip) {
|
||||
updateData.pla_account_id = paResolved.value === undefined ? null : paResolved.value;
|
||||
}
|
||||
}
|
||||
if (is_enabled !== undefined) updateData.is_enabled = is_enabled;
|
||||
if (sort_order !== undefined) updateData.sort_order = sort_order;
|
||||
|
||||
await job_types.update(updateData, { where: { id } });
|
||||
|
||||
// 清除缓存
|
||||
const jobFilterService = require('../middleware/job/job_filter_service.js');
|
||||
const jobFilterService = require('../middleware/job/services/jobFilterService.js');
|
||||
jobFilterService.clearCache(id);
|
||||
|
||||
return ctx.success({ message: '职位类型更新成功' });
|
||||
const updated = await job_types.findByPk(id);
|
||||
const [enriched] = await attachPlaAccountToJobTypeRows([updated], models);
|
||||
return ctx.success(enriched);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -289,7 +390,7 @@ module.exports = {
|
||||
await job_types.destroy({ where: { id } });
|
||||
|
||||
// 清除缓存
|
||||
const jobFilterService = require('../middleware/job/job_filter_service.js');
|
||||
const jobFilterService = require('../middleware/job/services/jobFilterService.js');
|
||||
jobFilterService.clearCache(id);
|
||||
|
||||
return ctx.success({ message: '职位类型删除成功' });
|
||||
|
||||
@@ -363,7 +363,7 @@ module.exports = {
|
||||
* description: 账号ID
|
||||
* taskType:
|
||||
* type: string
|
||||
* description: 指令类型: get_login_qr_code-登录检查, get_resume-获取简历, search_jobs-搜索岗位
|
||||
* description: 任务类型: get_login_qr_code-登录检查, get_resume-获取简历, auto_search-搜索岗位
|
||||
* taskName:
|
||||
* type: string
|
||||
* description: 指令名称
|
||||
|
||||
@@ -69,12 +69,7 @@ module.exports = {
|
||||
}),
|
||||
chat_records.count({
|
||||
where: {
|
||||
sn_code: deviceSn,
|
||||
direction: 'sent',
|
||||
sendTime: {
|
||||
[op.gte]: todayStart,
|
||||
[op.lte]: todayEnd
|
||||
}
|
||||
sn_code: deviceSn
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('[统计] 查询聊天数量失败:', err);
|
||||
@@ -189,14 +184,9 @@ module.exports = {
|
||||
}),
|
||||
chat_records.findAll({
|
||||
where: {
|
||||
sn_code: deviceSn,
|
||||
direction: 'sent',
|
||||
sendTime: {
|
||||
[op.gte]: start,
|
||||
[op.lte]: end
|
||||
}
|
||||
sn_code: deviceSn
|
||||
},
|
||||
attributes: ['sendTime'],
|
||||
attributes: ['updateTime'],
|
||||
raw: true
|
||||
}).catch(err => {
|
||||
console.error('[统计] 查询聊天记录失败:', err);
|
||||
@@ -226,7 +216,7 @@ module.exports = {
|
||||
}).length;
|
||||
|
||||
const chatCount = allChats.filter(item => {
|
||||
const itemDate = dayjs(item.sendTime);
|
||||
const itemDate = dayjs(item.updateTime);
|
||||
return !itemDate.isBefore(dayStart, 'day') && !itemDate.isAfter(dayEnd, 'day');
|
||||
}).length;
|
||||
|
||||
@@ -499,14 +489,9 @@ module.exports = {
|
||||
|
||||
const allChats = await chat_records.findAll({
|
||||
where: {
|
||||
sn_code: deviceSn,
|
||||
direction: 'sent',
|
||||
sendTime: {
|
||||
[op.gte]: start,
|
||||
[op.lte]: end
|
||||
}
|
||||
sn_code: deviceSn
|
||||
},
|
||||
attributes: ['sendTime'],
|
||||
attributes: ['updateTime'],
|
||||
raw: true
|
||||
}).catch(err => {
|
||||
console.error('[统计] 查询聊天记录失败:', err);
|
||||
@@ -523,7 +508,7 @@ module.exports = {
|
||||
const dayEnd = currentDate.endOf('day');
|
||||
|
||||
const count = allChats.filter(item => {
|
||||
const itemDate = dayjs(item.sendTime);
|
||||
const itemDate = dayjs(item.updateTime);
|
||||
return !itemDate.isBefore(dayStart, 'day') && !itemDate.isAfter(dayEnd, 'day');
|
||||
}).length;
|
||||
|
||||
|
||||
@@ -439,9 +439,7 @@ return ctx.success({
|
||||
return ctx.fail('任务不存在');
|
||||
}
|
||||
|
||||
if (task.status !== 'failed') {
|
||||
return ctx.fail('只能重试失败的任务');
|
||||
}
|
||||
|
||||
|
||||
await task_status.update({
|
||||
status: 'pending',
|
||||
|
||||
93
api/controller_front/static.js
Normal file
93
api/controller_front/static.js
Normal file
@@ -0,0 +1,93 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const axios = require('axios');
|
||||
|
||||
/**
|
||||
* 静态 JS 代理/缓存
|
||||
*
|
||||
* 规则:
|
||||
* - 前端请求:GET /static/boss,header 里带 path,例如:
|
||||
* path: https://static.zhipin.com/fe-zhipin-geek/web/chat-new/v5410/static/js/app.4e199352.js
|
||||
* - 从 URL 中取 pathname:/fe-zhipin-geek/web/chat-new/v5410/static/js/app.4e199352.js
|
||||
* - 去掉开头的 /,中间的 / 全部替换为 _,得到本地文件名:
|
||||
* fe-zhipin-geek_web-chat-new_v5410_static_js_app.4e199352.js
|
||||
* - 在项目根目录下的 js 目录保存/读取该文件:./js/<文件名>
|
||||
* - 如果已存在:直接返回本地文件
|
||||
* - 如果不存在:从远程 URL 下载,保存后返回
|
||||
*/
|
||||
module.exports = {
|
||||
'GET /static/boss': async (ctx) => {
|
||||
// 1. 获取原始 URL(优先从 header,兼容 query/body)
|
||||
const urlStr =
|
||||
ctx.get('path') ||
|
||||
ctx.query.path ||
|
||||
(ctx.request.body && ctx.request.body.path);
|
||||
|
||||
if (!urlStr) {
|
||||
ctx.status = 400;
|
||||
ctx.body = { code: 400, message: '缺少 path 参数' };
|
||||
return;
|
||||
}
|
||||
|
||||
let urlObj = new URL(urlStr);
|
||||
|
||||
// 2. 生成本地文件名:去掉开头的 /,中间 / 替换为 _
|
||||
const remotePath = urlObj.pathname || '/';
|
||||
const fileName = remotePath
|
||||
.replace(/^\/+/, '')
|
||||
.replace(/\//g, '_');
|
||||
|
||||
// 根目录下 js 目录
|
||||
const jsRootDir = path.join(process.cwd(), 'static/boss');
|
||||
const localFilePath = path.join(jsRootDir, fileName);
|
||||
|
||||
// 钩子注入:在 JS 中注入自定义 onMessageArrived 钩子
|
||||
const injectOnMessageArrivedHook = (buffer) => {
|
||||
try {
|
||||
let js = buffer.toString('utf8');
|
||||
const needle = 'onMessageArrived:function(e){try{var t=e.payloadBytes,n=S.decode(t);';
|
||||
if (js.includes(needle)) {
|
||||
const hook = `${needle}if(window.Function&&window.Function.__proto__&&typeof window.Function.__proto__.$onMessageArrived==="function"){try{window.Function.__proto__.$onMessageArrived(n);}catch(e){}}`;
|
||||
js = js.replace(needle, hook);
|
||||
return Buffer.from(js, 'utf8');
|
||||
}
|
||||
return buffer;
|
||||
} catch (e) {
|
||||
return buffer;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// 确保目录存在
|
||||
if (!fs.existsSync(jsRootDir)) {
|
||||
fs.mkdirSync(jsRootDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 3. 如果文件已存在,直接返回本地文件(文件内容已是替换后的,无需再次注入)
|
||||
if (fs.existsSync(localFilePath)) {
|
||||
ctx.type = 'application/javascript; charset=utf-8';
|
||||
ctx.body = fs.createReadStream(localFilePath);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 文件不存在:从远程下载并保存(带钩子注入)
|
||||
const response = await axios.get(urlStr, {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
ctx.status = 502;
|
||||
ctx.body = { code: 502, message: '下载远程 JS 失败' };
|
||||
return;
|
||||
}
|
||||
|
||||
const patched = injectOnMessageArrivedHook(Buffer.from(response.data));
|
||||
|
||||
fs.writeFileSync(localFilePath, patched);
|
||||
|
||||
ctx.type = 'application/javascript; charset=utf-8';
|
||||
ctx.body = patched;
|
||||
|
||||
},
|
||||
}
|
||||
@@ -75,7 +75,7 @@ module.exports = {
|
||||
* example: '用户不存在或密码错误'
|
||||
*/
|
||||
"POST /user/login": async (ctx) => {
|
||||
const { login_name:email, password, device_id: client_device_id } = ctx.getBody();
|
||||
const { login_name: email, password, device_id: client_device_id } = ctx.getBody();
|
||||
const dayjs = require('dayjs');
|
||||
const { verifyPassword, validateDeviceId, maskEmail } = require('../utils/crypto_utils');
|
||||
|
||||
@@ -87,16 +87,6 @@ module.exports = {
|
||||
// 统一邮箱地址为小写
|
||||
const email_normalized = email.toLowerCase().trim();
|
||||
|
||||
// 验证邮箱格式
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email_normalized)) {
|
||||
return ctx.fail('邮箱格式不正确');
|
||||
}
|
||||
|
||||
// 验证密码长度
|
||||
if (password.length < 6 || password.length > 50) {
|
||||
return ctx.fail('密码长度不正确');
|
||||
}
|
||||
|
||||
const { pla_account } = await Framework.getModels();
|
||||
|
||||
@@ -404,17 +394,17 @@ module.exports = {
|
||||
|
||||
// 获取原有配置
|
||||
const original_deliver_config = user.deliver_config || {};
|
||||
|
||||
|
||||
// 深度合并配置(只覆盖传入的字段,保留原有的其他字段)
|
||||
const deepMerge = (target, source) => {
|
||||
const result = { ...target };
|
||||
Object.keys(source).forEach(key => {
|
||||
const sourceValue = source[key];
|
||||
const targetValue = target[key];
|
||||
|
||||
|
||||
// 如果源值是对象且目标值也是对象,递归合并(排除数组)
|
||||
if (sourceValue && typeof sourceValue === 'object' && !Array.isArray(sourceValue) &&
|
||||
targetValue && typeof targetValue === 'object' && !Array.isArray(targetValue)) {
|
||||
targetValue && typeof targetValue === 'object' && !Array.isArray(targetValue)) {
|
||||
result[key] = deepMerge(targetValue, sourceValue);
|
||||
} else {
|
||||
// 否则直接覆盖
|
||||
@@ -423,7 +413,7 @@ module.exports = {
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
|
||||
const new_deliver_config = deepMerge(original_deliver_config, deliver_config);
|
||||
|
||||
// 处理 auto_deliver 字段(支持 auto_deliver 和 auto_delivery 两种字段名)
|
||||
@@ -447,7 +437,7 @@ module.exports = {
|
||||
|
||||
console.log('[保存投递配置成功]', {
|
||||
sn_code,
|
||||
deliver_config:new_deliver_config,
|
||||
deliver_config: new_deliver_config,
|
||||
auto_deliver: auto_deliver_value,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
@@ -460,5 +450,182 @@ module.exports = {
|
||||
});
|
||||
return ctx.fail('保存投递配置失败');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 根据设备SN码获取账号全部功能配置(投递、沟通、活跃)
|
||||
*/
|
||||
'POST /user/account-config/get': async (ctx) => {
|
||||
try {
|
||||
const body = ctx.getBody();
|
||||
const { sn_code } = body;
|
||||
if (!sn_code) return ctx.fail('请提供设备SN码');
|
||||
|
||||
const { pla_account, resume_info } = await Framework.getModels();
|
||||
const user = await pla_account.findOne({ where: { sn_code } });
|
||||
if (!user) return ctx.fail('用户不存在');
|
||||
|
||||
const u = user.toJSON ? user.toJSON() : user;
|
||||
const result = {
|
||||
deliver_config: u.deliver_config || null,
|
||||
chat_strategy: u.chat_strategy || null,
|
||||
active_actions: u.active_actions || null,
|
||||
auto_chat: u.auto_chat != null ? !!u.auto_chat : false,
|
||||
auto_active: u.auto_active != null ? !!u.auto_active : false
|
||||
};
|
||||
|
||||
const platform = u.platform_type || 'boss';
|
||||
const resume = await resume_info.findOne({
|
||||
where: { sn_code, platform, isActive: true },
|
||||
order: [['last_modify_time', 'DESC']]
|
||||
});
|
||||
if (resume) {
|
||||
const r = resume.toJSON ? resume.toJSON() : resume;
|
||||
result.job_listings = Array.isArray(r.job_listings) ? r.job_listings : [];
|
||||
result.deliver_tab_label = r.deliver_tab_label != null ? String(r.deliver_tab_label) : '';
|
||||
} else {
|
||||
result.job_listings = [];
|
||||
result.deliver_tab_label = '';
|
||||
}
|
||||
return ctx.success(result);
|
||||
} catch (error) {
|
||||
console.error('[获取账号配置失败]', error);
|
||||
return ctx.fail('获取账号配置失败');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 仅保存投递标签:标签列表(来自 get_job_listings)+ 当前选中的标签
|
||||
* 更新 resume_info 的 job_listings、deliver_tab_label,并同步 pla_account.keyword(与推荐/期望职位一致)
|
||||
*/
|
||||
'POST /user/deliver-tab-label/save': async (ctx) => {
|
||||
try {
|
||||
const body = ctx.getBody();
|
||||
const { sn_code, job_listings, deliver_tab_label } = body;
|
||||
if (!sn_code) return ctx.fail('请提供设备SN码');
|
||||
|
||||
const { pla_account, resume_info } = await Framework.getModels();
|
||||
const user = await pla_account.findOne({ where: { sn_code } });
|
||||
if (!user) return ctx.fail('用户不存在');
|
||||
|
||||
const platform = user.platform_type || 'boss';
|
||||
const account_id = user.account_id != null ? String(user.account_id) : (user.id != null ? String(user.id) : '');
|
||||
const list = Array.isArray(job_listings) ? job_listings : [];
|
||||
const label = deliver_tab_label != null ? String(deliver_tab_label) : '';
|
||||
|
||||
let resume = await resume_info.findOne({
|
||||
where: { sn_code, platform },
|
||||
order: [['last_modify_time', 'DESC']]
|
||||
});
|
||||
if (resume) {
|
||||
await resume_info.update(
|
||||
{ job_listings: list, deliver_tab_label: label },
|
||||
{ where: { id: resume.id } }
|
||||
);
|
||||
} else {
|
||||
await resume_info.create({
|
||||
sn_code,
|
||||
account_id: account_id || '',
|
||||
platform,
|
||||
resumeId: '',
|
||||
job_listings: list,
|
||||
deliver_tab_label: label,
|
||||
isActive: true
|
||||
});
|
||||
}
|
||||
|
||||
const keywordSync = label.trim().slice(0, 50);
|
||||
await pla_account.update({ keyword: keywordSync }, { where: { id: user.id } });
|
||||
|
||||
return ctx.success({ message: '投递标签已保存', job_listings: list, deliver_tab_label: label });
|
||||
} catch (error) {
|
||||
console.error('[保存投递标签失败]', error);
|
||||
return ctx.fail('保存投递标签失败');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 保存账号功能配置(可只传需要更新的部分:deliver_config / chat_strategy / active_actions)
|
||||
*/
|
||||
'POST /user/account-config/save': async (ctx) => {
|
||||
try {
|
||||
const body = ctx.getBody();
|
||||
const { sn_code, deliver_config, chat_strategy, active_actions, deliver_tab_label, job_listings } = body;
|
||||
if (!sn_code) return ctx.fail('请提供设备SN码');
|
||||
|
||||
const { pla_account, resume_info } = await Framework.getModels();
|
||||
const user = await pla_account.findOne({ where: { sn_code } });
|
||||
if (!user) return ctx.fail('用户不存在');
|
||||
|
||||
const updateData = {};
|
||||
if (deliver_config !== undefined) {
|
||||
const original = user.deliver_config || {};
|
||||
const deepMerge = (t, s) => {
|
||||
const r = { ...t };
|
||||
Object.keys(s).forEach(k => {
|
||||
const sv = s[k], tv = t[k];
|
||||
if (sv && typeof sv === 'object' && !Array.isArray(sv) && tv && typeof tv === 'object' && !Array.isArray(tv)) {
|
||||
r[k] = deepMerge(tv, sv);
|
||||
} else {
|
||||
r[k] = sv;
|
||||
}
|
||||
});
|
||||
return r;
|
||||
};
|
||||
updateData.deliver_config = deepMerge(original, deliver_config);
|
||||
if (deliver_config.auto_deliver !== undefined) updateData.auto_deliver = deliver_config.auto_deliver ? 1 : 0;
|
||||
else if (deliver_config.auto_delivery !== undefined) updateData.auto_deliver = deliver_config.auto_delivery ? 1 : 0;
|
||||
}
|
||||
if (chat_strategy !== undefined) {
|
||||
updateData.chat_strategy = chat_strategy;
|
||||
if (chat_strategy.auto_chat !== undefined) updateData.auto_chat = chat_strategy.auto_chat ? 1 : 0;
|
||||
}
|
||||
if (active_actions !== undefined) {
|
||||
updateData.active_actions = active_actions;
|
||||
if (active_actions.auto_active !== undefined) updateData.auto_active = active_actions.auto_active ? 1 : 0;
|
||||
}
|
||||
if (deliver_tab_label !== undefined || job_listings !== undefined) {
|
||||
const platform = user.platform_type || 'boss';
|
||||
const account_id = user.account_id != null ? String(user.account_id) : (user.id != null ? String(user.id) : '');
|
||||
let resume = await resume_info.findOne({
|
||||
where: { sn_code, platform },
|
||||
order: [['last_modify_time', 'DESC']]
|
||||
});
|
||||
const resumePayload = {};
|
||||
if (deliver_tab_label !== undefined) resumePayload.deliver_tab_label = deliver_tab_label;
|
||||
if (job_listings !== undefined) resumePayload.job_listings = Array.isArray(job_listings) ? job_listings : [];
|
||||
if (resume) {
|
||||
await resume_info.update(resumePayload, { where: { id: resume.id } });
|
||||
} else {
|
||||
await resume_info.create({
|
||||
sn_code,
|
||||
account_id: account_id || '',
|
||||
platform,
|
||||
resumeId: '',
|
||||
deliver_tab_label: resumePayload.deliver_tab_label != null ? resumePayload.deliver_tab_label : '',
|
||||
job_listings: resumePayload.job_listings || [],
|
||||
isActive: true
|
||||
});
|
||||
}
|
||||
|
||||
if (deliver_tab_label !== undefined) {
|
||||
const keywordSync = (deliver_tab_label != null ? String(deliver_tab_label) : '')
|
||||
.trim()
|
||||
.slice(0, 50);
|
||||
await pla_account.update({ keyword: keywordSync }, { where: { id: user.id } });
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length === 0 && deliver_tab_label === undefined && job_listings === undefined) {
|
||||
return ctx.success({ message: '无更新' });
|
||||
}
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await pla_account.update(updateData, { where: { id: user.id } });
|
||||
}
|
||||
return ctx.success({ message: '配置保存成功' });
|
||||
} catch (error) {
|
||||
console.error('[保存账号配置失败]', error);
|
||||
return ctx.fail('保存账号配置失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,421 +0,0 @@
|
||||
// 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,10 +3,7 @@
|
||||
* 聚合所有 job 相关模块的方法,提供统一的对外接口
|
||||
*/
|
||||
|
||||
const jobManager = require('./jobManager');
|
||||
const resumeManager = require('./resumeManager');
|
||||
const chatManager = require('./chatManager');
|
||||
|
||||
const { jobManager, resumeManager, chatManager } = require('./managers');
|
||||
|
||||
const pack = (instance) => {
|
||||
const proto = Object.getPrototypeOf(instance);
|
||||
@@ -23,7 +20,6 @@ const pack = (instance) => {
|
||||
|
||||
/**
|
||||
* 便捷方法:直接导出常用方法
|
||||
* 使用下划线命名规范
|
||||
*/
|
||||
module.exports = {
|
||||
...pack(jobManager),
|
||||
|
||||
@@ -1,651 +0,0 @@
|
||||
# 职位过滤服务文档
|
||||
|
||||
## 概述
|
||||
|
||||
`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**: 初始版本,支持基础文本匹配和过滤功能
|
||||
- 支持从数据库动态获取职位类型配置
|
||||
- 支持自定义权重评分计算
|
||||
|
||||
406
api/middleware/job/managers/chatManager.js
Normal file
406
api/middleware/job/managers/chatManager.js
Normal file
@@ -0,0 +1,406 @@
|
||||
const ai_service = require('../../../services/ai_service');
|
||||
const db = require('../../dbProxy');
|
||||
|
||||
/**
|
||||
* 聊天管理模块
|
||||
* 负责沟通列表、沟通详情、发送消息等与设备端的 MQTT 指令对接
|
||||
*/
|
||||
class ChatManager {
|
||||
/**
|
||||
* 解析沟通列表返回值,统一为 { friendList, foldText, ... }
|
||||
* 只支持新的结构:
|
||||
* response.data = { success, apiData: [ { response: { code, zpData:{...} } } ] }
|
||||
* @private
|
||||
*/
|
||||
_parse_chat_list_response(response) {
|
||||
const outerData = response && response.data;
|
||||
if (!outerData || !Array.isArray(outerData.apiData) || outerData.apiData.length === 0) {
|
||||
return { friendList: [], foldText: '', filterEncryptIdList: [], filterBossIdList: [] };
|
||||
}
|
||||
|
||||
const firstApi = outerData.apiData[0] || {};
|
||||
const innerResp = firstApi.response || firstApi.data || null;
|
||||
const raw = innerResp && (innerResp.zpData != null ? innerResp.zpData : innerResp.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 }
|
||||
});
|
||||
|
||||
// 只认新结构:data.success === true
|
||||
const ok = !!response && response.data && response.data.success === true;
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 解析 get_chat_detail 设备端返回格式
|
||||
* 格式: { type, code, message, data: { success, apiData: { response: { zpData } }, getBossData: { response: { zpData } } } }
|
||||
* apiData.response.zpData = 消息列表 hasMore/messages/type/minMsgId
|
||||
* getBossData.response.zpData = 会话 data+job
|
||||
* @private
|
||||
*/
|
||||
_parse_chat_detail_response(response) {
|
||||
if (!response) return { variant: 'unknown', data: null, job: null, hasMore: false, messages: [] };
|
||||
|
||||
const d = response.data;
|
||||
const api_data = d && d.apiData;
|
||||
const get_boss_data = d && d.getBossData;
|
||||
const msg_zp = api_data && api_data.response && api_data.response.zpData;
|
||||
const boss_zp = get_boss_data && get_boss_data.response && get_boss_data.response.zpData;
|
||||
|
||||
if (msg_zp && Array.isArray(msg_zp.messages)) {
|
||||
return {
|
||||
variant: 'messages',
|
||||
hasMore: !!msg_zp.hasMore,
|
||||
messages: msg_zp.messages,
|
||||
type: msg_zp.type,
|
||||
minMsgId: msg_zp.minMsgId,
|
||||
data: (boss_zp && boss_zp.data) || null,
|
||||
job: (boss_zp && boss_zp.job) || null
|
||||
};
|
||||
}
|
||||
if (boss_zp && (boss_zp.data != null || boss_zp.job != null)) {
|
||||
return {
|
||||
variant: 'session',
|
||||
data: boss_zp.data || null,
|
||||
job: boss_zp.job || null,
|
||||
hasMore: false,
|
||||
messages: [],
|
||||
type: null,
|
||||
minMsgId: null
|
||||
};
|
||||
}
|
||||
return { variant: 'unknown', data: null, job: null, hasMore: false, messages: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析详情,统一返回 { variant, hasMore, minMsgId, messages, data, job }
|
||||
* 入参可为设备端完整返回(data.apiData/data.getBossData)或已解析对象(直接返回)
|
||||
*/
|
||||
parseDetailResponse(apiResponse) {
|
||||
if (apiResponse && (apiResponse.variant === 'messages' || apiResponse.variant === 'session' || apiResponse.variant === 'unknown')) {
|
||||
return apiResponse;
|
||||
}
|
||||
return this._parse_chat_detail_response(apiResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取沟通详情(会话信息或聊天消息列表)
|
||||
* 返回值: { 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;
|
||||
const friendId = rest.friendId != null ? Number(rest.friendId) : NaN;
|
||||
if (!Number.isFinite(friendId) || friendId <= 0) {
|
||||
throw new Error('缺少必要参数:friendId(需为有效正数)');
|
||||
}
|
||||
console.log(`[聊天管理] 开始获取设备 ${sn_code} 的沟通详情`, { friendId });
|
||||
|
||||
const response = await mqttClient.publishAndWait(sn_code, {
|
||||
platform,
|
||||
action: 'get_chat_detail',
|
||||
data: { ...rest, friendId }
|
||||
});
|
||||
|
||||
const ok = response && (response.code === 200 || response.code === 0);
|
||||
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;
|
||||
}
|
||||
|
||||
/** 是否为系统/模板消息(竞争者PK、拒绝模板、系统卡片等),不参与回复判断 */
|
||||
_isSystemMessage(msg) {
|
||||
const body = msg.body || {};
|
||||
if (msg.bizType === 317 || msg.bizType === 21050003) return true;
|
||||
if (msg.type === 4) return true;
|
||||
if (body.type === 16) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 统一 uid 为可比较的字符串(支持 number 或 { low, high }) */
|
||||
_normalizeUid(uid) {
|
||||
if (uid == null) return null;
|
||||
if (typeof uid === 'number' || typeof uid === 'string') return String(uid);
|
||||
if (typeof uid === 'object' && typeof uid.low === 'number') return String(uid.low);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 过滤出 HR 发的、非系统、可回复的消息列表(已排除自己发的) */
|
||||
_filterHrReplyableMessages(messages, geek_uid) {
|
||||
if (!geek_uid || !Array.isArray(messages)) return [];
|
||||
const geekStr = this._normalizeUid(geek_uid);
|
||||
if (!geekStr) return [];
|
||||
const list = messages.filter(msg => {
|
||||
if (!msg.from) return false;
|
||||
const fromStr = this._normalizeUid(msg.from.uid);
|
||||
if (fromStr === geekStr) return false; // 自己发的,排除
|
||||
if (this._isSystemMessage(msg)) return false;
|
||||
return true;
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
/** AI 回复后写入 chat_reply_intent_log,options 含 sn_code/platform/friendId/encryptFriendId,securityId 为 HR 消息唯一 id */
|
||||
_saveReplyIntentLog(options, hr_message_text, jobInfo, action, reply_content, replied, reason, securityId) {
|
||||
if (!options || options.sn_code == null) return;
|
||||
try {
|
||||
const model = db.getModel('chat_reply_intent_log');
|
||||
model.create({
|
||||
sn_code: options.sn_code || '',
|
||||
platform: options.platform || 'boss',
|
||||
friendId: options.friendId ?? null,
|
||||
encrypt_friend_id: options.encryptFriendId || '',
|
||||
security_id: securityId || null,
|
||||
hr_message_text: hr_message_text || null,
|
||||
action: action || '',
|
||||
reply_content: reply_content || null,
|
||||
replied: !!replied,
|
||||
reason: reason || null,
|
||||
job_name: (jobInfo && (jobInfo.jobName || jobInfo.title)) || null,
|
||||
create_time: new Date()
|
||||
}).catch(e => console.warn('[聊天管理] 写入 chat_reply_intent_log 失败:', e.message));
|
||||
} catch (e) {
|
||||
console.warn('[聊天管理] 写入 chat_reply_intent_log 失败:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据沟通详情判断是否需回复,并用 AI 判断意图(文字回复 / 发简历)及生成内容
|
||||
* @param {object} detail - 沟通详情,含 variant、messages、job 等
|
||||
* @param {object} options - 可选,落库用 { sn_code, platform, friendId, encryptFriendId }
|
||||
* @returns {Promise<object>} { replied, action?, reply_content?, hr_message_text?, reason? }
|
||||
*/
|
||||
async getReplyContentFromDetail(detail, options) {
|
||||
if (!detail || detail.variant !== 'messages' || !Array.isArray(detail.messages) || detail.messages.length === 0) {
|
||||
return { replied: false, reason: '无可用消息' };
|
||||
}
|
||||
|
||||
const messages = detail.messages;
|
||||
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 lastRaw = messages[messages.length - 1];
|
||||
if (lastRaw && lastRaw.from && lastRaw.to) {
|
||||
hr_uid = hr_uid || lastRaw.from.uid;
|
||||
geek_uid = geek_uid || lastRaw.to.uid;
|
||||
}
|
||||
|
||||
const jobInfo = detail.job || {};
|
||||
|
||||
// 用 messages 判定整条对话最后一条是否来自 HR,只有最后一条是 HR 才需要回复
|
||||
const last_in_messages = messages[messages.length - 1];
|
||||
const last_from_uid = last_in_messages && last_in_messages.from ? this._normalizeUid(last_in_messages.from.uid) : null;
|
||||
const hr_uid_str = this._normalizeUid(hr_uid);
|
||||
const is_last_from_hr = hr_uid_str && last_from_uid === hr_uid_str && !this._isSystemMessage(last_in_messages);
|
||||
if (!is_last_from_hr) {
|
||||
this._saveReplyIntentLog(options, '', jobInfo, '', '', false, '最后一条消息不是HR发的', null);
|
||||
return { replied: false, reason: '最后一条消息不是HR发的' };
|
||||
}
|
||||
|
||||
const hrList = this._filterHrReplyableMessages(messages, geek_uid);
|
||||
if (hrList.length === 0) {
|
||||
this._saveReplyIntentLog(options, '', jobInfo, '', '', false, '无HR可回复消息(已过滤系统与己方)', null);
|
||||
return { replied: false, reason: '无HR可回复消息(已过滤系统与己方)' };
|
||||
}
|
||||
|
||||
const last = hrList[hrList.length - 1];
|
||||
const body = last.body || {};
|
||||
const hr_message_text =
|
||||
(typeof body.text === 'string' && body.text) ||
|
||||
(typeof last.pushText === 'string' && last.pushText) ||
|
||||
'';
|
||||
const security_id = last.securityId || last.security_id || '';
|
||||
|
||||
if (security_id && options) {
|
||||
try {
|
||||
const logModel = db.getModel('chat_reply_intent_log');
|
||||
const existing = await logModel.findOne({ where: { security_id } });
|
||||
if (existing) {
|
||||
// 已回复过的 HR 消息:不再重复发,避免每次扫描都发一条
|
||||
if (existing.replied) {
|
||||
return { replied: false, reason: '该条HR消息已回复过,跳过' };
|
||||
}
|
||||
// 之前记录为不回复:直接沿用,不再调 AI
|
||||
return {
|
||||
replied: false,
|
||||
reason: existing.reason || '已记录为不回复'
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[聊天管理] 查询 chat_reply_intent_log 失败:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
const { action, reply_content } = await ai_service.replyIntentAndContent({
|
||||
jobInfo,
|
||||
hrMessage: hr_message_text,
|
||||
previousMessages: hrList.slice(-5).map(m => (m.body && m.body.text) || m.pushText || '')
|
||||
});
|
||||
|
||||
if (action === 'no_reply') {
|
||||
this._saveReplyIntentLog(options, hr_message_text, jobInfo, action, reply_content, false, 'HR表示暂不匹配/无需回复', security_id || null);
|
||||
return { replied: false, reason: 'HR表示暂不匹配/无需回复' };
|
||||
}
|
||||
|
||||
const needContent = action === 'text';
|
||||
if (needContent && (!reply_content || !reply_content.trim())) {
|
||||
this._saveReplyIntentLog(options, hr_message_text, jobInfo, action, reply_content, false, 'AI 未生成有效回复文案', security_id || null);
|
||||
return { replied: false, reason: 'AI 未生成有效回复文案' };
|
||||
}
|
||||
|
||||
this._saveReplyIntentLog(options, hr_message_text, jobInfo, action, reply_content, true, null, security_id || null);
|
||||
return {
|
||||
replied: true,
|
||||
action: action || 'text',
|
||||
reply_content: reply_content || '',
|
||||
hr_message_text
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 AI 自动决定是否回复,并发送回复(内部会先获取详情,再调用 getReplyContentFromDetail,再发送)
|
||||
* 单条指令场景用;任务 auto_chat 已改为下发 get_chat_list / get_chat_detail / send_chat_message 多条指令。
|
||||
*
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} mqttClient - MQTT客户端
|
||||
* @param {object} params - 包含 friendId + 获取详情所需参数
|
||||
* @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 不能为空');
|
||||
|
||||
const parsed = await this.get_chat_detail(sn_code, mqttClient, { platform, ...detailParams });
|
||||
const decision = await this.getReplyContentFromDetail(parsed, {
|
||||
sn_code,
|
||||
platform,
|
||||
friendId,
|
||||
encryptFriendId: detailParams.encryptFriendId || ''
|
||||
});
|
||||
if (!decision.replied) return decision;
|
||||
|
||||
const action = decision.action || 'text';
|
||||
const content = decision.reply_content || '';
|
||||
const actionMessages = {
|
||||
send_resume: [{ type: 'send_resume', content }],
|
||||
exchange_wechat: [{ type: 'exchange_wechat', content }],
|
||||
exchange_phone: [{ type: 'exchange_phone', content }]
|
||||
};
|
||||
const messages = actionMessages[action] || [{ type: 'text', content }];
|
||||
await this.send_chat_message(sn_code, mqttClient, {
|
||||
friendId,
|
||||
messages,
|
||||
chatType: 'reply',
|
||||
platform
|
||||
});
|
||||
return {
|
||||
replied: true,
|
||||
reply_content: decision.reply_content,
|
||||
hr_message_text: decision.hr_message_text
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ChatManager();
|
||||
13
api/middleware/job/managers/index.js
Normal file
13
api/middleware/job/managers/index.js
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Managers 模块统一导出
|
||||
*/
|
||||
|
||||
const jobManager = require('./jobManager');
|
||||
const resumeManager = require('./resumeManager');
|
||||
const chatManager = require('./chatManager');
|
||||
|
||||
module.exports = {
|
||||
jobManager,
|
||||
resumeManager,
|
||||
chatManager
|
||||
};
|
||||
@@ -1,10 +1,12 @@
|
||||
// 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 { Op } = require('sequelize');
|
||||
const aiService = require('../../../services/ai_service');
|
||||
const { jobFilterService } = require('../services');
|
||||
const locationService = require('../../../services/locationService');
|
||||
const logs = require('../../logProxy');
|
||||
const db = require('../../dbProxy');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
|
||||
/**
|
||||
* 工作管理模块
|
||||
* 负责简历获取、分析、存储和匹配度计算
|
||||
@@ -13,6 +15,25 @@ class JobManager {
|
||||
constructor() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析设备 get_job_list / search_job_list 等返回的 response.data。
|
||||
* 实际形态为「多页 XHR 监听结果」数组,与客户端一致,例如:
|
||||
* `[{ url, method, status, data: { code: 0, message, zpData: { hasMore, jobList: [...] } } }, ...]`
|
||||
* pageCount=3 时通常 3 条(每页一次 list.json),本方法将所有页的 jobList 合并为一维数组。
|
||||
*/
|
||||
_jobListFromRecommendMonitorData(responseData) {
|
||||
if (!Array.isArray(responseData)) return [];
|
||||
const jobs = [];
|
||||
for (const item of responseData) {
|
||||
const inner = item?.data;
|
||||
if (!inner || typeof inner !== 'object') continue;
|
||||
if (inner.code !== undefined && inner.code !== 0) continue;
|
||||
const list = inner.zpData?.jobList;
|
||||
if (Array.isArray(list)) jobs.push(...list);
|
||||
}
|
||||
return jobs;
|
||||
}
|
||||
|
||||
// 启动客户端那个平台 用户信息,心跳机制
|
||||
async set_user_info(sn_code, mqttClient, user_info) {
|
||||
const response = await mqttClient.publishAndWait(sn_code, {
|
||||
@@ -144,21 +165,320 @@ 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('多条件搜索职位失败');
|
||||
}
|
||||
|
||||
const jobs = this._jobListFromRecommendMonitorData(response.data);
|
||||
|
||||
console.log(`[工作管理] 成功获取岗位数据,共 ${jobs.length} 个岗位`);
|
||||
|
||||
// 保存职位到数据库
|
||||
|
||||
await this.saveJobsToDatabase(sn_code, platform, keyword, jobs);
|
||||
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
// 未通过规则/评分的待投递记录标记为 filtered,避免长期 pending
|
||||
const passedIds = new Set(matchedJobs.map((j) => j.id).filter((id) => id != null));
|
||||
const notPassedIds = searchedJobs
|
||||
.map((row) => (row.toJSON ? row.toJSON() : row))
|
||||
.map((j) => j.id)
|
||||
.filter((id) => id != null && !passedIds.has(id));
|
||||
if (notPassedIds.length > 0) {
|
||||
try {
|
||||
await job_postings.update(
|
||||
{ applyStatus: 'filtered' },
|
||||
{
|
||||
where: {
|
||||
id: { [Op.in]: notPassedIds },
|
||||
sn_code,
|
||||
platform,
|
||||
applyStatus: 'pending'
|
||||
}
|
||||
}
|
||||
);
|
||||
console.log(`[工作管理] 搜索并投递:不符合条件已标记 filtered ${notPassedIds.length} 条`);
|
||||
} catch (e) {
|
||||
console.warn('[工作管理] 标记 filtered 失败:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 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} 个`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取期望 tab 列表(投递用标签,如 推荐、前端开发工程师)
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} mqttClient - MQTT客户端
|
||||
* @param {object} params - 参数 { platform }
|
||||
* @returns {Promise<Array<{ index: number, text: string }>>} tab 列表
|
||||
*/
|
||||
async get_job_listings(sn_code, mqttClient, params = {}) {
|
||||
const { platform = 'boss' } = params;
|
||||
const response = await mqttClient.publishAndWait(sn_code, {
|
||||
platform,
|
||||
action: 'get_job_listings',
|
||||
data: {}
|
||||
});
|
||||
if (!response || response.code !== 200) {
|
||||
console.error(`[工作管理] 获取 job_listings 失败:`, response);
|
||||
throw new Error(response?.message || '获取 job_listings 失败');
|
||||
}
|
||||
const list = Array.isArray(response.data) ? response.data : [];
|
||||
console.log(`[工作管理] 获取 job_listings 成功,共 ${list.length} 个 tab`);
|
||||
|
||||
try {
|
||||
const jobTypeAiSyncService = require('../../../services/job_type_ai_sync_service');
|
||||
await jobTypeAiSyncService.maybeSyncAfterListings(sn_code, list, platform);
|
||||
} catch (syncErr) {
|
||||
console.warn('[工作管理] job_types AI 同步失败:', syncErr.message);
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取岗位列表(与客户端/Electron 约定一致)
|
||||
* @param {string} sn_code - 设备 SN
|
||||
* @param {object} mqttClient - MQTT 客户端
|
||||
* @param {object} params - { platform?, pageCount?, tabLabel?, keyword? };keyword 仅服务端入库用,下发设备只有 pageCount + tabLabel
|
||||
* @returns {Promise<object>} 岗位列表
|
||||
*/
|
||||
async get_job_list(sn_code, mqttClient, params = {}) {
|
||||
const { keyword = '前端', platform = 'boss', pageCount = 3 } = params;
|
||||
console.log(`[工作管理] 开始获取设备 ${sn_code} 的岗位列表,关键词: ${keyword}`);
|
||||
const { platform = 'boss', pageCount = 3, tabLabel } = params;
|
||||
const keyword = String(params.keyword || '').trim() || String(tabLabel || '').trim();
|
||||
|
||||
const data = {
|
||||
pageCount,
|
||||
...(String(tabLabel || '').trim() ? { tabLabel: String(tabLabel).trim() } : {})
|
||||
};
|
||||
|
||||
console.log(`[工作管理] get_job_list ${sn_code} → 设备`, data, `入库 keyword=${keyword}`);
|
||||
|
||||
// 通过MQTT指令获取岗位列表
|
||||
const response = await mqttClient.publishAndWait(sn_code, {
|
||||
platform,
|
||||
action: "get_job_list",
|
||||
data: { keyword, pageCount }
|
||||
action: 'get_job_list',
|
||||
data
|
||||
});
|
||||
|
||||
if (!response || response.code !== 200) {
|
||||
@@ -166,43 +486,19 @@ class JobManager {
|
||||
throw new Error('获取岗位列表失败');
|
||||
}
|
||||
|
||||
// 处理职位列表数据:response.data 可能是数组(职位列表.json 格式)或单个对象
|
||||
let jobs = [];
|
||||
|
||||
if (Array.isArray(response.data)) {
|
||||
// 如果是数组格式(职位列表.json),遍历每个元素提取岗位数据
|
||||
for (const item of response.data) {
|
||||
if (item.data?.zpData?.jobList && Array.isArray(item.data.zpData.jobList)) {
|
||||
jobs = jobs.concat(item.data.zpData.jobList);
|
||||
}
|
||||
}
|
||||
console.log(`[工作管理] 从 ${response.data.length} 个响应中提取岗位数据`);
|
||||
} else if (response.data?.data?.zpData?.jobList) {
|
||||
// 如果是单个对象格式,从 data.zpData.jobList 获取
|
||||
jobs = response.data.data.zpData.jobList || [];
|
||||
} else if (response.data?.zpData?.jobList) {
|
||||
// 兼容旧格式:直接从 zpData.jobList 获取
|
||||
jobs = response.data.zpData.jobList || [];
|
||||
}
|
||||
|
||||
const jobs = this._jobListFromRecommendMonitorData(response.data);
|
||||
console.log(`[工作管理] 成功获取岗位数据,共 ${jobs.length} 个岗位`);
|
||||
|
||||
// 保存职位到数据库
|
||||
try {
|
||||
await this.saveJobsToDatabase(sn_code, platform, keyword, jobs);
|
||||
} catch (error) {
|
||||
console.error(`[工作管理] 保存职位到数据库失败:`, error);
|
||||
// 不影响主流程,继续返回数据
|
||||
}
|
||||
|
||||
const result = {
|
||||
jobs: jobs,
|
||||
keyword: keyword,
|
||||
platform: platform,
|
||||
await this.saveJobsToDatabase(sn_code, platform, keyword, jobs);
|
||||
|
||||
|
||||
return {
|
||||
jobs,
|
||||
keyword,
|
||||
platform,
|
||||
count: jobs.length
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -218,89 +514,93 @@ class JobManager {
|
||||
console.log(`[工作管理] 开始保存 ${jobs.length} 个职位到数据库`);
|
||||
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
// 构建职位信息对象
|
||||
const jobInfo = {
|
||||
sn_code,
|
||||
platform,
|
||||
keyword,
|
||||
|
||||
// Boss直聘字段映射
|
||||
encryptBossId: job.encryptBossId || '',
|
||||
jobId: job.encryptJobId || '',
|
||||
jobTitle: job.jobName || '',
|
||||
companyId: job.encryptBrandId || '',
|
||||
companyName: job.brandName || '',
|
||||
companySize: job.brandScaleName || '',
|
||||
companyIndustry: job.brandIndustry || '',
|
||||
salary: job.salaryDesc || '',
|
||||
// 构建职位信息对象
|
||||
const jobInfo = {
|
||||
sn_code,
|
||||
platform,
|
||||
keyword,
|
||||
|
||||
// 岗位要求(从 jobLabels 和 skills 提取)
|
||||
jobRequirements: JSON.stringify({
|
||||
experience: job.jobExperience || '',
|
||||
education: job.jobDegree || '',
|
||||
labels: job.jobLabels || [],
|
||||
skills: job.skills || []
|
||||
}),
|
||||
|
||||
// 工作地点
|
||||
location: [job.cityName, job.areaDistrict, job.businessDistrict]
|
||||
.filter(Boolean).join(' '),
|
||||
// Boss直聘字段映射
|
||||
encryptBossId: job.encryptBossId || '',
|
||||
jobId: job.encryptJobId || '',
|
||||
jobTitle: job.jobName || '',
|
||||
companyId: job.encryptBrandId || '',
|
||||
companyName: job.brandName || '',
|
||||
companySize: job.brandScaleName || '',
|
||||
companyIndustry: job.brandIndustry || '',
|
||||
salary: job.salaryDesc || '',
|
||||
|
||||
// 岗位要求(从 jobLabels 和 skills 提取)
|
||||
jobRequirements: JSON.stringify({
|
||||
experience: job.jobExperience || '',
|
||||
education: job.jobDegree || '',
|
||||
labels: job.jobLabels || [],
|
||||
skills: job.skills || []
|
||||
}),
|
||||
|
||||
// 原始数据
|
||||
originalData: JSON.stringify(job),
|
||||
// 工作地点
|
||||
location: [job.cityName, job.areaDistrict, job.businessDistrict]
|
||||
.filter(Boolean).join(' '),
|
||||
|
||||
// 默认状态
|
||||
applyStatus: 'pending',
|
||||
chatStatus: 'none'
|
||||
};
|
||||
experience: job.jobExperience || '',
|
||||
education: job.jobDegree || '',
|
||||
|
||||
// 调用位置服务解析 location + companyName 获取坐标
|
||||
if (jobInfo.location && jobInfo.companyName) {
|
||||
const addressToParse = `${jobInfo.location.replaceAll(' ', '')}${jobInfo.companyName}`;
|
||||
// 原始数据
|
||||
originalData: JSON.stringify(job),
|
||||
|
||||
// 默认状态
|
||||
applyStatus: 'pending',
|
||||
chatStatus: 'none'
|
||||
};
|
||||
|
||||
// 调用位置服务解析 location + companyName 获取坐标
|
||||
if (jobInfo.location && jobInfo.companyName) {
|
||||
const addressToParse = `${jobInfo.location.replaceAll(' ', '')}${jobInfo.companyName}`;
|
||||
|
||||
|
||||
// 等待 1秒
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
// 等待 1秒
|
||||
// await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
// const location = await locationService.getLocationByAddress(addressToParse).catch(error => {
|
||||
// console.error(`[工作管理] 获取位置失败:`, error);
|
||||
// });
|
||||
|
||||
const location = await locationService.getLocationByAddress(addressToParse).catch(error => {
|
||||
console.error(`[工作管理] 获取位置失败:`, error);
|
||||
});
|
||||
// if (location) {
|
||||
// jobInfo.latitude = String(location.lat);
|
||||
// jobInfo.longitude = String(location.lng);
|
||||
// }
|
||||
}
|
||||
|
||||
if (location) {
|
||||
jobInfo.latitude = String(location.lat);
|
||||
jobInfo.longitude = String(location.lng);
|
||||
// 创建新职位 重复投递时间 从 pla_account 中获取(pla_account 列为 platform_type,不是 platform)
|
||||
const pla_account = db.getModel('pla_account');
|
||||
const account = await pla_account.findOne({
|
||||
where: {
|
||||
sn_code: sn_code,
|
||||
platform_type: platform
|
||||
}
|
||||
});
|
||||
|
||||
let repeatDeliverDays = 30;
|
||||
if (account) {
|
||||
let dc = account.deliver_config?.repeat_deliver_days||30;
|
||||
repeatDeliverDays = Number(dc);
|
||||
}
|
||||
|
||||
const existingJob = await job_postings.findOne({
|
||||
where: {
|
||||
jobId: jobInfo.jobId,
|
||||
sn_code: sn_code,
|
||||
last_modify_time: {
|
||||
[Op.gte]: new Date(Date.now() - repeatDeliverDays * 24 * 60 * 60 * 1000)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 检查是否已存在(根据 jobId 和 sn_code)
|
||||
const existingJob = await job_postings.findOne({
|
||||
where: {
|
||||
jobId: jobInfo.jobId,
|
||||
sn_code: sn_code
|
||||
}
|
||||
});
|
||||
|
||||
if (existingJob) {
|
||||
// 更新现有职位
|
||||
await job_postings.update(jobInfo, {
|
||||
where: {
|
||||
jobId: jobInfo.jobId,
|
||||
sn_code: sn_code
|
||||
}
|
||||
});
|
||||
console.log(`[工作管理] 职位已更新 - ${jobInfo.jobTitle} @ ${jobInfo.companyName}`);
|
||||
} else {
|
||||
// 创建新职位
|
||||
await job_postings.create(jobInfo);
|
||||
console.log(`[工作管理] 职位已创建 - ${jobInfo.jobTitle} @ ${jobInfo.companyName}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[工作管理] 保存职位失败:`, error, job);
|
||||
// 继续处理下一个职位
|
||||
if (existingJob) {
|
||||
await job_postings.update(jobInfo, { where: { id: existingJob.id } });
|
||||
} else {
|
||||
await job_postings.create(jobInfo);
|
||||
console.log(`[工作管理] 职位已创建 - ${jobInfo.jobTitle} @ ${jobInfo.companyName}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,10 +620,11 @@ 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 applyJob(sn_code, mqttClient, params = {}) {
|
||||
const { platform = 'boss', jobId, encryptBossId, securityId, brandName, jobTitle, companyName } = params;
|
||||
async deliver_resume(sn_code, mqttClient, params = {}) {
|
||||
const { platform = 'boss', jobId, encryptBossId, securityId, brandName, jobTitle, companyName, action = 'deliver_resume' } = params;
|
||||
|
||||
if (!jobId) {
|
||||
throw new Error('jobId 参数不能为空,请指定要投递的职位ID');
|
||||
@@ -365,9 +666,9 @@ class JobManager {
|
||||
};
|
||||
}
|
||||
|
||||
// 检查该公司是否在一个月内已投递过(避免连续投递同一公司)
|
||||
const oneMonthAgo = new Date();
|
||||
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
|
||||
// 检查该公司是否在 30 天内已投递过(超过 30 天可再投该公司其它岗位)
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
const Sequelize = require('sequelize');
|
||||
const recentCompanyApply = await apply_records.findOne({
|
||||
@@ -375,7 +676,7 @@ class JobManager {
|
||||
sn_code: sn_code,
|
||||
companyName: jobData.companyName,
|
||||
applyTime: {
|
||||
[Sequelize.Op.gte]: oneMonthAgo
|
||||
[Sequelize.Op.gte]: thirtyDaysAgo
|
||||
}
|
||||
},
|
||||
order: [['applyTime', 'DESC']]
|
||||
@@ -383,28 +684,28 @@ class JobManager {
|
||||
|
||||
if (recentCompanyApply) {
|
||||
const daysAgo = Math.floor((new Date() - new Date(recentCompanyApply.applyTime)) / (1000 * 60 * 60 * 24));
|
||||
console.log(`[工作管理] 跳过一个月内已投递的公司: ${jobData.companyName} (${daysAgo}天前投递过)`);
|
||||
console.log(`[工作管理] 跳过30天内已投递的公司: ${jobData.companyName} (${daysAgo}天前投递过)`);
|
||||
return {
|
||||
success: false,
|
||||
deliveredCount: 0,
|
||||
failedCount: 1,
|
||||
message: `该公司在${daysAgo}天前已投递过,一个月内不重复投递`,
|
||||
message: `该公司在${daysAgo}天前已投递过,30天内不重复投递`,
|
||||
deliveredJobs: [],
|
||||
failedJobs: [{
|
||||
jobId: jobData.jobId,
|
||||
jobTitle: jobData.jobTitle,
|
||||
companyName: jobData.companyName,
|
||||
error: `该公司在${daysAgo}天前已投递过,一个月内不重复投递`
|
||||
error: `该公司在${daysAgo}天前已投递过,30天内不重复投递`
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`[工作管理] 投递职位: ${jobData.jobTitle} @ ${jobData.companyName}`);
|
||||
|
||||
// 通过MQTT指令投递简历
|
||||
// 通过MQTT指令投递简历(支持自定义action)
|
||||
const response = await mqttClient.publishAndWait(sn_code, {
|
||||
platform,
|
||||
action: "deliver_resume",
|
||||
action: action, // 使用传入的action参数,默认为"deliver_resume"
|
||||
data: {
|
||||
encryptJobId: jobData.jobId,
|
||||
securityId: jobData.securityId || securityId || '',
|
||||
@@ -1,7 +1,7 @@
|
||||
const aiService = require('./aiService');
|
||||
const jobFilterService = require('./job_filter_service');
|
||||
const logs = require('../logProxy');
|
||||
const db = require('../dbProxy');
|
||||
const aiService = require('../../../services/ai_service');
|
||||
const { jobFilterService } = require('../services');
|
||||
const logs = require('../../logProxy');
|
||||
const db = require('../../dbProxy');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
/**
|
||||
9
api/middleware/job/services/index.js
Normal file
9
api/middleware/job/services/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Services 模块统一导出
|
||||
*/
|
||||
|
||||
const jobFilterService = require('./jobFilterService');
|
||||
|
||||
module.exports = {
|
||||
jobFilterService
|
||||
};
|
||||
@@ -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() {
|
||||
@@ -32,15 +32,16 @@ class JobFilterService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据职位类型ID获取技能关键词和排除关键词
|
||||
* 根据职位类型ID获取技能关键词、排除关键词、标题须含词
|
||||
* @param {number} jobTypeId - 职位类型ID
|
||||
* @returns {Promise<{commonSkills: Array, excludeKeywords: Array}>}
|
||||
* @returns {Promise<{commonSkills: Array, excludeKeywords: Array, titleIncludeKeywords: Array}>}
|
||||
*/
|
||||
async getJobTypeConfig(jobTypeId) {
|
||||
if (!jobTypeId) {
|
||||
return {
|
||||
commonSkills: this.defaultCommonSkills,
|
||||
excludeKeywords: this.defaultExcludeKeywords
|
||||
excludeKeywords: this.defaultExcludeKeywords,
|
||||
titleIncludeKeywords: []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -55,7 +56,8 @@ class JobFilterService {
|
||||
console.warn('[职位过滤服务] job_types 模型不存在,使用默认配置');
|
||||
return {
|
||||
commonSkills: this.defaultCommonSkills,
|
||||
excludeKeywords: this.defaultExcludeKeywords
|
||||
excludeKeywords: this.defaultExcludeKeywords,
|
||||
titleIncludeKeywords: []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -67,7 +69,8 @@ class JobFilterService {
|
||||
console.warn(`[职位过滤服务] 职位类型 ${jobTypeId} 不存在或已禁用,使用默认配置`);
|
||||
return {
|
||||
commonSkills: this.defaultCommonSkills,
|
||||
excludeKeywords: this.defaultExcludeKeywords
|
||||
excludeKeywords: this.defaultExcludeKeywords,
|
||||
titleIncludeKeywords: []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,6 +79,7 @@ class JobFilterService {
|
||||
// 解析 JSON 字段
|
||||
let commonSkills = this.defaultCommonSkills;
|
||||
let excludeKeywords = this.defaultExcludeKeywords;
|
||||
let titleIncludeKeywords = [];
|
||||
|
||||
if (jobTypeData.commonSkills) {
|
||||
try {
|
||||
@@ -103,9 +107,23 @@ class JobFilterService {
|
||||
}
|
||||
}
|
||||
|
||||
if (jobTypeData.titleIncludeKeywords) {
|
||||
try {
|
||||
const parsed = typeof jobTypeData.titleIncludeKeywords === 'string'
|
||||
? JSON.parse(jobTypeData.titleIncludeKeywords)
|
||||
: jobTypeData.titleIncludeKeywords;
|
||||
if (Array.isArray(parsed)) {
|
||||
titleIncludeKeywords = parsed.map((k) => String(k || '').trim()).filter(Boolean);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[职位过滤服务] 解析 titleIncludeKeywords 失败:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
const config = {
|
||||
commonSkills,
|
||||
excludeKeywords
|
||||
excludeKeywords,
|
||||
titleIncludeKeywords
|
||||
};
|
||||
|
||||
// 缓存配置(缓存5分钟)
|
||||
@@ -119,7 +137,8 @@ class JobFilterService {
|
||||
console.error(`[职位过滤服务] 获取职位类型配置失败:`, error);
|
||||
return {
|
||||
commonSkills: this.defaultCommonSkills,
|
||||
excludeKeywords: this.defaultExcludeKeywords
|
||||
excludeKeywords: this.defaultExcludeKeywords,
|
||||
titleIncludeKeywords: []
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -655,71 +674,6 @@ class JobFilterService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤职位列表(基于文本匹配)
|
||||
* @param {Array} jobs - 职位列表
|
||||
* @param {object} filterRules - 过滤规则
|
||||
* @param {object} resumeInfo - 简历信息(可选)
|
||||
* @param {number} jobTypeId - 职位类型ID(可选)
|
||||
* @param {object} options - 选项
|
||||
* @param {boolean} options.autoSave - 是否自动保存评分结果到数据库(默认false)
|
||||
* @returns {Promise<Array>} 过滤后的职位列表(带匹配分数)
|
||||
*/
|
||||
async filterJobs(jobs, filterRules = {}, resumeInfo = {}, jobTypeId = null, options = {}) {
|
||||
const {
|
||||
minScore = 60, // 最低匹配分数
|
||||
excludeOutsourcing = true, // 是否排除外包
|
||||
excludeKeywords = [] // 额外排除关键词
|
||||
} = filterRules;
|
||||
|
||||
const { autoSave = false } = options;
|
||||
|
||||
// 获取职位类型配置
|
||||
const { excludeKeywords: typeExcludeKeywords } = await this.getJobTypeConfig(jobTypeId);
|
||||
const allExcludeKeywords = [...typeExcludeKeywords, ...excludeKeywords];
|
||||
|
||||
const results = [];
|
||||
for (const job of jobs) {
|
||||
const jobData = job.toJSON ? job.toJSON() : job;
|
||||
|
||||
// 分析匹配度(如果 autoSave 为 true 且 job 有 id,则自动保存)
|
||||
const analysisOptions = autoSave && jobData.id ? {
|
||||
jobPostingId: jobData.id,
|
||||
autoSave: true
|
||||
} : {};
|
||||
|
||||
const analysis = await this.analyzeJobMatch(jobData, resumeInfo, jobTypeId, analysisOptions);
|
||||
|
||||
results.push({
|
||||
...jobData,
|
||||
matchScore: analysis.overallScore,
|
||||
matchAnalysis: analysis
|
||||
});
|
||||
}
|
||||
|
||||
return results
|
||||
.filter(job => {
|
||||
// 1. 最低分数过滤
|
||||
if (job.matchScore < minScore) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 外包过滤
|
||||
if (excludeOutsourcing && job.matchAnalysis.isOutsourcing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 排除关键词过滤
|
||||
const jobText = this.buildJobText(job);
|
||||
if (allExcludeKeywords.some(kw => jobText.includes(kw.toLowerCase()))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => b.matchScore - a.matchScore); // 按匹配分数降序排序
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据自定义权重配置计算职位评分
|
||||
* @param {Object} jobData - 职位数据
|
||||
7
api/middleware/job/utils/index.js
Normal file
7
api/middleware/job/utils/index.js
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Utils 模块统一导出
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
// 工具函数将在需要时添加
|
||||
};
|
||||
@@ -8,15 +8,45 @@ class MqttSyncClient {
|
||||
constructor(brokerUrl, options = {}) {
|
||||
this.client = mqtt.connect(brokerUrl, options)
|
||||
this.isConnected = false
|
||||
/** @type {string[]} 需在每次 connect(含重连)后向 Broker 幂等订阅的主题 */
|
||||
this._maintainedTopics = []
|
||||
/** 最近一次收到任意 `response` 主题消息的时间(用于超时日志关联) */
|
||||
this.lastResponseAt = null
|
||||
|
||||
// 使用 Map 结构优化消息监听器,按 topic 分组
|
||||
this.messageListeners = new Map(); // Map<topic, Set<listener>>
|
||||
this.globalListeners = new Set(); // 全局监听器(监听所有 topic)
|
||||
|
||||
const ts = () => new Date().toISOString()
|
||||
const markDisconnected = (reason) => {
|
||||
this.isConnected = false
|
||||
console.warn(`[MQTT] ${ts()} 连接不可用 reason=${reason}`)
|
||||
}
|
||||
|
||||
this.client.on('connect', () => {
|
||||
this.isConnected = true
|
||||
console.log(`[MQTT] ${ts()} 服务端已连接(含重连后的 connect)`)
|
||||
this._resubscribeMaintainedTopics()
|
||||
})
|
||||
|
||||
console.log('MQTT 服务端已连接')
|
||||
this.client.on('reconnect', () => {
|
||||
console.log(`[MQTT] ${ts()} 正在重连 Broker...`)
|
||||
})
|
||||
|
||||
this.client.on('offline', () => {
|
||||
markDisconnected('offline')
|
||||
})
|
||||
|
||||
this.client.on('disconnect', () => {
|
||||
markDisconnected('disconnect')
|
||||
})
|
||||
|
||||
this.client.on('close', () => {
|
||||
markDisconnected('close')
|
||||
})
|
||||
|
||||
this.client.on('end', () => {
|
||||
markDisconnected('end')
|
||||
})
|
||||
|
||||
this.client.on('message', (topic, message) => {
|
||||
@@ -29,12 +59,10 @@ 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');
|
||||
if (topic === 'response') {
|
||||
this.lastResponseAt = Date.now()
|
||||
}
|
||||
|
||||
// 优化:只通知相关 topic 的监听器,而不是所有监听器
|
||||
// 1. 触发该 topic 的专用监听器
|
||||
const topicListeners = this.messageListeners.get(topic);
|
||||
if (topicListeners && topicListeners.size > 0) {
|
||||
@@ -61,18 +89,52 @@ class MqttSyncClient {
|
||||
})
|
||||
|
||||
this.client.on('error', (err) => {
|
||||
console.warn('[MQTT] Error:', err.message)
|
||||
console.warn(`[MQTT] ${ts()} Error:`, err && err.message ? err.message : err)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 与 mqtt.js 原生 connected 对齐,供单例健康检查
|
||||
*/
|
||||
isBrokerConnected() {
|
||||
return !!(this.client && this.client.connected)
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册需在每次 connect 后向 Broker 重新声明订阅的主题(不重复注册消息监听器)
|
||||
* @param {string[]} topics
|
||||
*/
|
||||
setMaintainedTopics(topics) {
|
||||
this._maintainedTopics = Array.isArray(topics) ? [...topics] : []
|
||||
}
|
||||
|
||||
_resubscribeMaintainedTopics() {
|
||||
if (!this._maintainedTopics.length) return
|
||||
if (!this.client || !this.client.connected) return
|
||||
const ts = new Date().toISOString()
|
||||
for (const topic of this._maintainedTopics) {
|
||||
this.client.subscribe(topic, { qos: 0 }, (err, granted) => {
|
||||
if (err) {
|
||||
console.warn(`[MQTT] ${ts} ensureSubscriptions 订阅失败 topic=${topic}`, err.message || err)
|
||||
} else {
|
||||
console.log(`[MQTT] ${ts} ensureSubscriptions 已订阅 topic=${topic}`, granted)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
waitForConnect(timeout = 5000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.isConnected) return resolve()
|
||||
if (this.isBrokerConnected()) {
|
||||
this.isConnected = true
|
||||
return resolve()
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error('MQTT connect timeout'))
|
||||
}, timeout)
|
||||
const check = () => {
|
||||
if (this.isConnected) {
|
||||
if (this.isBrokerConnected()) {
|
||||
this.isConnected = true
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
} else {
|
||||
@@ -118,7 +180,6 @@ class MqttSyncClient {
|
||||
resolve(granted)
|
||||
}
|
||||
}
|
||||
1
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -148,7 +209,12 @@ class MqttSyncClient {
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this.removeMessageListener(onMessage);
|
||||
reject(new Error('Timeout waiting for response'));
|
||||
const last = this.lastResponseAt
|
||||
const extra = last
|
||||
? ` lastResponseAt=${new Date(last).toISOString()} brokerConnected=${this.isBrokerConnected()}`
|
||||
: ` brokerConnected=${this.isBrokerConnected()}`
|
||||
console.warn(`[MQTT] ${new Date().toISOString()} publishAndWait 超时 uuid=${uuid} topic=request_${sn_code}${extra}`)
|
||||
reject(new Error('Timeout waiting for response' + (last ? `; lastResponseAt=${new Date(last).toISOString()}` : '')));
|
||||
}, timeout);
|
||||
|
||||
const onMessage = (topic, message) => {
|
||||
@@ -247,6 +313,7 @@ class MqttSyncClient {
|
||||
}
|
||||
|
||||
end(force = false) {
|
||||
this.isConnected = false
|
||||
this.client.end(force)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const db = require('../dbProxy.js');
|
||||
const logProxy = require('../logProxy.js');
|
||||
const deviceManager = require('../schedule/deviceManager.js');
|
||||
const deviceManager = require('../schedule/core/deviceManager.js');
|
||||
const chatManager = require('../job/managers/chatManager');
|
||||
|
||||
/**
|
||||
* MQTT 消息分发器
|
||||
@@ -12,6 +13,8 @@ class MqttDispatcher {
|
||||
this.mqttClient = mqttClient;
|
||||
this.actionHandlers = new Map();
|
||||
this.subscribedTopics = new Set();
|
||||
// 去重防抖:记录最近处理过的 Boss 消息 securityId -> timestamp
|
||||
this.bossMessageDedupMap = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -212,8 +215,6 @@ 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);
|
||||
@@ -286,7 +287,6 @@ class MqttDispatcher {
|
||||
},
|
||||
{ where: { sn_code } }
|
||||
);
|
||||
console.log(`[MQTT心跳] 设备 ${sn_code} 状态已更新到数据库 - 在线: true, 登录: ${updateData.isLoggedIn || false}`);
|
||||
} catch (error) {
|
||||
console.error(`[MQTT心跳] 更新数据库状态失败:`, error);
|
||||
}
|
||||
@@ -303,6 +303,136 @@ class MqttDispatcher {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理来自 boss-automation-nodejs 的 Boss 聊天消息
|
||||
* @param {object|string} message - Boss 消息对象或 JSON 字符串
|
||||
*/
|
||||
async handleBossMessage(message) {
|
||||
try {
|
||||
let data = message;
|
||||
if (typeof message === 'string') {
|
||||
try {
|
||||
data = JSON.parse(message);
|
||||
} catch (e) {
|
||||
console.warn('[MQTT Boss 消息] JSON 解析失败,按原始字符串处理');
|
||||
}
|
||||
}
|
||||
|
||||
const sn_code = data && data.sn_code ? data.sn_code : null;
|
||||
const payload = data && data.payload ? data.payload : null;
|
||||
|
||||
if (!sn_code || !payload) {
|
||||
console.warn('[MQTT Boss 消息] sn_code 或 payload 缺失,忽略:', data);
|
||||
return;
|
||||
}
|
||||
|
||||
// 按你给的结构解析:取第一条消息的文本等关键信息
|
||||
const firstMsg = Array.isArray(payload.messages) && payload.messages.length > 0
|
||||
? payload.messages[0]
|
||||
: null;
|
||||
|
||||
const fromUidObj = firstMsg && firstMsg.from && firstMsg.from.uid;
|
||||
const toUidObj = firstMsg && firstMsg.to && firstMsg.to.uid;
|
||||
const text = firstMsg && firstMsg.body && typeof firstMsg.body.text === 'string'
|
||||
? firstMsg.body.text
|
||||
: null;
|
||||
|
||||
// 兼容 uid 为数字或 { low, high } 两种格式
|
||||
const toUidStr = (uid) => {
|
||||
if (uid == null) return null;
|
||||
if (typeof uid === 'number' && !Number.isNaN(uid)) return String(uid);
|
||||
if (typeof uid === 'object' && typeof uid.low === 'number') return String(uid.low);
|
||||
return null;
|
||||
};
|
||||
|
||||
const normalized = {
|
||||
sn_code,
|
||||
type: payload.type || null,
|
||||
version: payload.version || null,
|
||||
from_uid: toUidStr(fromUidObj),
|
||||
to_uid: toUidStr(toUidObj),
|
||||
text,
|
||||
raw: payload
|
||||
};
|
||||
|
||||
// 去重防抖:按 securityId(或 cmid)在一定时间窗口内只处理一次
|
||||
const securityId = firstMsg && firstMsg.securityId;
|
||||
const cmidObj = firstMsg && firstMsg.cmid;
|
||||
const cmid = cmidObj && typeof cmidObj.low === 'number' ? `${cmidObj.high}:${cmidObj.low}` : null;
|
||||
const dedupKey = securityId || cmid;
|
||||
const now = Date.now();
|
||||
const windowMs = 2 * 60 * 1000; // 2 分钟内视为重复
|
||||
|
||||
if (dedupKey) {
|
||||
const lastTs = this.bossMessageDedupMap.get(dedupKey);
|
||||
if (lastTs && now - lastTs < windowMs) {
|
||||
console.log('[MQTT Boss 消息] 检测到重复消息,跳过处理:', { sn_code, dedupKey });
|
||||
return;
|
||||
}
|
||||
this.bossMessageDedupMap.set(dedupKey, now);
|
||||
}
|
||||
|
||||
console.log('[MQTT Boss 消息] 解析结果:', {
|
||||
sn_code: normalized.sn_code,
|
||||
type: normalized.type,
|
||||
version: normalized.version,
|
||||
from_uid: normalized.from_uid,
|
||||
to_uid: normalized.to_uid,
|
||||
text: normalized.text
|
||||
});
|
||||
|
||||
// 落库到 chat_message 表(与自动沟通的落库格式保持一致)
|
||||
try {
|
||||
const chatMessageModel = db.getModel('chat_message');
|
||||
const platform = 'boss';
|
||||
const friendId = normalized.from_uid ? Number(normalized.from_uid) : 0;
|
||||
const mid =
|
||||
firstMsg && firstMsg.mid && typeof firstMsg.mid.low === 'number'
|
||||
? firstMsg.mid.low
|
||||
: 0;
|
||||
const base = {
|
||||
sn_code,
|
||||
platform,
|
||||
friendId,
|
||||
encryptFriendId: '',
|
||||
fetch_time: new Date()
|
||||
};
|
||||
if (friendId && chatMessageModel) {
|
||||
const existing = await chatMessageModel.findOne({
|
||||
where: { sn_code, platform, friendId, mid }
|
||||
});
|
||||
const row = { ...base, mid, message_data: firstMsg };
|
||||
if (existing) {
|
||||
await existing.update(row);
|
||||
} else {
|
||||
await chatMessageModel.create(row);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[MQTT Boss 消息] 写入 chat_message 失败:', e.message);
|
||||
}
|
||||
|
||||
// 调用现有 AI 自动回复流程(基于 get_chat_detail + getReplyContentFromDetail)
|
||||
try {
|
||||
const friendIdNum = normalized.from_uid != null ? Number(normalized.from_uid) : 0;
|
||||
const hasValidFriendId = friendIdNum > 0 && Number.isFinite(friendIdNum);
|
||||
if (hasValidFriendId && this.mqttClient) {
|
||||
const result = await chatManager.auto_reply_with_ai(sn_code, this.mqttClient, {
|
||||
friendId: friendIdNum,
|
||||
platform: 'boss'
|
||||
});
|
||||
console.log('[MQTT Boss 消息] AI 自动回复结果:', result);
|
||||
} else if (!hasValidFriendId && normalized.from_uid != null) {
|
||||
console.warn('[MQTT Boss 消息] 跳过 AI 回复:friendId 无效或为 0', { from_uid: normalized.from_uid, friendIdNum });
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[MQTT Boss 消息] AI 自动回复失败:', e.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MQTT Boss 消息] 处理 Boss 消息失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理响应消息
|
||||
* @param {object|string} message - 响应消息对象或JSON字符串
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
const MqttSyncClient = require('./mqttClient');
|
||||
const Framework = require('../../../framework/node-core-framework');
|
||||
const logs = require('../logProxy');
|
||||
const appConfig = require('../../../config/config.js');
|
||||
// action.js 已合并到 mqttDispatcher.js,不再需要单独引入
|
||||
|
||||
function buildMqttManagerConfig() {
|
||||
const mqttCfg = appConfig.mqtt || {};
|
||||
const brokerUrl = (mqttCfg.brokerUrl && String(mqttCfg.brokerUrl).trim())
|
||||
? mqttCfg.brokerUrl.trim()
|
||||
: `mqtt://${mqttCfg.host || '192.144.167.231'}:${mqttCfg.port != null ? mqttCfg.port : 1883}`;
|
||||
const options = {
|
||||
clientId: mqttCfg.clientId || `mqtt_server_${Math.random().toString(16).substr(2, 8)}`,
|
||||
clean: mqttCfg.clean !== false,
|
||||
connectTimeout: mqttCfg.connectTimeout != null ? mqttCfg.connectTimeout : 5000,
|
||||
reconnectPeriod: mqttCfg.reconnectPeriod != null ? mqttCfg.reconnectPeriod : 5000,
|
||||
keepalive: mqttCfg.keepalive != null ? mqttCfg.keepalive : 60
|
||||
};
|
||||
if (mqttCfg.username) {
|
||||
options.username = mqttCfg.username;
|
||||
}
|
||||
if (mqttCfg.password) {
|
||||
options.password = mqttCfg.password;
|
||||
}
|
||||
return { brokerUrl, options };
|
||||
}
|
||||
|
||||
/**
|
||||
* MQTT管理器 - 单例模式
|
||||
* 负责管理MQTT连接,确保全局只有一个MQTT客户端实例
|
||||
@@ -11,16 +33,7 @@ class MqttManager {
|
||||
constructor() {
|
||||
this.client = null;
|
||||
this.isInitialized = false;
|
||||
this.config = {
|
||||
brokerUrl: 'mqtt://192.144.167.231:1883', // MQTT Broker地址
|
||||
options: {
|
||||
clientId: `mqtt_server_${Math.random().toString(16).substr(2, 8)}`,
|
||||
clean: true,
|
||||
connectTimeout: 5000,
|
||||
reconnectPeriod: 5000, // 自动重连间隔
|
||||
keepalive: 10
|
||||
}
|
||||
};
|
||||
this.config = buildMqttManagerConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,8 +43,16 @@ class MqttManager {
|
||||
*/
|
||||
async getInstance(config = {}) {
|
||||
if (this.client && this.isInitialized) {
|
||||
console.log('[MQTT管理器] 返回已存在的MQTT客户端实例');
|
||||
return this.client;
|
||||
const brokerOk = typeof this.client.isBrokerConnected === 'function'
|
||||
? this.client.isBrokerConnected()
|
||||
: this.client.isConnected;
|
||||
if (!brokerOk) {
|
||||
console.warn('[MQTT管理器] 单例已初始化但 Broker 未连接,重置并重建');
|
||||
await this.reset();
|
||||
} else {
|
||||
console.log('[MQTT管理器] 返回已存在的MQTT客户端实例');
|
||||
return this.client;
|
||||
}
|
||||
}
|
||||
|
||||
// 合并配置
|
||||
@@ -91,7 +112,13 @@ class MqttManager {
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isReady() {
|
||||
return this.isInitialized && this.client && this.client.isConnected;
|
||||
if (!this.isInitialized || !this.client) {
|
||||
return false;
|
||||
}
|
||||
if (typeof this.client.isBrokerConnected === 'function') {
|
||||
return this.client.isBrokerConnected();
|
||||
}
|
||||
return !!this.client.isConnected;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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');
|
||||
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');
|
||||
|
||||
|
||||
/**
|
||||
@@ -84,10 +84,11 @@ class CommandManager {
|
||||
const start_time = new Date();
|
||||
let command_id = null;
|
||||
let command_record = null;
|
||||
let task = null;
|
||||
|
||||
try {
|
||||
// 1. 获取任务信息
|
||||
const task = await db.getModel('task_status').findByPk(task_id);
|
||||
task = await db.getModel('task_status').findByPk(task_id);
|
||||
if (!task) {
|
||||
throw new Error(`任务不存在: ${task_id}`);
|
||||
}
|
||||
@@ -129,7 +130,7 @@ class CommandManager {
|
||||
|
||||
// 4.5 推送指令开始执行状态
|
||||
try {
|
||||
const deviceWorkStatusNotifier = require('./deviceWorkStatusNotifier');
|
||||
const deviceWorkStatusNotifier = require('../notifiers/deviceWorkStatusNotifier');
|
||||
const taskQueue = require('./taskQueue');
|
||||
const summary = await taskQueue.getTaskStatusSummary(task.sn_code);
|
||||
await deviceWorkStatusNotifier.sendDeviceWorkStatus(task.sn_code, summary, {
|
||||
@@ -163,7 +164,7 @@ class CommandManager {
|
||||
|
||||
// 6.5 推送指令完成状态
|
||||
try {
|
||||
const deviceWorkStatusNotifier = require('./deviceWorkStatusNotifier');
|
||||
const deviceWorkStatusNotifier = require('../notifiers/deviceWorkStatusNotifier');
|
||||
const taskQueue = require('./taskQueue');
|
||||
const summary = await taskQueue.getTaskStatusSummary(task.sn_code);
|
||||
await deviceWorkStatusNotifier.sendDeviceWorkStatus(task.sn_code, summary);
|
||||
@@ -191,15 +192,16 @@ class CommandManager {
|
||||
start_time
|
||||
);
|
||||
|
||||
// 推送指令失败状态
|
||||
try {
|
||||
const deviceWorkStatusNotifier = require('./deviceWorkStatusNotifier');
|
||||
const taskQueue = require('./taskQueue');
|
||||
const summary = await taskQueue.getTaskStatusSummary(task.sn_code);
|
||||
await deviceWorkStatusNotifier.sendDeviceWorkStatus(task.sn_code, summary);
|
||||
} catch (pushError) {
|
||||
// 推送失败不影响错误处理
|
||||
console.warn(`[指令管理] 推送设备工作状态失败:`, pushError.message);
|
||||
// 推送指令失败状态(需有 task 或从 DB 取 sn_code)
|
||||
if (task && task.sn_code) {
|
||||
try {
|
||||
const deviceWorkStatusNotifier = require('../notifiers/deviceWorkStatusNotifier');
|
||||
const taskQueue = require('./taskQueue');
|
||||
const summary = await taskQueue.getTaskStatusSummary(task.sn_code);
|
||||
await deviceWorkStatusNotifier.sendDeviceWorkStatus(task.sn_code, summary);
|
||||
} catch (pushError) {
|
||||
console.warn(`[指令管理] 推送设备工作状态失败:`, pushError.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,33 +212,18 @@ class CommandManager {
|
||||
|
||||
/**
|
||||
* 执行指令(带超时保护)
|
||||
* command_type 与 job 层方法名一致,统一使用下划线命名
|
||||
* @private
|
||||
*/
|
||||
async _execute_command_with_timeout(command_id, command_type, command_name, command_params, sn_code, mqttClient, start_time) {
|
||||
// 将驼峰命名转换为下划线命名
|
||||
const to_snake_case = (str) => {
|
||||
if (str.includes('_')) {
|
||||
return str;
|
||||
}
|
||||
return str.replace(/([A-Z])/g, '_$1').toLowerCase().replace(/^_/, '');
|
||||
};
|
||||
const timeout = ScheduleConfig.taskTimeouts[command_type] || 5 * 60 * 1000;
|
||||
|
||||
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 () => {
|
||||
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} (尝试的方法名: ${method_name})`);
|
||||
const fn = jobManager[command_type];
|
||||
if (!fn) {
|
||||
throw new Error(`未知的指令类型: ${command_type}, jobManager 中不存在对应方法`);
|
||||
}
|
||||
return await fn(sn_code, mqttClient, command_params);
|
||||
})();
|
||||
|
||||
// 使用超时机制包装
|
||||
@@ -1,8 +1,8 @@
|
||||
const dayjs = require('dayjs');
|
||||
const Sequelize = require('sequelize');
|
||||
const db = require('../dbProxy');
|
||||
const config = require('./config');
|
||||
const utils = require('./utils');
|
||||
const db = require('../../dbProxy');
|
||||
const config = require('../infrastructure/config');
|
||||
const utils = require('../utils/scheduleUtils');
|
||||
|
||||
/**
|
||||
* 设备管理器(简化版)
|
||||
@@ -77,7 +77,6 @@ class DeviceManager {
|
||||
// 更新登录状态
|
||||
if (heartbeatData.isLoggedIn !== undefined) {
|
||||
device.isLoggedIn = heartbeatData.isLoggedIn;
|
||||
console.log(`[设备管理器] 设备 ${sn_code} 登录状态更新 - isLoggedIn: ${device.isLoggedIn}`);
|
||||
}
|
||||
}
|
||||
|
||||
16
api/middleware/schedule/core/index.js
Normal file
16
api/middleware/schedule/core/index.js
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Core 模块导出
|
||||
* 统一导出核心模块,简化引用路径
|
||||
*/
|
||||
|
||||
const deviceManager = require('./deviceManager');
|
||||
const taskQueue = require('./taskQueue');
|
||||
const command = require('./command');
|
||||
const scheduledJobs = require('./scheduledJobs');
|
||||
|
||||
module.exports = {
|
||||
deviceManager,
|
||||
taskQueue,
|
||||
command,
|
||||
scheduledJobs
|
||||
};
|
||||
701
api/middleware/schedule/core/scheduledJobs.js
Normal file
701
api/middleware/schedule/core/scheduledJobs.js
Normal file
@@ -0,0 +1,701 @@
|
||||
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 = [];
|
||||
|
||||
// 业务任务防重入标记(按任务类型存)
|
||||
this._runningFlags = {
|
||||
auto_search: false,
|
||||
auto_deliver: false,
|
||||
auto_chat: false,
|
||||
auto_active: false,
|
||||
job_type_listings_ai: false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动所有定时任务
|
||||
*/
|
||||
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 */1 * * * *', () => {
|
||||
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小时)');
|
||||
|
||||
// 5. 每日拉取 get_job_listings 并用 AI 更新 job_types(description / excludeKeywords / commonSkills / titleIncludeKeywords)
|
||||
const jobTypeListingsAiJob = node_schedule.scheduleJob(config.schedules.jobTypeListingsAi || '0 0 4 * * *', () => {
|
||||
this.runDailyJobTypeListingsAiSync().catch((err) => {
|
||||
console.error('[定时任务] 每日 job_types AI 同步失败:', err);
|
||||
});
|
||||
});
|
||||
this.jobs.push(jobTypeListingsAiJob);
|
||||
console.log('[定时任务] ✓ 已启动每日 job_types AI 同步 (每天 04:00)');
|
||||
|
||||
// 立即执行一次业务任务(可选)
|
||||
setTimeout(() => {
|
||||
console.log('[定时任务] 立即执行一次初始化任务...');
|
||||
this.runAutoDeliverTask();
|
||||
this.runAutoChatTask();
|
||||
this.runDailyJobTypeListingsAiSync().catch((err) => {
|
||||
console.error('[定时任务] 启动时 job_types AI 同步失败:', err);
|
||||
});
|
||||
}, 10000); // 延迟10秒,等待系统初始化完成和设备心跳
|
||||
|
||||
console.log('[定时任务] 所有定时任务启动完成!');
|
||||
}
|
||||
|
||||
// ==================== 业务任务执行方法(使用新的任务模块) ====================
|
||||
|
||||
/**
|
||||
* 运行自动搜索任务
|
||||
* 为所有启用自动搜索的账号添加搜索任务
|
||||
*/
|
||||
async runAutoSearchTask() {
|
||||
const key = 'auto_search';
|
||||
if (this._runningFlags[key]) {
|
||||
console.log('[自动搜索调度] 上一次执行尚未完成,本次跳过');
|
||||
return;
|
||||
}
|
||||
|
||||
this._runningFlags[key] = true;
|
||||
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);
|
||||
} finally {
|
||||
this._runningFlags[key] = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行自动投递任务
|
||||
* 为所有启用自动投递的账号添加投递任务
|
||||
*/
|
||||
async runAutoDeliverTask() {
|
||||
const key = 'auto_deliver';
|
||||
if (this._runningFlags[key]) {
|
||||
console.log('[自动投递调度] 上一次执行尚未完成,本次跳过');
|
||||
return;
|
||||
}
|
||||
|
||||
this._runningFlags[key] = true;
|
||||
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);
|
||||
} finally {
|
||||
this._runningFlags[key] = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行自动沟通任务
|
||||
* 为所有启用自动沟通的账号添加沟通任务
|
||||
*/
|
||||
async runAutoChatTask() {
|
||||
const key = 'auto_chat';
|
||||
if (this._runningFlags[key]) {
|
||||
console.log('[自动沟通调度] 上一次执行尚未完成,本次跳过');
|
||||
return;
|
||||
}
|
||||
|
||||
this._runningFlags[key] = true;
|
||||
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);
|
||||
} finally {
|
||||
this._runningFlags[key] = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行自动活跃任务
|
||||
* 为所有启用自动活跃的账号添加活跃任务
|
||||
*/
|
||||
async runAutoActiveTask() {
|
||||
const key = 'auto_active';
|
||||
if (this._runningFlags[key]) {
|
||||
console.log('[自动活跃调度] 上一次执行尚未完成,本次跳过');
|
||||
return;
|
||||
}
|
||||
|
||||
this._runningFlags[key] = true;
|
||||
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);
|
||||
} finally {
|
||||
this._runningFlags[key] = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 每日一次:对已绑定 job_type_id 且设备在线的账号下发 get_job_listings,成功后在 jobManager 内触发 AI 更新 job_types
|
||||
*/
|
||||
async runDailyJobTypeListingsAiSync() {
|
||||
const key = 'job_type_listings_ai';
|
||||
if (this._runningFlags[key]) {
|
||||
console.log('[job_type_listings_ai] 上一次执行尚未完成,本次跳过');
|
||||
return;
|
||||
}
|
||||
this._runningFlags[key] = true;
|
||||
try {
|
||||
const Sequelize = require('sequelize');
|
||||
const { Op } = Sequelize;
|
||||
const scheduleManager = require('../index');
|
||||
const jobApi = require('../../job/index');
|
||||
const mqtt = scheduleManager.mqttClient;
|
||||
if (!mqtt) {
|
||||
console.warn('[job_type_listings_ai] MQTT 未初始化,跳过');
|
||||
return;
|
||||
}
|
||||
|
||||
const { pla_account } = db.models;
|
||||
const accounts = await pla_account.findAll({
|
||||
where: {
|
||||
is_delete: 0,
|
||||
is_enabled: 1,
|
||||
job_type_id: { [Op.ne]: null }
|
||||
},
|
||||
attributes: ['id', 'sn_code', 'job_type_id', 'platform_type']
|
||||
});
|
||||
|
||||
if (!accounts || accounts.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const offlineThreshold = 3 * 60 * 1000;
|
||||
let ok = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const acc of accounts) {
|
||||
const sn_code = acc.sn_code;
|
||||
const device = deviceManager.devices.get(sn_code);
|
||||
const lastHb = device && device.lastHeartbeat ? device.lastHeartbeat : 0;
|
||||
const isOnline = device && device.isOnline && now - lastHb < offlineThreshold;
|
||||
if (!isOnline) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const platform =
|
||||
acc.platform_type ||
|
||||
(typeof acc.getDataValue === 'function' && acc.getDataValue('platform_type')) ||
|
||||
'boss';
|
||||
|
||||
try {
|
||||
await jobApi.get_job_listings(sn_code, mqtt, { platform });
|
||||
ok++;
|
||||
} catch (err) {
|
||||
console.warn(`[job_type_listings_ai] 设备 ${sn_code} 失败:`, err.message);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
if (ok > 0 || skipped > 0) {
|
||||
console.log(`[job_type_listings_ai] 完成: 成功 ${ok},跳过/失败 ${skipped},共 ${accounts.length} 个账号`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[job_type_listings_ai] 执行失败:', error);
|
||||
} finally {
|
||||
this._runningFlags[key] = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取启用指定功能的账号列表
|
||||
* @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,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('./PriorityQueue');
|
||||
const ErrorHandler = require('./ErrorHandler');
|
||||
const PriorityQueue = require('../infrastructure/PriorityQueue');
|
||||
const ErrorHandler = require('../infrastructure/ErrorHandler');
|
||||
const deviceManager = require('./deviceManager');
|
||||
const ScheduleUtils = require('./utils');
|
||||
const ScheduleConfig = require('./config');
|
||||
const deviceWorkStatusNotifier = require('./deviceWorkStatusNotifier');
|
||||
const ScheduleUtils = require('../utils/scheduleUtils');
|
||||
const ScheduleConfig = require('../infrastructure/config');
|
||||
const deviceWorkStatusNotifier = require('../notifiers/deviceWorkStatusNotifier');
|
||||
|
||||
/**
|
||||
* 任务队列管理器(重构版)
|
||||
@@ -222,7 +222,6 @@ class TaskQueue {
|
||||
|
||||
// 移除 device_status 依赖,不再检查设备在线状态
|
||||
// 如果需要在线状态检查,可以从 deviceManager 获取
|
||||
const deviceManager = require('./deviceManager');
|
||||
const deviceStatus = deviceManager.getAllDevicesStatus();
|
||||
const onlineSnCodes = new Set(
|
||||
Object.entries(deviceStatus)
|
||||
@@ -230,24 +229,7 @@ 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;
|
||||
@@ -406,7 +388,7 @@ class TaskQueue {
|
||||
const authDays = accountData.authorization_days || 0;
|
||||
|
||||
// 使用工具函数计算剩余天数
|
||||
const { calculateRemainingDays } = require('../../utils/account_utils');
|
||||
const { calculateRemainingDays } = require('../../../utils/account_utils');
|
||||
const remaining_days = calculateRemainingDays(authDate, authDays);
|
||||
|
||||
// 如果没有授权信息或剩余天数 <= 0,不允许创建任务
|
||||
@@ -1065,13 +1047,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) {
|
||||
89
api/middleware/schedule/handlers/activeHandler.js
Normal file
89
api/middleware/schedule/handlers/activeHandler.js
Normal file
@@ -0,0 +1,89 @@
|
||||
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 actionNameMap = { view_jobs: '浏览职位', browse_jobs: '浏览职位', refresh_resume: '刷新简历', check_notifications: '查看通知' };
|
||||
const actions = activeStrategy.actions || ['view_jobs'];
|
||||
const activeCommands = actions.map(action => ({
|
||||
command_type: `active_${action}`,
|
||||
command_name: actionNameMap[action] || `活跃-${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;
|
||||
250
api/middleware/schedule/handlers/baseHandler.js
Normal file
250
api/middleware/schedule/handlers/baseHandler.js
Normal file
@@ -0,0 +1,250 @@
|
||||
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;
|
||||
234
api/middleware/schedule/handlers/chatHandler.js
Normal file
234
api/middleware/schedule/handlers/chatHandler.js
Normal file
@@ -0,0 +1,234 @@
|
||||
const BaseHandler = require('./baseHandler');
|
||||
const ConfigManager = require('../services/configManager');
|
||||
const command = require('../core/command');
|
||||
const config = require('../infrastructure/config');
|
||||
const chatManager = require('../../job/managers/chatManager');
|
||||
const db = require('../../dbProxy');
|
||||
|
||||
/**
|
||||
* 自动沟通处理器
|
||||
* 负责自动回复 HR 消息。auto_chat 是任务,其下按指令执行:获取列表 → 获取详情 →(若需回复)发送消息
|
||||
*/
|
||||
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 = taskParams.platform || 'boss';
|
||||
|
||||
console.log(`[自动沟通] 开始 - 设备: ${sn_code}`);
|
||||
|
||||
const accountConfig = await this.getAccountConfig(sn_code, ['platform_type', 'chat_strategy']);
|
||||
if (!accountConfig) {
|
||||
return { chatCount: 0, message: '未找到账户配置' };
|
||||
}
|
||||
|
||||
const chatStrategy = ConfigManager.parseChatStrategy(accountConfig.chat_strategy);
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
const platform_type = platform || accountConfig.platform_type || 'boss';
|
||||
const page_count = chatStrategy.page_count || 3;
|
||||
|
||||
// 1. 下发「获取列表」指令
|
||||
const list_command = {
|
||||
command_type: 'get_chat_list',
|
||||
command_name: '获取聊天列表',
|
||||
command_params: { platform: platform_type, pageCount: page_count },
|
||||
priority: config.getTaskPriority('auto_chat') || 6
|
||||
};
|
||||
const list_exec = await command.executeCommands(task.id, [list_command], this.mqttClient);
|
||||
const list_result = list_exec?.results?.[0]?.result;
|
||||
const friend_list = Array.isArray(list_result?.friendList) ? list_result.friendList : [];
|
||||
|
||||
if (friend_list.length === 0) {
|
||||
console.log(`[自动沟通] 完成 - 设备: ${sn_code},无会话`);
|
||||
return { chatCount: 0, message: '没有可沟通的会话', detail: { total_contacts: 0 } };
|
||||
}
|
||||
|
||||
this._syncFriendListToChatRecords(sn_code, platform_type, friend_list);
|
||||
|
||||
let replied_count = 0;
|
||||
const details = [];
|
||||
for (const friend of friend_list) {
|
||||
if (!friend.friendId) continue;
|
||||
const item = await this._processOneFriend(task, friend, sn_code, platform_type);
|
||||
details.push(item);
|
||||
if (item.replied) replied_count++;
|
||||
|
||||
}
|
||||
|
||||
console.log(`[自动沟通] 完成 - 设备: ${sn_code},会话 ${friend_list.length},回复 ${replied_count}`);
|
||||
|
||||
return {
|
||||
chatCount: replied_count,
|
||||
message: '自动沟通完成',
|
||||
detail: {
|
||||
total_contacts: friend_list.length,
|
||||
replied_count,
|
||||
details
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 将会话列表同步到 chat_records,内部 catch 仅打日志 */
|
||||
async _syncFriendListToChatRecords(sn_code, platform_type, friend_list) {
|
||||
try {
|
||||
const chatRecordsModel = db.getModel('chat_records');
|
||||
for (const friend of friend_list) {
|
||||
const friend_id = friend.friendId;
|
||||
if (friend_id == null) continue;
|
||||
const encryptId = friend.encryptFriendId || '';
|
||||
const existing = await chatRecordsModel.findOne({
|
||||
where: { sn_code, platform: platform_type, encryptFriendId: encryptId }
|
||||
});
|
||||
const baseData = {
|
||||
sn_code,
|
||||
platform: platform_type,
|
||||
friendId: friend_id,
|
||||
encryptFriendId: encryptId,
|
||||
name: friend.name || '',
|
||||
updateTime: friend.updateTime != null ? friend.updateTime : null,
|
||||
brandName: friend.brandName || '',
|
||||
jobName: friend.jobName || '',
|
||||
jobCity: friend.jobCity || '',
|
||||
positionName: friend.positionName || '',
|
||||
bossTitle: friend.bossTitle || '',
|
||||
friendSource: friend.friendSource != null ? friend.friendSource : 0,
|
||||
jobTypeDesc: friend.jobTypeDesc || '',
|
||||
waterLevel: friend.waterLevel != null ? friend.waterLevel : 0
|
||||
};
|
||||
if (existing) await existing.update(baseData);
|
||||
else await chatRecordsModel.create(baseData);
|
||||
}
|
||||
console.log(`[自动沟通] 已同步 ${friend_list.length} 条会话到 chat_records`);
|
||||
} catch (e) {
|
||||
console.warn('[自动沟通] 同步聊天会话列表到 chat_records 失败:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 落库 chat_message,入参为解析后格式 { variant, messages, data, job }
|
||||
* 有 messages 则每条一条记录;仅会话时 mid=0 存 { data, job }
|
||||
*/
|
||||
async _saveChatMessagesToDb(parsed, friend, sn_code, platform_type) {
|
||||
if (!parsed) return;
|
||||
const messages = Array.isArray(parsed.messages) ? parsed.messages : [];
|
||||
const has_session = parsed.data != null || parsed.job != null;
|
||||
if (messages.length === 0 && !has_session) return;
|
||||
|
||||
try {
|
||||
const chatMessageModel = db.getModel('chat_message');
|
||||
const friend_id = friend.friendId;
|
||||
const encrypt_id = friend.encryptFriendId || '';
|
||||
const fetch_time = new Date();
|
||||
const base = { sn_code, platform: platform_type, friendId: friend_id, encryptFriendId: encrypt_id, fetch_time };
|
||||
|
||||
if (messages.length > 0) {
|
||||
for (const msg of messages) {
|
||||
const mid = msg.mid != null ? msg.mid : 0;
|
||||
const existing = await chatMessageModel.findOne({
|
||||
where: { sn_code, platform: platform_type, friendId: friend_id, mid }
|
||||
});
|
||||
const row = { ...base, mid, message_data: msg };
|
||||
if (existing) await existing.update(row);
|
||||
else await chatMessageModel.create(row);
|
||||
}
|
||||
} else {
|
||||
const boss_payload = { data: parsed.data || null, job: parsed.job || null };
|
||||
const existing = await chatMessageModel.findOne({
|
||||
where: { sn_code, platform: platform_type, friendId: friend_id, mid: 0 }
|
||||
});
|
||||
const row = { ...base, mid: 0, message_data: boss_payload };
|
||||
if (existing) await existing.update(row);
|
||||
else await chatMessageModel.create(row);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[自动沟通] 写入 chat_message 失败:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理单个会话:获取详情 → 落库 → 判断是否回复 → 若需则发送消息
|
||||
* @returns {{ friendId: number, replied: boolean, reason: string|null }}
|
||||
*/
|
||||
async _processOneFriend(task, friend, sn_code, platform_type) {
|
||||
const friend_id = friend.friendId;
|
||||
try {
|
||||
const detail_command = {
|
||||
command_type: 'get_chat_detail',
|
||||
command_name: '获取聊天详情',
|
||||
command_params: { platform: platform_type, friendId: friend_id },
|
||||
priority: config.getTaskPriority('auto_chat') || 6
|
||||
};
|
||||
const detail_exec = await command.executeCommands(task.id, [detail_command], this.mqttClient);
|
||||
const result = detail_exec?.results?.[0]?.result;
|
||||
const parsed = chatManager.parseDetailResponse(result || {});
|
||||
|
||||
await this._saveChatMessagesToDb(parsed, friend, sn_code, platform_type);
|
||||
|
||||
const decision = await chatManager.getReplyContentFromDetail(parsed || {}, {
|
||||
sn_code,
|
||||
platform: platform_type,
|
||||
friendId: friend_id,
|
||||
encryptFriendId: friend.encryptFriendId || ''
|
||||
});
|
||||
|
||||
if (decision.replied) {
|
||||
const action = decision.action || 'text';
|
||||
const content = decision.reply_content || '';
|
||||
const actionMessages = {
|
||||
send_resume: [{ type: 'send_resume', content }],
|
||||
exchange_wechat: [{ type: 'exchange_wechat', content }],
|
||||
exchange_phone: [{ type: 'exchange_phone', content }]
|
||||
};
|
||||
const messages = actionMessages[action] || [{ type: 'text', content }];
|
||||
const actionNames = { send_resume: '发送简历', exchange_wechat: '换微信', exchange_phone: '换电话' };
|
||||
const send_command = {
|
||||
command_type: 'send_chat_message',
|
||||
command_name: actionNames[action] || '发送聊天消息',
|
||||
command_params: {
|
||||
platform: platform_type,
|
||||
friendId: friend_id,
|
||||
messages,
|
||||
chatType: 'reply'
|
||||
},
|
||||
priority: config.getTaskPriority('auto_chat') || 6
|
||||
};
|
||||
await command.executeCommands(task.id, [send_command], this.mqttClient);
|
||||
}
|
||||
|
||||
return {
|
||||
friendId: friend_id,
|
||||
replied: !!decision.replied,
|
||||
reason: decision.reason || null
|
||||
};
|
||||
} catch (err) {
|
||||
return { friendId: friend_id, replied: false, reason: err.message || '处理失败' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ChatHandler;
|
||||
454
api/middleware/schedule/handlers/deliverHandler.js
Normal file
454
api/middleware/schedule/handlers/deliverHandler.js
Normal file
@@ -0,0 +1,454 @@
|
||||
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');
|
||||
|
||||
/**
|
||||
* 自动投递处理器
|
||||
* 负责职位搜索、过滤、评分和自动投递
|
||||
*/
|
||||
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 } = 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. 获取职位类型配置(同时下发 get_job_listings 并保存到 resume_info.job_listings)
|
||||
const jobTypeConfig = await this.getJobTypeConfig(accountConfig.job_type_id, {
|
||||
sn_code,
|
||||
platform,
|
||||
taskId: task.id,
|
||||
mqttClient: this.mqttClient
|
||||
});
|
||||
|
||||
// 6. 下发 get_job_list(与前端一致:command 只带 pageCount + tabLabel,设备端不接收 keyword/job_type_id)
|
||||
const tabLabel = resume.deliver_tab_label || '';
|
||||
await this.getJobList(sn_code, platform, pageCount, task.id, tabLabel);
|
||||
|
||||
// 7. 从数据库获取待投递职位
|
||||
const pendingJobs = await this.getPendingJobs(sn_code, platform, actualMaxCount * 3);
|
||||
|
||||
if (!pendingJobs || pendingJobs.length === 0) {
|
||||
return {
|
||||
deliveredCount: 0,
|
||||
message: '没有待投递的职位'
|
||||
};
|
||||
}
|
||||
|
||||
// 8. 过滤配置仅来自职位类型 job_types(排除词 / 标题须含词等),不与账号投递配置、任务参数混用
|
||||
const filterConfig = this.mergeFilterConfig(jobTypeConfig);
|
||||
|
||||
// 9. 过滤已投递的公司(repeat_deliver_days 由投递配置给出,缺省 30,上限 365)
|
||||
const repeatDeliverDays = Math.min(365, Math.max(1, Number(deliverConfig.repeat_deliver_days) || 30));
|
||||
const recentCompanies = await this.getRecentDeliveredCompanies(sn_code, repeatDeliverDays);
|
||||
|
||||
// 10. 过滤 + 评分 + 按 60 分阈值筛(入口在 jobFilterEngine,便于阅读)
|
||||
const filteredJobs = await jobFilterEngine.filterAndScoreJobsForDeliver(
|
||||
pendingJobs,
|
||||
filterConfig,
|
||||
resume,
|
||||
accountConfig,
|
||||
jobTypeConfig,
|
||||
recentCompanies
|
||||
);
|
||||
|
||||
// 本轮未进入「可投递」列表的待投递记录,标记为已过滤,避免长期停留在 pending
|
||||
await this.markFilteredJobsNotPassed(pendingJobs, filteredJobs, sn_code, platform);
|
||||
|
||||
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: 'get_online_resume',
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取职位类型配置;若传入 options,先下发 get_job_listings 获取 tab 列表并写入 resume_info.job_listings
|
||||
* @param {number} jobTypeId - 职位类型 ID
|
||||
* @param {object} options - 可选 { sn_code, platform, taskId, mqttClient },用于下发 get_job_listings 并保存
|
||||
*/
|
||||
async getJobTypeConfig(jobTypeId, options = {}) {
|
||||
const { sn_code, platform = 'boss', taskId, mqttClient } = options;
|
||||
if (sn_code && taskId && mqttClient) {
|
||||
try {
|
||||
const getListingsCommand = {
|
||||
command_type: 'get_job_listings',
|
||||
command_name: '获取投递标签列表',
|
||||
command_params: JSON.stringify({ sn_code, platform }),
|
||||
priority: config.getTaskPriority('auto_deliver') || 7
|
||||
};
|
||||
const ret = await command.executeCommands(taskId, [getListingsCommand], mqttClient);
|
||||
const firstResult = ret.results && ret.results[0];
|
||||
const list = firstResult && firstResult.result && Array.isArray(firstResult.result)
|
||||
? firstResult.result
|
||||
: [];
|
||||
const job_listings = list.map((item) => (item && item.text != null ? String(item.text).trim() : '')).filter(Boolean);
|
||||
if (job_listings.length > 0) {
|
||||
const resume_info = db.getModel('resume_info');
|
||||
const [updated] = await resume_info.update(
|
||||
{ job_listings },
|
||||
{ where: { sn_code, platform } }
|
||||
);
|
||||
if (updated) {
|
||||
console.log(`[自动投递] job_listings 已保存,共 ${job_listings.length} 项`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[自动投递] 下发 get_job_listings 或保存失败:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下发 get_job_list 命令拉取职位列表(command_params 与前端约定:pageCount、tabLabel + sn_code、platform)
|
||||
*/
|
||||
async getJobList(sn_code, platform, pageCount, taskId, tabLabel = '') {
|
||||
const label = tabLabel != null && String(tabLabel).trim() !== '' ? String(tabLabel).trim() : '';
|
||||
const params = {
|
||||
sn_code,
|
||||
platform,
|
||||
pageCount,
|
||||
...(label ? { tabLabel: label } : {})
|
||||
};
|
||||
const getJobListCommand = {
|
||||
command_type: 'get_job_list',
|
||||
command_name: '获取职位列表',
|
||||
command_params: JSON.stringify(params),
|
||||
priority: config.getTaskPriority('auto_deliver') || 7
|
||||
};
|
||||
|
||||
await command.executeCommands(taskId, [getJobListCommand], this.mqttClient);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将本批中未通过过滤/评分的职位从 pending 更新为 filtered(仍 pending 的仅为通过筛选且等待下轮投递的)
|
||||
* @param {Array} pendingJobs - 本批拉取的待投递
|
||||
* @param {Array} filteredJobs - filterAndScoreJobsForDeliver 通过的结果(含 matchScore)
|
||||
*/
|
||||
async markFilteredJobsNotPassed(pendingJobs, filteredJobs, sn_code, platform) {
|
||||
if (!pendingJobs || pendingJobs.length === 0) {
|
||||
return;
|
||||
}
|
||||
const passedIds = new Set(
|
||||
(filteredJobs || []).map((j) => j.id).filter((id) => id != null)
|
||||
);
|
||||
const notPassedIds = pendingJobs
|
||||
.map((j) => (j && j.id != null ? j.id : null))
|
||||
.filter((id) => id != null && !passedIds.has(id));
|
||||
if (notPassedIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const job_postings = db.getModel('job_postings');
|
||||
const { op } = db.models;
|
||||
try {
|
||||
const [n] = await job_postings.update(
|
||||
{ applyStatus: 'filtered' },
|
||||
{
|
||||
where: {
|
||||
id: { [op.in]: notPassedIds },
|
||||
sn_code,
|
||||
platform,
|
||||
applyStatus: 'pending'
|
||||
}
|
||||
}
|
||||
);
|
||||
console.log(`[自动投递] 不符合条件已标记 filtered: ${notPassedIds.length} 条(更新行数 ${n})`);
|
||||
} catch (e) {
|
||||
console.warn('[自动投递] 标记 filtered 失败:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取待投递职位
|
||||
*/
|
||||
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',
|
||||
create_time: {
|
||||
[db.models.op.gte]: new Date(Date.now() - 1000 * 60 * 60 * 24)
|
||||
}
|
||||
|
||||
},
|
||||
order: [['create_time', 'DESC']],
|
||||
limit
|
||||
});
|
||||
|
||||
return jobs.map(job => job.toJSON ? job.toJSON() : job);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动投递过滤配置:仅使用 job_types(excludeKeywords、titleIncludeKeywords)
|
||||
* 薪资筛选不在此合并(min/max 为 0 表示不做薪资过滤);评分权重仍走 accountConfig.is_salary_priority
|
||||
*/
|
||||
mergeFilterConfig(jobTypeConfig) {
|
||||
const base = {
|
||||
exclude_keywords: [],
|
||||
filter_keywords: [],
|
||||
title_include_keywords: [],
|
||||
min_salary: 0,
|
||||
max_salary: 0,
|
||||
priority_weights: []
|
||||
};
|
||||
|
||||
if (!jobTypeConfig) {
|
||||
return base;
|
||||
}
|
||||
|
||||
if (jobTypeConfig.excludeKeywords) {
|
||||
try {
|
||||
const raw = jobTypeConfig.excludeKeywords;
|
||||
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
base.exclude_keywords = Array.isArray(parsed) ? parsed.map((k) => String(k || '').trim()).filter(Boolean) : [];
|
||||
} catch (e) {
|
||||
base.exclude_keywords = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (jobTypeConfig.titleIncludeKeywords != null) {
|
||||
const v = jobTypeConfig.titleIncludeKeywords;
|
||||
if (Array.isArray(v)) {
|
||||
base.title_include_keywords = v.map((k) => String(k || '').trim()).filter(Boolean);
|
||||
} else if (typeof v === 'string' && v.trim()) {
|
||||
try {
|
||||
const p = JSON.parse(v);
|
||||
if (Array.isArray(p)) {
|
||||
base.title_include_keywords = p.map((k) => String(k || '').trim()).filter(Boolean);
|
||||
}
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取近期已投递的公司
|
||||
*/
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建投递指令
|
||||
*/
|
||||
createDeliverCommands(jobs, sn_code, platform) {
|
||||
return jobs.map(job => ({
|
||||
command_type: 'deliver_resume',
|
||||
command_name: '投递简历',
|
||||
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;
|
||||
18
api/middleware/schedule/handlers/index.js
Normal file
18
api/middleware/schedule/handlers/index.js
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 处理器模块导出
|
||||
* 统一导出所有任务处理器
|
||||
*/
|
||||
|
||||
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
|
||||
};
|
||||
106
api/middleware/schedule/handlers/searchHandler.js
Normal file
106
api/middleware/schedule/handlers/searchHandler.js
Normal file
@@ -0,0 +1,106 @@
|
||||
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. 从 resume_info 取 deliver_tab_label,下发给 get_job_list 用于切换期望 tab
|
||||
const platformType = platform || accountConfig.platform_type || 'boss';
|
||||
let tabLabel = '';
|
||||
try {
|
||||
const db = require('../../dbProxy');
|
||||
const resume_info = db.getModel('resume_info');
|
||||
const resume = await resume_info.findOne({
|
||||
where: { sn_code, platform: platformType, isActive: true },
|
||||
order: [['last_modify_time', 'DESC']],
|
||||
attributes: ['deliver_tab_label']
|
||||
});
|
||||
if (resume && resume.deliver_tab_label) {
|
||||
tabLabel = String(resume.deliver_tab_label).trim();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[自动搜索] 读取 resume_info.deliver_tab_label 失败:', e.message);
|
||||
}
|
||||
|
||||
const commandParams = {
|
||||
sn_code,
|
||||
platform: platformType,
|
||||
pageCount: pageCount || searchConfig.page_count || 3,
|
||||
...(tabLabel ? { tabLabel } : {})
|
||||
};
|
||||
|
||||
const searchCommand = {
|
||||
command_type: 'get_job_list',
|
||||
command_name: '获取职位列表',
|
||||
command_params: JSON.stringify(commandParams),
|
||||
priority: config.getTaskPriority('auto_search') || 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,17 +1,23 @@
|
||||
const mqttManager = require("../mqtt/mqttManager.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 TaskQueue = require('./core/taskQueue.js');
|
||||
const Command = require('./core/command.js');
|
||||
const deviceManager = require('./core/deviceManager.js');
|
||||
const ScheduledJobs = require('./core/scheduledJobs.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 ScheduledJobs = require('./scheduledJobs.js');
|
||||
const DeviceWorkStatusNotifier = require('./deviceWorkStatusNotifier.js');
|
||||
|
||||
// 导入通知器
|
||||
const DeviceWorkStatusNotifier = require('./notifiers/deviceWorkStatusNotifier.js');
|
||||
|
||||
/**
|
||||
* 调度系统管理器
|
||||
@@ -22,7 +28,7 @@ class ScheduleManager {
|
||||
this.mqttClient = null;
|
||||
this.isInitialized = false;
|
||||
this.startTime = new Date();
|
||||
|
||||
|
||||
// 子模块
|
||||
this.taskHandlers = null;
|
||||
this.mqttDispatcher = null;
|
||||
@@ -80,9 +86,9 @@ class ScheduleManager {
|
||||
async initComponents() {
|
||||
// 初始化设备管理器
|
||||
await deviceManager.init();
|
||||
|
||||
|
||||
// 初始化任务队列
|
||||
await TaskQueue.init?.();
|
||||
await TaskQueue.init();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,16 +130,25 @@ class ScheduleManager {
|
||||
console.error('[调度管理器] 处理响应消息失败:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// 订阅 Boss 聊天消息主题,将 boss-automation-nodejs 转发过来的新消息交给 mqttDispatcher 处理
|
||||
this.mqttClient.subscribe("boss/message", async (topic, message) => {
|
||||
try {
|
||||
if (this.mqttDispatcher && typeof this.mqttDispatcher.handleBossMessage === 'function') {
|
||||
await this.mqttDispatcher.handleBossMessage(message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[调度管理器] 处理 Boss 消息失败:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// 重连后向 Broker 幂等声明订阅(监听器仅在上方注册一次,不重复添加)
|
||||
if (typeof this.mqttClient.setMaintainedTopics === 'function') {
|
||||
this.mqttClient.setMaintainedTopics(['heartbeat', 'response', 'boss/message']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动执行找工作流程(已废弃,full_flow 不再使用)
|
||||
* @deprecated 请使用其他任务类型,如 auto_deliver
|
||||
*/
|
||||
async manualExecuteJobFlow(sn_code, keyword = '前端') {
|
||||
console.warn(`[手动执行] manualExecuteJobFlow 已废弃,full_flow 不再使用`);
|
||||
throw new Error('full_flow 任务类型已废弃,请使用其他任务类型');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取系统状态
|
||||
@@ -142,7 +157,11 @@ class ScheduleManager {
|
||||
const status = this.mqttDispatcher ? this.mqttDispatcher.getSystemStatus() : {};
|
||||
return {
|
||||
isInitialized: this.isInitialized,
|
||||
mqttConnected: this.mqttClient && this.mqttClient.isConnected,
|
||||
mqttConnected: this.mqttClient && (
|
||||
typeof this.mqttClient.isBrokerConnected === 'function'
|
||||
? this.mqttClient.isBrokerConnected()
|
||||
: this.mqttClient.isConnected
|
||||
),
|
||||
systemStats: deviceManager.getSystemStats(),
|
||||
allDevices: deviceManager.getAllDevicesStatus(),
|
||||
taskQueues: TaskQueue.getAllDeviceStatus(),
|
||||
@@ -178,28 +197,18 @@ 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,4 +1,4 @@
|
||||
const db = require('../dbProxy');
|
||||
const db = require('../../dbProxy');
|
||||
|
||||
/**
|
||||
* 统一错误处理模块
|
||||
@@ -23,6 +23,7 @@ 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分钟
|
||||
@@ -30,6 +31,7 @@ class ScheduleConfig {
|
||||
|
||||
// 任务优先级配置
|
||||
this.taskPriorities = {
|
||||
auto_search: 8, // 自动搜索任务(最高优先级,先搜索后投递)
|
||||
auto_deliver: 7, // 自动投递任务
|
||||
auto_chat: 6, // 自动沟通任务
|
||||
auto_active_account: 5, // 自动活跃账号任务
|
||||
@@ -44,10 +46,13 @@ class ScheduleConfig {
|
||||
|
||||
// 定时任务配置
|
||||
this.schedules = {
|
||||
dailyReset: '0 0 * * *', // 每天凌晨重置统计
|
||||
monitoringInterval: '*/1 * * * *', // 监控检查间隔:1分钟
|
||||
autoDeliver: '0 */1 * * * *', // 自动投递任务:每1分钟执行一次
|
||||
autoChat: '0 */15 * * * *' // 自动沟通任务:每15分钟执行一次
|
||||
dailyReset: '0 0 * * *', // 每天凌晨重置统计
|
||||
monitoringInterval: '*/1 * * * *', // 监控检查间隔:1分钟
|
||||
autoSearch: '0 0 */1 * * *', // 自动搜索任务:每1小时执行一次
|
||||
autoDeliver: '0 */2 * * * *', // 自动投递任务:每2分钟执行一次
|
||||
autoChat: '0 */1 * * * *', // 自动沟通任务:每1分钟执行一次
|
||||
autoActive: '0 0 */2 * * *', // 自动活跃任务:每2小时执行一次
|
||||
jobTypeListingsAi: '0 0 4 * * *' // 每天 04:00 对有 job_type_id 的在线设备拉取 get_job_listings 并 AI 更新 job_types
|
||||
};
|
||||
}
|
||||
|
||||
14
api/middleware/schedule/infrastructure/index.js
Normal file
14
api/middleware/schedule/infrastructure/index.js
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Infrastructure 模块导出
|
||||
* 统一导出基础设施模块
|
||||
*/
|
||||
|
||||
const PriorityQueue = require('./PriorityQueue');
|
||||
const ErrorHandler = require('./ErrorHandler');
|
||||
const config = require('./config');
|
||||
|
||||
module.exports = {
|
||||
PriorityQueue,
|
||||
ErrorHandler,
|
||||
config
|
||||
};
|
||||
@@ -3,7 +3,7 @@
|
||||
* 负责向客户端推送设备当前工作状态(任务、指令等)
|
||||
*/
|
||||
|
||||
const db = require('../dbProxy');
|
||||
const db = require('../../dbProxy');
|
||||
|
||||
class DeviceWorkStatusNotifier {
|
||||
constructor() {
|
||||
@@ -186,44 +186,33 @@ class DeviceWorkStatusNotifier {
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化指令描述
|
||||
* 格式化指令描述(与前端/后端/下发统一:只用一个名字 command_type,不做映射)
|
||||
* @private
|
||||
*/
|
||||
_formatCommandDescription(command) {
|
||||
const params = command.command_params || command.params || {};
|
||||
let parsedParams = {};
|
||||
|
||||
if (typeof params === 'string') {
|
||||
try {
|
||||
parsedParams = JSON.parse(params);
|
||||
} catch (e) {
|
||||
// 解析失败,忽略
|
||||
}
|
||||
} catch (e) {}
|
||||
} else {
|
||||
parsedParams = params;
|
||||
}
|
||||
|
||||
// 根据指令类型格式化描述
|
||||
const commandType = command.command_type || command.type || '';
|
||||
const commandName = command.command_name || command.name || '';
|
||||
|
||||
const command_type = command.command_type || command.type || '';
|
||||
if (parsedParams.jobTitle && parsedParams.companyName) {
|
||||
const companyName = parsedParams.companyName.length > 20
|
||||
? parsedParams.companyName.substring(0, 20) + '...'
|
||||
const companyName = parsedParams.companyName.length > 20
|
||||
? parsedParams.companyName.substring(0, 20) + '...'
|
||||
: parsedParams.companyName;
|
||||
return `投递职位: ${parsedParams.jobTitle} @ ${companyName}`;
|
||||
} else if (parsedParams.jobTitle) {
|
||||
return `投递职位: ${parsedParams.jobTitle}`;
|
||||
} else if (commandType === 'applyJob' || commandName.includes('投递')) {
|
||||
return '投递简历';
|
||||
} else if (commandType === 'searchJobs' || commandName.includes('搜索')) {
|
||||
return `搜索职位: ${parsedParams.keyword || ''}`;
|
||||
} else if (commandType === 'sendChatMessage' || commandName.includes('沟通')) {
|
||||
return '发送消息';
|
||||
} else if (commandName) {
|
||||
return commandName;
|
||||
return `${command_type}: ${parsedParams.jobTitle} @ ${companyName}`;
|
||||
}
|
||||
return '执行指令';
|
||||
if (parsedParams.jobTitle) {
|
||||
return `${command_type}: ${parsedParams.jobTitle}`;
|
||||
}
|
||||
if (parsedParams.keyword) {
|
||||
return `${command_type}: ${parsedParams.keyword}`;
|
||||
}
|
||||
return command_type || command.command_name || '执行指令';
|
||||
}
|
||||
|
||||
/**
|
||||
9
api/middleware/schedule/notifiers/index.js
Normal file
9
api/middleware/schedule/notifiers/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Notifiers 模块导出
|
||||
*/
|
||||
|
||||
const deviceWorkStatusNotifier = require('./deviceWorkStatusNotifier');
|
||||
|
||||
module.exports = {
|
||||
deviceWorkStatusNotifier
|
||||
};
|
||||
@@ -1,779 +0,0 @@
|
||||
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;
|
||||
|
||||
199
api/middleware/schedule/services/accountValidator.js
Normal file
199
api/middleware/schedule/services/accountValidator.js
Normal file
@@ -0,0 +1,199 @@
|
||||
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;
|
||||
230
api/middleware/schedule/services/configManager.js
Normal file
230
api/middleware/schedule/services/configManager.js
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* 配置管理服务
|
||||
* 统一处理账户配置的解析和验证
|
||||
*/
|
||||
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, // 最大投递数
|
||||
repeat_deliver_days: 30, // 多少天内已投递过的公司不再投递(与 getRecentDeliveredCompanies 一致)
|
||||
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, // 时间范围
|
||||
city: '', // 城市
|
||||
salary_range: '', // 薪资范围
|
||||
experience: '', // 经验
|
||||
education: '' // 学历
|
||||
};
|
||||
|
||||
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;
|
||||
479
api/middleware/schedule/services/jobFilterEngine.js
Normal file
479
api/middleware/schedule/services/jobFilterEngine.js
Normal file
@@ -0,0 +1,479 @@
|
||||
const SalaryParser = require('../utils/salaryParser');
|
||||
const KeywordMatcher = require('../utils/keywordMatcher');
|
||||
const db = require('../../dbProxy');
|
||||
|
||||
/**
|
||||
* 职位过滤引擎(schedule 自动投递用)
|
||||
* 本文件集中:过滤(薪资/关键词/活跃度/去重)+ 评分(权重分+关键词奖励)+ 按分数阈值筛。
|
||||
* 自动投递只需调 filterAndScoreJobsForDeliver 一个方法。
|
||||
*/
|
||||
class JobFilterEngine {
|
||||
getJobKey(job) {
|
||||
return String(job.id || job.jobId || `${job.companyName || ''}|${job.jobTitle || ''}`);
|
||||
}
|
||||
|
||||
getRemovedTitles(beforeJobs, afterJobs, limit = 5) {
|
||||
const keptKeySet = new Set(afterJobs.map((job) => this.getJobKey(job)));
|
||||
return beforeJobs
|
||||
.filter((job) => !keptKeySet.has(this.getJobKey(job)))
|
||||
.map((job) => job.jobTitle || '')
|
||||
.filter(Boolean)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤职位列表(薪资 → 标题须含词 → 关键词 → 活跃度 → 去重)
|
||||
* @param {Array} jobs - 职位列表
|
||||
* @param {object} config - 过滤配置
|
||||
* @param {object} resumeInfo - 简历信息(未使用,兼容签名)
|
||||
* @returns {Promise<Array>} 过滤后的职位列表
|
||||
*/
|
||||
async filterJobs(jobs, config) {
|
||||
if (!jobs || jobs.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let filtered = [...jobs];
|
||||
|
||||
// 1. 薪资过滤
|
||||
const beforeSalaryJobs = [...filtered];
|
||||
filtered = this.filterBySalary(filtered, config);
|
||||
const salaryRemoved = beforeSalaryJobs.length - filtered.length;
|
||||
if (salaryRemoved > 0) {
|
||||
const removedTitles = this.getRemovedTitles(beforeSalaryJobs, filtered);
|
||||
console.log(`[jobFilterEngine] 步骤1-薪资过滤: 范围=${config.min_salary ?? 0}-${config.max_salary ?? 0}K 剔除标题=${removedTitles.join(' | ') || '无'}`);
|
||||
}
|
||||
|
||||
// 2. 职位标题须包含(job_types.titleIncludeKeywords,仅 jobTitle,与 commonSkills 无关)
|
||||
const beforeTitleFilterJobs = [...filtered];
|
||||
filtered = this.filterByTitleIncludeKeywords(filtered, config);
|
||||
const titleKwRemoved = beforeTitleFilterJobs.length - filtered.length;
|
||||
if (titleKwRemoved > 0) {
|
||||
const removedTitles = this.getRemovedTitles(beforeTitleFilterJobs, filtered);
|
||||
console.log(`[jobFilterEngine] 步骤2-标题须含: 关键词=[${(config.title_include_keywords || []).join('、') || '无'}] 剔除标题=${removedTitles.join(' | ') || '无'}`);
|
||||
}
|
||||
|
||||
// 3. 关键词过滤(排除词 + filter_keywords,匹配标题与行业等)
|
||||
const beforeKeywordFilterJobs = [...filtered];
|
||||
filtered = this.filterByKeywords(filtered, config);
|
||||
const keywordsRemoved = beforeKeywordFilterJobs.length - filtered.length;
|
||||
if (keywordsRemoved > 0) {
|
||||
const removedTitles = this.getRemovedTitles(beforeKeywordFilterJobs, filtered);
|
||||
console.log(`[jobFilterEngine] 步骤3-关键词过滤: 排除=[${(config.exclude_keywords || []).join('、') || '无'}] 包含=[${(config.filter_keywords || []).join('、') || '无'}] 剔除标题=${removedTitles.join(' | ') || '无'}`);
|
||||
}
|
||||
|
||||
// 4. 公司活跃度过滤
|
||||
if (config.filter_inactive_companies) {
|
||||
const beforeActivityJobs = [...filtered];
|
||||
filtered = await this.filterByCompanyActivity(filtered, config.company_active_days || 7);
|
||||
const activityRemoved = beforeActivityJobs.length - filtered.length;
|
||||
if (activityRemoved > 0) {
|
||||
const removedTitles = this.getRemovedTitles(beforeActivityJobs, filtered);
|
||||
console.log(`[jobFilterEngine] 步骤4-公司活跃度过滤: 剔除标题=${removedTitles.join(' | ') || '无'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 去重(同一公司、同一职位名称)
|
||||
if (config.deduplicate) {
|
||||
const beforeDedupJobs = [...filtered];
|
||||
filtered = this.deduplicateJobs(filtered);
|
||||
const dedupRemoved = beforeDedupJobs.length - filtered.length;
|
||||
if (dedupRemoved > 0) {
|
||||
const removedTitles = this.getRemovedTitles(beforeDedupJobs, filtered);
|
||||
console.log(`[jobFilterEngine] 步骤5-去重: 剔除标题=${removedTitles.join(' | ') || '无'}`);
|
||||
}
|
||||
}
|
||||
|
||||
const keptTitles = filtered.map((j) => j.jobTitle || '').filter(Boolean).slice(0, 5);
|
||||
console.log(`[jobFilterEngine] filterJobs 结束: 通过标题=${keptTitles.join(' | ') || '无'}`);
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动投递用:过滤 + 评分 + 按 60 分阈值筛,一次调用完成(便于阅读与维护)
|
||||
*/
|
||||
async filterAndScoreJobsForDeliver(jobs, filterConfig, resume, accountConfig, jobTypeConfig, recentCompanies) {
|
||||
const scored = [];
|
||||
const jobDesc = (j) => `${j.companyName || '?'} / ${j.jobTitle || '?'}`;
|
||||
const { jobFilterService } = require('../../job/services');
|
||||
|
||||
console.log(`[jobFilterEngine] filterAndScoreJobsForDeliver 开始,待处理: ${jobs.length}`);
|
||||
|
||||
for (const job of jobs) {
|
||||
if (job.companyName && recentCompanies.has(job.companyName)) {
|
||||
console.log(`[jobFilterEngine] 已投递公司 剔除: ${jobDesc(job)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const filtered = await this.filterJobs([job], filterConfig, resume);
|
||||
if (filtered.length === 0) {
|
||||
console.log(`[jobFilterEngine] 过滤条件不通过 剔除: ${jobDesc(job)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const scoreResult = jobFilterService.calculateJobScoreWithWeights(
|
||||
job,
|
||||
resume,
|
||||
accountConfig,
|
||||
jobTypeConfig,
|
||||
accountConfig.is_salary_priority || []
|
||||
);
|
||||
const keywordBonus = KeywordMatcher.calculateBonus(
|
||||
`${job.jobTitle || ''} ${job.companyName || ''} ${job.jobDescription || ''}`,
|
||||
filterConfig.filter_keywords || [],
|
||||
{ baseScore: 5, maxBonus: 20 }
|
||||
);
|
||||
const finalScore = scoreResult.totalScore + keywordBonus.score;
|
||||
|
||||
if (finalScore < 60) {
|
||||
console.log(`[jobFilterEngine] 评分不足(>=60) 剔除: ${jobDesc(job)} 总分=${finalScore.toFixed(1)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
scored.push({
|
||||
...job,
|
||||
matchScore: finalScore,
|
||||
scoreDetails: { ...scoreResult.scores, keywordBonus: keywordBonus.score }
|
||||
});
|
||||
}
|
||||
|
||||
scored.sort((a, b) => b.matchScore - a.matchScore);
|
||||
console.log(`[jobFilterEngine] filterAndScoreJobsForDeliver 结束: 原始${jobs.length} 通过${scored.length}`);
|
||||
return scored;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按薪资过滤
|
||||
* @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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 职位标题须包含配置中的关键词(命中任意一个即通过),不扫描描述/公司名/commonSkills
|
||||
* @param {Array} jobs
|
||||
* @param {object} config
|
||||
* @returns {Array}
|
||||
*/
|
||||
filterByTitleIncludeKeywords(jobs, config) {
|
||||
const kws = config.title_include_keywords;
|
||||
if (!Array.isArray(kws) || kws.length === 0) {
|
||||
return jobs;
|
||||
}
|
||||
return jobs.filter((job) => {
|
||||
const title = `${job.jobTitle || ''}`.toLowerCase();
|
||||
return kws.some((kw) => {
|
||||
const k = String(kw || '').toLowerCase().trim();
|
||||
if (!k) {
|
||||
return false;
|
||||
}
|
||||
return title.includes(k);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 按关键词过滤
|
||||
* @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
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 按公司活跃度过滤
|
||||
* @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.jobTitle || '').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.jobTitle || '',
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
const jobFilterEngine = new JobFilterEngine();
|
||||
module.exports = jobFilterEngine;
|
||||
158
api/middleware/schedule/services/timeRangeValidator.js
Normal file
158
api/middleware/schedule/services/timeRangeValidator.js
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* 时间范围验证器
|
||||
* 检查当前时间是否在指定的时间范围内(支持工作日限制)
|
||||
*/
|
||||
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,803 +1,96 @@
|
||||
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');
|
||||
const { SearchHandler, DeliverHandler, ChatHandler, ActiveHandler } = require('./handlers');
|
||||
|
||||
/**
|
||||
* 任务处理器(简化版)
|
||||
* 处理各种类型的任务
|
||||
* 任务处理器工厂(重构版)
|
||||
* 使用独立的处理器类替代原有的内嵌处理方法
|
||||
*
|
||||
* 重构说明:
|
||||
* - 原 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);
|
||||
});
|
||||
|
||||
// 自动沟通任务(待实现)
|
||||
// 注册自动沟通处理器
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
console.log(`[任务处理器] 调度自动投递任务 - 设备: ${task.sn_code}`);
|
||||
return await this.deliverHandler.handle(task);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查当前时间是否在指定的时间范围内
|
||||
* @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进行沟通,回复消息等
|
||||
* 处理自动沟通任务
|
||||
* @param {object} task - 任务对象
|
||||
* @returns {Promise<object>} 执行结果
|
||||
*/
|
||||
async handleAutoChatTask(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;
|
||||
}
|
||||
console.log(`[任务处理器] 调度自动沟通任务 - 设备: ${task.sn_code}`);
|
||||
return await this.chatHandler.handle(task);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理自动活跃账号任务(待实现)
|
||||
* 功能:自动执行一些操作来保持账号活跃度,如浏览职位、搜索等
|
||||
* 处理自动活跃账户任务
|
||||
* @param {object} task - 任务对象
|
||||
* @returns {Promise<object>} 执行结果
|
||||
*/
|
||||
async handleAutoActiveAccountTask(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;
|
||||
console.log(`[任务处理器] 调度自动活跃任务 - 设备: ${task.sn_code}`);
|
||||
return await this.activeHandler.handle(task);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskHandlers;
|
||||
|
||||
|
||||
182
api/middleware/schedule/tasks/autoActiveTask.js
Normal file
182
api/middleware/schedule/tasks/autoActiveTask.js
Normal file
@@ -0,0 +1,182 @@
|
||||
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();
|
||||
181
api/middleware/schedule/tasks/autoChatTask.js
Normal file
181
api/middleware/schedule/tasks/autoChatTask.js
Normal file
@@ -0,0 +1,181 @@
|
||||
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();
|
||||
297
api/middleware/schedule/tasks/autoDeliverTask.js
Normal file
297
api/middleware/schedule/tasks/autoDeliverTask.js
Normal file
@@ -0,0 +1,297 @@
|
||||
const BaseTask = require('./baseTask');
|
||||
const db = require('../../dbProxy');
|
||||
const config = require('../infrastructure/config');
|
||||
const authorizationService = require('../../../services/authorization_service');
|
||||
const ConfigManager = require('../services/configManager');
|
||||
|
||||
/**
|
||||
* 自动投递任务
|
||||
* 从数据库读取职位列表并进行自动投递
|
||||
*/
|
||||
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 = ConfigManager.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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取今日已投递数量
|
||||
*/
|
||||
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 = ConfigManager.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();
|
||||
211
api/middleware/schedule/tasks/autoSearchTask.js
Normal file
211
api/middleware/schedule/tasks/autoSearchTask.js
Normal file
@@ -0,0 +1,211 @@
|
||||
const BaseTask = require('./baseTask');
|
||||
const db = require('../../dbProxy');
|
||||
const config = require('../infrastructure/config');
|
||||
const ConfigManager = require('../services/configManager');
|
||||
|
||||
/**
|
||||
* 自动搜索职位任务
|
||||
* 定期搜索符合条件的职位并保存到数据库
|
||||
*/
|
||||
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 = ConfigManager.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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取今日已搜索数量
|
||||
*/
|
||||
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 = ConfigManager.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();
|
||||
405
api/middleware/schedule/tasks/baseTask.js
Normal file
405
api/middleware/schedule/tasks/baseTask.js
Normal file
@@ -0,0 +1,405 @@
|
||||
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;
|
||||
16
api/middleware/schedule/tasks/index.js
Normal file
16
api/middleware/schedule/tasks/index.js
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 任务模块索引
|
||||
* 统一导出所有任务类型
|
||||
*/
|
||||
|
||||
const autoSearchTask = require('./autoSearchTask');
|
||||
const autoDeliverTask = require('./autoDeliverTask');
|
||||
const autoChatTask = require('./autoChatTask');
|
||||
const autoActiveTask = require('./autoActiveTask');
|
||||
|
||||
module.exports = {
|
||||
autoSearchTask,
|
||||
autoDeliverTask,
|
||||
autoChatTask,
|
||||
autoActiveTask
|
||||
};
|
||||
14
api/middleware/schedule/utils/index.js
Normal file
14
api/middleware/schedule/utils/index.js
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Utils 模块导出
|
||||
* 统一导出工具类模块
|
||||
*/
|
||||
|
||||
const SalaryParser = require('./salaryParser');
|
||||
const KeywordMatcher = require('./keywordMatcher');
|
||||
const ScheduleUtils = require('./scheduleUtils');
|
||||
|
||||
module.exports = {
|
||||
SalaryParser,
|
||||
KeywordMatcher,
|
||||
ScheduleUtils
|
||||
};
|
||||
227
api/middleware/schedule/utils/keywordMatcher.js
Normal file
227
api/middleware/schedule/utils/keywordMatcher.js
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* 关键词匹配工具
|
||||
* 提供职位描述的关键词匹配和评分功能
|
||||
*/
|
||||
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) {
|
||||
if (!jobs || jobs.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const textExtractor=(job) => `${job.jobTitle || ''} ${job.companyIndustry || ''}`;
|
||||
|
||||
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;
|
||||
126
api/middleware/schedule/utils/salaryParser.js
Normal file
126
api/middleware/schedule/utils/salaryParser.js
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 薪资解析工具
|
||||
* 统一处理职位薪资和期望薪资的解析逻辑
|
||||
*/
|
||||
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;
|
||||
62
api/model/chat_message.js
Normal file
62
api/model/chat_message.js
Normal file
@@ -0,0 +1,62 @@
|
||||
const Sequelize = require('sequelize');
|
||||
|
||||
/**
|
||||
* 聊天消息表
|
||||
* 每句话一条记录;会话形态(仅 data+job)用 mid=0 存一条
|
||||
*/
|
||||
module.exports = (db) => {
|
||||
const chat_message = db.define('chat_message', {
|
||||
sn_code: {
|
||||
comment: '设备SN码',
|
||||
type: Sequelize.STRING(50),
|
||||
allowNull: false,
|
||||
defaultValue: ''
|
||||
},
|
||||
platform: {
|
||||
comment: '平台: boss / liepin',
|
||||
type: Sequelize.STRING(20),
|
||||
allowNull: false,
|
||||
defaultValue: 'boss'
|
||||
},
|
||||
friendId: {
|
||||
comment: '好友/会话ID',
|
||||
type: Sequelize.BIGINT,
|
||||
allowNull: false
|
||||
},
|
||||
encryptFriendId: {
|
||||
comment: '好友加密ID',
|
||||
type: Sequelize.STRING(100),
|
||||
allowNull: true,
|
||||
defaultValue: ''
|
||||
},
|
||||
mid: {
|
||||
comment: '消息ID(接口返回)',
|
||||
type: Sequelize.BIGINT,
|
||||
allowNull: false
|
||||
},
|
||||
message_data: {
|
||||
comment: '单条消息原始数据 JSON',
|
||||
type: Sequelize.JSON,
|
||||
allowNull: true,
|
||||
defaultValue: null
|
||||
},
|
||||
fetch_time: {
|
||||
comment: '拉取时间',
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true,
|
||||
defaultValue: Sequelize.NOW
|
||||
}
|
||||
}, {
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{ unique: true, fields: ['sn_code', 'platform', 'friendId', 'mid'] },
|
||||
{ unique: false, fields: ['sn_code', 'platform', 'friendId'] },
|
||||
{ unique: false, fields: ['fetch_time'] }
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
// chat_message.sync({ force: true });
|
||||
|
||||
return chat_message;
|
||||
};
|
||||
@@ -1,12 +1,12 @@
|
||||
const Sequelize = require('sequelize');
|
||||
|
||||
/**
|
||||
* 聊天记录表模型
|
||||
* 记录与HR的聊天内容和效果
|
||||
* 聊天会话列表表模型
|
||||
* 按 Boss 「好友/会话」列表维度存储,会话摘要信息
|
||||
*/
|
||||
module.exports = (db) => {
|
||||
const chat_records = db.define("chat_records", {
|
||||
// 聊天基本信息
|
||||
const chat_records = db.define('chat_records', {
|
||||
// 设备与平台
|
||||
sn_code: {
|
||||
comment: '设备SN码',
|
||||
type: Sequelize.STRING(50),
|
||||
@@ -19,270 +19,91 @@ module.exports = (db) => {
|
||||
allowNull: false,
|
||||
defaultValue: 'boss'
|
||||
},
|
||||
|
||||
// 岗位关联
|
||||
jobId: {
|
||||
comment: '岗位ID',
|
||||
|
||||
// Boss 会话列表字段(与接口 friend 对象对应)
|
||||
friendId: {
|
||||
comment: '好友ID',
|
||||
type: Sequelize.BIGINT,
|
||||
allowNull: false
|
||||
},
|
||||
friendSource: {
|
||||
comment: '好友来源',
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: 0
|
||||
},
|
||||
encryptFriendId: {
|
||||
comment: '好友加密ID',
|
||||
type: Sequelize.STRING(100),
|
||||
allowNull: true,
|
||||
allowNull: false,
|
||||
defaultValue: ''
|
||||
},
|
||||
encryptBossId: {
|
||||
comment: 'Boss加密ID',
|
||||
type: Sequelize.STRING(100),
|
||||
allowNull: true,
|
||||
defaultValue: ''
|
||||
},
|
||||
jobTitle: {
|
||||
comment: '岗位名称',
|
||||
type: Sequelize.STRING(200),
|
||||
allowNull: true,
|
||||
defaultValue: ''
|
||||
},
|
||||
companyName: {
|
||||
comment: '公司名称',
|
||||
type: Sequelize.STRING(200),
|
||||
allowNull: true,
|
||||
defaultValue: ''
|
||||
},
|
||||
|
||||
// HR信息
|
||||
hrName: {
|
||||
name: {
|
||||
comment: 'HR姓名',
|
||||
type: Sequelize.STRING(50),
|
||||
allowNull: true,
|
||||
defaultValue: ''
|
||||
},
|
||||
hrTitle: {
|
||||
comment: 'HR职位',
|
||||
type: Sequelize.STRING(50),
|
||||
allowNull: true,
|
||||
defaultValue: ''
|
||||
},
|
||||
hrId: {
|
||||
comment: 'HR ID',
|
||||
type: Sequelize.STRING(100),
|
||||
allowNull: true,
|
||||
defaultValue: ''
|
||||
},
|
||||
|
||||
// 聊天内容
|
||||
chatType: {
|
||||
comment: '聊天类型: greeting-打招呼, follow_up-跟进, interview-面试邀约, reply-回复',
|
||||
type: Sequelize.STRING(20),
|
||||
allowNull: false,
|
||||
defaultValue: 'greeting'
|
||||
},
|
||||
direction: {
|
||||
comment: '消息方向: sent-发送, received-接收',
|
||||
type: Sequelize.STRING(20),
|
||||
allowNull: false,
|
||||
defaultValue: 'sent'
|
||||
},
|
||||
content: {
|
||||
comment: '聊天内容',
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: false,
|
||||
defaultValue: ''
|
||||
},
|
||||
contentType: {
|
||||
comment: '内容类型: text-文本, image-图片, file-文件',
|
||||
type: Sequelize.STRING(20),
|
||||
allowNull: true,
|
||||
defaultValue: 'text'
|
||||
},
|
||||
|
||||
// AI生成信息
|
||||
isAiGenerated: {
|
||||
comment: '是否AI生成',
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: true,
|
||||
defaultValue: false
|
||||
},
|
||||
aiPrompt: {
|
||||
comment: 'AI生成的提示词',
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
defaultValue: ''
|
||||
},
|
||||
aiModel: {
|
||||
comment: 'AI模型名称',
|
||||
type: Sequelize.STRING(50),
|
||||
allowNull: true,
|
||||
defaultValue: ''
|
||||
},
|
||||
|
||||
// 发送状态
|
||||
sendStatus: {
|
||||
comment: '发送状态: pending-待发送, sending-发送中, sent-已发送, failed-发送失败',
|
||||
type: Sequelize.STRING(20),
|
||||
allowNull: true,
|
||||
defaultValue: 'pending'
|
||||
},
|
||||
sendTime: {
|
||||
comment: '发送时间',
|
||||
type: Sequelize.DATE,
|
||||
updateTime: {
|
||||
comment: '最后更新时间(毫秒时间戳)',
|
||||
type: Sequelize.BIGINT,
|
||||
allowNull: true
|
||||
},
|
||||
receiveTime: {
|
||||
comment: '接收时间',
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
|
||||
// 回复信息
|
||||
hasReply: {
|
||||
comment: '是否有回复',
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: true,
|
||||
defaultValue: false
|
||||
},
|
||||
replyTime: {
|
||||
comment: '回复时间',
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
replyContent: {
|
||||
comment: '回复内容',
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
defaultValue: ''
|
||||
},
|
||||
replyDuration: {
|
||||
comment: '回复时长(分钟)',
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: 0
|
||||
},
|
||||
|
||||
// 面试邀约信息
|
||||
isInterviewInvitation: {
|
||||
comment: '是否包含面试邀约',
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: true,
|
||||
defaultValue: false
|
||||
},
|
||||
interviewTime: {
|
||||
comment: '面试时间',
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
interviewLocation: {
|
||||
comment: '面试地点',
|
||||
brandName: {
|
||||
comment: '公司名称',
|
||||
type: Sequelize.STRING(200),
|
||||
allowNull: true,
|
||||
defaultValue: ''
|
||||
},
|
||||
interviewType: {
|
||||
comment: '面试类型: online-线上, offline-线下',
|
||||
type: Sequelize.STRING(20),
|
||||
jobName: {
|
||||
comment: '职位名称',
|
||||
type: Sequelize.STRING(200),
|
||||
allowNull: true,
|
||||
defaultValue: ''
|
||||
},
|
||||
interviewStatus: {
|
||||
comment: '面试状态: pending-待确认, confirmed-已确认, completed-已完成, cancelled-已取消',
|
||||
type: Sequelize.STRING(20),
|
||||
jobTypeDesc: {
|
||||
comment: '职位类型描述',
|
||||
type: Sequelize.STRING(100),
|
||||
allowNull: true,
|
||||
defaultValue: ''
|
||||
},
|
||||
|
||||
// 效果评估
|
||||
effectScore: {
|
||||
comment: '效果评分(0-100)',
|
||||
jobCity: {
|
||||
comment: '职位城市',
|
||||
type: Sequelize.STRING(100),
|
||||
allowNull: true,
|
||||
defaultValue: ''
|
||||
},
|
||||
positionName: {
|
||||
comment: '岗位名称/方向',
|
||||
type: Sequelize.STRING(200),
|
||||
allowNull: true,
|
||||
defaultValue: ''
|
||||
},
|
||||
bossTitle: {
|
||||
comment: 'Boss/HR 职位头衔',
|
||||
type: Sequelize.STRING(100),
|
||||
allowNull: true,
|
||||
defaultValue: ''
|
||||
},
|
||||
waterLevel: {
|
||||
comment: '水位(Boss 优先级标记)',
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: 0
|
||||
},
|
||||
sentiment: {
|
||||
comment: '情感倾向: positive-正面, neutral-中性, negative-负面',
|
||||
type: Sequelize.STRING(20),
|
||||
allowNull: true,
|
||||
defaultValue: 'neutral'
|
||||
},
|
||||
|
||||
// 会话信息
|
||||
conversationId: {
|
||||
comment: '会话ID(同一个岗位的聊天属于一个会话)',
|
||||
type: Sequelize.STRING(50),
|
||||
allowNull: true,
|
||||
defaultValue: ''
|
||||
},
|
||||
messageIndex: {
|
||||
comment: '消息序号(会话内的消息顺序)',
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: 0
|
||||
},
|
||||
|
||||
// 关联信息
|
||||
taskId: {
|
||||
comment: '关联任务ID',
|
||||
type: Sequelize.STRING(50),
|
||||
allowNull: true,
|
||||
defaultValue: ''
|
||||
},
|
||||
|
||||
// 其他信息
|
||||
originalData: {
|
||||
comment: '原始数据(JSON)',
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
defaultValue: ''
|
||||
},
|
||||
errorMessage: {
|
||||
comment: '错误信息',
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
defaultValue: ''
|
||||
},
|
||||
notes: {
|
||||
comment: '备注',
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
defaultValue: ''
|
||||
},
|
||||
|
||||
|
||||
}
|
||||
|
||||
}, {
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{
|
||||
unique: false,
|
||||
fields: ['sn_code']
|
||||
},
|
||||
{
|
||||
unique: false,
|
||||
fields: ['jobId']
|
||||
},
|
||||
{
|
||||
unique: false,
|
||||
fields: ['encryptBossId']
|
||||
},
|
||||
{
|
||||
unique: false,
|
||||
fields: ['conversationId']
|
||||
},
|
||||
{
|
||||
unique: false,
|
||||
fields: ['chatType']
|
||||
},
|
||||
{
|
||||
unique: false,
|
||||
fields: ['sendStatus']
|
||||
},
|
||||
{
|
||||
unique: false,
|
||||
fields: ['hasReply']
|
||||
},
|
||||
|
||||
{ unique: false, fields: ['sn_code'] },
|
||||
{ unique: false, fields: ['platform'] },
|
||||
{ unique: false, fields: ['friendId'] },
|
||||
{ unique: false, fields: ['encryptFriendId'] }
|
||||
]
|
||||
});
|
||||
|
||||
//chat_records.sync({ force: true });
|
||||
// chat_records.sync({ force: true });
|
||||
|
||||
return chat_records
|
||||
|
||||
|
||||
|
||||
return chat_records;
|
||||
};
|
||||
|
||||
|
||||
87
api/model/chat_reply_intent_log.js
Normal file
87
api/model/chat_reply_intent_log.js
Normal file
@@ -0,0 +1,87 @@
|
||||
const Sequelize = require('sequelize');
|
||||
|
||||
/**
|
||||
* 沟通回复意图 AI 调用记录
|
||||
* 记录 getReplyContentFromDetail 中 replyIntentAndContent 的入参与结果,便于排查与统计
|
||||
*/
|
||||
module.exports = (db) => {
|
||||
const chat_reply_intent_log = db.define('chat_reply_intent_log', {
|
||||
sn_code: {
|
||||
comment: '设备SN码',
|
||||
type: Sequelize.STRING(50),
|
||||
allowNull: true,
|
||||
defaultValue: ''
|
||||
},
|
||||
platform: {
|
||||
comment: '平台: boss / liepin',
|
||||
type: Sequelize.STRING(20),
|
||||
allowNull: true,
|
||||
defaultValue: 'boss'
|
||||
},
|
||||
friendId: {
|
||||
comment: '好友/会话ID',
|
||||
type: Sequelize.BIGINT,
|
||||
allowNull: true
|
||||
},
|
||||
encrypt_friend_id: {
|
||||
comment: '好友加密ID',
|
||||
type: Sequelize.STRING(100),
|
||||
allowNull: true,
|
||||
defaultValue: ''
|
||||
},
|
||||
security_id: {
|
||||
comment: 'HR 消息唯一 id,用于去重,同一消息只调用一次 AI',
|
||||
type: Sequelize.STRING(500),
|
||||
allowNull: true
|
||||
},
|
||||
hr_message_text: {
|
||||
comment: 'HR 最新消息原文(AI 入参)',
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
action: {
|
||||
comment: 'AI 返回意图: no_reply/text/send_resume/exchange_wechat/exchange_phone',
|
||||
type: Sequelize.STRING(30),
|
||||
allowNull: true,
|
||||
defaultValue: ''
|
||||
},
|
||||
reply_content: {
|
||||
comment: 'AI 返回的回复文案',
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
replied: {
|
||||
comment: '是否执行了回复',
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: true,
|
||||
defaultValue: false
|
||||
},
|
||||
reason: {
|
||||
comment: '未回复时的原因',
|
||||
type: Sequelize.STRING(200),
|
||||
allowNull: true
|
||||
},
|
||||
job_name: {
|
||||
comment: '职位名称(便于排查)',
|
||||
type: Sequelize.STRING(200),
|
||||
allowNull: true
|
||||
},
|
||||
create_time: {
|
||||
comment: '创建时间',
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true,
|
||||
defaultValue: Sequelize.NOW
|
||||
}
|
||||
}, {
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{ unique: true, fields: ['security_id'], name: 'uk_security_id' },
|
||||
{ unique: false, fields: ['sn_code', 'platform', 'friendId'] },
|
||||
{ unique: false, fields: ['create_time'] }
|
||||
]
|
||||
});
|
||||
|
||||
// chat_reply_intent_log.sync({ force: true });
|
||||
|
||||
return chat_reply_intent_log;
|
||||
};
|
||||
@@ -190,7 +190,7 @@ module.exports = (db) => {
|
||||
},
|
||||
// 投递状态
|
||||
applyStatus: {
|
||||
comment: '投递状态: pending-待投递, applied-已投递, rejected-被拒绝, accepted-已接受',
|
||||
comment: '投递状态: pending-待投递, filtered-已过滤(不符合规则未投递), applied-已投递, rejected-被拒绝, accepted-已接受, success/failed-见业务',
|
||||
type: Sequelize.STRING(20),
|
||||
allowNull: true,
|
||||
defaultValue: 'pending'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user