Compare commits
9 Commits
820e437729
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ef0c68ad1 | ||
|
|
37daa2f99f | ||
|
|
51bbdacdda | ||
|
|
f2a8e61016 | ||
|
|
048c40d802 | ||
|
|
bfd39eddcf | ||
|
|
21fe005c19 | ||
|
|
e3d14dd637 | ||
|
|
ca8bbcd9cd |
@@ -229,6 +229,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span="8">
|
<Col span="8">
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="label">同公司重复投递间隔(天):</span>
|
||||||
|
<span class="value">{{ deliverConfig.repeat_deliver_days != null ? deliverConfig.repeat_deliver_days : '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<Col span="8">
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<span class="label">过滤关键词:</span>
|
<span class="label">过滤关键词:</span>
|
||||||
<span class="value">{{ deliverConfig.filter_keywords || '-' }}</span>
|
<span class="value">{{ deliverConfig.filter_keywords || '-' }}</span>
|
||||||
@@ -560,6 +566,7 @@ export default {
|
|||||||
max_salary: 0,
|
max_salary: 0,
|
||||||
page_count: 3,
|
page_count: 3,
|
||||||
max_deliver: 10,
|
max_deliver: 10,
|
||||||
|
repeat_deliver_days: 30,
|
||||||
filter_keywords: '',
|
filter_keywords: '',
|
||||||
exclude_keywords: ''
|
exclude_keywords: ''
|
||||||
},
|
},
|
||||||
@@ -908,6 +915,7 @@ export default {
|
|||||||
max_salary: deliverConfig.max_salary || 0,
|
max_salary: deliverConfig.max_salary || 0,
|
||||||
page_count: deliverConfig.page_count || 3,
|
page_count: deliverConfig.page_count || 3,
|
||||||
max_deliver: deliverConfig.max_deliver || 10,
|
max_deliver: deliverConfig.max_deliver || 10,
|
||||||
|
repeat_deliver_days: deliverConfig.repeat_deliver_days != null ? deliverConfig.repeat_deliver_days : 30,
|
||||||
filter_keywords: Array.isArray(deliverConfig.filter_keywords)
|
filter_keywords: Array.isArray(deliverConfig.filter_keywords)
|
||||||
? deliverConfig.filter_keywords.join(',')
|
? deliverConfig.filter_keywords.join(',')
|
||||||
: (deliverConfig.filter_keywords || ''),
|
: (deliverConfig.filter_keywords || ''),
|
||||||
@@ -926,6 +934,7 @@ export default {
|
|||||||
max_salary: 0,
|
max_salary: 0,
|
||||||
page_count: 3,
|
page_count: 3,
|
||||||
max_deliver: 10,
|
max_deliver: 10,
|
||||||
|
repeat_deliver_days: 30,
|
||||||
filter_keywords: '',
|
filter_keywords: '',
|
||||||
exclude_keywords: '',
|
exclude_keywords: '',
|
||||||
deliver_start_time: '09:00',
|
deliver_start_time: '09:00',
|
||||||
|
|||||||
@@ -148,6 +148,9 @@
|
|||||||
<FormItem label="每次最多投递数">
|
<FormItem label="每次最多投递数">
|
||||||
<InputNumber v-model="formData.max_deliver" :min="1" placeholder="默认10个" style="width: 100%;" />
|
<InputNumber v-model="formData.max_deliver" :min="1" placeholder="默认10个" style="width: 100%;" />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
<FormItem label="同公司重复投递间隔(天)">
|
||||||
|
<InputNumber v-model="formData.repeat_deliver_days" :min="1" :max="365" placeholder="默认30天,N天内投过的公司跳过" style="width: 100%;" />
|
||||||
|
</FormItem>
|
||||||
<FormItem label="过滤关键词">
|
<FormItem label="过滤关键词">
|
||||||
<Input
|
<Input
|
||||||
v-model="formData.filter_keywords"
|
v-model="formData.filter_keywords"
|
||||||
@@ -268,6 +271,7 @@ export default {
|
|||||||
max_salary: 0,
|
max_salary: 0,
|
||||||
page_count: 3,
|
page_count: 3,
|
||||||
max_deliver: 10,
|
max_deliver: 10,
|
||||||
|
repeat_deliver_days: 30,
|
||||||
filter_keywords: '',
|
filter_keywords: '',
|
||||||
exclude_keywords: '',
|
exclude_keywords: '',
|
||||||
deliver_start_time: '09:00',
|
deliver_start_time: '09:00',
|
||||||
@@ -386,6 +390,7 @@ export default {
|
|||||||
this.formData.max_salary = deliverConfig.max_salary || 0
|
this.formData.max_salary = deliverConfig.max_salary || 0
|
||||||
this.formData.page_count = deliverConfig.page_count || 3
|
this.formData.page_count = deliverConfig.page_count || 3
|
||||||
this.formData.max_deliver = deliverConfig.max_deliver || 10
|
this.formData.max_deliver = deliverConfig.max_deliver || 10
|
||||||
|
this.formData.repeat_deliver_days = deliverConfig.repeat_deliver_days != null ? deliverConfig.repeat_deliver_days : 30
|
||||||
this.formData.filter_keywords = Array.isArray(deliverConfig.filter_keywords)
|
this.formData.filter_keywords = Array.isArray(deliverConfig.filter_keywords)
|
||||||
? deliverConfig.filter_keywords.join('\n')
|
? deliverConfig.filter_keywords.join('\n')
|
||||||
: (deliverConfig.filter_keywords || '')
|
: (deliverConfig.filter_keywords || '')
|
||||||
@@ -447,6 +452,7 @@ export default {
|
|||||||
max_salary: 0,
|
max_salary: 0,
|
||||||
page_count: 3,
|
page_count: 3,
|
||||||
max_deliver: 10,
|
max_deliver: 10,
|
||||||
|
repeat_deliver_days: 30,
|
||||||
filter_keywords: '',
|
filter_keywords: '',
|
||||||
exclude_keywords: '',
|
exclude_keywords: '',
|
||||||
auto_chat: 0,
|
auto_chat: 0,
|
||||||
@@ -600,6 +606,9 @@ export default {
|
|||||||
if (saveData.max_deliver !== undefined) {
|
if (saveData.max_deliver !== undefined) {
|
||||||
deliverConfig.max_deliver = Number(saveData.max_deliver) || 10
|
deliverConfig.max_deliver = Number(saveData.max_deliver) || 10
|
||||||
}
|
}
|
||||||
|
if (saveData.repeat_deliver_days !== undefined) {
|
||||||
|
deliverConfig.repeat_deliver_days = Number(saveData.repeat_deliver_days) || 30
|
||||||
|
}
|
||||||
// 解析过滤关键词:支持换行和逗号分隔
|
// 解析过滤关键词:支持换行和逗号分隔
|
||||||
if (saveData.filter_keywords !== undefined) {
|
if (saveData.filter_keywords !== undefined) {
|
||||||
deliverConfig.filter_keywords = this.parseKeywords(saveData.filter_keywords)
|
deliverConfig.filter_keywords = this.parseKeywords(saveData.filter_keywords)
|
||||||
@@ -623,6 +632,7 @@ export default {
|
|||||||
delete saveData.max_salary
|
delete saveData.max_salary
|
||||||
delete saveData.page_count
|
delete saveData.page_count
|
||||||
delete saveData.max_deliver
|
delete saveData.max_deliver
|
||||||
|
delete saveData.repeat_deliver_days
|
||||||
delete saveData.filter_keywords
|
delete saveData.filter_keywords
|
||||||
delete saveData.exclude_keywords
|
delete saveData.exclude_keywords
|
||||||
delete saveData.deliver_start_time
|
delete saveData.deliver_start_time
|
||||||
|
|||||||
@@ -28,13 +28,14 @@
|
|||||||
<tables :columns="listColumns" :value="gridOption.data" :pageOption="gridOption.param.pageOption"
|
<tables :columns="listColumns" :value="gridOption.data" :pageOption="gridOption.param.pageOption"
|
||||||
@changePage="query"></tables>
|
@changePage="query"></tables>
|
||||||
</div>
|
</div>
|
||||||
<editModal ref="editModal" :columns="editColumns" :rules="gridOption.rules" @on-save="handleSaveSuccess">
|
<editModal ref="editModal" :columns="editFormColumns" :rules="gridOption.rules" @on-save="handleSaveSuccess">
|
||||||
</editModal>
|
</editModal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import jobTypesServer from '@/api/work/job_types_server.js'
|
import jobTypesServer from '@/api/work/job_types_server.js'
|
||||||
|
import plaAccountServer from '@/api/profile/pla_account_server.js'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
@@ -44,8 +45,10 @@ export default {
|
|||||||
return {
|
return {
|
||||||
seachTypes: [
|
seachTypes: [
|
||||||
{ key: 'name', value: '职位类型名称' },
|
{ key: 'name', value: '职位类型名称' },
|
||||||
{ key: 'description', value: '描述' }
|
{ key: 'description', value: '描述' },
|
||||||
|
{ key: 'pla_account_id', value: '关联账户ID' }
|
||||||
],
|
],
|
||||||
|
plaAccountOptions: [],
|
||||||
gridOption: {
|
gridOption: {
|
||||||
param: {
|
param: {
|
||||||
seachOption: {
|
seachOption: {
|
||||||
@@ -63,6 +66,23 @@ export default {
|
|||||||
},
|
},
|
||||||
listColumns: [
|
listColumns: [
|
||||||
{ title: 'ID', key: 'id', minWidth: 80 },
|
{ title: 'ID', key: 'id', minWidth: 80 },
|
||||||
|
{
|
||||||
|
title: '关联账户',
|
||||||
|
key: 'pla_account',
|
||||||
|
minWidth: 200,
|
||||||
|
render: (h, params) => {
|
||||||
|
const id = params.row.pla_account_id
|
||||||
|
const pa = params.row.pla_account
|
||||||
|
if (id == null || id === '') {
|
||||||
|
return h('span', { style: { color: '#999' } }, '-')
|
||||||
|
}
|
||||||
|
if (pa && (pa.name || pa.sn_code)) {
|
||||||
|
const txt = `${pa.name || ''} (SN:${pa.sn_code || '-'})`
|
||||||
|
return h('span', { attrs: { title: `ID:${id} ${txt}` } }, txt)
|
||||||
|
}
|
||||||
|
return h('span', { attrs: { title: '仅ID,账户可能已删除' } }, `ID:${id}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
{ title: '职位类型名称', key: 'name', minWidth: 150 },
|
{ title: '职位类型名称', key: 'name', minWidth: 150 },
|
||||||
{ title: '描述', key: 'description', minWidth: 200 },
|
{ title: '描述', key: 'description', minWidth: 200 },
|
||||||
{
|
{
|
||||||
@@ -77,6 +97,11 @@ export default {
|
|||||||
{
|
{
|
||||||
title: '常见技能关键词', key: 'commonSkills', minWidth: 200,
|
title: '常见技能关键词', key: 'commonSkills', minWidth: 200,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '标题须含关键词',
|
||||||
|
key: 'titleIncludeKeywords',
|
||||||
|
minWidth: 200
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '排除关键词', key: 'excludeKeywords', minWidth: 200
|
title: '排除关键词', key: 'excludeKeywords', minWidth: 200
|
||||||
},
|
},
|
||||||
@@ -147,6 +172,22 @@ export default {
|
|||||||
placeholder: '请输入JSON数组格式,例如:["外包", "销售", "客服"]',
|
placeholder: '请输入JSON数组格式,例如:["外包", "销售", "客服"]',
|
||||||
tooltip: '排除关键词列表,JSON数组格式'
|
tooltip: '排除关键词列表,JSON数组格式'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '标题须含关键词',
|
||||||
|
key: 'titleIncludeKeywords',
|
||||||
|
com: 'TextArea',
|
||||||
|
required: false,
|
||||||
|
placeholder: '请输入JSON数组格式,例如:["售前", "工程师"]',
|
||||||
|
tooltip: 'JSON数组格式;仅匹配岗位标题,须同时包含每一项;与「常见技能关键词」无关'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '关联账户',
|
||||||
|
key: 'pla_account_id',
|
||||||
|
type: 'select',
|
||||||
|
required: false,
|
||||||
|
tooltip: '可选;与设备/账号绑定,AI 同步 Tab 时会写入',
|
||||||
|
options: []
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '是否启用',
|
title: '是否启用',
|
||||||
key: 'is_enabled',
|
key: 'is_enabled',
|
||||||
@@ -171,12 +212,35 @@ export default {
|
|||||||
seachTypePlaceholder() {
|
seachTypePlaceholder() {
|
||||||
const item = this.seachTypes.find(item => item.key === this.gridOption.param.seachOption.key)
|
const item = this.seachTypes.find(item => item.key === this.gridOption.param.seachOption.key)
|
||||||
return item ? `请输入${item.value}` : '请选择'
|
return item ? `请输入${item.value}` : '请选择'
|
||||||
|
},
|
||||||
|
editFormColumns() {
|
||||||
|
const accOpts = [{ value: '', label: '不关联' }, ...this.plaAccountOptions]
|
||||||
|
return this.editColumns.map((col) => {
|
||||||
|
if (col.key === 'pla_account_id') {
|
||||||
|
return { ...col, options: accOpts }
|
||||||
|
}
|
||||||
|
return col
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
this.loadPlaAccountOptions()
|
||||||
this.query(1)
|
this.query(1)
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
loadPlaAccountOptions() {
|
||||||
|
plaAccountServer.page({
|
||||||
|
pageOption: { page: 1, pageSize: 999 },
|
||||||
|
seachOption: {}
|
||||||
|
}).then((res) => {
|
||||||
|
if (res.code === 0 && res.data && res.data.rows) {
|
||||||
|
this.plaAccountOptions = res.data.rows.map((r) => ({
|
||||||
|
value: r.id,
|
||||||
|
label: `${r.name || ''} (SN:${r.sn_code || r.id})`
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}).catch(() => {})
|
||||||
|
},
|
||||||
query(page) {
|
query(page) {
|
||||||
if (page) {
|
if (page) {
|
||||||
this.gridOption.param.pageOption.page = page
|
this.gridOption.param.pageOption.page = page
|
||||||
@@ -217,6 +281,8 @@ export default {
|
|||||||
description: '',
|
description: '',
|
||||||
commonSkills: '[]',
|
commonSkills: '[]',
|
||||||
excludeKeywords: '[]',
|
excludeKeywords: '[]',
|
||||||
|
titleIncludeKeywords: '[]',
|
||||||
|
pla_account_id: '',
|
||||||
is_enabled: 1,
|
is_enabled: 1,
|
||||||
sort_order: 0
|
sort_order: 0
|
||||||
})
|
})
|
||||||
@@ -249,12 +315,26 @@ export default {
|
|||||||
excludeKeywords = JSON.stringify(excludeKeywords, null, 2)
|
excludeKeywords = JSON.stringify(excludeKeywords, null, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let titleIncludeKeywords = row.titleIncludeKeywords || '[]'
|
||||||
|
if (typeof titleIncludeKeywords === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(titleIncludeKeywords)
|
||||||
|
titleIncludeKeywords = JSON.stringify(parsed, null, 2)
|
||||||
|
} catch (e) {
|
||||||
|
// 保持原样
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
titleIncludeKeywords = JSON.stringify(titleIncludeKeywords, null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
this.$refs.editModal.editShow({
|
this.$refs.editModal.editShow({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
description: row.description || '',
|
description: row.description || '',
|
||||||
commonSkills: commonSkills,
|
commonSkills: commonSkills,
|
||||||
excludeKeywords: excludeKeywords,
|
excludeKeywords: excludeKeywords,
|
||||||
|
titleIncludeKeywords: titleIncludeKeywords,
|
||||||
|
pla_account_id: row.pla_account_id != null && row.pla_account_id !== '' ? row.pla_account_id : '',
|
||||||
is_enabled: row.is_enabled,
|
is_enabled: row.is_enabled,
|
||||||
sort_order: row.sort_order || 0
|
sort_order: row.sort_order || 0
|
||||||
})
|
})
|
||||||
@@ -281,7 +361,7 @@ export default {
|
|||||||
// 处理 JSON 字段
|
// 处理 JSON 字段
|
||||||
const formData = { ...data }
|
const formData = { ...data }
|
||||||
|
|
||||||
// 处理 commonSkills
|
// 处理 commonSkills(JSON 数组)
|
||||||
if (formData.commonSkills) {
|
if (formData.commonSkills) {
|
||||||
try {
|
try {
|
||||||
const parsed = typeof formData.commonSkills === 'string'
|
const parsed = typeof formData.commonSkills === 'string'
|
||||||
@@ -294,7 +374,7 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理 excludeKeywords
|
// 处理 excludeKeywords(JSON 数组)
|
||||||
if (formData.excludeKeywords) {
|
if (formData.excludeKeywords) {
|
||||||
try {
|
try {
|
||||||
const parsed = typeof formData.excludeKeywords === 'string'
|
const parsed = typeof formData.excludeKeywords === 'string'
|
||||||
@@ -307,6 +387,28 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理 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
|
const apiMethod = formData.id ? jobTypesServer.update : jobTypesServer.add
|
||||||
apiMethod(formData).then(res => {
|
apiMethod(formData).then(res => {
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
|
|||||||
@@ -5,6 +5,57 @@
|
|||||||
|
|
||||||
const Framework = require("../../framework/node-core-framework.js");
|
const Framework = require("../../framework/node-core-framework.js");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为 job_types 行批量附加 pla_account(列表/详情展示)
|
||||||
|
* @param {import('sequelize').Model[]} rowInstances
|
||||||
|
* @param {object} models
|
||||||
|
* @returns {Promise<object[]>}
|
||||||
|
*/
|
||||||
|
async function attachPlaAccountToJobTypeRows(rowInstances, models) {
|
||||||
|
const { pla_account, op } = models;
|
||||||
|
const plain = (rowInstances || []).map((r) => (r && typeof r.toJSON === 'function' ? r.toJSON() : r));
|
||||||
|
const ids = [...new Set(plain.map((row) => row.pla_account_id).filter((id) => id != null && id !== ''))];
|
||||||
|
if (!pla_account || ids.length === 0) {
|
||||||
|
return plain.map((row) => ({ ...row, pla_account: null }));
|
||||||
|
}
|
||||||
|
const accounts = await pla_account.findAll({
|
||||||
|
where: { id: { [op.in]: ids } },
|
||||||
|
attributes: ['id', 'name', 'sn_code', 'login_name']
|
||||||
|
});
|
||||||
|
const map = {};
|
||||||
|
accounts.forEach((a) => {
|
||||||
|
const j = a.toJSON();
|
||||||
|
map[j.id] = j;
|
||||||
|
});
|
||||||
|
return plain.map((row) => ({
|
||||||
|
...row,
|
||||||
|
pla_account: row.pla_account_id != null ? map[row.pla_account_id] || null : null
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {*} raw
|
||||||
|
* @param {object} pla_account
|
||||||
|
* @returns {Promise<{ ok: boolean, value?: number|null, message?: string }>}
|
||||||
|
*/
|
||||||
|
async function normalizePlaAccountId(raw, pla_account) {
|
||||||
|
if (raw === undefined) {
|
||||||
|
return { ok: true, skip: true };
|
||||||
|
}
|
||||||
|
if (raw === null || raw === '') {
|
||||||
|
return { ok: true, value: null };
|
||||||
|
}
|
||||||
|
const n = parseInt(raw, 10);
|
||||||
|
if (Number.isNaN(n) || n < 1) {
|
||||||
|
return { ok: false, message: '关联账户ID无效' };
|
||||||
|
}
|
||||||
|
const acc = await pla_account.findByPk(n);
|
||||||
|
if (!acc) {
|
||||||
|
return { ok: false, message: '关联账户不存在' };
|
||||||
|
}
|
||||||
|
return { ok: true, value: n };
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
@@ -37,14 +88,26 @@ module.exports = {
|
|||||||
const models = Framework.getModels();
|
const models = Framework.getModels();
|
||||||
const { job_types, op } = models;
|
const { job_types, op } = models;
|
||||||
const body = ctx.getBody();
|
const body = ctx.getBody();
|
||||||
const { name } = body;
|
const seachOption = body.seachOption || {};
|
||||||
|
|
||||||
// 获取分页参数
|
|
||||||
const { limit, offset } = ctx.getPageSize();
|
const { limit, offset } = ctx.getPageSize();
|
||||||
|
|
||||||
const where = {};
|
const where = {};
|
||||||
if (name) {
|
const key = seachOption.key || body.key;
|
||||||
where.name = { [op.like]: `%${name}%` };
|
const value = seachOption.value !== undefined && seachOption.value !== null ? seachOption.value : body.value;
|
||||||
|
if (key && value !== undefined && value !== null && String(value).trim() !== '') {
|
||||||
|
const v = String(value).trim();
|
||||||
|
if (key === 'pla_account_id') {
|
||||||
|
const n = parseInt(v, 10);
|
||||||
|
if (!Number.isNaN(n)) {
|
||||||
|
where.pla_account_id = n;
|
||||||
|
}
|
||||||
|
} else if (key === 'name' || key === 'description') {
|
||||||
|
where[key] = { [op.like]: `%${v}%` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (seachOption.is_enabled !== undefined && seachOption.is_enabled !== null) {
|
||||||
|
where.is_enabled = seachOption.is_enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await job_types.findAndCountAll({
|
const result = await job_types.findAndCountAll({
|
||||||
@@ -54,7 +117,8 @@ module.exports = {
|
|||||||
order: [['sort_order', 'ASC'], ['id', 'ASC']]
|
order: [['sort_order', 'ASC'], ['id', 'ASC']]
|
||||||
});
|
});
|
||||||
|
|
||||||
return ctx.success(result);
|
const rows = await attachPlaAccountToJobTypeRows(result.rows, models);
|
||||||
|
return ctx.success({ rows, count: result.count });
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -89,7 +153,8 @@ module.exports = {
|
|||||||
return ctx.fail('职位类型不存在');
|
return ctx.fail('职位类型不存在');
|
||||||
}
|
}
|
||||||
|
|
||||||
return ctx.success(jobType);
|
const [enriched] = await attachPlaAccountToJobTypeRows([jobType], models);
|
||||||
|
return ctx.success(enriched);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -120,6 +185,9 @@ module.exports = {
|
|||||||
* excludeKeywords:
|
* excludeKeywords:
|
||||||
* type: array
|
* type: array
|
||||||
* description: 排除关键词(JSON数组)
|
* description: 排除关键词(JSON数组)
|
||||||
|
* titleIncludeKeywords:
|
||||||
|
* type: array
|
||||||
|
* description: 职位标题须同时包含的子串(JSON数组),仅匹配岗位标题
|
||||||
* is_enabled:
|
* is_enabled:
|
||||||
* type: integer
|
* type: integer
|
||||||
* description: 是否启用(1=启用,0=禁用)
|
* description: 是否启用(1=启用,0=禁用)
|
||||||
@@ -132,9 +200,9 @@ module.exports = {
|
|||||||
*/
|
*/
|
||||||
'POST /job_type/create': async (ctx) => {
|
'POST /job_type/create': async (ctx) => {
|
||||||
const models = Framework.getModels();
|
const models = Framework.getModels();
|
||||||
const { job_types } = models;
|
const { job_types, pla_account } = models;
|
||||||
const body = ctx.getBody();
|
const body = ctx.getBody();
|
||||||
const { name, description, commonSkills, excludeKeywords, is_enabled, sort_order } = body;
|
const { name, description, commonSkills, excludeKeywords, titleIncludeKeywords, is_enabled, sort_order } = body;
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return ctx.fail('职位类型名称不能为空');
|
return ctx.fail('职位类型名称不能为空');
|
||||||
@@ -146,16 +214,30 @@ module.exports = {
|
|||||||
return ctx.fail('职位类型名称已存在');
|
return ctx.fail('职位类型名称已存在');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let pla_account_id = null;
|
||||||
|
const paResolved = await normalizePlaAccountId(body.pla_account_id, pla_account);
|
||||||
|
if (!paResolved.ok) {
|
||||||
|
return ctx.fail(paResolved.message || '关联账户校验失败');
|
||||||
|
}
|
||||||
|
if (!paResolved.skip) {
|
||||||
|
pla_account_id = paResolved.value === undefined ? null : paResolved.value;
|
||||||
|
}
|
||||||
|
|
||||||
const jobType = await job_types.create({
|
const jobType = await job_types.create({
|
||||||
name,
|
name,
|
||||||
description: description || '',
|
description: description || '',
|
||||||
commonSkills: Array.isArray(commonSkills) ? JSON.stringify(commonSkills) : (commonSkills || '[]'),
|
commonSkills: Array.isArray(commonSkills) ? JSON.stringify(commonSkills) : (commonSkills || '[]'),
|
||||||
excludeKeywords: Array.isArray(excludeKeywords) ? JSON.stringify(excludeKeywords) : (excludeKeywords || '[]'),
|
excludeKeywords: Array.isArray(excludeKeywords) ? JSON.stringify(excludeKeywords) : (excludeKeywords || '[]'),
|
||||||
|
titleIncludeKeywords: Array.isArray(titleIncludeKeywords)
|
||||||
|
? JSON.stringify(titleIncludeKeywords)
|
||||||
|
: (titleIncludeKeywords || '[]'),
|
||||||
|
pla_account_id,
|
||||||
is_enabled: is_enabled !== undefined ? is_enabled : 1,
|
is_enabled: is_enabled !== undefined ? is_enabled : 1,
|
||||||
sort_order: sort_order || 0
|
sort_order: sort_order || 0
|
||||||
});
|
});
|
||||||
|
|
||||||
return ctx.success(jobType);
|
const [enriched] = await attachPlaAccountToJobTypeRows([jobType], models);
|
||||||
|
return ctx.success(enriched);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -189,6 +271,9 @@ module.exports = {
|
|||||||
* excludeKeywords:
|
* excludeKeywords:
|
||||||
* type: array
|
* type: array
|
||||||
* description: 排除关键词(JSON数组)
|
* description: 排除关键词(JSON数组)
|
||||||
|
* titleIncludeKeywords:
|
||||||
|
* type: array
|
||||||
|
* description: 职位标题须同时包含的子串(JSON数组)
|
||||||
* is_enabled:
|
* is_enabled:
|
||||||
* type: integer
|
* type: integer
|
||||||
* description: 是否启用(1=启用,0=禁用)
|
* description: 是否启用(1=启用,0=禁用)
|
||||||
@@ -201,9 +286,9 @@ module.exports = {
|
|||||||
*/
|
*/
|
||||||
'POST /job_type/update': async (ctx) => {
|
'POST /job_type/update': async (ctx) => {
|
||||||
const models = Framework.getModels();
|
const models = Framework.getModels();
|
||||||
const { job_types } = models;
|
const { job_types, pla_account } = models;
|
||||||
const body = ctx.getBody();
|
const body = ctx.getBody();
|
||||||
const { id, name, description, commonSkills, excludeKeywords, is_enabled, sort_order } = body;
|
const { id, name, description, commonSkills, excludeKeywords, titleIncludeKeywords, is_enabled, sort_order } = body;
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return ctx.fail('职位类型ID不能为空');
|
return ctx.fail('职位类型ID不能为空');
|
||||||
@@ -231,16 +316,32 @@ module.exports = {
|
|||||||
if (excludeKeywords !== undefined) {
|
if (excludeKeywords !== undefined) {
|
||||||
updateData.excludeKeywords = Array.isArray(excludeKeywords) ? JSON.stringify(excludeKeywords) : excludeKeywords;
|
updateData.excludeKeywords = Array.isArray(excludeKeywords) ? JSON.stringify(excludeKeywords) : excludeKeywords;
|
||||||
}
|
}
|
||||||
|
if (titleIncludeKeywords !== undefined) {
|
||||||
|
updateData.titleIncludeKeywords = Array.isArray(titleIncludeKeywords)
|
||||||
|
? JSON.stringify(titleIncludeKeywords)
|
||||||
|
: titleIncludeKeywords;
|
||||||
|
}
|
||||||
|
if (body.pla_account_id !== undefined) {
|
||||||
|
const paResolved = await normalizePlaAccountId(body.pla_account_id, pla_account);
|
||||||
|
if (!paResolved.ok) {
|
||||||
|
return ctx.fail(paResolved.message || '关联账户校验失败');
|
||||||
|
}
|
||||||
|
if (!paResolved.skip) {
|
||||||
|
updateData.pla_account_id = paResolved.value === undefined ? null : paResolved.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (is_enabled !== undefined) updateData.is_enabled = is_enabled;
|
if (is_enabled !== undefined) updateData.is_enabled = is_enabled;
|
||||||
if (sort_order !== undefined) updateData.sort_order = sort_order;
|
if (sort_order !== undefined) updateData.sort_order = sort_order;
|
||||||
|
|
||||||
await job_types.update(updateData, { where: { id } });
|
await job_types.update(updateData, { where: { id } });
|
||||||
|
|
||||||
// 清除缓存
|
// 清除缓存
|
||||||
const jobFilterService = require('../middleware/job/job_filter_service.js');
|
const jobFilterService = require('../middleware/job/services/jobFilterService.js');
|
||||||
jobFilterService.clearCache(id);
|
jobFilterService.clearCache(id);
|
||||||
|
|
||||||
return ctx.success({ message: '职位类型更新成功' });
|
const updated = await job_types.findByPk(id);
|
||||||
|
const [enriched] = await attachPlaAccountToJobTypeRows([updated], models);
|
||||||
|
return ctx.success(enriched);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -289,7 +390,7 @@ module.exports = {
|
|||||||
await job_types.destroy({ where: { id } });
|
await job_types.destroy({ where: { id } });
|
||||||
|
|
||||||
// 清除缓存
|
// 清除缓存
|
||||||
const jobFilterService = require('../middleware/job/job_filter_service.js');
|
const jobFilterService = require('../middleware/job/services/jobFilterService.js');
|
||||||
jobFilterService.clearCache(id);
|
jobFilterService.clearCache(id);
|
||||||
|
|
||||||
return ctx.success({ message: '职位类型删除成功' });
|
return ctx.success({ message: '职位类型删除成功' });
|
||||||
|
|||||||
@@ -496,7 +496,7 @@ module.exports = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 仅保存投递标签:标签列表(来自 get_job_listings)+ 当前选中的标签
|
* 仅保存投递标签:标签列表(来自 get_job_listings)+ 当前选中的标签
|
||||||
* 只更新 resume_info 的 job_listings、deliver_tab_label,不碰其他配置
|
* 更新 resume_info 的 job_listings、deliver_tab_label,并同步 pla_account.keyword(与推荐/期望职位一致)
|
||||||
*/
|
*/
|
||||||
'POST /user/deliver-tab-label/save': async (ctx) => {
|
'POST /user/deliver-tab-label/save': async (ctx) => {
|
||||||
try {
|
try {
|
||||||
@@ -533,6 +533,10 @@ module.exports = {
|
|||||||
isActive: true
|
isActive: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const keywordSync = label.trim().slice(0, 50);
|
||||||
|
await pla_account.update({ keyword: keywordSync }, { where: { id: user.id } });
|
||||||
|
|
||||||
return ctx.success({ message: '投递标签已保存', job_listings: list, deliver_tab_label: label });
|
return ctx.success({ message: '投递标签已保存', job_listings: list, deliver_tab_label: label });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[保存投递标签失败]', error);
|
console.error('[保存投递标签失败]', error);
|
||||||
@@ -603,6 +607,13 @@ module.exports = {
|
|||||||
isActive: true
|
isActive: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (deliver_tab_label !== undefined) {
|
||||||
|
const keywordSync = (deliver_tab_label != null ? String(deliver_tab_label) : '')
|
||||||
|
.trim()
|
||||||
|
.slice(0, 50);
|
||||||
|
await pla_account.update({ keyword: keywordSync }, { where: { id: user.id } });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(updateData).length === 0 && deliver_tab_label === undefined && job_listings === undefined) {
|
if (Object.keys(updateData).length === 0 && deliver_tab_label === undefined && job_listings === undefined) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
const { Op } = require('sequelize');
|
||||||
const aiService = require('../../../services/ai_service');
|
const aiService = require('../../../services/ai_service');
|
||||||
const { jobFilterService } = require('../services');
|
const { jobFilterService } = require('../services');
|
||||||
const locationService = require('../../../services/locationService');
|
const locationService = require('../../../services/locationService');
|
||||||
@@ -5,6 +6,7 @@ const logs = require('../../logProxy');
|
|||||||
const db = require('../../dbProxy');
|
const db = require('../../dbProxy');
|
||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 工作管理模块
|
* 工作管理模块
|
||||||
* 负责简历获取、分析、存储和匹配度计算
|
* 负责简历获取、分析、存储和匹配度计算
|
||||||
@@ -13,6 +15,25 @@ class JobManager {
|
|||||||
constructor() {
|
constructor() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析设备 get_job_list / search_job_list 等返回的 response.data。
|
||||||
|
* 实际形态为「多页 XHR 监听结果」数组,与客户端一致,例如:
|
||||||
|
* `[{ url, method, status, data: { code: 0, message, zpData: { hasMore, jobList: [...] } } }, ...]`
|
||||||
|
* pageCount=3 时通常 3 条(每页一次 list.json),本方法将所有页的 jobList 合并为一维数组。
|
||||||
|
*/
|
||||||
|
_jobListFromRecommendMonitorData(responseData) {
|
||||||
|
if (!Array.isArray(responseData)) return [];
|
||||||
|
const jobs = [];
|
||||||
|
for (const item of responseData) {
|
||||||
|
const inner = item?.data;
|
||||||
|
if (!inner || typeof inner !== 'object') continue;
|
||||||
|
if (inner.code !== undefined && inner.code !== 0) continue;
|
||||||
|
const list = inner.zpData?.jobList;
|
||||||
|
if (Array.isArray(list)) jobs.push(...list);
|
||||||
|
}
|
||||||
|
return jobs;
|
||||||
|
}
|
||||||
|
|
||||||
// 启动客户端那个平台 用户信息,心跳机制
|
// 启动客户端那个平台 用户信息,心跳机制
|
||||||
async set_user_info(sn_code, mqttClient, user_info) {
|
async set_user_info(sn_code, mqttClient, user_info) {
|
||||||
const response = await mqttClient.publishAndWait(sn_code, {
|
const response = await mqttClient.publishAndWait(sn_code, {
|
||||||
@@ -151,8 +172,8 @@ class JobManager {
|
|||||||
* @returns {Promise<object>} 搜索结果
|
* @returns {Promise<object>} 搜索结果
|
||||||
*/
|
*/
|
||||||
async search_jobs_with_params(sn_code, mqttClient, params = {}) {
|
async search_jobs_with_params(sn_code, mqttClient, params = {}) {
|
||||||
const {
|
const {
|
||||||
keyword = '前端',
|
keyword = '前端',
|
||||||
platform = 'boss',
|
platform = 'boss',
|
||||||
city = '',
|
city = '',
|
||||||
cityName = '',
|
cityName = '',
|
||||||
@@ -199,28 +220,14 @@ class JobManager {
|
|||||||
throw new Error('多条件搜索职位失败');
|
throw new Error('多条件搜索职位失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理职位列表数据
|
const jobs = this._jobListFromRecommendMonitorData(response.data);
|
||||||
let jobs = [];
|
|
||||||
if (Array.isArray(response.data)) {
|
|
||||||
for (const item of response.data) {
|
|
||||||
if (item.data?.zpData?.jobList && Array.isArray(item.data.zpData.jobList)) {
|
|
||||||
jobs = jobs.concat(item.data.zpData.jobList);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (response.data?.data?.zpData?.jobList) {
|
|
||||||
jobs = response.data.data.zpData.jobList || [];
|
|
||||||
} else if (response.data?.zpData?.jobList) {
|
|
||||||
jobs = response.data.zpData.jobList || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[工作管理] 成功获取岗位数据,共 ${jobs.length} 个岗位`);
|
console.log(`[工作管理] 成功获取岗位数据,共 ${jobs.length} 个岗位`);
|
||||||
|
|
||||||
// 保存职位到数据库
|
// 保存职位到数据库
|
||||||
try {
|
|
||||||
await this.saveJobsToDatabase(sn_code, platform, keyword, jobs);
|
await this.saveJobsToDatabase(sn_code, platform, keyword, jobs);
|
||||||
} catch (error) {
|
|
||||||
console.error(`[工作管理] 保存职位到数据库失败:`, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
jobs: jobs,
|
jobs: jobs,
|
||||||
@@ -238,7 +245,7 @@ class JobManager {
|
|||||||
* @returns {Promise<object>} 执行结果
|
* @returns {Promise<object>} 执行结果
|
||||||
*/
|
*/
|
||||||
async search_and_deliver(sn_code, mqttClient, params = {}) {
|
async search_and_deliver(sn_code, mqttClient, params = {}) {
|
||||||
const {
|
const {
|
||||||
keyword,
|
keyword,
|
||||||
searchParams = {},
|
searchParams = {},
|
||||||
pageCount = 3,
|
pageCount = 3,
|
||||||
@@ -334,6 +341,31 @@ class JobManager {
|
|||||||
resumeInfo: resumeData
|
resumeInfo: resumeData
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 未通过规则/评分的待投递记录标记为 filtered,避免长期 pending
|
||||||
|
const passedIds = new Set(matchedJobs.map((j) => j.id).filter((id) => id != null));
|
||||||
|
const notPassedIds = searchedJobs
|
||||||
|
.map((row) => (row.toJSON ? row.toJSON() : row))
|
||||||
|
.map((j) => j.id)
|
||||||
|
.filter((id) => id != null && !passedIds.has(id));
|
||||||
|
if (notPassedIds.length > 0) {
|
||||||
|
try {
|
||||||
|
await job_postings.update(
|
||||||
|
{ applyStatus: 'filtered' },
|
||||||
|
{
|
||||||
|
where: {
|
||||||
|
id: { [Op.in]: notPassedIds },
|
||||||
|
sn_code,
|
||||||
|
platform,
|
||||||
|
applyStatus: 'pending'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
console.log(`[工作管理] 搜索并投递:不符合条件已标记 filtered ${notPassedIds.length} 条`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[工作管理] 标记 filtered 失败:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 7. 限制投递数量
|
// 7. 限制投递数量
|
||||||
const jobsToDeliver = matchedJobs.slice(0, maxCount);
|
const jobsToDeliver = matchedJobs.slice(0, maxCount);
|
||||||
console.log(`[工作管理] 匹配到 ${matchedJobs.length} 个职位,将投递 ${jobsToDeliver.length} 个`);
|
console.log(`[工作管理] 匹配到 ${matchedJobs.length} 个职位,将投递 ${jobsToDeliver.length} 个`);
|
||||||
@@ -351,8 +383,8 @@ class JobManager {
|
|||||||
let securityId = jobData.securityId || '';
|
let securityId = jobData.securityId || '';
|
||||||
try {
|
try {
|
||||||
if (jobData.originalData) {
|
if (jobData.originalData) {
|
||||||
const originalData = typeof jobData.originalData === 'string'
|
const originalData = typeof jobData.originalData === 'string'
|
||||||
? JSON.parse(jobData.originalData)
|
? JSON.parse(jobData.originalData)
|
||||||
: jobData.originalData;
|
: jobData.originalData;
|
||||||
securityId = originalData.securityId || securityId;
|
securityId = originalData.securityId || securityId;
|
||||||
}
|
}
|
||||||
@@ -414,110 +446,39 @@ class JobManager {
|
|||||||
}
|
}
|
||||||
const list = Array.isArray(response.data) ? response.data : [];
|
const list = Array.isArray(response.data) ? response.data : [];
|
||||||
console.log(`[工作管理] 获取 job_listings 成功,共 ${list.length} 个 tab`);
|
console.log(`[工作管理] 获取 job_listings 成功,共 ${list.length} 个 tab`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const jobTypeAiSyncService = require('../../../services/job_type_ai_sync_service');
|
||||||
|
await jobTypeAiSyncService.maybeSyncAfterListings(sn_code, list, platform);
|
||||||
|
} catch (syncErr) {
|
||||||
|
console.warn('[工作管理] job_types AI 同步失败:', syncErr.message);
|
||||||
|
}
|
||||||
|
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取岗位列表(支持多条件搜索)
|
* 获取岗位列表(与客户端/Electron 约定一致)
|
||||||
* @param {string} sn_code - 设备SN码
|
* @param {string} sn_code - 设备 SN
|
||||||
* @param {object} mqttClient - MQTT客户端
|
* @param {object} mqttClient - MQTT 客户端
|
||||||
* @param {object} params - 参数
|
* @param {object} params - { platform?, pageCount?, tabLabel?, keyword? };keyword 仅服务端入库用,下发设备只有 pageCount + tabLabel
|
||||||
* @returns {Promise<object>} 岗位列表
|
* @returns {Promise<object>} 岗位列表
|
||||||
*/
|
*/
|
||||||
async get_job_list(sn_code, mqttClient, params = {}) {
|
async get_job_list(sn_code, mqttClient, params = {}) {
|
||||||
const {
|
const { platform = 'boss', pageCount = 3, tabLabel } = params;
|
||||||
keyword = '前端',
|
const keyword = String(params.keyword || '').trim() || String(tabLabel || '').trim();
|
||||||
platform = 'boss',
|
|
||||||
pageCount = 3,
|
|
||||||
city = '',
|
|
||||||
cityName = '',
|
|
||||||
salary = '',
|
|
||||||
experience = '',
|
|
||||||
education = '',
|
|
||||||
industry = '',
|
|
||||||
companySize = '',
|
|
||||||
financingStage = '',
|
|
||||||
page = 1,
|
|
||||||
pageSize = 20
|
|
||||||
} = params;
|
|
||||||
|
|
||||||
// 判断是否是多条件搜索(如果包含多条件参数,使用多条件搜索逻辑)
|
const data = {
|
||||||
const hasMultiParams = city || cityName || salary || experience || education ||
|
pageCount,
|
||||||
industry || companySize || financingStage || page || pageSize;
|
...(String(tabLabel || '').trim() ? { tabLabel: String(tabLabel).trim() } : {})
|
||||||
|
};
|
||||||
|
|
||||||
if (hasMultiParams) {
|
console.log(`[工作管理] get_job_list ${sn_code} → 设备`, data, `入库 keyword=${keyword}`);
|
||||||
// 使用多条件搜索逻辑
|
|
||||||
console.log(`[工作管理] 开始多条件搜索设备 ${sn_code} 的职位,关键词: ${keyword}, 城市: ${cityName || city}`);
|
|
||||||
|
|
||||||
// 构建完整的搜索参数对象
|
|
||||||
const searchData = {
|
|
||||||
keyword,
|
|
||||||
pageCount
|
|
||||||
};
|
|
||||||
|
|
||||||
// 添加可选搜索条件
|
|
||||||
if (city) searchData.city = city;
|
|
||||||
if (cityName) searchData.cityName = cityName;
|
|
||||||
if (salary) searchData.salary = salary;
|
|
||||||
if (experience) searchData.experience = experience;
|
|
||||||
if (education) searchData.education = education;
|
|
||||||
if (industry) searchData.industry = industry;
|
|
||||||
if (companySize) searchData.companySize = companySize;
|
|
||||||
if (financingStage) searchData.financingStage = financingStage;
|
|
||||||
if (page) searchData.page = page;
|
|
||||||
if (pageSize) searchData.pageSize = pageSize;
|
|
||||||
|
|
||||||
// 通过MQTT指令获取岗位列表(保持action不变,前端已使用)
|
|
||||||
const response = await mqttClient.publishAndWait(sn_code, {
|
|
||||||
platform,
|
|
||||||
action: "get_job_list", // 保持与原有get_job_list相同的action,前端已使用
|
|
||||||
data: searchData
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response || response.code !== 200) {
|
|
||||||
console.error(`[工作管理] 多条件搜索职位失败:`, response);
|
|
||||||
throw new Error('多条件搜索职位失败');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理职位列表数据
|
|
||||||
let jobs = [];
|
|
||||||
if (Array.isArray(response.data)) {
|
|
||||||
for (const item of response.data) {
|
|
||||||
if (item.data?.zpData?.jobList && Array.isArray(item.data.zpData.jobList)) {
|
|
||||||
jobs = jobs.concat(item.data.zpData.jobList);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (response.data?.data?.zpData?.jobList) {
|
|
||||||
jobs = response.data.data.zpData.jobList || [];
|
|
||||||
} else if (response.data?.zpData?.jobList) {
|
|
||||||
jobs = response.data.zpData.jobList || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[工作管理] 成功获取岗位数据,共 ${jobs.length} 个岗位`);
|
|
||||||
|
|
||||||
// 保存职位到数据库
|
|
||||||
try {
|
|
||||||
await this.saveJobsToDatabase(sn_code, platform, keyword, jobs);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[工作管理] 保存职位到数据库失败:`, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
jobs: jobs,
|
|
||||||
keyword: keyword,
|
|
||||||
platform: platform,
|
|
||||||
count: jobs.length
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 简单搜索逻辑(保持原有逻辑)
|
|
||||||
console.log(`[工作管理] 开始获取设备 ${sn_code} 的岗位列表,关键词: ${keyword}`);
|
|
||||||
|
|
||||||
// 通过MQTT指令获取岗位列表
|
|
||||||
const response = await mqttClient.publishAndWait(sn_code, {
|
const response = await mqttClient.publishAndWait(sn_code, {
|
||||||
platform,
|
platform,
|
||||||
action: "get_job_list",
|
action: 'get_job_list',
|
||||||
data: { keyword, pageCount }
|
data
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response || response.code !== 200) {
|
if (!response || response.code !== 200) {
|
||||||
@@ -525,43 +486,19 @@ class JobManager {
|
|||||||
throw new Error('获取岗位列表失败');
|
throw new Error('获取岗位列表失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理职位列表数据:response.data 可能是数组(职位列表.json 格式)或单个对象
|
const jobs = this._jobListFromRecommendMonitorData(response.data);
|
||||||
let jobs = [];
|
|
||||||
|
|
||||||
if (Array.isArray(response.data)) {
|
|
||||||
// 如果是数组格式(职位列表.json),遍历每个元素提取岗位数据
|
|
||||||
for (const item of response.data) {
|
|
||||||
if (item.data?.zpData?.jobList && Array.isArray(item.data.zpData.jobList)) {
|
|
||||||
jobs = jobs.concat(item.data.zpData.jobList);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(`[工作管理] 从 ${response.data.length} 个响应中提取岗位数据`);
|
|
||||||
} else if (response.data?.data?.zpData?.jobList) {
|
|
||||||
// 如果是单个对象格式,从 data.zpData.jobList 获取
|
|
||||||
jobs = response.data.data.zpData.jobList || [];
|
|
||||||
} else if (response.data?.zpData?.jobList) {
|
|
||||||
// 兼容旧格式:直接从 zpData.jobList 获取
|
|
||||||
jobs = response.data.zpData.jobList || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[工作管理] 成功获取岗位数据,共 ${jobs.length} 个岗位`);
|
console.log(`[工作管理] 成功获取岗位数据,共 ${jobs.length} 个岗位`);
|
||||||
|
|
||||||
// 保存职位到数据库
|
|
||||||
try {
|
|
||||||
await this.saveJobsToDatabase(sn_code, platform, keyword, jobs);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[工作管理] 保存职位到数据库失败:`, error);
|
|
||||||
// 不影响主流程,继续返回数据
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = {
|
await this.saveJobsToDatabase(sn_code, platform, keyword, jobs);
|
||||||
jobs: jobs,
|
|
||||||
keyword: keyword,
|
|
||||||
platform: platform,
|
return {
|
||||||
|
jobs,
|
||||||
|
keyword,
|
||||||
|
platform,
|
||||||
count: jobs.length
|
count: jobs.length
|
||||||
};
|
};
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -577,89 +514,93 @@ class JobManager {
|
|||||||
console.log(`[工作管理] 开始保存 ${jobs.length} 个职位到数据库`);
|
console.log(`[工作管理] 开始保存 ${jobs.length} 个职位到数据库`);
|
||||||
|
|
||||||
for (const job of jobs) {
|
for (const job of jobs) {
|
||||||
try {
|
|
||||||
// 构建职位信息对象
|
|
||||||
const jobInfo = {
|
|
||||||
sn_code,
|
|
||||||
platform,
|
|
||||||
keyword,
|
|
||||||
|
|
||||||
// Boss直聘字段映射
|
// 构建职位信息对象
|
||||||
encryptBossId: job.encryptBossId || '',
|
const jobInfo = {
|
||||||
jobId: job.encryptJobId || '',
|
sn_code,
|
||||||
jobTitle: job.jobName || '',
|
platform,
|
||||||
companyId: job.encryptBrandId || '',
|
keyword,
|
||||||
companyName: job.brandName || '',
|
|
||||||
companySize: job.brandScaleName || '',
|
|
||||||
companyIndustry: job.brandIndustry || '',
|
|
||||||
salary: job.salaryDesc || '',
|
|
||||||
|
|
||||||
// 岗位要求(从 jobLabels 和 skills 提取)
|
// Boss直聘字段映射
|
||||||
jobRequirements: JSON.stringify({
|
encryptBossId: job.encryptBossId || '',
|
||||||
experience: job.jobExperience || '',
|
jobId: job.encryptJobId || '',
|
||||||
education: job.jobDegree || '',
|
jobTitle: job.jobName || '',
|
||||||
labels: job.jobLabels || [],
|
companyId: job.encryptBrandId || '',
|
||||||
skills: job.skills || []
|
companyName: job.brandName || '',
|
||||||
}),
|
companySize: job.brandScaleName || '',
|
||||||
|
companyIndustry: job.brandIndustry || '',
|
||||||
// 工作地点
|
salary: job.salaryDesc || '',
|
||||||
location: [job.cityName, job.areaDistrict, job.businessDistrict]
|
|
||||||
.filter(Boolean).join(' '),
|
|
||||||
|
|
||||||
|
// 岗位要求(从 jobLabels 和 skills 提取)
|
||||||
|
jobRequirements: JSON.stringify({
|
||||||
experience: job.jobExperience || '',
|
experience: job.jobExperience || '',
|
||||||
education: job.jobDegree || '',
|
education: job.jobDegree || '',
|
||||||
|
labels: job.jobLabels || [],
|
||||||
|
skills: job.skills || []
|
||||||
|
}),
|
||||||
|
|
||||||
// 原始数据
|
// 工作地点
|
||||||
originalData: JSON.stringify(job),
|
location: [job.cityName, job.areaDistrict, job.businessDistrict]
|
||||||
|
.filter(Boolean).join(' '),
|
||||||
|
|
||||||
// 默认状态
|
experience: job.jobExperience || '',
|
||||||
applyStatus: 'pending',
|
education: job.jobDegree || '',
|
||||||
chatStatus: 'none'
|
|
||||||
};
|
|
||||||
|
|
||||||
// 调用位置服务解析 location + companyName 获取坐标
|
// 原始数据
|
||||||
if (jobInfo.location && jobInfo.companyName) {
|
originalData: JSON.stringify(job),
|
||||||
const addressToParse = `${jobInfo.location.replaceAll(' ', '')}${jobInfo.companyName}`;
|
|
||||||
|
// 默认状态
|
||||||
|
applyStatus: 'pending',
|
||||||
|
chatStatus: 'none'
|
||||||
|
};
|
||||||
|
|
||||||
|
// 调用位置服务解析 location + companyName 获取坐标
|
||||||
|
if (jobInfo.location && jobInfo.companyName) {
|
||||||
|
const addressToParse = `${jobInfo.location.replaceAll(' ', '')}${jobInfo.companyName}`;
|
||||||
|
|
||||||
|
|
||||||
// 等待 1秒
|
// 等待 1秒
|
||||||
// await new Promise(resolve => setTimeout(resolve, 1000));
|
// await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
// const location = await locationService.getLocationByAddress(addressToParse).catch(error => {
|
||||||
|
// console.error(`[工作管理] 获取位置失败:`, error);
|
||||||
|
// });
|
||||||
|
|
||||||
// const location = await locationService.getLocationByAddress(addressToParse).catch(error => {
|
// if (location) {
|
||||||
// console.error(`[工作管理] 获取位置失败:`, error);
|
// jobInfo.latitude = String(location.lat);
|
||||||
// });
|
// jobInfo.longitude = String(location.lng);
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
// if (location) {
|
// 创建新职位 重复投递时间 从 pla_account 中获取(pla_account 列为 platform_type,不是 platform)
|
||||||
// jobInfo.latitude = String(location.lat);
|
const pla_account = db.getModel('pla_account');
|
||||||
// jobInfo.longitude = String(location.lng);
|
const account = await pla_account.findOne({
|
||||||
// }
|
where: {
|
||||||
|
sn_code: sn_code,
|
||||||
|
platform_type: platform
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 检查是否已存在(根据 jobId 和 sn_code)
|
let repeatDeliverDays = 30;
|
||||||
const existingJob = await job_postings.findOne({
|
if (account) {
|
||||||
where: {
|
let dc = account.deliver_config?.repeat_deliver_days||30;
|
||||||
jobId: jobInfo.jobId,
|
repeatDeliverDays = Number(dc);
|
||||||
sn_code: sn_code
|
}
|
||||||
|
|
||||||
|
const existingJob = await job_postings.findOne({
|
||||||
|
where: {
|
||||||
|
jobId: jobInfo.jobId,
|
||||||
|
sn_code: sn_code,
|
||||||
|
last_modify_time: {
|
||||||
|
[Op.gte]: new Date(Date.now() - repeatDeliverDays * 24 * 60 * 60 * 1000)
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
if (existingJob) {
|
|
||||||
// 更新现有职位
|
|
||||||
await job_postings.update(jobInfo, {
|
|
||||||
where: {
|
|
||||||
jobId: jobInfo.jobId,
|
|
||||||
sn_code: sn_code
|
|
||||||
}
|
|
||||||
});
|
|
||||||
console.log(`[工作管理] 职位已更新 - ${jobInfo.jobTitle} @ ${jobInfo.companyName}`);
|
|
||||||
} else {
|
|
||||||
// 创建新职位
|
|
||||||
await job_postings.create(jobInfo);
|
|
||||||
console.log(`[工作管理] 职位已创建 - ${jobInfo.jobTitle} @ ${jobInfo.companyName}`);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
});
|
||||||
console.error(`[工作管理] 保存职位失败:`, error, job);
|
|
||||||
// 继续处理下一个职位
|
if (existingJob) {
|
||||||
|
await job_postings.update(jobInfo, { where: { id: existingJob.id } });
|
||||||
|
} else {
|
||||||
|
await job_postings.create(jobInfo);
|
||||||
|
console.log(`[工作管理] 职位已创建 - ${jobInfo.jobTitle} @ ${jobInfo.companyName}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,15 +32,16 @@ class JobFilterService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据职位类型ID获取技能关键词和排除关键词
|
* 根据职位类型ID获取技能关键词、排除关键词、标题须含词
|
||||||
* @param {number} jobTypeId - 职位类型ID
|
* @param {number} jobTypeId - 职位类型ID
|
||||||
* @returns {Promise<{commonSkills: Array, excludeKeywords: Array}>}
|
* @returns {Promise<{commonSkills: Array, excludeKeywords: Array, titleIncludeKeywords: Array}>}
|
||||||
*/
|
*/
|
||||||
async getJobTypeConfig(jobTypeId) {
|
async getJobTypeConfig(jobTypeId) {
|
||||||
if (!jobTypeId) {
|
if (!jobTypeId) {
|
||||||
return {
|
return {
|
||||||
commonSkills: this.defaultCommonSkills,
|
commonSkills: this.defaultCommonSkills,
|
||||||
excludeKeywords: this.defaultExcludeKeywords
|
excludeKeywords: this.defaultExcludeKeywords,
|
||||||
|
titleIncludeKeywords: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +56,8 @@ class JobFilterService {
|
|||||||
console.warn('[职位过滤服务] job_types 模型不存在,使用默认配置');
|
console.warn('[职位过滤服务] job_types 模型不存在,使用默认配置');
|
||||||
return {
|
return {
|
||||||
commonSkills: this.defaultCommonSkills,
|
commonSkills: this.defaultCommonSkills,
|
||||||
excludeKeywords: this.defaultExcludeKeywords
|
excludeKeywords: this.defaultExcludeKeywords,
|
||||||
|
titleIncludeKeywords: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +69,8 @@ class JobFilterService {
|
|||||||
console.warn(`[职位过滤服务] 职位类型 ${jobTypeId} 不存在或已禁用,使用默认配置`);
|
console.warn(`[职位过滤服务] 职位类型 ${jobTypeId} 不存在或已禁用,使用默认配置`);
|
||||||
return {
|
return {
|
||||||
commonSkills: this.defaultCommonSkills,
|
commonSkills: this.defaultCommonSkills,
|
||||||
excludeKeywords: this.defaultExcludeKeywords
|
excludeKeywords: this.defaultExcludeKeywords,
|
||||||
|
titleIncludeKeywords: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +79,7 @@ class JobFilterService {
|
|||||||
// 解析 JSON 字段
|
// 解析 JSON 字段
|
||||||
let commonSkills = this.defaultCommonSkills;
|
let commonSkills = this.defaultCommonSkills;
|
||||||
let excludeKeywords = this.defaultExcludeKeywords;
|
let excludeKeywords = this.defaultExcludeKeywords;
|
||||||
|
let titleIncludeKeywords = [];
|
||||||
|
|
||||||
if (jobTypeData.commonSkills) {
|
if (jobTypeData.commonSkills) {
|
||||||
try {
|
try {
|
||||||
@@ -103,9 +107,23 @@ class JobFilterService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (jobTypeData.titleIncludeKeywords) {
|
||||||
|
try {
|
||||||
|
const parsed = typeof jobTypeData.titleIncludeKeywords === 'string'
|
||||||
|
? JSON.parse(jobTypeData.titleIncludeKeywords)
|
||||||
|
: jobTypeData.titleIncludeKeywords;
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
titleIncludeKeywords = parsed.map((k) => String(k || '').trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[职位过滤服务] 解析 titleIncludeKeywords 失败:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
commonSkills,
|
commonSkills,
|
||||||
excludeKeywords
|
excludeKeywords,
|
||||||
|
titleIncludeKeywords
|
||||||
};
|
};
|
||||||
|
|
||||||
// 缓存配置(缓存5分钟)
|
// 缓存配置(缓存5分钟)
|
||||||
@@ -119,7 +137,8 @@ class JobFilterService {
|
|||||||
console.error(`[职位过滤服务] 获取职位类型配置失败:`, error);
|
console.error(`[职位过滤服务] 获取职位类型配置失败:`, error);
|
||||||
return {
|
return {
|
||||||
commonSkills: this.defaultCommonSkills,
|
commonSkills: this.defaultCommonSkills,
|
||||||
excludeKeywords: this.defaultExcludeKeywords
|
excludeKeywords: this.defaultExcludeKeywords,
|
||||||
|
titleIncludeKeywords: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,15 +8,45 @@ class MqttSyncClient {
|
|||||||
constructor(brokerUrl, options = {}) {
|
constructor(brokerUrl, options = {}) {
|
||||||
this.client = mqtt.connect(brokerUrl, options)
|
this.client = mqtt.connect(brokerUrl, options)
|
||||||
this.isConnected = false
|
this.isConnected = false
|
||||||
|
/** @type {string[]} 需在每次 connect(含重连)后向 Broker 幂等订阅的主题 */
|
||||||
|
this._maintainedTopics = []
|
||||||
|
/** 最近一次收到任意 `response` 主题消息的时间(用于超时日志关联) */
|
||||||
|
this.lastResponseAt = null
|
||||||
|
|
||||||
// 使用 Map 结构优化消息监听器,按 topic 分组
|
// 使用 Map 结构优化消息监听器,按 topic 分组
|
||||||
this.messageListeners = new Map(); // Map<topic, Set<listener>>
|
this.messageListeners = new Map(); // Map<topic, Set<listener>>
|
||||||
this.globalListeners = new Set(); // 全局监听器(监听所有 topic)
|
this.globalListeners = new Set(); // 全局监听器(监听所有 topic)
|
||||||
|
|
||||||
|
const ts = () => new Date().toISOString()
|
||||||
|
const markDisconnected = (reason) => {
|
||||||
|
this.isConnected = false
|
||||||
|
console.warn(`[MQTT] ${ts()} 连接不可用 reason=${reason}`)
|
||||||
|
}
|
||||||
|
|
||||||
this.client.on('connect', () => {
|
this.client.on('connect', () => {
|
||||||
this.isConnected = true
|
this.isConnected = true
|
||||||
|
console.log(`[MQTT] ${ts()} 服务端已连接(含重连后的 connect)`)
|
||||||
|
this._resubscribeMaintainedTopics()
|
||||||
|
})
|
||||||
|
|
||||||
console.log('MQTT 服务端已连接')
|
this.client.on('reconnect', () => {
|
||||||
|
console.log(`[MQTT] ${ts()} 正在重连 Broker...`)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.client.on('offline', () => {
|
||||||
|
markDisconnected('offline')
|
||||||
|
})
|
||||||
|
|
||||||
|
this.client.on('disconnect', () => {
|
||||||
|
markDisconnected('disconnect')
|
||||||
|
})
|
||||||
|
|
||||||
|
this.client.on('close', () => {
|
||||||
|
markDisconnected('close')
|
||||||
|
})
|
||||||
|
|
||||||
|
this.client.on('end', () => {
|
||||||
|
markDisconnected('end')
|
||||||
})
|
})
|
||||||
|
|
||||||
this.client.on('message', (topic, message) => {
|
this.client.on('message', (topic, message) => {
|
||||||
@@ -29,6 +59,9 @@ class MqttSyncClient {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (topic === 'response') {
|
||||||
|
this.lastResponseAt = Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 触发该 topic 的专用监听器
|
// 1. 触发该 topic 的专用监听器
|
||||||
const topicListeners = this.messageListeners.get(topic);
|
const topicListeners = this.messageListeners.get(topic);
|
||||||
@@ -56,18 +89,52 @@ class MqttSyncClient {
|
|||||||
})
|
})
|
||||||
|
|
||||||
this.client.on('error', (err) => {
|
this.client.on('error', (err) => {
|
||||||
console.warn('[MQTT] Error:', err.message)
|
console.warn(`[MQTT] ${ts()} Error:`, err && err.message ? err.message : err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 与 mqtt.js 原生 connected 对齐,供单例健康检查
|
||||||
|
*/
|
||||||
|
isBrokerConnected() {
|
||||||
|
return !!(this.client && this.client.connected)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册需在每次 connect 后向 Broker 重新声明订阅的主题(不重复注册消息监听器)
|
||||||
|
* @param {string[]} topics
|
||||||
|
*/
|
||||||
|
setMaintainedTopics(topics) {
|
||||||
|
this._maintainedTopics = Array.isArray(topics) ? [...topics] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
_resubscribeMaintainedTopics() {
|
||||||
|
if (!this._maintainedTopics.length) return
|
||||||
|
if (!this.client || !this.client.connected) return
|
||||||
|
const ts = new Date().toISOString()
|
||||||
|
for (const topic of this._maintainedTopics) {
|
||||||
|
this.client.subscribe(topic, { qos: 0 }, (err, granted) => {
|
||||||
|
if (err) {
|
||||||
|
console.warn(`[MQTT] ${ts} ensureSubscriptions 订阅失败 topic=${topic}`, err.message || err)
|
||||||
|
} else {
|
||||||
|
console.log(`[MQTT] ${ts} ensureSubscriptions 已订阅 topic=${topic}`, granted)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
waitForConnect(timeout = 5000) {
|
waitForConnect(timeout = 5000) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (this.isConnected) return resolve()
|
if (this.isBrokerConnected()) {
|
||||||
|
this.isConnected = true
|
||||||
|
return resolve()
|
||||||
|
}
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
reject(new Error('MQTT connect timeout'))
|
reject(new Error('MQTT connect timeout'))
|
||||||
}, timeout)
|
}, timeout)
|
||||||
const check = () => {
|
const check = () => {
|
||||||
if (this.isConnected) {
|
if (this.isBrokerConnected()) {
|
||||||
|
this.isConnected = true
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
resolve()
|
resolve()
|
||||||
} else {
|
} else {
|
||||||
@@ -113,7 +180,6 @@ class MqttSyncClient {
|
|||||||
resolve(granted)
|
resolve(granted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
1
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -143,7 +209,12 @@ class MqttSyncClient {
|
|||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
this.removeMessageListener(onMessage);
|
this.removeMessageListener(onMessage);
|
||||||
reject(new Error('Timeout waiting for response'));
|
const last = this.lastResponseAt
|
||||||
|
const extra = last
|
||||||
|
? ` lastResponseAt=${new Date(last).toISOString()} brokerConnected=${this.isBrokerConnected()}`
|
||||||
|
: ` brokerConnected=${this.isBrokerConnected()}`
|
||||||
|
console.warn(`[MQTT] ${new Date().toISOString()} publishAndWait 超时 uuid=${uuid} topic=request_${sn_code}${extra}`)
|
||||||
|
reject(new Error('Timeout waiting for response' + (last ? `; lastResponseAt=${new Date(last).toISOString()}` : '')));
|
||||||
}, timeout);
|
}, timeout);
|
||||||
|
|
||||||
const onMessage = (topic, message) => {
|
const onMessage = (topic, message) => {
|
||||||
@@ -242,6 +313,7 @@ class MqttSyncClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
end(force = false) {
|
end(force = false) {
|
||||||
|
this.isConnected = false
|
||||||
this.client.end(force)
|
this.client.end(force)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,30 @@
|
|||||||
const MqttSyncClient = require('./mqttClient');
|
const MqttSyncClient = require('./mqttClient');
|
||||||
const Framework = require('../../../framework/node-core-framework');
|
const Framework = require('../../../framework/node-core-framework');
|
||||||
const logs = require('../logProxy');
|
const logs = require('../logProxy');
|
||||||
|
const appConfig = require('../../../config/config.js');
|
||||||
// action.js 已合并到 mqttDispatcher.js,不再需要单独引入
|
// action.js 已合并到 mqttDispatcher.js,不再需要单独引入
|
||||||
|
|
||||||
|
function buildMqttManagerConfig() {
|
||||||
|
const mqttCfg = appConfig.mqtt || {};
|
||||||
|
const brokerUrl = (mqttCfg.brokerUrl && String(mqttCfg.brokerUrl).trim())
|
||||||
|
? mqttCfg.brokerUrl.trim()
|
||||||
|
: `mqtt://${mqttCfg.host || '192.144.167.231'}:${mqttCfg.port != null ? mqttCfg.port : 1883}`;
|
||||||
|
const options = {
|
||||||
|
clientId: mqttCfg.clientId || `mqtt_server_${Math.random().toString(16).substr(2, 8)}`,
|
||||||
|
clean: mqttCfg.clean !== false,
|
||||||
|
connectTimeout: mqttCfg.connectTimeout != null ? mqttCfg.connectTimeout : 5000,
|
||||||
|
reconnectPeriod: mqttCfg.reconnectPeriod != null ? mqttCfg.reconnectPeriod : 5000,
|
||||||
|
keepalive: mqttCfg.keepalive != null ? mqttCfg.keepalive : 60
|
||||||
|
};
|
||||||
|
if (mqttCfg.username) {
|
||||||
|
options.username = mqttCfg.username;
|
||||||
|
}
|
||||||
|
if (mqttCfg.password) {
|
||||||
|
options.password = mqttCfg.password;
|
||||||
|
}
|
||||||
|
return { brokerUrl, options };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MQTT管理器 - 单例模式
|
* MQTT管理器 - 单例模式
|
||||||
* 负责管理MQTT连接,确保全局只有一个MQTT客户端实例
|
* 负责管理MQTT连接,确保全局只有一个MQTT客户端实例
|
||||||
@@ -11,16 +33,7 @@ class MqttManager {
|
|||||||
constructor() {
|
constructor() {
|
||||||
this.client = null;
|
this.client = null;
|
||||||
this.isInitialized = false;
|
this.isInitialized = false;
|
||||||
this.config = {
|
this.config = buildMqttManagerConfig();
|
||||||
brokerUrl: 'mqtt://192.144.167.231:1883', // MQTT Broker地址
|
|
||||||
options: {
|
|
||||||
clientId: `mqtt_server_${Math.random().toString(16).substr(2, 8)}`,
|
|
||||||
clean: true,
|
|
||||||
connectTimeout: 5000,
|
|
||||||
reconnectPeriod: 5000, // 自动重连间隔
|
|
||||||
keepalive: 10
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,8 +43,16 @@ class MqttManager {
|
|||||||
*/
|
*/
|
||||||
async getInstance(config = {}) {
|
async getInstance(config = {}) {
|
||||||
if (this.client && this.isInitialized) {
|
if (this.client && this.isInitialized) {
|
||||||
console.log('[MQTT管理器] 返回已存在的MQTT客户端实例');
|
const brokerOk = typeof this.client.isBrokerConnected === 'function'
|
||||||
return this.client;
|
? this.client.isBrokerConnected()
|
||||||
|
: this.client.isConnected;
|
||||||
|
if (!brokerOk) {
|
||||||
|
console.warn('[MQTT管理器] 单例已初始化但 Broker 未连接,重置并重建');
|
||||||
|
await this.reset();
|
||||||
|
} else {
|
||||||
|
console.log('[MQTT管理器] 返回已存在的MQTT客户端实例');
|
||||||
|
return this.client;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 合并配置
|
// 合并配置
|
||||||
@@ -91,7 +112,13 @@ class MqttManager {
|
|||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
isReady() {
|
isReady() {
|
||||||
return this.isInitialized && this.client && this.client.isConnected;
|
if (!this.isInitialized || !this.client) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (typeof this.client.isBrokerConnected === 'function') {
|
||||||
|
return this.client.isBrokerConnected();
|
||||||
|
}
|
||||||
|
return !!this.client.isConnected;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ class ScheduledJobs {
|
|||||||
auto_search: false,
|
auto_search: false,
|
||||||
auto_deliver: false,
|
auto_deliver: false,
|
||||||
auto_chat: false,
|
auto_chat: false,
|
||||||
auto_active: false
|
auto_active: false,
|
||||||
|
job_type_listings_ai: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,11 +112,23 @@ class ScheduledJobs {
|
|||||||
this.jobs.push(autoActiveJob);
|
this.jobs.push(autoActiveJob);
|
||||||
console.log('[定时任务] ✓ 已启动自动活跃任务 (每2小时)');
|
console.log('[定时任务] ✓ 已启动自动活跃任务 (每2小时)');
|
||||||
|
|
||||||
|
// 5. 每日拉取 get_job_listings 并用 AI 更新 job_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(() => {
|
setTimeout(() => {
|
||||||
console.log('[定时任务] 立即执行一次初始化任务...');
|
console.log('[定时任务] 立即执行一次初始化任务...');
|
||||||
this.runAutoDeliverTask();
|
this.runAutoDeliverTask();
|
||||||
this.runAutoChatTask();
|
this.runAutoChatTask();
|
||||||
|
this.runDailyJobTypeListingsAiSync().catch((err) => {
|
||||||
|
console.error('[定时任务] 启动时 job_types AI 同步失败:', err);
|
||||||
|
});
|
||||||
}, 10000); // 延迟10秒,等待系统初始化完成和设备心跳
|
}, 10000); // 延迟10秒,等待系统初始化完成和设备心跳
|
||||||
|
|
||||||
console.log('[定时任务] 所有定时任务启动完成!');
|
console.log('[定时任务] 所有定时任务启动完成!');
|
||||||
@@ -295,6 +308,80 @@ class ScheduledJobs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每日一次:对已绑定 job_type_id 且设备在线的账号下发 get_job_listings,成功后在 jobManager 内触发 AI 更新 job_types
|
||||||
|
*/
|
||||||
|
async runDailyJobTypeListingsAiSync() {
|
||||||
|
const key = 'job_type_listings_ai';
|
||||||
|
if (this._runningFlags[key]) {
|
||||||
|
console.log('[job_type_listings_ai] 上一次执行尚未完成,本次跳过');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._runningFlags[key] = true;
|
||||||
|
try {
|
||||||
|
const Sequelize = require('sequelize');
|
||||||
|
const { Op } = Sequelize;
|
||||||
|
const scheduleManager = require('../index');
|
||||||
|
const jobApi = require('../../job/index');
|
||||||
|
const mqtt = scheduleManager.mqttClient;
|
||||||
|
if (!mqtt) {
|
||||||
|
console.warn('[job_type_listings_ai] MQTT 未初始化,跳过');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pla_account } = db.models;
|
||||||
|
const accounts = await pla_account.findAll({
|
||||||
|
where: {
|
||||||
|
is_delete: 0,
|
||||||
|
is_enabled: 1,
|
||||||
|
job_type_id: { [Op.ne]: null }
|
||||||
|
},
|
||||||
|
attributes: ['id', 'sn_code', 'job_type_id', 'platform_type']
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!accounts || accounts.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const offlineThreshold = 3 * 60 * 1000;
|
||||||
|
let ok = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
for (const acc of accounts) {
|
||||||
|
const sn_code = acc.sn_code;
|
||||||
|
const device = deviceManager.devices.get(sn_code);
|
||||||
|
const lastHb = device && device.lastHeartbeat ? device.lastHeartbeat : 0;
|
||||||
|
const isOnline = device && device.isOnline && now - lastHb < offlineThreshold;
|
||||||
|
if (!isOnline) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const platform =
|
||||||
|
acc.platform_type ||
|
||||||
|
(typeof acc.getDataValue === 'function' && acc.getDataValue('platform_type')) ||
|
||||||
|
'boss';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await jobApi.get_job_listings(sn_code, mqtt, { platform });
|
||||||
|
ok++;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[job_type_listings_ai] 设备 ${sn_code} 失败:`, err.message);
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ok > 0 || skipped > 0) {
|
||||||
|
console.log(`[job_type_listings_ai] 完成: 成功 ${ok},跳过/失败 ${skipped},共 ${accounts.length} 个账号`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[job_type_listings_ai] 执行失败:', error);
|
||||||
|
} finally {
|
||||||
|
this._runningFlags[key] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取启用指定功能的账号列表
|
* 获取启用指定功能的账号列表
|
||||||
* @param {string} featureType - 功能类型: auto_search, auto_deliver, auto_chat, auto_active
|
* @param {string} featureType - 功能类型: auto_search, auto_deliver, auto_chat, auto_active
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class DeliverHandler extends BaseHandler {
|
|||||||
*/
|
*/
|
||||||
async doDeliver(task) {
|
async doDeliver(task) {
|
||||||
const { sn_code, taskParams } = task;
|
const { sn_code, taskParams } = task;
|
||||||
const { keyword, platform = 'boss', pageCount = 3, maxCount = 10, filterRules = {} } = taskParams;
|
const { keyword, platform = 'boss', pageCount = 3, maxCount = 10 } = taskParams;
|
||||||
|
|
||||||
console.log(`[自动投递] 开始 - 设备: ${sn_code}, 关键词: ${keyword}`);
|
console.log(`[自动投递] 开始 - 设备: ${sn_code}, 关键词: ${keyword}`);
|
||||||
|
|
||||||
@@ -89,9 +89,9 @@ class DeliverHandler extends BaseHandler {
|
|||||||
mqttClient: this.mqttClient
|
mqttClient: this.mqttClient
|
||||||
});
|
});
|
||||||
|
|
||||||
// 6. 下发 get_job_list 拉取职位列表(tabLabel 切换期望 tab,job_type_id 随指令下发供设备使用)
|
// 6. 下发 get_job_list(与前端一致:command 只带 pageCount + tabLabel,设备端不接收 keyword/job_type_id)
|
||||||
const tabLabel = resume.deliver_tab_label || '';
|
const tabLabel = resume.deliver_tab_label || '';
|
||||||
await this.getJobList(sn_code, platform, pageCount, task.id, tabLabel, accountConfig.job_type_id);
|
await this.getJobList(sn_code, platform, pageCount, task.id, tabLabel);
|
||||||
|
|
||||||
// 7. 从数据库获取待投递职位
|
// 7. 从数据库获取待投递职位
|
||||||
const pendingJobs = await this.getPendingJobs(sn_code, platform, actualMaxCount * 3);
|
const pendingJobs = await this.getPendingJobs(sn_code, platform, actualMaxCount * 3);
|
||||||
@@ -103,11 +103,12 @@ class DeliverHandler extends BaseHandler {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. 合并过滤配置
|
// 8. 过滤配置仅来自职位类型 job_types(排除词 / 标题须含词等),不与账号投递配置、任务参数混用
|
||||||
const filterConfig = this.mergeFilterConfig(deliverConfig, filterRules, jobTypeConfig);
|
const filterConfig = this.mergeFilterConfig(jobTypeConfig);
|
||||||
|
|
||||||
// 9. 过滤已投递的公司
|
// 9. 过滤已投递的公司(repeat_deliver_days 由投递配置给出,缺省 30,上限 365)
|
||||||
const recentCompanies = await this.getRecentDeliveredCompanies(sn_code, 30);
|
const repeatDeliverDays = Math.min(365, Math.max(1, Number(deliverConfig.repeat_deliver_days) || 30));
|
||||||
|
const recentCompanies = await this.getRecentDeliveredCompanies(sn_code, repeatDeliverDays);
|
||||||
|
|
||||||
// 10. 过滤 + 评分 + 按 60 分阈值筛(入口在 jobFilterEngine,便于阅读)
|
// 10. 过滤 + 评分 + 按 60 分阈值筛(入口在 jobFilterEngine,便于阅读)
|
||||||
const filteredJobs = await jobFilterEngine.filterAndScoreJobsForDeliver(
|
const filteredJobs = await jobFilterEngine.filterAndScoreJobsForDeliver(
|
||||||
@@ -119,6 +120,9 @@ class DeliverHandler extends BaseHandler {
|
|||||||
recentCompanies
|
recentCompanies
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 本轮未进入「可投递」列表的待投递记录,标记为已过滤,避免长期停留在 pending
|
||||||
|
await this.markFilteredJobsNotPassed(pendingJobs, filteredJobs, sn_code, platform);
|
||||||
|
|
||||||
const jobsToDeliver = filteredJobs.slice(0, actualMaxCount);
|
const jobsToDeliver = filteredJobs.slice(0, actualMaxCount);
|
||||||
|
|
||||||
console.log(`[自动投递] 职位筛选完成 - 原始: ${pendingJobs.length}, 符合条件: ${filteredJobs.length}, 将投递: ${jobsToDeliver.length}`);
|
console.log(`[自动投递] 职位筛选完成 - 原始: ${pendingJobs.length}, 符合条件: ${filteredJobs.length}, 将投递: ${jobsToDeliver.length}`);
|
||||||
@@ -273,22 +277,16 @@ class DeliverHandler extends BaseHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 下发 get_job_list 命令拉取职位列表
|
* 下发 get_job_list 命令拉取职位列表(command_params 与前端约定:pageCount、tabLabel + sn_code、platform)
|
||||||
* @param {string} tabLabel - 投递用期望标签文案,对应 resume_info.deliver_tab_label,get_job_list 会按此选择 tab
|
|
||||||
* @param {number} jobTypeId - 职位类型 ID,随指令下发供设备使用
|
|
||||||
*/
|
*/
|
||||||
async getJobList(sn_code, platform, pageCount, taskId, tabLabel = '', jobTypeId = null) {
|
async getJobList(sn_code, platform, pageCount, taskId, tabLabel = '') {
|
||||||
|
const label = tabLabel != null && String(tabLabel).trim() !== '' ? String(tabLabel).trim() : '';
|
||||||
const params = {
|
const params = {
|
||||||
sn_code,
|
sn_code,
|
||||||
platform,
|
platform,
|
||||||
pageCount
|
pageCount,
|
||||||
|
...(label ? { tabLabel: label } : {})
|
||||||
};
|
};
|
||||||
if (tabLabel != null && String(tabLabel).trim() !== '') {
|
|
||||||
params.tabLabel = String(tabLabel).trim();
|
|
||||||
}
|
|
||||||
if (jobTypeId != null && jobTypeId !== '') {
|
|
||||||
params.job_type_id = jobTypeId;
|
|
||||||
}
|
|
||||||
const getJobListCommand = {
|
const getJobListCommand = {
|
||||||
command_type: 'get_job_list',
|
command_type: 'get_job_list',
|
||||||
command_name: '获取职位列表',
|
command_name: '获取职位列表',
|
||||||
@@ -299,6 +297,44 @@ class DeliverHandler extends BaseHandler {
|
|||||||
await command.executeCommands(taskId, [getJobListCommand], this.mqttClient);
|
await command.executeCommands(taskId, [getJobListCommand], this.mqttClient);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将本批中未通过过滤/评分的职位从 pending 更新为 filtered(仍 pending 的仅为通过筛选且等待下轮投递的)
|
||||||
|
* @param {Array} pendingJobs - 本批拉取的待投递
|
||||||
|
* @param {Array} filteredJobs - filterAndScoreJobsForDeliver 通过的结果(含 matchScore)
|
||||||
|
*/
|
||||||
|
async markFilteredJobsNotPassed(pendingJobs, filteredJobs, sn_code, platform) {
|
||||||
|
if (!pendingJobs || pendingJobs.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const passedIds = new Set(
|
||||||
|
(filteredJobs || []).map((j) => j.id).filter((id) => id != null)
|
||||||
|
);
|
||||||
|
const notPassedIds = pendingJobs
|
||||||
|
.map((j) => (j && j.id != null ? j.id : null))
|
||||||
|
.filter((id) => id != null && !passedIds.has(id));
|
||||||
|
if (notPassedIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const job_postings = db.getModel('job_postings');
|
||||||
|
const { op } = db.models;
|
||||||
|
try {
|
||||||
|
const [n] = await job_postings.update(
|
||||||
|
{ applyStatus: 'filtered' },
|
||||||
|
{
|
||||||
|
where: {
|
||||||
|
id: { [op.in]: notPassedIds },
|
||||||
|
sn_code,
|
||||||
|
platform,
|
||||||
|
applyStatus: 'pending'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
console.log(`[自动投递] 不符合条件已标记 filtered: ${notPassedIds.length} 条(更新行数 ${n})`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[自动投递] 标记 filtered 失败:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取待投递职位
|
* 获取待投递职位
|
||||||
*/
|
*/
|
||||||
@@ -309,7 +345,11 @@ class DeliverHandler extends BaseHandler {
|
|||||||
where: {
|
where: {
|
||||||
sn_code,
|
sn_code,
|
||||||
platform,
|
platform,
|
||||||
applyStatus: 'pending'
|
applyStatus: 'pending',
|
||||||
|
create_time: {
|
||||||
|
[db.models.op.gte]: new Date(Date.now() - 1000 * 60 * 60 * 24)
|
||||||
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
order: [['create_time', 'DESC']],
|
order: [['create_time', 'DESC']],
|
||||||
limit
|
limit
|
||||||
@@ -319,39 +359,50 @@ class DeliverHandler extends BaseHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 合并过滤配置
|
* 自动投递过滤配置:仅使用 job_types(excludeKeywords、titleIncludeKeywords)
|
||||||
|
* 薪资筛选不在此合并(min/max 为 0 表示不做薪资过滤);评分权重仍走 accountConfig.is_salary_priority
|
||||||
*/
|
*/
|
||||||
mergeFilterConfig(deliverConfig, filterRules, jobTypeConfig) {
|
mergeFilterConfig(jobTypeConfig) {
|
||||||
// 排除关键词
|
const base = {
|
||||||
const rawJobTypeExclude = jobTypeConfig?.excludeKeywords
|
exclude_keywords: [],
|
||||||
? ConfigManager.parseConfig(jobTypeConfig.excludeKeywords, [])
|
filter_keywords: [],
|
||||||
: [];
|
title_include_keywords: [],
|
||||||
|
min_salary: 0,
|
||||||
const jobTypeExclude = Array.isArray(rawJobTypeExclude) ? rawJobTypeExclude : [];
|
max_salary: 0,
|
||||||
|
priority_weights: []
|
||||||
const deliverExcludeRaw = ConfigManager.getExcludeKeywords(deliverConfig);
|
|
||||||
const deliverExclude = Array.isArray(deliverExcludeRaw) ? deliverExcludeRaw : [];
|
|
||||||
const filterExcludeRaw = filterRules.excludeKeywords || [];
|
|
||||||
const filterExclude = Array.isArray(filterExcludeRaw) ? filterExcludeRaw : [];
|
|
||||||
|
|
||||||
// 过滤关键词
|
|
||||||
const deliverFilterRaw = ConfigManager.getFilterKeywords(deliverConfig);
|
|
||||||
const deliverFilter = Array.isArray(deliverFilterRaw) ? deliverFilterRaw : [];
|
|
||||||
const filterKeywordsRaw = filterRules.keywords || [];
|
|
||||||
const filterKeywords = Array.isArray(filterKeywordsRaw) ? filterKeywordsRaw : [];
|
|
||||||
|
|
||||||
// 薪资范围
|
|
||||||
const salaryRange = filterRules.minSalary || filterRules.maxSalary
|
|
||||||
? { min: filterRules.minSalary || 0, max: filterRules.maxSalary || 0 }
|
|
||||||
: ConfigManager.getSalaryRange(deliverConfig);
|
|
||||||
|
|
||||||
return {
|
|
||||||
exclude_keywords: [...jobTypeExclude, ...deliverExclude, ...filterExclude],
|
|
||||||
filter_keywords: filterKeywords.length > 0 ? filterKeywords : deliverFilter,
|
|
||||||
min_salary: salaryRange.min,
|
|
||||||
max_salary: salaryRange.max,
|
|
||||||
priority_weights: ConfigManager.getPriorityWeights(deliverConfig)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!jobTypeConfig) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jobTypeConfig.excludeKeywords) {
|
||||||
|
try {
|
||||||
|
const raw = jobTypeConfig.excludeKeywords;
|
||||||
|
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||||
|
base.exclude_keywords = Array.isArray(parsed) ? parsed.map((k) => String(k || '').trim()).filter(Boolean) : [];
|
||||||
|
} catch (e) {
|
||||||
|
base.exclude_keywords = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jobTypeConfig.titleIncludeKeywords != null) {
|
||||||
|
const v = jobTypeConfig.titleIncludeKeywords;
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
base.title_include_keywords = v.map((k) => String(k || '').trim()).filter(Boolean);
|
||||||
|
} else if (typeof v === 'string' && v.trim()) {
|
||||||
|
try {
|
||||||
|
const p = JSON.parse(v);
|
||||||
|
if (Array.isArray(p)) {
|
||||||
|
base.title_include_keywords = p.map((k) => String(k || '').trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -79,11 +79,10 @@ class SearchHandler extends BaseHandler {
|
|||||||
|
|
||||||
const commandParams = {
|
const commandParams = {
|
||||||
sn_code,
|
sn_code,
|
||||||
keyword: keyword || accountConfig.keyword || '',
|
|
||||||
platform: platformType,
|
platform: platformType,
|
||||||
pageCount: pageCount || searchConfig.page_count || 3
|
pageCount: pageCount || searchConfig.page_count || 3,
|
||||||
|
...(tabLabel ? { tabLabel } : {})
|
||||||
};
|
};
|
||||||
if (tabLabel) commandParams.tabLabel = tabLabel;
|
|
||||||
|
|
||||||
const searchCommand = {
|
const searchCommand = {
|
||||||
command_type: 'get_job_list',
|
command_type: 'get_job_list',
|
||||||
|
|||||||
@@ -141,6 +141,11 @@ class ScheduleManager {
|
|||||||
console.error('[调度管理器] 处理 Boss 消息失败:', error);
|
console.error('[调度管理器] 处理 Boss 消息失败:', error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 重连后向 Broker 幂等声明订阅(监听器仅在上方注册一次,不重复添加)
|
||||||
|
if (typeof this.mqttClient.setMaintainedTopics === 'function') {
|
||||||
|
this.mqttClient.setMaintainedTopics(['heartbeat', 'response', 'boss/message']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -152,7 +157,11 @@ class ScheduleManager {
|
|||||||
const status = this.mqttDispatcher ? this.mqttDispatcher.getSystemStatus() : {};
|
const status = this.mqttDispatcher ? this.mqttDispatcher.getSystemStatus() : {};
|
||||||
return {
|
return {
|
||||||
isInitialized: this.isInitialized,
|
isInitialized: this.isInitialized,
|
||||||
mqttConnected: this.mqttClient && this.mqttClient.isConnected,
|
mqttConnected: this.mqttClient && (
|
||||||
|
typeof this.mqttClient.isBrokerConnected === 'function'
|
||||||
|
? this.mqttClient.isBrokerConnected()
|
||||||
|
: this.mqttClient.isConnected
|
||||||
|
),
|
||||||
systemStats: deviceManager.getSystemStats(),
|
systemStats: deviceManager.getSystemStats(),
|
||||||
allDevices: deviceManager.getAllDevicesStatus(),
|
allDevices: deviceManager.getAllDevicesStatus(),
|
||||||
taskQueues: TaskQueue.getAllDeviceStatus(),
|
taskQueues: TaskQueue.getAllDeviceStatus(),
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ class ScheduleConfig {
|
|||||||
autoSearch: '0 0 */1 * * *', // 自动搜索任务:每1小时执行一次
|
autoSearch: '0 0 */1 * * *', // 自动搜索任务:每1小时执行一次
|
||||||
autoDeliver: '0 */2 * * * *', // 自动投递任务:每2分钟执行一次
|
autoDeliver: '0 */2 * * * *', // 自动投递任务:每2分钟执行一次
|
||||||
autoChat: '0 */1 * * * *', // 自动沟通任务:每1分钟执行一次
|
autoChat: '0 */1 * * * *', // 自动沟通任务:每1分钟执行一次
|
||||||
autoActive: '0 0 */2 * * *' // 自动活跃任务:每2小时执行一次
|
autoActive: '0 0 */2 * * *', // 自动活跃任务:每2小时执行一次
|
||||||
|
jobTypeListingsAi: '0 0 4 * * *' // 每天 04:00 对有 job_type_id 的在线设备拉取 get_job_listings 并 AI 更新 job_types
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ class ConfigManager {
|
|||||||
max_salary: 0, // 最高薪资
|
max_salary: 0, // 最高薪资
|
||||||
page_count: 3, // 搜索页数
|
page_count: 3, // 搜索页数
|
||||||
max_deliver: 10, // 最大投递数
|
max_deliver: 10, // 最大投递数
|
||||||
|
repeat_deliver_days: 30, // 多少天内已投递过的公司不再投递(与 getRecentDeliveredCompanies 一致)
|
||||||
filter_keywords: [], // 过滤关键词
|
filter_keywords: [], // 过滤关键词
|
||||||
exclude_keywords: [], // 排除关键词
|
exclude_keywords: [], // 排除关键词
|
||||||
time_range: null, // 时间范围
|
time_range: null, // 时间范围
|
||||||
|
|||||||
@@ -8,8 +8,21 @@ const db = require('../../dbProxy');
|
|||||||
* 自动投递只需调 filterAndScoreJobsForDeliver 一个方法。
|
* 自动投递只需调 filterAndScoreJobsForDeliver 一个方法。
|
||||||
*/
|
*/
|
||||||
class JobFilterEngine {
|
class JobFilterEngine {
|
||||||
|
getJobKey(job) {
|
||||||
|
return String(job.id || job.jobId || `${job.companyName || ''}|${job.jobTitle || ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getRemovedTitles(beforeJobs, afterJobs, limit = 5) {
|
||||||
|
const keptKeySet = new Set(afterJobs.map((job) => this.getJobKey(job)));
|
||||||
|
return beforeJobs
|
||||||
|
.filter((job) => !keptKeySet.has(this.getJobKey(job)))
|
||||||
|
.map((job) => job.jobTitle || '')
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 过滤职位列表(薪资 → 关键词 → 活跃度 → 去重)
|
* 过滤职位列表(薪资 → 标题须含词 → 关键词 → 活跃度 → 去重)
|
||||||
* @param {Array} jobs - 职位列表
|
* @param {Array} jobs - 职位列表
|
||||||
* @param {object} config - 过滤配置
|
* @param {object} config - 过滤配置
|
||||||
* @param {object} resumeInfo - 简历信息(未使用,兼容签名)
|
* @param {object} resumeInfo - 简历信息(未使用,兼容签名)
|
||||||
@@ -23,42 +36,56 @@ class JobFilterEngine {
|
|||||||
let filtered = [...jobs];
|
let filtered = [...jobs];
|
||||||
|
|
||||||
// 1. 薪资过滤
|
// 1. 薪资过滤
|
||||||
const beforeSalary = filtered.length;
|
const beforeSalaryJobs = [...filtered];
|
||||||
filtered = this.filterBySalary(filtered, config);
|
filtered = this.filterBySalary(filtered, config);
|
||||||
const salaryRemoved = beforeSalary - filtered.length;
|
const salaryRemoved = beforeSalaryJobs.length - filtered.length;
|
||||||
if (salaryRemoved > 0) {
|
if (salaryRemoved > 0) {
|
||||||
console.log(`[jobFilterEngine] 步骤1-薪资过滤: 输入${beforeSalary} 输出${filtered.length} 剔除${salaryRemoved} (范围: ${config.min_salary ?? 0}-${config.max_salary ?? 0}K)`);
|
const removedTitles = this.getRemovedTitles(beforeSalaryJobs, filtered);
|
||||||
|
console.log(`[jobFilterEngine] 步骤1-薪资过滤: 范围=${config.min_salary ?? 0}-${config.max_salary ?? 0}K 剔除标题=${removedTitles.join(' | ') || '无'}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 关键词过滤
|
// 2. 职位标题须包含(job_types.titleIncludeKeywords,仅 jobTitle,与 commonSkills 无关)
|
||||||
const beforeKeywords = filtered.length;
|
const beforeTitleFilterJobs = [...filtered];
|
||||||
|
filtered = this.filterByTitleIncludeKeywords(filtered, config);
|
||||||
|
const titleKwRemoved = beforeTitleFilterJobs.length - filtered.length;
|
||||||
|
if (titleKwRemoved > 0) {
|
||||||
|
const removedTitles = this.getRemovedTitles(beforeTitleFilterJobs, filtered);
|
||||||
|
console.log(`[jobFilterEngine] 步骤2-标题须含: 关键词=[${(config.title_include_keywords || []).join('、') || '无'}] 剔除标题=${removedTitles.join(' | ') || '无'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 关键词过滤(排除词 + filter_keywords,匹配标题与行业等)
|
||||||
|
const beforeKeywordFilterJobs = [...filtered];
|
||||||
filtered = this.filterByKeywords(filtered, config);
|
filtered = this.filterByKeywords(filtered, config);
|
||||||
const keywordsRemoved = beforeKeywords - filtered.length;
|
const keywordsRemoved = beforeKeywordFilterJobs.length - filtered.length;
|
||||||
if (keywordsRemoved > 0) {
|
if (keywordsRemoved > 0) {
|
||||||
console.log(`[jobFilterEngine] 步骤2-关键词过滤: 输入${beforeKeywords} 输出${filtered.length} 剔除${keywordsRemoved} (排除: ${(config.exclude_keywords || []).join(',') || '无'} 包含: ${(config.filter_keywords || []).join(',') || '无'})`);
|
const removedTitles = this.getRemovedTitles(beforeKeywordFilterJobs, filtered);
|
||||||
|
console.log(`[jobFilterEngine] 步骤3-关键词过滤: 排除=[${(config.exclude_keywords || []).join('、') || '无'}] 包含=[${(config.filter_keywords || []).join('、') || '无'}] 剔除标题=${removedTitles.join(' | ') || '无'}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 公司活跃度过滤
|
// 4. 公司活跃度过滤
|
||||||
if (config.filter_inactive_companies) {
|
if (config.filter_inactive_companies) {
|
||||||
const beforeActivity = filtered.length;
|
const beforeActivityJobs = [...filtered];
|
||||||
filtered = await this.filterByCompanyActivity(filtered, config.company_active_days || 7);
|
filtered = await this.filterByCompanyActivity(filtered, config.company_active_days || 7);
|
||||||
const activityRemoved = beforeActivity - filtered.length;
|
const activityRemoved = beforeActivityJobs.length - filtered.length;
|
||||||
if (activityRemoved > 0) {
|
if (activityRemoved > 0) {
|
||||||
console.log(`[jobFilterEngine] 步骤3-公司活跃度过滤: 输入${beforeActivity} 输出${filtered.length} 剔除${activityRemoved}`);
|
const removedTitles = this.getRemovedTitles(beforeActivityJobs, filtered);
|
||||||
|
console.log(`[jobFilterEngine] 步骤4-公司活跃度过滤: 剔除标题=${removedTitles.join(' | ') || '无'}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 去重(同一公司、同一职位名称)
|
// 5. 去重(同一公司、同一职位名称)
|
||||||
if (config.deduplicate) {
|
if (config.deduplicate) {
|
||||||
const beforeDedup = filtered.length;
|
const beforeDedupJobs = [...filtered];
|
||||||
filtered = this.deduplicateJobs(filtered);
|
filtered = this.deduplicateJobs(filtered);
|
||||||
const dedupRemoved = beforeDedup - filtered.length;
|
const dedupRemoved = beforeDedupJobs.length - filtered.length;
|
||||||
if (dedupRemoved > 0) {
|
if (dedupRemoved > 0) {
|
||||||
console.log(`[jobFilterEngine] 步骤4-去重: 输入${beforeDedup} 输出${filtered.length} 剔除${dedupRemoved}`);
|
const removedTitles = this.getRemovedTitles(beforeDedupJobs, filtered);
|
||||||
|
console.log(`[jobFilterEngine] 步骤5-去重: 剔除标题=${removedTitles.join(' | ') || '无'}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[jobFilterEngine] filterJobs 结束: 原始${jobs.length} 通过${filtered.length} 总剔除${jobs.length - filtered.length}`);
|
const keptTitles = filtered.map((j) => j.jobTitle || '').filter(Boolean).slice(0, 5);
|
||||||
|
console.log(`[jobFilterEngine] filterJobs 结束: 通过标题=${keptTitles.join(' | ') || '无'}`);
|
||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +161,29 @@ class JobFilterEngine {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 职位标题须包含配置中的关键词(命中任意一个即通过),不扫描描述/公司名/commonSkills
|
||||||
|
* @param {Array} jobs
|
||||||
|
* @param {object} config
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
filterByTitleIncludeKeywords(jobs, config) {
|
||||||
|
const kws = config.title_include_keywords;
|
||||||
|
if (!Array.isArray(kws) || kws.length === 0) {
|
||||||
|
return jobs;
|
||||||
|
}
|
||||||
|
return jobs.filter((job) => {
|
||||||
|
const title = `${job.jobTitle || ''}`.toLowerCase();
|
||||||
|
return kws.some((kw) => {
|
||||||
|
const k = String(kw || '').toLowerCase().trim();
|
||||||
|
if (!k) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return title.includes(k);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按关键词过滤
|
* 按关键词过滤
|
||||||
* @param {Array} jobs - 职位列表
|
* @param {Array} jobs - 职位列表
|
||||||
@@ -220,7 +270,7 @@ class JobFilterEngine {
|
|||||||
|
|
||||||
for (const job of jobs) {
|
for (const job of jobs) {
|
||||||
const company = (job.company || job.companyName || '').toLowerCase().trim();
|
const company = (job.company || job.companyName || '').toLowerCase().trim();
|
||||||
const jobName = (job.name || job.jobName || '').toLowerCase().trim();
|
const jobName = (job.jobTitle || '').toLowerCase().trim();
|
||||||
const key = `${company}||${jobName}`;
|
const key = `${company}||${jobName}`;
|
||||||
|
|
||||||
if (!seen.has(key)) {
|
if (!seen.has(key)) {
|
||||||
@@ -303,8 +353,8 @@ class JobFilterEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const jobText = [
|
const jobText = [
|
||||||
job.name || job.jobName || '',
|
job.jobTitle || '',
|
||||||
job.description || job.jobDescription || '',
|
job.jobDescription || '',
|
||||||
job.skills || ''
|
job.skills || ''
|
||||||
].join(' ');
|
].join(' ');
|
||||||
|
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ module.exports = (db) => {
|
|||||||
},
|
},
|
||||||
// 投递状态
|
// 投递状态
|
||||||
applyStatus: {
|
applyStatus: {
|
||||||
comment: '投递状态: pending-待投递, applied-已投递, rejected-被拒绝, accepted-已接受',
|
comment: '投递状态: pending-待投递, filtered-已过滤(不符合规则未投递), applied-已投递, rejected-被拒绝, accepted-已接受, success/failed-见业务',
|
||||||
type: Sequelize.STRING(20),
|
type: Sequelize.STRING(20),
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
defaultValue: 'pending'
|
defaultValue: 'pending'
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const Sequelize = require('sequelize');
|
|||||||
module.exports = (db) => {
|
module.exports = (db) => {
|
||||||
const job_types = db.define("job_types", {
|
const job_types = db.define("job_types", {
|
||||||
name: {
|
name: {
|
||||||
comment: '职位类型名称(如:前端开发、后端开发、全栈开发等)',
|
comment: '职位类型名称:须与 Boss 页 get_job_listings 返回的 Tab 文案 text 完全一致(投递标签)',
|
||||||
type: Sequelize.STRING(100),
|
type: Sequelize.STRING(100),
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
defaultValue: ''
|
defaultValue: ''
|
||||||
@@ -19,7 +19,7 @@ module.exports = (db) => {
|
|||||||
defaultValue: ''
|
defaultValue: ''
|
||||||
},
|
},
|
||||||
commonSkills: {
|
commonSkills: {
|
||||||
comment: '常见技能关键词(JSON数组)',
|
comment: '常见技能关键词(JSON数组),仅用于简历技能匹配/评分;职位标题过滤请用 titleIncludeKeywords',
|
||||||
type: Sequelize.TEXT,
|
type: Sequelize.TEXT,
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
defaultValue: '[]'
|
defaultValue: '[]'
|
||||||
@@ -30,6 +30,12 @@ module.exports = (db) => {
|
|||||||
allowNull: true,
|
allowNull: true,
|
||||||
defaultValue: '[]'
|
defaultValue: '[]'
|
||||||
},
|
},
|
||||||
|
titleIncludeKeywords: {
|
||||||
|
comment: '职位标题须包含的子串(JSON数组),仅按岗位标题匹配;commonSkills 仅用于简历技能匹配,不参与标题过滤',
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: '[]'
|
||||||
|
},
|
||||||
is_enabled: {
|
is_enabled: {
|
||||||
comment: '是否启用(1=启用,0=禁用)',
|
comment: '是否启用(1=启用,0=禁用)',
|
||||||
type: Sequelize.TINYINT(1),
|
type: Sequelize.TINYINT(1),
|
||||||
@@ -41,6 +47,12 @@ module.exports = (db) => {
|
|||||||
type: Sequelize.INTEGER,
|
type: Sequelize.INTEGER,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
defaultValue: 0
|
defaultValue: 0
|
||||||
|
},
|
||||||
|
pla_account_id: {
|
||||||
|
comment: '关联账户ID(pla_account.id,可选;AI 根据 get_job_listings 更新本行时写入)',
|
||||||
|
type: Sequelize.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: null
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
timestamps: false,
|
timestamps: false,
|
||||||
@@ -52,9 +64,15 @@ module.exports = (db) => {
|
|||||||
{
|
{
|
||||||
unique: false,
|
unique: false,
|
||||||
fields: ['name']
|
fields: ['name']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
unique: false,
|
||||||
|
fields: ['pla_account_id']
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// job_types.sync({ force: true });
|
||||||
|
|
||||||
return job_types;
|
return job_types;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,12 +21,6 @@ module.exports = (db) => {
|
|||||||
allowNull: true,
|
allowNull: true,
|
||||||
defaultValue: ''
|
defaultValue: ''
|
||||||
},
|
},
|
||||||
platform_type: {
|
|
||||||
comment: '平台',
|
|
||||||
type: Sequelize.STRING(50),
|
|
||||||
allowNull: false,
|
|
||||||
defaultValue: ''
|
|
||||||
},
|
|
||||||
login_name: {
|
login_name: {
|
||||||
comment: '登录名',
|
comment: '登录名',
|
||||||
type: Sequelize.STRING(50),
|
type: Sequelize.STRING(50),
|
||||||
@@ -40,7 +34,7 @@ module.exports = (db) => {
|
|||||||
defaultValue: ''
|
defaultValue: ''
|
||||||
},
|
},
|
||||||
keyword: {
|
keyword: {
|
||||||
comment: '关键词',
|
comment: '搜索/推荐职位关键词;保存投递期望标签时会与 resume_info.deliver_tab_label 同步',
|
||||||
type: Sequelize.STRING(50),
|
type: Sequelize.STRING(50),
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
defaultValue: ''
|
defaultValue: ''
|
||||||
|
|||||||
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,12 +481,11 @@ class PlaAccountService {
|
|||||||
? { ...baseParams, ...commandParams }
|
? { ...baseParams, ...commandParams }
|
||||||
: baseParams;
|
: baseParams;
|
||||||
|
|
||||||
// 如果有关键词相关的操作,添加关键词
|
if (commandTypeSnake === 'search_jobs' && account.keyword) {
|
||||||
if (['search_jobs', 'get_job_list'].includes(commandTypeSnake) && account.keyword) {
|
|
||||||
finalParams.keyword = account.keyword;
|
finalParams.keyword = account.keyword;
|
||||||
}
|
}
|
||||||
|
|
||||||
// get_job_list 从 resume_info 取 deliver_tab_label 作为 tabLabel 参数
|
// get_job_list:command 只约定 pageCount + tabLabel(与前端一致),入库 keyword 由 jobManager 用 tabLabel 推导
|
||||||
if (commandTypeSnake === 'get_job_list') {
|
if (commandTypeSnake === 'get_job_list') {
|
||||||
try {
|
try {
|
||||||
const resume_info = db.getModel('resume_info');
|
const resume_info = db.getModel('resume_info');
|
||||||
|
|||||||
@@ -71,13 +71,20 @@ module.exports = {
|
|||||||
"model": "qwen-plus"
|
"model": "qwen-plus"
|
||||||
},
|
},
|
||||||
|
|
||||||
// MQTT配置
|
// MQTT配置(Broker 地址、保活与重连与 Broker 策略对齐时可改此处或环境变量)
|
||||||
mqtt: {
|
mqtt: {
|
||||||
host: process.env.MQTT_HOST || 'localhost',
|
/** 完整连接串,优先于 host+port */
|
||||||
port: process.env.MQTT_PORT || 1883,
|
brokerUrl: process.env.MQTT_BROKER_URL || '',
|
||||||
|
host: process.env.MQTT_HOST || '192.144.167.231',
|
||||||
|
port: Number(process.env.MQTT_PORT || 1883),
|
||||||
username: process.env.MQTT_USERNAME || '',
|
username: process.env.MQTT_USERNAME || '',
|
||||||
password: process.env.MQTT_PASSWORD || '',
|
password: process.env.MQTT_PASSWORD || '',
|
||||||
clientId: 'autowork-' + Math.random().toString(16).substr(2, 8)
|
clientId: process.env.MQTT_CLIENT_ID || `mqtt_server_${Math.random().toString(16).substr(2, 8)}`,
|
||||||
|
clean: true,
|
||||||
|
connectTimeout: Number(process.env.MQTT_CONNECT_TIMEOUT || 5000),
|
||||||
|
reconnectPeriod: Number(process.env.MQTT_RECONNECT_PERIOD || 5000),
|
||||||
|
/** 秒;过小易被 Broker 策略影响,过大对断线感知慢 */
|
||||||
|
keepalive: Number(process.env.MQTT_KEEPALIVE || 60)
|
||||||
},
|
},
|
||||||
|
|
||||||
// 定时任务配置
|
// 定时任务配置
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user