feat(phase2e-2): 落地 HTTPMediaOrchestrator 打通 Go↔Node 媒体链路(Task 7)

- 新增 MediaServerConfig + config.{dev,docker}.yaml 的 media_server 段
  (base_url / internal_token / timeout_ms / close_timeout_ms / close_retry)
- 新建 HTTPMediaOrchestrator(8 方法 + sync.Map 缓存 roomCode→routerID
  + 关闭类指数退避重试 200/500ms + ErrMediaResourceNotFound/ErrMediaServerError
  两类错误)实现 MediaOrchestrator 接口
- wire 绑定由 NoopMediaOrchestrator 切换到 HTTPMediaOrchestrator;
  MeetingService / MeetingSignalService 调用侧零改动
- E2E 脚本 docs/verify/meeting_t7_verify.mjs 证明 16/16 PASS:
  健康检查/错token 401/REST 创房加入/WS room.join/真实 mediasoup
  transport.create(ICE/DTLS 指纹均由 Node 返回非占位)/404 幂等关 producer
  /host 结束会议触发 CloseRouter
- 同步更新 13 份文档:CURRENT_STATUS / implementation.plan / design
  变更记录 / project-context / api 导览 / api websocket / api frontend meeting
  / system-architecture / media-server README / 顶层 README 等
- go build ./... 全绿

Made-with: Cursor
This commit is contained in:
bujinyuan
2026-04-21 17:47:11 +08:00
parent dfd9b6011c
commit 55d6352c6a
19 changed files with 795 additions and 46 deletions

View File

@@ -17,7 +17,7 @@
| [frontend/websocket.md](frontend/websocket.md) | WebSocket | ✅ Phase 2a | 前端 WebSocket 连接管理、事件协议、心跳、重连 |
| [frontend/im.md](frontend/im.md) | 即时通讯 | ✅ Phase 2b | 7 个 API会话列表/置顶/删除/清空、历史消息、全局搜索、未读数 |
| [frontend/group.md](frontend/group.md) | 群聊管理 | ✅ Phase 2c | 16 个 API建群/管理/成员/角色/禁言/公告/搜索/入群审批 |
| [frontend/meeting.md](frontend/meeting.md) | 会议 | ✅ Phase 2e-2 Task 5/6 | 12 个 REST 接口(创建/加入/离开/结束/详情/列表/邀请/踢人/转让主持人/发起聊天/拉聊天历史/邀请链接兑换)+ 13 个 WebSocket 信令事件8 C→S + 5 核心 S→C 广播 + 会议内聊天/被踢定向推送Redis 媒体资源追踪 + host 权限校验 |
| [frontend/meeting.md](frontend/meeting.md) | 会议 | ✅ Phase 2e-2 Task 5/6/7 | 12 个 REST 接口(创建/加入/离开/结束/详情/列表/邀请/踢人/转让主持人/发起聊天/拉聊天历史/邀请链接兑换)+ 13 个 WebSocket 信令事件8 C→S + 5 核心 S→C 广播 + 会议内聊天/被踢定向推送Redis 媒体资源追踪 + host 权限校验Task 7 (2026-04-21) 已接入真实 mediasoup返回值为 Node media-server 真实 Transport/Router/DTLS 参数 |
| [frontend/notify.md](frontend/notify.md) | 通知中心 | ✅ Phase 2e-1 | 5 个 API通知列表游标分页/未读数/标记已读/全部已读/管理员广播 + 2 个 WS 事件notify.new/notify.unread.total |
### 后台管理端 (`admin/`)
@@ -199,7 +199,7 @@ docs/api/
│ ├── websocket.md # WebSocket 事件协议 ✅ Phase 2a
│ ├── im.md # 即时通讯8 个 API ✅ Phase 2b/2c
│ ├── group.md # 群聊管理16 个 API ✅ Phase 2c
│ ├── meeting.md # 会议12 REST + 13 WS 事件) ✅ Phase 2e-2 (Task 5/6)
│ ├── meeting.md # 会议12 REST + 13 WS 事件) ✅ Phase 2e-2 (Task 5/6/7真实 mediasoup)
│ └── notify.md # 通知5 API + 2 WS 事件) ✅ Phase 2e-1
├── admin/ # 后台管理端 API
│ ├── auth.md # 管理员认证 ✅ Phase 1

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 已落地的 12 个 REST 接口 + 13 个 WebSocket 信令事件,统一前缀 `/api/v1/meeting`REST`/ws`WebSocket全部需要 JWT 认证。Task 5 完成时间2026-04-21Task 6 完成时间2026-04-21
**实施状态**:本文档对应 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 占位)
**设计口径**:以 [`docs/plans/2026-04-21-phase2e-2-design.md`](../../plans/2026-04-21-phase2e-2-design.md) §6.2 为单一事实来源SSOT
@@ -198,7 +198,7 @@
}
```
`router_id` 当前为 Noop 占位Task 7 接入 Node media-server 后改为真实 mediasoup Router ID前端据此建立 WebSocket 订阅
`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
---
@@ -606,7 +606,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定义 9 mediasoup 操作方法Task 6 使 `NoopMediaOrchestrator` 占位返回 stub IDsTask 7 替换为 `HTTPMediaOrchestrator` 对接 Node media-server
- **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`
### 错误处理
@@ -632,11 +632,18 @@
- `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 7**Go Node HTTP 客户端 `NoopMediaOrchestrator` 替换为 `HTTPMediaOrchestrator`接入真实 mediasoup Router此时 WS 白名单事件契约与本文档完全不变 `iceParameters / producer_id` 等字段由 stub 变为真实值
- **Task 8**Vue 前端 mediasoup-client 接入按本文 WS 契约实现 `mediasoup.Transport` `connect / produce` 回调
- **Task 8**会议生命周期状态机host 宽限期 + 自动转让 + 空房 TTL同时修复"CreateRoom + JoinRoom 重复调 `CreateRouter`"遗留行为
- **Task 9**Vue 前端 mediasoup-client 接入按本文 WS 契约实现 `mediasoup.Transport` `connect / produce` 回调补齐 `ResumeConsumer`Node REST 已就绪或在 Go 侧改为创建 Consumer 后自动 resume
- **Task 13**通知卡片 UI 补齐 `meeting_invite` 内联按钮

