feat(phase2e-2): 前端 mediasoup-client 集成 + Pinia meeting Store(Task 9)

- frontend/src/api/meeting.js:12 个 REST 接口封装,统一 unwrap envelope.data
- frontend/src/services/websocket.js:新增 sendWithAck(Promise 化 + 超时 + 序列号)
- frontend/src/utils/mediasoup-client.js:MediaEngine 包装 Device/Transport/Producer/Consumer
- frontend/src/store/meeting.js:Pinia 会议状态机,桥接 14 个 WS 事件 + cleanupStaleMeetings
- frontend/src/constants/meeting.js:状态枚举 + 事件名集中管理
- frontend/src/pages/meeting/debug.vue:临时调试页(H5 原生 video/audio DOM 绕过 uni 组件限制)
- backend:meeting.consume.resume WS 事件 + create/join 响应透传 router_id + rtp_capabilities
- 文档:frontend/meeting.md、websocket.md、CURRENT_STATUS、plan 全部同步 Task 9 落地

Made-with: Cursor
This commit is contained in:
bujinyuan
2026-04-22 11:22:49 +08:00
parent e7f6a32dfe
commit f97fec24a8
22 changed files with 2314 additions and 60 deletions

View File

@@ -3,7 +3,7 @@
> 通用规范(认证、响应包络、通用错误码)见 [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 占位)。
**实施状态**:本文档对应 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
@@ -193,12 +193,18 @@
"data": {
"room": { ... },
"participant": { ... },
"router_id": "stub-router-835-000-036"
"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前端可据此建立 WebSocket 订阅也可忽略仅依赖 WS `meeting.transport.create` 的返回值创建 mediasoup-client Transport
`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`。
---
@@ -373,7 +379,7 @@
## WebSocket 信令协议Task 6
全部会议相关实时信令走 `/ws?token=<access_token>` 统一通道 **13 个 `meeting.*` 事件**8 个客户端服务端CS操作事件 + 5 个服务端客户端SC广播事件 + 2 个补充业务事件聊天 + 被踢定向推送 REST 广播复用)。
全部会议相关实时信令走 `/ws?token=<access_token>` 统一通道 **14 个 `meeting.*` 事件****9 ** 客户端服务端CS操作事件Task 9 新增 `meeting.consume.resume`+ 5 个服务端客户端SC广播事件 + 2 个补充业务事件聊天 + 被踢定向推送 REST 广播复用)。
### 帧格式
@@ -404,16 +410,17 @@
| 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 | 房间广播 |
| 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 白名单**中的 8 个事件(见 `app/constants/meeting.go:MeetingWSClientEvents`),其余 `meeting.*` 事件若由客户端发送均被静默丢弃,防止恶意客户端伪造广播。
> 说明:客户端仅注册 **C→S 白名单**中的 9 个事件(见 `app/constants/meeting.go:MeetingWSClientEvents`),其余 `meeting.*` 事件若由客户端发送均被静默丢弃,防止恶意客户端伪造广播。
### 客户端白名单C→S详细契约
@@ -588,6 +595,31 @@
}
```
#### 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详细契约
| 事件 | 载荷字段 | 说明 |
@@ -606,7 +638,7 @@
- **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`
- **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` 字段透传)。
### 错误处理

View File

@@ -217,18 +217,19 @@
| 6 | C→S | `meeting.produce.start` | 创建 Producer |
| 7 | C→S | `meeting.consume.start` | 创建 Consumer |
| 8 | C→S | `meeting.producer.close` | 关闭自己的 Producer |
| 9 | S→C | `meeting.member.joined` | 新成员加入广播REST /join 触发|
| 10 | S→C | `meeting.member.left` | 成员离开广播REST /leave /kick 或 WS 资源清理|
| 11 | S→C | `meeting.member.kicked` | 定向通知被踢者 |
| 12 | S→C | `meeting.host.changed` | 主持人变更 |
| 13 | S→C | `meeting.room.ended` | 会议被结束 |
| 14 | S→C | `meeting.member.state.changed` | 成员状态变化广播 |
| 15 | S→C | `meeting.member.producer.new` | 成员开启/关闭媒体流(`closed=true` 表示关闭)|
| 16 | S→C | `meeting.chat` | 会议内聊天REST /chats 触发|
| 9 | C→S | `meeting.consume.resume` | 客户端完成 track 挂载后请求 resume ConsumerTask 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 列的 8 个事件,其余 `meeting.*` 事件若由客户端发送被静默丢弃。
- **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` 时自动清理。