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 = `
+

旧引用
明天方便吗
![[微笑]](emoji.png)
+
+
+
+
视频
+
新卡片
`
+ 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