# 会议模块 API (Meeting) — Phase 2e-2 MVP

> 通用规范（认证、响应包络、通用错误码）见 [README.md](../README.md)
> 会议内实时信令（Transport / Producer / Consumer / 控制事件）通过 WebSocket 完成，见 [websocket.md](../websocket.md)

**实施状态**：本文档对应 Phase 2e-2 Task 5 / Task 6 / Task 7 / Task 9 已落地的 12 个 REST 接口 + **14 个** WebSocket 信令事件（Task 9 新增 `meeting.consume.resume`），统一前缀 `/api/v1/meeting`（REST）与 `/ws`（WebSocket），全部需要 JWT 认证。Task 5/6/7/9 完成时间：2026-04-21。自 Task 7 起 **Go 后端直连 Node media-server**，`transport.id` / `iceCandidates` / `dtlsParameters.fingerprints` 等字段均由真实 mediasoup 返回（不再是 stub 占位）；自 Task 9 起 `CreateRoom` / `JoinRoom` 响应同时返回 `router_id` + `rtp_capabilities`，供前端 `mediasoupClient.Device.load()` 直接初始化。

**设计口径**：以 [`docs/plans/2026-04-21-phase2e-2-design.md`](../../plans/2026-04-21-phase2e-2-design.md) §6.2 为单一事实来源（SSOT）。

---

## 路径总览

| # | 方法 | 路径 | 业务 | 权限 |
|---|------|------|------|------|
| 1 | POST | `/api/v1/meeting/rooms` | 创建即时会议 | 已登录且当前不在其他活跃会议 |
| 2 | GET  | `/api/v1/meeting/rooms/mine` | 我发起/参与过的最近会议 | 已登录 |
| 3 | GET  | `/api/v1/meeting/rooms/:code` | 会议详情 + 成员列表 | 当前活跃参会者 |
| 4 | POST | `/api/v1/meeting/rooms/:code/join` | 加入会议 | 已登录 |
| 5 | POST | `/api/v1/meeting/rooms/:code/leave` | 离开会议 | 当前活跃参会者 |
| 6 | POST | `/api/v1/meeting/rooms/:code/end` | 结束会议 | host |
| 7 | POST | `/api/v1/meeting/rooms/:code/transfer-host` | 转让主持人 | host |
| 8 | POST | `/api/v1/meeting/rooms/:code/kick` | 移除成员 | host |
| 9 | POST | `/api/v1/meeting/rooms/:code/invite` | 邀请用户（走通知中心） | 当前活跃参会者 |
| 10 | POST | `/api/v1/meeting/invite-tokens/:token/redeem` | 兑换邀请链接 | 已登录 |
| 11 | POST | `/api/v1/meeting/rooms/:code/chats` | 发送会议内文字消息 | 当前活跃参会者 |
| 12 | GET  | `/api/v1/meeting/rooms/:code/chats` | 拉取会议历史消息（游标分页） | 当前活跃参会者 |

**路径参数约定**：`:code` 为用户可见的 9 位会议号 `XXX-XXX-XXX`；`:token` 为 32 位十六进制邀请令牌。

---

## 通用约定

### 响应包络

成功：

```json
{ "code": 0, "message": "success", "data": { ... }, "trace_id": "...", "time": "2026-04-21 16:30:00" }
```

失败：

```json
{ "code": 400, "message": "会议号或密码错误", "trace_id": "...", "time": "2026-04-21 16:30:00" }
```

其中 HTTP 状态码与 `code` 一致：`200 OK` / `201 Created` / `400 Bad Request` / `403 Forbidden` / `404 Not Found` / `500 Internal Server Error`。

### 领域错误码映射

所有领域错误以 `message` 中文字面量为准，由 controller 层 `handleError` 统一映射：

