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

@@ -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)),
)

View File

@@ -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")
// ====== HTTPMediaOrchestratorMediaOrchestrator 的真实 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 创建,前端收到后需额外调 /resumeMediaOrchestrator 暂未暴露 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] + "..."
}

View File

@@ -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

View File

@@ -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,