| import { |
| ERROR_CODE, |
| EVENTS, |
| roomCreateSchema, |
| roomJoinSchema, |
| roomSettingsSchema, |
| setRoleSchema, |
| } from '@music-together/shared' |
| import type { TypedServer, TypedSocket } from '../middleware/types.js' |
| import { createWithOwnerOnly } from '../middleware/withControl.js' |
| import { createWithRoom } from '../middleware/withRoom.js' |
| import { cleanupSocketRateLimit } from '../middleware/socketRateLimiter.js' |
| import { roomRepo } from '../repositories/roomRepository.js' |
| import { userRepo } from '../repositories/userRepository.js' |
| import * as chatService from '../services/chatService.js' |
| import * as authService from '../services/authService.js' |
| import * as playerService from '../services/playerService.js' |
| import { issueRejoinTicket, revokeRejoinTickets } from '../services/rejoinTicketService.js' |
| import * as roomService from '../services/roomService.js' |
| import * as voteService from '../services/voteService.js' |
| import { logger } from '../utils/logger.js' |
|
|
| export function registerRoomController(io: TypedServer, socket: TypedSocket) { |
| const withRoom = createWithRoom(io) |
| const withOwnerOnly = createWithOwnerOnly(io) |
|
|
| |
| socket.on(EVENTS.ROOM_LIST, () => { |
| try { |
| socket.emit(EVENTS.ROOM_LIST_UPDATE, roomService.listPublicRooms()) |
| } catch (err) { |
| logger.error('ROOM_LIST handler error', err, { socketId: socket.id }) |
| } |
| }) |
|
|
| |
| socket.on(EVENTS.ROOM_CREATE, (raw) => { |
| try { |
| const parsed = roomCreateSchema.safeParse(raw) |
| if (!parsed.success) { |
| socket.emit(EVENTS.ROOM_ERROR, { |
| code: ERROR_CODE.INVALID_INPUT, |
| message: parsed.error.issues[0]?.message ?? '输入格式错误', |
| }) |
| return |
| } |
| const { nickname, roomName, password } = parsed.data |
|
|
| |
| handleLeave(io, socket, 'auto-leave before create', true) |
|
|
| const { room, user } = roomService.createRoom( |
| socket.id, |
| nickname.trim(), |
| roomName, |
| password, |
| socket.data.identityUserId, |
| ) |
|
|
| socket.leave('lobby') |
| socket.join(room.id) |
| authService.restoreUserCookies(room.id, user.id) |
| void refreshRestoredMembershipDetails(io, socket, room.id, user.id) |
| socket.emit(EVENTS.ROOM_CREATED, { roomId: room.id, userId: user.id }) |
| |
| socket.emit(EVENTS.ROOM_STATE, roomService.toPublicRoomStateForOwner(room)) |
| const rejoin = issueRejoinTicket(room.id, user.id) |
| socket.emit(EVENTS.ROOM_REJOIN_TOKEN, { roomId: room.id, token: rejoin.token, expiresAt: rejoin.expiresAt }) |
|
|
| |
| roomService.broadcastRoomList(io) |
| } catch (err) { |
| logger.error('ROOM_CREATE handler error', err, { socketId: socket.id }) |
| socket.emit(EVENTS.ROOM_ERROR, { code: ERROR_CODE.INTERNAL, message: '服务器内部错误' }) |
| } |
| }) |
|
|
| |
| socket.on(EVENTS.ROOM_JOIN, async (raw) => { |
| try { |
| const parsed = roomJoinSchema.safeParse(raw) |
| if (!parsed.success) { |
| socket.emit(EVENTS.ROOM_ERROR, { |
| code: ERROR_CODE.INVALID_INPUT, |
| message: parsed.error.issues[0]?.message ?? '输入格式错误', |
| }) |
| return |
| } |
| const { roomId, nickname, password, rejoinToken } = parsed.data |
|
|
| |
| const validation = roomService.validateJoinRequest( |
| roomId, |
| socket.id, |
| socket.data.identityUserId, |
| password, |
| rejoinToken, |
| ) |
| if (!validation.valid) { |
| socket.emit(EVENTS.ROOM_ERROR, { |
| code: ERROR_CODE[validation.errorCode as keyof typeof ERROR_CODE] ?? ERROR_CODE.JOIN_FAILED, |
| message: validation.errorMessage ?? '加入房间失败', |
| }) |
| return |
| } |
|
|
| |
| const existingMapping = roomRepo.getSocketMapping(socket.id) |
| if (existingMapping && existingMapping.roomId !== roomId) { |
| handleLeave(io, socket, 'auto-leave before join', true) |
| } |
|
|
| const result = roomService.joinRoom(socket.id, roomId, nickname.trim(), socket.data.identityUserId) |
| if (!result) { |
| socket.emit(EVENTS.ROOM_ERROR, { code: ERROR_CODE.JOIN_FAILED, message: '加入房间失败' }) |
| return |
| } |
|
|
| const { room: updatedRoom, user, hostChanged, roleChanged } = result |
| const rejoin = issueRejoinTicket(roomId, user.id) |
|
|
| socket.leave('lobby') |
| authService.restoreUserCookies(roomId, user.id) |
|
|
| |
| |
| |
| await refreshRestoredMembershipDetails(io, socket, roomId, user.id) |
| if (!socket.connected) return |
|
|
| |
| |
| await playerService.refreshStreamUrlForJoin(roomId) |
| if (!socket.connected) return |
| socket.join(roomId) |
|
|
| |
| |
| socket.emit(EVENTS.CHAT_HISTORY, chatService.getHistory(roomId)) |
|
|
| |
| |
| |
| playerService.preparePlaybackForJoiningRoom(roomId, updatedRoom) |
|
|
| |
| |
| const isOwner = user.role === 'owner' |
| const stateForJoiner = isOwner |
| ? roomService.toPublicRoomStateForOwner(updatedRoom) |
| : roomService.toPublicRoomState(updatedRoom) |
| socket.emit(EVENTS.ROOM_STATE, stateForJoiner) |
|
|
| |
| |
| if (hostChanged || roleChanged) { |
| socket.to(roomId).emit(EVENTS.ROOM_STATE, roomService.toPublicRoomState(updatedRoom)) |
| } |
| socket.emit(EVENTS.ROOM_REJOIN_TOKEN, { roomId, token: rejoin.token, expiresAt: rejoin.expiresAt }) |
| socket.emit(EVENTS.AUTH_MY_STATUS, authService.getUserAuthStatus(user.id, roomId)) |
| io.to(roomId).emit(EVENTS.AUTH_STATUS_UPDATE, authService.getAllPlatformStatus(roomId)) |
|
|
| |
| playerService.syncPlaybackToSocket(io, socket, roomId, updatedRoom).catch((err) => { |
| logger.error('syncPlaybackToSocket failed', err, { roomId }) |
| }) |
|
|
| |
| const activeVote = voteService.getActiveVote(roomId) |
| if (activeVote) { |
| socket.emit(EVENTS.VOTE_STARTED, voteService.toVoteState(activeVote)) |
| } |
|
|
| |
| if (!validation.isRejoin) { |
| socket.to(roomId).emit(EVENTS.ROOM_USER_JOINED, user) |
| |
| const joinMsg = chatService.createSystemMessage(roomId, `${user.nickname} 加入了房间`) |
| io.to(roomId).emit(EVENTS.CHAT_MESSAGE, joinMsg) |
| } |
|
|
| |
| roomService.broadcastRoomList(io) |
| } catch (err) { |
| logger.error('ROOM_JOIN handler error', err, { socketId: socket.id }) |
| socket.emit(EVENTS.ROOM_ERROR, { code: ERROR_CODE.INTERNAL, message: '服务器内部错误' }) |
| } |
| }) |
|
|
| |
| socket.on(EVENTS.ROOM_LEAVE, () => { |
| try { |
| logger.debug('收到用户主动离开房间请求', { socketId: socket.id }) |
| handleLeave(io, socket, undefined, true) |
| } catch (err) { |
| logger.error('ROOM_LEAVE handler error', err, { socketId: socket.id }) |
| } |
| }) |
|
|
| |
| socket.on( |
| EVENTS.ROOM_SETTINGS, |
| withRoom((ctx, raw) => { |
| const parsed = roomSettingsSchema.safeParse(raw) |
| if (!parsed.success) { |
| ctx.socket.emit(EVENTS.ROOM_ERROR, { |
| code: ERROR_CODE.INVALID_INPUT, |
| message: parsed.error.issues[0]?.message ?? '输入格式错误', |
| }) |
| return |
| } |
|
|
| const changedFields = Object.keys(parsed.data).filter( |
| (key) => parsed.data[key as keyof typeof parsed.data] !== undefined, |
| ) |
| const canManageAllSettings = ctx.user.role === 'owner' || userRepo.isServerAdmin(ctx.user.id) |
| const canAdjustAudioQuality = |
| ctx.user.role === 'admin' && changedFields.length === 1 && changedFields[0] === 'audioQuality' |
| if (!canManageAllSettings && !canAdjustAudioQuality) { |
| ctx.socket.emit(EVENTS.ROOM_ERROR, { |
| code: ERROR_CODE.NO_PERMISSION, |
| message: '只有房主可以修改房间设置,管理员仅可调整音质', |
| }) |
| return |
| } |
|
|
| roomService.updateSettings(ctx.roomId, { |
| name: parsed.data.name, |
| password: parsed.data.password, |
| audioQuality: parsed.data.audioQuality, |
| hidden: parsed.data.hidden, |
| permanent: parsed.data.permanent, |
| allowTemporaryAdminTrackRemoval: parsed.data.allowTemporaryAdminTrackRemoval, |
| allowTemporaryAdminQueueClear: parsed.data.allowTemporaryAdminQueueClear, |
| }) |
|
|
| const updatedRoom = roomRepo.get(ctx.roomId) |
| if (!updatedRoom) return |
|
|
| |
| const baseSettings = { |
| name: updatedRoom.name, |
| hasPassword: updatedRoom.password !== null, |
| hidden: updatedRoom.hidden, |
| permanent: updatedRoom.permanent, |
| allowTemporaryAdminTrackRemoval: updatedRoom.allowTemporaryAdminTrackRemoval, |
| allowTemporaryAdminQueueClear: updatedRoom.allowTemporaryAdminQueueClear, |
| audioQuality: updatedRoom.audioQuality, |
| } |
| if (canManageAllSettings) { |
| ctx.socket.emit(EVENTS.ROOM_SETTINGS, { |
| ...baseSettings, |
| password: updatedRoom.password ?? null, |
| }) |
| ctx.socket.to(ctx.roomId).emit(EVENTS.ROOM_SETTINGS, baseSettings) |
| } else { |
| io.to(ctx.roomId).emit(EVENTS.ROOM_SETTINGS, baseSettings) |
| } |
|
|
| logger.info(`房间 ${ctx.roomId} 的设置已更新`, { |
| event: 'room.settings_updated', |
| roomId: ctx.roomId, |
| operatorId: ctx.user.id, |
| operator: ctx.user.nickname, |
| operatorIsServerAdmin: userRepo.isServerAdmin(ctx.user.id), |
| roomName: updatedRoom.name, |
| audioQuality: updatedRoom.audioQuality, |
| passwordProtected: updatedRoom.password !== null, |
| hidden: updatedRoom.hidden, |
| permanent: updatedRoom.permanent, |
| allowTemporaryAdminTrackRemoval: updatedRoom.allowTemporaryAdminTrackRemoval, |
| allowTemporaryAdminQueueClear: updatedRoom.allowTemporaryAdminQueueClear, |
| changedFields, |
| }) |
|
|
| |
| roomService.broadcastRoomList(io) |
| }), |
| ) |
|
|
| |
| socket.on( |
| EVENTS.ROOM_SET_ROLE, |
| withOwnerOnly((ctx, raw) => { |
| const parsed = setRoleSchema.safeParse(raw) |
| if (!parsed.success) { |
| ctx.socket.emit(EVENTS.ROOM_ERROR, { |
| code: ERROR_CODE.INVALID_INPUT, |
| message: parsed.error.issues[0]?.message ?? '输入格式错误', |
| }) |
| return |
| } |
|
|
| const { userId, role } = parsed.data |
| const result = roomService.setUserRole(ctx.roomId, userId, role) |
| if (!result.success) { |
| ctx.socket.emit(EVENTS.ROOM_ERROR, { code: ERROR_CODE.SET_ROLE_FAILED, message: '无法设置该用户的角色' }) |
| return |
| } |
|
|
| io.to(ctx.roomId).emit(EVENTS.ROOM_ROLE_CHANGED, { userId, role }) |
| if (result.hostChanged || result.roleChanged) { |
| |
| |
| ctx.socket.emit(EVENTS.ROOM_STATE, roomService.toPublicRoomStateForOwner(ctx.room)) |
| ctx.socket.to(ctx.roomId).emit(EVENTS.ROOM_STATE, roomService.toPublicRoomState(ctx.room)) |
| } |
| logger.info(`房间 ${ctx.roomId} 的用户角色已调整为 ${role}`, { |
| event: 'room.role_changed', |
| roomId: ctx.roomId, |
| operatorId: ctx.user.id, |
| operator: ctx.user.nickname, |
| operatorIsServerAdmin: userRepo.isServerAdmin(ctx.user.id), |
| targetUserId: userId, |
| role, |
| conductorChanged: result.hostChanged, |
| }) |
| }), |
| ) |
|
|
| |
| socket.on('disconnect', (reason) => { |
| try { |
| logger.debug('客户端连接已断开', { socketId: socket.id, reason }) |
| handleLeave(io, socket) |
| |
| |
| |
| roomRepo.deleteSocketMapping(socket.id) |
| cleanupSocketRateLimit(socket) |
| } catch (err) { |
| logger.error('disconnect handler error', err, { socketId: socket.id }) |
| } |
| }) |
| } |
|
|
| async function refreshRestoredMembershipDetails( |
| io: TypedServer, |
| socket: TypedSocket, |
| roomId: string, |
| userId: string, |
| ): Promise<void> { |
| try { |
| const updatedPlatforms = await authService.refreshMissingMembershipDetails(roomId, userId) |
| if (updatedPlatforms.length === 0) return |
| io.to(roomId).emit(EVENTS.AUTH_STATUS_UPDATE, authService.getAllPlatformStatus(roomId)) |
| const mapping = roomRepo.getSocketMapping(socket.id) |
| if (mapping?.roomId === roomId && mapping.userId === userId) { |
| socket.emit(EVENTS.AUTH_MY_STATUS, authService.getUserAuthStatus(userId, roomId)) |
| } |
| } catch (error) { |
| logger.warn('自动刷新恢复账号的会员详情失败', { |
| event: 'auth.membership_refresh_failed', |
| roomId, |
| userId, |
| error: error instanceof Error ? error.message : String(error), |
| }) |
| } |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| function handleLeave(io: TypedServer, socket: TypedSocket, reason?: string, revokeTicket = false): void { |
| const result = roomService.leaveRoom(socket.id, io) |
| if (!result) return |
|
|
| const { roomId, user, room, hostChanged, roleChanged, voteUpdated, staleSocketOnly } = result |
| if (revokeTicket) { |
| revokeRejoinTickets(roomId, user.id) |
| } |
| socket.leave(roomId) |
| socket.join('lobby') |
|
|
| |
| |
| if (staleSocketOnly) return |
|
|
| io.to(roomId).emit(EVENTS.ROOM_USER_LEFT, user) |
|
|
| |
| |
| if (room) { |
| const leaveMsg = chatService.createSystemMessage(roomId, `${user.nickname} 离开了房间`) |
| if (room.users.length > 0) { |
| io.to(roomId).emit(EVENTS.CHAT_MESSAGE, leaveMsg) |
| } |
| } |
|
|
| |
| |
| if ((hostChanged || roleChanged) && room && room.users.length > 0) { |
| const newOwner = room.users.find((u) => u.role === 'owner') |
| const ownerSocketId = newOwner ? roomRepo.getSocketIdForUser(roomId, newOwner.id) : null |
| if (ownerSocketId) { |
| io.to(ownerSocketId).emit(EVENTS.ROOM_STATE, roomService.toPublicRoomStateForOwner(room)) |
| io.to(roomId).except(ownerSocketId).emit(EVENTS.ROOM_STATE, roomService.toPublicRoomState(room)) |
| } else { |
| io.to(roomId).emit(EVENTS.ROOM_STATE, roomService.toPublicRoomState(room)) |
| } |
| } |
|
|
| |
| if (voteUpdated) { |
| const activeVote = voteService.getActiveVote(roomId) |
| if (activeVote) { |
| io.to(roomId).emit(EVENTS.VOTE_STARTED, voteService.toVoteState(activeVote)) |
| } |
| } |
|
|
| |
| roomService.broadcastRoomList(io) |
|
|
| if (reason) { |
| logger.debug('连接因切换操作离开原房间', { roomId, socketId: socket.id, reason }) |
| } |
| } |
|
|