完善虎牙短信登录与图形安全验证

This commit is contained in:
yml
2026-11-14 02:11:23 +08:00
parent 9d738becd3
commit 2c2dc26a93
17 changed files with 2914 additions and 148 deletions
@@ -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,
@@ -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)),
@@ -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,
@@ -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)
})
@@ -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',
@@ -45,6 +45,99 @@ test('创建登录会话按真实 Connector 协议发送蛇形字段', async ()
}
})
test('虎牙短信发码和登录向外部 Connector 传递完整任务上下文', async () => {
const originalFetch = globalThis.fetch
const requests: Array<{ url: string; body: Record<string, unknown> }> = []
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
requests.push({
url: String(url),
body: JSON.parse(String(init?.body || '{}')) as Record<string, unknown>,
})
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<string, unknown> = {}
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
requestUrl = String(url)
requestBody = JSON.parse(String(init?.body || '{}')) as Record<string, unknown>
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 = ''
@@ -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<HuyaSmsSendResult> {
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<HuyaSmsSendResult | ConnectorLoginSession>(
'/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<HuyaSmsSendResult> {
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<HuyaSmsSendResult>(
'/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<ConnectorLoginSession> {
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<ConnectorLoginSession>(
'/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(),
@@ -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<string, string>
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<Record<string, unknown>> = [
{
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<HuyaSmsSendResult> {
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<HuyaSmsSendResult> {
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<ConnectorLoginSession> {
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<string, unknown>): Promise<void> {
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) : '',
@@ -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('<html></html>', {
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('<html></html>', { 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('<html></html>', { 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')
})
@@ -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<string, unknown>
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<string, string>
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<string, string> {
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<void> {
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<void> {
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<HuyaSmsResponse> {
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<HuyaVerificationChallenge> {
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<ArrayBufferLike> = 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<void> {
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<void> {
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<string, unknown>).data)
: ''
}
private async requestConfig3(
urlParamMap: Record<string, string>,
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<string, unknown>) {
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<string, unknown>; 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<string, unknown>
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<string> {
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<string>()
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<string, unknown>,
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<unknown>)
.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
}
}
@@ -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<ConnectorLoginSession['status'], LoginStatus> = {
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<string, unknown>) : {}
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,
@@ -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()
}
},
)
@@ -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<CustomerHuyaSmsChallenge | null>(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' ? (
<section className="customer-auth-section">
{isWeibo ? (
<Tabs
className="customer-auth-tabs"
activeKey={loginMode}
onChange={(key) => setLoginMode(key as 'sms' | 'qr')}
items={[
{
key: 'sms',
label: '手机号登录',
children: (
<div className="customer-auth-form">
<div className="customer-auth-field customer-auth-phone-field">
<span className="customer-auth-country-prefix">+86</span>
<Input
value={mobile}
onChange={(event) => setMobile(event.target.value)}
placeholder="请输入微博绑定手机号"
maxLength={11}
inputMode="numeric"
/>
<Button
className="customer-auth-send-code"
type="primary"
loading={smsSending}
disabled={smsCountdown > 0}
onClick={() => void handleSendWeiboSms()}
>
{smsCountdown > 0 ? `${smsCountdown}s` : '发送验证码'}
</Button>
</div>
{smsMfaRequired ? (
<div className="customer-auth-note">
请在安全验证弹窗中完成验证,通过后会继续发送短信验证码。
</div>
) : null}
<div className="customer-auth-field customer-auth-code-field">
<Input
value={smsCode}
onChange={(event) => setSmsCode(event.target.value)}
placeholder="请输入短信验证码"
maxLength={8}
prefix={<SafetyCertificateOutlined />}
onPressEnter={() => void handleLoginWeiboSms()}
/>
<Button
type="primary"
className="customer-auth-code-submit"
loading={smsLogging}
onClick={() => void handleLoginWeiboSms()}
>
登录微博
</Button>
<Tabs
className="customer-auth-tabs"
activeKey={loginMode}
onChange={(key) => setLoginMode(key as 'sms' | 'qr')}
items={[
{
key: 'sms',
label: '手机号登录',
children: (
<div className="customer-auth-form">
<div className="customer-auth-field customer-auth-phone-field">
<span className="customer-auth-country-prefix">+86</span>
<Input
value={mobile}
onChange={(event) => setMobile(event.target.value)}
placeholder={`请输入${isHuya ? '虎牙' : '微博'}绑定手机号`}
maxLength={11}
inputMode="numeric"
/>
<Button
className="customer-auth-send-code"
type="primary"
loading={smsSending}
disabled={smsCountdown > 0}
onClick={() => void handleSendSms()}
>
{smsCountdown > 0 ? `${smsCountdown}s` : '发送验证码'}
</Button>
</div>
{isWeibo && smsMfaRequired ? (
<div className="customer-auth-note">
请在安全验证弹窗中完成验证,通过后会继续发送短信验证码。
</div>
) : null}
<div className="customer-auth-field customer-auth-code-field">
<Input
value={smsCode}
onChange={(event) => setSmsCode(event.target.value)}
placeholder="请输入短信验证码"
maxLength={8}
prefix={<SafetyCertificateOutlined />}
onPressEnter={() => void handleLoginSms()}
/>
<Button
type="primary"
className="customer-auth-code-submit"
loading={smsLogging}
onClick={() => void handleLoginSms()}
>
登录{isHuya ? '虎牙' : '微博'}
</Button>
</div>
{isWeibo ? (
<span className="customer-auth-form-hint">
如安全验证弹窗未出现,请检查浏览器是否拦截脚本。
</span>
</div>
),
},
{
key: 'qr',
label: '扫码登录',
children: (
<div className="customer-auth-form">
<p className="customer-auth-form-copy">
点击下方按钮后,使用微博 App 扫码并确认登录。
</p>
<Button
type="primary"
size="large"
block
onClick={() => void handleStartLogin()}
>
获取登录二维码
</Button>
</div>
),
},
]}
/>
) : (
<div className="customer-auth-form">
<p className="customer-auth-form-copy">
{config.loginInstructions || '使用虎牙 App 扫码登录,请确认扫码账号属于本人。'}
</p>
<Button type="primary" size="large" block onClick={() => void handleStartLogin()}>
获取登录二维码
</Button>
</div>
)}
) : null}
</div>
),
},
{
key: 'qr',
label: '扫码登录',
children: (
<div className="customer-auth-form">
<p className="customer-auth-form-copy">
{config.loginInstructions ||
`点击下方按钮后,使用${isHuya ? '虎牙' : '微博'} App 扫码并确认登录。`}
</p>
<Button
type="primary"
size="large"
block
onClick={() => void handleStartLogin()}
>
获取登录二维码
</Button>
</div>
),
},
]}
/>
</section>
) : null}
@@ -486,6 +535,16 @@ export default function CustomerFulfillmentPage() {
</div>
) : null}
<HuyaChallengeModal
open={Boolean(huyaChallenge)}
challenge={huyaChallenge}
loading={huyaChallengeLoading}
onCancel={() => {
if (!huyaChallengeLoading) setHuyaChallenge(null)
}}
onSubmit={(solution) => void handleVerifyHuyaChallenge(solution)}
/>
<CustomerPrivacyNote />
</div>
</CustomerAuthFrame>
@@ -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)
@@ -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<Array<{ x: number; y: number }>>([])
const [dragging, setDragging] = useState(false)
const containerRef = useRef<HTMLDivElement | null>(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<HTMLButtonElement>) {
if (input.loading) return
event.currentTarget.setPointerCapture(event.pointerId)
dragRef.current = { startX: event.clientX, startOffset: offset }
setDragging(true)
}
function handlePointerMove(event: ReactPointerEvent<HTMLButtonElement>) {
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<HTMLButtonElement>) {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
setDragging(false)
}
function handleStageClick(event: ReactMouseEvent<HTMLDivElement>) {
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 (
<Modal
open={input.open}
title="完成虎牙安全验证"
width={430}
centered
maskClosable={false}
keyboard={!input.loading}
closable={!input.loading}
onCancel={input.onCancel}
footer={[
<Button key="cancel" disabled={input.loading} onClick={input.onCancel}>
取消
</Button>,
<Button
key="submit"
type="primary"
loading={input.loading}
disabled={!challenge || !canSubmit}
onClick={submit}
>
完成验证并发送验证码
</Button>,
]}
>
<div className="huya-slider-challenge">
{challenge?.type === 'slider' ? <p>拖动下方滑块,将拼图对齐到缺口后发送验证码。</p> : null}
{challenge?.type === 'rotate' ? (
<p>拖动角度滑块,把图片方向调整正确后发送验证码。</p>
) : null}
{challenge?.type === 'click' ? (
<p>
{challenge.question || `请按顺序点击图中的 ${clickLimit} 个目标`}
<span className="huya-challenge-progress">
已选择 {clickPoints.length}/{clickLimit}
</span>
</p>
) : null}
{challenge ? (
<>
{challenge.type === 'click' && challenge.tipImage ? (
<img className="huya-click-tip" src={challenge.tipImage} alt="" draggable={false} />
) : null}
<div
ref={containerRef}
className={`huya-slider-stage ${challenge.type === 'click' ? 'is-click' : ''}`}
style={{ aspectRatio: `${challenge.width} / ${challenge.height}` }}
onClick={handleStageClick}
>
<img
className="huya-slider-background"
src={challenge.image}
alt=""
draggable={false}
/>
{challenge.type === 'slider' ? (
<img
className="huya-slider-puzzle"
src={challenge.puzzleImage}
alt=""
draggable={false}
style={{
left: IMAGE_LEFT * scale + offset,
top: challenge.puzzleY * scale,
width: Math.max(1, challenge.puzzleWidth) * scale,
}}
/>
) : null}
{challenge.type === 'rotate' ? (
<img
className="huya-slider-puzzle is-rotate"
src={challenge.puzzleImage}
alt=""
draggable={false}
style={{
left: challenge.puzzleX * scale,
top: challenge.puzzleY * scale,
width: challenge.puzzleWidth * scale,
height: challenge.puzzleHeight * scale,
transform: `rotate(${angle}deg)`,
}}
/>
) : null}
{challenge.type === 'click'
? clickPoints.map((point, index) => (
<span
key={`${point.x}-${point.y}-${index}`}
className="huya-click-point"
style={{ left: point.x * scale, top: point.y * scale }}
>
{index + 1}
</span>
))
: null}
</div>
{challenge.type === 'slider' ? (
<div className="huya-slider-track">
<div className="huya-slider-track-fill" style={{ width: offset + 28 }} />
<button
type="button"
className="huya-slider-handle"
style={{ transform: `translateX(${offset}px)` }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={finishDrag}
onPointerCancel={finishDrag}
>
拖动
</button>
</div>
) : null}
{challenge.type === 'rotate' ? (
<div className="huya-rotate-control">
<Slider min={0} max={359} value={angle} onChange={setAngle} />
<span>{angle}°</span>
</div>
) : null}
{challenge.type === 'click' && clickPoints.length > 0 ? (
<Button size="small" onClick={() => setClickPoints([])}>
重新点选
</Button>
) : null}
</>
) : null}
</div>
</Modal>
)
}
@@ -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<CustomerHuyaSmsSendResult>(
`/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/huya/sms/send`,
{ mobile },
)
}
export function verifyCustomerHuyaSmsChallenge(
orderNo: string,
taskId: number,
solution: CustomerHuyaChallengeSolution,
) {
return apiPost<CustomerHuyaSmsSendResult>(
`/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
+135
View File
@@ -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;