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} 执行结果 */ 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', create_time: { [db.models.op.gte]: new Date(Date.now() - 1000 * 60 * 60 * 24) } }, order: [['create_time', 'DESC']], limit }); return jobs.map(job => job.toJSON ? job.toJSON() : job); } /** * 自动投递过滤配置:仅使用 job_types(excludeKeywords、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;