13 KiB
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... 话题,形成双写。
结果:客户端本地同一好友出现两条单聊会话,导致用户可见的两个问题:
- 会话行的
usr<uid>那条没有服务端下发的acs→isJoiner == false→ 客户端在会话行右下角显示出nosign(🚫)禁止图标; - 客户端会话列表去重逻辑保留
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. 对客户端的影响(为什么客户端只能兜、不能治)
- 会话列表出现两条同一好友的会话,去重只能靠启发式规则(按对端 uid 分组打分)择一保留;
- 保留错的那条就丢消息/未读:旧命名话题
acs为空(isJoiner == false)但本地已落库消息,客户端历史上"标准命名优先"的规则会保留它、隐藏真会话 → 用户看到会话不更新; - 误显示禁止图标:
acs为空时客户端无法区分"未同步"与"已不是成员",会在会话行右下角画nosign(🚫); - 历史数据永久分裂:已有一批用户的本地历史落在
usr<uid>,服务端新会话在p2p...,客户端无法自动合并; - 客户端对未订阅话题收到的
{data}只能被动落库(已回执recv),无法判断该不该收。
6. 需要后端确认 / 处理
friend_added等会话内系统提示为什么写到usr<uid>? 请改为只写会话 topic(当前为p2p...),与payload.topic/GET /friends的topic保持一致。- 同一对好友为什么存在两套 Topic + 两份订阅?
重装/删好友/重新加好友后,旧的
usr<uid>topic 与订阅应当被撤销或合并; 当前usrHnkwn8VSfKc(本端 uid 维度)与p2pv97xq86jw3ceeTCfxVJ8pw同时存在,且系统提示双写。 {data}投递到未在me的meta sub里的话题是否预期? 若不是,请修投递;若是(设计如此),请在文档里明确usr<uid>仍是合法会话名,并同步在meta sub里下发它的acs/seq/pub,否则客户端无法正确渲染。- 是否可以把单聊统一收敛到
p2p...,并提供旧usr<uid>历史消息的迁移/合并方案(客户端愿意配合按话题重定向)。 - (附带,需确认口径)
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)。
步骤
- 先制造"旧命名话题"残留:B 删除 A 好友(B 端:聊天 → A 的会话 → 右上角设置 → 「删除好友」,确认删除)。
- 预期:A 端收到
{"info":{"...","event":"friend.deleted","payload":{"user_id":"usrv97xq86jw3c"}}}。 - 此时 B 端本地已存在旧命名话题
usr<A的uid>(本次取证中为usrHnkwn8VSfKc)。
- 预期:A 端收到
- A 向 B 发起好友申请(A 端:添加好友 → 搜索
echarles5→ 发送申请)。 - B 通过好友申请(B 端:通讯录 → 新的朋友 → 通过)。
- 观察 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。 - 观察 B 端界面:会话列表里 A 的那一行副标题是"你们已成为好友,现在可以开始聊天了。",右下角带 🚫 图标。
- A 再发一条消息(如
a)→ B 端该会话行副标题与未读数都不更新(消息实际落在p2pv97xq86jw3ceeTCfxVJ8pw,被去重隐藏)。 - 服务端核对:
topics表应能查到同一对好友的两行 (usrHnkwn8VSfKc与p2pv97xq86jw3ceeTCfxVJ8pw),以及双方的订阅记录;messages表里friend_added在两个话题下各有一条(同ts、同seq)。
最小验证(不依赖"先删好友"这段历史)
让 A 申请、B 通过,直接核对服务端把 friend_added 写到了哪个 topic:若仍是 usr<A的uid>,即复现本问题(第 1 步只是让残留话题必然存在,便于稳定复现)。
8. 验收标准
- 好友申请通过后,
friend_added只下发到会话 topic(当前为p2p...),且该 topic 出现在me的meta sub中;usr<uid>上不再出现任何消息; me的meta sub中同一对好友只有一个 P2P topic、一份订阅;历史遗留的usr<uid>topic/订阅被撤销或合并;- 对不存在的会话不再有"投递到未订阅话题"的
{data}(或按文档补齐该话题的meta sub); - 客户端表现(不依赖本轮客户端兜底):B 端会话列表只出现一行 A 的会话,副标题为最新消息、未读角标正确、无 🚫 图标;A 端同理。
9. 客户端已做的兜底(不依赖后端修复,但不能替代后端修)
Utils.duplicateP2PTopicNames改为按对端 uid 分组 + 打分择一保留 (本地有消息 8 > 仍是成员isJoiner4 > 标准命名 2 > 有priv.peer1), 让"有消息且在关系内"的p2p...真会话优先,同时保留"有历史优先"以免去重丢消息;ChatListViewCell的禁止图标改为仅在确认服务端下发过acs.mode且确非成员时显示 (acs未同步不再误画 🚫);- 会话列表过滤对"本地已有消息但
acs未下发"的单聊放宽(isLocalKnownP2PConversation), 保证"有消息就一定有会话行"。
以上兜底只解决"显示哪一条、图标对不对"。系统提示被写进隐藏话题、历史分裂在两个 topic 这两点必须后端收敛后才能根治。