Files
EchoChat/backend/go-service/app/meeting/service/interfaces.go
bujinyuan 3b83c79036 feat(phase2e-2): 落地会议生命周期状态机 + Router 幂等双层防御(Task 8)
核心交付:
- 新建 MeetingLifecycleService(6 钩子 + sync.Map 本地 timer + Redis key 双保险 + RescheduleFromRedis)
- 新建 MeetingCleanupTask(启动重建 timer + 每 N 秒扫 host_grace/empty_ttl 兜底 + 4h stale active 回收)
- MediaOrchestrator 新增 ResolveRouterID;HTTPMediaOrchestrator.CreateRouter 入口 sync.Map 幂等防御
- 业务层 JoinRoom 移除 CreateRouter 调用改走 CancelEmptyTTL + ResolveRouterID;LeaveRoom 空房分支改调 OnAllMembersLeft 不再立即销毁
- MeetingSignalService 新增 OnWSDisconnect 实现 ws.MeetingDisconnectHook;OnRoomJoin 追加 host 重连钩子
- ws.handler 定义 MeetingDisconnectHook 接口 + SetMeetingDisconnectHook,解耦 ws→meeting 反向依赖
- config 新增 MeetingConfig{HostGrace=120, EmptyRoomTTL=300, CleanupInterval=30, StaleRoomHours=4}

关键设计决策:
- Redis key TTL = 业务时长 + max(CleanupIntervalSeconds*2, 30s) buffer:避免本地 timer 与
  Redis 自动过期同步到期导致 DEL 返回 0 被误判为"已被其他路径处理"而跳过业务逻辑
- Router 幂等双层防御(决策 q2_router_dedup=a2_both):业务层不重复调 + HTTP 层 sync.Map 命中直接返回
- 普通成员 WS 断开仅清 media 资源不动 participant 表(决策 q1_nonhost_disconnect=a1_keep_current)

E2E 验证:docs/verify/meeting_t8_verify.mjs PASS=20 FAIL=0,覆盖 5 场景:
- S1 host 宽限期过期自动转让(meeting.host.changed + DB host_id 更新)
- S2 宽限期内重连保留身份
- S3 empty_ttl 期内新成员加入复活房间
- S4 empty_ttl 过期 → 房间 Ended + 新 join 被拒
- S5 CreateRoom +1 Router / JoinRoom 不再创建新 Router(通过 media-server /internal/info stats.routers 断言)

media-server:/internal/info 响应追加 stats.routers + routers[] 供 E2E 断言 Router 幂等

文档同步:
- docs/progress/CURRENT_STATUS.md 头部 + 新增 Task 8 交付条目
- docs/plans/2026-04-21-phase2e-2-implementation.plan.md Task 8 标记完成 + 实际产出/决策/验证
- docs/api/frontend/meeting.md 补充 host.changed.auto_reason / room.ended.reason=system_error / 空房 TTL 复活语义 + Task 8 验证记录
- docs/architecture/system-architecture.md meeting 模块职责补充"会议生命周期状态机"
- .cursor/rules/project-context.mdc 追加 Task 8 条目并更新 Phase 2e-2 进度(Task 0-8 )

Made-with: Cursor
2026-04-21 18:21:04 +08:00

180 lines
8.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
}
// ====== MediaOrchestratorGo → 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)
// 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
// 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
CreateConsumer(ctx context.Context, req *CreateConsumerReq) (*ConsumerInfo, 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
}
// 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
}
// 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
}
// CloseConsumer 占位:直接返回 nil
func (n *NoopMediaOrchestrator) CloseConsumer(_ context.Context, _ string) error {
return nil
}