# IM 后端问题报告:好友申请通过后 `friend_added` 系统提示被投递到旧命名话题 `usr`,同一会话存在两套 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` 那条没有服务端下发的 `acs` → `isJoiner == false` → 客户端在会话行右下角显示出 `nosign`(🚫)禁止图标; 2. 客户端会话列表去重逻辑保留 `usr`、隐藏 `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 系统提示被实时投递到旧命名话题(本端未订阅) ```json { "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": "你们已成为好友,现在可以开始聊天了。" } } } ``` 客户端侧随即落库并回执(说明客户端把它当作**合法会话消息**处理): ```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`** 的同一条提示: ```json { "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 完全一致" } } } ``` ```text 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`) ```json { "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...` ```json {"info":{"topic":"usrv97xq86jw3c","from":"usrHnkwn8VSfKc","what":"im","event":"friend.request.accepted", "payload":{"topic":"p2pv97xq86jw3ceeTCfxVJ8pw","user_id":"usrHnkwn8VSfKc","request_id":"frq_OYo72xKkkgU"}}} ``` ```json {"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`。 --- ## 4. 与接口文档契约的冲突 | 文档位置 | 约定 | 实际 | | --- | --- | --- | | 《IM-前后端对接文档》0.5.4.3.1(约 330 行) | "会话内系统消息**必须进入对应 P2P 或群 Topic 的消息流**,占用 `seq`" | `friend_added` 同时进入 `usr`(非对应会话) | | 同章 `system_notice` 下行示例(约 337 行) | `{"data":{"topic":"p2p...", ... "x-im-template":"friend_added" ...}}` | 实际 `"topic":"usrHnkwn8VSfKc"` | | 同文档 3.5(约 1108 行) | 好友申请通过后服务端"在同一事务内建立双方关系和 **P2P 订阅**" | 建立了 `p2p...` 订阅,但旧 `usr` topic/订阅仍在,且系统提示写到旧 topic | | 同文档 `GET /v1/im/friends`(约 1564 行) | 好友的会话 `topic` 为 `p2p...` | 一致 ✅(正因如此客户端更无法理解 `usr` 上的消息) | --- ## 5. 对客户端的影响(为什么客户端只能兜、不能治) 1. **会话列表出现两条同一好友的会话**,去重只能靠启发式规则(按对端 uid 分组打分)择一保留; 2. **保留错的那条就丢消息/未读**:旧命名话题 `acs` 为空(`isJoiner == false`)但本地已落库消息,客户端历史上"标准命名优先"的规则会保留它、隐藏真会话 → 用户看到会话不更新; 3. **误显示禁止图标**:`acs` 为空时客户端无法区分"未同步"与"已不是成员",会在会话行右下角画 `nosign`(🚫); 4. **历史数据永久分裂**:已有一批用户的本地历史落在 `usr`,服务端新会话在 `p2p...`,客户端无法自动合并; 5. 客户端对**未订阅**话题收到的 `{data}` 只能被动落库(已回执 `recv`),无法判断该不该收。 --- ## 6. 需要后端确认 / 处理 1. **`friend_added` 等会话内系统提示为什么写到 `usr`?** 请改为只写**会话 topic**(当前为 `p2p...`),与 `payload.topic` / `GET /friends` 的 `topic` 保持一致。 2. **同一对好友为什么存在两套 Topic + 两份订阅?** 重装/删好友/重新加好友后,旧的 `usr` topic 与订阅应当被撤销或合并; 当前 `usrHnkwn8VSfKc`(本端 uid 维度)与 `p2pv97xq86jw3ceeTCfxVJ8pw` 同时存在,且系统提示双写。 3. **`{data}` 投递到未在 `me` 的 `meta sub` 里的话题是否预期?** 若不是,请修投递;若是(设计如此),请在文档里明确 `usr` 仍是合法会话名,并同步在 `meta sub` 里下发它的 `acs`/`seq`/`pub`,否则客户端无法正确渲染。 4. **是否可以把单聊统一收敛到 `p2p...`**,并提供旧 `usr` 历史消息的迁移/合并方案(客户端愿意配合按话题重定向)。 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`(本次取证中为 `usrHnkwn8VSfKc`)。 2. A 向 B 发起好友申请(A 端:添加好友 → 搜索 `echarles5` → 发送申请)。 3. B 通过好友申请(B 端:通讯录 → 新的朋友 → 通过)。 4. **观察 B 端日志**,应能复现: ```text 入站[消息数据(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`,即复现本问题(第 1 步只是让残留话题必然存在,便于稳定复现)。 --- ## 8. 验收标准 1. 好友申请通过后,`friend_added` **只**下发到会话 topic(当前为 `p2p...`),且该 topic 出现在 `me` 的 `meta sub` 中;`usr` 上不再出现任何消息; 2. `me` 的 `meta sub` 中同一对好友**只有一个** P2P topic、一份订阅;历史遗留的 `usr` 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** 这两点必须后端收敛后才能根治。