多端同步与消息漫游
同一账号可以在多个设备同时在线,每个设备一条独立的 WebSocket 会话。rev25 起服务端按设备维护投递游标(WS query deviceId,SDK 自动生成并持久化):A 设备确认消息不会推进 B 设备的游标;未读/已读回执仍按用户聚合,通过各自的同步流校正而不是端到端互推。本页聚焦“多端不一致”的排查与实现约束。
模型
| 对象 | 多端行为 | 注意 |
|---|---|---|
| 登录会话 | 同一用户允许多条 WS 会话;新会话不会自动踢掉旧会话 | rev25 起 WS query 可带 deviceId(SDK 自动生成并持久化),hello 回显 |
| 消息 | 每条消息按各自会话投递;离线消息在 hello 后按该设备游标补发 | at-least-once:每端都必须按 messageId 去重 |
| 投递游标 | 每设备独立 deliveredSeq({app}:{user}:{device}:{conv});ack/read/read_receipt 只推进当前设备;无 deviceId 走 legacy 用户级游标 | A ack 不影响 B 的离线补收;新设备游标为 0 |
| 未读 | 未读由服务端累加;进入会话后清零(用户级) | 一端清零会影响服务端计数,其他端需重新拉会话列表 |
| 已读回执 | 按读者聚合;发送方查询已读人数/列表 | 群聊含累计已读人数,快照不保证实时 |
| 会话设置 | 置顶/免打扰写服务端;其他端重新拉列表才可见 | 草稿仅本地,不同步 |
| 历史消息 | 按会话分页拉取;本地缓存因端而异 | Web/Flutter 无本地持久化;Android/Electron 有本地库 |
| 撤回 | 通过 MESSAGE_RECALLED 事件通知;历史里是 recall 占位 | 冷启动后需重新拉历史才能看到撤回 |
多端不是实时镜像
协议没有“跨端广播会话设置/草稿/已读明细”的强一致通道;除消息外,其他状态以“重新拉取 + 事件驱动”为准。rev25 的 deviceId 只解决“补发到哪”,未读仍用户级(端级未读/端级已读是剩余缺口)。
常见不一致与排查
| 现象 | 可能原因 | 排查 / 修复 |
|---|---|---|
| A 端发的消息 B 端没有 | B 端未在线且离线消息超过每会话 500 条上限;或 B 端未等到 sync_done | 检查 B 端重连后是否补收;超出上限的消息需通过历史分页拉取 |
| B 端多出重复消息 | 没有按 messageId 去重;重试换了 clientMsgId;或 B 的 deviceId 变化(清缓存/换设备→新设备全量补收) | 加去重表;重试复用 clientMsgId;确认 deviceId 持久化未被清除 |
| A 端已读后 B 端仍收到补收 | A/B 是不同设备:A 的 read 只推进 A 的游标 | 预期行为;B 需自行 ack/read 推进自己的游标 |
| 未读数两端不同 | A 端清零后 B 端未重新拉会话列表(未读用户级) | 监听会话事件或前台刷新时重新拉取 |
| 置顶/免打扰不同步 | 设置写服务端,但 B 端没有重新拉列表 | B 端重新 getConversationList |
| 已读数不实时 | 已读回执是快照,事件可能延迟 | 以事件驱动刷新,必要时查询已读明细 |
| 被踢下线后仍在收消息 | KICKED 后未停止业务轮询/未清理连接 | 处理 KICKED:停止重连、清理状态、提示原因 |
| 换设备后草稿丢失 | 草稿仅本地存储 | 如需跨端草稿,业务侧自行同步 |
JavaScript
// 多端一致性的最小实现:消息去重 + 前台刷新 + KICKED 处理
const seen = new Set(); // 生产建议持久化(按会话 + messageId)
client.addEventListener(Events.MESSAGES, ({ messages }) => {
const fresh = messages.filter((m) => !seen.has(m.messageId));
fresh.forEach((m) => seen.add(m.messageId));
if (fresh.length) render(fresh); // 只渲染新消息,避免重复
});
document.addEventListener('visibilitychange', async () => {
if (document.visibilityState !== 'visible') return;
// 回到前台:重新拉会话列表与当前会话历史,校正未读/撤回/多端操作
const conversations = await client.getConversationList();
renderConversations(conversations.data);
if (current) {
const page = await client.getHistoryMessages(current.type, current.targetId, { limit: 20 });
renderHistory(page.data);
await client.clearUnreadCount(current.type, current.targetId);
}
});
client.addEventListener(Events.KICKED, ({ code, reason }) => {
clearInterval(pollTimer);
disconnectAndLogout({ code, reason });
});多端在线策略建议
| 业务类型 | 建议 | 理由 |
|---|---|---|
| 社交/办公 | 允许多端同时在线;投递按设备游标(rev25),未读按用户共享 | 用户期望手机/桌面同时收消息;端级未读需业务自行补偿 |
| 金融/安全敏感 | 单端在线或新登录踢旧端(业务层实现) | 协议不强制踢人;需要业务自行下发踢人/失效 Token |
| 直播互动 | 允许多端,但聊天室成员按 WS 会话计数 | 断连即离开,在线人数按会话 |
| 客服坐席 | 按坐席绑定固定端 + 服务端会话审计 | 需要可追溯的操作日志 |