1
This commit is contained in:
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();
|
||||
Reference in New Issue
Block a user