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:
@@ -27,7 +27,7 @@ alwaysApply: true
|
||||
* 单端 WS 连接架构(沿用),不做多端已读同步(设计文档 §3.1/§3.5/§九 已修订,多端改造推迟到 Phase 2f/二期)
|
||||
* 专用设计:`docs/plans/2026-04-20-phase2e-1-design.md`;实施计划:`docs/plans/2026-04-20-phase2e-1-implementation.plan.md`;验证报告:`test-report-phase2e-1-notification.md`
|
||||
* API 文档:`docs/api/frontend/notify.md`
|
||||
- 2e-2 会议 MVP(约 17 天)🚧 **代码开发中**(Task 0-6 ✅ / Task 7-16 待执行):mediasoup Node.js 独立 `media-server/` + 即时会议(≤8 人)+ 密码/邀请链接/通知邀请三合一 + 设备预览页 + 主持人四件套 + 会议内聊天 + 双态部署(本机 + 公网 coturn)+ 响应式(桌面/手机)
|
||||
- 2e-2 会议 MVP(约 17 天)🚧 **代码开发中**(Task 0-7 ✅ / Task 8-16 待执行):mediasoup Node.js 独立 `media-server/` + 即时会议(≤8 人)+ 密码/邀请链接/通知邀请三合一 + 设备预览页 + 主持人四件套 + 会议内聊天 + 双态部署(本机 + 公网 coturn)+ 响应式(桌面/手机)
|
||||
* 专用设计:`docs/plans/2026-04-21-phase2e-2-design.md`(16 章节);实施计划:`docs/plans/2026-04-21-phase2e-2-implementation.plan.md`(17 个 Task)
|
||||
* 11 项关键决策已锁定(D01-D11),详见设计文档 §三
|
||||
* **重要修订**:`meeting_rooms.password` → `password_hash`(bcrypt),新增 `meeting_chats` 表 + `ended_reason` / `left_reason` 字段,新增 `echo:meeting:invite:{token}` / `host_grace:{code}` Redis key
|
||||
@@ -36,6 +36,7 @@ alwaysApply: true
|
||||
* **Task 2 ✅ 9 个内部 REST API 完成(2026-04-21,含代码审查修复)**:`media-server/` 落地 Router/Transport/Producer/Consumer 四类资源的 9 个接口,全部挂 `/internal/v1/*` 前缀;**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')` 自清理**,`producerclose` 级联关闭下游 consumer;Consumer 强制 `paused:true` 创建 + `/resume` 独立接口;direction 强约束(recv transport 拒 produce、send transport 拒 consume)。**code-reviewer 子代理"有条件通过"**,2 Major + 4 高价值 Minor 当场修复:(1) M1 `_clearXxxMap` 新增 `assertTestOnly` 守卫(生产误调用直接抛错);(2) M2 新增 `src/schemas/rtp.ts` 对 `rtpParameters` / `rtpCapabilities` 做 codecs 浅层校验(mimeType/clockRate/payloadType 必填、codecs 数组 ≥1),消除 `as unknown as` 双跳断言;(3) m1 `connectTransport` 改乐观锁(先置位再 await);(4) m2 改读 `consumer.producerPaused`;(5) m3 `producerclose` 改为 `once`;(6) m5 `internal-auth` 改为反向白名单 `PRIVATE_PATH_PREFIXES = ['/internal/']`(默认开放)。**65 个 vitest 测试全过(~1s)、覆盖率 82.87%/75.83%/91.3%/82.87%**(stmts/branches/funcs/lines,较首版 +2pp);9 接口 happy path + 6 类错误路径人工 curl 全部按预期返回(201/200/400/401/404/409)。产出:`src/schemas/*`(6 文件,新增 `rtp.ts`)+ `src/services/*`(4 文件)+ `src/routes/*`(4 文件)+ `src/middlewares/{error-handler,internal-auth}.ts` + `src/utils/{errors,test-guard}.ts` + `src/mediasoup/codecs.ts` + `vitest.config.ts` + `tests/*`(8 spec 文件,含 `test-guard.spec.ts`)。余下 Minor/Nits(m4/m6~m10、n1~n10)登记至 Task 16 收尾清单
|
||||
* **Task 3 ✅ Go meeting 模块数据库 DDL + Model + DAO 完成(2026-04-21)**:三张持久化表(`meeting_rooms` / `meeting_participants` / `meeting_chats`)落地 PostgreSQL,DDL 同时写入 `init.sql`(全量初始化)与 `phase2e2_migration.sql`(幂等增量升级)。Go 侧 `backend/go-service/app/meeting/{model,dao}` + 统一常量 `app/constants/meeting.go`:3 个 model + 3 个 DAO(`meeting_room_dao.go` 9 方法 / `meeting_participant_dao.go` 11 方法 / `meeting_chat_dao.go` 4 方法),共 24 个持久化方法;`JoinRoom` 事务内复用离会后的旧记录(`left_at=NULL,joined_at=NOW,duration=0`)避免审计表污染;`LeaveRoom` 用 `EXTRACT(EPOCH FROM (? - joined_at))::INT` 走 DB 时间防跨时区漂移;`TransferHost` 事务链(`role=1→0` + `role=0→1`);`FindActiveByUser` 用 JOIN 校验用户单点参会;`MarkEnded` 乐观锁防重复覆盖 `ended_reason`;`ListExpiredForCleanup` 供后续清理任务批量扫描。**关键风格修正(偏离实施计划草案)**:按 `project-context` 第 11 条「代码风格全局一致(最高优先级)」,常量归入 `app/constants/meeting.go` 单文件(与 `group.go`/`notify.go` 同构),而非草案的 `app/meeting/constants/*.go`;时间字段统一 `TIMESTAMP(0)` 取代草案的 `TIMESTAMPTZ` 对齐项目所有现有表;冗余 `idx_meeting_rooms_code` 移除(`room_code UNIQUE` 已自动建索引)。验证:`go build ./...` / `go vet ./...` / `ReadLints` 零错误;psql 集成脚本跑通 8 场景(CRUD + 双 UNIQUE 约束 + 主持人转让事务 + `duration=10s` 精确匹配 + CASCADE 清零)。延续项目 Go 侧"零 `_test.go`"风格(用代码审查 + psql 真库验证 + Playwright E2E 三层守护)
|
||||
* **Task 4 ✅ Go meeting 模块 service/controller/router 骨架完成(2026-04-21)**:`app/meeting/` 补齐 service/controller/router/provider 四件套,接口 → 实现按设计文档 §5.3 一一对齐。产出:(1) `service/interfaces.go` 定义 3 个外部依赖接口(`NotifyPusher` / `UserInfoResolver` / `OnlineChecker`),解耦 notify/contact/ws 模块避免循环依赖;`OnlineChecker.IsOnline` 签名与现存 `ws.OnlineService` 一致(返回单 `bool`);(2) `service/meeting_service.go` 声明 `MeetingService` + 8 个 sentinel error + 17 个业务方法空实现,全部返回 `ErrNotImplemented`;(3) `controller/meeting_controller.go` 12 个 Gin 处理器 + `responseNotImplemented`(501)+ `requireUserID` 辅助;(4) `router.go` 12 条路由挂载到 `/api/v1/meeting/*` 并统一套 `jwtAuth` 中间件;(5) `provider.go` 定义 `MeetingSet = wire.NewSet(DAO×3, Service, Controller)`;(6) 全局 `app/provider/wire.go` 挂入 `meetingApp.MeetingSet` + 3 条 `wire.Bind`(`NotifyPusher→NotifyService` / `UserInfoResolver→FriendshipDAO` / `OnlineChecker→ws.OnlineService`);(7) `app/provider/provider.go` `App` 加 `MeetingService/MeetingController` 字段;(8) `router/router.go` 调用 `meetingApp.RegisterRoutes`。**顺手修复存量 bug**:`admin/provider.go` 补齐 `MessageManage{DAO,Service,Controller}` 三个 provider,解决旧版 `wire` 重生成报"no provider found"的遗留问题。验证:`go build ./...` / `go vet ./...` / `wire ./app/provider` 全绿;`GIN_MODE=debug` 启动 server 日志打印全部 12 条 `[GIN-debug] ... meeting/controller.(*MeetingController).Xxx-fm`;curl 无 token 打 3 条代表性路由均返回 401 `缺少认证信息`,JWT 中间件生效
|
||||
* **Task 7 ✅ HTTPMediaOrchestrator Go↔Node 媒体链路打通(2026-04-21)**:`NoopMediaOrchestrator` 替换为真实 HTTP 实现,`MeetingService` / `MeetingSignalService` 的 9 个媒体操作全部走 `POST/DELETE /internal/v1/*` 调到 Node media-server。产出:(1) `config/config.go` 新增 `MediaServerConfig{BaseURL, InternalToken, TimeoutMS(5000), CloseTimeoutMS(2000), CloseRetry(2)}` + dev/docker 两份 yaml 配置(dev 直连 `localhost:3300`,docker 走服务名 `media-server:3300`);(2) `app/meeting/service/http_media_orchestrator.go`(新 340 行)用 `net/http` 标准库实现 `MediaOrchestrator` 8 方法,`context.WithTimeout` 驱动差异化超时,创建类一次性透传,**关闭类指数退避 200ms→500ms 最多 CloseRetry+1 次**(幂等安全);`X-Internal-Token` header 与 `media-server/.env MEDIA_INTERNAL_TOKEN` 配对;错误类型 `ErrMediaResourceNotFound`(404)与 `ErrMediaServerError`(5xx/超时/网络错)可供 `errors.Is` 精准区分;**`sync.Map` 本地 `roomCode ↔ routerID` 缓存**兼容设计 §6.6 `CloseRouter(roomCode)` 签名但 Node 以 routerID 为主键的约束,go-service 重启缓存丢失与 Node 重启 Router 释放状态自然同步;(3) `app/meeting/provider.go` 切换 `wire.Bind` 到 `*HTTPMediaOrchestrator`,`wire_gen.go` 再生;(4) `docs/verify/meeting_t7_verify.mjs`(新 140 行)E2E 验证脚本。**关键设计决策**:(a) 关闭类幂等重试 vs 创建类一次性透传 —— 防孤儿资源;(b) 错误语义二分 —— 让 404 关闭转 nil、5xx 透传 WS ACK;(c) 接口维持 8 方法不含 `ResumeConsumer` —— Node REST 已就绪但 WS 契约未暴露,留给 Task 9 前端 mediasoup-client 接入时按需补齐。**已知遗留**:(i) CreateRoom + JoinRoom 各自调 `CreateRouter` 重复创建,Task 8 需改为"仅首次创建 Router 其余复用";(ii) `/transports/:id/stats` Node 暂未实现,Task 10 可观测性补齐。验证:`go build`/`go vet` 全绿;启动 media-server(Node 3300) + go-service(Go 8085),E2E **16/16 PASS** —— REST 创建/加入/结束会议触发 Router 创建/销毁,WS `transport.create` 返回真实 mediasoup `id`(非 noop- 前缀)+ 非空 `iceCandidates[]` + `dtlsParameters.fingerprints[]`,关闭虚构 producer 触发 Node 404 → Go 幂等转 ok;media-server 日志对齐 `router created × 2 / webrtc transport created / router closed explicitly`
|
||||
* **Task 6 ✅ WebSocket 信令协议 13 事件全量落地(2026-04-21)**:`meeting.*` 事件族从 Task 5 的 `PublishToUser` 循环升级为完整 WS 信令协议。产出:(1) `app/constants/meeting.go` WS 事件常量与设计 §6.3 对齐(3 房间 + 5 成员 + 5 媒体 + 1 聊天)+ `MeetingWSClientEvents` 白名单限制客户端仅能发起 8 个 C→S 事件(防恶意客户端伪造 `room.ended` 等广播);(2) `service/interfaces.go` 扩容 `MediaOrchestrator` 至 9 方法(Router/Transport/Producer/Consumer 全生命周期)+ 配套 5 个 DTO + `NoopMediaOrchestrator` 9 占位实现;(3) **新建 `service/meeting_broadcaster.go`**(75 行)抽离 `BroadcastToMeeting` / `PublishToUser` 统一广播层,REST + WS 共用;(4) `service/meeting_service.go` 12 方法改调 `broadcaster.*` 不再直连 `ws.PubSub`;(5) **新建 `service/meeting_signal_service.go`**(430 行)承载 8 C→S 事件业务:`OnRoomJoin`/`OnRoomLeave`/`OnMemberStateChanged`/`OnTransportCreate/Connect`/`OnProduceStart`/`OnConsumeStart`/`OnProducerClose`;**Redis 资源追踪** `echo:meeting:resources:{room_id}:{user_id}`(Set,TTL 1 小时)+ `cleanupUserResources`(WS 断开钩子遍历清理 transport/producer/consumer)+ host 权限校验(非 host 改他人状态返回 `-1 仅主持人可执行此操作`);(6) **新建 `controller/meeting_ws_handler.go`**(200 行)薄层:构造时 `hub.RegisterEvent` 注册 8 C→S 事件,每 handler 仅 JSON 反序列化 + 调 `signalSvc.On*` + 构造 ACK;(7) `provider.go` + `wire_gen.go` 扩充三个新 provider;(8) `docs/api/frontend/meeting.md` 追加 §WebSocket 信令协议(200 行):16 事件总览 + 8 C→S 完整契约 + S→C 广播契约 + 架构 + 错误处理。**关键设计决策**:(a) Broadcaster 单独抽层让 Task 5 REST 广播与 Task 6 WS 广播复用同一对象;(b) C→S 白名单防伪造;(c) Redis Set 追踪媒体资源即使 Go 进程崩溃也不泄漏 mediasoup 端;(d) MediaOrchestrator 接口完整化让 Task 7 只需替换绑定不改 signal service / handler 代码;(e) ACK `code=0/-1` 与 REST 领域错误口径完全一致;(f) `meeting.room.leave` 只清 WS 资源不改 participant 表(真正离会仍需 REST `/leave`),允许客户端 WS 重连刷新 transport 不退会。验证:`go build` / `go vet` / `wire` 全绿;端到端 WS 冒烟 `/tmp/meeting_ws_t6_test.mjs` **PASS=18 / FAIL=0**,覆盖 8 C→S 白名单事件 + 3 S→C 广播 + 3 错误路径(非 host 越权/不存在会议号/WS leave 资源清理)。测试期间修复 `waitEvent` 死循环 bug(pop→push 自循环)改为 stash 缓冲区归还模式
|
||||
* **Task 5 ✅ Go meeting 模块 12 个 REST 接口业务逻辑全量落地(2026-04-21)**:`MeetingService` + `MeetingController` 从 501 占位升级为完整实现,对接 PostgreSQL / Redis / NotifyPusher / PubSub / MediaOrchestrator。产出:(1) `app/dto/meeting_dto.go` 13 个 DTO(3 基础 + 10 请求/响应);(2) `pkg/utils/meeting_code.go` 生成 9 位 `XXX-XXX-XXX` 会议号 + 32 位 hex 邀请令牌;(3) `service/meeting_service.go` 12 业务方法 + 11 领域错误 + `assertIsActiveParticipant`/`assertIsHost`/`generateUniqueRoomCode`/`broadcastToActiveParticipants` 辅助;(4) `controller/meeting_controller.go` 12 Gin 处理器 + `handleError` 领域错误 → HTTP 映射 + `roomToDTO`/`participantToDTO`/`chatToDTO` 转换;(5) `service/interfaces.go` 新增 `MediaOrchestrator` 接口 + `NoopMediaOrchestrator` 占位(Task 7 替换);(6) `provider.go` 注册 `NoopMediaOrchestrator`;(7) `router.go` 路径对齐设计 `GET /rooms/mine` + `POST /invite-tokens/:token/redeem`;(8) `docs/api/frontend/meeting.md` 重写为 280 行的 12 接口完整文档。**DAO 契约修复**:`meeting_room_dao.GetByID/GetByCode` + `meeting_participant_dao.GetByRoomAndUser/FindActiveByUser` 全部将 `gorm.ErrRecordNotFound` 转换为 `(nil, nil)`,由 service 统一 `result == nil` 判定,消除 500 误报。**关键设计决策**:(a) 单点参会用 `meeting_participants` JOIN `meeting_rooms.status != 2` 判断;(b) 密码限流用 `echo:meeting:pwd:fail:{code}:{user_id}` 5 次锁 10 分钟(`ErrMeetingPasswordLocked`);(c) host 离会若还有其他活跃成员自动转让给"最早加入者"并广播 `meeting.host.changed`;(d) 邀请 token 不返回给调用方,仅通过 `NotifyPusher.PushBatch.Extra.invite_token` 定向下发;兑换后保留 60 秒冗余由 Redis TTL 自然过期;(e) 创建类接口 201、动作类 200、领域错误按 404/403/400 三档映射。**Stub 策略**:`MediaOrchestrator.CreateRouter/CloseRouter` 当前 Noop(Task 7 接入 HTTPMediaOrchestrator);WS 广播暂用 `pubsub.PublishToUser` 逐人循环(Task 6 封装为 `BroadcastToMeeting` 无感替换);`NotifyPusher.PushBatch` 复用 Phase 2e-1 成果。验证:`go build` / `go vet` / `wire` 全绿;启动 server + 3 用户端到端脚本 `/tmp/meeting_t5_test.sh` **PASS=19 / FAIL=0**,覆盖 12 接口 happy path + 5 类错误路径(密码错/房间不存在/单点参会冲突/非 host 越权/邀请链接失效)
|
||||
- 2e-3 会议增强(7-10 天)📋 待开发:预约会议(`type=2`)+ 定时提醒(`meeting_reminder`)+ 等候室/锁定会议 + 设备预览高级参数(降噪/回声/虚拟背景)
|
||||
|
||||
@@ -60,7 +60,7 @@ EchoChat/
|
||||
│ ├── config/ # 配置文件
|
||||
│ ├── pkg/ # 公共包 (db / logs / middleware / utils)
|
||||
│ └── router/ # 路由聚合
|
||||
├── media-server/ # mediasoup Node 媒体服务 (Phase 3)
|
||||
├── media-server/ # mediasoup Node 媒体服务 (Phase 2e-2 Task 0-2 已落地 9 REST API,Task 7 起 Go 通过 HTTPMediaOrchestrator 接入)
|
||||
├── deploy/ # 部署配置 (Docker Compose)
|
||||
├── design-system/ # UI 设计系统 (ui-ux-pro-max 生成)
|
||||
├── docs/ # 项目文档
|
||||
|
||||
@@ -25,7 +25,9 @@ var MeetingSet = wire.NewSet(
|
||||
controller.NewMeetingController,
|
||||
controller.NewMeetingWSHandler,
|
||||
|
||||
// MediaOrchestrator 目前使用 Noop 实现(Task 7 将替换为 node_client.NodeClient)
|
||||
service.NewNoopMediaOrchestrator,
|
||||
wire.Bind(new(service.MediaOrchestrator), new(*service.NoopMediaOrchestrator)),
|
||||
// MediaOrchestrator 使用真实 HTTP 实现(Task 7 落地)
|
||||
// 注入 *config.Config,通过 cfg.MediaServer 获取 base_url / internal_token / timeout 等配置
|
||||
// 如需回退到本地 stub 调试,可临时改为 service.NewNoopMediaOrchestrator 并调整 wire.Bind
|
||||
service.NewHTTPMediaOrchestrator,
|
||||
wire.Bind(new(service.MediaOrchestrator), new(*service.HTTPMediaOrchestrator)),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
// Package service 提供 meeting 模块的业务逻辑
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/echochat/backend/config"
|
||||
"github.com/echochat/backend/pkg/logs"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ====== 错误定义(供上层 errors.Is 判定并映射为 WS/HTTP 错误语义)======
|
||||
|
||||
// ErrMediaResourceNotFound media-server 明确返回 404 时的错误
|
||||
// 典型场景:close 已经不存在的 router / transport / producer / consumer
|
||||
// 对调用方而言通常可容忍(幂等关闭),在 SignalService 中一般当作"已清理"处理
|
||||
var ErrMediaResourceNotFound = errors.New("media resource not found")
|
||||
|
||||
// ErrMediaServerError media-server 返回 5xx 或网络异常(超时 / 连接拒绝)时的错误
|
||||
// 会在 WS ACK 中以 code=-1 + message 形式返回给前端
|
||||
var ErrMediaServerError = errors.New("media server error")
|
||||
|
||||
// ====== HTTPMediaOrchestrator:MediaOrchestrator 的真实 HTTP 实现 ======
|
||||
|
||||
// HTTPMediaOrchestrator 通过 HTTP 调 Node media-server 的 /internal/v1/* API,实现 MediaOrchestrator 接口
|
||||
//
|
||||
// 核心特性:
|
||||
// - 创建类操作(router / transport / producer / consumer):超时即失败,无重试,防止重复创建资源
|
||||
// - 关闭类操作(CloseRouter / CloseProducer / CloseConsumer / 隐含的 transport.close):
|
||||
// 独立较短超时;失败时幂等重试 CloseRetry 次,指数退避 200ms→500ms
|
||||
// - 404 统一映射为 ErrMediaResourceNotFound,其它 4xx/5xx 统一映射为 ErrMediaServerError
|
||||
// - roomCode → routerID 本地缓存:设计 §6.6 规定 CloseRouter 入参为 roomCode,但 Node 以 routerID 作为资源主键,
|
||||
// 本 Orchestrator 在 CreateRouter 成功后记住映射,CloseRouter 时反查
|
||||
// - X-Internal-Token header 与 media-server/.env 的 MEDIA_INTERNAL_TOKEN 配对,两端不匹配将被 Node 的 401 拒绝
|
||||
//
|
||||
// 并发:所有方法可安全并发调用;内部 http.Client 复用连接池
|
||||
type HTTPMediaOrchestrator struct {
|
||||
cfg config.MediaServerConfig
|
||||
client *http.Client
|
||||
|
||||
// roomCode → routerID 本地缓存,服务重启后会丢失(此时 Node 侧的 Router 也会随 Node 重启而释放,状态一致)
|
||||
roomRouterIDs sync.Map
|
||||
}
|
||||
|
||||
// NewHTTPMediaOrchestrator 构造真实 HTTP 客户端
|
||||
// 由 wire 注入,在 app/provider/provider.go 中统一绑定为 MediaOrchestrator
|
||||
// 创建时做一次性配置校验:base_url 必须非空,internal_token 必须非空
|
||||
func NewHTTPMediaOrchestrator(cfg *config.Config) *HTTPMediaOrchestrator {
|
||||
mc := cfg.MediaServer
|
||||
if mc.TimeoutMS <= 0 {
|
||||
mc.TimeoutMS = 5000
|
||||
}
|
||||
if mc.CloseTimeoutMS <= 0 {
|
||||
mc.CloseTimeoutMS = 2000
|
||||
}
|
||||
if mc.CloseRetry < 0 {
|
||||
mc.CloseRetry = 0
|
||||
}
|
||||
// 去除 base_url 末尾斜杠,统一拼接风格
|
||||
mc.BaseURL = strings.TrimRight(mc.BaseURL, "/")
|
||||
|
||||
return &HTTPMediaOrchestrator{
|
||||
cfg: mc,
|
||||
client: &http.Client{
|
||||
// 不在 Client 层设置 Timeout,由各请求通过 context 控制,便于细粒度区分 create/close
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 50,
|
||||
MaxIdleConnsPerHost: 20,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ====== MediaOrchestrator 接口实现 ======
|
||||
|
||||
// CreateRouter 调用 POST /internal/v1/routers
|
||||
// 成功后在本地缓存 roomCode → routerID 映射供 CloseRouter 使用
|
||||
func (h *HTTPMediaOrchestrator) CreateRouter(ctx context.Context, roomCode string) (string, error) {
|
||||
funcName := "service.http_media_orchestrator.CreateRouter"
|
||||
|
||||
reqBody := map[string]any{"roomCode": roomCode}
|
||||
var resp struct {
|
||||
RouterID string `json:"routerId"`
|
||||
RtpCapabilities json.RawMessage `json:"rtpCapabilities"`
|
||||
}
|
||||
if err := h.doRequest(ctx, requestOptions{
|
||||
method: http.MethodPost,
|
||||
path: "/internal/v1/routers",
|
||||
body: reqBody,
|
||||
timeoutMS: h.cfg.TimeoutMS,
|
||||
funcName: funcName,
|
||||
logFields: []zap.Field{zap.String("room_code", roomCode)},
|
||||
}, &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 记忆映射:一个 roomCode 仅对应一个 Router;若此前存在旧 Router ID(极少见),以新值覆盖
|
||||
h.roomRouterIDs.Store(roomCode, resp.RouterID)
|
||||
|
||||
logs.Info(ctx, funcName, "Node Router 创建成功",
|
||||
zap.String("room_code", roomCode),
|
||||
zap.String("router_id", resp.RouterID))
|
||||
return resp.RouterID, nil
|
||||
}
|
||||
|
||||
// CloseRouter 调用 DELETE /internal/v1/routers/:routerId
|
||||
// 入参是 roomCode(符合设计 §6.6 NodeClient 契约),内部反查本地缓存拿到 routerID
|
||||
// 若本地缓存不存在映射(可能是 go-service 重启后状态丢失),直接返回 nil:
|
||||
// - Node 那边的 Router 也会随 Node 重启释放
|
||||
// - 对 meeting 业务层是幂等的"已清理"语义
|
||||
func (h *HTTPMediaOrchestrator) CloseRouter(ctx context.Context, roomCode string) error {
|
||||
funcName := "service.http_media_orchestrator.CloseRouter"
|
||||
|
||||
v, ok := h.roomRouterIDs.Load(roomCode)
|
||||
if !ok {
|
||||
logs.Debug(ctx, funcName, "无本地映射,跳过 Close", zap.String("room_code", roomCode))
|
||||
return nil
|
||||
}
|
||||
routerID, _ := v.(string)
|
||||
if routerID == "" {
|
||||
h.roomRouterIDs.Delete(roomCode)
|
||||
return nil
|
||||
}
|
||||
|
||||
err := h.doCloseRequest(ctx, fmt.Sprintf("/internal/v1/routers/%s", routerID), funcName, []zap.Field{
|
||||
zap.String("room_code", roomCode),
|
||||
zap.String("router_id", routerID),
|
||||
})
|
||||
// 成功或 ResourceNotFound 都从本地缓存删除(幂等)
|
||||
if err == nil || errors.Is(err, ErrMediaResourceNotFound) {
|
||||
h.roomRouterIDs.Delete(roomCode)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// CreateTransport 调用 POST /internal/v1/transports
|
||||
// 需要先从本地缓存取 routerID;若 roomCode 对应 Router 不存在,返回 ErrMediaResourceNotFound
|
||||
func (h *HTTPMediaOrchestrator) CreateTransport(ctx context.Context, req *CreateTransportReq) (*TransportInfo, error) {
|
||||
funcName := "service.http_media_orchestrator.CreateTransport"
|
||||
|
||||
routerID, err := h.routerIDByRoomCode(req.RoomCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reqBody := map[string]any{
|
||||
"routerId": routerID,
|
||||
"userId": req.UserID, // Node zod schema 支持 number → string 转换
|
||||
"direction": req.Direction,
|
||||
}
|
||||
var resp TransportInfo
|
||||
if err := h.doRequest(ctx, requestOptions{
|
||||
method: http.MethodPost,
|
||||
path: "/internal/v1/transports",
|
||||
body: reqBody,
|
||||
timeoutMS: h.cfg.TimeoutMS,
|
||||
funcName: funcName,
|
||||
logFields: []zap.Field{
|
||||
zap.String("room_code", req.RoomCode),
|
||||
zap.Int64("user_id", req.UserID),
|
||||
zap.String("direction", req.Direction),
|
||||
},
|
||||
}, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// ConnectTransport 调用 POST /internal/v1/transports/:id/connect
|
||||
// 请求体需保留 Node zod schema 要求的 {dtlsParameters: {role?, fingerprints}} 结构,由前端传入并原样转发
|
||||
func (h *HTTPMediaOrchestrator) ConnectTransport(ctx context.Context, transportID string, dtlsParameters json.RawMessage) error {
|
||||
funcName := "service.http_media_orchestrator.ConnectTransport"
|
||||
|
||||
reqBody := map[string]any{"dtlsParameters": dtlsParameters}
|
||||
return h.doRequest(ctx, requestOptions{
|
||||
method: http.MethodPost,
|
||||
path: fmt.Sprintf("/internal/v1/transports/%s/connect", transportID),
|
||||
body: reqBody,
|
||||
timeoutMS: h.cfg.TimeoutMS,
|
||||
funcName: funcName,
|
||||
logFields: []zap.Field{zap.String("transport_id", transportID)},
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// CreateProducer 调用 POST /internal/v1/producers
|
||||
func (h *HTTPMediaOrchestrator) CreateProducer(ctx context.Context, req *CreateProducerReq) (string, error) {
|
||||
funcName := "service.http_media_orchestrator.CreateProducer"
|
||||
|
||||
reqBody := map[string]any{
|
||||
"transportId": req.TransportID,
|
||||
"kind": req.Kind,
|
||||
"rtpParameters": req.RtpParameters,
|
||||
"appData": map[string]any{
|
||||
"userId": req.UserID,
|
||||
"roomCode": req.RoomCode,
|
||||
},
|
||||
}
|
||||
var resp struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := h.doRequest(ctx, requestOptions{
|
||||
method: http.MethodPost,
|
||||
path: "/internal/v1/producers",
|
||||
body: reqBody,
|
||||
timeoutMS: h.cfg.TimeoutMS,
|
||||
funcName: funcName,
|
||||
logFields: []zap.Field{
|
||||
zap.String("room_code", req.RoomCode),
|
||||
zap.Int64("user_id", req.UserID),
|
||||
zap.String("transport_id", req.TransportID),
|
||||
zap.String("kind", req.Kind),
|
||||
},
|
||||
}, &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.ID, nil
|
||||
}
|
||||
|
||||
// CloseProducer 调用 DELETE /internal/v1/producers/:id
|
||||
func (h *HTTPMediaOrchestrator) CloseProducer(ctx context.Context, producerID string) error {
|
||||
funcName := "service.http_media_orchestrator.CloseProducer"
|
||||
|
||||
err := h.doCloseRequest(ctx, fmt.Sprintf("/internal/v1/producers/%s", producerID), funcName, []zap.Field{
|
||||
zap.String("producer_id", producerID),
|
||||
})
|
||||
// 幂等:Producer 已不存在视为已清理,返回 nil
|
||||
if errors.Is(err, ErrMediaResourceNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// CreateConsumer 调用 POST /internal/v1/consumers
|
||||
// Node 侧 Consumer 强制 paused=true 创建,前端收到后需额外调 /resume(MediaOrchestrator 暂未暴露 ResumeConsumer,
|
||||
// 将在 Task 8 前端接入 mediasoup-client 时按需补充)
|
||||
func (h *HTTPMediaOrchestrator) CreateConsumer(ctx context.Context, req *CreateConsumerReq) (*ConsumerInfo, error) {
|
||||
funcName := "service.http_media_orchestrator.CreateConsumer"
|
||||
|
||||
routerID, err := h.routerIDByRoomCode(req.RoomCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reqBody := map[string]any{
|
||||
"routerId": routerID,
|
||||
"transportId": req.TransportID,
|
||||
"producerId": req.ProducerID,
|
||||
"rtpCapabilities": req.RtpCapabilities,
|
||||
}
|
||||
var resp ConsumerInfo
|
||||
if err := h.doRequest(ctx, requestOptions{
|
||||
method: http.MethodPost,
|
||||
path: "/internal/v1/consumers",
|
||||
body: reqBody,
|
||||
timeoutMS: h.cfg.TimeoutMS,
|
||||
funcName: funcName,
|
||||
logFields: []zap.Field{
|
||||
zap.String("room_code", req.RoomCode),
|
||||
zap.Int64("user_id", req.UserID),
|
||||
zap.String("transport_id", req.TransportID),
|
||||
zap.String("producer_id", req.ProducerID),
|
||||
},
|
||||
}, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// CloseConsumer 调用 DELETE /internal/v1/consumers/:id
|
||||
func (h *HTTPMediaOrchestrator) CloseConsumer(ctx context.Context, consumerID string) error {
|
||||
funcName := "service.http_media_orchestrator.CloseConsumer"
|
||||
|
||||
err := h.doCloseRequest(ctx, fmt.Sprintf("/internal/v1/consumers/%s", consumerID), funcName, []zap.Field{
|
||||
zap.String("consumer_id", consumerID),
|
||||
})
|
||||
if errors.Is(err, ErrMediaResourceNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ====== 内部工具 ======
|
||||
|
||||
// routerIDByRoomCode 从本地缓存反查 routerID,缺失时返回 ErrMediaResourceNotFound
|
||||
// 此错误语义表达的是"meeting 业务侧尚未(或已清理了)为该房间创建 Router",调用方通常应转为"会议未开始/已结束"
|
||||
func (h *HTTPMediaOrchestrator) routerIDByRoomCode(roomCode string) (string, error) {
|
||||
v, ok := h.roomRouterIDs.Load(roomCode)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%w: no router mapped for room_code=%s", ErrMediaResourceNotFound, roomCode)
|
||||
}
|
||||
routerID, _ := v.(string)
|
||||
if routerID == "" {
|
||||
return "", fmt.Errorf("%w: empty router_id for room_code=%s", ErrMediaResourceNotFound, roomCode)
|
||||
}
|
||||
return routerID, nil
|
||||
}
|
||||
|
||||
// requestOptions 统一请求参数
|
||||
type requestOptions struct {
|
||||
method string
|
||||
path string
|
||||
body any
|
||||
timeoutMS int
|
||||
funcName string
|
||||
logFields []zap.Field
|
||||
}
|
||||
|
||||
// doRequest 执行一次带超时的 HTTP 请求(无重试),将 2xx 的 JSON 响应解码到 respOut 指向的结构体
|
||||
// respOut 为 nil 时忽略响应体(适用于 DELETE / POST connect 等无返回值接口)
|
||||
func (h *HTTPMediaOrchestrator) doRequest(ctx context.Context, opts requestOptions, respOut any) error {
|
||||
reqCtx, cancel := context.WithTimeout(ctx, time.Duration(opts.timeoutMS)*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
var bodyReader io.Reader
|
||||
if opts.body != nil {
|
||||
buf, err := json.Marshal(opts.body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: marshal request body: %v", ErrMediaServerError, err)
|
||||
}
|
||||
bodyReader = bytes.NewReader(buf)
|
||||
}
|
||||
|
||||
url := h.cfg.BaseURL + opts.path
|
||||
httpReq, err := http.NewRequestWithContext(reqCtx, opts.method, url, bodyReader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: new request: %v", ErrMediaServerError, err)
|
||||
}
|
||||
httpReq.Header.Set("X-Internal-Token", h.cfg.InternalToken)
|
||||
if bodyReader != nil {
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
resp, err := h.client.Do(httpReq)
|
||||
duration := time.Since(started)
|
||||
if err != nil {
|
||||
logs.Warn(ctx, opts.funcName, "HTTP 请求 media-server 失败",
|
||||
append(opts.logFields,
|
||||
zap.String("url", url),
|
||||
zap.Duration("duration", duration),
|
||||
zap.Error(err))...)
|
||||
return fmt.Errorf("%w: %v", ErrMediaServerError, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
if respOut == nil {
|
||||
// 消耗响应体保证连接可复用
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
logs.Debug(ctx, opts.funcName, "HTTP 调用成功",
|
||||
append(opts.logFields,
|
||||
zap.Int("status", resp.StatusCode),
|
||||
zap.Duration("duration", duration))...)
|
||||
return nil
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(respOut); err != nil {
|
||||
return fmt.Errorf("%w: decode response: %v", ErrMediaServerError, err)
|
||||
}
|
||||
logs.Debug(ctx, opts.funcName, "HTTP 调用成功",
|
||||
append(opts.logFields,
|
||||
zap.Int("status", resp.StatusCode),
|
||||
zap.Duration("duration", duration))...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 非 2xx:读取响应体(用于日志)+ 映射错误类型
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
bodyStr := truncate(string(bodyBytes), 512)
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
logs.Debug(ctx, opts.funcName, "media-server 返回 404",
|
||||
append(opts.logFields,
|
||||
zap.Int("status", resp.StatusCode),
|
||||
zap.String("resp_body", bodyStr))...)
|
||||
return fmt.Errorf("%w: %s %s returned 404: %s",
|
||||
ErrMediaResourceNotFound, opts.method, opts.path, bodyStr)
|
||||
}
|
||||
|
||||
logs.Warn(ctx, opts.funcName, "media-server 返回异常状态",
|
||||
append(opts.logFields,
|
||||
zap.Int("status", resp.StatusCode),
|
||||
zap.String("resp_body", bodyStr),
|
||||
zap.Duration("duration", duration))...)
|
||||
return fmt.Errorf("%w: %s %s returned %d: %s",
|
||||
ErrMediaServerError, opts.method, opts.path, resp.StatusCode, bodyStr)
|
||||
}
|
||||
|
||||
// doCloseRequest DELETE 类关闭接口的专用封装:较短超时 + 幂等重试(指数退避 200ms → 500ms)
|
||||
// - 404 不重试(资源已不存在,视为成功的幂等场景,由调用方判断是否转 nil)
|
||||
// - 5xx / 超时 / 网络错 重试 CloseRetry 次
|
||||
func (h *HTTPMediaOrchestrator) doCloseRequest(ctx context.Context, path, funcName string, logFields []zap.Field) error {
|
||||
backoffs := []time.Duration{200 * time.Millisecond, 500 * time.Millisecond}
|
||||
attempts := h.cfg.CloseRetry + 1 // +1 表示首次
|
||||
var lastErr error
|
||||
|
||||
for i := 0; i < attempts; i++ {
|
||||
if i > 0 {
|
||||
// 使用 min(i-1, len(backoffs)-1) 选退避间隔,超过预设值用最后一个
|
||||
idx := i - 1
|
||||
if idx >= len(backoffs) {
|
||||
idx = len(backoffs) - 1
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(backoffs[idx]):
|
||||
}
|
||||
}
|
||||
|
||||
err := h.doRequest(ctx, requestOptions{
|
||||
method: http.MethodDelete,
|
||||
path: path,
|
||||
body: nil,
|
||||
timeoutMS: h.cfg.CloseTimeoutMS,
|
||||
funcName: funcName,
|
||||
logFields: append(logFields, zap.Int("attempt", i+1)),
|
||||
}, nil)
|
||||
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
|
||||
// 404 不重试
|
||||
if errors.Is(err, ErrMediaResourceNotFound) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
logs.Warn(ctx, funcName, "关闭类请求重试耗尽",
|
||||
append(logFields, zap.Int("attempts", attempts), zap.Error(lastErr))...)
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// truncate 截断超长字符串用于日志避免污染
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + "..."
|
||||
}
|
||||
@@ -79,9 +79,10 @@ type CreateConsumerReq struct {
|
||||
}
|
||||
|
||||
// MediaOrchestrator 媒体服务器编排接口(设计 §6.6 NodeClient)
|
||||
// Task 7 落地 Go → Node media-server HTTP Client 后由 node_client.NodeClient 实现
|
||||
// Task 5/6 阶段使用 NoopMediaOrchestrator 占位:
|
||||
// - CreateRouter 返回 "noop-router-{code}";其他方法返回可解析的占位数据,用于 WS 信令链路自测
|
||||
// Task 7 (2026-04-21) 起默认实现为 HTTPMediaOrchestrator(通过 X-Internal-Token
|
||||
// 调用 Node media-server 的 /internal/v1/* REST API)。
|
||||
// NoopMediaOrchestrator 仅保留用于本地调试(无 Node 服务时可临时切换):
|
||||
// - CreateRouter 返回 "noop-router-{code}";其他方法返回可解析的占位数据
|
||||
// - 所有方法均幂等:重复调用不报错,符合 WS 信令重试语义
|
||||
type MediaOrchestrator interface {
|
||||
// CreateRouter 为会议房间创建 mediasoup Router
|
||||
@@ -105,9 +106,10 @@ type MediaOrchestrator interface {
|
||||
CloseConsumer(ctx context.Context, consumerID string) error
|
||||
}
|
||||
|
||||
// NoopMediaOrchestrator 占位实现:Task 7 完成前使用
|
||||
// NoopMediaOrchestrator 本地调试占位实现(Task 7 后默认不再使用)
|
||||
// 返回伪造的 ID 与固定占位数据(JSON:空对象 / 空数组),所有操作仅写日志不调用 Node
|
||||
// Task 7 完成后全局 wire 切换到真实 NodeClient 实现
|
||||
// Task 7 已将全局 wire 绑定切换为 HTTPMediaOrchestrator,此类型仅作为无 Node 服务时
|
||||
// 的临时回退(需手动修改 provider.go 的 wire.Bind 才会生效)
|
||||
type NoopMediaOrchestrator struct{}
|
||||
|
||||
// NewNoopMediaOrchestrator 构造占位的 MediaOrchestrator
|
||||
|
||||
@@ -69,7 +69,7 @@ type MeetingService struct {
|
||||
|
||||
// NewMeetingService 创建 MeetingService 实例
|
||||
// 依赖通过构造函数注入;接口依赖由上游 Wire 绑定到具体实现
|
||||
// Task 7 之前 mediaOrchestrator 使用 NoopMediaOrchestrator
|
||||
// Task 7 (2026-04-21) 起 mediaOrchestrator 默认绑定 HTTPMediaOrchestrator
|
||||
func NewMeetingService(
|
||||
roomDAO *dao.MeetingRoomDAO,
|
||||
participantDAO *dao.MeetingParticipantDAO,
|
||||
|
||||
@@ -102,9 +102,9 @@ func InitializeApp(cfg *config.Config) (*App, error) {
|
||||
meetingParticipantDAO := dao6.NewMeetingParticipantDAO(gormDB)
|
||||
meetingChatDAO := dao6.NewMeetingChatDAO(gormDB)
|
||||
meetingBroadcaster := service7.NewMeetingBroadcaster(meetingParticipantDAO, pubSub)
|
||||
noopMediaOrchestrator := service7.NewNoopMediaOrchestrator()
|
||||
meetingService := service7.NewMeetingService(meetingRoomDAO, meetingParticipantDAO, meetingChatDAO, gormDB, client, meetingBroadcaster, notifyService, friendshipDAO, onlineService, noopMediaOrchestrator)
|
||||
meetingSignalService := service7.NewMeetingSignalService(meetingRoomDAO, meetingParticipantDAO, client, meetingBroadcaster, noopMediaOrchestrator)
|
||||
httpMediaOrchestrator := service7.NewHTTPMediaOrchestrator(cfg)
|
||||
meetingService := service7.NewMeetingService(meetingRoomDAO, meetingParticipantDAO, meetingChatDAO, gormDB, client, meetingBroadcaster, notifyService, friendshipDAO, onlineService, httpMediaOrchestrator)
|
||||
meetingSignalService := service7.NewMeetingSignalService(meetingRoomDAO, meetingParticipantDAO, client, meetingBroadcaster, httpMediaOrchestrator)
|
||||
meetingController := controller7.NewMeetingController(meetingService)
|
||||
meetingWSHandler := controller7.NewMeetingWSHandler(meetingSignalService, hub)
|
||||
app := NewApp(cfg, gormDB, client, minioClient, authService, authController, adminAuthController, userManageController, onlineController, contactManageController, groupManageController, messageManageController, handler, hub, pubSub, onlineService, contactController, imController, eventHandler, offlinePusher, fileController, groupController, notifyService, notificationController, cleanupTask, meetingService, meetingSignalService, meetingController, meetingWSHandler)
|
||||
|
||||
@@ -53,3 +53,13 @@ minio:
|
||||
secret_key: "echochat123456" # 密钥(与 docker-compose 中的 MINIO_ROOT_PASSWORD 一致)
|
||||
bucket: "echochat" # 存储桶名称
|
||||
use_ssl: false # 开发环境不使用 HTTPS
|
||||
|
||||
# Node media-server 接入配置(Phase 2e-2 Task 7 HTTPMediaOrchestrator)
|
||||
# 对应 media-server/.env 的 MEDIA_INTERNAL_TOKEN + HTTP_PORT(默认 3300)
|
||||
# 生产环境通过环境变量 ECHOCHAT_MEDIA_SERVER_INTERNAL_TOKEN 覆盖
|
||||
media_server:
|
||||
base_url: "http://localhost:3300" # media-server 基础 URL(不含末尾斜杠)
|
||||
internal_token: "dev-token-abcdef1234567890" # 与 media-server/.env MEDIA_INTERNAL_TOKEN 一致(开发环境)
|
||||
timeout_ms: 5000 # 创建类接口超时(毫秒)
|
||||
close_timeout_ms: 2000 # 关闭类接口超时(毫秒)
|
||||
close_retry: 2 # 关闭类接口失败重试次数(指数退避 200/500ms)
|
||||
|
||||
@@ -47,3 +47,11 @@ minio:
|
||||
secret_key: "echochat123456"
|
||||
bucket: "echochat"
|
||||
use_ssl: false
|
||||
|
||||
# Node media-server 接入配置(Phase 2e-2 Task 7)
|
||||
media_server:
|
||||
base_url: "http://media-server:3300" # Docker 网络内服务名
|
||||
internal_token: "dev-internal-token-change-me" # 生产环境通过 ECHOCHAT_MEDIA_SERVER_INTERNAL_TOKEN 覆盖
|
||||
timeout_ms: 5000
|
||||
close_timeout_ms: 2000
|
||||
close_retry: 2
|
||||
|
||||
@@ -11,12 +11,24 @@ import (
|
||||
|
||||
// Config 应用全局配置结构体
|
||||
type Config struct {
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Database DatabaseConfig `mapstructure:"database"`
|
||||
Redis RedisConfig `mapstructure:"redis"`
|
||||
JWT JWTConfig `mapstructure:"jwt"`
|
||||
Log LogConfig `mapstructure:"log"`
|
||||
Minio MinioConfig `mapstructure:"minio"`
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Database DatabaseConfig `mapstructure:"database"`
|
||||
Redis RedisConfig `mapstructure:"redis"`
|
||||
JWT JWTConfig `mapstructure:"jwt"`
|
||||
Log LogConfig `mapstructure:"log"`
|
||||
Minio MinioConfig `mapstructure:"minio"`
|
||||
MediaServer MediaServerConfig `mapstructure:"media_server"`
|
||||
}
|
||||
|
||||
// MediaServerConfig Node media-server 接入配置(Phase 2e-2 Task 7)
|
||||
// 与 media-server/.env 中的 MEDIA_INTERNAL_TOKEN / HTTP_PORT 成对使用
|
||||
// BaseURL 需精确到协议与端口:http://host:port,不含末尾斜杠
|
||||
type MediaServerConfig struct {
|
||||
BaseURL string `mapstructure:"base_url"` // 如 http://localhost:3300
|
||||
InternalToken string `mapstructure:"internal_token"` // 与 Node 共享密钥
|
||||
TimeoutMS int `mapstructure:"timeout_ms"` // 创建类接口超时(毫秒),默认 5000
|
||||
CloseTimeoutMS int `mapstructure:"close_timeout_ms"` // 关闭类接口超时(毫秒),默认 2000
|
||||
CloseRetry int `mapstructure:"close_retry"` // 关闭类接口失败重试次数,默认 2
|
||||
}
|
||||
|
||||
// MinioConfig MinIO 对象存储配置
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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-21;Task 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 ID(Phase 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 个 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):定义 9 个 mediasoup 操作方法;Task 6 使用 `NoopMediaOrchestrator` 占位(返回 stub IDs),Task 7 替换为 `HTTPMediaOrchestrator` 对接 Node media-server。
|
||||
- **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` 次。
|
||||
|
||||
### 错误处理
|
||||
|
||||
@@ -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` 内联按钮。
|
||||
|
||||
@@ -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` 时自动清理。
|
||||
- **真实 mediasoup(Task 7 起)**:`meeting.transport.create` ACK 的 `id` / `iceParameters` / `iceCandidates` / `dtlsParameters`,以及 `meeting.produce.start` / `meeting.consume.start` 的 ID 均来自 Node media-server 真实 mediasoup Worker;Node 404(如关闭不存在 producer)在 Go 侧幂等转 `code=0`。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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-1(10 种落地 + 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/14(TypeScript + Fastify + mediasoup v3)
|
||||
media-server: # mediasoup Node → :3300 + :40000-40199/udp 🚧 Phase 2e-2 Task 0-2 ✅(TypeScript + Fastify + mediasoup v3,9 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 ✅ 已实现
|
||||
|
||||
@@ -1090,6 +1090,7 @@ TabBar 「我的」红点逻辑不变(Phase 2e-1 已实现 `unreadTotal > 0`
|
||||
| 2026-04-21 | Agent | 首版落盘。16 章节完整撰写,16 章节含 4 张 mermaid 图、3 张 DDL、1 份 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` 已覆盖);C→S 事件引入白名单常量 `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 C→S 事件未暴露,推迟到 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 补齐) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
> **上级路线图:** [Phase 2e 整体路线图](./2026-04-20-phase2e-design.md)
|
||||
> **分支:** `feature/phase2e-2-meeting-mvp`
|
||||
> **预估总工时:** **约 17 人日**(17 个 Task,含 PoC 与 UI 打磨)
|
||||
> **最后更新:** 2026-04-21(Task 0-6 ✅ 已落地,下一步 Task 7 HTTPMediaOrchestrator)
|
||||
> **最后更新:** 2026-04-21(Task 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 7:Go → Node HTTP Client 封装
|
||||
### Task 7:Go → 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.create、404 幂等关闭
|
||||
- ✅ 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)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# EchoChat 项目开发进度
|
||||
|
||||
> **最后更新**:2026-04-21(Phase 2e-2 Task 6 WebSocket 信令协议落地,13 个 meeting.* 事件全量打通,端到端 18/18 PASS)
|
||||
> **当前阶段**:Phase 2e-2 会议 MVP **代码开发阶段** 🚧(Task 0-6 ✅ / Task 7-16 待执行)
|
||||
> **最后更新**:2026-04-21(Phase 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 遗留行为):当前每次有用户加入都会尝试重新创建 Router,Node 没做去重,最新一次会覆盖本地缓存指向新 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 类错误路径。
|
||||
|
||||
176
docs/verify/meeting_t7_verify.mjs
Normal file
176
docs/verify/meeting_t7_verify.mjs
Normal 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);
|
||||
});
|
||||
@@ -124,4 +124,6 @@ docker run --rm \
|
||||
|
||||
## 后续任务
|
||||
|
||||
Task 2 起将在此骨架上继续落地:`/internal/routers`、`/transports`、`/produce`、`/consume` 等 API,以及与 Go backend 的协同。详见实施计划。
|
||||
- **Task 2 ✅**:`/internal/v1/routers`、`/transports`、`/produce`、`/consume` 等 9 个内部 REST API 全部落地(58 单元/集成测试,覆盖率 80.89%)。
|
||||
- **Task 7 ✅**(2026-04-21):Go 侧 `HTTPMediaOrchestrator` 通过 `X-Internal-Token` 已完整接入本服务的 `/internal/v1/*`;Go↔Node 媒体链路在端到端脚本 `docs/verify/meeting_t7_verify.mjs` 中 **16/16 PASS**,媒体 Router/Transport/Producer/Consumer 生命周期均由 Go 驱动。
|
||||
- 后续:Task 9(前端 mediasoup-client)接入时若暴露 `meeting.consume.resume` 事件,需由 Go 侧调用本服务 `POST /internal/v1/consumers/:id/resume`(已实现)。Task 10 将新增 `GET /internal/v1/transports/:id/stats` 供 Go 定时拉取。
|
||||
|
||||
Reference in New Issue
Block a user