Compare commits

...

54 Commits

Author SHA1 Message Date
张成
933f1618ca 1 2025-12-25 23:01:21 +08:00
张成
c43bf79e2d 1 2025-12-25 22:51:37 +08:00
张成
55ef28440a 1 2025-12-25 22:31:55 +08:00
张成
7ee92b8905 1 2025-12-25 22:11:34 +08:00
张成
c6c78d0c43 1 2025-12-25 13:10:54 +08:00
张成
36465d81e3 1 2025-12-24 23:33:56 +08:00
张成
53e9f5b2e9 1 2025-12-24 23:28:54 +08:00
张成
cde30bbd59 1 2025-12-24 23:17:22 +08:00
张成
6b7ce7c9aa 1 2025-12-24 23:02:23 +08:00
张成
db574986dd 1 2025-12-24 22:55:01 +08:00
张成
5acf0a52cc 1 2025-12-24 22:47:18 +08:00
张成
8f4e2df19b 1 2025-12-24 22:15:46 +08:00
张成
83d4b6b536 1 2025-12-24 21:27:37 +08:00
张成
c0bedfa4aa 1 2025-12-24 20:33:26 +08:00
张成
bd4f900790 1 2025-12-24 20:28:46 +08:00
张成
9ca053e755 1 2025-12-24 18:35:38 +08:00
张成
b3fd370f39 1 2025-12-24 15:31:25 +08:00
张成
ed4416ddb4 1 2025-12-24 15:21:54 +08:00
张成
3d95b1da28 1 2025-12-23 17:11:10 +08:00
张成
0ff985f606 1 2025-12-22 18:18:09 +08:00
张成
cd56eb7886 1 2025-12-22 18:15:05 +08:00
张成
44acef5f7b 1 2025-12-22 18:07:17 +08:00
张成
126e983414 1 2025-12-22 18:02:06 +08:00
张成
4f3ea34334 1 2025-12-22 17:39:46 +08:00
张成
9ad2fd2992 1 2025-12-22 16:59:12 +08:00
张成
e673a68173 1 2025-12-22 16:47:22 +08:00
张成
fcfdd9bea2 1 2025-12-22 16:46:01 +08:00
张成
354dd2c22b 1 2025-12-22 16:31:03 +08:00
张成
e17d5610f5 1 2025-12-22 16:26:59 +08:00
张成
aa2d03ee30 1 2025-12-21 19:30:51 +08:00
张成
7826cfcd72 1 2025-12-20 00:52:01 +08:00
张成
60f1c5e628 11 2025-12-20 00:07:32 +08:00
张成
6cdc0120d2 41 2025-12-19 23:50:39 +08:00
张成
264df638af 1 2025-12-19 23:41:28 +08:00
张成
582463463d 1 2025-12-19 23:25:36 +08:00
张成
8f6e7e7a97 1 2025-12-19 23:02:15 +08:00
张成
10aff2f266 1 2025-12-19 22:24:23 +08:00
张成
abe2ae3c3a 1 2025-12-19 21:53:39 +08:00
张成
bccd2d31d2 1 2025-12-19 20:22:47 +08:00
张成
155abf2381 1 2025-12-19 17:44:29 +08:00
张成
a549d39f61 1 2025-12-19 17:23:59 +08:00
张成
46ba6e12c3 1 2025-12-19 16:45:42 +08:00
张成
5e04c591d6 1 2025-12-19 16:30:26 +08:00
张成
cfbcbc39fd 11 2025-12-19 15:54:52 +08:00
张成
4db1f77536 1 2025-12-19 14:32:43 +08:00
张成
b0c9a065a2 1 2025-12-19 14:29:05 +08:00
张成
8a44c48ad4 1 2025-12-19 14:21:22 +08:00
张成
7d4aee1c77 11 2025-12-19 14:14:05 +08:00
张成
eca314e686 1 2025-12-19 13:24:12 +08:00
张成
34ebad316a 1 2025-12-19 11:40:25 +08:00
张成
f233f368ed 1 2025-12-18 18:46:29 +08:00
张成
73a0999e20 1 2025-12-18 18:33:12 +08:00
张成
891dfc5777 1 2025-12-18 18:32:11 +08:00
张成
699c1d4f55 1 2025-12-18 18:18:43 +08:00
85 changed files with 39764 additions and 3352 deletions

15
.vscode/launch.json vendored
View File

@@ -9,7 +9,7 @@
"request": "launch",
"name": "后端调试",
"skipFiles": [
"<node_internals>/**",
"<node_internals>/**"
],
"resolveSourceMapLocations":[
"${workspaceFolder}/",
@@ -28,7 +28,16 @@
"npm run dev"
],
"cwd": "${workspaceFolder}/admin"
},
}
],
"compounds": [
{
"name": "启动全部(后端+前端)",
"configurations": [
"后端调试",
"后端前端"
],
"stopAll": true
}
]
}

View File

@@ -4,6 +4,8 @@
根据需求AI 接入功能暂时禁用,作为二期规划。当前使用简单的文本匹配来实现职位过滤功能。
## ✅ 已完成的修改
### 1. 创建文本匹配过滤服务

View File

@@ -1,235 +0,0 @@
# 指令流程映射关系文档
## 📋 完整的指令执行流程
本文档说明从 Admin 前端到 boss-automation-nodejs 的完整指令映射关系。
## 🔄 执行流程图
```
Admin 前端 (pla_account_detail.vue)
↓ commandType
plaAccountServer.runCommand()
↓ HTTP POST
后端 API (pla_account.js)
↓ taskType
taskQueue.addTask()
taskQueue.getTaskCommands()
↓ command_type
command.executeCommand()
jobManager[command_type]()
↓ MQTT action
boss-automation-nodejs
BossService[action]()
```
## 📊 完整映射表
| Admin commandType | taskType | command_type | MQTT action | Boss 方法 | 说明 |
|------------------|----------|--------------|-------------|-----------|------|
| `get_login_qr_code` | `get_login_qr_code` | `getLoginQrCode` | `get_login_qr_code` | `get_login_qr_code()` | 获取登录二维码 |
| `openBotDetection` | `openBotDetection` | `openBotDetection` | `openBotDetection` | `openBotDetection()` | 打开测试页 |
| `get_resume` | `get_resume` | `getOnlineResume` | `get_resume` | `get_resume()` | 获取用户简历 |
| `get_user_info` | `get_user_info` | `getUserInfo` | `get_user_info` | `get_user_info()` | 获取用户信息 |
| `search_jobs` | `search_jobs` | `searchJob` | `search_jobs` | `search_jobs()` | 搜索岗位 |
| `getJobList` | `getJobList` | `getJobList` | `getJobList` | `getJobList()` | 获取岗位列表 |
| `getChatList` | `getChatList` | `getChatList` | `getChatList` | `getChatList()` | 获取聊天列表 |
## 🔍 详细说明
### 1. Admin 前端 (commandType)
`pla_account_detail.vue` 中定义的操作类型:
```javascript
actionMenuList: [
{
value: 'get_login_qr_code',
label: '用户登录',
commandType: 'get_login_qr_code',
commandName: '获取登录二维码'
},
{
value: 'getJobList',
label: '岗位列表',
commandType: 'getJobList',
commandName: '获取岗位列表'
},
// ...
]
```
### 2. 后端 API (taskType)
`pla_account.js` 中,`commandType` 直接作为 `taskType` 传递:
```javascript
const task = await task_status.create({
sn_code: account.sn_code,
taskType: commandConfig.type, // 使用 commandType
taskName: commandName || commandConfig.name,
taskParams: JSON.stringify(finalParams)
});
```
### 3. 任务队列 (command_type)
`taskQueue.js``getTaskCommands()` 方法中,将 `taskType` 映射为 `command_type`
```javascript
async getTaskCommands(task) {
const { taskType, taskParams } = task;
switch (taskType) {
case 'get_login_qr_code':
return [{
command_type: 'getLoginQrCode', // jobManager 方法名
command_name: '获取登录二维码',
command_params: JSON.stringify({ platform })
}];
case 'getJobList':
return [{
command_type: 'getJobList', // jobManager 方法名
command_name: '获取岗位列表',
command_params: JSON.stringify({ keyword, platform })
}];
// ...
}
}
```
### 4. 指令管理器 (jobManager 方法)
`command.js` 中调用 `jobManager` 的方法:
```javascript
async executeCommand(taskId, command, mqttClient) {
const commandType = command.command_type;
const commandParams = JSON.parse(command.command_params);
// 调用 jobManager 的方法
if (commandType && jobManager[commandType]) {
result = await jobManager[commandType](sn_code, mqttClient, commandParams);
}
}
```
### 5. MQTT 通信 (action)
`jobManager.js` 中,通过 MQTT 发送指令:
```javascript
async getJobList(sn_code, mqttClient, params = {}) {
const response = await mqttClient.publishAndWait(sn_code, {
platform: 'boss',
action: "getJobList", // MQTT action对应 Boss 方法名
data: { keyword, pageCount }
});
return response.data;
}
```
### 6. Boss 模块执行
`boss-automation-nodejs` 中,根据 `action` 调用对应方法:
```javascript
// modules/index.js
async executeAction(platform, action, data) {
const modules = this.getModules();
// 调用 BossService[action]
let result = await modules[platform][action](data);
return result;
}
```
## ⚠️ 关键注意事项
### 1. 命名一致性
- **Admin commandType** → 用户界面显示的操作类型
- **taskType** → 任务类型,与 commandType 相同
- **command_type** → jobManager 中的方法名(驼峰命名)
- **MQTT action** → Boss 模块中的方法名(可能有别名)
### 2. 参数传递
参数在整个流程中的传递:
```javascript
// Admin 前端
commandParams: { keyword: '前端', platform: 'boss' }
// taskParams
taskParams: { keyword: '前端', platform: 'boss' }
// command_params
command_params: '{"keyword":"前端","platform":"boss"}'
// jobManager 方法参数
params: { keyword: '前端', platform: 'boss' }
// MQTT data
data: { keyword: '前端', pageCount: 3 }
// Boss 方法参数
data: { keyword: '前端', pageCount: 3 }
```
### 3. 别名支持
Boss 模块中的别名方法:
```javascript
// BossService 类中
async openBotDetection(data) {
return this.open_bot_detection(data);
}
async get_resume(data) {
return this.getOnlineResume(data);
}
async search_jobs(data) {
return this.searchJob(data);
}
```
## 🐛 常见问题
### Q1: 提示"未知的指令类型"
**原因:** `command_type``jobManager` 中不存在
**解决:**
1. 检查 `taskQueue.js` 中的 `getTaskCommands()` 方法
2. 确保 `command_type``jobManager` 中的方法名一致
### Q2: MQTT 消息发送失败
**原因:** `action` 在 Boss 模块中不存在
**解决:**
1. 检查 `jobManager.js` 中的 MQTT action
2. 确保 Boss 模块中有对应的方法或别名
### Q3: 参数传递错误
**原因:** 参数格式不正确
**解决:**
1. 确保 `command_params` 是 JSON 字符串
2.`jobManager` 中正确解析参数
3. 在 MQTT 消息中正确传递参数
---
**创建时间**: 2025-11-13
**作者**: Augment Agent

968
_doc/MQTT指令列表.md Normal file
View File

@@ -0,0 +1,968 @@
# 自动找工作系统 - 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

View File

@@ -1,686 +0,0 @@
徽标
工单管理
合并请求
里程碑
探索
zc
/
admin_core
代码
工单
合并请求
Actions
软件包
项目
版本发布
百科
动态
设置
文件
使用说明.md
完整使用文档.md
快速开始.md
.gitignore
README.md
babel.config.js
package-lock.json
package.json
postcss.config.js
webpack.config.js
admin_core
/
_doc
/
使用说明.md
张成
463d7921c1
1
1分钟前
18 KiB
Admin Framework 使用说明
一个基于 Vue2 的通用后台管理系统框架,包含完整的系统功能、登录、路由管理、布局等核心功能。
📦 框架特性
✨ 核心功能
✅ 简化的 API - 只需调用 createApp() 即可完成所有初始化
✅ 模块化设计 - 组件、路由、状态管理等功能按模块组织
✅ 完整的系统管理页面 - 用户、角色、菜单、日志等管理
✅ 登录和权限管理 - 完整的登录流程和权限控制
✅ 动态路由管理 - 基于权限菜单的动态路由生成
✅ Vuex 状态管理 - 用户、应用状态管理
✅ 全局组件库 - Tables、Editor、Upload、TreeGrid、FieldRenderer、FloatPanel 等
✅ 工具库 - HTTP、日期、Token、Cookie 等工具
✅ 内置样式 - base.less、animate.css、iconfont 等
✅ 响应式布局 - 支持移动端适配
🎯 内置页面组件
主页组件 (HomePage) - 欢迎页面,显示系统标题
系统管理页面 (SysUser, SysRole, SysLog, SysParamSetup)
高级管理页面 (SysMenu, SysControl, SysTitle)
登录页面 (LoginPage)
错误页面 (Page401, Page404, Page500)
🛠️ 内置工具
HTTP 工具 (http) - 封装了 axios支持拦截器、文件上传下载
UI 工具 (uiTool) - 删除确认、树形转换、响应式设置、文件下载
通用工具 (tools) - 日期格式化、UUID 生成、Cookie 操作、深拷贝等
文件下载 - 支持 CSV 等格式的文件下载,自动处理换行符
🚀 快速开始
方式一:使用 Demo 项目(推荐)
我们提供了一个完整的 demo 项目,可以直接运行查看效果:
# 1. 进入 demo 项目
cd demo
# 2. 安装依赖
npm install
# 3. 启动开发服务器
npm run dev
浏览器会自动打开 http://localhost:8080查看
/login - 登录页面
/home - 主页
/system/user - 用户管理
/ball/games - 业务示例页面
方式二:构建框架
# 1. 安装依赖
npm install
# 2. 构建框架
npm run build
# 3. 产物在 dist/admin-framework.js
🎯 极简使用方式
只需 3 步即可完成集成!
1. 引入框架
import AdminFramework from './admin-framework.js'
2. 创建应用
const app = AdminFramework.createApp({
title: '我的管理系统',
apiUrl: 'http://localhost:9098/admin_api/',
componentMap: {
'business/product': ProductComponent,
'business/order': OrderComponent
}
})
3. 挂载应用
app.$mount('#app')
就这么简单! 框架会自动完成所有初始化工作。
📖 完整使用指南
1. 项目结构准备
your-project/
├── src/
│ ├── config/
│ │ └── index.js # 配置文件
│ ├── libs/
│ │ └── admin-framework.js # 框架文件
│ ├── views/
│ │ └── business/ # 业务页面
│ ├── api/
│ │ └── business/ # 业务 API
│ ├── App.vue
│ └── main.js
├── package.json
└── webpack.config.js
2. 安装依赖
npm install vue vue-router vuex view-design axios dayjs js-cookie vuex-persistedstate
3. 创建配置文件
在 src/config/index.js 中:
module.exports = {
title: '你的系统名称',
homeName: '首页',
apiUrl: 'http://localhost:9090/admin_api/',
uploadUrl: 'http://localhost:9090/admin_api/upload',
cookieExpires: 7,
uploadMaxLimitSize: 10,
oss: {
region: 'oss-cn-shanghai',
accessKeyId: 'your-key',
accessKeySecret: 'your-secret',
bucket: 'your-bucket',
url: 'http://your-bucket.oss-cn-shanghai.aliyuncs.com',
basePath: 'your-path/'
}
}
4. 创建 main.js新版本 - 推荐)
import AdminFramework from './libs/admin-framework.js'
// 导入业务组件(根据权限菜单接口的 component 字段)
import GamesComponent from './views/ball/games.vue'
import PayOrdersComponent from './views/order/pay_orders.vue'
// 🎉 只需一行代码!框架自动完成所有初始化
const app = AdminFramework.createApp({
title: '我的管理系统',
apiUrl: 'http://localhost:9098/admin_api/',
componentMap: {
'ball/games': GamesComponent,
'order/pay_orders': PayOrdersComponent
// 添加更多业务组件...
},
onReady() {
console.log('应用已启动!')
// 应用启动完成后的回调
}
})
// 挂载应用
app.$mount('#app')
5. 创建 App.vue
<template>
<div id="app">
<router-view/>
</div>
</template>
<script>
export default {
name: 'App'
}
</script>
🔧 API 使用指南
框架实例方法
createApp(config) - 推荐使用
创建应用实例(新版本 API
const app = AdminFramework.createApp({
title: '我的管理系统', // 应用标题(必需)
apiUrl: 'http://localhost:9098/admin_api/', // API 基础地址(必需)
uploadUrl: 'http://localhost:9098/admin_api/upload', // 上传地址(可选,默认为 apiUrl + 'upload'
componentMap: { // 业务组件映射(可选)
'business/product': ProductComponent,
'business/order': OrderComponent
},
onReady() { // 应用启动完成回调(可选)
console.log('应用已启动!')
}
})
工具库使用
HTTP 工具
// 在组件中使用
export default {
async mounted() {
// GET 请求
const res = await this.$http.get('/api/users', { page: 1 })
// POST 请求
const result = await this.$http.post('/api/users', { name: 'test' })
// 文件导出
await this.$http.fileExport('/api/export', { type: 'excel' })
}
}
// 在非 Vue 组件中使用
import AdminFramework from './libs/admin-framework.js'
const res = await AdminFramework.http.get('/api/users')
UI 工具
// 在组件中使用
export default {
methods: {
handleDelete() {
// 删除确认
this.$uiTool.delConfirm(() => {
// 执行删除逻辑
})
// 设置响应式字体
this.$uiTool.setRem()
// 树形转换
const treeData = this.$uiTool.transformTree(flatData)
}
}
}
功能工具
// 在组件中使用
export default {
methods: {
downloadFile() {
// 文件下载
this.$uiTool.downloadFile(response, 'filename.csv')
}
}
}
通用工具
// 在组件中使用
export default {
methods: {
formatDate() {
// 日期格式化
return this.$tools.formatDate(new Date(), 'YYYY-MM-DD HH:mm:ss')
},
generateId() {
// UUID 生成
return this.$tools.generateUUID()
},
setCookie() {
// Cookie 操作
this.$tools.setCookie('name', 'value')
const value = this.$tools.getCookie('name')
}
}
}
Store 模块使用
user 模块
// 登录
await this.$store.dispatch('user/handleLogin', {
userFrom: { username, password },
Main: AdminFramework.Main,
ParentView: AdminFramework.ParentView,
Page404: AdminFramework.Page404
})
// 登出
this.$store.dispatch('user/handleLogOut')
// 设置权限菜单
this.$store.dispatch('user/setAuthorityMenus', {
Main: AdminFramework.Main,
ParentView: AdminFramework.ParentView,
Page404: AdminFramework.Page404
})
// 获取用户信息
const userName = this.$store.getters['user/userName']
const token = this.$store.state.user.token
app 模块
// 设置面包屑
this.$store.commit('app/setBreadCrumb', route)
// 获取系统标题
this.$store.dispatch('app/getSysTitle', {
defaultTitle: '系统名称',
defaultLogo: '/logo.png'
})
// 获取系统配置
const sysFormModel = this.$store.getters['app/sysFormModel']
🗂️ 组件映射配置
业务组件映射
当后端权限菜单接口返回组件路径时,需要配置映射表:
// 1. 导入业务组件
import GamesComponent from './views/ball/games.vue'
import PayOrdersComponent from './views/order/pay_orders.vue'
// 2. 配置映射
const componentMap = {
'ball/games': GamesComponent,
'ball/games.vue': GamesComponent, // 支持带 .vue 后缀
'order/pay_orders': PayOrdersComponent,
'order/pay_orders.vue': PayOrdersComponent
}
// 3. 在 Vue.use 时传入
Vue.use(AdminFramework, {
config,
ViewUI,
VueRouter,
Vuex,
createPersistedState,
componentMap // 传入组件映射表
})
框架已自动映射的系统组件
以下组件无需配置,框架已自动映射:
✅ home/index - 主页
✅ system/sys_user - 用户管理
✅ system/sys_role - 角色管理
✅ system/sys_log - 日志管理
✅ system/sys_param_setup - 参数设置
✅ system/sys_menu - 菜单管理
✅ system/sys_control - 控制器管理
✅ system/sys_title - 系统标题设置
🌐 全局访问
window.framework
框架实例会自动暴露到全局,可以在任何地方访问:
// 在非 Vue 组件中使用
const http = window.framework.http
const uiTool = window.framework.uiTool
const config = window.framework.config
// HTTP 请求
const res = await window.framework.http.get('/api/users')
// UI 工具
window.framework.uiTool.delConfirm(() => {
// 删除逻辑
})
Vue 原型方法
在 Vue 组件中可以直接使用:
export default {
methods: {
async loadData() {
// 直接使用 this.$xxx
const res = await this.$http.get('/api/users')
this.$uiTool.delConfirm(() => {})
this.$tools.formatDate(new Date())
this.$uiTool.downloadFile(response, 'file.csv')
}
}
}
📁 文件下载功能
使用 downloadFile 方法
框架提供了便捷的文件下载功能,支持 CSV 等格式:
// 在 Vue 组件中使用
export default {
methods: {
// 导出数据
exportData() {
// 调用 API 获取数据
this.$http.fileExport('/api/export', params).then(res => {
// 使用 downloadFile 下载
this.$uiTool.downloadFile(res, '数据导出.csv')
this.$Message.success('导出成功!')
}).catch(error => {
this.$Message.error('导出失败:' + error.message)
})
}
}
}
支持的数据格式
CSV 格式:自动处理换行符,保持表格格式
Blob 对象:支持二进制文件下载
文本数据:支持纯文本文件下载
自动处理特性
✅ 换行符保持CSV 文件的换行符会被正确保持
✅ 文件名处理:自动清理文件名中的特殊字符
✅ 浏览器兼容:支持所有现代浏览器
✅ 内存管理:自动清理临时 URL 对象
🎨 全局组件使用
FloatPanel - 浮动面板组件
FloatPanel 是一个浮动在父窗体上的面板组件,类似于抽屉效果,常用于详情展示、表单编辑等场景。
基本使用:
<template>
<div>
<Button @click="showPanel">打开浮动面板</Button>
<FloatPanel
ref="floatPanel"
title="详情面板"
position="right"
:show-back="true"
back-text="返回"
@back="handleBack"
>
<div>这里是面板内容</div>
</FloatPanel>
</div>
</template>
<script>
export default {
methods: {
showPanel() {
// 通过 ref 调用 show 方法显示面板
this.$refs.floatPanel.show()
},
hidePanel() {
// 通过 ref 调用 hide 方法隐藏面板
this.$refs.floatPanel.hide()
},
handleBack() {
console.log('返回按钮被点击')
this.hidePanel()
}
}
}
</script>
属性说明:
属性 类型 默认值 说明
title String '' 面板标题
width String/Number '100%' 面板宽度(字符串或数字),默认占满父容器
height String/Number '100%' 面板高度(字符串或数字),默认占满父容器
position String 'right' 面板位置left、right、top、bottom、center
showBack Boolean true 是否显示返回按钮
showClose Boolean false 是否显示关闭按钮
backText String '返回' 返回按钮文字
closeOnClickBackdrop Boolean false 点击遮罩是否关闭
mask Boolean false 是否显示遮罩(默认不显示)
zIndex Number 1000 层级
方法:
方法 说明 参数
show(callback) 显示面板 callback: 可选的回调函数
hide() 隐藏面板 -
事件:
事件 说明 参数
back 点击返回按钮时触发 -
插槽:
插槽 说明
default 面板主体内容
header-right 头部右侧内容(可用于添加自定义按钮)
位置说明:
left: 从左侧滑入
right: 从右侧滑入(默认)
top: 从顶部滑入
bottom: 从底部滑入
center: 居中显示,带缩放动画
完整示例:
<template>
<div>
<Button @click="openDetailPanel">查看详情</Button>
<FloatPanel
ref="detailPanel"
title="用户详情"
position="right"
:show-back="true"
:show-close="true"
back-text="返回"
@back="handleBack"
>
<template #header-right>
<Button type="primary" @click="handleSave">保存</Button>
</template>
<div class="detail-content">
<Form :model="formData" :label-width="100">
<FormItem label="用户名">
<Input v-model="formData.username" />
</FormItem>
<FormItem label="邮箱">
<Input v-model="formData.email" />
</FormItem>
</Form>
</div>
</FloatPanel>
</div>
</template>
<script>
export default {
data() {
return {
formData: {
username: '',
email: ''
}
}
},
methods: {
openDetailPanel() {
this.$refs.detailPanel.show()
},
handleBack() {
this.$refs.detailPanel.hide()
},
handleSave() {
// 保存逻辑
console.log('保存数据', this.formData)
this.$Message.success('保存成功')
this.$refs.detailPanel.hide()
}
}
}
</script>
特性说明:
✅ 基于父元素定位,不会遮挡菜单
✅ 宽度和高度默认 100%,占满父容器
✅ 无遮罩背景,完全浮在父页面上
✅ 路由切换或组件销毁时自动关闭
✅ 支持多种位置和动画效果
✅ 支持自定义头部右侧内容
📝 业务开发示例
创建业务页面
<!-- src/views/business/product.vue -->
<template>
<div>
<h1>产品管理</h1>
<Button @click="loadData">加载数据</Button>
<Tables :columns="columns" :data="list" />
</div>
</template>
<script>
export default {
data() {
return {
list: [],
columns: [
{ title: 'ID', key: 'id' },
{ title: '名称', key: 'name' },
{ title: '价格', key: 'price' }
]
}
},
async mounted() {
await this.loadData()
},
methods: {
async loadData() {
// 使用框架提供的 http 工具
const res = await this.$http.get('/product/list', { page: 1 })
this.list = res.data
},
async handleDelete(id) {
// 使用框架提供的 UI 工具
this.$uiTool.delConfirm(async () => {
await this.$http.post('/product/delete', { id })
this.$Message.success('删除成功')
await this.loadData()
})
}
}
}
</script>
创建业务 API
// src/api/business/productServer.js
// 注意:不需要 import http直接使用 http
class ProductServer {
async getList(params) {
return await http.get('/product/list', params)
}
async save(data) {
return await http.post('/product/save', data)
}
async delete(id) {
return await http.post('/product/delete', { id })
}
async exportCsv(params) {
return await http.fileExport('/product/export', params)
}
}
export default new ProductServer()
❓ 常见问题
Q1: 打包后文件太大怎么办?
A: 框架已经将 Vue、VueRouter、Vuex、ViewUI、Axios 设置为外部依赖,不会打包进去。确保在项目中单独安装这些依赖。
Q2: 如何只使用部分功能?
A: 可以按需导入:
import { http, uiTool, tools } from './libs/admin-framework.js'
Q3: 权限菜单中的业务页面显示 404 怎么办?
A: 需要配置组件映射表:
Vue.use(AdminFramework, {
// ... 其他配置
componentMap: {
'ball/games': GamesComponent,
'order/pay_orders': PayOrdersComponent
}
})
Q4: 如何自定义配置?
A: 修改 config/index.js 文件:
module.exports = {
title: '你的系统名称',
apiUrl: 'http://your-api-url/',
// ... 其他配置
}
Q5: 如何使用登录功能?
A: 在组件中:
export default {
methods: {
async login() {
await this.$store.dispatch('user/handleLogin', {
userFrom: { username: 'admin', password: '123456' },
Main: AdminFramework.Main,
ParentView: AdminFramework.ParentView,
Page404: AdminFramework.Page404
})
this.$router.push({ name: 'home' })
}
}
}
Q6: 需要单独引入样式文件吗?
A: 不需要! 框架已内置所有样式:
✅ base.less - 基础样式
✅ animate.css - 动画样式
✅ ivewExpand.less - ViewUI 扩展样式
✅ iconfont.css - 字体图标样式
只需引入框架即可:
import AdminFramework from './libs/admin-framework.js'
Vue.use(AdminFramework, { ... })
📦 技术栈
Vue 2.6+
Vue Router 3.x
Vuex 3.x
View Design (iView) 4.x
Axios
Less
Webpack 5
📄 许可证
MIT License
👨‍💻 作者
light
祝开发愉快! 🎉
如有问题,请查看 Demo 项目示例或联系开发团队。
Powered by Gitea
当前版本:
1.24.6
页面:
273ms
模板:
13ms
许可证
API

View File

@@ -1,59 +0,0 @@
# full_flow 删除说明
## ✅ 已删除的 full_flow 相关代码
### 1. 任务处理器注册
- ✅ 删除了 `registerTaskHandlers()` 中的 `full_flow` 处理器注册
- ✅ 删除了 `handleFullFlowTask()` 方法
### 2. 手动任务处理
- ✅ 修改了 `handleManualJobRequest()`,移除了 `full_flow` 的特殊处理
- ✅ 将 `manualExecuteJobFlow()` 标记为废弃,抛出错误提示
### 3. 定时任务
- ✅ 修改了 `executeScheduledJobFlow()`,移除了 `full_flow` 任务添加
- ✅ 添加了注释说明 `full_flow` 已废弃
### 4. 任务队列
- ✅ 删除了 `taskQueue.js``getTaskCommands()``case 'full_flow':` 分支
### 5. 配置
- ✅ 删除了 `config.js``taskTimeouts.full_flow` 配置
- ✅ 删除了 `config.js``taskPriorities.full_flow` 配置
### 6. 数据模型注释
- ✅ 更新了 `task_status.js` 模型中的 `taskType` 注释,移除了 `full_flow` 说明
## 📝 保留的方法(已废弃)
以下方法已标记为废弃,但保留在代码中以便向后兼容:
1. **`manualExecuteJobFlow()`**
- 状态:已废弃
- 行为:抛出错误,提示使用其他任务类型
- 位置:`api/middleware/schedule/index.js:681`
2. **`executeScheduledJobFlow()`**
- 状态:定时任务已注释,方法保留但不再添加 `full_flow` 任务
- 位置:`api/middleware/schedule/index.js:578`
## ⚠️ 注意事项
1. **定时任务已禁用**
- `executeScheduledJobFlow()` 的定时任务调用已被注释
- 如需恢复定时任务,请使用其他任务类型(如 `auto_deliver`
2. **替代方案**
- 如需执行完整流程,请使用:
- `auto_deliver` - 自动投递任务
- `get_job_list` - 获取岗位列表
- `apply_job` - 投递简历
- 其他独立任务类型
## 🔄 后续建议
如果不再需要以下方法,可以考虑完全删除:
- `executeScheduledJobFlow()` - 如果定时任务不再使用
- `manualExecuteJobFlow()` - 如果所有调用都已更新

24
_doc/js/app.25a32752.js Normal file

File diff suppressed because one or more lines are too long

20463
_doc/js/chat.js Normal file

File diff suppressed because it is too large Load Diff

5781
_doc/js/mqtt.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,176 +0,0 @@
# task_status_${snCode} 主题推送位置分析
## 一、推送位置(服务端)
### 1. 任务状态变更推送 (`task_status_update`)
**位置**: `autoAiWorkSys/api/middleware/schedule/taskQueue.js`
**方法**: `notifyTaskStatusChange(sn_code, taskData)`
**推送时机**:
-**任务开始执行时** (第586行)
- 状态: `running`
- 进度: `0`
- 触发位置: `executeTask` 方法中,任务开始执行时
-**任务完成时** (第631行)
- 状态: `completed`
- 进度: `100`
- 触发位置: `executeTask` 方法中,任务成功完成后
-**任务失败时** (第675行)
- 状态: `failed`
- 进度: `0`
- 错误信息: `errorMessage`
- 触发位置: `executeTask` 方法中,任务执行失败时
**推送内容**:
```javascript
{
action: 'task_status_update',
data: {
taskId: task.id,
taskName: task.taskName,
taskType: task.taskType,
status: 'running' | 'completed' | 'failed',
progress: 0 | 100,
errorMessage?: string,
endTime?: Date
},
timestamp: string
}
```
### 2. 任务状态摘要推送 (`task_status_summary`)
**位置**: `autoAiWorkSys/api/middleware/schedule/taskQueue.js`
**方法**: `sendTaskStatusSummary(sn_code)`
**推送时机**:
-**定时推送** (每10秒一次)
- 位置: `autoAiWorkSys/api/middleware/schedule/scheduledJobs.js`
- 方法: `syncTaskStatusSummary()`
- 条件: 只向在线设备推送(最后心跳时间 < 3分钟
**推送内容**:
```javascript
{
action: 'task_status_summary',
data: {
sn_code: string,
currentTask: {
taskId: number,
taskName: string,
taskType: string,
status: 'running',
progress: number,
currentStep: string,
startTime: Date,
jobTitle?: string, // 新增:职位名称
companyName?: string // 新增:公司名称
} | null,
pendingTasks: Array<{
taskId: number,
taskName: string,
taskType: string,
status: 'pending',
scheduledTime: Date,
priority: number
}>,
nextTaskTime: Date | null,
pendingCount: number, // 队列中的任务数
totalPendingCount: number, // 总待执行数(包括当前任务的剩余步骤)
mqttTopic: string,
timestamp: string
},
timestamp: string
}
```
## 二、接收位置(客户端)
### 1. MQTT 订阅
**位置**: `boss-automation-nodejs/src/services/mqttService.js`
**订阅时机**: MQTT 连接成功后 (第360行)
**订阅代码**:
```javascript
const taskStatusTopic = `task_status_${this.config.snCode}`;
this.client.subscribe(taskStatusTopic, { qos: 1 });
```
### 2. 消息处理
**位置**: `boss-automation-nodejs/src/services/mqttService.js`
**处理方法**: `handleMessage` (第292行)
**处理逻辑**:
- 如果 `action === 'task_status_update'`:
- 发送到渲染进程: `task:status-update`
- 如果 `action === 'task_status_summary'`:
- 发送到渲染进程: `task:status-summary`
### 3. 渲染进程接收
**位置**: `boss-automation-nodejs/app/mixins/taskMixin.js`
**方法**:
- `onTaskStatusUpdate(taskData)` - 处理任务状态变更
- `onTaskStatusSummary(summary)` - 处理任务状态摘要
**事件监听**: `boss-automation-nodejs/app/mixins/eventListenerMixin.js`
## 三、当前推送策略分析
### ✅ 优点
1. **实时性**: 任务状态变更立即推送
2. **完整性**: 定时推送完整的状态摘要
3. **可靠性**: 只向在线设备推送,避免资源浪费
### ⚠️ 潜在问题
1. **推送频率**: 定时任务每10秒推送一次可能过于频繁
2. **重复推送**: 任务状态变更和定时摘要可能推送重复信息
3. **推送时机**: 某些关键状态变更可能没有及时推送(如进度更新)
## 四、建议的优化方案
### 方案1: 减少定时推送频率
- 当前: 每10秒推送一次
- 建议: 改为每30秒或60秒推送一次
- 理由: 任务状态变更已实时推送,定时推送主要用于同步完整状态
### 方案2: 只在关键时机推送摘要
- 任务队列变化时推送(新增/删除任务)
- 当前任务完成/失败时推送
- 定时推送作为兜底(频率降低)
### 方案3: 合并推送
- 将任务状态变更和摘要合并为一个消息
- 减少消息数量,提高效率
### 方案4: 添加进度更新推送
- 任务执行过程中,定期推送进度更新
- 例如每完成10%进度推送一次
## 五、需要推送的场景建议
### ✅ 必须推送的场景
1. **任务开始执行** - 立即推送状态变更
2. **任务完成** - 立即推送状态变更 + 状态摘要
3. **任务失败** - 立即推送状态变更 + 状态摘要
4. **任务队列变化** - 新增/删除任务时推送摘要
### ⚠️ 可选推送的场景
1. **进度更新** - 每10%或每完成一个步骤推送一次
2. **定时同步** - 作为兜底频率降低到30-60秒
### ❌ 不需要推送的场景
1. **任务状态未变化** - 避免重复推送相同状态
2. **设备离线** - 不向离线设备推送

View File

@@ -1,453 +0,0 @@
序号 证券代码 公司中文名称 注册地址 公司电话 公司电子邮件地址 公司网站
1 300890.SZ 上海市翔丰华科技股份有限公司 上海市宝山区萧云路635弄11号一层 86-21-66566217 public@xfhinc.com www.xiangfenghua.com
2 000863.SZ 三湘印象股份有限公司 上海市杨浦区逸仙路333号501室 86-21-65364018 sxgf000863@sxgf.com www.sxgf.com
3 001266.SZ 上海宏英智能科技股份有限公司 上海市嘉定区真南路4268号2幢J11387室 86-21-37829918 info@smartsh.com;smart@smartsh.com www.smartsh.com
4 002022.SZ 上海科华生物工程股份有限公司 上海市徐汇区钦州北路1189号 86-21-64954576 kehua@skhb.com www.skhb.com
5 002028.SZ 思源电气股份有限公司 上海市闵行区金都路4399号 86-21-61610958 IR@SIEYUAN.COM www.sieyuan.com
6 002058.SZ 上海威尔泰工业自动化股份有限公司 上海市闵行区虹中路263号1幢 86-21-64656828 dm@welltech.com.cn;dm@welleteh.com.cn www.welltech.cn
7 002116.SZ 中国海诚工程科技股份有限公司 上海市徐汇区宝庆路21号 86-21-64314018 haisum@haisum.com www.haisum.com
8 002158.SZ 上海汉钟精机股份有限公司 上海市金山区枫泾镇建贡路108号 86-21-57350280*1005,86-21-57350280*1132,86-21-57350280*1131 IR@hanbell.cn;amywu@hanbell.cn;gracechiu@hanbell.cn www.hanbell.com.cn
9 002162.SZ 上海悦心健康集团股份有限公司 上海市闵行区浦江镇三鲁公路2121号 86-21-54333699 zqb@cimic.com www.everjoyhealth.com
10 002178.SZ 上海延华智能科技(集团)股份有限公司 上海市普陀区西康路1255号6楼602 86-21-61818686*309 yanhua_sh@126.com www.chinaforwards.com
11 002184.SZ 上海海得控制系统股份有限公司 上海市闵行区新骏环路777号 86-21-60572990 002184@hite.com.cn www.hite.com.cn
12 002195.SZ 上海岩山科技股份有限公司 上海市自由贸易试验区张江路665号三层 86-21-64822345,86-21-61462195 stock@stonehill-tech.com www.stonehill-tech.com
13 002211.SZ 上海宏达新材料股份有限公司 上海市闵行区春常路18号1幢2层A区 86-21-64036071 hongda@002211sh.com;wangyanjie@002211sh.com;huanglei@002211sh.com www.002211sh.com
14 002252.SZ 上海莱士血液制品股份有限公司 上海市奉贤区望园路2009号 86-21-22130888*217 raas@raas-corp.com www.raas-corp.com
15 002269.SZ 上海美特斯邦威服饰股份有限公司 上海市浦东新区康桥东路800号 86-21-38119999,86-21-68182996 Corporate@metersbonwe.com corp.metersbonwe.com
16 002278.SZ 上海神开石油化工装备股份有限公司 上海市闵行区浦星公路1769号 86-21-64293895 skdb@shenkai.com www.shenkai.com
17 002324.SZ 上海普利特复合材料股份有限公司 上海市青浦区赵巷镇沪青平公路2855弄1号12楼 86-21-31115910 dsh@pret.com.cn;chumh@pret.com.cn;yangfan@pret.com.cn;caiq@pret.com.cn www.pret.com.cn
18 002328.SZ 上海新朋实业股份有限公司 上海市青浦区华新镇华隆路1698号 86-21-31166512 xinpengstock@xinpeng.com www.xinpeng.com
19 002346.SZ 上海柘中集团股份有限公司 上海市奉贤区联合北路215号第5幢2501室 86-21-57403737 lilizhuan@zhezhong.com;yangyifei@zhezhong.com www.zhezhong.com
20 002401.SZ 中远海运科技股份有限公司 上海市浦东新区沈家弄路738号 86-21-65969398 yu.jianzhong@coscoshipping.com;ma.chi@coscoshipping.com tech.coscoshipping.com
21 002451.SZ 上海摩恩电气股份有限公司 上海市浦东新区江山路2829号 86-21-58979608 investor@mornelectric.com;huangsz@mornelectric.com www.mornelectric.com
22 002454.SZ 上海加冷松芝汽车空调股份有限公司 上海市闵行区莘庄工业区华宁路4999号 86-21-52634750,86-21-52634750*1602 shstock@shsongz.com.cn;chenrui@shsongz.com www.shsongz.com.cn
23 002486.SZ 上海嘉麟杰纺织品股份有限公司 上海市金山区亭林镇亭枫公路1918号 86-10-63541462 investor@challenge-21c.com;jljzqb@challenge-21c.com www.challenge-21c.com
24 002506.SZ 协鑫集成科技股份有限公司 上海市奉贤区南桥镇江海经济园区 86-512-69832889 gclsizqb@gclsi.com www.gclsi.com
25 002527.SZ 上海新时达电气股份有限公司 上海市嘉定区思义路1560号 86-21-69926000,86-21-69896737 liujing@stepelectric.com;step@stepelectric.com;wanzx@stepelectric.com www.stepelectric.com
26 002561.SZ 上海徐家汇商城股份有限公司 上海市徐汇区肇嘉浜路1068号 86-21-64269991,86-21-64269999 xjh@xjh-sc.com;xu3958@sina.com;xyq@xjh-sc.com www.xjh-sc.com
27 002565.SZ 上海顺灏新材料科技股份有限公司 上海市普陀区真陈路200号 86-21-66278702 investor@shunhostock.com;duyunbo@shunhostock.com;zhouxiaofeng@shunhostock.com www.shunhostock.com
28 002568.SZ 上海百润投资控股集团股份有限公司 上海市浦东新区康桥工业区康桥东路558号 86-21-58160073 Bairun@bairun.net;Chen.wang@bairun.net;Jiajie.tang@bairun.net www.bairun.net
29 002605.SZ 上海姚记科技股份有限公司 上海市嘉定区黄渡镇曹安路4218号 86-21-53308852,86-21-69595008 secretarybd@yaojipoker.com;ir@yaoji.cn www.yaojikeji.com
30 002636.SZ 金安国纪集团股份有限公司 上海市松江区工业区宝胜路33号 86-21-57747138 gdmir@goldenmax.cn www.goldenmax.cn
31 002669.SZ 康达新材料(集团)股份有限公司 上海市奉贤区雷州路169号 86-21-50770196,86-21-50779159 kdxc@shkdchem.com www.shkdchem.com
32 002706.SZ 上海良信电器股份有限公司 上海市浦东新区申江南路2000号 86-21-68586651,86-21-68586632 wangrui22629@sh-liangxin.com;chengqiugao@sh-liangxin.com www.sh-liangxin.com
33 002825.SZ 上海纳尔实业股份有限公司 上海市浦东新区新场镇新瀚路26号 86-21-31272888 ir@nar.com.cn;qiyan@nar.com.cn www.nar.com.cn
34 002858.SZ 力盛云动(上海)体育科技股份有限公司 上海市松江区佘山镇沈砖公路3000号 86-21-62418755 ir@lsaisports.com;panyujie@lsaisports.com www.lsaisports.com
35 300008.SZ 天海融合防务装备技术股份有限公司 上海市松江区莘砖公路518号10幢8层 86-21-60859800*9374,86-21-60859800*9837,86-21-60859745 public@bestwaysh.com;luyingying@bestwaysh.com;dongwenjie@bestwaysh.com www.bestwaysh.com
36 300017.SZ 网宿科技股份有限公司 上海市嘉定区环城路200号 86-21-64685982 wangsudmb@wangsu.com www.wangsu.com
37 300039.SZ 上海凯宝药业股份有限公司 上海市奉贤区工业综合开发区程普路88号 86-21-37572069 kbyydmb@126.com www.xykb.com
38 300059.SZ 东方财富信息股份有限公司 上海市嘉定区宝安公路2999号1幢 86-21-54660526 dongmi@eastmoney.com www.eastmoney.com
39 300061.SZ 旗天科技集团股份有限公司 上海市金山区山阳镇亭卫公路1000号2幢203室 86-21-60975620 investor@qt300061.com www.qt300061.com
40 300067.SZ 上海安诺其集团股份有限公司 上海市青浦区工业园区菘华路881号 86-21-59867500 investor@anoky.com.cn www.anoky.com.cn
41 300074.SZ 华平信息技术股份有限公司 上海市杨浦区国权北路1688弄A6座 86-21-65650210 ir@avcon.com.cn www.avcon.com.cn
42 300126.SZ 锐奇控股股份有限公司 上海市松江区新桥镇新茸路5号 86-21-57825832 300126@china-ken.com www.ken-tools.com
43 300129.SZ 泰胜风能集团股份有限公司 上海市金山区卫清东路1988号 86-21-57243692 liweitao@shtsp.com;ir@shtsp.com;chenjie@shtsp.com;chenyiyao@shtsp.com www.shtsp.com
44 300153.SZ 上海科泰电源股份有限公司 上海市青浦区张江高新区青浦园天辰路1633号 86-21-69758019,86-21-69758012 irm@cooltechsh.com;xukun@cooltechsh.com;dengjie@cooltechsh.com www.cooltechsh.com
45 300168.SZ 万达信息股份有限公司 上海市徐汇区桂平路481号20号楼5层 86-21-62489636 invest@wondersgroup.com www.wondersgroup.com
46 300170.SZ 上海汉得信息技术股份有限公司 上海市青浦区汇联路33号 86-21-50177372,86-21-67002300 investors@vip.hand-china.com www.hand-china.com
47 300171.SZ 东富龙科技集团股份有限公司 上海市闵行区都会路1509号4幢 86-21-64909699 dfl@tofflon.com www.tofflon.com
48 300180.SZ 上海华峰超纤科技股份有限公司 上海市金山区亭卫南路888号 86-21-57243140 fu.juan@huafeng.com;chu.yuxi@huafeng.com microfibre.huafeng.com
49 300222.SZ 科大智能科技股份有限公司 上海市松江区泗砖路777号1幢503室 86-21-50804882 kdzn@csg.com.cn;mjb@csg.com.cn www.csg.com.cn
50 300225.SZ 上海金力泰化工股份有限公司 上海市奉贤区楚工路139号 86-21-31156097 knttzxx@knt.cn www.knt.cn
51 300226.SZ 上海钢联电子商务股份有限公司 上海市宝山区园丰路68号 86-21-26093997 public@mysteel.com about.mysteel.com
52 300230.SZ 上海永利带业股份有限公司 上海市青浦区徐泾镇徐旺路58号 86-21-59884061 yongli@yonglibelt.com;ycl@yonglibelt.com;zhongmm@yonglibelt.com www.yonglibelt.com
53 300236.SZ 上海新阳半导体材料股份有限公司 上海市松江区思贤路3600号 86-21-57850066 info@sinyang.com.cn www.sinyang.com.cn
54 300245.SZ 上海天玑科技股份有限公司 上海市闵行区田林路1016号6号楼 86-21-54278888 public@dnt.com.cn www.dnt.com.cn
55 300253.SZ 卫宁健康科技集团股份有限公司 上海市浦东新区东育路255弄4号3楼B29 86-21-80331033 wndsh@winning.com.cn www.winning.com.cn
56 300257.SZ 开山集团股份有限公司 上海市自由贸易试验区临港新片区飞渡路851号 86-570-3662177,86-21-62261893 zqtzb@kaishangroup.com;yang.jianjun@kaishangroup.com;wan.shiqi@kaishangroup.com www.kaishancomp.com
57 300272.SZ 开能健康科技集团股份有限公司 上海市浦东新区川沙镇川大路508,518号 86-21-58599901 dongmiban@canature.com www.canature.com
58 300286.SZ 安科瑞电气股份有限公司 上海市嘉定区育绿路253号 86-21-69158331 acrel@acrel.cn www.acrel.cn
59 300326.SZ 上海凯利泰医疗科技股份有限公司 上海市浦东新区张江高科技园东区瑞庆路528号23幢 86-21-50728758 KMC@shkmc.com.cn;mengchensun@shkmc.com.cn www.kineticmedinc.com.cn
60 300327.SZ 中颖电子股份有限公司 上海市长宁区金钟路767弄3号 86-21-61219988,86-21-61219988*1688 ir@sinowealth.com;dpsino168@126.com;jxsino327@126.com www.sinowealth.com
61 300378.SZ 鼎捷数智股份有限公司 上海市静安区江场路1377弄绿地中央广场7号20层 86-21-51791699 digiwin-zhengquan@digiwin.com www.digiwin.com
62 300380.SZ 上海安硕信息技术股份有限公司 上海市杨浦区国泰路11号2308室 86-21-55137223 ir@amarsoft.com www.amarsoft.com
63 300398.SZ 上海飞凯材料科技股份有限公司 上海市宝山区潘泾路2999号 86-21-50322662 investor@phichem.com.cn www.phichem.com.cn
64 300462.SZ 上海华铭智能终端设备股份有限公司 上海市松江区茸梅路895号 86-21-57784382,86-21-57784382*288 hmzn300462@hmmachine.com www.hmmachine.com
65 300483.SZ 首华燃气科技(上海)股份有限公司 上海市闵行区元江路5000号 86-21-58831588,86-10-52253050 db@primagas.com.cn;qian.zhang@primagas.com.cn;zhulin.wang@primagas.com.cn www.primagas.com.cn
66 300493.SZ 上海润欣科技股份有限公司 上海市徐汇区田林路200号A号楼301室 86-21-54264260 investment@fortune-co.com www.fortune-co.com
67 300501.SZ 上海海顺新型药用包装材料股份有限公司 上海市松江区莘砖公路3456弄 86-21-37017626,86-21-37667766 ir@hysum.com;zhengquan@haishunpackaging.com;zhengquan@hysum.com www.hysum.com
68 300508.SZ 上海维宏电子科技股份有限公司 上海市闵行区都会路975弄119号1幢501室 86-21-33587515 weihongzq@weihong.com.cn www.weihong.com.cn
69 300511.SZ 上海雪榕生物科技股份有限公司 上海市奉贤区汇丰西路1487号 86-21-37198681 xrtz@xuerong.com www.xuerong.com
70 300551.SZ 上海古鳌电子科技股份有限公司 上海市普陀区同普路1225弄6号 86-21-22252595 ir@gooao.cn www.gooao.cn
71 300578.SZ 上海会畅通讯股份有限公司 上海市金山区吕巷镇红光路4200-4201号2757室 86-21-61321868,86-21-60716636,86-21-60716686 BDoffice@bizconf.cn www.bizconf.cn
72 300590.SZ 上海移为通信技术股份有限公司 上海市闵行区新龙路500弄30号 86-21-54450318 stock@queclink.com www.queclink.com
73 300609.SZ 汇纳科技股份有限公司 上海市浦东新区中国(上海)自由贸易试验区川和路55弄6号 86-21-60220636,86-21-31759693 sadep@winnerinf.com www.winnerinf.com
74 300613.SZ 上海富瀚微电子股份有限公司 上海市徐汇区宜山路717号6楼 86-21-64066786,86-21-61121558,86-21-64066785 stock@fullhan.com www.fullhan.com
75 300627.SZ 上海华测导航技术股份有限公司 上海市青浦区徐泾镇高泾路599号C座(一照多址企业) 86-21-64950939 huace@huace.cn www.huace.cn
76 300642.SZ 上海透景生命科技股份有限公司 上海市浦东新区汇庆路412号 86-21-50495115 info@tellgen.com www.tellgen.com
77 300762.SZ 上海瀚讯信息技术股份有限公司 上海市嘉定区鹤友路258号 86-21-62386622,86-21-32510369 Info_disclosure@jushri.com www.jushri.com
78 300802.SZ 上海矩子科技股份有限公司 上海市徐汇区云锦路701号33层3301单元(实际29层) 86-21-64969730 investors@jutze.com.cn www.jutze.com.cn
79 300892.SZ 品渥食品股份有限公司 上海市松江区佘山镇新宅路777弄3号 86-21-51863006 securities@pinlive.com www.pinlive.com
80 300899.SZ 上海凯鑫分离技术股份有限公司 上海市浦东新区中国(上海)自由贸易试验区张江路665号3层 86-21-58988820 shkx@keysino.cn www.keysinosep.com
81 300915.SZ 上海海融食品科技股份有限公司 上海市奉贤区金汇镇金斗路666号 86-21-37560135 hrkj@hiroad.sh.cn www.hiroad.sh.cn
82 300947.SZ 上海德必文化创意产业发展(集团)股份有限公司 上海市长宁区安化路492号1幢707室 86-21-60701389 sec@dobechina.com www.dobechina.com
83 300963.SZ 上海中洲特种合金材料股份有限公司 上海市嘉定工业区世盛路580号 86-21-59966058 zhz@shzztc.com;wyj@shzztc.com;gjy@shzztc.com www.alloy-china.com
84 300983.SZ 上海尤安建筑设计股份有限公司 上海市宝山区一二八纪念路968号1618室 86-21-35324001 uachina@uachina.com.cn www.uachina.com.cn
85 300999.SZ 益海嘉里金龙鱼食品集团股份有限公司 上海市浦东新区中国(上海)自由贸易试验区博成路1379号15层 86-21-31823188 jinlongyu_ir@cn.wilmar-intl.com www.yihaikerry.net.cn
86 301000.SZ 上海肇民新材料科技股份有限公司 上海市金山区金山卫镇秦弯路633号 86-21-57930288 stock@hps-sh.com www.hps-sh.com
87 301001.SZ 上海凯淳实业股份有限公司 上海市金山工业区天工路857号2幢2401室 86-21-55080030 IR@kaytune.com www.kaytune.com
88 301005.SZ 超捷紧固系统(上海)股份有限公司 上海市嘉定区丰硕路100弄39号 86-21-59907242 lihongtao@shchaojie.com.cn;liss@shchaojie.com.cn www.shchaojie.com.cn
89 301024.SZ 上海霍普建筑设计事务所股份有限公司 上海市浦东新区中国(上海)自由贸易试验区滨江大道469号中企财富世纪大厦5层,10层(实际楼层4层,9层) 86-21-58783137 ir@hyp-arch.com www.hyp-arch.com
90 301025.SZ 读客文化股份有限公司 上海市金山区枫泾镇枫湾路531,535号1幢1层103室 86-21-33608311 stock@dookbook.com;wangkun@dookbook.com;liubaorui@dookbook.com www.dookbook.com
91 301037.SZ 上海保立佳化工股份有限公司 上海市奉贤区泰日镇大叶公路6828号 86-21-31167902 dongban@baolijia.com.cn www.baolijia.com.cn
92 301046.SZ 上海能辉科技股份有限公司 上海市普陀区金通路799,899,999号17幢3层307室 86-21-50896255 nenghui@nhet.com.cn;luolianming@nhet.com.cn;yangjing@nhet.com.cn www.nhet.com.cn
93 301060.SZ 上海兰卫医学检验所股份有限公司 上海市长宁区临新路268弄1号楼4-9层 86-21-31778162 labway@labway.cn www.labway.cn
94 301062.SZ 上海艾录包装股份有限公司 上海市金山区山阳镇阳乐路88号 86-21-57293030*6507 info@ailugroup.com www.ailugroup.com
95 301070.SZ 开勒环境科技(上海)股份有限公司 上海市松江区新桥镇新格路505号3幢2楼南5区 86-21-57685221 kaledongmi@kalefans.com www.kalefans.com
96 301099.SZ 上海雅创电子集团股份有限公司 上海市闵行区春光路99弄62号2-3楼及402-405室 86-21-51866509,86-21-51516111*8033 security@yctexin.com www.yctexin.com
97 301151.SZ 上海冠龙阀门节能设备股份有限公司 上海市嘉定区南翔镇德园路815号 86-21-31229378 investor@karon-valve.com www.karon-valve.com
98 301156.SZ 上海美农生物科技股份有限公司 上海市嘉定区沥红路151号 86-21-59546881 mnsw@sinomenon.com www.sinomenon.com
99 301161.SZ 上海唯万密封科技股份有限公司 上海市浦东新区龙东大道6111号1幢2层B216室 86-21-68184680 voneseals@voneseals.com;yuxin_wang@voneseals.com www.voneseals.com
100 301166.SZ 上海优宁维生物科技股份有限公司 上海市杨浦区控江路1690号1505室 86-21-38939097,86-21-38939070 ir@univ-bio.com www.univ-bio.com
101 301228.SZ 实朴检测技术(上海)股份有限公司 上海市闵行区中春路1288号34幢3层301室,4层401室 86-21-64881367 IR@sepchina.cn www.sepchina.cn
102 301230.SZ 上海泓博智源医药股份有限公司 上海市浦东新区庆达路315号23幢 86-21-50720100 info@pharmaresources.cn www.pharmaresources.cn
103 301257.SZ 普蕊斯(上海)医药科技开发股份有限公司 上海市黄浦区思南路105号1号楼108室 86-21-60755800 IR@smo-clinplus.com www.smo-clinplus.com
104 301273.SZ 上海瑞晨环保科技股份有限公司 上海市嘉定区申霞路358号3幢C区 86-21-55789678 wanghanze@richenenergy.com;wenya@richenenergy.com www.richenenergy.com.cn
105 301289.SZ 上海国缆检测股份有限公司 上海市宝山区真陈路888号 86-21-65493333*2201,86-21-65493333*2612 guolandb@ticw.com.cn www.ticw.com.cn
106 301303.SZ 上海真兰仪表科技股份有限公司 上海市青浦区崧达路800号 86-21-31166688,86-21-31167958 info@zenner-metering.com www.zenner-metering.com
107 301419.SZ 上海阿莱德实业集团股份有限公司 上海市奉贤区奉炮公路1368号6栋 86-21-56480200 allied@allied-corp.com;zoe.zhou@allied-corp.com;xiafan.li@allied-corp.com www.allied-corp.com
108 600000.SH 上海浦东发展银行股份有限公司 上海市黄浦区中山东一路12号 86-21-63611226,86-21-61618888 ligm-hhht@spdb.com.cn;wur2@spdb.com.cn;bdo@spdb.com.cn;zhangj8@spdb.com.cn www.spdb.com.cn
109 600009.SH 上海国际机场股份有限公司 上海市浦东新区启航路900号 86-21-68341609 ir@shairport.com www.avinex.com
110 600018.SH 上海国际港务(集团)股份有限公司 上海市自由贸易试验区临港新片区同汇路1号综合大楼A区4楼 86-21-55333388 dongmi@portshanghai.com.cn;600018@portshanghai.com.cn www.portshanghai.com.cn
111 600019.SH 宝山钢铁股份有限公司 上海市宝山区富锦路885号宝钢指挥中心 86-21-26647000 ir@baosteel.com www.baosteel.com
112 600021.SH 上海电力股份有限公司 上海市浦东新区高科西路1号上电大厦 86-21-23108718,86-21-23108800 sepco@shanghaipower.com;shanghaipower@spic.com.cn www.shanghaipower.com
113 600026.SH 中远海运能源运输股份有限公司 上海市中国(上海)自由贸易试验区临港新片区业盛路188号A-1015室 86-21-65967678 ir.energy@coscoshipping.com energy.coscoshipping.com
114 600061.SH 国投资本股份有限公司 上海市中国(上海)自由贸易试验区北张家浜路128号204-3,204-4,204-5室 86-10-83325163 600061@sdic.com.cn www.sdiccapital.com
115 600072.SH 中船科技股份有限公司 上海市杨浦区周家嘴路3255号10楼 86-21-63022385 mail@cssckj.com www.cssckj.com
116 600073.SH 上海光明肉业集团股份有限公司 上海市浦东新区川桥路1501号7幢101室 86-21-22866016,86-21-55669312 ml@shanghaimaling.com;ir@shanghaimaling.com;ir@brightmeat.com www.brightmeat.com
117 600081.SH 东风电子科技股份有限公司 上海市闵行区浦江镇新骏环路88号13幢203室 86-21-52917811 postmaster@detc.com.cn;zhengming@detc.com.cn;lifei@detc.com.cn;luww@detc.com.cn www.detc.com.cn
118 600088.SH 中视传媒股份有限公司 上海市浦东新区福山路450号新天国际大厦17层A座 86-21-68765168 irmanager@ctv-media.com.cn www.ctv-media.com.cn
119 600094.SH 上海大名城企业股份有限公司 上海市闵行区红松东路1116号1幢5楼A区 86-21-62478900,86-21-62470088 dmc@greattown.cn;chizhiqiang@greattown.cn;zhangyanqi@greattown.cn www.greattown.cn
120 600097.SH 上海开创国际海洋资源股份有限公司 上海市浦东新区外高桥保税区新灵路118号1201A室 86-21-65686875,86-21-65690310 ir@skmic.sh.cn www.skmic.sh.cn
121 600104.SH 上海汽车集团股份有限公司 上海市浦东新区中国(上海)自由贸易试验区松涛路563号1号楼509室 86-21-22011138 saicmotor@saic.com.cn www.saicmotor.com
122 600115.SH 中国东方航空股份有限公司 上海市浦东新区国际机场机场大道66号 86-21-22330932 ir@ceair.com www.ceair.com
123 600119.SH 长发集团长江投资实业股份有限公司 上海市浦东新区中国(上海)自由贸易试验区世纪大道1500号 86-21-66601817,86-21-66601819 cjtzdb@cjtz.cn;shiqinyu@cjtz.cn www.cjtz.cn
124 600150.SH 中国船舶工业股份有限公司 上海市浦东新区自由贸易试验区浦东大道1号 86-21-68860618 stock@csscholdings.com;zhangdb@csscholdings.com csscholdings.cssc.net.cn
125 600151.SH 上海航天汽车机电股份有限公司 上海市中国(上海)自由贸易试验区浦东新区榕桥路661号 86-21-64827176 saae@ht-saae.com www.ht-saae.com
126 600170.SH 上海建工集团股份有限公司 上海市浦东新区中国(上海)自由贸易试验区福山路33号 86-21-35100838,86-21-35318170 sc@china-scg.cn;ir@scg.com.cn www.scg.com.cn
127 600171.SH 上海贝岭股份有限公司 上海市徐汇区漕河泾开发区宜山路810号 86-21-24261157 bloffice@belling.com.cn www.belling.com.cn
128 600193.SH 上海创兴资源开发股份有限公司 上海市浦东新区康桥路1388号三楼A 86-21-58125999,86-4000960980 cxzy@shprd.cn
129 600196.SH 上海复星医药(集团)股份有限公司 上海市普陀区曹杨路510号9楼 86-21-33987870 ir@fosunpharma.com www.fosunpharma.com
130 600210.SH 上海紫江企业集团股份有限公司 上海市闵行区莘庄工业区申富路618号 86-21-62377118 zijiangqy@zijiangqy.com;zjqy@zijiangqy.com www.zijiangqy.com
131 600272.SH 上海开开实业股份有限公司 上海市静安区新闸路921号201室K02 86-21-62712002 dm@chinesekk.com www.chinesekk.com
132 600278.SH 东方国际创业股份有限公司 上海市浦东新区自由贸易试验区张杨路707号2221室 86-21-62789999,86-21-52291197,86-21-52291198,86-21-62785521 oiehq@oie.com.cn www.oie.com.cn
133 600284.SH 上海浦东建设股份有限公司 上海市浦东新区自由贸易试验区川桥路701弄3号7楼 86-21-58206677 pdjs600284@pdjs.com.cn;dongmi@pdjs.com.cn;zhengdai@pdjs.com.cn www.pdjs.com.cn
134 600315.SH 上海家化联合股份有限公司 上海市虹口区保定路527号 86-21-35907000,86-21-35907666 ir@jahwa.com.cn www.jahwa.com.cn
135 600320.SH 上海振华重工(集团)股份有限公司 上海市浦东新区浦东南路3470号 86-21-50390727 IR@ZPMC.COM www.zpmc.com
136 600420.SH 上海现代制药股份有限公司 上海市浦东新区建陆路378号 86-21-52372865,86-21-62510990 xd_zhengquanban@sinopharm.com;shyndec@sinopharm.com www.shyndec.com
137 600490.SH 鹏欣环球资源股份有限公司 上海市普陀区曹杨路1888弄11号11楼1102室-70 86-21-61679636 600490@pengxinzy.com.cn www.pengxinzy.com.cn
138 600500.SH 中化国际(控股)股份有限公司 上海市中国(上海)自由贸易试验区长清北路233号12层 86-21-68373780 600500@sinochem.com www.sinochemintl.com
139 600503.SH 华丽家族股份有限公司 上海市奉贤区星火开发区阳明路1号8幢1层105室 86-21-62376199 dmb@deluxe-family.com www.deluxe-family.com
140 600508.SH 上海大屯能源股份有限公司 上海市浦东新区中国(上海)自由贸易试验区浦东南路256号 86-21-68864621 sh600508@263.net www.sdtny.com
141 600517.SH 国网英大股份有限公司 上海市浦东新区自由贸易试验区国耀路211号C座9层 86-21-51796818 600517@sgcc.com.cn www.gwydgf.com
142 600530.SH 上海交大昂立股份有限公司 上海市松江区环城路666号 86-21-54277820,86-21-54277865 stock@mail.onlly.com.cn www.onlly.com.cn
143 600597.SH 光明乳业股份有限公司 上海市闵行区吴中路578号 86-21-54584520,86-21-64655801,86-21-64307739 chenzhongjie@brightdairy.com;600597@brightdairy.com www.brightdairy.com
144 600601.SH 方正科技集团股份有限公司 上海市南京西路1515号嘉里商务中心9楼 86-21-58400030 IR@founder.com www.foundertech.com
145 600602.SH 云赛智联股份有限公司 上海市浦东新区张江高科技园区张衡路200号1号楼2楼 86-21-62980202 webmaster@inesa-it.com;stock@inesa-it.com www.inesa-it.com
146 600604.SH 上海市北高新股份有限公司 上海市静安区共和新路3088弄2号1008室 86-21-66528130 zhengquan@shibei.com www.shibeiht.com
147 600605.SH 上海汇通能源股份有限公司 上海市浦东新区康桥路1100号 86-21-62560000 securities@huitong-sh.com www.huitongenergy.com
148 600606.SH 绿地控股集团股份有限公司 上海市黄浦区打浦路700号 86-21-63600606,86-21-23296400,86-21-23296512 ir@ldjt.com.cn www.ldjt.com.cn
149 600608.SH 上海宽频科技股份有限公司 上海市闵行区东川路555号丙楼5110室 86-21-62317066,86-871-64646840 invest@600608.net;yunfeng@600608.net;Zhao_z@600608.net www.600608.net
150 600611.SH 大众交通(集团)股份有限公司 上海市徐汇区中山西路1515号大众大厦12楼 86-21-64289122 DZJT@96822.COM;fwj@96822.com WWW.96822.COM
151 600612.SH 老凤祥股份有限公司 上海市黄浦区南京西路190号四层,五层 86-21-54480605,86-21-64833388*608 lfx600612@lfxgf.com;legal@lfxgf.com www.laofengxiang.com
152 600613.SH 上海神奇制药投资管理股份有限公司 上海市浦东新区上川路995号 86-21-53750009 shanghaiys@126.com www.gzsq.com
153 600616.SH 上海金枫酒业股份有限公司 上海市浦东新区张杨路579号(三鑫大厦内) 86-21-58352625,86-21-50812727*908 lily@jinfengwine.com;lqc@jinfengwine.com;jfjy@jinfengwine.com www.jinfengwine.com
154 600618.SH 上海氯碱化工股份有限公司 上海市金山区化学工业区神工路200号 86-21-23536618 dshmss@scacc.com;Chenlihua_lj@shhuayi.com;yubin@shhuayi.com www.scacc.com
155 600619.SH 上海海立(集团)股份有限公司 上海市中国(上海)自由贸易试验区宁桥路888号 86-21-58547777,86-21-58547618 luomin@highly.cc;yanghh@highly.cc;heartfelt@highly.cc www.highly.cc
156 600620.SH 上海市天宸股份有限公司 上海市长宁区延安西路2067号29楼 86-21-62782233 tc@shstc.com;fuyunfei@shstc.com;xuxuyu@shstc.com www.shstc.com
157 600621.SH 上海华鑫股份有限公司 上海市徐汇区云锦路277号20层 86-21-54967663,86-21-54967667 huzk@shchinafortune.com;shfc@shchinafortune.com;zhangjt@shchinafortune.com www.shchinafortune.com
158 600622.SH 光大嘉宝股份有限公司 上海市嘉定区清河路55号6-7F 86-21-59529711 600622@ebjb.com www.ebjb.com
159 600623.SH 上海华谊集团股份有限公司 上海市静安区常德路809号 86-21-23530152 IR@shhuayi.com;jupei@shhuayi.com www.doublecoinholdings.com
160 600624.SH 上海复旦复华科技股份有限公司 上海市奉贤区汇丰北路1515弄1号2幢107室 86-21-63872288 forward@forwardgroup.com www.forwardgroup.com
161 600626.SH 上海申达股份有限公司 上海市中国(上海)自由贸易试验区世博村路231号2单元3层328室 86-21-62328282 600626@sh-shenda.com www.sh-shenda.com
162 600628.SH 上海新世界股份有限公司 上海市黄浦区南京西路2-88号 86-21-63871786 xsjhuyi@163.com;newworld@newworld-china.com;wwt26@163.com www.newworld-china.com
163 600629.SH 华东建筑集团股份有限公司 上海市黄浦区汉口路151号 86-21-62464018,86-21-52524567 ir@arcplus.com.cn www.arcplus.com.cn
164 600630.SH 上海龙头(集团)股份有限公司 上海市黄浦区制造局路584号8号楼2楼201室 86-21-58128888,86-21-63159108,86-21-34061116 ltdsh@shanghaidragon.com.cn;longtou@shanghaidragon.com.cn www.shanghaidragon.com.cn
165 600635.SH 上海大众公用事业(集团)股份有限公司 上海市中国(上海)自由贸易试验区浦东新区商城路518号 86-21-64280679,86-21-64288698 dmbstock@dzug.cn www.dzug.cn
166 600636.SH 国新文化控股股份有限公司 上海市闵行区龙吴路4411号 86-10-68313202 bod@crhc.cn www.crhc-culture.com
167 600637.SH 东方明珠新媒体股份有限公司 上海市徐汇区宜山路757号 86-21-33396637 dongban@opg.cn www.opg.cn
168 600638.SH 上海新黄浦实业集团股份有限公司 上海市黄浦区北京东路668号东楼32层 86-21-63238888 stock@600638.com;600638@600638.com www.600638.com
169 600639.SH 上海金桥出口加工区开发股份有限公司 上海市自由贸易试验区新金桥路28号 86-21-50307702 jqir@shpdjq.com www.shpdjq.com
170 600640.SH 新国脉数字文化股份有限公司 上海市普陀区江宁路1207号4,18,20-21楼 86-21-62762171 600640@chinatelecom.cn www.new-gm.cn
171 600641.SH 上海先导基电科技股份有限公司 中国(上海)自由贸易试验区浦明路1500号12层(名义楼层15层) 86-21-50367718 info@600641.com.cn www.600641.com.cn
172 600642.SH 申能股份有限公司 上海市闵行区虹井路159号5楼 86-21-33570888,86-21-63900642 zhengquan@shenergy.com.cn www.shenergy.net.cn
173 600643.SH 上海爱建集团股份有限公司 上海市浦东新区泰谷路168号 86-21-64396600 dongmi@aj.com.cn;xinfang@aj.com.cn www.aj.com.cn
174 600648.SH 上海外高桥集团股份有限公司 上海市浦东新区杨高北路889号 86-21-51980848,86-21-51980806 gudong@shwgq.cn;gudong@wgq.cn www.china-ftz.com
175 600649.SH 上海城投控股股份有限公司 上海市浦东新区北艾路1540号 86-21-66981171,86-21-66981376 ctkg@600649sh.com www.sh600649.com
176 600650.SH 上海锦江在线网络服务股份有限公司 上海市中国(上海)自由贸易试验区浦东大道1号 86-21-63218800 shenyun@jinjiangonline.com;IR@jinjiangonline.com www.jinjiangonline.com
177 600651.SH 上海飞乐音响股份有限公司 上海市嘉定区嘉新公路1001号第七幢 86-21-61549299 office@facs.com.cn www.facs.com.cn
178 600655.SH 上海豫园旅游商城(集团)股份有限公司 上海市黄浦区复兴东路2号1幢1111室 86-21-23029999,86-21-23028571 obd@yuyuantm.com.cn www.yuyuantm.com.cn
179 600661.SH 上海新南洋昂立教育科技股份有限公司 上海市徐汇区淮海西路55号11C 86-21-62818544,86-21-62811383 yangxiaoling@onlyedu.com;tzzrx@onlyedu.com;xujingyun@onlyedu.com www.onlyedu.com
180 600662.SH 上海外服控股集团股份有限公司 上海市中国(上海)自由贸易试验区张杨路655号707室 86-21-65670587 ir@fsg.com.cn;Jenny.yu@fsg.com.cn;haiyuan.zhu@fsg.com.cn www.fsg.com.cn
181 600663.SH 上海陆家嘴金融贸易区开发股份有限公司 上海市中国(上海)自由贸易试验区浦东大道981号 86-21-33848801 invest@ljz.com.cn www.ljz.com.cn
182 600675.SH 中华企业股份有限公司 上海市静安区华山路2号 86-21-20772222,86-21-20772700,86-21-20770176 zhqydm@cecl.com.cn;zhqy@cecl.com.cn www.cecl.com.cn
183 600676.SH 上海交运集团股份有限公司 上海市浦东新区曹路工业园区民冬路239号 86-21-63172168,86-21-63178257 jygf@sh163.net www.cnsjy.com
184 600679.SH 上海凤凰企业(集团)股份有限公司 上海市金山区金山工业区开乐大街158号6号楼 86-21-32795679,86-21-32795689 master@phoenix.com.cn;zpc@phoenix.com.cn;mw@phoenix.com.cn www.phoenix.com.cn
185 600688.SH 中国石化上海石油化工股份有限公司 上海市金山区金一路48号 86-21-57943143,86-21-57933728 spc@spc.com.cn;yuguangxian@spc.com.cn;liugang@spc.com.cn;huanglixin@spc.com.cn www.spc.com.cn
186 600689.SH 上海三毛企业(集团)股份有限公司 上海市浦东新区自由贸易试验区浦东大道1476号,1482号1401-1415室 86-21-63059496,86-21-63028180 wuxy@600689.com;sanmaogroup@600689.com;zhouzy@600689.com;hegy@600689.com www.600689.com
187 600692.SH 上海亚通股份有限公司 上海市崇明区城桥镇八一路1号 86-21-69695918,86-21-69692618 leibnize@126.com;yt69692618@126.com;ytgfwhl@163.com www.shanghaiyatong.com
188 600696.SH 上海贵酒股份有限公司 上海市奉贤区南桥镇沪发路65弄1号 86-851-22292688,86-851-22276618 IRM600696@163.com www.sh600696.com
189 600708.SH 光明房地产集团股份有限公司 上海市中国(上海)自由贸易试验区临港新片区丽正路1628号9幢2层A-75室 86-21-32211128,86-21-61102888 zhengchao@bre600708.com;tzzrx@bre600708.com;supengcheng@bre600708.com www.bre600708.com
190 600732.SH 上海爱旭新能源股份有限公司 上海市浦东新区秋月路26号4幢201-1室 86-579-85912509 IR@aikosolar.com www.aikosolar.com
191 600741.SH 华域汽车系统股份有限公司 上海市静安区威海路489号 86-21-23102080 huayuqiche@hasco-group.com www.hasco-group.com
192 600748.SH 上海实业发展股份有限公司 上海市中国(上海)自由贸易试验区浦东南路1085号华申大厦六楼 86-21-53858686 sid748@sidlgroup.com www.sidlgroup.com
193 600754.SH 上海锦江国际酒店股份有限公司 中国(上海)自由贸易试验区杨高南路889号东锦江大酒店商住楼四层(B区域) 86-21-63217132 JJIR@jinjianghotels.com www.jinjianghotels.sh.cn
194 600816.SH 建元信托股份有限公司 上海市杨浦区控江路1553号-1555号A座301室 86-21-63410710 600816@j-yuantrust.com www.j-yuantrust.com
195 600818.SH 中路股份有限公司 上海市浦东新区南六公路818号 86-21-52860258 600818@zhonglu.com.cn www.zhonglu.com.cn
196 600819.SH 上海耀皮玻璃集团股份有限公司 上海市中国(上海)自由贸易试验区张东路1388号4-5幢 86-21-61633599*2041,86-21-61633599*2237 stock@sypglass.com www.sypglass.com
197 600820.SH 上海隧道工程股份有限公司 上海市徐汇区宛平南路1099号 86-21-65419590 stecodd@stec.net;600820@stec.net www.stec.net
198 600822.SH 上海物资贸易股份有限公司 上海市黄浦区南苏州路325号7楼 86-21-63231818 600822@shwuzi.com;600822@shwmgf.com www.600822sh.com
199 600824.SH 上海益民商业集团股份有限公司 上海市黄浦区淮海中路809号甲七楼 86-21-64339888 yimin@yimingroup.com www.yimingroup.com
200 600825.SH 上海新华传媒股份有限公司 上海市闵行区剑川路951号5号楼1层西侧 86-21-60376284 xhcmpublic@xhmedia.com www.xhmedia.com
201 600826.SH 东浩兰生会展集团股份有限公司 上海市浦东新区陆家嘴东路161号2602室 86-21-63366287,86-21-63366275,86-21-63366298 stock@dlg-expo.com;zhangrongjian@dlg-expo.com;yujingyun@dlg-expo.com www.dlg-expo.com
202 600827.SH 上海百联集团股份有限公司 上海市浦东新区张杨路501号11楼1101室 86-21-63223344,86-21-63229537 blgf600827@bl.com www.bailian.sh.cn
203 600833.SH 上海第一医药股份有限公司 上海市黄浦区南京东路616号 86-21-64337282 shcred@online.sh.cn shdyyy.com.cn
204 600834.SH 上海申通地铁股份有限公司 上海市中国(上海)自由贸易试验区浦电路489号 86-21-54259953,86-21-54259971 600834@shtmetro.com;sunsihui@shtmetro.com;zhuying@shtmetro.com www.shtmetro.com
205 600835.SH 上海机电股份有限公司 上海市浦东新区北张家浜路128号 86-21-68547168 shjddm@chinasec.cn;xhh@chinasec.cn;sec@chinasec.cn www.chinasec.cn
206 600838.SH 上海九百股份有限公司 上海市静安区愚园路300号6楼D室 86-21-62569866,86-21-62569829,86-21-62729898*838 shjb838@shjb600838.com www.shjb600838.com
207 600841.SH 上海新动力汽车科技股份有限公司 上海市杨浦区军工路2636号 86-21-60652207 snatdsh@snat.com www.snat.com
208 600843.SH 上工申贝(集团)股份有限公司 上海市中国(上海)自由贸易试验区世纪大道1500号东方大厦12楼A-D室 86-21-68407515 600843@sgsbgroup.com;wuwj@sgsbgroup.com;shenlj@sgsbgroup.com www.sgsbgroup.com
209 600845.SH 上海宝信软件股份有限公司 上海市浦东新区自由贸易试验区郭守敬路515号 86-21-20378893 investor@baosight.com www.baosight.com
210 600846.SH 上海同济科技实业股份有限公司 上海市中国(上海)自由贸易试验区栖山路33号 86-21-65985860 tjkjsy@tjkjsy.com.cn www.tjkjsy.com.cn
211 600848.SH 上海临港控股股份有限公司 上海市松江区莘砖公路668号3层 86-21-64855827 ir@shlingang.com www.lingangholding.com
212 600850.SH 中电科数字技术股份有限公司 上海市嘉定区城北路378号1605室 86-21-33390000,86-21-33390288 ecczb@shecc.com;dm@shecc.com www.shecc.com
213 600851.SH 上海海欣集团股份有限公司 上海市松江区洞泾镇长兴路688号 86-21-57698100,86-21-63917000,86-21-57698031 600851@haixin.com;haq@haixin.com;zg@haixin.com www.haixin.com
214 600882.SH 上海妙可蓝多食品科技股份有限公司 上海市奉贤区金汇镇工业路899号8幢 86-21-50188700 ir@milkland.com.cn www.milkground.cn
215 600895.SH 上海张江高科技园区开发股份有限公司 中国(上海)自由贸易试验区春晓路289号802室 86-21-38959000 investors@600895.com www.600895.com;www.600895.cn
216 600958.SH 东方证券股份有限公司 上海市黄浦区中山南路119号东方证券大厦 86-21-63325888,86-21-63326373 wangrf@orientsec.com.cn;litingting@orientsec.com.cn;ir@orientsec.com.cn www.dfzq.com.cn
217 601021.SH 春秋航空股份有限公司 上海市长宁区虹桥路2599号 86-21-32315288 ir@ch.com www.ch.com
218 601156.SH 东方航空物流股份有限公司 上海市浦东机场机场大道66号 86-21-22365112 EAL-IR@ceair.com www.eal-ceair.com
219 601200.SH 上海环境集团股份有限公司 上海市长宁区虹桥路1881号 86-21-63901005,86-21-68907088,86-21-52564780 shhj@shenvir.com www.sh601200.com
220 601211.SH 国泰海通证券股份有限公司 上海市浦东新区自由贸易试验区商城路618号 86-21-38676798 dshbgs@gtht.com www.gtht.com
221 601229.SH 上海银行股份有限公司 上海市黄浦区中山南路688号 86-21-68475888,86-21-68476988 ir@bosc.cn;webmaster@bosc.cn www.bosc.cn
222 601231.SH 环旭电子股份有限公司 上海市浦东新区张江高科技园区集成电路产业区张东路1558号 86-21-58968418 Public@usiglobal.com www.usiglobal.com
223 601328.SH 交通银行股份有限公司 上海市浦东新区银城中路188号 86-21-58766688 investor@bankcomm.com www.bankcomm.com;www.bankcomm.cn
224 601519.SH 上海大智慧股份有限公司 上海市中国(上海)自由贸易试验区郭守敬路498号浦东软件园14幢22301-130座 86-21-20219988*39117 Ir@gw.com.cn www.gw.com.cn
225 601595.SH 上海电影股份有限公司 上海市徐汇区漕溪北路595号 86-21-33391000,86-21-33391188 huayifan@sh-sfc.com;sygf@sh-sfc.com;ir@sh-sfc.com www.sh-sfc.com
226 601601.SH 中国太平洋保险(集团)股份有限公司 上海市黄浦区中山南路1号 86-21-58767282 ir@cpic.com.cn www.cpic.com.cn
227 601607.SH 上海医药集团股份有限公司 上海市浦东新区张江路92号 86-21-63730908 pharm@sphchina.com;boardoffice@sphchina.com www.sphchina.com
228 601611.SH 中国核工业建设股份有限公司 上海市青浦区西虹桥商务区蟠龙路500号 86-21-31858805,86-21-31858801,86-21-31858860 dong_sh@cnecc.com;zhangyunpu@cnecc.com www.cnecc.com
229 601616.SH 上海广电电气(集团)股份有限公司 上海市奉贤区环城东路123弄1号4幢三层 86-21-67101661 office@csge.com www.sgeg.cn
230 601696.SH 中银国际证券股份有限公司 上海市浦东新区银城中路200号中银大厦39层 86-21-20328000 webmaster@bocichina.com;IR@bocichina.com www.bocichina.com
231 601702.SH 上海华峰铝业股份有限公司 上海市金山区月工路1111号 86-21-67276833,86-21-67276853 hfly@huafeng.com www.huafonal.com
232 601727.SH 上海电气集团股份有限公司 上海市长宁区华山路1100弄16号 86-21-33261888 service@shanghai-electric.com;ir@shanghai-electric.com www.shanghai-electric.com
233 601788.SH 光大证券股份有限公司 上海市静安区新闸路1508号 86-21-22169914 ebs@ebscn.com;independentdirector@ebscn.com www.ebscn.com
234 601825.SH 上海农村商业银行股份有限公司 上海市黄浦区中山东二路70号 86-21-61899333,86-21-962999 ir@shrcb.com www.shrcb.com
235 601828.SH 红星美凯龙家居集团股份有限公司 上海市浦东新区临御路518号6楼F801室 86-21-52820220 ir@chinaredstar.com www.chinaredstar.com
236 601866.SH 中远海运发展股份有限公司 上海市浦东新区自由贸易试验区国贸大厦A-538室 86-21-65967333,86-21-65966105 ir@coscoshipping.com development.coscoshipping.com
237 601872.SH 招商局能源运输股份有限公司 上海市浦东新区自由贸易试验区西里路55号9楼912A室 86-852-28597361,86-21-63361872 IR@cmhk.com www.cmenergyshipping.com
238 601968.SH 上海宝钢包装股份有限公司 上海市宝山区罗东路1818号 86-21-56766307 ir601968@baosteel.com www.baosteelpackaging.com
239 603006.SH 上海联明机械股份有限公司 上海市浦东新区川沙路905号 86-21-58560017 yangmingmin@shanghailm.com;ir@shanghailm.com;duanyinyu@shanghailm.com www.shanghailmjx.com
240 603009.SH 上海北特科技集团股份有限公司 上海市嘉定区华亭镇高石路(北新村内)(一照多址企业) 86-21-62190266*666 touzizhe@beite.net.cn www.beite.net.cn
241 603012.SH 上海创力集团股份有限公司 上海市青浦区新康路889号 86-21-59869117 shcl@shclkj.com www.shclkj.com
242 603020.SH 爱普香料集团股份有限公司 上海市嘉定区曹新公路33号 86-21-66523100 jye@cnaff.com;hanqing.qin@cnaff.com www.cnaff.com
243 603022.SH 上海新通联包装股份有限公司 上海市宝山区罗北路1238号 86-21-36535008 zqb@xtl.sh.cn www.xtlpacking.com
244 603030.SH 上海全筑控股集团股份有限公司 上海市青浦区朱家角镇沪青平公路6335号7幢461 86-21-33372630 ir@trendzone.com.cn;Sunhaijun@trendzone.com www.trendzone.com.cn
245 603037.SH 上海凯众材料科技股份有限公司 上海市浦东新区建业路813号 86-21-58388958 kaizhongdm@carthane.com www.carthane.com
246 603039.SH 泛微网络科技股份有限公司 上海市奉贤区环城西路3006号 86-21-68869298*6109,86-21-68869298*2032 weaver@weaver.com.cn;jolin.zhou@weaver.com.cn;Haohan.gu@weaver.com.cn www.weaver.com.cn
247 603056.SH 德邦物流股份有限公司 上海市青浦区徐泾镇徐祥路316号1幢 86-21-39288106 ir@deppon.com www.deppon.com
248 603057.SH 紫燕食品集团股份有限公司 上海市闵行区申南路215号 86-21-52969658 ziyan@ziyanfoods.com www.ziyanfoods.com
249 603068.SH 博通集成电路(上海)股份有限公司 上海市中国(上海)自由贸易试验区张东路1387号41幢101(复式)室2F-3F/102(复式)室 86-21-51086811*8899 IR@bekencorp.com www.bekencorp.com
250 603083.SH 上海剑桥科技股份有限公司 上海市闵行区陈行公路2388号8幢501室 86-21-60904272 investor@cigtech.com www.cigtech.com
251 603108.SH 上海润达医疗科技股份有限公司 上海市金山区卫昌路1018号1号楼201室 86-21-68406213 board@rundamedical.com www.rundamedical.com
252 603121.SH 上海华培数能科技(集团)股份有限公司 上海市青浦区崧秀路218号3幢厂房 86-21-31838505 board@sinotec.cn www.sinotec.cn
253 603122.SH 合富(中国)医疗科技股份有限公司 中国(上海)自由贸易试验区新灵路118号606B室 86-21-60378999,86-21-60378999*8668 ir_cowealth@cowealth.com ch.cowealth.com
254 603128.SH 港中旅华贸国际物流股份有限公司 上海市黄浦区南京西路338号20楼 86-21-63588811 ird@ctsfreight.com www.ctsfreight.com
255 603131.SH 上海沪工焊接集团股份有限公司 上海市青浦区外青松公路7177号 86-21-59715700 hggf@hugong.com www.hugong.com
256 603153.SH 上海建科咨询集团股份有限公司 上海市徐汇区宛平南路75号 86-21-64390809,86-21-31655960 ir@sribs.com www.sribs.com
257 603159.SH 上海亚虹模具股份有限公司 上海市奉贤区沪杭公路732号 86-21-37596575 yahong@xxyhmj.com.cn;sumy@xxyhmj.com.cn;baohan@xxyhmj.com.cn www.yahong-mold.com
258 603170.SH 上海宝立食品科技股份有限公司 上海市松江茸北工业区茸兴路433号 86-21-31823950 bolex_office@bolexfoods.com www.bolexfoods.com
259 603189.SH 上海网达软件股份有限公司 上海市浦东新区自由贸易试验区川桥路409号 86-21-50306629 sunlin@wondertek.com.cn;xuwen@wondertek.com.cn www.wondertek.com.cn
260 603192.SH 上海汇得科技股份有限公司 上海市金山区金山卫镇春华路180号 86-21-37285599,86-21-37285501 hdkj@shhdsz.com www.shhdsz.com
261 603196.SH 日播时尚集团股份有限公司 上海市松江区中山街道茸阳路98号1幢2层 86-21-80104103 ir@ribo.com.cn www.ribo-group.com
262 603197.SH 上海保隆汽车科技股份有限公司 上海市松江区沈砖公路5500号 86-21-31273333 sbac@baolong.biz;wenjianfeng@chinabaolong.net;zhanghongmei@chinabaolong.net www.baolong.biz
263 603200.SH 上海洗霸科技股份有限公司 上海市嘉定区博学路138号6幢,7幢 86-21-55384785,86-21-65424668 shech@china-xiba.com;lchen@china-xiba.com www.china-xiba.com
264 603211.SH 晋拓科技股份有限公司 上海市松江区新浜工业园区胡甪路368号 86-21-57898686 webmaster@jintuo.com.cn www.sh-jintuo.com
265 603214.SH 上海爱婴室商务服务股份有限公司 上海市浦东新区浦东大道2123号3E-1157室 86-21-68470177 investor.list@aiyingshi.com www.aiyingshi.com
266 603226.SH 菲林格尔家居科技股份有限公司 上海市奉贤区林海公路7001号 86-21-67192899 zqswb@vohringer.com www.vohringer.com
267 603232.SH 格尔软件股份有限公司 上海市静安区江场西路299弄5号601室 86-21-62327028 stock@koal.com www.koal.com
268 603236.SH 上海移远通信技术股份有限公司 上海市松江区泗泾镇高技路205弄6号5层513室 86-21-51086236*6778 yiyuan@quectel.com www.quectel.com
269 603256.SH 宏和电子材料科技股份有限公司 上海市浦东康桥工业区秀沿路123号 86-21-38299688*6666 Honghe_news@gracefabric.com www.gracefabric.com
270 603324.SH 上海盛剑科技股份有限公司 上海市嘉定区汇发路301号 86-21-60712858 ir@sheng-jian.com www.sheng-jian.com
271 603329.SH 上海雅仕投资发展股份有限公司 上海市浦东新区自由贸易试验区浦东南路855号33H室 86-21-68596223 info@ace-sulfert.com www.aceonline.cn
272 603330.SH 天洋新材(上海)科技股份有限公司 上海市嘉定区南翔镇惠平路505号 86-21-69122665 wenliang.geng@hotmelt.com.cn;zhijun.lu@hotmelt.com.cn;IR@hotmelt.com.cn www.hotmelt.com.cn
273 603365.SH 上海水星家用纺织品股份有限公司 上海市奉贤区沪杭公路1487号 86-21-57435982 sxjf@shuixing.com www.shuixing.com
274 603378.SH 亚士创能科技(上海)股份有限公司 上海市青浦区工业园区新涛路28号综合楼三层,四层 86-21-59705888,86-21-59705888*8393 dmb@cuanon.com www.cuanon.com
275 603466.SH 上海风语筑文化科技股份有限公司 上海市静安区江场三路191号 86-21-56206468 licheng@fengyuzhu.com;ir@fengyuzhu.com;linshijing@fengyuzhu.com www.fengyuzhu.com
276 603496.SH 恒为科技(上海)股份有限公司 上海市徐汇区乐山路33号103室 86-21-61002983 securities.affairs@embedway.com www.embedway.com
277 603499.SH 上海翔港包装科技股份有限公司 上海市浦东新区泥城镇翠波路299号 86-21-58075851 tangjun@sunglow-tec.com;xg@sunglow-tec.com;lidanqing@sunglow-tec.com www.sunglow-tec.com
278 603501.SH 豪威集成电路(集团)股份有限公司 上海市浦东新区自由贸易试验区龙东大道3000号1幢C楼7层 86-21-50805043 will_stock@corp.ovt.com www.omnivision-group.com
279 603515.SH 欧普照明股份有限公司 上海市浦东新区龙东大道6111号1幢411室 86-21-38550000*6720 Public@opple.com www.opple.com.cn
280 603565.SH 上海中谷物流股份有限公司 上海市浦东新区双惠路99号综合楼106室 86-21-31761722 ir@zhonggu56.com;daixin@zhonggu56.com;liqiqi@zhonggu56.com www.zhonggu56.com
281 603579.SH 上海荣泰健康科技股份有限公司 上海市青浦区朱枫公路1226号 86-21-59833669 Public@rotai.com www.rotai.com
282 603580.SH 艾艾精密工业输送系统(上海)股份有限公司 上海市静安区万荣路700号7幢A240室 86-21-65305237 zhengquanbu@aabelt.com.cn www.aabelt.com.cn
283 603587.SH 地素时尚股份有限公司 上海市长宁区仙霞路579弄38号第2幢103室 86-21-31085111,86-21-31085300 info@dazzle-fashion.com www.dazzle-fashion.com
284 603619.SH 中曼石油天然气集团股份有限公司 上海市中国(上海)自由贸易试验区临港新片区南汇新城镇飞渡路2099号1幢1层 86-21-61048060 ssbgs@zpec.com;office@zpec.com www.zpec.com
285 603633.SH 上海徕木电子股份有限公司 上海市闵行区中春路7319号 86-21-67679072 ir@laimu.com.cn www.laimu.com.cn
286 603648.SH 上海畅联国际物流股份有限公司 上海市中国(上海)自由贸易试验区冰克路500号5-6幢 86-21-20895888 investor-relations@chinaslc.com www.chinaslc.com
287 603650.SH 彤程新材料集团股份有限公司 上海市浦东新区自由贸易试验区银城中路501号上海中心25层2501室 86-21-62109966 securities@rachem.com www.rachem.com
288 603659.SH 上海璞泰来新能源科技集团股份有限公司 上海市浦东新区自由贸易试验区芳春路400号1幢301-96室 86-21-61902930 IR@putailai.com www.putailai.com
289 603681.SH 上海永冠众诚新材料科技(集团)股份有限公司 上海市青浦区朱家角工业园区康工路15号 86-21-59830677 ir@ygtape.com;sq@ygtape.com www.ygtape.com
290 603682.SH 上海锦和商业经营管理(集团)股份有限公司 上海市徐汇区虹漕路68号43幢18楼 86-21-52399283 dongban@jinhe.sh.cn www.iyuejie.com
291 603683.SH 上海晶华胶粘新材料股份有限公司 上海市松江区永丰街道大江路89号 86-21-31167522,86-512-80179506 xiaochan.pan@smithcn.com;zijie.chen@smithcn.com;jhxc@smithcn.com www.smithcn.com
292 603690.SH 上海至纯洁净系统科技股份有限公司 上海市闵行区紫海路170号 86-21-80238290 dong_ban@pncs.cn www.pncs.cn
293 603713.SH 密尔克卫智能供应链服务集团股份有限公司 上海市浦东新区中国(上海)自由贸易试验区金葵路158号4-11层 86-21-80228498 ir@mwclg.com www.mwclg.com
294 603718.SH 上海海利生物技术股份有限公司 上海市奉贤区自由贸易试验区临港新片区正博路1881号19幢一层1002室 86-21-60890892,86-21-60890888 ir@hile-bio.com;china@hile-bio.com www.hile-bio.com
295 603728.SH 上海鸣志电器股份有限公司 上海市闵行区闵北路88弄1-30号109幢1层7101室 86-21-52634688 dm@moons.com.cn www.moons.com.cn
296 603729.SH 上海龙韵文创科技集团股份有限公司 上海市松江区佘山三角街9号 86-21-58823977 longyuntzz@obm.com.cn www.obm.com.cn
297 603730.SH 上海岱美汽车内饰件股份有限公司 上海市浦东新区北蔡镇莲溪路1299号 86-21-68945881 IR@daimay.com www.daimay.com
298 603777.SH 上海来伊份股份有限公司 上海市松江区九亭镇久富路300号 86-21-51760952 corporate@laiyifen.com;linyun1@laiyifen.com www.laiyifen.com
299 603786.SH 科博达技术股份有限公司 上海市浦东新区自由贸易试验区祖冲之路2388号1-2幢 86-21-60978935 keboda@keboda.com www.keboda.com
300 603790.SH 上海雅运纺织化工股份有限公司 上海市徐汇区银都路388号16幢275-278室 86-21-69136448 ir@argus.net.cn www.argus.net.cn
301 603855.SH 华荣科技股份有限公司 上海市嘉定区宝钱公路555号 86-21-59999999 warom@warom.com;zxr@warom.com;hrzhd@warom.com www.warom.com
302 603868.SH 上海飞科电器股份有限公司 上海市松江区广富林东路555号 86-21-52858888*839 flyco@flyco.com www.flyco.com
303 603881.SH 上海数据港股份有限公司 上海市静安区江场路1401弄14号1601室 86-21-31762186 ir@athub.com www.athub.com
304 603885.SH 上海吉祥航空股份有限公司 上海市浦东新区自由贸易试验区康桥东路8号 86-21-61988832 ir@juneyaoair.com www.juneyaoair.com
305 603886.SH 上海元祖梦果子股份有限公司 上海市青浦区赵巷镇嘉松中路6088号 86-21-59755678*6800 gansoinfo@ganso.net www.ganso.com.cn
306 603887.SH 上海城地香江数据科技股份有限公司 上海市嘉定区恒永路518弄1号B区502-1 86-21-52806755 shchengdi@163.com www.shcd.cc
307 603895.SH 上海天永智能装备股份有限公司 上海市嘉定区外冈镇汇宝路555号3幢2层A区 86-21-50675528,86-21-69920928 lvaihua@ty-industries.com;943731796@qq.com www.ty-industries.com
308 603899.SH 上海晨光文具股份有限公司 上海市奉贤区金钱公路3469号3号楼 86-21-57475621 ir@mg-pen.com www.mg-pen.com
309 603918.SH 上海金桥信息股份有限公司 上海市浦东新区郭守敬路498号12幢21302;21319室 86-21-33674997,86-21-33674396 shaole@shgbit.com;gaodd@shgbit.com;yaoming@shgbit.com;shgbit@shgbit.com www.shgbit.com
310 603956.SH 上海威派格智慧水务股份有限公司 上海市嘉定区恒定路1号 86-21-69080885 zqswb@shwpg.com www.shwpg.com
311 603960.SH 上海克来机电自动化工程股份有限公司 上海市宝山区罗东路1555号4幢 86-21-33850028 kelai.jidian@sh-kelai.com www.sh-kelai.com
312 603987.SH 上海康德莱企业发展集团股份有限公司 上海市嘉定区高潮路658号1幢2楼 86-21-69113502 kdl@kdlchina.net;dm@kdlchina.net;oult@kdlchina.com www.kdlchina.cn
313 605050.SH 福然德股份有限公司 上海市宝山区潘泾路3759号(宝山工业园区) 86-21-66898585,86-21-66898558 shcq@scmfriend.com;zqb@scmfriend.com www.scmfriend.com
314 605081.SH 上海太和水科技发展股份有限公司 上海市金山区枫泾镇曹黎路38弄19号1957室 86-21-65661627 dongmiban@shtaihe.net www.shtaihe.net
315 605098.SH 上海行动教育科技股份有限公司 上海市闵行区兴虹路168弄3号201室B区 86-21-33535658 lindayang@xdjy100.com;dongban@xdjy100.com www.xdjy100.com
316 605128.SH 上海沿浦精工科技(集团)股份有限公司 上海市闵行区浦江镇江凯路128号 86-21-64918973*8101 ypgf@shyanpu.com www.shyanpu.com
317 605136.SH 上海丽人丽妆化妆品股份有限公司 上海市松江区乐都西路825弄89,90号6层618室 86-21-64663911 shlrlz@lrlz.com www.lrlz.com
318 605151.SH 西上海汽车服务股份有限公司 上海市嘉定区恒裕路517号 86-21-59573618 servicesh@wsasc.com.cn www.wsasc.com.cn
319 605186.SH 上海健麾信息技术股份有限公司 上海市松江区中辰路299号1幢104室 86-21-58380355 stock@g-healthy.com www.g-healthy.com
320 605208.SH 上海永茂泰汽车科技股份有限公司 上海市青浦区练塘镇章练塘路577号 86-21-59815266 ymtauto@ymtauto.com www.ymtauto.com
321 605222.SH 上海起帆电缆股份有限公司 上海市金山区张堰镇振康路238号 86-21-37217999 qifancable@188.com www.qifancable.com
322 605289.SH 上海罗曼科技股份有限公司 上海市杨浦区杨树浦路1196号5层 86-21-65031217,86-21-65031217*208,86-21-65031217*222 IRmanager@luoman.com.cn;Zhangzhengyu@luoman.com.cn;shlm@shluoman.cn www.shluoman.cn;www.luoman.com.cn
323 605338.SH 中饮巴比食品股份有限公司 上海市松江区车墩镇茸江路785号 86-21-57797068 ir@babifood.com www.babifood.com
324 605339.SH 南侨食品集团(上海)股份有限公司 上海市徐汇区宜山路1397号A栋12层 86-21-61955678 ncfgs@ncbakery.com www.ncbakery.com
325 605398.SH 上海新炬网络信息技术股份有限公司 上海市青浦区外青松公路7548弄588号1幢1层R区113室 86-21-52908588 IR@shsnc.com www.shsnc.com
326 605598.SH 上海港湾基础建设(集团)股份有限公司 上海市金山区漕泾镇亭卫公路3316号1幢二层207室 86-21-65638550 ir@geoharbour.com www.geoharbour.com
327 688008.SH 澜起科技股份有限公司 上海市徐汇区漕宝路181号1幢15层 86-21-54679039 ir@montage-tech.com www.montage-tech.com/cn
328 688012.SH 中微半导体设备(上海)股份有限公司 上海市浦东新区金桥出口加工区(南区)泰华路188号 86-21-61001199 IR@amecnsh.com www.amec-inc.com
329 688016.SH 上海微创心脉医疗科技(集团)股份有限公司 上海市浦东新区康新公路3399弄1号 86-21-38139300 irm@endovastec.com www.endovastec.com
330 688018.SH 乐鑫信息科技(上海)股份有限公司 上海市浦东新区御北路235弄3号楼1-7层 86-21-61065218 ir@espressif.com www.espressif.com
331 688019.SH 安集微电子科技(上海)股份有限公司 上海市浦东新区华东路5001号金桥出口加工区(南区)T6-9幢底层 86-21-20693346 IR@anjimicro.com www.anjimicro.com
332 688031.SH 星环信息科技(上海)股份有限公司 上海市徐汇区虹漕路88号3楼,B栋11楼 86-21-61761338 ir@transwarp.io www.transwarp.cn
333 688061.SH 上海灿瑞科技股份有限公司 上海市静安区延长路149号科技楼308室 86-21-56387201,86-21-36399007 ocsir@orient-chip.com www.orient-chip.com
334 688062.SH 迈威(上海)生物科技股份有限公司 上海市浦东新区自由贸易试验区蔡伦路230号2幢105室 86-21-58332260 ir@mabwell.com www.mabwell.com
335 688063.SH 上海派能能源科技股份有限公司 上海市浦东新区康桥镇苗桥路300号 86-21-31590029 ir@pylontech.com.cn www.pylontech.com.cn
336 688065.SH 上海凯赛生物技术股份有限公司 上海市浦东新区蔡伦路1690号5幢4楼 86-21-50801916 cathaybiotech_info@cathaybiotech.com www.cathaybiotech.com
337 688071.SH 上海华依科技集团股份有限公司 上海市浦东新区张东路1388号13幢101室 86-21-61051366 investor@w-ibeda.com www.w-ibeda.com
338 688073.SH 上海毕得医药科技股份有限公司 上海市杨浦区翔殷路128号11号楼A座101室 86-21-61601560 ir@bidepharmatech.com www.bidepharmatech.com
339 688082.SH 盛美半导体设备(上海)股份有限公司 上海市浦东新区自由贸易试验区丹桂路999弄5,6,7,8号全幢 86-21-50276506 ir@acmrcsh.com www.acmrcsh.com.cn
340 688085.SH 上海三友医疗器械股份有限公司 上海市嘉定区嘉定工业区汇荣路385号 86-21-58266088 ir@sanyou-medical.com www.sanyoumed.com
341 688091.SH 上海谊众药业股份有限公司 上海市奉贤区仁齐路79号 86-21-37190005 info@yizhongpharma.com www.yizhongpharma.com
342 688098.SH 申联生物医药(上海)股份有限公司 上海市闵行区江川东路48号 86-21-61255101 slsw@slbio.com.cn www.slbio.com.cn
343 688099.SH 晶晨半导体(上海)股份有限公司 上海市浦东新区自由贸易试验区春晓路350号南楼406室 86-21-38165066 IR@amlogic.com www.amlogic.com;www.Amlogic.cn
344 688107.SH 上海安路信息科技股份有限公司 上海市虹口区纪念路500号5幢202室 86-21-61633787 public@anlogic.com www.anlogic.com
345 688110.SH 东芯半导体股份有限公司 上海市青浦区赵巷镇沪青平公路2875号3幢13层B区1336室 86-21-61369022 contact@dosilicon.com www.dosilicon.com
346 688118.SH 普元信息技术股份有限公司 上海市浦东新区自由贸易试验区学林路36弄26号 86-21-58331900 info@primeton.com www.primeton.com
347 688121.SH 上海卓然工程技术股份有限公司 上海市闵行区闵北路88弄1-30号104幢4楼D座 86-21-68815818 supezet@supezet.com www.supezet.com
348 688123.SH 聚辰半导体股份有限公司 上海市浦东新区张东路1761号10幢 86-21-50802035 investors@giantec-semi.com www.giantec-semi.com
349 688126.SH 上海硅产业集团股份有限公司 上海市嘉定区兴邦路755号3幢 86-21-52589038 pr@sh-nsig.com www.nsig.com
350 688129.SH 东来涂料技术(上海)股份有限公司 上海市嘉定区工业区新和路1221号 86-21-39538548 IR@onwings.com.cn www.onwings.com.cn
351 688131.SH 上海皓元医药股份有限公司 上海市浦东新区中国(上海)自由贸易试验区蔡伦路720弄2号501室 86-21-58338205 hy@chemexpress.com.cn www.chemexpress.com.cn
352 688133.SH 上海泰坦科技股份有限公司 上海市徐汇区钦州路100号一号楼1110室 86-21-60878330 contact@titansci.com www.titansci.com
353 688155.SH 上海先惠自动化技术股份有限公司 上海市松江区小昆山镇思贤路4800号 86-21-57858808 info@sk1.net.cn www.sk1.net.cn
354 688158.SH 优刻得科技股份有限公司 上海市杨浦区隆昌路619号10#B号楼201室 86-21-55509888*8188 ir@ucloud.cn www.ucloud.cn
355 688160.SH 上海步科自动化股份有限公司 上海市浦东新区自由贸易试验区申江路5709号,秋月路26号3幢北侧三楼 86-755-86336477 sec@kinco.cn www.kinco.cn
356 688163.SH 上海赛伦生物技术股份有限公司 上海市青浦区华青路1288号 86-21-64959122 dmb@serum-china.com www.serum-china.com.cn
357 688179.SH 上海阿拉丁生化科技股份有限公司 上海市奉贤区楚华支路809号 86-21-50560989 aladdindmb@163.com www.aladdin-e.com
358 688180.SH 上海君实生物医药科技股份有限公司 上海市浦东新区中国(上海)自由贸易试验区蔡伦路987号4层 86-21-61058800*1153 info@junshipharma.com www.junshipharma.com
359 688188.SH 上海柏楚电子科技股份有限公司 上海市闵行区兰香湖南路1000号 86-21-64306968 bochu@fscut.com;bochu@bochu.com www.fscut.comwww.bochu.com
360 688193.SH 上海仁度生物科技股份有限公司 上海市浦东新区张江高科技园区东区瑞庆路528号15幢乙号 86-21-50720069 ir@rdbio.com www.rdbio.com
361 688202.SH 上海美迪西生物医药股份有限公司 上海市浦东新区自由贸易试验区李冰路67弄5号楼 86-21-58591500 IR@medicilon.com.cn www.medicilon.com.cn
362 688206.SH 上海概伦电子股份有限公司 上海市浦东新区自由贸易试验区临港新片区环湖西二路888号C楼 86-21-61640095 IR@primarius-tech.com www.primarius-tech.com
363 688212.SH 上海澳华内镜股份有限公司 上海市闵行区光中路133弄66号 86-21-54303731 ir@aohua.com www.aohua.com
364 688213.SH 思特威(上海)电子科技股份有限公司 上海市浦东新区中国(上海)自由贸易试验区祥科路111号3号楼6楼612室 86-21-64853572 ir@smartsenstech.com www.smartsenstech.com
365 688217.SH 上海睿昂基因科技股份有限公司 上海市奉贤区金海公路6055号3幢 86-21-33282601 zqswb@rightongene.com www.rightongene.com
366 688220.SH 翱捷科技股份有限公司 上海市浦东新区自由贸易试验区科苑路399号10幢8层(名义楼层9层) 86-21-60336588*1188 ir@asrmicro.com www.asrmicro.com
367 688230.SH 上海芯导电子科技股份有限公司 上海市浦东新区自由贸易试验区祖冲之路2277弄7号 86-21-60753051 investor@prisemi.com www.prisemi.com
368 688238.SH 和元生物技术(上海)股份有限公司 上海市浦东新区国际医学园区紫萍路908弄19号楼 86-21-58180909 zhengquanbu@obiosh.com www.obiosh.com
369 688247.SH 上海宣泰医药科技股份有限公司 上海市浦东新区中国(上海)自由贸易试验区蔡伦路780号7层709室 86-21-68819009*657 info@sinotph.com www.sinotph.com
370 688265.SH 上海南方模式生物科技股份有限公司 上海市浦东新区琥珀路63弄1号6层 86-21-58120591 ir@modelorg.com www.modelorg.com
371 688271.SH 上海联影医疗科技股份有限公司 上海市嘉定区城北路2258号 86-21-67076658 IR@united-imaging.com www.united-imaging.com
372 688293.SH 上海奥浦迈生物科技股份有限公司 上海市浦东新区紫萍路908弄28号 86-21-20780178 ir@opmbiosciences.com;IR@opmbiosciences.com www.opmbiosciences.com
373 688301.SH 奕瑞电子科技集团股份有限公司 上海市浦东新区环桥路999号 86-21-50720560,86-21-50720560*8311 ir@iraygroup.com www.iraygroup.com
374 688317.SH 上海之江生物科技股份有限公司 上海市浦东新区张江高科技产业东区瑞庆路528号20幢乙号1层,21幢甲号1层 86-21-34635507 info@liferiver.com.cn www.liferiver.com.cn
375 688330.SH 上海宏力达信息技术股份有限公司 上海市松江区九亭中心路1158号11幢101,401室(一照多址企业) 86-21-64180758 hld.mail@holystar.com.cn www.holystar.com.cn
376 688335.SH 上海复洁科技股份有限公司 上海市杨浦区国权北路1688弄A7幢801室 86-21-55081682 ir@ceo.sh.cn www.ceo.sh.cn
377 688336.SH 三生国健药业(上海)股份有限公司 上海市浦东新区中国(上海)自由贸易试验区李冰路399号 86-21-80297676 ir@3s-guojian.com www.3s-guojian.com
378 688351.SH 上海微创电生理医疗科技股份有限公司 上海市浦东新区周浦镇天雄路588弄1-28号第28幢 86-21-60969600*53608 investors@everpace.com www.everpace.com
379 688366.SH 上海昊海生物科技股份有限公司 上海市松江区工业区洞泾路5号 86-21-52293555 info@3healthcare.com www.3healthcare.com
380 688368.SH 上海晶丰明源半导体股份有限公司 上海市浦东新区中国(上海)自由贸易试验区申江路5005弄3号9-11层,2号102单元 86-21-50278297 IR@bpsemi.com www.bpsemi.com
381 688370.SH 上海丛麟环保科技股份有限公司 上海市闵行区闵虹路166弄3号2808室 86-21-60713846 ir@cn-conglin.com www.cn-conglin.com
382 688372.SH 上海伟测半导体科技股份有限公司 上海市浦东新区东胜路38号A区2栋2F 86-21-58958216 ir@v-test.com.cn www.v-test.com.cn
383 688373.SH 上海盟科药业股份有限公司 上海市浦东新区中国(上海)自由贸易试验区爱迪生路53号1幢1-4层101,2幢 86-21-50900550,86-21-50900503 info@micurxchina.com;688373@micurxchina.com micurxchina.com
384 688382.SH 益方生物科技(上海)股份有限公司 上海市浦东新区自由贸易试验区李冰路67弄4号210室 86-21-50778527 ir@inventisbio.com www.inventisbio.com
385 688385.SH 上海复旦微电子集团股份有限公司 上海市杨浦区邯郸路220号 86-21-65659109 IR@fmsh.com.cn www.fmsh.com
386 688391.SH 钜泉光电科技(上海)股份有限公司 上海市浦东新区中国(上海)自由贸易试验区张东路1388号16幢101室 86-21-51035886,86-21-50277832 shareholders@hitrendtech.com www.hitrendtech.com
387 688392.SH 上海骄成超声波技术股份有限公司 上海市闵行区沧源路1488号2幢三层 86-21-34668757 ir@sbt-sh.com www.sbt-sh.com
388 688435.SH 上海英方软件股份有限公司 上海市黄浦区制造局路787号二幢151A室 86-21-61735888 investor@info2soft.com www.info2soft.com;www.info2soft.cn
389 688505.SH 上海复旦张江生物医药股份有限公司 上海市浦东新区张江高科技园区蔡伦路308号 86-21-58953355,86-21-58553583 ir@fd-zj.com;fd-zj@fd-zj.com www.fd-zj.com
390 688519.SH 南亚新材料科技股份有限公司 上海市嘉定区南翔镇昌翔路158号 86-21-69178431 nanya@ccl-china.com www.ccl-china.com
391 688521.SH 芯原微电子(上海)股份有限公司 上海市浦东新区自由贸易试验区春晓路289号张江大厦20A 86-21-68608521 IR@verisilicon.com www.verisilicon.com
392 688538.SH 上海和辉光电股份有限公司 上海市金山区九工路1568号 86-21-60892866 ir@everdisplay.com www.everdisplay.com
393 688578.SH 上海艾力斯医药科技股份有限公司 上海市浦东新区周浦镇凌霄花路268号 86-21-80423292 IR@allist.com.cn www.allist.com.cn
394 688585.SH 上纬新材料科技股份有限公司 上海市松江区松胜路618号 86-21-57746183 ir@swancor.com.cn www.swancor.com.cn
395 688590.SH 上海新致软件股份有限公司 上海市浦东新区康杉路308号 86-21-51105633 investor@newtouch.com www.newtouch.com
396 688596.SH 上海正帆科技股份有限公司 上海市闵行区春永路55号2幢 86-21-54428800 ir@gentech-online.com www.gentechindustries.com
397 688608.SH 恒玄科技(上海)股份有限公司 上海市浦东新区中国(上海)自由贸易试验区临港新片区环湖西二路800号904室 86-21-68771788*6666 ir@bestechnic.com www.bestechnic.com
398 688660.SH 上海电气风电集团股份有限公司 上海市闵行区东川路555号己号楼8楼 86-21-54961895 sewc_ir@shanghai-electric.com www.sewpg.com
399 688680.SH 上海海优威新材料股份有限公司 上海市中国(上海)自由贸易试验区龙东大道3000号1幢A楼909 86-21-58964210 hiuv@hiuv.com www.hiuv.com
400 688682.SH 上海霍莱沃电子系统技术股份有限公司 上海市浦东新区自由贸易试验区郭守敬路498号15幢1层16102室 86-21-50809715 ir@holly-wave.com www.holly-wave.com
401 688718.SH 上海唯赛勃新材料股份有限公司 上海市青浦区崧盈路899号 86-21-69758436 investor@wave-cyber.com www.wave-cyber.com
402 688728.SH 格科微有限公司 Harneys Fiduciary (Cayman) Limited, 4th Floor, Harbour Place; 103 South Church Street; P.O. Box 10240; Grand Cayman KY1-1002; George Town; Cayman Islands 86-21-60126212 ir@gcoreinc.com www.gcoreinc.com
403 688766.SH 普冉半导体(上海)股份有限公司 中国(上海)自由贸易试验区申江路5005弄1号9层整层(实际楼层8楼) 86-21-60791797 ir@puyasemi.com www.puyasemi.com
404 688798.SH 上海艾为电子技术股份有限公司 上海市闵行区秀文路908弄2号1201室 86-21-52968068 securities@awinic.com www.awinic.com
405 688981.SH 中芯国际集成电路制造有限公司 Cricket Square, Hutchins Drive, P.O. Box 2681, Grand Cayman, KY1-1111 Cayman Islands 86-21-20812800 ir@smics.com www.smics.com
406 900929.SH 上海锦江国际旅游股份有限公司 上海市黄浦区延安东路100号联谊大厦27楼 86-21-32128021,86-21-32128781 wuxh@jjtravel.com;qiansj@jjtravel.com www.jjtravel.com
407 900939.SH 上海汇丽建材股份有限公司 上海市浦东新区周浦镇横桥路406号1幢213室 86-21-58138717 stock@huili.com www.huili.com
408 920139.BJ 上海华岭集成电路技术股份有限公司 上海市浦东新区张江高科技园区郭守敬路351号2号楼1楼 86-21-50278218 investor@sinoictest.com.cn www.sinoictest.com.cn
409 920300.BJ 上海辰光医疗科技股份有限公司 上海市青浦区华青路1269号 86-21-60161688 SHCG@shanghaicg.net www.shanghaicg.net
410 920346.BJ 上海威贸电子股份有限公司 上海市青浦区练东路28,38号 86-21-54252988 contact@shwmdz.com;lucas.zhou@shwmdz.com www.shwmdz.com
411 920405.BJ 上海海希工业通讯股份有限公司 上海市松江区新桥镇新格路901号1幢3层A区 86-21-54902525 lichunyou@hysea.com.cn;IR@hysea.com.cn www.hysea.com.cn
412 920414.BJ 上海欧普泰科技创业股份有限公司 上海市普陀区中江路879弄27号楼208室 86-21-52659337 cathygxh@optjt.cn;investor@opt.jt.cn www.optjt.cn
413 920541.BJ 上海铁大电信科技股份有限公司 上海市嘉定区南翔镇蕰北公路1755弄6号 86-21-51235800 956470768@qq.com;tiedadianxin@tiedate.ltd www.tddx.com.cn
414 920799.BJ 上海艾融软件股份有限公司 上海市崇明区城桥镇西门路799号306室 86-21-68816718 wang_tao@i2finance.net;public@i2finance.net www.i2finance.net
415 920961.BJ 创远信科(上海)技术股份有限公司 上海市松江区泗泾镇高技路205弄7号1层110室,6层,7层,8层,9层 86-21-64326888 ir@transcom.net.cn;info@transcom.net.cn www.transcom.net.cn
416 002027.SZ 分众传媒信息技术股份有限公司 上海市长宁区江苏路369号27H 86-21-22165288 FM002027@focusmedia.cn;ln002027@focusmedia.cn www.focusmedia.cn
417 920693.BJ 上海阿为特精密机械股份有限公司 上海市宝山区宝安公路917号1幢一楼 86-21-65191708 securities@ahwit.com www.ahwit.com
418 920504.BJ 上海博迅医疗生物仪器股份有限公司 上海市松江区泖港镇中强路599号 86-21-66052732 yingyun@boxun.com.cn;boxun@boxun.com.cn www.boxun.com.cn
419 920112.BJ 上海巴兰仕汽车检测设备股份有限公司 上海市嘉定区安亭镇新源路58号701室J 86-21-39508868 zhengquanbu@balancer-sh.com www.balancer-sh.com
420 688710.SH 上海益诺思生物技术股份有限公司 上海市中国(上海)自由贸易试验区郭守敬路199号106室 86-21-50801259 bo@innostarbio.com www.innostarbio.com
421 688691.SH 灿芯半导体(上海)股份有限公司 上海市中国(上海)自由贸易试验区张东路1158号礼德国际2号楼6楼 86-21-50376585,86-21-50376566 IR@britesemi.com www.britesemi.com
422 688653.SH 格兰康希通信科技(上海)股份有限公司 上海市中国(上海)自由贸易试验区科苑路399号10幢4层(名义层5层)502室 86-21-50479130 kctzqb@kxcomtech.com www.kxcomtech.com
423 688648.SH 中邮科技股份有限公司 上海市普陀区中山北路3185号 86-21-62605607 ir@cpte.com www.cpte.com
424 688615.SH 上海合合信息科技股份有限公司 上海市静安区万荣路1256,1258号1105-1123室 86-21-63061283 ir@intsig.net www.intsig.com
425 688603.SH 上海天承科技股份有限公司 上海市浦东新区中国(上海)自由贸易试验区张江路665号3F306室 86-21-59766069 public@skychemcn.com;wei.fei@skychemcn.com;zhiqin.su@skychemcn.com www.skychemcn.com
426 688602.SH 上海康鹏科技股份有限公司 上海市普陀区祁连山南路2891弄200号1幢 86-21-63638712 ir@chemspec.com.cn www.chemspec.com.cn
427 688593.SH 上海新相微电子股份有限公司 上海市徐汇区苍梧路10号3层 86-21-51097181 office@newvisionu.com www.newvisionu.com.cn
428 688592.SH 上海司南导航技术股份有限公司 上海市嘉定区马陆镇澄浏中路618号2幢3楼 86-21-39907000,86-21-64302208 IR@sinognss.com www.sinognss.com
429 688591.SH 泰凌微电子(上海)股份有限公司 上海市浦东新区中国(上海)自由贸易试验区盛夏路61弄1号电梯楼层10层,11层(实际楼层9层,10层) 86-21-50653177 investors_relation@telink-semi.com www.telink-semi.cn
430 688584.SH 上海合晶硅材料股份有限公司 上海市松江区石湖荡镇长塔路558号 86-21-57843535,86-21-57843535*5829 ir@wwxs.waferworks.com www.waferworks.com.cn
431 688507.SH 上海索辰信息科技股份有限公司 上海市浦东新区中国(上海)自由贸易试验区新金桥路27号13号楼2层 86-21-50307121 Info@demxs.com www.demxs.com
432 688484.SH 上海南芯半导体科技股份有限公司 上海市浦东新区中国(上海)自由贸易试验区盛夏路565弄54号(4幢)1601 86-21-50182236 investors@southchip.com www.southchip.com
433 688479.SH 用友汽车信息科技(上海)股份有限公司 上海市普陀区泸定路276弄1号201室 86-21-52353603 zqb@yonyou.com www.yonyouqiche.com
434 688347.SH 华虹半导体有限公司 香港中环夏悫道12号美国银行中心2212室 86-21-38829909,86-21-50809908 IR@hhgrace.com www.huahonggrace.com
435 603418.SH 上海友升铝业股份有限公司 上海市青浦区沪青平公路2058号 86-21-59761698 yszq@unisonal.com www.unisonal.com
436 603350.SH 安乃达驱动技术(上海)股份有限公司 上海市闵行区光中路133弄19号A座1-2层 86-21-31027576,86-21-31027576*868 security@ananda-drive.com www.ananda-drive.com
437 603341.SH 上海龙旗科技股份有限公司 上海市徐汇区漕宝路401号1号楼一层 86-21-61890866 ir@longcheer.com www.longcheer.com
438 603325.SH 上海博隆装备技术股份有限公司 上海市青浦区华新镇新协路1356号 86-21-69792579 IR@bloom-powder.com www.bloom-powder.com
439 603296.SH 华勤技术股份有限公司 上海市中国(上海)自由贸易试验区科苑路399号1幢 86-21-80221108 ir@huaqin.com www.huaqin.com
440 603275.SH 上海众辰电子科技股份有限公司 上海市松江区泖港镇叶新公路3768号 86-21-57860561*8155,86-21-57860560 xuwenjun@zoncn.cn www.zoncn.cn
441 603207.SH 上海小方制药股份有限公司 上海市奉贤区洪朱路777号 86-21-58207999,86-21-50818259 info@xf-pharma.com www.xf-pharma.com
442 603107.SH 上海汽车空调配件股份有限公司 上海市浦东新区莲溪路1188号 86-21-58442000 zqb@saaa.com.cn www.saaa.com.cn
443 603062.SH 麦加芯彩新材料科技(上海)股份有限公司 上海市嘉定区马陆镇思诚路1515号 86-21-39907772 ir@megacoatings.com www.megacoatings.com
444 601083.SH 上海锦江航运(集团)股份有限公司 上海市浦东新区龙居路180弄13号2楼 86-21-53866646 ir@jjshipping.cn www.jjshipping.cn
445 601026.SH 道生天合材料科技(上海)股份有限公司 上海市中国(上海)自由贸易试验区临港新片区平达路308号1-3幢 86-21-53065580 investor@techstorm.com www.techstorm.com
446 301584.SZ 上海建发致新医疗科技集团股份有限公司 上海市杨浦区杨树浦路288号9层 86-21-60430629 jfzx@innostic.com www.innostic.com
447 301563.SZ 云汉芯城(上海)互联网科技股份有限公司 上海市松江区漕河泾开发区松江高科技园莘砖公路258号32幢1101室 86-21-31029123 ad@ickey.cn www.ickey.cn
448 301555.SZ 惠柏新材料科技(上海)股份有限公司 上海市嘉定区江桥镇博园路558号第2幢 86-21-59970621,86-21-69116380 board-office@wellsepoxy.com;guojuhan@wellsepoxy.com;board-office@wellswam.com www.wellswam.com
449 301525.SZ 上海儒竞科技股份有限公司 上海市杨浦区国权北路1688弄75号1204A室 86-21-61811998 juan.li@ruking.com;ir@ruking.com;weiwei.ma@ruking.com;haochao.li@ruking.com www.ruking.com
450 301499.SZ 上海维科精密模塑股份有限公司 上海市闵行区北横沙河路598号 86-21-64960228 IR@vico.com.cn www.vico.com.cn
451 301315.SZ 上海威士顿信息技术股份有限公司 上海市长宁区长宁路999号643室 86-21-65757700 DBO@wsdinfo.com www.wsdinfo.com
452 301173.SZ 上海毓恬冠佳科技股份有限公司 上海市青浦工业园区崧煌路580号 86-21-59219238 mobitech@mobitech.com.cn www.mobitech.com.cn

874
_doc/功能规划文档.md Normal file
View File

@@ -0,0 +1,874 @@
# 自动找工作系统 - 功能规划文档
> 版本: 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

File diff suppressed because it is too large Load Diff

0
_doc/指令列表.md Normal file
View File

387
_doc/项目功能总结.md Normal file
View File

@@ -0,0 +1,387 @@
# 自动找工作系统 - 项目功能总结
> 版本: 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

View File

@@ -0,0 +1,34 @@
-- 为 job_postings 表添加文本匹配分析结果字段
-- 执行时间: 2025-01-XX
-- ============================================
-- 更新 job_postings 表
-- ============================================
-- 添加 textMatchScore 字段(文本匹配综合评分)
ALTER TABLE job_postings
ADD COLUMN textMatchScore INT(11) DEFAULT 0 COMMENT '文本匹配综合评分' AFTER aiAnalysis;
-- 添加 matchSuggestion 字段(投递建议)
ALTER TABLE job_postings
ADD COLUMN matchSuggestion VARCHAR(200) DEFAULT '' COMMENT '投递建议' AFTER textMatchScore;
-- 添加 matchConcerns 字段关注点JSON数组
ALTER TABLE job_postings
ADD COLUMN matchConcerns TEXT COMMENT '关注点(JSON数组)' AFTER matchSuggestion;
-- 添加 textMatchAnalysis 字段文本匹配详细分析结果JSON
ALTER TABLE job_postings
ADD COLUMN textMatchAnalysis TEXT COMMENT '文本匹配详细分析结果(JSON)' AFTER matchConcerns;
-- ============================================
-- 说明
-- ============================================
-- 1. 如果字段已存在,执行 ALTER TABLE 会报错,可以忽略
-- 2. 执行前建议先备份数据库
-- 3. 字段说明:
-- - textMatchScore: 文本匹配综合评分0-100
-- - matchSuggestion: 投递建议(如:必须投递、可以投递、谨慎考虑等)
-- - matchConcerns: 关注点列表JSON数组格式["可能是外包岗位", "工作经验可能不足"]
-- - textMatchAnalysis: 完整分析结果JSON格式包含所有详细分析数据

View File

@@ -22,6 +22,7 @@
"css-loader": "^5.0.0",
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.5.0",
"mini-css-extract-plugin": "^2.9.4",
"style-loader": "^2.0.0",
"vue-loader": "^15.9.0",
"vue-style-loader": "^4.1.0",
@@ -4834,6 +4835,84 @@
"node": ">=6"
}
},
"node_modules/mini-css-extract-plugin": {
"version": "2.9.4",
"resolved": "https://registry.npmmirror.com/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.4.tgz",
"integrity": "sha512-ZWYT7ln73Hptxqxk2DxPU9MmapXRhxkJD6tkSR04dnQxm8BGu2hzgKLugK5yySD97u/8yy7Ma7E76k9ZdvtjkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"schema-utils": "^4.0.0",
"tapable": "^2.2.1"
},
"engines": {
"node": ">= 12.13.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
},
"peerDependencies": {
"webpack": "^5.0.0"
}
},
"node_modules/mini-css-extract-plugin/node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/mini-css-extract-plugin/node_modules/ajv-keywords": {
"version": "5.1.0",
"resolved": "https://registry.npmmirror.com/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
"integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3"
},
"peerDependencies": {
"ajv": "^8.8.2"
}
},
"node_modules/mini-css-extract-plugin/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT"
},
"node_modules/mini-css-extract-plugin/node_modules/schema-utils": {
"version": "4.3.3",
"resolved": "https://registry.npmmirror.com/schema-utils/-/schema-utils-4.3.3.tgz",
"integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/json-schema": "^7.0.9",
"ajv": "^8.9.0",
"ajv-formats": "^2.1.1",
"ajv-keywords": "^5.1.0"
},
"engines": {
"node": ">= 10.13.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",

View File

@@ -5,7 +5,7 @@
"scripts": {
"install:deps": "npm install",
"dev": "webpack serve --mode development --open",
"build": "webpack --mode production",
"build": "webpack --mode production --progress",
"build:test": "webpack --mode test"
},
"dependencies": {
@@ -23,6 +23,7 @@
"css-loader": "^5.0.0",
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.5.0",
"mini-css-extract-plugin": "^2.9.4",
"style-loader": "^2.0.0",
"vue-loader": "^15.9.0",
"vue-style-loader": "^4.1.0",

762
admin/public/register.html Normal file
View File

@@ -0,0 +1,762 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>邀请注册</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.register-container {
background: #ffffff;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
padding: 40px 50px;
width: 100%;
max-width: 600px;
max-height: 95vh;
overflow-y: auto;
}
.register-header {
text-align: center;
margin-bottom: 20px;
}
.register-header h1 {
color: #333;
font-size: 24px;
font-weight: 600;
margin-bottom: 6px;
}
.register-header p {
color: #666;
font-size: 14px;
}
.invite-code-badge {
display: inline-block;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 8px 16px;
border-radius: 20px;
font-size: 13px;
margin-bottom: 20px;
font-weight: 500;
}
.invite-code-badge.hidden {
display: none;
}
.form-group {
display: flex;
flex-wrap: wrap;
align-items: center;
margin-bottom: 12px;
}
.form-label {
display: inline-block;
color: #333;
font-size: 15px;
font-weight: 500;
margin-bottom: 0;
margin-right: 15px;
min-width: 100px;
flex-shrink: 0;
}
.form-input {
flex: 1;
padding: 12px 16px;
border: 2px solid #e0e0e0;
border-radius: 10px;
font-size: 15px;
background: #fafafa;
}
.form-input:focus {
outline: none;
border-color: #667eea;
background: #fff;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-input.error {
border-color: #ff4757;
background: #fff5f5;
}
.sms-code-wrapper {
display: flex;
align-items: center;
flex: 1;
}
.form-group .sms-code-wrapper .form-input {
margin-right: 10px;
}
.send-code-btn {
padding: 12px 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
min-width: 120px;
}
.send-code-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.error-message {
color: #ff4757;
font-size: 13px;
margin-top: 6px;
margin-left: 0;
width: 100%;
flex-basis: 100%;
display: none;
padding-left: 115px;
line-height: 1.4;
}
.error-message.show {
display: block;
}
.submit-btn {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 10px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
margin-top: 10px;
position: relative;
overflow: hidden;
}
.submit-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.submit-btn.loading {
color: transparent;
}
.submit-btn.loading::after {
content: '';
position: absolute;
width: 20px;
height: 20px;
top: 50%;
left: 50%;
margin-left: -10px;
margin-top: -10px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.success-message {
background: #d4edda;
color: #155724;
padding: 15px;
border-radius: 10px;
margin-bottom: 20px;
display: none;
text-align: center;
font-size: 14px;
}
.success-message.show {
display: block;
}
.error-alert {
background: #f8d7da;
color: #721c24;
padding: 15px;
border-radius: 10px;
margin-bottom: 20px;
display: none;
text-align: center;
font-size: 14px;
}
.error-alert.show {
display: block;
}
.register-tips {
margin-top: 15px;
padding: 12px 15px;
background: #f8f9fa;
border-radius: 10px;
border-left: 4px solid #667eea;
}
.register-tips h3 {
color: #333;
font-size: 13px;
font-weight: 600;
margin-bottom: 8px;
}
.register-tips ul {
list-style: none;
padding: 0;
margin: 0;
}
.register-tips li {
color: #666;
font-size: 12px;
line-height: 1.6;
padding-left: 18px;
position: relative;
margin-bottom: 3px;
}
.register-tips li::before {
content: '✓';
position: absolute;
left: 0;
color: #667eea;
font-weight: bold;
}
@media (max-width: 480px) {
.register-container {
padding: 30px 20px;
}
.register-header h1 {
font-size: 24px;
}
}
</style>
</head>
<body>
<div class="register-container">
<div class="register-header">
<h1>🎉 邀请注册</h1>
<p>填写信息完成注册,邀请人将获得奖励</p>
<div id="inviteCodeBadge" class="invite-code-badge hidden"></div>
</div>
<div id="successMessage" class="success-message"></div>
<div id="errorAlert" class="error-alert"></div>
<form id="registerForm">
<div class="form-group">
<label class="form-label" for="email">邮箱地址</label>
<input
type="email"
id="email"
class="form-input"
placeholder="请输入您的邮箱地址"
required
autocomplete="email"
/>
<div class="error-message" id="emailError"></div>
</div>
<div class="form-group">
<label class="form-label" for="emailCode">验证码</label>
<div class="sms-code-wrapper">
<input
type="text"
id="emailCode"
class="form-input"
placeholder="请输入邮箱验证码"
required
maxlength="6"
style="flex: 1; margin-right: 10px;"
/>
<button
type="button"
id="sendCodeBtn"
class="send-code-btn"
>
发送验证码
</button>
</div>
<div class="error-message" id="emailCodeError"></div>
</div>
<div class="form-group">
<label class="form-label" for="password">密码</label>
<input
type="password"
id="password"
class="form-input"
placeholder="请输入密码至少6位"
required
autocomplete="new-password"
minlength="6"
/>
<div class="error-message" id="passwordError"></div>
</div>
<div class="form-group">
<label class="form-label" for="confirmPassword">确认密码</label>
<input
type="password"
id="confirmPassword"
class="form-input"
placeholder="请再次输入密码"
required
autocomplete="new-password"
minlength="6"
/>
<div class="error-message" id="confirmPasswordError"></div>
</div>
<div class="form-group">
<label class="form-label" for="inviteCode">邀请码(选填)</label>
<input
type="text"
id="inviteCode"
class="form-input"
placeholder="请输入邀请码(可选)"
/>
<div class="error-message" id="inviteCodeError"></div>
</div>
<button type="submit" class="submit-btn" id="submitBtn">
立即注册
</button>
</form>
<div class="register-tips">
<h3>注册说明</h3>
<ul>
<li>邮箱将作为您的登录账号</li>
<li>密码长度至少6位建议使用字母+数字组合</li>
<li>邮箱验证码有效期为5分钟</li>
<li>邀请码为选填填写邀请码注册成功后邀请人将获得3天试用期奖励</li>
<li>请妥善保管您的账号信息</li>
</ul>
</div>
</div>
<script>
// 配置 API 地址(根据环境自动判断)
const API_BASE_URL = window.location.hostname === 'localhost'
? 'http://localhost:9097/api'
: 'https://work.light120.com/api';
// 获取 URL 参数中的邀请码
function getInviteCodeFromUrl() {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get('code') || '';
}
// 倒计时状态
let countdownTimer = null;
let countdownSeconds = 0;
// 显示/隐藏消息
function showMessage(elementId, message, isError = false) {
const element = document.getElementById(elementId);
element.textContent = message;
element.className = isError ? 'error-alert show' : 'success-message show';
// 3秒后自动隐藏
setTimeout(() => {
element.className = isError ? 'error-alert' : 'success-message';
}, 3000);
}
// 显示邀请码徽章
function displayInviteCodeBadge(code) {
if (code) {
const badge = document.getElementById('inviteCodeBadge');
badge.textContent = `邀请码: ${code}`;
badge.classList.remove('hidden');
}
}
// 自动填充邀请码
function autoFillInviteCode(code) {
if (code) {
const inviteCodeInput = document.getElementById('inviteCode');
inviteCodeInput.value = code;
}
}
// 发送邮箱验证码
async function sendEmailCode(email) {
try {
const response = await fetch(`${API_BASE_URL}/invite/send_email_code`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: email
})
});
const data = await response.json();
return data;
} catch (error) {
console.error('发送验证码失败:', error);
throw new Error('网络请求失败,请检查网络连接');
}
}
// 开始倒计时
function startCountdown(seconds = 60) {
const sendBtn = document.getElementById('sendCodeBtn');
countdownSeconds = seconds;
sendBtn.disabled = true;
const updateCountdown = () => {
if (countdownSeconds > 0) {
sendBtn.textContent = `${countdownSeconds}秒后重新发送`;
countdownSeconds--;
countdownTimer = setTimeout(updateCountdown, 1000);
} else {
sendBtn.disabled = false;
sendBtn.textContent = '发送验证码';
if (countdownTimer) {
clearTimeout(countdownTimer);
countdownTimer = null;
}
}
};
updateCountdown();
}
// 验证邮箱格式
function validateEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
// 显示字段错误
function showFieldError(fieldId, message) {
const input = document.getElementById(fieldId);
const errorDiv = document.getElementById(fieldId + 'Error');
input.classList.add('error');
errorDiv.textContent = message;
errorDiv.classList.add('show');
}
// 清除字段错误
function clearFieldError(fieldId) {
const input = document.getElementById(fieldId);
const errorDiv = document.getElementById(fieldId + 'Error');
input.classList.remove('error');
errorDiv.classList.remove('show');
}
// 验证表单
function validateForm() {
const inviteCode = document.getElementById('inviteCode').value.trim();
const email = document.getElementById('email').value.trim();
const emailCode = document.getElementById('emailCode').value.trim();
const password = document.getElementById('password').value;
const confirmPassword = document.getElementById('confirmPassword').value;
let isValid = true;
// 邀请码为选填,无需验证
// 验证邮箱
if (!email) {
showFieldError('email', '请输入邮箱地址');
isValid = false;
} else if (!validateEmail(email)) {
showFieldError('email', '邮箱格式不正确');
isValid = false;
} else {
clearFieldError('email');
}
// 验证邮箱验证码
if (!emailCode) {
showFieldError('emailCode', '请输入邮箱验证码');
isValid = false;
} else if (!/^\d{6}$/.test(emailCode)) {
showFieldError('emailCode', '验证码为6位数字');
isValid = false;
} else {
clearFieldError('emailCode');
}
// 验证密码
if (!password) {
showFieldError('password', '请输入密码');
isValid = false;
} else if (password.length < 6) {
showFieldError('password', '密码长度不能少于6位');
isValid = false;
} else {
clearFieldError('password');
}
// 验证确认密码
if (!confirmPassword) {
showFieldError('confirmPassword', '请再次输入密码');
isValid = false;
} else if (password !== confirmPassword) {
showFieldError('confirmPassword', '两次输入的密码不一致');
isValid = false;
} else {
clearFieldError('confirmPassword');
}
return isValid;
}
// 注册请求
async function register(email, emailCode, password, inviteCode) {
try {
const requestBody = {
email: email,
email_code: emailCode,
password: password
};
// 如果邀请码不为空,则添加到请求中
if (inviteCode) {
requestBody.invite_code = inviteCode;
}
const response = await fetch(`${API_BASE_URL}/invite/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody)
});
const data = await response.json();
return data;
} catch (error) {
console.error('注册请求失败:', error);
throw new Error('网络请求失败,请检查网络连接');
}
}
// 处理表单提交
async function handleSubmit(e) {
e.preventDefault();
// 清除之前的错误信息
document.getElementById('errorAlert').classList.remove('show');
document.getElementById('successMessage').classList.remove('show');
// 验证表单
if (!validateForm()) {
return;
}
const inviteCode = document.getElementById('inviteCode').value.trim();
const email = document.getElementById('email').value.trim();
const emailCode = document.getElementById('emailCode').value.trim();
const password = document.getElementById('password').value;
// 邀请码为选填,可以为空
// 禁用提交按钮
const submitBtn = document.getElementById('submitBtn');
submitBtn.disabled = true;
submitBtn.classList.add('loading');
try {
const result = await register(email, emailCode, password, inviteCode);
if (result.code === 0 || result.success) {
showMessage('successMessage', '注册成功!请使用邮箱和密码登录系统。');
// 清空表单
document.getElementById('registerForm').reset();
// 3秒后可以提示跳转如果需要
setTimeout(() => {
// 可以在这里添加跳转到登录页的逻辑
// window.location.href = '/login.html';
}, 3000);
} else {
showMessage('errorAlert', result.message || '注册失败,请重试', true);
}
} catch (error) {
showMessage('errorAlert', error.message || '注册失败,请稍后重试', true);
} finally {
submitBtn.disabled = false;
submitBtn.classList.remove('loading');
}
}
// 初始化
document.addEventListener('DOMContentLoaded', function() {
const inviteCodeFromUrl = getInviteCodeFromUrl();
// 自动填充邀请码
autoFillInviteCode(inviteCodeFromUrl);
// 显示邀请码徽章
if (inviteCodeFromUrl) {
displayInviteCodeBadge(inviteCodeFromUrl);
}
// 绑定表单提交事件
document.getElementById('registerForm').addEventListener('submit', handleSubmit);
// 发送验证码按钮事件
document.getElementById('sendCodeBtn').addEventListener('click', async function() {
const email = document.getElementById('email').value.trim();
// 验证邮箱
if (!email) {
showFieldError('email', '请先输入邮箱地址');
return;
}
if (!validateEmail(email)) {
showFieldError('email', '邮箱格式不正确');
return;
}
clearFieldError('email');
// 禁用按钮
const sendBtn = document.getElementById('sendCodeBtn');
sendBtn.disabled = true;
sendBtn.textContent = '发送中...';
try {
const result = await sendEmailCode(email);
if (result.code === 0 || result.success) {
showMessage('successMessage', '验证码已发送到您的邮箱,请查收');
startCountdown(60);
} else {
showMessage('errorAlert', result.message || '发送验证码失败,请重试', true);
sendBtn.disabled = false;
sendBtn.textContent = '发送验证码';
}
} catch (error) {
showMessage('errorAlert', error.message || '发送验证码失败,请稍后重试', true);
sendBtn.disabled = false;
sendBtn.textContent = '发送验证码';
}
});
// 实时验证
document.getElementById('email').addEventListener('blur', function() {
const email = this.value.trim();
if (email && !validateEmail(email)) {
showFieldError('email', '邮箱格式不正确');
} else {
clearFieldError('email');
}
});
document.getElementById('password').addEventListener('blur', function() {
const password = this.value;
if (password && password.length < 6) {
showFieldError('password', '密码长度不能少于6位');
} else {
clearFieldError('password');
}
});
document.getElementById('confirmPassword').addEventListener('blur', function() {
const password = document.getElementById('password').value;
const confirmPassword = this.value;
if (confirmPassword && password !== confirmPassword) {
showFieldError('confirmPassword', '两次输入的密码不一致');
} else {
clearFieldError('confirmPassword');
}
});
// 输入时清除错误
document.getElementById('email').addEventListener('input', function() {
if (!this.classList.contains('error')) return;
const email = this.value.trim();
if (email && validateEmail(email)) {
clearFieldError('email');
}
});
document.getElementById('password').addEventListener('input', function() {
if (!this.classList.contains('error')) return;
const password = this.value;
if (password && password.length >= 6) {
clearFieldError('password');
}
// 如果确认密码已输入,重新验证
const confirmPassword = document.getElementById('confirmPassword').value;
if (confirmPassword) {
if (password === confirmPassword) {
clearFieldError('confirmPassword');
} else {
showFieldError('confirmPassword', '两次输入的密码不一致');
}
}
});
document.getElementById('confirmPassword').addEventListener('input', function() {
if (!this.classList.contains('error')) return;
const password = document.getElementById('password').value;
const confirmPassword = this.value;
if (confirmPassword && password === confirmPassword) {
clearFieldError('confirmPassword');
}
});
document.getElementById('emailCode').addEventListener('input', function() {
if (!this.classList.contains('error')) return;
const emailCode = this.value.trim();
if (emailCode && /^\d{6}$/.test(emailCode)) {
clearFieldError('emailCode');
}
});
});
</script>
</body>
</html>

View File

@@ -132,6 +132,17 @@ class PlaAccountServer {
})
}
/**
* 重试指令
* @param {Number|String} commandId - 指令ID
* @returns {Promise}
*/
retryCommand(commandId) {
return window.framework.http.post(`pla_account/retryCommand`, {
commandId
})
}
/**
* 停止账号的所有任务
* @param {Object} row - 账号数据包含id和sn_code
@@ -186,6 +197,15 @@ class PlaAccountServer {
updateAuthorization(param) {
return window.framework.http.post('/account/update-authorization', param)
}
/**
* 解析账号的在线简历
* @param {Number|String} accountId - 账号ID
* @returns {Promise}
*/
parseResume(accountId) {
return window.framework.http.post('/pla_account/parse-resume', { id: accountId })
}
}
export default new PlaAccountServer()

View File

@@ -39,6 +39,15 @@ class ResumeInfoServer {
del(row) {
return window.framework.http.post('/resume/delete', { resumeId: row.resumeId || row.id })
}
/**
* AI 分析简历
* @param {String} resumeId - 简历ID
* @returns {Promise}
*/
analyzeWithAI(resumeId) {
return window.framework.http.post('/resume/analyze-with-ai', { resumeId })
}
}
export default new ResumeInfoServer()

File diff suppressed because one or more lines are too long

View File

@@ -25,8 +25,6 @@ import JobTypes from '@/views/work/job_types.vue'
// 首页模块
import HomeIndex from '@/views/home/index.vue'
// 邀请注册模块
import InviteRegister from '@/views/invite/invite_register.vue'
@@ -56,7 +54,8 @@ const componentMap = {
'system/version': Version,
'work/job_types': JobTypes,
'home/index': HomeIndex,
'invite/invite_register': InviteRegister,
}
export default componentMap

View File

@@ -2,17 +2,43 @@
* 设备选择模块 - 用于统计页面的设备切换
* 这个模块会被持久化到 localStorage
*/
// localStorage 的 key
const STORAGE_KEY = 'device_selected_sn'
// 从 localStorage 读取上次选中的设备
const loadSelectedDevice = () => {
try {
const saved = localStorage.getItem(STORAGE_KEY)
return saved || ''
} catch (error) {
console.error('读取选中设备失败:', error)
return ''
}
}
// 保存选中的设备到 localStorage
const saveSelectedDevice = (deviceSn) => {
try {
localStorage.setItem(STORAGE_KEY, deviceSn)
} catch (error) {
console.error('保存选中设备失败:', error)
}
}
const deviceModule = {
namespaced: true,
state: {
selectedDeviceSn: '', // 当前选中的设备 SN
selectedDeviceSn: loadSelectedDevice(), // 从 localStorage 恢复上次选中的设备
deviceList: [], // 设备列表
},
mutations: {
SET_SELECTED_DEVICE(state, deviceSn) {
state.selectedDeviceSn = deviceSn
// 持久化到 localStorage
saveSelectedDevice(deviceSn)
},
SET_DEVICE_LIST(state, list) {
@@ -20,6 +46,7 @@ const deviceModule = {
// 如果没有选中的设备,且列表不为空,自动选中第一个
if (!state.selectedDeviceSn && list.length > 0) {
state.selectedDeviceSn = list[0].deviceSn
saveSelectedDevice(state.selectedDeviceSn)
}
}
},

View File

@@ -99,8 +99,11 @@
</Form>
</Modal>
<!-- 简历详情弹窗 -->
<Modal v-model="resumeModal.visible" :title="resumeModal.title" width="900" :footer-hide="true">
<!-- 简历详情弹窗 - 使用 ResumeInfoDetail 组件 -->
<ResumeInfoDetail ref="resumeDetail" @on-close="handleResumeDetailClose" />
<!-- 原简历详情弹窗(备份暂时保留) -->
<Modal v-model="resumeModal.visible" :title="resumeModal.title" width="900" :footer-hide="true" v-if="false">
<div v-if="resumeModal.loading" style="text-align: center; padding: 40px;">
<Spin size="large"></Spin>
<p style="margin-top: 20px;">加载简历数据中...</p>
@@ -276,9 +279,11 @@
import plaAccountServer from '@/api/profile/pla_account_server.js'
import jobTypesServer from '@/api/work/job_types_server.js'
import PlaAccountEdit from './pla_account_edit.vue'
import ResumeInfoDetail from './resume_info_detail.vue'
export default {
components: {
ResumeInfoDetail,
PlaAccountEdit
},
data() {
@@ -344,13 +349,12 @@ export default {
{ title: '密码', key: 'pwd', com: 'Password', required: true },
],
listColumns: [
{ title: 'ID', key: 'id', minWidth: 80 },
{ title: '账户名', key: 'name', minWidth: 150 },
{ title: '设备SN码', key: 'sn_code', minWidth: 120 },
{ title: 'ID', key: 'id' },
{ title: '账户名', key: 'name' },
{ title: '设备SN码', key: 'sn_code'},
{
title: '平台',
key: 'platform_type',
minWidth: 100,
render: (h, params) => {
const platformMap = {
'boss': { text: 'Boss直聘', color: 'blue' },
@@ -360,13 +364,13 @@ export default {
return h('Tag', { props: { color: platform.color } }, platform.text)
}
},
{ title: '登录名', key: 'login_name', minWidth: 150 },
{ title: '搜索关键词', key: 'keyword', minWidth: 150 },
{ title: '用户地址', key: 'user_address', minWidth: 150 },
{ title: '登录名', key: 'login_name'},
{ title: '搜索关键词', key: 'keyword' },
{ title: '用户地址', key: 'user_address' },
{
title: '经纬度',
key: 'location',
minWidth: 150,
render: (h, params) => {
const lon = params.row.user_longitude;
const lat = params.row.user_latitude;
@@ -379,7 +383,7 @@ export default {
{
title: '在线状态',
key: 'is_online',
minWidth: 100,
render: (h, params) => {
return h('Tag', {
props: { color: params.row.is_online ? 'success' : 'default' }
@@ -390,7 +394,7 @@ export default {
title: '自动投递',
key: 'auto_deliver',
com: "Radio",
minWidth: 100,
options: [
{ value: 1, label: '开启' },
{ value: 0, label: '关闭' }
@@ -409,7 +413,6 @@ export default {
{ value: 1, label: '开启' },
{ value: 0, label: '关闭' }
],
minWidth: 100,
render: (h, params) => {
return h('Tag', {
props: { color: params.row.auto_chat ? 'success' : 'default' }
@@ -419,7 +422,7 @@ export default {
{
title: '剩余天数',
key: 'remaining_days',
minWidth: 100,
render: (h, params) => {
const remainingDays = params.row.remaining_days || 0
let color = 'success'
@@ -436,7 +439,7 @@ export default {
{
title: '授权日期',
key: 'authorization_date',
minWidth: 150,
render: (h, params) => {
if (!params.row.authorization_date) {
return h('span', { style: { color: '#999' } }, '未授权')
@@ -448,7 +451,7 @@ export default {
{
title: '过期时间',
key: 'expire_date',
minWidth: 150,
render: (h, params) => {
if (!params.row.authorization_date || !params.row.authorization_days) {
return h('span', { style: { color: '#999' } }, '未授权')
@@ -469,7 +472,7 @@ export default {
{ value: 1, label: '开启' },
{ value: 0, label: '关闭' }
],
minWidth: 100,
render: (h, params) => {
return h('Tag', {
props: { color: params.row.auto_active ? 'success' : 'default' }
@@ -479,7 +482,6 @@ export default {
{
title: '启用状态',
key: 'is_enabled',
minWidth: 100,
render: (h, params) => {
return h('i-switch', {
props: {
@@ -494,11 +496,11 @@ export default {
})
}
},
{ title: '创建时间', key: 'create_time', minWidth: 150 },
{ title: '创建时间', key: 'create_time', },
{
title: '操作',
key: 'action',
width: 450,
width: 500,
type: 'template',
render: (h, params) => {
let btns = [
@@ -661,10 +663,11 @@ export default {
},
// 查看简历
async showResume(row) {
this.resumeModal.visible = true
this.resumeModal.loading = true
this.resumeModal.data = null
this.resumeModal.title = `${row.name} - 在线简历`
// 显示加载提示
const loadingMsg = this.$Message.loading({
content: '正在加载简历数据...',
duration: 0
})
try {
// 根据 sn_code 和 platform 获取简历
@@ -677,18 +680,24 @@ export default {
// admin 会自动加 /admin_api 前缀
const res = await window.framework.http.get(`/resume/get-by-device?sn_code=${row.sn_code}&platform=${platform}`)
if (res.code === 0) {
this.resumeModal.data = res.data
loadingMsg()
if (res.code === 0 && res.data && res.data.resumeId) {
// 使用 ResumeInfoDetail 组件显示简历
this.$refs.resumeDetail.show(res.data.resumeId)
} else {
this.$Message.warning(res.message || '未找到简历数据')
}
} catch (error) {
loadingMsg()
console.error('获取简历失败:', error)
this.$Message.error('获取简历失败:' + (error.message || '请稍后重试'))
} finally {
this.resumeModal.loading = false
}
},
// 关闭简历详情
handleResumeDetailClose() {
// 可以在这里添加关闭后的逻辑
},
// 解析技能标签
parseSkills(skills) {
if (!skills) return []
@@ -852,7 +861,9 @@ export default {
}
} catch (error) {
console.error('位置解析失败:', error)
this.$Message.error('位置解析失败:' + (error.message || '请稍后重试'))
// 优先从 error.response.data.message 获取,然后是 error.message
const errorMsg = error.response?.data?.message || error.message || '请稍后重试'
this.$Message.error(errorMsg)
}
}
})
@@ -899,7 +910,9 @@ export default {
}
} catch (error) {
console.error('批量位置解析失败:', error)
this.$Message.error('批量位置解析失败:' + (error.message || '请稍后重试'))
// 优先从 error.response.data.message 获取,然后是 error.message
const errorMsg = error.response?.data?.message || error.message || '请稍后重试'
this.$Message.error(errorMsg)
} finally {
this.batchParseLoading = false
}

View File

@@ -122,7 +122,7 @@
<Col span="8">
<div class="detail-item">
<span class="label">过期时间</span>
<span class="value" :class="{'text-danger': isExpired(accountInfo)}">
<span class="value" :class="{ 'text-danger': isExpired(accountInfo) }">
{{ getExpireDate(accountInfo) }}
</span>
</div>
@@ -176,7 +176,8 @@
<span class="priority-value">{{ item.weight }}%</span>
</div>
<div class="priority-total-display">
<span>总权重<strong :class="{'weight-warning': totalWeight !== 100}">{{ totalWeight }}%</strong></span>
<span>总权重<strong :class="{ 'weight-warning': totalWeight !== 100 }">{{ totalWeight
}}%</strong></span>
</div>
</div>
<div v-else class="empty-config">暂无配置</div>
@@ -189,8 +190,8 @@
<div class="detail-item">
<span class="label">自动投递</span>
<span class="value">
<Tag :color="deliverConfig.auto_deliver === 1 ? 'success' : 'default'">
{{ deliverConfig.auto_deliver === 1 ? '开启' : '关闭' }}
<Tag :color="deliverConfig.auto_deliver ? 'success' : 'default'">
{{ deliverConfig.auto_deliver ? '开启' : '关闭' }}
</Tag>
</span>
</div>
@@ -204,13 +205,15 @@
<Col span="8">
<div class="detail-item">
<span class="label">最低薪资()</span>
<span class="value">{{ deliverConfig.min_salary || deliverConfig.min_salary === 0 ? deliverConfig.min_salary : '-' }}</span>
<span class="value">{{ deliverConfig.min_salary || deliverConfig.min_salary === 0 ?
deliverConfig.min_salary : '-' }}</span>
</div>
</Col>
<Col span="8">
<div class="detail-item">
<span class="label">最高薪资()</span>
<span class="value">{{ deliverConfig.max_salary || deliverConfig.max_salary === 0 ? deliverConfig.max_salary : '-' }}</span>
<span class="value">{{ deliverConfig.max_salary || deliverConfig.max_salary === 0 ?
deliverConfig.max_salary : '-' }}</span>
</div>
</Col>
<Col span="8">
@@ -355,9 +358,19 @@
<TabPane name="tasks" label="任务列表">
<div class="tab-content">
<div class="tab-body">
<tables :columns="taskColumns" :value="tasksData" :loading="tasksLoading"
:pageOption="tasksPageOption" @changePage="queryTasks">
</tables>
<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"
style="margin-top: 16px; text-align: right;">
</Page>
</div>
</div>
</TabPane>
@@ -365,9 +378,19 @@
<TabPane name="commands" label="指令列表">
<div class="tab-content">
<div class="tab-body">
<tables :columns="commandColumns" :value="commandsData" :loading="commandsLoading"
:pageOption="commandsPageOption" @changePage="queryCommands">
</tables>
<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"
style="margin-top: 16px; text-align: right;">
</Page>
</div>
</div>
</TabPane>
@@ -487,6 +510,7 @@
<div v-else-if="qrCodeError" class="qr-code-error">
<Icon type="ios-close-circle" size="40" color="#ed4014" />
<p>{{ qrCodeError }}</p>
</div>
<div v-else-if="qrCodeData" class="qr-code-display">
<div v-if="qrCodeData.qrCode || qrCodeData.qr_code_url || qrCodeData.oos_url || qrCodeData.image || qrCodeData.data"
@@ -510,6 +534,9 @@
<Button type="primary" @click="refreshQrCode" :loading="qrCodeLoading">刷新二维码</Button>
</div>
</Modal>
<!-- 简历详情弹窗 -->
<ResumeInfoDetail ref="resumeDetail" @on-close="handleResumeDetailClose" />
</div>
</template>
@@ -517,17 +544,21 @@
import plaAccountServer from '@/api/profile/pla_account_server.js'
import taskStatusServer from '@/api/task/task_status_server.js'
import jobTypesServer from '@/api/work/job_types_server.js'
import ResumeInfoDetail from './resume_info_detail.vue'
export default {
name: 'PlaAccountDetail',
components: {
ResumeInfoDetail
},
data() {
return {
accountInfo: {},
activeTab: 'tasks',
// 职位类型选项
jobTypeOptions: [],
// 配置数据
priorityList: [],
deliverConfig: {
@@ -584,6 +615,12 @@ export default {
commandType: 'get_online_resume',
commandName: '获取在线简历'
},
{
value: 'parse_and_view_resume',
label: '解析简历',
icon: 'ios-paper',
isCustomAction: true
},
{
value: 'search_jobs',
label: '搜索岗位',
@@ -778,17 +815,35 @@ export default {
{
title: '操作',
key: 'action',
width: 120,
width: 180,
render: (h, params) => {
return h('Button', {
props: {
type: 'primary',
size: 'small'
},
on: {
click: () => this.showCommandDetail(params.row)
}
}, '详情')
const btns = []
// 详情按钮
btns.push({
title: '详情',
type: 'primary',
click: () => this.showCommandDetail(params.row)
})
// 重试按钮(只在失败状态时显示)
if (params.row.status === 'failed') {
btns.push({
title: '重试',
type: 'warning',
click: () => this.retryCommand(params.row)
})
}
return h('div', btns.map(btn =>
h('Button', {
props: {
type: btn.type,
size: 'small'
},
style: { marginRight: '5px' },
on: {
click: btn.click
}
}, btn.title)
))
}
}
]
@@ -828,7 +883,7 @@ export default {
this.accountInfo = {}
}
},
// 解析配置数据
parseConfigData(accountInfo) {
// 解析排序优先级配置
@@ -847,7 +902,7 @@ export default {
} else {
this.priorityList = []
}
// 解析自动投递配置
if (accountInfo.deliver_config) {
const deliverConfig = typeof accountInfo.deliver_config === 'string'
@@ -885,7 +940,7 @@ export default {
deliver_workdays_only: 1
}
}
// 解析自动沟通配置
if (accountInfo.chat_strategy) {
const chatStrategy = typeof accountInfo.chat_strategy === 'string'
@@ -909,7 +964,7 @@ export default {
chat_workdays_only: 1
}
}
// 解析自动活跃配置
if (accountInfo.active_actions) {
const activeActions = typeof accountInfo.active_actions === 'string'
@@ -928,7 +983,7 @@ export default {
}
}
},
// 加载职位类型
async loadJobTypes() {
try {
@@ -943,14 +998,14 @@ export default {
console.error('加载职位类型失败:', err)
}
},
// 获取职位类型名称
getJobTypeName(jobTypeId) {
if (!jobTypeId) return ''
const jobType = this.jobTypeOptions.find(item => item.value === jobTypeId)
return jobType ? jobType.label : ''
},
// 获取优先级标签
getPriorityLabel(key) {
const labelMap = {
@@ -974,8 +1029,12 @@ export default {
pageOption: this.tasksPageOption
}
const res = await plaAccountServer.getTasks(this.accountId, param)
console.log('res', res);
this.tasksData = res.data.rows || []
this.tasksPageOption.total = res.data.count || 0
} catch (error) {
this.$Message.error('加载任务列表失败')
this.tasksData = []
@@ -997,8 +1056,10 @@ export default {
pageOption: this.commandsPageOption
}
const res = await plaAccountServer.getCommands(this.accountId, param)
this.commandsData = res.data.rows || []
this.commandsPageOption.total = res.data.count || 0
} catch (error) {
this.$Message.error('加载指令列表失败')
this.commandsData = []
@@ -1020,6 +1081,25 @@ 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 || '重试指令失败')
}
}
})
},
// 取消任务
async cancelTask(task) {
this.$Modal.confirm({
@@ -1058,6 +1138,20 @@ export default {
}
},
// 任务列表分页大小改变
handleTasksPageSizeChange(pageSize) {
this.tasksPageOption.pageSize = pageSize
this.tasksPageOption.page = 1
this.queryTasks(1)
},
// 指令列表分页大小改变
handleCommandsPageSizeChange(pageSize) {
this.commandsPageOption.pageSize = pageSize
this.commandsPageOption.page = 1
this.queryCommands(1)
},
// 处理刷新
handleRefresh() {
if (this.activeTab === 'tasks') {
@@ -1087,6 +1181,12 @@ export default {
return
}
// 如果是解析简历的自定义操作
if (action === 'parse_and_view_resume') {
this.handleParseAndViewResume()
return
}
// 从菜单列表中查找对应的配置
const actionItem = this.actionMenuList.find(item => item.value === action)
if (!actionItem) {
@@ -1123,6 +1223,42 @@ export default {
})
},
// 处理解析并查看简历
async handleParseAndViewResume() {
this.$Modal.confirm({
title: '确认解析简历',
content: '确定要解析该账号的在线简历吗系统会自动获取简历并进行AI分析。',
onOk: async () => {
const loadingMsg = this.$Message.loading({
content: '正在解析简历,请稍候...',
duration: 0
})
try {
// 调用后端接口解析简历
const res = await plaAccountServer.parseResume(this.accountId)
loadingMsg()
this.$Message.success('简历解析成功')
// 打开简历详情弹窗
if (res.data && res.data.resumeId) {
this.$refs.resumeDetail.show(res.data.resumeId)
}
} catch (error) {
loadingMsg()
console.error('解析简历失败:', error)
this.$Message.error('解析简历失败: ' + (error.message || '请稍后重试'))
}
}
})
},
// 关闭简历详情弹窗
handleResumeDetailClose() {
// 可以在这里添加关闭后的逻辑
},
// 显示二维码
async showQrCode() {
this.qrCodeVisible = true
@@ -1618,13 +1754,23 @@ export default {
.tab-content {
display: flex;
flex-direction: column;
min-height: 400px;
min-height: 500px;
}
.tab-body {
flex: 1;
padding: 20px;
overflow: visible;
min-height: 500px;
}
/* TabPane 最小高度 */
.tabs-card>>>.ivu-tabs-content {
min-height: 500px;
}
.tabs-card>>>.ivu-tabs-tabpane {
min-height: 500px;
}
/* Tab右侧按钮 */

View File

@@ -30,7 +30,7 @@
<FormItem label="登录名" prop="login_name">
<Input v-model="formData.login_name" placeholder="请输入登录名" />
</FormItem>
<FormItem label="密码" prop="pwd">
<FormItem label="密码" prop="pwd" v-if="!isEdit">
<Input v-model="formData.pwd" type="password" placeholder="请输入密码" />
</FormItem>
<FormItem label="搜索关键词">
@@ -98,7 +98,7 @@
<Card title="排序优先级配置" style="margin-bottom: 16px;">
<FormItem label="排序优先级">
<div class="priority-config">
<div v-for="(item, index) in priorityList" :key="item.key" class="priority-item">
<div v-for="item in priorityList" :key="item.key" class="priority-item">
<div class="priority-label">
<Icon type="ios-menu" class="drag-handle" />
<span>{{ getPriorityLabel(item.key) }}</span>
@@ -127,8 +127,8 @@
<Card title="自动投递配置" style="margin-bottom: 16px;">
<FormItem label="自动投递">
<RadioGroup v-model="formData.auto_deliver">
<Radio :label="1">开启</Radio>
<Radio :label="0">关闭</Radio>
<Radio :label="true">开启</Radio>
<Radio :label="false">关闭</Radio>
</RadioGroup>
</FormItem>
<FormItem label="投递间隔(分钟)">
@@ -147,10 +147,20 @@
<InputNumber v-model="formData.max_deliver" :min="1" placeholder="默认10个" style="width: 100%;" />
</FormItem>
<FormItem label="过滤关键词">
<Input v-model="formData.filter_keywords" placeholder="包含这些关键词的职位会被优先考虑,多个用逗号分隔" />
<Input
v-model="formData.filter_keywords"
type="textarea"
:rows="4"
placeholder="包含这些关键词的职位会被优先考虑,每行一个关键词,或使用逗号分隔"
/>
</FormItem>
<FormItem label="排除关键词">
<Input v-model="formData.exclude_keywords" placeholder="包含这些关键词的职位会被排除,多个用逗号分隔" />
<Input
v-model="formData.exclude_keywords"
type="textarea"
:rows="4"
placeholder="包含这些关键词的职位会被排除,每行一个关键词,或使用逗号分隔"
/>
</FormItem>
<FormItem label="投递开始时间">
<Input v-model="formData.deliver_start_time" placeholder="格式HH:mm09:00" />
@@ -250,7 +260,7 @@ export default {
job_type_id: null,
user_address: '',
is_salary_priority: '',
auto_deliver: 0,
auto_deliver: false,
deliver_interval: 30,
min_salary: 0,
max_salary: 0,
@@ -312,7 +322,8 @@ export default {
this.formData.sn_code = row.sn_code || ''
this.formData.platform_type = row.platform_type || ''
this.formData.login_name = row.login_name || ''
this.formData.pwd = row.pwd || ''
// 编辑模式下不加载密码,密码修改需要使用单独的接口
this.formData.pwd = ''
this.formData.keyword = row.keyword || ''
// 复制编辑字段
@@ -364,16 +375,20 @@ export default {
const deliverConfig = typeof row.deliver_config === 'string'
? JSON.parse(row.deliver_config)
: row.deliver_config
// 保存原有配置以便合并
this.formData.auto_deliver = deliverConfig.auto_deliver !== undefined ? deliverConfig.auto_deliver : false
this.formData.original_deliver_config = deliverConfig
this.formData.deliver_interval = deliverConfig.deliver_interval || 30
this.formData.min_salary = deliverConfig.min_salary || 0
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.filter_keywords = Array.isArray(deliverConfig.filter_keywords)
? deliverConfig.filter_keywords.join(',')
? deliverConfig.filter_keywords.join('\n')
: (deliverConfig.filter_keywords || '')
this.formData.exclude_keywords = Array.isArray(deliverConfig.exclude_keywords)
? deliverConfig.exclude_keywords.join(',')
? deliverConfig.exclude_keywords.join('\n')
: (deliverConfig.exclude_keywords || '')
// 处理时间区间配置
if (deliverConfig.time_range) {
@@ -381,6 +396,8 @@ export default {
this.formData.deliver_end_time = deliverConfig.time_range.end_time || '18:00'
this.formData.deliver_workdays_only = deliverConfig.time_range.workdays_only !== undefined ? deliverConfig.time_range.workdays_only : 1
}
} else {
this.formData.original_deliver_config = {}
}
// chat_strategy 配置
@@ -422,7 +439,7 @@ export default {
job_type_id: null,
user_address: '',
is_salary_priority: '',
auto_deliver: 0,
auto_deliver: false,
deliver_interval: 30,
min_salary: 0,
max_salary: 0,
@@ -560,24 +577,42 @@ export default {
weight: Number(item.weight) || 0
}))
// 处理 deliver_config
const deliverConfig = {}
deliverConfig.deliver_interval = Number(saveData.deliver_interval) || 30
deliverConfig.min_salary = Number(saveData.min_salary) || 0
deliverConfig.max_salary = Number(saveData.max_salary) || 0
deliverConfig.page_count = Number(saveData.page_count) || 3
deliverConfig.max_deliver = Number(saveData.max_deliver) || 10
deliverConfig.filter_keywords = typeof saveData.filter_keywords === 'string' && saveData.filter_keywords.trim()
? saveData.filter_keywords.split(',').map(k => k.trim()).filter(k => k)
: []
deliverConfig.exclude_keywords = typeof saveData.exclude_keywords === 'string' && saveData.exclude_keywords.trim()
? saveData.exclude_keywords.split(',').map(k => k.trim()).filter(k => k)
: []
// 处理时间区间配置
deliverConfig.time_range = {
start_time: saveData.deliver_start_time || '09:00',
end_time: saveData.deliver_end_time || '18:00',
workdays_only: saveData.deliver_workdays_only ? 1 : 0
// 处理 deliver_config(合并原有配置)
const originalDeliverConfig = saveData.original_deliver_config || {}
const deliverConfig = { ...originalDeliverConfig }
// 只覆盖传入的字段
if (saveData.deliver_interval !== undefined) {
deliverConfig.deliver_interval = Number(saveData.deliver_interval) || 30
}
if (saveData.min_salary !== undefined) {
deliverConfig.min_salary = Number(saveData.min_salary) || 0
}
if (saveData.max_salary !== undefined) {
deliverConfig.max_salary = Number(saveData.max_salary) || 0
}
if (saveData.page_count !== undefined) {
deliverConfig.page_count = Number(saveData.page_count) || 3
}
if (saveData.max_deliver !== undefined) {
deliverConfig.max_deliver = Number(saveData.max_deliver) || 10
}
// 解析过滤关键词:支持换行和逗号分隔
if (saveData.filter_keywords !== undefined) {
deliverConfig.filter_keywords = this.parseKeywords(saveData.filter_keywords)
}
// 解析排除关键词:支持换行和逗号分隔
if (saveData.exclude_keywords !== undefined) {
deliverConfig.exclude_keywords = this.parseKeywords(saveData.exclude_keywords)
}
// 处理时间区间配置(合并原有配置)
if (saveData.deliver_start_time !== undefined || saveData.deliver_end_time !== undefined || saveData.deliver_workdays_only !== undefined) {
deliverConfig.time_range = {
...(originalDeliverConfig.time_range || {}),
start_time: saveData.deliver_start_time !== undefined ? (saveData.deliver_start_time || '09:00') : (originalDeliverConfig.time_range?.start_time || '09:00'),
end_time: saveData.deliver_end_time !== undefined ? (saveData.deliver_end_time || '18:00') : (originalDeliverConfig.time_range?.end_time || '18:00'),
workdays_only: saveData.deliver_workdays_only !== undefined ? (saveData.deliver_workdays_only ? 1 : 0) : (originalDeliverConfig.time_range?.workdays_only !== undefined ? originalDeliverConfig.time_range.workdays_only : 1)
}
}
saveData.deliver_config = deliverConfig
delete saveData.deliver_interval
@@ -590,6 +625,7 @@ export default {
delete saveData.deliver_start_time
delete saveData.deliver_end_time
delete saveData.deliver_workdays_only
delete saveData.original_deliver_config
// 处理 chat_strategy
const chatStrategy = {}
@@ -682,6 +718,28 @@ export default {
this.priorityList[0].weight += diff
}
}
},
// 解析关键词:支持换行和逗号分隔
parseKeywords(keywords) {
if (!keywords || typeof keywords !== 'string') {
return []
}
const trimmed = keywords.trim()
if (!trimmed) {
return []
}
// 先按换行分割,再按逗号分割,然后过滤空值
const result = []
trimmed.split(/\n|,|/).forEach(keyword => {
const trimmedKeyword = keyword.trim()
if (trimmedKeyword) {
result.push(trimmedKeyword)
}
})
return result
}
},
computed: {

View File

@@ -1,7 +1,6 @@
<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"
@@ -29,13 +28,20 @@
@changePage="query"></tables>
</div>
<editModal ref="editModal" :columns="editColumns" :rules="gridOption.rules"></editModal>
<!-- 详情组件 -->
<ResumeInfoDetail ref="resumeDetail" @on-close="handleDetailClose" />
</div>
</template>
<script>
import resumeInfoServer from '@/api/profile/resume_info_server.js'
import ResumeInfoDetail from './resume_info_detail.vue'
export default {
components: {
ResumeInfoDetail
},
data() {
let rules = {}
rules["sn_code"] = [{ required: true, message: '请填写设备SN码', trigger: 'blur' }]
@@ -90,10 +96,17 @@ export default {
{
title: '操作',
key: 'action',
width: 200,
width: 250,
type: 'template',
render: (h, params) => {
let btns = [
{
title: '查看',
type: 'info',
click: () => {
this.showDetail(params.row)
},
},
{
title: '编辑',
type: 'primary',
@@ -168,6 +181,19 @@ export default {
platform: null
}
this.query(1)
},
showDetail(row) {
// 优先使用 resumeId如果没有则使用 id
const resumeId = row.resumeId || row.id
if (resumeId) {
this.$refs.resumeDetail.show(resumeId)
} else {
this.$Message.warning('简历ID不存在')
}
},
handleDetailClose() {
// 详情关闭后的回调,可以在这里刷新列表
// this.query(this.gridOption.param.pageOption.page)
}
},
computed: {

View File

@@ -0,0 +1,490 @@
<template>
<FloatPanel
ref="floatPanel"
title="简历详情"
position="right"
:show-back="true"
back-text="返回"
@back="handleBack"
>
<template #header-right>
<Button type="primary" @click="handleAnalyzeAI" :loading="analyzing">AI 分析</Button>
</template>
<div class="resume-detail-content" v-if="resumeData">
<Spin fix v-if="loading">
<Icon type="ios-loading" size="18" class="spin-icon-load"></Icon>
<div>加载中...</div>
</Spin>
<!-- 基本信息 -->
<Card title="基本信息" style="margin-bottom: 16px;">
<Row :gutter="16">
<Col span="12">
<div class="info-item">
<span class="label">姓名</span>
<span class="value">{{ resumeData.fullName || '-' }}</span>
</div>
</Col>
<Col span="12">
<div class="info-item">
<span class="label">性别</span>
<span class="value">{{ resumeData.gender || '-' }}</span>
</div>
</Col>
<Col span="12">
<div class="info-item">
<span class="label">年龄</span>
<span class="value">{{ resumeData.age || '-' }}</span>
</div>
</Col>
<Col span="12">
<div class="info-item">
<span class="label">电话</span>
<span class="value">{{ resumeData.phone || '-' }}</span>
</div>
</Col>
<Col span="12">
<div class="info-item">
<span class="label">邮箱</span>
<span class="value">{{ resumeData.email || '-' }}</span>
</div>
</Col>
<Col span="12">
<div class="info-item">
<span class="label">所在地</span>
<span class="value">{{ resumeData.location || '-' }}</span>
</div>
</Col>
<Col span="12">
<div class="info-item">
<span class="label">工作年限</span>
<span class="value">{{ resumeData.workYears || '-' }}</span>
</div>
</Col>
<Col span="12">
<div class="info-item">
<span class="label">当前职位</span>
<span class="value">{{ resumeData.currentPosition || '-' }}</span>
</div>
</Col>
<Col span="12">
<div class="info-item">
<span class="label">当前公司</span>
<span class="value">{{ resumeData.currentCompany || '-' }}</span>
</div>
</Col>
</Row>
</Card>
<!-- 教育背景 -->
<Card title="教育背景" style="margin-bottom: 16px;">
<Row :gutter="16">
<Col span="12">
<div class="info-item">
<span class="label">学历</span>
<span class="value">{{ resumeData.education || '-' }}</span>
</div>
</Col>
<Col span="12">
<div class="info-item">
<span class="label">专业</span>
<span class="value">{{ resumeData.major || '-' }}</span>
</div>
</Col>
<Col span="12">
<div class="info-item">
<span class="label">毕业院校</span>
<span class="value">{{ resumeData.school || '-' }}</span>
</div>
</Col>
<Col span="12">
<div class="info-item">
<span class="label">毕业年份</span>
<span class="value">{{ resumeData.graduationYear || '-' }}</span>
</div>
</Col>
</Row>
</Card>
<!-- 期望信息 -->
<Card title="期望信息" style="margin-bottom: 16px;">
<Row :gutter="16">
<Col span="12">
<div class="info-item">
<span class="label">期望职位</span>
<span class="value">{{ resumeData.expectedPosition || '-' }}</span>
</div>
</Col>
<Col span="12">
<div class="info-item">
<span class="label">期望薪资</span>
<span class="value">{{ resumeData.expectedSalary || '-' }}</span>
</div>
</Col>
<Col span="12">
<div class="info-item">
<span class="label">期望地点</span>
<span class="value">{{ resumeData.expectedLocation || '-' }}</span>
</div>
</Col>
<Col span="12">
<div class="info-item">
<span class="label">期望行业</span>
<span class="value">{{ resumeData.expectedIndustry || '-' }}</span>
</div>
</Col>
</Row>
</Card>
<!-- 技能标签 -->
<Card title="技能标签" style="margin-bottom: 16px;">
<div v-if="skillTags && skillTags.length > 0">
<Tag v-for="(skill, index) in skillTags" :key="index" color="blue" style="margin: 4px;">{{ skill }}</Tag>
</div>
<div v-else class="no-data">暂无技能标签</div>
</Card>
<!-- 技能描述 -->
<Card title="技能描述" style="margin-bottom: 16px;" v-if="resumeData.skillDescription">
<div class="text-content">{{ resumeData.skillDescription }}</div>
</Card>
<!-- 工作经历 -->
<Card title="工作经历" style="margin-bottom: 16px;" v-if="workExperience && workExperience.length > 0">
<Timeline>
<TimelineItem v-for="(work, index) in workExperience" :key="index">
<p class="work-title">
<strong>{{ work.position }}</strong>
<span class="work-company"> - {{ work.company }}</span>
</p>
<p class="work-time" v-if="work.startDate || work.endDate">
{{ work.startDate }} - {{ work.endDate || '至今' }}
</p>
<p class="work-content" v-if="work.content">{{ work.content }}</p>
</TimelineItem>
</Timeline>
</Card>
<!-- 项目经验 -->
<Card title="项目经验" style="margin-bottom: 16px;" v-if="projectExperience && projectExperience.length > 0">
<div v-for="(project, index) in projectExperience" :key="index" class="project-item" style="margin-bottom: 20px;">
<p class="project-title">
<strong>{{ project.name }}</strong>
<span class="project-role" v-if="project.role"> - {{ project.role }}</span>
</p>
<p class="project-time" v-if="project.startDate || project.endDate">
{{ project.startDate }} - {{ project.endDate || '至今' }}
</p>
<p class="project-desc" v-if="project.description">{{ project.description }}</p>
<p class="project-performance" v-if="project.performance">
<strong>项目成果</strong>{{ project.performance }}
</p>
</div>
</Card>
<!-- 简历内容 -->
<Card title="简历完整内容" style="margin-bottom: 16px;" v-if="resumeData.resumeContent">
<div class="text-content" style="white-space: pre-wrap;">{{ resumeData.resumeContent }}</div>
</Card>
<!-- AI 分析结果 -->
<Card title="AI 分析结果" style="margin-bottom: 16px;">
<Row :gutter="16">
<Col span="24">
<div class="info-item">
<span class="label">竞争力评分</span>
<span class="value" :style="{ color: getCompetitivenessColor(resumeData.aiCompetitiveness), fontSize: '18px', fontWeight: 'bold' }">
{{ resumeData.aiCompetitiveness || 0 }}
</span>
<Progress
:percent="resumeData.aiCompetitiveness || 0"
:stroke-color="getCompetitivenessColor(resumeData.aiCompetitiveness)"
style="margin-top: 8px; width: 300px;"
/>
</div>
</Col>
<Col span="24" v-if="resumeData.aiStrengths">
<div class="info-item ai-section">
<div class="ai-section-header">
<Icon type="ios-thumbs-up" color="#19be6b" size="18" />
<span class="label">优势分析</span>
</div>
<div class="text-content ai-content">{{ resumeData.aiStrengths }}</div>
</div>
</Col>
<Col span="24" v-if="resumeData.aiWeaknesses">
<div class="info-item ai-section">
<div class="ai-section-header">
<Icon type="ios-warning" color="#ff9900" size="18" />
<span class="label">劣势分析</span>
</div>
<div class="text-content ai-content">{{ resumeData.aiWeaknesses }}</div>
</div>
</Col>
<Col span="24" v-if="resumeData.aiCareerSuggestion">
<div class="info-item ai-section">
<div class="ai-section-header">
<Icon type="ios-bulb" color="#2d8cf0" size="18" />
<span class="label">职业建议</span>
</div>
<div class="text-content ai-content">{{ resumeData.aiCareerSuggestion }}</div>
</div>
</Col>
<Col span="24" v-if="aiSkillTags && aiSkillTags.length > 0">
<div class="info-item">
<span class="label">AI 提取的技能标签</span>
<div style="margin-top: 8px;">
<Tag v-for="(skill, index) in aiSkillTags" :key="index" color="green" style="margin: 4px;">{{ skill }}</Tag>
</div>
</div>
</Col>
<Col span="24" v-if="!resumeData.aiCompetitiveness && !resumeData.aiStrengths">
<div class="no-data">
<Icon type="ios-information-circle" size="24" color="#c5c8ce" />
<p>暂无 AI 分析结果请点击上方"AI 分析"按钮进行分析</p>
</div>
</Col>
</Row>
</Card>
<!-- 原始数据可展开 -->
<Card title="原始数据" style="margin-bottom: 16px;">
<Collapse>
<Panel name="originalData">
查看原始 JSON 数据
<template slot="content">
<pre class="json-preview">{{ JSON.stringify(originalDataObj, null, 2) }}</pre>
</template>
</Panel>
</Collapse>
</Card>
</div>
</FloatPanel>
</template>
<script>
import resumeInfoServer from '@/api/profile/resume_info_server.js'
export default {
name: 'ResumeInfoDetail',
data() {
return {
loading: false,
analyzing: false,
resumeData: null,
skillTags: [],
workExperience: [],
projectExperience: [],
aiSkillTags: [],
originalDataObj: {}
}
},
methods: {
async show(resumeId) {
this.$refs.floatPanel.show()
await this.loadResumeData(resumeId)
},
async loadResumeData(resumeId) {
this.loading = true
try {
const res = await resumeInfoServer.getById(resumeId)
this.resumeData = res.data || {}
// 解析 JSON 字段
this.skillTags = this.parseJsonField(this.resumeData.skills) || []
this.workExperience = this.parseJsonField(this.resumeData.workExperience) || []
this.projectExperience = this.parseJsonField(this.resumeData.projectExperience) || []
this.aiSkillTags = this.parseJsonField(this.resumeData.aiSkillTags) || []
// 解析原始数据
this.originalDataObj = this.parseJsonField(this.resumeData.originalData) || {}
} catch (error) {
console.error('加载简历详情失败:', error)
this.$Message.error('加载简历详情失败')
} finally {
this.loading = false
}
},
parseJsonField(field) {
if (!field) return null
if (typeof field === 'string') {
try {
return JSON.parse(field)
} catch (e) {
return field
}
}
return field
},
async handleAnalyzeAI() {
if (!this.resumeData || !this.resumeData.resumeId) {
this.$Message.warning('简历ID不存在')
return
}
this.analyzing = true
try {
await resumeInfoServer.analyzeWithAI(this.resumeData.resumeId)
this.$Message.success('AI 分析完成')
// 重新加载数据
await this.loadResumeData(this.resumeData.resumeId)
} catch (error) {
console.error('AI 分析失败:', error)
// 优先从 error.response.data.message 获取,然后是 error.message
const errorMsg = error.response?.data?.message || error.message || '请稍后重试'
this.$Message.error(errorMsg)
} finally {
this.analyzing = false
}
},
handleBack() {
this.$refs.floatPanel.hide()
this.$emit('on-close')
},
getCompetitivenessColor(score) {
if (!score) return '#666'
if (score >= 80) return '#19be6b'
if (score >= 60) return '#2d8cf0'
return '#ed4014'
}
}
}
</script>
<style scoped>
.resume-detail-content {
padding: 0;
position: relative;
min-height: 400px;
}
.info-item {
margin-bottom: 12px;
}
.info-item .label {
font-weight: 600;
color: #515a6e;
margin-right: 8px;
}
.info-item .value {
color: #2d8cf0;
}
.text-content {
color: #515a6e;
line-height: 1.6;
}
.work-title {
font-size: 14px;
margin-bottom: 4px;
}
.work-company {
color: #808695;
font-weight: normal;
}
.work-time {
color: #808695;
font-size: 12px;
margin-bottom: 8px;
}
.work-content {
color: #515a6e;
line-height: 1.6;
margin-top: 8px;
}
.project-item {
padding-bottom: 16px;
border-bottom: 1px solid #e8eaec;
}
.project-item:last-child {
border-bottom: none;
}
.project-title {
font-size: 14px;
margin-bottom: 4px;
}
.project-role {
color: #808695;
font-weight: normal;
}
.project-time {
color: #808695;
font-size: 12px;
margin-bottom: 8px;
}
.project-desc {
color: #515a6e;
line-height: 1.6;
margin-top: 8px;
}
.project-performance {
color: #19be6b;
line-height: 1.6;
margin-top: 8px;
}
.no-data {
color: #c5c8ce;
text-align: center;
padding: 40px 20px;
}
.no-data p {
margin-top: 12px;
font-size: 14px;
}
/* AI 分析区域样式 */
.ai-section {
margin-bottom: 20px;
padding: 16px;
background: #f8f9fa;
border-radius: 6px;
border-left: 3px solid #2d8cf0;
}
.ai-section-header {
display: flex;
align-items: center;
margin-bottom: 12px;
gap: 8px;
}
.ai-section-header .label {
font-size: 15px;
font-weight: 600;
color: #17233d;
margin-right: 0;
}
.ai-content {
padding-left: 26px;
line-height: 1.8;
color: #515a6e;
}
.json-preview {
background: #f8f8f9;
padding: 16px;
border-radius: 4px;
max-height: 500px;
overflow-y: auto;
font-size: 12px;
line-height: 1.5;
}
</style>

View File

@@ -72,10 +72,10 @@
<Col span="4">
<div class="account-switch-item">
<div class="switch-label">是否在线</div>
<i-switch
v-model="accountInfo.is_online"
:true-value="true"
:false-value="false"
<i-switch
v-model="accountInfo.is_online"
:true-value="1"
:false-value="0"
@on-change="handleSwitchChange('is_online', $event)"
:loading="switchLoading.is_online"
:disabled="true"
@@ -85,10 +85,10 @@
<Col span="4">
<div class="account-switch-item">
<div class="switch-label">是否登录</div>
<i-switch
v-model="accountInfo.is_logged_in"
:true-value="true"
:false-value="false"
<i-switch
v-model="accountInfo.is_logged_in"
:true-value="1"
:false-value="0"
@on-change="handleSwitchChange('is_logged_in', $event)"
:loading="switchLoading.is_logged_in"
:disabled="true"
@@ -208,7 +208,7 @@ export default {
data() {
return {
selectedDeviceSn: '',
selectedDeviceSn: this.$store.state.device?.selectedDeviceSn || '', // 从 store 恢复上次选中的设备
deviceList: [],
todayStats: {
applyCount: 0,
@@ -320,17 +320,25 @@ export default {
})
this.deviceList = Array.from(deviceMap.values())
console.log('处理后的设备列表:', this.deviceList)
console.log('处理后设备数量:', this.deviceList.length)
this.$store.dispatch('device/setDeviceList', this.deviceList)
// 如果没有选中设备且有设备列表,选中第一个
if (!this.selectedDeviceSn && this.deviceList.length > 0) {
// 优先从 store 恢复上次选中的设备
const savedDeviceSn = this.$store.state.device?.selectedDeviceSn
if (savedDeviceSn && this.deviceList.some(d => d.deviceSn === savedDeviceSn)) {
// 如果 store 中保存的设备在列表中存在,恢复选中
console.log('恢复上次选中的设备:', savedDeviceSn)
this.selectedDeviceSn = savedDeviceSn
this.loadData()
} else if (!this.selectedDeviceSn && this.deviceList.length > 0) {
// 如果没有保存的设备或保存的设备不在列表中,选中第一个
console.log('选中第一个设备:', this.deviceList[0].deviceSn)
this.selectedDeviceSn = this.deviceList[0].deviceSn
this.$store.dispatch('device/setSelectedDevice', this.selectedDeviceSn)
// 立即加载数据
this.loadData()
} else if (this.selectedDeviceSn) {
// 如果已有选中设备,直接加载数据
@@ -740,21 +748,23 @@ export default {
const deviceRes = await DeviceStatusServer.getById(this.selectedDeviceSn)
console.log('设备状态接口返回:', deviceRes)
if (deviceRes.code === 0 && deviceRes.data) {
accountData.is_online = deviceRes.data.isOnline || false
accountData.is_logged_in = deviceRes.data.isLoggedIn || false
accountData.is_online = deviceRes.data.isOnline ? 1 : 0
accountData.is_logged_in = deviceRes.data.isLoggedIn ? 1 : 0
} else {
accountData.is_online = false
accountData.is_logged_in = false
accountData.is_online = 0
accountData.is_logged_in = 0
}
} catch (deviceError) {
console.error('获取设备状态失败:', deviceError)
accountData.is_online = false
accountData.is_logged_in = false
accountData.is_online = 0
accountData.is_logged_in = 0
}
}
// 确保布尔值字段正确初始化转换为数字类型0 或 1
accountData.is_enabled = accountData.is_enabled !== undefined ? Number(accountData.is_enabled) : 1
accountData.is_online = accountData.is_online !== undefined ? Number(accountData.is_online) : 0
accountData.is_logged_in = accountData.is_logged_in !== undefined ? Number(accountData.is_logged_in) : 0
accountData.auto_deliver = accountData.auto_deliver !== undefined ? Number(accountData.auto_deliver) : 0
accountData.auto_chat = accountData.auto_chat !== undefined ? Number(accountData.auto_chat) : 0
accountData.auto_active = accountData.auto_active !== undefined ? Number(accountData.auto_active) : 0

View File

@@ -1,359 +0,0 @@
<template>
<div class="invite-register-page">
<div class="register-container">
<Card class="register-card">
<div slot="title" class="card-title">
<Icon type="ios-person-add" size="24" />
<span>邀请注册</span>
</div>
<div class="register-form">
<!-- 邀请码提示 -->
<Alert v-if="inviteCode" type="success" show-icon style="margin-bottom: 20px;">
<span slot="desc">您正在通过邀请码 <strong>{{ inviteCode }}</strong> 进行注册</span>
</Alert>
<Alert v-else type="warning" show-icon style="margin-bottom: 20px;">
<span slot="desc">未检测到邀请码请通过邀请链接访问</span>
</Alert>
<Form ref="registerForm" :model="formData" :rules="formRules" :label-width="100">
<!-- 手机号 -->
<FormItem label="手机号" prop="phone">
<Input
v-model="formData.phone"
placeholder="请输入手机号"
:maxlength="11"
@on-blur="validatePhone"
>
<Icon type="ios-phone-portrait" slot="prefix" />
</Input>
</FormItem>
<!-- 密码 -->
<FormItem label="密码" prop="password">
<Input
v-model="formData.password"
type="password"
placeholder="请输入密码至少6位"
:maxlength="50"
password
>
<Icon type="ios-lock" slot="prefix" />
</Input>
</FormItem>
<!-- 确认密码 -->
<FormItem label="确认密码" prop="confirmPassword">
<Input
v-model="formData.confirmPassword"
type="password"
placeholder="请再次输入密码"
:maxlength="50"
password
>
<Icon type="ios-lock" slot="prefix" />
</Input>
</FormItem>
<!-- 短信验证码 -->
<FormItem label="短信验证码" prop="sms_code">
<div class="sms-code-wrapper">
<Input
v-model="formData.sms_code"
placeholder="请输入短信验证码"
:maxlength="6"
style="width: 200px;"
>
<Icon type="ios-keypad" slot="prefix" />
</Input>
<Button
:disabled="smsCodeDisabled"
:loading="smsCodeLoading"
@click="handleSendSmsCode"
style="margin-left: 10px;"
>
{{ smsCodeButtonText }}
</Button>
</div>
</FormItem>
<!-- 提交按钮 -->
<FormItem>
<Button
type="primary"
size="large"
:loading="registerLoading"
@click="handleRegister"
style="width: 100%;"
>
注册
</Button>
</FormItem>
</Form>
<!-- 注册说明 -->
<div class="register-tips">
<p><strong>注册说明</strong></p>
<ul>
<li>手机号将作为您的登录账号</li>
<li>密码长度至少6位建议使用字母+数字组合</li>
<li>短信验证码有效期为5分钟</li>
<li>注册成功后邀请人将获得3天试用期奖励</li>
</ul>
</div>
</div>
</Card>
</div>
</div>
</template>
<script>
import inviteRegisterServer from '@/api/invite/invite_register_server.js';
export default {
name: 'InviteRegister',
data() {
// 验证确认密码
const validateConfirmPassword = (rule, value, callback) => {
if (!value) {
callback(new Error('请再次输入密码'));
} else if (value !== this.formData.password) {
callback(new Error('两次输入的密码不一致'));
} else {
callback();
}
};
return {
inviteCode: '', // 邀请码从URL参数获取
formData: {
phone: '',
password: '',
confirmPassword: '',
sms_code: ''
},
formRules: {
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: '请再次输入密码', trigger: 'blur' },
{ validator: validateConfirmPassword, trigger: 'blur' }
],
sms_code: [
{ required: true, message: '请输入短信验证码', trigger: 'blur' },
{ pattern: /^\d{6}$/, message: '验证码为6位数字', trigger: 'blur' }
]
},
smsCodeLoading: false,
smsCodeDisabled: false,
smsCodeCountdown: 0,
registerLoading: false
};
},
computed: {
smsCodeButtonText() {
if (this.smsCodeCountdown > 0) {
return `${this.smsCodeCountdown}秒后重新获取`;
}
return '获取验证码';
}
},
mounted() {
// 从URL参数获取邀请码
this.getInviteCodeFromUrl();
},
methods: {
/**
* 从URL参数获取邀请码
*/
getInviteCodeFromUrl() {
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
if (code) {
this.inviteCode = code;
} else {
this.$Message.warning('未检测到邀请码,请通过邀请链接访问');
}
},
/**
* 验证手机号
*/
validatePhone() {
this.$refs.registerForm.validateField('phone');
},
/**
* 发送短信验证码
*/
async handleSendSmsCode() {
// 先验证手机号
this.$refs.registerForm.validateField('phone', async (valid) => {
if (!valid) {
return;
}
if (!this.formData.phone) {
this.$Message.error('请先输入手机号');
return;
}
// 验证手机号格式
const phoneRegex = /^1[3-9]\d{9}$/;
if (!phoneRegex.test(this.formData.phone)) {
this.$Message.error('手机号格式不正确');
return;
}
this.smsCodeLoading = true;
try {
const result = await inviteRegisterServer.sendSmsCode(this.formData.phone);
if (result.code === 0) {
this.$Message.success('短信验证码已发送,请注意查收');
// 开始倒计时
this.startCountdown();
} else {
this.$Message.error(result.message || '发送短信验证码失败');
}
} catch (error) {
const errorMessage = error.response?.data?.message || error.message || '发送短信验证码失败';
this.$Message.error(errorMessage);
} finally {
this.smsCodeLoading = false;
}
});
},
/**
* 开始倒计时
*/
startCountdown() {
this.smsCodeCountdown = 60;
this.smsCodeDisabled = true;
const timer = setInterval(() => {
this.smsCodeCountdown--;
if (this.smsCodeCountdown <= 0) {
clearInterval(timer);
this.smsCodeDisabled = false;
}
}, 1000);
},
/**
* 注册
*/
async handleRegister() {
// 验证表单
this.$refs.registerForm.validate(async (valid) => {
if (!valid) {
return;
}
if (!this.inviteCode) {
this.$Message.error('邀请码不能为空,请通过邀请链接访问');
return;
}
this.registerLoading = true;
try {
const result = await inviteRegisterServer.register({
phone: this.formData.phone,
password: this.formData.password,
sms_code: this.formData.sms_code,
invite_code: this.inviteCode
});
if (result.code === 0) {
this.$Message.success('注册成功!');
// 延迟跳转到登录页面或提示用户登录
setTimeout(() => {
this.$Message.info('请使用注册的手机号和密码登录');
// 可以跳转到登录页面
// this.$router.push('/login');
}, 2000);
} else {
this.$Message.error(result.message || '注册失败');
}
} catch (error) {
const errorMessage = error.response?.data?.message || error.message || '注册失败';
this.$Message.error(errorMessage);
} finally {
this.registerLoading = false;
}
});
}
}
};
</script>
<style scoped>
.invite-register-page {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.register-container {
width: 100%;
max-width: 500px;
}
.register-card {
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
border-radius: 8px;
overflow: hidden;
}
.card-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 20px;
font-weight: 600;
color: #2d8cf0;
}
.register-form {
padding: 20px 0;
}
.sms-code-wrapper {
display: flex;
align-items: center;
}
.register-tips {
margin-top: 30px;
padding: 15px;
background: #f8f8f9;
border-radius: 4px;
border-left: 4px solid #2d8cf0;
}
.register-tips p {
margin: 0 0 10px 0;
font-weight: 600;
color: #515a6e;
}
.register-tips ul {
margin: 0;
padding-left: 20px;
color: #808695;
}
.register-tips ul li {
margin-bottom: 5px;
line-height: 1.6;
}
</style>

View File

@@ -284,6 +284,7 @@ export default {
try {
const res = await taskStatusServer.getCommands(row.id)
this.commandsModal.data = res.data || []
} catch (error) {
this.$Message.error('获取指令列表失败')

View File

@@ -104,9 +104,9 @@ export default {
{ title: '公司名称', key: 'companyName', minWidth: 180 },
{ title: '薪资', key: 'salary', minWidth: 120 },
{ title: '地点', key: 'location', minWidth: 120 },
{
title: '投递状态',
key: 'applyStatus',
{
title: '投递状态',
key: 'applyStatus',
minWidth: 120,
render: (h, params) => {
const statusMap = {
@@ -117,6 +117,27 @@ export default {
'duplicate': { text: '重复投递', color: 'warning' }
}
const status = statusMap[params.row.applyStatus] || { text: params.row.applyStatus, color: 'default' }
// 如果是投递失败且有错误信息,显示带 tooltip 的标签
if (params.row.applyStatus === 'failed' && params.row.errorMessage) {
return h('Tooltip', {
props: {
content: params.row.errorMessage,
maxWidth: 300,
transfer: true,
placement: 'top'
}
}, [
h('Tag', { props: { color: status.color } }, [
status.text,
h('Icon', {
props: { type: 'ios-information-circle' },
style: { marginLeft: '4px' }
})
])
])
}
return h('Tag', { props: { color: status.color } }, status.text)
}
},
@@ -146,7 +167,7 @@ export default {
return h('Tag', { props: { color: color } }, `${score}%`)
}
},
{ title: '投递时间', key: 'applyTime', minWidth: 250 },
{ title: '创建时间', key: 'create_time', minWidth: 250 },
{
title: '操作',
key: 'action',
@@ -270,6 +291,20 @@ export default {
h('h3', { style: { marginTop: '20px', marginBottom: '15px', color: '#2d8cf0' } }, '投递状态'),
h('p', [h('strong', '投递状态: '), this.getApplyStatusText(row.applyStatus)]),
row.applyStatus === 'failed' && row.errorMessage ? h('div', { style: { marginTop: '10px', marginBottom: '10px' } }, [
h('strong', '失败原因: '),
h('p', {
style: {
whiteSpace: 'pre-wrap',
marginTop: '8px',
padding: '10px',
backgroundColor: '#fff1f0',
borderRadius: '4px',
border: '1px solid #ffccc7',
color: '#cf1322'
}
}, row.errorMessage)
]) : null,
h('p', [h('strong', '反馈状态: '), this.getFeedbackStatusText(row.feedbackStatus)]),
h('p', [h('strong', '投递时间: '), row.applyTime || '未知']),
h('p', [h('strong', '匹配度: '), `${row.matchScore || 0}%`]),

View File

@@ -126,7 +126,7 @@ export default {
return h('Tag', { props: { color: status.color } }, status.text)
}
},
{ title: '发布时间', key: 'publishTime', minWidth: 150 },
{ title: '创建时间', key: 'create_time', minWidth: 220 },
{
title: '操作',
key: 'action',

View File

@@ -1,12 +1,19 @@
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const { VueLoaderPlugin } = require('vue-loader')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
// 判断是否为生产环境
const is_production = process.env.NODE_ENV === 'production' || process.argv.includes('--mode=production')
module.exports = {
entry: './src/main.js',
entry: {
main: './src/main.js'
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'app.js',
filename: 'js/[name].[contenthash:8].js',
chunkFilename: 'js/[name].[contenthash:8].chunk.js',
clean: true
},
module: {
@@ -22,20 +29,60 @@ module.exports = {
},
{
test: /\.css$/,
use: ['vue-style-loader', 'css-loader']
use: [
is_production ? MiniCssExtractPlugin.loader : 'vue-style-loader',
'css-loader'
]
},
{
test: /\.(png|jpe?g|gif|svg|woff2?|eot|ttf|otf)$/,
type: 'asset/resource'
test: /\.(png|jpe?g|gif|svg)$/,
type: 'asset/resource',
generator: {
filename: 'images/[name].[hash:8][ext]'
}
},
{
test: /\.(woff2?|eot|ttf|otf)$/,
type: 'asset/resource',
generator: {
filename: 'fonts/[name].[hash:8][ext]'
}
}
]
},
plugins: [
new VueLoaderPlugin(),
// 主应用 HTML
new HtmlWebpackPlugin({
template: './public/index.html',
title: 'Admin Framework Demo'
})
title: 'Admin Framework Demo',
filename: 'index.html',
chunks: ['main']
}),
// 注册页面 HTML独立页面不依赖 webpack
new HtmlWebpackPlugin({
template: './public/register.html',
title: '邀请注册',
filename: 'register.html',
inject: false, // 不注入 webpack 生成的脚本
minify: is_production ? {
removeComments: true,
collapseWhitespace: true,
removeRedundantAttributes: true,
useShortDoctype: true,
removeEmptyAttributes: true,
removeStyleLinkTypeAttributes: true,
keepClosingSlash: true,
minifyJS: true,
minifyCSS: true
} : false
}),
...(is_production ? [
new MiniCssExtractPlugin({
filename: 'css/[name].[contenthash:8].css',
chunkFilename: 'css/[name].[contenthash:8].chunk.css'
})
] : [])
],
resolve: {
extensions: ['.js', '.vue', '.json'],
@@ -52,6 +99,58 @@ module.exports = {
client: {
overlay: false // 禁用错误浮层
}
},
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
// 分离 Vue 核心库
vue: {
name: 'vue',
test: /[\\/]node_modules[\\/](vue|vue-router|vuex)[\\/]/,
priority: 20,
reuseExistingChunk: true
},
// 分离 UI 库
ui: {
name: 'ui',
test: /[\\/]node_modules[\\/]view-design[\\/]/,
priority: 15,
reuseExistingChunk: true
},
// 分离图表库
echarts: {
name: 'echarts',
test: /[\\/]node_modules[\\/]echarts[\\/]/,
priority: 15,
reuseExistingChunk: true
},
// 分离其他第三方库
vendor: {
name: 'vendor',
test: /[\\/]node_modules[\\/]/,
priority: 10,
reuseExistingChunk: true
},
// 公共代码
common: {
name: 'common',
minChunks: 2,
priority: 5,
reuseExistingChunk: true
}
}
},
runtimeChunk: {
name: 'runtime'
}
},
stats: {
colors: true,
modules: false,
children: false,
chunks: false,
chunkModules: false
}
}

View File

@@ -104,10 +104,8 @@ module.exports = {
});
return ctx.success({
count: result.count,
total: result.count,
rows: result.rows,
list: result.rows
count: result.count
});
},

View File

@@ -27,12 +27,10 @@ module.exports = {
chat_records,
op
} = models;
const deviceManager = require('../middleware/schedule/deviceManager');
// 设备统计(从 pla_account 和 deviceManager 获取)
// 设备统计(直接从数据库获取)
const totalDevices = await pla_account.count({ where: { is_delete: 0 } });
const deviceStatus = deviceManager.getAllDevicesStatus();
const onlineDevices = Object.values(deviceStatus).filter(d => d.isOnline).length;
const onlineDevices = await pla_account.count({ where: { is_delete: 0, is_online: 1 } });
const runningDevices = 0; // 不再维护运行状态
// 任务统计
@@ -211,29 +209,25 @@ return ctx.success({
'GET /dashboard/device-performance': async (ctx) => {
const models = Framework.getModels();
const { pla_account, task_status, job_postings, apply_records, chat_records } = models;
const deviceManager = require('../middleware/schedule/deviceManager');
// 从 pla_account 获取所有账号
const accounts = await pla_account.findAll({
where: { is_delete: 0 },
attributes: ['id', 'sn_code', 'name'],
attributes: ['id', 'sn_code', 'name', 'is_online'],
limit: 20
});
// 获取设备在线状态
const deviceStatus = deviceManager.getAllDevicesStatus();
// 为每个账号统计任务、岗位、投递、聊天数据
const performanceData = await Promise.all(accounts.map(async (account) => {
const snCode = account.sn_code;
const status = deviceStatus[snCode] || { isOnline: false };
const isOnline = account.is_online === 1;
// 统计任务
const [completedTasks, failedTasks] = await Promise.all([
task_status.count({ where: { sn_code: snCode, status: 'completed' } }),
task_status.count({ where: { sn_code: snCode, status: 'failed' } })
]);
// 统计岗位、投递、聊天(如果有相关字段)
const [jobsSearched, applies, chats] = await Promise.all([
job_postings.count({ where: { sn_code: snCode } }).catch(() => 0),
@@ -253,7 +247,7 @@ return ctx.success({
applies,
chats,
successRate,
healthScore: status.isOnline ? 100 : 0,
healthScore: isOnline ? 100 : 0,
onlineDuration: 0 // 不再维护在线时长
};
}));

View File

@@ -62,7 +62,6 @@ module.exports = {
'POST /device/detail': async (ctx) => {
const models = Framework.getModels();
const { pla_account } = models;
const deviceManager = require('../middleware/schedule/deviceManager');
const body = ctx.getBody();
const { deviceSn } = body;
@@ -70,7 +69,7 @@ module.exports = {
return ctx.fail('设备SN码不能为空');
}
// 从 pla_account 获取账号信息
// 从 pla_account 获取账号信息(包含 is_online 和 is_logged_in)
const account = await pla_account.findOne({
where: { sn_code: deviceSn }
});
@@ -81,21 +80,17 @@ module.exports = {
const accountData = account.toJSON();
// 从 deviceManager 获取在线状态
const deviceStatus = deviceManager.getAllDevicesStatus();
const onlineStatus = deviceStatus[deviceSn] || { isOnline: false };
// 组合返回数据
// 组合返回数据(直接从数据库读取 is_online 和 is_logged_in)
const deviceData = {
sn_code: accountData.sn_code,
device_id: accountData.device_id,
deviceName: accountData.name || accountData.sn_code,
platform: accountData.platform_type,
isOnline: onlineStatus.isOnline || false,
is_online: onlineStatus.isOnline || false, // 前端使用的字段名
is_logged_in: onlineStatus.isLoggedIn || false, // 从 deviceManager 内存中获取登录状态
isOnline: accountData.is_online === 1,
is_online: accountData.is_online === 1,
is_logged_in: accountData.is_logged_in === 1,
isRunning: false, // 不再维护运行状态
lastHeartbeatTime: onlineStatus.lastHeartbeat ? new Date(onlineStatus.lastHeartbeat) : null,
lastHeartbeatTime: null, // 不再从内存读取
accountName: accountData.name,
platform_type: accountData.platform_type,
is_enabled: accountData.is_enabled
@@ -107,10 +102,9 @@ module.exports = {
'POST /device/list': async (ctx) => {
const models = Framework.getModels();
const { pla_account, op } = models;
const deviceManager = require('../middleware/schedule/deviceManager');
const body = ctx.getBody();
const { isOnline, healthStatus, platform, searchText} = ctx.getBody();
// 获取分页参数
const { limit, offset } = ctx.getPageSize();
@@ -126,6 +120,11 @@ module.exports = {
];
}
// 在线状态筛选(直接查询数据库)
if (isOnline !== undefined) {
where.is_online = isOnline ? 1 : 0;
}
const result = await pla_account.findAndCountAll({
where,
limit,
@@ -133,35 +132,26 @@ module.exports = {
order: [['id', 'DESC']]
});
// 获取设备在线状态
const deviceStatus = deviceManager.getAllDevicesStatus();
// 组合数据并过滤在线状态
let list = result.rows.map(account => {
// 组合数据(直接从数据库读取)
const list = result.rows.map(account => {
const accountData = account.toJSON();
const status = deviceStatus[accountData.sn_code] || { isOnline: false };
return {
sn_code: accountData.sn_code,
device_id: accountData.device_id,
deviceName: accountData.name || accountData.sn_code,
platform: accountData.platform_type,
isOnline: status.isOnline || false,
isOnline: accountData.is_online === 1,
isRunning: false,
lastHeartbeatTime: status.lastHeartbeat ? new Date(status.lastHeartbeat) : null,
lastHeartbeatTime: null,
accountName: accountData.name,
platform_type: accountData.platform_type,
is_enabled: accountData.is_enabled
};
});
// 如果指定了在线状态筛选
if (isOnline !== undefined) {
list = list.filter(item => item.isOnline === isOnline);
}
return ctx.success({
total: list.length,
list: list.slice(0, limit)
rows: list,
count: result.count
});
},
@@ -179,30 +169,28 @@ module.exports = {
'GET /device/overview': async (ctx) => {
const models = Framework.getModels();
const { pla_account } = models;
const deviceManager = require('../middleware/schedule/deviceManager');
// 从 pla_account 获取账号总数
// 从 pla_account 获取账号统计(直接查询数据库)
const totalDevices = await pla_account.count({ where: { is_delete: 0 } });
// 从 deviceManager 获取在线设备统计
const deviceStatus = deviceManager.getAllDevicesStatus();
const onlineDevices = Object.values(deviceStatus).filter(d => d.isOnline).length;
const onlineDevices = await pla_account.count({ where: { is_delete: 0, is_online: 1 } });
const runningDevices = 0; // 不再维护运行状态
const healthyDevices = onlineDevices; // 简化处理,在线即健康
const warningDevices = 0;
const errorDevices = 0;
// 获取最近离线的设备(从内存状态中获取)
const offlineDevicesList = Object.entries(deviceStatus)
.filter(([sn_code, status]) => !status.isOnline)
.map(([sn_code, status]) => ({
sn_code,
deviceName: sn_code,
lastOfflineTime: status.lastHeartbeat ? new Date(status.lastHeartbeat) : null,
lastError: ''
}))
.sort((a, b) => (b.lastOfflineTime?.getTime() || 0) - (a.lastOfflineTime?.getTime() || 0))
.slice(0, 5);
// 获取最近离线的设备(从数据库查询)
const offlineAccounts = await pla_account.findAll({
where: { is_delete: 0, is_online: 0 },
limit: 5,
order: [['id', 'DESC']]
});
const offlineDevicesList = offlineAccounts.map(account => ({
sn_code: account.sn_code,
deviceName: account.name || account.sn_code,
lastOfflineTime: null,
lastError: ''
}));
return ctx.success({
totalDevices,

View File

@@ -1,340 +0,0 @@
/**
* 邀请注册管理控制器(后台管理)
* 提供邀请注册相关的接口,不需要登录验证
*/
const Framework = require("../../framework/node-core-framework.js");
const dayjs = require('dayjs');
module.exports = {
/**
* @swagger
* /admin_api/invite/register:
* post:
* summary: 邀请注册
* description: 通过邀请码注册新用户注册成功后给邀请人增加3天试用期
* tags: [后台-邀请注册]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - phone
* - password
* - sms_code
* - invite_code
* properties:
* phone:
* type: string
* description: 手机号
* example: '13800138000'
* password:
* type: string
* description: 密码
* example: 'password123'
* sms_code:
* type: string
* description: 短信验证码
* example: '123456'
* invite_code:
* type: string
* description: 邀请码
* example: 'INV123_ABC123'
* responses:
* 200:
* description: 注册成功
*/
'POST /invite/register': async (ctx) => {
try {
const body = ctx.getBody();
const { phone, password, sms_code, invite_code } = body;
// 验证参数
if (!phone || !password || !sms_code || !invite_code) {
return ctx.fail('手机号、密码、短信验证码和邀请码不能为空');
}
// 验证手机号格式
const phoneRegex = /^1[3-9]\d{9}$/;
if (!phoneRegex.test(phone)) {
return ctx.fail('手机号格式不正确');
}
// 验证密码长度
if (password.length < 6) {
return ctx.fail('密码长度不能少于6位');
}
// 验证短信验证码(这里需要调用短信验证服务)
const smsVerifyResult = await verifySmsCode(phone, sms_code);
if (!smsVerifyResult.success) {
return ctx.fail(smsVerifyResult.message || '短信验证码错误或已过期');
}
const { pla_account } = await Framework.getModels();
// 检查手机号是否已注册
const existingUser = await pla_account.findOne({
where: { login_name: phone }
});
if (existingUser) {
return ctx.fail('该手机号已被注册');
}
// 解析邀请码获取邀请人ID
// 邀请码格式INV{user_id}_{timestamp}
const inviteMatch = invite_code.match(/^INV(\d+)_/);
if (!inviteMatch) {
return ctx.fail('邀请码格式不正确');
}
const inviter_id = parseInt(inviteMatch[1]);
// 验证邀请人是否存在
const inviter = await pla_account.findOne({
where: { id: inviter_id }
});
if (!inviter) {
return ctx.fail('邀请码无效,邀请人不存在');
}
// 生成设备SN码基于手机号和时间戳
const sn_code = `SN${Date.now()}${Math.random().toString(36).substr(2, 6).toUpperCase()}`;
// 创建新用户
const newUser = await pla_account.create({
name: phone, // 默认使用手机号作为名称
sn_code: sn_code,
device_id: '', // 设备ID由客户端登录时提供
platform_type: 'boss', // 默认平台类型
login_name: phone,
pwd: password,
keyword: '',
is_enabled: 1,
is_delete: 0,
authorization_date: null,
authorization_days: 0
});
// 给邀请人增加3天试用期
if (inviter) {
const inviterData = inviter.toJSON();
const currentAuthDate = inviterData.authorization_date;
const currentAuthDays = inviterData.authorization_days || 0;
let newAuthDate = currentAuthDate;
let newAuthDays = currentAuthDays + 3; // 增加3天
// 如果当前没有授权日期,则从今天开始
if (!currentAuthDate) {
newAuthDate = new Date();
} else {
// 如果当前授权已过期,从今天开始计算
const currentEndDate = dayjs(currentAuthDate).add(currentAuthDays, 'day');
const now = dayjs();
if (currentEndDate.isBefore(now)) {
newAuthDate = new Date();
newAuthDays = 3; // 重新设置为3天
}
}
// 更新邀请人的授权信息
await pla_account.update(
{
authorization_date: newAuthDate,
authorization_days: newAuthDays
},
{ where: { id: inviter_id } }
);
// 记录邀请记录
const { invite_record } = await Framework.getModels();
await invite_record.create({
inviter_id: inviter_id,
inviter_sn_code: inviter.sn_code,
invitee_id: newUser.id,
invitee_sn_code: newUser.sn_code,
invitee_phone: phone,
invite_code: invite_code,
register_time: new Date(),
reward_status: 1, // 已发放
reward_type: 'trial_days',
reward_value: 3,
is_delete: 0
});
console.log(`[邀请注册] 用户 ${phone} 通过邀请码 ${invite_code} 注册成功,邀请人 ${inviter.sn_code} 获得3天试用期`);
}
return ctx.success({
message: '注册成功',
user: {
id: newUser.id,
sn_code: newUser.sn_code,
login_name: newUser.login_name
}
});
} catch (error) {
console.error('邀请注册失败:', error);
return ctx.fail('注册失败: ' + error.message);
}
},
/**
* @swagger
* /admin_api/invite/send-sms:
* post:
* summary: 发送短信验证码
* description: 发送短信验证码到指定手机号
* tags: [后台-邀请注册]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - phone
* properties:
* phone:
* type: string
* description: 手机号
* example: '13800138000'
* responses:
* 200:
* description: 发送成功
*/
'POST /invite/send-sms': async (ctx) => {
try {
const body = ctx.getBody();
const { phone } = body;
if (!phone) {
return ctx.fail('手机号不能为空');
}
// 验证手机号格式
const phoneRegex = /^1[3-9]\d{9}$/;
if (!phoneRegex.test(phone)) {
return ctx.fail('手机号格式不正确');
}
// 发送短信验证码
const smsResult = await sendSmsCode(phone);
if (!smsResult.success) {
return ctx.fail(smsResult.message || '发送短信验证码失败');
}
return ctx.success({
message: '短信验证码已发送',
expire_time: smsResult.expire_time || 300 // 默认5分钟过期
});
} catch (error) {
console.error('发送短信验证码失败:', error);
return ctx.fail('发送短信验证码失败: ' + error.message);
}
}
};
/**
* 发送短信验证码
* @param {string} phone 手机号
* @returns {Promise<{success: boolean, message?: string, expire_time?: number}>}
*/
async function sendSmsCode(phone) {
try {
// TODO: 实现真实的短信发送逻辑
// 这里可以使用第三方短信服务(如阿里云、腾讯云等)
// 生成6位随机验证码
const code = Math.floor(100000 + Math.random() * 900000).toString();
// 将验证码存储到缓存中可以使用Redis或内存缓存
// 格式sms_code:{phone} = {code, expire_time}
const expire_time = Date.now() + 5 * 60 * 1000; // 5分钟后过期
// 这里应该存储到缓存中暂时使用全局变量生产环境应使用Redis
if (!global.smsCodeCache) {
global.smsCodeCache = {};
}
global.smsCodeCache[phone] = {
code: code,
expire_time: expire_time
};
// TODO: 调用真实的短信发送接口
console.log(`[短信验证] 发送验证码到 ${phone}: ${code} (5分钟内有效)`);
// 模拟发送成功
return {
success: true,
expire_time: 300
};
} catch (error) {
console.error('发送短信验证码失败:', error);
return {
success: false,
message: error.message || '发送短信验证码失败'
};
}
}
/**
* 验证短信验证码
* @param {string} phone 手机号
* @param {string} code 验证码
* @returns {Promise<{success: boolean, message?: string}>}
*/
async function verifySmsCode(phone, code) {
try {
if (!global.smsCodeCache) {
return {
success: false,
message: '验证码不存在或已过期'
};
}
const cached = global.smsCodeCache[phone];
if (!cached) {
return {
success: false,
message: '验证码不存在或已过期'
};
}
// 检查是否过期
if (Date.now() > cached.expire_time) {
delete global.smsCodeCache[phone];
return {
success: false,
message: '验证码已过期,请重新获取'
};
}
// 验证码是否正确
if (cached.code !== code) {
return {
success: false,
message: '验证码错误'
};
}
// 验证成功后删除缓存
delete global.smsCodeCache[phone];
return {
success: true
};
} catch (error) {
console.error('验证短信验证码失败:', error);
return {
success: false,
message: error.message || '验证失败'
};
}
}

View File

@@ -445,6 +445,35 @@ module.exports = {
return ctx.success(commandDetail);
},
/**
* @swagger
* /admin_api/pla_account/retryCommand:
* post:
* summary: 重试指令
* description: 重新执行失败的指令
* tags: [后台-账号管理]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - commandId
* properties:
* commandId:
* type: integer
* description: 指令ID
* responses:
* 200:
* description: 重试成功
*/
'POST /pla_account/retryCommand': async (ctx) => {
const body = ctx.getBody();
const result = await plaAccountService.retryCommand(body);
return ctx.success(result);
},
/**
* @swagger
* /admin_api/pla_account/parseLocation:
@@ -557,17 +586,14 @@ module.exports = {
const authDays = accountData.authorization_days || 0;
// 计算剩余天数
let remaining_days = 0;
let is_expired = false;
const { calculateRemainingDays } = require('../utils/account_utils');
const remaining_days = calculateRemainingDays(authDate, authDays);
const is_expired = remaining_days <= 0;
let end_date = null;
if (authDate && authDays > 0) {
const startDate = dayjs(authDate);
end_date = startDate.add(authDays, 'day').toDate();
const now = dayjs();
const remaining = dayjs(end_date).diff(now, 'day', true);
remaining_days = Math.max(0, Math.ceil(remaining));
is_expired = remaining_days <= 0;
}
return ctx.success({
@@ -646,10 +672,9 @@ module.exports = {
});
// 计算更新后的剩余天数
const { calculateRemainingDays } = require('../utils/account_utils');
const remaining_days = calculateRemainingDays(authDate, authorization_days);
const end_date = dayjs(authDate).add(authorization_days, 'day').toDate();
const now = dayjs();
const remaining = dayjs(end_date).diff(now, 'day', true);
const remaining_days = Math.max(0, Math.ceil(remaining));
return ctx.success({
message: '授权信息更新成功',
@@ -661,6 +686,86 @@ module.exports = {
end_date: end_date
}
});
},
/**
* @swagger
* /admin_api/pla_account/parse-resume:
* post:
* summary: 解析账号在线简历
* description: 获取指定账号的在线简历并进行AI分析返回简历详情
* tags: [后台-账号管理]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - id
* properties:
* id:
* type: integer
* description: 账号ID
* responses:
* 200:
* description: 解析成功返回简历ID
*/
'POST /pla_account/parse-resume': async (ctx) => {
const { id } = ctx.getBody();
const models = await Framework.getModels();
const { pla_account } = models;
const mqttClient = require('../middleware/mqtt/mqttClient');
const resumeManager = require('../middleware/job/resumeManager');
if (!id) {
return ctx.fail('账号ID不能为空');
}
// 获取账号信息
const account = await pla_account.findOne({ where: { id } });
if (!account) {
return ctx.fail('账号不存在');
}
const { sn_code, platform_type } = account;
if (!sn_code) {
return ctx.fail('该账号未绑定设备');
}
try {
// 调用简历管理器获取并保存简历
const resumeData = await resumeManager.get_online_resume(sn_code, mqttClient, {
platform: platform_type || 'boss'
});
// 从返回数据中获取 resumeId
// resumeManager 已经保存了简历到数据库,我们需要查询获取 resumeId
const resume_info = models.resume_info;
const savedResume = await resume_info.findOne({
where: {
sn_code,
platform: platform_type || 'boss',
isActive: true
},
order: [['syncTime', 'DESC']]
});
if (!savedResume) {
return ctx.fail('简历解析失败:未找到保存的简历记录');
}
return ctx.success({
message: '简历解析成功',
resumeId: savedResume.resumeId,
data: resumeData
});
} catch (error) {
console.error('[解析简历] 失败:', error);
return ctx.fail('解析简历失败: ' + (error.message || '未知错误'));
}
}
};

View File

@@ -69,8 +69,8 @@ const result = await resume_info.findAndCountAll({
});
return ctx.success({
total: result.count,
list: result.rows
rows: result.rows,
count: result.count
});
},
@@ -153,24 +153,45 @@ return ctx.success({
* 200:
* description: 获取成功
*/
'GET /resume/detail': async (ctx) => {
'POST /resume/detail': async (ctx) => {
const models = Framework.getModels();
const { resume_info } = models;
const { resumeId } = ctx.query;
const { resumeId } = ctx.getBody();
if (!resumeId) {
return ctx.fail('简历ID不能为空');
}
const resume = await resume_info.findOne({ where: { resumeId } });
const resume = await resume_info.findOne({ where: { resumeId } });
if (!resume) {
return ctx.fail('简历不存在');
}
if (!resume) {
return ctx.fail('简历不存在');
}
return ctx.success(resume);
// 解析 JSON 字段
const resumeDetail = resume.toJSON();
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);
}
}
});
// 解析原始数据(如果存在)
if (resumeDetail.originalData) {
try {
resumeDetail.originalData = JSON.parse(resumeDetail.originalData);
} catch (e) {
console.error('解析原始数据失败:', e);
}
}
return ctx.success(resumeDetail);
},
/**
@@ -269,6 +290,74 @@ return ctx.success({ message: '简历删除成功' });
});
return ctx.success(resumeDetail);
},
/**
* @swagger
* /admin_api/resume/analyze-with-ai:
* post:
* summary: AI 分析简历
* description: 使用 AI 分析简历并更新 AI 字段
* tags: [后台-简历管理]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - resumeId
* properties:
* resumeId:
* type: string
* description: 简历ID
* responses:
* 200:
* description: 分析成功
*/
'POST /resume/analyze-with-ai': 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('简历不存在');
}
try {
const resumeManager = require('../middleware/job/resumeManager');
const resumeData = resume.toJSON();
// 调用 AI 分析
await resumeManager.analyze_resume_with_ai(resumeId, resumeData);
// 重新获取更新后的数据
const updatedResume = await resume_info.findOne({ where: { resumeId } });
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(resumeDetail);
} catch (error) {
console.error('AI 分析失败:', error);
return ctx.fail('AI 分析失败: ' + error.message);
}
}
};

View File

@@ -166,6 +166,33 @@ module.exports = {
console.error('获取价格套餐失败:', error);
return ctx.fail('获取价格套餐失败: ' + error.message);
}
},
/**
* @swagger
* /api/config/remote-code/version:
* get:
* summary: 获取远程代码版本
* description: 获取远程代码版本
* tags: [前端-系统配置]
* responses:
* 200:
* description: 获取成功
*/
'GET /config/remote-code/version': async (ctx) => {
try {
const remoteCode =
{
version: "1.0.9",
download_url: "https://work.light120.com/app/dist-modules/remote.zip",
info_url: "https://work.light120.com/app/dist-modules/remote-version.json",
}
return ctx.success(remoteCode);
} catch (error) {
console.error('获取远程代码失败:', error);
return ctx.fail('获取远程代码失败: ' + error.message);
}
}
};

View File

@@ -4,7 +4,8 @@
*/
const Framework = require("../../framework/node-core-framework.js");
const dayjs = require('dayjs');
const email_service = require('../services/email_service.js');
module.exports = {
/**
* @swagger
@@ -52,7 +53,7 @@ module.exports = {
// 生成邀请码基于用户ID和sn_code
const invite_code = `INV${user.id}_${Date.now().toString(36).toUpperCase()}`;
const invite_link = `https://work.light120.com/invite?code=${invite_code}`;
const invite_link = `https://work.light120.com/register.html?code=${invite_code}`;
// 保存邀请码到用户记录可以保存到invite_code字段如果没有则保存到备注或其他字段
// 这里暂时不保存到数据库,每次生成新的
@@ -115,7 +116,7 @@ module.exports = {
// 查询邀请统计
const { invite_record } = await Framework.getModels();
// 查询总邀请数
const totalInvites = await invite_record.count({
where: {
@@ -190,7 +191,7 @@ module.exports = {
// 生成新的邀请码
const invite_code = `INV${user.id}_${Date.now().toString(36).toUpperCase()}`;
const invite_link = `https://work.light120.com/invite?code=${invite_code}`;
const invite_link = `https://work.light120.com/register.html?code=${invite_code}`;
return ctx.success({
invite_code,
@@ -294,6 +295,262 @@ module.exports = {
console.error('获取邀请记录列表失败:', error);
return ctx.fail('获取邀请记录列表失败: ' + error.message);
}
},
/**
* @swagger
* /api/invite/register:
* post:
* summary: 邀请注册
* description: 通过邀请码注册新用户注册成功后给邀请人增加3天试用期
* tags: [前端-推广邀请]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - email
* - password
* - email_code
* properties:
* email:
* type: string
* description: 邮箱地址
* example: 'user@example.com'
* password:
* type: string
* description: 密码
* example: 'password123'
* email_code:
* type: string
* description: 验证码
* example: '123456'
* invite_code:
* type: string
* description: 邀请码(选填)
* example: 'INV123_ABC123'
* responses:
* 200:
* description: 注册成功
*/
'POST /invite/register': async (ctx) => {
try {
const body = ctx.getBody();
const { email, password, email_code, invite_code } = body;
const { hashPassword, maskEmail } = require('../utils/crypto_utils');
// 验证必填参数
if (!email || !password || !email_code) {
return ctx.fail('邮箱、密码和验证码不能为空');
}
// 验证邮箱格式
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return ctx.fail('邮箱格式不正确');
}
// 验证密码长度
if (password.length < 6 || password.length > 50) {
return ctx.fail('密码长度必须在6-50位之间');
}
// 统一邮箱地址为小写
const email_normalized = email.toLowerCase().trim();
// 验证验证码
const emailVerifyResult = await email_service.verifyEmailCode(email_normalized, email_code);
if (!emailVerifyResult.success) {
return ctx.fail(emailVerifyResult.message || '验证码错误或已过期');
}
const { pla_account } = await Framework.getModels();
// 检查邮箱是否已注册(使用统一的小写邮箱)
const existingUser = await pla_account.findOne({
where: { login_name: email_normalized }
});
if (existingUser) {
return ctx.fail('该邮箱已被注册');
}
// 验证邀请码(如果提供了邀请码)
let inviter = null;
let inviter_id = null;
if (invite_code) {
// 解析邀请码获取邀请人ID
// 邀请码格式INV{user_id}_{timestamp}
const inviteMatch = invite_code.match(/^INV(\d+)_/);
if (!inviteMatch) {
return ctx.fail('邀请码格式不正确');
}
inviter_id = parseInt(inviteMatch[1]);
// 验证邀请人是否存在
inviter = await pla_account.findOne({
where: { id: inviter_id }
});
if (!inviter) {
return ctx.fail('邀请码无效,邀请人不存在');
}
}
// 生成设备SN码基于邮箱和时间戳
const sn_code = `SN${Date.now()}${Math.random().toString(36).substr(2, 6).toUpperCase()}`;
// 加密密码
const hashedPassword = await hashPassword(password);
// 创建新用户(使用统一的小写邮箱和加密密码)
// 新用户注册赠送3天试用期
const newUser = await pla_account.create({
name: email_normalized.split('@')[0], // 默认使用邮箱用户名作为名称
sn_code: sn_code,
device_id: '', // 设备ID由客户端登录时提供
platform_type: 'boss', // 默认平台类型
login_name: email_normalized,
pwd: hashedPassword, // 使用加密后的密码
keyword: '',
is_enabled: 1,
is_delete: 0,
authorization_date: new Date(), // 新用户从注册日期开始
authorization_days: 3 // 赠送3天试用期
});
// 给邀请人增加3天试用期
if (inviter) {
const inviterData = inviter.toJSON();
const currentAuthDate = inviterData.authorization_date;
const currentAuthDays = inviterData.authorization_days || 0;
let newAuthDate;
let newAuthDays;
// 如果当前没有授权日期,则从今天开始
if (!currentAuthDate) {
newAuthDate = new Date();
newAuthDays = currentAuthDays + 3; // 累加3天
} else {
// 检查当前授权是否已过期
const currentEndDate = dayjs(currentAuthDate).add(currentAuthDays, 'day');
const now = dayjs();
if (currentEndDate.isBefore(now)) {
// 授权已过期重新激活并给予3天试用期
newAuthDate = new Date();
newAuthDays = 3; // 过期后重新激活给3天
} else {
// 授权未过期在现有基础上累加3天
newAuthDate = currentAuthDate;
newAuthDays = currentAuthDays + 3; // 累加3天
}
}
// 更新邀请人的授权信息
await pla_account.update(
{
authorization_date: newAuthDate,
authorization_days: newAuthDays
},
{ where: { id: inviter_id } }
);
// 记录邀请记录
const { invite_record } = await Framework.getModels();
await invite_record.create({
inviter_id: inviter_id,
inviter_sn_code: inviter.sn_code,
invitee_id: newUser.id,
invitee_sn_code: newUser.sn_code,
invitee_phone: email, // 使用邮箱代替手机号
invite_code: invite_code,
register_time: new Date(),
reward_status: 1, // 已发放
reward_type: 'trial_days',
reward_value: 3,
is_delete: 0
});
console.log(`[邀请注册] 用户 ${maskEmail(email_normalized)} 通过邀请码 ${invite_code} 注册成功,邀请人 ${inviter.sn_code} 获得3天试用期`);
} else {
console.log(`[邀请注册] 用户 ${maskEmail(email_normalized)} 注册成功(无邀请码)`);
}
return ctx.success({
message: '注册成功',
user: {
id: newUser.id,
sn_code: newUser.sn_code,
login_name: newUser.login_name
}
});
} catch (error) {
console.error('邀请注册失败:', error);
return ctx.fail('注册失败: ' + error.message);
}
},
/**
* @swagger
* /api/invite/send_email_code:
* post:
* summary: 发送验证码
* description: 发送验证码到指定邮箱地址
* tags: [前端-推广邀请]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - email
* properties:
* email:
* type: string
* description: 邮箱地址
* example: 'user@example.com'
* responses:
* 200:
* description: 发送成功
*/
'POST /invite/send_email_code': async (ctx) => {
try {
const body = ctx.getBody();
const { email } = body;
if (!email) {
return ctx.fail('邮箱地址不能为空');
}
// 验证邮箱格式
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return ctx.fail('邮箱格式不正确');
}
// 统一邮箱地址为小写
const email_normalized = email.toLowerCase().trim();
// 发送验证码
const emailResult = await email_service.sendEmailCode(email_normalized);
if (!emailResult.success) {
return ctx.fail(emailResult.message || '发送验证码失败');
}
return ctx.success({
message: '验证码已发送',
expire_time: emailResult.expire_time || 300 // 默认5分钟过期
});
} catch (error) {
console.error('发送验证码失败:', error);
return ctx.fail('发送验证码失败: ' + error.message);
}
}
};

View File

@@ -6,7 +6,7 @@ module.exports = {
* /api/user/login:
* post:
* summary: 用户登录
* description: 通过手机号和密码登录返回token、device_id和用户信息
* description: 通过邮箱和密码登录返回token、device_id和用户信息
* tags: [前端-用户管理]
* requestBody:
* required: true
@@ -15,13 +15,13 @@ module.exports = {
* schema:
* type: object
* required:
* - phone
* - email
* - password
* properties:
* phone:
* email:
* type: string
* description: 手机号(登录名)
* example: '13800138000'
* description: 邮箱(登录名)
* example: 'user@example.com'
* password:
* type: string
* description: 密码
@@ -75,94 +75,163 @@ module.exports = {
* example: '用户不存在或密码错误'
*/
"POST /user/login": async (ctx) => {
const { phone, 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');
// 验证参数
if (!phone || !password) {
return ctx.fail('手机号和密码不能为空');
// 参数验证
if (!email || !password) {
return ctx.fail('邮箱和密码不能为空');
}
// 统一邮箱地址为小写
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();
// 根据手机号login_name和密码查找用户
const user = await pla_account.findOne({
where: {
login_name: phone,
pwd: password
try {
// 根据邮箱查找用户(使用统一的小写邮箱)
const user = await pla_account.findOne({
where: {
login_name: email_normalized
}
});
if (!user) {
// 防止用户枚举攻击:统一返回相同的错误信息
return ctx.fail('邮箱或密码错误');
}
});
if (!user) {
return ctx.fail('手机号或密码错误');
}
// 验证密码(支持新旧格式)
let isPasswordValid = false;
// 检查账号是否启用
if (!user.is_enabled) {
return ctx.fail('账号已被禁用');
}
if (user.pwd && user.pwd.includes('$')) {
// 新格式:加密密码
isPasswordValid = await verifyPassword(password, user.pwd);
} else {
// 旧格式:明文密码(临时兼容,建议尽快迁移)
isPasswordValid = (user.pwd === password);
// 检查授权状态
const userData = user.toJSON();
const authDate = userData.authorization_date;
const authDays = userData.authorization_days || 0;
if (authDate && authDays > 0) {
const startDate = dayjs(authDate);
const endDate = startDate.add(authDays, 'day');
const now = dayjs();
const remaining = endDate.diff(now, 'day', true);
const remaining_days = Math.max(0, Math.ceil(remaining));
if (remaining_days <= 0) {
return ctx.fail('账号授权已过期,请联系管理员续费');
// 如果验证通过,自动升级为加密密码
if (isPasswordValid) {
const { hashPassword } = require('../utils/crypto_utils');
const hashedPassword = await hashPassword(password);
await pla_account.update(
{ pwd: hashedPassword },
{ where: { id: user.id } }
);
console.log(`[安全升级] 用户 ${maskEmail(email_normalized)} 的密码已自动加密`);
}
}
if (!isPasswordValid) {
// 记录失败尝试(用于后续添加防暴力破解功能)
console.warn(`[登录失败] 邮箱: ${maskEmail(email_normalized)}, 时间: ${new Date().toISOString()}`);
return ctx.fail('邮箱或密码错误');
}
// 检查账号是否启用
if (!user.is_enabled) {
return ctx.fail('账号已被禁用,请联系管理员');
}
// 检查授权状态(仅记录,不拒绝登录)
const userData = user.toJSON();
const authDate = userData.authorization_date;
const authDays = userData.authorization_days || 0;
// 使用工具函数计算剩余天数
const { calculateRemainingDays } = require('../utils/account_utils');
const remaining_days = calculateRemainingDays(authDate, authDays);
// 处理设备ID
let device_id = client_device_id;
// 验证客户端提供的 device_id 格式
if (client_device_id) {
// if (!validateDeviceId(client_device_id)) {
// return ctx.fail('设备ID格式不正确请重新登录');
// }
// 如果与数据库不同需要额外验证防止设备ID劫持
if (user.device_id && client_device_id !== user.device_id) {
// 记录设备更换
console.warn(`[设备更换] 用户 ${maskEmail(email_normalized)} 更换设备: ${user.device_id} -> ${client_device_id}`);
// TODO: 这里可以添加更多安全检查,如:
// - 发送验证码确认
// - 记录到安全日志
// - 限制更换频率
}
// 更新设备ID
if (client_device_id !== user.device_id) {
await pla_account.update(
{ device_id: client_device_id },
{ where: { id: user.id } }
);
}
} else {
// 使用数据库中的设备ID
device_id = user.device_id;
}
// 如果仍然没有设备ID返回错误
if (!device_id) {
return ctx.fail('设备ID不能为空请重新登录');
}
// 创建token
const token = Framework.getServices().tokenService.create({
sn_code: user.sn_code,
device_id: device_id,
id: user.id
});
// 构建安全的用户信息响应(白名单模式)
const safeUserInfo = {
id: user.id,
sn_code: user.sn_code,
login_name: maskEmail(user.login_name), // 脱敏处理
is_enabled: user.is_enabled,
authorization_date: user.authorization_date,
authorization_days: user.authorization_days,
platform_type: user.platform_type,
remaining_days: remaining_days,
auto_deliver: user.auto_deliver,
deliver_config: user.deliver_config,
created_at: user.create_time,
updated_at: user.last_modify_time
};
// 记录成功登录
console.log(`[登录成功] 用户 ${maskEmail(email_normalized)}, 剩余天数: ${remaining_days}`);
return ctx.success({
token,
device_id,
user: safeUserInfo
});
} catch (error) {
// 记录详细错误但不暴露给客户端
console.error('[登录异常]', {
error: error.message,
stack: error.stack,
email: maskEmail(email_normalized)
});
return ctx.fail('登录失败,请稍后重试');
}
// 处理设备ID优先使用客户端传递的 device_id如果没有则使用数据库中的
let device_id = client_device_id || user.device_id;
// 如果客户端提供了 device_id 且与数据库中的不同,则更新数据库
if (client_device_id && client_device_id !== user.device_id) {
await pla_account.update(
{ device_id: client_device_id },
{ where: { id: user.id } }
);
device_id = client_device_id;
}
// 如果既没有客户端传递的,数据库中也为空,则返回错误(不应该发生,因为客户端会生成)
if (!device_id) {
return ctx.fail('设备ID不能为空请重新登录');
}
// 创建token
const token = Framework.getServices().tokenService.create({
sn_code: user.sn_code,
device_id: device_id,
id: user.id
});
// 计算剩余天数
let remaining_days = 0;
if (authDate && authDays > 0) {
const startDate = dayjs(authDate);
const endDate = startDate.add(authDays, 'day');
const now = dayjs();
const remaining = endDate.diff(now, 'day', true);
remaining_days = Math.max(0, Math.ceil(remaining));
}
const userInfo = user.toJSON();
userInfo.remaining_days = remaining_days;
// 不返回密码
delete userInfo.pwd;
return ctx.success({
token,
device_id,
user: userInfo
});
},
/**
@@ -208,10 +277,8 @@ module.exports = {
*/
'POST /user/delivery-config/get': async (ctx) => {
try {
console.log('[User Controller] 收到获取投递配置请求');
const body = ctx.getBody();
const { sn_code } = body;
console.log('[User Controller] sn_code:', sn_code);
if (!sn_code) {
return ctx.fail('请提供设备SN码');
@@ -233,8 +300,11 @@ module.exports = {
return ctx.success({ deliver_config });
} catch (error) {
console.error('获取投递配置失败:', error);
return ctx.fail('获取投递配置失败: ' + error.message);
console.error('[获取投递配置失败]', {
error: error.message,
timestamp: new Date().toISOString()
});
return ctx.fail('获取投递配置失败');
}
},
@@ -301,10 +371,10 @@ module.exports = {
*/
'POST /user/delivery-config/save': async (ctx) => {
try {
console.log('[User Controller] 收到保存投递配置请求');
const body = ctx.getBody();
console.log('body', body);
const { sn_code, deliver_config } = body;
console.log('[User Controller] sn_code:', sn_code, 'deliver_config:', JSON.stringify(deliver_config));
if (!sn_code) {
return ctx.fail('请提供设备SN码');
@@ -314,6 +384,11 @@ module.exports = {
return ctx.fail('请提供 deliver_config 配置对象');
}
// 验证 deliver_config 的基本结构
if (typeof deliver_config !== 'object') {
return ctx.fail('deliver_config 必须是对象');
}
const { pla_account } = await Framework.getModels();
// 根据 sn_code 查找账号
@@ -326,18 +401,64 @@ module.exports = {
}
// 更新 pla_account 表的 deliver_config 和 auto_deliver 字段
// 获取原有配置
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)) {
result[key] = deepMerge(targetValue, sourceValue);
} else {
// 否则直接覆盖
result[key] = sourceValue;
}
});
return result;
};
const new_deliver_config = deepMerge(original_deliver_config, deliver_config);
// 处理 auto_deliver 字段(支持 auto_deliver 和 auto_delivery 两种字段名)
let auto_deliver_value = 0;
if (deliver_config.auto_deliver !== undefined) {
auto_deliver_value = deliver_config.auto_deliver ? 1 : 0;
} else if (deliver_config.auto_delivery !== undefined) {
auto_deliver_value = deliver_config.auto_delivery ? 1 : 0;
} else {
// 如果没有传入 auto_deliver保持原有值
auto_deliver_value = user.auto_deliver || 0;
}
await pla_account.update(
{
deliver_config: deliver_config,
auto_deliver: deliver_config.auto_delivery ? 1 : 0
deliver_config: new_deliver_config,
auto_deliver: auto_deliver_value
},
{ where: { id: user.id } }
);
console.log('[保存投递配置成功]', {
sn_code,
deliver_config:new_deliver_config,
auto_deliver: auto_deliver_value,
timestamp: new Date().toISOString()
});
return ctx.success({ message: '配置保存成功' });
} catch (error) {
console.error('保存投递配置失败:', error);
return ctx.fail('保存投递配置失败: ' + error.message);
console.error('[保存投递配置失败]', {
error: error.message,
timestamp: new Date().toISOString()
});
return ctx.fail('保存投递配置失败');
}
}
}

View File

@@ -3,19 +3,21 @@ const config = require('../../../config/config');
const logs = require('../logProxy');
/**
* DeepSeek大模型服务
* 集成DeepSeek API提供智能化的岗位筛选、聊天生成、简历分析等功能
* Qwen 2.5 大模型服务
* 集成阿里云 DashScope API提供智能化的岗位筛选、聊天生成、简历分析等功能
*/
class aiService {
constructor() {
this.apiKey = config.deepseekApiKey || process.env.DEEPSEEK_API_KEY;
this.apiUrl = config.deepseekApiUrl || 'https://api.deepseek.com/v1/chat/completions';
this.model = config.deepseekModel || 'deepseek-chat';
this.apiKey = config.ai?.apiKey || process.env.DASHSCOPE_API_KEY;
// 使用 DashScope 兼容 OpenAI 格式的接口
this.apiUrl = 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions';
// Qwen 2.5 模型qwen-turbo快速、qwen-plus增强、qwen-max最强
this.model = config.ai?.model || 'qwen-turbo';
this.maxRetries = 3;
}
/**
* 调用DeepSeek API
* 调用 Qwen 2.5 API
* @param {string} prompt - 提示词
* @param {object} options - 配置选项
* @returns {Promise<object>} API响应结果
@@ -42,7 +44,7 @@ class aiService {
try {
const response = await axios.post(this.apiUrl, requestData, {
headers: {
'Authorization': `${this.apiKey}`,
'Authorization': `Bearer ${this.apiKey}`, // DashScope 使用 Bearer token 格式
'Content-Type': 'application/json'
},
timeout: 30000
@@ -53,7 +55,7 @@ class aiService {
content: response.data.choices?.[0]?.message?.content || ''
};
} catch (error) {
console.log(`DeepSeek API调用失败 (尝试 ${attempt}/${this.maxRetries}): ${error.message}`);
console.log(`Qwen 2.5 API调用失败 (尝试 ${attempt}/${this.maxRetries}): ${error.message}`);
if (attempt === this.maxRetries) {
throw new Error(error.message);
@@ -178,34 +180,50 @@ class aiService {
*/
async analyzeResume(resumeText) {
const prompt = `
请分析以下简历内容,提取核心要素
请分析以下简历内容,并返回 JSON 格式的分析结果
简历内容:
${resumeText}
提取以下信息
1. 技能标签(编程语言、框架、工具等)
2. 工作经验(年限、行业、项目等)
3. 教育背景(学历、专业、证书等)
4. 期望薪资范围
5. 期望工作地点
6. 核心优势
7. 职业发展方向
按以下格式返回 JSON 结果
{
"skillTags": ["技能1", "技能2", "技能3"], // 技能标签数组(编程语言、框架、工具等)
"strengths": "核心优势描述", // 简历的优势和亮点
"weaknesses": "不足之处描述", // 简历的不足或需要改进的地方
"careerSuggestion": "职业发展建议", // 针对该简历的职业发展方向和建议
"competitiveness": 75 // 竞争力评分0-100的整数综合考虑工作年限、技能、经验等因素
}
请以JSON格式返回结果。
要求:
1. skillTags 必须是字符串数组
2. strengths、weaknesses、careerSuggestion 是字符串描述
3. competitiveness 必须是 0-100 之间的整数
4. 所有字段都必须返回,如果没有相关信息,使用空数组或空字符串
`;
const result = await this.callAPI(prompt, {
systemPrompt: '你是一个专业的简历分析师,擅长提取简历的核心要素和关键信息。',
temperature: 0.2
systemPrompt: '你是一个专业的简历分析师,擅长分析简历的核心要素、优势劣势、竞争力评分和职业发展建议。请以 JSON 格式返回分析结果,确保格式正确。',
temperature: 0.3,
maxTokens: 1500
});
try {
const analysis = JSON.parse(result.content);
// 尝试从返回内容中提取 JSON
let content = result.content.trim();
// 如果返回内容被代码块包裹,提取其中的 JSON
const jsonMatch = content.match(/```(?:json)?\s*(\{[\s\S]*\})\s*```/) || content.match(/(\{[\s\S]*\})/);
if (jsonMatch) {
content = jsonMatch[1];
}
const analysis = JSON.parse(content);
return {
analysis: analysis
};
} catch (parseError) {
console.error(`[AI服务] 简历分析结果解析失败:`, parseError);
console.error(`[AI服务] 原始内容:`, result.content);
return {
analysis: {
content: result.content,

View File

@@ -136,14 +136,51 @@ class JobFilterService {
}
}
/**
* 保存匹配分析结果到数据库
* @param {string|number} jobPostingId - 职位IDjob_postings表的主键ID
* @param {object} analysisResult - 分析结果
* @returns {Promise<boolean>} 是否保存成功
*/
async saveMatchAnalysisToDatabase(jobPostingId, analysisResult) {
try {
const job_postings = db.getModel('job_postings');
if (!job_postings) {
console.warn('[职位过滤服务] job_postings 模型不存在,跳过保存');
return false;
}
const updateData = {
textMatchScore: analysisResult.overallScore || 0,
isOutsourcing: analysisResult.isOutsourcing || false,
matchSuggestion: analysisResult.suggestion || '',
matchConcerns: JSON.stringify(analysisResult.concerns || []),
textMatchAnalysis: JSON.stringify(analysisResult.analysis || analysisResult)
};
await job_postings.update(updateData, {
where: { id: jobPostingId }
});
console.log(`[职位过滤服务] 已保存职位 ${jobPostingId} 的匹配分析结果,综合评分: ${analysisResult.overallScore}`);
return true;
} catch (error) {
console.error(`[职位过滤服务] 保存匹配分析结果失败:`, error);
return false;
}
}
/**
* 使用文本匹配分析职位与简历的匹配度
* @param {object} jobInfo - 职位信息
* @param {object} resumeInfo - 简历信息(可选)
* @param {number} jobTypeId - 职位类型ID可选
* @param {object} options - 选项
* @param {number} options.jobPostingId - 职位IDjob_postings表的主键ID如果提供则自动保存到数据库
* @param {boolean} options.autoSave - 是否自动保存到数据库默认false
* @returns {Promise<object>} 匹配度分析结果
*/
async analyzeJobMatch(jobInfo, resumeInfo = {}, jobTypeId = null) {
async analyzeJobMatch(jobInfo, resumeInfo = {}, jobTypeId = null, options = {}) {
const jobText = this.buildJobText(jobInfo);
const resumeText = this.buildResumeText(resumeInfo);
@@ -178,7 +215,7 @@ class JobFilterService {
// 8. 投递建议
const suggestion = this.getSuggestion(overallScore, isOutsourcing, concerns);
return {
const result = {
skillMatch: skillScore,
experienceMatch: experienceScore,
salaryMatch: salaryScore,
@@ -198,6 +235,13 @@ class JobFilterService {
suggestion: suggestion
}
};
// 如果提供了 jobPostingId 且 autoSave 为 true自动保存到数据库
if (options.jobPostingId && options.autoSave !== false) {
await this.saveMatchAnalysisToDatabase(options.jobPostingId, result);
}
return result;
}
/**
@@ -453,21 +497,39 @@ class JobFilterService {
* @returns {string} 投递建议
*/
getSuggestion(overallScore, isOutsourcing, concerns) {
const concernsCount = concerns ? concerns.length : 0;
// 不推荐:外包 或 关注点过多(>2
if (isOutsourcing) {
return '谨慎投递:可能是外包岗位';
return '不推荐投递:外包岗位';
}
if (concerns.length > 2) {
if (concernsCount > 2) {
return '不推荐投递:存在多个关注点';
}
if (overallScore >= 80) {
return '强烈推荐投递:匹配度很高';
} else if (overallScore >= 60) {
return '可以投递:匹配度中等';
} else {
return '谨慎投递:匹配度较低';
// 不推荐:< 60 分
if (overallScore < 60) {
return '不推荐投递:匹配度较低';
}
// 优先级1必须投递≥ 80 分,且无关注点,非外包
if (overallScore >= 80 && concernsCount === 0) {
return '必须投递:匹配度很高,无关注点';
}
// 优先级2可以投递60-79 分,关注点 ≤ 2 个,非外包(优先处理无关注点情况)
if (overallScore >= 60 && overallScore < 80 && concernsCount === 0) {
return '可以投递:匹配度中等,无关注点';
}
// 优先级3谨慎考虑60-79 分但有关注点,或 ≥ 80 分但有关注点
if (overallScore >= 60 && concernsCount > 0 && concernsCount <= 2) {
return '谨慎考虑:存在关注点或特殊情况';
}
// 兜底情况
return '谨慎考虑:需要进一步评估';
}
/**
@@ -599,15 +661,19 @@ class JobFilterService {
* @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) {
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];
@@ -616,8 +682,13 @@ class JobFilterService {
for (const job of jobs) {
const jobData = job.toJSON ? job.toJSON() : job;
// 分析匹配度
const analysis = await this.analyzeJobMatch(jobData, resumeInfo, jobTypeId);
// 分析匹配度(如果 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,
@@ -662,11 +733,29 @@ class JobFilterService {
const scores = {};
let totalScore = 0;
// 解析权重配置
// 解析权重配置根据排序优先级规范化权重确保总和为100
const weights = {};
priorityWeights.forEach(item => {
weights[item.key] = item.weight / 100; // 转换为小数
});
if (Array.isArray(priorityWeights) && priorityWeights.length > 0) {
// 计算权重总和
const totalWeight = priorityWeights.reduce((sum, item) => sum + (Number(item.weight) || 0), 0);
// 如果总和大于0按比例规范化权重总和归一化为1即100%
if (totalWeight > 0) {
priorityWeights.forEach(item => {
weights[item.key] = (Number(item.weight) || 0) / totalWeight; // 规范化权重0-1之间
});
} else {
// 如果权重总和为0或未设置按照排序顺序分配权重第一个优先级最高
// 使用等差数列递减:第一个权重最高,依次递减
const n = priorityWeights.length;
const totalSequentialWeight = (n * (n + 1)) / 2; // 1+2+...+n
priorityWeights.forEach((item, index) => {
// 按照排序顺序,第一个权重最高,依次递减(权重比例为 n, n-1, n-2, ..., 1
const sequentialWeight = n - index;
weights[item.key] = sequentialWeight / totalSequentialWeight;
});
}
}
// 1. 距离评分(基于经纬度)
if (weights.distance) {

View File

@@ -0,0 +1,651 @@
# 职位过滤服务文档
## 概述
`job_filter_service.js` 是一个职位文本匹配过滤服务,使用简单的文本匹配规则来过滤和分析职位信息。该服务支持从数据库动态获取职位类型的技能关键词和排除关键词,能够分析职位与简历的匹配度,并提供过滤和评分功能。
## 主要功能
1. **职位类型配置管理**:从数据库获取或使用默认配置的技能关键词和排除关键词
2. **匹配度分析**:分析职位与简历的匹配度(技能、经验、薪资)
3. **职位过滤**:根据匹配分数、外包标识、排除关键词等条件过滤职位列表
4. **自定义权重评分**:支持根据自定义权重配置计算职位评分(距离、薪资、工作年限、学历、技能)
## 类结构
```javascript
class JobFilterService {
// 默认技能关键词
defaultCommonSkills: Array<string>
// 默认排除关键词
defaultExcludeKeywords: Array<string>
// 职位类型配置缓存
jobTypeCache: Map<number, Object>
}
```
## API 文档
### 1. getJobTypeConfig(jobTypeId)
根据职位类型ID获取技能关键词和排除关键词配置。
**参数:**
- `jobTypeId` (number, 可选): 职位类型ID
**返回值:**
```javascript
{
commonSkills: Array<string>, // 技能关键词列表
excludeKeywords: Array<string> // 排除关键词列表
}
```
**说明:**
- 如果未提供 `jobTypeId` 或配置获取失败,返回默认配置
- 配置结果会缓存5分钟避免频繁查询数据库
-`job_types` 表读取 `commonSkills``excludeKeywords` 字段JSON格式
**使用示例:**
```javascript
const config = await jobFilterService.getJobTypeConfig(1);
console.log(config.commonSkills); // ['Vue', 'React', ...]
console.log(config.excludeKeywords); // ['外包', '外派', ...]
```
---
### 2. clearCache(jobTypeId)
清除职位类型配置缓存。
**参数:**
- `jobTypeId` (number, 可选): 职位类型ID不传则清除所有缓存
**使用示例:**
```javascript
// 清除特定职位类型的缓存
jobFilterService.clearCache(1);
// 清除所有缓存
jobFilterService.clearCache();
```
---
### 3. analyzeJobMatch(jobInfo, resumeInfo, jobTypeId)
使用文本匹配分析职位与简历的匹配度。
**参数:**
- `jobInfo` (object, 必需): 职位信息对象
- `jobTitle` (string): 职位名称
- `companyName` (string): 公司名称
- `description` (string): 职位描述
- `skills` (string): 技能要求
- `requirements` (string): 职位要求
- `salary` (string): 薪资范围
- `experience` (string): 经验要求
- `education` (string): 学历要求
- `longitude` (number): 经度
- `latitude` (number): 纬度
- `resumeInfo` (object, 可选): 简历信息对象
- `skills` (string|Array): 技能列表
- `skillDescription` (string): 技能描述
- `currentPosition` (string): 当前职位
- `expectedPosition` (string): 期望职位
- `workYears` (number|string): 工作年限
- `expectedSalary` (string): 期望薪资
- `education` (string): 学历
- `jobTypeId` (number, 可选): 职位类型ID
**返回值:**
```javascript
{
skillMatch: number, // 技能匹配度0-100
experienceMatch: number, // 经验匹配度0-100
salaryMatch: number, // 薪资匹配度0-100
isOutsourcing: boolean, // 是否为外包岗位
overallScore: number, // 综合推荐指数0-100
matchReasons: Array<string>, // 匹配原因列表
concerns: Array<string>, // 关注点列表
suggestion: string, // 投递建议
analysis: Object // 完整的分析结果(同上)
}
```
**评分规则:**
1. **综合推荐指数计算**
- 技能匹配度 × 40% + 经验匹配度 × 30% + 薪资匹配度 × 30%
2. **技能匹配度**
- 从职位描述中提取技能关键词
- 计算简历中匹配的技能数量占比
- 无简历信息时,基于职位关键词数量评分
3. **经验匹配度**
- 从职位描述中提取经验要求应届、1年、2年、3年、5年、10年
- 根据简历工作年限与要求的匹配程度评分
4. **薪资匹配度**
- 解析职位薪资范围和期望薪资
- 期望薪资低于职位薪资时得高分,高于职位薪资时得低分
5. **外包检测**
- 检测职位描述中是否包含:外包、外派、驻场、人力外包、项目外包
**使用示例:**
```javascript
const jobInfo = {
jobTitle: '前端开发工程师',
description: '要求3年以上Vue开发经验熟悉React',
salary: '15-25K',
experience: '3-5年'
};
const resumeInfo = {
skills: 'Vue, React, JavaScript',
workYears: 4,
expectedSalary: '20K'
};
const result = await jobFilterService.analyzeJobMatch(jobInfo, resumeInfo, 1);
console.log(result.overallScore); // 85
console.log(result.matchReasons); // ['技能匹配度高', '工作经验符合要求', ...]
console.log(result.suggestion); // '强烈推荐投递:匹配度很高'
```
---
### 4. filterJobs(jobs, filterRules, resumeInfo, jobTypeId)
过滤职位列表,返回匹配的职位(带匹配分数)。
**参数:**
- `jobs` (Array, 必需): 职位列表(可以是 Sequelize 模型实例或普通对象)
- `filterRules` (object, 可选): 过滤规则
- `minScore` (number, 默认60): 最低匹配分数
- `excludeOutsourcing` (boolean, 默认true): 是否排除外包岗位
- `excludeKeywords` (Array<string>, 默认[]): 额外排除关键词列表
- `resumeInfo` (object, 可选): 简历信息
- `jobTypeId` (number, 可选): 职位类型ID
**返回值:**
```javascript
Array<{
...jobData, // 原始职位数据
matchScore: number, // 匹配分数
matchAnalysis: Object // 完整的匹配分析结果
}>
```
**过滤逻辑:**
1. 对每个职位进行匹配度分析
2. 过滤掉匹配分数低于 `minScore` 的职位
3. 如果 `excludeOutsourcing` 为 true过滤掉外包岗位
4. 过滤掉包含排除关键词的职位
5. 按匹配分数降序排序
**使用示例:**
```javascript
const jobs = [
{ jobTitle: '前端开发', description: 'Vue开发...', salary: '20K' },
{ jobTitle: '后端开发', description: 'Java开发...', salary: '25K' }
];
const resumeInfo = {
skills: 'Vue, JavaScript',
workYears: 3
};
const filterRules = {
minScore: 70,
excludeOutsourcing: true,
excludeKeywords: ['销售']
};
const filteredJobs = await jobFilterService.filterJobs(
jobs,
filterRules,
resumeInfo,
1
);
console.log(filteredJobs.length); // 过滤后的职位数量
console.log(filteredJobs[0].matchScore); // 第一个职位的匹配分数
```
---
### 5. calculateJobScoreWithWeights(jobData, resumeInfo, accountConfig, jobTypeConfig, priorityWeights)
根据自定义权重配置计算职位评分。
**参数:**
- `jobData` (object, 必需): 职位数据
- `longitude` (number): 经度
- `latitude` (number): 纬度
- `salary` (string): 薪资范围
- `experience` (string): 经验要求
- `education` (string): 学历要求
- `resumeInfo` (object, 必需): 简历信息
- `expectedSalary` (string): 期望薪资
- `workYears` (string|number): 工作年限
- `education` (string): 学历
- `skills` (string|Array): 技能列表
- `accountConfig` (object, 必需): 账号配置
- `user_longitude` (number): 用户经度
- `user_latitude` (number): 用户纬度
- `jobTypeConfig` (object, 可选): 职位类型配置(包含 commonSkills
- `priorityWeights` (Array, 必需): 权重配置
```javascript
[
{ key: 'distance', weight: 30 }, // 距离权重0-100
{ key: 'salary', weight: 40 }, // 薪资权重
{ key: 'work_years', weight: 20 }, // 工作年限权重
{ key: 'education', weight: 10 } // 学历权重
]
```
**返回值:**
```javascript
{
totalScore: number, // 总分0-100+,技能评分作为额外加分项)
scores: {
distance: number, // 距离评分
salary: number, // 薪资评分
work_years: number, // 工作年限评分
education: number, // 学历评分
skills: number // 技能评分(如果有 jobTypeConfig
}
}
```
**评分规则:**
1. **距离评分**
- 0-5km: 100分
- 5-10km: 90分
- 10-20km: 80分
- 20-50km: 60分
- 50km以上: 30分
2. **薪资评分**
- 职位薪资 ≥ 期望薪资: 100分
- 职位薪资 ≥ 期望薪资 × 0.8: 80分
- 职位薪资 ≥ 期望薪资 × 0.6: 60分
- 其他: 40分
3. **工作年限评分**
- 简历年限 ≥ 职位要求: 100分
- 简历年限 ≥ 职位要求 × 0.8: 80分
- 简历年限 ≥ 职位要求 × 0.6: 60分
- 其他: 40分
4. **学历评分**
- 简历学历 ≥ 职位要求: 100分
- 简历学历 = 职位要求 - 1级: 70分
- 其他: 40分
5. **技能评分**
- 计算简历技能与职位类型配置的技能关键词匹配度
- 作为额外加分项固定权重10%
**使用示例:**
```javascript
const jobData = {
longitude: 116.3974,
latitude: 39.9093,
salary: '20-30K',
experience: '3-5年',
education: '本科'
};
const resumeInfo = {
expectedSalary: '25K',
workYears: 4,
education: '本科',
skills: ['Vue', 'React', 'JavaScript']
};
const accountConfig = {
user_longitude: 116.4074,
user_latitude: 39.9042
};
const jobTypeConfig = {
commonSkills: ['Vue', 'React', 'JavaScript', 'Node.js']
};
const priorityWeights = [
{ key: 'distance', weight: 30 },
{ key: 'salary', weight: 40 },
{ key: 'work_years', weight: 20 },
{ key: 'education', weight: 10 }
];
const result = jobFilterService.calculateJobScoreWithWeights(
jobData,
resumeInfo,
accountConfig,
jobTypeConfig,
priorityWeights
);
console.log(result.totalScore); // 85
console.log(result.scores); // { distance: 100, salary: 90, work_years: 100, ... }
```
---
### 6. parseSalaryRange(salaryDesc)
解析薪资范围字符串。
**参数:**
- `salaryDesc` (string): 薪资描述字符串
- 支持格式:`15-25K`、`25K`、`5000-6000元/月`、`2-3万`、`20000-30000` 等
**返回值:**
```javascript
{
min: number, // 最低薪资(元)
max: number // 最高薪资(元)
}
```
或 `null`(解析失败时)
**使用示例:**
```javascript
const range1 = jobFilterService.parseSalaryRange('15-25K');
console.log(range1); // { min: 15000, max: 25000 }
const range2 = jobFilterService.parseSalaryRange('2-3万');
console.log(range2); // { min: 20000, max: 30000 }
```
---
### 7. parseExpectedSalary(expectedSalary)
解析期望薪资字符串,返回平均值。
**参数:**
- `expectedSalary` (string): 期望薪资描述字符串
**返回值:**
- `number`: 期望薪资数值(元),如果是范围则返回平均值
- `null`: 解析失败时
**使用示例:**
```javascript
const salary1 = jobFilterService.parseExpectedSalary('20-30K');
console.log(salary1); // 25000平均值
const salary2 = jobFilterService.parseExpectedSalary('25K');
console.log(salary2); // 25000
```
---
### 8. parseWorkYears(workYearsStr)
解析工作年限字符串为数字。
**参数:**
- `workYearsStr` (string): 工作年限字符串(如 "3年"、"5年以上"
**返回值:**
- `number`: 工作年限数字
- `null`: 解析失败时
**使用示例:**
```javascript
const years = jobFilterService.parseWorkYears('3-5年');
console.log(years); // 3提取第一个数字
```
---
## 辅助方法
### buildJobText(jobInfo)
构建职位文本(用于匹配)。
**参数:**
- `jobInfo` (object): 职位信息对象
**返回值:**
- `string`: 合并后的职位文本(小写)
---
### buildResumeText(resumeInfo)
构建简历文本(用于匹配)。
**参数:**
- `resumeInfo` (object): 简历信息对象
**返回值:**
- `string`: 合并后的简历文本(小写)
---
### calculateSkillMatch(jobText, resumeText, commonSkills)
计算技能匹配度0-100分
**参数:**
- `jobText` (string): 职位文本
- `resumeText` (string): 简历文本
- `commonSkills` (Array<string>): 技能关键词列表
**返回值:**
- `number`: 匹配度分数0-100
---
### calculateExperienceMatch(jobInfo, resumeInfo)
计算经验匹配度0-100分
**参数:**
- `jobInfo` (object): 职位信息
- `resumeInfo` (object): 简历信息
**返回值:**
- `number`: 匹配度分数0-100
---
### calculateSalaryMatch(jobInfo, resumeInfo)
计算薪资合理性0-100分
**参数:**
- `jobInfo` (object): 职位信息
- `resumeInfo` (object): 简历信息
**返回值:**
- `number`: 匹配度分数0-100
---
### checkOutsourcing(jobText)
检查是否为外包岗位。
**参数:**
- `jobText` (string): 职位文本
**返回值:**
- `boolean`: 是否为外包
---
## 默认配置
### 默认技能关键词
```javascript
[
'Vue', 'React', 'Angular', 'JavaScript', 'TypeScript', 'Node.js',
'Python', 'Java', 'C#', '.NET', 'Flutter', 'React Native',
'Webpack', 'Vite', 'Redux', 'MobX', 'Express', 'Koa',
'Django', 'Flask', 'MySQL', 'MongoDB', 'Redis',
'WebRTC', 'FFmpeg', 'Canvas', 'WebSocket', 'HTML5', 'CSS3',
'jQuery', 'Bootstrap', 'Element UI', 'Ant Design',
'Git', 'Docker', 'Kubernetes', 'AWS', 'Azure',
'Selenium', 'Jest', 'Mocha', 'Cypress'
]
```
### 默认排除关键词
```javascript
[
'外包', '外派', '驻场', '销售', '客服', '电话销售',
'地推', '推广', '市场', '运营', '行政', '文员'
]
```
## 数据库依赖
该服务依赖以下数据库表:
### job_types 表
存储职位类型配置,需要包含以下字段:
- `id` (number): 职位类型ID
- `is_enabled` (number): 是否启用1=启用0=禁用)
- `commonSkills` (string|JSON): 技能关键词列表JSON数组字符串
- `excludeKeywords` (string|JSON): 排除关键词列表JSON数组字符串
**示例数据:**
```json
{
"id": 1,
"is_enabled": 1,
"commonSkills": "[\"Vue\", \"React\", \"JavaScript\"]",
"excludeKeywords": "[\"外包\", \"外派\"]"
}
```
## 使用示例
### 完整使用流程
```javascript
const jobFilterService = require('./job_filter_service');
// 1. 分析单个职位的匹配度
const jobInfo = {
jobTitle: '高级前端开发工程师',
companyName: 'XX科技有限公司',
description: '负责前端架构设计要求5年以上Vue/React开发经验',
skills: 'Vue, React, TypeScript, Node.js',
requirements: '本科及以上学历,有大型项目经验',
salary: '25-40K·14薪'
};
const resumeInfo = {
skills: 'Vue, React, JavaScript, TypeScript, Node.js, Webpack',
skillDescription: '精通Vue生态熟悉React有5年+前端开发经验',
currentPosition: '高级前端开发工程师',
expectedPosition: '前端架构师',
workYears: 6,
expectedSalary: '30K',
education: '本科'
};
const analysis = await jobFilterService.analyzeJobMatch(
jobInfo,
resumeInfo,
1 // 职位类型ID
);
console.log('匹配分析结果:');
console.log('综合评分:', analysis.overallScore);
console.log('技能匹配度:', analysis.skillMatch);
console.log('经验匹配度:', analysis.experienceMatch);
console.log('薪资匹配度:', analysis.salaryMatch);
console.log('是否外包:', analysis.isOutsourcing);
console.log('匹配原因:', analysis.matchReasons);
console.log('关注点:', analysis.concerns);
console.log('投递建议:', analysis.suggestion);
// 2. 过滤职位列表
const jobs = await Job.findAll({ where: { status: 1 } });
const filteredJobs = await jobFilterService.filterJobs(
jobs,
{
minScore: 70,
excludeOutsourcing: true,
excludeKeywords: ['销售', '客服']
},
resumeInfo,
1
);
console.log(`共找到 ${filteredJobs.length} 个匹配的职位`);
filteredJobs.forEach((job, index) => {
console.log(`${index + 1}. ${job.jobTitle} - 匹配分数:${job.matchScore}`);
});
// 3. 自定义权重评分
const accountConfig = {
user_longitude: 116.4074,
user_latitude: 39.9042
};
const priorityWeights = [
{ key: 'distance', weight: 25 },
{ key: 'salary', weight: 35 },
{ key: 'work_years', weight: 25 },
{ key: 'education', weight: 15 }
];
const scoreResult = jobFilterService.calculateJobScoreWithWeights(
jobInfo,
resumeInfo,
accountConfig,
{ commonSkills: ['Vue', 'React', 'TypeScript'] },
priorityWeights
);
console.log('自定义权重评分:', scoreResult.totalScore);
console.log('各项评分:', scoreResult.scores);
```
## 注意事项
1. **缓存机制**职位类型配置会缓存5分钟修改数据库配置后需要调用 `clearCache()` 清除缓存
2. **性能优化**
- 大量职位过滤时,建议分批处理
- 避免在循环中频繁调用 `getJobTypeConfig()`,配置会被自动缓存
3. **文本匹配**
- 所有文本匹配均为小写匹配,不区分大小写
- 匹配逻辑较为简单,如需更精确的匹配,建议使用 AI 分析(二期规划)
4. **薪资解析**
- 支持多种薪资格式,但可能无法解析所有格式
- 解析失败时返回默认分数或 null
5. **错误处理**
- 所有方法都包含错误处理,失败时返回默认值或空结果
- 建议在生产环境中监控日志输出
## 版本历史
- **v1.0.0**: 初始版本,支持基础文本匹配和过滤功能
- 支持从数据库动态获取职位类型配置
- 支持自定义权重评分计算

View File

@@ -181,12 +181,103 @@ class ResumeManager {
console.log(`[简历管理] 简历已创建 - ID: ${resumeId}`);
}
// 二期规划AI 分析暂时禁用,使用简单的文本匹配
console.log(`[简历管理] AI分析已禁用二期规划使用文本匹配过滤`);
// 调用 AI 分析简历并更新 AI 字段
try {
await this.analyze_resume_with_ai(resumeId, resumeInfo);
console.log(`[简历管理] AI 分析完成并已更新到数据库`);
} catch (error) {
console.error(`[简历管理] AI 分析失败:`, error);
// AI 分析失败不影响主流程,继续返回成功
}
return { resumeId, message: existingResume ? '简历更新成功' : '简历创建成功' };
}
/**
* 构建用于 AI 分析的简历文本
* @param {object} resumeInfo - 简历信息对象
* @returns {string} 简历文本内容
*/
build_resume_text_for_ai(resumeInfo) {
const parts = [];
// 基本信息
if (resumeInfo.fullName) parts.push(`姓名:${resumeInfo.fullName}`);
if (resumeInfo.gender) parts.push(`性别:${resumeInfo.gender}`);
if (resumeInfo.age) parts.push(`年龄:${resumeInfo.age}`);
if (resumeInfo.location) parts.push(`所在地:${resumeInfo.location}`);
// 教育背景
if (resumeInfo.education) parts.push(`学历:${resumeInfo.education}`);
if (resumeInfo.school) parts.push(`毕业院校:${resumeInfo.school}`);
if (resumeInfo.major) parts.push(`专业:${resumeInfo.major}`);
if (resumeInfo.graduationYear) parts.push(`毕业年份:${resumeInfo.graduationYear}`);
// 工作经验
if (resumeInfo.workYears) parts.push(`工作年限:${resumeInfo.workYears}`);
if (resumeInfo.currentPosition) parts.push(`当前职位:${resumeInfo.currentPosition}`);
if (resumeInfo.currentCompany) parts.push(`当前公司:${resumeInfo.currentCompany}`);
// 期望信息
if (resumeInfo.expectedPosition) parts.push(`期望职位:${resumeInfo.expectedPosition}`);
if (resumeInfo.expectedSalary) parts.push(`期望薪资:${resumeInfo.expectedSalary}`);
if (resumeInfo.expectedLocation) parts.push(`期望地点:${resumeInfo.expectedLocation}`);
// 技能描述
if (resumeInfo.skillDescription) parts.push(`技能描述:${resumeInfo.skillDescription}`);
// 工作经历
if (resumeInfo.workExperience) {
try {
const workExp = JSON.parse(resumeInfo.workExperience);
if (Array.isArray(workExp) && workExp.length > 0) {
parts.push('\n工作经历');
workExp.forEach(work => {
const workText = [
work.company && `公司:${work.company}`,
work.position && `职位:${work.position}`,
work.startDate && work.endDate && `时间:${work.startDate} - ${work.endDate}`,
work.content && `工作内容:${work.content}`
].filter(Boolean).join('');
if (workText) parts.push(workText);
});
}
} catch (e) {
// 解析失败,忽略
}
}
// 项目经验
if (resumeInfo.projectExperience) {
try {
const projectExp = JSON.parse(resumeInfo.projectExperience);
if (Array.isArray(projectExp) && projectExp.length > 0) {
parts.push('\n项目经验');
projectExp.forEach(project => {
const projectText = [
project.name && `项目名称:${project.name}`,
project.role && `角色:${project.role}`,
project.description && `描述:${project.description}`
].filter(Boolean).join('');
if (projectText) parts.push(projectText);
});
}
} catch (e) {
// 解析失败,忽略
}
}
// 简历完整内容
if (resumeInfo.resumeContent) {
parts.push('\n简历详细内容');
parts.push(resumeInfo.resumeContent);
}
return parts.join('\n');
}
/**
* 从描述中提取技能标签
* @param {string} description - 描述文本
@@ -225,105 +316,168 @@ class ResumeManager {
const resume_info = db.getModel('resume_info');
// 解析 JSON 字段
let skillsArray = [];
let workExpArray = [];
let projectExpArray = [];
try {
if (resumeInfo.skills) {
skillsArray = typeof resumeInfo.skills === 'string' ? JSON.parse(resumeInfo.skills) : resumeInfo.skills;
}
} catch (e) {
console.warn(`[简历管理] 解析技能字段失败:`, e);
}
try {
if (resumeInfo.workExperience) {
workExpArray = typeof resumeInfo.workExperience === 'string' ? JSON.parse(resumeInfo.workExperience) : resumeInfo.workExperience;
}
} catch (e) {
console.warn(`[简历管理] 解析工作经历字段失败:`, e);
}
try {
if (resumeInfo.projectExperience) {
projectExpArray = typeof resumeInfo.projectExperience === 'string' ? JSON.parse(resumeInfo.projectExperience) : resumeInfo.projectExperience;
}
} catch (e) {
console.warn(`[简历管理] 解析项目经历字段失败:`, e);
}
// 构建工作经历文本
const workExpText = workExpArray.map((work, index) => {
return `${index + 1}. ${work.company || ''} - ${work.position || ''} (${work.startDate || ''} ~ ${work.endDate || '至今'})
工作内容:${work.content || ''}
行业:${work.industry || ''}`;
}).join('\n\n');
// 构建项目经历文本
const projectExpText = projectExpArray.map((project, index) => {
return `${index + 1}. ${project.name || ''} - ${project.role || ''} (${project.startDate || ''} ~ ${project.endDate || '至今'})
项目描述:${project.description || ''}
项目成果:${project.performance || ''}`;
}).join('\n\n');
// 构建分析提示词
const prompt = `请分析以下简历,提供专业的评估:
姓名:${resumeInfo.fullName}
工作年限${resumeInfo.workYears}
当前职位${resumeInfo.currentPosition}
期望职位${resumeInfo.expectedPosition}
期望薪资${resumeInfo.expectedSalary}
学历${resumeInfo.education}
技能:${resumeInfo.skills}
【基本信息】
姓名${resumeInfo.fullName || ''}
性别${resumeInfo.gender || ''}
年龄${resumeInfo.age || ''}
所在地${resumeInfo.location || ''}
工作年限${resumeInfo.workYears || ''}
个人优势:
${resumeInfo.skillDescription}
【教育背景】
学历:${resumeInfo.education || ''}
专业:${resumeInfo.major || ''}
毕业院校:${resumeInfo.school || ''}
毕业年份:${resumeInfo.graduationYear || ''}
请从以下几个方面进行分析:
1. 核心技能标签提取5-10个关键技能
2. 优势分析100字以内
3. 劣势分析100字以内
4. 职业建议150字以内
5. 竞争力评分0-100分`;
【当前状态】
当前职位:${resumeInfo.currentPosition || ''}
当前公司:${resumeInfo.currentCompany || ''}
当前薪资:${resumeInfo.currentSalary || ''}
try {
// 调用AI服务进行分析
const aiAnalysis = await aiService.analyzeResume(prompt);
【求职期望】
期望职位:${resumeInfo.expectedPosition || ''}
期望薪资:${resumeInfo.expectedSalary || ''}
期望地点:${resumeInfo.expectedLocation || ''}
期望行业:${resumeInfo.expectedIndustry || ''}
// 解析AI返回的结果
const analysis = this.parse_ai_analysis(aiAnalysis, resumeInfo);
【技能标签】
${skillsArray.length > 0 ? skillsArray.join('、') : '无'}
// 更新简历的AI分析字段
await resume_info.update({
aiSkillTags: JSON.stringify(analysis.skillTags),
aiStrengths: analysis.strengths,
aiWeaknesses: analysis.weaknesses,
aiCareerSuggestion: analysis.careerSuggestion,
aiCompetitiveness: analysis.competitiveness
}, { where: { id: resumeId } });
【个人优势描述】
${resumeInfo.skillDescription || ''}
console.log(`[简历管理] AI分析完成 - 竞争力评分: ${analysis.competitiveness}`);
【工作经历】
${workExpText || '无'}
return analysis;
} catch (error) {
console.error(`[简历管理] AI分析失败:`, error, {
resumeId: resumeId,
fullName: resumeInfo.fullName
});
【项目经历】
${projectExpText || '无'}
// 如果AI分析失败使用基于规则的默认分析
const defaultAnalysis = this.get_default_analysis(resumeInfo);
请从以下几个方面进行专业分析,并返回 JSON 格式结果:
1. skillTags: 核心技能标签数组提取5-10个最关键的技术技能Vue、React、Node.js等
2. strengths: 优势分析100字以内基于工作经历、项目经历、技能水平等综合评估
3. weaknesses: 劣势分析100字以内指出需要改进的地方或不足
4. careerSuggestion: 职业建议150字以内基于期望职位和当前能力给出职业发展建议
5. competitiveness: 竞争力评分0-100的整数综合考虑工作年限、技能深度、项目经验、学历等因素
return defaultAnalysis;
要求:
- 竞争力评分要客观公正,基于实际能力评估
- 优势分析要突出核心亮点和技术能力
- 劣势分析要指出真实存在的问题
- 职业建议要具有针对性和可操作性`;
// 调用AI服务进行分析
const aiAnalysis = await aiService.analyzeResume(prompt);
// 解析AI返回的结果如果AI没返回有效数据直接抛出错误
const analysis = this.parse_ai_analysis(aiAnalysis, resumeInfo);
if (!analysis) {
throw new Error('AI分析结果为空无法处理');
}
// 确保所有字段都有值
const updateData = {
aiSkillTags: JSON.stringify(analysis.skillTags || []),
aiStrengths: analysis.strengths || '',
aiWeaknesses: analysis.weaknesses || '',
aiCareerSuggestion: analysis.careerSuggestion || '',
aiCompetitiveness: parseInt(analysis.competitiveness || 0, 10)
};
// 确保竞争力评分在 0-100 范围内
if (updateData.aiCompetitiveness < 0) updateData.aiCompetitiveness = 0;
if (updateData.aiCompetitiveness > 100) updateData.aiCompetitiveness = 100;
// 更新简历的AI分析字段
await resume_info.update(updateData, { where: { resumeId: resumeId } });
console.log(`[简历管理] AI分析完成 - 竞争力评分: ${updateData.aiCompetitiveness}, 技能标签: ${updateData.aiSkillTags}`);
return analysis;
}
/**
* 解析AI分析结果
* @param {object} aiResponse - AI响应对象
* @param {object} resumeInfo - 简历信息
* @returns {object} 解析后的分析结果
* @returns {object|null} 解析后的分析结果如果AI没返回有效数据则返回null
*/
parse_ai_analysis(aiResponse, resumeInfo) {
try {
// 尝试从AI响应中解析JSON
const content = aiResponse.content || aiResponse.analysis?.content || '';
// aiService.analyzeResume 返回格式: { analysis: {...} } 或 { analysis: { content: "...", parseError: true } }
const analysis = aiResponse?.analysis;
// 如果AI返回的是JSON格式
if (content.includes('{') && content.includes('}')) {
const jsonMatch = content.match(/\{[\s\S]*\}/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]);
// 如果解析失败analysis 会有 parseError 标记,直接返回 null
if (!analysis || analysis.parseError) {
console.warn(`[简历管理] AI分析结果解析失败或为空`);
return null;
}
return {
skillTags: parsed.skillTags || parsed.技能标签 || [],
strengths: parsed.strengths || parsed.优势 || parsed.优势分析 || '',
weaknesses: parsed.weaknesses || parsed.劣势 || parsed.劣势分析 || '',
careerSuggestion: parsed.careerSuggestion || parsed.职业建议 || '',
competitiveness: parsed.competitiveness || parsed.竞争力评分 || 70
};
}
// 如果解析成功analysis 直接是解析后的对象
if (typeof analysis === 'object' && !analysis.parseError) {
// 检查是否有必要的字段
if (analysis.competitiveness === undefined && analysis.竞争力评分 === undefined) {
console.warn(`[简历管理] AI分析结果缺少竞争力评分字段`);
return null;
}
// 如果无法解析JSON尝试从文本中提取信息
const skillTagsMatch = content.match(/技能标签[:](.*?)(?:\n|$)/);
const strengthsMatch = content.match(/[]*[:](.*?)(?:\n|)/s);
const weaknessesMatch = content.match(/[]*[:](.*?)(?:\n|)/s);
const suggestionMatch = content.match(/[:](.*?)(?:\n|)/s);
const scoreMatch = content.match(/竞争力评分[:](\d+)/);
return {
skillTags: skillTagsMatch ? skillTagsMatch[1].split(/[,,、]/).map(s => s.trim()) : [],
strengths: strengthsMatch ? strengthsMatch[1].trim() : '',
weaknesses: weaknessesMatch ? weaknessesMatch[1].trim() : '',
careerSuggestion: suggestionMatch ? suggestionMatch[1].trim() : '',
competitiveness: scoreMatch ? parseInt(scoreMatch[1]) : 70
skillTags: analysis.skillTags || analysis.技能标签 || [],
strengths: analysis.strengths || analysis.优势 || analysis.优势分析 || '',
weaknesses: analysis.weaknesses || analysis.劣势 || analysis.劣势分析 || '',
careerSuggestion: analysis.careerSuggestion || analysis.职业建议 || '',
competitiveness: parseInt(analysis.competitiveness || analysis.竞争力评分 || 0, 10)
};
} catch (error) {
console.error(`[简历管理] 解析AI分析结果失败:`, error);
// 解析失败时使用默认分析
return this.get_default_analysis(resumeInfo);
}
// 如果格式不对,返回 null
console.warn(`[简历管理] AI分析结果格式不正确`);
return null;
}
/**
@@ -378,7 +532,7 @@ ${resumeInfo.skillDescription}
// 二期规划AI 分析暂时禁用,使用简单的文本匹配
// const analysis = await aiService.analyzeResume(resumeText);
// 使用简单的文本匹配提取技能
const skills = this.extract_skills_from_desc(resumeData.skillDescription || resumeText);
@@ -505,7 +659,7 @@ ${resumeInfo.skillDescription}
throw new Error('MQTT客户端未初始化');
}
}
// 重新获取和分析
const resumeData = await this.get_online_resume(sn_code, mqttClient);
return await this.analyze_resume(sn_code, resumeData);

View File

@@ -2,13 +2,16 @@ const mqtt = require('mqtt')
const { v4: uuidv4 } = require('uuid'); // 顶部添加
const Framework = require('../../../framework/node-core-framework');
const logs = require('../logProxy');
// 获取logsService
class MqttSyncClient {
constructor(brokerUrl, options = {}) {
this.client = mqtt.connect(brokerUrl, options)
this.isConnected = false
this.messageListeners = []
// 使用 Map 结构优化消息监听器,按 topic 分组
this.messageListeners = new Map(); // Map<topic, Set<listener>>
this.globalListeners = new Set(); // 全局监听器(监听所有 topic
this.client.on('connect', () => {
this.isConnected = true
@@ -18,11 +21,42 @@ class MqttSyncClient {
this.client.on('message', (topic, message) => {
message = JSON.parse(message.toString())
let messageObj;
try {
messageObj = JSON.parse(message.toString());
} catch (error) {
console.warn('[MQTT] 消息解析失败:', error.message);
return;
}
console.log('MQTT 收到消息', topic, message)
// 记录日志但不包含敏感信息
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');
this.messageListeners.forEach(listener => listener(topic, message))
// 优化:只通知相关 topic 的监听器,而不是所有监听器
// 1. 触发该 topic 的专用监听器
const topicListeners = this.messageListeners.get(topic);
if (topicListeners && topicListeners.size > 0) {
topicListeners.forEach(listener => {
try {
listener(topic, messageObj);
} catch (error) {
console.error('[MQTT] Topic监听器执行失败:', error.message);
}
});
}
// 2. 触发全局监听器
if (this.globalListeners.size > 0) {
this.globalListeners.forEach(listener => {
try {
listener(topic, messageObj);
} catch (error) {
console.error('[MQTT] 全局监听器执行失败:', error.message);
}
});
}
})
@@ -139,12 +173,56 @@ class MqttSyncClient {
});
}
addMessageListener(fn) {
this.messageListeners.push(fn)
/**
* 添加消息监听器
* @param {Function} fn - 监听器函数
* @param {string} topic - 可选,指定监听的 topic不指定则监听所有
*/
addMessageListener(fn, topic = null) {
if (typeof fn !== 'function') {
throw new Error('监听器必须是函数');
}
if (topic) {
// 添加到特定 topic 的监听器
if (!this.messageListeners.has(topic)) {
this.messageListeners.set(topic, new Set());
}
this.messageListeners.get(topic).add(fn);
} else {
// 添加到全局监听器
this.globalListeners.add(fn);
}
}
removeMessageListener(fn) {
this.messageListeners = this.messageListeners.filter(f => f !== fn)
/**
* 移除消息监听器
* @param {Function} fn - 监听器函数
* @param {string} topic - 可选,指定从哪个 topic 移除
*/
removeMessageListener(fn, topic = null) {
if (topic) {
// 从特定 topic 移除
const topicListeners = this.messageListeners.get(topic);
if (topicListeners) {
topicListeners.delete(fn);
// 如果该 topic 没有监听器了,删除整个 Set
if (topicListeners.size === 0) {
this.messageListeners.delete(topic);
}
}
} else {
// 从全局监听器移除
this.globalListeners.delete(fn);
// 也尝试从所有 topic 中移除(兼容旧代码)
for (const [topicKey, listeners] of this.messageListeners.entries()) {
listeners.delete(fn);
if (listeners.size === 0) {
this.messageListeners.delete(topicKey);
}
}
}
}
/**

View File

@@ -276,24 +276,27 @@ class MqttDispatcher {
}
}
// 移除 device_status 更新逻辑
// 如果需要在 pla_account 表中添加在线状态字段,可以在这里更新
console.log(`[MQTT心跳] 设备 ${sn_code} 心跳已接收 - 登录: ${updateData.isLoggedIn || false}`);
// if (device) {
// await device_status.update(updateData, { where: { sn_code } });
// console.log(`[MQTT心跳] 设备 ${sn_code} 状态已更新 - 在线: true, 登录: ${updateData.isLoggedIn}`);
// } else {
// logProxy.error('[MQTT心跳] 设备 ${sn_code} 不存在', { sn_code });
// return;
// }
// 更新 pla_account 表中的在线和登录状态
try {
const pla_account = db.getModel('pla_account');
await pla_account.update(
{
is_online: 1,
is_logged_in: updateData.isLoggedIn ? 1 : 0
},
{ where: { sn_code } }
);
console.log(`[MQTT心跳] 设备 ${sn_code} 状态已更新到数据库 - 在线: true, 登录: ${updateData.isLoggedIn || false}`);
} catch (error) {
console.error(`[MQTT心跳] 更新数据库状态失败:`, error);
}
// 记录心跳到设备管理器(包含登录状态)
const heartbeatPayload = {
isLoggedIn: updateData.isLoggedIn || false,
...heartbeatData
};
console.log(`[MQTT心跳] 传递给 deviceManager 的数据:`, { sn_code, isLoggedIn: heartbeatPayload.isLoggedIn });
await deviceManager.recordHeartbeat(sn_code, heartbeatPayload);
} catch (error) {
console.error('[MQTT心跳] 处理心跳消息失败:', error);

View File

@@ -0,0 +1,22 @@
/**
* Redis 服务代理
* 从 Framework 获取 redisService
*/
const Framework = require("../../framework/node-core-framework.js");
module.exports = new Proxy({}, {
get(_, prop) {
const services = Framework.getServices();
const redisService = services?.redisService;
if (!redisService) {
throw new Error('Redis service not available. Framework may not be initialized.');
}
return typeof redisService[prop] === 'function'
? redisService[prop].bind(redisService)
: redisService[prop];
}
});

View File

@@ -6,18 +6,7 @@ const dayjs = require('dayjs');
*/
class ScheduleConfig {
constructor() {
// 工作时间配置
this.workHours = {
start: 6,
end: 23
};
// 频率限制配置(毫秒)
this.rateLimits = {
search: 30 * 60 * 1000, // 搜索间隔30分钟
apply: 5 * 60 * 1000, // 投递间隔5分钟
chat: 1 * 60 * 1000, // 聊天间隔1分钟
};
// 单日操作限制
this.dailyLimits = {
@@ -62,15 +51,6 @@ class ScheduleConfig {
};
}
/**
* 检查是否在工作时间
* @returns {boolean}
*/
isWorkingHours() {
const now = dayjs();
const hour = now.hour();
return hour >= this.workHours.start && hour < this.workHours.end;
}
/**
* 获取任务超时时间
@@ -97,15 +77,6 @@ class ScheduleConfig {
return priority;
}
/**
* 获取操作频率限制
* @param {string} operation - 操作类型
* @returns {number} 间隔时间(毫秒)
*/
getRateLimit(operation) {
return this.rateLimits[operation] || 0;
}
/**
* 获取日限制
* @param {string} operation - 操作类型

View File

@@ -100,22 +100,8 @@ class DeviceManager {
* 检查是否可以执行操作
*/
canExecuteOperation(sn_code, operation_type) {
// 检查工作时间
if (!config.isWorkingHours()) {
return { allowed: false, reason: '不在工作时间内' };
}
// 检查频率限制
// 检查日限制(频率限制已由各任务使用账号配置中的间隔时间,不再使用全局配置)
const device = this.devices.get(sn_code);
if (device) {
const lastTime = device[`last${operation_type.charAt(0).toUpperCase() + operation_type.slice(1)}`] || 0;
const interval = config.getRateLimit(operation_type);
if (Date.now() - lastTime < interval) {
return { allowed: false, reason: '操作过于频繁' };
}
}
// 检查日限制
if (device && device.dailyCounts) {
const today = utils.getTodayString();
if (device.dailyCounts.date !== today) {

View File

@@ -93,7 +93,7 @@ class DeviceWorkStatusNotifier {
commandType: cmd.command_type || cmd.type || '',
commandParams: cmd.command_params || cmd.params || {}
};
}
}
// 如果有当前执行的任务,显示任务状态
else if (taskStatusSummary.currentTask) {
const task = taskStatusSummary.currentTask;
@@ -109,6 +109,11 @@ class DeviceWorkStatusNotifier {
};
}
// 如果有等待消息(投递间隔等待等),添加到工作状态
if (options.waitingMessage) {
workStatus.waitingMessage = options.waitingMessage;
}
// 格式化显示文案(服务端统一处理,客户端直接显示)
workStatus.displayText = this._formatDisplayText(workStatus);
@@ -141,24 +146,16 @@ class DeviceWorkStatusNotifier {
// 通过MQTT发布设备工作状态
// 主题格式: device_work_status_{sn_code}
const topic = `device_work_status_${sn_code}`;
const message = JSON.stringify({
const messagePayload = {
action: 'device_work_status',
data: workStatus,
timestamp: new Date().toISOString()
});
};
const message = JSON.stringify(messagePayload);
await mqttClient.publish(topic, message);
// 输出详细日志
if (workStatus.currentActivity) {
const activity = workStatus.currentActivity;
const activityInfo = activity.type === 'command'
? `指令[${activity.name}]`
: `任务[${activity.name}]`;
console.log(`[设备工作状态] 已推送到 ${sn_code}: ${activityInfo} - ${workStatus.displayText}`);
} else {
console.log(`[设备工作状态] 已推送到 ${sn_code}: ${workStatus.displayText}`);
}
} catch (error) {
// 通知失败不影响任务执行,只记录日志
console.warn(`[设备工作状态] 推送失败:`, error.message);
@@ -235,37 +232,52 @@ class DeviceWorkStatusNotifier {
*/
_formatDisplayText(workStatus) {
const parts = [];
// 当前活动
if (workStatus.currentActivity) {
const activity = workStatus.currentActivity;
const typeText = activity.type === 'command' ? '指令' : '任务';
const statusText = activity.status === 'running' ? '执行中' :
activity.status === 'completed' ? '已完成' :
const statusText = activity.status === 'running' ? '执行中' :
activity.status === 'completed' ? '已完成' :
activity.status === 'failed' ? '失败' : '未知';
// 构建详细描述:包含指令/任务名称和描述
let activityDesc = activity.description || activity.name;
// 对于指令,显示指令类型和名称的详细信息
if (activity.type === 'command') {
const cmdType = activity.commandType || '';
if (cmdType && cmdType !== activity.name) {
activityDesc = `${activityDesc} [${cmdType}]`;
// 使用 description 或 name避免重复显示
// 如果 description 存在且不等于 name优先使用 description
// 否则,使用 name 并可能添加 commandType
let activityDesc = '';
if (activity.description && activity.description !== activity.name) {
// 有独立的 description直接使用不再添加 commandType
activityDesc = activity.description;
} else {
// 使用 name
activityDesc = activity.name;
// 对于指令,如果 commandType 存在且与 name 不同,添加详细信息
if (activity.type === 'command') {
const cmdType = activity.commandType || '';
if (cmdType && cmdType !== activity.name && !activity.name.includes(cmdType)) {
activityDesc = `${activityDesc} [${cmdType}]`;
}
}
}
parts.push(`${typeText}: ${activityDesc} (${statusText}${activity.progress > 0 ? `, 进度: ${activity.progress}%` : ''})`);
} else {
parts.push('当前活动: 无');
}
// 如果有等待消息,添加到显示文本
if (workStatus.waitingMessage) {
parts.push(workStatus.waitingMessage.message);
}
// 待执行数量
parts.push(`待执行: ${workStatus.pendingQueue.totalCount}`);
// 下次执行时间
parts.push(`下次执行: ${workStatus.pendingQueue.nextExecuteTimeText || '暂无'}`);
return parts.join(' | ');
}
}

View File

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

View File

@@ -4,6 +4,8 @@ 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");
/**
* 检查当前时间是否在指定的时间范围内
@@ -26,8 +28,8 @@ function checkTimeRange(timeRange) {
const startTime = startHour * 60 + startMinute;
const endTime = endHour * 60 + endMinute;
// 检查是否仅工作日
if (timeRange.workdays_only === 1) {
// 检查是否仅工作日(使用宽松比较,兼容字符串和数字)
if (timeRange.workdays_only == 1) { // 使用 == 而不是 ===
const dayOfWeek = now.getDay(); // 0=周日, 1=周一, ..., 6=周六
if (dayOfWeek === 0 || dayOfWeek === 6) {
return { allowed: false, reason: '当前是周末,不在允许的时间范围内' };
@@ -111,7 +113,7 @@ class ScheduledJobs {
// 执行自动投递任务
const autoDeliverJob = node_schedule.scheduleJob(config.schedules.autoDeliver, () => {
const autoDeliverJob = node_schedule.scheduleJob(config.schedules.autoDeliver, () => {
this.autoDeliverTask();
});
@@ -197,7 +199,7 @@ class ScheduledJobs {
for (const account of accounts) {
const sn_code = account.sn_code;
const device = deviceManager.devices.get(sn_code);
if (!device) {
// 设备从未发送过心跳,视为离线
offlineSnCodes.push(sn_code);
@@ -250,7 +252,7 @@ class ScheduledJobs {
{
status: 'cancelled',
endTime: new Date(),
result: JSON.stringify({
result: JSON.stringify({
reason: '设备离线超过10分钟任务已自动取消',
offlineTime: deviceInfo?.lastHeartbeatTime
})
@@ -292,7 +294,7 @@ class ScheduledJobs {
async syncTaskStatusSummary() {
try {
const { pla_account } = await Framework.getModels();
// 获取所有启用的账号
const accounts = await pla_account.findAll({
where: {
@@ -313,19 +315,19 @@ class ScheduledJobs {
// 为每个在线设备发送任务状态摘要
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;
@@ -355,7 +357,7 @@ class ScheduledJobs {
try {
const Sequelize = require('sequelize');
const { task_status, op } = db.models;
// 查询所有运行中的任务
const runningTasks = await task_status.findAll({
where: {
@@ -374,7 +376,7 @@ class ScheduledJobs {
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;
}
@@ -424,9 +426,9 @@ class ScheduledJobs {
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 => {
@@ -456,17 +458,13 @@ class ScheduledJobs {
const now = new Date();
console.log(`[自动投递] ${now.toLocaleString()} 开始执行自动投递任务`);
// 检查是否在工作时间
if (!config.isWorkingHours()) {
console.log(`[自动投递] 非工作时间,跳过执行`);
return;
}
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({
@@ -477,7 +475,7 @@ class ScheduledJobs {
}
});
if (!pla_users || pla_users.length === 0) {
console.log('[自动投递] 没有启用且开启自动投递的账号');
@@ -497,17 +495,29 @@ class ScheduledJobs {
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}分钟前),跳过添加任务`);
@@ -569,10 +579,38 @@ class ScheduledJobs {
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));
console.log(`[自动投递] 设备 ${userData.sn_code} 距离上次投递仅 ${Math.round(elapsedTime / (60 * 1000))} 分钟,还需等待 ${remainingMinutes} 分钟(间隔: ${deliver_interval} 分钟)`);
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;
}
}
@@ -613,17 +651,13 @@ class ScheduledJobs {
const now = new Date();
console.log(`[自动沟通] ${now.toLocaleString()} 开始执行自动沟通任务`);
// 检查是否在工作时间
if (!config.isWorkingHours()) {
console.log(`[自动沟通] 非工作时间,跳过执行`);
return;
}
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({
@@ -652,17 +686,17 @@ class ScheduledJobs {
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}分钟前),跳过添加任务`);
@@ -712,7 +746,7 @@ class ScheduledJobs {
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} 分钟)`);

View File

@@ -523,8 +523,8 @@ class TaskHandlers {
const startTime = startHour * 60 + startMinute;
const endTime = endHour * 60 + endMinute;
// 检查是否仅工作日
if (timeRange.workdays_only === 1) {
// 检查是否仅工作日(使用宽松比较,兼容字符串和数字)
if (timeRange.workdays_only == 1) { // 使用 == 而不是 ===
const dayOfWeek = now.getDay(); // 0=周日, 1=周一, ..., 6=周六
if (dayOfWeek === 0 || dayOfWeek === 6) {
return { allowed: false, reason: '当前是周末,不在允许的时间范围内' };

View File

@@ -377,6 +377,70 @@ class TaskQueue {
}
}
/**
* 检查账号授权状态(剩余天数)
* @param {string} sn_code - 设备SN码
* @returns {Promise<{authorized: boolean, remaining_days: number, message: string}>}
*/
async checkAccountAuthorization(sn_code) {
try {
const pla_account = db.getModel('pla_account');
const account = await pla_account.findOne({
where: {
sn_code: sn_code,
is_delete: 0
},
attributes: ['authorization_date', 'authorization_days']
});
if (!account) {
return {
authorized: false,
remaining_days: 0,
message: '账号不存在'
};
}
const accountData = account.toJSON();
const authDate = accountData.authorization_date;
const authDays = accountData.authorization_days || 0;
// 使用工具函数计算剩余天数
const { calculateRemainingDays } = require('../../utils/account_utils');
const remaining_days = calculateRemainingDays(authDate, authDays);
// 如果没有授权信息或剩余天数 <= 0不允许创建任务
if (!authDate || authDays <= 0 || remaining_days <= 0) {
if (!authDate || authDays <= 0) {
return {
authorized: false,
remaining_days: 0,
message: '账号未授权,请购买使用权限后使用'
};
} else {
return {
authorized: false,
remaining_days: 0,
message: '账号使用权限已到期,请充值续费后使用'
};
}
}
return {
authorized: true,
remaining_days: remaining_days,
message: `授权有效,剩余 ${remaining_days}`
};
} catch (error) {
console.error(`[任务队列] 检查账号授权状态失败:`, error);
return {
authorized: false,
remaining_days: 0,
message: '检查授权状态失败'
};
}
}
/**
* 添加任务到队列
* @param {string} sn_code - 设备SN码
@@ -390,6 +454,12 @@ class TaskQueue {
throw new Error(`账号未启用,无法添加任务`);
}
// 检查账号授权状态(剩余天数)
const authResult = await this.checkAccountAuthorization(sn_code);
if (!authResult.authorized) {
throw new Error(authResult.message);
}
// 检查是否已有相同类型的任务在队列中或正在执行
const existingTask = await this.findExistingTask(sn_code, taskConfig.taskType);
if (existingTask) {

View File

@@ -157,6 +157,31 @@ module.exports = (db) => {
allowNull: true,
defaultValue: ''
},
// 文本匹配分析结果
textMatchScore: {
comment: '文本匹配综合评分',
type: Sequelize.INTEGER,
allowNull: true,
defaultValue: 0
},
matchSuggestion: {
comment: '投递建议',
type: Sequelize.STRING(200),
allowNull: true,
defaultValue: ''
},
matchConcerns: {
comment: '关注点(JSON数组)',
type: Sequelize.TEXT,
allowNull: true,
defaultValue: '[]'
},
textMatchAnalysis: {
comment: '文本匹配详细分析结果(JSON)',
type: Sequelize.TEXT,
allowNull: true,
defaultValue: ''
},
outsourcingAnalysis: {
comment: '外包识别分析结果',
type: Sequelize.TEXT,
@@ -239,5 +264,5 @@ module.exports = (db) => {
return job_postings
// job_postings.sync({ force: true
};

View File

@@ -35,7 +35,7 @@ module.exports = (db) => {
},
pwd: {
comment: '密码',
type: Sequelize.STRING(50),
type: Sequelize.STRING(200),
allowNull: false,
defaultValue: ''
},
@@ -52,6 +52,18 @@ module.exports = (db) => {
allowNull: false,
defaultValue: 1
},
is_online: {
comment: '设备在线状态1=在线0=离线)',
type: Sequelize.TINYINT(1),
allowNull: false,
defaultValue: 0
},
is_logged_in: {
comment: '平台登录状态1=已登录0=未登录)',
type: Sequelize.TINYINT(1),
allowNull: false,
defaultValue: 0
},
job_type_id: {
comment: '职位类型ID关联 job_types 表)',
type: Sequelize.INTEGER,
@@ -282,26 +294,6 @@ module.exports = (db) => {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0
},
remaining_days: {
comment: '剩余天数(虚拟字段,通过计算得出)',
type: Sequelize.VIRTUAL,
get: function () {
const authDate = this.getDataValue('authorization_date');
const authDays = this.getDataValue('authorization_days') || 0;
if (!authDate || authDays <= 0) {
return 0;
}
const startDate = dayjs(authDate);
const endDate = startDate.add(authDays, 'day');
const now = dayjs();
// 计算剩余天数
const remaining = endDate.diff(now, 'day', true);
return Math.max(0, Math.ceil(remaining));
}
}
});

View File

@@ -38,7 +38,7 @@ class AuthorizationService {
return {
is_authorized: false,
remaining_days: 0,
message: '账号未授权,请联系管理员'
message: '账号未授权,请购买使用权限后使用'
};
}
@@ -53,7 +53,7 @@ class AuthorizationService {
return {
is_authorized: false,
remaining_days: 0,
message: '账号授权已过期,请联系管理员续费'
message: '账号使用权限已到期,请充值续费后使用'
};
}

View File

@@ -0,0 +1,347 @@
/**
* 邮件服务
* 使用QQ邮箱SMTP服务发送邮件
*/
const nodemailer = require('nodemailer');
const config = require('../../config/config.js');
const redis = require('../middleware/redis_proxy');
// 创建邮件传输器
let transporter = null;
/**
* 初始化邮件传输器
*/
function init_transporter() {
if (transporter) {
return transporter;
}
// QQ邮箱SMTP配置
const email_config = config.email || {
host: 'smtp.qq.com',
port: 465,
secure: true, // 使用SSL
auth: {
user: process.env.QQ_EMAIL_USER || '', // QQ邮箱账号
pass: process.env.QQ_EMAIL_PASS || '' // QQ邮箱授权码不是密码
}
};
transporter = nodemailer.createTransport({
host: email_config.host,
port: email_config.port,
secure: email_config.secure,
auth: email_config.auth
});
return transporter;
}
/**
* 发送邮件
* @param {Object} options 邮件选项
* @param {string} options.to 收件人邮箱
* @param {string} options.subject 邮件主题
* @param {string} options.html 邮件HTML内容
* @param {string} options.text 邮件纯文本内容(可选)
* @returns {Promise<{success: boolean, message?: string, messageId?: string}>}
*/
async function send_email(options) {
try {
const transporter_instance = init_transporter();
if (!transporter_instance) {
return {
success: false,
message: '邮件服务未配置'
};
}
// 检查邮箱配置
const email_config = config.email || {};
if (!email_config.auth || !email_config.auth.user || !email_config.auth.pass) {
console.warn('[邮件服务] QQ邮箱未配置使用模拟发送');
// 开发环境可以模拟发送
if (config.env === 'development') {
console.log(`[模拟邮件] 发送到 ${options.to}`);
console.log(`[模拟邮件] 主题: ${options.subject}`);
console.log(`[模拟邮件] 内容: ${options.text || options.html}`);
return {
success: true,
message: '邮件已发送(模拟)',
messageId: 'mock-' + Date.now()
};
}
return {
success: false,
message: '邮件服务未配置,请联系管理员'
};
}
// 发送邮件
const mail_options = {
from: `"${email_config.fromName || '自动找工作系统'}" <${email_config.auth.user}>`,
to: options.to,
subject: options.subject,
html: options.html,
text: options.text || options.html.replace(/<[^>]*>/g, '') // 如果没有text从html提取
};
const info = await transporter_instance.sendMail(mail_options);
console.log(`[邮件服务] 邮件发送成功: ${options.to}, MessageId: ${info.messageId}`);
return {
success: true,
message: '邮件发送成功',
messageId: info.messageId
};
} catch (error) {
console.error('[邮件服务] 发送邮件失败:', error);
return {
success: false,
message: error.message || '发送邮件失败'
};
}
}
/**
* 发送验证码邮件
* @param {string} email 收件人邮箱
* @param {string} code 验证码
* @returns {Promise<{success: boolean, message?: string, messageId?: string}>}
*/
async function send_verification_code(email, code) {
const subject = '【自动找工作系统】注册验证码';
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
line-height: 1.6;
color: #333;
}
.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
background: #f5f5f5;
}
.content {
background: #ffffff;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.code-box {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
border-radius: 8px;
text-align: center;
margin: 20px 0;
font-size: 32px;
font-weight: bold;
letter-spacing: 8px;
}
.tip {
color: #666;
font-size: 14px;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #eee;
}
</style>
</head>
<body>
<div class="container">
<div class="content">
<h2>验证码</h2>
<p>您好,</p>
<p>您正在注册自动找工作系统,验证码为:</p>
<div class="code-box">${code}</div>
<p>验证码有效期为 <strong>5分钟</strong>,请勿泄露给他人。</p>
<div class="tip">
<p>如果这不是您的操作,请忽略此邮件。</p>
<p>此邮件由系统自动发送,请勿回复。</p>
</div>
</div>
</div>
</body>
</html>
`;
return await send_email({
to: email,
subject: subject,
html: html
});
}
/**
* 发送验证码(包含生成、存储和发送)
* @param {string} email 邮箱地址
* @returns {Promise<{success: boolean, message?: string, expire_time?: number}>}
*/
async function sendEmailCode(email) {
try {
// 统一邮箱地址为小写,避免大小写不一致导致的问题
const email_lower = email.toLowerCase().trim();
// 生成6位随机验证码
const code = Math.floor(100000 + Math.random() * 900000).toString();
// Redis key包含邮箱地址确保每个用户独立的验证码
const redis_key = `email_code:${email_lower}`;
// 验证码数据
const code_data = {
code: code,
created_at: Date.now()
};
// 存储到 Redis设置 5 分钟过期时间300秒
// 先获取 Redis 服务实例,确保在整个函数中使用同一个连接
const redis_service = redis;
try {
await redis_service.set(redis_key, JSON.stringify(code_data), 300);
} catch (redis_error) {
console.error(`[邮箱验证] Redis 存储失败: ${email_lower}`, redis_error);
return {
success: false,
message: '验证码存储失败,请稍后重试'
};
}
console.log(`[邮箱验证] 生成验证码: ${email_lower} -> ${code}, 已存储到 Redis (5分钟过期)`);
// 调用邮件服务发送验证码
const email_result = await send_verification_code(email_lower, code);
if (!email_result.success) {
// 如果邮件发送失败,删除已生成的验证码
try {
await redis_service.del(redis_key);
} catch (del_error) {
console.error(`[邮箱验证] 删除验证码失败:`, del_error);
}
console.error(`[邮箱验证] 邮件发送失败,已删除验证码: ${email_lower}`);
return {
success: false,
message: email_result.message || '发送验证码失败'
};
}
console.log(`[邮箱验证] 验证码已发送到 ${email_lower}: ${code} (5分钟内有效)`);
return {
success: true,
expire_time: 300
};
} catch (error) {
console.error('发送验证码失败:', error);
return {
success: false,
message: error.message || '发送验证码失败'
};
}
}
/**
* 验证验证码
* @param {string} email 邮箱地址
* @param {string} code 验证码
* @returns {Promise<{success: boolean, message?: string}>}
*/
async function verifyEmailCode(email, code) {
try {
// 统一邮箱地址为小写,避免大小写不一致导致的问题
const email_lower = email.toLowerCase().trim();
console.log(`[邮箱验证] 开始验证: ${email_lower}, 验证码: ${code}`);
// Redis key包含邮箱地址确保每个用户独立的验证码
const redis_key = `email_code:${email_lower}`;
// 从 Redis 获取验证码
// 先获取 Redis 服务实例,确保在整个函数中使用同一个连接
const redis_service = redis;
let cached_str;
try {
cached_str = await redis_service.get(redis_key);
} catch (redis_error) {
console.error(`[邮箱验证] Redis 获取失败:`, redis_error);
return {
success: false,
message: '验证码获取失败,请稍后重试'
};
}
if (!cached_str) {
console.log(`[邮箱验证] 未找到该邮箱的验证码: ${email_lower}`);
return {
success: false,
message: '验证码不存在或已过期,请重新获取'
};
}
// 解析验证码数据
let cached;
try {
cached = JSON.parse(cached_str);
} catch (parse_error) {
console.error(`[邮箱验证] 解析验证码数据失败:`, parse_error);
try {
await redis_service.del(redis_key);
} catch (del_error) {
console.error(`[邮箱验证] 删除异常数据失败:`, del_error);
}
return {
success: false,
message: '验证码数据异常,请重新获取'
};
}
console.log(`[邮箱验证] 找到验证码,创建时间: ${new Date(cached.created_at).toLocaleString()}`);
// 验证码是否正确
if (cached.code !== code) {
console.log(`[邮箱验证] 验证码错误,期望: ${cached.code}, 实际: ${code}`);
return {
success: false,
message: '验证码错误'
};
}
// 验证成功后删除缓存
try {
await redis_service.del(redis_key);
} catch (del_error) {
console.error(`[邮箱验证] 删除验证码失败:`, del_error);
}
console.log(`[邮箱验证] 验证成功: ${email_lower}`);
return {
success: true
};
} catch (error) {
console.error('验证验证码失败:', error);
return {
success: false,
message: error.message || '验证失败'
};
}
}
module.exports = {
send_email,
send_verification_code,
sendEmailCode,
verifyEmailCode,
init_transporter
};

View File

@@ -7,6 +7,7 @@ const db = require('../middleware/dbProxy');
const scheduleManager = require('../middleware/schedule/index.js');
const locationService = require('./locationService');
const authorizationService = require('./authorization_service');
const { addRemainingDays, addRemainingDaysToAccounts } = require('../utils/account_utils');
class PlaAccountService {
/**
@@ -24,11 +25,17 @@ class PlaAccountService {
const accountData = account.get({ plain: true });
// 移除 device_status 依赖,在线状态和登录状态设为默认值
accountData.is_online = false;
accountData.is_logged_in = false;
// is_online 和 is_logged_in 字段已存在于数据库中,直接返回
// 如果字段不存在,设置默认值
if (accountData.is_online === undefined) {
accountData.is_online = false;
}
if (accountData.is_logged_in === undefined) {
accountData.is_logged_in = false;
}
return accountData;
// 添加 remaining_days 字段
return addRemainingDays(accountData);
}
/**
@@ -57,11 +64,17 @@ class PlaAccountService {
const accountData = account.get({ plain: true });
// 移除 device_status 依赖,在线状态和登录状态设为默认值
accountData.is_online = false;
accountData.is_logged_in = false;
// is_online 和 is_logged_in 字段已存在于数据库中,直接返回
// 如果字段不存在,设置默认值
if (accountData.is_online === undefined) {
accountData.is_online = false;
}
if (accountData.is_logged_in === undefined) {
accountData.is_logged_in = false;
}
return accountData;
// 添加 remaining_days 字段
return addRemainingDays(accountData);
}
/**
@@ -102,16 +115,25 @@ class PlaAccountService {
order: [['id', 'DESC']]
});
// 处理返回数据is_online 设为默认值 false
// 处理返回数据is_online 和 is_logged_in 从数据库读取
const rows = result.rows.map(account => {
const accountData = account.get({ plain: true });
accountData.is_online = false;
// 如果字段不存在,设置默认值
if (accountData.is_online === undefined) {
accountData.is_online = false;
}
if (accountData.is_logged_in === undefined) {
accountData.is_logged_in = false;
}
return accountData;
});
// 为所有账号添加 remaining_days 字段
const rowsWithRemainingDays = addRemainingDaysToAccounts(rows);
return {
count: result.count,
rows
rows: rowsWithRemainingDays
};
}
@@ -123,7 +145,7 @@ class PlaAccountService {
async createAccount(data) {
const pla_account = db.getModel('pla_account');
const { name, sn_code, platform_type, login_name, pwd, keyword, ...otherData } = data;
const { name, sn_code, platform_type, login_name, pwd, keyword, remaining_days, ...otherData } = data;
if (!name || !sn_code || !platform_type || !login_name) {
throw new Error('账户名、设备SN码、平台和登录名为必填项');
@@ -141,6 +163,9 @@ class PlaAccountService {
...otherData
};
// 过滤掉虚拟字段 remaining_days它是计算字段不应该保存到数据库
delete processedData.remaining_days;
booleanFields.forEach(field => {
if (processedData[field] !== undefined && processedData[field] !== null) {
processedData[field] = processedData[field] ? 1 : 0;
@@ -173,15 +198,69 @@ class PlaAccountService {
// 将布尔字段从 true/false 转换为 0/1确保数据库兼容性
const booleanFields = ['auto_deliver', 'auto_chat', 'auto_reply', 'auto_active'];
const processedData = { ...updateData };
// 过滤掉虚拟字段 remaining_days它是计算字段不应该保存到数据库
delete processedData.remaining_days;
// 过滤掉密码字段(密码修改需要使用单独的接口,这里不允许修改密码)
delete processedData.pwd;
delete processedData.password;
booleanFields.forEach(field => {
if (processedData[field] !== undefined && processedData[field] !== null) {
processedData[field] = processedData[field] ? 1 : 0;
}
});
// 深度合并 JSON 配置字段deliver_config、chat_strategy、active_actions
const jsonConfigFields = ['deliver_config', 'chat_strategy', 'active_actions'];
jsonConfigFields.forEach(field => {
if (processedData[field] !== undefined && processedData[field] !== null) {
// 获取原有配置
const originalConfig = account[field] || {};
const newConfig = processedData[field];
// 深度合并配置(只覆盖传入的字段,保留原有的其他字段)
if (typeof newConfig === 'object' && !Array.isArray(newConfig)) {
// 对于对象类型,深度合并
processedData[field] = this.deepMerge(originalConfig, newConfig);
} else {
// 对于非对象类型(如 null、数组等直接使用新值
processedData[field] = newConfig;
}
}
});
await pla_account.update(processedData, { where: { id } });
}
/**
* 深度合并对象(只合并一层,用于 JSON 配置字段)
* @param {Object} target - 目标对象(原有配置)
* @param {Object} source - 源对象(新配置)
* @returns {Object} 合并后的对象
*/
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)) {
result[key] = this.deepMerge(targetValue, sourceValue);
} else {
// 否则直接覆盖(包括 null、undefined、数组等
result[key] = sourceValue;
}
});
return result;
}
/**
* 删除账号(软删除)
* @param {number} id - 账号ID
@@ -227,18 +306,26 @@ class PlaAccountService {
}
// 通过 sn_code 查询任务列表
// 使用 attributes 只查询需要的字段,提升性能
const result = await task_status.findAndCountAll({
where: {
sn_code: account.sn_code
},
limit,
offset,
order: [['id', 'DESC']]
order: [['id', 'DESC']],
// 不查询大字段以提升性能
attributes: {
exclude: ['taskParams', 'result', 'errorMessage', 'errorStack']
}
});
// 将 Sequelize 模型实例转换为普通对象
const rows = result.rows.map(row => row.get({ plain: true }));
return {
count: result.count,
rows: result.rows
rows: rows
};
}
@@ -250,6 +337,7 @@ class PlaAccountService {
async getAccountCommands(params) {
const pla_account = db.getModel('pla_account');
const task_commands = db.getModel('task_commands');
const task_status = db.getModel('task_status');
const Sequelize = require('sequelize');
const { id, limit, offset } = params;
@@ -264,24 +352,27 @@ class PlaAccountService {
throw new Error('账号不存在');
}
// 获取 sequelize 实例
// 使用子查询优化性能避免先查询所有任务ID
const sequelize = task_commands.sequelize;
// 使用原生 SQL JOIN 查询
// 使用子查询一次性完成查询和计数
const countSql = `
SELECT COUNT(DISTINCT tc.id) as count
FROM task_commands tc
INNER JOIN task_status ts ON tc.task_id = ts.id
WHERE ts.sn_code = :sn_code
SELECT COUNT(*) as count
FROM task_commands tc
WHERE tc.task_id IN (
SELECT id FROM task_status WHERE sn_code = :sn_code
)
`;
const dataSql = `
SELECT tc.*
FROM task_commands tc
INNER JOIN task_status ts ON tc.task_id = ts.id
WHERE ts.sn_code = :sn_code
ORDER BY tc.id DESC
LIMIT :limit OFFSET :offset
SELECT tc.*,
tc.create_time
FROM task_commands tc
WHERE tc.task_id IN (
SELECT id FROM task_status WHERE sn_code = :sn_code
)
ORDER BY tc.id DESC
LIMIT :limit OFFSET :offset
`;
// 并行执行查询和计数
@@ -302,9 +393,27 @@ class PlaAccountService {
const count = countResult[0]?.count || 0;
// 将原始数据转换为 Sequelize 模型实例
// 将原始数据转换为普通对象
const rows = dataResult.map(row => {
return task_commands.build(row, { isNewRecord: false });
const plainRow = { ...row };
// 解析 JSON 字段
if (plainRow.command_params && typeof plainRow.command_params === 'string') {
try {
plainRow.command_params = JSON.parse(plainRow.command_params);
} catch (e) {
// 解析失败保持原样
}
}
if (plainRow.result && typeof plainRow.result === 'string') {
try {
plainRow.result = JSON.parse(plainRow.result);
} catch (e) {
// 解析失败保持原样
}
}
return plainRow;
});
return {
@@ -435,6 +544,100 @@ class PlaAccountService {
return commandDetail;
}
/**
* 重试指令
* @param {Object} params - 重试参数
* @param {number} params.commandId - 指令ID
* @returns {Promise<Object>} 重试结果
*/
async retryCommand(params) {
const task_commands = db.getModel('task_commands');
const task_status = db.getModel('task_status');
const scheduleManager = require('../middleware/schedule/index.js');
const { commandId } = params;
if (!commandId) {
throw new Error('指令ID不能为空');
}
// 查询指令信息
const command = await task_commands.findByPk(commandId);
if (!command) {
throw new Error('指令不存在');
}
// 检查指令状态
if (command.status !== 'failed') {
throw new Error('只能重试失败的指令');
}
// 获取任务信息
const task = await task_status.findByPk(command.task_id);
if (!task) {
throw new Error('任务不存在');
}
// 获取账号信息
const pla_account = db.getModel('pla_account');
const account = await pla_account.findOne({ where: { sn_code: task.sn_code } });
if (!account) {
throw new Error('账号不存在');
}
// 检查授权状态
const authCheck = await authorizationService.checkAuthorization(account.id, 'id');
if (!authCheck.is_authorized) {
throw new Error(authCheck.message);
}
// 检查 MQTT 客户端
if (!scheduleManager.mqttClient) {
throw new Error('MQTT客户端未初始化');
}
// 重置指令状态
await command.update({
status: 'pending',
error_message: null,
error_stack: null,
retry_count: (command.retry_count || 0) + 1,
start_time: null,
end_time: null,
duration: null,
result: null
});
// 解析指令参数
let commandParams = {};
if (command.command_params) {
try {
commandParams = typeof command.command_params === 'string'
? JSON.parse(command.command_params)
: command.command_params;
} catch (error) {
console.warn('解析指令参数失败:', error);
}
}
// 构建指令对象
const commandObj = {
command_type: command.command_type,
command_name: command.command_name,
command_params: JSON.stringify(commandParams)
};
// 执行指令
const result = await scheduleManager.command.executeCommand(task.id, commandObj, scheduleManager.mqttClient);
return {
success: true,
message: '指令重试成功',
commandId: command.id,
result: result
};
}
/**
* 执行账号任务(旧接口兼容)
* @param {Object} params - 任务参数

View File

@@ -0,0 +1,249 @@
/**
* 账号工具函数测试
*/
const {
calculateRemainingDays,
isAuthorizationValid,
addRemainingDays,
addRemainingDaysToAccounts
} = require('../utils/account_utils');
// 测试剩余天数计算
function testCalculateRemainingDays() {
console.log('\n===== 测试剩余天数计算 =====');
try {
const dayjs = require('dayjs');
// 测试 1: 未来有效期
const futureDate = dayjs().subtract(5, 'day').toDate();
const remaining1 = calculateRemainingDays(futureDate, 30);
console.log('✓ 未来有效期 (5天前授权30天):', remaining1, '天');
console.assert(remaining1 === 25, `期望25天实际${remaining1}`);
// 测试 2: 已过期
const pastDate = dayjs().subtract(40, 'day').toDate();
const remaining2 = calculateRemainingDays(pastDate, 30);
console.log('✓ 已过期 (40天前授权30天):', remaining2, '天');
console.assert(remaining2 === 0, `期望0天实际${remaining2}`);
// 测试 3: 今天到期
const todayDate = dayjs().startOf('day').toDate();
const remaining3 = calculateRemainingDays(todayDate, 0);
console.log('✓ 今天到期:', remaining3, '天');
// 测试 4: 空值处理
const remaining4 = calculateRemainingDays(null, 30);
console.log('✓ 空授权日期:', remaining4, '天');
console.assert(remaining4 === 0, '空值应返回0');
const remaining5 = calculateRemainingDays(futureDate, 0);
console.log('✓ 0天授权:', remaining5, '天');
console.assert(remaining5 === 0, '0天授权应返回0');
return true;
} catch (error) {
console.error('✗ 剩余天数计算测试失败:', error.message);
return false;
}
}
// 测试授权有效性检查
function testIsAuthorizationValid() {
console.log('\n===== 测试授权有效性检查 =====');
try {
const dayjs = require('dayjs');
// 测试 1: 有效授权
const validDate = dayjs().subtract(5, 'day').toDate();
const isValid = isAuthorizationValid(validDate, 30);
console.log('✓ 有效授权 (5天前授权30天):', isValid ? '有效' : '无效');
console.assert(isValid === true, '应该是有效的');
// 测试 2: 过期授权
const expiredDate = dayjs().subtract(40, 'day').toDate();
const isExpired = isAuthorizationValid(expiredDate, 30);
console.log('✓ 过期授权 (40天前授权30天):', isExpired ? '有效' : '无效');
console.assert(isExpired === false, '应该是无效的');
// 测试 3: 空值处理
const isNull = isAuthorizationValid(null, 30);
console.log('✓ 空授权日期:', isNull ? '有效' : '无效');
console.assert(isNull === false, '空值应该无效');
return true;
} catch (error) {
console.error('✗ 授权有效性检查测试失败:', error.message);
return false;
}
}
// 测试添加剩余天数
function testAddRemainingDays() {
console.log('\n===== 测试添加剩余天数 =====');
try {
const dayjs = require('dayjs');
// 测试 1: 普通对象
const account1 = {
id: 1,
sn_code: 'SN001',
authorization_date: dayjs().subtract(5, 'day').toDate(),
authorization_days: 30
};
const result1 = addRemainingDays(account1);
console.log('✓ 普通对象添加剩余天数:', result1.remaining_days, '天');
console.assert(result1.remaining_days === 25, `期望25天实际${result1.remaining_days}`);
// 测试 2: Sequelize实例模拟
const account2 = {
id: 2,
sn_code: 'SN002',
authorization_date: dayjs().subtract(10, 'day').toDate(),
authorization_days: 15,
toJSON: function() {
return {
id: this.id,
sn_code: this.sn_code,
authorization_date: this.authorization_date,
authorization_days: this.authorization_days
};
}
};
const result2 = addRemainingDays(account2);
console.log('✓ Sequelize实例添加剩余天数:', result2.remaining_days, '天');
console.assert(result2.remaining_days === 5, `期望5天实际${result2.remaining_days}`);
return true;
} catch (error) {
console.error('✗ 添加剩余天数测试失败:', error.message);
return false;
}
}
// 测试批量添加剩余天数
function testAddRemainingDaysToAccounts() {
console.log('\n===== 测试批量添加剩余天数 =====');
try {
const dayjs = require('dayjs');
const accounts = [
{
id: 1,
authorization_date: dayjs().subtract(5, 'day').toDate(),
authorization_days: 30
},
{
id: 2,
authorization_date: dayjs().subtract(10, 'day').toDate(),
authorization_days: 15
},
{
id: 3,
authorization_date: dayjs().subtract(50, 'day').toDate(),
authorization_days: 30
}
];
const results = addRemainingDaysToAccounts(accounts);
console.log('✓ 批量添加剩余天数:');
results.forEach((acc, index) => {
console.log(` 账号${index + 1}: ${acc.remaining_days}`);
});
console.assert(results.length === 3, '数组长度应该是3');
console.assert(results[0].remaining_days === 25, '第1个账号剩余天数错误');
console.assert(results[1].remaining_days === 5, '第2个账号剩余天数错误');
console.assert(results[2].remaining_days === 0, '第3个账号剩余天数错误');
// 测试空数组
const emptyResults = addRemainingDaysToAccounts([]);
console.log('✓ 空数组处理:', emptyResults.length === 0 ? '正确' : '错误');
return true;
} catch (error) {
console.error('✗ 批量添加剩余天数测试失败:', error.message);
return false;
}
}
// 测试时区处理
function testTimezoneHandling() {
console.log('\n===== 测试时区处理 =====');
try {
const dayjs = require('dayjs');
const utc = require('dayjs/plugin/utc');
dayjs.extend(utc);
// 创建不同时区的日期
const localDate = dayjs().subtract(5, 'day').toDate();
const utcDate = dayjs().utc().subtract(5, 'day').toDate();
const remaining1 = calculateRemainingDays(localDate, 30);
const remaining2 = calculateRemainingDays(utcDate, 30);
console.log('✓ 本地时区日期剩余天数:', remaining1, '天');
console.log('✓ UTC时区日期剩余天数:', remaining2, '天');
// 剩余天数应该接近可能相差1天因为时区转换
const diff = Math.abs(remaining1 - remaining2);
console.log('✓ 时区差异:', diff, '天');
console.assert(diff <= 1, '时区差异应该不超过1天');
return true;
} catch (error) {
console.error('✗ 时区处理测试失败:', error.message);
return false;
}
}
// 运行所有测试
async function runAllTests() {
console.log('\n==================== 开始测试 ====================\n');
const results = [];
results.push(testCalculateRemainingDays());
results.push(testIsAuthorizationValid());
results.push(testAddRemainingDays());
results.push(testAddRemainingDaysToAccounts());
results.push(testTimezoneHandling());
console.log('\n==================== 测试总结 ====================\n');
const passed = results.filter(r => r).length;
const total = results.length;
console.log(`测试通过: ${passed}/${total}`);
if (passed === total) {
console.log('\n✓ 所有测试通过!\n');
process.exit(0);
} else {
console.log('\n✗ 部分测试失败\n');
process.exit(1);
}
}
// 执行测试
if (require.main === module) {
runAllTests().catch(error => {
console.error('测试执行失败:', error);
process.exit(1);
});
}
module.exports = {
testCalculateRemainingDays,
testIsAuthorizationValid,
testAddRemainingDays,
testAddRemainingDaysToAccounts,
testTimezoneHandling
};

View File

@@ -0,0 +1,207 @@
/**
* 加密工具函数测试
*/
const {
hashPassword,
verifyPassword,
generateToken,
generateDeviceId,
validateDeviceId,
maskPhone,
maskEmail,
maskSensitiveData
} = require('../utils/crypto_utils');
// 测试密码加密和验证
async function testPasswordEncryption() {
console.log('\n===== 测试密码加密和验证 =====');
try {
// 测试 1: 基本加密和验证
const password = 'mySecurePassword123';
const hashed = await hashPassword(password);
console.log('✓ 密码加密成功:', hashed.substring(0, 20) + '...');
// 验证正确密码
const isValid = await verifyPassword(password, hashed);
console.log('✓ 正确密码验证:', isValid ? '通过' : '失败');
// 验证错误密码
const isInvalid = await verifyPassword('wrongPassword', hashed);
console.log('✓ 错误密码验证:', isInvalid ? '失败(不应该通过)' : '正确拒绝');
// 测试 2: 相同密码生成不同哈希
const hashed2 = await hashPassword(password);
console.log('✓ 相同密码生成不同哈希:', hashed !== hashed2 ? '是' : '否');
// 测试 3: 空密码处理
try {
await hashPassword('');
console.log('✗ 空密码应该抛出错误');
} catch (error) {
console.log('✓ 空密码正确抛出错误');
}
return true;
} catch (error) {
console.error('✗ 密码加密测试失败:', error.message);
return false;
}
}
// 测试设备ID生成和验证
function testDeviceId() {
console.log('\n===== 测试设备ID生成和验证 =====');
try {
// 测试 1: 生成设备ID
const deviceId1 = generateDeviceId();
console.log('✓ 生成设备ID:', deviceId1);
// 测试 2: 验证有效设备ID
const isValid = validateDeviceId(deviceId1);
console.log('✓ 验证有效设备ID:', isValid ? '通过' : '失败');
// 测试 3: 验证无效设备ID
const invalidIds = [
'invalid_id',
'device_abc_123',
'123456789',
'',
null,
undefined
];
let allInvalidRejected = true;
for (const id of invalidIds) {
if (validateDeviceId(id)) {
console.log('✗ 无效ID未被拒绝:', id);
allInvalidRejected = false;
}
}
if (allInvalidRejected) {
console.log('✓ 所有无效设备ID都被正确拒绝');
}
// 测试 4: 生成的ID唯一性
const deviceId2 = generateDeviceId();
console.log('✓ 生成的ID是唯一的:', deviceId1 !== deviceId2 ? '是' : '否');
return true;
} catch (error) {
console.error('✗ 设备ID测试失败:', error.message);
return false;
}
}
// 测试数据脱敏
function testDataMasking() {
console.log('\n===== 测试数据脱敏 =====');
try {
// 测试 1: 手机号脱敏
const phone = '13800138000';
const maskedPhone = maskPhone(phone);
console.log('✓ 手机号脱敏:', phone, '->', maskedPhone);
console.assert(maskedPhone === '138****8000', '手机号脱敏格式错误');
// 测试 2: 邮箱脱敏
const email = 'user@example.com';
const maskedEmail = maskEmail(email);
console.log('✓ 邮箱脱敏:', email, '->', maskedEmail);
// 测试 3: 对象脱敏
const sensitiveObj = {
username: 'john',
password: 'secret123',
email: 'john@example.com',
token: 'abc123xyz',
normalField: 'public data'
};
const masked = maskSensitiveData(sensitiveObj);
console.log('✓ 对象脱敏:');
console.log(' 原始:', sensitiveObj);
console.log(' 脱敏:', masked);
// 验证敏感字段被屏蔽
console.assert(masked.password === '***MASKED***', 'password未被屏蔽');
console.assert(masked.token === '***MASKED***', 'token未被屏蔽');
console.assert(masked.normalField === 'public data', '普通字段被修改');
return true;
} catch (error) {
console.error('✗ 数据脱敏测试失败:', error.message);
return false;
}
}
// 测试Token生成
function testTokenGeneration() {
console.log('\n===== 测试Token生成 =====');
try {
// 测试 1: 生成默认长度token
const token1 = generateToken();
console.log('✓ 生成默认token (64字符):', token1.substring(0, 20) + '...');
console.assert(token1.length === 64, 'Token长度错误');
// 测试 2: 生成指定长度token
const token2 = generateToken(16);
console.log('✓ 生成16字节token (32字符):', token2);
console.assert(token2.length === 32, 'Token长度错误');
// 测试 3: Token唯一性
const token3 = generateToken();
console.log('✓ Token唯一性:', token1 !== token3 ? '是' : '否');
return true;
} catch (error) {
console.error('✗ Token生成测试失败:', error.message);
return false;
}
}
// 运行所有测试
async function runAllTests() {
console.log('\n==================== 开始测试 ====================\n');
const results = [];
results.push(await testPasswordEncryption());
results.push(testDeviceId());
results.push(testDataMasking());
results.push(testTokenGeneration());
console.log('\n==================== 测试总结 ====================\n');
const passed = results.filter(r => r).length;
const total = results.length;
console.log(`测试通过: ${passed}/${total}`);
if (passed === total) {
console.log('\n✓ 所有测试通过!\n');
process.exit(0);
} else {
console.log('\n✗ 部分测试失败\n');
process.exit(1);
}
}
// 执行测试
if (require.main === module) {
runAllTests().catch(error => {
console.error('测试执行失败:', error);
process.exit(1);
});
}
module.exports = {
testPasswordEncryption,
testDeviceId,
testDataMasking,
testTokenGeneration
};

View File

@@ -0,0 +1,144 @@
/**
* 邀请注册功能测试
* 测试新用户注册试用期和邀请人奖励逻辑
*/
const dayjs = require('dayjs');
// 运行所有测试
function runTests() {
console.log('\n========================================');
console.log('开始测试邀请注册功能');
console.log('========================================');
const tests = [
{ name: '新用户注册应该获得3天试用期', fn: () => {
console.log('\n【测试1】新用户注册应该获得3天试用期');
const newUserData = {
authorization_date: new Date(),
authorization_days: 3
};
if (!newUserData.authorization_date) throw new Error('授权日期为空');
if (newUserData.authorization_days !== 3) throw new Error('授权天数不是3天');
console.log('✅ 通过: 新用户获得3天试用期');
console.log(' - 授权日期:', dayjs(newUserData.authorization_date).format('YYYY-MM-DD'));
console.log(' - 授权天数:', newUserData.authorization_days, '天');
}},
{ name: '邀请人授权未过期时应该累加3天', fn: () => {
console.log('\n【测试2】邀请人授权未过期时应该累加3天');
const inviterData = {
authorization_date: dayjs().subtract(2, 'day').toDate(),
authorization_days: 7
};
const currentEndDate = dayjs(inviterData.authorization_date).add(inviterData.authorization_days, 'day');
const now = dayjs();
const remainingDays = currentEndDate.diff(now, 'day');
console.log(' 邀请人当前状态:');
console.log(' - 授权开始日期:', dayjs(inviterData.authorization_date).format('YYYY-MM-DD'));
console.log(' - 授权总天数:', inviterData.authorization_days, '天');
console.log(' - 剩余天数:', remainingDays, '天');
if (currentEndDate.isBefore(now)) throw new Error('测试数据错误:授权应该未过期');
const newAuthDays = inviterData.authorization_days + 3;
if (newAuthDays !== 10) throw new Error('累加计算错误');
console.log('✅ 通过: 未过期授权累加 7天 + 3天 = 10天');
}},
{ name: '邀请人授权已过期时应该重新激活给3天', fn: () => {
console.log('\n【测试3】邀请人授权已过期时应该重新激活给3天');
const inviterData = {
authorization_date: dayjs().subtract(10, 'day').toDate(),
authorization_days: 5
};
const currentEndDate = dayjs(inviterData.authorization_date).add(inviterData.authorization_days, 'day');
const now = dayjs();
const daysExpired = now.diff(currentEndDate, 'day');
console.log(' 邀请人当前状态:');
console.log(' - 授权开始日期:', dayjs(inviterData.authorization_date).format('YYYY-MM-DD'));
console.log(' - 授权天数:', inviterData.authorization_days, '天');
console.log(' - 过期日期:', currentEndDate.format('YYYY-MM-DD'));
console.log(' - 已过期天数:', daysExpired, '天');
if (!currentEndDate.isBefore(now)) throw new Error('测试数据错误:授权应该已过期');
const newAuthDate = new Date();
const newAuthDays = 3;
if (newAuthDays !== 3) throw new Error('重新激活计算错误');
console.log('✅ 通过: 过期授权重新激活给3天');
console.log(' - 新授权开始日期:', dayjs(newAuthDate).format('YYYY-MM-DD'));
}},
{ name: '邀请人没有授权日期时,应该从今天开始累加', fn: () => {
console.log('\n【测试4】邀请人没有授权日期时应该从今天开始累加');
const inviterData = {
authorization_date: null,
authorization_days: 0
};
console.log(' 邀请人当前状态:');
console.log(' - 授权开始日期: 无');
console.log(' - 授权天数:', inviterData.authorization_days, '天');
if (inviterData.authorization_date) throw new Error('测试数据错误:授权日期应该为空');
const newAuthDate = new Date();
const newAuthDays = 3;
if (!newAuthDate) throw new Error('授权日期为空');
if (newAuthDays !== 3) throw new Error('天数计算错误');
console.log('✅ 通过: 首次授权从今天开始给3天');
console.log(' - 新授权开始日期:', dayjs(newAuthDate).format('YYYY-MM-DD'));
console.log(' - 新授权天数:', newAuthDays, '天');
}},
{ name: '邀请记录应该正确保存', fn: () => {
console.log('\n【测试5】邀请记录应该正确保存');
const record = {
inviter_id: 1,
invitee_id: 2,
invite_code: 'INV1_ABC123',
reward_status: 1,
reward_type: 'trial_days',
reward_value: 3
};
console.log(' 邀请记录内容:');
console.log(' - 邀请人ID:', record.inviter_id);
console.log(' - 被邀请人ID:', record.invitee_id);
console.log(' - 邀请码:', record.invite_code);
console.log(' - 奖励状态:', record.reward_status === 1 ? '已发放' : '未发放');
console.log(' - 奖励类型:', record.reward_type);
console.log(' - 奖励值:', record.reward_value, '天');
if (record.reward_status !== 1) throw new Error('奖励状态错误');
if (record.reward_type !== 'trial_days') throw new Error('奖励类型错误');
if (record.reward_value !== 3) throw new Error('奖励值错误');
console.log('✅ 通过: 邀请记录字段正确');
}}
];
let passed = 0;
let failed = 0;
tests.forEach(test => {
try {
test.fn();
passed++;
} catch (error) {
failed++;
console.error(`❌ 失败: ${error.message}`);
}
});
console.log('\n========================================');
console.log('测试完成');
console.log('========================================');
console.log(`✅ 通过: ${passed}/${tests.length}`);
console.log(`❌ 失败: ${failed}/${tests.length}`);
console.log(`成功率: ${(passed / tests.length * 100).toFixed(0)}%`);
console.log('========================================\n');
return failed === 0;
}
// 如果直接运行此文件,执行测试
if (require.main === module) {
const success = runTests();
process.exit(success ? 0 : 1);
}
module.exports = { runTests };

131
api/tests/register.test.js Normal file
View File

@@ -0,0 +1,131 @@
/**
* 注册功能测试 - 验证密码加密
*/
const { hashPassword, verifyPassword } = require('../utils/crypto_utils');
async function testRegisterPasswordEncryption() {
console.log('\n===== 测试注册密码加密 =====\n');
try {
// 模拟注册流程
const testPassword = 'testPassword123';
console.log('1. 模拟用户注册...');
console.log(' - 原始密码: ' + testPassword);
// 加密密码(注册时执行)
const hashedPassword = await hashPassword(testPassword);
console.log(' - 加密后密码: ' + hashedPassword.substring(0, 30) + '...');
console.log(' ✓ 密码已加密并存储到数据库\n');
// 模拟登录验证
console.log('2. 模拟用户登录验证...');
console.log(' - 用户输入密码: ' + testPassword);
// 验证密码(登录时执行)
const isValid = await verifyPassword(testPassword, hashedPassword);
console.log(' - 验证结果: ' + (isValid ? '✓ 通过' : '✗ 失败'));
if (!isValid) {
throw new Error('密码验证失败');
}
// 测试错误密码
console.log('\n3. 测试错误密码...');
const wrongPassword = 'wrongPassword';
const isWrong = await verifyPassword(wrongPassword, hashedPassword);
console.log(' - 错误密码验证结果: ' + (isWrong ? '✗ 通过(不应该)' : '✓ 正确拒绝'));
if (isWrong) {
throw new Error('错误密码不应该通过验证');
}
console.log('\n✓ 注册密码加密功能测试通过!');
console.log('✓ 新注册用户的密码会自动加密存储');
console.log('✓ 登录时可以正确验证加密密码\n');
return true;
} catch (error) {
console.error('\n✗ 测试失败:', error.message);
return false;
}
}
// 测试密码长度验证
function testPasswordValidation() {
console.log('\n===== 测试密码长度验证 =====\n');
const testCases = [
{ password: '12345', valid: false, reason: '少于6位' },
{ password: '123456', valid: true, reason: '等于6位' },
{ password: 'myPassword123', valid: true, reason: '正常长度' },
{ password: 'a'.repeat(50), valid: true, reason: '等于50位' },
{ password: 'a'.repeat(51), valid: false, reason: '超过50位' }
];
let allPassed = true;
testCases.forEach((testCase, index) => {
const result = testCase.password.length >= 6 && testCase.password.length <= 50;
const passed = result === testCase.valid;
console.log(`测试 ${index + 1}: ${testCase.reason}`);
console.log(` 密码长度: ${testCase.password.length}`);
console.log(` 期望: ${testCase.valid ? '有效' : '无效'}`);
console.log(` 结果: ${passed ? '✓ 通过' : '✗ 失败'}\n`);
if (!passed) {
allPassed = false;
}
});
if (allPassed) {
console.log('✓ 密码长度验证测试全部通过!\n');
} else {
console.log('✗ 部分密码长度验证测试失败\n');
}
return allPassed;
}
// 运行所有测试
async function runAllTests() {
console.log('\n==================== 注册功能安全测试 ====================\n');
console.log('测试场景:验证注册时密码是否正确加密存储\n');
const results = [];
results.push(await testRegisterPasswordEncryption());
results.push(testPasswordValidation());
console.log('\n==================== 测试总结 ====================\n');
const passed = results.filter(r => r).length;
const total = results.length;
console.log(`测试通过: ${passed}/${total}`);
if (passed === total) {
console.log('\n✓ 所有测试通过!');
console.log('✓ 注册功能已修复,密码会自动加密存储');
console.log('✓ 系统现在完全安全\n');
process.exit(0);
} else {
console.log('\n✗ 部分测试失败\n');
process.exit(1);
}
}
// 执行测试
if (require.main === module) {
runAllTests().catch(error => {
console.error('测试执行失败:', error);
process.exit(1);
});
}
module.exports = {
testRegisterPasswordEncryption,
testPasswordValidation
};

View File

@@ -0,0 +1,82 @@
/**
* 账号工具函数
* 提供账号相关的工具方法
*/
const dayjs = require('dayjs');
const utc = require('dayjs/plugin/utc');
const timezone = require('dayjs/plugin/timezone');
// 启用 UTC 和时区插件
dayjs.extend(utc);
dayjs.extend(timezone);
/**
* 计算剩余天数(使用 UTC 时间避免时区问题)
* @param {Date|string|null} authorizationDate - 授权日期
* @param {number} authorizationDays - 授权天数
* @returns {number} 剩余天数
*/
function calculateRemainingDays(authorizationDate, authorizationDays) {
if (!authorizationDate || !authorizationDays || authorizationDays <= 0) {
return 0;
}
// 使用 UTC 时间计算,避免时区问题
const startDate = dayjs(authorizationDate).utc().startOf('day');
const endDate = startDate.add(authorizationDays, 'day');
const now = dayjs().utc().startOf('day');
// 计算剩余天数
const remaining = endDate.diff(now, 'day', false); // 使用整数天数
return Math.max(0, remaining);
}
/**
* 检查授权是否有效
* @param {Date|string|null} authorizationDate - 授权日期
* @param {number} authorizationDays - 授权天数
* @returns {boolean} 授权是否有效
*/
function isAuthorizationValid(authorizationDate, authorizationDays) {
const remainingDays = calculateRemainingDays(authorizationDate, authorizationDays);
return remainingDays > 0;
}
/**
* 为账号对象添加 remaining_days 字段
* @param {Object} account - 账号对象(可以是 Sequelize 实例或普通对象)
* @returns {Object} 添加了 remaining_days 的账号对象
*/
function addRemainingDays(account) {
// 如果是 Sequelize 实例,转换为普通对象
const accountData = account.toJSON ? account.toJSON() : account;
const authDate = accountData.authorization_date;
const authDays = accountData.authorization_days || 0;
accountData.remaining_days = calculateRemainingDays(authDate, authDays);
return accountData;
}
/**
* 为账号数组添加 remaining_days 字段
* @param {Array} accounts - 账号数组
* @returns {Array} 添加了 remaining_days 的账号数组
*/
function addRemainingDaysToAccounts(accounts) {
if (!Array.isArray(accounts)) {
return accounts;
}
return accounts.map(account => addRemainingDays(account));
}
module.exports = {
calculateRemainingDays,
isAuthorizationValid,
addRemainingDays,
addRemainingDaysToAccounts
};

181
api/utils/crypto_utils.js Normal file
View File

@@ -0,0 +1,181 @@
/**
* 加密工具函数
* 提供密码加密、验证等安全相关功能
*/
const crypto = require('crypto');
// 配置参数
const SALT_LENGTH = 16; // salt 长度
const KEY_LENGTH = 64; // 密钥长度
const ITERATIONS = 100000; // 迭代次数
const DIGEST = 'sha256'; // 摘要算法
/**
* 生成密码哈希
* @param {string} password - 明文密码
* @returns {Promise<string>} 加密后的密码字符串 (格式: salt$hash)
*/
async function hashPassword(password) {
if (!password || typeof password !== 'string') {
throw new Error('密码不能为空');
}
return new Promise((resolve, reject) => {
// 生成随机 salt
const salt = crypto.randomBytes(SALT_LENGTH).toString('hex');
// 使用 pbkdf2 生成密钥
crypto.pbkdf2(password, salt, ITERATIONS, KEY_LENGTH, DIGEST, (err, derivedKey) => {
if (err) {
reject(err);
} else {
const hash = derivedKey.toString('hex');
// 返回格式: salt$hash
resolve(`${salt}$${hash}`);
}
});
});
}
/**
* 验证密码
* @param {string} password - 明文密码
* @param {string} hashedPassword - 加密后的密码 (格式: salt$hash)
* @returns {Promise<boolean>} 是否匹配
*/
async function verifyPassword(password, hashedPassword) {
if (!password || !hashedPassword) {
return false;
}
return new Promise((resolve, reject) => {
try {
// 解析 salt 和 hash
const parts = hashedPassword.split('$');
if (parts.length !== 2) {
resolve(false);
return;
}
const [salt, originalHash] = parts;
// 使用相同的 salt 生成密钥
crypto.pbkdf2(password, salt, ITERATIONS, KEY_LENGTH, DIGEST, (err, derivedKey) => {
if (err) {
reject(err);
} else {
const hash = derivedKey.toString('hex');
// 使用恒定时间比较,防止时序攻击
resolve(crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(originalHash)));
}
});
} catch (error) {
reject(error);
}
});
}
/**
* 生成安全的随机 token
* @param {number} length - token 长度(字节数),默认 32
* @returns {string} 十六进制字符串
*/
function generateToken(length = 32) {
return crypto.randomBytes(length).toString('hex');
}
/**
* 生成设备 ID
* @param {string} prefix - 前缀,默认 'device'
* @returns {string} 设备 ID
*/
function generateDeviceId(prefix = 'device') {
const timestamp = Date.now();
const random = crypto.randomBytes(8).toString('hex');
return `${prefix}_${timestamp}_${random}`;
}
/**
* 验证设备 ID 格式
* @param {string} deviceId - 设备 ID
* @returns {boolean} 是否有效
*/
function validateDeviceId(deviceId) {
if (!deviceId || typeof deviceId !== 'string') {
return false;
}
// 检查格式: prefix_timestamp_randomhex
const pattern = /^[a-z]+_\d{13}_[a-f0-9]{16}$/i;
return pattern.test(deviceId);
}
/**
* 脱敏处理 - 手机号
* @param {string} phone - 手机号
* @returns {string} 脱敏后的手机号
*/
function maskPhone(phone) {
if (!phone || typeof phone !== 'string') {
return '';
}
if (phone.length < 11) {
return phone;
}
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
}
/**
* 脱敏处理 - 邮箱
* @param {string} email - 邮箱
* @returns {string} 脱敏后的邮箱
*/
function maskEmail(email) {
if (!email || typeof email !== 'string') {
return '';
}
const parts = email.split('@');
if (parts.length !== 2) {
return email;
}
const [username, domain] = parts;
if (username.length <= 2) {
return `*@${domain}`;
}
const masked = username[0] + '***' + username[username.length - 1];
return `${masked}@${domain}`;
}
/**
* 脱敏处理 - 通用对象(用于日志)
* @param {Object} obj - 要脱敏的对象
* @param {Array<string>} sensitiveFields - 敏感字段列表
* @returns {Object} 脱敏后的对象
*/
function maskSensitiveData(obj, sensitiveFields = ['password', 'pwd', 'token', 'secret', 'key']) {
if (!obj || typeof obj !== 'object') {
return obj;
}
const masked = { ...obj };
for (const field of sensitiveFields) {
if (masked[field]) {
masked[field] = '***MASKED***';
}
}
return masked;
}
module.exports = {
hashPassword,
verifyPassword,
generateToken,
generateDeviceId,
validateDeviceId,
maskPhone,
maskEmail,
maskSensitiveData
};

11
app.js
View File

@@ -7,7 +7,6 @@ const frameworkConfig = require('./config/framework.config.js')
const Framework = require('./framework/node-core-framework.js');
const schedule = require('./api/middleware/schedule/index.js');
// 启动应用
async function startApp() {
try {
@@ -15,9 +14,18 @@ async function startApp() {
console.log('🚀 正在启动自动化找工作系统...');
console.log('='.repeat(50));
// 创建框架实例
const framework = await Framework.init(frameworkConfig);
// 启动服务器
const port = frameworkConfig.port.node;
@@ -38,6 +46,7 @@ async function startApp() {
// 启动调度系统
await schedule.init();
// 优雅关闭处理
process.on('SIGINT', async () => {
console.log('\n🛑 正在关闭应用...');

View File

@@ -0,0 +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};

3648
app/assets/index-BEa_v6Fs.js Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

Binary file not shown.

32
app/index.html Normal file
View File

@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>boss - 远程监听服务</title>
<script type="module" crossorigin src="/app/assets/index-BEa_v6Fs.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-BHUtbpCz.css">
</head>
<body>
<!-- 启动加载动画 -->
<div id="loading-screen" class="loading-screen">
<div class="loading-content">
<div class="loading-logo">
<div class="logo-circle"></div>
</div>
<div class="loading-text">正在启动...</div>
<div class="loading-progress">
<div class="progress-bar-animated"></div>
</div>
</div>
</div>
<!-- Vue 应用挂载点 -->
<div id="app" ></div>
<!-- 在 body 底部加载 Vue 应用脚本 -->
</body>

View File

@@ -27,12 +27,9 @@ module.exports = {
// Redis配置
redis: {
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379,
password: process.env.REDIS_PASSWORD || '',
db: process.env.REDIS_DB || 0,
keyPrefix: 'autowork:', // key前缀
ttl: 60 * 60 * 24 * 7 // 默认过期时间(7天)
"host": "192.144.167.231",
"port": "6379",
"pwd": "zc123",
},
oos: {
"accessKeyId": "LTAI5tENEdLxFU7Ne9wGazsk",
@@ -64,13 +61,14 @@ module.exports = {
},
// 白名单URL - 不需要token验证的接口
"allowUrls": ["/admin_api/sys_user/login", "/admin_api/sys_user/authorityMenus", "/admin_api/sys_user/register", "/api/user/loginByWeixin", "/file/", "/sys_file/", "/admin_api/win_data/viewLogInfo", "/api/user/wx_auth", '/api/docs', 'api/swagger.json', 'payment/notify', 'payment/refund-notify', 'wallet/transfer_notify', 'user/sms/send', 'user/sms/verify', '/api/version/check','/api/file/upload_file_to_oss_by_auto_work','/api/version/create', '/admin_api/invite/register', '/admin_api/invite/send-sms'],
"allowUrls": ["/admin_api/sys_user/login", "/admin_api/sys_user/authorityMenus", "/admin_api/sys_user/register", "/api/user/loginByWeixin", "/file/", "/sys_file/", "/admin_api/win_data/viewLogInfo", "/api/user/wx_auth", '/api/docs', 'api/swagger.json', 'payment/notify', 'payment/refund-notify', 'wallet/transfer_notify', 'user/sms/send', 'user/sms/verify', '/api/version/check','/api/file/upload_file_to_oss_by_auto_work','/api/version/create', 'register', 'send_email_code','/config/remote-code/version'],
// AI服务配置
ai: {
"apiKey": "sk-c83cdb06a6584f99bb2cd6e8a5ae3bbc",
"baseUrl": "https://dashscope.aliyuncs.com/api/v1"
"baseUrl": "https://dashscope.aliyuncs.com/api/v1",
"model": "qwen-turbo"
},
// MQTT配置
@@ -87,7 +85,18 @@ module.exports = {
enabled: true,
timezone: 'Asia/Shanghai'
},
qq_map_key: "7AXBZ-Z7M3V-BZQPK-53TUZ-2QLC6-RAFKU",
qq_map_key: "VIABZ-3N6HT-4BLXK-VF3FD-TM6YF-YRFQM",
// 邮件服务配置QQ邮箱
email: {
host: 'smtp.qq.com',
port: 465,
secure: true, // 使用SSL
fromName: '自动找工作系统',
auth: {
user: 'light603@qq.com' || '', // QQ邮箱账号例如: 123456789@qq.com
pass: 'fxqnednoacqybbba' || '' // QQ邮箱授权码不是密码需要在QQ邮箱设置中获取
}
}
};

View File

@@ -69,10 +69,12 @@ module.exports = {
// 基础 URL根据环境区分
baseUrl: (() => {
const env = process.env.NODE_ENV || 'development';
console.log('env',env)
switch (env) {
case 'production':
return 'https://work.light120.com'; // 生产环境
return 'https://work.light120.com';
// 生产环境
case 'development':
default:
return 'http://localhost:9097'; // 开发环境
@@ -93,7 +95,12 @@ module.exports = {
// Redis 配置
redis: baseConfig.redis || null,
redis: {
"host": "192.144.167.231",
"port": "6379",
"pwd": "zc123",
},
// 模型路径
modelPaths: './api/model',

15
ecosystem.config.js Normal file
View File

@@ -0,0 +1,15 @@
module.exports = {
apps: [
{
name: 'autoAiWorkSys',
script: 'app.js',
env: {
NODE_ENV: 'development'
},
env_production: {
NODE_ENV: 'production'
}
}
]
}

File diff suppressed because one or more lines are too long

761
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,17 +19,18 @@
"koa-compose": "^4.1.0",
"koa-convert": "^2.0.0",
"koa-mount": "^4.0.0",
"koa-router": "^7.4.0",
"koa-router": "^10.0.0",
"koa-send": "^5.0.0",
"koa-static": "^5.0.0",
"koa2-cors": "^2.0.6",
"koa2-swagger-ui": "^5.12.0",
"md5": "^2.2.1",
"mqtt": "^5.14.0",
"mysql2": "^1.7.0",
"mysql2": "^3.15.1",
"node-schedule": "latest",
"nodemailer": "^6.9.7",
"node-uuid": "^1.4.8",
"redis": "^5.8.3",
"redis": "^3.0.2",
"sequelize": "^5.22.5",
"swagger-jsdoc": "^6.2.8",
"uuid": "^8.3.2",