Files
EchoChat/backend/go-service/app/meeting/service/meeting_lifecycle_service.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

476 lines
18 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"
"fmt"
"sync"
"time"
"github.com/echochat/backend/app/constants"
"github.com/echochat/backend/app/meeting/dao"
"github.com/echochat/backend/config"
"github.com/echochat/backend/pkg/logs"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
)
// Redis key 前缀(设计文档 §5.4 生命周期 keysTask 8 落地)
const (
redisKeyHostGracePrefix = "echo:meeting:host_grace:"
redisKeyEmptyTTLPrefix = "echo:meeting:empty_ttl:"
)
// hostGracePayload host 宽限期 Redis value 结构
type hostGracePayload struct {
HostID int64 `json:"host_id"`
StartedAt int64 `json:"started_at"` // 秒级 unix
GraceUntil int64 `json:"grace_until"` // 秒级 unix
}
// MeetingLifecycleService 会议生命周期状态机
// 负责设计 §6.5 的 5 类状态跃迁副作用host 宽限期、自动转让、空房 TTL、TTL 过期销毁)
// 采用 time.AfterFunc 本地 timer + Redis key TTL 双保险:
// - 本地 timer 负责低延迟触发;服务重启由 RescheduleFromRedis 按 Redis 剩余 PTTL 重新装载
// - 后台定时任务 MeetingCleanupTask 每 N 秒扫描 Redis 兜底防御timer 丢失 / 多实例部署重复触发场景)
//
// 并发保护HandleHostGraceExpired / HandleEmptyRoomExpired 入口先 DEL Redis key
// 通过 DEL 返回值确认唯一处理权0 = 已被其他 goroutine/节点清走,直接 return 避免重复操作)
type MeetingLifecycleService struct {
roomDAO *dao.MeetingRoomDAO
participantDAO *dao.MeetingParticipantDAO
db dbExecutor
redis *redis.Client
broadcaster *MeetingBroadcaster
media MediaOrchestrator
cfg config.MeetingConfig
graceTimers sync.Map // roomCode -> *time.Timerhost 宽限期)
emptyTTLTimers sync.Map // roomCode -> *time.Timer空房 TTL
}
// dbExecutor MeetingLifecycleService 对事务 DB 的最小依赖抽象
// 仅用于 HandleEmptyRoomExpired 的 MarkEnded 调用链路,避免强绑 *gorm.DB
type dbExecutor interface{}
// NewMeetingLifecycleService 构造生命周期服务
// media 由上游通过 wire 注入 HTTPMediaOrchestrator
func NewMeetingLifecycleService(
roomDAO *dao.MeetingRoomDAO,
participantDAO *dao.MeetingParticipantDAO,
redis *redis.Client,
broadcaster *MeetingBroadcaster,
media MediaOrchestrator,
cfg *config.Config,
) *MeetingLifecycleService {
meetingCfg := cfg.Meeting
// 零值兜底:防止 yaml 未配置时走 0 秒 TTL
if meetingCfg.HostGraceSeconds <= 0 {
meetingCfg.HostGraceSeconds = constants.MeetingHostGraceSeconds
}
if meetingCfg.EmptyRoomTTLSeconds <= 0 {
meetingCfg.EmptyRoomTTLSeconds = constants.MeetingEmptyRoomTTLSeconds
}
if meetingCfg.CleanupIntervalSeconds <= 0 {
meetingCfg.CleanupIntervalSeconds = 30
}
if meetingCfg.StaleRoomHours <= 0 {
meetingCfg.StaleRoomHours = 4
}
return &MeetingLifecycleService{
roomDAO: roomDAO,
participantDAO: participantDAO,
redis: redis,
broadcaster: broadcaster,
media: media,
cfg: meetingCfg,
}
}
// Config 返回生效的生命周期配置(供定时任务读取扫描周期等)
func (s *MeetingLifecycleService) Config() config.MeetingConfig {
return s.cfg
}
// HostGraceKey 返回 host 宽限期 Redis key
func HostGraceKey(roomCode string) string {
return redisKeyHostGracePrefix + roomCode
}
// EmptyTTLKey 返回空房 TTL Redis key
func EmptyTTLKey(roomCode string) string {
return redisKeyEmptyTTLPrefix + roomCode
}
// OnHostDisconnect host WS 断线钩子
// 写入 host_grace keyTTL HostGraceSeconds并启动本地 AfterFunc
// 若已有同 roomCode 的 grace 记录(重复触发),幂等刷新 TTL + 替换 timer
func (s *MeetingLifecycleService) OnHostDisconnect(ctx context.Context, roomCode string, hostID int64) {
funcName := "service.meeting_lifecycle_service.OnHostDisconnect"
now := time.Now()
payload := hostGracePayload{
HostID: hostID,
StartedAt: now.Unix(),
GraceUntil: now.Add(time.Duration(s.cfg.HostGraceSeconds) * time.Second).Unix(),
}
raw, _ := json.Marshal(payload)
graceDur := time.Duration(s.cfg.HostGraceSeconds) * time.Second
// Redis TTL 比本地 timer 多留一段 buffermax(cleanup*2, 30s)
// 保证本地 timer 先触发 → DEL 命中;即便本地 timer 丢失cleanup 也能在 buffer 内兜底扫到
redisTTL := graceDur + s.ttlBuffer()
if err := s.redis.Set(ctx, HostGraceKey(roomCode), string(raw), redisTTL).Err(); err != nil {
logs.Warn(ctx, funcName, "写入 host_grace key 失败",
zap.String("room_code", roomCode), zap.Int64("host_id", hostID), zap.Error(err))
return
}
s.scheduleHostGraceTimer(roomCode, graceDur)
logs.Info(ctx, funcName, "host 进入宽限期",
zap.String("room_code", roomCode),
zap.Int64("host_id", hostID),
zap.Int("grace_seconds", s.cfg.HostGraceSeconds))
}
// OnHostReconnect host 重连钩子
// 清除 host_grace key 并取消本地 timer用于 host 掉线后 TTL 内重新出现在 WS 层
// 幂等key 不存在也不报错
func (s *MeetingLifecycleService) OnHostReconnect(ctx context.Context, roomCode string, hostID int64) {
funcName := "service.meeting_lifecycle_service.OnHostReconnect"
deleted, err := s.redis.Del(ctx, HostGraceKey(roomCode)).Result()
if err != nil {
logs.Warn(ctx, funcName, "清除 host_grace key 失败",
zap.String("room_code", roomCode), zap.Error(err))
}
s.cancelTimer(&s.graceTimers, roomCode)
if deleted > 0 {
logs.Info(ctx, funcName, "host 宽限期内重连,撤销转让计划",
zap.String("room_code", roomCode), zap.Int64("host_id", hostID))
}
}
// HandleHostGraceExpired host 宽限期过期处理
// 触发时机:本地 timer 到期 或 cleanup task 扫到 key TTL <=0
// 流程:
// 1. DEL host_grace key → 返回 0 表示已被其他路径处理,直接 return 防止重复转让
// 2. 加载 room + 活跃成员;若 room 已 Ended / host 已变更,视为幂等完成
// 3. 活跃成员存在:选最早加入者转 host事务广播 host.changedauto_reason=host_grace_expired
// 4. 无活跃成员:走 OnAllMembersLeft 路径(置 empty_ttl
func (s *MeetingLifecycleService) HandleHostGraceExpired(ctx context.Context, roomCode string) {
funcName := "service.meeting_lifecycle_service.HandleHostGraceExpired"
deleted, err := s.redis.Del(ctx, HostGraceKey(roomCode)).Result()
if err != nil {
logs.Warn(ctx, funcName, "DEL host_grace key 失败(跳过本次处理)",
zap.String("room_code", roomCode), zap.Error(err))
return
}
s.cancelTimer(&s.graceTimers, roomCode)
if deleted == 0 {
logs.Debug(ctx, funcName, "host_grace key 不存在(已被重连/其他节点清走)",
zap.String("room_code", roomCode))
return
}
room, err := s.roomDAO.GetByCode(ctx, roomCode)
if err != nil || room == nil {
logs.Warn(ctx, funcName, "加载会议失败或已不存在",
zap.String("room_code", roomCode), zap.Error(err))
return
}
if room.Status == constants.MeetingStatusEnded {
logs.Info(ctx, funcName, "会议已结束,跳过 host 宽限期过期处理",
zap.String("room_code", roomCode))
return
}
actives, err := s.participantDAO.ListActiveByRoom(ctx, room.ID)
if err != nil {
logs.Warn(ctx, funcName, "拉取活跃成员失败",
zap.String("room_code", roomCode), zap.Error(err))
return
}
// 排除原 host若 left_at IS NULL 但 WS 掉线未落库)
candidates := make([]int64, 0, len(actives))
for _, p := range actives {
if p.UserID == room.HostID {
continue
}
candidates = append(candidates, p.UserID)
}
if len(candidates) == 0 {
logs.Info(ctx, funcName, "host 宽限期过期且无其他活跃成员,转空房 TTL",
zap.String("room_code", roomCode), zap.Int64("old_host_id", room.HostID))
// 清掉 host 自己的记录(若 WS 掉线未落库,由兜底 LeaveRoom 处理)
_, _ = s.participantDAO.LeaveRoom(ctx, room.ID, room.HostID, constants.MeetingLeftReasonDisconnect)
s.OnAllMembersLeft(ctx, roomCode)
return
}
newHostID := candidates[0]
if err := s.participantDAO.TransferHost(ctx, room.ID, room.HostID, newHostID); err != nil {
logs.Error(ctx, funcName, "host 自动转让 TransferHost 失败",
zap.String("room_code", roomCode), zap.Error(err))
return
}
if err := s.roomDAO.UpdateHost(ctx, room.ID, newHostID); err != nil {
logs.Warn(ctx, funcName, "UpdateHost 失败(已发生参与者 role 变更)",
zap.String("room_code", roomCode), zap.Error(err))
}
// 老 host 彻底离会(宽限期内未重连视为网络断开)
_, _ = s.participantDAO.LeaveRoom(ctx, room.ID, room.HostID, constants.MeetingLeftReasonDisconnect)
s.broadcaster.BroadcastToMeeting(ctx, room.ID, constants.MeetingWSEventHostChanged, map[string]interface{}{
"room_code": roomCode,
"old_host_id": room.HostID,
"new_host_id": newHostID,
"auto_reason": "host_grace_expired",
})
logs.Info(ctx, funcName, "host 宽限期过期自动转让完成",
zap.String("room_code", roomCode),
zap.Int64("old_host_id", room.HostID),
zap.Int64("new_host_id", newHostID))
}
// OnAllMembersLeft 全员退出钩子
// 设置 empty_ttl key + 启动本地 AfterFunc期间有人重新加入可由 CancelEmptyTTL 复活房间
func (s *MeetingLifecycleService) OnAllMembersLeft(ctx context.Context, roomCode string) {
funcName := "service.meeting_lifecycle_service.OnAllMembersLeft"
ttl := time.Duration(s.cfg.EmptyRoomTTLSeconds) * time.Second
// Redis TTL 同样加 buffer 避免与本地 timer 同时到期导致 DEL 无法命中
redisTTL := ttl + s.ttlBuffer()
if err := s.redis.Set(ctx, EmptyTTLKey(roomCode), "1", redisTTL).Err(); err != nil {
logs.Warn(ctx, funcName, "写入 empty_ttl key 失败",
zap.String("room_code", roomCode), zap.Error(err))
return
}
s.scheduleEmptyTTLTimer(roomCode, ttl)
logs.Info(ctx, funcName, "空房 TTL 已启动",
zap.String("room_code", roomCode), zap.Int("ttl_seconds", s.cfg.EmptyRoomTTLSeconds))
}
// CancelEmptyTTL 取消空房 TTL有新成员加入时调用房间复活
// 幂等key 不存在也不报错
func (s *MeetingLifecycleService) CancelEmptyTTL(ctx context.Context, roomCode string) {
funcName := "service.meeting_lifecycle_service.CancelEmptyTTL"
deleted, err := s.redis.Del(ctx, EmptyTTLKey(roomCode)).Result()
if err != nil {
logs.Warn(ctx, funcName, "清除 empty_ttl key 失败",
zap.String("room_code", roomCode), zap.Error(err))
}
s.cancelTimer(&s.emptyTTLTimers, roomCode)
if deleted > 0 {
logs.Info(ctx, funcName, "空房 TTL 已取消(有人加入)",
zap.String("room_code", roomCode))
}
}
// HandleEmptyRoomExpired 空房 TTL 过期处理
// 流程DEL empty_ttl key → 确认没被新成员清走 → MarkEnded(reason=empty_ttl) + CloseRouter 幂等
func (s *MeetingLifecycleService) HandleEmptyRoomExpired(ctx context.Context, roomCode string) {
funcName := "service.meeting_lifecycle_service.HandleEmptyRoomExpired"
deleted, err := s.redis.Del(ctx, EmptyTTLKey(roomCode)).Result()
if err != nil {
logs.Warn(ctx, funcName, "DEL empty_ttl key 失败",
zap.String("room_code", roomCode), zap.Error(err))
return
}
s.cancelTimer(&s.emptyTTLTimers, roomCode)
if deleted == 0 {
logs.Debug(ctx, funcName, "empty_ttl key 已消失(有人重入 或 已处理过)",
zap.String("room_code", roomCode))
return
}
room, err := s.roomDAO.GetByCode(ctx, roomCode)
if err != nil || room == nil {
logs.Warn(ctx, funcName, "加载会议失败或已不存在",
zap.String("room_code", roomCode), zap.Error(err))
return
}
if room.Status == constants.MeetingStatusEnded {
return
}
// 二次校验DEL 成功与 DB 校验之间可能有新成员加入(并发场景)
activeCount, _ := s.participantDAO.CountActiveByRoom(ctx, room.ID)
if activeCount > 0 {
logs.Info(ctx, funcName, "检测到活跃成员,放弃销毁",
zap.String("room_code", roomCode), zap.Int64("active_count", activeCount))
return
}
if _, mErr := s.roomDAO.MarkEnded(ctx, room.ID, constants.MeetingEndedReasonEmptyTTL, time.Now()); mErr != nil {
logs.Error(ctx, funcName, "MarkEnded 失败",
zap.String("room_code", roomCode), zap.Error(mErr))
return
}
if err := s.media.CloseRouter(ctx, roomCode); err != nil {
logs.Warn(ctx, funcName, "CloseRouter 失败(不影响 DB 状态)",
zap.String("room_code", roomCode), zap.Error(err))
}
s.broadcaster.BroadcastToMeeting(ctx, room.ID, constants.MeetingWSEventRoomEnded, map[string]interface{}{
"room_code": roomCode,
"reason": constants.MeetingEndedReasonEmptyTTL,
})
logs.Info(ctx, funcName, "空房 TTL 过期,会议已销毁",
zap.String("room_code", roomCode), zap.Int64("room_id", room.ID))
}
// RescheduleFromRedis 服务启动时扫描 Redis 已存在的 grace / empty_ttl key按剩余 PTTL 补装本地 timer
// 服务重启后调用一次,避免重启期间 WS 断线事件的本地 timer 丢失导致宽限期/TTL 永远不触发
func (s *MeetingLifecycleService) RescheduleFromRedis(ctx context.Context) {
funcName := "service.meeting_lifecycle_service.RescheduleFromRedis"
s.rescheduleByPattern(ctx, funcName, redisKeyHostGracePrefix+"*", func(roomCode string, dur time.Duration) {
s.scheduleHostGraceTimer(roomCode, dur)
})
s.rescheduleByPattern(ctx, funcName, redisKeyEmptyTTLPrefix+"*", func(roomCode string, dur time.Duration) {
s.scheduleEmptyTTLTimer(roomCode, dur)
})
}
// ScanExpired 扫描指定 key pattern返回 TTL <=0 (或即将过期)对应的 roomCode 列表
// 供定时任务兜底调用:若本地 timer 已丢失,此时 Redis key 可能仍存在但 TTL 为负
// 本实现采用 SCAN + PTTL单次扫描 limit 控制内存
func (s *MeetingLifecycleService) ScanExpired(ctx context.Context, keyPrefix string, limit int64) []string {
funcName := "service.meeting_lifecycle_service.ScanExpired"
expired := make([]string, 0, limit)
var cursor uint64
for {
keys, next, err := s.redis.Scan(ctx, cursor, keyPrefix+"*", limit).Result()
if err != nil {
logs.Warn(ctx, funcName, "SCAN 失败", zap.String("pattern", keyPrefix), zap.Error(err))
return expired
}
for _, k := range keys {
pttl, err := s.redis.PTTL(ctx, k).Result()
if err != nil {
continue
}
// PTTL<=0redis 过期延迟删除场景PTTL==-2 已不存在
if pttl > 0 {
continue
}
roomCode := keyToRoomCode(k, keyPrefix)
if roomCode != "" {
expired = append(expired, roomCode)
}
}
if next == 0 {
break
}
cursor = next
}
return expired
}
// HostGracePrefix / EmptyTTLPrefix 暴露 key 前缀供定时任务扫描使用
func (s *MeetingLifecycleService) HostGracePrefix() string { return redisKeyHostGracePrefix }
func (s *MeetingLifecycleService) EmptyTTLPrefix() string { return redisKeyEmptyTTLPrefix }
// ========== 内部辅助 ==========
func (s *MeetingLifecycleService) scheduleHostGraceTimer(roomCode string, d time.Duration) {
s.cancelTimer(&s.graceTimers, roomCode)
timer := time.AfterFunc(d, func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
s.HandleHostGraceExpired(ctx, roomCode)
})
s.graceTimers.Store(roomCode, timer)
}
func (s *MeetingLifecycleService) scheduleEmptyTTLTimer(roomCode string, d time.Duration) {
s.cancelTimer(&s.emptyTTLTimers, roomCode)
timer := time.AfterFunc(d, func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
s.HandleEmptyRoomExpired(ctx, roomCode)
})
s.emptyTTLTimers.Store(roomCode, timer)
}
// ttlBuffer 返回 Redis key 相对本地 timer 的额外 buffer 时长
// 用于保证本地 timer 先触发DEL 命中),避免 Redis 自动过期后 DEL 返回 0 被误判为重复触发
// 规则max(CleanupIntervalSeconds * 2, 30s),确保单机 cleanup 至少能扫到一轮兜底
func (s *MeetingLifecycleService) ttlBuffer() time.Duration {
buf := time.Duration(s.cfg.CleanupIntervalSeconds*2) * time.Second
if buf < 30*time.Second {
buf = 30 * time.Second
}
return buf
}
// cancelTimer 取消本地 timer幂等不存在也安全
func (s *MeetingLifecycleService) cancelTimer(store *sync.Map, roomCode string) {
if raw, ok := store.LoadAndDelete(roomCode); ok {
if t, ok := raw.(*time.Timer); ok {
t.Stop()
}
}
}
// rescheduleByPattern 通用"按 pattern 扫描 Redis + 按 PTTL 重建本地 timer"逻辑
func (s *MeetingLifecycleService) rescheduleByPattern(
ctx context.Context,
funcName string,
pattern string,
schedule func(roomCode string, dur time.Duration),
) {
var cursor uint64
total := 0
for {
keys, next, err := s.redis.Scan(ctx, cursor, pattern, 100).Result()
if err != nil {
logs.Warn(ctx, funcName, "SCAN 失败", zap.String("pattern", pattern), zap.Error(err))
return
}
for _, k := range keys {
pttl, err := s.redis.PTTL(ctx, k).Result()
if err != nil || pttl <= 0 {
continue
}
prefix := pattern[:len(pattern)-1] // 去掉末尾 *
roomCode := keyToRoomCode(k, prefix)
if roomCode == "" {
continue
}
schedule(roomCode, pttl)
total++
}
if next == 0 {
break
}
cursor = next
}
if total > 0 {
logs.Info(ctx, funcName, "服务启动重建 timer 完成",
zap.String("pattern", pattern), zap.Int("count", total))
}
}
// keyToRoomCode 从完整 Redis key 中去掉前缀得到 roomCode
// 返回空字符串表示格式异常
func keyToRoomCode(fullKey, prefix string) string {
if len(fullKey) <= len(prefix) {
return ""
}
return fullKey[len(prefix):]
}
// formatErr 构造统一带上下文的错误(用于少数需要 return 的场景,避免裸字符串拼接)
// nolint:unused
func formatErr(funcName, msg string, err error) error {
return fmt.Errorf("%s: %s: %w", funcName, msg, err)
}