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

This commit is contained in:
yml
2026-11-14 02:11:23 +08:00
parent 9d738becd3
commit 2c2dc26a93
17 changed files with 2914 additions and 148 deletions
@@ -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()
}
},
)