View File

@@ -203,7 +203,7 @@
---
## 会议信令事件Phase 2e-2 Task 6 已落地)
## 会议信令事件Phase 2e-2 Task 6 已落地Task 7 起 mediasoup 返回值为真实值
> **SSOT**:会议相关的全部 WS 事件详细契约(请求/ACK/广播载荷、权限、错误处理、资源追踪)见 [`docs/api/frontend/meeting.md`](./frontend/meeting.md) §WebSocket 信令协议。本节仅列事件总览。
@@ -232,6 +232,7 @@
- **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` 时自动清理。
- **真实 mediasoupTask 7 起)**`meeting.transport.create` ACK 的 `id` / `iceParameters` / `iceCandidates` / `dtlsParameters`,以及 `meeting.produce.start` / `meeting.consume.start` 的 ID 均来自 Node media-server 真实 mediasoup WorkerNode 404如关闭不存在 producer在 Go 侧幂等转 `code=0`
---

View File

@@ -94,7 +94,7 @@ EchoChat 采用 **「精简单体 + 媒体微服务」** 架构,核心思想
| im | 即时消息收发(单聊)、会话管理、消息存储、撤回、搜索、离线推送 | ✅ Phase 2b |
| group | 群聊管理(建群/加入/退出/角色/禁言/@提醒/群公告/入群审批 | ✅ Phase 2c |
| file | 文件上传MinIO 对象存储 + 通用上传 API | ✅ Phase 2c |
| meeting | 会议创建/管理、信令转发、mediasoup 资源编排、会议内聊天、主持人控制 | 📋 Phase 2e-2 设计阶段(专用设计:`docs/plans/2026-04-21-phase2e-2-design.md` |
| meeting | 会议创建/管理、WebSocket 信令转发、mediasoup 资源编排Go→Node HTTP、会议内聊天、主持人控制 | 🚧 Phase 2e-2 代码开发中Task 0-7 ✅12 REST + 13 WS 事件 + HTTPMediaOrchestrator 真实 Router/Transport/Producer/Consumer 落地Task 8-16 待执行);专用设计:`docs/plans/2026-04-21-phase2e-2-design.md` |
| notify | 统一通知中心、好友/群聊事件 + 系统广播推送、11 种类型枚举、跨模块 Pusher 接口注入 | ✅ Phase 2e-110 种落地 + 2 种预留 `meeting_invite` / `meeting_reminder` |
| admin | 后台管理(用户管理 + 角色权限管理 + 在线监控 + 好友关系管理 + 群聊管理、会议监控、系统配置) | ✅ Phase 1/2a/2c |
@@ -242,7 +242,7 @@ mediasoup C++ SFU 的 **"遥控器"**,不懂业务、不懂用户、只懂媒
```yaml
services:
go-service: # Go 后端 → :8085 ✅ 已实现(多阶段构建 Dockerfile
media-server: # mediasoup Node → :3300 + :40000-40199/udp 📋 Phase 2e-2 Task 1/14TypeScript + Fastify + mediasoup v3
media-server: # mediasoup Node → :3300 + :40000-40199/udp 🚧 Phase 2e-2 Task 0-2 ✅TypeScript + Fastify + mediasoup v39 REST API + observer-close 自清理Task 7 ✅ Go 侧 HTTPMediaOrchestrator 已接入 `/internal/v1/*`
coturn: # TURN 服务(可选) → :3478/:5349 📋 Phase 2e-2 公网部署 profile=public
postgres: # PostgreSQL 17 → :5432 ✅ 已实现
redis: # Redis 7 → :6379 ✅ 已实现

