Files
EchoChat/media-server/docs/poc-notes.md
bujinyuan 6e2e792db0 feat(phase2e-2): media-server Task 0/1/2 落地 + 代码审查修复
本次提交一次性落盘 Phase 2e-2 的 Task 0(PoC Spike)、Task 1(骨架 + Fastify 5 升级)
和 Task 2(9 个内部 REST API + code-reviewer 审查修复),覆盖 media-server 子项目
从零到可用的全部工作。

【Task 0 - PoC Spike】
- media-server/poc/:Node + mediasoup + fastify-websocket + 前端 mediasoup-client
- Playwright 双 tab 自动化验证 2 人会议:4 transports / 4 producers / 4 consumers / RSS 61MB
- media-server/docs/poc-notes.md 归档 7 项关键坑 + 启动步骤 + Task 1/2/9 复用映射
- 锁定技术栈:mediasoup + fastify + mediasoup-client,不改用 livekit-server

【Task 1 - 骨架 + Fastify 5 升级】
- 依赖版本:mediasoup@3.19.0 + fastify@5.8.5 + fastify-plugin@5.1.0
           + @fastify/sensible@6.0.4 + @fastify/websocket@11.2.0
           + pino@9.3.2 + zod@3.23.8
- src 五件套:app.ts / config.ts / utils/logger.ts / mediasoup/worker.ts
             / middlewares/internal-auth.ts
- /healthz + /readyz + /internal/info 三端点实测通过
- X-Internal-Token 鉴权用 timingSafeEqual 防侧信道
- mediasoup Worker died 指数退避自愈通过 kill -9 验证
- 多阶段 Dockerfile:非 root + curl HEALTHCHECK + 暴露 40000-40199 UDP/TCP
- Fastify 4 → 5 升级:loggerInstance: logger 消灭两个 pino 实例

