feat: add workbench kafka migration support

This commit is contained in:
richjohnrambo
2026-09-13 22:17:02 +08:00
parent c46cb4b382
commit 67e8c2c6f6
211 changed files with 7391 additions and 478 deletions
@@ -0,0 +1,239 @@
# 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 系统提示被实时投递到旧命名话题(本端未订阅)
```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<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 端日志**,应能复现:
```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<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** 这两点必须后端收敛后才能根治。