Compare commits

...

11 Commits

Author SHA1 Message Date
张成
a45418883c 1 2026-04-16 17:39:25 +08:00
张成
df0aacc782 11 2026-04-16 14:01:52 +08:00
张成
7ef0c68ad1 1 2026-04-10 18:45:10 +08:00
张成
37daa2f99f 1 2026-04-10 18:35:13 +08:00
张成
51bbdacdda 1 2026-04-08 17:27:40 +08:00
张成
f2a8e61016 1 2026-04-08 16:39:27 +08:00
张成
048c40d802 1 2026-04-08 15:28:02 +08:00
张成
bfd39eddcf 1 2026-04-08 15:00:49 +08:00
张成
21fe005c19 1 2026-04-08 14:09:26 +08:00
张成
e3d14dd637 1 2026-04-08 13:30:50 +08:00
张成
ca8bbcd9cd 1 2026-04-08 13:29:22 +08:00
32 changed files with 2032 additions and 362 deletions

View 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;

View 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`);

View File

@@ -229,6 +229,12 @@
</div> </div>
</Col> </Col>
<Col span="8"> <Col span="8">
<div class="detail-item">
<span class="label">同公司重复投递间隔()</span>
<span class="value">{{ deliverConfig.repeat_deliver_days != null ? deliverConfig.repeat_deliver_days : '-' }}</span>
</div>
</Col>
<Col span="8">
<div class="detail-item"> <div class="detail-item">
<span class="label">过滤关键词</span> <span class="label">过滤关键词</span>
<span class="value">{{ deliverConfig.filter_keywords || '-' }}</span> <span class="value">{{ deliverConfig.filter_keywords || '-' }}</span>
@@ -560,6 +566,7 @@ export default {
max_salary: 0, max_salary: 0,
page_count: 3, page_count: 3,
max_deliver: 10, max_deliver: 10,
repeat_deliver_days: 30,
filter_keywords: '', filter_keywords: '',
exclude_keywords: '' exclude_keywords: ''
}, },
@@ -908,6 +915,7 @@ export default {
max_salary: deliverConfig.max_salary || 0, max_salary: deliverConfig.max_salary || 0,
page_count: deliverConfig.page_count || 3, page_count: deliverConfig.page_count || 3,
max_deliver: deliverConfig.max_deliver || 10, max_deliver: deliverConfig.max_deliver || 10,
repeat_deliver_days: deliverConfig.repeat_deliver_days != null ? deliverConfig.repeat_deliver_days : 30,
filter_keywords: Array.isArray(deliverConfig.filter_keywords) filter_keywords: Array.isArray(deliverConfig.filter_keywords)
? deliverConfig.filter_keywords.join(',') ? deliverConfig.filter_keywords.join(',')
: (deliverConfig.filter_keywords || ''), : (deliverConfig.filter_keywords || ''),
@@ -926,6 +934,7 @@ export default {
max_salary: 0, max_salary: 0,
page_count: 3, page_count: 3,
max_deliver: 10, max_deliver: 10,
repeat_deliver_days: 30,
filter_keywords: '', filter_keywords: '',
exclude_keywords: '', exclude_keywords: '',
deliver_start_time: '09:00', deliver_start_time: '09:00',

View File

@@ -148,6 +148,9 @@
<FormItem label="每次最多投递数"> <FormItem label="每次最多投递数">
<InputNumber v-model="formData.max_deliver" :min="1" placeholder="默认10个" style="width: 100%;" /> <InputNumber v-model="formData.max_deliver" :min="1" placeholder="默认10个" style="width: 100%;" />
</FormItem> </FormItem>
<FormItem label="同公司重复投递间隔(天)">
<InputNumber v-model="formData.repeat_deliver_days" :min="1" :max="365" placeholder="默认30天N天内投过的公司跳过" style="width: 100%;" />
</FormItem>
<FormItem label="过滤关键词"> <FormItem label="过滤关键词">
<Input <Input
v-model="formData.filter_keywords" v-model="formData.filter_keywords"
@@ -268,6 +271,7 @@ export default {
max_salary: 0, max_salary: 0,
page_count: 3, page_count: 3,
max_deliver: 10, max_deliver: 10,
repeat_deliver_days: 30,
filter_keywords: '', filter_keywords: '',
exclude_keywords: '', exclude_keywords: '',
deliver_start_time: '09:00', deliver_start_time: '09:00',
@@ -386,6 +390,7 @@ export default {
this.formData.max_salary = deliverConfig.max_salary || 0 this.formData.max_salary = deliverConfig.max_salary || 0
this.formData.page_count = deliverConfig.page_count || 3 this.formData.page_count = deliverConfig.page_count || 3
this.formData.max_deliver = deliverConfig.max_deliver || 10 this.formData.max_deliver = deliverConfig.max_deliver || 10
this.formData.repeat_deliver_days = deliverConfig.repeat_deliver_days != null ? deliverConfig.repeat_deliver_days : 30
this.formData.filter_keywords = Array.isArray(deliverConfig.filter_keywords) this.formData.filter_keywords = Array.isArray(deliverConfig.filter_keywords)
? deliverConfig.filter_keywords.join('\n') ? deliverConfig.filter_keywords.join('\n')
: (deliverConfig.filter_keywords || '') : (deliverConfig.filter_keywords || '')
@@ -447,6 +452,7 @@ export default {
max_salary: 0, max_salary: 0,
page_count: 3, page_count: 3,
max_deliver: 10, max_deliver: 10,
repeat_deliver_days: 30,
filter_keywords: '', filter_keywords: '',
exclude_keywords: '', exclude_keywords: '',
auto_chat: 0, auto_chat: 0,
@@ -600,6 +606,9 @@ export default {
if (saveData.max_deliver !== undefined) { if (saveData.max_deliver !== undefined) {
deliverConfig.max_deliver = Number(saveData.max_deliver) || 10 deliverConfig.max_deliver = Number(saveData.max_deliver) || 10
} }
if (saveData.repeat_deliver_days !== undefined) {
deliverConfig.repeat_deliver_days = Number(saveData.repeat_deliver_days) || 30
}
// 解析过滤关键词:支持换行和逗号分隔 // 解析过滤关键词:支持换行和逗号分隔
if (saveData.filter_keywords !== undefined) { if (saveData.filter_keywords !== undefined) {
deliverConfig.filter_keywords = this.parseKeywords(saveData.filter_keywords) deliverConfig.filter_keywords = this.parseKeywords(saveData.filter_keywords)
@@ -623,6 +632,7 @@ export default {
delete saveData.max_salary delete saveData.max_salary
delete saveData.page_count delete saveData.page_count
delete saveData.max_deliver delete saveData.max_deliver
delete saveData.repeat_deliver_days
delete saveData.filter_keywords delete saveData.filter_keywords
delete saveData.exclude_keywords delete saveData.exclude_keywords
delete saveData.deliver_start_time delete saveData.deliver_start_time

View File

@@ -323,7 +323,7 @@ export default {
sort_order: row.sort_order || 0 sort_order: row.sort_order || 0
} }
this.$refs.editModal.editShow(editRow,(newRow)=>{ this.$refs.editModal.editShow(editRow,(newRow)=>{
debugger
this.handleSaveSuccess(newRow) this.handleSaveSuccess(newRow)
}) })

View File

@@ -28,13 +28,14 @@
<tables :columns="listColumns" :value="gridOption.data" :pageOption="gridOption.param.pageOption" <tables :columns="listColumns" :value="gridOption.data" :pageOption="gridOption.param.pageOption"
@changePage="query"></tables> @changePage="query"></tables>
</div> </div>
<editModal ref="editModal" :columns="editColumns" :rules="gridOption.rules" @on-save="handleSaveSuccess"> <editModal ref="editModal" :columns="editFormColumns" :rules="gridOption.rules" @on-save="handleSaveSuccess">
</editModal> </editModal>
</div> </div>
</template> </template>
<script> <script>
import jobTypesServer from '@/api/work/job_types_server.js' import jobTypesServer from '@/api/work/job_types_server.js'
import plaAccountServer from '@/api/profile/pla_account_server.js'
export default { export default {
data() { data() {
@@ -44,8 +45,10 @@ export default {
return { return {
seachTypes: [ seachTypes: [
{ key: 'name', value: '职位类型名称' }, { key: 'name', value: '职位类型名称' },
{ key: 'description', value: '描述' } { key: 'description', value: '描述' },
{ key: 'pla_account_id', value: '关联账户ID' }
], ],
plaAccountOptions: [],
gridOption: { gridOption: {
param: { param: {
seachOption: { seachOption: {
@@ -63,6 +66,23 @@ export default {
}, },
listColumns: [ listColumns: [
{ title: 'ID', key: 'id', minWidth: 80 }, { title: 'ID', key: 'id', minWidth: 80 },
{
title: '关联账户',
key: 'pla_account',
minWidth: 200,
render: (h, params) => {
const id = params.row.pla_account_id
const pa = params.row.pla_account
if (id == null || id === '') {
return h('span', { style: { color: '#999' } }, '-')
}
if (pa && (pa.name || pa.sn_code)) {
const txt = `${pa.name || ''} (SN:${pa.sn_code || '-'})`
return h('span', { attrs: { title: `ID:${id} ${txt}` } }, txt)
}
return h('span', { attrs: { title: '仅ID账户可能已删除' } }, `ID:${id}`)
}
},
{ title: '职位类型名称', key: 'name', minWidth: 150 }, { title: '职位类型名称', key: 'name', minWidth: 150 },
{ title: '描述', key: 'description', minWidth: 200 }, { title: '描述', key: 'description', minWidth: 200 },
{ {
@@ -77,6 +97,11 @@ export default {
{ {
title: '常见技能关键词', key: 'commonSkills', minWidth: 200, title: '常见技能关键词', key: 'commonSkills', minWidth: 200,
}, },
{
title: '标题须含关键词',
key: 'titleIncludeKeywords',
minWidth: 200
},
{ {
title: '排除关键词', key: 'excludeKeywords', minWidth: 200 title: '排除关键词', key: 'excludeKeywords', minWidth: 200
}, },
@@ -147,6 +172,22 @@ export default {
placeholder: '请输入JSON数组格式例如["外包", "销售", "客服"]', placeholder: '请输入JSON数组格式例如["外包", "销售", "客服"]',
tooltip: '排除关键词列表JSON数组格式' tooltip: '排除关键词列表JSON数组格式'
}, },
{
title: '标题须含关键词',
key: 'titleIncludeKeywords',
com: 'TextArea',
required: false,
placeholder: '请输入JSON数组格式例如["售前", "工程师"]',
tooltip: 'JSON数组格式仅匹配岗位标题须同时包含每一项与「常见技能关键词」无关'
},
{
title: '关联账户',
key: 'pla_account_id',
type: 'select',
required: false,
tooltip: '可选;与设备/账号绑定AI 同步 Tab 时会写入',
options: []
},
{ {
title: '是否启用', title: '是否启用',
key: 'is_enabled', key: 'is_enabled',
@@ -171,12 +212,35 @@ export default {
seachTypePlaceholder() { seachTypePlaceholder() {
const item = this.seachTypes.find(item => item.key === this.gridOption.param.seachOption.key) const item = this.seachTypes.find(item => item.key === this.gridOption.param.seachOption.key)
return item ? `请输入${item.value}` : '请选择' return item ? `请输入${item.value}` : '请选择'
},
editFormColumns() {
const accOpts = [{ value: '', label: '不关联' }, ...this.plaAccountOptions]
return this.editColumns.map((col) => {
if (col.key === 'pla_account_id') {
return { ...col, options: accOpts }
}
return col
})
} }
}, },
mounted() { mounted() {
this.loadPlaAccountOptions()
this.query(1) this.query(1)
}, },
methods: { methods: {
loadPlaAccountOptions() {
plaAccountServer.page({
pageOption: { page: 1, pageSize: 999 },
seachOption: {}
}).then((res) => {
if (res.code === 0 && res.data && res.data.rows) {
this.plaAccountOptions = res.data.rows.map((r) => ({
value: r.id,
label: `${r.name || ''} (SN:${r.sn_code || r.id})`
}))
}
}).catch(() => {})
},
query(page) { query(page) {
if (page) { if (page) {
this.gridOption.param.pageOption.page = page this.gridOption.param.pageOption.page = page
@@ -217,6 +281,8 @@ export default {
description: '', description: '',
commonSkills: '[]', commonSkills: '[]',
excludeKeywords: '[]', excludeKeywords: '[]',
titleIncludeKeywords: '[]',
pla_account_id: '',
is_enabled: 1, is_enabled: 1,
sort_order: 0 sort_order: 0
}) })
@@ -249,12 +315,26 @@ export default {
excludeKeywords = JSON.stringify(excludeKeywords, null, 2) excludeKeywords = JSON.stringify(excludeKeywords, null, 2)
} }
let titleIncludeKeywords = row.titleIncludeKeywords || '[]'
if (typeof titleIncludeKeywords === 'string') {
try {
const parsed = JSON.parse(titleIncludeKeywords)
titleIncludeKeywords = JSON.stringify(parsed, null, 2)
} catch (e) {
// 保持原样
}
} else {
titleIncludeKeywords = JSON.stringify(titleIncludeKeywords, null, 2)
}
this.$refs.editModal.editShow({ this.$refs.editModal.editShow({
id: row.id, id: row.id,
name: row.name, name: row.name,
description: row.description || '', description: row.description || '',
commonSkills: commonSkills, commonSkills: commonSkills,
excludeKeywords: excludeKeywords, excludeKeywords: excludeKeywords,
titleIncludeKeywords: titleIncludeKeywords,
pla_account_id: row.pla_account_id != null && row.pla_account_id !== '' ? row.pla_account_id : '',
is_enabled: row.is_enabled, is_enabled: row.is_enabled,
sort_order: row.sort_order || 0 sort_order: row.sort_order || 0
}) })
@@ -281,7 +361,7 @@ export default {
// 处理 JSON 字段 // 处理 JSON 字段
const formData = { ...data } const formData = { ...data }
// 处理 commonSkills // 处理 commonSkillsJSON 数组)
if (formData.commonSkills) { if (formData.commonSkills) {
try { try {
const parsed = typeof formData.commonSkills === 'string' const parsed = typeof formData.commonSkills === 'string'
@@ -294,7 +374,7 @@ export default {
} }
} }
// 处理 excludeKeywords // 处理 excludeKeywordsJSON 数组)
if (formData.excludeKeywords) { if (formData.excludeKeywords) {
try { try {
const parsed = typeof formData.excludeKeywords === 'string' const parsed = typeof formData.excludeKeywords === 'string'
@@ -307,6 +387,28 @@ export default {
} }
} }
// 处理 titleIncludeKeywordsJSON 数组,与上两项一致)
if (formData.titleIncludeKeywords) {
try {
const parsed = typeof formData.titleIncludeKeywords === 'string'
? JSON.parse(formData.titleIncludeKeywords)
: formData.titleIncludeKeywords
formData.titleIncludeKeywords = Array.isArray(parsed) ? parsed : []
} catch (e) {
this.$Message.warning('标题须含关键词格式错误,将使用空数组')
formData.titleIncludeKeywords = []
}
} else {
formData.titleIncludeKeywords = []
}
if (formData.pla_account_id === undefined || formData.pla_account_id === '') {
formData.pla_account_id = null
} else if (formData.pla_account_id != null) {
const n = parseInt(formData.pla_account_id, 10)
formData.pla_account_id = Number.isNaN(n) ? null : n
}
const apiMethod = formData.id ? jobTypesServer.update : jobTypesServer.add const apiMethod = formData.id ? jobTypesServer.update : jobTypesServer.add
apiMethod(formData).then(res => { apiMethod(formData).then(res => {
if (res.code === 0) { if (res.code === 0) {

View File

@@ -5,6 +5,57 @@
const Framework = require("../../framework/node-core-framework.js"); const Framework = require("../../framework/node-core-framework.js");
/**
* 为 job_types 行批量附加 pla_account列表/详情展示)
* @param {import('sequelize').Model[]} rowInstances
* @param {object} models
* @returns {Promise<object[]>}
*/
async function attachPlaAccountToJobTypeRows(rowInstances, models) {
const { pla_account, op } = models;
const plain = (rowInstances || []).map((r) => (r && typeof r.toJSON === 'function' ? r.toJSON() : r));
const ids = [...new Set(plain.map((row) => row.pla_account_id).filter((id) => id != null && id !== ''))];
if (!pla_account || ids.length === 0) {
return plain.map((row) => ({ ...row, pla_account: null }));
}
const accounts = await pla_account.findAll({
where: { id: { [op.in]: ids } },
attributes: ['id', 'name', 'sn_code', 'login_name']
});
const map = {};
accounts.forEach((a) => {
const j = a.toJSON();
map[j.id] = j;
});
return plain.map((row) => ({
...row,
pla_account: row.pla_account_id != null ? map[row.pla_account_id] || null : null
}));
}
/**
* @param {*} raw
* @param {object} pla_account
* @returns {Promise<{ ok: boolean, value?: number|null, message?: string }>}
*/
async function normalizePlaAccountId(raw, pla_account) {
if (raw === undefined) {
return { ok: true, skip: true };
}
if (raw === null || raw === '') {
return { ok: true, value: null };
}
const n = parseInt(raw, 10);
if (Number.isNaN(n) || n < 1) {
return { ok: false, message: '关联账户ID无效' };
}
const acc = await pla_account.findByPk(n);
if (!acc) {
return { ok: false, message: '关联账户不存在' };
}
return { ok: true, value: n };
}
module.exports = { module.exports = {
/** /**
* @swagger * @swagger
@@ -37,14 +88,26 @@ module.exports = {
const models = Framework.getModels(); const models = Framework.getModels();
const { job_types, op } = models; const { job_types, op } = models;
const body = ctx.getBody(); const body = ctx.getBody();
const { name } = body; const seachOption = body.seachOption || {};
// 获取分页参数
const { limit, offset } = ctx.getPageSize(); const { limit, offset } = ctx.getPageSize();
const where = {}; const where = {};
if (name) { const key = seachOption.key || body.key;
where.name = { [op.like]: `%${name}%` }; const value = seachOption.value !== undefined && seachOption.value !== null ? seachOption.value : body.value;
if (key && value !== undefined && value !== null && String(value).trim() !== '') {
const v = String(value).trim();
if (key === 'pla_account_id') {
const n = parseInt(v, 10);
if (!Number.isNaN(n)) {
where.pla_account_id = n;
}
} else if (key === 'name' || key === 'description') {
where[key] = { [op.like]: `%${v}%` };
}
}
if (seachOption.is_enabled !== undefined && seachOption.is_enabled !== null) {
where.is_enabled = seachOption.is_enabled;
} }
const result = await job_types.findAndCountAll({ const result = await job_types.findAndCountAll({
@@ -54,7 +117,8 @@ module.exports = {
order: [['sort_order', 'ASC'], ['id', 'ASC']] order: [['sort_order', 'ASC'], ['id', 'ASC']]
}); });
return ctx.success(result); const rows = await attachPlaAccountToJobTypeRows(result.rows, models);
return ctx.success({ rows, count: result.count });
}, },
/** /**
@@ -89,7 +153,8 @@ module.exports = {
return ctx.fail('职位类型不存在'); return ctx.fail('职位类型不存在');
} }
return ctx.success(jobType); const [enriched] = await attachPlaAccountToJobTypeRows([jobType], models);
return ctx.success(enriched);
}, },
/** /**
@@ -120,6 +185,9 @@ module.exports = {
* excludeKeywords: * excludeKeywords:
* type: array * type: array
* description: 排除关键词JSON数组 * description: 排除关键词JSON数组
* titleIncludeKeywords:
* type: array
* description: 职位标题须同时包含的子串JSON数组仅匹配岗位标题
* is_enabled: * is_enabled:
* type: integer * type: integer
* description: 是否启用1=启用0=禁用) * description: 是否启用1=启用0=禁用)
@@ -132,9 +200,9 @@ module.exports = {
*/ */
'POST /job_type/create': async (ctx) => { 'POST /job_type/create': async (ctx) => {
const models = Framework.getModels(); const models = Framework.getModels();
const { job_types } = models; const { job_types, pla_account } = models;
const body = ctx.getBody(); const body = ctx.getBody();
const { name, description, commonSkills, excludeKeywords, is_enabled, sort_order } = body; const { name, description, commonSkills, excludeKeywords, titleIncludeKeywords, is_enabled, sort_order } = body;
if (!name) { if (!name) {
return ctx.fail('职位类型名称不能为空'); return ctx.fail('职位类型名称不能为空');
@@ -146,16 +214,30 @@ module.exports = {
return ctx.fail('职位类型名称已存在'); return ctx.fail('职位类型名称已存在');
} }
let pla_account_id = null;
const paResolved = await normalizePlaAccountId(body.pla_account_id, pla_account);
if (!paResolved.ok) {
return ctx.fail(paResolved.message || '关联账户校验失败');
}
if (!paResolved.skip) {
pla_account_id = paResolved.value === undefined ? null : paResolved.value;
}
const jobType = await job_types.create({ const jobType = await job_types.create({
name, name,
description: description || '', description: description || '',
commonSkills: Array.isArray(commonSkills) ? JSON.stringify(commonSkills) : (commonSkills || '[]'), commonSkills: Array.isArray(commonSkills) ? JSON.stringify(commonSkills) : (commonSkills || '[]'),
excludeKeywords: Array.isArray(excludeKeywords) ? JSON.stringify(excludeKeywords) : (excludeKeywords || '[]'), excludeKeywords: Array.isArray(excludeKeywords) ? JSON.stringify(excludeKeywords) : (excludeKeywords || '[]'),
titleIncludeKeywords: Array.isArray(titleIncludeKeywords)
? JSON.stringify(titleIncludeKeywords)
: (titleIncludeKeywords || '[]'),
pla_account_id,
is_enabled: is_enabled !== undefined ? is_enabled : 1, is_enabled: is_enabled !== undefined ? is_enabled : 1,
sort_order: sort_order || 0 sort_order: sort_order || 0
}); });
return ctx.success(jobType); const [enriched] = await attachPlaAccountToJobTypeRows([jobType], models);
return ctx.success(enriched);
}, },
/** /**
@@ -189,6 +271,9 @@ module.exports = {
* excludeKeywords: * excludeKeywords:
* type: array * type: array
* description: 排除关键词JSON数组 * description: 排除关键词JSON数组
* titleIncludeKeywords:
* type: array
* description: 职位标题须同时包含的子串JSON数组
* is_enabled: * is_enabled:
* type: integer * type: integer
* description: 是否启用1=启用0=禁用) * description: 是否启用1=启用0=禁用)
@@ -201,9 +286,9 @@ module.exports = {
*/ */
'POST /job_type/update': async (ctx) => { 'POST /job_type/update': async (ctx) => {
const models = Framework.getModels(); const models = Framework.getModels();
const { job_types } = models; const { job_types, pla_account } = models;
const body = ctx.getBody(); const body = ctx.getBody();
const { id, name, description, commonSkills, excludeKeywords, is_enabled, sort_order } = body; const { id, name, description, commonSkills, excludeKeywords, titleIncludeKeywords, is_enabled, sort_order } = body;
if (!id) { if (!id) {
return ctx.fail('职位类型ID不能为空'); return ctx.fail('职位类型ID不能为空');
@@ -231,16 +316,32 @@ module.exports = {
if (excludeKeywords !== undefined) { if (excludeKeywords !== undefined) {
updateData.excludeKeywords = Array.isArray(excludeKeywords) ? JSON.stringify(excludeKeywords) : excludeKeywords; updateData.excludeKeywords = Array.isArray(excludeKeywords) ? JSON.stringify(excludeKeywords) : excludeKeywords;
} }
if (titleIncludeKeywords !== undefined) {
updateData.titleIncludeKeywords = Array.isArray(titleIncludeKeywords)
? JSON.stringify(titleIncludeKeywords)
: titleIncludeKeywords;
}
if (body.pla_account_id !== undefined) {
const paResolved = await normalizePlaAccountId(body.pla_account_id, pla_account);
if (!paResolved.ok) {
return ctx.fail(paResolved.message || '关联账户校验失败');
}
if (!paResolved.skip) {
updateData.pla_account_id = paResolved.value === undefined ? null : paResolved.value;
}
}
if (is_enabled !== undefined) updateData.is_enabled = is_enabled; if (is_enabled !== undefined) updateData.is_enabled = is_enabled;
if (sort_order !== undefined) updateData.sort_order = sort_order; if (sort_order !== undefined) updateData.sort_order = sort_order;
await job_types.update(updateData, { where: { id } }); await job_types.update(updateData, { where: { id } });
// 清除缓存 // 清除缓存
const jobFilterService = require('../middleware/job/job_filter_service.js'); const jobFilterService = require('../middleware/job/services/jobFilterService.js');
jobFilterService.clearCache(id); jobFilterService.clearCache(id);
return ctx.success({ message: '职位类型更新成功' }); const updated = await job_types.findByPk(id);
const [enriched] = await attachPlaAccountToJobTypeRows([updated], models);
return ctx.success(enriched);
}, },
/** /**
@@ -289,7 +390,7 @@ module.exports = {
await job_types.destroy({ where: { id } }); await job_types.destroy({ where: { id } });
// 清除缓存 // 清除缓存
const jobFilterService = require('../middleware/job/job_filter_service.js'); const jobFilterService = require('../middleware/job/services/jobFilterService.js');
jobFilterService.clearCache(id); jobFilterService.clearCache(id);
return ctx.success({ message: '职位类型删除成功' }); return ctx.success({ message: '职位类型删除成功' });

View 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
});
}
};

View File

@@ -308,6 +308,147 @@ 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_commands } = await Framework.getModels();
const sequelize = task_commands.sequelize;
const Sequelize = require('sequelize');
const conditions = ['ts.sn_code = :sn_code'];
const replacements = {
sn_code,
limit: page_size,
offset
};
const command_type = body.command_type != null ? String(body.command_type).trim() : '';
const status = body.status != null ? String(body.status).trim() : '';
if (command_type) {
conditions.push('tc.command_type = :command_type');
replacements.command_type = command_type;
}
if (status) {
conditions.push('tc.status = :status');
replacements.status = status;
}
// 仅保留真实下发到客户端的指令,过滤服务端汇总类记录
conditions.push("tc.command_type <> 'job_filter_summary'");
const where_sql = conditions.join(' AND ');
// 用 JOIN + 覆盖索引,避免大 IN 列表导致慢查询
const count_sql = `
SELECT COUNT(*) AS count
FROM task_commands tc
INNER JOIN task_status ts ON ts.id = tc.task_id
WHERE ${where_sql}
`;
const data_sql = `
SELECT
tc.id,
tc.task_id,
tc.command_type,
tc.command_name,
tc.command_params,
tc.status,
tc.priority,
tc.sequence,
tc.retry_count,
tc.max_retries,
tc.start_time,
tc.end_time,
tc.duration,
tc.result,
tc.error_message,
tc.progress
FROM task_commands tc
INNER JOIN task_status ts ON ts.id = tc.task_id
WHERE ${where_sql}
ORDER BY tc.id DESC
LIMIT :limit OFFSET :offset
`;
const [count_result, rows] = await Promise.all([
sequelize.query(count_sql, {
replacements,
type: Sequelize.QueryTypes.SELECT
}),
sequelize.query(data_sql, {
replacements,
type: Sequelize.QueryTypes.SELECT
})
]);
const count = Number(count_result && count_result[0] ? count_result[0].count : 0) || 0;
const list = rows.map((r) => {
const j = r && typeof r.toJSON === 'function' ? 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 || '未知错误'));
}
},
/**
* 按设备 SN 重试单条指令
*/
'POST /task/command/retry': async (ctx) => {
try {
const body = ctx.getBody() || {};
const query = typeof ctx.getQuery === 'function' ? (ctx.getQuery() || {}) : {};
const sn_code = body.sn_code || query.sn_code;
const command_id = Number(body.command_id || body.commandId || 0);
if (!sn_code) {
return ctx.fail('请提供设备SN码');
}
if (!command_id) {
return ctx.fail('请提供指令ID');
}
const { task_commands, task_status } = await Framework.getModels();
const command_row = await task_commands.findByPk(command_id);
if (!command_row) {
return ctx.fail('指令不存在');
}
const task_row = await task_status.findByPk(command_row.task_id);
if (!task_row || String(task_row.sn_code || '') !== String(sn_code)) {
return ctx.fail('无权重试该指令');
}
const plaAccountService = require('../services/pla_account_service.js');
const result = await plaAccountService.retryCommand({ commandId: command_id });
return ctx.success(result);
} catch (error) {
console.error('[任务管理] 重试指令失败:', error);
return ctx.fail('重试指令失败: ' + (error.message || '未知错误'));
}
} }
}; };

View File

@@ -496,7 +496,7 @@ module.exports = {
/** /**
* 仅保存投递标签:标签列表(来自 get_job_listings+ 当前选中的标签 * 仅保存投递标签:标签列表(来自 get_job_listings+ 当前选中的标签
* 更新 resume_info 的 job_listings、deliver_tab_label不碰其他配置 * 更新 resume_info 的 job_listings、deliver_tab_label并同步 pla_account.keyword与推荐/期望职位一致)
*/ */
'POST /user/deliver-tab-label/save': async (ctx) => { 'POST /user/deliver-tab-label/save': async (ctx) => {
try { try {
@@ -533,6 +533,10 @@ module.exports = {
isActive: true isActive: true
}); });
} }
const keywordSync = label.trim().slice(0, 50);
await pla_account.update({ keyword: keywordSync }, { where: { id: user.id } });
return ctx.success({ message: '投递标签已保存', job_listings: list, deliver_tab_label: label }); return ctx.success({ message: '投递标签已保存', job_listings: list, deliver_tab_label: label });
} catch (error) { } catch (error) {
console.error('[保存投递标签失败]', error); console.error('[保存投递标签失败]', error);
@@ -603,6 +607,13 @@ module.exports = {
isActive: true isActive: true
}); });
} }
if (deliver_tab_label !== undefined) {
const keywordSync = (deliver_tab_label != null ? String(deliver_tab_label) : '')
.trim()
.slice(0, 50);
await pla_account.update({ keyword: keywordSync }, { where: { id: user.id } });
}
} }
if (Object.keys(updateData).length === 0 && deliver_tab_label === undefined && job_listings === undefined) { if (Object.keys(updateData).length === 0 && deliver_tab_label === undefined && job_listings === undefined) {

View File

@@ -1,3 +1,4 @@
const { Op } = require('sequelize');
const aiService = require('../../../services/ai_service'); const aiService = require('../../../services/ai_service');
const { jobFilterService } = require('../services'); const { jobFilterService } = require('../services');
const locationService = require('../../../services/locationService'); const locationService = require('../../../services/locationService');
@@ -5,6 +6,7 @@ const logs = require('../../logProxy');
const db = require('../../dbProxy'); const db = require('../../dbProxy');
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
/** /**
* 工作管理模块 * 工作管理模块
* 负责简历获取、分析、存储和匹配度计算 * 负责简历获取、分析、存储和匹配度计算
@@ -13,6 +15,25 @@ class JobManager {
constructor() { constructor() {
} }
/**
* 解析设备 get_job_list / search_job_list 等返回的 response.data。
* 实际形态为「多页 XHR 监听结果」数组,与客户端一致,例如:
* `[{ url, method, status, data: { code: 0, message, zpData: { hasMore, jobList: [...] } } }, ...]`
* pageCount=3 时通常 3 条(每页一次 list.json本方法将所有页的 jobList 合并为一维数组。
*/
_jobListFromRecommendMonitorData(responseData) {
if (!Array.isArray(responseData)) return [];
const jobs = [];
for (const item of responseData) {
const inner = item?.data;
if (!inner || typeof inner !== 'object') continue;
if (inner.code !== undefined && inner.code !== 0) continue;
const list = inner.zpData?.jobList;
if (Array.isArray(list)) jobs.push(...list);
}
return jobs;
}
// 启动客户端那个平台 用户信息,心跳机制 // 启动客户端那个平台 用户信息,心跳机制
async set_user_info(sn_code, mqttClient, user_info) { async set_user_info(sn_code, mqttClient, user_info) {
const response = await mqttClient.publishAndWait(sn_code, { const response = await mqttClient.publishAndWait(sn_code, {
@@ -151,8 +172,8 @@ class JobManager {
* @returns {Promise<object>} 搜索结果 * @returns {Promise<object>} 搜索结果
*/ */
async search_jobs_with_params(sn_code, mqttClient, params = {}) { async search_jobs_with_params(sn_code, mqttClient, params = {}) {
const { const {
keyword = '前端', keyword = '前端',
platform = 'boss', platform = 'boss',
city = '', city = '',
cityName = '', cityName = '',
@@ -199,28 +220,14 @@ class JobManager {
throw new Error('多条件搜索职位失败'); throw new Error('多条件搜索职位失败');
} }
// 处理职位列表数据 const jobs = this._jobListFromRecommendMonitorData(response.data);
let jobs = [];
if (Array.isArray(response.data)) {
for (const item of response.data) {
if (item.data?.zpData?.jobList && Array.isArray(item.data.zpData.jobList)) {
jobs = jobs.concat(item.data.zpData.jobList);
}
}
} else if (response.data?.data?.zpData?.jobList) {
jobs = response.data.data.zpData.jobList || [];
} else if (response.data?.zpData?.jobList) {
jobs = response.data.zpData.jobList || [];
}
console.log(`[工作管理] 成功获取岗位数据,共 ${jobs.length} 个岗位`); console.log(`[工作管理] 成功获取岗位数据,共 ${jobs.length} 个岗位`);
// 保存职位到数据库 // 保存职位到数据库
try {
await this.saveJobsToDatabase(sn_code, platform, keyword, jobs); await this.saveJobsToDatabase(sn_code, platform, keyword, jobs);
} catch (error) {
console.error(`[工作管理] 保存职位到数据库失败:`, error);
}
return { return {
jobs: jobs, jobs: jobs,
@@ -238,7 +245,7 @@ class JobManager {
* @returns {Promise<object>} 执行结果 * @returns {Promise<object>} 执行结果
*/ */
async search_and_deliver(sn_code, mqttClient, params = {}) { async search_and_deliver(sn_code, mqttClient, params = {}) {
const { const {
keyword, keyword,
searchParams = {}, searchParams = {},
pageCount = 3, pageCount = 3,
@@ -334,6 +341,35 @@ class JobManager {
resumeInfo: resumeData resumeInfo: resumeData
}); });
// 未通过规则/评分的待投递记录标记为 filtered避免长期 pending
const passedIds = new Set(matchedJobs.map((j) => j.id).filter((id) => id != null));
const notPassedIds = searchedJobs
.map((row) => (row.toJSON ? row.toJSON() : row))
.map((j) => j.id)
.filter((id) => id != null && !passedIds.has(id));
if (notPassedIds.length > 0) {
try {
await job_postings.update(
{
applyStatus: 'filtered',
is_delivered: false,
deliver_failed_reason: '未通过搜索并投递筛选规则'
},
{
where: {
id: { [Op.in]: notPassedIds },
sn_code,
platform,
applyStatus: 'pending'
}
}
);
console.log(`[工作管理] 搜索并投递:不符合条件已标记 filtered ${notPassedIds.length}`);
} catch (e) {
console.warn('[工作管理] 标记 filtered 失败:', e.message);
}
}
// 7. 限制投递数量 // 7. 限制投递数量
const jobsToDeliver = matchedJobs.slice(0, maxCount); const jobsToDeliver = matchedJobs.slice(0, maxCount);
console.log(`[工作管理] 匹配到 ${matchedJobs.length} 个职位,将投递 ${jobsToDeliver.length}`); console.log(`[工作管理] 匹配到 ${matchedJobs.length} 个职位,将投递 ${jobsToDeliver.length}`);
@@ -351,8 +387,8 @@ class JobManager {
let securityId = jobData.securityId || ''; let securityId = jobData.securityId || '';
try { try {
if (jobData.originalData) { if (jobData.originalData) {
const originalData = typeof jobData.originalData === 'string' const originalData = typeof jobData.originalData === 'string'
? JSON.parse(jobData.originalData) ? JSON.parse(jobData.originalData)
: jobData.originalData; : jobData.originalData;
securityId = originalData.securityId || securityId; securityId = originalData.securityId || securityId;
} }
@@ -414,110 +450,39 @@ class JobManager {
} }
const list = Array.isArray(response.data) ? response.data : []; const list = Array.isArray(response.data) ? response.data : [];
console.log(`[工作管理] 获取 job_listings 成功,共 ${list.length} 个 tab`); console.log(`[工作管理] 获取 job_listings 成功,共 ${list.length} 个 tab`);
try {
const jobTypeAiSyncService = require('../../../services/job_type_ai_sync_service');
await jobTypeAiSyncService.maybeSyncAfterListings(sn_code, list, platform);
} catch (syncErr) {
console.warn('[工作管理] job_types AI 同步失败:', syncErr.message);
}
return list; return list;
} }
/** /**
* 获取岗位列表(支持多条件搜索 * 获取岗位列表(与客户端/Electron 约定一致
* @param {string} sn_code - 设备SN * @param {string} sn_code - 设备 SN
* @param {object} mqttClient - MQTT客户端 * @param {object} mqttClient - MQTT 客户端
* @param {object} params - 参数 * @param {object} params - { platform?, pageCount?, tabLabel?, keyword? }keyword 仅服务端入库用,下发设备只有 pageCount + tabLabel
* @returns {Promise<object>} 岗位列表 * @returns {Promise<object>} 岗位列表
*/ */
async get_job_list(sn_code, mqttClient, params = {}) { async get_job_list(sn_code, mqttClient, params = {}) {
const { const { platform = 'boss', pageCount = 3, tabLabel } = params;
keyword = '前端', const keyword = String(params.keyword || '').trim() || String(tabLabel || '').trim();
platform = 'boss',
pageCount = 3,
city = '',
cityName = '',
salary = '',
experience = '',
education = '',
industry = '',
companySize = '',
financingStage = '',
page = 1,
pageSize = 20
} = params;
// 判断是否是多条件搜索(如果包含多条件参数,使用多条件搜索逻辑) const data = {
const hasMultiParams = city || cityName || salary || experience || education || pageCount,
industry || companySize || financingStage || page || pageSize; ...(String(tabLabel || '').trim() ? { tabLabel: String(tabLabel).trim() } : {})
};
if (hasMultiParams) { console.log(`[工作管理] get_job_list ${sn_code} → 设备`, data, `入库 keyword=${keyword}`);
// 使用多条件搜索逻辑
console.log(`[工作管理] 开始多条件搜索设备 ${sn_code} 的职位,关键词: ${keyword}, 城市: ${cityName || city}`);
// 构建完整的搜索参数对象
const searchData = {
keyword,
pageCount
};
// 添加可选搜索条件
if (city) searchData.city = city;
if (cityName) searchData.cityName = cityName;
if (salary) searchData.salary = salary;
if (experience) searchData.experience = experience;
if (education) searchData.education = education;
if (industry) searchData.industry = industry;
if (companySize) searchData.companySize = companySize;
if (financingStage) searchData.financingStage = financingStage;
if (page) searchData.page = page;
if (pageSize) searchData.pageSize = pageSize;
// 通过MQTT指令获取岗位列表保持action不变前端已使用
const response = await mqttClient.publishAndWait(sn_code, {
platform,
action: "get_job_list", // 保持与原有get_job_list相同的action前端已使用
data: searchData
});
if (!response || response.code !== 200) {
console.error(`[工作管理] 多条件搜索职位失败:`, response);
throw new Error('多条件搜索职位失败');
}
// 处理职位列表数据
let jobs = [];
if (Array.isArray(response.data)) {
for (const item of response.data) {
if (item.data?.zpData?.jobList && Array.isArray(item.data.zpData.jobList)) {
jobs = jobs.concat(item.data.zpData.jobList);
}
}
} else if (response.data?.data?.zpData?.jobList) {
jobs = response.data.data.zpData.jobList || [];
} else if (response.data?.zpData?.jobList) {
jobs = response.data.zpData.jobList || [];
}
console.log(`[工作管理] 成功获取岗位数据,共 ${jobs.length} 个岗位`);
// 保存职位到数据库
try {
await this.saveJobsToDatabase(sn_code, platform, keyword, jobs);
} catch (error) {
console.error(`[工作管理] 保存职位到数据库失败:`, error);
}
return {
jobs: jobs,
keyword: keyword,
platform: platform,
count: jobs.length
};
}
// 简单搜索逻辑(保持原有逻辑)
console.log(`[工作管理] 开始获取设备 ${sn_code} 的岗位列表,关键词: ${keyword}`);
// 通过MQTT指令获取岗位列表
const response = await mqttClient.publishAndWait(sn_code, { const response = await mqttClient.publishAndWait(sn_code, {
platform, platform,
action: "get_job_list", action: 'get_job_list',
data: { keyword, pageCount } data
}); });
if (!response || response.code !== 200) { if (!response || response.code !== 200) {
@@ -525,43 +490,19 @@ class JobManager {
throw new Error('获取岗位列表失败'); throw new Error('获取岗位列表失败');
} }
// 处理职位列表数据response.data 可能是数组(职位列表.json 格式)或单个对象 const jobs = this._jobListFromRecommendMonitorData(response.data);
let jobs = [];
if (Array.isArray(response.data)) {
// 如果是数组格式(职位列表.json遍历每个元素提取岗位数据
for (const item of response.data) {
if (item.data?.zpData?.jobList && Array.isArray(item.data.zpData.jobList)) {
jobs = jobs.concat(item.data.zpData.jobList);
}
}
console.log(`[工作管理] 从 ${response.data.length} 个响应中提取岗位数据`);
} else if (response.data?.data?.zpData?.jobList) {
// 如果是单个对象格式,从 data.zpData.jobList 获取
jobs = response.data.data.zpData.jobList || [];
} else if (response.data?.zpData?.jobList) {
// 兼容旧格式:直接从 zpData.jobList 获取
jobs = response.data.zpData.jobList || [];
}
console.log(`[工作管理] 成功获取岗位数据,共 ${jobs.length} 个岗位`); console.log(`[工作管理] 成功获取岗位数据,共 ${jobs.length} 个岗位`);
// 保存职位到数据库
try {
await this.saveJobsToDatabase(sn_code, platform, keyword, jobs);
} catch (error) {
console.error(`[工作管理] 保存职位到数据库失败:`, error);
// 不影响主流程,继续返回数据
}
const result = { await this.saveJobsToDatabase(sn_code, platform, keyword, jobs);
jobs: jobs,
keyword: keyword,
platform: platform, return {
jobs,
keyword,
platform,
count: jobs.length count: jobs.length
}; };
return result;
} }
/** /**
@@ -577,89 +518,93 @@ class JobManager {
console.log(`[工作管理] 开始保存 ${jobs.length} 个职位到数据库`); console.log(`[工作管理] 开始保存 ${jobs.length} 个职位到数据库`);
for (const job of jobs) { for (const job of jobs) {
try {
// 构建职位信息对象
const jobInfo = {
sn_code,
platform,
keyword,
// Boss直聘字段映射 // 构建职位信息对象
encryptBossId: job.encryptBossId || '', const jobInfo = {
jobId: job.encryptJobId || '', sn_code,
jobTitle: job.jobName || '', platform,
companyId: job.encryptBrandId || '', keyword,
companyName: job.brandName || '',
companySize: job.brandScaleName || '',
companyIndustry: job.brandIndustry || '',
salary: job.salaryDesc || '',
// 岗位要求(从 jobLabels 和 skills 提取) // Boss直聘字段映射
jobRequirements: JSON.stringify({ encryptBossId: job.encryptBossId || '',
experience: job.jobExperience || '', jobId: job.encryptJobId || '',
education: job.jobDegree || '', jobTitle: job.jobName || '',
labels: job.jobLabels || [], companyId: job.encryptBrandId || '',
skills: job.skills || [] companyName: job.brandName || '',
}), companySize: job.brandScaleName || '',
companyIndustry: job.brandIndustry || '',
// 工作地点 salary: job.salaryDesc || '',
location: [job.cityName, job.areaDistrict, job.businessDistrict]
.filter(Boolean).join(' '),
// 岗位要求(从 jobLabels 和 skills 提取)
jobRequirements: JSON.stringify({
experience: job.jobExperience || '', experience: job.jobExperience || '',
education: job.jobDegree || '', education: job.jobDegree || '',
labels: job.jobLabels || [],
skills: job.skills || []
}),
// 原始数据 // 工作地点
originalData: JSON.stringify(job), location: [job.cityName, job.areaDistrict, job.businessDistrict]
.filter(Boolean).join(' '),
// 默认状态 experience: job.jobExperience || '',
applyStatus: 'pending', education: job.jobDegree || '',
chatStatus: 'none'
};
// 调用位置服务解析 location + companyName 获取坐标 // 原始数据
if (jobInfo.location && jobInfo.companyName) { originalData: JSON.stringify(job),
const addressToParse = `${jobInfo.location.replaceAll(' ', '')}${jobInfo.companyName}`;
// 默认状态
applyStatus: 'pending',
chatStatus: 'none'
};
// 调用位置服务解析 location + companyName 获取坐标
if (jobInfo.location && jobInfo.companyName) {
const addressToParse = `${jobInfo.location.replaceAll(' ', '')}${jobInfo.companyName}`;
// 等待 1秒 // 等待 1秒
// await new Promise(resolve => setTimeout(resolve, 1000)); // await new Promise(resolve => setTimeout(resolve, 1000));
// const location = await locationService.getLocationByAddress(addressToParse).catch(error => {
// console.error(`[工作管理] 获取位置失败:`, error);
// });
// const location = await locationService.getLocationByAddress(addressToParse).catch(error => { // if (location) {
// console.error(`[工作管理] 获取位置失败:`, error); // jobInfo.latitude = String(location.lat);
// }); // jobInfo.longitude = String(location.lng);
// }
}
// if (location) { // 创建新职位 重复投递时间 从 pla_account 中获取pla_account 列为 platform_type不是 platform
// jobInfo.latitude = String(location.lat); const pla_account = db.getModel('pla_account');
// jobInfo.longitude = String(location.lng); const account = await pla_account.findOne({
// } where: {
sn_code: sn_code,
platform_type: platform
} }
});
// 检查是否已存在(根据 jobId 和 sn_code let repeatDeliverDays = 30;
const existingJob = await job_postings.findOne({ if (account) {
where: { let dc = account.deliver_config?.repeat_deliver_days||30;
jobId: jobInfo.jobId, repeatDeliverDays = Number(dc);
sn_code: sn_code }
const existingJob = await job_postings.findOne({
where: {
jobId: jobInfo.jobId,
sn_code: sn_code,
last_modify_time: {
[Op.gte]: new Date(Date.now() - repeatDeliverDays * 24 * 60 * 60 * 1000)
} }
});
if (existingJob) {
// 更新现有职位
await job_postings.update(jobInfo, {
where: {
jobId: jobInfo.jobId,
sn_code: sn_code
}
});
console.log(`[工作管理] 职位已更新 - ${jobInfo.jobTitle} @ ${jobInfo.companyName}`);
} else {
// 创建新职位
await job_postings.create(jobInfo);
console.log(`[工作管理] 职位已创建 - ${jobInfo.jobTitle} @ ${jobInfo.companyName}`);
} }
} catch (error) { });
console.error(`[工作管理] 保存职位失败:`, error, job);
// 继续处理下一个职位 if (existingJob) {
await job_postings.update(jobInfo, { where: { id: existingJob.id } });
} else {
await job_postings.create(jobInfo);
console.log(`[工作管理] 职位已创建 - ${jobInfo.jobTitle} @ ${jobInfo.companyName}`);
} }
} }
@@ -709,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,
@@ -743,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,
@@ -775,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 } }
); );
@@ -854,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 } }
); );

View File

@@ -32,15 +32,16 @@ class JobFilterService {
} }
/** /**
* 根据职位类型ID获取技能关键词排除关键词 * 根据职位类型ID获取技能关键词排除关键词、标题须含词
* @param {number} jobTypeId - 职位类型ID * @param {number} jobTypeId - 职位类型ID
* @returns {Promise<{commonSkills: Array, excludeKeywords: Array}>} * @returns {Promise<{commonSkills: Array, excludeKeywords: Array, titleIncludeKeywords: Array}>}
*/ */
async getJobTypeConfig(jobTypeId) { async getJobTypeConfig(jobTypeId) {
if (!jobTypeId) { if (!jobTypeId) {
return { return {
commonSkills: this.defaultCommonSkills, commonSkills: this.defaultCommonSkills,
excludeKeywords: this.defaultExcludeKeywords excludeKeywords: this.defaultExcludeKeywords,
titleIncludeKeywords: []
}; };
} }
@@ -55,7 +56,8 @@ class JobFilterService {
console.warn('[职位过滤服务] job_types 模型不存在,使用默认配置'); console.warn('[职位过滤服务] job_types 模型不存在,使用默认配置');
return { return {
commonSkills: this.defaultCommonSkills, commonSkills: this.defaultCommonSkills,
excludeKeywords: this.defaultExcludeKeywords excludeKeywords: this.defaultExcludeKeywords,
titleIncludeKeywords: []
}; };
} }
@@ -67,7 +69,8 @@ class JobFilterService {
console.warn(`[职位过滤服务] 职位类型 ${jobTypeId} 不存在或已禁用,使用默认配置`); console.warn(`[职位过滤服务] 职位类型 ${jobTypeId} 不存在或已禁用,使用默认配置`);
return { return {
commonSkills: this.defaultCommonSkills, commonSkills: this.defaultCommonSkills,
excludeKeywords: this.defaultExcludeKeywords excludeKeywords: this.defaultExcludeKeywords,
titleIncludeKeywords: []
}; };
} }
@@ -76,6 +79,7 @@ class JobFilterService {
// 解析 JSON 字段 // 解析 JSON 字段
let commonSkills = this.defaultCommonSkills; let commonSkills = this.defaultCommonSkills;
let excludeKeywords = this.defaultExcludeKeywords; let excludeKeywords = this.defaultExcludeKeywords;
let titleIncludeKeywords = [];
if (jobTypeData.commonSkills) { if (jobTypeData.commonSkills) {
try { try {
@@ -103,9 +107,23 @@ class JobFilterService {
} }
} }
if (jobTypeData.titleIncludeKeywords) {
try {
const parsed = typeof jobTypeData.titleIncludeKeywords === 'string'
? JSON.parse(jobTypeData.titleIncludeKeywords)
: jobTypeData.titleIncludeKeywords;
if (Array.isArray(parsed)) {
titleIncludeKeywords = parsed.map((k) => String(k || '').trim()).filter(Boolean);
}
} catch (e) {
console.warn(`[职位过滤服务] 解析 titleIncludeKeywords 失败:`, e);
}
}
const config = { const config = {
commonSkills, commonSkills,
excludeKeywords excludeKeywords,
titleIncludeKeywords
}; };
// 缓存配置缓存5分钟 // 缓存配置缓存5分钟
@@ -119,7 +137,8 @@ class JobFilterService {
console.error(`[职位过滤服务] 获取职位类型配置失败:`, error); console.error(`[职位过滤服务] 获取职位类型配置失败:`, error);
return { return {
commonSkills: this.defaultCommonSkills, commonSkills: this.defaultCommonSkills,
excludeKeywords: this.defaultExcludeKeywords excludeKeywords: this.defaultExcludeKeywords,
titleIncludeKeywords: []
}; };
} }
} }

View File

@@ -8,15 +8,45 @@ class MqttSyncClient {
constructor(brokerUrl, options = {}) { constructor(brokerUrl, options = {}) {
this.client = mqtt.connect(brokerUrl, options) this.client = mqtt.connect(brokerUrl, options)
this.isConnected = false this.isConnected = false
/** @type {string[]} 需在每次 connect含重连后向 Broker 幂等订阅的主题 */
this._maintainedTopics = []
/** 最近一次收到任意 `response` 主题消息的时间(用于超时日志关联) */
this.lastResponseAt = null
// 使用 Map 结构优化消息监听器,按 topic 分组 // 使用 Map 结构优化消息监听器,按 topic 分组
this.messageListeners = new Map(); // Map<topic, Set<listener>> this.messageListeners = new Map(); // Map<topic, Set<listener>>
this.globalListeners = new Set(); // 全局监听器(监听所有 topic this.globalListeners = new Set(); // 全局监听器(监听所有 topic
const ts = () => new Date().toISOString()
const markDisconnected = (reason) => {
this.isConnected = false
console.warn(`[MQTT] ${ts()} 连接不可用 reason=${reason}`)
}
this.client.on('connect', () => { this.client.on('connect', () => {
this.isConnected = true this.isConnected = true
console.log(`[MQTT] ${ts()} 服务端已连接(含重连后的 connect`)
this._resubscribeMaintainedTopics()
})
console.log('MQTT 服务端已连接') this.client.on('reconnect', () => {
console.log(`[MQTT] ${ts()} 正在重连 Broker...`)
})
this.client.on('offline', () => {
markDisconnected('offline')
})
this.client.on('disconnect', () => {
markDisconnected('disconnect')
})
this.client.on('close', () => {
markDisconnected('close')
})
this.client.on('end', () => {
markDisconnected('end')
}) })
this.client.on('message', (topic, message) => { this.client.on('message', (topic, message) => {
@@ -29,6 +59,9 @@ class MqttSyncClient {
return; return;
} }
if (topic === 'response') {
this.lastResponseAt = Date.now()
}
// 1. 触发该 topic 的专用监听器 // 1. 触发该 topic 的专用监听器
const topicListeners = this.messageListeners.get(topic); const topicListeners = this.messageListeners.get(topic);
@@ -56,18 +89,52 @@ class MqttSyncClient {
}) })
this.client.on('error', (err) => { this.client.on('error', (err) => {
console.warn('[MQTT] Error:', err.message) console.warn(`[MQTT] ${ts()} Error:`, err && err.message ? err.message : err)
}) })
} }
/**
* 与 mqtt.js 原生 connected 对齐,供单例健康检查
*/
isBrokerConnected() {
return !!(this.client && this.client.connected)
}
/**
* 注册需在每次 connect 后向 Broker 重新声明订阅的主题(不重复注册消息监听器)
* @param {string[]} topics
*/
setMaintainedTopics(topics) {
this._maintainedTopics = Array.isArray(topics) ? [...topics] : []
}
_resubscribeMaintainedTopics() {
if (!this._maintainedTopics.length) return
if (!this.client || !this.client.connected) return
const ts = new Date().toISOString()
for (const topic of this._maintainedTopics) {
this.client.subscribe(topic, { qos: 0 }, (err, granted) => {
if (err) {
console.warn(`[MQTT] ${ts} ensureSubscriptions 订阅失败 topic=${topic}`, err.message || err)
} else {
console.log(`[MQTT] ${ts} ensureSubscriptions 已订阅 topic=${topic}`, granted)
}
})
}
}
waitForConnect(timeout = 5000) { waitForConnect(timeout = 5000) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (this.isConnected) return resolve() if (this.isBrokerConnected()) {
this.isConnected = true
return resolve()
}
const timer = setTimeout(() => { const timer = setTimeout(() => {
reject(new Error('MQTT connect timeout')) reject(new Error('MQTT connect timeout'))
}, timeout) }, timeout)
const check = () => { const check = () => {
if (this.isConnected) { if (this.isBrokerConnected()) {
this.isConnected = true
clearTimeout(timer) clearTimeout(timer)
resolve() resolve()
} else { } else {
@@ -113,7 +180,6 @@ class MqttSyncClient {
resolve(granted) resolve(granted)
} }
} }
1
}) })
}) })
} }
@@ -143,7 +209,12 @@ class MqttSyncClient {
const timer = setTimeout(() => { const timer = setTimeout(() => {
this.removeMessageListener(onMessage); this.removeMessageListener(onMessage);
reject(new Error('Timeout waiting for response')); const last = this.lastResponseAt
const extra = last
? ` lastResponseAt=${new Date(last).toISOString()} brokerConnected=${this.isBrokerConnected()}`
: ` brokerConnected=${this.isBrokerConnected()}`
console.warn(`[MQTT] ${new Date().toISOString()} publishAndWait 超时 uuid=${uuid} topic=request_${sn_code}${extra}`)
reject(new Error('Timeout waiting for response' + (last ? `; lastResponseAt=${new Date(last).toISOString()}` : '')));
}, timeout); }, timeout);
const onMessage = (topic, message) => { const onMessage = (topic, message) => {
@@ -242,6 +313,7 @@ class MqttSyncClient {
} }
end(force = false) { end(force = false) {
this.isConnected = false
this.client.end(force) this.client.end(force)
} }
} }

View File

@@ -1,8 +1,30 @@
const MqttSyncClient = require('./mqttClient'); const MqttSyncClient = require('./mqttClient');
const Framework = require('../../../framework/node-core-framework'); const Framework = require('../../../framework/node-core-framework');
const logs = require('../logProxy'); const logs = require('../logProxy');
const appConfig = require('../../../config/config.js');
// action.js 已合并到 mqttDispatcher.js不再需要单独引入 // action.js 已合并到 mqttDispatcher.js不再需要单独引入
function buildMqttManagerConfig() {
const mqttCfg = appConfig.mqtt || {};
const brokerUrl = (mqttCfg.brokerUrl && String(mqttCfg.brokerUrl).trim())
? mqttCfg.brokerUrl.trim()
: `mqtt://${mqttCfg.host || '192.144.167.231'}:${mqttCfg.port != null ? mqttCfg.port : 1883}`;
const options = {
clientId: mqttCfg.clientId || `mqtt_server_${Math.random().toString(16).substr(2, 8)}`,
clean: mqttCfg.clean !== false,
connectTimeout: mqttCfg.connectTimeout != null ? mqttCfg.connectTimeout : 5000,
reconnectPeriod: mqttCfg.reconnectPeriod != null ? mqttCfg.reconnectPeriod : 5000,
keepalive: mqttCfg.keepalive != null ? mqttCfg.keepalive : 60
};
if (mqttCfg.username) {
options.username = mqttCfg.username;
}
if (mqttCfg.password) {
options.password = mqttCfg.password;
}
return { brokerUrl, options };
}
/** /**
* MQTT管理器 - 单例模式 * MQTT管理器 - 单例模式
* 负责管理MQTT连接确保全局只有一个MQTT客户端实例 * 负责管理MQTT连接确保全局只有一个MQTT客户端实例
@@ -11,16 +33,7 @@ class MqttManager {
constructor() { constructor() {
this.client = null; this.client = null;
this.isInitialized = false; this.isInitialized = false;
this.config = { this.config = buildMqttManagerConfig();
brokerUrl: 'mqtt://192.144.167.231:1883', // MQTT Broker地址
options: {
clientId: `mqtt_server_${Math.random().toString(16).substr(2, 8)}`,
clean: true,
connectTimeout: 5000,
reconnectPeriod: 5000, // 自动重连间隔
keepalive: 10
}
};
} }
/** /**
@@ -30,8 +43,16 @@ class MqttManager {
*/ */
async getInstance(config = {}) { async getInstance(config = {}) {
if (this.client && this.isInitialized) { if (this.client && this.isInitialized) {
console.log('[MQTT管理器] 返回已存在的MQTT客户端实例'); const brokerOk = typeof this.client.isBrokerConnected === 'function'
return this.client; ? this.client.isBrokerConnected()
: this.client.isConnected;
if (!brokerOk) {
console.warn('[MQTT管理器] 单例已初始化但 Broker 未连接,重置并重建');
await this.reset();
} else {
console.log('[MQTT管理器] 返回已存在的MQTT客户端实例');
return this.client;
}
} }
// 合并配置 // 合并配置
@@ -91,7 +112,13 @@ class MqttManager {
* @returns {boolean} * @returns {boolean}
*/ */
isReady() { isReady() {
return this.isInitialized && this.client && this.client.isConnected; if (!this.isInitialized || !this.client) {
return false;
}
if (typeof this.client.isBrokerConnected === 'function') {
return this.client.isBrokerConnected();
}
return !!this.client.isConnected;
} }
/** /**

View File

@@ -26,7 +26,8 @@ class ScheduledJobs {
auto_search: false, auto_search: false,
auto_deliver: false, auto_deliver: false,
auto_chat: false, auto_chat: false,
auto_active: false auto_active: false,
job_type_listings_ai: false
}; };
} }
@@ -111,11 +112,23 @@ class ScheduledJobs {
this.jobs.push(autoActiveJob); this.jobs.push(autoActiveJob);
console.log('[定时任务] ✓ 已启动自动活跃任务 (每2小时)'); console.log('[定时任务] ✓ 已启动自动活跃任务 (每2小时)');
// 5. 每日拉取 get_job_listings 并用 AI 更新 job_typesdescription / excludeKeywords / commonSkills / titleIncludeKeywords
const jobTypeListingsAiJob = node_schedule.scheduleJob(config.schedules.jobTypeListingsAi || '0 0 4 * * *', () => {
this.runDailyJobTypeListingsAiSync().catch((err) => {
console.error('[定时任务] 每日 job_types AI 同步失败:', err);
});
});
this.jobs.push(jobTypeListingsAiJob);
console.log('[定时任务] ✓ 已启动每日 job_types AI 同步 (每天 04:00)');
// 立即执行一次业务任务(可选) // 立即执行一次业务任务(可选)
setTimeout(() => { setTimeout(() => {
console.log('[定时任务] 立即执行一次初始化任务...'); console.log('[定时任务] 立即执行一次初始化任务...');
this.runAutoDeliverTask(); this.runAutoDeliverTask();
this.runAutoChatTask(); this.runAutoChatTask();
this.runDailyJobTypeListingsAiSync().catch((err) => {
console.error('[定时任务] 启动时 job_types AI 同步失败:', err);
});
}, 10000); // 延迟10秒,等待系统初始化完成和设备心跳 }, 10000); // 延迟10秒,等待系统初始化完成和设备心跳
console.log('[定时任务] 所有定时任务启动完成!'); console.log('[定时任务] 所有定时任务启动完成!');
@@ -295,6 +308,80 @@ class ScheduledJobs {
} }
} }
/**
* 每日一次:对已绑定 job_type_id 且设备在线的账号下发 get_job_listings成功后在 jobManager 内触发 AI 更新 job_types
*/
async runDailyJobTypeListingsAiSync() {
const key = 'job_type_listings_ai';
if (this._runningFlags[key]) {
console.log('[job_type_listings_ai] 上一次执行尚未完成,本次跳过');
return;
}
this._runningFlags[key] = true;
try {
const Sequelize = require('sequelize');
const { Op } = Sequelize;
const scheduleManager = require('../index');
const jobApi = require('../../job/index');
const mqtt = scheduleManager.mqttClient;
if (!mqtt) {
console.warn('[job_type_listings_ai] MQTT 未初始化,跳过');
return;
}
const { pla_account } = db.models;
const accounts = await pla_account.findAll({
where: {
is_delete: 0,
is_enabled: 1,
job_type_id: { [Op.ne]: null }
},
attributes: ['id', 'sn_code', 'job_type_id', 'platform_type']
});
if (!accounts || accounts.length === 0) {
return;
}
const now = Date.now();
const offlineThreshold = 3 * 60 * 1000;
let ok = 0;
let skipped = 0;
for (const acc of accounts) {
const sn_code = acc.sn_code;
const device = deviceManager.devices.get(sn_code);
const lastHb = device && device.lastHeartbeat ? device.lastHeartbeat : 0;
const isOnline = device && device.isOnline && now - lastHb < offlineThreshold;
if (!isOnline) {
skipped++;
continue;
}
const platform =
acc.platform_type ||
(typeof acc.getDataValue === 'function' && acc.getDataValue('platform_type')) ||
'boss';
try {
await jobApi.get_job_listings(sn_code, mqtt, { platform });
ok++;
} catch (err) {
console.warn(`[job_type_listings_ai] 设备 ${sn_code} 失败:`, err.message);
skipped++;
}
}
if (ok > 0 || skipped > 0) {
console.log(`[job_type_listings_ai] 完成: 成功 ${ok},跳过/失败 ${skipped},共 ${accounts.length} 个账号`);
}
} catch (error) {
console.error('[job_type_listings_ai] 执行失败:', error);
} finally {
this._runningFlags[key] = false;
}
}
/** /**
* 获取启用指定功能的账号列表 * 获取启用指定功能的账号列表
* @param {string} featureType - 功能类型: auto_search, auto_deliver, auto_chat, auto_active * @param {string} featureType - 功能类型: auto_search, auto_deliver, auto_chat, auto_active

View File

@@ -30,7 +30,7 @@ class DeliverHandler extends BaseHandler {
*/ */
async doDeliver(task) { async doDeliver(task) {
const { sn_code, taskParams } = task; const { sn_code, taskParams } = task;
const { keyword, platform = 'boss', pageCount = 3, maxCount = 10, filterRules = {} } = taskParams; const { keyword, platform = 'boss', pageCount = 3, maxCount = 10 } = taskParams;
console.log(`[自动投递] 开始 - 设备: ${sn_code}, 关键词: ${keyword}`); console.log(`[自动投递] 开始 - 设备: ${sn_code}, 关键词: ${keyword}`);
@@ -89,9 +89,9 @@ class DeliverHandler extends BaseHandler {
mqttClient: this.mqttClient mqttClient: this.mqttClient
}); });
// 6. 下发 get_job_list 拉取职位列表tabLabel 切换期望 tabjob_type_id 随指令下发供设备使用 // 6. 下发 get_job_list与前端一致command 只带 pageCount + tabLabel设备端不接收 keyword/job_type_id
const tabLabel = resume.deliver_tab_label || ''; const tabLabel = resume.deliver_tab_label || '';
await this.getJobList(sn_code, platform, pageCount, task.id, tabLabel, accountConfig.job_type_id); await this.getJobList(sn_code, platform, pageCount, task.id, tabLabel);
// 7. 从数据库获取待投递职位 // 7. 从数据库获取待投递职位
const pendingJobs = await this.getPendingJobs(sn_code, platform, actualMaxCount * 3); const pendingJobs = await this.getPendingJobs(sn_code, platform, actualMaxCount * 3);
@@ -103,14 +103,15 @@ class DeliverHandler extends BaseHandler {
}; };
} }
// 8. 合并过滤配置 // 8. 过滤配置仅来自职位类型 job_types排除词 / 标题须含词等),不与账号投递配置、任务参数混用
const filterConfig = this.mergeFilterConfig(deliverConfig, filterRules, jobTypeConfig); const filterConfig = this.mergeFilterConfig(jobTypeConfig);
// 9. 过滤已投递的公司 // 9. 过滤已投递的公司repeat_deliver_days 由投递配置给出,缺省 30上限 365
const recentCompanies = await this.getRecentDeliveredCompanies(sn_code, 30); 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便于阅读 // 10. 过滤 + 评分 + 按 60 分阈值筛(入口在 jobFilterEngine便于阅读
const filteredJobs = await jobFilterEngine.filterAndScoreJobsForDeliver( const { scored: filteredJobs, skipReasonByJobId } = await jobFilterEngine.filterAndScoreJobsForDeliver(
pendingJobs, pendingJobs,
filterConfig, filterConfig,
resume, resume,
@@ -119,6 +120,9 @@ class DeliverHandler extends BaseHandler {
recentCompanies recentCompanies
); );
// 本轮未进入「可投递」列表的待投递记录,标记为已过滤,避免长期停留在 pending
await this.markFilteredJobsNotPassed(pendingJobs, filteredJobs, sn_code, platform, skipReasonByJobId);
const jobsToDeliver = filteredJobs.slice(0, actualMaxCount); const jobsToDeliver = filteredJobs.slice(0, actualMaxCount);
console.log(`[自动投递] 职位筛选完成 - 原始: ${pendingJobs.length}, 符合条件: ${filteredJobs.length}, 将投递: ${jobsToDeliver.length}`); console.log(`[自动投递] 职位筛选完成 - 原始: ${pendingJobs.length}, 符合条件: ${filteredJobs.length}, 将投递: ${jobsToDeliver.length}`);
@@ -138,6 +142,12 @@ class DeliverHandler extends BaseHandler {
return { return {
deliveredCount: deliverCommands.length, deliveredCount: deliverCommands.length,
filterSummary: {
rawCount: pendingJobs.length,
passedCount: filteredJobs.length,
deliverCount: jobsToDeliver.length,
filteredCount: Math.max(0, pendingJobs.length - filteredJobs.length)
},
...result ...result
}; };
} }
@@ -273,22 +283,16 @@ class DeliverHandler extends BaseHandler {
} }
/** /**
* 下发 get_job_list 命令拉取职位列表 * 下发 get_job_list 命令拉取职位列表command_params 与前端约定pageCount、tabLabel + sn_code、platform
* @param {string} tabLabel - 投递用期望标签文案,对应 resume_info.deliver_tab_labelget_job_list 会按此选择 tab
* @param {number} jobTypeId - 职位类型 ID随指令下发供设备使用
*/ */
async getJobList(sn_code, platform, pageCount, taskId, tabLabel = '', jobTypeId = null) { async getJobList(sn_code, platform, pageCount, taskId, tabLabel = '') {
const label = tabLabel != null && String(tabLabel).trim() !== '' ? String(tabLabel).trim() : '';
const params = { const params = {
sn_code, sn_code,
platform, platform,
pageCount pageCount,
...(label ? { tabLabel: label } : {})
}; };
if (tabLabel != null && String(tabLabel).trim() !== '') {
params.tabLabel = String(tabLabel).trim();
}
if (jobTypeId != null && jobTypeId !== '') {
params.job_type_id = jobTypeId;
}
const getJobListCommand = { const getJobListCommand = {
command_type: 'get_job_list', command_type: 'get_job_list',
command_name: '获取职位列表', command_name: '获取职位列表',
@@ -299,6 +303,54 @@ class DeliverHandler extends BaseHandler {
await command.executeCommands(taskId, [getJobListCommand], this.mqttClient); 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, skipReasonByJobId = {}) {
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 default_reason = '未通过自动投递筛选';
try {
await Promise.all(
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: {
id,
sn_code,
platform,
applyStatus: 'pending'
}
}
);
})
);
console.log(`[自动投递] 不符合条件已标记 filtered: ${notPassedIds.length} 条(含原因)`);
} catch (e) {
console.warn('[自动投递] 标记 filtered 失败:', e.message);
}
}
/** /**
* 获取待投递职位 * 获取待投递职位
*/ */
@@ -309,7 +361,11 @@ class DeliverHandler extends BaseHandler {
where: { where: {
sn_code, sn_code,
platform, platform,
applyStatus: 'pending' applyStatus: 'pending',
create_time: {
[db.models.op.gte]: new Date(Date.now() - 1000 * 60 * 60 * 24)
}
}, },
order: [['create_time', 'DESC']], order: [['create_time', 'DESC']],
limit limit
@@ -319,39 +375,50 @@ class DeliverHandler extends BaseHandler {
} }
/** /**
* 合并过滤配置 * 自动投递过滤配置:仅使用 job_typesexcludeKeywords、titleIncludeKeywords
* 薪资筛选不在此合并min/max 为 0 表示不做薪资过滤);评分权重仍走 accountConfig.is_salary_priority
*/ */
mergeFilterConfig(deliverConfig, filterRules, jobTypeConfig) { mergeFilterConfig(jobTypeConfig) {
// 排除关键词 const base = {
const rawJobTypeExclude = jobTypeConfig?.excludeKeywords exclude_keywords: [],
? ConfigManager.parseConfig(jobTypeConfig.excludeKeywords, []) filter_keywords: [],
: []; title_include_keywords: [],
min_salary: 0,
const jobTypeExclude = Array.isArray(rawJobTypeExclude) ? rawJobTypeExclude : []; max_salary: 0,
priority_weights: []
const deliverExcludeRaw = ConfigManager.getExcludeKeywords(deliverConfig);
const deliverExclude = Array.isArray(deliverExcludeRaw) ? deliverExcludeRaw : [];
const filterExcludeRaw = filterRules.excludeKeywords || [];
const filterExclude = Array.isArray(filterExcludeRaw) ? filterExcludeRaw : [];
// 过滤关键词
const deliverFilterRaw = ConfigManager.getFilterKeywords(deliverConfig);
const deliverFilter = Array.isArray(deliverFilterRaw) ? deliverFilterRaw : [];
const filterKeywordsRaw = filterRules.keywords || [];
const filterKeywords = Array.isArray(filterKeywordsRaw) ? filterKeywordsRaw : [];
// 薪资范围
const salaryRange = filterRules.minSalary || filterRules.maxSalary
? { min: filterRules.minSalary || 0, max: filterRules.maxSalary || 0 }
: ConfigManager.getSalaryRange(deliverConfig);
return {
exclude_keywords: [...jobTypeExclude, ...deliverExclude, ...filterExclude],
filter_keywords: filterKeywords.length > 0 ? filterKeywords : deliverFilter,
min_salary: salaryRange.min,
max_salary: salaryRange.max,
priority_weights: ConfigManager.getPriorityWeights(deliverConfig)
}; };
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;
} }
/** /**

View File

@@ -79,11 +79,10 @@ class SearchHandler extends BaseHandler {
const commandParams = { const commandParams = {
sn_code, sn_code,
keyword: keyword || accountConfig.keyword || '',
platform: platformType, platform: platformType,
pageCount: pageCount || searchConfig.page_count || 3 pageCount: pageCount || searchConfig.page_count || 3,
...(tabLabel ? { tabLabel } : {})
}; };
if (tabLabel) commandParams.tabLabel = tabLabel;
const searchCommand = { const searchCommand = {
command_type: 'get_job_list', command_type: 'get_job_list',

View File

@@ -141,6 +141,11 @@ class ScheduleManager {
console.error('[调度管理器] 处理 Boss 消息失败:', error); console.error('[调度管理器] 处理 Boss 消息失败:', error);
} }
}); });
// 重连后向 Broker 幂等声明订阅(监听器仅在上方注册一次,不重复添加)
if (typeof this.mqttClient.setMaintainedTopics === 'function') {
this.mqttClient.setMaintainedTopics(['heartbeat', 'response', 'boss/message']);
}
} }
@@ -152,7 +157,11 @@ class ScheduleManager {
const status = this.mqttDispatcher ? this.mqttDispatcher.getSystemStatus() : {}; const status = this.mqttDispatcher ? this.mqttDispatcher.getSystemStatus() : {};
return { return {
isInitialized: this.isInitialized, isInitialized: this.isInitialized,
mqttConnected: this.mqttClient && this.mqttClient.isConnected, mqttConnected: this.mqttClient && (
typeof this.mqttClient.isBrokerConnected === 'function'
? this.mqttClient.isBrokerConnected()
: this.mqttClient.isConnected
),
systemStats: deviceManager.getSystemStats(), systemStats: deviceManager.getSystemStats(),
allDevices: deviceManager.getAllDevicesStatus(), allDevices: deviceManager.getAllDevicesStatus(),
taskQueues: TaskQueue.getAllDeviceStatus(), taskQueues: TaskQueue.getAllDeviceStatus(),

View File

@@ -51,7 +51,8 @@ class ScheduleConfig {
autoSearch: '0 0 */1 * * *', // 自动搜索任务每1小时执行一次 autoSearch: '0 0 */1 * * *', // 自动搜索任务每1小时执行一次
autoDeliver: '0 */2 * * * *', // 自动投递任务每2分钟执行一次 autoDeliver: '0 */2 * * * *', // 自动投递任务每2分钟执行一次
autoChat: '0 */1 * * * *', // 自动沟通任务每1分钟执行一次 autoChat: '0 */1 * * * *', // 自动沟通任务每1分钟执行一次
autoActive: '0 0 */2 * * *' // 自动活跃任务每2小时执行一次 autoActive: '0 0 */2 * * *', // 自动活跃任务每2小时执行一次
jobTypeListingsAi: '0 0 4 * * *' // 每天 04:00 对有 job_type_id 的在线设备拉取 get_job_listings 并 AI 更新 job_types
}; };
} }