【Task 2 - 9 个内部 REST API + 审查修复】
接口全部挂 /internal/v1/* 前缀,覆盖 Router/Transport/Producer/Consumer 完整生命周期:
- POST /routers, DELETE /routers/:id
- POST /transports, POST /transports/:id/connect
- POST /producers, DELETE /producers/:id
- POST /consumers, POST /consumers/:id/resume, DELETE /consumers/:id

工程特性:
- zod 手动 parse + 全局 errorHandler(不引入 fastify-type-provider-zod 避免
  zod v4 依赖冲突)
- AppError 统一错误码(NOT_FOUND/CONFLICT/CAN_NOT_CONSUME/ROUTER_LIMIT_EXCEEDED
  /MEDIASOUP_ERROR/VALIDATION_ERROR/UNAUTHORIZED/INTERNAL_ERROR)
- 所有资源用 Map + observer.once('close') 自清理,Consumer 监听 producerclose 级联关闭
- Consumer 强制 paused:true 创建,/resume 独立接口
- Transport direction 强约束:recv 拒 produce、send 拒 consume

code-reviewer 子代理审查"有条件通过",同步修复:
- M1 _clearXxxMap 新增 src/utils/test-guard.ts#assertTestOnly 守卫(生产误调用抛错)
- M2 新增 src/schemas/rtp.ts 对 rtpParameters / rtpCapabilities 做 codecs 浅层校验
  (mimeType/clockRate/payloadType 必填、codecs ≥1),消除 as unknown as 双跳断言
- m1 connectTransport 改乐观锁:先置位再 await,失败回退
- m2 改读 consumer.producerPaused(更符合 mediasoup 语义)
- m3 producerclose 改为 once(风格一致)
- m5 internal-auth 改为反向白名单 PRIVATE_PATH_PREFIXES = ['/internal/']

验证:
- typecheck / lint 0 错误
- vitest:65 passed / 8 spec 文件
- 覆盖率 stmts 82.87% / branches 75.83% / funcs 91.3% / lines 82.87%
- 9 接口 happy path + 6 类错误路径 curl 手测全部按预期返回

【文档同步】
- CURRENT_STATUS.md +232 行:新增 Task 0/1/2 完整记录 + 代码审查修复章节 + 延后清单
- project-context.mdc:Task 0/1/2 状态同步
- phase2e-2-design.md +20 行:Fastify 5 升级相关决策记录
- phase2e-2-implementation.plan.md +117 行:Task 0/1/2 实际产出 + 修复记录

余下 Minor/Nits(m4/m6~m10/n1~n10)登记至 Task 16 收尾清单一次性清扫。

Made-with: Cursor
2026-04-21 15:31:01 +08:00

271 lines
12 KiB
Markdown
Raw Permalink 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.

# Phase 2e-2 Task 0 — mediasoup PoC Spike 结论
> **状态:** ✅ 通过 / 技术栈可用性已验证
> **执行日期:** 2026-04-21
> **位置:** [`media-server/poc/`](../poc)
> **关联:**
> - [Phase 2e-2 设计文档](../../docs/plans/2026-04-21-phase2e-2-design.md)§4.1 组件拓扑 / §4.2 Go-Node 协同时序)
> - [Phase 2e-2 实施计划](../../docs/plans/2026-04-21-phase2e-2-implementation.plan.md) §Task 0
---
## 一、目标与验收
按实施计划 §Task 0 要求,用最小代码验证:
1. mediasoup Worker + 单 Router 可在本机跑起来
2. 浏览器 ↔ Node 的完整 SFU 信令链路:`rtpCapabilities → Transport → Producer → Consumer`
3. 两个浏览器上下文之间能互相收到对方的音视频流
4. peer 断开时资源能被自动回收,无泄漏
**验收结果**:全部通过。下述第 §三 章记录了实际运行数据。
---
## 二、最小可行架构PoC 版)
```
┌──────────────┐ ws://:3300/ws ┌──────────────────────┐
│ Browser A │──────信令──────────→ │ Fastify + ws │
│ (mediasoup- │←──RtpCapabilities── │ │
│ client 3) │ │ ┌───────────────┐ │
└──────┬───────┘ │ │ mediasoup │ │
│ DTLS/ICE/RTP │ │ Worker │ │
└──────────────────────────────┼──→│ └── Router │ │
│ │ ├─Trans. │ │
┌──────────────────────────────┼──→│ ├─Prod. │ │
│ DTLS/ICE/RTP │ │ └─Cons. │ │
┌──────┴───────┐ │ └───────────────┘ │
│ Browser B │──────信令──────────→ │ /healthz /stats │
└──────────────┘ └──────────────────────┘
```
PoC 刻意简化:**无权限、无房间、无鉴权**,全部 peer 加入同一个全局 Router每个 peer 订阅所有其他 peer。真实 Go-Node 架构(设计文档 §4.2)中由 Go 承载这些权威状态。
信令协议PoC 自定义,**与正式业务 WS 事件互不相关**,仅用于验证 mediasoup API
| 消息 | 方向 | 用途 |
|---|---|---|
| `getRtpCapabilities` | C→S | 取 Router capabilities |
| `createTransport {direction}` | C→S | 创建 send / recv Transport |
| `connectTransport {transportId, dtlsParameters}` | C→S | DTLS 握手 |
| `produce {transportId, kind, rtpParameters}` | C→S | 推流 |
| `consume {transportId, producerId, rtpCapabilities}` | C→S | 订阅远端流 |
| `resumeConsumer {consumerId}` | C→S | 恢复 paused consumer |
| `newProducer {peerId, producerId, kind}` | S→C 广播 | 通知其他 peer 新流出现 |
| `peerLeft {peerId}` | S→C 广播 | 通知其他 peer 离开 |
---
## 三、实际运行数据2026-04-21
### 3.1 启动阶段
```
mediasoup worker + router ready
workerPid: 77170
routerId: "f21e2f6a-1361-413c-8f2a-41a8290d0611"
rtcPortRange: "40000-40099"
listenIp: "0.0.0.0"
announcedIp: "(unset, use local LAN ip)"
PoC ready: http://localhost:3300
```
`/healthz` 返回 `{ok:true, workerPid, routerId, peers:0}`,服务就绪。
### 3.2 2 peer 会议Playwright 驱动 Chrome 双 tab
两个 tab 分别点击「加入并推流」控制台日志Tab A
```
WS connected as peer adb1eff1
mediasoup Device loaded
local audio producer: 51c16d40
local video producer: 20d0d275
existing remote producers: 0 ← Tab A 先进,房间空
remote producer: peer=8dcec923 kind=audio ← Tab B 加入后广播
remote producer: peer=8dcec923 kind=video
consuming audio from 8dcec923
consuming video from 8dcec923
```
Tab B
```
existing remote producers: 2 ← Tab B 后进,拿到 A 的两条 producer
consuming audio from adb1eff1
consuming video from adb1eff1
```
**双向互通成立**。浏览器端 WebRTC `iceGatheringState` 正常走到 `complete``connectionState` 正常走到 `connected`
### 3.3 资源统计(`/stats` 实测)
| 阶段 | peers | transports | producers | consumers | RSS(MB) |
|---|---:|---:|---:|---:|---:|
| 空闲 | 0 | 0 | 0 | 0 | 60 |
| Tab A 加入 | 1 | 2 | 2 | 0 | 60 |
| Tab B 加入 | 2 | 4 | 4 | 4 | 61 |
| Tab B 关闭 | 1 | 2 | 2 | 0 | 64 |
**资源回收链路**WS `close``cleanupPeer` → 级联 close producers/consumers/transports → mediasoup `transportclose`/`producerclose` 事件 → 对端 consumer 自动清理 → 广播 `peerLeft`。实测对端 consumers 自动归零,无泄漏。
### 3.4 线性外推(单 Worker 单 Router
| N 人会议 | transports | producers | consumers | 单 peer 入向 track |
|---:|---:|---:|---:|---:|
| 2 | 4 | 4 | 4 | 2 |
| 4 | 8 | 8 | 24 | 6 |
| 6 | 12 | 12 | 60 | 10 |
| 8 | 16 | 16 | 112 | 14 |
consumers 数量平方级增长:`N × (N-1) × 2`。MVP 硬上限 8 人Node 单进程承载毫无压力;以 baseline `61MB / 2 peers` 推断8 人会议常驻约 200MB RSS远低于风险阈值。
---
## 四、关键坑与决策锁定
### 4.1 `announcedIp` 在 localhost 的处理
- **现象**:本机 Demo 场景下,若 `announcedIp` 留空 + `listenIp="0.0.0.0"`mediasoup 生成的 `iceCandidates` 会包含 `0.0.0.0` 条目Chromium 会自动替换为 `127.0.0.1` 与可用 LAN IP 完成 ICE 协商
- **结论**:本机 Demo **留空 `MEDIASOUP_ANNOUNCED_IP` 即可工作**,不必显式配 `127.0.0.1`,否则反而会让远端浏览器只拿到回环地址从而无法在同机多 tab 之外跑通
- **公网**:必须填服务器对外公网 IP或 A 记录解析到的 IP否则 ICE Candidate 全是内网 IP 客户端无法连上
### 4.2 DTLS 握手
- 默认 `enableUdp:true + enableTcp:true + preferUdp:true` 组合开箱即用
- 本机 Demo 全程走 UDP公网若遇对称 NAT 走 coturn TURN 转发到 UDP
- **未遇到 DTLS 超时**;若后续线上出现,排查顺序:`udp 40000-40199 防火墙``announcedIp 正确性``TURN 凭证有效性``dtlsParameters 传参顺序(先 create 后 connect`
### 4.3 mediasoup-client 在浏览器端的加载
- mediasoup-client **未提供预构建 UMD bundle**,不能用 `<script src>` 直接引入
- PoC 零构建步骤,使用 `https://esm.sh/mediasoup-client@3` 通过 ESM CDN 加载(实测解析到 3.19.0
- **正式 frontend 模块Task 9必须改为 `npm i mediasoup-client@^3` + vite 打包**,避免线上依赖公共 CDN
### 4.4 Consumer 必须 paused-then-resume
- `transport.consume({paused:true})` → 客户端 `transport.consume(...)` → 服务端 `resumeConsumer` → 下发首帧
- 理由:若直接 `paused:false` 启动,某些浏览器会在 consumer 还没完成 RTP SDP 协商前就开始渲染,导致前几百毫秒丢帧或画面静止
- **本 PoC 完整复现了这个模式**Task 2 / Task 9 需延续
### 4.5 Worker 崩溃保护
- `worker.on('died')` 在 PoC 中直接 `process.exit(1)` 触发外部重启
- **Task 1 正式实现** 需要按设计文档 §7.3 的约定升级为"监听 `died` → 重启 Worker + 通过内部通道告知 Go 层所有房间重建"
### 4.6 Simulcast 三档 encodings
PoC 已启用 3 档 encodings 并成功推拉:
```js
encodings: [
{ maxBitrate: 150_000, scaleResolutionDownBy: 4 },
{ maxBitrate: 400_000, scaleResolutionDownBy: 2 },
{ maxBitrate: 1_000_000 },
]
```
与设计文档 §7.4 / §11.5 的三档档位完全一致MVP 可直接复用这组参数。
### 4.7 Playwright 自动化兼容性
Playwright 启动的 Chromium 在无真实摄像头环境下会使用**内置 fake 媒体源**`--use-fake-device-for-media-stream` 默认启用),`getUserMedia` 能返回带假画面/静音的 MediaStreamPoC 验证完全可自动化。这项能力为后续 Task 16 的 E2E 自动化扫清障碍。
---
## 五、技术栈可用性判定Task 0 决策关卡)
| 维度 | 判定 |
|---|---|
| mediasoup v3 在 Node 24 下可用 | ✅(原生模块 C++ 编译 53 秒内完成,无 Python/GCC 警告) |
| Fastify 4 + @fastify/websocket 信令栈 | ✅ |
| mediasoup-client 3 在最新 Chromium 可用 | ✅(通过 esm.sh 加载) |
| PoC 代码量 | 约 250 行 server + 220 行 client总 ~500 行 |
| 学习曲线 | 比预期低Transport/Producer/Consumer 三件套概念清晰) |
| 是否需要改选型livekit-server | ❌ 不需要mediasoup 选型成立 |
**决策**:维持原选型 **mediasoup v3 + fastify + mediasoup-client 3**,按实施计划推进 Task 1-2正式 `media-server/` 骨架 + 9 个 Internal REST API
---
## 六、对后续 Task 的输入
### Task 1media-server 项目骨架)直接复用
- `package.json` 的依赖列表mediasoup `3.14.11` / fastify `4.28.1` / pino可直接平移
- `createWorker({rtcMinPort,rtcMaxPort,logLevel:"warn"})` + `createRouter({mediaCodecs})` 的代码范式
- Worker `died` 事件监听 + 自动退出给外部进程管理器
### Task 2Node 9 个 REST API直接复用
- `router.createWebRtcTransport(listenIps, enableUdp, enableTcp, preferUdp, initialAvailableOutgoingBitrate)` 参数已验证
- Consumer 必须先 `paused:true`,再通过独立 API `resume` 的契约
- Transport / Producer / Consumer 的 `Map<id, resource>` 管理模式
- 资源回收依赖 mediasoup 内建事件(`transportclose` / `producerclose` / `observer.close`Node 层无需主动追踪
### Task 9前端 mediasoup-client + Pinia Store直接复用
- `SignalClient` 的 Promise 化 WS 请求(`reqId` 配对)可升级为 `utils/ws.js` 的 meeting 模块分发器
- `sendTransport.on('connect')``on('produce')` 的 callback-to-Promise 桥接模式
- Consumer 创建后调 `resumeConsumer` 的两步流程
### Task 14双态部署已验证参数
- `MEDIASOUP_LISTEN_IP` / `MEDIASOUP_ANNOUNCED_IP` / `MEDIASOUP_RTC_MIN_PORT` / `MEDIASOUP_RTC_MAX_PORT` 四个环境变量的语义与默认值
- UDP 端口范围 MVP 使用 `40000-40199`200 端口,可承载约 25 用户级 Transport
---
## 七、PoC 清理与归档
PoC 代码保留在 [`media-server/poc/`](../poc/),不进入正式生产路径:
- `server.mjs`248 行)
- `public/index.html`101 行)
- `public/client.mjs`215 行)
- `package.json`(依赖清单)
- `.gitignore``node_modules/`
**后续处理策略**
1. Task 1-2 完成后,`media-server/poc/` 作为参考样例保留在仓库,方便新成员上手
2. Task 16 收官时若归档到单独分支,可从主干移除
---
## 八、启动步骤(供后续开发者复现)
```bash
# 1. 安装依赖mediasoup 需要编译 C++macOS 需 Xcode Command Line Tools
cd media-server/poc
npm install
# 2. 启动(默认监听 0.0.0.0:3300
node server.mjs
# 3. 健康检查
curl http://localhost:3300/healthz
curl http://localhost:3300/stats
# 4. 浏览器验证:打开两个 Chrome 窗口(或 Chrome + Firefox访问 http://localhost:3300/
# 两边都点「加入并推流」→ 授权摄像头/麦克风 → 应能互相看到对方画面
```
**环境变量(可选)**
```bash
HTTP_PORT=3300 # HTTP/WS 监听端口
MEDIASOUP_LISTEN_IP=0.0.0.0 # RTCTransport 监听
MEDIASOUP_ANNOUNCED_IP= # 本机留空;公网填服务器公网 IP
MEDIASOUP_RTC_MIN_PORT=40000
MEDIASOUP_RTC_MAX_PORT=40099
```
---
## 九、变更记录
| 日期 | 作者 | 内容 |
|---|---|---|
| 2026-04-21 | Agent | Task 0 PoC Spike 首版落盘,技术栈可用性验证通过,维持 mediasoup 选型 |