| HTTP | 领域错误 | 触发场景 |
|------|----------|---------|
| 404 | `会议不存在` | `:code` 无匹配记录 |
| 403 | `仅主持人可操作` | 非 host 调用 end / transfer-host / kick |
| 400 | `会议已结束` | 会议 status=2 时再次操作 |
| 400 | `会议已满员` | 活跃参会人数 ≥ `max_members` |
| 400 | `会议需要密码` | 房间设密但请求未携带 |
| 400 | `会议密码错误` | bcrypt 校验失败 |
| 400 | `密码尝试过多，请稍后再试` | 同 `(user, code)` 5 次内错，Redis 锁 10 分钟 |
| 400 | `你当前未在会议中` | 操作要求活跃参会者但调用方 left_at 非空 |
| 400 | `你已在会议中` | 已活跃时重复 join |
| 400 | `你当前已在其他会议中` | 用户已在其它活跃会议中，违反单点参会 |
| 400 | `邀请链接已失效` | redis key 过期或被兑换 |
| 400 | `会议号冲突，请重试` | 生成房间号 5 次重试仍冲突（理论无发生） |
| 400 | `不能踢自己` | kick 目标为自己 |
| 400 | `不能将主持人转让给自己` | transfer-host 目标为自己 |
| 400 | `目标用户不是当前活跃参会者` | transfer-host / kick 对象 left_at 非空 |
| 500 | `{fallbackMsg}` + 原 error | 未识别异常（DB 故障等） |

---

## 1. 创建即时会议

`POST /api/v1/meeting/rooms`

**请求体**

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| title | string | 是 | 会议标题，1~200 字符 |
| password | string | 否 | 入会密码明文，4~20 字符；服务端 bcrypt 哈希存储 |
| max_members | int | 否 | 容量上限，2~8（MVP 硬上限 8，超过将被截断为 8） |

**响应 `201 Created`**

```json
{
  "code": 0,
  "data": {
    "room": {
      "id": 12,
      "room_code": "835-000-036",
      "title": "T5 验证会议",
      "host_id": 16,
      "type": 1,
      "has_password": false,
      "max_members": 4,
      "status": 1,
      "status_label": "进行中",
      "started_at": "2026-04-21 16:30:10",
      "settings": "{}",
      "created_at": "2026-04-21 16:30:10",
      "online_count": 1
    }
  }
}
```

**错误**：`你当前已在其他会议中` (400) / `会议号冲突，请重试` (400)。

---

## 2. 我的会议列表

`GET /api/v1/meeting/rooms/mine`

**查询参数**

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| status | int | 否 | 过滤状态：0=未开始 / 1=进行中 / 2=已结束；不传=全部 |
| before_id | int64 | 否 | 游标：仅返回 id < before_id 的记录 |
| limit | int | 否 | 页大小，默认 20，最大 50 |

**响应 `200 OK`**

```json
{
  "code": 0,
  "data": {
    "list": [
      { "id": 12, "room_code": "835-000-036", "title": "T5 验证会议", "status": 1, ... }
    ],
    "has_more": false
  }
}
```

**说明**：返回 host + 参会者两类记录合并后的最近会议，按 `id DESC` 排序。

---

## 3. 会议详情

`GET /api/v1/meeting/rooms/:code`

**权限**：调用方必须是该会议的当前活跃参会者（host 或 participant，left_at IS NULL）。

**响应 `200 OK`**

```json
{
  "code": 0,
  "data": {
    "room": { "id": 12, "room_code": "835-000-036", ... },
    "participants": [
      { "id": 30, "room_id": 12, "user_id": 16, "role": 1, "role_label": "主持人", "is_active": true, "joined_at": "...", "duration": 0 },
      { "id": 31, "room_id": 12, "user_id": 17, "role": 0, "role_label": "参会者", "is_active": true, "joined_at": "...", "duration": 0 }
    ],
    "online_count": 2
  }
}
```

**错误**：`会议不存在` (404) / `你当前未在会议中` (400)。

---

## 4. 加入会议

`POST /api/v1/meeting/rooms/:code/join`

**请求体**

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| password | string | 条件 | 房间 `has_password=true` 时必传 |

**校验顺序**：房间存在 → 未结束 → 单点参会（若已在其他活跃会议则 400）→ 密码锁定（5 次错误封禁 10 分钟）→ 密码校验 → 容量 → 写 participant → 广播 `meeting.member.joined`。

**首次加入**：创建 `meeting_participants` 新行。
**复入**（之前 left）：复用原行重置 `left_at=NULL`、`joined_at=now`、`duration=0`，避免审计表膨胀。

**响应 `200 OK`**

```json
{
  "code": 0,
  "data": {
    "room": { ... },
    "participant": { ... },
    "router_id": "abc-router-835-000-036",
    "rtp_capabilities": {
      "codecs": [ { "mimeType": "audio/opus", "clockRate": 48000, ... } ],
      "headerExtensions": [ ... ]
    }
  }
}
```

