# WS 事件接入手册（APP 端对接版）

> 面向 Flutter 端开发，完整描述：连接生命周期 → 帧结构 → 35 种事件字段 + **完整 JSON 示例** → 客户端处理建议。
> 当本文档与代码冲突，以代码为准（路径见文末"附录 D"）。

---

## 目录

- [1. 整体协议流程](#1-整体协议流程)
- [2. WebSocket 帧结构](#2-websocket-帧结构)
- [3. 公共字段速查](#3-公共字段速查)
- [4. 事件总表（53 个）](#4-事件总表53-个)
- [5. 事件详解（含完整示例）](#5-事件详解含完整示例)
- [6. 客户端通用处理规范](#6-客户端通用处理规范)
- [7. 已知坑点（重点关注）](#7-已知坑点重点关注)
- [附录 A. msg.type 与 sevent 对照](#附录-a-msgtype-与-sevent-对照)
- [附录 B. extendData 已知 schema 汇总](#附录-b-extenddata-已知-schema-汇总)
- [附录 C. 错误状态与重连](#附录-c-错误状态与重连)
- [附录 D. 后端代码索引](#附录-d-后端代码索引)

---

## 1. 整体协议流程

### 1.1 连接握手

```
┌─APP─────────────┐                        ┌─业务网关 (imsvr)─┐         ┌─ws 网关────┐
│ 1. 登录 ─────────┼──HTTPS /v1/login─────▶│                  │         │            │
│                  │◀──token, deviceId─────│                  │         │            │
│                  │                        │                  │         │            │
│ 2. 建 WS 前调:   │                        │                  │         │            │
│   /v1/ws/precon  ├──HTTPS────────────────▶│ ─── gRPC ───────▶│            │
│                  │◀──{ wsAddr }──────────│ ◀── wsid ─────────│            │
│                  │                        │                  │         │            │
│ 3. WS 升级       ├────WS /v1/ws?wsid=xxx────────────────────▶│            │
│                  │◀────连接建立 (并自动补推全部离线消息)─────────────│
│                  │                        │                  │         │            │
│ 4. 持续心跳/收消息◀━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┃            │
└──────────────────┘                                                    └────────────┘
```

**连接关键点**：
- `wsAddr` 形如 `wss://gateway.example.com/v1/ws?wsid=<uid>.<deviceId-50位>`
- **wsid 一次有效**：断线重连必须**重新调** `/v1/ws/preconnect` 拿新地址，不能复用
- ws 服务端心跳：`PingMessage`(控制帧) 每 27 秒发一次，30 秒收不到 Pong 主动断开
- 连接成功后服务端会**自动补推**该 wsid 所有未 ack 的离线消息

### 1.2 已读 / 已收上报

| 时机 | 接口 | 作用 |
|---|---|---|
| 收到 ws 推送，写库完成 | `POST /v1/sdk/msg/report` | 上报 `chatCursor` / `systemCursor`，服务端清掉对应 OfflineMsg |
| 用户进入会话查看消息 | `POST /v1/sdk/msg/read` | 上报已读 mid 列表，触发 `msg_read` 推给消息发送方 |

```json
// /v1/sdk/msg/report 请求
{ "chatCursor": 12345, "systemCursor": 12300 }

// /v1/sdk/msg/read 请求
{ "msgIds": ["s_1001_2002:100", "s_1001_2002:101"] }
```

> **必须实现**。不上报 cursor 会导致离线消息不断累积，重连时服务端整包重发。

---

## 2. WebSocket 帧结构

### 2.1 外层包络

每帧是一个 JSON 对象，**两个数组**任一可为空：

```json
{
  "msgDao":       [ /* 聊天消息列表 */ ],
  "systemMsgDao": [ /* 系统/事件通知列表 */ ]
}
```

### 2.2 解析模板（伪代码）

```dart
final root = jsonDecode(frame) as Map<String, dynamic>;

// 处理聊天消息
for (final m in (root['msgDao'] ?? []) as List) {
  final msg = MsgDao.fromJson(m);
  await _onMsg(msg);              // 写库 + 派发到会话
  _trackChatCursor(msg.cursor);
}

// 处理系统事件
for (final s in (root['systemMsgDao'] ?? []) as List) {
  final sys = SysMsgDao.fromJson(s);
  await _onSysEvent(sys);          // 按 sevent 路由
  _trackSystemCursor(sys.cursor);
}

// 处理完整批后上报 cursor（去抖：500ms / 累计 ≥ 50 条）
_scheduleCursorReport();
```

### 2.3 单帧批量约束

- 单帧大小一般 < 256 KB；离线积压时可能更大
- 同一 `mid` 可能在同一帧出现多次 → **必须批内 Set<mid> 去重**
- 推送顺序按 `cursor` 升序，但**不能假设跨帧严格单调**（重连补推会回退）

### 2.4 字段省略规则

后端用 Go `omitempty` 序列化，**零值字段不会出现在 JSON 中**：

- `tid=""` → 没有 `tid` key
- `sender=0` → 没有 `sender` key
- `targetUids=[]` → 没有 `targetUids` key
- `extendData=""` → ⚠️ **仍然出现**（无 omitempty）

**始终出现的字段**：

- MsgDao: `mid` / `content` / `visible` / `mask` / `auditRst` / `restrict` / `rtcId` / `duration` / `cursor` / `extendData`
- SysMsgDao: `mid` / `content` / `cursor` / `extendData`

---

## 3. 公共字段速查

### 3.1 MsgDao（聊天消息）

```jsonc
{
  "tid": "s_1001_2002",              // TopicId（见 3.3）
  "mid": "s_1001_2002:100",          // = tid:tmid，全局唯一
  "tmid": 100,                       // 会话内自增
  "cid": "client-uuid-xxx",          // 客户端 cid（去重）
  "sender": 1001,                    // 发送方 uid（1000=系统/机器人）
  "stime": 1714680000000,            // 发送时间（毫秒）
  "sevent": "msg_send_to_user",      // 事件名（见 §4 总表）
  "type": "txt",                     // txt / tip / img / file / voice / video / talk / coupon / voice_rtc / video_rtc
  "content": "你好",                  // 消息正文（可能是 base64 加密）
  "meta": { "key": "value" },        // 客户端自定义 KV
  "encrypt": 0,                      // 1=加密，其余=明文
  "visible": 0,                      // 0=都可见 / 1=仅自己+群管理员 / -1=自己+群管理员不可见 / -2=都不可见
  "mask": 0,                         // 0=正常 / 1=删除 / 2=撤回
  "etime": 2724710400000,            // 失效时间（含义：批量拉取时跳过）
  "targetCnt": 1,
  "targetUids": [2002],              // 目标用户列表
  "atCnt": 0,
  "atUids": [],                      // 被 @ 列表（仅群聊）
  "atReadCnt": 0,
  "atReadUids": [],
  "readCnt": 0,
  "readUids": [],
  "auditWord": "",                   // 命中的敏感词
  "auditRst": "",                    // 审核结果："" / pass / refused
  "auditMid": "",
  "auditWordTip": "",
  "restrict": false,                 // 是否被敏感词限制
  "rtcId": 0,
  "duration": 0,                     // RTC 通话时长（秒）
  "cursor": 12345,                   // 全局推送游标 ★必须 ack
  "extendData": "{...}"              // JSON 字符串，见 §3.4
}
```

### 3.2 SysMsgDao（系统/事件通知）

比 MsgDao 字段少：**没有** `meta / encrypt / mask / readCnt / atCnt / readUids / atUids / auditXxx / restrict / rtcId / duration`。

```jsonc
{
  "tid": "g_room_999",               // 多数事件不带（仅会话/群相关填）
  "mid": "...",
  "tmid": 0,
  "cid": "",
  "sender": 1001,
  "stime": 1714680000000,
  "sevent": "user_kick",             // ★ 路由 key
  "type": "user_kick",               // 通常等于 sevent
  "content": "...",                  // 业务负载（mid / 提示文本 / JSON）
  "visible": 0,
  "etime": 2724710400000,
  "targetCnt": 1,
  "targetUids": [1001],
  "targetGroupIds": [],
  "cursor": 12345,                   // ★ 必须 ack
  "extendData": "..."                // 事件特定 JSON
}
```

### 3.3 TopicId 命名约定

| 前缀 | 含义 | 例 |
|---|---|---|
| `s_<uidA>_<uidB>` | 单聊（uidA < uidB） | `s_1001_2002` |
| `g_<groupId>` | 群聊 | `g_room_999` |
| `s_sys_<uid>` | 系统会话（机器人/小助手） | `s_sys_msg` |
| `""` | 纯系统通知（如 user_kick），无会话归属 | — |

**单聊 topic 解析**：

```dart
String singleTopicOf(int meUid, int peerUid) {
  final a = meUid < peerUid ? meUid : peerUid;
  final b = meUid < peerUid ? peerUid : meUid;
  return 's_${a}_$b';
}
```

### 3.4 extendData

- **类型为 string** — 是 JSON 序列化后的字符串，需二次 `jsonDecode`
- **可能为 ""** — 大量事件该字段是空串，解析前判空
- 不同事件 schema 不同，详见 [附录 B](#附录-b-extenddata-已知-schema-汇总)

---

## 4. 事件总表（53 个）

### 4.1 业务域分组

| # | sevent | 域 | 载体 | 触发方 / 接收方 |
|---|---|---|---|---|
| 1 | `friend_apply` | 好友 | sysMsg | 申请人 → 申请人 + 被申请人 |
| 2 | `friend_apply_deal` | 好友 | sysMsg(+msg.tip) | 处理人 → 申请人(+处理人本人) |
| 3 | `friend_delete` | 好友 | sysMsg | 操作人 → 操作人本人（多端同步） |
| 4 | `friend_label_add` 🆕 | 好友标签 | sysMsg | 自己 → 自己（多端同步） |
| 5 | `friend_label_delete` 🆕 | 好友标签 | sysMsg | 自己 → 自己（多端同步） |
| 6 | `friend_label_modify` 🆕 | 好友标签 | sysMsg | 自己 → 自己（多端同步） |
| 7 | `friend_label_list` 🆕‡ | 好友标签 | — | 不下发推送（仅 HTTP 占位） |
| 8 | `userinfo_set` | 用户 | sysMsg | 自己 → 自己 + 所有好友 |
| 9 | `user_remark` | 用户 | sysMsg | 自己 → 自己（多端同步） |
| 10 | `user_kick` | 用户 | sysMsg | 服务端 → 被踢者 |
| 11 | `user_passwd_changed`* | 用户 | sysMsg(以 `user_kick` 名义) | 自己改密 → 被踢的其他端 |
| 12 | `user_deleted` | 用户 | sysMsg | 服务端/管理员 → 被删者 |
| 13 | `group_apply` | 群 | sysMsg | 邀请人 → 群主 + 申请人 |
| 14 | `group_apply_accept` | 群 | msg.tip | 处理人 → 群全员（含新成员） |
| 15 | `group_apply_reject` | 群 | sysMsg | 处理人 → 申请人 |
| 16 | `group_join` | 群 | msg.tip | 操作人 → 群全员 |
| 17 | `group_quit` | 群 | msg.tip | 退群者 → 群全员 |
| 18 | `group_kick` | 群 | msg.tip | 操作人 → 被踢者 + 群其他人 |
| 19 | `group_delete` | 群 | msg.tip + sysMsg | 群主 → 群全员 |
| 20 | `group_info_set` | 群 | sysMsg 或 msg.tip | 操作人 → 操作人 或 群全员 |
| 21 | `group_change` | 群 | msg.tip | 操作人 → 单个目标用户 |
| 22 | `group_set_memberalias` | 群 | sysMsg | 自己 → 自己（多端同步） |
| 23 | `group_admin_set_on` | 群 | msg.tip + sysMsg | 群主 → 群全员 + 被任命者 |
| 24 | `group_admin_set_off` | 群 | msg.tip + sysMsg | 群主 → 群全员 + 被撤销者 |
| 25 | `group_black` | 群 | sysMsg | 操作人 → 被禁言/拉黑者 |
| 26 | `topic_create` | 会话 | msg.tip | 创建人 → 群全员（**单聊场景静默**） |
| 27 | `topic_modify` | 会话 | sysMsg | 自己 → 自己（多端同步） |
| 28 | `topic_remark` | 会话 | sysMsg | 自己 → 自己（多端同步） |
| 29 | `msg_send_to_user` | 消息 | msg | 发送方 → 双方所有 wsid |
| 30 | `msg_send_to_group` | 消息 | msg | 发送方 → 群全员 |
| 31 | `msg_mask` | 消息 | sysMsg | 撤回者 → 原消息所有目标 |
| 32 | `msg_delete` | 消息 | sysMsg | 删除者 → 原消息所有目标 |
| 33 | `msg_read`§ | 消息 | sysMsg | 已读者 → 消息发送方 |
| 34 | `user_connected`† | 内部 | — | 不下发，仅触发离线补推 |
| 35 | `report_received`† | 内部 | — | 不下发，客户端 ack 用 |
| 36 | `msg_local_delete` | 消息 | sysMsg | 自己 → 自己（多端同步本地隐藏标记） |
| 37 | `friend_apply_list` | 好友 | sysMsg | 自己 → 自己（多端同步"已查看新申请"，仅 `skip=0` 时触发） |
| 38 | `group_apply_notify` | 群 | sysMsg | 申请人 → 群主 + 全部管理员（新版入群审核入口） |
| 39 | `admin_notify_user_phone_email` | 后台 | sysMsg | 后台 → 被改用户（多端） |
| 40 | `admin_notify_user_nick` | 后台 | sysMsg | 后台 → 被改用户（多端） |
| 41 | `admin_notify_user_password` | 后台 | sysMsg | 后台 → 被改用户（多端，重置密码后） |
| 42 | `admin_notify_msg_delete` | 后台 | sysMsg | 后台 → 消息发送方 + 接收方所有人 |
| 43 | `admin_notify_msg_mute` | 后台 | sysMsg | 后台 → 被禁言/解禁的 userIds（私聊或群聊） |
| 44 | `admin_disband_group_message` | 后台 | msg.tip | 后台 → 群全员（解散群聊场景一） |
| 45 | `admin_disband_group_notify` | 后台 | sysMsg | 后台 → 群全员（解散群聊场景二） |
| 46 | `admin_notify_real_translate` | 后台 | sysMsg | 后台 → 商户全部用户（开/关实时翻译） |
| 47 | `assistant_notify_event` | 后台 | msg(txt) | 后台 → 商户全部用户 或 指定 userIds（小助手广播） |
| 48 | `admin_notify_msg_assistant` | 后台 | sysMsg | 后台 → 用户（群发助手开关） |
| 49 | `admin_notify_business_switch` | 后台 | sysMsg | 后台 → 商户全部用户（功能开关下发） |
| 50 | `admin_notify_config_assistant` | 后台 | sysMsg | 后台 → 商户全部用户（小助手配置变更） |
| 51 | `admin_notify_device_info` | 后台 | sysMsg | 后台 → 用户（设备信息变更，多端同步） |
| 52 | `system_notify_event` | 后台 | msg(txt) | 后台 → 全平台 或 指定商户全部用户（平台广播） |

> ❶ "后台" 域：经 Kafka topic `kk_admin_notify` 由后端管理后台触发，详见 [§ 5.7 后台事件](#57-后台事件adminnotify--admindisband--assistantnotify--systemnotify)。

\* `user_passwd_changed` 实际下发的 sevent 写死为 `user_kick`，前端无法区分原因
† 这两个事件**不会出现在 ws 帧的 sevent 字段中**
‡ `friend_label_list` 在后端是 HTTP 入口的 EventType，handler 内未走 channel.Notify，**不会**产生 ws 推送
§ 后端入口 EventType 为 `report_read`，handler 内部以 `WithEvent("msg_read")` 下发 — **客户端只会看到 `sevent=msg_read`**

### 4.2 客户端 switch 模板

```dart
Future<void> _onSysEvent(SysMsgDao s) async {
  // 防御层：后台事件统一拦截（appkey=='000000' 或 sevent 前缀）
  if (s.appkey == '000000' &&
      (s.sevent.startsWith('admin_') || s.sevent == 'system_notify_event')) {
    return _handleAdminBackofficeEvent(s);
  }

  switch (s.sevent) {
    case 'friend_apply':            return _handleFriendApply(s);
    case 'friend_apply_deal':       return _handleFriendApplyDeal(s);
    case 'friend_delete':           return _handleFriendDelete(s);
    case 'friend_label_add':                                      // 🆕
    case 'friend_label_delete':                                   // 🆕
    case 'friend_label_modify':     return _handleFriendLabel(s); // 🆕
    case 'friend_apply_list':       return _handleFriendApplyListSynced(s); // 🆕 多端同步红点清零
    case 'userinfo_set':            return _handleUserInfoSet(s);
    case 'user_remark':             return _handleUserRemark(s);
    case 'user_kick':               return _handleUserKick(s);          // ★ 必须强制下线
    case 'user_deleted':            return _handleUserDeleted(s);
    case 'group_apply':             return _handleGroupApply(s);
    case 'group_apply_notify':      return _handleGroupApplyNotify(s);  // 🆕 群主/管理员收到申请
    case 'group_apply_reject':      return _handleGroupApplyReject(s);
    case 'group_delete':            return _handleGroupDelete(s);
    case 'group_info_set':          return _handleGroupInfoSet(s);
    case 'group_set_memberalias':   return _handleGroupSetAlias(s);
    case 'group_admin_set_on':
    case 'group_admin_set_off':     return _handleGroupAdminChange(s);
    case 'group_black':             return _handleGroupBlack(s);
    case 'topic_modify':
    case 'topic_remark':            return _handleTopicChange(s);
    case 'msg_mask':                return _handleMsgMask(s);
    case 'msg_delete':              return _handleMsgDelete(s);
    case 'msg_local_delete':        return _handleMsgLocalDelete(s);    // 🆕 多端同步本地隐藏
    case 'msg_read':                return _handleMsgRead(s);
    // 后台事件兜底（防御层未命中时）
    case 'admin_notify_user_phone_email':
    case 'admin_notify_user_nick':
    case 'admin_notify_user_password':
    case 'admin_notify_msg_delete':
    case 'admin_notify_msg_mute':
    case 'admin_disband_group_notify':
    case 'admin_notify_real_translate':
    case 'admin_notify_msg_assistant':
    case 'admin_notify_business_switch':
    case 'admin_notify_config_assistant':
    case 'admin_notify_device_info': return _handleAdminBackofficeEvent(s);
    default:                        log.w('unknown sevent: ${s.sevent}');
  }
}

Future<void> _onMsg(MsgDao m) async {
  switch (m.sevent) {
    case 'msg_send_to_user':
    case 'msg_send_to_group':       return _handleNewMsg(m);
    case 'group_apply_accept':      return _handleGroupApplyAccept(m);  // ⚠️ msg.tip 不是 sysMsg
    case 'group_kick':              return _handleGroupKick(m);          // ⚠️ msg.tip
    case 'group_join':              return _handleGroupJoin(m);
    case 'group_quit':              return _handleGroupQuit(m);
    case 'group_change':            return _handleGroupChange(m);
    case 'topic_create':            return _handleTopicCreate(m);
    case 'group_admin_set_on':
    case 'group_admin_set_off':     return _handleGroupAdminTip(m);
    case 'group_info_set':          return _handleGroupInfoTip(m);
    case 'friend_apply_deal':       return _handleFriendApplyDealTip(m);
    case 'group_delete':            return _handleGroupDeleteTip(m);
    case 'admin_disband_group_message': return _handleGroupDeleteTip(m); // 🆕 与 group_delete tip 同处理
    case 'assistant_notify_event':                                       // 🆕 小助手广播 / 商户级
    case 'system_notify_event':     return _handleBroadcastMsg(m);       // 🆕 平台广播 / 跨商户
    default:                        return _handleChatMsg(m);            // 普通消息走 type
  }
}
```

> ⚠️ 同一 sevent 既可能在 sysMsg 也可能在 msg 中出现。**两个 switch 都要写**。

---

## 5. 事件详解（含完整示例）

> 全部示例假设：自己 uid=1001，对端 uid=2002，群 id=g_room_999，群成员 [1001,2002,3003,4004]，群主 1001。
> 时间 stime=1714680000000，etime=2724710400000，cursor 各事件递增。

> 每条按相同顺序写：**触发 → 承载 → 关键字段 → 📨 完整帧示例 → 客户端动作 → 注意事项**。

---

### 5.1 好友

#### ① `friend_apply` — 收到好友申请

- **触发**：B(2002) 向 A(1001) 发起好友申请
- **承载**：`systemMsgDao`
- **接收方**：A 与 B 都收（双向多端同步）

**关键字段**

| 字段 | 含义 |
|---|---|
| `sender` | 申请人 uid（B=2002） |
| `tid` | 单聊 topicId |
| `targetUids` | `[申请人, 被申请人]` |
| `extendData` | `""` |

**📨 完整帧示例**（A 端 / B 端结构相同）

```json
{
  "msgDao": [],
  "systemMsgDao": [
    {
      "tid": "s_1001_2002",
      "mid": "s_1001_2002:5",
      "tmid": 5,
      "sender": 2002,
      "stime": 1714680000000,
      "sevent": "friend_apply",
      "type": "friend_apply",
      "content": "",
      "etime": 2724710400000,
      "targetCnt": 2,
      "targetUids": [2002, 1001],
      "cursor": 10001,
      "extendData": ""
    }
  ]
}
```

**客户端动作**

- A 端：拉一次 `/v1/sdk/friend/apply/list` 刷新申请列表 + 红点 +1
- B 端：UI 显示"申请已发送"
- 弹通知 / push（仅 A 端）

**注意**：申请人单方面删除场景下服务端**直接发 `friend_apply_deal` + extendData="pass"**，不会先发 apply。前端不要假定一定有 apply 在前。

---

#### ② `friend_apply_deal` — 申请被处理

- **触发**：A 同意 / 拒绝 B 的申请
- **承载**：
  - **同意**：`systemMsgDao`(给 B) + `msgDao(tip)`(双方单聊)
  - **拒绝**：`systemMsgDao`(给 A 自己 + B)

**关键字段**

| 字段 | 含义 |
|---|---|
| `sender` | 处理人 uid（A=1001） |
| `content` | `"<昵称> 通过你的好友申请"` 或 `"<昵称> 拒绝你的好友申请"` |
| `extendData` | `"pass"` 或 `"refuse"` |

**📨 完整帧示例 — 同意（B 端收到）**

```json
{
  "msgDao": [
    {
      "tid": "s_1001_2002",
      "mid": "s_1001_2002:7",
      "tmid": 7,
      "sender": 1001,
      "stime": 1714680001000,
      "sevent": "friend_apply_deal",
      "type": "tip",
      "content": "你们加好友成功，现在可以开始聊天了",
      "visible": 0,
      "mask": 0,
      "etime": 2724710400000,
      "targetCnt": 2,
      "targetUids": [1001, 2002],
      "auditRst": "",
      "restrict": false,
      "rtcId": 0,
      "duration": 0,
      "cursor": 10003,
      "extendData": ""
    }
  ],
  "systemMsgDao": [
    {
      "tid": "s_1001_2002",
      "mid": "s_1001_2002:6",
      "tmid": 6,
      "sender": 1001,
      "stime": 1714680001000,
      "sevent": "friend_apply_deal",
      "type": "friend_apply_deal",
      "content": "Alice 通过了你的好友申请",
      "etime": 2724710400000,
      "targetCnt": 1,
      "targetUids": [2002],
      "cursor": 10002,
      "extendData": "pass"
    }
  ]
}
```

**📨 完整帧示例 — 拒绝（B 端收到）**

```json
{
  "msgDao": [],
  "systemMsgDao": [
    {
      "tid": "s_1001_2002",
      "mid": "s_1001_2002:8",
      "tmid": 8,
      "sender": 1001,
      "stime": 1714680002000,
      "sevent": "friend_apply_deal",
      "type": "friend_apply_deal",
      "content": "Alice 拒绝你的好友申请",
      "etime": 2724710400000,
      "targetCnt": 2,
      "targetUids": [1001, 2002],
      "cursor": 10004,
      "extendData": "refuse"
    }
  ]
}
```

**客户端动作**

- B 端 + extendData=="pass"：本地写入好友关系，刷会话列表，单聊 topic 出现"你们加好友成功，现在可以开始聊天了"
- B 端 + extendData=="refuse"：UI 提示申请被拒，刷申请列表
- A 端：刷新本地申请记录状态

---

#### ③ `friend_delete` — 删除好友

- **触发**：A(1001) 主动删 B(2002)
- **承载**：`systemMsgDao`
- **接收方**：**仅 A 自己**（多端同步）

**关键字段**

| 字段 | 含义 |
|---|---|
| `sender` | A 的 uid |
| `targetUids` | `[A.uid, B.uid]`（msgDao 内的 targetUids，但 SystemNotify 实际只发 A 自己） |
| `tid` | 空（不带） |

**📨 完整帧示例**（A 端收到）

```json
{
  "msgDao": [],
  "systemMsgDao": [
    {
      "mid": ":9",
      "tmid": 9,
      "sender": 1001,
      "stime": 1714680003000,
      "sevent": "friend_delete",
      "type": "friend_delete",
      "content": "",
      "etime": 2724710400000,
      "targetCnt": 2,
      "targetUids": [1001, 2002],
      "cursor": 10005,
      "extendData": ""
    }
  ]
}
```

**客户端动作**

- A 端：删除本地好友/标签关系，置该单聊 `order=0`、`hideMid=最新 mid`、解除免打扰

> ⚠️ **B 端不会收到任何 ws 通知**。B 端依赖：① 启动时 `/sdk/sync/friend` 校准；② 发消息时服务端返回"非好友"错误码再触发本地清理。

---

#### ③ₐ `friend_label_add` 🆕 — 新增好友标签

- **触发**：自己创建一个新标签并把若干好友放入其中（HTTP `/sdk/friend/label/add`）
- **承载**：`systemMsgDao`
- **接收方**：**仅自己**（多端同步）

**关键字段**

| 字段 | 含义 |
|---|---|
| `sender` | 自己 uid |
| `content` | `{"Uids": [...], "Label": "...", "NType": ...}` JSON 字符串 |
| `targetUids` | `[自己]` |
| `extendData` | `""` |

**📨 完整帧示例**

```json
{
  "msgDao": [],
  "systemMsgDao": [
    {
      "mid": ":15",
      "tmid": 15,
      "sender": 1001,
      "stime": 1714680003500,
      "sevent": "friend_label_add",
      "type": "friend_label_add",
      "content": "{\"Uids\":[2002,3003],\"Label\":\"同事\",\"NType\":0}",
      "etime": 2724710400000,
      "targetCnt": 1,
      "targetUids": [1001],
      "cursor": 10005,
      "extendData": ""
    }
  ]
}
```

**字段说明（content 内）**

| key | 含义 |
|---|---|
| `Uids` | 加入该标签的好友 uid 列表 |
| `Label` | 标签名 |
| `NType` | 标签业务类型（具体取值由 `/sdk/friend/label/add` 接口约定） |

**客户端动作**

- 本端：本地新增 / 合并标签关系 + 刷分组列表
- 其它端：同上（用于多端同步）

---

#### ③ᵦ `friend_label_delete` 🆕 — 从标签中移除好友 / 删除标签

- **触发**：HTTP `/sdk/friend/label/delete`
- **承载**：`systemMsgDao`
- **接收方**：**仅自己**（多端同步）

**关键字段**

| 字段 | 含义 |
|---|---|
| `content` | `{"Uids": [...], "Label": "...", "NType": ...}`（结构与 `friend_label_add` 一致） |
| `targetUids` | `[自己]` |

**📨 完整帧示例**

```json
{
  "msgDao": [],
  "systemMsgDao": [
    {
      "mid": ":16",
      "tmid": 16,
      "sender": 1001,
      "stime": 1714680003600,
      "sevent": "friend_label_delete",
      "type": "friend_label_delete",
      "content": "{\"Uids\":[3003],\"Label\":\"同事\",\"NType\":0}",
      "etime": 2724710400000,
      "targetCnt": 1,
      "targetUids": [1001],
      "cursor": 10005,
      "extendData": ""
    }
  ]
}
```

**客户端动作**：本地从标签中移除对应 uid；当标签下没人时按业务决定是否一并删除标签

> ⚠️ 后端不会下发"删除整个标签"的独立语义事件，**所有移除/删空都通过 `friend_label_delete` 传达**，客户端自行判断是否需要清空空标签。

---

#### ③ᵧ `friend_label_modify` 🆕 — 重命名标签

- **触发**：HTTP `/sdk/friend/label/modify`
- **承载**：`systemMsgDao`
- **接收方**：**仅自己**（多端同步）

**关键字段**

| 字段 | 含义 |
|---|---|
| `content` | `{"OldLabel": "...", "NewLabel": "..."}` JSON 字符串 |
| `targetUids` | `[自己]` |

**📨 完整帧示例**

```json
{
  "msgDao": [],
  "systemMsgDao": [
    {
      "mid": ":17",
      "tmid": 17,
      "sender": 1001,
      "stime": 1714680003700,
      "sevent": "friend_label_modify",
      "type": "friend_label_modify",
      "content": "{\"OldLabel\":\"同事\",\"NewLabel\":\"同事(在职)\"}",
      "etime": 2724710400000,
      "targetCnt": 1,
      "targetUids": [1001],
      "cursor": 10005,
      "extendData": ""
    }
  ]
}
```

**客户端动作**：把本地标签名从 OldLabel 切到 NewLabel，关联的好友关系**不变**

---

#### ③ᵨ `friend_label_list` 🆕‡ — 仅 HTTP 入口（不下发推送）

- **触发**：HTTP `/sdk/friend/label/list`
- **承载**：**无**（后端 handler 内未走 channel.Notify，直接返回 HTTP 响应）
- **接收方**：—

> ℹ️ 该 EventType 仅用于后端事件分发框架的入口名，**客户端在 ws 帧里看不到 `sevent=friend_label_list`**。前端如果做 sevent 路由，可以不写这个分支；如果非要写，加 `default` 兜底打日志即可。

---

#### ㊲ `friend_apply_list` — 已查看好友申请列表（多端同步）

- **触发**：A(1001) 调用 `/v1/sdk/friend/apply/list` 且 `skip == 0`（即首屏拉取，认为是"翻进入申请页"）
- **承载**：`systemMsgDao`
- **接收方**：**仅 A 自己**（多端同步"红点已清"）

**关键字段**

| 字段 | 含义 |
|---|---|
| `sender` | A 的 uid |
| `tid` | `s_<A.uid>_<A.uid>`（自己的占位 topic，例：`s_1001_1001`） |
| `type` | `"friend_apply"`（handler `Load()` 内 `params.event` 默认值是 `SEVENT_FRIEND_APPLY` → `msgType` 也写为 `friend_apply`） |
| `sevent` | `"friend_apply_list"`（`WithEvent(SEVENT_FRIEND_APPLY_LIST)` 显式覆盖） |
| `content` | `""` |
| `targetUids` | `[A.uid]` |
| `extendData` | `""` |

**📨 完整帧示例**（A 端多端收到）

```json
{
  "msgDao": [],
  "systemMsgDao": [
    {
      "tid": "s_1001_1001",
      "mid": "s_1001_1001:5",
      "tmid": 5,
      "sender": 1001,
      "stime": 1714680020000,
      "sevent": "friend_apply_list",
      "type": "friend_apply",
      "content": "",
      "etime": 2724710400000,
      "targetCnt": 1,
      "targetUids": [1001],
      "cursor": 10200,
      "extendData": ""
    }
  ]
}
```

**客户端动作**

- A 的其他端收到后，把本地"未读好友申请"红点清零
- 不需要再拉申请列表（自己刚拉过会触发，列表内容自己已经有了）

**注意**

- `type` 字段填的是 `friend_apply`，但 `sevent = friend_apply_list`，**以 sevent 为准**
- 仅 `skip == 0` 触发，分页继续拉取时不会再发；客户端不要把它误当成 `friend_apply` 处理（不要弹出"收到新申请"提示）

---

### 5.2 用户

#### ④ `userinfo_set` — 资料变更

- **触发**：自己修改昵称 / 头像 / 性别 / 生日 / 手机 / 邮箱 / 隐私
- **承载**：`systemMsgDao`
- **接收方**：自己 + 所有好友

**关键字段**

| 字段 | 含义 |
|---|---|
| `sender` | 修改者 uid |
| `content` | **完整 UserDao JSON 字符串**（包含全部字段） |
| `targetUids` | `[自己] + 所有好友 uid` |

**📨 完整帧示例**（好友收到）

```json
{
  "msgDao": [],
  "systemMsgDao": [
    {
      "mid": ":10",
      "tmid": 10,
      "sender": 1001,
      "stime": 1714680004000,
      "sevent": "userinfo_set",
      "type": "userinfo_set",
      "content": "{\"userId\":1001,\"appkey\":\"ak_xxx\",\"employee\":\"Alice\",\"nick\":\"小爱\",\"avatar\":\"https://cdn.example.com/u/1001.jpg\",\"sex\":\"f\",\"birth\":\"19950310\",\"telno\":\"+8613800000001\",\"email\":\"alice@example.com\",\"privacy\":\"all\"}",
      "etime": 2724710400000,
      "targetCnt": 4,
      "targetUids": [1001, 2002, 3003, 4004],
      "cursor": 10006,
      "extendData": ""
    }
  ]
}
```

**客户端动作**

```dart
final user = UserDao.fromJson(jsonDecode(s.content));
await db.upsertUser(user);
notifyContactsChanged();
if (user.userId == mySelfId) notifyProfileChanged();
```

---

#### ⑤ `user_remark` — 修改好友备注

- **触发**：自己给好友改备注
- **承载**：`systemMsgDao`
- **接收方**：仅自己（多端同步）

**关键字段**

| 字段 | 含义 |
|---|---|
| `sender` | 自己 uid |
| `extendData` | `{"uid": <被备注 uid>, "remark": "<新备注>"}` |

**📨 完整帧示例**（自己另一端收到）

```json
{
  "msgDao": [],
  "systemMsgDao": [
    {
      "mid": ":11",
      "tmid": 11,
      "sender": 1001,
      "stime": 1714680005000,
      "sevent": "user_remark",
      "type": "user_remark",
      "content": "",
      "etime": 2724710400000,
      "targetCnt": 1,
      "targetUids": [1001],
      "cursor": 10007,
      "extendData": "{\"uid\":2002,\"remark\":\"老王\"}"
    }
  ]
}
```

**客户端动作**：更新本地 friend.remark + 刷新通讯录排序（拼音变化）

---

#### ⑥ `user_kick` — 强制下线 ★

- **触发**：① 同账号在另一端登录（互踢）；② 管理员强制下线；③ 修改密码（见 ⑦）
- **承载**：`systemMsgDao`
- **接收方**：被踢的 wsid

**关键字段**

| 字段 | 含义 |
|---|---|
| `content` | **被踢的 token 字符串** |

**📨 完整帧示例**

```json
{
  "msgDao": [],
  "systemMsgDao": [
    {
      "mid": ":12",
      "tmid": 12,
      "stime": 1714680006000,
      "sevent": "user_kick",
      "type": "user_kick",
      "content": "abc123tokenold",
      "etime": 2724710400000,
      "targetCnt": 1,
      "targetUids": [1001],
      "cursor": 10008,
      "extendData": ""
    }
  ]
}
```

> 注意：服务端代码里 user_kick 的 cursor 赋值在 InsertSysMsg 之后（已知 bug，详见坑点 #11），DB 中的 cursor 是 0，但下发时 msgDao.Cursor 已是新值，**前端按下发的 cursor 处理即可**。

**客户端动作（必须实现）**

```dart
if (s.sevent == 'user_kick' && s.content == myToken) {
  await sdk.logout(clearLocal: true);
  await ws.disconnect();
  navigator.replaceTo('/login', reason: 'kicked');
}
```

> ⚠️ 比对 `content == 自己的 token`，不一致说明踢的是别端，本端不能下线。

---

#### ⑦ `user_passwd_changed` — 修改密码（**实际 sevent=`user_kick`**）

- **触发**：用户改密码 → 服务端踢掉所有其它端
- **承载**：`systemMsgDao`，**`sevent` 字段 = `user_kick`**
- **客户端无法从 ws 帧本身区分"改密"还是"被踢"**

**📨 完整帧示例**（与 ⑥ 完全相同结构，前端无法区分）

```json
{
  "msgDao": [],
  "systemMsgDao": [
    {
      "mid": ":13",
      "tmid": 13,
      "stime": 1714680007000,
      "sevent": "user_kick",
      "type": "user_kick",
      "content": "abc123tokenold",
      "etime": 2724710400000,
      "targetCnt": 1,
      "targetUids": [1001],
      "cursor": 10009,
      "extendData": ""
    }
  ]
}
```

**前端处理建议**：

- 当前端正在执行"改密"流程，本地标记 `expectingKick = true`
- 收到 `user_kick` 时：
  - `expectingKick == true` 且 `content == 旧 token` → 静默切到新 token 重连
  - 否则 → 走标准 ⑥ 流程下线

---

#### ⑧ `user_deleted` — 账号被删

- **承载**：`systemMsgDao`
- **接收方**：被删者本人

**关键字段**

| 字段 | 含义 |
|---|---|
| `sender` | 0（不带 sender 字段） |
| `content` | `""` |
| `targetUids` | `[被删 uid]` |

**📨 完整帧示例**

```json
{
  "msgDao": [],
  "systemMsgDao": [
    {
      "mid": ":14",
      "tmid": 14,
      "stime": 1714680008000,
      "sevent": "user_deleted",
      "type": "user_deleted",
      "content": "",
      "etime": 2724710400000,
      "targetCnt": 1,
      "targetUids": [1001],
      "cursor": 10010,
      "extendData": ""
    }
  ]
}
```

**客户端动作**：清空本地数据 + 跳到注册/登录页 + 不可重新用同账号登录

---

### 5.3 群组

#### ⑨ `group_apply` — 申请入群

- **承载**：`systemMsgDao`
- **接收方**：群主 + 申请人（**注意：被邀请的待审者也归在此通知中**）

**关键字段**

| 字段 | 含义 |
|---|---|
| `tid` | 群 id |
| `sender` | 触发人（邀请人或自己申请） |
| `visible` | 1（仅自己 + 管理员可见） |

**📨 完整帧示例**（群主端收到）

```json
{
  "msgDao": [],
  "systemMsgDao": [
    {
      "tid": "g_room_999",
      "mid": "g_room_999:50",
      "tmid": 50,
      "sender": 2002,
      "stime": 1714680010000,
      "sevent": "group_apply",
      "type": "group_apply",
      "content": "",
      "visible": 1,
      "etime": 2724710400000,
      "targetCnt": 1,
      "targetUids": [5005],
      "cursor": 10011,
      "extendData": ""
    }
  ]
}
```

**客户端动作**：群主端刷申请列表 + 红点；申请人本端 UI 提示"申请已发送"

---

#### ⑩ `group_apply_accept` — 入群申请通过

- **承载**：⚠️ **`msgDao` 中的 `tip` 类型**，不是 sysMsg
- **接收方**：群当前所有成员（含新加入的人）

**关键字段**

| 字段 | 含义 |
|---|---|
| `tid` | 群 id |
| `sender` | 处理人（群主/管理员） uid |
| `extendData` | `"pass"` |

**📨 完整帧示例**（群成员收到）

```json
{
  "msgDao": [
    {
      "tid": "g_room_999",
      "mid": "g_room_999:51",
      "tmid": 51,
      "sender": 1001,
      "stime": 1714680011000,
      "sevent": "group_apply_accept",
      "type": "tip",
      "content": "",
      "visible": 1,
      "mask": 0,
      "etime": 2724710400000,
      "auditRst": "",
      "restrict": false,
      "rtcId": 0,
      "duration": 0,
      "cursor": 10012,
      "extendData": "pass"
    }
  ],
  "systemMsgDao": []
}
```

**客户端动作**

- 申请人本端：调群详情接口 + 把该群加入本地会话列表
- 群其他人：刷新群成员列表
- 在群聊页显示"<新成员> 加入了群"tip

---

#### ⑪ `group_apply_reject` — 入群申请被拒

- **承载**：`systemMsgDao`
- **接收方**：申请人

**关键字段**

| 字段 | 含义 |
|---|---|
| `content` | `"[<昵称>]拒绝了您的群申请"` |
| `extendData` | `"refuse"` |
| `visible` | 1 |

**📨 完整帧示例**

```json
{
  "msgDao": [],
  "systemMsgDao": [
    {
      "mid": ":52",
      "tmid": 52,
      "sender": 1001,
      "stime": 1714680012000,
      "sevent": "group_apply_reject",
      "type": "group_apply_reject",
      "content": "[小爱]拒绝了您的群申请",
      "visible": 1,
      "etime": 2724710400000,
      "targetCnt": 1,
      "targetUids": [5005],
      "cursor": 10013,
      "extendData": "refuse"
    }
  ]
}
```

**客户端动作**：本端弹提示 + 刷申请列表

---

#### ⑫ `group_join` — 直接入群（管理员加人 / 免审批群）

- **承载**：`msgDao(tip)`
- **接收方**：群全员

**关键字段**

| 字段 | 含义 |
|---|---|
| `tid` | 群 id |
| `sender` | 操作人 uid |
| `targetUids` | 群全员 uid（含新成员） |

**📨 完整帧示例**

```json
{
  "msgDao": [
    {
      "tid": "g_room_999",
      "mid": "g_room_999:53",
      "tmid": 53,
      "sender": 1001,
      "stime": 1714680013000,
      "sevent": "group_join",
      "type": "tip",
      "content": "",
      "visible": 0,
      "mask": 0,
      "etime": 2724710400000,
      "targetCnt": 5,
      "targetUids": [1001, 2002, 3003, 4004, 5005],
      "auditRst": "",
      "restrict": false,
      "rtcId": 0,
      "duration": 0,
      "cursor": 10014,
      "extendData": ""
    }
  ],
  "systemMsgDao": []
}
```

**客户端动作**：刷成员列表 + 群聊页显示"X 加入了群"tip

> ⚠️ 群配置 `joinNotice > 0` 时**不发任何通知**。前端依赖 `/sdk/sync/group` 启动时校准。

---

#### ⑬ `group_quit` — 主动退群

- **承载**：`msgDao(tip)`
- **接收方**：群全员

**关键字段**

| 字段 | 含义 |
|---|---|
| `sender` | 退群者 uid |
| `content` | `""` |

**📨 完整帧示例**

```json
{
  "msgDao": [
    {
      "tid": "g_room_999",
      "mid": "g_room_999:54",
      "tmid": 54,
      "sender": 4004,
      "stime": 1714680014000,
      "sevent": "group_quit",
      "type": "tip",
      "content": "",
      "visible": 0,
      "mask": 0,
      "etime": 2724710400000,
      "auditRst": "",
      "restrict": false,
      "rtcId": 0,
      "duration": 0,
      "cursor": 10015,
      "extendData": ""
    }
  ],
  "systemMsgDao": []
}
```

**客户端动作**：本端清本地 conversation/group/member；其他人刷成员列表

---

#### ⑭ `group_kick` — 被踢出群

- **承载**：`msgDao(tip)`
- **接收方**：被踢者 + 群其他人

**关键字段**

| 字段 | 含义 |
|---|---|
| `sender` | 操作人 uid |
| `content` | `"您被<昵称>踢出了群聊"` |
| `tid` | 群 id |

**📨 完整帧示例**（被踢者 4004 收到）

```json
{
  "msgDao": [
    {
      "tid": "g_room_999",
      "mid": "g_room_999:55",
      "tmid": 55,
      "sender": 1001,
      "stime": 1714680015000,
      "sevent": "group_kick",
      "type": "tip",
      "content": "您被小爱踢出了群聊",
      "visible": 0,
      "mask": 0,
      "etime": 2724710400000,
      "auditRst": "",
      "restrict": false,
      "rtcId": 0,
      "duration": 0,
      "cursor": 10016,
      "extendData": ""
    }
  ],
  "systemMsgDao": []
}
```

**客户端动作（被踢者）**：

```dart
if (msg.sevent == 'group_kick' && msg.targetUids.contains(mySelfId)) {
  await db.deleteGroupLocally(msg.tid);
  await db.deleteConversation(msg.tid);
  await db.deleteGroupMembers(msg.tid);
  notifyLeftGroup(msg.tid);
  if (currentChatTid == msg.tid) navigator.popToRoot();
}
```

**其他人**：刷成员列表，聊天页显示 tip。

---

#### ⑮ `group_delete` — 群解散

- **承载**：`msgDao(tip)` + `systemMsgDao`（双发）
- **接收方**：群全员

**关键字段**

| 字段 | 含义 |
|---|---|
| `content` | `"群主解散了群"` |

**📨 完整帧示例**（群成员收到，msg + sysMsg 同帧）

```json
{
  "msgDao": [
    {
      "tid": "g_room_999",
      "mid": "g_room_999:56",
      "tmid": 56,
      "sender": 1001,
      "stime": 1714680016000,
      "sevent": "group_delete",
      "type": "tip",
      "content": "群主解散了群",
      "visible": 0,
      "mask": 0,
      "etime": 2724710400000,
      "auditRst": "",
      "restrict": false,
      "rtcId": 0,
      "duration": 0,
      "cursor": 10017,
      "extendData": ""
    }
  ],
  "systemMsgDao": [
    {
      "mid": ":57",
      "tmid": 57,
      "stime": 1714680016000,
      "sevent": "group_delete",
      "type": "group_delete",
      "content": "群主解散了群",
      "etime": 2724710400000,
      "targetCnt": 4,
      "targetUids": [1001, 2002, 3003, 4004],
      "cursor": 10018,
      "extendData": ""
    }
  ]
}
```

**客户端动作**：清本地 group/conversation/member；当前在该群聊页面则强制退出

> ⚠️ 客户端会**同时收到 msg.tip + sysMsg 各一条**，需 dedup（按 tid+sevent 去重）

---

#### ⑯ `group_info_set` — 群信息变更（群名/公告/各种开关）

- **承载**：取决于服务端调用时传的 `msgType`：
  - `msgType=1`: `systemMsgDao`（多端同步给操作人）
  - `msgType=2`: `msgDao(tip)`（群全员）

**关键字段**

| 字段 | 含义 |
|---|---|
| `tid` | 群 id |
| `content` | 业务自定义文本（如 "群名改为 XXX"） |

**📨 完整帧示例 — msgType=1（自己另一端收到）**

```json
{
  "msgDao": [],
  "systemMsgDao": [
    {
      "tid": "g_room_999",
      "mid": "g_room_999:58",
      "tmid": 58,
      "sender": 1001,
      "stime": 1714680017000,
      "sevent": "group_info_set",
      "type": "group_info_set",
      "content": "群名改为「研发周会」",
      "etime": 2724710400000,
      "targetCnt": 1,
      "targetUids": [1001],
      "targetGroupIds": ["g_room_999"],
      "cursor": 10019,
      "extendData": ""
    }
  ]
}
```

**📨 完整帧示例 — msgType=2（群全员收到）**

```json
{
  "msgDao": [
    {
      "tid": "g_room_999",
      "mid": "g_room_999:59",
      "tmid": 59,
      "sender": 1001,
      "stime": 1714680017000,
      "sevent": "group_info_set",
      "type": "tip",
      "content": "群名改为「研发周会」",
      "visible": 0,
      "mask": 0,
      "etime": 2724710400000,
      "auditRst": "",
      "restrict": false,
      "rtcId": 0,
      "duration": 0,
      "cursor": 10020,
      "extendData": ""
    }
  ],
  "systemMsgDao": []
}
```

**客户端动作**：调 `/v1/sdk/group/detail` 拉最新群信息

---

#### ⑰ `group_change` — 群成员个性化变更（如自己改群昵称）

- **承载**：`msgDao(tip)`
- **接收方**：单个目标用户

**关键字段**

| 字段 | 含义 |
|---|---|
| `tid` | 群 id |
| `sender` | 操作人 uid |
| `content` | 自定义文本 |

**📨 完整帧示例**

```json
{
  "msgDao": [
    {
      "tid": "g_room_999",
      "mid": "g_room_999:60",
      "tmid": 60,
      "sender": 2002,
      "stime": 1714680018000,
      "sevent": "group_change",
      "type": "tip",
      "content": "「老王」修改了群昵称为「王哥」",
      "visible": 0,
      "mask": 0,
      "etime": 2724710400000,
      "targetCnt": 1,
      "targetUids": [2002],
      "auditRst": "",
      "restrict": false,
      "rtcId": 0,
      "duration": 0,
      "cursor": 10021,
      "extendData": ""
    }
  ],
  "systemMsgDao": []
}
```

---

#### ⑱ `group_set_memberalias` — 修改自己在群内的别名

- **承载**：`systemMsgDao`
- **接收方**：仅自己（多端同步）

**关键字段**

| 字段 | 含义 |
|---|---|
| `tid` | 群 id |
| `extendData` | `""` |

**📨 完整帧示例**

```json
{
  "msgDao": [],
  "systemMsgDao": [
    {
      "tid": "g_room_999",
      "mid": "g_room_999:61",
      "tmid": 61,
      "sender": 1001,
      "stime": 1714680019000,
      "sevent": "group_set_memberalias",
      "type": "group_set_memberalias",
      "content": "",
      "etime": 2724710400000,
      "targetCnt": 1,
      "targetUids": [1001],
      "cursor": 10022,
      "extendData": ""
    }
  ]
}
```

**客户端动作**：更新本地 topic.groupRemark 字段（结合本次接口请求参数）

---

#### ⑲ `group_admin_set_on` / ⑳ `group_admin_set_off` — 任命/撤销管理员

- **承载**：`msgDao(tip)` + `systemMsgDao`（双发）
- **接收方**：群全员

**关键字段**

| 字段 | 含义 |
|---|---|
| `tid` | 群 id |
| `extendData` | `{"AdminIds": [<被任命/撤销的 uid 列表>]}` |
| `sender` | 群主 uid |

**📨 完整帧示例 — 任命（群成员收到，msg + sysMsg 同帧）**

```json
{
  "msgDao": [
    {
      "tid": "g_room_999",
      "mid": "g_room_999:62",
      "tmid": 62,
      "sender": 1001,
      "stime": 1714680020000,
      "sevent": "group_admin_set_on",
      "type": "tip",
      "content": "",
      "visible": 0,
      "mask": 0,
      "etime": 2724710400000,
      "targetCnt": 4,
      "targetUids": [1001, 2002, 3003, 4004],
      "auditRst": "",
      "restrict": false,
      "rtcId": 0,
      "duration": 0,
      "cursor": 10023,
      "extendData": "{\"AdminIds\":[2002]}"
    }
  ],
  "systemMsgDao": [
    {
      "tid": "g_room_999",
      "mid": "g_room_999:63",
      "tmid": 63,
      "sender": 1001,
      "stime": 1714680020000,
      "sevent": "group_admin_set_on",
      "type": "group_admin_set_on",
      "content": "",
      "etime": 2724710400000,
      "targetCnt": 4,
      "targetUids": [1001, 2002, 3003, 4004],
      "cursor": 10024,
      "extendData": "{\"AdminIds\":[2002]}"
    }
  ]
}
```

**📨 完整帧示例 — 撤销**

同上，把 `sevent`、`type` 改成 `group_admin_set_off`，`AdminIds` 含被撤销的 uid。

**客户端动作**

⚠️ **extendData 只携带"被操作的 uid"，不是最新完整 admin 列表**。前端必须：

```dart
final ext = jsonDecode(s.extendData);
final affected = (ext['AdminIds'] as List).cast<int>();

// 必须重新拉群详情拿到完整 admin
final group = await api.groupDetail(s.tid);
db.updateGroup(group);
notifyGroupAdminsChanged(s.tid, group.adminUids);
```

---

#### ㉑ `group_black` — 群禁言 / 拉黑

- **承载**：`systemMsgDao`
- **接收方**：被操作人

**关键字段**

| 字段 | 含义 |
|---|---|
| `tid` | 群 id |
| `content` | `"black type: 0"` 或 `"black type: 1"` |

**📨 完整帧示例**

```json
{
  "msgDao": [],
  "systemMsgDao": [
    {
      "tid": "g_room_999",
      "mid": "g_room_999:64",
      "tmid": 64,
      "sender": 1001,
      "stime": 1714680021000,
      "sevent": "group_black",
      "type": "group_black",
      "content": "black type: 0",
      "etime": 2724710400000,
      "targetCnt": 1,
      "targetUids": [4004],
      "cursor": 10025,
      "extendData": ""
    }
  ]
}
```

**`black type` 解释**：

- `0` = 拉入黑名单 / 禁言
- `1` = 拉出黑名单 / 解禁

**客户端动作**：UI 提示 + 禁用发言按钮

---

#### ㊳ `group_apply_notify` — 入群申请通知（群主 / 管理员）

- **触发**：B(2002) 走新版入群审核接口 `/v1/sdk/group/apply/join`，群开启了审核（`code` 校验通过但仍需审核）
- **承载**：`systemMsgDao`
- **接收方**：群主 + 全部管理员（去重后的 `notifyUsers`）

**关键字段**

| 字段 | 含义 |
|---|---|
| `sender` | 申请人 uid（B=2002） |
| `tid` | 申请的群 topicId（即 `req.TopicId`，例：`g_room_999`） |
| `type` | `"group_apply_notify"` |
| `content` | JSON 序列化的 `ApplyInfo` 数组，每项含 `applyUserId / applyName / hello / scene / topicId / members` |
| `targetUids` | `[群主, ...adminIds]` 去重 |
| `extendData` | `""` |

**📨 完整帧示例**（群主 1001 / 管理员 3003 收到）

```json
{
  "msgDao": [],
  "systemMsgDao": [
    {
      "tid": "g_room_999",
      "mid": "g_room_999:301",
      "tmid": 301,
      "sender": 2002,
      "stime": 1714680025000,
      "sevent": "group_apply_notify",
      "type": "group_apply_notify",
      "content": "[{\"applyUserId\":2002,\"applyName\":\"Bob\",\"hello\":\"求加群\",\"scene\":\"qrcode\",\"topicId\":\"g_room_999\",\"members\":[2002]}]",
      "etime": 2724710400000,
      "targetCnt": 2,
      "targetUids": [1001, 3003],
      "cursor": 10300,
      "extendData": ""
    }
  ]
}
```

**客户端动作**

- 群主 / 管理员端：在群信息或申请列表上加红点；可调 `/sdk/group/apply/list` 拉取最新申请汇总
- 申请人本人 **不会** 收到该事件（他得到的是 HTTP 响应即可）

**注意**

- 与旧事件 `group_apply` 的区别：`group_apply` 是邀请场景（已邀请→申请人收 sysMsg + 群主收 sysMsg），`group_apply_notify` 仅在新版"开启审核"路径上触发，**只发给群主 + 管理员**，承载完整 `ApplyInfo[]`，前端可直接渲染
- `content` 是 JSON 字符串，前端解析后用 `applyUserId` 拉申请人头像/昵称

---

### 5.4 会话

#### ㉒ `topic_create` — 创建会话

- **承载**：
  - `topicType="group"`: `msgDao(tip)` 全员
  - `topicType="single"`: ⚠️ **不发任何通知**（代码已注释）

**关键字段**

| 字段 | 含义 |
|---|---|
| `tid` | topicId |
| `sender` | 创建人 uid |

**📨 完整帧示例**（群创建后全员收到）

```json
{
  "msgDao": [
    {
      "tid": "g_room_999",
      "mid": "g_room_999:1",
      "tmid": 1,
      "sender": 1001,
      "stime": 1714680022000,
      "sevent": "topic_create",
      "type": "tip",
      "content": "",
      "visible": 0,
      "mask": 0,
      "etime": 2724710400000,
      "auditRst": "",
      "restrict": false,
      "rtcId": 0,
      "duration": 0,
      "cursor": 10026,
      "extendData": ""
    }
  ],
  "systemMsgDao": []
}
```

**客户端动作**：群场景下添加本地会话；单聊场景**前端必须自己处理**（发首条消息时同步创建本地会话）

---

#### ㉓ `topic_modify` — 会话置顶 / 已读位置 / 隐藏 mid / 备注

- **承载**：`systemMsgDao`
- **接收方**：仅自己（多端同步），`targetGroupIds=[topicId]`

**关键字段**

| 字段 | 含义 |
|---|---|
| `meta` | ❌ **SysMsgDao 没有 meta 字段** —— 实际下发时不会出现（虽然代码 `WithMeta` 调用了，但 NewSysMsg 没把 meta 写入）|

> 🚨 **现状勘误**：`topic_modify.go` 与 `topic_remark.go` 都调用了 `WithMeta(...)`，但 `NewSysMsg` 构造函数里**没有把 meta 写到 SysMsgDao**（SysMsgDao 结构体也没有 Meta 字段）。前端**收不到 remark 内容**，只能感知到"有变更"。

**📨 完整帧示例**

```json
{
  "msgDao": [],
  "systemMsgDao": [
    {
      "tid": "s_1001_2002",
      "mid": "s_1001_2002:65",
      "tmid": 65,
      "sender": 1001,
      "stime": 1714680023000,
      "sevent": "topic_modify",
      "type": "topic_modify",
      "content": "",
      "etime": 2724710400000,
      "targetCnt": 1,
      "targetUids": [1001],
      "targetGroupIds": ["s_1001_2002"],
      "cursor": 10027,
      "extendData": ""
    }
  ]
}
```

**客户端动作**：调 `/v1/sdk/topic/detail` 拉完整 topic 字段写本地

---

#### ㉔ `topic_remark` — 单独修改会话备注

- **承载**：`systemMsgDao`
- 同 ㉓，但只改 remark

**📨 完整帧示例**

```json
{
  "msgDao": [],
  "systemMsgDao": [
    {
      "tid": "s_1001_2002",
      "mid": "s_1001_2002:66",
      "tmid": 66,
      "sender": 1001,
      "stime": 1714680024000,
      "sevent": "topic_remark",
      "type": "topic_remark",
      "content": "",
      "etime": 2724710400000,
      "targetCnt": 1,
      "targetUids": [1001],
      "targetGroupIds": ["s_1001_2002"],
      "cursor": 10028,
      "extendData": ""
    }
  ]
}
```

**客户端动作**：与 ㉓ 相同，建议拉 topic detail 校准

---

### 5.5 消息

#### ㉕ `msg_send_to_user` — 私聊新消息

- **承载**：`msgDao`（type 为具体消息类型）
- **接收方**：发送者多端 + 接收者多端

**关键字段**：标准 MsgDao 全字段

**extendData 注入** (sender 信息):

```jsonc
{
  "sender": {
    "userId": 1001,
    "userEmployee": "Alice",
    "userNick": "小爱",
    "userRemark": "",          // 接收者对发送者的备注
    "avatar": "https://..."
  }
}
```

**📨 完整帧示例 — 文本消息**

```json
{
  "msgDao": [
    {
      "tid": "s_1001_2002",
      "mid": "s_1001_2002:100",
      "tmid": 100,
      "cid": "client-msg-uuid-001",
      "sender": 1001,
      "stime": 1714680030000,
      "sevent": "msg_send_to_user",
      "type": "txt",
      "content": "晚上一起吃饭吗？",
      "encrypt": 0,
      "visible": 0,
      "mask": 0,
      "etime": 2724710400000,
      "targetCnt": 1,
      "targetUids": [2002],
      "auditRst": "",
      "restrict": false,
      "rtcId": 0,
      "duration": 0,
      "cursor": 10029,
      "extendData": "{\"sender\":{\"userId\":1001,\"userEmployee\":\"Alice\",\"userNick\":\"小爱\",\"userRemark\":\"\",\"avatar\":\"https://cdn.example.com/u/1001.jpg\"},\"targets\":null}"
    }
  ],
  "systemMsgDao": []
}
```

**📨 完整帧示例 — 加密文本**

```json
{
  "msgDao": [
    {
      "tid": "s_1001_2002",
      "mid": "s_1001_2002:101",
      "tmid": 101,
      "cid": "client-msg-uuid-002",
      "sender": 1001,
      "stime": 1714680031000,
      "sevent": "msg_send_to_user",
      "type": "txt",
      "content": "U2FsdGVkX1+abcdefghijklmnopqrstuv==",
      "encrypt": 1,
      "visible": 0,
      "mask": 0,
      "etime": 2724710400000,
      "targetCnt": 1,
      "targetUids": [2002],
      "auditRst": "",
      "restrict": false,
      "rtcId": 0,
      "duration": 0,
      "cursor": 10030,
      "extendData": "{\"sender\":{\"userId\":1001,\"userNick\":\"小爱\",\"avatar\":\"https://cdn.example.com/u/1001.jpg\"}}"
    }
  ],
  "systemMsgDao": []
}
```

**📨 完整帧示例 — 图片消息**

```json
{
  "msgDao": [
    {
      "tid": "s_1001_2002",
      "mid": "s_1001_2002:102",
      "tmid": 102,
      "cid": "client-msg-uuid-003",
      "sender": 1001,
      "stime": 1714680032000,
      "sevent": "msg_send_to_user",
      "type": "img",
      "content": "https://cdn.example.com/img/abc.jpg",
      "meta": {
        "w": "1080",
        "h": "1920",
        "size": "456789",
        "thumb": "https://cdn.example.com/img/abc_thumb.jpg"
      },
      "visible": 0,
      "mask": 0,
      "etime": 2724710400000,
      "targetCnt": 1,
      "targetUids": [2002],
      "auditRst": "",
      "restrict": false,
      "rtcId": 0,
      "duration": 0,
      "cursor": 10031,
      "extendData": "{\"sender\":{\"userId\":1001,\"userNick\":\"小爱\",\"avatar\":\"https://cdn.example.com/u/1001.jpg\"}}"
    }
  ],
  "systemMsgDao": []
}
```

**客户端动作**：写库 + 派发到对应 single topic 的会话流；更新会话列表的 lastMsg

---

#### ㉖ `msg_send_to_group` — 群聊新消息

- **承载**：`msgDao`
- **接收方**：群全员

**比私聊多的字段**：`atUids` / `atCnt`（被 @ 列表）

**📨 完整帧示例 — 群文本（含 @）**

```json
{
  "msgDao": [
    {
      "tid": "g_room_999",
      "mid": "g_room_999:200",
      "tmid": 200,
      "cid": "client-msg-uuid-004",
      "sender": 1001,
      "stime": 1714680033000,
      "sevent": "msg_send_to_group",
      "type": "txt",
      "content": "@王哥 看下这个需求",
      "visible": 0,
      "mask": 0,
      "etime": 2724710400000,
      "targetCnt": 3,
      "targetUids": [2002, 3003, 4004],
      "atCnt": 1,
      "atUids": [2002],
      "auditRst": "",
      "restrict": false,
      "rtcId": 0,
      "duration": 0,
      "cursor": 10032,
      "extendData": "{\"sender\":{\"userId\":1001,\"userNick\":\"小爱\",\"groupName\":\"研发周会\",\"groupUserNick\":\"小爱(组长)\",\"avatar\":\"https://cdn.example.com/u/1001.jpg\"}}"
    }
  ],
  "systemMsgDao": []
}
```

**📨 完整帧示例 — 群语音（RTC）**

```json
{
  "msgDao": [
    {
      "tid": "g_room_999",
      "mid": "g_room_999:201",
      "tmid": 201,
      "cid": "client-msg-uuid-005",
      "sender": 1001,
      "stime": 1714680034000,
      "sevent": "msg_send_to_group",
      "type": "voice_rtc",
      "content": "voice_rtc_invite",
      "visible": 0,
      "mask": 0,
      "etime": 2724710400000,
      "targetCnt": 3,
      "targetUids": [2002, 3003, 4004],
      "auditRst": "",
      "restrict": false,
      "rtcId": 8888888,
      "duration": 0,
      "cursor": 10033,
      "extendData": "{\"sender\":{\"userId\":1001,\"userNick\":\"小爱\"}}"
    }
  ],
  "systemMsgDao": []
}
```

**客户端动作**：

- 写库 + 刷群聊
- 自己被 @ → 触发"有人@我"红点
- 计算未读时按 `cursor > 自己已读 cursor` 累加

---

#### ㉗ `msg_mask` — 撤回

- **承载**：`systemMsgDao`
- **接收方**：原消息所有 targetUids

**关键字段**

| 字段 | 含义 |
|---|---|
| `tid` | 原消息所在 topicId |
| `type` | `"msg_mask"` |
| `sender` | 撤回操作人 uid |
| `content` | **被撤回的 mid** ★ |

**📨 完整帧示例**

```json
{
  "msgDao": [],
  "systemMsgDao": [
    {
      "tid": "s_1001_2002",
      "mid": "s_1001_2002:103",
      "tmid": 103,
      "sender": 1001,
      "stime": 1714680035000,
      "sevent": "msg_mask",
      "type": "msg_mask",
      "content": "s_1001_2002:100",
      "etime": 2724710400000,
      "targetCnt": 1,
      "targetUids": [2002],
      "cursor": 10034,
      "extendData": ""
    }
  ]
}
```

**客户端动作**

```dart
final maskMid = s.content;
await db.updateMsgMask(maskMid, mask: 2);   // 标记 mask=2
notifyMsgMaskChanged(s.tid, maskMid);
// UI 显示"X 撤回了一条消息"
```

---

#### ㉘ `msg_delete` — 删除消息（清除会话内某条历史）

- **承载**：`systemMsgDao`
- **接收方**：原消息所有 targetUids

**关键字段**：与 `msg_mask` 完全相同结构，区别在 `type=msg_delete`

**📨 完整帧示例**

```json
{
  "msgDao": [],
  "systemMsgDao": [
    {
      "tid": "s_1001_2002",
      "mid": "s_1001_2002:104",
      "tmid": 104,
      "sender": 1001,
      "stime": 1714680036000,
      "sevent": "msg_delete",
      "type": "msg_delete",
      "content": "s_1001_2002:99",
      "etime": 2724710400000,
      "targetCnt": 1,
      "targetUids": [2002],
      "cursor": 10035,
      "extendData": ""
    }
  ]
}
```

**客户端动作**：本地物理删 / 隐藏；区别于撤回，不显示"撤回"提示

---

#### ㉙ `msg_read` — 已读回执

- **触发**：对端调用 `/v1/sdk/msg/read` 上报 mid 列表，服务端**逐条**通知发送方
- **承载**：`systemMsgDao`
- **接收方**：消息发送方

**关键字段**

| 字段 | 含义 |
|---|---|
| `sender` | **已读者 uid**（注意：sender 不是消息原发送人，而是当前已读上报人） |
| `content` | **被已读的 mid** |
| `targetUids` | `[消息原发送方 uid]` |

**📨 完整帧示例**（消息发送者 1001 收到）

```json
{
  "msgDao": [],
  "systemMsgDao": [
    {
      "mid": ":105",
      "tmid": 105,
      "sender": 2002,
      "stime": 1714680037000,
      "sevent": "msg_read",
      "type": "msg_read",
      "content": "s_1001_2002:100",
      "etime": 2724710400000,
      "targetCnt": 1,
      "targetUids": [1001],
      "cursor": 10036,
      "extendData": ""
    }
  ]
}
```

**客户端动作**

```dart
// 增量累加 readUids，不要覆盖
final mid = s.content;
final reader = s.sender;
await db.appendMsgReader(mid, reader);
notifyMsgReadChanged(mid);
```

> ⚠️ **群消息已读未聚合**：群内 100 人各读 1 条会推 100 条 sysMsg。客户端必须按 mid + reader 增量合并，不能直接覆盖。

---

#### ㊱ `msg_local_delete` — 本地消息删除（多端同步隐藏）

- **触发**：A(1001) 调用 `/v1/sdk/msg/local/delete` 删除一组本地消息（不影响对端，仅本端隐藏）
- **承载**：`systemMsgDao`
- **接收方**：**仅 A 自己**（多端同步本地隐藏标记）

**关键字段**

| 字段 | 含义 |
|---|---|
| `sender` | A 的 uid |
| `tid` | 被操作的会话 topicId |
| `type` | `"msg_local_delete"` |
| `content` | JSON 序列化的被删 mid 数组，如 `["s_1001_2002:120","s_1001_2002:121"]` |
| `targetUids` | `[A.uid]` |
| `extendData` | `""` |

**📨 完整帧示例**（A 端多端收到）

```json
{
  "msgDao": [],
  "systemMsgDao": [
    {
      "tid": "s_1001_2002",
      "mid": "s_1001_2002:200",
      "tmid": 200,
      "sender": 1001,
      "stime": 1714680010000,
      "sevent": "msg_local_delete",
      "type": "msg_local_delete",
      "content": "[\"s_1001_2002:120\",\"s_1001_2002:121\"]",
      "etime": 2724710400000,
      "targetCnt": 1,
      "targetUids": [1001],
      "cursor": 10100,
      "extendData": ""
    }
  ]
}
```

**客户端动作**

- 解析 `content` 为 mid 列表，对每个 mid 在本地写入"已隐藏"标记，刷新会话页面（这些消息从本端 UI 消失）
- 当前 wsid 自己触发的请求会 **同时** 收到一份（服务端不会跳过发起端的其他 wsid）

**注意**

- 这是 **多端同步本地状态** 的事件，对端用户完全无感
- 调用方 `MsgLocalDeleteHandler` 的 `WithMsgType` 与 `WithEvent` 同为 `msg_local_delete`，是少数 sysMsg 中 `type == sevent` 的事件之一

---

### 5.6 内部事件（不会暴露 sevent）

#### ㉚ `user_connected`

- **触发**：ws 网关 → imsvr 回调（用户上线）
- **客户端表现**：连接建立后**立即收到一帧或多帧**包含本 wsid 所有未 ack 的 OfflineMsg
- 客户端**不需要识别**该 sevent，只需正常处理收到的批

**📨 表现示例**：连接建立后服务端推送一个聚合帧

```json
{
  "msgDao": [
    { "tid": "s_1001_2002", "mid": "s_1001_2002:80", "sevent": "msg_send_to_user", "...": "..." },
    { "tid": "s_1001_2002", "mid": "s_1001_2002:81", "sevent": "msg_send_to_user", "...": "..." }
  ],
  "systemMsgDao": [
    { "mid": ":90", "sevent": "user_remark", "...": "..." },
    { "mid": ":91", "sevent": "msg_mask", "...": "..." }
  ]
}
```

#### ㉛ `report_received`

- **触发**：客户端调 `/v1/sdk/msg/report`
- **作用**：服务端清理 cursor ≤ chat/system Cursor 的 OfflineMsg
- 客户端只需调接口，不需识别 ws 中的 sevent

---
### 5.7 后台事件（admin_notify_* / admin_disband_* / assistant_notify_* / system_notify_*）

#### 前置说明

以下 15 个事件全部由 **管理后台** 经 Kafka 触发，路径：

```
admin 后台 ──HTTP──▶ admin svr ──Kafka topic(kk_admin_notify)──▶ ws.imsvr.adminConsumer.ConsumeClaim
                                                                  │
                                                       switch(NotifyType) case N:
                                                                  │
                                              chann.SystemNotify / BusinessNotify / PlatformNotify
                                                                  ▼
                                                            前端 ws 帧
```

**统一约束**：

- 这些事件的 `sysMsg.appkey` 多为 `"000000"`（admin_consumer.go 写死，仅 `admin_notify_real_translate` / `admin_notify_business_switch` / `admin_notify_config_assistant` / `assistant_notify_event` / `system_notify_event` 走商户 appkey）；客户端在收到时 **按 appkey 区分**：`appkey == "000000"` 代表是后台跨业务下发，不要按当前商户的业务逻辑处理。
- `sender` 在 admin_consumer 内有 `WithSenderId(103) ... WithSenderId(realUserId)` 双调用：后者覆盖前者，**最终 `sender` 是实际目标用户的 uid**（设计上原意是 103=后台机器人，但代码当前是真实 uid）。`assistant_notify_event` / `system_notify_event` 因走 `ChannelManager.NewMsg`，`sender` 写死 `1000`。前端拿到 `sender=1000`/`sender=103` 都要按"系统消息"渲染（不要去拉用户信息）。
- 离线下发与普通事件相同：进 `OfflineMsg` 表，在 wsid 上线时随补推一起发出。
- 这些事件 **统一不进 `_onSysEvent` 业务 switch**，客户端建议在公共 switch 之前拦截一层 `if (s.sevent.startsWith('admin_') || s.sevent == 'assistant_notify_event' || s.sevent == 'system_notify_event')` 直接走"系统通知/红点"通用逻辑。

---

#### ㊴ `admin_notify_user_phone_email` — 后台修改手机/邮箱

- **触发**：管理员后台改用户的手机或邮箱（NotifyType=1）
- **承载**：`systemMsgDao`
- **接收方**：被改的用户 uid（多端）

**关键字段**

| 字段 | 含义 |
|---|---|
| `sender` | 被改用户 uid（覆盖了 103） |
| `tid` | `s_103_<userId>`（如 `s_103_1001`） |
| `appkey` | `"000000"` |
| `content` | 原始 `AdminNotifyInfo.Message` 字符串，反序列化后是 `UserInfoSet{userId, userPhone, userEmail, ...}` |
| `targetUids` | `[userId]` |

**📨 完整帧示例**（A=1001 收到）

```json
{
  "msgDao": [],
  "systemMsgDao": [
    {
      "appkey": "000000",
      "tid": "s_103_1001",
      "mid": "s_103_1001:1",
      "tmid": 1,
      "sender": 1001,
      "stime": 1714680030000,
      "sevent": "admin_notify_user_phone_email",
      "type": "admin_notify_user_phone_email",
      "content": "{\"userId\":1001,\"userPhone\":\"13800000000\",\"userEmail\":\"alice@example.com\",\"userPassword\":\"\",\"userNick\":\"\"}",
      "etime": 2724710400000,
      "targetCnt": 1,
      "targetUids": [1001],
      "cursor": 10400,
      "extendData": ""
    }
  ]
}
```

**客户端动作**

- 解析 `content` 取 `userPhone` / `userEmail`，刷新本地用户档案
- 不弹通知（静默更新即可）

---

#### ㊵ `admin_notify_user_nick` — 后台修改昵称

- **触发**：NotifyType=2
- **承载**：`systemMsgDao`
- **接收方**：被改的用户 uid（多端）

**关键字段**：同 `admin_notify_user_phone_email`，`content` 反序列化后 `UserInfoSet.userNick` 字段非空。

**📨 完整帧示例**

```json
{
  "msgDao": [],
  "systemMsgDao": [
    {
      "appkey": "000000",
      "tid": "s_103_1001",
      "mid": "s_103_1001:2",
      "tmid": 2,
      "sender": 1001,
      "stime": 1714680031000,
      "sevent": "admin_notify_user_nick",
      "type": "admin_notify_user_nick",
      "content": "{\"userId\":1001,\"userPhone\":\"\",\"userEmail\":\"\",\"userPassword\":\"\",\"userNick\":\"Alice-后台改名\"}",
      "etime": 2724710400000,
      "targetCnt": 1,
      "targetUids": [1001],
      "cursor": 10401,
      "extendData": ""
    }
  ]
}
```

**客户端动作**

- 取 `userNick` 刷新当前用户档案 + 会话列表里"我的昵称"显示

---

#### ㊶ `admin_notify_user_password` — 后台重置密码

- **触发**：NotifyType=3
- **承载**：`systemMsgDao`
- **接收方**：被重置密码的用户 uid（多端）

**关键字段**：`content` = `UserInfoSet{userId, userPassword, ...}`（`userPassword` 是新密码 / 哈希后值，**不应在前端展示**）

**📨 完整帧示例**

```json
{
  "msgDao": [],
  "systemMsgDao": [
    {
      "appkey": "000000",
      "tid": "s_103_1001",
      "mid": "s_103_1001:3",
      "tmid": 3,
      "sender": 1001,
      "stime": 1714680032000,
      "sevent": "admin_notify_user_password",
      "type": "admin_notify_user_password",
      "content": "{\"userId\":1001,\"userPhone\":\"\",\"userEmail\":\"\",\"userPassword\":\"newpwd-or-hash\",\"userNick\":\"\"}",
      "etime": 2724710400000,
      "targetCnt": 1,
      "targetUids": [1001],
      "cursor": 10402,
      "extendData": ""
    }
  ]
}
```

**客户端动作**

- 弹"密码已被管理员重置，请重新登录"提示
- **本地清 token + 强制下线**（与 `user_kick` 同等处理；服务端理论上也会另发 `user_kick`，但前端不要依赖）

---

#### ㊷ `admin_notify_msg_delete` — 后台删除聊天记录

- **触发**：NotifyType=4
- **承载**：`systemMsgDao`
- **接收方**：消息发送方 + 全部接收方（`receiverIds` + 自动追加 `senderId`）

**关键字段**

| 字段 | 含义 |
|---|---|
| `sender` | 被删消息的原发送方 uid |
| `tid` | `s_103_<msgSenderId>` |
| `content` | 被删消息的 mid 字符串（如 `"s_1001_2002:123"`） |
| `targetUids` | `[receiverIds..., senderId]` 去重前的合并 |

**📨 完整帧示例**

```json
{
  "msgDao": [],
  "systemMsgDao": [
    {
      "appkey": "000000",
      "tid": "s_103_1001",
      "mid": "s_103_1001:4",
      "tmid": 4,
      "sender": 1001,
      "stime": 1714680033000,
      "sevent": "admin_notify_msg_delete",
      "type": "admin_notify_msg_delete",
      "content": "s_1001_2002:123",
      "etime": 2724710400000,
      "targetCnt": 3,
      "targetUids": [2002, 3003, 1001],
      "cursor": 10403,
      "extendData": ""
    }
  ]
}
```

**客户端动作**

- 取 `content` 作为 mid，在本地把该消息标记为"已删除"（与 `msg_delete` 走同一处理函数即可）
- 这条事件主要用于"已撤回/已删除"提示在多端、双方都生效

---

#### ㊸ `admin_notify_msg_mute` — 后台禁言

- **触发**：NotifyType=5
- **承载**：`systemMsgDao`
- **接收方**：被禁言/解禁的 `userIds` 列表（私聊或群聊均可）

**关键字段**

| 字段 | 含义 |
|---|---|
| `sender` | `userIds[0]`（第一个被禁言用户的 uid） |
| `tid` | `MuteInfo.topicId`（私聊则 `s_a_b`，群聊则 `g_room_xxx`） |
| `content` | 原始 `AdminNotifyInfo.Message`，反序列化后是 `MuteInfo{topicId, userIds, status, type}` |
| `targetUids` | `MuteInfo.userIds` |

**📨 完整帧示例**

```json
{
  "msgDao": [],
  "systemMsgDao": [
    {
      "appkey": "000000",
      "tid": "g_room_999",
      "mid": "g_room_999:401",
      "tmid": 401,
      "sender": 2002,
      "stime": 1714680034000,
      "sevent": "admin_notify_msg_mute",
      "type": "admin_notify_msg_mute",
      "content": "{\"topicId\":\"g_room_999\",\"userIds\":[2002,3003],\"status\":\"1\",\"type\":\"0\"}",
      "etime": 2724710400000,
      "targetCnt": 2,
      "targetUids": [2002, 3003],
      "cursor": 10404,
      "extendData": ""
    }
  ]
}
```

**客户端动作**

- 解析 `content` 取 `status`（`"1"` 禁言 / `"0"` 解禁）、`type`（`"1"` 私聊禁言 / `"0"` 群聊禁言）
- 群聊禁言：在群信息页面更新"成员禁言状态"
- 被禁言用户本端：UI 禁用发送按钮 + 提示"你已被管理员禁言"

---

#### ㊹ `admin_disband_group_message` — 后台解散群（msg.tip 通知群全员）

- **触发**：NotifyType=6 且 `DisbandGroupInfo.type == 1`
- **承载**：`msgDao(type=tip)`（**注意是 msg 不是 sysMsg**）
- **接收方**：群全员（`DisbandGroupInfo.members`）

**关键字段**

| 字段 | 含义 |
|---|---|
| `sender` | 群主 uid（`DisbandGroupInfo.groupOwner`） |
| `tid` | 群 topicId |
| `appkey` | `DisbandGroupInfo.appkey`（保留商户 appkey，**不是** `"000000"`） |
| `type` | `"tip"` |
| `content` | `"后端管理员解散了群聊"` |
| `targetUids` | 全体群成员 |

**📨 完整帧示例**（成员 2002 收到）

```json
{
  "msgDao": [
    {
      "appkey": "kk_xxx",
      "tid": "g_room_999",
      "mid": "g_room_999:402",
      "tmid": 402,
      "sender": 1001,
      "stime": 1714680035000,
      "sevent": "admin_disband_group_message",
      "type": "tip",
      "content": "后端管理员解散了群聊",
      "visible": 0,
      "mask": 0,
      "etime": 2724710400000,
      "targetCnt": 4,
      "targetUids": [1001, 2002, 3003, 4004],
      "auditRst": "",
      "restrict": false,
      "rtcId": 0,
      "duration": 0,
      "cursor": 10405,
      "extendData": "{\"sender\":{\"userId\":1001,\"userNick\":\"小爱\",\"groupName\":\"研发周会\"},\"targets\":[...]}"
    }
  ],
  "systemMsgDao": []
}
```

**客户端动作**

- 与普通 `group_delete` msg.tip 一样：在群聊界面置一条系统提示气泡
- **注意**：与 `admin_disband_group_notify` 通常同一次后台动作 **同时下发**（NType=1 和 NType=2 是后端两条 Kafka 消息，前端要按 (tid, sevent) 去重）

---

#### ㊺ `admin_disband_group_notify` — 后台解散群（sysMsg 系统通知）

- **触发**：NotifyType=6 且 `DisbandGroupInfo.type == 2`
- **承载**：`systemMsgDao`
- **接收方**：群全员（`DisbandGroupInfo.members`）

**关键字段**

| 字段 | 含义 |
|---|---|
| `sender` | `members[0]`（成员列表第一项的 uid，**不是群主**） |
| `tid` | `DisbandGroupInfo.topicId` |
| `appkey` | `"000000"` |
| `content` | 原始 `AdminNotifyInfo.Message`（反序列化后即 `DisbandGroupInfo`） |
| `targetUids` | 全体群成员 |

**📨 完整帧示例**

```json
{
  "msgDao": [],
  "systemMsgDao": [
    {
      "appkey": "000000",
      "tid": "g_room_999",
      "mid": "g_room_999:403",
      "tmid": 403,
      "sender": 1001,
      "stime": 1714680036000,
      "sevent": "admin_disband_group_notify",
      "type": "admin_disband_group_notify",
      "content": "{\"type\":2,\"appkey\":\"kk_xxx\",\"topicId\":\"g_room_999\",\"groupOwner\":1001,\"members\":[1001,2002,3003,4004]}",
      "etime": 2724710400000,
      "targetCnt": 4,
      "targetUids": [1001, 2002, 3003, 4004],
      "cursor": 10406,
      "extendData": ""
    }
  ]
}
```

**客户端动作**

- 与 `group_delete` 一样处理：本地置该群 `inGroup=false`、`order=0`、隐藏后续输入
- 解析 `content` 取 `groupOwner` 区别普通解散（这里是后台强制解散）

---

#### ㊻ `admin_notify_real_translate` — 实时翻译开关

- **触发**：NotifyType=7
- **承载**：`systemMsgDao`
- **接收方**：该商户全部有效用户（`QryUsersByAppkey`）

**关键字段**

| 字段 | 含义 |
|---|---|
| `sender` | `1000`（`WithSenderId(1000)`，没有第二次覆盖） |
| `tid` | **空**（未调 `WithTopicId`） |
| `appkey` | 商户 appkey（businessId 反查得到） |
| `content` | `"1"`（开） 或 `"0"`（关），`RealtimeTranslationInfo.Status` 转字符串 |
| `targetUids` | 商户全部用户 |

**📨 完整帧示例**

```json
{
  "msgDao": [],
  "systemMsgDao": [
    {
      "appkey": "kk_xxx",
      "tid": "",
      "mid": ":501",
      "tmid": 501,
      "sender": 1000,
      "stime": 1714680040000,
      "sevent": "admin_notify_real_translate",
      "type": "admin_notify_real_translate",
      "content": "1",
      "etime": 2724710400000,
      "targetCnt": 3,
      "targetUids": [1001, 2002, 3003],
      "cursor": 10500,
      "extendData": ""
    }
  ]
}
```

**客户端动作**

- 解析 `content`，更新本地"实时翻译开关"全局状态：`1` 开 / `0` 关
- 影响 UI：消息气泡上是否显示翻译按钮 / 自动翻译开关

---

#### ㊼ `assistant_notify_event` — 小助手广播（商户级）

- **触发**：NotifyType=8（管理后台发送 `Announcement`），同时**也被 `PlatformNotify` 间接复用**：当 announcement.scene=3/4 时， `PlatformNotify` → `BusinessNotify("0", appkey, nil, msg, "system_notify_event", scene)`，但 case 8 直接路径中 BusinessNotify 写死了 `assistant_notify_event`，所以 scene=0/1 必然下发 `assistant_notify_event`
- **承载**：`msgDao(type=txt)`（**走 `BusinessNotify`，不是 sysMsg**）
- **接收方**：
  - `scene=0`：商户全部用户（`QryUsersByAppkey`）
  - `scene=1`：指定 `receiveUids`

**关键字段**

| 字段 | 含义 |
|---|---|
| `sender` | `1000`（`RobotID_Assistant`，由 `ChannelManager.NewMsg` 写死） |
| `tid` | `s_<RobotID_Assistant>_<userId>`（每个接收用户独立 topic） |
| `appkey` | 该 announcement 的商户 appkey |
| `type` | `"txt"` |
| `content` | `Announcement.Msg[0].Content`（公告正文，纯文本） |
| `targetUids` | `[userId]`（单条 msg 只发给一个用户；商户多用户时拆成多条 msg） |
| `extendData` | `""`（落库时为空，因为不走 process*ExtendData） |

**📨 完整帧示例**

```json
{
  "msgDao": [
    {
      "appkey": "kk_xxx",
      "tid": "s_10000001_1001",
      "mid": "s_10000001_1001:601",
      "tmid": 601,
      "sender": 1000,
      "stime": 1714680050000,
      "sevent": "assistant_notify_event",
      "type": "txt",
      "content": "本周五系统升级，预计 20:00-22:00 暂停服务",
      "encrypt": 0,
      "visible": 0,
      "mask": 0,
      "etime": 2724710400000,
      "targetCnt": 1,
      "targetUids": [1001],
      "auditRst": "",
      "restrict": false,
      "rtcId": 0,
      "duration": 0,
      "cursor": 10600,
      "extendData": ""
    }
  ],
  "systemMsgDao": []
}
```

**客户端动作**

- 进 **"小助手会话"**（顶部固定一条/几条助手消息）渲染：`sender=1000` 对应"小助手"机器人
- 走 `_onMsg` switch，不走 `_onSysEvent`
- 不需要回复，不能撤回

**注意**

- `tid` 形如 `s_10000001_<userId>`（`RobotID_Assistant`，具体常量值见 `models.RobotID_Assistant`）
- 调用了 `JPush.PushMsg`，离线设备会收到极光推送

---

#### ㊽ `admin_notify_msg_assistant` — 群发助手开关

- **触发**：NotifyType=9
- **承载**：`systemMsgDao`
- **接收方**：被设置开关的 userId（单人）

**关键字段**

| 字段 | 含义 |
|---|---|
| `sender` | 被设置用户 uid（覆盖 103） |
| `tid` | `s_103_<userId>` |
| `appkey` | `"000000"` |
| `content` | `"1"` 开 / `"0"` 关（`AssistantInfo.Status` 转字符串） |
| `targetUids` | `[userId]` |

**📨 完整帧示例**

```json
{
  "msgDao": [],
  "systemMsgDao": [
    {
      "appkey": "000000",
      "tid": "s_103_1001",
      "mid": "s_103_1001:7",
      "tmid": 7,
      "sender": 1001,
      "stime": 1714680055000,
      "sevent": "admin_notify_msg_assistant",
      "type": "admin_notify_msg_assistant",
      "content": "1",
      "etime": 2724710400000,
      "targetCnt": 1,
      "targetUids": [1001],
      "cursor": 10700,
      "extendData": ""
    }
  ]
}
```

**客户端动作**

- 更新本地"群发助手是否启用"状态：`1` 开 / `0` 关
- 不弹通知

---

#### ㊾ `admin_notify_business_switch` — 商户开关下发

- **触发**：NotifyType=10
- **承载**：`systemMsgDao`
- **接收方**：商户全部有效用户

**关键字段**

| 字段 | 含义 |
|---|---|
| `sender` | `1000`（`WithSenderId(1000)`，未覆盖） |
| `tid` | **空** |
| `appkey` | 商户 appkey |
| `content` | `BusinessSwitchInfo.Settings`（业务侧 JSON 字符串，schema 由后台 announce） |
| `targetUids` | 商户全部用户 |

**📨 完整帧示例**

```json
{
  "msgDao": [],
  "systemMsgDao": [
    {
      "appkey": "kk_xxx",
      "tid": "",
      "mid": ":801",
      "tmid": 801,
      "sender": 1000,
      "stime": 1714680060000,
      "sevent": "admin_notify_business_switch",
      "type": "admin_notify_business_switch",
      "content": "{\"realTranslate\":1,\"voiceCall\":0,\"videoCall\":1}",
      "etime": 2724710400000,
      "targetCnt": 3,
      "targetUids": [1001, 2002, 3003],
      "cursor": 10800,
      "extendData": ""
    }
  ]
}
```

**客户端动作**

- 解析 `content` JSON 更新本地"商户级功能开关"全局缓存
- 通常需要静默拉一次 `/sdk/config` 等接口校准

---

#### ㊿ `admin_notify_config_assistant` — 配置管理-小助手修改

- **触发**：NotifyType=11
- **承载**：`systemMsgDao`
- **接收方**：商户全部有效用户

**关键字段**：同 `admin_notify_business_switch`，`content` = `ConfigAssistantInfo.Content`（小助手的新配置，JSON 字符串）

**📨 完整帧示例**

```json
{
  "msgDao": [],
  "systemMsgDao": [
    {
      "appkey": "kk_xxx",
      "tid": "",
      "mid": ":802",
      "tmid": 802,
      "sender": 1000,
      "stime": 1714680061000,
      "sevent": "admin_notify_config_assistant",
      "type": "admin_notify_config_assistant",
      "content": "{\"welcome\":\"欢迎使用\",\"quickReply\":[\"你好\",\"再见\"]}",
      "etime": 2724710400000,
      "targetCnt": 3,
      "targetUids": [1001, 2002, 3003],
      "cursor": 10801,
      "extendData": ""
    }
  ]
}
```

**客户端动作**

- 解析 `content` 更新本地"小助手配置"（欢迎语 / 快捷回复 等）

---

#### ⓢ `admin_notify_device_info` — 设备信息变更（多端同步）

- **触发**：NotifyType=12（管理员或风控修改了用户的设备记录）
- **承载**：`systemMsgDao`
- **接收方**：被改用户 uid（多端）

**关键字段**

| 字段 | 含义 |
|---|---|
| `sender` | 被改用户 uid（覆盖 103） |
| `tid` | `s_103_<userId>` |
| `appkey` | `"000000"` |
| `content` | `DeviceChangeInfo.DeviceInfo` 字符串（业务侧自定义 schema，常见为 JSON） |
| `targetUids` | `[userId]` |

**📨 完整帧示例**

```json
{
  "msgDao": [],
  "systemMsgDao": [
    {
      "appkey": "000000",
      "tid": "s_103_1001",
      "mid": "s_103_1001:9",
      "tmid": 9,
      "sender": 1001,
      "stime": 1714680065000,
      "sevent": "admin_notify_device_info",
      "type": "admin_notify_device_info",
      "content": "{\"deviceId\":\"abc-50-bit-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"status\":\"removed\"}",
      "etime": 2724710400000,
      "targetCnt": 1,
      "targetUids": [1001],
      "cursor": 10900,
      "extendData": ""
    }
  ]
}
```

**客户端动作**

- 解析 `content` 取 `deviceId / status`，本地刷新"已登录设备列表"
- 若当前设备 `deviceId` 命中且 `status=removed`：清 token + 强制下线

---

#### ⓣ `system_notify_event` — 平台广播（跨商户）

- **触发**：`PlatformNotify` 在 announcement.scene=3（全平台）/4（指定商户列表）时，对每个商户调用 `BusinessNotify("0", appkey, nil, msg, "system_notify_event", scene)`
- **承载**：`msgDao(type=txt)`（走 `BusinessNotify`）
- **接收方**：
  - `scene=3`：所有商户所有用户（`QryAllBusiness` → 每商户 `QryUsersByAppkey`）
  - `scene=4`：指定 businessIds 的商户 → 每商户全部用户

**关键字段**

| 字段 | 含义 |
|---|---|
| `sender` | `1000`（`RobotID_System`，由 `ChannelManager.NewMsg` 写死） |
| `tid` | `s_<RobotID_System>_<userId>`（scene=3/4 走 system 机器人 topic） |
| `appkey` | 当前迭代商户的 appkey |
| `type` | `"txt"` |
| `content` | `Announcement.Msg[0].Content`（平台公告正文） |
| `targetUids` | `[userId]` |

**📨 完整帧示例**

```json
{
  "msgDao": [
    {
      "appkey": "kk_xxx",
      "tid": "s_10000002_1001",
      "mid": "s_10000002_1001:1001",
      "tmid": 1001,
      "sender": 1000,
      "stime": 1714680070000,
      "sevent": "system_notify_event",
      "type": "txt",
      "content": "【平台公告】关于近期版本升级的说明……",
      "encrypt": 0,
      "visible": 0,
      "mask": 0,
      "etime": 2724710400000,
      "targetCnt": 1,
      "targetUids": [1001],
      "auditRst": "",
      "restrict": false,
      "rtcId": 0,
      "duration": 0,
      "cursor": 11000,
      "extendData": ""
    }
  ],
  "systemMsgDao": []
}
```

**客户端动作**

- 与 `assistant_notify_event` 类似，进 **"系统通知会话"** 渲染（`tid` 前缀 `s_<RobotID_System>_*`）
- 走 `_onMsg` switch
- 通常额外触发一次站内公告中心刷新

**注意**

- `assistant_notify_event` vs `system_notify_event` 区分：
  - `assistant_notify_event` 是商户内小助手（scene=0/1），`tid` 走 `RobotID_Assistant`
  - `system_notify_event` 是平台级（scene=3/4），`tid` 走 `RobotID_System`
- 客户端可仅以 sevent 字符串区分，不必依赖 sender / tid 解析


---

## 6. 客户端通用处理规范

### 6.1 帧处理流水线

```
WS frame
  ├─ jsonDecode
  ├─ 遍历 msgDao + systemMsgDao
  │    ├─ 批内 dedup by mid
  │    ├─ 写库（批量 upsert）
  │    ├─ 累计 maxChatCursor / maxSystemCursor
  │    ├─ 路由到 handler（按 sevent）
  │    └─ 通知 UI（debounced）
  └─ 报 cursor（debounce 500ms / batch ≥ 50）
```

### 6.2 必须实现的 5 件事

| # | 项 | 后果 |
|---|---|---|
| 1 | **cursor 上报**（`/sdk/msg/report`） | 否则离线消息无限堆积 |
| 2 | **批内 mid 去重** | 否则 UI 双闪、会话列表错乱 |
| 3 | **`user_kick` 强制下线** | 否则被踢账号继续发消息（合规风险） |
| 4 | **断线重连 → 重新 preconnect** | 否则用旧 wsid 永远握手失败 |
| 5 | **收到任何帧刷新最近一次"接收时间"** | 用于客户端心跳超时检测 |

### 6.3 推荐：启动同步 + 增量推送双保险

```
App 启动
  ├─ /v1/sdk/sync/topic    （分页拉会话）
  ├─ /v1/sdk/sync/friend   （分页拉好友）
  ├─ /v1/sdk/sync/group    （分页拉群）
  ├─ /v1/sdk/sync/label    （分页拉标签）
  ├─ /v1/sdk/group/apply/list、/v1/sdk/friend/apply/list  （申请红点）
  └─ ws connect    （之后增量靠推送 + cursor）
```

**为什么必须**：长时间离线后服务端 OfflineMsg 可能被清理，纯靠 ws 补推会丢数据。

### 6.4 心跳

- 服务端每 27s 发 `PingMessage` 控制帧
- web_socket_channel 默认会**自动响应 Pong** —— 不需要客户端手写
- 客户端**自身**应有"超过 60s 没收到任何帧" → 主动断 + 重连的兜底

### 6.5 UI 提示文案表

| 事件 | 建议文案模板 |
|---|---|
| `friend_apply_deal` pass | 「{昵称} 同意了你的好友申请」 |
| `friend_apply_deal` refuse | 「{昵称} 拒绝了你的好友申请」 |
| `group_apply_reject` | content 字段（已含文案） |
| `group_kick`(对己) | 「你已被移出群聊」 |
| `group_delete` | 「群主解散了该群」 |
| `msg_mask` | 「{昵称} 撤回了一条消息」 |
| `user_kick` | 「你的账号在其他设备登录」 |
| `user_deleted` | 「账号已被注销」 |

---

## 7. 已知坑点（重点关注）

| # | 现象 | 影响 | 客户端规避 |
|---|---|---|---|
| 1 | `friend_delete` 对端不收 ws | B 端列表残留已删好友 | 启动 sync_friend 校准 + 发消息失败时本地清理 |
| 2 | `user_passwd_changed` 复用 `user_kick` | 改密本端无法静默切 token | 改密时本地标 `expectingKick=true` |
| 3 | `group_admin_set_*` extendData 不含完整 admin | UI 错乱 | 收到事件**强制拉群详情** |
| 4 | `group_apply_accept` 是 msg.tip 不是 sysMsg | 走错 switch 分支 | 在 `_onMsg` 中也判 sevent |
| 5 | `group_delete` 双发（msg.tip + sysMsg） | 双气泡 | 按 (tid, sevent) 去重 |
| 6 | 单聊 `topic_create` 不通知 | 多端会话不同步 | 发首条消息时本地建会话 |
| 7 | `msg_read` 群已读不聚合 | 100 人 = 100 条 sysMsg | 增量合并 readUids |
| 8 | `report_received` chat / system cursor 共用计数器 | chat ack 可能误清 system msg | 服务端待修；客户端建议 `chatCursor=systemCursor=max(两者)` 一起报 |
| 9 | 离线积压可能 > 1MB | Kafka 单帧上限/UI 卡顿 | 解析后异步分批写 DB |
| 10 | 服务端 wsid 路由依赖 redis，机器重启可能脏数据 | 偶发收不到推送 | 心跳超时主动重连 |
| 11 | `user_kick` cursor 在 InsertSysMsg 之后才赋值 | DB 中 cursor=0，下发的 cursor 与 DB 不一致 | 客户端按下发值处理即可，不影响功能 |
| 12 | `topic_modify`/`topic_remark` 的 meta 实际不下发 | 收到通知拿不到新 remark 内容 | 收到事件后调 topic/detail 拉最新 |
| 13 | `friend_apply_list` 的 `type` 字段写成 `friend_apply`（与 sevent 不一致） | 误触发"收到新申请"提示 | 始终以 `sevent` 为准，不要看 type |
| 14 | `admin_notify_*` 后台事件的 `sender` 在代码里 `WithSenderId(103) ... WithSenderId(userId)` 双调用，最终 `sender = userId` 而非 103 | 前端按 sender 路由会错把后台事件当成自己发的消息 | 用 `sevent` 前缀判定（`admin_` / `assistant_notify_` / `system_notify_`），不依赖 sender |
| 15 | `admin_disband_group_message` 与 `admin_disband_group_notify` 通常同一次后台动作 **双发** | 双气泡 | 按 (tid, sevent 前缀=admin_disband) 去重 |
| 16 | `admin_notify_real_translate` / `admin_notify_business_switch` / `admin_notify_config_assistant` 的 `tid` 为空 | 客户端按 tid 入会话会失败 | 这三个直接落到全局配置，不要尝试塞到任何会话 |
| 17 | `device_change.go` 中 `DeviceChangeHandler` 实际写入 `sevent = msg_local_delete`（疑似复制 msg_local_delete 后忘改），且没有 API 路由调用它 | 暂无下发，但代码留有陷阱 | 真要做"设备变更通知"应走 `admin_notify_device_info`；`device_change` 这个常量目前是死代码 |

---

## 附录 A. msg.type 与 sevent 对照

`msg.type` 描述消息内容类型（与 UI 渲染相关），`sevent` 描述社交事件（与业务逻辑相关）。

| msg.type | 描述 | content 含义 |
|---|---|---|
| `txt` | 文本 | 文本（可能加密 base64） |
| `tip` | 系统提示 | 自由文本，常含"加群/退群/撤回"等 |
| `img` | 图片 | URL 或 OSS key |
| `file` | 文件 | URL + 元数据在 meta |
| `voice` | 语音 | URL + duration in meta |
| `video` | 视频 | URL + thumbnail in meta |
| `voice_rtc` | RTC 语音通话 | rtcId 主导，duration=通话时长 |
| `video_rtc` | RTC 视频通话 | 同上 |
| `talk` | 转发合并消息 | 嵌套 JSON |
| `coupon` | 红包 | 红包 id + 金额 in meta |
| `quan5_prod` | 全屋产品（业务定制） | — |
| `quan5_order` | 全屋订单（业务定制） | — |
| `msg_mask` | （仅在 SysMsgDao） | content = 被撤回的 mid |
| `msg_delete` | （仅在 SysMsgDao） | content = 被删的 mid |
| `msg_local_delete` | （仅在 SysMsgDao） | content = `JSON.stringify(被删 mid 数组)` |
| `friend_apply` | （仅在 SysMsgDao；亦由 `friend_apply_list` 复用） | content 为空 |
| `group_apply_notify` | （仅在 SysMsgDao） | content = `JSON.stringify(ApplyInfo[])` |
| `admin_notify_*` | （仅在 SysMsgDao） | content = 后台 `AdminNotifyInfo.Message` 原文，按事件具体反序列化 |
| `admin_disband_group_notify` | （仅在 SysMsgDao） | content = `DisbandGroupInfo` JSON |
| `admin_disband_group_message` | `tip` | 固定文案 `后端管理员解散了群聊` |
| `assistant_notify_event` / `system_notify_event` | `txt` | 平台/小助手公告正文（纯文本） |

---

## 附录 B. extendData 已知 schema 汇总

| 事件 | extendData 示例 |
|---|---|
| `friend_apply` | `""` |
| `friend_apply_deal` (pass) | `"pass"` |
| `friend_apply_deal` (refuse) | `"refuse"` |
| `friend_delete` | `""` |
| `friend_label_add` 🆕 | `""`（载荷在 content：`{"Uids":[...],"Label":"...","NType":...}`） |
| `friend_label_delete` 🆕 | `""`（载荷同上） |
| `friend_label_modify` 🆕 | `""`（载荷在 content：`{"OldLabel":"...","NewLabel":"..."}`） |
| `userinfo_set` | `""`（用户数据在 content） |
| `user_remark` | `{"uid": 2002, "remark": "老王"}` |
| `user_kick` | `""`（被踢 token 在 content） |
| `user_deleted` | `""` |
| `group_apply` | `""` |
| `group_apply_accept` | `"pass"` |
| `group_apply_reject` | `"refuse"` |
| `group_admin_set_on/off` | `{"AdminIds": [2002]}` |
| `group_set_memberalias` | `""` |
| `group_kick` | `""`（msg 中由 channel 注入 sender 信息） |
| `group_delete` | `""`（msg 中由 channel 注入 sender 信息） |
| `topic_modify` / `topic_remark` | `""`（remark 实际不下发，详见坑点 #12） |
| `msg_send_to_user/group` | `{"sender": {"userId":1001, "userNick":"小爱", "avatar":"..."}}` |
| `msg_mask` / `msg_delete` | `""`（mid 在 content） |
| `msg_read` | `""`（已读 mid 在 content） |
| `msg_local_delete` 🆕 | `""`（被删 mid 在 content 数组） |
| `friend_apply_list` 🆕 | `""` |
| `group_apply_notify` 🆕 | `""`（ApplyInfo 列表在 content） |
| `admin_notify_*` / `admin_disband_group_notify` 🆕 | `""`（载荷在 content） |
| `admin_disband_group_message` 🆕 | 由 channel 注入 sender 信息（与普通 group tip 同 schema） |
| `assistant_notify_event` / `system_notify_event` 🆕 | `""` |

**通用 sender 注入结构**（`msg_send_to_user/group` 等聊天消息）：

```json
{
  "sender": {
    "userId": 1001,
    "userEmployee": "Alice",
    "userNick": "小爱",
    "userRemark": "",
    "groupName": "研发周会",
    "groupRemark": "",
    "groupUserNick": "小爱(组长)",
    "avatar": "https://cdn.example.com/u/1001.jpg"
  },
  "targets": null
}
```

---

## 附录 C. 错误状态与重连

### C.1 ws 连接失败 / 中断的常见原因

| close code / 错误 | 含义 | 客户端动作 |
|---|---|---|
| 1000 normal | 用户主动断 | 不重连 |
| 1001 going away | 服务端关 / app 切后台 | 重连前重新 preconnect |
| 1006 abnormal | 网络断 / 心跳超时 | 指数退避重连 |
| 4xxx 业务码（若有） | wsid 失效 / token 失效 | 立即重新登录 + preconnect |

### C.2 推荐重连策略

```
失败次数:  1     2     3     4      5      6+
延时:     5s   10s   20s   40s    80s    冷却 5min
抖动:     ±20% jitter on each
preconnect: 每次重连前都重新调
```

### C.3 token 失效路径

- HTTPS 业务接口返回 401/403/特定 code → 触发统一登出
- ws 收到 `user_kick` 且 content == 当前 token → 同上

---

## 附录 D. 后端代码索引

| 内容 | 路径 |
|---|---|
| 事件 handler | [bm/imsvr/eventservice/socialevent/](bm/imsvr/eventservice/socialevent/) |
| 推送编排 / 在线判定 / OfflineMsg | [bm/imsvr/eventservice/channel/channel.go](bm/imsvr/eventservice/channel/channel.go) |
| ws 网关 Send / Preconnect | [ws/internal/rpc/server/ws_server.go](ws/internal/rpc/server/ws_server.go) |
| ws 连接管理 | [ws/internal/wsconn/](ws/internal/wsconn/) |
| MsgDao 模型 | [bm/imsvr/models/msg.go](bm/imsvr/models/msg.go) |
| SysMsgDao 模型 | [bm/imsvr/models/sysmsg.go](bm/imsvr/models/sysmsg.go) |
| 业务 API | [bm/imsvr/api/](bm/imsvr/api/) |
| 后台事件 Kafka 消费 🆕 | [bm/imsvr/wskafka/consumer/admin_consumer.go](bm/imsvr/wskafka/consumer/admin_consumer.go) |
| 后台事件 channel 推送（BusinessNotify / PlatformNotify）🆕 | [bm/imsvr/eventservice/channel/admin_notify.go](bm/imsvr/eventservice/channel/admin_notify.go) |
| 本地消息删除 handler 🆕 | [bm/imsvr/eventservice/socialevent/msg_local_delete.go](bm/imsvr/eventservice/socialevent/msg_local_delete.go) |
| 申请列表同步 handler 🆕 | [bm/imsvr/eventservice/socialevent/friend_apply_list.go](bm/imsvr/eventservice/socialevent/friend_apply_list.go) |
| 入群审核通知（HTTP 内直发）🆕 | [bm/imsvr/api/sdk_group.go](bm/imsvr/api/sdk_group.go)（`GroupApplyJoin`） |

---

**文档版本**: 1.4（APP 端对接版 + 完整示例 + 后台事件章节）
**对应分支**: `dev_ws`
**最后更新**: 2026-05-26

**v1.3 变更**：新增 4 个 `friend_label_*` 事件；澄清 `msg_read` 与后端 `report_read` EventType 的关系（参见总表脚注 §）。

**v1.4 变更**：补全代码已实现但未文档化的 18 个事件 —— 直接事件 3 个（`msg_local_delete` / `friend_apply_list` / `group_apply_notify`），后台事件 15 个（`admin_notify_*` / `admin_disband_*` / `assistant_notify_event` / `system_notify_event`），新增 §5.7 章节并扩展事件总表至 53 个；switch 模板加防御层；新增坑点 #13–#17。
