Files
autoAiWorkSys/api/middleware/schedule/handlers/deliverHandler.js
张成 51bbdacdda 1
2026-04-08 17:27:40 +08:00

451 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const BaseHandler = require('./baseHandler');
const ConfigManager = require('../services/configManager');
const jobFilterEngine = require('../services/jobFilterEngine');
const command = require('../core/command');
const config = require('../infrastructure/config');
const db = require('../../dbProxy');
/**
* 自动投递处理器
* 负责职位搜索、过滤、评分和自动投递
*/
class DeliverHandler extends BaseHandler {
/**
* 处理自动投递任务
* @param {object} task - 任务对象
* @returns {Promise<object>} 执行结果
*/
async handle(task) {
return await this.execute(task, async () => {
return await this.doDeliver(task);
}, {
checkAuth: true,
checkOnline: true,
recordDeviceMetrics: true
});
}
/**
* 执行投递逻辑
*/
async doDeliver(task) {
const { sn_code, taskParams } = task;
const { keyword, platform = 'boss', pageCount = 3, maxCount = 10 } = taskParams;
console.log(`[自动投递] 开始 - 设备: ${sn_code}, 关键词: ${keyword}`);
// 1. 检查每日投递限制
const dailyCheck = await this.checkDailyDeliverLimit(sn_code, platform);
if (!dailyCheck.allowed) {
return {
deliveredCount: 0,
message: dailyCheck.message
};
}
const actualMaxCount = dailyCheck.actualMaxCount;
// 2. 检查并获取简历
const resume = await this.getOrRefreshResume(sn_code, platform, task.id);
if (!resume) {
return {
deliveredCount: 0,
message: '未找到简历信息'
};
}
// 3. 获取账户配置
const accountConfig = await this.getAccountConfig(sn_code, [
'keyword', 'platform_type', 'deliver_config', 'job_type_id', 'is_salary_priority'
]);
if (!accountConfig) {
return {
deliveredCount: 0,
message: '未找到账户配置'
};
}
// 4. 检查投递时间范围
const deliverConfig = ConfigManager.parseDeliverConfig(accountConfig.deliver_config);
const timeRange = ConfigManager.getTimeRange(deliverConfig);
if (timeRange) {
const timeRangeValidator = require('../services/timeRangeValidator');
const timeCheck = timeRangeValidator.checkTimeRange(timeRange);
if (!timeCheck.allowed) {
return {
deliveredCount: 0,
message: timeCheck.reason
};
}
}
// 5. 获取职位类型配置(同时下发 get_job_listings 并保存到 resume_info.job_listings
const jobTypeConfig = await this.getJobTypeConfig(accountConfig.job_type_id, {
sn_code,
platform,
taskId: task.id,
mqttClient: this.mqttClient
});
// 6. 下发 get_job_list与前端一致command 只带 pageCount + tabLabel设备端不接收 keyword/job_type_id
const tabLabel = resume.deliver_tab_label || '';
await this.getJobList(sn_code, platform, pageCount, task.id, tabLabel);
// 7. 从数据库获取待投递职位
const pendingJobs = await this.getPendingJobs(sn_code, platform, actualMaxCount * 3);
if (!pendingJobs || pendingJobs.length === 0) {
return {
deliveredCount: 0,
message: '没有待投递的职位'
};
}
// 8. 过滤配置仅来自职位类型 job_types排除词 / 标题须含词等),不与账号投递配置、任务参数混用
const filterConfig = this.mergeFilterConfig(jobTypeConfig);
// 9. 过滤已投递的公司repeat_deliver_days 由投递配置给出,缺省 30上限 365
const repeatDeliverDays = Math.min(365, Math.max(1, Number(deliverConfig.repeat_deliver_days) || 30));
const recentCompanies = await this.getRecentDeliveredCompanies(sn_code, repeatDeliverDays);
// 10. 过滤 + 评分 + 按 60 分阈值筛(入口在 jobFilterEngine便于阅读
const filteredJobs = await jobFilterEngine.filterAndScoreJobsForDeliver(
pendingJobs,
filterConfig,
resume,
accountConfig,
jobTypeConfig,
recentCompanies
);
// 本轮未进入「可投递」列表的待投递记录,标记为已过滤,避免长期停留在 pending
await this.markFilteredJobsNotPassed(pendingJobs, filteredJobs, sn_code, platform);
const jobsToDeliver = filteredJobs.slice(0, actualMaxCount);
console.log(`[自动投递] 职位筛选完成 - 原始: ${pendingJobs.length}, 符合条件: ${filteredJobs.length}, 将投递: ${jobsToDeliver.length}`);
if (jobsToDeliver.length === 0) {
return {
deliveredCount: 0,
message: '没有符合条件的职位'
};
}
// 11. 创建投递指令并执行
const deliverCommands = this.createDeliverCommands(jobsToDeliver, sn_code, platform);
const result = await command.executeCommands(task.id, deliverCommands, this.mqttClient);
console.log(`[自动投递] 完成 - 设备: ${sn_code}, 投递: ${deliverCommands.length} 个职位`);
return {
deliveredCount: deliverCommands.length,
...result
};
}
/**
* 检查每日投递限制
*/
async checkDailyDeliverLimit(sn_code, platform) {
const apply_records = db.getModel('apply_records');
const dailyLimit = config.getDailyLimit('apply', platform);
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayApplyCount = await apply_records.count({
where: {
sn_code,
platform,
applyTime: {
[db.models.op.gte]: today
}
}
});
console.log(`[自动投递] 今日已投递: ${todayApplyCount}/${dailyLimit}`);
if (todayApplyCount >= dailyLimit) {
return {
allowed: false,
message: `已达到每日投递上限(${dailyLimit}次)`
};
}
const remainingQuota = dailyLimit - todayApplyCount;
return {
allowed: true,
actualMaxCount: remainingQuota,
todayCount: todayApplyCount,
limit: dailyLimit
};
}
/**
* 获取或刷新简历
*/
async getOrRefreshResume(sn_code, platform, taskId) {
const resume_info = db.getModel('resume_info');
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
let resume = await resume_info.findOne({
where: {
sn_code,
platform,
isActive: true
},
order: [['last_modify_time', 'DESC']]
});
const needRefresh = !resume ||
!resume.last_modify_time ||
new Date(resume.last_modify_time) < twoHoursAgo;
if (needRefresh) {
console.log(`[自动投递] 简历超过2小时未更新重新获取`);
try {
await command.executeCommands(taskId, [{
command_type: 'get_online_resume',
command_name: '获取在线简历',
command_params: JSON.stringify({ sn_code, platform }),
priority: config.getTaskPriority('get_resume') || 5
}], this.mqttClient);
// 重新查询
resume = await resume_info.findOne({
where: { sn_code, platform, isActive: true },
order: [['last_modify_time', 'DESC']]
});
} catch (error) {
console.warn(`[自动投递] 获取在线简历失败:`, error.message);
}
}
return resume ? resume.toJSON() : null;
}
/**
* 获取职位类型配置;若传入 options先下发 get_job_listings 获取 tab 列表并写入 resume_info.job_listings
* @param {number} jobTypeId - 职位类型 ID
* @param {object} options - 可选 { sn_code, platform, taskId, mqttClient },用于下发 get_job_listings 并保存
*/
async getJobTypeConfig(jobTypeId, options = {}) {
const { sn_code, platform = 'boss', taskId, mqttClient } = options;
if (sn_code && taskId && mqttClient) {
try {
const getListingsCommand = {
command_type: 'get_job_listings',
command_name: '获取投递标签列表',
command_params: JSON.stringify({ sn_code, platform }),
priority: config.getTaskPriority('auto_deliver') || 7
};
const ret = await command.executeCommands(taskId, [getListingsCommand], mqttClient);
const firstResult = ret.results && ret.results[0];
const list = firstResult && firstResult.result && Array.isArray(firstResult.result)
? firstResult.result
: [];
const job_listings = list.map((item) => (item && item.text != null ? String(item.text).trim() : '')).filter(Boolean);
if (job_listings.length > 0) {
const resume_info = db.getModel('resume_info');
const [updated] = await resume_info.update(
{ job_listings },
{ where: { sn_code, platform } }
);
if (updated) {
console.log(`[自动投递] job_listings 已保存,共 ${job_listings.length}`);
}
}
} catch (err) {
console.warn(`[自动投递] 下发 get_job_listings 或保存失败:`, err.message);
}
}
if (!jobTypeId) return null;
try {
const job_types = db.getModel('job_types');
const jobType = await job_types.findByPk(jobTypeId);
return jobType ? jobType.toJSON() : null;
} catch (error) {
console.error(`[自动投递] 获取职位类型配置失败:`, error);
return null;
}
}
/**
* 下发 get_job_list 命令拉取职位列表command_params 与前端约定pageCount、tabLabel + sn_code、platform
*/
async getJobList(sn_code, platform, pageCount, taskId, tabLabel = '') {
const label = tabLabel != null && String(tabLabel).trim() !== '' ? String(tabLabel).trim() : '';
const params = {
sn_code,
platform,
pageCount,
...(label ? { tabLabel: label } : {})
};
const getJobListCommand = {
command_type: 'get_job_list',
command_name: '获取职位列表',
command_params: JSON.stringify(params),
priority: config.getTaskPriority('auto_deliver') || 7
};
await command.executeCommands(taskId, [getJobListCommand], this.mqttClient);
}
/**
* 将本批中未通过过滤/评分的职位从 pending 更新为 filtered仍 pending 的仅为通过筛选且等待下轮投递的)
* @param {Array} pendingJobs - 本批拉取的待投递
* @param {Array} filteredJobs - filterAndScoreJobsForDeliver 通过的结果(含 matchScore
*/
async markFilteredJobsNotPassed(pendingJobs, filteredJobs, sn_code, platform) {
if (!pendingJobs || pendingJobs.length === 0) {
return;
}
const passedIds = new Set(
(filteredJobs || []).map((j) => j.id).filter((id) => id != null)
);
const notPassedIds = pendingJobs
.map((j) => (j && j.id != null ? j.id : null))
.filter((id) => id != null && !passedIds.has(id));
if (notPassedIds.length === 0) {
return;
}
const job_postings = db.getModel('job_postings');
const { op } = db.models;
try {
const [n] = await job_postings.update(
{ applyStatus: 'filtered' },
{
where: {
id: { [op.in]: notPassedIds },
sn_code,
platform,
applyStatus: 'pending'
}
}
);
console.log(`[自动投递] 不符合条件已标记 filtered: ${notPassedIds.length} 条(更新行数 ${n}`);
} catch (e) {
console.warn('[自动投递] 标记 filtered 失败:', e.message);
}
}
/**
* 获取待投递职位
*/
async getPendingJobs(sn_code, platform, limit) {
const job_postings = db.getModel('job_postings');
const jobs = await job_postings.findAll({
where: {
sn_code,
platform,
applyStatus: 'pending'
},
order: [['create_time', 'DESC']],
limit
});
return jobs.map(job => job.toJSON ? job.toJSON() : job);
}
/**
* 自动投递过滤配置:仅使用 job_typesexcludeKeywords、titleIncludeKeywords
* 薪资筛选不在此合并min/max 为 0 表示不做薪资过滤);评分权重仍走 accountConfig.is_salary_priority
*/
mergeFilterConfig(jobTypeConfig) {
const base = {
exclude_keywords: [],
filter_keywords: [],
title_include_keywords: [],
min_salary: 0,
max_salary: 0,
priority_weights: []
};
if (!jobTypeConfig) {
return base;
}
if (jobTypeConfig.excludeKeywords) {
try {
const raw = jobTypeConfig.excludeKeywords;
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
base.exclude_keywords = Array.isArray(parsed) ? parsed.map((k) => String(k || '').trim()).filter(Boolean) : [];
} catch (e) {
base.exclude_keywords = [];
}
}
if (jobTypeConfig.titleIncludeKeywords != null) {
const v = jobTypeConfig.titleIncludeKeywords;
if (Array.isArray(v)) {
base.title_include_keywords = v.map((k) => String(k || '').trim()).filter(Boolean);
} else if (typeof v === 'string' && v.trim()) {
try {
const p = JSON.parse(v);
if (Array.isArray(p)) {
base.title_include_keywords = p.map((k) => String(k || '').trim()).filter(Boolean);
}
} catch (e) {
/* ignore */
}
}
}
return base;
}
/**
* 获取近期已投递的公司
*/
async getRecentDeliveredCompanies(sn_code, days = 30) {
const apply_records = db.getModel('apply_records');
const daysAgo = new Date();
daysAgo.setDate(daysAgo.getDate() - days);
const recentApplies = await apply_records.findAll({
where: {
sn_code,
applyTime: {
[db.models.op.gte]: daysAgo
}
},
attributes: ['companyName'],
group: ['companyName']
});
return new Set(recentApplies.map(apply => apply.companyName).filter(Boolean));
}
/**
* 创建投递指令
*/
createDeliverCommands(jobs, sn_code, platform) {
return jobs.map(job => ({
command_type: 'deliver_resume',
command_name: '投递简历',
command_params: JSON.stringify({
sn_code,
platform,
jobId: job.jobId,
encryptBossId: job.encryptBossId || '',
securityId: job.securityId || '',
brandName: job.companyName,
jobTitle: job.jobTitle,
companyName: job.companyName,
matchScore: job.matchScore,
scoreDetails: job.scoreDetails
}),
priority: config.getTaskPriority('apply') || 6
}));
}
}
module.exports = DeliverHandler;