11
This commit is contained in:
14
api/middleware/schedule/utils/index.js
Normal file
14
api/middleware/schedule/utils/index.js
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Utils 模块导出
|
||||
* 统一导出工具类模块
|
||||
*/
|
||||
|
||||
const SalaryParser = require('./salaryParser');
|
||||
const KeywordMatcher = require('./keywordMatcher');
|
||||
const ScheduleUtils = require('./scheduleUtils');
|
||||
|
||||
module.exports = {
|
||||
SalaryParser,
|
||||
KeywordMatcher,
|
||||
ScheduleUtils
|
||||
};
|
||||
225
api/middleware/schedule/utils/keywordMatcher.js
Normal file
225
api/middleware/schedule/utils/keywordMatcher.js
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* 关键词匹配工具
|
||||
* 提供职位描述的关键词匹配和评分功能
|
||||
*/
|
||||
class KeywordMatcher {
|
||||
/**
|
||||
* 检查是否包含排除关键词
|
||||
* @param {string} text - 待检查的文本
|
||||
* @param {string[]} excludeKeywords - 排除关键词列表
|
||||
* @returns {{matched: boolean, keywords: string[]}} 匹配结果
|
||||
*/
|
||||
static matchExcludeKeywords(text, excludeKeywords = []) {
|
||||
if (!text || !excludeKeywords || excludeKeywords.length === 0) {
|
||||
return { matched: false, keywords: [] };
|
||||
}
|
||||
|
||||
const matched = [];
|
||||
const lowerText = text.toLowerCase();
|
||||
|
||||
for (const keyword of excludeKeywords) {
|
||||
if (!keyword || !keyword.trim()) continue;
|
||||
|
||||
const lowerKeyword = keyword.toLowerCase().trim();
|
||||
if (lowerText.includes(lowerKeyword)) {
|
||||
matched.push(keyword);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
matched: matched.length > 0,
|
||||
keywords: matched
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否包含过滤关键词(必须匹配)
|
||||
* @param {string} text - 待检查的文本
|
||||
* @param {string[]} filterKeywords - 过滤关键词列表
|
||||
* @returns {{matched: boolean, keywords: string[], matchCount: number}} 匹配结果
|
||||
*/
|
||||
static matchFilterKeywords(text, filterKeywords = []) {
|
||||
if (!text) {
|
||||
return { matched: false, keywords: [], matchCount: 0 };
|
||||
}
|
||||
|
||||
if (!filterKeywords || filterKeywords.length === 0) {
|
||||
return { matched: true, keywords: [], matchCount: 0 };
|
||||
}
|
||||
|
||||
const matched = [];
|
||||
const lowerText = text.toLowerCase();
|
||||
|
||||
for (const keyword of filterKeywords) {
|
||||
if (!keyword || !keyword.trim()) continue;
|
||||
|
||||
const lowerKeyword = keyword.toLowerCase().trim();
|
||||
if (lowerText.includes(lowerKeyword)) {
|
||||
matched.push(keyword);
|
||||
}
|
||||
}
|
||||
|
||||
// 只要匹配到至少一个过滤关键词即可通过
|
||||
return {
|
||||
matched: matched.length > 0,
|
||||
keywords: matched,
|
||||
matchCount: matched.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算关键词匹配奖励分数
|
||||
* @param {string} text - 待检查的文本
|
||||
* @param {string[]} keywords - 关键词列表
|
||||
* @param {object} options - 选项
|
||||
* @returns {{score: number, matchedKeywords: string[], matchCount: number}}
|
||||
*/
|
||||
static calculateBonus(text, keywords = [], options = {}) {
|
||||
const {
|
||||
baseScore = 10, // 每个关键词的基础分
|
||||
maxBonus = 50, // 最大奖励分
|
||||
caseSensitive = false // 是否区分大小写
|
||||
} = options;
|
||||
|
||||
if (!text || !keywords || keywords.length === 0) {
|
||||
return { score: 0, matchedKeywords: [], matchCount: 0 };
|
||||
}
|
||||
|
||||
const matched = [];
|
||||
const searchText = caseSensitive ? text : text.toLowerCase();
|
||||
|
||||
for (const keyword of keywords) {
|
||||
if (!keyword || !keyword.trim()) continue;
|
||||
|
||||
const searchKeyword = caseSensitive ? keyword.trim() : keyword.toLowerCase().trim();
|
||||
if (searchText.includes(searchKeyword)) {
|
||||
matched.push(keyword);
|
||||
}
|
||||
}
|
||||
|
||||
const score = Math.min(matched.length * baseScore, maxBonus);
|
||||
|
||||
return {
|
||||
score,
|
||||
matchedKeywords: matched,
|
||||
matchCount: matched.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 高亮匹配的关键词(用于展示)
|
||||
* @param {string} text - 原始文本
|
||||
* @param {string[]} keywords - 关键词列表
|
||||
* @param {string} prefix - 前缀标记(默认 <mark>)
|
||||
* @param {string} suffix - 后缀标记(默认 </mark>)
|
||||
* @returns {string} 高亮后的文本
|
||||
*/
|
||||
static highlight(text, keywords = [], prefix = '<mark>', suffix = '</mark>') {
|
||||
if (!text || !keywords || keywords.length === 0) {
|
||||
return text;
|
||||
}
|
||||
|
||||
let result = text;
|
||||
|
||||
for (const keyword of keywords) {
|
||||
if (!keyword || !keyword.trim()) continue;
|
||||
|
||||
const regex = new RegExp(`(${this.escapeRegex(keyword.trim())})`, 'gi');
|
||||
result = result.replace(regex, `${prefix}$1${suffix}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转义正则表达式特殊字符
|
||||
* @param {string} str - 待转义的字符串
|
||||
* @returns {string} 转义后的字符串
|
||||
*/
|
||||
static escapeRegex(str) {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* 综合匹配(排除 + 过滤 + 奖励)
|
||||
* @param {string} text - 待检查的文本
|
||||
* @param {object} config - 配置
|
||||
* @param {string[]} config.excludeKeywords - 排除关键词
|
||||
* @param {string[]} config.filterKeywords - 过滤关键词
|
||||
* @param {string[]} config.bonusKeywords - 奖励关键词
|
||||
* @returns {{pass: boolean, reason?: string, score: number, details: object}}
|
||||
*/
|
||||
static match(text, config = {}) {
|
||||
const {
|
||||
excludeKeywords = [],
|
||||
filterKeywords = [],
|
||||
bonusKeywords = []
|
||||
} = config;
|
||||
|
||||
// 1. 检查排除关键词
|
||||
const excludeResult = this.matchExcludeKeywords(text, excludeKeywords);
|
||||
if (excludeResult.matched) {
|
||||
return {
|
||||
pass: false,
|
||||
reason: `包含排除关键词: ${excludeResult.keywords.join(', ')}`,
|
||||
score: 0,
|
||||
details: { exclude: excludeResult }
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 检查过滤关键词(必须匹配)
|
||||
const filterResult = this.matchFilterKeywords(text, filterKeywords);
|
||||
if (filterKeywords.length > 0 && !filterResult.matched) {
|
||||
return {
|
||||
pass: false,
|
||||
reason: '不包含任何必需关键词',
|
||||
score: 0,
|
||||
details: { filter: filterResult }
|
||||
};
|
||||
}
|
||||
|
||||
// 3. 计算奖励分数
|
||||
const bonusResult = this.calculateBonus(text, bonusKeywords);
|
||||
|
||||
return {
|
||||
pass: true,
|
||||
score: bonusResult.score,
|
||||
details: {
|
||||
exclude: excludeResult,
|
||||
filter: filterResult,
|
||||
bonus: bonusResult
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量匹配职位列表
|
||||
* @param {Array} jobs - 职位列表
|
||||
* @param {object} config - 匹配配置
|
||||
* @param {Function} textExtractor - 文本提取函数 (job) => string
|
||||
* @returns {Array} 匹配通过的职位(带匹配信息)
|
||||
*/
|
||||
static filterJobs(jobs, config, textExtractor = (job) => `${job.name || ''} ${job.description || ''}`) {
|
||||
if (!jobs || jobs.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filtered = [];
|
||||
|
||||
for (const job of jobs) {
|
||||
const text = textExtractor(job);
|
||||
const matchResult = this.match(text, config);
|
||||
|
||||
if (matchResult.pass) {
|
||||
filtered.push({
|
||||
...job,
|
||||
_matchInfo: matchResult
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = KeywordMatcher;
|
||||
126
api/middleware/schedule/utils/salaryParser.js
Normal file
126
api/middleware/schedule/utils/salaryParser.js
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 薪资解析工具
|
||||
* 统一处理职位薪资和期望薪资的解析逻辑
|
||||
*/
|
||||
class SalaryParser {
|
||||
/**
|
||||
* 解析薪资范围字符串
|
||||
* @param {string} salaryDesc - 薪资描述 (如 "15-20K", "8000-12000元")
|
||||
* @returns {{ min: number, max: number }} 薪资范围(单位:元)
|
||||
*/
|
||||
static parse(salaryDesc) {
|
||||
if (!salaryDesc || typeof salaryDesc !== 'string') {
|
||||
return { min: 0, max: 0 };
|
||||
}
|
||||
|
||||
// 尝试各种格式
|
||||
return this.parseK(salaryDesc)
|
||||
|| this.parseYuan(salaryDesc)
|
||||
|| this.parseMixed(salaryDesc)
|
||||
|| { min: 0, max: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 K 格式薪资 (如 "15-20K", "8-12k")
|
||||
*/
|
||||
static parseK(desc) {
|
||||
const kMatch = desc.match(/(\d+)[-~](\d+)[kK千]/);
|
||||
if (kMatch) {
|
||||
return {
|
||||
min: parseInt(kMatch[1]) * 1000,
|
||||
max: parseInt(kMatch[2]) * 1000
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析元格式薪资 (如 "8000-12000元", "15000-20000")
|
||||
*/
|
||||
static parseYuan(desc) {
|
||||
const yuanMatch = desc.match(/(\d+)[-~](\d+)元?/);
|
||||
if (yuanMatch) {
|
||||
return {
|
||||
min: parseInt(yuanMatch[1]),
|
||||
max: parseInt(yuanMatch[2])
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析混合格式 (如 "8k-12000元")
|
||||
*/
|
||||
static parseMixed(desc) {
|
||||
const mixedMatch = desc.match(/(\d+)[kK千][-~](\d+)元?/);
|
||||
if (mixedMatch) {
|
||||
return {
|
||||
min: parseInt(mixedMatch[1]) * 1000,
|
||||
max: parseInt(mixedMatch[2])
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查职位薪资是否在期望范围内
|
||||
* @param {object} jobSalary - 职位薪资 { min, max }
|
||||
* @param {number} minExpected - 期望最低薪资
|
||||
* @param {number} maxExpected - 期望最高薪资
|
||||
*/
|
||||
static isWithinRange(jobSalary, minExpected, maxExpected) {
|
||||
if (!jobSalary || jobSalary.min === 0) {
|
||||
return true; // 无法判断时默认通过
|
||||
}
|
||||
|
||||
// 职位最高薪资 >= 期望最低薪资
|
||||
if (minExpected > 0 && jobSalary.max < minExpected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 职位最低薪资 <= 期望最高薪资
|
||||
if (maxExpected > 0 && jobSalary.min > maxExpected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算薪资匹配度(用于职位评分)
|
||||
* @param {object} jobSalary - 职位薪资
|
||||
* @param {object} expectedSalary - 期望薪资
|
||||
* @returns {number} 匹配度 0-1
|
||||
*/
|
||||
static calculateMatch(jobSalary, expectedSalary) {
|
||||
if (!jobSalary || !expectedSalary || jobSalary.min === 0 || expectedSalary.min === 0) {
|
||||
return 0.5; // 无法判断时返回中性值
|
||||
}
|
||||
|
||||
const jobAvg = (jobSalary.min + jobSalary.max) / 2;
|
||||
const expectedAvg = (expectedSalary.min + expectedSalary.max) / 2;
|
||||
|
||||
const diff = Math.abs(jobAvg - expectedAvg);
|
||||
const range = (jobSalary.max - jobSalary.min + expectedSalary.max - expectedSalary.min) / 2;
|
||||
|
||||
// 差距越小,匹配度越高
|
||||
return Math.max(0, 1 - diff / (range || 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化薪资显示
|
||||
* @param {object} salary - 薪资对象 { min, max }
|
||||
* @returns {string} 格式化字符串
|
||||
*/
|
||||
static format(salary) {
|
||||
if (!salary || salary.min === 0) {
|
||||
return '面议';
|
||||
}
|
||||
|
||||
const minK = (salary.min / 1000).toFixed(0);
|
||||
const maxK = (salary.max / 1000).toFixed(0);
|
||||
return `${minK}-${maxK}K`;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SalaryParser;
|
||||
265
api/middleware/schedule/utils/scheduleUtils.js
Normal file
265
api/middleware/schedule/utils/scheduleUtils.js
Normal file
@@ -0,0 +1,265 @@
|
||||
const dayjs = require('dayjs');
|
||||
|
||||
/**
|
||||
* 调度系统工具函数
|
||||
* 提供通用的辅助功能
|
||||
*/
|
||||
class ScheduleUtils {
|
||||
/**
|
||||
* 生成唯一任务ID
|
||||
* @returns {string} 任务ID
|
||||
*/
|
||||
static generateTaskId() {
|
||||
return `task_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一指令ID
|
||||
* @returns {string} 指令ID
|
||||
*/
|
||||
static generateCommandId() {
|
||||
return `cmd_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间戳
|
||||
* @param {number} timestamp - 时间戳
|
||||
* @returns {string} 格式化的时间
|
||||
*/
|
||||
static formatTimestamp(timestamp) {
|
||||
return dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化持续时间
|
||||
* @param {number} ms - 毫秒数
|
||||
* @returns {string} 格式化的持续时间
|
||||
*/
|
||||
static formatDuration(ms) {
|
||||
if (ms < 1000) {
|
||||
return `${ms}ms`;
|
||||
} else if (ms < 60 * 1000) {
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
} else if (ms < 60 * 60 * 1000) {
|
||||
return `${(ms / (60 * 1000)).toFixed(1)}min`;
|
||||
} else {
|
||||
return `${(ms / (60 * 60 * 1000)).toFixed(1)}h`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 深度克隆对象
|
||||
* @param {object} obj - 要克隆的对象
|
||||
* @returns {object} 克隆后的对象
|
||||
*/
|
||||
static deepClone(obj) {
|
||||
if (obj === null || typeof obj !== 'object') {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (obj instanceof Date) {
|
||||
return new Date(obj.getTime());
|
||||
}
|
||||
|
||||
if (obj instanceof Array) {
|
||||
return obj.map(item => this.deepClone(item));
|
||||
}
|
||||
|
||||
const cloned = {};
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key)) {
|
||||
cloned[key] = this.deepClone(obj[key]);
|
||||
}
|
||||
}
|
||||
|
||||
return cloned;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全解析JSON
|
||||
* @param {string} jsonString - JSON字符串
|
||||
* @param {any} defaultValue - 默认值
|
||||
* @returns {any} 解析结果或默认值
|
||||
*/
|
||||
static safeJsonParse(jsonString, defaultValue = null) {
|
||||
try {
|
||||
return JSON.parse(jsonString);
|
||||
} catch (error) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全序列化JSON
|
||||
* @param {any} obj - 要序列化的对象
|
||||
* @param {string} defaultValue - 默认值
|
||||
* @returns {string} JSON字符串或默认值
|
||||
*/
|
||||
static safeJsonStringify(obj, defaultValue = '{}') {
|
||||
try {
|
||||
return JSON.stringify(obj);
|
||||
} catch (error) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 延迟执行
|
||||
* @param {number} ms - 延迟毫秒数
|
||||
* @returns {Promise} Promise对象
|
||||
*/
|
||||
static delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试执行函数
|
||||
* @param {function} fn - 要执行的函数
|
||||
* @param {number} maxRetries - 最大重试次数
|
||||
* @param {number} delay - 重试延迟(毫秒)
|
||||
* @returns {Promise} 执行结果
|
||||
*/
|
||||
static async retry(fn, maxRetries = 3, delay = 1000) {
|
||||
let lastError;
|
||||
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
if (i < maxRetries) {
|
||||
console.log(`[工具函数] 执行失败,${delay}ms后重试 (${i + 1}/${maxRetries + 1}):`, error.message);
|
||||
await this.delay(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* 限制并发执行数量
|
||||
* @param {Array} tasks - 任务数组
|
||||
* @param {number} concurrency - 并发数量
|
||||
* @returns {Promise<Array>} 执行结果数组
|
||||
*/
|
||||
static async limitConcurrency(tasks, concurrency = 5) {
|
||||
const results = [];
|
||||
const executing = [];
|
||||
|
||||
for (const task of tasks) {
|
||||
const promise = Promise.resolve(task()).then(result => {
|
||||
executing.splice(executing.indexOf(promise), 1);
|
||||
return result;
|
||||
});
|
||||
|
||||
results.push(promise);
|
||||
|
||||
if (tasks.length >= concurrency) {
|
||||
executing.push(promise);
|
||||
|
||||
if (executing.length >= concurrency) {
|
||||
await Promise.race(executing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.all(results);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带超时的Promise
|
||||
* @param {Promise} promise - 原始Promise
|
||||
* @param {number} timeout - 超时时间(毫秒)
|
||||
* @param {string} timeoutMessage - 超时错误消息
|
||||
* @returns {Promise} 带超时的Promise
|
||||
*/
|
||||
static withTimeout(promise, timeout, timeoutMessage = 'Operation timed out') {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error(timeoutMessage)), timeout);
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取今天的日期字符串
|
||||
* @returns {string} 日期字符串 YYYY-MM-DD
|
||||
*/
|
||||
static getTodayString() {
|
||||
return dayjs().format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查日期是否为今天
|
||||
* @param {string|Date} date - 日期
|
||||
* @returns {boolean} 是否为今天
|
||||
*/
|
||||
static isToday(date) {
|
||||
return dayjs(date).format('YYYY-MM-DD') === this.getTodayString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取随机延迟时间
|
||||
* @param {number} min - 最小延迟(毫秒)
|
||||
* @param {number} max - 最大延迟(毫秒)
|
||||
* @returns {number} 随机延迟时间
|
||||
*/
|
||||
static getRandomDelay(min = 1000, max = 5000) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
* @param {number} bytes - 字节数
|
||||
* @returns {string} 格式化的文件大小
|
||||
*/
|
||||
static formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算成功率
|
||||
* @param {number} success - 成功次数
|
||||
* @param {number} total - 总次数
|
||||
* @returns {string} 百分比字符串
|
||||
*/
|
||||
static calculateSuccessRate(success, total) {
|
||||
if (total === 0) return '0%';
|
||||
return ((success / total) * 100).toFixed(2) + '%';
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证设备SN码格式
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @returns {boolean} 是否有效
|
||||
*/
|
||||
static isValidSnCode(sn_code) {
|
||||
return typeof sn_code === 'string' && sn_code.length > 0 && sn_code.length <= 50;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理对象中的空值
|
||||
* @param {object} obj - 要清理的对象
|
||||
* @returns {object} 清理后的对象
|
||||
*/
|
||||
static cleanObject(obj) {
|
||||
const cleaned = {};
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key) && obj[key] !== null && obj[key] !== undefined && obj[key] !== '') {
|
||||
cleaned[key] = obj[key];
|
||||
}
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ScheduleUtils;
|
||||
Reference in New Issue
Block a user