diff --git a/chrome-extension/background.js b/chrome-extension/background.js index a441120..b7efeb0 100644 --- a/chrome-extension/background.js +++ b/chrome-extension/background.js @@ -448,6 +448,17 @@ async function requestLocalApi(path, options = {}) { const response = await fetchWithTimeout(`${baseUrl}${path}`, requestOptions, timeoutMs); const data = await parseLocalApiResponse(response); 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}`); @@ -721,9 +732,15 @@ async function handleBossDeliver(tab, config, message, pageTabId) { if (message.type === "BOSS_DELIVER_ONE") { const result = await deliverBossTask(tab, config, message.task, message, pageTabId, 1, 1).catch(async (error) => { const errorMessage = error.message || String(error); - await postBossDeliveryResult(message.task, false, classifyDeliveryFailure(errorMessage)).catch(() => {}); + let persisted = false; + await postBossDeliveryResult(message.task, null, errorMessage, "NO_CONFIRMATION") + .then(() => { persisted = true; }) + .catch(() => {}); return { success: false, + outcome: "UNKNOWN", + evidence: "NO_CONFIRMATION", + persisted, message: errorMessage, failureType: classifyDeliveryFailure(errorMessage).failureType }; @@ -738,35 +755,52 @@ async function handleBossDeliver(tab, config, message, pageTabId) { let success = 0; let failed = 0; + let unknown = 0; + const results = []; 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) => { const errorMessage = error.message || String(error); - await postBossDeliveryResult(task, false, classifyDeliveryFailure(errorMessage)).catch(() => {}); + let persisted = false; + await postBossDeliveryResult(task, null, errorMessage, "NO_CONFIRMATION") + .then(() => { persisted = true; }) + .catch(() => {}); return { success: false, + outcome: "UNKNOWN", + evidence: "NO_CONFIRMATION", + persisted, message: errorMessage, failureType: classifyDeliveryFailure(errorMessage).failureType }; }); - if (result?.success) success += 1; + const outcome = deliveryOutcomeOf(result); + 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 || "" }); } return { - success: true, - message: `Boss批量投递完成:成功${success},失败${failed}`, + success: failed === 0 && unknown === 0, + partial: success > 0 && (failed > 0 || unknown > 0), + message: `Boss批量投递完成:已确认${success},待确认${unknown},失败${failed}`, successCount: success, - failedCount: failed + unknownCount: unknown, + failedCount: failed, + results }; } async function deliverBossTask(tab, config, task, message, pageTabId, index, total) { if (!task?.url || !task?.id) { + let persisted = false; if (task?.id) { - await postBossDeliveryResult(task, false, classifyDeliveryFailure("投递任务缺少岗位链接或ID")).catch(() => {}); + await postBossDeliveryResult(task, false, classifyDeliveryFailure("投递任务缺少岗位链接或ID"), "PRE_ACTION_ERROR") + .then(() => { persisted = true; }) + .catch(() => {}); } - return { success: false, message: "投递任务缺少岗位链接或ID" }; + return { success: false, outcome: "FAILED", evidence: "PRE_ACTION_ERROR", persisted, message: "投递任务缺少岗位链接或ID" }; } postPlatformProgress(pageTabId, { @@ -792,9 +826,15 @@ async function deliverBossTask(tab, config, task, message, pageTabId, index, tot } catch (error) { const errorMessage = buildContentScriptError("boss", error, "投递"); const failure = classifyDeliveryFailure(errorMessage); - await postBossDeliveryResult(task, false, failure).catch(() => {}); + let persisted = false; + await postBossDeliveryResult(task, null, failure.failureReason, "NO_CONFIRMATION") + .then(() => { persisted = true; }) + .catch(() => {}); return { success: false, + outcome: "UNKNOWN", + evidence: "NO_CONFIRMATION", + persisted, message: failure.failureReason, failureType: failure.failureType }; @@ -814,9 +854,12 @@ async function sendBossDeliverCurrent(tabId, message, task, pageTabId, index, to deliveryIndex: index, deliveryTotal: total }); - if (response) return response; + if (response) { + const recorded = await recordBossDeliveryResponse(task, response); + return { ...response, success: recorded.outcome === "CONFIRMED", ...recorded }; + } const fallback = await inferBossDeliveryAfterEmptyResponse(tabId, task); - if (fallback.success) return fallback; + if (fallback.success || fallback.outcome === "UNKNOWN") return fallback; } catch (error) { lastError = error; await sleep(500); @@ -829,9 +872,15 @@ async function handleZhilianDeliver(tab, config, message, pageTabId) { if (message.type === "ZHILIAN_DELIVER_ONE") { const result = await deliverZhilianTask(tab, config, message.task, message, pageTabId, 1, 1).catch(async (error) => { const errorMessage = error.message || String(error); - await postZhilianDeliveryResult(message.task, false, classifyZhilianDeliveryFailure(errorMessage)).catch(() => {}); + let persisted = false; + await postZhilianDeliveryResult(message.task, null, errorMessage, "NO_CONFIRMATION") + .then(() => { persisted = true; }) + .catch(() => {}); return { success: false, + outcome: "UNKNOWN", + evidence: "NO_CONFIRMATION", + persisted, message: errorMessage, failureType: classifyZhilianDeliveryFailure(errorMessage).failureType }; @@ -846,42 +895,62 @@ async function handleZhilianDeliver(tab, config, message, pageTabId) { let success = 0; let failed = 0; + let unknown = 0; + const results = []; for (let index = 0; index < tasks.length; index++) { const task = tasks[index]; const result = await deliverZhilianTask(tab, config, task, message, pageTabId, index + 1, tasks.length).catch(async (error) => { const errorMessage = error.message || String(error); - await postZhilianDeliveryResult(task, false, classifyZhilianDeliveryFailure(errorMessage)).catch(() => {}); + let persisted = false; + await postZhilianDeliveryResult(task, null, errorMessage, "NO_CONFIRMATION") + .then(() => { persisted = true; }) + .catch(() => {}); return { success: false, + outcome: "UNKNOWN", + evidence: "NO_CONFIRMATION", + persisted, message: errorMessage, failureType: classifyZhilianDeliveryFailure(errorMessage).failureType }; }); - if (result?.success) success += 1; + const outcome = deliveryOutcomeOf(result); + 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 || "" }); } return { - success: true, - message: `智联批量投递完成:成功${success},失败${failed}`, + success: failed === 0 && unknown === 0, + partial: success > 0 && (failed > 0 || unknown > 0), + message: `智联批量投递完成:已确认${success},待确认${unknown},失败${failed}`, successCount: success, - failedCount: failed + unknownCount: unknown, + failedCount: failed, + results }; } async function deliverZhilianTask(tab, config, task, message, pageTabId, index, total) { if (!task?.url || !task?.id) { + let persisted = false; if (task?.id) { - await postZhilianDeliveryResult(task, false, classifyZhilianDeliveryFailure("投递任务缺少岗位链接或ID")).catch(() => {}); + await postZhilianDeliveryResult(task, false, classifyZhilianDeliveryFailure("投递任务缺少岗位链接或ID"), "PRE_ACTION_ERROR") + .then(() => { persisted = true; }) + .catch(() => {}); } - return { success: false, message: "投递任务缺少岗位链接或ID" }; + return { success: false, outcome: "FAILED", evidence: "PRE_ACTION_ERROR", persisted, message: "投递任务缺少岗位链接或ID" }; } const targetUrl = normalizeZhilianUrl(task.url); if (!targetUrl || !isZhilianJobDetailUrl(targetUrl)) { const failure = classifyZhilianDeliveryFailure(`拒绝打开非智联岗位详情页:${task.url || ""}`); - await postZhilianDeliveryResult(task, false, failure).catch(() => {}); - return { success: false, message: failure.failureReason, failureType: failure.failureType }; + let persisted = false; + await postZhilianDeliveryResult(task, false, failure, "PRE_ACTION_ERROR") + .then(() => { persisted = true; }) + .catch(() => {}); + return { success: false, outcome: "FAILED", evidence: "PRE_ACTION_ERROR", persisted, message: failure.failureReason, failureType: failure.failureType }; } postPlatformProgress(pageTabId, { @@ -906,9 +975,15 @@ async function deliverZhilianTask(tab, config, task, message, pageTabId, index, } catch (error) { const errorMessage = buildContentScriptError("zhilian", error, "投递"); const failure = classifyZhilianDeliveryFailure(errorMessage); - await postZhilianDeliveryResult(task, false, failure).catch(() => {}); + let persisted = false; + await postZhilianDeliveryResult(task, null, failure.failureReason, "NO_CONFIRMATION") + .then(() => { persisted = true; }) + .catch(() => {}); return { success: false, + outcome: "UNKNOWN", + evidence: "NO_CONFIRMATION", + persisted, message: failure.failureReason, failureType: failure.failureType }; @@ -928,9 +1003,12 @@ async function sendZhilianDeliverCurrent(tabId, message, task, pageTabId, index, deliveryIndex: index, deliveryTotal: total }); - if (response) return response; + if (response) { + const recorded = await recordZhilianDeliveryResponse(task, response); + return { ...response, success: recorded.outcome === "CONFIRMED", ...recorded }; + } const fallback = await inferZhilianDeliveryAfterEmptyResponse(tabId, task); - if (fallback.success) return fallback; + if (fallback.success || fallback.outcome === "UNKNOWN") return fallback; } catch (error) { lastError = error; await sleep(500); @@ -944,8 +1022,15 @@ async function inferZhilianDeliveryAfterEmptyResponse(tabId, task) { const tab = await chrome.tabs.get(tabId); const currentUrl = tab.url || tab.pendingUrl || ""; if (isZhilianUrl(currentUrl)) { + let persisted = false; + await postZhilianDeliveryResult(task, null, "智联投递未返回明确平台结果", "NO_CONFIRMATION") + .then(() => { persisted = true; }) + .catch(() => {}); return { success: false, + outcome: "UNKNOWN", + evidence: "NO_CONFIRMATION", + persisted, message: "智联投递未返回结果,请在详情页确认是否出现投递成功状态。" }; } @@ -960,10 +1045,16 @@ async function inferBossDeliveryAfterEmptyResponse(tabId, task) { const tab = await chrome.tabs.get(tabId); const currentUrl = tab.url || tab.pendingUrl || ""; if (isBossChatUrl(currentUrl)) { - await postBossDeliveryResult(task, true, "Boss已进入沟通页").catch(() => {}); + let persisted = false; + await postBossDeliveryResult(task, null, "Boss已进入沟通页,但未收到明确平台成功状态", "CHAT_SURFACE_ONLY") + .then(() => { persisted = true; }) + .catch(() => {}); return { - success: true, - message: "Boss已进入沟通页,按成功处理。" + success: false, + outcome: "UNKNOWN", + evidence: "CHAT_SURFACE_ONLY", + persisted, + message: "Boss已进入沟通页,但未收到明确成功状态,已标记待确认。" }; } return { success: false, message: "Boss投递未返回结果,未确认进入沟通页。" }; @@ -972,34 +1063,80 @@ async function inferBossDeliveryAfterEmptyResponse(tabId, task) { } } -async function postBossDeliveryResult(task, success, message) { +async function postBossDeliveryResult(task, success, message, evidence) { if (!task?.id) return; - const failure = success ? null : normalizeFailurePayload(message); - await fetch(`http://localhost:6866/api/boss/jobs/${task.id}/delivery-result`, { + const failure = success === false ? normalizeFailurePayload(message) : null; + const outcome = success === true ? "CONFIRMED" : success === false ? "FAILED" : "UNKNOWN"; + const result = await requestLocalApi(`/api/boss/jobs/${task.id}/delivery-result`, { + operation: "delivery-result", method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ + body: { + requestKey: task.requestKey, + outcome, + evidence: evidence || (outcome === "FAILED" ? "PLATFORM_ERROR" : "NO_CONFIRMATION"), success, - message: success ? message : failure.failureReason, + message: success === true ? message : failure?.failureReason || String(message || ""), failureType: failure?.failureType, failureReason: failure?.failureReason - }) + }, + platform: "boss" }); + if (!result.success) throw new Error(result.message || "Boss投递结果写入失败"); + return result; +} + +async function recordBossDeliveryResponse(task, response) { + const outcome = deliveryOutcomeOf(response); + 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 }; } -async function postZhilianDeliveryResult(task, success, message) { +async function postZhilianDeliveryResult(task, success, message, evidence) { if (!task?.id) return; - const failure = success ? null : normalizeZhilianFailurePayload(message); - await fetch(`http://localhost:6866/api/zhilian/jobs/${task.id}/delivery-result`, { + const failure = success === false ? normalizeZhilianFailurePayload(message) : null; + const outcome = success === true ? "CONFIRMED" : success === false ? "FAILED" : "UNKNOWN"; + const result = await requestLocalApi(`/api/zhilian/jobs/${task.id}/delivery-result`, { + operation: "delivery-result", method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ + body: { + requestKey: task.requestKey, + outcome, + evidence: evidence || (outcome === "FAILED" ? "PLATFORM_ERROR" : "NO_CONFIRMATION"), success, - message: success ? message : failure.failureReason, + message: success === true ? message : failure?.failureReason || String(message || ""), failureType: failure?.failureType, failureReason: failure?.failureReason - }) + }, + platform: "zhilian" }); + if (!result.success) throw new Error(result.message || "智联投递结果写入失败"); + return result; +} + +async function recordZhilianDeliveryResponse(task, response) { + const outcome = deliveryOutcomeOf(response); + 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 postZhilianDeliveryResult(task, success, response?.message || "智联投递结果回写", evidence); + return { outcome, evidence, persisted: true }; +} + +function deliveryOutcomeOf(result) { + const outcome = String(result?.outcome || "").toUpperCase(); + if (outcome === "CONFIRMED") { + return isExplicitConfirmationEvidence(result?.evidence) ? "CONFIRMED" : "UNKNOWN"; + } + if (outcome === "FAILED" || outcome === "UNKNOWN") return outcome; + return "UNKNOWN"; +} + +function isExplicitConfirmationEvidence(evidence) { + return ["PLATFORM_STATUS_TEXT", "PLATFORM_SUCCESS_DIALOG", "EXISTING_CONVERSATION"] + .includes(String(evidence || "").toUpperCase()); } function classifyDeliveryFailure(message) { diff --git a/chrome-extension/boss-content.js b/chrome-extension/boss-content.js index 21305f1..38f13c7 100644 --- a/chrome-extension/boss-content.js +++ b/chrome-extension/boss-content.js @@ -27,6 +27,7 @@ let stopRequestedRunId = ""; let activeScanRunId = ""; let activeScanPromise = null; + const deliveryExecutions = new Map(); chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { if (!isCurrentContentInstance()) return; @@ -115,7 +116,7 @@ } if (message?.type === "BOSS_DELIVER_ONE") { prepareStandaloneDelivery(); - deliverOne(message.task, message).then(sendResponse).catch((error) => { + executeDeliveryOnce(message.task, () => deliverOne(message.task, message)).then(sendResponse).catch((error) => { postProgress(message, "error", error.message || String(error), { operation: "deliver", stage: "error" @@ -2751,10 +2752,9 @@ }); await waitForPage(); if (!isSameBossJobUrl(window.location.href, task.url)) { - return { - success: false, - message: "Boss投递需要先由扩展后台打开岗位详情页,请刷新扩展和页面后重试。" - }; + const failure = classifyDeliveryFailure("Boss投递需要先由扩展后台打开岗位详情页,请刷新扩展和页面后重试。"); + await postDeliveryResult(task, false, failure, "PRE_ACTION_ERROR"); + return { success: false, outcome: "FAILED", evidence: "PRE_ACTION_ERROR", message: failure.failureReason, failureType: failure.failureType }; } return deliverOnCurrentPage(task, message); } @@ -2771,30 +2771,44 @@ } }; - deliverOnCurrentPage(message.task, message, respondOnce).then((result) => { - respondOnce(result); + executeDeliveryOnce(message.task, () => deliverOnCurrentPage(message.task, message)).then((result) => { + respondOnce({ ...result, persisted: true }); }).catch((error) => { postProgress(message, "error", error.message || String(error), { operation: "deliver", stage: "error" }); - respondOnce({ success: false, message: error.message || String(error) }); + respondOnce({ success: false, outcome: "UNKNOWN", evidence: "NO_CONFIRMATION", persisted: false, message: error.message || String(error) }); }); } async function deliverOnCurrentPage(task, message, earlyRespond) { if (!task?.url || !task?.id) { - return { success: false, message: "投递任务缺少岗位链接或ID" }; + return { success: false, outcome: "FAILED", evidence: "PRE_ACTION_ERROR", message: "投递任务缺少岗位链接或ID" }; } await waitForPage(); if (!isSameBossJobUrl(window.location.href, task.url)) { - return { success: false, message: "当前Boss页面不是目标岗位详情页,已取消投递。" }; + const failure = classifyDeliveryFailure("当前Boss页面不是目标岗位详情页,已取消投递。"); + await postDeliveryResult(task, false, failure, "PRE_ACTION_ERROR"); + return { success: false, outcome: "FAILED", evidence: "PRE_ACTION_ERROR", message: failure.failureReason, failureType: failure.failureType }; } if (message?.respectScanStop && isStopRequested(message?.runId)) { stopRequested = true; - return { success: false, message: "Boss扫描已停止" }; + const failure = classifyDeliveryFailure("Boss扫描已停止,未执行投递"); + await postDeliveryResult(task, false, failure, "PRE_ACTION_ERROR"); + return { success: false, outcome: "FAILED", evidence: "PRE_ACTION_ERROR", message: failure.failureReason, failureType: failure.failureType }; } await sleep(1500); + if (detectBossDeliveryStatus(document)) { + const messageText = "Boss岗位页面已显示沟通或投递状态"; + await postDeliveryResult(task, true, messageText, "PLATFORM_STATUS_TEXT"); + return { + success: true, + outcome: "CONFIRMED", + evidence: "PLATFORM_STATUS_TEXT", + message: messageText + }; + } postProgress(message, "info", `Boss Chrome正在当前详情页投递:${task.companyName || ""} ${task.jobName || ""}`.trim(), { operation: "deliver", stage: "submitting", @@ -2822,12 +2836,12 @@ } if (!chatButton) { const failure = classifyDeliveryFailure("未找到立即沟通按钮"); - await postDeliveryResult(task, false, failure); + await postDeliveryResult(task, false, failure, "PRE_ACTION_ERROR"); postProgress(message, "warning", `Boss Chrome投递失败:${failure.failureReason}`, { operation: "deliver", stage: "error" }); - return { success: false, message: failure.failureReason, failureType: failure.failureType }; + return { success: false, outcome: "FAILED", evidence: "PRE_ACTION_ERROR", message: failure.failureReason, failureType: failure.failureType }; } postProgress(message, "info", "Boss Chrome已找到沟通入口,准备点击立即沟通。", { operation: "deliver", @@ -2836,9 +2850,9 @@ clickElement(chatButton); const successMessage = favoriteButton ? "Boss岗位已点击感兴趣并立即沟通" : "Boss岗位已点击立即沟通"; const deliveryCheck = await waitForDeliveryOpened(beforeUrl, task, 9000); - if (!deliveryCheck.success) { + if (deliveryCheck.outcome === "FAILED") { const failure = classifyDeliveryFailure(deliveryCheck.message); - await postDeliveryResult(task, false, failure); + await postDeliveryResult(task, false, failure, deliveryCheck.evidence || "PLATFORM_ERROR"); postProgress(message, "warning", `Boss Chrome投递失败:${failure.failureReason}`, { operation: "deliver", stage: "error" @@ -2847,14 +2861,28 @@ } const greetingResult = await sendConfiguredGreeting(task, message); const finalMessage = greetingResult?.sent ? `${successMessage},已发送开场白` : successMessage; - await postDeliveryResult(task, true, finalMessage); - earlyRespond?.({ success: true, message: finalMessage, early: true }); - postProgress(message, "success", buildDeliverySuccessMessage(favoriteButton, greetingResult), { + const confirmed = deliveryCheck.outcome === "CONFIRMED"; + await postDeliveryResult( + task, + confirmed ? true : null, + confirmed ? finalMessage : `${finalMessage},但未检测到明确平台成功状态`, + deliveryCheck.evidence || (confirmed ? "PLATFORM_STATUS_TEXT" : "CHAT_SURFACE_ONLY") + ); + const result = { + success: confirmed, + outcome: confirmed ? "CONFIRMED" : "UNKNOWN", + evidence: deliveryCheck.evidence || (confirmed ? "PLATFORM_STATUS_TEXT" : "CHAT_SURFACE_ONLY"), + message: confirmed ? finalMessage : `${finalMessage},结果待人工确认` + }; + earlyRespond?.({ ...result, early: true }); + postProgress(message, confirmed ? "success" : "warning", confirmed + ? buildDeliverySuccessMessage(favoriteButton, greetingResult) + : `${buildDeliverySuccessMessage(favoriteButton, greetingResult)}未检测到明确平台成功状态,已标记待确认。`, { operation: "deliver", stage: "complete", - saved: 1 + saved: confirmed ? 1 : 0 }); - return { success: true, message: finalMessage }; + return result; } async function sendConfiguredGreeting(task, message) { @@ -2948,6 +2976,8 @@ async function deliverBatch(tasks, message) { let success = 0; let failed = 0; + let unknown = 0; + const results = []; postProgress(message, "info", `Boss Chrome批量投递开始,共 ${tasks.length} 个待确认岗位。`, { operation: "deliver", stage: "received", @@ -2964,28 +2994,48 @@ keywordTotal: tasks.length, saved: success }); - const result = await deliverOne(task, message).catch(async (error) => { + const result = await executeDeliveryOnce(task, () => deliverOne(task, message)) + .then((value) => ({ ...value, persisted: true })) + .catch(async (error) => { const failure = classifyDeliveryFailure(error.message || String(error)); - await postDeliveryResult(task, false, failure).catch(() => {}); - return { success: false, message: failure.failureReason, failureType: failure.failureType }; + let persisted = false; + await postDeliveryResult(task, null, failure.failureReason, "NO_CONFIRMATION") + .then(() => { persisted = true; }) + .catch(() => {}); + return { success: false, outcome: "UNKNOWN", evidence: "NO_CONFIRMATION", persisted, message: failure.failureReason, failureType: failure.failureType }; }); - if (result.success) success += 1; + const outcome = result?.outcome || (result?.success ? "CONFIRMED" : "FAILED"); + 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 || "" }); } - postProgress(message, failed ? "warning" : "success", `Boss批量投递完成:成功${success},失败${failed}`, { + postProgress(message, failed || unknown ? "warning" : "success", `Boss批量投递完成:已确认${success},待确认${unknown},失败${failed}`, { operation: "deliver", stage: "complete", keywordTotal: tasks.length, saved: success }); - return { success: true, message: `Boss批量投递完成:成功${success},失败${failed}`, successCount: success, failedCount: failed }; + return { + success: failed === 0 && unknown === 0, + partial: success > 0 && (failed > 0 || unknown > 0), + message: `Boss批量投递完成:已确认${success},待确认${unknown},失败${failed}`, + successCount: success, + unknownCount: unknown, + failedCount: failed, + results + }; } - async function postDeliveryResult(task, success, message) { - const failure = success ? null : normalizeFailurePayload(message); + async function postDeliveryResult(task, success, message, evidence) { + const failure = success === false ? normalizeFailurePayload(message) : null; + const outcome = success === true ? "CONFIRMED" : success === false ? "FAILED" : "UNKNOWN"; await callBossLocalApi("delivery-result", { + requestKey: task.requestKey, + outcome, + evidence: evidence || (outcome === "CONFIRMED" ? "PLATFORM_STATUS_TEXT" : outcome === "FAILED" ? "PLATFORM_ERROR" : "NO_CONFIRMATION"), success, - message: success ? message : failure.failureReason, + message: success === true ? message : failure?.failureReason || String(message || ""), failureType: failure?.failureType, failureReason: failure?.failureReason }, { @@ -3588,20 +3638,40 @@ const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { const failure = detectDeliveryFailure(""); - if (failure) return { success: false, message: failure }; + if (failure) return { success: false, outcome: "FAILED", evidence: "PLATFORM_ERROR", message: failure }; + if (detectBossDeliveryStatus(document)) { + return { success: true, outcome: "CONFIRMED", evidence: "PLATFORM_STATUS_TEXT", message: "Boss页面已显示沟通或投递状态" }; + } if (isBossChatPage(window.location.href)) { - return { success: true, message: "已进入Boss沟通页" }; + return { success: false, outcome: "UNKNOWN", evidence: "CHAT_SURFACE_ONLY", message: "已进入Boss沟通页,但未显示明确成功状态" }; } if (findChatInput()) { - return { success: true, message: "已打开Boss聊天窗口" }; + return { success: false, outcome: "UNKNOWN", evidence: "CHAT_SURFACE_ONLY", message: "已打开Boss聊天窗口,但未显示明确成功状态" }; } const continueButton = findBossDeliverButton(["继续沟通", "已沟通"], []); if (continueButton && (!isSameBossJobUrl(beforeUrl, window.location.href) || isSameBossJobUrl(window.location.href, task.url))) { - return { success: true, message: "Boss沟通状态已更新" }; + return { success: true, outcome: "CONFIRMED", evidence: "PLATFORM_STATUS_TEXT", message: "Boss沟通状态已更新" }; } await sleep(300); } - return { success: false, message: detectDeliveryFailure("点击立即沟通后未出现聊天窗口或沟通页") }; + const failure = detectDeliveryFailure(""); + if (failure) return { success: false, outcome: "FAILED", evidence: "PLATFORM_ERROR", message: failure }; + return { success: false, outcome: "UNKNOWN", evidence: "NO_CONFIRMATION", message: "点击立即沟通后未出现明确平台结果" }; + } + + function executeDeliveryOnce(task, action) { + const requestKey = compact(task?.requestKey || ""); + if (!requestKey) { + return Promise.resolve({ success: false, outcome: "FAILED", message: "投递任务缺少 requestKey,已拒绝执行" }); + } + const existing = deliveryExecutions.get(requestKey); + if (existing) return existing; + if (deliveryExecutions.size >= 200) { + deliveryExecutions.delete(deliveryExecutions.keys().next().value); + } + const execution = Promise.resolve().then(action); + deliveryExecutions.set(requestKey, execution); + return execution; } function detectDeliveryFailure(fallback) { diff --git a/chrome-extension/tests/background-tab-routing.test.cjs b/chrome-extension/tests/background-tab-routing.test.cjs index b1143b5..163e2ec 100644 --- a/chrome-extension/tests/background-tab-routing.test.cjs +++ b/chrome-extension/tests/background-tab-routing.test.cjs @@ -317,6 +317,81 @@ test("allows numeric Zhilian delivery result IDs and rejects invalid or unknown assert.equal(urls.length, 1); }); +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: "状态已变化" }); } + }) + }); + + const result = await context.requestLocalApi("/api/boss/jobs/1/delivery-result", { + operation: "delivery-result", + method: "POST", + body: { requestKey: "request-1", outcome: "CONFIRMED", evidence: "PLATFORM_STATUS_TEXT" } + }); + + assert.equal(result.success, false); + assert.equal(result.errorType, "BUSINESS_REJECTED"); + assert.equal(result.message, "状态已变化"); +}); + +test("records an empty Boss chat-page response as unknown instead of confirmed", async () => { + const requests = []; + const { context } = loadBackground({ + 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}'; } }; + } + }); + + const result = await context.inferBossDeliveryAfterEmptyResponse(7, { + id: 99, + requestKey: "request-99" + }); + + assert.equal(result.success, false); + assert.equal(result.outcome, "UNKNOWN"); + assert.equal(result.persisted, true); + assert.equal(requests.length, 1); + assert.equal(requests[0].body.requestKey, "request-99"); + assert.equal(requests[0].body.outcome, "UNKNOWN"); + assert.equal(requests[0].body.evidence, "CHAT_SURFACE_ONLY"); +}); + +test("does not upgrade legacy success booleans to confirmed without explicit evidence", () => { + const { context } = loadBackground({ tabs: [] }); + + assert.equal(context.deliveryOutcomeOf({ success: true }), "UNKNOWN"); + assert.equal(context.deliveryOutcomeOf({ outcome: "CONFIRMED" }), "UNKNOWN"); + assert.equal(context.deliveryOutcomeOf({ + outcome: "CONFIRMED", + evidence: "PLATFORM_STATUS_TEXT" + }), "CONFIRMED"); +}); + +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" }); } + }) + }); + + await assert.rejects( + context.recordBossDeliveryResponse( + { id: 77, requestKey: "request-77" }, + { outcome: "UNKNOWN", evidence: "NO_CONFIRMATION", message: "result uncertain" } + ), + /database unavailable/ + ); +}); + test("rejects forged Zhilian senders before any local API request", async () => { let fetchCalls = 0; const { runtimeMessageListener } = loadBackground({ diff --git a/chrome-extension/tests/delivery-state-safety.test.cjs b/chrome-extension/tests/delivery-state-safety.test.cjs new file mode 100644 index 0000000..dabfdda --- /dev/null +++ b/chrome-extension/tests/delivery-state-safety.test.cjs @@ -0,0 +1,46 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); + +const EXTENSION_DIR = path.resolve(__dirname, ".."); + +function source(file) { + return fs.readFileSync(path.join(EXTENSION_DIR, file), "utf8"); +} + +test("Boss and Zhilian callbacks carry request identity, outcome and evidence", () => { + for (const file of ["boss-content.js", "zhilian-content.js"]) { + const text = source(file); + assert.match(text, /requestKey:\s*task\.requestKey/); + assert.match(text, /outcome,/); + assert.match(text, /evidence:/); + assert.match(text, /executeDeliveryOnce\(message\.task/); + } +}); + +test("uncertain delivery paths remain unknown and batch responses expose per-row results", () => { + const background = source("background.js"); + const boss = source("boss-content.js"); + const zhilian = source("zhilian-content.js"); + + assert.match(background, /Boss已进入沟通页,但未收到明确平台成功状态/); + assert.doesNotMatch(background, /Boss已进入沟通页,按成功处理/); + assert.match(boss, /outcome:\s*"UNKNOWN"/); + assert.match(zhilian, /outcome:\s*"UNKNOWN"/); + for (const text of [background, boss, zhilian]) { + assert.match(text, /unknownCount:/); + assert.match(text, /results/); + assert.match(text, /persisted/); + } +}); + +test("delivery-result persistence is explicit so the frontend can compensate failed callbacks", () => { + const background = source("background.js"); + const boss = source("boss-content.js"); + const zhilian = source("zhilian-content.js"); + + assert.match(background, /persisted:\s*result\?\.persisted\s*===\s*true/); + assert.match(boss, /persisted:\s*false/); + assert.match(zhilian, /persisted:\s*false/); +}); diff --git a/chrome-extension/zhilian-content.js b/chrome-extension/zhilian-content.js index dfed197..3c36250 100644 --- a/chrome-extension/zhilian-content.js +++ b/chrome-extension/zhilian-content.js @@ -77,6 +77,7 @@ ]; let stopRequested = false; let activeScanPromise = null; + const deliveryExecutions = new Map(); chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { if (window.__GET_JOBS_ZHILIAN_CONTENT_INSTANCE_ID__ !== CONTENT_INSTANCE_ID) return; @@ -111,7 +112,7 @@ return true; } if (messageType === "ZHILIAN_DELIVER_ONE") { - deliverOne(message.task, message).then(sendResponse).catch((error) => sendResponse({ success: false, message: error.message || String(error) })); + executeDeliveryOnce(message.task, () => deliverOne(message.task, message)).then(sendResponse).catch((error) => sendResponse({ success: false, message: error.message || String(error) })); return true; } if (messageType === "ZHILIAN_DELIVER_BATCH") { @@ -1385,8 +1386,8 @@ await waitForPage(); if (!isCurrentZhilianJobDetailPage(task.url) && !isSameUrl(window.location.href, task.url)) { const failure = classifyDeliveryFailure("当前智联页面不是目标岗位详情页,已取消投递。"); - await postDeliveryResult(task, false, failure); - return { success: false, message: failure.failureReason, failureType: failure.failureType }; + await postDeliveryResult(task, false, failure, "PRE_ACTION_ERROR"); + return { success: false, outcome: "FAILED", evidence: "PRE_ACTION_ERROR", message: failure.failureReason, failureType: failure.failureType }; } return deliverOnCurrentPage(task, message); } @@ -1403,26 +1404,26 @@ } }; - deliverOnCurrentPage(message.task, message, respondOnce).then((result) => { - respondOnce(result); + executeDeliveryOnce(message.task, () => deliverOnCurrentPage(message.task, message)).then((result) => { + respondOnce({ ...result, persisted: true }); }).catch((error) => { postProgress(message, "error", error.message || String(error), { operation: "deliver", stage: "error" }); - respondOnce({ success: false, message: error.message || String(error) }); + respondOnce({ success: false, outcome: "UNKNOWN", evidence: "NO_CONFIRMATION", persisted: false, message: error.message || String(error) }); }); } async function deliverOnCurrentPage(task, message = {}, earlyRespond) { if (!task?.url || !task?.id) { - return { success: false, message: "投递任务缺少岗位链接或ID" }; + return { success: false, outcome: "FAILED", evidence: "PRE_ACTION_ERROR", message: "投递任务缺少岗位链接或ID" }; } await waitForPage(); if (!isCurrentZhilianJobDetailPage(task.url) && !isSameUrl(window.location.href, task.url)) { const failure = classifyDeliveryFailure("当前智联页面不是目标岗位详情页,已取消投递。"); - await postDeliveryResult(task, false, failure); - return { success: false, message: failure.failureReason, failureType: failure.failureType }; + await postDeliveryResult(task, false, failure, "PRE_ACTION_ERROR"); + return { success: false, outcome: "FAILED", evidence: "PRE_ACTION_ERROR", message: failure.failureReason, failureType: failure.failureType }; } await sleep(1500); @@ -1436,25 +1437,25 @@ if (detectZhilianDeliveryStatus(document)) { const successMessage = "智联岗位已是已投递状态"; - await postDeliveryResult(task, true, successMessage); - earlyRespond?.({ success: true, message: successMessage, early: true }); + await postDeliveryResult(task, true, successMessage, "PLATFORM_STATUS_TEXT"); + earlyRespond?.({ success: true, outcome: "CONFIRMED", evidence: "PLATFORM_STATUS_TEXT", message: successMessage, early: true }); postProgress(message, "success", successMessage, { operation: "deliver", stage: "complete", saved: 1 }); - return { success: true, message: successMessage }; + return { success: true, outcome: "CONFIRMED", evidence: "PLATFORM_STATUS_TEXT", message: successMessage }; } const pageFailure = detectZhilianDeliveryFailure(""); if (pageFailure) { const failure = classifyDeliveryFailure(pageFailure); - await postDeliveryResult(task, false, failure); + await postDeliveryResult(task, false, failure, "PRE_ACTION_ERROR"); postProgress(message, "warning", `智联 Chrome投递失败:${failure.failureReason}`, { operation: "deliver", stage: "error" }); - return { success: false, message: failure.failureReason, failureType: failure.failureType }; + return { success: false, outcome: "FAILED", evidence: "PRE_ACTION_ERROR", message: failure.failureReason, failureType: failure.failureType }; } const favoriteButton = findZhilianActionButton(["收藏"], ["已收藏", "取消收藏"]); @@ -1473,12 +1474,12 @@ } if (!applyButton) { const failure = classifyDeliveryFailure("未找到智联投递按钮"); - await postDeliveryResult(task, false, failure); + await postDeliveryResult(task, false, failure, "PRE_ACTION_ERROR"); postProgress(message, "warning", `智联 Chrome投递失败:${failure.failureReason}`, { operation: "deliver", stage: "error" }); - return { success: false, message: failure.failureReason, failureType: failure.failureType }; + return { success: false, outcome: "FAILED", evidence: "PRE_ACTION_ERROR", message: failure.failureReason, failureType: failure.failureType }; } postProgress(message, "info", "智联 Chrome已找到投递入口,准备点击立即投递。", { @@ -1490,25 +1491,25 @@ if (detectZhilianDeliveryStatus(document)) { const successMessage = favoriteButton ? "智联岗位已收藏并投递" : "智联岗位已投递"; - await postDeliveryResult(task, true, successMessage); - earlyRespond?.({ success: true, message: successMessage, early: true }); + await postDeliveryResult(task, true, successMessage, "PLATFORM_STATUS_TEXT"); + earlyRespond?.({ success: true, outcome: "CONFIRMED", evidence: "PLATFORM_STATUS_TEXT", message: successMessage, early: true }); postProgress(message, "success", `智联 Chrome投递完成:${successMessage}。`, { operation: "deliver", stage: "complete", saved: 1 }); - return { success: true, message: successMessage }; + return { success: true, outcome: "CONFIRMED", evidence: "PLATFORM_STATUS_TEXT", message: successMessage }; } const clickedFailure = detectZhilianDeliveryFailure(""); if (clickedFailure) { const failure = classifyDeliveryFailure(clickedFailure); - await postDeliveryResult(task, false, failure); + await postDeliveryResult(task, false, failure, "PLATFORM_ERROR"); postProgress(message, "warning", `智联 Chrome投递失败:${failure.failureReason}`, { operation: "deliver", stage: "error" }); - return { success: false, message: failure.failureReason, failureType: failure.failureType }; + return { success: false, outcome: "FAILED", evidence: "PLATFORM_ERROR", message: failure.failureReason, failureType: failure.failureType }; } const confirm = await waitForZhilianActionButton(["确认投递", "确定", "继续投递"], ["取消"], 2500); @@ -1517,50 +1518,86 @@ await sleep(1200); } + if (detectZhilianDeliveryStatus(document)) { + const successMessage = favoriteButton ? "智联岗位已收藏并投递" : "智联岗位已投递"; + await postDeliveryResult(task, true, successMessage, "PLATFORM_STATUS_TEXT"); + earlyRespond?.({ success: true, outcome: "CONFIRMED", evidence: "PLATFORM_STATUS_TEXT", message: successMessage, early: true }); + postProgress(message, "success", `智联 Chrome投递完成:${successMessage}。`, { + operation: "deliver", + stage: "complete", + saved: 1 + }); + return { success: true, outcome: "CONFIRMED", evidence: "PLATFORM_STATUS_TEXT", message: successMessage }; + } + const finalFailure = detectZhilianDeliveryFailure(""); if (finalFailure && !detectZhilianDeliveryStatus(document)) { const failure = classifyDeliveryFailure(finalFailure); - await postDeliveryResult(task, false, failure); + await postDeliveryResult(task, false, failure, "PLATFORM_ERROR"); postProgress(message, "warning", `智联 Chrome投递失败:${failure.failureReason}`, { operation: "deliver", stage: "error" }); - return { success: false, message: failure.failureReason, failureType: failure.failureType }; + return { success: false, outcome: "FAILED", evidence: "PLATFORM_ERROR", message: failure.failureReason, failureType: failure.failureType }; } - const successMessage = favoriteButton ? "智联岗位已收藏并在Chrome中投递" : "智联岗位已在Chrome中投递"; - await postDeliveryResult(task, true, successMessage); - earlyRespond?.({ success: true, message: successMessage, early: true }); - postProgress(message, "success", `智联 Chrome投递完成:${successMessage}。`, { + const unknownMessage = favoriteButton + ? "智联已完成收藏和投递点击,但未检测到明确平台成功状态" + : "智联已完成投递点击,但未检测到明确平台成功状态"; + await postDeliveryResult(task, null, unknownMessage, "NO_CONFIRMATION"); + earlyRespond?.({ success: false, outcome: "UNKNOWN", evidence: "NO_CONFIRMATION", message: unknownMessage, early: true }); + postProgress(message, "warning", `智联 Chrome投递结果待确认:${unknownMessage}。`, { operation: "deliver", stage: "complete", - saved: 1 + saved: 0 }); - return { success: true, message: successMessage }; + return { success: false, outcome: "UNKNOWN", evidence: "NO_CONFIRMATION", message: unknownMessage }; } async function deliverBatch(tasks, message = {}) { let success = 0; let failed = 0; + let unknown = 0; + const results = []; for (const task of tasks) { - const result = await deliverOne(task, message).catch(async (error) => { + const result = await executeDeliveryOnce(task, () => deliverOne(task, message)) + .then((value) => ({ ...value, persisted: true })) + .catch(async (error) => { const failure = classifyDeliveryFailure(error.message || String(error)); - await postDeliveryResult(task, false, failure).catch(() => {}); - return { success: false, message: failure.failureReason, failureType: failure.failureType }; + let persisted = false; + await postDeliveryResult(task, null, failure.failureReason, "NO_CONFIRMATION") + .then(() => { persisted = true; }) + .catch(() => {}); + return { success: false, outcome: "UNKNOWN", evidence: "NO_CONFIRMATION", persisted, message: failure.failureReason, failureType: failure.failureType }; }); - if (result.success) success += 1; + const outcome = result?.outcome || (result?.success ? "CONFIRMED" : "FAILED"); + 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 || "" }); } - return { success: true, message: `智联批量投递完成:成功${success},失败${failed}`, successCount: success, failedCount: failed }; + return { + success: failed === 0 && unknown === 0, + partial: success > 0 && (failed > 0 || unknown > 0), + message: `智联批量投递完成:已确认${success},待确认${unknown},失败${failed}`, + successCount: success, + unknownCount: unknown, + failedCount: failed, + results + }; } - async function postDeliveryResult(task, success, message) { - const failure = success ? null : normalizeFailurePayload(message); + async function postDeliveryResult(task, success, message, evidence) { + const failure = success === false ? normalizeFailurePayload(message) : null; + const outcome = success === true ? "CONFIRMED" : success === false ? "FAILED" : "UNKNOWN"; await requestZhilianLocalApi("delivery-result", { params: { id: task.id }, body: { + requestKey: task.requestKey, + outcome, + evidence: evidence || (outcome === "CONFIRMED" ? "PLATFORM_STATUS_TEXT" : outcome === "FAILED" ? "PLATFORM_ERROR" : "NO_CONFIRMATION"), success, - message: success ? message : failure.failureReason, + message: success === true ? message : failure?.failureReason || String(message || ""), failureType: failure?.failureType, failureReason: failure?.failureReason }, @@ -1609,6 +1646,21 @@ }); } + function executeDeliveryOnce(task, action) { + const requestKey = compact(task?.requestKey || ""); + if (!requestKey) { + return Promise.resolve({ success: false, outcome: "FAILED", message: "投递任务缺少 requestKey,已拒绝执行" }); + } + const existing = deliveryExecutions.get(requestKey); + if (existing) return existing; + if (deliveryExecutions.size >= 200) { + deliveryExecutions.delete(deliveryExecutions.keys().next().value); + } + const execution = Promise.resolve().then(action); + deliveryExecutions.set(requestKey, execution); + return execution; + } + function findClickable(labels) { const all = Array.from(document.querySelectorAll("button, a, div, span")).filter((el) => el.offsetParent !== null); return all.find((el) => labels.some((label) => compact(el.innerText || "").includes(label))); @@ -1688,13 +1740,19 @@ } function detectZhilianDeliveryStatus(root = document) { - const text = compact([ - ...Array.from(root.querySelectorAll?.("button, a, [role='button'], div, span") || []) - .filter((el) => el.offsetParent !== null) - .map((el) => [el.innerText, el.textContent, el.getAttribute?.("aria-label"), el.getAttribute?.("title")].filter(Boolean).join(" ")), - root === document ? "" : root.innerText - ].filter(Boolean).join(" ")); - if (/(已投递|已申请|投递成功|申请成功|继续沟通)/.test(text)) return "已投递"; + const successLabels = ["已投递", "已申请", "投递成功", "申请成功", "继续沟通"]; + const elements = Array.from(root.querySelectorAll?.("button, a, [role='button']") || []) + .filter((el) => el.offsetParent !== null); + const matched = elements.some((el) => { + const text = compact([ + el.innerText, + el.textContent, + el.getAttribute?.("aria-label"), + el.getAttribute?.("title") + ].filter(Boolean).join(" ")); + return successLabels.some((label) => text === label || (text.includes(label) && text.length <= label.length + 4)); + }); + if (matched) return "已投递"; return ""; } @@ -1702,7 +1760,7 @@ const text = compact(document.body?.innerText || ""); if (isSecurityPrompt(text)) return "智联页面出现平台验证,请处理后重试"; if (isStrongLoginPrompt(text, window.location.href)) return "智联登录状态失效,请在Chrome中重新登录后重试"; - const reason = firstMatch(text, /(职位已关闭|停止招聘|职位不存在|岗位已下线|已暂停招聘|已投递|已申请|投递成功|申请成功|今日投递.*?已用完|投递上限|账号异常|操作过于频繁|请先完善简历|请上传简历|请先完成实名认证)/); + const reason = firstMatch(text, /(职位已关闭|停止招聘|职位不存在|岗位已下线|已暂停招聘|今日投递.*?已用完|投递上限|账号异常|操作过于频繁|请先完善简历|请上传简历|请先完成实名认证)/); return reason || fallback || ""; } diff --git a/front/app/51job/analysis/AnalysisContent.tsx b/front/app/51job/analysis/AnalysisContent.tsx index 1d7b585..d329b17 100644 --- a/front/app/51job/analysis/AnalysisContent.tsx +++ b/front/app/51job/analysis/AnalysisContent.tsx @@ -17,6 +17,8 @@ type StatsResponse = { total: number delivered: number pending: number + requested: number + unknown: number filtered: number failed: number avgMonthlyK?: number | null @@ -109,7 +111,7 @@ export default function AnalysisContent({ showHeader = false }:{ showHeader?: bo const [reloading,setReloading]=useState(false) const [exporting,setExporting]=useState(false) - const statusOptions = ["未投递","已投递"] + const statusOptions = ["未投递", "投递确认中", "投递结果待确认", "已投递", "投递失败"] useEffect(()=>{ loadStats() },[]) useEffect(()=>{ setInputPage(page) },[page]) @@ -172,7 +174,7 @@ export default function AnalysisContent({ showHeader = false }:{ showHeader?: bo }catch(e){ console.error("export CSV failed",e); alert("导出失败,请稍后重试") } finally { setExporting(false) } } - const kpiCards = useMemo(()=>{ const k=stats?.kpi; return [ { title:"总岗位数", value:k?.total??0 }, { title:"已投递", value:k?.delivered??0 }, { title:"未投递", value:k?.pending??0 }, { title:"平均月薪(K)", value:k?.avgMonthlyK??0 } ] },[stats]) + const kpiCards = useMemo(()=>{ const k=stats?.kpi; return [ { title:"总岗位数", value:k?.total??0 }, { title:"已投递", value:k?.delivered??0 }, { title:"投递确认中", value:k?.requested??0 }, { title:"结果待确认", value:k?.unknown??0 }, { title:"未投递", value:k?.pending??0 }, { title:"平均月薪(K)", value:k?.avgMonthlyK??0 } ] },[stats]) return (
diff --git a/front/app/boss/analysis/AnalysisContent.tsx b/front/app/boss/analysis/AnalysisContent.tsx index 36af561..c43e851 100644 --- a/front/app/boss/analysis/AnalysisContent.tsx +++ b/front/app/boss/analysis/AnalysisContent.tsx @@ -102,6 +102,8 @@ export default function AnalysisContent({ handleConfirmBatch, handleConfirmAiRecommendedBatch, handleConfirmManualBatch, + handleReconcileJob, + handleRetryJob, handleSkipJob, clearAnalysisData, } = useBossDeliveryActions({ @@ -287,6 +289,8 @@ export default function AnalysisContent({ selectedManualJobIds={selectedManualJobIds} onOpenText={openTextDialog} onConfirmJob={handleConfirmJob} + onReconcileJob={handleReconcileJob} + onRetryJob={handleRetryJob} onSkipJob={handleSkipJob} onLoadList={loadList} onInputPageChange={setInputPage} diff --git a/front/app/boss/analysis/components/BossJobTable.tsx b/front/app/boss/analysis/components/BossJobTable.tsx index 3034f05..1aabd23 100644 --- a/front/app/boss/analysis/components/BossJobTable.tsx +++ b/front/app/boss/analysis/components/BossJobTable.tsx @@ -23,6 +23,8 @@ export function BossJobTable({ selectedManualJobIds, onOpenText, onConfirmJob, + onReconcileJob, + onRetryJob, onSkipJob, onLoadList, onInputPageChange, @@ -44,6 +46,8 @@ export function BossJobTable({ selectedManualJobIds: ReadonlySet onOpenText: (title: string, content?: string) => void onConfirmJob: (job: BossJob) => void + onReconcileJob: (job: BossJob) => void + onRetryJob: (job: BossJob) => void onSkipJob: (job: BossJob) => void onLoadList: (page: number, size: number) => void onInputPageChange: (value: number | string) => void @@ -190,15 +194,30 @@ export function BossJobTable({ {(page - 1) * size + idx + 1} - {job.deliveryStatus === "待确认" ? ( + {job.deliveryStatus === "待确认" || job.deliveryStatus === "投递确认中" ? (
- + ) : null} +
+ ) : job.deliveryStatus === "投递结果待确认" ? ( +
+ +
+ ) : job.deliveryStatus === "投递失败" ? ( + ) : (job.deliveryStatus || "").includes("已投递") ? ( diff --git a/front/app/boss/analysis/hooks/useBossDeliveryActions.ts b/front/app/boss/analysis/hooks/useBossDeliveryActions.ts index a695fea..6442b00 100644 --- a/front/app/boss/analysis/hooks/useBossDeliveryActions.ts +++ b/front/app/boss/analysis/hooks/useBossDeliveryActions.ts @@ -6,6 +6,72 @@ import { API_BASE } from "@/lib/api" import { sendChromeBridgeMessage } from "@/lib/chromeBridge" import type { BossJob, FilterState } from "../types" +type ReservedTask = { id?: number; requestKey?: string } + +async function postJsonWithRetry(url: string, body?: unknown) { + let lastError: unknown + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + const response = await fetch(url, { + method: "POST", + headers: body === undefined ? undefined : { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + return await response.json() + } catch (error) { + lastError = error + } + } + throw lastError instanceof Error ? lastError : new Error("投递请求未收到响应") +} + +function unresolvedReservations(tasks: ReservedTask[], result: Record) { + const rows = Array.isArray(result.results) ? result.results : [] + if (rows.length === 0) return tasks + const persistedKeys = new Set(rows.map((row) => { + if (!row || typeof row !== "object") return "" + const item = row as { requestKey?: unknown; persisted?: unknown } + return item.persisted === true ? String(item.requestKey || "") : "" + })) + return tasks.filter((task) => !task.requestKey || !persistedKeys.has(task.requestKey)) +} + +function formatBatchDeliveryResult(result: Record) { + const summary = String(result.message || "批量投递任务已结束。") + const rows = Array.isArray(result.results) ? result.results : [] + if (rows.length === 0) return summary + const details = rows.slice(0, 50).map((row, index) => { + const item = row && typeof row === "object" + ? row as { id?: unknown; requestKey?: unknown; outcome?: unknown; evidence?: unknown; persisted?: unknown; message?: unknown } + : {} + const persisted = item.persisted === true ? "已落库" : "待补偿" + return `${index + 1}. 岗位 ${String(item.id || "-")} · ${String(item.outcome || "UNKNOWN")} · ${persisted} · ${String(item.evidence || "-")}\n${String(item.message || "")}` + }) + return `${summary}\n\n逐条结果:\n${details.join("\n")}` +} + +async function markUnknownReservations(tasks: ReservedTask[], reason: string) { + const results = await Promise.allSettled(tasks.map(async (task) => { + if (!task.id || !task.requestKey) return + const response = await fetch(`${API_BASE}/api/boss/jobs/${task.id}/delivery-result`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + requestKey: task.requestKey, + outcome: "UNKNOWN", + evidence: "NO_CONFIRMATION", + message: reason, + }), + }) + const data = await response.json().catch(() => ({})) + if (!response.ok || data.success === false) { + throw new Error(data.message || "Boss UNKNOWN 状态回写失败") + } + })) + const failed = results.filter((result) => result.status === "rejected") + if (failed.length > 0) console.error("Boss UNKNOWN 状态回写失败", failed) +} + export function useBossDeliveryActions({ filters, activeScanRunId, @@ -57,31 +123,88 @@ export function useBossDeliveryActions({ }, [openTextDialog]) const handleConfirmJob = useCallback(async (job: BossJob) => { + let reservedTasks: ReservedTask[] = [] try { setActingJobId(job.id) - const res = await fetch(`${API_BASE}/api/boss/jobs/${job.id}/confirm`, { method: "POST" }) - const data = await res.json() + const ok = window.confirm(`将通过 Chrome 真实联系 Boss HR:${job.companyName || ""} / ${job.jobName || ""}。确认继续?`) + if (!ok) return + const data = await postJsonWithRetry(`${API_BASE}/api/boss/jobs/${job.id}/confirm`) if (!data.success) { openTextDialog("确认投递", data.message || "该岗位暂不能投递。") return } - const ok = window.confirm(`将通过 Chrome 真实联系 Boss HR:${job.companyName || ""} / ${job.jobName || ""}。确认继续?`) - if (!ok) return + reservedTasks = [data.task] const result = await sendChromeBridgeMessage({ type: "BOSS_DELIVER_ONE", platform: "boss", task: data.task, }, 120000) + if (result.persisted !== true) { + await markUnknownReservations(reservedTasks, result.message || "Chrome Bridge 未返回岗位结果") + } openTextDialog("确认投递", result.message || (result.success ? "已发送投递请求。" : "Chrome投递失败。")) await loadList(page, size) await refreshStats() } catch { + await markUnknownReservations(reservedTasks, "前端未收到 Chrome 投递执行结果") openTextDialog("待确认发送", "确认失败:网络或服务异常。") } finally { setActingJobId(null) } }, [loadList, openTextDialog, page, refreshStats, size]) + const handleReconcileJob = useCallback(async (job: BossJob) => { + const answer = window.prompt( + "请先在 Boss 平台核对该岗位。输入“已投递”确认成功,输入“未投递”确认失败;其他内容不会修改状态。", + )?.trim() + if (answer !== "已投递" && answer !== "未投递") return + try { + setActingJobId(job.id) + const data = await postJsonWithRetry(`${API_BASE}/api/boss/jobs/${job.id}/delivery-reconcile`, { + outcome: answer === "已投递" ? "CONFIRMED" : "FAILED", + message: `用户在 Boss 平台人工核对:${answer}`, + }) + openTextDialog("人工对账", data.message || (data.success ? "人工对账已保存。" : "人工对账失败。")) + await loadList(page, size) + await refreshStats() + } catch { + openTextDialog("人工对账", "人工对账失败:网络或服务异常。") + } finally { + setActingJobId(null) + } + }, [loadList, openTextDialog, page, refreshStats, size]) + + const handleRetryJob = useCallback(async (job: BossJob) => { + let reservedTasks: ReservedTask[] = [] + const ok = window.confirm("这会创建新的投递 attempt,并可能再次联系该 Boss HR。确认显式重试?") + if (!ok) return + try { + setActingJobId(job.id) + const data = await postJsonWithRetry(`${API_BASE}/api/boss/jobs/${job.id}/delivery-retry`) + if (!data.success || !data.task) { + openTextDialog("重试投递", data.message || "当前岗位不能重试。") + return + } + reservedTasks = [data.task] + const result = await sendChromeBridgeMessage({ + type: "BOSS_DELIVER_ONE", + platform: "boss", + task: data.task, + }, 120000) + if (result.persisted !== true) { + await markUnknownReservations(reservedTasks, result.message || "Chrome 重试结果未确认写入") + } + openTextDialog("重试投递", result.message || "重试任务已结束。") + await loadList(page, size) + await refreshStats() + } catch { + await markUnknownReservations(reservedTasks, "前端未收到 Chrome 重试执行结果") + openTextDialog("重试投递", "重试失败:网络或服务异常,已保守标记待对账。") + } finally { + setActingJobId(null) + } + }, [loadList, openTextDialog, page, refreshStats, size]) + const currentBatchFilters = useCallback(() => ({ location: filters.location || undefined, experience: filters.experience || undefined, @@ -95,30 +218,32 @@ export function useBossDeliveryActions({ }), [activeScanRunId, filters]) const handleConfirmBatch = useCallback(async () => { + let reservedTasks: ReservedTask[] = [] try { setActingBatch(true) - const res = await fetch(`${API_BASE}/api/boss/jobs/confirm-batch`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(currentBatchFilters()), - }) - const data = await res.json() + const ok = window.confirm("将通过 Chrome 真实联系当前筛选范围内的 Boss 待确认岗位。确认继续?") + if (!ok) return + const data = await postJsonWithRetry(`${API_BASE}/api/boss/jobs/confirm-batch`, currentBatchFilters()) const tasks = data.tasks || [] + reservedTasks = tasks if (!data.success || tasks.length === 0) { openTextDialog("批量投递", data.message || "当前筛选条件下没有待确认岗位。") return } - const ok = window.confirm(`将通过 Chrome 真实联系 ${tasks.length} 个 Boss 待确认岗位。确认继续?`) - if (!ok) return const result = await sendChromeBridgeMessage({ type: "BOSS_DELIVER_BATCH", platform: "boss", tasks, }, Math.max(120000, tasks.length * 30000)) - openTextDialog("批量投递", result.message || "批量投递任务已结束。") + const unresolved = unresolvedReservations(reservedTasks, result) + if (unresolved.length > 0) { + await markUnknownReservations(unresolved, result.message || "Chrome Bridge 未确认写入完整批量结果") + } + openTextDialog("批量投递", formatBatchDeliveryResult(result)) await loadList(page, size) await refreshStats() } catch { + await markUnknownReservations(reservedTasks, "前端未收到 Chrome 批量投递执行结果") openTextDialog("批量投递", "批量投递失败:网络或服务异常。") } finally { setActingBatch(false) @@ -126,30 +251,35 @@ export function useBossDeliveryActions({ }, [currentBatchFilters, loadList, openTextDialog, page, refreshStats, size]) const handleConfirmAiRecommendedBatch = useCallback(async () => { + let reservedTasks: ReservedTask[] = [] try { setActingAiBatch(true) - const res = await fetch(`${API_BASE}/api/boss/jobs/confirm-batch`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ aiRecommendedOnly: true, scanRunId: activeScanRunId || undefined }), + const ok = window.confirm("将通过 Chrome 真实联系当前批次中 AI 推荐的 Boss 待确认岗位。确认继续?") + if (!ok) return + const data = await postJsonWithRetry(`${API_BASE}/api/boss/jobs/confirm-batch`, { + aiRecommendedOnly: true, + scanRunId: activeScanRunId || undefined, }) - const data = await res.json() const tasks = data.tasks || [] + reservedTasks = tasks if (!data.success || tasks.length === 0) { openTextDialog("AI推荐一键投递", data.message || "当前没有 AI 推荐的待确认岗位。") return } - const ok = window.confirm(`将通过 Chrome 真实联系 ${tasks.length} 个 Boss AI推荐待确认岗位。确认继续?`) - if (!ok) return const result = await sendChromeBridgeMessage({ type: "BOSS_DELIVER_BATCH", platform: "boss", tasks, }, Math.max(120000, tasks.length * 30000)) - openTextDialog("AI推荐一键投递", result.message || "AI推荐批量投递任务已结束。") + const unresolved = unresolvedReservations(reservedTasks, result) + if (unresolved.length > 0) { + await markUnknownReservations(unresolved, result.message || "Chrome Bridge 未确认写入完整批量结果") + } + openTextDialog("AI推荐一键投递", formatBatchDeliveryResult(result)) await loadList(page, size) await refreshStats() } catch { + await markUnknownReservations(reservedTasks, "前端未收到 Chrome AI 推荐批量投递结果") openTextDialog("AI推荐一键投递", "AI推荐批量投递失败:网络或服务异常。") } finally { setActingAiBatch(false) @@ -163,39 +293,40 @@ export function useBossDeliveryActions({ return false } + let reservedTasks: ReservedTask[] = [] try { setActingManualBatch(true) - const res = await fetch(`${API_BASE}/api/boss/jobs/confirm-batch`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - ids: uniqueIds, - manualOverrideAiNotMatch: true, - }), + const ok = window.confirm( + `AI 已将这些岗位判定为不匹配。你正在按人工判断强制投递 ${uniqueIds.length} 个岗位,` + + "将通过 Chrome 真实联系 Boss HR。确认继续?", + ) + if (!ok) return false + const data = await postJsonWithRetry(`${API_BASE}/api/boss/jobs/confirm-batch`, { + ids: uniqueIds, + manualOverrideAiNotMatch: true, }) - const data = await res.json() const tasks = data.tasks || [] + reservedTasks = tasks if (!data.success || tasks.length === 0) { openTextDialog("人工投递", data.message || "所选岗位中没有可人工投递的AI不匹配岗位。") return false } - const ok = window.confirm( - `AI 已将这些岗位判定为不匹配。你正在按人工判断强制投递 ${tasks.length} 个岗位,` - + `将通过 Chrome 真实联系 Boss HR。确认继续?${data.message ? `\n\n${data.message}` : ""}`, - ) - if (!ok) return false - const result = await sendChromeBridgeMessage({ type: "BOSS_DELIVER_BATCH", platform: "boss", tasks, }, Math.max(120000, tasks.length * 30000)) - openTextDialog("人工投递", result.message || "人工批量投递任务已结束。") + const unresolved = unresolvedReservations(reservedTasks, result) + if (unresolved.length > 0) { + await markUnknownReservations(unresolved, result.message || "Chrome Bridge 未确认写入完整批量结果") + } + openTextDialog("人工投递", formatBatchDeliveryResult(result)) await loadList(page, size) await refreshStats() return true } catch { + await markUnknownReservations(reservedTasks, "前端未收到 Chrome 人工批量投递结果") openTextDialog("人工投递", "人工批量投递失败:网络或服务异常。") return false } finally { @@ -254,6 +385,8 @@ export function useBossDeliveryActions({ handleConfirmBatch, handleConfirmAiRecommendedBatch, handleConfirmManualBatch, + handleReconcileJob, + handleRetryJob, handleSkipJob, clearAnalysisData, } diff --git a/front/app/boss/analysis/types.ts b/front/app/boss/analysis/types.ts index 96b067f..c444f2d 100644 --- a/front/app/boss/analysis/types.ts +++ b/front/app/boss/analysis/types.ts @@ -93,7 +93,7 @@ export type FilterState = { filterHeadhunter: boolean } -export const DELIVERY_STATUS_OPTIONS = ["待确认", "LIST_COLLECTED", "AI分析中", "已投递", "未投递", "AI不匹配", "AI分析失败", "采集信息不足", "已过滤", "已跳过", "投递失败"] +export const DELIVERY_STATUS_OPTIONS = ["待确认", "投递确认中", "投递结果待确认", "LIST_COLLECTED", "AI分析中", "已投递", "未投递", "AI不匹配", "AI分析失败", "采集信息不足", "已过滤", "已跳过", "投递失败"] export const EXPERIENCE_OPTIONS = ["在校/应届", "1年以内", "1-3年", "3-5年", "5-10年", "10年以上"] export const DEGREE_OPTIONS = ["不限", "中专/中技", "高中", "大专", "本科", "硕士", "博士"] diff --git a/front/app/boss/analysis/utils.ts b/front/app/boss/analysis/utils.ts index d39c20e..6ced181 100644 --- a/front/app/boss/analysis/utils.ts +++ b/front/app/boss/analysis/utils.ts @@ -59,6 +59,8 @@ export function badgeClass(kind: "delivery" | "hr" | "recruitment", value?: stri if (kind === "delivery") { if (v.includes("已投递")) return `${base} bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300` if (v.includes("待确认")) return `${base} bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300` + if (v.includes("投递确认中")) return `${base} bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300` + if (v.includes("投递结果待确认")) return `${base} bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300` if (v === "LIST_COLLECTED") return `${base} bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300` if (v.includes("AI分析中")) return `${base} bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300` if (v.includes("采集信息不足")) return `${base} bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300` diff --git a/front/app/liepin/analysis/AnalysisContent.tsx b/front/app/liepin/analysis/AnalysisContent.tsx index 9e2dbcb..9255b89 100644 --- a/front/app/liepin/analysis/AnalysisContent.tsx +++ b/front/app/liepin/analysis/AnalysisContent.tsx @@ -19,6 +19,8 @@ type StatsResponse = { total: number delivered: number pending: number + requested: number + unknown: number filtered: number failed: number avgMonthlyK?: number | null @@ -51,9 +53,16 @@ type LiepinJob = { hrName?: string hrTitle?: string delivered?: number + deliveryStatus?: string createTime?: string } +function deliveryStatusOf(job: LiepinJob) { + const status = job.deliveryStatus?.trim() + if (status) return status + return job.delivered === 1 ? "已投递" : "未投递" +} + type PagedResult = { items: LiepinJob[] total: number @@ -227,7 +236,7 @@ export default function AnalysisContent({ showHeader = false }: { showHeader?: b const [detailJob, setDetailJob] = useState(null) const [computedSalaryBuckets, setComputedSalaryBuckets] = useState([]) - const statusOptions = ["未投递", "已投递"] + const statusOptions = ["未投递", "投递确认中", "投递结果待确认", "已投递", "投递失败"] useEffect(() => { loadStats() @@ -349,7 +358,7 @@ export default function AnalysisContent({ showHeader = false }: { showHeader?: b it.jobExpReq || "", it.jobEduReq || "", it.hrName || "", - (it.delivered === 1 ? "已投递" : "未投递"), + deliveryStatusOf(it), it.jobLink || "", it.createTime || "", ]) @@ -466,6 +475,8 @@ export default function AnalysisContent({ showHeader = false }: { showHeader?: b return [ { title: "总岗位数", value: k?.total ?? 0 }, { title: "已投递", value: k?.delivered ?? 0 }, + { title: "投递确认中", value: k?.requested ?? 0 }, + { title: "结果待确认", value: k?.unknown ?? 0 }, { title: "未投递", value: k?.pending ?? 0 }, { title: "平均月薪(K)", value: (k?.avgMonthlyK ?? avgMonthlyKFromItems ?? 0) }, ] @@ -771,7 +782,7 @@ export default function AnalysisContent({ showHeader = false }: { showHeader?: b {it.jobEduReq || ""} {it.hrName || ""} - {it.delivered === 1 ? "已投递" : "未投递"} + {deliveryStatusOf(it)} {it.jobLink ? ( @@ -804,7 +815,7 @@ export default function AnalysisContent({ showHeader = false }: { showHeader?: b
经验:{detailJob.jobExpReq || ""}
学历:{detailJob.jobEduReq || ""}
HR:{detailJob.hrName || ""}
-
状态:{detailJob.delivered === 1 ? "已投递" : "未投递"}
+
状态:{deliveryStatusOf(detailJob)}
创建时间:{formatDateOnly(detailJob.createTime)}
diff --git a/front/app/zhilian/analysis/AnalysisContent.tsx b/front/app/zhilian/analysis/AnalysisContent.tsx index 03a19a5..49e342b 100644 --- a/front/app/zhilian/analysis/AnalysisContent.tsx +++ b/front/app/zhilian/analysis/AnalysisContent.tsx @@ -93,6 +93,76 @@ type PagedResult = { size: number } +async function markZhilianUnknownReservations( + tasks: Array<{ id?: number; requestKey?: string }>, + reason: string, +) { + const results = await Promise.allSettled(tasks.map(async (task) => { + if (!task.id || !task.requestKey) return + const response = await fetch(`${API_BASE}/api/zhilian/jobs/${task.id}/delivery-result`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + requestKey: task.requestKey, + outcome: "UNKNOWN", + evidence: "NO_CONFIRMATION", + message: reason, + }), + }) + const data = await response.json().catch(() => ({})) + if (!response.ok || data.success === false) { + throw new Error(data.message || "智联 UNKNOWN 状态回写失败") + } + })) + const failed = results.filter((result) => result.status === "rejected") + if (failed.length > 0) console.error("智联 UNKNOWN 状态回写失败", failed) +} + +async function postZhilianJsonWithRetry(url: string, body?: unknown) { + let lastError: unknown + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + const response = await fetch(url, { + method: "POST", + headers: body === undefined ? undefined : { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + return await response.json() + } catch (error) { + lastError = error + } + } + throw lastError instanceof Error ? lastError : new Error("智联投递请求未收到响应") +} + +function unresolvedZhilianReservations( + tasks: Array<{ id?: number; requestKey?: string }>, + result: Record, +) { + const rows = Array.isArray(result.results) ? result.results : [] + if (rows.length === 0) return tasks + const persistedKeys = new Set(rows.map((row) => { + if (!row || typeof row !== "object") return "" + const item = row as { requestKey?: unknown; persisted?: unknown } + return item.persisted === true ? String(item.requestKey || "") : "" + })) + return tasks.filter((task) => !task.requestKey || !persistedKeys.has(task.requestKey)) +} + +function formatZhilianBatchResult(result: Record) { + const summary = String(result.message || "批量投递任务已结束。") + const rows = Array.isArray(result.results) ? result.results : [] + if (rows.length === 0) return summary + const details = rows.slice(0, 50).map((row, index) => { + const item = row && typeof row === "object" + ? row as { id?: unknown; outcome?: unknown; evidence?: unknown; persisted?: unknown; message?: unknown } + : {} + const persisted = item.persisted === true ? "已落库" : "待补偿" + return `${index + 1}. 岗位 ${String(item.id || "-")} · ${String(item.outcome || "UNKNOWN")} · ${persisted} · ${String(item.evidence || "-")}\n${String(item.message || "")}` + }) + return `${summary}\n\n逐条结果:\n${details.join("\n")}` +} + type ChartRef = { destroy: () => void } const CATEGORY_COLORS = [ @@ -528,7 +598,7 @@ export default function AnalysisContent({ showHeader = false, refreshSignal = 0 const [pendingCardsExpanded, setPendingCardsExpanded] = useState(false) const activeScanRunId = "" - const statusOptions = ["待确认", "AI分析中", "未投递", "已投递", "已过滤", "投递失败", "AI不匹配", "AI分析失败"] + const statusOptions = ["待确认", "投递确认中", "投递结果待确认", "AI分析中", "未投递", "已投递", "已过滤", "投递失败", "AI不匹配", "AI分析失败"] const loadList = async (toPage = page, toSize = size) => { try { @@ -785,58 +855,121 @@ export default function AnalysisContent({ showHeader = false, refreshSignal = 0 alert("该智联岗位缺少内部ID,无法确认投递。") return } + let reservedTasks: Array<{ id?: number; requestKey?: string }> = [] try { setActingJobId(job.id) - const res = await fetch(`${API_BASE}/api/zhilian/jobs/${job.id}/confirm`, { method: "POST" }) - const data = await res.json() + const ok = window.confirm(`将通过 Chrome 真实申请智联岗位:${job.companyName || ""} / ${job.jobTitle || ""}。确认继续?`) + if (!ok) return + const data = await postZhilianJsonWithRetry(`${API_BASE}/api/zhilian/jobs/${job.id}/confirm`) if (!data.success) { alert(data.message || "该智联岗位暂不能投递。") return } - const ok = window.confirm(`将通过 Chrome 真实申请智联岗位:${job.companyName || ""} / ${job.jobTitle || ""}。确认继续?`) - if (!ok) return + reservedTasks = [data.task] const result = await sendChromeBridgeMessage({ type: "ZHILIAN_DELIVER_ONE", platform: "zhilian", task: data.task, }, 120000) + if (result.persisted !== true) { + await markZhilianUnknownReservations(reservedTasks, result.message || "Chrome Bridge 未返回岗位结果") + } alert(result.message || (result.success ? "已发送投递请求。" : "Chrome投递失败。")) await loadList(page, size) await loadStats() await loadDashboardStats() } catch { + await markZhilianUnknownReservations(reservedTasks, "前端未收到 Chrome 投递执行结果") alert("确认投递失败:网络或服务异常。") } finally { setActingJobId(null) } } + const handleReconcileJob = async (job: ZhilianJob) => { + if (!job.id) return + const answer = window.prompt( + "请先在智联平台核对该岗位。输入“已投递”确认成功,输入“未投递”确认失败;其他内容不会修改状态。", + )?.trim() + if (answer !== "已投递" && answer !== "未投递") return + try { + setActingJobId(job.id) + const data = await postZhilianJsonWithRetry(`${API_BASE}/api/zhilian/jobs/${job.id}/delivery-reconcile`, { + outcome: answer === "已投递" ? "CONFIRMED" : "FAILED", + message: `用户在智联平台人工核对:${answer}`, + }) + alert(data.message || (data.success ? "人工对账已保存。" : "人工对账失败。")) + await loadList(page, size) + await loadStats() + await loadDashboardStats() + } catch { + alert("人工对账失败:网络或服务异常。") + } finally { + setActingJobId(null) + } + } + + const handleRetryJob = async (job: ZhilianJob) => { + if (!job.id) return + const ok = window.confirm("这会创建新的投递 attempt,并可能再次申请该智联岗位。确认显式重试?") + if (!ok) return + let reservedTasks: Array<{ id?: number; requestKey?: string }> = [] + try { + setActingJobId(job.id) + const data = await postZhilianJsonWithRetry(`${API_BASE}/api/zhilian/jobs/${job.id}/delivery-retry`) + if (!data.success || !data.task) { + alert(data.message || "当前岗位不能重试。") + return + } + reservedTasks = [data.task] + const result = await sendChromeBridgeMessage({ + type: "ZHILIAN_DELIVER_ONE", + platform: "zhilian", + task: data.task, + }, 120000) + if (result.persisted !== true) { + await markZhilianUnknownReservations(reservedTasks, result.message || "Chrome 重试结果未确认写入") + } + alert(result.message || "重试任务已结束。") + await loadList(page, size) + await loadStats() + await loadDashboardStats() + } catch { + await markZhilianUnknownReservations(reservedTasks, "前端未收到 Chrome 重试执行结果") + alert("重试失败:网络或服务异常,已保守标记待对账。") + } finally { + setActingJobId(null) + } + } + const handleConfirmBatch = async () => { + let reservedTasks: Array<{ id?: number; requestKey?: string }> = [] try { setActingBatch(true) - const res = await fetch(`${API_BASE}/api/zhilian/jobs/confirm-batch`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(currentBatchFilters()), - }) - const data = await res.json() + const ok = window.confirm("将通过 Chrome 真实申请当前筛选范围内的智联待确认岗位。确认继续?") + if (!ok) return + const data = await postZhilianJsonWithRetry(`${API_BASE}/api/zhilian/jobs/confirm-batch`, currentBatchFilters()) const tasks = data.tasks || [] + reservedTasks = tasks if (!data.success || tasks.length === 0) { alert(data.message || "当前筛选条件下没有智联待确认岗位。") return } - const ok = window.confirm(`将通过 Chrome 真实申请 ${tasks.length} 个智联待确认岗位。确认继续?`) - if (!ok) return const result = await sendChromeBridgeMessage({ type: "ZHILIAN_DELIVER_BATCH", platform: "zhilian", tasks, }, Math.max(120000, tasks.length * 30000)) - alert(result.message || "批量投递任务已结束。") + const unresolved = unresolvedZhilianReservations(reservedTasks, result) + if (unresolved.length > 0) { + await markZhilianUnknownReservations(unresolved, result.message || "Chrome Bridge 未确认写入完整批量结果") + } + alert(formatZhilianBatchResult(result)) await loadList(page, size) await loadStats() await loadDashboardStats() } catch { + await markZhilianUnknownReservations(reservedTasks, "前端未收到 Chrome 批量投递执行结果") alert("批量投递失败:网络或服务异常。") } finally { setActingBatch(false) @@ -1180,14 +1313,27 @@ export default function AnalysisContent({ showHeader = false, refreshSignal = 0 }`} > - {it.deliveryStatus === "待确认" ? ( + {it.deliveryStatus === "待确认" || it.deliveryStatus === "投递确认中" ? ( + ) : it.deliveryStatus === "投递结果待确认" ? ( +
+ + +
+ ) : it.deliveryStatus === "投递失败" ? ( + ) : (it.deliveryStatus || "").trim() === "已投递" ? ( diff --git a/src/main/java/com/getjobs/application/controller/BossAnalyticsController.java b/src/main/java/com/getjobs/application/controller/BossAnalyticsController.java index 52fffc0..c8a6e58 100644 --- a/src/main/java/com/getjobs/application/controller/BossAnalyticsController.java +++ b/src/main/java/com/getjobs/application/controller/BossAnalyticsController.java @@ -5,6 +5,7 @@ import com.getjobs.application.dto.DeliveryResultRequest; import com.getjobs.application.entity.BossConfigEntity; import com.getjobs.application.service.DeliveryStatus; +import com.getjobs.application.service.DeliveryAttemptService; import com.getjobs.application.service.BossService; import com.getjobs.application.service.BossStatsService; import org.springframework.web.bind.annotation.*; @@ -23,10 +24,14 @@ public class BossAnalyticsController { private final BossService bossService; private final BossStatsService bossStatsService; + private final DeliveryAttemptService deliveryAttemptService; - public BossAnalyticsController(BossService bossService, BossStatsService bossStatsService) { + public BossAnalyticsController(BossService bossService, + BossStatsService bossStatsService, + DeliveryAttemptService deliveryAttemptService) { this.bossService = bossService; this.bossStatsService = bossStatsService; + this.deliveryAttemptService = deliveryAttemptService; } /** @@ -128,10 +133,16 @@ public Map confirmPendingJob(@PathVariable("id") Long id) { BossJobDataEntity job = bossService.getBossJobById(id); Map error = validateDeliverable(job); if (error != null) return error; + DeliveryAttemptService.RequestResult attempt = deliveryAttemptService.requestBoss( + job.getId(), job.getProfileId(), firstNonBlank(job.getEncryptId(), String.valueOf(job.getId())), false); + if (!attempt.accepted()) { + return Map.of("success", false, "message", attempt.message(), "status", Objects.toString(job.getDeliveryStatus(), "")); + } return Map.of( "success", true, - "message", "请在 Chrome 中确认投递该岗位", - "task", toDeliveryTask(job) + "resumed", !attempt.created(), + "message", attempt.created() ? "投递请求已创建,请在 Chrome 中等待平台确认" : "已恢复原投递请求,请勿重复创建", + "task", toDeliveryTask(job, attempt.requestKey()) ); } @@ -170,7 +181,7 @@ public Map confirmBatch(@RequestBody ConfirmBatchRequest request } } else if (aiRecommendedOnly) { BossService.PagedResult page = bossService.listBossJobs( - List.of(DeliveryStatus.WAITING_CONFIRM), + List.of(DeliveryStatus.WAITING_CONFIRM, DeliveryStatus.DELIVERY_REQUESTED), null, null, null, @@ -190,7 +201,7 @@ public Map confirmBatch(@RequestBody ConfirmBatchRequest request } } else { BossService.PagedResult page = bossService.listBossJobs( - List.of(DeliveryStatus.WAITING_CONFIRM), + List.of(DeliveryStatus.WAITING_CONFIRM, DeliveryStatus.DELIVERY_REQUESTED), request == null ? null : request.getLocation(), request == null ? null : request.getExperience(), request == null ? null : request.getDegree(), @@ -206,14 +217,27 @@ public Map confirmBatch(@RequestBody ConfirmBatchRequest request if (page != null && page.items != null) candidates.addAll(page.items); } - List> tasks = candidates.stream() + List deliverableJobs = candidates.stream() .filter(job -> manualOverrideAiNotMatch ? DeliveryStatus.AI_NOT_MATCH.equals(Objects.toString(job.getDeliveryStatus(), "").trim()) - : DeliveryStatus.isWaitingConfirm(job.getDeliveryStatus())) + || DeliveryStatus.DELIVERY_REQUESTED.equals(Objects.toString(job.getDeliveryStatus(), "").trim()) + : DeliveryStatus.isWaitingConfirm(job.getDeliveryStatus()) + || DeliveryStatus.DELIVERY_REQUESTED.equals(Objects.toString(job.getDeliveryStatus(), "").trim())) .filter(job -> !aiRecommendedOnly || "APPLY".equalsIgnoreCase(Objects.toString(job.getAiDecision(), ""))) .filter(job -> job.getJobUrl() != null && !job.getJobUrl().isBlank()) - .map(this::toDeliveryTask) .collect(Collectors.toList()); + List> tasks = new ArrayList<>(); + for (BossJobDataEntity job : deliverableJobs) { + DeliveryAttemptService.RequestResult attempt = deliveryAttemptService.requestBoss( + job.getId(), + job.getProfileId(), + firstNonBlank(job.getEncryptId(), String.valueOf(job.getId())), + manualOverrideAiNotMatch + ); + if (attempt.accepted()) { + tasks.add(toDeliveryTask(job, attempt.requestKey())); + } + } if (manualOverrideAiNotMatch && tasks.isEmpty()) { return Map.of( "success", false, @@ -239,19 +263,87 @@ public Map confirmBatch(@RequestBody ConfirmBatchRequest request public Map updateDeliveryResult(@PathVariable("id") Long id, @RequestBody DeliveryResultRequest request) { BossJobDataEntity job = bossService.getBossJobById(id); if (job == null) return Map.of("success", false, "message", "岗位不存在"); - String status = request != null && Boolean.TRUE.equals(request.getSuccess()) ? DeliveryStatus.DELIVERED : DeliveryStatus.DELIVERY_FAILED; - String message = request == null ? null : request.getMessage(); - String failureReason = request == null ? null : request.getFailureReason(); - BossJobDataEntity updated = bossService.updateDeliveryStatusById(id, status, request == null ? null : request.getFailureType(), firstNonBlank(failureReason, message)); + if (request == null) return Map.of("success", false, "message", "投递结果不能为空"); + DeliveryAttemptService.State outcome = DeliveryAttemptService.State.parse(request.getOutcome()); + if (outcome == null && request.getSuccess() != null) { + outcome = Boolean.TRUE.equals(request.getSuccess()) + ? DeliveryAttemptService.State.CONFIRMED + : DeliveryAttemptService.State.FAILED; + } + DeliveryAttemptService.ResolutionResult result = deliveryAttemptService.resolve( + "boss", + job.getProfileId(), + job.getId(), + request.getRequestKey(), + outcome, + request.getEvidence(), + request.getMessage(), + request.getFailureType(), + firstNonBlank(request.getFailureReason(), request.getMessage()) + ); + BossJobDataEntity updated = bossService.getBossJobById(id); + return Map.of( + "success", result.accepted(), + "accepted", result.accepted(), + "idempotent", result.idempotent(), + "message", result.message(), + "state", result.state() == null ? "" : result.state().name(), + "status", updated == null ? "" : Objects.toString(updated.getDeliveryStatus(), "") + ); + } + + @PostMapping("/jobs/{id}/delivery-reconcile") + public Map reconcileDeliveryResult(@PathVariable("id") Long id, + @RequestBody DeliveryResultRequest request) { + BossJobDataEntity job = bossService.getBossJobById(id); + if (job == null) return Map.of("success", false, "message", "岗位不存在"); + DeliveryAttemptService.State target = request == null + ? null + : DeliveryAttemptService.State.parse(request.getOutcome()); + DeliveryAttemptService.ResolutionResult result = deliveryAttemptService.reconcileLatest( + "boss", + job.getProfileId(), + job.getId(), + firstNonBlank(job.getEncryptId(), String.valueOf(job.getId())), + target, + request == null ? null : request.getMessage() + ); + return Map.of( + "success", result.accepted(), + "idempotent", result.idempotent(), + "message", result.message(), + "state", result.state() == null ? "" : result.state().name() + ); + } + + @PostMapping("/jobs/{id}/delivery-retry") + public Map retryDelivery(@PathVariable("id") Long id) { + BossJobDataEntity job = bossService.getBossJobById(id); + if (job == null) return Map.of("success", false, "message", "岗位不存在"); + DeliveryAttemptService.RequestResult attempt = deliveryAttemptService.retryBoss( + job.getId(), + job.getProfileId(), + firstNonBlank(job.getEncryptId(), String.valueOf(job.getId())) + ); + if (!attempt.accepted()) { + return Map.of("success", false, "message", attempt.message()); + } return Map.of( "success", true, - "message", message == null ? "投递状态已更新" : message, - "status", updated.getDeliveryStatus() + "resumed", !attempt.created(), + "message", attempt.created() + ? "已创建新的显式重试任务,请再次核对平台结果" + : "已恢复原重试任务,未创建重复 attempt", + "task", toDeliveryTask(job, attempt.requestKey()) ); } @PostMapping("/jobs/{id}/skip") public Map skipPendingJob(@PathVariable("id") Long id) { + BossJobDataEntity current = bossService.getBossJobById(id); + if (current != null && DeliveryStatus.isDeliveryLocked(current.getDeliveryStatus())) { + return Map.of("success", false, "message", "投递已进入请求或结果状态,不能再跳过", "status", current.getDeliveryStatus()); + } BossJobDataEntity updated = bossService.updateDeliveryStatusById(id, DeliveryStatus.SKIPPED); if (updated == null) { return Map.of("success", false, "message", "岗位不存在"); @@ -263,7 +355,8 @@ private Map validateDeliverable(BossJobDataEntity job) { if (job == null) { return Map.of("success", false, "message", "岗位不存在"); } - if (!DeliveryStatus.isWaitingConfirm(job.getDeliveryStatus())) { + if (!DeliveryStatus.isWaitingConfirm(job.getDeliveryStatus()) + && !DeliveryStatus.DELIVERY_REQUESTED.equals(Objects.toString(job.getDeliveryStatus(), "").trim())) { return Map.of("success", false, "message", "只有待确认岗位可以确认投递", "status", job.getDeliveryStatus() == null ? "" : job.getDeliveryStatus()); } if (job.getJobUrl() == null || job.getJobUrl().isBlank()) { @@ -272,7 +365,7 @@ private Map validateDeliverable(BossJobDataEntity job) { return null; } - private Map toDeliveryTask(BossJobDataEntity job) { + private Map toDeliveryTask(BossJobDataEntity job, String requestKey) { Map task = new HashMap<>(); task.put("id", job.getId()); task.put("platform", "boss"); @@ -281,6 +374,7 @@ private Map toDeliveryTask(BossJobDataEntity job) { task.put("jobName", Objects.toString(job.getJobName(), "")); task.put("salary", Objects.toString(job.getSalary(), "")); task.put("greeting", bossSayHi()); + task.put("requestKey", requestKey); return task; } diff --git a/src/main/java/com/getjobs/application/controller/ZhilianController.java b/src/main/java/com/getjobs/application/controller/ZhilianController.java index f9673fb..4f79610 100644 --- a/src/main/java/com/getjobs/application/controller/ZhilianController.java +++ b/src/main/java/com/getjobs/application/controller/ZhilianController.java @@ -12,6 +12,7 @@ import com.getjobs.application.service.ChromeJobAnalysisQueueService; import com.getjobs.application.service.CookieService; import com.getjobs.application.service.DeliveryStatus; +import com.getjobs.application.service.DeliveryAttemptService; import com.getjobs.application.service.JobAiAnalysisService; import com.getjobs.application.service.OpenClawJobProbeService; import com.getjobs.application.service.ZhilianService; @@ -75,6 +76,9 @@ public class ZhilianController { @Autowired private OpenClawJobProbeService openClawJobProbeService; + @Autowired + private DeliveryAttemptService deliveryAttemptService; + @Autowired @Qualifier("jobTaskExecutor") private Executor jobTaskExecutor; @@ -412,7 +416,7 @@ public ResponseEntity> receiveChromeJobs(@RequestBody Chrome continue; } if (!isFinalZhilianStatus(currentStatus)) { - zhilianService.updateDeliveryStatusByJobId(saved.getJobId(), DeliveryStatus.AI_ANALYZING); + zhilianService.updateDeliveryStatusById(saved.getId(), DeliveryStatus.AI_ANALYZING); saved = zhilianService.getZhilianJobById(saved.getId()); } @@ -440,7 +444,7 @@ public ResponseEntity> receiveChromeJobs(@RequestBody Chrome ChromeJobAnalysisQueueService.EnqueueResult enqueueResult = chromeJobAnalysisQueueService.enqueue(job); if (enqueueResult.isRejected()) { - zhilianService.updateDeliveryStatusByJobId(saved.getJobId(), firstNonBlank(currentStatus, DeliveryStatus.NOT_DELIVERED)); + zhilianService.updateDeliveryStatusById(saved.getId(), firstNonBlank(currentStatus, DeliveryStatus.NOT_DELIVERED)); Map response = zhilianChromeJobsResponse( false, false, received, savedCount, queued, skipped, insufficient, restored, analyses ); @@ -502,7 +506,17 @@ public Map confirmZhilianJob(@PathVariable("id") Long id) { ZhilianJobDataEntity job = getZhilianJobById(id); Map error = validateDeliverable(job); if (error != null) return error; - return Map.of("success", true, "message", "请在 Chrome 中确认投递该智联岗位", "task", toDeliveryTask(job)); + DeliveryAttemptService.RequestResult attempt = deliveryAttemptService.requestZhilian( + job.getId(), job.getProfileId(), firstNonBlank(job.getJobId(), String.valueOf(job.getId()))); + if (!attempt.accepted()) { + return Map.of("success", false, "message", attempt.message(), "status", Objects.toString(job.getDeliveryStatus(), "")); + } + return Map.of( + "success", true, + "resumed", !attempt.created(), + "message", attempt.created() ? "投递请求已创建,请在 Chrome 中等待平台确认" : "已恢复原投递请求,请勿重复创建", + "task", toDeliveryTask(job, attempt.requestKey()) + ); } @PostMapping("/jobs/confirm-batch") @@ -515,7 +529,7 @@ public Map confirmZhilianBatch(@RequestBody ConfirmBatchRequest } } else { ZhilianService.PagedResult page = zhilianService.listZhilianJobs( - List.of(DeliveryStatus.WAITING_CONFIRM), + List.of(DeliveryStatus.WAITING_CONFIRM, DeliveryStatus.DELIVERY_REQUESTED), request == null ? null : request.getLocation(), request == null ? null : request.getExperience(), request == null ? null : request.getDegree(), @@ -528,10 +542,18 @@ public Map confirmZhilianBatch(@RequestBody ConfirmBatchRequest ); if (page != null && page.items != null) candidates.addAll(page.items); } - List> tasks = candidates.stream() - .filter(job -> DeliveryStatus.isWaitingConfirm(job.getDeliveryStatus())) - .map(this::toDeliveryTask) + List deliverableJobs = candidates.stream() + .filter(job -> DeliveryStatus.isWaitingConfirm(job.getDeliveryStatus()) + || DeliveryStatus.DELIVERY_REQUESTED.equals(Objects.toString(job.getDeliveryStatus(), "").trim())) .collect(Collectors.toList()); + List> tasks = new ArrayList<>(); + for (ZhilianJobDataEntity job : deliverableJobs) { + DeliveryAttemptService.RequestResult attempt = deliveryAttemptService.requestZhilian( + job.getId(), job.getProfileId(), firstNonBlank(job.getJobId(), String.valueOf(job.getId()))); + if (attempt.accepted()) { + tasks.add(toDeliveryTask(job, attempt.requestKey())); + } + } return Map.of("success", true, "message", "已生成智联批量 Chrome 投递任务", "tasks", tasks, "count", tasks.size()); } @@ -539,20 +561,87 @@ public Map confirmZhilianBatch(@RequestBody ConfirmBatchRequest public Map updateZhilianDeliveryResult(@PathVariable("id") Long id, @RequestBody DeliveryResultRequest request) { ZhilianJobDataEntity job = getZhilianJobById(id); if (job == null) return Map.of("success", false, "message", "岗位不存在"); - String status = request != null && Boolean.TRUE.equals(request.getSuccess()) ? DeliveryStatus.DELIVERED : DeliveryStatus.DELIVERY_FAILED; - String message = request == null ? null : request.getMessage(); - String failureReason = request == null ? null : request.getFailureReason(); - ZhilianJobDataEntity updated = zhilianService.updateDeliveryStatusById( - id, - status, - request == null ? null : request.getFailureType(), - firstNonBlank(failureReason, message) + if (request == null) return Map.of("success", false, "message", "投递结果不能为空"); + DeliveryAttemptService.State outcome = DeliveryAttemptService.State.parse(request.getOutcome()); + if (outcome == null && request.getSuccess() != null) { + outcome = Boolean.TRUE.equals(request.getSuccess()) + ? DeliveryAttemptService.State.CONFIRMED + : DeliveryAttemptService.State.FAILED; + } + DeliveryAttemptService.ResolutionResult result = deliveryAttemptService.resolve( + "zhilian", + job.getProfileId(), + job.getId(), + request.getRequestKey(), + outcome, + request.getEvidence(), + request.getMessage(), + request.getFailureType(), + firstNonBlank(request.getFailureReason(), request.getMessage()) + ); + ZhilianJobDataEntity updated = getZhilianJobById(id); + return Map.of( + "success", result.accepted(), + "accepted", result.accepted(), + "idempotent", result.idempotent(), + "message", result.message(), + "state", result.state() == null ? "" : result.state().name(), + "status", updated == null ? "" : Objects.toString(updated.getDeliveryStatus(), "") + ); + } + + @PostMapping("/jobs/{id}/delivery-reconcile") + public Map reconcileZhilianDeliveryResult(@PathVariable("id") Long id, + @RequestBody DeliveryResultRequest request) { + ZhilianJobDataEntity job = getZhilianJobById(id); + if (job == null) return Map.of("success", false, "message", "岗位不存在"); + DeliveryAttemptService.State target = request == null + ? null + : DeliveryAttemptService.State.parse(request.getOutcome()); + DeliveryAttemptService.ResolutionResult result = deliveryAttemptService.reconcileLatest( + "zhilian", + job.getProfileId(), + job.getId(), + firstNonBlank(job.getJobId(), String.valueOf(job.getId())), + target, + request == null ? null : request.getMessage() + ); + return Map.of( + "success", result.accepted(), + "idempotent", result.idempotent(), + "message", result.message(), + "state", result.state() == null ? "" : result.state().name() + ); + } + + @PostMapping("/jobs/{id}/delivery-retry") + public Map retryZhilianDelivery(@PathVariable("id") Long id) { + ZhilianJobDataEntity job = getZhilianJobById(id); + if (job == null) return Map.of("success", false, "message", "岗位不存在"); + DeliveryAttemptService.RequestResult attempt = deliveryAttemptService.retryZhilian( + job.getId(), + job.getProfileId(), + firstNonBlank(job.getJobId(), String.valueOf(job.getId())) + ); + if (!attempt.accepted()) { + return Map.of("success", false, "message", attempt.message()); + } + return Map.of( + "success", true, + "resumed", !attempt.created(), + "message", attempt.created() + ? "已创建新的显式重试任务,请再次核对平台结果" + : "已恢复原重试任务,未创建重复 attempt", + "task", toDeliveryTask(job, attempt.requestKey()) ); - return Map.of("success", true, "message", request == null || request.getMessage() == null ? "投递状态已更新" : request.getMessage(), "status", updated == null ? status : updated.getDeliveryStatus()); } @PostMapping("/jobs/{id}/skip") public Map skipZhilianJob(@PathVariable("id") Long id) { + ZhilianJobDataEntity current = getZhilianJobById(id); + if (current != null && DeliveryStatus.isDeliveryLocked(current.getDeliveryStatus())) { + return Map.of("success", false, "message", "投递已进入请求或结果状态,不能再跳过", "status", current.getDeliveryStatus()); + } ZhilianJobDataEntity updated = zhilianService.updateDeliveryStatusById(id, DeliveryStatus.SKIPPED); if (updated == null) { return Map.of("success", false, "message", "岗位不存在"); @@ -766,7 +855,8 @@ private ZhilianJobDataEntity getZhilianJobById(Long id) { private Map validateDeliverable(ZhilianJobDataEntity job) { if (job == null) return Map.of("success", false, "message", "岗位不存在"); - if (!DeliveryStatus.isWaitingConfirm(job.getDeliveryStatus())) { + if (!DeliveryStatus.isWaitingConfirm(job.getDeliveryStatus()) + && !DeliveryStatus.DELIVERY_REQUESTED.equals(Objects.toString(job.getDeliveryStatus(), "").trim())) { return Map.of("success", false, "message", "只有待确认岗位可以确认投递", "status", job.getDeliveryStatus() == null ? "" : job.getDeliveryStatus()); } if (job.getJobLink() == null || job.getJobLink().isBlank()) { @@ -780,7 +870,7 @@ private boolean isFinalZhilianStatus(String status) { return DeliveryStatus.isFinalStatus(status); } - private Map toDeliveryTask(ZhilianJobDataEntity job) { + private Map toDeliveryTask(ZhilianJobDataEntity job, String requestKey) { Map task = new HashMap<>(); task.put("id", job.getId()); task.put("platform", "zhilian"); @@ -788,6 +878,7 @@ private Map toDeliveryTask(ZhilianJobDataEntity job) { task.put("companyName", Objects.toString(job.getCompanyName(), "")); task.put("jobName", Objects.toString(job.getJobTitle(), "")); task.put("salary", Objects.toString(job.getSalary(), "")); + task.put("requestKey", requestKey); return task; } diff --git a/src/main/java/com/getjobs/application/dto/DeliveryResultRequest.java b/src/main/java/com/getjobs/application/dto/DeliveryResultRequest.java index 2521fb1..10ff636 100644 --- a/src/main/java/com/getjobs/application/dto/DeliveryResultRequest.java +++ b/src/main/java/com/getjobs/application/dto/DeliveryResultRequest.java @@ -4,7 +4,11 @@ @Data public class DeliveryResultRequest { + /** 仅作旧扩展兼容;新调用方应使用 outcome。 */ private Boolean success; + private String requestKey; + private String outcome; + private String evidence; private String message; private String failureType; private String failureReason; diff --git a/src/main/java/com/getjobs/application/entity/Job51Entity.java b/src/main/java/com/getjobs/application/entity/Job51Entity.java index 698657f..d55b6a4 100644 --- a/src/main/java/com/getjobs/application/entity/Job51Entity.java +++ b/src/main/java/com/getjobs/application/entity/Job51Entity.java @@ -35,6 +35,7 @@ public class Job51Entity { // 状态与时间戳 private Integer delivered; // 0=未投递 1=已投递 + private String deliveryStatus; private String createTime; private String updateTime; -} \ No newline at end of file +} diff --git a/src/main/java/com/getjobs/application/entity/LiepinEntity.java b/src/main/java/com/getjobs/application/entity/LiepinEntity.java index c0ef097..62039e9 100644 --- a/src/main/java/com/getjobs/application/entity/LiepinEntity.java +++ b/src/main/java/com/getjobs/application/entity/LiepinEntity.java @@ -39,8 +39,10 @@ public class LiepinEntity { // ========== 投递状态 ========== // 是否已投递:0 未投递(默认),1 已投递 private Integer delivered; + // 新投递状态真相的兼容读模型;只有 CONFIRMED 才会同时写 delivered=1 + private String deliveryStatus; // ========== 系统字段 ========== private LocalDateTime createTime; private LocalDateTime updateTime; -} \ No newline at end of file +} diff --git a/src/main/java/com/getjobs/application/service/BossService.java b/src/main/java/com/getjobs/application/service/BossService.java index aa0e464..e51dc89 100644 --- a/src/main/java/com/getjobs/application/service/BossService.java +++ b/src/main/java/com/getjobs/application/service/BossService.java @@ -32,6 +32,7 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; import java.util.regex.Matcher; @@ -729,6 +730,10 @@ private String bestLongText(String incoming, String existing) { */ public void updateDeliveryStatus(String encryptId, String encryptUserId, String status) { if (encryptId == null || status == null) return; + if (DeliveryStatus.isDelivered(status) || DeliveryStatus.isDeliveryFailed(status)) { + log.warn("旧 Boss Worker 无 requestKey,拒绝写入投递终态: encryptId={}, status={}", encryptId, status); + return; + } Long profileId = profileService.getCurrentProfileIdOrNull(); if (profileId == null) return; BossJobDataEntity update = new BossJobDataEntity(); @@ -744,6 +749,12 @@ public void updateDeliveryStatus(String encryptId, String encryptUserId, String com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper uw = new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper<>(); uw.eq("profile_id", profileId).eq("encrypt_id", encryptId); + uw.notIn("delivery_status", List.of( + DeliveryStatus.DELIVERY_REQUESTED, + DeliveryStatus.DELIVERY_UNKNOWN, + DeliveryStatus.DELIVERED, + DeliveryStatus.DELIVERY_FAILED + )); if (encryptUserId != null) { uw.eq("encrypt_user_id", encryptUserId); } @@ -914,6 +925,10 @@ public BossJobDataEntity updateDeliveryStatusById(Long id, String status, String } BossJobDataEntity current = getBossJobById(id); if (current == null) return null; + if (DeliveryStatus.isDeliveryLocked(current.getDeliveryStatus()) + && !Objects.equals(current.getDeliveryStatus(), status)) { + return current; + } BossJobDataEntity update = new BossJobDataEntity(); update.setId(id); update.setDeliveryStatus(status); @@ -1677,7 +1692,6 @@ public Map clearBossAnalysisData() { Long profileId = profileService.getCurrentProfileId(); analysisDeleted = st.executeUpdate("DELETE FROM job_ai_analysis WHERE lower(platform)='boss' AND profile_id=" + profileId); jobsDeleted = st.executeUpdate("DELETE FROM boss_data WHERE profile_id=" + profileId); - try { st.executeUpdate("DELETE FROM sqlite_sequence WHERE name='boss_data'"); } catch (Exception ignore) {} } conn.commit(); diff --git a/src/main/java/com/getjobs/application/service/DeliveryAttemptService.java b/src/main/java/com/getjobs/application/service/DeliveryAttemptService.java new file mode 100644 index 0000000..7558729 --- /dev/null +++ b/src/main/java/com/getjobs/application/service/DeliveryAttemptService.java @@ -0,0 +1,539 @@ +package com.getjobs.application.service; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.DependsOn; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; + +/** + * 投递动作的唯一事实源。平台表中的旧字段只作为兼容读模型,由本服务在同一事务内更新。 + */ +@Service +@RequiredArgsConstructor +@DependsOn("databaseSchemaService") +public class DeliveryAttemptService { + public static final String PLATFORM_STATUS_TEXT = "PLATFORM_STATUS_TEXT"; + public static final String PLATFORM_SUCCESS_DIALOG = "PLATFORM_SUCCESS_DIALOG"; + public static final String EXISTING_CONVERSATION = "EXISTING_CONVERSATION"; + public static final String CHAT_SURFACE_ONLY = "CHAT_SURFACE_ONLY"; + public static final String NO_CONFIRMATION = "NO_CONFIRMATION"; + public static final String PLATFORM_ERROR = "PLATFORM_ERROR"; + public static final String PRE_ACTION_ERROR = "PRE_ACTION_ERROR"; + public static final String MANUAL_RECONCILIATION = "MANUAL_RECONCILIATION"; + + private static final Set PLATFORMS = Set.of("boss", "zhilian", "liepin", "51job"); + private static final Set CONFIRMATION_EVIDENCE = Set.of( + PLATFORM_STATUS_TEXT, + PLATFORM_SUCCESS_DIALOG, + EXISTING_CONVERSATION + ); + + private final JdbcTemplate jdbcTemplate; + private final PlatformTransactionManager transactionManager; + + @PostConstruct + public void validateSchema() { + Integer tableCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='delivery_attempt'", + Integer.class + ); + Integer requestKeyColumn = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM pragma_table_info('delivery_attempt') WHERE name='request_key'", + Integer.class + ); + Integer jobIndex = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_delivery_attempt_job'", + Integer.class + ); + Integer stateIndex = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_delivery_attempt_state_updated'", + Integer.class + ); + if (tableCount == null || tableCount != 1 + || requestKeyColumn == null || requestKeyColumn != 1 + || jobIndex == null || jobIndex != 1 + || stateIndex == null || stateIndex != 1) { + throw new IllegalStateException("投递 attempt schema 不完整,已阻止应用继续启动"); + } + } + + public RequestResult requestBoss(long rowId, long profileId, String jobKey, boolean allowAiNotMatch) { + return request("boss", profileId, rowId, jobKey, () -> { + if (allowAiNotMatch) { + return jdbcTemplate.update("UPDATE boss_data SET delivery_status=?, failure_type='', failure_reason='', " + + "updated_at=CURRENT_TIMESTAMP WHERE id=? AND profile_id=? " + + "AND TRIM(COALESCE(delivery_status, '')) IN (?, ?)", + DeliveryStatus.DELIVERY_REQUESTED, rowId, profileId, + DeliveryStatus.WAITING_CONFIRM, DeliveryStatus.AI_NOT_MATCH); + } + return jdbcTemplate.update("UPDATE boss_data SET delivery_status=?, failure_type='', failure_reason='', " + + "updated_at=CURRENT_TIMESTAMP WHERE id=? AND profile_id=? " + + "AND TRIM(COALESCE(delivery_status, ''))=?", + DeliveryStatus.DELIVERY_REQUESTED, rowId, profileId, DeliveryStatus.WAITING_CONFIRM); + }); + } + + public RequestResult requestZhilian(long rowId, long profileId, String jobKey) { + return request("zhilian", profileId, rowId, jobKey, () -> jdbcTemplate.update( + "UPDATE zhilian_data SET delivery_status=?, failure_type='', failure_reason='', " + + "update_time=CURRENT_TIMESTAMP WHERE id=? AND profile_id=? " + + "AND TRIM(COALESCE(delivery_status, ''))=?", + DeliveryStatus.DELIVERY_REQUESTED, rowId, profileId, DeliveryStatus.WAITING_CONFIRM)); + } + + public RequestResult requestLegacy(String platform, long jobId) { + String normalizedPlatform = normalizePlatform(platform); + if (!Set.of("liepin", "51job").contains(normalizedPlatform)) { + return RequestResult.rejected("旧平台投递只支持 liepin/51job"); + } + String table = "liepin".equals(normalizedPlatform) ? "liepin_data" : "job51_data"; + return request(normalizedPlatform, null, jobId, String.valueOf(jobId), () -> jdbcTemplate.update( + "UPDATE " + table + " SET delivery_status=?, delivered=0, update_time=CURRENT_TIMESTAMP " + + "WHERE job_id=? AND TRIM(COALESCE(delivery_status, '未投递'))=?", + DeliveryStatus.DELIVERY_REQUESTED, jobId, DeliveryStatus.NOT_DELIVERED)); + } + + public RequestResult retryBoss(long rowId, long profileId, String jobKey) { + return retry("boss", profileId, rowId, jobKey, previous -> jdbcTemplate.update( + "UPDATE boss_data SET delivery_status=?, failure_type='', failure_reason='', updated_at=CURRENT_TIMESTAMP " + + "WHERE id=? AND profile_id=? AND delivery_status=?", + DeliveryStatus.DELIVERY_REQUESTED, rowId, profileId, displayStatus(previous))); + } + + public RequestResult retryZhilian(long rowId, long profileId, String jobKey) { + return retry("zhilian", profileId, rowId, jobKey, previous -> jdbcTemplate.update( + "UPDATE zhilian_data SET delivery_status=?, failure_type='', failure_reason='', update_time=CURRENT_TIMESTAMP " + + "WHERE id=? AND profile_id=? AND delivery_status=?", + DeliveryStatus.DELIVERY_REQUESTED, rowId, profileId, displayStatus(previous))); + } + + public ResolutionResult reconcileLatest(String platform, + Long profileId, + long rowId, + String jobKey, + State target, + String message) { + String normalizedPlatform = normalizePlatform(platform); + if (target != State.CONFIRMED && target != State.FAILED) { + return ResolutionResult.rejected("人工对账只允许确认已投递或确认失败"); + } + Attempt latest = findLatest(normalizedPlatform, profileId, rowId); + if (latest == null || !sameJobKey(jobKey, latest.jobKey())) { + return ResolutionResult.rejected("当前岗位没有可人工对账的 UNKNOWN 投递记录"); + } + if (latest.stateEnum() == target) { + return ResolutionResult.idempotent(target, "相同人工对账结果已保存"); + } + if (latest.stateEnum() != State.UNKNOWN) { + return ResolutionResult.rejected("当前岗位没有可人工对账的 UNKNOWN 投递记录"); + } + return resolve( + normalizedPlatform, + profileId, + rowId, + latest.requestKey(), + target, + MANUAL_RECONCILIATION, + firstNonBlank(message, target == State.CONFIRMED ? "人工核对平台后确认已投递" : "人工核对平台后确认失败"), + target == State.FAILED ? "MANUAL_RECONCILIATION" : null, + target == State.FAILED ? firstNonBlank(message, "人工核对平台后确认失败") : null, + true + ); + } + + public ResolutionResult resolve(String platform, + Long profileId, + long rowId, + String requestKey, + State target, + String evidence, + String message, + String failureType, + String failureReason) { + return resolve(platform, profileId, rowId, requestKey, target, evidence, message, + failureType, failureReason, false); + } + + private ResolutionResult resolve(String platform, + Long profileId, + long rowId, + String requestKey, + State target, + String evidence, + String message, + String failureType, + String failureReason, + boolean manualReconciliation) { + String normalizedPlatform = normalizePlatform(platform); + if (requestKey == null || requestKey.isBlank()) { + return ResolutionResult.rejected("缺少 requestKey,拒绝无任务绑定的投递回调"); + } + if (target == null || target == State.REQUESTED) { + return ResolutionResult.rejected("投递结果状态无效"); + } + String normalizedEvidence = normalizeEvidence(evidence); + boolean validConfirmationEvidence = CONFIRMATION_EVIDENCE.contains(normalizedEvidence) + || (manualReconciliation && MANUAL_RECONCILIATION.equals(normalizedEvidence)); + if (target == State.CONFIRMED && !validConfirmationEvidence) { + return ResolutionResult.rejected("缺少明确平台成功证据,不能确认已投递"); + } + + TransactionTemplate transaction = new TransactionTemplate(transactionManager); + return transaction.execute(status -> { + Attempt attempt = findByRequestKey(requestKey); + if (attempt == null + || !normalizedPlatform.equals(attempt.platform()) + || !sameProfile(profileId, attempt.profileId()) + || rowId != attempt.jobRowId()) { + return ResolutionResult.rejected("requestKey 与平台、档案或岗位不匹配"); + } + Attempt latest = findLatest(normalizedPlatform, profileId, rowId); + if (latest == null || latest.id() != attempt.id()) { + return ResolutionResult.rejected("该回调属于旧投递任务,已拒绝覆盖当前状态"); + } + State current = State.valueOf(attempt.state()); + if (current == target) { + return ResolutionResult.idempotent(target, "重复回调已幂等接受"); + } + if (current == State.CONFIRMED || current == State.FAILED) { + return ResolutionResult.rejected("投递终态不可被相反或延迟回调覆盖"); + } + if (current != State.REQUESTED && current != State.UNKNOWN) { + return ResolutionResult.rejected("当前投递状态不允许该转换"); + } + + int changed = jdbcTemplate.update("UPDATE delivery_attempt SET state=?, evidence=?, message=?, " + + "failure_type=?, failure_reason=?, resolved_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP " + + "WHERE id=? AND state=?", + target.name(), normalizedEvidence, blankToNull(message), + target == State.FAILED ? normalizeFailureType(failureType) : null, + target == State.FAILED ? firstNonBlank(failureReason, message, DeliveryStatus.DELIVERY_FAILED) : null, + attempt.id(), current.name()); + if (changed != 1) { + status.setRollbackOnly(); + return ResolutionResult.rejected("投递状态已被并发更新,请刷新后确认"); + } + int mirrored = updateLegacyReadModel( + normalizedPlatform, + profileId, + rowId, + current, + target, + failureType, + failureReason, + message, + attempt.jobKey() + ); + if (mirrored != 1) { + status.setRollbackOnly(); + return ResolutionResult.rejected("岗位兼容状态写回失败,已回滚 attempt 更新"); + } + return ResolutionResult.accepted(target, "投递结果已写入"); + }); + } + + public ResolutionResult resolveLegacy(String platform, + long jobId, + String requestKey, + State target, + String evidence, + String message) { + return resolve(platform, null, jobId, requestKey, target, evidence, message, null, null); + } + + private RequestResult request(String platform, + Long profileId, + long rowId, + String jobKey, + LegacyRequestWriter writer) { + String normalizedPlatform = normalizePlatform(platform); + if (rowId <= 0 || jobKey == null || jobKey.isBlank()) { + return RequestResult.rejected("投递岗位标识无效"); + } + TransactionTemplate transaction = new TransactionTemplate(transactionManager); + return transaction.execute(status -> { + Attempt latest = findLatest(normalizedPlatform, profileId, rowId); + if (latest != null) { + if (!sameJobKey(jobKey, latest.jobKey())) { + return RequestResult.rejected("岗位行号与历史投递记录不一致,已阻止错误绑定"); + } + State latestState = State.valueOf(latest.state()); + if (latestState == State.REQUESTED) { + return RequestResult.existing(latest.requestKey(), latestState, "投递请求已存在,请勿重复执行"); + } + return RequestResult.rejected("该岗位已有 " + latestState + " 投递记录,需先人工对账或显式重试"); + } + + int reserved = writer.markRequested(); + if (reserved != 1) { + return RequestResult.rejected("岗位状态已变化,未创建重复投递任务"); + } + String requestKey = UUID.randomUUID().toString(); + insertRequestedAttempt(requestKey, normalizedPlatform, profileId, rowId, jobKey); + return RequestResult.created(requestKey); + }); + } + + private RequestResult retry(String platform, + Long profileId, + long rowId, + String jobKey, + LegacyRetryWriter writer) { + String normalizedPlatform = normalizePlatform(platform); + if (rowId <= 0 || jobKey == null || jobKey.isBlank()) { + return RequestResult.rejected("投递岗位标识无效"); + } + TransactionTemplate transaction = new TransactionTemplate(transactionManager); + return transaction.execute(status -> { + Attempt latest = findLatest(normalizedPlatform, profileId, rowId); + if (latest == null || !sameJobKey(jobKey, latest.jobKey())) { + return RequestResult.rejected("没有可重试的同岗位投递记录"); + } + State previous = latest.stateEnum(); + if (previous == State.REQUESTED) { + return RequestResult.existing(latest.requestKey(), previous, "重试任务已存在,请恢复原任务"); + } + if (previous != State.UNKNOWN && previous != State.FAILED) { + return RequestResult.rejected("仅 UNKNOWN 或 FAILED 状态允许显式重试"); + } + if (writer.markRequested(previous) != 1) { + return RequestResult.rejected("岗位状态已变化,未创建重试任务"); + } + String requestKey = UUID.randomUUID().toString(); + insertRequestedAttempt(requestKey, normalizedPlatform, profileId, rowId, jobKey); + return RequestResult.created(requestKey); + }); + } + + private void insertRequestedAttempt(String requestKey, + String platform, + Long profileId, + long rowId, + String jobKey) { + LocalDateTime now = LocalDateTime.now(); + jdbcTemplate.update("INSERT INTO delivery_attempt " + + "(request_key, platform, profile_id, job_key, job_row_id, state, evidence, message, requested_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + requestKey, platform, profileId, jobKey.trim(), rowId, + State.REQUESTED.name(), "USER_CONFIRMED", "已创建投递请求", now, now); + } + + private int updateLegacyReadModel(String platform, + Long profileId, + long rowId, + State current, + State target, + String failureType, + String failureReason, + String message, + String jobKey) { + String displayStatus = switch (target) { + case CONFIRMED -> DeliveryStatus.DELIVERED; + case FAILED -> DeliveryStatus.DELIVERY_FAILED; + case UNKNOWN -> DeliveryStatus.DELIVERY_UNKNOWN; + case REQUESTED -> DeliveryStatus.DELIVERY_REQUESTED; + }; + String expectedStatus = switch (current) { + case REQUESTED -> DeliveryStatus.DELIVERY_REQUESTED; + case UNKNOWN -> DeliveryStatus.DELIVERY_UNKNOWN; + case CONFIRMED -> DeliveryStatus.DELIVERED; + case FAILED -> DeliveryStatus.DELIVERY_FAILED; + }; + if ("boss".equals(platform)) { + return jdbcTemplate.update("UPDATE boss_data SET delivery_status=?, failure_type=?, failure_reason=?, " + + "updated_at=CURRENT_TIMESTAMP WHERE id=? AND profile_id=? AND delivery_status=? " + + "AND COALESCE(NULLIF(encrypt_id, ''), CAST(id AS TEXT))=?", + displayStatus, + target == State.FAILED ? normalizeFailureType(failureType) : "", + target == State.FAILED ? firstNonBlank(failureReason, message, DeliveryStatus.DELIVERY_FAILED) : "", + rowId, profileId, expectedStatus, jobKey); + } + if ("zhilian".equals(platform)) { + return jdbcTemplate.update("UPDATE zhilian_data SET delivery_status=?, failure_type=?, failure_reason=?, " + + "update_time=CURRENT_TIMESTAMP WHERE id=? AND profile_id=? AND delivery_status=? " + + "AND COALESCE(NULLIF(job_id, ''), CAST(id AS TEXT))=?", + displayStatus, + target == State.FAILED ? normalizeFailureType(failureType) : "", + target == State.FAILED ? firstNonBlank(failureReason, message, DeliveryStatus.DELIVERY_FAILED) : "", + rowId, profileId, expectedStatus, jobKey); + } + String table = "liepin".equals(platform) ? "liepin_data" : "job51_data"; + return jdbcTemplate.update("UPDATE " + table + " SET delivery_status=?, delivered=?, update_time=CURRENT_TIMESTAMP " + + "WHERE job_id=? AND delivery_status=?", + displayStatus, target == State.CONFIRMED ? 1 : 0, rowId, expectedStatus); + } + + private Attempt findByRequestKey(String requestKey) { + List attempts = jdbcTemplate.query( + "SELECT id, request_key, platform, profile_id, job_key, job_row_id, state FROM delivery_attempt WHERE request_key=?", + (resultSet, rowNum) -> new Attempt( + resultSet.getLong("id"), + resultSet.getString("request_key"), + resultSet.getString("platform"), + nullableLong(resultSet, "profile_id"), + resultSet.getString("job_key"), + resultSet.getLong("job_row_id"), + resultSet.getString("state") + ), + requestKey.trim() + ); + return attempts.isEmpty() ? null : attempts.getFirst(); + } + + private Attempt findLatest(String platform, Long profileId, long rowId) { + String profilePredicate = profileId == null ? "profile_id IS NULL" : "profile_id=?"; + String sql = "SELECT id, request_key, platform, profile_id, job_key, job_row_id, state " + + "FROM delivery_attempt WHERE platform=? AND job_row_id=? AND " + profilePredicate + + " ORDER BY id DESC LIMIT 1"; + Object[] args = profileId == null + ? new Object[]{platform, rowId} + : new Object[]{platform, rowId, profileId}; + List attempts = jdbcTemplate.query( + sql, + (resultSet, rowNum) -> new Attempt( + resultSet.getLong("id"), + resultSet.getString("request_key"), + resultSet.getString("platform"), + nullableLong(resultSet, "profile_id"), + resultSet.getString("job_key"), + resultSet.getLong("job_row_id"), + resultSet.getString("state") + ), + args + ); + return attempts.isEmpty() ? null : attempts.getFirst(); + } + + private Long nullableLong(java.sql.ResultSet resultSet, String column) throws java.sql.SQLException { + long value = resultSet.getLong(column); + return resultSet.wasNull() ? null : value; + } + + private String normalizePlatform(String platform) { + String normalized = platform == null ? "" : platform.trim().toLowerCase(Locale.ROOT); + if (!PLATFORMS.contains(normalized)) { + throw new IllegalArgumentException("不支持的投递平台: " + platform); + } + return normalized; + } + + private String normalizeEvidence(String evidence) { + return evidence == null ? "" : evidence.trim().toUpperCase(Locale.ROOT); + } + + private String normalizeFailureType(String failureType) { + return firstNonBlank(failureType, DeliveryStatus.UNKNOWN_FAILURE_TYPE).trim(); + } + + private String blankToNull(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + private String firstNonBlank(String... values) { + if (values != null) { + for (String value : values) { + if (value != null && !value.isBlank()) return value.trim(); + } + } + return ""; + } + + private boolean sameProfile(Long expected, Long actual) { + return expected == null ? actual == null : expected.equals(actual); + } + + private boolean sameJobKey(String expected, String actual) { + return expected != null && actual != null && expected.trim().equals(actual.trim()); + } + + private String displayStatus(State state) { + return switch (state) { + case REQUESTED -> DeliveryStatus.DELIVERY_REQUESTED; + case CONFIRMED -> DeliveryStatus.DELIVERED; + case FAILED -> DeliveryStatus.DELIVERY_FAILED; + case UNKNOWN -> DeliveryStatus.DELIVERY_UNKNOWN; + }; + } + + public enum State { + REQUESTED, + CONFIRMED, + FAILED, + UNKNOWN; + + public static State parse(String value) { + if (value == null || value.isBlank()) return null; + try { + return valueOf(value.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ignored) { + return null; + } + } + } + + public record RequestResult(boolean accepted, + boolean created, + String requestKey, + State state, + String message) { + static RequestResult created(String requestKey) { + return new RequestResult(true, true, requestKey, State.REQUESTED, "投递请求已创建"); + } + + static RequestResult existing(String requestKey, State state, String message) { + return new RequestResult(true, false, requestKey, state, message); + } + + static RequestResult rejected(String message) { + return new RequestResult(false, false, null, null, message); + } + } + + public record ResolutionResult(boolean accepted, + boolean idempotent, + State state, + String message) { + static ResolutionResult accepted(State state, String message) { + return new ResolutionResult(true, false, state, message); + } + + static ResolutionResult idempotent(State state, String message) { + return new ResolutionResult(true, true, state, message); + } + + static ResolutionResult rejected(String message) { + return new ResolutionResult(false, false, null, message); + } + } + + private record Attempt(long id, + String requestKey, + String platform, + Long profileId, + String jobKey, + long jobRowId, + String state) { + State stateEnum() { + return State.valueOf(state); + } + } + + @FunctionalInterface + private interface LegacyRequestWriter { + int markRequested(); + } + + @FunctionalInterface + private interface LegacyRetryWriter { + int markRequested(State previous); + } +} diff --git a/src/main/java/com/getjobs/application/service/DeliveryStatus.java b/src/main/java/com/getjobs/application/service/DeliveryStatus.java index c5cb157..87760e4 100644 --- a/src/main/java/com/getjobs/application/service/DeliveryStatus.java +++ b/src/main/java/com/getjobs/application/service/DeliveryStatus.java @@ -13,6 +13,8 @@ public final class DeliveryStatus { public static final String SKIPPED = "已跳过"; public static final String DELIVERED = "已投递"; public static final String DELIVERY_FAILED = "投递失败"; + public static final String DELIVERY_REQUESTED = "投递确认中"; + public static final String DELIVERY_UNKNOWN = "投递结果待确认"; public static final String FILTERED = "已过滤"; public static final String UNKNOWN_FAILURE_TYPE = "UNKNOWN_ERROR"; @@ -25,7 +27,9 @@ public final class DeliveryStatus { AI_ANALYSIS_FAILED, COLLECTION_INSUFFICIENT, LIST_COLLECTED, - DELIVERY_FAILED + DELIVERY_FAILED, + DELIVERY_REQUESTED, + DELIVERY_UNKNOWN ); public static final Set FINAL_STATUSES = Set.of( @@ -35,7 +39,9 @@ public final class DeliveryStatus { AI_NOT_MATCH, AI_ANALYSIS_FAILED, COLLECTION_INSUFFICIENT, - DELIVERY_FAILED + DELIVERY_FAILED, + DELIVERY_REQUESTED, + DELIVERY_UNKNOWN ); private DeliveryStatus() { @@ -70,7 +76,15 @@ public static String fromAiResult(JobAiAnalysisService.AnalysisResult result) { } public static String protectDelivered(String currentStatus, String nextStatus) { - return isDelivered(currentStatus) ? DELIVERED : nextStatus; + return isDeliveryLocked(currentStatus) ? trim(currentStatus) : nextStatus; + } + + public static boolean isDeliveryLocked(String status) { + String value = trim(status); + return DELIVERED.equals(value) + || DELIVERY_FAILED.equals(value) + || DELIVERY_REQUESTED.equals(value) + || DELIVERY_UNKNOWN.equals(value); } public static String defaultIfBlank(String status) { diff --git a/src/main/java/com/getjobs/application/service/Job51Service.java b/src/main/java/com/getjobs/application/service/Job51Service.java index 7f207c8..8c2e8dc 100644 --- a/src/main/java/com/getjobs/application/service/Job51Service.java +++ b/src/main/java/com/getjobs/application/service/Job51Service.java @@ -372,49 +372,16 @@ private static Long readLong(com.fasterxml.jackson.databind.JsonNode... nodes) { // ==================== 投递状态写回 ==================== /** 将指定 jobId 标记为已投递 */ + @Deprecated(forRemoval = false) public void markDelivered(Long jobId) { - if (jobId == null) return; - try (Connection conn = dataSource.getConnection(); - java.sql.PreparedStatement ps = conn.prepareStatement( - "UPDATE job51_data SET delivered=1, update_time=? WHERE job_id=?")) { - java.time.LocalDateTime now = java.time.LocalDateTime.now(); - ps.setString(1, now.toString()); - ps.setLong(2, jobId); - ps.executeUpdate(); - } catch (Exception e) { - log.warn("标记 51job 已投递失败 job_id={}: {}", jobId, e.getMessage()); - } + log.warn("已拒绝无 requestKey 的 51job 已投递写入 job_id={},请使用 DeliveryAttemptService", jobId); } /** 批量标记为已投递 */ + @Deprecated(forRemoval = false) public void markDeliveredBatch(java.util.Collection jobIds) { - if (jobIds == null || jobIds.isEmpty()) return; - try (Connection conn = dataSource.getConnection(); - java.sql.PreparedStatement ps = conn.prepareStatement( - "UPDATE job51_data SET delivered=1, update_time=? WHERE job_id=?")) { - conn.setAutoCommit(false); - java.time.LocalDateTime now = java.time.LocalDateTime.now(); - for (Long id : jobIds) { - if (id == null) continue; - ps.setString(1, now.toString()); - ps.setLong(2, id); - ps.addBatch(); - } - int[] counts = ps.executeBatch(); - conn.commit(); - try { - int updated = 0; - if (counts != null) { - for (int c : counts) { - if (c > 0) updated += c; - } - } - String sample = jobIds.stream().filter(java.util.Objects::nonNull).limit(5).map(String::valueOf).collect(java.util.stream.Collectors.joining(", ")); - log.info("[51job] 批量标记已投递完成,入参 {} 条,成功更新 {} 条,示例ID: {}", jobIds.size(), updated, sample); - } catch (Exception ignored) {} - } catch (Exception e) { - log.warn("批量标记 51job 已投递失败: {}", e.getMessage()); - } + log.warn("已拒绝无 requestKey 的 51job 批量已投递写入,入参 {} 条,请使用 DeliveryAttemptService", + jobIds == null ? 0 : jobIds.size()); } // ==================== 投递分析与列表 ==================== @@ -431,7 +398,7 @@ public static class Charts { public java.util.List salaryBuckets; public java.util.List dailyTrend; // date as name } - public static class Kpi { public long total; public long delivered; public long pending; public long filtered; public long failed; public Double avgMonthlyK; } + public static class Kpi { public long total; public long delivered; public long pending; public long requested; public long unknown; public long filtered; public long failed; public Double avgMonthlyK; } public static class StatsResponse { public Kpi kpi; public Charts charts; } public static class Job51Row { @@ -482,10 +449,12 @@ public StatsResponse getJob51Stats( try { com.baomidou.mybatisplus.core.conditions.query.QueryWrapper wrapper = new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<>(); if (statuses != null && !statuses.isEmpty()) { - java.util.List deliveredVals = new java.util.ArrayList<>(); - if (statuses.contains("已投递")) deliveredVals.add(1); - if (statuses.contains("未投递")) deliveredVals.add(0); - if (!deliveredVals.isEmpty()) wrapper.in("delivered", deliveredVals); + java.util.Set normalizedStatuses = statuses.stream() + .filter(java.util.Objects::nonNull) + .map(String::trim) + .filter(value -> !value.isEmpty()) + .collect(java.util.stream.Collectors.toSet()); + if (!normalizedStatuses.isEmpty()) wrapper.in("delivery_status", normalizedStatuses); } if (location != null && !location.trim().isEmpty()) wrapper.eq("job_area", location.trim()); if (experience != null && !experience.trim().isEmpty()) wrapper.eq("job_exp_req", experience.trim()); @@ -523,15 +492,17 @@ public StatsResponse getJob51Stats( // KPI resp.kpi.total = filtered.size(); - resp.kpi.delivered = filtered.stream().filter(e -> e.getDelivered() != null && e.getDelivered() == 1).count(); - resp.kpi.pending = filtered.stream().filter(e -> e.getDelivered() == null || e.getDelivered() == 0).count(); + resp.kpi.delivered = filtered.stream().filter(e -> DeliveryStatus.DELIVERED.equals(deliveryStatusOf(e))).count(); + resp.kpi.pending = filtered.stream().filter(e -> DeliveryStatus.NOT_DELIVERED.equals(deliveryStatusOf(e))).count(); + resp.kpi.requested = filtered.stream().filter(e -> DeliveryStatus.DELIVERY_REQUESTED.equals(deliveryStatusOf(e))).count(); + resp.kpi.unknown = filtered.stream().filter(e -> DeliveryStatus.DELIVERY_UNKNOWN.equals(deliveryStatusOf(e))).count(); resp.kpi.filtered = 0; // 51 无明确“已过滤” - resp.kpi.failed = 0; // 51 无明确“投递失败” + resp.kpi.failed = filtered.stream().filter(e -> DeliveryStatus.DELIVERY_FAILED.equals(deliveryStatusOf(e))).count(); resp.kpi.avgMonthlyK = countMedian > 0 ? Math.round((sumMedian / countMedian) * 100.0) / 100.0 : null; // Charts java.util.Map byStatus = filtered.stream() - .collect(java.util.stream.Collectors.groupingBy(e -> (e.getDelivered()!=null && e.getDelivered()==1) ? "已投递" : "未投递", java.util.stream.Collectors.counting())); + .collect(java.util.stream.Collectors.groupingBy(this::deliveryStatusOf, java.util.stream.Collectors.counting())); byStatus.forEach((k,v) -> charts.byStatus.add(new NameValue(nullSafe(k), v))); java.util.Map byCity = filtered.stream() @@ -605,10 +576,12 @@ public PagedResult51 listJob51( com.baomidou.mybatisplus.core.conditions.query.QueryWrapper wrapper = new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<>(); if (statuses != null && !statuses.isEmpty()) { - java.util.List deliveredVals = new java.util.ArrayList<>(); - if (statuses.contains("已投递")) deliveredVals.add(1); - if (statuses.contains("未投递")) deliveredVals.add(0); - if (!deliveredVals.isEmpty()) wrapper.in("delivered", deliveredVals); + java.util.Set normalizedStatuses = statuses.stream() + .filter(java.util.Objects::nonNull) + .map(String::trim) + .filter(value -> !value.isEmpty()) + .collect(java.util.stream.Collectors.toSet()); + if (!normalizedStatuses.isEmpty()) wrapper.in("delivery_status", normalizedStatuses); } if (location != null && !location.trim().isEmpty()) wrapper.eq("job_area", location.trim()); if (experience != null && !experience.trim().isEmpty()) wrapper.eq("job_exp_req", experience.trim()); @@ -650,7 +623,7 @@ public PagedResult51 listJob51( r.experience = e.getJobExpReq(); r.degree = e.getJobEduReq(); r.hrName = e.getHrName(); - r.deliveryStatus = (e.getDelivered()!=null && e.getDelivered()==1) ? "已投递" : "未投递"; + r.deliveryStatus = deliveryStatusOf(e); r.jobUrl = e.getJobLink(); r.publishTime = e.getJobPublishTime(); r.createdAt = e.getCreateTime(); @@ -667,6 +640,15 @@ public PagedResult51 listJob51( return result; } + private String deliveryStatusOf(Job51Entity entity) { + if (entity.getDeliveryStatus() != null && !entity.getDeliveryStatus().isBlank()) { + return entity.getDeliveryStatus().trim(); + } + return entity.getDelivered() != null && entity.getDelivered() == 1 + ? DeliveryStatus.DELIVERED + : DeliveryStatus.NOT_DELIVERED; + } + // ==================== 薪资解析 ==================== private static class SalaryInfo { Double medianK; } private SalaryInfo parse51Salary(String salaryText) { diff --git a/src/main/java/com/getjobs/application/service/JobAiAnalysisService.java b/src/main/java/com/getjobs/application/service/JobAiAnalysisService.java index fafc45e..704db2f 100644 --- a/src/main/java/com/getjobs/application/service/JobAiAnalysisService.java +++ b/src/main/java/com/getjobs/application/service/JobAiAnalysisService.java @@ -478,7 +478,7 @@ public void updatePlatformCache(JobAnalysisRequest request, AnalysisResult resul if (request.getScanRunId() != null && !request.getScanRunId().isBlank()) { update.setScanRunId(request.getScanRunId()); } - if (!DeliveryStatus.isDelivered(existing == null ? null : existing.getDeliveryStatus())) { + if (!DeliveryStatus.isDeliveryLocked(existing == null ? null : existing.getDeliveryStatus())) { update.setDeliveryStatus(nextStatus); } update.setUpdatedAt(LocalDateTime.now()); @@ -500,7 +500,7 @@ public void updatePlatformCache(JobAnalysisRequest request, AnalysisResult resul if (request.getJobDescription() != null && !request.getJobDescription().isBlank()) { update.setJobDescription(request.getJobDescription()); } - if (!DeliveryStatus.isDelivered(existing == null ? null : existing.getDeliveryStatus())) { + if (!DeliveryStatus.isDeliveryLocked(existing == null ? null : existing.getDeliveryStatus())) { update.setDeliveryStatus(nextStatus); } update.setUpdateTime(LocalDateTime.now()); @@ -512,14 +512,14 @@ private void markPlatformAnalysisStarted(JobAnalysisRequest request) { if (request == null) return; if ("boss".equalsIgnoreCase(request.getPlatform())) { BossJobDataEntity existing = findBossJobForAnalysis(request); - if (DeliveryStatus.isDelivered(existing == null ? null : existing.getDeliveryStatus())) return; + if (DeliveryStatus.isDeliveryLocked(existing == null ? null : existing.getDeliveryStatus())) return; BossJobDataEntity update = new BossJobDataEntity(); update.setDeliveryStatus(DeliveryStatus.AI_ANALYZING); update.setUpdatedAt(LocalDateTime.now()); bossJobDataMapper.update(update, bossUpdateWrapper(request)); } else if ("zhilian".equalsIgnoreCase(request.getPlatform())) { ZhilianJobDataEntity existing = findZhilianJobForAnalysis(request); - if (DeliveryStatus.isDelivered(existing == null ? null : existing.getDeliveryStatus())) return; + if (DeliveryStatus.isDeliveryLocked(existing == null ? null : existing.getDeliveryStatus())) return; ZhilianJobDataEntity update = new ZhilianJobDataEntity(); update.setDeliveryStatus(DeliveryStatus.AI_ANALYZING); update.setUpdateTime(LocalDateTime.now()); diff --git a/src/main/java/com/getjobs/application/service/LiepinService.java b/src/main/java/com/getjobs/application/service/LiepinService.java index 14ad59a..861a385 100644 --- a/src/main/java/com/getjobs/application/service/LiepinService.java +++ b/src/main/java/com/getjobs/application/service/LiepinService.java @@ -51,12 +51,14 @@ public void saveOrUpdateSnapshot(LiepinEntity entity) { entity.setCreateTime(now); entity.setUpdateTime(now); if (entity.getDelivered() == null) entity.setDelivered(0); + if (entity.getDeliveryStatus() == null) entity.setDeliveryStatus(DeliveryStatus.NOT_DELIVERED); liepinMapper.insert(entity); } else { // 保留 create_time,更新其他字段与 update_time entity.setCreateTime(existing.getCreateTime()); entity.setUpdateTime(now); if (entity.getDelivered() == null) entity.setDelivered(existing.getDelivered()); + if (entity.getDeliveryStatus() == null) entity.setDeliveryStatus(existing.getDeliveryStatus()); liepinMapper.updateById(entity); } } catch (Exception e) { @@ -78,6 +80,7 @@ public void insertSnapshotIfNotExists(LiepinEntity entity) { entity.setCreateTime(now); entity.setUpdateTime(now); if (entity.getDelivered() == null) entity.setDelivered(0); + if (entity.getDeliveryStatus() == null) entity.setDeliveryStatus(DeliveryStatus.NOT_DELIVERED); liepinMapper.insert(entity); } else { // already exists, skip @@ -90,21 +93,9 @@ public void insertSnapshotIfNotExists(LiepinEntity entity) { /** * 标记岗位为已投递(delivered=1),如存在该记录 */ + @Deprecated(forRemoval = false) public void markDelivered(Long jobId) { - if (jobId == null) return; - try { - LiepinEntity existing = liepinMapper.selectById(jobId); - if (existing != null) { - LiepinEntity update = new LiepinEntity(); - update.setJobId(jobId); - update.setDelivered(1); - update.setCreateTime(existing.getCreateTime()); - update.setUpdateTime(LocalDateTime.now()); - liepinMapper.updateById(update); - } - } catch (Exception e) { - log.warn("更新投递状态失败 job_id={}: {}", jobId, e.getMessage()); - } + log.warn("已拒绝无 requestKey 的猎聘已投递写入 job_id={},请使用 DeliveryAttemptService", jobId); } /** @@ -358,6 +349,8 @@ public static class Kpi { public long total; public long delivered; public long pending; + public long requested; + public long unknown; public long filtered; // 猎聘暂无,置0 public long failed; // 猎聘暂无,置0 public Double avgMonthlyK; // 平均中位数K @@ -420,19 +413,14 @@ public StatsResponse getLiepinStats( try { com.baomidou.mybatisplus.core.conditions.query.QueryWrapper wrapper = new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<>(); - // 状态:已投递/未投递 -> delivered 1/0 + // 投递状态以 V6 delivery_status 兼容读模型为准。 if (statuses != null && !statuses.isEmpty()) { - Set deliveredSet = new HashSet<>(); - for (String s : statuses) { - if (s != null) { - String t = s.trim(); - if ("已投递".equals(t)) deliveredSet.add(1); - if ("未投递".equals(t)) deliveredSet.add(0); - } - } - if (!deliveredSet.isEmpty()) { - wrapper.in("delivered", deliveredSet); - } + Set normalizedStatuses = statuses.stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(value -> !value.isEmpty()) + .collect(Collectors.toSet()); + if (!normalizedStatuses.isEmpty()) wrapper.in("delivery_status", normalizedStatuses); } if (location != null && !location.trim().isEmpty()) wrapper.eq("job_area", location.trim()); if (experience != null && !experience.trim().isEmpty()) wrapper.eq("job_exp_req", experience.trim()); @@ -472,15 +460,17 @@ public StatsResponse getLiepinStats( // KPI resp.kpi.total = filtered.size(); - resp.kpi.delivered = filtered.stream().filter(e -> Objects.equals(e.getDelivered(), 1)).count(); - resp.kpi.pending = filtered.stream().filter(e -> e.getDelivered() == null || Objects.equals(e.getDelivered(), 0)).count(); + resp.kpi.delivered = filtered.stream().filter(e -> DeliveryStatus.DELIVERED.equals(deliveryStatusOf(e))).count(); + resp.kpi.pending = filtered.stream().filter(e -> DeliveryStatus.NOT_DELIVERED.equals(deliveryStatusOf(e))).count(); + resp.kpi.requested = filtered.stream().filter(e -> DeliveryStatus.DELIVERY_REQUESTED.equals(deliveryStatusOf(e))).count(); + resp.kpi.unknown = filtered.stream().filter(e -> DeliveryStatus.DELIVERY_UNKNOWN.equals(deliveryStatusOf(e))).count(); resp.kpi.filtered = 0; - resp.kpi.failed = 0; + resp.kpi.failed = filtered.stream().filter(e -> DeliveryStatus.DELIVERY_FAILED.equals(deliveryStatusOf(e))).count(); resp.kpi.avgMonthlyK = countMedian > 0 ? Math.round((sumMedian / countMedian) * 100.0) / 100.0 : null; // Charts 聚合 Map byStatus = filtered.stream() - .collect(Collectors.groupingBy(e -> Objects.equals(e.getDelivered(), 1) ? "已投递" : "未投递", Collectors.counting())); + .collect(Collectors.groupingBy(this::deliveryStatusOf, Collectors.counting())); byStatus.forEach((k, v) -> charts.byStatus.add(new NameValue(k, v))); Map byCity = filtered.stream() @@ -581,15 +571,12 @@ public PagedResult listLiepinJobs( com.baomidou.mybatisplus.core.conditions.query.QueryWrapper wrapper = new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<>(); if (statuses != null && !statuses.isEmpty()) { - Set deliveredSet = new HashSet<>(); - for (String s : statuses) { - if (s != null) { - String t = s.trim(); - if ("已投递".equals(t)) deliveredSet.add(1); - if ("未投递".equals(t)) deliveredSet.add(0); - } - } - if (!deliveredSet.isEmpty()) wrapper.in("delivered", deliveredSet); + Set normalizedStatuses = statuses.stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(value -> !value.isEmpty()) + .collect(Collectors.toSet()); + if (!normalizedStatuses.isEmpty()) wrapper.in("delivery_status", normalizedStatuses); } if (location != null && !location.trim().isEmpty()) wrapper.eq("job_area", location.trim()); if (experience != null && !experience.trim().isEmpty()) wrapper.eq("job_exp_req", experience.trim()); @@ -630,4 +617,11 @@ public PagedResult listLiepinJobs( pr.size = size; return pr; } + + private String deliveryStatusOf(LiepinEntity entity) { + if (entity.getDeliveryStatus() != null && !entity.getDeliveryStatus().isBlank()) { + return entity.getDeliveryStatus().trim(); + } + return Objects.equals(entity.getDelivered(), 1) ? DeliveryStatus.DELIVERED : DeliveryStatus.NOT_DELIVERED; + } } diff --git a/src/main/java/com/getjobs/application/service/ZhilianService.java b/src/main/java/com/getjobs/application/service/ZhilianService.java index 800c583..0bacf2e 100644 --- a/src/main/java/com/getjobs/application/service/ZhilianService.java +++ b/src/main/java/com/getjobs/application/service/ZhilianService.java @@ -361,7 +361,7 @@ public ZhilianJobDataEntity getZhilianJobById(Long id) { } public void markDeliveredByJobId(String jobId) { - updateDeliveryStatusByJobId(jobId, DeliveryStatus.DELIVERED); + log.warn("旧智联 Worker 无 requestKey,拒绝按 jobId 写入已投递状态: jobId={}", jobId); } public void markWaitingConfirmByJobId(String jobId) { @@ -396,6 +396,12 @@ public void updateDeliveryStatusByJobId(String jobId, String status, Long profil com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper uw = new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper<>(); uw.eq("profile_id", profileId).eq("job_id", jobId); + uw.notIn("delivery_status", List.of( + DeliveryStatus.DELIVERY_REQUESTED, + DeliveryStatus.DELIVERY_UNKNOWN, + DeliveryStatus.DELIVERED, + DeliveryStatus.DELIVERY_FAILED + )); zhilianJobDataMapper.update(upd, uw); } @@ -405,7 +411,7 @@ private String normalizeFailureType(String failureType) { } public void markDeliveredByTitleAndCompany(String jobTitle, String companyName) { - updateDeliveryStatusByTitleAndCompany(jobTitle, companyName, DeliveryStatus.DELIVERED); + log.warn("旧智联 Worker 无 requestKey,拒绝按岗位名称写入已投递状态: company={}, title={}", companyName, jobTitle); } public void markWaitingConfirmByTitleAndCompany(String jobTitle, String companyName) { @@ -429,6 +435,12 @@ public void updateDeliveryStatusByTitleAndCompany(String jobTitle, String compan com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper uw = new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper<>(); uw.eq("profile_id", profileId).eq("job_title", jobTitle).eq("company_name", companyName); + uw.notIn("delivery_status", List.of( + DeliveryStatus.DELIVERY_REQUESTED, + DeliveryStatus.DELIVERY_UNKNOWN, + DeliveryStatus.DELIVERED, + DeliveryStatus.DELIVERY_FAILED + )); zhilianJobDataMapper.update(upd, uw); } @@ -442,6 +454,10 @@ public ZhilianJobDataEntity updateDeliveryStatusById(Long id, String status, Str } ZhilianJobDataEntity current = getZhilianJobById(id); if (current == null) return null; + if (DeliveryStatus.isDeliveryLocked(current.getDeliveryStatus()) + && !Objects.equals(current.getDeliveryStatus(), status)) { + return current; + } ZhilianJobDataEntity update = new ZhilianJobDataEntity(); update.setId(id); update.setDeliveryStatus(status); @@ -810,7 +826,6 @@ public Map clearZhilianAnalysisData() { Long profileId = profileService.getCurrentProfileId(); analysisDeleted = st.executeUpdate("DELETE FROM job_ai_analysis WHERE lower(platform)='zhilian' AND profile_id=" + profileId); jobsDeleted = st.executeUpdate("DELETE FROM zhilian_data WHERE profile_id=" + profileId); - try { st.executeUpdate("DELETE FROM sqlite_sequence WHERE name='zhilian_data'"); } catch (Exception ignore) {} } conn.commit(); diff --git a/src/main/java/com/getjobs/worker/job51/Job51.java b/src/main/java/com/getjobs/worker/job51/Job51.java index 878bbe6..1fa7a17 100644 --- a/src/main/java/com/getjobs/worker/job51/Job51.java +++ b/src/main/java/com/getjobs/worker/job51/Job51.java @@ -1,6 +1,7 @@ package com.getjobs.worker.job51; import com.getjobs.application.service.Job51Service; +import com.getjobs.application.service.DeliveryAttemptService; import com.getjobs.worker.utils.JobUtils; import com.getjobs.worker.utils.PlaywrightUtil; import com.microsoft.playwright.Locator; @@ -35,6 +36,7 @@ public class Job51 { private final List resultList = new ArrayList<>(); private final Job51Service job51Service; + private final DeliveryAttemptService deliveryAttemptService; private boolean networkHooked = false; private boolean reachedDailyLimit = false; private final java.util.Set processedRequestIds = new java.util.HashSet<>(); @@ -95,6 +97,7 @@ public int execute() { } catch (Exception e) { log.error("51job投递过程出现异常", e); sendProgress("投递出现异常: " + e.getMessage(), null, null); + throw new IllegalStateException("51job投递过程异常", e); } return resultList.size(); @@ -229,13 +232,20 @@ private void deliverByKeyword(String keyword, String searchUrl) { } // 关键词完成不输出日志 - } catch (Exception e) { /* 静默 */ } + } catch (Exception e) { + log.error("51job关键词投递流程失败,已停止后续页面", e); + throw e instanceof RuntimeException runtimeException + ? runtimeException + : new IllegalStateException("51job关键词投递流程失败", e); + } } /** * 投递当前页面的所有职位 */ private void deliverCurrentPage() { + java.util.Map selectedAttempts = new java.util.LinkedHashMap<>(); + boolean platformActionStarted = false; try { PlaywrightUtil.sleep(1); @@ -248,24 +258,78 @@ private void deliverCurrentPage() { Locator companies = page.locator("[class*='cname text-cut']"); int jobCount = checkboxes.count(); + List jobIdsByIndex = new ArrayList<>(); + java.util.Set uniqueJobIds = new java.util.LinkedHashSet<>(); + for (int i = 0; i < jobCount; i++) { + Long jobId = collectJobIdForCheckbox(checkboxes.nth(i)); + if (jobId == null || !uniqueJobIds.add(jobId)) { + sendProgress("51job岗位与 jobId 无法一一对应,本页已停止且未执行真实投递", null, null); + return; + } + jobIdsByIndex.add(jobId); + } // 选中所有职位 for (int i = 0; i < jobCount; i++) { if (shouldStop()) { + resolveJob51Attempts(selectedAttempts, DeliveryAttemptService.State.FAILED, + DeliveryAttemptService.PRE_ACTION_ERROR, "用户在批量投递前取消操作"); return; } + Long selectedJobId = jobIdsByIndex.get(i); + DeliveryAttemptService.RequestResult attempt = null; try { Locator checkbox = checkboxes.nth(i); + attempt = deliveryAttemptService.requestLegacy("51job", selectedJobId); + if (!attempt.created()) { + if (attempt.accepted() && attempt.state() == DeliveryAttemptService.State.REQUESTED) { + DeliveryAttemptService.ResolutionResult recovered = deliveryAttemptService.resolveLegacy( + "51job", selectedJobId, attempt.requestKey(), + DeliveryAttemptService.State.UNKNOWN, + DeliveryAttemptService.NO_CONFIRMATION, + "检测到上次 51job 投递流程中断,已转为人工待对账" + ); + if (!recovered.accepted()) { + throw new IllegalStateException("51job 中断 attempt 无法恢复: " + recovered.message()); + } + sendProgress("51job检测到上次中断的投递记录,已转为待对账且未重复点击平台", null, null); + } else { + log.info("跳过 51job 非首次投递请求 jobId={}: {}", selectedJobId, attempt.message()); + } + continue; + } // 使用JavaScript点击,避免元素被遮挡 checkbox.evaluate("el => el.click()"); + selectedAttempts.put(selectedJobId, attempt.requestKey()); String title = i < titles.count() ? titles.nth(i).textContent() : "未知职位"; String company = i < companies.count() ? companies.nth(i).textContent() : "未知公司"; String jobInfo = company + " | " + title; resultList.add(jobInfo); // log.info("选中: {}", jobInfo); - } catch (Exception e) { /* 静默 */ } + } catch (Exception e) { + log.warn("51job岗位预留或勾选失败,未把该岗位纳入批量动作: {}", e.getMessage()); + if (attempt != null && !attempt.created()) { + throw new IllegalStateException("51job历史投递记录恢复失败", e); + } + if (attempt != null && attempt.created()) { + DeliveryAttemptService.ResolutionResult failed = deliveryAttemptService.resolveLegacy( + "51job", selectedJobId, attempt.requestKey(), + DeliveryAttemptService.State.FAILED, + DeliveryAttemptService.PRE_ACTION_ERROR, + "51job岗位勾选失败,未执行批量投递" + ); + if (!failed.accepted()) { + throw new IllegalStateException("51job岗位勾选失败状态无法落库: " + failed.message(), e); + } + } + } + } + + if (selectedAttempts.isEmpty()) { + sendProgress("51job本页没有可安全追踪的投递岗位,未执行批量投递", null, null); + return; } PlaywrightUtil.sleep(1); @@ -275,7 +339,14 @@ private void deliverCurrentPage() { PlaywrightUtil.sleep(1); // 点击批量投递按钮 - clickBatchDeliverButton(); + boolean clicked = clickBatchDeliverButton(); + if (!clicked) { + resolveJob51Attempts(selectedAttempts, DeliveryAttemptService.State.UNKNOWN, + DeliveryAttemptService.NO_CONFIRMATION, "51job批量投递按钮点击结果不明确"); + sendProgress("51job批量投递按钮点击结果不明确,本页岗位已标记待对账", null, null); + return; + } + platformActionStarted = true; PlaywrightUtil.sleep(3); @@ -285,30 +356,95 @@ private void deliverCurrentPage() { // 处理单独投递申请弹窗 handleSeparateDeliveryDialog(); - // 投递状态写回:采集当前页 jobId 并标记 delivered=1 + // 51job 弹窗无法证明每个岗位的逐条结果;本页只记录 UNKNOWN,绝不批量写 confirmed。 try { - List deliveredIds = collectJobIdsOnPage(); - if (!deliveredIds.isEmpty()) { - job51Service.markDeliveredBatch(deliveredIds); - } - } catch (Exception e) { /* 静默 */ } + resolveJob51Attempts(selectedAttempts, DeliveryAttemptService.State.UNKNOWN, + DeliveryAttemptService.NO_CONFIRMATION, + "51job已点击批量投递,但平台未返回可映射到逐条岗位的确认回执"); + } catch (Exception e) { + log.error("记录 51job 待对账状态失败,任务将以失败结束并在下次运行恢复", e); + throw e; + } } catch (Exception e) { log.error("投递当前页面失败", e); + if (!selectedAttempts.isEmpty()) { + try { + resolveJob51Attempts( + selectedAttempts, + platformActionStarted ? DeliveryAttemptService.State.UNKNOWN : DeliveryAttemptService.State.FAILED, + platformActionStarted ? DeliveryAttemptService.NO_CONFIRMATION : DeliveryAttemptService.PRE_ACTION_ERROR, + platformActionStarted ? "51job批量动作后流程异常,平台结果未知" : "51job批量动作前流程异常" + ); + } catch (Exception recoveryError) { + log.error("51job投递 attempt 补偿写入失败,需要人工检查 REQUESTED 记录", recoveryError); + sendProgress("严重告警:51job平台动作后的待对账记录写入失败,请停止重试并人工检查", null, null); + } + } + throw e instanceof RuntimeException runtimeException + ? runtimeException + : new IllegalStateException("51job当前页面投递失败", e); + } + } + + private void resolveJob51Attempts(java.util.Map attempts, + DeliveryAttemptService.State state, + String evidence, + String message) { + List failedJobIds = new ArrayList<>(); + attempts.forEach((jobId, requestKey) -> { + boolean resolved = false; + for (int attempt = 0; attempt < 2 && !resolved; attempt++) { + try { + DeliveryAttemptService.ResolutionResult result = deliveryAttemptService.resolveLegacy( + "51job", jobId, requestKey, state, evidence, message); + resolved = result.accepted(); + if (!resolved) log.warn("51job投递 attempt 被拒绝 jobId={}: {}", jobId, result.message()); + } catch (Exception e) { + log.warn("51job投递 attempt 第{}次回写失败 jobId={}: {}", attempt + 1, jobId, e.getMessage()); + } + } + if (!resolved) failedJobIds.add(jobId); + }); + if (!failedJobIds.isEmpty()) { + throw new IllegalStateException("51job投递 attempt 回写失败 jobIds=" + failedJobIds); + } + } + + private Long collectJobIdForCheckbox(Locator checkbox) { + try { + Object raw = checkbox.evaluate(""" + el => { + const root = el.closest('[data-jobid], [data-analysis-jobid], [data-job-id], li, [class*="job"]'); + if (!root) return ''; + const direct = root.getAttribute('data-jobid') + || root.getAttribute('data-analysis-jobid') + || root.getAttribute('data-job-id'); + if (direct) return direct; + const anchor = root.querySelector("a[href*='/pc/jobdetail'], a[href*='jobs.51job.com'], a.jname[href]"); + return anchor ? (anchor.getAttribute('href') || '') : ''; + } + """); + String value = raw == null ? "" : String.valueOf(raw).trim(); + if (value.matches("\\d+")) return Long.parseLong(value); + return parseJobIdFromHref(value); + } catch (Exception e) { + log.debug("51job 已选岗位无法解析 jobId,不写入投递状态: {}", e.getMessage()); + return null; } } /** * 点击批量投递按钮 */ - private void clickBatchDeliverButton() { + private boolean clickBatchDeliverButton() { int retryCount = 0; boolean success = false; while (!success && retryCount < 5) { try { if (shouldStop()) { - return; + return false; } // 查找批量投递按钮 @@ -327,6 +463,7 @@ private void clickBatchDeliverButton() { PlaywrightUtil.sleep(1); } } + return success; } /** @@ -741,11 +878,6 @@ private Long parseJobIdFromHref(String href) { if (m2.find()) { return Long.parseLong(m2.group(1)); } - // 兜底:从路径段中找较长数字片段 - java.util.regex.Matcher m3 = java.util.regex.Pattern.compile("(\\d{5,})").matcher(href); - if (m3.find()) { - return Long.parseLong(m3.group(1)); - } } catch (Exception ignored) {} return null; } diff --git a/src/main/java/com/getjobs/worker/liepin/Liepin.java b/src/main/java/com/getjobs/worker/liepin/Liepin.java index 2996bb6..24e4b26 100644 --- a/src/main/java/com/getjobs/worker/liepin/Liepin.java +++ b/src/main/java/com/getjobs/worker/liepin/Liepin.java @@ -2,6 +2,7 @@ import com.getjobs.worker.utils.PlaywrightUtil; import com.getjobs.application.service.LiepinService; +import com.getjobs.application.service.DeliveryAttemptService; import com.getjobs.application.entity.LiepinEntity; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -49,6 +50,8 @@ public class Liepin { private Page page; @Autowired private LiepinService liepinService; + @Autowired + private DeliveryAttemptService deliveryAttemptService; public interface ProgressCallback { void onProgress(String message, Integer current, Integer total); @@ -334,17 +337,28 @@ private void submitJob() { } // 获取当前岗位卡片(用于后续操作与缺省展示) Locator currentJobCard = page.locator(JOB_CARDS).nth(i); - // 从接口数据获取展示所需字段,若接口数据缺失则使用缺省占位,仍尝试打招呼 + Long jobIdForUpdate = extractJobIdFromCard(currentJobCard); + if (jobIdForUpdate == null) { + log.warn("猎聘当前卡片无法解析 jobId,拒绝按 API 数组下标猜测岗位,已跳过该卡片"); + continue; + } + LiepinEntity matchedEntity = null; + for (LiepinEntity candidate : lastApiEntities) { + if (candidate != null && java.util.Objects.equals(candidate.getJobId(), jobIdForUpdate)) { + matchedEntity = candidate; + break; + } + } + // 只使用与卡片 jobId 精确匹配的接口数据作为展示信息。 String jobName = null; String companyName = null; String salary = null; String recruiterName = null; - if (i < lastApiEntities.size()) { - LiepinEntity apiEntity = lastApiEntities.get(i); - jobName = safeText(apiEntity.getJobTitle()); - companyName = safeText(apiEntity.getCompName()); - salary = safeText(apiEntity.getJobSalaryText()); - recruiterName = safeText(apiEntity.getHrName()); + if (matchedEntity != null) { + jobName = safeText(matchedEntity.getJobTitle()); + companyName = safeText(matchedEntity.getCompName()); + salary = safeText(matchedEntity.getJobSalaryText()); + recruiterName = safeText(matchedEntity.getHrName()); } if (recruiterName == null) recruiterName = "HR"; if (jobName == null) jobName = "岗位"; @@ -496,17 +510,13 @@ private void submitJob() { continue; } - // 提取 jobId(用于更新投递状态) - Long jobIdForUpdate = null; - if (i < lastApiEntities.size()) { - jobIdForUpdate = lastApiEntities.get(i).getJobId(); - } - if (jobIdForUpdate == null) { - jobIdForUpdate = extractJobIdFromCard(currentJobCard); - } - // 检查按钮文本并点击 if (button != null && buttonText.contains("聊一聊")) { + DeliveryAttemptService.RequestResult attempt = deliveryAttemptService.requestLegacy("liepin", jobIdForUpdate); + if (!attempt.created()) { + recoverInterruptedAttempt(jobIdForUpdate, attempt); + return; + } try { // 在点击按钮前进行鼠标微调,先向右移动2像素,再向左移动2像素 try { @@ -540,7 +550,7 @@ private void submitJob() { button.click(); // PlaywrightUtil.sleep(1); // 等待点击响应 - // 猎聘会自动发送打招呼语,所以我们只需要关闭聊天窗口 + // 猎聘会自动发送打招呼语;出现聊天窗口只证明动作已发起,不能等同平台确认成功。 try { // 等待聊天界面加载 page.waitForSelector(CHAT_HEADER, new Page.WaitForSelectorOptions().setTimeout(3000)); @@ -554,28 +564,48 @@ private void submitJob() { resultList.add(sb.append("【").append(companyName).append(" ").append(jobName).append(" ").append(salary).append(" ").append(recruiterName).append(" ").append("】").toString()); sb.setLength(0); - // 点击成功后标记为已投递 - if (jobIdForUpdate != null) { - liepinService.markDelivered(jobIdForUpdate); - } + resolveLiepinAttempt( + "liepin", jobIdForUpdate, attempt.requestKey(), + DeliveryAttemptService.State.UNKNOWN, + DeliveryAttemptService.CHAT_SURFACE_ONLY, + "猎聘已打开聊天窗口,但没有可验证的平台成功回执" + ); } catch (Exception e) { - log.warn("关闭聊天窗口失败,但投递可能已成功: {}", e.getMessage()); - // 即使关闭失败,也认为投递成功 + log.warn("猎聘点击后无法确认聊天窗口结果,记录为待对账: {}", e.getMessage()); resultList.add(sb.append("【").append(companyName).append(" ").append(jobName).append(" ").append(salary).append(" ").append(recruiterName).append(" ").append("】").toString()); sb.setLength(0); - if (jobIdForUpdate != null) { - liepinService.markDelivered(jobIdForUpdate); - } + resolveLiepinAttempt( + "liepin", jobIdForUpdate, attempt.requestKey(), + DeliveryAttemptService.State.UNKNOWN, + DeliveryAttemptService.NO_CONFIRMATION, + "猎聘按钮已点击,但聊天窗口状态无法确认" + ); } } catch (Exception e) { log.error("点击按钮失败: {}", e.getMessage()); + resolveLiepinAttempt( + "liepin", jobIdForUpdate, attempt.requestKey(), + DeliveryAttemptService.State.UNKNOWN, + DeliveryAttemptService.NO_CONFIRMATION, + "猎聘点击过程异常,平台结果未知: " + e.getMessage() + ); } } else { - // 如果按钮是“继续聊”,视为已投递 + // “继续聊”是明确的平台既有会话状态,可作为确认凭据导入本次 attempt。 if (button != null && buttonText.contains("继续聊") && jobIdForUpdate != null) { - liepinService.markDelivered(jobIdForUpdate); + DeliveryAttemptService.RequestResult attempt = deliveryAttemptService.requestLegacy("liepin", jobIdForUpdate); + if (attempt.created()) { + resolveLiepinAttempt( + "liepin", jobIdForUpdate, attempt.requestKey(), + DeliveryAttemptService.State.CONFIRMED, + DeliveryAttemptService.EXISTING_CONVERSATION, + "猎聘页面明确显示继续聊" + ); + } else { + recoverInterruptedAttempt(jobIdForUpdate, attempt); + } } if (button != null) { log.debug("跳过岗位(按钮文本不匹配): 【{}】的【{}·{}】岗位,按钮文本: '{}'", companyName, jobName, salary, buttonText); @@ -586,6 +616,36 @@ private void submitJob() { } } + private void recoverInterruptedAttempt(Long jobId, DeliveryAttemptService.RequestResult attempt) { + if (attempt.accepted() && attempt.state() == DeliveryAttemptService.State.REQUESTED) { + DeliveryAttemptService.ResolutionResult recovered = deliveryAttemptService.resolveLegacy( + "liepin", jobId, attempt.requestKey(), + DeliveryAttemptService.State.UNKNOWN, + DeliveryAttemptService.NO_CONFIRMATION, + "检测到上次猎聘投递流程中断,已转为人工待对账" + ); + if (!recovered.accepted()) { + throw new IllegalStateException("猎聘中断 attempt 无法恢复: " + recovered.message()); + } + log.warn("猎聘检测到上次中断的投递记录 jobId={},已转为待对账且未重复点击平台", jobId); + return; + } + log.info("跳过猎聘非首次投递 jobId={}: {}", jobId, attempt.message()); + } + + private void resolveLiepinAttempt(String platform, + Long jobId, + String requestKey, + DeliveryAttemptService.State state, + String evidence, + String message) { + DeliveryAttemptService.ResolutionResult result = deliveryAttemptService.resolveLegacy( + platform, jobId, requestKey, state, evidence, message); + if (!result.accepted()) { + throw new IllegalStateException("猎聘投递 attempt 回写被拒绝: " + result.message()); + } + } + // 从岗位卡片的 data 属性中提取 jobId(兼容 lastApiEntities 缺失场景) private Long extractJobIdFromCard(Locator card) { try { diff --git a/src/main/java/com/getjobs/worker/service/Job51JobService.java b/src/main/java/com/getjobs/worker/service/Job51JobService.java index 291db06..21da014 100644 --- a/src/main/java/com/getjobs/worker/service/Job51JobService.java +++ b/src/main/java/com/getjobs/worker/service/Job51JobService.java @@ -93,8 +93,8 @@ public void executeDelivery(Consumer progressCallback) { int deliveredCount = job51.execute(); - progressCallback.accept(JobProgressMessage.success(PLATFORM, - String.format("投递任务完成,共投递%d个职位", deliveredCount))); + progressCallback.accept(JobProgressMessage.warning(PLATFORM, + String.format("51job已发起%d个候选动作;平台未提供逐条确认,结果已进入待对账状态", deliveredCount))); } catch (Exception e) { log.error("51job投递任务执行失败", e); progressCallback.accept(JobProgressMessage.error(PLATFORM, "投递失败: " + e.getMessage())); diff --git a/src/main/java/com/getjobs/worker/service/LiepinJobService.java b/src/main/java/com/getjobs/worker/service/LiepinJobService.java index 83599ed..8693038 100644 --- a/src/main/java/com/getjobs/worker/service/LiepinJobService.java +++ b/src/main/java/com/getjobs/worker/service/LiepinJobService.java @@ -86,8 +86,8 @@ public void executeDelivery(Consumer progressCallback) { int deliveredCount = liepin.execute(); - progressCallback.accept(JobProgressMessage.success(PLATFORM, - String.format("投递任务完成,共发起%d个聊天", deliveredCount))); + progressCallback.accept(JobProgressMessage.warning(PLATFORM, + String.format("猎聘已发起%d个聊天动作;只有明确既有会话才记为确认,其余进入待对账", deliveredCount))); } catch (Exception e) { log.error("猎聘投递任务执行失败", e); progressCallback.accept(JobProgressMessage.error(PLATFORM, "投递失败: " + e.getMessage())); diff --git a/src/main/java/db/migration/V6__add_delivery_attempt_state.java b/src/main/java/db/migration/V6__add_delivery_attempt_state.java new file mode 100644 index 0000000..462432b --- /dev/null +++ b/src/main/java/db/migration/V6__add_delivery_attempt_state.java @@ -0,0 +1,123 @@ +package db.migration; + +import org.flywaydb.core.api.migration.BaseJavaMigration; +import org.flywaydb.core.api.migration.Context; + +import java.sql.ResultSet; +import java.sql.Statement; + +/** + * 增加可审计、可幂等的投递 attempt;旧状态字段继续作为兼容读模型。 + */ +public class V6__add_delivery_attempt_state extends BaseJavaMigration { + @Override + public void migrate(Context context) throws Exception { + try (Statement statement = context.getConnection().createStatement()) { + statement.execute(""" + CREATE TABLE IF NOT EXISTS delivery_attempt ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_key TEXT NOT NULL UNIQUE CHECK (TRIM(request_key) <> ''), + platform TEXT NOT NULL CHECK (platform IN ('boss', 'zhilian', 'liepin', '51job')), + profile_id INTEGER, + job_key TEXT NOT NULL, + job_row_id INTEGER NOT NULL CHECK (job_row_id > 0), + state TEXT NOT NULL CHECK (state IN ('REQUESTED', 'CONFIRMED', 'FAILED', 'UNKNOWN')), + evidence TEXT, + message TEXT, + failure_type TEXT, + failure_reason TEXT, + requested_at DATETIME NOT NULL, + resolved_at DATETIME, + updated_at DATETIME NOT NULL + ) + """); + statement.execute("CREATE INDEX IF NOT EXISTS idx_delivery_attempt_job " + + "ON delivery_attempt(platform, profile_id, job_row_id, id)"); + statement.execute("CREATE INDEX IF NOT EXISTS idx_delivery_attempt_state_updated " + + "ON delivery_attempt(state, updated_at)"); + + boolean liepinStatusAdded = addColumn(statement, "liepin_data", "delivery_status", "TEXT DEFAULT '未投递'"); + boolean job51StatusAdded = addColumn(statement, "job51_data", "delivery_status", "TEXT DEFAULT '未投递'"); + backfillBinaryStatus(statement, "liepin_data", liepinStatusAdded); + backfillBinaryStatus(statement, "job51_data", job51StatusAdded); + + importLegacyBoss(statement); + importLegacyZhilian(statement); + importLegacyBinaryPlatform(statement, "liepin", "liepin_data"); + importLegacyBinaryPlatform(statement, "51job", "job51_data"); + } + } + + private void importLegacyBoss(Statement statement) throws Exception { + statement.executeUpdate(""" + INSERT OR IGNORE INTO delivery_attempt ( + request_key, platform, profile_id, job_key, job_row_id, state, + evidence, message, requested_at, resolved_at, updated_at + ) + SELECT 'legacy:boss:' || id, 'boss', profile_id, + COALESCE(NULLIF(encrypt_id, ''), CAST(id AS TEXT)), id, + CASE WHEN TRIM(delivery_status) = '已投递' THEN 'CONFIRMED' ELSE 'FAILED' END, + 'LEGACY_STATUS_IMPORT', '由 V6 导入旧投递状态', + COALESCE(NULLIF(TRIM(updated_at), ''), NULLIF(TRIM(created_at), ''), CURRENT_TIMESTAMP), + COALESCE(NULLIF(TRIM(updated_at), ''), NULLIF(TRIM(created_at), ''), CURRENT_TIMESTAMP), + COALESCE(NULLIF(TRIM(updated_at), ''), NULLIF(TRIM(created_at), ''), CURRENT_TIMESTAMP) + FROM boss_data + WHERE TRIM(COALESCE(delivery_status, '')) IN ('已投递', '投递失败') + """); + } + + private void importLegacyZhilian(Statement statement) throws Exception { + statement.executeUpdate(""" + INSERT OR IGNORE INTO delivery_attempt ( + request_key, platform, profile_id, job_key, job_row_id, state, + evidence, message, requested_at, resolved_at, updated_at + ) + SELECT 'legacy:zhilian:' || id, 'zhilian', profile_id, + COALESCE(NULLIF(job_id, ''), CAST(id AS TEXT)), id, + CASE WHEN TRIM(delivery_status) = '已投递' THEN 'CONFIRMED' ELSE 'FAILED' END, + 'LEGACY_STATUS_IMPORT', '由 V6 导入旧投递状态', + COALESCE(NULLIF(TRIM(update_time), ''), NULLIF(TRIM(create_time), ''), CURRENT_TIMESTAMP), + COALESCE(NULLIF(TRIM(update_time), ''), NULLIF(TRIM(create_time), ''), CURRENT_TIMESTAMP), + COALESCE(NULLIF(TRIM(update_time), ''), NULLIF(TRIM(create_time), ''), CURRENT_TIMESTAMP) + FROM zhilian_data + WHERE TRIM(COALESCE(delivery_status, '')) IN ('已投递', '投递失败') + """); + } + + private void importLegacyBinaryPlatform(Statement statement, String platform, String table) throws Exception { + statement.executeUpdate("INSERT OR IGNORE INTO delivery_attempt (" + + "request_key, platform, profile_id, job_key, job_row_id, state, " + + "evidence, message, requested_at, resolved_at, updated_at) " + + "SELECT 'legacy:" + platform + ":' || job_id, '" + platform + "', NULL, " + + "CAST(job_id AS TEXT), job_id, 'CONFIRMED', 'LEGACY_STATUS_IMPORT', " + + "'由 V6 导入旧投递状态', COALESCE(NULLIF(TRIM(update_time), ''), NULLIF(TRIM(create_time), ''), CURRENT_TIMESTAMP), " + + "COALESCE(NULLIF(TRIM(update_time), ''), NULLIF(TRIM(create_time), ''), CURRENT_TIMESTAMP), " + + "COALESCE(NULLIF(TRIM(update_time), ''), NULLIF(TRIM(create_time), ''), CURRENT_TIMESTAMP) " + + "FROM " + table + " WHERE delivered = 1"); + } + + private void backfillBinaryStatus(Statement statement, String table, boolean columnAdded) throws Exception { + String predicate = columnAdded ? "1=1" : "delivery_status IS NULL OR TRIM(delivery_status) = ''"; + statement.executeUpdate("UPDATE " + table + " SET delivery_status = " + + "CASE WHEN delivered = 1 THEN '已投递' ELSE '未投递' END WHERE " + predicate); + } + + private boolean addColumn(Statement statement, String table, String column, String definition) throws Exception { + if (!columnExists(statement, table, column)) { + statement.execute("ALTER TABLE " + table + " ADD COLUMN " + column + " " + definition); + return true; + } + return false; + } + + private boolean columnExists(Statement statement, String table, String column) throws Exception { + try (ResultSet resultSet = statement.executeQuery("PRAGMA table_info('" + table + "')")) { + while (resultSet.next()) { + if (column.equalsIgnoreCase(resultSet.getString("name"))) { + return true; + } + } + } + return false; + } +} diff --git a/src/test/java/com/getjobs/application/controller/BossAnalyticsControllerTest.java b/src/test/java/com/getjobs/application/controller/BossAnalyticsControllerTest.java index 8d82782..6f7d781 100644 --- a/src/test/java/com/getjobs/application/controller/BossAnalyticsControllerTest.java +++ b/src/test/java/com/getjobs/application/controller/BossAnalyticsControllerTest.java @@ -1,10 +1,12 @@ package com.getjobs.application.controller; import com.getjobs.application.dto.ConfirmBatchRequest; +import com.getjobs.application.dto.DeliveryResultRequest; import com.getjobs.application.entity.BossJobDataEntity; import com.getjobs.application.service.BossService; import com.getjobs.application.service.BossStatsService; import com.getjobs.application.service.DeliveryStatus; +import com.getjobs.application.service.DeliveryAttemptService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -22,12 +24,14 @@ class BossAnalyticsControllerTest { private BossService bossService; + private DeliveryAttemptService deliveryAttemptService; private BossAnalyticsController controller; @BeforeEach void setUp() { bossService = mock(BossService.class); - controller = new BossAnalyticsController(bossService, mock(BossStatsService.class)); + deliveryAttemptService = mock(DeliveryAttemptService.class); + controller = new BossAnalyticsController(bossService, mock(BossStatsService.class), deliveryAttemptService); } @Test @@ -69,6 +73,9 @@ void manualOverrideDeduplicatesIdsAndOnlyReturnsValidAiNotMatchJobs() { when(bossService.getBossJobById(3L)).thenReturn(missingUrl); when(bossService.getBossJobById(4L)).thenReturn(delivered); when(bossService.getBossJobById(999L)).thenReturn(null); + when(deliveryAttemptService.requestBoss(1L, 1L, "boss-1", true)).thenReturn( + new DeliveryAttemptService.RequestResult( + true, true, "request-1", DeliveryAttemptService.State.REQUESTED, "投递请求已创建")); ConfirmBatchRequest request = new ConfirmBatchRequest(); request.setManualOverrideAiNotMatch(true); @@ -85,6 +92,7 @@ void manualOverrideDeduplicatesIdsAndOnlyReturnsValidAiNotMatchJobs() { assertThat(tasks).singleElement().satisfies(task -> { assertThat(task).containsEntry("id", 1L); assertThat(task).containsEntry("url", "https://www.zhipin.com/job_detail/1.html"); + assertThat(task).containsEntry("requestKey", "request-1"); }); } @@ -105,9 +113,52 @@ void manualOverrideRejectsEmptyOrConflictingModes() { .containsEntry("count", 0); } + @Test + void deliveryCallbackPassesRequestIdentityAndEvidenceToAttemptService() { + BossJobDataEntity current = job(5L, DeliveryStatus.DELIVERY_REQUESTED, "https://www.zhipin.com/job_detail/5.html"); + when(bossService.getBossJobById(5L)).thenReturn(current); + when(deliveryAttemptService.resolve( + "boss", 1L, 5L, "request-5", DeliveryAttemptService.State.CONFIRMED, + DeliveryAttemptService.PLATFORM_STATUS_TEXT, "页面显示已沟通", null, "页面显示已沟通" + )).thenReturn(new DeliveryAttemptService.ResolutionResult( + true, false, DeliveryAttemptService.State.CONFIRMED, "投递结果已写入")); + + DeliveryResultRequest request = new DeliveryResultRequest(); + request.setRequestKey("request-5"); + request.setOutcome("CONFIRMED"); + request.setEvidence(DeliveryAttemptService.PLATFORM_STATUS_TEXT); + request.setMessage("页面显示已沟通"); + + Map response = controller.updateDeliveryResult(5L, request); + + assertThat(response) + .containsEntry("success", true) + .containsEntry("state", "CONFIRMED"); + } + + @Test + void confirmResumesTheSameRequestedAttemptAfterResponseLoss() { + BossJobDataEntity current = job(6L, DeliveryStatus.DELIVERY_REQUESTED, "https://www.zhipin.com/job_detail/6.html"); + when(bossService.getBossJobById(6L)).thenReturn(current); + when(deliveryAttemptService.requestBoss(6L, 1L, "boss-6", false)).thenReturn( + new DeliveryAttemptService.RequestResult( + true, false, "request-6", DeliveryAttemptService.State.REQUESTED, "投递请求已存在")); + + Map response = controller.confirmPendingJob(6L); + + assertThat(response) + .containsEntry("success", true) + .containsEntry("resumed", true); + @SuppressWarnings("unchecked") + Map task = (Map) response.get("task"); + assertThat(task).containsEntry("requestKey", "request-6"); + } + private BossJobDataEntity job(Long id, String status, String url) { BossJobDataEntity job = new BossJobDataEntity(); job.setId(id); + job.setProfileId(1L); + job.setEncryptId("boss-" + id); job.setDeliveryStatus(status); job.setJobUrl(url); job.setCompanyName("测试公司"); diff --git a/src/test/java/com/getjobs/application/controller/ZhilianDeliveryControllerTest.java b/src/test/java/com/getjobs/application/controller/ZhilianDeliveryControllerTest.java new file mode 100644 index 0000000..4cc64b7 --- /dev/null +++ b/src/test/java/com/getjobs/application/controller/ZhilianDeliveryControllerTest.java @@ -0,0 +1,58 @@ +package com.getjobs.application.controller; + +import com.getjobs.application.dto.DeliveryResultRequest; +import com.getjobs.application.entity.ZhilianJobDataEntity; +import com.getjobs.application.service.DeliveryAttemptService; +import com.getjobs.application.service.DeliveryStatus; +import com.getjobs.application.service.ZhilianService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ZhilianDeliveryControllerTest { + private ZhilianService zhilianService; + private DeliveryAttemptService deliveryAttemptService; + private ZhilianController controller; + + @BeforeEach + void setUp() { + zhilianService = mock(ZhilianService.class); + deliveryAttemptService = mock(DeliveryAttemptService.class); + controller = new ZhilianController(); + ReflectionTestUtils.setField(controller, "zhilianService", zhilianService); + ReflectionTestUtils.setField(controller, "deliveryAttemptService", deliveryAttemptService); + } + + @Test + void deliveryCallbackPassesRequestIdentityAndEvidenceToAttemptService() { + ZhilianJobDataEntity job = new ZhilianJobDataEntity(); + job.setId(8L); + job.setProfileId(1L); + job.setJobId("zhilian-8"); + job.setDeliveryStatus(DeliveryStatus.DELIVERY_REQUESTED); + when(zhilianService.getZhilianJobById(8L)).thenReturn(job); + when(deliveryAttemptService.resolve( + "zhilian", 1L, 8L, "request-8", DeliveryAttemptService.State.UNKNOWN, + DeliveryAttemptService.NO_CONFIRMATION, "未出现明确结果", null, "未出现明确结果" + )).thenReturn(new DeliveryAttemptService.ResolutionResult( + true, false, DeliveryAttemptService.State.UNKNOWN, "投递结果已写入")); + + DeliveryResultRequest request = new DeliveryResultRequest(); + request.setRequestKey("request-8"); + request.setOutcome("UNKNOWN"); + request.setEvidence(DeliveryAttemptService.NO_CONFIRMATION); + request.setMessage("未出现明确结果"); + + Map response = controller.updateZhilianDeliveryResult(8L, request); + + assertThat(response) + .containsEntry("success", true) + .containsEntry("state", "UNKNOWN"); + } +} diff --git a/src/test/java/com/getjobs/application/service/AnalysisClearSequenceSafetyTest.java b/src/test/java/com/getjobs/application/service/AnalysisClearSequenceSafetyTest.java new file mode 100644 index 0000000..8f9319e --- /dev/null +++ b/src/test/java/com/getjobs/application/service/AnalysisClearSequenceSafetyTest.java @@ -0,0 +1,54 @@ +package com.getjobs.application.service; + +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; + +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AnalysisClearSequenceSafetyTest { + @TempDir + Path tempDir; + + @Test + void clearingAnalysisPreservesAttemptHistoryAndDoesNotReuseBossOrZhilianRowIds() { + DriverManagerDataSource dataSource = new DriverManagerDataSource( + "jdbc:sqlite:" + tempDir.resolve("clear-safety.db").toAbsolutePath()); + Flyway.configure() + .dataSource(dataSource) + .locations("classpath:db/migration") + .load() + .migrate(); + JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource); + jdbcTemplate.update("INSERT INTO profile(id, name, is_active) VALUES (1, 'profile', 1)"); + jdbcTemplate.update("INSERT INTO boss_data(id, profile_id, encrypt_id, delivery_status) VALUES (10, 1, 'boss-old', '投递结果待确认')"); + jdbcTemplate.update("INSERT INTO zhilian_data(id, profile_id, job_id, delivery_status) VALUES (20, 1, 'zhilian-old', '投递结果待确认')"); + jdbcTemplate.update("INSERT INTO delivery_attempt " + + "(request_key, platform, profile_id, job_key, job_row_id, state, requested_at, updated_at) " + + "VALUES ('boss-old-attempt', 'boss', 1, 'boss-old', 10, 'UNKNOWN', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), " + + "('zhilian-old-attempt', 'zhilian', 1, 'zhilian-old', 20, 'UNKNOWN', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"); + + ProfileService profileService = mock(ProfileService.class); + when(profileService.getCurrentProfileId()).thenReturn(1L); + BossService bossService = new BossService(null, null, null, null, null, dataSource, profileService); + ZhilianService zhilianService = new ZhilianService(null, null, null, dataSource, profileService); + + assertThat(bossService.clearBossAnalysisData()).containsEntry("success", true); + assertThat(zhilianService.clearZhilianAnalysisData()).containsEntry("success", true); + jdbcTemplate.update("INSERT INTO boss_data(profile_id, encrypt_id, delivery_status) VALUES (1, 'boss-new', '待确认')"); + jdbcTemplate.update("INSERT INTO zhilian_data(profile_id, job_id, delivery_status) VALUES (1, 'zhilian-new', '待确认')"); + + assertThat(jdbcTemplate.queryForObject("SELECT id FROM boss_data WHERE encrypt_id='boss-new'", Long.class)) + .isGreaterThan(10L); + assertThat(jdbcTemplate.queryForObject("SELECT id FROM zhilian_data WHERE job_id='zhilian-new'", Long.class)) + .isGreaterThan(20L); + assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM delivery_attempt", Integer.class)) + .isEqualTo(2); + } +} diff --git a/src/test/java/com/getjobs/application/service/DatabaseMigrationRehearsalTest.java b/src/test/java/com/getjobs/application/service/DatabaseMigrationRehearsalTest.java index 47166b9..2aa66c3 100644 --- a/src/test/java/com/getjobs/application/service/DatabaseMigrationRehearsalTest.java +++ b/src/test/java/com/getjobs/application/service/DatabaseMigrationRehearsalTest.java @@ -58,7 +58,10 @@ void migratesIsolatedCopyWithoutChangingSourceDatabase() throws Exception { DatabaseSchemaService.validateSchema(connection); assertThat(scalarText(connection, "PRAGMA integrity_check")).isEqualTo("ok"); assertThat(scalarLong(connection, - "SELECT COUNT(*) FROM flyway_schema_history WHERE success=1 AND version='5'")) + "SELECT COUNT(*) FROM flyway_schema_history WHERE success=1 AND version='6'")) + .isEqualTo(1L); + assertThat(scalarLong(connection, + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='delivery_attempt'")) .isEqualTo(1L); } assertThat(tableCounts(rehearsalUrl)).containsAllEntriesOf(countsBefore); diff --git a/src/test/java/com/getjobs/application/service/DatabaseMigrationTest.java b/src/test/java/com/getjobs/application/service/DatabaseMigrationTest.java index e9443db..d399716 100644 --- a/src/test/java/com/getjobs/application/service/DatabaseMigrationTest.java +++ b/src/test/java/com/getjobs/application/service/DatabaseMigrationTest.java @@ -18,7 +18,7 @@ class DatabaseMigrationTest { Path tempDir; @Test - void freshDatabaseMigratesThroughV5AndMatchesSchemaContract() throws Exception { + void freshDatabaseMigratesThroughV6AndMatchesSchemaContract() throws Exception { String url = sqliteUrl(tempDir.resolve("fresh.db")); Flyway flyway = flyway(url); @@ -27,11 +27,49 @@ void freshDatabaseMigratesThroughV5AndMatchesSchemaContract() 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='5'")) + "SELECT COUNT(*) FROM flyway_schema_history WHERE success=1 AND version='6'")) .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"); + assertThat(columns(connection, "liepin_data")).contains("delivery_status"); + assertThat(columns(connection, "job51_data")).contains("delivery_status"); + assertThat(tableExists(connection, "delivery_attempt")).isTrue(); + } + } + + @Test + void v6ImportsLegacyDeliveryFactsWithoutInventingNewConfirmations() throws Exception { + String url = sqliteUrl(tempDir.resolve("legacy-delivery.db")); + Flyway flyway = Flyway.configure() + .dataSource(url, null, null) + .locations("classpath:db/migration") + .target("5") + .load(); + flyway.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, delivery_status, created_at) " + + "VALUES (10, 1, 'boss-key', '已投递', CURRENT_TIMESTAMP)"); + statement.execute("INSERT INTO zhilian_data(id, profile_id, job_id, delivery_status, create_time) " + + "VALUES (20, 1, 'zhilian-key', '投递失败', CURRENT_TIMESTAMP)"); + statement.execute("INSERT INTO liepin_data(job_id, delivered, create_time) VALUES (30, 1, CURRENT_TIMESTAMP)"); + statement.execute("INSERT INTO job51_data(job_id, delivered, create_time) VALUES (40, 0, CURRENT_TIMESTAMP)"); + } + + flyway(url).migrate(); + + try (Connection connection = DriverManager.getConnection(url)) { + assertThat(scalar(connection, "SELECT COUNT(*) FROM delivery_attempt WHERE state='CONFIRMED'")) + .isEqualTo(2L); + assertThat(scalar(connection, "SELECT COUNT(*) FROM delivery_attempt WHERE state='FAILED'")) + .isEqualTo(1L); + assertThat(scalar(connection, "SELECT COUNT(*) FROM delivery_attempt WHERE platform='51job'")) + .isZero(); + assertThat(scalar(connection, "SELECT COUNT(*) FROM liepin_data WHERE delivery_status='已投递'")) + .isEqualTo(1L); + assertThat(scalar(connection, "SELECT COUNT(*) FROM job51_data WHERE delivery_status='未投递'")) + .isEqualTo(1L); } } diff --git a/src/test/java/com/getjobs/application/service/DeliveryAttemptServiceTest.java b/src/test/java/com/getjobs/application/service/DeliveryAttemptServiceTest.java new file mode 100644 index 0000000..85bae87 --- /dev/null +++ b/src/test/java/com/getjobs/application/service/DeliveryAttemptServiceTest.java @@ -0,0 +1,206 @@ +package com.getjobs.application.service; + +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; + +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class DeliveryAttemptServiceTest { + @TempDir + Path tempDir; + + private JdbcTemplate jdbcTemplate; + private DeliveryAttemptService service; + + @BeforeEach + void setUp() { + String url = "jdbc:sqlite:" + tempDir.resolve("delivery.db").toAbsolutePath(); + DriverManagerDataSource dataSource = new DriverManagerDataSource(url); + Flyway.configure() + .dataSource(dataSource) + .locations("classpath:db/migration") + .load() + .migrate(); + jdbcTemplate = new JdbcTemplate(dataSource); + service = new DeliveryAttemptService(jdbcTemplate, new DataSourceTransactionManager(dataSource)); + service.validateSchema(); + jdbcTemplate.update("INSERT INTO profile(id, name, is_active) VALUES (1, 'profile', 1)"); + } + + @Test + void confirmedAttemptIsIdempotentAndRejectsLateFailure() { + insertBoss(10, DeliveryStatus.WAITING_CONFIRM); + + DeliveryAttemptService.RequestResult requested = service.requestBoss(10, 1, "boss-10", false); + assertThat(requested.created()).isTrue(); + assertThat(status("boss_data", 10)).isEqualTo(DeliveryStatus.DELIVERY_REQUESTED); + + DeliveryAttemptService.ResolutionResult confirmed = service.resolve( + "boss", 1L, 10, requested.requestKey(), DeliveryAttemptService.State.CONFIRMED, + DeliveryAttemptService.PLATFORM_STATUS_TEXT, "页面显示已沟通", null, null); + DeliveryAttemptService.ResolutionResult duplicate = service.resolve( + "boss", 1L, 10, requested.requestKey(), DeliveryAttemptService.State.CONFIRMED, + DeliveryAttemptService.PLATFORM_STATUS_TEXT, "重复回调", null, null); + DeliveryAttemptService.ResolutionResult lateFailure = service.resolve( + "boss", 1L, 10, requested.requestKey(), DeliveryAttemptService.State.FAILED, + DeliveryAttemptService.PLATFORM_ERROR, "延迟失败", "NETWORK_ERROR", "延迟失败"); + + assertThat(confirmed.accepted()).isTrue(); + assertThat(duplicate.accepted()).isTrue(); + assertThat(duplicate.idempotent()).isTrue(); + assertThat(lateFailure.accepted()).isFalse(); + assertThat(status("boss_data", 10)).isEqualTo(DeliveryStatus.DELIVERED); + assertThat(attemptState(requested.requestKey())).isEqualTo("CONFIRMED"); + } + + @Test + void unknownCanBeReconciledButCannotConfirmWithoutStrongEvidence() { + insertZhilian(20, DeliveryStatus.WAITING_CONFIRM); + DeliveryAttemptService.RequestResult requested = service.requestZhilian(20, 1, "zhilian-20"); + + DeliveryAttemptService.ResolutionResult unknown = service.resolve( + "zhilian", 1L, 20, requested.requestKey(), DeliveryAttemptService.State.UNKNOWN, + DeliveryAttemptService.NO_CONFIRMATION, "响应丢失", null, null); + assertThat(unknown.accepted()).isTrue(); + assertThat(status("zhilian_data", 20)).isEqualTo(DeliveryStatus.DELIVERY_UNKNOWN); + + DeliveryAttemptService.ResolutionResult weakConfirmation = service.resolve( + "zhilian", 1L, 20, requested.requestKey(), DeliveryAttemptService.State.CONFIRMED, + DeliveryAttemptService.CHAT_SURFACE_ONLY, "只有聊天页", null, null); + DeliveryAttemptService.ResolutionResult forgedManualConfirmation = service.resolve( + "zhilian", 1L, 20, requested.requestKey(), DeliveryAttemptService.State.CONFIRMED, + DeliveryAttemptService.MANUAL_RECONCILIATION, "伪造人工证据", null, null); + DeliveryAttemptService.ResolutionResult confirmed = service.resolve( + "zhilian", 1L, 20, requested.requestKey(), DeliveryAttemptService.State.CONFIRMED, + DeliveryAttemptService.PLATFORM_STATUS_TEXT, "页面显示已投递", null, null); + + assertThat(weakConfirmation.accepted()).isFalse(); + assertThat(forgedManualConfirmation.accepted()).isFalse(); + assertThat(confirmed.accepted()).isTrue(); + assertThat(status("zhilian_data", 20)).isEqualTo(DeliveryStatus.DELIVERED); + } + + @Test + void duplicateReservationAndMismatchedCallbackAreRejected() { + insertBoss(30, DeliveryStatus.WAITING_CONFIRM); + DeliveryAttemptService.RequestResult first = service.requestBoss(30, 1, "boss-30", false); + DeliveryAttemptService.RequestResult duplicate = service.requestBoss(30, 1, "boss-30", false); + + DeliveryAttemptService.ResolutionResult wrongProfile = service.resolve( + "boss", 2L, 30, first.requestKey(), DeliveryAttemptService.State.CONFIRMED, + DeliveryAttemptService.PLATFORM_STATUS_TEXT, "错误档案", null, null); + DeliveryAttemptService.ResolutionResult missingKey = service.resolve( + "boss", 1L, 30, null, DeliveryAttemptService.State.FAILED, + DeliveryAttemptService.PRE_ACTION_ERROR, "无任务绑定", null, null); + + assertThat(first.created()).isTrue(); + assertThat(duplicate.accepted()).isTrue(); + assertThat(duplicate.created()).isFalse(); + assertThat(duplicate.requestKey()).isEqualTo(first.requestKey()); + assertThat(wrongProfile.accepted()).isFalse(); + assertThat(missingKey.accepted()).isFalse(); + assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM delivery_attempt WHERE job_row_id=30", Integer.class)) + .isEqualTo(1); + assertThat(status("boss_data", 30)).isEqualTo(DeliveryStatus.DELIVERY_REQUESTED); + } + + @Test + void staleAttemptCannotOverwriteANewerAttemptAndFailureIsTerminal() { + insertBoss(40, DeliveryStatus.WAITING_CONFIRM); + DeliveryAttemptService.RequestResult first = service.requestBoss(40, 1, "boss-40", false); + jdbcTemplate.update("UPDATE delivery_attempt SET state='UNKNOWN', updated_at=CURRENT_TIMESTAMP WHERE request_key=?", + first.requestKey()); + jdbcTemplate.update("INSERT INTO delivery_attempt " + + "(request_key, platform, profile_id, job_key, job_row_id, state, requested_at, updated_at) " + + "VALUES ('newer-request', 'boss', 1, 'boss-40', 40, 'REQUESTED', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"); + + DeliveryAttemptService.ResolutionResult stale = service.resolve( + "boss", 1L, 40, first.requestKey(), DeliveryAttemptService.State.CONFIRMED, + DeliveryAttemptService.PLATFORM_STATUS_TEXT, "旧任务延迟成功", null, null); + DeliveryAttemptService.ResolutionResult failed = service.resolve( + "boss", 1L, 40, "newer-request", DeliveryAttemptService.State.FAILED, + DeliveryAttemptService.PLATFORM_ERROR, "平台明确失败", "PLATFORM_ERROR", "平台明确失败"); + DeliveryAttemptService.ResolutionResult lateSuccess = service.resolve( + "boss", 1L, 40, "newer-request", DeliveryAttemptService.State.CONFIRMED, + DeliveryAttemptService.PLATFORM_STATUS_TEXT, "延迟成功", null, null); + + assertThat(stale.accepted()).isFalse(); + assertThat(failed.accepted()).isTrue(); + assertThat(lateSuccess.accepted()).isFalse(); + assertThat(status("boss_data", 40)).isEqualTo(DeliveryStatus.DELIVERY_FAILED); + assertThat(attemptState("newer-request")).isEqualTo("FAILED"); + } + + @Test + void unknownCanBeManuallyReconciledAndRetryCreatesANewRequestKey() { + insertBoss(50, DeliveryStatus.WAITING_CONFIRM); + DeliveryAttemptService.RequestResult first = service.requestBoss(50, 1, "boss-50", false); + assertThat(service.resolve( + "boss", 1L, 50, first.requestKey(), DeliveryAttemptService.State.UNKNOWN, + DeliveryAttemptService.NO_CONFIRMATION, "响应丢失", null, null).accepted()).isTrue(); + + DeliveryAttemptService.RequestResult retry = service.retryBoss(50, 1, "boss-50"); + + assertThat(retry.accepted()).isTrue(); + assertThat(retry.created()).isTrue(); + assertThat(retry.requestKey()).isNotEqualTo(first.requestKey()); + DeliveryAttemptService.RequestResult duplicateRetry = service.retryBoss(50, 1, "boss-50"); + assertThat(duplicateRetry.accepted()).isTrue(); + assertThat(duplicateRetry.created()).isFalse(); + assertThat(duplicateRetry.requestKey()).isEqualTo(retry.requestKey()); + assertThat(status("boss_data", 50)).isEqualTo(DeliveryStatus.DELIVERY_REQUESTED); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM delivery_attempt WHERE platform='boss' AND job_row_id=50", Integer.class)) + .isEqualTo(2); + + assertThat(service.resolve( + "boss", 1L, 50, retry.requestKey(), DeliveryAttemptService.State.UNKNOWN, + DeliveryAttemptService.NO_CONFIRMATION, "第二次结果未知", null, null).accepted()).isTrue(); + DeliveryAttemptService.ResolutionResult reconciled = service.reconcileLatest( + "boss", 1L, 50, "boss-50", DeliveryAttemptService.State.CONFIRMED, "人工核对已投递"); + assertThat(reconciled.accepted()).isTrue(); + assertThat(status("boss_data", 50)).isEqualTo(DeliveryStatus.DELIVERED); + } + + @Test + void reusedRowIdWithDifferentJobKeyIsRejected() { + insertBoss(60, DeliveryStatus.WAITING_CONFIRM); + DeliveryAttemptService.RequestResult first = service.requestBoss(60, 1, "old-job", false); + assertThat(first.created()).isTrue(); + jdbcTemplate.update("DELETE FROM boss_data WHERE id=60"); + jdbcTemplate.update("INSERT INTO boss_data(id, profile_id, encrypt_id, delivery_status, created_at, updated_at) " + + "VALUES (60, 1, 'new-job', ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", DeliveryStatus.WAITING_CONFIRM); + + DeliveryAttemptService.RequestResult reused = service.requestBoss(60, 1, "new-job", false); + + assertThat(reused.accepted()).isFalse(); + assertThat(reused.message()).contains("历史投递记录不一致"); + assertThat(status("boss_data", 60)).isEqualTo(DeliveryStatus.WAITING_CONFIRM); + } + + private void insertBoss(long id, String status) { + jdbcTemplate.update("INSERT INTO boss_data(id, profile_id, encrypt_id, delivery_status, created_at, updated_at) " + + "VALUES (?, 1, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", id, "boss-" + id, status); + } + + private void insertZhilian(long id, String status) { + jdbcTemplate.update("INSERT INTO zhilian_data(id, profile_id, job_id, delivery_status, create_time, update_time) " + + "VALUES (?, 1, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", id, "zhilian-" + id, status); + } + + private String status(String table, long id) { + return jdbcTemplate.queryForObject("SELECT delivery_status FROM " + table + " WHERE id=?", String.class, id); + } + + private String attemptState(String requestKey) { + return jdbcTemplate.queryForObject( + "SELECT state FROM delivery_attempt WHERE request_key=?", String.class, requestKey); + } +} diff --git a/src/test/java/com/getjobs/application/service/DeliveryStatusListCollectedTest.java b/src/test/java/com/getjobs/application/service/DeliveryStatusListCollectedTest.java index a681135..e1666bc 100644 --- a/src/test/java/com/getjobs/application/service/DeliveryStatusListCollectedTest.java +++ b/src/test/java/com/getjobs/application/service/DeliveryStatusListCollectedTest.java @@ -13,4 +13,16 @@ void acceptsListCollectedWithoutTreatingItAsFinalStatus() { assertThat(DeliveryStatus.isFinalStatus(DeliveryStatus.LIST_COLLECTED)) .isFalse(); } + + @Test + void locksRequestedUnknownAndTerminalDeliveryStates() { + assertThat(DeliveryStatus.isDeliveryLocked(DeliveryStatus.DELIVERY_REQUESTED)).isTrue(); + assertThat(DeliveryStatus.isDeliveryLocked(DeliveryStatus.DELIVERY_UNKNOWN)).isTrue(); + assertThat(DeliveryStatus.isDeliveryLocked(DeliveryStatus.DELIVERED)).isTrue(); + assertThat(DeliveryStatus.isDeliveryLocked(DeliveryStatus.DELIVERY_FAILED)).isTrue(); + assertThat(DeliveryStatus.protectDelivered( + DeliveryStatus.DELIVERY_UNKNOWN, + DeliveryStatus.AI_ANALYZING + )).isEqualTo(DeliveryStatus.DELIVERY_UNKNOWN); + } } diff --git a/tasks/2026-08-24-p0-3-delivery-state-safety.md b/tasks/2026-08-24-p0-3-delivery-state-safety.md new file mode 100644 index 0000000..df71669 --- /dev/null +++ b/tasks/2026-08-24-p0-3-delivery-state-safety.md @@ -0,0 +1,75 @@ +# P0.3 投递真实性与幂等回写 + +## 背景 + +审计和三路只读侦察确认,四个平台目前没有统一的“投递尝试”事实记录。Boss/智联确认接口只生成临时任务,回调只带 `success` 布尔值并直接覆盖状态;延迟失败可覆盖已投递。扩展在部分 DOM/URL 推断下会把未知结果报为成功,批量部分失败仍返回 `success=true`。猎聘/51job 旧 Playwright 链也会把点击或整页岗位误写为 `delivered=1`。 + +## 目标 + +1. 新增持久化投递 attempt,统一使用 `REQUESTED / CONFIRMED / FAILED / UNKNOWN`。 +2. 每次投递必须绑定服务端生成的 `requestKey`;回调用事务和 CAS 保证幂等、单调、不可乱序覆盖。 +3. 保留旧 `delivery_status` / `delivered` 作为兼容读模型,但只有 `CONFIRMED` 才能映射为“已投递”或 `1`。 +4. Boss/智联扩展只有明确平台证据才回写 `CONFIRMED`;点击后证据不足写 `UNKNOWN`。 +5. 批量结果返回逐条 `results` 和 confirmed/failed/unknown 计数;部分成功不再伪装为整体成功。 +6. 猎聘/51job 旧链停止无证据写 `delivered=1`,无法逐条证明时保守记录 `UNKNOWN`。 +7. `UNKNOWN` 提供人工对账和显式重试;重试必须创建新 `requestKey`,并再次提示真实平台动作风险。 +8. 清空 Boss/智联分析数据不得重置岗位自增 ID,避免历史 attempt 与新岗位发生行号碰撞。 + +## 允许修改范围 + +- Flyway V6、投递 attempt 服务、状态 DTO/常量和对应隔离测试。 +- Boss/智联确认与结果回调 Controller,以及兼容读模型更新。 +- `chrome-extension/background.js`、Boss/智联 content script 与纯 Node 测试。 +- 猎聘/51job Worker 中投递结果判定和旧状态展示。 +- 必要的前端类型、状态标签与批量结果提示。 + +## 禁止修改范围 + +- 不访问真实招聘平台,不执行真实投递,不调用真实 AI/Provider。 +- 不写入、迁移或覆盖 `db/getjobs.db`;迁移只在 `@TempDir` 和隔离副本验证。 +- 不处理 AI 队列持久化、Worker 单实例锁、Provider 重试、Profile 数据迁移或全局架构重构。 +- 不删除旧状态字段,不清洗历史投递记录,不把历史未知记录自动升级为确认成功。 + +## 已确定实现要求 + +- `requestKey` 由后端生成并进入任务、content message、callback 全链。 +- 同一 attempt 的相同结果重复提交返回幂等成功;相反终态回调拒绝且不改读模型。 +- 旧 attempt 的延迟回调不得覆盖更新 attempt 的状态。 +- 无 `requestKey`、岗位/profile 不匹配、无明确 evidence 的成功回调必须失败关闭。 +- `UNKNOWN` 可以在同一 attempt 上被更强的明确证据修正为 `CONFIRMED` 或 `FAILED`;`CONFIRMED/FAILED` 互不覆盖。 +- callback HTTP 失败必须暴露给调用方;不得用沟通页 URL 或“未检测到错误”作为确认成功。 +- 扩展返回必须携带 `persisted`;前端只把 `persisted=true` 视为后端已确认写入,否则幂等补偿为 `UNKNOWN`。 +- 旧库中已有“已投递”仅导入为 `LEGACY_STATUS_IMPORT`,不改写原业务字段。 + +## 验收标准 + +- fresh/legacy SQLite 可迁移到 V6,Schema 和历史行数完整。 +- REQUESTED→CONFIRMED/FAILED/UNKNOWN、重复同结果、相反终态、UNKNOWN 对账、stale request 均有测试。 +- Boss/智联 confirm 重复调用不产生不同 request;回调必须匹配当前 profile、岗位和 requestKey。 +- 智联无成功 DOM/有无错误均不能默认 CONFIRMED;Boss 空响应进入沟通页只能 UNKNOWN。 +- 批量结果逐条可见,存在失败或未知时整体 `success=false` 且 `partial` 正确。 +- 51job 不再把当前页全部 jobId 无条件写为已投递;猎聘聊天关闭失败不再写确认成功。 +- 清空分析后新岗位不得复用旧行 ID;旧 attempt 继续保留用于审计但不能污染新岗位。 +- UNKNOWN 可人工对账或显式重试;51job 回写失败必须升级为任务错误,并在下一轮安全恢复为 UNKNOWN。 +- 后端完整测试、前端 lint/typecheck/build、扩展测试全部通过;原数据库 hash/mtime 不变。 + +## 测试命令 + +```powershell +.\gradlew.bat test +$env:P0_REHEARSAL_DB = (Resolve-Path db/getjobs.db).Path +.\gradlew.bat test --tests com.getjobs.application.service.DatabaseMigrationRehearsalTest +Remove-Item Env:P0_REHEARSAL_DB +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 +``` + +## 返回格式 + +- 状态机、幂等键、证据等级和兼容映射说明。 +- 四平台在 confirmed/failed/unknown/partial 下的行为。 +- migration、CAS、乱序/重复 callback 与扩展判定测试证据。 +- 原数据库未修改证据、diff、Commit、Push 与 PR。