1
This commit is contained in:
75
api/middleware/dbProxy.js
Normal file
75
api/middleware/dbProxy.js
Normal file
@@ -0,0 +1,75 @@
|
||||
const Framework = require('../../framework/node-core-framework');
|
||||
|
||||
/**
|
||||
* 数据库代理模块
|
||||
* 提供统一的数据库模型访问接口,延迟获取models
|
||||
*/
|
||||
class DbProxy {
|
||||
constructor() {
|
||||
this._models = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取models实例
|
||||
* @returns {object} models对象
|
||||
*/
|
||||
get models() {
|
||||
if (!this._models) {
|
||||
try {
|
||||
this._models = Framework.getModels();
|
||||
} catch (error) {
|
||||
console.warn('无法获取models,请确保框架已正确初始化:', error.message);
|
||||
this._models = {};
|
||||
}
|
||||
}
|
||||
return this._models;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定的模型
|
||||
* @param {string} modelName 模型名称
|
||||
* @returns {object} 模型实例
|
||||
*/
|
||||
getModel(modelName) {
|
||||
const models = this.models;
|
||||
if (!models[modelName]) {
|
||||
throw new Error(`模型 '${modelName}' 不存在`);
|
||||
}
|
||||
return models[modelName];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有模型
|
||||
* @returns {object} 所有模型对象
|
||||
*/
|
||||
getAllModels() {
|
||||
return this.models;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查模型是否存在
|
||||
* @param {string} modelName 模型名称
|
||||
* @returns {boolean} 是否存在
|
||||
*/
|
||||
hasModel(modelName) {
|
||||
return !!this.models[modelName];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模型列表
|
||||
* @returns {array} 模型名称列表
|
||||
*/
|
||||
getModelNames() {
|
||||
return Object.keys(this.models);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新加载模型(在框架重新初始化后调用)
|
||||
*/
|
||||
reload() {
|
||||
this._models = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例
|
||||
module.exports = new DbProxy();
|
||||
289
api/middleware/job/aiService.js
Normal file
289
api/middleware/job/aiService.js
Normal file
@@ -0,0 +1,289 @@
|
||||
const axios = require('axios');
|
||||
const config = require('../../../config/config');
|
||||
const logs = require('../logProxy');
|
||||
|
||||
/**
|
||||
* DeepSeek大模型服务
|
||||
* 集成DeepSeek 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.maxRetries = 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用DeepSeek API
|
||||
* @param {string} prompt - 提示词
|
||||
* @param {object} options - 配置选项
|
||||
* @returns {Promise<object>} API响应结果
|
||||
*/
|
||||
async callAPI(prompt, options = {}) {
|
||||
const requestData = {
|
||||
model: this.model,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: options.systemPrompt || '你是一个专业的招聘顾问,擅长分析岗位信息、生成聊天内容和分析简历匹配度。'
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt
|
||||
}
|
||||
],
|
||||
temperature: options.temperature || 0.7,
|
||||
max_tokens: options.maxTokens || 2000,
|
||||
top_p: options.topP || 0.9
|
||||
};
|
||||
|
||||
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
|
||||
try {
|
||||
const response = await axios.post(this.apiUrl, requestData, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
return {
|
||||
data: response.data,
|
||||
content: response.data.choices?.[0]?.message?.content || ''
|
||||
};
|
||||
} catch (error) {
|
||||
console.log(`DeepSeek API调用失败 (尝试 ${attempt}/${this.maxRetries}): ${error.message}`);
|
||||
|
||||
if (attempt === this.maxRetries) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
// 等待后重试
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 岗位智能筛选
|
||||
* @param {object} jobInfo - 岗位信息
|
||||
* @param {object} resumeInfo - 简历信息
|
||||
* @returns {Promise<object>} 筛选结果
|
||||
*/
|
||||
async analyzeJob(jobInfo, resumeInfo) {
|
||||
const prompt = `
|
||||
请分析以下岗位信息,并给出详细的评估结果:
|
||||
|
||||
岗位信息:
|
||||
- 公司名称:${jobInfo.companyName || '未知'}
|
||||
- 职位名称:${jobInfo.jobTitle || '未知'}
|
||||
- 薪资范围:${jobInfo.salary || '未知'}
|
||||
- 工作地点:${jobInfo.location || '未知'}
|
||||
- 岗位描述:${jobInfo.description || '未知'}
|
||||
- 技能要求:${jobInfo.skills || '未知'}
|
||||
|
||||
简历信息:
|
||||
- 技能标签:${resumeInfo.skills || '未知'}
|
||||
- 工作经验:${resumeInfo.experience || '未知'}
|
||||
- 教育背景:${resumeInfo.education || '未知'}
|
||||
- 期望薪资:${resumeInfo.expectedSalary || '未知'}
|
||||
|
||||
请从以下维度进行分析:
|
||||
1. 技能匹配度(0-100分)
|
||||
2. 经验匹配度(0-100分)
|
||||
3. 薪资合理性(0-100分)
|
||||
4. 公司质量评估(0-100分)
|
||||
5. 是否为外包岗位(是/否)
|
||||
6. 综合推荐指数(0-100分)
|
||||
7. 详细分析说明
|
||||
8. 投递建议
|
||||
|
||||
请以JSON格式返回结果。
|
||||
`;
|
||||
|
||||
const result = await this.callAPI(prompt, {
|
||||
systemPrompt: '你是一个专业的招聘分析师,擅长评估岗位与简历的匹配度。请提供客观、专业的分析结果。',
|
||||
temperature: 0.3
|
||||
});
|
||||
|
||||
try {
|
||||
// 尝试解析JSON响应
|
||||
const analysis = JSON.parse(result.content);
|
||||
return {
|
||||
analysis: analysis
|
||||
};
|
||||
} catch (parseError) {
|
||||
// 如果解析失败,返回原始内容
|
||||
return {
|
||||
analysis: {
|
||||
content: result.content,
|
||||
parseError: true
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成个性化聊天内容
|
||||
* @param {object} jobInfo - 岗位信息
|
||||
* @param {object} resumeInfo - 简历信息
|
||||
* @param {string} chatType - 聊天类型 (greeting/interview/followup)
|
||||
* @returns {Promise<object>} 聊天内容
|
||||
*/
|
||||
async generateChatContent(jobInfo, resumeInfo, chatType = 'greeting') {
|
||||
const chatTypeMap = {
|
||||
'greeting': '初次打招呼',
|
||||
'interview': '面试邀约',
|
||||
'followup': '跟进沟通'
|
||||
};
|
||||
|
||||
const prompt = `
|
||||
请为以下场景生成个性化的聊天内容:
|
||||
|
||||
聊天类型:${chatTypeMap[chatType] || chatType}
|
||||
|
||||
岗位信息:
|
||||
- 公司名称:${jobInfo.companyName || '未知'}
|
||||
- 职位名称:${jobInfo.jobTitle || '未知'}
|
||||
- 技能要求:${jobInfo.skills || '未知'}
|
||||
|
||||
简历信息:
|
||||
- 技能标签:${resumeInfo.skills || '未知'}
|
||||
- 工作经验:${resumeInfo.experience || '未知'}
|
||||
- 项目经验:${resumeInfo.projects || '未知'}
|
||||
|
||||
要求:
|
||||
1. 内容要自然、专业、个性化
|
||||
2. 突出简历与岗位的匹配点
|
||||
3. 避免过于机械化的表达
|
||||
4. 长度控制在100-200字
|
||||
5. 体现求职者的诚意和热情
|
||||
|
||||
请直接返回聊天内容,不需要其他格式。
|
||||
`;
|
||||
|
||||
const result = await this.callAPI(prompt, {
|
||||
systemPrompt: '你是一个专业的招聘沟通专家,擅长生成自然、专业的求职聊天内容。',
|
||||
temperature: 0.8
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析简历要素
|
||||
* @param {string} resumeText - 简历文本内容
|
||||
* @returns {Promise<object>} 简历分析结果
|
||||
*/
|
||||
async analyzeResume(resumeText) {
|
||||
const prompt = `
|
||||
请分析以下简历内容,提取核心要素:
|
||||
|
||||
简历内容:
|
||||
${resumeText}
|
||||
|
||||
请提取以下信息:
|
||||
1. 技能标签(编程语言、框架、工具等)
|
||||
2. 工作经验(年限、行业、项目等)
|
||||
3. 教育背景(学历、专业、证书等)
|
||||
4. 期望薪资范围
|
||||
5. 期望工作地点
|
||||
6. 核心优势
|
||||
7. 职业发展方向
|
||||
|
||||
请以JSON格式返回结果。
|
||||
`;
|
||||
|
||||
const result = await this.callAPI(prompt, {
|
||||
systemPrompt: '你是一个专业的简历分析师,擅长提取简历的核心要素和关键信息。',
|
||||
temperature: 0.2
|
||||
});
|
||||
|
||||
try {
|
||||
const analysis = JSON.parse(result.content);
|
||||
return {
|
||||
analysis: analysis
|
||||
};
|
||||
} catch (parseError) {
|
||||
return {
|
||||
analysis: {
|
||||
content: result.content,
|
||||
parseError: true
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成面试邀约内容
|
||||
* @param {object} jobInfo - 岗位信息
|
||||
* @param {object} chatHistory - 聊天历史
|
||||
* @returns {Promise<object>} 面试邀约内容
|
||||
*/
|
||||
async generateInterviewInvitation(jobInfo, chatHistory) {
|
||||
const prompt = `
|
||||
请基于以下信息生成面试邀约内容:
|
||||
|
||||
岗位信息:
|
||||
- 公司名称:${jobInfo.companyName || '未知'}
|
||||
- 职位名称:${jobInfo.jobTitle || '未知'}
|
||||
- 工作地点:${jobInfo.location || '未知'}
|
||||
|
||||
聊天历史:
|
||||
${chatHistory || '无'}
|
||||
|
||||
要求:
|
||||
1. 表达面试邀约的诚意
|
||||
2. 提供灵活的时间选择
|
||||
3. 说明面试形式和地点
|
||||
4. 体现对候选人的重视
|
||||
5. 语言自然、专业
|
||||
|
||||
请直接返回面试邀约内容。
|
||||
`;
|
||||
|
||||
const result = await this.callAPI(prompt, {
|
||||
systemPrompt: '你是一个专业的HR,擅长生成面试邀约内容。',
|
||||
temperature: 0.6
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 识别外包岗位
|
||||
* @param {object} jobInfo - 岗位信息
|
||||
* @returns {Promise<object>} 外包识别结果
|
||||
*/
|
||||
async identifyOutsourcingJob(jobInfo) {
|
||||
const prompt = `
|
||||
请分析以下岗位信息,判断是否为外包岗位:
|
||||
|
||||
岗位信息:
|
||||
- 公司名称:${jobInfo.companyName || '未知'}
|
||||
- 职位名称:${jobInfo.jobTitle || '未知'}
|
||||
- 岗位描述:${jobInfo.description || '未知'}
|
||||
- 技能要求:${jobInfo.skills || '未知'}
|
||||
- 工作地点:${jobInfo.location || '未知'}
|
||||
|
||||
外包岗位特征:
|
||||
1. 公司名称包含"外包"、"派遣"、"人力"等关键词
|
||||
2. 岗位描述提到"项目外包"、"驻场开发"等
|
||||
3. 技能要求过于宽泛或具体
|
||||
4. 工作地点频繁变动
|
||||
5. 薪资结构不明确
|
||||
|
||||
请判断是否为外包岗位,并给出详细分析。
|
||||
`;
|
||||
|
||||
const result = await this.callAPI(prompt, {
|
||||
systemPrompt: '你是一个专业的岗位分析师,擅长识别外包岗位的特征。',
|
||||
temperature: 0.3
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new aiService();
|
||||
421
api/middleware/job/chatManager.js
Normal file
421
api/middleware/job/chatManager.js
Normal file
@@ -0,0 +1,421 @@
|
||||
// const aiService = require('./aiService'); // 二期规划:AI 服务暂时禁用
|
||||
const logs = require('../logProxy');
|
||||
|
||||
/**
|
||||
* 智能聊天管理模块
|
||||
* 负责聊天内容生成、发送策略和效果监控
|
||||
*/
|
||||
class ChatManager {
|
||||
constructor() {
|
||||
this.chatHistory = new Map(); // 聊天历史记录
|
||||
this.chatStrategies = new Map(); // 聊天策略配置
|
||||
this.effectStats = new Map(); // 聊天效果统计
|
||||
this.initDefaultStrategies();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化默认聊天策略
|
||||
*/
|
||||
initDefaultStrategies() {
|
||||
// 初次打招呼策略
|
||||
this.chatStrategies.set('greeting', {
|
||||
name: '初次打招呼',
|
||||
description: '向HR发送初次打招呼消息',
|
||||
template: 'greeting',
|
||||
timing: 'immediate',
|
||||
retryCount: 1,
|
||||
retryInterval: 300000 // 5分钟
|
||||
});
|
||||
|
||||
// 面试邀约策略
|
||||
this.chatStrategies.set('interview', {
|
||||
name: '面试邀约',
|
||||
description: '发送面试邀约消息',
|
||||
template: 'interview',
|
||||
timing: 'after_greeting',
|
||||
retryCount: 2,
|
||||
retryInterval: 600000 // 10分钟
|
||||
});
|
||||
|
||||
// 跟进沟通策略
|
||||
this.chatStrategies.set('followup', {
|
||||
name: '跟进沟通',
|
||||
description: '跟进之前的沟通',
|
||||
template: 'followup',
|
||||
timing: 'after_interview',
|
||||
retryCount: 1,
|
||||
retryInterval: 86400000 // 24小时
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成聊天内容
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} jobInfo - 岗位信息
|
||||
* @param {object} resumeInfo - 简历信息
|
||||
* @param {string} chatType - 聊天类型
|
||||
* @param {object} context - 聊天上下文
|
||||
* @returns {Promise<object>} 聊天内容
|
||||
*/
|
||||
async generateChatContent(sn_code, jobInfo, resumeInfo, chatType = 'greeting', context = {}) {
|
||||
console.log(`[聊天管理] 开始生成设备 ${sn_code} 的聊天内容,类型: ${chatType}`);
|
||||
|
||||
// 获取聊天策略
|
||||
const strategy = this.chatStrategies.get(chatType);
|
||||
if (!strategy) {
|
||||
throw new Error(`未找到聊天类型 ${chatType} 的策略配置`);
|
||||
}
|
||||
|
||||
// 二期规划:AI 生成聊天内容暂时禁用,使用默认模板
|
||||
// const chatContent = await aiService.generateChatContent(jobInfo, resumeInfo, chatType);
|
||||
// if (!chatContent.success) {
|
||||
// console.error(`[聊天管理] AI生成聊天内容失败:`, chatContent.error);
|
||||
// throw new Error(chatContent.error);
|
||||
// }
|
||||
|
||||
console.log(`[聊天管理] AI生成已禁用(二期规划),使用默认聊天模板`);
|
||||
const chatContent = this.generateDefaultChatContent(jobInfo, resumeInfo, chatType);
|
||||
|
||||
const result = {
|
||||
sn_code: sn_code,
|
||||
jobInfo: jobInfo,
|
||||
chatType: chatType,
|
||||
strategy: strategy,
|
||||
content: chatContent.content,
|
||||
context: context,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
// 记录聊天历史
|
||||
this.recordChatHistory(sn_code, result);
|
||||
|
||||
console.log(`[聊天管理] 聊天内容生成成功:`, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送聊天消息
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} mqttClient - MQTT客户端
|
||||
* @param {object} chatData - 聊天数据
|
||||
* @returns {Promise<object>} 发送结果
|
||||
*/
|
||||
async sendChatMessage(sn_code, mqttClient, chatData) {
|
||||
console.log(`[聊天管理] 开始发送聊天消息到设备 ${sn_code}`);
|
||||
|
||||
// 构建发送指令
|
||||
const sendData = {
|
||||
platform: 'boss',
|
||||
action: 'send_chat_message',
|
||||
data: {
|
||||
jobId: chatData.jobInfo.jobId,
|
||||
companyId: chatData.jobInfo.companyId,
|
||||
message: chatData.content,
|
||||
chatType: chatData.chatType
|
||||
}
|
||||
};
|
||||
|
||||
// 发送MQTT指令
|
||||
const response = await mqttClient.publishAndWait(sn_code, sendData);
|
||||
|
||||
if (!response || response.code !== 200) {
|
||||
// 更新聊天状态
|
||||
this.updateChatStatus(sn_code, chatData, 'failed', response);
|
||||
|
||||
console.error(`[聊天管理] 聊天消息发送失败:`, response);
|
||||
throw new Error(response?.message || '聊天消息发送失败');
|
||||
}
|
||||
|
||||
// 更新聊天状态
|
||||
this.updateChatStatus(sn_code, chatData, 'sent', response);
|
||||
|
||||
// 记录效果统计
|
||||
this.recordChatEffect(sn_code, chatData, 'sent');
|
||||
|
||||
console.log(`[聊天管理] 聊天消息发送成功:`, response);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成面试邀约
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} jobInfo - 岗位信息
|
||||
* @param {object} chatHistory - 聊天历史
|
||||
* @returns {Promise<object>} 面试邀约内容
|
||||
*/
|
||||
async generateInterviewInvitation(sn_code, jobInfo, chatHistory) {
|
||||
console.log(`[聊天管理] 开始生成设备 ${sn_code} 的面试邀约`);
|
||||
console.log(`[聊天管理] AI生成已禁用(二期规划),使用默认模板`);
|
||||
|
||||
// 二期规划:AI 生成面试邀约暂时禁用,使用默认模板
|
||||
// const invitation = await aiService.generateInterviewInvitation(jobInfo, chatHistory);
|
||||
// if (!invitation.success) {
|
||||
// console.error(`[聊天管理] AI生成面试邀约失败:`, invitation.error);
|
||||
// throw new Error(invitation.error);
|
||||
// }
|
||||
|
||||
const invitation = this.generateDefaultInterviewInvitation(jobInfo);
|
||||
|
||||
const result = {
|
||||
sn_code: sn_code,
|
||||
jobInfo: jobInfo,
|
||||
chatType: 'interview',
|
||||
content: invitation.content,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
// 记录聊天历史
|
||||
this.recordChatHistory(sn_code, result);
|
||||
|
||||
console.log(`[聊天管理] 面试邀约生成成功:`, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录聊天历史
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} chatData - 聊天数据
|
||||
*/
|
||||
recordChatHistory(sn_code, chatData) {
|
||||
if (!this.chatHistory.has(sn_code)) {
|
||||
this.chatHistory.set(sn_code, []);
|
||||
}
|
||||
|
||||
const history = this.chatHistory.get(sn_code);
|
||||
history.push({
|
||||
...chatData,
|
||||
id: Date.now() + Math.random(),
|
||||
status: 'generated'
|
||||
});
|
||||
|
||||
// 限制历史记录数量
|
||||
if (history.length > 100) {
|
||||
history.splice(0, history.length - 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新聊天状态
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} chatData - 聊天数据
|
||||
* @param {string} status - 新状态
|
||||
* @param {object} response - 响应数据
|
||||
*/
|
||||
updateChatStatus(sn_code, chatData, status, response = {}) {
|
||||
if (!this.chatHistory.has(sn_code)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const history = this.chatHistory.get(sn_code);
|
||||
const chatRecord = history.find(record =>
|
||||
record.timestamp === chatData.timestamp &&
|
||||
record.chatType === chatData.chatType
|
||||
);
|
||||
|
||||
if (chatRecord) {
|
||||
chatRecord.status = status;
|
||||
chatRecord.response = response;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录聊天效果
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} chatData - 聊天数据
|
||||
* @param {string} action - 动作类型
|
||||
*/
|
||||
recordChatEffect(sn_code, chatData, action) {
|
||||
if (!this.effectStats.has(sn_code)) {
|
||||
this.effectStats.set(sn_code, {
|
||||
totalSent: 0,
|
||||
totalReplied: 0,
|
||||
totalInterview: 0,
|
||||
replyRate: 0,
|
||||
interviewRate: 0,
|
||||
lastUpdate: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
const stats = this.effectStats.get(sn_code);
|
||||
|
||||
if (action === 'sent') {
|
||||
stats.totalSent++;
|
||||
} else if (action === 'replied') {
|
||||
stats.totalReplied++;
|
||||
} else if (action === 'interview') {
|
||||
stats.totalInterview++;
|
||||
}
|
||||
|
||||
// 计算比率
|
||||
if (stats.totalSent > 0) {
|
||||
stats.replyRate = (stats.totalReplied / stats.totalSent * 100).toFixed(2);
|
||||
stats.interviewRate = (stats.totalInterview / stats.totalSent * 100).toFixed(2);
|
||||
}
|
||||
|
||||
stats.lastUpdate = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取聊天历史
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} filters - 过滤条件
|
||||
* @returns {Array} 聊天历史
|
||||
*/
|
||||
getChatHistory(sn_code, filters = {}) {
|
||||
if (!this.chatHistory.has(sn_code)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let history = this.chatHistory.get(sn_code);
|
||||
|
||||
// 应用过滤条件
|
||||
if (filters.chatType) {
|
||||
history = history.filter(record => record.chatType === filters.chatType);
|
||||
}
|
||||
|
||||
if (filters.status) {
|
||||
history = history.filter(record => record.status === filters.status);
|
||||
}
|
||||
|
||||
if (filters.startTime) {
|
||||
history = history.filter(record => record.timestamp >= filters.startTime);
|
||||
}
|
||||
|
||||
if (filters.endTime) {
|
||||
history = history.filter(record => record.timestamp <= filters.endTime);
|
||||
}
|
||||
|
||||
// 按时间倒序排列
|
||||
return history.sort((a, b) => b.timestamp - a.timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取聊天效果统计
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @returns {object} 效果统计
|
||||
*/
|
||||
getChatEffectStats(sn_code) {
|
||||
if (!this.effectStats.has(sn_code)) {
|
||||
return {
|
||||
totalSent: 0,
|
||||
totalReplied: 0,
|
||||
totalInterview: 0,
|
||||
replyRate: 0,
|
||||
interviewRate: 0,
|
||||
lastUpdate: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
return this.effectStats.get(sn_code);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置聊天策略
|
||||
* @param {string} chatType - 聊天类型
|
||||
* @param {object} strategy - 策略配置
|
||||
*/
|
||||
setChatStrategy(chatType, strategy) {
|
||||
this.chatStrategies.set(chatType, {
|
||||
...this.chatStrategies.get(chatType),
|
||||
...strategy
|
||||
});
|
||||
|
||||
console.log(`[聊天管理] 更新聊天策略 ${chatType}:`, strategy);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期数据
|
||||
*/
|
||||
cleanup() {
|
||||
const now = Date.now();
|
||||
const expireTime = 30 * 24 * 3600000; // 30天
|
||||
|
||||
// 清理过期的聊天历史
|
||||
for (const [sn_code, history] of this.chatHistory.entries()) {
|
||||
const filteredHistory = history.filter(record =>
|
||||
now - record.timestamp < expireTime
|
||||
);
|
||||
|
||||
if (filteredHistory.length === 0) {
|
||||
this.chatHistory.delete(sn_code);
|
||||
} else {
|
||||
this.chatHistory.set(sn_code, filteredHistory);
|
||||
}
|
||||
}
|
||||
|
||||
// 清理过期的效果统计
|
||||
for (const [sn_code, stats] of this.effectStats.entries()) {
|
||||
if (now - stats.lastUpdate > expireTime) {
|
||||
this.effectStats.delete(sn_code);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[聊天管理] 数据清理完成`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成默认聊天内容(替代 AI 生成)
|
||||
* @param {object} jobInfo - 岗位信息
|
||||
* @param {object} resumeInfo - 简历信息
|
||||
* @param {string} chatType - 聊天类型
|
||||
* @returns {object} 聊天内容
|
||||
*/
|
||||
generateDefaultChatContent(jobInfo, resumeInfo, chatType) {
|
||||
const templates = {
|
||||
greeting: '您好,我对这个岗位很感兴趣,希望能进一步了解。',
|
||||
interview: '感谢您的回复,我很期待与您进一步沟通。',
|
||||
followup: '您好,想了解一下这个岗位的最新进展。'
|
||||
};
|
||||
|
||||
const content = templates[chatType] || templates.greeting;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
content: content,
|
||||
chatType: chatType
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成默认面试邀约(替代 AI 生成)
|
||||
* @param {object} jobInfo - 岗位信息
|
||||
* @returns {object} 面试邀约内容
|
||||
*/
|
||||
generateDefaultInterviewInvitation(jobInfo) {
|
||||
return {
|
||||
success: true,
|
||||
content: '感谢您的邀请,我很期待与您面谈。请问方便的时间是什么时候?',
|
||||
jobTitle: jobInfo.jobTitle || '该岗位',
|
||||
companyName: jobInfo.companyName || '贵公司'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取聊天列表
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} mqttClient - MQTT客户端
|
||||
* @param {object} params - 参数
|
||||
* @returns {Promise<object>} 聊天列表
|
||||
*/
|
||||
async get_chat_list(sn_code, mqttClient, params = {}) {
|
||||
const { platform = 'boss', pageCount = 3 } = params;
|
||||
console.log(`[聊天管理] 开始获取设备 ${sn_code} 的聊天列表`);
|
||||
|
||||
// 通过MQTT指令获取聊天列表
|
||||
const response = await mqttClient.publishAndWait(sn_code, {
|
||||
platform,
|
||||
action: "get_chat_list",
|
||||
data: { pageCount }
|
||||
});
|
||||
|
||||
if (!response || response.code !== 200) {
|
||||
console.error(`[聊天管理] 获取聊天列表失败:`, response);
|
||||
throw new Error('获取聊天列表失败');
|
||||
}
|
||||
|
||||
console.log(`[聊天管理] 成功获取聊天列表`);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ChatManager();
|
||||
33
api/middleware/job/index.js
Normal file
33
api/middleware/job/index.js
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Job 模块统一导出
|
||||
* 聚合所有 job 相关模块的方法,提供统一的对外接口
|
||||
*/
|
||||
|
||||
const jobManager = require('./jobManager');
|
||||
const resumeManager = require('./resumeManager');
|
||||
const chatManager = require('./chatManager');
|
||||
|
||||
|
||||
const pack = (instance) => {
|
||||
const proto = Object.getPrototypeOf(instance);
|
||||
|
||||
const methods = Object.getOwnPropertyNames(proto)
|
||||
.filter(k => k !== 'constructor')
|
||||
.reduce((acc, key) => {
|
||||
acc[key] = proto[key].bind(instance);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return { ...instance, ...methods };
|
||||
}
|
||||
|
||||
/**
|
||||
* 便捷方法:直接导出常用方法
|
||||
* 使用下划线命名规范
|
||||
*/
|
||||
module.exports = {
|
||||
...pack(jobManager),
|
||||
...pack(resumeManager),
|
||||
...pack(chatManager),
|
||||
};
|
||||
|
||||
876
api/middleware/job/jobManager.js
Normal file
876
api/middleware/job/jobManager.js
Normal file
@@ -0,0 +1,876 @@
|
||||
// const aiService = require('./aiService'); // 二期规划:AI 服务暂时禁用
|
||||
const jobFilterService = require('./job_filter_service'); // 使用文本匹配过滤服务
|
||||
const locationService = require('../../services/locationService'); // 位置服务
|
||||
const logs = require('../logProxy');
|
||||
const db = require('../dbProxy');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
/**
|
||||
* 工作管理模块
|
||||
* 负责简历获取、分析、存储和匹配度计算
|
||||
*/
|
||||
class JobManager {
|
||||
constructor() {
|
||||
}
|
||||
|
||||
// 启动客户端那个平台 用户信息,心跳机制
|
||||
async set_user_info(sn_code, mqttClient, user_info) {
|
||||
const response = await mqttClient.publishAndWait(sn_code, {
|
||||
platform: platform,
|
||||
action: 'set_user_info',
|
||||
data: {
|
||||
user_info: user_info
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取登录二维码
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} mqttClient - MQTT客户端
|
||||
* @param {object} params - 参数对象
|
||||
* @returns {Promise<object>} 二维码信息
|
||||
*/
|
||||
async get_login_qr_code(sn_code, mqttClient, params = {}) {
|
||||
console.log(`[工作管理] 开始获取设备 ${sn_code} 的登录二维码`);
|
||||
|
||||
const { platform = 'boss' } = params;
|
||||
|
||||
// 通过MQTT指令获取登录二维码
|
||||
const response = await mqttClient.publishAndWait(sn_code, {
|
||||
platform: platform,
|
||||
action: 'get_login_qr_code',
|
||||
data: {}
|
||||
});
|
||||
|
||||
if (!response || response.code !== 200) {
|
||||
console.error(`[工作管理] 获取登录二维码失败:`, response);
|
||||
throw new Error(response?.message || '获取登录二维码失败');
|
||||
}
|
||||
|
||||
const qrCodeData = response.data;
|
||||
console.log(`[工作管理] 成功获取登录二维码数据:`, qrCodeData);
|
||||
|
||||
return qrCodeData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开机器人检测测试页
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} mqttClient - MQTT客户端
|
||||
* @param {object} params - 参数对象
|
||||
* @returns {Promise<object>} 执行结果
|
||||
*/
|
||||
async open_bot_detection(sn_code, mqttClient, params = {}) {
|
||||
console.log(`[工作管理] 开始打开设备 ${sn_code} 的机器人检测测试页`);
|
||||
|
||||
const { platform = 'boss' } = params;
|
||||
|
||||
// 通过MQTT指令打开机器人检测测试页
|
||||
const response = await mqttClient.publishAndWait(sn_code, {
|
||||
platform: platform,
|
||||
action: 'open_bot_detection',
|
||||
data: {}
|
||||
});
|
||||
|
||||
if (!response || response.code !== 200) {
|
||||
console.error(`[工作管理] 打开机器人检测测试页失败:`, response);
|
||||
throw new Error(response?.message || '打开机器人检测测试页失败');
|
||||
}
|
||||
|
||||
const result = response.data;
|
||||
console.log(`[工作管理] 成功打开机器人检测测试页:`, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户信息
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} mqttClient - MQTT客户端
|
||||
* @param {object} params - 参数对象
|
||||
* @returns {Promise<object>} 用户信息
|
||||
*/
|
||||
async get_user_info(sn_code, mqttClient, params = {}) {
|
||||
console.log(`[工作管理] 开始获取设备 ${sn_code} 的用户信息`);
|
||||
|
||||
const { platform = 'boss' } = params;
|
||||
|
||||
// 通过MQTT指令获取用户信息
|
||||
const response = await mqttClient.publishAndWait(sn_code, {
|
||||
platform: platform,
|
||||
action: 'get_user_info',
|
||||
data: {}
|
||||
});
|
||||
|
||||
if (!response || response.code !== 200) {
|
||||
console.error(`[工作管理] 获取用户信息失败:`, response);
|
||||
throw new Error(response?.message || '获取用户信息失败');
|
||||
}
|
||||
|
||||
const userInfo = response.data;
|
||||
console.log(`[工作管理] 成功获取用户信息:`, userInfo);
|
||||
|
||||
return userInfo;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 搜索岗位
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} mqttClient - MQTT客户端
|
||||
* @param {object} params - 参数
|
||||
* @returns {Promise<object>} 搜索结果
|
||||
*/
|
||||
async search_jobs(sn_code, mqttClient, params = {}) {
|
||||
const { keyword = '前端', platform = 'boss' } = params;
|
||||
console.log(`[工作管理] 开始搜索设备 ${sn_code} 的岗位,关键词: ${keyword}`);
|
||||
|
||||
// 通过MQTT指令搜索岗位
|
||||
const response = await mqttClient.publishAndWait(sn_code, {
|
||||
platform,
|
||||
action: "search_jobs",
|
||||
data: { keyword }
|
||||
});
|
||||
|
||||
if (!response || response.code !== 200) {
|
||||
console.error(`[工作管理] 搜索岗位失败:`, response);
|
||||
throw new Error('搜索岗位失败');
|
||||
}
|
||||
|
||||
console.log(`[工作管理] 成功搜索岗位`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取岗位列表
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} mqttClient - MQTT客户端
|
||||
* @param {object} params - 参数
|
||||
* @returns {Promise<object>} 岗位列表
|
||||
*/
|
||||
async get_job_list(sn_code, mqttClient, params = {}) {
|
||||
const { keyword = '前端', platform = 'boss', pageCount = 3 } = params;
|
||||
console.log(`[工作管理] 开始获取设备 ${sn_code} 的岗位列表,关键词: ${keyword}`);
|
||||
|
||||
// 通过MQTT指令获取岗位列表
|
||||
const response = await mqttClient.publishAndWait(sn_code, {
|
||||
platform,
|
||||
action: "get_job_list",
|
||||
data: { keyword, pageCount }
|
||||
});
|
||||
|
||||
if (!response || response.code !== 200) {
|
||||
console.error(`[工作管理] 获取岗位列表失败:`, response);
|
||||
throw new Error('获取岗位列表失败');
|
||||
}
|
||||
|
||||
// 处理职位列表数据:response.data 可能是数组(职位列表.json 格式)或单个对象
|
||||
let jobs = [];
|
||||
|
||||
if (Array.isArray(response.data)) {
|
||||
// 如果是数组格式(职位列表.json),遍历每个元素提取岗位数据
|
||||
for (const item of response.data) {
|
||||
if (item.data?.zpData?.jobList && Array.isArray(item.data.zpData.jobList)) {
|
||||
jobs = jobs.concat(item.data.zpData.jobList);
|
||||
}
|
||||
}
|
||||
console.log(`[工作管理] 从 ${response.data.length} 个响应中提取岗位数据`);
|
||||
} else if (response.data?.data?.zpData?.jobList) {
|
||||
// 如果是单个对象格式,从 data.zpData.jobList 获取
|
||||
jobs = response.data.data.zpData.jobList || [];
|
||||
} else if (response.data?.zpData?.jobList) {
|
||||
// 兼容旧格式:直接从 zpData.jobList 获取
|
||||
jobs = response.data.zpData.jobList || [];
|
||||
}
|
||||
|
||||
console.log(`[工作管理] 成功获取岗位数据,共 ${jobs.length} 个岗位`);
|
||||
|
||||
// 保存职位到数据库
|
||||
try {
|
||||
await this.saveJobsToDatabase(sn_code, platform, keyword, jobs);
|
||||
} catch (error) {
|
||||
console.error(`[工作管理] 保存职位到数据库失败:`, error);
|
||||
// 不影响主流程,继续返回数据
|
||||
}
|
||||
|
||||
const result = {
|
||||
jobs: jobs,
|
||||
keyword: keyword,
|
||||
platform: platform,
|
||||
count: jobs.length
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存职位列表到数据库
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {string} platform - 平台
|
||||
* @param {string} keyword - 搜索关键词
|
||||
* @param {Array} jobs - 职位列表
|
||||
*/
|
||||
async saveJobsToDatabase(sn_code, platform, keyword, jobs) {
|
||||
const job_postings = db.getModel('job_postings');
|
||||
|
||||
console.log(`[工作管理] 开始保存 ${jobs.length} 个职位到数据库`);
|
||||
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
// 构建职位信息对象
|
||||
const jobInfo = {
|
||||
sn_code,
|
||||
platform,
|
||||
keyword,
|
||||
|
||||
// Boss直聘字段映射
|
||||
encryptBossId: job.encryptBossId || '',
|
||||
jobId: job.encryptJobId || '',
|
||||
jobTitle: job.jobName || '',
|
||||
companyId: job.encryptBrandId || '',
|
||||
companyName: job.brandName || '',
|
||||
companySize: job.brandScaleName || '',
|
||||
companyIndustry: job.brandIndustry || '',
|
||||
salary: job.salaryDesc || '',
|
||||
|
||||
// 岗位要求(从 jobLabels 和 skills 提取)
|
||||
jobRequirements: JSON.stringify({
|
||||
experience: job.jobExperience || '',
|
||||
education: job.jobDegree || '',
|
||||
labels: job.jobLabels || [],
|
||||
skills: job.skills || []
|
||||
}),
|
||||
|
||||
// 工作地点
|
||||
location: [job.cityName, job.areaDistrict, job.businessDistrict]
|
||||
.filter(Boolean).join(' '),
|
||||
|
||||
experience: job.jobExperience || '',
|
||||
education: job.jobDegree || '',
|
||||
|
||||
// 原始数据
|
||||
originalData: JSON.stringify(job),
|
||||
|
||||
// 默认状态
|
||||
applyStatus: 'pending',
|
||||
chatStatus: 'none'
|
||||
};
|
||||
|
||||
// 调用位置服务解析 location + companyName 获取坐标
|
||||
if (jobInfo.location && jobInfo.companyName) {
|
||||
const addressToParse = `${jobInfo.location.replaceAll(' ', '')}${jobInfo.companyName}`;
|
||||
|
||||
|
||||
// 等待 1秒
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
const location = await locationService.getLocationByAddress(addressToParse).catch(error => {
|
||||
console.error(`[工作管理] 获取位置失败:`, error);
|
||||
});
|
||||
|
||||
if (location) {
|
||||
jobInfo.latitude = String(location.lat);
|
||||
jobInfo.longitude = String(location.lng);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否已存在(根据 jobId 和 sn_code)
|
||||
const existingJob = await job_postings.findOne({
|
||||
where: {
|
||||
jobId: jobInfo.jobId,
|
||||
sn_code: sn_code
|
||||
}
|
||||
});
|
||||
|
||||
if (existingJob) {
|
||||
// 更新现有职位
|
||||
await job_postings.update(jobInfo, {
|
||||
where: {
|
||||
jobId: jobInfo.jobId,
|
||||
sn_code: sn_code
|
||||
}
|
||||
});
|
||||
console.log(`[工作管理] 职位已更新 - ${jobInfo.jobTitle} @ ${jobInfo.companyName}`);
|
||||
} else {
|
||||
// 创建新职位
|
||||
await job_postings.create(jobInfo);
|
||||
console.log(`[工作管理] 职位已创建 - ${jobInfo.jobTitle} @ ${jobInfo.companyName}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[工作管理] 保存职位失败:`, error, job);
|
||||
// 继续处理下一个职位
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[工作管理] 职位保存完成`);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 投递简历(单个职位)
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} mqttClient - MQTT客户端
|
||||
* @param {object} params - 参数
|
||||
* @param {string} params.jobId - 职位ID(必需)
|
||||
* @param {string} params.platform - 平台(默认boss)
|
||||
* @param {string} params.encryptBossId - 加密的Boss ID(可选)
|
||||
* @param {string} params.securityId - 安全ID(可选)
|
||||
* @param {string} params.brandName - 公司名称(可选)
|
||||
* @param {string} params.jobTitle - 职位标题(可选)
|
||||
* @param {string} params.companyName - 公司名称(可选)
|
||||
* @returns {Promise<object>} 投递结果
|
||||
*/
|
||||
async applyJob(sn_code, mqttClient, params = {}) {
|
||||
const { platform = 'boss', jobId, encryptBossId, securityId, brandName, jobTitle, companyName } = params;
|
||||
|
||||
if (!jobId) {
|
||||
throw new Error('jobId 参数不能为空,请指定要投递的职位ID');
|
||||
}
|
||||
|
||||
console.log(`[工作管理] 开始投递单个职位,设备: ${sn_code}, 职位: ${jobTitle || jobId}`);
|
||||
|
||||
const job_postings = db.getModel('job_postings');
|
||||
const apply_records = db.getModel('apply_records');
|
||||
|
||||
try {
|
||||
// 从数据库获取职位信息
|
||||
const where = { sn_code, jobId, platform };
|
||||
if (encryptBossId) where.encryptBossId = encryptBossId;
|
||||
|
||||
const job = await job_postings.findOne({ where });
|
||||
if (!job) {
|
||||
throw new Error(`未找到职位记录: ${jobId}`);
|
||||
}
|
||||
|
||||
const jobData = job.toJSON();
|
||||
|
||||
// 检查是否已存在投递记录(避免重复投递同一职位)
|
||||
const existingApply = await apply_records.findOne({ where: { sn_code, jobId: jobData.jobId } });
|
||||
if (existingApply) {
|
||||
console.log(`[工作管理] 跳过已投递职位: ${jobData.jobTitle} @ ${jobData.companyName}`);
|
||||
return {
|
||||
success: false,
|
||||
deliveredCount: 0,
|
||||
failedCount: 1,
|
||||
message: '该岗位已投递过',
|
||||
deliveredJobs: [],
|
||||
failedJobs: [{
|
||||
jobId: jobData.jobId,
|
||||
jobTitle: jobData.jobTitle,
|
||||
companyName: jobData.companyName,
|
||||
error: '该岗位已投递过'
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
// 检查该公司是否在一个月内已投递过(避免连续投递同一公司)
|
||||
const oneMonthAgo = new Date();
|
||||
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
|
||||
|
||||
const Sequelize = require('sequelize');
|
||||
const recentCompanyApply = await apply_records.findOne({
|
||||
where: {
|
||||
sn_code: sn_code,
|
||||
companyName: jobData.companyName,
|
||||
applyTime: {
|
||||
[Sequelize.Op.gte]: oneMonthAgo
|
||||
}
|
||||
},
|
||||
order: [['applyTime', 'DESC']]
|
||||
});
|
||||
|
||||
if (recentCompanyApply) {
|
||||
const daysAgo = Math.floor((new Date() - new Date(recentCompanyApply.applyTime)) / (1000 * 60 * 60 * 24));
|
||||
console.log(`[工作管理] 跳过一个月内已投递的公司: ${jobData.companyName} (${daysAgo}天前投递过)`);
|
||||
return {
|
||||
success: false,
|
||||
deliveredCount: 0,
|
||||
failedCount: 1,
|
||||
message: `该公司在${daysAgo}天前已投递过,一个月内不重复投递`,
|
||||
deliveredJobs: [],
|
||||
failedJobs: [{
|
||||
jobId: jobData.jobId,
|
||||
jobTitle: jobData.jobTitle,
|
||||
companyName: jobData.companyName,
|
||||
error: `该公司在${daysAgo}天前已投递过,一个月内不重复投递`
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`[工作管理] 投递职位: ${jobData.jobTitle} @ ${jobData.companyName}`);
|
||||
|
||||
// 通过MQTT指令投递简历
|
||||
const response = await mqttClient.publishAndWait(sn_code, {
|
||||
platform,
|
||||
action: "deliver_resume",
|
||||
data: {
|
||||
encryptJobId: jobData.jobId,
|
||||
securityId: jobData.securityId || securityId || '',
|
||||
brandName: jobData.companyName || brandName || ''
|
||||
}
|
||||
});
|
||||
|
||||
if (response && response.code === 200) {
|
||||
// 投递成功
|
||||
await job_postings.update(
|
||||
{ applyStatus: 'applied', applyTime: new Date() },
|
||||
{ where: { id: jobData.id } }
|
||||
);
|
||||
|
||||
// 计算距离和获取评分信息
|
||||
let distance = null;
|
||||
let locationScore = jobData.scoreDetails?.locationScore || 0;
|
||||
|
||||
try {
|
||||
// 获取账号的经纬度
|
||||
const pla_account = db.getModel('pla_account');
|
||||
const account = await pla_account.findOne({
|
||||
where: { sn_code, platform_type: platform }
|
||||
});
|
||||
|
||||
// 如果账号和职位都有经纬度,计算实际距离
|
||||
if (account && account.user_latitude && account.user_longitude &&
|
||||
jobData.latitude && jobData.longitude) {
|
||||
const userLat = parseFloat(account.user_latitude);
|
||||
const userLng = parseFloat(account.user_longitude);
|
||||
const jobLat = parseFloat(jobData.latitude);
|
||||
const jobLng = parseFloat(jobData.longitude);
|
||||
|
||||
if (!isNaN(userLat) && !isNaN(userLng) && !isNaN(jobLat) && !isNaN(jobLng)) {
|
||||
distance = locationService.calculateDistance(userLat, userLng, jobLat, jobLng);
|
||||
console.log(`[工作管理] 计算距离: ${distance} 公里`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[工作管理] 计算距离失败:`, error.message);
|
||||
// 距离计算失败不影响投递记录保存
|
||||
}
|
||||
|
||||
await apply_records.create({
|
||||
sn_code: sn_code,
|
||||
platform: platform,
|
||||
jobId: jobData.jobId,
|
||||
encryptBossId: jobData.encryptBossId || '',
|
||||
jobTitle: jobData.jobTitle,
|
||||
companyName: jobData.companyName,
|
||||
companyId: jobData.companyId || '',
|
||||
salary: jobData.salary || '',
|
||||
location: jobData.location || '',
|
||||
resumeId: jobData.resumeId || '',
|
||||
resumeName: jobData.resumeName || '',
|
||||
matchScore: jobData.matchScore || jobData.scoreDetails?.totalScore || 0,
|
||||
isOutsourcing: jobData.isOutsourcing || false,
|
||||
priority: jobData.priority || 5,
|
||||
isAutoApply: true,
|
||||
applyStatus: 'success',
|
||||
applyTime: new Date(),
|
||||
feedbackStatus: 'none',
|
||||
taskId: jobData.taskId || '',
|
||||
keyword: jobData.keyword || '',
|
||||
originalData: JSON.stringify({
|
||||
...(response.data || {}),
|
||||
scoreDetails: {
|
||||
...(jobData.scoreDetails || {}),
|
||||
locationScore: locationScore,
|
||||
distance: distance // 实际距离(公里)
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
console.log(`[工作管理] 投递成功: ${jobData.jobTitle}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deliveredCount: 1,
|
||||
failedCount: 0,
|
||||
deliveredJobs: [{
|
||||
jobId: jobData.jobId,
|
||||
jobTitle: jobData.jobTitle,
|
||||
companyName: jobData.companyName
|
||||
}],
|
||||
failedJobs: []
|
||||
};
|
||||
} else {
|
||||
// 投递失败
|
||||
await job_postings.update(
|
||||
{ applyStatus: 'failed' },
|
||||
{ where: { id: jobData.id } }
|
||||
);
|
||||
|
||||
|
||||
await apply_records.create({
|
||||
sn_code: sn_code,
|
||||
platform: platform,
|
||||
jobId: jobData.jobId,
|
||||
encryptBossId: jobData.encryptBossId || '',
|
||||
jobTitle: jobData.jobTitle,
|
||||
companyName: jobData.companyName,
|
||||
companyId: jobData.companyId || '',
|
||||
salary: jobData.salary || '',
|
||||
location: jobData.location || '',
|
||||
resumeId: jobData.resumeId || '',
|
||||
resumeName: jobData.resumeName || '',
|
||||
matchScore: jobData.matchScore || 0,
|
||||
isOutsourcing: jobData.isOutsourcing || false,
|
||||
priority: jobData.priority || 5,
|
||||
isAutoApply: true,
|
||||
applyStatus: 'failed',
|
||||
applyTime: new Date(),
|
||||
feedbackStatus: 'none',
|
||||
taskId: jobData.taskId || '',
|
||||
keyword: jobData.keyword || '',
|
||||
errorMessage: response?.message || '投递失败',
|
||||
originalData: JSON.stringify(response || {})
|
||||
});
|
||||
|
||||
throw new Error(response?.message || '投递失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[工作管理] 投递异常: ${jobTitle || jobId}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据规则过滤职位并评分
|
||||
* 评分规则:距离30% + 关键字10% + 薪资20% + 公司规模20% = 总分
|
||||
* 只有总分 >= 60 的职位才会被投递
|
||||
* @param {Array} jobs - 职位列表
|
||||
* @param {Object} filterRules - 过滤规则(包含账号配置和简历信息)
|
||||
* @returns {Promise<Array>} 过滤并评分后的职位列表(按总分降序排序)
|
||||
*/
|
||||
async filter_jobs_by_rules(jobs, filterRules) {
|
||||
const {
|
||||
minSalary = 0,
|
||||
maxSalary = 0,
|
||||
keywords = [],
|
||||
excludeKeywords = [],
|
||||
accountConfig = {}, // 账号配置
|
||||
resumeInfo = {} // 简历信息
|
||||
} = filterRules;
|
||||
|
||||
// 关键词从职位类型配置中获取,不再从账号配置中获取
|
||||
let filterKeywords = keywords || [];
|
||||
let excludeKeywordsList = excludeKeywords || [];
|
||||
|
||||
// 使用账号配置的薪资范围
|
||||
const accountMinSalary = accountConfig.min_salary || minSalary || 0;
|
||||
const accountMaxSalary = accountConfig.max_salary || maxSalary || 0;
|
||||
|
||||
// 对每个职位进行评分
|
||||
const scoredJobs = jobs.map(job => {
|
||||
const jobData = job.toJSON ? job.toJSON() : job;
|
||||
|
||||
// 1. 距离分数(30%)
|
||||
const locationScore = this.calculate_location_score(
|
||||
jobData.location,
|
||||
resumeInfo.expectedLocation
|
||||
);
|
||||
|
||||
// 2. 关键字分数(10%)
|
||||
const keywordScore = this.calculate_keyword_score(
|
||||
jobData,
|
||||
filterKeywords
|
||||
);
|
||||
|
||||
// 3. 薪资分数(20%)
|
||||
const salaryScore = this.calculate_salary_score(
|
||||
jobData.salary,
|
||||
resumeInfo.expectedSalary,
|
||||
accountMinSalary,
|
||||
accountMaxSalary
|
||||
);
|
||||
|
||||
// 4. 公司规模分数(20%)
|
||||
const companySizeScore = this.calculate_company_size_score(
|
||||
jobData.companySize
|
||||
);
|
||||
|
||||
// 计算总分(累加)
|
||||
const totalScore = Math.round(
|
||||
locationScore * 0.3 +
|
||||
keywordScore * 0.1 +
|
||||
salaryScore * 0.2 +
|
||||
companySizeScore * 0.2
|
||||
);
|
||||
|
||||
return {
|
||||
...jobData,
|
||||
matchScore: totalScore,
|
||||
scoreDetails: {
|
||||
locationScore,
|
||||
keywordScore,
|
||||
salaryScore,
|
||||
companySizeScore,
|
||||
totalScore
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// 过滤:排除关键词、最低分数、按总分排序
|
||||
return scoredJobs
|
||||
.filter(job => {
|
||||
// 1. 排除关键词过滤
|
||||
if (excludeKeywordsList && excludeKeywordsList.length > 0) {
|
||||
const jobText = `${job.jobTitle} ${job.companyName} ${job.jobDescription || ''}`.toLowerCase();
|
||||
const hasExcluded = excludeKeywordsList.some(kw => jobText.includes(kw.toLowerCase()));
|
||||
if (hasExcluded) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 最低分数过滤(总分 >= 60 才投递)
|
||||
if (job.matchScore < 60) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => b.matchScore - a.matchScore); // 按总分降序排序
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算距离分数(0-100分)
|
||||
* @param {string} jobLocation - 职位工作地点
|
||||
* @param {string} expectedLocation - 期望工作地点
|
||||
* @returns {number} 距离分数
|
||||
*/
|
||||
calculate_location_score(jobLocation, expectedLocation) {
|
||||
if (!jobLocation) return 50; // 没有地点信息,给中等分数
|
||||
if (!expectedLocation) return 70; // 没有期望地点,给较高分数
|
||||
|
||||
const jobLoc = jobLocation.toLowerCase().trim();
|
||||
const expectedLoc = expectedLocation.toLowerCase().trim();
|
||||
|
||||
// 完全匹配
|
||||
if (jobLoc === expectedLoc) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
// 包含匹配(如:期望"北京",职位"北京朝阳区")
|
||||
if (jobLoc.includes(expectedLoc) || expectedLoc.includes(jobLoc)) {
|
||||
return 90;
|
||||
}
|
||||
|
||||
// 城市匹配(提取城市名)
|
||||
const jobCity = this.extract_city(jobLoc);
|
||||
const expectedCity = this.extract_city(expectedLoc);
|
||||
if (jobCity && expectedCity && jobCity === expectedCity) {
|
||||
return 80;
|
||||
}
|
||||
|
||||
// 部分匹配(如:都包含"北京")
|
||||
if (jobLoc.includes('北京') && expectedLoc.includes('北京')) {
|
||||
return 70;
|
||||
}
|
||||
if (jobLoc.includes('上海') && expectedLoc.includes('上海')) {
|
||||
return 70;
|
||||
}
|
||||
if (jobLoc.includes('深圳') && expectedLoc.includes('深圳')) {
|
||||
return 70;
|
||||
}
|
||||
if (jobLoc.includes('广州') && expectedLoc.includes('广州')) {
|
||||
return 70;
|
||||
}
|
||||
|
||||
// 不匹配
|
||||
return 30;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取城市名
|
||||
* @param {string} location - 地点字符串
|
||||
* @returns {string|null} 城市名
|
||||
*/
|
||||
extract_city(location) {
|
||||
const cities = ['北京', '上海', '深圳', '广州', '杭州', '成都', '南京', '武汉', '西安', '苏州'];
|
||||
for (const city of cities) {
|
||||
if (location.includes(city)) {
|
||||
return city;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算关键字分数(0-100分)
|
||||
* @param {object} jobData - 职位数据
|
||||
* @param {Array} filterKeywords - 过滤关键词列表
|
||||
* @returns {number} 关键字分数
|
||||
*/
|
||||
calculate_keyword_score(jobData, filterKeywords) {
|
||||
if (!filterKeywords || filterKeywords.length === 0) {
|
||||
return 70; // 没有关键词要求,给较高分数
|
||||
}
|
||||
|
||||
const jobText = `${jobData.jobTitle} ${jobData.companyName} ${jobData.jobDescription || ''} ${jobData.keyword || ''}`.toLowerCase();
|
||||
|
||||
// 计算匹配的关键词数量
|
||||
let matchedCount = 0;
|
||||
filterKeywords.forEach(kw => {
|
||||
if (jobText.includes(kw.toLowerCase())) {
|
||||
matchedCount++;
|
||||
}
|
||||
});
|
||||
|
||||
// 计算匹配度百分比
|
||||
const matchRate = matchedCount / filterKeywords.length;
|
||||
return Math.round(matchRate * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算薪资分数(0-100分)
|
||||
* @param {string} jobSalary - 职位薪资
|
||||
* @param {string} expectedSalary - 期望薪资
|
||||
* @param {number} accountMinSalary - 账号配置最低薪资
|
||||
* @param {number} accountMaxSalary - 账号配置最高薪资
|
||||
* @returns {number} 薪资分数
|
||||
*/
|
||||
calculate_salary_score(jobSalary, expectedSalary, accountMinSalary, accountMaxSalary) {
|
||||
if (!jobSalary) return 50; // 没有薪资信息,给中等分数
|
||||
|
||||
const salaryRange = this.parse_salary_range(jobSalary);
|
||||
if (!salaryRange || salaryRange.min === 0) return 50;
|
||||
|
||||
const avgJobSalary = (salaryRange.min + salaryRange.max) / 2;
|
||||
|
||||
// 优先使用账号配置的薪资范围
|
||||
if (accountMinSalary > 0 || accountMaxSalary > 0) {
|
||||
if (accountMinSalary > 0 && salaryRange.max < accountMinSalary) {
|
||||
return 20; // 职位最高薪资低于账号最低要求
|
||||
}
|
||||
if (accountMaxSalary > 0 && salaryRange.min > accountMaxSalary) {
|
||||
return 20; // 职位最低薪资高于账号最高要求
|
||||
}
|
||||
// 在范围内,根据薪资水平给分
|
||||
if (avgJobSalary >= accountMinSalary && (accountMaxSalary === 0 || avgJobSalary <= accountMaxSalary)) {
|
||||
return 100; // 完全符合要求
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有期望薪资,与期望薪资比较
|
||||
if (expectedSalary) {
|
||||
const expected = this.parse_expected_salary(expectedSalary);
|
||||
if (expected) {
|
||||
const ratio = expected / avgJobSalary;
|
||||
if (ratio <= 0.8) {
|
||||
return 100; // 期望薪资低于职位薪资,完全匹配
|
||||
} else if (ratio <= 1.0) {
|
||||
return 90; // 期望薪资略低于或等于职位薪资
|
||||
} else if (ratio <= 1.2) {
|
||||
return 70; // 期望薪资略高于职位薪资
|
||||
} else {
|
||||
return 50; // 期望薪资明显高于职位薪资
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 没有期望薪资和账号配置,给中等分数
|
||||
return 60;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析期望薪资
|
||||
* @param {string} expectedSalary - 期望薪资描述
|
||||
* @returns {number|null} 期望薪资数值(元)
|
||||
*/
|
||||
parse_expected_salary(expectedSalary) {
|
||||
if (!expectedSalary) return null;
|
||||
|
||||
// 匹配数字+K格式(如:20K)
|
||||
const match = expectedSalary.match(/(\d+)[kK千]/);
|
||||
if (match) {
|
||||
return parseInt(match[1]) * 1000;
|
||||
}
|
||||
|
||||
// 匹配纯数字(如:20000)
|
||||
const numMatch = expectedSalary.match(/(\d+)/);
|
||||
if (numMatch) {
|
||||
return parseInt(numMatch[1]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算公司规模分数(0-100分)
|
||||
* @param {string} companySize - 公司规模
|
||||
* @returns {number} 公司规模分数
|
||||
*/
|
||||
calculate_company_size_score(companySize) {
|
||||
if (!companySize) return 60; // 没有规模信息,给中等分数
|
||||
|
||||
const size = companySize.toLowerCase();
|
||||
|
||||
// 大型公司(1000人以上)给高分
|
||||
if (size.includes('1000') || size.includes('1000+') || size.includes('1000人以上')) {
|
||||
return 100;
|
||||
}
|
||||
if (size.includes('500-1000') || size.includes('500人以上')) {
|
||||
return 90;
|
||||
}
|
||||
|
||||
// 中型公司(100-500人)给中高分
|
||||
if (size.includes('100-500') || size.includes('100人以上')) {
|
||||
return 80;
|
||||
}
|
||||
if (size.includes('50-100')) {
|
||||
return 70;
|
||||
}
|
||||
|
||||
// 小型公司(50人以下)给中低分
|
||||
if (size.includes('20-50') || size.includes('20人以上')) {
|
||||
return 60;
|
||||
}
|
||||
if (size.includes('0-20') || size.includes('20人以下')) {
|
||||
return 50;
|
||||
}
|
||||
|
||||
// 默认中等分数
|
||||
return 60;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析薪资范围
|
||||
* @param {string} salaryDesc - 薪资描述(如 "15-25K·14薪")
|
||||
* @returns {object} 薪资范围 { min, max }
|
||||
*/
|
||||
parse_salary_range(salaryDesc) {
|
||||
if (!salaryDesc) return { min: 0, max: 0 };
|
||||
|
||||
// 匹配常见格式:15-25K, 15K-25K, 15-25k·14薪
|
||||
const match = salaryDesc.match(/(\d+)[-~](\d+)[kK]/);
|
||||
if (match) {
|
||||
return {
|
||||
min: parseInt(match[1]) * 1000,
|
||||
max: parseInt(match[2]) * 1000
|
||||
};
|
||||
}
|
||||
|
||||
// 匹配单个数值:25K
|
||||
const singleMatch = salaryDesc.match(/(\d+)[kK]/);
|
||||
if (singleMatch) {
|
||||
const value = parseInt(singleMatch[1]) * 1000;
|
||||
return { min: value, max: value };
|
||||
}
|
||||
|
||||
return { min: 0, max: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 延迟函数
|
||||
* @param {number} ms - 毫秒数
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
module.exports = new JobManager();
|
||||
722
api/middleware/job/job_filter_service.js
Normal file
722
api/middleware/job/job_filter_service.js
Normal file
@@ -0,0 +1,722 @@
|
||||
/**
|
||||
* 职位文本匹配过滤服务
|
||||
* 使用简单的文本匹配规则来过滤职位,替代 AI 分析(二期规划)
|
||||
* 支持从数据库动态获取职位类型的技能关键词和排除关键词
|
||||
*/
|
||||
|
||||
const db = require('../dbProxy.js');
|
||||
const locationService = require('../../services/locationService');
|
||||
|
||||
class JobFilterService {
|
||||
constructor() {
|
||||
// 默认技能关键词(当没有职位类型或获取失败时使用)
|
||||
this.defaultCommonSkills = [
|
||||
'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'
|
||||
];
|
||||
|
||||
// 默认排除关键词(当没有职位类型或获取失败时使用)
|
||||
this.defaultExcludeKeywords = [
|
||||
'外包', '外派', '驻场', '销售', '客服', '电话销售',
|
||||
'地推', '推广', '市场', '运营', '行政', '文员'
|
||||
];
|
||||
|
||||
// 缓存职位类型配置(避免频繁查询数据库)
|
||||
this.jobTypeCache = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据职位类型ID获取技能关键词和排除关键词
|
||||
* @param {number} jobTypeId - 职位类型ID
|
||||
* @returns {Promise<{commonSkills: Array, excludeKeywords: Array}>}
|
||||
*/
|
||||
async getJobTypeConfig(jobTypeId) {
|
||||
if (!jobTypeId) {
|
||||
return {
|
||||
commonSkills: this.defaultCommonSkills,
|
||||
excludeKeywords: this.defaultExcludeKeywords
|
||||
};
|
||||
}
|
||||
|
||||
// 检查缓存
|
||||
if (this.jobTypeCache.has(jobTypeId)) {
|
||||
return this.jobTypeCache.get(jobTypeId);
|
||||
}
|
||||
|
||||
try {
|
||||
const job_types = db.getModel('job_types');
|
||||
if (!job_types) {
|
||||
console.warn('[职位过滤服务] job_types 模型不存在,使用默认配置');
|
||||
return {
|
||||
commonSkills: this.defaultCommonSkills,
|
||||
excludeKeywords: this.defaultExcludeKeywords
|
||||
};
|
||||
}
|
||||
|
||||
const jobType = await job_types.findOne({
|
||||
where: { id: jobTypeId, is_enabled: 1 }
|
||||
});
|
||||
|
||||
if (!jobType) {
|
||||
console.warn(`[职位过滤服务] 职位类型 ${jobTypeId} 不存在或已禁用,使用默认配置`);
|
||||
return {
|
||||
commonSkills: this.defaultCommonSkills,
|
||||
excludeKeywords: this.defaultExcludeKeywords
|
||||
};
|
||||
}
|
||||
|
||||
const jobTypeData = jobType.toJSON();
|
||||
|
||||
// 解析 JSON 字段
|
||||
let commonSkills = this.defaultCommonSkills;
|
||||
let excludeKeywords = this.defaultExcludeKeywords;
|
||||
|
||||
if (jobTypeData.commonSkills) {
|
||||
try {
|
||||
const parsed = typeof jobTypeData.commonSkills === 'string'
|
||||
? JSON.parse(jobTypeData.commonSkills)
|
||||
: jobTypeData.commonSkills;
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
commonSkills = parsed;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[职位过滤服务] 解析 commonSkills 失败:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (jobTypeData.excludeKeywords) {
|
||||
try {
|
||||
const parsed = typeof jobTypeData.excludeKeywords === 'string'
|
||||
? JSON.parse(jobTypeData.excludeKeywords)
|
||||
: jobTypeData.excludeKeywords;
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
excludeKeywords = parsed;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[职位过滤服务] 解析 excludeKeywords 失败:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
const config = {
|
||||
commonSkills,
|
||||
excludeKeywords
|
||||
};
|
||||
|
||||
// 缓存配置(缓存5分钟)
|
||||
this.jobTypeCache.set(jobTypeId, config);
|
||||
setTimeout(() => {
|
||||
this.jobTypeCache.delete(jobTypeId);
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
return config;
|
||||
} catch (error) {
|
||||
console.error(`[职位过滤服务] 获取职位类型配置失败:`, error);
|
||||
return {
|
||||
commonSkills: this.defaultCommonSkills,
|
||||
excludeKeywords: this.defaultExcludeKeywords
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除职位类型缓存
|
||||
* @param {number} jobTypeId - 职位类型ID(可选,不传则清除所有缓存)
|
||||
*/
|
||||
clearCache(jobTypeId = null) {
|
||||
if (jobTypeId) {
|
||||
this.jobTypeCache.delete(jobTypeId);
|
||||
} else {
|
||||
this.jobTypeCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用文本匹配分析职位与简历的匹配度
|
||||
* @param {object} jobInfo - 职位信息
|
||||
* @param {object} resumeInfo - 简历信息(可选)
|
||||
* @param {number} jobTypeId - 职位类型ID(可选)
|
||||
* @returns {Promise<object>} 匹配度分析结果
|
||||
*/
|
||||
async analyzeJobMatch(jobInfo, resumeInfo = {}, jobTypeId = null) {
|
||||
const jobText = this.buildJobText(jobInfo);
|
||||
const resumeText = this.buildResumeText(resumeInfo);
|
||||
|
||||
// 获取职位类型配置
|
||||
const { commonSkills, excludeKeywords } = await this.getJobTypeConfig(jobTypeId);
|
||||
|
||||
// 1. 技能匹配度(0-100分)
|
||||
const skillScore = this.calculateSkillMatch(jobText, resumeText, commonSkills);
|
||||
|
||||
// 2. 经验匹配度(0-100分)
|
||||
const experienceScore = this.calculateExperienceMatch(jobInfo, resumeInfo);
|
||||
|
||||
// 3. 薪资合理性(0-100分)
|
||||
const salaryScore = this.calculateSalaryMatch(jobInfo, resumeInfo);
|
||||
|
||||
// 4. 是否为外包岗位
|
||||
const isOutsourcing = this.checkOutsourcing(jobText);
|
||||
|
||||
// 5. 综合推荐指数(加权平均)
|
||||
const overallScore = Math.round(
|
||||
skillScore * 0.4 +
|
||||
experienceScore * 0.3 +
|
||||
salaryScore * 0.3
|
||||
);
|
||||
|
||||
// 6. 匹配原因
|
||||
const matchReasons = this.getMatchReasons(jobText, resumeText, skillScore, experienceScore, commonSkills);
|
||||
|
||||
// 7. 关注点
|
||||
const concerns = this.getConcerns(jobInfo, resumeInfo, isOutsourcing, excludeKeywords);
|
||||
|
||||
// 8. 投递建议
|
||||
const suggestion = this.getSuggestion(overallScore, isOutsourcing, concerns);
|
||||
|
||||
return {
|
||||
skillMatch: skillScore,
|
||||
experienceMatch: experienceScore,
|
||||
salaryMatch: salaryScore,
|
||||
isOutsourcing: isOutsourcing,
|
||||
overallScore: overallScore,
|
||||
matchReasons: matchReasons,
|
||||
concerns: concerns,
|
||||
suggestion: suggestion,
|
||||
analysis: {
|
||||
skillMatch: skillScore,
|
||||
experienceMatch: experienceScore,
|
||||
salaryMatch: salaryScore,
|
||||
isOutsourcing: isOutsourcing,
|
||||
overallScore: overallScore,
|
||||
matchReasons: matchReasons,
|
||||
concerns: concerns,
|
||||
suggestion: suggestion
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建职位文本(用于匹配)
|
||||
* @param {object} jobInfo - 职位信息
|
||||
* @returns {string} 职位文本
|
||||
*/
|
||||
buildJobText(jobInfo) {
|
||||
const parts = [
|
||||
jobInfo.jobTitle || '',
|
||||
jobInfo.companyName || '',
|
||||
jobInfo.description || '',
|
||||
jobInfo.skills || '',
|
||||
jobInfo.requirements || ''
|
||||
];
|
||||
return parts.join(' ').toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建简历文本(用于匹配)
|
||||
* @param {object} resumeInfo - 简历信息
|
||||
* @returns {string} 简历文本
|
||||
*/
|
||||
buildResumeText(resumeInfo) {
|
||||
const parts = [
|
||||
resumeInfo.skills || '',
|
||||
resumeInfo.skillDescription || '',
|
||||
resumeInfo.currentPosition || '',
|
||||
resumeInfo.expectedPosition || ''
|
||||
];
|
||||
return parts.join(' ').toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算技能匹配度
|
||||
* @param {string} jobText - 职位文本
|
||||
* @param {string} resumeText - 简历文本
|
||||
* @param {Array} commonSkills - 技能关键词列表
|
||||
* @returns {number} 匹配度分数(0-100)
|
||||
*/
|
||||
calculateSkillMatch(jobText, resumeText, commonSkills = null) {
|
||||
const skills = commonSkills || this.defaultCommonSkills;
|
||||
|
||||
if (!resumeText || resumeText.trim() === '') {
|
||||
// 如果没有简历信息,基于职位关键词匹配
|
||||
let matchedSkills = 0;
|
||||
skills.forEach(skill => {
|
||||
if (jobText.includes(skill.toLowerCase())) {
|
||||
matchedSkills++;
|
||||
}
|
||||
});
|
||||
return Math.min(100, matchedSkills * 10);
|
||||
}
|
||||
|
||||
// 提取职位中的技能关键词
|
||||
const jobSkills = [];
|
||||
skills.forEach(skill => {
|
||||
if (jobText.includes(skill.toLowerCase())) {
|
||||
jobSkills.push(skill);
|
||||
}
|
||||
});
|
||||
|
||||
if (jobSkills.length === 0) {
|
||||
return 50; // 如果没有明确技能要求,给中等分数
|
||||
}
|
||||
|
||||
// 计算简历中匹配的技能数量
|
||||
let matchedCount = 0;
|
||||
jobSkills.forEach(skill => {
|
||||
if (resumeText.includes(skill.toLowerCase())) {
|
||||
matchedCount++;
|
||||
}
|
||||
});
|
||||
|
||||
// 计算匹配度百分比
|
||||
const matchRate = matchedCount / jobSkills.length;
|
||||
return Math.round(matchRate * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算经验匹配度
|
||||
* @param {object} jobInfo - 职位信息
|
||||
* @param {object} resumeInfo - 简历信息
|
||||
* @returns {number} 匹配度分数(0-100)
|
||||
*/
|
||||
calculateExperienceMatch(jobInfo, resumeInfo) {
|
||||
if (!resumeInfo || !resumeInfo.workYears) {
|
||||
return 60; // 默认中等分数
|
||||
}
|
||||
|
||||
const jobText = this.buildJobText(jobInfo);
|
||||
const resumeYears = parseInt(resumeInfo.workYears) || 0;
|
||||
|
||||
// 从职位描述中提取经验要求
|
||||
const experienceKeywords = {
|
||||
'应届': 0,
|
||||
'1年': 1,
|
||||
'2年': 2,
|
||||
'3年': 3,
|
||||
'5年': 5,
|
||||
'10年': 10
|
||||
};
|
||||
|
||||
let requiredYears = null;
|
||||
for (const [keyword, years] of Object.entries(experienceKeywords)) {
|
||||
if (jobText.includes(keyword)) {
|
||||
requiredYears = years;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (requiredYears === null) {
|
||||
return 70; // 没有明确要求,给较高分数
|
||||
}
|
||||
|
||||
// 计算匹配度
|
||||
if (resumeYears >= requiredYears) {
|
||||
return 100; // 经验满足要求
|
||||
} else if (resumeYears >= requiredYears - 1) {
|
||||
return 80; // 经验略低于要求
|
||||
} else {
|
||||
return Math.max(30, 100 - (requiredYears - resumeYears) * 15); // 经验不足
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算薪资合理性
|
||||
* @param {object} jobInfo - 职位信息
|
||||
* @param {object} resumeInfo - 简历信息
|
||||
* @returns {number} 匹配度分数(0-100)
|
||||
*/
|
||||
calculateSalaryMatch(jobInfo, resumeInfo) {
|
||||
if (!jobInfo.salary) {
|
||||
return 60; // 没有薪资信息,给中等分数
|
||||
}
|
||||
|
||||
const salaryRange = this.parseSalaryRange(jobInfo.salary);
|
||||
if (!salaryRange) {
|
||||
return 60;
|
||||
}
|
||||
|
||||
if (!resumeInfo || !resumeInfo.expectedSalary) {
|
||||
return 70; // 没有期望薪资,给较高分数
|
||||
}
|
||||
|
||||
const expectedSalary = this.parseExpectedSalary(resumeInfo.expectedSalary);
|
||||
if (!expectedSalary) {
|
||||
return 70;
|
||||
}
|
||||
|
||||
// 计算匹配度
|
||||
const avgJobSalary = (salaryRange.min + salaryRange.max) / 2;
|
||||
const ratio = expectedSalary / avgJobSalary;
|
||||
|
||||
if (ratio <= 0.8) {
|
||||
return 100; // 期望薪资低于职位薪资,完全匹配
|
||||
} else if (ratio <= 1.0) {
|
||||
return 90; // 期望薪资略低于或等于职位薪资
|
||||
} else if (ratio <= 1.2) {
|
||||
return 70; // 期望薪资略高于职位薪资
|
||||
} else {
|
||||
return 50; // 期望薪资明显高于职位薪资
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为外包岗位
|
||||
* @param {string} jobText - 职位文本
|
||||
* @returns {boolean} 是否为外包
|
||||
*/
|
||||
checkOutsourcing(jobText) {
|
||||
const outsourcingKeywords = ['外包', '外派', '驻场', '人力外包', '项目外包'];
|
||||
return outsourcingKeywords.some(keyword => jobText.includes(keyword));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取匹配原因
|
||||
* @param {string} jobText - 职位文本
|
||||
* @param {string} resumeText - 简历文本
|
||||
* @param {number} skillScore - 技能匹配度
|
||||
* @param {number} experienceScore - 经验匹配度
|
||||
* @param {Array} commonSkills - 技能关键词列表
|
||||
* @returns {Array<string>} 匹配原因列表
|
||||
*/
|
||||
getMatchReasons(jobText, resumeText, skillScore, experienceScore, commonSkills = null) {
|
||||
const skills = commonSkills || this.defaultCommonSkills;
|
||||
const reasons = [];
|
||||
|
||||
if (skillScore >= 80) {
|
||||
reasons.push('技能匹配度高');
|
||||
} else if (skillScore >= 60) {
|
||||
reasons.push('技能部分匹配');
|
||||
}
|
||||
|
||||
if (experienceScore >= 80) {
|
||||
reasons.push('工作经验符合要求');
|
||||
}
|
||||
|
||||
// 检查是否有共同技能
|
||||
const matchedSkills = [];
|
||||
skills.forEach(skill => {
|
||||
if (jobText.includes(skill.toLowerCase()) &&
|
||||
resumeText.includes(skill.toLowerCase())) {
|
||||
matchedSkills.push(skill);
|
||||
}
|
||||
});
|
||||
|
||||
if (matchedSkills.length > 0) {
|
||||
reasons.push(`共同技能: ${matchedSkills.slice(0, 3).join(', ')}`);
|
||||
}
|
||||
|
||||
return reasons.length > 0 ? reasons : ['基础匹配'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取关注点
|
||||
* @param {object} jobInfo - 职位信息
|
||||
* @param {object} resumeInfo - 简历信息
|
||||
* @param {boolean} isOutsourcing - 是否为外包
|
||||
* @param {Array} excludeKeywords - 排除关键词列表
|
||||
* @returns {Array<string>} 关注点列表
|
||||
*/
|
||||
getConcerns(jobInfo, resumeInfo, isOutsourcing, excludeKeywords = null) {
|
||||
const keywords = excludeKeywords || this.defaultExcludeKeywords;
|
||||
const concerns = [];
|
||||
|
||||
if (isOutsourcing) {
|
||||
concerns.push('可能是外包岗位');
|
||||
}
|
||||
|
||||
const jobText = this.buildJobText(jobInfo);
|
||||
keywords.forEach(keyword => {
|
||||
if (jobText.includes(keyword)) {
|
||||
concerns.push(`包含排除关键词: ${keyword}`);
|
||||
}
|
||||
});
|
||||
|
||||
if (resumeInfo && resumeInfo.workYears) {
|
||||
const resumeYears = parseInt(resumeInfo.workYears) || 0;
|
||||
if (jobText.includes('5年') && resumeYears < 5) {
|
||||
concerns.push('工作经验可能不足');
|
||||
}
|
||||
}
|
||||
|
||||
return concerns;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取投递建议
|
||||
* @param {number} overallScore - 综合分数
|
||||
* @param {boolean} isOutsourcing - 是否为外包
|
||||
* @param {Array<string>} concerns - 关注点
|
||||
* @returns {string} 投递建议
|
||||
*/
|
||||
getSuggestion(overallScore, isOutsourcing, concerns) {
|
||||
if (isOutsourcing) {
|
||||
return '谨慎投递:可能是外包岗位';
|
||||
}
|
||||
|
||||
if (concerns.length > 2) {
|
||||
return '不推荐投递:存在多个关注点';
|
||||
}
|
||||
|
||||
if (overallScore >= 80) {
|
||||
return '强烈推荐投递:匹配度很高';
|
||||
} else if (overallScore >= 60) {
|
||||
return '可以投递:匹配度中等';
|
||||
} else {
|
||||
return '谨慎投递:匹配度较低';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析薪资范围
|
||||
* @param {string} salaryDesc - 薪资描述(如 "15-25K·14薪")
|
||||
* @returns {object|null} 薪资范围 { min, max }
|
||||
*/
|
||||
parseSalaryRange(salaryDesc) {
|
||||
if (!salaryDesc) return null;
|
||||
|
||||
// 匹配 "15-25K" 或 "15K-25K" 格式
|
||||
const match = salaryDesc.match(/(\d+)[-~](\d+)[Kk千]/);
|
||||
if (match) {
|
||||
return {
|
||||
min: parseInt(match[1]) * 1000,
|
||||
max: parseInt(match[2]) * 1000
|
||||
};
|
||||
}
|
||||
|
||||
// 匹配单个数字 "20K"
|
||||
const singleMatch = salaryDesc.match(/(\d+)[Kk千]/);
|
||||
if (singleMatch) {
|
||||
const value = parseInt(singleMatch[1]) * 1000;
|
||||
return { min: value, max: value };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析期望薪资
|
||||
* @param {string} expectedSalary - 期望薪资描述
|
||||
* @returns {number|null} 期望薪资数值
|
||||
*/
|
||||
parseExpectedSalary(expectedSalary) {
|
||||
if (!expectedSalary) return null;
|
||||
|
||||
// 匹配数字+K格式
|
||||
const match = expectedSalary.match(/(\d+)[Kk千]/);
|
||||
if (match) {
|
||||
return parseInt(match[1]) * 1000;
|
||||
}
|
||||
|
||||
// 匹配纯数字
|
||||
const numMatch = expectedSalary.match(/(\d+)/);
|
||||
if (numMatch) {
|
||||
return parseInt(numMatch[1]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤职位列表(基于文本匹配)
|
||||
* @param {Array} jobs - 职位列表
|
||||
* @param {object} filterRules - 过滤规则
|
||||
* @param {object} resumeInfo - 简历信息(可选)
|
||||
* @param {number} jobTypeId - 职位类型ID(可选)
|
||||
* @returns {Promise<Array>} 过滤后的职位列表(带匹配分数)
|
||||
*/
|
||||
async filterJobs(jobs, filterRules = {}, resumeInfo = {}, jobTypeId = null) {
|
||||
const {
|
||||
minScore = 60, // 最低匹配分数
|
||||
excludeOutsourcing = true, // 是否排除外包
|
||||
excludeKeywords = [] // 额外排除关键词
|
||||
} = filterRules;
|
||||
|
||||
// 获取职位类型配置
|
||||
const { excludeKeywords: typeExcludeKeywords } = await this.getJobTypeConfig(jobTypeId);
|
||||
const allExcludeKeywords = [...typeExcludeKeywords, ...excludeKeywords];
|
||||
|
||||
const results = [];
|
||||
for (const job of jobs) {
|
||||
const jobData = job.toJSON ? job.toJSON() : job;
|
||||
|
||||
// 分析匹配度
|
||||
const analysis = await this.analyzeJobMatch(jobData, resumeInfo, jobTypeId);
|
||||
|
||||
results.push({
|
||||
...jobData,
|
||||
matchScore: analysis.overallScore,
|
||||
matchAnalysis: analysis
|
||||
});
|
||||
}
|
||||
|
||||
return results
|
||||
.filter(job => {
|
||||
// 1. 最低分数过滤
|
||||
if (job.matchScore < minScore) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 外包过滤
|
||||
if (excludeOutsourcing && job.matchAnalysis.isOutsourcing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 排除关键词过滤
|
||||
const jobText = this.buildJobText(job);
|
||||
if (allExcludeKeywords.some(kw => jobText.includes(kw.toLowerCase()))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => b.matchScore - a.matchScore); // 按匹配分数降序排序
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据自定义权重配置计算职位评分
|
||||
* @param {Object} jobData - 职位数据
|
||||
* @param {Object} resumeInfo - 简历信息
|
||||
* @param {Object} accountConfig - 账号配置(包含 user_longitude, user_latitude)
|
||||
* @param {Object} jobTypeConfig - 职位类型配置(可选)
|
||||
* @param {Array} priorityWeights - 权重配置 [{key: "distance", weight: 50}, ...]
|
||||
* @returns {Object} 评分结果 {totalScore, scores}
|
||||
*/
|
||||
calculateJobScoreWithWeights(jobData, resumeInfo, accountConfig, jobTypeConfig, priorityWeights) {
|
||||
const scores = {};
|
||||
let totalScore = 0;
|
||||
|
||||
// 解析权重配置
|
||||
const weights = {};
|
||||
priorityWeights.forEach(item => {
|
||||
weights[item.key] = item.weight / 100; // 转换为小数
|
||||
});
|
||||
|
||||
// 1. 距离评分(基于经纬度)
|
||||
if (weights.distance) {
|
||||
let distanceScore = 50; // 默认分数
|
||||
if (accountConfig.user_longitude && accountConfig.user_latitude &&
|
||||
jobData.longitude && jobData.latitude) {
|
||||
try {
|
||||
const userLon = parseFloat(accountConfig.user_longitude);
|
||||
const userLat = parseFloat(accountConfig.user_latitude);
|
||||
const jobLon = parseFloat(jobData.longitude);
|
||||
const jobLat = parseFloat(jobData.latitude);
|
||||
|
||||
if (!isNaN(userLon) && !isNaN(userLat) && !isNaN(jobLon) && !isNaN(jobLat)) {
|
||||
const distance = locationService.calculateDistance(userLat, userLon, jobLat, jobLon);
|
||||
// 距离越近分数越高:0-5km=100分,5-10km=90分,10-20km=80分,20-50km=60分,50km以上=30分
|
||||
if (distance <= 5) distanceScore = 100;
|
||||
else if (distance <= 10) distanceScore = 90;
|
||||
else if (distance <= 20) distanceScore = 80;
|
||||
else if (distance <= 50) distanceScore = 60;
|
||||
else distanceScore = 30;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[职位过滤服务] 计算距离失败:`, e);
|
||||
}
|
||||
}
|
||||
scores.distance = distanceScore;
|
||||
totalScore += distanceScore * weights.distance;
|
||||
}
|
||||
|
||||
// 2. 薪资评分
|
||||
if (weights.salary) {
|
||||
let salaryScore = 50;
|
||||
if (jobData.salary && resumeInfo.expectedSalary) {
|
||||
const jobSalary = this.parseExpectedSalary(jobData.salary);
|
||||
const expectedSalary = this.parseExpectedSalary(resumeInfo.expectedSalary);
|
||||
if (jobSalary && expectedSalary) {
|
||||
if (jobSalary >= expectedSalary) salaryScore = 100;
|
||||
else if (jobSalary >= expectedSalary * 0.8) salaryScore = 80;
|
||||
else if (jobSalary >= expectedSalary * 0.6) salaryScore = 60;
|
||||
else salaryScore = 40;
|
||||
}
|
||||
}
|
||||
scores.salary = salaryScore;
|
||||
totalScore += salaryScore * weights.salary;
|
||||
}
|
||||
|
||||
// 3. 工作年限评分
|
||||
if (weights.work_years) {
|
||||
let workYearsScore = 50;
|
||||
if (jobData.experience && resumeInfo.workYears) {
|
||||
const jobExp = this.parseWorkYears(jobData.experience);
|
||||
const resumeExp = this.parseWorkYears(resumeInfo.workYears);
|
||||
if (jobExp !== null && resumeExp !== null) {
|
||||
if (resumeExp >= jobExp) workYearsScore = 100;
|
||||
else if (resumeExp >= jobExp * 0.8) workYearsScore = 80;
|
||||
else if (resumeExp >= jobExp * 0.6) workYearsScore = 60;
|
||||
else workYearsScore = 40;
|
||||
}
|
||||
}
|
||||
scores.work_years = workYearsScore;
|
||||
totalScore += workYearsScore * weights.work_years;
|
||||
}
|
||||
|
||||
// 4. 学历评分
|
||||
if (weights.education) {
|
||||
let educationScore = 50;
|
||||
if (jobData.education && resumeInfo.education) {
|
||||
const educationLevels = { '博士': 7, '硕士': 6, '本科': 5, '大专': 4, '高中': 3, '中专': 2, '初中': 1 };
|
||||
const jobLevel = educationLevels[jobData.education] || 0;
|
||||
const resumeLevel = educationLevels[resumeInfo.education] || 0;
|
||||
if (resumeLevel >= jobLevel) educationScore = 100;
|
||||
else if (resumeLevel >= jobLevel - 1) educationScore = 70;
|
||||
else educationScore = 40;
|
||||
}
|
||||
scores.education = educationScore;
|
||||
totalScore += educationScore * weights.education;
|
||||
}
|
||||
|
||||
// 5. 技能匹配评分(基于职位类型的 commonSkills)
|
||||
if (jobTypeConfig && jobTypeConfig.commonSkills) {
|
||||
let skillScore = 0;
|
||||
try {
|
||||
const commonSkills = typeof jobTypeConfig.commonSkills === 'string'
|
||||
? JSON.parse(jobTypeConfig.commonSkills)
|
||||
: jobTypeConfig.commonSkills;
|
||||
const resumeSkills = typeof resumeInfo.skills === 'string'
|
||||
? JSON.parse(resumeInfo.skills || '[]')
|
||||
: (resumeInfo.skills || []);
|
||||
|
||||
if (Array.isArray(commonSkills) && Array.isArray(resumeSkills)) {
|
||||
const matchedSkills = commonSkills.filter(skill =>
|
||||
resumeSkills.some(rs => rs.toLowerCase().includes(skill.toLowerCase()) ||
|
||||
skill.toLowerCase().includes(rs.toLowerCase()))
|
||||
);
|
||||
skillScore = (matchedSkills.length / commonSkills.length) * 100;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[职位过滤服务] 解析技能失败:`, e);
|
||||
}
|
||||
scores.skills = skillScore;
|
||||
// 技能评分作为额外加分项,权重10%
|
||||
totalScore += skillScore * 0.1;
|
||||
}
|
||||
|
||||
return {
|
||||
totalScore: Math.round(totalScore),
|
||||
scores
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析工作年限字符串为数字
|
||||
* @param {string} workYearsStr - 工作年限字符串
|
||||
* @returns {number|null} 工作年限数字
|
||||
*/
|
||||
parseWorkYears(workYearsStr) {
|
||||
if (!workYearsStr) return null;
|
||||
const match = workYearsStr.match(/(\d+)/);
|
||||
if (match) return parseInt(match[1]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
module.exports = new JobFilterService();
|
||||
|
||||
555
api/middleware/job/resumeManager.js
Normal file
555
api/middleware/job/resumeManager.js
Normal file
@@ -0,0 +1,555 @@
|
||||
const aiService = require('./aiService');
|
||||
const jobFilterService = require('./job_filter_service');
|
||||
const logs = require('../logProxy');
|
||||
const db = require('../dbProxy');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
/**
|
||||
* 简历管理模块
|
||||
* 负责简历获取、分析、存储和匹配度计算
|
||||
*/
|
||||
class ResumeManager {
|
||||
constructor() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户在线简历
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} mqttClient - MQTT客户端
|
||||
* @param {object} params - 参数
|
||||
* @returns {Promise<object>} 简历信息
|
||||
*/
|
||||
async get_online_resume(sn_code, mqttClient, params = {}) {
|
||||
console.log(`[简历管理] 开始获取设备 ${sn_code} 的在线简历`);
|
||||
|
||||
const { platform = 'boss' } = params;
|
||||
|
||||
// 通过MQTT指令获取简历信息
|
||||
const response = await mqttClient.publishAndWait(sn_code, {
|
||||
platform: platform,
|
||||
action: 'get_online_resume',
|
||||
data: {}
|
||||
});
|
||||
|
||||
if (!response || response.code !== 200) {
|
||||
console.error(`[简历管理] 获取简历失败:`, response);
|
||||
throw new Error(response?.message || '获取简历失败');
|
||||
}
|
||||
|
||||
const resumeData = response.data;
|
||||
console.log(`[简历管理] 成功获取简历数据:`, resumeData);
|
||||
|
||||
// 存储用户在线简历数据到数据库
|
||||
try {
|
||||
await this.save_resume_to_database(sn_code, platform, resumeData);
|
||||
console.log(`[简历管理] 简历数据已保存到数据库`);
|
||||
} catch (error) {
|
||||
console.error(`[简历管理] 保存简历数据失败:`, error);
|
||||
// 不抛出错误,继续返回数据
|
||||
}
|
||||
|
||||
return resumeData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存简历数据到数据库
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {string} platform - 平台名称
|
||||
* @param {object} resumeData - 简历数据
|
||||
* @returns {Promise<object>} 保存结果
|
||||
*/
|
||||
async save_resume_to_database(sn_code, platform, resumeData) {
|
||||
const resume_info = db.getModel('resume_info');
|
||||
const pla_account = db.getModel('pla_account');
|
||||
|
||||
// 通过 sn_code 查询对应的 pla_account 获取 account_id
|
||||
const account = await pla_account.findOne({
|
||||
where: { sn_code, platform_type: platform }
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
throw new Error(`未找到设备 ${sn_code} 在平台 ${platform} 的账户信息`);
|
||||
}
|
||||
|
||||
// 确保 account_id 是字符串类型(模型定义为 STRING(50))
|
||||
const account_id = String(account.id || ''); // pla_account 的自增ID,转换为字符串
|
||||
|
||||
// 提取基本信息
|
||||
const baseInfo = resumeData.baseInfo || {};
|
||||
const expectList = resumeData.expectList || [];
|
||||
const workExpList = resumeData.workExpList || [];
|
||||
const projectExpList = resumeData.projectExpList || [];
|
||||
const educationExpList = resumeData.educationExpList || [];
|
||||
const userDesc = resumeData.userDesc || '';
|
||||
|
||||
// 查找是否已存在简历
|
||||
const existingResume = await resume_info.findOne({
|
||||
where: { sn_code, platform, isActive: true }
|
||||
});
|
||||
|
||||
// 如果已存在简历,使用数据库中的 resumeId(可能是字符串或数字)
|
||||
// 如果不存在,生成新的 UUID 字符串
|
||||
const resumeId = existingResume ? String(existingResume.resumeId || existingResume.id || uuidv4()) : uuidv4();
|
||||
|
||||
// 提取技能标签(从个人优势中提取)
|
||||
const skills = this.extract_skills_from_desc(userDesc);
|
||||
|
||||
// 获取期望信息
|
||||
const expectInfo = expectList[0] || {};
|
||||
|
||||
// 获取最新工作经验
|
||||
const latestWork = workExpList[0] || {};
|
||||
|
||||
// 获取最高学历
|
||||
const highestEdu = educationExpList[0] || {};
|
||||
|
||||
// 构建简历信息对象
|
||||
const resumeInfo = {
|
||||
sn_code,
|
||||
account_id, // 使用 pla_account 的自增ID
|
||||
platform,
|
||||
resumeId: resumeId,
|
||||
// 个人信息
|
||||
fullName: String(baseInfo.name || ''),
|
||||
gender: baseInfo.gender === 1 ? '男' : baseInfo.gender === 0 ? '女' : '',
|
||||
age: parseInt(String(baseInfo.age || 0), 10) || 0, // 确保是整数
|
||||
phone: String(baseInfo.account || ''),
|
||||
email: String(baseInfo.emailBlur || ''),
|
||||
location: String(expectInfo.locationName || ''),
|
||||
|
||||
// 教育背景
|
||||
education: String(highestEdu.degreeName || baseInfo.degreeCategory || ''),
|
||||
major: String(highestEdu.major || ''),
|
||||
school: String(highestEdu.school || ''),
|
||||
graduationYear: parseInt(String(highestEdu.endYear || 0), 10) || 0, // 确保是整数
|
||||
|
||||
// 工作经验
|
||||
workYears: baseInfo.workYearDesc || `${baseInfo.workYears || 0}年`,
|
||||
currentPosition: latestWork.positionName || '',
|
||||
currentCompany: latestWork.companyName || '',
|
||||
currentSalary: '',
|
||||
|
||||
// 期望信息
|
||||
expectedPosition: expectInfo.positionName || '',
|
||||
expectedSalary: expectInfo.salaryDesc || '',
|
||||
expectedLocation: expectInfo.locationName || '',
|
||||
expectedIndustry: expectInfo.industryDesc || '',
|
||||
|
||||
// 技能和专长
|
||||
skills: JSON.stringify(skills),
|
||||
skillDescription: userDesc,
|
||||
certifications: JSON.stringify(resumeData.certificationList || []),
|
||||
|
||||
// 项目经验
|
||||
projectExperience: JSON.stringify(projectExpList.map(p => ({
|
||||
name: p.name,
|
||||
role: p.roleName,
|
||||
startDate: p.startDate,
|
||||
endDate: p.endDate,
|
||||
description: p.projectDesc,
|
||||
performance: p.performance
|
||||
}))),
|
||||
|
||||
// 工作经历
|
||||
workExperience: JSON.stringify(workExpList.map(w => ({
|
||||
company: w.companyName,
|
||||
position: w.positionName,
|
||||
startDate: w.startDate,
|
||||
endDate: w.endDate,
|
||||
content: w.workContent,
|
||||
industry: w.industry?.name
|
||||
}))),
|
||||
|
||||
// 简历内容
|
||||
resumeContent: userDesc,
|
||||
|
||||
// 原始数据
|
||||
originalData: JSON.stringify(resumeData),
|
||||
|
||||
// 状态信息
|
||||
isActive: true,
|
||||
isPublic: true,
|
||||
syncTime: new Date()
|
||||
};
|
||||
|
||||
// 保存或更新简历
|
||||
if (existingResume) {
|
||||
await resume_info.update(resumeInfo, { where: { resumeId: resumeId } });
|
||||
console.log(`[简历管理] 简历已更新 - ID: ${resumeId}`);
|
||||
} else {
|
||||
await resume_info.create(resumeInfo);
|
||||
console.log(`[简历管理] 简历已创建 - ID: ${resumeId}`);
|
||||
}
|
||||
|
||||
// 二期规划:AI 分析暂时禁用,使用简单的文本匹配
|
||||
console.log(`[简历管理] AI分析已禁用(二期规划),使用文本匹配过滤`);
|
||||
|
||||
return { resumeId, message: existingResume ? '简历更新成功' : '简历创建成功' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 从描述中提取技能标签
|
||||
* @param {string} description - 描述文本
|
||||
* @returns {Array} 技能标签数组
|
||||
*/
|
||||
extract_skills_from_desc(description) {
|
||||
const skills = [];
|
||||
const commonSkills = [
|
||||
'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'
|
||||
];
|
||||
|
||||
commonSkills.forEach(skill => {
|
||||
if (description.includes(skill)) {
|
||||
skills.push(skill);
|
||||
}
|
||||
});
|
||||
|
||||
return [...new Set(skills)]; // 去重
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用AI分析简历
|
||||
* @param {string} resumeId - 简历ID
|
||||
* @param {object} resumeInfo - 简历信息
|
||||
* @returns {Promise<object>} 分析结果
|
||||
*/
|
||||
async analyze_resume_with_ai(resumeId, resumeInfo) {
|
||||
console.log(`[简历管理] 开始AI分析简历 - ID: ${resumeId}`);
|
||||
|
||||
const resume_info = db.getModel('resume_info');
|
||||
|
||||
// 构建分析提示词
|
||||
const prompt = `请分析以下简历,提供专业的评估:
|
||||
|
||||
姓名:${resumeInfo.fullName}
|
||||
工作年限:${resumeInfo.workYears}
|
||||
当前职位:${resumeInfo.currentPosition}
|
||||
期望职位:${resumeInfo.expectedPosition}
|
||||
期望薪资:${resumeInfo.expectedSalary}
|
||||
学历:${resumeInfo.education}
|
||||
技能:${resumeInfo.skills}
|
||||
|
||||
个人优势:
|
||||
${resumeInfo.skillDescription}
|
||||
|
||||
请从以下几个方面进行分析:
|
||||
1. 核心技能标签(提取5-10个关键技能)
|
||||
2. 优势分析(100字以内)
|
||||
3. 劣势分析(100字以内)
|
||||
4. 职业建议(150字以内)
|
||||
5. 竞争力评分(0-100分)`;
|
||||
|
||||
try {
|
||||
// 调用AI服务进行分析
|
||||
const aiAnalysis = await aiService.analyzeResume(prompt);
|
||||
|
||||
// 解析AI返回的结果
|
||||
const analysis = this.parse_ai_analysis(aiAnalysis, resumeInfo);
|
||||
|
||||
// 更新简历的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 } });
|
||||
|
||||
console.log(`[简历管理] AI分析完成 - 竞争力评分: ${analysis.competitiveness}`);
|
||||
|
||||
return analysis;
|
||||
} catch (error) {
|
||||
console.error(`[简历管理] AI分析失败:`, error, {
|
||||
resumeId: resumeId,
|
||||
fullName: resumeInfo.fullName
|
||||
});
|
||||
|
||||
// 如果AI分析失败,使用基于规则的默认分析
|
||||
const defaultAnalysis = this.get_default_analysis(resumeInfo);
|
||||
|
||||
return defaultAnalysis;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析AI分析结果
|
||||
* @param {object} aiResponse - AI响应对象
|
||||
* @param {object} resumeInfo - 简历信息
|
||||
* @returns {object} 解析后的分析结果
|
||||
*/
|
||||
parse_ai_analysis(aiResponse, resumeInfo) {
|
||||
try {
|
||||
// 尝试从AI响应中解析JSON
|
||||
const content = aiResponse.content || aiResponse.analysis?.content || '';
|
||||
|
||||
// 如果AI返回的是JSON格式
|
||||
if (content.includes('{') && content.includes('}')) {
|
||||
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
|
||||
return {
|
||||
skillTags: parsed.skillTags || parsed.技能标签 || [],
|
||||
strengths: parsed.strengths || parsed.优势 || parsed.优势分析 || '',
|
||||
weaknesses: parsed.weaknesses || parsed.劣势 || parsed.劣势分析 || '',
|
||||
careerSuggestion: parsed.careerSuggestion || parsed.职业建议 || '',
|
||||
competitiveness: parsed.competitiveness || parsed.竞争力评分 || 70
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 如果无法解析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
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[简历管理] 解析AI分析结果失败:`, error);
|
||||
// 解析失败时使用默认分析
|
||||
return this.get_default_analysis(resumeInfo);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取默认分析结果(基于规则)
|
||||
* @param {object} resumeInfo - 简历信息
|
||||
* @returns {object} 分析结果
|
||||
*/
|
||||
get_default_analysis(resumeInfo) {
|
||||
const skills = JSON.parse(resumeInfo.skills || '[]');
|
||||
const workYears = parseInt(resumeInfo.workYears) || 0;
|
||||
|
||||
// 计算竞争力评分
|
||||
let competitiveness = 50; // 基础分
|
||||
|
||||
// 工作年限加分
|
||||
if (workYears >= 10) competitiveness += 20;
|
||||
else if (workYears >= 5) competitiveness += 15;
|
||||
else if (workYears >= 3) competitiveness += 10;
|
||||
|
||||
// 技能数量加分
|
||||
if (skills.length >= 10) competitiveness += 15;
|
||||
else if (skills.length >= 5) competitiveness += 10;
|
||||
|
||||
// 学历加分
|
||||
if (resumeInfo.education.includes('硕士')) competitiveness += 10;
|
||||
else if (resumeInfo.education.includes('本科')) competitiveness += 5;
|
||||
|
||||
// 确保分数在0-100之间
|
||||
competitiveness = Math.min(100, Math.max(0, competitiveness));
|
||||
|
||||
return {
|
||||
skillTags: skills.slice(0, 10),
|
||||
strengths: `拥有${resumeInfo.workYears}工作经验,技术栈全面,掌握${skills.slice(0, 5).join('、')}等主流技术。`,
|
||||
weaknesses: '建议继续深化技术深度,关注行业前沿技术发展。',
|
||||
careerSuggestion: `基于您的${resumeInfo.expectedPosition}职业目标和${resumeInfo.workYears}经验,建议重点关注中大型互联网公司的相关岗位,期望薪资${resumeInfo.expectedSalary}较为合理。`,
|
||||
competitiveness: competitiveness
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析简历要素
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} resumeData - 简历数据
|
||||
* @returns {Promise<object>} 分析结果
|
||||
*/
|
||||
async analyze_resume(sn_code, resumeData) {
|
||||
console.log(`[简历管理] 开始分析设备 ${sn_code} 的简历要素`);
|
||||
console.log(`[简历管理] AI分析已禁用(二期规划),使用文本匹配过滤`);
|
||||
|
||||
// 构建简历文本
|
||||
const resumeText = this.build_resume_text(resumeData);
|
||||
|
||||
// 二期规划:AI 分析暂时禁用,使用简单的文本匹配
|
||||
// const analysis = await aiService.analyzeResume(resumeText);
|
||||
|
||||
// 使用简单的文本匹配提取技能
|
||||
const skills = this.extract_skills_from_desc(resumeData.skillDescription || resumeText);
|
||||
|
||||
const result = {
|
||||
sn_code: sn_code,
|
||||
resumeData: resumeData,
|
||||
analysis: {
|
||||
skills: skills,
|
||||
content: '文本匹配分析(AI分析已禁用,二期规划)'
|
||||
},
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
console.log(`[简历管理] 简历分析完成,提取技能: ${skills.join(', ')}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建简历文本
|
||||
* @param {object} resumeData - 简历数据
|
||||
* @returns {string} 简历文本
|
||||
*/
|
||||
build_resume_text(resumeData) {
|
||||
const parts = [];
|
||||
|
||||
// 基本信息
|
||||
if (resumeData.basicInfo) {
|
||||
parts.push(`基本信息:${JSON.stringify(resumeData.basicInfo)}`);
|
||||
}
|
||||
|
||||
// 技能标签
|
||||
if (resumeData.skills && resumeData.skills.length > 0) {
|
||||
parts.push(`技能标签:${resumeData.skills.join(', ')}`);
|
||||
}
|
||||
|
||||
// 工作经验
|
||||
if (resumeData.workExperience && resumeData.workExperience.length > 0) {
|
||||
const workExp = resumeData.workExperience.map(exp =>
|
||||
`${exp.company} - ${exp.position} (${exp.duration})`
|
||||
).join('; ');
|
||||
parts.push(`工作经验:${workExp}`);
|
||||
}
|
||||
|
||||
// 项目经验
|
||||
if (resumeData.projectExperience && resumeData.projectExperience.length > 0) {
|
||||
const projectExp = resumeData.projectExperience.map(project =>
|
||||
`${project.name} - ${project.description}`
|
||||
).join('; ');
|
||||
parts.push(`项目经验:${projectExp}`);
|
||||
}
|
||||
|
||||
// 教育背景
|
||||
if (resumeData.education) {
|
||||
parts.push(`教育背景:${resumeData.education.school} - ${resumeData.education.major} - ${resumeData.education.degree}`);
|
||||
}
|
||||
|
||||
// 期望信息
|
||||
if (resumeData.expectations) {
|
||||
parts.push(`期望薪资:${resumeData.expectations.salary}`);
|
||||
parts.push(`期望地点:${resumeData.expectations.location}`);
|
||||
}
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算简历与岗位的匹配度
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} jobInfo - 岗位信息
|
||||
* @returns {Promise<object>} 匹配度分析结果
|
||||
*/
|
||||
async calculate_match_score(sn_code, jobInfo) {
|
||||
console.log(`[简历管理] 开始计算设备 ${sn_code} 与岗位的匹配度`);
|
||||
console.log(`[简历管理] 使用文本匹配分析(AI分析已禁用,二期规划)`);
|
||||
|
||||
// 获取简历分析结果
|
||||
const resumeAnalysis = await this.get_resume_analysis(sn_code);
|
||||
|
||||
// 获取账号的职位类型ID
|
||||
let jobTypeId = null;
|
||||
try {
|
||||
const db = require('../dbProxy.js');
|
||||
const pla_account = db.getModel('pla_account');
|
||||
if (pla_account) {
|
||||
const account = await pla_account.findOne({
|
||||
where: { sn_code, platform_type: jobInfo.platform || 'boss' }
|
||||
});
|
||||
if (account) {
|
||||
jobTypeId = account.job_type_id;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[简历管理] 获取职位类型ID失败:`, error);
|
||||
}
|
||||
|
||||
// 使用文本匹配进行岗位分析(替代 AI)
|
||||
const jobAnalysis = await jobFilterService.analyzeJobMatch(jobInfo, resumeAnalysis.analysis || {}, jobTypeId);
|
||||
|
||||
const result = {
|
||||
sn_code: sn_code,
|
||||
jobInfo: jobInfo,
|
||||
resumeAnalysis: resumeAnalysis.analysis,
|
||||
jobAnalysis: jobAnalysis,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
console.log(`[简历管理] 匹配度分析完成,综合分数: ${jobAnalysis.overallScore}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取简历分析结果
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} mqttClient - MQTT客户端(可选)
|
||||
* @returns {Promise<object>} 简历分析结果
|
||||
*/
|
||||
async get_resume_analysis(sn_code, mqttClient = null) {
|
||||
// 获取MQTT客户端
|
||||
if (!mqttClient) {
|
||||
const scheduleManager = require('../schedule');
|
||||
if (scheduleManager && scheduleManager.mqttClient) {
|
||||
mqttClient = scheduleManager.mqttClient;
|
||||
} else {
|
||||
throw new Error('MQTT客户端未初始化');
|
||||
}
|
||||
}
|
||||
|
||||
// 重新获取和分析
|
||||
const resumeData = await this.get_online_resume(sn_code, mqttClient);
|
||||
return await this.analyze_resume(sn_code, resumeData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新简历信息
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} newResumeData - 新的简历数据
|
||||
* @returns {Promise<object>} 更新结果
|
||||
*/
|
||||
async update_resume(sn_code, newResumeData) {
|
||||
console.log(`[简历管理] 更新设备 ${sn_code} 的简历信息`);
|
||||
|
||||
// 重新分析简历
|
||||
const analysis = await this.analyze_resume(sn_code, newResumeData);
|
||||
|
||||
return {
|
||||
message: '简历更新成功',
|
||||
analysis: analysis
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取简历摘要
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @returns {Promise<object>} 简历摘要
|
||||
*/
|
||||
async get_resume_summary(sn_code) {
|
||||
const analysis = await this.get_resume_analysis(sn_code);
|
||||
|
||||
const summary = {
|
||||
sn_code: sn_code,
|
||||
skills: analysis.analysis.skills || [],
|
||||
experience: analysis.analysis.experience || '',
|
||||
education: analysis.analysis.education || '',
|
||||
expectedSalary: analysis.analysis.expectedSalary || '',
|
||||
expectedLocation: analysis.analysis.expectedLocation || '',
|
||||
lastUpdate: analysis.timestamp
|
||||
};
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = new ResumeManager();
|
||||
81
api/middleware/logProxy.js
Normal file
81
api/middleware/logProxy.js
Normal file
@@ -0,0 +1,81 @@
|
||||
const Framework = require('../../framework/node-core-framework');
|
||||
|
||||
/**
|
||||
* Log代理模块
|
||||
* 提供统一的日志接口,延迟获取logsService
|
||||
*/
|
||||
class LogProxy {
|
||||
constructor() {
|
||||
this._logsService = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取logsService实例
|
||||
* @returns {object} logsService实例
|
||||
*/
|
||||
get logsService() {
|
||||
if (!this._logsService) {
|
||||
try {
|
||||
this._logsService = Framework.getServices().logsService;
|
||||
} catch (error) {
|
||||
console.error('无法获取logsService,使用默认日志:', error.message);
|
||||
}
|
||||
}
|
||||
return this._logsService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取调用位置信息
|
||||
* @returns {object} 包含文件路径和行号的对象
|
||||
*/
|
||||
getCallerInfo() {
|
||||
const stack = new Error().stack;
|
||||
const lines = stack.split('\n');
|
||||
|
||||
// 跳过前3行:Error、getCallerInfo、当前方法
|
||||
for (let i = 3; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
// 匹配文件路径和行号
|
||||
const match = line.match(/at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)/);
|
||||
if (match) {
|
||||
const [, functionName, filePath, lineNumber, columnNumber] = match;
|
||||
return {
|
||||
functionName: functionName || 'anonymous',
|
||||
filePath: filePath,
|
||||
lineNumber: parseInt(lineNumber),
|
||||
columnNumber: parseInt(columnNumber)
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录普通日志
|
||||
* @param {...any} args 日志参数
|
||||
*/
|
||||
log(...args) {
|
||||
this.logsService.log(...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录错误日志
|
||||
* @param {...any} args 日志参数
|
||||
*/
|
||||
error(...args) {
|
||||
this.logsService.error(...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录警告日志
|
||||
* @param {...any} args 日志参数
|
||||
*/
|
||||
warn(...args) {
|
||||
|
||||
this.logsService.warn(...args);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 导出单例实例
|
||||
module.exports = new LogProxy();
|
||||
176
api/middleware/mqtt/mqttClient.js
Normal file
176
api/middleware/mqtt/mqttClient.js
Normal file
@@ -0,0 +1,176 @@
|
||||
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 = []
|
||||
|
||||
this.client.on('connect', () => {
|
||||
this.isConnected = true
|
||||
|
||||
console.log('MQTT 服务端已连接')
|
||||
})
|
||||
|
||||
this.client.on('message', (topic, message) => {
|
||||
|
||||
message = JSON.parse(message.toString())
|
||||
|
||||
console.log('MQTT 收到消息', topic, message)
|
||||
|
||||
this.messageListeners.forEach(listener => listener(topic, message))
|
||||
|
||||
})
|
||||
|
||||
this.client.on('error', (err) => {
|
||||
console.warn('[MQTT] Error:', err.message)
|
||||
})
|
||||
}
|
||||
|
||||
waitForConnect(timeout = 5000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.isConnected) return resolve()
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error('MQTT connect timeout'))
|
||||
}, timeout)
|
||||
const check = () => {
|
||||
if (this.isConnected) {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
} else {
|
||||
setTimeout(check, 100)
|
||||
}
|
||||
}
|
||||
check()
|
||||
})
|
||||
}
|
||||
|
||||
publish(topic, message, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client.publish(topic, message, options, (err) => {
|
||||
if (err) reject(err)
|
||||
else resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
subscribe(topic, callback = null, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client.subscribe(topic, options, (err, granted) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
// 如果提供了回调函数,添加到消息监听器
|
||||
if (callback && typeof callback === 'function') {
|
||||
const messageHandler = (responseTopic, message) => {
|
||||
if (responseTopic !== topic) return
|
||||
try {
|
||||
callback(topic, message)
|
||||
} catch (error) {
|
||||
console.warn(`[MQTT] 处理订阅消息失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加到消息监听器
|
||||
this.addMessageListener(messageHandler)
|
||||
|
||||
// 在订阅成功后返回订阅信息和消息处理器
|
||||
resolve({ granted, messageHandler, topic })
|
||||
} else {
|
||||
resolve(granted)
|
||||
}
|
||||
}
|
||||
1
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布指令并等待某个主题返回(模拟同步),通过uuid确定唯一项
|
||||
* @param {*} requestTopic
|
||||
* @param {*} requestMessage
|
||||
* @param {*} responseTopic
|
||||
* @param {*} timeout
|
||||
*/
|
||||
async publishAndWait(sn_code, requestMessage, timeout = 60 * 3 * 1000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log(`[MQTT指令] 发送到主题: request_${sn_code}`, requestMessage);
|
||||
|
||||
|
||||
const uuid = uuidv4();
|
||||
|
||||
// 将uuid加入消息体,建议使用JSON格式
|
||||
const msgObj = typeof requestMessage === 'object'
|
||||
? { ...requestMessage, uuid }
|
||||
: { data: requestMessage, uuid };
|
||||
|
||||
|
||||
const sendMsg = JSON.stringify(msgObj);
|
||||
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this.removeMessageListener(onMessage);
|
||||
reject(new Error('Timeout waiting for response'));
|
||||
}, timeout);
|
||||
|
||||
const onMessage = (topic, message) => {
|
||||
let { uuid: responseUuid } = message
|
||||
if (topic === 'response' && responseUuid === uuid) {
|
||||
clearTimeout(timer);
|
||||
this.removeMessageListener(onMessage);
|
||||
resolve(message)
|
||||
}
|
||||
}
|
||||
|
||||
this.addMessageListener(onMessage)
|
||||
|
||||
this.publish(`request_${sn_code}`, sendMsg).catch(err => {
|
||||
clearTimeout(timer);
|
||||
this.removeMessageListener(onMessage);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
addMessageListener(fn) {
|
||||
this.messageListeners.push(fn)
|
||||
}
|
||||
|
||||
removeMessageListener(fn) {
|
||||
this.messageListeners = this.messageListeners.filter(f => f !== fn)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消订阅主题
|
||||
* @param {string} topic 要取消订阅的主题
|
||||
* @param {object} subscriptionInfo 订阅时返回的订阅信息
|
||||
*/
|
||||
async unsubscribe(topic, subscriptionInfo = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client.unsubscribe(topic, (err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
// 如果提供了订阅信息,移除对应的消息处理器
|
||||
if (subscriptionInfo && subscriptionInfo.messageHandler) {
|
||||
this.removeMessageListener(subscriptionInfo.messageHandler)
|
||||
}
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
end(force = false) {
|
||||
this.client.end(force)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MqttSyncClient
|
||||
302
api/middleware/mqtt/mqttDispatcher.js
Normal file
302
api/middleware/mqtt/mqttDispatcher.js
Normal file
@@ -0,0 +1,302 @@
|
||||
const db = require('../dbProxy.js');
|
||||
const deviceManager = require('../schedule/deviceManager.js');
|
||||
|
||||
/**
|
||||
* MQTT 消息分发器
|
||||
* 处理所有 MQTT 消息的分发和响应(包括请求、心跳、响应等)
|
||||
*/
|
||||
class MqttDispatcher {
|
||||
constructor(components, mqttClient) {
|
||||
this.taskQueue = components.taskQueue;
|
||||
this.mqttClient = mqttClient;
|
||||
this.actionHandlers = new Map();
|
||||
this.subscribedTopics = new Set();
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动 MQTT 分发器
|
||||
*/
|
||||
start() {
|
||||
this.registerActionHandlers();
|
||||
console.log('[MQTT分发器] 已启动');
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册指令处理器
|
||||
*/
|
||||
registerActionHandlers() {
|
||||
// 注册各种MQTT指令处理器
|
||||
this.actionHandlers.set('manual_job', async (params) => {
|
||||
return await this.handleManualJobRequest(params);
|
||||
});
|
||||
|
||||
this.actionHandlers.set('get_status', async () => {
|
||||
return this.getSystemStatus();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理手动任务请求
|
||||
*/
|
||||
async handleManualJobRequest(params) {
|
||||
const { sn_code, data } = params;
|
||||
const { keyword, taskType } = data;
|
||||
const config = require('../schedule/config.js');
|
||||
|
||||
try {
|
||||
// addTask 内部会检查账号是否启用,这里直接调用即可
|
||||
const taskId = await this.taskQueue.addTask(sn_code, {
|
||||
taskType: taskType,
|
||||
taskName: `手动任务 - ${keyword || taskType}`,
|
||||
taskParams: data,
|
||||
priority: config.getTaskPriority(taskType, { urgent: true })
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
taskId: taskId,
|
||||
message: '任务已添加到队列'
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统状态(供调度管理器使用)
|
||||
*/
|
||||
getSystemStatus() {
|
||||
return {
|
||||
systemStats: deviceManager.getSystemStats(),
|
||||
allDevices: deviceManager.getAllDevicesStatus(),
|
||||
taskQueues: this.taskQueue.getAllDeviceStatus()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 分发 MQTT 请求消息
|
||||
*/
|
||||
async dispatchMqttMessage(topic, messageStr, mqttClient) {
|
||||
try {
|
||||
const message = JSON.parse(messageStr);
|
||||
const { action, data, uuid, platform, sn_code } = message;
|
||||
|
||||
const deviceCode = topic.replace('request_', '') || sn_code;
|
||||
console.log(`[MQTT分发器] 收到指令 - 设备: ${deviceCode}, 动作: ${action}, UUID: ${uuid}`);
|
||||
|
||||
// 检查设备是否在线
|
||||
if (!deviceManager.isDeviceOnline(deviceCode)) {
|
||||
console.log(`[MQTT分发器] 设备 ${deviceCode} 离线,忽略指令`);
|
||||
return this.sendMqttResponse(mqttClient, uuid, {
|
||||
code: 500,
|
||||
message: '设备离线',
|
||||
data: null
|
||||
});
|
||||
}
|
||||
|
||||
// 获取对应的处理器
|
||||
const handler = this.actionHandlers.get(action);
|
||||
if (!handler) {
|
||||
console.log(`[MQTT分发器] 未找到动作 ${action} 的处理器`);
|
||||
return this.sendMqttResponse(mqttClient, uuid, {
|
||||
code: 404,
|
||||
message: `未找到动作 ${action} 的处理器`,
|
||||
data: null
|
||||
});
|
||||
}
|
||||
|
||||
// 执行处理器
|
||||
const result = await handler({
|
||||
sn_code: deviceCode,
|
||||
action,
|
||||
data,
|
||||
platform,
|
||||
mqttClient,
|
||||
uuid
|
||||
});
|
||||
|
||||
// 发送响应
|
||||
return this.sendMqttResponse(mqttClient, uuid, result);
|
||||
|
||||
} catch (error) {
|
||||
console.error('[MQTT分发器] 分发指令时出错:', error);
|
||||
|
||||
try {
|
||||
const message = JSON.parse(messageStr);
|
||||
if (message.uuid) {
|
||||
return this.sendMqttResponse(mqttClient, message.uuid, {
|
||||
code: 500,
|
||||
message: error.message,
|
||||
data: null
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[MQTT分发器] 解析消息失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 MQTT 响应
|
||||
*/
|
||||
async sendMqttResponse(mqttClient, uuid, result) {
|
||||
const response = {
|
||||
uuid: uuid,
|
||||
code: result.code || 200,
|
||||
message: result.message || 'success',
|
||||
data: result.data || null,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
try {
|
||||
await mqttClient.publish('response', JSON.stringify(response));
|
||||
console.log(`[MQTT分发器] 发送响应 - UUID: ${uuid}, Code: ${response.code}`);
|
||||
} catch (error) {
|
||||
console.error('[MQTT分发器] 发送响应失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建成功响应
|
||||
*/
|
||||
static createSuccessResponse(data = null, message = 'success') {
|
||||
return {
|
||||
code: 200,
|
||||
message: message,
|
||||
data: data
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建失败响应
|
||||
*/
|
||||
static createErrorResponse(message, code = 500) {
|
||||
return {
|
||||
code: code,
|
||||
message: message,
|
||||
data: null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理心跳消息
|
||||
* @param {object|string} message - 心跳消息对象或JSON字符串
|
||||
*/
|
||||
async handleHeartbeat(message) {
|
||||
try {
|
||||
// 解析消息(如果是字符串,则解析JSON)
|
||||
let heartbeatData = message;
|
||||
if (typeof message === 'string') {
|
||||
heartbeatData = JSON.parse(message);
|
||||
}
|
||||
|
||||
const { sn_code, timestamp, status, memory, platform_status, platform_login_status } = heartbeatData;
|
||||
|
||||
if (!sn_code) {
|
||||
console.warn('[MQTT心跳] 心跳消息中未找到设备SN码');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[MQTT心跳] 收到设备 ${sn_code} 的心跳消息`);
|
||||
|
||||
const device_status = db.getModel('device_status');
|
||||
|
||||
// 检查设备是否存在
|
||||
let device = await device_status.findByPk(sn_code);
|
||||
|
||||
const updateData = {
|
||||
isOnline: true, // 收到心跳,设备在线
|
||||
lastHeartbeatTime: timestamp ? new Date(timestamp) : new Date(),
|
||||
missedHeartbeats: 0
|
||||
};
|
||||
|
||||
// 更新内存信息
|
||||
if (memory && memory.percent !== undefined) {
|
||||
updateData.memoryUsage = memory.percent;
|
||||
}
|
||||
// 优先使用 platform_login_status(新格式),
|
||||
const loginStatusData = platform_login_status ;
|
||||
|
||||
if (loginStatusData) {
|
||||
let isLoggedIn = false;
|
||||
let loggedInPlatform = null;
|
||||
let loggedInUsername = null;
|
||||
let loggedInUserId = null;
|
||||
let loginTime = null;
|
||||
|
||||
// 判断是新格式还是旧格式
|
||||
isLoggedIn = platform_login_status.login || false;
|
||||
loggedInPlatform = platform_login_status.platform || null;
|
||||
loggedInUsername = platform_login_status.username || '';
|
||||
loggedInUserId = platform_login_status.user_id || null;
|
||||
|
||||
if (platform_login_status.timestamp) {
|
||||
loginTime = new Date(platform_login_status.timestamp);
|
||||
}
|
||||
|
||||
// 更新登录状态
|
||||
const previousIsLoggedIn = device ? device.isLoggedIn : false;
|
||||
|
||||
updateData.isLoggedIn = isLoggedIn;
|
||||
|
||||
if (isLoggedIn) {
|
||||
updateData.platform = loggedInPlatform;
|
||||
updateData.accountName = loggedInUsername || '';
|
||||
|
||||
// 如果之前未登录,现在登录了,更新登录时间
|
||||
if (!previousIsLoggedIn) {
|
||||
updateData.loginTime = loginTime || updateData.lastHeartbeatTime;
|
||||
}
|
||||
|
||||
console.log(`[MQTT心跳] 设备 ${sn_code} 已登录 - 平台: ${loggedInPlatform}, 用户: ${loggedInUsername}, ID: ${loggedInUserId}`);
|
||||
} else {
|
||||
// 如果之前已登录,现在未登录了,清空登录相关信息
|
||||
if (previousIsLoggedIn) {
|
||||
updateData.accountName = '';
|
||||
// loginTime 保持不变,记录最后登录时间
|
||||
}
|
||||
|
||||
console.log(`[MQTT心跳] 设备 ${sn_code} 未登录`);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新或创建设备记录
|
||||
if (device) {
|
||||
await device_status.update(updateData, { where: { sn_code } });
|
||||
console.log(`[MQTT心跳] 设备 ${sn_code} 状态已更新 - 在线: true, 登录: ${updateData.isLoggedIn}`);
|
||||
} else {
|
||||
// 创建新设备记录
|
||||
await device_status.create({
|
||||
sn_code,
|
||||
deviceName: `设备_${sn_code}`,
|
||||
deviceType: 'node_mqtt_client',
|
||||
...updateData,
|
||||
isRunning: false,
|
||||
taskStatus: 'idle',
|
||||
healthStatus: 'unknown',
|
||||
healthScore: 0
|
||||
});
|
||||
console.log(`[MQTT心跳] 设备 ${sn_code} 记录已创建 - 在线: true, 登录: ${updateData.isLoggedIn}`);
|
||||
}
|
||||
|
||||
// 记录心跳到设备管理器
|
||||
await deviceManager.recordHeartbeat(sn_code, heartbeatData);
|
||||
} catch (error) {
|
||||
console.error('[MQTT心跳] 处理心跳消息失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理响应消息
|
||||
* @param {object|string} message - 响应消息对象或JSON字符串
|
||||
*/
|
||||
handleResponse(message) {
|
||||
// 响应消息处理逻辑(目前为空,可根据需要扩展)
|
||||
// 例如:记录响应日志、更新任务状态等
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MqttDispatcher;
|
||||
|
||||
127
api/middleware/mqtt/mqttManager.js
Normal file
127
api/middleware/mqtt/mqttManager.js
Normal file
@@ -0,0 +1,127 @@
|
||||
const MqttSyncClient = require('./mqttClient');
|
||||
const Framework = require('../../../framework/node-core-framework');
|
||||
const logs = require('../logProxy');
|
||||
// action.js 已合并到 mqttDispatcher.js,不再需要单独引入
|
||||
|
||||
/**
|
||||
* MQTT管理器 - 单例模式
|
||||
* 负责管理MQTT连接,确保全局只有一个MQTT客户端实例
|
||||
*/
|
||||
class MqttManager {
|
||||
constructor() {
|
||||
this.client = null;
|
||||
this.isInitialized = false;
|
||||
this.config = {
|
||||
brokerUrl: 'mqtt://192.144.167.231:1883', // MQTT Broker地址
|
||||
options: {
|
||||
clientId: `mqtt_server_${Math.random().toString(16).substr(2, 8)}`,
|
||||
clean: true,
|
||||
connectTimeout: 5000,
|
||||
reconnectPeriod: 5000, // 自动重连间隔
|
||||
keepalive: 10
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取MQTT客户端实例(单例模式)
|
||||
* @param {object} config - 可选的配置覆盖
|
||||
* @returns {Promise<MqttSyncClient>} MQTT客户端实例
|
||||
*/
|
||||
async getInstance(config = {}) {
|
||||
if (this.client && this.isInitialized) {
|
||||
console.log('[MQTT管理器] 返回已存在的MQTT客户端实例');
|
||||
return this.client;
|
||||
}
|
||||
|
||||
// 合并配置
|
||||
if (config.brokerUrl) {
|
||||
this.config.brokerUrl = config.brokerUrl;
|
||||
}
|
||||
if (config.options) {
|
||||
this.config.options = { ...this.config.options, ...config.options };
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[MQTT管理器] 创建新的MQTT客户端实例');
|
||||
console.log(`[MQTT管理器] Broker地址: ${this.config.brokerUrl}`);
|
||||
|
||||
// 创建MQTT客户端
|
||||
this.client = new MqttSyncClient(this.config.brokerUrl, this.config.options);
|
||||
|
||||
// 等待连接成功
|
||||
await this.client.waitForConnect(10000);
|
||||
|
||||
// 注意:心跳和响应的订阅已移到 mqttDispatcher 中处理
|
||||
// 这里不再直接订阅,由调度系统通过 mqttDispatcher 统一管理
|
||||
console.log('[MQTT管理器] MQTT客户端连接成功,等待分发器初始化订阅');
|
||||
|
||||
this.isInitialized = true;
|
||||
console.log('[MQTT管理器] MQTT客户端初始化成功');
|
||||
|
||||
return this.client;
|
||||
} catch (error) {
|
||||
console.error('[MQTT管理器] MQTT客户端初始化失败:', error);
|
||||
this.client = null;
|
||||
this.isInitialized = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置MQTT客户端(用于重新连接)
|
||||
*/
|
||||
async reset() {
|
||||
if (this.client) {
|
||||
try {
|
||||
console.log('[MQTT管理器] 关闭现有MQTT连接');
|
||||
this.client.end(true);
|
||||
} catch (error) {
|
||||
console.error('[MQTT管理器] 关闭MQTT连接时出错:', error);
|
||||
}
|
||||
}
|
||||
|
||||
this.client = null;
|
||||
this.isInitialized = false;
|
||||
console.log('[MQTT管理器] MQTT客户端已重置');
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查MQTT客户端是否已初始化
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isReady() {
|
||||
return this.isInitialized && this.client && this.client.isConnected;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新配置
|
||||
* @param {object} config - 新的配置
|
||||
*/
|
||||
updateConfig(config) {
|
||||
if (this.isInitialized) {
|
||||
console.warn('[MQTT管理器] MQTT客户端已初始化,配置更新需要重置连接');
|
||||
}
|
||||
|
||||
if (config.brokerUrl) {
|
||||
this.config.brokerUrl = config.brokerUrl;
|
||||
}
|
||||
if (config.options) {
|
||||
this.config.options = { ...this.config.options, ...config.options };
|
||||
}
|
||||
|
||||
console.log('[MQTT管理器] 配置已更新:', this.config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前配置
|
||||
* @returns {object}
|
||||
*/
|
||||
getConfig() {
|
||||
return { ...this.config };
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
const mqttManager = new MqttManager();
|
||||
module.exports = mqttManager;
|
||||
118
api/middleware/schedule/ErrorHandler.js
Normal file
118
api/middleware/schedule/ErrorHandler.js
Normal file
@@ -0,0 +1,118 @@
|
||||
const db = require('../dbProxy');
|
||||
|
||||
/**
|
||||
* 统一错误处理模块
|
||||
* 负责错误分类、记录、恢复决策
|
||||
*/
|
||||
class ErrorHandler {
|
||||
/**
|
||||
* 可重试的错误类型
|
||||
*/
|
||||
static RETRYABLE_ERRORS = [
|
||||
'ETIMEDOUT',
|
||||
'ECONNRESET',
|
||||
'ENOTFOUND',
|
||||
'NetworkError',
|
||||
'MQTT客户端未初始化',
|
||||
'设备离线',
|
||||
'超时'
|
||||
];
|
||||
|
||||
/**
|
||||
* 处理错误
|
||||
* @param {Error} error - 错误对象
|
||||
* @param {Object} context - 上下文信息
|
||||
* @returns {Object} 错误处理结果
|
||||
*/
|
||||
static async handleError(error, context = {}) {
|
||||
const errorInfo = {
|
||||
message: error.message || '未知错误',
|
||||
stack: error.stack || '',
|
||||
code: error.code || '',
|
||||
context: {
|
||||
taskId: context.taskId,
|
||||
sn_code: context.sn_code,
|
||||
taskType: context.taskType,
|
||||
...context
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
isRetryable: this.isRetryableError(error)
|
||||
};
|
||||
|
||||
// 记录到日志
|
||||
console.error(`[错误处理] ${errorInfo.message}`, {
|
||||
context: errorInfo.context,
|
||||
isRetryable: errorInfo.isRetryable,
|
||||
stack: errorInfo.stack
|
||||
});
|
||||
|
||||
// 错误信息已通过 console.error 记录到控制台日志
|
||||
|
||||
return errorInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断错误是否可重试
|
||||
* @param {Error} error - 错误对象
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static isRetryableError(error) {
|
||||
if (!error) return false;
|
||||
|
||||
const errorMessage = (error.message || '').toLowerCase();
|
||||
const errorCode = error.code || '';
|
||||
|
||||
// 检查错误代码
|
||||
if (this.RETRYABLE_ERRORS.some(code => errorCode === code)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查错误消息
|
||||
return this.RETRYABLE_ERRORS.some(code =>
|
||||
errorMessage.includes(code.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算重试延迟(指数退避)
|
||||
* @param {number} retryCount - 当前重试次数
|
||||
* @param {number} baseDelay - 基础延迟(毫秒)
|
||||
* @param {number} maxDelay - 最大延迟(毫秒)
|
||||
* @returns {number} 延迟时间(毫秒)
|
||||
*/
|
||||
static calculateRetryDelay(retryCount, baseDelay = 1000, maxDelay = 30000) {
|
||||
const delay = Math.min(baseDelay * Math.pow(2, retryCount - 1), maxDelay);
|
||||
return delay;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建可重试错误
|
||||
* @param {string} message - 错误消息
|
||||
* @param {Object} context - 上下文
|
||||
* @returns {Error}
|
||||
*/
|
||||
static createRetryableError(message, context = {}) {
|
||||
const error = new Error(message);
|
||||
error.code = 'RETRYABLE';
|
||||
error.context = context;
|
||||
error.isRetryable = true;
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建致命错误
|
||||
* @param {string} message - 错误消息
|
||||
* @param {Object} context - 上下文
|
||||
* @returns {Error}
|
||||
*/
|
||||
static createFatalError(message, context = {}) {
|
||||
const error = new Error(message);
|
||||
error.code = 'FATAL';
|
||||
error.context = context;
|
||||
error.isRetryable = false;
|
||||
return error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ErrorHandler;
|
||||
|
||||
215
api/middleware/schedule/PriorityQueue.js
Normal file
215
api/middleware/schedule/PriorityQueue.js
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* 优先级队列实现(使用最小堆)
|
||||
* 优先级高的任务(priority值大)会优先出队
|
||||
*/
|
||||
class PriorityQueue {
|
||||
constructor() {
|
||||
this.heap = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取父节点索引
|
||||
*/
|
||||
parent(index) {
|
||||
return Math.floor((index - 1) / 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取左子节点索引
|
||||
*/
|
||||
leftChild(index) {
|
||||
return 2 * index + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取右子节点索引
|
||||
*/
|
||||
rightChild(index) {
|
||||
return 2 * index + 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* 交换两个节点
|
||||
*/
|
||||
swap(i, j) {
|
||||
[this.heap[i], this.heap[j]] = [this.heap[j], this.heap[i]];
|
||||
}
|
||||
|
||||
/**
|
||||
* 上浮操作(插入时使用)
|
||||
*/
|
||||
bubbleUp(index) {
|
||||
if (index === 0) return;
|
||||
|
||||
const parentIndex = this.parent(index);
|
||||
const current = this.heap[index];
|
||||
const parent = this.heap[parentIndex];
|
||||
|
||||
// 优先级高的在前(priority值大),如果优先级相同,创建时间早的在前
|
||||
if (
|
||||
current.priority > parent.priority ||
|
||||
(current.priority === parent.priority && current.createdAt < parent.createdAt)
|
||||
) {
|
||||
this.swap(index, parentIndex);
|
||||
this.bubbleUp(parentIndex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下沉操作(删除时使用)
|
||||
*/
|
||||
bubbleDown(index) {
|
||||
const leftIndex = this.leftChild(index);
|
||||
const rightIndex = this.rightChild(index);
|
||||
let largest = index;
|
||||
|
||||
const current = this.heap[index];
|
||||
|
||||
// 比较左子节点
|
||||
if (leftIndex < this.heap.length) {
|
||||
const left = this.heap[leftIndex];
|
||||
if (
|
||||
left.priority > current.priority ||
|
||||
(left.priority === current.priority && left.createdAt < current.createdAt)
|
||||
) {
|
||||
largest = leftIndex;
|
||||
}
|
||||
}
|
||||
|
||||
// 比较右子节点
|
||||
if (rightIndex < this.heap.length) {
|
||||
const right = this.heap[rightIndex];
|
||||
const largestNode = this.heap[largest];
|
||||
if (
|
||||
right.priority > largestNode.priority ||
|
||||
(right.priority === largestNode.priority && right.createdAt < largestNode.createdAt)
|
||||
) {
|
||||
largest = rightIndex;
|
||||
}
|
||||
}
|
||||
|
||||
if (largest !== index) {
|
||||
this.swap(index, largest);
|
||||
this.bubbleDown(largest);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加任务到队列
|
||||
* @param {Object} task - 任务对象,必须包含 priority 和 createdAt 属性
|
||||
*/
|
||||
push(task) {
|
||||
if (!task.hasOwnProperty('priority')) {
|
||||
task.priority = 5; // 默认优先级
|
||||
}
|
||||
if (!task.hasOwnProperty('createdAt')) {
|
||||
task.createdAt = Date.now();
|
||||
}
|
||||
this.heap.push(task);
|
||||
this.bubbleUp(this.heap.length - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取出优先级最高的任务
|
||||
* @returns {Object|null} 任务对象或null
|
||||
*/
|
||||
pop() {
|
||||
if (this.heap.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.heap.length === 1) {
|
||||
return this.heap.pop();
|
||||
}
|
||||
|
||||
const top = this.heap[0];
|
||||
this.heap[0] = this.heap.pop();
|
||||
this.bubbleDown(0);
|
||||
|
||||
return top;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查看优先级最高的任务(不移除)
|
||||
* @returns {Object|null} 任务对象或null
|
||||
*/
|
||||
peek() {
|
||||
return this.heap.length > 0 ? this.heap[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取队列大小
|
||||
* @returns {number}
|
||||
*/
|
||||
size() {
|
||||
return this.heap.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查队列是否为空
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isEmpty() {
|
||||
return this.heap.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空队列
|
||||
*/
|
||||
clear() {
|
||||
this.heap = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找任务
|
||||
* @param {Function} predicate - 查找条件函数
|
||||
* @returns {Object|null} 任务对象或null
|
||||
*/
|
||||
find(predicate) {
|
||||
return this.heap.find(predicate) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除任务
|
||||
* @param {Function} predicate - 查找条件函数
|
||||
* @returns {boolean} 是否成功移除
|
||||
*/
|
||||
remove(predicate) {
|
||||
const index = this.heap.findIndex(predicate);
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (index === this.heap.length - 1) {
|
||||
this.heap.pop();
|
||||
return true;
|
||||
}
|
||||
|
||||
// 将最后一个元素移到当前位置
|
||||
this.heap[index] = this.heap.pop();
|
||||
|
||||
// 重新调整堆
|
||||
const parentIndex = this.parent(index);
|
||||
if (index > 0 && this.heap[parentIndex] &&
|
||||
(this.heap[index].priority > this.heap[parentIndex].priority ||
|
||||
(this.heap[index].priority === this.heap[parentIndex].priority &&
|
||||
this.heap[index].createdAt < this.heap[parentIndex].createdAt))) {
|
||||
this.bubbleUp(index);
|
||||
} else {
|
||||
this.bubbleDown(index);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为数组(用于调试)
|
||||
* @returns {Array}
|
||||
*/
|
||||
toArray() {
|
||||
return [...this.heap];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PriorityQueue;
|
||||
|
||||
308
api/middleware/schedule/command.js
Normal file
308
api/middleware/schedule/command.js
Normal file
@@ -0,0 +1,308 @@
|
||||
const logs = require('../logProxy');
|
||||
const db = require('../dbProxy');
|
||||
const jobManager = require('../job/index');
|
||||
const ScheduleUtils = require('./utils');
|
||||
const ScheduleConfig = require('./config');
|
||||
|
||||
|
||||
/**
|
||||
* 指令管理器
|
||||
* 负责管理任务下的多个指令,简化MQTT通信流程
|
||||
*/
|
||||
class CommandManager {
|
||||
constructor() {
|
||||
this.pendingCommands = new Map(); // 等待响应的指令 { commandId: { resolve, reject, timeout } }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 执行指令序列
|
||||
* @param {Array} commands - 指令数组
|
||||
* @param {object} mqttClient - MQTT客户端
|
||||
* @param {object} options - 执行选项
|
||||
* @returns {Promise<object>} 执行结果
|
||||
*/
|
||||
async executeCommands(taskId, commands, mqttClient, options = {}) {
|
||||
// try {
|
||||
if (!commands || commands.length === 0) {
|
||||
throw new Error('没有找到要执行的指令');
|
||||
}
|
||||
|
||||
const {
|
||||
maxRetries = 1, // 最大重试次数
|
||||
retryDelay = 1000 // 重试延迟(毫秒)
|
||||
} = options;
|
||||
|
||||
console.log(`[指令管理] 开始执行 ${commands.length} 个指令`);
|
||||
|
||||
const results = [];
|
||||
const errors = [];
|
||||
|
||||
// 顺序执行指令,失败时停止
|
||||
for (let i = 0; i < commands.length; i++) {
|
||||
const command = commands[i];
|
||||
let retryCount = 0;
|
||||
let commandResult = null;
|
||||
|
||||
// 重试逻辑
|
||||
while (retryCount <= maxRetries) {
|
||||
|
||||
console.log(`[指令管理] 执行指令 ${i + 1}/${commands.length}: ${command.command_name || command.name} (尝试 ${retryCount + 1}/${maxRetries + 1})`);
|
||||
|
||||
commandResult = await this.executeCommand(taskId, command, mqttClient);
|
||||
|
||||
results. push(commandResult);
|
||||
break; // 成功执行,跳出重试循环
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const successCount = results.length;
|
||||
const errorCount = errors.length;
|
||||
|
||||
console.log(`[指令管理] 指令执行完成: 成功 ${successCount}/${commands.length}, 失败 ${errorCount}`);
|
||||
|
||||
return {
|
||||
success: errorCount === 0, // 只有全部成功才算成功
|
||||
results: results,
|
||||
errors: errors,
|
||||
totalCommands: commands.length,
|
||||
successCount: successCount,
|
||||
errorCount: errorCount
|
||||
};
|
||||
|
||||
// } catch (error) {
|
||||
// console.error(`[指令管理] 执行指令序列失败:`, error);
|
||||
// throw error;
|
||||
// }
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 执行单个指令
|
||||
* @param {object} command - 指令对象
|
||||
* @param {object} mqttClient - MQTT客户端
|
||||
* @returns {Promise<object>} 执行结果
|
||||
*/
|
||||
async executeCommand(taskId, command, mqttClient) {
|
||||
const startTime = new Date();
|
||||
let commandRecord = null;
|
||||
|
||||
const task = await db.getModel('task_status').findByPk(taskId);
|
||||
|
||||
// 获取指令信息(支持两种格式)
|
||||
const commandName = command.command_name;
|
||||
const commandType = command.command_type;
|
||||
const commandParams = command.command_params ? JSON.parse(command.command_params) : {};
|
||||
|
||||
// 创建指令记录
|
||||
commandRecord = await db.getModel('task_commands').create({
|
||||
task_id: taskId,
|
||||
command_type: commandType,
|
||||
command_name: commandName,
|
||||
command_params: JSON.stringify(commandParams),
|
||||
priority: command.priority || 1,
|
||||
sequence: command.sequence || 1,
|
||||
max_retries: command.maxRetries || command.max_retries || 3,
|
||||
status: 'pending'
|
||||
});
|
||||
|
||||
let commandId = commandRecord.id;
|
||||
console.log(`[指令管理] 创建指令记录: ${commandName} (ID: ${commandId})`);
|
||||
|
||||
|
||||
// 更新指令状态为运行中
|
||||
await this.updateCommandStatus(commandId, 'running');
|
||||
|
||||
|
||||
console.log(`[指令管理] 执行指令: ${commandName} (ID: ${commandId})`);
|
||||
|
||||
const sn_code = task.sn_code;
|
||||
|
||||
// 将驼峰命名转换为下划线命名(如:getOnlineResume -> get_online_resume)
|
||||
const toSnakeCase = (str) => {
|
||||
// 如果已经是下划线格式,直接返回
|
||||
if (str.includes('_')) {
|
||||
return str;
|
||||
}
|
||||
// 驼峰转下划线
|
||||
return str.replace(/([A-Z])/g, '_$1').toLowerCase().replace(/^_/, '');
|
||||
};
|
||||
|
||||
const methodName = toSnakeCase(commandType);
|
||||
|
||||
// 获取指令超时时间(从配置中获取,默认5分钟)
|
||||
const timeout = ScheduleConfig.taskTimeouts[commandType] || ScheduleConfig.taskTimeouts[methodName] || 5 * 60 * 1000;
|
||||
|
||||
let result;
|
||||
try {
|
||||
// 使用超时机制包装指令执行
|
||||
const commandPromise = (async () => {
|
||||
if (commandType && jobManager[methodName]) {
|
||||
return await jobManager[methodName](sn_code, mqttClient, commandParams);
|
||||
} else {
|
||||
// 如果转换后找不到,尝试直接使用原名称
|
||||
if (jobManager[commandType]) {
|
||||
return await jobManager[commandType](sn_code, mqttClient, commandParams);
|
||||
} else {
|
||||
throw new Error(`未知的指令类型: ${commandType} (尝试的方法名: ${methodName})`);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
// 使用超时机制
|
||||
result = await ScheduleUtils.withTimeout(
|
||||
commandPromise,
|
||||
timeout,
|
||||
`指令执行超时: ${commandName} (超时时间: ${timeout / 1000}秒)`
|
||||
);
|
||||
} catch (error) {
|
||||
const endTime = new Date();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
// 如果是超时错误,更新指令状态为失败
|
||||
const errorMessage = error.message || '指令执行失败';
|
||||
await this.updateCommandStatus(commandId, 'failed', null, errorMessage);
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const endTime = new Date();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
// 更新指令状态为完成
|
||||
await this.updateCommandStatus(commandId, 'completed', result);
|
||||
|
||||
return {
|
||||
commandId: commandId,
|
||||
commandName: commandName,
|
||||
result: result,
|
||||
duration: duration,
|
||||
success: true
|
||||
};
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 更新指令状态
|
||||
* @param {number} commandId - 指令ID
|
||||
* @param {string} status - 状态
|
||||
* @param {object} result - 结果
|
||||
* @param {string} errorMessage - 错误信息
|
||||
*/
|
||||
async updateCommandStatus(commandId, status, result = null, errorMessage = null) {
|
||||
try {
|
||||
const updateData = {
|
||||
status: status,
|
||||
updated_at: new Date()
|
||||
};
|
||||
|
||||
if (status === 'running') {
|
||||
updateData.start_time = new Date();
|
||||
} else if (status === 'completed' || status === 'failed') {
|
||||
updateData.end_time = new Date();
|
||||
if (result) {
|
||||
// 将结果转换为JSON字符串,并限制长度(TEXT类型最大约65KB)
|
||||
let resultStr = JSON.stringify(result);
|
||||
const maxLength = 60000; // 限制为60KB,留一些余量
|
||||
|
||||
if (resultStr.length > maxLength) {
|
||||
// 如果结果太长,尝试压缩或截断
|
||||
try {
|
||||
// 如果是对象,尝试只保存关键信息
|
||||
if (typeof result === 'object' && result !== null) {
|
||||
const summary = {
|
||||
success: result.success !== undefined ? result.success : true,
|
||||
message: result.message || '执行成功',
|
||||
dataLength: resultStr.length,
|
||||
truncated: true,
|
||||
preview: resultStr.substring(0, 1000) // 保存前1000字符作为预览
|
||||
};
|
||||
resultStr = JSON.stringify(summary);
|
||||
} else {
|
||||
// 直接截断
|
||||
resultStr = resultStr.substring(0, maxLength) + '...[数据已截断]';
|
||||
}
|
||||
} catch (e) {
|
||||
// 如果处理失败,直接截断
|
||||
resultStr = resultStr.substring(0, maxLength) + '...[数据已截断]';
|
||||
}
|
||||
}
|
||||
updateData.result = resultStr;
|
||||
updateData.progress = 100;
|
||||
}
|
||||
if (errorMessage) {
|
||||
// 错误信息也限制长度
|
||||
const maxErrorLength = 10000; // 错误信息限制10KB
|
||||
updateData.error_message = errorMessage.length > maxErrorLength
|
||||
? errorMessage.substring(0, maxErrorLength) + '...[错误信息已截断]'
|
||||
: errorMessage;
|
||||
}
|
||||
// 计算执行时长
|
||||
const command = await db.getModel('task_commands').findByPk(commandId);
|
||||
if (command && command.start_time) {
|
||||
const duration = new Date() - new Date(command.start_time);
|
||||
updateData.duration = duration;
|
||||
}
|
||||
}
|
||||
|
||||
await db.getModel('task_commands').update(updateData, {
|
||||
where: { id: commandId }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logs.error(`[指令管理] 更新指令状态失败:`, error, {
|
||||
commandId: commandId,
|
||||
status: status
|
||||
});
|
||||
// 如果是因为数据太长导致的错误,尝试只保存错误信息
|
||||
if (error.message && error.message.includes('Data too long')) {
|
||||
try {
|
||||
await db.getModel('task_commands').update({
|
||||
status: status,
|
||||
error_message: '结果数据过长,无法保存完整结果',
|
||||
end_time: new Date(),
|
||||
updated_at: new Date()
|
||||
}, {
|
||||
where: { id: commandId }
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`[指令管理] 保存截断结果也失败:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 清理过期的指令记录
|
||||
* @param {number} days - 保留天数
|
||||
*/
|
||||
async cleanupExpiredCommands(days = 30) {
|
||||
try {
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - days);
|
||||
|
||||
const deletedCount = await db.getModel('task_commands').destroy({
|
||||
where: {
|
||||
create_time: {
|
||||
[db.Sequelize.Op.lt]: cutoffDate
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[指令管理] 清理了 ${deletedCount} 条过期指令记录`);
|
||||
return deletedCount;
|
||||
|
||||
} catch (error) {
|
||||
logs.error(`[指令管理] 清理过期指令失败:`, error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new CommandManager();
|
||||
150
api/middleware/schedule/config.js
Normal file
150
api/middleware/schedule/config.js
Normal file
@@ -0,0 +1,150 @@
|
||||
const dayjs = require('dayjs');
|
||||
const config = require('../../../config/config');
|
||||
|
||||
/**
|
||||
* 调度系统配置中心
|
||||
* 统一管理所有配置参数
|
||||
*/
|
||||
class ScheduleConfig {
|
||||
constructor() {
|
||||
// 工作时间配置
|
||||
this.workHours = {
|
||||
start: 9,
|
||||
end: 18
|
||||
};
|
||||
|
||||
// 频率限制配置(毫秒)
|
||||
this.rateLimits = {
|
||||
search: 30 * 60 * 1000, // 搜索间隔:30分钟
|
||||
apply: 5 * 60 * 1000, // 投递间隔:5分钟
|
||||
chat: 1 * 60 * 1000, // 聊天间隔:1分钟
|
||||
};
|
||||
|
||||
// 单日操作限制
|
||||
this.dailyLimits = {
|
||||
maxSearch: 20, // 每天最多搜索20次
|
||||
maxApply: 50, // 每天最多投递50份简历
|
||||
maxChat: 100, // 每天最多发送100条聊天
|
||||
};
|
||||
|
||||
// 任务超时配置(毫秒)
|
||||
this.taskTimeouts = {
|
||||
get_login_qr_code: 30 * 1000, // 登录检查:30秒
|
||||
get_resume: 60 * 1000, // 获取简历:1分钟
|
||||
search_jobs: 5 * 60 * 1000, // 搜索岗位:5分钟
|
||||
chat: 30 * 1000, // 聊天:30秒
|
||||
apply: 30 * 1000 // 投递:30秒
|
||||
};
|
||||
|
||||
// 任务优先级配置
|
||||
this.taskPriorities = {
|
||||
get_login_qr_code: 10, // 最高优先级
|
||||
get_resume: 9,
|
||||
apply: 8,
|
||||
auto_deliver: 7, // 自动投递任务
|
||||
search_jobs: 6,
|
||||
chat: 5,
|
||||
cleanup: 1
|
||||
};
|
||||
|
||||
// 监控配置
|
||||
this.monitoring = {
|
||||
heartbeatTimeout: 3 * 60 * 1000, // 心跳超时:5分钟
|
||||
taskFailureRate: 0.5, // 任务失败率:50%
|
||||
consecutiveFailures: 3, // 连续失败次数:3次
|
||||
alertCooldown: 5 * 60 * 1000, // 告警冷却:5分钟
|
||||
offlineThreshold: 24 * 60 * 60 * 1000 // 离线设备清理:24小时
|
||||
};
|
||||
|
||||
// 定时任务配置
|
||||
this.schedules = {
|
||||
dailyReset: '0 0 * * *', // 每天凌晨重置统计
|
||||
jobFlowInterval: '0 */5 * * * *', // 每10秒执行一次找工作流程
|
||||
monitoringInterval: '*/1 * * * *', // 监控检查间隔:1分钟
|
||||
autoDeliver: '0 */5 * * * *', // 自动投递任务:每5分钟执行一次
|
||||
// 监控检查间隔:1分钟
|
||||
};
|
||||
|
||||
// 测试配置覆盖
|
||||
if (config.test) {
|
||||
this.applyTestConfig(config.test);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用测试配置
|
||||
* @param {object} testConfig - 测试配置
|
||||
*/
|
||||
applyTestConfig(testConfig) {
|
||||
if (testConfig.skipWorkStartHour) {
|
||||
this.workHours.start = 0;
|
||||
this.workHours.end = 24;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 测试模式下缩短所有间隔时间
|
||||
if (testConfig.fastMode) {
|
||||
this.rateLimits.search = 10 * 1000; // 10秒
|
||||
this.rateLimits.apply = 5 * 1000; // 5秒
|
||||
this.rateLimits.chat = 2 * 1000; // 2秒
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否在工作时间
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isWorkingHours() {
|
||||
const now = dayjs();
|
||||
const hour = now.hour();
|
||||
return hour >= this.workHours.start && hour < this.workHours.end;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务超时时间
|
||||
* @param {string} taskType - 任务类型
|
||||
* @returns {number} 超时时间(毫秒)
|
||||
*/
|
||||
getTaskTimeout(taskType) {
|
||||
return this.taskTimeouts[taskType] || 30000; // 默认30秒
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务优先级
|
||||
* @param {string} taskType - 任务类型
|
||||
* @param {object} options - 选项
|
||||
* @returns {number} 优先级(1-10)
|
||||
*/
|
||||
getTaskPriority(taskType, options = {}) {
|
||||
let priority = this.taskPriorities[taskType] || 5;
|
||||
|
||||
if (options.urgent) {
|
||||
priority = Math.min(10, priority + 2);
|
||||
}
|
||||
|
||||
return priority;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取操作频率限制
|
||||
* @param {string} operation - 操作类型
|
||||
* @returns {number} 间隔时间(毫秒)
|
||||
*/
|
||||
getRateLimit(operation) {
|
||||
return this.rateLimits[operation] || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取日限制
|
||||
* @param {string} operation - 操作类型
|
||||
* @returns {number} 日限制次数
|
||||
*/
|
||||
getDailyLimit(operation) {
|
||||
return this.dailyLimits[`max${operation.charAt(0).toUpperCase() + operation.slice(1)}`] || Infinity;
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
const scheduleConfig = new ScheduleConfig();
|
||||
module.exports = scheduleConfig;
|
||||
287
api/middleware/schedule/deviceManager.js
Normal file
287
api/middleware/schedule/deviceManager.js
Normal file
@@ -0,0 +1,287 @@
|
||||
const dayjs = require('dayjs');
|
||||
const Sequelize = require('sequelize');
|
||||
const db = require('../dbProxy');
|
||||
const config = require('./config');
|
||||
const utils = require('./utils');
|
||||
|
||||
/**
|
||||
* 设备管理器(简化版)
|
||||
* 合并了 Monitor 和 Strategy 的核心功能
|
||||
*/
|
||||
class DeviceManager {
|
||||
constructor() {
|
||||
// 设备状态 { sn_code: { isOnline, lastHeartbeat, lastSearch, lastApply, lastChat, dailyCounts } }
|
||||
this.devices = new Map();
|
||||
|
||||
// 系统统计
|
||||
this.stats = {
|
||||
totalDevices: 0,
|
||||
onlineDevices: 0,
|
||||
totalTasks: 0,
|
||||
completedTasks: 0,
|
||||
failedTasks: 0,
|
||||
startTime: new Date()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化
|
||||
*/
|
||||
async init() {
|
||||
console.log('[设备管理器] 初始化中...');
|
||||
await this.loadStats();
|
||||
console.log('[设备管理器] 初始化完成');
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载统计数据
|
||||
*/
|
||||
async loadStats() {
|
||||
try {
|
||||
const devices = await db.getModel('pla_account').findAll();
|
||||
this.stats.totalDevices = devices.length;
|
||||
|
||||
const completedCount = await db.getModel('task_status').count({
|
||||
where: { status: 'completed' }
|
||||
});
|
||||
const failedCount = await db.getModel('task_status').count({
|
||||
where: { status: 'failed' }
|
||||
});
|
||||
|
||||
this.stats.completedTasks = completedCount;
|
||||
this.stats.failedTasks = failedCount;
|
||||
this.stats.totalTasks = completedCount + failedCount;
|
||||
} catch (error) {
|
||||
console.error('[设备管理器] 加载统计失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录心跳
|
||||
*/
|
||||
async recordHeartbeat(sn_code, heartbeatData = {}) {
|
||||
const now = Date.now();
|
||||
if (!this.devices.has(sn_code)) {
|
||||
this.devices.set(sn_code, {
|
||||
isOnline: true,
|
||||
lastHeartbeat: now,
|
||||
dailyCounts: { date: utils.getTodayString(), searchCount: 0, applyCount: 0, chatCount: 0 }
|
||||
});
|
||||
}
|
||||
|
||||
const device = this.devices.get(sn_code);
|
||||
device.isOnline = true;
|
||||
device.lastHeartbeat = now;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查设备是否在线
|
||||
*/
|
||||
isDeviceOnline(sn_code) {
|
||||
const device = this.devices.get(sn_code);
|
||||
if (!device) return false;
|
||||
|
||||
const elapsed = Date.now() - device.lastHeartbeat;
|
||||
if (elapsed > config.monitoring.heartbeatTimeout) {
|
||||
device.isOnline = false;
|
||||
return false;
|
||||
}
|
||||
return device.isOnline;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以执行操作
|
||||
*/
|
||||
canExecuteOperation(sn_code, operationType) {
|
||||
// 检查工作时间
|
||||
if (!config.isWorkingHours()) {
|
||||
return { allowed: false, reason: '不在工作时间内' };
|
||||
}
|
||||
|
||||
// 检查频率限制
|
||||
const device = this.devices.get(sn_code);
|
||||
if (device) {
|
||||
const lastTime = device[`last${operationType.charAt(0).toUpperCase() + operationType.slice(1)}`] || 0;
|
||||
const interval = config.getRateLimit(operationType);
|
||||
if (Date.now() - lastTime < interval) {
|
||||
return { allowed: false, reason: '操作过于频繁' };
|
||||
}
|
||||
}
|
||||
|
||||
// 检查日限制
|
||||
if (device && device.dailyCounts) {
|
||||
const today = utils.getTodayString();
|
||||
if (device.dailyCounts.date !== today) {
|
||||
device.dailyCounts = { date: today, searchCount: 0, applyCount: 0, chatCount: 0 };
|
||||
}
|
||||
const countKey = `${operationType}Count`;
|
||||
const current = device.dailyCounts[countKey] || 0;
|
||||
const max = config.getDailyLimit(operationType);
|
||||
if (current >= max) {
|
||||
return { allowed: false, reason: `今日${operationType}操作已达上限` };
|
||||
}
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录操作
|
||||
*/
|
||||
recordOperation(sn_code, operationType) {
|
||||
const device = this.devices.get(sn_code) || {};
|
||||
device[`last${operationType.charAt(0).toUpperCase() + operationType.slice(1)}`] = Date.now();
|
||||
|
||||
if (device.dailyCounts) {
|
||||
const countKey = `${operationType}Count`;
|
||||
device.dailyCounts[countKey] = (device.dailyCounts[countKey] || 0) + 1;
|
||||
}
|
||||
|
||||
this.devices.set(sn_code, device);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录任务开始
|
||||
*/
|
||||
recordTaskStart(sn_code, task) {
|
||||
// 简化实现,只记录日志
|
||||
console.log(`[设备管理器] 设备 ${sn_code} 开始执行任务: ${task.taskName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录任务完成
|
||||
*/
|
||||
recordTaskComplete(sn_code, task, success, duration) {
|
||||
if (success) {
|
||||
this.stats.completedTasks++;
|
||||
} else {
|
||||
this.stats.failedTasks++;
|
||||
}
|
||||
this.stats.totalTasks++;
|
||||
console.log(`[设备管理器] 设备 ${sn_code} 任务${success ? '成功' : '失败'}: ${task.taskName} (${duration}ms)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统统计
|
||||
*/
|
||||
getSystemStats() {
|
||||
const onlineCount = Array.from(this.devices.values()).filter(d => d.isOnline).length;
|
||||
return {
|
||||
...this.stats,
|
||||
onlineDevices: onlineCount,
|
||||
uptime: utils.formatDuration(Date.now() - this.stats.startTime.getTime())
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有设备状态
|
||||
*/
|
||||
getAllDevicesStatus() {
|
||||
const result = {};
|
||||
for (const [sn_code, device] of this.devices.entries()) {
|
||||
result[sn_code] = {
|
||||
isOnline: device.isOnline,
|
||||
lastHeartbeat: device.lastHeartbeat,
|
||||
dailyCounts: device.dailyCounts || {}
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查心跳状态(异步更新数据库)
|
||||
*/
|
||||
async checkHeartbeatStatus() {
|
||||
try {
|
||||
const now = Date.now();
|
||||
const device_status = db.getModel('device_status');
|
||||
const offlineDevices = [];
|
||||
|
||||
for (const [sn_code, device] of this.devices.entries()) {
|
||||
if (now - device.lastHeartbeat > config.monitoring.heartbeatTimeout) {
|
||||
// 如果之前是在线状态,现在检测到离线,需要更新数据库
|
||||
if (device.isOnline) {
|
||||
device.isOnline = false;
|
||||
offlineDevices.push(sn_code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 批量更新数据库中的离线设备状态
|
||||
if (offlineDevices.length > 0) {
|
||||
await device_status.update(
|
||||
{ isOnline: false },
|
||||
{
|
||||
where: {
|
||||
sn_code: {
|
||||
[Sequelize.Op.in]: offlineDevices
|
||||
},
|
||||
isOnline: true // 只更新当前在线的设备,避免重复更新
|
||||
}
|
||||
}
|
||||
);
|
||||
console.log(`[设备管理器] 检测到 ${offlineDevices.length} 个设备心跳超时,已同步到数据库: ${offlineDevices.join(', ')}`);
|
||||
}
|
||||
|
||||
// 同时检查数据库中的设备状态(处理内存中没有但数据库中有心跳超时的情况)
|
||||
const heartbeatTimeout = config.monitoring.heartbeatTimeout;
|
||||
const heartbeatThreshold = new Date(now - heartbeatTimeout);
|
||||
|
||||
const timeoutDevices = await device_status.findAll({
|
||||
where: {
|
||||
isOnline: true,
|
||||
lastHeartbeatTime: {
|
||||
[Sequelize.Op.lt]: heartbeatThreshold
|
||||
}
|
||||
},
|
||||
attributes: ['sn_code', 'lastHeartbeatTime']
|
||||
});
|
||||
|
||||
if (timeoutDevices.length > 0) {
|
||||
const timeoutSnCodes = timeoutDevices.map(dev => dev.sn_code);
|
||||
await device_status.update(
|
||||
{ isOnline: false },
|
||||
{
|
||||
where: {
|
||||
sn_code: {
|
||||
[Sequelize.Op.in]: timeoutSnCodes
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
console.log(`[设备管理器] 从数据库检测到 ${timeoutSnCodes.length} 个心跳超时设备,已更新为离线: ${timeoutSnCodes.join(', ')}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[设备管理器] 检查心跳状态失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置所有日计数器
|
||||
*/
|
||||
resetAllDailyCounters() {
|
||||
const today = utils.getTodayString();
|
||||
for (const device of this.devices.values()) {
|
||||
if (device.dailyCounts && device.dailyCounts.date !== today) {
|
||||
device.dailyCounts = { date: today, searchCount: 0, applyCount: 0, chatCount: 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理离线设备
|
||||
*/
|
||||
cleanupOfflineDevices(threshold = 3600000) {
|
||||
const now = Date.now();
|
||||
for (const [sn_code, device] of this.devices.entries()) {
|
||||
if (now - device.lastHeartbeat > threshold) {
|
||||
this.devices.delete(sn_code);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
const deviceManager = new DeviceManager();
|
||||
module.exports = deviceManager;
|
||||
|
||||
203
api/middleware/schedule/index.js
Normal file
203
api/middleware/schedule/index.js
Normal file
@@ -0,0 +1,203 @@
|
||||
const mqttManager = require("../mqtt/mqttManager.js");
|
||||
|
||||
// 导入调度模块(简化版)
|
||||
const TaskQueue = require('./taskQueue.js');
|
||||
const Command = require('./command.js');
|
||||
const deviceManager = require('./deviceManager.js');
|
||||
const config = require('./config.js');
|
||||
const utils = require('./utils.js');
|
||||
|
||||
// 导入新的模块
|
||||
const TaskHandlers = require('./taskHandlers.js');
|
||||
const MqttDispatcher = require('../mqtt/mqttDispatcher.js');
|
||||
const ScheduledJobs = require('./scheduledJobs.js');
|
||||
|
||||
/**
|
||||
* 调度系统管理器
|
||||
* 统一管理整个调度系统的生命周期
|
||||
*/
|
||||
class ScheduleManager {
|
||||
constructor() {
|
||||
this.mqttClient = null;
|
||||
this.isInitialized = false;
|
||||
this.startTime = new Date();
|
||||
|
||||
// 子模块
|
||||
this.taskHandlers = null;
|
||||
this.mqttDispatcher = null;
|
||||
this.scheduledJobs = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化调度系统
|
||||
*/
|
||||
async init() {
|
||||
try {
|
||||
console.log('[调度管理器] 开始初始化...');
|
||||
|
||||
// 1. 初始化MQTT管理器
|
||||
await this.initMqttClient();
|
||||
console.log('[调度管理器] MQTT管理器已初始化');
|
||||
|
||||
// 2. 初始化各个组件
|
||||
await this.initComponents();
|
||||
console.log('[调度管理器] 组件已初始化');
|
||||
|
||||
|
||||
// 3. 初始化子模块
|
||||
this.initSubModules();
|
||||
console.log('[调度管理器] 子模块已初始化');
|
||||
|
||||
// 4. 启动心跳监听
|
||||
this.startHeartbeatListener();
|
||||
console.log('[调度管理器] 心跳监听已启动');
|
||||
|
||||
// 5. 启动定时任务
|
||||
this.scheduledJobs.start();
|
||||
console.log('[调度管理器] 定时任务已启动');
|
||||
|
||||
this.isInitialized = true;
|
||||
|
||||
} catch (error) {
|
||||
console.error('[调度管理器] 初始化失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化MQTT客户端
|
||||
*/
|
||||
async initMqttClient() {
|
||||
this.mqttClient = await mqttManager.getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化各个组件(简化版)
|
||||
*/
|
||||
async initComponents() {
|
||||
// 初始化设备管理器
|
||||
await deviceManager.init();
|
||||
|
||||
// 初始化任务队列
|
||||
await TaskQueue.init?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化子模块
|
||||
*/
|
||||
initSubModules() {
|
||||
// 初始化任务处理器
|
||||
this.taskHandlers = new TaskHandlers(this.mqttClient);
|
||||
this.taskHandlers.register(TaskQueue);
|
||||
|
||||
// 初始化 MQTT 分发器(简化:不再需要 components)
|
||||
this.mqttDispatcher = new MqttDispatcher({ deviceManager, taskQueue: TaskQueue }, this.mqttClient);
|
||||
this.mqttDispatcher.start();
|
||||
|
||||
// 初始化定时任务管理器
|
||||
this.scheduledJobs = new ScheduledJobs({ deviceManager, taskQueue: TaskQueue }, this.taskHandlers);
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动心跳监听
|
||||
*/
|
||||
startHeartbeatListener() {
|
||||
// 订阅心跳主题,使用 mqttDispatcher 处理
|
||||
this.mqttClient.subscribe("heartbeat", async (topic, message) => {
|
||||
try {
|
||||
await this.mqttDispatcher.handleHeartbeat(message);
|
||||
} catch (error) {
|
||||
console.error('[调度管理器] 处理心跳消息失败:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// 订阅响应主题
|
||||
this.mqttClient.subscribe("response", async (topic, message) => {
|
||||
try {
|
||||
if (this.mqttDispatcher) {
|
||||
this.mqttDispatcher.handleResponse(message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[调度管理器] 处理响应消息失败:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动执行找工作流程(已废弃,full_flow 不再使用)
|
||||
* @deprecated 请使用其他任务类型,如 auto_deliver
|
||||
*/
|
||||
async manualExecuteJobFlow(sn_code, keyword = '前端') {
|
||||
console.warn(`[手动执行] manualExecuteJobFlow 已废弃,full_flow 不再使用`);
|
||||
throw new Error('full_flow 任务类型已废弃,请使用其他任务类型');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统状态
|
||||
*/
|
||||
getSystemStatus() {
|
||||
const status = this.mqttDispatcher ? this.mqttDispatcher.getSystemStatus() : {};
|
||||
return {
|
||||
isInitialized: this.isInitialized,
|
||||
mqttConnected: this.mqttClient && this.mqttClient.isConnected,
|
||||
systemStats: deviceManager.getSystemStats(),
|
||||
allDevices: deviceManager.getAllDevicesStatus(),
|
||||
taskQueues: TaskQueue.getAllDeviceStatus(),
|
||||
uptime: utils.formatDuration(Date.now() - this.startTime.getTime()),
|
||||
startTime: utils.formatTimestamp(this.startTime),
|
||||
...status
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止调度系统
|
||||
*/
|
||||
stop() {
|
||||
console.log('[调度管理器] 正在停止...');
|
||||
|
||||
// 停止所有定时任务
|
||||
if (this.scheduledJobs) {
|
||||
this.scheduledJobs.stop();
|
||||
}
|
||||
|
||||
// 停止任务队列扫描器
|
||||
if (TaskQueue && typeof TaskQueue.stopQueueScanner === 'function') {
|
||||
TaskQueue.stopQueueScanner();
|
||||
}
|
||||
|
||||
// 关闭MQTT连接
|
||||
if (this.mqttClient) {
|
||||
this.mqttClient.end();
|
||||
}
|
||||
|
||||
this.isInitialized = false;
|
||||
console.log('[调度管理器] 已停止');
|
||||
}
|
||||
}
|
||||
|
||||
// 创建调度管理器实例
|
||||
const scheduleManager = new ScheduleManager();
|
||||
|
||||
// 导出兼容的接口,保持与原有代码的一致性
|
||||
module.exports = {
|
||||
// 初始化方法
|
||||
init: () => scheduleManager.init(),
|
||||
|
||||
// 手动执行任务
|
||||
manualExecuteJobFlow: (sn_code, keyword) => scheduleManager.manualExecuteJobFlow(sn_code, keyword),
|
||||
|
||||
// 获取系统状态
|
||||
getSystemStatus: () => scheduleManager.getSystemStatus(),
|
||||
|
||||
// 停止系统
|
||||
stop: () => scheduleManager.stop(),
|
||||
|
||||
// 访问各个组件(为了兼容性)
|
||||
get mqttClient() { return scheduleManager.mqttClient; },
|
||||
get isInitialized() { return scheduleManager.isInitialized; },
|
||||
|
||||
// 访问各个组件实例(简化版)
|
||||
get taskQueue() { return TaskQueue; },
|
||||
get command() { return Command; },
|
||||
get deviceManager() { return deviceManager; }
|
||||
};
|
||||
168
api/middleware/schedule/scheduledJobs.js
Normal file
168
api/middleware/schedule/scheduledJobs.js
Normal file
@@ -0,0 +1,168 @@
|
||||
const node_schedule = require("node-schedule");
|
||||
const config = require('./config.js');
|
||||
const deviceManager = require('./deviceManager.js');
|
||||
const command = require('./command.js');
|
||||
const db = require('../dbProxy');
|
||||
/**
|
||||
* 定时任务管理器(简化版)
|
||||
* 管理所有定时任务的创建和销毁
|
||||
*/
|
||||
class ScheduledJobs {
|
||||
constructor(components, taskHandlers) {
|
||||
this.taskQueue = components.taskQueue;
|
||||
this.taskHandlers = taskHandlers;
|
||||
this.jobs = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动所有定时任务
|
||||
*/
|
||||
start() {
|
||||
// 每天凌晨重置统计数据
|
||||
const resetJob = node_schedule.scheduleJob(config.schedules.dailyReset, () => {
|
||||
this.resetDailyStats();
|
||||
});
|
||||
this.jobs.push(resetJob);
|
||||
|
||||
// 启动心跳检查定时任务(每分钟检查一次)
|
||||
const monitoringJob = node_schedule.scheduleJob(config.schedules.monitoringInterval, async () => {
|
||||
await deviceManager.checkHeartbeatStatus().catch(error => {
|
||||
console.error('[定时任务] 检查心跳状态失败:', error);
|
||||
});
|
||||
});
|
||||
|
||||
this.jobs.push(monitoringJob);
|
||||
|
||||
|
||||
// 执行自动投递任务
|
||||
const autoDeliverJob = node_schedule.scheduleJob(config.schedules.autoDeliver, () => {
|
||||
this.autoDeliverTask();
|
||||
});
|
||||
this.jobs.push(autoDeliverJob);
|
||||
console.log('[定时任务] 已启动自动投递任务');
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 重置每日统计
|
||||
*/
|
||||
resetDailyStats() {
|
||||
console.log('[定时任务] 重置每日统计数据');
|
||||
|
||||
try {
|
||||
deviceManager.resetAllDailyCounters();
|
||||
console.log('[定时任务] 每日统计重置完成');
|
||||
} catch (error) {
|
||||
console.error('[定时任务] 重置统计失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期数据
|
||||
*/
|
||||
cleanupCaches() {
|
||||
console.log('[定时任务] 开始清理过期数据');
|
||||
|
||||
try {
|
||||
deviceManager.cleanupOfflineDevices(config.monitoring.offlineThreshold);
|
||||
command.cleanupExpiredCommands(30);
|
||||
console.log('[定时任务] 数据清理完成');
|
||||
} catch (error) {
|
||||
console.error('[定时任务] 数据清理失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动投递任务
|
||||
*/
|
||||
async autoDeliverTask() {
|
||||
const now = new Date();
|
||||
console.log(`[自动投递] ${now.toLocaleString()} 开始执行自动投递任务`);
|
||||
|
||||
// 检查是否在工作时间
|
||||
if (!config.isWorkingHours()) {
|
||||
console.log(`[自动投递] 非工作时间,跳过执行`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 从 device_status 查询所有在线且已登录的设备
|
||||
const models = db.models;
|
||||
const { device_status, pla_account, op } = models;
|
||||
const onlineDevices = await device_status.findAll({
|
||||
where: {
|
||||
isOnline: true,
|
||||
isLoggedIn: true
|
||||
},
|
||||
attributes: ['sn_code', 'accountName', 'platform']
|
||||
});
|
||||
|
||||
if (!onlineDevices || onlineDevices.length === 0) {
|
||||
console.log('[自动投递] 没有在线且已登录的设备');
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取这些在线设备对应的账号配置(只获取启用且开启自动投递的账号)
|
||||
const snCodes = onlineDevices.map(device => device.sn_code);
|
||||
const pla_users = await pla_account.findAll({
|
||||
where: {
|
||||
sn_code: { [op.in]: snCodes },
|
||||
is_delete: 0,
|
||||
is_enabled: 1, // 只获取启用的账号
|
||||
auto_deliver: 1
|
||||
}
|
||||
});
|
||||
|
||||
if (!pla_users || pla_users.length === 0) {
|
||||
console.log('[自动投递] 没有启用且开启自动投递的账号');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[自动投递] 找到 ${pla_users.length} 个可用账号`);
|
||||
|
||||
// 为每个设备添加自动投递任务到队列
|
||||
for (const pl_user of pla_users) {
|
||||
const userData = pl_user.toJSON();
|
||||
|
||||
|
||||
// 检查设备调度策略
|
||||
const canExecute = deviceManager.canExecuteOperation(userData.sn_code, 'deliver');
|
||||
if (!canExecute.allowed) {
|
||||
console.log(`[自动投递] 设备 ${userData.sn_code} 不满足执行条件: ${canExecute.reason}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 添加自动投递任务到队列
|
||||
await this.taskQueue.addTask(userData.sn_code, {
|
||||
taskType: 'auto_deliver',
|
||||
taskName: `自动投递 - ${userData.keyword || '默认关键词'}`,
|
||||
taskParams: {
|
||||
keyword: userData.keyword || '',
|
||||
platform: userData.platform_type || 'boss',
|
||||
pageCount: 3, // 默认值
|
||||
maxCount: 10, // 默认值
|
||||
filterRules: {
|
||||
minSalary: userData.min_salary || 0,
|
||||
maxSalary: userData.max_salary || 0,
|
||||
keywords: [],
|
||||
excludeKeywords: []
|
||||
}
|
||||
},
|
||||
priority: config.getTaskPriority('auto_deliver') || 6
|
||||
});
|
||||
|
||||
console.log(`[自动投递] 已为设备 ${userData.sn_code} 添加自动投递任务,关键词: ${userData.keyword || '默认'}`);
|
||||
}
|
||||
|
||||
console.log('[自动投递] 任务添加完成');
|
||||
|
||||
} catch (error) {
|
||||
console.error('[自动投递] 执行失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ScheduledJobs;
|
||||
|
||||
432
api/middleware/schedule/taskHandlers.js
Normal file
432
api/middleware/schedule/taskHandlers.js
Normal file
@@ -0,0 +1,432 @@
|
||||
const db = require('../dbProxy.js');
|
||||
const config = require('./config.js');
|
||||
const deviceManager = require('./deviceManager.js');
|
||||
const command = require('./command.js');
|
||||
const jobFilterService = require('../job/job_filter_service.js');
|
||||
|
||||
/**
|
||||
* 任务处理器(简化版)
|
||||
* 处理各种类型的任务
|
||||
*/
|
||||
class TaskHandlers {
|
||||
constructor(mqttClient) {
|
||||
this.mqttClient = mqttClient;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 注册任务处理器到任务队列
|
||||
* @param {object} taskQueue - 任务队列实例
|
||||
*/
|
||||
register(taskQueue) {
|
||||
taskQueue.registerHandler('get_resume', async (task) => {
|
||||
return await this.handleGetResumeTask(task);
|
||||
});
|
||||
|
||||
taskQueue.registerHandler('get_job_list', async (task) => {
|
||||
return await this.handleGetJobListTask(task);
|
||||
});
|
||||
|
||||
taskQueue.registerHandler('send_chat', async (task) => {
|
||||
return await this.handleSendChatTask(task);
|
||||
});
|
||||
|
||||
taskQueue.registerHandler('apply_job', async (task) => {
|
||||
return await this.handleApplyJobTask(task);
|
||||
});
|
||||
|
||||
taskQueue.registerHandler('auto_deliver', async (task) => {
|
||||
return await this.handleAutoDeliverTask(task);
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理获取简历任务
|
||||
*/
|
||||
async handleGetResumeTask(task) {
|
||||
const { sn_code } = task;
|
||||
console.log(`[任务处理器] 获取简历任务 - 设备: ${sn_code}`);
|
||||
|
||||
deviceManager.recordTaskStart(sn_code, task);
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const commands = [{
|
||||
command_type: 'getOnlineResume',
|
||||
command_name: '获取在线简历',
|
||||
command_params: JSON.stringify({ sn_code }),
|
||||
priority: config.getTaskPriority('get_resume')
|
||||
}];
|
||||
|
||||
const result = await command.executeCommands(task.id, commands, this.mqttClient);
|
||||
const duration = Date.now() - startTime;
|
||||
deviceManager.recordTaskComplete(sn_code, task, true, duration);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
deviceManager.recordTaskComplete(sn_code, task, false, duration);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理获取岗位列表任务
|
||||
*/
|
||||
async handleGetJobListTask(task) {
|
||||
const { sn_code, taskParams } = task;
|
||||
const { keyword, platform } = taskParams;
|
||||
|
||||
console.log(`[任务处理器] 获取岗位列表任务 - 设备: ${sn_code}`);
|
||||
|
||||
deviceManager.recordTaskStart(sn_code, task);
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const commands = [{
|
||||
command_type: 'getJobList',
|
||||
command_name: '获取岗位列表',
|
||||
command_params: JSON.stringify({ sn_code, keyword, platform }),
|
||||
priority: config.getTaskPriority('search_jobs')
|
||||
}];
|
||||
|
||||
const result = await command.executeCommands(task.id, commands, this.mqttClient);
|
||||
const duration = Date.now() - startTime;
|
||||
deviceManager.recordTaskComplete(sn_code, task, true, duration);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
deviceManager.recordTaskComplete(sn_code, task, false, duration);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理发送聊天任务
|
||||
*/
|
||||
async handleSendChatTask(task) {
|
||||
const { sn_code, taskParams } = task;
|
||||
|
||||
console.log(`[任务处理器] 发送聊天任务 - 设备: ${sn_code}`);
|
||||
|
||||
deviceManager.recordTaskStart(sn_code, task);
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const commands = [{
|
||||
command_type: 'sendChatMessage',
|
||||
command_name: '发送聊天消息',
|
||||
command_params: JSON.stringify(taskParams),
|
||||
priority: config.getTaskPriority('chat')
|
||||
}];
|
||||
|
||||
const result = await command.executeCommands(task.id, commands, this.mqttClient);
|
||||
const duration = Date.now() - startTime;
|
||||
deviceManager.recordTaskComplete(sn_code, task, true, duration);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
deviceManager.recordTaskComplete(sn_code, task, false, duration);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理投递简历任务
|
||||
*/
|
||||
async handleApplyJobTask(task) {
|
||||
const { sn_code, taskParams } = task;
|
||||
|
||||
console.log(`[任务处理器] 投递简历任务 - 设备: ${sn_code}`);
|
||||
|
||||
deviceManager.recordTaskStart(sn_code, task);
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const commands = [{
|
||||
command_type: 'applyJob',
|
||||
command_name: '投递简历',
|
||||
command_params: JSON.stringify(taskParams),
|
||||
priority: config.getTaskPriority('apply')
|
||||
}];
|
||||
|
||||
const result = await command.executeCommands(task.id, commands, this.mqttClient);
|
||||
const duration = Date.now() - startTime;
|
||||
deviceManager.recordTaskComplete(sn_code, task, true, duration);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
deviceManager.recordTaskComplete(sn_code, task, false, duration);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 处理自动投递任务
|
||||
*/
|
||||
|
||||
async handleAutoDeliverTask(task) {
|
||||
const { sn_code, taskParams } = task;
|
||||
const { keyword, platform, pageCount, maxCount } = taskParams;
|
||||
|
||||
console.log(`[任务处理器] 自动投递任务 - 设备: ${sn_code}, 关键词: ${keyword}`);
|
||||
|
||||
deviceManager.recordTaskStart(sn_code, task);
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const job_postings = db.getModel('job_postings');
|
||||
const pla_account = db.getModel('pla_account');
|
||||
const resume_info = db.getModel('resume_info');
|
||||
const job_types = db.getModel('job_types');
|
||||
|
||||
// 1. 检查并获取在线简历(如果2小时内没有获取)
|
||||
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
|
||||
let resume = await resume_info.findOne({
|
||||
where: {
|
||||
sn_code,
|
||||
platform: platform || 'boss',
|
||||
isActive: true
|
||||
},
|
||||
order: [['last_modify_time', 'DESC']]
|
||||
});
|
||||
|
||||
const needRefreshResume = !resume ||
|
||||
!resume.last_modify_time ||
|
||||
new Date(resume.last_modify_time) < twoHoursAgo;
|
||||
|
||||
if (needRefreshResume) {
|
||||
console.log(`[任务处理器] 简历超过2小时未更新,重新获取在线简历`);
|
||||
try {
|
||||
// 通过 command 系统获取在线简历,而不是直接调用 jobManager
|
||||
const getResumeCommand = {
|
||||
command_type: 'getOnlineResume',
|
||||
command_name: '获取在线简历',
|
||||
command_params: JSON.stringify({ sn_code, platform: platform || 'boss' }),
|
||||
priority: config.getTaskPriority('get_resume') || 5
|
||||
};
|
||||
await command.executeCommands(task.id, [getResumeCommand], this.mqttClient);
|
||||
|
||||
// 重新查询简历
|
||||
resume = await resume_info.findOne({
|
||||
where: {
|
||||
sn_code,
|
||||
platform: platform || 'boss',
|
||||
isActive: true
|
||||
},
|
||||
order: [['last_modify_time', 'DESC']]
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`[任务处理器] 获取在线简历失败,使用已有简历:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (!resume) {
|
||||
console.log(`[任务处理器] 未找到简历信息,无法进行自动投递`);
|
||||
return {
|
||||
success: false,
|
||||
deliveredCount: 0,
|
||||
message: '未找到简历信息'
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 获取账号配置和职位类型配置
|
||||
const account = await pla_account.findOne({
|
||||
where: { sn_code, platform_type: platform || 'boss' }
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
console.log(`[任务处理器] 未找到账号配置`);
|
||||
return {
|
||||
success: false,
|
||||
deliveredCount: 0,
|
||||
message: '未找到账号配置'
|
||||
};
|
||||
}
|
||||
|
||||
const accountConfig = account.toJSON();
|
||||
const resumeInfo = resume.toJSON();
|
||||
|
||||
// 获取职位类型配置
|
||||
let jobTypeConfig = null;
|
||||
if (accountConfig.job_type_id) {
|
||||
const jobType = await job_types.findByPk(accountConfig.job_type_id);
|
||||
if (jobType) {
|
||||
jobTypeConfig = jobType.toJSON();
|
||||
}
|
||||
}
|
||||
|
||||
// 获取优先级权重配置
|
||||
let priorityWeights = accountConfig.is_salary_priority;
|
||||
if (!Array.isArray(priorityWeights) || priorityWeights.length === 0) {
|
||||
priorityWeights = [
|
||||
{ key: "distance", weight: 50 },
|
||||
{ key: "salary", weight: 20 },
|
||||
{ key: "work_years", weight: 10 },
|
||||
{ key: "education", weight: 20 }
|
||||
];
|
||||
}
|
||||
|
||||
// 3. 先获取职位列表
|
||||
const getJobListCommand = {
|
||||
command_type: 'getJobList',
|
||||
command_name: '获取职位列表',
|
||||
command_params: JSON.stringify({
|
||||
sn_code: sn_code,
|
||||
keyword: keyword || accountConfig.keyword || '',
|
||||
platform: platform || 'boss',
|
||||
pageCount: pageCount || 3
|
||||
}),
|
||||
priority: config.getTaskPriority('search_jobs') || 5
|
||||
};
|
||||
|
||||
await command.executeCommands(task.id, [getJobListCommand], this.mqttClient);
|
||||
|
||||
// 4. 从数据库获取待投递的职位
|
||||
const pendingJobs = await job_postings.findAll({
|
||||
where: {
|
||||
sn_code: sn_code,
|
||||
platform: platform || 'boss',
|
||||
applyStatus: 'pending'
|
||||
},
|
||||
order: [['create_time', 'DESC']],
|
||||
limit: (maxCount || 10) * 3 // 获取更多职位用于筛选
|
||||
});
|
||||
|
||||
if (!pendingJobs || pendingJobs.length === 0) {
|
||||
console.log(`[任务处理器] 没有待投递的职位`);
|
||||
return {
|
||||
success: true,
|
||||
deliveredCount: 0,
|
||||
message: '没有待投递的职位'
|
||||
};
|
||||
}
|
||||
|
||||
// 5. 根据简历信息、职位类型配置和权重配置进行评分和过滤
|
||||
const scoredJobs = [];
|
||||
const excludeKeywords = jobTypeConfig && jobTypeConfig.excludeKeywords
|
||||
? (typeof jobTypeConfig.excludeKeywords === 'string'
|
||||
? JSON.parse(jobTypeConfig.excludeKeywords)
|
||||
: jobTypeConfig.excludeKeywords)
|
||||
: [];
|
||||
|
||||
// 获取一个月内已投递的公司列表(用于过滤)
|
||||
const apply_records = db.getModel('apply_records');
|
||||
const Sequelize = require('sequelize');
|
||||
const oneMonthAgo = new Date();
|
||||
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
|
||||
|
||||
const recentApplies = await apply_records.findAll({
|
||||
where: {
|
||||
sn_code: sn_code,
|
||||
applyTime: {
|
||||
[Sequelize.Op.gte]: oneMonthAgo
|
||||
}
|
||||
},
|
||||
attributes: ['companyName'],
|
||||
group: ['companyName']
|
||||
});
|
||||
|
||||
const recentCompanyNames = new Set(recentApplies.map(apply => apply.companyName).filter(Boolean));
|
||||
|
||||
for (const job of pendingJobs) {
|
||||
const jobData = job.toJSON ? job.toJSON() : job;
|
||||
|
||||
// 排除关键词过滤
|
||||
if (Array.isArray(excludeKeywords) && excludeKeywords.length > 0) {
|
||||
const jobText = `${jobData.jobTitle} ${jobData.companyName} ${jobData.jobDescription || ''}`.toLowerCase();
|
||||
const hasExcluded = excludeKeywords.some(kw => jobText.includes(kw.toLowerCase()));
|
||||
if (hasExcluded) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查该公司是否在一个月内已投递过
|
||||
if (jobData.companyName && recentCompanyNames.has(jobData.companyName)) {
|
||||
console.log(`[任务处理器] 跳过一个月内已投递的公司: ${jobData.companyName}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 使用 job_filter_service 计算评分
|
||||
const scoreResult = jobFilterService.calculateJobScoreWithWeights(
|
||||
jobData,
|
||||
resumeInfo,
|
||||
accountConfig,
|
||||
jobTypeConfig,
|
||||
priorityWeights
|
||||
);
|
||||
|
||||
// 只保留总分 >= 60 的职位
|
||||
if (scoreResult.totalScore >= 60) {
|
||||
scoredJobs.push({
|
||||
...jobData,
|
||||
matchScore: scoreResult.totalScore,
|
||||
scoreDetails: scoreResult.scores
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 按总分降序排序
|
||||
scoredJobs.sort((a, b) => b.matchScore - a.matchScore);
|
||||
|
||||
// 取前 maxCount 个职位
|
||||
const jobsToDeliver = scoredJobs.slice(0, maxCount || 10);
|
||||
|
||||
console.log(`[任务处理器] 职位评分完成,共 ${pendingJobs.length} 个职位,评分后 ${scoredJobs.length} 个符合条件,将投递 ${jobsToDeliver.length} 个`);
|
||||
|
||||
if (jobsToDeliver.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
deliveredCount: 0,
|
||||
message: '没有符合条件的职位'
|
||||
};
|
||||
}
|
||||
|
||||
// 6. 为每个职位创建一条独立的投递指令
|
||||
const deliverCommands = [];
|
||||
for (const jobData of jobsToDeliver) {
|
||||
console.log(`[任务处理器] 准备投递职位: ${jobData.jobTitle} @ ${jobData.companyName}, 评分: ${jobData.matchScore}`, jobData.scoreDetails);
|
||||
deliverCommands.push({
|
||||
command_type: 'applyJob',
|
||||
command_name: `投递简历 - ${jobData.jobTitle} @ ${jobData.companyName} (评分:${jobData.matchScore})`,
|
||||
command_params: JSON.stringify({
|
||||
sn_code: sn_code,
|
||||
platform: platform || 'boss',
|
||||
jobId: jobData.jobId,
|
||||
encryptBossId: jobData.encryptBossId || '',
|
||||
securityId: jobData.securityId || '',
|
||||
brandName: jobData.companyName,
|
||||
jobTitle: jobData.jobTitle,
|
||||
companyName: jobData.companyName,
|
||||
matchScore: jobData.matchScore,
|
||||
scoreDetails: jobData.scoreDetails
|
||||
}),
|
||||
priority: config.getTaskPriority('apply') || 6
|
||||
});
|
||||
}
|
||||
|
||||
// 7. 执行所有投递指令
|
||||
const result = await command.executeCommands(task.id, deliverCommands, this.mqttClient);
|
||||
const duration = Date.now() - startTime;
|
||||
deviceManager.recordTaskComplete(sn_code, task, true, duration);
|
||||
|
||||
console.log(`[任务处理器] 自动投递任务完成 - 设备: ${sn_code}, 创建了 ${deliverCommands.length} 条投递指令, 耗时: ${duration}ms`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
deviceManager.recordTaskComplete(sn_code, task, false, duration);
|
||||
console.error(`[任务处理器] 自动投递任务失败 - 设备: ${sn_code}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskHandlers;
|
||||
|
||||
927
api/middleware/schedule/taskQueue.js
Normal file
927
api/middleware/schedule/taskQueue.js
Normal file
@@ -0,0 +1,927 @@
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const Sequelize = require('sequelize');
|
||||
const logs = require('../logProxy');
|
||||
const db = require('../dbProxy');
|
||||
const command = require('./command');
|
||||
const PriorityQueue = require('./PriorityQueue');
|
||||
const ErrorHandler = require('./ErrorHandler');
|
||||
const deviceManager = require('./deviceManager');
|
||||
|
||||
/**
|
||||
* 任务队列管理器(重构版)
|
||||
* - 使用优先级队列(堆)提升性能
|
||||
* - 工作池模式:设备内串行执行,设备间并行执行
|
||||
* - 统一重试机制
|
||||
* - 统一MQTT管理
|
||||
*/
|
||||
class TaskQueue {
|
||||
constructor(config = {}) {
|
||||
// 设备任务队列映射 { sn_code: PriorityQueue }
|
||||
this.deviceQueues = new Map();
|
||||
|
||||
// 设备执行状态 { sn_code: { isRunning, currentTask, runningCount } }
|
||||
this.deviceStatus = new Map();
|
||||
|
||||
// 任务处理器映射 { taskType: handler }
|
||||
this.taskHandlers = new Map();
|
||||
|
||||
// 工作池配置
|
||||
this.config = {
|
||||
maxConcurrency: config.maxConcurrency || 5, // 全局最大并发数(设备数)
|
||||
deviceMaxConcurrency: config.deviceMaxConcurrency || 1, // 每个设备最大并发数(保持串行)
|
||||
...config
|
||||
};
|
||||
|
||||
// 全局运行中的任务数
|
||||
this.globalRunningCount = 0;
|
||||
|
||||
// 全局任务队列(用于跨设备优先级调度,可选)
|
||||
this.globalQueue = new PriorityQueue();
|
||||
|
||||
// 定期扫描定时器
|
||||
this.scanInterval = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化(从数据库恢复未完成的任务)
|
||||
*/
|
||||
async init() {
|
||||
try {
|
||||
console.log('[任务队列] 初始化中...');
|
||||
|
||||
// 从数据库加载pending和running状态的任务
|
||||
const pendingTasks = await db.getModel('task_status').findAll({
|
||||
where: {
|
||||
status: ['pending', 'running']
|
||||
},
|
||||
order: [['priority', 'DESC'], ['id', 'ASC']]
|
||||
});
|
||||
|
||||
// 获取所有启用的账号和设备在线状态
|
||||
const pla_account = db.getModel('pla_account');
|
||||
const device_status = db.getModel('device_status');
|
||||
|
||||
const enabledAccounts = await pla_account.findAll({
|
||||
where: {
|
||||
is_delete: 0,
|
||||
is_enabled: 1
|
||||
},
|
||||
attributes: ['sn_code']
|
||||
});
|
||||
const enabledSnCodes = new Set(enabledAccounts.map(acc => acc.sn_code));
|
||||
|
||||
// 检查设备在线状态(需要同时满足:isOnline = true 且心跳未超时)
|
||||
const heartbeatTimeout = require('./config.js').monitoring.heartbeatTimeout; // 默认5分钟
|
||||
const now = new Date();
|
||||
const heartbeatThreshold = new Date(now.getTime() - heartbeatTimeout);
|
||||
|
||||
const onlineDevices = await device_status.findAll({
|
||||
where: {
|
||||
isOnline: true,
|
||||
lastHeartbeatTime: {
|
||||
[Sequelize.Op.gte]: heartbeatThreshold // 心跳时间在阈值内
|
||||
}
|
||||
},
|
||||
attributes: ['sn_code', 'lastHeartbeatTime']
|
||||
});
|
||||
const onlineSnCodes = new Set(onlineDevices.map(dev => dev.sn_code));
|
||||
|
||||
|
||||
let restoredCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
for (const taskRecord of pendingTasks) {
|
||||
const task = taskRecord.toJSON();
|
||||
const sn_code = task.sn_code;
|
||||
|
||||
// 检查账号是否启用
|
||||
if (!enabledSnCodes.has(sn_code)) {
|
||||
console.log(`[任务队列] 初始化时跳过任务 ${task.id}:账号 ${sn_code} 未启用`);
|
||||
// 标记任务为已取消
|
||||
await db.getModel('task_status').update(
|
||||
{
|
||||
status: 'cancelled',
|
||||
endTime: new Date(),
|
||||
result: JSON.stringify({ error: '账号未启用,任务已取消' })
|
||||
},
|
||||
{ where: { id: task.id } }
|
||||
);
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查设备是否在线
|
||||
if (!onlineSnCodes.has(sn_code)) {
|
||||
console.log(`[任务队列] 初始化时跳过任务 ${task.id}:设备 ${sn_code} 不在线`);
|
||||
// 不在线的任务仍然恢复,等待设备上线后执行
|
||||
// 不取消任务,只是不立即执行
|
||||
}
|
||||
|
||||
// 初始化设备队列
|
||||
if (!this.deviceQueues.has(sn_code)) {
|
||||
this.deviceQueues.set(sn_code, new PriorityQueue());
|
||||
}
|
||||
|
||||
// 初始化设备状态(重要:确保设备状态存在)
|
||||
if (!this.deviceStatus.has(sn_code)) {
|
||||
this.deviceStatus.set(sn_code, {
|
||||
isRunning: false,
|
||||
currentTask: null,
|
||||
runningCount: 0
|
||||
});
|
||||
}
|
||||
|
||||
// 恢复任务对象
|
||||
const taskObj = {
|
||||
id: task.id,
|
||||
sn_code: task.sn_code,
|
||||
taskType: task.taskType,
|
||||
taskName: task.taskName,
|
||||
taskParams: task.taskParams ? JSON.parse(task.taskParams) : {},
|
||||
priority: task.priority || 5,
|
||||
maxRetries: task.maxRetries || 3,
|
||||
retryCount: task.retryCount || 0,
|
||||
status: 'pending',
|
||||
createdAt: task.create_time ? new Date(task.create_time).getTime() : Date.now()
|
||||
};
|
||||
|
||||
// 添加到设备队列
|
||||
this.deviceQueues.get(sn_code).push(taskObj);
|
||||
restoredCount++;
|
||||
|
||||
// 如果状态是running,重置为pending
|
||||
if (task.status === 'running') {
|
||||
await db.getModel('task_status').update(
|
||||
{ status: 'pending' },
|
||||
{ where: { id: task.id } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复任务后,尝试执行所有设备的队列(只执行在线且启用的设备)
|
||||
for (const sn_code of this.deviceQueues.keys()) {
|
||||
// 只处理启用且在线的设备
|
||||
if (enabledSnCodes.has(sn_code) && onlineSnCodes.has(sn_code)) {
|
||||
this.processQueue(sn_code).catch(error => {
|
||||
console.error(`[任务队列] 初始化后执行队列失败 (设备: ${sn_code}):`, error);
|
||||
});
|
||||
} else {
|
||||
console.log(`[任务队列] 初始化时跳过设备 ${sn_code} 的队列执行:${!enabledSnCodes.has(sn_code) ? '账号未启用' : '设备不在线'}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[任务队列] 初始化完成,恢复 ${restoredCount} 个任务,跳过 ${skippedCount} 个未启用账号的任务`);
|
||||
|
||||
// 启动定期扫描机制(每10秒扫描一次)
|
||||
this.startQueueScanner();
|
||||
} catch (error) {
|
||||
console.error('[任务队列] 初始化失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动队列扫描器(定期检查并执行队列中的任务)
|
||||
*/
|
||||
startQueueScanner() {
|
||||
// 如果已经启动,先清除
|
||||
if (this.scanInterval) {
|
||||
clearInterval(this.scanInterval);
|
||||
}
|
||||
|
||||
// 每10秒扫描一次所有设备的队列
|
||||
this.scanInterval = setInterval(() => {
|
||||
this.scanAndProcessQueues();
|
||||
}, 10000); // 10秒扫描一次
|
||||
|
||||
console.log('[任务队列] 队列扫描器已启动(每10秒扫描一次)');
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止队列扫描器
|
||||
*/
|
||||
stopQueueScanner() {
|
||||
if (this.scanInterval) {
|
||||
clearInterval(this.scanInterval);
|
||||
this.scanInterval = null;
|
||||
console.log('[任务队列] 队列扫描器已停止');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描所有设备的队列并尝试执行任务(过滤未启用的账号)
|
||||
*/
|
||||
async scanAndProcessQueues() {
|
||||
try {
|
||||
const deviceCount = this.deviceQueues.size;
|
||||
if (deviceCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取所有启用的账号对应的设备SN码
|
||||
const pla_account = db.getModel('pla_account');
|
||||
const enabledAccounts = await pla_account.findAll({
|
||||
where: {
|
||||
is_delete: 0,
|
||||
is_enabled: 1
|
||||
},
|
||||
attributes: ['sn_code']
|
||||
});
|
||||
|
||||
const enabledSnCodes = new Set(enabledAccounts.map(acc => acc.sn_code));
|
||||
|
||||
let processedCount = 0;
|
||||
let queuedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
// 遍历所有设备的队列,只处理启用账号的设备
|
||||
for (const [sn_code, queue] of this.deviceQueues.entries()) {
|
||||
// 跳过未启用的账号
|
||||
if (!enabledSnCodes.has(sn_code)) {
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const queueSize = queue.size();
|
||||
if (queueSize > 0) {
|
||||
queuedCount += queueSize;
|
||||
// 尝试处理该设备的队列
|
||||
this.processQueue(sn_code).catch(error => {
|
||||
console.error(`[任务队列] 扫描执行队列失败 (设备: ${sn_code}):`, error);
|
||||
});
|
||||
processedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (queuedCount > 0) {
|
||||
console.log(`[任务队列] 扫描完成: ${processedCount} 个设备有任务,共 ${queuedCount} 个待执行任务`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[任务队列] 扫描队列失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册任务处理器
|
||||
* @param {string} taskType - 任务类型
|
||||
* @param {function} handler - 处理函数
|
||||
*/
|
||||
registerHandler(taskType, handler) {
|
||||
this.taskHandlers.set(taskType, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找设备是否已有相同类型的任务
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {string} taskType - 任务类型
|
||||
* @returns {object|null} 现有任务或null
|
||||
*/
|
||||
findExistingTask(sn_code, taskType) {
|
||||
// 检查当前正在执行的任务
|
||||
const deviceStatus = this.deviceStatus.get(sn_code);
|
||||
if (deviceStatus && deviceStatus.currentTask && deviceStatus.currentTask.taskType === taskType) {
|
||||
return deviceStatus.currentTask;
|
||||
}
|
||||
|
||||
// 检查队列中等待的任务
|
||||
const queue = this.deviceQueues.get(sn_code);
|
||||
if (queue) {
|
||||
const existingTask = queue.find(task => task.taskType === taskType && task.status === 'pending');
|
||||
if (existingTask) {
|
||||
return existingTask;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查账号是否启用
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @returns {Promise<boolean>} 是否启用
|
||||
*/
|
||||
async checkAccountEnabled(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: ['is_enabled']
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
console.warn(`[任务队列] 设备 ${sn_code} 对应的账号不存在`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const isEnabled = Boolean(account.is_enabled);
|
||||
if (!isEnabled) {
|
||||
console.log(`[任务队列] 设备 ${sn_code} 对应的账号未启用,跳过任务`);
|
||||
}
|
||||
|
||||
return isEnabled;
|
||||
} catch (error) {
|
||||
console.error(`[任务队列] 检查账号启用状态失败:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加任务到队列
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} taskConfig - 任务配置
|
||||
* @returns {Promise<string>} 任务ID
|
||||
*/
|
||||
async addTask(sn_code, taskConfig) {
|
||||
// 检查账号是否启用
|
||||
const isEnabled = await this.checkAccountEnabled(sn_code);
|
||||
if (!isEnabled) {
|
||||
throw new Error(`账号未启用,无法添加任务`);
|
||||
}
|
||||
|
||||
// 检查是否已有相同类型的任务在队列中或正在执行
|
||||
const existingTask = this.findExistingTask(sn_code, taskConfig.taskType);
|
||||
if (existingTask) {
|
||||
console.log(`[任务队列] 设备 ${sn_code} 已有 ${taskConfig.taskType} 任务在执行或等待中,跳过添加`);
|
||||
return existingTask.id;
|
||||
}
|
||||
|
||||
const task = {
|
||||
sn_code,
|
||||
taskType: taskConfig.taskType,
|
||||
taskName: taskConfig.taskName || taskConfig.taskType,
|
||||
taskParams: taskConfig.taskParams || {},
|
||||
priority: taskConfig.priority || 5,
|
||||
maxRetries: taskConfig.maxRetries || 3,
|
||||
retryCount: 0,
|
||||
status: 'pending',
|
||||
createdAt: Date.now()
|
||||
};
|
||||
|
||||
// 初始化设备队列
|
||||
if (!this.deviceQueues.has(sn_code)) {
|
||||
this.deviceQueues.set(sn_code, new PriorityQueue());
|
||||
}
|
||||
|
||||
// 初始化设备状态
|
||||
if (!this.deviceStatus.has(sn_code)) {
|
||||
this.deviceStatus.set(sn_code, {
|
||||
isRunning: false,
|
||||
currentTask: null,
|
||||
runningCount: 0
|
||||
});
|
||||
}
|
||||
|
||||
// 保存到数据库
|
||||
let res = await db.getModel('task_status').create({
|
||||
sn_code: task.sn_code,
|
||||
taskType: task.taskType,
|
||||
taskName: task.taskName,
|
||||
taskParams: JSON.stringify(task.taskParams),
|
||||
status: task.status,
|
||||
priority: task.priority,
|
||||
maxRetries: task.maxRetries,
|
||||
retryCount: task.retryCount,
|
||||
});
|
||||
|
||||
// 使用数据库返回的自增ID
|
||||
task.id = res.id;
|
||||
|
||||
// 添加到优先级队列
|
||||
const queue = this.deviceQueues.get(sn_code);
|
||||
queue.push(task);
|
||||
|
||||
console.log(`[任务队列] 任务已添加到队列: ${task.taskName} (ID: ${task.id}, 优先级: ${task.priority}),等待扫描机制执行`);
|
||||
|
||||
// 不立即执行,等待扫描机制自动执行
|
||||
// 扫描机制会定期检查队列并执行任务
|
||||
|
||||
return res.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理设备的任务队列(工作池模式)
|
||||
* 设备内串行执行,设备间并行执行
|
||||
* @param {string} sn_code - 设备SN码
|
||||
*/
|
||||
async processQueue(sn_code) {
|
||||
try {
|
||||
// 先检查账号是否启用
|
||||
const isEnabled = await this.checkAccountEnabled(sn_code);
|
||||
if (!isEnabled) {
|
||||
// 如果账号未启用,从队列中移除所有待执行任务
|
||||
const queue = this.deviceQueues.get(sn_code);
|
||||
if (queue && queue.size() > 0) {
|
||||
console.log(`[任务队列] 设备 ${sn_code} 账号未启用,清空队列中的 ${queue.size()} 个待执行任务`);
|
||||
// 标记所有待执行任务为已取消
|
||||
const queueArray = queue.toArray();
|
||||
for (const task of queueArray) {
|
||||
if (task.status === 'pending') {
|
||||
try {
|
||||
await db.getModel('task_status').update(
|
||||
{
|
||||
status: 'cancelled',
|
||||
endTime: new Date(),
|
||||
result: JSON.stringify({ error: '账号未启用,任务已取消' })
|
||||
},
|
||||
{ where: { id: task.id } }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`[任务队列] 更新任务状态失败:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
queue.clear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const status = this.deviceStatus.get(sn_code);
|
||||
if (!status) {
|
||||
console.warn(`[任务队列] 设备 ${sn_code} 状态不存在,无法执行任务`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查设备是否正在执行任务(设备内串行)
|
||||
if (status.isRunning || status.runningCount >= this.config.deviceMaxConcurrency) {
|
||||
console.log(`[任务队列] 设备 ${sn_code} 正在执行任务,等待中... (isRunning: ${status.isRunning}, runningCount: ${status.runningCount})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查全局并发限制(设备间并行控制)
|
||||
if (this.globalRunningCount >= this.config.maxConcurrency) {
|
||||
console.log(`[任务队列] 全局并发数已达上限 (${this.globalRunningCount}/${this.config.maxConcurrency}),等待中...`);
|
||||
return;
|
||||
}
|
||||
|
||||
const queue = this.deviceQueues.get(sn_code);
|
||||
if (!queue || queue.isEmpty()) {
|
||||
console.log(`[任务队列] 设备 ${sn_code} 队列为空,无任务可执行`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 从优先级队列取出任务
|
||||
const task = queue.pop();
|
||||
if (!task) {
|
||||
console.warn(`[任务队列] 设备 ${sn_code} 队列非空但无法取出任务`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[任务队列] 开始执行任务: ${task.taskName} (ID: ${task.id}, 设备: ${sn_code})`);
|
||||
|
||||
// 更新状态
|
||||
status.isRunning = true;
|
||||
status.currentTask = task;
|
||||
status.runningCount++;
|
||||
this.globalRunningCount++;
|
||||
|
||||
// 异步执行任务(不阻塞)
|
||||
this.executeTask(task).finally(() => {
|
||||
// 任务完成后更新状态
|
||||
status.isRunning = false;
|
||||
status.currentTask = null;
|
||||
status.runningCount--;
|
||||
this.globalRunningCount--;
|
||||
|
||||
console.log(`[任务队列] 任务完成,设备 ${sn_code} 状态已重置,准备处理下一个任务`);
|
||||
|
||||
// 继续处理队列中的下一个任务(延迟一小段时间,确保状态已更新)
|
||||
setTimeout(() => {
|
||||
this.processQueue(sn_code).catch(error => {
|
||||
console.error(`[任务队列] processQueue 执行失败 (设备: ${sn_code}):`, error);
|
||||
});
|
||||
}, 100); // 延迟100ms确保状态已更新
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[任务队列] processQueue 处理失败 (设备: ${sn_code}):`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行任务(统一重试机制)
|
||||
* @param {object} task - 任务对象
|
||||
*/
|
||||
async executeTask(task) {
|
||||
const startTime = Date.now();
|
||||
task.status = 'running';
|
||||
task.startTime = new Date();
|
||||
|
||||
try {
|
||||
// 执行前再次检查账号是否启用(双重保险)
|
||||
const isEnabled = await this.checkAccountEnabled(task.sn_code);
|
||||
if (!isEnabled) {
|
||||
// 更新任务状态为已取消
|
||||
await db.getModel('task_status').update(
|
||||
{
|
||||
status: 'cancelled',
|
||||
endTime: new Date(),
|
||||
result: JSON.stringify({ error: '账号未启用,任务已取消' })
|
||||
},
|
||||
{ where: { id: task.id } }
|
||||
);
|
||||
throw new Error(`账号未启用,任务已取消`);
|
||||
}
|
||||
|
||||
// 更新数据库状态
|
||||
await db.getModel('task_status').update(
|
||||
{
|
||||
status: 'running',
|
||||
startTime: task.startTime
|
||||
},
|
||||
{ where: { id: task.id } }
|
||||
);
|
||||
|
||||
// 使用注册的任务处理器执行任务
|
||||
const handler = this.taskHandlers.get(task.taskType);
|
||||
if (!handler) {
|
||||
throw new Error(`未找到任务类型 ${task.taskType} 的处理器,请先注册处理器`);
|
||||
}
|
||||
|
||||
// 执行任务处理器
|
||||
const result = await handler(task);
|
||||
|
||||
// 任务成功
|
||||
task.status = 'completed';
|
||||
task.endTime = new Date();
|
||||
task.duration = Date.now() - startTime;
|
||||
task.result = result;
|
||||
|
||||
// 更新数据库
|
||||
await db.getModel('task_status').update(
|
||||
{
|
||||
status: 'completed',
|
||||
endTime: task.endTime,
|
||||
duration: task.duration,
|
||||
result: JSON.stringify(task.result),
|
||||
progress: 100
|
||||
},
|
||||
{ where: { id: task.id } }
|
||||
);
|
||||
|
||||
console.log(`[任务队列] 设备 ${task.sn_code} 任务执行成功: ${task.taskName} (耗时: ${task.duration}ms)`);
|
||||
|
||||
} catch (error) {
|
||||
// 使用统一错误处理
|
||||
const errorInfo = await ErrorHandler.handleError(error, {
|
||||
taskId: task.id,
|
||||
sn_code: task.sn_code,
|
||||
taskType: task.taskType,
|
||||
taskName: task.taskName
|
||||
});
|
||||
|
||||
|
||||
// 直接标记为失败(重试已禁用)
|
||||
task.status = 'failed';
|
||||
task.endTime = new Date();
|
||||
task.duration = Date.now() - startTime;
|
||||
task.errorMessage = errorInfo.message || error.message || '未知错误';
|
||||
task.errorStack = errorInfo.stack || error.stack || '';
|
||||
|
||||
console.error(`[任务队列] 任务执行失败: ${task.taskName} (ID: ${task.id}), 错误: ${task.errorMessage}`, {
|
||||
errorStack: task.errorStack,
|
||||
taskId: task.id,
|
||||
sn_code: task.sn_code,
|
||||
taskType: task.taskType
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消任务
|
||||
* @param {string} taskId - 任务ID
|
||||
* @returns {Promise<boolean>} 是否成功取消
|
||||
*/
|
||||
async cancelTask(taskId) {
|
||||
// 遍历所有设备队列查找任务
|
||||
for (const [sn_code, queue] of this.deviceQueues.entries()) {
|
||||
const removed = queue.remove(task => task.id === taskId);
|
||||
|
||||
if (removed) {
|
||||
// 检查是否正在执行
|
||||
const status = this.deviceStatus.get(sn_code);
|
||||
if (status && status.currentTask && status.currentTask.id === taskId) {
|
||||
// 正在执行的任务无法取消,只能标记
|
||||
console.warn(`[任务队列] 任务 ${taskId} 正在执行,无法取消`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 更新数据库
|
||||
await db.getModel('task_status').update(
|
||||
{
|
||||
status: 'cancelled',
|
||||
endTime: new Date()
|
||||
},
|
||||
{ where: { id: taskId } }
|
||||
);
|
||||
|
||||
console.log(`[任务队列] 任务已取消: ${taskId}`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 未找到可取消的任务
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消设备的所有待执行任务
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @returns {Promise<number>} 取消的任务数量
|
||||
*/
|
||||
async cancelDeviceTasks(sn_code) {
|
||||
let cancelledCount = 0;
|
||||
|
||||
// 1. 从队列中移除所有待执行任务
|
||||
const queue = this.deviceQueues.get(sn_code);
|
||||
if (queue) {
|
||||
const pendingTasks = [];
|
||||
// 获取所有待执行任务(不包括正在执行的)
|
||||
const status = this.deviceStatus.get(sn_code);
|
||||
const currentTaskId = status && status.currentTask ? status.currentTask.id : null;
|
||||
|
||||
// 遍历队列,收集待取消的任务
|
||||
const queueArray = queue.toArray();
|
||||
for (const task of queueArray) {
|
||||
if (task.id !== currentTaskId && (task.status === 'pending' || !task.status)) {
|
||||
pendingTasks.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
// 从队列中移除这些任务
|
||||
for (const task of pendingTasks) {
|
||||
queue.remove(t => t.id === task.id);
|
||||
cancelledCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 更新数据库中的任务状态
|
||||
try {
|
||||
const taskStatusModel = db.getModel('task_status');
|
||||
const status = this.deviceStatus.get(sn_code);
|
||||
const currentTaskId = status && status.currentTask ? status.currentTask.id : null;
|
||||
|
||||
// 更新所有待执行或运行中的任务(除了当前正在执行的)
|
||||
const whereCondition = {
|
||||
sn_code: sn_code,
|
||||
status: ['pending', 'running']
|
||||
};
|
||||
|
||||
if (currentTaskId) {
|
||||
whereCondition.id = { [Sequelize.Op.ne]: currentTaskId };
|
||||
}
|
||||
|
||||
const updateResult = await taskStatusModel.update(
|
||||
{
|
||||
status: 'cancelled',
|
||||
endTime: new Date()
|
||||
},
|
||||
{ where: whereCondition }
|
||||
);
|
||||
|
||||
const dbCancelledCount = Array.isArray(updateResult) ? updateResult[0] : updateResult;
|
||||
console.log(`[任务队列] 设备 ${sn_code} 已取消 ${cancelledCount} 个队列任务,${dbCancelledCount} 个数据库任务`);
|
||||
} catch (error) {
|
||||
console.error(`[任务队列] 更新数据库任务状态失败:`, error, {
|
||||
sn_code: sn_code,
|
||||
cancelledCount: cancelledCount
|
||||
});
|
||||
}
|
||||
|
||||
return cancelledCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取设备队列状态
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @returns {object} 队列状态
|
||||
*/
|
||||
getDeviceStatus(sn_code) {
|
||||
const queue = this.deviceQueues.get(sn_code);
|
||||
const status = this.deviceStatus.get(sn_code) || {
|
||||
isRunning: false,
|
||||
currentTask: null,
|
||||
runningCount: 0
|
||||
};
|
||||
|
||||
return {
|
||||
sn_code,
|
||||
isRunning: status.isRunning,
|
||||
currentTask: status.currentTask,
|
||||
queueLength: queue ? queue.size() : 0,
|
||||
pendingTasks: queue ? queue.size() : 0,
|
||||
runningCount: status.runningCount
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务状态
|
||||
* @param {string} taskId - 任务ID
|
||||
* @returns {Promise<object|null>} 任务对象
|
||||
*/
|
||||
async getTaskStatus(taskId) {
|
||||
// 先从内存中查找
|
||||
for (const queue of this.deviceQueues.values()) {
|
||||
const task = queue.find(t => t.id === taskId);
|
||||
if (task) {
|
||||
return task;
|
||||
}
|
||||
}
|
||||
|
||||
// 从正在执行的任务中查找
|
||||
for (const status of this.deviceStatus.values()) {
|
||||
if (status.currentTask && status.currentTask.id === taskId) {
|
||||
return status.currentTask;
|
||||
}
|
||||
}
|
||||
|
||||
// 从数据库中查找
|
||||
try {
|
||||
const taskRecord = await db.getModel('task_status').findOne({
|
||||
where: { id: taskId }
|
||||
});
|
||||
|
||||
if (taskRecord) {
|
||||
return taskRecord.toJSON();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[任务队列] 查询任务状态失败:`, error, {
|
||||
taskId: taskId
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空设备队列
|
||||
* @param {string} sn_code - 设备SN码
|
||||
*/
|
||||
clearQueue(sn_code) {
|
||||
if (this.deviceQueues.has(sn_code)) {
|
||||
const queue = this.deviceQueues.get(sn_code);
|
||||
const count = queue.size();
|
||||
queue.clear();
|
||||
console.log(`[任务队列] 已清空设备 ${sn_code} 的队列,共移除 ${count} 个任务`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除所有任务(从内存队列和数据库)
|
||||
* @returns {Promise<object>} 删除结果
|
||||
*/
|
||||
async deleteAllTaskFromDatabase() {
|
||||
try {
|
||||
console.log('[任务队列] 开始删除所有任务...');
|
||||
|
||||
let totalQueued = 0;
|
||||
let totalRunning = 0;
|
||||
|
||||
// 1. 清空所有设备的内存队列
|
||||
for (const [sn_code, queue] of this.deviceQueues.entries()) {
|
||||
const queueSize = queue.size();
|
||||
totalQueued += queueSize;
|
||||
queue.clear();
|
||||
|
||||
// 重置设备状态(但保留正在执行的任务信息,稍后处理)
|
||||
const status = this.deviceStatus.get(sn_code);
|
||||
if (status && status.currentTask) {
|
||||
totalRunning++;
|
||||
// 标记正在执行的任务,但不立即取消(让它们自然完成或失败)
|
||||
console.warn(`[任务队列] 设备 ${sn_code} 有正在执行的任务,将在完成后清理`);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 使用 MCP MySQL 删除所有关联数据(先删除关联表,再删除主表)
|
||||
// 注意:MCP MySQL 是只读的,这里使用 Sequelize 执行删除操作
|
||||
// 但移除数据库层面的外键关联,避免约束问题
|
||||
|
||||
const taskCommandsModel = db.getModel('task_commands');
|
||||
const chatRecordsModel = db.getModel('chat_records');
|
||||
const applyRecordsModel = db.getModel('apply_records');
|
||||
const taskStatusModel = db.getModel('task_status');
|
||||
|
||||
// 删除任务指令记录(所有记录)
|
||||
const commandsDeleted = await taskCommandsModel.destroy({
|
||||
where: {},
|
||||
truncate: false
|
||||
});
|
||||
console.log(`[任务队列] 已删除任务指令记录: ${commandsDeleted} 条`);
|
||||
|
||||
// 删除聊天记录中关联的任务记录(删除所有有 taskId 且不为空的记录)
|
||||
const chatRecordsDeleted = await chatRecordsModel.destroy({
|
||||
where: {
|
||||
[Sequelize.Op.and]: [
|
||||
{ taskId: { [Sequelize.Op.ne]: null } },
|
||||
{ taskId: { [Sequelize.Op.ne]: '' } }
|
||||
]
|
||||
},
|
||||
truncate: false
|
||||
});
|
||||
console.log(`[任务队列] 已删除聊天记录: ${chatRecordsDeleted} 条`);
|
||||
|
||||
// 删除投递记录中关联的任务记录(删除所有有 taskId 且不为空的记录)
|
||||
const applyRecordsDeleted = await applyRecordsModel.destroy({
|
||||
where: {
|
||||
[Sequelize.Op.and]: [
|
||||
{ taskId: { [Sequelize.Op.ne]: null } },
|
||||
{ taskId: { [Sequelize.Op.ne]: '' } }
|
||||
]
|
||||
},
|
||||
truncate: false
|
||||
});
|
||||
console.log(`[任务队列] 已删除投递记录: ${applyRecordsDeleted} 条`);
|
||||
|
||||
// 3. 删除数据库中的所有任务记录
|
||||
const deleteResult = await taskStatusModel.destroy({
|
||||
where: {},
|
||||
truncate: false // 使用 DELETE 而不是 TRUNCATE,保留表结构
|
||||
});
|
||||
|
||||
console.log(`[任务队列] 已删除所有任务:`);
|
||||
console.log(` - 内存队列任务: ${totalQueued} 个`);
|
||||
console.log(` - 正在执行任务: ${totalRunning} 个(将在完成后清理)`);
|
||||
console.log(` - 任务指令记录: ${commandsDeleted} 条`);
|
||||
console.log(` - 聊天记录: ${chatRecordsDeleted} 条`);
|
||||
console.log(` - 投递记录: ${applyRecordsDeleted} 条`);
|
||||
console.log(` - 数据库任务记录: ${deleteResult} 条`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
memoryQueued: totalQueued,
|
||||
memoryRunning: totalRunning,
|
||||
commandsDeleted: commandsDeleted,
|
||||
chatRecordsDeleted: chatRecordsDeleted,
|
||||
applyRecordsDeleted: applyRecordsDeleted,
|
||||
databaseDeleted: deleteResult,
|
||||
message: `已删除所有任务及关联数据(任务: ${deleteResult} 条,指令: ${commandsDeleted} 条,聊天: ${chatRecordsDeleted} 条,投递: ${applyRecordsDeleted} 条)`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[任务队列] 删除所有任务失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有设备的队列状态
|
||||
* @returns {array} 所有设备的队列状态
|
||||
*/
|
||||
getAllDeviceStatus() {
|
||||
const allStatus = [];
|
||||
|
||||
for (const sn_code of this.deviceQueues.keys()) {
|
||||
allStatus.push(this.getDeviceStatus(sn_code));
|
||||
}
|
||||
|
||||
return allStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取全局统计信息
|
||||
* @returns {object} 统计信息
|
||||
*/
|
||||
getStatistics() {
|
||||
let totalQueued = 0;
|
||||
for (const queue of this.deviceQueues.values()) {
|
||||
totalQueued += queue.size();
|
||||
}
|
||||
|
||||
return {
|
||||
globalRunningCount: this.globalRunningCount,
|
||||
maxConcurrency: this.config.maxConcurrency,
|
||||
totalDevices: this.deviceQueues.size,
|
||||
totalQueuedTasks: totalQueued,
|
||||
deviceStatuses: this.getAllDeviceStatus()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取MQTT客户端(统一管理)
|
||||
* @returns {Promise<object|null>} MQTT客户端实例
|
||||
*/
|
||||
async getMqttClient() {
|
||||
try {
|
||||
// 首先尝试从调度系统获取已初始化的MQTT客户端
|
||||
const scheduleManager = require('./index');
|
||||
if (scheduleManager.mqttClient) {
|
||||
return scheduleManager.mqttClient;
|
||||
}
|
||||
|
||||
// 如果调度系统没有初始化,则直接创建
|
||||
const mqttManager = require('../mqtt/mqttManager');
|
||||
console.log('[任务队列] 创建新的MQTT客户端');
|
||||
return await mqttManager.getInstance();
|
||||
} catch (error) {
|
||||
console.error(`[任务队列] 获取MQTT客户端失败:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
const taskQueue = new TaskQueue();
|
||||
module.exports = taskQueue;
|
||||
265
api/middleware/schedule/utils.js
Normal file
265
api/middleware/schedule/utils.js
Normal file
@@ -0,0 +1,265 @@
|
||||
const dayjs = require('dayjs');
|
||||
|
||||
/**
|
||||
* 调度系统工具函数
|
||||
* 提供通用的辅助功能
|
||||
*/
|
||||
class ScheduleUtils {
|
||||
/**
|
||||
* 生成唯一任务ID
|
||||
* @returns {string} 任务ID
|
||||
*/
|
||||
static generateTaskId() {
|
||||
return `task_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一指令ID
|
||||
* @returns {string} 指令ID
|
||||
*/
|
||||
static generateCommandId() {
|
||||
return `cmd_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间戳
|
||||
* @param {number} timestamp - 时间戳
|
||||
* @returns {string} 格式化的时间
|
||||
*/
|
||||
static formatTimestamp(timestamp) {
|
||||
return dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化持续时间
|
||||
* @param {number} ms - 毫秒数
|
||||
* @returns {string} 格式化的持续时间
|
||||
*/
|
||||
static formatDuration(ms) {
|
||||
if (ms < 1000) {
|
||||
return `${ms}ms`;
|
||||
} else if (ms < 60 * 1000) {
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
} else if (ms < 60 * 60 * 1000) {
|
||||
return `${(ms / (60 * 1000)).toFixed(1)}min`;
|
||||
} else {
|
||||
return `${(ms / (60 * 60 * 1000)).toFixed(1)}h`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 深度克隆对象
|
||||
* @param {object} obj - 要克隆的对象
|
||||
* @returns {object} 克隆后的对象
|
||||
*/
|
||||
static deepClone(obj) {
|
||||
if (obj === null || typeof obj !== 'object') {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (obj instanceof Date) {
|
||||
return new Date(obj.getTime());
|
||||
}
|
||||
|
||||
if (obj instanceof Array) {
|
||||
return obj.map(item => this.deepClone(item));
|
||||
}
|
||||
|
||||
const cloned = {};
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key)) {
|
||||
cloned[key] = this.deepClone(obj[key]);
|
||||
}
|
||||
}
|
||||
|
||||
return cloned;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全解析JSON
|
||||
* @param {string} jsonString - JSON字符串
|
||||
* @param {any} defaultValue - 默认值
|
||||
* @returns {any} 解析结果或默认值
|
||||
*/
|
||||
static safeJsonParse(jsonString, defaultValue = null) {
|
||||
try {
|
||||
return JSON.parse(jsonString);
|
||||
} catch (error) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全序列化JSON
|
||||
* @param {any} obj - 要序列化的对象
|
||||
* @param {string} defaultValue - 默认值
|
||||
* @returns {string} JSON字符串或默认值
|
||||
*/
|
||||
static safeJsonStringify(obj, defaultValue = '{}') {
|
||||
try {
|
||||
return JSON.stringify(obj);
|
||||
} catch (error) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 延迟执行
|
||||
* @param {number} ms - 延迟毫秒数
|
||||
* @returns {Promise} Promise对象
|
||||
*/
|
||||
static delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试执行函数
|
||||
* @param {function} fn - 要执行的函数
|
||||
* @param {number} maxRetries - 最大重试次数
|
||||
* @param {number} delay - 重试延迟(毫秒)
|
||||
* @returns {Promise} 执行结果
|
||||
*/
|
||||
static async retry(fn, maxRetries = 3, delay = 1000) {
|
||||
let lastError;
|
||||
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
if (i < maxRetries) {
|
||||
console.log(`[工具函数] 执行失败,${delay}ms后重试 (${i + 1}/${maxRetries + 1}):`, error.message);
|
||||
await this.delay(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* 限制并发执行数量
|
||||
* @param {Array} tasks - 任务数组
|
||||
* @param {number} concurrency - 并发数量
|
||||
* @returns {Promise<Array>} 执行结果数组
|
||||
*/
|
||||
static async limitConcurrency(tasks, concurrency = 5) {
|
||||
const results = [];
|
||||
const executing = [];
|
||||
|
||||
for (const task of tasks) {
|
||||
const promise = Promise.resolve(task()).then(result => {
|
||||
executing.splice(executing.indexOf(promise), 1);
|
||||
return result;
|
||||
});
|
||||
|
||||
results.push(promise);
|
||||
|
||||
if (tasks.length >= concurrency) {
|
||||
executing.push(promise);
|
||||
|
||||
if (executing.length >= concurrency) {
|
||||
await Promise.race(executing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.all(results);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带超时的Promise
|
||||
* @param {Promise} promise - 原始Promise
|
||||
* @param {number} timeout - 超时时间(毫秒)
|
||||
* @param {string} timeoutMessage - 超时错误消息
|
||||
* @returns {Promise} 带超时的Promise
|
||||
*/
|
||||
static withTimeout(promise, timeout, timeoutMessage = 'Operation timed out') {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error(timeoutMessage)), timeout);
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取今天的日期字符串
|
||||
* @returns {string} 日期字符串 YYYY-MM-DD
|
||||
*/
|
||||
static getTodayString() {
|
||||
return dayjs().format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查日期是否为今天
|
||||
* @param {string|Date} date - 日期
|
||||
* @returns {boolean} 是否为今天
|
||||
*/
|
||||
static isToday(date) {
|
||||
return dayjs(date).format('YYYY-MM-DD') === this.getTodayString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取随机延迟时间
|
||||
* @param {number} min - 最小延迟(毫秒)
|
||||
* @param {number} max - 最大延迟(毫秒)
|
||||
* @returns {number} 随机延迟时间
|
||||
*/
|
||||
static getRandomDelay(min = 1000, max = 5000) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
* @param {number} bytes - 字节数
|
||||
* @returns {string} 格式化的文件大小
|
||||
*/
|
||||
static formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算成功率
|
||||
* @param {number} success - 成功次数
|
||||
* @param {number} total - 总次数
|
||||
* @returns {string} 百分比字符串
|
||||
*/
|
||||
static calculateSuccessRate(success, total) {
|
||||
if (total === 0) return '0%';
|
||||
return ((success / total) * 100).toFixed(2) + '%';
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证设备SN码格式
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @returns {boolean} 是否有效
|
||||
*/
|
||||
static isValidSnCode(sn_code) {
|
||||
return typeof sn_code === 'string' && sn_code.length > 0 && sn_code.length <= 50;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理对象中的空值
|
||||
* @param {object} obj - 要清理的对象
|
||||
* @returns {object} 清理后的对象
|
||||
*/
|
||||
static cleanObject(obj) {
|
||||
const cleaned = {};
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key) && obj[key] !== null && obj[key] !== undefined && obj[key] !== '') {
|
||||
cleaned[key] = obj[key];
|
||||
}
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ScheduleUtils;
|
||||
12
api/middleware/sqlUpdate.js
Normal file
12
api/middleware/sqlUpdate.js
Normal file
@@ -0,0 +1,12 @@
|
||||
const { querySql, user, op } = require("./baseModel");
|
||||
class SqlUpdate {
|
||||
async init() {
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
const sqlUpdate = new SqlUpdate();
|
||||
module.exports = sqlUpdate;
|
||||
Reference in New Issue
Block a user