# WebSocket 事件协议

> 通用规范（认证方式、响应格式、错误码）见 [README.md](README.md)
> 本文档定义 EchoChat 系统中所有 WebSocket 实时通信事件。

---

## 连接说明

### 连接地址

| 环境 | 地址 |
|------|------|
| 开发环境 | `ws://localhost:8080/ws?token=<access_token>` |
| 生产环境 | `wss://api.echochat.com/ws?token=<access_token>` |

### 连接认证

通过 URL 查询参数 `token` 携带 JWT Access Token，服务端验证通过后建立连接。

### 心跳机制

- 客户端每 **30 秒** 发送一次 ping 帧
- 服务端响应 pong 帧
- 如果 **90 秒** 内未收到客户端心跳，服务端主动断开连接

### 断线重连

- 客户端检测到连接断开后自动重连
- 重连间隔采用指数退避：1s → 2s → 4s → 8s → 16s → 最大 30s
- 重连成功后拉取离线消息

---

## 消息格式

### 客户端发送格式

```json
{
    "event": "im.message.send",
    "seq": 1001,
    "data": { ... },
    "time": "2026-02-27 18:06:40"
}
```

| 字段 | 类型 | 说明 |
|------|------|------|
| event | string | 事件名称，格式：`{模块}.{对象}.{动作}` |
| seq | int | 消息序列号，客户端自增，用于匹配请求和响应 |
| data | object | 事件数据 |
| time | string | 发送时间，格式：`yyyy-MM-dd HH:mm:ss`，时区 Asia/Shanghai |

### 服务端响应格式（ACK）

```json
{
    "event": "im.message.send.ack",
    "seq": 1001,
    "code": 0,
    "message": "ok",
    "data": { "msg_id": 10086 }
}
```

### 服务端推送格式

```json
{
    "event": "im.message.new",
    "data": { ... },
    "time": "2026-02-27 18:06:40"
}
```

推送类消息没有 seq 字段（不需要客户端确认）。

---

## 即时通讯事件

### im.message.send

**方向：** 客户端 → 服务端

**说明：** 发送消息到会话。`conversation_id` 和 `target_user_id` 二选一：首次发消息使用 `target_user_id`（自动创建会话），后续使用 `conversation_id`。

**data 参数：**

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| conversation_id | int | 否 | 已有会话 ID（与 target_user_id 二选一） |
| target_user_id | int | 否 | 对方用户 ID（首次发消息时使用） |
| type | int | 是 | 消息类型：1=文本 |
| content | string | 是 | 文本内容 |
| client_msg_id | string | 否 | 客户端消息唯一 ID，用于幂等去重 |

**ACK 响应 data：**

```json
{
    "id": 10086,
    "conversation_id": 1,
    "sender_id": 1,
    "type": 1,
    "content": "你好",
    "status": 1,
    "client_msg_id": "xxxx-xxxx",
    "created_at": "2026-03-03 10:30:00"
}
```

---

### im.message.new

**方向：** 服务端 → 客户端（推送）

**说明：** 收到新消息推送

**data 内容：**

```json
{
    "id": 10086,
    "conversation_id": 1,
    "sender_id": 2,
    "sender_name": "李四",
    "sender_avatar": "https://...",
    "type": 1,
    "content": "你好",
    "client_msg_id": "",
    "created_at": "2026-03-03 10:30:00"
}
```

---

### im.message.recall

**方向：** 客户端 → 服务端

**说明：** 撤回消息（发送后 2 分钟内）。撤回成功后若该消息是会话最后一条，会同步更新会话预览为"XX 撤回了一条消息"。

**data 参数：** `{ "message_id": 10086 }`

---

### im.message.recalled

**方向：** 服务端 → 客户端（推送）

**说明：** 消息被撤回通知

**data 内容：** `{ "message_id": 10086, "conversation_id": 1, "sender_id": 2 }`

---

### im.conversation.read

**方向：** 客户端 → 服务端

**说明：** 标记会话已读（清零未读数 + 更新 Redis 全局未读数）

**data 参数：** `{ "conversation_id": 1 }`

---

### im.typing

**方向：** 双向（客户端发送 → 服务端转发给对方）

**说明：** 正在输入通知。客户端发送后服务端转发给对方，前端收到后设置 3 秒超时自动清除。

