Files
im_server/docs/problems/IM后端问题报告-好友通过后系统提示投递到旧命名话题.md
T

13 KiB
Raw Blame History

IM 后端问题报告:好友申请通过后 friend_added 系统提示被投递到旧命名话题 usr<uid>,同一会话存在两套 Topic

报告日期:2026-09-13 客户端:iOS(MeetingIM Swift SDK) 涉及账号:usrv97xq86jw3c(echarles5,通过人本人,本报告取证端)、usrHnkwn8VSfKc(charles1,申请人) 涉及话题:usrHnkwn8VSfKc(旧标准命名)与 p2pv97xq86jw3ceeTCfxVJ8pw(后端内部命名) 优先级建议:高(会话列表消息与未读不更新,用户可见)


1. 问题概述

好友申请通过后,服务端把一条会话内系统提示 system_notice / friend_added("你们已成为好友,现在可以开始聊天了。")下发到了

  • ❌ usrHnkwn8VSfKc(MeetingIM 的 usr<对端uid> 旧标准命名,本端并未订阅该话题)

而不是

  • ✅ p2pv97xq86jw3ceeTCfxVJ8pw(服务端自己在 me 的 meta sub 里下发、并在 friend.request.accepted 的 payload.topic 里给出的真正会话)。

同一时刻服务端还把同一条逻辑消息(同 ts、同 seq=17)写进了 p2p... 话题,形成双写。

结果:客户端本地同一好友出现两条单聊会话,导致用户可见的两个问题:

  1. 会话行的 usr<uid> 那条没有服务端下发的 acs → isJoiner == false → 客户端在会话行右下角显示出 nosign(🚫)禁止图标;
  2. 客户端会话列表去重逻辑保留 usr<uid>、隐藏 p2p... → 对方的后续消息与未读数都不更新(未读还被从总数里扣掉,"消息"Tab 角标偏小)。

2. 时间线(取证端 = 通过人本人 B,2026-09-13)

时间(UTC) 事件
06:13:39 B(echarles5)删除好友 A(charles1),双方关系解除(A 端收到 friend.deleted)
06:27:09.610 A 申请、B 通过;服务端下发 friend_added 系统提示,同时写入 usrHnkwn8VSfKc(seq 17)与 p2pv97xq86jw3ceeTCfxVJ8pw(seq 17)
06:27:09.594 GET /v1/im/friends 返回 A 的会话 topic = p2pv97xq86jw3ceeTCfxVJ8pw
06:27:10.367 POST /v1/im/conversations/p2p {"peer_user_id":"usrHnkwn8VSfKc"} 返回 topic = p2pv97xq86jw3ceeTCfxVJ8pw
06:27:11.070 me 的 meta sub 只下发 p2pv97xq86jw3ceeTCfxVJ8pw(订阅 acs=JRWPA、priv.peer=usrHnkwn8VSfKc),没有 usrHnkwn8VSfKc
06:27:32.089 A 发送消息 a,落在 p2pv97xq86jw3ceeTCfxVJ8pw seq 18

3. 服务端下行报文(原文)

3.1 系统提示被实时投递到旧命名话题(本端未订阅)

{
  "data": {
    "topic": "usrHnkwn8VSfKc",
    "ts": "2026-09-13T06:27:09.61Z",
    "seq": 17,
    "head": {
      "x-im-from": "system",
      "x-im-schema": 1,
      "x-im-template": "friend_added",
      "x-im-type": "system_notice"
    },
    "content": {
      "actors": [
        { "user_id": "usrv97xq86jw3c", "click": { "target_id": "usrv97xq86jw3c", "type": "user_profile" } },
        { "user_id": "usrHnkwn8VSfKc", "click": { "target_id": "usrHnkwn8VSfKc", "type": "user_profile" } }
      ],
      "extra": { "request_id": "frq_OYo72xKkkgU" },
      "level": "normal",
      "template": "friend_added",
      "text": "你们已成为好友,现在可以开始聊天了。"
    }
  }
}

客户端侧随即落库并回执(说明客户端把它当作合法会话消息处理):

SqlStore - msgReceived:来自话题 payload usrHnkwn8VSfKc,topicId=9,userId=3,seq=17
出站[回执(note)] {"note":{"what":"recv","topic":"usrHnkwn8VSfKc","seq":17}}
入站[在线状态(pres)] {"pres":{"topic":"me","src":"usrHnkwn8VSfKc","what":"msg","seq":17}}

3.2 同一条消息也被写进真会话(双写证据)

随后客户端订阅真会话拉历史,拿到同 ts、同 seq 的同一条提示:

{
  "data": {
    "topic": "p2pv97xq86jw3ceeTCfxVJ8pw",
    "ts": "2026-09-13T06:27:09.61Z",
    "seq": 17,
    "head": { "x-im-from": "system", "x-im-schema": 1, "x-im-template": "friend_added", "x-im-type": "system_notice" },
    "content": { "template": "friend_added", "text": "你们已成为好友,现在可以开始聊天了。", "...": "与上一条 content 完全一致" }
  }
}
SqlStore - msgReceived:来自话题 payload p2pv97xq86jw3ceeTCfxVJ8pw,topicId=11,userId=3,seq=17