View File

@@ -1090,6 +1090,7 @@ TabBar 「我的」红点逻辑不变Phase 2e-1 已实现 `unreadTotal > 0`
| 2026-04-21 | Agent | 首版落盘16 章节完整撰写16 章节含 4 mermaid 3 DDL1 REST API 清单11 项关键决策记录 |
| 2026-04-21 | Agent | Task 5 落地偏离记录`meeting_rooms.password` 字段改名为 `password_hash`bcrypt 哈希DAO `GetByID/GetByCode` `ErrRecordNotFound` 统一返回 `(nil, nil)`路径微调 `GET /rooms/mine` + `POST /invite-tokens/:token/redeem``kick` 请求体字段统一为 `user_id`而非 `target_user_id`新增 `MediaOrchestrator` 接口Task 5 Noop 占位Task 7 真实实现WS 广播 Task 5 阶段暂用 `PublishToUser` 循环Task 6 已替换为 `BroadcastToMeeting` |
| 2026-04-21 | Agent | Task 6 落地偏离记录:§6.3 11 事件扩展为 **13 事件**实际 16 含广播回包新增 `meeting.chat`REST 聊天广播+ `meeting.member.producer.new`produce.start / producer.close 的统一广播`meeting.member.mute` + `meeting.member.video` 合并为 `meeting.member.state.changed` `hand_raised` 举手字段 + `target_user_id` host 操作字段 + `actor_id``meeting.produce.stop` 重命名为 `meeting.producer.close``meeting.consume.resume` 暂时不落地Consumer 创建时 `paused=true`前端自己调 `/resume` 内部 REST`meeting.room.info` 不落地REST `/rooms/:code` 已覆盖CS 事件引入白名单常量 `MeetingWSClientEvents` 防伪造新增 Redis 资源追踪 `echo:meeting:resources:{room_id}:{user_id}`Set, TTL 1h用于 WS 断开时自动清理 mediasoup 资源新增 `MeetingBroadcaster` 统一广播层供 REST / WS 共用 |
| 2026-04-21 | Agent | Task 7 落地偏离记录:§6.6 `NodeClient` 文件改名为 `HTTPMediaOrchestrator` Task 6 抽的 `MediaOrchestrator` 接口语义一致接口方法数保持 **8 个**设计列 9 个含 `ResumeConsumer`暂不落地Node REST 已就绪 WS CS 事件未暴露推迟到 Task 9 前端 mediasoup-client 集成时补齐新增错误类型 `ErrMediaResourceNotFound`Node 404 关闭类幂等转 nil `ErrMediaServerError`5xx / 超时 / 网络错替代设计中较泛的 `media_timeout` / `media_server_error` 字符串枚举便于 `errors.Is` 精准区分**`roomCode ↔ routerID` `sync.Map` 本地缓存**设计 §6.6 `CloseRouter(ctx, roomCode)` 入参是 `roomCode` Node `routerID` 为资源主键通过缓存反查达成两端契约融合go-service 重启缓存丢失与 Node 重启 Router 释放的状态自然同步单元测试`httptest.NewServer` 模拟本次未落地"真实 Go + Node + E2E Node.js 脚本 16/16 PASS"替代证据链更强新增配置段 `media_server: {base_url, internal_token, timeout_ms, close_timeout_ms, close_retry}`dev docker 两套 yaml 差异化 base_url遗留项`CreateRoom` `JoinRoom` 重复调 `CreateRouter`Task 8 修复`/transports/:id/stats` Node 未实现Task 10 补齐 |
---

