File size: 5,879 Bytes
00a912e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 | /**
* Room Lifecycle Service
*
* Manages timers (room deletion) and debounced lobby broadcasts.
* Extracted from roomService to separate lifecycle/timer concerns from CRUD logic
* and eliminate the circular dependency (roomService -> playerController -> roomService).
*
* Dependency direction:
* roomService -> roomLifecycleService -> (repos, playerService, voteService, authService)
* roomLifecycleService does NOT depend on roomService (no circular risk).
*
* Note: Role grace period has been removed. Owner role is permanent (based on creatorId),
* admin is persisted via adminUserIds, temporary admin is assigned when no permanent
* privileged user is online, and conductor (hostId) is auto-elected by electConductor()
* in roomService whenever users join/leave.
*/
import type { RoomListItem } from '@music-together/shared'
import { EVENTS } from '@music-together/shared'
import { config } from '../config.js'
import type { TypedServer } from '../middleware/types.js'
import { chatRepo } from '../repositories/chatRepository.js'
import { roomRepo } from '../repositories/roomRepository.js'
import { logger } from '../utils/logger.js'
import { cleanupRoom as cleanupAuthRoom } from './authService.js'
import { cleanupRoom as cleanupPlayerRoom } from './playerService.js'
import { cleanupRoom as cleanupVoteRoom } from './voteService.js'
import { cleanupRoomRejoinTickets } from './rejoinTicketService.js'
// ---------------------------------------------------------------------------
// Module-level state
// ---------------------------------------------------------------------------
/** 宽限期定时器:房间变空后延迟删除,给断线用户重连的窗口 */
const roomDeletionTimers = new Map<string, ReturnType<typeof setTimeout>>()
/** 防抖广播:100ms trailing debounce */
let broadcastTimer: ReturnType<typeof setTimeout> | null = null
let pendingIO: TypedServer | null = null
// ---------------------------------------------------------------------------
// Room deletion timer
// ---------------------------------------------------------------------------
export function scheduleDeletion(roomId: string, io?: TypedServer): void {
// Prevent duplicate timers if called multiple times for the same room
cancelDeletionTimer(roomId)
const room = roomRepo.get(roomId)
if (room?.permanent) {
logger.info(`永久房间 ${roomId} 已空置,将保留等待用户加入`, {
event: 'room.permanent_retained',
roomId,
})
return
}
logger.info(`房间 ${roomId} 已空置,将在 ${config.room.gracePeriodMs / 1000} 秒后删除`, {
event: 'room.deletion_scheduled',
roomId,
gracePeriodMs: config.room.gracePeriodMs,
})
const timer = setTimeout(() => {
const r = roomRepo.get(roomId)
if (r && r.users.length === 0 && !r.permanent) {
roomRepo.delete(roomId)
chatRepo.deleteRoom(roomId)
cleanupPlayerRoom(roomId)
cleanupVoteRoom(roomId)
cleanupAuthRoom(roomId)
cleanupRoomRejoinTickets(roomId)
roomDeletionTimers.delete(roomId)
logger.info(`空置房间 ${roomId} 已删除`, { event: 'room.deleted', roomId })
// Notify lobby users that the room is gone
if (io) broadcastRoomList(io)
}
}, config.room.gracePeriodMs)
roomDeletionTimers.set(roomId, timer)
}
export function cancelDeletionTimer(roomId: string): void {
const timer = roomDeletionTimers.get(roomId)
if (timer) {
clearTimeout(timer)
roomDeletionTimers.delete(roomId)
logger.info(`用户重新加入,已取消删除房间 ${roomId}`, { event: 'room.deletion_cancelled', roomId })
}
}
/** Immediately dissolve a room and notify every connected member. */
export function destroyRoom(roomId: string, io: TypedServer): boolean {
const room = roomRepo.get(roomId)
if (!room) return false
cancelDeletionTimer(roomId)
for (const socket of io.getSocketsInRoom(roomId)) {
roomRepo.deleteSocketMapping(socket.id)
socket.leave(roomId)
socket.join('lobby')
socket.emit(EVENTS.ROOM_ERROR, { code: 'ROOM_NOT_FOUND', message: '房间已被服务器管理员解散' })
}
roomRepo.delete(roomId)
chatRepo.deleteRoom(roomId)
cleanupPlayerRoom(roomId)
cleanupVoteRoom(roomId)
cleanupAuthRoom(roomId)
cleanupRoomRejoinTickets(roomId)
broadcastRoomList(io)
logger.info(`房间 ${roomId} 已被服务器管理员解散`, { event: 'room.dissolved', roomId })
return true
}
// ---------------------------------------------------------------------------
// Debounced lobby broadcast
// ---------------------------------------------------------------------------
/**
* 向 lobby 频道广播房间列表变更(100ms trailing 防抖)。
* 多次快速调用(如 create+join、多人同时 leave)会合并为一次广播,
* 避免重复执行公开大厅投影的遍历和序列化。
*/
export function broadcastRoomList(io: TypedServer): void {
pendingIO = io
if (broadcastTimer) return
broadcastTimer = setTimeout(() => {
broadcastTimer = null
if (pendingIO) {
const rooms: RoomListItem[] = roomRepo.getPublicLobbyList()
pendingIO.to('lobby').emit(EVENTS.ROOM_LIST_UPDATE, rooms)
}
}, 100)
}
// ---------------------------------------------------------------------------
// Shutdown cleanup — clear all module-level timers
// ---------------------------------------------------------------------------
/** Clear all timers managed by this module. Call during graceful shutdown. */
export function clearAllTimers(): void {
// Room deletion timers
for (const timer of roomDeletionTimers.values()) clearTimeout(timer)
roomDeletionTimers.clear()
// Broadcast debounce timer
if (broadcastTimer) {
clearTimeout(broadcastTimer)
broadcastTimer = null
}
pendingIO = null
logger.debug('房间生命周期定时器已全部清理')
}
|