diff --git a/backend/go-service/app/meeting/service/meeting_lifecycle_service.go b/backend/go-service/app/meeting/service/meeting_lifecycle_service.go index b0ff16f..9e9dc45 100644 --- a/backend/go-service/app/meeting/service/meeting_lifecycle_service.go +++ b/backend/go-service/app/meeting/service/meeting_lifecycle_service.go @@ -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) { diff --git a/backend/go-service/app/meeting/service/meeting_service.go b/backend/go-service/app/meeting/service/meeting_service.go index bd8e5be..15b6697 100644 --- a/backend/go-service/app/meeting/service/meeting_service.go +++ b/backend/go-service/app/meeting/service/meeting_service.go @@ -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 } diff --git a/backend/go-service/app/meeting/service/meeting_signal_service.go b/backend/go-service/app/meeting/service/meeting_signal_service.go index 1c25f85..8adbf01 100644 --- a/backend/go-service/app/meeting/service/meeting_signal_service.go +++ b/backend/go-service/app/meeting/service/meeting_signal_service.go @@ -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))) +} diff --git a/frontend/src/pages/meeting/room.vue b/frontend/src/pages/meeting/room.vue index 66f896a..592f438 100644 --- a/frontend/src/pages/meeting/room.vue +++ b/frontend/src/pages/meeting/room.vue @@ -602,6 +602,11 @@ onUnload(() => { if (meetingStore.isInMeeting && meetingStore.localState !== MEETING_LOCAL_STATE_LEAVING) { meetingStore.leave().catch(() => {}) } + // Task 16 资源清理:若停留在"会议已结束"页面后物理返回,清空 pinia state + // 避免 currentRoom / participants / chatMessages 等大对象常驻内存 + if (meetingStore.localState === MEETING_LOCAL_STATE_ENDED) { + meetingStore.exitEndedRoom() + } }) diff --git a/frontend/src/store/meeting.js b/frontend/src/store/meeting.js index ebadcbc..d9efd05 100644 --- a/frontend/src/store/meeting.js +++ b/frontend/src/store/meeting.js @@ -177,6 +177,12 @@ export const useMeetingStore = defineStore('meeting', () => { /** { [userId]: { inCount: number, outCount: number } },用于防抖 1-in / 2-out */ const _speakingDebounce = {} + /** + * Task 16 资源清理专项:_broadcastSelfState 重试期间的 setTimeout 句柄集合 + * 离会 / 结束会议时统一 clearTimeout,避免会议结束后仍有 stale 重试逻辑在 event loop 排队 + */ + const _pendingBroadcastTimers = new Set() + // ==================== Getters ==================== const isInMeeting = computed(() => { @@ -583,6 +589,12 @@ export const useMeetingStore = defineStore('meeting', () => { const _cleanupMedia = () => { _stopSpeakingDetection() + // Task 16 资源清理:清理 _broadcastSelfState 挂起的 setTimeout 重试 + // 防止会议结束后重试逻辑仍在 event loop 排队造成 memory pin + _pendingBroadcastTimers.forEach((handle) => { + try { clearTimeout(handle) } catch {} + }) + _pendingBroadcastTimers.clear() if (_engine) { try { _engine.close() } catch (e) { _log('warn', '_cleanupMedia engine.close', e) } _engine = null @@ -927,7 +939,13 @@ export const useMeetingStore = defineStore('meeting', () => { } } - /** 主持人结束会议(会广播 room.ended 给所有人) */ + /** + * 主持人结束会议(后端会广播 room.ended 给所有人) + * Task 16 资源清理:API 成功后立即本地兜底 _cleanupMedia + * - 避免"API 成功但 WS 断线导致 room.ended 广播丢失 → 本地 engine/timer/AudioContext 泄漏" + * - 同时触发 _onRoomEnded 走 ENDED 状态,UI 展示"会议已结束"提示页 + * - 若之后广播到达也是幂等(_engine 已 null + closed 判定) + */ const endMeeting = async () => { if (!currentRoom.value) return const code = currentRoom.value.room_code @@ -937,7 +955,20 @@ export const useMeetingStore = defineStore('meeting', () => { _log('warn', '[Meeting] end API 失败', err) throw err } - // room.ended WS 广播会触发 _onRoomEnded 清理 + _onRoomEnded({ data: { room_code: code, reason: 'host_ended' } }) + } + + /** + * Task 16 资源清理:退出"会议已结束"提示页时的清理 + * - _onRoomEnded 刻意保留了 currentRoom / participants / chatMessages 供结束页渲染 + * - 但若用户长时间停留在结束页(或直接 reLaunch 去别处),pinia state 会一直占内存 + * - 调用方:room.vue 的 onUnload / 结束页"返回首页"按钮 + * - 仅在 ENDED 状态下执行 _reset;其他状态用 leave() 通道 + */ + const exitEndedRoom = () => { + if (localState.value === MEETING_LOCAL_STATE_ENDED) { + _reset() + } } // ==================== Actions:本地推流 ==================== @@ -1052,7 +1083,13 @@ export const useMeetingStore = defineStore('meeting', () => { } const delay = backoffMs[attempt + 1] || 2100 _log('warn', `[Meeting] 上报本地音视频状态失败,${delay}ms 后重试 (${attempt + 2}/${maxAttempts})`, err) - setTimeout(() => attemptSend(attempt + 1), delay) + // Task 16 资源清理:把 setTimeout 句柄登记到全局 Set + // 离会/结束会议时 _cleanupMedia() 统一 clearTimeout,避免 stale 重试排队 + const handle = setTimeout(() => { + _pendingBroadcastTimers.delete(handle) + attemptSend(attempt + 1) + }, delay) + _pendingBroadcastTimers.add(handle) }) } attemptSend(0) @@ -1224,6 +1261,7 @@ export const useMeetingStore = defineStore('meeting', () => { joinAndEnter, leave, endMeeting, + exitEndedRoom, // media startLocalAudio,