/** * 开发环境内置的半自动履约 Connector。 * * 只用于本地和测试环境:模拟虎牙登录、角色绑定、一次性浏览器注入票据, * 不接触真实平台 Cookie。生产环境由 `isDevMockEnabled()` 拦截。 */ import crypto from 'node:crypto' import type { ConnectorBindingSession, ConnectorBrowserTicket, ConnectorInjectionPayload, ConnectorLoginSession, } from '../semi-auto/huya-connector-client.js' import type { HuyaStoredCredential } from '../semi-auto/huya-native/huya-native-connector.js' import { createHttpError } from '../../utils/http.js' const LOGIN_QR_TTL_MS = 5 * 60 * 1000 const CREDENTIAL_TTL_MS = 24 * 60 * 60 * 1000 const BINDING_QR_TTL_MS = 5 * 60 * 1000 const BROWSER_TICKET_TTL_MS = 2 * 60 * 1000 type MockLoginStatus = ConnectorLoginSession['status'] type MockBindingStatus = ConnectorBindingSession['status'] type MockTicket = { ticket: string targetUrl: string expiresAtMs: number consumed: boolean } type MockSession = { sessionId: string taskId: number orderNo: string createdAtMs: number loginStatus: MockLoginStatus loginPolls: number loginExpiresAtMs: number credentialExpiresAtMs: number qrContent: string smsPhone: string smsCode: string huyaUid: string nickname: string hasCredential: boolean bindingStatus: MockBindingStatus bindingPolls: number bindingQrExpiresAtMs: number bindGameAccount: string bindGameRole: string bindGameRoleId: string bindGameChannel: string message: string tickets: Map } const sessions = new Map() function buildMockUid(sessionId: string) { return `mock-huya-${sessionId.slice(-8)}` } function buildMockBindingQr(sessionId: string) { const svg = ` MOCK BIND ${escapeXml(sessionId.slice(-12))} 虎牙角色绑定测试二维码 `.trim() return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}` } function escapeXml(value: string) { return value.replace(/[<>&'"]/g, (char) => { const entities: Record = { '<': '<', '>': '>', '&': '&', "'": ''', '"': '"', } return entities[char] || char }) } function requireSession(sessionIdInput: unknown): MockSession { const sessionId = String(sessionIdInput || '').trim() const session = sessions.get(sessionId) if (!session) { throw createHttpError('Mock Connector 会话不存在或已失效', { statusCode: 404, errorCode: 'mock_semi_auto_session_not_found', }) } expireSessionIfNeeded(session) return session } function expireSessionIfNeeded(session: MockSession) { const now = Date.now() if (session.loginStatus === 'authenticated') { if (session.credentialExpiresAtMs <= now) { session.loginStatus = 'expired' session.hasCredential = false session.message = 'Mock 登录凭证已过期' } return } if (session.loginExpiresAtMs <= now && session.loginStatus !== 'expired') { session.loginStatus = 'expired' session.hasCredential = false session.message = 'Mock 登录二维码已过期' } } function loginView(session: MockSession): ConnectorLoginSession { return { sessionId: session.sessionId, status: session.loginStatus, qrContent: session.qrContent, expiresAt: new Date(session.loginExpiresAtMs).toISOString(), huyaUid: session.huyaUid, nickname: session.nickname, hasCredential: session.hasCredential, message: session.message, } } function bindingView(session: MockSession): ConnectorBindingSession { return { sessionId: session.sessionId, status: session.bindingStatus, qrImage: session.bindingStatus === 'not_started' ? '' : buildMockBindingQr(session.sessionId), qrExpiresAt: new Date(session.bindingQrExpiresAtMs).toISOString(), qrUrl: '', bindGameAccount: session.bindGameAccount, bindGameRole: session.bindGameRole, bindGameRoleId: session.bindGameRoleId, bindGameChannel: session.bindGameChannel, message: session.message, } } function markAuthenticated(session: MockSession) { session.loginStatus = 'authenticated' session.hasCredential = true session.smsPhone = '' session.smsCode = '' session.huyaUid = buildMockUid(session.sessionId) session.nickname = 'Mock虎牙用户' session.credentialExpiresAtMs = Date.now() + CREDENTIAL_TTL_MS session.message = 'Mock 登录成功' } function markRoleReady(session: MockSession) { session.bindingStatus = 'role_ready' session.bindGameAccount = `mock-account-${session.sessionId.slice(-6)}` session.bindGameRole = 'Mock测试角色' session.bindGameRoleId = `mock-role-${session.sessionId.slice(-6)}` session.bindGameChannel = '虎牙' session.message = 'Mock 已识别游戏角色,请确认绑定' } export function createMockLoginSession(input: { taskId?: unknown orderNo?: unknown }): ConnectorLoginSession { const sessionId = crypto.randomUUID() const now = Date.now() const session: MockSession = { sessionId, taskId: Number(input.taskId || 0), orderNo: String(input.orderNo || '').trim(), createdAtMs: now, loginStatus: 'waiting_scan', loginPolls: 0, loginExpiresAtMs: now + LOGIN_QR_TTL_MS, credentialExpiresAtMs: 0, qrContent: `mock-huya-login://${sessionId}`, smsPhone: '', smsCode: '', huyaUid: '', nickname: '', hasCredential: false, bindingStatus: 'not_started', bindingPolls: 0, bindingQrExpiresAtMs: 0, bindGameAccount: '', bindGameRole: '', bindGameRoleId: '', bindGameChannel: '', message: 'Mock 登录二维码已生成,轮询后会自动模拟扫码成功', tickets: new Map(), } sessions.set(sessionId, session) return loginView(session) } export function sendMockHuyaSmsCode(input: { taskId?: unknown orderNo?: unknown mobile?: unknown }): ConnectorLoginSession { const mobile = String(input.mobile || '').replace(/\D/g, '') if (!/^1\d{10}$/.test(mobile)) { throw createHttpError('请输入正确的手机号', { statusCode: 400, errorCode: 'huya_sms_mobile_invalid', }) } const sessionId = crypto.randomUUID() const now = Date.now() const session: MockSession = { sessionId, taskId: Number(input.taskId || 0), orderNo: String(input.orderNo || '').trim(), createdAtMs: now, loginStatus: 'waiting_code', loginPolls: 0, loginExpiresAtMs: now + LOGIN_QR_TTL_MS, credentialExpiresAtMs: 0, qrContent: '', smsPhone: mobile, smsCode: '123456', huyaUid: '', nickname: '', hasCredential: false, bindingStatus: 'not_started', bindingPolls: 0, bindingQrExpiresAtMs: 0, bindGameAccount: '', bindGameRole: '', bindGameRoleId: '', bindGameChannel: '', message: 'Mock 短信验证码已发送,请输入 123456', tickets: new Map(), } sessions.set(sessionId, session) return loginView(session) } export function loginMockHuyaSms(input: { sessionId?: unknown mobile?: unknown code?: unknown }): ConnectorLoginSession { const session = requireSession(input.sessionId) if (session.loginStatus === 'authenticated') return loginView(session) if (session.loginStatus !== 'waiting_code') { throw createHttpError('Mock 短信登录会话状态无效', { statusCode: 409, errorCode: 'mock_huya_sms_session_invalid', }) } const mobile = String(input.mobile || '').replace(/\D/g, '') if (mobile !== session.smsPhone) { throw createHttpError('手机号与发送验证码时不一致', { statusCode: 409, errorCode: 'huya_sms_phone_mismatch', }) } if (String(input.code || '').trim() !== session.smsCode) { throw createHttpError('验证码错误或已过期', { statusCode: 409, errorCode: 'huya_sms_login_failed', }) } markAuthenticated(session) return loginView(session) } export function getMockLoginSession(sessionIdInput: unknown): ConnectorLoginSession { const session = requireSession(sessionIdInput) if (session.loginStatus === 'waiting_scan' || session.loginStatus === 'scanned') { session.loginPolls += 1 if (session.loginPolls >= 2) { markAuthenticated(session) } else if (session.loginPolls >= 1) { session.loginStatus = 'scanned' session.message = 'Mock 已扫码,等待确认登录' } } return loginView(session) } export function exportMockSessionCredential(sessionIdInput: unknown): HuyaStoredCredential { const session = requireSession(sessionIdInput) if (session.loginStatus !== 'authenticated' || !session.hasCredential) { throw createHttpError('Mock 登录尚未完成,不能导出 CK', { statusCode: 409, errorCode: 'mock_semi_auto_credential_not_ready', }) } return { platformUid: session.huyaUid, nickname: session.nickname, cookies: [ { name: 'mock_huya_uid', value: session.huyaUid, domain: '.huya.com', path: '/', secure: true, httpOnly: false, hostOnly: false, expiresAt: null, }, { name: 'mock_huya_session', value: session.sessionId, domain: '.huya.com', path: '/', secure: true, httpOnly: true, hostOnly: false, expiresAt: null, }, ], } } export function startMockBinding(sessionIdInput: unknown): ConnectorBindingSession { const session = requireSession(sessionIdInput) if (session.loginStatus !== 'authenticated') { throw createHttpError('Mock 登录尚未完成,不能开始绑定', { statusCode: 409, errorCode: 'mock_semi_auto_login_required', }) } const now = Date.now() session.bindingStatus = 'qr_pending' session.bindingPolls = 0 session.bindingQrExpiresAtMs = now + BINDING_QR_TTL_MS session.bindGameAccount = '' session.bindGameRole = '' session.bindGameChannel = '' session.message = 'Mock 绑定二维码已生成,轮询后会自动识别角色' return bindingView(session) } export function getMockBinding(sessionIdInput: unknown): ConnectorBindingSession { const session = requireSession(sessionIdInput) if (session.bindingStatus === 'qr_pending' || session.bindingStatus === 'scanned') { if (session.bindingQrExpiresAtMs <= Date.now()) { session.bindingStatus = 'expired' session.message = 'Mock 绑定二维码已过期' return bindingView(session) } session.bindingPolls += 1 if (session.bindingPolls >= 2) { markRoleReady(session) } else if (session.bindingPolls >= 1) { session.bindingStatus = 'scanned' session.message = 'Mock 已扫码,正在识别角色' } } return bindingView(session) } export function confirmMockBinding(sessionIdInput: unknown): ConnectorBindingSession { const session = requireSession(sessionIdInput) if (session.bindingStatus !== 'role_ready') { throw createHttpError('Mock 角色尚未就绪,不能确认绑定', { statusCode: 409, errorCode: 'mock_semi_auto_binding_not_ready', }) } session.bindingStatus = 'confirmed' session.message = 'Mock 角色绑定已确认' return bindingView(session) } export function createMockBrowserTicket(input: { sessionId?: unknown targetUrl?: unknown }): ConnectorBrowserTicket { const session = requireSession(input.sessionId) if (session.loginStatus !== 'authenticated' || !session.hasCredential) { throw createHttpError('Mock 登录尚未完成,不能生成浏览器票据', { statusCode: 409, errorCode: 'mock_semi_auto_login_required', }) } const ticket = `mock-ticket-${crypto.randomUUID()}` const targetUrl = String(input.targetUrl || 'https://www.huya.com/').trim() const expiresAtMs = Date.now() + BROWSER_TICKET_TTL_MS session.tickets.set(ticket, { ticket, targetUrl, expiresAtMs, consumed: false }) return { ticket, expiresAt: new Date(expiresAtMs).toISOString(), targetUrl, } } export function consumeMockBrowserTicket(ticketInput: unknown): ConnectorInjectionPayload { const ticket = String(ticketInput || '').trim() for (const session of sessions.values()) { const item = session.tickets.get(ticket) if (!item) continue if (item.consumed) { throw createHttpError('Mock 注入票据已被消费', { statusCode: 410, errorCode: 'mock_semi_auto_ticket_consumed', }) } if (item.expiresAtMs <= Date.now()) { throw createHttpError('Mock 注入票据已过期', { statusCode: 410, errorCode: 'mock_semi_auto_ticket_expired', }) } item.consumed = true return { platform: 'huya', targetUrl: item.targetUrl, expiresAt: new Date(item.expiresAtMs).toISOString(), cookies: [ { name: 'mock_huya_uid', value: session.huyaUid || buildMockUid(session.sessionId), domain: '.huya.com', path: '/', secure: true, }, { name: 'mock_huya_session', value: session.sessionId, domain: '.huya.com', path: '/', secure: true, }, ], } } throw createHttpError('Mock 注入票据不存在', { statusCode: 404, errorCode: 'mock_semi_auto_ticket_not_found', }) } export function revokeMockSession(sessionIdInput: unknown) { const sessionId = String(sessionIdInput || '').trim() sessions.delete(sessionId) return { ok: true } } export function setMockLoginStatus( sessionIdInput: unknown, statusInput: unknown, ): ConnectorLoginSession { const session = requireSession(sessionIdInput) const status = String(statusInput || '').trim() as MockLoginStatus if ( !['waiting_code', 'waiting_scan', 'scanned', 'authenticated', 'expired', 'failed'].includes( status, ) ) { throw createHttpError('Mock 登录状态无效', { statusCode: 400, errorCode: 'mock_semi_auto_login_status_invalid', }) } session.loginStatus = status if (status === 'authenticated') { markAuthenticated(session) } else { session.hasCredential = false session.message = `Mock 登录状态已切换为 ${status}` } return loginView(session) } export function setMockBindingStatus( sessionIdInput: unknown, statusInput: unknown, ): ConnectorBindingSession { const session = requireSession(sessionIdInput) const status = String(statusInput || '').trim() as MockBindingStatus if ( ![ 'not_started', 'qr_pending', 'scanned', 'role_ready', 'confirmed', 'expired', 'failed', ].includes(status) ) { throw createHttpError('Mock 绑定状态无效', { statusCode: 400, errorCode: 'mock_semi_auto_binding_status_invalid', }) } session.bindingStatus = status if (status === 'role_ready' || status === 'confirmed') { if (!session.bindGameRole) markRoleReady(session) session.bindingStatus = status } else { session.bindGameAccount = '' session.bindGameRole = '' session.bindGameChannel = '' } session.message = `Mock 绑定状态已切换为 ${status}` return bindingView(session) } export function listMockSessions() { return [...sessions.values()].map((session) => ({ sessionId: session.sessionId, taskId: session.taskId, orderNo: session.orderNo, loginStatus: session.loginStatus, bindingStatus: session.bindingStatus, hasCredential: session.hasCredential, ticketCount: session.tickets.size, createdAt: new Date(session.createdAtMs).toISOString(), })) } export function resetMockSessions() { const count = sessions.size sessions.clear() return { cleared: count } }