**data 参数（客户端发送）：** `{ "conversation_id": 1 }`

**data 内容（服务端推送）：** `{ "conversation_id": 1, "user_id": 2 }`

---

### im.offline.sync

**方向：** 服务端 → 客户端（推送）

**说明：** WebSocket 连接成功后服务端主动推送离线未读摘要

**data 内容：**

```json
{
    "total_unread": 5,
    "conversations": [
        {
            "conversation_id": 1,
            "unread_count": 3,
            "last_msg_content": "你好",
            "last_msg_time": "2026-03-03 10:30:00"
        }
    ]
}
```

---

## 会议信令事件（Phase 2e-2 Task 6 已落地；Task 7 起 mediasoup 返回值为真实值）

> **SSOT**：会议相关的全部 WS 事件详细契约（请求/ACK/广播载荷、权限、错误处理、资源追踪）见 [`docs/api/frontend/meeting.md`](./frontend/meeting.md) §WebSocket 信令协议。本节仅列事件总览。

| # | 方向 | 事件 | 用途 |
|---|------|------|------|
| 1 | C→S | `meeting.room.join` | 声明加入某会议 WS 频道（需已在 REST 层完成 `/join`）|
| 2 | C→S | `meeting.room.leave` | 离开 WS 频道并清理媒体资源（不改 participant 表）|
| 3 | C→S | `meeting.member.state.changed` | 更新自己的 audio/video/hand_raised；host 可指定 `target_user_id` 强制静音他人 |
| 4 | C→S | `meeting.transport.create` | 创建 mediasoup WebRtcTransport（`direction: send/recv`）|
| 5 | C→S | `meeting.transport.connect` | 提交 DTLS Parameters |
| 6 | C→S | `meeting.produce.start` | 创建 Producer |
| 7 | C→S | `meeting.consume.start` | 创建 Consumer |
| 8 | C→S | `meeting.producer.close` | 关闭自己的 Producer |
| 9 | C→S | `meeting.consume.resume` | 客户端完成 track 挂载后请求 resume Consumer（Task 9 新增，不广播）|
| 10 | S→C | `meeting.member.joined` | 新成员加入广播（REST /join 触发）|
| 11 | S→C | `meeting.member.left` | 成员离开广播（REST /leave /kick 或 WS 资源清理）|
| 12 | S→C | `meeting.member.kicked` | 定向通知被踢者 |
| 13 | S→C | `meeting.host.changed` | 主持人变更 |
| 14 | S→C | `meeting.room.ended` | 会议被结束 |
| 15 | S→C | `meeting.member.state.changed` | 成员状态变化广播 |
| 16 | S→C | `meeting.member.producer.new` | 成员开启/关闭媒体流（`closed=true` 表示关闭）|
| 17 | S→C | `meeting.chat` | 会议内聊天（REST /chats 触发）|

### 协议约定

- **C→S 白名单**：客户端仅可发起上表 C→S 列的 9 个事件（Task 9 扩到 9），其余 `meeting.*` 事件若由客户端发送被静默丢弃。
- **ACK 规则**：每个 C→S 事件服务端必回 `<event>.ack`；成功 `code=0`，业务失败 `code=-1` + 中文 `message`（与 REST 领域错误口径一致）。
- **错误码示例**：`会议不存在` / `你当前未在会议中` / `仅主持人可执行此操作` / `会议已结束`。
- **资源追踪**：服务端对每用户在每会议的 transport/producer/consumer 用 Redis Set `echo:meeting:resources:{room_id}:{user_id}` 记录，WS 断开或 `room.leave` 时自动清理。
- **真实 mediasoup（Task 7 起）**：`meeting.transport.create` ACK 的 `id` / `iceParameters` / `iceCandidates` / `dtlsParameters`，以及 `meeting.produce.start` / `meeting.consume.start` 的 ID 均来自 Node media-server 真实 mediasoup Worker；Node 404（如关闭不存在 producer）在 Go 侧幂等转 `code=0`。

---

## 用户状态事件

### user.status.online

**方向：** 服务端 → 客户端

**说明：** 好友上线通知

**data 内容：** `{ "user_id": 2, "nickname": "李四" }`

---

### user.status.offline

**方向：** 服务端 → 客户端

**说明：** 好友离线通知

