From 3d6ae089a159ff1214f45bc896b1979a3eb6d288 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 16 Sep 2026 20:32:49 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BB=8A=E5=A4=A9=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E7=9A=84=E5=85=9C=E5=BA=95=E5=90=8C=E6=AD=A5=E4=BC=9A?= =?UTF-8?q?=E8=AE=A9=E5=B7=B2=E8=AF=BB=E4=BC=9A=E8=AF=9D=E7=9A=84=E6=9C=AA?= =?UTF-8?q?=E8=AF=BB=E8=A7=92=E6=A0=87=E6=97=A0=E6=95=85=E9=87=8D=E6=96=B0?= =?UTF-8?q?=E5=87=BA=E7=8E=B0=E7=9A=84=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:tinode-sdk自己的合并工具mergeObj(utils.js)对Date类型字段有"保留更新 的值"的防护,但普通数字字段(seq/read/recv/unread)完全没有防护,谁的数据 后到就直接覆盖——读源码逐行确认。startMeReconciliation()新增的定时/切换焦点 触发的withLaterSub()增量拉取一旦命中"服务端返回的read游标还没跟上客户端刚 发出的已读回执"这种时序,就会把已经读过的会话悄悄改回未读:角标无缘无故重新 出现,且重新聚焦窗口也不会清除,因为对这个会话来说根本没有发生任何新事情去 重新触发标记已读。 修复:reconcileMeSubs()改成在发起这次增量拉取前,先给所有已知会话的 seq/read/recv/unread拍个快照;拉取完成后,对每个"拉取前就已经认识"的会话, 如果发现seq没变但unread反而变多了,直接把read/recv/unread改回快照里的值并 触发通知——只有真正全新的会话(拉取前不认识)才信任这次拉取回来的数据,这 本来就是这个兜底机制存在的唯一目的。 实机验证:monkey-patch me.getMeta,让它在真实网络请求返回后额外注入一条 过期的sub记录(模拟服务端读游标滞后),再用真实的focus事件触发 reconcileMeSubs()整条链路——确认最终状态仍然正确显示已读,而不是被污染。 Co-Authored-By: Claude Sonnet 5 --- web/src/lib/tinode/topics.ts | 38 +++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/web/src/lib/tinode/topics.ts b/web/src/lib/tinode/topics.ts index 0e783abf..35cba77f 100644 --- a/web/src/lib/tinode/topics.ts +++ b/web/src/lib/tinode/topics.ts @@ -382,7 +382,43 @@ let meReconcileTimer: ReturnType | null = null; function reconcileMeSubs() { const me = getTinode().getMeTopic(); if (!me.isSubscribed()) return; - me.getMeta(me.startMetaQuery().withLaterSub().build()).catch(() => {}); + // Snapshot every currently-known topic's read-state fields before the fetch -- see the .then() + // below for why the response can't be trusted at face value for topics already tracked (only for + // genuinely new ones, which is this mitigation's entire reason to exist). + const before = new Map(); + for (const c of getChatListSnapshot()) { + if (c.topic) before.set(c.topic, { seq: c.seq || 0, read: c.read || 0, recv: c.recv || 0, unread: c.unread || 0 }); + } + me.getMeta(me.startMetaQuery().withLaterSub().build()) + .then(() => { + // Real, live-confirmed bug (2026-09-16, reported directly): tinode-sdk's own merge utility + // (mergeObj in utils.js) has a defensive "keep the later value" rule for Date fields but NONE + // for plain numbers -- seq/read/recv/unread just get blindly overwritten by whatever this + // fetch's response says, confirmed by reading the source. If the server's view of a sub's read + // cursor is even momentarily behind a `{note read}` the client already sent (e.g. this timer or + // a focus/visibility trigger firing shortly after the user opened and read a conversation), this + // silently un-reads it: the badge reappears with no new message behind it, and re-focusing the + // window doesn't clear it either since nothing about it actually changed to re-trigger a fresh + // read-mark -- confirmed reproducing exactly like this live, not a hypothetical race. Since plain + // numbers can't defend themselves the way Dates do, restore anything that regressed for topics + // already known BEFORE this fetch started, rather than trust the fetch there -- this mitigation + // only needs to win for topics that were unknown before it (the actual gap it exists to close), + // not overwrite ones a user has already read in the meantime. + for (const c of getChatListSnapshot()) { + if (!c.topic) continue; + const prev = before.get(c.topic); + if (!prev) continue; // brand-new topic to this session -- exactly what this mitigation is for + const seq = c.seq || 0; + const unread = c.unread || 0; + if (seq <= prev.seq && unread > prev.unread) { + c.read = prev.read; + c.recv = prev.recv; + c.unread = prev.unread; + notifyTopic(c.topic); + } + } + }) + .catch(() => {}); } /**