`router_id` 自 Task 7 起为 Node media-server 返回的真实 mediasoup Router ID（Phase 2e-2 Task 7 完成，2026-04-21）；`rtp_capabilities` 自 Task 9 起随响应一并返回（Phase 2e-2 Task 9，2026-04-21），供前端 `mediasoupClient.Device.load({ routerRtpCapabilities })` 初始化媒体协商上下文。前端 MVP 推荐流程：`joinRoom → Device.load(rtp_capabilities) → Transport 双建 → produce/consume`，整个链路不再需要额外 REST 往返。

> 同形响应体在 `POST /rooms`（创建即时会议）中也会返回 `router_id` + `rtp_capabilities`，创建者无需额外请求即可 `Device.load`。

---

## 5. 离开会议

`POST /api/v1/meeting/rooms/:code/leave`

**行为**：
- `participant.left_at = now`、`duration = EXTRACT(EPOCH FROM now - joined_at)`；
- 若离开者是 host 且仍有其他活跃成员 → 自动将 host 转让给**最早加入的活跃成员**，广播 `meeting.host.changed`（`auto_reason=host_left_with_members`）；
- **若房间无剩余活跃成员**（Task 8 变更）→ **不再立即销毁**，改为调 `MeetingLifecycleService.OnAllMembersLeft` 设置 `echo:meeting:empty_ttl:{code}`（默认 TTL 300s）+ 启动本地 `time.AfterFunc`；TTL 内若有新成员 `POST /join`，`MeetingLifecycleService.CancelEmptyTTL` 会 DEL key 让房间复活；TTL 过期触发 `HandleEmptyRoomExpired` → `MarkEnded(reason=empty_ttl)` + `mediaOrchestrator.CloseRouter` + 广播 `meeting.room.ended{reason=empty_ttl}`；
- 广播 `meeting.member.left`。

**响应 `200 OK`**

```json
{ "code": 0, "data": { "duration": 185 } }
```

---

## 6. 结束会议

`POST /api/v1/meeting/rooms/:code/end` — host 专用。

**行为**：所有活跃成员强制离会（`left_reason=host_end`），房间 `status=2 ended_reason=host_ended`，广播 `meeting.room.ended`，调用 `mediaOrchestrator.CloseRouter`。

**错误**：`仅主持人可操作` (403)。

---

## 7. 转让主持人

`POST /api/v1/meeting/rooms/:code/transfer-host`

**请求体**

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| target_user_id | int64 | 是 | 新 host 的用户 ID，必须是当前活跃参会者且不是自己 |

**行为**：`meeting_rooms.host_id` 更新 + `meeting_participants.role` 对调（事务），广播 `meeting.host.changed`。

**错误**：`仅主持人可操作` (403) / `不能将主持人转让给自己` (400) / `目标用户不是当前活跃参会者` (400)。

---

## 8. 踢出成员

`POST /api/v1/meeting/rooms/:code/kick`

**请求体**

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| user_id | int64 | 是 | 被踢用户 ID |

**行为**：标记 `left_at=now`、`left_reason=kicked`；对被踢者定向推送 `meeting.member.kicked`（前端收到后跳首页），同时房间广播 `meeting.member.left`。

**错误**：`仅主持人可操作` (403) / `不能踢自己` (400) / `目标用户不是当前活跃参会者` (400)。

---

## 9. 邀请用户

`POST /api/v1/meeting/rooms/:code/invite`

**请求体**

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| invitee_ids | int64[] | 是 | 被邀请用户 ID 数组，1~50 个，自动去重 |

**行为**：对每个 invitee：
1. 若该用户已在本会议活跃 → 跳过；
2. 生成 32 位十六进制 Token 写 Redis key `echo:meeting:invite:{token}`，TTL 600 秒（`MeetingInviteTokenTTL`），value = `{"room_code","inviter_id","invitee_id","has_password"}`；
3. 通过 Phase 2e-1 `notify.Pusher.PushBatch` 推送 `type=meeting_invite` 通知，`Extra`（Phase 2e-2 Task 13 完整版）包含以下字段：
   - `room_code`：9 位会议号 `XXX-XXX-XXX`，前端跳 preview 页使用
   - `room_title`：房间标题，卡片展示
   - `has_password`：是否有密码，前端据此决定是否在 preview 页弹密码输入
   - `invite_token`：32 位 hex token，可用于兑换接口快速拉取 room_code
   - `inviter_id` / `inviter_name` / `inviter_avatar`：邀请人展示字段，卡片左上角头像 + "XX 邀请你加入..." 文案来源
   - `expired_at`：Unix 秒，与 Redis TTL 同步；前端用 `expired_at * 1000 < Date.now()` 判断过期并灰显按钮