**data 内容：** `{ "user_id": 2 }`

---

## 通知事件

### notify.new

**方向：** 服务端 → 客户端

**说明：** 新通知推送（通用）

**data 内容：** 与 Notify API 获取通知列表中的单条通知格式一致

---

### notify.meeting.invite

**方向：** 服务端 → 客户端

**说明：** 会议邀请推送

**data 内容：**
```json
{
    "room_code": "123-456-789",
    "title": "产品需求讨论",
    "from_user_id": 1,
    "from_nickname": "张三"
}
```

---

### notify.friend.request

**方向：** 服务端 → 客户端

**说明：** 好友申请推送

**data 内容：**
```json
{
    "friendship_id": 5,
    "from_user_id": 2,
    "from_nickname": "李四",
    "from_avatar": "https://...",
    "message": "我是你的同事"
}
```

---

## 群聊事件（Phase 2c）

### im.group.read

**方向：** 客户端 → 服务端

**说明：** 标记群聊消息已读（消息级已读回执）

**data 内容：**
```json
{
    "conversation_id": 100,
    "message_ids": [501, 502, 503]
}
```

**ACK 响应：** `{ "code": 0, "message": "ok" }`

---

### group.created

**方向：** 服务端 → 客户端

**说明：** 群聊创建成功，推送给所有初始成员

**data 内容：**
```json
{
    "group_id": 10,
    "conversation_id": 100,
    "name": "项目讨论组",
    "owner_id": 1
}
```

---

### group.info.update

**方向：** 服务端 → 客户端

**说明：** 群信息更新（名称、头像、公告等），推送给所有群成员

**data 内容：**
```json
{
    "group_id": 10,
    "conversation_id": 100,
    "operator_id": 1
}
```

---

### group.dissolved

**方向：** 服务端 → 客户端

**说明：** 群聊已解散，推送给所有群成员

**data 内容：**
```json
{
    "group_id": 10,
    "conversation_id": 100,
    "operator_id": 1
}
```

---

### group.member.join

**方向：** 服务端 → 客户端

**说明：** 新成员加入群聊（邀请加入或审批通过），推送给所有群成员

**data 内容：**
```json
{
    "group_id": 10,
    "conversation_id": 100,
    "user_ids": [5, 6]
}
```

---

### group.member.kicked

**方向：** 服务端 → 客户端

**说明：** 成员被移出群聊，推送给所有群成员

**data 内容：**
```json
{
    "group_id": 10,
    "conversation_id": 100,
    "user_id": 5,
    "operator_id": 1
}
```

---

### group.member.leave

**方向：** 服务端 → 客户端

**说明：** 成员主动退出群聊，推送给其余群成员

**data 内容：**
```json
{
    "group_id": 10,
    "conversation_id": 100,
    "user_id": 5
}
```

---

### group.role.update

**方向：** 服务端 → 客户端

**说明：** 成员角色变更（设为/取消管理员），推送给所有群成员

**data 内容：**
```json
{
    "group_id": 10,
    "conversation_id": 100,
    "user_id": 5,
    "new_role": 1,
    "operator_id": 1
}
```

> `new_role`：0=普通成员，1=管理员，2=群主

---

### group.mute.update

**方向：** 服务端 → 客户端

**说明：** 禁言状态变更（个人禁言或全体禁言），推送给所有群成员

**data 内容（个人禁言）：**
```json
{
    "group_id": 10,
    "conversation_id": 100,
    "user_id": 5,
    "is_muted": true,
    "operator_id": 1
}
```

**data 内容（全体禁言）：**
```json
{
    "group_id": 10,
    "conversation_id": 100,
    "is_all_muted": true,
    "operator_id": 1
}
```

---

### group.owner.transfer

**方向：** 服务端 → 客户端

**说明：** 群主转让，推送给所有群成员

**data 内容：**
```json
{
    "group_id": 10,
    "conversation_id": 100,
    "old_owner_id": 1,
    "new_owner_id": 5
}
```

---

### group.join.request

**方向：** 服务端 → 客户端

**说明：** 新的入群申请，推送给群主和管理员

**data 内容：**
```json
{
    "group_id": 10,
    "request_id": 20,
    "user_id": 8,
    "user_nickname": "新用户",
    "message": "请让我加入"
}
```