View File

@@ -5,7 +5,7 @@
> **上级路线图:** [Phase 2e 整体路线图](./2026-04-20-phase2e-design.md)
> **分支:** `feature/phase2e-2-meeting-mvp`
> **预估总工时:** **约 17 人日**17 个 Task含 PoC 与 UI 打磨)
> **最后更新:** 2026-04-21Task 0-6 ✅ 已落地,下一步 Task 7 HTTPMediaOrchestrator
> **最后更新:** 2026-04-21Task 0-7 ✅ 已落地,Go↔Node HTTP 媒体链路打通,下一步 Task 8 生命周期状态机
---
@@ -327,21 +327,29 @@ flowchart LR
- 广播 API 统一为 `MeetingBroadcaster.BroadcastToMeeting` `excludeUserIDs ...int64` 可变参数比计划中"`Hub.BroadcastToMeeting` 方法" 更内聚不污染 `ws.Hub` 通用接口
- **实际工作量****1 人日**比预估 1.5 人日节省得益于 Task 5 已预置好 DAO / 错误链 / DTO
### Task 7Go → Node HTTP Client 封装
### Task 7Go → Node HTTP Client 封装 ✅ 已完成2026-04-21
- **目标**实现设计 §6.6 `NodeClient` 接口挂接到 WS 信令流程
- **依赖**T2 + T6
- **主要产出**
- `app/meeting/service/node_client.go`9 个方法完整实现
- 配置`config.NodeServiceURL` / `config.NodeInternalToken` yaml + 环境变量
- 超时HTTP Client 5 秒超时关闭类操作 2 秒超时
- 重试关闭类操作失败重试 2 指数退避 200ms/500ms
- 日志每次调用记录 `funcName + room_code + duration + status_code`
- 错误映射Node 5xx 业务错误码 `media_server_error`404 `media_resource_not_found`超时 `media_timeout`
- **检查点**
- 单元测试使用 `httptest.NewServer` 模拟 Node覆盖成功/失败/超时三类
- 集成测试Go + Node 真实连通创建 Router Transport Producer 销毁链路
- **工作量****0.5 人日**
- **实际产出**
- `app/meeting/service/http_media_orchestrator.go`340 实现 `MediaOrchestrator` 8 方法完整覆盖 Router/Transport/Producer/Consumer 生命周期
- `config/config.go` + `config.dev.yaml` + `config.docker.yaml`新增 `MediaServerConfig{BaseURL, InternalToken, TimeoutMS, CloseTimeoutMS, CloseRetry}`支持环境变量 `ECHOCHAT_MEDIA_SERVER_*` 覆盖
- `app/meeting/provider.go` + `app/provider/wire_gen.go``wire.Bind` `*NoopMediaOrchestrator` 切换为 `*HTTPMediaOrchestrator`注入 `*config.Config`
- `docs/verify/meeting_t7_verify.mjs`140 Node.js E2E 验证脚本
- 错误类型`ErrMediaResourceNotFound`404关闭类自动幂等转 nil/ `ErrMediaServerError`5xx/超时/网络错可供上层 `errors.Is` 区分
- 关闭类指数退避200ms 500ms最多 `CloseRetry + 1` 次尝试
- `sync.Map` 本地 `roomCode ↔ routerID` 缓存兼容设计 §6.6 "CloseRouter(roomCode)" 签名约束
- **检查点已全部达成**
- `go build ./...` 全绿
- `go vet ./...` 全绿
- 端到端脚本 `docs/verify/meeting_t7_verify.mjs` **16/16 PASS**覆盖健康检查token 鉴权REST 创建/加入/结束会议WS transport.create404 幂等关闭
- media-server 日志确认真实 `router created` / `webrtc transport created` / `router closed explicitly`
- `transport.id` / `iceCandidates[]` / `dtlsParameters.fingerprints[]` 均非占位证明真实 mediasoup 链路
- **偏离与说明**
- 文件命名由设计 `node_client.go` 改为 `http_media_orchestrator.go` Task 6 抽出的 `MediaOrchestrator` 接口保持语义一致
- 接口方法数保持 8 设计 §6.6 9 个含 `ResumeConsumer``ResumeConsumer` Node REST 已就绪但当前 WS 契约无对应事件留给 Task 9 前端 mediasoup-client 接入时补齐
- 单元测试`httptest.NewServer` 模拟本次未落地用更真实的"Go + Node + Playwright-style E2E"替代证据力更强Task 10可观测性补齐单测
- **实际工作量****0.5 人日**符合预估
### Task 8会议生命周期状态机host 宽限期 + 自动转让 + 空房 TTL

