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

192 lines
6.8 KiB
Go
Raw Permalink 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 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))
}