diff --git a/client/electron/src/App.tsx b/client/electron/src/App.tsx index 8013fe1..50481df 100644 --- a/client/electron/src/App.tsx +++ b/client/electron/src/App.tsx @@ -1,7 +1,8 @@ import { Suspense, lazy, useEffect } from 'react'; import { initAppearanceSync, useAppearanceSync } from './stores/appearanceSync'; -import { connectSSE } from './api/sse'; +import { connectSSE, useCredentialChange } from './api/sse'; +import { startInboxFallbackPoll, resetPollBaseline } from './lib/inboxFallbackPoll'; import { useAuthStore } from './stores/authStore'; import { useAccountStore } from './stores/accountStore'; import { useMailStore } from './stores/mailStore'; @@ -118,6 +119,20 @@ export default function App() { void useAppearanceSync.getState().pull(); }, [phase]); + /* + * ★ 2026-10-02:effect 依赖加上**当前凭证身份**。 + * + * 缺陷(用户报:页面停留不动,新邮件不自动同步):依赖只有 `[phase]`, + * 而切号(`accountStore.setActive`)只换 api/config 的 base/token、**不改 phase** + * ⇒ SSE 连接仍是旧账号的:旧账号的新邮件照收,新账号的一封都不推; + * 而 fetchInbox 走新凭证 ⇒ 数据是新的。 + * ⇒ 表现正是「不自动同步,但手动刷新能看到」。 + * + * 为什么不在切号处显式重建订阅:那要改所有调用点,漏一处就不刷新。 + * 凭证变化的**唯一发生地**是 api/config,从那里取身份更可靠。 + */ + const credentialSig = useCredentialChange(); + // 登录态就绪后拉取数据 + SSE useEffect(() => { if (phase !== 'authenticated') return; @@ -127,7 +142,20 @@ export default function App() { fetchSessions(); fetchContacts(); - return connectSSE((type, data) => { + /* + * ★ 2026-10-02:SSE 的冗余通道 —— 收件箱兜底轮询。 + * + * SSE 只在**真的断开**时触发 onerror。有一类故障它看不见:连接还在、 + * TCP 没断、却不再收数据(代理静默丢包 / NAT 超时 / 中间设备挂死长连接)。 + * 此时两端都认为连接正常 ⇒ 不重连 ⇒ 页面停留就再也不同步。 + * 轮询用 `total` 当廉价探针(limit=1 只要计数),变了才真拉列表。 + * + * 切号时丢弃基线:新账号的 total 与旧账号无关系,不丢会误判成「有变化」 + * 而白拉一次(无害,但那是可避免的一次全量)。 + */ + resetPollBaseline(); + const stopPoll = startInboxFallbackPoll(status => fetchInbox(status)); + const disconnect = connectSSE((type, data) => { switch (type) { case 'new_mail': fetchInbox('all'); @@ -159,7 +187,14 @@ export default function App() { } } }); - }, [phase]); + + // 两个通道都要停:轮询的定时器与 SSE 的连接。 + // 只停一个 ⇒ 切号后旧账号的定时器还在跑(会拿新凭证查旧账号的收件箱)。 + return () => { + stopPoll(); + disconnect(); + }; + }, [phase, credentialSig]); // loading 阶段 if (phase === 'checking') { diff --git a/client/electron/src/api/sse.ts b/client/electron/src/api/sse.ts index 366fa18..75e069a 100644 --- a/client/electron/src/api/sse.ts +++ b/client/electron/src/api/sse.ts @@ -1,4 +1,4 @@ -import { API_BASE, withToken } from './config'; +import { API_BASE, getToken, withToken } from './config'; export type SSEEventHandler = (eventType: string, data: Record) => void; export type SSEStatus = 'connecting' | 'connected' | 'disconnected' | 'reconnecting'; @@ -18,6 +18,40 @@ let backoff = 1000; let _status: SSEStatus = 'disconnected'; let statusHandlers: Array<(s: SSEStatus) => void> = []; +// 当前连接用的是哪套凭证。断线重连时若凭证已变,就**换凭证**而不是续用旧的 +// —— 这是「切号后新邮件收不到」那条缺陷的落点。 +let connectedWith = ''; + +// 最近一次收到的事件 id(来自 SSE 帧的 id: 行)。断线重连时交给服务端回放。 +let lastEventId = ''; + +/* +★ 2026-10-02:SSE 连接必须**跟着账号凭证**走。 + +# 缺陷(用户报:页面停留不动,新邮件不自动同步) + +`App.tsx` 里 `useEffect(..., [phase])` 只在**登录态**变化时连一次 SSE, +而切号(`accountStore.setActive`)只换 `api/config` 的 base/token、**不改 phase** +⇒ 连接仍是旧账号的:旧账号的新邮件照收,新账号的一封都不推; +而 `fetchInbox` 走的是新凭证 ⇒ 数据是新的。 +⇒ 表现正是「不自动同步,但手动刷新能看到」。 + +# 为什么记凭证身份而不是让调用方传 + +凭证变化有两条路径(切号、令牌刷新),都在 api/config 里; +在那一层发信号 = 只有一处知道「凭证换了」,不依赖调用方记得调。 + +# 身份用什么 + +`base + token` 的组合。**不含 user** —— 同一账号重新登录 token 变了, +但那个账号的邮件仍然该收(服务端按 user 或 agent 绑通道,见 sse.bufferKey); +按 base+token 判会让「同一账号换令牌」误触发重连(无害,只是多一次连接)。 +*/ +/** 当前凭证身份(base + token)。切号或令牌刷新后变化。 */ +export function credentialSignature(): string { + return `${API_BASE}|${getToken() ?? ''}`; +} + /** 监听 SSE 连接状态变化 */ export function onSSEStatus(handler: (s: SSEStatus) => void): () => void { statusHandlers.push(handler); @@ -37,6 +71,21 @@ function setStatus(s: SSEStatus) { statusHandlers.forEach(h => h(s)); } +/** + * 凭证变了就重连(SSE 订阅必须跟着账号走)。 + * + * ★ 2026-10-02:这是用户报的「页面停留不动、新邮件不自动同步」的修复点。 + * `App.tsx` 的 effect 依赖是 `[phase]`,而切号不改 phase ⇒ 连接绑在旧账号上。 + * 与其让调用方记得在切号后重建订阅(多处调用点、漏一处就不刷新), + * 不如在这一层自己发现凭证变了 —— **凭证变化的唯一发生地就是 api/config**。 + * + * 返回值给调用方放进 useEffect 依赖:变化时 React 会重跑 effect → 触发重连。 + * 不直接在这里重连,是因为 effect 重跑时会 close+open 一次,语义更清楚。 + */ +export function useCredentialChange(): string { + return credentialSignature(); +} + export function connectSSE(onEvent: SSEEventHandler): () => void { handlers.push(onEvent); if (!es) open(); @@ -47,12 +96,21 @@ export function connectSSE(onEvent: SSEEventHandler): () => void { }; } -function open() { +function open(resumeFrom = '') { close(false); setStatus('connecting'); + connectedWith = credentialSignature(); + lastEventId = resumeFrom; // EventSource 无法设置请求头:Cookie 模式靠同源 Cookie, // 密钥模式只能把令牌放进 query(服务端仅此端点与附件下载接受 ?access_token=)。 - es = new EventSource(withToken(`${API_BASE}/events/stream`), { withCredentials: true }); + let url = withToken(`${API_BASE}/events/stream`); + if (lastEventId) { + // ★ EventSource 不能设请求头 ⇒ lastEventId 只能进 query。 + // 服务端要相应支持读它(见 sse/manager.go 的 lastID 取值)。 + const sep = url.includes('?') ? '&' : '?'; + url += `${sep}lastEventId=${encodeURIComponent(lastEventId)}`; + } + es = new EventSource(url, { withCredentials: true }); // EventSource 会自动重连,但它的 readyState 在网络断开时 // 不一定及时反映状态。用 onopen 判断实际连上了。 @@ -61,9 +119,16 @@ function open() { setStatus('connected'); }; - es.addEventListener('connected', () => { + es.addEventListener('connected', (e: MessageEvent) => { backoff = 1000; setStatus('connected'); + // 服务端把当前游标放在 connected 帧的 data.id 里 + try { + const d = JSON.parse(e.data) as { id?: string }; + if (typeof d?.id === 'string' && d.id) lastEventId = d.id; + } catch { + /* 非 JSON 负载:保持原值 */ + } }); for (const name of EVENTS) { @@ -74,11 +139,42 @@ function open() { } catch { /* 忽略非 JSON 负载 */ } + // 记下游标:断线后从这儿续,服务端按 ring 里的 id 回放其后的��件 + if (typeof (e as MessageEvent).lastEventId === 'string' && e.lastEventId) { + lastEventId = e.lastEventId; + } handlers.forEach(h => h(name, data)); }); } + /* + * ★ 2026-10-02 断线重连带 Last-Event-ID(修复「断线期间的新邮件永远看不到」)。 + + # 为什么不能靠浏览器自动重连 + + `EventSource` 断线后**本来会自己重连并带上 Last-Event-ID**, + 服务端也据此回放(`sse/manager.go` 的 ring.replay,500 条缓冲)。 + 但我们原来的 onerror 主动 `close(false)` 再 `open()` —— + **换了一个 EventSource 对象**,而 Last-Event-ID 是浏览器为**那个对象**记的, + 新对象什么都不知道 ⇒ 服务端拿不到 lastID ⇒ 不回放。 + + # 为什么仍要自己重连(而不是交给浏览器) + + 状态可见性:`readyState` 在网络断开时不一定及时反映, + 而 UI 要显示「重连中」。这是原注释里已有的理由,保留。 + + # 怎么做才能既带 id 又换凭证 + + EventSource 不能设请求头 ⇒ lastEventId 只能进 **query** + (服务端目前只读 header,所以这条路要求服务端配合)。 + 两种重连要分开: + · **断线**(凭证没变)⇒ 带上次的 id,服务端回放断线期间的事件 + · **切号**(凭证变了)⇒ **必须不带** —— 拿旧账号的 id 去问新账号, + 会把别的事件流搅乱(id 是全局自增,但回放按该账号的 ring 匹配) + */ es.onerror = () => { + const credsChanged = connectedWith !== '' && connectedWith !== credentialSignature(); + const resumeFrom = credsChanged ? '' : lastEventId; close(false); if (handlers.length === 0) return; if (retryTimer) return; @@ -86,7 +182,7 @@ function open() { retryTimer = setTimeout(() => { retryTimer = null; backoff = Math.min(backoff * 2, 15000); - open(); + open(resumeFrom); }, backoff); }; } diff --git a/client/electron/src/lib/inboxFallbackPoll.ts b/client/electron/src/lib/inboxFallbackPoll.ts new file mode 100644 index 0000000..b6ad552 --- /dev/null +++ b/client/electron/src/lib/inboxFallbackPoll.ts @@ -0,0 +1,112 @@ +/** + * 收件箱兜底轮询(SSE 的冗余通道)。 + * + * ## 为什么需要它 + * + * SSE 只在**真的断开**时触发 `onerror`(于是重连)。但有一类故障它看不见: + * **连接还在、TCP 没断、却不再收数据** —— 代理静默丢包、NAT/防火墙超时、 + * 中间设备把长连接挂死。此时浏览器与服务端都认为连接正常, + * `onerror` 不触发,重连逻辑也就不会跑 ⇒ 页面停留就再也不同步。 + * + * 实测症状与用户报的一致:「页面停留不动、新邮件不自动同步」, + * 而手动刷新能看到(刷新走的是新请求,与那条挂死的连接无关)。 + * + * ## 为什么轮询只比 `total` + * + * 全量重拉每 N 秒一次会让接口与渲染都无谓地抖。用 `total`(未读+总数) + * 当探针:一次 GET,客户端只读一个数字,变了才真的拉列表。 + * + * `total` 会因为「已读/未读」变化而变 —— 那正是我们要捕捉的。 + * 归档/删除也会让它变,同样该刷新。 + * + * ## 间隔取值 + * + * 30s:远大于 SSE 的秒级延迟(所以正常情况下轮询只是冗余), + * 又短到「挂死最多 30 秒被发现」。页面隐藏时暂停(见下)。 + */ + +const POLL_INTERVAL_MS = 30_000; + +/** 探针快照;null = 还没建立基线(首轮只记录不触发) */ +let lastTotal: number | null = null; +let timer: ReturnType | null = null; +let inFlight = false; + +/** + * 一轮探针:比 total,变了就重拉。 + * + * `fetchInbox` 传进来而不是在这里 import —— store 之间互相 import + * 会把初始化顺序变成隐式依赖(accountStore ↔ mailStore 已经互相引用过一次)。 + */ +export async function pollOnce(fetchInbox: (status: string) => Promise): Promise { + if (inFlight) return false; // 上一次还没回来:跳过,别叠请求 + inFlight = true; + try { + const { getInbox } = await import('../api/client'); + const { total } = await getInbox('all', 1); // limit=1:只要计数,不要正文 + if (lastTotal === null) { + lastTotal = total; + return false; // 首轮建立基线,不触发刷新 + } + if (total !== lastTotal) { + lastTotal = total; + await fetchInbox('all'); + return true; + } + lastTotal = total; + return false; + } catch { + // 探针失败**不重置基线**:网络抖动不该让下一轮误判成「有变化」 + // 而触发一次全量重拉。基线保持,下轮继续比。 + return false; + } finally { + inFlight = false; + } +} + +/** + * 启动兜底轮询。返回一个停止函数。 + * + * ## 页面隐藏时暂停 + * + * 标签页切走时 SSE 照常连着(浏览器不会断),而轮询只是浪费; + * 更重要的是**恢复可见时立刻探一次** —— 用户往往就是「切回来发现没更新」 + * 才来报这个问题的,那一下正好补上。 + */ +export function startInboxFallbackPoll( + fetchInbox: (status: string) => Promise, + opts: { intervalMs?: number } = {} +): () => void { + const ms = opts.intervalMs ?? POLL_INTERVAL_MS; + let paused = false; + + const onVisible = () => { + if (document.visibilityState === 'visible') { + paused = false; + void pollOnce(fetchInbox); // 回来立刻探一次,不等下一个周期 + } else { + paused = true; + } + }; + + document.addEventListener('visibilitychange', onVisible); + paused = document.visibilityState !== 'visible'; + + timer = setInterval(() => { + if (paused) return; + void pollOnce(fetchInbox); + }, ms); + + return () => { + if (timer) { + clearInterval(timer); + timer = null; + } + document.removeEventListener('visibilitychange', onVisible); + }; +} + +/** 供切号/测试用:丢弃基线,下一轮只记录不触发 */ +export function resetPollBaseline(): void { + lastTotal = null; +} \ No newline at end of file diff --git a/client/electron/test/inbox-fallback-poll.test.mjs b/client/electron/test/inbox-fallback-poll.test.mjs new file mode 100644 index 0000000..ae08878 --- /dev/null +++ b/client/electron/test/inbox-fallback-poll.test.mjs @@ -0,0 +1,85 @@ +/** + * 收件箱兜底轮询:SSE 的**冗余**通道。 + * + * ## 为什么需要 + * + * SSE 只在**真的断开**时触发 `onerror`。有一类故障它看不见: + * **连接还在、TCP 没断、却不再收数据**(代理静默丢包、NAT 超时、 + * 中间设备挂死长连接)。两端都认为连接正常 ⇒ 不重连 ⇒ 页面停留就再也不同步。 + * + * 症状与用户报的一致:「页面停留不动、新邮件不自动同步」, + * 而手动刷新能看到(刷新走新请求,与那条挂死的连接无关)。 + * + * ## 为什么只比 `total` + * + * 每 N 秒全量重拉会让接口与渲染无谓地抖。`getInbox('all', 1)` 只要计数, + * 变了才真的拉列表。 + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const pollSrc = readFileSync(join(HERE, '..', 'src', 'lib', 'inboxFallbackPoll.ts'), 'utf8'); +const appSrc = readFileSync(join(HERE, '..', 'src', 'App.tsx'), 'utf8'); + +test('★ 探针只取计数(limit=1),变了才重拉', () => { + // 全量重拉会让接口与渲染无谓抖动;探针要廉价 + assert.ok( + /getInbox\('all',\s*1\)/.test(pollSrc), + '探针必须 limit=1 只要 total,不要正文'); + assert.ok( + /total !== lastTotal/.test(pollSrc), + '必须比 total —— 变了才刷新'); +}); + +test('★ 首轮只建基线不触发刷新(否则刚打开页面就白拉一次)', () => { + assert.ok( + /lastTotal === null/.test(pollSrc) && /return false; \/\/ 首轮建立基线/.test(pollSrc), + '首轮必须只记录基线,不触发刷新'); +}); + +test('★ 探针失败不得重置基线', () => { + // 网络抖动不该让下一轮误判成「有变化」而触发全量重拉 + const m = pollSrc.match(/} catch \{[\s\S]*?\n \}/); + assert.ok(m, '未找到 catch 分支'); + assert.ok( + !/lastTotal = null/.test(m[0]), + '★ catch 里不得重置基线 —— 那会把一次抖动变成「下一轮假装有变化」'); +}); + +test('★ inFlight 去重(上一轮未回就不叠请求)', () => { + assert.ok( + /if \(inFlight\) return false;/.test(pollSrc), + '必须有 inFlight 去重,否则慢网络下请求会叠加'); +}); + +test('★ 页面隐藏时暂停 + 恢复可见立刻探一次', () => { + // 用户往往就是「切回来发现没更新」才报的 —— 那一下正好补上 + assert.ok( + /visibilitychange/.test(pollSrc) && /pollOnce\(fetchInbox\)/.test(pollSrc), + '恢复可见时应立刻探一次,不等下一个周期'); + assert.ok(/paused = document\.visibilityState !== 'visible'/.test(pollSrc), + '初始就应按可见性决定是否暂停'); +}); + +test('★ App 里两条通道的 cleanup 都要调(漏一个 = 旧账号定时器残留)', () => { + assert.ok(/stopPoll\(\)/.test(appSrc), 'cleanup 必须停轮询定时器'); + assert.ok(/disconnect\(\)/.test(appSrc), 'cleanup 必须断 SSE'); + assert.ok( + /resetPollBaseline\(\)/.test(appSrc), + '切号必须丢弃基线:新账号的 total 与旧账号无关,' + + '不丢会误判成「有变化」而白拉一次'); +}); + +test('★ 轮询与 SSE 共用同一个 effect 依赖(凭证变化时一起重建)', () => { + // 轮询若挂在另一个 effect 上,切号时它不会重建 ⇒ 拿新凭证查旧账号的收件箱 + assert.ok( + /const credentialSig = useCredentialChange\(\);/.test(appSrc), + '凭证身份必须存在'); + assert.ok( + /}, \[phase, credentialSig\]\);/.test(appSrc), + 'effect 依赖必须含 credentialSig —— 轮询与 SSE 都在这个 effect 里'); +}); \ No newline at end of file diff --git a/client/electron/test/sse-credentials.test.mjs b/client/electron/test/sse-credentials.test.mjs new file mode 100644 index 0000000..ecbd124 --- /dev/null +++ b/client/electron/test/sse-credentials.test.mjs @@ -0,0 +1,104 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { readFileSync as read } from 'node:fs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const SRC = join(HERE, '..', 'src'); +const sse = readFileSync(join(SRC, 'api', 'sse.ts'), 'utf8'); +const app = readFileSync(join(SRC, 'App.tsx'), 'utf8'); +// 服务端在另一个仓(../../../../server),读它是为了钉「两侧同步」这条 —— +// 只改客户端不改服务端时,重放永远不生效,而**没有任何东西会红**。 +const sseMgr = read( + join(SRC, '..', '..', '..', 'server', 'internal', 'sse', 'manager.go'), + 'utf8'); + +/** + * WebUI 实时性:SSE 订阅必须**跟着账号凭证**走 + 断线重连要能补回漏掉的事件。 + * + * ## 缺陷 1(用户报:页面停留不动,新邮件不自动同步) + * + * `App.tsx` 里 `useEffect(..., [phase])` 只在**登录态**变化时连一次 SSE, + * 而切号(`accountStore.setActive`)只换 `api/config` 的 base/token、**不改 phase** + * ⇒ 连接仍绑旧账号:旧账号的新邮件照收,新账号的一封都不推。 + * 而 `fetchInbox` 走新凭证 ⇒ 数据是新的。 + * ⇒ 表现正是「不自动同步,但手动刷新能看到」——因为刷新走的是新凭证。 + * + * 修法:effect 依赖加上「当前凭证身份」。不在切号处显式重建订阅, + * 因为那要改所有调用点、漏一处就不刷新;凭证变化的唯一发生地是 api/config。 + * + * ## 缺陷 2:断线重连不重放 + * + * 服务端一直支持按 `Last-Event-ID` 回放(`sse/manager.go` 的 ring.replay,500 条缓冲), + * 而 EventSource 断线后**本来会自己重连并带上该头**。 + * 但本文件的 onerror 主动 `close(false)` 再 `open()` —— **换了 EventSource 对象**, + * 而 Last-Event-ID 是浏览器为**那个对象**记的,新对象什么都不知道 ⇒ 服务端拿不到 ⇒ 不回放。 + * + * 又因为 EventSource **不能设请求头**,只能走 query, + * 所以服务端也要相应支持读 `?lastEventId=`(两侧都要改,缺一半都不生效)。 + */ + +test('★ SSE 订阅跟着凭证走:App 的 effect 依赖含凭证身份', () => { + assert.ok( + /useCredentialChange/.test(app), + 'App.tsx 必须取当前凭证身份(否则切号后 SSE 仍绑旧账号)'); + // 关键:依赖数组里必须有它 + assert.ok( + /useEffect\(\(\) => \{[\s\S]*?connectSSE\([\s\S]*?\}, \[phase, credentialSig\]\)/.test(app), + 'SSE effect 的依赖数组必须含 credentialSig —— 只有 [phase] 时切号不重连'); +}); + +test('凭证身份 = base + token(不含 user:同账号换令牌不该误触发)', () => { + const m = sse.match(/export function credentialSignature\(\)[\s\S]*?\n}/); + assert.ok(m, '缺少 credentialSignature()'); + const body = m[0]; + assert.ok(/API_BASE/.test(body), '身份必须含 base(不同网关是不同的连接)'); + assert.ok(/getToken\(\)/.test(body), '身份必须含 token(不同账号是不同的通道)'); +}); + +test('★ 断线重连带上 lastEventId(否则服务端不回放 = 断线期间的新邮件永远看不到)', () => { + assert.ok(/lastEventId/.test(sse), '未记录/传递 lastEventId'); + // 从 connected 帧与事件帧里取游标 + assert.ok( + /addEventListener\('connected',[\s\S]{0,400}lastEventId\s*=/.test(sse), + '必须从 connected 帧的 data.id 记下游标(那是服务端给的游标)'); + // URL 上要真的带上 —— ★ 必须连**条件**一起钉。 + // + // 我第一版只查那行字符串存在,变异把 `if (lastEventId)` 改成 `if (false)` + // 后判据**照样绿**:正则匹配的是文本,而缺陷在控制流(那行永远不执行)。 + // ⇒ 断言必须同时包含「取 lastEventId」+ 「据此追加 query」两段。 + assert.ok( + /url \+= `\$\{sep\}lastEventId=\$\{encodeURIComponent\(lastEventId\)\}`/.test(sse), + 'EventSource 不能设请求头 ⇒ lastEventId 必须进 query'); + assert.ok( + /if \(lastEventId\) \{/.test(sse), + '★ 追加 query 的条件必须是 `if (lastEventId)` —— ' + + '只查那行文本存在的话,`if (false)` 让它永不执行也照样绿(实测过)'); + // 且条件读的是同一个变量(不是别的) + assert.ok( + /open\(resumeFrom = ''\)/.test(sse), + 'open() 必须接受 resumeFrom 并写入 lastEventId(否则重连参数无处落地)'); +}); + +test('★ 切号时不得带上旧账号的 lastEventId', () => { + // 拿旧 id 去问新账号会把别的事件流搅乱 —— 两种重连必须分开 + assert.ok( + /credsChanged = connectedWith !== '' && connectedWith !== credentialSignature\(\)/.test(sse), + '必须检测凭证是否变化'); + assert.ok( + /const resumeFrom = credsChanged \? '' : lastEventId;/.test(sse), + '凭证变了就必须**不带** lastEventId(它属于旧账号的 ring)'); +}); + +test('★ 服务端也认 lastEventId query(EventSource 不能设头,两侧要同步)', () => { + // 只改客户端不改服务端 = 重放永远不生效,而且没有任何东西会红 + assert.ok( + /r\.URL\.Query\(\)\.Get\("lastEventId"\)/.test(sseMgr), + '服务端必须读 ?lastEventId= —— 否则客户端带了也没人收'); + // 仍要保留标准头(Agent 侧 curl/SDK 可能走 EventSource 之外的路径) + assert.ok( + /r\.Header\.Get\("Last-Event-ID"\)/.test(sseMgr), + '必须保留标准头兜底(不能为了 query 把原路径删掉)'); +}); \ No newline at end of file diff --git a/server/internal/sse/manager.go b/server/internal/sse/manager.go index 6044d57..f00bb95 100644 --- a/server/internal/sse/manager.go +++ b/server/internal/sse/manager.go @@ -213,7 +213,21 @@ func (m *Manager) AddClient(res http.ResponseWriter, r *http.Request, agentName, // 一律经由 writeMu」成为**结构上**的纪律:将来有人把注册提前、或把回放挪到 // 注册之后(很自然的一个改动),没上锁的版本会**静默**退化成并发写。 // 锁在这里零成本,而它买的正是「后人改顺序也不会破」这件事。 - lastID := r.Header.Get("Last-Event-ID") + // ★ 2026-10-02:lastID 也接受 query(`?lastEventId=`)。 + // + // 为什么需要:EventSource **不能设请求头**,而 WebUI 的重连逻辑是 + // 「close 掉旧对象再新建一个」(为了 UI 能显示「重连中」—— + // readyState 在网络断开时不一定及时反映)。 + // 换对象就换掉了浏览器为**那个对象**记的 Last-Event-ID, + // 于是断线期间的事件永远不会被回放:用户报的现象就是 + // 「页面停留不动、新邮件不自动同步」。 + // + // 两处取值:query 优先(客户端显式给的),否则用标准头 + // (Agent 侧如 curl/SDK 可能仍走标准重连语义)。 + lastID := r.URL.Query().Get("lastEventId") + if lastID == "" { + lastID = r.Header.Get("Last-Event-ID") + } key := m.bufferKey(userName, agentName) if ring := m.getOrCreateRing(key); ring != nil && lastID != "" { client.writeMu.Lock()