Files
EchoChat/docs/api/frontend/meeting.md
bujinyuan 55d6352c6a feat(phase2e-2): 落地 HTTPMediaOrchestrator 打通 Go↔Node 媒体链路(Task 7)
- 新增 MediaServerConfig + config.{dev,docker}.yaml 的 media_server 段
  (base_url / internal_token / timeout_ms / close_timeout_ms / close_retry)
- 新建 HTTPMediaOrchestrator(8 方法 + sync.Map 缓存 roomCode→routerID
  + 关闭类指数退避重试 200/500ms + ErrMediaResourceNotFound/ErrMediaServerError
  两类错误)实现 MediaOrchestrator 接口
- wire 绑定由 NoopMediaOrchestrator 切换到 HTTPMediaOrchestrator;
  MeetingService / MeetingSignalService 调用侧零改动
- E2E 脚本 docs/verify/meeting_t7_verify.mjs 证明 16/16 PASS:
  健康检查/错token 401/REST 创房加入/WS room.join/真实 mediasoup
  transport.create(ICE/DTLS 指纹均由 Node 返回非占位)/404 幂等关 producer
  /host 结束会议触发 CloseRouter
- 同步更新 13 份文档:CURRENT_STATUS / implementation.plan / design
  变更记录 / project-context / api 导览 / api websocket / api frontend meeting
  / system-architecture / media-server README / 顶层 README 等
- go build ./... 全绿

Made-with: Cursor
2026-04-21 17:47:11 +08:00

