diff --git a/apps/backend/src/services/semi-auto/huya-native/huya-native-connector.ts b/apps/backend/src/services/semi-auto/huya-native/huya-native-connector.ts
index e4085d9c..5808ef60 100644
--- a/apps/backend/src/services/semi-auto/huya-native/huya-native-connector.ts
+++ b/apps/backend/src/services/semi-auto/huya-native/huya-native-connector.ts
@@ -400,20 +400,56 @@ export class HuyaNativeConnector {
await sms.bootstrapMiddle()
const device = sms.device()
const behavior = smsBehavior()
- const response = await sms.call('/web/v2/smsCode', '60027', {
+ let 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',
+ let challenge: HuyaSmsChallenge | null = null
+ let sessionData = ''
+ for (let autoAttempt = 0; ; autoAttempt += 1) {
+ logWarn('huya/sms/raw-response', '虎牙短信发码原始响应', response)
+ const returnCode = extractInt(response.returnCode)
+ if (returnCode === 0) {
+ sessionData = readString(response.data?.sessionData)
+ challenge = null
+ break
+ }
+ if (returnCode !== 10030 && returnCode !== 10039) {
+ throw createHttpError('虎牙短信验证码发送失败,请稍后重试', {
+ statusCode: 502,
+ errorCode: 'huya_sms_send_failed',
+ })
+ }
+ challenge = await sms.prepareChallenge(response)
+ if (challenge.type !== 'slider' || autoAttempt >= 2) break
+ const detection = await sms.detectSliderDistance()
+ if (!detection) {
+ logWarn('huya/sms/slider-auto', '虎牙滑块自动识别置信度不足,转客户手动验证', {
+ autoAttempt: autoAttempt + 1,
+ })
+ break
+ }
+ try {
+ await sms.solveChallenge({ distance: detection.distance })
+ logWarn('huya/sms/slider-auto', '虎牙滑块自动验证成功,重试发码', {
+ autoAttempt: autoAttempt + 1,
+ distance: detection.distance,
+ score: Number(detection.score.toFixed(4)),
+ })
+ } catch (error) {
+ logWarn('huya/sms/slider-auto', '虎牙滑块自动验证未通过,转客户手动验证', {
+ autoAttempt: autoAttempt + 1,
+ distance: detection.distance,
+ score: Number(detection.score.toFixed(4)),
+ message: error instanceof Error ? error.message : String(error),
+ })
+ break
+ }
+ response = await sms.call('/web/v2/smsCode', '60027', {
+ phone,
+ behavior: behavior.behavior,
+ page: behavior.page,
})
}
if (!challenge && !sessionData) {
diff --git a/apps/backend/src/services/semi-auto/huya-native/huya-native.test.ts b/apps/backend/src/services/semi-auto/huya-native/huya-native.test.ts
index d4ccc2f6..e9d791ae 100644
--- a/apps/backend/src/services/semi-auto/huya-native/huya-native.test.ts
+++ b/apps/backend/src/services/semi-auto/huya-native/huya-native.test.ts
@@ -6,6 +6,7 @@ import sharp from 'sharp'
import { buildHuyaLoginContext, ensureHuyaWebCookieIdentity, x64hash128 } from './cookie.js'
import { HuyaNativeConnector } from './huya-native-connector.js'
+import { detectHuyaSliderDistance } from './huya-sms-role.js'
import { hasExistingHuyaBinding, parseBindStruct } from './huya-rpc.js'
const BIND_RESP_HEX =
@@ -20,6 +21,126 @@ test('QIMEI x64hash128 与 SDK 向量一致', () => {
assert.equal(x64hash128('abc', 31), '31844fd43df93041e52bcddd13312671')
})
+test('虎牙滑块自动识别按图片边缘定位缺口', async () => {
+ const background = await sharp(
+ Buffer.from(
+ '',
+ ),
+ )
+ .jpeg()
+ .toBuffer()
+ const puzzle = await sharp(
+ Buffer.from(
+ '',
+ ),
+ )
+ .png()
+ .toBuffer()
+ const detected = await detectHuyaSliderDistance({
+ image: `data:image/jpeg;base64,${background.toString('base64')}`,
+ puzzleImage: `data:image/png;base64,${puzzle.toString('base64')}`,
+ puzzleY: 65,
+ puzzleWidth: 60,
+ })
+ assert.ok(detected)
+ assert.ok(Math.abs(detected.distance - 175) <= 2)
+ assert.ok(detected.score > 0.7)
+})
+
+test('虎牙短信发码自动通过滑块并继续发送验证码', async () => {
+ const background = await sharp(
+ Buffer.from(
+ '',
+ ),
+ )
+ .jpeg()
+ .toBuffer()
+ const puzzle = await sharp(
+ Buffer.from(
+ '',
+ ),
+ )
+ .png()
+ .toBuffer()
+ let smsCodeCalls = 0
+ let verifyCalled = false
+ const fetchImpl = (async (urlInput: string | URL | Request) => {
+ const url = String(urlInput)
+ if (url.endsWith('/web/df/token')) {
+ return new Response(JSON.stringify({ data: { token: 'auto-token' } }), { status: 200 })
+ }
+ if (url.endsWith('/web/df/collect')) {
+ return new Response(JSON.stringify({ data: { sdid: 'auto-sdid', hdid: 'auto-hdid' } }), {
+ status: 200,
+ })
+ }
+ if (url.includes('/web/middle/')) return new Response('', { status: 200 })
+ if (url.endsWith('/web/v2/smsCode')) {
+ smsCodeCalls += 1
+ if (smsCodeCalls === 1) {
+ return new Response(
+ JSON.stringify({
+ returnCode: 10030,
+ data: {
+ strategys: [
+ {
+ data: 'https://aq.huya.com/p/safe_auth/pt_auth.html?param=auto-param&sceneType=4',
+ },
+ ],
+ },
+ }),
+ { status: 200 },
+ )
+ }
+ return new Response(
+ JSON.stringify({ returnCode: 0, data: { sessionData: 'auto-sms-session' } }),
+ { status: 200 },
+ )
+ }
+ if (url.endsWith('/auth/client/config3')) {
+ return new Response(
+ JSON.stringify({
+ data: {
+ gurl: 'https://udbrtt.huya.com/auth/auto/get',
+ vurl: 'https://udbrtt.huya.com/auth/auto/verify',
+ js: 'window.UdbCipher={encrypt(){return [1,2,3]},decrypt(){return "{}"}}',
+ },
+ }),
+ { status: 200 },
+ )
+ }
+ if (url.endsWith('/auth/auto/get')) {
+ return new Response(
+ JSON.stringify({
+ data: {
+ code: 'auto-code',
+ huyapk: 'auto-key',
+ dtuStr: background.toString('base64'),
+ puzzleStr: puzzle.toString('base64'),
+ location_y: 65,
+ },
+ }),
+ { status: 200 },
+ )
+ }
+ if (url.includes('/auth/auto/verify')) {
+ verifyCalled = true
+ return new Response(JSON.stringify({ data: { authId: 'auto-auth-id' } }), { status: 200 })
+ }
+ throw new Error(`unexpected request: ${url}`)
+ }) as typeof fetch
+ const connector = new HuyaNativeConnector({ fetchImpl })
+ const result = await connector.sendSmsCode({
+ taskId: 5,
+ orderNo: 'ORDER-AUTO-SLIDER',
+ mobile: '13800138000',
+ })
+ assert.equal(result.challenge, null)
+ assert.equal(result.session.status, 'waiting_code')
+ assert.equal(smsCodeCalls, 2)
+ assert.equal(verifyCalled, true)
+})
+
test('登录 context 使用网页设备 Cookie 的 guiddata、yamid 和 guid', () => {
const identity = ensureHuyaWebCookieIdentity({
cookie:
diff --git a/apps/backend/src/services/semi-auto/huya-native/huya-sms-role.ts b/apps/backend/src/services/semi-auto/huya-native/huya-sms-role.ts
index ad9740a1..2231b51d 100644
--- a/apps/backend/src/services/semi-auto/huya-native/huya-sms-role.ts
+++ b/apps/backend/src/services/semi-auto/huya-native/huya-sms-role.ts
@@ -85,6 +85,12 @@ export type HuyaChallengeSolution = {
points?: unknown
}
+export type HuyaSliderDetection = {
+ distance: number
+ score: number
+ y: number
+}
+
type ChallengeState = {
strategyUrl: string
page: string
@@ -184,6 +190,96 @@ function generateTrack(distance: number): number[][] {
return points
}
+function edgeMap(data: Buffer, width: number, height: number, channels: number): Float32Array {
+ const gray = new Float32Array(width * height)
+ const at = (index: number) => gray[index] ?? 0
+ for (let index = 0, offset = 0; index < gray.length; index += 1, offset += channels) {
+ gray[index] =
+ 0.299 * Number(data[offset] || 0) +
+ 0.587 * Number(data[offset + 1] || 0) +
+ 0.114 * Number(data[offset + 2] || 0)
+ }
+ const edges = new Float32Array(width * height)
+ for (let y = 1; y < height - 1; y += 1) {
+ for (let x = 1; x < width - 1; x += 1) {
+ const index = y * width + x
+ const gx =
+ -at(index - width - 1) -
+ 2 * at(index - 1) -
+ at(index + width - 1) +
+ at(index - width + 1) +
+ 2 * at(index + 1) +
+ at(index + width + 1)
+ const gy =
+ -at(index - width - 1) -
+ 2 * at(index - width) -
+ at(index - width + 1) +
+ at(index + width - 1) +
+ 2 * at(index + width) +
+ at(index + width + 1)
+ edges[index] = Math.hypot(gx, gy)
+ }
+ }
+ return edges
+}
+
+export async function detectHuyaSliderDistance(input: {
+ image: string
+ puzzleImage: string
+ puzzleY: number
+ puzzleWidth: number
+}): Promise {
+ const background = await sharp(imageBuffer(input.image))
+ .grayscale()
+ .raw()
+ .toBuffer({ resolveWithObject: true })
+ const puzzle = await sharp(imageBuffer(input.puzzleImage))
+ .ensureAlpha()
+ .raw()
+ .toBuffer({ resolveWithObject: true })
+ if (background.info.width <= puzzle.info.width || background.info.height <= puzzle.info.height) {
+ return null
+ }
+ const backgroundEdges = edgeMap(background.data, background.info.width, background.info.height, 1)
+ const puzzleEdges = edgeMap(puzzle.data, puzzle.info.width, puzzle.info.height, 4)
+ const alpha = new Float32Array(puzzle.info.width * puzzle.info.height)
+ for (let index = 0; index < alpha.length; index += 1) {
+ alpha[index] = Number(puzzle.data[index * 4 + 3] || 0) / 255
+ }
+
+ const yCenter = Math.max(
+ 0,
+ Math.min(background.info.height - puzzle.info.height, Math.round(input.puzzleY)),
+ )
+ const yStart = Math.max(1, yCenter - 8)
+ const yEnd = Math.min(background.info.height - puzzle.info.height - 1, yCenter + 8)
+ let best: HuyaSliderDetection | null = null
+ for (let x = 1; x <= background.info.width - puzzle.info.width - 1; x += 1) {
+ for (let y = yStart; y <= yEnd; y += 1) {
+ let dot = 0
+ let puzzleEnergy = 0
+ let backgroundEnergy = 0
+ for (let pieceY = 0; pieceY < puzzle.info.height; pieceY += 1) {
+ for (let pieceX = 0; pieceX < puzzle.info.width; pieceX += 1) {
+ const pieceIndex = pieceY * puzzle.info.width + pieceX
+ const mask = alpha[pieceIndex] ?? 0
+ if (mask <= 0.2) continue
+ const pieceEdge = (puzzleEdges[pieceIndex] ?? 0) * mask
+ const backgroundEdge =
+ backgroundEdges[(y + pieceY) * background.info.width + x + pieceX] ?? 0
+ dot += pieceEdge * backgroundEdge
+ puzzleEnergy += pieceEdge * pieceEdge
+ backgroundEnergy += backgroundEdge * backgroundEdge
+ }
+ }
+ if (puzzleEnergy <= 0 || backgroundEnergy <= 0) continue
+ const score = dot / Math.sqrt(puzzleEnergy * backgroundEnergy)
+ if (!best || score > best.score) best = { distance: x, score, y }
+ }
+ }
+ return best && best.score >= 0.7 ? best : null
+}
+
function challengeTypeFromUrl(url: string): HuyaVerificationChallenge['type'] | 'qr' | 'sms' {
const lower = url.toLowerCase()
if (lower.includes('rotate_auth') || lower.includes('/auth/xz/')) return 'rotate'
@@ -490,6 +586,17 @@ export class HuyaSmsRole {
this.challengeState = null
}
+ async detectSliderDistance(): Promise {
+ const challenge = this.challengeState?.challenge
+ if (!challenge || challenge.type !== 'slider') return null
+ return detectHuyaSliderDistance({
+ image: challenge.image,
+ puzzleImage: challenge.puzzleImage,
+ puzzleY: challenge.puzzleY,
+ puzzleWidth: challenge.puzzleWidth,
+ })
+ }
+
async verifyCookie(): Promise {
const response = await this.fetchImpl(`${UDB_BASE}/web/cookie/verify`, {
method: 'POST',
diff --git a/docs/统一订单履约与财务模型-权威版.md b/docs/统一订单履约与财务模型-权威版.md
index 0e585feb..a6dd2c44 100644
--- a/docs/统一订单履约与财务模型-权威版.md
+++ b/docs/统一订单履约与财务模型-权威版.md
@@ -3,7 +3,7 @@ type: 权威架构与实施计划
description: 订单、内部 SKU、履约路由、履约执行、商品、成本与财务的唯一权威口径及重构计划。
tags: [订单, 内部SKU, 履约, 商品, 成本, 财务, 迁移]
status: authoritative
-version: 2.14
+version: 2.15
updated_at: 2026-11-14
---
@@ -316,11 +316,12 @@ manual fulfillment
“发送验证码”后触发,页面不得在加载、轮询或切换 Tab 时自动发码;短信登录会话有效期 `5` 分钟。
登录成功后的 Connector 内存会话有效期 `24` 小时,账号级 CK 由内部凭证表保存并通过健康检查判定有效性;
浏览器一次性注入票据 `2` 分钟且消费后立即失效。重新登录前必须撤销旧 Connector 会话。
- 虎牙发码触发图形安全验证时,应由客户在 order_site 页面完成:`pt_auth` 使用滑块拼图,
- `dx_auth` 使用按序点选,`rotate_auth` 使用旋转调整。服务端只接收客户最终位置、角度或点击坐标,
- 生成对应交互轨迹并重试发码,不自动识别、伪造或绕过平台风控。发码原始响应必须记录到集成日志,
- 但日志系统必须继续屏蔽 Cookie、令牌、验证码等敏感字段。`qr_auth` 和二次短信验证必须明确提示,
- 不得伪装成可完成的图形验证。
+ 虎牙发码触发图形安全验证时,`pt_auth` 滑块允许服务端基于背景与拼图的边缘模板匹配自动定位缺口,
+ 自动生成交互轨迹并在验证通过后重试发码;自动识别置信度不足、提交失败或连续重试失败时必须
+ 回退到客户手动滑块,不能阻塞登录。`dx_auth` 点选和 `rotate_auth` 旋转仍由客户在 order_site
+ 页面完成,服务端接收客户最终角度或点击坐标后生成对应交互轨迹。所有自动化都只复现正常浏览器交互,
+ 不绕过平台风控。发码与挑战原始响应必须记录到集成日志,但日志系统必须继续屏蔽 Cookie、令牌、
+ 验证码等敏感字段。`qr_auth` 和二次短信验证必须明确提示,不得伪装成可完成的图形验证。
- 虎牙客户登录成功后,无论账号此前是否已有角色,都立即保存 CK 并进入等待客服处理。
角色绑定、换绑和角色状态查询在后续独立流程实现,不能阻塞当前 CK 获取。
- 浏览器扩展复用 live-hub-py 的 `web/extension/huya-exchange-helper`,不新增第二个扩展;