Compare commits

...

19 Commits

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

3
.gitignore vendored
View File

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

View File

@@ -0,0 +1,764 @@
# Boss直聘搜索列表和投递功能开发规划
## 📋 功能概述
基于Boss直聘Web端职位搜索页面`https://www.zhipin.com/web/geek/jobs`),完善搜索列表获取和职位投递功能,包括服务端任务创建、指令生成和完整流程实现。
## 🎯 目标功能
### 1. 搜索列表功能
- 支持多条件搜索(关键词、城市、薪资、经验、学历等)
- 支持分页获取职位列表
- 自动保存职位到数据库
- 支持职位去重和更新
### 2. 投递功能
- 单个职位投递
- 批量职位投递
- 投递状态跟踪
- 投递记录管理
## 📊 Boss直聘响应数据结构
### 响应格式示例
Boss直聘搜索职位列表的响应数据结构如下
```json
{
"code": 0,
"message": "Success",
"zpData": {
"resCount": 450, // 搜索结果总数
"hasMore": true, // 是否还有更多
"totalCount": 300, // 总职位数
"jobList": [ // 职位列表
{
"encryptJobId": "5ae70dfe114c23ab0nR-2ti-FFpU", // 职位ID投递必需
"encryptBossId": "b55854108ac215180XZ62N-_FlNT", // Boss ID投递必需
"securityId": "HP23zbQfaslvy-c1...", // 安全ID投递必需
"jobName": "全栈软件工程师", // 职位名称
"salaryDesc": "25-50K·19薪", // 薪资描述(需解析)
"jobExperience": "在校/应届", // 工作经验(需解析)
"jobDegree": "学历不限", // 学历要求
"city": 101020100, // 城市代码
"cityName": "上海", // 城市名称
"areaDistrict": "长宁区", // 区域
"businessDistrict": "新华路", // 商圈
"gps": { // 位置信息(优先使用)
"longitude": 121.41902537687392,
"latitude": 31.210308153576566
},
"encryptBrandId": "d283b66de3cefd891H1529q5Flc~", // 公司ID
"brandName": "上海大裂谷智能科技", // 公司名称
"brandScaleName": "100-499人", // 公司规模
"brandIndustry": "人工智能", // 公司行业
"brandStageName": "天使轮", // 融资阶段
"bossName": "杨明雨", // Boss姓名
"bossTitle": "HR", // Boss职位
"bossOnline": true, // Boss是否在线
"jobLabels": ["在校/应届", "学历不限"], // 职位标签
"skills": [], // 技能要求
"welfareList": ["带薪年假", "五险一金"], // 福利列表
"proxyJob": 0, // 是否外包0否1是
"industry": 100028 // 行业代码
}
]
}
}
```
### 关键字段说明
1. **投递必需字段**:
- `encryptJobId`: 职位ID投递时必须
- `encryptBossId`: Boss ID投递时必须
- `securityId`: 安全ID投递时必须每次搜索可能不同
2. **位置信息**:
- `gps.longitude``gps.latitude`: 直接使用无需调用位置服务API
- 如果没有gps字段再使用 `cityName + areaDistrict + businessDistrict + brandName` 调用位置服务
3. **薪资解析**:
- `salaryDesc` 格式多样:`"25-50K·19薪"``"20-30K"``"面议"`
- 需要解析出 `salaryMin``salaryMax`(单位:元)
4. **工作年限解析**:
- `jobExperience` 可能为:`"在校/应届"``"3-5年"``"1-3年"`
- 需要解析出 `experienceMin``experienceMax`
## 📊 功能架构
```
用户/系统触发
创建任务 (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. 优化位置解析优先使用响应中的gps字段减少API调用
3. 解析薪资范围从salaryDesc提取min/max
4. 解析工作年限从jobExperience提取min/max
5. 添加职位状态管理
6. 添加职位匹配度字段
**Boss直聘响应数据字段映射表**:
| 响应字段 | 数据库字段 | 说明 | 示例值 |
|---------|-----------|------|--------|
| `encryptJobId` | `jobId` | 职位ID加密 | "5ae70dfe114c23ab0nR-2ti-FFpU" |
| `jobName` | `jobTitle` | 职位名称 | "全栈软件工程师" |
| `encryptBrandId` | `companyId` | 公司ID加密 | "d283b66de3cefd891H1529q5Flc~" |
| `brandName` | `companyName` | 公司名称 | "上海大裂谷智能科技" |
| `brandScaleName` | `companySize` | 公司规模 | "100-499人" |
| `brandIndustry` | `companyIndustry` | 公司行业 | "人工智能" |
| `brandStageName` | `brandStage` | 融资阶段 | "天使轮" |
| `salaryDesc` | `salary` | 薪资描述 | "25-50K·19薪" |
| `salaryDesc` | `salaryMin`, `salaryMax` | 薪资范围(需解析) | 25000, 50000 |
| `jobExperience` | `experience` | 工作经验 | "在校/应届" |
| `jobExperience` | `experienceMin`, `experienceMax` | 工作年限范围(需解析) | - |
| `jobDegree` | `education` | 学历要求 | "学历不限" |
| `jobDegree` | `educationLevel` | 学历等级(需映射) | - |
| `city` | `city` | 城市代码 | 101020100 |
| `cityName` | `cityName` | 城市名称 | "上海" |
| `areaDistrict` | `areaDistrict` | 区域 | "长宁区" |
| `businessDistrict` | `businessDistrict` | 商圈 | "新华路" |
| `gps.longitude` | `longitude` | 经度(优先使用) | 121.41902537687392 |
| `gps.latitude` | `latitude` | 纬度(优先使用) | 31.210308153576566 |
| `encryptBossId` | `encryptBossId` | Boss ID投递需要 | "b55854108ac215180XZ62N-_FlNT" |
| `securityId` | `securityId` | 安全ID投递需要 | "HP23zbQfaslvy-c1..." |
| `bossName` | `bossName` | Boss姓名 | "杨明雨" |
| `bossTitle` | `bossTitle` | Boss职位 | "HR" |
| `bossOnline` | `bossOnline` | Boss是否在线 | true |
| `jobLabels` | `jobLabels` | 职位标签JSON | ["在校/应届", "学历不限"] |
| `skills` | `skills` | 技能要求JSON | ["Java", "MySQL"] |
| `welfareList` | `welfareList` | 福利列表JSON | ["带薪年假", "五险一金"] |
| `proxyJob` | `isOutsourcing` | 是否外包 | 0/1 |
| `industry` | `industry` | 行业代码 | 100028 |
**关键优化点**:
1. **位置信息**: 优先使用响应中的 `gps.longitude``gps.latitude`避免调用位置服务API
- 如果 `gps` 字段存在,直接使用
- 如果不存在,再使用 `cityName + areaDistrict + businessDistrict + brandName` 调用位置服务
2. **薪资解析**: 从 `salaryDesc` 解析薪资范围
- 格式示例:`"25-50K·19薪"` → min: 25000, max: 50000
- 格式示例:`"20-30K"` → min: 20000, max: 30000
- 格式示例:`"面议"` → min: 0, max: 0
- 格式示例:`"15K以上"` → min: 15000, max: 999999
- 需要处理K、W、薪年终奖倍数
3. **工作年限解析**: 从 `jobExperience` 解析年限范围
- `"在校/应届"` → min: 0, max: 0
- `"1-3年"` → min: 1, max: 3
- `"3-5年"` → min: 3, max: 5
- `"5-10年"` → min: 5, max: 10
- `"10年以上"` → min: 10, max: 99
4. **学历映射**: 将学历描述映射为等级
- `"学历不限"``"unlimited"`
- `"高中"``"high_school"`
- `"大专"``"college"`
- `"本科"``"bachelor"`
- `"硕士"``"master"`
- `"博士"``"doctor"`
5. **投递必需字段**: 确保保存 `encryptJobId``encryptBossId``securityId`
- 这些字段在投递时必须使用
- `securityId` 每次搜索可能不同,需要实时保存
**代码位置**: 第215-308行
**预计工作量**: 4小时增加字段解析逻辑
---
### 任务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()`
**方法签名**:
```javascript
/**
* 创建搜索职位列表任务(支持可选投递)
* @param {Object} params - 任务参数
* @param {number} params.id - 账号ID
* @param {string} params.keyword - 搜索关键词
* @param {Object} params.searchParams - 搜索条件(城市、薪资、经验、学历等)
* @param {number} params.pageCount - 获取页数
* @param {boolean} params.autoDeliver - 是否自动投递默认false
* @param {Object} params.filterRules - 过滤规则autoDeliver=true时使用
* @param {number} params.maxCount - 最大投递数量autoDeliver=true时使用
* @returns {Promise<Object>} 任务创建结果 { taskId, message, jobCount, deliveredCount }
*/
async createSearchJobListTask(params) {
// 1. 验证账号和授权
// 2. 创建任务记录 (taskType: 'search_jobs' 或 'auto_deliver')
// 3. 生成搜索指令
// 4. 执行搜索指令
// 5. 等待搜索完成(职位会自动保存到数据库)
// 6. 如果 autoDeliver=true:
// - 从数据库获取刚搜索到的职位列表
// - 根据简历信息和过滤规则匹配职位
// - 生成投递指令序列
// - 执行投递指令(带间隔控制)
// - 保存投递记录
// - 更新职位状态
// 7. 返回任务信息
}
```
**任务内容**:
1. 验证账号和授权
2. 创建任务记录根据autoDeliver参数设置taskType: 'search_jobs' 或 'auto_deliver'
3. 生成搜索指令command_type: 'get_job_list'
4. 执行搜索指令通过MQTT发送到设备
5. 等待搜索完成(职位会自动保存到数据库)
6. 如果 `autoDeliver=true`,继续执行投递流程:
- 从数据库获取刚搜索到的职位列表applyStatus = 'pending'
- 根据简历信息和过滤规则匹配职位(距离、薪资、工作年限、学历等)
- 为每个匹配的职位生成投递指令command_type: 'apply_job'
- 批量执行投递指令(带间隔控制,避免频繁投递)
- 保存投递记录 (apply_records)
- 更新职位状态 (job_postings.applyStatus = 'applied')
7. 返回任务信息(包含搜索到的职位数量和投递数量)
**代码位置**: 在 `runCommand()` 方法后添加
**预计工作量**: 5小时增加投递逻辑
---
---
### 任务6: 完善指令类型映射
**文件**: `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小时
---
### 任务7: 添加搜索条件配置管理
**文件**: `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()
- 参数: { id, keyword, searchParams, pageCount, autoDeliver: true/false, filterRules, maxCount }
2. 创建任务记录 (task_status)
- taskType: 'search_jobs' 或 'auto_deliver'根据autoDeliver参数
3. 生成搜索指令 (task_commands)
- command_type: 'get_job_list'
- command_params: { keyword, city, salary, experience, education, ... }
4. 执行指令 (通过MQTT发送到设备)
5. 设备执行搜索并返回职位列表
6. 保存职位到数据库 (job_postings)
- 去重处理
- 位置解析优先使用gps字段
- 字段映射
- 状态: applyStatus = 'pending'(待投递)
7. 更新搜索指令状态为完成
8. 如果 autoDeliver=true继续执行投递流程:
8.1 从数据库获取刚搜索到的职位列表
- 筛选条件: applyStatus = 'pending', sn_code = 账号SN码
8.2 根据简历信息和过滤规则匹配职位
- 距离匹配(基于经纬度)
- 薪资匹配解析salaryDesc
- 工作年限匹配解析jobExperience
- 学历匹配解析jobDegree
- 权重评分
8.3 为每个匹配的职位生成投递指令
- command_type: 'apply_job'
- command_params: {
jobId: job.encryptJobId, // 职位ID必需
encryptBossId: job.encryptBossId, // Boss ID必需
securityId: job.securityId, // 安全ID必需从最新搜索结果获取
brandName: job.brandName, // 公司名称(可选)
jobTitle: job.jobName // 职位名称(可选)
}
8.4 批量执行投递指令(带间隔控制,避免频繁投递)
8.5 保存投递记录 (apply_records)
8.6 更新职位状态 (job_postings.applyStatus = 'applied')
9. 更新任务状态为完成
10. 返回任务信息(包含搜索到的职位数量和投递数量)
```
**说明**:
- 此接口支持两种模式:
- `autoDeliver=false`: 仅搜索,不投递。职位保存到数据库,状态为'pending'
- `autoDeliver=true`: 搜索完成后立即投递匹配的职位
- **重要**: 投递必须在搜索完成后立即执行,因为 `securityId` 等字段可能有时效性,前端页面变化后这些字段可能失效
- 不支持从已保存的职位中选择投递,因为职位信息可能已过期
## 📊 数据库字段说明
### job_postings 表需要完善的字段
| 字段名 | 类型 | 说明 | 状态 | 数据来源 |
|--------|------|------|------|----------|
| `city` | VARCHAR | 城市代码 | 待添加 | `job.city` |
| `cityName` | VARCHAR | 城市名称 | 待添加 | `job.cityName` |
| `areaDistrict` | VARCHAR | 区域 | 待添加 | `job.areaDistrict` |
| `businessDistrict` | VARCHAR | 商圈 | 待添加 | `job.businessDistrict` |
| `salaryMin` | INT | 最低薪资(元) | 待添加 | 从 `salaryDesc` 解析 |
| `salaryMax` | INT | 最高薪资(元) | 待添加 | 从 `salaryDesc` 解析 |
| `experienceMin` | INT | 最低工作年限 | 待添加 | 从 `jobExperience` 解析 |
| `experienceMax` | INT | 最高工作年限 | 待添加 | 从 `jobExperience` 解析 |
| `educationLevel` | VARCHAR | 学历等级 | 待添加 | 从 `jobDegree` 映射 |
| `matchScore` | DECIMAL | 匹配度评分 | 待添加 | 计算得出 |
| `encryptBossId` | VARCHAR | Boss ID | 已有 | `job.encryptBossId` |
| `securityId` | VARCHAR | 安全ID | 待添加 | `job.securityId`(投递必需) |
| `bossName` | VARCHAR | Boss姓名 | 待添加 | `job.bossName` |
| `bossTitle` | VARCHAR | Boss职位 | 待添加 | `job.bossTitle` |
| `bossOnline` | TINYINT | Boss是否在线 | 待添加 | `job.bossOnline` |
| `brandStage` | VARCHAR | 融资阶段 | 待添加 | `job.brandStageName` |
| `jobLabels` | JSON | 职位标签 | 待添加 | `job.jobLabels` |
| `skills` | JSON | 技能要求 | 待添加 | `job.skills` |
| `welfareList` | JSON | 福利列表 | 待添加 | `job.welfareList` |
| `isOutsourcing` | TINYINT | 是否外包 | 待添加 | `job.proxyJob` |
| `industry` | INT | 行业代码 | 待添加 | `job.industry` |
### 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: 优化职位数据保存 | 4小时 | 高 |
| 任务3: 完善自动投递任务搜索条件 | 2小时 | 高 |
| 任务4: 优化职位匹配算法 | 4小时 | 高 |
| 任务5: 创建搜索任务接口(支持可选投递) | 5小时 | 高 |
| 任务6: 完善指令类型映射 | 1小时 | 中 |
| 任务7: 添加搜索条件配置管理 | 1小时 | 低 |
**总计**: 约19小时
## 🚀 开发优先级
### 第一阶段(核心功能)
1. 任务1: 完善搜索参数支持
2. 任务2: 优化职位数据保存
3. 任务3: 完善自动投递任务搜索条件
4. 任务4: 优化职位匹配算法
5. 任务5: 创建搜索任务接口(支持可选投递)
### 第二阶段(接口完善)
6. 任务6: 完善指令类型映射
### 第三阶段(配置管理)
7. 任务7: 添加搜索条件配置管理
## 💡 使用场景说明
### 场景1: 仅搜索职位列表
```javascript
// 只搜索职位,不投递
const result = await plaAccountService.createSearchJobListTask({
id: accountId,
keyword: '全栈工程师',
searchParams: {
city: '101020100',
cityName: '上海',
salary: '20-30K',
experience: '3-5年',
education: '本科'
},
pageCount: 3,
autoDeliver: false // 不自动投递
});
// 返回: { taskId: 123, message: '搜索任务已创建', jobCount: 45 }
// 职位会自动保存到数据库,状态为 'pending'(待投递)
```
### 场景2: 搜索并自动投递(推荐)
```javascript
// 搜索职位并自动投递匹配的职位
const result = await plaAccountService.createSearchJobListTask({
id: accountId,
keyword: '全栈工程师',
searchParams: {
city: '101020100',
cityName: '上海',
salary: '20-30K',
experience: '3-5年',
education: '本科'
},
pageCount: 3,
autoDeliver: true, // 自动投递
filterRules: {
minSalary: 20000,
maxSalary: 30000,
keywords: ['Vue', 'React'],
excludeKeywords: ['外包', '外派']
},
maxCount: 10 // 最多投递10个职位
});
// 返回: { taskId: 123, message: '搜索并投递任务已创建', jobCount: 45, deliveredCount: 8 }
```
**重要说明**:
- **投递必须在搜索完成后立即执行**,因为 `securityId` 等字段可能有时效性
- 前端页面变化后,已保存的职位信息中的 `securityId` 可能失效,无法用于投递
- 因此不支持从已保存的职位中选择投递,必须在搜索后立即投递
- 如果只需要搜索不投递,设置 `autoDeliver: false`
- 如果需要搜索并投递,设置 `autoDeliver: true`,系统会根据匹配规则自动投递
## 📌 注意事项
1. **命名规范**: 统一使用下划线命名(`get_job_list` 而不是 `getJobList`
2. **错误处理**: 所有方法都需要完善的错误处理和日志记录
3. **数据验证**: 所有输入参数都需要验证
4. **性能优化**: 批量操作需要考虑性能,避免阻塞
5. **MQTT通信**: 确保指令参数格式正确,与客户端协议一致
6. **数据库事务**: 批量操作需要使用事务保证数据一致性
7. **投递时机**: 投递必须在搜索完成后立即执行,因为 `securityId` 等字段可能有时效性,前端页面变化后这些字段可能失效
8. **职位状态验证**: 投递前必须验证职位状态applyStatus = 'pending'),避免重复投递
9. **投递必需字段**: 投递时需要 `encryptJobId`、`encryptBossId` 和 `securityId`,这些字段必须从最新搜索结果中获取
10. **位置信息**: 优先使用响应中的 `gps` 字段避免不必要的API调用
11. **接口设计**: 搜索和投递在同一接口中完成,不支持单独的投递接口,因为已保存的职位信息可能已过期
## 🔗 相关文件
- `api/middleware/job/jobManager.js` - 工作管理核心逻辑
- `api/middleware/schedule/taskHandlers.js` - 任务处理器
- `api/middleware/schedule/command.js` - 指令管理器
- `api/services/pla_account_service.js` - 账号服务
- `api/model/job_postings.js` - 职位数据模型
- `api/model/pla_account.js` - 账号数据模型

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -144,14 +144,350 @@ class JobManager {
} }
/** /**
* 获取岗位列表 * 多条件搜索职位列表新指令使用新的MQTT action
* @param {string} sn_code - 设备SN码
* @param {object} mqttClient - MQTT客户端
* @param {object} params - 搜索参数
* @returns {Promise<object>} 搜索结果
*/
async search_jobs_with_params(sn_code, mqttClient, params = {}) {
const {
keyword = '前端',
platform = 'boss',
city = '',
cityName = '',
salary = '',
experience = '',
education = '',
industry = '',
companySize = '',
financingStage = '',
page = 1,
pageSize = 20,
pageCount = 3
} = params;
console.log(`[工作管理] 开始多条件搜索设备 ${sn_code} 的职位,关键词: ${keyword}, 城市: ${cityName || city}`);
// 构建完整的搜索参数对象
const searchData = {
keyword,
pageCount
};
// 添加可选搜索条件
if (city) searchData.city = city;
if (cityName) searchData.cityName = cityName;
if (salary) searchData.salary = salary;
if (experience) searchData.experience = experience;
if (education) searchData.education = education;
if (industry) searchData.industry = industry;
if (companySize) searchData.companySize = companySize;
if (financingStage) searchData.financingStage = financingStage;
if (page) searchData.page = page;
if (pageSize) searchData.pageSize = pageSize;
// 通过MQTT指令获取岗位列表使用新的action
const response = await mqttClient.publishAndWait(sn_code, {
platform,
action: "search_job_list", // 新的搜索action
data: searchData
});
if (!response || response.code !== 200) {
console.error(`[工作管理] 多条件搜索职位失败:`, response);
throw new Error('多条件搜索职位失败');
}
// 处理职位列表数据
let jobs = [];
if (Array.isArray(response.data)) {
for (const item of response.data) {
if (item.data?.zpData?.jobList && Array.isArray(item.data.zpData.jobList)) {
jobs = jobs.concat(item.data.zpData.jobList);
}
}
} else if (response.data?.data?.zpData?.jobList) {
jobs = response.data.data.zpData.jobList || [];
} else if (response.data?.zpData?.jobList) {
jobs = response.data.zpData.jobList || [];
}
console.log(`[工作管理] 成功获取岗位数据,共 ${jobs.length} 个岗位`);
// 保存职位到数据库
try {
await this.saveJobsToDatabase(sn_code, platform, keyword, jobs);
} catch (error) {
console.error(`[工作管理] 保存职位到数据库失败:`, error);
}
return {
jobs: jobs,
keyword: keyword,
platform: platform,
count: jobs.length
};
}
/**
* 搜索并投递职位(新指令)
* @param {string} sn_code - 设备SN码
* @param {object} mqttClient - MQTT客户端
* @param {object} params - 参数
* @returns {Promise<object>} 执行结果
*/
async search_and_deliver(sn_code, mqttClient, params = {}) {
const {
keyword,
searchParams = {},
pageCount = 3,
filterRules = {},
maxCount = 10,
platform = 'boss'
} = params;
console.log(`[工作管理] 开始搜索并投递职位,设备: ${sn_code}, 关键词: ${keyword}`);
// 1. 先执行搜索使用search_jobs_with_params新的搜索指令
const searchResult = await this.search_jobs_with_params(sn_code, mqttClient, {
keyword,
platform,
...searchParams,
pageCount
});
if (!searchResult || searchResult.count === 0) {
return {
success: true,
jobCount: 0,
deliveredCount: 0,
message: '未找到职位'
};
}
// 2. 等待数据保存完成
await new Promise(resolve => setTimeout(resolve, 2000));
// 3. 从数据库获取刚搜索到的职位
const job_postings = db.getModel('job_postings');
const searchedJobs = await job_postings.findAll({
where: {
sn_code: sn_code,
platform: platform,
applyStatus: 'pending',
keyword: keyword
},
order: [['create_time', 'DESC']],
limit: 1000
});
if (searchedJobs.length === 0) {
return {
success: true,
jobCount: searchResult.count,
deliveredCount: 0,
message: '未找到待投递的职位'
};
}
// 4. 获取简历信息用于匹配
const resume_info = db.getModel('resume_info');
const resume = await resume_info.findOne({
where: {
sn_code: sn_code,
platform: platform,
isActive: true
},
order: [['last_modify_time', 'DESC']]
});
if (!resume) {
return {
success: true,
jobCount: searchResult.count,
deliveredCount: 0,
message: '未找到活跃简历,无法投递'
};
}
// 5. 获取账号配置
const pla_account = db.getModel('pla_account');
const account = await pla_account.findOne({
where: { sn_code, platform_type: platform }
});
if (!account) {
throw new Error('账号不存在');
}
const accountConfig = account.toJSON();
const resumeData = resume.toJSON();
// 6. 使用过滤方法进行职位匹配
const matchedJobs = await this.filter_jobs_by_rules(searchedJobs, {
minSalary: filterRules.minSalary || 0,
maxSalary: filterRules.maxSalary || 0,
keywords: filterRules.keywords || [],
excludeKeywords: filterRules.excludeKeywords || [],
accountConfig: accountConfig,
resumeInfo: resumeData
});
// 7. 限制投递数量
const jobsToDeliver = matchedJobs.slice(0, maxCount);
console.log(`[工作管理] 匹配到 ${matchedJobs.length} 个职位,将投递 ${jobsToDeliver.length}`);
// 8. 执行投递
let deliveredCount = 0;
const apply_records = db.getModel('apply_records');
for (let i = 0; i < jobsToDeliver.length; i++) {
const job = jobsToDeliver[i];
const jobData = job.toJSON ? job.toJSON() : job;
try {
// 从原始数据中获取 securityId
let securityId = jobData.securityId || '';
try {
if (jobData.originalData) {
const originalData = typeof jobData.originalData === 'string'
? JSON.parse(jobData.originalData)
: jobData.originalData;
securityId = originalData.securityId || securityId;
}
} catch (e) {
console.warn(`[工作管理] 解析职位原始数据失败:`, e);
}
// 执行投递使用新的deliver_resume_search action
const deliverResult = await this.deliver_resume(sn_code, mqttClient, {
jobId: jobData.jobId,
encryptBossId: jobData.encryptBossId || '',
securityId: securityId,
brandName: jobData.companyName || '',
jobTitle: jobData.jobTitle || '',
companyName: jobData.companyName || '',
platform: platform,
action: 'deliver_resume_search' // 搜索并投递使用新的action
});
if (deliverResult && deliverResult.success) {
deliveredCount++;
}
// 投递间隔控制
if (i < jobsToDeliver.length - 1) {
await new Promise(resolve => setTimeout(resolve, 3000));
}
} catch (error) {
console.error(`[工作管理] 投递职位失败:`, error);
// 继续投递下一个职位
}
}
return {
success: true,
jobCount: searchResult.count,
deliveredCount: deliveredCount,
message: `搜索完成,找到 ${searchResult.count} 个职位,成功投递 ${deliveredCount}`
};
}
/**
* 获取岗位列表(支持多条件搜索)
* @param {string} sn_code - 设备SN码 * @param {string} sn_code - 设备SN码
* @param {object} mqttClient - MQTT客户端 * @param {object} mqttClient - MQTT客户端
* @param {object} params - 参数 * @param {object} params - 参数
* @returns {Promise<object>} 岗位列表 * @returns {Promise<object>} 岗位列表
*/ */
async get_job_list(sn_code, mqttClient, params = {}) { async get_job_list(sn_code, mqttClient, params = {}) {
const { keyword = '前端', platform = 'boss', pageCount = 3 } = params; const {
keyword = '前端',
platform = 'boss',
pageCount = 3,
city = '',
cityName = '',
salary = '',
experience = '',
education = '',
industry = '',
companySize = '',
financingStage = '',
page = 1,
pageSize = 20
} = params;
// 判断是否是多条件搜索(如果包含多条件参数,使用多条件搜索逻辑)
const hasMultiParams = city || cityName || salary || experience || education ||
industry || companySize || financingStage || page || pageSize;
if (hasMultiParams) {
// 使用多条件搜索逻辑
console.log(`[工作管理] 开始多条件搜索设备 ${sn_code} 的职位,关键词: ${keyword}, 城市: ${cityName || city}`);
// 构建完整的搜索参数对象
const searchData = {
keyword,
pageCount
};
// 添加可选搜索条件
if (city) searchData.city = city;
if (cityName) searchData.cityName = cityName;
if (salary) searchData.salary = salary;
if (experience) searchData.experience = experience;
if (education) searchData.education = education;
if (industry) searchData.industry = industry;
if (companySize) searchData.companySize = companySize;
if (financingStage) searchData.financingStage = financingStage;
if (page) searchData.page = page;
if (pageSize) searchData.pageSize = pageSize;
// 通过MQTT指令获取岗位列表保持action不变前端已使用
const response = await mqttClient.publishAndWait(sn_code, {
platform,
action: "get_job_list", // 保持与原有get_job_list相同的action前端已使用
data: searchData
});
if (!response || response.code !== 200) {
console.error(`[工作管理] 多条件搜索职位失败:`, response);
throw new Error('多条件搜索职位失败');
}
// 处理职位列表数据
let jobs = [];
if (Array.isArray(response.data)) {
for (const item of response.data) {
if (item.data?.zpData?.jobList && Array.isArray(item.data.zpData.jobList)) {
jobs = jobs.concat(item.data.zpData.jobList);
}
}
} else if (response.data?.data?.zpData?.jobList) {
jobs = response.data.data.zpData.jobList || [];
} else if (response.data?.zpData?.jobList) {
jobs = response.data.zpData.jobList || [];
}
console.log(`[工作管理] 成功获取岗位数据,共 ${jobs.length} 个岗位`);
// 保存职位到数据库
try {
await this.saveJobsToDatabase(sn_code, platform, keyword, jobs);
} catch (error) {
console.error(`[工作管理] 保存职位到数据库失败:`, error);
}
return {
jobs: jobs,
keyword: keyword,
platform: platform,
count: jobs.length
};
}
// 简单搜索逻辑(保持原有逻辑)
console.log(`[工作管理] 开始获取设备 ${sn_code} 的岗位列表,关键词: ${keyword}`); console.log(`[工作管理] 开始获取设备 ${sn_code} 的岗位列表,关键词: ${keyword}`);
// 通过MQTT指令获取岗位列表 // 通过MQTT指令获取岗位列表
@@ -320,10 +656,11 @@ class JobManager {
* @param {string} params.brandName - 公司名称(可选) * @param {string} params.brandName - 公司名称(可选)
* @param {string} params.jobTitle - 职位标题(可选) * @param {string} params.jobTitle - 职位标题(可选)
* @param {string} params.companyName - 公司名称(可选) * @param {string} params.companyName - 公司名称(可选)
* @param {string} params.action - MQTT Action默认deliver_resume可选deliver_resume_search
* @returns {Promise<object>} 投递结果 * @returns {Promise<object>} 投递结果
*/ */
async applyJob(sn_code, mqttClient, params = {}) { async deliver_resume(sn_code, mqttClient, params = {}) {
const { platform = 'boss', jobId, encryptBossId, securityId, brandName, jobTitle, companyName } = params; const { platform = 'boss', jobId, encryptBossId, securityId, brandName, jobTitle, companyName, action = 'deliver_resume' } = params;
if (!jobId) { if (!jobId) {
throw new Error('jobId 参数不能为空请指定要投递的职位ID'); throw new Error('jobId 参数不能为空请指定要投递的职位ID');
@@ -401,10 +738,10 @@ class JobManager {
console.log(`[工作管理] 投递职位: ${jobData.jobTitle} @ ${jobData.companyName}`); console.log(`[工作管理] 投递职位: ${jobData.jobTitle} @ ${jobData.companyName}`);
// 通过MQTT指令投递简历 // 通过MQTT指令投递简历支持自定义action
const response = await mqttClient.publishAndWait(sn_code, { const response = await mqttClient.publishAndWait(sn_code, {
platform, platform,
action: "deliver_resume", action: action, // 使用传入的action参数默认为"deliver_resume"
data: { data: {
encryptJobId: jobData.jobId, encryptJobId: jobData.jobId,
securityId: jobData.securityId || securityId || '', securityId: jobData.securityId || securityId || '',

View File

@@ -230,12 +230,41 @@ class CommandManager {
// 构建指令执行 Promise // 构建指令执行 Promise
const command_promise = (async () => { const command_promise = (async () => {
// 指令类型映射表(内部指令类型 -> jobManager方法名
const commandMethodMap = {
// get_job_list 指令对应MQTT Action: "get_job_list"
'get_job_list': 'get_job_list',
'getJobList': 'get_job_list',
// search_jobs_with_params 指令对应MQTT Action: "search_job_list"
'search_jobs_with_params': 'search_jobs_with_params',
'searchJobsWithParams': 'search_jobs_with_params',
// search_and_deliver 指令内部调用search_jobs_with_params和deliver_resume
'search_and_deliver': 'search_and_deliver',
'searchAndDeliver': 'search_and_deliver',
// deliver_resume 指令对应MQTT Action: "deliver_resume"
'deliver_resume': 'deliver_resume',
'deliverResume': 'deliver_resume'
// search_jobs 指令对应MQTT Action: "search_jobs"
'search_jobs': 'search_jobs',
'searchJobs': 'search_jobs'
};
// 优先使用映射表
const mappedMethod = commandMethodMap[command_type] || commandMethodMap[method_name];
if (mappedMethod && jobManager[mappedMethod]) {
return await jobManager[mappedMethod](sn_code, mqttClient, command_params);
}
// 其次尝试转换后的方法名
if (command_type && jobManager[method_name]) { if (command_type && jobManager[method_name]) {
return await jobManager[method_name](sn_code, mqttClient, command_params); return await jobManager[method_name](sn_code, mqttClient, command_params);
} else if (jobManager[command_type]) { }
// 最后尝试原始指令类型
if (jobManager[command_type]) {
return await jobManager[command_type](sn_code, mqttClient, command_params); return await jobManager[command_type](sn_code, mqttClient, command_params);
} else { } else {
throw new Error(`未知的指令类型: ${command_type} (尝试的方法名: ${method_name})`); throw new Error(`未知的指令类型: ${command_type} (尝试的方法名: ${method_name}, 映射方法: ${mappedMethod})`);
} }
})(); })();

View File

@@ -214,7 +214,7 @@ class DeviceWorkStatusNotifier {
return `投递职位: ${parsedParams.jobTitle} @ ${companyName}`; return `投递职位: ${parsedParams.jobTitle} @ ${companyName}`;
} else if (parsedParams.jobTitle) { } else if (parsedParams.jobTitle) {
return `投递职位: ${parsedParams.jobTitle}`; return `投递职位: ${parsedParams.jobTitle}`;
} else if (commandType === 'applyJob' || commandName.includes('投递')) { } else if (commandType === 'deliver_resume' || commandName.includes('投递')) {
return '投递简历'; return '投递简历';
} else if (commandType === 'searchJobs' || commandName.includes('搜索')) { } else if (commandType === 'searchJobs' || commandName.includes('搜索')) {
return `搜索职位: ${parsedParams.keyword || ''}`; return `搜索职位: ${parsedParams.keyword || ''}`;

View File

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

View File

@@ -24,6 +24,11 @@ class TaskHandlers {
return await this.handleAutoDeliverTask(task); return await this.handleAutoDeliverTask(task);
}); });
// 搜索职位列表任务(新功能)
taskQueue.registerHandler('search_jobs', async (task) => {
return await this.handleSearchJobListTask(task);
});
// 自动沟通任务(待实现) // 自动沟通任务(待实现)
taskQueue.registerHandler('auto_chat', async (task) => { taskQueue.registerHandler('auto_chat', async (task) => {
return await this.handleAutoChatTask(task); return await this.handleAutoChatTask(task);
@@ -469,7 +474,7 @@ class TaskHandlers {
for (const jobData of jobsToDeliver) { for (const jobData of jobsToDeliver) {
console.log(`[任务处理器] 准备投递职位: ${jobData.jobTitle} @ ${jobData.companyName}, 评分: ${jobData.matchScore}`, jobData.scoreDetails); console.log(`[任务处理器] 准备投递职位: ${jobData.jobTitle} @ ${jobData.companyName}, 评分: ${jobData.matchScore}`, jobData.scoreDetails);
deliverCommands.push({ deliverCommands.push({
command_type: 'applyJob', command_type: 'deliver_resume', // 与MQTT Action保持一致
command_name: `投递简历 - ${jobData.jobTitle} @ ${jobData.companyName} (评分:${jobData.matchScore})`, command_name: `投递简历 - ${jobData.jobTitle} @ ${jobData.companyName} (评分:${jobData.matchScore})`,
command_params: JSON.stringify({ command_params: JSON.stringify({
sn_code: sn_code, sn_code: sn_code,
@@ -547,6 +552,180 @@ class TaskHandlers {
return { allowed: true, reason: '在允许的时间范围内' }; return { allowed: true, reason: '在允许的时间范围内' };
} }
/**
* 处理搜索职位列表任务(新功能)
* 支持多条件搜索和可选投递
* @param {object} task - 任务对象
* @returns {Promise<object>} 执行结果
*/
async handleSearchJobListTask(task) {
const { sn_code, taskParams } = task;
const {
keyword,
searchParams = {},
pageCount = 3,
autoDeliver = false,
filterRules = {},
maxCount = 10
} = taskParams;
console.log(`[任务处理器] 搜索职位列表任务 - 设备: ${sn_code}, 关键词: ${keyword}, 自动投递: ${autoDeliver}`);
// 检查授权状态
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,
jobCount: 0,
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 apply_records = db.getModel('apply_records');
const Sequelize = require('sequelize');
// 1. 获取账号配置
const account = await pla_account.findOne({
where: { sn_code, platform_type: taskParams.platform || 'boss' }
});
if (!account) {
throw new Error('账号不存在');
}
const accountConfig = account.toJSON();
// 2. 从账号配置中读取搜索条件
const searchConfig = accountConfig.search_config
? (typeof accountConfig.search_config === 'string'
? JSON.parse(accountConfig.search_config)
: accountConfig.search_config)
: {};
// 3. 构建完整的搜索参数(任务参数优先,其次账号配置)
const searchCommandParams = {
sn_code: sn_code,
platform: taskParams.platform || accountConfig.platform_type || 'boss',
keyword: keyword || accountConfig.keyword || searchConfig.keyword || '',
city: searchParams.city || accountConfig.city || searchConfig.city || '',
cityName: searchParams.cityName || accountConfig.cityName || searchConfig.cityName || '',
salary: searchParams.salary || searchConfig.defaultSalary || '',
experience: searchParams.experience || searchConfig.defaultExperience || '',
education: searchParams.education || searchConfig.defaultEducation || '',
industry: searchParams.industry || searchConfig.industry || '',
companySize: searchParams.companySize || searchConfig.companySize || '',
financingStage: searchParams.financingStage || searchConfig.financingStage || '',
page: 1,
pageSize: 20,
pageCount: pageCount
};
// 4. 根据是否投递选择不同的指令
let searchCommand;
if (autoDeliver) {
// 使用搜索并投递指令
searchCommand = {
command_type: 'search_and_deliver',
command_name: '搜索并投递职位',
command_params: JSON.stringify({
keyword: searchCommandParams.keyword,
searchParams: {
city: searchCommandParams.city,
cityName: searchCommandParams.cityName,
salary: searchCommandParams.salary,
experience: searchCommandParams.experience,
education: searchCommandParams.education,
industry: searchCommandParams.industry,
companySize: searchCommandParams.companySize,
financingStage: searchCommandParams.financingStage,
page: searchCommandParams.page,
pageSize: searchCommandParams.pageSize,
pageCount: searchCommandParams.pageCount
},
filterRules: filterRules,
maxCount: maxCount,
platform: searchCommandParams.platform
}),
priority: config.getTaskPriority('search_and_deliver') || 5,
sequence: 1
};
} else {
// 使用多条件搜索指令新的指令类型使用新的MQTT action
searchCommand = {
command_type: 'search_jobs_with_params', // 新的指令类型
command_name: '多条件搜索职位列表',
command_params: JSON.stringify(searchCommandParams), // 包含多条件参数
priority: config.getTaskPriority('search_jobs_with_params') || 5,
sequence: 1
};
}
// 5. 执行指令
const commandResult = await command.executeCommands(task.id, [searchCommand], this.mqttClient);
// 6. 处理执行结果
let jobCount = 0;
let deliveredCount = 0;
if (autoDeliver) {
// 如果使用 search_and_deliver 指令,结果中已包含投递信息
if (commandResult && commandResult.results && commandResult.results.length > 0) {
const result = commandResult.results[0].result;
if (result) {
jobCount = result.jobCount || 0;
deliveredCount = result.deliveredCount || 0;
}
}
} else {
// 如果使用 search_jobs_with_params 指令,等待搜索完成并从数据库获取结果
await new Promise(resolve => setTimeout(resolve, 2000));
const searchedJobs = await job_postings.findAll({
where: {
sn_code: sn_code,
platform: searchCommandParams.platform,
applyStatus: 'pending',
keyword: searchCommandParams.keyword
},
order: [['create_time', 'DESC']],
limit: 1000
});
jobCount = searchedJobs.length;
}
const duration = Date.now() - startTime;
deviceManager.recordTaskComplete(sn_code, task, true, duration);
console.log(`[任务处理器] 搜索职位列表任务完成 - 设备: ${sn_code}, 找到 ${jobCount} 个职位, 投递 ${deliveredCount} 个, 耗时: ${duration}ms`);
return {
success: true,
jobCount: jobCount,
deliveredCount: deliveredCount,
message: autoDeliver
? `搜索完成,找到 ${jobCount} 个职位,成功投递 ${deliveredCount}`
: `搜索完成,找到 ${jobCount} 个职位`
};
} catch (error) {
const duration = Date.now() - startTime;
deviceManager.recordTaskComplete(sn_code, task, false, duration);
console.error(`[任务处理器] 搜索职位列表任务失败 - 设备: ${sn_code}:`, error);
throw error;
}
}
/** /**
* 处理自动沟通任务(待实现) * 处理自动沟通任务(待实现)
* 功能自动与HR进行沟通回复消息等 * 功能自动与HR进行沟通回复消息等

View File

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

View File

@@ -680,13 +680,120 @@ class PlaAccountService {
} }
}); });
const taskId = await scheduleManager.taskQueue.addTask(account.sn_code, {
taskType: taskType,
taskName: `手动任务 - ${taskName}`,
taskParams: {
keyword: account.keyword,
platform: account.platform_type
}
});
return { return {
message: '任务已添加到队列', message: '任务已添加到队列',
taskId: task.id taskId: taskId
}; };
}
/**
* 创建搜索职位列表任务(支持可选投递)
* @param {Object} params - 任务参数
* @param {number} params.id - 账号ID
* @param {string} params.keyword - 搜索关键词
* @param {Object} params.searchParams - 搜索条件(城市、薪资、经验、学历等)
* @param {number} params.pageCount - 获取页数
* @param {boolean} params.autoDeliver - 是否自动投递默认false
* @param {Object} params.filterRules - 过滤规则autoDeliver=true时使用
* @param {number} params.maxCount - 最大投递数量autoDeliver=true时使用
* @returns {Promise<Object>} 任务创建结果 { taskId, message, jobCount, deliveredCount }
*/
async createSearchJobListTask(params) {
const pla_account = db.getModel('pla_account');
const task_status = db.getModel('task_status');
const {
id,
keyword,
searchParams = {},
pageCount = 3,
autoDeliver = false,
filterRules = {},
maxCount = 10
} = params;
// 1. 验证账号和授权
if (!id) {
throw new Error('账号ID不能为空');
}
const account = await pla_account.findByPk(id);
if (!account) {
throw new Error('账号不存在');
}
// 检查账号是否启用
if (!account.is_enabled) {
throw new Error('账号未启用,无法执行任务');
}
// 检查授权状态
const authCheck = await authorizationService.checkAuthorization(id, 'id');
if (!authCheck.is_authorized) {
throw new Error(authCheck.message);
}
// 检查MQTT客户端
if (!scheduleManager.mqttClient) {
throw new Error('MQTT客户端未初始化');
}
const sn_code = account.sn_code;
const platform = account.platform_type || 'boss';
// 2. 创建任务记录(使用新的搜索任务类型)
const taskType = 'search_jobs';
const taskName = autoDeliver ? '搜索并投递职位' : '搜索职位列表';
const task = await task_status.create({
sn_code: sn_code,
taskType: taskType,
taskName: taskName,
taskParams: JSON.stringify({
keyword: keyword || account.keyword || '',
searchParams: searchParams,
pageCount: pageCount,
autoDeliver: autoDeliver,
filterRules: filterRules,
maxCount: maxCount,
platform: platform
}),
status: 'pending',
progress: 0
});
console.log(`[账号服务] 创建搜索任务: ${taskName} (ID: ${task.id}, 设备: ${sn_code})`);
// 3. 将任务添加到队列,由 handleSearchJobListTask 处理
const taskId = await scheduleManager.taskQueue.addTask(sn_code, {
taskType: taskType,
taskName: taskName,
taskParams: {
keyword: keyword || account.keyword || '',
searchParams: searchParams,
pageCount: pageCount,
autoDeliver: autoDeliver,
filterRules: filterRules,
maxCount: maxCount,
platform: platform
}
});
return {
taskId: taskId,
message: `搜索任务已创建任务ID: ${taskId}`,
jobCount: 0,
deliveredCount: 0
};
} }
/** /**

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@@ -0,0 +1,138 @@
/**
* 添加"价格套餐管理"菜单项到用户管理菜单下
* 执行 SQL 插入操作
*/
const Framework = require('../framework/node-core-framework.js');
const frameworkConfig = require('../config/framework.config.js');
async function addPricingPlansMenu() {
console.log('🔄 开始添加"价格套餐管理"菜单项...\n');
try {
// 初始化框架
console.log('正在初始化框架...');
const framework = await Framework.init(frameworkConfig);
const models = Framework.getModels();
if (!models) {
throw new Error('无法获取模型列表');
}
// 从任意模型获取 sequelize 实例
const Sequelize = require('sequelize');
const firstModel = Object.values(models)[0];
if (!firstModel || !firstModel.sequelize) {
throw new Error('无法获取数据库连接');
}
const sequelize = firstModel.sequelize;
// 查找用户管理菜单的ID
const [userMenu] = await sequelize.query(
`SELECT id FROM sys_menu WHERE name = '用户管理' AND parent_id = 0 AND is_delete = 0 LIMIT 1`,
{ type: Sequelize.QueryTypes.SELECT }
);
let parentId = 120; // 默认 fallback 值
if (userMenu && userMenu.id) {
parentId = userMenu.id;
console.log(`找到用户管理菜单ID: ${parentId}`);
} else {
console.log(`未找到用户管理菜单,使用默认 parent_id: ${parentId}`);
}
// 检查是否已存在
const [existing] = await sequelize.query(
`SELECT id, name FROM sys_menu WHERE path = 'pricing_plans' AND is_delete = 0`,
{ type: Sequelize.QueryTypes.SELECT }
);
if (existing) {
console.log(`⚠️ 菜单项已存在 (ID: ${existing.id}, 名称: ${existing.name})`);
console.log('✅ 无需重复添加\n');
return;
}
// 获取最大排序值
const [maxSort] = await sequelize.query(
`SELECT MAX(sort) as maxSort FROM sys_menu WHERE parent_id = ${parentId} AND is_delete = 0`,
{ type: Sequelize.QueryTypes.SELECT }
);
const nextSort = (maxSort && maxSort.maxSort ? maxSort.maxSort : 0) + 1;
// 执行插入
await sequelize.query(
`INSERT INTO sys_menu (
name,
parent_id,
model_id,
form_id,
icon,
path,
component,
api_path,
is_show_menu,
is_show,
type,
sort,
create_time,
last_modify_time,
is_delete
) VALUES (
'价格套餐管理',
${parentId},
0,
0,
'md-pricetags',
'pricing_plans',
'system/pricing_plans.vue',
'system/pricing_plans_server.js',
1,
1,
'页面',
${nextSort},
NOW(),
NOW(),
0
)`,
{ type: Sequelize.QueryTypes.INSERT }
);
console.log('✅ "价格套餐管理"菜单项添加成功!\n');
// 验证插入结果
const [menu] = await sequelize.query(
`SELECT id, name, parent_id, path, component, api_path, sort
FROM sys_menu
WHERE path = 'pricing_plans' AND is_delete = 0`,
{ type: Sequelize.QueryTypes.SELECT }
);
if (menu) {
console.log('📋 菜单项详情:');
console.log(` ID: ${menu.id}`);
console.log(` 名称: ${menu.name}`);
console.log(` 父菜单ID: ${menu.parent_id}`);
console.log(` 路由路径: ${menu.path}`);
console.log(` 组件路径: ${menu.component}`);
console.log(` API路径: ${menu.api_path}`);
console.log(` 排序: ${menu.sort}\n`);
}
} catch (error) {
console.error('❌ 添加失败:', error.message);
console.error('\n详细错误:', error);
throw error;
}
}
// 执行添加
addPricingPlansMenu()
.then(() => {
console.log('✨ 操作完成!');
process.exit(0);
})
.catch(error => {
console.error('\n💥 执行失败:', error);
process.exit(1);
});

View File

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