650 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 会议模块 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 已落地的 12 个 REST 接口 + 13 个 WebSocket 信令事件,统一前缀 `/api/v1/meeting`REST`/ws`WebSocket全部需要 JWT 认证。Task 5/6/7 完成时间2026-04-21。自 Task 7 起 **Go 后端直连 Node media-server**`transport.id` / `iceCandidates` / `dtlsParameters.fingerprints` 等字段均由真实 mediasoup 返回(不再是 stub 占位)。
**设计口径**:以 [`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~8MVP 硬上限 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 participantleft_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": "stub-router-835-000-036"
}
}
```
`router_id` Task 7 起为 Node media-server 返回的真实 mediasoup Router IDPhase 2e-2 Task 7 完成2026-04-21)。前端可据此建立 WebSocket 订阅也可忽略仅依赖 WS `meeting.transport.create` 的返回值创建 mediasoup-client Transport
---
## 5. 离开会议
`POST /api/v1/meeting/rooms/:code/leave`
**行为**
- `participant.left_at = now``duration = EXTRACT(EPOCH FROM now - joined_at)`
- 若离开者是 host 且仍有其他活跃成员 自动将 host 转让给**最早加入的活跃成员**广播 `meeting.host.changed`
- 若房间无剩余活跃成员 标记 `status=2 ended_reason=empty_ttl` 并触发 mediaOrchestrator.CloseRouter
- 广播 `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` `room_code / room_title / has_password / invite_token`
4. 离线被邀请者走通知入库上线后由 WS 或未读轮询获得
**响应 `200 OK`**
```json
{ "code": 0, "data": { "pushed": 1, "skipped": 0 } }
```
出于安全考虑响应体**不包含** tokentoken 仅通过通知 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>` 统一通道 **13 个 `meeting.*` 事件**8 个客户端服务端CS操作事件 + 5 个服务端客户端SC广播事件 + 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 约定**每一个 CS 事件服务端都会回发 `<event>.ack``seq` 与请求一致成功时 `code=0`失败时 `code=-1` `message` 含中文原因 `仅主持人可执行此操作``会议不存在``你当前未在会议中`)。
**连接级鉴权**Token 校验见 [websocket.md](websocket.md) 连接管理会议内事件额外在每次调用时校验 `(user_id, room_code)` 是否为当前活跃参会者非法请求全部以 ACK 形式返回 `-1`不会触发 401/403 HTTP 错误
### 事件总览
| # | 方向 | 事件 | 用途 | 服务端动作 |
|---|------|------|------|-----------|
| 1 | CS | `meeting.room.join` | 前端建立 WS 后声明加入某会议房间频道 | 验证活跃参会记录 WS 会话 |
| 2 | CS | `meeting.room.leave` | 前端主动离开 WS 会议频道 | 清理媒体资源transport / producer / consumer |
| 3 | CS | `meeting.member.state.changed` | 成员更新自己的麦克风/摄像头host 可指定 `target_user_id` 强制静音他人 | 广播 SC 同名事件权限不符返回 ACK `code=-1` |
| 4 | CS | `meeting.transport.create` | 请求创建 mediasoup WebRtcTransportsend/recv | 调用 `mediaOrchestrator.CreateTransport`返回 `id/iceParameters/iceCandidates/dtlsParameters` |
| 5 | CS | `meeting.transport.connect` | 提交 DTLS Parameters 完成 transport 连接 | `mediaOrchestrator.ConnectTransport` |
| 6 | CS | `meeting.produce.start` | 创建 Producer上行流 | `mediaOrchestrator.CreateProducer`成功后广播 `meeting.member.producer.new` |
| 7 | CS | `meeting.consume.start` | 创建 Consumer订阅对端 Producer | `mediaOrchestrator.CreateConsumer` |
| 8 | CS | `meeting.producer.close` | 关闭自己的 Producer | `mediaOrchestrator.CloseProducer`广播 `meeting.member.producer.new` (closed=true) |
| 9 | SC | `meeting.member.joined` | 新成员加入广播复用 REST /join | 房间广播 |
| 10 | SC | `meeting.member.left` | 成员离开广播REST /leave /kick WS leave | 房间广播 |
| 11 | SC | `meeting.member.kicked` | 定向通知被踢者REST /kick | `PublishToUser` |
| 12 | SC | `meeting.host.changed` | 主持人变更 | 房间广播 |
| 13 | SC | `meeting.room.ended` | 会议被结束REST /end 或空房 TTL | 房间广播 |
| 14 | SC | `meeting.member.state.changed` | 成员状态变化静音/关摄像头/举手 | 房间广播 |
| 15 | SC | `meeting.member.producer.new` | 成员开启/关闭媒体流 | 房间广播`closed=true` 表示关闭 |
| 16 | SC | `meeting.chat` | 会议内聊天REST /chats | 房间广播 |
> 说明:客户端仅注册 **C→S 白名单**中的 8 个事件(见 `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_idhost 强制静音场景下与 `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
}
```
### 服务端广播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 | REST /transfer-host host 离会自动转让 |
| `meeting.room.ended` | room_code / reason`host_ended` / `empty_ttl` | REST /end 或空房 TTL |
| `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 CS 事件的业务逻辑活跃参会校验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定义 8 mediasoup 操作方法 9 `ResumeConsumer` 推迟到 Task 9Task 6 曾用 `NoopMediaOrchestrator` 占位**Task 7 (2026-04-21) 已替换为 `HTTPMediaOrchestrator`**通过 `X-Internal-Token` 鉴权直连 Node media-server `/internal/v1/*` 接口错误类型 `ErrMediaResourceNotFound`Node 404 关闭类幂等转 nil `ErrMediaServerError`5xx / 超时 / 网络错可供上层 `errors.Is` 区分关闭类操作指数退避 200ms500ms 最多 `CloseRetry+1`
### 错误处理
- 鉴权失败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**会议生命周期状态机host 宽限期 + 自动转让 + 空房 TTL同时修复"CreateRoom + JoinRoom 重复调 `CreateRouter`"遗留行为
- **Task 9**Vue 前端 mediasoup-client 接入按本文 WS 契约实现 `mediasoup.Transport` `connect / produce` 回调补齐 `ResumeConsumer`Node REST 已就绪或在 Go 侧改为创建 Consumer 后自动 resume
- **Task 13**通知卡片 UI 补齐 `meeting_invite` 内联按钮