This commit is contained in:
张成
2026-04-08 16:39:27 +08:00
parent 048c40d802
commit f2a8e61016
8 changed files with 597 additions and 66 deletions

View File

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

View File

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

View File

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

View File

@@ -112,7 +112,7 @@ class ScheduledJobs {
this.jobs.push(autoActiveJob);
console.log('[定时任务] ✓ 已启动自动活跃任务 (每2小时)');
// 5. 每日拉取 get_job_listings 并用 AI 更新 job_typesdescription / excludeKeywords / commonSkills
// 5. 每日拉取 get_job_listings 并用 AI 更新 job_typesdescription / excludeKeywords / commonSkills / titleIncludeKeywords
const jobTypeListingsAiJob = node_schedule.scheduleJob(config.schedules.jobTypeListingsAi || '0 0 4 * * *', () => {
this.runDailyJobTypeListingsAiSync().catch((err) => {
console.error('[定时任务] 每日 job_types AI 同步失败:', err);

View File

@@ -340,9 +340,27 @@ class DeliverHandler extends BaseHandler {
? { min: filterRules.minSalary || 0, max: filterRules.maxSalary || 0 }
: ConfigManager.getSalaryRange(deliverConfig);
let title_include_keywords = [];
if (jobTypeConfig && jobTypeConfig.titleIncludeKeywords != null) {
const v = jobTypeConfig.titleIncludeKeywords;
if (Array.isArray(v)) {
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)) {
title_include_keywords = p.map((k) => String(k || '').trim()).filter(Boolean);
}
} catch (e) {
/* ignore */
}
}
}
return {
exclude_keywords: [...jobTypeExclude, ...deliverExclude, ...filterExclude],
filter_keywords: filterKeywords.length > 0 ? filterKeywords : deliverFilter,
title_include_keywords,
min_salary: salaryRange.min,
max_salary: salaryRange.max,
priority_weights: ConfigManager.getPriorityWeights(deliverConfig)

View File

@@ -9,7 +9,7 @@ const db = require('../../dbProxy');
*/
class JobFilterEngine {
/**
* 过滤职位列表(薪资 → 关键词 → 活跃度 → 去重)
* 过滤职位列表(薪资 → 标题须含词 → 关键词 → 活跃度 → 去重)
* @param {Array} jobs - 职位列表
* @param {object} config - 过滤配置
* @param {object} resumeInfo - 简历信息(未使用,兼容签名)
@@ -30,31 +30,39 @@ class JobFilterEngine {
console.log(`[jobFilterEngine] 步骤1-薪资过滤: 输入${beforeSalary} 输出${filtered.length} 剔除${salaryRemoved} (范围: ${config.min_salary ?? 0}-${config.max_salary ?? 0}K)`);
}
// 2. 关键词过滤
// 2. 职位标题须包含job_types.titleIncludeKeywords仅 jobTitle/jobName/name与 commonSkills 无关)
const beforeTitleKw = filtered.length;
filtered = this.filterByTitleIncludeKeywords(filtered, config);
const titleKwRemoved = beforeTitleKw - filtered.length;
if (titleKwRemoved > 0) {
console.log(`[jobFilterEngine] 步骤2-标题须含: 输入${beforeTitleKw} 输出${filtered.length} 剔除${titleKwRemoved} (须同时含: ${(config.title_include_keywords || []).join(' · ') || '无'})`);
}
// 3. 关键词过滤(排除词 + filter_keywords匹配标题与行业等
const beforeKeywords = filtered.length;
filtered = this.filterByKeywords(filtered, config);
const keywordsRemoved = beforeKeywords - filtered.length;
if (keywordsRemoved > 0) {
console.log(`[jobFilterEngine] 步骤2-关键词过滤: 输入${beforeKeywords} 输出${filtered.length} 剔除${keywordsRemoved} (排除: ${(config.exclude_keywords || []).join(',') || '无'} 包含: ${(config.filter_keywords || []).join(',') || '无'})`);
console.log(`[jobFilterEngine] 步骤3-关键词过滤: 输入${beforeKeywords} 输出${filtered.length} 剔除${keywordsRemoved} (排除: ${(config.exclude_keywords || []).join(',') || '无'} 包含: ${(config.filter_keywords || []).join(',') || '无'})`);
}
// 3. 公司活跃度过滤
// 4. 公司活跃度过滤
if (config.filter_inactive_companies) {
const beforeActivity = filtered.length;
filtered = await this.filterByCompanyActivity(filtered, config.company_active_days || 7);
const activityRemoved = beforeActivity - filtered.length;
if (activityRemoved > 0) {
console.log(`[jobFilterEngine] 步骤3-公司活跃度过滤: 输入${beforeActivity} 输出${filtered.length} 剔除${activityRemoved}`);
console.log(`[jobFilterEngine] 步骤4-公司活跃度过滤: 输入${beforeActivity} 输出${filtered.length} 剔除${activityRemoved}`);
}
}
// 4. 去重(同一公司、同一职位名称)
// 5. 去重(同一公司、同一职位名称)
if (config.deduplicate) {
const beforeDedup = filtered.length;
filtered = this.deduplicateJobs(filtered);
const dedupRemoved = beforeDedup - filtered.length;
if (dedupRemoved > 0) {
console.log(`[jobFilterEngine] 步骤4-去重: 输入${beforeDedup} 输出${filtered.length} 剔除${dedupRemoved}`);
console.log(`[jobFilterEngine] 步骤5-去重: 输入${beforeDedup} 输出${filtered.length} 剔除${dedupRemoved}`);
}
}
@@ -134,6 +142,29 @@ class JobFilterEngine {
});
}
/**
* 职位标题须包含配置中的每个子串AND 关系),不扫描描述/公司名/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 || job.jobName || job.name || ''}`.toLowerCase();
return kws.every((kw) => {
const k = String(kw || '').toLowerCase().trim();
if (!k) {
return true;
}
return title.includes(k);
});
});
}
/**
* 按关键词过滤
* @param {Array} jobs - 职位列表

View File

@@ -7,7 +7,7 @@ const Sequelize = require('sequelize');
module.exports = (db) => {
const job_types = db.define("job_types", {
name: {
comment: '职位类型名称(如:前端开发、后端开发、全栈开发等',
comment: '职位类型名称:须与 Boss 页 get_job_listings 返回的 Tab 文案 text 完全一致(投递标签',
type: Sequelize.STRING(100),
allowNull: false,
defaultValue: ''
@@ -19,7 +19,7 @@ module.exports = (db) => {
defaultValue: ''
},
commonSkills: {
comment: '常见技能关键词JSON数组',
comment: '常见技能关键词JSON数组,仅用于简历技能匹配/评分;职位标题过滤请用 titleIncludeKeywords',
type: Sequelize.TEXT,
allowNull: true,
defaultValue: '[]'
@@ -30,6 +30,12 @@ module.exports = (db) => {
allowNull: true,
defaultValue: '[]'
},
titleIncludeKeywords: {
comment: '职位标题须包含的子串JSON数组仅按岗位标题匹配commonSkills 仅用于简历技能匹配,不参与标题过滤',
type: Sequelize.TEXT,
allowNull: true,
defaultValue: '[]'
},
is_enabled: {
comment: '是否启用1=启用0=禁用)',
type: Sequelize.TINYINT(1),
@@ -65,6 +71,8 @@ module.exports = (db) => {
}
]
});
// job_types.sync({ force: true });
return job_types;
}

View File

@@ -1,14 +1,62 @@
/**
* 根据 get_job_listings 返回的 Tab 列表,用 AI 更新 job_types 的 description / excludeKeywords / commonSkills
* 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');
/**
* 从模型回复中解析 JSON 对象
* @param {string} text
* @returns {object|null}
* @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 [];
const m = s.match(/^([\u4e00-\u9fff]{2})[\u4e00-\u9fff]{2,}/);
if (m) return [m[1]];
return [];
}
/**
* @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();
@@ -30,60 +78,254 @@ function parseJsonFromAi(text) {
}
/**
* 成功拉取 job_listings 后:若账号绑定了 job_type_id用 AI 更新对应 job_types
* @param {string} sn_code
* @param {Array<{ index?: number, text?: string }>} tabs
* @param {string} platform
* @returns {Promise<{ updated: boolean, jobTypeId?: number }|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职位名称标题中须同时包含的子串用于过滤岗位例如 Tab 为「售前工程师」时应有「售前」。勿把技能栈写进此数组。`;
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 = Array.isArray(parsed.titleIncludeKeywords)
? parsed.titleIncludeKeywords.map(String).map((s) => s.trim()).filter(Boolean)
: [];
}
} 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 = ['沟通', '协作'];
}
if (titleIncArr.length === 0) {
titleIncArr = deriveTitleIncludeKeywordsFromTabName(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 pla_account = db.getModel('pla_account');
const account = await pla_account.findOne({
where: { sn_code, is_delete: 0 }
});
if (!account || !account.job_type_id) {
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 jobType = await job_types.findByPk(account.job_type_id);
if (!jobType) {
console.warn('[job_type_ai_sync] job_types 不存在, id=', account.job_type_id);
return null;
}
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 更新');
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 typeName = jobType.name || '';
const labelsStr = tabTexts.join('、');
const typeName = jobType.name || '';
const prompt = `你是招聘平台求职助手。用户在某招聘网站的「期望职类/Tab」列表如下按顺序
${labelsStr}
当前在系统中登记的职位类型名称为:「${typeName}
当前重点维护的职位类型名称为:「${typeName}
平台标识:${platform}
请根据上述 Tab 名称,补充该求职方向的说明、自动投递时应排除的岗位关键词、以及该方向常见技能关键词。
请根据上述 Tab 名称,补充该求职方向的说明、自动投递时应排除的岗位关键词、常见技能关键词、以及职位标题须包含的子串
请只输出一段 JSON不要 Markdown 代码块,不要其它说明),格式严格如下:
{"description":"50~200字中文概括该求职方向","excludeKeywords":["关键词1","关键词2"],"commonSkills":["技能1","技能2"]}
{"description":"50~200字中文概括该求职方向","excludeKeywords":["关键词1","关键词2"],"commonSkills":["技能1","技能2"],"titleIncludeKeywords":["子串1","子串2"]}
要求:
- description面向求职者的简短说明。
- excludeKeywords5~12 个字符串,用于过滤明显不合适的岗位(如用户做研发可排除「纯销售」「客服」等,按 Tab 语义推断)
- commonSkills8~20 个字符串,该方向常见技能或技术栈关键词,用于匹配加分
- 所有字符串使用中文或业界通用英文技术词均可`;
- excludeKeywords5~12 个字符串,用于过滤明显不合适的岗位。
- commonSkills8~20 个字符串,该方向常见技能或技术栈关键词,用于简历技能匹配加分,不得替代标题关键词
- titleIncludeKeywords1~4 个短子串,投递时岗位标题须**同时包含**这些子串(如「售前工程师」对应含「售前」);勿把编程技能写进此数组`;
const { content } = await aiService.callAPI(prompt, {
systemPrompt: '你只输出合法 JSON 对象,键为 description、excludeKeywords、commonSkills不要输出其它文字。',
systemPrompt: '你只输出合法 JSON 对象,键为 description、excludeKeywords、commonSkills、titleIncludeKeywords,不要输出其它文字。',
temperature: 0.35,
maxTokens: 2000,
sn_code,
@@ -99,19 +341,26 @@ ${labelsStr}
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 = Array.isArray(parsed.titleIncludeKeywords)
? parsed.titleIncludeKeywords.map(String).map((s) => s.trim()).filter(Boolean)
: [];
if (titleIncArr.length === 0) {
titleIncArr = deriveTitleIncludeKeywordsFromTabName(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} pla_account_id=${account.id} exclude=${excludeArr.length} skills=${skillsArr.length}`
`[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 };
@@ -119,5 +368,8 @@ ${labelsStr}
module.exports = {
maybeSyncAfterListings,
parseJsonFromAi
ensureJobTypesForTabs,
parseJsonFromAi,
isRecommendTab,
deriveTitleIncludeKeywordsFromTabName
};