11
This commit is contained in:
8
_sql/add_job_postings_deliver_fields.sql
Normal file
8
_sql/add_job_postings_deliver_fields.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
-- job_postings:是否已投递成功、未投递/投递失败原因(直接存表,不连表查询)
|
||||||
|
-- 若字段已存在可忽略报错
|
||||||
|
|
||||||
|
ALTER TABLE job_postings
|
||||||
|
ADD COLUMN is_delivered TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已投递成功:1是 0否' AFTER applyTime;
|
||||||
|
|
||||||
|
ALTER TABLE job_postings
|
||||||
|
ADD COLUMN deliver_failed_reason TEXT NULL COMMENT '未投递或投递失败原因' AFTER is_delivered;
|
||||||
58
_sql/add_query_performance_indexes.sql
Normal file
58
_sql/add_query_performance_indexes.sql
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
-- 查询性能索引补充(按前端高频接口整理)
|
||||||
|
-- 适用场景:
|
||||||
|
-- 1) /api/job_postings/page_list, /api/job_postings/deliver_status_map
|
||||||
|
-- 2) /api/apply/list, /api/apply/statistics
|
||||||
|
-- 3) /api/task/command/page_list
|
||||||
|
-- 4) /api/user/account-config/get 中 resume_info 最新记录查询
|
||||||
|
|
||||||
|
-- =========================
|
||||||
|
-- job_postings
|
||||||
|
-- =========================
|
||||||
|
ALTER TABLE `job_postings`
|
||||||
|
ADD INDEX `idx_job_postings_sn_platform_modify` (`sn_code`, `platform`, `last_modify_time`);
|
||||||
|
|
||||||
|
ALTER TABLE `job_postings`
|
||||||
|
ADD INDEX `idx_job_postings_sn_create` (`sn_code`, `create_time`);
|
||||||
|
|
||||||
|
-- =========================
|
||||||
|
-- apply_records
|
||||||
|
-- =========================
|
||||||
|
ALTER TABLE `apply_records`
|
||||||
|
ADD INDEX `idx_apply_records_sn_create` (`sn_code`, `create_time`);
|
||||||
|
|
||||||
|
ALTER TABLE `apply_records`
|
||||||
|
ADD INDEX `idx_apply_records_sn_platform_create` (`sn_code`, `platform`, `create_time`);
|
||||||
|
|
||||||
|
ALTER TABLE `apply_records`
|
||||||
|
ADD INDEX `idx_apply_records_sn_apply_status_create` (`sn_code`, `applyStatus`, `create_time`);
|
||||||
|
|
||||||
|
ALTER TABLE `apply_records`
|
||||||
|
ADD INDEX `idx_apply_records_sn_feedback_status_create` (`sn_code`, `feedbackStatus`, `create_time`);
|
||||||
|
|
||||||
|
-- =========================
|
||||||
|
-- task_status
|
||||||
|
-- =========================
|
||||||
|
ALTER TABLE `task_status`
|
||||||
|
ADD INDEX `idx_task_status_sn_id` (`sn_code`, `id`);
|
||||||
|
|
||||||
|
ALTER TABLE `task_status`
|
||||||
|
ADD INDEX `idx_task_status_sn_status_id` (`sn_code`, `status`, `id`);
|
||||||
|
|
||||||
|
-- =========================
|
||||||
|
-- task_commands
|
||||||
|
-- =========================
|
||||||
|
ALTER TABLE `task_commands`
|
||||||
|
ADD INDEX `idx_task_commands_task_id_id` (`task_id`, `id`);
|
||||||
|
|
||||||
|
ALTER TABLE `task_commands`
|
||||||
|
ADD INDEX `idx_task_commands_task_id_status_id` (`task_id`, `status`, `id`);
|
||||||
|
|
||||||
|
-- =========================
|
||||||
|
-- resume_info / pla_account
|
||||||
|
-- =========================
|
||||||
|
ALTER TABLE `resume_info`
|
||||||
|
ADD INDEX `idx_resume_info_sn_platform_active_modify` (`sn_code`, `platform`, `isActive`, `last_modify_time`);
|
||||||
|
|
||||||
|
ALTER TABLE `pla_account`
|
||||||
|
ADD INDEX `idx_pla_account_sn_code` (`sn_code`);
|
||||||
|
|
||||||
178
api/controller_front/job_postings.js
Normal file
178
api/controller_front/job_postings.js
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* 岗位表(job_postings)客户端接口:仅查本表字段,不连表
|
||||||
|
*/
|
||||||
|
|
||||||
|
const Framework = require('../../framework/node-core-framework.js');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 resume_info.job_listings 解析 Boss Tab 文案列表(与 get_job_listings 一致)
|
||||||
|
* @param {unknown} job_listings
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
function parse_resume_tab_labels(job_listings) {
|
||||||
|
if (job_listings == null) return [];
|
||||||
|
let arr = job_listings;
|
||||||
|
if (typeof arr === 'string') {
|
||||||
|
try {
|
||||||
|
arr = JSON.parse(arr);
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Array.isArray(arr)) return [];
|
||||||
|
const out = [];
|
||||||
|
arr.forEach((item) => {
|
||||||
|
if (item == null) return;
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
const s = item.trim();
|
||||||
|
if (s) out.push(s);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof item === 'object' && item.text != null) {
|
||||||
|
const s = String(item.text).trim();
|
||||||
|
if (s) out.push(s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return [...new Set(out)];
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
/**
|
||||||
|
* 按设备 SN 拉取 jobId -> 投递结果字段(is_delivered、deliver_failed_reason、applyStatus)
|
||||||
|
* 用于 Electron 职位列表页合并展示,不查 apply_records
|
||||||
|
*/
|
||||||
|
'POST /job_postings/deliver_status_map': async (ctx) => {
|
||||||
|
const models = Framework.getModels();
|
||||||
|
const { job_postings, op } = models;
|
||||||
|
const body = ctx.getBody() || {};
|
||||||
|
const sn_code = body.sn_code || ctx.query?.sn_code;
|
||||||
|
const platform = body.platform || 'boss';
|
||||||
|
const startTime = body.startTime;
|
||||||
|
const endTime = body.endTime;
|
||||||
|
|
||||||
|
if (!sn_code) {
|
||||||
|
return ctx.fail('请提供设备SN码');
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = { sn_code, platform };
|
||||||
|
if (startTime || endTime) {
|
||||||
|
where.last_modify_time = {};
|
||||||
|
if (startTime) {
|
||||||
|
where.last_modify_time[op.gte] = new Date(startTime);
|
||||||
|
}
|
||||||
|
if (endTime) {
|
||||||
|
const end = new Date(endTime);
|
||||||
|
end.setHours(23, 59, 59, 999);
|
||||||
|
where.last_modify_time[op.lte] = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await job_postings.findAll({
|
||||||
|
where,
|
||||||
|
attributes: ['jobId', 'is_delivered', 'deliver_failed_reason', 'applyStatus', 'last_modify_time'],
|
||||||
|
order: [['last_modify_time', 'DESC']],
|
||||||
|
limit: Math.min(Number(body.limit) || 2000, 5000)
|
||||||
|
});
|
||||||
|
|
||||||
|
const list = rows.map((r) => {
|
||||||
|
const j = r.toJSON ? r.toJSON() : r;
|
||||||
|
return {
|
||||||
|
jobId: j.jobId,
|
||||||
|
is_delivered: !!j.is_delivered,
|
||||||
|
deliver_failed_reason: j.deliver_failed_reason || '',
|
||||||
|
applyStatus: j.applyStatus || 'pending',
|
||||||
|
last_modify_time: j.last_modify_time
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return ctx.success({ list });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按设备 SN 分页查询 job_postings(仅本表字段,用于 Electron 职位列表只读展示)
|
||||||
|
*/
|
||||||
|
'POST /job_postings/page_list': async (ctx) => {
|
||||||
|
const models = Framework.getModels();
|
||||||
|
const { job_postings, resume_info, op } = models;
|
||||||
|
const body = ctx.getBody() || {};
|
||||||
|
const sn_code = body.sn_code || ctx.query?.sn_code;
|
||||||
|
const platform = body.platform || 'boss';
|
||||||
|
|
||||||
|
if (!sn_code) {
|
||||||
|
return ctx.fail('请提供设备SN码');
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = Math.max(1, Number(body.page) || 1);
|
||||||
|
const raw_page_size = body.page_size ?? body.pageSize ?? 20;
|
||||||
|
const page_size = Math.min(100, Math.max(1, Number(raw_page_size) || 20));
|
||||||
|
const offset = (page - 1) * page_size;
|
||||||
|
|
||||||
|
const base_where = { sn_code, platform };
|
||||||
|
const startTime = body.startTime;
|
||||||
|
const endTime = body.endTime;
|
||||||
|
if (startTime || endTime) {
|
||||||
|
base_where.last_modify_time = {};
|
||||||
|
if (startTime) {
|
||||||
|
base_where.last_modify_time[op.gte] = new Date(startTime);
|
||||||
|
}
|
||||||
|
if (endTime) {
|
||||||
|
const end = new Date(endTime);
|
||||||
|
end.setHours(23, 59, 59, 999);
|
||||||
|
base_where.last_modify_time[op.lte] = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let tab_labels = [];
|
||||||
|
if (resume_info) {
|
||||||
|
const resume = await resume_info.findOne({
|
||||||
|
where: { sn_code, platform, isActive: true },
|
||||||
|
order: [['last_modify_time', 'DESC']]
|
||||||
|
});
|
||||||
|
if (resume) {
|
||||||
|
const rj = resume.toJSON ? resume.toJSON() : resume;
|
||||||
|
tab_labels = parse_resume_tab_labels(rj.job_listings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 不按「当前投递标签」过滤;列表为设备+平台下库中全部职位(各标签写入的 keyword 均会出现在列表中)。tab_labels 供前端展示「账户已同步的全部 Tab」 */
|
||||||
|
const where = base_where;
|
||||||
|
|
||||||
|
const { rows, count } = await job_postings.findAndCountAll({
|
||||||
|
where,
|
||||||
|
attributes: [
|
||||||
|
'id',
|
||||||
|
'jobId',
|
||||||
|
'jobTitle',
|
||||||
|
'companyName',
|
||||||
|
'salary',
|
||||||
|
'location',
|
||||||
|
'education',
|
||||||
|
'experience',
|
||||||
|
'last_modify_time',
|
||||||
|
'create_time',
|
||||||
|
'is_delivered',
|
||||||
|
'deliver_failed_reason',
|
||||||
|
'applyStatus',
|
||||||
|
'platform',
|
||||||
|
'keyword',
|
||||||
|
'aiMatchScore'
|
||||||
|
],
|
||||||
|
limit: page_size,
|
||||||
|
offset,
|
||||||
|
order: [['last_modify_time', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
const list = rows.map((r) => {
|
||||||
|
const j = r.toJSON ? r.toJSON() : r;
|
||||||
|
return j;
|
||||||
|
});
|
||||||
|
|
||||||
|
return ctx.success({
|
||||||
|
list,
|
||||||
|
total: count,
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
tab_labels
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -308,6 +308,98 @@ module.exports = {
|
|||||||
console.error('[任务管理] 获取任务统计失败:', error);
|
console.error('[任务管理] 获取任务统计失败:', error);
|
||||||
return ctx.fail('获取任务统计失败: ' + (error.message || '未知错误'));
|
return ctx.fail('获取任务统计失败: ' + (error.message || '未知错误'));
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按设备 SN 分页查询任务指令列表(展示请求参数与响应结果)
|
||||||
|
*/
|
||||||
|
'POST /task/command/page_list': async (ctx) => {
|
||||||
|
try {
|
||||||
|
const body = ctx.getBody() || {};
|
||||||
|
const sn_code = body.sn_code || ctx.query?.sn_code;
|
||||||
|
const page = Math.max(1, Number(body.page) || 1);
|
||||||
|
const raw_page_size = body.page_size ?? body.pageSize ?? 20;
|
||||||
|
const page_size = Math.min(100, Math.max(1, Number(raw_page_size) || 20));
|
||||||
|
const offset = (page - 1) * page_size;
|
||||||
|
|
||||||
|
if (!sn_code) {
|
||||||
|
return ctx.fail('请提供设备SN码');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { task_status, task_commands, op } = await Framework.getModels();
|
||||||
|
|
||||||
|
// 先查当前账号对应任务,再按 task_id 关联查指令
|
||||||
|
const task_rows = await task_status.findAll({
|
||||||
|
where: { sn_code },
|
||||||
|
attributes: ['id'],
|
||||||
|
order: [['id', 'DESC']],
|
||||||
|
limit: 2000
|
||||||
|
});
|
||||||
|
const task_ids = task_rows.map((row) => row.id).filter((id) => id != null);
|
||||||
|
if (!task_ids.length) {
|
||||||
|
return ctx.success({
|
||||||
|
list: [],
|
||||||
|
total: 0,
|
||||||
|
page,
|
||||||
|
page_size
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
task_id: { [op.in]: task_ids }
|
||||||
|
};
|
||||||
|
if (body.command_type) {
|
||||||
|
where.command_type = String(body.command_type).trim();
|
||||||
|
}
|
||||||
|
if (body.status) {
|
||||||
|
where.status = String(body.status).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows, count } = await task_commands.findAndCountAll({
|
||||||
|
where,
|
||||||
|
attributes: [
|
||||||
|
'id',
|
||||||
|
'task_id',
|
||||||
|
'command_type',
|
||||||
|
'command_name',
|
||||||
|
'command_params',
|
||||||
|
'status',
|
||||||
|
'priority',
|
||||||
|
'sequence',
|
||||||
|
'retry_count',
|
||||||
|
'max_retries',
|
||||||
|
'start_time',
|
||||||
|
'end_time',
|
||||||
|
'duration',
|
||||||
|
'result',
|
||||||
|
'error_message',
|
||||||
|
'progress'
|
||||||
|
],
|
||||||
|
order: [['id', 'DESC']],
|
||||||
|
limit: page_size,
|
||||||
|
offset
|
||||||
|
});
|
||||||
|
|
||||||
|
const list = rows.map((r) => {
|
||||||
|
const j = r.toJSON ? r.toJSON() : r;
|
||||||
|
return {
|
||||||
|
...j,
|
||||||
|
// 前端直接展示,避免超长字段压垮 UI
|
||||||
|
command_params_preview: j.command_params ? String(j.command_params).slice(0, 1200) : '',
|
||||||
|
result_preview: j.result ? String(j.result).slice(0, 1200) : ''
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return ctx.success({
|
||||||
|
list,
|
||||||
|
total: count,
|
||||||
|
page,
|
||||||
|
page_size
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[任务管理] 获取指令分页失败:', error);
|
||||||
|
return ctx.fail('获取指令分页失败: ' + (error.message || '未知错误'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -350,7 +350,11 @@ class JobManager {
|
|||||||
if (notPassedIds.length > 0) {
|
if (notPassedIds.length > 0) {
|
||||||
try {
|
try {
|
||||||
await job_postings.update(
|
await job_postings.update(
|
||||||
{ applyStatus: 'filtered' },
|
{
|
||||||
|
applyStatus: 'filtered',
|
||||||
|
is_delivered: false,
|
||||||
|
deliver_failed_reason: '未通过搜索并投递筛选规则'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
where: {
|
where: {
|
||||||
id: { [Op.in]: notPassedIds },
|
id: { [Op.in]: notPassedIds },
|
||||||
@@ -650,6 +654,13 @@ class JobManager {
|
|||||||
// 检查是否已存在投递记录(避免重复投递同一职位)
|
// 检查是否已存在投递记录(避免重复投递同一职位)
|
||||||
const existingApply = await apply_records.findOne({ where: { sn_code, jobId: jobData.jobId } });
|
const existingApply = await apply_records.findOne({ where: { sn_code, jobId: jobData.jobId } });
|
||||||
if (existingApply) {
|
if (existingApply) {
|
||||||
|
await job_postings.update(
|
||||||
|
{
|
||||||
|
is_delivered: false,
|
||||||
|
deliver_failed_reason: '该岗位已投递过'
|
||||||
|
},
|
||||||
|
{ where: { id: jobData.id } }
|
||||||
|
);
|
||||||
console.log(`[工作管理] 跳过已投递职位: ${jobData.jobTitle} @ ${jobData.companyName}`);
|
console.log(`[工作管理] 跳过已投递职位: ${jobData.jobTitle} @ ${jobData.companyName}`);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -684,6 +695,14 @@ class JobManager {
|
|||||||
|
|
||||||
if (recentCompanyApply) {
|
if (recentCompanyApply) {
|
||||||
const daysAgo = Math.floor((new Date() - new Date(recentCompanyApply.applyTime)) / (1000 * 60 * 60 * 24));
|
const daysAgo = Math.floor((new Date() - new Date(recentCompanyApply.applyTime)) / (1000 * 60 * 60 * 24));
|
||||||
|
const skip_reason = `该公司在${daysAgo}天前已投递过,30天内不重复投递`;
|
||||||
|
await job_postings.update(
|
||||||
|
{
|
||||||
|
is_delivered: false,
|
||||||
|
deliver_failed_reason: skip_reason
|
||||||
|
},
|
||||||
|
{ where: { id: jobData.id } }
|
||||||
|
);
|
||||||
console.log(`[工作管理] 跳过30天内已投递的公司: ${jobData.companyName} (${daysAgo}天前投递过)`);
|
console.log(`[工作管理] 跳过30天内已投递的公司: ${jobData.companyName} (${daysAgo}天前投递过)`);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -716,7 +735,12 @@ class JobManager {
|
|||||||
if (response && response.code === 200) {
|
if (response && response.code === 200) {
|
||||||
// 投递成功
|
// 投递成功
|
||||||
await job_postings.update(
|
await job_postings.update(
|
||||||
{ applyStatus: 'applied', applyTime: new Date() },
|
{
|
||||||
|
applyStatus: 'applied',
|
||||||
|
applyTime: new Date(),
|
||||||
|
is_delivered: true,
|
||||||
|
deliver_failed_reason: ''
|
||||||
|
},
|
||||||
{ where: { id: jobData.id } }
|
{ where: { id: jobData.id } }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -795,8 +819,13 @@ class JobManager {
|
|||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// 投递失败
|
// 投递失败
|
||||||
|
const fail_msg = String(response?.message || response?.msg || '投递失败').slice(0, 65000);
|
||||||
await job_postings.update(
|
await job_postings.update(
|
||||||
{ applyStatus: 'failed' },
|
{
|
||||||
|
applyStatus: 'failed',
|
||||||
|
is_delivered: false,
|
||||||
|
deliver_failed_reason: fail_msg
|
||||||
|
},
|
||||||
{ where: { id: jobData.id } }
|
{ where: { id: jobData.id } }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ class DeliverHandler extends BaseHandler {
|
|||||||
const recentCompanies = await this.getRecentDeliveredCompanies(sn_code, repeatDeliverDays);
|
const recentCompanies = await this.getRecentDeliveredCompanies(sn_code, repeatDeliverDays);
|
||||||
|
|
||||||
// 10. 过滤 + 评分 + 按 60 分阈值筛(入口在 jobFilterEngine,便于阅读)
|
// 10. 过滤 + 评分 + 按 60 分阈值筛(入口在 jobFilterEngine,便于阅读)
|
||||||
const filteredJobs = await jobFilterEngine.filterAndScoreJobsForDeliver(
|
const { scored: filteredJobs, skipReasonByJobId } = await jobFilterEngine.filterAndScoreJobsForDeliver(
|
||||||
pendingJobs,
|
pendingJobs,
|
||||||
filterConfig,
|
filterConfig,
|
||||||
resume,
|
resume,
|
||||||
@@ -121,7 +121,7 @@ class DeliverHandler extends BaseHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 本轮未进入「可投递」列表的待投递记录,标记为已过滤,避免长期停留在 pending
|
// 本轮未进入「可投递」列表的待投递记录,标记为已过滤,避免长期停留在 pending
|
||||||
await this.markFilteredJobsNotPassed(pendingJobs, filteredJobs, sn_code, platform);
|
await this.markFilteredJobsNotPassed(pendingJobs, filteredJobs, sn_code, platform, skipReasonByJobId);
|
||||||
|
|
||||||
const jobsToDeliver = filteredJobs.slice(0, actualMaxCount);
|
const jobsToDeliver = filteredJobs.slice(0, actualMaxCount);
|
||||||
|
|
||||||
@@ -302,7 +302,7 @@ class DeliverHandler extends BaseHandler {
|
|||||||
* @param {Array} pendingJobs - 本批拉取的待投递
|
* @param {Array} pendingJobs - 本批拉取的待投递
|
||||||
* @param {Array} filteredJobs - filterAndScoreJobsForDeliver 通过的结果(含 matchScore)
|
* @param {Array} filteredJobs - filterAndScoreJobsForDeliver 通过的结果(含 matchScore)
|
||||||
*/
|
*/
|
||||||
async markFilteredJobsNotPassed(pendingJobs, filteredJobs, sn_code, platform) {
|
async markFilteredJobsNotPassed(pendingJobs, filteredJobs, sn_code, platform, skipReasonByJobId = {}) {
|
||||||
if (!pendingJobs || pendingJobs.length === 0) {
|
if (!pendingJobs || pendingJobs.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -316,20 +316,30 @@ class DeliverHandler extends BaseHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const job_postings = db.getModel('job_postings');
|
const job_postings = db.getModel('job_postings');
|
||||||
const { op } = db.models;
|
const default_reason = '未通过自动投递筛选';
|
||||||
try {
|
try {
|
||||||
const [n] = await job_postings.update(
|
await Promise.all(
|
||||||
{ applyStatus: 'filtered' },
|
notPassedIds.map((id) => {
|
||||||
|
const reason = (skipReasonByJobId && skipReasonByJobId[id]) || default_reason;
|
||||||
|
const text = String(reason).slice(0, 65000);
|
||||||
|
return job_postings.update(
|
||||||
|
{
|
||||||
|
applyStatus: 'filtered',
|
||||||
|
is_delivered: false,
|
||||||
|
deliver_failed_reason: text
|
||||||
|
},
|
||||||
{
|
{
|
||||||
where: {
|
where: {
|
||||||
id: { [op.in]: notPassedIds },
|
id,
|
||||||
sn_code,
|
sn_code,
|
||||||
platform,
|
platform,
|
||||||
applyStatus: 'pending'
|
applyStatus: 'pending'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
console.log(`[自动投递] 不符合条件已标记 filtered: ${notPassedIds.length} 条(更新行数 ${n})`);
|
})
|
||||||
|
);
|
||||||
|
console.log(`[自动投递] 不符合条件已标记 filtered: ${notPassedIds.length} 条(含原因)`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[自动投递] 标记 filtered 失败:', e.message);
|
console.warn('[自动投递] 标记 filtered 失败:', e.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const db = require('../../dbProxy');
|
|||||||
/**
|
/**
|
||||||
* 职位过滤引擎(schedule 自动投递用)
|
* 职位过滤引擎(schedule 自动投递用)
|
||||||
* 本文件集中:过滤(薪资/关键词/活跃度/去重)+ 评分(权重分+关键词奖励)+ 按分数阈值筛。
|
* 本文件集中:过滤(薪资/关键词/活跃度/去重)+ 评分(权重分+关键词奖励)+ 按分数阈值筛。
|
||||||
* 自动投递只需调 filterAndScoreJobsForDeliver 一个方法。
|
* 自动投递调用 filterAndScoreJobsForDeliver,返回 { scored, skipReasonByJobId }。
|
||||||
*/
|
*/
|
||||||
class JobFilterEngine {
|
class JobFilterEngine {
|
||||||
getJobKey(job) {
|
getJobKey(job) {
|
||||||
@@ -89,24 +89,82 @@ class JobFilterEngine {
|
|||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单条职位:判断 filterJobs 中哪一步未通过(用于写入 job_postings.deliver_failed_reason)
|
||||||
|
*/
|
||||||
|
async get_single_job_filter_fail_reason(job, config) {
|
||||||
|
const j = job;
|
||||||
|
const after_salary = this.filterBySalary([j], config);
|
||||||
|
if (after_salary.length === 0) {
|
||||||
|
return `薪资不在设定范围(${config.min_salary ?? 0}-${config.max_salary ?? 0}K)`;
|
||||||
|
}
|
||||||
|
const after_title = this.filterByTitleIncludeKeywords(after_salary, config);
|
||||||
|
if (after_title.length === 0) {
|
||||||
|
const kws = (config.title_include_keywords || []).join('、') || '无';
|
||||||
|
return `职位标题须包含以下关键词之一:${kws}`;
|
||||||
|
}
|
||||||
|
const after_kw = this.filterByKeywords(after_title, config);
|
||||||
|
if (after_kw.length === 0) {
|
||||||
|
const match_text = `${j.jobTitle || ''}`;
|
||||||
|
const match_result = KeywordMatcher.match(match_text, {
|
||||||
|
excludeKeywords: Array.isArray(config.exclude_keywords) ? config.exclude_keywords : [],
|
||||||
|
filterKeywords: Array.isArray(config.filter_keywords) ? config.filter_keywords : [],
|
||||||
|
bonusKeywords: []
|
||||||
|
});
|
||||||
|
if (match_result && match_result.details && match_result.details.exclude && match_result.details.exclude.matched) {
|
||||||
|
const hit_keywords = match_result.details.exclude.keywords || [];
|
||||||
|
if (hit_keywords.length) {
|
||||||
|
return `命中排除关键词: ${hit_keywords.join('、')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (match_result && match_result.details && match_result.details.filter && !match_result.details.filter.matched) {
|
||||||
|
const needed = Array.isArray(config.filter_keywords) ? config.filter_keywords : [];
|
||||||
|
if (needed.length) {
|
||||||
|
return `未命中包含关键词: ${needed.join('、')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '命中排除关键词或未满足包含词规则';
|
||||||
|
}
|
||||||
|
if (config.filter_inactive_companies) {
|
||||||
|
const after_act = await this.filterByCompanyActivity(after_kw, config.company_active_days || 7);
|
||||||
|
if (after_act.length === 0) {
|
||||||
|
return '公司活跃度不满足配置';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (config.deduplicate) {
|
||||||
|
const after_dedup = this.deduplicateJobs(after_kw);
|
||||||
|
if (after_dedup.length === 0) {
|
||||||
|
return '与列表内职位重复(公司+岗位名)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '未通过职位过滤';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 自动投递用:过滤 + 评分 + 按 60 分阈值筛,一次调用完成(便于阅读与维护)
|
* 自动投递用:过滤 + 评分 + 按 60 分阈值筛,一次调用完成(便于阅读与维护)
|
||||||
|
* @returns {{ scored: Array, skipReasonByJobId: Record<number|string, string> }}
|
||||||
*/
|
*/
|
||||||
async filterAndScoreJobsForDeliver(jobs, filterConfig, resume, accountConfig, jobTypeConfig, recentCompanies) {
|
async filterAndScoreJobsForDeliver(jobs, filterConfig, resume, accountConfig, jobTypeConfig, recentCompanies) {
|
||||||
const scored = [];
|
const scored = [];
|
||||||
|
const skipReasonByJobId = {};
|
||||||
const jobDesc = (j) => `${j.companyName || '?'} / ${j.jobTitle || '?'}`;
|
const jobDesc = (j) => `${j.companyName || '?'} / ${j.jobTitle || '?'}`;
|
||||||
const { jobFilterService } = require('../../job/services');
|
const { jobFilterService } = require('../../job/services');
|
||||||
|
|
||||||
console.log(`[jobFilterEngine] filterAndScoreJobsForDeliver 开始,待处理: ${jobs.length}`);
|
console.log(`[jobFilterEngine] filterAndScoreJobsForDeliver 开始,待处理: ${jobs.length}`);
|
||||||
|
|
||||||
for (const job of jobs) {
|
for (const job of jobs) {
|
||||||
|
const job_id = job.id;
|
||||||
if (job.companyName && recentCompanies.has(job.companyName)) {
|
if (job.companyName && recentCompanies.has(job.companyName)) {
|
||||||
|
const msg = '近期已在配置天数内投递过该公司';
|
||||||
|
if (job_id != null) skipReasonByJobId[job_id] = msg;
|
||||||
console.log(`[jobFilterEngine] 已投递公司 剔除: ${jobDesc(job)}`);
|
console.log(`[jobFilterEngine] 已投递公司 剔除: ${jobDesc(job)}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = await this.filterJobs([job], filterConfig, resume);
|
const filtered = await this.filterJobs([job], filterConfig, resume);
|
||||||
if (filtered.length === 0) {
|
if (filtered.length === 0) {
|
||||||
|
const reason = await this.get_single_job_filter_fail_reason(job, filterConfig);
|
||||||
|
if (job_id != null) skipReasonByJobId[job_id] = reason;
|
||||||
console.log(`[jobFilterEngine] 过滤条件不通过 剔除: ${jobDesc(job)}`);
|
console.log(`[jobFilterEngine] 过滤条件不通过 剔除: ${jobDesc(job)}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -126,6 +184,8 @@ class JobFilterEngine {
|
|||||||
const finalScore = scoreResult.totalScore + keywordBonus.score;
|
const finalScore = scoreResult.totalScore + keywordBonus.score;
|
||||||
|
|
||||||
if (finalScore < 60) {
|
if (finalScore < 60) {
|
||||||
|
const msg = `评分不足(总分${finalScore.toFixed(1)},需>=60)`;
|
||||||
|
if (job_id != null) skipReasonByJobId[job_id] = msg;
|
||||||
console.log(`[jobFilterEngine] 评分不足(>=60) 剔除: ${jobDesc(job)} 总分=${finalScore.toFixed(1)}`);
|
console.log(`[jobFilterEngine] 评分不足(>=60) 剔除: ${jobDesc(job)} 总分=${finalScore.toFixed(1)}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -139,7 +199,7 @@ class JobFilterEngine {
|
|||||||
|
|
||||||
scored.sort((a, b) => b.matchScore - a.matchScore);
|
scored.sort((a, b) => b.matchScore - a.matchScore);
|
||||||
console.log(`[jobFilterEngine] filterAndScoreJobsForDeliver 结束: 原始${jobs.length} 通过${scored.length}`);
|
console.log(`[jobFilterEngine] filterAndScoreJobsForDeliver 结束: 原始${jobs.length} 通过${scored.length}`);
|
||||||
return scored;
|
return { scored, skipReasonByJobId };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ class KeywordMatcher {
|
|||||||
if (filterKeywords.length > 0 && !filterResult.matched) {
|
if (filterKeywords.length > 0 && !filterResult.matched) {
|
||||||
return {
|
return {
|
||||||
pass: false,
|
pass: false,
|
||||||
reason: '不包含任何必需关键词',
|
reason: `未命中包含关键词: ${filterKeywords.join(', ')}`,
|
||||||
score: 0,
|
score: 0,
|
||||||
details: { filter: filterResult }
|
details: { filter: filterResult }
|
||||||
};
|
};
|
||||||
@@ -204,7 +204,7 @@ class KeywordMatcher {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const textExtractor=(job) => `${job.jobTitle || ''} ${job.companyIndustry || ''}`;
|
const textExtractor=(job) => `${job.jobTitle || ''}`;
|
||||||
|
|
||||||
const filtered = [];
|
const filtered = [];
|
||||||
|
|
||||||
|
|||||||
@@ -200,6 +200,18 @@ module.exports = (db) => {
|
|||||||
type: Sequelize.DATE,
|
type: Sequelize.DATE,
|
||||||
allowNull: true
|
allowNull: true
|
||||||
},
|
},
|
||||||
|
is_delivered: {
|
||||||
|
comment: '是否已投递成功:true 是,false 否(含未投、失败、过滤)',
|
||||||
|
type: Sequelize.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false
|
||||||
|
},
|
||||||
|
deliver_failed_reason: {
|
||||||
|
comment: '未投递或投递失败原因(直接落库,不依赖连表)',
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: ''
|
||||||
|
},
|
||||||
chatStatus: {
|
chatStatus: {
|
||||||
comment: '聊天状态: none-未聊天, sent-已发送, replied-已回复',
|
comment: '聊天状态: none-未聊天, sent-已发送, replied-已回复',
|
||||||
type: Sequelize.STRING(20),
|
type: Sequelize.STRING(20),
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user