| import { timingSafeEqual } from 'node:crypto' |
| import type { AudioQuality, RoomListItem, RoomMember, User, UserRole } from '@music-together/shared' |
| import { nanoid } from 'nanoid' |
| import type { RoomData } from '../repositories/types.js' |
| import { roomRepo } from '../repositories/roomRepository.js' |
| import { chatRepo } from '../repositories/chatRepository.js' |
| import { scheduleDeletion, cancelDeletionTimer } from './roomLifecycleService.js' |
| import { consumeRejoinTicket } from './rejoinTicketService.js' |
| import { estimateCurrentTime } from './syncService.js' |
| import { updateVoteThreshold } from './voteService.js' |
| import { logger } from '../utils/logger.js' |
| import type { TypedServer } from '../middleware/types.js' |
| import { userRepo } from '../repositories/userRepository.js' |
|
|
| |
| |
| export { toPublicRoomState, toPublicRoomStateForOwner } from '../utils/roomUtils.js' |
| export { broadcastRoomList } from './roomLifecycleService.js' |
|
|
| |
| |
| |
|
|
| function isPermanentPrivileged(room: RoomData, userId: string): boolean { |
| return userId === room.creatorId || room.adminUserIds.has(userId) |
| } |
|
|
| function setRoleIfChanged(user: User, role: UserRole): boolean { |
| if (user.role === role) return false |
| user.role = role |
| return true |
| } |
|
|
| function setMemberRoleIfChanged(room: RoomData, userId: string, role: UserRole): boolean { |
| const member = room.members.find((item) => item.id === userId) |
| if (!member || member.role === role) return false |
| member.role = role |
| return true |
| } |
|
|
| function upsertRoomMember(room: RoomData, user: User, role: UserRole): RoomMember { |
| const now = Date.now() |
| const existing = room.members.find((member) => member.id === user.id) |
| if (existing) { |
| existing.nickname = user.nickname |
| existing.avatarUrl = user.avatarUrl |
| existing.isServerAdmin = user.isServerAdmin |
| existing.role = role |
| existing.isOnline = true |
| existing.lastSeenAt = now |
| return existing |
| } |
|
|
| const member: RoomMember = { |
| ...user, |
| role, |
| isOnline: true, |
| joinedAt: now, |
| lastSeenAt: now, |
| } |
| room.members.push(member) |
| return member |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function reconcileRoomRoles(room: RoomData): boolean { |
| let changed = false |
|
|
| if (room.users.length === 0) { |
| if (room.temporaryAdminUserId !== null) { |
| room.temporaryAdminUserId = null |
| changed = true |
| } |
| for (const member of room.members) { |
| const role: UserRole = |
| member.id === room.creatorId ? 'owner' : room.adminUserIds.has(member.id) ? 'admin' : 'member' |
| changed = setMemberRoleIfChanged(room, member.id, role) || changed |
| } |
| return changed |
| } |
|
|
| const hasOnlinePermanentPrivileged = room.users.some((u) => isPermanentPrivileged(room, u.id)) |
|
|
| if (hasOnlinePermanentPrivileged) { |
| if (room.temporaryAdminUserId !== null) { |
| room.temporaryAdminUserId = null |
| changed = true |
| } |
| for (const user of room.users) { |
| const role: UserRole = user.id === room.creatorId ? 'owner' : room.adminUserIds.has(user.id) ? 'admin' : 'member' |
| changed = setRoleIfChanged(user, role) || changed |
| changed = setMemberRoleIfChanged(room, user.id, role) || changed |
| } |
| for (const member of room.members) { |
| const role: UserRole = |
| member.id === room.creatorId ? 'owner' : room.adminUserIds.has(member.id) ? 'admin' : 'member' |
| changed = setMemberRoleIfChanged(room, member.id, role) || changed |
| } |
| return changed |
| } |
|
|
| const currentTempStillOnline = room.users.some((u) => u.id === room.temporaryAdminUserId) |
| if (!room.temporaryAdminUserId || !currentTempStillOnline) { |
| room.temporaryAdminUserId = room.users[0]!.id |
| changed = true |
| } |
|
|
| for (const user of room.users) { |
| const role: UserRole = user.id === room.temporaryAdminUserId ? 'admin' : 'member' |
| changed = setRoleIfChanged(user, role) || changed |
| changed = setMemberRoleIfChanged(room, user.id, role) || changed |
| } |
| for (const member of room.members) { |
| const role: UserRole = |
| member.id === room.creatorId |
| ? 'owner' |
| : room.adminUserIds.has(member.id) |
| ? 'admin' |
| : member.id === room.temporaryAdminUserId |
| ? 'admin' |
| : 'member' |
| changed = setMemberRoleIfChanged(room, member.id, role) || changed |
| } |
|
|
| return changed |
| } |
|
|
| |
| |
| |
| |
| function electConductor(room: RoomData): boolean { |
| const prev = room.hostId |
| const candidate = |
| room.users.find((u) => u.role === 'owner') ?? room.users.find((u) => u.role === 'admin') ?? room.users[0] |
| room.hostId = candidate?.id ?? room.hostId |
|
|
| if (room.hostId !== prev) { |
| return true |
| } |
| return false |
| } |
|
|
| |
| |
| |
|
|
| export function createRoom( |
| socketId: string, |
| nickname: string, |
| roomName?: string, |
| password?: string | null, |
| persistentUserId?: string, |
| ): { room: RoomData; user: User } { |
| const roomId = nanoid(6).toUpperCase() |
| const userId = persistentUserId || socketId |
| const persistedUser = userRepo.ensure(userId, { nickname }) |
| const profile = persistedUser.nickname ? persistedUser : userRepo.updateProfile(userId, { nickname }) |
| const user: User = { |
| id: userId, |
| nickname: profile.nickname, |
| avatarUrl: profile.avatarUrl, |
| role: 'owner', |
| isServerAdmin: userRepo.isServerAdmin(userId), |
| } |
|
|
| const room: RoomData = { |
| id: roomId, |
| name: roomName?.trim() || `${nickname}的房间`, |
| password: password || null, |
| creatorId: userId, |
| hostId: userId, |
| adminUserIds: new Set(), |
| temporaryAdminUserId: null, |
| allowTemporaryAdminTrackRemoval: false, |
| allowTemporaryAdminQueueClear: false, |
| hidden: false, |
| permanent: false, |
| audioQuality: 320, |
| members: [ |
| { |
| ...user, |
| isOnline: true, |
| joinedAt: Date.now(), |
| lastSeenAt: Date.now(), |
| }, |
| ], |
| users: [user], |
| queue: [], |
| currentTrack: null, |
| playState: { |
| isPlaying: false, |
| currentTime: 0, |
| serverTimestamp: Date.now(), |
| }, |
| playMode: 'loop-all', |
| } |
|
|
| roomRepo.set(roomId, room) |
| chatRepo.createRoom(roomId) |
| roomRepo.setSocketMapping(socketId, roomId, userId) |
|
|
| logger.info(`房间已创建:${room.name}(${roomId}),房主:${nickname}`, { |
| event: 'room.created', |
| roomId, |
| roomName: room.name, |
| userId, |
| nickname, |
| audioQuality: room.audioQuality, |
| playMode: room.playMode, |
| passwordProtected: room.password !== null, |
| }) |
| return { room, user } |
| } |
|
|
| export function joinRoom( |
| socketId: string, |
| roomId: string, |
| nickname: string, |
| persistentUserId?: string, |
| ): { room: RoomData; user: User; hostChanged: boolean; roleChanged: boolean } | null { |
| const room = roomRepo.get(roomId) |
| if (!room) return null |
|
|
| |
| cancelDeletionTimer(roomId) |
|
|
| const userId = persistentUserId || socketId |
| const persistedUser = userRepo.ensure(userId, { nickname }) |
| const profile = persistedUser.nickname ? persistedUser : userRepo.updateProfile(userId, { nickname }) |
| const isCreator = userId === room.creatorId |
|
|
| |
| function resolveRole(): User['role'] { |
| if (isCreator) return 'owner' |
| if (room!.adminUserIds.has(userId)) return 'admin' |
| return 'member' |
| } |
|
|
| |
| const existing = room.users.find((u) => u.id === userId) |
| if (existing) { |
| existing.nickname = profile.nickname |
| existing.avatarUrl = profile.avatarUrl |
| existing.role = resolveRole() |
| existing.isServerAdmin = userRepo.isServerAdmin(userId) |
| upsertRoomMember(room, existing, resolveRole()) |
| roomRepo.setSocketMapping(socketId, roomId, userId) |
| const roleChanged = reconcileRoomRoles(room) |
| const hostChanged = electConductor(room) |
| roomRepo.persist(roomId) |
| return { room, user: existing, hostChanged, roleChanged } |
| } |
|
|
| |
| const role = resolveRole() |
| const user: User = { |
| id: userId, |
| nickname: profile.nickname, |
| avatarUrl: profile.avatarUrl, |
| role, |
| isServerAdmin: userRepo.isServerAdmin(userId), |
| } |
| room.users.push(user) |
| upsertRoomMember(room, user, role) |
| roomRepo.setSocketMapping(socketId, roomId, userId) |
|
|
| |
| const roleChanged = reconcileRoomRoles(room) |
| |
| const hostChanged = electConductor(room) |
| roomRepo.persist(roomId) |
|
|
| logger.info(`用户“${nickname}”加入房间 ${roomId}`, { |
| event: 'room.user_joined', |
| roomId, |
| userId, |
| nickname, |
| role, |
| onlineUsers: room.users.length, |
| conductorId: room.hostId, |
| }) |
| return { room, user, hostChanged, roleChanged } |
| } |
|
|
| export function leaveRoom( |
| socketId: string, |
| io?: TypedServer, |
| ): { |
| roomId: string |
| user: User |
| room: RoomData | null |
| hostChanged: boolean |
| roleChanged: boolean |
| voteUpdated: boolean |
| staleSocketOnly: boolean |
| } | null { |
| const mapping = roomRepo.getSocketMapping(socketId) |
| if (!mapping) return null |
|
|
| const { roomId, userId } = mapping |
| const room = roomRepo.get(roomId) |
| if (!room) return null |
|
|
| const user = room.users.find((u) => u.id === userId) |
| if (!user) return null |
|
|
| |
| |
| |
| if (roomRepo.hasOtherSocketForUser(roomId, userId, socketId)) { |
| roomRepo.deleteSocketMapping(socketId) |
| logger.debug('忽略用户旧连接的断开事件(已有新连接)', { roomId, userId, socketId }) |
| return { roomId, user, room, hostChanged: false, roleChanged: false, voteUpdated: false, staleSocketOnly: true } |
| } |
|
|
| room.users = room.users.filter((u) => u.id !== userId) |
| const member = room.members.find((item) => item.id === userId) |
| if (member) { |
| member.isOnline = false |
| member.lastSeenAt = Date.now() |
| } |
| roomRepo.deleteSocketMapping(socketId) |
|
|
| |
| |
| |
| |
| |
| if (room.users.length === 0) { |
| reconcileRoomRoles(room) |
| if (room.playState.isPlaying) { |
| room.playState = { |
| isPlaying: false, |
| currentTime: estimateCurrentTime(roomId), |
| serverTimestamp: Date.now(), |
| } |
| roomRepo.persist(roomId) |
| logger.info(`房间 ${roomId} 已无人在线,播放已自动暂停`, { |
| event: 'player.auto_paused_empty_room', |
| roomId, |
| currentTime: room.playState.currentTime, |
| }) |
| } |
| roomRepo.persist(roomId) |
| scheduleDeletion(roomId, io) |
| return { roomId, user, room, hostChanged: false, roleChanged: false, voteUpdated: false, staleSocketOnly: false } |
| } |
|
|
| |
| const roleChanged = reconcileRoomRoles(room) |
| |
| const hostChanged = electConductor(room) |
| roomRepo.persist(roomId) |
|
|
| |
| const voteUpdated = updateVoteThreshold(roomId, room.users.length, user.id) |
|
|
| logger.info(`用户“${user.nickname}”离开房间 ${roomId}`, { |
| event: 'room.user_left', |
| roomId, |
| userId: user.id, |
| nickname: user.nickname, |
| role: user.role, |
| onlineUsers: room.users.length, |
| conductorChanged: hostChanged, |
| roleChanged, |
| }) |
| return { roomId, user, room, hostChanged, roleChanged, voteUpdated, staleSocketOnly: false } |
| } |
|
|
| |
| |
| |
|
|
| export function getRoom(roomId: string): RoomData | undefined { |
| return roomRepo.get(roomId) |
| } |
|
|
| export function listPublicRooms(): RoomListItem[] { |
| return roomRepo.getPublicLobbyList() |
| } |
|
|
| export function updateSettings( |
| roomId: string, |
| settings: { |
| name?: string |
| password?: string | null |
| audioQuality?: AudioQuality |
| hidden?: boolean |
| permanent?: boolean |
| allowTemporaryAdminTrackRemoval?: boolean |
| allowTemporaryAdminQueueClear?: boolean |
| }, |
| ): void { |
| const room = roomRepo.get(roomId) |
| if (!room) return |
|
|
| if (settings.name !== undefined) { |
| room.name = settings.name |
| } |
|
|
| |
| if (settings.password !== undefined) { |
| room.password = settings.password |
| } |
|
|
| if (settings.audioQuality !== undefined) { |
| room.audioQuality = settings.audioQuality |
| } |
|
|
| if (settings.hidden !== undefined) { |
| room.hidden = settings.hidden |
| } |
|
|
| if (settings.permanent !== undefined) { |
| room.permanent = settings.permanent |
| } |
|
|
| if (settings.allowTemporaryAdminTrackRemoval !== undefined) { |
| room.allowTemporaryAdminTrackRemoval = settings.allowTemporaryAdminTrackRemoval |
| } |
|
|
| if (settings.allowTemporaryAdminQueueClear !== undefined) { |
| room.allowTemporaryAdminQueueClear = settings.allowTemporaryAdminQueueClear |
| } |
|
|
| roomRepo.persist(roomId) |
| if (room.permanent) { |
| chatRepo.persistRoom(roomId) |
| } |
| } |
|
|
| export function setUserRole( |
| roomId: string, |
| targetUserId: string, |
| role: 'admin' | 'member', |
| ): { success: boolean; roleChanged: boolean; hostChanged: boolean } { |
| const room = roomRepo.get(roomId) |
| if (!room) return { success: false, roleChanged: false, hostChanged: false } |
| const member = room.members.find((item) => item.id === targetUserId) |
| if (!member) return { success: false, roleChanged: false, hostChanged: false } |
| |
| if (member.role === 'owner') return { success: false, roleChanged: false, hostChanged: false } |
|
|
| const directRoleChanged = member.role !== role |
| member.role = role |
| const onlineUser = room.users.find((user) => user.id === targetUserId) |
| if (onlineUser) setRoleIfChanged(onlineUser, role) |
| |
| if (role === 'admin') { |
| room.adminUserIds.add(targetUserId) |
| } else { |
| room.adminUserIds.delete(targetUserId) |
| } |
| const reconciledRoleChanged = reconcileRoomRoles(room) |
| |
| const hostChanged = electConductor(room) |
| roomRepo.persist(roomId) |
| return { success: true, roleChanged: directRoleChanged || reconciledRoleChanged, hostChanged } |
| } |
|
|
| export function getUserBySocket(socketId: string): User | null { |
| const mapping = roomRepo.getSocketMapping(socketId) |
| if (!mapping) return null |
| const room = roomRepo.get(mapping.roomId) |
| if (!room) return null |
| return room.users.find((u) => u.id === mapping.userId) ?? null |
| } |
|
|
| export function getRoomBySocket(socketId: string): { roomId: string; room: RoomData } | null { |
| const mapping = roomRepo.getSocketMapping(socketId) |
| if (!mapping) return null |
| const room = roomRepo.get(mapping.roomId) |
| if (!room) return null |
| return { roomId: mapping.roomId, room } |
| } |
|
|
| |
| |
| |
|
|
| |
| function safeCompare(a: string, b: string): boolean { |
| const bufA = Buffer.from(a) |
| const bufB = Buffer.from(b) |
| if (bufA.length !== bufB.length) return false |
| return timingSafeEqual(bufA, bufB) |
| } |
|
|
| export interface JoinValidationResult { |
| valid: boolean |
| errorCode?: string |
| errorMessage?: string |
| |
| isRejoin: boolean |
| |
| skipPassword: boolean |
| } |
|
|
| |
| |
| |
| |
| export function validateJoinRequest( |
| roomId: string, |
| socketId: string, |
| identityUserId: string, |
| password?: string, |
| rejoinToken?: string, |
| ): JoinValidationResult { |
| const room = roomRepo.get(roomId) |
| if (!room) { |
| return { |
| valid: false, |
| errorCode: 'ROOM_NOT_FOUND', |
| errorMessage: '房间不存在', |
| isRejoin: false, |
| skipPassword: false, |
| } |
| } |
|
|
| const existingMapping = roomRepo.getSocketMapping(socketId) |
| const effectiveUserId = identityUserId |
| const alreadyInRoom = room.users.some((u) => u.id === effectiveUserId) |
| const isCreator = effectiveUserId === room.creatorId |
| const isPersistentAdmin = room.adminUserIds.has(effectiveUserId) |
| const isServerAdmin = userRepo.isServerAdmin(effectiveUserId) |
| const hasValidRejoinTicket = |
| typeof rejoinToken === 'string' && rejoinToken.length > 0 |
| ? consumeRejoinTicket(rejoinToken, roomId, effectiveUserId) |
| : false |
|
|
| |
| const skipPassword = |
| hasValidRejoinTicket || |
| existingMapping?.roomId === roomId || |
| alreadyInRoom || |
| isCreator || |
| isPersistentAdmin || |
| isServerAdmin |
| |
| const isRejoin = existingMapping?.roomId === roomId || alreadyInRoom |
|
|
| if (!skipPassword && room.password !== null) { |
| if (!password || !safeCompare(password, room.password)) { |
| return { valid: false, errorCode: 'WRONG_PASSWORD', errorMessage: '密码错误', isRejoin, skipPassword } |
| } |
| } |
|
|
| |
| |
|
|
| return { valid: true, isRejoin, skipPassword } |
| } |
|
|