Compare commits
11 Commits
820e437729
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a45418883c | ||
|
|
df0aacc782 | ||
|
|
7ef0c68ad1 | ||
|
|
37daa2f99f | ||
|
|
51bbdacdda | ||
|
|
f2a8e61016 | ||
|
|
048c40d802 | ||
|
|
bfd39eddcf | ||
|
|
21fe005c19 | ||
|
|
e3d14dd637 | ||
|
|
ca8bbcd9cd |
8
_sql/add_job_postings_deliver_fields.sql
Normal file
8
_sql/add_job_postings_deliver_fields.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- job_postings:是否已投递成功、未投递/投递失败原因(直接存表,不连表查询)
|
||||
-- 若字段已存在可忽略报错
|
||||
|
||||
ALTER TABLE job_postings
|
||||
ADD COLUMN is_delivered TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已投递成功:1是 0否' AFTER applyTime;
|
||||
|
||||
ALTER TABLE job_postings
|
||||
ADD COLUMN deliver_failed_reason TEXT NULL COMMENT '未投递或投递失败原因' AFTER is_delivered;
|
||||
58
_sql/add_query_performance_indexes.sql
Normal file
58
_sql/add_query_performance_indexes.sql
Normal file
@@ -0,0 +1,58 @@
|
||||
-- 查询性能索引补充(按前端高频接口整理)
|
||||
-- 适用场景:
|
||||
-- 1) /api/job_postings/page_list, /api/job_postings/deliver_status_map
|
||||
-- 2) /api/apply/list, /api/apply/statistics
|
||||
-- 3) /api/task/command/page_list
|
||||
-- 4) /api/user/account-config/get 中 resume_info 最新记录查询
|
||||
|
||||
-- =========================
|
||||
-- job_postings
|
||||
-- =========================
|
||||
ALTER TABLE `job_postings`
|
||||
ADD INDEX `idx_job_postings_sn_platform_modify` (`sn_code`, `platform`, `last_modify_time`);
|
||||
|
||||
ALTER TABLE `job_postings`
|
||||
ADD INDEX `idx_job_postings_sn_create` (`sn_code`, `create_time`);
|
||||
|
||||
-- =========================
|
||||
-- apply_records
|
||||
-- =========================
|
||||
ALTER TABLE `apply_records`
|
||||
ADD INDEX `idx_apply_records_sn_create` (`sn_code`, `create_time`);
|
||||
|
||||
ALTER TABLE `apply_records`
|
||||
ADD INDEX `idx_apply_records_sn_platform_create` (`sn_code`, `platform`, `create_time`);
|
||||
|
||||
ALTER TABLE `apply_records`
|
||||
ADD INDEX `idx_apply_records_sn_apply_status_create` (`sn_code`, `applyStatus`, `create_time`);
|
||||
|
||||
ALTER TABLE `apply_records`
|
||||
ADD INDEX `idx_apply_records_sn_feedback_status_create` (`sn_code`, `feedbackStatus`, `create_time`);
|
||||
|
||||
-- =========================
|
||||
-- task_status
|
||||
-- =========================
|
||||
ALTER TABLE `task_status`
|
||||
ADD INDEX `idx_task_status_sn_id` (`sn_code`, `id`);
|
||||
|
||||
ALTER TABLE `task_status`
|
||||
ADD INDEX `idx_task_status_sn_status_id` (`sn_code`, `status`, `id`);
|
||||
|
||||
-- =========================
|
||||
-- task_commands
|
||||
-- =========================
|
||||
ALTER TABLE `task_commands`
|
||||
ADD INDEX `idx_task_commands_task_id_id` (`task_id`, `id`);
|
||||
|
||||
ALTER TABLE `task_commands`
|
||||
ADD INDEX `idx_task_commands_task_id_status_id` (`task_id`, `status`, `id`);
|
||||
|
||||
-- =========================
|
||||
-- resume_info / pla_account
|
||||
-- =========================
|
||||
ALTER TABLE `resume_info`
|
||||
ADD INDEX `idx_resume_info_sn_platform_active_modify` (`sn_code`, `platform`, `isActive`, `last_modify_time`);
|
||||
|
||||
ALTER TABLE `pla_account`
|
||||
ADD INDEX `idx_pla_account_sn_code` (`sn_code`);
|
||||
|
||||
@@ -229,6 +229,12 @@
|
||||
</div>
|
||||
</Col>
|
||||
<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">
|
||||
<span class="label">过滤关键词:</span>
|
||||
<span class="value">{{ deliverConfig.filter_keywords || '-' }}</span>
|
||||
@@ -560,6 +566,7 @@ export default {
|
||||
max_salary: 0,
|
||||
page_count: 3,
|
||||
max_deliver: 10,
|
||||
repeat_deliver_days: 30,
|
||||
filter_keywords: '',
|
||||
exclude_keywords: ''
|
||||
},
|
||||
@@ -908,6 +915,7 @@ export default {
|
||||
max_salary: deliverConfig.max_salary || 0,
|
||||
page_count: deliverConfig.page_count || 3,
|
||||
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)
|
||||
? deliverConfig.filter_keywords.join(',')
|
||||
: (deliverConfig.filter_keywords || ''),
|
||||
@@ -926,6 +934,7 @@ export default {
|
||||
max_salary: 0,
|
||||
page_count: 3,
|
||||
max_deliver: 10,
|
||||
repeat_deliver_days: 30,
|
||||
filter_keywords: '',
|
||||
exclude_keywords: '',
|
||||
deliver_start_time: '09:00',
|
||||
|
||||
@@ -148,6 +148,9 @@
|
||||
<FormItem label="每次最多投递数">
|
||||
<InputNumber v-model="formData.max_deliver" :min="1" placeholder="默认10个" style="width: 100%;" />
|
||||
</FormItem>
|
||||
<FormItem label="同公司重复投递间隔(天)">
|
||||
<InputNumber v-model="formData.repeat_deliver_days" :min="1" :max="365" placeholder="默认30天,N天内投过的公司跳过" style="width: 100%;" />
|
||||
</FormItem>
|
||||
<FormItem label="过滤关键词">
|
||||
<Input
|
||||
v-model="formData.filter_keywords"
|
||||
@@ -268,6 +271,7 @@ export default {
|
||||
max_salary: 0,
|
||||
page_count: 3,
|
||||
max_deliver: 10,
|
||||
repeat_deliver_days: 30,
|
||||
filter_keywords: '',
|
||||
exclude_keywords: '',
|
||||
deliver_start_time: '09:00',
|
||||
@@ -386,6 +390,7 @@ export default {
|
||||
this.formData.max_salary = deliverConfig.max_salary || 0
|
||||
this.formData.page_count = deliverConfig.page_count || 3
|
||||
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)
|
||||
? deliverConfig.filter_keywords.join('\n')
|
||||
: (deliverConfig.filter_keywords || '')
|
||||
@@ -447,6 +452,7 @@ export default {
|
||||
max_salary: 0,
|
||||
page_count: 3,
|
||||
max_deliver: 10,
|
||||
repeat_deliver_days: 30,
|
||||
filter_keywords: '',
|
||||
exclude_keywords: '',
|
||||
auto_chat: 0,
|
||||
@@ -600,6 +606,9 @@ export default {
|
||||
if (saveData.max_deliver !== undefined) {
|
||||
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) {
|
||||
deliverConfig.filter_keywords = this.parseKeywords(saveData.filter_keywords)
|
||||
@@ -623,6 +632,7 @@ export default {
|
||||
delete saveData.max_salary
|
||||
delete saveData.page_count
|
||||
delete saveData.max_deliver
|
||||
delete saveData.repeat_deliver_days
|
||||
delete saveData.filter_keywords
|
||||
delete saveData.exclude_keywords
|
||||
delete saveData.deliver_start_time
|
||||
|
||||
@@ -323,7 +323,7 @@ export default {
|
||||
sort_order: row.sort_order || 0
|
||||
}
|
||||
this.$refs.editModal.editShow(editRow,(newRow)=>{
|
||||
debugger
|
||||
|
||||
this.handleSaveSuccess(newRow)
|
||||
|
||||
})
|
||||
|
||||
@@ -28,13 +28,14 @@
|
||||
<tables :columns="listColumns" :value="gridOption.data" :pageOption="gridOption.param.pageOption"
|
||||
@changePage="query"></tables>
|
||||
</div>
|
||||
<editModal ref="editModal" :columns="editColumns" :rules="gridOption.rules" @on-save="handleSaveSuccess">
|
||||
<editModal ref="editModal" :columns="editFormColumns" :rules="gridOption.rules" @on-save="handleSaveSuccess">
|
||||
</editModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import jobTypesServer from '@/api/work/job_types_server.js'
|
||||
import plaAccountServer from '@/api/profile/pla_account_server.js'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
@@ -44,8 +45,10 @@ export default {
|
||||
return {
|
||||
seachTypes: [
|
||||
{ key: 'name', value: '职位类型名称' },
|
||||
{ key: 'description', value: '描述' }
|
||||
{ key: 'description', value: '描述' },
|
||||
{ key: 'pla_account_id', value: '关联账户ID' }
|
||||
],
|
||||
plaAccountOptions: [],
|
||||
gridOption: {
|
||||
param: {
|
||||
seachOption: {
|
||||
@@ -63,6 +66,23 @@ export default {
|
||||
},
|
||||
listColumns: [
|
||||
{ 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: 'description', minWidth: 200 },
|
||||
{
|
||||
@@ -77,6 +97,11 @@ export default {
|
||||
{
|
||||
title: '常见技能关键词', key: 'commonSkills', minWidth: 200,
|
||||
},
|
||||
{
|
||||
title: '标题须含关键词',
|
||||
key: 'titleIncludeKeywords',
|
||||
minWidth: 200
|
||||
},
|
||||
{
|
||||
title: '排除关键词', key: 'excludeKeywords', minWidth: 200
|
||||
},
|
||||
@@ -147,6 +172,22 @@ export default {
|
||||
placeholder: '请输入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: '是否启用',
|
||||
key: 'is_enabled',
|
||||
@@ -171,12 +212,35 @@ export default {
|
||||
seachTypePlaceholder() {
|
||||
const item = this.seachTypes.find(item => item.key === this.gridOption.param.seachOption.key)
|
||||
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() {
|
||||
this.loadPlaAccountOptions()
|
||||
this.query(1)
|
||||
},
|
||||
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) {
|
||||
if (page) {
|
||||
this.gridOption.param.pageOption.page = page
|
||||
@@ -217,6 +281,8 @@ export default {
|
||||
description: '',
|
||||
commonSkills: '[]',
|
||||
excludeKeywords: '[]',
|
||||
titleIncludeKeywords: '[]',
|
||||
pla_account_id: '',
|
||||
is_enabled: 1,
|
||||
sort_order: 0
|
||||
})
|
||||
@@ -249,12 +315,26 @@ export default {
|
||||
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({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description || '',
|
||||
commonSkills: commonSkills,
|
||||
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,
|
||||
sort_order: row.sort_order || 0
|
||||
})
|
||||
@@ -281,7 +361,7 @@ export default {
|
||||
// 处理 JSON 字段
|
||||
const formData = { ...data }
|
||||
|
||||
// 处理 commonSkills
|
||||
// 处理 commonSkills(JSON 数组)
|
||||
if (formData.commonSkills) {
|
||||
try {
|
||||
const parsed = typeof formData.commonSkills === 'string'
|
||||
@@ -294,7 +374,7 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 excludeKeywords
|
||||
// 处理 excludeKeywords(JSON 数组)
|
||||
if (formData.excludeKeywords) {
|
||||
try {
|
||||
const parsed = typeof formData.excludeKeywords === 'string'
|
||||
@@ -307,6 +387,28 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 titleIncludeKeywords(JSON 数组,与上两项一致)
|
||||
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
|
||||
apiMethod(formData).then(res => {
|
||||
if (res.code === 0) {
|
||||
|
||||
@@ -5,6 +5,57 @@
|
||||
|
||||
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 = {
|
||||
/**
|
||||
* @swagger
|
||||
@@ -37,14 +88,26 @@ module.exports = {
|
||||
const models = Framework.getModels();
|
||||
const { job_types, op } = models;
|
||||
const body = ctx.getBody();
|
||||
const { name } = body;
|
||||
const seachOption = body.seachOption || {};
|
||||
|
||||
// 获取分页参数
|
||||
const { limit, offset } = ctx.getPageSize();
|
||||
|
||||
const where = {};
|
||||
if (name) {
|
||||
where.name = { [op.like]: `%${name}%` };
|
||||
const key = seachOption.key || body.key;
|
||||
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({
|
||||
@@ -54,7 +117,8 @@ module.exports = {
|
||||
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.success(jobType);
|
||||
const [enriched] = await attachPlaAccountToJobTypeRows([jobType], models);
|
||||
return ctx.success(enriched);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -120,6 +185,9 @@ module.exports = {
|
||||
* excludeKeywords:
|
||||
* type: array
|
||||
* description: 排除关键词(JSON数组)
|
||||
* titleIncludeKeywords:
|
||||
* type: array
|
||||
* description: 职位标题须同时包含的子串(JSON数组),仅匹配岗位标题
|
||||
* is_enabled:
|
||||
* type: integer
|
||||
* description: 是否启用(1=启用,0=禁用)
|
||||
@@ -132,9 +200,9 @@ module.exports = {
|
||||
*/
|
||||
'POST /job_type/create': async (ctx) => {
|
||||
const models = Framework.getModels();
|
||||
const { job_types } = models;
|
||||
const { job_types, pla_account } = models;
|
||||
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) {
|
||||
return ctx.fail('职位类型名称不能为空');
|
||||
@@ -146,16 +214,30 @@ module.exports = {
|
||||
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({
|
||||
name,
|
||||
description: description || '',
|
||||
commonSkills: Array.isArray(commonSkills) ? JSON.stringify(commonSkills) : (commonSkills || '[]'),
|
||||
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,
|
||||
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:
|
||||
* type: array
|
||||
* description: 排除关键词(JSON数组)
|
||||
* titleIncludeKeywords:
|
||||
* type: array
|
||||
* description: 职位标题须同时包含的子串(JSON数组)
|
||||
* is_enabled:
|
||||
* type: integer
|
||||
* description: 是否启用(1=启用,0=禁用)
|
||||
@@ -201,9 +286,9 @@ module.exports = {
|
||||
*/
|
||||
'POST /job_type/update': async (ctx) => {
|
||||
const models = Framework.getModels();
|
||||
const { job_types } = models;
|
||||
const { job_types, pla_account } = models;
|
||||
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) {
|
||||
return ctx.fail('职位类型ID不能为空');
|
||||
@@ -231,16 +316,32 @@ module.exports = {
|
||||
if (excludeKeywords !== undefined) {
|
||||
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 (sort_order !== undefined) updateData.sort_order = sort_order;
|
||||
|
||||
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);
|
||||
|
||||
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 } });
|
||||
|
||||
// 清除缓存
|
||||
const jobFilterService = require('../middleware/job/job_filter_service.js');
|
||||
const jobFilterService = require('../middleware/job/services/jobFilterService.js');
|
||||
jobFilterService.clearCache(id);
|
||||
|
||||
return ctx.success({ message: '职位类型删除成功' });
|
||||
|
||||
178
api/controller_front/job_postings.js
Normal file
178
api/controller_front/job_postings.js
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* 岗位表(job_postings)客户端接口:仅查本表字段,不连表
|
||||
*/
|
||||
|
||||
const Framework = require('../../framework/node-core-framework.js');
|
||||
|
||||
/**
|
||||
* 从 resume_info.job_listings 解析 Boss Tab 文案列表(与 get_job_listings 一致)
|
||||
* @param {unknown} job_listings
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function parse_resume_tab_labels(job_listings) {
|
||||
if (job_listings == null) return [];
|
||||
let arr = job_listings;
|
||||
if (typeof arr === 'string') {
|
||||
try {
|
||||
arr = JSON.parse(arr);
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(arr)) return [];
|
||||
const out = [];
|
||||
arr.forEach((item) => {
|
||||
if (item == null) return;
|
||||
if (typeof item === 'string') {
|
||||
const s = item.trim();
|
||||
if (s) out.push(s);
|
||||
return;
|
||||
}
|
||||
if (typeof item === 'object' && item.text != null) {
|
||||
const s = String(item.text).trim();
|
||||
if (s) out.push(s);
|
||||
}
|
||||
});
|
||||
return [...new Set(out)];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
/**
|
||||
* 按设备 SN 拉取 jobId -> 投递结果字段(is_delivered、deliver_failed_reason、applyStatus)
|
||||
* 用于 Electron 职位列表页合并展示,不查 apply_records
|
||||
*/
|
||||
'POST /job_postings/deliver_status_map': async (ctx) => {
|
||||
const models = Framework.getModels();
|
||||
const { job_postings, op } = models;
|
||||
const body = ctx.getBody() || {};
|
||||
const sn_code = body.sn_code || ctx.query?.sn_code;
|
||||
const platform = body.platform || 'boss';
|
||||
const startTime = body.startTime;
|
||||
const endTime = body.endTime;
|
||||
|
||||
if (!sn_code) {
|
||||
return ctx.fail('请提供设备SN码');
|
||||
}
|
||||
|
||||
const where = { sn_code, platform };
|
||||
if (startTime || endTime) {
|
||||
where.last_modify_time = {};
|
||||
if (startTime) {
|
||||
where.last_modify_time[op.gte] = new Date(startTime);
|
||||
}
|
||||
if (endTime) {
|
||||
const end = new Date(endTime);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
where.last_modify_time[op.lte] = end;
|
||||
}
|
||||
}
|
||||
|
||||
const rows = await job_postings.findAll({
|
||||
where,
|
||||
attributes: ['jobId', 'is_delivered', 'deliver_failed_reason', 'applyStatus', 'last_modify_time'],
|
||||
order: [['last_modify_time', 'DESC']],
|
||||
limit: Math.min(Number(body.limit) || 2000, 5000)
|
||||
});
|
||||
|
||||
const list = rows.map((r) => {
|
||||
const j = r.toJSON ? r.toJSON() : r;
|
||||
return {
|
||||
jobId: j.jobId,
|
||||
is_delivered: !!j.is_delivered,
|
||||
deliver_failed_reason: j.deliver_failed_reason || '',
|
||||
applyStatus: j.applyStatus || 'pending',
|
||||
last_modify_time: j.last_modify_time
|
||||
};
|
||||
});
|
||||
|
||||
return ctx.success({ list });
|
||||
},
|
||||
|
||||
/**
|
||||
* 按设备 SN 分页查询 job_postings(仅本表字段,用于 Electron 职位列表只读展示)
|
||||
*/
|
||||
'POST /job_postings/page_list': async (ctx) => {
|
||||
const models = Framework.getModels();
|
||||
const { job_postings, resume_info, op } = models;
|
||||
const body = ctx.getBody() || {};
|
||||
const sn_code = body.sn_code || ctx.query?.sn_code;
|
||||
const platform = body.platform || 'boss';
|
||||
|
||||
if (!sn_code) {
|
||||
return ctx.fail('请提供设备SN码');
|
||||
}
|
||||
|
||||
const page = Math.max(1, Number(body.page) || 1);
|
||||
const raw_page_size = body.page_size ?? body.pageSize ?? 20;
|
||||
const page_size = Math.min(100, Math.max(1, Number(raw_page_size) || 20));
|
||||
const offset = (page - 1) * page_size;
|
||||
|
||||
const base_where = { sn_code, platform };
|
||||
const startTime = body.startTime;
|
||||
const endTime = body.endTime;
|
||||
if (startTime || endTime) {
|
||||
base_where.last_modify_time = {};
|
||||
if (startTime) {
|
||||
base_where.last_modify_time[op.gte] = new Date(startTime);
|
||||
}
|
||||
if (endTime) {
|
||||
const end = new Date(endTime);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
base_where.last_modify_time[op.lte] = end;
|
||||
}
|
||||
}
|
||||
|
||||
let tab_labels = [];
|
||||
if (resume_info) {
|
||||
const resume = await resume_info.findOne({
|
||||
where: { sn_code, platform, isActive: true },
|
||||
order: [['last_modify_time', 'DESC']]
|
||||
});
|
||||
if (resume) {
|
||||
const rj = resume.toJSON ? resume.toJSON() : resume;
|
||||
tab_labels = parse_resume_tab_labels(rj.job_listings);
|
||||
}
|
||||
}
|
||||
|
||||
/** 不按「当前投递标签」过滤;列表为设备+平台下库中全部职位(各标签写入的 keyword 均会出现在列表中)。tab_labels 供前端展示「账户已同步的全部 Tab」 */
|
||||
const where = base_where;
|
||||
|
||||
const { rows, count } = await job_postings.findAndCountAll({
|
||||
where,
|
||||
attributes: [
|
||||
'id',
|
||||
'jobId',
|
||||
'jobTitle',
|
||||
'companyName',
|
||||
'salary',
|
||||
'location',
|
||||
'education',
|
||||
'experience',
|
||||
'last_modify_time',
|
||||
'create_time',
|
||||
'is_delivered',
|
||||
'deliver_failed_reason',
|
||||
'applyStatus',
|
||||
'platform',
|
||||
'keyword',
|
||||
'aiMatchScore'
|
||||
],
|
||||
limit: page_size,
|
||||
offset,
|
||||
order: [['last_modify_time', 'DESC']]
|
||||
});
|
||||
|
||||
const list = rows.map((r) => {
|
||||
const j = r.toJSON ? r.toJSON() : r;
|
||||
return j;
|
||||
});
|
||||
|
||||
return ctx.success({
|
||||
list,
|
||||
total: count,
|
||||
page,
|
||||
page_size,
|
||||
tab_labels
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -308,6 +308,147 @@ module.exports = {
|
||||
console.error('[任务管理] 获取任务统计失败:', error);
|
||||
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 || '未知错误'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -496,7 +496,7 @@ module.exports = {
|
||||
|
||||
/**
|
||||
* 仅保存投递标签:标签列表(来自 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) => {
|
||||
try {
|
||||
@@ -533,6 +533,10 @@ module.exports = {
|
||||
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 });
|
||||
} catch (error) {
|
||||
console.error('[保存投递标签失败]', error);
|
||||
@@ -603,6 +607,13 @@ module.exports = {
|
||||
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) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { Op } = require('sequelize');
|
||||
const aiService = require('../../../services/ai_service');
|
||||
const { jobFilterService } = require('../services');
|
||||
const locationService = require('../../../services/locationService');
|
||||
@@ -5,6 +6,7 @@ const logs = require('../../logProxy');
|
||||
const db = require('../../dbProxy');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
|
||||
/**
|
||||
* 工作管理模块
|
||||
* 负责简历获取、分析、存储和匹配度计算
|
||||
@@ -13,6 +15,25 @@ class JobManager {
|
||||
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) {
|
||||
const response = await mqttClient.publishAndWait(sn_code, {
|
||||
@@ -199,28 +220,14 @@ class JobManager {
|
||||
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 || [];
|
||||
}
|
||||
const jobs = this._jobListFromRecommendMonitorData(response.data);
|
||||
|
||||
console.log(`[工作管理] 成功获取岗位数据,共 ${jobs.length} 个岗位`);
|
||||
|
||||
// 保存职位到数据库
|
||||
try {
|
||||
await this.saveJobsToDatabase(sn_code, platform, keyword, jobs);
|
||||
} catch (error) {
|
||||
console.error(`[工作管理] 保存职位到数据库失败:`, error);
|
||||
}
|
||||
|
||||
await this.saveJobsToDatabase(sn_code, platform, keyword, jobs);
|
||||
|
||||
|
||||
return {
|
||||
jobs: jobs,
|
||||
@@ -334,6 +341,35 @@ class JobManager {
|
||||
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. 限制投递数量
|
||||
const jobsToDeliver = matchedJobs.slice(0, maxCount);
|
||||
console.log(`[工作管理] 匹配到 ${matchedJobs.length} 个职位,将投递 ${jobsToDeliver.length} 个`);
|
||||
@@ -414,110 +450,39 @@ class JobManager {
|
||||
}
|
||||
const list = Array.isArray(response.data) ? response.data : [];
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取岗位列表(支持多条件搜索)
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @param {object} mqttClient - MQTT客户端
|
||||
* @param {object} params - 参数
|
||||
* 获取岗位列表(与客户端/Electron 约定一致)
|
||||
* @param {string} sn_code - 设备 SN
|
||||
* @param {object} mqttClient - MQTT 客户端
|
||||
* @param {object} params - { platform?, pageCount?, tabLabel?, keyword? };keyword 仅服务端入库用,下发设备只有 pageCount + tabLabel
|
||||
* @returns {Promise<object>} 岗位列表
|
||||
*/
|
||||
async get_job_list(sn_code, mqttClient, params = {}) {
|
||||
const {
|
||||
keyword = '前端',
|
||||
platform = 'boss',
|
||||
pageCount = 3,
|
||||
city = '',
|
||||
cityName = '',
|
||||
salary = '',
|
||||
experience = '',
|
||||
education = '',
|
||||
industry = '',
|
||||
companySize = '',
|
||||
financingStage = '',
|
||||
page = 1,
|
||||
pageSize = 20
|
||||
} = params;
|
||||
const { platform = 'boss', pageCount = 3, tabLabel } = params;
|
||||
const keyword = String(params.keyword || '').trim() || String(tabLabel || '').trim();
|
||||
|
||||
// 判断是否是多条件搜索(如果包含多条件参数,使用多条件搜索逻辑)
|
||||
const hasMultiParams = city || cityName || salary || experience || education ||
|
||||
industry || companySize || financingStage || page || pageSize;
|
||||
const data = {
|
||||
pageCount,
|
||||
...(String(tabLabel || '').trim() ? { tabLabel: String(tabLabel).trim() } : {})
|
||||
};
|
||||
|
||||
if (hasMultiParams) {
|
||||
// 使用多条件搜索逻辑
|
||||
console.log(`[工作管理] 开始多条件搜索设备 ${sn_code} 的职位,关键词: ${keyword}, 城市: ${cityName || city}`);
|
||||
console.log(`[工作管理] get_job_list ${sn_code} → 设备`, data, `入库 keyword=${keyword}`);
|
||||
|
||||
// 构建完整的搜索参数对象
|
||||
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, {
|
||||
platform,
|
||||
action: "get_job_list",
|
||||
data: { keyword, pageCount }
|
||||
action: 'get_job_list',
|
||||
data
|
||||
});
|
||||
|
||||
if (!response || response.code !== 200) {
|
||||
@@ -525,43 +490,19 @@ class JobManager {
|
||||
throw new Error('获取岗位列表失败');
|
||||
}
|
||||
|
||||
// 处理职位列表数据:response.data 可能是数组(职位列表.json 格式)或单个对象
|
||||
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 || [];
|
||||
}
|
||||
|
||||
const jobs = this._jobListFromRecommendMonitorData(response.data);
|
||||
console.log(`[工作管理] 成功获取岗位数据,共 ${jobs.length} 个岗位`);
|
||||
|
||||
// 保存职位到数据库
|
||||
try {
|
||||
await this.saveJobsToDatabase(sn_code, platform, keyword, jobs);
|
||||
} catch (error) {
|
||||
console.error(`[工作管理] 保存职位到数据库失败:`, error);
|
||||
// 不影响主流程,继续返回数据
|
||||
}
|
||||
|
||||
const result = {
|
||||
jobs: jobs,
|
||||
keyword: keyword,
|
||||
platform: platform,
|
||||
await this.saveJobsToDatabase(sn_code, platform, keyword, jobs);
|
||||
|
||||
|
||||
return {
|
||||
jobs,
|
||||
keyword,
|
||||
platform,
|
||||
count: jobs.length
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -577,89 +518,93 @@ class JobManager {
|
||||
console.log(`[工作管理] 开始保存 ${jobs.length} 个职位到数据库`);
|
||||
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
// 构建职位信息对象
|
||||
const jobInfo = {
|
||||
sn_code,
|
||||
platform,
|
||||
keyword,
|
||||
|
||||
// Boss直聘字段映射
|
||||
encryptBossId: job.encryptBossId || '',
|
||||
jobId: job.encryptJobId || '',
|
||||
jobTitle: job.jobName || '',
|
||||
companyId: job.encryptBrandId || '',
|
||||
companyName: job.brandName || '',
|
||||
companySize: job.brandScaleName || '',
|
||||
companyIndustry: job.brandIndustry || '',
|
||||
salary: job.salaryDesc || '',
|
||||
// 构建职位信息对象
|
||||
const jobInfo = {
|
||||
sn_code,
|
||||
platform,
|
||||
keyword,
|
||||
|
||||
// 岗位要求(从 jobLabels 和 skills 提取)
|
||||
jobRequirements: JSON.stringify({
|
||||
experience: job.jobExperience || '',
|
||||
education: job.jobDegree || '',
|
||||
labels: job.jobLabels || [],
|
||||
skills: job.skills || []
|
||||
}),
|
||||
|
||||
// 工作地点
|
||||
location: [job.cityName, job.areaDistrict, job.businessDistrict]
|
||||
.filter(Boolean).join(' '),
|
||||
// Boss直聘字段映射
|
||||
encryptBossId: job.encryptBossId || '',
|
||||
jobId: job.encryptJobId || '',
|
||||
jobTitle: job.jobName || '',
|
||||
companyId: job.encryptBrandId || '',
|
||||
companyName: job.brandName || '',
|
||||
companySize: job.brandScaleName || '',
|
||||
companyIndustry: job.brandIndustry || '',
|
||||
salary: job.salaryDesc || '',
|
||||
|
||||
// 岗位要求(从 jobLabels 和 skills 提取)
|
||||
jobRequirements: JSON.stringify({
|
||||
experience: job.jobExperience || '',
|
||||
education: job.jobDegree || '',
|
||||
labels: job.jobLabels || [],
|
||||
skills: job.skills || []
|
||||
}),
|
||||
|
||||
// 原始数据
|
||||
originalData: JSON.stringify(job),
|
||||
// 工作地点
|
||||
location: [job.cityName, job.areaDistrict, job.businessDistrict]
|
||||
.filter(Boolean).join(' '),
|
||||
|
||||
// 默认状态
|
||||
applyStatus: 'pending',
|
||||
chatStatus: 'none'
|
||||
};
|
||||
experience: job.jobExperience || '',
|
||||
education: job.jobDegree || '',
|
||||
|
||||
// 调用位置服务解析 location + companyName 获取坐标
|
||||
if (jobInfo.location && jobInfo.companyName) {
|
||||
const addressToParse = `${jobInfo.location.replaceAll(' ', '')}${jobInfo.companyName}`;
|
||||
// 原始数据
|
||||
originalData: JSON.stringify(job),
|
||||
|
||||
// 默认状态
|
||||
applyStatus: 'pending',
|
||||
chatStatus: 'none'
|
||||
};
|
||||
|
||||
// 调用位置服务解析 location + companyName 获取坐标
|
||||
if (jobInfo.location && jobInfo.companyName) {
|
||||
const addressToParse = `${jobInfo.location.replaceAll(' ', '')}${jobInfo.companyName}`;
|
||||
|
||||
|
||||
// 等待 1秒
|
||||
// await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
// 等待 1秒
|
||||
// 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 => {
|
||||
// console.error(`[工作管理] 获取位置失败:`, error);
|
||||
// });
|
||||
// if (location) {
|
||||
// jobInfo.latitude = String(location.lat);
|
||||
// jobInfo.longitude = String(location.lng);
|
||||
// }
|
||||
}
|
||||
|
||||
// if (location) {
|
||||
// jobInfo.latitude = String(location.lat);
|
||||
// jobInfo.longitude = String(location.lng);
|
||||
// }
|
||||
// 创建新职位 重复投递时间 从 pla_account 中获取(pla_account 列为 platform_type,不是 platform)
|
||||
const pla_account = db.getModel('pla_account');
|
||||
const account = await pla_account.findOne({
|
||||
where: {
|
||||
sn_code: sn_code,
|
||||
platform_type: platform
|
||||
}
|
||||
});
|
||||
|
||||
// 检查是否已存在(根据 jobId 和 sn_code)
|
||||
const existingJob = await job_postings.findOne({
|
||||
where: {
|
||||
jobId: jobInfo.jobId,
|
||||
sn_code: sn_code
|
||||
let repeatDeliverDays = 30;
|
||||
if (account) {
|
||||
let dc = account.deliver_config?.repeat_deliver_days||30;
|
||||
repeatDeliverDays = Number(dc);
|
||||
}
|
||||
|
||||
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 } });
|
||||
if (existingApply) {
|
||||
await job_postings.update(
|
||||
{
|
||||
is_delivered: false,
|
||||
deliver_failed_reason: '该岗位已投递过'
|
||||
},
|
||||
{ where: { id: jobData.id } }
|
||||
);
|
||||
console.log(`[工作管理] 跳过已投递职位: ${jobData.jobTitle} @ ${jobData.companyName}`);
|
||||
return {
|
||||
success: false,
|
||||
@@ -743,6 +695,14 @@ class JobManager {
|
||||
|
||||
if (recentCompanyApply) {
|
||||
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}天前投递过)`);
|
||||
return {
|
||||
success: false,
|
||||
@@ -775,7 +735,12 @@ class JobManager {
|
||||
if (response && response.code === 200) {
|
||||
// 投递成功
|
||||
await job_postings.update(
|
||||
{ applyStatus: 'applied', applyTime: new Date() },
|
||||
{
|
||||
applyStatus: 'applied',
|
||||
applyTime: new Date(),
|
||||
is_delivered: true,
|
||||
deliver_failed_reason: ''
|
||||
},
|
||||
{ where: { id: jobData.id } }
|
||||
);
|
||||
|
||||
@@ -854,8 +819,13 @@ class JobManager {
|
||||
};
|
||||
} else {
|
||||
// 投递失败
|
||||
const fail_msg = String(response?.message || response?.msg || '投递失败').slice(0, 65000);
|
||||
await job_postings.update(
|
||||
{ applyStatus: 'failed' },
|
||||
{
|
||||
applyStatus: 'failed',
|
||||
is_delivered: false,
|
||||
deliver_failed_reason: fail_msg
|
||||
},
|
||||
{ where: { id: jobData.id } }
|
||||
);
|
||||
|
||||
|
||||
@@ -32,15 +32,16 @@ class JobFilterService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据职位类型ID获取技能关键词和排除关键词
|
||||
* 根据职位类型ID获取技能关键词、排除关键词、标题须含词
|
||||
* @param {number} jobTypeId - 职位类型ID
|
||||
* @returns {Promise<{commonSkills: Array, excludeKeywords: Array}>}
|
||||
* @returns {Promise<{commonSkills: Array, excludeKeywords: Array, titleIncludeKeywords: Array}>}
|
||||
*/
|
||||
async getJobTypeConfig(jobTypeId) {
|
||||
if (!jobTypeId) {
|
||||
return {
|
||||
commonSkills: this.defaultCommonSkills,
|
||||
excludeKeywords: this.defaultExcludeKeywords
|
||||
excludeKeywords: this.defaultExcludeKeywords,
|
||||
titleIncludeKeywords: []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -55,7 +56,8 @@ class JobFilterService {
|
||||
console.warn('[职位过滤服务] job_types 模型不存在,使用默认配置');
|
||||
return {
|
||||
commonSkills: this.defaultCommonSkills,
|
||||
excludeKeywords: this.defaultExcludeKeywords
|
||||
excludeKeywords: this.defaultExcludeKeywords,
|
||||
titleIncludeKeywords: []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -67,7 +69,8 @@ class JobFilterService {
|
||||
console.warn(`[职位过滤服务] 职位类型 ${jobTypeId} 不存在或已禁用,使用默认配置`);
|
||||
return {
|
||||
commonSkills: this.defaultCommonSkills,
|
||||
excludeKeywords: this.defaultExcludeKeywords
|
||||
excludeKeywords: this.defaultExcludeKeywords,
|
||||
titleIncludeKeywords: []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,6 +79,7 @@ class JobFilterService {
|
||||
// 解析 JSON 字段
|
||||
let commonSkills = this.defaultCommonSkills;
|
||||
let excludeKeywords = this.defaultExcludeKeywords;
|
||||
let titleIncludeKeywords = [];
|
||||
|
||||
if (jobTypeData.commonSkills) {
|
||||
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 = {
|
||||
commonSkills,
|
||||
excludeKeywords
|
||||
excludeKeywords,
|
||||
titleIncludeKeywords
|
||||
};
|
||||
|
||||
// 缓存配置(缓存5分钟)
|
||||
@@ -119,7 +137,8 @@ class JobFilterService {
|
||||
console.error(`[职位过滤服务] 获取职位类型配置失败:`, error);
|
||||
return {
|
||||
commonSkills: this.defaultCommonSkills,
|
||||
excludeKeywords: this.defaultExcludeKeywords
|
||||
excludeKeywords: this.defaultExcludeKeywords,
|
||||
titleIncludeKeywords: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,15 +8,45 @@ class MqttSyncClient {
|
||||
constructor(brokerUrl, options = {}) {
|
||||
this.client = mqtt.connect(brokerUrl, options)
|
||||
this.isConnected = false
|
||||
/** @type {string[]} 需在每次 connect(含重连)后向 Broker 幂等订阅的主题 */
|
||||
this._maintainedTopics = []
|
||||
/** 最近一次收到任意 `response` 主题消息的时间(用于超时日志关联) */
|
||||
this.lastResponseAt = null
|
||||
|
||||
// 使用 Map 结构优化消息监听器,按 topic 分组
|
||||
this.messageListeners = new Map(); // Map<topic, Set<listener>>
|
||||
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.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) => {
|
||||
@@ -29,6 +59,9 @@ class MqttSyncClient {
|
||||
return;
|
||||
}
|
||||
|
||||
if (topic === 'response') {
|
||||
this.lastResponseAt = Date.now()
|
||||
}
|
||||
|
||||
// 1. 触发该 topic 的专用监听器
|
||||
const topicListeners = this.messageListeners.get(topic);
|
||||
@@ -56,18 +89,52 @@ class MqttSyncClient {
|
||||
})
|
||||
|
||||
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) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.isConnected) return resolve()
|
||||
if (this.isBrokerConnected()) {
|
||||
this.isConnected = true
|
||||
return resolve()
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error('MQTT connect timeout'))
|
||||
}, timeout)
|
||||
const check = () => {
|
||||
if (this.isConnected) {
|
||||
if (this.isBrokerConnected()) {
|
||||
this.isConnected = true
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
} else {
|
||||
@@ -113,7 +180,6 @@ class MqttSyncClient {
|
||||
resolve(granted)
|
||||
}
|
||||
}
|
||||
1
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -143,7 +209,12 @@ class MqttSyncClient {
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
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);
|
||||
|
||||
const onMessage = (topic, message) => {
|
||||
@@ -242,6 +313,7 @@ class MqttSyncClient {
|
||||
}
|
||||
|
||||
end(force = false) {
|
||||
this.isConnected = false
|
||||
this.client.end(force)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
const MqttSyncClient = require('./mqttClient');
|
||||
const Framework = require('../../../framework/node-core-framework');
|
||||
const logs = require('../logProxy');
|
||||
const appConfig = require('../../../config/config.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客户端实例
|
||||
@@ -11,16 +33,7 @@ class MqttManager {
|
||||
constructor() {
|
||||
this.client = null;
|
||||
this.isInitialized = false;
|
||||
this.config = {
|
||||
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
|
||||
}
|
||||
};
|
||||
this.config = buildMqttManagerConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,8 +43,16 @@ class MqttManager {
|
||||
*/
|
||||
async getInstance(config = {}) {
|
||||
if (this.client && this.isInitialized) {
|
||||
console.log('[MQTT管理器] 返回已存在的MQTT客户端实例');
|
||||
return this.client;
|
||||
const brokerOk = typeof this.client.isBrokerConnected === 'function'
|
||||
? 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}
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,7 +26,8 @@ class ScheduledJobs {
|
||||
auto_search: false,
|
||||
auto_deliver: 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);
|
||||
console.log('[定时任务] ✓ 已启动自动活跃任务 (每2小时)');
|
||||
|
||||
// 5. 每日拉取 get_job_listings 并用 AI 更新 job_types(description / 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(() => {
|
||||
console.log('[定时任务] 立即执行一次初始化任务...');
|
||||
this.runAutoDeliverTask();
|
||||
this.runAutoChatTask();
|
||||
this.runDailyJobTypeListingsAiSync().catch((err) => {
|
||||
console.error('[定时任务] 启动时 job_types AI 同步失败:', err);
|
||||
});
|
||||
}, 10000); // 延迟10秒,等待系统初始化完成和设备心跳
|
||||
|
||||
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
|
||||
|
||||
@@ -30,7 +30,7 @@ class DeliverHandler extends BaseHandler {
|
||||
*/
|
||||
async doDeliver(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}`);
|
||||
|
||||
@@ -89,9 +89,9 @@ class DeliverHandler extends BaseHandler {
|
||||
mqttClient: this.mqttClient
|
||||
});
|
||||
|
||||
// 6. 下发 get_job_list 拉取职位列表(tabLabel 切换期望 tab,job_type_id 随指令下发供设备使用)
|
||||
// 6. 下发 get_job_list(与前端一致:command 只带 pageCount + tabLabel,设备端不接收 keyword/job_type_id)
|
||||
const tabLabel = resume.deliver_tab_label || '';
|
||||
await this.getJobList(sn_code, platform, pageCount, task.id, tabLabel, accountConfig.job_type_id);
|
||||
await this.getJobList(sn_code, platform, pageCount, task.id, tabLabel);
|
||||
|
||||
// 7. 从数据库获取待投递职位
|
||||
const pendingJobs = await this.getPendingJobs(sn_code, platform, actualMaxCount * 3);
|
||||
@@ -103,14 +103,15 @@ class DeliverHandler extends BaseHandler {
|
||||
};
|
||||
}
|
||||
|
||||
// 8. 合并过滤配置
|
||||
const filterConfig = this.mergeFilterConfig(deliverConfig, filterRules, jobTypeConfig);
|
||||
// 8. 过滤配置仅来自职位类型 job_types(排除词 / 标题须含词等),不与账号投递配置、任务参数混用
|
||||
const filterConfig = this.mergeFilterConfig(jobTypeConfig);
|
||||
|
||||
// 9. 过滤已投递的公司
|
||||
const recentCompanies = await this.getRecentDeliveredCompanies(sn_code, 30);
|
||||
// 9. 过滤已投递的公司(repeat_deliver_days 由投递配置给出,缺省 30,上限 365)
|
||||
const repeatDeliverDays = Math.min(365, Math.max(1, Number(deliverConfig.repeat_deliver_days) || 30));
|
||||
const recentCompanies = await this.getRecentDeliveredCompanies(sn_code, repeatDeliverDays);
|
||||
|
||||
// 10. 过滤 + 评分 + 按 60 分阈值筛(入口在 jobFilterEngine,便于阅读)
|
||||
const filteredJobs = await jobFilterEngine.filterAndScoreJobsForDeliver(
|
||||
const { scored: filteredJobs, skipReasonByJobId } = await jobFilterEngine.filterAndScoreJobsForDeliver(
|
||||
pendingJobs,
|
||||
filterConfig,
|
||||
resume,
|
||||
@@ -119,6 +120,9 @@ class DeliverHandler extends BaseHandler {
|
||||
recentCompanies
|
||||
);
|
||||
|
||||
// 本轮未进入「可投递」列表的待投递记录,标记为已过滤,避免长期停留在 pending
|
||||
await this.markFilteredJobsNotPassed(pendingJobs, filteredJobs, sn_code, platform, skipReasonByJobId);
|
||||
|
||||
const jobsToDeliver = filteredJobs.slice(0, actualMaxCount);
|
||||
|
||||
console.log(`[自动投递] 职位筛选完成 - 原始: ${pendingJobs.length}, 符合条件: ${filteredJobs.length}, 将投递: ${jobsToDeliver.length}`);
|
||||
@@ -138,6 +142,12 @@ class DeliverHandler extends BaseHandler {
|
||||
|
||||
return {
|
||||
deliveredCount: deliverCommands.length,
|
||||
filterSummary: {
|
||||
rawCount: pendingJobs.length,
|
||||
passedCount: filteredJobs.length,
|
||||
deliverCount: jobsToDeliver.length,
|
||||
filteredCount: Math.max(0, pendingJobs.length - filteredJobs.length)
|
||||
},
|
||||
...result
|
||||
};
|
||||
}
|
||||
@@ -273,22 +283,16 @@ class DeliverHandler extends BaseHandler {
|
||||
}
|
||||
|
||||
/**
|
||||
* 下发 get_job_list 命令拉取职位列表
|
||||
* @param {string} tabLabel - 投递用期望标签文案,对应 resume_info.deliver_tab_label,get_job_list 会按此选择 tab
|
||||
* @param {number} jobTypeId - 职位类型 ID,随指令下发供设备使用
|
||||
* 下发 get_job_list 命令拉取职位列表(command_params 与前端约定:pageCount、tabLabel + sn_code、platform)
|
||||
*/
|
||||
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 = {
|
||||
sn_code,
|
||||
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 = {
|
||||
command_type: 'get_job_list',
|
||||
command_name: '获取职位列表',
|
||||
@@ -299,6 +303,54 @@ class DeliverHandler extends BaseHandler {
|
||||
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: {
|
||||
sn_code,
|
||||
platform,
|
||||
applyStatus: 'pending'
|
||||
applyStatus: 'pending',
|
||||
create_time: {
|
||||
[db.models.op.gte]: new Date(Date.now() - 1000 * 60 * 60 * 24)
|
||||
}
|
||||
|
||||
},
|
||||
order: [['create_time', 'DESC']],
|
||||
limit
|
||||
@@ -319,39 +375,50 @@ class DeliverHandler extends BaseHandler {
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并过滤配置
|
||||
* 自动投递过滤配置:仅使用 job_types(excludeKeywords、titleIncludeKeywords)
|
||||
* 薪资筛选不在此合并(min/max 为 0 表示不做薪资过滤);评分权重仍走 accountConfig.is_salary_priority
|
||||
*/
|
||||
mergeFilterConfig(deliverConfig, filterRules, jobTypeConfig) {
|
||||
// 排除关键词
|
||||
const rawJobTypeExclude = jobTypeConfig?.excludeKeywords
|
||||
? ConfigManager.parseConfig(jobTypeConfig.excludeKeywords, [])
|
||||
: [];
|
||||
|
||||
const jobTypeExclude = Array.isArray(rawJobTypeExclude) ? rawJobTypeExclude : [];
|
||||
|
||||
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)
|
||||
mergeFilterConfig(jobTypeConfig) {
|
||||
const base = {
|
||||
exclude_keywords: [],
|
||||
filter_keywords: [],
|
||||
title_include_keywords: [],
|
||||
min_salary: 0,
|
||||
max_salary: 0,
|
||||
priority_weights: []
|
||||
};
|
||||
|
||||
if (!jobTypeConfig) {
|
||||
return base;
|
||||
}
|
||||
|
||||
if (jobTypeConfig.excludeKeywords) {
|
||||
try {
|
||||
const raw = jobTypeConfig.excludeKeywords;
|
||||
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
base.exclude_keywords = Array.isArray(parsed) ? parsed.map((k) => String(k || '').trim()).filter(Boolean) : [];
|
||||
} catch (e) {
|
||||
base.exclude_keywords = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (jobTypeConfig.titleIncludeKeywords != null) {
|
||||
const v = jobTypeConfig.titleIncludeKeywords;
|
||||
if (Array.isArray(v)) {
|
||||
base.title_include_keywords = v.map((k) => String(k || '').trim()).filter(Boolean);
|
||||
} else if (typeof v === 'string' && v.trim()) {
|
||||
try {
|
||||
const p = JSON.parse(v);
|
||||
if (Array.isArray(p)) {
|
||||
base.title_include_keywords = p.map((k) => String(k || '').trim()).filter(Boolean);
|
||||
}
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -79,11 +79,10 @@ class SearchHandler extends BaseHandler {
|
||||
|
||||
const commandParams = {
|
||||
sn_code,
|
||||
keyword: keyword || accountConfig.keyword || '',
|
||||
platform: platformType,
|
||||
pageCount: pageCount || searchConfig.page_count || 3
|
||||
pageCount: pageCount || searchConfig.page_count || 3,
|
||||
...(tabLabel ? { tabLabel } : {})
|
||||
};
|
||||
if (tabLabel) commandParams.tabLabel = tabLabel;
|
||||
|
||||
const searchCommand = {
|
||||
command_type: 'get_job_list',
|
||||
|
||||
@@ -141,6 +141,11 @@ class ScheduleManager {
|
||||
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() : {};
|
||||
return {
|
||||
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(),
|
||||
allDevices: deviceManager.getAllDevicesStatus(),
|
||||
taskQueues: TaskQueue.getAllDeviceStatus(),
|
||||
|
||||
@@ -51,7 +51,8 @@ class ScheduleConfig {
|
||||
autoSearch: '0 0 */1 * * *', // 自动搜索任务:每1小时执行一次
|
||||
autoDeliver: '0 */2 * * * *', // 自动投递任务:每2分钟执行一次
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ class ConfigManager {
|
||||
max_salary: 0, // 最高薪资
|
||||
page_count: 3, // 搜索页数
|
||||
max_deliver: 10, // 最大投递数
|
||||
repeat_deliver_days: 30, // 多少天内已投递过的公司不再投递(与 getRecentDeliveredCompanies 一致)
|
||||
filter_keywords: [], // 过滤关键词
|
||||
exclude_keywords: [], // 排除关键词
|
||||
time_range: null, // 时间范围
|
||||
|
||||
@@ -5,11 +5,24 @@ const db = require('../../dbProxy');
|
||||
/**
|
||||
* 职位过滤引擎(schedule 自动投递用)
|
||||
* 本文件集中:过滤(薪资/关键词/活跃度/去重)+ 评分(权重分+关键词奖励)+ 按分数阈值筛。
|
||||
* 自动投递只需调 filterAndScoreJobsForDeliver 一个方法。
|
||||
* 自动投递调用 filterAndScoreJobsForDeliver,返回 { scored, skipReasonByJobId }。
|
||||
*/
|
||||
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 {object} config - 过滤配置
|
||||
* @param {object} resumeInfo - 简历信息(未使用,兼容签名)
|
||||
@@ -23,63 +36,137 @@ class JobFilterEngine {
|
||||
let filtered = [...jobs];
|
||||
|
||||
// 1. 薪资过滤
|
||||
const beforeSalary = filtered.length;
|
||||
const beforeSalaryJobs = [...filtered];
|
||||
filtered = this.filterBySalary(filtered, config);
|
||||
const salaryRemoved = beforeSalary - filtered.length;
|
||||
const salaryRemoved = beforeSalaryJobs.length - filtered.length;
|
||||
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. 关键词过滤
|
||||
const beforeKeywords = filtered.length;
|
||||
// 2. 职位标题须包含(job_types.titleIncludeKeywords,仅 jobTitle,与 commonSkills 无关)
|
||||
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);
|
||||
const keywordsRemoved = beforeKeywords - filtered.length;
|
||||
const keywordsRemoved = beforeKeywordFilterJobs.length - filtered.length;
|
||||
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) {
|
||||
const beforeActivity = filtered.length;
|
||||
const beforeActivityJobs = [...filtered];
|
||||
filtered = await this.filterByCompanyActivity(filtered, config.company_active_days || 7);
|
||||
const activityRemoved = beforeActivity - filtered.length;
|
||||
const activityRemoved = beforeActivityJobs.length - filtered.length;
|
||||
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) {
|
||||
const beforeDedup = filtered.length;
|
||||
const beforeDedupJobs = [...filtered];
|
||||
filtered = this.deduplicateJobs(filtered);
|
||||
const dedupRemoved = beforeDedup - filtered.length;
|
||||
const dedupRemoved = beforeDedupJobs.length - filtered.length;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条职位:判断 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 分阈值筛,一次调用完成(便于阅读与维护)
|
||||
* @returns {{ scored: Array, skipReasonByJobId: Record<number|string, string> }}
|
||||
*/
|
||||
async filterAndScoreJobsForDeliver(jobs, filterConfig, resume, accountConfig, jobTypeConfig, recentCompanies) {
|
||||
const scored = [];
|
||||
const skipReasonByJobId = {};
|
||||
const jobDesc = (j) => `${j.companyName || '?'} / ${j.jobTitle || '?'}`;
|
||||
const { jobFilterService } = require('../../job/services');
|
||||
|
||||
console.log(`[jobFilterEngine] filterAndScoreJobsForDeliver 开始,待处理: ${jobs.length}`);
|
||||
|
||||
for (const job of jobs) {
|
||||
const job_id = job.id;
|
||||
if (job.companyName && recentCompanies.has(job.companyName)) {
|
||||
const msg = '近期已在配置天数内投递过该公司';
|
||||
if (job_id != null) skipReasonByJobId[job_id] = msg;
|
||||
console.log(`[jobFilterEngine] 已投递公司 剔除: ${jobDesc(job)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const filtered = await this.filterJobs([job], filterConfig, resume);
|
||||
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)}`);
|
||||
continue;
|
||||
}
|
||||
@@ -99,6 +186,8 @@ class JobFilterEngine {
|
||||
const finalScore = scoreResult.totalScore + keywordBonus.score;
|
||||
|
||||
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)}`);
|
||||
continue;
|
||||
}
|
||||
@@ -112,7 +201,7 @@ class JobFilterEngine {
|
||||
|
||||
scored.sort((a, b) => b.matchScore - a.matchScore);
|
||||
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 - 职位列表
|
||||
@@ -220,7 +332,7 @@ class JobFilterEngine {
|
||||
|
||||
for (const job of jobs) {
|
||||
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}`;
|
||||
|
||||
if (!seen.has(key)) {
|
||||
@@ -303,8 +415,8 @@ class JobFilterEngine {
|
||||
}
|
||||
|
||||
const jobText = [
|
||||
job.name || job.jobName || '',
|
||||
job.description || job.jobDescription || '',
|
||||
job.jobTitle || '',
|
||||
job.jobDescription || '',
|
||||
job.skills || ''
|
||||
].join(' ');
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ class KeywordMatcher {
|
||||
if (filterKeywords.length > 0 && !filterResult.matched) {
|
||||
return {
|
||||
pass: false,
|
||||
reason: '不包含任何必需关键词',
|
||||
reason: `未命中包含关键词: ${filterKeywords.join(', ')}`,
|
||||
score: 0,
|
||||
details: { filter: filterResult }
|
||||
};
|
||||
@@ -204,7 +204,7 @@ class KeywordMatcher {
|
||||
return [];
|
||||
}
|
||||
|
||||
const textExtractor=(job) => `${job.jobTitle || ''} ${job.companyIndustry || ''}`;
|
||||
const textExtractor=(job) => `${job.jobTitle || ''}`;
|
||||
|
||||
const filtered = [];
|
||||
|
||||
|
||||
@@ -190,7 +190,7 @@ module.exports = (db) => {
|
||||
},
|
||||
// 投递状态
|
||||
applyStatus: {
|
||||
comment: '投递状态: pending-待投递, applied-已投递, rejected-被拒绝, accepted-已接受',
|
||||
comment: '投递状态: pending-待投递, filtered-已过滤(不符合规则未投递), applied-已投递, rejected-被拒绝, accepted-已接受, success/failed-见业务',
|
||||
type: Sequelize.STRING(20),
|
||||
allowNull: true,
|
||||
defaultValue: 'pending'
|
||||
@@ -200,6 +200,18 @@ module.exports = (db) => {
|
||||
type: Sequelize.DATE,
|
||||
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: {
|
||||
comment: '聊天状态: none-未聊天, sent-已发送, replied-已回复',
|
||||
type: Sequelize.STRING(20),
|
||||
|
||||
@@ -7,7 +7,7 @@ const Sequelize = require('sequelize');
|
||||
module.exports = (db) => {
|
||||
const job_types = db.define("job_types", {
|
||||
name: {
|
||||
comment: '职位类型名称(如:前端开发、后端开发、全栈开发等)',
|
||||
comment: '职位类型名称:须与 Boss 页 get_job_listings 返回的 Tab 文案 text 完全一致(投递标签)',
|
||||
type: Sequelize.STRING(100),
|
||||
allowNull: false,
|
||||
defaultValue: ''
|
||||
@@ -19,7 +19,7 @@ module.exports = (db) => {
|
||||
defaultValue: ''
|
||||
},
|
||||
commonSkills: {
|
||||
comment: '常见技能关键词(JSON数组)',
|
||||
comment: '常见技能关键词(JSON数组),仅用于简历技能匹配/评分;职位标题过滤请用 titleIncludeKeywords',
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
defaultValue: '[]'
|
||||
@@ -30,6 +30,12 @@ module.exports = (db) => {
|
||||
allowNull: true,
|
||||
defaultValue: '[]'
|
||||
},
|
||||
titleIncludeKeywords: {
|
||||
comment: '职位标题须包含的子串(JSON数组),仅按岗位标题匹配;commonSkills 仅用于简历技能匹配,不参与标题过滤',
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
defaultValue: '[]'
|
||||
},
|
||||
is_enabled: {
|
||||
comment: '是否启用(1=启用,0=禁用)',
|
||||
type: Sequelize.TINYINT(1),
|
||||
@@ -41,6 +47,12 @@ module.exports = (db) => {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
pla_account_id: {
|
||||
comment: '关联账户ID(pla_account.id,可选;AI 根据 get_job_listings 更新本行时写入)',
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: null
|
||||
}
|
||||
}, {
|
||||
timestamps: false,
|
||||
@@ -52,10 +64,16 @@ module.exports = (db) => {
|
||||
{
|
||||
unique: false,
|
||||
fields: ['name']
|
||||
},
|
||||
{
|
||||
unique: false,
|
||||
fields: ['pla_account_id']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// job_types.sync({ force: true });
|
||||
|
||||
return job_types;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,12 +21,6 @@ module.exports = (db) => {
|
||||
allowNull: true,
|
||||
defaultValue: ''
|
||||
},
|
||||
platform_type: {
|
||||
comment: '平台',
|
||||
type: Sequelize.STRING(50),
|
||||
allowNull: false,
|
||||
defaultValue: ''
|
||||
},
|
||||
login_name: {
|
||||
comment: '登录名',
|
||||
type: Sequelize.STRING(50),
|
||||
@@ -40,7 +34,7 @@ module.exports = (db) => {
|
||||
defaultValue: ''
|
||||
},
|
||||
keyword: {
|
||||
comment: '关键词',
|
||||
comment: '搜索/推荐职位关键词;保存投递期望标签时会与 resume_info.deliver_tab_label 同步',
|
||||
type: Sequelize.STRING(50),
|
||||
allowNull: false,
|
||||
defaultValue: ''
|
||||
|
||||
418
api/services/job_type_ai_sync_service.js
Normal file
418
api/services/job_type_ai_sync_service.js
Normal 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:面向求职者的简短说明。
|
||||
- excludeKeywords:5~12 个字符串,用于过滤明显不合适的岗位。
|
||||
- commonSkills:8~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
|
||||
};
|
||||
@@ -481,13 +481,13 @@ class PlaAccountService {
|
||||
? { ...baseParams, ...commandParams }
|
||||
: baseParams;
|
||||
|
||||
// 如果有关键词相关的操作,添加关键词
|
||||
if (['search_jobs', 'get_job_list'].includes(commandTypeSnake) && account.keyword) {
|
||||
if (commandTypeSnake === 'search_jobs' && account.keyword) {
|
||||
finalParams.keyword = account.keyword;
|
||||
}
|
||||
|
||||
// get_job_list 从 resume_info 取 deliver_tab_label 作为 tabLabel 参数
|
||||
// get_job_list:优先使用调用方显式传入的 tabLabel;仅在未传时回退到 resume_info.deliver_tab_label
|
||||
if (commandTypeSnake === 'get_job_list') {
|
||||
const passedTabLabel = finalParams.tabLabel != null ? String(finalParams.tabLabel).trim() : '';
|
||||
try {
|
||||
const resume_info = db.getModel('resume_info');
|
||||
const resume = await resume_info.findOne({
|
||||
@@ -495,7 +495,7 @@ class PlaAccountService {
|
||||
order: [['last_modify_time', 'DESC']],
|
||||
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();
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -640,6 +640,35 @@ class PlaAccountService {
|
||||
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();
|
||||
try {
|
||||
@@ -651,7 +680,7 @@ class PlaAccountService {
|
||||
start_time: start_time,
|
||||
end_time: end_time,
|
||||
duration: end_time.getTime() - start_time.getTime(),
|
||||
result: JSON.stringify(result || {})
|
||||
result: serialize_retry_result_for_db(result)
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -71,13 +71,20 @@ module.exports = {
|
||||
"model": "qwen-plus"
|
||||
},
|
||||
|
||||
// MQTT配置
|
||||
// MQTT配置(Broker 地址、保活与重连与 Broker 策略对齐时可改此处或环境变量)
|
||||
mqtt: {
|
||||
host: process.env.MQTT_HOST || 'localhost',
|
||||
port: process.env.MQTT_PORT || 1883,
|
||||
/** 完整连接串,优先于 host+port */
|
||||
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 || '',
|
||||
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
Reference in New Issue
Block a user