File size: 8,617 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 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 | import {
EVENTS,
ERROR_CODE,
TIMING,
voteStartSchema,
voteCastSchema,
playerSetModeSchema,
} from '@music-together/shared'
import type { Actions, Subjects, PlayMode, VoteAction } from '@music-together/shared'
import { createWithRoom } from '../middleware/withRoom.js'
import { defineAbilityForRoomUser } from '../middleware/withControl.js'
import { checkSocketRateLimit } from '../middleware/socketRateLimiter.js'
import { roomRepo } from '../repositories/roomRepository.js'
import * as voteService from '../services/voteService.js'
import * as playerService from '../services/playerService.js'
import * as queueService from '../services/queueService.js'
import * as roomService from '../services/roomService.js'
import { logger } from '../utils/logger.js'
import type { TypedServer, TypedSocket } from '../middleware/types.js'
const ACTION_LABELS: Record<VoteAction, string> = {
pause: '暂停播放',
resume: '继续播放',
next: '播放下一首',
prev: '播放上一首',
'set-mode': '切换播放模式',
'play-track': '播放指定歌曲',
'remove-track': '移除歌曲',
}
/**
* Execute the voted action on the player.
* No initiatorSocket — broadcast to everyone since this is a collective decision.
*/
async function executeAction(
io: TypedServer,
roomId: string,
action: VoteAction,
payload?: Record<string, unknown>,
): Promise<void> {
switch (action) {
case 'pause':
playerService.pauseTrack(io, roomId)
break
case 'resume':
playerService.resumeTrack(io, roomId)
break
case 'next': {
const room = roomRepo.get(roomId)
await playerService.playNextTrackInRoom(io, roomId, room?.playMode ?? 'sequential', { skipDebounce: true })
break
}
case 'prev': {
await playerService.playPrevTrackInRoom(io, roomId, { skipDebounce: true })
break
}
case 'set-mode': {
const parsed = playerSetModeSchema.safeParse(payload)
if (!parsed.success) {
io.to(roomId).emit(EVENTS.ROOM_ERROR, { code: ERROR_CODE.INVALID_INPUT, message: '无效的播放模式' })
break
}
const room = roomRepo.get(roomId)
if (!room) break
room.playMode = parsed.data.mode
roomRepo.persist(roomId)
io.to(roomId).emit(EVENTS.ROOM_STATE, roomService.toPublicRoomState(room))
logger.info(`投票通过:房间 ${roomId} 的播放模式已切换为 ${parsed.data.mode}`, {
event: 'player.mode_changed_by_vote',
roomId,
playMode: parsed.data.mode,
})
break
}
case 'play-track': {
const trackId = payload?.trackId
if (typeof trackId !== 'string') {
io.to(roomId).emit(EVENTS.ROOM_ERROR, { code: ERROR_CODE.INVALID_INPUT, message: '无效的歌曲 ID' })
break
}
const room = roomRepo.get(roomId)
if (!room) break
const track = room.queue.find((t) => t.id === trackId)
if (track) {
await playerService.playTrackInRoom(io, roomId, track)
logger.debug('投票操作已触发指定歌曲播放', { roomId, trackId })
} else {
io.to(roomId).emit(EVENTS.ROOM_ERROR, { code: ERROR_CODE.INVALID_INPUT, message: '歌曲不在播放列表中' })
}
break
}
case 'remove-track': {
const trackId = payload?.trackId
if (typeof trackId !== 'string') {
io.to(roomId).emit(EVENTS.ROOM_ERROR, { code: ERROR_CODE.INVALID_INPUT, message: '无效的歌曲 ID' })
break
}
const room = roomRepo.get(roomId)
if (!room) break
const isCurrentTrack = room.currentTrack?.id === trackId
queueService.removeTrack(roomId, trackId)
io.to(roomId).emit(EVENTS.QUEUE_UPDATED, { queue: room.queue })
if (isCurrentTrack) {
await playerService.playNextTrackInRoom(io, roomId, room.playMode, { skipDebounce: true })
}
logger.info(`投票操作已从队列移除歌曲 ${trackId}`, {
event: 'queue.track_removed_by_vote',
roomId,
trackId,
wasPlaying: isCurrentTrack,
})
break
}
}
}
export function registerVoteController(io: TypedServer, socket: TypedSocket) {
const withRoom = createWithRoom(io)
socket.on(
EVENTS.VOTE_START,
withRoom(async (ctx, raw) => {
if (!(await checkSocketRateLimit(ctx.socket))) return
const parsed = voteStartSchema.safeParse(raw)
if (!parsed.success) {
ctx.socket.emit(EVENTS.ROOM_ERROR, { code: ERROR_CODE.INVALID_INPUT, message: '无效的投票请求' })
return
}
const { action, payload } = parsed.data
// Check if user has direct permission (owner/admin don't need to vote).
// VoteAction includes 'resume' which is not in CASL Actions — cast is intentional.
// Some vote actions map to a different CASL action for permission checks
// (e.g. 'play-track' requires the same 'play' permission as normal playback).
// Safety net: if the client mistakenly routes through VOTE_START (e.g. due to
// client-server role desync), execute the action directly instead of returning an error.
const PERM_MAP: Partial<Record<VoteAction, { action: string; subject: string }>> = {
'play-track': { action: 'play', subject: 'Player' },
'remove-track': { action: 'remove', subject: 'Queue' },
}
const ability = defineAbilityForRoomUser(ctx.user.id, ctx.user.role)
const perm = PERM_MAP[action]
const permAction = perm?.action ?? action
const permSubject = perm?.subject ?? 'Player'
if (ability.can(permAction as Actions, permSubject as Subjects)) {
await executeAction(io, ctx.roomId, action, payload)
logger.debug(`有权限的用户“${ctx.user.nickname}”直接执行:${ACTION_LABELS[action]}`, {
roomId: ctx.roomId,
userId: ctx.user.id,
role: ctx.user.role,
action,
})
return
}
// Check if user can vote
if (!ability.can('vote', 'Player')) {
ctx.socket.emit(EVENTS.ROOM_ERROR, { code: ERROR_CODE.NO_PERMISSION, message: '你没有投票权限' })
return
}
const vote = voteService.createVote(ctx.roomId, ctx.room.hostId, ctx.user, action, ctx.room.users.length, payload)
if (!vote) {
ctx.socket.emit(EVENTS.ROOM_ERROR, { code: ERROR_CODE.VOTE_IN_PROGRESS, message: '已有投票正在进行中' })
return
}
// Check if the vote is already decided (e.g. only 1-2 users in the room)
const approveCount = Object.values(vote.votes).filter(Boolean).length
if (approveCount >= vote.requiredVotes) {
// Auto-pass: execute immediately
clearTimeout(vote.timeoutHandle)
await executeAction(io, ctx.roomId, action, vote.payload)
io.to(ctx.roomId).emit(EVENTS.VOTE_RESULT, { passed: true, action })
voteService.cancelVote(ctx.roomId)
return
}
// Set timeout for auto-reject
vote.timeoutHandle = setTimeout(() => {
io.to(ctx.roomId).emit(EVENTS.VOTE_RESULT, { passed: false, action, reason: 'timeout' })
voteService.cancelVote(ctx.roomId)
logger.info(`房间 ${ctx.roomId} 的“${ACTION_LABELS[action]}”投票已超时`, {
event: 'vote.timed_out',
roomId: ctx.roomId,
action,
})
}, TIMING.VOTE_TIMEOUT_MS)
// Broadcast vote started
io.to(ctx.roomId).emit(EVENTS.VOTE_STARTED, voteService.toVoteState(vote))
}),
)
socket.on(
EVENTS.VOTE_CAST,
withRoom(async (ctx, raw) => {
const parsed = voteCastSchema.safeParse(raw)
if (!parsed.success) {
ctx.socket.emit(EVENTS.ROOM_ERROR, { code: ERROR_CODE.INVALID_INPUT, message: '无效的投票数据' })
return
}
const result = voteService.castVote(ctx.roomId, ctx.user.id, parsed.data.approve)
if (!result) {
ctx.socket.emit(EVENTS.ROOM_ERROR, { code: ERROR_CODE.ALREADY_VOTED, message: '你已经投过票了' })
return
}
// Broadcast updated vote state
io.to(ctx.roomId).emit(EVENTS.VOTE_STARTED, voteService.toVoteState(result.vote))
if (result.decided) {
clearTimeout(result.vote.timeoutHandle)
if (result.passed) {
await executeAction(io, ctx.roomId, result.vote.action, result.vote.payload)
}
io.to(ctx.roomId).emit(EVENTS.VOTE_RESULT, {
passed: result.passed,
action: result.vote.action,
reason: result.reason,
})
voteService.cancelVote(ctx.roomId)
}
}),
)
}
|