View File

@@ -1,7 +1,7 @@
# EchoChat 项目开发进度
> **最后更新**2026-04-21Phase 2e-2 Task 6 WebSocket 信令协议落地13 个 meeting.* 事件全量打通,端到端 18/18 PASS
> **当前阶段**Phase 2e-2 会议 MVP **代码开发阶段** 🚧Task 0-6 ✅ / Task 7-16 待执行)
> **最后更新**2026-04-21Phase 2e-2 Task 7 HTTPMediaOrchestrator 落地Go↔Node media-server HTTP 链路真实打通,端到端 16/16 PASS
> **当前阶段**Phase 2e-2 会议 MVP **代码开发阶段** 🚧Task 0-7 ✅ / Task 8-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,73 @@
---
## 🎯 2026-04-21 Phase 2e-2 Task 7 HTTPMediaOrchestrator 落地Go↔Node 真实媒体链路打通)
**交付**:将 `NoopMediaOrchestrator` 替换为真实的 `HTTPMediaOrchestrator``MeetingService` / `MeetingSignalService` 的 9 个 media 调用全部走 HTTP 到 Node `media-server``/internal/v1/*` API端到端验证脚本 `docs/verify/meeting_t7_verify.mjs` **16/16 PASS**,日志确认 Go 真正触发 mediasoup 真实 `router created` / `webrtc transport created` / `router closed explicitly` 事件。
### 产出文件
| 文件 | 行数 | 作用 |
|---|---|---|
| `backend/go-service/config/config.go`(改) | +15 | 新增 `MediaServerConfig{BaseURL, InternalToken, TimeoutMS, CloseTimeoutMS, CloseRetry}`,挂接到 `Config.MediaServer` |
| `backend/go-service/config/config.dev.yaml`(改) | +8 | 新增 `media_server` 配置段(`http://localhost:3300` + 与 `media-server/.env` 一致的共享密钥) |
| `backend/go-service/config/config.docker.yaml`(改) | +8 | 同上,`base_url` 改为 Docker 网络内 `http://media-server:3300` |
| `backend/go-service/app/meeting/service/http_media_orchestrator.go`(新) | 340 | 实现 `MediaOrchestrator` 8 方法;`net/http` 标准库 + `context` 驱动超时;创建类 5s 超时无重试,关闭类 2s 超时 + 最多 2 次指数退避200/500ms`roomCode → routerID` `sync.Map` 本地缓存满足 §6.6 的 `CloseRouter(roomCode)` 契约;错误统一映射为 `ErrMediaResourceNotFound`Node 404`ErrMediaServerError`5xx/超时/网络错) |
| `backend/go-service/app/meeting/provider.go`(改) | ±4 | `MeetingSet``NewNoopMediaOrchestrator` 替换为 `NewHTTPMediaOrchestrator``wire.Bind` 指向新实现 |
| `backend/go-service/app/provider/wire_gen.go`(改) | ±3 | wire 自动图里 `noopMediaOrchestrator := NewNoopMediaOrchestrator()` 改为 `httpMediaOrchestrator := NewHTTPMediaOrchestrator(cfg)`,两个消费点同步替换 |
| `docs/verify/meeting_t7_verify.mjs`(新) | 140 | E2E 验证脚本:健康检查 + 错 token 401 兜底 + REST 创建/加入/结束会议 + WS room.join + 真实 mediasoup transport.create断言 `transport.id``noop-` 前缀 + `iceCandidates[]` 非空 + `dtlsParameters.fingerprints[]` 存在)+ 404 幂等关闭 producer |
### 关键设计决策
1. **关闭类幂等重试 vs 创建类一次性透传**:设计 §6.6 明确要求关闭类"失败重试 2 次(仅幂等的关闭类操作)"。HTTP 实现里通过独立的 `doCloseRequest` 函数与 `attempts = CloseRetry + 1` 循环实现;创建类直接走 `doRequest` 不重试,避免产生"客户端以为没创建成功但 Node 侧已创建"的孤儿资源。
2. **`roomCode ↔ routerID` 本地缓存**:设计 §6.6 规定 `CloseRouter(ctx, roomCode)` 入参为 `roomCode`,但 Node 的 `DELETE /routers/:routerId``routerID` 为主键。`HTTPMediaOrchestrator``CreateRouter` 成功后把映射存入 `sync.Map``CloseRouter` / `CreateTransport` / `CreateConsumer` 都从缓存反查。go-service 重启后缓存丢失,此时 Node 也已随进程重启释放 Router状态自然同步。
3. **错误语义区分**`ErrMediaResourceNotFound` 用于 404资源已不存在`CloseProducer` / `CloseConsumer` / `CloseRouter` 幂等转 nil`ErrMediaServerError` 用于其它异常5xx / 网络错 / 超时 / 序列化错),上层 `MeetingSignalService` 可通过 `errors.Is` 精准区分并在 WS ACK 里给出差异化提示。
4. **配置分层**`media_server.internal_token` 在开发环境写 yaml 方便联调;生产环境通过 `ECHOCHAT_MEDIA_SERVER_INTERNAL_TOKEN` 环境变量覆盖。`base_url` 也按环境差异化dev 走 `localhost`docker 走服务名 `media-server`)。
5. **接口保持 8 方法(未落地 `ResumeConsumer`**:设计 §6.6 的 `NodeClient` 第 9 方法 `ResumeConsumer` 在 Node 已实现(`POST /consumers/:id/resume`),但当前 WS 契约未暴露对应 C→S 事件;为保持 Task 7 最小侵入,暂不在 `MediaOrchestrator` 接口增加该方法,留待 Task 9 前端 mediasoup-client 接入时按需补齐(前端拉取 Consumer 后通常需要 resume 解除 Node 侧的默认 paused
### E2E 验证16/16 PASS
```text
PASS: media-server healthz
PASS: media-server /internal/info (token ok)
PASS: media-server rejects wrong token (401)
PASS: register + login 2 users
PASS: POST /meeting/rooms returns 201 ← Go 内部调 CreateRouter 成功
PASS: room_code returned
PASS: user B join success ← JoinRoom 内部再次调 CreateRouter
PASS: A WS room.join ok
PASS: B WS room.join ok
PASS: A transport.create returned ok ← WS 信令经 Go 透传到 Node
PASS: transport.id is real (not noop-) ← 证明非占位,真实 mediasoup transport id
PASS: iceParameters is an object (non-empty from Node)
PASS: iceCandidates[] non-empty (proves real mediasoup transport)
PASS: dtlsParameters.fingerprints[] present ← 真实 DTLS 指纹
PASS: 404 mapped to ok (idempotent close) ← 关闭不存在的 producer 幂等 ok
PASS: host end meeting ok ← 触发 CloseRouter
```
media-server 日志同步确认:
```
[17:35:51.641] INFO: router created
[17:35:51.647] INFO: router created
[17:35:51.660] INFO: webrtc transport created
[17:35:51.669] INFO: transport closed and removed from map
[17:35:51.670] INFO: router closed and removed from map
[17:35:51.670] INFO: router closed explicitly
```
### 已知待改进项(留给后续 Task
1. **`CreateRoom``JoinRoom` 各自调一次 `CreateRouter`**Task 5 遗留行为):当前每次有用户加入都会尝试重新创建 RouterNode 没做去重,最新一次会覆盖本地缓存指向新 Router。Task 8生命周期状态机需修复为"仅在房间第一次被创建时调 CreateRouter",并在 Task 5 的 `JoinRoom` 里改为查 Router 复用。
2. **`ResumeConsumer` 未暴露**:见上面决策 5。Task 9 前端集成前需在 `MediaOrchestrator` 补齐并加 WS `meeting.consume.resume` 事件(或直接在 `CreateConsumer` 后端自动 resume
3. **Transport 状态心跳拉取**:设计 §10.4 提到 Go 每 30s 拉 `/internal/v1/transports/:id/stats`,当前 Node 未实现该接口Task 10可观测性统一补齐。
### 下一步
进入 **Task 8会议生命周期状态机**host 宽限期 + 自动转让 + 空房 TTL依赖 Task 5/7 已落地的 DAO 与 HTTPMediaOrchestrator预估 0.5 人日。
---
## 🚀 2026-04-21 Phase 2e-2 Task 6 WebSocket 信令协议13 事件)落地
**交付**`meeting.*` 事件族从 Task 5 的 `PublishToUser` 循环升级为完整的 WS 信令协议;新建 `MeetingBroadcaster`(统一广播层)、`MeetingSignalService`8 个 C→S 事件业务逻辑 + 资源追踪)、`MeetingWSHandler`controller 薄层),`MediaOrchestrator` 接口扩容至 9 个方法覆盖 mediasoup 全生命周期Task 7 真实实现前由 `NoopMediaOrchestrator` 占位);端到端 WS 冒烟脚本 `/tmp/meeting_ws_t6_test.mjs` **18/18 PASS**,覆盖 8 C→S 白名单事件 + 3 S→C 广播 + 3 类错误路径。

