完善虎牙短信登录与图形安全验证
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user