多端在线与多设备同步

多端在线描述“同一个 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 依赖。

步骤​

  1. 创建应用与用户
    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 重复上一步。

  2. 签发 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 变量。

  3. 同账号开两个会话

    运行下方「多端副本验证」脚本。两端都应收到 hello 与 sync_done。

    脚本手工建连时未带 deviceId,因此两端都走 legacy 用户级游标;用各端 SDK 建连会自动带 deviceId,syncState 按各自设备返回(见 多设备投递游标)。

  4. 验证多端副本

    脚本由 A 端发送一条单聊消息给 bob。A 端收到 send_ack;B 端应收到同一条消息的副本,direction=1(SEND),且 clientMsgId 与 A 端一致。

    若 bob 也有在线客户端,bob 收到的是 direction=2(RECEIVE)的独立副本。

  5. 观察游标推进与补发

    脚本在 send_ack 后自动发送 ack 帧。带 deviceId 的 SDK 连接中,B 端 ack 只推进 B 设备游标,A 端重连仍按 A 游标补收;不带 deviceId 时仍是用户级游标(任一端 ack 即全体前进,升级前行为)。

    新设备(新 deviceId)首次连接游标为 0,会补收全部未 ack 消息(每会话 ≤500 条)。

  6. 验证 kick

    运行「KICK 验证」脚本,再在另一个终端封禁该用户。连接应收到 kick 帧(code=1016),随后被服务端关闭,客户端不再重连。

参数表​

WS 建连参数​

参数位置必填说明
appkeyQuery是应用 AppKey;与 Token 中的 appkey 必须一致
tokenQuery是用户 JWT;校验失败返回 HTTP 401/403(1002/1016/1017)
sessionIdhello 响应—服务端生成的会话 ID,同一用户多连接各不相同

hello 与同步帧​

帧 / 字段方向说明
helloS→C建连成功;含 userId、sessionId、serverTime(毫秒)
hello.syncState.conversations[]S→C每会话 {conversationType,targetId,deliveredSeq};用于客户端判断补发范围
messageS→C离线补发、多端副本、实时消息统一用该帧;data.message.direction 区分收发
sync_doneS→C补发结束;data.count 为本次补发条数
ackC→S{messageId};推进该用户该会话 deliveredSeq,无 ack 则重连重放
readC→S{conversationType,targetId,seq?};清该用户未读,返回 read_ok
syncC→S契约预留的主动补拉;当前服务端固定返回 sync_done{count:0},不实际补拉
kickS→C{code,reason};发送后服务端主动关闭连接

服务端环境变量(多端相关)​

变量默认值作用
IM_ADDR127.0.0.1:8080监听地址;WS 路径固定为 /ws
IM_DATA_DIRdata数据目录;游标与会话随状态快照/日志持久化,重启后仍生效
IM_ADMIN_SECRETdev-adminAdmin API 密钥(签发 Token、封禁、查看连接)
IM_DATABASE_URL空配置 PostgreSQL 时游标写入 delivered 表;空则使用本地状态文件
IM_DATA_LOCK1同一数据目录的进程锁;0 关闭(仅隔离环境使用)
IM_MAINTENANCE_MS内置默认维护任务周期(聊天室空闲销毁、nonce 过期清理)

协议层面的上限(离线补发条数、帧大小、发送频率)见 限频、配额与容量限制。

示例​

多端副本验证(Node.js)​

dual-session.mjs
// 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.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));

查看当前多端连接​

Shell
# 该用户的会话列表(每个在线端一行;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 初始化为空字符串,仅作为字段占位。

相关链接​