4. 离线被邀请者走通知入库，上线后由 WS 或未读轮询获得。

**响应 `200 OK`**

```json
{ "code": 0, "data": { "pushed": 1, "skipped": 0 } }
```

出于安全考虑，响应体**不包含** token（token 仅通过通知 Extra 定向下发给被邀请者）。

---

## 10. 兑换邀请链接

`POST /api/v1/meeting/invite-tokens/:token/redeem`

**行为**：查询 Redis key，若存在则返回 `room_code + inviter_id + has_password`，前端据此决定弹密码框再调 `POST /rooms/:code/join`。Token 兑换后**保留 60 秒冗余**（不立即删除，允许用户刷新页面二次兑换），随后由 Redis TTL 自然过期。

**响应 `200 OK`**

```json
{
  "code": 0,
  "data": {
    "room_code": "835-000-036",
    "inviter_id": 16,
    "has_password": false
  }
}
```

**错误**：`邀请链接已失效` (400)。

---

## 11. 发送会议内聊天

`POST /api/v1/meeting/rooms/:code/chats`

**请求体**

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| content | string | 是 | 消息文本，1~500 字符 |

**行为**：写 `meeting_chats` 后向房间内活跃成员广播 WS 事件 `meeting.chat`，载荷为响应中的 `message` 对象。

**响应 `201 Created`**

```json
{
  "code": 0,
  "data": {
    "message": {
      "id": 18,
      "room_id": 12,
      "user_id": 17,
      "content": "hello",
      "created_at": "2026-04-21 16:32:05"
    }
  }
}
```

---

## 12. 拉取会议历史聊天

`GET /api/v1/meeting/rooms/:code/chats`

**查询参数**

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| before_id | int64 | 否 | 游标：仅返回 id < before_id 的消息 |
| limit | int | 否 | 页大小，默认 30，最大 100 |

**响应 `200 OK`**

```json
{
  "code": 0,
  "data": {
    "list": [
      { "id": 18, "room_id": 12, "user_id": 17, "content": "hello", "created_at": "..." }
    ],
    "has_more": false
  }
}
```

**保留策略**：定时任务每日清理 `status=2 AND ended_at < NOW() - 24h` 的房间聊天记录（不进入 IM 消息流）。

---

## WebSocket 信令协议（Task 6）

全部会议相关实时信令走 `/ws?token=<access_token>` 统一通道，共 **14 个 `meeting.*` 事件**：**9 个** 客户端→服务端（C→S）操作事件（Task 9 新增 `meeting.consume.resume`）+ 5 个服务端→客户端（S→C）广播事件 + 2 个补充业务事件（聊天 + 被踢定向推送，与 REST 广播复用）。

### 帧格式

所有消息使用 `ws.Message` 统一信封（JSON）：

| 字段 | 类型 | 说明 |
|------|------|------|
| event | string | 事件名，如 `meeting.room.join` |
| seq | int64 | 客户端自增序号；服务端 ACK 原样回传，用于客户端匹配请求-响应 |
| data | object | 业务载荷 |
| code | int | 仅 ACK / 广播带：0=成功，-1=业务失败 |
| message | string | 仅 ACK：失败时的人类可读原因（与 REST 领域错误口径一致） |
| time | string | ISO8601 时间戳 |

**ACK 约定**：每一个 C→S 事件服务端都会回发 `<event>.ack`，`seq` 与请求一致；成功时 `code=0`，失败时 `code=-1` 且 `message` 含中文原因（如 `仅主持人可执行此操作`、`会议不存在`、`你当前未在会议中`）。

**连接级鉴权**：Token 校验见 [websocket.md](websocket.md) 连接管理；会议内事件额外在每次调用时校验 `(user_id, room_code)` 是否为当前活跃参会者，非法请求全部以 ACK 形式返回 `-1`，不会触发 401/403 HTTP 错误。

