From 3ea5dd5ce43d82c7c76adfed567b879bcf09afea Mon Sep 17 00:00:00 2001 From: yml Date: Sat, 14 Nov 2026 02:18:38 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=99=8E=E7=89=99=E7=9F=AD?= =?UTF-8?q?=E4=BF=A1=E6=BB=91=E5=9D=97=E8=87=AA=E5=8A=A8=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../huya-native/huya-native-connector.ts | 56 ++++++-- .../semi-auto/huya-native/huya-native.test.ts | 121 ++++++++++++++++++ .../semi-auto/huya-native/huya-sms-role.ts | 107 ++++++++++++++++ docs/统一订单履约与财务模型-权威版.md | 13 +- 4 files changed, 281 insertions(+), 16 deletions(-) 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`,不新增第二个扩展;