ghp / packages /server /src /services /roomService.ts
QSLY's picture
deploy: build Hugging Face Space from source
00a912e
Raw
History Blame Contribute Delete
19.1 kB
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'
// Re-export from their new homes so existing `roomService.xxx()` callers
// in controllers don't need import changes.
export { toPublicRoomState, toPublicRoomStateForOwner } from '../utils/roomUtils.js'
export { broadcastRoomList } from './roomLifecycleService.js'
// ---------------------------------------------------------------------------
// Room role invariant + conductor election
// ---------------------------------------------------------------------------
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
}
/**
* 保证非空房间始终至少有一个具备管理能力的在线用户。
*
* - creator 在线:creator 为 owner,清除临时管理员
* - 持久 admin 在线:保持 admin,清除临时管理员
* - owner / 持久 admin 都不在线:授予一个在线用户临时 admin
*
* 临时 admin 仅存在于当前在线会话,不写入 adminUserIds;当 owner / 持久 admin
* 回来时自动降回 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
}
/**
* 从在线用户中选出最高优先级的 conductor(播放主持)。
* 优先级:owner > admin(含临时 admin) > member(按加入顺序)。
*/
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
}
// ---------------------------------------------------------------------------
// Public API — Room CRUD
// ---------------------------------------------------------------------------
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
// Cancel any pending room deletion (e.g. user refreshed and is rejoining)
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
// Determine the permission role — purely based on identity, no grace logic
function resolveRole(): User['role'] {
if (isCreator) return 'owner'
if (room!.adminUserIds.has(userId)) return 'admin'
return 'member'
}
// Rejoin — update existing user entry instead of creating duplicate
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 }
}
// New user entry
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)
// Reconcile roles first so owner/admin returning clears any temporary admin.
const roleChanged = reconcileRoomRoles(room)
// Re-elect conductor (owner joining takes priority over current conductor)
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
// Race condition guard: if the user has another active socket in this room
// (e.g. page refresh — new socket joined before old socket disconnected),
// only clean up the stale mapping without removing the user from the room.
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)
// An empty room has no conductor to advance the queue. Freeze the
// server-authoritative position as soon as the last user
// leaves so permanent rooms do not keep accumulating playback time while
// nobody is listening. Persist before scheduling cleanup because permanent
// rooms return early from scheduleDeletion().
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 }
}
// Keep at least one online admin-capable user before electing conductor.
const roleChanged = reconcileRoomRoles(room)
// Re-elect conductor immediately — no grace period
const hostChanged = electConductor(room)
roomRepo.persist(roomId)
// Update active vote threshold so it doesn't become impossible to pass
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 }
}
// ---------------------------------------------------------------------------
// Public API — Read / Settings / Roles
// ---------------------------------------------------------------------------
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
}
// password: string -> set password; null -> remove password; undefined -> no change
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 }
// Cannot change owner's role
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)
// Sync persistent admin set
if (role === 'admin') {
room.adminUserIds.add(targetUserId)
} else {
room.adminUserIds.delete(targetUserId)
}
const reconciledRoleChanged = reconcileRoomRoles(room)
// Re-elect conductor (admin promotion/demotion may change priority)
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 }
}
// ---------------------------------------------------------------------------
// Join validation (business logic extracted from roomController)
// ---------------------------------------------------------------------------
/** Constant-time string comparison to mitigate timing attacks */
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
/** Whether this is a rejoin (user already in room or same socket mapping) — skip join notification */
isRejoin: boolean
/** Whether password should be bypassed (rejoin, creator, or persistent admin) */
skipPassword: boolean
}
/**
* Validate a join request: check room existence, password, rejoin scenarios.
* Pure business logic — no socket operations.
*/
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
// Password bypass: same socket mapping, already in room, creator, or persistent admin
const skipPassword =
hasValidRejoinTicket ||
existingMapping?.roomId === roomId ||
alreadyInRoom ||
isCreator ||
isPersistentAdmin ||
isServerAdmin
// Notification skip: only when user is literally still in the room
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 }
}
}
// Auto-leave check: if the socket is mapped to a different room, the caller
// should call leaveRoom before proceeding. We just flag the scenario here.
return { valid: true, isRejoin, skipPassword }
}