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:
bujinyuan
2026-04-23 17:12:38 +08:00
parent ea2bf96c2f
commit f5ae095033
5 changed files with 123 additions and 3 deletions

View File

@@ -357,10 +357,50 @@ func (s *MeetingLifecycleService) HandleEmptyRoomExpired(ctx context.Context, ro
"room_code": roomCode, "room_code": roomCode,
"reason": constants.MeetingEndedReasonEmptyTTL, "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 过期,会议已销毁", logs.Info(ctx, funcName, "空房 TTL 过期,会议已销毁",
zap.String("room_code", roomCode), zap.Int64("room_id", room.ID)) 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 // RescheduleFromRedis 服务启动时扫描 Redis 已存在的 grace / empty_ttl key按剩余 PTTL 补装本地 timer
// 服务重启后调用一次,避免重启期间 WS 断线事件的本地 timer 丢失导致宽限期/TTL 永远不触发 // 服务重启后调用一次,避免重启期间 WS 断线事件的本地 timer 丢失导致宽限期/TTL 永远不触发
func (s *MeetingLifecycleService) RescheduleFromRedis(ctx context.Context) { func (s *MeetingLifecycleService) RescheduleFromRedis(ctx context.Context) {

View File

@@ -575,6 +575,18 @@ func (s *MeetingService) EndRoom(ctx context.Context, userID int64, code string)
logs.Warn(ctx, funcName, "关闭 mediasoup Router 失败", zap.Error(err)) 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)) logs.Info(ctx, funcName, "会议已结束", zap.String("room_code", code), zap.Int64("host_id", userID))
return nil return nil
} }

View File

@@ -708,3 +708,28 @@ func (s *MeetingSignalService) cleanupUserResources(ctx context.Context, roomCod
zap.Int("resource_count", len(members))) 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)))
}

View File

@@ -602,6 +602,11 @@ onUnload(() => {
if (meetingStore.isInMeeting && meetingStore.localState !== MEETING_LOCAL_STATE_LEAVING) { if (meetingStore.isInMeeting && meetingStore.localState !== MEETING_LOCAL_STATE_LEAVING) {
meetingStore.leave().catch(() => {}) meetingStore.leave().catch(() => {})
} }
// Task 16 资源清理:若停留在"会议已结束"页面后物理返回,清空 pinia state
// 避免 currentRoom / participants / chatMessages 等大对象常驻内存
if (meetingStore.localState === MEETING_LOCAL_STATE_ENDED) {
meetingStore.exitEndedRoom()
}
}) })
</script> </script>

View File

@@ -177,6 +177,12 @@ export const useMeetingStore = defineStore('meeting', () => {
/** { [userId]: { inCount: number, outCount: number } },用于防抖 1-in / 2-out */ /** { [userId]: { inCount: number, outCount: number } },用于防抖 1-in / 2-out */
const _speakingDebounce = {} const _speakingDebounce = {}
/**
* Task 16 资源清理专项_broadcastSelfState 重试期间的 setTimeout 句柄集合
* 离会 / 结束会议时统一 clearTimeout避免会议结束后仍有 stale 重试逻辑在 event loop 排队
*/
const _pendingBroadcastTimers = new Set()
// ==================== Getters ==================== // ==================== Getters ====================
const isInMeeting = computed(() => { const isInMeeting = computed(() => {
@@ -583,6 +589,12 @@ export const useMeetingStore = defineStore('meeting', () => {
const _cleanupMedia = () => { const _cleanupMedia = () => {
_stopSpeakingDetection() _stopSpeakingDetection()
// Task 16 资源清理:清理 _broadcastSelfState 挂起的 setTimeout 重试
// 防止会议结束后重试逻辑仍在 event loop 排队造成 memory pin
_pendingBroadcastTimers.forEach((handle) => {
try { clearTimeout(handle) } catch {}
})
_pendingBroadcastTimers.clear()
if (_engine) { if (_engine) {
try { _engine.close() } catch (e) { _log('warn', '_cleanupMedia engine.close', e) } try { _engine.close() } catch (e) { _log('warn', '_cleanupMedia engine.close', e) }
_engine = null _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 () => { const endMeeting = async () => {
if (!currentRoom.value) return if (!currentRoom.value) return
const code = currentRoom.value.room_code const code = currentRoom.value.room_code
@@ -937,7 +955,20 @@ export const useMeetingStore = defineStore('meeting', () => {
_log('warn', '[Meeting] end API 失败', err) _log('warn', '[Meeting] end API 失败', err)
throw 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本地推流 ==================== // ==================== Actions本地推流 ====================
@@ -1052,7 +1083,13 @@ export const useMeetingStore = defineStore('meeting', () => {
} }
const delay = backoffMs[attempt + 1] || 2100 const delay = backoffMs[attempt + 1] || 2100
_log('warn', `[Meeting] 上报本地音视频状态失败,${delay}ms 后重试 (${attempt + 2}/${maxAttempts})`, err) _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) attemptSend(0)
@@ -1224,6 +1261,7 @@ export const useMeetingStore = defineStore('meeting', () => {
joinAndEnter, joinAndEnter,
leave, leave,
endMeeting, endMeeting,
exitEndedRoom,
// media // media
startLocalAudio, startLocalAudio,