View File

@@ -43,6 +43,7 @@ class ConfigManager {
max_salary: 0, // 最高薪资 max_salary: 0, // 最高薪资
page_count: 3, // 搜索页数 page_count: 3, // 搜索页数
max_deliver: 10, // 最大投递数 max_deliver: 10, // 最大投递数
repeat_deliver_days: 30, // 多少天内已投递过的公司不再投递(与 getRecentDeliveredCompanies 一致)
filter_keywords: [], // 过滤关键词 filter_keywords: [], // 过滤关键词
exclude_keywords: [], // 排除关键词 exclude_keywords: [], // 排除关键词
time_range: null, // 时间范围 time_range: null, // 时间范围

View File

@@ -5,11 +5,24 @@ const db = require('../../dbProxy');
/** /**
* 职位过滤引擎schedule 自动投递用) * 职位过滤引擎schedule 自动投递用)
* 本文件集中:过滤(薪资/关键词/活跃度/去重)+ 评分(权重分+关键词奖励)+ 按分数阈值筛。 * 本文件集中:过滤(薪资/关键词/活跃度/去重)+ 评分(权重分+关键词奖励)+ 按分数阈值筛。
* 自动投递只需调 filterAndScoreJobsForDeliver 一个方法 * 自动投递调 filterAndScoreJobsForDeliver,返回 { scored, skipReasonByJobId }
*/ */
class JobFilterEngine { class JobFilterEngine {
getJobKey(job) {
return String(job.id || job.jobId || `${job.companyName || ''}|${job.jobTitle || ''}`);
}
getRemovedTitles(beforeJobs, afterJobs, limit = 5) {
const keptKeySet = new Set(afterJobs.map((job) => this.getJobKey(job)));
return beforeJobs
.filter((job) => !keptKeySet.has(this.getJobKey(job)))
.map((job) => job.jobTitle || '')
.filter(Boolean)
.slice(0, limit);
}
/** /**
* 过滤职位列表(薪资 → 关键词 → 活跃度 → 去重) * 过滤职位列表(薪资 → 标题须含词 → 关键词 → 活跃度 → 去重)
* @param {Array} jobs - 职位列表 * @param {Array} jobs - 职位列表
* @param {object} config - 过滤配置 * @param {object} config - 过滤配置
* @param {object} resumeInfo - 简历信息(未使用,兼容签名) * @param {object} resumeInfo - 简历信息(未使用,兼容签名)
@@ -23,63 +36,137 @@ class JobFilterEngine {
let filtered = [...jobs]; let filtered = [...jobs];
// 1. 薪资过滤 // 1. 薪资过滤
const beforeSalary = filtered.length; const beforeSalaryJobs = [...filtered];
filtered = this.filterBySalary(filtered, config); filtered = this.filterBySalary(filtered, config);
const salaryRemoved = beforeSalary - filtered.length; const salaryRemoved = beforeSalaryJobs.length - filtered.length;
if (salaryRemoved > 0) { if (salaryRemoved > 0) {
console.log(`[jobFilterEngine] 步骤1-薪资过滤: 输入${beforeSalary} 输出${filtered.length} 剔除${salaryRemoved} (范围: ${config.min_salary ?? 0}-${config.max_salary ?? 0}K)`); const removedTitles = this.getRemovedTitles(beforeSalaryJobs, filtered);
console.log(`[jobFilterEngine] 步骤1-薪资过滤: 范围=${config.min_salary ?? 0}-${config.max_salary ?? 0}K 剔除标题=${removedTitles.join(' | ') || '无'}`);
} }
// 2. 关键词过滤 // 2. 职位标题须包含job_types.titleIncludeKeywords仅 jobTitle与 commonSkills 无关)
const beforeKeywords = filtered.length; const beforeTitleFilterJobs = [...filtered];
filtered = this.filterByTitleIncludeKeywords(filtered, config);
const titleKwRemoved = beforeTitleFilterJobs.length - filtered.length;
if (titleKwRemoved > 0) {
const removedTitles = this.getRemovedTitles(beforeTitleFilterJobs, filtered);
console.log(`[jobFilterEngine] 步骤2-标题须含: 关键词=[${(config.title_include_keywords || []).join('、') || '无'}] 剔除标题=${removedTitles.join(' | ') || '无'}`);
}
// 3. 关键词过滤(排除词 + filter_keywords匹配标题与行业等
const beforeKeywordFilterJobs = [...filtered];
filtered = this.filterByKeywords(filtered, config); filtered = this.filterByKeywords(filtered, config);
const keywordsRemoved = beforeKeywords - filtered.length; const keywordsRemoved = beforeKeywordFilterJobs.length - filtered.length;
if (keywordsRemoved > 0) { if (keywordsRemoved > 0) {
console.log(`[jobFilterEngine] 步骤2-关键词过滤: 输入${beforeKeywords} 输出${filtered.length} 剔除${keywordsRemoved} (排除: ${(config.exclude_keywords || []).join(',') || '无'} 包含: ${(config.filter_keywords || []).join(',') || '无'})`); const removedTitles = this.getRemovedTitles(beforeKeywordFilterJobs, filtered);
console.log(`[jobFilterEngine] 步骤3-关键词过滤: 排除=[${(config.exclude_keywords || []).join('、') || '无'}] 包含=[${(config.filter_keywords || []).join('、') || '无'}] 剔除标题=${removedTitles.join(' | ') || '无'}`);
} }
// 3. 公司活跃度过滤 // 4. 公司活跃度过滤
if (config.filter_inactive_companies) { if (config.filter_inactive_companies) {
const beforeActivity = filtered.length; const beforeActivityJobs = [...filtered];
filtered = await this.filterByCompanyActivity(filtered, config.company_active_days || 7); filtered = await this.filterByCompanyActivity(filtered, config.company_active_days || 7);
const activityRemoved = beforeActivity - filtered.length; const activityRemoved = beforeActivityJobs.length - filtered.length;
if (activityRemoved > 0) { if (activityRemoved > 0) {
console.log(`[jobFilterEngine] 步骤3-公司活跃度过滤: 输入${beforeActivity} 输出${filtered.length} 剔除${activityRemoved}`); const removedTitles = this.getRemovedTitles(beforeActivityJobs, filtered);
console.log(`[jobFilterEngine] 步骤4-公司活跃度过滤: 剔除标题=${removedTitles.join(' | ') || '无'}`);
} }
} }
// 4. 去重(同一公司、同一职位名称) // 5. 去重(同一公司、同一职位名称)
if (config.deduplicate) { if (config.deduplicate) {
const beforeDedup = filtered.length; const beforeDedupJobs = [...filtered];
filtered = this.deduplicateJobs(filtered); filtered = this.deduplicateJobs(filtered);
const dedupRemoved = beforeDedup - filtered.length; const dedupRemoved = beforeDedupJobs.length - filtered.length;
if (dedupRemoved > 0) { if (dedupRemoved > 0) {
console.log(`[jobFilterEngine] 步骤4-去重: 输入${beforeDedup} 输出${filtered.length} 剔除${dedupRemoved}`); const removedTitles = this.getRemovedTitles(beforeDedupJobs, filtered);
console.log(`[jobFilterEngine] 步骤5-去重: 剔除标题=${removedTitles.join(' | ') || '无'}`);
} }
} }
console.log(`[jobFilterEngine] filterJobs 结束: 原始${jobs.length} 通过${filtered.length} 总剔除${jobs.length - filtered.length}`); const keptTitles = filtered.map((j) => j.jobTitle || '').filter(Boolean).slice(0, 5);
console.log(`[jobFilterEngine] filterJobs 结束: 通过标题=${keptTitles.join(' | ') || '无'}`);
return filtered; return filtered;
} }
/**
* 单条职位:判断 filterJobs 中哪一步未通过(用于写入 job_postings.deliver_failed_reason
*/
async get_single_job_filter_fail_reason(job, config) {
const j = job;
const title_text = String(j.jobTitle || '').trim() || '未知职位';
const after_salary = this.filterBySalary([j], config);
if (after_salary.length === 0) {
const salary_text = String(j.salary || j.salaryDesc || '-');
return `职位「${title_text}」薪资不在设定范围(${config.min_salary ?? 0}-${config.max_salary ?? 0}K),当前薪资: ${salary_text}`;
}
const after_title = this.filterByTitleIncludeKeywords(after_salary, config);
if (after_title.length === 0) {
const kws = (config.title_include_keywords || []).join('、') || '无';
return `职位「${title_text}」标题未命中必含关键词:${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 `职位「${title_text}」命中排除关键词: ${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 `职位「${title_text}」未命中包含关键词: ${needed.join('、')}`;
}
}
return `职位「${title_text}」命中排除关键词或未满足包含词规则`;
}
if (config.filter_inactive_companies) {
const after_act = await this.filterByCompanyActivity(after_kw, config.company_active_days || 7);
if (after_act.length === 0) {
return `职位「${title_text}」公司活跃度不满足配置`;
}
}
if (config.deduplicate) {
const after_dedup = this.deduplicateJobs(after_kw);
if (after_dedup.length === 0) {
return `职位「${title_text}」与列表内职位重复(公司+岗位名)`;
}
}
return `职位「${title_text}」未通过职位过滤`;
}
/** /**
* 自动投递用:过滤 + 评分 + 按 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;
} }
@@ -99,6 +186,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;
} }
@@ -112,7 +201,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 };
} }
/** /**
@@ -134,6 +223,29 @@ class JobFilterEngine {
}); });
} }
/**
* 职位标题须包含配置中的关键词(命中任意一个即通过),不扫描描述/公司名/commonSkills
* @param {Array} jobs
* @param {object} config
* @returns {Array}
*/
filterByTitleIncludeKeywords(jobs, config) {
const kws = config.title_include_keywords;
if (!Array.isArray(kws) || kws.length === 0) {
return jobs;
}
return jobs.filter((job) => {
const title = `${job.jobTitle || ''}`.toLowerCase();
return kws.some((kw) => {
const k = String(kw || '').toLowerCase().trim();
if (!k) {
return false;
}
return title.includes(k);
});
});
}
/** /**
* 按关键词过滤 * 按关键词过滤
* @param {Array} jobs - 职位列表 * @param {Array} jobs - 职位列表
@@ -220,7 +332,7 @@ class JobFilterEngine {
for (const job of jobs) { for (const job of jobs) {
const company = (job.company || job.companyName || '').toLowerCase().trim(); const company = (job.company || job.companyName || '').toLowerCase().trim();
const jobName = (job.name || job.jobName || '').toLowerCase().trim(); const jobName = (job.jobTitle || '').toLowerCase().trim();
const key = `${company}||${jobName}`; const key = `${company}||${jobName}`;
if (!seen.has(key)) { if (!seen.has(key)) {
@@ -303,8 +415,8 @@ class JobFilterEngine {
} }
const jobText = [ const jobText = [
job.name || job.jobName || '', job.jobTitle || '',
job.description || job.jobDescription || '', job.jobDescription || '',
job.skills || '' job.skills || ''
].join(' '); ].join(' ');

View File

@@ -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 = [];

View File

@@ -190,7 +190,7 @@ module.exports = (db) => {
}, },
// 投递状态 // 投递状态
applyStatus: { applyStatus: {
comment: '投递状态: pending-待投递, applied-已投递, rejected-被拒绝, accepted-已接受', comment: '投递状态: pending-待投递, filtered-已过滤(不符合规则未投递), applied-已投递, rejected-被拒绝, accepted-已接受, success/failed-见业务',
type: Sequelize.STRING(20), type: Sequelize.STRING(20),
allowNull: true, allowNull: true,
defaultValue: 'pending' defaultValue: 'pending'
@@ -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),

View File

@@ -7,7 +7,7 @@ const Sequelize = require('sequelize');
module.exports = (db) => { module.exports = (db) => {
const job_types = db.define("job_types", { const job_types = db.define("job_types", {
name: { name: {
comment: '职位类型名称(如:前端开发、后端开发、全栈开发等', comment: '职位类型名称:须与 Boss 页 get_job_listings 返回的 Tab 文案 text 完全一致(投递标签',
type: Sequelize.STRING(100), type: Sequelize.STRING(100),
allowNull: false, allowNull: false,
defaultValue: '' defaultValue: ''
@@ -19,7 +19,7 @@ module.exports = (db) => {
defaultValue: '' defaultValue: ''
}, },
commonSkills: { commonSkills: {
comment: '常见技能关键词JSON数组', comment: '常见技能关键词JSON数组,仅用于简历技能匹配/评分;职位标题过滤请用 titleIncludeKeywords',
type: Sequelize.TEXT, type: Sequelize.TEXT,
allowNull: true, allowNull: true,
defaultValue: '[]' defaultValue: '[]'
@@ -30,6 +30,12 @@ module.exports = (db) => {
allowNull: true, allowNull: true,
defaultValue: '[]' defaultValue: '[]'
}, },
titleIncludeKeywords: {
comment: '职位标题须包含的子串JSON数组仅按岗位标题匹配commonSkills 仅用于简历技能匹配,不参与标题过滤',
type: Sequelize.TEXT,
allowNull: true,
defaultValue: '[]'
},
is_enabled: { is_enabled: {
comment: '是否启用1=启用0=禁用)', comment: '是否启用1=启用0=禁用)',
type: Sequelize.TINYINT(1), type: Sequelize.TINYINT(1),
@@ -41,6 +47,12 @@ module.exports = (db) => {
type: Sequelize.INTEGER, type: Sequelize.INTEGER,
allowNull: false, allowNull: false,
defaultValue: 0 defaultValue: 0
},
pla_account_id: {
comment: '关联账户IDpla_account.id可选AI 根据 get_job_listings 更新本行时写入)',
type: Sequelize.INTEGER,
allowNull: true,
defaultValue: null
} }
}, { }, {
timestamps: false, timestamps: false,
@@ -52,9 +64,15 @@ module.exports = (db) => {
{ {
unique: false, unique: false,
fields: ['name'] fields: ['name']
},
{
unique: false,
fields: ['pla_account_id']
} }
] ]
}); });
// job_types.sync({ force: true });
return job_types; return job_types;
} }

View File

@@ -21,12 +21,6 @@ module.exports = (db) => {
allowNull: true, allowNull: true,
defaultValue: '' defaultValue: ''
}, },
platform_type: {
comment: '平台',
type: Sequelize.STRING(50),
allowNull: false,
defaultValue: ''
},
login_name: { login_name: {
comment: '登录名', comment: '登录名',
type: Sequelize.STRING(50), type: Sequelize.STRING(50),
@@ -40,7 +34,7 @@ module.exports = (db) => {
defaultValue: '' defaultValue: ''
}, },
keyword: { keyword: {
comment: '关键词', comment: '搜索/推荐职位关键词;保存投递期望标签时会与 resume_info.deliver_tab_label 同步',
type: Sequelize.STRING(50), type: Sequelize.STRING(50),
allowNull: false, allowNull: false,
defaultValue: '' defaultValue: ''

View File

@@ -0,0 +1,418 @@
/**
* get_job_listings 后:按投递 Tab 确保 job_types 存在(无则创建);
* 「推荐」标签结合在线简历由 AI 生成描述与关键词后再落库;
* 再根据 resume.deliver_tab_label 同步 pla_account.job_type_id
* 最后对当前投递对应的 job_types 做一轮 AI 更新。
*/
const db = require('../middleware/dbProxy');
const aiService = require('./ai_service');
/**
* @param {string} text
* @returns {boolean}
*/
function isRecommendTab(text) {
const t = String(text || '').trim();
return t === '推荐' || /^推荐[·•\s]*/.test(t) || t.startsWith('推荐');
}
/**
* 从 Tab 文案推断「职位标题须包含」子串(如「售前工程师」→「售前」);推荐类返回 [],交给 AI。
* 与 commonSkills 无关,仅供标题子串过滤。
*/
function deriveTitleIncludeKeywordsFromTabName(text) {
const s = String(text || '').trim();
if (!s || isRecommendTab(s)) return [];
return normalizeTitleIncludeKeywords([], s);
}
/**
* 将标题文本细分为更短子串,提升匹配通过率。
* 例:项目经理/主管(上海) -> ["项目","经理","主管"]
* @param {string} text
* @returns {string[]}
*/
function splitTitleTextToKeywords(text) {
const raw = String(text || '')
.replace(/[(].*?[)]/g, ' ')
.replace(/[\/|、,,·•\-\s]+/g, ' ')
.trim();
if (!raw) return [];
const fragments = raw.split(/\s+/).filter(Boolean);
const suffixes = [
'工程师', '经理', '主管', '专员', '顾问', '总监', '助理',
'开发', '测试', '运维', '产品', '前端', '后端', '算法', '架构'
];
const stopWords = new Set(['岗位', '职位', '方向']);
const tokens = [];
for (const f of fragments) {
if (f.length <= 1 || stopWords.has(f)) continue;
let matched = false;
for (const sf of suffixes) {
if (f.endsWith(sf) && f.length > sf.length) {
const left = f.slice(0, f.length - sf.length).trim();
if (left && left.length >= 2 && !stopWords.has(left)) tokens.push(left);
if (!stopWords.has(sf)) tokens.push(sf);
matched = true;
break;
}
}
if (!matched) tokens.push(f);
}
return [...new Set(tokens)].slice(0, 8);
}
/**
* 统一清洗 titleIncludeKeywords优先用 AI 结果,缺失时从 tabName 推导并细分。
* @param {Array<string>} aiKeywords
* @param {string} tabName
* @returns {string[]}
*/
function normalizeTitleIncludeKeywords(aiKeywords, tabName) {
const aiList = Array.isArray(aiKeywords) ? aiKeywords : [];
const fromAi = aiList.map((s) => String(s || '').trim()).filter(Boolean);
const fromTab = splitTitleTextToKeywords(tabName);
const merged = [...fromAi, ...fromTab].filter(Boolean);
return [...new Set(merged)].slice(0, 8);
}
/**
* @param {object} resume - resume_info 实例或 plain
* @returns {string}
*/
function buildResumeSnippet(resume) {
if (!resume) return '';
const r = typeof resume.toJSON === 'function' ? resume.toJSON() : resume;
const parts = [];
if (r.expectedPosition) parts.push(`期望职位: ${r.expectedPosition}`);
if (r.expectedIndustry) parts.push(`期望行业: ${r.expectedIndustry}`);
if (r.currentPosition) parts.push(`当前职位: ${r.currentPosition}`);
if (r.currentCompany) parts.push(`当前公司: ${r.currentCompany}`);
if (r.workYears) parts.push(`工作年限: ${r.workYears}`);
if (r.education) parts.push(`学历: ${r.education}`);
if (r.major) parts.push(`专业: ${r.major}`);
let skills = r.skills;
if (typeof skills === 'string' && skills) {
try {
const arr = JSON.parse(skills);
if (Array.isArray(arr)) parts.push(`技能标签: ${arr.join('、')}`);
} catch (e) {
parts.push(`技能: ${skills}`);
}
}
if (r.skillDescription) parts.push(`技能描述: ${String(r.skillDescription).slice(0, 500)}`);
if (r.resumeContent) parts.push(`简历摘录:\n${String(r.resumeContent).slice(0, 2800)}`);
return parts.join('\n') || '(暂无简历正文,仅根据「推荐」标签生成通用配置)';
}
function parseJsonFromAi(text) {
if (!text || typeof text !== 'string') return null;
let s = text.trim();
const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/i);
if (fence) s = fence[1].trim();
try {
return JSON.parse(s);
} catch (e) {
const m = s.match(/\{[\s\S]*\}/);
if (m) {
try {
return JSON.parse(m[0]);
} catch (e2) {
return null;
}
}
}
return null;
}
/**
* 「推荐」类 Tab无记录时根据在线简历调用 AI 创建 job_types。
* name 必须与网页 get_job_listings 返回的 text 完全一致(含「推荐」等字样)。
*/
async function createRecommendJobTypeIfNeeded(job_types, pla_account, resume, platform, sortOrder, tabText) {
const tabName = String(tabText != null ? tabText : '').trim().slice(0, 100);
if (!tabName) {
return null;
}
const existing = await job_types.findOne({
where: { name: tabName, pla_account_id: pla_account.id }
});
if (existing) {
return existing;
}
const snippet = buildResumeSnippet(resume);
const prompt = `用户在 Boss 直聘使用投递标签「${tabName}」(名称须与页面 Tab 一致)。请根据以下在线简历/期望信息,生成该投递方向的说明与关键词(用于自动投递过滤与匹配)。
${snippet}
请只输出一段 JSON不要 Markdown格式
{"description":"80~200字中文","excludeKeywords":["5~12个排除词"],"commonSkills":["8~20个技能关键词"],"titleIncludeKeywords":["1~4个简短子串"]}
excludeKeywords不适合投递的岗位特征词如与简历方向不符的销售、客服等按简历推断
commonSkills与简历主线一致的技能与技术栈仅用于简历技能匹配加分不得替代标题关键词。
titleIncludeKeywords职位名称标题关键词尽量细分例如「项目经理/主管(上海)」应输出 ["项目","经理","主管"]。勿把技能栈写进此数组。`;
let description = '';
let excludeArr = [];
let skillsArr = [];
let titleIncArr = [];
try {
const { content } = await aiService.callAPI(prompt, {
systemPrompt: '你只输出合法 JSON键为 description、excludeKeywords、commonSkills、titleIncludeKeywords。',
temperature: 0.35,
maxTokens: 2000,
sn_code: pla_account.sn_code,
service_type: 'job_type_sync',
business_type: 'job_type_recommend_create'
});
const parsed = parseJsonFromAi(content);
if (parsed && typeof parsed === 'object') {
description = String(parsed.description || '').slice(0, 4000);
excludeArr = Array.isArray(parsed.excludeKeywords) ? parsed.excludeKeywords.map(String) : [];
skillsArr = Array.isArray(parsed.commonSkills) ? parsed.commonSkills.map(String) : [];
titleIncArr = normalizeTitleIncludeKeywords(parsed.titleIncludeKeywords, tabName);
}
} catch (e) {
console.warn('[job_type_ai_sync] 推荐标签 AI 创建失败,使用占位:', e.message);
}
if (!description) {
description = `Boss 页面标签「${tabName}」流职位,与在线简历期望综合匹配。`;
}
if (excludeArr.length === 0) {
excludeArr = ['普工', '纯客服'];
}
if (skillsArr.length === 0) {
skillsArr = ['沟通', '协作'];
}
titleIncArr = normalizeTitleIncludeKeywords(titleIncArr, tabName);
const row = await job_types.create({
name: tabName,
description,
excludeKeywords: JSON.stringify(excludeArr),
commonSkills: JSON.stringify(skillsArr),
titleIncludeKeywords: JSON.stringify(titleIncArr),
is_enabled: 1,
sort_order: sortOrder,
pla_account_id: pla_account.id
});
console.log(`[job_type_ai_sync] 已创建 job_types id=${row.id} name="${tabName}"(与网页标签一致) account=${pla_account.id}`);
return row;
}
/**
* 普通 Tab无则插入占位 job_types
*/
async function ensureSimpleTabJobType(job_types, pla_account, tabText, sortOrder) {
const name = String(tabText).trim().slice(0, 100);
if (!name) return null;
let row = await job_types.findOne({
where: { name, pla_account_id: pla_account.id }
});
if (row) return row;
const titleKws = deriveTitleIncludeKeywordsFromTabName(name);
row = await job_types.create({
name,
description: '',
excludeKeywords: '[]',
commonSkills: '[]',
titleIncludeKeywords: JSON.stringify(titleKws),
is_enabled: 1,
sort_order: sortOrder,
pla_account_id: pla_account.id
});
console.log(`[job_type_ai_sync] 已创建 Tab job_types id=${row.id} name=${name}`);
return row;
}
/**
* 遍历 get_job_listings 的 Tab按账户确保每条标签在 job_types 中有对应行
* @returns {Promise<{ account: object, resume: object|null, tabKeyToRow: Map<string, object> }|null>}
*/
async function ensureJobTypesForTabs(sn_code, tabs, platform = 'boss') {
const pla_account = db.getModel('pla_account');
const resume_info = db.getModel('resume_info');
const job_types = db.getModel('job_types');
const account = await pla_account.findOne({
where: { sn_code, is_delete: 0 }
});
if (!account) return null;
const resume = await resume_info.findOne({
where: { sn_code, platform, isActive: true },
order: [['last_modify_time', 'DESC']]
});
const tabTexts = (tabs || [])
.map((t) => (t && t.text != null ? String(t.text).trim() : ''))
.filter(Boolean);
const tabKeyToRow = new Map();
for (let i = 0; i < tabTexts.length; i++) {
const raw = tabTexts[i];
const sortOrder = i;
if (isRecommendTab(raw)) {
const row = await createRecommendJobTypeIfNeeded(
job_types,
account,
resume,
platform,
sortOrder,
raw
);
if (row) tabKeyToRow.set(raw, row);
continue;
}
const row = await ensureSimpleTabJobType(job_types, account, raw, sortOrder);
if (row) tabKeyToRow.set(raw, row);
}
const label = resume && resume.deliver_tab_label ? String(resume.deliver_tab_label).trim() : '';
if (label) {
let targetRow = tabKeyToRow.get(label);
if (!targetRow && isRecommendTab(label)) {
for (const [k, v] of tabKeyToRow.entries()) {
if (isRecommendTab(k)) {
targetRow = v;
break;
}
}
}
if (!targetRow) {
targetRow = await job_types.findOne({
where: { name: label.slice(0, 100), pla_account_id: account.id }
});
}
if (targetRow && targetRow.id !== account.job_type_id) {
await pla_account.update({ job_type_id: targetRow.id }, { where: { id: account.id } });
account.job_type_id = targetRow.id;
console.log(`[job_type_ai_sync] 已同步 pla_account.job_type_id=${targetRow.id} ← deliver_tab_label「${label}`);
}
}
return { account, resume, tabKeyToRow };
}
/**
* 成功拉取 job_listings 后:确保标签行存在 → 同步 job_type_id → AI 更新当前投递类型
*/
async function maybeSyncAfterListings(sn_code, tabs, platform = 'boss') {
if (!sn_code) return null;
const ensured = await ensureJobTypesForTabs(sn_code, tabs, platform);
if (!ensured) return null;
const { account, resume, tabKeyToRow } = ensured;
const job_types = db.getModel('job_types');
const tabTexts = (tabs || [])
.map((t) => (t && t.text != null ? String(t.text).trim() : ''))
.filter(Boolean);
if (tabTexts.length === 0) {
console.warn('[job_type_ai_sync] Tab 列表为空,跳过后续 AI 更新');
return null;
}
let jobType = null;
const label = resume && resume.deliver_tab_label ? String(resume.deliver_tab_label).trim() : '';
if (label) {
jobType = tabKeyToRow.get(label);
if (!jobType && isRecommendTab(label)) {
for (const [k, v] of tabKeyToRow.entries()) {
if (isRecommendTab(k)) {
jobType = v;
break;
}
}
}
}
if (!jobType && account.job_type_id) {
jobType = await job_types.findByPk(account.job_type_id);
}
if (!jobType && tabTexts.length > 0) {
jobType = tabKeyToRow.get(tabTexts[0]);
}
if (!jobType) {
console.warn('[job_type_ai_sync] 无法解析当前投递 job_types跳过 AI 更新');
return null;
}
const labelsStr = tabTexts.join('、');
const typeName = jobType.name || '';
const prompt = `你是招聘平台求职助手。用户在某招聘网站的「期望职类/Tab」列表如下按顺序
${labelsStr}
当前重点维护的职位类型名称为:「${typeName}
平台标识:${platform}
请根据上述 Tab 名称,补充该求职方向的说明、自动投递时应排除的岗位关键词、常见技能关键词、以及职位标题须包含的子串。
请只输出一段 JSON不要 Markdown 代码块,不要其它说明),格式严格如下:
{"description":"50~200字中文概括该求职方向","excludeKeywords":["关键词1","关键词2"],"commonSkills":["技能1","技能2"],"titleIncludeKeywords":["子串1","子串2"]}
要求:
- description面向求职者的简短说明。
- excludeKeywords5~12 个字符串,用于过滤明显不合适的岗位。
- commonSkills8~20 个字符串,该方向常见技能或技术栈关键词,仅用于简历技能匹配加分,不得替代标题关键词。
- titleIncludeKeywords优先给细分词例如「项目经理/主管(上海)」写成 ["项目","经理","主管"];勿把编程技能写进此数组。`;
const { content } = await aiService.callAPI(prompt, {
systemPrompt: '你只输出合法 JSON 对象,键为 description、excludeKeywords、commonSkills、titleIncludeKeywords不要输出其它文字。',
temperature: 0.35,
maxTokens: 2000,
sn_code,
service_type: 'job_type_sync',
business_type: 'job_type_listings'
});
const parsed = parseJsonFromAi(content);
if (!parsed || typeof parsed !== 'object') {
throw new Error('AI 返回无法解析为 JSON');
}
const description = String(parsed.description || '').slice(0, 4000);
const excludeArr = Array.isArray(parsed.excludeKeywords) ? parsed.excludeKeywords.map(String) : [];
const skillsArr = Array.isArray(parsed.commonSkills) ? parsed.commonSkills.map(String) : [];
let titleIncArr = normalizeTitleIncludeKeywords(parsed.titleIncludeKeywords, typeName);
await job_types.update(
{
description,
excludeKeywords: JSON.stringify(excludeArr),
commonSkills: JSON.stringify(skillsArr),
titleIncludeKeywords: JSON.stringify(titleIncArr),
pla_account_id: account.id
},
{ where: { id: jobType.id } }
);
console.log(
`[job_type_ai_sync] 已更新 job_types id=${jobType.id} name=${typeName} pla_account_id=${account.id} exclude=${excludeArr.length} skills=${skillsArr.length} titleKw=${titleIncArr.length}`
);
return { updated: true, jobTypeId: jobType.id };
}
module.exports = {
maybeSyncAfterListings,
ensureJobTypesForTabs,
parseJsonFromAi,
isRecommendTab,
deriveTitleIncludeKeywordsFromTabName,
normalizeTitleIncludeKeywords
};

View File

@@ -481,13 +481,13 @@ class PlaAccountService {
? { ...baseParams, ...commandParams } ? { ...baseParams, ...commandParams }
: baseParams; : baseParams;
// 如果有关键词相关的操作,添加关键词 if (commandTypeSnake === 'search_jobs' && account.keyword) {
if (['search_jobs', 'get_job_list'].includes(commandTypeSnake) && account.keyword) {
finalParams.keyword = account.keyword; finalParams.keyword = account.keyword;
} }
// get_job_list resume_infodeliver_tab_label 作为 tabLabel 参数 // get_job_list:优先使用调用方显式传入的 tabLabel仅在未传时回退到 resume_info.deliver_tab_label
if (commandTypeSnake === 'get_job_list') { if (commandTypeSnake === 'get_job_list') {
const passedTabLabel = finalParams.tabLabel != null ? String(finalParams.tabLabel).trim() : '';
try { try {
const resume_info = db.getModel('resume_info'); const resume_info = db.getModel('resume_info');
const resume = await resume_info.findOne({ const resume = await resume_info.findOne({
@@ -495,7 +495,7 @@ class PlaAccountService {
order: [['last_modify_time', 'DESC']], order: [['last_modify_time', 'DESC']],
attributes: ['deliver_tab_label'] attributes: ['deliver_tab_label']
}); });
if (resume && resume.deliver_tab_label) { if (!passedTabLabel && resume && resume.deliver_tab_label) {
finalParams.tabLabel = String(resume.deliver_tab_label).trim(); finalParams.tabLabel = String(resume.deliver_tab_label).trim();
} }
} catch (e) { } catch (e) {
@@ -640,6 +640,35 @@ class PlaAccountService {
command_params: JSON.stringify(commandParams) command_params: JSON.stringify(commandParams)
}; };
// 防止 result 超长导致 "Data too long for column 'result'"
const serialize_retry_result_for_db = (value) => {
const max_length = 60000;
try {
let text = JSON.stringify(value || {});
if (text.length <= max_length) {
return text;
}
const summary = {
success: !!(value && value.success),
message: (value && value.message) ? String(value.message) : '重试结果过长,已摘要存储',
truncated: true,
raw_length: text.length,
preview: text.substring(0, 1000)
};
text = JSON.stringify(summary);
if (text.length > max_length) {
return text.substring(0, max_length - 12) + '...[已截断]';
}
return text;
} catch (error) {
return JSON.stringify({
success: false,
message: '结果序列化失败',
error: error.message || 'unknown'
});
}
};
// 执行指令并同步更新当前指令记录状态 // 执行指令并同步更新当前指令记录状态
const start_time = new Date(); const start_time = new Date();
try { try {
@@ -651,7 +680,7 @@ class PlaAccountService {
start_time: start_time, start_time: start_time,
end_time: end_time, end_time: end_time,
duration: end_time.getTime() - start_time.getTime(), duration: end_time.getTime() - start_time.getTime(),
result: JSON.stringify(result || {}) result: serialize_retry_result_for_db(result)
}); });
return { return {

View File

@@ -71,13 +71,20 @@ module.exports = {
"model": "qwen-plus" "model": "qwen-plus"
}, },
// MQTT配置 // MQTT配置Broker 地址、保活与重连与 Broker 策略对齐时可改此处或环境变量)
mqtt: { mqtt: {
host: process.env.MQTT_HOST || 'localhost', /** 完整连接串,优先于 host+port */
port: process.env.MQTT_PORT || 1883, brokerUrl: process.env.MQTT_BROKER_URL || '',
host: process.env.MQTT_HOST || '192.144.167.231',
port: Number(process.env.MQTT_PORT || 1883),
username: process.env.MQTT_USERNAME || '', username: process.env.MQTT_USERNAME || '',
password: process.env.MQTT_PASSWORD || '', password: process.env.MQTT_PASSWORD || '',
clientId: 'autowork-' + Math.random().toString(16).substr(2, 8) clientId: process.env.MQTT_CLIENT_ID || `mqtt_server_${Math.random().toString(16).substr(2, 8)}`,
clean: true,
connectTimeout: Number(process.env.MQTT_CONNECT_TIMEOUT || 5000),
reconnectPeriod: Number(process.env.MQTT_RECONNECT_PERIOD || 5000),
/** 秒;过小易被 Broker 策略影响,过大对断线感知慢 */
keepalive: Number(process.env.MQTT_KEEPALIVE || 60)
}, },
// 定时任务配置 // 定时任务配置

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long