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
This commit is contained in:
bujinyuan
2026-04-21 18:21:04 +08:00
parent 55d6352c6a
commit 3b83c79036
23 changed files with 1344 additions and 48 deletions

View File

@@ -0,0 +1,191 @@
// Package task 提供 meeting 模块的后台定时任务
package task
import (
"context"
"time"
"github.com/echochat/backend/app/constants"
"github.com/echochat/backend/app/meeting/dao"
"github.com/echochat/backend/app/meeting/service"
"github.com/echochat/backend/pkg/logs"
"go.uber.org/zap"
)
// MeetingCleanupTask 会议生命周期兜底清理任务Phase 2e-2 Task 8
// 职责(设计 §11.4 兜底策略 + Task 8 落地):
// 1. 启动时一次性 RescheduleFromRedis按 Redis 残留 grace/empty_ttl key 重建本地 timer
// 2. 每 cleanupInterval 秒扫描 host_grace / empty_ttl命中"Redis 仍在但本地 timer 可能已丢失"的场景
// 兜底触发 HandleHostGraceExpired / HandleEmptyRoomExpired真正过期判断由 Lifecycle 内部 DEL 互斥
// 3. 每 10 分钟扫一次 stale active rooms活跃状态但超过 StaleRoomHours 无成员动作)→ 强制 MarkEnded(system_error)
// 4. 每 24 小时扫 Ended 超过 chat 保留时长的房间 → DeleteByRoomIDs 清理 meeting_chats
//
// 所有周期都由独立 ticker 驱动,相互不阻塞;退出走 stopCh 统一关闭
type MeetingCleanupTask struct {
lifecycleSvc *service.MeetingLifecycleService
roomDAO *dao.MeetingRoomDAO
chatDAO *dao.MeetingChatDAO
cleanupInterval time.Duration // 扫 grace/empty_ttl 的周期
staleInterval time.Duration // 扫 stale active 的周期
chatInterval time.Duration // 扫 chat 清理的周期
staleHours int // 超过此小时的活跃房间视为 stale
stopCh chan struct{}
}
// NewMeetingCleanupTask 构造定时任务
// 扫描参数从 lifecycleSvc.Config() 读取,避免两处配置分离
func NewMeetingCleanupTask(
lifecycleSvc *service.MeetingLifecycleService,
roomDAO *dao.MeetingRoomDAO,
chatDAO *dao.MeetingChatDAO,
) *MeetingCleanupTask {
cfg := lifecycleSvc.Config()
interval := time.Duration(cfg.CleanupIntervalSeconds) * time.Second
if interval <= 0 {
interval = 30 * time.Second
}
staleHours := cfg.StaleRoomHours
if staleHours <= 0 {
staleHours = 4
}
return &MeetingCleanupTask{
lifecycleSvc: lifecycleSvc,
roomDAO: roomDAO,
chatDAO: chatDAO,
cleanupInterval: interval,
staleInterval: 10 * time.Minute,
chatInterval: 24 * time.Hour,
staleHours: staleHours,
stopCh: make(chan struct{}),
}
}
// Start 启动任务(非阻塞)
// 应在 main 进程启动后调用,进程退出时调用 Stop
func (t *MeetingCleanupTask) Start() {
funcName := "task.meeting_cleanup_task.Start"
logs.Info(nil, funcName, "启动会议生命周期兜底任务",
zap.Duration("cleanup_interval", t.cleanupInterval),
zap.Duration("stale_interval", t.staleInterval),
zap.Duration("chat_interval", t.chatInterval),
zap.Int("stale_hours", t.staleHours))
go t.runRescheduleOnce()
go t.runLoop("grace_empty_ttl", t.cleanupInterval, 5*time.Second, t.scanExpiredKeys)
go t.runLoop("stale_active", t.staleInterval, 30*time.Second, t.scanStaleActive)
go t.runLoop("chat_cleanup", t.chatInterval, 60*time.Second, t.cleanupExpiredChats)
}
// Stop 停止所有循环
func (t *MeetingCleanupTask) Stop() {
close(t.stopCh)
}
// runRescheduleOnce 启动后一次性扫描 Redis 残留 key 并补装本地 timer
func (t *MeetingCleanupTask) runRescheduleOnce() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
t.lifecycleSvc.RescheduleFromRedis(ctx)
}
// runLoop 通用"固定间隔 + 预热延迟 + stopCh 退出"循环
// warmup 用于错峰避免启动风暴,单次业务执行由 do 函数自行控制超时
func (t *MeetingCleanupTask) runLoop(name string, interval, warmup time.Duration, do func()) {
funcName := "task.meeting_cleanup_task.runLoop." + name
timer := time.NewTimer(warmup)
select {
case <-t.stopCh:
timer.Stop()
return
case <-timer.C:
}
do()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-t.stopCh:
logs.Info(nil, funcName, "任务循环退出")
return
case <-ticker.C:
do()
}
}
}
// scanExpiredKeys 扫 host_grace 与 empty_ttl 两个 key 集合
// Redis 原生 TTL 过期时 Go 的 PTTL 会返回 -2不存在此时无需兜底
// 少数场景AfterFunc 丢失 / 多实例部署)会出现 Redis 仍有 key 但无本地 timer
// 本扫描对 "TTL≤0 但仍存在" 的 key 主动调 HandleXxxExpired内部 DEL 互斥)
func (t *MeetingCleanupTask) scanExpiredKeys() {
funcName := "task.meeting_cleanup_task.scanExpiredKeys"
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
hostExpired := t.lifecycleSvc.ScanExpired(ctx, t.lifecycleSvc.HostGracePrefix(), 100)
for _, code := range hostExpired {
t.lifecycleSvc.HandleHostGraceExpired(ctx, code)
}
emptyExpired := t.lifecycleSvc.ScanExpired(ctx, t.lifecycleSvc.EmptyTTLPrefix(), 100)
for _, code := range emptyExpired {
t.lifecycleSvc.HandleEmptyRoomExpired(ctx, code)
}
if len(hostExpired)+len(emptyExpired) > 0 {
logs.Info(ctx, funcName, "兜底处理过期 key 完成",
zap.Int("host_grace", len(hostExpired)),
zap.Int("empty_ttl", len(emptyExpired)))
}
}
// scanStaleActive 扫超 staleHours 无任何成员动作的活跃会议,强制结束
// reason=system_error明确标记非正常终止便于运维排查 Node worker 掉链等异常
func (t *MeetingCleanupTask) scanStaleActive() {
funcName := "task.meeting_cleanup_task.scanStaleActive"
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
ids, err := t.roomDAO.ListStaleActive(ctx, t.staleHours, 100)
if err != nil {
logs.Warn(ctx, funcName, "扫描 stale active 失败", zap.Error(err))
return
}
if len(ids) == 0 {
return
}
now := time.Now()
for _, id := range ids {
if _, err := t.roomDAO.MarkEnded(ctx, id, constants.MeetingEndedReasonSystemError, now); err != nil {
logs.Warn(ctx, funcName, "兜底 MarkEnded 失败",
zap.Int64("room_id", id), zap.Error(err))
}
}
logs.Info(ctx, funcName, "stale 会议兜底结束", zap.Int("count", len(ids)))
}
// cleanupExpiredChats 扫超过 MeetingChatRetentionHours 的 Ended 会议,批量删除聊天记录
func (t *MeetingCleanupTask) cleanupExpiredChats() {
funcName := "task.meeting_cleanup_task.cleanupExpiredChats"
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
ids, err := t.roomDAO.ListExpiredForCleanup(ctx, constants.MeetingChatRetentionHours, 500)
if err != nil {
logs.Warn(ctx, funcName, "扫描过期会议失败", zap.Error(err))
return
}
if len(ids) == 0 {
return
}
deleted, err := t.chatDAO.DeleteByRoomIDs(ctx, ids)
if err != nil {
logs.Warn(ctx, funcName, "批量删除聊天记录失败", zap.Error(err))
return
}
logs.Info(ctx, funcName, "会议聊天清理完成",
zap.Int("room_count", len(ids)),
zap.Int64("chat_deleted", deleted))
}