聊天项目中的已读光标

Jul 18·5 min
AI 生成的摘要
商业 IM 软件用已读光标同步 App 和 Web 的阅读进度。记录旧响应的合并规则,以及群下私聊为什么还要带 sourceGroupJid。

继续整理商业 IM 软件的聊天逻辑,这次是已读状态。

只做一个页面时,未读数似乎很好处理:收到消息加一,打开会话清零。加上 App、Web 同时在线以后就不够了。手机读过的消息要通知电脑,较早发出的请求又可能比较晚才返回,直接覆盖会把未读提醒改回去。

光标记录的是读到了哪里

Web 端的 ReadCursor 定义很直接:

snippet
typescript
export interface ReadCursor {
  roomJid: string
  lastMessageId: string | null
  lastReadTime: number | null
  sourceGroupJid?: string
}

它表示某个会话当前的阅读位置。本地可以根据这个位置,批量更新相关消息的已读状态,而不用在跨端同步时为每一条消息单独传递一个布尔值。

项目仍然保留消息级的 isRead。光标负责表达会话进度,消息级字段用于本地存储和展示,两者需要同步。

此外,本人读到了哪里、对方是否看过我发的消息、消息是否发送成功,也分别是不同的事实。一个字段承担太多含义,最后通常会变成各种条件分支互相修正。

旧响应不能覆盖新状态

假设本地已经知道阅读位置是 2000,这时一个较早发起的请求返回了 1000

如果按“最后收到的结果覆盖当前值”处理,阅读进度就会倒退。已经消失的未读提醒,也可能重新出现。

项目的 mergeReadCursor 会比较 lastReadTime,较新的光标才会推进当前状态。源码中的核心分支如下:

snippet
typescript
if (incoming.lastReadTime > current.lastReadTime) {
  return incoming
}

if (incoming.lastReadTime === current.lastReadTime
  && !current.lastMessageId
  && incoming.lastMessageId) {
  return incoming
}

return current

这段摘录之前还有空值处理:当前时间为空而新值有时间时,可以接纳新值;新值时间为空时,不覆盖已有的有效时间。

同一个时间点也没有直接比较两个消息 ID 的大小。只有当前缺少 ID、新值带有 ID 时,才补上这部分信息。

消息 ID 是标识,不一定是序号。拿它做字典序比较,看起来能排出顺序,却不一定符合业务时间线。

这套合并规则解决了“旧光标覆盖新光标”的问题。它依赖读时间的语义在各条链路上可比较;如果未来要处理更严格的顺序、同时间精确边界或阅读位置重置,就需要进一步定义版本或序号规则。这段代码还没有覆盖后面这些情况。

一个聊天对象,可能对应多个业务会话

这款商业 IM 软件还有群下成员私聊。同一个成员,可能在不同群业务里与 staff 沟通。

为了说明这个问题,可以把两个范围写成:

snippet
text
成员 A + 来源群甲
成员 A + 来源群乙

如果只拿成员 A 的 roomJid 当 key,两个范围会被当成同一个会话。群甲中的阅读动作,就可能影响群乙的未读数。

项目为光标生成 key 时,加入了来源群:

snippet
typescript
function getCursorKey(roomJid: string, sourceGroupJid?: string): string {
  return sourceGroupJid ? `${roomJid}::${sourceGroupJid}` : roomJid
}

sourceGroupJid 虽然可选,但群下私聊不能漏掉它。

也因此,这个字段不能只在 UI 筛选时使用。历史请求、消息存储、分页、已读通知、光标和未读统计,都需要遵守相同的范围。

没有来源群,不是查询所有来源群

这个地方很容易写出看起来正确的筛选:传了来源群就按群筛选,不传就把整个 room 的消息都拿出来。

但对于主会话,“没有来源群”通常表达的是一个具体范围:消息本身也没有来源群。

Web 的部分未读统计分支就是这样处理的:有 sourceGroupJid 时要求匹配对应来源;没有时只统计不带来源群的消息。

这是两种完全不同的查询语义:

  • 不传筛选条件,表示所有来源。
  • 不带来源群,表示主会话。

如果接口和调用方没有讲清楚,代码里同样一个 undefined,就会在不同位置被理解成不同的意思。

检查这类问题时,我会同时看读光标、查消息和计数的调用链。某个 helper 使用了正确的复合 key,只能说明这一处识别了会话范围,不能直接证明所有更新路径都已经正确隔离。

改了光标,还要让页面看到变化

跨端已读最终会影响几个地方:本地消息记录、内存中的消息、会话未读数量,以及 UI。

如果只改数据库,当前页面可能继续显示旧状态;只改内存,刷新之后又会从本地库读回旧结果。

项目的 Web 已读处理包含数据库批量更新、消息状态更新和未读刷新。其中一处更新失败,还得靠后续同步校准。

如果未读数又跳回来了,可以先查是否接纳了旧光标,再看来源群有没有漏传。最后核对数据库和页面内存是否都更新了。

最后修改时间: Sep 15
cd ..