### 事件总览

| # | 方向 | 事件 | 用途 | 服务端动作 |
|---|------|------|------|-----------|
| 1 | C→S | `meeting.room.join` | 前端建立 WS 后声明加入某会议房间频道 | 验证活跃参会，记录 WS 会话 |
| 2 | C→S | `meeting.room.leave` | 前端主动离开 WS 会议频道 | 清理媒体资源（transport / producer / consumer） |
| 3 | C→S | `meeting.member.state.changed` | 成员更新自己的麦克风/摄像头；host 可指定 `target_user_id` 强制静音他人 | 广播 S→C 同名事件，权限不符返回 ACK `code=-1` |
| 4 | C→S | `meeting.transport.create` | 请求创建 mediasoup WebRtcTransport（send/recv） | 调用 `mediaOrchestrator.CreateTransport`，返回 `id/iceParameters/iceCandidates/dtlsParameters` |
| 5 | C→S | `meeting.transport.connect` | 提交 DTLS Parameters 完成 transport 连接 | `mediaOrchestrator.ConnectTransport` |
| 6 | C→S | `meeting.produce.start` | 创建 Producer（上行流） | `mediaOrchestrator.CreateProducer`，成功后广播 `meeting.member.producer.new` |
| 7 | C→S | `meeting.consume.start` | 创建 Consumer（订阅对端 Producer） | `mediaOrchestrator.CreateConsumer` |
| 8 | C→S | `meeting.producer.close` | 关闭自己的 Producer | `mediaOrchestrator.CloseProducer`，广播 `meeting.member.producer.new` (closed=true) |
| 9 | C→S | `meeting.consume.resume` | 客户端完成 track 挂载后请求 resume Consumer（Task 9 新增） | `mediaOrchestrator.ResumeConsumer` → Node `POST /internal/v1/consumers/:id/resume`，不广播 |
| 10 | S→C | `meeting.member.joined` | 新成员加入广播（复用 REST /join） | 房间广播 |
| 11 | S→C | `meeting.member.left` | 成员离开广播（REST /leave /kick 或 WS leave） | 房间广播 |
| 12 | S→C | `meeting.member.kicked` | 定向通知被踢者（REST /kick） | `PublishToUser` |
| 13 | S→C | `meeting.host.changed` | 主持人变更 | 房间广播 |
| 14 | S→C | `meeting.room.ended` | 会议被结束（REST /end 或空房 TTL） | 房间广播 |
| 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 白名单**中的 9 个事件（见 `app/constants/meeting.go:MeetingWSClientEvents`），其余 `meeting.*` 事件若由客户端发送均被静默丢弃，防止恶意客户端伪造广播。

### 客户端白名单（C→S）详细契约

#### 1. `meeting.room.join`

**请求载荷**

```json
{ "room_code": "835-000-036" }
```

**ACK** `code=0`：`data` 为空对象；失败原因：`会议不存在` / `你当前未在会议中`。

**说明**：WS 加入仅用于开启该房间的事件推送通道，REST `/join` 已将 participant 写入 DB，此事件**不会再次修改 participant 表**。

#### 2. `meeting.room.leave`

**请求载荷**

```json
{ "room_code": "835-000-036" }
```

**ACK** `code=0`：`data` 为空对象；服务端同时发起媒体资源清理：按 Redis key `echo:meeting:resources:{room_id}:{user_id}` 中记录的 transport / producer / consumer 依次调用 `mediaOrchestrator.CloseTransport/CloseProducer/CloseConsumer`，随后删除该 key。

**说明**：此事件**不会**将 `participant.left_at` 置空，若用户希望真正离会仍需调用 REST `POST /rooms/:code/leave`。此设计允许客户端在 WS 重连时调用 leave + join 刷新资源而不退出会议。

#### 3. `meeting.member.state.changed`

**请求载荷**

```json
{
  "room_code": "835-000-036",
  "target_user_id": 17,
  "audio_enabled": false,
  "video_enabled": true,
  "hand_raised": false
}
```

- `target_user_id` 可选：省略或等于自己时为自我操作，任何人均可；指定为他人时需调用方为 host，否则 ACK `code=-1 message=仅主持人可执行此操作`。
- `audio_enabled / video_enabled / hand_raised` 均为可选布尔字段，至少传一个。

