多端在线与多设备同步
多端在线描述“同一个 userId 可以同时建立多少条长连接”,多设备同步描述“同一条消息如何复制给该账号的全部在线端,以及重连时从哪里继续补发”。rev25 起服务端按设备维护投递游标(deviceId),不再由“任一端 ack”推进全体游标;未读/已读回执仍按用户聚合。本页给出行为、参数与可运行验证脚本。
概念
| 概念 | 本项目定义 | 实现位置 |
|---|---|---|
| 会话 session | 一次 WS 建连对应一个随机 UUID 的 sessionId;同一用户允许多条并存 | main.rs handle_socket |
| 多端在线 | 同一 {appkey,userId} 的 online 列表保存全部在线 sessionId;新连接不驱逐旧连接 | Server.online: HashMap<String, Vec<String>> |
| 多端同步 | 消息扇出到该用户全部在线会话;发送者本人的其他端收到 direction=1(SEND)副本 | deliver_message 的 targets 构造 |
| 设备标识 | rev25:WS query 可选 deviceId(≤128,SDK 自动生成并持久化);hello 回显 | SDK 本地存储 + 服务端握手 |
| 投递游标 | 按 {app}:{user}:{device}:{conv} 记录 deliveredSeq;ack/read/read_receipt 只推进当前设备;无 deviceId 的旧客户端走 {app}:{user}:{conv} legacy 游标 | Server.device_delivered / Server.delivered |
| 已读状态 | read 帧把该用户该会话的 unreadCount/mentionedCount 清零;未读仍用户级(多端共享),但设备游标独立 | read 分支 + 会话持久化 |
rev25 起本项目已有设备级投递游标(A 设备 ack 不影响 B 设备的离线补收)与稳定 deviceId;但仍没有设备注册表/设备管理 API,也没有端级未读与端级已读状态。
“手机读了、PC 未读清零”仍会发生:未读是用户级;设备游标只决定补发位置。端级未读已登记为剩余缺口(见 C04 收口页)。
前置条件
- 服务端已启动并监听
http://127.0.0.1:8080(环境变量IM_ADDR,默认127.0.0.1:8080)。 - 已创建应用并取得 AppKey;已注册至少两个用户(
alice作为多端账号,bob作为对端)。 - 已为
alice签发 Token(7 天有效);两端使用同一个 Token 才能验证多端副本。 - 验证脚本使用 Node.js 22+ 的全局
WebSocket,无需安装ws依赖。
步骤
- 创建应用与用户
curl -sS -X POST http://127.0.0.1:8080/api/admin/apps -H 'X-Admin-Secret: dev-admin' -H 'content-type: application/json' -d '{"appkey":"demo","name":"Demo"}'curl -sS -X POST http://127.0.0.1:8080/api/admin/apps/demo/users -H 'X-Admin-Secret: dev-admin' -H 'content-type: application/json' -d '{"userId":"alice","name":"Alice"}'对
bob重复上一步。 - 签发 Token
curl -sS -X POST http://127.0.0.1:8080/api/admin/apps/demo/users/alice/token -H 'X-Admin-Secret: dev-admin'响应
data.token即 JWT;data.expireAt为过期毫秒时间戳。把 Token 写入脚本的IM_TOKEN变量。 - 同账号开两个会话
运行下方「多端副本验证」脚本。两端都应收到
hello与sync_done。脚本手工建连时未带
deviceId,因此两端都走 legacy 用户级游标;用各端 SDK 建连会自动带 deviceId,syncState按各自设备返回(见 多设备投递游标)。 - 验证多端副本
脚本由 A 端发送一条单聊消息给
bob。A 端收到send_ack;B 端应收到同一条消息的副本,direction=1(SEND),且clientMsgId与 A 端一致。若
bob也有在线客户端,bob收到的是direction=2(RECEIVE)的独立副本。 - 观察游标推进与补发
脚本在
send_ack后自动发送ack帧。带 deviceId 的 SDK 连接中,B 端 ack 只推进 B 设备游标,A 端重连仍按 A 游标补收;不带 deviceId 时仍是用户级游标(任一端 ack 即全体前进,升级前行为)。新设备(新 deviceId)首次连接游标为 0,会补收全部未 ack 消息(每会话 ≤500 条)。
- 验证 kick
运行「KICK 验证」脚本,再在另一个终端封禁该用户。连接应收到
kick帧(code=1016),随后被服务端关闭,客户端不再重连。
参数表
WS 建连参数
| 参数 | 位置 | 必填 | 说明 |
|---|---|---|---|
appkey | Query | 是 | 应用 AppKey;与 Token 中的 appkey 必须一致 |
token | Query | 是 | 用户 JWT;校验失败返回 HTTP 401/403(1002/1016/1017) |
sessionId | hello 响应 | — | 服务端生成的会话 ID,同一用户多连接各不相同 |
hello 与同步帧
| 帧 / 字段 | 方向 | 说明 |
|---|---|---|
hello | S→C | 建连成功;含 userId、sessionId、serverTime(毫秒) |
hello.syncState.conversations[] | S→C | 每会话 {conversationType,targetId,deliveredSeq};用于客户端判断补发范围 |
message | S→C | 离线补发、多端副本、实时消息统一用该帧;data.message.direction 区分收发 |
sync_done | S→C | 补发结束;data.count 为本次补发条数 |
ack | C→S | {messageId};推进该用户该会话 deliveredSeq,无 ack 则重连重放 |
read | C→S | {conversationType,targetId,seq?};清该用户未读,返回 read_ok |
sync | C→S | 契约预留的主动补拉;当前服务端固定返回 sync_done{count:0},不实际补拉 |
kick | S→C | {code,reason};发送后服务端主动关闭连接 |
服务端环境变量(多端相关)
| 变量 | 默认值 | 作用 |
|---|---|---|
IM_ADDR | 127.0.0.1:8080 | 监听地址;WS 路径固定为 /ws |
IM_DATA_DIR | data | 数据目录;游标与会话随状态快照/日志持久化,重启后仍生效 |
IM_ADMIN_SECRET | dev-admin | Admin API 密钥(签发 Token、封禁、查看连接) |
IM_DATABASE_URL | 空 | 配置 PostgreSQL 时游标写入 delivered 表;空则使用本地状态文件 |
IM_DATA_LOCK | 1 | 同一数据目录的进程锁;0 关闭(仅隔离环境使用) |
IM_MAINTENANCE_MS | 内置默认 | 维护任务周期(聊天室空闲销毁、nonce 过期清理) |
协议层面的上限(离线补发条数、帧大小、发送频率)见 限频、配额与容量限制。
示例
多端副本验证(Node.js)
// dual-session.mjs — 同一用户开两个 WS 会话,验证「多端副本 + 用户级游标」
// 运行:node dual-session.mjs(Node.js 22+,使用全局 WebSocket,无需安装依赖)
// 前置:服务端已启动;已注册 alice/bob;已取得 alice 的 Token。
const HOST = process.env.IM_HOST || 'http://127.0.0.1:8080';
const APPKEY = process.env.IM_APPKEY || 'demo'; // 变量:控制台创建应用后的 AppKey
const TOKEN = process.env.IM_TOKEN || 'YOUR_USER_TOKEN'; // 变量:alice 的 JWT(见下方 curl)
const wsUrl = HOST.replace(/^http/, 'ws') + '/ws?appkey=' + encodeURIComponent(APPKEY) +
'&token=' + encodeURIComponent(TOKEN);
function open(label) {
const ws = new WebSocket(wsUrl);
ws.addEventListener('open', () => console.log('[' + label + '] open'));
ws.addEventListener('message', (ev) => {
const frame = JSON.parse(ev.data);
if (frame.type === 'hello') {
console.log('[' + label + '] hello sessionId=' + frame.data.sessionId);
console.log('[' + label + '] syncState=' + JSON.stringify(frame.data.syncState));
} else if (frame.type === 'message') {
console.log('[' + label + '] message seq=' + frame.data.message.seq +
' direction=' + frame.data.message.direction + ' from=' + frame.data.message.senderId);
} else if (frame.type === 'sync_done') {
console.log('[' + label + '] sync_done count=' + frame.data.count);
} else if (frame.type === 'send_ack') {
console.log('[' + label + '] send_ack seq=' + frame.data.seq);
// 客户端「持久化成功」后 ack:推进该用户此会话的 deliveredSeq。
ws.send(JSON.stringify({ id: 'ack-' + frame.data.messageId, type: 'ack',
data: { messageId: frame.data.messageId } }));
} else if (frame.type === 'kick') {
console.log('[' + label + '] KICK code=' + frame.data.code + ' reason=' + frame.data.reason);
} else if (frame.type === 'error') {
console.log('[' + label + '] error ' + JSON.stringify(frame.data));
}
});
ws.addEventListener('close', (ev) => console.log('[' + label + '] close code=' + ev.code));
return ws;
}
const a = open('A');
const b = open('B');
// 等两端都收到 hello/sync_done 后,由 A 发一条单聊消息给 bob;B 应收到 direction=1 的副本。
setTimeout(() => {
a.send(JSON.stringify({ id: 'send-1', type: 'send', data: {
conversationType: 1, targetId: 'bob',
content: { type: 'text', text: 'hello from session A' },
clientMsgId: 'c_' + Date.now()
} }));
}, 1000);
setTimeout(() => { a.close(); b.close(); }, 4000);KICK 验证(Node.js)
// kick.mjs — 观察封禁后的 kick 帧与连接关闭
// 运行:node kick.mjs(Node.js 22+)
// 另一个终端执行封禁(把 <TOKEN> 换成被观察用户的 Token,<USER> 换成该用户 ID):
// curl -sS -X POST http://127.0.0.1:8080/api/admin/apps/demo/users/<USER>/ban \
// -H 'X-Admin-Secret: dev-admin' -H 'content-type: application/json' \
// -d '{"banned":true,"reason":"policy"}'
const HOST = process.env.IM_HOST || 'http://127.0.0.1:8080';
const APPKEY = process.env.IM_APPKEY || 'demo';
const TOKEN = process.env.IM_TOKEN || 'YOUR_USER_TOKEN';
const ws = new WebSocket(HOST.replace(/^http/, 'ws') + '/ws?appkey=' +
encodeURIComponent(APPKEY) + '&token=' + encodeURIComponent(TOKEN));
ws.addEventListener('message', (ev) => {
const frame = JSON.parse(ev.data);
if (frame.type === 'kick') console.log('kick:', frame.data.code, frame.data.reason);
});
ws.addEventListener('close', (ev) => console.log('closed:', ev.code, ev.reason));查看当前多端连接
# 该用户的会话列表(每个在线端一行;device 当前恒为空)
curl -sS "http://127.0.0.1:8080/api/admin/apps/demo/connections?userId=alice" \
-H 'X-Admin-Secret: dev-admin'
# 应用维度连接统计(在线用户数 / 会话数 / 今日峰值)
curl -sS "http://127.0.0.1:8080/api/admin/apps/demo/connections/stats" \
-H 'X-Admin-Secret: dev-admin'常见问题
同账号两端都收到同一条消息,是重复投递吗?
不是。这是多端副本:发送者的其他端收到 direction=1 副本(clientMsgId 相同),接收方各端收到 direction=2 副本。客户端必须按 messageId 去重(SDK 已内置去重并回 ack)。
新登录会把旧端踢下线吗?
不会。建连只追加 sessionId,不驱逐既有会话。只有封禁、注销、删号、Token 吊销(user.token.revoke)会推送 kick 并关闭连接。
为什么一端已读,另一端未读还在?
未读按用户存储,不按设备。任一端执行 read 后服务端会话未读清零,但其他端不会收到“已读”推送,需要重新 getConversationList() 才能看到;端级已读同步尚未实现。
重连后为什么没有补发刚才的消息?
因为该用户此会话的 deliveredSeq 已被任一端 ack 推进。补发条件是 seq > deliveredSeq,与“哪台设备”无关。
主动发 sync 帧为什么不返回消息?
该帧在协议中标注为预留;服务端实现固定返回 sync_done{count:0}。需要主动补拉请改用 Open API message.history 或客户端 SDK getHistoryMessages。
connections 接口的 device 字段为什么是空?
服务端未从握手参数读取设备信息,SessionInfo.device 初始化为空字符串,仅作为字段占位。
相关链接
- 多设备投递游标(deviceId):rev25 设备级游标、hello 回显与兼容
- deviceId 持久化与排障:各端存储位置、重置与排障
- 多设备补收与排障:deliveredSeq、500 条上限与历史兜底
- 多端同步与消息漫游:多端一致性排查
- 图解消息流转:发送、ACK、离线补发时序
- WebSocket 协议参考:帧结构与可靠性字段
- 限频、配额与容量限制:离线补发与帧上限
- 本地存储与离线时长:各端本地缓存与清理策略
- C04 多设备能力收口