覆盖 Phase 2e-2 代码审查报告(docs/reviews/2026-04-23-phase2e-2-code-review.md) P2/Nit 收尾批次,均在本仓库完成闭环;剩余 5 项登记推迟至 Phase 2f/3。 ===== P2 七项 ===== - P2-1 cleanupUserResources 补 transport 清理 · media-server 新增 DELETE /internal/v1/transports/:id + transport.service.closeTransport · Go MediaOrchestrator 接口新增 CloseTransport;HTTP 实现按 doCloseRequest 走 4xx 幂等 + 指数退避 · meeting_signal_service.cleanupUserResources 新增 "transport:<id>" 分支 - P2-2 preview.vue 快速切设备竞态 · previewSeq 序号 + nextTick 后 stale 判断,丢弃过期结果 · onVideoChange/onAudioChange 走 scheduleRestartPreview 200ms 防抖 · onBeforeUnmount 清理 changeDebounceTimer - P2-3 room.vue onLoad redirectTo 后补 return · 引入 redirectingToJoin 守卫,跳转页不再执行 onMounted 初始化 - P2-6 generateUniqueRoomCode 重试上限监控 · 重试后成功:Warn 日志(码空间健康度告警) · 重试耗尽:Error 日志 + ErrRoomCodeConflict · 修正 logs.Error 调用签名(去掉多余的 nil) - P2-7 SendChatMessage 服务端长度 + 频率限制 · 新增 ErrChatContentEmpty / ErrChatContentTooLong / ErrChatRateLimited · utf8.RuneCountInString 校验 500 字符上限 · Redis INCR + EXPIRE 滑动窗口(30 条/60s,首次写入 EXPIRE 兜底) · controller.handleError 映射为 HTTP 400 - P2-8 MEETING_ENDED_REASON_LABEL 覆盖复核 · 新增前端专属常量 MEETING_ENDED_REASON_KICKED + 文案 · store/meeting.js _onMemberKicked 使用常量 · 同步修复后端 OnWSDisconnect 硬编码 "ws_disconnect" → constants.MeetingLeftReasonDisconnect - P2 已修 P2-1/2/3/6/7/8;P2-4(WS token 迁出 URL query)与 P2-5(Chat 服务拆分)登记推迟 ===== Nit 七项 ===== - Nit: kind:id 解析改用 strings.SplitN · cleanupUserResources / pushExistingRoomState 两处同步 - Nit: resourceTTL 中央化 · 新增 constants.MeetingResourceTrackTTLSeconds(3600) · meeting_signal_service.go resourceTTL 由 const 改 var 并引用常量 - Nit: ws/handler.go CheckOrigin 白名单 · NewHandler 新增 serverCfg 依赖;checkOrigin 支持同源放行 / dev 模式放行 / release 模式白名单严格匹配 · config.ServerConfig 新增 WSAllowedOrigins(逗号分隔)+ AllowedOrigins() / IsRelease() 辅助方法 · provider.go 新增 provideServerConfig,wire_gen.go 同步 - Nit: http_media_orchestrator.go 超时 + CreateRouter 重试 · 默认 TimeoutMS 5000→10000ms 兼容 Worker 冷启动 · 新增 CreateRouterRetry(默认 1 次,300ms 退避),仅对非 404 错误重试 · config.dev.yaml / config.docker.yaml 同步写入显式配置 - Nit: deploy-public.sh REDIS_PASSWORD × redis.conf 联动校验 · 检测 REDIS_PASSWORD 与 redis.conf 的 requirepass 配对一致性 · redis.conf 增加公网部署 requirepass 使用说明 - Nit: media-server internal-auth isPrivatePath 按 path 匹配 · 剔除 query/hash 后再与白名单 startsWith,避免 "?" 语义混淆 - Nit: mediasoup-client.js in-flight 锁走读确认 · finally 分支已覆盖 resolve/reject 两路,追加注释强化语义 - Nit 走读复核:_onMemberLeft 整槽关闭 vs _onProducerNew(closed=true) 精确匹配 producerId · 粒度正确,无需改动(登记结论) ===== 构建验证 ===== - go vet ./... / go build ./... 通过 - frontend npm run build:h5 通过(仅 uni-app legacy warning,无 error) - media-server npx tsc --noEmit 通过 ===== 审查追踪小节 ===== docs/reviews/2026-04-23-phase2e-2-code-review.md 追加 "Task 16 修复追踪(2026-04-24 更新)": - 已修复一览(本批次 14 处 + 历次 commitcdaa39d/ea2bf96/f5ae095/ 5ed14c2) - 推迟登记表(P2-4 / P2-5 / 端口收敛 / appData 校验 / RFC3339 时间格式,共 5 项) Made-with: Cursor
208 lines
9.8 KiB
Go
208 lines
9.8 KiB
Go
// Package service 提供 meeting 模块的业务逻辑
|
||
package service
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
|
||
authModel "github.com/echochat/backend/app/auth/model"
|
||
notifyService "github.com/echochat/backend/app/notify/service"
|
||
)
|
||
|
||
// NotifyPusher 通知推送接口
|
||
// 由 notify.service.NotifyService 隐式实现,供 meeting 模块注入使用
|
||
// 会议邀请、主持人变更、踢出会议等事件通过此接口落地通知中心
|
||
type NotifyPusher interface {
|
||
Push(ctx context.Context, payload *notifyService.PushPayload)
|
||
PushBatch(ctx context.Context, payloads []*notifyService.PushPayload)
|
||
}
|
||
|
||
// UserInfoResolver 查询用户昵称 / 头像的接口
|
||
// 由 contact.FriendshipDAO 隐式实现(已有 GetUsersByIDs 方法)
|
||
// 用于拉取会议邀请中主持人信息、成员列表头像等
|
||
type UserInfoResolver interface {
|
||
GetUsersByIDs(ctx context.Context, userIDs []int64) ([]authModel.User, error)
|
||
}
|
||
|
||
// OnlineChecker 查询用户在线状态的接口
|
||
// 由 ws.OnlineService 隐式实现(签名对齐现有实现:不返回 error,查询失败按离线处理)
|
||
// 用于会议邀请时判断收件人是否在线(离线则降级为仅通知入库,上线时通过未读补偿推送)
|
||
type OnlineChecker interface {
|
||
IsOnline(ctx context.Context, userID int64) bool
|
||
}
|
||
|
||
// ====== MediaOrchestrator:Go → Node media-server 统一封装(设计 §6.6)======
|
||
|
||
// TransportInfo Node 端创建的 Transport 元信息
|
||
// 字段与 mediasoup-client 的 Device.createSendTransport / createRecvTransport 入参兼容
|
||
type TransportInfo struct {
|
||
ID string `json:"id"` // Transport ID
|
||
IceParameters json.RawMessage `json:"iceParameters"` // mediasoup ICE 参数
|
||
IceCandidates json.RawMessage `json:"iceCandidates"` // ICE 候选列表
|
||
DtlsParameters json.RawMessage `json:"dtlsParameters"` // DTLS 指纹参数
|
||
SctpParameters json.RawMessage `json:"sctpParameters,omitempty"`
|
||
}
|
||
|
||
// ConsumerInfo Node 端创建的 Consumer 元信息
|
||
type ConsumerInfo struct {
|
||
ID string `json:"id"`
|
||
ProducerID string `json:"producerId"`
|
||
Kind string `json:"kind"` // "audio" | "video"
|
||
RtpParameters json.RawMessage `json:"rtpParameters"`
|
||
Type string `json:"type,omitempty"` // "simple" | "simulcast" | "svc"
|
||
ProducerPaused bool `json:"producerPaused,omitempty"`
|
||
}
|
||
|
||
// CreateTransportReq 创建 Transport 请求
|
||
type CreateTransportReq struct {
|
||
RoomCode string `json:"roomCode"`
|
||
UserID int64 `json:"userId"`
|
||
Direction string `json:"direction"` // "send" | "recv"
|
||
}
|
||
|
||
// CreateProducerReq 创建 Producer 请求
|
||
type CreateProducerReq struct {
|
||
RoomCode string `json:"roomCode"`
|
||
UserID int64 `json:"userId"`
|
||
TransportID string `json:"transportId"`
|
||
Kind string `json:"kind"` // "audio" | "video"
|
||
RtpParameters json.RawMessage `json:"rtpParameters"` // 直接转发给 Node,由其做结构校验
|
||
}
|
||
|
||
// CreateConsumerReq 创建 Consumer 请求
|
||
type CreateConsumerReq struct {
|
||
RoomCode string `json:"roomCode"`
|
||
UserID int64 `json:"userId"`
|
||
TransportID string `json:"transportId"`
|
||
ProducerID string `json:"producerId"`
|
||
RtpCapabilities json.RawMessage `json:"rtpCapabilities"`
|
||
}
|
||
|
||
// MediaOrchestrator 媒体服务器编排接口(设计 §6.6 NodeClient)
|
||
// 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
|
||
// Task 8 起实现需自带 sync.Map 幂等缓存:roomCode 已有 Router 时直接返回缓存值
|
||
CreateRouter(ctx context.Context, roomCode string) (routerID string, err error)
|
||
// CloseRouter 关闭房间对应 Router 及其下所有资源(幂等)
|
||
CloseRouter(ctx context.Context, roomCode string) error
|
||
// ResolveRouterID 从本地缓存查询 roomCode 对应的 routerID(不触发 HTTP 调用)
|
||
// Task 8 引入:用于 JoinRoom 等"仅需复用"的场景,避免重复调 CreateRouter
|
||
// 返回 (id, true) 命中;(_, false) 缺失(通常说明房间未创建 Router 或服务重启后未 reschedule)
|
||
ResolveRouterID(roomCode string) (string, bool)
|
||
// ResolveRouterInfo 读取 roomCode 对应的 Router 完整信息(routerID + rtpCapabilities)
|
||
// Task 9 引入:前端 mediasoup-client Device.load 必须 rtpCapabilities,JoinRoom 响应由此填充
|
||
// 返回 (id, caps, true) 命中;(_, _, false) 缺失
|
||
ResolveRouterInfo(roomCode string) (string, json.RawMessage, bool)
|
||
|
||
// CreateTransport 为用户创建 send/recv WebRTC Transport
|
||
CreateTransport(ctx context.Context, req *CreateTransportReq) (*TransportInfo, error)
|
||
// ConnectTransport Transport DTLS 握手(幂等:重复 connect 对已连接 transport 视为成功)
|
||
ConnectTransport(ctx context.Context, transportID string, dtlsParameters json.RawMessage) error
|
||
// CloseTransport 主动关闭指定 Transport(Task 16 P2-1 引入)
|
||
// 场景:用户离会 / WS 断连时 cleanupUserResources 精确清理,避免等待 Router 级联
|
||
// 语义:404(Transport 已关闭/不存在)返回 ErrMediaResourceNotFound,上层可视为"已清理"幂等成功
|
||
CloseTransport(ctx context.Context, transportID string) error
|
||
|
||
// CreateProducer 在指定 send Transport 上创建 Producer
|
||
CreateProducer(ctx context.Context, req *CreateProducerReq) (producerID string, err error)
|
||
// CloseProducer 关闭指定 Producer(幂等)
|
||
CloseProducer(ctx context.Context, producerID string) error
|
||
|
||
// CreateConsumer 在指定 recv Transport 上创建 Consumer(订阅远端 Producer)
|
||
// Node 端创建的 Consumer 默认 paused=true,需在前端 track 就绪后调用 ResumeConsumer 才会开始推流
|
||
CreateConsumer(ctx context.Context, req *CreateConsumerReq) (*ConsumerInfo, error)
|
||
// ResumeConsumer 恢复 Consumer 开始接收 RTP(Task 9 引入)
|
||
// 前端 recv Transport 与 track 挂载完成后由 meeting.consume.resume WS 事件触发
|
||
// Node 未找到对应 Consumer 返回 ErrMediaResourceNotFound,调用方可按"已失效"处理
|
||
ResumeConsumer(ctx context.Context, consumerID string) error
|
||
// CloseConsumer 关闭指定 Consumer(幂等)
|
||
CloseConsumer(ctx context.Context, consumerID string) error
|
||
}
|
||
|
||
// NoopMediaOrchestrator 本地调试占位实现(Task 7 后默认不再使用)
|
||
// 返回伪造的 ID 与固定占位数据(JSON:空对象 / 空数组),所有操作仅写日志不调用 Node
|
||
// Task 7 已将全局 wire 绑定切换为 HTTPMediaOrchestrator,此类型仅作为无 Node 服务时
|
||
// 的临时回退(需手动修改 provider.go 的 wire.Bind 才会生效)
|
||
type NoopMediaOrchestrator struct{}
|
||
|
||
// NewNoopMediaOrchestrator 构造占位的 MediaOrchestrator
|
||
func NewNoopMediaOrchestrator() *NoopMediaOrchestrator {
|
||
return &NoopMediaOrchestrator{}
|
||
}
|
||
|
||
// CreateRouter 返回以 "noop-router-" 为前缀的伪造 RouterID
|
||
func (n *NoopMediaOrchestrator) CreateRouter(_ context.Context, roomCode string) (string, error) {
|
||
return "noop-router-" + roomCode, nil
|
||
}
|
||
|
||
// CloseRouter 占位实现:直接返回 nil
|
||
func (n *NoopMediaOrchestrator) CloseRouter(_ context.Context, _ string) error {
|
||
return nil
|
||
}
|
||
|
||
// ResolveRouterID 占位实现:始终命中,返回 noop-router-{code}
|
||
func (n *NoopMediaOrchestrator) ResolveRouterID(roomCode string) (string, bool) {
|
||
return "noop-router-" + roomCode, true
|
||
}
|
||
|
||
// ResolveRouterInfo 占位:返回空 rtpCapabilities 对象(保证前端 mediasoup-client 能通过 JSON 解析,但 Device.load 会失败;适合本地无 Node 调试)
|
||
func (n *NoopMediaOrchestrator) ResolveRouterInfo(roomCode string) (string, json.RawMessage, bool) {
|
||
return "noop-router-" + roomCode, json.RawMessage(`{}`), true
|
||
}
|
||
|
||
// CreateTransport 占位:返回以 "noop-transport-" 为前缀的伪造 ID,带最小合法 JSON 结构
|
||
func (n *NoopMediaOrchestrator) CreateTransport(_ context.Context, req *CreateTransportReq) (*TransportInfo, error) {
|
||
return &TransportInfo{
|
||
ID: "noop-transport-" + req.Direction,
|
||
IceParameters: json.RawMessage(`{}`),
|
||
IceCandidates: json.RawMessage(`[]`),
|
||
DtlsParameters: json.RawMessage(`{}`),
|
||
}, nil
|
||
}
|
||
|
||
// ConnectTransport 占位:直接返回 nil
|
||
func (n *NoopMediaOrchestrator) ConnectTransport(_ context.Context, _ string, _ json.RawMessage) error {
|
||
return nil
|
||
}
|
||
|
||
// CloseTransport 占位:直接返回 nil(Task 16 P2-1 引入)
|
||
func (n *NoopMediaOrchestrator) CloseTransport(_ context.Context, _ string) error {
|
||
return nil
|
||
}
|
||
|
||
// CreateProducer 占位:返回伪造 ID
|
||
func (n *NoopMediaOrchestrator) CreateProducer(_ context.Context, req *CreateProducerReq) (string, error) {
|
||
return "noop-producer-" + req.Kind, nil
|
||
}
|
||
|
||
// CloseProducer 占位:直接返回 nil
|
||
func (n *NoopMediaOrchestrator) CloseProducer(_ context.Context, _ string) error {
|
||
return nil
|
||
}
|
||
|
||
// CreateConsumer 占位:返回伪造的 ConsumerInfo(含目标 producerID 回填)
|
||
func (n *NoopMediaOrchestrator) CreateConsumer(_ context.Context, req *CreateConsumerReq) (*ConsumerInfo, error) {
|
||
return &ConsumerInfo{
|
||
ID: "noop-consumer-" + req.ProducerID,
|
||
ProducerID: req.ProducerID,
|
||
Kind: "video",
|
||
RtpParameters: json.RawMessage(`{}`),
|
||
Type: "simple",
|
||
}, nil
|
||
}
|
||
|
||
// ResumeConsumer 占位:直接返回 nil
|
||
func (n *NoopMediaOrchestrator) ResumeConsumer(_ context.Context, _ string) error {
|
||
return nil
|
||
}
|
||
|
||
// CloseConsumer 占位:直接返回 nil
|
||
func (n *NoopMediaOrchestrator) CloseConsumer(_ context.Context, _ string) error {
|
||
return nil
|
||
}
|