| import type { MusicSource, PlatformAuthStatus, MyPlatformAuth } from '@music-together/shared' |
| import { platformAuthRepo } from '../repositories/platformAuthRepository.js' |
| import { logger } from '../utils/logger.js' |
| import { AUTH_PROVIDERS, type GetUserInfoResult } from './authProvider.js' |
|
|
| |
| |
| |
| |
| |
| |
|
|
| interface CookieEntry { |
| cookie: string |
| userId: string |
| nickname: string |
| |
| vipType: number |
| vipLabel?: string |
| vipLevel?: number |
| } |
|
|
| export interface MembershipDetails { |
| vipLabel?: string |
| vipLevel?: number |
| } |
|
|
| function normalizeVipType(platform: MusicSource, vipType: number): 0 | 1 | 2 { |
| if (vipType <= 0) return 0 |
| |
| |
| if (platform === 'netease') return vipType === 2 ? 2 : 1 |
| return vipType >= 2 ? 2 : 1 |
| } |
|
|
| function normalizeMembershipDetails(details?: MembershipDetails): MembershipDetails { |
| const vipLabel = |
| details?.vipLabel |
| ?.replace(/[\u0000-\u001f\u007f]/g, '') |
| .trim() |
| .slice(0, 32) || undefined |
| const level = Number(details?.vipLevel) |
| const vipLevel = Number.isInteger(level) && level > 0 && level <= 100 ? level : undefined |
| return { vipLabel, vipLevel } |
| } |
|
|
| function isHigherMembership(candidate: CookieEntry, current: CookieEntry | undefined): boolean { |
| if (!current) return true |
| if (candidate.vipType !== current.vipType) return candidate.vipType > current.vipType |
| return (candidate.vipLevel ?? 0) > (current.vipLevel ?? 0) |
| } |
|
|
| |
| const roomCookiePool = new Map<string, Map<MusicSource, CookieEntry[]>>() |
| const membershipRefreshPool = new Map<string, Promise<boolean>>() |
| const membershipRefreshHistory = new Map<string, { cookie: string; refreshedAt: number }>() |
| const MEMBERSHIP_REFRESH_COOLDOWN_MS = 60_000 |
|
|
| |
| |
| |
|
|
| function getRoomPool(roomId: string): Map<MusicSource, CookieEntry[]> { |
| let pool = roomCookiePool.get(roomId) |
| if (!pool) { |
| pool = new Map() |
| roomCookiePool.set(roomId, pool) |
| } |
| return pool |
| } |
|
|
| function getPlatformEntries(roomId: string, platform: MusicSource): CookieEntry[] { |
| const pool = getRoomPool(roomId) |
| let entries = pool.get(platform) |
| if (!entries) { |
| entries = [] |
| pool.set(platform, entries) |
| } |
| return entries |
| } |
|
|
| |
| |
| |
|
|
| export function addCookie( |
| roomId: string, |
| platform: MusicSource, |
| userId: string, |
| cookie: string, |
| nickname: string, |
| vipType: number, |
| persist = true, |
| membership?: MembershipDetails, |
| ): void { |
| vipType = normalizeVipType(platform, vipType) |
| const { vipLabel, vipLevel } = normalizeMembershipDetails(membership) |
| const entries = getPlatformEntries(roomId, platform) |
| |
| const idx = entries.findIndex((e) => e.cookie === cookie || e.userId === userId) |
| const previousCookie = idx === -1 ? null : entries[idx]?.cookie |
| if (idx !== -1) entries.splice(idx, 1) |
| entries.push({ cookie, userId, nickname, vipType, vipLabel, vipLevel }) |
| if (persist) { |
| platformAuthRepo.save( |
| { userId, platform, cookie, nickname, vipType, vipLabel, vipLevel }, |
| { resetCredentialRefreshSchedule: platform === 'tencent' && previousCookie !== cookie }, |
| ) |
| } |
| logger.info(`用户“${nickname}”已在房间 ${roomId} 登录 ${platform}`, { |
| event: 'auth.account_added', |
| roomId, |
| userId, |
| nickname, |
| platform, |
| vipType, |
| vipLabel, |
| vipLevel, |
| }) |
| } |
|
|
| export function removeCookie(roomId: string, platform: MusicSource, userId: string): boolean { |
| membershipRefreshHistory.delete(`${roomId}:${platform}:${userId}`) |
| const removedPersisted = platformAuthRepo.remove(userId, platform) |
| const pool = roomCookiePool.get(roomId) |
| if (!pool) return removedPersisted |
| const entries = pool.get(platform) |
| if (!entries) return removedPersisted |
| const idx = entries.findIndex((e) => e.userId === userId) |
| if (idx === -1) return removedPersisted |
| const removed = entries.splice(idx, 1)[0] |
| logger.info(`用户“${removed.nickname}”已在房间 ${roomId} 退出 ${platform}`, { |
| event: 'auth.account_removed', |
| roomId, |
| userId, |
| nickname: removed.nickname, |
| platform, |
| }) |
| return true |
| } |
|
|
| |
| export function restoreUserCookies(roomId: string, userId: string): number { |
| const entries = platformAuthRepo.loadUser(userId) |
| for (const entry of entries) { |
| addCookie(roomId, entry.platform, userId, entry.cookie, entry.nickname, entry.vipType, false, { |
| vipLabel: entry.vipLabel, |
| vipLevel: entry.vipLevel, |
| }) |
| } |
| return entries.length |
| } |
|
|
| export type MembershipInfoLoader = (platform: MusicSource, cookie: string) => Promise<GetUserInfoResult> |
|
|
| async function refreshMembershipEntry( |
| roomId: string, |
| platform: MusicSource, |
| userId: string, |
| cookie: string, |
| loadInfo: MembershipInfoLoader, |
| ): Promise<boolean> { |
| const refreshKey = `${roomId}:${platform}:${userId}` |
| const inFlight = membershipRefreshPool.get(refreshKey) |
| if (inFlight) return inFlight |
| const previousRefresh = membershipRefreshHistory.get(refreshKey) |
| if (previousRefresh?.cookie === cookie && Date.now() - previousRefresh.refreshedAt < MEMBERSHIP_REFRESH_COOLDOWN_MS) { |
| return false |
| } |
|
|
| const refresh = (async () => { |
| try { |
| let result = await loadInfo(platform, cookie) |
| if (!result.ok && result.reason === 'error') { |
| result = await loadInfo(platform, cookie) |
| } |
| if (!result.ok) { |
| logger.warn('恢复平台账号时未能刷新会员详情,保留原有账号信息', { |
| event: 'auth.membership_refresh_failed', |
| roomId, |
| userId, |
| platform, |
| reason: result.reason, |
| }) |
| return false |
| } |
|
|
| const current = roomCookiePool |
| .get(roomId) |
| ?.get(platform) |
| ?.find((entry) => entry.userId === userId) |
| |
| |
| if (!current || current.cookie !== cookie) return false |
|
|
| const userInfo = result.data |
| addCookie(roomId, platform, userId, cookie, userInfo.nickname, userInfo.vipType, true, { |
| vipLabel: userInfo.vipLabel, |
| vipLevel: userInfo.vipLevel, |
| }) |
| membershipRefreshHistory.set(refreshKey, { cookie, refreshedAt: Date.now() }) |
| logger.info('已自动刷新恢复账号的会员详情', { |
| event: 'auth.membership_refreshed', |
| roomId, |
| userId, |
| platform, |
| vipType: userInfo.vipType, |
| vipLabel: userInfo.vipLabel, |
| vipLevel: userInfo.vipLevel, |
| }) |
| return true |
| } catch (error) { |
| logger.warn('恢复平台账号时刷新会员详情发生异常,保留原有账号信息', { |
| event: 'auth.membership_refresh_failed', |
| roomId, |
| userId, |
| platform, |
| error: error instanceof Error ? error.message : String(error), |
| }) |
| return false |
| } finally { |
| membershipRefreshPool.delete(refreshKey) |
| } |
| })() |
|
|
| membershipRefreshPool.set(refreshKey, refresh) |
| return refresh |
| } |
|
|
| |
| export async function refreshMissingMembershipDetails( |
| roomId: string, |
| userId: string, |
| loadInfo: MembershipInfoLoader = (platform, cookie) => AUTH_PROVIDERS[platform].getUserInfo(cookie), |
| ): Promise<MusicSource[]> { |
| const candidates = [...(roomCookiePool.get(roomId)?.entries() ?? [])].flatMap(([platform, entries]) => |
| entries.filter((entry) => entry.userId === userId).map((entry) => ({ platform, cookie: entry.cookie })), |
| ) |
|
|
| const results = await Promise.all( |
| candidates.map(async ({ platform, cookie }) => ({ |
| platform, |
| refreshed: await refreshMembershipEntry(roomId, platform, userId, cookie, loadInfo), |
| })), |
| ) |
| return results.filter((result) => result.refreshed).map((result) => result.platform) |
| } |
|
|
| export function persistUserCookieFromRoom(roomId: string, platform: MusicSource, userId: string): boolean { |
| const entry = roomCookiePool |
| .get(roomId) |
| ?.get(platform) |
| ?.find((item) => item.userId === userId) |
| if (!entry) return false |
| platformAuthRepo.save({ |
| userId, |
| platform, |
| cookie: entry.cookie, |
| nickname: entry.nickname, |
| vipType: entry.vipType, |
| vipLabel: entry.vipLabel, |
| vipLevel: entry.vipLevel, |
| }) |
| return true |
| } |
|
|
| |
| export function replaceCredentialCookie( |
| userId: string, |
| platform: MusicSource, |
| expectedCookie: string, |
| refreshedCookie: string, |
| ): boolean { |
| const persisted = platformAuthRepo.loadUser(userId).find((entry) => entry.platform === platform) |
| if (!persisted || persisted.cookie !== expectedCookie) return false |
|
|
| platformAuthRepo.save({ ...persisted, cookie: refreshedCookie }) |
| for (const [roomId, pool] of roomCookiePool) { |
| const entry = pool.get(platform)?.find((item) => item.userId === userId && item.cookie === expectedCookie) |
| if (!entry) continue |
| entry.cookie = refreshedCookie |
| membershipRefreshHistory.delete(`${roomId}:${platform}:${userId}`) |
| } |
| return true |
| } |
|
|
| export function replaceUserId(oldUserId: string, newUserId: string): void { |
| for (const pool of roomCookiePool.values()) { |
| for (const entries of pool.values()) { |
| for (const entry of entries) { |
| if (entry.userId === oldUserId) entry.userId = newUserId |
| } |
| } |
| } |
| } |
|
|
| |
| |
| |
| export function cleanupRoom(roomId: string): void { |
| if (roomCookiePool.delete(roomId)) { |
| logger.debug('已清理销毁房间的账号凭据池', { roomId }) |
| } |
| const prefix = `${roomId}:` |
| for (const key of membershipRefreshHistory.keys()) { |
| if (key.startsWith(prefix)) membershipRefreshHistory.delete(key) |
| } |
| } |
|
|
| |
| |
| |
| |
| export function hasCookie(roomId: string, platform: MusicSource, cookie: string): boolean { |
| const pool = roomCookiePool.get(roomId) |
| if (!pool) return false |
| const entries = pool.get(platform) |
| if (!entries) return false |
| return entries.some((e) => e.cookie === cookie) |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| export function getAnyCookie(platform: MusicSource, roomId: string): string | null { |
| return getBestAuth(platform, roomId)?.cookie ?? null |
| } |
|
|
| export interface BestPlatformAuth { |
| cookie: string |
| vipType: 0 | 1 | 2 |
| } |
|
|
| |
| export function getBestAuth(platform: MusicSource, roomId: string): BestPlatformAuth | null { |
| const pool = roomCookiePool.get(roomId) |
| if (!pool) return null |
| const entries = pool.get(platform) |
| if (!entries || entries.length === 0) return null |
|
|
| |
| let best = entries[0] |
| for (let i = 1; i < entries.length; i++) { |
| if (isHigherMembership(entries[i], best)) best = entries[i] |
| } |
| return { cookie: best.cookie, vipType: normalizeVipType(platform, best.vipType) } |
| } |
|
|
| |
| |
| |
| |
| export function getUserCookie(userId: string, platform: MusicSource, roomId: string): string | null { |
| const pool = roomCookiePool.get(roomId) |
| if (!pool) return null |
| const entries = pool.get(platform) |
| if (!entries) return null |
| const entry = entries.find((e) => e.userId === userId) |
| return entry?.cookie ?? null |
| } |
|
|
| |
| export function needsMembershipRefresh(userId: string, platform: MusicSource, roomId: string): boolean { |
| const entry = roomCookiePool |
| .get(roomId) |
| ?.get(platform) |
| ?.find((item) => item.userId === userId) |
| return Boolean(entry && entry.vipType > 0 && !entry.vipLabel) |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| export function getAllPlatformStatus(roomId: string): PlatformAuthStatus[] { |
| const platforms: MusicSource[] = ['netease', 'tencent', 'kugou', 'kugou_concept', 'bilibili'] |
| const pool = roomCookiePool.get(roomId) |
| return platforms.map((platform) => { |
| const entries = pool?.get(platform) ?? [] |
| const best = entries.reduce<CookieEntry | undefined>( |
| (current, entry) => (isHigherMembership(entry, current) ? entry : current), |
| undefined, |
| ) |
| const maxVipType = best?.vipType ?? 0 |
| return { |
| platform, |
| loggedInCount: entries.length, |
| hasVip: maxVipType > 0, |
| maxVipType, |
| maxVipLabel: best?.vipLabel, |
| maxVipLevel: best?.vipLevel, |
| } |
| }) |
| } |
|
|
| |
| |
| |
| export function getUserAuthStatus(userId: string, roomId: string): MyPlatformAuth[] { |
| const platforms: MusicSource[] = ['netease', 'tencent', 'kugou', 'kugou_concept', 'bilibili'] |
| const pool = roomCookiePool.get(roomId) |
| return platforms.map((platform) => { |
| const entries = pool?.get(platform) ?? [] |
| const entry = entries.find((e) => e.userId === userId) |
| return { |
| platform, |
| loggedIn: !!entry, |
| nickname: entry?.nickname, |
| vipType: entry?.vipType, |
| vipLabel: entry?.vipLabel, |
| vipLevel: entry?.vipLevel, |
| } |
| }) |
| } |
|
|