diff --git a/.env.example b/.env.example index f86359f..c1ebb51 100644 --- a/.env.example +++ b/.env.example @@ -65,7 +65,7 @@ HUAWEI_API_KEY= AI_PROVIDER=codex CODEX_PATH=codex CODEX_HOME= -CODEX_MODEL=gpt-5.6-sol +CODEX_MODEL=gpt-6-astra CODEX_TIMEOUT_SECONDS=300 AI_REQUEST_TIMEOUT_SECONDS=120 BASE_URL= diff --git a/chrome-extension/background.js b/chrome-extension/background.js index 1665dcf..5136668 100644 --- a/chrome-extension/background.js +++ b/chrome-extension/background.js @@ -29,19 +29,22 @@ const PLATFORM_CONFIG = { const pageTabs = new Map(); let scanSessionsWriteQueue = Promise.resolve(); +let hrOutboxWriteQueue = Promise.resolve(); +let hrWatchWriteQueue = Promise.resolve(); +let hrStopInProgress = false; const SCAN_SESSIONS_STORAGE_KEY = "__GET_JOBS_PLATFORM_SCAN_SESSIONS__"; const SCAN_SESSION_TTL_MS = 24 * 60 * 60 * 1000; const PLATFORM_SHARED_SCAN_KEYS = { boss: ["__GET_JOBS_BOSS_SHARED_SCAN_TASK__", "__GET_JOBS_BOSS_SHARED_SCAN_CANCEL__"], zhilian: ["__GET_JOBS_ZHILIAN_SHARED_SCAN_TASK__", "__GET_JOBS_ZHILIAN_SHARED_SCAN_CANCEL__"] }; -const BACKGROUND_VERSION = "2026-09-04-boss-hr-direct"; +const BACKGROUND_VERSION = "2026-09-06-hr-profile-guard"; const CONTENT_READY_RETRIES = 12; const CONTENT_READY_INTERVAL_MS = 250; const TAB_LOAD_TIMEOUT_MS = 10000; const DELIVERY_NAVIGATION_TIMEOUT_MS = 15000; -const REQUIRED_BOSS_CONTENT_VERSION = "2026-09-04-boss-hr-direct"; -const REQUIRED_ZHILIAN_CONTENT_VERSION = "2026-09-04-boss-hr-direct"; +const REQUIRED_BOSS_CONTENT_VERSION = "2026-09-06-hr-profile-guard"; +const REQUIRED_ZHILIAN_CONTENT_VERSION = "2026-09-06-hr-profile-guard"; const LOCAL_API_BASE_URLS = ["http://127.0.0.1:6866"]; const BOSS_LOCAL_API_MAX_ATTEMPTS = 3; const BOSS_LOCAL_API_TIMEOUT_MS = 30000; @@ -59,6 +62,7 @@ const ALLOWED_PAGE_ORIGINS = new Set([ ]); const ALLOWED_PAGE_MESSAGE_TYPES = new Set([ "GET_JOBS_EXTENSION_PING", + "BOSS_HR_OPEN_CHAT", "BOSS_PAGE_STATUS", "BOSS_DEBUG_COLLECT", "BOSS_COLLECT_CURRENT_PAGE", @@ -75,7 +79,24 @@ const ALLOWED_PAGE_MESSAGE_TYPES = new Set([ "ZHILIAN_DELIVER_BATCH" ]); +// Extension updates can replace the assistant on an existing chat page without +// reloading BOSS or reopening conversations. Starting a watch remains explicit. +chrome.runtime.onInstalled?.addListener?.(() => { + (async () => { + if ((await readBossHrWatch())?.watching) await stopBossHrWatch("USER_STOPPED", "扩展更新,等待重新开始值守"); + for (const tab of await chrome.tabs.query({ url: "https://www.zhipin.com/web/geek/chat*" })) { + if (!isBossChatUrl(tab.url || "")) continue; + await chrome.scripting.executeScript({ target: { tabId: tab.id }, world: "MAIN", files: ["boss-hr-identity.js"] }); + await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ["boss-hr-support.js", "boss-hr-bridge.js", "boss-hr-assistant.js"] }); + } + })().catch(error => console.warn("HR panel update failed:", error.message)); +}); + chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message?.source === "GET_JOBS_BOSS_HR_CONTENT" && message.type === "BOSS_HR_CAPTURE_RESULT") { + submitBossHrCapture(message, sender).then(sendResponse).catch(error => sendResponse({ success: false, errorCode: "HR_CAPTURE_SUBMIT_FAILED", message: error.message || String(error) })); + return true; + } if (message?.source === "GET_JOBS_BOSS_HR_CONTENT" && message.type === "BOSS_HR_OUTBOX_PUT") { handleBossHrOutboxPut(message, sender).then(sendResponse).catch((error) => { sendResponse({ success: false, errorCode: "HR_OUTBOX_WRITE_FAILED", message: error.message || String(error) }); @@ -417,7 +438,7 @@ async function handleBossLocalApiRequest(message, sender) { requireActionToken: endpoint.requireActionToken === true }; if (operation === "hr-start") { - return await startBossHrWatch(sender, requestContext); + return await startBossHrWatch(sender, requestContext, message.body?.expectedProfileId, message.body?.intervalMinutes ?? 1); } if (operation === "hr-stop") { return await stopBossHrWatch("USER_STOPPED", "用户主动停止", requestContext); @@ -425,12 +446,31 @@ async function handleBossLocalApiRequest(message, sender) { if (operation === "hr-command-poll") { return await pollBossHrSendCommand(sender, requestContext); } + if (operation === "hr-scan-all") { + const state = await readBossHrWatch(); + if (!state?.watching || state.tabId !== sender?.tab?.id || state.profileId !== message.body?.expectedProfileId) { + return { success: false, errorCode: "STALE_STATE", message: "请在当前档案绑定的聊天页开始值守" }; + } + if (bossHrScanPromise || bossHrCommandPromise) return { success: false, errorCode: "HR_WATCH_ACTIVE", message: "上一轮采集或发送仍在执行" }; + setTimeout(() => runBossHrScan("manual-all").catch(() => {}), 0); + return { success: true, data: { success: true, data: { scheduled: true } } }; + } const result = await requestLocalApi(endpoint.path, { ...requestContext, method: endpoint.method, body: message.body }); + if (operation === "hr-status" && result.success && result.data?.data) { + const active = await readBossHrWatch(); + if (active?.watching && active.watchSessionId === result.data.data.watchSessionId) { + result.data.data.scannedCount = active.scannedCount || 0; + result.data.data.scanRunning = Boolean(active.scanRunning || result.data.data.scanRunning); + } + const legacy = Object.values(await readBossHrOutbox()).filter(item => !normalizeProfileId(item.profileId)).length; + result.data.data.legacyOutboxCount = legacy; + if (legacy) result.data.data.lastError = [result.data.data.lastError, `有 ${legacy} 条旧版待处理记录缺少档案归属,已保留,请人工处理`].filter(Boolean).join(";"); + } if (operation === "hr-send" && result.success) { await pollBossHrSendCommand(sender, requestContext); } @@ -438,7 +478,12 @@ async function handleBossLocalApiRequest(message, sender) { return result; } -async function startBossHrWatch(sender, requestContext) { +const REQUIRED_BOSS_HR_CONTENT_VERSION = "2026-09-06-hr-all-conversations"; + +async function startBossHrWatch(sender, requestContext, expectedProfileId, intervalMinutes = 1) { + if (![1, 30].includes(intervalMinutes)) return { success: false, errorCode: "INVALID_INTERVAL", message: "值守间隔仅支持 1 分钟或 30 分钟" }; + if (!normalizeProfileId(expectedProfileId)) return { success: false, errorCode: "PROFILE_REQUIRED", message: "请刷新页面并确认当前人物档案后再开始值守" }; + if (hrStopInProgress || bossHrScanPromise || bossHrCommandPromise) return { success: false, errorCode: "HR_WATCH_ACTIVE", message: "上一轮采集或发送尚未结束,请稍后再开始值守" }; const tabId = sender?.tab?.id; const url = sender?.tab?.url || sender?.url || ""; if (!Number.isInteger(tabId) || !isBossChatUrl(url)) { @@ -448,7 +493,7 @@ async function startBossHrWatch(sender, requestContext) { const ready = await chrome.tabs.sendMessage(tabId, { source: "GET_JOBS_BACKGROUND", type: "BOSS_HR_CONTENT_VERSION" }).catch(() => null); - if (ready?.version !== REQUIRED_BOSS_CONTENT_VERSION) { + if (ready?.version !== REQUIRED_BOSS_HR_CONTENT_VERSION) { return { success: false, errorCode: "BOSS_HR_CONTENT_OUTDATED", message: "HR 内容脚本未就绪,请重新加载扩展并刷新 BOSS 页面" }; } const browserSessionId = createBossHrId("browser"); @@ -456,7 +501,7 @@ async function startBossHrWatch(sender, requestContext) { ...requestContext, operation: "hr-start", method: "POST", - body: { tabId, url, contentVersion: ready.version, browserSessionId }, + body: { tabId, url, contentVersion: ready.version, browserSessionId, expectedProfileId, intervalMinutes }, requireActionToken: true }); const watchStatus = result?.data?.data; @@ -469,30 +514,40 @@ async function startBossHrWatch(sender, requestContext) { contentVersion: ready.version, browserSessionId, watchSessionId: watchStatus.watchSessionId, + profileId: watchStatus.profileId, + intervalMinutes, scanRunning: false, lastScanAt: null, nextScanAt: Date.now(), updatedAt: Date.now() }); - await chrome.alarms?.create?.(BOSS_HR_ALARM_NAME, { periodInMinutes: 1 }); + await chrome.alarms?.create?.(BOSS_HR_ALARM_NAME, { periodInMinutes: intervalMinutes }); setTimeout(() => runBossHrScan("initial").catch(() => {}), 0); return result; } async function stopBossHrWatch(reasonCode, reason, requestContext = {}) { + hrStopInProgress = true; + try { return await stopBossHrWatchLocked(reasonCode, reason, requestContext); } + finally { hrStopInProgress = false; } +} + +async function stopBossHrWatchLocked(reasonCode, reason, requestContext = {}) { const state = await readBossHrWatch(); await chrome.alarms?.clear?.(BOSS_HR_ALARM_NAME); - try { - return await requestLocalApi("/api/hr-assistant/watch/stop", { + if (state) await writeBossHrWatch({ ...state, watching: false }); + if (reasonCode === "USER_STOPPED") { + await Promise.allSettled([bossHrScanPromise, bossHrCommandPromise].filter(Boolean)); + } + const result = await requestLocalApi("/api/hr-assistant/watch/stop", { ...requestContext, operation: "hr-stop", method: "POST", body: { watchSessionId: state?.watchSessionId || "", reason: `${reasonCode}: ${reason}` }, requireActionToken: true }); - } finally { - await bossHrWatchStorage().remove(BOSS_HR_WATCH_STORAGE_KEY).catch(() => {}); - } + if (result.success) await bossHrWatchStorage().remove(BOSS_HR_WATCH_STORAGE_KEY).catch(() => {}); + return result; } async function pauseBossHrWatch(errorCode, message) { @@ -507,24 +562,37 @@ async function stopBossHrWatchIfOwned(tabId, errorCode, message) { } async function handleBossHrOutboxPut(message, sender) { + const write = hrOutboxWriteQueue.then(() => putBossHrOutbox(message, sender)); + hrOutboxWriteQueue = write.catch(() => {}); + return await write; +} + +async function putBossHrOutbox(message, sender) { const state = await readBossHrWatch(); const tabId = sender?.tab?.id; if (!state?.watching || state.tabId !== tabId || !isBossChatUrl(sender?.tab?.url || sender?.url || "")) { return { success: false, errorCode: "BOSS_HR_TAB_NOT_BOUND", message: "当前标签页不是已绑定的 BOSS 值守标签" }; } + if (!normalizeProfileId(state.profileId) || state.watchSessionId !== message.watchSessionId) { + return { success: false, errorCode: "STALE_STATE", message: "采集所属人物档案或值守会话已变化" }; + } const capture = message?.capture; if (!capture?.captureId || !capture?.uid) { return { success: false, errorCode: "HR_OUTBOX_INVALID", message: "Outbox 会话身份不完整" }; } const outbox = await readBossHrOutbox(); - outbox[capture.captureId] = { + const key = `${state.profileId}:${capture.captureId}`; + if (!outbox[key] && Object.values(outbox).filter(item => item.profileId === state.profileId).length >= 100) { + return { success: false, errorCode: "HR_OUTBOX_FULL", message: "当前档案待处理记录已满,请先处理积压记录" }; + } + outbox[key] = { + profileId: state.profileId, watchSessionId: state.watchSessionId, captureId: String(capture.captureId), uid: String(capture.uid), unreadCount: Number(capture.unreadCount || 1), - addedAt: Number(outbox[capture.captureId]?.addedAt || Date.now()) + addedAt: Number(outbox[key]?.addedAt || Date.now()) }; - const entries = Object.values(outbox).sort((left, right) => left.addedAt - right.addedAt).slice(-100); - await writeBossHrOutbox(Object.fromEntries(entries.map((entry) => [entry.captureId, entry]))); - return { success: true, outboxCount: entries.length }; + await writeBossHrOutbox(outbox); + return { success: true, outboxCount: Object.values(outbox).filter(item => item.profileId === state.profileId).length }; } async function runBossHrScan(trigger = "alarm") { @@ -537,40 +605,49 @@ async function runBossHrScan(trigger = "alarm") { async function runBossHrScanLocked(trigger) { const state = await readBossHrWatch(); if (!state?.watching) return { success: true, skipped: true, reason: "WATCH_STOPPED" }; + if (trigger === "alarm" && Number(state.nextScanAt || 0) > Date.now()) return { success: true, skipped: true, reason: "NOT_DUE" }; + const scanAll = trigger === "manual-all" || state.intervalMinutes === 30; + const timeoutMs = scanAll ? 25 * 60 * 1000 : BOSS_HR_SCAN_TIMEOUT_MS; const tab = await chrome.tabs.get(state.tabId).catch(() => null); if (!tab || !isBossChatUrl(tab.url || "")) { await pauseBossHrWatch("BOSS_CHAT_TAB_UNAVAILABLE", "绑定的 BOSS 聊天标签页不存在或已跳转"); return { success: false, errorCode: "BOSS_CHAT_TAB_UNAVAILABLE" }; } const scanId = createBossHrId("scan"); - const deadlineAt = Date.now() + BOSS_HR_SCAN_TIMEOUT_MS; - const outbox = Object.values(await readBossHrOutbox()); - await writeBossHrWatch({ ...state, scanRunning: true, updatedAt: Date.now() }); - await postBossHrHeartbeat(state, tab.url, true, outbox.length, "").catch(() => {}); + const deadlineAt = Date.now() + timeoutMs; + const outbox = Object.values(await readBossHrOutbox()).filter(item => item.profileId === state.profileId); + if (!await updateBossHrWatchIfActive(state, { scanRunning: true, scanId, scannedCount: 0, updatedAt: Date.now() })) return { success: true, skipped: true, reason: "WATCH_STOPPED" }; try { + const heartbeat = await postBossHrHeartbeat(state, tab.url, true, outbox.length, ""); + if (!heartbeat.success) throw Object.assign(new Error(heartbeat.message || "值守会话已失效"), { errorCode: heartbeat.errorType }); const scanResult = await withBossHrTimeout(chrome.tabs.sendMessage(state.tabId, { - source: "GET_JOBS_BACKGROUND", type: "BOSS_HR_SCAN", scanId, trigger, deadlineAt, outbox - }), BOSS_HR_SCAN_TIMEOUT_MS); + source: "GET_JOBS_BACKGROUND", type: "BOSS_HR_SCAN", scanId, trigger, scanAll, streamResults: true, deadlineAt, outbox, watchSessionId: state.watchSessionId + }), timeoutMs); if (!scanResult?.success) { throw Object.assign(new Error(scanResult?.message || "BOSS HR 扫描失败"), { errorCode: scanResult?.errorCode || "BOSS_HR_SCAN_FAILED" }); } + const current = await readBossHrWatch(); + if (!current?.watching || current.watchSessionId !== state.watchSessionId) return { success: true, skipped: true, reason: "WATCH_STOPPED" }; const submitted = await requestLocalApi("/api/hr-assistant/watch/scan-results", { operation: "hr-scan-results", method: "POST", requireActionToken: true, platform: "boss", pageTabId: state.tabId, timeoutMs: 120000, body: { watchSessionId: state.watchSessionId, tabId: state.tabId, scanId, - totalUnread: Number(scanResult.totalUnread || 0), captures: scanResult.captures || [] + totalUnread: scanResult.streamed ? 0 : Number(scanResult.totalUnread || 0), captures: scanResult.captures || [] } }); if (!submitted.success) throw Object.assign(new Error(submitted.message || "扫描结果提交失败"), { errorCode: submitted.errorType }); const acknowledged = submitted.data?.data?.acknowledgedCaptureIds || []; - await acknowledgeBossHrOutbox(acknowledged); + await acknowledgeBossHrOutbox(acknowledged, state.profileId); const finishedAt = Date.now(); - const remaining = Object.keys(await readBossHrOutbox()).length; - await writeBossHrWatch({ ...state, scanRunning: false, lastScanAt: finishedAt, - nextScanAt: finishedAt + 60000, updatedAt: finishedAt }); + const remaining = Object.values(await readBossHrOutbox()).filter(item => item.profileId === state.profileId).length; + const latest = await readBossHrWatch(); + if (!latest?.watching || latest.watchSessionId !== state.watchSessionId) return { success: true, skipped: true, reason: "WATCH_STOPPED" }; + if (!await updateBossHrWatchIfActive(state, { scanRunning: false, lastScanAt: finishedAt, + nextScanAt: finishedAt + (state.intervalMinutes || 1) * 60000, updatedAt: finishedAt })) return { success: true, skipped: true, reason: "WATCH_STOPPED" }; + await chrome.alarms?.create?.(BOSS_HR_ALARM_NAME, { delayInMinutes: state.intervalMinutes || 1, periodInMinutes: state.intervalMinutes || 1 }); await postBossHrHeartbeat(state, tab.url, false, remaining, "").catch(() => {}); return { success: true, scanId, received: scanResult.captures?.length || 0, acknowledged: acknowledged.length }; } catch (error) { @@ -580,6 +657,33 @@ async function runBossHrScanLocked(trigger) { } } +async function submitBossHrCapture(message, sender) { + const state = await readBossHrWatch(); + if (!state?.watching || !state.scanRunning || state.tabId !== sender?.tab?.id || !isBossChatUrl(sender?.tab?.url || "") + || state.watchSessionId !== message.watchSessionId || state.scanId !== message.scanId) { + return { success: false, errorCode: "STALE_STATE", message: "已停止值守或采集会话已变化" }; + } + const pending = Object.values(await readBossHrOutbox()).find(item => item.profileId === state.profileId + && item.captureId === message.capture?.captureId && item.uid === message.capture?.session?.uid); + if (!pending) return { success: false, errorCode: "HR_OUTBOX_INVALID", message: "消息不属于已保存的当前档案采集任务" }; + if (Array.isArray(message.capture.messages) && message.capture.messages.length === 0) { + await acknowledgeBossHrOutbox([message.capture.captureId], state.profileId); + await updateBossHrWatchIfActive(state, { scannedCount: Number(state.scannedCount || 0) + 1 }); + return { success: true, skipped: true, reason: "NO_RENDERED_MESSAGES" }; + } + const result = await requestLocalApi("/api/hr-assistant/watch/scan-results", { + operation: "hr-scan-results", method: "POST", requireActionToken: true, + platform: "boss", pageTabId: state.tabId, timeoutMs: 360000, + body: { watchSessionId: state.watchSessionId, tabId: state.tabId, scanId: message.scanId, totalUnread: 0, captures: [message.capture] } + }); + if (!result.success) return result; + const acknowledged = result.data?.data?.acknowledgedCaptureIds || []; + if (!acknowledged.includes(message.capture.captureId)) return { success: false, errorCode: "HR_ACK_MISSING", message: "后端未确认保存,已保留待处理记录" }; + await acknowledgeBossHrOutbox(acknowledged, state.profileId); + await updateBossHrWatchIfActive(state, { scannedCount: Number(state.scannedCount || 0) + 1 }); + return { success: true }; +} + async function postBossHrHeartbeat(state, url, scanRunning, outboxCount, fault) { return await requestLocalApi("/api/hr-assistant/watch/heartbeat", { operation: "hr-heartbeat", method: "POST", requireActionToken: true, @@ -623,13 +727,13 @@ async function pollBossHrSendCommandLocked(sender, requestContext) { let execution; try { execution = await chrome.tabs.sendMessage(state.tabId, { - source: "GET_JOBS_BACKGROUND", type: "BOSS_HR_SEND", command + source: "GET_JOBS_BACKGROUND", type: "BOSS_HR_SEND", command: { ...command, deadlineAt: Math.min(Date.now() + 45000, Number(command.leaseDeadlineEpochMs) - 5000) } }); } catch (error) { - execution = { success: true, outcome: "FAILED_SAFE", evidence: `内容脚本未执行发送:${friendlyLocalApiError(error)}` }; + execution = { success: true, outcome: "RESULT_UNKNOWN", evidence: `内容脚本发送结果无法确认:${friendlyLocalApiError(error)}` }; } const allowed = new Set(["SENT", "STALE", "RESULT_UNKNOWN", "FAILED_SAFE"]); - const outcome = allowed.has(execution?.outcome) ? execution.outcome : "FAILED_SAFE"; + const outcome = allowed.has(execution?.outcome) ? execution.outcome : "RESULT_UNKNOWN"; return await requestLocalApi(`/api/hr-assistant/send-commands/${encodeURIComponent(command.commandId)}/result`, { operation: "hr-command-result", method: "POST", requireActionToken: true, platform: "boss", pageTabId: state.tabId, @@ -647,7 +751,20 @@ async function readBossHrWatch() { } async function writeBossHrWatch(state) { - await bossHrWatchStorage().set({ [BOSS_HR_WATCH_STORAGE_KEY]: state }); + const write = hrWatchWriteQueue.then(() => bossHrWatchStorage().set({ [BOSS_HR_WATCH_STORAGE_KEY]: state })); + hrWatchWriteQueue = write.catch(() => {}); + return await write; +} + +async function updateBossHrWatchIfActive(expected, changes) { + const write = hrWatchWriteQueue.then(async () => { + const current = await readBossHrWatch(); + if (!current?.watching || current.watchSessionId !== expected.watchSessionId) return false; + await bossHrWatchStorage().set({ [BOSS_HR_WATCH_STORAGE_KEY]: { ...current, ...changes } }); + return true; + }); + hrWatchWriteQueue = write.catch(() => {}); + return await write; } function bossHrWatchStorage() { @@ -664,10 +781,14 @@ async function writeBossHrOutbox(outbox) { await chrome.storage.local.set({ [BOSS_HR_OUTBOX_STORAGE_KEY]: outbox }); } -async function acknowledgeBossHrOutbox(captureIds) { - const outbox = await readBossHrOutbox(); - for (const captureId of captureIds || []) delete outbox[String(captureId)]; - await writeBossHrOutbox(outbox); +async function acknowledgeBossHrOutbox(captureIds, profileId) { + const write = hrOutboxWriteQueue.then(async () => { + const outbox = await readBossHrOutbox(); + for (const captureId of captureIds || []) delete outbox[`${profileId}:${captureId}`]; + await writeBossHrOutbox(outbox); + }); + hrOutboxWriteQueue = write.catch(() => {}); + return await write; } function createBossHrId(prefix) { @@ -718,6 +839,7 @@ function resolveBossLocalApiEndpoint(message) { if (operation === "hr-settings-save") return { success: true, method: "PUT", path: "/api/hr-assistant/settings", requireActionToken: true }; if (operation === "hr-proposals") return { success: true, method: "GET", path: "/api/hr-assistant/proposals" }; if (operation === "hr-start") return { success: true, method: "POST", path: "/api/hr-assistant/watch/start", requireActionToken: true }; + if (operation === "hr-scan-all") return { success: true, method: "POST", path: "/api/hr-assistant/watch/scan-results", requireActionToken: true }; if (operation === "hr-stop") return { success: true, method: "POST", path: "/api/hr-assistant/watch/stop", requireActionToken: true }; if (operation === "hr-command-poll") return { success: true, method: "POST", path: "/api/hr-assistant/send-commands/claim", requireActionToken: true }; if (["hr-revise", "hr-send", "hr-skip"].includes(operation)) { @@ -786,7 +908,16 @@ async function requestLocalApi(path, options = {}) { for (const baseUrl of baseUrls) { try { const response = await fetchWithTimeout(`${baseUrl}${path}`, requestOptions, timeoutMs); - const data = await parseLocalApiResponse(response); + if (response.status === 404 && path.startsWith("/api/hr-assistant/")) { + return { success: false, httpStatus: 404, errorType: "HR_BACKEND_UNAVAILABLE", + message: "当前后端缺少 BOSS 值守功能(HTTP 404),请切换到包含值守功能的服务版本", attempt, baseUrl }; + } + let data; + try { data = await parseLocalApiResponse(response); } + catch (error) { + return { success: false, httpStatus: response.status, errorType: "LOCAL_API_CONTRACT_MISMATCH", + message: `本地接口响应格式不兼容(HTTP ${response.status}):${error.message}`, attempt, baseUrl }; + } if (response.status === 401 && options.requireActionToken) { localActionToken = null; localActionBaseUrl = ""; @@ -947,6 +1078,18 @@ async function postLocalApiRetryProgress(platform, pageTabId, operation, nextAtt }); } +async function openBossHrChat() { + const state = await readBossHrWatch(); + const bound = state?.tabId ? await chrome.tabs.get(state.tabId).catch(() => null) : null; + const tabs = await chrome.tabs.query({ url: ["https://*.zhipin.com/web/geek/chat*", "https://zhipin.com/web/geek/chat*"] }); + const tab = bound && isBossChatUrl(bound.url || "") ? bound + : tabs.filter(item => isBossChatUrl(item.url || "")).sort((a, b) => (b.lastAccessed || 0) - (a.lastAccessed || 0))[0]; + const opened = tab ? await chrome.tabs.update(tab.id, { active: true }) + : await chrome.tabs.create({ url: "https://www.zhipin.com/web/geek/chat", active: true }); + if (opened.windowId != null) await chrome.windows.update(opened.windowId, { focused: true }); + return { success: true, tabId: opened.id, message: "已打开 BOSS 聊天页,请确认人物档案和 BOSS 账号后点击开始值守" }; +} + async function handlePageMessage(message, sender) { const pageTabId = sender.tab?.id; if (pageTabId) pageTabs.set(pageTabId, Date.now()); @@ -955,6 +1098,8 @@ async function handlePageMessage(message, sender) { return { success: true, message: "Chrome扩展已连接", version: BACKGROUND_VERSION }; } + if (message.type === "BOSS_HR_OPEN_CHAT") return await openBossHrChat(); + const platform = message.platform || inferPlatform(message.type); if (!platform || !PLATFORM_CONFIG[platform]) { return { success: false, message: "未知平台" }; diff --git a/chrome-extension/boss-content.js b/chrome-extension/boss-content.js index 447185f..b7875ce 100644 --- a/chrome-extension/boss-content.js +++ b/chrome-extension/boss-content.js @@ -1,5 +1,5 @@ (function () { - const EXTENSION_VERSION = "2026-09-04-boss-hr-direct"; + const EXTENSION_VERSION = "2026-09-06-hr-profile-guard"; const CONTENT_INSTANCE_ID = `${Date.now()}-${Math.random().toString(16).slice(2)}`; window.__GET_JOBS_BOSS_CONTENT__ = true; window.__GET_JOBS_BOSS_CONTENT_VERSION__ = EXTENSION_VERSION; diff --git a/chrome-extension/boss-hr-assistant.js b/chrome-extension/boss-hr-assistant.js index a1488ad..cecd1d6 100644 --- a/chrome-extension/boss-hr-assistant.js +++ b/chrome-extension/boss-hr-assistant.js @@ -1,15 +1,19 @@ (function () { "use strict"; - if (window.top !== window.self || window.__GET_JOBS_BOSS_HR_ASSISTANT__) return; - window.__GET_JOBS_BOSS_HR_ASSISTANT__ = true; + const PANEL_VERSION = "2026-09-06-hr-all-conversations"; + if (window.top !== window.self || window.__GET_JOBS_BOSS_HR_ASSISTANT__ === PANEL_VERSION) return; + window.__GET_JOBS_BOSS_HR_ASSISTANT_CLEANUP__?.(); + window.__GET_JOBS_BOSS_HR_ASSISTANT__ = PANEL_VERSION; const HOST_ID = "getjobs-boss-hr-assistant"; + document.getElementById(HOST_ID)?.remove(); const REFRESH_MS = 15_000; let activeRequest = false; let latestStatus = null; let latestProposals = []; let actionError = ""; + let intervalMinutes = 30; const host = document.createElement("div"); host.id = HOST_ID; @@ -43,10 +47,11 @@ render(); refresh(); - window.setInterval(() => { + const refreshTimer = window.setInterval(() => { host.style.display = location.pathname.startsWith("/web/geek/chat") ? "block" : "none"; if (host.style.display !== "none") refresh(); }, REFRESH_MS); + window.__GET_JOBS_BOSS_HR_ASSISTANT_CLEANUP__ = () => { window.clearInterval(refreshTimer); host.remove(); }; async function refresh() { if (activeRequest || !location.pathname.startsWith("/web/geek/chat")) return; @@ -75,14 +80,28 @@ const timing = latestStatus?.lastScanAt ? `|上次扫描 ${formatTime(latestStatus.lastScanAt)}` : ""; const next = latestStatus?.nextScanAt ? `|下次 ${formatTime(latestStatus.nextScanAt)}` : ""; statusBox.textContent = latestStatus - ? `${watching ? "值守中:每 60 秒扫描一次" : "值守已停止"}|Chrome 扩展已连接${bridge?.tabBound ? "/当前 BOSS 标签已绑定" : "/标签未绑定"}|Outbox ${bridge?.outboxCount || 0}|NapCat ${latestStatus.napcatConnected ? "已连接" : "未连接"}${timing}${next}${latestStatus.lastError ? `|${latestStatus.lastError}` : ""}` + ? `${watching ? (latestStatus.intervalMs === 1800000 ? "值守中:每 30 分钟读取全部会话" : "值守中:每 60 秒检查未读") : "值守已停止"}|Chrome 扩展已连接${bridge?.tabBound ? "/当前 BOSS 标签已绑定" : "/标签未绑定"}|Outbox ${bridge?.outboxCount || 0}|NapCat ${latestStatus.napcatConnected ? "已连接" : "未连接"}${latestStatus.scanRunning ? `|正在逐个读取与生成,已处理 ${latestStatus.scannedCount || 0} 个` : timing + next}${latestStatus.lastError ? `|${latestStatus.lastError}` : ""}` : "正在连接本地 AI-JobPilot…"; body.appendChild(statusBox); + body.appendChild(element("div", "status", `当前人物档案:${latestStatus?.currentProfileName || "未读取"};切换档案不会切换 BOSS 登录账号。值守期间请先停止再切换。`)); + const schedule = document.createElement("select"); + schedule.setAttribute("aria-label", "值守检查范围与间隔"); + schedule.className = "btn"; + for (const [value, label] of [[30, "每 30 分钟:全部会话(含已读)"], [1, "每 1 分钟:仅未读会话"]]) { + const option = element("option", "", label); + option.value = String(value); + schedule.appendChild(option); + } + schedule.value = String(watching ? (latestStatus.intervalMs === 1800000 ? 30 : 1) : intervalMinutes); + schedule.disabled = watching; + schedule.addEventListener("change", () => { intervalMinutes = Number(schedule.value); }); + body.appendChild(schedule); const actions = element("div", "actions"); const start = button("开始值守", "btn primary"); start.disabled = watching; - start.addEventListener("click", () => mutate("hr-start")); + start.disabled = watching || !latestStatus?.currentProfileId || latestStatus?.profileSwitchBlocked; + start.addEventListener("click", () => mutate("hr-start", null, { expectedProfileId: latestStatus?.currentProfileId, intervalMinutes })); const stop = button("停止", "btn danger"); stop.disabled = !watching; stop.addEventListener("click", () => mutate("hr-stop")); @@ -90,6 +109,10 @@ locked.disabled = true; actions.append(start, stop, locked); body.appendChild(actions); + const readAll = button("立即读取全部并生成草稿", "btn"); + readAll.disabled = !watching || Boolean(latestStatus?.scanRunning); + readAll.addEventListener("click", () => mutate("hr-scan-all", null, { expectedProfileId: latestStatus?.currentProfileId })); + body.appendChild(readAll); body.appendChild(element("div", "section-title", `待确认回复(${latestProposals.length})`)); if (!latestProposals.length) { body.appendChild(element("div", "empty", "暂无待确认消息。AI 不会未经确认自动回复。")); @@ -107,7 +130,11 @@ element("span", "", proposal.jobName || "未知岗位"), element("span", "", proposal.hrName || "HR") ); - if (proposal.highValue) meta.appendChild(element("span", "tag", "高价值/需注意")); + const risks = Array.isArray(proposal.riskTags) ? proposal.riskTags : []; + const label = risks.includes("AI_FAILURE") ? "草稿生成失败" + : risks.includes("NON_TEXT_MESSAGE") ? "需人工查看" + : ({ NEEDS_USER: "待补充信息", REJECTION: "婉拒 / 无需回复", NO_REPLY: "无需回复", INTERVIEW_INVITE: "面试邀请", OFFER: "录用意向" })[proposal.classification]; + if (label || proposal.highValue) meta.appendChild(element("span", "tag", label || "需注意")); const source = element("div", "source", `HR:${proposal.sourceMessage || "(非文本消息)"}`); const draft = document.createElement("textarea"); draft.value = proposal.draft || ""; @@ -131,7 +158,11 @@ const skip = button("跳过", "btn danger"); skip.addEventListener("click", () => mutate("hr-skip", proposal.id)); cardActions.append(save, send, skip); - card.append(meta, source, draft, cardActions); + card.append(meta, source); + if (proposal.summary) card.appendChild(element("div", "source", proposal.summary)); + const missingFacts = Array.isArray(proposal.missingFacts) ? proposal.missingFacts.filter(value => typeof value === "string" && value.trim()) : []; + if (missingFacts.length) card.appendChild(element("div", "source", `需要处理:${missingFacts.join(";")}`)); + card.append(draft, cardActions); return card; } diff --git a/chrome-extension/boss-hr-bridge.js b/chrome-extension/boss-hr-bridge.js index ed78cae..54f9bea 100644 --- a/chrome-extension/boss-hr-bridge.js +++ b/chrome-extension/boss-hr-bridge.js @@ -1,11 +1,10 @@ (function () { "use strict"; - if (window.top !== window.self || window.__GET_JOBS_BOSS_HR_BRIDGE__) return; - window.__GET_JOBS_BOSS_HR_BRIDGE__ = true; - + const CONTENT_VERSION = "2026-09-06-hr-all-conversations"; + if (window.top !== window.self || window.__GET_JOBS_BOSS_HR_BRIDGE__ === CONTENT_VERSION) return; + window.__GET_JOBS_BOSS_HR_BRIDGE__ = CONTENT_VERSION; const support = globalThis.GetJobsBossHrSupport; - const CONTENT_VERSION = "2026-09-04-boss-hr-direct"; const MAX_CAPTURES = 100; const OPEN_WAIT_MS = 450; @@ -35,18 +34,19 @@ } const initialUnreadTotal = support.unreadTotal(document); - const unread = support.unreadTab(document); + const scanAll = message.scanAll === true; + const unread = scanAll ? support.allTab(document) : support.unreadTab(document); if (unread && !/active|selected/i.test(String(unread.className || ""))) { unread.click(); await wait(350); } - const collected = await collectUnreadTargets(message.deadlineAt); + const collected = await collectUnreadTargets(message.deadlineAt, scanAll); if (!collected.success) return collected; const targets = collected.targets; for (const pending of Array.isArray(message.outbox) ? message.outbox : []) { if (pending?.uid && !targets.has(pending.uid)) targets.set(pending.uid, pending); - if (targets.size >= MAX_CAPTURES) break; + if (targets.size >= (scanAll ? 1000 : MAX_CAPTURES)) break; } // Opening an unread conversation clears its badge. Process the saved targets from @@ -59,12 +59,13 @@ const captures = []; const errors = []; + let scannedCount = 0; for (const snapshot of targets.values()) { if (Date.now() > Number(message.deadlineAt || 0)) { - return { success: false, pause: true, errorCode: "BOSS_HR_SCAN_TIMEOUT", message: "单轮扫描超过 5 分钟,已暂停" }; + return { success: false, pause: true, errorCode: "BOSS_HR_SCAN_TIMEOUT", message: "本轮读取达到安全时限,已暂停;已保存的进度保留" }; } - const captureId = snapshot.captureId || support.captureId(snapshot); - const stored = await backgroundRequest("BOSS_HR_OUTBOX_PUT", { capture: { ...snapshot, captureId } }); + const captureId = snapshot.captureId || (scanAll ? `${message.scanId}:${snapshot.uid}` : support.captureId(snapshot)); + const stored = await backgroundRequest("BOSS_HR_OUTBOX_PUT", { capture: { ...snapshot, captureId }, watchSessionId: message.watchSessionId }); if (!stored?.success) return { success: false, pause: true, errorCode: "HR_OUTBOX_WRITE_FAILED", message: "无法在打开会话前保存 Outbox" }; const located = await locateByUid(snapshot.uid); @@ -80,24 +81,33 @@ unreadCount: snapshot.unreadCount || visibleSnapshot.unreadCount || 1 }; located.unique.click(); - await wait(OPEN_WAIT_MS); + await wait(scanAll ? 2000 : OPEN_WAIT_MS); const afterSafety = support.pageSafety(document); if (!afterSafety.safe) return { success: false, pause: true, ...afterSafety }; const session = support.currentSession(document, currentSnapshot); + if (session.uid !== snapshot.uid) { + errors.push({ captureId, errorCode: "BOSS_CHAT_IDENTITY_AMBIGUOUS" }); + continue; + } const messages = support.readMessages(document); - if (!messages.length) { + if (!messages.length && !scanAll) { errors.push({ captureId, errorCode: "BOSS_CHAT_MESSAGES_MISSING" }); continue; } const inbound = support.latestInbound(messages); - if (!inbound) { + if (!inbound && !scanAll) { errors.push({ captureId, errorCode: "BOSS_CHAT_INBOUND_MISSING" }); continue; } - session.lastMessage = inbound.text; - session.lastTime = inbound.time || currentSnapshot.lastTime; + session.lastMessage = inbound?.text || ""; + session.lastTime = inbound?.time || currentSnapshot.lastTime; delete session.surfaceText; - captures.push({ captureId, unreadCount: currentSnapshot.unreadCount, session, messages }); + const capture = { captureId, unreadCount: currentSnapshot.unreadCount, session, messages }; + if (message.streamResults) { + const saved = await backgroundRequest("BOSS_HR_CAPTURE_RESULT", { capture, scanId: message.scanId, watchSessionId: message.watchSessionId }); + if (!saved?.success) return { success: false, pause: true, errorCode: saved?.errorCode || "HR_CAPTURE_SUBMIT_FAILED", message: saved?.message || "生成或保存结果未确认,已暂停,不自动重试" }; + } else captures.push(capture); + scannedCount++; } if (errors.length) { @@ -115,38 +125,50 @@ totalUnread: initialUnreadTotal, captures, errors, - truncated: targets.size >= MAX_CAPTURES + streamed: Boolean(message.streamResults), + scannedCount, + truncated: targets.size >= (scanAll ? 1000 : MAX_CAPTURES) }; } - async function collectUnreadTargets(deadlineAt) { + async function collectUnreadTargets(deadlineAt, scanAll = false) { const targets = new Map(); let unchangedRounds = 0; - let list = null; + let reachedEnd = false; + let list = findScrollableList(support.chatItems(document)[0]); + if (list && list.scrollTop > 0) { + list.scrollTop = 0; + list.dispatchEvent(new Event("scroll", { bubbles: true })); + await wait(350); + } for (let round = 0; round < 120; round++) { if (Date.now() > Number(deadlineAt || 0)) { - return { success: false, pause: true, errorCode: "BOSS_HR_SCAN_TIMEOUT", message: "单轮扫描超过 5 分钟,已暂停" }; + return { success: false, pause: true, errorCode: "BOSS_HR_SCAN_TIMEOUT", message: "本轮读取达到安全时限,已暂停;已保存的进度保留" }; } const before = targets.size; const items = support.chatItems(document); for (const item of items) { const snapshot = support.itemSnapshot(item); - if (snapshot.unreadCount <= 0) continue; + if (!scanAll && snapshot.unreadCount <= 0) continue; if (!snapshot.uid) { - return { success: false, pause: true, errorCode: "BOSS_CHAT_UID_MISSING", message: "未读会话缺少稳定 UID,已暂停避免误认 HR" }; + return { success: false, pause: true, errorCode: "BOSS_CHAT_UID_MISSING", message: "会话缺少稳定 UID,已暂停避免误认 HR" }; } targets.set(snapshot.uid, snapshot); - if (targets.size >= MAX_CAPTURES) break; + if (targets.size >= (scanAll ? 1000 : MAX_CAPTURES)) break; + } + if (targets.size >= (scanAll ? 1000 : MAX_CAPTURES)) { + if (scanAll) return { success: false, pause: true, errorCode: "HR_LIST_LIMIT", message: "会话列表超过本轮安全上限,未完成全部读取,已暂停" }; + break; } - if (targets.size >= MAX_CAPTURES) break; list ||= findScrollableList(items[0]); - if (!list || list.scrollTop + list.clientHeight >= list.scrollHeight - 2) break; + if (!list || list.scrollTop + list.clientHeight >= list.scrollHeight - 2) { reachedEnd = true; break; } unchangedRounds = targets.size === before ? unchangedRounds + 1 : 0; if (unchangedRounds >= 3) break; list.scrollTop = Math.min(list.scrollHeight, list.scrollTop + Math.max(240, Math.floor(list.clientHeight * 0.85))); list.dispatchEvent(new Event("scroll", { bubbles: true })); await wait(140); } + if (scanAll && !reachedEnd) return { success: false, pause: true, errorCode: "HR_LIST_INCOMPLETE", message: "未能确认已读到会话列表底部,已暂停,未标记全部完成" }; return { success: true, targets }; } @@ -205,6 +227,7 @@ const inputs = Array.from(document.querySelectorAll("#chat-input,textarea,[contenteditable='true']")) .filter((element) => visible(element)); if (inputs.length !== 1) return { success: true, outcome: "FAILED_SAFE", evidence: "聊天输入框未唯一命中" }; + if (!Number.isFinite(command.deadlineAt) || Date.now() >= command.deadlineAt) return { success: true, outcome: "FAILED_SAFE", evidence: "发送命令已过期" }; const input = inputs[0]; writeInput(input, command.draft); if (support.normalizeText(inputValue(input)) !== support.normalizeText(command.draft)) { @@ -213,6 +236,7 @@ const sendButtons = Array.from(document.querySelectorAll("button,[role='button']")) .filter((element) => visible(element) && /^发送$/.test(support.normalizeText(element.textContent)) && !element.disabled); + if (Date.now() >= command.deadlineAt) return { success: true, outcome: "FAILED_SAFE", evidence: "发送命令已过期" }; let dispatched = false; if (sendButtons.length === 1) { sendButtons[0].click(); @@ -238,7 +262,7 @@ const hrName = support.normalizeText(command.hrName); const companyName = support.normalizeText(command.companyName); const jobName = support.normalizeText(command.jobName); - return (!hrName || title.includes(hrName)) + return session.uid === command.uid && (!hrName || title.includes(hrName)) && (!companyName || surface.includes(companyName)) && (!jobName || surface.includes(jobName)); } diff --git a/chrome-extension/boss-hr-identity.js b/chrome-extension/boss-hr-identity.js new file mode 100644 index 0000000..62643a1 --- /dev/null +++ b/chrome-extension/boss-hr-identity.js @@ -0,0 +1,78 @@ +(function (root, factory) { + const api = factory(); + if (typeof module === "object" && module.exports) module.exports = api; + else if (root.top === root.self && root.location.hostname === "www.zhipin.com" + && /^\/web\/geek\/chat\/?$/.test(root.location.pathname)) api.install(root.document); +})(typeof globalThis !== "undefined" ? globalThis : this, function () { + "use strict"; + const ATTRIBUTE = "data-getjobs-hr-uid"; + const MESSAGE_TYPE = "data-getjobs-hr-message-type"; + const EVENT = "getjobs:hr:refresh-identities"; + + function text(value) { return String(value ?? "").replace(/\s+/g, " ").trim(); } + + function sourceUid(source) { + if (!source || typeof source !== "object") return ""; + if ([source.friendId, source.friendSource].some(value => typeof value === "number" && !Number.isSafeInteger(value))) return ""; + const friendId = text(source.friendId); + const friendSource = text(source.friendSource); + // The current BOSS chat bundle keys both the virtual row and its card by + // `${friendId}-${friendSource}`. Never derive identity from a name or index. + if (!/^[1-9]\d{0,19}$/.test(friendId) || !/^\d{1,3}$/.test(friendSource)) return ""; + const uid = `${friendId}-${friendSource}`; + return text(source.uniqueId) === uid ? uid : ""; + } + + function sync(documentRef) { + // Clear first: virtual-list nodes can be reused for a different person. + for (const node of documentRef.querySelectorAll(`[${ATTRIBUTE}]`)) node.removeAttribute(ATTRIBUTE); + for (const node of documentRef.querySelectorAll(`[${MESSAGE_TYPE}]`)) node.removeAttribute(MESSAGE_TYPE); + for (const row of documentRef.querySelectorAll(".chat-conversation .im-list > .message-item")) { + try { + const component = row.__vue__; + const message = component?.$el === row ? component.$props?.message : null; + // ChatMessage renders this very li and selects its body by messageType. + // Avatars, quoted images and inline emoji do not determine that type. + if (!message || !text(message.mid) || text(message.mid) !== row.getAttribute("data-mid")) continue; + const type = ({ text: "文本", image: "图片", sticker: "图片", sound: "语音", video: "视频", resume: "附件" })[message.messageType]; + row.setAttribute(MESSAGE_TYPE, typeof type === "string" ? type : "其他"); + } catch { /* Unrecognized message metadata requires manual review. */ } + } + for (const wrapper of documentRef.querySelectorAll(".user-list .friend-content-warp")) { + try { + const component = wrapper.__vue__; + if (!component || component.$el !== wrapper) continue; + const source = component.$props?.source; + const uid = sourceUid(source); + const cards = wrapper.querySelectorAll(".friend-content"); + if (!uid || cards.length !== 1) continue; + const card = cards[0]; + // Check that these props still describe this rendered card. These texts + // are consistency checks only; they are never an identity fallback. + const name = card.querySelector(".name-text"); + const brand = card.querySelector(".name-box > span:nth-child(2)"); + if (!name || !text(source.name) || text(name.textContent) !== text(source.name)) continue; + if (!brand || text(brand.textContent) !== text(source.brandName)) continue; + card.setAttribute(ATTRIBUTE, uid); + } catch { + // Unsupported component shapes stay unbound and fail closed. + } + } + for (const pane of documentRef.querySelectorAll(".chat-conversation")) { + try { + const component = pane.__vue__; + // The public chat component subscribes to selectedFriend$. Require + // the right-hand conversation and the selected list card to agree. + const uid = component?.$el === pane ? sourceUid(component.selectedFriend$) : ""; + if (uid) pane.setAttribute(ATTRIBUTE, uid); + } catch { /* Leave an unsupported pane unbound. */ } + } + } + + function install(documentRef) { + // On-demand, synchronous metadata only: no timer, network, navigation or + // component method calls. Do not export the rest of the component props. + documentRef.addEventListener(EVENT, () => sync(documentRef)); + } + return { sourceUid, sync, install }; +}); diff --git a/chrome-extension/boss-hr-support.js b/chrome-extension/boss-hr-support.js index 290f1fd..f44ac98 100644 --- a/chrome-extension/boss-hr-support.js +++ b/chrome-extension/boss-hr-support.js @@ -68,7 +68,16 @@ .find((element) => normalizeText(element.textContent) === "全部") || null; } + function refreshIdentityMetadata(documentRef) { + const EventType = documentRef?.defaultView?.Event; + if (EventType && documentRef?.dispatchEvent) documentRef.dispatchEvent(new EventType("getjobs:hr:refresh-identities")); + } + function chatItems(documentRef) { + refreshIdentityMetadata(documentRef); + const cards = Array.from(documentRef.querySelectorAll(".user-list .friend-content")) + .filter(element => element.matches?.(".friend-content") && isVisible(element)); + if (cards.length) return cards; const seen = new Set(); const items = []; const roots = Array.from(documentRef.querySelectorAll(CHAT_ROOT_SELECTORS.join(","))) @@ -107,6 +116,7 @@ } function stableUid(item) { + if (item.matches?.(".friend-content")) return normalizeText(item.getAttribute("data-getjobs-hr-uid")); for (const element of [item, ...item.querySelectorAll(UID_ATTRIBUTES.map((name) => `[${name}]`).join(","))]) { for (const attribute of UID_ATTRIBUTES) { const value = normalizeText(element.getAttribute?.(attribute)); @@ -130,13 +140,14 @@ } function itemSnapshot(item) { + refreshIdentityMetadata(item.ownerDocument); const textLines = String(item.innerText || item.textContent || "").split(/\r?\n/).map(normalizeText).filter(Boolean); const uid = stableUid(item); return { uid, unreadCount: badgeCount(item), - hrName: normalizeText(item.querySelector(".name-box,.title-box span,[class*='name'],[class*='title']")?.textContent || textLines[0]), - companyName: normalizeText(item.querySelector("[class*='company'],[class*='brand']")?.textContent || textLines[1]), + hrName: normalizeText((item.querySelector(".name-text") || item.querySelector(".name-box,.title-box span,[class*='name'],[class*='title']"))?.textContent || textLines[0]), + companyName: normalizeText(item.querySelector(".name-box > span:nth-child(2),[class*='company'],[class*='brand']")?.textContent || textLines[1]), jobName: normalizeText(item.querySelector("[class*='job'],[class*='position']")?.textContent || ""), lastMessage: normalizeText(item.querySelector(".last-msg-text,.last-msg,[class*='last-msg'],[class*='last'],[class*='message'],[class*='desc']")?.textContent || textLines.at(-1)), lastTime: normalizeText(item.querySelector("time,[class*='time']")?.textContent || "") @@ -150,11 +161,19 @@ } function currentSession(documentRef, fallback) { - const scope = documentRef.querySelector("[class*='chat-content'],[class*='conversation-detail'],[class*='chat-panel'],main") || documentRef; - const title = normalizeText(scope.querySelector("h1,h2,h3,[class*='chat-title'],[class*='title']")?.textContent || fallback?.hrName); + const pane = documentRef.querySelector(".chat-conversation"); + const scope = pane || documentRef.querySelector("[class*='chat-content'],[class*='conversation-detail'],[class*='chat-panel'],main") || documentRef; + const title = normalizeText((scope.querySelector(".user-info .name-text") || scope.querySelector("h1,h2,h3,[class*='chat-title'],[class*='title']"))?.textContent || (!pane && fallback?.hrName)); const text = normalizeText(scope.innerText || scope.textContent).slice(0, 1600); + const cards = chatItems(documentRef); + const modern = Boolean(pane) || cards.some(item => item.matches?.(".friend-content")); + const selected = cards.filter(item => item.matches?.(".friend-content.selected")); + const selectedUid = selected.length === 1 ? stableUid(selected[0]) : ""; + const paneUid = normalizeText(pane?.getAttribute("data-getjobs-hr-uid")); + const loading = Array.from(scope.querySelectorAll(".pre-loading")).some(isVisible); + const uid = modern ? (!loading && title && selectedUid === paneUid ? selectedUid : "") : fallback?.uid || ""; return { - uid: fallback?.uid || "", + uid, securityId: normalizeText(scope.querySelector("[data-security-id]")?.getAttribute("data-security-id")), hrName: fallback?.hrName || title, companyName: fallback?.companyName || "", @@ -167,27 +186,39 @@ } function readMessages(documentRef) { + refreshIdentityMetadata(documentRef); const seen = new Set(); const messages = []; - for (const selector of MESSAGE_SELECTORS) { - for (const element of documentRef.querySelectorAll(selector)) { - if (seen.has(element) || !isVisible(element)) continue; - seen.add(element); - const className = String(element.className || ""); - const directionHint = normalizeText(element.getAttribute?.("data-direction") || element.getAttribute?.("data-from")); - const direction = /(self|mine|outbound|本人|我)/i.test(directionHint) - || /(^|[\s_-])(item-myself|myself|self|mine|message-self|right)([\s_-]|$)/i.test(className) - ? "本人" : "对方"; - const content = element.querySelector("[class*='bubble'],[class*='message-content'],[class*='messageContent']") || element; - const type = content.querySelector("audio,[class*='voice']") ? "语音" - : content.querySelector("[class*='file'],[class*='attachment']") ? "附件" - : content.querySelector("img:not([class*='avatar'])") ? "图片" : "文本"; - const textNode = content.querySelector("[class*='text'],[class*='content']") || content; - const text = normalizeText(textNode?.innerText || textNode?.textContent || element.innerText || element.textContent); - const time = normalizeText(element.querySelector("time,[class*='time']")?.textContent || element.getAttribute?.("data-time")); - if (!text && type === "文本") continue; - messages.push({ from: direction, type, text, time }); + const scope = documentRef.querySelector?.(".chat-conversation") || documentRef; + const rows = Array.from(scope.querySelectorAll(".im-list > .message-item")); + const candidates = scope.querySelector?.(".im-list") ? rows : MESSAGE_SELECTORS.flatMap(selector => Array.from(scope.querySelectorAll(selector))); + for (const element of candidates) { + if (/\bitem-system\b/.test(String(element.className || ""))) continue; + if (seen.has(element) || !isVisible(element)) continue; + seen.add(element); + const className = String(element.className || ""); + const directionHint = normalizeText(element.getAttribute?.("data-direction") || element.getAttribute?.("data-from")); + const direction = /(self|mine|outbound|本人|我)/i.test(directionHint) + || /(^|[\s_-])(item-myself|myself|self|mine|message-self|right)([\s_-]|$)/i.test(className) + ? "本人" : "对方"; + const content = element.querySelector("[class*='bubble'],[class*='message-content'],[class*='messageContent']") || element; + const modern = element.matches?.(".im-list > .message-item") && scope !== documentRef; + const type = modern ? (element.getAttribute("data-getjobs-hr-message-type") || "未知类型") + : content.querySelector("audio,[class*='voice']") ? "语音" + : content.querySelector("[class*='file'],[class*='attachment']") ? "附件" + : content.querySelector("img:not([class*='avatar'])") ? "图片" : "文本"; + const textNode = content.querySelector(".text > p > .text-content") + || content.querySelector("[class*='text'],[class*='content']") || content; + const body = textNode.cloneNode?.(true); + if (body) { + body.querySelectorAll(".quote-message,.message-status,.item-time,.figure,[class*='avatar']").forEach(node => node.remove()); + body.querySelectorAll("img[alt]").forEach(node => node.replaceWith(node.getAttribute("alt"))); + body.querySelectorAll("br").forEach(node => node.replaceWith("\n")); } + const text = normalizeText(body ? body.textContent : textNode?.innerText || textNode?.textContent); + const time = normalizeText(element.querySelector("time,[class*='time']")?.textContent || element.getAttribute?.("data-time")); + if (!text && type === "文本") continue; + messages.push({ from: direction, type, text, time }); } return messages; } diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json index d5f45d6..a28d0f2 100644 --- a/chrome-extension/manifest.json +++ b/chrome-extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "投递牛马 Chrome Bridge", - "version": "1.6.0", + "version": "1.6.4", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzzdIlNVOv76Y/cSWrjD5Tg2Vlsha8yWHzsn46PBsg724/2dftOUzIIr2n70VRaRgGwEd8FjO/Y768Ori443zF4pQpWvuxXxm05YO25ILQ/+aJLmUycAEdWbkdhcagr4YXnXJdYlSCGSAToSQBjk+owQOdlBLQn5wofPoshrqayoJjRQ5aAUj1SuSlnNv9iimle8GMA1IaA1l5rw6K/chfcgwMTg6HxRAIoludt5JGbIBryi2Lu1hOJRMaDnL7A57ofBnn3qx3H2HIGWGkkTW9EMkls0XMXwx8+mJVIj5HSYl0EeuCvEoTa1W3i1CbOf3kY2yCPKS3Qz3lOvJiwJ4ZQIDAQAB", "description": "Use signed-in Chrome tabs to scan jobs, confirm deliveries, and review BOSS HR reply drafts for 投递牛马.", "icons": { @@ -47,6 +47,12 @@ "matches": ["https://www.zhaopin.com/*", "https://*.zhaopin.com/*"], "js": ["zhilian-scan-support.js", "zhilian-content.js"], "run_at": "document_idle" + }, + { + "matches": ["https://www.zhipin.com/web/geek/chat*"], + "js": ["boss-hr-identity.js"], + "world": "MAIN", + "run_at": "document_idle" } ] } diff --git a/chrome-extension/page-bridge.js b/chrome-extension/page-bridge.js index c478442..8575235 100644 --- a/chrome-extension/page-bridge.js +++ b/chrome-extension/page-bridge.js @@ -8,6 +8,7 @@ ]); const ALLOWED_PAGE_MESSAGE_TYPES = new Set([ "GET_JOBS_EXTENSION_PING", + "BOSS_HR_OPEN_CHAT", "BOSS_PAGE_STATUS", "BOSS_DEBUG_COLLECT", "BOSS_COLLECT_CURRENT_PAGE", diff --git a/chrome-extension/tests/background-tab-routing.test.cjs b/chrome-extension/tests/background-tab-routing.test.cjs index e7d2342..1a68871 100644 --- a/chrome-extension/tests/background-tab-routing.test.cjs +++ b/chrome-extension/tests/background-tab-routing.test.cjs @@ -35,6 +35,7 @@ function loadBackground({ contentReady = true, bossContentVersion = BOSS_CONTENT_VERSION, zhilianContentVersion = ZHILIAN_CONTENT_VERSION, + bossHrContentVersion = "2026-09-06-hr-all-conversations", bossDeliveryResponses = [], fetchImpl = async () => { throw new Error("fetch should not be called"); @@ -98,7 +99,7 @@ function loadBackground({ return { success: true, version: currentZhilianContentVersion }; } if (message.type === "BOSS_HR_CONTENT_VERSION") { - return { success: true, version: currentBossContentVersion }; + return { success: true, version: bossHrContentVersion }; } if (message.type === "BOSS_SCAN_STATUS" || message.type === "ZHILIAN_SCAN_STATUS_V2") { return statuses[tabId] || { success: true, isRunning: false, hasStoredTask: false, stage: "idle" }; @@ -413,7 +414,7 @@ test("binds the initiating Boss chat directly without opening or focusing anothe return jsonResponse({ success: true, data: { token: "test-action-token" } }); } if (url.endsWith("/api/hr-assistant/watch/start")) { - return jsonResponse({ success: true, data: { watching: true, watchSessionId: "watch-1" } }); + return jsonResponse({ success: true, data: { watching: true, watchSessionId: "watch-1", profileId: 1 } }); } throw new Error(`unexpected URL: ${url}`); } @@ -423,6 +424,7 @@ test("binds the initiating Boss chat directly without opening or focusing anothe source: "GET_JOBS_BOSS_CONTENT", type: "BOSS_LOCAL_API", operation: "hr-start", + body: { expectedProfileId: 1 }, timeoutMs: 30000 }, { tab: { id: 7, windowId: 3, url: "https://www.zhipin.com/web/geek/chat" } @@ -442,16 +444,17 @@ test("persists an HR Outbox identity before a content script opens the conversat const { context, dispatchRuntimeMessage, storage } = loadBackground({ tabs: [{ id: 7, windowId: 3, url: "https://www.zhipin.com/web/geek/chat", status: "complete" }] }); - await context.writeBossHrWatch({ watching: true, tabId: 7, watchSessionId: "watch-1" }); + await context.writeBossHrWatch({ watching: true, tabId: 7, watchSessionId: "watch-1", profileId: 1 }); const response = await dispatchRuntimeMessage({ source: "GET_JOBS_BOSS_HR_CONTENT", type: "BOSS_HR_OUTBOX_PUT", + watchSessionId: "watch-1", capture: { captureId: "capture-1", uid: "friend-1", unreadCount: 1, hrName: "HR" } }, { tab: { id: 7, url: "https://www.zhipin.com/web/geek/chat" } }); assert.equal(response.success, true); - assert.equal(storage.__GET_JOBS_BOSS_HR_OUTBOX__["capture-1"].uid, "friend-1"); + assert.equal(storage.__GET_JOBS_BOSS_HR_OUTBOX__["1:capture-1"].uid, "friend-1"); }); test("preserves backend profile errors and rejects unscoped job submission locally", async () => { @@ -902,3 +905,143 @@ test("rejects unsafe Boss API paths and out-of-scope pagination", () => { params: { scene: "1", query: "Java", city: "101280600", page: "1", pageSize: "11" } }).success, false); }); + + +test("HR backend 404 retains HTTP identity while malformed 200 stays a contract error", async () => { + for (const [status, expected] of [[404, "HR_BACKEND_UNAVAILABLE"], [200, "LOCAL_API_CONTRACT_MISMATCH"]]) { + let calls = 0; + const { context } = loadBackground({ tabs: [], fetchImpl: async () => { + calls++; return jsonResponse({ status, error: "Not Found" }, { status, ok: status === 200 }); + } }); + const result = await context.requestLocalApi("/api/hr-assistant/status", { method: "GET", operation: "hr-status" }); + assert.equal(result.httpStatus, status); + assert.equal(result.errorType, expected); + assert.equal(calls, 1); + } +}); + +test("chat entry focuses the bound tab, reuses an existing chat, or opens the fixed URL", async () => { + for (const mode of ["bound", "existing", "new"]) { + const tabs = mode === "new" ? [] : [ + { id: 7, windowId: 3, url: "https://www.zhipin.com/web/geek/chat", lastAccessed: 10 }, + { id: 8, windowId: 4, url: "https://www.zhipin.com/web/geek/chat", lastAccessed: 20 }, + ]; + const { context, tabList, dispatchRuntimeMessage, windowUpdates } = loadBackground({ tabs }); + if (mode === "bound") await context.writeBossHrWatch({ tabId: 7 }); + const result = await dispatchRuntimeMessage({ source: "GET_JOBS_PAGE", type: "BOSS_HR_OPEN_CHAT", url: "https://evil.example/" }, + { tab: { id: 20, url: "http://127.0.0.1:6866/env-config" } }); + assert.equal(result.success, true); + assert.equal(result.tabId, mode === "bound" ? 7 : mode === "existing" ? 8 : 1); + assert.equal(tabList.length, mode === "new" ? 1 : 2); + assert.equal(windowUpdates.length, 1); + assert.ok(tabList.every(tab => tab.url === "https://www.zhipin.com/web/geek/chat")); + const rejected = await dispatchRuntimeMessage({ source: "GET_JOBS_PAGE", type: "BOSS_HR_OPEN_CHAT" }, + { tab: { id: 21, url: "https://evil.example/" } }); + assert.equal(rejected.success, false); + } +}); + +test("Outbox acknowledgements and old-session captures cannot cross profiles or erase legacy records", async () => { + const { context, storage } = loadBackground({ tabs: [] }); + await context.writeBossHrOutbox({ + "legacy": { captureId: "legacy", uid: "old" }, + "1:same": { captureId: "same", uid: "first", profileId: 1 }, + "2:same": { captureId: "same", uid: "second", profileId: 2 }, + }); + await context.acknowledgeBossHrOutbox(["same"], 1); + assert.equal(storage.__GET_JOBS_BOSS_HR_OUTBOX__["1:same"], undefined); + assert.equal(storage.__GET_JOBS_BOSS_HR_OUTBOX__["2:same"].uid, "second"); + assert.equal(storage.__GET_JOBS_BOSS_HR_OUTBOX__.legacy.uid, "old"); + await context.writeBossHrWatch({ watching: true, tabId: 7, profileId: 2, watchSessionId: "new" }); + const rejected = await context.handleBossHrOutboxPut({ watchSessionId: "old", capture: { captureId: "late", uid: "uid" } }, + { tab: { id: 7, url: "https://www.zhipin.com/web/geek/chat" } }); + assert.equal(rejected.errorCode, "STALE_STATE"); + assert.equal(Object.keys(storage.__GET_JOBS_BOSS_HR_OUTBOX__).length, 2); +}); + + +test("a late scan cannot restore stopped watch state", async () => { + const { context } = loadBackground({ tabs: [] }); + const state = { watching: true, watchSessionId: "watch-1", profileId: 1, tabId: 7 }; + await context.writeBossHrWatch(state); + await context.writeBossHrWatch({ ...state, watching: false }); + assert.equal(await context.updateBossHrWatchIfActive(state, { scanRunning: false }), false); + assert.equal((await context.readBossHrWatch()).watching, false); +}); + +test("losing the send channel records an unknown outcome exactly once", async () => { + const results = []; + const { context } = loadBackground({ tabs: [], fetchImpl: async (url, options) => { + if (url.endsWith("/action-token")) return jsonResponse({ success: true, data: { token: "t" } }); + if (url.endsWith("/claim")) return jsonResponse({ success: true, data: { commandId: "cmd", leaseToken: "lease", leaseDeadlineEpochMs: Date.now() + 60000 } }); + if (url.endsWith("/result")) { results.push(JSON.parse(options.body)); return jsonResponse({ success: true, data: {} }); } + throw new Error(url); + } }); + await context.writeBossHrWatch({ watching: true, watchSessionId: "watch-1", profileId: 1, tabId: 7 }); + context.chrome.tabs.sendMessage = async () => { throw new Error("message channel closed"); }; + const result = await context.pollBossHrSendCommandLocked({ tab: { id: 7 } }, {}); + assert.equal(result.success, true); + assert.equal(results.length, 1); + assert.equal(results[0].outcome, "RESULT_UNKNOWN"); +}); + + +test("rejects the old HR script before any backend start request", async () => { + const { dispatchRuntimeMessage } = loadBackground({ + tabs: [{ id: 7, url: "https://www.zhipin.com/web/geek/chat", status: "complete" }], + bossHrContentVersion: "2026-09-06-hr-profile-guard" + }); + const response = await dispatchRuntimeMessage({ + source: "GET_JOBS_BOSS_CONTENT", type: "BOSS_LOCAL_API", operation: "hr-start", + body: { expectedProfileId: 1 } + }, { tab: { id: 7, url: "https://www.zhipin.com/web/geek/chat" } }); + assert.equal(response.success, false); + assert.equal(response.errorCode, "BOSS_HR_CONTENT_OUTDATED"); +}); + + +test("thirty minute mode is persisted and asks the content bridge to read all conversations", async () => { + const requests = []; + const { context, alarmCreates, storage, sentMessages } = loadBackground({ + tabs: [{ id: 7, url: "https://www.zhipin.com/web/geek/chat", status: "complete" }], + fetchImpl: async (url, options) => { + if (url.endsWith("action-token")) return jsonResponse({ success: true, data: { token: "test" } }); + requests.push({ url, body: JSON.parse(options.body) }); + if (url.endsWith("watch/start")) return jsonResponse({ success: true, data: { watchSessionId: "watch", profileId: 1 } }); + return jsonResponse({ success: true, data: { acknowledgedCaptureIds: [] } }); + } + }); + const run = context.runBossHrScan; + context.runBossHrScan = async () => {}; + await context.startBossHrWatch({ tab: { id: 7, url: "https://www.zhipin.com/web/geek/chat" } }, {}, 1, 30); + assert.equal(requests[0].body.intervalMinutes, 30); + assert.equal(storage.__GET_JOBS_BOSS_HR_WATCH__.intervalMinutes, 30); + assert.equal(alarmCreates[0].options.periodInMinutes, 30); + await run("initial"); + const scan = sentMessages.find(entry => entry.message.type === "BOSS_HR_SCAN"); + assert.equal(scan.message.scanAll, true); + assert.equal(scan.message.streamResults, true); + assert.equal(alarmCreates.at(-1).options.delayInMinutes, 30); +}); + +test("streamed capture rejects stale sessions and retains Outbox after an unknown AI submission", async () => { + let submissions = 0; + const { context, storage } = loadBackground({ + tabs: [], + fetchImpl: async (url) => { + if (url.endsWith("action-token")) return jsonResponse({ success: true, data: { token: "test" } }); + submissions++; + throw new Error("NetworkError"); + } + }); + await context.writeBossHrWatch({ watching: true, scanRunning: true, tabId: 7, profileId: 1, watchSessionId: "watch", scanId: "scan" }); + await context.writeBossHrOutbox({ "1:cap": { profileId: 1, uid: "101-0", captureId: "cap" } }); + const sender = { tab: { id: 7, url: "https://www.zhipin.com/web/geek/chat" } }; + const message = { watchSessionId: "old", scanId: "scan", capture: { captureId: "cap", session: { uid: "101-0" }, messages: [{ from: "对方", type: "文本", text: "你好" }] } }; + assert.equal((await context.submitBossHrCapture(message, sender)).errorCode, "STALE_STATE"); + assert.equal(submissions, 0); + message.watchSessionId = "watch"; + assert.equal((await context.submitBossHrCapture(message, sender)).success, false); + assert.equal(submissions, 1); + assert.ok(storage.__GET_JOBS_BOSS_HR_OUTBOX__["1:cap"]); +}); diff --git a/chrome-extension/tests/boss-hr-assistant.test.cjs b/chrome-extension/tests/boss-hr-assistant.test.cjs index 9bcf866..d1d4727 100644 --- a/chrome-extension/tests/boss-hr-assistant.test.cjs +++ b/chrome-extension/tests/boss-hr-assistant.test.cjs @@ -15,7 +15,7 @@ test("manifest loads the direct HR bridge and one-minute alarm capability", () = const bossScripts = manifest.content_scripts.find((entry) => entry.matches.some((value) => value.includes("zhipin.com"))).js; assert.deepEqual(bossScripts.slice(-3), ["boss-hr-support.js", "boss-hr-bridge.js", "boss-hr-assistant.js"]); assert.ok(manifest.permissions.includes("alarms")); - assert.equal(manifest.version, "1.6.0"); + assert.equal(manifest.version, "1.6.4"); }); test("assistant keeps full auto locked and sends only after explicit confirmation", () => { @@ -33,7 +33,7 @@ test("background binds one exact BOSS tab, scans every minute, and persists Outb const background = source("chrome-extension/background.js"); const bridge = source("chrome-extension/boss-hr-bridge.js"); assert.match(background, /BOSS_HR_ALARM_NAME/); - assert.match(background, /periodInMinutes: 1/); + assert.match(background, /periodInMinutes: intervalMinutes/); assert.match(background, /if \(bossHrScanPromise\)/); assert.match(background, /BOSS_HR_SCAN_TIMEOUT_MS = 5 \* 60 \* 1000/); assert.match(background, /LOCAL_API_BASE_URLS = \["http:\/\/127\.0\.0\.1:6866"\]/); diff --git a/chrome-extension/tests/manifest-id.test.cjs b/chrome-extension/tests/manifest-id.test.cjs index 7b815de..cfd52a7 100644 --- a/chrome-extension/tests/manifest-id.test.cjs +++ b/chrome-extension/tests/manifest-id.test.cjs @@ -17,7 +17,7 @@ function extensionIdFromKey(key) { test('manifest public key derives the backend allowlisted extension id', () => { const manifestPath = path.join(__dirname, '..', 'manifest.json'); const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); - assert.equal(manifest.version, '1.6.0'); + assert.equal(manifest.version, '1.6.4'); assert.equal(extensionIdFromKey(manifest.key), EXPECTED_EXTENSION_ID); const publicKey = crypto.createPublicKey({ diff --git a/chrome-extension/tests/profile-scoped-scan-contract.test.cjs b/chrome-extension/tests/profile-scoped-scan-contract.test.cjs index 89f632a..3600371 100644 --- a/chrome-extension/tests/profile-scoped-scan-contract.test.cjs +++ b/chrome-extension/tests/profile-scoped-scan-contract.test.cjs @@ -15,11 +15,11 @@ test("extension release and both content scripts use the profile-scoped contract const boss = source("boss-content.js"); const zhilian = source("zhilian-content.js"); - assert.equal(manifest.version, "1.6.0"); - assert.match(background, /BACKGROUND_VERSION = "2026-09-04-boss-hr-direct"/); - assert.match(background, /REQUIRED_BOSS_CONTENT_VERSION = "2026-09-04-boss-hr-direct"/); - assert.match(boss, /EXTENSION_VERSION = "2026-09-04-boss-hr-direct"/); - assert.match(zhilian, /EXTENSION_VERSION = "2026-09-04-boss-hr-direct"/); + assert.equal(manifest.version, "1.6.4"); + assert.match(background, /BACKGROUND_VERSION = "2026-09-06-hr-profile-guard"/); + assert.match(background, /REQUIRED_BOSS_CONTENT_VERSION = "2026-09-06-hr-profile-guard"/); + assert.match(boss, /EXTENSION_VERSION = "2026-09-06-hr-profile-guard"/); + assert.match(zhilian, /EXTENSION_VERSION = "2026-09-06-hr-profile-guard"/); }); test("both platforms bind cursors, dedupe, submissions and progress to profileId", () => { diff --git a/chrome-extension/zhilian-content.js b/chrome-extension/zhilian-content.js index 13a035e..8f07f8e 100644 --- a/chrome-extension/zhilian-content.js +++ b/chrome-extension/zhilian-content.js @@ -1,5 +1,5 @@ (function () { - const EXTENSION_VERSION = "2026-09-04-boss-hr-direct"; + const EXTENSION_VERSION = "2026-09-06-hr-profile-guard"; const CONTENT_INSTANCE_ID = `${Date.now()}-${Math.random().toString(16).slice(2)}`; window.__GET_JOBS_ZHILIAN_CONTENT__ = true; window.__GET_JOBS_ZHILIAN_CONTENT_VERSION__ = EXTENSION_VERSION; diff --git "a/doc/\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/doc/\344\275\277\347\224\250\346\214\207\345\215\227.md" index 73ac24a..a7a6cad 100644 --- "a/doc/\344\275\277\347\224\250\346\214\207\345\215\227.md" +++ "b/doc/\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -142,7 +142,7 @@ AI 配置主要用于 Boss 直聘岗位匹配和打招呼语生成。常见字 | --- | --- | | `HOOK_URL` | 企业微信机器人 webhook,用于推送运行消息 | | `AI_PROVIDER` | 默认 `codex`,复用本机 Codex 登录;填写 `api` 时改用远程接口 | -| `CODEX_MODEL` | Codex CLI 模型,默认 `gpt-5.6-sol` | +| `CODEX_MODEL` | Codex CLI 模型,默认 `gpt-6-astra` | | `AI_REQUEST_TIMEOUT_SECONDS` | 一次远程 AI 调用的总超时(含限流重试或兼容切换),默认 120 秒 | | `BASE_URL` | 仅远程 API 模式使用的模型服务地址 | | `API_KEY` | 仅远程 API 模式使用的密钥 | diff --git a/docs/BOSS_HR_ASSISTANT.md b/docs/BOSS_HR_ASSISTANT.md index f00a46c..c963e4a 100644 --- a/docs/BOSS_HR_ASSISTANT.md +++ b/docs/BOSS_HR_ASSISTANT.md @@ -45,3 +45,13 @@ | `APP_HR_KEY_PATH` | `%LOCALAPPDATA%\AI-JobPilot\secrets\hr-chat.key` | 加密密钥路径 | 不要把密钥、Token、Cookie、QQ 号或群号写入仓库或公开截图。 + + +## 值守版本与人物档案(2026-09-06) + +- 环境配置顶部提供“打开 BOSS 聊天页”,优先聚焦已绑定标签,其次复用聊天标签,否则新建固定 BOSS 聊天地址。打开页面不会自动启动值守或发送。 +- AI 调用方式、模型和密钥全局共用;简历、沟通资料和 QQ 设置按人物档案保存。切换档案不会切换浏览器 BOSS 账号,开始值守前必须核对两者。 +- 值守期间后端拒绝切换或删除当前档案,返回 HTTP 409 / `HR_WATCH_ACTIVE`。先停止值守;采集入库和已领取发送命令完成前仍不可切换。未知发送结果不自动重试。 +- 扩展 Outbox 增加档案和会话归属,只重放当前档案记录。旧版缺少归属的记录保留并提示人工处理,不能自动分配给新档案。 +- `/api/ready` 正常只表示基础服务健康。必须同时确认 `/api/hr-assistant/status` 返回成功;404 说明运行的后端缺少值守功能,应检查 6866 监听进程及其工作目录。 +- 同一次发布必须包含后端、前端静态资源与 Chrome 扩展 1.6.1。更新后刷新扩展、工作台和 BOSS 聊天页,避免旧内容脚本继续运行。 diff --git a/front/app/components/ProfileSwitcher.test.tsx b/front/app/components/ProfileSwitcher.test.tsx new file mode 100644 index 0000000..1ee8e6b --- /dev/null +++ b/front/app/components/ProfileSwitcher.test.tsx @@ -0,0 +1,24 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import ProfileSwitcher from './ProfileSwitcher' + +afterEach(() => vi.unstubAllGlobals()) + +it('keeps the original profile when the backend rejects switching during watch', async () => { + const original = { id: 1, name: '求职者甲', isActive: 1 } + const onChange = vi.fn() + const alert = vi.fn() + vi.stubGlobal('alert', alert) + vi.stubGlobal('fetch', vi.fn((url: string) => Promise.resolve(new Response(JSON.stringify( + url.endsWith('/activate') + ? { success: false, errorCode: 'HR_WATCH_ACTIVE', message: '请先停止值守再切换人物档案' } + : { success: true, data: [original, { id: 2, name: '求职者乙' }], current: original }, + ), { status: url.endsWith('/activate') ? 409 : 200, headers: { 'Content-Type': 'application/json' } })))) + render() + fireEvent.click(await screen.findByRole('button', { name: '求职者甲' })) + fireEvent.click(screen.getByText('求职者乙')) + await waitFor(() => expect(alert).toHaveBeenCalledWith('请先停止值守再切换人物档案')) + expect(screen.getByRole('button', { name: '求职者甲' })).toBeInTheDocument() + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith(original) +}) diff --git a/front/app/env-config/HrAssistantSettingsCard.tsx b/front/app/env-config/HrAssistantSettingsCard.tsx index e2d4569..e035f40 100644 --- a/front/app/env-config/HrAssistantSettingsCard.tsx +++ b/front/app/env-config/HrAssistantSettingsCard.tsx @@ -1,6 +1,7 @@ 'use client' import { useRef, useState } from 'react' +import HrWatchConnection from './HrWatchConnection' import { BiMessageDetail, BiRefresh, BiSave } from 'react-icons/bi' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' @@ -216,6 +217,7 @@ export default function HrAssistantSettingsCard() { 按人物档案保存沟通资料和 NapCat 通知设置;BOSS 页面仅保留值守与待确认回复。 + ({ + getChromeBridgeStatus: vi.fn(), sendChromeBridgeMessage: vi.fn(), +})) +beforeEach(() => { vi.mocked(getChromeBridgeStatus).mockResolvedValue({ success: true }) }) +afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks() }) + +it('shows the bound profile and opens chat through the extension without starting watch', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ success: true, data: { + watching: true, profileId: 2, currentProfileId: 2, currentProfileName: '求职者乙', + profileSwitchBlocked: true, chromeBridge: { tabId: 77, tabBound: true }, + } }), { headers: { 'Content-Type': 'application/json' } })) + vi.stubGlobal('fetch', fetchMock) + vi.mocked(sendChromeBridgeMessage).mockResolvedValue({ success: true }) + render() + expect(await screen.findByText(/求职者乙/)).toBeInTheDocument() + expect(screen.getByText('绑定标签页:#77')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '打开 BOSS 聊天页' })) + expect(await screen.findByText(/已打开聊天页/)).toBeInTheDocument() + expect(sendChromeBridgeMessage).toHaveBeenCalledWith({ type: 'BOSS_HR_OPEN_CHAT' }, 5000) + expect(fetchMock.mock.calls.every(([url]) => String(url).endsWith('/status'))).toBe(true) +}) + +it('distinguishes a missing backend from an unavailable extension and offers the fixed fallback URL', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status: 404 }))) + vi.mocked(getChromeBridgeStatus).mockResolvedValue({ success: false }) + render() + expect(await screen.findByText(/当前后端缺少 BOSS 值守功能/)).toBeInTheDocument() + expect(screen.getByText(/Chrome 扩展:未连接/)).toBeInTheDocument() + expect(screen.getByRole('link', { name: '直接在浏览器打开' })).toHaveAttribute('href', 'https://www.zhipin.com/web/geek/chat') +}) diff --git a/front/app/env-config/HrWatchConnection.tsx b/front/app/env-config/HrWatchConnection.tsx new file mode 100644 index 0000000..424c474 --- /dev/null +++ b/front/app/env-config/HrWatchConnection.tsx @@ -0,0 +1,86 @@ +'use client' + +import { useEffect, useState } from 'react' +import { Button } from '@/components/ui/button' +import { API_BASE, readApiResponse, friendlyApiError } from '@/lib/api' +import { getChromeBridgeStatus, sendChromeBridgeMessage } from '@/lib/chromeBridge' + +type WatchStatus = { + watching: boolean + profileId?: number + currentProfileId?: number + currentProfileName?: string + profileSwitchBlocked?: boolean + chromeBridge?: { tabId?: number; tabBound?: boolean } +} + +export default function HrWatchConnection() { + const [watch, setWatch] = useState(null) + const [backendError, setBackendError] = useState('正在检查值守后端…') + const [extension, setExtension] = useState(false) + const [opening, setOpening] = useState(false) + const [message, setMessage] = useState('') + + useEffect(() => { + let disposed = false + let busy = false + const refresh = async () => { + if (busy) return + busy = true + try { + const [backend, bridge] = await Promise.allSettled([ + (async () => { + const response = await fetch(`${API_BASE}/api/hr-assistant/status`, { cache: 'no-store' }) + if (response.status === 404) throw new Error('当前后端缺少 BOSS 值守功能(HTTP 404),请使用包含值守功能的服务版本。') + const result = await readApiResponse(response, '值守后端检查失败') + if (!result.data || typeof result.data.watching !== 'boolean') throw new Error('值守接口响应格式不兼容,请更新前后端版本。') + return result.data + })(), + getChromeBridgeStatus(), + ]) + if (disposed) return + setExtension(bridge.status === 'fulfilled' && bridge.value.success) + if (backend.status === 'fulfilled') { + setWatch(backend.value) + setBackendError('') + } else { + setWatch(null) + setBackendError(friendlyApiError(backend.reason, '无法连接值守后端')) + } + } finally { busy = false } + } + void refresh() + const timer = window.setInterval(() => void refresh(), 5000) + return () => { disposed = true; window.clearInterval(timer) } + }, []) + + const openChat = async () => { + setOpening(true) + try { + const response = await sendChromeBridgeMessage({ type: 'BOSS_HR_OPEN_CHAT' }, 5000) + setMessage(response.success ? '已打开聊天页,请确认档案与 BOSS 账号后点击“开始值守”。' : response.message || '扩展未响应,请使用下方直接打开链接。') + } catch (error) { + setMessage(friendlyApiError(error, '打开聊天页失败,请使用下方直接打开链接。')) + } finally { setOpening(false) } + } + + return ( +
+
+ + 直接在浏览器打开 +
+
+

{backendError || `值守后端可用 · ${watch?.watching ? '值守中' : '已停止'}`}

+

Chrome 扩展:{extension ? '已连接' : '未连接,请加载或刷新投递牛马 Chrome Bridge 扩展,再刷新此页面。'}

+

当前人物档案:{watch?.currentProfileName || '尚未读取'}{watch?.profileId ? ` · 值守档案 ID:${watch.profileId}` : ''}

+

绑定标签页:{watch?.chromeBridge?.tabBound ? `#${watch.chromeBridge.tabId}` : '未绑定'}

+ {watch?.profileSwitchBlocked &&

请先停止值守再切换人物档案;正在执行的采集或发送需要先处理完成。

} +

切换人物档案不会切换 BOSS 登录账号。请确认聊天页登录的是对应求职者。

+ {message &&

{message}

} +
+
+ ) +} diff --git a/front/app/env-config/page.tsx b/front/app/env-config/page.tsx index bc625c8..72e8c05 100644 --- a/front/app/env-config/page.tsx +++ b/front/app/env-config/page.tsx @@ -15,7 +15,7 @@ export default function EnvConfig() { hookUrl: '', aiProvider: 'codex', codexPath: 'codex', - codexModel: 'gpt-5.6-sol', + codexModel: 'gpt-6-astra', codexTimeoutSeconds: '300', apiTimeoutSeconds: '120', baseUrl: '', @@ -54,7 +54,7 @@ export default function EnvConfig() { hookUrl: '', aiProvider: result.data.AI_PROVIDER === 'api' || result.data.AI_PROVIDER === 'remote' ? 'api' : 'codex', codexPath: result.data.CODEX_PATH || 'codex', - codexModel: result.data.CODEX_MODEL || 'gpt-5.6-sol', + codexModel: result.data.CODEX_MODEL || 'gpt-6-astra', codexTimeoutSeconds: result.data.CODEX_TIMEOUT_SECONDS || '300', apiTimeoutSeconds: result.data.AI_REQUEST_TIMEOUT_SECONDS || '120', baseUrl: result.data.BASE_URL || '', @@ -262,7 +262,7 @@ export default function EnvConfig() { AI 调用方式 - 本机默认复用 Codex/ChatGPT 登录态;需要时仍可手动切回远程 API + 全局共用:切换人物档案不会改变 AI 调用方式、模型或密钥。本机默认复用 Codex/ChatGPT 登录态;需要时可手动切回远程 API
diff --git a/front/lib/boss-hr-dom.test.ts b/front/lib/boss-hr-dom.test.ts new file mode 100644 index 0000000..c879edc --- /dev/null +++ b/front/lib/boss-hr-dom.test.ts @@ -0,0 +1,210 @@ +import { createRequire } from 'node:module' +import { readFileSync } from 'node:fs' +import { runInNewContext } from 'node:vm' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const require = createRequire(import.meta.url) +const identity = require('../../chrome-extension/boss-hr-identity.js') +const support = require('../../chrome-extension/boss-hr-support.js') + +function addCard(id = '101', source = 0, name = '王女士') { + const wrapper = document.createElement('div') + wrapper.className = 'friend-content-warp' + wrapper.innerHTML = `
2${name}测试公司方便聊聊吗?
` + const props = { friendId: id, friendSource: source, uniqueId: `${id}-${source}`, name, brandName: '测试公司' } + Object.assign(wrapper, { __vue__: { $el: wrapper, $props: { source: props } } }) + document.querySelector('.user-list')!.append(wrapper) + return { wrapper, card: wrapper.firstElementChild!, props } +} + +function bindMessages() { + document.querySelectorAll('.im-list > .message-item').forEach((row, index) => { + const mid = `synthetic-${index}` + row.setAttribute('data-mid', mid) + Object.assign(row, { __vue__: { $el: row, $props: { message: { mid, messageType: row.getAttribute('data-fixture-type') || 'text' } } } }) + }) +} + +// This fixture follows the observed DOM and the public v5535 rendering code. +// It contains no real HR identifiers, message history or account data. +describe('BOSS virtual-list identity adapter', () => { + beforeEach(() => { + document.body.innerHTML = '
    ' + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ width: 100, height: 40 } as DOMRect) + identity.install(document) + }) + + it('recognizes a DOM-only card with no legacy UID and separates same-name HRs and sources', () => { + const a = addCard('101', 0) + const b = addCard('102', 0) + const c = addCard('101', 1) + expect(support.chatItems(document)).toEqual([a.card, b.card, c.card]) + expect(support.itemSnapshot(a.card)).toMatchObject({ uid: '101-0', unreadCount: 2, hrName: '王女士', companyName: '测试公司' }) + expect(support.findByUid(document, '101-1').unique).toBe(c.card) + }) + + it('clears recycled metadata when props disappear and never falls back to name or HTML UID', () => { + const a = addCard() + expect(support.itemSnapshot(a.card).uid).toBe('101-0') + Object.assign(a.wrapper, { __vue__: undefined }) + a.card.setAttribute('data-friend-id', 'old-id') + expect(support.itemSnapshot(a.card).uid).toBe('') + }) + + it('rebinds reused virtual nodes only to internally consistent IDs', () => { + const a = addCard() + support.chatItems(document) + a.props.friendId = '202' + expect(support.itemSnapshot(a.card).uid).toBe('') + a.props.uniqueId = '202-0' + expect(support.itemSnapshot(a.card).uid).toBe('202-0') + expect(support.findByUid(document, '101-0').unique).toBeNull() + }) + + it('rejects stale component props and duplicate identities', () => { + const a = addCard() + a.props.name = '别的人' + expect(support.itemSnapshot(a.card).uid).toBe('') + a.props.name = '王女士' + addCard() + expect(support.findByUid(document, '101-0').matches).toHaveLength(2) + expect(support.findByUid(document, '101-0').unique).toBeNull() + }) + + it('reads selected identity rather than echoing the expected target', () => { + const a = addCard('101') + const b = addCard('102') + b.card.classList.add('selected') + const pane = document.querySelector('.chat-conversation')! + Object.assign(pane, { __vue__: { $el: pane, selectedFriend$: b.props } }) + expect(support.currentSession(document, { uid: '101-0' }).uid).toBe('102-0') + Object.assign(pane, { __vue__: { $el: pane, selectedFriend$: a.props } }) + expect(support.currentSession(document, { uid: '101-0' }).uid).toBe('') + Object.assign(pane, { __vue__: { $el: pane, selectedFriend$: b.props } }) + a.card.classList.add('selected') + expect(support.currentSession(document, { uid: '101-0' }).uid).toBe('') + }) + + it('reads messages in DOM order without duplicating the history container or nested bubbles', () => { + document.querySelector('.im-list')!.innerHTML = '
  • 请问何时到岗?
  • 两周内。
  • 系统提示
  • 好的,明天方便面试吗?
  • ' + bindMessages() + const messages = support.readMessages(document) + expect(messages).toHaveLength(3) + expect(messages.map((message: { from: string }) => message.from)).toEqual(['对方', '本人', '对方']) + expect(support.latestInbound(messages).text).toBe('好的,明天方便面试吗?') + }) + + it('uses message type rather than avatars, quoted images or inline emoji', () => { + document.querySelector('.im-list')!.innerHTML = ` +
  • 旧引用

    明天方便吗
    [微笑]

  • +
  • +
  • 语音
  • +
  • 附件.pdf
  • +
  • 视频
  • +
  • 新卡片
  • ` + bindMessages() + const messages = support.readMessages(document) + expect(messages.map((message: { type: string }) => message.type)).toEqual(['文本', '图片', '语音', '附件', '视频', '其他']) + expect(messages[0].text).toBe('明天方便吗 [微笑]') + expect(messages[1].text).toBe('') + }) + + it('clears stale message metadata and does not guess text when a row is recycled', () => { + document.querySelector('.im-list')!.innerHTML = '
  • 消息
  • ' + bindMessages() + const row = document.querySelector('.message-item')! + expect(support.readMessages(document)[0].type).toBe('文本') + row.setAttribute('data-mid', 'recycled-message') + expect(support.readMessages(document)[0].type).toBe('未知类型') + Object.assign(row, { __vue__: undefined }) + expect(support.readMessages(document)[0].type).toBe('未知类型') + }) + + it('rejects malformed, synthetic and numerically unsafe identity fields', () => { + for (const source of [ + { friendId: 0, friendSource: 0, uniqueId: '0-0' }, + { friendId: '101', friendSource: 0, uniqueId: '王女士' }, + { friendId: '101', friendSource: -1, uniqueId: '101--1' }, + { friendId: Number.MAX_SAFE_INTEGER + 1, friendSource: 0, uniqueId: `${Number.MAX_SAFE_INTEGER + 1}-0` }, + ]) expect(identity.sourceUid(source)).toBe('') + }) + + it('captures a current-layout unread conversation only after persisting its stable Outbox identity', async () => { + const { card, props } = addCard() + const pane = document.querySelector('.chat-conversation')! + let opened = false + card.addEventListener('click', () => { + opened = true + card.classList.add('selected') + Object.assign(pane, { __vue__: { $el: pane, selectedFriend$: props } }) + pane.querySelector('.im-list')!.innerHTML = '
  • 明天方便面试吗?
  • ' + bindMessages() + }) + type Result = { success: boolean; captures: Array<{ session: { uid: string }; messages: Array<{ text: string }> }> } + let listener!: (message: object, sender: object, respond: (result: Result) => void) => unknown + const writes: string[] = [] + runInNewContext(readFileSync(require.resolve('../../chrome-extension/boss-hr-bridge.js'), 'utf8'), { + window, document, location: { pathname: '/web/geek/chat' }, + GetJobsBossHrSupport: support, Event, getComputedStyle, + setTimeout: (callback: () => void) => setTimeout(callback, 0), + chrome: { runtime: { + onMessage: { addListener: (value: typeof listener) => { listener = value } }, + sendMessage: (message: { type: string; capture: { uid: string } }, respond: (result: object) => void) => { + expect(opened).toBe(false) + expect(message.type).toBe('BOSS_HR_OUTBOX_PUT') + writes.push(message.capture.uid) + respond({ success: true }) + }, + } }, + }) + const result = await new Promise(resolve => listener({ + source: 'GET_JOBS_BACKGROUND', type: 'BOSS_HR_SCAN', deadlineAt: Date.now() + 30_000, + scanId: 'synthetic-scan', watchSessionId: 'synthetic-watch', outbox: [], + }, {}, resolve)) + expect(writes).toEqual(['101-0']) + expect(result.success).toBe(true) + expect(result.captures).toHaveLength(1) + expect(result.captures[0].session.uid).toBe('101-0') + expect(result.captures[0].messages[0].text).toBe('明天方便面试吗?') + }) + + it('reads already-read conversations sequentially, persists each result and stops on an unknown submission', async () => { + const a = addCard('101') + const b = addCard('102') + const c = addCard('103') + const pane = document.querySelector('.chat-conversation')! + const order: string[] = [] + for (const entry of [a, b, c]) { + entry.card.querySelector('.notice-badge')!.remove() + entry.card.addEventListener('click', () => { + order.push(`open:${entry.props.uniqueId}`) + document.querySelectorAll('.friend-content').forEach(card => card.classList.remove('selected')) + entry.card.classList.add('selected') + Object.assign(pane, { __vue__: { $el: pane, selectedFriend$: entry.props } }) + pane.querySelector('.im-list')!.innerHTML = '
  • 方便聊聊吗?
  • ' + bindMessages() + }) + } + let listener!: (message: object, sender: object, respond: (result: { success: boolean }) => void) => unknown + runInNewContext(readFileSync(require.resolve('../../chrome-extension/boss-hr-bridge.js'), 'utf8'), { + window: { top: window, self: window }, document, location: { pathname: '/web/geek/chat' }, + GetJobsBossHrSupport: support, Event, getComputedStyle, + setTimeout: (callback: () => void) => setTimeout(callback, 0), + chrome: { runtime: { + onMessage: { addListener: (value: typeof listener) => { listener = value } }, + sendMessage: (message: { type: string; capture: { uid?: string; session?: { uid: string }; messages?: Array<{ type: string }> } }, respond: (result: object) => void) => { + const uid = message.capture.uid || message.capture.session!.uid + order.push(`${message.type === 'BOSS_HR_OUTBOX_PUT' ? 'put' : 'save'}:${uid}`) + if (message.type === 'BOSS_HR_CAPTURE_RESULT') expect(message.capture.messages![0].type).toBe('文本') + respond({ success: !(message.type === 'BOSS_HR_CAPTURE_RESULT' && uid === '102-0'), message: '结果未知' }) + }, + } }, + }) + const result = await new Promise<{ success: boolean }>(resolve => listener({ + source: 'GET_JOBS_BACKGROUND', type: 'BOSS_HR_SCAN', deadlineAt: Date.now() + 30_000, + scanId: 'full-scan', watchSessionId: 'watch', scanAll: true, streamResults: true, outbox: [], + }, {}, resolve)) + expect(result.success).toBe(false) + expect(order).toEqual(['put:101-0', 'open:101-0', 'save:101-0', 'put:102-0', 'open:102-0', 'save:102-0']) + }) +}) diff --git a/front/lib/boss-hr-panel.test.ts b/front/lib/boss-hr-panel.test.ts new file mode 100644 index 0000000..7bec67b --- /dev/null +++ b/front/lib/boss-hr-panel.test.ts @@ -0,0 +1,35 @@ +import { createRequire } from 'node:module' +import { readFileSync } from 'node:fs' +import { runInNewContext } from 'node:vm' +import { expect, it, vi } from 'vitest' + +const require = createRequire(import.meta.url) + +it('explains manual review and AI failure without enabling an empty draft or rendering markup', async () => { + const attach = Element.prototype.attachShadow + vi.spyOn(Element.prototype, 'attachShadow').mockImplementation(function (this: Element) { + return attach.call(this, { mode: 'open' }) + }) + const proposals = [ + { id: 1, classification: 'NEEDS_USER', highValue: true, riskTags: ['NON_TEXT_MESSAGE'], summary: 'HR 发送了非文本消息,需要人工查看。', missingFacts: ['请人工查看 图片'], draft: '', status: 'REVIEW_REQUIRED' }, + { id: 2, classification: 'NEEDS_USER', highValue: true, riskTags: ['AI_FAILURE'], summary: 'AI 草稿生成失败,需要人工填写回复。', missingFacts: [''], draft: '', status: 'REVIEW_REQUIRED' }, + { id: 3, classification: 'REJECTION', highValue: false, riskTags: [], summary: '对方暂不考虑。', draft: '', status: 'REVIEW_REQUIRED' }, + ] + const operations: string[] = [] + runInNewContext(readFileSync(require.resolve('../../chrome-extension/boss-hr-assistant.js'), 'utf8'), { + document, location: { pathname: '/web/geek/chat' }, + window: { top: window, self: window, setInterval: vi.fn() }, + chrome: { runtime: { sendMessage: (message: { operation: string }, respond: (value: object) => void) => { + operations.push(message.operation) + respond({ success: true, data: { success: true, data: message.operation === 'hr-status' ? { watching: false } : proposals } }) + } } }, + }) + const root = document.getElementById('getjobs-boss-hr-assistant')!.shadowRoot! + await vi.waitFor(() => expect(root.querySelectorAll('.card')).toHaveLength(3)) + expect(Array.from(root.querySelectorAll('.tag')).map(node => node.textContent)).toEqual(['需人工查看', '草稿生成失败', '婉拒 / 无需回复']) + expect(root.textContent).toContain('需要处理:请人工查看 图片') + expect(root.textContent).toContain('') + expect(root.querySelector('img')).toBeNull() + expect(Array.from(root.querySelectorAll('.card .primary')).every(node => node.disabled)).toBe(true) + expect(operations).toEqual(['hr-status', 'hr-proposals']) +}) diff --git a/src/main/java/com/getjobs/application/controller/GlobalExceptionHandler.java b/src/main/java/com/getjobs/application/controller/GlobalExceptionHandler.java index 23f6c12..f05fdb6 100644 --- a/src/main/java/com/getjobs/application/controller/GlobalExceptionHandler.java +++ b/src/main/java/com/getjobs/application/controller/GlobalExceptionHandler.java @@ -22,6 +22,11 @@ public ResponseEntity> handleMaxUploadSize(MaxUploadSizeExce return ResponseEntity.badRequest().body(failure("UPLOAD_TOO_LARGE", "文件过大,请压缩到30MB以内后再上传", requestId)); } + @ExceptionHandler(com.getjobs.application.service.HrProfileGuard.WatchActiveException.class) + public ResponseEntity> handleWatchActive(com.getjobs.application.service.HrProfileGuard.WatchActiveException e) { + return ResponseEntity.status(409).body(failure("HR_WATCH_ACTIVE", e.getMessage(), requestId())); + } + @ExceptionHandler(IllegalStateException.class) public ResponseEntity> handleIllegalState(IllegalStateException e) { String requestId = requestId(); diff --git a/src/main/java/com/getjobs/application/controller/HrAssistantController.java b/src/main/java/com/getjobs/application/controller/HrAssistantController.java index 7b0fbb3..0bfda5b 100644 --- a/src/main/java/com/getjobs/application/controller/HrAssistantController.java +++ b/src/main/java/com/getjobs/application/controller/HrAssistantController.java @@ -77,7 +77,7 @@ public ResponseEntity start( if (!localActionTokenService.isValid(actionToken)) return unauthorized(); if (request == null) return badRequest("值守启动请求不能为空"); return execute(() -> watchService.start(request.getTabId(), request.getUrl(), request.getContentVersion(), - request.getBrowserSessionId())); + request.getBrowserSessionId(), request.getExpectedProfileId(), request.getIntervalMinutes())); } @PostMapping("/watch/heartbeat") @@ -117,8 +117,8 @@ public ResponseEntity claimSendCommand( if (request == null) return badRequest("发送命令领取请求不能为空"); return execute(() -> { Long profileId = profileService.getCurrentProfileId(); - watchService.assertActiveSession(profileId, request.getWatchSessionId(), request.getTabId()); - return actionService.claim(profileId, request.getWatchSessionId()); + return watchService.withSession(profileId, request.getWatchSessionId(), request.getTabId(), false, + () -> actionService.claim(profileId, request.getWatchSessionId())); }); } @@ -131,9 +131,9 @@ public ResponseEntity completeSendCommand( if (request == null) return badRequest("发送结果不能为空"); return execute(() -> { Long profileId = profileService.getCurrentProfileId(); - watchService.assertActiveSession(profileId, request.getWatchSessionId(), request.getTabId()); - return actionService.complete(profileId, request.getWatchSessionId(), id, request.getLeaseToken(), - request.getOutcome(), request.getEvidence(), request.getObservedLatestInbound()); + return watchService.withSession(profileId, request.getWatchSessionId(), request.getTabId(), true, + () -> actionService.complete(profileId, request.getWatchSessionId(), id, request.getLeaseToken(), + request.getOutcome(), request.getEvidence(), request.getObservedLatestInbound())); }); } @@ -180,6 +180,8 @@ private ResponseEntity execute(Action action) { String requestId = UUID.randomUUID().toString(); try { return ResponseEntity.ok(envelope(true, "", "", requestId, action.run())); + } catch (com.getjobs.application.service.HrProfileGuard.WatchActiveException e) { + return failure(HttpStatus.CONFLICT, "HR_WATCH_ACTIVE", e.getMessage(), requestId); } catch (HrAssistantStore.StaleProposalException e) { return failure(HttpStatus.CONFLICT, "STALE_STATE", e.getMessage(), requestId); } catch (IllegalArgumentException e) { @@ -246,10 +248,12 @@ public static class ProposalActionRequest { @Data public static class WatchStartRequest { + private int intervalMinutes = 1; private int tabId; private String url; private String contentVersion; private String browserSessionId; + private Long expectedProfileId; } @Data diff --git a/src/main/java/com/getjobs/application/hr/HrAssistantTypes.java b/src/main/java/com/getjobs/application/hr/HrAssistantTypes.java index 187d783..3f96740 100644 --- a/src/main/java/com/getjobs/application/hr/HrAssistantTypes.java +++ b/src/main/java/com/getjobs/application/hr/HrAssistantTypes.java @@ -103,7 +103,8 @@ public record SendCommandView( String sourceFingerprint, ChatMessage expectedLatestInbound, String draft, - LocalDateTime expiresAt + LocalDateTime expiresAt, + long leaseDeadlineEpochMs ) { } @@ -183,7 +184,11 @@ public record WatchStatus( String lastError, ChromeBridgeStatus chromeBridge, boolean napcatConnected, - boolean fullAutoLocked + boolean fullAutoLocked, + Long profileId, + Long currentProfileId, + String currentProfileName, + boolean profileSwitchBlocked ) { } } diff --git a/src/main/java/com/getjobs/application/service/CodexCliService.java b/src/main/java/com/getjobs/application/service/CodexCliService.java index 373ad3f..1918e87 100644 --- a/src/main/java/com/getjobs/application/service/CodexCliService.java +++ b/src/main/java/com/getjobs/application/service/CodexCliService.java @@ -93,7 +93,7 @@ String run(String content, List imagePaths, Map config) { String run(String content, List imagePaths, String outputSchema, Map config) { String executable = resolveExecutable(value(config, "CODEX_PATH", "codex")); - String model = value(config, "CODEX_MODEL", "gpt-5.6-sol"); + String model = value(config, "CODEX_MODEL", "gpt-6-astra"); int timeoutSeconds = parseTimeout(value(config, "CODEX_TIMEOUT_SECONDS", "300")); long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds); diff --git a/src/main/java/com/getjobs/application/service/ConfigService.java b/src/main/java/com/getjobs/application/service/ConfigService.java index f65a5c5..182d94a 100644 --- a/src/main/java/com/getjobs/application/service/ConfigService.java +++ b/src/main/java/com/getjobs/application/service/ConfigService.java @@ -191,7 +191,7 @@ public Map getAiConfigs() { result.put("AI_PROVIDER", "remote".equals(provider) ? "api" : provider); result.put("CODEX_PATH", optionalAiConfigValue("CODEX_PATH", "codex")); result.put("CODEX_HOME", optionalAiConfigValue("CODEX_HOME", "")); - result.put("CODEX_MODEL", optionalAiConfigValue("CODEX_MODEL", "gpt-5.6-sol")); + result.put("CODEX_MODEL", optionalAiConfigValue("CODEX_MODEL", "gpt-6-astra")); result.put("CODEX_TIMEOUT_SECONDS", optionalAiConfigValue("CODEX_TIMEOUT_SECONDS", "300")); result.put("AI_REQUEST_TIMEOUT_SECONDS", optionalAiConfigValue("AI_REQUEST_TIMEOUT_SECONDS", "120")); if ("codex".equals(provider)) { diff --git a/src/main/java/com/getjobs/application/service/HrAssistantStore.java b/src/main/java/com/getjobs/application/service/HrAssistantStore.java index c0a73e6..db49083 100644 --- a/src/main/java/com/getjobs/application/service/HrAssistantStore.java +++ b/src/main/java/com/getjobs/application/service/HrAssistantStore.java @@ -237,6 +237,19 @@ SELECT COUNT(*) FROM hr_reply_proposal return count != null && count > 0; } + @Transactional + public void expireAnsweredProposals(long conversationId) { + jdbcTemplate.update(""" + UPDATE hr_reply_proposal SET status='EXPIRED', version=version+1, updated_at=CURRENT_TIMESTAMP + WHERE conversation_id=? AND status IN ('OBSERVED','GENERATING','REVIEW_REQUIRED','APPROVED') + """, conversationId); + jdbcTemplate.update(""" + UPDATE hr_send_command SET status='STALE', outcome='STALE', updated_at=CURRENT_TIMESTAMP + WHERE status='PENDING' AND proposal_id IN ( + SELECT id FROM hr_reply_proposal WHERE conversation_id=? AND status='EXPIRED') + """, conversationId); + } + @Transactional public long createProposal(Long profileId, long conversationId, @@ -438,7 +451,9 @@ public SendCommandView claimSendCommand(Long profileId, String watchSessionId) { .orElseThrow(() -> new StaleProposalException("发送命令的来源消息已不存在")); return new SendCommandView(commandId, leaseToken, proposalId, record.uid(), record.hrName(), record.companyName(), record.jobName(), record.sourceFingerprint(), expectedInbound, - record.draft(), record.expiresAt()); + record.draft(), record.expiresAt(), java.util.Objects.requireNonNull(jdbcTemplate.queryForObject( + "SELECT CAST(strftime('%s', lease_expires_at) AS INTEGER)*1000 FROM hr_send_command WHERE command_id=?", + Long.class, commandId))); } @Transactional @@ -499,7 +514,13 @@ public ProposalView completeSendCommand(Long profileId, return getProposalView(profileId, command.proposalId()); } - private void expireUnconfirmedLeases() { + public boolean hasLeasedSendCommands() { + Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM hr_send_command WHERE status='LEASED'", Integer.class); + return count != null && count > 0; + } + + @Transactional + public void expireUnconfirmedLeases() { List expired = jdbcTemplate.query(""" SELECT proposal_id FROM hr_send_command WHERE status='LEASED' AND lease_expires_at watching.get() || processingScan.get() || browserScanRunning || store.hasLeasedSendCommands()); this.store = store; this.draftService = draftService; this.events = events; @@ -56,67 +61,82 @@ public HrAssistantWatchService(ProfileService profileService, this.maxConversations = Math.max(1, Math.min(maxConversations, 100)); } - public synchronized WatchStatus start(int tabId, String url, String contentVersion, String browserSessionId) { - validateChatTab(tabId, url, contentVersion, browserSessionId); - if (watching.get()) { - if (session != null && session.tabId() == tabId && session.browserSessionId().equals(browserSessionId)) return status(); - throw new IllegalStateException("已有其他 BOSS 标签页正在值守,请先在原标签页停止"); - } - Long profileId = profileService.getCurrentProfileId(); - session = new WatchSession(UUID.randomUUID().toString(), browserSessionId.trim(), profileId, - tabId, url.trim(), contentVersion.trim()); - watching.set(true); - processingScan.set(false); - browserScanRunning = false; - lastScanAt = null; - lastHeartbeatAt = LocalDateTime.now(); - lastError = ""; - outboxCount = 0; - WatchStatus status = status(); - events.emit("watch-status", status); - return status; + public WatchStatus start(int tabId, String url, String contentVersion, String browserSessionId, Long expectedProfileId) { + return start(tabId, url, contentVersion, browserSessionId, expectedProfileId, 1); } - public synchronized WatchStatus stop(String watchSessionId, String reason) { - if (session != null && watchSessionId != null && !watchSessionId.isBlank() - && !session.watchSessionId().equals(watchSessionId.trim())) { - throw new HrAssistantStore.StaleProposalException("值守会话已变化,拒绝停止其他标签页的值守"); - } - watching.set(false); - processingScan.set(false); - browserScanRunning = false; - lastError = safe(reason); - if (session != null && !lastError.isBlank() && !lastError.startsWith("USER_STOPPED")) { - napCatGateway.notifySystemFault(session.profileId(), lastError); - } - WatchStatus status = status(); - events.emit("watch-status", status); - return status; + public WatchStatus start(int tabId, String url, String contentVersion, String browserSessionId, Long expectedProfileId, int intervalMinutes) { + if (intervalMinutes != 1 && intervalMinutes != 30) throw new IllegalArgumentException("值守间隔仅支持 1 分钟或 30 分钟"); + return profileGuard.locked(() -> { + if (expectedProfileId == null || !expectedProfileId.equals(profileService.getCurrentProfileId())) { + throw new HrAssistantStore.StaleProposalException("当前人物档案已变化,请刷新后重新开始值守"); + } + validateChatTab(tabId, url, contentVersion, browserSessionId); + if (watching.get()) { + if (session != null && session.tabId() == tabId && session.browserSessionId().equals(browserSessionId)) return status(); + throw new IllegalStateException("已有其他 BOSS 标签页正在值守,请先在原标签页停止"); + } + profileGuard.requireChangeAllowed(); + Long profileId = profileService.getCurrentProfileId(); + session = new WatchSession(UUID.randomUUID().toString(), browserSessionId.trim(), profileId, + tabId, url.trim(), contentVersion.trim()); + watching.set(true); + scanIntervalMs = intervalMinutes * 60_000L; + processingScan.set(false); + browserScanRunning = false; + lastScanAt = null; + lastHeartbeatAt = LocalDateTime.now(); + lastError = ""; + outboxCount = 0; + WatchStatus status = status(); + events.emit("watch-status", status); + return status; + }); } - public synchronized WatchStatus heartbeat(String watchSessionId, + public WatchStatus stop(String watchSessionId, String reason) { + return profileGuard.locked(() -> { + if (session != null && watchSessionId != null && !watchSessionId.isBlank() + && !session.watchSessionId().equals(watchSessionId.trim())) { + throw new HrAssistantStore.StaleProposalException("值守会话已变化,拒绝停止其他标签页的值守"); + } + watching.set(false); + // A stop cancels future work, but must not release an in-flight ingestion. + browserScanRunning = false; + lastError = safe(reason); + if (session != null && !lastError.isBlank() && !lastError.startsWith("USER_STOPPED")) { + napCatGateway.notifySystemFault(session.profileId(), lastError); + } + WatchStatus status = status(); + events.emit("watch-status", status); + return status; + }); + } + + public WatchStatus heartbeat(String watchSessionId, int tabId, String url, String contentVersion, boolean browserScanRunning, int browserOutboxCount, String fault) { - requireSession(watchSessionId, tabId); - validateChatTab(tabId, url, contentVersion, session.browserSessionId()); - session = new WatchSession(session.watchSessionId(), session.browserSessionId(), session.profileId(), - tabId, url.trim(), contentVersion.trim()); - lastHeartbeatAt = LocalDateTime.now(); - this.browserScanRunning = browserScanRunning; - outboxCount = Math.max(0, browserOutboxCount); - if (fault != null && !fault.isBlank()) { - lastError = concise(fault); - watching.set(false); - processingScan.set(false); - this.browserScanRunning = false; - napCatGateway.notifySystemFault(session.profileId(), lastError); - events.emit("watch-paused", java.util.Map.of("message", lastError)); - } - return status(); + return profileGuard.locked(() -> { + requireSession(watchSessionId, tabId); + validateChatTab(tabId, url, contentVersion, session.browserSessionId()); + session = new WatchSession(session.watchSessionId(), session.browserSessionId(), session.profileId(), + tabId, url.trim(), contentVersion.trim()); + lastHeartbeatAt = LocalDateTime.now(); + this.browserScanRunning = browserScanRunning; + outboxCount = Math.max(0, browserOutboxCount); + if (fault != null && !fault.isBlank()) { + lastError = concise(fault); + watching.set(false); + this.browserScanRunning = false; + napCatGateway.notifySystemFault(session.profileId(), lastError); + events.emit("watch-paused", java.util.Map.of("message", lastError)); + } + return status(); + }); } public ScanReceipt ingestScan(String watchSessionId, @@ -124,7 +144,6 @@ public ScanReceipt ingestScan(String watchSessionId, String scanId, int totalUnread, List captures) { - WatchSession active = requireSession(watchSessionId, tabId); String normalizedScanId = requireNonBlank(scanId, "scanId"); List safeCaptures = captures == null ? List.of() : List.copyOf(captures); if (safeCaptures.size() > maxConversations) throw new IllegalArgumentException("单轮 HR 会话数量超过 " + maxConversations); @@ -137,13 +156,18 @@ public ScanReceipt ingestScan(String watchSessionId, throw new HrAssistantStore.StaleProposalException("同一轮扫描出现重复会话 UID,已暂停避免错误映射"); } } - if (!processingScan.compareAndSet(false, true)) throw new IllegalStateException("上一轮 HR 消息仍在处理,本轮已跳过"); + WatchSession active = profileGuard.locked(() -> { + WatchSession bound = requireSession(watchSessionId, tabId); + if (!processingScan.compareAndSet(false, true)) throw new IllegalStateException("上一轮 HR 消息仍在处理,本轮已跳过"); + return bound; + }); List acknowledged = new ArrayList<>(); int processed = 0; int duplicates = 0; try { HrAssistantStore.SettingsSecret settings = store.loadSettingsSecret(active.profileId()); for (ChatCapture capture : safeCaptures) { + requireSession(watchSessionId, tabId); validateCapture(capture); boolean shouldProcess = store.beginCapture(active.profileId(), active.watchSessionId(), normalizedScanId, capture.captureId()); if (!shouldProcess) { @@ -180,14 +204,21 @@ public ScanReceipt ingestScan(String watchSessionId, } public WatchStatus status() { + return profileGuard.locked(this::statusLocked); + } + + private WatchStatus statusLocked() { + var currentProfile = profileService.getCurrentProfile(); WatchSession active = session; - LocalDateTime next = watching.get() && lastScanAt != null ? lastScanAt.plusNanos(SCAN_INTERVAL_MS * 1_000_000) : null; + LocalDateTime next = watching.get() && lastScanAt != null ? lastScanAt.plusNanos(scanIntervalMs * 1_000_000) : null; ChromeBridgeStatus bridge = new ChromeBridgeStatus(active != null, watching.get() && active != null, active == null ? null : active.tabId(), active == null ? "" : active.url(), active == null ? "" : active.contentVersion(), lastHeartbeatAt, outboxCount, active == null ? "等待 BOSS 聊天页绑定" : "投递牛马 Chrome 扩展直连"); - return new WatchStatus(watching.get(), browserScanRunning || processingScan.get(), active == null ? "" : active.watchSessionId(), SCAN_INTERVAL_MS, - lastScanAt, next, lastError, bridge, napCatGateway.isConnected(), true); + return new WatchStatus(watching.get(), browserScanRunning || processingScan.get(), active == null ? "" : active.watchSessionId(), scanIntervalMs, + lastScanAt, next, lastError, bridge, napCatGateway.isConnected(), true, + active == null ? null : active.profileId(), currentProfile == null ? null : currentProfile.getId(), + currentProfile == null ? "" : currentProfile.getName(), profileGuard.isBlocked()); } public String requireActiveWatchSession(Long profileId) { @@ -204,6 +235,29 @@ public void assertActiveSession(Long profileId, String watchSessionId, int tabId } } + public T withSession(Long profileId, String watchSessionId, int tabId, boolean completing, + java.util.function.Supplier action) { + return profileGuard.locked(() -> { + if (completing) { + WatchSession active = session; + if (active == null || !active.profileId().equals(profileId) + || !profileId.equals(profileService.getCurrentProfileId()) + || !active.watchSessionId().equals(watchSessionId) || active.tabId() != tabId) { + throw new HrAssistantStore.StaleProposalException("发送结果不属于当前档案和值守会话"); + } + } else { + assertActiveSession(profileId, watchSessionId, tabId); + } + return action.get(); + }); + } + + + @Scheduled(fixedDelay = 5000) + public void expireSendLeases() { + profileGuard.locked(() -> { store.expireUnconfirmedLeases(); return null; }); + } + @Scheduled(cron = "0 15 3 * * *") public void purgeExpiredSensitiveData() { int deleted = store.purgeExpired(); @@ -214,6 +268,12 @@ private void processCapture(Long profileId, HrAssistantStore.SettingsSecret sett ChatSession chat = capture.session(); long conversationId = store.upsertConversation(profileId, chat); for (ChatMessage message : capture.messages()) store.saveMessage(conversationId, message, settings.retentionDays()); + // Full-list scans include conversations that the user has already answered. + // Never draft a second response to an older inbound message in that case. + if (!capture.messages().get(capture.messages().size() - 1).inbound()) { + store.expireAnsweredProposals(conversationId); + return; + } ChatMessage source = latestInboundForCurrentLastMessage(capture.messages(), chat.lastMessage()); if (source == null) throw new IllegalStateException("最新入站消息与会话列表不一致,已保留 Outbox 并停止处理"); String sourceFingerprint = store.sourceFingerprint(conversationId, source); @@ -226,7 +286,10 @@ private void processCapture(Long profileId, HrAssistantStore.SettingsSecret sett List.of("NON_TEXT_MESSAGE"), List.of("请人工查看 " + source.type()), 1); } else { try { - draft = draftService.generate(profileId, conversationId, settings.communicationProfile(), store.recentMessages(conversationId, 12)); + // The current ordered capture avoids mixing old, misclassified copies + // with the corrected messages saved during historical recovery. + draft = draftService.generate(profileId, conversationId, settings.communicationProfile(), + capture.messages().subList(Math.max(0, capture.messages().size() - 12), capture.messages().size())); } catch (RuntimeException aiFailure) { draft = new AiDraft(Classification.NEEDS_USER, "", "AI 草稿生成失败,需要人工填写回复。", List.of("AI_FAILURE"), List.of("请人工填写回复"), 0); @@ -236,7 +299,7 @@ private void processCapture(Long profileId, HrAssistantStore.SettingsSecret sett long proposalId = store.createProposal(profileId, conversationId, sourceFingerprint, draft); ProposalView proposal = store.getProposalView(profileId, proposalId); events.emit("proposal-created", proposal); - if (proposal.highValue() && !napCatGateway.notifyProposal(proposal)) { + if (settings.qqEnabled() && !napCatGateway.notifyProposal(proposal)) { events.emit("qq-notification-failed", java.util.Map.of("proposalId", proposal.id(), "message", "NapCat 未连接或 QQ 通知发送失败")); } } @@ -252,6 +315,9 @@ private ChatMessage latestInboundForCurrentLastMessage(List message private WatchSession requireSession(String watchSessionId, int tabId) { WatchSession active = session; if (!watching.get() || active == null) throw new IllegalStateException("BOSS HR 值守未启动或已暂停"); + if (!active.profileId().equals(profileService.getCurrentProfileId())) { + throw new HrAssistantStore.StaleProposalException("当前人物档案已变化,请重新开始值守"); + } if (!active.watchSessionId().equals(safe(watchSessionId)) || active.tabId() != tabId) { throw new HrAssistantStore.StaleProposalException("值守会话或标签页已变化,拒绝处理旧请求"); } diff --git a/src/main/java/com/getjobs/application/service/HrProfileGuard.java b/src/main/java/com/getjobs/application/service/HrProfileGuard.java new file mode 100644 index 0000000..5cf5f83 --- /dev/null +++ b/src/main/java/com/getjobs/application/service/HrProfileGuard.java @@ -0,0 +1,33 @@ +package com.getjobs.application.service; + +import org.springframework.stereotype.Component; +import java.util.function.BooleanSupplier; +import java.util.function.Supplier; + +/** Serializes profile transactions with watch admission and send-command leases. */ +@Component +public class HrProfileGuard { + private BooleanSupplier blocked = () -> false; + + public synchronized void registerBlocker(BooleanSupplier blocker) { + blocked = blocker; + } + + public synchronized T locked(Supplier action) { + return action.get(); + } + + public synchronized boolean isBlocked() { + return blocked.getAsBoolean(); + } + + public synchronized void requireChangeAllowed() { + if (isBlocked()) throw new WatchActiveException(); + } + + public static class WatchActiveException extends IllegalStateException { + public WatchActiveException() { + super("请先停止值守再切换人物档案;若仍有采集或发送任务,请等待处理完成"); + } + } +} diff --git a/src/main/java/com/getjobs/application/service/ProfileService.java b/src/main/java/com/getjobs/application/service/ProfileService.java index 8b99617..97586ff 100644 --- a/src/main/java/com/getjobs/application/service/ProfileService.java +++ b/src/main/java/com/getjobs/application/service/ProfileService.java @@ -45,6 +45,7 @@ public class ProfileService { private final ProfileMapper profileMapper; private final JdbcTemplate jdbcTemplate; private final PlatformTransactionManager transactionManager; + private final HrProfileGuard hrProfileGuard; @Transactional(readOnly = true) public List listProfiles() { @@ -106,19 +107,24 @@ public ProfileEntity renameProfile(Long id, String name) { return entity; } - @Transactional public ProfileEntity activateProfile(Long id) { - ProfileEntity entity = requireProfile(id); - profileMapper.update(null, new UpdateWrapper().set("is_active", 0)); - entity.setIsActive(1); - entity.setUpdatedAt(LocalDateTime.now()); - profileMapper.updateById(entity); - return entity; + return hrProfileGuard.locked(() -> new TransactionTemplate(transactionManager).execute(status -> { + ProfileEntity entity = requireProfile(id); + if (!id.equals(getCurrentProfileIdOrNull())) hrProfileGuard.requireChangeAllowed(); + profileMapper.update(null, new UpdateWrapper().set("is_active", 0)); + entity.setIsActive(1); + entity.setUpdatedAt(LocalDateTime.now()); + profileMapper.updateById(entity); + return entity; + })); } public DeleteProfileResult deleteProfile(Long id, boolean force) { TransactionTemplate transaction = new TransactionTemplate(transactionManager); - return transaction.execute(status -> deleteProfileInTransaction(id, force, status)); + return hrProfileGuard.locked(() -> { + if (id != null && id.equals(getCurrentProfileIdOrNull())) hrProfileGuard.requireChangeAllowed(); + return transaction.execute(status -> deleteProfileInTransaction(id, force, status)); + }); } private DeleteProfileResult deleteProfileInTransaction(Long id, diff --git a/src/test/java/com/getjobs/application/controller/HrAssistantControllerTest.java b/src/test/java/com/getjobs/application/controller/HrAssistantControllerTest.java index 9628529..d9f5849 100644 --- a/src/test/java/com/getjobs/application/controller/HrAssistantControllerTest.java +++ b/src/test/java/com/getjobs/application/controller/HrAssistantControllerTest.java @@ -42,6 +42,7 @@ void startsTheExactChromeTabOnlyWithFreshLocalActionToken() { request.setUrl("https://www.zhipin.com/web/geek/chat"); request.setContentVersion("direct"); request.setBrowserSessionId("browser-session"); + request.setExpectedProfileId(1L); var rejected = controller.start("invalid-token", request); var accepted = controller.start(tokens.issueToken(), request); @@ -49,7 +50,7 @@ void startsTheExactChromeTabOnlyWithFreshLocalActionToken() { assertThat(rejected.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); assertThat(responseBody(rejected)).containsKeys("success", "errorCode", "message", "requestId"); assertThat(accepted.getStatusCode()).isEqualTo(HttpStatus.OK); - verify(watcher).start(77, "https://www.zhipin.com/web/geek/chat", "direct", "browser-session"); + verify(watcher).start(77, "https://www.zhipin.com/web/geek/chat", "direct", "browser-session", 1L, 1); verifyNoInteractions(actions, profiles, store, events); } @@ -60,10 +61,14 @@ void validatesBoundSessionBeforeClaimingSendCommand() { request.setWatchSessionId("watch-1"); request.setTabId(77); + when(watcher.withSession(org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq("watch-1"), + org.mockito.ArgumentMatchers.eq(77), org.mockito.ArgumentMatchers.eq(false), org.mockito.ArgumentMatchers.any())) + .thenAnswer(call -> ((java.util.function.Supplier) call.getArgument(4)).get()); var response = controller.claimSendCommand(tokens.issueToken(), request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - verify(watcher).assertActiveSession(1L, "watch-1", 77); + verify(watcher).withSession(org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq("watch-1"), + org.mockito.ArgumentMatchers.eq(77), org.mockito.ArgumentMatchers.eq(false), org.mockito.ArgumentMatchers.any()); verify(actions).claim(1L, "watch-1"); } diff --git a/src/test/java/com/getjobs/application/service/AiServiceProviderTest.java b/src/test/java/com/getjobs/application/service/AiServiceProviderTest.java index 9242d99..27aace0 100644 --- a/src/test/java/com/getjobs/application/service/AiServiceProviderTest.java +++ b/src/test/java/com/getjobs/application/service/AiServiceProviderTest.java @@ -38,7 +38,7 @@ void textRequestUsesCodexWithoutApiKey() { Map config = Map.of( "AI_PROVIDER", "codex", "CODEX_PATH", "codex", - "CODEX_MODEL", "gpt-5.6-sol" + "CODEX_MODEL", "gpt-6-astra" ); when(configService.getAiConfigs()).thenReturn(config); when(codexCliService.generateText("岗位分析", config)).thenReturn("{\"decision\":\"SKIP\"}"); diff --git a/src/test/java/com/getjobs/application/service/CodexCliServiceTest.java b/src/test/java/com/getjobs/application/service/CodexCliServiceTest.java index c9870cf..fd3354a 100644 --- a/src/test/java/com/getjobs/application/service/CodexCliServiceTest.java +++ b/src/test/java/com/getjobs/application/service/CodexCliServiceTest.java @@ -24,7 +24,7 @@ void commandUsesReadOnlyEphemeralSessionAndOptionalImage() { Path output = cwd.resolve("final.txt"); Path image = cwd.resolve("resume.png"); - List command = service.buildCommand("codex", "gpt-5.6-sol", cwd, output, image); + List command = service.buildCommand("codex", "gpt-6-astra", cwd, output, image); assertThat(command).containsSubsequence("exec", "-C", cwd.toString()); assertThat(command).containsSubsequence("--sandbox", "read-only"); @@ -70,7 +70,7 @@ void commandWrapsWindowsCmdLauncher() { List command = service.buildCommand( "C:\\Users\\demo\\AppData\\Roaming\\npm\\codex.cmd", - "gpt-5.6-sol", + "gpt-6-astra", cwd, cwd.resolve("final.txt"), null diff --git a/src/test/java/com/getjobs/application/service/ConfigServiceTest.java b/src/test/java/com/getjobs/application/service/ConfigServiceTest.java index 7a5c938..0d2db9e 100644 --- a/src/test/java/com/getjobs/application/service/ConfigServiceTest.java +++ b/src/test/java/com/getjobs/application/service/ConfigServiceTest.java @@ -115,7 +115,7 @@ void codexIsDefaultAndDoesNotRequireApiKey() { assertThat(configs) .containsEntry("AI_PROVIDER", "codex") .containsEntry("CODEX_PATH", "codex") - .containsEntry("CODEX_MODEL", "gpt-5.6-sol") + .containsEntry("CODEX_MODEL", "gpt-6-astra") .containsEntry("AI_REQUEST_TIMEOUT_SECONDS", "120") .containsEntry("API_KEY", ""); } diff --git a/src/test/java/com/getjobs/application/service/HrAssistantWatchServiceTest.java b/src/test/java/com/getjobs/application/service/HrAssistantWatchServiceTest.java index 79a9e6f..5972ad8 100644 --- a/src/test/java/com/getjobs/application/service/HrAssistantWatchServiceTest.java +++ b/src/test/java/com/getjobs/application/service/HrAssistantWatchServiceTest.java @@ -34,6 +34,7 @@ class HrAssistantWatchServiceTest { private final HrAssistantEventService events = mock(HrAssistantEventService.class); private final NapCatGateway napCatGateway = mock(NapCatGateway.class); private HrAssistantWatchService service; + private final HrProfileGuard guard = new HrProfileGuard(); @BeforeEach void setUp() { @@ -41,13 +42,55 @@ void setUp() { when(store.loadSettingsSecret(1L)).thenReturn(new HrAssistantStore.SettingsSecret( 1L, CommunicationProfile.empty(), false, "ws://127.0.0.1:3001", "", QqTargetType.PRIVATE, "", "", 30)); - service = new HrAssistantWatchService(profileService, store, draftService, events, napCatGateway, 100); + service = new HrAssistantWatchService(profileService, store, draftService, events, napCatGateway, 100, guard); + } + + @Test + void enabledQqReceivesOrdinaryDraftsAsWellAsHighValueReminders() { + when(store.loadSettingsSecret(1L)).thenReturn(new HrAssistantStore.SettingsSecret( + 1L, CommunicationProfile.empty(), true, "ws://127.0.0.1:3001", "test-token", + QqTargetType.GROUP, "123456", "", 30)); + var status = service.start(77, "https://www.zhipin.com/web/geek/chat", "version", "browser-session", 1L, 30); + when(store.beginCapture(eq(1L), eq(status.watchSessionId()), any(), any())).thenReturn(true); + when(store.upsertConversation(eq(1L), any())).thenReturn(1L); + when(store.sourceFingerprint(anyLong(), any())).thenReturn("new-text-fingerprint"); + when(draftService.generate(eq(1L), anyLong(), any(), anyList())).thenReturn( + new AiDraft(Classification.REPLY, "您好", "普通消息", List.of(), List.of(), 0.9)); + when(store.createProposal(eq(1L), anyLong(), any(), any())).thenReturn(101L); + var ordinary = proposal(); + assertThat(ordinary.highValue()).isFalse(); + when(store.getProposalView(1L, 101L)).thenReturn(ordinary); + when(napCatGateway.notifyProposal(ordinary)).thenReturn(true); + service.ingestScan(status.watchSessionId(), 77, "all", 0, List.of(capture(1))); + verify(napCatGateway).notifyProposal(ordinary); + } + + @Test + void thirtyMinuteWatchKeepsTheIntervalAcrossHeartbeats() { + var status = service.start(77, "https://www.zhipin.com/web/geek/chat", "version", "browser-session", 1L, 30); + assertThat(status.intervalMs()).isEqualTo(1_800_000L); + assertThat(service.heartbeat(status.watchSessionId(), 77, "https://www.zhipin.com/web/geek/chat", "version", true, 0, "").intervalMs()).isEqualTo(1_800_000L); + assertThatThrownBy(() -> service.start(77, "https://www.zhipin.com/web/geek/chat", "version", "browser-session", 1L, 2)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void fullListCaptureDoesNotReplyAgainAfterTheUserHasAnswered() { + var status = service.start(77, "https://www.zhipin.com/web/geek/chat", "version", "browser-session", 1L, 30); + var old = capture(1); + var answered = new ChatCapture("answered", 0, old.session(), List.of( + old.messages().get(0), new ChatMessage("本人", "文本", "好的谢谢", "刚刚"))); + when(store.beginCapture(eq(1L), eq(status.watchSessionId()), any(), eq("answered"))).thenReturn(true); + when(store.upsertConversation(eq(1L), any())).thenReturn(1L); + var result = service.ingestScan(status.watchSessionId(), 77, "all", 0, List.of(answered)); + assertThat(result.acknowledgedCaptureIds()).containsExactly("answered"); + verify(store).expireAnsweredProposals(1L); + org.mockito.Mockito.verifyNoInteractions(draftService); } @Test void bindsOnlyTheExactCurrentBossChatTab() { var status = service.start(77, "https://www.zhipin.com/web/geek/chat?ka=header-message", - "2026-09-04-boss-hr-direct", "browser-session"); + "2026-09-04-boss-hr-direct", "browser-session", 1L); assertThat(status.watching()).isTrue(); assertThat(status.watchSessionId()).isNotBlank(); @@ -55,12 +98,12 @@ void bindsOnlyTheExactCurrentBossChatTab() { assertThat(status.chromeBridge().tabId()).isEqualTo(77); assertThat(status.chromeBridge().tabBound()).isTrue(); assertThatThrownBy(() -> service.start(78, "https://www.zhipin.com/web/geek/job", - "version", "other-session")).isInstanceOf(IllegalArgumentException.class); + "version", "other-session", 1L)).isInstanceOf(IllegalArgumentException.class); } @Test void ingestsSixteenCapturesOnceAndAcknowledgesDuplicateCaptureIds() { - var status = service.start(77, "https://www.zhipin.com/web/geek/chat", "version", "browser-session"); + var status = service.start(77, "https://www.zhipin.com/web/geek/chat", "version", "browser-session", 1L); List captures = IntStream.rangeClosed(1, 16).mapToObj(this::capture).toList(); when(store.beginCapture(eq(1L), eq(status.watchSessionId()), eq("scan-1"), any())).thenReturn(true); when(store.upsertConversation(eq(1L), any(ChatSession.class))).thenAnswer(call -> { @@ -90,7 +133,7 @@ void ingestsSixteenCapturesOnceAndAcknowledgesDuplicateCaptureIds() { @Test void browserHeartbeatDoesNotOccupyTheBackendProcessingLock() { - var status = service.start(77, "https://www.zhipin.com/web/geek/chat", "version", "browser-session"); + var status = service.start(77, "https://www.zhipin.com/web/geek/chat", "version", "browser-session", 1L); service.heartbeat(status.watchSessionId(), 77, "https://www.zhipin.com/web/geek/chat", "version", true, 1, ""); when(store.beginCapture(1L, status.watchSessionId(), "scan", "capture-1")).thenReturn(false); @@ -102,13 +145,54 @@ void browserHeartbeatDoesNotOccupyTheBackendProcessingLock() { @Test void unreadCountWithoutAnySafeCaptureFailsClosed() { - var status = service.start(77, "https://www.zhipin.com/web/geek/chat", "version", "browser-session"); + var status = service.start(77, "https://www.zhipin.com/web/geek/chat", "version", "browser-session", 1L); assertThatThrownBy(() -> service.ingestScan(status.watchSessionId(), 77, "scan", 16, List.of())) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("未能安全识别"); } + @Test + void rejectsStaleProfileAndExposesSwitchLock() { + assertThatThrownBy(() -> service.start(77, "https://www.zhipin.com/web/geek/chat", "v", "b", 2L)) + .isInstanceOf(HrAssistantStore.StaleProposalException.class); + var status = service.start(77, "https://www.zhipin.com/web/geek/chat", "v", "b", 1L); + assertThat(status.profileId()).isEqualTo(1); + assertThat(status.profileSwitchBlocked()).isTrue(); + assertThatThrownBy(guard::requireChangeAllowed).isInstanceOf(HrProfileGuard.WatchActiveException.class); + service.stop(status.watchSessionId(), "USER_STOPPED"); + assertThat(guard.isBlocked()).isFalse(); + assertThatThrownBy(() -> service.ingestScan(status.watchSessionId(), 77, "late", 0, List.of())) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void leasedSendStaysBlockedAfterStopButItsResultCanStillComplete() { + var status = service.start(77, "https://www.zhipin.com/web/geek/chat", "v", "b", 1L); + when(store.hasLeasedSendCommands()).thenReturn(true); + service.stop(status.watchSessionId(), "USER_STOPPED"); + assertThat(guard.isBlocked()).isTrue(); + assertThat(service.withSession(1L, status.watchSessionId(), 77, true, () -> "recorded")).isEqualTo("recorded"); + assertThatThrownBy(() -> service.withSession(2L, status.watchSessionId(), 77, true, () -> "wrong")) + .isInstanceOf(HrAssistantStore.StaleProposalException.class); + assertThatThrownBy(() -> service.withSession(1L, status.watchSessionId(), 77, false, () -> "new send")) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void stoppingDuringIngestionDoesNotUnlockProfileUntilTheIngestionReturns() { + var status = service.start(77, "https://www.zhipin.com/web/geek/chat", "v", "b", 1L); + when(store.beginCapture(1L, status.watchSessionId(), "scan", "capture-1")).thenAnswer(call -> { + service.stop(status.watchSessionId(), "USER_STOPPED"); + assertThat(guard.isBlocked()).isTrue(); + assertThatThrownBy(guard::requireChangeAllowed).isInstanceOf(HrProfileGuard.WatchActiveException.class); + return false; + }); + service.ingestScan(status.watchSessionId(), 77, "scan", 1, List.of(capture(1))); + assertThat(guard.isBlocked()).isFalse(); + assertThat(service.status().watching()).isFalse(); + } + private ChatCapture capture(int index) { String text = "消息" + index; ChatSession session = new ChatSession("uid-" + index, "security-" + index, "HR" + index, diff --git a/src/test/java/com/getjobs/application/service/HrReplyActionServiceTest.java b/src/test/java/com/getjobs/application/service/HrReplyActionServiceTest.java index 28327ef..fefd948 100644 --- a/src/test/java/com/getjobs/application/service/HrReplyActionServiceTest.java +++ b/src/test/java/com/getjobs/application/service/HrReplyActionServiceTest.java @@ -32,7 +32,7 @@ void explicitConfirmationOnlyQueuesACommandForTheBoundExtension() { @Test void claimsOneCommandAndRecordsResultUnknownWithoutRetrying() { SendCommandView command = new SendCommandView("command-1", "lease", 10L, "uid-1", "HR", "公司", "岗位", - "fingerprint", new ChatMessage("对方", "文本", "你好", "11:02"), "您好", LocalDateTime.now().plusMinutes(1)); + "fingerprint", new ChatMessage("对方", "文本", "你好", "11:02"), "您好", LocalDateTime.now().plusMinutes(1), System.currentTimeMillis() + 60000); when(store.claimSendCommand(1L, "watch-1")).thenReturn(command); when(store.completeSendCommand(1L, "watch-1", "command-1", "lease", "RESULT_UNKNOWN", "未确认", null)) .thenReturn(proposal("SEND_UNKNOWN")); diff --git a/src/test/java/com/getjobs/application/service/ProfileHrGuardTest.java b/src/test/java/com/getjobs/application/service/ProfileHrGuardTest.java new file mode 100644 index 0000000..a9d77af --- /dev/null +++ b/src/test/java/com/getjobs/application/service/ProfileHrGuardTest.java @@ -0,0 +1,88 @@ +package com.getjobs.application.service; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.getjobs.application.entity.ProfileEntity; +import com.getjobs.application.mapper.ProfileMapper; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.support.AbstractPlatformTransactionManager; +import org.springframework.transaction.support.DefaultTransactionStatus; +import org.springframework.transaction.TransactionDefinition; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class ProfileHrGuardTest { + private final HrProfileGuard guard = new HrProfileGuard(); + private final ProfileMapper mapper = mock(ProfileMapper.class); + private final JdbcTemplate jdbc = mock(JdbcTemplate.class); + + private ProfileEntity profile(long id) { + ProfileEntity value = new ProfileEntity(); + value.setId(id); + value.setName("档案" + id); + return value; + } + + private ProfileService service(Runnable onCommit) { + when(mapper.selectById(2L)).thenReturn(profile(2)); + when(mapper.selectOne(any(QueryWrapper.class))).thenReturn(profile(1)); + var transactions = new AbstractPlatformTransactionManager() { + protected Object doGetTransaction() { return new Object(); } + protected void doBegin(Object tx, TransactionDefinition definition) {} + protected void doCommit(DefaultTransactionStatus status) { onCommit.run(); } + protected void doRollback(DefaultTransactionStatus status) {} + }; + return new ProfileService(mapper, jdbc, transactions, guard); + } + + @Test + void watchBlocksActivationAndDeletionOfCurrentProfileBeforeAnyWrites() { + var service = service(() -> {}); + guard.registerBlocker(() -> true); + assertThatThrownBy(() -> service.activateProfile(2L)).isInstanceOf(HrProfileGuard.WatchActiveException.class); + assertThatThrownBy(() -> service.deleteProfile(1L, true)).isInstanceOf(HrProfileGuard.WatchActiveException.class); + verifyNoInteractions(jdbc); + verify(mapper, never()).updateById(any(ProfileEntity.class)); + guard.registerBlocker(() -> false); + assertThat(service.activateProfile(2L).getId()).isEqualTo(2); + } + + @Test + void activationEndpointReturnsConflictWithStableErrorCode() throws Exception { + var service = service(() -> {}); + guard.registerBlocker(() -> true); + var mvc = org.springframework.test.web.servlet.setup.MockMvcBuilders + .standaloneSetup(new com.getjobs.application.controller.ProfileController(service)) + .setControllerAdvice(new com.getjobs.application.controller.GlobalExceptionHandler()).build(); + mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/profiles/2/activate")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isConflict()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.errorCode").value("HR_WATCH_ACTIVE")); + } + + @Test + void switchingKeepsGuardUntilTransactionCommitSoWatchCannotStartInBetween() throws Exception { + var inCommit = new CountDownLatch(1); + var allowCommit = new CountDownLatch(1); + var admitted = new AtomicBoolean(false); + var service = service(() -> { + inCommit.countDown(); + try { assertThat(allowCommit.await(3, TimeUnit.SECONDS)).isTrue(); } + catch (InterruptedException e) { throw new RuntimeException(e); } + }); + try (var executor = Executors.newFixedThreadPool(2)) { + var switching = executor.submit(() -> service.activateProfile(2L)); + assertThat(inCommit.await(3, TimeUnit.SECONDS)).isTrue(); + var starting = executor.submit(() -> guard.locked(() -> { admitted.set(true); return null; })); + assertThat(admitted.get()).isFalse(); + allowCommit.countDown(); + switching.get(3, TimeUnit.SECONDS); + starting.get(3, TimeUnit.SECONDS); + assertThat(admitted.get()).isTrue(); + } finally { allowCommit.countDown(); } + } +} diff --git a/src/test/java/com/getjobs/application/service/ProfileServiceAiTaskSafetyTest.java b/src/test/java/com/getjobs/application/service/ProfileServiceAiTaskSafetyTest.java index 2a43b63..dc093df 100644 --- a/src/test/java/com/getjobs/application/service/ProfileServiceAiTaskSafetyTest.java +++ b/src/test/java/com/getjobs/application/service/ProfileServiceAiTaskSafetyTest.java @@ -52,7 +52,7 @@ void forceDeleteRollsBackWhenAiTaskLeaseIsActive() { ProfileService service = new ProfileService( profileMapper, jdbcTemplate, - new DataSourceTransactionManager(dataSource) + new DataSourceTransactionManager(dataSource), new HrProfileGuard() ); ProfileService.DeleteProfileResult result = service.deleteProfile(1L, true); @@ -90,7 +90,7 @@ void forceDeleteRemovesLegacyPlatformRowsAndDeliveryAttempts() { when(profileMapper.selectCount(isNull())).thenReturn(2L); when(profileMapper.selectOne(any(QueryWrapper.class))).thenReturn(active); ProfileService service = new ProfileService( - profileMapper, jdbcTemplate, new DataSourceTransactionManager(dataSource)); + profileMapper, jdbcTemplate, new DataSourceTransactionManager(dataSource), new HrProfileGuard()); ProfileService.DeleteProfileResult result = service.deleteProfile(2L, true); diff --git a/tasks/2026-09-06-boss-hr-watch-profile.md b/tasks/2026-09-06-boss-hr-watch-profile.md new file mode 100644 index 0000000..8a59ad6 --- /dev/null +++ b/tasks/2026-09-06-boss-hr-watch-profile.md @@ -0,0 +1,90 @@ +# BOSS 值守修复与人物档案保护 + +## 原因与改动 + +- 2026-09-06 实测 6866 基础服务健康,但 `/api/hr-assistant/status` 返回 404。监听 Java PID 7980 来自岗位扫描工作树;RunDock Backend 成员 `1d84134e-f688-4890-8efe-f175c8790b53` 仍使用该工作树的 `scripts/run_backend.ps1`。 +- 本修复基于 HR 分支,保留岗位扫描改动和 `gpt-6-astra` 默认模型。扩展升级到 1.6.1,增加环境配置聊天入口及连接状态,区分后端缺失与响应契约错误。 +- AI Provider、模型及密钥全局共用,切换档案不改变这些配置;简历、HR 设置和待处理记录按人物档案隔离。 +- 值守启动、发送命令领取和档案切换使用同一个后端锁;档案事务提交之前不会释放锁。值守或入库/发送未结束时,切换及删除当前档案返回 `HR_WATCH_ACTIVE`。 +- 停止后允许已领取的发送命令回报结果;未知结果不自动重试。发送使用后端租约截止时间,过期不得点击发送。扩展停止后的旧扫描不得恢复值守状态。 +- 未增加数据库迁移;旧版无档案归属的 Outbox 保留并提示人工处理。 + +## 验证 + +- `gradlew.bat --no-daemon test bootJar`:324 项测试,320 通过,4 跳过,无失败。 +- `node --test chrome-extension/tests/*.test.cjs`:106 通过。 +- `node scripts/validate-chrome-extension.mjs`:通过。 +- `front` 中 `pnpm test`:40 通过;`pnpm typecheck` 通过;`pnpm lint` 0 错误、35 条既有警告;`pnpm build:prod` 通过。 +- `git diff --check`:通过。 +- 使用本次 JAR、独立 `target/hr-smoke/smoke.db` 和 6867 端口验证:健康接口和环境配置 HTTP 200,静态页面包含聊天入口与全局 AI 说明;测试档案开始值守后切换/删除返回 409,停止后可切换,旧采集请求被拒绝,AI 配置前后完全一致。测试服务已关闭。 +- 没有操作真实 BOSS 会话,没有调用真实 AI 生成或发送 HR/QQ 消息。真实采集到草稿验收仍待服务切换和浏览器扩展刷新。 + +## 服务切换记录 + +1. 重新确认 6866 监听与 RunDock Backend 成员身份,备份共享 `C:\Users\10578\Documents\New project 3\db\getjobs.db`,记录完整性及 SHA-256。 +2. 仅停止该 Backend 成员,备份其原始启动配置,将工作目录和启动脚本改成本修复工作树;保留其他启动参数与环境变量,显式沿用共享数据库和原有数据路径。 +3. 通过 RunDock 启动,核验成员状态、监听 PID 及父进程、运行目录、共享数据库、`/api/ready`、`/api/hr-assistant/status` 和环境配置静态页面。 +4. Chrome 加载本工作树 `chrome-extension`,刷新扩展及 BOSS 聊天页;核对人物档案和 BOSS 账号后,由用户启动半自动值守,验证采集与待确认草稿。 + +2026-09-06 用户确认后已完成服务切换。RunDock Backend 持久化 cwd 和脚本均指向 `C:\Users\10578\Documents\AI-JobPilot-worktrees\boss-hr-watch-profile`。最终监听 6866 的 Java PID 51720,父进程链经 Gradle / PowerShell PID 47764 到 RunDock daemon PID 27408。`/api/ready`、值守状态和 HR 设置接口均 HTTP 200;当前档案 4、值守停止、全自动锁定。 + +共享数据库仍为原项目 `db/getjobs.db`:schema 17,完整性检查正常、外键错误 0;人物档案 3、岗位 895、HR 会话/草稿/发送命令均 0,与切换前一致。数据库、原 RunDock 配置和原密钥备份位于 `C:\Users\10578\Documents\New project 3\target\backups\hr-watch-cutover-20260906-173016`,不进入 Git。 + +部署发现 Windows 对 Codex 的 AppData 文件重定向:原可用密钥实际位于 Codex 包的 LocalCache 中,而 RunDock 在相同表面路径读取另一份新密钥。已保留原件,将可用密钥复制到共享 `data/secrets/hr-chat.key`,通过 Backend 的 `APP_HR_KEY_PATH` 显式指定,解决旧 HR 设置解密失败。除 cwd、脚本参数和这个密钥路径外,其他启动环境保持不变。密钥内容及临时诊断日志不进入源码或 PR。 + +浏览器环境配置已加载新版入口、值守状态和全局 AI 说明。Chrome 当前扩展仍从旧 HR 工作树加载,需在扩展管理页加载本修复工作树的 `chrome-extension`(1.6.1),再刷新工作台与聊天页。真实标签绑定及采集到待确认草稿仍待此步骤后验收;未发送真实 HR/QQ 消息。PR 不自动合并。 + +## 回滚 + +- 本次代码修复可通过 `git revert` 回退修复提交,不重写历史。 +- 若已切换运行服务,停止同一 Backend 成员并恢复已备份的 cwd、脚本参数和环境配置,再启动旧版本。此次没有新增迁移,不应覆盖数据库或删除新产生的数据。 + + +## 2026-09-06 当前 BOSS DOM 兼容修复 + +用户加载 1.6.1 并开始值守后,首次真实采集在打开任何会话前以 `BOSS_CHAT_UID_MISSING` 暂停。观察到当前列表使用 `.user-list .friend-content-warp .friend-content`,没有旧版 `data-friend-id` 等属性。公开 v5535 的 `chat.c4b6e86c.js` 定义卡片 `source` 属性,列表以 `source.uniqueId` 为 key;`app.c4b6e86c.js` 定义此标识为 `friendId-friendSource`。 + +扩展 1.6.2 增加仅在 BOSS 聊天页执行的 MAIN world 标识适配器。适配器只在内容脚本请求时,将当前卡片自身组件的有效 UID 写入 DOM;验证原始 ID、来源、uniqueId 及当前显示内容一致,先清除旧属性以避免虚拟节点复用造成串人。不会读取登录凭据、请求 BOSS 接口、点击页面、滚动或定时刷新。采集与发送继续要求唯一命中,并核对选中卡片和右侧聊天组件的 UID 一致。 + +同步修正姓名/公司拆分和聊天消息容器重复读取;只读 `.im-list` 中实际消息行并跳过系统消息。新增使用合成 HR 数据的 DOM 测试,覆盖无旧属性、同名 HR、来源区分、虚拟节点复用、重复 UID、左右会话不一致、消息方向/顺序,以及先写 Outbox 再读取目标会话。扩展版本检查独立于岗位扫描脚本版本。 + +验证:扩展 107 项通过,清单与脚本语法校验通过;前端全部 48 项通过(包含新增 DOM 场景 8 项),类型检查及新增文件 lint 通过。此次无后端或数据库修改。 + +用户指出频繁刷新有风控风险后,已停止 BOSS 页面操作,后续仅离线开发和测试。未在真实页面加载 1.6.2,未重新开始值守,未生成真实草稿、发送 HR 或 QQ 消息。下一次真实验收须与用户约定一次受控扩展更新和聊天页刷新,不连续重试。用户已明确人物档案名称仅为标签,不要求与简历姓名或 BOSS 显示名一致,不增加姓名匹配拦截。 + +本补丁回滚可 `git revert` 对应 DOM 兼容修复提交,再在约定时间更新扩展;数据库保持原样。生产后端运行版本无需因这个扩展补丁重启。 + + +## 2026-09-06 文本误判与空草稿原因提示 + +用户自行加载 1.6.2 后已成功开始值守,Chrome 标签绑定、扫描和入库均成功,Outbox 为 0。只读检查本地状态与草稿接口确认 22 条记录均为 NEEDS_USER / NON_TEXT_MESSAGE,未进入 AI 生成;并非 22 份生成成功的回复。公开 BOSS v5535 渲染代码把 MessageAvatar(figure)与文字放在同一个 message-content 中,旧版任意 img 判断将头像当成图片消息。 + +1.6.3 将当前消息行自身 ChatMessage 组件的 messageType 映射为消息类型,并核对 $el 与 data-mid,节点复用时先清除过期信息。头像、引用图片、行内表情不改变消息类型;未知类型仍需人工查看。正文排除引用与界面状态,保留表情 alt 和换行。面板展示 summary 与 missingFacts,明确区分需人工查看、草稿生成失败、待补充信息和无需回复;空草稿继续禁止发送。 + +测试:扩展 107 项通过,前端全部 51 项通过(DOM 10 项、面板 1 项),typecheck 和涉及文件 lint 通过;扩展清单/脚本语法及 diff 检查通过。未修改后端、数据库或服务配置,未重启服务,未操作或刷新 BOSS 页面,未发送 HR/QQ 消息。22 条旧记录原样保留,不自动改类型、重放或重新生成。用户可跳过不需要回复的旧记录;新版真正的文字消息到 AI 草稿流程仍待用户安排单次验收。档案名称只是标签,无须对应简历或 BOSS 姓名。 + +更新步骤:先停止值守,确认停止且 Outbox 为 0,再重新加载 Bridge 1.6.3;只刷新已有聊天页一次,手动开始值守。优先等待下一条正常文字消息,不以重复刷新或反复打开历史会话验收。看到非空草稿仅说明生成成功,实际发送仍需逐条确认。回滚本次提交可使用 git revert,保留历史数据库;更新扩展应在停止值守后进行。 + + +## 2026-09-06 全部会话读取与半小时值守 + +用户授权逐个打开全部列表、读取聊天并生成待确认回复,新增每半小时检查全部会话。扩展 1.6.4 提供两种值守范围:每 30 分钟全部会话(默认选项,含已读),每 1 分钟仅未读。另有“立即读取全部并生成草稿”。启动后执行第一轮,后续从上一轮完成时间再等待指定间隔,不重入、不与发送并行。全列表读取只操作现有聊天标签,不刷新页面;每次打开后等待 2 秒,逐条提交并确认 Outbox,显示已处理数量。生成或保存结果未知时暂停,不自动重试。全列表最多 1000 会话、25 分钟;未确认读到列表底部时明确暂停,不宣称全部完成。 + +后端接受 1 / 30 分钟间隔并在状态中返回。已读历史经新扩展重新采集后,正确的消息类型形成新来源指纹,旧错误草稿自然过期;同一来源仍去重,不周期性重复调用 AI。使用当前采集的有序上下文生成,避免混入之前图片误判的历史副本。最后一条是本人发送的会话仅保存记录,关闭旧待回复任务,不再次回复更早 HR 消息;已领取或结果未知的发送记录不会被改写。 + +扩展更新监听器可以把新助手注入现有聊天页,停止旧值守,保留手动启动,不需要 BOSS 页面刷新。真实使用仍须先保证运行服务与扩展版本一致。 + +验证:后端全套 326 项(322 通过、4 跳过),bootJar 通过;使用 target/hr-all-build 独立构建目录,未触发线上 devtools 重启。扩展 109 项、前端 52 项通过,类型检查、涉及文件 lint、扩展清单和脚本语法通过。测试包含半小时间隔、全列表含已读、逐条保存、提交结果未知后停止、过期会话拒绝、已回复会话不再生成。 + +真实操作状态:只读查看了现有 Chrome/BOSS 页面;Windows Computer Use 在读取 Chrome 窗口状态时无法可靠确认 URL,终止了本轮桌面操作。未执行全部列表点击、未生成新的真实草稿、未发送 HR/QQ 消息。没有绕过工具拦截,未重启服务或更新实际扩展。代码已准备,运行切换和第一轮全列表验收待继续。 + +回滚:停止值守后 git revert 本次功能提交,恢复前一服务/扩展版本;不覆盖共享数据库,保留历史记录。部署仍采用前述共享数据库在线备份、只重启 RunDock Backend、核验 PID/数据库/健康与值守接口的流程。 + + +## 2026-09-06 全列表读取后的 QQ 同步 + +用户明确要求全部列表读取后同步 QQ。NapCat 已在线,QQ 通知已启用,目标群连接测试已获得消息 ID 和回读匹配。调整草稿通知条件:启用 QQ 时同步所有新生成的待确认记录,不再只推送 highValue 记录;关闭 QQ 时不发送通知。保持来源去重、本人已回复会话不重复生成、真实 BOSS 发送逐条确认。 + +新增普通 REPLY / highValue=false 也会调用 QQ 通知的测试;相关值守和 NapCat 测试及 bootJar 通过,使用独立构建目录。Windows Computer Use 再次因不能可靠确认 Chrome URL 而中止桌面操作,未执行聊天点击。服务更新使用已授权的现有 RunDock Backend 入口,不改数据库路径或密钥;切换前在线备份位于共享 target/backups/hr-all-qq-20260906-215648,完整性正常,profile=3、boss_data=895、hr_message=82、hr_reply_proposal=23。 + +本次最终仍须区分代码与运行验证:同步规则的真实执行,需在新版后端运行后由用户点击一次开始值守。不能把测试消息送达当成全列表同步完成。回滚本次通知规则可 git revert 本提交;服务回退按已有备份流程执行,不覆盖共享数据库。