1
This commit is contained in:
34
_sql/add_job_postings_text_match_fields.sql
Normal file
34
_sql/add_job_postings_text_match_fields.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
-- 为 job_postings 表添加文本匹配分析结果字段
|
||||
-- 执行时间: 2025-01-XX
|
||||
|
||||
-- ============================================
|
||||
-- 更新 job_postings 表
|
||||
-- ============================================
|
||||
|
||||
-- 添加 textMatchScore 字段(文本匹配综合评分)
|
||||
ALTER TABLE job_postings
|
||||
ADD COLUMN textMatchScore INT(11) DEFAULT 0 COMMENT '文本匹配综合评分' AFTER aiAnalysis;
|
||||
|
||||
-- 添加 matchSuggestion 字段(投递建议)
|
||||
ALTER TABLE job_postings
|
||||
ADD COLUMN matchSuggestion VARCHAR(200) DEFAULT '' COMMENT '投递建议' AFTER textMatchScore;
|
||||
|
||||
-- 添加 matchConcerns 字段(关注点,JSON数组)
|
||||
ALTER TABLE job_postings
|
||||
ADD COLUMN matchConcerns TEXT COMMENT '关注点(JSON数组)' AFTER matchSuggestion;
|
||||
|
||||
-- 添加 textMatchAnalysis 字段(文本匹配详细分析结果,JSON)
|
||||
ALTER TABLE job_postings
|
||||
ADD COLUMN textMatchAnalysis TEXT COMMENT '文本匹配详细分析结果(JSON)' AFTER matchConcerns;
|
||||
|
||||
-- ============================================
|
||||
-- 说明
|
||||
-- ============================================
|
||||
-- 1. 如果字段已存在,执行 ALTER TABLE 会报错,可以忽略
|
||||
-- 2. 执行前建议先备份数据库
|
||||
-- 3. 字段说明:
|
||||
-- - textMatchScore: 文本匹配综合评分(0-100)
|
||||
-- - matchSuggestion: 投递建议(如:必须投递、可以投递、谨慎考虑等)
|
||||
-- - matchConcerns: 关注点列表(JSON数组格式,如:["可能是外包岗位", "工作经验可能不足"])
|
||||
-- - textMatchAnalysis: 完整分析结果(JSON格式,包含所有详细分析数据)
|
||||
|
||||
@@ -3,19 +3,21 @@ const config = require('../../../config/config');
|
||||
const logs = require('../logProxy');
|
||||
|
||||
/**
|
||||
* DeepSeek大模型服务
|
||||
* 集成DeepSeek API,提供智能化的岗位筛选、聊天生成、简历分析等功能
|
||||
* Qwen 2.5 大模型服务
|
||||
* 集成阿里云 DashScope API,提供智能化的岗位筛选、聊天生成、简历分析等功能
|
||||
*/
|
||||
class aiService {
|
||||
constructor() {
|
||||
this.apiKey = config.deepseekApiKey || process.env.DEEPSEEK_API_KEY;
|
||||
this.apiUrl = config.deepseekApiUrl || 'https://api.deepseek.com/v1/chat/completions';
|
||||
this.model = config.deepseekModel || 'deepseek-chat';
|
||||
this.apiKey = config.ai?.apiKey || process.env.DASHSCOPE_API_KEY;
|
||||
// 使用 DashScope 兼容 OpenAI 格式的接口
|
||||
this.apiUrl = 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions';
|
||||
// Qwen 2.5 模型:qwen-turbo(快速)、qwen-plus(增强)、qwen-max(最强)
|
||||
this.model = config.ai?.model || 'qwen-turbo';
|
||||
this.maxRetries = 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用DeepSeek API
|
||||
* 调用 Qwen 2.5 API
|
||||
* @param {string} prompt - 提示词
|
||||
* @param {object} options - 配置选项
|
||||
* @returns {Promise<object>} API响应结果
|
||||
@@ -42,7 +44,7 @@ class aiService {
|
||||
try {
|
||||
const response = await axios.post(this.apiUrl, requestData, {
|
||||
headers: {
|
||||
'Authorization': `${this.apiKey}`,
|
||||
'Authorization': `Bearer ${this.apiKey}`, // DashScope 使用 Bearer token 格式
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
timeout: 30000
|
||||
@@ -53,7 +55,7 @@ class aiService {
|
||||
content: response.data.choices?.[0]?.message?.content || ''
|
||||
};
|
||||
} catch (error) {
|
||||
console.log(`DeepSeek API调用失败 (尝试 ${attempt}/${this.maxRetries}): ${error.message}`);
|
||||
console.log(`Qwen 2.5 API调用失败 (尝试 ${attempt}/${this.maxRetries}): ${error.message}`);
|
||||
|
||||
if (attempt === this.maxRetries) {
|
||||
throw new Error(error.message);
|
||||
|
||||
@@ -136,14 +136,51 @@ class JobFilterService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存匹配分析结果到数据库
|
||||
* @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) {
|
||||
async analyzeJobMatch(jobInfo, resumeInfo = {}, jobTypeId = null, options = {}) {
|
||||
const jobText = this.buildJobText(jobInfo);
|
||||
const resumeText = this.buildResumeText(resumeInfo);
|
||||
|
||||
@@ -178,7 +215,7 @@ class JobFilterService {
|
||||
// 8. 投递建议
|
||||
const suggestion = this.getSuggestion(overallScore, isOutsourcing, concerns);
|
||||
|
||||
return {
|
||||
const result = {
|
||||
skillMatch: skillScore,
|
||||
experienceMatch: experienceScore,
|
||||
salaryMatch: salaryScore,
|
||||
@@ -198,6 +235,13 @@ class JobFilterService {
|
||||
suggestion: suggestion
|
||||
}
|
||||
};
|
||||
|
||||
// 如果提供了 jobPostingId 且 autoSave 为 true,自动保存到数据库
|
||||
if (options.jobPostingId && options.autoSave !== false) {
|
||||
await this.saveMatchAnalysisToDatabase(options.jobPostingId, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -453,21 +497,39 @@ class JobFilterService {
|
||||
* @returns {string} 投递建议
|
||||
*/
|
||||
getSuggestion(overallScore, isOutsourcing, concerns) {
|
||||
const concernsCount = concerns ? concerns.length : 0;
|
||||
|
||||
// 不推荐:外包 或 关注点过多(>2)
|
||||
if (isOutsourcing) {
|
||||
return '谨慎投递:可能是外包岗位';
|
||||
return '不推荐投递:外包岗位';
|
||||
}
|
||||
|
||||
if (concerns.length > 2) {
|
||||
if (concernsCount > 2) {
|
||||
return '不推荐投递:存在多个关注点';
|
||||
}
|
||||
|
||||
if (overallScore >= 80) {
|
||||
return '强烈推荐投递:匹配度很高';
|
||||
} else if (overallScore >= 60) {
|
||||
return '可以投递:匹配度中等';
|
||||
} else {
|
||||
return '谨慎投递:匹配度较低';
|
||||
// 不推荐:< 60 分
|
||||
if (overallScore < 60) {
|
||||
return '不推荐投递:匹配度较低';
|
||||
}
|
||||
|
||||
// 优先级1(必须投递):≥ 80 分,且无关注点,非外包
|
||||
if (overallScore >= 80 && concernsCount === 0) {
|
||||
return '必须投递:匹配度很高,无关注点';
|
||||
}
|
||||
|
||||
// 优先级2(可以投递):60-79 分,关注点 ≤ 2 个,非外包(优先处理无关注点情况)
|
||||
if (overallScore >= 60 && overallScore < 80 && concernsCount === 0) {
|
||||
return '可以投递:匹配度中等,无关注点';
|
||||
}
|
||||
|
||||
// 优先级3(谨慎考虑):60-79 分但有关注点,或 ≥ 80 分但有关注点
|
||||
if (overallScore >= 60 && concernsCount > 0 && concernsCount <= 2) {
|
||||
return '谨慎考虑:存在关注点或特殊情况';
|
||||
}
|
||||
|
||||
// 兜底情况
|
||||
return '谨慎考虑:需要进一步评估';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -599,15 +661,19 @@ class JobFilterService {
|
||||
* @param {object} filterRules - 过滤规则
|
||||
* @param {object} resumeInfo - 简历信息(可选)
|
||||
* @param {number} jobTypeId - 职位类型ID(可选)
|
||||
* @param {object} options - 选项
|
||||
* @param {boolean} options.autoSave - 是否自动保存评分结果到数据库(默认false)
|
||||
* @returns {Promise<Array>} 过滤后的职位列表(带匹配分数)
|
||||
*/
|
||||
async filterJobs(jobs, filterRules = {}, resumeInfo = {}, jobTypeId = null) {
|
||||
async filterJobs(jobs, filterRules = {}, resumeInfo = {}, jobTypeId = null, options = {}) {
|
||||
const {
|
||||
minScore = 60, // 最低匹配分数
|
||||
excludeOutsourcing = true, // 是否排除外包
|
||||
excludeKeywords = [] // 额外排除关键词
|
||||
} = filterRules;
|
||||
|
||||
const { autoSave = false } = options;
|
||||
|
||||
// 获取职位类型配置
|
||||
const { excludeKeywords: typeExcludeKeywords } = await this.getJobTypeConfig(jobTypeId);
|
||||
const allExcludeKeywords = [...typeExcludeKeywords, ...excludeKeywords];
|
||||
@@ -616,8 +682,13 @@ class JobFilterService {
|
||||
for (const job of jobs) {
|
||||
const jobData = job.toJSON ? job.toJSON() : job;
|
||||
|
||||
// 分析匹配度
|
||||
const analysis = await this.analyzeJobMatch(jobData, resumeInfo, jobTypeId);
|
||||
// 分析匹配度(如果 autoSave 为 true 且 job 有 id,则自动保存)
|
||||
const analysisOptions = autoSave && jobData.id ? {
|
||||
jobPostingId: jobData.id,
|
||||
autoSave: true
|
||||
} : {};
|
||||
|
||||
const analysis = await this.analyzeJobMatch(jobData, resumeInfo, jobTypeId, analysisOptions);
|
||||
|
||||
results.push({
|
||||
...jobData,
|
||||
@@ -662,11 +733,29 @@ class JobFilterService {
|
||||
const scores = {};
|
||||
let totalScore = 0;
|
||||
|
||||
// 解析权重配置
|
||||
// 解析权重配置,根据排序优先级规范化权重(确保总和为100)
|
||||
const weights = {};
|
||||
priorityWeights.forEach(item => {
|
||||
weights[item.key] = item.weight / 100; // 转换为小数
|
||||
});
|
||||
if (Array.isArray(priorityWeights) && priorityWeights.length > 0) {
|
||||
// 计算权重总和
|
||||
const totalWeight = priorityWeights.reduce((sum, item) => sum + (Number(item.weight) || 0), 0);
|
||||
|
||||
// 如果总和大于0,按比例规范化权重(总和归一化为1,即100%)
|
||||
if (totalWeight > 0) {
|
||||
priorityWeights.forEach(item => {
|
||||
weights[item.key] = (Number(item.weight) || 0) / totalWeight; // 规范化权重(0-1之间)
|
||||
});
|
||||
} else {
|
||||
// 如果权重总和为0或未设置,按照排序顺序分配权重(第一个优先级最高)
|
||||
// 使用等差数列递减:第一个权重最高,依次递减
|
||||
const n = priorityWeights.length;
|
||||
const totalSequentialWeight = (n * (n + 1)) / 2; // 1+2+...+n
|
||||
priorityWeights.forEach((item, index) => {
|
||||
// 按照排序顺序,第一个权重最高,依次递减(权重比例为 n, n-1, n-2, ..., 1)
|
||||
const sequentialWeight = n - index;
|
||||
weights[item.key] = sequentialWeight / totalSequentialWeight;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 距离评分(基于经纬度)
|
||||
if (weights.distance) {
|
||||
|
||||
651
api/middleware/job/job_filter_service.md
Normal file
651
api/middleware/job/job_filter_service.md
Normal file
@@ -0,0 +1,651 @@
|
||||
# 职位过滤服务文档
|
||||
|
||||
## 概述
|
||||
|
||||
`job_filter_service.js` 是一个职位文本匹配过滤服务,使用简单的文本匹配规则来过滤和分析职位信息。该服务支持从数据库动态获取职位类型的技能关键词和排除关键词,能够分析职位与简历的匹配度,并提供过滤和评分功能。
|
||||
|
||||
## 主要功能
|
||||
|
||||
1. **职位类型配置管理**:从数据库获取或使用默认配置的技能关键词和排除关键词
|
||||
2. **匹配度分析**:分析职位与简历的匹配度(技能、经验、薪资)
|
||||
3. **职位过滤**:根据匹配分数、外包标识、排除关键词等条件过滤职位列表
|
||||
4. **自定义权重评分**:支持根据自定义权重配置计算职位评分(距离、薪资、工作年限、学历、技能)
|
||||
|
||||
## 类结构
|
||||
|
||||
```javascript
|
||||
class JobFilterService {
|
||||
// 默认技能关键词
|
||||
defaultCommonSkills: Array<string>
|
||||
|
||||
// 默认排除关键词
|
||||
defaultExcludeKeywords: Array<string>
|
||||
|
||||
// 职位类型配置缓存
|
||||
jobTypeCache: Map<number, Object>
|
||||
}
|
||||
```
|
||||
|
||||
## API 文档
|
||||
|
||||
### 1. getJobTypeConfig(jobTypeId)
|
||||
|
||||
根据职位类型ID获取技能关键词和排除关键词配置。
|
||||
|
||||
**参数:**
|
||||
- `jobTypeId` (number, 可选): 职位类型ID
|
||||
|
||||
**返回值:**
|
||||
```javascript
|
||||
{
|
||||
commonSkills: Array<string>, // 技能关键词列表
|
||||
excludeKeywords: Array<string> // 排除关键词列表
|
||||
}
|
||||
```
|
||||
|
||||
**说明:**
|
||||
- 如果未提供 `jobTypeId` 或配置获取失败,返回默认配置
|
||||
- 配置结果会缓存5分钟,避免频繁查询数据库
|
||||
- 从 `job_types` 表读取 `commonSkills` 和 `excludeKeywords` 字段(JSON格式)
|
||||
|
||||
**使用示例:**
|
||||
```javascript
|
||||
const config = await jobFilterService.getJobTypeConfig(1);
|
||||
console.log(config.commonSkills); // ['Vue', 'React', ...]
|
||||
console.log(config.excludeKeywords); // ['外包', '外派', ...]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. clearCache(jobTypeId)
|
||||
|
||||
清除职位类型配置缓存。
|
||||
|
||||
**参数:**
|
||||
- `jobTypeId` (number, 可选): 职位类型ID,不传则清除所有缓存
|
||||
|
||||
**使用示例:**
|
||||
```javascript
|
||||
// 清除特定职位类型的缓存
|
||||
jobFilterService.clearCache(1);
|
||||
|
||||
// 清除所有缓存
|
||||
jobFilterService.clearCache();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. analyzeJobMatch(jobInfo, resumeInfo, jobTypeId)
|
||||
|
||||
使用文本匹配分析职位与简历的匹配度。
|
||||
|
||||
**参数:**
|
||||
- `jobInfo` (object, 必需): 职位信息对象
|
||||
- `jobTitle` (string): 职位名称
|
||||
- `companyName` (string): 公司名称
|
||||
- `description` (string): 职位描述
|
||||
- `skills` (string): 技能要求
|
||||
- `requirements` (string): 职位要求
|
||||
- `salary` (string): 薪资范围
|
||||
- `experience` (string): 经验要求
|
||||
- `education` (string): 学历要求
|
||||
- `longitude` (number): 经度
|
||||
- `latitude` (number): 纬度
|
||||
- `resumeInfo` (object, 可选): 简历信息对象
|
||||
- `skills` (string|Array): 技能列表
|
||||
- `skillDescription` (string): 技能描述
|
||||
- `currentPosition` (string): 当前职位
|
||||
- `expectedPosition` (string): 期望职位
|
||||
- `workYears` (number|string): 工作年限
|
||||
- `expectedSalary` (string): 期望薪资
|
||||
- `education` (string): 学历
|
||||
- `jobTypeId` (number, 可选): 职位类型ID
|
||||
|
||||
**返回值:**
|
||||
```javascript
|
||||
{
|
||||
skillMatch: number, // 技能匹配度(0-100)
|
||||
experienceMatch: number, // 经验匹配度(0-100)
|
||||
salaryMatch: number, // 薪资匹配度(0-100)
|
||||
isOutsourcing: boolean, // 是否为外包岗位
|
||||
overallScore: number, // 综合推荐指数(0-100)
|
||||
matchReasons: Array<string>, // 匹配原因列表
|
||||
concerns: Array<string>, // 关注点列表
|
||||
suggestion: string, // 投递建议
|
||||
analysis: Object // 完整的分析结果(同上)
|
||||
}
|
||||
```
|
||||
|
||||
**评分规则:**
|
||||
|
||||
1. **综合推荐指数计算**:
|
||||
- 技能匹配度 × 40% + 经验匹配度 × 30% + 薪资匹配度 × 30%
|
||||
|
||||
2. **技能匹配度**:
|
||||
- 从职位描述中提取技能关键词
|
||||
- 计算简历中匹配的技能数量占比
|
||||
- 无简历信息时,基于职位关键词数量评分
|
||||
|
||||
3. **经验匹配度**:
|
||||
- 从职位描述中提取经验要求(应届、1年、2年、3年、5年、10年)
|
||||
- 根据简历工作年限与要求的匹配程度评分
|
||||
|
||||
4. **薪资匹配度**:
|
||||
- 解析职位薪资范围和期望薪资
|
||||
- 期望薪资低于职位薪资时得高分,高于职位薪资时得低分
|
||||
|
||||
5. **外包检测**:
|
||||
- 检测职位描述中是否包含:外包、外派、驻场、人力外包、项目外包
|
||||
|
||||
**使用示例:**
|
||||
```javascript
|
||||
const jobInfo = {
|
||||
jobTitle: '前端开发工程师',
|
||||
description: '要求3年以上Vue开发经验,熟悉React',
|
||||
salary: '15-25K',
|
||||
experience: '3-5年'
|
||||
};
|
||||
|
||||
const resumeInfo = {
|
||||
skills: 'Vue, React, JavaScript',
|
||||
workYears: 4,
|
||||
expectedSalary: '20K'
|
||||
};
|
||||
|
||||
const result = await jobFilterService.analyzeJobMatch(jobInfo, resumeInfo, 1);
|
||||
console.log(result.overallScore); // 85
|
||||
console.log(result.matchReasons); // ['技能匹配度高', '工作经验符合要求', ...]
|
||||
console.log(result.suggestion); // '强烈推荐投递:匹配度很高'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. filterJobs(jobs, filterRules, resumeInfo, jobTypeId)
|
||||
|
||||
过滤职位列表,返回匹配的职位(带匹配分数)。
|
||||
|
||||
**参数:**
|
||||
- `jobs` (Array, 必需): 职位列表(可以是 Sequelize 模型实例或普通对象)
|
||||
- `filterRules` (object, 可选): 过滤规则
|
||||
- `minScore` (number, 默认60): 最低匹配分数
|
||||
- `excludeOutsourcing` (boolean, 默认true): 是否排除外包岗位
|
||||
- `excludeKeywords` (Array<string>, 默认[]): 额外排除关键词列表
|
||||
- `resumeInfo` (object, 可选): 简历信息
|
||||
- `jobTypeId` (number, 可选): 职位类型ID
|
||||
|
||||
**返回值:**
|
||||
```javascript
|
||||
Array<{
|
||||
...jobData, // 原始职位数据
|
||||
matchScore: number, // 匹配分数
|
||||
matchAnalysis: Object // 完整的匹配分析结果
|
||||
}>
|
||||
```
|
||||
|
||||
**过滤逻辑:**
|
||||
1. 对每个职位进行匹配度分析
|
||||
2. 过滤掉匹配分数低于 `minScore` 的职位
|
||||
3. 如果 `excludeOutsourcing` 为 true,过滤掉外包岗位
|
||||
4. 过滤掉包含排除关键词的职位
|
||||
5. 按匹配分数降序排序
|
||||
|
||||
**使用示例:**
|
||||
```javascript
|
||||
const jobs = [
|
||||
{ jobTitle: '前端开发', description: 'Vue开发...', salary: '20K' },
|
||||
{ jobTitle: '后端开发', description: 'Java开发...', salary: '25K' }
|
||||
];
|
||||
|
||||
const resumeInfo = {
|
||||
skills: 'Vue, JavaScript',
|
||||
workYears: 3
|
||||
};
|
||||
|
||||
const filterRules = {
|
||||
minScore: 70,
|
||||
excludeOutsourcing: true,
|
||||
excludeKeywords: ['销售']
|
||||
};
|
||||
|
||||
const filteredJobs = await jobFilterService.filterJobs(
|
||||
jobs,
|
||||
filterRules,
|
||||
resumeInfo,
|
||||
1
|
||||
);
|
||||
|
||||
console.log(filteredJobs.length); // 过滤后的职位数量
|
||||
console.log(filteredJobs[0].matchScore); // 第一个职位的匹配分数
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. calculateJobScoreWithWeights(jobData, resumeInfo, accountConfig, jobTypeConfig, priorityWeights)
|
||||
|
||||
根据自定义权重配置计算职位评分。
|
||||
|
||||
**参数:**
|
||||
- `jobData` (object, 必需): 职位数据
|
||||
- `longitude` (number): 经度
|
||||
- `latitude` (number): 纬度
|
||||
- `salary` (string): 薪资范围
|
||||
- `experience` (string): 经验要求
|
||||
- `education` (string): 学历要求
|
||||
- `resumeInfo` (object, 必需): 简历信息
|
||||
- `expectedSalary` (string): 期望薪资
|
||||
- `workYears` (string|number): 工作年限
|
||||
- `education` (string): 学历
|
||||
- `skills` (string|Array): 技能列表
|
||||
- `accountConfig` (object, 必需): 账号配置
|
||||
- `user_longitude` (number): 用户经度
|
||||
- `user_latitude` (number): 用户纬度
|
||||
- `jobTypeConfig` (object, 可选): 职位类型配置(包含 commonSkills)
|
||||
- `priorityWeights` (Array, 必需): 权重配置
|
||||
```javascript
|
||||
[
|
||||
{ key: 'distance', weight: 30 }, // 距离权重(0-100)
|
||||
{ key: 'salary', weight: 40 }, // 薪资权重
|
||||
{ key: 'work_years', weight: 20 }, // 工作年限权重
|
||||
{ key: 'education', weight: 10 } // 学历权重
|
||||
]
|
||||
```
|
||||
|
||||
**返回值:**
|
||||
```javascript
|
||||
{
|
||||
totalScore: number, // 总分(0-100+,技能评分作为额外加分项)
|
||||
scores: {
|
||||
distance: number, // 距离评分
|
||||
salary: number, // 薪资评分
|
||||
work_years: number, // 工作年限评分
|
||||
education: number, // 学历评分
|
||||
skills: number // 技能评分(如果有 jobTypeConfig)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**评分规则:**
|
||||
|
||||
1. **距离评分**:
|
||||
- 0-5km: 100分
|
||||
- 5-10km: 90分
|
||||
- 10-20km: 80分
|
||||
- 20-50km: 60分
|
||||
- 50km以上: 30分
|
||||
|
||||
2. **薪资评分**:
|
||||
- 职位薪资 ≥ 期望薪资: 100分
|
||||
- 职位薪资 ≥ 期望薪资 × 0.8: 80分
|
||||
- 职位薪资 ≥ 期望薪资 × 0.6: 60分
|
||||
- 其他: 40分
|
||||
|
||||
3. **工作年限评分**:
|
||||
- 简历年限 ≥ 职位要求: 100分
|
||||
- 简历年限 ≥ 职位要求 × 0.8: 80分
|
||||
- 简历年限 ≥ 职位要求 × 0.6: 60分
|
||||
- 其他: 40分
|
||||
|
||||
4. **学历评分**:
|
||||
- 简历学历 ≥ 职位要求: 100分
|
||||
- 简历学历 = 职位要求 - 1级: 70分
|
||||
- 其他: 40分
|
||||
|
||||
5. **技能评分**:
|
||||
- 计算简历技能与职位类型配置的技能关键词匹配度
|
||||
- 作为额外加分项,固定权重10%
|
||||
|
||||
**使用示例:**
|
||||
```javascript
|
||||
const jobData = {
|
||||
longitude: 116.3974,
|
||||
latitude: 39.9093,
|
||||
salary: '20-30K',
|
||||
experience: '3-5年',
|
||||
education: '本科'
|
||||
};
|
||||
|
||||
const resumeInfo = {
|
||||
expectedSalary: '25K',
|
||||
workYears: 4,
|
||||
education: '本科',
|
||||
skills: ['Vue', 'React', 'JavaScript']
|
||||
};
|
||||
|
||||
const accountConfig = {
|
||||
user_longitude: 116.4074,
|
||||
user_latitude: 39.9042
|
||||
};
|
||||
|
||||
const jobTypeConfig = {
|
||||
commonSkills: ['Vue', 'React', 'JavaScript', 'Node.js']
|
||||
};
|
||||
|
||||
const priorityWeights = [
|
||||
{ key: 'distance', weight: 30 },
|
||||
{ key: 'salary', weight: 40 },
|
||||
{ key: 'work_years', weight: 20 },
|
||||
{ key: 'education', weight: 10 }
|
||||
];
|
||||
|
||||
const result = jobFilterService.calculateJobScoreWithWeights(
|
||||
jobData,
|
||||
resumeInfo,
|
||||
accountConfig,
|
||||
jobTypeConfig,
|
||||
priorityWeights
|
||||
);
|
||||
|
||||
console.log(result.totalScore); // 85
|
||||
console.log(result.scores); // { distance: 100, salary: 90, work_years: 100, ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. parseSalaryRange(salaryDesc)
|
||||
|
||||
解析薪资范围字符串。
|
||||
|
||||
**参数:**
|
||||
- `salaryDesc` (string): 薪资描述字符串
|
||||
- 支持格式:`15-25K`、`25K`、`5000-6000元/月`、`2-3万`、`20000-30000` 等
|
||||
|
||||
**返回值:**
|
||||
```javascript
|
||||
{
|
||||
min: number, // 最低薪资(元)
|
||||
max: number // 最高薪资(元)
|
||||
}
|
||||
```
|
||||
或 `null`(解析失败时)
|
||||
|
||||
**使用示例:**
|
||||
```javascript
|
||||
const range1 = jobFilterService.parseSalaryRange('15-25K');
|
||||
console.log(range1); // { min: 15000, max: 25000 }
|
||||
|
||||
const range2 = jobFilterService.parseSalaryRange('2-3万');
|
||||
console.log(range2); // { min: 20000, max: 30000 }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. parseExpectedSalary(expectedSalary)
|
||||
|
||||
解析期望薪资字符串,返回平均值。
|
||||
|
||||
**参数:**
|
||||
- `expectedSalary` (string): 期望薪资描述字符串
|
||||
|
||||
**返回值:**
|
||||
- `number`: 期望薪资数值(元),如果是范围则返回平均值
|
||||
- `null`: 解析失败时
|
||||
|
||||
**使用示例:**
|
||||
```javascript
|
||||
const salary1 = jobFilterService.parseExpectedSalary('20-30K');
|
||||
console.log(salary1); // 25000(平均值)
|
||||
|
||||
const salary2 = jobFilterService.parseExpectedSalary('25K');
|
||||
console.log(salary2); // 25000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8. parseWorkYears(workYearsStr)
|
||||
|
||||
解析工作年限字符串为数字。
|
||||
|
||||
**参数:**
|
||||
- `workYearsStr` (string): 工作年限字符串(如 "3年"、"5年以上")
|
||||
|
||||
**返回值:**
|
||||
- `number`: 工作年限数字
|
||||
- `null`: 解析失败时
|
||||
|
||||
**使用示例:**
|
||||
```javascript
|
||||
const years = jobFilterService.parseWorkYears('3-5年');
|
||||
console.log(years); // 3(提取第一个数字)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 辅助方法
|
||||
|
||||
### buildJobText(jobInfo)
|
||||
|
||||
构建职位文本(用于匹配)。
|
||||
|
||||
**参数:**
|
||||
- `jobInfo` (object): 职位信息对象
|
||||
|
||||
**返回值:**
|
||||
- `string`: 合并后的职位文本(小写)
|
||||
|
||||
---
|
||||
|
||||
### buildResumeText(resumeInfo)
|
||||
|
||||
构建简历文本(用于匹配)。
|
||||
|
||||
**参数:**
|
||||
- `resumeInfo` (object): 简历信息对象
|
||||
|
||||
**返回值:**
|
||||
- `string`: 合并后的简历文本(小写)
|
||||
|
||||
---
|
||||
|
||||
### calculateSkillMatch(jobText, resumeText, commonSkills)
|
||||
|
||||
计算技能匹配度(0-100分)。
|
||||
|
||||
**参数:**
|
||||
- `jobText` (string): 职位文本
|
||||
- `resumeText` (string): 简历文本
|
||||
- `commonSkills` (Array<string>): 技能关键词列表
|
||||
|
||||
**返回值:**
|
||||
- `number`: 匹配度分数(0-100)
|
||||
|
||||
---
|
||||
|
||||
### calculateExperienceMatch(jobInfo, resumeInfo)
|
||||
|
||||
计算经验匹配度(0-100分)。
|
||||
|
||||
**参数:**
|
||||
- `jobInfo` (object): 职位信息
|
||||
- `resumeInfo` (object): 简历信息
|
||||
|
||||
**返回值:**
|
||||
- `number`: 匹配度分数(0-100)
|
||||
|
||||
---
|
||||
|
||||
### calculateSalaryMatch(jobInfo, resumeInfo)
|
||||
|
||||
计算薪资合理性(0-100分)。
|
||||
|
||||
**参数:**
|
||||
- `jobInfo` (object): 职位信息
|
||||
- `resumeInfo` (object): 简历信息
|
||||
|
||||
**返回值:**
|
||||
- `number`: 匹配度分数(0-100)
|
||||
|
||||
---
|
||||
|
||||
### checkOutsourcing(jobText)
|
||||
|
||||
检查是否为外包岗位。
|
||||
|
||||
**参数:**
|
||||
- `jobText` (string): 职位文本
|
||||
|
||||
**返回值:**
|
||||
- `boolean`: 是否为外包
|
||||
|
||||
---
|
||||
|
||||
## 默认配置
|
||||
|
||||
### 默认技能关键词
|
||||
|
||||
```javascript
|
||||
[
|
||||
'Vue', 'React', 'Angular', 'JavaScript', 'TypeScript', 'Node.js',
|
||||
'Python', 'Java', 'C#', '.NET', 'Flutter', 'React Native',
|
||||
'Webpack', 'Vite', 'Redux', 'MobX', 'Express', 'Koa',
|
||||
'Django', 'Flask', 'MySQL', 'MongoDB', 'Redis',
|
||||
'WebRTC', 'FFmpeg', 'Canvas', 'WebSocket', 'HTML5', 'CSS3',
|
||||
'jQuery', 'Bootstrap', 'Element UI', 'Ant Design',
|
||||
'Git', 'Docker', 'Kubernetes', 'AWS', 'Azure',
|
||||
'Selenium', 'Jest', 'Mocha', 'Cypress'
|
||||
]
|
||||
```
|
||||
|
||||
### 默认排除关键词
|
||||
|
||||
```javascript
|
||||
[
|
||||
'外包', '外派', '驻场', '销售', '客服', '电话销售',
|
||||
'地推', '推广', '市场', '运营', '行政', '文员'
|
||||
]
|
||||
```
|
||||
|
||||
## 数据库依赖
|
||||
|
||||
该服务依赖以下数据库表:
|
||||
|
||||
### job_types 表
|
||||
|
||||
存储职位类型配置,需要包含以下字段:
|
||||
- `id` (number): 职位类型ID
|
||||
- `is_enabled` (number): 是否启用(1=启用,0=禁用)
|
||||
- `commonSkills` (string|JSON): 技能关键词列表(JSON数组字符串)
|
||||
- `excludeKeywords` (string|JSON): 排除关键词列表(JSON数组字符串)
|
||||
|
||||
**示例数据:**
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"is_enabled": 1,
|
||||
"commonSkills": "[\"Vue\", \"React\", \"JavaScript\"]",
|
||||
"excludeKeywords": "[\"外包\", \"外派\"]"
|
||||
}
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 完整使用流程
|
||||
|
||||
```javascript
|
||||
const jobFilterService = require('./job_filter_service');
|
||||
|
||||
// 1. 分析单个职位的匹配度
|
||||
const jobInfo = {
|
||||
jobTitle: '高级前端开发工程师',
|
||||
companyName: 'XX科技有限公司',
|
||||
description: '负责前端架构设计,要求5年以上Vue/React开发经验',
|
||||
skills: 'Vue, React, TypeScript, Node.js',
|
||||
requirements: '本科及以上学历,有大型项目经验',
|
||||
salary: '25-40K·14薪'
|
||||
};
|
||||
|
||||
const resumeInfo = {
|
||||
skills: 'Vue, React, JavaScript, TypeScript, Node.js, Webpack',
|
||||
skillDescription: '精通Vue生态,熟悉React,有5年+前端开发经验',
|
||||
currentPosition: '高级前端开发工程师',
|
||||
expectedPosition: '前端架构师',
|
||||
workYears: 6,
|
||||
expectedSalary: '30K',
|
||||
education: '本科'
|
||||
};
|
||||
|
||||
const analysis = await jobFilterService.analyzeJobMatch(
|
||||
jobInfo,
|
||||
resumeInfo,
|
||||
1 // 职位类型ID
|
||||
);
|
||||
|
||||
console.log('匹配分析结果:');
|
||||
console.log('综合评分:', analysis.overallScore);
|
||||
console.log('技能匹配度:', analysis.skillMatch);
|
||||
console.log('经验匹配度:', analysis.experienceMatch);
|
||||
console.log('薪资匹配度:', analysis.salaryMatch);
|
||||
console.log('是否外包:', analysis.isOutsourcing);
|
||||
console.log('匹配原因:', analysis.matchReasons);
|
||||
console.log('关注点:', analysis.concerns);
|
||||
console.log('投递建议:', analysis.suggestion);
|
||||
|
||||
// 2. 过滤职位列表
|
||||
const jobs = await Job.findAll({ where: { status: 1 } });
|
||||
|
||||
const filteredJobs = await jobFilterService.filterJobs(
|
||||
jobs,
|
||||
{
|
||||
minScore: 70,
|
||||
excludeOutsourcing: true,
|
||||
excludeKeywords: ['销售', '客服']
|
||||
},
|
||||
resumeInfo,
|
||||
1
|
||||
);
|
||||
|
||||
console.log(`共找到 ${filteredJobs.length} 个匹配的职位`);
|
||||
filteredJobs.forEach((job, index) => {
|
||||
console.log(`${index + 1}. ${job.jobTitle} - 匹配分数:${job.matchScore}`);
|
||||
});
|
||||
|
||||
// 3. 自定义权重评分
|
||||
const accountConfig = {
|
||||
user_longitude: 116.4074,
|
||||
user_latitude: 39.9042
|
||||
};
|
||||
|
||||
const priorityWeights = [
|
||||
{ key: 'distance', weight: 25 },
|
||||
{ key: 'salary', weight: 35 },
|
||||
{ key: 'work_years', weight: 25 },
|
||||
{ key: 'education', weight: 15 }
|
||||
];
|
||||
|
||||
const scoreResult = jobFilterService.calculateJobScoreWithWeights(
|
||||
jobInfo,
|
||||
resumeInfo,
|
||||
accountConfig,
|
||||
{ commonSkills: ['Vue', 'React', 'TypeScript'] },
|
||||
priorityWeights
|
||||
);
|
||||
|
||||
console.log('自定义权重评分:', scoreResult.totalScore);
|
||||
console.log('各项评分:', scoreResult.scores);
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **缓存机制**:职位类型配置会缓存5分钟,修改数据库配置后需要调用 `clearCache()` 清除缓存
|
||||
|
||||
2. **性能优化**:
|
||||
- 大量职位过滤时,建议分批处理
|
||||
- 避免在循环中频繁调用 `getJobTypeConfig()`,配置会被自动缓存
|
||||
|
||||
3. **文本匹配**:
|
||||
- 所有文本匹配均为小写匹配,不区分大小写
|
||||
- 匹配逻辑较为简单,如需更精确的匹配,建议使用 AI 分析(二期规划)
|
||||
|
||||
4. **薪资解析**:
|
||||
- 支持多种薪资格式,但可能无法解析所有格式
|
||||
- 解析失败时返回默认分数或 null
|
||||
|
||||
5. **错误处理**:
|
||||
- 所有方法都包含错误处理,失败时返回默认值或空结果
|
||||
- 建议在生产环境中监控日志输出
|
||||
|
||||
## 版本历史
|
||||
|
||||
- **v1.0.0**: 初始版本,支持基础文本匹配和过滤功能
|
||||
- 支持从数据库动态获取职位类型配置
|
||||
- 支持自定义权重评分计算
|
||||
|
||||
@@ -6,18 +6,7 @@ const dayjs = require('dayjs');
|
||||
*/
|
||||
class ScheduleConfig {
|
||||
constructor() {
|
||||
// 工作时间配置
|
||||
this.workHours = {
|
||||
start: 6,
|
||||
end: 23
|
||||
};
|
||||
|
||||
// 频率限制配置(毫秒)
|
||||
this.rateLimits = {
|
||||
search: 30 * 60 * 1000, // 搜索间隔:30分钟
|
||||
apply: 5 * 60 * 1000, // 投递间隔:5分钟
|
||||
chat: 1 * 60 * 1000, // 聊天间隔:1分钟
|
||||
};
|
||||
|
||||
|
||||
// 单日操作限制
|
||||
this.dailyLimits = {
|
||||
@@ -62,15 +51,6 @@ class ScheduleConfig {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否在工作时间
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isWorkingHours() {
|
||||
const now = dayjs();
|
||||
const hour = now.hour();
|
||||
return hour >= this.workHours.start && hour < this.workHours.end;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务超时时间
|
||||
@@ -97,15 +77,6 @@ class ScheduleConfig {
|
||||
return priority;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取操作频率限制
|
||||
* @param {string} operation - 操作类型
|
||||
* @returns {number} 间隔时间(毫秒)
|
||||
*/
|
||||
getRateLimit(operation) {
|
||||
return this.rateLimits[operation] || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取日限制
|
||||
* @param {string} operation - 操作类型
|
||||
|
||||
@@ -100,19 +100,8 @@ class DeviceManager {
|
||||
* 检查是否可以执行操作
|
||||
*/
|
||||
canExecuteOperation(sn_code, operation_type) {
|
||||
|
||||
|
||||
// 检查频率限制
|
||||
// 检查日限制(频率限制已由各任务使用账号配置中的间隔时间,不再使用全局配置)
|
||||
const device = this.devices.get(sn_code);
|
||||
if (device) {
|
||||
const lastTime = device[`last${operation_type.charAt(0).toUpperCase() + operation_type.slice(1)}`] || 0;
|
||||
const interval = config.getRateLimit(operation_type);
|
||||
if (Date.now() - lastTime < interval) {
|
||||
return { allowed: false, reason: '操作过于频繁' };
|
||||
}
|
||||
}
|
||||
|
||||
// 检查日限制
|
||||
if (device && device.dailyCounts) {
|
||||
const today = utils.getTodayString();
|
||||
if (device.dailyCounts.date !== today) {
|
||||
|
||||
@@ -157,6 +157,31 @@ module.exports = (db) => {
|
||||
allowNull: true,
|
||||
defaultValue: ''
|
||||
},
|
||||
// 文本匹配分析结果
|
||||
textMatchScore: {
|
||||
comment: '文本匹配综合评分',
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: 0
|
||||
},
|
||||
matchSuggestion: {
|
||||
comment: '投递建议',
|
||||
type: Sequelize.STRING(200),
|
||||
allowNull: true,
|
||||
defaultValue: ''
|
||||
},
|
||||
matchConcerns: {
|
||||
comment: '关注点(JSON数组)',
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
defaultValue: '[]'
|
||||
},
|
||||
textMatchAnalysis: {
|
||||
comment: '文本匹配详细分析结果(JSON)',
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
defaultValue: ''
|
||||
},
|
||||
outsourcingAnalysis: {
|
||||
comment: '外包识别分析结果',
|
||||
type: Sequelize.TEXT,
|
||||
|
||||
1
app/dist-web/assets/delivery_config-BLn-zF88.js
Normal file
1
app/dist-web/assets/delivery_config-BLn-zF88.js
Normal file
@@ -0,0 +1 @@
|
||||
import{a as t}from"./index-Cia_UppJ.js";class s{async getConfig(r){try{return await t.post("/user/delivery-config/get",{sn_code:r})}catch(e){throw console.error("获取投递配置失败:",e),e}}async saveConfig(r,e){try{return await t.post("/user/delivery-config/save",{sn_code:r,deliver_config:e})}catch(o){throw console.error("保存投递配置失败:",o),o}}}const i=new s;export{i as default};
|
||||
1
app/dist-web/assets/index--P_P-eHg.css
Normal file
1
app/dist-web/assets/index--P_P-eHg.css
Normal file
File diff suppressed because one or more lines are too long
3648
app/dist-web/assets/index-Cia_UppJ.js
Normal file
3648
app/dist-web/assets/index-Cia_UppJ.js
Normal file
File diff suppressed because one or more lines are too long
BIN
app/dist-web/assets/primeicons-C6QP2o4f.woff2
Normal file
BIN
app/dist-web/assets/primeicons-C6QP2o4f.woff2
Normal file
Binary file not shown.
BIN
app/dist-web/assets/primeicons-DMOk5skT.eot
Normal file
BIN
app/dist-web/assets/primeicons-DMOk5skT.eot
Normal file
Binary file not shown.
345
app/dist-web/assets/primeicons-Dr5RGzOO.svg
Normal file
345
app/dist-web/assets/primeicons-Dr5RGzOO.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 334 KiB |
BIN
app/dist-web/assets/primeicons-MpK4pl85.ttf
Normal file
BIN
app/dist-web/assets/primeicons-MpK4pl85.ttf
Normal file
Binary file not shown.
BIN
app/dist-web/assets/primeicons-WjwUDZjB.woff
Normal file
BIN
app/dist-web/assets/primeicons-WjwUDZjB.woff
Normal file
Binary file not shown.
32
app/dist-web/index.html
Normal file
32
app/dist-web/index.html
Normal file
@@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>boss - 远程监听服务</title>
|
||||
<script type="module" crossorigin src="/app/assets/index-Cia_UppJ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/app/assets/index--P_P-eHg.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- 启动加载动画 -->
|
||||
<div id="loading-screen" class="loading-screen">
|
||||
<div class="loading-content">
|
||||
<div class="loading-logo">
|
||||
<div class="logo-circle"></div>
|
||||
</div>
|
||||
<div class="loading-text">正在启动...</div>
|
||||
<div class="loading-progress">
|
||||
<div class="progress-bar-animated"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vue 应用挂载点 -->
|
||||
<div id="app" ></div>
|
||||
|
||||
<!-- 在 body 底部加载 Vue 应用脚本 -->
|
||||
|
||||
</body>
|
||||
|
||||
@@ -67,7 +67,8 @@ module.exports = {
|
||||
// AI服务配置
|
||||
ai: {
|
||||
"apiKey": "sk-c83cdb06a6584f99bb2cd6e8a5ae3bbc",
|
||||
"baseUrl": "https://dashscope.aliyuncs.com/api/v1"
|
||||
"baseUrl": "https://dashscope.aliyuncs.com/api/v1",
|
||||
"model": "qwen-turbo"
|
||||
},
|
||||
|
||||
// MQTT配置
|
||||
|
||||
Reference in New Issue
Block a user