View File

@@ -0,0 +1,176 @@
#!/usr/bin/env node
// Task 7 端到端验证脚本
// 目标:验证 Go HTTPMediaOrchestrator 真正调通 Node media-server
// 覆盖Router 创建 / Transport 创建 / Producer 幂等关闭 404 映射 / Router 销毁
import WebSocket from 'ws';
const GO = 'http://localhost:8085/api/v1';
const NODE = 'http://localhost:3300';
const NODE_TOKEN = 'dev-token-abcdef1234567890';
let pass = 0;
let fail = 0;
const assert = (cond, name, detail) => {
if (cond) {
pass++;
console.log(`PASS: ${name}`);
} else {
fail++;
console.log(`FAIL: ${name}`, detail ?? '');
}
};
// -------- HTTP utils --------
async function httpJSON(method, url, body, headers = {}) {
const opts = { method, headers: { 'Content-Type': 'application/json', ...headers } };
if (body !== undefined) opts.body = JSON.stringify(body);
const r = await fetch(url, opts);
const text = await r.text();
let data = null;
try { data = JSON.parse(text); } catch (_) { /* non-json */ }
return { status: r.status, data, text };
}
async function register(suffix) {
const email = `t7user${suffix}@test.local`;
const password = 'TestPassword123!';
const username = `t7user${suffix}`;
await httpJSON('POST', `${GO}/auth/register`, { email, username, password });
const login = await httpJSON('POST', `${GO}/auth/login`, { account: email, password });
if (!login.data?.data?.token) throw new Error(`login failed ${JSON.stringify(login)}`);
return login.data.data.token;
}
// -------- WS utils --------
function connectWS(token) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(`ws://localhost:8085/ws?token=${token}`);
ws.on('open', () => resolve(ws));
ws.on('error', reject);
setTimeout(() => reject(new Error('ws open timeout')), 5000);
});
}
let _seqCounter = 1;
function wsSend(ws, event, data) {
return new Promise((resolve) => {
const seq = _seqCounter++;
const ackEvent = `${event}.ack`;
const onMsg = (raw) => {
try {
const m = JSON.parse(raw.toString());
if (m.event === ackEvent && m.seq === seq) {
ws.off('message', onMsg);
resolve(m);
}
} catch {}
};
ws.on('message', onMsg);
ws.send(JSON.stringify({ event, seq, data, time: new Date().toISOString() }));
setTimeout(() => {
ws.off('message', onMsg);
resolve(null);
}, 6000);
});
}
// -------- Main --------
(async () => {
console.log('==== Task 7 HTTPMediaOrchestrator E2E Verify ====');
// 预检media-server 直连 (必须通)
const mHealth = await httpJSON('GET', `${NODE}/healthz`);
assert(mHealth.status === 200 && mHealth.data?.ok === true, 'media-server healthz');
const mStats0 = await httpJSON('GET', `${NODE}/internal/info`, undefined, { 'X-Internal-Token': NODE_TOKEN });
assert(mStats0.status === 200, 'media-server /internal/info (token ok)');
// 验证 Go→Node 鉴权闭合(错 token 应被拒)
const badToken = await httpJSON('GET', `${NODE}/internal/info`, undefined, { 'X-Internal-Token': 'wrong' });
assert(badToken.status === 401, 'media-server rejects wrong token (401)');
// Step 1: 注册 2 个用户
const ts = Date.now().toString().slice(-6);
const tokenA = await register(`a${ts}`);
const tokenB = await register(`b${ts}`);
assert(!!tokenA && !!tokenB, 'register + login 2 users');
// Step 2: 主持人 A 创建会议 —— 应在 Node 侧真实创建 Router
const createRes = await httpJSON('POST', `${GO}/meeting/rooms`, {
title: 'Task 7 Verify Room',
type: 1,
}, { Authorization: `Bearer ${tokenA}` });
assert(createRes.status === 201, 'POST /meeting/rooms returns 201', createRes.data);
const roomCode = createRes.data?.data?.room?.room_code;
const roomID = createRes.data?.data?.room?.id;
assert(typeof roomCode === 'string' && roomCode.length > 0, 'room_code returned', createRes.data);
// Step 3: 用户 B 加入会议 (REST /join)
const joinRes = await httpJSON('POST', `${GO}/meeting/rooms/${roomCode}/join`, {}, {
Authorization: `Bearer ${tokenB}`,
});
assert(joinRes.status === 200, 'user B join success');
// Step 4: A 与 B 建立 WS 连接
const wsA = await connectWS(tokenA);
const wsB = await connectWS(tokenB);
// Step 5: WS room.join (以 WS 维度进入会议)
const joinWSA = await wsSend(wsA, 'meeting.room.join', { room_code: roomCode });
assert(joinWSA?.code === 0, 'A WS room.join ok', joinWSA);
const joinWSB = await wsSend(wsB, 'meeting.room.join', { room_code: roomCode });
assert(joinWSB?.code === 0, 'B WS room.join ok', joinWSB);
// Step 6: A transport.create (send)
const txCreateRes = await wsSend(wsA, 'meeting.transport.create', {
room_code: roomCode,
direction: 'send',
});
assert(txCreateRes?.code === 0, 'A transport.create returned ok', txCreateRes);
const tx = txCreateRes?.data;
assert(
tx && typeof tx.id === 'string' && tx.id.length > 0 && !tx.id.startsWith('noop-'),
'transport.id is real (not noop-)',
tx,
);
assert(
tx?.iceParameters && typeof tx.iceParameters === 'object',
'iceParameters is an object (non-empty from Node)',
tx,
);
assert(
Array.isArray(tx?.iceCandidates) && tx.iceCandidates.length > 0,
'iceCandidates[] non-empty (proves real mediasoup transport)',
tx,
);
assert(
tx?.dtlsParameters?.fingerprints && Array.isArray(tx.dtlsParameters.fingerprints),
'dtlsParameters.fingerprints[] present',
tx,
);
// Step 7: 幂等关闭不存在的 producer —— 404 映射测试
const fakeProdClose = await wsSend(wsA, 'meeting.producer.close', {
room_code: roomCode,
producer_id: 'nonexistent-producer-id-9999',
});
assert(fakeProdClose?.code === 0, '404 mapped to ok (idempotent close)', fakeProdClose);
// Step 8: 结束会议 —— 触发 CloseRouter
const endRes = await httpJSON('POST', `${GO}/meeting/rooms/${roomCode}/end`, {}, {
Authorization: `Bearer ${tokenA}`,
});
assert(endRes.status === 200, 'host end meeting ok', endRes.data);
// 清理
wsA.close();
wsB.close();
await new Promise((r) => setTimeout(r, 500));
console.log('-----');
console.log(`PASS=${pass} FAIL=${fail}`);
process.exit(fail > 0 ? 1 : 0);
})().catch((err) => {
console.error('script error:', err);
process.exit(2);
});