**ACK** `code=0`：`data` 为空。**广播** `meeting.member.state.changed`：

```json
{
  "room_code": "835-000-036",
  "user_id": 17,
  "audio_enabled": false,
  "video_enabled": true,
  "hand_raised": false,
  "actor_id": 16
}
```

`actor_id` 为触发变更的 user_id（host 强制静音场景下与 `user_id` 不同）。

#### 4. `meeting.transport.create`

**请求载荷**

```json
{ "room_code": "835-000-036", "direction": "send" }
```

`direction`：`"send"` 或 `"recv"`。

**ACK** `code=0` `data`：

```json
{
  "id": "transport-abc",
  "iceParameters": { ... },
  "iceCandidates": [ ... ],
  "dtlsParameters": { ... }
}
```

**资源追踪**：服务端将 `transport-id` 按 `echo:meeting:resources:{room_id}:{user_id}` 的 Redis Set 记录，TTL 1 小时。

#### 5. `meeting.transport.connect`

**请求载荷**

```json
{
  "room_code": "835-000-036",
  "transport_id": "transport-abc",
  "dtls_parameters": { ... }
}
```

**ACK** `code=0`：`data` 为空。

#### 6. `meeting.produce.start`

**请求载荷**

```json
{
  "room_code": "835-000-036",
  "transport_id": "transport-abc",
  "kind": "audio",
  "rtp_parameters": { ... }
}
```

`kind`: `"audio"` | `"video"`。

**ACK** `code=0` `data`：

```json
{ "producer_id": "producer-xyz" }
```

**同时广播** `meeting.member.producer.new`：

```json
{
  "room_code": "835-000-036",
  "user_id": 16,
  "producer_id": "producer-xyz",
  "kind": "audio",
  "closed": false
}
```

#### 7. `meeting.consume.start`

**请求载荷**

```json
{
  "room_code": "835-000-036",
  "transport_id": "transport-recv",
  "producer_id": "producer-xyz",
  "rtp_capabilities": { ... }
}
```

**ACK** `code=0` `data`：

```json
{
  "id": "consumer-def",
  "producerId": "producer-xyz",
  "kind": "audio",
  "rtpParameters": { ... }
}
```

**资源追踪**：consumer-id 也会进入 Redis resources Set。

#### 8. `meeting.producer.close`

**请求载荷**

```json
{ "room_code": "835-000-036", "producer_id": "producer-xyz" }
```

**ACK** `code=0`：`data` 为空。**广播** `meeting.member.producer.new` `closed=true`：

```json
{
  "room_code": "835-000-036",
  "user_id": 16,
  "producer_id": "producer-xyz",
  "closed": true
}
```

#### 9. `meeting.consume.resume`（Task 9 新增，2026-04-21）

**请求载荷**

```json
{ "consumer_id": "consumer-def" }
```

**ACK** `code=0`：`data` 为空。失败原因：`会议资源不存在`（Node 404 → Go 转译，HTTP 层归因 `ErrMediaResourceNotFound`）或 `会议媒体服务暂不可用`（5xx / 网络错）。

**调用时机**（前端推荐流程）：

1. 收到广播 `meeting.member.producer.new { closed:false }`；
2. 前端创建 `recvTransport` + `consumer = device.consume()`，此时 Consumer 为 **paused**（后端创建时就强制 `paused:true`）；
3. 前端把 `consumer.track` 挂载到 DOM 元素（`<video>.srcObject = new MediaStream([track])`）；
4. 监听 `<video>.onloadedmetadata`（或图像解码首帧回调）；
5. **回调中发送** `meeting.consume.resume { consumer_id }`；
6. 服务端 `ResumeConsumer` 调 Node `POST /internal/v1/consumers/:id/resume`，Node mediasoup 开始 forward RTP。

**为什么要显式 resume**：mediasoup 官方规范要求"DOM 挂完 track 再 resume"，避免 RTP forward 时浏览器解码空帧造成首帧黑屏/抖动（50-200ms）；同时保留未来 simulcast 层切换、订阅清单变更、后台节流等扩展点。

**不广播**：本事件是纯 C→S 调用，服务端不广播任何事件。

---

### 服务端广播（S→C）详细契约

