聊天项目中的已读光标
继续整理商业 IM 软件的聊天逻辑,这次是已读状态。
只做一个页面时,未读数似乎很好处理:收到消息加一,打开会话清零。加上 App、Web 同时在线以后就不够了。手机读过的消息要通知电脑,较早发出的请求又可能比较晚才返回,直接覆盖会把未读提醒改回去。
光标记录的是读到了哪里
Web 端的 ReadCursor 定义很直接:
export interface ReadCursor {
roomJid: string
lastMessageId: string | null
lastReadTime: number | null
sourceGroupJid?: string
}
它表示某个会话当前的阅读位置。本地可以根据这个位置,批量更新相关消息的已读状态,而不用在跨端同步时为每一条消息单独传递一个布尔值。
项目仍然保留消息级的 isRead。光标负责表达会话进度,消息级字段用于本地存储和展示,两者需要同步。
此外,本人读到了哪里、对方是否看过我发的消息、消息是否发送成功,也分别是不同的事实。一个字段承担太多含义,最后通常会变成各种条件分支互相修正。
旧响应不能覆盖新状态
假设本地已经知道阅读位置是 2000,这时一个较早发起的请求返回了 1000。
如果按“最后收到的结果覆盖当前值”处理,阅读进度就会倒退。已经消失的未读提醒,也可能重新出现。
项目的 mergeReadCursor 会比较 lastReadTime,较新的光标才会推进当前状态。源码中的核心分支如下:
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 沟通。
为了说明这个问题,可以把两个范围写成:
成员 A + 来源群甲
成员 A + 来源群乙
如果只拿成员 A 的 roomJid 当 key,两个范围会被当成同一个会话。群甲中的阅读动作,就可能影响群乙的未读数。
项目为光标生成 key 时,加入了来源群:
function getCursorKey(roomJid: string, sourceGroupJid?: string): string {
return sourceGroupJid ? `${roomJid}::${sourceGroupJid}` : roomJid
}
sourceGroupJid 虽然可选,但群下私聊不能漏掉它。
也因此,这个字段不能只在 UI 筛选时使用。历史请求、消息存储、分页、已读通知、光标和未读统计,都需要遵守相同的范围。
没有来源群,不是查询所有来源群
这个地方很容易写出看起来正确的筛选:传了来源群就按群筛选,不传就把整个 room 的消息都拿出来。
但对于主会话,“没有来源群”通常表达的是一个具体范围:消息本身也没有来源群。
Web 的部分未读统计分支就是这样处理的:有 sourceGroupJid 时要求匹配对应来源;没有时只统计不带来源群的消息。
这是两种完全不同的查询语义:
- 不传筛选条件,表示所有来源。
- 不带来源群,表示主会话。
如果接口和调用方没有讲清楚,代码里同样一个 undefined,就会在不同位置被理解成不同的意思。
检查这类问题时,我会同时看读光标、查消息和计数的调用链。某个 helper 使用了正确的复合 key,只能说明这一处识别了会话范围,不能直接证明所有更新路径都已经正确隔离。
改了光标,还要让页面看到变化
跨端已读最终会影响几个地方:本地消息记录、内存中的消息、会话未读数量,以及 UI。
如果只改数据库,当前页面可能继续显示旧状态;只改内存,刷新之后又会从本地库读回旧结果。
项目的 Web 已读处理包含数据库批量更新、消息状态更新和未读刷新。其中一处更新失败,还得靠后续同步校准。
如果未读数又跳回来了,可以先查是否接纳了旧光标,再看来源群有没有漏传。最后核对数据库和页面内存是否都更新了。