同一 ts + 同一 seq=17 出现在两个 topic ⇒ 服务端对该提示写了两份。 本地 topicId:usrHnkwn8VSfKc = 9、p2pv97xq86jw3ceeTCfxVJ8pw = 11 —— 旧命名话题更早存在,本轮关系重建后并未被撤销或合并。

3.3 服务端自己只承认 p2p...(me 的 meta sub)

{
  "meta": {
    "id": "115975",
    "topic": "me",
    "ts": "2026-09-13T06:27:32.444Z",
    "sub": [
      {
        "updated": "2026-09-13T06:27:11.07Z",
        "online": true,
        "acs": { "want": "JRWPA", "given": "JRWPA", "mode": "JRWPA" },
        "public": {
          "avatar_file_id": "ZrI4Fut4heE.jpg",
          "display_name": "charles1",
          "fn": "charles1",
          "name": "charles1",
          "nickname": "charles1",
          "public_account": "usrHnkwn8VSfKc"
        },
        "private": { "created_by": "http", "peer": "usrHnkwn8VSfKc" },
        "topic": "p2pv97xq86jw3ceeTCfxVJ8pw",
        "touched": "2026-09-13T06:27:32.089Z",
        "seq": 18
      }
    ]
  }
}

sub 数组里只有 p2pv97xq86jw3ceeTCfxVJ8pw。服务端却把 {data} 投到了 usrHnkwn8VSfKc —— 这是一个本端从未订阅的话题。

3.4 业务事件与 HTTP 接口都指向 p2p...

{"info":{"topic":"usrv97xq86jw3c","from":"usrHnkwn8VSfKc","what":"im","event":"friend.request.accepted",
         "payload":{"topic":"p2pv97xq86jw3ceeTCfxVJ8pw","user_id":"usrHnkwn8VSfKc","request_id":"frq_OYo72xKkkgU"}}}
{"user_id":"usrHnkwn8VSfKc","display_name":"charles1","relationship":"friend",
 "topic":"p2pv97xq86jw3ceeTCfxVJ8pw","avatar_file_id":"ZrI4Fut4heE.jpg"}

friend.request.accepted 的 payload.topic、GET /v1/im/friends 的 topic、POST /v1/im/conversations/p2p 的响应都明确是 p2pv97xq86jw3ceeTCfxVJ8pw。 说明服务端知道该会话的真实 topic,但系统提示仍写到了 usr<uid>。


4. 与接口文档契约的冲突

文档位置 约定 实际
《IM-前后端对接文档》0.5.4.3.1(约 330 行) "会话内系统消息必须进入对应 P2P 或群 Topic 的消息流,占用 seq" friend_added 同时进入 usr<uid>(非对应会话)
同章 system_notice 下行示例(约 337 行) {"data":{"topic":"p2p...", ... "x-im-template":"friend_added" ...}} 实际 "topic":"usrHnkwn8VSfKc"
同文档 3.5(约 1108 行) 好友申请通过后服务端"在同一事务内建立双方关系和 P2P 订阅" 建立了 p2p... 订阅,但旧 usr<uid> topic/订阅仍在,且系统提示写到旧 topic
同文档 GET /v1/im/friends(约 1564 行) 好友的会话 topic 为 p2p... 一致 ✅(正因如此客户端更无法理解 usr<uid> 上的消息)

5. 对客户端的影响(为什么客户端只能兜、不能治)

  1. 会话列表出现两条同一好友的会话,去重只能靠启发式规则(按对端 uid 分组打分)择一保留;
  2. 保留错的那条就丢消息/未读:旧命名话题 acs 为空(isJoiner == false)但本地已落库消息,客户端历史上"标准命名优先"的规则会保留它、隐藏真会话 → 用户看到会话不更新;
  3. 误显示禁止图标:acs 为空时客户端无法区分"未同步"与"已不是成员",会在会话行右下角画 nosign(🚫);
  4. 历史数据永久分裂:已有一批用户的本地历史落在 usr<uid>,服务端新会话在 p2p...,客户端无法自动合并;
  5. 客户端对未订阅话题收到的 {data} 只能被动落库(已回执 recv),无法判断该不该收。