| 事件 | 载荷字段 | 说明 |
|------|---------|------|
| `meeting.member.joined` | room_code / participant 对象 | REST /join 触发 |
| `meeting.member.left` | room_code / user_id / reason | REST /leave /kick 或 WS 资源清理 |
| `meeting.member.kicked` | room_code / reason | 仅发给被踢者本人，对应 REST /kick |
| `meeting.host.changed` | room_code / old_host_id / new_host_id / **auto_reason**? | REST /transfer-host（无 `auto_reason`）/ host 离会自动转让（`auto_reason=host_left_with_members`）/ **host 宽限期过期自动转让**（`auto_reason=host_grace_expired`，Task 8 新增） |
| `meeting.room.ended` | room_code / reason（`host_ended` / `empty_ttl` / **`system_error`**） | REST /end（`host_ended`）/ 空房 TTL 过期（`empty_ttl`）/ 兜底清理 4 小时陈旧房间（`system_error`，Task 8 新增） |
| `meeting.member.state.changed` | 见白名单 #3 | WS `meeting.member.state.changed` 触发 |
| `meeting.member.producer.new` | 见白名单 #6 / #8 | WS `meeting.produce.start` / `meeting.producer.close` 触发 |
| `meeting.chat` | message 对象 | REST /chats 触发 |

### 架构

- **MeetingWSHandler**（controller 层）：thin adapter，仅负责 ws.Hub 事件注册 + JSON 反序列化 + ACK 回写；位于 `app/meeting/controller/meeting_ws_handler.go`。
- **MeetingSignalService**（service 层）：承载 8 个 C→S 事件的业务逻辑（活跃参会校验、host 权限校验、mediaOrchestrator 调用、Redis 资源追踪、广播），位于 `app/meeting/service/meeting_signal_service.go`。
- **MeetingBroadcaster**（service 层）：封装 `BroadcastToMeeting`（查询活跃 participant 列表 → 逐个 `PubSub.PublishToUser`）与 `PublishToUser`，供 REST / WS 两个入口统一使用，位于 `app/meeting/service/meeting_broadcaster.go`。
- **MediaOrchestrator**（interface）：定义 **10 个** mediasoup 操作方法（Task 9 新增 `ResumeConsumer` + `ResolveRouterInfo(roomCode) → (routerID, rtpCapabilities, ok)`）；Task 6 曾用 `NoopMediaOrchestrator` 占位，**Task 7 (2026-04-21) 已替换为 `HTTPMediaOrchestrator`**，通过 `X-Internal-Token` 鉴权直连 Node media-server 的 `/internal/v1/*` 接口；错误类型 `ErrMediaResourceNotFound`（Node 404 → 关闭类幂等转 nil）与 `ErrMediaServerError`（5xx / 超时 / 网络错）可供上层 `errors.Is` 区分；关闭类操作指数退避 200ms→500ms 最多 `CloseRetry+1` 次。Router 信息缓存升级：`sync.Map[roomCode]*routerInfoCache{ID, RtpCapabilities}` 同时缓存 Router ID 与 RTP Capabilities（Task 9 变更，供 REST `rtp_capabilities` 字段透传）。

### 错误处理

- 鉴权失败（token 无效 / 未传）：WS 握手时直接 4001 关闭，不进入业务层。
- 业务失败（非参会者 / 非 host / 会议已结束）：ACK `code=-1` + 中文 `message`，与 REST 领域错误口径完全一致。
- mediasoup 调用失败：`message` 形如 `mediaOrchestrator error: <原始错误>`，前端可据此降级。
- WS 连接断开：服务端 `OnDisconnect` 钩子会对该用户所有活跃会议调用 `cleanupUserResources`（遍历 Redis `echo:meeting:resources:*:{user_id}` 完成 transport / producer / consumer 关闭），防止服务端资源泄漏。

---

## 验证记录

