Files
EchoChat/docs/api/frontend/meeting.md
bujinyuan ba8c72aae0 docs(task13): 补齐 meeting/notify API 文档 + Playwright UI 回归结果
## API 文档(Task 13 前漏补)
- docs/api/frontend/meeting.md §9 邀请用户接口:展开 extra 8 字段清单
  (room_code / room_title / has_password / invite_token / inviter_id
   / inviter_name / inviter_avatar / expired_at),并说明前端如何据
   expired_at 判过期
- docs/api/frontend/meeting.md 后续任务关联:Task 13 标  + deep-link 路径
- docs/api/frontend/notify.md 概览:
  * "支持内联按钮的通知类型" 表(friend_request / meeting_invite)
  * "meeting_invite 专用 extra 字段" 详细清单

## Playwright UI 回归结果(2026-04-23 补做)
采用 "A 侧 curl 邀请 + B 侧 Playwright 登录" 策略:
- testuser1 邀请 testuser2 → 通知中心渲染 meeting_invite 卡片
- 新邀请卡片 → 双按钮「立即加入 / 稍后」
- 过期邀请卡片 → 合并为单 disabled「邀请已过期」按钮 
- 点「立即加入」→ 跳 /pages/meeting/preview?mode=join&code=516-162-828 
- preview 页正确渲染 "即将加入会议 516-162-828" + 设备面板 
- 截图:task13-notify-meeting-invite-cards.png
        task13-preview-page-after-invite-accept.png
- CURRENT_STATUS.md + implementation.plan.md 已同步补录

Made-with: Cursor
2026-04-22 17:34:29 +08:00

693 lines
29 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 / 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~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": "abc-router-835-000-036",
"rtp_capabilities": {
"codecs": [ { "mimeType": "audio/opus", "clockRate": 48000, ... } ],
"headerExtensions": [ ... ]
}
}
}
```
`router_id` Task 7 起为 Node media-server 返回的真实 mediasoup Router IDPhase 2e-2 Task 7 完成2026-04-21`rtp_capabilities` Task 9 起随响应一并返回Phase 2e-2 Task 92026-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 } }
```
出于安全考虑响应体**不包含** 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>` 统一通道 **14 个 `meeting.*` 事件****9 ** 客户端服务端CS操作事件Task 9 新增 `meeting.consume.resume`+ 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 | CS | `meeting.consume.resume` | 客户端完成 track 挂载后请求 resume ConsumerTask 9 新增 | `mediaOrchestrator.ResumeConsumer` Node `POST /internal/v1/consumers/:id/resume`不广播 |
| 10 | SC | `meeting.member.joined` | 新成员加入广播复用 REST /join | 房间广播 |
| 11 | SC | `meeting.member.left` | 成员离开广播REST /leave /kick WS leave | 房间广播 |
| 12 | SC | `meeting.member.kicked` | 定向通知被踢者REST /kick | `PublishToUser` |
| 13 | SC | `meeting.host.changed` | 主持人变更 | 房间广播 |
| 14 | SC | `meeting.room.ended` | 会议被结束REST /end 或空房 TTL | 房间广播 |
| 15 | SC | `meeting.member.state.changed` | 成员状态变化静音/关摄像头/举手 | 房间广播 |
| 16 | SC | `meeting.member.producer.new` | 成员开启/关闭媒体流 | 房间广播`closed=true` 表示关闭 |
| 17 | SC | `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_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
}
```
#### 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 层切换订阅清单变更后台节流等扩展点
**不广播**本事件是纯 CS 调用服务端不广播任何事件
---
### 服务端广播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 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定义 **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` 区分关闭类操作指数退避 200ms500ms 最多 `CloseRetry+1` Router 信息缓存升级`sync.Map[roomCode]*routerInfoCache{ID, RtpCapabilities}` 同时缓存 Router ID RTP CapabilitiesTask 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**Vue 前端 mediasoup-client 接入按本文 WS 契约实现 `mediasoup.Transport` `connect / produce` 回调补齐 `ResumeConsumer`Node REST 已就绪或在 Go 侧改为创建 Consumer 后自动 resume需处理 `meeting.host.changed` `auto_reason` 字段以在 UI 上标注"自动转让"。
- **Task 13 ✅(2026-04-23**通知卡片 UI 已完成内联按钮"立即加入 / 稍后"点击"立即加入" `/pages/meeting/preview?mode=join&code=xxx` preview 页走 `/rooms/:code/join`过期态由 `extra.expired_at * 1000 < Date.now()` 判定并合并为单个 disabled "邀请已过期"按钮不再发起任何请求