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:
@@ -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 ID(Phase 2e-2 Task 7 完成,2026-04-21)。前端可据此建立 WebSocket 订阅,也可忽略,仅依赖 WS `meeting.transport.create` 的返回值创建 mediasoup-client Transport。
|
||||
`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`。
|
||||
|
||||
---
|
||||
|
||||
@@ -373,7 +379,7 @@
|
||||
|
||||
## WebSocket 信令协议(Task 6)
|
||||
|
||||
全部会议相关实时信令走 `/ws?token=<access_token>` 统一通道,共 **13 个 `meeting.*` 事件**:8 个客户端→服务端(C→S)操作事件 + 5 个服务端→客户端(S→C)广播事件 + 2 个补充业务事件(聊天 + 被踢定向推送,与 REST 广播复用)。
|
||||
全部会议相关实时信令走 `/ws?token=<access_token>` 统一通道,共 **14 个 `meeting.*` 事件**:**9 个** 客户端→服务端(C→S)操作事件(Task 9 新增 `meeting.consume.resume`)+ 5 个服务端→客户端(S→C)广播事件 + 2 个补充业务事件(聊天 + 被踢定向推送,与 REST 广播复用)。
|
||||
|
||||
### 帧格式
|
||||
|
||||
@@ -404,16 +410,17 @@
|
||||
| 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 | S→C | `meeting.member.joined` | 新成员加入广播(复用 REST /join) | 房间广播 |
|
||||
| 10 | S→C | `meeting.member.left` | 成员离开广播(REST /leave /kick 或 WS leave) | 房间广播 |
|
||||
| 11 | S→C | `meeting.member.kicked` | 定向通知被踢者(REST /kick) | `PublishToUser` |
|
||||
| 12 | S→C | `meeting.host.changed` | 主持人变更 | 房间广播 |
|
||||
| 13 | S→C | `meeting.room.ended` | 会议被结束(REST /end 或空房 TTL) | 房间广播 |
|
||||
| 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 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 白名单**中的 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 层切换、订阅清单变更、后台节流等扩展点。
|
||||
|
||||
**不广播**:本事件是纯 C→S 调用,服务端不广播任何事件。
|
||||
|
||||
---
|
||||
|
||||
### 服务端广播(S→C)详细契约
|
||||
|
||||
| 事件 | 载荷字段 | 说明 |
|
||||
@@ -606,7 +638,7 @@
|
||||
- **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):定义 8 个 mediasoup 操作方法(第 9 个 `ResumeConsumer` 推迟到 Task 9);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` 次。
|
||||
- **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` 字段透传)。
|
||||
|
||||
### 错误处理
|
||||
|
||||
|
||||
@@ -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 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 列的 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` 时自动清理。
|
||||
|
||||
@@ -628,6 +628,7 @@ flowchart LR
|
||||
| 2026-04-21 | Agent | Task 0 mediasoup PoC Spike 完成:`media-server/poc/` 跑通 2 浏览器互通,`media-server/docs/poc-notes.md` 归档压测数据与 7 项关键坑,技术栈选型锁定 |
|
||||
| 2026-04-21 | Agent | Task 1 media-server 项目骨架完成:src 五件套(app/config/logger/worker/internal-auth)落盘;`/healthz` + `/readyz` + `/internal/info` 全部验收通过;Worker died 自动重启通过 `kill -9` 实测;Dockerfile 多阶段 + 非 root + HEALTHCHECK |
|
||||
| 2026-04-21 | Agent | Task 1 Fastify 4 → 5 同日升级:fastify@4.28.1→5.8.5、fastify-plugin@4→5.1.0、@fastify/sensible@5→6.0.4、@fastify/websocket@10→11.2.0;`src/app.ts` 改用 `loggerInstance: logger` 消灭 pino 双实例;理由:Fastify 4 已过 2025-06-30 LTS 支持、v5 生态 GA、单实例回归;回归测试全部通过 |
|
||||
| 2026-04-21 | Agent | Task 9 前端 mediasoup-client + Pinia Store 完成:新增 `constants/meeting.js`(14 个 WS 事件)/ `api/meeting.js`(12 REST)/ `utils/mediasoup-client.js`(Device/Transport/Producer/Consumer 全生命周期封装)/ `store/meeting.js`(615 行,20+ action + 8 个广播事件桥)+ 扩展 `services/websocket.js` 的 `sendWithAck` Promise 化;后端补齐 `meeting.consume.resume` WS 事件 + `RouterRtpCapabilities` 透传到 `CreateMeetingRoomResponse` / `JoinMeetingRoomResponse`;新增 `pages/meeting/debug.vue` 临时调试页(原生 DOM 挂 `<video>`/`<audio>` 绕过 uni-h5 组件限制);`npm run build:h5` 通过无新增 warning;Q1-Q7 七项决策全部锁定(H5 Only / npm dep / 进房注册 WS 监听 / 12 接口全量封装 / services 层 sendWithAck / WS 暴露 consume.resume / debug 页手测) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# EchoChat 项目开发进度
|
||||
|
||||
> **最后更新**:2026-04-21(Phase 2e-2 Task 8 会议生命周期状态机落地,host 宽限期 + 自动转让 + 空房 TTL + Router 幂等,E2E 20/20 PASS)
|
||||
> **当前阶段**:Phase 2e-2 会议 MVP **代码开发阶段** 🚧(Task 0-8 ✅ / Task 9-16 待执行)
|
||||
> **最后更新**:2026-04-21(Phase 2e-2 Task 9 前端 mediasoup-client + Pinia Store 落地,REST/WS/媒体三链路打通,`npm run build:h5` 通过)
|
||||
> **当前阶段**:Phase 2e-2 会议 MVP **代码开发阶段** 🚧(Task 0-9 ✅ / Task 10-16 待执行)
|
||||
> **当前分支**:`feature/phase2e-2-meeting-mvp`(从 `feature/phase2c-group-read-receipt` 衍生)
|
||||
> **Phase 2e 整体设计**:`docs/plans/2026-04-20-phase2e-design.md`(三子阶段路线图 + 后续规划清单)
|
||||
> **Phase 2e-1 专用设计**:`docs/plans/2026-04-20-phase2e-1-design.md`(✅ 已完成)
|
||||
@@ -170,6 +170,86 @@
|
||||
|
||||
---
|
||||
|
||||
## 🎯 2026-04-21 Phase 2e-2 Task 9 前端 mediasoup-client + Pinia Store 落地
|
||||
|
||||
**交付**:前端新增 5 个模块(`constants/meeting.js` / `api/meeting.js` / `services/websocket.js` 的 `sendWithAck` 扩展 / `utils/mediasoup-client.js` / `store/meeting.js`)+ 1 个临时调试页(`pages/meeting/debug.vue`),把 REST(12 接口)/ WebSocket(14 事件)/ mediasoup 媒体三链路全部汇聚到 Pinia Store 对外提供 ~20 个 action;后端补齐 `meeting.consume.resume` WS 事件 + `RouterRtpCapabilities` 透传到 REST 响应;`npm run build:h5` 通过(无本次新增 warning)。
|
||||
|
||||
### 产出文件
|
||||
|
||||
| 文件 | 行数 | 作用 |
|
||||
|---|---|---|
|
||||
| `frontend/package.json`(改) | +1 | `dependencies` 增加 `mediasoup-client@^3.9`(Q2=`a2_npm_dep`) |
|
||||
| `frontend/src/constants/meeting.js`(新) | 150 | 会议类型/状态/角色/结束原因/离会原因/host 自动转让原因/14 个 WS 事件名(含 `meeting.consume.resume`)/前端本地状态机常量,对齐后端 `app/constants/meeting.go` |
|
||||
| `frontend/src/api/meeting.js`(新) | 120 | 12 个 REST API 封装(`createRoom` / `getRoom` / `joinRoom` / `leaveRoom` / `endRoom` / `listMyMeetings` / `transferHost` / `kickMember` / `inviteUsers` / `redeemInvite` / `sendChat` / `listChats`),复用 `utils/request.js`(Q4=`a4_full_12`) |
|
||||
| `frontend/src/services/websocket.js`(改) | +90 | 新增 `sendWithAck(event, data, timeoutMS)` Promise 接口 + `pendingAcks` Map + `_handleAck` 路由 + `_rejectAllPendingAcks` 断线清理;自动识别 `*.ack` 后缀消息路由到 Promise(Q5=`a5_services_layer`) |
|
||||
| `frontend/src/utils/mediasoup-client.js`(新) | 270 | `createMediaEngine` 工厂:封装 Device / SendTransport / RecvTransport / Producer / Consumer 全生命周期;`Transport` 的 `connect`/`produce` 事件桥接到 `wsService.sendWithAck`;`#ifdef H5` 条件编译隔离非 H5 平台(Q1=`a1_h5_only`);所有 mediasoup 实例 `markRaw` 包裹避免 Vue 深度代理 |
|
||||
| `frontend/src/store/meeting.js`(新) | 615 | Pinia Store:本地状态机(idle/joining/connecting/connected/reconnecting/leaving/ended)+ 当前 room/participant + routerID + participants/chatMessages + localProducers / remoteConsumers;监听 8 个 `meeting.*` 广播事件(room.ended / member.joined / member.left / member.state.changed / member.kicked / member.producer.new / host.changed / chat.message);生命周期 4 个 action(createAndEnter / joinAndEnter / leave / endMeeting)+ 媒体 4 个(startLocalAudio/Video / stopLocalAudio/Video)+ 读取 2 个(getLocalTrack / getRemoteTrack)+ 聊天/管理 5 个(sendChat / loadChatHistory / transferHost / kickMember / inviteUsers);WS 监听在进房时注册、离房时注销(Q3=`a3_on_enter_room`) |
|
||||
| `frontend/src/pages/meeting/debug.vue`(新) | 280 | 临时调试页:会议状态面板 + 生命周期按钮 + 本地音/视频开关 + 远端参与者视频/音频动态渲染 + 聊天面板;`<video>`/`<audio>` 通过 `document.createElement` 原生 DOM 挂载到 `<view>` 容器(绕过 uni-h5 的 Video/Audio 组件不支持 `srcObject` 的限制);`#ifdef H5` 保护确保非 H5 平台构建正常(Q7=`a7_debug_page`) |
|
||||
| `frontend/src/pages.json`(改) | +6 | 注册 `pages/meeting/debug` 路由(未进入 tabBar,仅供手测) |
|
||||
| `backend/go-service/app/constants/meeting.go`(改) | +2 | `MeetingWSEventConsumeResume = "meeting.consume.resume"` + 加入 `MeetingWSClientEvents` |
|
||||
| `backend/go-service/app/meeting/service/interfaces.go`(改) | +10 | `MediaOrchestrator` 新增 `ResumeConsumer(ctx, consumerID) error` + `ResolveRouterInfo(roomCode) (routerID, rtpCapabilities, bool)`;`NoopMediaOrchestrator` 同步实现 |
|
||||
| `backend/go-service/app/meeting/service/http_media_orchestrator.go`(改) | ±40 | `sync.Map[roomCode]*routerInfoCache` 替换原 `roomRouterIDs`,同时缓存 `ID` + `RtpCapabilities`;`CreateRouter` 缓存 `rtpCapabilities` → `ResolveRouterInfo` 返回两者;新增 `ResumeConsumer` 调 Node `POST /internal/v1/consumers/:id/resume` |
|
||||
| `backend/go-service/app/meeting/service/meeting_signal_service.go`(改) | +25 | `OnConsumeResume` 处理函数 + `ConsumeResumePayload` 结构体 |
|
||||
| `backend/go-service/app/meeting/controller/meeting_ws_handler.go`(改) | +10 | 注册 `MeetingWSEventConsumeResume` + `handleConsumeResume` 分发 |
|
||||
| `backend/go-service/app/dto/meeting_dto.go`(改) | +4 | `CreateMeetingRoomResponse` / `JoinMeetingRoomResponse` 新增 `router_id` / `rtp_capabilities`(供 `mediasoup-client.Device.load()` 使用) |
|
||||
| `backend/go-service/app/meeting/service/meeting_service.go`(改) | +6 | 新增 `ResolveRouterInfo` 代理方法 |
|
||||
| `backend/go-service/app/meeting/controller/meeting_controller.go`(改) | ±15 | `CreateRoom` / `JoinRoom` 响应组装时填充 `RtpCapabilities` |
|
||||
|
||||
### 关键设计决策(7 项)
|
||||
|
||||
| 编号 | 决策 | 选择 |
|
||||
|---|---|---|
|
||||
| Q1 | 多端策略 | 仅 H5(mediasoup-client 代码 `#ifdef H5` 包裹,其他平台 action 抛出"仅支持 H5"提示) |
|
||||
| Q2 | mediasoup-client 安装 | `dependencies`(生产依赖,走 npm registry) |
|
||||
| Q3 | Store 与 WS 监听挂载 | 进入会议室时注册 8 个广播监听,离会时注销(避免侵入其他页面) |
|
||||
| Q4 | REST 封装范围 | 12 个接口一次性全量封装(避免后续 UI 阶段反复改 api 层) |
|
||||
| Q5 | WS seq→ack 处理 | 封装在 `services/websocket.js` 层,对外 Promise 化 `sendWithAck` |
|
||||
| Q6 | Consumer 自动 resume | **WS 事件暴露**(`meeting.consume.resume`)而非 Go 自动 resume:符合 mediasoup 官方规范(DOM 挂完 track 再 resume),为未来订阅/分辨率自适应/退会场景保留扩展点 |
|
||||
| Q7 | E2E 验证方式 | 临时 `debug.vue` 页面 + Chrome 两 tab 手测(比 Playwright 脚本更适合 MVP 快速验证阶段) |
|
||||
|
||||
### Q6 深入:为什么是 WS 暴露 resume 而不是 Go 自动 resume
|
||||
|
||||
- **Go 自动 resume 的隐藏代价**:Consumer 创建时若立即 resume,客户端 DOM 还没挂 `<video.srcObject = track>`,mediasoup 已经开始 forward RTP 但浏览器解码出空帧 ~50-200ms,造成首帧黑屏/抖动。
|
||||
- **WS 暴露方案的正确流程**:`Go createConsumer`(默认 paused)→ 下发 `consumer.created` 事件给客户端 → 客户端 `consumer = device.consume()` → `attach track to <video>` → **`video.onloadedmetadata`** 触发 → 客户端发 `meeting.consume.resume` → Go 转给 Node → RTP 开始 forward。全链路可观测,每一步都能打点。
|
||||
- **长期扩展点**:未来 simulcast 层切换 / 订阅清单变更 / 进后台节流都只需要在客户端决策何时 resume/pause,不需要改 Go 业务层。
|
||||
|
||||
### sendWithAck 语义
|
||||
|
||||
```text
|
||||
发送:client.send({ event: "meeting.transport.create", seq: "t_xxx", data: {...} })
|
||||
回执:server.send({ event: "meeting.transport.create.ack", seq: "t_xxx", code: 0, data: {...} })
|
||||
```
|
||||
|
||||
- **seq**:`${timestamp}_${random}` 本地生成,纯前端跟踪
|
||||
- **超时**:默认 `MEETING_WS_ACK_TIMEOUT_MS=10s`(可通过第 3 参数覆盖)
|
||||
- **断线保护**:`_rejectAllPendingAcks('ws_closed')` 在 `onclose` / `onerror` 回调里被调用,所有未完成 Promise 一次性 reject,避免"悬挂" Promise 导致 store action 卡住
|
||||
- **消息识别**:只要 `event.endsWith('.ack')` 且 `seq` 匹配,就路由到 ACK 分发器,不再 `_emit` 到业务监听器
|
||||
|
||||
### 构建验证
|
||||
|
||||
```text
|
||||
$ npm run build:h5
|
||||
> uni-preset-vue@0.0.0 build:h5
|
||||
> uni build
|
||||
编译器版本:4.87(vue3)
|
||||
正在编译中...
|
||||
DONE Build complete.
|
||||
```
|
||||
|
||||
剩余 warning(`chat.js` / `notify.js` dynamic import 提示、Sass legacy JS API)**均为 Task 9 之前既有**,非本次引入。
|
||||
|
||||
### 已知待改进项(留给后续 Task)
|
||||
|
||||
1. **Consumer resume 流程**:当前 Store `_onProducerNew` 内部立即调 `resumeConsumer`,未等页面 `<video>.onloadedmetadata` 再 resume,MVP 阶段首帧可能有 50-100ms 抖动;Task 11 会议室主页将把 resume 时机下移到页面层 `loadedmetadata` 事件。
|
||||
2. **`_cleanupRemoteProducer` 粒度粗**:当前实现是"该用户 slot 内的所有 Consumer 一并关掉",未按 producerId→consumerId 精准关;Task 11 重构时改为 `Map<producerId, consumer>` 精细索引。
|
||||
3. **E2E 手测待执行**:Chrome 两 tab 跨 tab 互看 + WS 断线重入会场景需要用户在本地运行后手动验证;Task 16 会补 Playwright 自动化脚本回归。
|
||||
|
||||
### 下一步
|
||||
|
||||
进入 **Task 10:前端会议预览/创建/加入页**,依赖 Task 9 的 Pinia Store + REST 封装;预估 1 人日。
|
||||
|
||||
---
|
||||
|
||||
## 🎯 2026-04-21 Phase 2e-2 Task 8 会议生命周期状态机落地(host 宽限期 + 自动转让 + 空房 TTL + Router 幂等)
|
||||
|
||||
**交付**:新增 `MeetingLifecycleService` + `MeetingCleanupTask`,落地设计 §6.5 的 5 类状态跃迁副作用(host 掉线宽限期、宽限期内重连、自动转让、空房 TTL、TTL 过期销毁);同步修复 Task 7 遗留的 "CreateRoom/JoinRoom 各自调一次 `CreateRouter`" 行为,业务层 `JoinRoom` 不再触发 `CreateRouter` + HTTP 层 `HTTPMediaOrchestrator.CreateRouter` 增加 `sync.Map` 幂等防御;端到端验证脚本 `docs/verify/meeting_t8_verify.mjs` **20/20 PASS**,覆盖 5 个核心场景(含 Router 数量断言)。
|
||||
|
||||
Reference in New Issue
Block a user