diff --git a/apps/backend/src/routes/customer-fulfillment.ts b/apps/backend/src/routes/customer-fulfillment.ts index 2f91cd19..793b121c 100644 --- a/apps/backend/src/routes/customer-fulfillment.ts +++ b/apps/backend/src/routes/customer-fulfillment.ts @@ -3,13 +3,16 @@ import { Router } from 'express' import { getPublicWeiboCountryCodes, listPublicSemiAutoTasks, + loginPublicHuyaSms, loginPublicWeiboSms, markCustomerLinkOpened, pollPublicLogin, pollPublicWeiboQrLogin, + sendPublicHuyaSmsCode, sendPublicWeiboSmsCode, startPublicLogin, startPublicWeiboQrLogin, + verifyPublicHuyaSmsChallenge, } from '../services/semi-auto/semi-auto-task-service.js' import { createRateLimitMiddleware, getParamRateLimitKey } from '../middleware/rate-limit.js' import { createRouteHandler } from '../utils/http.js' @@ -76,6 +79,45 @@ router.get( }), ) +router.post( + '/:orderNo/tasks/:taskId/huya/sms/send', + writeLimit, + createRouteHandler( + (req) => sendPublicHuyaSmsCode(req.params.orderNo, req.params.taskId, req.body?.mobile), + { + successMessage: '验证码请求已处理', + errorMessage: '发送虎牙短信验证码失败', + scope: '[customer-fulfillment/:orderNo/tasks/:taskId/huya/sms/send]', + }, + ), +) + +router.post( + '/:orderNo/tasks/:taskId/huya/sms/login', + writeLimit, + createRouteHandler( + (req) => loginPublicHuyaSms(req.params.orderNo, req.params.taskId, req.body || {}), + { + successMessage: '虎牙登录成功', + errorMessage: '虎牙短信登录失败', + scope: '[customer-fulfillment/:orderNo/tasks/:taskId/huya/sms/login]', + }, + ), +) + +router.post( + '/:orderNo/tasks/:taskId/huya/sms/verify', + writeLimit, + createRouteHandler( + (req) => verifyPublicHuyaSmsChallenge(req.params.orderNo, req.params.taskId, req.body || {}), + { + successMessage: '安全验证已处理', + errorMessage: '虎牙安全验证失败', + scope: '[customer-fulfillment/:orderNo/tasks/:taskId/huya/sms/verify]', + }, + ), +) + router.post( '/:orderNo/tasks/:taskId/weibo/sms/send', writeLimit, diff --git a/apps/backend/src/routes/dev-mock-connector.ts b/apps/backend/src/routes/dev-mock-connector.ts index ac520578..50a12405 100644 --- a/apps/backend/src/routes/dev-mock-connector.ts +++ b/apps/backend/src/routes/dev-mock-connector.ts @@ -10,9 +10,11 @@ import { createMockLoginSession, getMockBinding, getMockLoginSession, + loginMockHuyaSms, listMockSessions, resetMockSessions, revokeMockSession, + sendMockHuyaSmsCode, setMockBindingStatus, setMockLoginStatus, startMockBinding, @@ -75,6 +77,30 @@ router.get( handle((req) => getMockLoginSession(req.params.sessionId)), ) +router.post( + '/api/internal/huya-semi-auto/sms/send', + handle((req) => { + const body = bodyOf(req) + return sendMockHuyaSmsCode({ + taskId: body.task_id ?? body.taskId, + orderNo: body.order_no ?? body.orderNo, + mobile: body.mobile, + }) + }), +) + +router.post( + '/api/internal/huya-semi-auto/sms/login', + handle((req) => { + const body = bodyOf(req) + return loginMockHuyaSms({ + sessionId: body.session_id ?? body.sessionId, + mobile: body.mobile, + code: body.code, + }) + }), +) + router.delete( '/api/internal/huya-semi-auto/sessions/:sessionId', handle((req) => revokeMockSession(req.params.sessionId)), diff --git a/apps/backend/src/services/dev-mock/dev-mock-service.ts b/apps/backend/src/services/dev-mock/dev-mock-service.ts index cf70d8ca..506fd818 100644 --- a/apps/backend/src/services/dev-mock/dev-mock-service.ts +++ b/apps/backend/src/services/dev-mock/dev-mock-service.ts @@ -368,9 +368,9 @@ export async function createSemiAutoMock( platform === 'weibo' ? '真实登录:使用微博绑定手机号接收验证码登录,或使用微博 App 扫码登录。登录信息仅用于本次订单履约。' : connectorMode === 'mock' - ? 'Mock 登录:点击获取二维码后,页面轮询两次会自动模拟登录成功。' - : '真实登录:点击获取二维码后,请使用虎牙 App 扫码并在手机上确认登录。', - ...(platform === 'weibo' ? { loginMethods: ['sms', 'qr'] } : {}), + ? 'Mock 登录:使用手机号发送验证码后输入 123456,或点击获取二维码模拟登录。' + : '真实登录:使用虎牙绑定手机号接收验证码登录,或使用虎牙 App 扫码登录。', + loginMethods: ['sms', 'qr'], browserTargets: [ { key: platform, diff --git a/apps/backend/src/services/dev-mock/mock-semi-auto-connector.test.ts b/apps/backend/src/services/dev-mock/mock-semi-auto-connector.test.ts index cd4f58a0..2950be6b 100644 --- a/apps/backend/src/services/dev-mock/mock-semi-auto-connector.test.ts +++ b/apps/backend/src/services/dev-mock/mock-semi-auto-connector.test.ts @@ -9,8 +9,10 @@ import { exportMockSessionCredential, getMockBinding, getMockLoginSession, + loginMockHuyaSms, resetMockSessions, revokeMockSession, + sendMockHuyaSmsCode, setMockBindingStatus, setMockLoginStatus, startMockBinding, @@ -87,3 +89,27 @@ test('Mock Connector 支持控制状态、撤销会话和重置', () => { assert.equal(reset.cleared, 1) assert.throws(() => getMockLoginSession(second.sessionId), /不存在或已失效/) }) + +test('Mock Connector 支持手动虎牙短信发码与验证码登录', () => { + const sent = sendMockHuyaSmsCode({ + taskId: 303, + orderNo: 'MOCK-SA-SMS', + mobile: '13800138000', + }) + assert.equal(sent.status, 'waiting_code') + assert.equal(sent.qrContent, '') + assert.equal(sent.hasCredential, false) + assert.throws( + () => loginMockHuyaSms({ sessionId: sent.sessionId, mobile: '13800138000', code: '000000' }), + /验证码错误或已过期/, + ) + + const authenticated = loginMockHuyaSms({ + sessionId: sent.sessionId, + mobile: '13800138000', + code: '123456', + }) + assert.equal(authenticated.status, 'authenticated') + assert.equal(authenticated.hasCredential, true) + assert.ok(authenticated.huyaUid) +}) diff --git a/apps/backend/src/services/dev-mock/mock-semi-auto-connector.ts b/apps/backend/src/services/dev-mock/mock-semi-auto-connector.ts index 7a2fb544..1349c6d5 100644 --- a/apps/backend/src/services/dev-mock/mock-semi-auto-connector.ts +++ b/apps/backend/src/services/dev-mock/mock-semi-auto-connector.ts @@ -40,6 +40,8 @@ type MockSession = { loginExpiresAtMs: number credentialExpiresAtMs: number qrContent: string + smsPhone: string + smsCode: string huyaUid: string nickname: string hasCredential: boolean @@ -144,6 +146,8 @@ function bindingView(session: MockSession): ConnectorBindingSession { 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 @@ -174,6 +178,8 @@ export function createMockLoginSession(input: { loginExpiresAtMs: now + LOGIN_QR_TTL_MS, credentialExpiresAtMs: 0, qrContent: `mock-huya-login://${sessionId}`, + smsPhone: '', + smsCode: '', huyaUid: '', nickname: '', hasCredential: false, @@ -190,6 +196,78 @@ export function createMockLoginSession(input: { 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: '', + 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') { @@ -371,7 +449,11 @@ export function setMockLoginStatus( ): ConnectorLoginSession { const session = requireSession(sessionIdInput) const status = String(statusInput || '').trim() as MockLoginStatus - if (!['waiting_scan', 'scanned', 'authenticated', 'expired', 'failed'].includes(status)) { + if ( + !['waiting_code', 'waiting_scan', 'scanned', 'authenticated', 'expired', 'failed'].includes( + status, + ) + ) { throw createHttpError('Mock 登录状态无效', { statusCode: 400, errorCode: 'mock_semi_auto_login_status_invalid', diff --git a/apps/backend/src/services/semi-auto/huya-connector-client.test.ts b/apps/backend/src/services/semi-auto/huya-connector-client.test.ts index c8afbdaf..a5205482 100644 --- a/apps/backend/src/services/semi-auto/huya-connector-client.test.ts +++ b/apps/backend/src/services/semi-auto/huya-connector-client.test.ts @@ -45,6 +45,99 @@ test('创建登录会话按真实 Connector 协议发送蛇形字段', async () } }) +test('虎牙短信发码和登录向外部 Connector 传递完整任务上下文', async () => { + const originalFetch = globalThis.fetch + const requests: Array<{ url: string; body: Record }> = [] + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + requests.push({ + url: String(url), + body: JSON.parse(String(init?.body || '{}')) as Record, + }) + return new Response( + JSON.stringify({ + sessionId: 'sms-session-1', + status: 'waiting_code', + qrContent: '', + expiresAt: '2026-11-14T00:00:00Z', + huyaUid: '', + nickname: '', + hasCredential: false, + message: '验证码已发送', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ) + }) as typeof fetch + try { + await connector.sendHuyaSmsCode( + { taskId: 123, orderNo: 'ORDER-1', mobile: '13800138000' }, + 'external', + ) + await connector.loginHuyaSms( + { + sessionId: 'sms-session-1', + taskId: 123, + orderNo: 'ORDER-1', + mobile: '13800138000', + code: '123456', + }, + 'external', + ) + assert.equal(requests[0]?.url, 'http://connector.test/api/internal/huya-semi-auto/sms/send') + assert.deepEqual(requests[0]?.body, { + task_id: 123, + order_no: 'ORDER-1', + mobile: '13800138000', + }) + assert.equal(requests[1]?.url, 'http://connector.test/api/internal/huya-semi-auto/sms/login') + assert.deepEqual(requests[1]?.body, { + session_id: 'sms-session-1', + task_id: 123, + order_no: 'ORDER-1', + mobile: '13800138000', + code: '123456', + }) + } finally { + globalThis.fetch = originalFetch + } +}) + +test('外部虎牙 Connector 接收客户完成的滑块距离', async () => { + const originalFetch = globalThis.fetch + let requestUrl = '' + let requestBody: Record = {} + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + requestUrl = String(url) + requestBody = JSON.parse(String(init?.body || '{}')) as Record + return new Response( + JSON.stringify({ + session: { + sessionId: 'sms-session-1', + status: 'waiting_code', + qrContent: '', + expiresAt: '2026-11-14T00:00:00Z', + huyaUid: '', + nickname: '', + hasCredential: false, + message: '验证码已发送', + }, + challenge: null, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ) + }) as typeof fetch + try { + const result = await connector.verifyHuyaSmsChallenge( + { sessionId: 'sms-session-1', distance: 126 }, + 'external', + ) + assert.equal(result.session.status, 'waiting_code') + assert.equal(requestUrl, 'http://connector.test/api/internal/huya-semi-auto/sms/verify') + assert.deepEqual(requestBody, { session_id: 'sms-session-1', distance: 126 }) + } finally { + globalThis.fetch = originalFetch + } +}) + test('外部虎牙 Connector 支持导出账号级 CK', async () => { const originalFetch = globalThis.fetch let requestUrl = '' diff --git a/apps/backend/src/services/semi-auto/huya-connector-client.ts b/apps/backend/src/services/semi-auto/huya-connector-client.ts index 70c99a6b..ea1751b5 100644 --- a/apps/backend/src/services/semi-auto/huya-connector-client.ts +++ b/apps/backend/src/services/semi-auto/huya-connector-client.ts @@ -6,7 +6,11 @@ import { devMockConnectorBaseUrl, } from '../dev-mock/connector-endpoint.js' import { isDevMockEnabled } from '../dev-mock/dev-mock-enabled.js' -import { exportMockSessionCredential } from '../dev-mock/mock-semi-auto-connector.js' +import { + exportMockSessionCredential, + loginMockHuyaSms, + sendMockHuyaSmsCode, +} from '../dev-mock/mock-semi-auto-connector.js' import { HUYA_NATIVE_TICKET_PREFIX, huyaNativeConnector, @@ -15,7 +19,14 @@ import { export type ConnectorLoginSession = { sessionId: string - status: 'waiting_scan' | 'scanned' | 'authenticated' | 'expired' | 'failed' + status: + | 'verification_pending' + | 'waiting_code' + | 'waiting_scan' + | 'scanned' + | 'authenticated' + | 'expired' + | 'failed' qrContent: string qrImage?: string expiresAt: string @@ -25,6 +36,53 @@ export type ConnectorLoginSession = { message: string } +type HuyaSmsChallengeBase = { + image: string + width: number + height: number +} + +export type HuyaSmsChallenge = + | (HuyaSmsChallengeBase & { + type: 'slider' + puzzleImage: string + puzzleWidth: number + puzzleY: number + }) + | (HuyaSmsChallengeBase & { + type: 'rotate' + puzzleImage: string + puzzleWidth: number + puzzleHeight: number + puzzleX: number + puzzleY: number + }) + | (HuyaSmsChallengeBase & { + type: 'click' + tipImage: string + question: string + wordSize: number + }) + +export type HuyaChallengeSolution = { + distance?: number + angle?: number + points?: Array<{ x: number; y: number }> +} + +export type HuyaSmsChallengeInput = HuyaChallengeSolution & { + sessionId: string +} + +export type HuyaSmsSendResult = { + session: ConnectorLoginSession + challenge: HuyaSmsChallenge | null +} + +/* + * Connector 的登录会话由运行期实现返回,具体挑战结构见上方联合类型。 + */ + export type ConnectorBindingSession = { sessionId: string status: @@ -220,6 +278,108 @@ export async function getHuyaLoginSession( ) } +export async function sendHuyaSmsCode( + input: { + taskId: number + orderNo: string + mobile: string + }, + mode: SemiAutoConnectorMode = defaultSemiAutoConnectorMode(), +): Promise { + if (mode === 'embedded') return huyaNativeConnector.sendSmsCode(input) + if (mode === 'mock') { + return { + session: sendMockHuyaSmsCode({ + taskId: input.taskId, + orderNo: input.orderNo, + mobile: input.mobile, + }), + challenge: null, + } + } + const payload = await callConnector( + '/api/internal/huya-semi-auto/sms/send', + { + method: 'POST', + body: { + task_id: input.taskId, + order_no: input.orderNo, + mobile: input.mobile, + }, + }, + mode, + ) + return 'session' in payload ? payload : { session: payload, challenge: null } +} + +export async function verifyHuyaSmsChallenge( + input: HuyaSmsChallengeInput, + mode: SemiAutoConnectorMode = defaultSemiAutoConnectorMode(), +): Promise { + if (mode === 'embedded') { + return huyaNativeConnector.verifySmsChallenge(input.sessionId, input) + } + if (mode === 'mock') { + throw createHttpError('Mock Connector 不使用虎牙安全验证', { + statusCode: 409, + errorCode: 'mock_huya_sms_challenge_unsupported', + }) + } + return callConnector( + '/api/internal/huya-semi-auto/sms/verify', + { + method: 'POST', + body: { + session_id: input.sessionId, + distance: input.distance, + angle: input.angle, + points: input.points, + }, + }, + mode, + ) +} + +export async function loginHuyaSms( + input: { + sessionId: string + taskId: number + orderNo: string + mobile: string + code: string + }, + mode: SemiAutoConnectorMode = defaultSemiAutoConnectorMode(), +): Promise { + if (mode === 'embedded') { + return huyaNativeConnector.loginWithSms({ + sessionId: input.sessionId, + mobile: input.mobile, + code: input.code, + }) + } + if (mode === 'mock') { + return loginMockHuyaSms({ + sessionId: input.sessionId, + mobile: input.mobile, + code: input.code, + }) + } + return callConnector( + '/api/internal/huya-semi-auto/sms/login', + { + method: 'POST', + body: { + session_id: input.sessionId, + task_id: input.taskId, + order_no: input.orderNo, + mobile: input.mobile, + code: input.code, + }, + }, + mode, + ) +} + export async function startHuyaBinding( sessionId: string, mode: SemiAutoConnectorMode = defaultSemiAutoConnectorMode(), diff --git a/apps/backend/src/services/semi-auto/huya-native/huya-native-connector.ts b/apps/backend/src/services/semi-auto/huya-native/huya-native-connector.ts index 90cb3cd9..e4085d9c 100644 --- a/apps/backend/src/services/semi-auto/huya-native/huya-native-connector.ts +++ b/apps/backend/src/services/semi-auto/huya-native/huya-native-connector.ts @@ -1,8 +1,8 @@ /** * order_site 内置虎牙半自动履约 Connector。 * - * 登录、角色绑定、CK 兑换和一次性浏览器注入票据全部在本进程完成。原始 CK - * 只保存在内存会话中,不落库、不写日志、不出现在票据字符串里。 + * 扫码/短信登录、角色绑定、CK 兑换和一次性浏览器注入票据全部在本进程完成。 + * 原始 CK 只保存在内存会话中,不写日志、不出现在票据字符串里。 */ import crypto from 'node:crypto' @@ -11,14 +11,18 @@ import path from 'node:path' import { runtimeConfig } from '../../../config/runtime.js' import { createHttpError } from '../../../utils/http.js' +import { logWarn } from '../../../utils/logger.js' import type { ConnectorBindingSession, ConnectorBrowserTicket, ConnectorInjectionPayload, ConnectorLoginSession, + HuyaSmsChallenge, + HuyaSmsSendResult, } from '../huya-connector-client.js' import { cookiePairs, + cookieValue, ensureHuyaWebCookieIdentity, fillWebCookieFields, mergeHuyaWebCookie, @@ -41,8 +45,10 @@ import { type HuyaBindResponse, type HuyaGameAccountData, } from './huya-rpc.js' +import { HuyaSmsRole, type HuyaChallengeSolution } from './huya-sms-role.js' const LOGIN_QR_TTL_MS = 5 * 60 * 1000 +const SMS_LOGIN_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 @@ -82,7 +88,7 @@ type NativeSession = { stateDir: string createdAtMs: number expiresAtMs: number - qr: HuyaQrRole + role: HuyaQrRole | HuyaSmsRole behavior: string page: string qrid: string @@ -91,6 +97,9 @@ type NativeSession = { hdid: string deviceInfo: Record status: NativeLoginStatus + smsSessionData: string + smsPhone: string + smsChallenge: HuyaSmsChallenge | null uid: number nickname: string cookie: string @@ -152,6 +161,61 @@ function readString(value: unknown): string { return String(value ?? '').trim() } +function normalizeMainlandMobile(value: unknown): string { + const digits = String(value ?? '').replace(/\D/g, '') + const national = digits.startsWith('086') + ? digits.slice(3) + : digits.startsWith('86') && digits.length === 13 + ? digits.slice(2) + : digits + if (!/^1\d{10}$/.test(national)) { + throw createHttpError('请输入正确的手机号', { + statusCode: 400, + errorCode: 'huya_sms_mobile_invalid', + }) + } + return `086${national}` +} + +function smsBehavior(page = 'https://www.huya.com/g'): { behavior: string; page: string } { + let now = Date.now() - (8000 + Math.floor(Math.random() * 10001)) + let elapsed = 1200 + Math.floor(Math.random() * 1201) + const actions: Array> = [ + { + id: '12', + x: 520 + Math.floor(Math.random() * 101), + y: 45 + Math.floor(Math.random() * 31), + d: elapsed, + time: now, + }, + ] + for (const actionId of ['17', '17']) { + const step = 1800 + Math.floor(Math.random() * 2401) + now += step + elapsed += step + actions.push({ id: actionId, d: elapsed, time: now }) + } + now += 80 + Math.floor(Math.random() * 161) + elapsed += 80 + Math.floor(Math.random() * 161) + actions.push({ + id: '18', + x: 610 + Math.floor(Math.random() * 51), + y: 165 + Math.floor(Math.random() * 31), + d: elapsed, + time: now, + }) + return { + behavior: encodeURIComponent( + JSON.stringify({ + furl: page, + curl: page, + user_action: actions, + }), + ), + page: encodeURIComponent(page), + } +} + function parseRole(response: HuyaBindResponse | null): { account: string role: string @@ -242,7 +306,7 @@ export class HuyaNativeConnector { stateDir, createdAtMs, expiresAtMs: createdAtMs + LOGIN_QR_TTL_MS, - qr, + role: qr, behavior: behavior.behavior, page: behavior.page, qrid, @@ -257,6 +321,9 @@ export class HuyaNativeConnector { udb_appid: '5002', }, status: 'waiting_scan', + smsSessionData: '', + smsPhone: '', + smsChallenge: null, uid: 0, nickname: '', cookie: '', @@ -283,16 +350,20 @@ export class HuyaNativeConnector { if ( session.status === 'authenticated' || session.status === 'failed' || - session.status === 'expired' + session.status === 'expired' || + session.status === 'verification_pending' ) { return this.loginView(session) } if (session.expiresAtMs <= this.now()) { session.status = 'expired' - session.message = '登录二维码已过期,请重新获取' + session.message = session.smsSessionData + ? '短信验证码已过期,请重新获取' + : '登录二维码已过期,请重新获取' return this.loginView(session) } - const response = await session.qr.call('/qrLgn/tryQrLogin', '70003', { + if (session.smsSessionData) return this.loginView(session) + const response = await session.role.call('/qrLgn/tryQrLogin', '70003', { qrId: session.qrid, remember: '1', domainList: '', @@ -314,6 +385,222 @@ export class HuyaNativeConnector { }) } + async sendSmsCode(input: { + taskId: number + orderNo: string + mobile: string + }): Promise { + this.prune() + const sessionId = crypto.randomBytes(24).toString('base64url') + const stateDir = path.join(this.stateRoot, sessionId) + const phone = normalizeMainlandMobile(input.mobile) + try { + const sms = new HuyaSmsRole(this.fetchImpl) + await sms.prepareDevice() + await sms.bootstrapMiddle() + const device = sms.device() + const behavior = smsBehavior() + const response = await sms.call('/web/v2/smsCode', '60027', { + phone, + behavior: behavior.behavior, + page: behavior.page, + }) + logWarn('huya/sms/raw-response', '虎牙短信发码原始响应', response) + const returnCode = extractInt(response.returnCode) + const sessionData = readString(response.data?.sessionData) + const challenge = + returnCode === 10030 || returnCode === 10039 ? await sms.prepareChallenge(response) : null + if (returnCode !== 0 && returnCode !== 10030 && returnCode !== 10039) { + throw createHttpError('虎牙短信验证码发送失败,请稍后重试', { + statusCode: 502, + errorCode: 'huya_sms_send_failed', + }) + } + if (!challenge && !sessionData) { + throw createHttpError('虎牙短信验证码发送失败,请稍后重试', { + statusCode: 502, + errorCode: 'huya_sms_send_failed', + }) + } + const createdAtMs = this.now() + const session: NativeSession = { + sessionId, + orderNo: input.orderNo, + taskId: input.taskId, + stateDir, + createdAtMs, + expiresAtMs: createdAtMs + SMS_LOGIN_TTL_MS, + role: sms, + behavior: behavior.behavior, + page: behavior.page, + qrid: '', + qrImage: '', + sdid: device.sdid, + hdid: device.hdid, + deviceInfo: { + ...Object.fromEntries(cookiePairs(sms.cookieHeader())), + sdid: device.sdid, + hdid: device.hdid, + udb_appid: '5002', + }, + status: challenge ? 'verification_pending' : 'waiting_code', + smsSessionData: sessionData, + smsPhone: phone, + smsChallenge: challenge, + uid: 0, + nickname: '', + cookie: '', + message: challenge ? '请完成安全验证后发送验证码' : '验证码已发送,请注意查收', + binding: defaultBindingState(), + tickets: new Map(), + } + this.sessions.set(sessionId, session) + this.prune() + return { + session: this.loginView(session), + challenge, + } + } catch (error) { + await removeFingerprintState(stateDir) + if (error && typeof error === 'object' && 'statusCode' in error) throw error + throw createHttpError( + `发送虎牙短信验证码失败:${error instanceof Error ? error.message : String(error)}`, + { statusCode: 502, errorCode: 'huya_sms_send_failed' }, + ) + } + } + + async verifySmsChallenge( + sessionId: string, + solution: HuyaChallengeSolution, + ): Promise { + return this.withLock(sessionId, async () => { + const session = this.requireSession(sessionId) + if (session.expiresAtMs <= this.now()) { + session.status = 'expired' + session.message = '安全验证已过期,请重新发送验证码' + throw createHttpError(session.message, { + statusCode: 409, + errorCode: 'huya_sms_verification_expired', + }) + } + if (!session.smsChallenge || !(session.role instanceof HuyaSmsRole)) { + throw createHttpError('当前没有待完成的虎牙安全验证', { + statusCode: 409, + errorCode: 'huya_sms_verification_not_found', + }) + } + try { + await session.role.solveChallenge(solution) + } catch (error) { + throw createHttpError( + error instanceof Error ? error.message : '虎牙安全验证未通过,请重新拖动', + { + statusCode: 409, + errorCode: 'huya_sms_verification_failed', + }, + ) + } + const behavior = smsBehavior() + const response = await session.role.call('/web/v2/smsCode', '60027', { + phone: session.smsPhone, + behavior: behavior.behavior, + page: behavior.page, + }) + const returnCode = extractInt(response.returnCode) + const sessionData = readString(response.data?.sessionData) + if (returnCode === 10030 || returnCode === 10039) { + const challenge = await session.role.prepareChallenge(response) + session.smsChallenge = challenge + session.status = 'verification_pending' + session.expiresAtMs = this.now() + SMS_LOGIN_TTL_MS + session.message = '请继续完成安全验证' + return { + session: this.loginView(session), + challenge, + } + } + if (returnCode !== 0 || !sessionData) { + throw createHttpError('虎牙短信验证码发送失败,请稍后重试', { + statusCode: 502, + errorCode: 'huya_sms_send_failed', + }) + } + session.smsSessionData = sessionData + session.smsChallenge = null + session.status = 'waiting_code' + session.expiresAtMs = this.now() + SMS_LOGIN_TTL_MS + session.message = '验证码已发送,请注意查收' + return { + session: this.loginView(session), + challenge: null, + } + }) + } + + async loginWithSms(input: { + sessionId: string + mobile: string + code: string + }): Promise { + return this.withLock(input.sessionId, async () => { + const session = this.requireSession(input.sessionId) + if (session.status === 'authenticated' && session.cookie) return this.loginView(session) + if (!session.smsSessionData) { + throw createHttpError('请先获取虎牙短信验证码', { + statusCode: 409, + errorCode: 'huya_sms_code_required', + }) + } + if (session.expiresAtMs <= this.now()) { + session.status = 'expired' + session.message = '短信验证码已过期,请重新获取' + return this.loginView(session) + } + const phone = normalizeMainlandMobile(input.mobile) + if (phone !== session.smsPhone) { + throw createHttpError('手机号与发送验证码时不一致,请重新获取验证码', { + statusCode: 409, + errorCode: 'huya_sms_phone_mismatch', + }) + } + const code = readString(input.code) + if (!/^\d{4,8}$/.test(code)) { + throw createHttpError('请输入正确的虎牙短信验证码', { + statusCode: 400, + errorCode: 'huya_sms_code_invalid', + }) + } + const behavior = smsBehavior() + const response = await session.role.call('/web/v2/smsLogin', '60025', { + phone, + authId: '', + authcode: code, + sessionData: session.smsSessionData, + domainList: '', + remember: '1', + behavior: behavior.behavior, + page: behavior.page, + }) + const returnCode = extractInt(response.returnCode) + if (returnCode === 10030 || returnCode === 10039) { + throw createHttpError('虎牙登录要求重新完成安全验证,请重新发送验证码', { + statusCode: 409, + errorCode: 'huya_sms_verification_required', + }) + } + if (returnCode !== 0) { + throw createHttpError('验证码错误或已过期', { + statusCode: 409, + errorCode: 'huya_sms_login_failed', + }) + } + const data = response.data && typeof response.data === 'object' ? response.data : {} + await this.finishLogin(session, data) + return this.loginView(session) + }) + } + async startBinding( sessionId: string, options: { forceRebind?: boolean } = {}, @@ -619,6 +906,9 @@ export class HuyaNativeConnector { const session = this.sessions.get(sessionId) if (session) { session.cookie = '' + session.smsSessionData = '' + session.smsPhone = '' + session.smsChallenge = null session.tickets.clear() this.sessions.delete(sessionId) await removeFingerprintState(session.stateDir) @@ -626,20 +916,32 @@ export class HuyaNativeConnector { } private async finishLogin(session: NativeSession, data: Record): Promise { - const biztoken = readString(data.biztoken) - const uid = extractInt(data.uid ?? data.yyuid ?? data.lUid) + const responseCookie = session.role.cookieHeader() + const biztoken = + readString(data.biztoken) || + cookieValue(responseCookie, 'udb_biztoken') || + cookieValue(responseCookie, 'udb_cred') + const uid = + extractInt(data.uid ?? data.yyuid ?? data.lUid) || + extractInt(cookieValue(responseCookie, 'udb_uid') || cookieValue(responseCookie, 'yyuid')) if (!biztoken || !uid) { session.status = 'failed' - session.message = '虎牙扫码已确认,但未返回完整登录凭据' + session.message = '虎牙登录已确认,但未返回完整登录凭据' return } - const username = readString(data.passport ?? data.udb_passport ?? data.username) - const nickname = readString(data.nickname ?? data.nickName ?? data.nick ?? username) + const username = + readString( + data.passport ?? + data.udb_passport ?? + data.username ?? + cookieValue(responseCookie, 'udb_passport'), + ) || '' + const nickname = readString(data.nickname ?? data.nickName ?? data.nick ?? username) || username session.deviceInfo.sdid = session.deviceInfo.sdid || session.sdid session.deviceInfo.hdid = session.deviceInfo.hdid || session.hdid try { let merged = mergeHuyaWebCookie({ - cookie: session.qr.cookieHeader(), + cookie: session.role.cookieHeader(), biztoken, sdid: session.sdid, hdid: session.hdid, @@ -648,10 +950,10 @@ export class HuyaNativeConnector { username: username.startsWith('hy_') ? username : '', includeTransient: false, }) - session.qr.setCookies(merged) - await session.qr.verifyCookie() + session.role.setCookies(merged) + await session.role.verifyCookie() merged = mergeHuyaWebCookie({ - cookie: session.qr.cookieHeader(), + cookie: session.role.cookieHeader(), biztoken, sdid: session.sdid, hdid: session.hdid, @@ -669,6 +971,9 @@ export class HuyaNativeConnector { session.uid = uid session.nickname = nickname session.status = 'authenticated' + session.smsSessionData = '' + session.smsPhone = '' + session.smsChallenge = null session.expiresAtMs = this.now() + CREDENTIAL_TTL_MS session.message = '虎牙登录成功' } catch (error) { @@ -721,6 +1026,9 @@ export class HuyaNativeConnector { for (const [sessionId, session] of this.sessions) { if (session.expiresAtMs <= now) { session.cookie = '' + session.smsSessionData = '' + session.smsPhone = '' + session.smsChallenge = null session.tickets.clear() this.sessions.delete(sessionId) void removeFingerprintState(session.stateDir) @@ -735,6 +1043,9 @@ export class HuyaNativeConnector { const overflow = this.sessions.size - MAX_SESSIONS for (const [sessionId, session] of Array.from(this.sessions.entries()).slice(0, overflow)) { session.cookie = '' + session.smsSessionData = '' + session.smsPhone = '' + session.smsChallenge = null session.tickets.clear() this.sessions.delete(sessionId) void removeFingerprintState(session.stateDir) @@ -746,7 +1057,7 @@ export class HuyaNativeConnector { return { sessionId: session.sessionId, status: session.status, - qrContent: `https://aq.huya.com/r/confirm.html?k=${session.qrid}&id=5002`, + qrContent: session.qrid ? `https://aq.huya.com/r/confirm.html?k=${session.qrid}&id=5002` : '', qrImage: session.qrImage, expiresAt: iso(session.expiresAtMs), huyaUid: session.uid ? String(session.uid) : '', diff --git a/apps/backend/src/services/semi-auto/huya-native/huya-native.test.ts b/apps/backend/src/services/semi-auto/huya-native/huya-native.test.ts index 81fcadcf..d4ccc2f6 100644 --- a/apps/backend/src/services/semi-auto/huya-native/huya-native.test.ts +++ b/apps/backend/src/services/semi-auto/huya-native/huya-native.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict' import os from 'node:os' import path from 'node:path' import test from 'node:test' +import sharp from 'sharp' import { buildHuyaLoginContext, ensureHuyaWebCookieIdentity, x64hash128 } from './cookie.js' import { HuyaNativeConnector } from './huya-native-connector.js' @@ -196,3 +197,366 @@ test('内置 Connector 完成扫码、CK 兑换和一次性票据且不泄露 CK await connector.revokeSession(created.sessionId) await assert.rejects(() => connector.getLoginSession(created.sessionId), /不存在或已过期/) }) + +test('内置 Connector 手动发码并完成短信登录后保存账号 CK', async () => { + const requests: Array<{ url: string; body: string }> = [] + const fetchImpl = (async (urlInput: string | URL | Request, init?: RequestInit) => { + const url = String(urlInput) + const body = typeof init?.body === 'string' ? init.body : '' + requests.push({ url, body }) + if (url.endsWith('/web/df/token')) { + return new Response(JSON.stringify({ data: { token: 'sms-df-token' } }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + if (url.endsWith('/web/df/collect')) { + return new Response( + JSON.stringify({ + data: { + sdid: 'sms-web-sdid', + hdid: 'sms-web-hdid', + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ) + } + if (url.includes('/web/middle/')) { + return new Response('', { + status: 200, + headers: { 'content-type': 'text/html;charset=UTF-8' }, + }) + } + if (url.endsWith('/web/v2/smsCode')) { + return new Response( + JSON.stringify({ + returnCode: 0, + data: { sessionData: 'sms-session-secret' }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ) + } + if (url.endsWith('/web/v2/smsLogin')) { + const headers = new Headers({ 'content-type': 'application/json' }) + headers.append('set-cookie', 'udb_cred=server-issued-sms-cred; Path=/; Domain=huya.com') + headers.append('set-cookie', 'udb_uid=987654; Path=/; Domain=huya.com') + headers.append('set-cookie', 'yyuid=987654; Path=/; Domain=huya.com') + headers.append('set-cookie', 'udb_passport=hy_987654; Path=/; Domain=huya.com') + return new Response( + JSON.stringify({ + returnCode: 0, + data: { + uid: 987654, + passport: 'hy_987654', + nickname: '短信登录用户', + biztoken: 'sms-biztoken', + }, + }), + { status: 200, headers }, + ) + } + if (url.endsWith('/web/cookie/verify')) { + return new Response(JSON.stringify({ returnCode: 0, data: null }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + throw new Error(`unexpected request: ${url}`) + }) as typeof fetch + + const connector = new HuyaNativeConnector({ + fetchImpl, + getFingerprint: async () => ({ + sdid: 'sms-fingerprint-sdid', + hdid: 'sms-fingerprint-hdid', + cookies: { + udb_appid: '5008', + game_did: 'sms-game-did', + _qimei_uuid42: '1ab09100209100ee4d631bc545468c6addc489a95a', + __yamid_new: 'CBE2807148F00001332411CB1E296A00', + udb_guiddata: '838ed427bbdb491d8e6f6782addb0364', + guid: '0a7dee10017ef16aad0229302a3029e3', + }, + source: 'fingerprint', + }), + stateRoot: path.join(os.tmpdir(), 'huya-native-sms-test'), + }) + + const sent = await connector.sendSmsCode({ + taskId: 2, + orderNo: 'ORDER-SMS-1', + mobile: '13800138000', + }) + assert.equal(sent.challenge, null) + assert.equal(sent.session.status, 'waiting_code') + assert.equal(sent.session.hasCredential, false) + assert.equal(JSON.stringify(sent).includes('sms-session-secret'), false) + assert.equal((await connector.getLoginSession(sent.session.sessionId)).status, 'waiting_code') + + const smsCodeRequest = requests.find((item) => item.url.endsWith('/web/v2/smsCode')) + assert.ok(smsCodeRequest) + const smsCodeEnvelope = JSON.parse(smsCodeRequest.body) as { + data?: { phone?: string } + } + assert.equal(smsCodeEnvelope.data?.phone, '08613800138000') + + const authenticated = await connector.loginWithSms({ + sessionId: sent.session.sessionId, + mobile: '13800138000', + code: '123456', + }) + assert.equal(authenticated.status, 'authenticated') + assert.equal(authenticated.huyaUid, '987654') + assert.equal(authenticated.nickname, '短信登录用户') + assert.equal(JSON.stringify(authenticated).includes('sms-session-secret'), false) + + const credential = connector.exportSessionCredential(sent.session.sessionId) + assert.equal(credential.platformUid, '987654') + assert.equal( + credential.cookies.find((item) => item.name === 'udb_cred')?.value, + 'server-issued-sms-cred', + ) +}) + +test('内置 Connector 将虎牙滑块验证交给客户完成后再发码', async () => { + let smsCodeCalls = 0 + const background = await sharp({ + create: { width: 52, height: 180, channels: 3, background: '#dbeafe' }, + }) + .jpeg() + .toBuffer() + const puzzle = await sharp({ + create: { width: 20, height: 20, channels: 4, background: '#f97316' }, + }) + .png() + .toBuffer() + const fetchImpl = (async (urlInput: string | URL | Request, init?: RequestInit) => { + const url = String(urlInput) + const body = typeof init?.body === 'string' ? init.body : '' + if (url.endsWith('/web/df/token')) { + return new Response(JSON.stringify({ data: { token: 'challenge-token' } }), { status: 200 }) + } + if (url.endsWith('/web/df/collect')) { + return new Response( + JSON.stringify({ data: { sdid: 'challenge-sdid', hdid: 'challenge-hdid' } }), + { status: 200 }, + ) + } + if (url.includes('/web/middle/')) { + return new Response('', { status: 200 }) + } + if (url.endsWith('/web/v2/smsCode')) { + smsCodeCalls += 1 + if (smsCodeCalls === 1) { + return new Response( + JSON.stringify({ + returnCode: 10030, + data: { + strategys: [ + { + data: 'https://aq.huya.com/p/safe_auth/pt_auth.html?param=captcha-param&sceneType=4', + }, + ], + }, + }), + { status: 200 }, + ) + } + return new Response( + JSON.stringify({ returnCode: 0, data: { sessionData: 'sms-after-slider' } }), + { status: 200 }, + ) + } + if (url.endsWith('/auth/client/config3')) { + assert.equal(JSON.parse(body).data.urlParamMap.param, 'captcha-param') + return new Response( + JSON.stringify({ + returnCode: 0, + data: { + gurl: 'https://udbrtt.huya.com/auth/test/get', + vurl: 'https://udbrtt.huya.com/auth/test/verify', + js: ` + window.UdbCipher = { + decrypt() { return btoa(JSON.stringify({ ord: [1, 0] })) }, + encrypt(value) { return 'encrypted-' + value } + } + `, + }, + }), + { status: 200 }, + ) + } + if (url.endsWith('/auth/test/get')) { + return new Response( + JSON.stringify({ + returnCode: 0, + data: { + code: 'captcha-code', + huyapk: 'captcha-key', + segment: Array.from(Buffer.from('captcha-segment')), + dtuStr: background.toString('base64'), + puzzleStr: puzzle.toString('base64'), + location_y: 40, + }, + }), + { status: 200 }, + ) + } + if (url.includes('/auth/test/verify')) { + const payload = JSON.parse(body) + assert.equal(payload.data.act.startsWith('encrypted-'), true) + return new Response(JSON.stringify({ data: { authId: 'captcha-auth-id' } }), { + status: 200, + }) + } + throw new Error(`unexpected request: ${url}`) + }) as typeof fetch + const connector = new HuyaNativeConnector({ + fetchImpl, + stateRoot: path.join(os.tmpdir(), 'huya-native-slider-test'), + }) + + const pending = await connector.sendSmsCode({ + taskId: 3, + orderNo: 'ORDER-SMS-CHALLENGE', + mobile: '13800138000', + }) + assert.equal(pending.session.status, 'verification_pending') + assert.equal(pending.challenge?.type, 'slider') + assert.match(pending.challenge?.image || '', /^data:image\/png;base64,/) + assert.equal(JSON.stringify(pending).includes('captcha-key'), false) + assert.equal(JSON.stringify(pending).includes('captcha-code'), false) + + const verified = await connector.verifySmsChallenge(pending.session.sessionId, { distance: 20 }) + assert.equal(verified.session.status, 'waiting_code') + assert.equal(verified.challenge, null) + assert.equal(smsCodeCalls, 2) +}) + +test('内置 Connector 支持客户完成虎牙点选和旋转验证', async () => { + const background = await sharp({ + create: { width: 350, height: 268, channels: 3, background: '#e2e8f0' }, + }) + .jpeg() + .toBuffer() + const puzzle = await sharp({ + create: { width: 180, height: 180, channels: 4, background: '#fb923c' }, + }) + .png() + .toBuffer() + + async function run(type: 'click' | 'rotate') { + let smsCodeCalls = 0 + const fetchImpl = (async (urlInput: string | URL | Request, init?: RequestInit) => { + const url = String(urlInput) + const body = typeof init?.body === 'string' ? init.body : '' + if (url.endsWith('/web/df/token')) { + return new Response(JSON.stringify({ data: { token: 'challenge-token' } }), { status: 200 }) + } + if (url.endsWith('/web/df/collect')) { + return new Response( + JSON.stringify({ data: { sdid: 'challenge-sdid', hdid: 'challenge-hdid' } }), + { status: 200 }, + ) + } + if (url.includes('/web/middle/')) return new Response('', { status: 200 }) + if (url.endsWith('/web/v2/smsCode')) { + smsCodeCalls += 1 + if (smsCodeCalls === 1) { + return new Response( + JSON.stringify({ + returnCode: 10039, + data: { + strategys: [ + { + data: + type === 'click' + ? 'https://aq.huya.com/p/safe_auth/dx_auth.html?param=captcha-param&sceneType=4' + : 'https://aq.huya.com/p/safe_auth/rotate_auth.html?param=captcha-param&sceneType=4', + }, + ], + }, + }), + { status: 200 }, + ) + } + return new Response( + JSON.stringify({ returnCode: 0, data: { sessionData: 'sms-after-challenge' } }), + { status: 200 }, + ) + } + if (url.endsWith('/auth/client/config3')) { + return new Response( + JSON.stringify({ + data: { + gurl: `https://udbrtt.huya.com/auth/${type}/get`, + vurl: `https://udbrtt.huya.com/auth/${type}/verify`, + js: ` + window.UdbCipher = { + decrypt() { return btoa(JSON.stringify({ ord: [0, 1] })) }, + encrypt() { return [1, 2, 3] } + } + `, + }, + }), + { status: 200 }, + ) + } + if (url.endsWith(`/auth/${type}/get`)) { + return new Response( + JSON.stringify({ + data: { + code: `${type}-code`, + huyapk: `${type}-key`, + ...(type === 'rotate' + ? { + segment: Array.from(Buffer.from(`${type}-segment`)), + dTuStr: background.toString('base64'), + puzzleStr: puzzle.toString('base64'), + } + : { + dTuStr: background.toString('base64'), + hZiStr: puzzle.toString('base64'), + question: '请依次点击目标', + wordSize: 2, + }), + }, + }), + { status: 200 }, + ) + } + if (url.includes(`/auth/${type}/verify`)) { + const payload = JSON.parse(body) + assert.deepEqual(payload.data.act, [1, 2, 3]) + return new Response(JSON.stringify({ data: { authId: `${type}-auth-id` } }), { + status: 200, + }) + } + throw new Error(`unexpected request: ${url}`) + }) as typeof fetch + const connector = new HuyaNativeConnector({ fetchImpl }) + const pending = await connector.sendSmsCode({ + taskId: 4, + orderNo: `ORDER-${type}`, + mobile: '13800138000', + }) + assert.equal(pending.session.status, 'verification_pending') + assert.equal(pending.challenge?.type, type) + const result = await connector.verifySmsChallenge( + pending.session.sessionId, + type === 'click' + ? { + points: [ + { x: 40, y: 60 }, + { x: 180, y: 120 }, + ], + } + : { angle: 90 }, + ) + assert.equal(result.session.status, 'waiting_code') + assert.equal(result.challenge, null) + } + + await run('click') + await run('rotate') +}) diff --git a/apps/backend/src/services/semi-auto/huya-native/huya-sms-role.ts b/apps/backend/src/services/semi-auto/huya-native/huya-sms-role.ts new file mode 100644 index 00000000..ad9740a1 --- /dev/null +++ b/apps/backend/src/services/semi-auto/huya-native/huya-sms-role.ts @@ -0,0 +1,832 @@ +/** + * 虎牙网页短信登录协议。 + * + * 短信登录使用独立的网页设备上下文:df/token -> df/collect -> UDB middle, + * 不能复用扫码链路的 App 设备指纹,否则 sdid 与 appId 语义不一致。 + */ + +import crypto from 'node:crypto' +import vm from 'node:vm' +import { brotliDecompressSync, gunzipSync, inflateSync } from 'node:zlib' + +import sharp from 'sharp' +import { logWarn } from '../../../utils/logger.js' +import { HuyaCookieJar, UDB_BASE } from './huya-rpc.js' + +const DF_TOKEN_URL = 'https://df.huya.com/web/df/token' +const DF_COLLECT_URL = 'https://df.huya.com/web/df/collect' +const APP_VERSION = '2.6' +const APP_ID = '5002' +const APP_SIGN = '1ce3bf682483d03f146f58232ec10635' +const LCID = '2052' +const AUTH_BASE = 'https://udbrtt.huya.com' +const SMS_WEB_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' + + '(KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36' + +type FetchLike = typeof fetch +type JsonRecord = Record + +export type HuyaSmsResponse = JsonRecord & { + data?: JsonRecord + returnCode?: number | string + message?: string +} + +type HuyaChallengeBase = { + image: string + width: number + height: number +} + +export type HuyaSliderChallenge = HuyaChallengeBase & { + type: 'slider' + puzzleImage: string + puzzleWidth: number + puzzleY: number +} + +export type HuyaRotateChallenge = HuyaChallengeBase & { + type: 'rotate' + puzzleImage: string + puzzleWidth: number + puzzleHeight: number + puzzleX: number + puzzleY: number +} + +export type HuyaClickChallenge = HuyaChallengeBase & { + type: 'click' + tipImage: string + question: string + wordSize: number +} + +export type HuyaVerificationChallenge = + | HuyaSliderChallenge + | HuyaRotateChallenge + | HuyaClickChallenge + +export type HuyaSmsSendResult = + | { + kind: 'sent' + sessionData: string + message: string + } + | { + kind: 'challenge' + challenge: HuyaVerificationChallenge + message: string + } + +export type HuyaChallengeSolution = { + distance?: unknown + angle?: unknown + points?: unknown +} + +type ChallengeState = { + strategyUrl: string + page: string + urlParamMap: Record + sceneType: string + gurl: string + vurl: string + code: string + huyapk: string + jsContext: vm.Context + challenge: HuyaVerificationChallenge +} + +function requestId(): string { + const now = new Date() + const midnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) + return String(Math.max(0, now.getTime() - midnight)) +} + +function readString(value: unknown): string { + return String(value ?? '').trim() +} + +function readSegment(value: unknown): string | number[] { + if (!Array.isArray(value)) return readString(value) + return value + .map((item) => Number(item)) + .filter((item) => Number.isFinite(item)) + .map((item) => Math.max(0, Math.min(255, Math.trunc(item)))) +} + +function commonHeaders(): Record { + return { + Accept: '*/*', + 'Accept-Language': 'zh-CN,zh;q=0.9', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'Content-Type': 'application/json;charset=UTF-8', + Origin: 'https://aq.huya.com', + Pragma: 'no-cache', + Referer: 'https://aq.huya.com/', + 'Sec-Fetch-Dest': 'empty', + 'Sec-Fetch-Mode': 'cors', + 'Sec-Fetch-Site': 'same-site', + 'User-Agent': SMS_WEB_UA, + 'sec-ch-ua': '"Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"', + 'sec-ch-ua-mobile': '?0', + 'sec-ch-ua-platform': '"macOS"', + } +} + +function imageDataUrl(value: unknown, mime = 'image/png'): string { + const raw = readString(value) + if (!raw) return '' + return raw.startsWith('data:image/') ? raw : `data:${mime};base64,${raw}` +} + +function imageBuffer(value: unknown): Buffer { + const raw = readString(value) + const payload = raw.includes(';base64,') ? raw.split(';base64,').pop() || '' : raw + return Buffer.from(payload, 'base64') +} + +function encodeBehavior(events: unknown[]): string { + return encodeURIComponent(JSON.stringify(events)) +} + +function authPageUrl(): string { + return `https://aq.huya.com/p/udb_login.html?v=${String(Date.now()).slice(0, 8)}00000` +} + +function formatTrack(points: number[][]): string { + return points.map(([x, y, time]) => `${x},${y},${time}`).join(';') +} + +function easeOutExpo(value: number): number { + return value === 1 ? 1 : 1 - 2 ** (-10 * value) +} + +function generateTrack(distance: number): number[][] { + const total = distance + 10 + Math.floor(Math.random() * 31) + let currentTime = Date.now() - (1500 + Math.floor(Math.random() * 501)) + const startX = 56 + Math.floor(Math.random() * 8) + const startY = 210 + Math.floor(Math.random() * 4) + const points: number[][] = [[startX, startY, currentTime]] + const count = 40 + Math.floor(Math.random() * 21) + let previous = 0 + for (let index = 0; index < count; index += 1) { + const x = Math.round(easeOutExpo(index / count) * total) + currentTime += 10 + Math.floor(Math.random() * 11) + if (x === previous) continue + points.push([x + startX, startY - 1 + Math.floor(Math.random() * 3), currentTime]) + previous = x + } + const lastPoint = points[points.length - 1] + if (lastPoint) points.push([...lastPoint]) + return points +} + +function challengeTypeFromUrl(url: string): HuyaVerificationChallenge['type'] | 'qr' | 'sms' { + const lower = url.toLowerCase() + if (lower.includes('rotate_auth') || lower.includes('/auth/xz/')) return 'rotate' + if (lower.includes('dx_auth') || lower.includes('/auth/dx/')) return 'click' + if (lower.includes('qr_auth')) return 'qr' + if (lower.includes('sms_auth')) return 'sms' + return 'slider' +} + +export class HuyaSmsRole { + private readonly jar = new HuyaCookieJar() + private readonly fetchImpl: FetchLike + private readonly deviceId = crypto.randomBytes(16).toString('hex') + private readonly mid = crypto.randomBytes(16).toString('hex').toUpperCase() + private readonly reqId = requestId() + private readonly context = `WB-${this.deviceId}-${this.mid}-` + private readonly middleUrl = `${UDB_BASE}/web/middle/${APP_VERSION}/${this.reqId}/https/${this.deviceId}` + private sdid = '' + private hdid = '' + private challengeState: ChallengeState | null = null + + constructor(fetchImpl: FetchLike = fetch) { + this.fetchImpl = fetchImpl + } + + cookieHeader(): string { + return this.jar.header() + } + + setCookies(cookie: string): void { + this.jar.clear() + this.jar.set(cookie) + } + + async prepareDevice(): Promise { + const tokenResponse = await this.fetchImpl(DF_TOKEN_URL, { + method: 'POST', + headers: commonHeaders(), + body: JSON.stringify({ + encryptVersion: '1.0.1', + fingerprintVersion: '1.2.41', + }), + signal: AbortSignal.timeout(20_000), + }) + this.jar.absorb(tokenResponse.headers) + if (!tokenResponse.ok) throw new Error(`虎牙 df token 获取失败:HTTP ${tokenResponse.status}`) + const tokenPayload = (await tokenResponse.json().catch(() => ({}))) as { + data?: { token?: string } + } + const token = readString(tokenPayload.data?.token) + if (!token) throw new Error('虎牙 df token 为空') + + const collectResponse = await this.fetchImpl(DF_COLLECT_URL, { + method: 'POST', + headers: commonHeaders(), + body: JSON.stringify({ token }), + signal: AbortSignal.timeout(20_000), + }) + this.jar.absorb(collectResponse.headers) + if (!collectResponse.ok) { + throw new Error(`虎牙 sdid 获取失败:HTTP ${collectResponse.status}`) + } + const collectPayload = (await collectResponse.json().catch(() => ({}))) as { + data?: { sdid?: string; hdid?: string } + } + this.sdid = readString(collectPayload.data?.sdid) + this.hdid = readString(collectPayload.data?.hdid) + if (!this.sdid) throw new Error('虎牙 sdid 为空') + } + + async bootstrapMiddle(): Promise { + for (const host of ['udblgn.huya.com', 'udb3lgn.huya.com', 'udbreg.huya.com']) { + const url = `https://${host}/web/middle/${APP_VERSION}/${this.reqId}/https/${this.deviceId}` + try { + const response = await this.fetchImpl(url, { + method: 'GET', + headers: { + ...commonHeaders(), + ...(this.jar.header() ? { cookie: this.jar.header() } : {}), + }, + signal: AbortSignal.timeout(20_000), + }) + this.jar.absorb(response.headers) + await response.arrayBuffer().catch(() => undefined) + } catch { + // UDB middle 是上下文初始化,个别备用域名失败不影响后续主接口。 + } + } + } + + async call(path: string, uri: string, data: JsonRecord): Promise { + const response = await this.fetchImpl(`${UDB_BASE}${path}`, { + method: 'POST', + headers: { + ...commonHeaders(), + Origin: UDB_BASE, + Referer: this.middleUrl, + Context: this.context, + Lcid: LCID, + Reqid: this.reqId, + Uri: uri, + ...(this.jar.header() ? { cookie: this.jar.header() } : {}), + }, + body: JSON.stringify({ + uri, + version: APP_VERSION, + context: this.context, + appId: APP_ID, + appSign: APP_SIGN, + authId: '', + sdid: this.sdid, + lcid: LCID, + byPass: '3', + requestId: this.reqId, + data, + }), + signal: AbortSignal.timeout(20_000), + }) + this.jar.absorb(response.headers) + if (!response.ok) throw new Error(`虎牙短信登录接口失败:HTTP ${response.status}`) + const text = await response.text() + try { + return (text ? JSON.parse(text) : {}) as HuyaSmsResponse + } catch { + throw new Error('虎牙短信登录接口返回了无法解析的响应') + } + } + + async prepareChallenge(response: HuyaSmsResponse): Promise { + const page = authPageUrl() + const strategyUrl = this.strategyUrl(response) + if (!strategyUrl) throw new Error('虎牙安全验证缺少策略地址') + const challengeType = challengeTypeFromUrl(strategyUrl) + if (challengeType === 'qr') { + throw new Error('虎牙要求使用已登录 App 扫码确认') + } + if (challengeType === 'sms') { + throw new Error('虎牙要求完成额外的短信安全验证') + } + const parsed = new URL(strategyUrl) + const urlParamMap = Object.fromEntries(parsed.searchParams.entries()) + if (!urlParamMap.param) throw new Error('虎牙安全验证缺少 param') + let sceneType = urlParamMap.sceneType || '' + let config = await this.requestConfig3(urlParamMap, sceneType, page) + if ((!config.gurl || !config.vurl || !config.js) && sceneType !== '4') { + urlParamMap.sceneType = '4' + sceneType = '4' + config = await this.requestConfig3(urlParamMap, sceneType, page) + } + if (!config.gurl || !config.vurl || !config.js) { + throw new Error('虎牙安全验证未下发图形验证配置') + } + const jsContext = this.compileCipher(config.js) + let data: JsonRecord = {} + let code = '' + let huyapk = '' + let segment: string | number[] = '' + let backgroundRaw: unknown = '' + let background: Buffer = Buffer.alloc(0) + let puzzleRaw = '' + let restoredImage = '' + let pointError: unknown = null + for (let attempt = 0; attempt < 3; attempt += 1) { + const pointResponse = await this.requestJson(config.gurl, { + appId: APP_ID, + data: { + urlParamMap, + wupData: '', + page, + behavior: encodeBehavior([]), + info: this.sdid, + ver: 5, + }, + }) + data = pointResponse.data || {} + logWarn('huya/sms/challenge-point', '虎牙图形验证原始响应', { + returnCode: pointResponse.returnCode, + data: { + code: data.code, + segment: data.segment, + huyapk: data.huyapk, + location_y: data.location_y, + question: data.question, + wordSize: data.wordSize, + title: data.title, + expire: data.expire, + dtuLength: readString(data.dtuStr ?? data.dTuStr ?? data.bgStr).length, + puzzleLength: readString(data.puzzleStr).length, + tipLength: readString(data.hZiStr ?? data.hziStr).length, + }, + }) + code = readString(data.code) + huyapk = readString(data.huyapk) + segment = readSegment(data.segment) + backgroundRaw = data.dtuStr ?? data.dTuStr ?? data.bgStr + background = imageBuffer(backgroundRaw) + puzzleRaw = readString(data.puzzleStr) + if (!code || !huyapk || !background.length) { + pointError = new Error('虎牙图形验证参数不完整') + } else if (challengeType === 'click') { + pointError = null + break + } else if (!puzzleRaw) { + pointError = new Error('虎牙拼图验证参数不完整') + } else if (!segment) { + restoredImage = imageDataUrl(backgroundRaw) + pointError = null + break + } else { + try { + restoredImage = await this.restoreScrambledBackground( + background, + segment, + huyapk, + jsContext, + ) + pointError = null + break + } catch (error) { + pointError = error + } + } + if (attempt < 2) await new Promise((resolve) => setTimeout(resolve, 250)) + } + if (pointError) throw pointError + let challenge: HuyaVerificationChallenge + if (challengeType === 'click') { + const metadata = await sharp(background).metadata() + challenge = { + type: 'click', + image: imageDataUrl(backgroundRaw), + tipImage: imageDataUrl(data.hZiStr ?? data.hziStr ?? data.puzzleStr), + question: readString(data.question), + wordSize: Math.max(1, Number(data.wordSize || 3)), + width: Number(metadata.width || 350), + height: Number(metadata.height || 268), + } + } else { + const metadata = await sharp(background).metadata() + const puzzleMetadata = await sharp(imageBuffer(puzzleRaw)).metadata() + const image = restoredImage || imageDataUrl(backgroundRaw) + challenge = + challengeType === 'rotate' + ? { + type: 'rotate', + image, + puzzleImage: imageDataUrl(puzzleRaw), + width: Number(metadata.width || 350), + height: Number(metadata.height || 180), + puzzleWidth: Number(puzzleMetadata.width || 180), + puzzleHeight: Number(puzzleMetadata.height || 180), + puzzleX: 105, + puzzleY: 0, + } + : { + type: 'slider', + image, + puzzleImage: imageDataUrl(puzzleRaw), + width: Number(metadata.width || 350), + height: Number(metadata.height || 180), + puzzleWidth: Number(puzzleMetadata.width || 0), + puzzleY: Number(data.location_y || 0), + } + } + this.challengeState = { + strategyUrl, + page, + urlParamMap, + sceneType, + gurl: config.gurl, + vurl: config.vurl, + code, + huyapk, + jsContext, + challenge, + } + return challenge + } + + async solveChallenge(solution: HuyaChallengeSolution): Promise { + const state = this.challengeState + if (!state) { + throw new Error('虎牙安全验证会话不存在或已过期') + } + const answer = this.buildChallengeAnswer(state, solution) + const actData = answer.actData + const act = this.encryptAct(state.jsContext, actData, state.huyapk) + const verifyUrl = new URL(state.vurl) + verifyUrl.searchParams.set('lock', 'true') + const result = await this.requestJson(verifyUrl.toString(), { + appId: APP_ID, + data: { + urlParamMap: state.urlParamMap, + wupData: '', + page: state.page, + behavior: encodeBehavior(answer.behaviorEvents), + info: this.sdid, + act, + ver: 5, + }, + }) + const authId = readString(result.data?.authId) + if (!authId) throw new Error('虎牙图形验证未通过,请重新完成') + this.challengeState = null + } + + async verifyCookie(): Promise { + const response = await this.fetchImpl(`${UDB_BASE}/web/cookie/verify`, { + method: 'POST', + headers: { + 'user-agent': SMS_WEB_UA, + origin: 'https://www.huya.com', + referer: 'https://www.huya.com/', + 'content-type': 'application/json', + accept: 'application/json, text/javascript, */*; q=0.01', + cookie: this.jar.header(), + }, + body: JSON.stringify({ appId: 5002 }), + signal: AbortSignal.timeout(20_000), + }) + this.jar.absorb(response.headers) + if (!response.ok) throw new Error(`虎牙 Cookie 兑换失败:HTTP ${response.status}`) + } + + device(): { sdid: string; hdid: string } { + return { sdid: this.sdid, hdid: this.hdid } + } + + private strategyUrl(response: HuyaSmsResponse): string { + const data = response.data || {} + const strategies = Array.isArray(data.strategys) ? data.strategys : [] + const strategy = strategies[0] + return strategy && typeof strategy === 'object' + ? readString((strategy as Record).data) + : '' + } + + private async requestConfig3( + urlParamMap: Record, + sceneType: string, + page: string, + ): Promise<{ gurl: string; vurl: string; js: string }> { + const response = await this.requestJson(`${AUTH_BASE}/auth/client/config3`, { + appId: APP_ID, + data: { + urlParamMap: { ...urlParamMap, sceneType }, + wupData: '', + page, + behavior: encodeBehavior([]), + info: this.sdid, + cTuIndex: 0, + }, + }) + return { + gurl: readString(response.data?.gurl), + vurl: readString(response.data?.vurl), + js: readString(response.data?.js), + } + } + + private async requestJson(url: string, payload: Record) { + const response = await this.fetchImpl(url, { + method: 'POST', + headers: { + ...commonHeaders(), + Origin: 'https://aq.huya.com', + Referer: 'https://aq.huya.com/', + ...(this.jar.header() ? { cookie: this.jar.header() } : {}), + }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(20_000), + }) + this.jar.absorb(response.headers) + const text = await response.text() + if (!response.ok) { + let requestUrl = url + try { + const parsed = new URL(url) + requestUrl = `${parsed.origin}${parsed.pathname}?${Array.from(parsed.searchParams.keys()).join(',')}` + } catch { + requestUrl = url.split('?', 1)[0] || url + } + logWarn('huya/sms/http-error', '虎牙安全验证请求返回错误', { + requestUrl, + status: response.status, + responseBody: text.slice(0, 800), + }) + throw new Error(`虎牙安全验证请求失败:HTTP ${response.status}`) + } + try { + return (text ? JSON.parse(text) : {}) as HuyaSmsResponse + } catch { + throw new Error('虎牙安全验证接口返回了无法解析的响应') + } + } + + private compileCipher(js: string): vm.Context { + const context = vm.createContext({ + console, + atob: (value: string) => Buffer.from(value, 'base64').toString('binary'), + btoa: (value: string) => Buffer.from(value, 'binary').toString('base64'), + navigator: { userAgent: SMS_WEB_UA }, + document: {}, + location: { href: 'https://aq.huya.com/' }, + crypto: crypto.webcrypto, + TextEncoder, + TextDecoder, + setTimeout, + clearTimeout, + }) + Object.assign(context, { window: context, self: context, globalThis: context }) + new vm.Script(js, { filename: 'huya-sms-captcha.js' }).runInContext(context, { + timeout: 3000, + }) + const available = vm.runInContext('Boolean(window.UdbCipher)', context, { timeout: 1000 }) + if (!available) throw new Error('虎牙图形验证加密组件初始化失败') + return context + } + + private buildChallengeAnswer( + state: ChallengeState, + solution: HuyaChallengeSolution, + ): { actData: Record; behaviorEvents: unknown[] } { + const challenge = state.challenge + if (challenge.type === 'click') { + const rawPoints = Array.isArray(solution.points) ? solution.points : [] + const points = rawPoints + .map((value) => { + if (Array.isArray(value)) { + return [Number(value[0]), Number(value[1])] as [number, number] + } + if (value && typeof value === 'object') { + const point = value as Record + return [Number(point.x), Number(point.y)] as [number, number] + } + return null + }) + .filter((value): value is [number, number] => Boolean(value)) + .filter( + ([x, y]) => + Number.isFinite(x) && + Number.isFinite(y) && + x >= 0 && + x <= challenge.width && + y >= 0 && + y <= challenge.height, + ) + .slice(0, Math.max(1, challenge.wordSize)) + if (points.length === 0) throw new Error('虎牙点选验证坐标无效') + let currentTime = Date.now() - (5000 + Math.floor(Math.random() * 5001)) + const clickData = points.map(([x, y]) => { + currentTime += 1000 + Math.floor(Math.random() * 1001) + return [Math.round(x), Math.round(y), currentTime] as [number, number, number] + }) + const intervalTimeList = clickData.map((item) => item[2]) + return { + actData: { + clickData: points.map(([x, y]) => `${Math.round(x)}_${Math.round(y)}`).join(','), + intervalTime: intervalTimeList.join('_'), + endTime: + Number(intervalTimeList[intervalTimeList.length - 1] || 0) - + Number(intervalTimeList[0] || 0), + sceneType: state.sceneType, + code: state.code, + }, + behaviorEvents: clickData.map(([x, y, time], index) => ({ + action: 'mousedown', + id: '', + txt: ['', '1', '1\\n2'][index] || '', + clientX: x, + clientY: y, + clientWidth: challenge.width, + clientHeight: challenge.height, + isTrusted: true, + timeStamp: 2000 + Math.floor(Math.random() * 3001), + nowTime: time, + })), + } + } + + const value = + challenge.type === 'rotate' + ? Math.round(Number(solution.angle)) + : Math.round(Number(solution.distance)) + if ( + !Number.isInteger(value) || + value < 0 || + (challenge.type === 'rotate' ? value >= 360 : value > challenge.width) + ) { + throw new Error(challenge.type === 'rotate' ? '虎牙旋转角度无效' : '虎牙滑块位置无效') + } + const dragDistance = + challenge.type === 'rotate' ? Math.max(5, Math.round((value / 360) * 260)) : value + const track = generateTrack(dragDistance) + const firstPoint = track[0] + const lastPoint = track[track.length - 1] + if (!firstPoint || !lastPoint) throw new Error('虎牙拖动轨迹生成失败') + return { + actData: { + point: value, + travel: formatTrack(track), + code: state.code, + sceneType: state.sceneType, + endTime: Number(lastPoint[2] || 0) - Number(firstPoint[2] || 0), + }, + behaviorEvents: [ + { + action: 'mousedown', + id: '', + txt: '', + clientX: firstPoint[0], + clientY: firstPoint[1], + clientWidth: challenge.width, + clientHeight: challenge.height, + isTrusted: true, + timeStamp: 1800 + Math.floor(Math.random() * 3701), + nowTime: Date.now(), + }, + ], + } + } + + private async restoreScrambledBackground( + background: Buffer, + segment: string | number[], + huyapk: string, + context: vm.Context, + ): Promise { + context.__segment = segment + context.__huyapk = huyapk + const decrypted = vm.runInContext('window.UdbCipher.decrypt(__segment, __huyapk)', context, { + timeout: 3000, + }) + const regionIndexes = this.parseRegionIndexes(decrypted) + if (!Array.isArray(regionIndexes) || regionIndexes.length === 0) { + throw new Error('虎牙拼图背景解密失败') + } + const metadata = await sharp(background).metadata() + const width = Number(metadata.width || 350) + const height = Number(metadata.height || 180) + const composites: Array<{ input: Buffer; left: number; top: number }> = [] + for (let index = 0; index < regionIndexes.length; index += 1) { + const sourceLeft = Math.max(0, Math.min(width - 1, Number(regionIndexes[index]) * 26)) + const stripWidth = Math.max(1, Math.min(26, width - sourceLeft, width - index * 26)) + const strip = await sharp(background) + .extract({ left: sourceLeft, top: 0, width: stripWidth, height }) + .png() + .toBuffer() + composites.push({ input: strip, left: index * 26, top: 0 }) + } + const restored = await sharp({ + create: { width, height, channels: 4, background: { r: 255, g: 255, b: 255, alpha: 1 } }, + }) + .composite(composites) + .png() + .toBuffer() + return `data:image/png;base64,${restored.toString('base64')}` + } + + private parseRegionIndexes(decrypted: unknown): number[] { + const candidates = new Set() + const buffers: Buffer[] = [] + if (ArrayBuffer.isView(decrypted)) { + buffers.push(Buffer.from(decrypted.buffer, decrypted.byteOffset, decrypted.byteLength)) + } else if (Object.prototype.toString.call(decrypted) === '[object ArrayBuffer]') { + buffers.push(Buffer.from(decrypted as ArrayBuffer)) + } else { + const text = readString(decrypted) + if (text) { + candidates.add(text) + buffers.push(Buffer.from(text, 'binary'), Buffer.from(text, 'utf8')) + const normalizedBase64 = text.replace(/-/g, '+').replace(/_/g, '/') + const padded = normalizedBase64.padEnd(Math.ceil(normalizedBase64.length / 4) * 4, '=') + buffers.push(Buffer.from(padded, 'base64')) + if (/^[0-9a-f]+$/i.test(text) && text.length % 2 === 0) { + buffers.push(Buffer.from(text, 'hex')) + } + try { + candidates.add(decodeURIComponent(text)) + } catch { + // 非 URL 编码时继续尝试其他形式。 + } + } + } + for (const buffer of buffers) { + candidates.add(buffer.toString('utf8')) + candidates.add(buffer.toString('latin1')) + for (const decompress of [gunzipSync, inflateSync, brotliDecompressSync]) { + try { + candidates.add(decompress(buffer).toString('utf8')) + } catch { + // 当前字节流不是该压缩格式。 + } + } + } + for (const candidate of candidates) { + try { + const parsed = JSON.parse(candidate) as { ord?: unknown } + if (Array.isArray(parsed.ord)) { + return parsed.ord.map((value) => Number(value)).filter((value) => Number.isFinite(value)) + } + } catch { + // 当前候选不是 JSON,继续尝试下一种编码。 + } + } + const decryptedType = ArrayBuffer.isView(decrypted) + ? 'bytes' + : Object.prototype.toString.call(decrypted) === '[object ArrayBuffer]' + ? 'arraybuffer' + : typeof decrypted + const decryptedLength = ArrayBuffer.isView(decrypted) + ? decrypted.byteLength + : Object.prototype.toString.call(decrypted) === '[object ArrayBuffer]' + ? (decrypted as ArrayBuffer).byteLength + : readString(decrypted).length + const decryptedPrefix = ArrayBuffer.isView(decrypted) + ? Array.from( + Buffer.from(decrypted.buffer, decrypted.byteOffset, decrypted.byteLength).subarray(0, 16), + ) + : Array.from(Buffer.from(readString(decrypted), 'binary').subarray(0, 16)) + throw new Error( + `虎牙拼图背景解密结果无法解析(type=${decryptedType}, length=${decryptedLength}, prefix=${decryptedPrefix.join(',')})`, + ) + } + + private encryptAct( + context: vm.Context, + actData: Record, + huyapk: string, + ): string | number[] { + context.__act = Buffer.from(encodeURI(JSON.stringify(actData))).toString('base64') + context.__huyapk = huyapk + const encrypted = vm.runInContext('window.UdbCipher.encrypt(__act, __huyapk)', context, { + timeout: 3000, + }) + if (Array.isArray(encrypted) || ArrayBuffer.isView(encrypted)) { + return Array.from(encrypted as ArrayLike) + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value)) + .map((value) => Math.max(0, Math.min(255, Math.trunc(value)))) + } + const value = readString(encrypted) + if (!value) throw new Error('虎牙验证轨迹加密失败') + return value + } +} diff --git a/apps/backend/src/services/semi-auto/semi-auto-task-service.ts b/apps/backend/src/services/semi-auto/semi-auto-task-service.ts index 712256de..546f2622 100644 --- a/apps/backend/src/services/semi-auto/semi-auto-task-service.ts +++ b/apps/backend/src/services/semi-auto/semi-auto-task-service.ts @@ -22,9 +22,12 @@ import { exportHuyaSessionCredential, getHuyaBinding, getHuyaLoginSession, + loginHuyaSms, revokeHuyaSession, + sendHuyaSmsCode, startHuyaBinding, consumeHuyaBrowserTicket, + verifyHuyaSmsChallenge, type ConnectorBindingSession, type SemiAutoConnectorMode, type ConnectorLoginSession, @@ -342,6 +345,15 @@ function assertWeiboTask(task: TaskRow) { } } +function assertHuyaTask(task: TaskRow) { + if (task.platform !== 'huya') { + throw createHttpError('当前任务不是虎牙任务', { + statusCode: 409, + errorCode: 'semi_auto_platform_mismatch', + }) + } +} + async function createLoginSessionForTask( task: TaskRow, orderNo: string, @@ -639,6 +651,8 @@ export async function markCustomerLinkOpened(orderNoInput: unknown, taskIdInput: function mapLoginUpdate(session: ConnectorLoginSession) { const statusMap: Record = { + verification_pending: 'waiting_login', + waiting_code: 'waiting_login', waiting_scan: 'qr_pending', scanned: 'scanned', authenticated: 'authenticated', @@ -658,6 +672,7 @@ async function updateLoginTask( task: TaskRow, session: ConnectorLoginSession, actorType: TaskActorType, + options: { credentialSource?: 'qr' | 'sms'; method?: 'qr' | 'sms' } = {}, ) { const mapped = mapLoginUpdate(session) const nextExecutionStatus: ExecutionStatus = @@ -678,7 +693,7 @@ async function updateLoginTask( const credentialId = storedCredential ? await upsertSemiAutoCredentialWithClient(client, { platform: task.platform, - source: 'qr', + source: options.credentialSource || 'qr', payload: storedCredential, }) : null @@ -715,6 +730,7 @@ async function updateLoginTask( detail: { hasCredential: mapped.hasCredential, message: mapped.message, + method: options.method || 'qr', }, }) }) @@ -831,6 +847,14 @@ export async function startPublicLogin(orderNoInput: unknown, taskIdInput: unkno await revokeSessionForTask(task, task.connector_session_id).catch(() => undefined) } + if ( + reusableSession?.status === 'waiting_code' || + reusableSession?.status === 'verification_pending' + ) { + await revokeSessionForTask(task, reusableSession.sessionId).catch(() => undefined) + reusableSession = null + } + if (reusableSession) { await updateLoginTask(task, reusableSession, 'customer') const refreshedTask = await loadTask(task.id) @@ -978,6 +1002,216 @@ export async function confirmPublicBinding(orderNoInput: unknown, taskIdInput: u return { task: mapTask(updated), binding } } +export async function sendPublicHuyaSmsCode( + orderNoInput: unknown, + taskIdInput: unknown, + mobileInput: unknown, +) { + const orderNo = normalizeOrderNo(orderNoInput) + const taskId = normalizeId(taskIdInput) + const task = await loadTaskForOrder(orderNo, taskId) + assertHuyaTask(task) + if (task.execution_status === 'completed' || task.execution_status === 'cancelled') { + throw createHttpError('当前订单已结束,不能重新登录', { + statusCode: 409, + errorCode: 'semi_auto_task_closed', + }) + } + + const result = await sendHuyaSmsCode( + { + taskId: task.id, + orderNo, + mobile: String(mobileInput ?? '').trim(), + }, + task.connector_mode, + ) + const session = result.session + const previousSessionId = task.connector_session_id + try { + await withTransaction(async (client) => { + await client.query( + ` + UPDATE semi_auto_tasks + SET connector_session_id = $2, + login_status = 'waiting_login', + execution_status = 'waiting_customer', + auth_started_at = COALESCE(auth_started_at, NOW()), + last_error = '', + updated_at = NOW() + WHERE id = $1 + `, + [task.id, session.sessionId], + ) + await appendTaskEvent(client, task.id, { + eventType: result.challenge ? 'sms_verification_required' : 'sms_code_sent', + actorType: 'customer', + fromLoginStatus: task.login_status, + toLoginStatus: 'waiting_login', + fromExecutionStatus: task.execution_status, + toExecutionStatus: 'waiting_customer', + detail: { + method: 'sms', + expiresAt: session.expiresAt, + ...(result.challenge ? { verificationType: result.challenge.type } : {}), + }, + }) + }) + } catch (error) { + await revokeHuyaSession(session.sessionId, task.connector_mode).catch(() => undefined) + throw error + } + if (previousSessionId && previousSessionId !== session.sessionId) { + await revokeHuyaSession(previousSessionId, task.connector_mode).catch(() => undefined) + } + return { + ok: !result.challenge, + message: session.message || '验证码已发送,请注意查收', + expiresAt: session.expiresAt, + challenge: result.challenge, + } +} + +export async function verifyPublicHuyaSmsChallenge( + orderNoInput: unknown, + taskIdInput: unknown, + input: { distance?: unknown; angle?: unknown; points?: unknown } = {}, +) { + const orderNo = normalizeOrderNo(orderNoInput) + const taskId = normalizeId(taskIdInput) + const task = await loadTaskForOrder(orderNo, taskId) + assertHuyaTask(task) + if (!task.connector_session_id) { + throw createHttpError('虎牙安全验证会话不存在,请重新发送验证码', { + statusCode: 409, + errorCode: 'huya_sms_verification_not_found', + }) + } + const distance = + input.distance === undefined || input.distance === null ? undefined : Number(input.distance) + const angle = input.angle === undefined || input.angle === null ? undefined : Number(input.angle) + const points = Array.isArray(input.points) + ? input.points.map((value) => { + const point = value && typeof value === 'object' ? (value as Record) : {} + return { x: Number(point.x), y: Number(point.y) } + }) + : undefined + if ( + (distance !== undefined && (!Number.isInteger(distance) || distance < 0)) || + (angle !== undefined && (!Number.isInteger(angle) || angle < 0 || angle > 359)) || + (points && + (points.length === 0 || + points.some((point) => !Number.isFinite(point.x) || !Number.isFinite(point.y)))) + ) { + throw createHttpError('安全验证参数无效,请重新完成', { + statusCode: 400, + errorCode: 'huya_sms_verification_solution_invalid', + }) + } + const result = await verifyHuyaSmsChallenge( + { + sessionId: task.connector_session_id, + ...(distance !== undefined ? { distance } : {}), + ...(angle !== undefined ? { angle } : {}), + ...(points ? { points } : {}), + }, + task.connector_mode, + ) + await withTransaction(async (client) => { + await client.query( + ` + UPDATE semi_auto_tasks + SET connector_session_id = $2, + login_status = 'waiting_login', + execution_status = 'waiting_customer', + last_error = '', + updated_at = NOW() + WHERE id = $1 + `, + [task.id, result.session.sessionId], + ) + await appendTaskEvent(client, task.id, { + eventType: result.challenge ? 'sms_verification_required' : 'sms_code_sent', + actorType: 'customer', + fromLoginStatus: task.login_status, + toLoginStatus: 'waiting_login', + fromExecutionStatus: task.execution_status, + toExecutionStatus: 'waiting_customer', + detail: { + method: 'sms', + verificationCompleted: !result.challenge, + }, + }) + }) + return { + ok: !result.challenge, + message: result.session.message || '验证码已发送,请注意查收', + expiresAt: result.session.expiresAt, + challenge: result.challenge, + } +} + +export async function loginPublicHuyaSms( + orderNoInput: unknown, + taskIdInput: unknown, + input: { mobile?: unknown; code?: unknown } = {}, +) { + const orderNo = normalizeOrderNo(orderNoInput) + const taskId = normalizeId(taskIdInput) + const task = await loadTaskForOrder(orderNo, taskId) + assertHuyaTask(task) + if (task.execution_status === 'completed' || task.execution_status === 'cancelled') { + throw createHttpError('当前订单已结束,不能重新登录', { + statusCode: 409, + errorCode: 'semi_auto_task_closed', + }) + } + if (!task.connector_session_id) { + throw createHttpError('请先发送虎牙短信验证码', { + statusCode: 409, + errorCode: 'huya_sms_code_required', + }) + } + + const session = await loginHuyaSms( + { + sessionId: task.connector_session_id, + taskId: task.id, + orderNo, + mobile: String(input.mobile ?? '').trim(), + code: String(input.code ?? '').trim(), + }, + task.connector_mode, + ) + const mapped = mapLoginUpdate(session) + if (session.status !== 'authenticated') { + await updateLoginTask(task, session, 'customer', { + credentialSource: 'sms', + method: 'sms', + }) + throw createHttpError(mapped.message || '虎牙短信登录失败,请重试', { + statusCode: 409, + errorCode: 'huya_login_failed', + }) + } + + await updateLoginTask(task, session, 'customer', { + credentialSource: 'sms', + method: 'sms', + }) + const updated = await loadTask(task.id) + return { + task: mapTask(updated), + login: { + status: mapped.status, + qrContent: '', + qrImage: '', + expiresAt: session.expiresAt, + message: session.message, + }, + } +} + export async function sendPublicWeiboSmsCode( orderNoInput: unknown, taskIdInput: unknown, diff --git a/apps/backend/src/services/semi-auto/semi-auto-task.integration.test.ts b/apps/backend/src/services/semi-auto/semi-auto-task.integration.test.ts index fe929e8c..7c21944b 100644 --- a/apps/backend/src/services/semi-auto/semi-auto-task.integration.test.ts +++ b/apps/backend/src/services/semi-auto/semi-auto-task.integration.test.ts @@ -594,3 +594,84 @@ test( } }, ) + +test( + '客户手动触发虎牙短信登录后保存 CK 并进入等待客服', + { skip: !TEST_DATABASE_URL ? '未设置 SEMI_AUTO_TASK_TEST_DATABASE_URL' : false }, + async () => { + process.env.DATABASE_URL = TEST_DATABASE_URL + process.env.ENABLE_DEV_MOCK = '1' + const { closeDb, query } = await import('../../db/client.js') + const { createSemiAutoMock } = await import('../dev-mock/dev-mock-service.js') + const { resetMockSessions } = await import('../dev-mock/mock-semi-auto-connector.js') + const { listPublicSemiAutoTasks, loginPublicHuyaSms, sendPublicHuyaSmsCode } = + await import('./semi-auto-task-service.js') + const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}` + const orderNo = `IT-HUYA-SMS-${suffix}` + let orderId = 0 + let configId = 0 + let credentialId = 0 + try { + const result = await createSemiAutoMock({ + orderNo, + skuName: `虎牙短信登录-${suffix}`, + quantity: 1, + connectorMode: 'mock', + }) + orderId = result.orderId + configId = result.configId + + const sent = await sendPublicHuyaSmsCode(orderNo, result.taskId, '13800138000') + assert.equal(sent.ok, true) + const waiting = await query<{ + connector_session_id: string + login_status: string + execution_status: string + }>( + `SELECT connector_session_id, login_status, execution_status + FROM semi_auto_tasks WHERE id = $1`, + [result.taskId], + ) + assert.ok(waiting.rows[0]?.connector_session_id) + assert.equal(waiting.rows[0]?.login_status, 'waiting_login') + assert.equal(waiting.rows[0]?.execution_status, 'waiting_customer') + + await loginPublicHuyaSms(orderNo, result.taskId, { + mobile: '13800138000', + code: '123456', + }) + const tasks = await listPublicSemiAutoTasks(orderNo) + assert.equal(tasks[0]?.loginStatus, 'authenticated') + assert.equal(tasks[0]?.executionStatus, 'waiting_operator') + assert.equal(tasks[0]?.credentialSource, 'sms') + + const stored = await query<{ + id: number + credential_source: string + credential_payload_json: string + }>( + `SELECT credential.id, credential.credential_source, + credential.credential_payload_json + FROM semi_auto_tasks task + JOIN semi_auto_credentials credential ON credential.id = task.credential_id + WHERE task.id = $1`, + [result.taskId], + ) + credentialId = Number(stored.rows[0]?.id || 0) + assert.ok(credentialId > 0) + assert.equal(stored.rows[0]?.credential_source, 'sms') + assert.match(stored.rows[0]?.credential_payload_json || '', /mock_huya_session/) + } finally { + if (orderId > 0) await query('DELETE FROM orders WHERE id = $1', [orderId]) + if (configId > 0) { + await query('DELETE FROM manual_fulfillment_configs WHERE id = $1', [configId]) + } + if (credentialId > 0) { + await query('DELETE FROM semi_auto_credentials WHERE id = $1', [credentialId]) + } + resetMockSessions() + delete process.env.ENABLE_DEV_MOCK + await closeDb() + } + }, +) diff --git a/apps/frontend/src/pages/customer-fulfillment/CustomerFulfillmentPage.tsx b/apps/frontend/src/pages/customer-fulfillment/CustomerFulfillmentPage.tsx index 15db20fd..3b1da320 100644 --- a/apps/frontend/src/pages/customer-fulfillment/CustomerFulfillmentPage.tsx +++ b/apps/frontend/src/pages/customer-fulfillment/CustomerFulfillmentPage.tsx @@ -12,17 +12,23 @@ import { useParams } from 'react-router' import { fetchCustomerFulfillment, + loginCustomerHuyaSms, loginCustomerWeiboSms, markCustomerFulfillmentOpened, pollCustomerLogin, pollCustomerWeiboQrLogin, + sendCustomerHuyaSmsCode, sendCustomerWeiboSmsCode, startCustomerLogin, startCustomerWeiboQrLogin, + verifyCustomerHuyaSmsChallenge, + type CustomerHuyaChallengeSolution, + type CustomerHuyaSmsChallenge, } from '@/services/customer-fulfillment' import type { CustomerLoginInfo, CustomerSemiAutoTask } from '@/types/customer-fulfillment' import { formatAdminDateTime } from '@/utils/admin-time' import { runWeiboYidunChallenge } from '@/utils/weibo-yidun' +import HuyaChallengeModal from './HuyaChallengeModal' const POLL_INTERVAL_MS = 2000 @@ -43,12 +49,15 @@ export default function CustomerFulfillmentPage() { const [smsLogging, setSmsLogging] = useState(false) const [smsCountdown, setSmsCountdown] = useState(0) const [smsMfaRequired, setSmsMfaRequired] = useState(false) + const [huyaChallenge, setHuyaChallenge] = useState(null) + const [huyaChallengeLoading, setHuyaChallengeLoading] = useState(false) const selectedTask = useMemo( () => tasks.find((task) => task.id === selectedTaskId) || tasks[0] || null, [selectedTaskId, tasks], ) const isWeibo = selectedTask?.platform === 'weibo' + const isHuya = selectedTask?.platform === 'huya' const load = useCallback( async (silent = false) => { @@ -83,6 +92,16 @@ export default function CustomerFulfillmentPage() { void load() }, [load]) + useEffect(() => { + if (!selectedTask) return + setLoginMode('sms') + setMobile('') + setSmsCode('') + setSmsMfaRequired(false) + setSmsCountdown(0) + setHuyaChallenge(null) + }, [selectedTask?.id]) + useEffect(() => { if (!selectedTask) return void markCustomerFulfillmentOpened(orderNo, selectedTask.id).catch(() => undefined) @@ -109,6 +128,7 @@ export default function CustomerFulfillmentPage() { setSmsCode('') setSmsMfaRequired(false) setSmsCountdown(0) + setHuyaChallenge(null) }, [selectedTask?.id, selectedTask?.loginStatus]) useEffect(() => { @@ -177,66 +197,83 @@ export default function CustomerFulfillmentPage() { } } - async function handleSendWeiboSms() { + async function handleSendSms() { if (!selectedTask) return const phone = mobile.trim() - if (!isValidWeiboMobile(phone)) { + if (!isValidMainlandMobile(phone)) { message.warning('请输入正确的手机号') return } setSmsSending(true) try { - let response = await sendCustomerWeiboSmsCode(orderNo, selectedTask.id, phone, '86') - if (response.data.mfaRequired) { - setSmsMfaRequired(true) - if (!response.data.mfaId || !response.data.captchaId) { - throw new Error('微博返回的安全验证参数不完整') - } - await runWeiboYidunChallenge(response.data.mfaId, response.data.captchaId) - response = await sendCustomerWeiboSmsCode( - orderNo, - selectedTask.id, - phone, - '86', - response.data.mfaId, - ) + if (selectedTask.platform === 'weibo') { + let response = await sendCustomerWeiboSmsCode(orderNo, selectedTask.id, phone, '86') if (response.data.mfaRequired) { - throw new Error('微博安全验证未通过,请重新获取验证码') + setSmsMfaRequired(true) + if (!response.data.mfaId || !response.data.captchaId) { + throw new Error('微博返回的安全验证参数不完整') + } + await runWeiboYidunChallenge(response.data.mfaId, response.data.captchaId) + response = await sendCustomerWeiboSmsCode( + orderNo, + selectedTask.id, + phone, + '86', + response.data.mfaId, + ) + if (response.data.mfaRequired) { + throw new Error('微博安全验证未通过,请重新获取验证码') + } } + message.success(response.data.message || '验证码已发送,请注意查收') + } else { + const response = await sendCustomerHuyaSmsCode(orderNo, selectedTask.id, phone) + if (response.data.challenge) { + setHuyaChallenge(response.data.challenge) + message.info('请先完成滑块安全验证') + return + } + message.success(response.data.message || '验证码已发送,请注意查收') } - message.success(response.data.message || '验证码已发送,请注意查收') setSmsMfaRequired(false) setSmsCountdown(60) } catch (error) { - message.error(error instanceof Error ? error.message : '发送微博验证码失败') + message.error(error instanceof Error ? error.message : '发送验证码失败') } finally { setSmsSending(false) } } - async function handleLoginWeiboSms() { + async function handleLoginSms() { if (!selectedTask) return const phone = mobile.trim() - if (!isValidWeiboMobile(phone)) { + if (!isValidMainlandMobile(phone)) { message.warning('请输入正确的手机号') return } if (!smsCode.trim()) { - message.warning('请输入微博短信验证码') + message.warning('请输入短信验证码') return } setSmsLogging(true) try { - const response = await loginCustomerWeiboSms(orderNo, selectedTask.id, { - mobile: phone, - code: smsCode.trim(), - countryCode: '86', - }) + const response = + selectedTask.platform === 'weibo' + ? await loginCustomerWeiboSms(orderNo, selectedTask.id, { + mobile: phone, + code: smsCode.trim(), + countryCode: '86', + }) + : await loginCustomerHuyaSms(orderNo, selectedTask.id, { + mobile: phone, + code: smsCode.trim(), + }) setTasks((current) => current.map((task) => (task.id === response.data.task.id ? response.data.task : task)), ) setLoginInfo(response.data.login) - message.success('微博登录成功,等待客服处理') + setHuyaChallenge(null) + message.success(`${isWeibo ? '微博' : '虎牙'}登录成功,等待客服处理`) } catch (error) { const errorCode = (error as { errorCode?: string } | null)?.errorCode || '' if (errorCode === 'weibo_sms_mfa_required') { @@ -244,12 +281,32 @@ export default function CustomerFulfillmentPage() { message.warning('微博要求二次验证,请改用扫码登录') return } - message.error(error instanceof Error ? error.message : '微博短信登录失败') + message.error(error instanceof Error ? error.message : '短信登录失败') } finally { setSmsLogging(false) } } + async function handleVerifyHuyaChallenge(solution: CustomerHuyaChallengeSolution) { + if (!selectedTask || !huyaChallenge) return + setHuyaChallengeLoading(true) + try { + const response = await verifyCustomerHuyaSmsChallenge(orderNo, selectedTask.id, solution) + if (response.data.challenge) { + setHuyaChallenge(response.data.challenge) + message.warning('安全验证未通过,请重新完成') + return + } + setHuyaChallenge(null) + setSmsCountdown(60) + message.success(response.data.message || '验证码已发送,请注意查收') + } catch (error) { + message.error(error instanceof Error ? error.message : '虎牙安全验证失败') + } finally { + setHuyaChallengeLoading(false) + } + } + const config = selectedTask?.semiAutoConfig || {} const displayTitle = isWeibo ? '微博账号登录授权' : '虎牙账号登录授权' const loginQrVisible = @@ -332,96 +389,88 @@ export default function CustomerFulfillmentPage() { selectedTask?.loginStatus === 'failed' || selectedTask?.loginStatus === 'expired' ? (
- {isWeibo ? ( - setLoginMode(key as 'sms' | 'qr')} - items={[ - { - key: 'sms', - label: '手机号登录', - children: ( -
-
- +86 - setMobile(event.target.value)} - placeholder="请输入微博绑定手机号" - maxLength={11} - inputMode="numeric" - /> - -
- {smsMfaRequired ? ( -
- 请在安全验证弹窗中完成验证,通过后会继续发送短信验证码。 -
- ) : null} -
- setSmsCode(event.target.value)} - placeholder="请输入短信验证码" - maxLength={8} - prefix={} - onPressEnter={() => void handleLoginWeiboSms()} - /> - + setLoginMode(key as 'sms' | 'qr')} + items={[ + { + key: 'sms', + label: '手机号登录', + children: ( +
+
+ +86 + setMobile(event.target.value)} + placeholder={`请输入${isHuya ? '虎牙' : '微博'}绑定手机号`} + maxLength={11} + inputMode="numeric" + /> + +
+ {isWeibo && smsMfaRequired ? ( +
+ 请在安全验证弹窗中完成验证,通过后会继续发送短信验证码。
+ ) : null} +
+ setSmsCode(event.target.value)} + placeholder="请输入短信验证码" + maxLength={8} + prefix={} + onPressEnter={() => void handleLoginSms()} + /> + +
+ {isWeibo ? ( 如安全验证弹窗未出现,请检查浏览器是否拦截脚本。 -
- ), - }, - { - key: 'qr', - label: '扫码登录', - children: ( -
-

- 点击下方按钮后,使用微博 App 扫码并确认登录。 -

- -
- ), - }, - ]} - /> - ) : ( -
-

- {config.loginInstructions || '使用虎牙 App 扫码登录,请确认扫码账号属于本人。'} -

- -
- )} + ) : null} +
+ ), + }, + { + key: 'qr', + label: '扫码登录', + children: ( +
+

+ {config.loginInstructions || + `点击下方按钮后,使用${isHuya ? '虎牙' : '微博'} App 扫码并确认登录。`} +

+ +
+ ), + }, + ]} + />
) : null} @@ -486,6 +535,16 @@ export default function CustomerFulfillmentPage() { ) : null} + { + if (!huyaChallengeLoading) setHuyaChallenge(null) + }} + onSubmit={(solution) => void handleVerifyHuyaChallenge(solution)} + /> + @@ -576,7 +635,7 @@ function CustomerPrivacyNote() { ) } -function isValidWeiboMobile(value: string) { +function isValidMainlandMobile(value: string) { const digits = value.replace(/\D/g, '') if (!digits) return false return /^1\d{10}$/.test(digits) diff --git a/apps/frontend/src/pages/customer-fulfillment/HuyaChallengeModal.tsx b/apps/frontend/src/pages/customer-fulfillment/HuyaChallengeModal.tsx new file mode 100644 index 00000000..66e8794d --- /dev/null +++ b/apps/frontend/src/pages/customer-fulfillment/HuyaChallengeModal.tsx @@ -0,0 +1,244 @@ +import { Button, Modal, Slider } from 'antd' +import { + useEffect, + useMemo, + useRef, + useState, + type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, +} from 'react' + +import type { + CustomerHuyaChallengeSolution, + CustomerHuyaSmsChallenge, +} from '@/services/customer-fulfillment' + +type Props = { + open: boolean + challenge: CustomerHuyaSmsChallenge | null + loading: boolean + onCancel: () => void + onSubmit: (solution: CustomerHuyaChallengeSolution) => void +} + +const IMAGE_LEFT = 10 + +export default function HuyaChallengeModal(input: Props) { + const [containerWidth, setContainerWidth] = useState(0) + const [offset, setOffset] = useState(0) + const [angle, setAngle] = useState(0) + const [clickPoints, setClickPoints] = useState>([]) + const [dragging, setDragging] = useState(false) + const containerRef = useRef(null) + const dragRef = useRef({ startX: 0, startOffset: 0 }) + + useEffect(() => { + if (!input.open) return + setOffset(0) + setAngle(0) + setClickPoints([]) + setDragging(false) + const updateWidth = () => { + setContainerWidth(containerRef.current?.clientWidth || 0) + } + updateWidth() + const observer = new ResizeObserver(updateWidth) + if (containerRef.current) observer.observe(containerRef.current) + window.addEventListener('resize', updateWidth) + return () => { + observer.disconnect() + window.removeEventListener('resize', updateWidth) + } + }, [input.open, input.challenge?.image, input.challenge?.type]) + + const challenge = input.challenge + const scale = challenge && containerWidth > 0 ? containerWidth / challenge.width : 1 + const maxOffset = useMemo(() => { + if (!challenge || challenge.type !== 'slider') return 0 + return Math.max(0, (challenge.width - IMAGE_LEFT - Math.max(1, challenge.puzzleWidth)) * scale) + }, [challenge, scale]) + const distance = Math.round(IMAGE_LEFT + offset / Math.max(scale, 0.0001) || 0) + const clickLimit = challenge?.type === 'click' ? Math.max(1, challenge.wordSize) : 0 + const canSubmit = + challenge?.type === 'slider' + ? offset >= 1 + : challenge?.type === 'click' + ? clickPoints.length > 0 + : Boolean(challenge) + + function handlePointerDown(event: ReactPointerEvent) { + if (input.loading) return + event.currentTarget.setPointerCapture(event.pointerId) + dragRef.current = { startX: event.clientX, startOffset: offset } + setDragging(true) + } + + function handlePointerMove(event: ReactPointerEvent) { + if (!dragging || input.loading) return + const next = dragRef.current.startOffset + event.clientX - dragRef.current.startX + setOffset(Math.max(0, Math.min(maxOffset, next))) + } + + function finishDrag(event: ReactPointerEvent) { + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId) + } + setDragging(false) + } + + function handleStageClick(event: ReactMouseEvent) { + if (!challenge || challenge.type !== 'click' || input.loading) return + const rect = event.currentTarget.getBoundingClientRect() + const x = Math.max(0, Math.min(challenge.width, (event.clientX - rect.left) / scale)) + const y = Math.max(0, Math.min(challenge.height, (event.clientY - rect.top) / scale)) + setClickPoints((current) => { + if (current.length >= clickLimit) return [{ x, y }] + return [...current, { x, y }] + }) + } + + function submit() { + if (!challenge) return + if (challenge.type === 'slider') { + input.onSubmit({ distance }) + return + } + if (challenge.type === 'rotate') { + input.onSubmit({ angle }) + return + } + input.onSubmit({ points: clickPoints }) + } + + return ( + + 取消 + , + , + ]} + > +
+ {challenge?.type === 'slider' ?

拖动下方滑块,将拼图对齐到缺口后发送验证码。

: null} + {challenge?.type === 'rotate' ? ( +

拖动角度滑块,把图片方向调整正确后发送验证码。

+ ) : null} + {challenge?.type === 'click' ? ( +

+ {challenge.question || `请按顺序点击图中的 ${clickLimit} 个目标`} + + 已选择 {clickPoints.length}/{clickLimit} + +

+ ) : null} + + {challenge ? ( + <> + {challenge.type === 'click' && challenge.tipImage ? ( + + ) : null} +
+ + {challenge.type === 'slider' ? ( + + ) : null} + {challenge.type === 'rotate' ? ( + + ) : null} + {challenge.type === 'click' + ? clickPoints.map((point, index) => ( + + {index + 1} + + )) + : null} +
+ + {challenge.type === 'slider' ? ( +
+
+ +
+ ) : null} + + {challenge.type === 'rotate' ? ( +
+ + {angle}° +
+ ) : null} + + {challenge.type === 'click' && clickPoints.length > 0 ? ( + + ) : null} + + ) : null} +
+ + ) +} diff --git a/apps/frontend/src/services/customer-fulfillment.ts b/apps/frontend/src/services/customer-fulfillment.ts index ef2bded2..b160b86f 100644 --- a/apps/frontend/src/services/customer-fulfillment.ts +++ b/apps/frontend/src/services/customer-fulfillment.ts @@ -27,6 +27,76 @@ export function pollCustomerLogin(orderNo: string, taskId: number) { ) } +type CustomerHuyaChallengeBase = { + image: string + width: number + height: number +} + +export type CustomerHuyaSmsChallenge = + | (CustomerHuyaChallengeBase & { + type: 'slider' + puzzleImage: string + puzzleWidth: number + puzzleY: number + }) + | (CustomerHuyaChallengeBase & { + type: 'rotate' + puzzleImage: string + puzzleWidth: number + puzzleHeight: number + puzzleX: number + puzzleY: number + }) + | (CustomerHuyaChallengeBase & { + type: 'click' + tipImage: string + question: string + wordSize: number + }) + +export type CustomerHuyaChallengeSolution = { + distance?: number + angle?: number + points?: Array<{ x: number; y: number }> +} + +export type CustomerHuyaSmsSendResult = { + ok: boolean + message: string + expiresAt: string + challenge: CustomerHuyaSmsChallenge | null +} + +export function sendCustomerHuyaSmsCode(orderNo: string, taskId: number, mobile: string) { + return apiPost( + `/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/huya/sms/send`, + { mobile }, + ) +} + +export function verifyCustomerHuyaSmsChallenge( + orderNo: string, + taskId: number, + solution: CustomerHuyaChallengeSolution, +) { + return apiPost( + `/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/huya/sms/verify`, + solution, + ) +} + +export function loginCustomerHuyaSms( + orderNo: string, + taskId: number, + input: { mobile: string; code: string }, +) { + return apiPost<{ task: CustomerSemiAutoTask; login: CustomerLoginInfo }>( + `/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/huya/sms/login`, + input, + ) +} + export type CustomerWeiboCountryCode = { code: string name: string diff --git a/apps/frontend/src/styles/client.css b/apps/frontend/src/styles/client.css index d1292bee..ede4a554 100644 --- a/apps/frontend/src/styles/client.css +++ b/apps/frontend/src/styles/client.css @@ -1885,6 +1885,141 @@ background: rgba(31, 55, 102, 0.72) !important; } +.huya-slider-challenge { + display: flex; + flex-direction: column; + gap: 14px; +} + +.huya-slider-challenge > p { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 6px 12px; + margin: 0; + color: #475569; + font-size: 14px; + line-height: 1.65; +} + +.huya-challenge-progress { + color: #c2410c; + font-size: 12px; + font-weight: 700; +} + +.huya-click-tip { + display: block; + max-width: 100%; + max-height: 64px; + object-fit: contain; +} + +.huya-slider-stage { + position: relative; + width: 100%; + overflow: hidden; + border: 1px solid rgba(148, 163, 184, 0.36); + border-radius: 8px; + background: #e2e8f0; + user-select: none; +} + +.huya-slider-stage.is-click { + cursor: crosshair; +} + +.huya-slider-background, +.huya-slider-puzzle { + pointer-events: none; + user-select: none; +} + +.huya-slider-puzzle.is-rotate { + transform-origin: center; +} + +.huya-click-point { + position: absolute; + display: grid; + width: 24px; + height: 24px; + place-items: center; + color: #fff; + font-size: 12px; + font-weight: 700; + border: 2px solid #fff; + border-radius: 50%; + background: #ea580c; + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.32); + transform: translate(-50%, -50%); + pointer-events: none; +} + +.huya-slider-background { + display: block; + width: 100%; + height: 100%; +} + +.huya-slider-puzzle { + position: absolute; + max-width: none; + height: auto; +} + +.huya-slider-track { + position: relative; + height: 46px; + overflow: hidden; + border: 1px solid rgba(148, 163, 184, 0.36); + border-radius: 8px; + background: #f1f5f9; + touch-action: none; +} + +.huya-slider-track-fill { + position: absolute; + inset: 0 auto 0 0; + max-width: 100%; + background: rgba(249, 115, 22, 0.16); +} + +.huya-slider-handle { + position: absolute; + top: 3px; + left: 3px; + width: 58px; + height: 38px; + padding: 0; + color: #111827; + font-size: 13px; + font-weight: 700; + border: 0; + border-radius: 6px; + background: #f97316; + cursor: grab; + touch-action: none; +} + +.huya-slider-handle:active { + cursor: grabbing; +} + +.huya-rotate-control { + display: grid; + grid-template-columns: minmax(0, 1fr) 48px; + gap: 12px; + align-items: center; +} + +.huya-rotate-control > span { + color: #475569; + font-size: 13px; + font-variant-numeric: tabular-nums; + text-align: right; +} + .customer-auth-qr { display: flex; flex-direction: column; diff --git a/docs/统一订单履约与财务模型-权威版.md b/docs/统一订单履约与财务模型-权威版.md index ffbdc203..0e585feb 100644 --- a/docs/统一订单履约与财务模型-权威版.md +++ b/docs/统一订单履约与财务模型-权威版.md @@ -3,14 +3,14 @@ type: 权威架构与实施计划 description: 订单、内部 SKU、履约路由、履约执行、商品、成本与财务的唯一权威口径及重构计划。 tags: [订单, 内部SKU, 履约, 商品, 成本, 财务, 迁移] status: authoritative -version: 2.11 -updated_at: 2026-11-13 +version: 2.14 +updated_at: 2026-11-14 --- # 统一订单、履约与财务模型 > 状态:**唯一权威文档**。 -> 最后校准:2026-11-13。 +> 最后校准:2026-11-14。 > 生产基线:迁移 `151`。 > 开发基线:迁移 `161`(仅本地,尚未部署生产)。 > @@ -312,9 +312,15 @@ manual fulfillment `embedded` 为 order_site 内置真实实现并作为默认值,`external` 为显式配置的远程真实实现, `mock` 为 order_site 内置开发 Mock 且仅在非生产环境可用。开发 Mock 造单时默认选择 `embedded`, 只有需要完全脱离虎牙接口联调页面时才显式选择 `mock`。三种模式都必须能导出统一 Cookie JSON。 -- 虎牙二维码登录有效期 `5` 分钟;登录成功后的 Connector 内存会话有效期 `24` 小时, - 账号级 CK 由内部凭证表保存并通过健康检查判定有效性;浏览器一次性注入票据 `2` 分钟且消费后立即失效。 - 重新登录前必须撤销旧 Connector 会话。 +- 虎牙默认使用手机号短信验证码登录,扫码登录作为同页可选方式。短信验证码只能在客户点击 + “发送验证码”后触发,页面不得在加载、轮询或切换 Tab 时自动发码;短信登录会话有效期 `5` 分钟。 + 登录成功后的 Connector 内存会话有效期 `24` 小时,账号级 CK 由内部凭证表保存并通过健康检查判定有效性; + 浏览器一次性注入票据 `2` 分钟且消费后立即失效。重新登录前必须撤销旧 Connector 会话。 + 虎牙发码触发图形安全验证时,应由客户在 order_site 页面完成:`pt_auth` 使用滑块拼图, + `dx_auth` 使用按序点选,`rotate_auth` 使用旋转调整。服务端只接收客户最终位置、角度或点击坐标, + 生成对应交互轨迹并重试发码,不自动识别、伪造或绕过平台风控。发码原始响应必须记录到集成日志, + 但日志系统必须继续屏蔽 Cookie、令牌、验证码等敏感字段。`qr_auth` 和二次短信验证必须明确提示, + 不得伪装成可完成的图形验证。 - 虎牙客户登录成功后,无论账号此前是否已有角色,都立即保存 CK 并进入等待客服处理。 角色绑定、换绑和角色状态查询在后续独立流程实现,不能阻塞当前 CK 获取。 - 浏览器扩展复用 live-hub-py 的 `web/extension/huya-exchange-helper`,不新增第二个扩展; @@ -968,6 +974,7 @@ docs/archive/ 4. 执行 `finance:rebuild-derived` 的 dry-run,核对 A 级数据守恒后, 再按窗口执行 `--execute --confirm=REBUILD_DERIVED`,并验证第二次重建幂等。 5. 观察一个完整财务期间,确认页面、履约、事实和总账持续对平。 -6. 半自动平台当前只负责客户登录和账号 CK 获取;继续完善微博手机号登录、易盾二次验证、 - 完整域名 Cookie 保存和会话恢复。角色绑定与换绑后续独立设计,不阻塞 CK 获取和客服处理。 +6. 半自动平台当前只负责客户登录和账号 CK 获取;微博支持手机号登录、易盾二次验证、 + 完整域名 Cookie 保存和会话恢复;虎牙支持客户手动触发的短信验证码登录和扫码登录。 + 角色绑定与换绑后续独立设计,不阻塞 CK 获取和客服处理。 7. 观察期结束后,再单独发布兼容表、旧脚本别名和 `legacy_*` 字段的删除迁移。