fix(meeting): Task 16 会议销毁路径资源清理专项
前端修复: - _onRoomEnded 保留结束页数据,新增 exitEndedRoom() 动作 room.vue onUnload 在 ENDED 状态调用,释放 currentRoom / participants / chatMessages 等 pinia state - endMeeting() API 成功后立即触发 _onRoomEnded 本地兜底,不再依赖 room.ended 广播 避免 API 成功但 WS 抖动导致 engine / timer / AudioContext 泄漏 - 新增 _pendingBroadcastTimers 集合收纳 _broadcastSelfState 的 setTimeout 句柄 _cleanupMedia 统一 clearTimeout,避免会议结束后 stale 重试逻辑排队 后端修复: - MeetingLifecycleService.OnRoomEnded 统一生命周期收尾 取消 graceTimers / emptyTTLTimers + Pipeline DEL host_grace / empty_ttl / 两个 handling 锁 key - EndRoom 事务提交后遍历 activesBefore 调 cleanupRoomRedisResidual 批量 DEL 每个前任活跃成员的 resourceTrackKey + memberStateKey 补调 lifecycleSvc.OnRoomEnded 清理自身 timer + keys - HandleEmptyRoomExpired MarkEnded 后补调 cleanupRoomRedisResidual + 清 host_grace 残留 验证:go vet + go build + npm run build:h5 全部通过 Made-with: Cursor
This commit is contained in:
@@ -357,10 +357,50 @@ func (s *MeetingLifecycleService) HandleEmptyRoomExpired(ctx context.Context, ro
|
||||
"room_code": roomCode,
|
||||
"reason": constants.MeetingEndedReasonEmptyTTL,
|
||||
})
|
||||
|
||||
// Task 16 资源清理专项:空房销毁时 activeCount==0,每个前任活跃成员的 Redis Set / Hash 理论上
|
||||
// 应已被各自的 LeaveRoom → cleanupUserResources 清掉;此处幂等兜底清理生命周期自身持有的 timer
|
||||
// 与 handling 锁 key(避免 OnRoomEnded 缺失导致的 handle/timers 累积)
|
||||
cleanupRoomRedisResidual(ctx, s.redis, roomCode, nil)
|
||||
s.cancelTimer(&s.graceTimers, roomCode)
|
||||
// empty_ttl 主 key 已经在当前函数顶部 DEL,此处再 DEL host_grace + handling 锁 key 做收尾
|
||||
pipe := s.redis.Pipeline()
|
||||
pipe.Del(ctx, HostGraceKey(roomCode))
|
||||
pipe.Del(ctx, redisKeyHostGraceHandlingLock+roomCode)
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
logs.Warn(ctx, funcName, "清理会议剩余生命周期 key 失败(忽略)",
|
||||
zap.String("room_code", roomCode), zap.Error(err))
|
||||
}
|
||||
|
||||
logs.Info(ctx, funcName, "空房 TTL 过期,会议已销毁",
|
||||
zap.String("room_code", roomCode), zap.Int64("room_id", room.ID))
|
||||
}
|
||||
|
||||
// OnRoomEnded 会议结束(host 主动 EndRoom / 空房 TTL 销毁 / 其他销毁路径)统一生命周期收尾
|
||||
// Task 16 资源清理专项:
|
||||
// 1. 取消 host 宽限期 / 空房 TTL 两条本地 timer,避免 time.AfterFunc 到期后跑一轮无效 HandleXxxExpired
|
||||
// (虽然 HandleXxxExpired 内部有 status=Ended 早退路径,但仍浪费 30s context + Redis 往返)
|
||||
// 2. DEL host_grace / empty_ttl 主 key 及对应的处理锁 key,避免 Redis 条目累积到自然过期
|
||||
// 幂等:多次调用、无 timer / 无 key 场景都安全
|
||||
func (s *MeetingLifecycleService) OnRoomEnded(ctx context.Context, roomCode string) {
|
||||
funcName := "service.meeting_lifecycle_service.OnRoomEnded"
|
||||
|
||||
s.cancelTimer(&s.graceTimers, roomCode)
|
||||
s.cancelTimer(&s.emptyTTLTimers, roomCode)
|
||||
|
||||
pipe := s.redis.Pipeline()
|
||||
pipe.Del(ctx, HostGraceKey(roomCode))
|
||||
pipe.Del(ctx, EmptyTTLKey(roomCode))
|
||||
pipe.Del(ctx, redisKeyHostGraceHandlingLock+roomCode)
|
||||
pipe.Del(ctx, redisKeyEmptyTTLHandlingLock+roomCode)
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
logs.Warn(ctx, funcName, "批量清理生命周期 Redis key 失败(忽略,继续)",
|
||||
zap.String("room_code", roomCode), zap.Error(err))
|
||||
}
|
||||
logs.Debug(ctx, funcName, "会议结束,生命周期 timers/keys 已清理",
|
||||
zap.String("room_code", roomCode))
|
||||
}
|
||||
|
||||
// RescheduleFromRedis 服务启动时扫描 Redis 已存在的 grace / empty_ttl key,按剩余 PTTL 补装本地 timer
|
||||
// 服务重启后调用一次,避免重启期间 WS 断线事件的本地 timer 丢失导致宽限期/TTL 永远不触发
|
||||
func (s *MeetingLifecycleService) RescheduleFromRedis(ctx context.Context) {
|
||||
|
||||
@@ -575,6 +575,18 @@ func (s *MeetingService) EndRoom(ctx context.Context, userID int64, code string)
|
||||
logs.Warn(ctx, funcName, "关闭 mediasoup Router 失败", zap.Error(err))
|
||||
}
|
||||
|
||||
// Task 16 资源清理专项:EndRoom 场景下 WS 断开钩子尚未触发(用户客户端可能仍在会议页),
|
||||
// 需主动清每个活跃成员在 Redis 的资源追踪集合 + 音视频状态 Hash,避免短期反复开会累积垃圾条目
|
||||
userIDs := make([]int64, 0, len(activesBefore))
|
||||
for _, p := range activesBefore {
|
||||
userIDs = append(userIDs, p.UserID)
|
||||
}
|
||||
cleanupRoomRedisResidual(ctx, s.redis, code, userIDs)
|
||||
// 取消生命周期 timer + 清 host_grace / empty_ttl / handling 锁 key(幂等)
|
||||
if s.lifecycleSvc != nil {
|
||||
s.lifecycleSvc.OnRoomEnded(ctx, code)
|
||||
}
|
||||
|
||||
logs.Info(ctx, funcName, "会议已结束", zap.String("room_code", code), zap.Int64("host_id", userID))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -708,3 +708,28 @@ func (s *MeetingSignalService) cleanupUserResources(ctx context.Context, roomCod
|
||||
zap.Int("resource_count", len(members)))
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupRoomRedisResidual 批量清理指定 roomCode 下一批用户的 Redis 资源追踪集合 + 音视频状态 Hash
|
||||
// Task 16 资源清理专项:
|
||||
// - EndRoom / HandleEmptyRoomExpired 等会议销毁路径调用,避免正常 WS 断开钩子尚未触发就结束会议导致的残留
|
||||
// - userIDs 为销毁前活跃成员快照;无活跃成员可传 nil/空切片,此时仅走上层 lifecycle 清理
|
||||
// - 使用 Pipeline 减少 Redis 往返;失败仅 Warn,不阻断上层销毁流程
|
||||
// - package 私有:允许 meeting_service / meeting_lifecycle_service 等同包文件直接调用
|
||||
func cleanupRoomRedisResidual(ctx context.Context, rdb *redis.Client, roomCode string, userIDs []int64) {
|
||||
funcName := "service.meeting_signal_service.cleanupRoomRedisResidual"
|
||||
if rdb == nil || len(userIDs) == 0 {
|
||||
return
|
||||
}
|
||||
pipe := rdb.Pipeline()
|
||||
for _, uid := range userIDs {
|
||||
pipe.Del(ctx, resourceTrackKey(roomCode, uid))
|
||||
pipe.Del(ctx, memberStateKey(roomCode, uid))
|
||||
}
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
logs.Warn(ctx, funcName, "批量清理用户 Redis 残留失败(忽略,继续)",
|
||||
zap.String("room_code", roomCode), zap.Int("user_count", len(userIDs)), zap.Error(err))
|
||||
return
|
||||
}
|
||||
logs.Debug(ctx, funcName, "会议销毁,用户 Redis 残留已清理",
|
||||
zap.String("room_code", roomCode), zap.Int("user_count", len(userIDs)))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user