- **Task 5** 端到端验证脚本（`/tmp/meeting_t5_test.sh`）结果：**19/19 PASS**，覆盖 12 接口的 happy path 与 5 类错误路径（密码错误 / 房间不存在 / 单点参会冲突 / 非 host 越权 / 邀请链接失效）。
- **Task 6** 端到端 WS 测试脚本（`/tmp/meeting_ws_t6_test.mjs`）结果：**18/18 PASS**，覆盖：
  - `meeting.room.join` 双端 ACK
  - `meeting.member.state.changed` 自我静音（ACK + 对端广播）
  - host 强制静音他人（ACK）
  - 非 host 强制静音他人 → ACK `code=-1 message=仅主持人可执行此操作`
  - `meeting.transport.create` 双方向（send / recv）
  - `meeting.transport.connect`
  - `meeting.produce.start` + `meeting.member.producer.new` 广播
  - `meeting.consume.start`
  - `meeting.producer.close` + `meeting.member.producer.new closed=true` 广播
  - `meeting.room.leave` + `meeting.member.left` 广播
  - 不存在会议号 `meeting.room.join` → ACK `code=-1`
- **Task 7** 端到端验证脚本（`docs/verify/meeting_t7_verify.mjs`）结果：**16/16 PASS**，覆盖：
  - media-server `/healthz` + `/internal/info`（正确 token 200、错误 token 401）
  - 注册登录 2 用户 → host 创建会议（HTTP 201 + 内部调 `CreateRouter`）→ 第二用户 REST 加入（再次 `CreateRouter`）
  - 双端 WS `meeting.room.join`
  - `meeting.transport.create`（direction=send）返回**真实** mediasoup `id`（非 `noop-` 前缀）+ `iceParameters` 对象 + `iceCandidates[]` 非空 + `dtlsParameters.fingerprints[]` 非空
  - 虚构 producer_id 调 `meeting.producer.close` → Node 返回 404 → Go 幂等转 `code=0`
  - host `POST /rooms/:code/end` 触发 `CloseRouter`，media-server 日志确认 `router closed explicitly`
- **Task 8** 端到端验证脚本（`docs/verify/meeting_t8_verify.mjs`）结果：**20/20 PASS**，覆盖 5 个会议生命周期场景：
  - **S1 host 宽限期过期自动转让**：host WS 断线 → Redis 写入 `echo:meeting:host_grace:{code}` → 3s 后过期 → `meeting.host.changed{auto_reason=host_grace_expired}` 广播 + DB `host_id` 更新为最早加入者
  - **S2 宽限期内重连保留身份**：host 断线 1s 内重连 `meeting.room.join` → `host_grace` key DEL + host 身份保留
  - **S3 空房 TTL 复活**：全员 leave → Redis 写入 `echo:meeting:empty_ttl:{code}` → TTL 内新用户 join → key DEL + 房间保持 Active
  - **S4 空房 TTL 过期销毁**：全员 leave → 3s 后 TTL 过期 → 房间 Ended + 新 join 被拒
  - **S5 Router 幂等**：`POST /meeting/rooms` 后 `stats.routers` +1；`POST /meeting/rooms/:code/join` 不再触发新 Router（业务层 `JoinRoom` 不调 `CreateRouter` + HTTP 层 `sync.Map` 幂等防御）

---

## 后续任务关联

- **Task 9 ✅（2026-04-21）**：Vue 前端 mediasoup-client 已接入，按本文 WS 契约实现 `Transport.on('connect'|'produce')` 回调；创建 Consumer 后前端通过 `meeting.consume.resume` WS 事件驱动 Go → Node `POST /internal/v1/consumers/:id/resume` 完成 resume；UI 已处理 `meeting.host.changed` 的 `auto_reason` 字段标注"自动转让"。
- **Task 10-12 ✅（2026-04-22）**：前端会议主链路全量打通（Hub / Create / Join / Preview / Room 5 页 + `MeetingToolbar / VideoGrid / VideoTile / MemberPanel / InviteDialog / ChatPanel` 6 个核心组件）；会议内聊天显示真实昵称（后端 `ResolveUsersDisplay` 批量补齐 `user_name / user_avatar`）。
- **Task 13 ✅（2026-04-23）**：通知卡片 UI 已完成内联按钮（"立即加入 / 稍后"），点击"立即加入"跳 `/pages/meeting/preview?mode=join&code=xxx` 由 preview 页走 `/rooms/:code/join`；过期态由 `extra.expired_at * 1000 < Date.now()` 判定并合并为单个 disabled 的"邀请已过期"按钮，不再发起任何请求。
- **Task 14 ✅（2026-04-24）**：后端服务已纳入 `deploy/docker-compose.dev.yml` 双态编排（本机 Demo / 公网 `--profile public` + coturn），部署指南见 `docs/deployment/meeting-mvp.md`；API 接口契约本身无变化。
