1
This commit is contained in:
9
api/middleware/job/services/index.js
Normal file
9
api/middleware/job/services/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Services 模块统一导出
|
||||
*/
|
||||
|
||||
const jobFilterService = require('./jobFilterService');
|
||||
|
||||
module.exports = {
|
||||
jobFilterService
|
||||
};
|
||||
884
api/middleware/job/services/jobFilterService.js
Normal file
884
api/middleware/job/services/jobFilterService.js
Normal file
@@ -0,0 +1,884 @@
|
||||
/**
|
||||
* 职位文本匹配过滤服务
|
||||
* 使用简单的文本匹配规则来过滤职位,替代 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 {string|number} jobPostingId - 职位ID(job_postings表的主键ID)
|
||||
* @param {object} analysisResult - 分析结果
|
||||
* @returns {Promise<boolean>} 是否保存成功
|
||||
*/
|
||||
async saveMatchAnalysisToDatabase(jobPostingId, analysisResult) {
|
||||
try {
|
||||
const job_postings = db.getModel('job_postings');
|
||||
if (!job_postings) {
|
||||
console.warn('[职位过滤服务] job_postings 模型不存在,跳过保存');
|
||||
return false;
|
||||
}
|
||||
|
||||
const updateData = {
|
||||
textMatchScore: analysisResult.overallScore || 0,
|
||||
isOutsourcing: analysisResult.isOutsourcing || false,
|
||||
matchSuggestion: analysisResult.suggestion || '',
|
||||
matchConcerns: JSON.stringify(analysisResult.concerns || []),
|
||||
textMatchAnalysis: JSON.stringify(analysisResult.analysis || analysisResult)
|
||||
};
|
||||
|
||||
await job_postings.update(updateData, {
|
||||
where: { id: jobPostingId }
|
||||
});
|
||||
|
||||
console.log(`[职位过滤服务] 已保存职位 ${jobPostingId} 的匹配分析结果,综合评分: ${analysisResult.overallScore}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[职位过滤服务] 保存匹配分析结果失败:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用文本匹配分析职位与简历的匹配度
|
||||
* @param {object} jobInfo - 职位信息
|
||||
* @param {object} resumeInfo - 简历信息(可选)
|
||||
* @param {number} jobTypeId - 职位类型ID(可选)
|
||||
* @param {object} options - 选项
|
||||
* @param {number} options.jobPostingId - 职位ID(job_postings表的主键ID),如果提供则自动保存到数据库
|
||||
* @param {boolean} options.autoSave - 是否自动保存到数据库(默认false)
|
||||
* @returns {Promise<object>} 匹配度分析结果
|
||||
*/
|
||||
async analyzeJobMatch(jobInfo, resumeInfo = {}, jobTypeId = null, options = {}) {
|
||||
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);
|
||||
|
||||
const result = {
|
||||
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
|
||||
}
|
||||
};
|
||||
|
||||
// 如果提供了 jobPostingId 且 autoSave 为 true,自动保存到数据库
|
||||
if (options.jobPostingId && options.autoSave !== false) {
|
||||
await this.saveMatchAnalysisToDatabase(options.jobPostingId, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建职位文本(用于匹配)
|
||||
* @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) {
|
||||
const concernsCount = concerns ? concerns.length : 0;
|
||||
|
||||
// 不推荐:外包 或 关注点过多(>2)
|
||||
if (isOutsourcing) {
|
||||
return '不推荐投递:外包岗位';
|
||||
}
|
||||
|
||||
if (concernsCount > 2) {
|
||||
return '不推荐投递:存在多个关注点';
|
||||
}
|
||||
|
||||
// 不推荐:< 60 分
|
||||
if (overallScore < 60) {
|
||||
return '不推荐投递:匹配度较低';
|
||||
}
|
||||
|
||||
// 优先级1(必须投递):≥ 80 分,且无关注点,非外包
|
||||
if (overallScore >= 80 && concernsCount === 0) {
|
||||
return '必须投递:匹配度很高,无关注点';
|
||||
}
|
||||
|
||||
// 优先级2(可以投递):60-79 分,关注点 ≤ 2 个,非外包(优先处理无关注点情况)
|
||||
if (overallScore >= 60 && overallScore < 80 && concernsCount === 0) {
|
||||
return '可以投递:匹配度中等,无关注点';
|
||||
}
|
||||
|
||||
// 优先级3(谨慎考虑):60-79 分但有关注点,或 ≥ 80 分但有关注点
|
||||
if (overallScore >= 60 && concernsCount > 0 && concernsCount <= 2) {
|
||||
return '谨慎考虑:存在关注点或特殊情况';
|
||||
}
|
||||
|
||||
// 兜底情况
|
||||
return '谨慎考虑:需要进一步评估';
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析薪资范围
|
||||
* @param {string} salaryDesc - 薪资描述(如 "15-25K·14薪"、"5000-6000元/月")
|
||||
* @returns {object|null} 薪资范围 { min, max },单位:元
|
||||
*/
|
||||
parseSalaryRange(salaryDesc) {
|
||||
if (!salaryDesc) return null;
|
||||
|
||||
// 1. 匹配K格式:40-60K, 30-40K·18薪(忽略后面的薪数)
|
||||
const kMatch = salaryDesc.match(/(\d+)[-~](\d+)[Kk千]/);
|
||||
if (kMatch) {
|
||||
return {
|
||||
min: parseInt(kMatch[1]) * 1000,
|
||||
max: parseInt(kMatch[2]) * 1000
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 匹配单个K值:25K
|
||||
const singleKMatch = salaryDesc.match(/(\d+)[Kk千]/);
|
||||
if (singleKMatch) {
|
||||
const value = parseInt(singleKMatch[1]) * 1000;
|
||||
return { min: value, max: value };
|
||||
}
|
||||
|
||||
// 3. 匹配元/月格式:5000-6000元/月
|
||||
const yuanMatch = salaryDesc.match(/(\d+)[-~](\d+)[元万]/);
|
||||
if (yuanMatch) {
|
||||
const min = parseInt(yuanMatch[1]);
|
||||
const max = parseInt(yuanMatch[2]);
|
||||
// 判断单位(万或元)
|
||||
if (salaryDesc.includes('万')) {
|
||||
return {
|
||||
min: min * 10000,
|
||||
max: max * 10000
|
||||
};
|
||||
} else {
|
||||
return { min, max };
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 匹配单个元/月值:5000元/月
|
||||
const singleYuanMatch = salaryDesc.match(/(\d+)[元万]/);
|
||||
if (singleYuanMatch) {
|
||||
const value = parseInt(singleYuanMatch[1]);
|
||||
if (salaryDesc.includes('万')) {
|
||||
return { min: value * 10000, max: value * 10000 };
|
||||
} else {
|
||||
return { min: value, max: value };
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 匹配纯数字格式(如:20000-30000)
|
||||
const numMatch = salaryDesc.match(/(\d+)[-~](\d+)/);
|
||||
if (numMatch) {
|
||||
return {
|
||||
min: parseInt(numMatch[1]),
|
||||
max: parseInt(numMatch[2])
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析期望薪资(返回平均值)
|
||||
* @param {string} expectedSalary - 期望薪资描述(如 "20-30K"、"5000-6000元/月")
|
||||
* @returns {number|null} 期望薪资数值(元),如果是范围则返回平均值
|
||||
*/
|
||||
parseExpectedSalary(expectedSalary) {
|
||||
if (!expectedSalary) return null;
|
||||
|
||||
// 1. 匹配K格式范围:20-30K
|
||||
const kRangeMatch = expectedSalary.match(/(\d+)[-~](\d+)[Kk千]/);
|
||||
if (kRangeMatch) {
|
||||
const min = parseInt(kRangeMatch[1]) * 1000;
|
||||
const max = parseInt(kRangeMatch[2]) * 1000;
|
||||
return (min + max) / 2; // 返回平均值
|
||||
}
|
||||
|
||||
// 2. 匹配单个K值:25K
|
||||
const kMatch = expectedSalary.match(/(\d+)[Kk千]/);
|
||||
if (kMatch) {
|
||||
return parseInt(kMatch[1]) * 1000;
|
||||
}
|
||||
|
||||
// 3. 匹配元/月格式范围:5000-6000元/月
|
||||
const yuanRangeMatch = expectedSalary.match(/(\d+)[-~](\d+)[元万]/);
|
||||
if (yuanRangeMatch) {
|
||||
const min = parseInt(yuanRangeMatch[1]);
|
||||
const max = parseInt(yuanRangeMatch[2]);
|
||||
if (expectedSalary.includes('万')) {
|
||||
return ((min + max) / 2) * 10000;
|
||||
} else {
|
||||
return (min + max) / 2; // 返回平均值
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 匹配单个元/月值:5000元/月
|
||||
const yuanMatch = expectedSalary.match(/(\d+)[元万]/);
|
||||
if (yuanMatch) {
|
||||
const value = parseInt(yuanMatch[1]);
|
||||
if (expectedSalary.includes('万')) {
|
||||
return value * 10000;
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 匹配纯数字范围(如:20000-30000)
|
||||
const numRangeMatch = expectedSalary.match(/(\d+)[-~](\d+)/);
|
||||
if (numRangeMatch) {
|
||||
return (parseInt(numRangeMatch[1]) + parseInt(numRangeMatch[2])) / 2; // 返回平均值
|
||||
}
|
||||
|
||||
// 6. 匹配纯数字(如:20000)
|
||||
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(可选)
|
||||
* @param {object} options - 选项
|
||||
* @param {boolean} options.autoSave - 是否自动保存评分结果到数据库(默认false)
|
||||
* @returns {Promise<Array>} 过滤后的职位列表(带匹配分数)
|
||||
*/
|
||||
async filterJobs(jobs, filterRules = {}, resumeInfo = {}, jobTypeId = null, options = {}) {
|
||||
const {
|
||||
minScore = 60, // 最低匹配分数
|
||||
excludeOutsourcing = true, // 是否排除外包
|
||||
excludeKeywords = [] // 额外排除关键词
|
||||
} = filterRules;
|
||||
|
||||
const { autoSave = false } = options;
|
||||
|
||||
// 获取职位类型配置
|
||||
const { excludeKeywords: typeExcludeKeywords } = await this.getJobTypeConfig(jobTypeId);
|
||||
const allExcludeKeywords = [...typeExcludeKeywords, ...excludeKeywords];
|
||||
|
||||
const results = [];
|
||||
for (const job of jobs) {
|
||||
const jobData = job.toJSON ? job.toJSON() : job;
|
||||
|
||||
// 分析匹配度(如果 autoSave 为 true 且 job 有 id,则自动保存)
|
||||
const analysisOptions = autoSave && jobData.id ? {
|
||||
jobPostingId: jobData.id,
|
||||
autoSave: true
|
||||
} : {};
|
||||
|
||||
const analysis = await this.analyzeJobMatch(jobData, resumeInfo, jobTypeId, analysisOptions);
|
||||
|
||||
results.push({
|
||||
...jobData,
|
||||
matchScore: analysis.overallScore,
|
||||
matchAnalysis: analysis
|
||||
});
|
||||
}
|
||||
|
||||
return results
|
||||
.filter(job => {
|
||||
// 1. 最低分数过滤
|
||||
if (job.matchScore < minScore) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 外包过滤
|
||||
if (excludeOutsourcing && job.matchAnalysis.isOutsourcing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 排除关键词过滤
|
||||
const jobText = this.buildJobText(job);
|
||||
if (allExcludeKeywords.some(kw => jobText.includes(kw.toLowerCase()))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => b.matchScore - a.matchScore); // 按匹配分数降序排序
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据自定义权重配置计算职位评分
|
||||
* @param {Object} jobData - 职位数据
|
||||
* @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;
|
||||
|
||||
// 解析权重配置,根据排序优先级规范化权重(确保总和为100)
|
||||
const weights = {};
|
||||
if (Array.isArray(priorityWeights) && priorityWeights.length > 0) {
|
||||
// 计算权重总和
|
||||
const totalWeight = priorityWeights.reduce((sum, item) => sum + (Number(item.weight) || 0), 0);
|
||||
|
||||
// 如果总和大于0,按比例规范化权重(总和归一化为1,即100%)
|
||||
if (totalWeight > 0) {
|
||||
priorityWeights.forEach(item => {
|
||||
weights[item.key] = (Number(item.weight) || 0) / totalWeight; // 规范化权重(0-1之间)
|
||||
});
|
||||
} else {
|
||||
// 如果权重总和为0或未设置,按照排序顺序分配权重(第一个优先级最高)
|
||||
// 使用等差数列递减:第一个权重最高,依次递减
|
||||
const n = priorityWeights.length;
|
||||
const totalSequentialWeight = (n * (n + 1)) / 2; // 1+2+...+n
|
||||
priorityWeights.forEach((item, index) => {
|
||||
// 按照排序顺序,第一个权重最高,依次递减(权重比例为 n, n-1, n-2, ..., 1)
|
||||
const sequentialWeight = n - index;
|
||||
weights[item.key] = sequentialWeight / totalSequentialWeight;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 距离评分(基于经纬度)
|
||||
if (weights.distance) {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user