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` 时自动清理。

View File

@@ -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.15.8.5fastify-plugin@45.1.0@fastify/sensible@56.0.4@fastify/websocket@1011.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` 通过无新增 warningQ1-Q7 七项决策全部锁定H5 Only / npm dep / 进房注册 WS 监听 / 12 接口全量封装 / services sendWithAck / WS 暴露 consume.resume / debug 页手测 |
---

View File

@@ -1,7 +1,7 @@
# EchoChat 项目开发进度
> **最后更新**2026-04-21Phase 2e-2 Task 8 会议生命周期状态机落地host 宽限期 + 自动转让 + 空房 TTL + Router 幂等E2E 20/20 PASS
> **当前阶段**Phase 2e-2 会议 MVP **代码开发阶段** 🚧Task 0-8 ✅ / Task 9-16 待执行)
> **最后更新**2026-04-21Phase 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`),把 REST12 接口)/ WebSocket14 事件)/ 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` 后缀消息路由到 PromiseQ5=`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 个 actioncreateAndEnter / joinAndEnter / leave / endMeeting+ 媒体 4 个startLocalAudio/Video / stopLocalAudio/Video+ 读取 2 个getLocalTrack / getRemoteTrack+ 聊天/管理 5 个sendChat / loadChatHistory / transferHost / kickMember / inviteUsersWS 监听在进房时注册、离房时注销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 | 多端策略 | 仅 H5mediasoup-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.87vue3
正在编译中...
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` 再 resumeMVP 阶段首帧可能有 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 数量断言)。