6. 需要后端确认 / 处理

  1. friend_added 等会话内系统提示为什么写到 usr<uid>? 请改为只写会话 topic(当前为 p2p...),与 payload.topic / GET /friends 的 topic 保持一致。
  2. 同一对好友为什么存在两套 Topic + 两份订阅? 重装/删好友/重新加好友后,旧的 usr<uid> topic 与订阅应当被撤销或合并; 当前 usrHnkwn8VSfKc(本端 uid 维度)与 p2pv97xq86jw3ceeTCfxVJ8pw 同时存在,且系统提示双写。
  3. {data} 投递到未在 me 的 meta sub 里的话题是否预期? 若不是,请修投递;若是(设计如此),请在文档里明确 usr<uid> 仍是合法会话名,并同步在 meta sub 里下发它的 acs/seq/pub,否则客户端无法正确渲染。
  4. 是否可以把单聊统一收敛到 p2p...,并提供旧 usr<uid> 历史消息的迁移/合并方案(客户端愿意配合按话题重定向)。
  5. (附带,需确认口径) friend.request.accepted 的 payload.user_id 与文档不一致: 文档写 payload:{"user_id":"usr_actor"}(通过人),实测该帧在通过人本人设备上 from 和 payload.user_id 都是申请人 usrHnkwn8VSfKc。客户端目前已按"payload.user_id = 对方"消费(用于刷新会话 context、反查会话),若后端要改回文档口径请提前同步。

7. 复现步骤

前置

  • 账号 A:usrHnkwn8VSfKc(charles1),账号 B:usrv97xq86jw3c(echarles5),两台设备/两个模拟器分别登录;
  • 两端都开启 Debug 日志(会打印 入站[消息数据(data)] 与 SqlStore - msgReceived)。

步骤

  1. 先制造"旧命名话题"残留:B 删除 A 好友(B 端:聊天 → A 的会话 → 右上角设置 → 「删除好友」,确认删除)。
    • 预期:A 端收到 {"info":{"...","event":"friend.deleted","payload":{"user_id":"usrv97xq86jw3c"}}}。
    • 此时 B 端本地已存在旧命名话题 usr<A的uid>(本次取证中为 usrHnkwn8VSfKc)。
  2. A 向 B 发起好友申请(A 端:添加好友 → 搜索 echarles5 → 发送申请)。
  3. B 通过好友申请(B 端:通讯录 → 新的朋友 → 通过)。
  4. 观察 B 端日志,应能复现:
    入站[消息数据(data)] {"data":{"topic":"usrHnkwn8VSfKc","seq":17,"head":{"x-im-template":"friend_added","x-im-type":"system_notice"},...}}
    SqlStore - msgReceived:来自话题 payload usrHnkwn8VSfKc,topicId=9,userId=3,seq=17
    入站[在线状态(pres)] {"pres":{"topic":"me","src":"usrHnkwn8VSfKc","what":"msg","seq":17}}
    
    随后拉真会话历史时,同 ts/seq 的提示又出现在 p2pv97xq86jw3ceeTCfxVJ8pw; 并且 me 的 {meta sub} 里只有 p2pv97xq86jw3ceeTCfxVJ8pw。
  5. 观察 B 端界面:会话列表里 A 的那一行副标题是"你们已成为好友,现在可以开始聊天了。",右下角带 🚫 图标。
  6. A 再发一条消息(如 a)→ B 端该会话行副标题与未读数都不更新(消息实际落在 p2pv97xq86jw3ceeTCfxVJ8pw,被去重隐藏)。
  7. 服务端核对:topics 表应能查到同一对好友的两行 (usrHnkwn8VSfKc 与 p2pv97xq86jw3ceeTCfxVJ8pw),以及双方的订阅记录;messages 表里 friend_added 在两个话题下各有一条(同 ts、同 seq)。

最小验证(不依赖"先删好友"这段历史)

让 A 申请、B 通过,直接核对服务端把 friend_added 写到了哪个 topic:若仍是 usr<A的uid>,即复现本问题(第 1 步只是让残留话题必然存在,便于稳定复现)。


8. 验收标准

  1. 好友申请通过后,friend_added 只下发到会话 topic(当前为 p2p...),且该 topic 出现在 me 的 meta sub 中;usr<uid> 上不再出现任何消息;
  2. me 的 meta sub 中同一对好友只有一个 P2P topic、一份订阅;历史遗留的 usr<uid> topic/订阅被撤销或合并;
  3. 对不存在的会话不再有"投递到未订阅话题"的 {data}(或按文档补齐该话题的 meta sub);
  4. 客户端表现(不依赖本轮客户端兜底):B 端会话列表只出现一行 A 的会话,副标题为最新消息、未读角标正确、无 🚫 图标;A 端同理。

9. 客户端已做的兜底(不依赖后端修复,但不能替代后端修)

  1. Utils.duplicateP2PTopicNames 改为按对端 uid 分组 + 打分择一保留 (本地有消息 8 > 仍是成员 isJoiner 4 > 标准命名 2 > 有 priv.peer 1), 让"有消息且在关系内"的 p2p... 真会话优先,同时保留"有历史优先"以免去重丢消息;
  2. ChatListViewCell 的禁止图标改为仅在确认服务端下发过 acs.mode 且确非成员时显示 (acs 未同步不再误画 🚫);
  3. 会话列表过滤对"本地已有消息但 acs 未下发"的单聊放宽(isLocalKnownP2PConversation), 保证"有消息就一定有会话行"。

以上兜底只解决"显示哪一条、图标对不对"。系统提示被写进隐藏话题、历史分裂在两个 topic 这两点必须后端收敛后才能根治。