From 8ffe96c70df793291b1c99062a6987f5271b5a7e Mon Sep 17 00:00:00 2001 From: damingishere-coder Date: Wed, 2 Sep 2026 16:45:14 +0800 Subject: [PATCH 01/12] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20BOSS=20?= =?UTF-8?q?=E6=89=AB=E6=8F=8F=E5=8E=86=E5=8F=B2=E5=B2=97=E4=BD=8D=E5=88=97?= =?UTF-8?q?=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- chrome-extension/boss-content.js | 223 ++++++++----- chrome-extension/boss-scan-support.js | 56 +++- .../tests/boss-scan-support.test.cjs | 67 ++++ front/app/boss/analysis/AnalysisContent.tsx | 6 - .../boss/analysis/components/BossJobTable.tsx | 9 +- .../app/boss/analysis/hooks/useBossFilters.ts | 7 - front/app/boss/analysis/hooks/useCsvExport.ts | 2 + front/app/boss/analysis/types.ts | 3 +- front/app/boss/page.tsx | 18 +- front/app/boss/scan-result.test.ts | 18 ++ front/app/boss/scan-result.ts | 15 + .../controller/BossController.java | 299 ++++++++++++------ .../getjobs/application/dto/ChromeJobDto.java | 1 + .../application/entity/BossJobDataEntity.java | 3 + .../application/service/BossService.java | 40 ++- .../service/DatabaseSchemaService.java | 6 +- .../V11__add_boss_scan_result_source.sql | 10 + .../BossControllerListOnlyTest.java | 176 +++++++++++ .../mapper/BossStatsSqlProviderTest.java | 11 + .../service/BossServiceAiScoreFilterTest.java | 35 ++ .../service/BossServiceDedupeTest.java | 56 ++++ .../service/DatabaseMigrationTest.java | 32 +- 22 files changed, 883 insertions(+), 210 deletions(-) create mode 100644 front/app/boss/scan-result.test.ts create mode 100644 front/app/boss/scan-result.ts create mode 100644 src/main/resources/db/migration/V11__add_boss_scan_result_source.sql diff --git a/chrome-extension/boss-content.js b/chrome-extension/boss-content.js index fe89173..c3cb7b5 100644 --- a/chrome-extension/boss-content.js +++ b/chrome-extension/boss-content.js @@ -249,9 +249,9 @@ operation: "listCollect", keyword: result.keyword }); - const jobsToSave = dedupeResult.jobs; + const jobsToSave = [...dedupeResult.jobs, ...(dedupeResult.reusedJobs || [])]; if (!jobsToSave.length) { - const messageText = `Boss当前页采集完成:识别 ${result.parsedCount} 个岗位,全部属于无需补全的历史岗位,本次未新增数据。`; + const messageText = `Boss当前页采集完成:识别 ${result.parsedCount} 个岗位,没有可入库或可恢复的岗位。`; postProgress(message, "info", messageText, { operation: "listCollect", stage: "dedupe", @@ -292,7 +292,7 @@ .filter(([, count]) => Number(count) > 0) .map(([field, count]) => `${field}=${count}`) .join(","); - const successMessage = `Boss当前页采集完成:识别候选 ${result.candidateCount} 个,成功解析 ${result.parsedCount} 个,历史跳过 ${dedupeResult.skipCount} 个,后端入库 ${numberValue(data.saved)} 个,状态 LIST_COLLECTED,不进入AI分析。${missingMessage ? ` 缺失字段统计:${missingMessage}。` : ""}`; + const successMessage = `Boss当前页采集完成:识别候选 ${result.candidateCount} 个,成功解析 ${result.parsedCount} 个,恢复历史结果 ${numberValue(data.restored)} 个,后端入库 ${numberValue(data.saved)} 个,新岗位状态 LIST_COLLECTED,不进入AI分析。${missingMessage ? ` 缺失字段统计:${missingMessage}。` : ""}`; postProgress(message, "success", successMessage, { operation: "listCollect", stage: "listCollected", @@ -426,8 +426,9 @@ } const dedupeResult = await filterDuplicateJobs(candidates, { ...message, runId }, baseMeta); - if (!dedupeResult.jobs.length) { - const messageText = `${diagnosticText} 识别 ${candidates.length} 个岗位,全部为无需补全的历史岗位,本次未新增。`; + const jobsToSave = [...dedupeResult.jobs, ...(dedupeResult.reusedJobs || [])]; + if (!jobsToSave.length) { + const messageText = `${diagnosticText} 识别 ${candidates.length} 个岗位,没有可入库或可恢复的岗位。`; postProgress(message, "info", messageText, { ...baseMeta, stage: "dedupe", @@ -459,14 +460,14 @@ keyword, collectionMode: "LIST_ONLY", autoDeliver: false, - jobs: dedupeResult.jobs + jobs: jobsToSave }, { pageTabId: message?.pageTabId, timeoutMs: 60000 }); if (!data.success) throw new Error(data.message || "后端未接受 Boss API POC 岗位数据"); - const successMessage = `${diagnosticText} 候选 ${candidates.length} 个,历史跳过 ${dedupeResult.skipCount} 个,后端入库 ${numberValue(data.saved)} 个,状态 LIST_COLLECTED,不进入 AI 分析。`; + const successMessage = `${diagnosticText} 候选 ${candidates.length} 个,恢复历史结果 ${numberValue(data.restored)} 个,后端入库 ${numberValue(data.saved)} 个,新岗位状态 LIST_COLLECTED,不进入 AI 分析。`; postProgress(message, "success", successMessage, { ...baseMeta, stage: "listCollected", @@ -908,6 +909,7 @@ const city = first(config.cityCode, "101280600"); let currentIndex = normalizeTaskIndex(task.currentIndex, keywords.length); let totalSaved = Number(task.totalSaved || 0); + let totalRestored = Number(task.totalRestored || 0); if (!keywords.length) { throw new Error("Boss扫描缺少关键词,请先在Boss配置中填写关键词。"); @@ -934,6 +936,7 @@ phase: "searching", currentIndex: index, totalSaved, + totalRestored, navigationKey, navigationAttempts: nextNavigationAttempts, navigationStartedAt: Date.now(), @@ -945,7 +948,8 @@ keyword, keywordIndex: index + 1, keywordTotal: keywords.length, - totalSaved + totalSaved, + totalRestored }; writeScanStatus({ isRunning: true, @@ -957,6 +961,7 @@ keywordIndex: index + 1, keywordTotal: keywords.length, totalSaved, + totalRestored, startedAt: task.startedAt, updatedAt: Date.now() }); @@ -968,15 +973,19 @@ } const detailResult = await continueBossDetailScan(task, keyword, runId, baseMeta); if (detailResult.pendingNavigation || detailResult.blocked) return detailResult; + if (detailResult.success === false || detailResult.resumable) return detailResult; totalSaved = detailResult.totalSaved; + totalRestored = Number(detailResult.totalRestored ?? task.totalRestored ?? totalRestored ?? 0); if (!stopRequested) advanceKeywordCursor(task, index + 1, keyword); task = { ...task, phase: "nextKeyword", jobs: [], + historicalJobs: [], detailIndex: 0, currentIndex: index + 1, - totalSaved + totalSaved, + totalRestored }; storeScanTask(task); continue; @@ -1120,6 +1129,7 @@ const discoveryResult = await collectFreshJobsForKeyword(keyword, task, baseMeta, searchJobLimit, candidates); candidates = discoveryResult.candidates; const freshCandidates = discoveryResult.jobs; + const historicalCandidates = discoveryResult.historicalJobs || []; const detailReadyCandidates = freshCandidates.filter(isDetailQueueJob); const invalidDetailCandidates = Math.max(0, freshCandidates.length - detailReadyCandidates.length); const jobs = detailReadyCandidates.slice(0, searchJobLimit); @@ -1137,7 +1147,7 @@ discoveryRounds: discoveryResult.rounds, stoppedByStagnation: discoveryResult.stoppedByStagnation }); - if (!jobs.length) { + if (!jobs.length && !historicalCandidates.length) { postProgress(task, "warning", `Boss关键词 ${keyword} 已继续向下采集,但没有找到新的可分析岗位,跳过本关键词。`, { ...baseMeta, stage: "dedupe", @@ -1153,7 +1163,7 @@ } const diagnostics = buildListDiagnostics(); - postProgress(task, "info", `Boss Chrome采集到 ${discoveryResult.candidateCount} 个候选岗位,学历过滤 ${discoveryResult.filteredCount} 个,历史跳过 ${discoveryResult.skipCount} 个,剩余 ${freshCandidates.length} 个可分析岗位,将进入前 ${jobs.length}/${searchJobLimit} 个详情页做AI比对。详情链接 ${diagnostics.detailLinks} 个。`, { + postProgress(task, "info", `Boss Chrome采集到 ${discoveryResult.candidateCount} 个候选岗位,学历过滤 ${discoveryResult.filteredCount} 个,历史结果 ${historicalCandidates.length} 个,剩余 ${freshCandidates.length} 个可分析岗位,将进入前 ${jobs.length}/${searchJobLimit} 个详情页做AI比对。详情链接 ${diagnostics.detailLinks} 个。`, { ...baseMeta, stage: "details", collected: jobs.length, @@ -1175,6 +1185,7 @@ phase: "detail", detailIndex: 0, jobs, + historicalJobs: historicalCandidates, searchUrl: url }; storeScanTask(detailTask); @@ -1202,22 +1213,29 @@ isRunning: false, stopRequested: stopped, stage: stopped ? "stopped" : "complete", - message: stopped ? `Boss Chrome扫描已停止,已提交 ${totalSaved} 个岗位` : `Boss Chrome扫描完成,已提交 ${totalSaved} 个岗位`, + message: stopped + ? `Boss Chrome扫描已停止:新入库 ${totalSaved} 个,恢复历史结果 ${totalRestored} 个` + : `Boss Chrome扫描完成:新入库 ${totalSaved} 个,恢复历史结果 ${totalRestored} 个`, runId, keywordTotal: keywords.length, totalSaved, + totalRestored, saved: totalSaved, startedAt: task.startedAt, updatedAt: Date.now() }); - postProgress(task, stopped ? "warning" : "success", stopped ? `Boss Chrome扫描已停止,已提交 ${totalSaved} 个岗位` : `Boss Chrome扫描完成,已提交 ${totalSaved} 个岗位`, { + postProgress(task, stopped ? "warning" : "success", stopped + ? `Boss Chrome扫描已停止:新入库 ${totalSaved} 个,恢复历史结果 ${totalRestored} 个` + : `Boss Chrome扫描完成:新入库 ${totalSaved} 个,恢复历史结果 ${totalRestored} 个`, { operation: "scan", stage: stopped ? "stopped" : "complete", keywordTotal: keywords.length, totalSaved, + totalRestored, + restored: totalRestored, saved: totalSaved }); - return { success: true, saved: totalSaved }; + return { success: true, saved: totalSaved, restored: totalRestored }; } function buildSearchUrl(keyword, city, config) { @@ -1475,6 +1493,7 @@ const duplicateKeys = new Set(); const enrichKeys = new Set(); const skipKeys = new Set(); + const historicalJobs = new Map(); const conditionFilteredKeys = new Set(); let lastUniqueCount = -1; let lastScrollSignature = ""; @@ -1517,9 +1536,10 @@ if (action === "ENRICH") enrichKeys.add(key); if (action === "SKIP") { skipKeys.add(key); + historicalJobs.set(key, { ...job, collectionAction: "REUSE_HISTORY" }); return; } - processableJobs.set(key, job); + processableJobs.set(key, { ...job, collectionAction: "ANALYZE" }); }); const currentScrollSignature = bossScrollSignature(); @@ -1551,6 +1571,7 @@ return { candidates: Array.from(allCandidates.values()), jobs: Array.from(processableJobs.values()).slice(0, target), + historicalJobs: Array.from(historicalJobs.values()).slice(0, target), candidateCount: allCandidates.size, filteredCount: conditionFilteredKeys.size, duplicateCount: duplicateKeys.size, @@ -1640,7 +1661,7 @@ async function filterDuplicateJobs(jobs, message, baseMeta) { const list = Array.isArray(jobs) ? jobs : []; - if (!list.length) return { jobs: [], duplicateCount: 0, enrichCount: 0, skipCount: 0, items: [] }; + if (!list.length) return { jobs: [], reusedJobs: [], duplicateCount: 0, enrichCount: 0, skipCount: 0, items: [] }; try { const data = await callBossLocalApi("chrome-jobs-dedupe", { @@ -1650,37 +1671,24 @@ }, { pageTabId: message?.pageTabId }); - if (!data.success || !Array.isArray(data.items)) throw new Error(data.message || "查重接口返回异常"); + if (data.success !== true || !Array.isArray(data.items)) throw new Error(data.message || "查重接口返回异常"); - const decisions = new Map(data.items.map((item) => [dedupeItemKey(item), String(item.action || (item.duplicate ? "SKIP" : "NEW"))])); - const freshJobs = list.filter((job) => decisions.get(dedupeJobKey(job)) !== "SKIP"); + const partition = SCAN_SUPPORT.partitionDedupeJobs(list, data.items); return { - jobs: freshJobs, + jobs: partition.detailJobs, + reusedJobs: partition.historicalJobs, duplicateCount: Number(data.duplicateCount ?? 0), enrichCount: Number(data.enrichCount ?? data.items.filter((item) => item.action === "ENRICH").length), skipCount: Number(data.skipCount ?? data.items.filter((item) => item.action === "SKIP").length), items: data.items }; } catch (error) { - postProgress(message, "warning", `Boss重复岗位检查失败,将继续扫描本页岗位:${error.message || String(error)}`, { + postProgress(message, "error", `Boss重复岗位检查失败,已停止本轮岗位入队:${error.message || String(error)}`, { ...baseMeta, stage: "dedupe", collected: list.length }); - return { - jobs: list, - duplicateCount: 0, - enrichCount: 0, - skipCount: 0, - items: list.map((job) => ({ - id: job?.id || extractBossId(job?.url), - url: job?.url || "", - title: job?.title || "", - company: job?.company || "", - duplicate: false, - action: "NEW" - })) - }; + throw new Error(`Boss重复岗位检查失败,未执行入队:${error.message || String(error)}`); } } @@ -2024,6 +2032,7 @@ async function continueBossDetailScan(message, keyword, runId, baseMeta) { const jobs = Array.isArray(message.jobs) ? message.jobs : []; + const historicalJobs = Array.isArray(message.historicalJobs) ? message.historicalJobs : []; const detailIndex = Number(message.detailIndex || 0); const totalSaved = Number(message.totalSaved || 0); @@ -2035,17 +2044,32 @@ return { success: true, totalSaved }; } - if (!jobs.length) { - advanceKeywordCursor(message, Number(message.currentIndex || 0) + 1, keyword); - storeScanTask({ ...message, phase: "", currentIndex: Number(message.currentIndex || 0) + 1, totalSaved }); - return { success: true, totalSaved }; - } - if (message.phase === "submitting") { - const submitJobs = jobs.filter(isSubmittableJob).map(normalizeJobForSubmit); + const submitJobs = [...jobs, ...historicalJobs].filter(isSubmittableJob).map(normalizeJobForSubmit); return submitCollectedBossJobs(submitJobs, message, keyword, runId, baseMeta, totalSaved); } + if (!jobs.length) { + const submitJobs = historicalJobs.filter(isSubmittableJob).map(normalizeJobForSubmit); + if (!submitJobs.length) { + advanceKeywordCursor(message, Number(message.currentIndex || 0) + 1, keyword); + storeScanTask({ ...message, phase: "", historicalJobs: [], currentIndex: Number(message.currentIndex || 0) + 1, totalSaved }); + return { success: true, totalSaved }; + } + const submittingTask = { + ...message, + phase: "submitting", + jobs: [], + historicalJobs, + detailIndex: 0, + submitBatchIndex: 0, + submitSummary: null, + totalSaved + }; + storeScanTask(submittingTask); + return submitCollectedBossJobs(submitJobs, submittingTask, keyword, runId, baseMeta, totalSaved); + } + const currentJob = jobs[detailIndex]; if (currentJob && !isDetailQueueJob(currentJob)) { jobs[detailIndex] = markBossDetailNavigationFailed( @@ -2226,7 +2250,7 @@ } const detailSummary = summarizeJobCollection(jobs); - const submitJobs = jobs.filter(isSubmittableJob).map(normalizeJobForSubmit); + const submitJobs = [...jobs, ...historicalJobs].filter(isSubmittableJob).map(normalizeJobForSubmit); if (isStopRequested(runId)) { stopRequested = true; clearStoredScanTask(); @@ -2253,6 +2277,7 @@ ...message, phase: "nextKeyword", jobs: [], + historicalJobs: [], detailIndex: 0, currentIndex: Number(message.currentIndex || 0) + 1, totalSaved @@ -2264,6 +2289,7 @@ ...message, phase: "submitting", jobs, + historicalJobs, detailIndex: jobs.length - 1, detailNavigationKey: "", detailNavigationAttempts: 0, @@ -2368,13 +2394,43 @@ keyword, autoDeliver: isAutoDeliverEnabled(message) }); - if (!data.success) throw new Error(data.message || "Boss岗位提交失败"); + if (data.success !== true) { + const storedTask = readStoredScanTask() || message; + const diagnosticType = String(storedTask?.lastSubmitError?.type || "LOCAL_API_ERROR"); + const failureMessage = data.message || "Boss岗位提交失败"; + writeScanStatus({ + isRunning: false, + stopRequested: false, + stage: "blocked", + paused: true, + resumable: true, + diagnosticType, + message: `${failureMessage} 扫描断点将在24小时内保留。`, + runId: storedTask?.runId || runId, + totalSaved, + totalRestored: numberValue(message.totalRestored), + startedAt: storedTask?.startedAt || message?.startedAt, + updatedAt: Date.now() + }); + return { + ...data, + totalSaved, + totalRestored: numberValue(message.totalRestored), + success: false, + resumable: true + }; + } if (data.cancelled || isStopRequested(runId)) { stopRequested = true; clearStoredScanTask(); - return { success: true, totalSaved: totalSaved + (data.saved || 0) }; + return { + success: true, + totalSaved: totalSaved + numberValue(data.saved), + totalRestored: numberValue(message.totalRestored) + numberValue(data.restored) + }; } const nextTotalSaved = totalSaved + (data.saved || 0); + const nextTotalRestored = numberValue(message.totalRestored) + numberValue(data.restored); postProgress(message, "success", `Boss Chrome已提交后台AI队列:采集 ${data.received ?? submitJobs.length} 个,入库 ${data.saved ?? 0} 个,入队 ${data.queued ?? 0} 个,恢复已有分析 ${data.restored ?? 0} 个,跳过 ${data.skipped ?? 0} 个,信息不足 ${data.insufficient ?? 0} 个。`, { ...baseMeta, stage: "submitted", @@ -2385,7 +2441,8 @@ restored: data.restored ?? 0, insufficient: data.insufficient ?? 0, queueSize: data.queueSize ?? 0, - totalSaved: nextTotalSaved + totalSaved: nextTotalSaved, + totalRestored: nextTotalRestored }); if (isAutoDeliverEnabled(message)) { postProgress(message, "warning", "扫描优先模式已启用:Boss扫描期间不会自动投递,AI通过岗位会进入待确认列表。", { @@ -2397,14 +2454,16 @@ ...message, phase: "nextKeyword", jobs: [], + historicalJobs: [], detailIndex: 0, submitBatchIndex: 0, submitSummary: null, currentIndex: Number(message.currentIndex || 0) + 1, - totalSaved: nextTotalSaved + totalSaved: nextTotalSaved, + totalRestored: nextTotalRestored }); advanceKeywordCursor(message, Number(message.currentIndex || 0) + 1, keyword); - return { success: true, totalSaved: nextTotalSaved }; + return { success: true, totalSaved: nextTotalSaved, totalRestored: nextTotalRestored }; } async function submitBossJobsInBatches(jobs, message, baseMeta, options) { @@ -2478,53 +2537,54 @@ await humanPause(800, 1500); continue; } - // 最终失败:记录错误但跳过本批,继续提交其他批次 + // 最终失败:保留当前批次检查点并停止,恢复后从原批次重试。 const reason = safeErrorMessage(error); const diagnostic = buildLocalApiDiagnostic(error, "submitting"); - postProgress(message, "error", `Boss岗位第 ${index + 1}/${batches.length} 批提交失败(已重试),跳过本批继续:${diagnostic.message}`, { + postProgress(message, "error", `Boss岗位第 ${index + 1}/${batches.length} 批提交失败(已重试),已保留断点:${diagnostic.message}`, { ...baseMeta, - stage: "submitBatchSkipped", + stage: "submitBatchFailed", diagnosticType: diagnostic.type, batchIndex: index + 1, batchTotal: batches.length }); - storeScanTask({ - ...message, - phase: "submitting", - jobs: message.jobs, - submitBatchIndex: index + 1, - submitSummary: checkpointSubmitSummary(summary), - lastSubmitError: { type: diagnostic.type, message: reason, failedAt: Date.now() } - }); - // 跳过本批,继续下一个批次 - data = null; - break; + storeScanTask(SCAN_SUPPORT.buildFailedSubmitCheckpoint( + { ...message, jobs: message.jobs }, + index, + checkpointSubmitSummary(summary), + { type: diagnostic.type, message: reason } + )); + return { + ...summary, + success: false, + failedBatchIndex: index, + resumable: true, + message: reason + }; } } - if (!data) { - // 本批已跳过,继续下一批 - continue; - } - - if (!data.success) { + if (data.success !== true) { const diagnostic = buildLocalApiDiagnostic(new Error(data.message || "后台未返回原因"), "submitting"); - postProgress(message, "error", `Boss岗位第 ${index + 1}/${batches.length} 批后台处理失败,跳过本批继续:${diagnostic.message}`, { + postProgress(message, "error", `Boss岗位第 ${index + 1}/${batches.length} 批后台处理失败,已保留断点:${diagnostic.message}`, { ...baseMeta, - stage: "submitBatchSkipped", + stage: "submitBatchFailed", diagnosticType: diagnostic.type, batchIndex: index + 1, batchTotal: batches.length }); - storeScanTask({ - ...message, - phase: "submitting", - jobs: message.jobs, - submitBatchIndex: index + 1, - submitSummary: checkpointSubmitSummary(summary), - lastSubmitError: { type: diagnostic.type, message: data.message || "后台未返回原因", failedAt: Date.now() } - }); - continue; + storeScanTask(SCAN_SUPPORT.buildFailedSubmitCheckpoint( + { ...message, jobs: message.jobs }, + index, + checkpointSubmitSummary(summary), + { type: diagnostic.type, message: data.message || "后台未返回原因" } + )); + return { + ...summary, + success: false, + failedBatchIndex: index, + resumable: true, + message: data.message || "后台未返回原因" + }; } summary.received += numberValue(data.received); @@ -2544,12 +2604,13 @@ lastSubmitError: null }); - postProgress(message, "info", `Boss Chrome第 ${index + 1}/${batches.length} 批已提交:入库 ${numberValue(data.saved)} 个,入队 ${numberValue(data.queued)} 个。`, { + postProgress(message, "info", `Boss Chrome第 ${index + 1}/${batches.length} 批已提交:入库 ${numberValue(data.saved)} 个,恢复历史结果 ${numberValue(data.restored)} 个,入队 ${numberValue(data.queued)} 个。`, { ...baseMeta, stage: "submitting", batchIndex: index + 1, batchTotal: batches.length, saved: summary.saved, + restored: summary.restored, queued: summary.queued, skipped: summary.skipped, insufficient: summary.insufficient @@ -3116,9 +3177,11 @@ type: "BOSS_SCAN_START", currentIndex: cursorState.currentIndex, totalSaved: Number(message.totalSaved || 0), + totalRestored: Number(message.totalRestored || 0), phase: message.phase || "searching", detailIndex: Number(message.detailIndex || 0), jobs: Array.isArray(message.jobs) ? message.jobs : [], + historicalJobs: Array.isArray(message.historicalJobs) ? message.historicalJobs : [], aiKeywordsLoaded: Boolean(message.aiKeywordsLoaded), autoDeliver: isAutoDeliverEnabled(message), startedAt: message.startedAt || Date.now(), diff --git a/chrome-extension/boss-scan-support.js b/chrome-extension/boss-scan-support.js index 225bb5e..3e6f92e 100644 --- a/chrome-extension/boss-scan-support.js +++ b/chrome-extension/boss-scan-support.js @@ -1,5 +1,5 @@ (function (root) { - const SUPPORT_VERSION = "2026-07-18-boss-security-resume-fix"; + const SUPPORT_VERSION = "2026-09-02-boss-history-reuse"; if (root.GetJobsBossScanSupport?.version === SUPPORT_VERSION) return; const DEFAULT_TASK_TTL_MS = 24 * 60 * 60 * 1000; @@ -162,6 +162,58 @@ return Math.min(parsed, total); } + function buildFailedSubmitCheckpoint(task, batchIndex, submitSummary, lastSubmitError, now = Date.now()) { + return { + ...(task || {}), + phase: "submitting", + submitBatchIndex: Math.max(0, Math.floor(Number(batchIndex) || 0)), + submitSummary: { ...(submitSummary || {}) }, + lastSubmitError: { + ...(lastSubmitError || {}), + failedAt: Number(lastSubmitError?.failedAt || now) + } + }; + } + + function partitionDedupeJobs(jobs, items) { + const jobList = Array.isArray(jobs) ? jobs : []; + const decisionItems = Array.isArray(items) ? items : []; + const jobKeys = jobList.map(dedupeDecisionKey); + const itemKeys = decisionItems.map(dedupeDecisionKey); + const allowedActions = new Set(["NEW", "ENRICH", "SKIP"]); + const uniqueJobKeys = new Set(jobKeys); + const uniqueItemKeys = new Set(itemKeys); + const complete = jobKeys.every((key) => key && uniqueItemKeys.has(key)) + && itemKeys.every((key) => key && uniqueJobKeys.has(key)) + && uniqueJobKeys.size === jobKeys.length + && uniqueItemKeys.size === itemKeys.length + && itemKeys.length === jobKeys.length + && decisionItems.every((item) => allowedActions.has(String(item?.action || "").trim().toUpperCase())); + if (!complete) throw new Error("Boss查重接口未完整返回全部岗位决策"); + + const decisionByKey = new Map(decisionItems.map((item) => [ + dedupeDecisionKey(item), + String(item.action).trim().toUpperCase() + ])); + const detailJobs = []; + const historicalJobs = []; + jobList.forEach((job) => { + const action = decisionByKey.get(dedupeDecisionKey(job)); + if (action === "SKIP") { + historicalJobs.push({ ...job, collectionAction: "REUSE_HISTORY" }); + } else { + detailJobs.push({ ...job, collectionAction: "ANALYZE" }); + } + }); + return { detailJobs, historicalJobs }; + } + + function dedupeDecisionKey(item) { + const id = compact(item?.id || extractBossJobId(item?.url)); + if (id) return `id:${id}`; + return `ct:${compact(item?.company).toLowerCase()}::${compact(item?.title).toLowerCase()}`; + } + function classifyLocalApiFailure(error) { const explicit = String(error?.code || ""); if (explicit) return explicit; @@ -197,6 +249,8 @@ isNonJobNavigationTitle, classifyBossDetailNavigation, normalizeBatchIndex, + buildFailedSubmitCheckpoint, + partitionDedupeJobs, classifyLocalApiFailure }); })(typeof window !== "undefined" ? window : globalThis); diff --git a/chrome-extension/tests/boss-scan-support.test.cjs b/chrome-extension/tests/boss-scan-support.test.cjs index 51be32b..0ce1f56 100644 --- a/chrome-extension/tests/boss-scan-support.test.cjs +++ b/chrome-extension/tests/boss-scan-support.test.cjs @@ -246,3 +246,70 @@ test("classifies CORS and local service failures for actionable diagnostics", () "LOCAL_SERVICE_UNAVAILABLE" ); }); + +test("partitions all historical Boss jobs for reuse without detail collection", () => { + const support = loadSupport(); + const jobs = [ + { id: "history-1", company: "甲公司", title: "产品经理", url: "https://www.zhipin.com/job_detail/history-1.html" }, + { id: "history-2", company: "乙公司", title: "运营经理", url: "https://www.zhipin.com/job_detail/history-2.html" }, + ]; + const partition = support.partitionDedupeJobs(jobs, jobs.map((job) => ({ ...job, duplicate: true, action: "SKIP" }))); + + assert.equal(partition.detailJobs.length, 0); + assert.equal(partition.historicalJobs.length, 2); + assert.deepEqual(Array.from(partition.historicalJobs, (job) => job.collectionAction), ["REUSE_HISTORY", "REUSE_HISTORY"]); +}); + +test("keeps new and enrich jobs in details while marking only skips as historical", () => { + const support = loadSupport(); + const jobs = [ + { id: "new-1", company: "甲公司", title: "新岗位", url: "https://www.zhipin.com/job_detail/new-1.html" }, + { id: "enrich-1", company: "乙公司", title: "补全岗位", url: "https://www.zhipin.com/job_detail/enrich-1.html" }, + { id: "history-1", company: "丙公司", title: "历史岗位", url: "https://www.zhipin.com/job_detail/history-1.html" }, + ]; + const partition = support.partitionDedupeJobs(jobs, [ + { ...jobs[0], action: "NEW" }, + { ...jobs[1], action: "ENRICH", duplicate: true }, + { ...jobs[2], action: "SKIP", duplicate: true }, + ]); + + assert.deepEqual(Array.from(partition.detailJobs, (job) => job.id), ["new-1", "enrich-1"]); + assert.deepEqual(Array.from(partition.historicalJobs, (job) => job.id), ["history-1"]); + assert.ok(partition.detailJobs.every((job) => job.collectionAction === "ANALYZE")); +}); + +test("rejects incomplete or invalid Boss dedupe decisions instead of treating them as new", () => { + const support = loadSupport(); + const jobs = [ + { id: "job-1", company: "甲公司", title: "岗位一" }, + { id: "job-2", company: "乙公司", title: "岗位二" }, + ]; + + assert.throws( + () => support.partitionDedupeJobs(jobs, [{ ...jobs[0], action: "NEW" }]), + /未完整返回/ + ); + assert.throws( + () => support.partitionDedupeJobs(jobs, jobs.map((job) => ({ ...job, action: "UNKNOWN" }))), + /未完整返回/ + ); +}); + +test("keeps the failed Boss submit batch index and prior success summary for resume", () => { + const support = loadSupport(); + const checkpoint = support.buildFailedSubmitCheckpoint( + { type: "BOSS_SCAN_START", runId: "boss-submit-retry", jobs: [{ id: 1 }, { id: 2 }] }, + 2, + { received: 40, saved: 36, queued: 30 }, + { type: "LOCAL_SERVICE_UNAVAILABLE", message: "database is locked" }, + 123456, + ); + + assert.equal(checkpoint.phase, "submitting"); + assert.equal(checkpoint.submitBatchIndex, 2); + assert.equal(checkpoint.submitSummary.received, 40); + assert.equal(checkpoint.submitSummary.saved, 36); + assert.equal(checkpoint.submitSummary.queued, 30); + assert.equal(checkpoint.lastSubmitError.failedAt, 123456); + assert.equal(checkpoint.lastSubmitError.message, "database is locked"); +}); diff --git a/front/app/boss/analysis/AnalysisContent.tsx b/front/app/boss/analysis/AnalysisContent.tsx index b531bb2..715ebc3 100644 --- a/front/app/boss/analysis/AnalysisContent.tsx +++ b/front/app/boss/analysis/AnalysisContent.tsx @@ -54,7 +54,6 @@ export default function AnalysisContent({ applyFilters, resetFilters, resetToPendingFilters, - showListCollectedFilters, } = useBossFilters() const { @@ -198,11 +197,6 @@ export default function AnalysisContent({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - useEffect(() => { - if (!focusScanRunId) return - showListCollectedFilters() - }, [focusScanRunId, showListCollectedFilters]) - useEffect(() => { if (!refreshSignal) return loadList(1, size) diff --git a/front/app/boss/analysis/components/BossJobTable.tsx b/front/app/boss/analysis/components/BossJobTable.tsx index 1aabd23..a16eb59 100644 --- a/front/app/boss/analysis/components/BossJobTable.tsx +++ b/front/app/boss/analysis/components/BossJobTable.tsx @@ -228,7 +228,14 @@ export function BossJobTable({ )} - + +
onOpenText("岗位名称", job.jobName)}>{job.jobName || "-"}
+ {job.scanResultSource === "HISTORICAL_REUSED" ? ( + + 历史结果 + + ) : null} + diff --git a/front/app/boss/analysis/hooks/useBossFilters.ts b/front/app/boss/analysis/hooks/useBossFilters.ts index a4a18af..59df2da 100644 --- a/front/app/boss/analysis/hooks/useBossFilters.ts +++ b/front/app/boss/analysis/hooks/useBossFilters.ts @@ -5,7 +5,6 @@ import { useCallback, useMemo, useState } from "react" import { DEFAULT_PENDING_FILTERS, EMPTY_FILTERS, - LIST_COLLECTED_FILTERS, type FilterState, } from "../types" @@ -66,11 +65,6 @@ export function useBossFilters() { setFilters(DEFAULT_PENDING_FILTERS) }, []) - const showListCollectedFilters = useCallback(() => { - setDraftFilters(LIST_COLLECTED_FILTERS) - setFilters(LIST_COLLECTED_FILTERS) - }, []) - return { filters, draftFilters, @@ -83,6 +77,5 @@ export function useBossFilters() { applyFilters, resetFilters, resetToPendingFilters, - showListCollectedFilters, } } diff --git a/front/app/boss/analysis/hooks/useCsvExport.ts b/front/app/boss/analysis/hooks/useCsvExport.ts index 0d5ba3f..00530fd 100644 --- a/front/app/boss/analysis/hooks/useCsvExport.ts +++ b/front/app/boss/analysis/hooks/useCsvExport.ts @@ -59,6 +59,7 @@ export function useCsvExport({ "AI分", "AI决策", "AI原因", + "结果来源", "优先公司", "链接", "创建时间", @@ -77,6 +78,7 @@ export function useCsvExport({ item.aiScore ?? "", item.aiDecision || "", item.aiReason || "", + item.scanResultSource === "HISTORICAL_REUSED" ? "历史结果" : item.scanResultSource === "CURRENT_SCAN" ? "本次扫描" : "", item.priorityCompany ? "是" : "", item.jobUrl || "", item.createdAt || "", diff --git a/front/app/boss/analysis/types.ts b/front/app/boss/analysis/types.ts index 0b7063f..f18909c 100644 --- a/front/app/boss/analysis/types.ts +++ b/front/app/boss/analysis/types.ts @@ -71,6 +71,7 @@ export type BossJob = { priorityCompany?: number sourceKeyword?: string scanRunId?: string + scanResultSource?: "CURRENT_SCAN" | "HISTORICAL_REUSED" createdAt?: string aiGreeting?: string greetingDraft?: string @@ -115,8 +116,6 @@ export const EMPTY_FILTERS: FilterState = { } export const DEFAULT_PENDING_FILTERS: FilterState = { ...EMPTY_FILTERS, statuses: ["待确认"] } -export const LIST_COLLECTED_FILTERS: FilterState = { ...EMPTY_FILTERS, statuses: ["LIST_COLLECTED"] } - export const FAILURE_TYPE_LABELS: Record = { LOGIN_EXPIRED: "登录失效", PLATFORM_VERIFICATION: "平台验证", diff --git a/front/app/boss/page.tsx b/front/app/boss/page.tsx index ff080fd..7b5ec89 100644 --- a/front/app/boss/page.tsx +++ b/front/app/boss/page.tsx @@ -15,6 +15,7 @@ import PageHeader from '@/app/components/PageHeader' import AnalysisContent from '@/app/boss/analysis/AnalysisContent' import CurrentProfileBadge, { type CurrentProfile } from '@/app/components/CurrentProfileBadge' import { formatSetupMissingMessage, validateSetupForPlatform } from '@/lib/setupChecklist' +import { hasBossScanResult, readBossScanRunId } from '@/app/boss/scan-result' interface BossConfig { id?: number @@ -112,6 +113,7 @@ interface BossCurrentPageCollectResponse extends BossDiagnosticsResponse { skippedCount?: number saved?: number listCollected?: number + restored?: number missingFieldCounts?: Record failures?: Array<{ index?: number @@ -123,6 +125,7 @@ interface BossCurrentPageCollectResponse extends BossDiagnosticsResponse { backend?: { saved?: number listCollected?: number + restored?: number collectionWarnings?: Array> } } @@ -138,6 +141,7 @@ interface BossApiPocResponse extends BossDiagnosticsResponse { collectorSource?: string saved?: number listCollected?: number + restored?: number } const BOSS_DELIVERY_STEPS: Array<{ key: BossStep; title: string; description: string }> = [ @@ -323,8 +327,8 @@ export default function BossPage() { window.setTimeout(() => setLogSpotlight(false), 2200) }, []) - const guideToConfirmStep = useCallback(() => { - setAnalysisFocusRunId('') + const guideToConfirmStep = useCallback((payload: Record) => { + setAnalysisFocusRunId(readBossScanRunId(payload)) setHasScanResult(true) setAnalysisRefreshSignal((value) => value + 1) }, []) @@ -450,7 +454,7 @@ export default function BossPage() { if (typeof data.runId === 'string' && data.runId.trim()) setActiveRunId(data.runId.trim()) } if (shouldRefreshAnalysisFromProgress(data)) { - guideToConfirmStep() + guideToConfirmStep(data) } if (data.type === 'error') { setIsDelivering(false) @@ -482,7 +486,7 @@ export default function BossPage() { }) if (shouldRefreshAnalysisFromProgress(payload)) { - guideToConfirmStep() + guideToConfirmStep(payload) } if (payload.stage === 'blocked' && (payload.paused || payload.resumable)) { setIsDelivering(false) @@ -1009,7 +1013,7 @@ export default function BossPage() { message: '当前页面未识别到岗位详情链接,可能是未进入搜索结果页、未登录、安全验证、页面结构变化或选择器失效。', }) } - if (data.success && typeof data.runId === 'string' && Number(data.saved || data.listCollected || 0) > 0) { + if (data.success && typeof data.runId === 'string' && hasBossScanResult(data)) { setAnalysisFocusRunId(data.runId) setHasScanResult(true) setAnalysisRefreshSignal((value) => value + 1) @@ -1085,10 +1089,10 @@ export default function BossPage() { }) appendProgressLog({ type: data.success ? 'info' : 'warning', - message: `Boss API POC 诊断:diagnosticType=${data.diagnosticType || '未知'};apiCode=${data.apiCode ?? '无'};httpStatus=${Number(data.httpStatus || 0)};candidateCount=${Number(data.candidateCount || 0)};missingSalaryCount=${Number(data.missingSalaryCount || 0)};fallbackUsed=${Boolean(data.fallbackUsed)};collectorSource=${data.collectorSource || 'none'};saved=${Number(data.saved || 0)};listCollected=${Number(data.listCollected || 0)}。`, + message: `Boss API POC 诊断:diagnosticType=${data.diagnosticType || '未知'};apiCode=${data.apiCode ?? '无'};httpStatus=${Number(data.httpStatus || 0)};candidateCount=${Number(data.candidateCount || 0)};missingSalaryCount=${Number(data.missingSalaryCount || 0)};fallbackUsed=${Boolean(data.fallbackUsed)};collectorSource=${data.collectorSource || 'none'};saved=${Number(data.saved || 0)};listCollected=${Number(data.listCollected || 0)};restored=${Number(data.restored || 0)}。`, }) - if (data.success && typeof data.runId === 'string' && Number(data.saved || data.listCollected || 0) > 0) { + if (data.success && typeof data.runId === 'string' && hasBossScanResult(data)) { setAnalysisFocusRunId(data.runId) setHasScanResult(true) setAnalysisRefreshSignal((value) => value + 1) diff --git a/front/app/boss/scan-result.test.ts b/front/app/boss/scan-result.test.ts new file mode 100644 index 0000000..862aa2c --- /dev/null +++ b/front/app/boss/scan-result.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' + +import { hasBossScanResult, readBossScanRunId } from './scan-result' + +describe('Boss 扫描结果聚焦', () => { + it('仅恢复历史结果时仍视为本次扫描有结果', () => { + expect(hasBossScanResult({ saved: 0, listCollected: 0, restored: 2 })).toBe(true) + }) + + it('没有新采集或历史恢复时保持空结果', () => { + expect(hasBossScanResult({ saved: 0, listCollected: 0, restored: 0 })).toBe(false) + }) + + it('从进度消息读取并清理扫描批次 ID', () => { + expect(readBossScanRunId({ runId: ' boss-run-1 ' })).toBe('boss-run-1') + expect(readBossScanRunId({ runId: 42 })).toBe('') + }) +}) diff --git a/front/app/boss/scan-result.ts b/front/app/boss/scan-result.ts new file mode 100644 index 0000000..a2f5adb --- /dev/null +++ b/front/app/boss/scan-result.ts @@ -0,0 +1,15 @@ +export interface BossScanResultSummary { + saved?: number + listCollected?: number + restored?: number +} + +export const hasBossScanResult = (summary: BossScanResultSummary): boolean => ( + Number(summary.saved || 0) + + Number(summary.listCollected || 0) + + Number(summary.restored || 0) +) > 0 + +export const readBossScanRunId = (payload: Record): string => ( + typeof payload.runId === 'string' ? payload.runId.trim() : '' +) diff --git a/src/main/java/com/getjobs/application/controller/BossController.java b/src/main/java/com/getjobs/application/controller/BossController.java index 6b46558..11767ae 100644 --- a/src/main/java/com/getjobs/application/controller/BossController.java +++ b/src/main/java/com/getjobs/application/controller/BossController.java @@ -116,6 +116,7 @@ public ResponseEntity> receiveChromeJobs(@RequestBody Chrome boolean listOnlyCollection = isListOnlyCollection(request); List> analyses = new ArrayList<>(); List> collectionWarnings = new ArrayList<>(); + List> rejected = new ArrayList<>(); if (request != null && request.getJobs() != null) { if (jobRunCoordinator.isCancelRequested(runId)) { jobRunCoordinator.clearCancel(runId); @@ -140,128 +141,171 @@ public ResponseEntity> receiveChromeJobs(@RequestBody Chrome listOnlyCollection, listCollected, collectionWarnings )); } - BossJobDataEntity entity = toBossEntity(dto, request.getKeyword()); - BossJobDataEntity saved = bossService.upsertChromeBossJob(entity, runId); - insertedOrUpdated++; + try { + if (isHistoricalReuse(dto)) { + BossJobDataEntity restoredJob = restoreHistoricalBossJob(profileId, dto, runId); + skipped++; + Map snapshot = toBossAnalysisSnapshot(restoredJob); + if (snapshot != null) { + analyses.add(snapshot); + restored++; + } + continue; + } - if (saved == null) { - log.warn("Boss Chrome岗位入库返回为空:company={}, title={}, url={}", dto == null ? "" : dto.getCompany(), dto == null ? "" : dto.getTitle(), dto == null ? "" : dto.getUrl()); - continue; - } + BossJobDataEntity entity = toBossEntity(dto, request.getKeyword()); + BossJobDataEntity saved = bossService.upsertChromeBossJob(entity, runId); + insertedOrUpdated++; + + if (saved == null) { + skipped++; + rejected.add(chromeJobRejection(dto, "Boss 岗位入库未返回有效记录")); + continue; + } - String currentStatus = saved.getDeliveryStatus(); - if (listOnlyCollection) { + String currentStatus = saved.getDeliveryStatus(); + if (listOnlyCollection) { + if (DeliveryStatus.AI_ANALYZING.equals(currentStatus) || isFinalBossStatus(currentStatus)) { + skipped++; + } else { + BossJobDataEntity updated = bossService.updateDeliveryStatusById(saved.getId(), DeliveryStatus.LIST_COLLECTED); + if (updated != null && DeliveryStatus.LIST_COLLECTED.equals(updated.getDeliveryStatus())) { + saved = updated; + listCollected++; + } else { + skipped++; + saved = updated == null ? saved : updated; + } + } + List missingListFields = collectMissingListFields(dto, saved, request.getKeyword()); + if (!missingListFields.isEmpty()) { + Map warning = Map.of( + "id", saved.getId() == null ? 0L : saved.getId(), + "title", dto == null ? "" : Objects.toString(dto.getTitle(), ""), + "company", dto == null ? "" : Objects.toString(dto.getCompany(), ""), + "missingFields", missingListFields, + "reason", "Boss列表页字段不完整,已按LIST_COLLECTED入库,未进入AI分析" + ); + collectionWarnings.add(warning); + sendBossProgress(JobProgressMessage.warning( + "boss", + "Boss列表岗位已入库但字段不完整:" + warning.get("company") + " / " + warning.get("title") + + ",缺少:" + String.join("、", missingListFields) + )); + } + continue; + } if (DeliveryStatus.AI_ANALYZING.equals(currentStatus) || isFinalBossStatus(currentStatus)) { skipped++; - } else { - saved = bossService.updateDeliveryStatusById(saved.getId(), DeliveryStatus.LIST_COLLECTED); - listCollected++; + Map snapshot = toBossAnalysisSnapshot(saved); + if (snapshot != null) { + analyses.add(snapshot); + restored++; + } + continue; + } + + List missingFields = collectMissingAnalysisFields(saved); + if (!missingFields.isEmpty()) { + BossJobDataEntity marked = bossService.markBossJobCollectionInsufficient(saved.getId(), missingFields); + BossJobDataEntity display = marked == null ? saved : marked; + if (!DeliveryStatus.COLLECTION_INSUFFICIENT.equals(display.getDeliveryStatus())) { + skipped++; + Map snapshot = toBossAnalysisSnapshot(display); + if (snapshot != null) analyses.add(snapshot); + continue; + } + insufficient++; + analyses.add(Map.of( + "id", display.getId(), + "jobKey", Objects.toString(display.getEncryptId(), ""), + "jobName", Objects.toString(display.getJobName(), ""), + "companyName", Objects.toString(display.getCompanyName(), ""), + "score", 0, + "decision", DeliveryStatus.COLLECTION_INSUFFICIENT, + "shouldApply", false + )); + String message = "采集信息不足:" + Objects.toString(display.getCompanyName(), "") + " / " + Objects.toString(display.getJobName(), "") + ",缺少:" + String.join("、", missingFields); + log.warn("{}", message); + sendBossProgress(JobProgressMessage.warning("boss", message)); + continue; } - List missingListFields = collectMissingListFields(dto, saved, request.getKeyword()); - if (!missingListFields.isEmpty()) { - Map warning = Map.of( - "id", saved == null || saved.getId() == null ? 0L : saved.getId(), - "title", dto == null ? "" : Objects.toString(dto.getTitle(), ""), - "company", dto == null ? "" : Objects.toString(dto.getCompany(), ""), - "missingFields", missingListFields, - "reason", "Boss列表页字段不完整,已按LIST_COLLECTED入库,未进入AI分析" - ); - collectionWarnings.add(warning); - sendBossProgress(JobProgressMessage.warning( + + JobAiAnalysisService.JobAnalysisRequest analysisRequest = new JobAiAnalysisService.JobAnalysisRequest(); + analysisRequest.setProfileId(profileId); + analysisRequest.setPlatform("boss"); + analysisRequest.setJobKey(saved.getEncryptId()); + analysisRequest.setJobRowId(saved.getId()); + analysisRequest.setKeyword(dto.getKeyword() == null ? request.getKeyword() : dto.getKeyword()); + analysisRequest.setCompanyName(saved.getCompanyName()); + analysisRequest.setJobName(saved.getJobName()); + analysisRequest.setSalary(saved.getSalary()); + analysisRequest.setLocation(saved.getLocation()); + analysisRequest.setExperience(saved.getExperience()); + analysisRequest.setDegree(saved.getDegree()); + analysisRequest.setCompanyInfo(saved.getIntroduce()); + analysisRequest.setJobDescription(saved.getJobDescription()); + analysisRequest.setScanRunId(runId); + ChromeJobAnalysisQueueService.AnalysisJob job = new ChromeJobAnalysisQueueService.AnalysisJob(); + job.setRunId(runId); + job.setCurrentStatus(currentStatus); + job.setCurrent(insertedOrUpdated); + job.setTotal(received); + job.setRequest(analysisRequest); + job.setProgressCallback(this::sendBossProgress); + + ChromeJobAnalysisQueueService.EnqueueResult enqueueResult = chromeJobAnalysisQueueService.enqueue(job); + if (enqueueResult.isRejected()) { + skipped++; + rejected.add(chromeJobRejection(dto, Objects.toString(enqueueResult.getMessage(), "AI 分析任务被拒绝"))); + continue; + } + if (enqueueResult.isQueued()) { + queued++; + sendBossProgress(JobProgressMessage.progress( "boss", - "Boss列表岗位已入库但字段不完整:" + warning.get("company") + " / " + warning.get("title") - + ",缺少:" + String.join("、", missingListFields) + "已加入后台AI队列:" + saved.getJobName(), + insertedOrUpdated, + received )); + } else { + skipped++; } - continue; - } - if (DeliveryStatus.AI_ANALYZING.equals(currentStatus) || isFinalBossStatus(currentStatus)) { + } catch (Exception exception) { skipped++; - Map snapshot = toBossAnalysisSnapshot(saved); - if (snapshot != null) { - analyses.add(snapshot); - restored++; - } - continue; - } - - List missingFields = collectMissingAnalysisFields(saved); - if (!missingFields.isEmpty()) { - insufficient++; - BossJobDataEntity marked = bossService.markBossJobCollectionInsufficient(saved.getId(), missingFields); - BossJobDataEntity display = marked == null ? saved : marked; - analyses.add(Map.of( - "id", display.getId(), - "jobKey", Objects.toString(display.getEncryptId(), ""), - "jobName", Objects.toString(display.getJobName(), ""), - "companyName", Objects.toString(display.getCompanyName(), ""), - "score", 0, - "decision", DeliveryStatus.COLLECTION_INSUFFICIENT, - "shouldApply", false - )); - String message = "采集信息不足:" + Objects.toString(display.getCompanyName(), "") + " / " + Objects.toString(display.getJobName(), "") + ",缺少:" + String.join("、", missingFields); - log.warn("{}", message); - sendBossProgress(JobProgressMessage.warning("boss", message)); - continue; + rejected.add(chromeJobRejection(dto, Objects.toString(exception.getMessage(), "Boss 岗位入库或持久 AI 入队失败"))); + log.warn("Boss Chrome岗位处理失败,jobId={}", dto == null ? "" : dto.getId(), exception); } - - JobAiAnalysisService.JobAnalysisRequest analysisRequest = new JobAiAnalysisService.JobAnalysisRequest(); - analysisRequest.setProfileId(profileId); - analysisRequest.setPlatform("boss"); - analysisRequest.setJobKey(saved.getEncryptId()); - analysisRequest.setJobRowId(saved.getId()); - analysisRequest.setKeyword(dto.getKeyword() == null ? request.getKeyword() : dto.getKeyword()); - analysisRequest.setCompanyName(saved.getCompanyName()); - analysisRequest.setJobName(saved.getJobName()); - analysisRequest.setSalary(saved.getSalary()); - analysisRequest.setLocation(saved.getLocation()); - analysisRequest.setExperience(saved.getExperience()); - analysisRequest.setDegree(saved.getDegree()); - analysisRequest.setCompanyInfo(saved.getIntroduce()); - analysisRequest.setJobDescription(saved.getJobDescription()); - analysisRequest.setScanRunId(runId); - ChromeJobAnalysisQueueService.AnalysisJob job = new ChromeJobAnalysisQueueService.AnalysisJob(); - job.setRunId(runId); - job.setCurrentStatus(currentStatus); - job.setCurrent(insertedOrUpdated); - job.setTotal(received); - job.setRequest(analysisRequest); - job.setProgressCallback(this::sendBossProgress); - - ChromeJobAnalysisQueueService.EnqueueResult enqueueResult = chromeJobAnalysisQueueService.enqueue(job); - if (enqueueResult.isRejected()) { - Map response = decorateListCollectionResponse( + } + } + if (!rejected.isEmpty()) { + Map response = decorateChromeJobRejections( + decorateListCollectionResponse( bossChromeJobsResponse(false, false, received, insertedOrUpdated, queued, skipped, insufficient, restored, autoDeliver, analyses), listOnlyCollection, listCollected, collectionWarnings - ); - response.put("message", enqueueResult.getMessage()); - return ResponseEntity.status(429).body(response); - } - if (enqueueResult.isQueued()) { - queued++; - sendBossProgress(JobProgressMessage.progress( - "boss", - "已加入后台AI队列:" + saved.getJobName(), - insertedOrUpdated, - received - )); - } else { - skipped++; - } - } + ), + received, + rejected + ); + response.put("message", "部分 Boss 岗位未完成入库、历史恢复或持久 AI 入队,请查看 rejected 明细后重试被拒绝项"); + return ResponseEntity.status(429).body(response); } if (listOnlyCollection) { sendBossProgress(JobProgressMessage.success( "boss", "Boss当前搜索结果页已入库 " + insertedOrUpdated + " 个岗位,其中LIST_COLLECTED " - + listCollected + " 个,未进入AI分析" + + listCollected + " 个,恢复历史结果 " + restored + " 个,未进入AI分析" )); } else { sendBossProgress(JobProgressMessage.success("boss", "Boss Chrome岗位已提交后台AI队列:入库 " + insertedOrUpdated + " 个,入队 " + queued + " 个,恢复已有分析 " + restored + " 个,信息不足 " + insufficient + " 个")); } - return ResponseEntity.ok(decorateListCollectionResponse( - bossChromeJobsResponse(true, false, received, insertedOrUpdated, queued, skipped, insufficient, restored, autoDeliver, analyses), - listOnlyCollection, listCollected, collectionWarnings + return ResponseEntity.ok(decorateChromeJobRejections( + decorateListCollectionResponse( + bossChromeJobsResponse(true, false, received, insertedOrUpdated, queued, skipped, insufficient, restored, autoDeliver, analyses), + listOnlyCollection, listCollected, collectionWarnings + ), + received, + rejected )); } @@ -320,6 +364,7 @@ private String dedupeAction(BossJobDataEntity existing, List missingFiel String status = Objects.toString(existing.getDeliveryStatus(), ""); if (DeliveryStatus.LIST_COLLECTED.equals(status) || DeliveryStatus.COLLECTION_INSUFFICIENT.equals(status) + || (!DeliveryStatus.AI_ANALYZING.equals(status) && !isFinalBossStatus(status)) || (missingFields != null && !missingFields.isEmpty())) { return "ENRICH"; } @@ -719,6 +764,36 @@ private boolean isListOnlyCollection(ChromeJobBatchRequest request) { && "LIST_ONLY".equalsIgnoreCase(Objects.toString(request.getCollectionMode(), "").trim()); } + private boolean isHistoricalReuse(ChromeJobDto dto) { + String action = Objects.toString(dto == null ? null : dto.getCollectionAction(), "").trim().toUpperCase(); + if (action.isEmpty() || "ANALYZE".equals(action)) return false; + if ("REUSE_HISTORY".equals(action)) return true; + throw new IllegalArgumentException("不支持的 Boss 岗位采集动作:" + action); + } + + private BossJobDataEntity restoreHistoricalBossJob(Long profileId, ChromeJobDto dto, String runId) { + if (profileId == null) throw new IllegalStateException("当前档案不可用,无法恢复历史岗位"); + if (runId == null || runId.isBlank()) throw new IllegalArgumentException("历史岗位恢复缺少本次 runId"); + String requestedId = firstNonBlank(dto == null ? null : dto.getId(), dto == null ? null : extractBossId(dto.getUrl())); + if (requestedId == null || requestedId.isBlank()) { + throw new IllegalArgumentException("历史岗位恢复缺少可核验的 Boss 岗位 ID"); + } + Map matches = bossService.findExistingChromeBossJobs(profileId, List.of(dto), null); + BossJobDataEntity existing = matches.get(0); + if (existing == null) throw new IllegalArgumentException("历史岗位身份核验失败,未找到当前档案中的匹配记录"); + if (!requestedId.equals(Objects.toString(existing.getEncryptId(), ""))) { + throw new IllegalArgumentException("历史岗位身份核验失败,Boss 岗位 ID 不匹配"); + } + List missingFields = collectMissingAnalysisFields(existing); + String action = dedupeAction(existing, missingFields); + if (!"SKIP".equals(action)) { + throw new IllegalStateException("历史岗位状态已变化,需要重新采集详情,当前动作:" + action); + } + BossJobDataEntity restored = bossService.reuseHistoricalBossJob(existing.getId(), profileId, runId); + if (restored == null) throw new IllegalStateException("历史岗位恢复失败,记录可能已被其他操作修改"); + return restored; + } + private boolean isFinalBossStatus(String status) { if (status == null || status.isBlank()) return false; return DeliveryStatus.isFinalStatus(status); @@ -763,6 +838,27 @@ private Map decorateListCollectionResponse(Map r return response; } + private Map decorateChromeJobRejections(Map response, + int requestedCount, + List> rejected) { + List> safeRejected = rejected == null ? List.of() : rejected; + int rejectedCount = safeRejected.size(); + int acceptedCount = Math.max(0, requestedCount - rejectedCount); + response.put("status", rejectedCount == 0 ? "SUCCESS" : acceptedCount > 0 ? "PARTIAL" : "FAILED"); + response.put("requestedCount", requestedCount); + response.put("acceptedCount", acceptedCount); + response.put("rejectedCount", rejectedCount); + response.put("rejected", safeRejected); + return response; + } + + private Map chromeJobRejection(ChromeJobDto dto, String message) { + return Map.of( + "jobId", Objects.toString(dto == null ? null : dto.getId(), ""), + "message", firstNonBlank(message, "Boss 岗位处理失败") + ); + } + private String normalizeRunId(String runId) { return runId == null || runId.isBlank() ? null : runId.trim(); } @@ -779,6 +875,7 @@ private Map toBossAnalysisSnapshot(BossJobDataEntity job) { item.put("deliveryStatus", Objects.toString(job.getDeliveryStatus(), "")); item.put("reason", Objects.toString(job.getAiReason(), "")); item.put("priorityCompany", job.getPriorityCompany() != null && job.getPriorityCompany() == 1); + item.put("scanResultSource", Objects.toString(job.getScanResultSource(), "")); item.put("shouldApply", DeliveryStatus.isWaitingConfirm(job.getDeliveryStatus()) || DeliveryStatus.isDelivered(job.getDeliveryStatus()) || "APPLY".equalsIgnoreCase(Objects.toString(job.getAiDecision(), ""))); item.put("restored", true); return item; diff --git a/src/main/java/com/getjobs/application/dto/ChromeJobDto.java b/src/main/java/com/getjobs/application/dto/ChromeJobDto.java index 9d1fc67..63a243f 100644 --- a/src/main/java/com/getjobs/application/dto/ChromeJobDto.java +++ b/src/main/java/com/getjobs/application/dto/ChromeJobDto.java @@ -19,6 +19,7 @@ public class ChromeJobDto { private String hrActive; private String description; private String deliveryStatus; + private String collectionAction; private String url; private String recruitmentStatus; private String companyAddress; diff --git a/src/main/java/com/getjobs/application/entity/BossJobDataEntity.java b/src/main/java/com/getjobs/application/entity/BossJobDataEntity.java index 0ba3c9c..b2a01a8 100644 --- a/src/main/java/com/getjobs/application/entity/BossJobDataEntity.java +++ b/src/main/java/com/getjobs/application/entity/BossJobDataEntity.java @@ -104,6 +104,9 @@ public class BossJobDataEntity { @TableField("scan_run_id") private String scanRunId; + @TableField("scan_result_source") + private String scanResultSource; + @TableField("ai_score") private Integer aiScore; diff --git a/src/main/java/com/getjobs/application/service/BossService.java b/src/main/java/com/getjobs/application/service/BossService.java index af79158..921bafb 100644 --- a/src/main/java/com/getjobs/application/service/BossService.java +++ b/src/main/java/com/getjobs/application/service/BossService.java @@ -50,6 +50,19 @@ public class BossService { public static final int DEFAULT_SEARCH_JOB_LIMIT = 20; public static final int MIN_SEARCH_JOB_LIMIT = 1; public static final int MAX_SEARCH_JOB_LIMIT = 200; + public static final String SCAN_RESULT_CURRENT = "CURRENT_SCAN"; + public static final String SCAN_RESULT_HISTORICAL = "HISTORICAL_REUSED"; + private static final Set HISTORICAL_REUSE_STATUSES = Set.of( + DeliveryStatus.AI_ANALYZING, + DeliveryStatus.WAITING_CONFIRM, + DeliveryStatus.DELIVERED, + DeliveryStatus.SKIPPED, + DeliveryStatus.AI_NOT_MATCH, + DeliveryStatus.AI_ANALYSIS_FAILED, + DeliveryStatus.DELIVERY_FAILED, + DeliveryStatus.DELIVERY_REQUESTED, + DeliveryStatus.DELIVERY_UNKNOWN + ); private final BossOptionMapper bossOptionMapper; private final BossIndustryMapper bossIndustryMapper; @@ -590,6 +603,7 @@ public synchronized BossJobDataEntity upsertChromeBossJob(BossJobDataEntity enti entity.setProfileId(profileId); if (scanRunId != null && !scanRunId.isBlank()) { entity.setScanRunId(scanRunId.trim()); + entity.setScanResultSource(SCAN_RESULT_CURRENT); } String encryptId = entity.getEncryptId(); String encryptUserId = entity.getEncryptUserId(); @@ -676,6 +690,7 @@ private BossJobDataEntity mergeChromeBossJob(BossJobDataEntity existing, BossJob merged.setCompanyScale(firstNonBlank(incoming.getCompanyScale(), existing.getCompanyScale())); merged.setSourceKeyword(firstNonBlank(incoming.getSourceKeyword(), existing.getSourceKeyword())); merged.setScanRunId(firstNonBlank(incoming.getScanRunId(), existing.getScanRunId())); + merged.setScanResultSource(firstNonBlank(incoming.getScanResultSource(), existing.getScanResultSource(), SCAN_RESULT_CURRENT)); merged.setAiScore(existing.getAiScore()); merged.setAiDecision(existing.getAiDecision()); merged.setAiReason(existing.getAiReason()); @@ -783,6 +798,28 @@ public BossJobDataEntity getBossJobById(Long id, Long profileId) { return bossJobDataMapper.selectOne(wrapper); } + /** + * 将已有完整分析关联到本次扫描。只更新扫描归属,不触碰岗位详情、分析结果或时间字段。 + */ + public BossJobDataEntity reuseHistoricalBossJob(Long id, Long profileId, String scanRunId) { + if (id == null || profileId == null || scanRunId == null || scanRunId.isBlank()) return null; + com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper wrapper = + new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper<>(); + wrapper.eq("id", id) + .eq("profile_id", profileId) + .in("delivery_status", HISTORICAL_REUSE_STATUSES) + .isNotNull("job_name") + .apply("TRIM(job_name) <> ''") + .isNotNull("company_name") + .apply("TRIM(company_name) <> ''") + .isNotNull("job_url") + .apply("TRIM(job_url) <> ''") + .set("scan_run_id", scanRunId.trim()) + .set("scan_result_source", SCAN_RESULT_HISTORICAL); + if (bossJobDataMapper.update(null, wrapper) != 1) return null; + return getBossJobById(id, profileId); + } + public BossJobDataEntity findExistingChromeBossJob(String encryptId, String companyName, String jobName) { return findExistingChromeBossJob(encryptId, companyName, jobName, null); } @@ -845,7 +882,8 @@ public Map findExistingChromeBossJobs(Long profileId if (!isBlank(lookup.encryptId())) { existing = byEncryptId.get(lookup.encryptId()); } - if (existing == null && !isBlank(lookup.companyName()) && !isBlank(lookup.jobName())) { + if (existing == null && isBlank(lookup.encryptId()) + && !isBlank(lookup.companyName()) && !isBlank(lookup.jobName())) { existing = byCompanyAndTitle.get(companyTitleKey(lookup.companyName(), lookup.jobName())); } if (existing != null) { diff --git a/src/main/java/com/getjobs/application/service/DatabaseSchemaService.java b/src/main/java/com/getjobs/application/service/DatabaseSchemaService.java index f0ef83e..5a63f49 100644 --- a/src/main/java/com/getjobs/application/service/DatabaseSchemaService.java +++ b/src/main/java/com/getjobs/application/service/DatabaseSchemaService.java @@ -14,6 +14,7 @@ import java.sql.Statement; import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -609,11 +610,13 @@ private static void validateSchema(Connection conn, boolean requireV7TaskSchema) requiredColumns.put("cookie", Set.of("platform", "cookie_value")); requiredColumns.put("ai", Set.of("profile_id", "apply_threshold", "priority_apply_threshold")); requiredColumns.put("priority_company", Set.of("profile_id", "company_name")); - requiredColumns.put("boss_data", Set.of( + Set bossDataColumns = new LinkedHashSet<>(Set.of( "profile_id", "encrypt_id", "encrypt_user_id", "delivery_status", "failure_type", "failure_reason", "scan_run_id", "source_keyword", "salary_min_k", "salary_max_k", "salary_median_k", "salary_months" )); + if (requireV7TaskSchema) bossDataColumns.add("scan_result_source"); + requiredColumns.put("boss_data", bossDataColumns); requiredColumns.put("zhilian_data", Set.of("profile_id", "job_id", "delivery_status", "scan_run_id")); requiredColumns.put("liepin_data", Set.of("job_id", "delivered")); requiredColumns.put("job51_data", Set.of("job_id", "delivered")); @@ -652,6 +655,7 @@ private static void validateSchema(Connection conn, boolean requireV7TaskSchema) )); if (requireV7TaskSchema) { requiredIndexes.addAll(List.of( + "idx_boss_data_profile_run_source", "idx_job_analysis_task_task_key", "idx_job_analysis_task_active_job", "idx_job_analysis_task_dispatch", diff --git a/src/main/resources/db/migration/V11__add_boss_scan_result_source.sql b/src/main/resources/db/migration/V11__add_boss_scan_result_source.sql new file mode 100644 index 0000000..9f5730b --- /dev/null +++ b/src/main/resources/db/migration/V11__add_boss_scan_result_source.sql @@ -0,0 +1,10 @@ +ALTER TABLE boss_data + ADD COLUMN scan_result_source TEXT NOT NULL DEFAULT 'CURRENT_SCAN' + CHECK (scan_result_source IN ('CURRENT_SCAN', 'HISTORICAL_REUSED')); + +UPDATE boss_data +SET scan_result_source = 'CURRENT_SCAN' +WHERE scan_result_source IS NULL OR TRIM(scan_result_source) = ''; + +CREATE INDEX IF NOT EXISTS idx_boss_data_profile_run_source + ON boss_data(profile_id, scan_run_id, scan_result_source); diff --git a/src/test/java/com/getjobs/application/controller/BossControllerListOnlyTest.java b/src/test/java/com/getjobs/application/controller/BossControllerListOnlyTest.java index b4e5383..7515c00 100644 --- a/src/test/java/com/getjobs/application/controller/BossControllerListOnlyTest.java +++ b/src/test/java/com/getjobs/application/controller/BossControllerListOnlyTest.java @@ -167,6 +167,182 @@ void dedupeReturnsNewSkipAndEnrichAcrossHistoricalRuns() { .containsEntry("newCount", 1); } + @Test + void dedupeRequiresCompletedOrInFlightAnalysisBeforeHistoricalReuse() { + BossService bossService = mock(BossService.class); + ProfileService profileService = mock(ProfileService.class); + BossController controller = controller( + bossService, + profileService, + mock(ChromeJobAnalysisQueueService.class), + mock(JobRunCoordinator.class) + ); + ChromeJobDto dto = chromeJob("job-not-analyzed", "待分析公司", "待分析岗位"); + ChromeJobBatchRequest request = new ChromeJobBatchRequest(); + request.setJobs(List.of(dto)); + BossJobDataEntity existing = savedJob(21L, dto, DeliveryStatus.NOT_DELIVERED); + + when(profileService.getCurrentProfileIdOrNull()).thenReturn(1L); + when(bossService.findExistingChromeBossJobs(eq(1L), any(), eq(null))).thenReturn(Map.of(0, existing)); + + ResponseEntity> response = controller.dedupeChromeJobs(request); + + @SuppressWarnings("unchecked") + List> items = (List>) response.getBody().get("items"); + assertThat(items).extracting(item -> item.get("action")).containsExactly("ENRICH"); + } + + @Test + void reusesHistoricalJobWithoutUpsertOrAiEnqueue() { + BossService bossService = mock(BossService.class); + ProfileService profileService = mock(ProfileService.class); + ChromeJobAnalysisQueueService queueService = mock(ChromeJobAnalysisQueueService.class); + JobRunCoordinator jobRunCoordinator = mock(JobRunCoordinator.class); + BossController controller = controller(bossService, profileService, queueService, jobRunCoordinator); + + ChromeJobDto dto = chromeJob("job-history", "历史公司", "历史岗位"); + dto.setCollectionAction("REUSE_HISTORY"); + ChromeJobBatchRequest request = new ChromeJobBatchRequest(); + request.setRunId("boss-current"); + request.setJobs(List.of(dto)); + + BossJobDataEntity existing = savedJob(31L, dto, DeliveryStatus.AI_NOT_MATCH); + existing.setAiScore(58); + existing.setAiReason("历史分析结果"); + BossJobDataEntity restored = savedJob(31L, dto, DeliveryStatus.AI_NOT_MATCH); + restored.setAiScore(58); + restored.setAiReason("历史分析结果"); + restored.setScanRunId("boss-current"); + restored.setScanResultSource(BossService.SCAN_RESULT_HISTORICAL); + + when(profileService.getCurrentProfileId()).thenReturn(1L); + when(jobRunCoordinator.isCancelRequested("boss-current")).thenReturn(false); + when(bossService.findExistingChromeBossJobs(eq(1L), any(), eq(null))).thenReturn(Map.of(0, existing)); + when(bossService.reuseHistoricalBossJob(31L, 1L, "boss-current")).thenReturn(restored); + when(queueService.queueSize()).thenReturn(0); + + ResponseEntity> response = controller.receiveChromeJobs(request); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + assertThat(response.getBody()) + .containsEntry("success", true) + .containsEntry("saved", 0) + .containsEntry("queued", 0) + .containsEntry("restored", 1) + .containsEntry("rejectedCount", 0); + verify(bossService, never()).upsertChromeBossJob(any(), any()); + verify(queueService, never()).enqueue(any()); + } + + @Test + void rejectsHistoricalReuseWhenJobNowNeedsEnrichment() { + BossService bossService = mock(BossService.class); + ProfileService profileService = mock(ProfileService.class); + ChromeJobAnalysisQueueService queueService = mock(ChromeJobAnalysisQueueService.class); + JobRunCoordinator jobRunCoordinator = mock(JobRunCoordinator.class); + BossController controller = controller(bossService, profileService, queueService, jobRunCoordinator); + + ChromeJobDto dto = chromeJob("job-changed", "变化公司", "变化岗位"); + dto.setCollectionAction("REUSE_HISTORY"); + ChromeJobBatchRequest request = new ChromeJobBatchRequest(); + request.setRunId("boss-current"); + request.setJobs(List.of(dto)); + BossJobDataEntity needsEnrichment = savedJob(41L, dto, DeliveryStatus.LIST_COLLECTED); + + when(profileService.getCurrentProfileId()).thenReturn(1L); + when(jobRunCoordinator.isCancelRequested("boss-current")).thenReturn(false); + when(bossService.findExistingChromeBossJobs(eq(1L), any(), eq(null))).thenReturn(Map.of(0, needsEnrichment)); + + ResponseEntity> response = controller.receiveChromeJobs(request); + + assertThat(response.getStatusCode().value()).isEqualTo(429); + assertThat(response.getBody()) + .containsEntry("success", false) + .containsEntry("status", "FAILED") + .containsEntry("rejectedCount", 1); + verify(bossService, never()).reuseHistoricalBossJob(any(), any(), any()); + verify(bossService, never()).upsertChromeBossJob(any(), any()); + verify(queueService, never()).enqueue(any()); + } + + @Test + void rejectsHistoricalReuseWhenCurrentProfileHasNoMatchingJob() { + BossService bossService = mock(BossService.class); + ProfileService profileService = mock(ProfileService.class); + ChromeJobAnalysisQueueService queueService = mock(ChromeJobAnalysisQueueService.class); + JobRunCoordinator jobRunCoordinator = mock(JobRunCoordinator.class); + BossController controller = controller(bossService, profileService, queueService, jobRunCoordinator); + + ChromeJobDto dto = chromeJob("job-other-profile", "其他档案公司", "其他档案岗位"); + dto.setCollectionAction("REUSE_HISTORY"); + ChromeJobBatchRequest request = new ChromeJobBatchRequest(); + request.setRunId("boss-current"); + request.setJobs(List.of(dto)); + + when(profileService.getCurrentProfileId()).thenReturn(1L); + when(jobRunCoordinator.isCancelRequested("boss-current")).thenReturn(false); + when(bossService.findExistingChromeBossJobs(eq(1L), any(), eq(null))).thenReturn(Map.of()); + + ResponseEntity> response = controller.receiveChromeJobs(request); + + assertThat(response.getStatusCode().value()).isEqualTo(429); + assertThat(response.getBody()) + .containsEntry("success", false) + .containsEntry("status", "FAILED") + .containsEntry("rejectedCount", 1); + verify(bossService, never()).reuseHistoricalBossJob(any(), any(), any()); + verify(bossService, never()).upsertChromeBossJob(any(), any()); + verify(queueService, never()).enqueue(any()); + } + + @Test + void rejectsHistoricalReuseWithoutVerifiableBossJobId() { + BossService bossService = mock(BossService.class); + ProfileService profileService = mock(ProfileService.class); + ChromeJobAnalysisQueueService queueService = mock(ChromeJobAnalysisQueueService.class); + JobRunCoordinator jobRunCoordinator = mock(JobRunCoordinator.class); + BossController controller = controller(bossService, profileService, queueService, jobRunCoordinator); + + ChromeJobDto dto = chromeJob("", "历史公司", "历史岗位"); + dto.setUrl(""); + dto.setCollectionAction("REUSE_HISTORY"); + ChromeJobBatchRequest request = new ChromeJobBatchRequest(); + request.setRunId("boss-current"); + request.setJobs(List.of(dto)); + + when(profileService.getCurrentProfileId()).thenReturn(1L); + when(jobRunCoordinator.isCancelRequested("boss-current")).thenReturn(false); + + ResponseEntity> response = controller.receiveChromeJobs(request); + + assertThat(response.getStatusCode().value()).isEqualTo(429); + assertThat(response.getBody()).containsEntry("rejectedCount", 1); + verify(bossService, never()).findExistingChromeBossJobs(any(), any(), any()); + verify(bossService, never()).reuseHistoricalBossJob(any(), any(), any()); + verify(queueService, never()).enqueue(any()); + } + + private BossController controller(BossService bossService, + ProfileService profileService, + ChromeJobAnalysisQueueService queueService, + JobRunCoordinator jobRunCoordinator) { + @SuppressWarnings("unchecked") + ObjectProvider bossProvider = mock(ObjectProvider.class); + return new BossController( + mock(BossJobService.class), + mock(PlaywrightManager.class), + mock(CookieService.class), + jobRunCoordinator, + mock(ConfigService.class), + bossProvider, + bossService, + profileService, + mock(JobAiAnalysisService.class), + queueService, + mock(Environment.class) + ); + } + private ChromeJobDto chromeJob(String id, String company, String title) { ChromeJobDto dto = new ChromeJobDto(); dto.setId(id); diff --git a/src/test/java/com/getjobs/application/mapper/BossStatsSqlProviderTest.java b/src/test/java/com/getjobs/application/mapper/BossStatsSqlProviderTest.java index 7a9d3e0..8fa360d 100644 --- a/src/test/java/com/getjobs/application/mapper/BossStatsSqlProviderTest.java +++ b/src/test/java/com/getjobs/application/mapper/BossStatsSqlProviderTest.java @@ -20,4 +20,15 @@ void minimumAiScoreExcludesNullAndLowerScoresInStatsQueries() { assertThat(provider.selectOverview(query)) .contains("ai_score >= #{minAiScore}"); } + + @Test + void currentRunStatsIncludeEveryStatusWithinTheRequestedScan() { + BossStatsQuery query = new BossStatsQuery(); + query.setProfileId(1L); + query.setScanRunId("boss-current"); + + assertThat(provider.selectKpi(query)).contains("scan_run_id = #{scanRunId}"); + assertThat(provider.selectOverview(query)).contains("scan_run_id = #{scanRunId}"); + assertThat(provider.selectStatusDistribution(query)).contains("scan_run_id = #{scanRunId}"); + } } diff --git a/src/test/java/com/getjobs/application/service/BossServiceAiScoreFilterTest.java b/src/test/java/com/getjobs/application/service/BossServiceAiScoreFilterTest.java index 726abcd..007c376 100644 --- a/src/test/java/com/getjobs/application/service/BossServiceAiScoreFilterTest.java +++ b/src/test/java/com/getjobs/application/service/BossServiceAiScoreFilterTest.java @@ -80,4 +80,39 @@ void jobLookupIsScopedToCurrentProfile() { assertThat(wrapper.getSqlSegment()).contains("id").contains("profile_id"); assertThat(wrapper.getParamNameValuePairs().values()).contains(99L, 1L); } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void currentRunListIncludesHistoricalReuseSource() { + BossJobDataEntity historical = new BossJobDataEntity(); + historical.setId(375L); + historical.setScanRunId("boss-current"); + historical.setScanResultSource(BossService.SCAN_RESULT_HISTORICAL); + when(bossJobDataMapper.selectList(any())).thenReturn(List.of(historical)); + + BossService.PagedResult result = bossService.listBossJobs( + null, + null, + null, + null, + null, + null, + null, + 1, + 20, + false, + " boss-current ", + null + ); + + ArgumentCaptor captor = ArgumentCaptor.forClass(QueryWrapper.class); + verify(bossJobDataMapper).selectList(captor.capture()); + assertThat(captor.getValue().getSqlSegment()).contains("scan_run_id"); + assertThat(captor.getValue().getParamNameValuePairs().values()).contains("boss-current"); + assertThat(result.items).singleElement().satisfies(item -> { + assertThat(item.getId()).isEqualTo(375L); + assertThat(item.getScanResultSource()).isEqualTo(BossService.SCAN_RESULT_HISTORICAL); + }); + assertThat(result.total).isEqualTo(1); + } } diff --git a/src/test/java/com/getjobs/application/service/BossServiceDedupeTest.java b/src/test/java/com/getjobs/application/service/BossServiceDedupeTest.java index d78fec9..d647e42 100644 --- a/src/test/java/com/getjobs/application/service/BossServiceDedupeTest.java +++ b/src/test/java/com/getjobs/application/service/BossServiceDedupeTest.java @@ -4,6 +4,7 @@ import com.getjobs.application.entity.BossJobDataEntity; import com.getjobs.application.mapper.BossJobDataMapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -11,12 +12,14 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.time.LocalDateTime; import java.util.List; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -116,6 +119,22 @@ void batchDedupeSkipsDatabaseWhenNoLookupKeyExists() { ); } + @Test + void batchDedupeDoesNotFallbackToCompanyTitleWhenStableIdMismatches() { + ChromeJobDto spoofed = chromeJob("spoofed-id", null, "相同公司", "相同岗位"); + BossJobDataEntity companyTitleMatched = bossJob(13L, "real-id", "相同公司", "相同岗位"); + when(bossJobDataMapper.selectExistingChromeBossJobs( + eq(1L), + org.mockito.ArgumentMatchers.anyList(), + org.mockito.ArgumentMatchers.anyList(), + org.mockito.ArgumentMatchers.anyList() + )).thenReturn(List.of(companyTitleMatched)); + + Map result = bossService.findExistingChromeBossJobs(1L, List.of(spoofed), null); + + assertThat(result).isEmpty(); + } + @Test void upsertUpdatesHistoricalJobAcrossScanRunsInsteadOfCreatingDuplicateRow() { BossJobDataEntity existing = bossJob(21L, "job-history", "历史公司", "Java工程师"); @@ -138,10 +157,47 @@ void upsertUpdatesHistoricalJobAcrossScanRunsInsteadOfCreatingDuplicateRow() { BossJobDataEntity updated = updateCaptor.getValue(); assertThat(updated.getId()).isEqualTo(21L); assertThat(updated.getScanRunId()).isEqualTo("run-new"); + assertThat(updated.getScanResultSource()).isEqualTo(BossService.SCAN_RESULT_CURRENT); assertThat(updated.getJobDescription()).contains("完整岗位要求"); verify(bossJobDataMapper, never()).insert(any(BossJobDataEntity.class)); } + @Test + void reuseHistoricalJobOnlyUpdatesScanOwnershipFields() { + LocalDateTime createdAt = LocalDateTime.of(2026, 7, 18, 10, 0); + LocalDateTime updatedAt = LocalDateTime.of(2026, 8, 24, 20, 0); + BossJobDataEntity existing = bossJob(31L, "history-job", "历史公司", "历史岗位"); + existing.setCreatedAt(createdAt); + existing.setUpdatedAt(updatedAt); + existing.setAiScore(58); + existing.setAiReason("历史分析结果"); + when(bossJobDataMapper.update(isNull(), any(UpdateWrapper.class))).thenReturn(1); + when(bossJobDataMapper.selectOne(any(QueryWrapper.class))).thenReturn(existing); + + BossJobDataEntity restored = bossService.reuseHistoricalBossJob(31L, 1L, " boss-new "); + + ArgumentCaptor> wrapperCaptor = ArgumentCaptor.forClass(UpdateWrapper.class); + verify(bossJobDataMapper).update(isNull(), wrapperCaptor.capture()); + String sqlSet = wrapperCaptor.getValue().getSqlSet(); + assertThat(sqlSet).contains("scan_run_id", "scan_result_source").doesNotContain("updated_at", "created_at", "ai_"); + assertThat(wrapperCaptor.getValue().getParamNameValuePairs().values()) + .contains("boss-new", BossService.SCAN_RESULT_HISTORICAL); + assertThat(restored.getCreatedAt()).isEqualTo(createdAt); + assertThat(restored.getUpdatedAt()).isEqualTo(updatedAt); + assertThat(restored.getAiScore()).isEqualTo(58); + assertThat(restored.getAiReason()).isEqualTo("历史分析结果"); + } + + @Test + void reuseHistoricalJobRejectsConcurrentStateChangeWithoutReturningData() { + when(bossJobDataMapper.update(isNull(), any(UpdateWrapper.class))).thenReturn(0); + + BossJobDataEntity restored = bossService.reuseHistoricalBossJob(31L, 1L, "boss-new"); + + assertThat(restored).isNull(); + verify(bossJobDataMapper, never()).selectOne(any(QueryWrapper.class)); + } + private ChromeJobDto chromeJob(String id, String url, String company, String title) { ChromeJobDto dto = new ChromeJobDto(); dto.setId(id); diff --git a/src/test/java/com/getjobs/application/service/DatabaseMigrationTest.java b/src/test/java/com/getjobs/application/service/DatabaseMigrationTest.java index 18fe60e..33bfb52 100644 --- a/src/test/java/com/getjobs/application/service/DatabaseMigrationTest.java +++ b/src/test/java/com/getjobs/application/service/DatabaseMigrationTest.java @@ -20,7 +20,7 @@ class DatabaseMigrationTest { Path tempDir; @Test - void freshDatabaseMigratesThroughV8AndMatchesSchemaContract() throws Exception { + void freshDatabaseMigratesThroughV11AndMatchesSchemaContract() throws Exception { String url = sqliteUrl(tempDir.resolve("fresh.db")); Flyway flyway = flyway(url); @@ -29,11 +29,11 @@ void freshDatabaseMigratesThroughV8AndMatchesSchemaContract() throws Exception { try (Connection connection = DriverManager.getConnection(url)) { DatabaseSchemaService.validateSchema(connection); assertThat(scalar(connection, - "SELECT COUNT(*) FROM flyway_schema_history WHERE success=1 AND version='8'")) + "SELECT COUNT(*) FROM flyway_schema_history WHERE success=1 AND version='11'")) .isEqualTo(1L); assertThat(columns(connection, "ai")).contains("apply_threshold", "priority_apply_threshold"); assertThat(columns(connection, "boss_data")) - .contains("source_keyword", "salary_min_k", "salary_max_k", "salary_median_k", "salary_months"); + .contains("source_keyword", "scan_result_source", "salary_min_k", "salary_max_k", "salary_median_k", "salary_months"); assertThat(columns(connection, "liepin_data")).contains("id", "profile_id", "job_id", "delivery_status"); assertThat(columns(connection, "job51_data")).contains("id", "profile_id", "job_id", "delivery_status"); assertThat(tableExists(connection, "delivery_attempt")).isTrue(); @@ -43,6 +43,32 @@ void freshDatabaseMigratesThroughV8AndMatchesSchemaContract() throws Exception { } } + @Test + void v11BackfillsHistoricalBossRowsWithoutChangingBusinessTimestamps() throws Exception { + String url = sqliteUrl(tempDir.resolve("boss-scan-source.db")); + Flyway.configure() + .dataSource(url, null, null) + .locations("classpath:db/migration") + .target("9") + .load() + .migrate(); + try (Connection connection = DriverManager.getConnection(url); Statement statement = connection.createStatement()) { + statement.execute("INSERT INTO profile(id, name, is_active) VALUES (1, 'profile', 1)"); + statement.execute("INSERT INTO boss_data(id, profile_id, encrypt_id, company_name, job_name, scan_run_id, created_at, updated_at) " + + "VALUES (99, 1, 'history-job', '历史公司', '历史岗位', 'boss-old', '2026-07-18 10:00:00', '2026-08-24 20:00:00')"); + } + + flyway(url).migrate(); + + try (Connection connection = DriverManager.getConnection(url); Statement statement = connection.createStatement(); + ResultSet result = statement.executeQuery("SELECT scan_result_source, created_at, updated_at FROM boss_data WHERE id=99")) { + assertThat(result.next()).isTrue(); + assertThat(result.getString("scan_result_source")).isEqualTo("CURRENT_SCAN"); + assertThat(result.getString("created_at")).isEqualTo("2026-07-18 10:00:00"); + assertThat(result.getString("updated_at")).isEqualTo("2026-08-24 20:00:00"); + } + } + @Test void v7PreservesLegacyAggregateRowsAndLeavesThemUndispatchable() throws Exception { String url = sqliteUrl(tempDir.resolve("legacy-ai-task.db")); From f800b11d63a7d2f3e6ac97aea5224bf41e668276 Mon Sep 17 00:00:00 2001 From: damingishere-coder Date: Wed, 2 Sep 2026 17:25:20 +0800 Subject: [PATCH 02/12] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=E7=BA=BF=E4=B8=8A=E6=95=B0=E6=8D=AE=E5=BA=93=20V10=20?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../V10__unique_zhilian_profile_job.java | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 src/main/java/db/migration/V10__unique_zhilian_profile_job.java diff --git a/src/main/java/db/migration/V10__unique_zhilian_profile_job.java b/src/main/java/db/migration/V10__unique_zhilian_profile_job.java new file mode 100644 index 0000000..1028143 --- /dev/null +++ b/src/main/java/db/migration/V10__unique_zhilian_profile_job.java @@ -0,0 +1,64 @@ +package db.migration; + +import org.flywaydb.core.api.migration.BaseJavaMigration; +import org.flywaydb.core.api.migration.Context; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; + +/** + * 为智联稳定岗位标识建立唯一事实源。历史重复数据必须人工处理,迁移不会自动删除或合并。 + */ +public class V10__unique_zhilian_profile_job extends BaseJavaMigration { + @Override + public void migrate(Context context) throws Exception { + List duplicates = duplicateGroups(context); + if (!duplicates.isEmpty()) { + throw new IllegalStateException("智联岗位存在重复记录,拒绝自动清理: " + String.join("; ", duplicates)); + } + try (Statement statement = context.getConnection().createStatement()) { + statement.execute(""" + CREATE UNIQUE INDEX idx_zhilian_data_profile_job_id + ON zhilian_data(profile_id, TRIM(job_id)) + WHERE profile_id IS NOT NULL AND job_id IS NOT NULL AND TRIM(job_id) <> '' + """); + } + } + + private List duplicateGroups(Context context) throws Exception { + List groups = new ArrayList<>(); + try (PreparedStatement duplicateQuery = context.getConnection().prepareStatement(""" + SELECT profile_id, TRIM(job_id) AS job_id + FROM zhilian_data + WHERE profile_id IS NOT NULL AND job_id IS NOT NULL AND TRIM(job_id) <> '' + GROUP BY profile_id, TRIM(job_id) + HAVING COUNT(*) > 1 + ORDER BY profile_id, TRIM(job_id) + """); + ResultSet duplicates = duplicateQuery.executeQuery()) { + while (duplicates.next()) { + long profileId = duplicates.getLong("profile_id"); + String jobId = duplicates.getString("job_id"); + groups.add("profile_id=" + profileId + ", job_id=" + jobId + ", ids=" + + rowIds(context, profileId, jobId)); + } + } + return groups; + } + + private List rowIds(Context context, long profileId, String jobId) throws Exception { + List ids = new ArrayList<>(); + try (PreparedStatement statement = context.getConnection().prepareStatement( + "SELECT id FROM zhilian_data WHERE profile_id=? AND TRIM(job_id)=? ORDER BY id")) { + statement.setLong(1, profileId); + statement.setString(2, jobId); + try (ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) ids.add(resultSet.getLong("id")); + } + } + return ids; + } +} From d2959cc0991c61a7df13f9982ea4c56572b87c94 Mon Sep 17 00:00:00 2001 From: damingishere-coder Date: Thu, 3 Sep 2026 10:49:38 +0800 Subject: [PATCH 03/12] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=B2=97?= =?UTF-8?q?=E4=BD=8D=E5=85=B3=E9=94=AE=E8=AF=8D=E6=8E=A8=E8=8D=90=E5=B9=B6?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=89=A9=E5=B1=95=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- chrome-extension/background.js | 70 +++++--- chrome-extension/boss-api-collector.js | 36 +++- chrome-extension/boss-content.js | 113 ++++++++++--- chrome-extension/boss-scan-support.js | 34 +++- chrome-extension/manifest.json | 3 +- chrome-extension/page-bridge.js | 1 + .../tests/background-tab-routing.test.cjs | 44 ++--- .../tests/boss-api-collector.test.cjs | 1 + .../tests/boss-scan-support.test.cjs | 20 ++- chrome-extension/tests/manifest-id.test.cjs | 29 ++++ .../tests/zhilian-scan-support.test.cjs | 20 ++- chrome-extension/zhilian-content.js | 157 +++++++++++++++--- chrome-extension/zhilian-scan-support.js | 21 ++- front/app/ai-config/page.tsx | 6 +- front/app/boss/page.tsx | 94 ++++++----- front/app/components/KeywordTagInput.test.tsx | 43 +++++ front/app/components/KeywordTagInput.tsx | 127 ++++++++++++++ front/app/zhilian/page.tsx | 86 ++++------ front/lib/job-keywords.test.ts | 24 +++ front/lib/job-keywords.ts | 57 +++++++ .../application/config/CorsConfig.java | 27 +-- .../controller/AiConfigController.java | 24 ++- .../controller/BossConfigController.java | 53 +----- .../controller/BossController.java | 3 +- .../controller/GlobalExceptionHandler.java | 9 + .../controller/ZhilianController.java | 29 ++++ .../entity/ResumeProfileEntity.java | 3 + .../application/service/AiService.java | 24 ++- .../service/JobAiAnalysisService.java | 31 +++- .../application/service/JobKeywordCodec.java | 87 ++++++++++ .../application/service/ZhilianService.java | 6 +- ...add_resume_job_keyword_recommendations.sql | 1 + .../application/config/CorsConfigTest.java | 49 ++++++ .../AiConfigControllerJobKeywordTest.java | 68 ++++++++ .../BossConfigControllerContractTest.java | 21 +++ .../GlobalExceptionHandlerTest.java | 21 +++ .../service/AiServiceResumeKeywordTest.java | 36 ++++ .../service/DatabaseMigrationTest.java | 32 +++- .../service/JobAiKeywordPersistenceTest.java | 82 +++++++++ .../service/JobKeywordCodecTest.java | 35 ++++ .../service/ZhilianServiceKeywordTest.java | 31 ++++ tasks/2026-09-03-job-keyword-cors-scan-fix.md | 69 ++++++++ 42 files changed, 1461 insertions(+), 266 deletions(-) create mode 100644 chrome-extension/tests/manifest-id.test.cjs create mode 100644 front/app/components/KeywordTagInput.test.tsx create mode 100644 front/app/components/KeywordTagInput.tsx create mode 100644 front/lib/job-keywords.test.ts create mode 100644 front/lib/job-keywords.ts create mode 100644 src/main/java/com/getjobs/application/service/JobKeywordCodec.java create mode 100644 src/main/resources/db/migration/V12__add_resume_job_keyword_recommendations.sql create mode 100644 src/test/java/com/getjobs/application/controller/AiConfigControllerJobKeywordTest.java create mode 100644 src/test/java/com/getjobs/application/controller/GlobalExceptionHandlerTest.java create mode 100644 src/test/java/com/getjobs/application/service/AiServiceResumeKeywordTest.java create mode 100644 src/test/java/com/getjobs/application/service/JobAiKeywordPersistenceTest.java create mode 100644 src/test/java/com/getjobs/application/service/JobKeywordCodecTest.java create mode 100644 src/test/java/com/getjobs/application/service/ZhilianServiceKeywordTest.java create mode 100644 tasks/2026-09-03-job-keyword-cors-scan-fix.md diff --git a/chrome-extension/background.js b/chrome-extension/background.js index 4e2ca4c..0afbdb7 100644 --- a/chrome-extension/background.js +++ b/chrome-extension/background.js @@ -32,14 +32,14 @@ const PLATFORM_SHARED_SCAN_KEYS = { boss: ["__GET_JOBS_BOSS_SHARED_SCAN_TASK__", "__GET_JOBS_BOSS_SHARED_SCAN_CANCEL__"], zhilian: ["__GET_JOBS_ZHILIAN_SHARED_SCAN_TASK__", "__GET_JOBS_ZHILIAN_SHARED_SCAN_CANCEL__"] }; -const BACKGROUND_VERSION = "2026-08-01-consolidated-scan-fix"; +const BACKGROUND_VERSION = "2026-09-03-keyword-cors-recovery"; const CONTENT_READY_RETRIES = 12; const CONTENT_READY_INTERVAL_MS = 250; const TAB_LOAD_TIMEOUT_MS = 10000; const DELIVERY_NAVIGATION_TIMEOUT_MS = 15000; -const REQUIRED_BOSS_CONTENT_VERSION = "2026-08-01-consolidated-boss-api"; -const REQUIRED_ZHILIAN_CONTENT_VERSION = "2026-07-29-zhilian-security-resume-fix"; -const LOCAL_API_BASE_URLS = ["http://localhost:6866", "http://127.0.0.1:6866", "http://localhost:8888", "http://127.0.0.1:8888"]; +const REQUIRED_BOSS_CONTENT_VERSION = "2026-09-03-keyword-deep-fill"; +const REQUIRED_ZHILIAN_CONTENT_VERSION = "2026-09-03-keyword-deep-fill"; +const LOCAL_API_BASE_URLS = ["http://localhost:8888", "http://127.0.0.1:8888", "http://localhost:6866", "http://127.0.0.1:6866"]; const BOSS_LOCAL_API_MAX_ATTEMPTS = 3; const BOSS_LOCAL_API_TIMEOUT_MS = 30000; const ALLOWED_PAGE_ORIGINS = new Set([ @@ -419,6 +419,9 @@ function resolveBossLocalApiEndpoint(message) { function resolveZhilianLocalApiEndpoint(message) { const operation = String(message?.operation || ""); + if (operation === "chrome-jobs-dedupe") { + return { success: true, method: "POST", path: "/api/zhilian/chrome/jobs/dedupe" }; + } if (operation === "chrome-jobs") { return { success: true, method: "POST", path: "/api/zhilian/chrome/jobs" }; } @@ -447,18 +450,20 @@ async function requestLocalApi(path, options = {}) { try { const response = await fetchWithTimeout(`${baseUrl}${path}`, requestOptions, timeoutMs); const data = await parseLocalApiResponse(response); + if (data.success === false) { + return { + success: false, + httpStatus: response.status, + data, + message: data.message || "本地接口拒绝了本次请求", + errorType: "BUSINESS_REJECTED", + attempt, + baseUrl + }; + } + const expectedState = options.expectedState || String(options.body?.outcome || "").toUpperCase(); + assertExpectedLocalApiPayload(data, options.operation, expectedState); if (response.ok) { - if (data && data.success === false) { - return { - success: false, - httpStatus: response.status, - data, - message: data.message || "本地接口拒绝了本次请求", - errorType: "BUSINESS_REJECTED", - attempt, - baseUrl - }; - } return { success: true, httpStatus: response.status, data, attempt, baseUrl }; } lastError = new Error(data?.message || `本地接口返回 HTTP ${response.status}`); @@ -502,12 +507,39 @@ async function fetchWithTimeout(url, options, timeoutMs) { } async function parseLocalApiResponse(response) { + const contentType = String(response.headers?.get?.("content-type") || "").toLowerCase(); + if (!contentType.includes("application/json")) { + throw new Error(`本地接口契约不匹配:Content-Type=${contentType || "missing"}`); + } const text = await response.text(); - if (!text) return {}; + if (!text) throw new Error("本地接口契约不匹配:JSON 响应为空"); + let data; try { - return JSON.parse(text); + data = JSON.parse(text); } catch { - return { message: text }; + throw new Error("本地接口契约不匹配:响应不是合法 JSON"); + } + if (!data || Array.isArray(data) || typeof data !== "object" || typeof data.success !== "boolean") { + throw new Error("本地接口契约不匹配:缺少 success 业务字段"); + } + return data; +} + +function assertExpectedLocalApiPayload(data, operation, expectedState) { + const name = String(operation || ""); + if (name === "chrome-jobs" && !["received", "saved", "queued"].every((field) => Number.isFinite(Number(data[field])))) { + throw new Error("本地接口契约不匹配:岗位批次响应缺少 received/saved/queued"); + } + if (name === "chrome-jobs-dedupe" && !Array.isArray(data.items)) { + throw new Error("本地接口契约不匹配:岗位查重响应缺少 items"); + } + if (name === "ai-keywords" && !Array.isArray(data.keywords)) { + throw new Error("本地接口契约不匹配:AI 关键词响应缺少 keywords"); + } + if (name === "delivery-result" + && (data.accepted !== true || !["CONFIRMED", "FAILED", "UNKNOWN"].includes(expectedState) + || data.state !== expectedState)) { + throw new Error("本地接口契约不匹配:投递回写必须 accepted=true 且 state 与请求一致"); } } @@ -524,7 +556,7 @@ function isRetryableLocalApiStatus(status) { function friendlyLocalApiError(error) { const message = error?.message || String(error || ""); if (error?.name === "AbortError" || /abort/i.test(message)) return "请求超时,请确认本地服务仍在运行"; - if (/Failed to fetch|NetworkError|fetch/i.test(message)) return "无法连接本地服务,请确认 6866 端口正常"; + if (/Failed to fetch|NetworkError|fetch/i.test(message)) return "无法连接本地后端,请确认 8888 端口正常"; return message || "未知网络错误"; } diff --git a/chrome-extension/boss-api-collector.js b/chrome-extension/boss-api-collector.js index b53e1fb..e8f26cb 100644 --- a/chrome-extension/boss-api-collector.js +++ b/chrome-extension/boss-api-collector.js @@ -100,13 +100,13 @@ function parsePageResult(pageResult, request) { const httpStatus = Number(pageResult?.httpStatus || 0); const pageState = pageResult?.pageState || {}; - if (pageState.isSecurityPage) { + if (pageState.isSecurityPage === true) { return diagnosticResult("SECURITY_VERIFICATION", { httpStatus, request, apiMessage: "Boss 页面要求安全验证" }); } - if (pageState.isLoginPage) { + if (pageState.isLoginPage === true) { return diagnosticResult("LOGIN_REQUIRED", { httpStatus, request, apiMessage: "Boss 登录状态已失效" }); } - if (!pageResult?.success) { + if (pageResult?.success !== true) { return diagnosticResult("API_REQUEST_FAILED", { httpStatus, request, @@ -116,7 +116,7 @@ const data = pageResult.data; if (!data || typeof data !== "object" || Array.isArray(data)) { - return diagnosticResult(pageResult?.responseOk ? "API_SCHEMA_CHANGED" : "API_REQUEST_FAILED", { + return diagnosticResult(pageResult?.responseOk === true ? "API_SCHEMA_CHANGED" : "API_REQUEST_FAILED", { httpStatus, request, apiMessage: compact(pageResult?.parseError) || "Boss 搜索接口未返回 JSON 对象" @@ -134,7 +134,7 @@ if (looksLikeLoginMessage(apiMessage)) { return diagnosticResult("LOGIN_REQUIRED", { apiCode, apiMessage, httpStatus, request }); } - if (!pageResult?.responseOk) { + if (pageResult?.responseOk !== true) { return diagnosticResult("API_REQUEST_FAILED", { apiCode, apiMessage, httpStatus, request }); } if (apiCode !== 0) { @@ -150,7 +150,18 @@ return diagnosticResult("API_EMPTY", { apiCode, apiMessage, httpStatus, request }); } - const jobs = data.zpData.jobList.slice(0, request.pageSize).map((job) => mapJob(job, request.keyword)); + const mappedJobs = data.zpData.jobList.slice(0, request.pageSize).map((job) => mapJob(job, request.keyword)); + const invalidJobCount = mappedJobs.filter((job) => !isValidApiCandidateJob(job)).length; + if (invalidJobCount > 0) { + return diagnosticResult("API_SCHEMA_CHANGED", { + apiCode, + apiMessage: `Boss 搜索接口包含 ${invalidJobCount} 条缺少稳定标识、岗位、公司或链接的记录`, + httpStatus, + request, + invalidJobCount + }); + } + const jobs = mappedJobs; const missingSalaryCount = jobs.filter((job) => !job.salary).length; const diagnosticType = missingSalaryCount > 0 ? "API_SALARY_MISSING" : "API_SUCCESS"; return diagnosticResult(diagnosticType, { @@ -211,12 +222,25 @@ jobs, candidateCount: Number(details.candidateCount ?? jobs.length ?? 0), missingSalaryCount: Number(details.missingSalaryCount || 0), + invalidJobCount: Number(details.invalidJobCount || 0), fallbackUsed: false, collectorSource: details.collectorSource || (jobs.length ? "boss-search-api" : "none"), request: details.request || null }; } + function isValidApiCandidateJob(job) { + if (!compact(job?.id) || !compact(job?.title) || !compact(job?.company) || !compact(job?.url)) return false; + try { + const url = new URL(job.url); + return url.protocol === "https:" + && /(^|\.)zhipin\.com$/i.test(url.hostname) + && /^\/job_detail\/[^/?#]+\.html$/i.test(url.pathname); + } catch { + return false; + } + } + async function requestInPage(request) { if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) { throw new Error("Chrome 扩展消息通道不可用"); diff --git a/chrome-extension/boss-content.js b/chrome-extension/boss-content.js index c3cb7b5..ceeb1ad 100644 --- a/chrome-extension/boss-content.js +++ b/chrome-extension/boss-content.js @@ -1,5 +1,5 @@ (function () { - const EXTENSION_VERSION = "2026-08-01-consolidated-boss-api"; + const EXTENSION_VERSION = "2026-09-03-keyword-deep-fill"; const CONTENT_INSTANCE_ID = `${Date.now()}-${Math.random().toString(16).slice(2)}`; window.__GET_JOBS_BOSS_CONTENT__ = true; window.__GET_JOBS_BOSS_CONTENT_VERSION__ = EXTENSION_VERSION; @@ -619,11 +619,11 @@ || SCAN_SUPPORT.classifyLocalApiFailure?.(error) || (/Invalid CORS request|cors/i.test(text) ? "CORS_REJECTED" : /超时|timeout|abort/i.test(text) ? "LOCAL_API_TIMEOUT" - : /无法连接|Failed to fetch|NetworkError|本地服务请求失败|6866端口/i.test(text) ? "LOCAL_SERVICE_UNAVAILABLE" + : /无法连接|Failed to fetch|NetworkError|本地服务请求失败|(?:8888|6866)端口/i.test(text) ? "LOCAL_SERVICE_UNAVAILABLE" : "LOCAL_API_ERROR"); const catalog = { CORS_REJECTED: ["Chrome扩展请求被后端CORS规则拒绝", "岗位已采集但本批尚未入库,扫描断点已保留。", "重启更新后的本地服务并重新加载Chrome扩展,然后继续扫描。"], - LOCAL_SERVICE_UNAVAILABLE: ["无法连接本地服务", "岗位暂时无法入库,扫描断点已保留。", "确认投递牛马本地服务和6866端口正常后,再次点击扫描继续。"], + LOCAL_SERVICE_UNAVAILABLE: ["无法连接本地服务", "岗位暂时无法入库,扫描断点已保留。", "确认投递牛马本地后端和8888端口正常后,再次点击扫描继续。"], LOCAL_API_TIMEOUT: ["本地服务响应超时", "当前提交批次未确认完成,扫描断点已保留。", "确认本地服务仍在运行后继续扫描,系统会从当前批次恢复。"], LOCAL_API_FORBIDDEN: ["本地接口拒绝访问", "当前提交批次未入库。", "重新加载扩展并确认使用的是本项目本地页面。"], LOCAL_API_NOT_FOUND: ["本地接口不存在或版本不匹配", "扩展无法提交岗位。", "重启最新版本的本地服务并重新加载扩展。"], @@ -1145,8 +1145,24 @@ invalidDetailCandidates, searchJobLimit, discoveryRounds: discoveryResult.rounds, - stoppedByStagnation: discoveryResult.stoppedByStagnation + stoppedByStagnation: discoveryResult.stoppedByStagnation, + stopReason: discoveryResult.stopReason, + stopReasonLabel: collectionStopReasonLabel(discoveryResult.stopReason), + elapsedMs: discoveryResult.elapsedMs }); + if (jobs.length < searchJobLimit) { + postProgress(task, "warning", `Boss关键词 ${keyword} 未补足目标:新岗位 ${jobs.length}/${searchJobLimit},停止原因:${collectionStopReasonLabel(discoveryResult.stopReason)}。`, { + ...baseMeta, + stage: "dedupe", + candidateCount: discoveryResult.candidateCount, + conditionFiltered: discoveryResult.filteredCount, + duplicates: discoveryResult.duplicateCount, + fresh: jobs.length, + discoveryRounds: discoveryResult.rounds, + stopReason: discoveryResult.stopReason, + elapsedMs: discoveryResult.elapsedMs + }); + } if (!jobs.length && !historicalCandidates.length) { postProgress(task, "warning", `Boss关键词 ${keyword} 已继续向下采集,但没有找到新的可分析岗位,跳过本关键词。`, { ...baseMeta, @@ -1194,15 +1210,6 @@ if (isStopRequested(runId)) stopRequested = true; - if (!stopRequested && shouldAppendAiKeywords(task) && !task.aiKeywordsLoaded) { - const aiResult = await appendAiKeywords(task, keywords); - task = aiResult.task; - keywords = aiResult.keywords; - if (Number(task.currentIndex || 0) < keywords.length) { - return runScanInternal(task); - } - } - if (!stopRequested) { advanceKeywordCursor(task, userKeywordCount(task), ""); } @@ -1486,8 +1493,12 @@ async function collectFreshJobsForKeyword(keyword, message, baseMeta, searchJobLimit, initialCandidates = []) { const target = normalizeSearchJobLimit(searchJobLimit); - const maxRounds = bossDiscoveryMaxRounds(target); - const maxCandidates = bossDiscoveryCandidateLimit(target); + const bounds = typeof SCAN_SUPPORT.deepCollectionBounds === "function" + ? SCAN_SUPPORT.deepCollectionBounds(target) + : { target, maxRounds: 30, maxCandidates: 500, maxDurationMs: 180000, maxStagnantRounds: 5 }; + const maxRounds = bounds.maxRounds; + const maxCandidates = bounds.maxCandidates; + const startedAt = Date.now(); const allCandidates = new Map(); const processableJobs = new Map(); const duplicateKeys = new Set(); @@ -1500,6 +1511,7 @@ let stagnantRounds = 0; let stoppedByStagnation = false; let roundsRan = 0; + let stopReason = ""; addUniqueJobs(allCandidates, initialCandidates, maxCandidates); @@ -1561,12 +1573,42 @@ }); } - if (stagnantRounds >= 2) { - stoppedByStagnation = true; + const elapsedMs = Date.now() - startedAt; + const stopState = { + target, + fresh: processableJobs.size, + candidates: allCandidates.size, + rounds: round + 1, + stagnantRounds, + elapsedMs, + platformExhausted: bossPlatformExhausted(), + stopped: isStopRequested() + }; + stopReason = typeof SCAN_SUPPORT.deepCollectionStopReason === "function" + ? SCAN_SUPPORT.deepCollectionStopReason(stopState) + : (processableJobs.size >= target ? "target_reached" : stagnantRounds >= bounds.maxStagnantRounds ? "stagnation_safety_cap" : ""); + if (stopReason) { + stoppedByStagnation = stopReason === "stagnation_safety_cap"; break; } } + if (!stopReason) { + const finalState = { + target, + fresh: processableJobs.size, + candidates: allCandidates.size, + rounds: roundsRan, + stagnantRounds, + elapsedMs: Date.now() - startedAt, + platformExhausted: bossPlatformExhausted(), + stopped: isStopRequested() + }; + stopReason = typeof SCAN_SUPPORT.deepCollectionStopReason === "function" + ? SCAN_SUPPORT.deepCollectionStopReason(finalState) + : "round_safety_cap"; + } + resetBossScrollPosition(); return { candidates: Array.from(allCandidates.values()), @@ -1578,7 +1620,9 @@ enrichCount: enrichKeys.size, skipCount: skipKeys.size, rounds: roundsRan, - stoppedByStagnation + stoppedByStagnation, + stopReason: stopReason || "round_safety_cap", + elapsedMs: Date.now() - startedAt }; } @@ -1640,12 +1684,41 @@ function bossDiscoveryCandidateLimit(searchJobLimit) { const limit = normalizeSearchJobLimit(searchJobLimit); - return Math.min(300, Math.max(80, limit * 8)); + return typeof SCAN_SUPPORT.deepCollectionBounds === "function" + ? SCAN_SUPPORT.deepCollectionBounds(limit).maxCandidates + : 500; } function bossDiscoveryMaxRounds(searchJobLimit) { const limit = normalizeSearchJobLimit(searchJobLimit); - return Math.min(18, Math.max(6, Math.ceil(limit / 5) + 5)); + return typeof SCAN_SUPPORT.deepCollectionBounds === "function" + ? SCAN_SUPPORT.deepCollectionBounds(limit).maxRounds + : 30; + } + + function bossPlatformExhausted() { + const bodyText = compact(document.body?.innerText || document.body?.textContent || ""); + if (!/(没有更多|暂无更多|已经到底|到底了|全部加载完)/.test(bodyText.slice(-3000))) return false; + const pageHeight = Number(document.documentElement?.scrollHeight || document.body?.scrollHeight || 0); + const windowAtBottom = Number(window.scrollY || 0) + Number(window.innerHeight || 0) >= pageHeight - 40; + const containers = bossScrollableContainers(); + const containersAtBottom = !containers.length || containers.some((target) => + Number(target.scrollTop || 0) + Number(target.clientHeight || 0) >= Number(target.scrollHeight || 0) - 40); + return windowAtBottom || containersAtBottom; + } + + function collectionStopReasonLabel(reason) { + return ({ + target_reached: "已达到目标", + platform_exhausted: "平台结果已到底", + stagnation_safety_cap: "连续5轮没有新增", + timeout_safety_cap: "单关键词采集达到180秒", + candidate_safety_cap: "候选岗位达到500个安全上限", + round_safety_cap: "滚动达到30轮安全上限", + page_safety_cap: "翻页达到安全上限", + blocked: "平台安全验证或页面阻断", + stopped: "用户停止扫描" + })[String(reason || "")] || "安全边界已触发"; } async function scrollForMoreBossCards(round = 0) { diff --git a/chrome-extension/boss-scan-support.js b/chrome-extension/boss-scan-support.js index 3e6f92e..05bead6 100644 --- a/chrome-extension/boss-scan-support.js +++ b/chrome-extension/boss-scan-support.js @@ -1,8 +1,10 @@ (function (root) { - const SUPPORT_VERSION = "2026-09-02-boss-history-reuse"; + const SUPPORT_VERSION = "2026-09-03-keyword-deep-fill"; if (root.GetJobsBossScanSupport?.version === SUPPORT_VERSION) return; const DEFAULT_TASK_TTL_MS = 24 * 60 * 60 * 1000; + const DEEP_COLLECTION_MAX_DURATION_MS = 180 * 1000; + const DEEP_COLLECTION_MAX_STAGNANT_ROUNDS = 5; const DEGREE_NAME_BY_CODE = Object.freeze({ "0": "不限", "209": "初中及以下", @@ -18,6 +20,30 @@ return DEGREE_NAME_BY_CODE[String(value ?? "").trim()] || ""; } + function deepCollectionBounds(targetValue) { + const target = Math.max(1, Math.min(200, Math.floor(Number(targetValue) || 20))); + return { + target, + maxRounds: 30, + maxCandidates: 500, + maxDurationMs: DEEP_COLLECTION_MAX_DURATION_MS, + maxStagnantRounds: DEEP_COLLECTION_MAX_STAGNANT_ROUNDS + }; + } + + function deepCollectionStopReason(state = {}) { + const bounds = deepCollectionBounds(state.target); + if (state.stopped) return "stopped"; + if (Number(state.fresh || 0) >= bounds.target) return "target_reached"; + if (state.blocked) return "blocked"; + if (state.platformExhausted) return "platform_exhausted"; + if (Number(state.elapsedMs || 0) >= bounds.maxDurationMs) return "timeout_safety_cap"; + if (Number(state.candidates || 0) >= bounds.maxCandidates) return "candidate_safety_cap"; + if (Number(state.stagnantRounds || 0) >= bounds.maxStagnantRounds) return "stagnation_safety_cap"; + if (Number(state.rounds || 0) >= bounds.maxRounds) return "round_safety_cap"; + return ""; + } + function isBossSecurityInstructionText(value) { const text = String(value || "").replace(/\s+/g, " ").trim(); if (!text) return false; @@ -220,7 +246,7 @@ const text = String(error?.message || error || ""); if (/Invalid CORS request|cors/i.test(text)) return "CORS_REJECTED"; if (/超时|timeout|abort/i.test(text)) return "LOCAL_API_TIMEOUT"; - if (/无法连接|Failed to fetch|NetworkError|本地服务请求失败|6866端口/i.test(text)) { + if (/无法连接|Failed to fetch|NetworkError|本地服务请求失败|(?:8888|6866)端口/i.test(text)) { return "LOCAL_SERVICE_UNAVAILABLE"; } return "LOCAL_API_ERROR"; @@ -233,6 +259,10 @@ root.GetJobsBossScanSupport = Object.freeze({ version: SUPPORT_VERSION, DEFAULT_TASK_TTL_MS, + DEEP_COLLECTION_MAX_DURATION_MS, + DEEP_COLLECTION_MAX_STAGNANT_ROUNDS, + deepCollectionBounds, + deepCollectionStopReason, DEGREE_NAME_BY_CODE, degreeNameForCode, isBossSecurityInstructionText, diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json index 5f91985..c7daee3 100644 --- a/chrome-extension/manifest.json +++ b/chrome-extension/manifest.json @@ -1,7 +1,8 @@ { "manifest_version": 3, "name": "投递牛马 Chrome Bridge", - "version": "1.3.0", + "version": "1.4.0", + "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzzdIlNVOv76Y/cSWrjD5Tg2Vlsha8yWHzsn46PBsg724/2dftOUzIIr2n70VRaRgGwEd8FjO/Y768Ori443zF4pQpWvuxXxm05YO25ILQ/+aJLmUycAEdWbkdhcagr4YXnXJdYlSCGSAToSQBjk+owQOdlBLQn5wofPoshrqayoJjRQ5aAUj1SuSlnNv9iimle8GMA1IaA1l5rw6K/chfcgwMTg6HxRAIoludt5JGbIBryi2Lu1hOJRMaDnL7A57ofBnn3qx3H2HIGWGkkTW9EMkls0XMXwx8+mJVIj5HSYl0EeuCvEoTa1W3i1CbOf3kY2yCPKS3Qz3lOvJiwJ4ZQIDAQAB", "description": "Use the signed-in Chrome tabs to scan jobs and confirm deliveries for 投递牛马.", "icons": { "16": "icons/icon16.png", diff --git a/chrome-extension/page-bridge.js b/chrome-extension/page-bridge.js index 6327f12..c478442 100644 --- a/chrome-extension/page-bridge.js +++ b/chrome-extension/page-bridge.js @@ -20,6 +20,7 @@ "ZHILIAN_SCAN_STATUS", "ZHILIAN_SCAN_START", "ZHILIAN_SCAN_STOP", + "ZHILIAN_PAGE_STATUS", "ZHILIAN_DELIVER_ONE", "ZHILIAN_DELIVER_BATCH" ]); diff --git a/chrome-extension/tests/background-tab-routing.test.cjs b/chrome-extension/tests/background-tab-routing.test.cjs index 51d007b..f42780d 100644 --- a/chrome-extension/tests/background-tab-routing.test.cjs +++ b/chrome-extension/tests/background-tab-routing.test.cjs @@ -16,6 +16,19 @@ function readContentVersion(file) { const BOSS_CONTENT_VERSION = readContentVersion("boss-content.js"); const ZHILIAN_CONTENT_VERSION = readContentVersion("zhilian-content.js"); +function jsonResponse(body, { ok = true, status = 200 } = {}) { + return { + ok, + status, + headers: { + get(name) { + return String(name).toLowerCase() === "content-type" ? "application/json; charset=utf-8" : null; + } + }, + async text() { return JSON.stringify(body); } + }; +} + function loadBackground({ tabs, statuses = {}, @@ -253,11 +266,7 @@ test("allows Zhilian job submission through the fixed local API route", async () tabs: [], fetchImpl: async (url, options) => { requests.push({ url, options }); - return { - ok: true, - status: 200, - async text() { return JSON.stringify({ success: true, saved: 1 }); } - }; + return jsonResponse({ success: true, received: 1, saved: 1, queued: 1 }); } }); @@ -273,7 +282,7 @@ test("allows Zhilian job submission through the fixed local API route", async () assert.equal(response.success, true); assert.equal(response.data.saved, 1); assert.equal(requests.length, 1); - assert.equal(requests[0].url, "http://localhost:6866/api/zhilian/chrome/jobs"); + assert.equal(requests[0].url, "http://localhost:8888/api/zhilian/chrome/jobs"); assert.equal(requests[0].options.method, "POST"); }); @@ -283,7 +292,7 @@ test("allows numeric Zhilian delivery result IDs and rejects invalid or unknown tabs: [], fetchImpl: async (url) => { urls.push(url); - return { ok: true, status: 200, async text() { return '{"success":true}'; } }; + return jsonResponse({ success: true, accepted: true, state: "CONFIRMED" }); } }); const sender = { tab: { id: 9, url: "https://www.zhaopin.com/jobdetail/demo.htm" } }; @@ -293,7 +302,7 @@ test("allows numeric Zhilian delivery result IDs and rejects invalid or unknown type: "ZHILIAN_LOCAL_API", operation: "delivery-result", params: { id: 123 }, - body: { success: true } + body: { outcome: "CONFIRMED", evidence: "PLATFORM_STATUS_TEXT" } }, sender); const invalidId = await dispatchRuntimeMessage(runtimeMessageListener, { source: "GET_JOBS_ZHILIAN_CONTENT", @@ -309,7 +318,7 @@ test("allows numeric Zhilian delivery result IDs and rejects invalid or unknown }, sender); assert.equal(allowed.success, true); - assert.equal(urls[0], "http://localhost:6866/api/zhilian/jobs/123/delivery-result"); + assert.equal(urls[0], "http://localhost:8888/api/zhilian/jobs/123/delivery-result"); assert.equal(invalidId.success, false); assert.match(invalidId.message, /有效岗位ID/); assert.equal(unknown.success, false); @@ -320,11 +329,7 @@ test("allows numeric Zhilian delivery result IDs and rejects invalid or unknown test("treats HTTP 200 business rejection as a failed local API request", async () => { const { context } = loadBackground({ tabs: [], - fetchImpl: async () => ({ - ok: true, - status: 200, - async text() { return JSON.stringify({ success: false, message: "状态已变化" }); } - }) + fetchImpl: async () => jsonResponse({ success: false, message: "状态已变化" }) }); const result = await context.requestLocalApi("/api/boss/jobs/1/delivery-result", { @@ -344,7 +349,7 @@ test("records an empty Boss chat-page response as unknown instead of confirmed", tabs: [{ id: 7, windowId: 1, url: "https://www.zhipin.com/web/geek/chat", status: "complete" }], fetchImpl: async (url, options) => { requests.push({ url, body: JSON.parse(options.body) }); - return { ok: true, status: 200, async text() { return '{"success":true}'; } }; + return jsonResponse({ success: true, accepted: true, state: "UNKNOWN" }); } }); @@ -376,11 +381,10 @@ test("does not upgrade legacy success booleans to confirmed without explicit evi test("surfaces delivery-result persistence failure instead of reporting a stored outcome", async () => { const { context } = loadBackground({ tabs: [], - fetchImpl: async () => ({ - ok: false, - status: 500, - async text() { return JSON.stringify({ success: false, message: "database unavailable" }); } - }) + fetchImpl: async () => jsonResponse( + { success: false, message: "database unavailable" }, + { ok: false, status: 500 } + ) }); await assert.rejects( diff --git a/chrome-extension/tests/boss-api-collector.test.cjs b/chrome-extension/tests/boss-api-collector.test.cjs index 2fea66f..9516b12 100644 --- a/chrome-extension/tests/boss-api-collector.test.cjs +++ b/chrome-extension/tests/boss-api-collector.test.cjs @@ -9,6 +9,7 @@ function loadCollector() { window.window = window; const context = vm.createContext({ window, + URL, URLSearchParams, console, Set diff --git a/chrome-extension/tests/boss-scan-support.test.cjs b/chrome-extension/tests/boss-scan-support.test.cjs index 0ce1f56..2a734e1 100644 --- a/chrome-extension/tests/boss-scan-support.test.cjs +++ b/chrome-extension/tests/boss-scan-support.test.cjs @@ -12,6 +12,24 @@ function loadSupport() { return window.GetJobsBossScanSupport; } +test("uses the agreed Boss deep collection safety bounds", () => { + const support = loadSupport(); + assert.deepEqual( + JSON.parse(JSON.stringify(support.deepCollectionBounds(40))), + { target: 40, maxRounds: 30, maxCandidates: 500, maxDurationMs: 180000, maxStagnantRounds: 5 } + ); + assert.equal(support.deepCollectionStopReason({ target: 40, fresh: 40 }), "target_reached"); + assert.equal(support.deepCollectionStopReason({ target: 40, fresh: 12, stagnantRounds: 4 }), ""); + assert.equal(support.deepCollectionStopReason({ target: 40, fresh: 12, stagnantRounds: 5 }), "stagnation_safety_cap"); + assert.equal(support.deepCollectionStopReason({ target: 40, fresh: 12, elapsedMs: 180000 }), "timeout_safety_cap"); + assert.equal(support.deepCollectionStopReason({ target: 40, fresh: 12, platformExhausted: true }), "platform_exhausted"); +}); + +test("does not auto append AI keywords after a Boss scan", () => { + const source = fs.readFileSync(path.resolve(__dirname, "..", "boss-content.js"), "utf8"); + assert.doesNotMatch(source, /await appendAiKeywords\(task, keywords\)/); +}); + test("keeps an unfinished Boss checkpoint for 24 hours", () => { const support = loadSupport(); const now = Date.now(); @@ -242,7 +260,7 @@ test("classifies CORS and local service failures for actionable diagnostics", () "CORS_REJECTED" ); assert.equal( - support.classifyLocalApiFailure(new Error("无法连接本地服务,请确认6866端口正常")), + support.classifyLocalApiFailure(new Error("无法连接本地服务,请确认8888端口正常")), "LOCAL_SERVICE_UNAVAILABLE" ); }); diff --git a/chrome-extension/tests/manifest-id.test.cjs b/chrome-extension/tests/manifest-id.test.cjs new file mode 100644 index 0000000..1354449 --- /dev/null +++ b/chrome-extension/tests/manifest-id.test.cjs @@ -0,0 +1,29 @@ +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const EXPECTED_EXTENSION_ID = 'igmjpelbjhlglhegjbgmdbgfcdflmigp'; + +function extensionIdFromKey(key) { + const publicKey = Buffer.from(key, 'base64'); + const hash = crypto.createHash('sha256').update(publicKey).digest(); + return Array.from(hash.subarray(0, 16), (byte) => + `${String.fromCharCode(97 + (byte >> 4))}${String.fromCharCode(97 + (byte & 0x0f))}`, + ).join(''); +} + +test('manifest public key derives the backend allowlisted extension id', () => { + const manifestPath = path.join(__dirname, '..', 'manifest.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + assert.equal(manifest.version, '1.4.0'); + assert.equal(extensionIdFromKey(manifest.key), EXPECTED_EXTENSION_ID); + + const publicKey = crypto.createPublicKey({ + key: Buffer.from(manifest.key, 'base64'), + format: 'der', + type: 'spki', + }); + assert.equal(publicKey.asymmetricKeyType, 'rsa'); +}); diff --git a/chrome-extension/tests/zhilian-scan-support.test.cjs b/chrome-extension/tests/zhilian-scan-support.test.cjs index 61c8287..97b1083 100644 --- a/chrome-extension/tests/zhilian-scan-support.test.cjs +++ b/chrome-extension/tests/zhilian-scan-support.test.cjs @@ -17,10 +17,28 @@ test("replaces a stale Zhilian support module after extension reload", () => { const support = loadSupport(staleSupport); assert.notEqual(support, staleSupport); - assert.equal(support.version, "2026-07-29-zhilian-security-resume-fix"); + assert.equal(support.version, "2026-09-03-keyword-deep-fill"); assert.equal(typeof support.isZhilianUrl, "function"); }); +test("uses history-aware deep collection safety bounds for Zhilian", () => { + const support = loadSupport(); + assert.equal(support.DEEP_COLLECTION_MAX_PAGES, 50); + assert.equal(support.DEEP_COLLECTION_MAX_DURATION_MS, 180000); + assert.equal(support.DEEP_COLLECTION_MAX_STAGNANT_PAGES, 5); + assert.equal(support.deepCollectionStopReason({ target: 20, fresh: 20 }), "target_reached"); + assert.equal(support.deepCollectionStopReason({ target: 20, fresh: 8, stagnantPages: 4 }), ""); + assert.equal(support.deepCollectionStopReason({ target: 20, fresh: 8, stagnantPages: 5 }), "stagnation_safety_cap"); + assert.equal(support.deepCollectionStopReason({ target: 20, fresh: 8, platformExhausted: true }), "platform_exhausted"); +}); + +test("routes Zhilian history dedupe before adding page candidates", () => { + const source = fs.readFileSync(path.resolve(__dirname, "..", "zhilian-content.js"), "utf8"); + assert.match(source, /requestZhilianLocalApi\("chrome-jobs-dedupe"/); + assert.match(source, /historyDuplicateCount/); + assert.match(source, /stagnantPages/); +}); + test("does not treat normal Zhilian job descriptions as security verification", () => { const support = loadSupport(); const realFalsePositive = { diff --git a/chrome-extension/zhilian-content.js b/chrome-extension/zhilian-content.js index 432714d..d3af29f 100644 --- a/chrome-extension/zhilian-content.js +++ b/chrome-extension/zhilian-content.js @@ -1,5 +1,5 @@ (function () { - const EXTENSION_VERSION = "2026-07-29-zhilian-security-resume-fix"; + const EXTENSION_VERSION = "2026-09-03-keyword-deep-fill"; const CONTENT_INSTANCE_ID = `${Date.now()}-${Math.random().toString(16).slice(2)}`; window.__GET_JOBS_ZHILIAN_CONTENT__ = true; window.__GET_JOBS_ZHILIAN_CONTENT_VERSION__ = EXTENSION_VERSION; @@ -701,11 +701,26 @@ const seenJobUrls = new Set(collectedJobs.map((job) => normalizeJobUrlKey(job)).filter(Boolean)); let pageNumber = Math.max(1, Number(task.searchPage || currentSearchPageNumber() || 1)); let pagesScanned = Number(task.pagesScanned || 0); + let stagnantPages = Number(task.stagnantPages || 0); + let historyDuplicateCount = Number(task.historyDuplicateCount || 0); + let candidateCount = Number(task.candidateCount || collectedJobs.length); + const collectionStartedAt = Number(task.collectionStartedAt || Date.now()); + const maxPages = Number(SCAN_SUPPORT.DEEP_COLLECTION_MAX_PAGES || 50); let lastDiagnostics = null; - - while (collectedJobs.length < searchJobLimit && pagesScanned < 50) { + let platformExhausted = false; + let stopReason = ""; + + while (collectedJobs.length < searchJobLimit && pagesScanned < maxPages) { + stopReason = zhilianCollectionStopReason({ + target: searchJobLimit, + fresh: collectedJobs.length, + pages: pagesScanned, + stagnantPages, + elapsedMs: Date.now() - collectionStartedAt + }); + if (stopReason) break; if (await hasStopRequested()) { - return { stopped: true, jobs: collectedJobs.slice(0, searchJobLimit), candidateCount: collectedJobs.length, pagesScanned }; + return { stopped: true, jobs: collectedJobs.slice(0, searchJobLimit), candidateCount, freshCount: collectedJobs.length, historyDuplicateCount, pagesScanned, stopReason: "stopped" }; } if (!isCurrentSearchPage(keyword, config, pageNumber)) { @@ -723,6 +738,10 @@ phase: "collecting", searchPage: pageNumber, pagesScanned, + stagnantPages, + historyDuplicateCount, + candidateCount, + collectionStartedAt, collectedJobs, totalSaved }); @@ -768,8 +787,15 @@ } const collectResult = collectJobs(keyword, task, { ...baseMeta, pageNumber }); + const pendingJobs = collectResult.jobs.filter((job) => { + const key = normalizeJobUrlKey(job); + return key && !seenJobUrls.has(key); + }); + candidateCount += pendingJobs.length; + const dedupeResult = await filterZhilianDuplicateJobs(pendingJobs, task, keyword); + historyDuplicateCount += dedupeResult.duplicateCount; let added = 0; - for (const job of collectResult.jobs) { + for (const job of dedupeResult.jobs) { const key = normalizeJobUrlKey(job); if (!key || seenJobUrls.has(key)) continue; seenJobUrls.add(key); @@ -778,8 +804,20 @@ if (collectedJobs.length >= searchJobLimit) break; } pagesScanned += 1; + stagnantPages = added > 0 ? 0 : stagnantPages + 1; + const nextPageNumber = pageNumber + 1; + const hasNextPage = hasNextSearchPage(nextPageNumber); + platformExhausted = !hasNextPage; + stopReason = zhilianCollectionStopReason({ + target: searchJobLimit, + fresh: collectedJobs.length, + pages: pagesScanned, + stagnantPages, + elapsedMs: Date.now() - collectionStartedAt, + platformExhausted + }); - postProgress(task, collectResult.parsed > 0 ? "info" : "warning", `智联第 ${pageNumber} 页解析完成:候选节点 ${collectResult.nodeCount} 个,首屏数据 ${collectResult.initialStateParsed || 0} 个,成功 ${collectResult.parsed} 个,本页新增 ${added} 个,累计 ${collectedJobs.length}/${searchJobLimit} 个,跳过 ${collectResult.skipped} 个,重复 ${collectResult.duplicated} 个。`, { + postProgress(task, collectResult.parsed > 0 ? "info" : "warning", `智联第 ${pageNumber} 页解析完成:候选节点 ${collectResult.nodeCount} 个,首屏数据 ${collectResult.initialStateParsed || 0} 个,解析 ${collectResult.parsed} 个,历史重复 ${dedupeResult.duplicateCount} 个,本页新增 ${added} 个,累计新增 ${collectedJobs.length}/${searchJobLimit} 个,跳过 ${collectResult.skipped} 个。${stopReason ? ` 停止原因:${collectionStopReasonLabel(stopReason)}。` : ""}`, { ...baseMeta, stage: "collecting", pageNumber, @@ -789,7 +827,13 @@ parsed: collectResult.parsed, added, skipped: collectResult.skipped, + conditionFiltered: 0, duplicated: collectResult.duplicated, + historyDuplicates: historyDuplicateCount, + candidateCount, + fresh: collectedJobs.length, + stagnantPages, + stopReason, initialStateParsed: collectResult.initialStateParsed || 0, errorCount: collectResult.errorCount, pagesScanned @@ -800,27 +844,46 @@ phase: "collecting", searchPage: pageNumber, pagesScanned, + stagnantPages, + historyDuplicateCount, + candidateCount, + collectionStartedAt, collectedJobs, totalSaved }); - if (collectedJobs.length >= searchJobLimit) break; - - const nextPageNumber = pageNumber + 1; - if (!hasNextSearchPage(nextPageNumber)) { - postProgress(task, "info", `智联 Chrome已无下一页,本关键词采集结束:累计 ${collectedJobs.length}/${searchJobLimit} 个岗位进入详情/AI流程。`, { - ...baseMeta, - stage: "collecting", - collected: collectedJobs.length, - searchJobLimit, - pageNumber, - pagesScanned - }); - break; - } + if (stopReason) break; pageNumber = nextPageNumber; } + if (!stopReason) { + stopReason = zhilianCollectionStopReason({ + target: searchJobLimit, + fresh: collectedJobs.length, + pages: pagesScanned, + stagnantPages, + elapsedMs: Date.now() - collectionStartedAt, + platformExhausted + }) || "page_safety_cap"; + } + + if (collectedJobs.length < searchJobLimit) { + postProgress(task, "warning", `智联关键词 ${keyword} 未补足目标:新增岗位 ${collectedJobs.length}/${searchJobLimit},候选 ${candidateCount} 个,历史重复 ${historyDuplicateCount} 个,停止原因:${collectionStopReasonLabel(stopReason)}。`, { + ...baseMeta, + stage: "collecting", + collected: collectedJobs.length, + candidateCount, + conditionFiltered: 0, + historyDuplicates: historyDuplicateCount, + fresh: collectedJobs.length, + searchJobLimit, + pagesScanned, + stagnantPages, + stopReason, + elapsedMs: Date.now() - collectionStartedAt + }); + } + const jobs = collectedJobs.slice(0, searchJobLimit); if (!jobs.length) { const diagnostics = lastDiagnostics || buildListDiagnostics(); @@ -842,10 +905,60 @@ searchJobLimit, ...diagnostics }); - return { empty: true, jobs: [], candidateCount: 0, pagesScanned }; + return { empty: true, jobs: [], candidateCount, freshCount: 0, historyDuplicateCount, pagesScanned, stopReason }; + } + + return { jobs, candidateCount, freshCount: jobs.length, historyDuplicateCount, pagesScanned, stopReason, empty: false }; + } + + async function filterZhilianDuplicateJobs(jobs, task, keyword) { + const list = Array.isArray(jobs) ? jobs : []; + if (!list.length) return { jobs: [], duplicateCount: 0 }; + const data = await requestZhilianLocalApi("chrome-jobs-dedupe", { + body: { runId: task?.runId, keyword, jobs: list }, + pageTabId: task?.pageTabId + }); + const items = Array.isArray(data.items) ? data.items : []; + if (items.length !== list.length) { + throw new Error("智联查重接口未完整返回全部岗位决策"); + } + const decisions = new Map(); + for (const item of items) { + const key = normalizeJobUrlKey(item); + if (!key || typeof item?.duplicate !== "boolean" || decisions.has(key)) { + throw new Error("智联查重接口返回了无效或重复的岗位决策"); + } + decisions.set(key, item.duplicate); + } + if (list.some((job) => !decisions.has(normalizeJobUrlKey(job)))) { + throw new Error("智联查重接口没有覆盖全部候选岗位"); } + const freshJobs = list.filter((job) => decisions.get(normalizeJobUrlKey(job)) === false); + return { jobs: freshJobs, duplicateCount: list.length - freshJobs.length }; + } + + function zhilianCollectionStopReason(state) { + if (typeof SCAN_SUPPORT.deepCollectionStopReason === "function") { + return SCAN_SUPPORT.deepCollectionStopReason(state); + } + if (Number(state?.fresh || 0) >= Number(state?.target || 20)) return "target_reached"; + if (state?.platformExhausted) return "platform_exhausted"; + if (Number(state?.stagnantPages || 0) >= 5) return "stagnation_safety_cap"; + if (Number(state?.elapsedMs || 0) >= 180000) return "timeout_safety_cap"; + if (Number(state?.pages || 0) >= 50) return "page_safety_cap"; + return ""; + } - return { jobs, candidateCount: collectedJobs.length, pagesScanned, empty: false }; + function collectionStopReasonLabel(reason) { + return ({ + target_reached: "已达到目标", + platform_exhausted: "平台结果已到底", + stagnation_safety_cap: "连续5页没有新增", + timeout_safety_cap: "单关键词采集达到180秒", + page_safety_cap: "翻页达到50页安全上限", + blocked: "平台安全验证或页面阻断", + stopped: "用户停止扫描" + })[String(reason || "")] || "安全边界已触发"; } function normalizeCollectedJobs(value) { diff --git a/chrome-extension/zhilian-scan-support.js b/chrome-extension/zhilian-scan-support.js index 1cebe65..7c91eea 100644 --- a/chrome-extension/zhilian-scan-support.js +++ b/chrome-extension/zhilian-scan-support.js @@ -1,9 +1,12 @@ (function (root) { - const SUPPORT_VERSION = "2026-07-29-zhilian-security-resume-fix"; + const SUPPORT_VERSION = "2026-09-03-keyword-deep-fill"; if (root.GetJobsZhilianScanSupport?.version === SUPPORT_VERSION) return; const DEFAULT_CITY_CODE = "489"; const DEFAULT_SALARY_CODE = "0000,9999999"; + const DEEP_COLLECTION_MAX_PAGES = 50; + const DEEP_COLLECTION_MAX_DURATION_MS = 180 * 1000; + const DEEP_COLLECTION_MAX_STAGNANT_PAGES = 5; const OFFICIAL_SALARY_CODES = new Set([ DEFAULT_SALARY_CODE, "0000,4000", @@ -89,6 +92,18 @@ return OFFICIAL_SALARY_CODES.has(raw) ? raw : DEFAULT_SALARY_CODE; } + function deepCollectionStopReason(state = {}) { + const target = Math.max(1, Math.min(200, Math.floor(Number(state.target) || 20))); + if (state.stopped) return "stopped"; + if (Number(state.fresh || 0) >= target) return "target_reached"; + if (state.blocked) return "blocked"; + if (state.platformExhausted) return "platform_exhausted"; + if (Number(state.elapsedMs || 0) >= DEEP_COLLECTION_MAX_DURATION_MS) return "timeout_safety_cap"; + if (Number(state.stagnantPages || 0) >= DEEP_COLLECTION_MAX_STAGNANT_PAGES) return "stagnation_safety_cap"; + if (Number(state.pages || 0) >= DEEP_COLLECTION_MAX_PAGES) return "page_safety_cap"; + return ""; + } + function isUnlimitedZhilianSalary(value) { return normalizeZhilianSalaryCode(value) === DEFAULT_SALARY_CODE; } @@ -187,6 +202,10 @@ version: SUPPORT_VERSION, DEFAULT_CITY_CODE, DEFAULT_SALARY_CODE, + DEEP_COLLECTION_MAX_PAGES, + DEEP_COLLECTION_MAX_DURATION_MS, + DEEP_COLLECTION_MAX_STAGNANT_PAGES, + deepCollectionStopReason, normalizeKeywordList, normalizeZhilianCityCode, normalizeZhilianSalaryCode, diff --git a/front/app/ai-config/page.tsx b/front/app/ai-config/page.tsx index 86a84c8..a229a26 100644 --- a/front/app/ai-config/page.tsx +++ b/front/app/ai-config/page.tsx @@ -56,6 +56,7 @@ type GeneratedAiConfig = { introduce?: string prompt?: string sayHi?: string + recommendedKeywords?: string[] } type ResumeParsePreview = { @@ -431,8 +432,9 @@ export default function AiConfigPage() { skipResume: true, showAlert: false, }) - setStatusMessage('已提交简历并生成AI配置') - alert('已提交简历,并生成打招呼话术和AI配置!') + const keywordCount = Array.isArray(result.data?.recommendedKeywords) ? result.data.recommendedKeywords.length : 0 + setStatusMessage(`已提交简历并生成AI配置和 ${keywordCount} 个岗位关键词`) + alert(`已提交简历,并生成打招呼话术、AI配置和 ${keywordCount} 个岗位关键词!请到 Boss 或智联页面点击选择。`) } catch (error) { alert(friendlyApiError(error, '提交简历并生成AI配置失败')) } finally { diff --git a/front/app/boss/page.tsx b/front/app/boss/page.tsx index 7b5ec89..b2a96a0 100644 --- a/front/app/boss/page.tsx +++ b/front/app/boss/page.tsx @@ -14,8 +14,10 @@ import { Select } from '@/components/ui/select' import PageHeader from '@/app/components/PageHeader' import AnalysisContent from '@/app/boss/analysis/AnalysisContent' import CurrentProfileBadge, { type CurrentProfile } from '@/app/components/CurrentProfileBadge' +import KeywordTagInput from '@/app/components/KeywordTagInput' import { formatSetupMissingMessage, validateSetupForPlatform } from '@/lib/setupChecklist' import { hasBossScanResult, readBossScanRunId } from '@/app/boss/scan-result' +import { MAX_JOB_KEYWORDS, parseJobKeywords, serializeJobKeywords } from '@/lib/job-keywords' interface BossConfig { id?: number @@ -47,6 +49,12 @@ type BossConfigEnvelope = ApiEnvelope & { hasProfile?: boolean } +type JobKeywordRecommendations = { + keywords?: string[] + maxSelected?: number + recommendedSelectionCount?: number +} + interface BossOption { id: number type: string @@ -191,8 +199,8 @@ export default function BossPage() { filterDeadHr: 0, autoDeliver: 0, }) - // 关键词显示用(无括号无引号,逗号分隔) - const [keywordsDisplay, setKeywordsDisplay] = useState('') + const [keywordsDisplay, setKeywordsDisplay] = useState([]) + const [recommendedKeywords, setRecommendedKeywords] = useState([]) // 多选选中的代码集合(按括号列表保存) const [selectedIndustry, setSelectedIndustry] = useState([]) const [selectedExperience, setSelectedExperience] = useState([]) @@ -524,8 +532,17 @@ export default function BossPage() { const fetchAllData = async () => { setLoading(true) try { - const response = await fetch(`${API_BASE}/api/boss/config`) + const [response, recommendationResponse] = await Promise.all([ + fetch(`${API_BASE}/api/boss/config`), + fetch(`${API_BASE}/api/ai/job-keywords`).catch(() => null), + ]) const data = await readApiResponse(response, 'Boss配置加载失败') as BossConfigEnvelope + if (recommendationResponse?.ok) { + const recommendationResult = await readApiResponse(recommendationResponse, '岗位关键词推荐加载失败') + setRecommendedKeywords(parseJobKeywords(recommendationResult.data?.keywords)) + } else { + setRecommendedKeywords([]) + } console.log('Fetched data:', data) console.log('Blacklist:', data.blacklist) @@ -555,31 +572,7 @@ export default function BossPage() { searchJobLimit, autoDeliver: 0, }) - // 将后端存储的关键词(可能是 JSON 数组或括号列表)转为展示用逗号分隔文本 - const toDisplayKeywords = (raw?: string): string => { - if (!raw) return '' - const s = raw.trim() - // 尝试作为 JSON 数组解析 - if (s.startsWith('[') && s.endsWith(']')) { - try { - const arr = JSON.parse(s) - if (Array.isArray(arr)) { - return arr.map((v) => String(v).trim()).filter((v) => v.length > 0).join(', ') - } - } catch (_) { - // 非严格 JSON,如 [a,b],走拆括号与逗号分隔 - const inner = s.slice(1, -1) - return inner - .split(',') - .map((v) => v.trim().replace(/^"|"$/g, '')) - .filter((v) => v.length > 0) - .join(', ') - } - } - // 普通文本:直接返回,去掉多余空格 - return s - } - setKeywordsDisplay(toDisplayKeywords(data.config.keywords)) + setKeywordsDisplay(parseJobKeywords(data.config.keywords)) // 解析括号列表为数组 setSelectedIndustry(parseListString(data.config.industry)) setSelectedExperience(parseListString(data.config.experience)) @@ -748,6 +741,17 @@ export default function BossPage() { setShowSaveDialog(true) return } + if (!keywordsDisplay.length || keywordsDisplay.length > MAX_JOB_KEYWORDS) { + setSaveDialogKind('save') + setSaveResult({ + success: false, + message: !keywordsDisplay.length + ? '请至少选择一个搜索关键词。' + : `岗位关键词最多选择 ${MAX_JOB_KEYWORDS} 个,请先删减后再保存。`, + }) + setShowSaveDialog(true) + return + } try { const searchJobLimit = commitSearchJobLimit(overrides?.searchJobLimit) // 组装要保存的负载:多选使用括号列表 @@ -755,8 +759,7 @@ export default function BossPage() { ...config, // 覆盖字段(用于失焦时使用当前控件值,避免异步状态滞后) ...(overrides || {}), - // 关键词:前端发送逗号分隔的纯文本,后端统一组装为 JSON 列表 - keywords: keywordsDisplay, + keywords: serializeJobKeywords(keywordsDisplay), searchJobLimit, industry: toBracketList(selectedIndustry), experience: toBracketList(selectedExperience), @@ -867,6 +870,14 @@ export default function BossPage() { alert('请先在简历配置页新建档案。') return } + if (!keywordsDisplay.length || keywordsDisplay.length > MAX_JOB_KEYWORDS) { + const message = !keywordsDisplay.length + ? '请至少选择一个搜索关键词。' + : `岗位关键词最多选择 ${MAX_JOB_KEYWORDS} 个,请先删减后再开始扫描。` + appendProgressLog({ type: 'error', message }) + alert(message) + return + } focusLogSection() const setup = await validateSetupForPlatform('boss', { requirePlatformLogin: false }) if (!setup.ready) { @@ -980,7 +991,7 @@ export default function BossPage() { const data = await sendChromeBridgeMessage({ type: 'BOSS_COLLECT_CURRENT_PAGE', platform: 'boss', - keyword: keywordsDisplay, + keyword: keywordsDisplay.join(', '), runId: `boss-list-${Date.now()}`, }, 70000) as BossCurrentPageCollectResponse @@ -1038,12 +1049,7 @@ export default function BossPage() { return } - const keywords = Array.from(new Set( - keywordsDisplay - .split(/[,,;;\n\r]+/) - .map((item) => item.trim()) - .filter(Boolean), - )) + const keywords = parseJobKeywords(keywordsDisplay) if (keywords.length !== 1) { appendProgressLog({ type: 'error', message: 'Boss API POC 仅支持一个关键词,请把关键词配置改为恰好一个后再测试。' }) return @@ -1246,14 +1252,14 @@ export default function BossPage() { {isStopping ? '停止中...' : '停止扫描'} ) : ( - )} - @@ -1314,15 +1320,13 @@ export default function BossPage() {
- - 搜索关键词 + setKeywordsDisplay(e.target.value)} - placeholder="例如:Java开发工程师" + onChange={setKeywordsDisplay} + recommendations={recommendedKeywords} disabled={!hasProfile} /> -

职位搜索的关键词

diff --git a/front/app/components/KeywordTagInput.test.tsx b/front/app/components/KeywordTagInput.test.tsx new file mode 100644 index 0000000..a9ce591 --- /dev/null +++ b/front/app/components/KeywordTagInput.test.tsx @@ -0,0 +1,43 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import KeywordTagInput from './KeywordTagInput' + +describe('KeywordTagInput', () => { + it('推荐词只有点击后才加入', () => { + const onChange = vi.fn() + render() + + expect(onChange).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: /AI产品经理/ })) + expect(onChange).toHaveBeenCalledWith(['Java', 'AI产品经理']) + }) + + it('支持回车、粘贴和删除标签', () => { + const onChange = vi.fn() + const { rerender } = render() + const input = screen.getByLabelText('新增岗位关键词') + fireEvent.change(input, { target: { value: 'Java 后端' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + expect(onChange).toHaveBeenLastCalledWith(['Java 后端']) + + rerender() + fireEvent.paste(screen.getByLabelText('新增岗位关键词'), { + clipboardData: { getData: () => 'AI产品经理,大模型产品' }, + }) + expect(onChange).toHaveBeenLastCalledWith(['Java 后端', 'AI产品经理', '大模型产品']) + + rerender() + fireEvent.click(screen.getByRole('button', { name: '删除关键词 Java 后端' })) + expect(onChange).toHaveBeenLastCalledWith(['AI产品经理']) + }) + + it('历史数据超过八个时保留全部并禁止新增', () => { + const legacy = Array.from({ length: 10 }, (_, index) => `岗位${index + 1}`) + render() + + expect(screen.getByText('10/8')).toBeInTheDocument() + expect(screen.getByText(/请删减到 8 个以内/)).toBeInTheDocument() + expect(screen.getByLabelText('新增岗位关键词')).toBeDisabled() + }) +}) diff --git a/front/app/components/KeywordTagInput.tsx b/front/app/components/KeywordTagInput.tsx new file mode 100644 index 0000000..bfcb134 --- /dev/null +++ b/front/app/components/KeywordTagInput.tsx @@ -0,0 +1,127 @@ +'use client' + +import { useMemo, useState } from 'react' +import { BiPlus, BiX } from 'react-icons/bi' +import { Input } from '@/components/ui/input' +import { MAX_JOB_KEYWORDS, mergeJobKeywords, parseJobKeywords } from '@/lib/job-keywords' + +type KeywordTagInputProps = { + value: string[] + onChange: (keywords: string[]) => void + recommendations?: string[] + disabled?: boolean + max?: number +} + +export default function KeywordTagInput({ + value, + onChange, + recommendations = [], + disabled = false, + max = MAX_JOB_KEYWORDS, +}: KeywordTagInputProps) { + const [draft, setDraft] = useState('') + const [message, setMessage] = useState('') + const normalizedValue = useMemo(() => parseJobKeywords(value), [value]) + const isOverLimit = normalizedValue.length > max + const isAtLimit = normalizedValue.length >= max + const availableRecommendations = parseJobKeywords(recommendations).filter( + (keyword) => !normalizedValue.some((selected) => selected.toLocaleLowerCase() === keyword.toLocaleLowerCase()), + ) + + const add = (raw: unknown) => { + if (disabled) return + const result = mergeJobKeywords(normalizedValue, raw, max) + if (result.keywords.length !== normalizedValue.length) onChange(result.keywords) + setMessage(result.rejected.length ? `最多选择 ${max} 个,未加入:${result.rejected.join('、')}` : '') + setDraft('') + } + + const remove = (keyword: string) => { + if (disabled) return + onChange(normalizedValue.filter((item) => item !== keyword)) + setMessage('') + } + + return ( +
+
+
+ {normalizedValue.map((keyword) => ( + + {keyword} + + + ))} +
+ setDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + add(draft) + } + }} + onPaste={(event) => { + const pasted = event.clipboardData.getData('text') + if (/[,,;;\n\r]/.test(pasted)) { + event.preventDefault() + add(pasted) + } + }} + placeholder={isAtLimit ? `最多 ${max} 个` : '输入后按回车,可粘贴多个'} + className="h-8 border-0 px-1 shadow-none focus-visible:ring-0" + disabled={disabled || isAtLimit} + aria-label="新增岗位关键词" + /> + +
+
+
+ +
+ + {isOverLimit ? `历史配置有 ${normalizedValue.length} 个,请删减到 ${max} 个以内` : '建议选择 3–5 个最贴近目标岗位的关键词'} + + {normalizedValue.length}/{max} +
+ + {availableRecommendations.length > 0 && ( +
+

简历 AI 推荐(点击后加入)

+
+ {availableRecommendations.map((keyword) => ( + + ))} +
+
+ )} + {message &&

{message}

} +
+ ) +} diff --git a/front/app/zhilian/page.tsx b/front/app/zhilian/page.tsx index ab65e7a..8ae9772 100644 --- a/front/app/zhilian/page.tsx +++ b/front/app/zhilian/page.tsx @@ -14,7 +14,9 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import AnalysisContent from '@/app/zhilian/analysis/AnalysisContent' import PageHeader from '@/app/components/PageHeader' import CurrentProfileBadge, { type CurrentProfile } from '@/app/components/CurrentProfileBadge' +import KeywordTagInput from '@/app/components/KeywordTagInput' import { formatSetupMissingMessage, validateSetupForPlatform } from '@/lib/setupChecklist' +import { MAX_JOB_KEYWORDS, parseJobKeywords as normalizeKeywordTokens, serializeJobKeywords } from '@/lib/job-keywords' interface ZhilianConfig { id?: number @@ -49,43 +51,6 @@ const OFFICIAL_ZHILIAN_SALARY_CODES = new Set([ ]) const ZHILIAN_KEYWORD_REQUIRED_MESSAGE = '请至少填写一个搜索关键词' -const normalizeKeywordTokens = (value: unknown): string[] => { - const keywords: string[] = [] - - const append = (rawValue: unknown) => { - if (Array.isArray(rawValue)) { - rawValue.forEach(append) - return - } - - const raw = String(rawValue ?? '').trim() - if (!raw) return - if (raw.startsWith('[') && raw.endsWith(']')) { - try { - const parsed: unknown = JSON.parse(raw) - if (Array.isArray(parsed)) { - parsed.forEach(append) - return - } - } catch { - append(raw.slice(1, -1)) - return - } - } - - raw.split(/[,,;;\n\r]+/).forEach((item) => { - const keyword = item.replace(/\s+/g, ' ').trim().replace(/^["']|["']$/g, '').trim() - if (!keyword) return - if (!keywords.some((existing) => existing.toLocaleLowerCase() === keyword.toLocaleLowerCase())) { - keywords.push(keyword) - } - }) - } - - append(value) - return keywords -} - const isTerminalScanPayload = (payload: Record) => { const stage = String(payload.stage || '') const message = String(payload.message || '') @@ -129,6 +94,7 @@ export default function ZhilianPage() { const [hasProfile, setHasProfile] = useState(false) const [config, setConfig] = useState({ keywords: '', cityCode: DEFAULT_ZHILIAN_CITY_CODE, salary: DEFAULT_ZHILIAN_SALARY_CODE, searchJobLimit: 20 }) + const [recommendedKeywords, setRecommendedKeywords] = useState([]) const [searchJobLimitInput, setSearchJobLimitInput] = useState('20') const [options, setOptions] = useState({ city: [], salary: [] }) const [configWarnings, setConfigWarnings] = useState>({}) @@ -369,18 +335,23 @@ export default function ZhilianPage() { // 统一兼容中英文逗号、JSON数组、换行和多余空白。 const parseKeywordsFromDb = (raw?: string): string => { - return normalizeKeywordTokens(raw).join(', ') - } - - const serializeKeywordsForDb = (display?: unknown): string => { - return JSON.stringify(normalizeKeywordTokens(display)) + return serializeJobKeywords(normalizeKeywordTokens(raw)) } const fetchAllData = async () => { setLoadingConfig(true) try { - const res = await fetch(`${API_BASE}/api/zhilian/config`) + const [res, recommendationResponse] = await Promise.all([ + fetch(`${API_BASE}/api/zhilian/config`), + fetch(`${API_BASE}/api/ai/job-keywords`).catch(() => null), + ]) const data = await res.json() + if (recommendationResponse?.ok) { + const recommendationEnvelope = await recommendationResponse.json() as { data?: { keywords?: string[] } } + setRecommendedKeywords(normalizeKeywordTokens(recommendationEnvelope.data?.keywords)) + } else { + setRecommendedKeywords([]) + } const nextOptions: ZhilianOptions = { city: Array.isArray(data.options?.city) ? data.options.city : [], salary: Array.isArray(data.options?.salary) ? data.options.salary : [], @@ -474,6 +445,12 @@ export default function ZhilianPage() { alert(ZHILIAN_KEYWORD_REQUIRED_MESSAGE) return } + if (keywords.length > MAX_JOB_KEYWORDS) { + const message = `岗位关键词最多选择 ${MAX_JOB_KEYWORDS} 个,请先删减后再开始扫描。` + appendProgressLog({ type: 'error', message }) + alert(message) + return + } if (!hasProfile) { appendProgressLog({ type: 'error', message: '请先在简历配置页新建档案。' }) alert('请先在简历配置页新建档案。') @@ -586,7 +563,7 @@ export default function ZhilianPage() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ runId: `zhilian-openclaw-${Date.now()}`, - keyword: probeData.keyword || config.keywords, + keyword: probeData.keyword || normalizeKeywordTokens(config.keywords)[0] || '', autoDeliver: false, jobs, }), @@ -662,6 +639,11 @@ export default function ZhilianPage() { setShowSaveDialog(true) return } + if (keywords.length > MAX_JOB_KEYWORDS) { + setSaveResult({ success: false, message: `岗位关键词最多选择 ${MAX_JOB_KEYWORDS} 个,请先删减后再保存。` }) + setShowSaveDialog(true) + return + } if (!hasProfile) { setSaveResult({ success: false, message: '请先在简历配置页新建档案。' }) setShowSaveDialog(true) @@ -685,7 +667,7 @@ export default function ZhilianPage() { ...config, cityCode, salary, - keywords: serializeKeywordsForDb(keywords), + keywords: serializeJobKeywords(keywords), searchJobLimit, } setConfig((current) => ({ ...current, cityCode, salary, searchJobLimit })) @@ -735,14 +717,14 @@ export default function ZhilianPage() { {isStopping ? '停止中...' : '停止扫描'} ) : ( - )} -
@@ -832,11 +814,11 @@ export default function ZhilianPage() { ) : (
- - setConfig((c) => ({ ...c, keywords: e.target.value }))} + + setConfig((current) => ({ ...current, keywords: serializeJobKeywords(keywords) }))} + recommendations={recommendedKeywords} disabled={!hasProfile} /> {!normalizeKeywordTokens(config.keywords).length && ( diff --git a/front/lib/job-keywords.test.ts b/front/lib/job-keywords.test.ts new file mode 100644 index 0000000..c02bc30 --- /dev/null +++ b/front/lib/job-keywords.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' + +import { MAX_JOB_KEYWORDS, mergeJobKeywords, parseJobKeywords, serializeJobKeywords } from './job-keywords' + +describe('job keywords', () => { + it('兼容 JSON、中文分隔符并忽略大小写去重', () => { + expect(parseJobKeywords('["Java 后端","AI产品经理","java 后端"]')).toEqual(['Java 后端', 'AI产品经理']) + expect(parseJobKeywords('Java 后端,AI产品经理;大模型产品\nRAG产品')).toEqual([ + 'Java 后端', 'AI产品经理', '大模型产品', 'RAG产品', + ]) + }) + + it('新保存值使用 JSON 数组', () => { + expect(serializeJobKeywords('Java,Java,AI产品经理')).toBe('["Java","AI产品经理"]') + }) + + it('最多加入八个且不会截断已有超限历史数据', () => { + const existing = Array.from({ length: MAX_JOB_KEYWORDS }, (_, index) => `岗位${index + 1}`) + expect(mergeJobKeywords(existing, '岗位9')).toEqual({ keywords: existing, rejected: ['岗位9'] }) + + const legacy = existing.concat('岗位9', '岗位10') + expect(mergeJobKeywords(legacy, '岗位11')).toEqual({ keywords: legacy, rejected: ['岗位11'] }) + }) +}) diff --git a/front/lib/job-keywords.ts b/front/lib/job-keywords.ts new file mode 100644 index 0000000..fcbbca2 --- /dev/null +++ b/front/lib/job-keywords.ts @@ -0,0 +1,57 @@ +export const MAX_JOB_KEYWORDS = 8 +export const RECOMMENDED_JOB_KEYWORD_COUNT = 3 + +export const parseJobKeywords = (value: unknown): string[] => { + const keywords: string[] = [] + + const append = (rawValue: unknown) => { + if (Array.isArray(rawValue)) { + rawValue.forEach(append) + return + } + + const raw = String(rawValue ?? '').trim() + if (!raw) return + if (raw.startsWith('[') && raw.endsWith(']')) { + try { + const parsed: unknown = JSON.parse(raw) + if (Array.isArray(parsed)) { + parsed.forEach(append) + return + } + } catch { + append(raw.slice(1, -1)) + return + } + } + + raw.split(/[,,;;\n\r]+/).forEach((item) => { + const keyword = item.replace(/\s+/g, ' ').trim().replace(/^["']|["']$/g, '').trim() + if (!keyword) return + if (!keywords.some((existing) => existing.toLocaleLowerCase() === keyword.toLocaleLowerCase())) { + keywords.push(keyword) + } + }) + } + + append(value) + return keywords +} + +export const serializeJobKeywords = (value: unknown): string => JSON.stringify(parseJobKeywords(value)) + +export const mergeJobKeywords = ( + current: unknown, + incoming: unknown, + max = MAX_JOB_KEYWORDS, +): { keywords: string[]; rejected: string[] } => { + const existing = parseJobKeywords(current) + const additions = parseJobKeywords(incoming) + .filter((keyword) => !existing.some((item) => item.toLocaleLowerCase() === keyword.toLocaleLowerCase())) + if (existing.length >= max) return { keywords: existing, rejected: additions } + const available = max - existing.length + return { + keywords: existing.concat(additions.slice(0, available)), + rejected: additions.slice(available), + } +} diff --git a/src/main/java/com/getjobs/application/config/CorsConfig.java b/src/main/java/com/getjobs/application/config/CorsConfig.java index 199f0c2..2c9e631 100644 --- a/src/main/java/com/getjobs/application/config/CorsConfig.java +++ b/src/main/java/com/getjobs/application/config/CorsConfig.java @@ -13,41 +13,48 @@ */ @Configuration public class CorsConfig { + public static final String CHROME_EXTENSION_ID = "igmjpelbjhlglhegjbgmdbgfcdflmigp"; + public static final String CHROME_EXTENSION_ORIGIN = "chrome-extension://" + CHROME_EXTENSION_ID; private static final List LOCAL_FRONTEND_ORIGINS = List.of( "http://localhost:6866", "http://127.0.0.1:6866" ); - private static final List BOSS_EXTENSION_API_PATHS = List.of( + private static final List EXTENSION_API_PATHS = List.of( "/api/boss/chrome/**", "/api/boss/ai-keywords", - "/api/boss/jobs/*/delivery-result" + "/api/boss/jobs/*/delivery-result", + "/api/zhilian/chrome/**", + "/api/zhilian/jobs/*/delivery-result" ); @Bean public CorsFilter corsFilter() { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - CorsConfiguration localFrontendConfig = baseConfiguration(); + CorsConfiguration localFrontendConfig = baseConfiguration(true); localFrontendConfig.setAllowedOrigins(LOCAL_FRONTEND_ORIGINS); - CorsConfiguration bossExtensionConfig = baseConfiguration(); - bossExtensionConfig.setAllowedOrigins(LOCAL_FRONTEND_ORIGINS); - // Chrome 扩展后台依靠 manifest host_permissions 访问本机 API;服务端不再信任任意扩展 ID。 + CorsConfiguration extensionConfig = baseConfiguration(false); + extensionConfig.setAllowedOrigins(List.of( + LOCAL_FRONTEND_ORIGINS.get(0), + LOCAL_FRONTEND_ORIGINS.get(1), + CHROME_EXTENSION_ORIGIN + )); // 必须先注册更具体的扩展接口,再注册全局本地前端规则。 - for (String path : BOSS_EXTENSION_API_PATHS) { - source.registerCorsConfiguration(path, bossExtensionConfig); + for (String path : EXTENSION_API_PATHS) { + source.registerCorsConfiguration(path, extensionConfig); } source.registerCorsConfiguration("/**", localFrontendConfig); return new CorsFilter(source); } - private CorsConfiguration baseConfiguration() { + private CorsConfiguration baseConfiguration(boolean allowCredentials) { CorsConfiguration config = new CorsConfiguration(); config.addAllowedHeader("*"); config.addAllowedMethod("*"); - config.setAllowCredentials(true); + config.setAllowCredentials(allowCredentials); config.setMaxAge(3600L); return config; } diff --git a/src/main/java/com/getjobs/application/controller/AiConfigController.java b/src/main/java/com/getjobs/application/controller/AiConfigController.java index bcca21f..b96bfc1 100644 --- a/src/main/java/com/getjobs/application/controller/AiConfigController.java +++ b/src/main/java/com/getjobs/application/controller/AiConfigController.java @@ -318,9 +318,15 @@ public ResponseEntity> generateConfigFromResume(@RequestBody return ResponseEntity.badRequest().body(response); } + Map generated = aiService.generateResumeAiConfig(resumeText); + Object rawKeywords = generated.get("recommendedKeywords"); + List keywords = rawKeywords instanceof List list + ? list.stream().filter(String.class::isInstance).map(String.class::cast).toList() + : List.of(); + generated.put("recommendedKeywords", jobAiAnalysisService.saveRecommendedJobKeywords(keywords)); response.put("success", true); - response.put("data", aiService.generateResumeAiConfig(resumeText)); - response.put("message", "AI文案生成成功"); + response.put("data", generated); + response.put("message", "AI文案和岗位关键词生成成功"); return ResponseEntity.ok(response); } catch (Exception e) { log.error("根据简历生成AI文案失败", e); @@ -330,6 +336,20 @@ public ResponseEntity> generateConfigFromResume(@RequestBody } } + @GetMapping("/job-keywords") + public ResponseEntity> getRecommendedJobKeywords() { + Map response = new HashMap<>(); + response.put("success", true); + response.put("data", Map.of( + "keywords", jobAiAnalysisService.getRecommendedJobKeywords(), + "maxSelected", com.getjobs.application.service.JobKeywordCodec.MAX_SELECTED, + "recommendedSelectionCount", com.getjobs.application.service.JobKeywordCodec.RECOMMENDED_SELECTION_COUNT + )); + response.put("currentProfile", profileService.getCurrentProfile()); + response.put("hasProfile", profileService.hasProfiles()); + return ResponseEntity.ok(response); + } + @GetMapping("/companies/priority") public ResponseEntity> getPriorityCompanies() { Map response = new HashMap<>(); diff --git a/src/main/java/com/getjobs/application/controller/BossConfigController.java b/src/main/java/com/getjobs/application/controller/BossConfigController.java index d33bdd2..3030cf3 100644 --- a/src/main/java/com/getjobs/application/controller/BossConfigController.java +++ b/src/main/java/com/getjobs/application/controller/BossConfigController.java @@ -4,18 +4,13 @@ import com.getjobs.application.entity.BossOptionEntity; import com.getjobs.application.service.ProfileService; import com.getjobs.application.service.BossService; +import com.getjobs.application.service.JobKeywordCodec; import com.getjobs.application.entity.BlacklistEntity; import org.springframework.web.bind.annotation.*; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.stream.Collectors; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; @RestController @RequestMapping("/api/boss/config") @@ -81,7 +76,9 @@ public Map getAllBossConfig() { @PutMapping public Map updateConfig(@RequestBody BossConfigEntity config) { // 关键词标准化:将来自前端的逗号分隔或括号列表统一转换为 JSON 字符串列表 - config.setKeywords(normalizeKeywords(config.getKeywords())); + if (config.getKeywords() != null) { + config.setKeywords(JobKeywordCodec.validateAndSerialize(config.getKeywords())); + } if (config.getAutoDeliver() == null) { config.setAutoDeliver(0); } @@ -140,48 +137,6 @@ public Map updateConfig(@RequestBody BossConfigEntity config) { return result; } - /** - * 将关键词字符串标准化为 JSON 字符串列表。 - * 支持输入形式: - * 1) 逗号分隔:"大模型, Python, Golang" - * 2) 中文逗号:"大模型,Python,Golang" - * 3) 括号列表:"[大模型,Python]" 或 "[\"大模型\",\"Python\"]" - * 4) JSON 数组:"["大模型","Python"]" - */ - private String normalizeKeywords(String raw) { - if (raw == null) return null; - String s = raw.trim(); - if (s.isEmpty()) return "[]"; - - ObjectMapper mapper = new ObjectMapper(); - // 优先尝试 JSON 解析 - if (s.startsWith("[") && s.endsWith("]")) { - try { - JsonNode node = mapper.readTree(s); - if (node.isArray()) { - java.util.List list = new ArrayList<>(); - node.forEach(it -> list.add(it.asText().trim())); - return mapper.writeValueAsString(list); - } - } catch (Exception ignore) { - // 非严格 JSON,继续走分隔解析 - } - // 去除括号后按逗号拆分 - s = s.substring(1, s.length() - 1); - } - - java.util.List items = Arrays.stream(s.split("[,,]")) - .map(String::trim) - .filter(v -> !v.isEmpty()) - .map(v -> v.replaceAll("^\"|\"$", "")) - .collect(Collectors.toList()); - try { - return mapper.writeValueAsString(items); - } catch (Exception e) { - return "[]"; - } - } - /** * 获取指定类型的选项列表 */ diff --git a/src/main/java/com/getjobs/application/controller/BossController.java b/src/main/java/com/getjobs/application/controller/BossController.java index 11767ae..d1e8eab 100644 --- a/src/main/java/com/getjobs/application/controller/BossController.java +++ b/src/main/java/com/getjobs/application/controller/BossController.java @@ -11,6 +11,7 @@ import com.getjobs.application.service.ConfigService; import com.getjobs.application.service.DeliveryStatus; import com.getjobs.application.service.JobAiAnalysisService; +import com.getjobs.application.service.JobKeywordCodec; import com.getjobs.worker.dto.JobProgressMessage; import com.getjobs.worker.boss.Boss; import com.getjobs.worker.boss.BossConfig; @@ -416,7 +417,7 @@ public ResponseEntity> generateBossAiKeywords(@RequestBody(r return ResponseEntity.ok(Map.of( "success", true, "keywords", keywords, - "limit", Math.min(Math.max(limit, 1), 5) + "limit", Math.min(Math.max(limit, 1), JobKeywordCodec.MAX_SELECTED) )); } catch (IllegalArgumentException e) { return ResponseEntity.badRequest().body(Map.of( diff --git a/src/main/java/com/getjobs/application/controller/GlobalExceptionHandler.java b/src/main/java/com/getjobs/application/controller/GlobalExceptionHandler.java index 27aa31a..4e93118 100644 --- a/src/main/java/com/getjobs/application/controller/GlobalExceptionHandler.java +++ b/src/main/java/com/getjobs/application/controller/GlobalExceptionHandler.java @@ -32,6 +32,15 @@ public ResponseEntity> handleIllegalState(IllegalStateExcept return ResponseEntity.badRequest().body(response); } + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgument(IllegalArgumentException e) { + log.warn("请求参数不正确: {}", e.getMessage()); + Map response = new HashMap<>(); + response.put("success", false); + response.put("message", e.getMessage()); + return ResponseEntity.badRequest().body(response); + } + @ExceptionHandler(HttpMessageNotReadableException.class) public ResponseEntity> handleHttpMessageNotReadable(HttpMessageNotReadableException e) { log.warn("请求体解析失败: {}", e.getMessage()); diff --git a/src/main/java/com/getjobs/application/controller/ZhilianController.java b/src/main/java/com/getjobs/application/controller/ZhilianController.java index 10da46d..36a7334 100644 --- a/src/main/java/com/getjobs/application/controller/ZhilianController.java +++ b/src/main/java/com/getjobs/application/controller/ZhilianController.java @@ -473,6 +473,35 @@ public ResponseEntity> receiveChromeJobs(@RequestBody Chrome )); } + @PostMapping("/chrome/jobs/dedupe") + public ResponseEntity> dedupeChromeJobs(@RequestBody ChromeJobBatchRequest request) { + List jobs = request == null || request.getJobs() == null ? List.of() : request.getJobs(); + List> items = new ArrayList<>(); + int duplicateCount = 0; + for (ChromeJobDto dto : jobs) { + String id = firstNonBlank(dto == null ? null : dto.getId(), dto == null ? null : extractUrlId(dto.getUrl())); + String title = dto == null ? "" : Objects.toString(dto.getTitle(), "").trim(); + String company = dto == null ? "" : Objects.toString(dto.getCompany(), "").trim(); + boolean duplicate = (!isBlank(id) && zhilianService.existsByJobId(id)) + || (!title.isBlank() && !company.isBlank() && zhilianService.existsByTitleAndCompany(title, company)); + if (duplicate) duplicateCount++; + Map item = new HashMap<>(); + item.put("id", Objects.toString(id, "")); + item.put("url", dto == null ? "" : Objects.toString(dto.getUrl(), "")); + item.put("title", title); + item.put("company", company); + item.put("duplicate", duplicate); + item.put("action", duplicate ? "SKIP" : "NEW"); + items.add(item); + } + return ResponseEntity.ok(Map.of( + "success", true, + "items", items, + "duplicateCount", duplicateCount, + "newCount", Math.max(0, jobs.size() - duplicateCount) + )); + } + @PostMapping("/chrome/stop") public ResponseEntity> stopChromeZhilian(@RequestBody(required = false) Map payload) { String runId = payload == null ? null : Objects.toString(payload.get("runId"), ""); diff --git a/src/main/java/com/getjobs/application/entity/ResumeProfileEntity.java b/src/main/java/com/getjobs/application/entity/ResumeProfileEntity.java index 6ae567e..63b0217 100644 --- a/src/main/java/com/getjobs/application/entity/ResumeProfileEntity.java +++ b/src/main/java/com/getjobs/application/entity/ResumeProfileEntity.java @@ -29,6 +29,9 @@ public class ResumeProfileEntity { @TableField("parse_message") private String parseMessage; + @TableField("recommended_job_keywords") + private String recommendedJobKeywords; + @TableField("created_at") private LocalDateTime createdAt; diff --git a/src/main/java/com/getjobs/application/service/AiService.java b/src/main/java/com/getjobs/application/service/AiService.java index 0d78ef7..67b3803 100644 --- a/src/main/java/com/getjobs/application/service/AiService.java +++ b/src/main/java/com/getjobs/application/service/AiService.java @@ -256,17 +256,18 @@ public record ResumeImage(byte[] bytes, String mimeType) { /** * 根据简历生成 AI 配置草稿。这里只返回生成结果,不写数据库。 */ - public Map generateResumeAiConfig(String resumeText) { + public Map generateResumeAiConfig(String resumeText) { if (resumeText == null || resumeText.trim().isEmpty()) { throw new IllegalArgumentException("简历内容不能为空,请先上传或粘贴简历内容"); } - String prompt = "你是求职自动化工具的配置助手。请根据候选人简历生成三段中文配置文案。\n" + - "只返回JSON,不要使用Markdown代码块,不要添加解释。JSON字段必须包含 introduce, prompt, sayHi。\n" + + String prompt = "你是求职自动化工具的配置助手。请根据候选人简历生成中文配置文案和岗位搜索关键词。\n" + + "只返回JSON,不要使用Markdown代码块,不要添加解释。JSON字段必须包含 introduce, prompt, sayHi, recommendedKeywords。\n" + "字段要求:\n" + "1. introduce:第一人称技能介绍,120到260字,突出技术栈、经验、方向和优势。\n" + "2. prompt:用于生成Boss直聘打招呼语的模板,必须且只能包含5个%s占位符,顺序分别是技能介绍、期望岗位方向、岗位名称、岗位要求、默认打招呼语。模板要说明不匹配时只返回false。\n" + - "3. sayHi:默认打招呼语,60字以内,第一人称,礼貌直接,适合发给HR。\n\n" + + "3. sayHi:默认打招呼语,60字以内,第一人称,礼貌直接,适合发给HR。\n" + + "4. recommendedKeywords:1到8个中文岗位名称组成的JSON数组,可直接用于Boss直聘和智联招聘搜索;每项优先2到12个字,覆盖候选人的核心方向并避免同义重复。\n\n" + "简历内容:\n" + limit(resumeText, 6000); String raw = sendRequest(prompt); @@ -274,18 +275,29 @@ public Map generateResumeAiConfig(String resumeText) { String introduce = normalizeGeneratedText(obj.optString("introduce", "")); String promptTemplate = normalizePromptTemplate(obj.optString("prompt", "")); String sayHi = normalizeGeneratedText(obj.optString("sayHi", "")); + Object keywordValue = obj.opt("recommendedKeywords"); + if (!(keywordValue instanceof JSONArray keywordArray)) { + throw new IllegalStateException("AI返回岗位关键词格式不正确,请稍后重试"); + } + List recommendedKeywords = new java.util.ArrayList<>(); + for (int index = 0; index < keywordArray.length(); index++) { + Object keyword = keywordArray.opt(index); + if (keyword instanceof String) recommendedKeywords.add((String) keyword); + } + recommendedKeywords = JobKeywordCodec.normalize(recommendedKeywords, JobKeywordCodec.MAX_SELECTED); - if (introduce.isEmpty() || sayHi.isEmpty()) { + if (introduce.isEmpty() || sayHi.isEmpty() || recommendedKeywords.isEmpty()) { throw new IllegalStateException("AI返回内容不完整,请稍后重试"); } if (countPlaceholders(promptTemplate) != 5) { promptTemplate = DEFAULT_GREETING_PROMPT_TEMPLATE; } - Map result = new LinkedHashMap<>(); + Map result = new LinkedHashMap<>(); result.put("introduce", introduce); result.put("prompt", promptTemplate); result.put("sayHi", sayHi); + result.put("recommendedKeywords", recommendedKeywords); return result; } diff --git a/src/main/java/com/getjobs/application/service/JobAiAnalysisService.java b/src/main/java/com/getjobs/application/service/JobAiAnalysisService.java index e861748..9cb405a 100644 --- a/src/main/java/com/getjobs/application/service/JobAiAnalysisService.java +++ b/src/main/java/com/getjobs/application/service/JobAiAnalysisService.java @@ -37,6 +37,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.atomic.AtomicBoolean; @@ -68,16 +69,19 @@ public ResumeProfileEntity saveResumeText(String resumeText, String sourceFilena Long profileId = profileService.getCurrentProfileId(); ResumeProfileEntity current = getResumeProfile(); LocalDateTime now = LocalDateTime.now(); + String nextResumeText = resumeText == null ? "" : resumeText; + boolean resumeChanged = current == null || !Objects.equals(current.getResumeText(), nextResumeText); if (current == null) { current = new ResumeProfileEntity(); current.setProfileId(profileId); current.setCreatedAt(now); } current.setProfileId(profileId); - current.setResumeText(resumeText == null ? "" : resumeText); + current.setResumeText(nextResumeText); current.setSourceFilename(sourceFilename); current.setParseStatus(status == null ? "manual" : status); current.setParseMessage(message); + if (resumeChanged) current.setRecommendedJobKeywords(null); current.setUpdatedAt(now); if (current.getId() == null) { resumeProfileMapper.insert(current); @@ -87,6 +91,29 @@ public ResumeProfileEntity saveResumeText(String resumeText, String sourceFilena return current; } + @Transactional + public List saveRecommendedJobKeywords(List keywords) { + List normalized = JobKeywordCodec.normalize(keywords, JobKeywordCodec.MAX_SELECTED); + if (normalized.isEmpty()) { + throw new IllegalArgumentException("AI未生成有效的岗位关键词"); + } + ResumeProfileEntity current = getResumeProfile(); + if (current == null || current.getId() == null) { + throw new IllegalArgumentException("请先保存当前档案的简历内容"); + } + ResumeProfileEntity update = new ResumeProfileEntity(); + update.setId(current.getId()); + update.setRecommendedJobKeywords(JobKeywordCodec.serialize(normalized)); + update.setUpdatedAt(LocalDateTime.now()); + resumeProfileMapper.updateById(update); + return normalized; + } + + public List getRecommendedJobKeywords() { + ResumeProfileEntity current = getResumeProfile(); + return current == null ? List.of() : JobKeywordCodec.parse(current.getRecommendedJobKeywords()); + } + public ResumeProfileEntity getResumeProfile() { Long profileId = profileService.getCurrentProfileIdOrNull(); if (profileId == null) return null; @@ -290,7 +317,7 @@ private boolean executeLeaseWrite(LeaseWriteGuard leaseWriteGuard, Runnable acti } public List generateBossSearchKeywords(List existingKeywords, int limitCount) { - int max = Math.max(1, Math.min(limitCount <= 0 ? 5 : limitCount, 5)); + int max = Math.max(1, Math.min(limitCount <= 0 ? 5 : limitCount, JobKeywordCodec.MAX_SELECTED)); ResumeProfileEntity resume = getResumeProfile(); String resumeText = resume == null ? "" : resume.getResumeText(); if (resumeText == null || resumeText.trim().isEmpty()) { diff --git a/src/main/java/com/getjobs/application/service/JobKeywordCodec.java b/src/main/java/com/getjobs/application/service/JobKeywordCodec.java new file mode 100644 index 0000000..0e43cfa --- /dev/null +++ b/src/main/java/com/getjobs/application/service/JobKeywordCodec.java @@ -0,0 +1,87 @@ +package com.getjobs.application.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** 岗位搜索关键词的兼容解析、去重和持久化编码。 */ +public final class JobKeywordCodec { + public static final int MAX_SELECTED = 8; + public static final int RECOMMENDED_SELECTION_COUNT = 3; + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private JobKeywordCodec() { + } + + /** + * 读取历史 JSON 数组或逗号、中文逗号、分号、换行分隔文本。 + * 此方法不截断历史数据,数量限制只在保存和启动前校验。 + */ + public static List parse(String raw) { + if (raw == null || raw.isBlank()) return List.of(); + String value = raw.trim(); + if (value.startsWith("[") && value.endsWith("]")) { + try { + JsonNode root = OBJECT_MAPPER.readTree(value); + if (root.isArray()) { + List values = new ArrayList<>(); + root.forEach(item -> values.add(item.isTextual() ? item.asText() : "")); + return normalize(values, Integer.MAX_VALUE); + } + } catch (Exception ignored) { + value = value.substring(1, value.length() - 1); + } + } + return normalize(List.of(value.split("[,,;;\\r\\n]+")), Integer.MAX_VALUE); + } + + public static List normalize(Collection values, int max) { + if (values == null || values.isEmpty() || max <= 0) return List.of(); + Map unique = new LinkedHashMap<>(); + for (Object item : values) { + if (item == null) continue; + String keyword = stripWrapperQuotes(String.valueOf(item).trim()); + if (keyword.isBlank()) continue; + unique.putIfAbsent(keyword.toLowerCase(Locale.ROOT), keyword); + if (unique.size() >= max) break; + } + return List.copyOf(unique.values()); + } + + public static List parseAndValidate(String raw) { + List keywords = parse(raw); + if (keywords.size() > MAX_SELECTED) { + throw new IllegalArgumentException("岗位关键词最多选择" + MAX_SELECTED + "个,请先删减后再保存"); + } + return keywords; + } + + public static String validateAndSerialize(String raw) { + return serialize(parseAndValidate(raw)); + } + + public static String serialize(Collection values) { + try { + return OBJECT_MAPPER.writeValueAsString(normalize(values, Integer.MAX_VALUE)); + } catch (Exception e) { + throw new IllegalArgumentException("岗位关键词格式不正确", e); + } + } + + private static String stripWrapperQuotes(String value) { + if (value.length() < 2) return value; + char first = value.charAt(0); + char last = value.charAt(value.length() - 1); + if ((first == '"' && last == '"') || (first == '\'' && last == '\'')) { + return value.substring(1, value.length() - 1).trim(); + } + return value; + } +} diff --git a/src/main/java/com/getjobs/application/service/ZhilianService.java b/src/main/java/com/getjobs/application/service/ZhilianService.java index 518c6ad..8b93bc9 100644 --- a/src/main/java/com/getjobs/application/service/ZhilianService.java +++ b/src/main/java/com/getjobs/application/service/ZhilianService.java @@ -67,8 +67,7 @@ public ZhilianConfig loadZhilianConfig() { return config; } - // 关键词解析:支持逗号或括号列表 - config.setKeywords(parseListString(entity.getKeywords())); + config.setKeywords(JobKeywordCodec.parse(entity.getKeywords())); config.setSearchJobLimit(normalizeSearchJobLimit(entity.getSearchJobLimit())); config.setCityCode(normalizeCityCode(entity.getCityCode())); config.setSalary(normalizeSalaryCode(entity.getSalary())); @@ -105,6 +104,9 @@ private String stripWrapperQuotes(String value) { public ZhilianConfigEntity updateConfig(ZhilianConfigEntity config) { if (config == null) return null; config.setId(null); + if (config.getKeywords() != null) { + config.setKeywords(JobKeywordCodec.validateAndSerialize(config.getKeywords())); + } config.setCityCode(normalizeCityCode(config.getCityCode())); config.setSalary(normalizeSalaryCode(config.getSalary())); config.setSearchJobLimit(normalizeSearchJobLimit(config.getSearchJobLimit())); diff --git a/src/main/resources/db/migration/V12__add_resume_job_keyword_recommendations.sql b/src/main/resources/db/migration/V12__add_resume_job_keyword_recommendations.sql new file mode 100644 index 0000000..5bdc846 --- /dev/null +++ b/src/main/resources/db/migration/V12__add_resume_job_keyword_recommendations.sql @@ -0,0 +1 @@ +ALTER TABLE resume_profile ADD COLUMN recommended_job_keywords TEXT; diff --git a/src/test/java/com/getjobs/application/config/CorsConfigTest.java b/src/test/java/com/getjobs/application/config/CorsConfigTest.java index ab765c3..95c3aaa 100644 --- a/src/test/java/com/getjobs/application/config/CorsConfigTest.java +++ b/src/test/java/com/getjobs/application/config/CorsConfigTest.java @@ -1,17 +1,56 @@ package com.getjobs.application.config; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockFilterChain; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.web.filter.CorsFilter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.Base64; + import static org.assertj.core.api.Assertions.assertThat; class CorsConfigTest { private final CorsFilter corsFilter = new CorsConfig().corsFilter(); + @Test + void manifestPublicKeyMatchesBackendExtensionId() throws Exception { + Path manifestPath = Path.of(System.getProperty("user.dir"), "chrome-extension", "manifest.json"); + JsonNode manifest = new ObjectMapper().readTree(Files.readString(manifestPath)); + byte[] publicKey = Base64.getDecoder().decode(manifest.path("key").asText()); + byte[] hash = MessageDigest.getInstance("SHA-256").digest(publicKey); + StringBuilder id = new StringBuilder(); + for (int index = 0; index < 16; index++) { + id.append((char) ('a' + ((hash[index] >>> 4) & 0x0f))); + id.append((char) ('a' + (hash[index] & 0x0f))); + } + assertThat(id.toString()).isEqualTo(CorsConfig.CHROME_EXTENSION_ID); + } + + @Test + void allowsStableChromeExtensionForBossAndZhilianEndpoints() throws Exception { + for (String path : new String[]{ + "/api/boss/chrome/jobs", + "/api/boss/chrome/jobs/dedupe", + "/api/boss/ai-keywords", + "/api/boss/jobs/123/delivery-result", + "/api/zhilian/chrome/jobs", + "/api/zhilian/chrome/jobs/dedupe", + "/api/zhilian/jobs/123/delivery-result" + }) { + MockHttpServletResponse response = preflight(path, CorsConfig.CHROME_EXTENSION_ORIGIN); + assertThat(response.getStatus()).as(path).isEqualTo(200); + assertThat(response.getHeader("Access-Control-Allow-Origin")) + .as(path).isEqualTo(CorsConfig.CHROME_EXTENSION_ORIGIN); + } + } + @Test void rejectsUnknownChromeExtensionForBossCollectionEndpoint() throws Exception { MockHttpServletResponse response = preflight( @@ -32,6 +71,16 @@ void rejectsUnknownChromeExtensionForBossDeliveryResultEndpoint() throws Excepti assertThat(response.getStatus()).isEqualTo(403); } + @Test + void rejectsUnknownChromeExtensionForZhilianCollectionEndpoint() throws Exception { + MockHttpServletResponse response = preflight( + "/api/zhilian/chrome/jobs", + "chrome-extension://abcdefghijklmnop" + ); + + assertThat(response.getStatus()).isEqualTo(403); + } + @Test void rejectsChromeExtensionForUnrelatedApi() throws Exception { MockHttpServletResponse response = preflight( diff --git a/src/test/java/com/getjobs/application/controller/AiConfigControllerJobKeywordTest.java b/src/test/java/com/getjobs/application/controller/AiConfigControllerJobKeywordTest.java new file mode 100644 index 0000000..43fc1ee --- /dev/null +++ b/src/test/java/com/getjobs/application/controller/AiConfigControllerJobKeywordTest.java @@ -0,0 +1,68 @@ +package com.getjobs.application.controller; + +import com.getjobs.application.service.AiService; +import com.getjobs.application.service.JobAiAnalysisService; +import com.getjobs.application.service.ProfileService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.ResponseEntity; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class AiConfigControllerJobKeywordTest { + private final AiService aiService = mock(AiService.class); + private final JobAiAnalysisService jobAiAnalysisService = mock(JobAiAnalysisService.class); + private final ProfileService profileService = mock(ProfileService.class); + private AiConfigController controller; + + @BeforeEach + void setUp() { + controller = new AiConfigController(); + ReflectionTestUtils.setField(controller, "aiService", aiService); + ReflectionTestUtils.setField(controller, "jobAiAnalysisService", jobAiAnalysisService); + ReflectionTestUtils.setField(controller, "profileService", profileService); + } + + @Test + @SuppressWarnings("unchecked") + void generatePersistsKeywordsReturnedByTheSameAiCall() { + Map generated = new LinkedHashMap<>(); + generated.put("introduce", "介绍"); + generated.put("prompt", "%s %s %s %s %s"); + generated.put("sayHi", "你好"); + generated.put("recommendedKeywords", List.of("AI产品经理", "RAG产品")); + when(aiService.generateResumeAiConfig("简历")).thenReturn(generated); + when(jobAiAnalysisService.saveRecommendedJobKeywords(List.of("AI产品经理", "RAG产品"))) + .thenReturn(List.of("AI产品经理", "RAG产品")); + + ResponseEntity> response = controller.generateConfigFromResume(Map.of("resumeText", "简历")); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + assertThat((Map) response.getBody().get("data")) + .containsEntry("recommendedKeywords", List.of("AI产品经理", "RAG产品")); + verify(jobAiAnalysisService).saveRecommendedJobKeywords(List.of("AI产品经理", "RAG产品")); + } + + @Test + @SuppressWarnings("unchecked") + void readEndpointReturnsSelectionPolicyAndCurrentProfileKeywords() { + when(jobAiAnalysisService.getRecommendedJobKeywords()).thenReturn(List.of("AI产品经理")); + when(profileService.hasProfiles()).thenReturn(true); + + ResponseEntity> response = controller.getRecommendedJobKeywords(); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + assertThat((Map) response.getBody().get("data")) + .containsEntry("keywords", List.of("AI产品经理")) + .containsEntry("maxSelected", 8) + .containsEntry("recommendedSelectionCount", 3); + } +} diff --git a/src/test/java/com/getjobs/application/controller/BossConfigControllerContractTest.java b/src/test/java/com/getjobs/application/controller/BossConfigControllerContractTest.java index e22875f..70e956b 100644 --- a/src/test/java/com/getjobs/application/controller/BossConfigControllerContractTest.java +++ b/src/test/java/com/getjobs/application/controller/BossConfigControllerContractTest.java @@ -10,6 +10,7 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; @@ -55,4 +56,24 @@ void putUsesStandardEnvelope() { .containsEntry("data", saved) .containsEntry("message", "Boss配置保存成功"); } + + @Test + void putPreservesMissingKeywordsForSelectiveUpdates() { + when(bossService.saveOrUpdateFirstSelective(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + Map response = controller.updateConfig(new BossConfigEntity()); + + BossConfigEntity data = (BossConfigEntity) response.get("data"); + assertThat(data.getKeywords()).isNull(); + } + + @Test + void putRejectsMoreThanEightKeywords() { + BossConfigEntity incoming = new BossConfigEntity(); + incoming.setKeywords("[\"1\",\"2\",\"3\",\"4\",\"5\",\"6\",\"7\",\"8\",\"9\"]"); + + assertThatThrownBy(() -> controller.updateConfig(incoming)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("最多选择8个"); + } } diff --git a/src/test/java/com/getjobs/application/controller/GlobalExceptionHandlerTest.java b/src/test/java/com/getjobs/application/controller/GlobalExceptionHandlerTest.java new file mode 100644 index 0000000..f16f8bc --- /dev/null +++ b/src/test/java/com/getjobs/application/controller/GlobalExceptionHandlerTest.java @@ -0,0 +1,21 @@ +package com.getjobs.application.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.http.ResponseEntity; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class GlobalExceptionHandlerTest { + @Test + void mapsKeywordLimitValidationToBadRequest() { + ResponseEntity> response = new GlobalExceptionHandler() + .handleIllegalArgument(new IllegalArgumentException("岗位关键词最多选择8个")); + + assertThat(response.getStatusCode().value()).isEqualTo(400); + assertThat(response.getBody()) + .containsEntry("success", false) + .containsEntry("message", "岗位关键词最多选择8个"); + } +} diff --git a/src/test/java/com/getjobs/application/service/AiServiceResumeKeywordTest.java b/src/test/java/com/getjobs/application/service/AiServiceResumeKeywordTest.java new file mode 100644 index 0000000..63fbb6a --- /dev/null +++ b/src/test/java/com/getjobs/application/service/AiServiceResumeKeywordTest.java @@ -0,0 +1,36 @@ +package com.getjobs.application.service; + +import com.getjobs.application.mapper.AiMapper; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; + +class AiServiceResumeKeywordTest { + @Test + @SuppressWarnings("unchecked") + void parsesAndLimitsRecommendedKeywordsFromTheSameAiCall() { + AiService service = spy(new AiService( + mock(ConfigService.class), + mock(AiMapper.class), + mock(ProfileService.class), + mock(CodexCliService.class) + )); + doReturn(""" + {"introduce":"熟悉AI产品落地","prompt":"%s %s %s %s %s","sayHi":"你好,希望进一步沟通",\ + "recommendedKeywords":["AI产品经理","大模型产品经理","ai产品经理","RAG产品","智能体产品","AI运营","产品运营","AI解决方案","AIGC产品"]} + """).when(service).sendRequest(anyString()); + + Map generated = service.generateResumeAiConfig("候选人有五年AI产品经验"); + + assertThat(generated).containsKeys("introduce", "prompt", "sayHi", "recommendedKeywords"); + assertThat((List) generated.get("recommendedKeywords")) + .containsExactly("AI产品经理", "大模型产品经理", "RAG产品", "智能体产品", "AI运营", "产品运营", "AI解决方案", "AIGC产品"); + } +} diff --git a/src/test/java/com/getjobs/application/service/DatabaseMigrationTest.java b/src/test/java/com/getjobs/application/service/DatabaseMigrationTest.java index 33bfb52..f829105 100644 --- a/src/test/java/com/getjobs/application/service/DatabaseMigrationTest.java +++ b/src/test/java/com/getjobs/application/service/DatabaseMigrationTest.java @@ -20,7 +20,7 @@ class DatabaseMigrationTest { Path tempDir; @Test - void freshDatabaseMigratesThroughV11AndMatchesSchemaContract() throws Exception { + void freshDatabaseMigratesThroughV12AndMatchesSchemaContract() throws Exception { String url = sqliteUrl(tempDir.resolve("fresh.db")); Flyway flyway = flyway(url); @@ -29,8 +29,9 @@ void freshDatabaseMigratesThroughV11AndMatchesSchemaContract() throws Exception try (Connection connection = DriverManager.getConnection(url)) { DatabaseSchemaService.validateSchema(connection); assertThat(scalar(connection, - "SELECT COUNT(*) FROM flyway_schema_history WHERE success=1 AND version='11'")) + "SELECT COUNT(*) FROM flyway_schema_history WHERE success=1 AND version='12'")) .isEqualTo(1L); + assertThat(columns(connection, "resume_profile")).contains("recommended_job_keywords"); assertThat(columns(connection, "ai")).contains("apply_threshold", "priority_apply_threshold"); assertThat(columns(connection, "boss_data")) .contains("source_keyword", "scan_result_source", "salary_min_k", "salary_max_k", "salary_median_k", "salary_months"); @@ -69,6 +70,33 @@ void v11BackfillsHistoricalBossRowsWithoutChangingBusinessTimestamps() throws Ex } } + @Test + void v12AddsRecommendationsWithoutChangingExistingResume() throws Exception { + String url = sqliteUrl(tempDir.resolve("resume-keywords.db")); + Flyway.configure() + .dataSource(url, null, null) + .locations("classpath:db/migration") + .target("11") + .load() + .migrate(); + try (Connection connection = DriverManager.getConnection(url); Statement statement = connection.createStatement()) { + statement.execute("INSERT INTO profile(id, name, is_active) VALUES (1, 'profile', 1)"); + statement.execute("INSERT INTO resume_profile(id, profile_id, resume_text, source_filename) " + + "VALUES (5, 1, '原有简历内容', 'resume.pdf')"); + } + + flyway(url).migrate(); + + try (Connection connection = DriverManager.getConnection(url); Statement statement = connection.createStatement(); + ResultSet result = statement.executeQuery( + "SELECT resume_text, source_filename, recommended_job_keywords FROM resume_profile WHERE id=5")) { + assertThat(result.next()).isTrue(); + assertThat(result.getString("resume_text")).isEqualTo("原有简历内容"); + assertThat(result.getString("source_filename")).isEqualTo("resume.pdf"); + assertThat(result.getString("recommended_job_keywords")).isNull(); + } + } + @Test void v7PreservesLegacyAggregateRowsAndLeavesThemUndispatchable() throws Exception { String url = sqliteUrl(tempDir.resolve("legacy-ai-task.db")); diff --git a/src/test/java/com/getjobs/application/service/JobAiKeywordPersistenceTest.java b/src/test/java/com/getjobs/application/service/JobAiKeywordPersistenceTest.java new file mode 100644 index 0000000..2c1bd52 --- /dev/null +++ b/src/test/java/com/getjobs/application/service/JobAiKeywordPersistenceTest.java @@ -0,0 +1,82 @@ +package com.getjobs.application.service; + +import com.getjobs.application.entity.ResumeProfileEntity; +import com.getjobs.application.mapper.BossJobDataMapper; +import com.getjobs.application.mapper.Job51Mapper; +import com.getjobs.application.mapper.JobAiAnalysisMapper; +import com.getjobs.application.mapper.LiepinMapper; +import com.getjobs.application.mapper.PriorityCompanyMapper; +import com.getjobs.application.mapper.ResumeProfileMapper; +import com.getjobs.application.mapper.ZhilianJobDataMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class JobAiKeywordPersistenceTest { + private final ProfileService profileService = mock(ProfileService.class); + private final ResumeProfileMapper resumeProfileMapper = mock(ResumeProfileMapper.class); + private JobAiAnalysisService service; + + @BeforeEach + void setUp() { + service = new JobAiAnalysisService( + mock(AiService.class), profileService, resumeProfileMapper, + mock(PriorityCompanyMapper.class), mock(JobAiAnalysisMapper.class), + mock(BossJobDataMapper.class), mock(ZhilianJobDataMapper.class), + mock(LiepinMapper.class), mock(Job51Mapper.class) + ); + when(profileService.getCurrentProfileId()).thenReturn(7L); + when(profileService.getCurrentProfileIdOrNull()).thenReturn(7L); + } + + @Test + void changedResumeInvalidatesOldRecommendations() { + ResumeProfileEntity current = resume("旧简历", "[\"旧岗位\"]"); + when(resumeProfileMapper.selectOne(any())).thenReturn(current); + + service.saveResumeText("新简历", "resume.txt", "manual", "已确认"); + + assertThat(current.getRecommendedJobKeywords()).isNull(); + verify(resumeProfileMapper).updateById(current); + } + + @Test + void sameResumeKeepsRecommendations() { + ResumeProfileEntity current = resume("同一份简历", "[\"AI产品经理\"]"); + when(resumeProfileMapper.selectOne(any())).thenReturn(current); + + service.saveResumeText("同一份简历", "resume.txt", "manual", "已确认"); + + assertThat(current.getRecommendedJobKeywords()).isEqualTo("[\"AI产品经理\"]"); + } + + @Test + void recommendationsAreStoredAsCanonicalJsonForCurrentProfile() { + when(resumeProfileMapper.selectOne(any())).thenReturn(resume("简历", null)); + + List saved = service.saveRecommendedJobKeywords(List.of("Java", "AI产品经理", "java")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ResumeProfileEntity.class); + verify(resumeProfileMapper).updateById(captor.capture()); + assertThat(saved).containsExactly("Java", "AI产品经理"); + assertThat(captor.getValue().getId()).isEqualTo(11L); + assertThat(captor.getValue().getRecommendedJobKeywords()).isEqualTo("[\"Java\",\"AI产品经理\"]"); + } + + private ResumeProfileEntity resume(String text, String keywords) { + ResumeProfileEntity entity = new ResumeProfileEntity(); + entity.setId(11L); + entity.setProfileId(7L); + entity.setResumeText(text); + entity.setRecommendedJobKeywords(keywords); + return entity; + } +} diff --git a/src/test/java/com/getjobs/application/service/JobKeywordCodecTest.java b/src/test/java/com/getjobs/application/service/JobKeywordCodecTest.java new file mode 100644 index 0000000..05c2c61 --- /dev/null +++ b/src/test/java/com/getjobs/application/service/JobKeywordCodecTest.java @@ -0,0 +1,35 @@ +package com.getjobs.application.service; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class JobKeywordCodecTest { + @Test + void parsesLegacyAndJsonFormatsWithCaseInsensitiveDedupe() { + assertThat(JobKeywordCodec.parse("Java,AI产品经理;java\nRAG产品")) + .containsExactly("Java", "AI产品经理", "RAG产品"); + assertThat(JobKeywordCodec.parse("[\"Java\",\"AI产品经理\",\"JAVA\"]")) + .containsExactly("Java", "AI产品经理"); + } + + @Test + void serializesCanonicalJsonAndRejectsMoreThanEight() { + assertThat(JobKeywordCodec.validateAndSerialize("Java,AI产品经理,Java")) + .isEqualTo("[\"Java\",\"AI产品经理\"]"); + assertThatThrownBy(() -> JobKeywordCodec.parseAndValidate( + "岗位1,岗位2,岗位3,岗位4,岗位5,岗位6,岗位7,岗位8,岗位9")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("最多选择8个"); + } + + @Test + void normalizeCanLimitAiRecommendationsWithoutChangingOrder() { + assertThat(JobKeywordCodec.normalize(List.of( + "岗位1", "岗位2", "岗位3", "岗位4", "岗位5", "岗位6", "岗位7", "岗位8", "岗位9"), 8)) + .containsExactly("岗位1", "岗位2", "岗位3", "岗位4", "岗位5", "岗位6", "岗位7", "岗位8"); + } +} diff --git a/src/test/java/com/getjobs/application/service/ZhilianServiceKeywordTest.java b/src/test/java/com/getjobs/application/service/ZhilianServiceKeywordTest.java new file mode 100644 index 0000000..343f227 --- /dev/null +++ b/src/test/java/com/getjobs/application/service/ZhilianServiceKeywordTest.java @@ -0,0 +1,31 @@ +package com.getjobs.application.service; + +import com.getjobs.application.entity.ZhilianConfigEntity; +import com.getjobs.application.mapper.ZhilianConfigMapper; +import com.getjobs.application.mapper.ZhilianJobDataMapper; +import com.getjobs.application.mapper.ZhilianOptionMapper; +import org.junit.jupiter.api.Test; + +import javax.sql.DataSource; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +class ZhilianServiceKeywordTest { + @Test + void rejectsMoreThanEightKeywordsBeforeSaving() { + ZhilianService service = new ZhilianService( + mock(ZhilianConfigMapper.class), + mock(ZhilianOptionMapper.class), + mock(ZhilianJobDataMapper.class), + mock(DataSource.class), + mock(ProfileService.class) + ); + ZhilianConfigEntity incoming = new ZhilianConfigEntity(); + incoming.setKeywords("1,2,3,4,5,6,7,8,9"); + + assertThatThrownBy(() -> service.updateConfig(incoming)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("最多选择8个"); + } +} diff --git a/tasks/2026-09-03-job-keyword-cors-scan-fix.md b/tasks/2026-09-03-job-keyword-cors-scan-fix.md new file mode 100644 index 0000000..675194c --- /dev/null +++ b/tasks/2026-09-03-job-keyword-cors-scan-fix.md @@ -0,0 +1,69 @@ +# 岗位关键词推荐、深度采集与扩展提交修复 + +## 背景 + +简历确认后没有可选的岗位关键词推荐,BOSS 与智联仍要求用户输入逗号分隔文本;BOSS 后续关键词在历史岗位大量重复时会过早停止。Chrome 扩展提交 JSON 时还会因后端未允许扩展 Origin 而收到 `403 Invalid CORS request`,导致岗位无法入库及进入分析队列。 + +## 目标 + +- 使用现有本地 Codex CLI 在简历配置生成阶段产生并持久化最多 8 个岗位关键词推荐。 +- 为 BOSS 与智联提供独立选择、可复用的标签式关键词输入。 +- 统一关键词解析、去重和数量校验,兼容历史字符串和 JSON 数据。 +- 让两个平台按“当前档案新增岗位”继续深翻,并输出可解释的停止原因。 +- 使用稳定扩展 ID 和精确 CORS 白名单恢复扩展提交,不放宽为任意扩展。 + +## 允许修改范围 + +- 简历档案实体、迁移、AI 配置控制器/服务及对应测试。 +- BOSS、智联配置、历史去重接口、前端页面及关键词组件测试。 +- Chrome 扩展 Manifest、后台桥接、BOSS/智联扫描流程及扩展测试。 +- CORS 配置、契约测试、必要的 API 文档和本任务文件。 + +## 禁止修改范围 + +- 不修改本地 Codex Provider、模型、登录或认证配置。 +- 不读取或提交 API Key、Token、Cookie、密码、`.env` 内容或浏览器数据。 +- 不覆盖、合并或推送根工作区领先远端的五个既有提交。 +- 不进行真实岗位投递、招聘平台消息发送、自动合并、强推或历史重写。 +- 不重启 AI-JobPilot 以外的 RunDock 服务,不删除用户数据库或扫描断点。 + +## 已确定实现要求 + +- `/api/ai/resume/generate-config` 返回 `recommendedKeywords`,并新增只读 `/api/ai/job-keywords`;推荐按档案保存,简历内容变化后失效。 +- 推荐词最多 8 个;界面建议 3–5 个。推荐项只有点击后才加入,BOSS 与智联各自保存选择。 +- 标签输入支持回车、自定义词、删除、逗号/中文逗号/分号/换行粘贴、忽略大小写去重和数量提示。 +- 历史配置超过 8 个时不得静默截断,在用户删减前禁止新增、保存或启动扫描。 +- 新保存值使用 JSON 数组;后端继续读取历史分隔字符串和 JSON,超过 8 个返回 400。 +- BOSS 单关键词最多 30 轮、500 个候选、180 秒或连续 5 轮无新增;智联最多 50 页、180 秒或连续 5 页无新增。达到目标、平台到底或最先触发安全边界时停止。 +- 进度记录目标、候选、配置过滤、历史重复、新增、轮次/页数和标准停止原因。 +- Manifest 使用项目固定公钥生成稳定 ID;后端仅允许该精确扩展 Origin 访问 BOSS/智联 `/chrome/**` 所需接口,未知扩展继续 403。 +- 保留根工作区较新的扩展恢复语义,但不得整批合并根分支提交。 + +## 验收标准 + +- 简历生成成功后两个平台都能读取同一推荐池,用户点击后独立加入,打开页面不重复调用 AI。 +- 旧关键词数据不丢失,8 个以内可正常保存和扫描,超过 8 个被明确阻止。 +- 后续关键词会在历史重复较多时继续深翻,日志能说明少于目标的真实原因。 +- 固定扩展 Origin 对 BOSS/智联预检为 200,未知扩展和普通网站为 403,本地前端仍为 200。 +- 扩展提交岗位后能完成入库、持久队列分析并出现在列表;真实投递保持关闭。 +- 自动测试、lint、typecheck、构建、数据库迁移与运行时健康检查通过。 + +## 测试命令 + +```powershell +gradlew.bat test +pnpm --dir front vitest run +pnpm --dir front lint +pnpm --dir front typecheck +pnpm --dir front build +$extensionTests = Get-ChildItem chrome-extension/tests/*.test.cjs | ForEach-Object { $_.FullName } +node --test $extensionTests +git diff --check +``` + +## 返回格式 + +- 修改文件、数据库/API/扩展身份变化和关键行为摘要。 +- 测试、构建、CORS、运行时 PID/cwd/HEAD、数据库与单岗位受控扫描证据。 +- 初始与最终 Git 状态、提交 SHA、远端 SHA、Push、PR 与 CI 状态。 +- 扩展手动重载步骤、回滚方式和所有未执行的外部动作。 From 7dabe033db3a9dc2fdc0438c2d24ec872357de7b Mon Sep 17 00:00:00 2001 From: damingishere-coder Date: Thu, 3 Sep 2026 11:10:41 +0800 Subject: [PATCH 04/12] =?UTF-8?q?refactor:=20=E7=BB=9F=E4=B8=80=E5=89=8D?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E8=87=B3=206866=20=E7=AB=AF=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +- chrome-extension/background.js | 4 +- chrome-extension/boss-content.js | 2 +- chrome-extension/manifest.json | 4 +- .../tests/background-tab-routing.test.cjs | 4 +- .../tests/boss-scan-support.test.cjs | 2 +- chrome-extension/tests/manifest-id.test.cjs | 6 +- scripts/run_backend.ps1 | 33 +++++++-- .../application/config/StartupRunner.java | 2 +- .../config/StaticServerConfiguration.java | 12 ++++ src/main/resources/application.yaml | 2 +- .../config/ProjectConfigurationSmokeTest.java | 12 +++- .../config/StartupRunnerDegradedModeTest.java | 4 +- .../config/StaticServerConfigurationTest.java | 14 ++++ start_windows.ps1 | 67 +++++-------------- tasks/2026-09-03-job-keyword-cors-scan-fix.md | 3 + 16 files changed, 103 insertions(+), 74 deletions(-) create mode 100644 src/test/java/com/getjobs/application/config/StaticServerConfigurationTest.java diff --git a/README.md b/README.md index 2616037..ba443ea 100644 --- a/README.md +++ b/README.md @@ -139,9 +139,9 @@ start_windows.bat 启动成功后打开: ```text -前端:http://localhost:6866 -后端存活检查:http://localhost:8888/api/health -后端就绪检查:http://localhost:8888/api/ready +页面与 API:http://localhost:6866 +存活检查:http://localhost:6866/api/health +就绪检查:http://localhost:6866/api/ready ``` `/api/health` 返回 `UP` 只表示进程存活;首页检查项正常且 `/api/ready` 返回就绪,才表示数据库、Schema 和任务队列可用于业务操作。 diff --git a/chrome-extension/background.js b/chrome-extension/background.js index 0afbdb7..d54d0d4 100644 --- a/chrome-extension/background.js +++ b/chrome-extension/background.js @@ -39,7 +39,7 @@ const TAB_LOAD_TIMEOUT_MS = 10000; const DELIVERY_NAVIGATION_TIMEOUT_MS = 15000; const REQUIRED_BOSS_CONTENT_VERSION = "2026-09-03-keyword-deep-fill"; const REQUIRED_ZHILIAN_CONTENT_VERSION = "2026-09-03-keyword-deep-fill"; -const LOCAL_API_BASE_URLS = ["http://localhost:8888", "http://127.0.0.1:8888", "http://localhost:6866", "http://127.0.0.1:6866"]; +const LOCAL_API_BASE_URLS = ["http://localhost:6866", "http://127.0.0.1:6866"]; const BOSS_LOCAL_API_MAX_ATTEMPTS = 3; const BOSS_LOCAL_API_TIMEOUT_MS = 30000; const ALLOWED_PAGE_ORIGINS = new Set([ @@ -556,7 +556,7 @@ function isRetryableLocalApiStatus(status) { function friendlyLocalApiError(error) { const message = error?.message || String(error || ""); if (error?.name === "AbortError" || /abort/i.test(message)) return "请求超时,请确认本地服务仍在运行"; - if (/Failed to fetch|NetworkError|fetch/i.test(message)) return "无法连接本地后端,请确认 8888 端口正常"; + if (/Failed to fetch|NetworkError|fetch/i.test(message)) return "无法连接本地服务,请确认 6866 端口正常"; return message || "未知网络错误"; } diff --git a/chrome-extension/boss-content.js b/chrome-extension/boss-content.js index ceeb1ad..19b4a0c 100644 --- a/chrome-extension/boss-content.js +++ b/chrome-extension/boss-content.js @@ -623,7 +623,7 @@ : "LOCAL_API_ERROR"); const catalog = { CORS_REJECTED: ["Chrome扩展请求被后端CORS规则拒绝", "岗位已采集但本批尚未入库,扫描断点已保留。", "重启更新后的本地服务并重新加载Chrome扩展,然后继续扫描。"], - LOCAL_SERVICE_UNAVAILABLE: ["无法连接本地服务", "岗位暂时无法入库,扫描断点已保留。", "确认投递牛马本地后端和8888端口正常后,再次点击扫描继续。"], + LOCAL_SERVICE_UNAVAILABLE: ["无法连接本地服务", "岗位暂时无法入库,扫描断点已保留。", "确认投递牛马本地服务和6866端口正常后,再次点击扫描继续。"], LOCAL_API_TIMEOUT: ["本地服务响应超时", "当前提交批次未确认完成,扫描断点已保留。", "确认本地服务仍在运行后继续扫描,系统会从当前批次恢复。"], LOCAL_API_FORBIDDEN: ["本地接口拒绝访问", "当前提交批次未入库。", "重新加载扩展并确认使用的是本项目本地页面。"], LOCAL_API_NOT_FOUND: ["本地接口不存在或版本不匹配", "扩展无法提交岗位。", "重启最新版本的本地服务并重新加载扩展。"], diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json index c7daee3..12726ce 100644 --- a/chrome-extension/manifest.json +++ b/chrome-extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "投递牛马 Chrome Bridge", - "version": "1.4.0", + "version": "1.4.1", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzzdIlNVOv76Y/cSWrjD5Tg2Vlsha8yWHzsn46PBsg724/2dftOUzIIr2n70VRaRgGwEd8FjO/Y768Ori443zF4pQpWvuxXxm05YO25ILQ/+aJLmUycAEdWbkdhcagr4YXnXJdYlSCGSAToSQBjk+owQOdlBLQn5wofPoshrqayoJjRQ5aAUj1SuSlnNv9iimle8GMA1IaA1l5rw6K/chfcgwMTg6HxRAIoludt5JGbIBryi2Lu1hOJRMaDnL7A57ofBnn3qx3H2HIGWGkkTW9EMkls0XMXwx8+mJVIj5HSYl0EeuCvEoTa1W3i1CbOf3kY2yCPKS3Qz3lOvJiwJ4ZQIDAQAB", "description": "Use the signed-in Chrome tabs to scan jobs and confirm deliveries for 投递牛马.", "icons": { @@ -14,8 +14,6 @@ "host_permissions": [ "http://localhost:6866/*", "http://127.0.0.1:6866/*", - "http://localhost:8888/*", - "http://127.0.0.1:8888/*", "https://www.zhipin.com/*", "https://*.zhipin.com/*", "https://www.zhaopin.com/*", diff --git a/chrome-extension/tests/background-tab-routing.test.cjs b/chrome-extension/tests/background-tab-routing.test.cjs index f42780d..8cb2b1b 100644 --- a/chrome-extension/tests/background-tab-routing.test.cjs +++ b/chrome-extension/tests/background-tab-routing.test.cjs @@ -282,7 +282,7 @@ test("allows Zhilian job submission through the fixed local API route", async () assert.equal(response.success, true); assert.equal(response.data.saved, 1); assert.equal(requests.length, 1); - assert.equal(requests[0].url, "http://localhost:8888/api/zhilian/chrome/jobs"); + assert.equal(requests[0].url, "http://localhost:6866/api/zhilian/chrome/jobs"); assert.equal(requests[0].options.method, "POST"); }); @@ -318,7 +318,7 @@ test("allows numeric Zhilian delivery result IDs and rejects invalid or unknown }, sender); assert.equal(allowed.success, true); - assert.equal(urls[0], "http://localhost:8888/api/zhilian/jobs/123/delivery-result"); + assert.equal(urls[0], "http://localhost:6866/api/zhilian/jobs/123/delivery-result"); assert.equal(invalidId.success, false); assert.match(invalidId.message, /有效岗位ID/); assert.equal(unknown.success, false); diff --git a/chrome-extension/tests/boss-scan-support.test.cjs b/chrome-extension/tests/boss-scan-support.test.cjs index 2a734e1..6700317 100644 --- a/chrome-extension/tests/boss-scan-support.test.cjs +++ b/chrome-extension/tests/boss-scan-support.test.cjs @@ -260,7 +260,7 @@ test("classifies CORS and local service failures for actionable diagnostics", () "CORS_REJECTED" ); assert.equal( - support.classifyLocalApiFailure(new Error("无法连接本地服务,请确认8888端口正常")), + support.classifyLocalApiFailure(new Error("无法连接本地服务,请确认6866端口正常")), "LOCAL_SERVICE_UNAVAILABLE" ); }); diff --git a/chrome-extension/tests/manifest-id.test.cjs b/chrome-extension/tests/manifest-id.test.cjs index 1354449..d486eab 100644 --- a/chrome-extension/tests/manifest-id.test.cjs +++ b/chrome-extension/tests/manifest-id.test.cjs @@ -17,7 +17,7 @@ function extensionIdFromKey(key) { test('manifest public key derives the backend allowlisted extension id', () => { const manifestPath = path.join(__dirname, '..', 'manifest.json'); const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); - assert.equal(manifest.version, '1.4.0'); + assert.equal(manifest.version, '1.4.1'); assert.equal(extensionIdFromKey(manifest.key), EXPECTED_EXTENSION_ID); const publicKey = crypto.createPublicKey({ @@ -26,4 +26,8 @@ test('manifest public key derives the backend allowlisted extension id', () => { type: 'spki', }); assert.equal(publicKey.asymmetricKeyType, 'rsa'); + assert.deepEqual( + manifest.host_permissions.filter((permission) => permission.startsWith('http://localhost:') || permission.startsWith('http://127.0.0.1:')), + ['http://localhost:6866/*', 'http://127.0.0.1:6866/*'], + ); }); diff --git a/scripts/run_backend.ps1 b/scripts/run_backend.ps1 index 9818adb..1052cf6 100644 --- a/scripts/run_backend.ps1 +++ b/scripts/run_backend.ps1 @@ -42,6 +42,7 @@ function Get-JavaMajorVersion { $ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path $GradlewBat = Join-Path $ProjectRoot "gradlew.bat" +$FrontDir = Join-Path $ProjectRoot "front" $DbDir = Join-Path $ProjectRoot "db" $DataDir = Join-Path $ProjectRoot "data" $OutputDir = Join-Path $ProjectRoot "output" @@ -60,9 +61,16 @@ if (-not (Test-Path -LiteralPath $GradlewBat)) { Fail-WithHelp "没有找到 $GradlewBat。" "请确认 Alter 的工作目录指向项目根目录。" } -$portOwner = Get-PortOwnerDescription -Port 8888 +if ($null -eq (Get-Command "pnpm" -ErrorAction SilentlyContinue)) { + Fail-WithHelp "没有找到 pnpm。" "请安装或启用 pnpm,统一服务启动前需要构建前端静态文件。" +} +if (-not (Test-Path -LiteralPath (Join-Path $FrontDir "node_modules"))) { + Fail-WithHelp "前端依赖尚未安装。" "请在 $FrontDir 执行 pnpm install。" +} + +$portOwner = Get-PortOwnerDescription -Port 6866 if ($portOwner) { - Fail-WithHelp "后端端口 8888 已被占用:$portOwner" "请先在 Alter 停止旧 Backend,确认端口释放后再启动。" + Fail-WithHelp "统一端口 6866 已被占用:$portOwner" "请先在 Alter 停止旧 Frontend/Backend,确认端口释放后再启动统一 Backend。" } foreach ($dir in @($DbDir, $DataDir, $OutputDir, $CacheDir, $LogDir, $ChromeProfileDir)) { @@ -75,6 +83,7 @@ if (-not $env:SPRING_DATASOURCE_URL) { if (-not $env:SERVER_ADDRESS) { $env:SERVER_ADDRESS = "127.0.0.1" } +$env:SERVER_PORT = "6866" if (-not $env:APP_DATA_DIR) { $env:APP_DATA_DIR = $DataDir } @@ -99,9 +108,7 @@ if (-not $env:APP_AUTO_OPEN_BROWSER) { if (-not $env:APP_BROWSER_INITIALIZE_ON_STARTUP) { $env:APP_BROWSER_INITIALIZE_ON_STARTUP = "false" } -if (-not $env:APP_STATIC_SERVER_ENABLED) { - $env:APP_STATIC_SERVER_ENABLED = "false" -} +$env:APP_STATIC_SERVER_ENABLED = "true" $encodingOptions = "-Dfile.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8" if ([string]::IsNullOrWhiteSpace($env:JAVA_TOOL_OPTIONS)) { @@ -110,8 +117,20 @@ if ([string]::IsNullOrWhiteSpace($env:JAVA_TOOL_OPTIONS)) { $env:JAVA_TOOL_OPTIONS = "$($env:JAVA_TOOL_OPTIONS) $encodingOptions" } -Write-Host "启动投递牛马后端:$ProjectRoot" -Write-Host "健康检查:http://127.0.0.1:8888/api/health" +Write-Host "构建统一服务前端静态文件:$FrontDir" +Push-Location $FrontDir +try { + & pnpm build:prod + if ($LASTEXITCODE -ne 0) { + Fail-WithHelp "前端静态构建失败。" "请查看上方 pnpm build:prod 输出并修复后重试。" + } +} finally { + Pop-Location +} + +Write-Host "启动投递牛马统一服务:$ProjectRoot" +Write-Host "页面地址:http://127.0.0.1:6866" +Write-Host "健康检查:http://127.0.0.1:6866/api/health" Write-Host "启动阶段不会打开管理页或招聘网站;使用平台功能时浏览器会按需启动。" Push-Location $ProjectRoot diff --git a/src/main/java/com/getjobs/application/config/StartupRunner.java b/src/main/java/com/getjobs/application/config/StartupRunner.java index 197f3a4..b3f86ad 100644 --- a/src/main/java/com/getjobs/application/config/StartupRunner.java +++ b/src/main/java/com/getjobs/application/config/StartupRunner.java @@ -23,7 +23,7 @@ @Component public class StartupRunner implements ApplicationRunner { - @Value("${server.port:8888}") + @Value("${server.port:6866}") private int backendPort; @Value("${app.auto-open-browser:false}") diff --git a/src/main/java/com/getjobs/application/config/StaticServerConfiguration.java b/src/main/java/com/getjobs/application/config/StaticServerConfiguration.java index aa87f7f..9f9d314 100644 --- a/src/main/java/com/getjobs/application/config/StaticServerConfiguration.java +++ b/src/main/java/com/getjobs/application/config/StaticServerConfiguration.java @@ -30,6 +30,9 @@ public class StaticServerConfiguration { @Value("${app.static-server.enabled:true}") private boolean staticServerEnabled; + @Value("${server.port:6866}") + private int serverPort; + @Bean public WebServerFactoryCustomizer servletContainer() { return server -> { @@ -38,6 +41,11 @@ public WebServerFactoryCustomizer servletContaine return; } + if (servesFrontendOnPrimaryPort(serverPort)) { + log.info("页面与 API 共用主端口 {},无需创建额外连接器", FRONTEND_PORT); + return; + } + // 检查前端 dev 服务是否运行 boolean hasFrontendDev = detectFrontendDevServer(); @@ -62,6 +70,10 @@ public WebServerFactoryCustomizer servletContaine }; } + static boolean servesFrontendOnPrimaryPort(int configuredServerPort) { + return configuredServerPort == FRONTEND_PORT; + } + /** * 探测前端开发服务器是否在运行 * 尝试 IPv4 和 IPv6 diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 4fffd1c..c217100 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -39,7 +39,7 @@ spring: # 服务器配置 server: address: ${SERVER_ADDRESS:127.0.0.1} - port: ${SERVER_PORT:8888} # 后端 API 固定端口 + port: ${SERVER_PORT:6866} # 页面与 API 共用单一端口 app: auto-open-browser: ${APP_AUTO_OPEN_BROWSER:false} diff --git a/src/test/java/com/getjobs/application/config/ProjectConfigurationSmokeTest.java b/src/test/java/com/getjobs/application/config/ProjectConfigurationSmokeTest.java index a87e1a8..0bba510 100644 --- a/src/test/java/com/getjobs/application/config/ProjectConfigurationSmokeTest.java +++ b/src/test/java/com/getjobs/application/config/ProjectConfigurationSmokeTest.java @@ -18,7 +18,7 @@ class ProjectConfigurationSmokeTest { void readsApplicationYamlConfiguration() throws Exception { JsonNode root = yamlMapper.readTree(Path.of("src/main/resources/application.yaml").toFile()); - assertThat(root.path("server").path("port").asText()).contains("8888"); + assertThat(root.path("server").path("port").asText()).contains("6866"); assertThat(root.path("server").path("address").asText()).contains("127.0.0.1"); assertThat(root.path("spring").path("datasource").path("url").asText()).contains("jdbc:sqlite"); assertThat(root.path("app").path("paths").path("data-dir").asText()).contains("APP_DATA_DIR"); @@ -45,6 +45,16 @@ void productionFrontendScriptsUseNextOutDirectory() throws Exception { assertThat(startScript).contains("127.0.0.1"); } + @Test + void unifiedWindowsBackendBuildsAndServesFrontendOn6866() throws Exception { + String backendScript = Files.readString(Path.of("scripts/run_backend.ps1"), StandardCharsets.UTF_8); + + assertThat(backendScript).contains("$env:SERVER_PORT = \"6866\""); + assertThat(backendScript).contains("$env:APP_STATIC_SERVER_ENABLED = \"true\""); + assertThat(backendScript).contains("pnpm build:prod"); + assertThat(backendScript).doesNotContain("Port 8888"); + } + @Test void dockerOverridesContainerBindAddressButKeepsHostLoopbackOnly() throws Exception { String compose = Files.readString(Path.of("docker-compose.yml"), StandardCharsets.UTF_8); diff --git a/src/test/java/com/getjobs/application/config/StartupRunnerDegradedModeTest.java b/src/test/java/com/getjobs/application/config/StartupRunnerDegradedModeTest.java index 9641cb7..922304f 100644 --- a/src/test/java/com/getjobs/application/config/StartupRunnerDegradedModeTest.java +++ b/src/test/java/com/getjobs/application/config/StartupRunnerDegradedModeTest.java @@ -20,7 +20,7 @@ void keepsBackendRunningWhenPlaywrightInitializationFails() { ReflectionTestUtils.setField(runner, "playwrightManager", playwrightManager); ReflectionTestUtils.setField(runner, "autoOpenBrowser", false); ReflectionTestUtils.setField(runner, "initializeBrowserOnStartup", true); - ReflectionTestUtils.setField(runner, "backendPort", 8888); + ReflectionTestUtils.setField(runner, "backendPort", 6866); assertThatCode(() -> runner.run(null)).doesNotThrowAnyException(); } @@ -33,7 +33,7 @@ void skipsPlaywrightInitializationByDefault() { ReflectionTestUtils.setField(runner, "playwrightManager", playwrightManager); ReflectionTestUtils.setField(runner, "autoOpenBrowser", false); ReflectionTestUtils.setField(runner, "initializeBrowserOnStartup", false); - ReflectionTestUtils.setField(runner, "backendPort", 8888); + ReflectionTestUtils.setField(runner, "backendPort", 6866); assertThatCode(() -> runner.run(null)).doesNotThrowAnyException(); verifyNoInteractions(playwrightManager); diff --git a/src/test/java/com/getjobs/application/config/StaticServerConfigurationTest.java b/src/test/java/com/getjobs/application/config/StaticServerConfigurationTest.java new file mode 100644 index 0000000..4ad8f5d --- /dev/null +++ b/src/test/java/com/getjobs/application/config/StaticServerConfigurationTest.java @@ -0,0 +1,14 @@ +package com.getjobs.application.config; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class StaticServerConfigurationTest { + + @Test + void reusesPrimaryConnectorWhenApplicationRunsOnUnifiedPort() { + assertThat(StaticServerConfiguration.servesFrontendOnPrimaryPort(6866)).isTrue(); + assertThat(StaticServerConfiguration.servesFrontendOnPrimaryPort(8888)).isFalse(); + } +} diff --git a/start_windows.ps1 b/start_windows.ps1 index e0b4e13..5358f2b 100644 --- a/start_windows.ps1 +++ b/start_windows.ps1 @@ -256,77 +256,46 @@ if (-not $env:APP_AUTO_OPEN_BROWSER) { if (-not $env:APP_BROWSER_INITIALIZE_ON_STARTUP) { $env:APP_BROWSER_INITIALIZE_ON_STARTUP = "false" } -if (-not $env:APP_STATIC_SERVER_ENABLED) { - $env:APP_STATIC_SERVER_ENABLED = "false" -} +$env:SERVER_PORT = "6866" +$env:APP_STATIC_SERVER_ENABLED = "true" $env:JAVA_TOOL_OPTIONS = "-Dfile.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8" -$BackendLog = Join-Path $LogDir "windows-backend.log" -$FrontendLog = Join-Path $LogDir "windows-frontend.log" +$BackendLog = Join-Path $LogDir "windows-unified.log" $backendLogLiteral = ConvertTo-PowerShellLiteral $BackendLog -$frontendLogLiteral = ConvertTo-PowerShellLiteral $FrontendLog $backendScriptLiteral = ConvertTo-PowerShellLiteral (Join-Path $ProjectRoot "scripts\run_backend.ps1") -$frontendScriptLiteral = ConvertTo-PowerShellLiteral (Join-Path $ProjectRoot "scripts\run_frontend.ps1") -$FrontendUrl = "http://127.0.0.1:6866/" -$BackendHealthUrl = "http://127.0.0.1:8888/api/health" - -Write-Section "5. 启动前端" -if ((Test-HttpEndpoint -Url $FrontendUrl) -and - (Test-PortOwnedByProject -Port 6866 -ProjectRoot $ProjectRoot -ExpectedProcessPattern '^node(\.exe)?$')) { - Write-Host "当前项目的前端已经正常运行,跳过重复启动。" -ForegroundColor Green -} elseif (Test-PortOpen -Port 6866) { - $owner = Get-PortOwnerDescription -Port 6866 - Fail-WithHelp ` - "前端端口 6866 已被占用,但不是当前项目可复用的健康前端:$owner" ` - "请先停止占用 6866 的旧进程,再重新运行本启动器。" -} else { - $FrontendCommand = @" -& $frontendScriptLiteral *>> $frontendLogLiteral -exit `$LASTEXITCODE -"@ - $frontendProcess = Start-BackgroundPowerShell -Command $FrontendCommand - Write-Host "前端启动进程:$($frontendProcess.Id)" - Write-Host "前端日志:$FrontendLog" +$BackendHealthUrl = "http://127.0.0.1:6866/api/ready" - if (-not (Wait-ForHttpEndpoint -Url $FrontendUrl -TimeoutSeconds 60)) { - Fail-WithHelp ` - "前端在 60 秒内未能通过 HTTP 健康检查。" ` - "请查看日志:$FrontendLog" - } - Write-Host "前端 HTTP 服务已就绪。" -ForegroundColor Green -} - -Write-Section "6. 启动后端" +Write-Section "5. 启动统一服务" if ((Test-HttpEndpoint -Url $BackendHealthUrl) -and - (Test-PortOwnedByProject -Port 8888 -ProjectRoot $ProjectRoot -ExpectedProcessPattern '^java(\.exe)?$')) { - Write-Host "当前项目的后端已经正常运行,跳过重复启动。" -ForegroundColor Green -} elseif (Test-PortOpen -Port 8888) { - $owner = Get-PortOwnerDescription -Port 8888 + (Test-PortOwnedByProject -Port 6866 -ProjectRoot $ProjectRoot -ExpectedProcessPattern '^java(\.exe)?$')) { + Write-Host "当前项目的统一服务已经正常运行,跳过重复启动。" -ForegroundColor Green +} elseif (Test-PortOpen -Port 6866) { + $owner = Get-PortOwnerDescription -Port 6866 Fail-WithHelp ` - "后端端口 8888 已被占用,但不是当前项目可复用的健康后端:$owner" ` - "请先停止占用 8888 的旧进程,再重新运行本启动器。" + "统一端口 6866 已被占用,但不是当前项目可复用的健康服务:$owner" ` + "请先停止占用 6866 的旧 Frontend/Backend,再重新运行本启动器。" } else { $BackendCommand = @" & $backendScriptLiteral *>> $backendLogLiteral exit `$LASTEXITCODE "@ $backendProcess = Start-BackgroundPowerShell -Command $BackendCommand - Write-Host "后端启动进程:$($backendProcess.Id)" - Write-Host "后端日志:$BackendLog" + Write-Host "统一服务启动进程:$($backendProcess.Id)" + Write-Host "统一服务日志:$BackendLog" if (-not (Wait-ForHttpEndpoint -Url $BackendHealthUrl -TimeoutSeconds 120)) { Fail-WithHelp ` - "后端在 120 秒内未能通过健康检查。" ` + "统一服务在 120 秒内未能通过健康检查。" ` "请查看日志:$BackendLog" } - Write-Host "后端健康检查已通过。" -ForegroundColor Green + Write-Host "统一服务健康检查已通过。" -ForegroundColor Green } -Write-Section "7. 启动完成" -Write-Host "前端:http://localhost:6866" +Write-Section "6. 启动完成" +Write-Host "页面与 API:http://localhost:6866" Write-Host "环境配置:http://localhost:6866/env-config" -Write-Host "后端健康检查:http://localhost:8888/api/health" +Write-Host "健康检查:http://localhost:6866/api/ready" Write-Host "" Write-Host "如果页面没有自动刷新,请在浏览器中按 Ctrl+R。" exit 0 diff --git a/tasks/2026-09-03-job-keyword-cors-scan-fix.md b/tasks/2026-09-03-job-keyword-cors-scan-fix.md index 675194c..556b41b 100644 --- a/tasks/2026-09-03-job-keyword-cors-scan-fix.md +++ b/tasks/2026-09-03-job-keyword-cors-scan-fix.md @@ -38,6 +38,8 @@ - 进度记录目标、候选、配置过滤、历史重复、新增、轮次/页数和标准停止原因。 - Manifest 使用项目固定公钥生成稳定 ID;后端仅允许该精确扩展 Origin 访问 BOSS/智联 `/chrome/**` 所需接口,未知扩展继续 403。 - 保留根工作区较新的扩展恢复语义,但不得整批合并根分支提交。 +- 本地运行改为单监听端口:Spring 后端直接监听 `127.0.0.1:6866` 并托管前端静态导出,Chrome 扩展只访问 6866;不得保留 8888 监听。 +- RunDock 只运行统一 Backend,独立 Frontend 停止但保留配置作为回滚入口,不删除其历史配置。 ## 验收标准 @@ -47,6 +49,7 @@ - 固定扩展 Origin 对 BOSS/智联预检为 200,未知扩展和普通网站为 403,本地前端仍为 200。 - 扩展提交岗位后能完成入库、持久队列分析并出现在列表;真实投递保持关闭。 - 自动测试、lint、typecheck、构建、数据库迁移与运行时健康检查通过。 +- 运行态 `6866` 同时返回页面与 `/api/ready`,`8888` 无监听;6866 的 PID、工作目录和进程祖先链可追溯到 AI-JobPilot 的 RunDock Backend。 ## 测试命令 From b4541eb2cfaf9551b488c9c22b1f089756e77e54 Mon Sep 17 00:00:00 2001 From: damingishere-coder Date: Thu, 3 Sep 2026 11:28:54 +0800 Subject: [PATCH 05/12] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E7=BA=A6?= =?UTF-8?q?=E6=9D=9F=20Codex=20=E5=B2=97=E4=BD=8D=E5=88=86=E6=9E=90?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E5=8C=96=E8=BE=93=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/service/AiService.java | 15 +++++++ .../application/service/CodexCliService.java | 35 ++++++++++++++++- .../service/JobAiAnalysisService.java | 17 +++++++- .../service/AiServiceProviderTest.java | 17 ++++++++ .../service/CodexCliServiceTest.java | 16 ++++++++ .../JobAiAnalysisServiceStatusTest.java | 39 ++++++++++--------- tasks/2026-09-03-job-keyword-cors-scan-fix.md | 2 + 7 files changed, 121 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/getjobs/application/service/AiService.java b/src/main/java/com/getjobs/application/service/AiService.java index 67b3803..42820c0 100644 --- a/src/main/java/com/getjobs/application/service/AiService.java +++ b/src/main/java/com/getjobs/application/service/AiService.java @@ -123,6 +123,21 @@ && containsReasoningParamError(response.body()) return parseTextResponse(response, endpoint, clientRequestId); } + /** + * 请求符合 JSON Schema 的结构化结果。本地 Codex CLI 使用其原生 + * --output-schema 能力;其他兼容 Provider 保持原有请求协议并由调用方继续校验结果。 + */ + public String sendStructuredRequest(String content, String outputSchema) { + if (outputSchema == null || outputSchema.isBlank()) { + throw new IllegalArgumentException("结构化输出 Schema 不能为空"); + } + var cfg = configService.getAiConfigs(); + if ("codex".equalsIgnoreCase(cfg.get("AI_PROVIDER"))) { + return codexCliService.generateStructuredText(content, outputSchema, cfg); + } + return sendRequest(content); + } + /** * 使用配置的视觉模型从图片简历中提取结构化文本。 */ diff --git a/src/main/java/com/getjobs/application/service/CodexCliService.java b/src/main/java/com/getjobs/application/service/CodexCliService.java index 9233992..373ad3f 100644 --- a/src/main/java/com/getjobs/application/service/CodexCliService.java +++ b/src/main/java/com/getjobs/application/service/CodexCliService.java @@ -25,6 +25,13 @@ public String generateText(String content, Map config) { return run(content, (Path) null, config); } + public String generateStructuredText(String content, String outputSchema, Map config) { + if (outputSchema == null || outputSchema.isBlank()) { + throw new IllegalArgumentException("Codex CLI 结构化输出 Schema 不能为空"); + } + return run(content, List.of(), outputSchema, config); + } + public String extractResumeFromImage(byte[] imageBytes, String mimeType, Map config) { if (imageBytes == null || imageBytes.length == 0) { throw new IllegalArgumentException("图片内容不能为空"); @@ -81,6 +88,10 @@ String run(String content, Path imagePath, Map config) { } String run(String content, List imagePaths, Map config) { + return run(content, imagePaths, null, config); + } + + String run(String content, List imagePaths, String outputSchema, Map config) { String executable = resolveExecutable(value(config, "CODEX_PATH", "codex")); String model = value(config, "CODEX_MODEL", "gpt-5.6-sol"); int timeoutSeconds = parseTimeout(value(config, "CODEX_TIMEOUT_SECONDS", "300")); @@ -88,12 +99,18 @@ String run(String content, List imagePaths, Map config) { Path tempDirectory = null; Path outputPath = null; + Path outputSchemaPath = null; Process process = null; boolean slotAcquired = false; try { tempDirectory = Files.createTempDirectory("jobpilot-codex-"); outputPath = tempDirectory.resolve("final.txt"); - List command = buildCommandWithImages(executable, model, tempDirectory, outputPath, imagePaths); + if (outputSchema != null && !outputSchema.isBlank()) { + outputSchemaPath = tempDirectory.resolve("output-schema.json"); + Files.writeString(outputSchemaPath, outputSchema, StandardCharsets.UTF_8); + } + List command = buildCommandWithImages( + executable, model, tempDirectory, outputPath, imagePaths, outputSchemaPath); ProcessBuilder builder = new ProcessBuilder(command) .directory(tempDirectory.toFile()) .redirectOutput(ProcessBuilder.Redirect.DISCARD) @@ -143,6 +160,7 @@ String run(String content, List imagePaths, Map config) { CODEX_SLOTS.release(); } deleteQuietly(outputPath); + deleteQuietly(outputSchemaPath); deleteQuietly(tempDirectory); } } @@ -163,6 +181,17 @@ List buildCommandWithImages( Path workingDirectory, Path outputPath, List imagePaths + ) { + return buildCommandWithImages(executable, model, workingDirectory, outputPath, imagePaths, null); + } + + List buildCommandWithImages( + String executable, + String model, + Path workingDirectory, + Path outputPath, + List imagePaths, + Path outputSchemaPath ) { List command = new ArrayList<>(); addExecutable(command, executable); @@ -179,6 +208,10 @@ List buildCommandWithImages( command.add("--image"); imagePaths.forEach(path -> command.add(path.toString())); } + if (outputSchemaPath != null) { + command.add("--output-schema"); + command.add(outputSchemaPath.toString()); + } command.add("--output-last-message"); command.add(outputPath.toString()); command.add("-"); diff --git a/src/main/java/com/getjobs/application/service/JobAiAnalysisService.java b/src/main/java/com/getjobs/application/service/JobAiAnalysisService.java index 9cb405a..b0fafa6 100644 --- a/src/main/java/com/getjobs/application/service/JobAiAnalysisService.java +++ b/src/main/java/com/getjobs/application/service/JobAiAnalysisService.java @@ -50,6 +50,21 @@ @RequiredArgsConstructor @DependsOn("databaseSchemaService") public class JobAiAnalysisService { + private static final String JOB_ANALYSIS_OUTPUT_SCHEMA = """ + { + "type": "object", + "properties": { + "score": {"type": "integer"}, + "decision": {"type": "string", "enum": ["APPLY", "SKIP"]}, + "summary": {"type": "string"}, + "strengths": {"type": "array", "items": {"type": "string"}}, + "risks": {"type": "array", "items": {"type": "string"}}, + "greeting": {"type": "string"} + }, + "required": ["score", "decision", "summary", "strengths", "risks", "greeting"], + "additionalProperties": false + } + """; public static final int DEFAULT_APPLY_THRESHOLD = 75; public static final int DEFAULT_PRIORITY_APPLY_THRESHOLD = 65; @@ -253,7 +268,7 @@ public AnalysisResult analyzeJob(JobAnalysisRequest request, String prompt = buildPrompt(resumeText, request, priority, threshold); String raw; try { - raw = aiService.sendRequest(prompt); + raw = aiService.sendStructuredRequest(prompt, JOB_ANALYSIS_OUTPUT_SCHEMA); AnalysisResult result = parseResult(raw); result.setPriorityCompany(priority); result.setThreshold(threshold); diff --git a/src/test/java/com/getjobs/application/service/AiServiceProviderTest.java b/src/test/java/com/getjobs/application/service/AiServiceProviderTest.java index b4396b6..9242d99 100644 --- a/src/test/java/com/getjobs/application/service/AiServiceProviderTest.java +++ b/src/test/java/com/getjobs/application/service/AiServiceProviderTest.java @@ -47,6 +47,23 @@ void textRequestUsesCodexWithoutApiKey() { verify(codexCliService).generateText("岗位分析", config); } + @Test + void structuredRequestUsesCodexOutputSchema() { + Map config = Map.of( + "AI_PROVIDER", "codex", + "CODEX_PATH", "codex", + "CODEX_MODEL", "gpt-5.6-sol" + ); + String schema = "{\"type\":\"object\"}"; + when(configService.getAiConfigs()).thenReturn(config); + when(codexCliService.generateStructuredText("岗位分析", schema, config)) + .thenReturn("{\"decision\":\"SKIP\"}"); + + assertThat(service.sendStructuredRequest("岗位分析", schema)) + .isEqualTo("{\"decision\":\"SKIP\"}"); + verify(codexCliService).generateStructuredText("岗位分析", schema, config); + } + @Test void imageResumeUsesCodexImageAttachment() { Map config = Map.of("AI_PROVIDER", "codex"); diff --git a/src/test/java/com/getjobs/application/service/CodexCliServiceTest.java b/src/test/java/com/getjobs/application/service/CodexCliServiceTest.java index c46a3c8..c9870cf 100644 --- a/src/test/java/com/getjobs/application/service/CodexCliServiceTest.java +++ b/src/test/java/com/getjobs/application/service/CodexCliServiceTest.java @@ -47,6 +47,22 @@ void commandAttachesAllResumePagesToOneCodexRequest() { assertThat(command.stream().filter("--image"::equals)).hasSize(1); } + @Test + void structuredCommandPassesOutputSchemaWithoutChangingOtherRequests() { + CodexCliService service = new CodexCliService(); + Path cwd = Path.of("work"); + Path output = cwd.resolve("final.txt"); + Path schema = cwd.resolve("output-schema.json"); + + List command = service.buildCommandWithImages( + "codex", "gpt-5.6-sol", cwd, output, List.of(), schema); + + assertThat(command).containsSubsequence("--output-schema", schema.toString()); + assertThat(command).containsSubsequence("--output-last-message", output.toString(), "-"); + assertThat(service.buildCommand("codex", "gpt-5.6-sol", cwd, output, null)) + .doesNotContain("--output-schema"); + } + @Test void commandWrapsWindowsCmdLauncher() { CodexCliService service = new CodexCliService(); diff --git a/src/test/java/com/getjobs/application/service/JobAiAnalysisServiceStatusTest.java b/src/test/java/com/getjobs/application/service/JobAiAnalysisServiceStatusTest.java index aeb02a3..a4cf7bc 100644 --- a/src/test/java/com/getjobs/application/service/JobAiAnalysisServiceStatusTest.java +++ b/src/test/java/com/getjobs/application/service/JobAiAnalysisServiceStatusTest.java @@ -223,7 +223,7 @@ void durableTaskDoesNotCallProviderWhenExactJobCannotBeReserved() { assertThat(result.isFailure()).isTrue(); assertThat(result.getSummary()).contains("未调用 AI Provider"); - verify(aiService, never()).sendRequest(any()); + verify(aiService, never()).sendStructuredRequest(any(), any()); } @Test @@ -232,7 +232,7 @@ void leaseTransactionRejectsLateProviderResultBeforeAnyResultWrite() { request.setJobRowId(99L); when(bossJobDataMapper.update(any(), any(UpdateWrapper.class))).thenReturn(1); when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); - when(aiService.sendRequest(any())).thenReturn(""" + when(aiService.sendStructuredRequest(any(), any())).thenReturn(""" {"score":90,"decision":"APPLY","summary":"旧租约结果","strengths":[],"risks":[],"greeting":"你好"} """); AtomicInteger guardedWrites = new AtomicInteger(); @@ -250,7 +250,7 @@ void leaseTransactionRejectsLateProviderResultBeforeAnyResultWrite() { ); assertThat(result.isStaleLease()).isTrue(); - verify(aiService).sendRequest(any()); + verify(aiService).sendStructuredRequest(any(), any()); verify(jobAiAnalysisMapper, never()).insert(any(com.getjobs.application.entity.JobAiAnalysisEntity.class)); verify(bossJobDataMapper, times(1)).update(any(), any(UpdateWrapper.class)); } @@ -259,7 +259,7 @@ void leaseTransactionRejectsLateProviderResultBeforeAnyResultWrite() { void manualZhilianAnalyzeApplyEndsWaitingConfirm() { when(zhilianJobDataMapper.selectOne(any())).thenReturn(zhilianJob(DeliveryStatus.NOT_DELIVERED)); when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); - when(aiService.sendRequest(any())).thenReturn(""" + when(aiService.sendStructuredRequest(any(), any())).thenReturn(""" {"score":90,"decision":"APPLY","summary":"匹配","strengths":["经验匹配"],"risks":[],"greeting":"你好"} """); @@ -276,7 +276,7 @@ void customThresholdAcceptsScoreExactlyAtSixty() { when(bossJobDataMapper.selectOne(any())).thenReturn(bossJob(DeliveryStatus.NOT_DELIVERED)); when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); when(aiService.getAiConfig(PROFILE_ID)).thenReturn(aiConfig(60, 50)); - when(aiService.sendRequest(any())).thenReturn(""" + when(aiService.sendStructuredRequest(any(), any())).thenReturn(""" {"score":60,"decision":"SKIP","summary":"达到自定义分数线","strengths":[],"risks":[],"greeting":"你好"} """); @@ -287,8 +287,11 @@ void customThresholdAcceptsScoreExactlyAtSixty() { assertThat(result.getDecision()).isEqualTo("APPLY"); assertThat(lastBossUpdate().getDeliveryStatus()).isEqualTo(DeliveryStatus.WAITING_CONFIRM); ArgumentCaptor prompt = ArgumentCaptor.forClass(String.class); - verify(aiService).sendRequest(prompt.capture()); + ArgumentCaptor schema = ArgumentCaptor.forClass(String.class); + verify(aiService).sendStructuredRequest(prompt.capture(), schema.capture()); assertThat(prompt.getValue()).contains("当前阈值为60"); + assertThat(schema.getValue()) + .contains("\"required\"", "\"score\"", "\"decision\"", "\"additionalProperties\": false"); } @Test @@ -296,7 +299,7 @@ void customThresholdRejectsScoreBelowSixty() { when(bossJobDataMapper.selectOne(any())).thenReturn(bossJob(DeliveryStatus.NOT_DELIVERED)); when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); when(aiService.getAiConfig(PROFILE_ID)).thenReturn(aiConfig(60, 50)); - when(aiService.sendRequest(any())).thenReturn(""" + when(aiService.sendStructuredRequest(any(), any())).thenReturn(""" {"score":59,"decision":"APPLY","summary":"低于自定义分数线","strengths":[],"risks":[],"greeting":"你好"} """); @@ -314,7 +317,7 @@ void priorityCompanyUsesItsOwnCustomThreshold() { when(bossJobDataMapper.selectOne(any())).thenReturn(bossJob(DeliveryStatus.NOT_DELIVERED)); when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); when(aiService.getAiConfig(PROFILE_ID)).thenReturn(aiConfig(60, 50)); - when(aiService.sendRequest(any())).thenReturn(""" + when(aiService.sendStructuredRequest(any(), any())).thenReturn(""" {"score":50,"decision":"SKIP","summary":"达到优先公司分数线","strengths":[],"risks":[],"greeting":"你好"} """); @@ -347,7 +350,7 @@ void savesConfirmedUtf8ResumeText() { void repairsMarkdownWrappedAiJsonAndKeepsWaitingConfirmFlow() { when(bossJobDataMapper.selectOne(any())).thenReturn(bossJob(DeliveryStatus.NOT_DELIVERED)); when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); - when(aiService.sendRequest(any())).thenReturn(""" + when(aiService.sendStructuredRequest(any(), any())).thenReturn(""" ```json {score:88, decision:"APPLY", summary:"匹配", strengths:["Java"], risks:[], greeting:"你好",} ``` @@ -365,7 +368,7 @@ void repairsMarkdownWrappedAiJsonAndKeepsWaitingConfirmFlow() { void emptyProviderOutputBecomesExplicitAiFailureInsteadOfSkip() { when(bossJobDataMapper.selectOne(any())).thenReturn(bossJob(DeliveryStatus.NOT_DELIVERED)); when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); - when(aiService.sendRequest(any())).thenReturn(" "); + when(aiService.sendStructuredRequest(any(), any())).thenReturn(" "); JobAiAnalysisService.AnalysisResult result = service.analyzeJob(bossRequest()); @@ -379,7 +382,7 @@ void emptyProviderOutputBecomesExplicitAiFailureInsteadOfSkip() { void missingRequiredOutputFieldBecomesExplicitAiFailure() { when(bossJobDataMapper.selectOne(any())).thenReturn(bossJob(DeliveryStatus.NOT_DELIVERED)); when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); - when(aiService.sendRequest(any())).thenReturn(""" + when(aiService.sendStructuredRequest(any(), any())).thenReturn(""" {"score":80,"decision":"APPLY","summary":"匹配","strengths":[],"risks":[]} """); @@ -394,7 +397,7 @@ void missingRequiredOutputFieldBecomesExplicitAiFailure() { void invalidScoreAndArrayElementTypesAreRejected() { when(bossJobDataMapper.selectOne(any())).thenReturn(bossJob(DeliveryStatus.NOT_DELIVERED)); when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); - when(aiService.sendRequest(any())) + when(aiService.sendStructuredRequest(any(), any())) .thenReturn(""" {"score":101,"decision":"APPLY","summary":"匹配","strengths":[],"risks":[],"greeting":"你好"} """) @@ -413,7 +416,7 @@ void invalidScoreAndArrayElementTypesAreRejected() { void invalidJsonAndDecisionAreRejected() { when(bossJobDataMapper.selectOne(any())).thenReturn(bossJob(DeliveryStatus.NOT_DELIVERED)); when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); - when(aiService.sendRequest(any())) + when(aiService.sendStructuredRequest(any(), any())) .thenReturn("not-json-at-all") .thenReturn(""" {"score":80,"decision":"MAYBE","summary":"匹配","strengths":[],"risks":[],"greeting":"你好"} @@ -431,7 +434,7 @@ void rawProviderResponseIsReplacedWithDiagnosticFingerprint() { String marker = "sensitive-response-marker"; when(bossJobDataMapper.selectOne(any())).thenReturn(bossJob(DeliveryStatus.NOT_DELIVERED)); when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); - when(aiService.sendRequest(any())).thenReturn(""" + when(aiService.sendStructuredRequest(any(), any())).thenReturn(""" {"score":88,"decision":"APPLY","summary":"sensitive-response-marker","strengths":[],"risks":[],"greeting":"你好"} """); @@ -451,7 +454,7 @@ void persistenceFailureNeverReportsTaskSuccess() { when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); when(jobAiAnalysisMapper.insert(any(JobAiAnalysisEntity.class))).thenReturn(0); when(bossJobDataMapper.update(any(), any(UpdateWrapper.class))).thenReturn(1, 0, 1); - when(aiService.sendRequest(any())).thenReturn(""" + when(aiService.sendStructuredRequest(any(), any())).thenReturn(""" {"score":88,"decision":"APPLY","summary":"匹配","strengths":[],"risks":[],"greeting":"你好"} """); @@ -468,7 +471,7 @@ void platformWriteFailureCanBeConfirmedAndRetriedWithoutGettingStuckAnalyzing() when(bossJobDataMapper.selectOne(any())).thenReturn(bossJob(DeliveryStatus.AI_ANALYZING)); when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); when(bossJobDataMapper.update(any(), any(UpdateWrapper.class))).thenReturn(1, 0, 1, 1, 1); - when(aiService.sendRequest(any())).thenReturn(""" + when(aiService.sendStructuredRequest(any(), any())).thenReturn(""" {"score":88,"decision":"APPLY","summary":"匹配","strengths":[],"risks":[],"greeting":"你好"} """); @@ -488,14 +491,14 @@ void platformWriteFailureCanBeConfirmedAndRetriedWithoutGettingStuckAnalyzing() DeliveryStatus.AI_ANALYZING, DeliveryStatus.WAITING_CONFIRM ); - verify(aiService, times(2)).sendRequest(any()); + verify(aiService, times(2)).sendStructuredRequest(any(), any()); } @Test void providerTimeoutIsPersistedAsUnknownOutcome() { when(bossJobDataMapper.selectOne(any())).thenReturn(bossJob(DeliveryStatus.NOT_DELIVERED)); when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); - when(aiService.sendRequest(any())).thenThrow(new AiProviderException( + when(aiService.sendStructuredRequest(any(), any())).thenThrow(new AiProviderException( AiProviderException.Code.TIMEOUT, "AI Provider 请求超时(requestId=test-request)", null, diff --git a/tasks/2026-09-03-job-keyword-cors-scan-fix.md b/tasks/2026-09-03-job-keyword-cors-scan-fix.md index 556b41b..a67ba34 100644 --- a/tasks/2026-09-03-job-keyword-cors-scan-fix.md +++ b/tasks/2026-09-03-job-keyword-cors-scan-fix.md @@ -40,6 +40,7 @@ - 保留根工作区较新的扩展恢复语义,但不得整批合并根分支提交。 - 本地运行改为单监听端口:Spring 后端直接监听 `127.0.0.1:6866` 并托管前端静态导出,Chrome 扩展只访问 6866;不得保留 8888 监听。 - RunDock 只运行统一 Backend,独立 Frontend 停止但保留配置作为回滚入口,不删除其历史配置。 +- 岗位分析通过本地 Codex CLI 的 `--output-schema` 约束 JSON 结果;仍保留服务端字段和值校验,避免格式漂移让已入库岗位误报为分析完成。 ## 验收标准 @@ -48,6 +49,7 @@ - 后续关键词会在历史重复较多时继续深翻,日志能说明少于目标的真实原因。 - 固定扩展 Origin 对 BOSS/智联预检为 200,未知扩展和普通网站为 403,本地前端仍为 200。 - 扩展提交岗位后能完成入库、持久队列分析并出现在列表;真实投递保持关闭。 +- 受控扫描若曾因 AI JSON 格式失败,可在修复后重试同一岗位并得到有效分析结果,不需要重复采集或投递。 - 自动测试、lint、typecheck、构建、数据库迁移与运行时健康检查通过。 - 运行态 `6866` 同时返回页面与 `/api/ready`,`8888` 无监听;6866 的 PID、工作目录和进程祖先链可追溯到 AI-JobPilot 的 RunDock Backend。 From 623c1f1849541d0a1ff323d34b9fbbeae719ea14 Mon Sep 17 00:00:00 2001 From: damingishere-coder Date: Thu, 3 Sep 2026 14:24:00 +0800 Subject: [PATCH 06/12] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=A1=A3?= =?UTF-8?q?=E6=A1=88=E6=89=AB=E6=8F=8F=E9=9A=94=E7=A6=BB=E4=B8=8E=E5=B2=97?= =?UTF-8?q?=E4=BD=8D=E8=BA=AB=E4=BB=BD=E5=8E=BB=E9=87=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- chrome-extension/background.js | 128 ++++++++++++++---- chrome-extension/boss-content.js | 85 ++++++++++-- chrome-extension/manifest.json | 2 +- .../tests/background-tab-routing.test.cjs | 113 ++++++++++++++-- chrome-extension/tests/manifest-id.test.cjs | 2 +- .../profile-scoped-scan-contract.test.cjs | 42 ++++++ chrome-extension/zhilian-content.js | 115 +++++++++++++--- front/app/boss/page.tsx | 36 ++++- front/app/zhilian/page.tsx | 24 +++- front/lib/scan-profile.test.ts | 20 +++ front/lib/scan-profile.ts | 16 +++ .../controller/BossController.java | 85 ++++++++++-- .../controller/ZhilianController.java | 86 ++++++++++-- .../dto/ChromeJobBatchRequest.java | 1 + .../application/service/BossService.java | 31 +++-- .../service/JobAiAnalysisService.java | 22 ++- .../service/JobAnalysisTaskStore.java | 54 +++++++- .../application/service/ZhilianService.java | 37 +++-- .../worker/dto/JobProgressMessage.java | 13 +- .../V13__unique_boss_profile_job.java | 64 +++++++++ .../BossControllerListOnlyTest.java | 56 ++++++-- ...ZhilianControllerProfileIsolationTest.java | 68 ++++++++++ .../service/BossServiceDedupeTest.java | 14 ++ .../ChromeJobAnalysisQueueServiceTest.java | 15 +- .../service/DatabaseMigrationTest.java | 51 ++++++- .../JobAiAnalysisServiceStatusTest.java | 41 +++++- .../service/JobAnalysisTaskStoreTest.java | 39 +++++- .../ZhilianServiceCrossRunUpsertTest.java | 21 +++ tasks/2026-09-03-profile-scoped-scan-fix.md | 50 +++++++ 29 files changed, 1172 insertions(+), 159 deletions(-) create mode 100644 chrome-extension/tests/profile-scoped-scan-contract.test.cjs create mode 100644 front/lib/scan-profile.test.ts create mode 100644 front/lib/scan-profile.ts create mode 100644 src/main/java/db/migration/V13__unique_boss_profile_job.java create mode 100644 src/test/java/com/getjobs/application/controller/ZhilianControllerProfileIsolationTest.java create mode 100644 tasks/2026-09-03-profile-scoped-scan-fix.md diff --git a/chrome-extension/background.js b/chrome-extension/background.js index d54d0d4..9834cd4 100644 --- a/chrome-extension/background.js +++ b/chrome-extension/background.js @@ -32,13 +32,13 @@ const PLATFORM_SHARED_SCAN_KEYS = { boss: ["__GET_JOBS_BOSS_SHARED_SCAN_TASK__", "__GET_JOBS_BOSS_SHARED_SCAN_CANCEL__"], zhilian: ["__GET_JOBS_ZHILIAN_SHARED_SCAN_TASK__", "__GET_JOBS_ZHILIAN_SHARED_SCAN_CANCEL__"] }; -const BACKGROUND_VERSION = "2026-09-03-keyword-cors-recovery"; +const BACKGROUND_VERSION = "2026-09-03-profile-scoped-scan"; const CONTENT_READY_RETRIES = 12; const CONTENT_READY_INTERVAL_MS = 250; const TAB_LOAD_TIMEOUT_MS = 10000; const DELIVERY_NAVIGATION_TIMEOUT_MS = 15000; -const REQUIRED_BOSS_CONTENT_VERSION = "2026-09-03-keyword-deep-fill"; -const REQUIRED_ZHILIAN_CONTENT_VERSION = "2026-09-03-keyword-deep-fill"; +const REQUIRED_BOSS_CONTENT_VERSION = "2026-09-03-profile-scoped-scan"; +const REQUIRED_ZHILIAN_CONTENT_VERSION = "2026-09-03-profile-scoped-scan"; const LOCAL_API_BASE_URLS = ["http://localhost:6866", "http://127.0.0.1:6866"]; const BOSS_LOCAL_API_MAX_ATTEMPTS = 3; const BOSS_LOCAL_API_TIMEOUT_MS = 30000; @@ -176,6 +176,13 @@ async function forwardPlatformEvent(message, sender) { ...message.payload, platform: message.payload?.platform || platform }; + if (payload.operation === "scan") { + const profileId = normalizeProfileId(payload.profileId); + if (!profileId) return; + const session = await readScanSession(platform); + if (session && normalizeProfileId(session.profileId) !== profileId) return; + payload.profileId = profileId; + } await updateScanSessionFromEvent(platform, sender.tab?.id, payload); await broadcastPlatformEvent(payload, message.pageTabId); } @@ -367,6 +374,9 @@ async function handleZhilianContentNavigation(message, sender) { async function handleBossLocalApiRequest(message) { const endpoint = resolveBossLocalApiEndpoint(message); if (!endpoint.success) return endpoint; + if (isProfileScopedLocalApiOperation(message?.operation) && !normalizeProfileId(message?.body?.profileId)) { + return profileRequiredResponse(); + } const result = await requestLocalApi(endpoint.path, { operation: String(message.operation || ""), @@ -383,6 +393,9 @@ async function handleBossLocalApiRequest(message) { async function handleZhilianLocalApiRequest(message) { const endpoint = resolveZhilianLocalApiEndpoint(message); if (!endpoint.success) return endpoint; + if (isProfileScopedLocalApiOperation(message?.operation) && !normalizeProfileId(message?.body?.profileId)) { + return profileRequiredResponse(); + } const result = await requestLocalApi(endpoint.path, { operation: String(message.operation || ""), @@ -456,7 +469,7 @@ async function requestLocalApi(path, options = {}) { httpStatus: response.status, data, message: data.message || "本地接口拒绝了本次请求", - errorType: "BUSINESS_REJECTED", + errorType: data.errorCode || "BUSINESS_REJECTED", attempt, baseUrl }; @@ -596,6 +609,12 @@ async function handlePageMessage(message, sender) { return { success: false, message: "未知平台" }; } + if (isProfileScopedScanMessage(message.type)) { + const profileId = normalizeProfileId(message.profileId); + if (!profileId) return profileRequiredResponse(); + message = { ...message, profileId }; + } + if (platform === "zhilian" && message.type === "ZHILIAN_SCAN_START" && !normalizeZhilianKeywordList(readZhilianKeywordInput(message)).length) { return { success: false, message: "请至少填写一个搜索关键词" }; } @@ -640,7 +659,7 @@ async function handlePageMessage(message, sender) { try { let scanSession = null; if (isScanStartMessage(message.type)) { - scanSession = await registerScanSession(platform, tab.id, message.runId, pageTabId, message.scanOwnerToken); + scanSession = await registerScanSession(platform, tab.id, message.runId, pageTabId, message.scanOwnerToken, message.profileId); } const response = await chrome.tabs.sendMessage(tab.id, { ...toPlatformContentMessage(message, platform), @@ -735,6 +754,34 @@ function isScanStartMessage(type) { return type === "BOSS_SCAN_START" || type === "ZHILIAN_SCAN_START"; } +function isProfileScopedScanMessage(type) { + return type === "BOSS_SCAN_START" + || type === "BOSS_SCAN_STATUS" + || type === "BOSS_SCAN_STOP" + || type === "ZHILIAN_SCAN_START" + || type === "ZHILIAN_SCAN_STATUS" + || type === "ZHILIAN_SCAN_STOP"; +} + +function isProfileScopedLocalApiOperation(operation) { + return operation === "chrome-jobs" || operation === "chrome-jobs-dedupe"; +} + +function profileRequiredResponse() { + return { + success: false, + httpStatus: 400, + errorCode: "PROFILE_REQUIRED", + errorType: "PROFILE_REQUIRED", + message: "缺少有效档案ID,请刷新本地页面后重新开始扫描" + }; +} + +function normalizeProfileId(value) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : 0; +} + function isBossDeliverMessage(type) { return type === "BOSS_DELIVER_ONE" || type === "BOSS_DELIVER_BATCH"; } @@ -1280,6 +1327,7 @@ async function buildRegisteredNavigationStatus(platform, tabId) { temporaryUnavailable: true, stage: "navigating", runId: session.runId || "", + profileId: normalizeProfileId(session.profileId) || null, message: `${platform === "boss" ? "Boss" : "智联"}扫描页面正在跳转,任务仍在后台继续。` }; } @@ -1318,13 +1366,13 @@ async function sendPassiveStop(tabId, platform, message, pageTabId) { async function resolvePlatformTab(platform, message) { if (isPassiveStatusMessage(message.type) || isPassiveStopMessage(message.type)) { - return await findRegisteredOrRunningScanTab(platform); + return await findRegisteredOrRunningScanTab(platform, message.profileId); } if (isDeliverMessage(platform, message.type)) { return await findDeliveryPlatformTab(platform, platformStartUrl(message)); } if (isScanStartMessage(message.type)) { - return await findScanPlatformTab(platform, platformStartUrl(message), message.runId); + return await findScanPlatformTab(platform, platformStartUrl(message), message.runId, message.profileId); } if (isNoCreatePlatformMessage(message.type)) { return await findPlatformTab(platform); @@ -1332,25 +1380,28 @@ async function resolvePlatformTab(platform, message) { return await findOrCreatePlatformTab(platform, platformStartUrl(message)); } -async function findScanPlatformTab(platform, startUrl, requestedRunId = "") { - const registered = await getRegisteredScanTab(platform, requestedRunId); +async function findScanPlatformTab(platform, startUrl, requestedRunId = "", requestedProfileId = 0) { + const registered = await getRegisteredScanTab(platform, requestedRunId, requestedProfileId); if (registered) return registered; - const running = await findRunningPlatformTab(platform, requestedRunId); + const running = await findRunningPlatformTab(platform, requestedRunId, requestedProfileId); if (running) return running; return await findOrCreatePlatformTab(platform, startUrl); } -async function findRegisteredOrRunningScanTab(platform) { - const registered = await getRegisteredScanTab(platform); +async function findRegisteredOrRunningScanTab(platform, requestedProfileId = 0) { + const registered = await getRegisteredScanTab(platform, "", requestedProfileId); if (registered) return registered; - return await findRunningPlatformTab(platform); + return await findRunningPlatformTab(platform, "", requestedProfileId); } async function findDeliveryPlatformTab(platform, startUrl) { const scanTab = await findRegisteredOrRunningScanTab(platform); - const scanStatus = scanTab?.id ? await probePlatformScanStatus(scanTab.id, platform) : null; + const session = await readScanSession(platform); + const scanStatus = scanTab?.id + ? await probePlatformScanStatus(scanTab.id, platform, session?.profileId) + : null; const scanIsActive = isActiveScanStatus(scanStatus); const excludedTabIds = scanIsActive && scanTab?.id ? [scanTab.id] : []; @@ -1384,30 +1435,36 @@ async function findPlatformTab(platform) { .sort((left, right) => Number(right.lastAccessed || 0) - Number(left.lastAccessed || 0))[0]; } -async function findRunningPlatformTab(platform, requestedRunId = "") { +async function findRunningPlatformTab(platform, requestedRunId = "", requestedProfileId = 0) { + const profileId = normalizeProfileId(requestedProfileId); + if (!profileId) return null; const config = PLATFORM_CONFIG[platform]; const tabs = (await chrome.tabs.query({})) .filter((tab) => isSupportedUrl(tab.url || tab.pendingUrl || "", config)); for (const tab of tabs) { if (!tab.id) continue; - const status = await probePlatformScanStatus(tab.id, platform); + const status = await probePlatformScanStatus(tab.id, platform, profileId); if (isActiveScanStatus(status)) { if (requestedRunId && !scanRunMatches(status?.runId, requestedRunId)) continue; - await registerScanSession(platform, tab.id, status.runId, null, status.scanOwnerToken); + if (normalizeProfileId(status?.profileId) !== profileId) continue; + await registerScanSession(platform, tab.id, status.runId, null, status.scanOwnerToken, profileId); return tab; } } return null; } -async function probePlatformScanStatus(tabId, platform) { +async function probePlatformScanStatus(tabId, platform, requestedProfileId) { + const profileId = normalizeProfileId(requestedProfileId); + if (!profileId) return null; if (!await pingContentScript(tabId)) return null; try { const type = platform === "boss" ? "BOSS_SCAN_STATUS" : "ZHILIAN_SCAN_STATUS_V2"; return await chrome.tabs.sendMessage(tabId, { source: "GET_JOBS_BACKGROUND", - type + type, + profileId }); } catch { return null; @@ -1442,22 +1499,26 @@ async function handleScanOwnerStatus(platform, sender) { isOwner: Boolean(session && tabId && session.tabId === tabId), ownerToken: session?.ownerToken || "", runId: session?.runId || "", + profileId: normalizeProfileId(session?.profileId) || null, tabId: session?.tabId || null }; } -async function registerScanSession(platform, tabId, runId, pageTabId, ownerToken = "") { - if (!platform || !tabId) return null; +async function registerScanSession(platform, tabId, runId, pageTabId, ownerToken = "", requestedProfileId = 0) { + const profileId = normalizeProfileId(requestedProfileId); + if (!platform || !tabId || !profileId) return null; return await mutateScanSessions((sessions) => { const existing = sessions[platform]; + const sameProfile = normalizeProfileId(existing?.profileId) === profileId; const session = { platform, tabId, - runId: String(runId || existing?.runId || ""), - pageTabId: pageTabId || existing?.pageTabId || null, + profileId, + runId: String(runId || (sameProfile ? existing?.runId : "") || ""), + pageTabId: pageTabId || (sameProfile ? existing?.pageTabId : null) || null, ownerToken: String( ownerToken - || (existing?.tabId === tabId ? existing?.ownerToken : "") + || (sameProfile && existing?.tabId === tabId ? existing?.ownerToken : "") || `${platform}-${tabId}-${Date.now()}-${Math.random().toString(16).slice(2)}` ), updatedAt: Date.now() @@ -1471,6 +1532,10 @@ async function readScanSession(platform) { const sessions = await readScanSessions(); const session = sessions[platform]; if (!session) return null; + if (!normalizeProfileId(session.profileId)) { + await cleanupPlatformScanState(platform, session.tabId); + return null; + } if (Date.now() - Number(session.updatedAt || 0) > SCAN_SESSION_TTL_MS) { await clearScanSession(platform, session.tabId); return null; @@ -1488,9 +1553,14 @@ async function readScanSessions() { } } -async function getRegisteredScanTab(platform, requestedRunId = "") { +async function getRegisteredScanTab(platform, requestedRunId = "", requestedProfileId = 0) { const session = await readScanSession(platform); if (!session?.tabId) return null; + const profileId = normalizeProfileId(requestedProfileId); + if (profileId && normalizeProfileId(session.profileId) !== profileId) { + await cleanupPlatformScanState(platform, session.tabId); + return null; + } if (requestedRunId && !scanRunMatches(session.runId, requestedRunId)) { await cleanupPlatformScanState(platform, session.tabId); return null; @@ -1539,13 +1609,15 @@ async function mutateScanSessions(mutator) { async function updateScanSessionFromEvent(platform, tabId, payload) { if (!tabId || payload?.operation !== "scan") return; + const profileId = normalizeProfileId(payload.profileId); + if (!profileId) return; + const session = await readScanSession(platform); + if (!session || session.tabId !== tabId || normalizeProfileId(session.profileId) !== profileId) return; if (["complete", "stopped", "error"].includes(String(payload.stage || ""))) { await clearScanSession(platform, tabId); return; } - const session = await readScanSession(platform); - if (!session || session.tabId !== tabId) return; - await registerScanSession(platform, tabId, payload.runId || session.runId, session.pageTabId, session.ownerToken); + await registerScanSession(platform, tabId, payload.runId || session.runId, session.pageTabId, session.ownerToken, profileId); } async function broadcastPlatformEvent(payload, preferredPageTabId = null) { diff --git a/chrome-extension/boss-content.js b/chrome-extension/boss-content.js index 19b4a0c..e9eb1b0 100644 --- a/chrome-extension/boss-content.js +++ b/chrome-extension/boss-content.js @@ -1,5 +1,5 @@ (function () { - const EXTENSION_VERSION = "2026-09-03-keyword-deep-fill"; + const EXTENSION_VERSION = "2026-09-03-profile-scoped-scan"; const CONTENT_INSTANCE_ID = `${Date.now()}-${Math.random().toString(16).slice(2)}`; window.__GET_JOBS_BOSS_CONTENT__ = true; window.__GET_JOBS_BOSS_CONTENT_VERSION__ = EXTENSION_VERSION; @@ -42,22 +42,34 @@ return; } if (message?.type === "BOSS_SCAN_STOP") { + const profileId = normalizeProfileId(message?.profileId); + if (!profileId) { + sendResponse({ success: false, errorCode: "PROFILE_REQUIRED", message: "Boss扫描停止请求缺少档案 ID" }); + return; + } + const storedProfileId = normalizeProfileId(readStoredScanTask()?.profileId); + if (storedProfileId && storedProfileId !== profileId) { + clearStoredScanTask(); + writeScanStatus({ isRunning: false, stopRequested: false, stage: "idle", paused: false, resumable: false, profileId, runId: "", message: "已清除其他档案的 Boss 扫描断点" }); + sendResponse({ success: true, message: "已清除其他档案的 Boss 扫描断点", profileId }); + return; + } const runId = normalizeScanRunId(message?.runId || activeScanRunId || readStoredScanTask()?.runId); activeScanRunId = runId || activeScanRunId; stopRequested = true; storeStopRequested(runId); clearStoredScanTask(); - writeScanStatus({ isRunning: false, stopRequested: true, stage: "stopped", message: "已请求停止Boss扫描", runId }); + writeScanStatus({ isRunning: false, stopRequested: true, stage: "stopped", profileId, message: "已请求停止Boss扫描", runId }); postProgress(message, "warning", "Boss Chrome扫描停止请求已接收,正在中断当前任务。", { operation: "scan", stage: "stopping", runId }); - sendResponse({ success: true, message: "已请求停止Boss扫描" }); + sendResponse({ success: true, message: "已请求停止Boss扫描", profileId }); return; } if (message?.type === "BOSS_SCAN_STATUS") { - handleScanStatusMessage(sendResponse); + handleScanStatusMessage(message, sendResponse); return true; } if (message?.type === "BOSS_PAGE_STATUS") { @@ -274,6 +286,7 @@ }; } const data = await callBossLocalApi("chrome-jobs", { + profileId: normalizeProfileId(message?.profileId), runId, keyword: result.keyword, collectionMode: "LIST_ONLY", @@ -456,6 +469,7 @@ } const data = await callBossLocalApi("chrome-jobs", { + profileId: normalizeProfileId(message?.profileId), runId, keyword, collectionMode: "LIST_ONLY", @@ -652,9 +666,21 @@ }; } - async function handleScanStatusMessage(sendResponse) { - const task = await readStoredScanTaskFromAnyStorage(); - const status = readScanStatus(); + async function handleScanStatusMessage(message, sendResponse) { + const profileId = normalizeProfileId(message?.profileId); + if (!profileId) { + sendResponse({ success: false, errorCode: "PROFILE_REQUIRED", message: "Boss扫描状态请求缺少档案 ID", isRunning: false, hasStoredTask: false }); + return; + } + let task = await readStoredScanTaskFromAnyStorage(); + let status = readScanStatus(); + if ((task && normalizeProfileId(task.profileId) !== profileId) + || (normalizeProfileId(status.profileId) && normalizeProfileId(status.profileId) !== profileId)) { + clearStoredScanTask(); + writeScanStatus({ isRunning: false, stopRequested: false, stage: "idle", paused: false, resumable: false, profileId, runId: "", message: "Boss档案已变化,旧扫描断点已清理" }); + task = null; + status = readScanStatus(); + } const paused = Boolean(status.paused || (status.stage === "blocked" && status.resumable)); const hasFreshTask = Boolean(task && isFreshScanTask(task)); const hasResumableTask = Boolean(hasFreshTask || paused); @@ -664,6 +690,10 @@ isRunning: false, stopRequested: false, stage: "idle", + paused: false, + resumable: false, + profileId, + runId: "", message: "Boss旧扫描任务已清理" }); } @@ -676,11 +706,17 @@ resumable: Boolean(nextStatus.resumable || hasResumableTask), runId: nextStatus.runId || task?.runId || "", scanOwnerToken: task?.scanOwnerToken || "", + profileId, hasStoredTask: hasResumableTask }); } async function handleScanStartMessage(message, sendResponse) { + const profileId = normalizeProfileId(message?.profileId); + if (!profileId) { + sendResponse({ success: false, errorCode: "PROFILE_REQUIRED", message: "Boss扫描启动请求缺少档案 ID" }); + return; + } const existingTask = await readStoredScanTaskFromAnyStorage(); const status = readScanStatus(); const incomingTask = normalizeScanTask(message); @@ -690,15 +726,18 @@ && existingTask.keywordCursorKey !== incomingTask.keywordCursorKey ); const sameRun = isSameScanRun(existingTask, incomingTask); - const shouldDiscardExisting = Boolean(existingTask && (!sameRun || configChanged)); + const profileChanged = Boolean(existingTask && normalizeProfileId(existingTask.profileId) !== profileId); + const shouldDiscardExisting = Boolean(existingTask && (profileChanged || !sameRun || configChanged)); if (shouldDiscardExisting) { clearStoredScanTask(); - postProgress(message, "warning", configChanged + postProgress(message, "warning", profileChanged + ? "Boss档案已变化,其他档案或旧版扫描断点已放弃,将从第一个关键词重新开始。" + : configChanged ? "Boss扫描配置已变化,旧断点已放弃,将按新配置重新开始。" : "Boss检测到新的扫描任务,旧断点已清理,将按新关键词重新开始。", { operation: "scan", stage: "checkpointReset", - diagnosticType: configChanged ? "CONFIG_CHANGED" : "NEW_RUN_DISCARDED_CHECKPOINT", + diagnosticType: profileChanged ? "PROFILE_CHANGED" : configChanged ? "CONFIG_CHANGED" : "NEW_RUN_DISCARDED_CHECKPOINT", previousRunId: existingTask?.runId || "", runId: incomingTask.runId || "" }); @@ -723,7 +762,7 @@ updatedAt: Date.now() }); }); - sendResponse({ success: true, message: "Boss Chrome扫描任务已恢复。", resumed: true, runId: activeScanRunId }); + sendResponse({ success: true, message: "Boss Chrome扫描任务已恢复。", resumed: true, runId: activeScanRunId, profileId }); return; } @@ -733,7 +772,7 @@ stopRequestedRunId = ""; clearStopRequested(); startScan({ ...incomingTask, runId }); - sendResponse({ success: true, message: "Boss Chrome扫描任务已启动。", runId }); + sendResponse({ success: true, message: "Boss Chrome扫描任务已启动。", runId, profileId }); } function startScan(message) { @@ -744,7 +783,10 @@ isRunning: true, stopRequested: false, stage: "received", + paused: false, + resumable: false, message: "Boss Chrome扫描任务已接收", + profileId: task.profileId, runId: task.runId, startedAt: task.startedAt, updatedAt: Date.now() @@ -759,7 +801,7 @@ } if (keywords.length) { const startIndex = normalizeKeywordIndex(task.currentIndex, keywords.length); - postProgress(task, "info", `Boss关键词历史:本次从第 ${startIndex + 1}/${keywords.length} 个关键词继续:${keywords[startIndex]}`, { + postProgress(task, "info", `Boss本档案关键词进度:本次从第 ${startIndex + 1}/${keywords.length} 个关键词继续:${keywords[startIndex]}`, { operation: "scan", stage: "keywordCursor", keyword: keywords[startIndex], @@ -1738,6 +1780,7 @@ try { const data = await callBossLocalApi("chrome-jobs-dedupe", { + profileId: normalizeProfileId(message?.profileId), runId: message?.runId, keyword: baseMeta.keyword, jobs: list.map(normalizeJobForDedupe) @@ -2589,6 +2632,7 @@ batchAttempt++; try { data = await callBossLocalApi("chrome-jobs", { + profileId: normalizeProfileId(message?.profileId), runId, keyword: options.keyword, jobs: batch, @@ -2599,6 +2643,10 @@ }); break; // 成功,跳出重试循环 } catch (error) { + if (["PROFILE_REQUIRED", "PROFILE_CHANGED"].includes(String(error?.code || ""))) { + clearStoredScanTask(); + throw error; + } if (batchAttempt < maxBatchAttempts) { postProgress(message, "warning", `Boss岗位提交第 ${index + 1}/${batches.length} 批失败,正在重试(${batchAttempt}/${maxBatchAttempts}):${safeErrorMessage(error)}`, { ...baseMeta, @@ -3193,7 +3241,7 @@ }); if (!response?.success) { const error = new Error(response?.message || "Boss本地服务请求失败"); - error.code = response?.errorType || "LOCAL_API_ERROR"; + error.code = response?.data?.errorCode || response?.errorType || "LOCAL_API_ERROR"; error.httpStatus = response?.httpStatus; throw error; } @@ -3225,6 +3273,7 @@ pageTabId: message.pageTabId, payload: { platform: "boss", + profileId: normalizeProfileId(message?.profileId), type, message: text, timestamp: Date.now(), @@ -3243,6 +3292,7 @@ const cursorState = resolveKeywordCursor(message, cursorKeywords, hasExplicitIndex); return { ...message, + profileId: normalizeProfileId(message?.profileId), config: { ...config, keywords, searchJobLimit }, keywords, cursorKeywords, @@ -3431,6 +3481,7 @@ const searchJobLimit = normalizeSearchJobLimit(message?.searchJobLimit ?? config.searchJobLimit); return stableKey({ platform: "boss", + profileId: normalizeProfileId(message?.profileId), keywords: uniqueStrings(keywords || message?.cursorKeywords || []), cityCode: normalizedList(config.cityCode), jobType: compact(config.jobType || ""), @@ -3515,6 +3566,7 @@ } function isFreshScanTask(task) { + if (!normalizeProfileId(task?.profileId)) return false; if (SCAN_SUPPORT.isFreshTask) { return SCAN_SUPPORT.isFreshTask(task, Date.now(), SCAN_TASK_TTL_MS); } @@ -3683,6 +3735,11 @@ return String(value || "").trim(); } + function normalizeProfileId(value) { + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null; + } + function writeScanStatus(nextStatus) { const previous = readScanStatus(); const merged = typeof SCAN_SUPPORT.mergeScanStatus === "function" diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json index 12726ce..cb76d5b 100644 --- a/chrome-extension/manifest.json +++ b/chrome-extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "投递牛马 Chrome Bridge", - "version": "1.4.1", + "version": "1.4.2", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzzdIlNVOv76Y/cSWrjD5Tg2Vlsha8yWHzsn46PBsg724/2dftOUzIIr2n70VRaRgGwEd8FjO/Y768Ori443zF4pQpWvuxXxm05YO25ILQ/+aJLmUycAEdWbkdhcagr4YXnXJdYlSCGSAToSQBjk+owQOdlBLQn5wofPoshrqayoJjRQ5aAUj1SuSlnNv9iimle8GMA1IaA1l5rw6K/chfcgwMTg6HxRAIoludt5JGbIBryi2Lu1hOJRMaDnL7A57ofBnn3qx3H2HIGWGkkTW9EMkls0XMXwx8+mJVIj5HSYl0EeuCvEoTa1W3i1CbOf3kY2yCPKS3Qz3lOvJiwJ4ZQIDAQAB", "description": "Use the signed-in Chrome tabs to scan jobs and confirm deliveries for 投递牛马.", "icons": { diff --git a/chrome-extension/tests/background-tab-routing.test.cjs b/chrome-extension/tests/background-tab-routing.test.cjs index 8cb2b1b..066f1cf 100644 --- a/chrome-extension/tests/background-tab-routing.test.cjs +++ b/chrome-extension/tests/background-tab-routing.test.cjs @@ -182,6 +182,7 @@ test("rejects empty Zhilian keywords before creating or starting a scan", async const response = await context.handlePageMessage({ type: "ZHILIAN_SCAN_START", platform: "zhilian", + profileId: 4, config: { keywords: "[]" } }, { tab: { id: 20, url: "http://localhost:6866/zhilian" } }); @@ -189,6 +190,25 @@ test("rejects empty Zhilian keywords before creating or starting a scan", async assert.equal(response.message, "请至少填写一个搜索关键词"); }); +test("requires a profile for every scan lifecycle request before touching tabs", async () => { + const { context, tabList } = loadBackground({ tabs: [] }); + const sender = { tab: { id: 20, url: "http://localhost:6866/boss" } }; + + for (const [type, platform] of [ + ["BOSS_SCAN_START", "boss"], + ["BOSS_SCAN_STATUS", "boss"], + ["BOSS_SCAN_STOP", "boss"], + ["ZHILIAN_SCAN_START", "zhilian"], + ["ZHILIAN_SCAN_STATUS", "zhilian"], + ["ZHILIAN_SCAN_STOP", "zhilian"] + ]) { + const response = await context.handlePageMessage({ type, platform, config: { keywords: ["Java"] } }, sender); + assert.equal(response.success, false); + assert.equal(response.errorCode, "PROFILE_REQUIRED"); + } + assert.equal(tabList.length, 0); +}); + test("injects all Zhilian dependencies when the content script is missing", async () => { const { context, executedScripts } = loadBackground({ tabs: [{ id: 1, windowId: 1, url: "https://www.zhaopin.com/", status: "complete" }], @@ -274,7 +294,7 @@ test("allows Zhilian job submission through the fixed local API route", async () source: "GET_JOBS_ZHILIAN_CONTENT", type: "ZHILIAN_LOCAL_API", operation: "chrome-jobs", - body: { runId: "run-1", keyword: "Java", jobs: [{ title: "Java工程师" }] } + body: { profileId: 4, runId: "run-1", keyword: "Java", jobs: [{ title: "Java工程师" }] } }, { tab: { id: 8, url: "https://www.zhaopin.com/jobdetail/demo.htm" } }); @@ -343,6 +363,24 @@ test("treats HTTP 200 business rejection as a failed local API request", async ( assert.equal(result.message, "状态已变化"); }); +test("preserves backend profile errors and rejects unscoped job submission locally", async () => { + let fetchCalls = 0; + const { context } = loadBackground({ + tabs: [], + fetchImpl: async () => { + fetchCalls += 1; + return jsonResponse({ success: false, errorCode: "PROFILE_CHANGED", message: "档案已切换" }, { ok: false, status: 409 }); + } + }); + + const missing = await context.handleBossLocalApiRequest({ operation: "chrome-jobs", body: { jobs: [] } }); + const changed = await context.handleBossLocalApiRequest({ operation: "chrome-jobs", body: { profileId: 4, jobs: [] } }); + + assert.equal(missing.errorCode, "PROFILE_REQUIRED"); + assert.equal(fetchCalls, 1); + assert.equal(changed.errorType, "PROFILE_CHANGED"); +}); + test("records an empty Boss chat-page response as unknown instead of confirmed", async () => { const requests = []; const { context } = loadBackground({ @@ -424,8 +462,8 @@ test("keeps Boss and Zhilian scan ownership when both start together", async () const { context, storage } = loadBackground({ tabs: [] }); await Promise.all([ - context.registerScanSession("boss", 1, "boss-run", 10), - context.registerScanSession("zhilian", 2, "zhilian-run", 10) + context.registerScanSession("boss", 1, "boss-run", 10, "", 4), + context.registerScanSession("zhilian", 2, "zhilian-run", 10, "", 4) ]); const sessions = storage.__GET_JOBS_PLATFORM_SCAN_SESSIONS__; @@ -433,6 +471,49 @@ test("keeps Boss and Zhilian scan ownership when both start together", async () assert.equal(sessions.zhilian.tabId, 2); }); +test("profile switch and legacy sessions invalidate shared checkpoints", async () => { + const { context, storage } = loadBackground({ + tabs: [ + { id: 1, windowId: 1, url: "https://www.zhipin.com/job_detail/old.html", status: "complete", lastAccessed: 10 }, + { id: 2, windowId: 1, url: "https://www.zhipin.com/web/geek/job", status: "complete", lastAccessed: 20 } + ], + statuses: { + 1: { success: true, isRunning: true, hasStoredTask: true, stage: "details", runId: "old", profileId: 3 } + } + }); + await context.registerScanSession("boss", 1, "old", 10, "", 3); + storage.__GET_JOBS_BOSS_SHARED_SCAN_TASK__ = { runId: "old", profileId: 3 }; + + const selected = await context.findScanPlatformTab("boss", "https://www.zhipin.com/web/geek/job", "new", 4); + + assert.equal(selected.id, 2); + assert.equal(storage.__GET_JOBS_PLATFORM_SCAN_SESSIONS__.boss, undefined); + assert.equal(storage.__GET_JOBS_BOSS_SHARED_SCAN_TASK__, undefined); + + storage.__GET_JOBS_PLATFORM_SCAN_SESSIONS__ = { + boss: { platform: "boss", tabId: 1, runId: "legacy", updatedAt: Date.now() } + }; + storage.__GET_JOBS_BOSS_SHARED_SCAN_TASK__ = { runId: "legacy" }; + assert.equal(await context.readScanSession("boss"), null); + assert.equal(storage.__GET_JOBS_BOSS_SHARED_SCAN_TASK__, undefined); +}); + +test("drops delayed scan events from another profile", async () => { + const { context, sentMessages } = loadBackground({ + tabs: [ + { id: 1, windowId: 1, url: "https://www.zhipin.com/web/geek/job", status: "complete" }, + { id: 10, windowId: 1, url: "http://localhost:6866/boss", status: "complete" } + ] + }); + await context.registerScanSession("boss", 1, "current", 10, "", 4); + + await context.forwardPlatformEvent({ + payload: { platform: "boss", operation: "scan", stage: "details", profileId: 3 } + }, { tab: { id: 1, url: "https://www.zhipin.com/web/geek/job" } }); + + assert.equal(sentMessages.filter((entry) => entry.message.type === "GET_JOBS_EXTENSION_EVENT").length, 0); +}); + test("uses a separate Boss tab for delivery while scanning", async () => { const { context } = loadBackground({ tabs: [ @@ -440,11 +521,11 @@ test("uses a separate Boss tab for delivery while scanning", async () => { { id: 2, windowId: 1, url: "https://www.zhipin.com/", status: "complete", lastAccessed: 20 } ], statuses: { - 1: { success: true, isRunning: true, hasStoredTask: true, stage: "details", runId: "boss-run" }, + 1: { success: true, isRunning: true, hasStoredTask: true, stage: "details", runId: "boss-run", profileId: 4 }, 2: { success: true, isRunning: false, hasStoredTask: false, stage: "idle" } } }); - await context.registerScanSession("boss", 1, "boss-run", 10); + await context.registerScanSession("boss", 1, "boss-run", 10, "", 4); const deliveryTab = await context.findDeliveryPlatformTab("boss", "https://www.zhipin.com/job_detail/demo.html"); @@ -458,9 +539,9 @@ test("status lookup keeps using the registered scan tab after another tab is cli { id: 2, windowId: 1, url: "https://www.zhipin.com/job_detail/other.html", status: "complete", lastAccessed: 999 } ] }); - await context.registerScanSession("boss", 1, "boss-run", 10); + await context.registerScanSession("boss", 1, "boss-run", 10, "", 4); - const scanTab = await context.findRegisteredOrRunningScanTab("boss"); + const scanTab = await context.findRegisteredOrRunningScanTab("boss", 4); const owner = await context.handleScanOwnerStatus("boss", { tab: { id: 2 } }); assert.equal(scanTab.id, 1); @@ -474,17 +555,18 @@ test("new Boss scan run does not keep using a registered stale scan tab", async { id: 2, windowId: 1, url: "https://www.zhipin.com/web/geek/job", status: "complete", lastAccessed: 20 } ], statuses: { - 1: { success: true, isRunning: true, hasStoredTask: true, stage: "details", runId: "boss-old-run" }, + 1: { success: true, isRunning: true, hasStoredTask: true, stage: "details", runId: "boss-old-run", profileId: 4 }, 2: { success: true, isRunning: false, hasStoredTask: false, stage: "idle" } } }); - await context.registerScanSession("boss", 1, "boss-old-run", 10); - storage.__GET_JOBS_BOSS_SHARED_SCAN_TASK__ = { runId: "boss-old-run" }; + await context.registerScanSession("boss", 1, "boss-old-run", 10, "", 4); + storage.__GET_JOBS_BOSS_SHARED_SCAN_TASK__ = { runId: "boss-old-run", profileId: 4 }; const scanTab = await context.findScanPlatformTab( "boss", "https://www.zhipin.com/web/geek/job?city=101280600&query=Java", - "boss-new-run" + "boss-new-run", + 4 ); assert.equal(scanTab.id, 2); @@ -549,11 +631,11 @@ test("Boss stop clears registered scan session and shared checkpoint", async () { id: 1, windowId: 1, url: "https://www.zhipin.com/job_detail/old.html", status: "complete" } ] }); - await context.registerScanSession("boss", 1, "boss-run", 10); - storage.__GET_JOBS_BOSS_SHARED_SCAN_TASK__ = { runId: "boss-run" }; + await context.registerScanSession("boss", 1, "boss-run", 10, "", 4); + storage.__GET_JOBS_BOSS_SHARED_SCAN_TASK__ = { runId: "boss-run", profileId: 4 }; storage.__GET_JOBS_BOSS_SHARED_SCAN_CANCEL__ = { runId: "boss-run", requested: true }; - const response = await context.sendPassiveStop(1, "boss", { type: "BOSS_SCAN_STOP", runId: "boss-run" }, 10); + const response = await context.sendPassiveStop(1, "boss", { type: "BOSS_SCAN_STOP", runId: "boss-run", profileId: 4 }, 10); assert.equal(response.success, true); assert.equal(storage.__GET_JOBS_PLATFORM_SCAN_SESSIONS__.boss, undefined); @@ -585,7 +667,7 @@ test("reports navigation as running while the registered scan content script rel { id: 1, windowId: 1, url: "https://www.zhipin.com/job_detail/demo.html", status: "loading" } ] }); - await context.registerScanSession("boss", 1, "boss-run", 10); + await context.registerScanSession("boss", 1, "boss-run", 10, "", 4); const status = await context.buildRegisteredNavigationStatus("boss", 1); @@ -593,6 +675,7 @@ test("reports navigation as running while the registered scan content script rel assert.equal(status.hasStoredTask, true); assert.equal(status.stage, "navigating"); assert.equal(status.runId, "boss-run"); + assert.equal(status.profileId, 4); }); test("runs only the fixed Boss search API request in the page MAIN world", async () => { diff --git a/chrome-extension/tests/manifest-id.test.cjs b/chrome-extension/tests/manifest-id.test.cjs index d486eab..caebea8 100644 --- a/chrome-extension/tests/manifest-id.test.cjs +++ b/chrome-extension/tests/manifest-id.test.cjs @@ -17,7 +17,7 @@ function extensionIdFromKey(key) { test('manifest public key derives the backend allowlisted extension id', () => { const manifestPath = path.join(__dirname, '..', 'manifest.json'); const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); - assert.equal(manifest.version, '1.4.1'); + assert.equal(manifest.version, '1.4.2'); assert.equal(extensionIdFromKey(manifest.key), EXPECTED_EXTENSION_ID); const publicKey = crypto.createPublicKey({ diff --git a/chrome-extension/tests/profile-scoped-scan-contract.test.cjs b/chrome-extension/tests/profile-scoped-scan-contract.test.cjs new file mode 100644 index 0000000..a5840dc --- /dev/null +++ b/chrome-extension/tests/profile-scoped-scan-contract.test.cjs @@ -0,0 +1,42 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); + +const extensionDir = path.resolve(__dirname, ".."); + +function source(file) { + return fs.readFileSync(path.join(extensionDir, file), "utf8"); +} + +test("extension release and both content scripts use the profile-scoped contract", () => { + const manifest = JSON.parse(source("manifest.json")); + const background = source("background.js"); + const boss = source("boss-content.js"); + const zhilian = source("zhilian-content.js"); + + assert.equal(manifest.version, "1.4.2"); + assert.match(background, /BACKGROUND_VERSION = "2026-09-03-profile-scoped-scan"/); + assert.match(boss, /EXTENSION_VERSION = "2026-09-03-profile-scoped-scan"/); + assert.match(zhilian, /EXTENSION_VERSION = "2026-09-03-profile-scoped-scan"/); +}); + +test("both platforms bind cursors, dedupe, submissions and progress to profileId", () => { + for (const file of ["boss-content.js", "zhilian-content.js"]) { + const content = source(file); + assert.match(content, /function buildKeywordCursorKey[\s\S]*?profileId: normalizeProfileId\(message\?\.profileId\)/); + assert.match(content, /chrome-jobs-dedupe[\s\S]*?profileId: normalizeProfileId/); + assert.match(content, /chrome-jobs[\s\S]*?profileId: normalizeProfileId/); + assert.match(content, /function postProgress[\s\S]*?profileId: normalizeProfileId\(message\?\.profileId\)/); + assert.match(content, /if \(!normalizeProfileId\(task\?\.profileId\)\) return false/); + } +}); + +test("profile contract failures are terminal rather than resumable", () => { + const boss = source("boss-content.js"); + const zhilian = source("zhilian-content.js"); + + assert.match(boss, /\["PROFILE_REQUIRED", "PROFILE_CHANGED"\][\s\S]*?clearStoredScanTask\(\)/); + assert.match(zhilian, /\["PROFILE_REQUIRED", "PROFILE_CHANGED"\][\s\S]*?clearStoredScanTask\(\)/); + assert.match(zhilian, /resumable: false/); +}); diff --git a/chrome-extension/zhilian-content.js b/chrome-extension/zhilian-content.js index d3af29f..8201b8b 100644 --- a/chrome-extension/zhilian-content.js +++ b/chrome-extension/zhilian-content.js @@ -1,5 +1,5 @@ (function () { - const EXTENSION_VERSION = "2026-09-03-keyword-deep-fill"; + const EXTENSION_VERSION = "2026-09-03-profile-scoped-scan"; const CONTENT_INSTANCE_ID = `${Date.now()}-${Math.random().toString(16).slice(2)}`; window.__GET_JOBS_ZHILIAN_CONTENT__ = true; window.__GET_JOBS_ZHILIAN_CONTENT_VERSION__ = EXTENSION_VERSION; @@ -98,7 +98,7 @@ return true; } if (messageType === "ZHILIAN_SCAN_STATUS") { - handleScanStatusMessage(sendResponse); + handleScanStatusMessage(message, sendResponse); return true; } if (messageType === "ZHILIAN_SCAN_START") { @@ -130,6 +130,15 @@ } async function handleScanStopMessage(message) { + const profileId = normalizeProfileId(message?.profileId); + if (!profileId) return { success: false, errorCode: "PROFILE_REQUIRED", message: "智联扫描停止请求缺少档案 ID" }; + const storedTask = await readStoredScanTaskFromAnyStorage(); + const storedProfileId = normalizeProfileId(storedTask?.profileId); + if (storedProfileId && storedProfileId !== profileId) { + clearStoredScanTask(); + writeScanStatus({ isRunning: false, stopRequested: false, stage: "idle", paused: false, resumable: false, profileId, runId: "", message: "已清除其他档案的智联扫描断点" }); + return { success: true, message: "已清除其他档案的智联扫描断点", profileId }; + } stopRequested = true; await storeStopRequested(message?.runId); clearStoredScanTask(); @@ -137,6 +146,7 @@ isRunning: false, stopRequested: true, stage: "stopped", + profileId, message: "已请求停止智联扫描", runId: message?.runId || readScanStatus().runId || "", updatedAt: Date.now() @@ -145,31 +155,37 @@ operation: "scan", stage: "stopping" }); - return { success: true, message: "已请求停止智联扫描" }; + return { success: true, message: "已请求停止智联扫描", profileId }; } async function handleScanStartMessage(message) { + const profileId = normalizeProfileId(message?.profileId); + if (!profileId) return { success: false, errorCode: "PROFILE_REQUIRED", message: "智联扫描启动请求缺少档案 ID" }; if (!scanKeywords(message).length) { return { success: false, message: "请至少填写一个搜索关键词" }; } const existingTask = await readStoredScanTaskFromAnyStorage(); const status = readScanStatus(); const incomingTask = normalizeScanTask(message); + const profileChanged = Boolean(existingTask && normalizeProfileId(existingTask.profileId) !== profileId); const configChanged = Boolean( existingTask?.keywordCursorKey && incomingTask.keywordCursorKey && existingTask.keywordCursorKey !== incomingTask.keywordCursorKey ); - if (configChanged) { + if (profileChanged || configChanged) { clearStoredScanTask(); - postProgress(message, "warning", "智联扫描配置已变化,旧断点已放弃,将按新配置重新开始。", { + postProgress(message, "warning", profileChanged + ? "智联档案已变化,其他档案或旧版扫描断点已放弃,将从第一个关键词重新开始。" + : "智联扫描配置已变化,旧断点已放弃,将按新配置重新开始。", { operation: "scan", stage: "checkpointReset", - diagnosticType: "CONFIG_CHANGED" + diagnosticType: profileChanged ? "PROFILE_CHANGED" : "CONFIG_CHANGED" }); } const canResumeExisting = Boolean( - !configChanged + !profileChanged + && !configChanged && existingTask && !existingTask.completed && (isResumableScanTask(existingTask) || status.resumable || status.stage === "blocked") @@ -188,7 +204,7 @@ updatedAt: Date.now() }); }); - return { success: true, message: "智联 Chrome扫描任务已恢复。", resumed: true, runId: existingTask.runId }; + return { success: true, message: "智联 Chrome扫描任务已恢复。", resumed: true, runId: existingTask.runId, profileId }; } startScan(incomingTask).catch((error) => { @@ -197,10 +213,15 @@ stage: "error" }); }); - return { success: true, message: "智联 Chrome扫描任务已启动。" }; + return { success: true, message: "智联 Chrome扫描任务已启动。", profileId }; } - async function handleScanStatusMessage(sendResponse) { + async function handleScanStatusMessage(message, sendResponse) { + const profileId = normalizeProfileId(message?.profileId); + if (!profileId) { + sendResponse({ success: false, errorCode: "PROFILE_REQUIRED", message: "智联扫描状态请求缺少档案 ID", isRunning: false, hasStoredTask: false }); + return; + } if (await hasStopRequested()) { stopRequested = true; clearStoredScanTask(); @@ -211,8 +232,15 @@ message: "智联扫描已取消" }); } - const task = await readStoredScanTaskFromAnyStorage(); - const status = readScanStatus(); + let task = await readStoredScanTaskFromAnyStorage(); + let status = readScanStatus(); + if ((task && normalizeProfileId(task.profileId) !== profileId) + || (normalizeProfileId(status.profileId) && normalizeProfileId(status.profileId) !== profileId)) { + clearStoredScanTask(); + writeScanStatus({ isRunning: false, stopRequested: false, stage: "idle", paused: false, resumable: false, profileId, runId: "", message: "智联档案已变化,旧扫描断点已清理" }); + task = null; + status = readScanStatus(); + } const paused = Boolean(status.paused || (status.stage === "blocked" && status.resumable)); const hasFreshTask = Boolean(task && isFreshScanTask(task)); const hasResumableTask = Boolean(hasFreshTask || paused); @@ -222,6 +250,10 @@ isRunning: false, stopRequested: false, stage: "idle", + paused: false, + resumable: false, + profileId, + runId: "", message: "智联旧扫描任务已清理" }); } @@ -234,6 +266,7 @@ resumable: Boolean(nextStatus.resumable || hasResumableTask), runId: nextStatus.runId || task?.runId || "", scanOwnerToken: task?.scanOwnerToken || "", + profileId, hasStoredTask: hasResumableTask }); } @@ -245,7 +278,10 @@ isRunning: true, stopRequested: false, stage: "received", + paused: false, + resumable: false, message: "智联 Chrome扫描任务已接收", + profileId: task.profileId, runId: task.runId, startedAt: task.startedAt, updatedAt: Date.now() @@ -260,7 +296,7 @@ } if (keywords.length) { const startIndex = normalizeKeywordIndex(task.currentIndex, keywords.length); - postProgress(task, "info", `智联关键词历史:本次从第 ${startIndex + 1}/${keywords.length} 个关键词继续:${keywords[startIndex]}`, { + postProgress(task, "info", `智联本档案关键词进度:本次从第 ${startIndex + 1}/${keywords.length} 个关键词继续:${keywords[startIndex]}`, { operation: "scan", stage: "keywordCursor", keyword: keywords[startIndex], @@ -915,7 +951,7 @@ const list = Array.isArray(jobs) ? jobs : []; if (!list.length) return { jobs: [], duplicateCount: 0 }; const data = await requestZhilianLocalApi("chrome-jobs-dedupe", { - body: { runId: task?.runId, keyword, jobs: list }, + body: { profileId: normalizeProfileId(task?.profileId), runId: task?.runId, keyword, jobs: list }, pageTabId: task?.pageTabId }); const items = Array.isArray(data.items) ? data.items : []; @@ -1328,7 +1364,7 @@ let data; try { data = await requestZhilianLocalApi("chrome-jobs", { - body: { runId, keyword, jobs }, + body: { profileId: normalizeProfileId(message?.profileId), runId, keyword, jobs }, pageTabId: message.pageTabId }); if (!data.success) { @@ -1395,6 +1431,44 @@ async function pauseZhilianSubmission(message, jobs, totalSaved, error, baseMeta) { const reason = error?.message || String(error || "未知错误"); const errorType = error?.errorType || "LOCAL_API_ERROR"; + if (["PROFILE_REQUIRED", "PROFILE_CHANGED"].includes(errorType)) { + const failureMessage = `智联岗位提交已终止:${reason}`; + clearStoredScanTask(); + writeScanStatus({ + isRunning: false, + stopRequested: false, + stage: "error", + paused: false, + profileId: normalizeProfileId(message?.profileId), + message: failureMessage, + runId: message.runId, + resumable: false, + diagnosticType: errorType, + errorType, + httpStatus: error?.httpStatus, + startedAt: message.startedAt, + updatedAt: Date.now() + }); + postProgress(message, "error", failureMessage, { + ...baseMeta, + stage: "error", + paused: false, + resumable: false, + errorType, + httpStatus: error?.httpStatus + }); + return { + success: false, + totalSaved, + totalRead: Number(message.totalRead || 0), + totalReceived: Number(message.totalReceived || 0), + totalInsufficient: Number(message.totalInsufficient || 0), + paused: false, + resumable: false, + message: failureMessage, + errorType + }; + } const failureMessage = `智联岗位提交失败,扫描断点已保留:${reason}`; const pausedAt = Date.now(); await storeScanTask({ @@ -1737,7 +1811,7 @@ } if (!response?.success) { const error = new Error(response?.message || "智联本地服务请求失败"); - error.errorType = response?.errorType || "LOCAL_API_ERROR"; + error.errorType = response?.data?.errorCode || response?.errorType || "LOCAL_API_ERROR"; error.httpStatus = response?.httpStatus; throw error; } @@ -1750,6 +1824,7 @@ pageTabId: message.pageTabId, payload: { platform: "zhilian", + profileId: normalizeProfileId(message?.profileId), type, message: text, timestamp: Date.now(), @@ -2046,6 +2121,7 @@ const cursorState = resolveKeywordCursor(message, keywords, hasExplicitIndex); return { ...message, + profileId: normalizeProfileId(message?.profileId), config: { ...config, keywords, searchJobLimit }, keywords, source: "GET_JOBS_BACKGROUND", @@ -2152,6 +2228,7 @@ const searchParams = normalizedZhilianSearchParams(config); return stableKey({ platform: "zhilian", + profileId: normalizeProfileId(message?.profileId), keywords: uniqueStrings(keywords), cityCode: searchParams.cityCode, salary: searchParams.salary, @@ -2184,11 +2261,17 @@ return Object.prototype.hasOwnProperty.call(value || {}, key); } + function normalizeProfileId(value) { + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null; + } + function isResumableScanTask(task) { return Boolean(isFreshScanTask(task) && isZhilianUrl(window.location.href)); } function isFreshScanTask(task) { + if (!normalizeProfileId(task?.profileId)) return false; if (!task || task.type !== "ZHILIAN_SCAN_START" || !task.runId) return false; if (task.completed || task.phase === "complete" || task.phase === "stopped" || task.phase === "error") return false; diff --git a/front/app/boss/page.tsx b/front/app/boss/page.tsx index b2a96a0..c637085 100644 --- a/front/app/boss/page.tsx +++ b/front/app/boss/page.tsx @@ -18,6 +18,7 @@ import KeywordTagInput from '@/app/components/KeywordTagInput' import { formatSetupMissingMessage, validateSetupForPlatform } from '@/lib/setupChecklist' import { hasBossScanResult, readBossScanRunId } from '@/app/boss/scan-result' import { MAX_JOB_KEYWORDS, parseJobKeywords, serializeJobKeywords } from '@/lib/job-keywords' +import { normalizeScanProfileId, scanEventMatchesProfile } from '@/lib/scan-profile' interface BossConfig { id?: number @@ -279,10 +280,13 @@ export default function BossPage() { }, []) const syncBossScanStatus = useCallback(async (silent = false) => { + const profileId = normalizeScanProfileId(currentProfile?.id) + if (!profileId) return try { const status = await sendChromeBridgeMessage({ type: 'BOSS_SCAN_STATUS', platform: 'boss', + profileId, }, 2000) const paused = Boolean(status.paused || (status.stage === 'blocked' && status.resumable)) const runId = typeof status.runId === 'string' && status.runId.trim() ? status.runId.trim() : null @@ -324,7 +328,7 @@ export default function BossPage() { } catch { // 扩展未连接或平台页未打开时,保持当前前端状态。 } - }, [appendProgressLog]) + }, [appendProgressLog, currentProfile?.id]) const focusLogSection = useCallback(() => { setActiveStep('scan') @@ -450,6 +454,7 @@ export default function BossPage() { try { const raw = JSON.parse(event.data) const data = typeof raw === 'string' ? JSON.parse(raw) : raw + if (!scanEventMatchesProfile(data, currentProfile?.id, true)) return appendProgressLog({ type: data.type || 'info', message: data.message || '', @@ -480,12 +485,13 @@ export default function BossPage() { }) return () => client.close() - }, [appendProgressLog, guideToConfirmStep]) + }, [appendProgressLog, currentProfile?.id, guideToConfirmStep]) useEffect(() => { return subscribeChromeBridgeEvents((event) => { const payload = event.payload if (!payload || payload.platform !== 'boss') return + if (!scanEventMatchesProfile(payload, currentProfile?.id, true)) return appendProgressLog({ type: payload.type || 'info', @@ -509,7 +515,7 @@ export default function BossPage() { setActiveRunId(null) } }) - }, [appendProgressLog, guideToConfirmStep]) + }, [appendProgressLog, currentProfile?.id, guideToConfirmStep]) const checkChromeBridge = async () => { try { @@ -870,6 +876,11 @@ export default function BossPage() { alert('请先在简历配置页新建档案。') return } + const profileId = normalizeScanProfileId(currentProfile?.id) + if (!profileId) { + appendProgressLog({ type: 'error', message: '当前档案 ID 无效,请刷新档案后重试。' }) + return + } if (!keywordsDisplay.length || keywordsDisplay.length > MAX_JOB_KEYWORDS) { const message = !keywordsDisplay.length ? '请至少选择一个搜索关键词。' @@ -896,6 +907,7 @@ export default function BossPage() { const data = await sendChromeBridgeMessage({ type: 'BOSS_SCAN_START', platform: 'boss', + profileId, runId, config: { ...config, @@ -980,6 +992,11 @@ export default function BossPage() { appendProgressLog({ type: 'error', message: '请先在简历配置页新建档案,后端需要用当前档案保存岗位。' }) return } + const profileId = normalizeScanProfileId(currentProfile?.id) + if (!profileId) { + appendProgressLog({ type: 'error', message: '当前档案 ID 无效,请刷新页面后重试。' }) + return + } focusLogSection() setIsCollectingCurrentPage(true) @@ -991,6 +1008,7 @@ export default function BossPage() { const data = await sendChromeBridgeMessage({ type: 'BOSS_COLLECT_CURRENT_PAGE', platform: 'boss', + profileId, keyword: keywordsDisplay.join(', '), runId: `boss-list-${Date.now()}`, }, 70000) as BossCurrentPageCollectResponse @@ -1048,6 +1066,11 @@ export default function BossPage() { appendProgressLog({ type: 'error', message: '请先在简历配置页新建档案,后端需要用当前档案保存 POC 岗位。' }) return } + const profileId = normalizeScanProfileId(currentProfile?.id) + if (!profileId) { + appendProgressLog({ type: 'error', message: '当前档案 ID 无效,请刷新页面后重试。' }) + return + } const keywords = parseJobKeywords(keywordsDisplay) if (keywords.length !== 1) { @@ -1071,6 +1094,7 @@ export default function BossPage() { const data = await sendChromeBridgeMessage({ type: 'BOSS_API_POC_COLLECT', platform: 'boss', + profileId, keyword: keywords[0], cityCode, page: 1, @@ -1117,13 +1141,15 @@ export default function BossPage() { setIsStopping(true) try { const runId = activeRunId + const profileId = normalizeScanProfileId(currentProfile?.id) + if (!profileId) throw new Error('当前档案 ID 无效') await fetch(`${API_BASE}/api/boss/chrome/stop`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ runId }), + body: JSON.stringify({ runId, profileId }), }).catch(() => null) - const data = await sendChromeBridgeMessage({ type: 'BOSS_SCAN_STOP', platform: 'boss', runId }, 1500) + const data = await sendChromeBridgeMessage({ type: 'BOSS_SCAN_STOP', platform: 'boss', runId, profileId }, 1500) if (data.success) { appendProgressLog({ type: 'warning', message: data.message || 'Boss扫描停止请求已发送。' }) diff --git a/front/app/zhilian/page.tsx b/front/app/zhilian/page.tsx index 8ae9772..3a61caa 100644 --- a/front/app/zhilian/page.tsx +++ b/front/app/zhilian/page.tsx @@ -17,6 +17,7 @@ import CurrentProfileBadge, { type CurrentProfile } from '@/app/components/Curre import KeywordTagInput from '@/app/components/KeywordTagInput' import { formatSetupMissingMessage, validateSetupForPlatform } from '@/lib/setupChecklist' import { MAX_JOB_KEYWORDS, parseJobKeywords as normalizeKeywordTokens, serializeJobKeywords } from '@/lib/job-keywords' +import { normalizeScanProfileId, scanEventMatchesProfile } from '@/lib/scan-profile' interface ZhilianConfig { id?: number @@ -147,10 +148,13 @@ export default function ZhilianPage() { }, []) const syncZhilianScanStatus = useCallback(async (silent = false, keepStopping = false) => { + const profileId = normalizeScanProfileId(currentProfile?.id) + if (!profileId) return try { const status = await sendChromeBridgeMessage({ type: 'ZHILIAN_SCAN_STATUS', platform: 'zhilian', + profileId, }, 2000) const running = Boolean(status.isRunning || status.hasStoredTask) if (running) { @@ -175,7 +179,7 @@ export default function ZhilianPage() { } catch { // 扩展未连接或平台页未打开时,保持当前前端状态。 } - }, [appendProgressLog]) + }, [appendProgressLog, currentProfile?.id]) useEffect(() => { checkChromeBridge() @@ -261,6 +265,7 @@ export default function ZhilianPage() { return subscribeChromeBridgeEvents((event) => { const payload = event.payload if (!payload || payload.platform !== 'zhilian') return + if (!scanEventMatchesProfile(payload, currentProfile?.id, true)) return appendProgressLog({ type: payload.type || 'info', @@ -277,7 +282,7 @@ export default function ZhilianPage() { setActiveRunId(null) } }) - }, [appendProgressLog]) + }, [appendProgressLog, currentProfile?.id]) useEffect(() => { if (typeof window === 'undefined' || typeof EventSource === 'undefined') { @@ -308,6 +313,7 @@ export default function ZhilianPage() { try { const raw = JSON.parse(event.data) const data = typeof raw === 'string' ? JSON.parse(raw) : raw + if (!scanEventMatchesProfile(data, currentProfile?.id, true)) return appendProgressLog({ type: data.type || 'info', message: data.message || '', @@ -331,7 +337,7 @@ export default function ZhilianPage() { }) return () => client.close() - }, [appendProgressLog]) + }, [appendProgressLog, currentProfile?.id]) // 统一兼容中英文逗号、JSON数组、换行和多余空白。 const parseKeywordsFromDb = (raw?: string): string => { @@ -456,6 +462,11 @@ export default function ZhilianPage() { alert('请先在简历配置页新建档案。') return } + const profileId = normalizeScanProfileId(currentProfile?.id) + if (!profileId) { + appendProgressLog({ type: 'error', message: '当前档案 ID 无效,请刷新档案后重试。' }) + return + } const setup = await validateSetupForPlatform('zhilian') if (!setup.ready) { const message = formatSetupMissingMessage('智联招聘', setup.missing) @@ -472,6 +483,7 @@ export default function ZhilianPage() { const data = await sendChromeBridgeMessage({ type: 'ZHILIAN_SCAN_START', platform: 'zhilian', + profileId, runId, config: { ...config, @@ -500,12 +512,14 @@ export default function ZhilianPage() { setIsStopping(true) try { const runId = activeRunId + const profileId = normalizeScanProfileId(currentProfile?.id) + if (!profileId) throw new Error('当前档案 ID 无效') await fetch(`${API_BASE}/api/zhilian/chrome/stop`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ runId }), + body: JSON.stringify({ runId, profileId }), }).catch(() => null) - const data = await sendChromeBridgeMessage({ type: 'ZHILIAN_SCAN_STOP', platform: 'zhilian', runId }, 1500) + const data = await sendChromeBridgeMessage({ type: 'ZHILIAN_SCAN_STOP', platform: 'zhilian', runId, profileId }, 1500) if (data.success) { appendProgressLog({ type: 'warning', message: data.message || '智联招聘扫描停止请求已处理。' }) await syncZhilianScanStatus(true, true) diff --git a/front/lib/scan-profile.test.ts b/front/lib/scan-profile.test.ts new file mode 100644 index 0000000..7a9b7e5 --- /dev/null +++ b/front/lib/scan-profile.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { normalizeScanProfileId, scanEventMatchesProfile } from './scan-profile' + +describe('scan profile binding', () => { + it('normalizes only positive integer profile ids', () => { + expect(normalizeScanProfileId('4')).toBe(4) + expect(normalizeScanProfileId(0)).toBeNull() + expect(normalizeScanProfileId('invalid')).toBeNull() + }) + + it('rejects events from another profile and untagged extension events', () => { + expect(scanEventMatchesProfile({ profileId: 4 }, 4, true)).toBe(true) + expect(scanEventMatchesProfile({ profileId: 2 }, 4, true)).toBe(false) + expect(scanEventMatchesProfile({}, 4, true)).toBe(false) + }) + + it('allows untagged non-scan events when explicitly requested', () => { + expect(scanEventMatchesProfile({}, 4)).toBe(true) + }) +}) diff --git a/front/lib/scan-profile.ts b/front/lib/scan-profile.ts new file mode 100644 index 0000000..9696b6f --- /dev/null +++ b/front/lib/scan-profile.ts @@ -0,0 +1,16 @@ +export function normalizeScanProfileId(value: unknown): number | null { + const parsed = Number(value) + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null +} + +export function scanEventMatchesProfile( + payload: Record | null | undefined, + currentProfileId: unknown, + requireTaggedEvent = false, +): boolean { + const current = normalizeScanProfileId(currentProfileId) + if (!current || !payload) return false + const eventProfile = normalizeScanProfileId(payload.profileId) + if (!eventProfile) return !requireTaggedEvent + return eventProfile === current +} diff --git a/src/main/java/com/getjobs/application/controller/BossController.java b/src/main/java/com/getjobs/application/controller/BossController.java index d1e8eab..3c53ba4 100644 --- a/src/main/java/com/getjobs/application/controller/BossController.java +++ b/src/main/java/com/getjobs/application/controller/BossController.java @@ -21,6 +21,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.catalina.connector.ClientAbortException; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.core.env.Environment; @@ -103,7 +104,9 @@ public ResponseEntity> executeBoss() { @PostMapping("/chrome/jobs") public ResponseEntity> receiveChromeJobs(@RequestBody ChromeJobBatchRequest request) { - Long profileId = profileService.getCurrentProfileId(); + ResponseEntity> profileError = validateChromeProfile(request == null ? null : request.getProfileId()); + if (profileError != null) return profileError; + Long profileId = request.getProfileId(); int received = request == null || request.getJobs() == null ? 0 : request.getJobs().size(); int insertedOrUpdated = 0; int queued = 0; @@ -121,13 +124,13 @@ public ResponseEntity> receiveChromeJobs(@RequestBody Chrome if (request != null && request.getJobs() != null) { if (jobRunCoordinator.isCancelRequested(runId)) { jobRunCoordinator.clearCancel(runId); - sendBossProgress(JobProgressMessage.warning("boss", "Boss Chrome扫描已停止,后端未继续处理本批岗位")); + sendBossProgress(profileId, JobProgressMessage.warning("boss", "Boss Chrome扫描已停止,后端未继续处理本批岗位")); return ResponseEntity.ok(decorateListCollectionResponse( bossChromeJobsResponse(true, true, received, 0, 0, 0, 0, 0, autoDeliver, List.of()), listOnlyCollection, 0, List.of() )); } - sendBossProgress(JobProgressMessage.info( + sendBossProgress(profileId, JobProgressMessage.info( "boss", listOnlyCollection ? "Chrome已采集到 " + received + " 个Boss列表岗位,正在按LIST_COLLECTED入库" @@ -136,7 +139,7 @@ public ResponseEntity> receiveChromeJobs(@RequestBody Chrome for (ChromeJobDto dto : request.getJobs()) { if (jobRunCoordinator.isCancelRequested(runId)) { jobRunCoordinator.clearCancel(runId); - sendBossProgress(JobProgressMessage.warning("boss", "Boss Chrome扫描已停止,后端已中断剩余岗位入队")); + sendBossProgress(profileId, JobProgressMessage.warning("boss", "Boss Chrome扫描已停止,后端已中断剩余岗位入队")); return ResponseEntity.ok(decorateListCollectionResponse( bossChromeJobsResponse(true, true, received, insertedOrUpdated, queued, skipped, insufficient, restored, autoDeliver, analyses), listOnlyCollection, listCollected, collectionWarnings @@ -155,7 +158,7 @@ public ResponseEntity> receiveChromeJobs(@RequestBody Chrome } BossJobDataEntity entity = toBossEntity(dto, request.getKeyword()); - BossJobDataEntity saved = bossService.upsertChromeBossJob(entity, runId); + BossJobDataEntity saved = bossService.upsertChromeBossJob(entity, runId, profileId); insertedOrUpdated++; if (saved == null) { @@ -163,6 +166,7 @@ public ResponseEntity> receiveChromeJobs(@RequestBody Chrome rejected.add(chromeJobRejection(dto, "Boss 岗位入库未返回有效记录")); continue; } + validateBossSavedIdentity(profileId, dto, saved); String currentStatus = saved.getDeliveryStatus(); if (listOnlyCollection) { @@ -188,7 +192,7 @@ public ResponseEntity> receiveChromeJobs(@RequestBody Chrome "reason", "Boss列表页字段不完整,已按LIST_COLLECTED入库,未进入AI分析" ); collectionWarnings.add(warning); - sendBossProgress(JobProgressMessage.warning( + sendBossProgress(profileId, JobProgressMessage.warning( "boss", "Boss列表岗位已入库但字段不完整:" + warning.get("company") + " / " + warning.get("title") + ",缺少:" + String.join("、", missingListFields) @@ -228,7 +232,7 @@ public ResponseEntity> receiveChromeJobs(@RequestBody Chrome )); String message = "采集信息不足:" + Objects.toString(display.getCompanyName(), "") + " / " + Objects.toString(display.getJobName(), "") + ",缺少:" + String.join("、", missingFields); log.warn("{}", message); - sendBossProgress(JobProgressMessage.warning("boss", message)); + sendBossProgress(profileId, JobProgressMessage.warning("boss", message)); continue; } @@ -253,7 +257,7 @@ public ResponseEntity> receiveChromeJobs(@RequestBody Chrome job.setCurrent(insertedOrUpdated); job.setTotal(received); job.setRequest(analysisRequest); - job.setProgressCallback(this::sendBossProgress); + job.setProgressCallback(message -> sendBossProgress(profileId, message)); ChromeJobAnalysisQueueService.EnqueueResult enqueueResult = chromeJobAnalysisQueueService.enqueue(job); if (enqueueResult.isRejected()) { @@ -263,7 +267,7 @@ public ResponseEntity> receiveChromeJobs(@RequestBody Chrome } if (enqueueResult.isQueued()) { queued++; - sendBossProgress(JobProgressMessage.progress( + sendBossProgress(profileId, JobProgressMessage.progress( "boss", "已加入后台AI队列:" + saved.getJobName(), insertedOrUpdated, @@ -292,13 +296,13 @@ public ResponseEntity> receiveChromeJobs(@RequestBody Chrome return ResponseEntity.status(429).body(response); } if (listOnlyCollection) { - sendBossProgress(JobProgressMessage.success( + sendBossProgress(profileId, JobProgressMessage.success( "boss", "Boss当前搜索结果页已入库 " + insertedOrUpdated + " 个岗位,其中LIST_COLLECTED " + listCollected + " 个,恢复历史结果 " + restored + " 个,未进入AI分析" )); } else { - sendBossProgress(JobProgressMessage.success("boss", "Boss Chrome岗位已提交后台AI队列:入库 " + insertedOrUpdated + " 个,入队 " + queued + " 个,恢复已有分析 " + restored + " 个,信息不足 " + insufficient + " 个")); + sendBossProgress(profileId, JobProgressMessage.success("boss", "Boss Chrome岗位已提交后台AI队列:入库 " + insertedOrUpdated + " 个,入队 " + queued + " 个,恢复本档案已有分析 " + restored + " 个,信息不足 " + insufficient + " 个")); } return ResponseEntity.ok(decorateChromeJobRejections( decorateListCollectionResponse( @@ -312,10 +316,12 @@ public ResponseEntity> receiveChromeJobs(@RequestBody Chrome @PostMapping("/chrome/jobs/dedupe") public ResponseEntity> dedupeChromeJobs(@RequestBody ChromeJobBatchRequest request) { + ResponseEntity> profileError = validateChromeProfile(request == null ? null : request.getProfileId()); + if (profileError != null) return profileError; List jobs = request == null || request.getJobs() == null ? List.of() : request.getJobs(); List> items = new ArrayList<>(); int duplicateCount = 0; - Long profileId = profileService.getCurrentProfileIdOrNull(); + Long profileId = request.getProfileId(); Map existingJobs = bossService.findExistingChromeBossJobs(profileId, jobs, null); for (int index = 0; index < jobs.size(); index++) { @@ -384,9 +390,12 @@ private String dedupeReason(String action, String matchReason, String existingSt @PostMapping("/chrome/stop") public ResponseEntity> stopChromeBoss(@RequestBody(required = false) Map payload) { + Long profileId = parseProfileId(payload == null ? null : payload.get("profileId")); + ResponseEntity> profileError = validateChromeProfile(profileId); + if (profileError != null) return profileError; String runId = payload == null ? null : Objects.toString(payload.get("runId"), ""); jobRunCoordinator.requestCancel(runId); - sendBossProgress(JobProgressMessage.warning("boss", "Boss Chrome扫描停止请求已发送")); + sendBossProgress(profileId, JobProgressMessage.warning("boss", "Boss Chrome扫描停止请求已发送")); return ResponseEntity.ok(Map.of( "success", true, "message", "Boss Chrome扫描停止请求已发送", @@ -864,6 +873,51 @@ private String normalizeRunId(String runId) { return runId == null || runId.isBlank() ? null : runId.trim(); } + private ResponseEntity> validateChromeProfile(Long requestedProfileId) { + if (requestedProfileId == null || requestedProfileId <= 0) { + return chromeProfileError(HttpStatus.BAD_REQUEST, "PROFILE_REQUIRED", "Chrome 扫描请求缺少有效档案 ID", null); + } + Long currentProfileId = profileService.getCurrentProfileIdOrNull(); + if (!Objects.equals(requestedProfileId, currentProfileId)) { + return chromeProfileError(HttpStatus.CONFLICT, "PROFILE_CHANGED", "当前档案已切换,旧扫描已停止;请重新加载扩展后从当前档案重新扫描", currentProfileId); + } + return null; + } + + private ResponseEntity> chromeProfileError(HttpStatus status, + String errorCode, + String message, + Long currentProfileId) { + Map body = new HashMap<>(); + body.put("success", false); + body.put("errorCode", errorCode); + body.put("message", message); + if (currentProfileId != null) body.put("currentProfileId", currentProfileId); + return ResponseEntity.status(status).body(body); + } + + private Long parseProfileId(Object value) { + if (value instanceof Number number) return number.longValue(); + try { + String text = Objects.toString(value, "").trim(); + return text.isEmpty() ? null : Long.parseLong(text); + } catch (NumberFormatException ignored) { + return null; + } + } + + private void validateBossSavedIdentity(Long profileId, ChromeJobDto dto, BossJobDataEntity saved) { + if (saved.getId() == null || !Objects.equals(profileId, saved.getProfileId())) { + throw new IllegalStateException("Boss 岗位入库结果与扫描档案不一致"); + } + String requestedJobId = firstNonBlank(dto == null ? null : dto.getId(), dto == null ? null : extractBossId(dto.getUrl())); + if (requestedJobId != null + && !requestedJobId.isBlank() + && !requestedJobId.trim().equals(Objects.toString(saved.getEncryptId(), "").trim())) { + throw new IllegalStateException("Boss 岗位稳定 ID 与入库记录不一致,已阻止错误分析任务"); + } + } + private Map toBossAnalysisSnapshot(BossJobDataEntity job) { if (job == null || job.getId() == null) return null; Map item = new HashMap<>(); @@ -935,6 +989,11 @@ private void sendBossProgress(JobProgressMessage message) { bossProgressEmitters.removeAll(deadEmitters); } + private void sendBossProgress(Long profileId, JobProgressMessage message) { + if (message != null) message.setProfileId(profileId); + sendBossProgress(message); + } + /** 心跳 - Boss进度 SSE */ @Scheduled(fixedRate = 30000) public void heartbeatBossProgress() { diff --git a/src/main/java/com/getjobs/application/controller/ZhilianController.java b/src/main/java/com/getjobs/application/controller/ZhilianController.java index 36a7334..52dfe8a 100644 --- a/src/main/java/com/getjobs/application/controller/ZhilianController.java +++ b/src/main/java/com/getjobs/application/controller/ZhilianController.java @@ -26,6 +26,7 @@ import org.apache.catalina.connector.ClientAbortException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.scheduling.annotation.Scheduled; @@ -349,7 +350,9 @@ public Map clearAnalysis() { @PostMapping("/chrome/jobs") public ResponseEntity> receiveChromeJobs(@RequestBody ChromeJobBatchRequest request) { - Long profileId = profileService.getCurrentProfileId(); + ResponseEntity> profileError = validateChromeProfile(request == null ? null : request.getProfileId()); + if (profileError != null) return profileError; + Long profileId = request.getProfileId(); int received = request == null || request.getJobs() == null ? 0 : request.getJobs().size(); int savedCount = 0; int queued = 0; @@ -361,22 +364,22 @@ public ResponseEntity> receiveChromeJobs(@RequestBody Chrome if (request != null && request.getJobs() != null) { if (jobRunCoordinator.isCancelRequested(runId)) { jobRunCoordinator.clearCancel(runId); - sendZhilianProgress(JobProgressMessage.warning("zhilian", "智联 Chrome扫描已停止,后端未继续处理本批岗位")); + sendZhilianProgress(profileId, JobProgressMessage.warning("zhilian", "智联 Chrome扫描已停止,后端未继续处理本批岗位")); return ResponseEntity.ok(zhilianChromeJobsResponse( true, true, received, 0, 0, 0, 0, 0, List.of() )); } - sendZhilianProgress(JobProgressMessage.info("zhilian", "Chrome已采集到 " + received + " 个智联岗位,正在提交后台AI队列")); + sendZhilianProgress(profileId, JobProgressMessage.info("zhilian", "Chrome已采集到 " + received + " 个智联岗位,正在提交后台AI队列")); for (ChromeJobDto dto : request.getJobs()) { if (jobRunCoordinator.isCancelRequested(runId)) { jobRunCoordinator.clearCancel(runId); - sendZhilianProgress(JobProgressMessage.warning("zhilian", "智联 Chrome扫描已停止,后端已中断剩余岗位入队")); + sendZhilianProgress(profileId, JobProgressMessage.warning("zhilian", "智联 Chrome扫描已停止,后端已中断剩余岗位入队")); return ResponseEntity.ok(zhilianChromeJobsResponse( true, true, received, savedCount, queued, skipped, insufficient, restored, analyses )); } ZhilianJobDataEntity entity = toZhilianEntity(dto); - ZhilianJobDataEntity saved = zhilianService.upsertChromeJob(entity, runId); + ZhilianJobDataEntity saved = zhilianService.upsertChromeJob(entity, runId, profileId); savedCount++; if (saved == null) { @@ -384,6 +387,7 @@ public ResponseEntity> receiveChromeJobs(@RequestBody Chrome log.warn("智联 Chrome岗位入库返回为空:company={}, title={}, url={}", dto == null ? "" : dto.getCompany(), dto == null ? "" : dto.getTitle(), dto == null ? "" : dto.getUrl()); continue; } + validateZhilianSavedIdentity(profileId, dto, saved); String currentStatus = saved.getDeliveryStatus(); if (DeliveryStatus.AI_ANALYZING.equals(currentStatus)) { skipped++; @@ -420,7 +424,7 @@ public ResponseEntity> receiveChromeJobs(@RequestBody Chrome )); String message = "采集信息不足:" + Objects.toString(display.getCompanyName(), "") + " / " + Objects.toString(display.getJobTitle(), "") + ",缺少:" + String.join("、", missingFields); log.warn("{}", message); - sendZhilianProgress(JobProgressMessage.warning("zhilian", message)); + sendZhilianProgress(profileId, JobProgressMessage.warning("zhilian", message)); continue; } JobAiAnalysisService.JobAnalysisRequest analysisRequest = new JobAiAnalysisService.JobAnalysisRequest(); @@ -444,7 +448,7 @@ public ResponseEntity> receiveChromeJobs(@RequestBody Chrome job.setCurrent(savedCount); job.setTotal(received); job.setRequest(analysisRequest); - job.setProgressCallback(this::sendZhilianProgress); + job.setProgressCallback(message -> sendZhilianProgress(profileId, message)); ChromeJobAnalysisQueueService.EnqueueResult enqueueResult = chromeJobAnalysisQueueService.enqueue(job); if (enqueueResult.isRejected()) { @@ -456,7 +460,7 @@ public ResponseEntity> receiveChromeJobs(@RequestBody Chrome } if (enqueueResult.isQueued()) { queued++; - sendZhilianProgress(JobProgressMessage.progress( + sendZhilianProgress(profileId, JobProgressMessage.progress( "zhilian", "已加入后台AI队列:" + saved.getJobTitle(), savedCount, @@ -467,7 +471,7 @@ public ResponseEntity> receiveChromeJobs(@RequestBody Chrome } } } - sendZhilianProgress(JobProgressMessage.success("zhilian", "智联 Chrome岗位已提交后台AI队列:入库 " + savedCount + " 个,入队 " + queued + " 个,恢复已有分析 " + restored + " 个,信息不足 " + insufficient + " 个")); + sendZhilianProgress(profileId, JobProgressMessage.success("zhilian", "智联 Chrome岗位已提交后台AI队列:入库 " + savedCount + " 个,入队 " + queued + " 个,恢复本档案已有分析 " + restored + " 个,信息不足 " + insufficient + " 个")); return ResponseEntity.ok(zhilianChromeJobsResponse( true, false, received, savedCount, queued, skipped, insufficient, restored, analyses )); @@ -475,6 +479,9 @@ public ResponseEntity> receiveChromeJobs(@RequestBody Chrome @PostMapping("/chrome/jobs/dedupe") public ResponseEntity> dedupeChromeJobs(@RequestBody ChromeJobBatchRequest request) { + ResponseEntity> profileError = validateChromeProfile(request == null ? null : request.getProfileId()); + if (profileError != null) return profileError; + Long profileId = request.getProfileId(); List jobs = request == null || request.getJobs() == null ? List.of() : request.getJobs(); List> items = new ArrayList<>(); int duplicateCount = 0; @@ -482,8 +489,10 @@ public ResponseEntity> dedupeChromeJobs(@RequestBody ChromeJ String id = firstNonBlank(dto == null ? null : dto.getId(), dto == null ? null : extractUrlId(dto.getUrl())); String title = dto == null ? "" : Objects.toString(dto.getTitle(), "").trim(); String company = dto == null ? "" : Objects.toString(dto.getCompany(), "").trim(); - boolean duplicate = (!isBlank(id) && zhilianService.existsByJobId(id)) - || (!title.isBlank() && !company.isBlank() && zhilianService.existsByTitleAndCompany(title, company)); + boolean duplicate = !isBlank(id) + ? zhilianService.existsByJobId(profileId, id) + : !title.isBlank() && !company.isBlank() + && zhilianService.existsByTitleAndCompany(profileId, title, company); if (duplicate) duplicateCount++; Map item = new HashMap<>(); item.put("id", Objects.toString(id, "")); @@ -504,9 +513,12 @@ public ResponseEntity> dedupeChromeJobs(@RequestBody ChromeJ @PostMapping("/chrome/stop") public ResponseEntity> stopChromeZhilian(@RequestBody(required = false) Map payload) { + Long profileId = parseProfileId(payload == null ? null : payload.get("profileId")); + ResponseEntity> profileError = validateChromeProfile(profileId); + if (profileError != null) return profileError; String runId = payload == null ? null : Objects.toString(payload.get("runId"), ""); jobRunCoordinator.requestCancel(runId); - sendZhilianProgress(JobProgressMessage.warning("zhilian", "智联 Chrome扫描停止请求已发送")); + sendZhilianProgress(profileId, JobProgressMessage.warning("zhilian", "智联 Chrome扫描停止请求已发送")); return ResponseEntity.ok(Map.of( "success", true, "message", "智联 Chrome扫描停止请求已发送", @@ -881,6 +893,11 @@ private void sendZhilianProgress(JobProgressMessage message) { zhilianProgressEmitters.removeAll(deadEmitters); } + private void sendZhilianProgress(Long profileId, JobProgressMessage message) { + if (message != null) message.setProfileId(profileId); + sendZhilianProgress(message); + } + private ZhilianJobDataEntity toZhilianEntity(ChromeJobDto dto) { ZhilianJobDataEntity entity = new ZhilianJobDataEntity(); if (dto == null) return entity; @@ -1050,6 +1067,51 @@ private String normalizeRunId(String runId) { return runId == null || runId.isBlank() ? null : runId.trim(); } + private ResponseEntity> validateChromeProfile(Long requestedProfileId) { + if (requestedProfileId == null || requestedProfileId <= 0) { + return chromeProfileError(HttpStatus.BAD_REQUEST, "PROFILE_REQUIRED", "Chrome 扫描请求缺少有效档案 ID", null); + } + Long currentProfileId = profileService.getCurrentProfileIdOrNull(); + if (!Objects.equals(requestedProfileId, currentProfileId)) { + return chromeProfileError(HttpStatus.CONFLICT, "PROFILE_CHANGED", "当前档案已切换,旧扫描已停止;请重新加载扩展后从当前档案重新扫描", currentProfileId); + } + return null; + } + + private ResponseEntity> chromeProfileError(HttpStatus status, + String errorCode, + String message, + Long currentProfileId) { + Map body = new HashMap<>(); + body.put("success", false); + body.put("errorCode", errorCode); + body.put("message", message); + if (currentProfileId != null) body.put("currentProfileId", currentProfileId); + return ResponseEntity.status(status).body(body); + } + + private Long parseProfileId(Object value) { + if (value instanceof Number number) return number.longValue(); + try { + String text = Objects.toString(value, "").trim(); + return text.isEmpty() ? null : Long.parseLong(text); + } catch (NumberFormatException ignored) { + return null; + } + } + + private void validateZhilianSavedIdentity(Long profileId, ChromeJobDto dto, ZhilianJobDataEntity saved) { + if (saved.getId() == null || !Objects.equals(profileId, saved.getProfileId())) { + throw new IllegalStateException("智联岗位入库结果与扫描档案不一致"); + } + String requestedJobId = firstNonBlank(dto == null ? null : dto.getId(), dto == null ? null : extractUrlId(dto.getUrl())); + if (requestedJobId != null + && !requestedJobId.isBlank() + && !requestedJobId.trim().equals(Objects.toString(saved.getJobId(), "").trim())) { + throw new IllegalStateException("智联岗位稳定 ID 与入库记录不一致,已阻止错误分析任务"); + } + } + private List collectMissingAnalysisFields(ZhilianJobDataEntity job) { List missing = new ArrayList<>(); if (job == null) { diff --git a/src/main/java/com/getjobs/application/dto/ChromeJobBatchRequest.java b/src/main/java/com/getjobs/application/dto/ChromeJobBatchRequest.java index c30098f..e47fcbf 100644 --- a/src/main/java/com/getjobs/application/dto/ChromeJobBatchRequest.java +++ b/src/main/java/com/getjobs/application/dto/ChromeJobBatchRequest.java @@ -6,6 +6,7 @@ @Data public class ChromeJobBatchRequest { + private Long profileId; private String runId; private String keyword; private String collectionMode; diff --git a/src/main/java/com/getjobs/application/service/BossService.java b/src/main/java/com/getjobs/application/service/BossService.java index 921bafb..04f2ddc 100644 --- a/src/main/java/com/getjobs/application/service/BossService.java +++ b/src/main/java/com/getjobs/application/service/BossService.java @@ -598,27 +598,36 @@ public BossJobDataEntity upsertChromeBossJob(BossJobDataEntity entity) { } public synchronized BossJobDataEntity upsertChromeBossJob(BossJobDataEntity entity, String scanRunId) { + return upsertChromeBossJob(entity, scanRunId, profileService.getCurrentProfileId()); + } + + public synchronized BossJobDataEntity upsertChromeBossJob(BossJobDataEntity entity, + String scanRunId, + Long profileId) { if (entity == null) return null; - Long profileId = profileService.getCurrentProfileId(); + if (profileId == null || profileId <= 0) { + throw new IllegalArgumentException("Boss 岗位入库缺少有效档案 ID"); + } entity.setProfileId(profileId); if (scanRunId != null && !scanRunId.isBlank()) { entity.setScanRunId(scanRunId.trim()); entity.setScanResultSource(SCAN_RESULT_CURRENT); } - String encryptId = entity.getEncryptId(); + String encryptId = entity.getEncryptId() == null ? null : entity.getEncryptId().trim(); + entity.setEncryptId(encryptId); String encryptUserId = entity.getEncryptUserId(); BossJobDataEntity existing = null; if (encryptId != null && !encryptId.isBlank()) { - existing = getBossJobByKey(encryptId, encryptUserId, null); - if (existing == null) { - QueryWrapper wrapper = new QueryWrapper<>(); - wrapper.eq("profile_id", profileId) - .eq("encrypt_id", encryptId); - wrapper.last("LIMIT 1"); - existing = bossJobDataMapper.selectOne(wrapper); - } + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("profile_id", profileId) + .apply("TRIM(encrypt_id) = {0}", encryptId); + wrapper.last("LIMIT 1"); + existing = bossJobDataMapper.selectOne(wrapper); } - if (existing == null && entity.getCompanyName() != null && entity.getJobName() != null) { + if ((encryptId == null || encryptId.isBlank()) + && existing == null + && entity.getCompanyName() != null + && entity.getJobName() != null) { QueryWrapper wrapper = new QueryWrapper<>(); wrapper.eq("profile_id", profileId) .eq("company_name", entity.getCompanyName()) diff --git a/src/main/java/com/getjobs/application/service/JobAiAnalysisService.java b/src/main/java/com/getjobs/application/service/JobAiAnalysisService.java index b0fafa6..0b0e237 100644 --- a/src/main/java/com/getjobs/application/service/JobAiAnalysisService.java +++ b/src/main/java/com/getjobs/application/service/JobAiAnalysisService.java @@ -54,7 +54,7 @@ public class JobAiAnalysisService { { "type": "object", "properties": { - "score": {"type": "integer"}, + "score": {"type": "integer", "minimum": 0, "maximum": 100}, "decision": {"type": "string", "enum": ["APPLY", "SKIP"]}, "summary": {"type": "string"}, "strengths": {"type": "array", "items": {"type": "string"}}, @@ -269,7 +269,19 @@ public AnalysisResult analyzeJob(JobAnalysisRequest request, String raw; try { raw = aiService.sendStructuredRequest(prompt, JOB_ANALYSIS_OUTPUT_SCHEMA); - AnalysisResult result = parseResult(raw); + AnalysisResult result; + try { + result = parseResult(raw); + } catch (AiOutputException outputError) { + if (!"AI_OUTPUT_INVALID_JSON".equals(outputError.code())) throw outputError; + if (!isLeaseCurrent(leaseIsCurrent)) return AnalysisResult.staleLease(); + log.warn("AI岗位分析返回无效 JSON,将使用同一 Provider、模型和 Schema 重试一次: {}", outputError.getMessage()); + raw = aiService.sendStructuredRequest( + prompt + "\n\n重要:上一次输出不是有效 JSON。本次只返回一个完全符合 Schema 的 JSON 对象,不要输出 Markdown、解释或额外文本。", + JOB_ANALYSIS_OUTPUT_SCHEMA + ); + result = parseResult(raw); + } result.setPriorityCompany(priority); result.setThreshold(threshold); if (result.getScore() == null) result.setScore(0); @@ -283,10 +295,12 @@ public AnalysisResult analyzeJob(JobAnalysisRequest request, result.setDecision("SKIP"); } if (!isLeaseCurrent(leaseIsCurrent)) return AnalysisResult.staleLease(); - AtomicReference storedResult = new AtomicReference<>(result); + AnalysisResult finalResult = result; + String finalRaw = raw; + AtomicReference storedResult = new AtomicReference<>(finalResult); if (!executeLeaseWrite(leaseWriteGuard, () -> { storedResult.set(persistAndUpdate( - request, result, responseDiagnostic(raw), true)); + request, finalResult, responseDiagnostic(finalRaw), true)); })) { return AnalysisResult.staleLease(); } diff --git a/src/main/java/com/getjobs/application/service/JobAnalysisTaskStore.java b/src/main/java/com/getjobs/application/service/JobAnalysisTaskStore.java index 9b63653..5a27789 100644 --- a/src/main/java/com/getjobs/application/service/JobAnalysisTaskStore.java +++ b/src/main/java/com/getjobs/application/service/JobAnalysisTaskStore.java @@ -99,6 +99,7 @@ public SubmitResult submit(JobAiAnalysisService.JobAnalysisRequest request) { validateRequest(request); String platform = normalizePlatform(request.getPlatform()); String jobKey = stableJobKey(request); + validateTargetJobIdentity(request, platform, jobKey); String taskKey = taskKey(request, platform, jobKey); String requestJson = serialize(request); TransactionTemplate transaction = new TransactionTemplate(transactionManager); @@ -148,6 +149,7 @@ public SubmitResult recordUnknown(JobAiAnalysisService.JobAnalysisRequest reques validateRequest(request); String platform = normalizePlatform(request.getPlatform()); String jobKey = stableJobKey(request); + validateTargetJobIdentity(request, platform, jobKey); String taskKey = taskKey(request, platform, jobKey); String requestJson = serialize(request); TransactionTemplate transaction = new TransactionTemplate(transactionManager); @@ -447,7 +449,19 @@ public JobAiAnalysisService.JobAnalysisRequest deserialize(TaskRecord task) { throw new IllegalArgumentException("任务缺少可恢复的请求快照"); } try { - return objectMapper.readValue(task.requestJson(), JobAiAnalysisService.JobAnalysisRequest.class); + JobAiAnalysisService.JobAnalysisRequest request = objectMapper.readValue( + task.requestJson(), JobAiAnalysisService.JobAnalysisRequest.class); + validateRequest(request); + String platform = normalizePlatform(request.getPlatform()); + String jobKey = stableJobKey(request); + if (!java.util.Objects.equals(task.profileId(), request.getProfileId()) + || !java.util.Objects.equals(normalizePlatform(task.platform()), platform) + || !java.util.Objects.equals(task.jobKey(), jobKey) + || !java.util.Objects.equals(task.jobRowId(), request.getJobRowId())) { + throw new IllegalStateException("AI 分析任务快照与任务索引不一致"); + } + validateTargetJobIdentity(request, platform, jobKey); + return request; } catch (JsonProcessingException e) { throw new IllegalStateException("AI 分析任务请求快照损坏", e); } @@ -565,6 +579,44 @@ private void validateRequest(JobAiAnalysisService.JobAnalysisRequest request) { if (stableJobKey(request).isBlank()) { throw new IllegalArgumentException("AI 分析任务缺少稳定岗位标识"); } + if (request.getJobRowId() == null || request.getJobRowId() <= 0) { + throw new IllegalArgumentException("AI 分析任务缺少有效岗位行 ID"); + } + } + + private void validateTargetJobIdentity(JobAiAnalysisService.JobAnalysisRequest request, + String platform, + String jobKey) { + String table = "boss".equals(platform) ? "boss_data" : "zhilian_data"; + String stableIdColumn = "boss".equals(platform) ? "encrypt_id" : "job_id"; + String jobNameColumn = "boss".equals(platform) ? "job_name" : "job_title"; + List> rows = jdbcTemplate.queryForList( + "SELECT profile_id, " + stableIdColumn + " AS stable_id, company_name, " + + jobNameColumn + " AS job_name FROM " + table + " WHERE id=?", + request.getJobRowId() + ); + String storedKey = ""; + Long storedProfileId = null; + if (rows.size() == 1) { + Map row = rows.get(0); + storedProfileId = nullableLong(row.get("profile_id")); + storedKey = firstNonBlank((String) row.get("stable_id")); + if (storedKey.isBlank()) { + String company = canonical((String) row.get("company_name")); + String jobName = canonical((String) row.get("job_name")); + storedKey = company.isBlank() && jobName.isBlank() ? "" : company + "::" + jobName; + } + } + if (rows.size() != 1 + || !java.util.Objects.equals(storedProfileId, request.getProfileId()) + || !java.util.Objects.equals(storedKey, jobKey)) { + throw new IllegalArgumentException( + "AI 分析任务与目标岗位不一致:profileId=" + request.getProfileId() + + ", platform=" + platform + + ", jobRowId=" + request.getJobRowId() + + ", jobKey=" + jobKey + ); + } } private String normalizePlatform(String platform) { diff --git a/src/main/java/com/getjobs/application/service/ZhilianService.java b/src/main/java/com/getjobs/application/service/ZhilianService.java index 8b93bc9..5606b2b 100644 --- a/src/main/java/com/getjobs/application/service/ZhilianService.java +++ b/src/main/java/com/getjobs/application/service/ZhilianService.java @@ -244,19 +244,25 @@ public int normalizeSearchJobLimit(Integer raw) { } public boolean existsByJobId(String jobId) { - if (jobId == null || jobId.trim().isEmpty()) return false; Long profileId = profileService.getCurrentProfileIdOrNull(); - if (profileId == null) return false; + return existsByJobId(profileId, jobId); + } + + public boolean existsByJobId(Long profileId, String jobId) { + if (profileId == null || jobId == null || jobId.trim().isEmpty()) return false; QueryWrapper w = new QueryWrapper<>(); - w.eq("profile_id", profileId).eq("job_id", jobId).last("LIMIT 1"); + w.eq("profile_id", profileId).apply("TRIM(job_id) = {0}", jobId.trim()).last("LIMIT 1"); Long c = zhilianJobDataMapper.selectCount(w); return c != null && c > 0; } public boolean existsByTitleAndCompany(String jobTitle, String companyName) { - if (jobTitle == null || companyName == null) return false; Long profileId = profileService.getCurrentProfileIdOrNull(); - if (profileId == null) return false; + return existsByTitleAndCompany(profileId, jobTitle, companyName); + } + + public boolean existsByTitleAndCompany(Long profileId, String jobTitle, String companyName) { + if (profileId == null || jobTitle == null || companyName == null) return false; QueryWrapper w = new QueryWrapper<>(); w.eq("profile_id", profileId).eq("job_title", jobTitle).eq("company_name", companyName).last("LIMIT 1"); Long c = zhilianJobDataMapper.selectCount(w); @@ -278,20 +284,33 @@ public ZhilianJobDataEntity upsertChromeJob(ZhilianJobDataEntity entity) { } public ZhilianJobDataEntity upsertChromeJob(ZhilianJobDataEntity entity, String scanRunId) { + return upsertChromeJob(entity, scanRunId, profileService.getCurrentProfileId()); + } + + public synchronized ZhilianJobDataEntity upsertChromeJob(ZhilianJobDataEntity entity, + String scanRunId, + Long profileId) { if (entity == null) return null; - Long profileId = profileService.getCurrentProfileId(); + if (profileId == null || profileId <= 0) { + throw new IllegalArgumentException("智联岗位入库缺少有效档案 ID"); + } entity.setProfileId(profileId); if (scanRunId != null && !scanRunId.isBlank()) { entity.setScanRunId(scanRunId.trim()); } ZhilianJobDataEntity existing = null; - if (entity.getJobId() != null && !entity.getJobId().isBlank()) { + String jobId = entity.getJobId() == null ? null : entity.getJobId().trim(); + entity.setJobId(jobId); + if (jobId != null && !jobId.isBlank()) { QueryWrapper wrapper = new QueryWrapper<>(); - wrapper.eq("profile_id", profileId).eq("job_id", entity.getJobId()); + wrapper.eq("profile_id", profileId).apply("TRIM(job_id) = {0}", jobId); wrapper.last("LIMIT 1"); existing = zhilianJobDataMapper.selectOne(wrapper); } - if (existing == null && entity.getJobTitle() != null && entity.getCompanyName() != null) { + if ((jobId == null || jobId.isBlank()) + && existing == null + && entity.getJobTitle() != null + && entity.getCompanyName() != null) { QueryWrapper wrapper = new QueryWrapper<>(); wrapper.eq("profile_id", profileId) .eq("job_title", entity.getJobTitle()) diff --git a/src/main/java/com/getjobs/worker/dto/JobProgressMessage.java b/src/main/java/com/getjobs/worker/dto/JobProgressMessage.java index 891c953..8bfb7ae 100644 --- a/src/main/java/com/getjobs/worker/dto/JobProgressMessage.java +++ b/src/main/java/com/getjobs/worker/dto/JobProgressMessage.java @@ -42,38 +42,41 @@ public class JobProgressMessage { */ private Long timestamp; + /** 扫描所属档案;登录状态等非扫描事件可为空。 */ + private Long profileId; + /** * 创建进度消息 */ public static JobProgressMessage progress(String platform, String message, int current, int total) { - return new JobProgressMessage(platform, "progress", message, current, total, System.currentTimeMillis()); + return new JobProgressMessage(platform, "progress", message, current, total, System.currentTimeMillis(), null); } /** * 创建信息消息 */ public static JobProgressMessage info(String platform, String message) { - return new JobProgressMessage(platform, "info", message, null, null, System.currentTimeMillis()); + return new JobProgressMessage(platform, "info", message, null, null, System.currentTimeMillis(), null); } /** * 创建成功消息 */ public static JobProgressMessage success(String platform, String message) { - return new JobProgressMessage(platform, "success", message, null, null, System.currentTimeMillis()); + return new JobProgressMessage(platform, "success", message, null, null, System.currentTimeMillis(), null); } /** * 创建错误消息 */ public static JobProgressMessage error(String platform, String message) { - return new JobProgressMessage(platform, "error", message, null, null, System.currentTimeMillis()); + return new JobProgressMessage(platform, "error", message, null, null, System.currentTimeMillis(), null); } /** * 创建警告消息 */ public static JobProgressMessage warning(String platform, String message) { - return new JobProgressMessage(platform, "warning", message, null, null, System.currentTimeMillis()); + return new JobProgressMessage(platform, "warning", message, null, null, System.currentTimeMillis(), null); } } diff --git a/src/main/java/db/migration/V13__unique_boss_profile_job.java b/src/main/java/db/migration/V13__unique_boss_profile_job.java new file mode 100644 index 0000000..e5185cb --- /dev/null +++ b/src/main/java/db/migration/V13__unique_boss_profile_job.java @@ -0,0 +1,64 @@ +package db.migration; + +import org.flywaydb.core.api.migration.BaseJavaMigration; +import org.flywaydb.core.api.migration.Context; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; + +/** + * 为 BOSS 稳定岗位标识建立唯一事实源。历史重复数据必须人工处理,迁移不会自动删除或合并。 + */ +public class V13__unique_boss_profile_job extends BaseJavaMigration { + @Override + public void migrate(Context context) throws Exception { + List duplicates = duplicateGroups(context); + if (!duplicates.isEmpty()) { + throw new IllegalStateException("BOSS 岗位存在重复记录,拒绝自动清理: " + String.join("; ", duplicates)); + } + try (Statement statement = context.getConnection().createStatement()) { + statement.execute(""" + CREATE UNIQUE INDEX idx_boss_data_profile_encrypt_id + ON boss_data(profile_id, TRIM(encrypt_id)) + WHERE profile_id IS NOT NULL AND encrypt_id IS NOT NULL AND TRIM(encrypt_id) <> '' + """); + } + } + + private List duplicateGroups(Context context) throws Exception { + List groups = new ArrayList<>(); + try (PreparedStatement duplicateQuery = context.getConnection().prepareStatement(""" + SELECT profile_id, TRIM(encrypt_id) AS encrypt_id + FROM boss_data + WHERE profile_id IS NOT NULL AND encrypt_id IS NOT NULL AND TRIM(encrypt_id) <> '' + GROUP BY profile_id, TRIM(encrypt_id) + HAVING COUNT(*) > 1 + ORDER BY profile_id, TRIM(encrypt_id) + """); + ResultSet duplicates = duplicateQuery.executeQuery()) { + while (duplicates.next()) { + long profileId = duplicates.getLong("profile_id"); + String encryptId = duplicates.getString("encrypt_id"); + groups.add("profile_id=" + profileId + ", encrypt_id=" + encryptId + ", ids=" + + rowIds(context, profileId, encryptId)); + } + } + return groups; + } + + private List rowIds(Context context, long profileId, String encryptId) throws Exception { + List ids = new ArrayList<>(); + try (PreparedStatement statement = context.getConnection().prepareStatement( + "SELECT id FROM boss_data WHERE profile_id=? AND TRIM(encrypt_id)=? ORDER BY id")) { + statement.setLong(1, profileId); + statement.setString(2, encryptId); + try (ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) ids.add(resultSet.getLong("id")); + } + } + return ids; + } +} diff --git a/src/test/java/com/getjobs/application/controller/BossControllerListOnlyTest.java b/src/test/java/com/getjobs/application/controller/BossControllerListOnlyTest.java index 7515c00..db5ee30 100644 --- a/src/test/java/com/getjobs/application/controller/BossControllerListOnlyTest.java +++ b/src/test/java/com/getjobs/application/controller/BossControllerListOnlyTest.java @@ -32,6 +32,35 @@ class BossControllerListOnlyTest { + @Test + void rejectsMissingOrChangedProfileWithoutSideEffects() { + BossService bossService = mock(BossService.class); + ProfileService profileService = mock(ProfileService.class); + ChromeJobAnalysisQueueService queueService = mock(ChromeJobAnalysisQueueService.class); + JobRunCoordinator jobRunCoordinator = mock(JobRunCoordinator.class); + BossController controller = controller(bossService, profileService, queueService, jobRunCoordinator); + when(profileService.getCurrentProfileIdOrNull()).thenReturn(4L); + + ChromeJobBatchRequest missing = new ChromeJobBatchRequest(); + missing.setJobs(List.of(chromeJob("job-missing", "公司", "岗位"))); + ResponseEntity> missingResponse = controller.receiveChromeJobs(missing); + + ChromeJobBatchRequest changed = new ChromeJobBatchRequest(); + changed.setProfileId(3L); + changed.setJobs(List.of(chromeJob("job-changed", "公司", "岗位"))); + ResponseEntity> changedResponse = controller.receiveChromeJobs(changed); + + assertThat(missingResponse.getStatusCode().value()).isEqualTo(400); + assertThat(missingResponse.getBody()).containsEntry("errorCode", "PROFILE_REQUIRED"); + assertThat(changedResponse.getStatusCode().value()).isEqualTo(409); + assertThat(changedResponse.getBody()) + .containsEntry("errorCode", "PROFILE_CHANGED") + .containsEntry("currentProfileId", 4L); + verify(bossService, never()).upsertChromeBossJob(any(), any(), any()); + verify(queueService, never()).enqueue(any()); + verify(jobRunCoordinator, never()).requestCancel(any()); + } + @Test void savesListOnlyJobsWithoutEnqueueingAiAnalysis() { BossJobService bossJobService = mock(BossJobService.class); @@ -72,6 +101,7 @@ void savesListOnlyJobsWithoutEnqueueingAiAnalysis() { dto.setDeliveryStatus(DeliveryStatus.LIST_COLLECTED); ChromeJobBatchRequest request = new ChromeJobBatchRequest(); + request.setProfileId(1L); request.setRunId("boss-list-test"); request.setKeyword("Java"); request.setCollectionMode("LIST_ONLY"); @@ -81,6 +111,7 @@ void savesListOnlyJobsWithoutEnqueueingAiAnalysis() { BossJobDataEntity saved = new BossJobDataEntity(); saved.setId(1L); + saved.setProfileId(1L); saved.setEncryptId("job-1"); saved.setJobName(dto.getTitle()); saved.setCompanyName(dto.getCompany()); @@ -89,9 +120,9 @@ void savesListOnlyJobsWithoutEnqueueingAiAnalysis() { saved.setJobUrl(dto.getUrl()); saved.setDeliveryStatus(DeliveryStatus.LIST_COLLECTED); - when(profileService.getCurrentProfileId()).thenReturn(1L); + when(profileService.getCurrentProfileIdOrNull()).thenReturn(1L); when(jobRunCoordinator.isCancelRequested("boss-list-test")).thenReturn(false); - when(bossService.upsertChromeBossJob(any(BossJobDataEntity.class), eq("boss-list-test"))).thenReturn(saved); + when(bossService.upsertChromeBossJob(any(BossJobDataEntity.class), eq("boss-list-test"), eq(1L))).thenReturn(saved); when(bossService.updateDeliveryStatusById(1L, DeliveryStatus.LIST_COLLECTED)).thenReturn(saved); when(queueService.queueSize()).thenReturn(0); @@ -142,6 +173,7 @@ void dedupeReturnsNewSkipAndEnrichAcrossHistoricalRuns() { ChromeJobDto collected = chromeJob("job-collected", "待补全公司", "待补全岗位"); ChromeJobDto fresh = chromeJob("job-new", "新公司", "新岗位"); ChromeJobBatchRequest request = new ChromeJobBatchRequest(); + request.setProfileId(1L); request.setRunId("run-new"); request.setJobs(List.of(complete, collected, fresh)); @@ -179,6 +211,7 @@ void dedupeRequiresCompletedOrInFlightAnalysisBeforeHistoricalReuse() { ); ChromeJobDto dto = chromeJob("job-not-analyzed", "待分析公司", "待分析岗位"); ChromeJobBatchRequest request = new ChromeJobBatchRequest(); + request.setProfileId(1L); request.setJobs(List.of(dto)); BossJobDataEntity existing = savedJob(21L, dto, DeliveryStatus.NOT_DELIVERED); @@ -203,6 +236,7 @@ void reusesHistoricalJobWithoutUpsertOrAiEnqueue() { ChromeJobDto dto = chromeJob("job-history", "历史公司", "历史岗位"); dto.setCollectionAction("REUSE_HISTORY"); ChromeJobBatchRequest request = new ChromeJobBatchRequest(); + request.setProfileId(1L); request.setRunId("boss-current"); request.setJobs(List.of(dto)); @@ -215,7 +249,7 @@ void reusesHistoricalJobWithoutUpsertOrAiEnqueue() { restored.setScanRunId("boss-current"); restored.setScanResultSource(BossService.SCAN_RESULT_HISTORICAL); - when(profileService.getCurrentProfileId()).thenReturn(1L); + when(profileService.getCurrentProfileIdOrNull()).thenReturn(1L); when(jobRunCoordinator.isCancelRequested("boss-current")).thenReturn(false); when(bossService.findExistingChromeBossJobs(eq(1L), any(), eq(null))).thenReturn(Map.of(0, existing)); when(bossService.reuseHistoricalBossJob(31L, 1L, "boss-current")).thenReturn(restored); @@ -230,7 +264,7 @@ void reusesHistoricalJobWithoutUpsertOrAiEnqueue() { .containsEntry("queued", 0) .containsEntry("restored", 1) .containsEntry("rejectedCount", 0); - verify(bossService, never()).upsertChromeBossJob(any(), any()); + verify(bossService, never()).upsertChromeBossJob(any(), any(), any()); verify(queueService, never()).enqueue(any()); } @@ -245,11 +279,12 @@ void rejectsHistoricalReuseWhenJobNowNeedsEnrichment() { ChromeJobDto dto = chromeJob("job-changed", "变化公司", "变化岗位"); dto.setCollectionAction("REUSE_HISTORY"); ChromeJobBatchRequest request = new ChromeJobBatchRequest(); + request.setProfileId(1L); request.setRunId("boss-current"); request.setJobs(List.of(dto)); BossJobDataEntity needsEnrichment = savedJob(41L, dto, DeliveryStatus.LIST_COLLECTED); - when(profileService.getCurrentProfileId()).thenReturn(1L); + when(profileService.getCurrentProfileIdOrNull()).thenReturn(1L); when(jobRunCoordinator.isCancelRequested("boss-current")).thenReturn(false); when(bossService.findExistingChromeBossJobs(eq(1L), any(), eq(null))).thenReturn(Map.of(0, needsEnrichment)); @@ -261,7 +296,7 @@ void rejectsHistoricalReuseWhenJobNowNeedsEnrichment() { .containsEntry("status", "FAILED") .containsEntry("rejectedCount", 1); verify(bossService, never()).reuseHistoricalBossJob(any(), any(), any()); - verify(bossService, never()).upsertChromeBossJob(any(), any()); + verify(bossService, never()).upsertChromeBossJob(any(), any(), any()); verify(queueService, never()).enqueue(any()); } @@ -276,10 +311,11 @@ void rejectsHistoricalReuseWhenCurrentProfileHasNoMatchingJob() { ChromeJobDto dto = chromeJob("job-other-profile", "其他档案公司", "其他档案岗位"); dto.setCollectionAction("REUSE_HISTORY"); ChromeJobBatchRequest request = new ChromeJobBatchRequest(); + request.setProfileId(1L); request.setRunId("boss-current"); request.setJobs(List.of(dto)); - when(profileService.getCurrentProfileId()).thenReturn(1L); + when(profileService.getCurrentProfileIdOrNull()).thenReturn(1L); when(jobRunCoordinator.isCancelRequested("boss-current")).thenReturn(false); when(bossService.findExistingChromeBossJobs(eq(1L), any(), eq(null))).thenReturn(Map.of()); @@ -291,7 +327,7 @@ void rejectsHistoricalReuseWhenCurrentProfileHasNoMatchingJob() { .containsEntry("status", "FAILED") .containsEntry("rejectedCount", 1); verify(bossService, never()).reuseHistoricalBossJob(any(), any(), any()); - verify(bossService, never()).upsertChromeBossJob(any(), any()); + verify(bossService, never()).upsertChromeBossJob(any(), any(), any()); verify(queueService, never()).enqueue(any()); } @@ -307,10 +343,11 @@ void rejectsHistoricalReuseWithoutVerifiableBossJobId() { dto.setUrl(""); dto.setCollectionAction("REUSE_HISTORY"); ChromeJobBatchRequest request = new ChromeJobBatchRequest(); + request.setProfileId(1L); request.setRunId("boss-current"); request.setJobs(List.of(dto)); - when(profileService.getCurrentProfileId()).thenReturn(1L); + when(profileService.getCurrentProfileIdOrNull()).thenReturn(1L); when(jobRunCoordinator.isCancelRequested("boss-current")).thenReturn(false); ResponseEntity> response = controller.receiveChromeJobs(request); @@ -355,6 +392,7 @@ private ChromeJobDto chromeJob(String id, String company, String title) { private BossJobDataEntity savedJob(Long id, ChromeJobDto dto, String status) { BossJobDataEntity entity = new BossJobDataEntity(); entity.setId(id); + entity.setProfileId(1L); entity.setEncryptId(dto.getId()); entity.setCompanyName(dto.getCompany()); entity.setJobName(dto.getTitle()); diff --git a/src/test/java/com/getjobs/application/controller/ZhilianControllerProfileIsolationTest.java b/src/test/java/com/getjobs/application/controller/ZhilianControllerProfileIsolationTest.java new file mode 100644 index 0000000..ecfb4dc --- /dev/null +++ b/src/test/java/com/getjobs/application/controller/ZhilianControllerProfileIsolationTest.java @@ -0,0 +1,68 @@ +package com.getjobs.application.controller; + +import com.getjobs.application.dto.ChromeJobBatchRequest; +import com.getjobs.application.service.ChromeJobAnalysisQueueService; +import com.getjobs.application.service.ProfileService; +import com.getjobs.application.service.ZhilianService; +import com.getjobs.worker.service.JobRunCoordinator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.ResponseEntity; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ZhilianControllerProfileIsolationTest { + private final ZhilianService zhilianService = mock(ZhilianService.class); + private final ProfileService profileService = mock(ProfileService.class); + private final JobRunCoordinator jobRunCoordinator = mock(JobRunCoordinator.class); + private final ChromeJobAnalysisQueueService queueService = mock(ChromeJobAnalysisQueueService.class); + private ZhilianController controller; + + @BeforeEach + void setUp() { + controller = new ZhilianController(); + ReflectionTestUtils.setField(controller, "zhilianService", zhilianService); + ReflectionTestUtils.setField(controller, "profileService", profileService); + ReflectionTestUtils.setField(controller, "jobRunCoordinator", jobRunCoordinator); + ReflectionTestUtils.setField(controller, "chromeJobAnalysisQueueService", queueService); + when(profileService.getCurrentProfileIdOrNull()).thenReturn(4L); + } + + @Test + void rejectsMissingProfileBeforeDedupeOrPersistence() { + ChromeJobBatchRequest request = new ChromeJobBatchRequest(); + + ResponseEntity> dedupe = controller.dedupeChromeJobs(request); + ResponseEntity> submit = controller.receiveChromeJobs(request); + + assertThat(dedupe.getStatusCode().value()).isEqualTo(400); + assertThat(dedupe.getBody()).containsEntry("errorCode", "PROFILE_REQUIRED"); + assertThat(submit.getStatusCode().value()).isEqualTo(400); + assertThat(submit.getBody()).containsEntry("errorCode", "PROFILE_REQUIRED"); + verify(zhilianService, never()).existsByJobId(any(), any()); + verify(zhilianService, never()).upsertChromeJob(any(), any(), any()); + verify(queueService, never()).enqueue(any()); + } + + @Test + void rejectsChangedProfileBeforeStopHasAnyEffect() { + ResponseEntity> response = controller.stopChromeZhilian(Map.of( + "profileId", 3L, + "runId", "old-profile-run" + )); + + assertThat(response.getStatusCode().value()).isEqualTo(409); + assertThat(response.getBody()) + .containsEntry("errorCode", "PROFILE_CHANGED") + .containsEntry("currentProfileId", 4L); + verify(jobRunCoordinator, never()).requestCancel(any()); + } +} diff --git a/src/test/java/com/getjobs/application/service/BossServiceDedupeTest.java b/src/test/java/com/getjobs/application/service/BossServiceDedupeTest.java index d647e42..a2d47c0 100644 --- a/src/test/java/com/getjobs/application/service/BossServiceDedupeTest.java +++ b/src/test/java/com/getjobs/application/service/BossServiceDedupeTest.java @@ -162,6 +162,20 @@ void upsertUpdatesHistoricalJobAcrossScanRunsInsteadOfCreatingDuplicateRow() { verify(bossJobDataMapper, never()).insert(any(BossJobDataEntity.class)); } + @Test + void upsertDoesNotMergeDifferentStableIdsWithSameCompanyAndTitle() { + BossJobDataEntity incoming = bossJob(null, "stable-new", "相同公司", "相同岗位"); + when(bossJobDataMapper.selectOne(any(QueryWrapper.class))).thenReturn(null); + when(bossJobDataMapper.insert(any(BossJobDataEntity.class))).thenReturn(1); + + BossJobDataEntity saved = bossService.upsertChromeBossJob(incoming, "run-new", 1L); + + verify(bossJobDataMapper).insert(incoming); + verify(bossJobDataMapper, never()).updateById(any(BossJobDataEntity.class)); + assertThat(saved.getEncryptId()).isEqualTo("stable-new"); + assertThat(saved.getProfileId()).isEqualTo(1L); + } + @Test void reuseHistoricalJobOnlyUpdatesScanOwnershipFields() { LocalDateTime createdAt = LocalDateTime.of(2026, 7, 18, 10, 0); diff --git a/src/test/java/com/getjobs/application/service/ChromeJobAnalysisQueueServiceTest.java b/src/test/java/com/getjobs/application/service/ChromeJobAnalysisQueueServiceTest.java index 17c3640..b021a24 100644 --- a/src/test/java/com/getjobs/application/service/ChromeJobAnalysisQueueServiceTest.java +++ b/src/test/java/com/getjobs/application/service/ChromeJobAnalysisQueueServiceTest.java @@ -286,7 +286,20 @@ private JobAiAnalysisService.JobAnalysisRequest request(String platform, String request.setProfileId(1L); request.setPlatform(platform); request.setJobKey(jobKey); - request.setJobRowId("boss".equals(platform) ? 10L : 20L); + jdbcTemplate.update("INSERT OR IGNORE INTO profile(id, name, is_active) VALUES (1, 'queue-profile', 0)"); + if ("boss".equals(platform)) { + jdbcTemplate.update("INSERT OR IGNORE INTO boss_data(profile_id, encrypt_id, company_name, job_name, delivery_status) " + + "VALUES (1, ?, '测试公司', 'Java 工程师', ?)", + jobKey, DeliveryStatus.NOT_DELIVERED); + request.setJobRowId(jdbcTemplate.queryForObject( + "SELECT id FROM boss_data WHERE profile_id=1 AND encrypt_id=?", Long.class, jobKey)); + } else { + jdbcTemplate.update("INSERT OR IGNORE INTO zhilian_data(profile_id, job_id, company_name, job_title, delivery_status) " + + "VALUES (1, ?, '测试公司', 'Java 工程师', ?)", + jobKey, DeliveryStatus.NOT_DELIVERED); + request.setJobRowId(jdbcTemplate.queryForObject( + "SELECT id FROM zhilian_data WHERE profile_id=1 AND job_id=?", Long.class, jobKey)); + } request.setKeyword("Java"); request.setCompanyName("测试公司"); request.setJobName("Java 工程师"); diff --git a/src/test/java/com/getjobs/application/service/DatabaseMigrationTest.java b/src/test/java/com/getjobs/application/service/DatabaseMigrationTest.java index f829105..d2f56ca 100644 --- a/src/test/java/com/getjobs/application/service/DatabaseMigrationTest.java +++ b/src/test/java/com/getjobs/application/service/DatabaseMigrationTest.java @@ -20,7 +20,7 @@ class DatabaseMigrationTest { Path tempDir; @Test - void freshDatabaseMigratesThroughV12AndMatchesSchemaContract() throws Exception { + void freshDatabaseMigratesThroughV13AndMatchesSchemaContract() throws Exception { String url = sqliteUrl(tempDir.resolve("fresh.db")); Flyway flyway = flyway(url); @@ -29,7 +29,10 @@ void freshDatabaseMigratesThroughV12AndMatchesSchemaContract() throws Exception try (Connection connection = DriverManager.getConnection(url)) { DatabaseSchemaService.validateSchema(connection); assertThat(scalar(connection, - "SELECT COUNT(*) FROM flyway_schema_history WHERE success=1 AND version='12'")) + "SELECT COUNT(*) FROM flyway_schema_history WHERE success=1 AND version='13'")) + .isEqualTo(1L); + assertThat(scalar(connection, + "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_boss_data_profile_encrypt_id'")) .isEqualTo(1L); assertThat(columns(connection, "resume_profile")).contains("recommended_job_keywords"); assertThat(columns(connection, "ai")).contains("apply_threshold", "priority_apply_threshold"); @@ -97,6 +100,50 @@ void v12AddsRecommendationsWithoutChangingExistingResume() throws Exception { } } + @Test + void v13AllowsSameBossJobAcrossProfilesAndRejectsDuplicatesWithinOneProfile() throws Exception { + String validUrl = sqliteUrl(tempDir.resolve("boss-unique-valid.db")); + Flyway.configure() + .dataSource(validUrl, null, null) + .locations("classpath:db/migration") + .target("12") + .load() + .migrate(); + try (Connection connection = DriverManager.getConnection(validUrl); Statement statement = connection.createStatement()) { + statement.execute("INSERT INTO profile(id, name, is_active) VALUES (1, 'one', 1), (2, 'two', 0)"); + statement.execute("INSERT INTO boss_data(profile_id, encrypt_id, company_name, job_name) VALUES " + + "(1, 'shared-job', 'A', '岗位'), (2, 'shared-job', 'A', '岗位')"); + } + flyway(validUrl).migrate(); + try (Connection connection = DriverManager.getConnection(validUrl)) { + assertThat(scalar(connection, + "SELECT COUNT(*) FROM boss_data WHERE encrypt_id='shared-job'")) + .isEqualTo(2L); + } + + String duplicateUrl = sqliteUrl(tempDir.resolve("boss-unique-duplicate.db")); + Flyway.configure() + .dataSource(duplicateUrl, null, null) + .locations("classpath:db/migration") + .target("12") + .load() + .migrate(); + try (Connection connection = DriverManager.getConnection(duplicateUrl); Statement statement = connection.createStatement()) { + statement.execute("INSERT INTO profile(id, name, is_active) VALUES (1, 'duplicate', 1)"); + statement.execute("INSERT INTO boss_data(profile_id, encrypt_id, company_name, job_name) VALUES " + + "(1, ' duplicate-job ', 'A', '岗位'), (1, 'duplicate-job', 'A', '岗位')"); + } + + assertThatThrownBy(() -> flyway(duplicateUrl).migrate()) + .hasRootCauseInstanceOf(IllegalStateException.class) + .hasStackTraceContaining("profile_id=1") + .hasStackTraceContaining("duplicate-job") + .hasStackTraceContaining("ids="); + try (Connection connection = DriverManager.getConnection(duplicateUrl)) { + assertThat(scalar(connection, "SELECT COUNT(*) FROM boss_data")).isEqualTo(2L); + } + } + @Test void v7PreservesLegacyAggregateRowsAndLeavesThemUndispatchable() throws Exception { String url = sqliteUrl(tempDir.resolve("legacy-ai-task.db")); diff --git a/src/test/java/com/getjobs/application/service/JobAiAnalysisServiceStatusTest.java b/src/test/java/com/getjobs/application/service/JobAiAnalysisServiceStatusTest.java index a4cf7bc..934df5c 100644 --- a/src/test/java/com/getjobs/application/service/JobAiAnalysisServiceStatusTest.java +++ b/src/test/java/com/getjobs/application/service/JobAiAnalysisServiceStatusTest.java @@ -413,20 +413,49 @@ void invalidScoreAndArrayElementTypesAreRejected() { } @Test - void invalidJsonAndDecisionAreRejected() { + void invalidJsonRetriesOnceWithSameSchemaAndCanSucceed() { when(bossJobDataMapper.selectOne(any())).thenReturn(bossJob(DeliveryStatus.NOT_DELIVERED)); when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); when(aiService.sendStructuredRequest(any(), any())) .thenReturn("not-json-at-all") .thenReturn(""" - {"score":80,"decision":"MAYBE","summary":"匹配","strengths":[],"risks":[],"greeting":"你好"} + {"score":80,"decision":"APPLY","summary":"重试成功","strengths":[],"risks":[],"greeting":"你好"} """); - JobAiAnalysisService.AnalysisResult invalidJson = service.analyzeJob(bossRequest()); - JobAiAnalysisService.AnalysisResult invalidDecision = service.analyzeJob(bossRequest()); + JobAiAnalysisService.AnalysisResult result = service.analyzeJob(bossRequest()); + + assertThat(result.isFailure()).isFalse(); + assertThat(result.getSummary()).isEqualTo("重试成功"); + ArgumentCaptor schema = ArgumentCaptor.forClass(String.class); + verify(aiService, times(2)).sendStructuredRequest(any(), schema.capture()); + assertThat(schema.getAllValues()).hasSize(2).allMatch(schema.getAllValues().get(0)::equals); + } + + @Test + void invalidJsonFailsAfterExactlyOneRetry() { + when(bossJobDataMapper.selectOne(any())).thenReturn(bossJob(DeliveryStatus.NOT_DELIVERED)); + when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); + when(aiService.sendStructuredRequest(any(), any())).thenReturn("bad-json", "still-bad-json"); + + JobAiAnalysisService.AnalysisResult result = service.analyzeJob(bossRequest()); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.getErrorCode()).isEqualTo("AI_OUTPUT_INVALID_JSON"); + verify(aiService, times(2)).sendStructuredRequest(any(), any()); + } + + @Test + void invalidDecisionFailsWithoutJsonRetry() { + when(bossJobDataMapper.selectOne(any())).thenReturn(bossJob(DeliveryStatus.NOT_DELIVERED)); + when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); + when(aiService.sendStructuredRequest(any(), any())).thenReturn(""" + {"score":80,"decision":"MAYBE","summary":"匹配","strengths":[],"risks":[],"greeting":"你好"} + """); - assertThat(invalidJson.getErrorCode()).isEqualTo("AI_OUTPUT_INVALID_JSON"); - assertThat(invalidDecision.getErrorCode()).isEqualTo("AI_OUTPUT_INVALID_DECISION"); + JobAiAnalysisService.AnalysisResult result = service.analyzeJob(bossRequest()); + + assertThat(result.getErrorCode()).isEqualTo("AI_OUTPUT_INVALID_DECISION"); + verify(aiService).sendStructuredRequest(any(), any()); } @Test diff --git a/src/test/java/com/getjobs/application/service/JobAnalysisTaskStoreTest.java b/src/test/java/com/getjobs/application/service/JobAnalysisTaskStoreTest.java index c677932..25a6481 100644 --- a/src/test/java/com/getjobs/application/service/JobAnalysisTaskStoreTest.java +++ b/src/test/java/com/getjobs/application/service/JobAnalysisTaskStoreTest.java @@ -19,6 +19,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class JobAnalysisTaskStoreTest { @TempDir @@ -59,6 +60,28 @@ void stableTaskKeyDeduplicatesAcrossRunIdsButSeparatesProfiles() { "SELECT COUNT(*) FROM job_analysis_task WHERE task_key IS NOT NULL", Integer.class)).isEqualTo(2); } + @Test + void rejectsTaskWhoseProfileOrJobKeyDoesNotMatchTargetRow() { + JobAiAnalysisService.JobAnalysisRequest wrongJobKey = request(1L, "boss", "job-real", "run-a"); + wrongJobKey.setJobKey("job-other"); + + assertThatThrownBy(() -> store.submit(wrongJobKey)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("目标岗位不一致"); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM job_analysis_task WHERE task_key IS NOT NULL", Integer.class)).isZero(); + } + + @Test + void rejectsPersistedTaskWhenItsIndexedIdentityNoLongerMatchesSnapshot() { + JobAnalysisTaskStore.SubmitResult submitted = store.submit(request(1L, "boss", "job-real", "run-a")); + jdbcTemplate.update("UPDATE job_analysis_task SET job_key='job-corrupted' WHERE id=?", submitted.task().id()); + + assertThatThrownBy(() -> store.deserialize(store.findById(submitted.task().id()))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("任务快照与任务索引不一致"); + } + @Test void concurrentConsumersCanOnlyClaimOnce() throws Exception { long taskId = store.submit(request(1L, "boss", "job-claim", "run-a")).task().id(); @@ -162,7 +185,21 @@ private JobAiAnalysisService.JobAnalysisRequest request(long profileId, request.setProfileId(profileId); request.setPlatform(platform); request.setJobKey(jobKey); - request.setJobRowId("boss".equals(platform) ? 10L : 20L); + jdbcTemplate.update("INSERT OR IGNORE INTO profile(id, name, is_active) VALUES (?, ?, 0)", + profileId, "profile-" + profileId); + if ("boss".equals(platform)) { + jdbcTemplate.update("INSERT OR IGNORE INTO boss_data(profile_id, encrypt_id, company_name, job_name, delivery_status) " + + "VALUES (?, ?, '测试公司', 'Java 工程师', ?)", + profileId, jobKey, DeliveryStatus.NOT_DELIVERED); + request.setJobRowId(jdbcTemplate.queryForObject( + "SELECT id FROM boss_data WHERE profile_id=? AND encrypt_id=?", Long.class, profileId, jobKey)); + } else { + jdbcTemplate.update("INSERT OR IGNORE INTO zhilian_data(profile_id, job_id, company_name, job_title, delivery_status) " + + "VALUES (?, ?, '测试公司', 'Java 工程师', ?)", + profileId, jobKey, DeliveryStatus.NOT_DELIVERED); + request.setJobRowId(jdbcTemplate.queryForObject( + "SELECT id FROM zhilian_data WHERE profile_id=? AND job_id=?", Long.class, profileId, jobKey)); + } request.setKeyword("Java"); request.setCompanyName("测试公司"); request.setJobName("Java 工程师"); diff --git a/src/test/java/com/getjobs/application/service/ZhilianServiceCrossRunUpsertTest.java b/src/test/java/com/getjobs/application/service/ZhilianServiceCrossRunUpsertTest.java index 8071e70..904d601 100644 --- a/src/test/java/com/getjobs/application/service/ZhilianServiceCrossRunUpsertTest.java +++ b/src/test/java/com/getjobs/application/service/ZhilianServiceCrossRunUpsertTest.java @@ -58,4 +58,25 @@ void sameJobAcrossScanRunsUpdatesExistingRowAndPreservesWorkflowState() { assertThat(updated.getAiReason()).isEqualTo("匹配"); assertThat(updated.getPriorityCompany()).isEqualTo(1); } + + @Test + void differentStableIdsWithSameCompanyAndTitleCreateSeparateRows() { + ProfileService profileService = mock(ProfileService.class); + ZhilianJobDataMapper mapper = mock(ZhilianJobDataMapper.class); + ZhilianService service = new ZhilianService(null, null, mapper, null, profileService); + when(mapper.selectOne(any(Wrapper.class))).thenReturn(null); + when(mapper.insert(any(ZhilianJobDataEntity.class))).thenReturn(1); + + ZhilianJobDataEntity incoming = new ZhilianJobDataEntity(); + incoming.setJobId("stable-new"); + incoming.setJobTitle("相同岗位"); + incoming.setCompanyName("相同公司"); + + ZhilianJobDataEntity saved = service.upsertChromeJob(incoming, "run-new", 7L); + + verify(mapper).insert(incoming); + verify(mapper, never()).updateById(any(ZhilianJobDataEntity.class)); + assertThat(saved.getJobId()).isEqualTo("stable-new"); + assertThat(saved.getProfileId()).isEqualTo(7L); + } } diff --git a/tasks/2026-09-03-profile-scoped-scan-fix.md b/tasks/2026-09-03-profile-scoped-scan-fix.md new file mode 100644 index 0000000..96ac462 --- /dev/null +++ b/tasks/2026-09-03-profile-scoped-scan-fix.md @@ -0,0 +1,50 @@ +# 新档案扫描隔离与岗位身份修复 + +## 背景 + +- BOSS、智联扩展的扫描断点与会话未绑定档案,相同配置可能复用其他档案的扫描进度。 +- Chrome 批次接口没有携带档案 ID,档案切换时可能将迟到批次写入错误档案。 +- 两个平台在稳定岗位 ID 不同的情况下仍会按公司与职位回退合并,导致分析任务与岗位行错配。 + +## 目标与允许范围 + +- 修改 BOSS、智联前端扫描消息、Chrome 扩展任务/会话/请求、后端批次 DTO/控制器/服务。 +- 新增 BOSS 稳定岗位 ID 唯一索引迁移与相关自动测试。 +- 为本地 Codex 非法结构化输出增加一次同模型重试。 +- 升级扩展版本并更新现有 PR #47。 + +## 禁止范围 + +- 不修改 AI Provider、模型或认证方式。 +- 不进行真实岗位投递,不删除任何投递历史。 +- 不修改根工作区已有提交,不启用 8888,不自动合并 PR。 + +## 已确定实现要求 + +- `profileId` 对扫描启动、状态、停止、查重和提交必填;后端缺失返回 `PROFILE_REQUIRED`,与当前档案不一致返回 409 `PROFILE_CHANGED`。 +- 扩展发现旧版无档案断点或不同档案任务时必须清除并从关键词 0 开始。 +- 有稳定岗位 ID 时禁止按公司与职位回退;只有稳定 ID 缺失时允许回退。 +- 分析任务的档案、岗位键与岗位行必须一致。 +- 当前 ID 4“蒋银峰”的 BOSS 数据仅在备份、停止 6866、确认无投递历史后定向清理。 + +## 验收标准 + +- 两个档案使用相同配置时,新档案从第一个关键词开始,同一岗位分别判定为新岗位。 +- 相同公司和职位但稳定 ID 不同的岗位分别入库并生成独立分析任务。 +- 缺失或错误档案请求零入库、零入队。 +- 仅 6866 监听且 `/api/ready` 正常;受控扫描目标 1 个,不执行真实投递。 + +## 测试命令 + +- `.\\gradlew.bat test` +- `pnpm --dir front test` +- `pnpm --dir front lint` +- `pnpm --dir front typecheck` +- `pnpm --dir front build:prod` +- `node --test ` +- `node scripts/validate-chrome-extension.mjs` +- `git diff --check` + +## 返回格式 + +- 分支、本地与远端 SHA、测试与 CI、数据库备份和清理计数、6866 运行证据、受控扫描结果、PR 状态与回滚方式。 From e6564ea333948f6c1e11d176ad416c476f9ce879 Mon Sep 17 00:00:00 2001 From: damingishere-coder Date: Thu, 3 Sep 2026 15:19:14 +0800 Subject: [PATCH 07/12] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E4=B8=AD=E6=96=87=E5=BC=95=E5=8F=B7=E7=A0=B4=E5=9D=8F?= =?UTF-8?q?=E5=B2=97=E4=BD=8D=E5=88=86=E6=9E=90JSON?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/service/JobAiAnalysisService.java | 9 ++++++++- .../service/JobAiAnalysisServiceStatusTest.java | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/getjobs/application/service/JobAiAnalysisService.java b/src/main/java/com/getjobs/application/service/JobAiAnalysisService.java index 0b0e237..af0d996 100644 --- a/src/main/java/com/getjobs/application/service/JobAiAnalysisService.java +++ b/src/main/java/com/getjobs/application/service/JobAiAnalysisService.java @@ -461,7 +461,14 @@ private String repairJsonObject(String raw) { if (raw == null || raw.trim().isEmpty()) { throw outputError("AI_OUTPUT_EMPTY", "AI 返回空内容", raw); } - String s = raw.trim() + String original = raw.trim(); + try { + new JSONObject(original); + return original; + } catch (Exception ignored) { + } + + String s = original .replace('\u201c', '"') .replace('\u201d', '"') .replace('\u2018', '\'') diff --git a/src/test/java/com/getjobs/application/service/JobAiAnalysisServiceStatusTest.java b/src/test/java/com/getjobs/application/service/JobAiAnalysisServiceStatusTest.java index 934df5c..67f7c96 100644 --- a/src/test/java/com/getjobs/application/service/JobAiAnalysisServiceStatusTest.java +++ b/src/test/java/com/getjobs/application/service/JobAiAnalysisServiceStatusTest.java @@ -364,6 +364,21 @@ void repairsMarkdownWrappedAiJsonAndKeepsWaitingConfirmFlow() { assertThat(update.getDeliveryStatus()).isEqualTo(DeliveryStatus.WAITING_CONFIRM); } + @Test + void keepsChineseCurlyQuotesInsideValidJsonString() { + when(bossJobDataMapper.selectOne(any())).thenReturn(bossJob(DeliveryStatus.NOT_DELIVERED)); + when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); + when(aiService.sendStructuredRequest(any(), any())).thenReturn(""" + {"score":88,"decision":"APPLY","summary":"岗位要求“3年以上”经验","strengths":["熟悉Java"],"risks":[],"greeting":"你好"} + """); + + JobAiAnalysisService.AnalysisResult result = service.analyzeJob(bossRequest()); + + assertThat(result.isFailure()).isFalse(); + assertThat(result.getSummary()).isEqualTo("岗位要求“3年以上”经验"); + verify(aiService).sendStructuredRequest(any(), any()); + } + @Test void emptyProviderOutputBecomesExplicitAiFailureInsteadOfSkip() { when(bossJobDataMapper.selectOne(any())).thenReturn(bossJob(DeliveryStatus.NOT_DELIVERED)); From 99b1edee09455e1c0d23af95e6958be208b18dcc Mon Sep 17 00:00:00 2001 From: damingishere-coder Date: Thu, 3 Sep 2026 16:09:23 +0800 Subject: [PATCH 08/12] =?UTF-8?q?=E4=BC=98=E5=8C=96=EF=BC=9A=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E5=88=86=E6=9E=90=E5=B2=97=E4=BD=8D=E5=B9=B6=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=E5=8C=B9=E9=85=8D=E8=AF=81=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- front/app/boss/analysis/AnalysisContent.tsx | 44 ++ .../analysis/components/BossJobTable.test.tsx | 71 ++ .../boss/analysis/components/BossJobTable.tsx | 24 +- .../analysis/components/BossPendingCards.tsx | 16 +- .../hooks/useBossAnalysisTasks.test.tsx | 84 +++ .../analysis/hooks/useBossAnalysisTasks.ts | 128 ++++ front/app/boss/analysis/hooks/useBossJobs.ts | 9 +- front/app/boss/analysis/hooks/useBossStats.ts | 16 +- front/app/boss/analysis/types.ts | 56 ++ front/app/boss/analysis/utils.test.ts | 94 +++ front/app/boss/analysis/utils.ts | 134 +++- .../controller/AiConfigController.java | 9 +- .../ChromeJobAnalysisQueueService.java | 269 +++++-- .../service/JobAiAnalysisService.java | 703 ++++++++++++++---- .../service/JobAnalysisTaskStore.java | 88 ++- .../AiConfigControllerJobTaskTest.java | 14 +- .../ChromeJobAnalysisQueueServiceTest.java | 150 +++- .../JobAiAnalysisServiceStatusTest.java | 344 +++++++-- .../service/JobAnalysisTaskStoreTest.java | 48 ++ tasks/2026-09-03-job-ai-batch-scoring.md | 63 ++ 20 files changed, 2061 insertions(+), 303 deletions(-) create mode 100644 front/app/boss/analysis/components/BossJobTable.test.tsx create mode 100644 front/app/boss/analysis/hooks/useBossAnalysisTasks.test.tsx create mode 100644 front/app/boss/analysis/hooks/useBossAnalysisTasks.ts create mode 100644 front/app/boss/analysis/utils.test.ts create mode 100644 tasks/2026-09-03-job-ai-batch-scoring.md diff --git a/front/app/boss/analysis/AnalysisContent.tsx b/front/app/boss/analysis/AnalysisContent.tsx index 715ebc3..6879a82 100644 --- a/front/app/boss/analysis/AnalysisContent.tsx +++ b/front/app/boss/analysis/AnalysisContent.tsx @@ -16,6 +16,7 @@ import { BossPendingCards } from "./components/BossPendingCards" import { BossThresholdSettings } from "./components/BossThresholdSettings" import { ConfirmDeliveryDialog } from "./components/ConfirmDeliveryDialog" import { useBossDeliveryActions } from "./hooks/useBossDeliveryActions" +import { useBossAnalysisTasks } from "./hooks/useBossAnalysisTasks" import { useBossFilters } from "./hooks/useBossFilters" import { useBossJobs } from "./hooks/useBossJobs" import { useBossStats } from "./hooks/useBossStats" @@ -82,6 +83,18 @@ export default function AnalysisContent({ clearStats, } = useBossStats({ filters, activeScanRunId, buildFilterParams }) + const { + taskByJobId, + queueSize: analysisQueueSize, + pendingCount: analysisPendingCount, + processingCount: analysisProcessingCount, + loading: loadingAnalysisTasks, + retryingTaskId, + error: analysisTaskError, + pollRevision, + retryTask: retryAnalysisTask, + } = useBossAnalysisTasks() + const openTextDialog = useCallback((title: string, content?: string) => { setDialogTitle(title) setDialogContent(content || "") @@ -93,6 +106,19 @@ export default function AnalysisContent({ await loadDashboardStats() }, [loadDashboardStats, loadStats]) + const handleRetryAnalysisJob = useCallback(async (job: BossJob) => { + const task = taskByJobId.get(job.id) + if (!task || (task.status !== "FAILED" && task.status !== "UNKNOWN")) { + openTextDialog("重试AI分析", "没有找到可重试的失败任务,请先刷新任务状态。") + return + } + if (task.status === "UNKNOWN" && !window.confirm( + "该任务上次执行结果未知,重新分析可能再次消耗一次 AI 调用。确认重试吗?", + )) return + const result = await retryAnalysisTask(task) + openTextDialog(result.success ? "AI分析已重新排队" : "AI分析重试失败", result.message) + }, [openTextDialog, retryAnalysisTask, taskByJobId]) + const { actingJobId, blacklistingJobId, @@ -204,6 +230,13 @@ export default function AnalysisContent({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [refreshSignal]) + useEffect(() => { + if (!pollRevision) return + loadList(page, size) + refreshStats() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pollRevision]) + useEffect(() => { loadList(1, size) refreshStats() @@ -255,6 +288,14 @@ export default function AnalysisContent({ />
+
+ AI分析队列 + 排队中 {analysisPendingCount} + 处理中 {analysisProcessingCount} + {loadingAnalysisTasks ? 读取中... : null} + {analysisQueueSize > 0 ? 页面可见时每 3 秒自动刷新 : null} + {analysisTaskError ? {analysisTaskError} : null} +
openGreetingDialog(job, true)} onReconcileJob={handleReconcileJob} onRetryJob={handleRetryJob} + analysisTaskByJobId={taskByJobId} + retryingAnalysisTaskId={retryingTaskId} + onRetryAnalysisJob={handleRetryAnalysisJob} onSkipJob={handleSkipJob} onLoadList={loadList} onInputPageChange={setInputPage} diff --git a/front/app/boss/analysis/components/BossJobTable.test.tsx b/front/app/boss/analysis/components/BossJobTable.test.tsx new file mode 100644 index 0000000..d0ecd6c --- /dev/null +++ b/front/app/boss/analysis/components/BossJobTable.test.tsx @@ -0,0 +1,71 @@ +import { fireEvent, render, screen } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" + +import { BossJobTable } from "./BossJobTable" +import type { BossJob, JobAnalysisTask } from "../types" + +function renderTable(job: BossJob, task?: JobAnalysisTask) { + const onOpenText = vi.fn() + const onRetryAnalysisJob = vi.fn() + render() + return { onOpenText, onRetryAnalysisJob } +} + +describe("Boss岗位表 AI分析展示", () => { + it("结构化展示结论且失败岗位提供单岗重试", () => { + const rawReason = JSON.stringify({ + schemaVersion: 2, + summary: "技能匹配但薪资待核实", + matches: ["Java匹配"], + gaps: [], + unknowns: ["薪资待核实"], + dimensions: [], + hardConflicts: [], + threshold: 75, + }) + const job: BossJob = { id: 9, jobName: "Java工程师", deliveryStatus: "AI分析失败", aiReason: rawReason } + const task: JobAnalysisTask = { + id: 19, + profileId: 1, + platform: "boss", + jobKey: "job-9", + jobRowId: 9, + status: "FAILED", + attemptCount: 1, + } + const { onOpenText, onRetryAnalysisJob } = renderTable(job, task) + + expect(screen.getByText("技能匹配但薪资待核实")).toBeInTheDocument() + expect(screen.queryByText(rawReason)).not.toBeInTheDocument() + fireEvent.click(screen.getByRole("button", { name: "重试分析" })) + expect(onRetryAnalysisJob).toHaveBeenCalledWith(job) + fireEvent.click(screen.getByText("技能匹配但薪资待核实")) + expect(onOpenText).toHaveBeenCalledWith("AI分析详情", expect.stringContaining("待核实")) + }) +}) diff --git a/front/app/boss/analysis/components/BossJobTable.tsx b/front/app/boss/analysis/components/BossJobTable.tsx index a16eb59..1838c89 100644 --- a/front/app/boss/analysis/components/BossJobTable.tsx +++ b/front/app/boss/analysis/components/BossJobTable.tsx @@ -6,8 +6,8 @@ import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Select } from "@/components/ui/select" -import type { BossJob } from "../types" -import { badgeClass, canManualDeliverAiNotMatch, deliveryStatusLabel, failureReasonText, formatDateOnly } from "../utils" +import type { BossJob, JobAnalysisTask } from "../types" +import { badgeClass, canManualDeliverAiNotMatch, deliveryStatusLabel, failureReasonText, formatAiReasonDetail, formatDateOnly, parseAiReason } from "../utils" export function BossJobTable({ items, @@ -25,6 +25,9 @@ export function BossJobTable({ onConfirmJob, onReconcileJob, onRetryJob, + analysisTaskByJobId, + retryingAnalysisTaskId, + onRetryAnalysisJob, onSkipJob, onLoadList, onInputPageChange, @@ -48,6 +51,9 @@ export function BossJobTable({ onConfirmJob: (job: BossJob) => void onReconcileJob: (job: BossJob) => void onRetryJob: (job: BossJob) => void + analysisTaskByJobId: ReadonlyMap + retryingAnalysisTaskId: number | null + onRetryAnalysisJob: (job: BossJob) => void onSkipJob: (job: BossJob) => void onLoadList: (page: number, size: number) => void onInputPageChange: (value: number | string) => void @@ -155,6 +161,8 @@ export function BossJobTable({ items.map((job, idx) => { const manualSelectable = canManualDeliverAiNotMatch(job) const aiNotMatchWithoutUrl = job.deliveryStatus === "AI不匹配" && !job.jobUrl?.trim() + const analysisTask = analysisTaskByJobId.get(job.id) + const aiReason = parseAiReason(job.aiReason) return ( onRetryJob(job)} className="h-7 w-full rounded px-2 text-xs leading-none"> 重试 + ) : job.deliveryStatus === "AI分析失败" ? ( + ) : (job.deliveryStatus || "").includes("已投递") ? ( @@ -263,7 +281,7 @@ export function BossJobTable({ -
onOpenText("AI原因", job.aiReason)}>{job.aiReason || "-"}
+
onOpenText("AI分析详情", formatAiReasonDetail(job.aiReason))}>{aiReason.summary}
{job.priorityCompany ? "是" : "-"} diff --git a/front/app/boss/analysis/components/BossPendingCards.tsx b/front/app/boss/analysis/components/BossPendingCards.tsx index 0c54ba5..7e157ea 100644 --- a/front/app/boss/analysis/components/BossPendingCards.tsx +++ b/front/app/boss/analysis/components/BossPendingCards.tsx @@ -5,7 +5,7 @@ import { BiBlock, BiBriefcase, BiCheckCircle, BiChevronDown, BiChevronUp, BiFilt import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import type { BossJob } from "../types" -import { riskTextOf } from "../utils" +import { formatAiReasonDetail, parseAiReason, riskTextOf } from "../utils" function PendingJobCard({ job, @@ -30,6 +30,7 @@ function PendingJobCard({ }) { const jobTitle = job.jobName || "未命名岗位" const company = job.companyName || "未知公司" + const aiReason = parseAiReason(job.aiReason) return ( @@ -72,17 +73,22 @@ function PendingJobCard({
diff --git a/front/app/boss/analysis/hooks/useBossAnalysisTasks.test.tsx b/front/app/boss/analysis/hooks/useBossAnalysisTasks.test.tsx new file mode 100644 index 0000000..f706fe3 --- /dev/null +++ b/front/app/boss/analysis/hooks/useBossAnalysisTasks.test.tsx @@ -0,0 +1,84 @@ +import { act, renderHook } from "@testing-library/react" +import { afterEach, describe, expect, it, vi } from "vitest" + +import { useBossAnalysisTasks } from "./useBossAnalysisTasks" + +function response(queueSize: number) { + return new Response(JSON.stringify({ + success: true, + queueSize, + pendingCount: queueSize, + processingCount: 0, + data: queueSize > 0 ? [{ + id: 9, + profileId: 1, + platform: "boss", + jobKey: "job-9", + jobRowId: 99, + status: "PENDING", + attemptCount: 0, + }] : [], + }), { status: 200, headers: { "Content-Type": "application/json" } }) +} + +describe("Boss AI任务轮询", () => { + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it("页面可见且有未完成任务时每3秒刷新,队列清空后停止", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + const fetchMock = vi.fn() + .mockResolvedValueOnce(response(1)) + .mockResolvedValueOnce(response(0)) + vi.stubGlobal("fetch", fetchMock) + + const { result } = renderHook(() => useBossAnalysisTasks()) + await vi.waitFor(() => expect(result.current.queueSize).toBe(1)) + + await act(async () => { + await vi.advanceTimersByTimeAsync(3000) + }) + + await vi.waitFor(() => expect(result.current.queueSize).toBe(0)) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining("platform=boss")) + await act(async () => { + await vi.advanceTimersByTimeAsync(6000) + }) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it("页面隐藏时不启动定时轮询", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden") + const fetchMock = vi.fn().mockResolvedValue(response(1)) + vi.stubGlobal("fetch", fetchMock) + + const { result } = renderHook(() => useBossAnalysisTasks()) + await vi.waitFor(() => expect(result.current.loading).toBe(false)) + + await act(async () => { + await vi.advanceTimersByTimeAsync(6000) + }) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it("相同任务快照不会重复触发岗位列表刷新", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + const fetchMock = vi.fn().mockResolvedValue(response(1)) + vi.stubGlobal("fetch", fetchMock) + + const { result } = renderHook(() => useBossAnalysisTasks()) + await vi.waitFor(() => expect(result.current.queueSize).toBe(1)) + expect(result.current.pollRevision).toBe(0) + + await act(async () => { + await vi.advanceTimersByTimeAsync(6000) + }) + + expect(fetchMock).toHaveBeenCalledTimes(3) + expect(result.current.pollRevision).toBe(0) + }) +}) diff --git a/front/app/boss/analysis/hooks/useBossAnalysisTasks.ts b/front/app/boss/analysis/hooks/useBossAnalysisTasks.ts new file mode 100644 index 0000000..694c6b7 --- /dev/null +++ b/front/app/boss/analysis/hooks/useBossAnalysisTasks.ts @@ -0,0 +1,128 @@ +"use client" + +import { useCallback, useEffect, useMemo, useRef, useState } from "react" + +import { API_BASE } from "@/lib/api" +import type { JobAnalysisTask, JobAnalysisTasksResponse } from "../types" + +export function useBossAnalysisTasks() { + const [tasks, setTasks] = useState([]) + const [queueSize, setQueueSize] = useState(0) + const [pendingCount, setPendingCount] = useState(0) + const [processingCount, setProcessingCount] = useState(0) + const [loading, setLoading] = useState(true) + const [retryingTaskId, setRetryingTaskId] = useState(null) + const [error, setError] = useState("") + const [visible, setVisible] = useState(true) + const [pollRevision, setPollRevision] = useState(0) + const requestInFlight = useRef | null>(null) + const snapshotSignature = useRef(null) + + const loadTasks = useCallback((): Promise => { + if (requestInFlight.current) return requestInFlight.current + const request = (async () => { + try { + const response = await fetch(`${API_BASE}/api/ai/job-analysis/tasks?limit=200&platform=boss`) + const payload: JobAnalysisTasksResponse = await response.json() + if (!response.ok || !payload.success) { + throw new Error(payload.message || "AI分析任务读取失败") + } + const bossTasks = (payload.data || []).filter( + (task) => task.platform.toLowerCase() === "boss", + ) + const nextSignature = JSON.stringify({ + queueSize: payload.queueSize ?? 0, + pendingCount: payload.pendingCount ?? 0, + processingCount: payload.processingCount ?? 0, + tasks: bossTasks.map((task) => [task.id, task.status, task.updatedAt, task.lastError]), + }) + setTasks(bossTasks) + setQueueSize(payload.queueSize ?? bossTasks.filter((task) => task.status === "PENDING" || task.status === "LEASED").length) + setPendingCount(payload.pendingCount ?? bossTasks.filter((task) => task.status === "PENDING").length) + setProcessingCount(payload.processingCount ?? bossTasks.filter((task) => task.status === "LEASED").length) + setError("") + if (snapshotSignature.current !== null && snapshotSignature.current !== nextSignature) { + setPollRevision((revision) => revision + 1) + } + snapshotSignature.current = nextSignature + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : "AI分析任务读取失败") + } finally { + setLoading(false) + } + })() + requestInFlight.current = request + void request.finally(() => { + if (requestInFlight.current === request) requestInFlight.current = null + }) + return request + }, []) + + useEffect(() => { + setVisible(document.visibilityState === "visible") + const onVisibilityChange = () => { + const nextVisible = document.visibilityState === "visible" + setVisible(nextVisible) + if (nextVisible) void loadTasks() + } + document.addEventListener("visibilitychange", onVisibilityChange) + return () => document.removeEventListener("visibilitychange", onVisibilityChange) + }, [loadTasks]) + + useEffect(() => { + void loadTasks() + }, [loadTasks]) + + useEffect(() => { + if (!visible || queueSize <= 0) return + const timer = window.setInterval(() => void loadTasks(), 3000) + return () => window.clearInterval(timer) + }, [loadTasks, queueSize, visible]) + + const taskByJobId = useMemo(() => { + const mapped = new Map() + tasks.forEach((task) => { + if (!mapped.has(task.jobRowId)) mapped.set(task.jobRowId, task) + }) + return mapped + }, [tasks]) + + const retryTask = useCallback(async (task: JobAnalysisTask) => { + setRetryingTaskId(task.id) + try { + const confirmUnknown = task.status === "UNKNOWN" + const response = await fetch( + `${API_BASE}/api/ai/job-analysis/tasks/${task.id}/retry?confirmUnknown=${confirmUnknown}`, + { method: "POST" }, + ) + const payload: JobAnalysisTasksResponse = await response.json() + if (!response.ok || !payload.success) { + throw new Error(payload.message || "AI分析任务重试失败") + } + if (requestInFlight.current) await requestInFlight.current + await loadTasks() + return { success: true, message: payload.message || "AI分析任务已重新进入队列" } + } catch (retryError) { + return { + success: false, + message: retryError instanceof Error ? retryError.message : "AI分析任务重试失败", + } + } finally { + setRetryingTaskId(null) + } + }, [loadTasks]) + + return { + tasks, + taskByJobId, + queueSize, + pendingCount, + processingCount, + loading, + retryingTaskId, + error, + pollRevision, + loadTasks, + retryTask, + } +} diff --git a/front/app/boss/analysis/hooks/useBossJobs.ts b/front/app/boss/analysis/hooks/useBossJobs.ts index d2ab79f..b38ede4 100644 --- a/front/app/boss/analysis/hooks/useBossJobs.ts +++ b/front/app/boss/analysis/hooks/useBossJobs.ts @@ -1,6 +1,6 @@ "use client" -import { useCallback, useEffect, useMemo, useState } from "react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { API_BASE } from "@/lib/api" import type { BossJob, FilterState, PagedResult } from "../types" @@ -22,6 +22,7 @@ export function useBossJobs({ const [inputSize, setInputSize] = useState(20) const [loadingList, setLoadingList] = useState(false) const [reloading, setReloading] = useState(false) + const listRequestSequence = useRef(0) const activeScanRunId = useMemo( () => requestedScanRunId.trim(), @@ -37,6 +38,7 @@ export function useBossJobs({ }, [size]) const loadList = useCallback(async (toPage = page, toSize = size) => { + const requestSequence = ++listRequestSequence.current const params = buildFilterParams(filters, activeScanRunId) params.set("page", String(toPage)) params.set("size", String(toSize)) @@ -45,6 +47,7 @@ export function useBossJobs({ setLoadingList(true) const res = await fetch(`${API_BASE}/api/boss/list?${params.toString()}`) const data: PagedResult = await res.json() + if (requestSequence !== listRequestSequence.current) return const filteredItems = (data.items || []).filter((item) => { if (!filters.filterHeadhunter) return true const hrPosition = (item.hrPosition || "").toLowerCase() @@ -55,9 +58,9 @@ export function useBossJobs({ setPage(data.page || toPage) setSize(data.size || toSize) } catch (error) { - console.error("fetch list failed", error) + if (requestSequence === listRequestSequence.current) console.error("fetch list failed", error) } finally { - setLoadingList(false) + if (requestSequence === listRequestSequence.current) setLoadingList(false) } }, [activeScanRunId, buildFilterParams, filters, page, size]) diff --git a/front/app/boss/analysis/hooks/useBossStats.ts b/front/app/boss/analysis/hooks/useBossStats.ts index 750997b..4e90343 100644 --- a/front/app/boss/analysis/hooks/useBossStats.ts +++ b/front/app/boss/analysis/hooks/useBossStats.ts @@ -1,6 +1,6 @@ "use client" -import { useCallback, useState } from "react" +import { useCallback, useRef, useState } from "react" import { API_BASE } from "@/lib/api" import type { FilterState, StatsResponse } from "../types" @@ -17,31 +17,35 @@ export function useBossStats({ const [stats, setStats] = useState(null) const [dashboardStats, setDashboardStats] = useState(null) const [loadingDashboardStats, setLoadingDashboardStats] = useState(true) + const statsRequestSequence = useRef(0) + const dashboardRequestSequence = useRef(0) const loadStats = useCallback(async () => { + const requestSequence = ++statsRequestSequence.current const params = buildFilterParams(filters, activeScanRunId) try { const res = await fetch(`${API_BASE}/api/boss/stats?${params.toString()}`) const data: StatsResponse = await res.json() - setStats(data) + if (requestSequence === statsRequestSequence.current) setStats(data) } catch (error) { - console.error("fetch stats failed", error) + if (requestSequence === statsRequestSequence.current) console.error("fetch stats failed", error) } }, [activeScanRunId, buildFilterParams, filters]) const loadDashboardStats = useCallback(async () => { + const requestSequence = ++dashboardRequestSequence.current try { setLoadingDashboardStats(true) const params = new URLSearchParams() if (activeScanRunId) params.set("scanRunId", activeScanRunId) const res = await fetch(`${API_BASE}/api/boss/stats?${params.toString()}`) const data: StatsResponse = await res.json() - setDashboardStats(data) + if (requestSequence === dashboardRequestSequence.current) setDashboardStats(data) } catch (error) { - console.error("fetch dashboard stats failed", error) + if (requestSequence === dashboardRequestSequence.current) console.error("fetch dashboard stats failed", error) } finally { - setLoadingDashboardStats(false) + if (requestSequence === dashboardRequestSequence.current) setLoadingDashboardStats(false) } }, [activeScanRunId]) diff --git a/front/app/boss/analysis/types.ts b/front/app/boss/analysis/types.ts index f18909c..b3c224f 100644 --- a/front/app/boss/analysis/types.ts +++ b/front/app/boss/analysis/types.ts @@ -80,6 +80,62 @@ export type BossJob = { finalGreeting?: string } +export type AiReasonDimension = { + key: string + label: string + weight: number + status: "MATCH" | "PARTIAL" | "UNKNOWN" | "CONFLICT" | string + awarded: number + jobEvidence: string[] + resumeEvidence: string[] + note: string +} + +export type AiReasonHardConflict = { + requirement: string + jobEvidence: string[] + resumeEvidence: string[] +} + +export type ParsedAiReason = { + schemaVersion: number + summary: string + matches: string[] + gaps: string[] + unknowns: string[] + dimensions: AiReasonDimension[] + hardConflicts: AiReasonHardConflict[] + threshold?: number + errorCode?: string + malformed: boolean +} + +export type JobAnalysisTask = { + id: number + profileId: number + platform: string + jobKey: string + jobRowId: number + scanRunId?: string + status: "PENDING" | "LEASED" | "SUCCEEDED" | "FAILED" | "UNKNOWN" + attemptCount: number + leaseExpiresAt?: string | null + lastError?: string | null + createdAt?: string | null + updatedAt?: string | null + startedAt?: string | null + completedAt?: string | null +} + +export type JobAnalysisTasksResponse = { + success: boolean + data?: JobAnalysisTask[] + queueSize?: number + pendingCount?: number + processingCount?: number + message?: string +} + export type PagedResult = { items: BossJob[] total: number diff --git a/front/app/boss/analysis/utils.test.ts b/front/app/boss/analysis/utils.test.ts new file mode 100644 index 0000000..f69dbd1 --- /dev/null +++ b/front/app/boss/analysis/utils.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest" + +import { formatAiReasonDetail, parseAiReason, riskTextOf } from "./utils" + +describe("AI岗位理由兼容解析", () => { + it("解析 schemaVersion 2 的证据、待核实和分项得分", () => { + const reason = JSON.stringify({ + schemaVersion: 2, + summary: "核心技能匹配,地点需要确认", + matches: ["岗位要求 Java,简历写有 Java"], + gaps: ["岗位要求带团队,简历仅描述个人贡献"], + unknowns: ["未写明到岗时间"], + dimensions: [{ + key: "CORE_SKILLS", + label: "核心职责与技能", + weight: 35, + status: "MATCH", + awarded: 35, + jobEvidence: ["Java"], + resumeEvidence: ["Java"], + note: "技术栈一致", + }], + hardConflicts: [], + threshold: 75, + errorCode: null, + }) + + const parsed = parseAiReason(reason) + + expect(parsed.schemaVersion).toBe(2) + expect(parsed.matches).toEqual(["岗位要求 Java,简历写有 Java"]) + expect(parsed.unknowns).toEqual(["未写明到岗时间"]) + const detail = formatAiReasonDetail(reason) + expect(detail).toContain("结论") + expect(detail).toContain("匹配证据") + expect(detail).toContain("明确差距") + expect(detail).toContain("待核实") + expect(detail).toContain("分项得分") + }) + + it("兼容旧版 JSON 并把 strengths/risks 映射为匹配和差距", () => { + const parsed = parseAiReason(JSON.stringify({ + summary: "旧版结论", + strengths: ["Java经验"], + risks: ["学历待确认"], + threshold: 75, + })) + + expect(parsed.schemaVersion).toBe(1) + expect(parsed.matches).toEqual(["Java经验"]) + expect(parsed.gaps).toEqual(["学历待确认"]) + }) + + it("兼容历史纯文本且不把损坏 JSON 原文显示给用户", () => { + expect(parseAiReason("升级前任务上下文已丢失").summary).toBe("升级前任务上下文已丢失") + const malformed = parseAiReason('{"summary":') + expect(malformed.malformed).toBe(true) + expect(malformed.summary).toBe("AI分析理由格式异常,请重试该岗位") + expect(malformed.summary).not.toContain('{"summary"') + }) + + it("风险摘要只展示差距和待核实,不重复整段理由", () => { + const aiReason = JSON.stringify({ + schemaVersion: 2, + summary: "总体匹配", + matches: ["技能匹配"], + gaps: ["管理经验不足"], + unknowns: ["薪资待确认"], + dimensions: [], + hardConflicts: [], + }) + + expect(riskTextOf({ id: 1, aiReason, jobUrl: "https://example.com" })) + .toBe("管理经验不足\n待核实:薪资待确认") + }) + + it("没有单独 gaps 时仍展示分项中的部分匹配和冲突", () => { + const aiReason = JSON.stringify({ + schemaVersion: 2, + summary: "需要复核经验与地点", + matches: [], + gaps: [], + unknowns: [], + dimensions: [ + { key: "RELEVANT_EXPERIENCE", label: "相关经历", status: "PARTIAL", note: "经验方向接近" }, + { key: "LOCATION_SALARY", label: "地点与薪资", status: "CONFLICT", note: "地点明确不符" }, + ], + hardConflicts: [], + }) + + expect(riskTextOf({ id: 1, aiReason, jobUrl: "https://example.com" })) + .toBe("相关经历:经验方向接近\n地点与薪资:地点明确不符") + }) +}) diff --git a/front/app/boss/analysis/utils.ts b/front/app/boss/analysis/utils.ts index 6ced181..424f17b 100644 --- a/front/app/boss/analysis/utils.ts +++ b/front/app/boss/analysis/utils.ts @@ -1,4 +1,4 @@ -import type { BossJob } from "./types" +import type { AiReasonDimension, AiReasonHardConflict, BossJob, ParsedAiReason } from "./types" import { FAILURE_TYPE_LABELS } from "./types" export function formatDateOnlyValue(value?: string | null) { @@ -46,13 +46,141 @@ export function deliveryStatusLabel(value?: string) { } export function riskTextOf(job: BossJob) { - const reason = (job.aiReason || "").trim() - if (reason) return reason + const reason = parseAiReason(job.aiReason) + const dimensionRisks = reason.dimensions + .filter((item) => item.status === "PARTIAL" || item.status === "CONFLICT") + .map((item) => `${item.label}:${item.note || (item.status === "CONFLICT" ? "存在冲突" : "部分匹配")}`) + const risks = Array.from(new Set([ + ...reason.hardConflicts.map((item) => `硬冲突:${item.requirement}`), + ...reason.gaps, + ...dimensionRisks, + ...reason.unknowns.map((item) => `待核实:${item}`), + ])) + if (risks.length > 0) return risks.join("\n") + if (reason.errorCode) return `${reason.summary}(${reason.errorCode})` if (!job.jobUrl) return "缺少原岗位链接,确认前建议核对岗位来源。" if (!job.aiScore && job.aiScore !== 0) return "暂无AI分数,确认前建议人工复核。" return "暂无明显风险点。" } +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value) +} + +function stringList(value: unknown) { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string" && item.trim().length > 0) + .map((item) => item.trim()) + : [] +} + +function numberValue(value: unknown) { + return typeof value === "number" && Number.isFinite(value) ? value : undefined +} + +const MATCH_STATUS_LABEL: Record = { + MATCH: "匹配", + PARTIAL: "部分匹配", + UNKNOWN: "待核实", + CONFLICT: "冲突", +} + +function parseDimensions(value: unknown): AiReasonDimension[] { + if (!Array.isArray(value)) return [] + return value.flatMap((item) => { + if (!isRecord(item) || typeof item.key !== "string") return [] + return [{ + key: item.key, + label: typeof item.label === "string" && item.label.trim() ? item.label.trim() : item.key, + weight: numberValue(item.weight) ?? 0, + status: typeof item.status === "string" ? item.status : "UNKNOWN", + awarded: numberValue(item.awarded) ?? 0, + jobEvidence: stringList(item.jobEvidence), + resumeEvidence: stringList(item.resumeEvidence), + note: typeof item.note === "string" ? item.note.trim() : "", + }] + }) +} + +function parseHardConflicts(value: unknown): AiReasonHardConflict[] { + if (!Array.isArray(value)) return [] + return value.flatMap((item) => { + if (!isRecord(item) || typeof item.requirement !== "string" || !item.requirement.trim()) return [] + return [{ + requirement: item.requirement.trim(), + jobEvidence: stringList(item.jobEvidence), + resumeEvidence: stringList(item.resumeEvidence), + }] + }) +} + +export function parseAiReason(value?: string | null): ParsedAiReason { + const text = (value || "").trim() + const empty: ParsedAiReason = { + schemaVersion: 0, + summary: "暂无AI理由", + matches: [], + gaps: [], + unknowns: [], + dimensions: [], + hardConflicts: [], + malformed: false, + } + if (!text) return empty + try { + const parsed: unknown = JSON.parse(text) + if (!isRecord(parsed)) throw new Error("reason is not an object") + const schemaVersion = numberValue(parsed.schemaVersion) ?? 1 + return { + schemaVersion, + summary: typeof parsed.summary === "string" && parsed.summary.trim() + ? parsed.summary.trim() + : "暂无AI结论", + matches: stringList(schemaVersion >= 2 ? parsed.matches : parsed.strengths), + gaps: stringList(schemaVersion >= 2 ? parsed.gaps : parsed.risks), + unknowns: stringList(parsed.unknowns), + dimensions: parseDimensions(parsed.dimensions), + hardConflicts: parseHardConflicts(parsed.hardConflicts), + threshold: numberValue(parsed.threshold), + errorCode: typeof parsed.errorCode === "string" && parsed.errorCode.trim() + ? parsed.errorCode.trim() + : undefined, + malformed: false, + } + } catch { + if (text.startsWith("{") || text.startsWith("[")) { + return { + ...empty, + summary: "AI分析理由格式异常,请重试该岗位", + errorCode: "AI_REASON_INVALID_JSON", + malformed: true, + } + } + return { ...empty, summary: text } + } +} + +export function formatAiReasonDetail(value?: string | null) { + const reason = parseAiReason(value) + const sections: string[] = [`结论\n${reason.summary}`] + if (reason.matches.length > 0) sections.push(`匹配证据\n${reason.matches.map((item, index) => `${index + 1}. ${item}`).join("\n")}`) + if (reason.gaps.length > 0) sections.push(`明确差距\n${reason.gaps.map((item, index) => `${index + 1}. ${item}`).join("\n")}`) + if (reason.unknowns.length > 0) sections.push(`待核实\n${reason.unknowns.map((item, index) => `${index + 1}. ${item}`).join("\n")}`) + if (reason.dimensions.length > 0) { + sections.push(`分项得分\n${reason.dimensions.map((item) => + `${item.label}:${Number.isInteger(item.awarded) ? item.awarded : item.awarded.toFixed(2)}/${item.weight}(${MATCH_STATUS_LABEL[item.status] || item.status})${item.note ? `,${item.note}` : ""}` + + `${item.jobEvidence.length > 0 ? `\n 岗位原文:${item.jobEvidence.join(";")}` : ""}` + + `${item.resumeEvidence.length > 0 ? `\n 简历原文:${item.resumeEvidence.join(";")}` : ""}`).join("\n")}`) + } + if (reason.hardConflicts.length > 0) { + sections.push(`硬冲突\n${reason.hardConflicts.map((item, index) => + `${index + 1}. ${item.requirement}\n 岗位原文:${item.jobEvidence.join(";")}\n 简历原文:${item.resumeEvidence.join(";")}`).join("\n")}`) + } + if (reason.threshold !== undefined) sections.push(`当前投递阈值\n${reason.threshold}`) + if (reason.errorCode) sections.push(`错误代码\n${reason.errorCode}`) + return sections.join("\n\n") +} + export function badgeClass(kind: "delivery" | "hr" | "recruitment", value?: string) { const base = "px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap" const v = (value || "").trim() diff --git a/src/main/java/com/getjobs/application/controller/AiConfigController.java b/src/main/java/com/getjobs/application/controller/AiConfigController.java index b96bfc1..c3bef13 100644 --- a/src/main/java/com/getjobs/application/controller/AiConfigController.java +++ b/src/main/java/com/getjobs/application/controller/AiConfigController.java @@ -399,14 +399,17 @@ public ResponseEntity> analyzeJob(@RequestBody JobAiAnalysis @GetMapping("/job-analysis/tasks") public ResponseEntity> listJobAnalysisTasks( - @RequestParam(name = "limit", defaultValue = "50") int limit + @RequestParam(name = "limit", defaultValue = "50") int limit, + @RequestParam(name = "platform", required = false) String platform ) { Map response = new HashMap<>(); try { long profileId = profileService.getCurrentProfileId(); response.put("success", true); - response.put("data", chromeJobAnalysisQueueService.listTasks(profileId, limit)); - response.put("queueSize", chromeJobAnalysisQueueService.queueSize(profileId)); + response.put("data", chromeJobAnalysisQueueService.listTasks(profileId, platform, limit)); + response.put("queueSize", chromeJobAnalysisQueueService.queueSize(profileId, platform)); + response.put("pendingCount", chromeJobAnalysisQueueService.pendingCount(profileId, platform)); + response.put("processingCount", chromeJobAnalysisQueueService.processingCount(profileId, platform)); response.put("message", "AI 分析任务读取成功"); return ResponseEntity.ok(response); } catch (Exception e) { diff --git a/src/main/java/com/getjobs/application/service/ChromeJobAnalysisQueueService.java b/src/main/java/com/getjobs/application/service/ChromeJobAnalysisQueueService.java index 4510fba..cebcd4c 100644 --- a/src/main/java/com/getjobs/application/service/ChromeJobAnalysisQueueService.java +++ b/src/main/java/com/getjobs/application/service/ChromeJobAnalysisQueueService.java @@ -10,6 +10,10 @@ import org.springframework.stereotype.Service; import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.UUID; @@ -32,6 +36,7 @@ public class ChromeJobAnalysisQueueService { private static final int AI_CONCURRENCY = 2; private static final int LOCAL_QUEUE_CAPACITY = 200; + private static final long BATCH_COALESCE_MILLIS = 250; private static final Duration LEASE_DURATION = Duration.ofMinutes(5); private static final long LEASE_HEARTBEAT_SECONDS = 60; @@ -41,6 +46,7 @@ public class ChromeJobAnalysisQueueService { private final ScheduledExecutorService leaseHeartbeatExecutor; private final java.util.Set locallyScheduledTaskIds = ConcurrentHashMap.newKeySet(); private final Map runtimeJobs = new ConcurrentHashMap<>(); + private final Map batchClaimLocks = new ConcurrentHashMap<>(); private volatile boolean stopping; public ChromeJobAnalysisQueueService(JobAiAnalysisService jobAiAnalysisService, @@ -108,6 +114,26 @@ public int queueSize(long profileId) { return taskStore.outstandingCount(profileId); } + public int queueSize(long profileId, String platform) { + return taskStore.outstandingCount(profileId, platform); + } + + public int pendingCount(long profileId) { + return taskStore.pendingCount(profileId); + } + + public int pendingCount(long profileId, String platform) { + return taskStore.pendingCount(profileId, platform); + } + + public int processingCount(long profileId) { + return taskStore.processingCount(profileId); + } + + public int processingCount(long profileId, String platform) { + return taskStore.processingCount(profileId, platform); + } + /** * Readiness 只读取本地执行器和持久任务计数,不调用任何 AI Provider。 */ @@ -126,6 +152,12 @@ public java.util.List listTasks(long profileId, i return taskStore.listRecent(profileId, limit); } + public java.util.List listTasks(long profileId, + String platform, + int limit) { + return taskStore.listRecent(profileId, platform, limit); + } + public JobAnalysisTaskStore.RetryResult retry(long taskId, long profileId, boolean confirmUnknown) { JobAnalysisTaskStore.TaskRecord current = taskStore.findByIdAndProfile(taskId, profileId); if (current != null @@ -196,90 +228,201 @@ void recoverOrphanedAnalyzingTasks() { private void schedule(long taskId) { if (stopping || !locallyScheduledTaskIds.add(taskId)) return; try { - executor.execute(() -> runPersistedTask(taskId)); + leaseHeartbeatExecutor.schedule(() -> { + try { + executor.execute(() -> runPersistedBatch(taskId)); + } catch (RejectedExecutionException e) { + locallyScheduledTaskIds.remove(taskId); + log.debug("本地 AI executor 已满,任务 {} 保留为 PENDING", taskId); + } + }, BATCH_COALESCE_MILLIS, TimeUnit.MILLISECONDS); } catch (RejectedExecutionException e) { locallyScheduledTaskIds.remove(taskId); - log.debug("本地 AI executor 已满,任务 {} 保留为 PENDING", taskId); + log.debug("本地 AI 批处理调度器已停止,任务 {} 保留为 PENDING", taskId); } } - private void runPersistedTask(long taskId) { + private void runPersistedBatch(long taskId) { + long startedAtNanos = System.nanoTime(); String leaseToken = UUID.randomUUID().toString(); - JobAnalysisTaskStore.TaskRecord claimed = null; - JobAiAnalysisService.JobAnalysisRequest request = null; + List claimedTasks = new ArrayList<>(); + Map requests = new LinkedHashMap<>(); + Map batchResults = Map.of(); ScheduledFuture heartbeat = null; try { - claimed = taskStore.claim(taskId, leaseToken, LEASE_DURATION); - if (claimed == null) return; - heartbeat = startLeaseHeartbeat(taskId, leaseToken); - - request = taskStore.deserialize(claimed); - AnalysisJob runtimeJob = runtimeJobs.get(taskId); - Consumer progress = runtimeJob == null ? null : runtimeJob.getProgressCallback(); - int current = runtimeJob == null ? 0 : runtimeJob.getCurrent(); - int total = runtimeJob == null ? 0 : runtimeJob.getTotal(); - String platform = Objects.toString(request.getPlatform(), ""); - String jobName = Objects.toString(request.getJobName(), ""); - - emit(progress, JobProgressMessage.progress(platform, "AI分析中:" + jobName, current, total)); - JobAiAnalysisService.AnalysisResult result = jobAiAnalysisService.analyzeJob( - request, - () -> taskStore.isLeaseOwner(taskId, leaseToken), - action -> taskStore.executeWithLease(taskId, leaseToken, action) - ); - if (result.isStaleLease()) { - log.warn("AI 任务 {} 的租约已失效,旧执行结果已丢弃", taskId); - return; - } - boolean failed = result.isFailure(); - String summary = Objects.toString(result.getSummary(), ""); - boolean completed = result.isProviderOutcomeUnknown() - ? taskStore.completeUnknown(taskId, leaseToken, summary) - : taskStore.complete(taskId, leaseToken, failed, summary); - if (!completed && !result.isProviderOutcomeUnknown()) { - reconcileLateWorkerResult(claimed, leaseToken, failed, summary); - } - if (!completed && result.isProviderOutcomeUnknown()) { - log.warn("AI 任务 {} 的 UNKNOWN 终态写入未命中当前租约,将等待过期对账", taskId); - } + claimedTasks.addAll(claimCompatibleBatch(taskId, leaseToken)); + if (claimedTasks.isEmpty()) return; + heartbeat = startLeaseHeartbeat( + claimedTasks.stream().map(JobAnalysisTaskStore.TaskRecord::id).toList(), leaseToken); - if (result.isProviderOutcomeUnknown()) { - emit(progress, JobProgressMessage.warning( - platform, "AI分析结果未知:" + jobName + "," + summary)); - } else if (failed) { - emit(progress, JobProgressMessage.warning(platform, "AI分析失败:" + jobName + "," + summary)); + List batch = new ArrayList<>(); + for (JobAnalysisTaskStore.TaskRecord task : claimedTasks) { + try { + JobAiAnalysisService.JobAnalysisRequest request = taskStore.deserialize(task); + requests.put(task.id(), request); + emitAnalysisStarted(task.id(), request); + batch.add(new JobAiAnalysisService.BatchAnalysisJob( + task.id(), + request, + () -> taskStore.isLeaseOwner(task.id(), leaseToken), + action -> taskStore.executeWithLease(task.id(), leaseToken, action) + )); + } catch (Exception e) { + finishAfterExecutionException(task, leaseToken, null, e); + } } - String completionLabel = "跳过:"; - if (result.shouldApply()) { - completionLabel = DeliveryStatus.WAITING_CONFIRM + ":"; - } else if (result.isProviderOutcomeUnknown()) { - completionLabel = "结果未知:"; - } else if (failed) { - completionLabel = DeliveryStatus.AI_ANALYSIS_FAILED + ":"; + if (batch.isEmpty()) return; + batchResults = jobAiAnalysisService.analyzeJobs(batch); + for (JobAnalysisTaskStore.TaskRecord task : claimedTasks) { + JobAiAnalysisService.JobAnalysisRequest request = requests.get(task.id()); + if (request == null) continue; + JobAiAnalysisService.AnalysisResult result = batchResults.get(task.id()); + if (result == null) { + finishAfterExecutionException(task, leaseToken, request, + new IllegalStateException("批量 AI 分析未返回该任务结果")); + continue; + } + completeTask(task, leaseToken, request, result); } - emit(progress, JobProgressMessage.progress( - platform, completionLabel + jobName, current, total)); } catch (Exception e) { - log.warn("Chrome 后台 AI 分析任务 {} 失败: {}", taskId, e.getMessage(), e); - if (claimed != null) { - finishAfterExecutionException(claimed, leaseToken, request, e); + log.warn("Chrome 后台 AI 分析批次 {} 失败: {}", taskId, e.getMessage(), e); + for (JobAnalysisTaskStore.TaskRecord task : claimedTasks) { + finishAfterExecutionException(task, leaseToken, requests.get(task.id()), e); } } finally { if (heartbeat != null) heartbeat.cancel(false); - runtimeJobs.remove(taskId); - locallyScheduledTaskIds.remove(taskId); + if (claimedTasks.isEmpty()) locallyScheduledTaskIds.remove(taskId); + for (JobAnalysisTaskStore.TaskRecord task : claimedTasks) { + runtimeJobs.remove(task.id()); + locallyScheduledTaskIds.remove(task.id()); + } + logBatchMetrics(taskId, claimedTasks, batchResults, startedAtNanos); dispatchPendingTasks(); } } - private ScheduledFuture startLeaseHeartbeat(long taskId, String leaseToken) { - return leaseHeartbeatExecutor.scheduleAtFixedRate(() -> { + private void logBatchMetrics(long seedTaskId, + List claimedTasks, + Map results, + long startedAtNanos) { + if (claimedTasks.isEmpty()) return; + long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAtNanos); + long unknown = results.values().stream() + .filter(JobAiAnalysisService.AnalysisResult::isProviderOutcomeUnknown).count(); + long stale = results.values().stream().filter(JobAiAnalysisService.AnalysisResult::isStaleLease).count(); + long failed = results.values().stream() + .filter(JobAiAnalysisService.AnalysisResult::isFailure) + .filter(result -> !result.isProviderOutcomeUnknown() && !result.isStaleLease()) + .count(); + long missing = Math.max(0, claimedTasks.size() - results.size()); + double failureRate = (failed + unknown + missing) * 100.0 / claimedTasks.size(); + log.info("AI岗位批次完成: seedTaskId={}, batchSize={}, resultCount={}, failed={}, unknown={}, " + + "stale={}, elapsedMs={}, failureRate={}", + seedTaskId, + claimedTasks.size(), + results.size(), + failed, + unknown, + stale, + elapsedMillis, + String.format(Locale.ROOT, "%.2f%%", failureRate)); + } + + private List claimCompatibleBatch(long taskId, String leaseToken) { + JobAnalysisTaskStore.TaskRecord pending = taskStore.findById(taskId); + if (pending == null || pending.statusEnum() != JobAnalysisTaskStore.Status.PENDING) return List.of(); + String groupKey = pending.profileId() + ":" + Objects.toString(pending.platform(), "").toLowerCase(); + Object lock = batchClaimLocks.computeIfAbsent(groupKey, ignored -> new Object()); + synchronized (lock) { + JobAnalysisTaskStore.TaskRecord seed = taskStore.claim(taskId, leaseToken, LEASE_DURATION); + if (seed == null) return List.of(); + List claimedTasks = new ArrayList<>(); + claimedTasks.add(seed); try { - if (!taskStore.renewLease(taskId, leaseToken, LEASE_DURATION)) { - log.warn("AI 任务 {} 的租约续期被拒绝,旧执行结果将被租约校验丢弃", taskId); + for (JobAnalysisTaskStore.TaskRecord candidate : taskStore.listCompatibleDuePending( + seed.profileId(), seed.platform(), JobAiAnalysisService.MAX_BATCH_SIZE - 1)) { + try { + JobAnalysisTaskStore.TaskRecord claimed = taskStore.claim( + candidate.id(), leaseToken, LEASE_DURATION); + if (claimed != null) claimedTasks.add(claimed); + } catch (RuntimeException claimError) { + log.warn("批量领取兼容 AI 任务 {} 失败,保留已领取任务继续执行: {}", + candidate.id(), claimError.getMessage()); + } + } + } catch (RuntimeException lookupError) { + log.warn("查询兼容 AI 任务失败,保留种子任务 {} 继续执行: {}", + seed.id(), lookupError.getMessage()); + } + return claimedTasks; + } + } + + private void emitAnalysisStarted(long taskId, JobAiAnalysisService.JobAnalysisRequest request) { + AnalysisJob runtimeJob = runtimeJobs.get(taskId); + Consumer progress = runtimeJob == null ? null : runtimeJob.getProgressCallback(); + emit(progress, JobProgressMessage.progress( + Objects.toString(request.getPlatform(), ""), + "AI分析中:" + Objects.toString(request.getJobName(), ""), + runtimeJob == null ? 0 : runtimeJob.getCurrent(), + runtimeJob == null ? 0 : runtimeJob.getTotal())); + } + + private void completeTask(JobAnalysisTaskStore.TaskRecord task, + String leaseToken, + JobAiAnalysisService.JobAnalysisRequest request, + JobAiAnalysisService.AnalysisResult result) { + if (result.isStaleLease()) { + log.warn("AI 任务 {} 的租约已失效,旧执行结果已丢弃", task.id()); + return; + } + boolean failed = result.isFailure(); + String summary = Objects.toString(result.getSummary(), ""); + boolean completed = result.isProviderOutcomeUnknown() + ? taskStore.completeUnknown(task.id(), leaseToken, summary) + : taskStore.complete(task.id(), leaseToken, failed, summary); + if (!completed && !result.isProviderOutcomeUnknown()) { + reconcileLateWorkerResult(task, leaseToken, failed, summary); + } + if (!completed && result.isProviderOutcomeUnknown()) { + log.warn("AI 任务 {} 的 UNKNOWN 终态写入未命中当前租约,将等待过期对账", task.id()); + } + + AnalysisJob runtimeJob = runtimeJobs.get(task.id()); + Consumer progress = runtimeJob == null ? null : runtimeJob.getProgressCallback(); + String platform = Objects.toString(request.getPlatform(), ""); + String jobName = Objects.toString(request.getJobName(), ""); + if (result.isProviderOutcomeUnknown()) { + emit(progress, JobProgressMessage.warning( + platform, "AI分析结果未知:" + jobName + "," + summary)); + } else if (failed) { + emit(progress, JobProgressMessage.warning(platform, "AI分析失败:" + jobName + "," + summary)); + } + String completionLabel = "跳过:"; + if (result.shouldApply()) { + completionLabel = DeliveryStatus.WAITING_CONFIRM + ":"; + } else if (result.isProviderOutcomeUnknown()) { + completionLabel = "结果未知:"; + } else if (failed) { + completionLabel = DeliveryStatus.AI_ANALYSIS_FAILED + ":"; + } + emit(progress, JobProgressMessage.progress( + platform, + completionLabel + jobName, + runtimeJob == null ? 0 : runtimeJob.getCurrent(), + runtimeJob == null ? 0 : runtimeJob.getTotal())); + } + + private ScheduledFuture startLeaseHeartbeat(List taskIds, String leaseToken) { + return leaseHeartbeatExecutor.scheduleAtFixedRate(() -> { + for (Long taskId : taskIds) { + try { + if (!taskStore.renewLease(taskId, leaseToken, LEASE_DURATION)) { + log.warn("AI 任务 {} 的租约续期被拒绝,旧执行结果将被租约校验丢弃", taskId); + } + } catch (Exception e) { + log.warn("AI 任务 {} 的租约续期失败: {}", taskId, e.getMessage()); } - } catch (Exception e) { - log.warn("AI 任务 {} 的租约续期失败: {}", taskId, e.getMessage()); } }, LEASE_HEARTBEAT_SECONDS, LEASE_HEARTBEAT_SECONDS, TimeUnit.SECONDS); } diff --git a/src/main/java/com/getjobs/application/service/JobAiAnalysisService.java b/src/main/java/com/getjobs/application/service/JobAiAnalysisService.java index af0d996..5b11f7e 100644 --- a/src/main/java/com/getjobs/application/service/JobAiAnalysisService.java +++ b/src/main/java/com/getjobs/application/service/JobAiAnalysisService.java @@ -32,12 +32,14 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Base64; -import java.util.HashMap; import java.util.HexFormat; +import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.atomic.AtomicBoolean; @@ -50,21 +52,73 @@ @RequiredArgsConstructor @DependsOn("databaseSchemaService") public class JobAiAnalysisService { + public static final int MAX_BATCH_SIZE = 5; private static final String JOB_ANALYSIS_OUTPUT_SCHEMA = """ { "type": "object", "properties": { - "score": {"type": "integer", "minimum": 0, "maximum": 100}, - "decision": {"type": "string", "enum": ["APPLY", "SKIP"]}, - "summary": {"type": "string"}, - "strengths": {"type": "array", "items": {"type": "string"}}, - "risks": {"type": "array", "items": {"type": "string"}}, - "greeting": {"type": "string"} + "results": { + "type": "array", + "maxItems": 5, + "items": { + "type": "object", + "properties": { + "taskId": {"type": "integer"}, + "summary": {"type": "string"}, + "matches": {"type": "array", "items": {"type": "string"}}, + "gaps": {"type": "array", "items": {"type": "string"}}, + "unknowns": {"type": "array", "items": {"type": "string"}}, + "dimensions": { + "type": "array", + "minItems": 6, + "maxItems": 6, + "items": { + "type": "object", + "properties": { + "key": {"type": "string", "enum": ["CORE_SKILLS", "RELEVANT_EXPERIENCE", "ACHIEVEMENTS_COMPLEXITY", "INDUSTRY_TRANSFER", "EDUCATION_TENURE", "LOCATION_SALARY"]}, + "status": {"type": "string", "enum": ["MATCH", "PARTIAL", "UNKNOWN", "CONFLICT"]}, + "jobEvidence": {"type": "array", "items": {"type": "string"}}, + "resumeEvidence": {"type": "array", "items": {"type": "string"}}, + "note": {"type": "string"} + }, + "required": ["key", "status", "jobEvidence", "resumeEvidence", "note"], + "additionalProperties": false + } + }, + "hardConflicts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "requirement": {"type": "string"}, + "jobEvidence": {"type": "array", "items": {"type": "string"}}, + "resumeEvidence": {"type": "array", "items": {"type": "string"}} + }, + "required": ["requirement", "jobEvidence", "resumeEvidence"], + "additionalProperties": false + } + }, + "greeting": {"type": "string"} + }, + "required": ["taskId", "summary", "matches", "gaps", "unknowns", "dimensions", "hardConflicts", "greeting"], + "additionalProperties": false + } + } }, - "required": ["score", "decision", "summary", "strengths", "risks", "greeting"], + "required": ["results"], "additionalProperties": false } """; + private static final List DIMENSION_SPECS = List.of( + new DimensionSpec("CORE_SKILLS", "核心职责与技能", 35), + new DimensionSpec("RELEVANT_EXPERIENCE", "相关经历", 25), + new DimensionSpec("ACHIEVEMENTS_COMPLEXITY", "成果与复杂度", 15), + new DimensionSpec("INDUSTRY_TRANSFER", "行业可迁移性", 10), + new DimensionSpec("EDUCATION_TENURE", "学历与年限", 10), + new DimensionSpec("LOCATION_SALARY", "地点与薪资", 5) + ); + private static final Map DIMENSION_BY_KEY = DIMENSION_SPECS.stream() + .collect(Collectors.toUnmodifiableMap(DimensionSpec::key, spec -> spec)); public static final int DEFAULT_APPLY_THRESHOLD = 75; public static final int DEFAULT_PRIORITY_APPLY_THRESHOLD = 65; @@ -230,98 +284,192 @@ public AnalysisResult analyzeJob(JobAnalysisRequest request) { public AnalysisResult analyzeJob(JobAnalysisRequest request, BooleanSupplier leaseIsCurrent, LeaseWriteGuard leaseWriteGuard) { - if (request == null) throw new IllegalArgumentException("岗位分析请求不能为空"); - if (!isLeaseCurrent(leaseIsCurrent)) return AnalysisResult.staleLease(); - Long profileId = resolveAnalysisProfileId(request); - request.setProfileId(profileId); - if (!isLeaseCurrent(leaseIsCurrent)) return AnalysisResult.staleLease(); - AtomicBoolean platformReserved = new AtomicBoolean(); - if (!executeLeaseWrite(leaseWriteGuard, - () -> platformReserved.set(markPlatformAnalysisStarted(request)))) { - return AnalysisResult.staleLease(); - } - if (!platformReserved.get()) { - return AnalysisResult.failed( - DeliveryStatus.AI_ANALYSIS_FAILED, - "岗位状态已变化或岗位不存在,未调用 AI Provider" - ); + BatchAnalysisJob job = new BatchAnalysisJob(1L, request, leaseIsCurrent, leaseWriteGuard); + return analyzeJobs(List.of(job)).getOrDefault(1L, + AnalysisResult.failed(DeliveryStatus.AI_ANALYSIS_FAILED, "AI 分析未返回结果")); + } + + /** + * 同一档案、同一平台的岗位批量分析。模型只判断维度与证据,分数和决策始终由后端计算。 + */ + public Map analyzeJobs(List jobs) { + validateBatch(jobs); + Map completed = new LinkedHashMap<>(); + List prepared = new ArrayList<>(); + + for (BatchAnalysisJob job : jobs) { + JobAnalysisRequest request = job.request(); + if (!isLeaseCurrent(job.leaseIsCurrent())) { + completed.put(job.taskId(), AnalysisResult.staleLease()); + continue; + } + Long profileId = resolveAnalysisProfileId(request); + request.setProfileId(profileId); + AtomicBoolean platformReserved = new AtomicBoolean(); + if (!executeLeaseWrite(job.leaseWriteGuard(), + () -> platformReserved.set(markPlatformAnalysisStarted(request)))) { + completed.put(job.taskId(), AnalysisResult.staleLease()); + continue; + } + if (!platformReserved.get()) { + completed.put(job.taskId(), AnalysisResult.failed( + DeliveryStatus.AI_ANALYSIS_FAILED, + "岗位状态已变化或岗位不存在,未调用 AI Provider")); + continue; + } + boolean priority = isPriorityCompany(request.getCompanyName(), profileId); + prepared.add(new PreparedJob( + job, + priority, + resolveApplyThreshold(profileId, priority) + )); } - boolean priority = isPriorityCompany(request.getCompanyName(), profileId); - int threshold = resolveApplyThreshold(profileId, priority); + + if (prepared.isEmpty()) return completed; + Long profileId = prepared.get(0).job().request().getProfileId(); ResumeProfileEntity resume = getResumeProfile(profileId); String resumeText = resume == null ? "" : resume.getResumeText(); if (resumeText == null || resumeText.trim().isEmpty()) { - if (!isLeaseCurrent(leaseIsCurrent)) return AnalysisResult.staleLease(); - AnalysisResult result = AnalysisResult.failed(DeliveryStatus.AI_ANALYSIS_FAILED, "请先在AI配置页保存简历内容"); - result.setErrorCode("AI_RESUME_MISSING"); - result.setPriorityCompany(priority); - AtomicReference storedResult = new AtomicReference<>(result); - if (!executeLeaseWrite(leaseWriteGuard, () -> { - storedResult.set(persistAndUpdate( - request, result, "{\"errorCode\":\"AI_RESUME_MISSING\"}", false)); - })) { - return AnalysisResult.staleLease(); + for (PreparedJob job : prepared) { + AnalysisResult failure = AnalysisResult.failed( + DeliveryStatus.AI_ANALYSIS_FAILED, "请先在AI配置页保存简历内容"); + failure.setErrorCode("AI_RESUME_MISSING"); + completed.put(job.job().taskId(), finalizeResult( + job, failure, "{\"errorCode\":\"AI_RESUME_MISSING\"}", false)); } - return storedResult.get(); + return completed; } - String prompt = buildPrompt(resumeText, request, priority, threshold); - String raw; + String prompt = buildBatchPrompt(resumeText, prepared); + String raw = null; try { raw = aiService.sendStructuredRequest(prompt, JOB_ANALYSIS_OUTPUT_SCHEMA); - AnalysisResult result; + BatchParse parsed; try { - result = parseResult(raw); + parsed = parseBatchResults(raw, prepared.stream() + .map(job -> job.job().taskId()).toList()); } catch (AiOutputException outputError) { - if (!"AI_OUTPUT_INVALID_JSON".equals(outputError.code())) throw outputError; - if (!isLeaseCurrent(leaseIsCurrent)) return AnalysisResult.staleLease(); - log.warn("AI岗位分析返回无效 JSON,将使用同一 Provider、模型和 Schema 重试一次: {}", outputError.getMessage()); + if (!isWholeBatchFormatError(outputError)) throw outputError; + if (prepared.stream().noneMatch(job -> isLeaseCurrent(job.job().leaseIsCurrent()))) { + prepared.forEach(job -> completed.putIfAbsent( + job.job().taskId(), AnalysisResult.staleLease())); + return completed; + } + log.warn("AI岗位批量分析返回无效 JSON,将使用同一 Provider、模型和 Schema 重试一次: {}", + outputError.getMessage()); raw = aiService.sendStructuredRequest( - prompt + "\n\n重要:上一次输出不是有效 JSON。本次只返回一个完全符合 Schema 的 JSON 对象,不要输出 Markdown、解释或额外文本。", + prompt + "\n\n重要:上一次输出不是有效的批量 JSON。本次只返回一个完全符合 Schema 的 JSON 对象,不要输出 Markdown、解释或额外文本。", JOB_ANALYSIS_OUTPUT_SCHEMA ); - result = parseResult(raw); + parsed = parseBatchResults(raw, prepared.stream() + .map(job -> job.job().taskId()).toList()); } - result.setPriorityCompany(priority); - result.setThreshold(threshold); - if (result.getScore() == null) result.setScore(0); - if (result.getDecision() == null || result.getDecision().isBlank()) { - result.setDecision(result.getScore() >= threshold ? "APPLY" : "SKIP"); + + Map byTaskId = prepared.stream().collect(Collectors.toMap( + job -> job.job().taskId(), job -> job, (left, right) -> left, LinkedHashMap::new)); + for (Map.Entry entry : parsed.results().entrySet()) { + PreparedJob job = byTaskId.get(entry.getKey()); + if (job != null) { + verifyQuotedEvidence(entry.getValue(), job.job().request(), resumeText); + completed.put(entry.getKey(), finalizeResult( + job, entry.getValue(), responseDiagnostic(raw), true)); + } } - if (!"APPLY".equalsIgnoreCase(result.getDecision()) && result.getScore() >= threshold) { - result.setDecision("APPLY"); + for (Map.Entry entry : parsed.errors().entrySet()) { + PreparedJob job = byTaskId.get(entry.getKey()); + if (job == null || completed.containsKey(entry.getKey())) continue; + completed.put(entry.getKey(), retrySingleInvalidJob( + resumeText, job, entry.getValue())); } - if ("APPLY".equalsIgnoreCase(result.getDecision()) && result.getScore() < threshold) { - result.setDecision("SKIP"); + return completed; + } catch (Exception e) { + log.warn("AI岗位批量分析失败: {}", e.getMessage()); + for (PreparedJob job : prepared) { + if (completed.containsKey(job.job().taskId())) continue; + completed.put(job.job().taskId(), finalizeFailure(job, e, true)); } - if (!isLeaseCurrent(leaseIsCurrent)) return AnalysisResult.staleLease(); - AnalysisResult finalResult = result; - String finalRaw = raw; - AtomicReference storedResult = new AtomicReference<>(finalResult); - if (!executeLeaseWrite(leaseWriteGuard, () -> { - storedResult.set(persistAndUpdate( - request, finalResult, responseDiagnostic(finalRaw), true)); - })) { - return AnalysisResult.staleLease(); + return completed; + } + } + + private AnalysisResult retrySingleInvalidJob(String resumeText, + PreparedJob job, + AiOutputException initialError) { + if (!isLeaseCurrent(job.job().leaseIsCurrent())) return AnalysisResult.staleLease(); + try { + log.warn("AI岗位批量结果中的任务 {} 缺失或无效,将只重试该岗位一次: {}", + job.job().taskId(), initialError.getMessage()); + String retryPrompt = buildBatchPrompt(resumeText, List.of(job)) + + "\n\n重要:上一次批量结果中这个岗位缺失或字段无效。本次只返回这个 taskId 的完整结果。"; + String retryRaw = aiService.sendStructuredRequest(retryPrompt, JOB_ANALYSIS_OUTPUT_SCHEMA); + BatchParse retried = parseBatchResults(retryRaw, List.of(job.job().taskId())); + AnalysisResult result = retried.results().get(job.job().taskId()); + if (result == null) { + throw retried.errors().getOrDefault(job.job().taskId(), initialError); } - return storedResult.get(); + verifyQuotedEvidence(result, job.job().request(), resumeText); + return finalizeResult(job, result, responseDiagnostic(retryRaw), true); } catch (Exception e) { - log.warn("AI岗位分析失败: {}", e.getMessage()); - if (!isLeaseCurrent(leaseIsCurrent)) return AnalysisResult.staleLease(); - AnalysisResult result = AnalysisResult.failed(DeliveryStatus.AI_ANALYSIS_FAILED, e.getMessage()); - result.setErrorCode(errorCode(e)); - result.setProviderOutcomeUnknown( - e instanceof AiProviderException providerError && providerError.isOutcomeUnknown()); - result.setPriorityCompany(priority); - result.setThreshold(threshold); - AtomicReference storedResult = new AtomicReference<>(result); - if (!executeLeaseWrite(leaseWriteGuard, () -> { - storedResult.set(persistAndUpdate( - request, result, errorDiagnostic(e), true)); - })) { - return AnalysisResult.staleLease(); + return finalizeFailure(job, e, true); + } + } + + private AnalysisResult finalizeFailure(PreparedJob job, Exception error, boolean providerWasCalled) { + if (!isLeaseCurrent(job.job().leaseIsCurrent())) return AnalysisResult.staleLease(); + AnalysisResult result = AnalysisResult.failed( + DeliveryStatus.AI_ANALYSIS_FAILED, + error == null ? "AI 分析失败" : error.getMessage()); + result.setErrorCode(errorCode(error)); + result.setProviderOutcomeUnknown( + error instanceof AiProviderException providerError && providerError.isOutcomeUnknown()); + return finalizeResult(job, result, errorDiagnostic(error), providerWasCalled); + } + + private AnalysisResult finalizeResult(PreparedJob job, + AnalysisResult result, + String diagnostic, + boolean providerWasCalled) { + result.setPriorityCompany(job.priority()); + result.setThreshold(job.threshold()); + if (!result.isFailure() && !result.isStaleLease()) { + boolean hasHardConflict = result.getHardConflicts() != null + && !result.getHardConflicts().isEmpty(); + result.setDecision(!hasHardConflict && result.getScore() != null + && result.getScore() >= job.threshold() ? "APPLY" : "SKIP"); + } + if (!isLeaseCurrent(job.job().leaseIsCurrent())) return AnalysisResult.staleLease(); + AtomicReference storedResult = new AtomicReference<>(result); + if (!executeLeaseWrite(job.job().leaseWriteGuard(), () -> storedResult.set(persistAndUpdate( + job.job().request(), result, diagnostic, providerWasCalled)))) { + return AnalysisResult.staleLease(); + } + return storedResult.get(); + } + + private boolean isWholeBatchFormatError(AiOutputException error) { + return error != null && Set.of( + "AI_OUTPUT_EMPTY", "AI_OUTPUT_INVALID_JSON", "AI_OUTPUT_INVALID_BATCH" + ).contains(error.code()); + } + + private void validateBatch(List jobs) { + if (jobs == null || jobs.isEmpty()) throw new IllegalArgumentException("岗位分析批次不能为空"); + if (jobs.size() > MAX_BATCH_SIZE) throw new IllegalArgumentException("岗位分析批次最多包含5个岗位"); + Set taskIds = new HashSet<>(); + Long profileId = null; + String platform = null; + for (BatchAnalysisJob job : jobs) { + if (job == null || job.request() == null) throw new IllegalArgumentException("岗位分析请求不能为空"); + if (job.taskId() <= 0 || !taskIds.add(job.taskId())) { + throw new IllegalArgumentException("岗位分析批次包含无效或重复的 taskId"); + } + Long resolvedProfileId = resolveAnalysisProfileId(job.request()); + String normalizedPlatform = safe(job.request().getPlatform()).trim().toLowerCase(Locale.ROOT); + if (profileId == null) profileId = resolvedProfileId; + if (platform == null) platform = normalizedPlatform; + if (!Objects.equals(profileId, resolvedProfileId) || !Objects.equals(platform, normalizedPlatform)) { + throw new IllegalArgumentException("岗位分析批次只能包含同一档案和同一平台的任务"); } - return storedResult.get(); } } @@ -368,64 +516,277 @@ public List generateBossSearchKeywords(List existingKeywords, in .collect(Collectors.toList()); } - private String buildPrompt(String resumeText, JobAnalysisRequest request, boolean priority, int threshold) { - return "你是求职投递决策助手。请根据候选人简历和岗位信息判断是否值得自动投递。\n" + - "只返回JSON,不要使用Markdown代码块。JSON字段必须包含 score, decision, summary, strengths, risks, greeting。\n" + - "score 必须是0到100之间的整数,请综合评估核心技能、工作经验、学历、地点、薪资和岗位硬性要求,不要为了达到阈值而抬高分数。\n" + - "decision 只能是 APPLY 或 SKIP。当前公司" + - (priority ? "是" : "不是") + "优先公司,当前阈值为" + threshold + "。\n" + - "简历:\n" + limit(resumeText, 6000) + "\n\n" + - "平台:" + safe(request.getPlatform()) + "\n" + - "搜索关键词:" + safe(request.getKeyword()) + "\n" + - "公司:" + safe(request.getCompanyName()) + "\n" + - "岗位:" + safe(request.getJobName()) + "\n" + - "薪资:" + safe(request.getSalary()) + "\n" + - "地点:" + safe(request.getLocation()) + "\n" + - "经验:" + safe(request.getExperience()) + "\n" + - "学历:" + safe(request.getDegree()) + "\n" + - "公司信息:" + safe(request.getCompanyInfo()) + "\n" + - "岗位描述:\n" + limit(safe(request.getJobDescription()), 5000) + "\n"; - } - - private AnalysisResult parseResult(String raw) { - JSONObject obj = new JSONObject(repairJsonObject(extractJson(raw))); - for (String field : List.of("score", "decision", "summary", "strengths", "risks", "greeting")) { - if (!obj.has(field) || obj.isNull(field)) { - throw outputError("AI_OUTPUT_MISSING_FIELD", "AI 返回缺少字段: " + field, raw); + private String buildBatchPrompt(String resumeText, List jobs) { + JSONArray jobArray = new JSONArray(); + for (PreparedJob prepared : jobs) { + JobAnalysisRequest request = prepared.job().request(); + JSONObject job = new JSONObject(); + job.put("taskId", prepared.job().taskId()); + job.put("platform", safe(request.getPlatform())); + job.put("keyword", safe(request.getKeyword())); + job.put("companyName", safe(request.getCompanyName())); + job.put("jobName", safe(request.getJobName())); + job.put("salary", safe(request.getSalary())); + job.put("location", safe(request.getLocation())); + job.put("experience", safe(request.getExperience())); + job.put("degree", safe(request.getDegree())); + job.put("companyInfo", limit(safe(request.getCompanyInfo()), 2000)); + job.put("jobDescription", limit(safe(request.getJobDescription()), 5000)); + jobArray.put(job); + } + return "你是求职岗位证据分析助手。请比较一份候选人简历和多个岗位,但不要计算分数,也不要给出 APPLY/SKIP 决策。\n" + + "只返回符合 Schema 的 JSON,不要使用 Markdown 或额外解释。每个输入 taskId 必须且只能返回一次。\n" + + "六个维度必须各返回一次:CORE_SKILLS、RELEVANT_EXPERIENCE、ACHIEVEMENTS_COMPLEXITY、INDUSTRY_TRANSFER、EDUCATION_TENURE、LOCATION_SALARY。\n" + + "每个维度的 status 只能是 MATCH、PARTIAL、UNKNOWN、CONFLICT。\n" + + "采用宁可多投原则:简历没有写明的信息只能判 UNKNOWN,不能推断为不具备;只有岗位明确要求且简历明确冲突时才能判 CONFLICT。\n" + + "jobEvidence 和 resumeEvidence 必须摘录对应原文短句。硬冲突必须同时具有岗位原文和简历原文,并复用对应 CONFLICT 分项中的双方证据;证据不足的差异放入 unknowns,不得放入 hardConflicts。\n" + + "summary 用一句自然中文给出总体结论,不要提分数、阈值或投递决策;matches 写具体匹配证据,gaps 只写有明确证据的差距,unknowns 写待核实信息。\n" + + "greeting 生成一条基于真实匹配点、不过度承诺的简短招呼语。\n\n" + + "候选人简历(本批岗位共用,只出现一次):\n" + limit(resumeText, 6000) + "\n\n" + + "待分析岗位 JSON:\n" + jobArray; + } + + private BatchParse parseBatchResults(String raw, List expectedTaskIds) { + JSONObject root = new JSONObject(repairJsonObject(extractJson(raw))); + if (!(root.opt("results") instanceof JSONArray values)) { + throw outputError("AI_OUTPUT_INVALID_BATCH", "AI 返回缺少 results 数组", raw); + } + Set expected = new HashSet<>(expectedTaskIds); + Set seen = new HashSet<>(); + Map results = new LinkedHashMap<>(); + Map errors = new LinkedHashMap<>(); + for (int i = 0; i < values.length(); i++) { + Object value = values.opt(i); + if (!(value instanceof JSONObject item)) continue; + Object taskIdValue = item.opt("taskId"); + if (!(taskIdValue instanceof Number number) + || number.doubleValue() != Math.rint(number.doubleValue())) continue; + long taskId = number.longValue(); + if (!expected.contains(taskId)) continue; + if (!seen.add(taskId)) { + errors.put(taskId, outputError( + "AI_OUTPUT_INVALID_SCHEMA", "AI 返回重复 taskId: " + taskId, raw)); + results.remove(taskId); + continue; + } + try { + results.put(taskId, parseEvidenceResult(item, raw)); + } catch (AiOutputException e) { + errors.put(taskId, e); } } - Object scoreValue = obj.opt("score"); - if (!(scoreValue instanceof Number number) - || number.doubleValue() != Math.rint(number.doubleValue()) - || number.intValue() < 0 - || number.intValue() > 100) { - throw outputError("AI_OUTPUT_INVALID_SCORE", "AI 返回 score 必须是 0 到 100 的整数", raw); + for (Long taskId : expectedTaskIds) { + if (!results.containsKey(taskId) && !errors.containsKey(taskId)) { + errors.put(taskId, outputError( + "AI_OUTPUT_MISSING_ITEM", "AI 返回缺少 taskId: " + taskId, raw)); + } } - String decision = obj.optString("decision", "").trim().toUpperCase(Locale.ROOT); - if (!"APPLY".equals(decision) && !"SKIP".equals(decision)) { - throw outputError("AI_OUTPUT_INVALID_DECISION", "AI 返回 decision 必须是 APPLY 或 SKIP", raw); + return new BatchParse(results, errors); + } + + private AnalysisResult parseEvidenceResult(JSONObject item, String raw) { + for (String field : List.of( + "summary", "matches", "gaps", "unknowns", "dimensions", "hardConflicts", "greeting")) { + if (!item.has(field) || item.isNull(field)) { + throw outputError("AI_OUTPUT_MISSING_FIELD", "AI 返回缺少字段: " + field, raw); + } } - if (!(obj.opt("summary") instanceof String summary) || summary.isBlank()) { + if (!(item.opt("summary") instanceof String summary) || summary.isBlank()) { throw outputError("AI_OUTPUT_INVALID_SCHEMA", "AI 返回 summary 不能为空", raw); } - if (!(obj.opt("strengths") instanceof JSONArray strengths) - || !(obj.opt("risks") instanceof JSONArray risks) - || !(obj.opt("greeting") instanceof String)) { + if (!(item.opt("matches") instanceof JSONArray matches) + || !(item.opt("gaps") instanceof JSONArray gaps) + || !(item.opt("unknowns") instanceof JSONArray unknowns) + || !(item.opt("dimensions") instanceof JSONArray dimensions) + || !(item.opt("hardConflicts") instanceof JSONArray hardConflicts) + || !(item.opt("greeting") instanceof String greeting) + || !containsOnlyStrings(matches) + || !containsOnlyStrings(gaps) + || !containsOnlyStrings(unknowns)) { throw outputError("AI_OUTPUT_INVALID_SCHEMA", "AI 返回字段类型不符合约定", raw); } - if (!containsOnlyStrings(strengths) || !containsOnlyStrings(risks)) { - throw outputError("AI_OUTPUT_INVALID_SCHEMA", "AI 返回 strengths/risks 必须是字符串数组", raw); - } + + List unknownItems = new ArrayList<>(toStringList(unknowns)); + List dimensionScores = parseDimensions(dimensions, unknownItems, raw); + List validHardConflicts = parseHardConflicts(hardConflicts, unknownItems, raw); + int score = (int) Math.round(dimensionScores.stream() + .mapToDouble(value -> value.getWeight() * MatchStatus.valueOf(value.getStatus()).factor) + .sum()); + AnalysisResult result = new AnalysisResult(); - result.setScore(number.intValue()); - result.setDecision(decision); - result.setSummary(summary); - result.setStrengths(toStringList(obj.opt("strengths"))); - result.setRisks(toStringList(obj.opt("risks"))); - result.setGreeting(obj.optString("greeting", "")); + result.setScore(score); + result.setDecision("SKIP"); + result.setSummary(summary.trim()); + result.setMatches(toStringList(matches)); + result.setGaps(toStringList(gaps)); + result.setUnknowns(List.copyOf(unknownItems)); + result.setDimensions(dimensionScores); + result.setHardConflicts(validHardConflicts); + result.setStrengths(result.getMatches()); + result.setRisks(result.getGaps()); + result.setGreeting(greeting.trim()); return result; } + private List parseDimensions(JSONArray dimensions, + List unknowns, + String raw) { + if (dimensions.length() != DIMENSION_SPECS.size()) { + throw outputError("AI_OUTPUT_INVALID_SCHEMA", "AI 返回必须包含六个评分维度", raw); + } + Map parsed = new LinkedHashMap<>(); + for (int i = 0; i < dimensions.length(); i++) { + Object value = dimensions.opt(i); + if (!(value instanceof JSONObject dimension)) { + throw outputError("AI_OUTPUT_INVALID_SCHEMA", "AI 返回维度必须是对象", raw); + } + String key = dimension.optString("key", "").trim().toUpperCase(Locale.ROOT); + String statusText = dimension.optString("status", "").trim().toUpperCase(Locale.ROOT); + DimensionSpec spec = DIMENSION_BY_KEY.get(key); + MatchStatus status; + try { + status = MatchStatus.valueOf(statusText); + } catch (IllegalArgumentException e) { + throw outputError("AI_OUTPUT_INVALID_SCHEMA", "AI 返回未知维度状态: " + statusText, raw); + } + if (spec == null || parsed.containsKey(key) + || !(dimension.opt("jobEvidence") instanceof JSONArray jobEvidence) + || !(dimension.opt("resumeEvidence") instanceof JSONArray resumeEvidence) + || !(dimension.opt("note") instanceof String note) + || !containsOnlyStrings(jobEvidence) + || !containsOnlyStrings(resumeEvidence)) { + throw outputError("AI_OUTPUT_INVALID_SCHEMA", "AI 返回维度字段无效或重复", raw); + } + List jobEvidenceItems = toStringList(jobEvidence); + List resumeEvidenceItems = toStringList(resumeEvidence); + if (status == MatchStatus.CONFLICT + && (jobEvidenceItems.isEmpty() || resumeEvidenceItems.isEmpty())) { + status = MatchStatus.UNKNOWN; + unknowns.add(spec.label() + "存在差异描述,但缺少双方原文证据,已降级为待核实"); + } + DimensionScore score = new DimensionScore(); + score.setKey(spec.key()); + score.setLabel(spec.label()); + score.setWeight(spec.weight()); + score.setStatus(status.name()); + score.setAwarded(spec.weight() * status.factor); + score.setJobEvidence(jobEvidenceItems); + score.setResumeEvidence(resumeEvidenceItems); + score.setNote(note.trim()); + parsed.put(key, score); + } + if (!parsed.keySet().equals(DIMENSION_BY_KEY.keySet())) { + throw outputError("AI_OUTPUT_INVALID_SCHEMA", "AI 返回评分维度不完整", raw); + } + return DIMENSION_SPECS.stream().map(spec -> parsed.get(spec.key())).toList(); + } + + private List parseHardConflicts(JSONArray conflicts, + List unknowns, + String raw) { + List valid = new ArrayList<>(); + for (int i = 0; i < conflicts.length(); i++) { + Object value = conflicts.opt(i); + if (!(value instanceof JSONObject conflict) + || !(conflict.opt("requirement") instanceof String requirement) + || !(conflict.opt("jobEvidence") instanceof JSONArray jobEvidence) + || !(conflict.opt("resumeEvidence") instanceof JSONArray resumeEvidence) + || !containsOnlyStrings(jobEvidence) + || !containsOnlyStrings(resumeEvidence)) { + throw outputError("AI_OUTPUT_INVALID_SCHEMA", "AI 返回硬冲突字段无效", raw); + } + List jobEvidenceItems = toStringList(jobEvidence); + List resumeEvidenceItems = toStringList(resumeEvidence); + if (requirement.isBlank() || jobEvidenceItems.isEmpty() || resumeEvidenceItems.isEmpty()) { + String label = requirement.isBlank() ? "疑似硬性要求" : requirement.trim(); + unknowns.add(label + "缺少双方原文证据,已降级为待核实"); + continue; + } + HardConflict hardConflict = new HardConflict(); + hardConflict.setRequirement(requirement.trim()); + hardConflict.setJobEvidence(jobEvidenceItems); + hardConflict.setResumeEvidence(resumeEvidenceItems); + valid.add(hardConflict); + } + return List.copyOf(valid); + } + + private void verifyQuotedEvidence(AnalysisResult result, + JobAnalysisRequest request, + String resumeText) { + if (result == null) return; + String jobSource = String.join("\n", + safe(request.getKeyword()), + safe(request.getCompanyName()), + safe(request.getJobName()), + safe(request.getSalary()), + safe(request.getLocation()), + safe(request.getExperience()), + safe(request.getDegree()), + safe(request.getCompanyInfo()), + safe(request.getJobDescription())); + List unknowns = new ArrayList<>(result.getUnknowns() == null + ? List.of() : result.getUnknowns()); + for (DimensionScore dimension : result.getDimensions() == null + ? List.of() : result.getDimensions()) { + MatchStatus status = MatchStatus.valueOf(dimension.getStatus()); + if (status == MatchStatus.UNKNOWN) continue; + if (quotesExist(dimension.getJobEvidence(), jobSource) + && quotesExist(dimension.getResumeEvidence(), resumeText)) continue; + dimension.setStatus(MatchStatus.UNKNOWN.name()); + dimension.setAwarded(dimension.getWeight() * MatchStatus.UNKNOWN.factor); + unknowns.add(dimension.getLabel() + "的双方原文证据无法核验,已降级为待核实"); + } + + List conflictDimensions = (result.getDimensions() == null + ? List.of() : result.getDimensions()).stream() + .filter(dimension -> MatchStatus.CONFLICT.name().equals(dimension.getStatus())) + .toList(); + List verifiedHardConflicts = new ArrayList<>(); + for (HardConflict conflict : result.getHardConflicts() == null + ? List.of() : result.getHardConflicts()) { + if (quotesExist(conflict.getJobEvidence(), jobSource) + && quotesExist(conflict.getResumeEvidence(), resumeText) + && conflictDimensions.stream().anyMatch(dimension -> + sharesEvidence(conflict.getJobEvidence(), dimension.getJobEvidence()) + && sharesEvidence(conflict.getResumeEvidence(), dimension.getResumeEvidence()))) { + verifiedHardConflicts.add(conflict); + } else { + unknowns.add(conflict.getRequirement() + "的原文证据无法核验或与冲突分项不一致,已降级为待核实"); + } + } + + int verifiedScore = (int) Math.round((result.getDimensions() == null + ? List.of() : result.getDimensions()).stream() + .mapToDouble(value -> value.getAwarded() == null ? 0.0 : value.getAwarded()) + .sum()); + result.setScore(verifiedScore); + result.setUnknowns(List.copyOf(unknowns)); + result.setHardConflicts(List.copyOf(verifiedHardConflicts)); + } + + private boolean quotesExist(List quotes, String source) { + if (quotes == null || quotes.isEmpty()) return false; + String normalizedSource = normalizeEvidence(source); + return quotes.stream() + .map(this::normalizeEvidence) + .allMatch(quote -> !quote.isBlank() && normalizedSource.contains(quote)); + } + + private boolean sharesEvidence(List left, List right) { + if (left == null || right == null) return false; + Set normalizedRight = right.stream() + .map(this::normalizeEvidence) + .filter(value -> !value.isBlank()) + .collect(Collectors.toSet()); + return left.stream().map(this::normalizeEvidence) + .anyMatch(value -> !value.isBlank() && normalizedRight.contains(value)); + } + + private String normalizeEvidence(String value) { + return safe(value).replaceAll("\\s+", "").toLowerCase(Locale.ROOT); + } + private List parseKeywordArray(String raw) { String json = extractJsonArray(raw); JSONArray arr = new JSONArray(json); @@ -486,7 +847,10 @@ private String repairJsonObject(String raw) { } s = s.replaceAll(",\\s*([}\\]])", "$1"); - for (String key : List.of("score", "decision", "summary", "strengths", "risks", "greeting")) { + for (String key : List.of( + "results", "taskId", "summary", "matches", "gaps", "unknowns", "dimensions", + "hardConflicts", "greeting", "key", "status", "jobEvidence", "resumeEvidence", + "note", "requirement")) { s = s.replaceAll("(?m)([{,]\\s*)" + key + "\\s*:", "$1\"" + key + "\":"); } try { @@ -1124,6 +1488,35 @@ public static PlatformAnalysisState incomplete(String status) { } } + public record BatchAnalysisJob(long taskId, + JobAnalysisRequest request, + BooleanSupplier leaseIsCurrent, + LeaseWriteGuard leaseWriteGuard) { + } + + private record PreparedJob(BatchAnalysisJob job, boolean priority, int threshold) { + } + + private record BatchParse(Map results, + Map errors) { + } + + private record DimensionSpec(String key, String label, int weight) { + } + + private enum MatchStatus { + MATCH(1.0), + PARTIAL(0.75), + UNKNOWN(0.6), + CONFLICT(0.0); + + private final double factor; + + MatchStatus(double factor) { + this.factor = factor; + } + } + private static final class AiOutputException extends RuntimeException { private final String code; @@ -1144,11 +1537,17 @@ public interface LeaseWriteGuard { @Data public static class AnalysisResult { + private Integer schemaVersion = 2; private Integer score; private String decision; private String summary; private List strengths = new ArrayList<>(); private List risks = new ArrayList<>(); + private List matches = new ArrayList<>(); + private List gaps = new ArrayList<>(); + private List unknowns = new ArrayList<>(); + private List dimensions = new ArrayList<>(); + private List hardConflicts = new ArrayList<>(); private String greeting; private Boolean priorityCompany; private Integer threshold; @@ -1165,10 +1564,16 @@ public boolean isFailure() { } public String toReasonText() { - Map map = new HashMap<>(); + Map map = new LinkedHashMap<>(); + map.put("schemaVersion", schemaVersion); map.put("summary", summary); - map.put("strengths", strengths); - map.put("risks", risks); + map.put("matches", matches == null || matches.isEmpty() ? strengths : matches); + map.put("gaps", gaps == null || gaps.isEmpty() ? risks : gaps); + map.put("unknowns", unknowns); + map.put("dimensions", dimensions == null ? List.of() : dimensions.stream() + .map(DimensionScore::toMap).toList()); + map.put("hardConflicts", hardConflicts == null ? List.of() : hardConflicts.stream() + .map(HardConflict::toMap).toList()); map.put("threshold", threshold); map.put("errorCode", errorCode); return new JSONObject(map).toString(); @@ -1189,4 +1594,44 @@ public static AnalysisResult staleLease() { return result; } } + + @Data + public static class DimensionScore { + private String key; + private String label; + private Integer weight; + private String status; + private Double awarded; + private List jobEvidence = new ArrayList<>(); + private List resumeEvidence = new ArrayList<>(); + private String note; + + private Map toMap() { + Map map = new LinkedHashMap<>(); + map.put("key", key); + map.put("label", label); + map.put("weight", weight); + map.put("status", status); + map.put("awarded", awarded); + map.put("jobEvidence", jobEvidence); + map.put("resumeEvidence", resumeEvidence); + map.put("note", note); + return map; + } + } + + @Data + public static class HardConflict { + private String requirement; + private List jobEvidence = new ArrayList<>(); + private List resumeEvidence = new ArrayList<>(); + + private Map toMap() { + Map map = new LinkedHashMap<>(); + map.put("requirement", requirement); + map.put("jobEvidence", jobEvidence); + map.put("resumeEvidence", resumeEvidence); + return map; + } + } } diff --git a/src/main/java/com/getjobs/application/service/JobAnalysisTaskStore.java b/src/main/java/com/getjobs/application/service/JobAnalysisTaskStore.java index 5a27789..fcdfaa4 100644 --- a/src/main/java/com/getjobs/application/service/JobAnalysisTaskStore.java +++ b/src/main/java/com/getjobs/application/service/JobAnalysisTaskStore.java @@ -222,6 +222,19 @@ public List listDuePending(int limit) { TASK_MAPPER, dbTime(LocalDateTime.now()), safeLimit); } + public List listCompatibleDuePending(long profileId, String platform, int limit) { + int safeLimit = Math.max(1, Math.min(limit, JobAiAnalysisService.MAX_BATCH_SIZE - 1)); + return jdbcTemplate.query("SELECT " + selectColumns() + " FROM job_analysis_task " + + "WHERE profile_id=? AND lower(platform)=? AND task_key IS NOT NULL " + + "AND request_json IS NOT NULL AND status='PENDING' " + + "AND (next_retry_at IS NULL OR next_retry_at<=?) ORDER BY id LIMIT ?", + TASK_MAPPER, + profileId, + normalizePlatform(platform), + dbTime(LocalDateTime.now()), + safeLimit); + } + public List listExpiredLeases(int limit) { int safeLimit = Math.max(1, Math.min(limit, MAX_OUTSTANDING_TASKS)); return jdbcTemplate.query("SELECT " + selectColumns() + " FROM job_analysis_task " + @@ -420,7 +433,18 @@ public RetryResult retry(long taskId, long profileId, boolean confirmUnknown) { } public List listRecent(long profileId, int limit) { + return listRecent(profileId, null, limit); + } + + public List listRecent(long profileId, String platform, int limit) { int safeLimit = Math.max(1, Math.min(limit, 200)); + if (platform != null && !platform.isBlank()) { + return jdbcTemplate.query("SELECT " + selectColumns() + " FROM job_analysis_task " + + "WHERE profile_id=? AND lower(platform)=? AND task_key IS NOT NULL " + + "ORDER BY id DESC LIMIT ?", + TASK_MAPPER, profileId, normalizePlatform(platform), safeLimit) + .stream().map(TaskRecord::toView).toList(); + } return jdbcTemplate.query("SELECT " + selectColumns() + " FROM job_analysis_task " + "WHERE profile_id=? AND task_key IS NOT NULL ORDER BY id DESC LIMIT ?", TASK_MAPPER, profileId, safeLimit).stream().map(TaskRecord::toView).toList(); @@ -435,6 +459,20 @@ public int outstandingCount() { } public int outstandingCount(long profileId) { + return outstandingCount(profileId, null); + } + + public int outstandingCount(long profileId, String platform) { + if (platform != null && !platform.isBlank()) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM job_analysis_task WHERE profile_id=? AND lower(platform)=? " + + "AND task_key IS NOT NULL AND status IN ('PENDING','LEASED')", + Integer.class, + profileId, + normalizePlatform(platform) + ); + return count == null ? 0 : count; + } Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM job_analysis_task WHERE profile_id=? AND task_key IS NOT NULL " + "AND status IN ('PENDING','LEASED')", @@ -444,6 +482,43 @@ public int outstandingCount(long profileId) { return count == null ? 0 : count; } + public int pendingCount(long profileId) { + return pendingCount(profileId, null); + } + + public int pendingCount(long profileId, String platform) { + return statusCount(profileId, platform, Status.PENDING); + } + + public int processingCount(long profileId) { + return processingCount(profileId, null); + } + + public int processingCount(long profileId, String platform) { + return statusCount(profileId, platform, Status.LEASED); + } + + private int statusCount(long profileId, String platform, Status status) { + if (platform != null && !platform.isBlank()) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM job_analysis_task WHERE profile_id=? AND lower(platform)=? " + + "AND task_key IS NOT NULL AND status=?", + Integer.class, + profileId, + normalizePlatform(platform), + status.name() + ); + return count == null ? 0 : count; + } + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM job_analysis_task WHERE profile_id=? AND task_key IS NOT NULL AND status=?", + Integer.class, + profileId, + status.name() + ); + return count == null ? 0 : count; + } + public JobAiAnalysisService.JobAnalysisRequest deserialize(TaskRecord task) { if (task == null || task.requestJson() == null || task.requestJson().isBlank()) { throw new IllegalArgumentException("任务缺少可恢复的请求快照"); @@ -521,14 +596,25 @@ private String taskKey(JobAiAnalysisService.JobAnalysisRequest request, String p inputs.put("degree", canonical(request.getDegree())); inputs.put("companyInfo", canonical(request.getCompanyInfo())); inputs.put("jobDescription", canonical(request.getJobDescription())); + inputs.put("resumeFingerprint", currentResumeFingerprint(request.getProfileId())); try { String digest = sha256(objectMapper.writeValueAsString(inputs)); - return "ai:v1:" + request.getProfileId() + ":" + platform + ":" + digest; + return "ai:v2:" + request.getProfileId() + ":" + platform + ":" + digest; } catch (JsonProcessingException e) { throw new IllegalStateException("无法生成 AI 分析任务摘要", e); } } + private String currentResumeFingerprint(Long profileId) { + List rows = jdbcTemplate.query( + "SELECT COALESCE(resume_text, '') FROM resume_profile WHERE profile_id=? " + + "ORDER BY updated_at DESC, id DESC LIMIT 1", + (rs, rowNum) -> rs.getString(1), + profileId + ); + return sha256(rows.isEmpty() ? "" : rows.get(0)); + } + private JobAiAnalysisService.JobAnalysisRequest analysisRequest(String platform, long rowId, long profileId, diff --git a/src/test/java/com/getjobs/application/controller/AiConfigControllerJobTaskTest.java b/src/test/java/com/getjobs/application/controller/AiConfigControllerJobTaskTest.java index 59de9d2..c07e4e7 100644 --- a/src/test/java/com/getjobs/application/controller/AiConfigControllerJobTaskTest.java +++ b/src/test/java/com/getjobs/application/controller/AiConfigControllerJobTaskTest.java @@ -38,16 +38,20 @@ void setUp() { @Test void taskListIsScopedToCurrentProfile() { - when(queueService.listTasks(7L, 20)).thenReturn(List.of()); - when(queueService.queueSize(7L)).thenReturn(3); + when(queueService.listTasks(7L, "boss", 20)).thenReturn(List.of()); + when(queueService.queueSize(7L, "boss")).thenReturn(3); + when(queueService.pendingCount(7L, "boss")).thenReturn(2); + when(queueService.processingCount(7L, "boss")).thenReturn(1); - ResponseEntity> response = controller.listJobAnalysisTasks(20); + ResponseEntity> response = controller.listJobAnalysisTasks(20, "boss"); assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); assertThat(response.getBody()) .containsEntry("success", true) - .containsEntry("queueSize", 3); - verify(queueService).listTasks(7L, 20); + .containsEntry("queueSize", 3) + .containsEntry("pendingCount", 2) + .containsEntry("processingCount", 1); + verify(queueService).listTasks(7L, "boss", 20); } @Test diff --git a/src/test/java/com/getjobs/application/service/ChromeJobAnalysisQueueServiceTest.java b/src/test/java/com/getjobs/application/service/ChromeJobAnalysisQueueServiceTest.java index b021a24..e4ef388 100644 --- a/src/test/java/com/getjobs/application/service/ChromeJobAnalysisQueueServiceTest.java +++ b/src/test/java/com/getjobs/application/service/ChromeJobAnalysisQueueServiceTest.java @@ -12,14 +12,23 @@ import java.nio.file.Path; import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.timeout; @@ -54,6 +63,12 @@ void setUp() { ); store.validateSchema(); analysisService = mock(JobAiAnalysisService.class); + lenient().when(analysisService.analyzeJobs(any())).thenAnswer(invocation -> { + List jobs = invocation.getArgument(0); + Map results = new LinkedHashMap<>(); + jobs.forEach(job -> results.put(job.taskId(), successResult())); + return results; + }); } @AfterEach @@ -63,7 +78,6 @@ void tearDown() { @Test void duplicateEnqueueInvokesProviderOnlyOnce() { - when(analysisService.analyzeJob(any(), any(), any())).thenReturn(successResult()); queue = new ChromeJobAnalysisQueueService(analysisService, store); ChromeJobAnalysisQueueService.AnalysisJob job = job(request("boss", "job-duplicate", "run-a")); @@ -73,19 +87,33 @@ void duplicateEnqueueInvokesProviderOnlyOnce() { assertThat(first.isQueued()).isTrue(); assertThat(duplicate.isQueued()).isFalse(); - verify(analysisService, timeout(3000).times(1)).analyzeJob(any(), any(), any()); + verify(analysisService, timeout(3000).times(1)).analyzeJobs(any()); awaitStatus(firstTaskId(), "SUCCEEDED"); } @Test void startupDispatchesPersistedPendingTask() { long taskId = store.submit(request("boss", "job-restart", "run-before-restart")).task().id(); - when(analysisService.analyzeJob(any(), any(), any())).thenReturn(successResult()); queue = new ChromeJobAnalysisQueueService(analysisService, store); queue.initialize(); - verify(analysisService, timeout(3000).times(1)).analyzeJob(any(), any(), any()); + verify(analysisService, timeout(3000).times(1)).analyzeJobs(any()); + awaitStatus(taskId, "SUCCEEDED"); + assertThat(store.findById(taskId).attemptCount()).isEqualTo(1); + } + + @Test + void compatibleLookupFailureStillProcessesAlreadyClaimedSeed() { + long taskId = store.submit(request("boss", "job-seed-only", "run-before-restart")).task().id(); + JobAnalysisTaskStore flakyStore = spy(store); + doThrow(new IllegalStateException("batch lookup failed")) + .when(flakyStore).listCompatibleDuePending(anyLong(), anyString(), anyInt()); + queue = new ChromeJobAnalysisQueueService(analysisService, flakyStore); + + queue.initialize(); + + verify(analysisService, timeout(3000).times(1)).analyzeJobs(any()); awaitStatus(taskId, "SUCCEEDED"); assertThat(store.findById(taskId).attemptCount()).isEqualTo(1); } @@ -100,7 +128,7 @@ void expiredLeaseWithPersistedPlatformResultIsReconciledWithoutProviderCall() { queue.reconcileExpiredLeases(); assertThat(store.findById(taskId).status()).isEqualTo("SUCCEEDED"); - verify(analysisService, never()).analyzeJob(any(), any(), any()); + verify(analysisService, never()).analyzeJobs(any()); verify(analysisService, never()).markAnalysisInterrupted(any(), any()); } @@ -115,7 +143,7 @@ void expiredUnresolvedLeaseBecomesUnknownAndDoesNotRetryProvider() { queue.reconcileExpiredLeases(); assertThat(store.findById(taskId).status()).isEqualTo("UNKNOWN"); - verify(analysisService, never()).analyzeJob(any(), any(), any()); + verify(analysisService, never()).analyzeJobs(any()); verify(analysisService).markAnalysisInterrupted(any(), any()); } @@ -135,13 +163,14 @@ void startupRegistersLegacyAnalyzingRowAsUnknownWithoutProviderCall() { assertThat(jdbcTemplate.queryForObject( "SELECT status FROM job_analysis_task WHERE platform='boss' AND job_row_id=30", String.class)).isEqualTo("UNKNOWN"); - verify(analysisService, never()).analyzeJob(any(), any(), any()); + verify(analysisService, never()).analyzeJobs(any()); verify(analysisService, times(1)).markAnalysisInterrupted(any(), any()); } @Test void unexpectedExecutionFailureWritesExplicitTaskAndPlatformFailure() { - when(analysisService.analyzeJob(any(), any(), any())).thenThrow(new IllegalStateException("executor failed")); + doThrow(new IllegalStateException("executor failed")) + .when(analysisService).analyzeJobs(any()); when(analysisService.inspectPlatformAnalysis(any())) .thenReturn(JobAiAnalysisService.PlatformAnalysisState.incomplete(DeliveryStatus.AI_ANALYZING)); when(analysisService.markAnalysisInterrupted(any(), any())).thenReturn(true); @@ -161,7 +190,6 @@ void completionWriteExceptionReconcilesPersistedPlatformSuccess() { .doCallRealMethod() .when(flakyStore) .complete(anyLong(), anyString(), anyBoolean(), anyString()); - when(analysisService.analyzeJob(any(), any(), any())).thenReturn(successResult()); when(analysisService.inspectPlatformAnalysis(any())) .thenReturn(new JobAiAnalysisService.PlatformAnalysisState( true, false, DeliveryStatus.WAITING_CONFIRM)); @@ -170,7 +198,7 @@ void completionWriteExceptionReconcilesPersistedPlatformSuccess() { queue.enqueue(job(request("boss", "job-completion-recovery", "run-a"))); awaitStatus(submittedTaskId("job-completion-recovery"), "SUCCEEDED"); - verify(analysisService, timeout(3000).times(1)).analyzeJob(any(), any(), any()); + verify(analysisService, timeout(3000).times(1)).analyzeJobs(any()); } @Test @@ -181,7 +209,10 @@ void providerUnknownOutcomeStopsWithoutAutomaticDuplicateCall() { ); unknown.setErrorCode("AI_PROVIDER_TIMEOUT"); unknown.setProviderOutcomeUnknown(true); - when(analysisService.analyzeJob(any(), any(), any())).thenReturn(unknown); + doAnswer(invocation -> { + List jobs = invocation.getArgument(0); + return Map.of(jobs.get(0).taskId(), unknown); + }).when(analysisService).analyzeJobs(any()); queue = new ChromeJobAnalysisQueueService(analysisService, store); ChromeJobAnalysisQueueService.EnqueueResult submitted = queue.enqueue( @@ -190,9 +221,9 @@ void providerUnknownOutcomeStopsWithoutAutomaticDuplicateCall() { long taskId = submittedTaskId("job-provider-unknown"); awaitStatus(taskId, "UNKNOWN"); assertThat(store.findById(taskId).lastError()).contains("provider timeout"); - verify(analysisService, timeout(3000).times(1)).analyzeJob(any(), any(), any()); + verify(analysisService, timeout(3000).times(1)).analyzeJobs(any()); queue.initialize(); - verify(analysisService, times(1)).analyzeJob(any(), any(), any()); + verify(analysisService, times(1)).analyzeJobs(any()); } @Test @@ -203,14 +234,13 @@ void confirmedUnknownRetryResetsAnalyzingStatusBeforeCallingProviderAgain() { when(analysisService.inspectPlatformAnalysis(any())) .thenReturn(JobAiAnalysisService.PlatformAnalysisState.incomplete(DeliveryStatus.AI_ANALYZING)); when(analysisService.markAnalysisInterrupted(any(), any())).thenReturn(true); - when(analysisService.analyzeJob(any(), any(), any())).thenReturn(successResult()); queue = new ChromeJobAnalysisQueueService(analysisService, store); JobAnalysisTaskStore.RetryResult retried = queue.retry(taskId, 1L, true); assertThat(retried.accepted()).isTrue(); verify(analysisService).markAnalysisInterrupted(any(), any()); - verify(analysisService, timeout(3000).times(1)).analyzeJob(any(), any(), any()); + verify(analysisService, timeout(3000).times(1)).analyzeJobs(any()); awaitStatus(taskId, "SUCCEEDED"); } @@ -229,7 +259,71 @@ void confirmedUnknownRetryFailsClosedWhenAnalyzingStatusCannotBeReset() { assertThat(retried.accepted()).isFalse(); assertThat(retried.message()).contains("未重新调用 AI Provider"); assertThat(store.findById(taskId).status()).isEqualTo("UNKNOWN"); - verify(analysisService, never()).analyzeJob(any(), any(), any()); + verify(analysisService, never()).analyzeJobs(any()); + } + + @Test + void tenCompatibleTasksRunAsTwoBatchesWithAtMostTwoConcurrentCalls() throws Exception { + AtomicInteger active = new AtomicInteger(); + AtomicInteger maxActive = new AtomicInteger(); + CountDownLatch entered = new CountDownLatch(2); + CountDownLatch release = new CountDownLatch(1); + doAnswer(invocation -> { + List jobs = invocation.getArgument(0); + assertThat(jobs).hasSize(5); + int current = active.incrementAndGet(); + maxActive.accumulateAndGet(current, Math::max); + entered.countDown(); + try { + assertThat(release.await(2, TimeUnit.SECONDS)).isTrue(); + } finally { + active.decrementAndGet(); + } + Map results = new LinkedHashMap<>(); + jobs.forEach(job -> results.put(job.taskId(), successResult())); + return results; + }).when(analysisService).analyzeJobs(any()); + queue = new ChromeJobAnalysisQueueService(analysisService, store); + for (int index = 0; index < 10; index++) { + assertThat(queue.enqueue(job(request("boss", "job-batch-" + index, "run-batch"))).isQueued()) + .isTrue(); + } + + assertThat(entered.await(3, TimeUnit.SECONDS)).isTrue(); + assertThat(maxActive.get()).isLessThanOrEqualTo(2); + release.countDown(); + for (int index = 0; index < 10; index++) { + awaitStatus(submittedTaskId("job-batch-" + index), "SUCCEEDED"); + } + verify(analysisService, times(2)).analyzeJobs(any()); + } + + @Test + void batchNeverMixesProfilesOrPlatforms() { + for (int index = 0; index < 4; index++) { + store.submit(request(1L, "boss", "boss-p1-" + index, "run-a")); + } + for (int index = 0; index < 3; index++) { + store.submit(request(1L, "zhilian", "zhilian-p1-" + index, "run-a")); + } + for (int index = 0; index < 2; index++) { + store.submit(request(2L, "boss", "boss-p2-" + index, "run-a")); + } + queue = new ChromeJobAnalysisQueueService(analysisService, store); + queue.initialize(); + + for (int index = 0; index < 4; index++) awaitStatus(submittedTaskId("boss-p1-" + index), "SUCCEEDED"); + for (int index = 0; index < 3; index++) awaitStatus(submittedTaskId("zhilian-p1-" + index), "SUCCEEDED"); + for (int index = 0; index < 2; index++) awaitStatus(submittedTaskId("boss-p2-" + index), "SUCCEEDED"); + + @SuppressWarnings("unchecked") + org.mockito.ArgumentCaptor> captor = + org.mockito.ArgumentCaptor.forClass(List.class); + verify(analysisService, times(3)).analyzeJobs(captor.capture()); + assertThat(captor.getAllValues()).allSatisfy(batch -> { + assertThat(batch).extracting(job -> job.request().getProfileId()).containsOnly(batch.get(0).request().getProfileId()); + assertThat(batch).extracting(job -> job.request().getPlatform()).containsOnly(batch.get(0).request().getPlatform()); + }); } private long leaseExpiredTask(String platform, String jobKey) { @@ -282,23 +376,31 @@ private ChromeJobAnalysisQueueService.AnalysisJob job(JobAiAnalysisService.JobAn } private JobAiAnalysisService.JobAnalysisRequest request(String platform, String jobKey, String runId) { + return request(1L, platform, jobKey, runId); + } + + private JobAiAnalysisService.JobAnalysisRequest request(long profileId, + String platform, + String jobKey, + String runId) { JobAiAnalysisService.JobAnalysisRequest request = new JobAiAnalysisService.JobAnalysisRequest(); - request.setProfileId(1L); + request.setProfileId(profileId); request.setPlatform(platform); request.setJobKey(jobKey); - jdbcTemplate.update("INSERT OR IGNORE INTO profile(id, name, is_active) VALUES (1, 'queue-profile', 0)"); + jdbcTemplate.update("INSERT OR IGNORE INTO profile(id, name, is_active) VALUES (?, ?, 0)", + profileId, "queue-profile-" + profileId); if ("boss".equals(platform)) { jdbcTemplate.update("INSERT OR IGNORE INTO boss_data(profile_id, encrypt_id, company_name, job_name, delivery_status) " + - "VALUES (1, ?, '测试公司', 'Java 工程师', ?)", - jobKey, DeliveryStatus.NOT_DELIVERED); + "VALUES (?, ?, '测试公司', 'Java 工程师', ?)", + profileId, jobKey, DeliveryStatus.NOT_DELIVERED); request.setJobRowId(jdbcTemplate.queryForObject( - "SELECT id FROM boss_data WHERE profile_id=1 AND encrypt_id=?", Long.class, jobKey)); + "SELECT id FROM boss_data WHERE profile_id=? AND encrypt_id=?", Long.class, profileId, jobKey)); } else { jdbcTemplate.update("INSERT OR IGNORE INTO zhilian_data(profile_id, job_id, company_name, job_title, delivery_status) " + - "VALUES (1, ?, '测试公司', 'Java 工程师', ?)", - jobKey, DeliveryStatus.NOT_DELIVERED); + "VALUES (?, ?, '测试公司', 'Java 工程师', ?)", + profileId, jobKey, DeliveryStatus.NOT_DELIVERED); request.setJobRowId(jdbcTemplate.queryForObject( - "SELECT id FROM zhilian_data WHERE profile_id=1 AND job_id=?", Long.class, jobKey)); + "SELECT id FROM zhilian_data WHERE profile_id=? AND job_id=?", Long.class, profileId, jobKey)); } request.setKeyword("Java"); request.setCompanyName("测试公司"); diff --git a/src/test/java/com/getjobs/application/service/JobAiAnalysisServiceStatusTest.java b/src/test/java/com/getjobs/application/service/JobAiAnalysisServiceStatusTest.java index 67f7c96..17b96b0 100644 --- a/src/test/java/com/getjobs/application/service/JobAiAnalysisServiceStatusTest.java +++ b/src/test/java/com/getjobs/application/service/JobAiAnalysisServiceStatusTest.java @@ -22,10 +22,13 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.json.JSONArray; +import org.json.JSONObject; import org.springframework.mock.web.MockMultipartFile; import java.nio.charset.StandardCharsets; import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import static org.assertj.core.api.Assertions.assertThat; @@ -102,6 +105,20 @@ void bossSkipUpdatesAiNotMatch() { assertThat(lastBossUpdate().getDeliveryStatus()).isEqualTo(DeliveryStatus.AI_NOT_MATCH); } + @Test + void reasonTextUsesVersionTwoWithoutDatabaseMigration() { + JobAiAnalysisService.AnalysisResult result = analysis("APPLY"); + result.setMatches(List.of("岗位和简历均包含 Java")); + result.setUnknowns(List.of("到岗时间待核实")); + result.setThreshold(75); + + JSONObject reason = new JSONObject(result.toReasonText()); + + assertThat(reason.getInt("schemaVersion")).isEqualTo(2); + assertThat(reason.getJSONArray("matches").getString(0)).contains("Java"); + assertThat(reason.getJSONArray("unknowns").getString(0)).contains("待核实"); + } + @Test void zhilianApplyUpdatesWaitingConfirm() { when(zhilianJobDataMapper.selectOne(any())).thenReturn(zhilianJob(DeliveryStatus.NOT_DELIVERED)); @@ -232,9 +249,7 @@ void leaseTransactionRejectsLateProviderResultBeforeAnyResultWrite() { request.setJobRowId(99L); when(bossJobDataMapper.update(any(), any(UpdateWrapper.class))).thenReturn(1); when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); - when(aiService.sendStructuredRequest(any(), any())).thenReturn(""" - {"score":90,"decision":"APPLY","summary":"旧租约结果","strengths":[],"risks":[],"greeting":"你好"} - """); + when(aiService.sendStructuredRequest(any(), any())).thenReturn(batchResult("旧租约结果")); AtomicInteger guardedWrites = new AtomicInteger(); JobAiAnalysisService.AnalysisResult result = service.analyzeJob( @@ -259,9 +274,7 @@ void leaseTransactionRejectsLateProviderResultBeforeAnyResultWrite() { void manualZhilianAnalyzeApplyEndsWaitingConfirm() { when(zhilianJobDataMapper.selectOne(any())).thenReturn(zhilianJob(DeliveryStatus.NOT_DELIVERED)); when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); - when(aiService.sendStructuredRequest(any(), any())).thenReturn(""" - {"score":90,"decision":"APPLY","summary":"匹配","strengths":["经验匹配"],"risks":[],"greeting":"你好"} - """); + when(aiService.sendStructuredRequest(any(), any())).thenReturn(batchResult("匹配")); service.analyzeJob(zhilianRequest()); @@ -276,9 +289,8 @@ void customThresholdAcceptsScoreExactlyAtSixty() { when(bossJobDataMapper.selectOne(any())).thenReturn(bossJob(DeliveryStatus.NOT_DELIVERED)); when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); when(aiService.getAiConfig(PROFILE_ID)).thenReturn(aiConfig(60, 50)); - when(aiService.sendStructuredRequest(any(), any())).thenReturn(""" - {"score":60,"decision":"SKIP","summary":"达到自定义分数线","strengths":[],"risks":[],"greeting":"你好"} - """); + when(aiService.sendStructuredRequest(any(), any())).thenReturn(batchResult( + "达到自定义分数线", "UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN")); JobAiAnalysisService.AnalysisResult result = service.analyzeJob(bossRequest()); @@ -289,9 +301,10 @@ void customThresholdAcceptsScoreExactlyAtSixty() { ArgumentCaptor prompt = ArgumentCaptor.forClass(String.class); ArgumentCaptor schema = ArgumentCaptor.forClass(String.class); verify(aiService).sendStructuredRequest(prompt.capture(), schema.capture()); - assertThat(prompt.getValue()).contains("当前阈值为60"); + assertThat(prompt.getValue()).doesNotContain("当前阈值为60"); assertThat(schema.getValue()) - .contains("\"required\"", "\"score\"", "\"decision\"", "\"additionalProperties\": false"); + .contains("\"required\"", "\"dimensions\"", "\"hardConflicts\"", "\"additionalProperties\": false") + .doesNotContain("\"score\"", "\"decision\""); } @Test @@ -299,9 +312,8 @@ void customThresholdRejectsScoreBelowSixty() { when(bossJobDataMapper.selectOne(any())).thenReturn(bossJob(DeliveryStatus.NOT_DELIVERED)); when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); when(aiService.getAiConfig(PROFILE_ID)).thenReturn(aiConfig(60, 50)); - when(aiService.sendStructuredRequest(any(), any())).thenReturn(""" - {"score":59,"decision":"APPLY","summary":"低于自定义分数线","strengths":[],"risks":[],"greeting":"你好"} - """); + when(aiService.sendStructuredRequest(any(), any())).thenReturn(batchResult( + "低于自定义分数线", "CONFLICT", "PARTIAL", "MATCH", "MATCH", "MATCH", "MATCH")); JobAiAnalysisService.AnalysisResult result = service.analyzeJob(bossRequest()); @@ -317,18 +329,136 @@ void priorityCompanyUsesItsOwnCustomThreshold() { when(bossJobDataMapper.selectOne(any())).thenReturn(bossJob(DeliveryStatus.NOT_DELIVERED)); when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); when(aiService.getAiConfig(PROFILE_ID)).thenReturn(aiConfig(60, 50)); - when(aiService.sendStructuredRequest(any(), any())).thenReturn(""" - {"score":50,"decision":"SKIP","summary":"达到优先公司分数线","strengths":[],"risks":[],"greeting":"你好"} - """); + when(aiService.sendStructuredRequest(any(), any())).thenReturn(batchResult( + "达到优先公司分数线", "UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN")); JobAiAnalysisService.AnalysisResult result = service.analyzeJob(bossRequest()); assertThat(result.getPriorityCompany()).isTrue(); + assertThat(result.getScore()).isEqualTo(60); assertThat(result.getThreshold()).isEqualTo(50); assertThat(result.getDecision()).isEqualTo("APPLY"); assertThat(lastBossUpdate().getDeliveryStatus()).isEqualTo(DeliveryStatus.WAITING_CONFIRM); } + @Test + void verifiedHardConflictForcesSkipEvenWhenRemainingScoreReachesThreshold() { + when(bossJobDataMapper.selectOne(any())).thenReturn(bossJob(DeliveryStatus.NOT_DELIVERED)); + when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); + when(aiService.sendStructuredRequest(any(), any())).thenReturn(batchWithHardConflict(true)); + + JobAiAnalysisService.AnalysisResult result = service.analyzeJob(bossRequest()); + + assertThat(result.getScore()).isEqualTo(75); + assertThat(result.getDecision()).isEqualTo("SKIP"); + assertThat(result.getHardConflicts()).hasSize(1); + assertThat(lastBossUpdate().getDeliveryStatus()).isEqualTo(DeliveryStatus.AI_NOT_MATCH); + } + + @Test + void unverifiedHardConflictIsDowngradedToUnknown() { + when(bossJobDataMapper.selectOne(any())).thenReturn(bossJob(DeliveryStatus.NOT_DELIVERED)); + when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); + when(aiService.sendStructuredRequest(any(), any())).thenReturn(batchWithHardConflict(false)); + + JobAiAnalysisService.AnalysisResult result = service.analyzeJob(bossRequest()); + + assertThat(result.getDecision()).isEqualTo("APPLY"); + assertThat(result.getHardConflicts()).isEmpty(); + assertThat(result.getUnknowns()).anyMatch(value -> value.contains("降级为待核实")); + } + + @Test + void hardConflictMustReuseEvidenceFromAConflictDimension() { + when(bossJobDataMapper.selectOne(any())).thenReturn(bossJob(DeliveryStatus.NOT_DELIVERED)); + when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); + JSONObject root = new JSONObject(batchResult("模型给出了不一致的硬冲突")); + JSONObject conflict = new JSONObject(); + conflict.put("requirement", "经验年限不符"); + conflict.put("jobEvidence", new JSONArray(List.of("3-5年"))); + conflict.put("resumeEvidence", new JSONArray(List.of("仅1年 Java 后端开发经验"))); + root.getJSONArray("results").getJSONObject(0) + .put("hardConflicts", new JSONArray().put(conflict)); + when(aiService.sendStructuredRequest(any(), any())).thenReturn(root.toString()); + + JobAiAnalysisService.AnalysisResult result = service.analyzeJob(bossRequest()); + + assertThat(result.getDecision()).isEqualTo("APPLY"); + assertThat(result.getHardConflicts()).isEmpty(); + assertThat(result.getUnknowns()).anyMatch(value -> value.contains("冲突分项不一致")); + } + + @Test + void fiveJobBatchMapsByTaskIdAndRetriesOnlyMissingItem() { + when(bossJobDataMapper.selectOne(any())).thenReturn(bossJob(DeliveryStatus.NOT_DELIVERED)); + when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); + when(aiService.sendStructuredRequest(any(), any())) + .thenReturn(batchResultForTaskIds(List.of(5L, 3L, 1L, 4L), "批量结果")) + .thenReturn(batchResultForTaskIds(List.of(2L), "单岗重试")); + List jobs = java.util.stream.LongStream.rangeClosed(1, 5) + .mapToObj(id -> batchJob(id, bossRequest())) + .toList(); + + java.util.Map results = service.analyzeJobs(jobs); + + assertThat(results).hasSize(5); + assertThat(results.get(2L).getSummary()).isEqualTo("单岗重试"); + assertThat(results.values()).allMatch(result -> !result.isFailure()); + ArgumentCaptor prompts = ArgumentCaptor.forClass(String.class); + verify(aiService, times(2)).sendStructuredRequest(prompts.capture(), any()); + String firstPrompt = prompts.getAllValues().get(0); + assertThat(firstPrompt.indexOf(resume().getResumeText())) + .isEqualTo(firstPrompt.lastIndexOf(resume().getResumeText())); + } + + @Test + void permanentlyInvalidItemDoesNotFailOtherBatchResults() { + when(bossJobDataMapper.selectOne(any())).thenReturn(bossJob(DeliveryStatus.NOT_DELIVERED)); + when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); + JSONObject first = new JSONObject(batchResultForTaskIds( + List.of(1L, 2L, 3L, 4L, 5L), "批量结果")); + first.getJSONArray("results").getJSONObject(1) + .getJSONArray("dimensions").getJSONObject(0).put("status", "MAYBE"); + JSONObject retry = new JSONObject(invalidStatusBatch()); + retry.getJSONArray("results").getJSONObject(0).put("taskId", 2L); + when(aiService.sendStructuredRequest(any(), any())).thenReturn(first.toString(), retry.toString()); + List jobs = java.util.stream.LongStream.rangeClosed(1, 5) + .mapToObj(id -> batchJob(id, bossRequest())) + .toList(); + + java.util.Map results = service.analyzeJobs(jobs); + + assertThat(results.get(2L).isFailure()).isTrue(); + assertThat(results.get(2L).getErrorCode()).isEqualTo("AI_OUTPUT_INVALID_SCHEMA"); + assertThat(results.entrySet()).filteredOn(entry -> entry.getKey() != 2L) + .allMatch(entry -> !entry.getValue().isFailure()); + verify(aiService, times(2)).sendStructuredRequest(any(), any()); + } + + @Test + void fiftyNormalJobsUseTenProviderCalls() { + when(bossJobDataMapper.selectOne(any())).thenReturn(bossJob(DeliveryStatus.NOT_DELIVERED)); + when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); + AtomicInteger providerBatch = new AtomicInteger(); + when(aiService.sendStructuredRequest(any(), any())).thenAnswer(invocation -> { + long firstId = providerBatch.getAndIncrement() * 5L + 1L; + return batchResultForTaskIds(java.util.stream.LongStream.range(firstId, firstId + 5) + .boxed().toList(), "批量结果"); + }); + + for (int batch = 0; batch < 10; batch++) { + long firstId = batch * 5L + 1L; + List jobs = java.util.stream.LongStream + .range(firstId, firstId + 5) + .mapToObj(id -> batchJob(id, bossRequest())) + .toList(); + assertThat(service.analyzeJobs(jobs)).hasSize(5); + } + + assertThat(providerBatch.get()).isEqualTo(10); + verify(aiService, times(10)).sendStructuredRequest(any(), any()); + } + @Test void savesConfirmedUtf8ResumeText() { when(profileService.getCurrentProfileId()).thenReturn(PROFILE_ID); @@ -350,16 +480,13 @@ void savesConfirmedUtf8ResumeText() { void repairsMarkdownWrappedAiJsonAndKeepsWaitingConfirmFlow() { when(bossJobDataMapper.selectOne(any())).thenReturn(bossJob(DeliveryStatus.NOT_DELIVERED)); when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); - when(aiService.sendStructuredRequest(any(), any())).thenReturn(""" - ```json - {score:88, decision:"APPLY", summary:"匹配", strengths:["Java"], risks:[], greeting:"你好",} - ``` - """); + when(aiService.sendStructuredRequest(any(), any())).thenReturn( + "```json\n" + batchResult("匹配") + "\n```"); service.analyzeJob(bossRequest()); BossJobDataEntity update = lastBossUpdate(); - assertThat(update.getAiScore()).isEqualTo(88); + assertThat(update.getAiScore()).isEqualTo(100); assertThat(update.getAiDecision()).isEqualTo("APPLY"); assertThat(update.getDeliveryStatus()).isEqualTo(DeliveryStatus.WAITING_CONFIRM); } @@ -368,9 +495,7 @@ void repairsMarkdownWrappedAiJsonAndKeepsWaitingConfirmFlow() { void keepsChineseCurlyQuotesInsideValidJsonString() { when(bossJobDataMapper.selectOne(any())).thenReturn(bossJob(DeliveryStatus.NOT_DELIVERED)); when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); - when(aiService.sendStructuredRequest(any(), any())).thenReturn(""" - {"score":88,"decision":"APPLY","summary":"岗位要求“3年以上”经验","strengths":["熟悉Java"],"risks":[],"greeting":"你好"} - """); + when(aiService.sendStructuredRequest(any(), any())).thenReturn(batchResult("岗位要求“3年以上”经验")); JobAiAnalysisService.AnalysisResult result = service.analyzeJob(bossRequest()); @@ -397,34 +522,31 @@ void emptyProviderOutputBecomesExplicitAiFailureInsteadOfSkip() { void missingRequiredOutputFieldBecomesExplicitAiFailure() { when(bossJobDataMapper.selectOne(any())).thenReturn(bossJob(DeliveryStatus.NOT_DELIVERED)); when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); - when(aiService.sendStructuredRequest(any(), any())).thenReturn(""" - {"score":80,"decision":"APPLY","summary":"匹配","strengths":[],"risks":[]} - """); + when(aiService.sendStructuredRequest(any(), any())).thenReturn( + missingGreetingBatch(), missingGreetingBatch()); JobAiAnalysisService.AnalysisResult result = service.analyzeJob(bossRequest()); assertThat(result.isFailure()).isTrue(); assertThat(result.getErrorCode()).isEqualTo("AI_OUTPUT_MISSING_FIELD"); assertThat(lastBossUpdate().getDeliveryStatus()).isEqualTo(DeliveryStatus.AI_ANALYSIS_FAILED); + verify(aiService, times(2)).sendStructuredRequest(any(), any()); } @Test - void invalidScoreAndArrayElementTypesAreRejected() { + void invalidDimensionStatusAndArrayElementTypesAreRetriedThenRejected() { when(bossJobDataMapper.selectOne(any())).thenReturn(bossJob(DeliveryStatus.NOT_DELIVERED)); when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); when(aiService.sendStructuredRequest(any(), any())) - .thenReturn(""" - {"score":101,"decision":"APPLY","summary":"匹配","strengths":[],"risks":[],"greeting":"你好"} - """) - .thenReturn(""" - {"score":80,"decision":"APPLY","summary":"匹配","strengths":[1],"risks":[],"greeting":"你好"} - """); - - JobAiAnalysisService.AnalysisResult invalidScore = service.analyzeJob(bossRequest()); + .thenReturn(invalidStatusBatch(), invalidStatusBatch()) + .thenReturn(invalidMatchesBatch(), invalidMatchesBatch()); + + JobAiAnalysisService.AnalysisResult invalidStatus = service.analyzeJob(bossRequest()); JobAiAnalysisService.AnalysisResult invalidArray = service.analyzeJob(bossRequest()); - assertThat(invalidScore.getErrorCode()).isEqualTo("AI_OUTPUT_INVALID_SCORE"); + assertThat(invalidStatus.getErrorCode()).isEqualTo("AI_OUTPUT_INVALID_SCHEMA"); assertThat(invalidArray.getErrorCode()).isEqualTo("AI_OUTPUT_INVALID_SCHEMA"); + verify(aiService, times(4)).sendStructuredRequest(any(), any()); } @Test @@ -433,9 +555,7 @@ void invalidJsonRetriesOnceWithSameSchemaAndCanSucceed() { when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); when(aiService.sendStructuredRequest(any(), any())) .thenReturn("not-json-at-all") - .thenReturn(""" - {"score":80,"decision":"APPLY","summary":"重试成功","strengths":[],"risks":[],"greeting":"你好"} - """); + .thenReturn(batchResult("重试成功")); JobAiAnalysisService.AnalysisResult result = service.analyzeJob(bossRequest()); @@ -446,6 +566,37 @@ void invalidJsonRetriesOnceWithSameSchemaAndCanSucceed() { assertThat(schema.getAllValues()).hasSize(2).allMatch(schema.getAllValues().get(0)::equals); } + @Test + void oneExpiredLeaseDoesNotDiscardOtherJobsDuringWholeBatchRetry() { + when(bossJobDataMapper.selectOne(any())).thenReturn(bossJob(DeliveryStatus.NOT_DELIVERED)); + when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); + AtomicBoolean secondLeaseCurrent = new AtomicBoolean(true); + when(aiService.sendStructuredRequest(any(), any())) + .thenAnswer(invocation -> { + secondLeaseCurrent.set(false); + return "not-json-at-all"; + }) + .thenReturn(batchResultForTaskIds(List.of(1L, 2L), "重试后有效")); + JobAiAnalysisService.BatchAnalysisJob first = batchJob(1L, bossRequest()); + JobAiAnalysisService.BatchAnalysisJob second = new JobAiAnalysisService.BatchAnalysisJob( + 2L, + bossRequest(), + secondLeaseCurrent::get, + action -> { + action.run(); + return true; + } + ); + + java.util.Map results = + service.analyzeJobs(List.of(first, second)); + + assertThat(results.get(1L).isFailure()).isFalse(); + assertThat(results.get(1L).getSummary()).isEqualTo("重试后有效"); + assertThat(results.get(2L).isStaleLease()).isTrue(); + verify(aiService, times(2)).sendStructuredRequest(any(), any()); + } + @Test void invalidJsonFailsAfterExactlyOneRetry() { when(bossJobDataMapper.selectOne(any())).thenReturn(bossJob(DeliveryStatus.NOT_DELIVERED)); @@ -460,17 +611,16 @@ void invalidJsonFailsAfterExactlyOneRetry() { } @Test - void invalidDecisionFailsWithoutJsonRetry() { + void invalidDimensionStatusRetriesOnlyThatJobOnce() { when(bossJobDataMapper.selectOne(any())).thenReturn(bossJob(DeliveryStatus.NOT_DELIVERED)); when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); - when(aiService.sendStructuredRequest(any(), any())).thenReturn(""" - {"score":80,"decision":"MAYBE","summary":"匹配","strengths":[],"risks":[],"greeting":"你好"} - """); + when(aiService.sendStructuredRequest(any(), any())).thenReturn( + invalidStatusBatch(), invalidStatusBatch()); JobAiAnalysisService.AnalysisResult result = service.analyzeJob(bossRequest()); - assertThat(result.getErrorCode()).isEqualTo("AI_OUTPUT_INVALID_DECISION"); - verify(aiService).sendStructuredRequest(any(), any()); + assertThat(result.getErrorCode()).isEqualTo("AI_OUTPUT_INVALID_SCHEMA"); + verify(aiService, times(2)).sendStructuredRequest(any(), any()); } @Test @@ -478,9 +628,7 @@ void rawProviderResponseIsReplacedWithDiagnosticFingerprint() { String marker = "sensitive-response-marker"; when(bossJobDataMapper.selectOne(any())).thenReturn(bossJob(DeliveryStatus.NOT_DELIVERED)); when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); - when(aiService.sendStructuredRequest(any(), any())).thenReturn(""" - {"score":88,"decision":"APPLY","summary":"sensitive-response-marker","strengths":[],"risks":[],"greeting":"你好"} - """); + when(aiService.sendStructuredRequest(any(), any())).thenReturn(batchResult(marker)); JobAiAnalysisService.AnalysisResult result = service.analyzeJob(bossRequest()); @@ -498,9 +646,7 @@ void persistenceFailureNeverReportsTaskSuccess() { when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); when(jobAiAnalysisMapper.insert(any(JobAiAnalysisEntity.class))).thenReturn(0); when(bossJobDataMapper.update(any(), any(UpdateWrapper.class))).thenReturn(1, 0, 1); - when(aiService.sendStructuredRequest(any(), any())).thenReturn(""" - {"score":88,"decision":"APPLY","summary":"匹配","strengths":[],"risks":[],"greeting":"你好"} - """); + when(aiService.sendStructuredRequest(any(), any())).thenReturn(batchResult("匹配")); JobAiAnalysisService.AnalysisResult result = service.analyzeJob(bossRequest()); @@ -515,9 +661,7 @@ void platformWriteFailureCanBeConfirmedAndRetriedWithoutGettingStuckAnalyzing() when(bossJobDataMapper.selectOne(any())).thenReturn(bossJob(DeliveryStatus.AI_ANALYZING)); when(resumeProfileMapper.selectOne(any())).thenReturn(resume()); when(bossJobDataMapper.update(any(), any(UpdateWrapper.class))).thenReturn(1, 0, 1, 1, 1); - when(aiService.sendStructuredRequest(any(), any())).thenReturn(""" - {"score":88,"decision":"APPLY","summary":"匹配","strengths":[],"risks":[],"greeting":"你好"} - """); + when(aiService.sendStructuredRequest(any(), any())).thenReturn(batchResult("匹配")); JobAiAnalysisService.AnalysisResult firstResult = service.analyzeJob(bossRequest()); JobAiAnalysisService.AnalysisResult confirmedRetryResult = service.analyzeJob(bossRequest()); @@ -632,6 +776,90 @@ private JobAiAnalysisService.AnalysisResult analysis(String decision) { return result; } + private JobAiAnalysisService.BatchAnalysisJob batchJob( + long taskId, JobAiAnalysisService.JobAnalysisRequest request) { + return new JobAiAnalysisService.BatchAnalysisJob(taskId, request, () -> true, action -> { + action.run(); + return true; + }); + } + + private String batchResult(String summary, String... statuses) { + return batchResultForTaskIds(List.of(1L), summary, statuses); + } + + private String batchResultForTaskIds(List taskIds, String summary, String... statuses) { + List keys = List.of( + "CORE_SKILLS", + "RELEVANT_EXPERIENCE", + "ACHIEVEMENTS_COMPLEXITY", + "INDUSTRY_TRANSFER", + "EDUCATION_TENURE", + "LOCATION_SALARY" + ); + JSONArray results = new JSONArray(); + for (Long taskId : taskIds) { + JSONObject item = new JSONObject(); + item.put("taskId", taskId); + item.put("summary", summary); + item.put("matches", new JSONArray(List.of("岗位与简历均提到 Java"))); + item.put("gaps", new JSONArray()); + item.put("unknowns", new JSONArray()); + JSONArray dimensions = new JSONArray(); + for (int index = 0; index < keys.size(); index++) { + JSONObject dimension = new JSONObject(); + dimension.put("key", keys.get(index)); + dimension.put("status", index < statuses.length ? statuses[index] : "MATCH"); + dimension.put("jobEvidence", new JSONArray(List.of("Java"))); + dimension.put("resumeEvidence", new JSONArray(List.of("Java"))); + dimension.put("note", "证据说明"); + dimensions.put(dimension); + } + item.put("dimensions", dimensions); + item.put("hardConflicts", new JSONArray()); + item.put("greeting", "你好"); + results.put(item); + } + return new JSONObject().put("results", results).toString(); + } + + private String batchWithHardConflict(boolean withResumeEvidence) { + JSONObject root = new JSONObject(batchResult("存在硬性条件差异")); + JSONObject experienceDimension = root.getJSONArray("results").getJSONObject(0) + .getJSONArray("dimensions").getJSONObject(1); + experienceDimension.put("status", "CONFLICT"); + experienceDimension.put("jobEvidence", new JSONArray(List.of("3-5年"))); + experienceDimension.put("resumeEvidence", new JSONArray(List.of("仅1年 Java 后端开发经验"))); + JSONObject conflict = new JSONObject(); + conflict.put("requirement", "经验年限不符"); + conflict.put("jobEvidence", new JSONArray(List.of("3-5年"))); + conflict.put("resumeEvidence", withResumeEvidence + ? new JSONArray(List.of("仅1年 Java 后端开发经验")) + : new JSONArray()); + root.getJSONArray("results").getJSONObject(0) + .put("hardConflicts", new JSONArray().put(conflict)); + return root.toString(); + } + + private String missingGreetingBatch() { + JSONObject root = new JSONObject(batchResult("缺少字段")); + root.getJSONArray("results").getJSONObject(0).remove("greeting"); + return root.toString(); + } + + private String invalidStatusBatch() { + JSONObject root = new JSONObject(batchResult("状态非法")); + root.getJSONArray("results").getJSONObject(0) + .getJSONArray("dimensions").getJSONObject(0).put("status", "MAYBE"); + return root.toString(); + } + + private String invalidMatchesBatch() { + JSONObject root = new JSONObject(batchResult("数组非法")); + root.getJSONArray("results").getJSONObject(0).put("matches", new JSONArray().put(1)); + return root.toString(); + } + private BossJobDataEntity bossJob(String status) { BossJobDataEntity job = new BossJobDataEntity(); job.setId(1L); @@ -657,7 +885,7 @@ private ZhilianJobDataEntity zhilianJob(String status) { private ResumeProfileEntity resume() { ResumeProfileEntity resume = new ResumeProfileEntity(); resume.setProfileId(PROFILE_ID); - resume.setResumeText("多年 Java 后端开发经验,熟悉 Spring Boot 和招聘业务系统。"); + resume.setResumeText("仅1年 Java 后端开发经验,熟悉 Spring Boot 和招聘业务系统。"); return resume; } diff --git a/src/test/java/com/getjobs/application/service/JobAnalysisTaskStoreTest.java b/src/test/java/com/getjobs/application/service/JobAnalysisTaskStoreTest.java index 25a6481..38a4ba7 100644 --- a/src/test/java/com/getjobs/application/service/JobAnalysisTaskStoreTest.java +++ b/src/test/java/com/getjobs/application/service/JobAnalysisTaskStoreTest.java @@ -58,6 +58,54 @@ void stableTaskKeyDeduplicatesAcrossRunIdsButSeparatesProfiles() { assertThat(otherProfile.created()).isTrue(); assertThat(jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM job_analysis_task WHERE task_key IS NOT NULL", Integer.class)).isEqualTo(2); + assertThat(jdbcTemplate.queryForObject( + "SELECT task_key FROM job_analysis_task WHERE id=?", String.class, first.task().id())) + .startsWith("ai:v2:"); + } + + @Test + void compatibleBatchSelectionKeepsProfileAndPlatformIsolated() { + store.submit(request(1L, "boss", "boss-one", "run-a")); + store.submit(request(1L, "boss", "boss-two", "run-a")); + store.submit(request(1L, "zhilian", "zhilian-one", "run-a")); + store.submit(request(2L, "boss", "boss-other-profile", "run-a")); + + List compatible = + store.listCompatibleDuePending(1L, "BOSS", 4); + + assertThat(compatible).hasSize(2); + assertThat(compatible).extracting(JobAnalysisTaskStore.TaskRecord::profileId).containsOnly(1L); + assertThat(compatible).extracting(JobAnalysisTaskStore.TaskRecord::platform).containsOnly("boss"); + assertThat(store.pendingCount(1L)).isEqualTo(3); + assertThat(store.pendingCount(1L, "boss")).isEqualTo(2); + assertThat(store.pendingCount(1L, "zhilian")).isEqualTo(1); + assertThat(store.outstandingCount(1L, "boss")).isEqualTo(2); + assertThat(store.listRecent(1L, "boss", 10)) + .extracting(JobAnalysisTaskStore.TaskView::platform) + .containsOnly("boss"); + assertThat(store.processingCount(1L)).isZero(); + } + + @Test + void changedResumeCreatesAFreshV2TaskForTheSameJob() { + JobAiAnalysisService.JobAnalysisRequest initialRequest = request(1L, "boss", "job-resume", "run-a"); + jdbcTemplate.update("INSERT INTO resume_profile(profile_id, resume_text, updated_at) VALUES (?, ?, ?)", + 1L, "三年 Java 经验", "2026-09-03 10:00:00.000"); + JobAnalysisTaskStore.SubmitResult first = store.submit(initialRequest); + assertThat(store.claim(first.task().id(), "lease-first", Duration.ofMinutes(1))).isNotNull(); + assertThat(store.complete(first.task().id(), "lease-first", false, "ok")).isTrue(); + + jdbcTemplate.update("UPDATE resume_profile SET resume_text=?, updated_at=? WHERE profile_id=?", + "五年 Java 与 Spring Boot 经验", "2026-09-03 10:01:00.000", 1L); + JobAnalysisTaskStore.SubmitResult second = store.submit(request(1L, "boss", "job-resume", "run-b")); + + assertThat(second.created()).isTrue(); + assertThat(second.task().id()).isNotEqualTo(first.task().id()); + String firstTaskKey = jdbcTemplate.queryForObject( + "SELECT task_key FROM job_analysis_task WHERE id=?", String.class, first.task().id()); + String secondTaskKey = jdbcTemplate.queryForObject( + "SELECT task_key FROM job_analysis_task WHERE id=?", String.class, second.task().id()); + assertThat(secondTaskKey).isNotEqualTo(firstTaskKey); } @Test diff --git a/tasks/2026-09-03-job-ai-batch-scoring.md b/tasks/2026-09-03-job-ai-batch-scoring.md new file mode 100644 index 0000000..c72d6f2 --- /dev/null +++ b/tasks/2026-09-03-job-ai-batch-scoring.md @@ -0,0 +1,63 @@ +# 岗位 AI 匹配提质、提速与失败修复 + +## 背景 + +当前岗位分析由模型直接给出分数与决策,理由以 JSON 字符串原样展示;持久队列逐岗调用 Codex CLI,并存在合法中文弯引号被兼容修复逻辑破坏的问题。 + +## 目标 + +- 使用六个固定维度在后端确定性计算分数和 APPLY/SKIP。 +- 将 AI 输出升级为带原文证据的 `schemaVersion: 2` 理由。 +- 同档案、同平台每批最多分析 5 岗,最多 2 批并发,单岗失败不影响同批其他岗位。 +- Boss 分析页结构化展示理由,显示队列状态并支持失败任务单岗重试。 + +## 允许修改范围 + +- `src/main/java/com/getjobs/application/service/JobAiAnalysisService.java` +- `src/main/java/com/getjobs/application/service/JobAnalysisTaskStore.java` +- `src/main/java/com/getjobs/application/service/ChromeJobAnalysisQueueService.java` +- `src/main/java/com/getjobs/application/controller/AiConfigController.java` +- 对应的 `src/test/java/com/getjobs/application/controller/` 测试 +- 对应的 `src/test/java/com/getjobs/application/service/` 测试 +- `front/app/boss/analysis/` 内的类型、工具、Hooks、组件和测试 +- 本任务说明文件 + +## 禁止修改范围 + +- AI Provider、登录方式、认证配置和当前 Codex 模型。 +- 数据库结构、用户简历、岗位历史数据和运行日志。 +- 自动投递或历史失败任务自动重跑。 +- 当前运行服务、RunDock 配置与端口监听;未经确认不得重启。 + +## 已确定实现要求 + +- 六维权重:核心职责与技能 35、相关经历 25、成果与复杂度 15、行业可迁移性 10、学历与年限 10、地点与薪资 5。 +- 状态系数:`MATCH=1.0`、`PARTIAL=0.75`、`UNKNOWN=0.6`、`CONFLICT=0`。 +- 简历未写只能是 `UNKNOWN`;硬冲突必须同时具有岗位与简历原文证据,并与一个 `CONFLICT` 分项复用双方证据,否则降级为待核实。 +- 最终决策:无有效硬冲突且分数达到普通或优先公司阈值才为 APPLY;APPLY 仍只进入待确认。 +- 先解析原始合法 JSON,失败后才兼容修复;批量 JSON 整体无效最多重试一次,单项缺失或无效只重试该岗位一次。 +- 每个岗位保留独立任务 ID、租约、终态与数据库写入。 +- 任务去重键包含当前简历指纹;Boss 页面任务列表及计数按平台隔离。 +- 记录每批岗位数量、耗时、失败数、未知数与失败率,供下一次正常扫描测量实际提速。 +- `aiReason` 继续使用字符串列,内部写入 `schemaVersion: 2`,前端兼容新版、旧版 JSON 与历史纯文本。 +- 页面可见且存在未完成任务时每 3 秒刷新;隐藏或无未完成任务时停止。 + +## 验收标准 + +- 合法 JSON 字符串中的中文弯引号不会触发失败。 +- 同档案、同平台 50 个岗位正常路径只调用 AI 10 次;批量并发不超过 2。 +- 混合档案或平台不会进入同一批;部分结果失败不影响其他岗位完成。 +- 阈值、未知证据与硬冲突规则由后端确定性测试证明。 +- Boss 页面不再直接显示原始 JSON或重复同一理由,失败任务可明确单岗重试。 + +## 测试命令 + +- `./gradlew.bat test` +- `pnpm test` +- `pnpm typecheck` +- `pnpm lint` +- `pnpm build:prod` + +## 返回格式 + +- 汇报变更文件、两次中文提交及 SHA、测试与 CI 结果、Push/PR 状态、未重启服务的说明和回滚方法。 From 3da9d120acea0b2fcfcea413db2b02d1fa69a606 Mon Sep 17 00:00:00 2001 From: damingishere-coder Date: Thu, 3 Sep 2026 16:57:42 +0800 Subject: [PATCH 09/12] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E9=98=BB?= =?UTF-8?q?=E6=AD=A2Boss=E6=90=9C=E7=B4=A2=E9=A1=B5=E9=87=8D=E5=AE=9A?= =?UTF-8?q?=E5=90=91=E5=BE=AA=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- chrome-extension/background.js | 6 +- chrome-extension/boss-content.js | 65 +++++++++++++++++-- chrome-extension/boss-scan-support.js | 33 +++++++++- chrome-extension/manifest.json | 2 +- .../tests/boss-scan-support.test.cjs | 58 +++++++++++++++++ chrome-extension/tests/manifest-id.test.cjs | 2 +- .../profile-scoped-scan-contract.test.cjs | 8 +-- chrome-extension/zhilian-content.js | 2 +- ...6-09-03-boss-search-navigation-loop-fix.md | 65 +++++++++++++++++++ 9 files changed, 225 insertions(+), 16 deletions(-) create mode 100644 tasks/2026-09-03-boss-search-navigation-loop-fix.md diff --git a/chrome-extension/background.js b/chrome-extension/background.js index 9834cd4..6b21927 100644 --- a/chrome-extension/background.js +++ b/chrome-extension/background.js @@ -32,13 +32,13 @@ const PLATFORM_SHARED_SCAN_KEYS = { boss: ["__GET_JOBS_BOSS_SHARED_SCAN_TASK__", "__GET_JOBS_BOSS_SHARED_SCAN_CANCEL__"], zhilian: ["__GET_JOBS_ZHILIAN_SHARED_SCAN_TASK__", "__GET_JOBS_ZHILIAN_SHARED_SCAN_CANCEL__"] }; -const BACKGROUND_VERSION = "2026-09-03-profile-scoped-scan"; +const BACKGROUND_VERSION = "2026-09-03-boss-navigation-loop-fix"; const CONTENT_READY_RETRIES = 12; const CONTENT_READY_INTERVAL_MS = 250; const TAB_LOAD_TIMEOUT_MS = 10000; const DELIVERY_NAVIGATION_TIMEOUT_MS = 15000; -const REQUIRED_BOSS_CONTENT_VERSION = "2026-09-03-profile-scoped-scan"; -const REQUIRED_ZHILIAN_CONTENT_VERSION = "2026-09-03-profile-scoped-scan"; +const REQUIRED_BOSS_CONTENT_VERSION = "2026-09-03-boss-navigation-loop-fix"; +const REQUIRED_ZHILIAN_CONTENT_VERSION = "2026-09-03-boss-navigation-loop-fix"; const LOCAL_API_BASE_URLS = ["http://localhost:6866", "http://127.0.0.1:6866"]; const BOSS_LOCAL_API_MAX_ATTEMPTS = 3; const BOSS_LOCAL_API_TIMEOUT_MS = 30000; diff --git a/chrome-extension/boss-content.js b/chrome-extension/boss-content.js index e9eb1b0..2349a9c 100644 --- a/chrome-extension/boss-content.js +++ b/chrome-extension/boss-content.js @@ -1,5 +1,5 @@ (function () { - const EXTENSION_VERSION = "2026-09-03-profile-scoped-scan"; + const EXTENSION_VERSION = "2026-09-03-boss-navigation-loop-fix"; const CONTENT_INSTANCE_ID = `${Date.now()}-${Math.random().toString(16).slice(2)}`; window.__GET_JOBS_BOSS_CONTENT__ = true; window.__GET_JOBS_BOSS_CONTENT_VERSION__ = EXTENSION_VERSION; @@ -205,7 +205,7 @@ // 关键检查:必须在Boss搜索结果页才能采集 if (!diagnostics.isSearchPage) { - const text = `当前不是Boss岗位搜索结果页,无法采集。请在Chrome中打开Boss搜索页(如 https://www.zhipin.com/web/geek/job?city=101280600&query=Java),再重新采集。当前页面:${diagnostics.currentUrl || window.location.href}`; + const text = `当前不是Boss岗位搜索结果页,无法采集。请在Chrome中打开Boss搜索页(如 https://www.zhipin.com/web/geek/jobs?city=101280600&query=Java),再重新采集。当前页面:${diagnostics.currentUrl || window.location.href}`; postProgress(message, "warning", text, { operation: "listCollect", stage: "blocked", @@ -971,7 +971,7 @@ markKeywordCursorCurrent(task, index, keyword); const url = buildSearchUrl(keyword, city, config); const navigationKey = buildNavigationKey(keyword, city); - const navigationAttempts = task.navigationKey === navigationKey ? Number(task.navigationAttempts || 0) : 0; + const navigationAttempts = bossSearchNavigationAttempts(task, navigationKey); const nextNavigationAttempts = navigationAttempts + 1; const searchTaskState = { ...task, @@ -1058,7 +1058,11 @@ return { success: true, saved: totalSaved, pendingNavigation: true }; } - storeScanTask({ ...searchTaskState, phase: "collecting", navigationAttempts: 0, navigationStartedAt: 0 }); + const collectingTaskState = beginBossSearchCollection({ + ...searchTaskState, + navigationAttempts + }); + storeScanTask(collectingTaskState); postProgress(task, "info", `Boss Chrome开始搜索:${keyword},当前URL:${window.location.href}`, { ...baseMeta, stage: "searching", @@ -1082,6 +1086,23 @@ if (handleBlockingState(task, waitState.diagnostics, baseMeta)) { return { success: true, saved: totalSaved, blocked: true }; } + if (!isCurrentSearchPage(keyword, city, url)) { + if (isBossSearchNavigationExhausted(collectingTaskState)) { + return stopSearchNavigationFailure(collectingTaskState, url); + } + const retryTaskState = retryBossSearchNavigation(collectingTaskState); + postProgress(task, "warning", `Boss搜索页在列表加载期间被重定向,准备重新打开:${keyword}(第 ${retryTaskState.navigationAttempts} 次导航),目标URL:${url},当前URL:${window.location.href}`, { + ...baseMeta, + stage: "searching", + currentUrl: window.location.href, + targetUrl: url, + navigationAttempts: retryTaskState.navigationAttempts, + diagnosticType: "SEARCH_PAGE_REDIRECTED" + }); + storeScanTask(retryTaskState); + openSearchPage(url, retryTaskState); + return { success: true, saved: totalSaved, pendingNavigation: true }; + } postProgress(task, "info", `Boss岗位列表加载检查完成,开始滚动采集。详情链接 ${waitState.diagnostics.detailLinks} 个,搜索结果容器 ${waitState.diagnostics.resultContainers} 个。`, { ...baseMeta, stage: "collecting", @@ -1298,7 +1319,7 @@ addList(params, "industry", config.industry); addList(params, "stage", config.stage); params.set("query", keyword); - return `https://www.zhipin.com/web/geek/job?${params.toString()}`; + return `https://www.zhipin.com/web/geek/jobs?${params.toString()}`; } function collectJobs(keyword, message, baseMeta, options = {}) { @@ -4093,6 +4114,40 @@ } } + function bossSearchNavigationAttempts(task, navigationKey) { + return SCAN_SUPPORT.bossSearchNavigationAttempts + ? SCAN_SUPPORT.bossSearchNavigationAttempts(task, navigationKey) + : task?.navigationKey === navigationKey + ? Math.max(0, Math.floor(Number(task?.navigationAttempts) || 0)) + : 0; + } + + function beginBossSearchCollection(task) { + if (SCAN_SUPPORT.beginBossSearchCollection) return SCAN_SUPPORT.beginBossSearchCollection(task); + return { + ...(task || {}), + phase: "collecting", + navigationAttempts: Math.max(0, Math.floor(Number(task?.navigationAttempts) || 0)), + navigationStartedAt: 0 + }; + } + + function retryBossSearchNavigation(task) { + if (SCAN_SUPPORT.retryBossSearchNavigation) return SCAN_SUPPORT.retryBossSearchNavigation(task); + return { + ...(task || {}), + phase: "searching", + navigationAttempts: Math.max(0, Math.floor(Number(task?.navigationAttempts) || 0)) + 1, + navigationStartedAt: Date.now() + }; + } + + function isBossSearchNavigationExhausted(task) { + return SCAN_SUPPORT.isBossSearchNavigationExhausted + ? SCAN_SUPPORT.isBossSearchNavigationExhausted(task, SEARCH_NAVIGATION_MAX_ATTEMPTS) + : Math.max(0, Math.floor(Number(task?.navigationAttempts) || 0)) >= SEARCH_NAVIGATION_MAX_ATTEMPTS; + } + function isSameSearchUrl(left, right) { try { const leftUrl = new URL(left, window.location.origin); diff --git a/chrome-extension/boss-scan-support.js b/chrome-extension/boss-scan-support.js index 05bead6..b4596d9 100644 --- a/chrome-extension/boss-scan-support.js +++ b/chrome-extension/boss-scan-support.js @@ -1,5 +1,5 @@ (function (root) { - const SUPPORT_VERSION = "2026-09-03-keyword-deep-fill"; + const SUPPORT_VERSION = "2026-09-03-boss-navigation-loop-fix"; if (root.GetJobsBossScanSupport?.version === SUPPORT_VERSION) return; const DEFAULT_TASK_TTL_MS = 24 * 60 * 60 * 1000; @@ -113,6 +113,33 @@ return true; } + function bossSearchNavigationAttempts(task, navigationKey) { + if (!task || task.navigationKey !== navigationKey) return 0; + return Math.max(0, Math.floor(Number(task.navigationAttempts) || 0)); + } + + function beginBossSearchCollection(task) { + return { + ...(task || {}), + phase: "collecting", + navigationAttempts: Math.max(0, Math.floor(Number(task?.navigationAttempts) || 0)), + navigationStartedAt: 0 + }; + } + + function retryBossSearchNavigation(task, now = Date.now()) { + return { + ...(task || {}), + phase: "searching", + navigationAttempts: Math.max(0, Math.floor(Number(task?.navigationAttempts) || 0)) + 1, + navigationStartedAt: Number(now) + }; + } + + function isBossSearchNavigationExhausted(task, maxAttempts = 5) { + return Math.max(0, Math.floor(Number(task?.navigationAttempts) || 0)) >= Math.max(1, Math.floor(Number(maxAttempts) || 5)); + } + function normalizeRunId(value) { return String(value || "").trim(); } @@ -272,6 +299,10 @@ isFreshTask, sameScanRun, canResumeScanTask, + bossSearchNavigationAttempts, + beginBossSearchCollection, + retryBossSearchNavigation, + isBossSearchNavigationExhausted, normalizeBossJobUrl, extractBossJobId, isBossJobDetailUrl, diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json index cb76d5b..5d04278 100644 --- a/chrome-extension/manifest.json +++ b/chrome-extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "投递牛马 Chrome Bridge", - "version": "1.4.2", + "version": "1.4.3", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzzdIlNVOv76Y/cSWrjD5Tg2Vlsha8yWHzsn46PBsg724/2dftOUzIIr2n70VRaRgGwEd8FjO/Y768Ori443zF4pQpWvuxXxm05YO25ILQ/+aJLmUycAEdWbkdhcagr4YXnXJdYlSCGSAToSQBjk+owQOdlBLQn5wofPoshrqayoJjRQ5aAUj1SuSlnNv9iimle8GMA1IaA1l5rw6K/chfcgwMTg6HxRAIoludt5JGbIBryi2Lu1hOJRMaDnL7A57ofBnn3qx3H2HIGWGkkTW9EMkls0XMXwx8+mJVIj5HSYl0EeuCvEoTa1W3i1CbOf3kY2yCPKS3Qz3lOvJiwJ4ZQIDAQAB", "description": "Use the signed-in Chrome tabs to scan jobs and confirm deliveries for 投递牛马.", "icons": { diff --git a/chrome-extension/tests/boss-scan-support.test.cjs b/chrome-extension/tests/boss-scan-support.test.cjs index 6700317..21597c4 100644 --- a/chrome-extension/tests/boss-scan-support.test.cjs +++ b/chrome-extension/tests/boss-scan-support.test.cjs @@ -265,6 +265,64 @@ test("classifies CORS and local service failures for actionable diagnostics", () ); }); +test("preserves Boss search attempts while collecting and increments after route drift", () => { + const support = loadSupport(); + assert.equal(support.version, "2026-09-03-boss-navigation-loop-fix"); + const collecting = support.beginBossSearchCollection({ + type: "BOSS_SCAN_START", + navigationKey: "AIGC产品运营::101280600", + navigationAttempts: 3, + navigationStartedAt: 123 + }); + const retrying = support.retryBossSearchNavigation(collecting, 456); + + assert.equal(collecting.phase, "collecting"); + assert.equal(collecting.navigationAttempts, 3); + assert.equal(collecting.navigationStartedAt, 0); + assert.equal(retrying.phase, "searching"); + assert.equal(retrying.navigationAttempts, 4); + assert.equal(retrying.navigationStartedAt, 456); +}); + +test("stops repeated Boss search redirects at five attempts and resets for the next keyword", () => { + const support = loadSupport(); + const firstNavigationKey = "AIGC产品运营::101280600"; + let task = { + type: "BOSS_SCAN_START", + navigationKey: firstNavigationKey, + navigationAttempts: 0 + }; + + for (let attempt = 1; attempt <= 5; attempt += 1) { + task = support.retryBossSearchNavigation(task, attempt); + assert.equal(task.navigationAttempts, attempt); + assert.equal(support.isBossSearchNavigationExhausted(task, 5), attempt === 5); + } + + assert.equal(support.bossSearchNavigationAttempts(task, firstNavigationKey), 5); + assert.equal(support.bossSearchNavigationAttempts(task, "AI产品运营::101280600"), 0); +}); + +test("keeps singular and plural Boss search paths compatible while generating the canonical plural path", () => { + const content = fs.readFileSync(path.resolve(__dirname, "..", "boss-content.js"), "utf8"); + + assert.match(content, /return `https:\/\/www\.zhipin\.com\/web\/geek\/jobs\?\$\{params\.toString\(\)\}`/); + assert.match(content, /pathname === "\/web\/geek\/job" \|\| pathname === "\/web\/geek\/jobs"/); +}); + +test("rechecks the Boss search URL after waiting for cards before collecting", () => { + const content = fs.readFileSync(path.resolve(__dirname, "..", "boss-content.js"), "utf8"); + const waitIndex = content.indexOf("const waitState = await waitForJobCards();"); + const redirectGuardIndex = content.indexOf("if (!isCurrentSearchPage(keyword, city, url))", waitIndex); + const collectIndex = content.indexOf("const collectResult = collectJobs", waitIndex); + + assert.ok(waitIndex >= 0); + assert.ok(redirectGuardIndex > waitIndex); + assert.ok(collectIndex > redirectGuardIndex); + assert.match(content.slice(redirectGuardIndex, collectIndex), /retryBossSearchNavigation\(collectingTaskState\)/); + assert.match(content.slice(redirectGuardIndex, collectIndex), /stopSearchNavigationFailure\(collectingTaskState, url\)/); +}); + test("partitions all historical Boss jobs for reuse without detail collection", () => { const support = loadSupport(); const jobs = [ diff --git a/chrome-extension/tests/manifest-id.test.cjs b/chrome-extension/tests/manifest-id.test.cjs index caebea8..996f370 100644 --- a/chrome-extension/tests/manifest-id.test.cjs +++ b/chrome-extension/tests/manifest-id.test.cjs @@ -17,7 +17,7 @@ function extensionIdFromKey(key) { test('manifest public key derives the backend allowlisted extension id', () => { const manifestPath = path.join(__dirname, '..', 'manifest.json'); const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); - assert.equal(manifest.version, '1.4.2'); + assert.equal(manifest.version, '1.4.3'); assert.equal(extensionIdFromKey(manifest.key), EXPECTED_EXTENSION_ID); const publicKey = crypto.createPublicKey({ diff --git a/chrome-extension/tests/profile-scoped-scan-contract.test.cjs b/chrome-extension/tests/profile-scoped-scan-contract.test.cjs index a5840dc..d6357e2 100644 --- a/chrome-extension/tests/profile-scoped-scan-contract.test.cjs +++ b/chrome-extension/tests/profile-scoped-scan-contract.test.cjs @@ -15,10 +15,10 @@ test("extension release and both content scripts use the profile-scoped contract const boss = source("boss-content.js"); const zhilian = source("zhilian-content.js"); - assert.equal(manifest.version, "1.4.2"); - assert.match(background, /BACKGROUND_VERSION = "2026-09-03-profile-scoped-scan"/); - assert.match(boss, /EXTENSION_VERSION = "2026-09-03-profile-scoped-scan"/); - assert.match(zhilian, /EXTENSION_VERSION = "2026-09-03-profile-scoped-scan"/); + assert.equal(manifest.version, "1.4.3"); + assert.match(background, /BACKGROUND_VERSION = "2026-09-03-boss-navigation-loop-fix"/); + assert.match(boss, /EXTENSION_VERSION = "2026-09-03-boss-navigation-loop-fix"/); + assert.match(zhilian, /EXTENSION_VERSION = "2026-09-03-boss-navigation-loop-fix"/); }); test("both platforms bind cursors, dedupe, submissions and progress to profileId", () => { diff --git a/chrome-extension/zhilian-content.js b/chrome-extension/zhilian-content.js index 8201b8b..eff5772 100644 --- a/chrome-extension/zhilian-content.js +++ b/chrome-extension/zhilian-content.js @@ -1,5 +1,5 @@ (function () { - const EXTENSION_VERSION = "2026-09-03-profile-scoped-scan"; + const EXTENSION_VERSION = "2026-09-03-boss-navigation-loop-fix"; const CONTENT_INSTANCE_ID = `${Date.now()}-${Math.random().toString(16).slice(2)}`; window.__GET_JOBS_ZHILIAN_CONTENT__ = true; window.__GET_JOBS_ZHILIAN_CONTENT_VERSION__ = EXTENSION_VERSION; diff --git a/tasks/2026-09-03-boss-search-navigation-loop-fix.md b/tasks/2026-09-03-boss-search-navigation-loop-fix.md new file mode 100644 index 0000000..637a175 --- /dev/null +++ b/tasks/2026-09-03-boss-search-navigation-loop-fix.md @@ -0,0 +1,65 @@ +# Boss 搜索页导航循环修复 + +## 背景 + +蒋银峰档案的真实 Boss 扫描在第 7/8 个关键词“AIGC产品运营”处反复往返于搜索页和岗位详情页。已确认页面进入采集阶段时会把导航重试次数清零,Boss 再次重定向后无法触发既有的 5 次失败上限。 + +## 目标 + +- 搜索页进入采集阶段后保留本关键词已经发生的导航次数。 +- 等待岗位列表期间如果页面漂移到详情页,重新进入搜索导航并累计一次重试。 +- 同一关键词连续重定向达到 5 次后以 `NAVIGATION_FAILED` 结束,不再无限循环。 +- 新关键词使用新的导航键,从 0 次重新计数。 +- 新生成的 Boss 搜索链接使用当前 `/web/geek/jobs` 路径,同时继续兼容历史 `/web/geek/job` 链接。 + +## 允许修改范围 + +- `chrome-extension/boss-content.js` +- `chrome-extension/boss-scan-support.js` +- `chrome-extension/background.js` +- `chrome-extension/zhilian-content.js` +- `chrome-extension/manifest.json` +- `chrome-extension/tests/*.test.cjs` +- 本任务文件 + +## 禁止修改范围 + +- 后端 AI Provider、模型、登录和认证配置。 +- 数据库、历史岗位、现有失败任务和投递状态。 +- 服务启动方式、端口和生产数据。 +- 自动重启服务、自动重新扫描或自动投递。 + +## 已确定实现要求 + +1. 导航次数由纯函数统一读取、进入采集时保留、重定向重试时递增。 +2. `waitForJobCards` 返回后必须再次校验当前 URL 是否仍是本关键词搜索页。 +3. 页面漂移时保存可恢复的 `searching` 检查点,再调用现有导航逻辑。 +4. 达到现有 `SEARCH_NAVIGATION_MAX_ATTEMPTS` 时复用现有失败出口。 +5. 提升扩展版本,使后台能识别并重载修复后的内容脚本。 + +## 验收标准 + +- `/web/geek/job` 与 `/web/geek/jobs` 仍被视为同一搜索路径。 +- 进入采集不会把非零导航次数重置为 0。 +- 采集阶段发生路由漂移会递增次数并回到 `searching`。 +- 连续 5 次重定向会达到失败上限。 +- 导航键变化后尝试次数归零。 +- 扩展单测、扩展校验、后端测试和前端质量检查通过。 + +## 测试命令 + +- `node --test chrome-extension/tests/*.test.cjs` +- `node scripts/validate-chrome-extension.mjs` +- `gradlew.bat test` +- `pnpm --dir front test` +- `pnpm --dir front typecheck` +- `pnpm --dir front lint` +- `pnpm --dir front build:prod` +- `git diff --check` + +## 返回格式 + +- 修复根因与行为变化。 +- 修改文件、测试结果和未执行的运行态操作。 +- 分支、提交、Push、PR 和 CI 状态。 +- 回滚方式与再次扫描前的人工步骤。 From 03b51b67679f9ad1ab4ef4bdee5fa3a4e5025261 Mon Sep 17 00:00:00 2001 From: damingishere-coder Date: Thu, 3 Sep 2026 17:41:43 +0800 Subject: [PATCH 10/12] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E5=8E=86?= =?UTF-8?q?=E5=8F=B2Boss=E5=B2=97=E4=BD=8D=E5=BA=94=E7=94=A8=E6=96=B0?= =?UTF-8?q?=E5=88=86=E6=95=B0=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- front/app/boss/analysis/AnalysisContent.tsx | 5 +- .../components/BossThresholdSettings.test.tsx | 67 +++++++++ .../components/BossThresholdSettings.tsx | 21 ++- .../controller/AiConfigController.java | 9 +- .../service/JobAiAnalysisService.java | 42 ++++++ .../AiConfigControllerThresholdTest.java | 14 +- ...esholdReclassificationIntegrationTest.java | 131 ++++++++++++++++++ .../JobAiAnalysisServiceStatusTest.java | 49 +++++++ ...boss-history-threshold-reclassification.md | 63 +++++++++ 9 files changed, 391 insertions(+), 10 deletions(-) create mode 100644 front/app/boss/analysis/components/BossThresholdSettings.test.tsx create mode 100644 src/test/java/com/getjobs/application/service/BossThresholdReclassificationIntegrationTest.java create mode 100644 tasks/2026-09-03-boss-history-threshold-reclassification.md diff --git a/front/app/boss/analysis/AnalysisContent.tsx b/front/app/boss/analysis/AnalysisContent.tsx index 6879a82..6c286a9 100644 --- a/front/app/boss/analysis/AnalysisContent.tsx +++ b/front/app/boss/analysis/AnalysisContent.tsx @@ -287,7 +287,10 @@ export default function AnalysisContent({ onConfirmBatch={handleConfirmBatch} />
- + { + setSelectedManualJobIds(new Set()) + await Promise.all([loadList(1, size), refreshStats()]) + }} />
AI分析队列 排队中 {analysisPendingCount} diff --git a/front/app/boss/analysis/components/BossThresholdSettings.test.tsx b/front/app/boss/analysis/components/BossThresholdSettings.test.tsx new file mode 100644 index 0000000..8cd6df5 --- /dev/null +++ b/front/app/boss/analysis/components/BossThresholdSettings.test.tsx @@ -0,0 +1,67 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { afterEach, describe, expect, it, vi } from "vitest" + +import { BossThresholdSettings } from "./BossThresholdSettings" + +const jsonResponse = (payload: unknown, status = 200) => new Response(JSON.stringify(payload), { + status, + headers: { "Content-Type": "application/json" }, +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe("Boss分数线历史岗位更新", () => { + it("保存后展示历史提升数量并通知分析页刷新", async () => { + const onApplied = vi.fn().mockResolvedValue(undefined) + const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + if (!init?.method) { + return Promise.resolve(jsonResponse({ + success: true, + data: { applyThreshold: 75, priorityApplyThreshold: 65 }, + })) + } + return Promise.resolve(jsonResponse({ + success: true, + data: { + applyThreshold: 60, + priorityApplyThreshold: 60, + bossHistoricalPromotedCount: 58, + }, + })) + }) + vi.stubGlobal("fetch", fetchMock) + render() + + const applyInput = await screen.findByLabelText("普通公司最低分") + fireEvent.change(applyInput, { target: { value: "60" } }) + fireEvent.click(screen.getByRole("button", { name: "保存分数线" })) + + expect(await screen.findByText(/58个历史岗位已改为“待确认”/)).toBeInTheDocument() + expect(onApplied).toHaveBeenCalledTimes(1) + const postCall = fetchMock.mock.calls.find((call) => call[1]?.method === "POST") + expect(postCall).toBeDefined() + expect(JSON.parse(String(postCall?.[1]?.body))).toEqual({ + applyThreshold: 60, + priorityApplyThreshold: 60, + }) + }) + + it("数据已保存但列表刷新失败时给出准确提示", async () => { + const onApplied = vi.fn().mockRejectedValue(new Error("refresh failed")) + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL, init?: RequestInit) => Promise.resolve(jsonResponse({ + success: true, + data: init?.method + ? { applyThreshold: 60, priorityApplyThreshold: 50, bossHistoricalPromotedCount: 3 } + : { applyThreshold: 60, priorityApplyThreshold: 50 }, + })))) + render() + + await screen.findByDisplayValue("60") + fireEvent.click(screen.getByRole("button", { name: "保存分数线" })) + + expect(await screen.findByText(/3个历史岗位已改为“待确认”/)).toBeInTheDocument() + await waitFor(() => expect(screen.getByText(/列表刷新失败/)).toBeInTheDocument()) + }) +}) diff --git a/front/app/boss/analysis/components/BossThresholdSettings.tsx b/front/app/boss/analysis/components/BossThresholdSettings.tsx index ad9713b..68431f6 100644 --- a/front/app/boss/analysis/components/BossThresholdSettings.tsx +++ b/front/app/boss/analysis/components/BossThresholdSettings.tsx @@ -14,6 +14,11 @@ const DEFAULT_PRIORITY_APPLY_THRESHOLD = 65 type ThresholdConfig = { applyThreshold: number priorityApplyThreshold: number + bossHistoricalPromotedCount?: number +} + +type BossThresholdSettingsProps = { + onApplied?: () => void | Promise } const parseThreshold = (value: unknown, fallback: number) => { @@ -21,7 +26,7 @@ const parseThreshold = (value: unknown, fallback: number) => { return Number.isInteger(parsed) && parsed >= 0 && parsed <= 100 ? parsed : fallback } -export function BossThresholdSettings() { +export function BossThresholdSettings({ onApplied }: BossThresholdSettingsProps) { const [thresholds, setThresholds] = useState({ applyThreshold: DEFAULT_APPLY_THRESHOLD, priorityApplyThreshold: DEFAULT_PRIORITY_APPLY_THRESHOLD, @@ -91,8 +96,18 @@ export function BossThresholdSettings() { thresholds.priorityApplyThreshold, ), } + const promotedCount = Math.max(0, Math.trunc(Number(result.data?.bossHistoricalPromotedCount) || 0)) setThresholds(savedThresholds) - setMessage(`已保存:普通公司${savedThresholds.applyThreshold}分,优先公司${savedThresholds.priorityApplyThreshold}分`) + const successMessage = `已保存:普通公司${savedThresholds.applyThreshold}分,优先公司${savedThresholds.priorityApplyThreshold}分;${promotedCount}个历史岗位已改为“待确认”` + setMessage(successMessage) + if (onApplied) { + try { + await onApplied() + } catch (refreshError) { + console.error("分数线保存后刷新Boss岗位失败:", refreshError) + setError("分数线和历史岗位已经更新,但列表刷新失败,请点击“刷新”重新加载。") + } + } } catch (saveError) { console.error("保存AI投递分数线失败:", saveError) setError(friendlyApiError(saveError, "分数线保存失败")) @@ -164,7 +179,7 @@ export function BossThresholdSettings() {

- AI分数达到或超过对应分数线后进入“待确认”,仍需你确认才会实际投递。 + 保存后,历史“AI不匹配”岗位也会按已有AI分数自动更新;进入“待确认”后仍需你确认才会实际投递。

{message ?

{message}

: null} {error ?

{error}

: null} diff --git a/src/main/java/com/getjobs/application/controller/AiConfigController.java b/src/main/java/com/getjobs/application/controller/AiConfigController.java index c3bef13..290af3a 100644 --- a/src/main/java/com/getjobs/application/controller/AiConfigController.java +++ b/src/main/java/com/getjobs/application/controller/AiConfigController.java @@ -142,13 +142,16 @@ public ResponseEntity> getAiThresholds() { public ResponseEntity> saveAiThresholds(@RequestBody AiThresholdRequest requestBody) { Map response = new HashMap<>(); try { - AiEntity aiEntity = aiService.saveOrUpdateAiThresholds( + JobAiAnalysisService.ThresholdApplicationResult result = + jobAiAnalysisService.saveThresholdsAndPromoteBossHistory( requestBody.getApplyThreshold(), requestBody.getPriorityApplyThreshold() ); + Map data = thresholdData(result.thresholds()); + data.put("bossHistoricalPromotedCount", result.bossHistoricalPromotedCount()); response.put("success", true); - response.put("data", thresholdData(aiEntity)); - response.put("message", "AI投递分数线已保存"); + response.put("data", data); + response.put("message", "AI投递分数线已保存,历史Boss岗位已更新"); return ResponseEntity.ok(response); } catch (IllegalArgumentException e) { log.warn("AI投递分数线参数不合法: {}", e.getMessage()); diff --git a/src/main/java/com/getjobs/application/service/JobAiAnalysisService.java b/src/main/java/com/getjobs/application/service/JobAiAnalysisService.java index 5b11f7e..fc3672c 100644 --- a/src/main/java/com/getjobs/application/service/JobAiAnalysisService.java +++ b/src/main/java/com/getjobs/application/service/JobAiAnalysisService.java @@ -133,6 +133,45 @@ public class JobAiAnalysisService { private final Job51Mapper job51Mapper; private final ConcurrentMap> enabledPriorityCompanyCache = new ConcurrentHashMap<>(); + /** + * 保存当前档案的投递分数线,并让历史 Boss AI 不匹配岗位应用新分数线。 + * 这里只复用已经保存的 AI 分数,不重新调用 Provider,也不创建投递请求。 + */ + @Transactional + public ThresholdApplicationResult saveThresholdsAndPromoteBossHistory( + Integer applyThreshold, + Integer priorityApplyThreshold + ) { + AiEntity saved = aiService.saveOrUpdateAiThresholds(applyThreshold, priorityApplyThreshold); + if (saved == null || saved.getProfileId() == null) { + throw new IllegalStateException("AI分数线保存后缺少档案信息"); + } + + BossJobDataEntity update = new BossJobDataEntity(); + update.setDeliveryStatus(DeliveryStatus.WAITING_CONFIRM); + update.setAiDecision("APPLY"); + update.setUpdatedAt(LocalDateTime.now()); + + UpdateWrapper wrapper = new UpdateWrapper<>(); + wrapper.eq("profile_id", saved.getProfileId()) + .eq("delivery_status", DeliveryStatus.AI_NOT_MATCH) + .isNotNull("ai_score") + .and(group -> group + .eq("priority_company", 1) + .ge("ai_score", saved.getPriorityApplyThreshold()) + .or(normal -> normal + .and(priorityFlag -> priorityFlag + .isNull("priority_company") + .or() + .ne("priority_company", 1)) + .ge("ai_score", saved.getApplyThreshold()))); + + int promotedCount = bossJobDataMapper.update(update, wrapper); + log.info("Boss历史岗位已应用新分数线: profileId={}, promotedCount={}", + saved.getProfileId(), promotedCount); + return new ThresholdApplicationResult(saved, promotedCount); + } + @Transactional public ResumeProfileEntity saveResumeText(String resumeText, String sourceFilename, String status, String message) { Long profileId = profileService.getCurrentProfileId(); @@ -1482,6 +1521,9 @@ public static class JobAnalysisRequest { private String scanRunId; } + public record ThresholdApplicationResult(AiEntity thresholds, int bossHistoricalPromotedCount) { + } + public record PlatformAnalysisState(boolean completed, boolean failed, String status) { public static PlatformAnalysisState incomplete(String status) { return new PlatformAnalysisState(false, false, status); diff --git a/src/test/java/com/getjobs/application/controller/AiConfigControllerThresholdTest.java b/src/test/java/com/getjobs/application/controller/AiConfigControllerThresholdTest.java index 54c5265..db4b445 100644 --- a/src/test/java/com/getjobs/application/controller/AiConfigControllerThresholdTest.java +++ b/src/test/java/com/getjobs/application/controller/AiConfigControllerThresholdTest.java @@ -2,6 +2,7 @@ import com.getjobs.application.entity.AiEntity; import com.getjobs.application.service.AiService; +import com.getjobs.application.service.JobAiAnalysisService; import com.getjobs.application.service.ProfileService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -16,23 +17,28 @@ class AiConfigControllerThresholdTest { private AiService aiService; + private JobAiAnalysisService jobAiAnalysisService; private AiConfigController controller; @BeforeEach void setUp() { aiService = mock(AiService.class); + jobAiAnalysisService = mock(JobAiAnalysisService.class); ProfileService profileService = mock(ProfileService.class); controller = new AiConfigController(); ReflectionTestUtils.setField(controller, "aiService", aiService); + ReflectionTestUtils.setField(controller, "jobAiAnalysisService", jobAiAnalysisService); ReflectionTestUtils.setField(controller, "profileService", profileService); } @Test void savesAndReturnsServerThresholdValues() { AiEntity saved = new AiEntity(); + saved.setProfileId(1L); saved.setApplyThreshold(60); saved.setPriorityApplyThreshold(50); - when(aiService.saveOrUpdateAiThresholds(60, 50)).thenReturn(saved); + when(jobAiAnalysisService.saveThresholdsAndPromoteBossHistory(60, 50)) + .thenReturn(new JobAiAnalysisService.ThresholdApplicationResult(saved, 7)); AiConfigController.AiThresholdRequest request = new AiConfigController.AiThresholdRequest(); request.setApplyThreshold(60); @@ -46,12 +52,14 @@ void savesAndReturnsServerThresholdValues() { Map data = (Map) response.getBody().get("data"); assertThat(data) .containsEntry("applyThreshold", 60) - .containsEntry("priorityApplyThreshold", 50); + .containsEntry("priorityApplyThreshold", 50) + .containsEntry("bossHistoricalPromotedCount", 7); + assertThat(response.getBody().get("message").toString()).contains("历史Boss岗位已更新"); } @Test void returnsReadableValidationError() { - when(aiService.saveOrUpdateAiThresholds(60, 70)) + when(jobAiAnalysisService.saveThresholdsAndPromoteBossHistory(60, 70)) .thenThrow(new IllegalArgumentException("优先公司分数线不能高于普通公司分数线")); AiConfigController.AiThresholdRequest request = new AiConfigController.AiThresholdRequest(); diff --git a/src/test/java/com/getjobs/application/service/BossThresholdReclassificationIntegrationTest.java b/src/test/java/com/getjobs/application/service/BossThresholdReclassificationIntegrationTest.java new file mode 100644 index 0000000..8e7fe36 --- /dev/null +++ b/src/test/java/com/getjobs/application/service/BossThresholdReclassificationIntegrationTest.java @@ -0,0 +1,131 @@ +package com.getjobs.application.service; + +import com.getjobs.application.init.ZhilianOptionInitializer; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE, properties = { + "app.auto-open-browser=false", + "app.browser.initialize-on-startup=false", + "app.static-server.enabled=false" +}) +class BossThresholdReclassificationIntegrationTest { + private static final Path TEST_ROOT = createTestRoot(); + + @DynamicPropertySource + static void testProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", () -> "jdbc:sqlite:" + TEST_ROOT.resolve("thresholds.db")); + registry.add("app.paths.data-dir", () -> TEST_ROOT.resolve("data").toString()); + registry.add("app.paths.output-dir", () -> TEST_ROOT.resolve("output").toString()); + registry.add("app.paths.cache-dir", () -> TEST_ROOT.resolve("cache").toString()); + registry.add("app.paths.log-dir", () -> TEST_ROOT.resolve("logs").toString()); + registry.add("logging.file.name", () -> TEST_ROOT.resolve("logs/get-jobs.log").toString()); + } + + @Autowired + private JobAiAnalysisService jobAiAnalysisService; + + @Autowired + private ProfileService profileService; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @MockitoBean + private ZhilianOptionInitializer zhilianOptionInitializer; + + @Test + void savingThresholdsPromotesOnlyEligibleCurrentProfileBossRowsAndIsIdempotent() { + jdbcTemplate.update("INSERT INTO profile(name, is_active) VALUES ('当前档案', 1)"); + long profileId = profileService.getCurrentProfileId(); + jdbcTemplate.update("INSERT INTO profile(name, is_active) VALUES ('其他档案', 0)"); + long otherProfileId = jdbcTemplate.queryForObject("SELECT MAX(id) FROM profile", Long.class); + + insertBoss(profileId, "normal-pass", DeliveryStatus.AI_NOT_MATCH, 60, "SKIP", 0, "normal-reason"); + insertBoss(profileId, "normal-low", DeliveryStatus.AI_NOT_MATCH, 59, "SKIP", 0, "normal-low-reason"); + insertBoss(profileId, "priority-pass", DeliveryStatus.AI_NOT_MATCH, 50, "SKIP", 1, "priority-reason"); + insertBoss(profileId, "priority-low", DeliveryStatus.AI_NOT_MATCH, 49, "SKIP", 1, "priority-low-reason"); + insertBoss(profileId, "null-score", DeliveryStatus.AI_NOT_MATCH, null, "SKIP", 0, "null-reason"); + insertBoss(profileId, "locked", DeliveryStatus.DELIVERED, 99, "APPLY", 0, "locked-reason"); + insertBoss(profileId, "skipped", DeliveryStatus.SKIPPED, 99, "SKIP", 0, "skipped-reason"); + insertBoss(otherProfileId, "other-profile", DeliveryStatus.AI_NOT_MATCH, 99, "SKIP", 0, "other-reason"); + jdbcTemplate.update(""" + INSERT INTO job_ai_analysis(profile_id, platform, job_key, score, decision, summary) + VALUES (?, 'boss', 'normal-pass', 60, 'SKIP', '历史分析') + """, profileId); + + JobAiAnalysisService.ThresholdApplicationResult first = + jobAiAnalysisService.saveThresholdsAndPromoteBossHistory(60, 50); + JobAiAnalysisService.ThresholdApplicationResult second = + jobAiAnalysisService.saveThresholdsAndPromoteBossHistory(60, 50); + + assertThat(first.bossHistoricalPromotedCount()).isEqualTo(2); + assertThat(second.bossHistoricalPromotedCount()).isZero(); + assertBoss("normal-pass", DeliveryStatus.WAITING_CONFIRM, "APPLY", "normal-reason"); + assertBoss("priority-pass", DeliveryStatus.WAITING_CONFIRM, "APPLY", "priority-reason"); + assertBoss("normal-low", DeliveryStatus.AI_NOT_MATCH, "SKIP", "normal-low-reason"); + assertBoss("priority-low", DeliveryStatus.AI_NOT_MATCH, "SKIP", "priority-low-reason"); + assertBoss("null-score", DeliveryStatus.AI_NOT_MATCH, "SKIP", "null-reason"); + assertBoss("locked", DeliveryStatus.DELIVERED, "APPLY", "locked-reason"); + assertBoss("skipped", DeliveryStatus.SKIPPED, "SKIP", "skipped-reason"); + assertBoss("other-profile", DeliveryStatus.AI_NOT_MATCH, "SKIP", "other-reason"); + assertThat(jdbcTemplate.queryForObject( + "SELECT decision FROM job_ai_analysis WHERE profile_id=? AND job_key='normal-pass'", + String.class, + profileId + )).isEqualTo("SKIP"); + } + + private void insertBoss( + long profileId, + String encryptId, + String status, + Integer score, + String decision, + int priorityCompany, + String reason + ) { + jdbcTemplate.update(""" + INSERT INTO boss_data( + profile_id, encrypt_id, delivery_status, ai_score, ai_decision, + priority_company, ai_reason, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, profileId, encryptId, status, score, decision, priorityCompany, reason); + } + + private void assertBoss(String encryptId, String status, String decision, String reason) { + assertThat(jdbcTemplate.queryForObject( + "SELECT delivery_status FROM boss_data WHERE encrypt_id=?", + String.class, + encryptId + )).isEqualTo(status); + assertThat(jdbcTemplate.queryForObject( + "SELECT ai_decision FROM boss_data WHERE encrypt_id=?", + String.class, + encryptId + )).isEqualTo(decision); + assertThat(jdbcTemplate.queryForObject( + "SELECT ai_reason FROM boss_data WHERE encrypt_id=?", + String.class, + encryptId + )).isEqualTo(reason); + } + + private static Path createTestRoot() { + try { + return Files.createTempDirectory("getjobs-boss-thresholds-"); + } catch (Exception exception) { + throw new ExceptionInInitializerError(exception); + } + } +} diff --git a/src/test/java/com/getjobs/application/service/JobAiAnalysisServiceStatusTest.java b/src/test/java/com/getjobs/application/service/JobAiAnalysisServiceStatusTest.java index 17b96b0..1a224ae 100644 --- a/src/test/java/com/getjobs/application/service/JobAiAnalysisServiceStatusTest.java +++ b/src/test/java/com/getjobs/application/service/JobAiAnalysisServiceStatusTest.java @@ -32,6 +32,7 @@ import java.util.concurrent.atomic.AtomicInteger; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.lenient; @@ -119,6 +120,54 @@ void reasonTextUsesVersionTwoWithoutDatabaseMigration() { assertThat(reason.getJSONArray("unknowns").getString(0)).contains("待核实"); } + @Test + void savingThresholdsPromotesOnlyCurrentProfileBossAiNotMatchRowsThatMeetStoredPriorityRules() { + AiEntity saved = new AiEntity(); + saved.setProfileId(PROFILE_ID); + saved.setApplyThreshold(60); + saved.setPriorityApplyThreshold(50); + when(aiService.saveOrUpdateAiThresholds(60, 50)).thenReturn(saved); + when(bossJobDataMapper.update(any(), any(UpdateWrapper.class))).thenReturn(4); + + JobAiAnalysisService.ThresholdApplicationResult result = + service.saveThresholdsAndPromoteBossHistory(60, 50); + + assertThat(result.thresholds()).isSameAs(saved); + assertThat(result.bossHistoricalPromotedCount()).isEqualTo(4); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(BossJobDataEntity.class); + @SuppressWarnings("unchecked") + ArgumentCaptor> wrapperCaptor = + ArgumentCaptor.forClass(UpdateWrapper.class); + verify(bossJobDataMapper).update(updateCaptor.capture(), wrapperCaptor.capture()); + + assertThat(updateCaptor.getValue().getDeliveryStatus()).isEqualTo(DeliveryStatus.WAITING_CONFIRM); + assertThat(updateCaptor.getValue().getAiDecision()).isEqualTo("APPLY"); + assertThat(updateCaptor.getValue().getAiScore()).isNull(); + assertThat(updateCaptor.getValue().getAiReason()).isNull(); + assertThat(updateCaptor.getValue().getUpdatedAt()).isNotNull(); + + UpdateWrapper wrapper = wrapperCaptor.getValue(); + assertThat(wrapper.getSqlSegment()) + .contains("profile_id", "delivery_status", "ai_score", "priority_company") + .contains("IS NOT NULL", "OR"); + assertThat(wrapper.getParamNameValuePairs().values()) + .contains(PROFILE_ID, DeliveryStatus.AI_NOT_MATCH, 1, 50, 60); + verify(aiService, never()).sendStructuredRequest(any(), any()); + } + + @Test + void thresholdValidationFailureDoesNotAttemptHistoricalBossUpdate() { + when(aiService.saveOrUpdateAiThresholds(60, 70)) + .thenThrow(new IllegalArgumentException("优先公司分数线不能高于普通公司分数线")); + + assertThatThrownBy(() -> service.saveThresholdsAndPromoteBossHistory(60, 70)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("优先公司分数线"); + + verify(bossJobDataMapper, never()).update(any(), any(UpdateWrapper.class)); + } + @Test void zhilianApplyUpdatesWaitingConfirm() { when(zhilianJobDataMapper.selectOne(any())).thenReturn(zhilianJob(DeliveryStatus.NOT_DELIVERED)); diff --git a/tasks/2026-09-03-boss-history-threshold-reclassification.md b/tasks/2026-09-03-boss-history-threshold-reclassification.md new file mode 100644 index 0000000..c9082ba --- /dev/null +++ b/tasks/2026-09-03-boss-history-threshold-reclassification.md @@ -0,0 +1,63 @@ +# Boss 历史岗位应用新分数线 + +## 背景 + +当前 Boss 分数线保存接口只更新当前档案的 AI 配置。已经完成 AI 分析并保存为“AI不匹配”的历史岗位不会重新按新分数线判定,用户降低分数线后无法让这些岗位回到正常的“待确认”流程。 + +## 目标 + +- 保存普通/优先公司分数线时,使用历史 `ai_score` 自动重判当前档案的 Boss 岗位。 +- 仅将达到新分数线的“AI不匹配”单向提升为“待确认”。 +- 返回本次提升数量,并让前端刷新列表和统计。 +- 不调用 AI Provider,不创建投递请求,不触发真实招聘平台操作。 + +## 允许修改范围 + +- AI 分数线保存 Controller 与岗位分析 Service。 +- Boss 分数线前端组件、分析页刷新回调及对应测试。 +- 本任务文件。 + +## 禁止修改范围 + +- 不新增数据库迁移,不修改 `db/getjobs.db`、WAL、SHM 或用户历史分析记录。 +- 不修改智联、猎聘、51job 的状态。 +- 不修改已投递、投递中、结果未知、失败、已跳过等非“AI不匹配”状态。 +- 不调用真实 AI、真实招聘平台或真实投递。 +- 不自动合并 PR、删除分支、force push、reset 或 stash。 + +## 已确定实现要求 + +- 分数线保存与 Boss 历史提升处于同一事务,任一步失败整体回滚。 +- 使用保存后的 `profile_id` 和阈值;`priority_company=1` 使用优先分数线,其余使用普通分数线。 +- 更新条件必须包含当前 `delivery_status='AI不匹配'`、有效 `ai_score` 和对应分数线,防止覆盖并发状态变化。 +- 提升时只更新 `delivery_status='待确认'`、`ai_decision='APPLY'` 和 `updated_at`;保留 AI 分数、原因及 `job_ai_analysis` 历史证据。 +- 重复保存必须幂等;提高分数线不得反向降级已有“待确认”岗位。 +- `POST /api/ai/thresholds` 请求保持兼容,响应 `data` 增加 `bossHistoricalPromotedCount`。 +- 前端显示提升数量;成功后刷新岗位列表与统计,刷新失败时必须明确说明配置和数据已经保存。 + +## 验收标准 + +- 普通/优先公司按各自阈值提升,低分、空分、其他档案和其他状态不变。 +- 提升后的岗位进入“待确认”,AI 决策显示 `APPLY`,现有待确认投递流程可继续使用。 +- 重复保存返回 0,不产生重复投递或 AI 调用。 +- 列表、待确认卡片和统计在保存后刷新。 +- 定向测试、完整后端测试、前端测试、lint、typecheck 和生产构建通过。 + +## 测试命令 + +```powershell +.\gradlew.bat test --tests "com.getjobs.application.service.JobAiAnalysisServiceStatusTest" --tests "com.getjobs.application.controller.AiConfigControllerThresholdTest" +.\gradlew.bat test --tests "com.getjobs.application.service.BossThresholdReclassificationIntegrationTest" +.\gradlew.bat test +pnpm --dir front test +pnpm --dir front lint +pnpm --dir front typecheck +pnpm --dir front build:prod +``` + +## 返回格式 + +- 修改文件与关键行为。 +- 测试命令、结果和失败证据。 +- Git 分支、提交、Push、PR 和 CI 状态。 +- 运行切换前后 PID、监听端口、数据库备份与接口验收证据。 From 54e56a6bacc37a6208452b77de321a7aeca1c058 Mon Sep 17 00:00:00 2001 From: damingishere-coder Date: Thu, 3 Sep 2026 17:55:28 +0800 Subject: [PATCH 11/12] =?UTF-8?q?test:=20=E8=A1=A5=E5=85=85=E5=88=86?= =?UTF-8?q?=E6=95=B0=E7=BA=BF=E4=BA=8B=E5=8A=A1=E5=9B=9E=E6=BB=9A=E9=AA=8C?= =?UTF-8?q?=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...esholdReclassificationIntegrationTest.java | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/test/java/com/getjobs/application/service/BossThresholdReclassificationIntegrationTest.java b/src/test/java/com/getjobs/application/service/BossThresholdReclassificationIntegrationTest.java index 8e7fe36..0fa90d0 100644 --- a/src/test/java/com/getjobs/application/service/BossThresholdReclassificationIntegrationTest.java +++ b/src/test/java/com/getjobs/application/service/BossThresholdReclassificationIntegrationTest.java @@ -13,6 +13,7 @@ import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE, properties = { "app.auto-open-browser=false", @@ -46,6 +47,7 @@ static void testProperties(DynamicPropertyRegistry registry) { @Test void savingThresholdsPromotesOnlyEligibleCurrentProfileBossRowsAndIsIdempotent() { + jdbcTemplate.update("UPDATE profile SET is_active=0"); jdbcTemplate.update("INSERT INTO profile(name, is_active) VALUES ('当前档案', 1)"); long profileId = profileService.getCurrentProfileId(); jdbcTemplate.update("INSERT INTO profile(name, is_active) VALUES ('其他档案', 0)"); @@ -63,6 +65,11 @@ void savingThresholdsPromotesOnlyEligibleCurrentProfileBossRowsAndIsIdempotent() INSERT INTO job_ai_analysis(profile_id, platform, job_key, score, decision, summary) VALUES (?, 'boss', 'normal-pass', 60, 'SKIP', '历史分析') """, profileId); + int deliveryAttemptsBefore = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM delivery_attempt WHERE profile_id=? AND platform='boss'", + Integer.class, + profileId + ); JobAiAnalysisService.ThresholdApplicationResult first = jobAiAnalysisService.saveThresholdsAndPromoteBossHistory(60, 50); @@ -84,6 +91,50 @@ INSERT INTO job_ai_analysis(profile_id, platform, job_key, score, decision, summ String.class, profileId )).isEqualTo("SKIP"); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM delivery_attempt WHERE profile_id=? AND platform='boss'", + Integer.class, + profileId + )).isEqualTo(deliveryAttemptsBefore); + } + + @Test + void bossUpdateFailureRollsBackThresholdSave() { + jdbcTemplate.update("UPDATE profile SET is_active=0"); + jdbcTemplate.update("INSERT INTO profile(name, is_active) VALUES ('回滚档案', 1)"); + long profileId = profileService.getCurrentProfileId(); + jdbcTemplate.update(""" + INSERT INTO ai(profile_id, apply_threshold, priority_apply_threshold) + VALUES (?, 75, 65) + """, profileId); + insertBoss(profileId, "rollback-row", DeliveryStatus.AI_NOT_MATCH, 80, "SKIP", 0, "rollback-reason"); + + jdbcTemplate.execute(""" + CREATE TRIGGER abort_boss_threshold_reclassification + BEFORE UPDATE ON boss_data + WHEN OLD.encrypt_id='rollback-row' + BEGIN + SELECT RAISE(ABORT, 'forced rollback'); + END + """); + try { + assertThatThrownBy(() -> jobAiAnalysisService.saveThresholdsAndPromoteBossHistory(60, 50)) + .isInstanceOf(RuntimeException.class); + } finally { + jdbcTemplate.execute("DROP TRIGGER IF EXISTS abort_boss_threshold_reclassification"); + } + + assertThat(jdbcTemplate.queryForObject( + "SELECT apply_threshold FROM ai WHERE profile_id=?", + Integer.class, + profileId + )).isEqualTo(75); + assertThat(jdbcTemplate.queryForObject( + "SELECT priority_apply_threshold FROM ai WHERE profile_id=?", + Integer.class, + profileId + )).isEqualTo(65); + assertBoss("rollback-row", DeliveryStatus.AI_NOT_MATCH, "SKIP", "rollback-reason"); } private void insertBoss( From ebf5a04cfbc822170b216f0ecc420a94ac52dc79 Mon Sep 17 00:00:00 2001 From: damingishere-coder Date: Fri, 4 Sep 2026 12:15:17 +0800 Subject: [PATCH 12/12] =?UTF-8?q?fix:=20=E5=8F=AF=E9=9D=A0=E5=8F=91?= =?UTF-8?q?=E9=80=81=20BOSS=20=E5=B2=97=E4=BD=8D=E5=AE=9A=E5=88=B6?= =?UTF-8?q?=E8=AF=9D=E6=9C=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- chrome-extension/background.js | 131 ++++++++--- chrome-extension/boss-content.js | 205 +++++++++++++++--- chrome-extension/manifest.json | 2 +- .../tests/background-tab-routing.test.cjs | 84 +++++++ .../tests/delivery-state-safety.test.cjs | 19 ++ chrome-extension/tests/manifest-id.test.cjs | 2 +- .../profile-scoped-scan-contract.test.cjs | 7 +- front/app/ai-config/page.test.tsx | 29 ++- front/app/ai-config/page.tsx | 37 +++- front/app/boss/analysis/AnalysisContent.tsx | 5 + .../components/BossDeliveryHistory.test.tsx | 29 +++ .../components/BossDeliveryHistory.tsx | 128 +++++++++++ .../analysis/components/BossPendingCards.tsx | 2 +- .../analysis/hooks/useBossDeliveryActions.ts | 33 ++- .../GreetingDraftDialog.test.tsx | 43 ++++ .../communication/GreetingDraftDialog.tsx | 12 +- .../controller/BossAnalyticsController.java | 39 +++- .../controller/BossConfigController.java | 3 + .../controller/BossController.java | 14 +- .../dto/DeliveryResultRequest.java | 2 + .../application/entity/BossConfigEntity.java | 3 + .../application/service/BossService.java | 12 + .../service/DatabaseSchemaService.java | 6 +- .../service/DeliveryAttemptService.java | 124 +++++++++-- .../service/JobAiAnalysisService.java | 157 ++++++++++++-- .../V14__add_boss_greeting_delivery_audit.sql | 18 ++ .../BossAnalyticsControllerTest.java | 27 ++- .../BossConfigControllerContractTest.java | 2 + .../BossControllerListOnlyTest.java | 35 +++ .../DatabaseMigrationRehearsalTest.java | 9 + .../service/DatabaseMigrationTest.java | 43 +++- .../service/DeliveryAttemptServiceTest.java | 121 ++++++++--- .../JobAiAnalysisServiceStatusTest.java | 77 ++++++- ...2026-09-04-boss-jd-greeting-reliability.md | 56 +++++ 34 files changed, 1340 insertions(+), 176 deletions(-) create mode 100644 front/app/boss/analysis/components/BossDeliveryHistory.test.tsx create mode 100644 front/app/boss/analysis/components/BossDeliveryHistory.tsx create mode 100644 front/components/communication/GreetingDraftDialog.test.tsx create mode 100644 src/main/resources/db/migration/V14__add_boss_greeting_delivery_audit.sql create mode 100644 tasks/2026-09-04-boss-jd-greeting-reliability.md diff --git a/chrome-extension/background.js b/chrome-extension/background.js index 6b21927..84c7b3c 100644 --- a/chrome-extension/background.js +++ b/chrome-extension/background.js @@ -32,12 +32,12 @@ const PLATFORM_SHARED_SCAN_KEYS = { boss: ["__GET_JOBS_BOSS_SHARED_SCAN_TASK__", "__GET_JOBS_BOSS_SHARED_SCAN_CANCEL__"], zhilian: ["__GET_JOBS_ZHILIAN_SHARED_SCAN_TASK__", "__GET_JOBS_ZHILIAN_SHARED_SCAN_CANCEL__"] }; -const BACKGROUND_VERSION = "2026-09-03-boss-navigation-loop-fix"; +const BACKGROUND_VERSION = "2026-09-04-boss-greeting-proof"; const CONTENT_READY_RETRIES = 12; const CONTENT_READY_INTERVAL_MS = 250; const TAB_LOAD_TIMEOUT_MS = 10000; const DELIVERY_NAVIGATION_TIMEOUT_MS = 15000; -const REQUIRED_BOSS_CONTENT_VERSION = "2026-09-03-boss-navigation-loop-fix"; +const REQUIRED_BOSS_CONTENT_VERSION = "2026-09-04-boss-greeting-proof"; const REQUIRED_ZHILIAN_CONTENT_VERSION = "2026-09-03-boss-navigation-loop-fix"; const LOCAL_API_BASE_URLS = ["http://localhost:6866", "http://127.0.0.1:6866"]; const BOSS_LOCAL_API_MAX_ATTEMPTS = 3; @@ -836,6 +836,9 @@ async function handleBossDeliver(tab, config, message, pageTabId) { let failed = 0; let unknown = 0; const results = []; + let halted = false; + let haltedJobId = null; + let unprocessedCount = 0; for (let index = 0; index < tasks.length; index++) { const task = tasks[index]; const result = await deliverBossTask(tab, config, task, message, pageTabId, index + 1, tasks.length).catch(async (error) => { @@ -857,16 +860,52 @@ async function handleBossDeliver(tab, config, message, pageTabId) { if (outcome === "CONFIRMED") success += 1; else if (outcome === "UNKNOWN") unknown += 1; else failed += 1; - results.push({ id: task?.id, requestKey: task?.requestKey, outcome, evidence: result?.evidence || "", persisted: result?.persisted === true, message: result?.message || "" }); + results.push({ id: task?.id, requestKey: task?.requestKey, outcome, evidence: result?.evidence || "", greetingOutcome: result?.greetingOutcome || "", greetingEvidence: result?.greetingEvidence || "", persisted: result?.persisted === true, message: result?.message || "" }); + if (outcome === "UNKNOWN") { + halted = true; + haltedJobId = task?.id || null; + const remaining = tasks.slice(index + 1); + unprocessedCount = remaining.length; + for (const skippedTask of remaining) { + const skippedMessage = `前一岗位 ${task?.id || "-"} 的发送结果待确认,批量任务已暂停,本岗位未触达`; + let persisted = false; + await postBossDeliveryResult( + skippedTask, + false, + { failureType: "BATCH_HALTED_BEFORE_ACTION", failureReason: skippedMessage }, + "BATCH_HALTED_BEFORE_ACTION", + "NOT_SENT", + "BATCH_HALTED_BEFORE_ACTION" + ).then(() => { persisted = true; }).catch(() => {}); + results.push({ + id: skippedTask?.id, + requestKey: skippedTask?.requestKey, + outcome: "FAILED", + evidence: "BATCH_HALTED_BEFORE_ACTION", + greetingOutcome: "NOT_SENT", + greetingEvidence: "BATCH_HALTED_BEFORE_ACTION", + persisted, + skipped: true, + message: skippedMessage + }); + } + break; + } } + const summary = halted + ? `Boss批量投递已暂停:已确认${success},待确认${unknown},未触达${unprocessedCount}` + : `Boss批量投递完成:已确认${success},待确认${unknown},失败${failed}`; return { - success: failed === 0 && unknown === 0, + success: !halted && failed === 0 && unknown === 0, partial: success > 0 && (failed > 0 || unknown > 0), - message: `Boss批量投递完成:已确认${success},待确认${unknown},失败${failed}`, + message: summary, successCount: success, unknownCount: unknown, failedCount: failed, + halted, + haltedJobId, + unprocessedCount, results }; } @@ -913,6 +952,8 @@ async function deliverBossTask(tab, config, task, message, pageTabId, index, tot success: false, outcome: "UNKNOWN", evidence: "NO_CONFIRMATION", + greetingOutcome: "UNKNOWN", + greetingEvidence: "GREETING_CALLBACK_UNAVAILABLE", persisted, message: failure.failureReason, failureType: failure.failureType @@ -921,30 +962,20 @@ async function deliverBossTask(tab, config, task, message, pageTabId, index, tot } async function sendBossDeliverCurrent(tabId, message, task, pageTabId, index, total) { - let lastError = null; - for (let attempt = 0; attempt < 3; attempt++) { - try { - const response = await chrome.tabs.sendMessage(tabId, { - ...message, - type: "BOSS_DELIVER_CURRENT_V2", - source: "GET_JOBS_BACKGROUND", - task, - pageTabId, - deliveryIndex: index, - deliveryTotal: total - }); - if (response) { - const recorded = await recordBossDeliveryResponse(task, response); - return { ...response, success: recorded.outcome === "CONFIRMED", ...recorded }; - } - const fallback = await inferBossDeliveryAfterEmptyResponse(tabId, task); - if (fallback.success || fallback.outcome === "UNKNOWN") return fallback; - } catch (error) { - lastError = error; - await sleep(500); - } + const response = await chrome.tabs.sendMessage(tabId, { + ...message, + type: "BOSS_DELIVER_CURRENT_V2", + source: "GET_JOBS_BACKGROUND", + task, + pageTabId, + deliveryIndex: index, + deliveryTotal: total + }); + if (response) { + const recorded = await recordBossDeliveryResponse(task, response); + return { ...response, success: recorded.outcome === "CONFIRMED", ...recorded }; } - throw lastError || new Error("Boss投递请求发送失败"); + return await inferBossDeliveryAfterEmptyResponse(tabId, task); } async function handleZhilianDeliver(tab, config, message, pageTabId) { @@ -1132,6 +1163,8 @@ async function inferBossDeliveryAfterEmptyResponse(tabId, task) { success: false, outcome: "UNKNOWN", evidence: "CHAT_SURFACE_ONLY", + greetingOutcome: "UNKNOWN", + greetingEvidence: "GREETING_CALLBACK_UNAVAILABLE", persisted, message: "Boss已进入沟通页,但未收到明确成功状态,已标记待确认。" }; @@ -1142,7 +1175,7 @@ async function inferBossDeliveryAfterEmptyResponse(tabId, task) { } } -async function postBossDeliveryResult(task, success, message, evidence) { +async function postBossDeliveryResult(task, success, message, evidence, greetingOutcome, greetingEvidence) { if (!task?.id) return; const failure = success === false ? normalizeFailurePayload(message) : null; const outcome = success === true ? "CONFIRMED" : success === false ? "FAILED" : "UNKNOWN"; @@ -1156,7 +1189,9 @@ async function postBossDeliveryResult(task, success, message, evidence) { success, message: success === true ? message : failure?.failureReason || String(message || ""), failureType: failure?.failureType, - failureReason: failure?.failureReason + failureReason: failure?.failureReason, + greetingOutcome: greetingOutcome || (outcome === "CONFIRMED" ? "CONFIRMED" : outcome === "FAILED" ? "NOT_SENT" : "UNKNOWN"), + greetingEvidence: greetingEvidence || (outcome === "CONFIRMED" ? "GREETING_RENDERED_EXACT" : "GREETING_UNCONFIRMED") }, platform: "boss" }); @@ -1165,12 +1200,38 @@ async function postBossDeliveryResult(task, success, message, evidence) { } async function recordBossDeliveryResponse(task, response) { - const outcome = deliveryOutcomeOf(response); + let outcome = deliveryOutcomeOf(response); + const responseGreetingOutcome = String(response?.greetingOutcome || "").toUpperCase(); + const responseGreetingEvidence = String(response?.greetingEvidence || "").toUpperCase(); + const greetingConfirmed = responseGreetingOutcome === "CONFIRMED" + && responseGreetingEvidence === "GREETING_RENDERED_EXACT"; + if (outcome === "CONFIRMED" && !greetingConfirmed) outcome = "UNKNOWN"; const success = outcome === "CONFIRMED" ? true : outcome === "FAILED" ? false : null; const evidence = response?.evidence - || (outcome === "CONFIRMED" ? "PLATFORM_STATUS_TEXT" : outcome === "FAILED" ? "PLATFORM_ERROR" : "NO_CONFIRMATION"); - await postBossDeliveryResult(task, success, response?.message || "Boss投递结果回写", evidence); - return { outcome, evidence, persisted: true }; + || (outcome === "CONFIRMED" ? "GREETING_RENDERED_EXACT" : outcome === "FAILED" ? "PLATFORM_ERROR" : "NO_CONFIRMATION"); + const greetingOutcome = greetingConfirmed + ? "CONFIRMED" + : ["UNKNOWN", "NOT_SENT"].includes(responseGreetingOutcome) + ? responseGreetingOutcome + : outcome === "FAILED" ? "NOT_SENT" : "UNKNOWN"; + const greetingEvidence = greetingConfirmed + ? "GREETING_RENDERED_EXACT" + : responseGreetingEvidence || "GREETING_UNCONFIRMED"; + await postBossDeliveryResult( + task, + success, + response?.message || "Boss投递结果回写", + evidence, + greetingOutcome, + greetingEvidence + ); + return { + outcome, + evidence, + greetingOutcome, + greetingEvidence, + persisted: true + }; } async function postZhilianDeliveryResult(task, success, message, evidence) { @@ -1214,7 +1275,7 @@ function deliveryOutcomeOf(result) { } function isExplicitConfirmationEvidence(evidence) { - return ["PLATFORM_STATUS_TEXT", "PLATFORM_SUCCESS_DIALOG", "EXISTING_CONVERSATION"] + return ["PLATFORM_STATUS_TEXT", "PLATFORM_SUCCESS_DIALOG", "EXISTING_CONVERSATION", "GREETING_RENDERED_EXACT"] .includes(String(evidence || "").toUpperCase()); } diff --git a/chrome-extension/boss-content.js b/chrome-extension/boss-content.js index 2349a9c..1109080 100644 --- a/chrome-extension/boss-content.js +++ b/chrome-extension/boss-content.js @@ -1,5 +1,5 @@ (function () { - const EXTENSION_VERSION = "2026-09-03-boss-navigation-loop-fix"; + const EXTENSION_VERSION = "2026-09-04-boss-greeting-proof"; const CONTENT_INSTANCE_ID = `${Date.now()}-${Math.random().toString(16).slice(2)}`; window.__GET_JOBS_BOSS_CONTENT__ = true; window.__GET_JOBS_BOSS_CONTENT_VERSION__ = EXTENSION_VERSION; @@ -3003,14 +3003,30 @@ await postDeliveryResult(task, false, failure, "PRE_ACTION_ERROR"); return { success: false, outcome: "FAILED", evidence: "PRE_ACTION_ERROR", message: failure.failureReason, failureType: failure.failureType }; } + const configuredGreeting = normalizeGreetingText(task?.greeting || ""); + if (!configuredGreeting) { + const failure = classifyDeliveryFailure("投递任务缺少已确认的沟通话术"); + await postDeliveryResult(task, false, failure, "PRE_ACTION_ERROR", "NOT_SENT", "GREETING_EMPTY"); + return { + success: false, + outcome: "FAILED", + evidence: "PRE_ACTION_ERROR", + greetingOutcome: "NOT_SENT", + greetingEvidence: "GREETING_EMPTY", + message: failure.failureReason, + failureType: failure.failureType + }; + } await sleep(1500); if (detectBossDeliveryStatus(document)) { - const messageText = "Boss岗位页面已显示沟通或投递状态"; - await postDeliveryResult(task, true, messageText, "PLATFORM_STATUS_TEXT"); + const messageText = "Boss岗位页面已存在沟通或投递状态,本次未补发话术,请人工核对"; + await postDeliveryResult(task, null, messageText, "EXISTING_CONVERSATION", "NOT_SENT", "ALREADY_CONTACTED"); return { - success: true, - outcome: "CONFIRMED", - evidence: "PLATFORM_STATUS_TEXT", + success: false, + outcome: "UNKNOWN", + evidence: "EXISTING_CONVERSATION", + greetingOutcome: "NOT_SENT", + greetingEvidence: "ALREADY_CONTACTED", message: messageText }; } @@ -3065,18 +3081,24 @@ return { ...deliveryCheck, message: failure.failureReason, failureType: failure.failureType }; } const greetingResult = await sendConfiguredGreeting(task, message); - const finalMessage = greetingResult?.sent ? `${successMessage},已发送开场白` : successMessage; - const confirmed = deliveryCheck.outcome === "CONFIRMED"; + const confirmed = greetingResult?.sent === true; + const finalMessage = confirmed + ? `${successMessage},已精确确认发送岗位话术` + : `${successMessage},但${greetingResult?.message || "话术发送未确认"}`; await postDeliveryResult( task, confirmed ? true : null, - confirmed ? finalMessage : `${finalMessage},但未检测到明确平台成功状态`, - deliveryCheck.evidence || (confirmed ? "PLATFORM_STATUS_TEXT" : "CHAT_SURFACE_ONLY") + confirmed ? finalMessage : `${finalMessage},已标记待人工确认`, + confirmed ? "GREETING_RENDERED_EXACT" : (deliveryCheck.evidence || "CHAT_SURFACE_ONLY"), + confirmed ? "CONFIRMED" : "UNKNOWN", + greetingResult?.evidence || (confirmed ? "GREETING_RENDERED_EXACT" : "GREETING_UNCONFIRMED") ); const result = { success: confirmed, outcome: confirmed ? "CONFIRMED" : "UNKNOWN", - evidence: deliveryCheck.evidence || (confirmed ? "PLATFORM_STATUS_TEXT" : "CHAT_SURFACE_ONLY"), + evidence: confirmed ? "GREETING_RENDERED_EXACT" : (deliveryCheck.evidence || "CHAT_SURFACE_ONLY"), + greetingOutcome: confirmed ? "CONFIRMED" : "UNKNOWN", + greetingEvidence: greetingResult?.evidence || (confirmed ? "GREETING_RENDERED_EXACT" : "GREETING_UNCONFIRMED"), message: confirmed ? finalMessage : `${finalMessage},结果待人工确认` }; earlyRespond?.({ ...result, early: true }); @@ -3091,30 +3113,65 @@ } async function sendConfiguredGreeting(task, message) { - const greeting = compact(task?.greeting || ""); - if (!greeting) return { attempted: false, sent: false, message: "未配置开场白" }; + const greeting = normalizeGreetingText(task?.greeting || ""); + if (!greeting) return { attempted: false, sent: false, evidence: "GREETING_EMPTY", message: "未配置开场白" }; - const input = await waitForChatInput(4500); - if (!input) return { attempted: false, sent: false, message: "未出现聊天输入框" }; + const input = await waitForChatInput(12000); + if (!input) return { attempted: false, sent: false, evidence: "GREETING_INPUT_MISSING", message: "未出现聊天输入框" }; writeChatInput(input, greeting); await sleep(400); - const sendButton = findSendButton(); + if (readChatInput(input) !== greeting) { + return { attempted: true, sent: false, evidence: "GREETING_INPUT_MISMATCH", message: "聊天输入框内容与确认话术不一致" }; + } + const sendButton = findSendButton(input); if (!sendButton) { postProgress(message, "warning", "Boss Chrome已填入配置开场白,但未找到发送按钮。", { operation: "deliver", stage: "submitting" }); - return { attempted: true, sent: false, message: "未找到发送按钮" }; + return { attempted: true, sent: false, evidence: "GREETING_SEND_BUTTON_MISSING", message: "未找到发送按钮" }; } + const messageCountBefore = countRenderedGreetingMessages(greeting, input); clickElement(sendButton); - await sleep(600); - postProgress(message, "info", "Boss Chrome已发送配置开场白。", { + const sent = await waitForGreetingConfirmation(greeting, input, messageCountBefore, 6000); + postProgress(message, sent ? "info" : "warning", sent + ? "Boss Chrome已精确确认发送岗位话术。" + : "Boss Chrome已点击发送,但未检测到精确话术出现在聊天记录。", { operation: "deliver", stage: "submitting" }); - return { attempted: true, sent: true, message: "已发送配置开场白" }; + return { + attempted: true, + sent, + evidence: sent ? "GREETING_RENDERED_EXACT" : "GREETING_RENDER_UNCONFIRMED", + message: sent ? "已精确确认发送岗位话术" : "点击发送后未检测到精确话术" + }; + } + + async function waitForGreetingConfirmation(greeting, input, beforeCount, timeoutMs) { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (countRenderedGreetingMessages(greeting, input) > beforeCount) return true; + await sleep(200); + } + return false; + } + + function countRenderedGreetingMessages(greeting, input) { + const selectors = [ + ".message-item", + ".chat-message", + ".message-content", + "[class*='message-item']", + "[class*='message-content']", + "[class*='chat-record'] [class*='text']" + ]; + const nodes = Array.from(document.querySelectorAll(selectors.join(","))); + return nodes.filter((node) => node !== input + && !node.contains?.(input) + && normalizeGreetingText(node.innerText || node.textContent || "") === greeting).length; } function buildDeliverySuccessMessage(favoriteButton, greetingResult) { @@ -3139,21 +3196,34 @@ "div#chat-input.chat-input[contenteditable='true']", "[contenteditable='true'].chat-input", "[contenteditable='true'][id*='chat']", - "textarea.input-area", - "textarea" + "[class*='chat-input'] [contenteditable='true']", + "[class*='chat'] textarea.input-area", + "[class*='chat'] textarea" ]; for (const selector of selectors) { - const node = Array.from(document.querySelectorAll(selector)).find((el) => el.offsetParent !== null); + const node = Array.from(document.querySelectorAll(selector)).find(isVisibleChatInput); if (node) return node; } return null; } + function isVisibleChatInput(element) { + if (!element || element.offsetParent === null) return false; + const hint = compact([ + element.getAttribute?.("placeholder"), + element.getAttribute?.("aria-label"), + element.getAttribute?.("title") + ].filter(Boolean).join(" ")); + return !/(搜索|职位搜索|公司搜索)/.test(hint); + } + function writeChatInput(input, text) { input.focus?.(); input.click?.(); if (String(input.tagName || "").toLowerCase() === "textarea") { - input.value = text; + const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement?.prototype || {}, "value")?.set; + if (setter) setter.call(input, text); + else input.value = text; input.dispatchEvent(new Event("input", { bubbles: true })); input.dispatchEvent(new Event("change", { bubbles: true })); return; @@ -3163,7 +3233,18 @@ input.dispatchEvent(new InputEvent("input", { bubbles: true, cancelable: true, inputType: "insertText", data: text })); } - function findSendButton() { + function readChatInput(input) { + if (!input) return ""; + return normalizeGreetingText(String(input.tagName || "").toLowerCase() === "textarea" + ? input.value || "" + : input.innerText || input.textContent || ""); + } + + function normalizeGreetingText(value) { + return String(value || "").replace(/\r\n?/g, "\n").trim(); + } + + function findSendButton(input) { const selectors = [ "div.send-message", "button[type='send'].btn-send", @@ -3171,11 +3252,24 @@ "[class*='send-message']", "[class*='btn-send']" ]; - for (const selector of selectors) { - const node = Array.from(document.querySelectorAll(selector)).find((el) => el.offsetParent !== null); - if (node) return node; + const scopes = []; + let scope = input; + for (let depth = 0; scope && depth < 6; depth++) { + if (scope.querySelectorAll) scopes.push(scope); + scope = scope.parentElement; + } + for (const current of scopes) { + for (const selector of selectors) { + const node = Array.from(current.querySelectorAll(selector)) + .find((el) => el.offsetParent !== null); + if (node) return node; + } + const textButton = Array.from(current.querySelectorAll("button, [role='button']")) + .find((el) => el.offsetParent !== null + && normalizeGreetingText(el.textContent || el.innerText || "") === "发送"); + if (textButton) return textButton; } - return findClickable(["发送"]); + return null; } async function deliverBatch(tasks, message) { @@ -3183,6 +3277,9 @@ let failed = 0; let unknown = 0; const results = []; + let halted = false; + let haltedJobId = null; + let unprocessedCount = 0; postProgress(message, "info", `Boss Chrome批量投递开始,共 ${tasks.length} 个待确认岗位。`, { operation: "deliver", stage: "received", @@ -3213,26 +3310,62 @@ if (outcome === "CONFIRMED") success += 1; else if (outcome === "UNKNOWN") unknown += 1; else failed += 1; - results.push({ id: task?.id, requestKey: task?.requestKey, outcome, evidence: result?.evidence || "", persisted: result?.persisted === true, message: result?.message || "" }); + results.push({ id: task?.id, requestKey: task?.requestKey, outcome, evidence: result?.evidence || "", greetingOutcome: result?.greetingOutcome || "", greetingEvidence: result?.greetingEvidence || "", persisted: result?.persisted === true, message: result?.message || "" }); + if (outcome === "UNKNOWN") { + halted = true; + haltedJobId = task?.id || null; + const remaining = tasks.slice(index + 1); + unprocessedCount = remaining.length; + for (const skippedTask of remaining) { + const skippedMessage = `前一岗位 ${task?.id || "-"} 的发送结果待确认,批量任务已暂停,本岗位未触达`; + let persisted = false; + await postDeliveryResult( + skippedTask, + false, + { failureType: "BATCH_HALTED_BEFORE_ACTION", failureReason: skippedMessage }, + "BATCH_HALTED_BEFORE_ACTION", + "NOT_SENT", + "BATCH_HALTED_BEFORE_ACTION" + ).then(() => { persisted = true; }).catch(() => {}); + results.push({ + id: skippedTask?.id, + requestKey: skippedTask?.requestKey, + outcome: "FAILED", + evidence: "BATCH_HALTED_BEFORE_ACTION", + greetingOutcome: "NOT_SENT", + greetingEvidence: "BATCH_HALTED_BEFORE_ACTION", + persisted, + skipped: true, + message: skippedMessage + }); + } + break; + } } - postProgress(message, failed || unknown ? "warning" : "success", `Boss批量投递完成:已确认${success},待确认${unknown},失败${failed}`, { + const summary = halted + ? `Boss批量投递已暂停:已确认${success},待确认${unknown},未触达${unprocessedCount}` + : `Boss批量投递完成:已确认${success},待确认${unknown},失败${failed}`; + postProgress(message, failed || unknown ? "warning" : "success", summary, { operation: "deliver", stage: "complete", keywordTotal: tasks.length, saved: success }); return { - success: failed === 0 && unknown === 0, + success: !halted && failed === 0 && unknown === 0, partial: success > 0 && (failed > 0 || unknown > 0), - message: `Boss批量投递完成:已确认${success},待确认${unknown},失败${failed}`, + message: summary, successCount: success, unknownCount: unknown, failedCount: failed, + halted, + haltedJobId, + unprocessedCount, results }; } - async function postDeliveryResult(task, success, message, evidence) { + async function postDeliveryResult(task, success, message, evidence, greetingOutcome, greetingEvidence) { const failure = success === false ? normalizeFailurePayload(message) : null; const outcome = success === true ? "CONFIRMED" : success === false ? "FAILED" : "UNKNOWN"; await callBossLocalApi("delivery-result", { @@ -3242,7 +3375,9 @@ success, message: success === true ? message : failure?.failureReason || String(message || ""), failureType: failure?.failureType, - failureReason: failure?.failureReason + failureReason: failure?.failureReason, + greetingOutcome: greetingOutcome || (outcome === "CONFIRMED" ? "CONFIRMED" : outcome === "FAILED" ? "NOT_SENT" : "UNKNOWN"), + greetingEvidence: greetingEvidence || (outcome === "CONFIRMED" ? "GREETING_RENDERED_EXACT" : "GREETING_UNCONFIRMED") }, { params: { id: task.id }, pageTabId: task?.pageTabId, diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json index 5d04278..c608da1 100644 --- a/chrome-extension/manifest.json +++ b/chrome-extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "投递牛马 Chrome Bridge", - "version": "1.4.3", + "version": "1.4.4", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzzdIlNVOv76Y/cSWrjD5Tg2Vlsha8yWHzsn46PBsg724/2dftOUzIIr2n70VRaRgGwEd8FjO/Y768Ori443zF4pQpWvuxXxm05YO25ILQ/+aJLmUycAEdWbkdhcagr4YXnXJdYlSCGSAToSQBjk+owQOdlBLQn5wofPoshrqayoJjRQ5aAUj1SuSlnNv9iimle8GMA1IaA1l5rw6K/chfcgwMTg6HxRAIoludt5JGbIBryi2Lu1hOJRMaDnL7A57ofBnn3qx3H2HIGWGkkTW9EMkls0XMXwx8+mJVIj5HSYl0EeuCvEoTa1W3i1CbOf3kY2yCPKS3Qz3lOvJiwJ4ZQIDAQAB", "description": "Use the signed-in Chrome tabs to scan jobs and confirm deliveries for 投递牛马.", "icons": { diff --git a/chrome-extension/tests/background-tab-routing.test.cjs b/chrome-extension/tests/background-tab-routing.test.cjs index 066f1cf..3b6b8e4 100644 --- a/chrome-extension/tests/background-tab-routing.test.cjs +++ b/chrome-extension/tests/background-tab-routing.test.cjs @@ -35,6 +35,7 @@ function loadBackground({ contentReady = true, bossContentVersion = BOSS_CONTENT_VERSION, zhilianContentVersion = ZHILIAN_CONTENT_VERSION, + bossDeliveryResponses = [], fetchImpl = async () => { throw new Error("fetch should not be called"); } @@ -93,6 +94,11 @@ function loadBackground({ if (message.type === "BOSS_SCAN_STATUS" || message.type === "ZHILIAN_SCAN_STATUS_V2") { return statuses[tabId] || { success: true, isRunning: false, hasStoredTask: false, stage: "idle" }; } + if (message.type === "BOSS_DELIVER_CURRENT_V2") { + const response = bossDeliveryResponses.shift(); + if (response instanceof Error) throw response; + return response || { success: false, outcome: "UNKNOWN", evidence: "NO_CONFIRMATION" }; + } return { success: true }; } }, @@ -471,6 +477,84 @@ test("keeps Boss and Zhilian scan ownership when both start together", async () assert.equal(sessions.zhilian.tabId, 2); }); +test("halts a Boss batch after the first unknown result and leaves later jobs untouched", async () => { + const requests = []; + const tasks = [1, 2, 3].map((id) => ({ + id, + requestKey: `request-${id}`, + url: `https://www.zhipin.com/job_detail/job-${id}.html`, + greeting: `岗位 ${id} 的精确话术` + })); + const { context, sentMessages } = loadBackground({ + tabs: [{ id: 7, windowId: 1, url: tasks[0].url, status: "complete" }], + bossDeliveryResponses: [{ + success: false, + outcome: "UNKNOWN", + evidence: "CHAT_SURFACE_ONLY", + greetingOutcome: "UNKNOWN", + greetingEvidence: "GREETING_RENDER_UNCONFIRMED", + message: "点击发送后未检测到精确话术" + }], + fetchImpl: async (url, options) => { + const body = JSON.parse(options.body); + requests.push({ url, body }); + return jsonResponse({ success: true, accepted: true, state: body.outcome }); + } + }); + + const result = await context.handleBossDeliver( + { id: 7, windowId: 1, url: tasks[0].url, status: "complete" }, + { hosts: ["zhipin.com"], contentScript: "boss-content.js" }, + { type: "BOSS_DELIVER_BATCH", tasks }, + null + ); + + assert.equal(result.success, false); + assert.equal(result.halted, true); + assert.equal(result.haltedJobId, 1); + assert.equal(result.unknownCount, 1); + assert.equal(result.failedCount, 0); + assert.equal(result.unprocessedCount, 2); + assert.equal(result.results.length, 3); + assert.deepEqual(Array.from(result.results.slice(1), (item) => item.skipped), [true, true]); + assert.equal(sentMessages.filter((entry) => entry.message.type === "BOSS_DELIVER_CURRENT_V2").length, 1); + assert.equal(requests.length, 3); + assert.equal(requests[0].body.outcome, "UNKNOWN"); + assert.deepEqual(requests.slice(1).map((entry) => entry.body.greetingOutcome), ["NOT_SENT", "NOT_SENT"]); + assert.deepEqual(requests.slice(1).map((entry) => entry.body.evidence), ["BATCH_HALTED_BEFORE_ACTION", "BATCH_HALTED_BEFORE_ACTION"]); +}); + +test("preserves Boss existing-conversation and not-sent evidence", async () => { + const requests = []; + const { context } = loadBackground({ + tabs: [], + fetchImpl: async (url, options) => { + requests.push({ url, body: JSON.parse(options.body) }); + return jsonResponse({ success: true, accepted: true, state: "UNKNOWN" }); + } + }); + + const result = await context.recordBossDeliveryResponse( + { id: 51, requestKey: "boss-existing" }, + { + success: false, + outcome: "UNKNOWN", + evidence: "EXISTING_CONVERSATION", + greetingOutcome: "NOT_SENT", + greetingEvidence: "ALREADY_CONTACTED", + message: "已有沟通,本次未补发" + } + ); + + assert.equal(result.outcome, "UNKNOWN"); + assert.equal(result.evidence, "EXISTING_CONVERSATION"); + assert.equal(result.greetingOutcome, "NOT_SENT"); + assert.equal(result.greetingEvidence, "ALREADY_CONTACTED"); + assert.equal(requests[0].body.evidence, "EXISTING_CONVERSATION"); + assert.equal(requests[0].body.greetingOutcome, "NOT_SENT"); + assert.equal(requests[0].body.greetingEvidence, "ALREADY_CONTACTED"); +}); + test("profile switch and legacy sessions invalidate shared checkpoints", async () => { const { context, storage } = loadBackground({ tabs: [ diff --git a/chrome-extension/tests/delivery-state-safety.test.cjs b/chrome-extension/tests/delivery-state-safety.test.cjs index dabfdda..de8671c 100644 --- a/chrome-extension/tests/delivery-state-safety.test.cjs +++ b/chrome-extension/tests/delivery-state-safety.test.cjs @@ -44,3 +44,22 @@ test("delivery-result persistence is explicit so the frontend can compensate fai assert.match(boss, /persisted:\s*false/); assert.match(zhilian, /persisted:\s*false/); }); + +test("Boss confirms only an exact rendered greeting and stops the batch on unknown", () => { + const background = source("background.js"); + const boss = source("boss-content.js"); + + assert.match(boss, /readChatInput\(input\)\s*!==\s*greeting/); + assert.match(boss, /function normalizeGreetingText/); + assert.match(boss, /countRenderedGreetingMessages\(greeting, input\)\s*>\s*beforeCount/); + assert.match(boss, /greetingEvidence:\s*greetingResult\?\.evidence/); + assert.match(boss, /GREETING_INPUT_MISSING/); + assert.match(boss, /GREETING_SEND_BUTTON_MISSING/); + assert.match(boss, /GREETING_RENDER_UNCONFIRMED/); + assert.match(background, /GREETING_RENDERED_EXACT/); + assert.match(background, /if \(outcome === "UNKNOWN"\)/); + assert.match(background, /unprocessedCount/); + assert.match(background, /skipped:\s*true/); + assert.doesNotMatch(boss, /return findClickable\(\["发送"\]\)/); + assert.doesNotMatch(background, /for \(let attempt = 0; attempt < 3; attempt\+\+\) \{\s*try \{\s*const response = await chrome\.tabs\.sendMessage\(tabId, \{\s*\.\.\.message,\s*type: "BOSS_DELIVER_CURRENT_V2"/s); +}); diff --git a/chrome-extension/tests/manifest-id.test.cjs b/chrome-extension/tests/manifest-id.test.cjs index 996f370..7a5896e 100644 --- a/chrome-extension/tests/manifest-id.test.cjs +++ b/chrome-extension/tests/manifest-id.test.cjs @@ -17,7 +17,7 @@ function extensionIdFromKey(key) { test('manifest public key derives the backend allowlisted extension id', () => { const manifestPath = path.join(__dirname, '..', 'manifest.json'); const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); - assert.equal(manifest.version, '1.4.3'); + assert.equal(manifest.version, '1.4.4'); assert.equal(extensionIdFromKey(manifest.key), EXPECTED_EXTENSION_ID); const publicKey = crypto.createPublicKey({ diff --git a/chrome-extension/tests/profile-scoped-scan-contract.test.cjs b/chrome-extension/tests/profile-scoped-scan-contract.test.cjs index d6357e2..6910172 100644 --- a/chrome-extension/tests/profile-scoped-scan-contract.test.cjs +++ b/chrome-extension/tests/profile-scoped-scan-contract.test.cjs @@ -15,9 +15,10 @@ test("extension release and both content scripts use the profile-scoped contract const boss = source("boss-content.js"); const zhilian = source("zhilian-content.js"); - assert.equal(manifest.version, "1.4.3"); - assert.match(background, /BACKGROUND_VERSION = "2026-09-03-boss-navigation-loop-fix"/); - assert.match(boss, /EXTENSION_VERSION = "2026-09-03-boss-navigation-loop-fix"/); + assert.equal(manifest.version, "1.4.4"); + assert.match(background, /BACKGROUND_VERSION = "2026-09-04-boss-greeting-proof"/); + assert.match(background, /REQUIRED_BOSS_CONTENT_VERSION = "2026-09-04-boss-greeting-proof"/); + assert.match(boss, /EXTENSION_VERSION = "2026-09-04-boss-greeting-proof"/); assert.match(zhilian, /EXTENSION_VERSION = "2026-09-03-boss-navigation-loop-fix"/); }); diff --git a/front/app/ai-config/page.test.tsx b/front/app/ai-config/page.test.tsx index de57e8f..cb7d0e3 100644 --- a/front/app/ai-config/page.test.tsx +++ b/front/app/ai-config/page.test.tsx @@ -33,7 +33,7 @@ function readyResponse() { function profileResponse(url: string, id: number, name: string) { const profile = { id, name } const payload = url.includes('/api/boss/config') - ? { success: true, currentProfile: profile, hasProfile: true, config: { enableAi: 1, sayHi: `${name} hi` } } + ? { success: true, currentProfile: profile, hasProfile: true, config: { enableAi: 1, sayHi: `${name} hi`, nativeGreetingDisabledConfirmed: 1 } } : url.includes('/api/ai/resume') ? { success: true, currentProfile: profile, hasProfile: true, data: { resumeText: `${name} resume` } } : url.includes('/api/ai/companies/priority') @@ -112,6 +112,33 @@ describe('AI config profile snapshot', () => { expect(screen.getByRole('button', { name: /保存配置/ })).toBeDisabled() }) + it('加载并保存关闭 BOSS 平台默认话术确认', async () => { + const fetchMock = vi.fn((input: RequestInfo | URL, _init?: RequestInit) => { + const url = String(input) + if (url.includes('/api/ready')) return Promise.resolve(readyResponse()) + return Promise.resolve(profileResponse(url, 1, 'A')) + }) + vi.stubGlobal('fetch', fetchMock) + vi.stubGlobal('alert', vi.fn()) + + render() + fireEvent.click(await screen.findByRole('button', { name: '切换A' })) + await screen.findByText('当前正在编辑:A') + + const confirmation = screen.getByRole('checkbox', { name: /我已关闭 BOSS 平台自带打招呼语/ }) + expect(confirmation).toBeChecked() + fireEvent.click(confirmation) + fireEvent.click(screen.getByRole('button', { name: /保存配置/ })) + + await waitFor(() => { + const saveCall = fetchMock.mock.calls.find(([url, init]) => + String(url).includes('/api/boss/config') && (init as RequestInit | undefined)?.method === 'PUT') + expect(saveCall).toBeTruthy() + const body = JSON.parse(String((saveCall?.[1] as RequestInit).body)) + expect(body).toMatchObject({ nativeGreetingDisabledConfirmed: 0 }) + }) + }) + it('文件先本地识别为可编辑预览,不会直接保存', async () => { const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = String(input) diff --git a/front/app/ai-config/page.tsx b/front/app/ai-config/page.tsx index a229a26..12a0846 100644 --- a/front/app/ai-config/page.tsx +++ b/front/app/ai-config/page.tsx @@ -16,7 +16,7 @@ const ANALYSIS_LOGIC_TEXT = `1. 平台配置页先决定怎么找岗位:关键 2. 自动任务按这些条件进入招聘平台搜索岗位,并读取公司、岗位名、薪资、地点、经验、学历、公司信息和岗位描述。 3. AI 会把你的简历内容和岗位信息放在一起分析,返回 score、decision、summary、strengths、risks、greeting。 4. 分数达到当前档案设置的投递分数线后,岗位进入“待确认”列表;分数线可在 Boss 投递分析页的“岗位数据”区域设置。 -5. 只有你在分析页确认后,系统才会执行实际投递,并优先使用 AI 返回的 greeting。` +5. 只有你在分析页确认后,系统才会执行实际投递,并优先使用岗位 JD 定制话术;AI 生成失败时才使用档案默认兜底。` type AiConfig = { introduce: string @@ -49,6 +49,7 @@ type BossConfigResponse = ProfileAwareResponse & { config?: { enableAi?: unknown sayHi?: string + nativeGreetingDisabledConfirmed?: unknown } } @@ -87,6 +88,7 @@ export default function AiConfigPage() { const [resumeFile, setResumeFile] = useState(null) const [resumeDirty, setResumeDirty] = useState(false) const [sayHi, setSayHi] = useState('') + const [nativeGreetingDisabledConfirmed, setNativeGreetingDisabledConfirmed] = useState(0) const [loading, setLoading] = useState(false) const [generating, setGenerating] = useState(false) @@ -174,6 +176,7 @@ export default function AiConfigPage() { setAiConfig({ introduce: '', prompt: '' }) setEnableAi(0) setSayHi('') + setNativeGreetingDisabledConfirmed(0) setResumeText('') setResumeMeta(null) setResumePreview(null) @@ -237,6 +240,9 @@ export default function AiConfigPage() { : { introduce: '', prompt: '' }) setEnableAi(parseEnableAi(bossResult.config?.enableAi)) setSayHi(bossResult.config?.sayHi || '') + setNativeGreetingDisabledConfirmed(parseEnableAi( + bossResult.config?.nativeGreetingDisabledConfirmed, + )) setResumeText(resume?.resumeText || '') setResumeMeta(resume ? { sourceFilename: resume.sourceFilename, @@ -347,7 +353,11 @@ export default function AiConfigPage() { const response = await fetch(`${API_BASE}/api/boss/config`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sayHi: nextSayHi, enableAi }), + body: JSON.stringify({ + sayHi: nextSayHi, + enableAi, + nativeGreetingDisabledConfirmed, + }), }) await readApiResponse(response, 'Boss默认打招呼语保存失败') } @@ -720,10 +730,29 @@ export default function AiConfigPage() { className="min-h-[120px] resize-y" />

- AI关闭、AI返回为空或生成失败时,Boss投递会使用这段话术 + 仅当岗位 JD 话术生成失败时使用;分析页会明确标为“AI 失败兜底”

+ +