| import { EVENTS, QR_STATUS } from '@music-together/shared' |
| import type { MusicSource } from '@music-together/shared' |
| import * as authService from '../services/authService.js' |
| import { AUTH_PROVIDERS } from '../services/authProvider.js' |
| import * as kugouAuth from '../services/kugouAuthService.js' |
| import * as tencentAuth from '../services/tencentAuthService.js' |
| import { roomRepo } from '../repositories/roomRepository.js' |
| import { logger } from '../utils/logger.js' |
| import type { TypedServer, TypedSocket } from '../middleware/types.js' |
|
|
| |
| function getSocketMapping(socketId: string) { |
| return roomRepo.getSocketMapping(socketId) ?? null |
| } |
|
|
| |
| const QR_PLATFORMS = new Set<MusicSource>(['netease', 'kugou', 'kugou_concept', 'tencent', 'bilibili']) |
|
|
| |
| const VALID_PLATFORMS = new Set<MusicSource>(['netease', 'tencent', 'kugou', 'kugou_concept', 'bilibili']) |
|
|
| export function registerAuthController(io: TypedServer, socket: TypedSocket) { |
| |
| let qrSuccessHandled = false |
| let activeQr: { key: string; platform: MusicSource } | null = null |
| let qrRequestVersion = 0 |
| let qrCheckInFlight = false |
|
|
| |
| |
| |
|
|
| socket.on(EVENTS.AUTH_REQUEST_QR, async (data) => { |
| qrSuccessHandled = false |
| activeQr = null |
| const requestVersion = ++qrRequestVersion |
| try { |
| const platform = data?.platform as MusicSource |
| if (!platform || !QR_PLATFORMS.has(platform)) { |
| socket.emit(EVENTS.AUTH_QR_STATUS, { status: QR_STATUS.EXPIRED, message: '暂不支持该平台扫码登录' }) |
| return |
| } |
|
|
| const provider = AUTH_PROVIDERS[platform] |
| const result = await provider.generateQrCode() |
|
|
| |
| if (requestVersion !== qrRequestVersion) return |
|
|
| if (!result) { |
| socket.emit(EVENTS.AUTH_QR_STATUS, { status: QR_STATUS.EXPIRED, message: '生成二维码失败,请重试' }) |
| return |
| } |
|
|
| activeQr = { key: result.key, platform } |
| socket.emit(EVENTS.AUTH_QR_GENERATED, { key: result.key, qrimg: result.qrimg }) |
| } catch (err) { |
| logger.error('AUTH_REQUEST_QR error', err, { socketId: socket.id }) |
| if (requestVersion !== qrRequestVersion) return |
| socket.emit(EVENTS.AUTH_QR_STATUS, { status: QR_STATUS.EXPIRED, message: '请求失败,请重试' }) |
| } |
| }) |
|
|
| socket.on(EVENTS.AUTH_CHECK_QR, async (data) => { |
| try { |
| if (!data?.key) { |
| socket.emit(EVENTS.AUTH_QR_STATUS, { status: QR_STATUS.EXPIRED, message: '缺少二维码 key' }) |
| return |
| } |
|
|
| const platform = data.platform as MusicSource |
| if (!platform || !QR_PLATFORMS.has(platform)) { |
| logger.warn('AUTH_CHECK_QR: invalid or missing platform', { platform }) |
| return |
| } |
|
|
| if (!activeQr || activeQr.key !== data.key || activeQr.platform !== platform) { |
| logger.debug('AUTH_CHECK_QR: ignoring stale QR session', { platform }) |
| return |
| } |
| if (qrSuccessHandled) return |
| if (qrCheckInFlight) return |
| qrCheckInFlight = true |
|
|
| const provider = AUTH_PROVIDERS[platform] |
| const result = await provider.checkQrStatus(data.key) |
| if (!activeQr || activeQr.key !== data.key) { |
| qrCheckInFlight = false |
| return |
| } |
|
|
| if (result.status !== QR_STATUS.SUCCESS) { |
| qrCheckInFlight = false |
| socket.emit(EVENTS.AUTH_QR_STATUS, { status: result.status, message: result.message, key: data.key }) |
| return |
| } |
| if (!result.cookie) { |
| qrCheckInFlight = false |
| activeQr = null |
| socket.emit(EVENTS.AUTH_QR_STATUS, { |
| status: QR_STATUS.EXPIRED, |
| message: '登录成功但未获取到凭据,请重新获取二维码', |
| key: data.key, |
| }) |
| return |
| } |
| if (platform === 'tencent' && !tencentAuth.isRefreshableCredential(result.cookie)) { |
| qrCheckInFlight = false |
| activeQr = null |
| socket.emit(EVENTS.AUTH_SET_COOKIE_RESULT, { |
| success: false, |
| message: '未获取到 QQ 音乐刷新凭证,请重新扫码登录', |
| platform, |
| reason: 'reauth_required', |
| }) |
| socket.emit(EVENTS.AUTH_QR_STATUS, { |
| status: QR_STATUS.EXPIRED, |
| message: '未获取到刷新凭证,请重新获取二维码', |
| key: data.key, |
| }) |
| return |
| } |
|
|
| |
| if (result.status === QR_STATUS.SUCCESS && result.cookie && !qrSuccessHandled) { |
| qrSuccessHandled = true |
|
|
| let infoResult = await provider.getUserInfo(result.cookie) |
| if (!infoResult.ok && infoResult.reason === 'error') { |
| await new Promise((resolve) => setTimeout(resolve, 1_000)) |
| infoResult = await provider.getUserInfo(result.cookie) |
| } |
|
|
| if (infoResult.ok) { |
| const userInfo = infoResult.data |
| const mapping = getSocketMapping(socket.id) |
| if (mapping) { |
| authService.addCookie( |
| mapping.roomId, |
| platform, |
| mapping.userId, |
| result.cookie, |
| userInfo.nickname, |
| userInfo.vipType, |
| true, |
| { vipLabel: userInfo.vipLabel, vipLevel: userInfo.vipLevel }, |
| ) |
| broadcastAuthStatus(io, socket, mapping) |
| } |
| socket.emit(EVENTS.AUTH_SET_COOKIE_RESULT, { |
| success: true, |
| message: `已登录为 ${userInfo.nickname}`, |
| platform, |
| cookie: result.cookie, |
| }) |
| socket.emit(EVENTS.AUTH_QR_STATUS, { status: QR_STATUS.SUCCESS, message: '登录成功', key: data.key }) |
| logger.info(`${platform} 扫码登录成功:${userInfo.nickname}`, { |
| event: 'auth.qr_login_succeeded', |
| platform, |
| nickname: userInfo.nickname, |
| vipType: userInfo.vipType, |
| }) |
| } else { |
| socket.emit(EVENTS.AUTH_SET_COOKIE_RESULT, { |
| success: false, |
| message: '登录成功但无法获取用户信息', |
| platform, |
| reason: infoResult.reason, |
| }) |
| socket.emit(EVENTS.AUTH_QR_STATUS, { |
| status: QR_STATUS.EXPIRED, |
| message: '登录凭据验证失败,请重新获取二维码', |
| key: data.key, |
| }) |
| } |
| } |
| activeQr = null |
| qrCheckInFlight = false |
| } catch (err) { |
| logger.error('AUTH_CHECK_QR error', err, { socketId: socket.id }) |
| qrCheckInFlight = false |
| if (data?.key && activeQr?.key !== data.key) return |
| socket.emit(EVENTS.AUTH_QR_STATUS, { |
| status: QR_STATUS.EXPIRED, |
| message: '检查登录状态失败,请重试', |
| key: data?.key, |
| }) |
| } |
| }) |
|
|
| |
| |
| |
| |
|
|
| socket.on(EVENTS.AUTH_SET_COOKIE, async (data) => { |
| try { |
| if ( |
| !data?.platform || |
| !VALID_PLATFORMS.has(data.platform) || |
| !data?.cookie || |
| typeof data.cookie !== 'string' || |
| data.cookie.length > 8000 |
| ) { |
| socket.emit(EVENTS.AUTH_SET_COOKIE_RESULT, { |
| success: false, |
| message: '参数不完整', |
| }) |
| return |
| } |
|
|
| const { cookie } = data |
| const platform = data.platform as MusicSource |
| const mapping = getSocketMapping(socket.id) |
| const roomId = mapping?.roomId ?? null |
| const serverCookie = mapping && roomId ? authService.getUserCookie(mapping.userId, platform, roomId) : null |
| const incomingTencentCredential = platform === 'tencent' ? tencentAuth.parseTencentCredential(cookie) : null |
| const serverTencentCredential = |
| platform === 'tencent' && serverCookie ? tencentAuth.parseTencentCredential(serverCookie) : null |
|
|
| |
| |
| |
| if ( |
| platform === 'tencent' && |
| serverCookie && |
| serverCookie !== cookie && |
| incomingTencentCredential?.musicid === serverTencentCredential?.musicid && |
| tencentAuth.isRefreshableCredential(serverCookie) |
| ) { |
| socket.emit(EVENTS.AUTH_SET_COOKIE_RESULT, { |
| success: true, |
| message: '已恢复服务器上的最新 QQ 音乐凭证', |
| platform, |
| cookie: serverCookie, |
| }) |
| if (mapping) broadcastAuthStatus(io, socket, mapping) |
| return |
| } |
|
|
| if (platform === 'tencent' && !tencentAuth.isRefreshableCredential(cookie)) { |
| socket.emit(EVENTS.AUTH_SET_COOKIE_RESULT, { |
| success: false, |
| message: 'QQ 音乐登录凭证已升级,请重新扫码登录', |
| platform, |
| reason: 'reauth_required', |
| }) |
| if (mapping) broadcastAuthStatus(io, socket, mapping) |
| return |
| } |
|
|
| |
| if ( |
| mapping && |
| roomId && |
| authService.getUserCookie(mapping.userId, platform, roomId) === cookie && |
| !authService.needsMembershipRefresh(mapping.userId, platform, roomId) |
| ) { |
| if (data.persist !== false) { |
| authService.persistUserCookieFromRoom(roomId, platform, mapping.userId) |
| } |
| socket.emit(EVENTS.AUTH_SET_COOKIE_RESULT, { |
| success: true, |
| message: 'Cookie 已生效', |
| platform, |
| cookie, |
| }) |
| broadcastAuthStatus(io, socket, mapping) |
| return |
| } |
|
|
| |
| const provider = AUTH_PROVIDERS[platform] |
| let infoResult = await provider.getUserInfo(cookie) |
| if (!infoResult.ok) { |
| logger.debug(`${platform} 用户信息获取失败,正在重试一次`, { platform, reason: infoResult.reason }) |
| await new Promise((r) => setTimeout(r, 1500)) |
| infoResult = await provider.getUserInfo(cookie) |
| } |
|
|
| if (infoResult.ok) { |
| const userInfo = infoResult.data |
| if (mapping && mapping.roomId) { |
| authService.addCookie( |
| mapping.roomId, |
| platform, |
| mapping.userId, |
| cookie, |
| userInfo.nickname, |
| userInfo.vipType, |
| data.persist !== false, |
| { vipLabel: userInfo.vipLabel, vipLevel: userInfo.vipLevel }, |
| ) |
| } |
| socket.emit(EVENTS.AUTH_SET_COOKIE_RESULT, { |
| success: true, |
| message: `已登录为 ${userInfo.nickname}`, |
| platform, |
| cookie, |
| }) |
| } else if ((platform === 'netease' || platform === 'bilibili') && infoResult.reason === 'expired') { |
| |
| socket.emit(EVENTS.AUTH_SET_COOKIE_RESULT, { |
| success: false, |
| message: 'Cookie 已过期,请重新登录', |
| platform, |
| reason: infoResult.reason, |
| }) |
| if (mapping) broadcastAuthStatus(io, socket, mapping) |
| return |
| } else { |
| |
| if (mapping && mapping.roomId) { |
| authService.addCookie(mapping.roomId, platform, mapping.userId, cookie, '手动登录', 0, data.persist !== false) |
| } |
| socket.emit(EVENTS.AUTH_SET_COOKIE_RESULT, { |
| success: true, |
| message: 'Cookie 已保存(验证失败,播放时生效)', |
| platform, |
| cookie, |
| }) |
| } |
|
|
| if (mapping) { |
| broadcastAuthStatus(io, socket, mapping) |
| } |
| } catch (err) { |
| logger.error('AUTH_SET_COOKIE error', err, { socketId: socket.id }) |
| socket.emit(EVENTS.AUTH_SET_COOKIE_RESULT, { |
| success: false, |
| message: '设置 Cookie 失败,请重试', |
| reason: 'error', |
| }) |
| } |
| }) |
|
|
| |
| |
| |
|
|
| socket.on(EVENTS.AUTH_CLAIM_KUGOU_CONCEPT_VIP, async () => { |
| try { |
| const mapping = getSocketMapping(socket.id) |
| if (!mapping) { |
| socket.emit(EVENTS.AUTH_CLAIM_KUGOU_CONCEPT_VIP_RESULT, { success: false, message: '请先进入房间后再领取' }) |
| return |
| } |
|
|
| const cookie = authService.getUserCookie(mapping.userId, 'kugou_concept', mapping.roomId) |
| if (!cookie) { |
| socket.emit(EVENTS.AUTH_CLAIM_KUGOU_CONCEPT_VIP_RESULT, { |
| success: false, |
| message: '请先登录酷狗概念版账号', |
| }) |
| return |
| } |
|
|
| let result = await kugouAuth.claimConceptDailyVip(cookie) |
| const claimAccepted = result.ok |
| let info: Awaited<ReturnType<typeof kugouAuth.conceptAuthProvider.getUserInfo>> | null = null |
| if (claimAccepted) { |
| |
| |
| for (const delayMs of [500, 1_000, 2_000]) { |
| await new Promise((resolve) => setTimeout(resolve, delayMs)) |
| info = await kugouAuth.conceptAuthProvider.getUserInfo(cookie) |
| if (info.ok && info.data.vipType > 0) break |
| } |
| } else { |
| |
| |
| info = await kugouAuth.conceptAuthProvider.getUserInfo(cookie) |
| } |
|
|
| if (info?.ok && info.data.vipType > 0) { |
| if (!claimAccepted) result = { ok: true, message: '今日概念版畅听 VIP 权益已生效' } |
| const currentMapping = getSocketMapping(socket.id) |
| const currentCookie = currentMapping |
| ? authService.getUserCookie(currentMapping.userId, 'kugou_concept', currentMapping.roomId) |
| : null |
| const canApplyRefresh = |
| currentMapping?.roomId === mapping.roomId && |
| currentMapping.userId === mapping.userId && |
| currentCookie === cookie |
|
|
| if (canApplyRefresh) { |
| authService.addCookie( |
| currentMapping.roomId, |
| 'kugou_concept', |
| currentMapping.userId, |
| cookie, |
| info.data.nickname, |
| info.data.vipType, |
| true, |
| { vipLabel: info.data.vipLabel, vipLevel: info.data.vipLevel }, |
| ) |
| broadcastAuthStatus(io, socket, currentMapping) |
| } else { |
| logger.info('领取酷狗概念版权益后用户状态已变化,已跳过房间账号刷新', { |
| event: 'auth.kugou_concept_claim_refresh_skipped', |
| roomId: mapping.roomId, |
| userId: mapping.userId, |
| currentRoomId: currentMapping?.roomId, |
| }) |
| } |
| } else if (claimAccepted) { |
| result = { |
| ok: false, |
| message: '领取接口已返回成功,但酷狗尚未确认畅听 VIP 生效,请稍后重新领取或重新登录概念版', |
| } |
| logger.warn('领取酷狗概念版权益后会员状态未生效', { |
| event: 'auth.kugou_concept_claim_not_effective', |
| roomId: mapping.roomId, |
| userId: mapping.userId, |
| refreshed: Boolean(info?.ok), |
| vipType: info?.ok ? info.data.vipType : null, |
| }) |
| } |
|
|
| socket.emit(EVENTS.AUTH_CLAIM_KUGOU_CONCEPT_VIP_RESULT, { success: result.ok, message: result.message }) |
| } catch (err) { |
| logger.error('AUTH_CLAIM_KUGOU_CONCEPT_VIP error', err, { socketId: socket.id }) |
| socket.emit(EVENTS.AUTH_CLAIM_KUGOU_CONCEPT_VIP_RESULT, { success: false, message: '领取失败,请稍后重试' }) |
| } |
| }) |
|
|
| |
| |
| |
|
|
| socket.on(EVENTS.AUTH_LOGOUT, (data) => { |
| try { |
| if (!data?.platform) return |
| const mapping = getSocketMapping(socket.id) |
| if (mapping) { |
| authService.removeCookie(mapping.roomId, data.platform, mapping.userId) |
| broadcastAuthStatus(io, socket, mapping) |
| } |
| } catch (err) { |
| logger.error('AUTH_LOGOUT handler error', err, { socketId: socket.id }) |
| } |
| }) |
|
|
| |
| |
| |
|
|
| socket.on(EVENTS.AUTH_GET_STATUS, () => { |
| try { |
| const mapping = getSocketMapping(socket.id) |
| if (mapping) { |
| broadcastAuthStatus(io, socket, mapping) |
| } |
| } catch (err) { |
| logger.error('AUTH_GET_STATUS handler error', err, { socketId: socket.id }) |
| } |
| }) |
|
|
| |
| |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| function broadcastAuthStatus(io: TypedServer, socket: TypedSocket, mapping: { roomId: string; userId: string }) { |
| socket.emit(EVENTS.AUTH_MY_STATUS, authService.getUserAuthStatus(mapping.userId, mapping.roomId)) |
| io.to(mapping.roomId).emit(EVENTS.AUTH_STATUS_UPDATE, authService.getAllPlatformStatus(mapping.roomId)) |
| } |
|
|