实现内置虎牙扫码登录与绑定 Connector

This commit is contained in:
yml2213
2026-11-09 17:11:01 +08:00
parent b0b8e983ba
commit a9937ef8c6
41 changed files with 4246 additions and 152 deletions
+4 -3
View File
@@ -69,9 +69,10 @@ ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"dev-admin-123456","rol
KAQUAN91_USER_ID=
KAQUAN91_SECRET=
# 半自动履约 Connector(虎牙登录/绑定/CK 注入票据)
SEMI_AUTO_CONNECTOR_BASE_URL=http://127.0.0.1:3000/api/v1/dev-mock/connector
SEMI_AUTO_CONNECTOR_API_KEY=local-semi-auto-connector-key
# 半自动履约默认使用 order_site 内置真实 Connector;只有选择 external 时才填写远程地址
SEMI_AUTO_DEFAULT_CONNECTOR_MODE=embedded
SEMI_AUTO_CONNECTOR_BASE_URL=
SEMI_AUTO_CONNECTOR_API_KEY=
SEMI_AUTO_CONNECTOR_TIMEOUT_MS=15000
# 快手行业电子凭证
+2 -1
View File
@@ -135,9 +135,10 @@ KAQUAN91_USER_ID=
KAQUAN91_SECRET=
# 半自动履约 Connector(虎牙登录/绑定/CK 注入票据)
# 留空时后台仍可查看任务,但客户登录和绑定会返回未配置。
# 默认使用 order_site 内置真实 Connector;只有切到 external 时才需要填写地址和密钥。
SEMI_AUTO_CONNECTOR_BASE_URL=
SEMI_AUTO_CONNECTOR_API_KEY=
SEMI_AUTO_DEFAULT_CONNECTOR_MODE=embedded
SEMI_AUTO_CONNECTOR_TIMEOUT_MS=15000
# 快手行业电子凭证
+1
View File
@@ -41,3 +41,4 @@ send_code/
/.deploy-state
artifacts/
har/
+1
View File
@@ -5,4 +5,5 @@
**/data
**/package-lock.json
**/.env*
**/services/semi-auto/huya-native/assets/fingerprint/*-min.js
+16 -16
View File
@@ -175,28 +175,28 @@ cp .env.server.example .env
| `ADMIN_SESSION_SECRET` | 后台登录态签名密钥 |
| `ADMIN_DEFAULT_USERS_JSON` | 默认后台用户 |
| `KAQUAN91_USER_ID` / `KAQUAN91_SECRET` | 91 卡券开放接口凭据 |
| `SEMI_AUTO_CONNECTOR_BASE_URL` | 半自动履约 Connector 基址(虎牙登录/绑定/CK 注入票据) |
| `SEMI_AUTO_CONNECTOR_API_KEY` | order_site 与 Connector 的内部鉴权密钥 |
| `SEMI_AUTO_CONNECTOR_BASE_URL` | 可选的远程真实 Connector 基址;默认使用 order_site 内置实现 |
| `SEMI_AUTO_CONNECTOR_API_KEY` | 使用远程 Connector 时的内部鉴权密钥 |
| `SEMI_AUTO_DEFAULT_CONNECTOR_MODE` | 新任务默认 Connector:`embedded` / `external` / `mock`,默认 `embedded` |
| `SEMI_AUTO_CONNECTOR_TIMEOUT_MS` | Connector 请求超时,默认 15000 毫秒 |
### 本地半自动履约 Mock
### 本地半自动履约测试
`docker-compose.dev.yml` 默认把 Connector 指向 order_site 自带的开发 Mock:
开发 Mock 只负责生成订单,登录、绑定、浏览器票据注入和完成确认都走真实流程。
默认使用 order_site 内置真实 Connector,不需要启动 live-hub-py,也不需要配置外部地址。
只有显式选择远程 Connector 时,才需要设置 `SEMI_AUTO_CONNECTOR_BASE_URL` 和
`SEMI_AUTO_CONNECTOR_API_KEY`。
```text
http://127.0.0.1:3000/api/v1/dev-mock/connector
```
真实登录 / 绑定验证流程:
本地验证流程:
1. 打开后台 `/admin/dev-mock`,Mock 范围选择“仅 Mock 订单(内置真实登录 / 绑定 / 注入)”。
2. 生成测试单,打开客户链接,使用虎牙 App 扫码并确认登录。
3. 页面创建绑定二维码,等待 Connector 识别角色,核对账号和区服后确认绑定。
4. 打开后台“任务 → 半自动履约”,确认任务 Connector 显示“内置真实”,领取并生成浏览器注入票据。
5. 扩展消费票据后完成履约,核对任务、履约、成本快照和财务确认。
1. 打开后台 `/admin/dev-mock`,在“半自动履约测试单”中生成订单。
2. 打开客户链接,点击获取登录二维码;Mock 会在两次轮询后自动模拟登录成功。
3. 页面自动创建绑定二维码,轮询后识别测试角色,点击确认绑定。
4. 打开后台“任务 → 半自动履约”,领取任务并生成浏览器注入票据。
5. 完成履约,核对任务、履约、成本快照和财务确认。
需要接真实 Connector 时,显式覆盖 `SEMI_AUTO_CONNECTOR_BASE_URL`,例如
`http://host.docker.internal:8800`。开发 Mock 路由在非开发环境返回 404。
只联调页面、不调用虎牙接口时,可在同一个表单选择“订单 + 登录绑定都 Mock”;
该模式使用 order_site 内置 Mock Connector。开发 Mock 路由在非开发环境返回 404。
半自动履约真实 PostgreSQL 集成测试需要独立库,避免写入生产 dump:
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"cleanup:dev-data": "tsx scripts/cleanup-dev-data.ts",
"build": "tsc -p tsconfig.build.json && rm -rf dist/db/migrations && mkdir -p dist/db && cp -R src/db/migrations dist/db/migrations && mkdir -p dist/services/platforms/pdd && cp src/services/platforms/pdd/pdd_anti.cjs dist/services/platforms/pdd/pdd_anti.cjs",
"build": "tsc -p tsconfig.build.json && rm -rf dist/db/migrations && mkdir -p dist/db && cp -R src/db/migrations dist/db/migrations && mkdir -p dist/services/platforms/pdd && cp src/services/platforms/pdd/pdd_anti.cjs dist/services/platforms/pdd/pdd_anti.cjs && rm -rf dist/services/semi-auto/huya-native/assets/fingerprint && mkdir -p dist/services/semi-auto/huya-native/assets && cp -R src/services/semi-auto/huya-native/assets/fingerprint dist/services/semi-auto/huya-native/assets/fingerprint",
"db:migrate": "tsx src/db/migrate.ts",
"db:migrate:create": "tsx scripts/create-migration.ts",
"db:migrate:status": "tsx scripts/migration-status.ts",
+6
View File
@@ -62,9 +62,15 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
},
semiAuto: {
defaultConnectorMode: 'embedded',
connectorBaseUrl: '',
connectorApiKey: '',
connectorTimeoutMs: 15_000,
huya: {
bindActId: 9271,
outerActId: '17096',
roomPid: '1199650619883',
},
},
admin: {
+4
View File
@@ -54,6 +54,10 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [
stringEnv('SEMI_AUTO_CONNECTOR_BASE_URL', ['semiAuto', 'connectorBaseUrl']),
stringEnv('SEMI_AUTO_CONNECTOR_API_KEY', ['semiAuto', 'connectorApiKey']),
integerEnv('SEMI_AUTO_CONNECTOR_TIMEOUT_MS', ['semiAuto', 'connectorTimeoutMs']),
stringEnv('SEMI_AUTO_DEFAULT_CONNECTOR_MODE', ['semiAuto', 'defaultConnectorMode']),
integerEnv('HUYA_BIND_ACT_ID', ['semiAuto', 'huya', 'bindActId']),
stringEnv('HUYA_OUTER_ACT_ID', ['semiAuto', 'huya', 'outerActId']),
stringEnv('HUYA_ROOM_PID', ['semiAuto', 'huya', 'roomPid']),
stringEnv('ADMIN_SESSION_SECRET', ['admin', 'sessionSecret']),
// 兼容旧变量:固定 TTL 时代按“绝对上限”理解,空闲窗口仍用新默认值并被绝对上限钳制。
integerEnv('ADMIN_SESSION_TTL_HOURS', ['admin', 'sessionAbsoluteTtlHours']),
@@ -126,6 +126,22 @@ test('validateRuntimeConfig accepts zero claim token TTL for permanent links', (
assert.deepEqual(issues, [])
})
test('validateRuntimeConfig rejects unknown semi-auto connector mode', () => {
const issues = validateRuntimeConfig(
createRuntimeConfig({
semiAuto: {
defaultConnectorMode: 'remote',
},
}),
{ env: { NODE_ENV: 'development' } },
)
assert.deepEqual(
issues.map((issue) => issue.path),
['semiAuto.defaultConnectorMode'],
)
})
test('assertRuntimeConfigValid throws a readable validation error', () => {
assert.throws(
() => {
@@ -187,6 +203,17 @@ function createRuntimeConfig(overrides: Record<string, unknown> = {}): RuntimeCo
claimBaseUrl: 'https://order.example.test/#/claim',
tokenTtlHours: 24,
},
semiAuto: {
defaultConnectorMode: 'embedded',
connectorBaseUrl: '',
connectorApiKey: '',
connectorTimeoutMs: 15000,
huya: {
bindActId: 9271,
outerActId: '17096',
roomPid: '1199650619883',
},
},
admin: {
sessionSecret: '0123456789abcdef0123456789abcdef',
sessionIdleTtlHours: 24,
@@ -78,6 +78,13 @@ export function validateRuntimeConfig(
{ min: 0 },
)
validateOptionalHttpUrl(issues, 'semiAuto.connectorBaseUrl', config.semiAuto?.connectorBaseUrl)
const connectorMode = String(config.semiAuto?.defaultConnectorMode || '').trim()
if (!['embedded', 'external', 'mock'].includes(connectorMode)) {
issues.push({
path: 'semiAuto.defaultConnectorMode',
message: '仅支持 embedded、external 或 mock',
})
}
requireOptionalInteger(
issues,
'semiAuto.connectorTimeoutMs',
@@ -0,0 +1,23 @@
-- 154_semi_auto_connector_mode.sql
--
-- 半自动任务记录登录/绑定/票据注入使用的 Connector 来源:
-- external:配置的远程 Connector,作为首版默认值;
-- mock:order_site 内置开发 Mock Connector,仅开发环境可用。
ALTER TABLE semi_auto_tasks
ADD COLUMN IF NOT EXISTS connector_mode TEXT NOT NULL DEFAULT 'external';
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'chk_semi_auto_tasks_connector_mode'
) THEN
ALTER TABLE semi_auto_tasks
ADD CONSTRAINT chk_semi_auto_tasks_connector_mode
CHECK (connector_mode IN ('external', 'mock'));
END IF;
END $$;
COMMENT ON COLUMN semi_auto_tasks.connector_mode IS
'Connector 来源;external 为远程真实实现,mock 仅开发环境可用';
@@ -0,0 +1,25 @@
-- 155_semi_auto_connector_mode_embedded.sql
--
-- 内置真实 Connector 成为默认实现,不再依赖外部 live-hub-py 进程:
-- embedded:order_site 进程内真实虎牙 Connector,默认值;
-- external:显式配置的远程 Connector,保留用于隔离部署;
-- mock:order_site 内置开发 Mock Connector,仅开发环境可用。
ALTER TABLE semi_auto_tasks
ALTER COLUMN connector_mode SET DEFAULT 'embedded';
ALTER TABLE semi_auto_tasks
DROP CONSTRAINT IF EXISTS chk_semi_auto_tasks_connector_mode;
-- 154 时代创建的存量任务均由远程 Connector 完成;切换到内置实现后仍需继续可操作。
UPDATE semi_auto_tasks
SET connector_mode = 'embedded',
updated_at = NOW()
WHERE connector_mode = 'external';
ALTER TABLE semi_auto_tasks
ADD CONSTRAINT chk_semi_auto_tasks_connector_mode
CHECK (connector_mode IN ('embedded', 'external', 'mock'));
COMMENT ON COLUMN semi_auto_tasks.connector_mode IS
'Connector 来源;embedded 为内置真实实现,external 为远程实现,mock 仅开发环境可用';
@@ -90,6 +90,7 @@ router.post(
platform: 'semi_auto',
orderNo: (data as { orderNo?: string })?.orderNo,
skuKey: (data as { skuKey?: string })?.skuKey,
connectorMode: (data as { connectorMode?: string })?.connectorMode,
},
}),
}),
+7 -10
View File
@@ -1,8 +1,8 @@
import type { Request, Response } from 'express'
import { Router } from 'express'
import { runtimeConfig } from '../config/runtime.js'
import { isDevMockEnabled } from '../services/dev-mock/dev-mock-service.js'
import { DEV_MOCK_CONNECTOR_API_KEY } from '../services/dev-mock/connector-endpoint.js'
import { isDevMockEnabled } from '../services/dev-mock/dev-mock-enabled.js'
import {
confirmMockBinding,
consumeMockBrowserTicket,
@@ -20,12 +20,6 @@ import {
const router = Router()
function connectorApiKey() {
return (
String(runtimeConfig.semiAuto?.connectorApiKey || '').trim() || 'local-semi-auto-connector-key'
)
}
function bodyOf(req: Request): Record<string, unknown> {
return req.body && typeof req.body === 'object' && !Array.isArray(req.body)
? (req.body as Record<string, unknown>)
@@ -58,7 +52,7 @@ router.use((req, res, next) => {
return
}
const key = String(req.header('x-semi-auto-key') || '').trim()
if (!key || key !== connectorApiKey()) {
if (!key || key !== DEV_MOCK_CONNECTOR_API_KEY) {
sendDetail(res, 401, '半自动履约 Connector 鉴权失败')
return
}
@@ -69,7 +63,10 @@ router.post(
'/api/internal/huya-semi-auto/sessions',
handle((req) => {
const body = bodyOf(req)
return createMockLoginSession({ taskId: body.taskId, orderNo: body.orderNo })
return createMockLoginSession({
taskId: body.task_id ?? body.taskId,
orderNo: body.order_no ?? body.orderNo,
})
}),
)
@@ -0,0 +1,20 @@
import { runtimeConfig } from '../../config/runtime.js'
import { createHttpError } from '../../utils/http.js'
import { isDevMockEnabled } from './dev-mock-enabled.js'
export const DEV_MOCK_CONNECTOR_PATH = '/api/v1/dev-mock/connector'
export const DEV_MOCK_CONNECTOR_API_KEY = 'local-semi-auto-connector-key'
export function devMockConnectorBaseUrl() {
const port = Math.trunc(Number(runtimeConfig.server.port || 3000))
return `http://127.0.0.1:${port}${DEV_MOCK_CONNECTOR_PATH}`
}
export function assertDevMockConnectorEnabled() {
if (!isDevMockEnabled()) {
throw createHttpError('内置 Mock Connector 仅在开发环境可用', {
statusCode: 503,
errorCode: 'dev_mock_connector_disabled',
})
}
}
@@ -0,0 +1,21 @@
import { isProductionLike } from '../../config/runtime-validation.js'
import { createHttpError } from '../../utils/http.js'
export function isDevMockEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
const flag = String(env.ENABLE_DEV_MOCK || '')
.trim()
.toLowerCase()
if (flag === '1' || flag === 'true') {
return true
}
return !isProductionLike(env)
}
export function assertDevMockEnabled() {
if (!isDevMockEnabled()) {
throw createHttpError('当前环境未开启开发 Mock(仅非 production 或 ENABLE_DEV_MOCK=1)', {
statusCode: 403,
errorCode: 'dev_mock_disabled',
})
}
}
@@ -26,13 +26,19 @@ import {
getKuaishouIndustrySourceConfig,
listKuaishouIndustryShopConfigs,
} from '../platforms/kuaishou-industry/source-config-service.js'
import { isProductionLike } from '../../config/runtime-validation.js'
import { runtimeConfig } from '../../config/runtime.js'
import { createHttpError } from '../../utils/http.js'
import { nowIso } from '../../utils/time.js'
import { randomId } from '../../utils/random.js'
import { TASK_STATUS } from '../../domain/task-status.js'
import { ensureSemiAutoTasksForOrder } from '../semi-auto/semi-auto-task-service.js'
import {
defaultSemiAutoConnectorMode,
isExternalSemiAutoConnectorConfigured,
type SemiAutoConnectorMode,
} from '../semi-auto/huya-connector-client.js'
import { assertDevMockEnabled, isDevMockEnabled } from './dev-mock-enabled.js'
export { assertDevMockEnabled, isDevMockEnabled }
export type DevMockCreateResult = {
platform: 'affiliate_dash'
@@ -69,35 +75,13 @@ export type DevMockSemiAutoResult = {
configId: number
taskId: number
skuKey: string
connectorMode: 'mock' | 'external'
connectorMode: SemiAutoConnectorMode
connectorConfigured: boolean
customerPath: string
adminPath: string
tips: string[]
}
export function isDevMockEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
if (String(env.ENABLE_DEV_MOCK || '').trim() === '1') {
return true
}
if (
String(env.ENABLE_DEV_MOCK || '')
.trim()
.toLowerCase() === 'true'
) {
return true
}
return !isProductionLike(env)
}
export function assertDevMockEnabled() {
if (!isDevMockEnabled()) {
throw createHttpError('当前环境未开启开发 Mock(仅非 production 或 ENABLE_DEV_MOCK=1)', {
statusCode: 403,
errorCode: 'dev_mock_disabled',
})
}
}
export function getDevMockStatus() {
const enabled = isDevMockEnabled()
return {
@@ -115,28 +99,21 @@ export function getDevMockStatus() {
key: 'semi_auto',
name: '半自动履约',
description:
'创建人工渠道半自动测试单;登录、绑定和浏览器票据由内置 Mock Connector 模拟',
'创建人工渠道半自动测试单;可选择仅 Mock 订单,登录、绑定和票据注入走真实 Connector',
steps: ['create_order', 'login', 'binding', 'browser_ticket', 'complete'],
},
]
: [],
defaultUid: '10001',
semiAuto: {
connectorMode: semiAutoConnectorMode(),
connectorConfigured: Boolean(
String(runtimeConfig.semiAuto?.connectorBaseUrl || '').trim() &&
String(runtimeConfig.semiAuto?.connectorApiKey || '').trim(),
),
defaultConnectorMode: defaultSemiAutoConnectorMode(),
externalConnectorConfigured: isExternalSemiAutoConnectorConfigured(),
mockConnectorAvailable: enabled,
platform: 'huya',
},
}
}
function semiAutoConnectorMode(): 'mock' | 'external' {
const baseUrl = String(runtimeConfig.semiAuto?.connectorBaseUrl || '').trim()
return baseUrl.includes('/dev-mock/connector') ? 'mock' : 'external'
}
type AffiliateDashMockStep = 'uid' | 'bind' | 'submitted' | 'completed' | 'failed'
/**
@@ -329,7 +306,7 @@ export async function createAffiliateDashMockClaim(
/**
* 生成半自动履约测试单:人工 SKU 配置、订单、履约和半自动任务都走真实领域服务。
* 平台登录、绑定和浏览器票据由内置 Mock Connector 模拟。
* 平台登录、绑定和浏览器票据按 connectorMode 选择真实 Connector 或内置 Mock。
*/
export async function createSemiAutoMock(
input: {
@@ -339,6 +316,7 @@ export async function createSemiAutoMock(
pageTitle?: unknown
pageDescription?: unknown
browserTargetUrl?: unknown
connectorMode?: unknown
} = {},
): Promise<DevMockSemiAutoResult> {
assertDevMockEnabled()
@@ -346,6 +324,7 @@ export async function createSemiAutoMock(
const orderNo = String(input.orderNo || `MOCKSA${Date.now()}`).trim()
const skuKey = normalizeMockSemiAutoSku(input.skuName)
const quantity = normalizeMockSemiAutoQuantity(input.quantity)
const connectorMode = normalizeMockSemiAutoConnectorMode(input.connectorMode)
const pageTitle = String(input.pageTitle || '虎牙账号授权测试')
.trim()
.slice(0, 60)
@@ -370,8 +349,14 @@ export async function createSemiAutoMock(
semiAutoConfig: {
pageTitle,
pageDescription,
loginInstructions: 'Mock 登录:点击获取二维码后,页面轮询两次会自动模拟登录成功。',
bindingInstructions: 'Mock 绑定:页面轮询两次后会自动识别角色,点击确认即可。',
loginInstructions:
connectorMode === 'mock'
? 'Mock 登录:点击获取二维码后,页面轮询两次会自动模拟登录成功。'
: '真实登录:点击获取二维码后,请使用虎牙 App 扫码并在手机上确认登录。',
bindingInstructions:
connectorMode === 'mock'
? 'Mock 绑定:页面轮询两次后会自动识别角色,点击确认即可。'
: '真实绑定:扫码后等待 Connector 识别角色,核对账号和区服后点击确认绑定。',
browserTargets: [{ key: 'huya', label: '虎牙', url: browserTargetUrl }],
},
})
@@ -469,7 +454,13 @@ export async function createSemiAutoMock(
})
}
const connectorMode = semiAutoConnectorMode()
await query('UPDATE semi_auto_tasks SET connector_mode = $2, updated_at = NOW() WHERE id = $1', [
taskId,
connectorMode,
])
const connectorConfigured =
connectorMode === 'external' ? isExternalSemiAutoConnectorConfigured() : true
return {
platform: 'semi_auto',
orderId: Number(order.id),
@@ -480,20 +471,35 @@ export async function createSemiAutoMock(
taskId,
skuKey,
connectorMode,
connectorConfigured,
customerPath: `/c/${encodeURIComponent(orderNo)}`,
adminPath: `/admin/semi-auto-tasks?keyword=${encodeURIComponent(orderNo)}`,
tips: [
'打开客户页,点击“获取登录二维码”;Mock Connector 会在两次轮询后自动模拟登录成功。',
'登录后页面会自动创建绑定二维码,轮询两次后识别角色,点击“确认绑定角色”。',
'后台“任务 → 半自动履约”领取任务后生成浏览器票据;扩展消费票据后写入 Mock Cookie。',
'完成履约仍走真实确认流程,会写入成本快照和财务确认。',
connectorMode === 'mock'
? '当前使用内置 Mock Connector,不需要启动 live-hub-py。'
: '当前配置的是外部 Connector;请确认 Connector 已启动,否则登录和绑定会失败。',
? '当前使用内置 Mock Connector:打开客户页点击登录,轮询两次后自动登录;绑定同样自动识别角色。'
: connectorMode === 'external'
? connectorConfigured
? '当前使用远程真实 Connector:订单是 Mock 的,登录、绑定和浏览器票据都走外部服务。'
: '当前选择远程真实 Connector,但 SEMI_AUTO_CONNECTOR_BASE_URL / API_KEY 未配置,登录会失败。'
: '当前使用 order_site 内置真实 Connector:无需启动 live-hub-py,扫码登录、绑定和票据都在本进程完成。',
connectorMode === 'mock'
? '后台领取任务后生成浏览器票据;扩展消费票据后写入 Mock Cookie。'
: connectorMode === 'external'
? '请确保远程 Connector 已启动,并保证两边 SEMI_AUTO_CONNECTOR_API_KEY 一致。'
: '登录凭据只保存在当前进程内存中,重启后端会要求客户重新扫码。',
],
}
}
function normalizeMockSemiAutoConnectorMode(value: unknown): SemiAutoConnectorMode {
const mode = String(value || '')
.trim()
.toLowerCase()
if (mode === 'external' || mode === 'mock') return mode
return 'embedded'
}
function normalizeMockSemiAutoSku(value: unknown) {
const raw = String(value || '').trim()
const withCjk = raw
@@ -0,0 +1,46 @@
import assert from 'node:assert/strict'
import test from 'node:test'
process.env.SEMI_AUTO_CONNECTOR_BASE_URL = 'http://connector.test'
process.env.SEMI_AUTO_CONNECTOR_API_KEY = 'test-key'
const connector = await import('./huya-connector-client.js')
test('浏览器注入票据按 Mock 前缀选择 Connector', () => {
assert.equal(connector.inferSemiAutoConnectorModeFromTicket('mock-ticket-0123456789'), 'mock')
assert.equal(
connector.inferSemiAutoConnectorModeFromTicket('native-ticket-0123456789'),
'embedded',
)
assert.equal(connector.inferSemiAutoConnectorModeFromTicket('real-ticket-0123456789'), 'external')
})
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({
sessionId: 'session-1',
status: 'waiting_scan',
qrContent: 'https://example.com/qr',
expiresAt: '2026-11-09T00:00:00Z',
huyaUid: '',
nickname: '',
hasCredential: false,
message: '等待扫码',
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)
}) as typeof fetch
try {
await connector.createHuyaLoginSession({ taskId: 123, orderNo: 'ORDER-1' }, 'external')
assert.equal(requestUrl, 'http://connector.test/api/internal/huya-semi-auto/sessions')
assert.deepEqual(requestBody, { task_id: 123, order_no: 'ORDER-1' })
} finally {
globalThis.fetch = originalFetch
}
})
@@ -1,10 +1,21 @@
import { runtimeConfig } from '../../config/runtime.js'
import { createHttpError } from '../../utils/http.js'
import {
assertDevMockConnectorEnabled,
DEV_MOCK_CONNECTOR_API_KEY,
devMockConnectorBaseUrl,
} from '../dev-mock/connector-endpoint.js'
import { isDevMockEnabled } from '../dev-mock/dev-mock-enabled.js'
import {
HUYA_NATIVE_TICKET_PREFIX,
huyaNativeConnector,
} from './huya-native/huya-native-connector.js'
export type ConnectorLoginSession = {
sessionId: string
status: 'waiting_scan' | 'scanned' | 'authenticated' | 'expired' | 'failed'
qrContent: string
qrImage?: string
expiresAt: string
huyaUid: string
nickname: string
@@ -54,29 +65,68 @@ type ConnectorRequestOptions = {
body?: unknown
}
function connectorBaseUrl() {
export type SemiAutoConnectorMode = 'embedded' | 'external' | 'mock'
type RemoteConnectorMode = Exclude<SemiAutoConnectorMode, 'embedded'>
type ConnectorEndpoint = {
baseUrl: string
apiKey: string
}
function externalConnectorBaseUrl() {
return String(runtimeConfig.semiAuto?.connectorBaseUrl || '')
.trim()
.replace(/\/+$/, '')
}
function connectorApiKey() {
function externalConnectorApiKey() {
return String(runtimeConfig.semiAuto?.connectorApiKey || '').trim()
}
export function isSemiAutoConnectorConfigured() {
return Boolean(connectorBaseUrl() && connectorApiKey())
export function isExternalSemiAutoConnectorConfigured() {
return Boolean(externalConnectorBaseUrl() && externalConnectorApiKey())
}
async function callConnector<T>(path: string, options: ConnectorRequestOptions = {}): Promise<T> {
const baseUrl = connectorBaseUrl()
const apiKey = connectorApiKey()
export function getSemiAutoConnectorStatus() {
return {
defaultConnectorMode: runtimeConfig.semiAuto?.defaultConnectorMode || ('embedded' as const),
embeddedConnectorAvailable: true,
externalConnectorConfigured: isExternalSemiAutoConnectorConfigured(),
mockConnectorAvailable: isDevMockEnabled(),
}
}
export function defaultSemiAutoConnectorMode(): SemiAutoConnectorMode {
const mode = String(runtimeConfig.semiAuto?.defaultConnectorMode || 'embedded').trim()
return mode === 'external' || mode === 'mock' ? mode : 'embedded'
}
function resolveConnectorEndpoint(mode: RemoteConnectorMode): ConnectorEndpoint {
if (mode === 'mock') {
assertDevMockConnectorEnabled()
return {
baseUrl: devMockConnectorBaseUrl(),
apiKey: DEV_MOCK_CONNECTOR_API_KEY,
}
}
const baseUrl = externalConnectorBaseUrl()
const apiKey = externalConnectorApiKey()
if (!baseUrl || !apiKey) {
throw createHttpError('半自动履约 Connector 尚未配置', {
statusCode: 503,
errorCode: 'semi_auto_connector_not_configured',
})
}
return { baseUrl, apiKey }
}
async function callConnector<T>(
path: string,
options: ConnectorRequestOptions = {},
mode: RemoteConnectorMode = 'external',
): Promise<T> {
const endpoint = resolveConnectorEndpoint(mode)
const timeoutMs = Math.max(1000, Number(runtimeConfig.semiAuto?.connectorTimeoutMs || 15_000))
const controller = new AbortController()
@@ -87,12 +137,12 @@ async function callConnector<T>(path: string, options: ConnectorRequestOptions =
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-Semi-Auto-Key': apiKey,
'X-Semi-Auto-Key': endpoint.apiKey,
},
signal: controller.signal,
}
if (options.body !== undefined) requestInit.body = JSON.stringify(options.body)
const response = await fetch(`${baseUrl}${path}`, requestInit)
const response = await fetch(`${endpoint.baseUrl}${path}`, requestInit)
const text = await response.text()
let payload: unknown = null
try {
@@ -131,61 +181,122 @@ async function callConnector<T>(path: string, options: ConnectorRequestOptions =
}
}
export async function createHuyaLoginSession(input: {
taskId: number
orderNo: string
}): Promise<ConnectorLoginSession> {
return callConnector<ConnectorLoginSession>('/api/internal/huya-semi-auto/sessions', {
method: 'POST',
body: input,
})
}
export async function getHuyaLoginSession(sessionId: string): Promise<ConnectorLoginSession> {
export async function createHuyaLoginSession(
input: {
taskId: number
orderNo: string
},
mode: SemiAutoConnectorMode = defaultSemiAutoConnectorMode(),
): Promise<ConnectorLoginSession> {
if (mode === 'embedded') {
return huyaNativeConnector.createLoginSession(input)
}
return callConnector<ConnectorLoginSession>(
`/api/internal/huya-semi-auto/sessions/${encodeURIComponent(sessionId)}`,
'/api/internal/huya-semi-auto/sessions',
{
method: 'POST',
body: {
task_id: input.taskId,
order_no: input.orderNo,
},
},
mode,
)
}
export async function startHuyaBinding(sessionId: string): Promise<ConnectorBindingSession> {
export async function getHuyaLoginSession(
sessionId: string,
mode: SemiAutoConnectorMode = defaultSemiAutoConnectorMode(),
): Promise<ConnectorLoginSession> {
if (mode === 'embedded') return huyaNativeConnector.getLoginSession(sessionId)
return callConnector<ConnectorLoginSession>(
`/api/internal/huya-semi-auto/sessions/${encodeURIComponent(sessionId)}`,
{},
mode,
)
}
export async function startHuyaBinding(
sessionId: string,
mode: SemiAutoConnectorMode = defaultSemiAutoConnectorMode(),
): Promise<ConnectorBindingSession> {
if (mode === 'embedded') return huyaNativeConnector.startBinding(sessionId)
return callConnector<ConnectorBindingSession>(
`/api/internal/huya-semi-auto/sessions/${encodeURIComponent(sessionId)}/binding`,
{ method: 'POST', body: {} },
mode,
)
}
export async function getHuyaBinding(sessionId: string): Promise<ConnectorBindingSession> {
export async function getHuyaBinding(
sessionId: string,
mode: SemiAutoConnectorMode = defaultSemiAutoConnectorMode(),
): Promise<ConnectorBindingSession> {
if (mode === 'embedded') return huyaNativeConnector.pollBinding(sessionId)
return callConnector<ConnectorBindingSession>(
`/api/internal/huya-semi-auto/sessions/${encodeURIComponent(sessionId)}/binding`,
{},
mode,
)
}
export async function confirmHuyaBinding(sessionId: string): Promise<ConnectorBindingSession> {
export async function confirmHuyaBinding(
sessionId: string,
mode: SemiAutoConnectorMode = defaultSemiAutoConnectorMode(),
): Promise<ConnectorBindingSession> {
if (mode === 'embedded') return huyaNativeConnector.confirmBinding(sessionId)
return callConnector<ConnectorBindingSession>(
`/api/internal/huya-semi-auto/sessions/${encodeURIComponent(sessionId)}/binding/confirm`,
{ method: 'POST', body: {} },
mode,
)
}
export async function createHuyaBrowserTicket(input: {
sessionId: string
targetUrl?: string
}): Promise<ConnectorBrowserTicket> {
export async function createHuyaBrowserTicket(
input: {
sessionId: string
targetUrl?: string
},
mode: SemiAutoConnectorMode = defaultSemiAutoConnectorMode(),
): Promise<ConnectorBrowserTicket> {
if (mode === 'embedded') return huyaNativeConnector.createBrowserTicket(input)
return callConnector<ConnectorBrowserTicket>(
`/api/internal/huya-semi-auto/sessions/${encodeURIComponent(input.sessionId)}/browser-ticket`,
{ method: 'POST', body: { targetUrl: input.targetUrl || '' } },
mode,
)
}
export async function consumeHuyaBrowserTicket(ticket: string): Promise<ConnectorInjectionPayload> {
export function inferSemiAutoConnectorModeFromTicket(ticket: string): SemiAutoConnectorMode {
const normalized = ticket.trim()
if (normalized.startsWith('mock-ticket-')) return 'mock'
if (normalized.startsWith(HUYA_NATIVE_TICKET_PREFIX)) return 'embedded'
return 'external'
}
export async function consumeHuyaBrowserTicket(
ticket: string,
mode: SemiAutoConnectorMode = inferSemiAutoConnectorModeFromTicket(ticket),
): Promise<ConnectorInjectionPayload> {
if (mode === 'embedded') return huyaNativeConnector.consumeBrowserTicket(ticket)
return callConnector<ConnectorInjectionPayload>(
`/api/internal/huya-semi-auto/browser-tickets/${encodeURIComponent(ticket)}`,
{},
mode,
)
}
export async function revokeHuyaSession(sessionId: string): Promise<void> {
export async function revokeHuyaSession(
sessionId: string,
mode: SemiAutoConnectorMode = defaultSemiAutoConnectorMode(),
): Promise<void> {
if (mode === 'embedded') {
await huyaNativeConnector.revokeSession(sessionId)
return
}
await callConnector<{ ok: boolean }>(
`/api/internal/huya-semi-auto/sessions/${encodeURIComponent(sessionId)}`,
{ method: 'DELETE' },
mode,
)
}
@@ -0,0 +1,560 @@
// Android WebView 环境仿真 (默认 M2102J2SC; 可传设备覆盖参数实现一号一设备)
// overrides = {model, androidVer, vendor, appVer, screenWidth, screenHeight}
function makeEnv(overrides) {
const ov = overrides || {}
const MODEL = ov.model || 'M2102J2SC'
const ANDROID_VER = ov.androidVer || '11'
const VENDOR = ov.vendor || 'xiaomi'
const APP_VER = ov.appVer || '13.4.22'
const UA =
ov.userAgent ||
'Mozilla/5.0 (Linux; Android ' +
ANDROID_VER +
'; ' +
MODEL +
' Build/RKQ1.200826.002; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 ' +
'Chrome/149.0.7827.159 Mobile Safari/537.36 huya adr/' +
APP_VER +
'/' +
VENDOR +
'/30'
// 视口按物理分辨率/3 仿真 (393x851 ≈ 1080x2120 / 2.75)
const VW = ov.screenWidth ? Math.round(ov.screenWidth / 2.75) : 393
const VH = ov.screenHeight ? Math.round(ov.screenHeight / 2.75) : 851
const W = globalThis
W.self = W
// Node 18/20 do not expose a browser navigator; create one before defining
// the properties consumed by hydevice. Newer Node versions already expose
// a Navigator instance, which is retained.
W.navigator = W.navigator || {}
W.screen = {
width: VW,
height: VH,
availWidth: VW,
availHeight: VH,
colorDepth: 24,
pixelDepth: 24,
availLeft: 0,
availTop: 0,
orientation: { type: 'portrait-primary', angle: 0 },
}
W.devicePixelRatio = 2.75
W.innerWidth = VW
W.innerHeight = Math.max(VH - 114, 500)
W.outerWidth = VW
W.outerHeight = VH
W.location = {
href: 'https://aq.huya.com/p/safe_auth/pt_auth.html',
protocol: 'https:',
host: 'aq.huya.com',
hostname: 'aq.huya.com',
port: '',
pathname: '/p/safe_auth/pt_auth.html',
search: '',
hash: '',
origin: 'https://aq.huya.com',
toString() {
return this.href
},
}
W.history = { length: 2, state: null, pushState() {}, replaceState() {}, back() {}, go() {} }
const navProps = {
userAgent: UA,
appName: 'Netscape',
appVersion: UA.replace(/^Mozilla\//, ''),
platform: 'Linux armv8l',
language: 'zh-CN',
languages: ['zh-CN', 'zh', 'en'],
cookieEnabled: true,
doNotTrack: null,
maxTouchPoints: 5,
hardwareConcurrency: 8,
deviceMemory: 8,
vendor: 'Google Inc.',
productSub: '20030107',
webdriver: false,
pdfViewerEnabled: false,
javaEnabled() {
return false
},
getBattery() {
return Promise.resolve({ charging: true, level: 0.9 })
},
userAgentData: {
brands: [
{ brand: 'Chromium', version: '149' },
{ brand: 'Google Chrome', version: '149' },
{ brand: 'Not?A_Brand', version: '24' },
],
mobile: true,
platform: 'Android',
getHighEntropyValues() {
return Promise.resolve({
architecture: 'arm',
bitness: '64',
model: MODEL,
platformVersion: ANDROID_VER + '.0.0',
uaFullVersion: '149.0.7827.159',
})
},
},
connection: { effectiveType: '4g', downlink: 10, rtt: 50, saveData: false },
mediaDevices: {
enumerateDevices() {
return Promise.resolve([])
},
getUserMedia() {
return Promise.reject(new Error('not supported'))
},
},
plugins: {
length: 0,
item() {
return null
},
namedItem() {
return null
},
},
mimeTypes: { length: 0 },
}
for (const [k, v] of Object.entries(navProps)) {
try {
Object.defineProperty(W.navigator, k, { value: v, configurable: true })
} catch (e) {
try {
W.navigator[k] = v
} catch (_) {}
}
}
// canvas 2d/webgl 最小仿真
const glConsts = {
RENDERER: 0x1f01,
VENDOR: 0x1f00,
VERSION: 0x1f02,
SHADING_LANGUAGE_VERSION: 0x8b8c,
MAX_TEXTURE_SIZE: 0x0d33,
MAX_RENDERBUFFER_SIZE: 0x84e8,
MAX_VIEWPORT_DIMS: 0x0d3a,
}
function fakeGL() {
return new Proxy(
{
getExtension() {
return { UNMASKED_VENDOR_WEBGL: 0x9245, UNMASKED_RENDERER_WEBGL: 0x9246 }
},
getParameter(p) {
if (p === 0x9245) return 'Qualcomm'
if (p === 0x9246) return 'Adreno (TM) 650'
if (p === glConsts.MAX_TEXTURE_SIZE) return 16384
if (p === glConsts.MAX_VIEWPORT_DIMS) return new Int32Array([16384, 16384])
return 'WebKit WebGL'
},
getContextAttributes() {
return { alpha: true, antialias: true, depth: true }
},
createBuffer() {
return {}
},
bindBuffer() {},
bufferData() {},
createProgram() {
return {}
},
attachShader() {},
linkProgram() {},
useProgram() {},
createShader() {
return {}
},
shaderSource() {},
compileShader() {},
getShaderParameter() {
return true
},
getProgramParameter() {
return true
},
getSupportedExtensions() {
return ['WEBGL_debug_renderer_info']
},
readPixels(x, y, w, h, t, f, buf) {
if (buf) buf.fill(128)
},
},
{
get(t, p) {
if (p in t) return t[p]
if (typeof p === 'string' && p.startsWith('on')) return null
if (typeof p === 'string') return () => null
return undefined
},
},
)
}
const ctx2d = new Proxy(
{
fillRect() {},
clearRect() {},
getImageData(x, y, w, h) {
return { data: new Uint8ClampedArray(w * h * 4).fill(128), width: w, height: h }
},
putImageData() {},
createImageData(w, h) {
return { data: new Uint8ClampedArray(w * h * 4), width: w, height: h }
},
setTransform() {},
transform() {},
translate() {},
scale() {},
rotate() {},
save() {},
restore() {},
beginPath() {},
closePath() {},
moveTo() {},
lineTo() {},
stroke() {},
fill() {},
arc() {},
rect() {},
clip() {},
createLinearGradient() {
return { addColorStop() {} }
},
createRadialGradient() {
return { addColorStop() {} }
},
fillText() {},
strokeText() {},
measureText(t) {
return { width: String(t).length * 7 }
},
isPointInPath() {
return false
},
drawImage() {},
arcTo() {},
bezierCurveTo() {},
quadraticCurveTo() {},
createPattern() {
return {}
},
setLineDash() {},
getLineDash() {
return []
},
},
{
get(t, p) {
if (p in t) return t[p]
if (p === 'canvas') return {}
return undefined
},
},
)
const canvasProto = {
getContext(type) {
if (type === '2d') return ctx2d
if (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl') return fakeGL()
return null
},
toDataURL() {
return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='
},
toBlob(cb) {
cb && cb(null)
},
addEventListener() {},
removeEventListener() {},
setAttribute() {},
getBoundingClientRect() {
return { left: 0, top: 0, width: 300, height: 150 }
},
style: {},
width: 300,
height: 150,
}
// hydevice writes browser device state through document.cookie. Keep a small
// in-memory jar so Node has the same read/append semantics as a WebView.
const cookieJar = {}
const document = {
createElement(tag) {
const el = Object.assign({}, canvasProto, {
tagName: String(tag || 'div').toUpperCase(),
style: {},
children: [],
setAttribute() {},
getAttribute() {
return null
},
appendChild(c) {
this.children.push(c)
},
removeChild() {},
addEventListener() {},
removeEventListener() {},
classList: {
add() {},
remove() {},
contains() {
return false
},
},
attachShadow() {
return { appendChild() {} }
},
getBoundingClientRect() {
return { left: 0, top: 0, right: 100, bottom: 30, width: 100, height: 30 }
},
})
return el
},
createEvent() {
return { initEvent() {} }
},
createElementNS(ns, tag) {
return this.createElement(tag)
},
createTextNode(t) {
return { textContent: t }
},
createDocumentFragment() {
return { appendChild() {} }
},
getElementById() {
return null
},
querySelector() {
return null
},
querySelectorAll() {
return []
},
getElementsByTagName(t) {
if (String(t).toLowerCase() === 'head') return [this.head]
return []
},
documentElement: {
setAttribute() {},
getAttribute() {
return null
},
style: {},
removeChild() {},
},
head: { appendChild() {}, removeChild() {} },
body: {
appendChild() {},
removeChild() {},
insertBefore() {},
contains() {
return false
},
},
referrer: '',
title: 'pt_auth',
domain: 'aq.huya.com',
readyState: 'complete',
visibilityState: 'visible',
addEventListener() {},
removeEventListener() {},
}
document.location = W.location
Object.defineProperty(document, 'cookie', {
configurable: true,
get() {
return Object.entries(cookieJar)
.map(([k, v]) => `${k}=${v}`)
.join('; ')
},
set(value) {
const first = String(value || '').split(';', 1)[0]
const index = first.indexOf('=')
if (index > 0) cookieJar[first.slice(0, index).trim()] = first.slice(index + 1).trim()
},
})
W.__huyaCookieJar = cookieJar
W.document = document
W.localStorage = (() => {
const m = {}
return {
getItem: (k) => m[k] ?? null,
setItem: (k, v) => (m[k] = String(v)),
removeItem: (k) => delete m[k],
clear: () => {},
key: (i) => Object.keys(m)[i] ?? null,
get length() {
return Object.keys(m).length
},
}
})()
W.sessionStorage = (() => {
const m = {}
return {
getItem: (k) => m[k] ?? null,
setItem: (k, v) => (m[k] = String(v)),
removeItem: (k) => delete m[k],
clear: () => {},
key: (i) => null,
get length() {
return 0
},
}
})()
W.indexedDB = {
open() {
return {
onsuccess: null,
onerror: null,
onupgradeneeded: null,
result: {
objectStoreNames: {
contains() {
return false
},
},
createObjectStore() {
return { createIndex() {} }
},
},
set onsuccess(f) {
setTimeout(() => {}, 0)
},
}
},
}
W.performance = W.performance || {
now: () => Date.now(),
timeOrigin: Date.now(),
timing: { navigationStart: Date.now() },
getEntriesByType() {
return []
},
mark() {},
measure() {},
}
W.AudioContext = class {
constructor() {
this.sampleRate = 48000
this.destination = {}
}
createOscillator() {
return {
type: '',
frequency: { value: 0, setValueAtTime() {} },
connect() {},
start() {},
stop() {},
}
}
createDynamicsCompressor() {
return {
threshold: { value: 0 },
knee: { value: 0 },
ratio: { value: 0 },
attack: { value: 0 },
release: { value: 0 },
connect() {},
}
}
createAnalyser() {
return {
frequencyBinCount: 1024,
getFloatFrequencyData(a) {
a && a.fill(-100)
},
}
}
createGain() {
return { gain: { value: 1, setValueAtTime() {} }, connect() {} }
}
createScriptProcessor() {
return { connect() {}, onaudioprocess: null }
}
decodeAudioData() {
return Promise.resolve({
getChannelData() {
return new Float32Array(5000)
},
})
}
resume() {
return Promise.resolve()
}
close() {
return Promise.resolve()
}
}
W.webkitAudioContext = W.AudioContext
W.OfflineAudioContext = W.AudioContext
W.WebGLRenderingContext = {
RENDERER: glConsts.RENDERER,
VENDOR: glConsts.VENDOR,
VERSION: glConsts.VERSION,
SHADING_LANGUAGE_VERSION: glConsts.SHADING_LANGUAGE_VERSION,
MAX_TEXTURE_SIZE: glConsts.MAX_TEXTURE_SIZE,
}
W.RTCPeerConnection = class {
createDataChannel() {
return {}
}
createOffer() {
return Promise.resolve({})
}
setLocalDescription() {
return Promise.resolve()
}
close() {}
}
W.webkitRTCPeerConnection = W.RTCPeerConnection
W.Notification = { permission: 'default' }
W.permissions = {
query() {
return Promise.resolve({ state: 'prompt' })
},
}
W.matchMedia = () => ({
matches: false,
media: '',
addListener() {},
removeListener() {},
addEventListener() {},
removeEventListener() {},
})
W.getComputedStyle = () =>
new Proxy(
{ getPropertyValue: () => '' },
{
get(t, p) {
if (p in t) return t[p]
return () => ''
},
},
)
W.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 16)
W.cancelAnimationFrame = clearTimeout
W.requestIdleCallback = (cb) =>
setTimeout(() => cb({ didTimeout: false, timeRemaining: () => 16 }), 1)
W.cancelIdleCallback = clearTimeout
W.chrome = W.chrome || {
runtime: {},
app: { isInstalled: false },
csi() {
return {}
},
loadTimes() {
return {}
},
}
W.HTMLCanvasElement = W.HTMLCanvasElement || function HTMLCanvasElement() {}
W.HTMLElement = W.HTMLElement || function HTMLElement() {}
W.Element = W.Element || function Element() {}
W.addEventListener = () => {}
W.removeEventListener = () => {}
W.dispatchEvent = () => true
W.crypto = require('crypto').webcrypto
W.TextEncoder = require('util').TextEncoder
W.TextDecoder = require('util').TextDecoder
}
module.exports = { makeEnv }
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
{
"type": "commonjs"
}
@@ -0,0 +1,169 @@
// hydevice 指纹运行器: 仿真 Android WebView 环境 -> 真实请求 df/token + df/collect -> 输出 sdid
// argv: [stateDir, deviceJson] — deviceJson 为账号画像派生的设备覆盖参数 (一号一设备)
process.on('uncaughtException', (e) => {
console.error('[uncaught]', String(e && e.message).slice(0, 120))
})
const fs = require('fs')
const path = require('path')
const https = require('https')
globalThis.window = globalThis
const stateDir = process.argv[2] || '.'
let _deviceOverrides = null
// Prefer an explicitly supplied JSON path; otherwise use the device hint that
// get_huya_sdid writes into the per-account state directory.
try {
const hintPath =
process.argv[3] && process.argv[3].endsWith('.json')
? process.argv[3]
: path.join(stateDir, 'device.json')
_deviceOverrides = JSON.parse(fs.readFileSync(hintPath, 'utf8'))
} catch (e) {}
require(path.join(__dirname, 'env.js')).makeEnv(_deviceOverrides)
// ---- localStorage 持久化(设备稳定性) ----
try {
fs.mkdirSync(stateDir, { recursive: true })
} catch (e) {}
const lsFile = path.join(stateDir, 'localstorage.json')
let _ls = {}
try {
_ls = JSON.parse(fs.readFileSync(lsFile, 'utf8'))
} catch (e) {}
globalThis.localStorage = {
getItem: (k) => (k in _ls ? _ls[k] : null),
setItem: (k, v) => {
_ls[k] = String(v)
try {
fs.writeFileSync(lsFile, JSON.stringify(_ls))
} catch (e) {}
},
removeItem: (k) => {
delete _ls[k]
try {
fs.writeFileSync(lsFile, JSON.stringify(_ls))
} catch (e) {}
},
clear: () => {
_ls = {}
try {
fs.writeFileSync(lsFile, JSON.stringify(_ls))
} catch (e) {}
},
key: (i) => Object.keys(_ls)[i] || null,
get length() {
return Object.keys(_ls).length
},
}
globalThis.sessionStorage = {
getItem: () => null,
setItem() {},
removeItem() {},
clear() {},
key: () => null,
get length() {
return 0
},
}
// ---- 真实 XHR ----
function xhr(url, body, headers) {
return new Promise((resolve) => {
const u = new URL(url)
const data = body == null ? null : Buffer.from(String(body), 'utf8')
const req = https.request(
{
hostname: u.hostname,
port: 443,
path: u.pathname + u.search,
method: 'POST',
headers: Object.assign(
{
accept: 'application/json, text/javascript, */*; q=0.01',
'content-type': 'application/json',
origin: 'https://aq.huya.com',
referer: 'https://aq.huya.com/',
'user-agent': globalThis.navigator.userAgent,
'accept-language': 'zh-CN,zh;q=0.9',
},
headers || {},
),
timeout: 15000,
},
(res) => {
let chunks = []
res.on('data', (c) => chunks.push(c))
res.on('end', () =>
resolve({ status: res.statusCode, text: Buffer.concat(chunks).toString('utf8') }),
)
},
)
req.on('timeout', () => {
req.destroy()
resolve({ status: 0, text: '' })
})
req.on('error', () => resolve({ status: 0, text: '' }))
if (data) req.write(data)
req.end()
})
}
globalThis.XMLHttpRequest = class {
open(m, u) {
this._m = m
this._u = u
this._h = {}
}
setRequestHeader(k, v) {
this._h[k] = v
}
getAllResponseHeaders() {
return ''
}
addEventListener(t, f) {
if (t === 'load' || t === 'readystatechange') this._on = f
}
send(d) {
xhr(this._u, d, this._h).then((r) => {
this.status = r.status
this.readyState = 4
this.responseText = r.text
this.response = r.text
const done = () => {
if (String(this._u).includes('/collect')) {
try {
const j = JSON.parse(r.text)
if (j && j.data) {
if (j.data.sdid) console.log('__SDID__' + j.data.sdid)
if (j.data.hdid) console.log('__HDID__' + j.data.hdid)
if (globalThis.document && globalThis.document.cookie) {
console.log('__COOKIES__' + globalThis.document.cookie)
}
setTimeout(() => process.exit(0), 200)
}
} catch (e) {}
}
try {
this.onreadystatechange && this.onreadystatechange()
} catch (e) {}
try {
this._on && this._on()
} catch (e) {}
try {
this.onload && this.onload()
} catch (e) {}
}
setTimeout(done, 30)
})
}
}
eval(fs.readFileSync(path.join(__dirname, 'hydevice-prod-1.2.45-min.js'), 'utf8'))
try {
globalThis.initFingerprint(process.argv[3] || '5008')
} catch (e) {
console.error('[init]', e.message.slice(0, 120))
}
setTimeout(() => {
console.error('__TIMEOUT__')
process.exit(2)
}, 25000)
@@ -0,0 +1,497 @@
/** 虎牙 Cookie 规范化、网页字段补齐与 QIMEI 指纹生成。 */
import crypto from 'node:crypto'
export type CookiePair = [string, string]
const MASK_64 = 0xffffffffffffffffn
const QIMEI_EPOCH_1582 = -12216643543000n
const GAME_DID_CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_'
const DEFAULT_QIMEI_FEATURE = {
screen: { resolution: '800x600', availResolution: '800x600' },
navigator: {
deviceMemory: 16,
pdfViewerEnabled: true,
cookieEnabled: true,
productSub: '20030107',
hardwareConcurrency: 10,
},
webgl: 'Google Inc. (Apple)~ANGLE (Apple, ANGLE Metal Renderer: Apple M4, Unspecified Version)',
timezoneOffset: -480,
}
const DEVICE_GPU_FEATURES: Record<string, [string, string]> = {
xiaomi: ['Qualcomm', 'Adreno (TM) 650'],
redmi: ['Qualcomm', 'Adreno (TM) 650'],
vivo: ['Qualcomm', 'Adreno (TM) 650'],
iqoo: ['Qualcomm', 'Adreno (TM) 650'],
oppo: ['Qualcomm', 'Adreno (TM) 650'],
oneplus: ['Qualcomm', 'Adreno (TM) 650'],
samsung: ['ARM', 'Mali-G78'],
honor: ['ARM', 'Mali-G78'],
motorola: ['Qualcomm', 'Adreno (TM) 642L'],
realme: ['Qualcomm', 'Adreno (TM) 650'],
nubia: ['Qualcomm', 'Adreno (TM) 650'],
gionee: ['Qualcomm', 'Adreno (TM) 506'],
}
const COOKIE_FILL_MAP: CookiePair[] = [
['SoundValue', '0.50'],
['alphaValue', '0.80'],
['isInLiveRoom', 'true'],
['udb_passdata', '3'],
['udb_accdata', 'undefined'],
]
export function cookiePairs(cookie: string): CookiePair[] {
const pairs: CookiePair[] = []
for (const part of String(cookie || '').split(';')) {
const item = part.trim()
if (!item || !item.includes('=')) continue
const separator = item.indexOf('=')
const key = item.slice(0, separator).trim()
if (!key) continue
pairs.push([key, item.slice(separator + 1).trim()])
}
return pairs
}
export function normalizeCookiePairs(pairs: CookiePair[]): string {
const orderedKeys: string[] = []
const values = new Map<string, string>()
for (const [rawKey, rawValue] of pairs) {
const key = String(rawKey || '').trim()
if (!key) continue
if (!values.has(key)) orderedKeys.push(key)
values.set(key, rawValue === null || rawValue === undefined ? '' : String(rawValue).trim())
}
return orderedKeys.map((key) => `${key}=${values.get(key) || ''}`).join('; ')
}
export function cookieValue(cookie: string, key: string): string {
let value = ''
for (const [itemKey, itemValue] of cookiePairs(cookie)) {
if (itemKey === key) value = itemValue
}
return value
}
export function buildHuyaLoginContext(cookie: string): string {
const guidData = cookieValue(cookie, 'udb_guiddata')
const yamid = cookieValue(cookie, '__yamid_new')
const guid = cookieValue(cookie, 'guid')
if (!guidData || !yamid || !guid) return ''
return `WB-${guidData}-${yamid}-${guid}`
}
function replaceCookie(pairs: CookiePair[], key: string, value: string): void {
const next = String(value ?? '').trim()
if (!next) return
for (let index = pairs.length - 1; index >= 0; index -= 1) {
if (pairs[index]?.[0] === key) pairs.splice(index, 1)
}
pairs.push([key, next])
}
function addCookie(pairs: CookiePair[], key: string, value: string): void {
const next = String(value ?? '').trim()
if (!next) return
if (pairs.some(([itemKey]) => itemKey === key)) return
pairs.push([key, next])
}
export function mergeHuyaWebCookie(input: {
cookie: string
biztoken: string
sdid: string
hdid: string
deviceInfo: Record<string, string>
uid: number
username?: string
includeTransient?: boolean
}): string {
const pairs = cookiePairs(input.cookie)
const username = String(input.username || '').trim()
// 扫码成功时服务端会通过 Set-Cookie 下发独立的 udb_cred;只有缺失时才用
// biztoken 兜底,不能覆盖服务端签发的真实值。
if (!cookieValue(input.cookie, 'udb_cred')) {
replaceCookie(pairs, 'udb_cred', input.biztoken)
}
if (input.uid) {
replaceCookie(pairs, 'udb_uid', String(input.uid))
replaceCookie(pairs, 'yyuid', String(input.uid))
}
replaceCookie(pairs, 'udb_biztoken', input.biztoken)
replaceCookie(pairs, 'udb_version', '1.0')
replaceCookie(pairs, 'udb_origin', '0')
replaceCookie(pairs, 'udb_status', '1')
replaceCookie(pairs, 'sdid', input.sdid)
replaceCookie(pairs, 'hdid', input.hdid)
if (username.startsWith('hy_')) {
replaceCookie(pairs, 'udb_passport', username)
replaceCookie(pairs, 'username', username)
}
const optionalKeys = [
'guid',
'udb_guiddata',
'__yamid_new',
'game_did',
'_qimei_uuid42',
'_qimei_fingerprint',
'_qimei_h38',
'udb_anouid',
'udb_anobiztoken',
'__yasmid',
'__yamid_tt1',
'SoundValue',
'alphaValue',
'isInLiveRoom',
'udb_deviceid',
'udb_passdata',
'_rep_cnt',
'Hm_lvt_51700b6c722f5bb4cf39906a596ea41f',
'HMACCOUNT',
'udb_appid',
'rep_cnt',
'udb_accdata',
'h_unt',
'__yaoldyyuid',
'_yasids',
'huya_flash_rep_cnt',
'huyasp_rep_cnt',
'huyawap_rep_cnt',
'huya_hd_rep_cnt',
'Hm_lpvt_51700b6c722f5bb4cf39906a596ea41f',
'huya_web_rep_cnt',
]
for (const key of optionalKeys) {
const value = String(input.deviceInfo[key] || '').trim()
if (!value) continue
for (let index = pairs.length - 1; index >= 0; index -= 1) {
if (pairs[index]?.[0] === key) pairs.splice(index, 1)
}
pairs.push([key, value])
}
const webGuideData = String(input.deviceInfo.web_guiddata || input.deviceInfo.udb_guiddata || '')
if (webGuideData) replaceCookie(pairs, 'udb_guiddata', webGuideData)
const webDeviceId = String(input.deviceInfo.web_device_id || '')
if (/^w_\d{19}$/.test(webDeviceId)) replaceCookie(pairs, 'udb_deviceid', webDeviceId)
return normalizeCookiePairs(
input.includeTransient === false
? pairs.filter(([key]) => key !== 'web_qrlogin_confirm_id')
: pairs,
)
}
export type QimeiFeature = {
screen: { resolution: string; availResolution: string }
navigator: {
deviceMemory: number | string
pdfViewerEnabled: boolean
cookieEnabled: boolean
productSub: string
hardwareConcurrency: number | string
}
webgl: string
timezoneOffset: number
}
export function qimeiFeatureFromDeviceProfile(
deviceInfo: Record<string, unknown> | null | undefined,
): QimeiFeature {
const info = deviceInfo || {}
const width = String(info.width || '1080')
const height = String(info.height || '2400')
const vendor = String(info.vendor || '')
.trim()
.toLowerCase()
const model = String(info.model || 'Android WebView').trim()
const [gpuVendor, renderer] = DEVICE_GPU_FEATURES[vendor] || ['Qualcomm', `${model} Graphics`]
return {
screen: { resolution: `${width}x${height}`, availResolution: `${width}x${height}` },
navigator: {
deviceMemory: (info.device_memory as number | string | undefined) || 8,
pdfViewerEnabled: false,
cookieEnabled: true,
productSub: '20030107',
hardwareConcurrency: (info.hardware_concurrency as number | string | undefined) || 8,
},
webgl: `Google Inc. (${gpuVendor})~ANGLE (${gpuVendor}, ${renderer})`,
timezoneOffset: -480,
}
}
function jsText(value: unknown): string {
if (value === null || value === undefined) return ''
if (typeof value === 'boolean') return value ? 'true' : 'false'
return String(value)
}
function qimeiFeatureString(feature: QimeiFeature): string {
return (
[
feature.screen.resolution,
feature.screen.availResolution,
feature.navigator.deviceMemory,
feature.navigator.pdfViewerEnabled,
feature.navigator.cookieEnabled,
feature.navigator.productSub,
feature.navigator.hardwareConcurrency,
feature.webgl,
feature.timezoneOffset,
]
.map(jsText)
.join('###') + '###'
)
}
function rotl64(value: bigint, shift: number): bigint {
const normalizedShift = shift % 64
if (normalizedShift === 0) return value & MASK_64
return ((value << BigInt(normalizedShift)) | (value >> BigInt(64 - normalizedShift))) & MASK_64
}
function fmix64(value: bigint): bigint {
let next = value
next ^= next >> 33n
next = (next * 0xff51afd7ed558ccdn) & MASK_64
next ^= next >> 33n
next = (next * 0xc4ceb9fe1a85ec53n) & MASK_64
next ^= next >> 33n
return next & MASK_64
}
export function x64hash128(value: string, seed = 0): string {
const utf16 = Buffer.from(value, 'utf16le')
const data = Buffer.alloc(utf16.length / 2)
for (let index = 0; index < data.length; index += 1) data[index] = utf16[index * 2] || 0
const length = data.length
let h1 = BigInt(seed) & MASK_64
let h2 = BigInt(seed) & MASK_64
const c1 = 0x87c37b91114253d5n
const c2 = 0x4cf5ad432745937fn
for (let offset = 0; offset < length - (length % 16); offset += 16) {
let k1 = data.readBigUInt64LE(offset)
let k2 = data.readBigUInt64LE(offset + 8)
k1 = (rotl64((k1 * c1) & MASK_64, 31) * c2) & MASK_64
h1 ^= k1
h1 = (rotl64(h1, 27) + h2) & MASK_64
h1 = (h1 * 5n + 0x52dce729n) & MASK_64
k2 = (rotl64((k2 * c2) & MASK_64, 33) * c1) & MASK_64
h2 ^= k2
h2 = (rotl64(h2, 31) + h1) & MASK_64
h2 = (h2 * 5n + 0x38495ab5n) & MASK_64
}
const tailStart = length - (length % 16)
const tail = data.subarray(tailStart)
const tailLength = tail.length
let k1 = 0n
let k2 = 0n
for (let index = tailLength - 1; index > 7; index -= 1) {
k2 ^= BigInt(tail[index] || 0) << BigInt(8 * (index - 8))
}
if (tailLength >= 9) h2 ^= (rotl64((k2 * c2) & MASK_64, 33) * c1) & MASK_64
for (let index = Math.min(tailLength, 8) - 1; index > 0; index -= 1) {
k1 ^= BigInt(tail[index] || 0) << BigInt(8 * index)
}
if (tailLength >= 1) {
k1 ^= BigInt(tail[0] || 0) << 32n
h1 ^= (rotl64((k1 * c1) & MASK_64, 31) * c2) & MASK_64
}
h1 ^= BigInt(length)
h2 ^= BigInt(length)
h1 = (h1 + h2) & MASK_64
h2 = (h2 + h1) & MASK_64
h1 = fmix64(h1)
h2 = fmix64(h2)
h1 = (h1 + h2) & MASK_64
h2 = (h2 + h1) & MASK_64
return `${h1.toString(16).padStart(16, '0')}${h2.toString(16).padStart(16, '0')}`
}
export function qimeiFingerprintGenerate(feature?: QimeiFeature): string {
return x64hash128(qimeiFeatureString(feature || DEFAULT_QIMEI_FEATURE), 31)
}
function crc32(buffer: Buffer): number {
let crc = 0xffffffff
for (const byte of buffer) {
crc ^= byte
for (let bit = 0; bit < 8; bit += 1) {
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0)
}
}
return (crc ^ 0xffffffff) >>> 0
}
function returnBase16(value: bigint): string {
if (value <= 0n) return ''
return value.toString(16).toUpperCase()
}
function generateBits(value: bigint, startBit: number, endBit: number): string {
const hex = returnBase16(value)
let output = ''
for (let index = Math.floor(startBit / 4); index <= Math.floor(endBit / 4); index += 1) {
output += hex[index] || '0'
}
return output
}
function randomInt(maxExclusive: number): number {
return Math.floor(Math.random() * maxExclusive)
}
function yamidNewGenerate32(): string {
const nowMs = BigInt(Date.now())
const value = nowMs - QIMEI_EPOCH_1582
return [
generateBits(value, 0, 31),
generateBits(value, 32, 47),
`${generateBits(value, 48, 59)}1`,
generateBits(BigInt(randomInt(4095)), 0, 7),
generateBits(BigInt(randomInt(4095)), 0, 7),
generateBits(BigInt(randomInt(8191)), 0, 7) +
generateBits(BigInt(randomInt(8191)), 8, 15) +
generateBits(BigInt(randomInt(8191)), 0, 7) +
generateBits(BigInt(randomInt(8191)), 8, 15) +
generateBits(BigInt(randomInt(8191)), 0, 15),
].join('')
}
function gameDidGenerate(length = 35): string {
let output = ''
for (let index = 0; index < length; index += 1) {
output += GAME_DID_CHARS[randomInt(GAME_DID_CHARS.length - 1)] || ''
}
return output
}
export function qimeiUuid42(
nowMs = Date.now(),
options: { cookieEnabled?: boolean; randomValue?: number } = {},
): string {
const current = new Date(nowMs)
const yearHex = (current.getFullYear() % 100).toString(16).padStart(2, '0')
const monthHex = (current.getMonth() + 1).toString(16)
const dateHex = [
current.getDate(),
current.getHours(),
current.getMinutes(),
current.getSeconds(),
]
.map((value) => value.toString(16).padStart(2, '0'))
.join('')
const prefix = yearHex + monthHex + dateHex + (options.cookieEnabled === false ? '000' : '100')
const randomSource = () => options.randomValue ?? Math.random()
const randomSegment = Array.from({ length: 20 }, () =>
Math.min(15, Math.max(0, Math.trunc(randomSource() * 16))).toString(16),
).join('')
const template = `${prefix}----${randomSegment}----`
const replacement = crc32(Buffer.from(template, 'ascii')).toString(16).padStart(8, '0')
return prefix + replacement.slice(0, 4) + randomSegment + replacement.slice(4)
}
export function fillWebCookieFields(input: {
cookie: string
guid: string
udbGuiddata?: string
uid?: number | string
keepGuid?: boolean
qimeiFeature?: QimeiFeature
}): string {
const pairs = new Map<string, string>()
for (const [key, value] of cookiePairs(input.cookie)) pairs.set(key, value)
const add = (key: string, value: string) => {
if (value && !pairs.get(key)) pairs.set(key, value)
}
const uid = Number(input.uid || 0) || 0
if (input.guid) {
if (!(input.keepGuid !== false && pairs.get('guid'))) add('guid', input.guid)
} else {
add(
'guid',
`0a${crypto.randomBytes(5).toString('hex')}966a${crypto.randomBytes(8).toString('hex')}`,
)
}
add('udb_guiddata', input.udbGuiddata || crypto.randomBytes(16).toString('hex'))
add('udb_appid', '5002')
add('_qimei_uuid42', qimeiUuid42())
add('__yamid_new', yamidNewGenerate32())
add('game_did', gameDidGenerate())
add('_qimei_fingerprint', qimeiFingerprintGenerate(input.qimeiFeature))
add('_qimei_h38', pairs.get('_qimei_uuid42') || '')
const yasmid = Math.random().toFixed(17)
add('__yasmid', yasmid)
add('__yamid_tt1', yasmid)
add('_rep_cnt', String(randomInt(10)))
add('rep_cnt', String(randomInt(10)))
add('h_unt', String(Math.floor(Date.now() / 1000)))
if (uid) add('__yaoldyyuid', String(uid))
add('_yasids', `__rootsid%3D${yamidNewGenerate32()}`)
add('Hm_lvt_51700b6c722f5bb4cf39906a596ea41f', String(Math.floor(Date.now() / 1000)))
add('Hm_lpvt_51700b6c722f5bb4cf39906a596ea41f', String(Math.floor(Date.now() / 1000)))
add('HMACCOUNT', crypto.randomBytes(8).toString('hex').toUpperCase())
add('huya_flash_rep_cnt', String(1 + randomInt(400)))
add('huyasp_rep_cnt', String(1 + randomInt(40)))
add('huya_hd_rep_cnt', String(1 + randomInt(40)))
add('huya_web_rep_cnt', String(300 + randomInt(601)))
for (const [key, value] of COOKIE_FILL_MAP) add(key, value)
return Array.from(pairs.entries())
.map(([key, value]) => `${key}=${value}`)
.join('; ')
}
export type HuyaWebCookieIdentity = {
cookie: string
context: string
guid: string
udbGuiddata: string
yamidNew: string
}
/** 为网页扫码登录补齐稳定的设备身份字段,并生成服务端要求的 WB context。 */
export function ensureHuyaWebCookieIdentity(input: {
cookie?: string
guid?: string
udbGuiddata?: string
uid?: number | string
qimeiFeature?: QimeiFeature
}): HuyaWebCookieIdentity {
const fillInput: Parameters<typeof fillWebCookieFields>[0] = {
cookie: String(input.cookie || ''),
guid: String(input.guid || ''),
}
if (input.udbGuiddata) fillInput.udbGuiddata = input.udbGuiddata
if (input.uid !== undefined) fillInput.uid = input.uid
if (input.qimeiFeature) fillInput.qimeiFeature = input.qimeiFeature
const cookie = fillWebCookieFields(fillInput)
const pairs = cookiePairs(cookie)
const set = (key: string, value: string) => {
const next = value.trim()
if (!next) return
for (let index = pairs.length - 1; index >= 0; index -= 1) {
if (pairs[index]?.[0] === key) pairs.splice(index, 1)
}
pairs.push([key, next])
}
set('udb_appid', '5002')
const normalized = normalizeCookiePairs(pairs)
return {
cookie: normalized,
context: buildHuyaLoginContext(normalized),
guid: cookieValue(normalized, 'guid'),
udbGuiddata: cookieValue(normalized, 'udb_guiddata'),
yamidNew: cookieValue(normalized, '__yamid_new'),
}
}
@@ -0,0 +1,132 @@
/** 通过随项目携带的 hydevice runner 获取虎牙高信任 sdid。 */
import { execFile } from 'node:child_process'
import fs from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
const execFileAsync = promisify(execFile)
const FINGERPRINT_DIR = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'assets/fingerprint',
)
const RUNNER_PATH = path.join(FINGERPRINT_DIR, 'runner.js')
const SDID_PREFIX = '__SDID__'
const HDID_PREFIX = '__HDID__'
const COOKIE_PREFIX = '__COOKIES__'
export type HuyaFingerprintResult = {
sdid: string
hdid: string
cookies: Record<string, string>
source: 'fingerprint' | 'fallback'
message?: string
}
export type FingerprintResolver = (
stateDir: string,
appId?: string,
) => Promise<HuyaFingerprintResult>
function parseCookieText(text: string): Record<string, string> {
const cookies: Record<string, string> = {}
for (const part of text.split(';')) {
const item = part.trim()
if (!item || !item.includes('=')) continue
const separator = item.indexOf('=')
const key = item.slice(0, separator).trim()
const value = item.slice(separator + 1).trim()
if (key && value) cookies[key] = value
}
return cookies
}
async function runFingerprintRunner(
stateDir: string,
appId = '5008',
): Promise<HuyaFingerprintResult | null> {
await fs.mkdir(stateDir, { recursive: true })
try {
const { stdout, stderr } = await execFileAsync(
process.execPath,
[RUNNER_PATH, stateDir, appId],
{
cwd: FINGERPRINT_DIR,
timeout: 45_000,
maxBuffer: 2 * 1024 * 1024,
},
)
let sdid = ''
let hdid = ''
let cookies: Record<string, string> = {}
for (const rawLine of stdout.split(/\r?\n/)) {
const line = rawLine.trim()
if (line.startsWith(SDID_PREFIX) && line.length > SDID_PREFIX.length + 20) {
sdid = line.slice(SDID_PREFIX.length)
} else if (line.startsWith(HDID_PREFIX) && line.length > HDID_PREFIX.length + 20) {
hdid = line.slice(HDID_PREFIX.length)
} else if (line.startsWith(COOKIE_PREFIX)) {
cookies = parseCookieText(line.slice(COOKIE_PREFIX.length))
}
}
if (sdid) return { sdid, hdid, cookies, source: 'fingerprint' }
const detail = stderr.trim().split(/\r?\n/).pop() || 'runner 未返回 sdid'
throw new Error(detail.slice(0, 160))
} catch (error) {
if (error instanceof Error) throw error
throw new Error(String(error), { cause: error })
}
}
async function fallbackFingerprint(): Promise<HuyaFingerprintResult | null> {
try {
const tokenResponse = await fetch('https://df.huya.com/web/df/token', {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({ encryptVersion: '1.0.1', fingerprintVersion: '1.2.45' }),
signal: AbortSignal.timeout(20_000),
})
if (!tokenResponse.ok) return null
const tokenPayload = (await tokenResponse.json()) as { data?: { token?: string } }
const token = String(tokenPayload.data?.token || '').trim()
if (!token) return null
const collectResponse = await fetch('https://df.huya.com/web/df/collect', {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({ token }),
signal: AbortSignal.timeout(20_000),
})
if (!collectResponse.ok) return null
const collectPayload = (await collectResponse.json()) as {
data?: { sdid?: string; hdid?: string }
}
const sdid = String(collectPayload.data?.sdid || '').trim()
if (!sdid) return null
return {
sdid,
hdid: String(collectPayload.data?.hdid || '').trim(),
cookies: {},
source: 'fallback',
message: '低信任指纹(降级)',
}
} catch {
return null
}
}
export const getHuyaFingerprint: FingerprintResolver = async (stateDir, appId = '5008') => {
try {
const result = await runFingerprintRunner(stateDir, appId)
if (result) return result
} catch {
// runner 失败时使用低信任降级,保持与现有 Connector 一致的可恢复行为。
}
const fallback = await fallbackFingerprint()
if (fallback) return fallback
throw new Error('获取虎牙设备指纹失败')
}
export async function removeFingerprintState(stateDir: string): Promise<void> {
await fs.rm(stateDir, { recursive: true, force: true }).catch(() => undefined)
}
@@ -0,0 +1,675 @@
/**
* order_site 内置虎牙半自动履约 Connector。
*
* 登录、角色绑定、CK 兑换和一次性浏览器注入票据全部在本进程完成。原始 CK
* 只保存在内存会话中,不落库、不写日志、不出现在票据字符串里。
*/
import crypto from 'node:crypto'
import os from 'node:os'
import path from 'node:path'
import { runtimeConfig } from '../../../config/runtime.js'
import { createHttpError } from '../../../utils/http.js'
import type {
ConnectorBindingSession,
ConnectorBrowserTicket,
ConnectorInjectionPayload,
ConnectorLoginSession,
} from '../huya-connector-client.js'
import {
cookiePairs,
ensureHuyaWebCookieIdentity,
fillWebCookieFields,
mergeHuyaWebCookie,
normalizeCookiePairs,
qimeiFeatureFromDeviceProfile,
} from './cookie.js'
import {
getHuyaFingerprint,
removeFingerprintState,
type FingerprintResolver,
} from './fingerprint.js'
import {
buildBindUrls,
getLiveLinkMiniQrcode,
getLiveLinkQrcodeStatus,
HuyaQrRole,
HuyaRpcClient,
webBehavior,
type HuyaBindResponse,
type HuyaGameAccountData,
} from './huya-rpc.js'
const LOGIN_QR_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
const MAX_SESSIONS = 500
const NATIVE_TICKET_PREFIX = 'native-ticket-'
type FetchLike = typeof fetch
type NativeLoginStatus = ConnectorLoginSession['status']
type NativeBindingStatus = ConnectorBindingSession['status']
type NativeBindState = {
status: NativeBindingStatus
qrImage: string
qrToken: string
expiresAtMs: number
account: string
role: string
channel: string
message: string
}
type NativeTicket = {
ticket: string
targetUrl: string
cookie: string
expiresAtMs: number
}
type NativeSession = {
sessionId: string
orderNo: string
taskId: number
stateDir: string
createdAtMs: number
expiresAtMs: number
qr: HuyaQrRole
behavior: string
page: string
qrid: string
qrImage: string
sdid: string
hdid: string
deviceInfo: Record<string, string>
status: NativeLoginStatus
uid: number
nickname: string
cookie: string
message: string
binding: NativeBindState
tickets: Map<string, NativeTicket>
}
export type HuyaNativeConnectorOptions = {
fetchImpl?: FetchLike
getFingerprint?: FingerprintResolver
now?: () => number
stateRoot?: string
rpcTimeoutMs?: number
}
function defaultBindingState(): NativeBindState {
return {
status: 'not_started',
qrImage: '',
qrToken: '',
expiresAtMs: 0,
account: '',
role: '',
channel: '',
message: '',
}
}
function iso(value: number): string {
return new Date(value).toISOString()
}
function extractInt(value: unknown): number {
const parsed = Number.parseInt(String(value ?? '').trim(), 10)
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0
}
function readString(value: unknown): string {
return String(value ?? '').trim()
}
function parseRole(response: HuyaBindResponse | null): {
account: string
role: string
channel: string
} {
if (!response || response.status !== 200) return { account: '', role: '', channel: '' }
const data: HuyaGameAccountData = response.accountData
const account = data.gameAccount.nick || data.gameRole.roleName
const role = data.gameRole.roleName
const channel = [data.gameRole.platName, data.gameRole.areaName].filter(Boolean).join(' · ')
return { account, role, channel }
}
function configValues() {
const huya = runtimeConfig.semiAuto?.huya
const bindActId = extractInt(huya?.bindActId || 9271)
const outerActId = readString(huya?.outerActId || '17096')
const roomPid = readString(huya?.roomPid || '1199650619883')
if (!bindActId) {
throw createHttpError('虎牙绑定活动 ID 未配置', {
statusCode: 503,
errorCode: 'huya_bind_act_not_configured',
})
}
return { bindActId, outerActId, roomPid }
}
export class HuyaNativeConnector {
private readonly sessions = new Map<string, NativeSession>()
private readonly locks = new Map<string, Promise<unknown>>()
private readonly fetchImpl: FetchLike
private readonly fingerprintResolver: FingerprintResolver
private readonly now: () => number
private readonly stateRoot: string
private readonly rpcTimeoutMs: number
constructor(options: HuyaNativeConnectorOptions = {}) {
this.fetchImpl = options.fetchImpl || fetch
this.fingerprintResolver = options.getFingerprint || getHuyaFingerprint
this.now = options.now || (() => Date.now())
this.stateRoot = options.stateRoot || path.join(os.tmpdir(), 'huya-semi-auto-device')
this.rpcTimeoutMs = options.rpcTimeoutMs || 15_000
}
async createLoginSession(input: {
taskId: number
orderNo: string
}): Promise<ConnectorLoginSession> {
this.prune()
const sessionId = crypto.randomBytes(24).toString('base64url')
const stateDir = path.join(this.stateRoot, sessionId)
try {
const fingerprint = await this.fingerprintResolver(stateDir, '5008')
if (!fingerprint.sdid) throw new Error(fingerprint.message || '获取虎牙设备指纹失败')
const fingerprintCookie = normalizeCookiePairs(Object.entries(fingerprint.cookies || {}))
const webIdentity = ensureHuyaWebCookieIdentity({
cookie: fingerprintCookie,
qimeiFeature: qimeiFeatureFromDeviceProfile(fingerprint.cookies),
})
if (!webIdentity.context) throw new Error('虎牙网页设备身份生成失败')
const qr = new HuyaQrRole({
sdid: fingerprint.sdid,
cookie: webIdentity.cookie,
context: webIdentity.context,
fetchImpl: this.fetchImpl,
})
await qr.bootstrapMiddle()
const behavior = webBehavior()
const response = await qr.call('/qrLgn/getQrId', '70001', {
behavior: behavior.behavior,
type: '',
domainList: '',
page: behavior.page,
})
const qrid = readString(response.data?.qrId)
if (!qrid) throw new Error(response.message || '获取虎牙登录二维码失败')
const qrImage = await qr.getQrImg(qrid)
qr.activateFingerprintSdid()
const createdAtMs = this.now()
const session: NativeSession = {
sessionId,
orderNo: input.orderNo,
taskId: input.taskId,
stateDir,
createdAtMs,
expiresAtMs: createdAtMs + LOGIN_QR_TTL_MS,
qr,
behavior: behavior.behavior,
page: behavior.page,
qrid,
qrImage,
sdid: fingerprint.sdid,
hdid: fingerprint.hdid,
deviceInfo: {
...(fingerprint.cookies || {}),
...Object.fromEntries(cookiePairs(webIdentity.cookie)),
sdid: fingerprint.sdid,
hdid: fingerprint.hdid,
udb_appid: '5002',
},
status: 'waiting_scan',
uid: 0,
nickname: '',
cookie: '',
message: '等待使用虎牙 App 扫码',
binding: defaultBindingState(),
tickets: new Map(),
}
this.sessions.set(sessionId, session)
this.prune()
return this.loginView(session)
} 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_native_login_create_failed' },
)
}
}
async getLoginSession(sessionId: string): Promise<ConnectorLoginSession> {
return this.withLock(sessionId, async () => {
const session = this.requireSession(sessionId)
if (
session.status === 'authenticated' ||
session.status === 'failed' ||
session.status === 'expired'
) {
return this.loginView(session)
}
if (session.expiresAtMs <= this.now()) {
session.status = 'expired'
session.message = '登录二维码已过期,请重新获取'
return this.loginView(session)
}
const response = await session.qr.call('/qrLgn/tryQrLogin', '70003', {
qrId: session.qrid,
remember: '1',
domainList: '',
behavior: session.behavior,
page: session.page,
})
const data = response.data && typeof response.data === 'object' ? response.data : {}
const stage = extractInt(data.stage)
if (stage >= 2) {
await this.finishLogin(session, data)
} else if (stage === 1) {
session.status = 'scanned'
session.message = '已扫码,请在虎牙 App 确认登录'
} else {
session.status = 'waiting_scan'
session.message = '等待使用虎牙 App 扫码'
}
return this.loginView(session)
})
}
async startBinding(sessionId: string): Promise<ConnectorBindingSession> {
return this.withLock(sessionId, async () => {
const session = this.requireAuthenticatedSession(sessionId)
const { bindActId, outerActId, roomPid } = configValues()
const rpc = new HuyaRpcClient({ fetchImpl: this.fetchImpl, timeoutMs: this.rpcTimeoutMs })
const roleResponse = await rpc.checkUserBindGameAccount({
uid: session.uid,
cookie: session.cookie,
bindActId,
outerActId,
})
const role = parseRole(roleResponse)
if (role.role) {
this.markRoleReady(session, role)
return this.bindingView(session)
}
if (!roleResponse || roleResponse.status !== 200) {
throw createHttpError(roleResponse?.msg || '查询虎牙绑定状态失败', {
statusCode: 502,
errorCode: 'huya_bind_query_failed',
})
}
const liveLink = await rpc.getLiveLinkParam({
uid: session.uid,
cookie: session.cookie,
bindActId,
outerActId: extractInt(outerActId),
gameAuthScene: roleResponse.gameAuthScene,
})
if (!liveLink || liveLink.status !== 200) {
throw createHttpError(liveLink?.msg || '获取绑定二维码参数失败', {
statusCode: 502,
errorCode: 'huya_livelink_param_failed',
})
}
const profileResponse = await rpc.getUserProfileBatch({
uid: session.uid,
cookie: session.cookie,
targetUids: [session.uid],
})
const profile = profileResponse?.profiles[0]
const nickname = profile?.nick || profile?.passport || session.nickname
const avatar = profile?.avatar || ''
const redirectUrl =
'https://zt.huya.com/b02faae1/pc/index.html' +
`?sourceId=yellowcarlist&pid=${roomPid}&anchorUid=${roomPid}&roomid=30596253`
const urls = buildBindUrls({
livelinkParam: liveLink.livelinkParam,
bindActId,
gameAuthScene: roleResponse.gameAuthScene,
nickName: nickname,
faceUrl: avatar,
redirectUrl,
})
const mini = await getLiveLinkMiniQrcode(urls.qrUrl, {
fetchImpl: this.fetchImpl,
timeoutMs: this.rpcTimeoutMs,
})
if (!mini) {
throw createHttpError('绑定小程序码获取失败', {
statusCode: 502,
errorCode: 'huya_bind_qrcode_failed',
})
}
session.binding.status = 'qr_pending'
session.binding.qrImage = mini.image
session.binding.qrToken = mini.token
session.binding.expiresAtMs = this.now() + BINDING_QR_TTL_MS
session.binding.message = '请使用虎牙 App 扫码绑定游戏角色'
return this.bindingView(session)
})
}
async pollBinding(sessionId: string): Promise<ConnectorBindingSession> {
return this.withLock(sessionId, async () => {
const session = this.requireAuthenticatedSession(sessionId)
const { bindActId, outerActId } = configValues()
if (session.binding.status === 'qr_pending' && session.binding.qrToken) {
const qrStatus = await getLiveLinkQrcodeStatus(session.binding.qrToken, {
fetchImpl: this.fetchImpl,
timeoutMs: this.rpcTimeoutMs,
})
if (qrStatus) {
if (qrStatus.isExpired || qrStatus.isFailure) {
session.binding.status = 'expired'
session.binding.message = '绑定二维码已过期,请重新获取'
return this.bindingView(session)
}
if (qrStatus.isScan) {
session.binding.status = 'scanned'
session.binding.message = '已扫码,等待绑定完成'
}
}
}
const rpc = new HuyaRpcClient({ fetchImpl: this.fetchImpl, timeoutMs: this.rpcTimeoutMs })
const response = await rpc.checkUserBindGameAccount({
uid: session.uid,
cookie: session.cookie,
bindActId,
outerActId,
})
if (!response || response.status !== 200) {
session.binding.status = 'failed'
session.binding.message = response?.msg || '查询绑定角色失败'
return this.bindingView(session)
}
const role = parseRole(response)
if (role.role) {
this.markRoleReady(session, role)
} else if (!['scanned', 'expired', 'failed'].includes(session.binding.status)) {
session.binding.status = 'qr_pending'
session.binding.message = '等待扫码绑定游戏角色'
}
return this.bindingView(session)
})
}
async confirmBinding(sessionId: string): Promise<ConnectorBindingSession> {
return this.withLock(sessionId, async () => {
const session = this.requireAuthenticatedSession(sessionId)
const { bindActId, outerActId } = configValues()
const rpc = new HuyaRpcClient({ fetchImpl: this.fetchImpl, timeoutMs: this.rpcTimeoutMs })
const response = await rpc.checkUserBindGameAccount({
uid: session.uid,
cookie: session.cookie,
bindActId,
outerActId,
})
const role = parseRole(response)
if (!response || response.status !== 200 || !role.role) {
throw createHttpError('尚未识别到可确认的绑定角色', {
statusCode: 409,
errorCode: 'huya_bind_role_not_ready',
})
}
const confirmed = await rpc.confirmBindActAccount({
uid: session.uid,
cookie: session.cookie,
bindActId,
outerActId,
})
if (!confirmed || confirmed.status !== 200) {
throw createHttpError(confirmed?.msg || '确认虎牙绑定失败', {
statusCode: 502,
errorCode: 'huya_bind_confirm_failed',
})
}
this.markRoleReady(session, role)
session.binding.status = 'confirmed'
session.binding.message = '虎牙角色绑定已确认'
return this.bindingView(session)
})
}
async createBrowserTicket(input: {
sessionId: string
targetUrl?: string
}): Promise<ConnectorBrowserTicket> {
return this.withLock(input.sessionId, async () => {
const session = this.requireSession(input.sessionId)
if (!session.cookie) {
throw createHttpError('虎牙登录态不存在或已过期', {
statusCode: 404,
errorCode: 'huya_native_credential_not_found',
})
}
const ticket = `${NATIVE_TICKET_PREFIX}${crypto.randomBytes(32).toString('base64url')}`
const targetUrl = readString(input.targetUrl) || 'https://www.huya.com/'
const expiresAtMs = this.now() + BROWSER_TICKET_TTL_MS
session.tickets.set(ticket, {
ticket,
targetUrl,
cookie: session.cookie,
expiresAtMs,
})
return { ticket, expiresAt: iso(expiresAtMs), targetUrl }
})
}
async consumeBrowserTicket(ticketInput: string): Promise<ConnectorInjectionPayload> {
this.prune()
const ticket = readString(ticketInput)
for (const session of this.sessions.values()) {
const item = session.tickets.get(ticket)
if (!item) continue
session.tickets.delete(ticket)
if (item.expiresAtMs <= this.now()) {
throw createHttpError('浏览器注入票据不存在或已过期', {
statusCode: 404,
errorCode: 'huya_native_ticket_expired',
})
}
return {
platform: 'huya',
targetUrl: item.targetUrl,
expiresAt: iso(item.expiresAtMs),
cookies: cookiePairs(item.cookie)
.filter(([name, value]) => name && value)
.map(([name, value]) => ({
name,
value,
domain: '.huya.com',
path: '/',
secure: true,
})),
}
}
throw createHttpError('浏览器注入票据不存在或已过期', {
statusCode: 404,
errorCode: 'huya_native_ticket_not_found',
})
}
async revokeSession(sessionIdInput: string): Promise<void> {
const sessionId = readString(sessionIdInput)
const session = this.sessions.get(sessionId)
if (session) {
session.cookie = ''
session.tickets.clear()
this.sessions.delete(sessionId)
await removeFingerprintState(session.stateDir)
}
}
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)
if (!biztoken || !uid) {
session.status = 'failed'
session.message = '虎牙扫码已确认,但未返回完整登录凭据'
return
}
const username = readString(data.passport ?? data.udb_passport ?? data.username)
const nickname = readString(data.nickname ?? data.nickName ?? data.nick ?? 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(),
biztoken,
sdid: session.sdid,
hdid: session.hdid,
deviceInfo: session.deviceInfo,
uid,
username: username.startsWith('hy_') ? username : '',
includeTransient: false,
})
session.qr.setCookies(merged)
await session.qr.verifyCookie()
merged = mergeHuyaWebCookie({
cookie: session.qr.cookieHeader(),
biztoken,
sdid: session.sdid,
hdid: session.hdid,
deviceInfo: session.deviceInfo,
uid,
username: username.startsWith('hy_') ? username : '',
includeTransient: false,
})
session.cookie = fillWebCookieFields({
cookie: merged,
guid: '',
uid,
qimeiFeature: qimeiFeatureFromDeviceProfile(session.deviceInfo),
})
session.uid = uid
session.nickname = nickname
session.status = 'authenticated'
session.expiresAtMs = this.now() + CREDENTIAL_TTL_MS
session.message = '虎牙登录成功'
} catch (error) {
session.cookie = ''
session.status = 'failed'
session.message = error instanceof Error ? error.message : '虎牙 Cookie 兑换失败'
}
}
private markRoleReady(
session: NativeSession,
role: { account: string; role: string; channel: string },
): void {
session.binding.status = 'role_ready'
session.binding.account = role.account
session.binding.role = role.role
session.binding.channel = role.channel
session.binding.message = '绑定角色已识别,请确认绑定'
}
private requireSession(sessionIdInput: string): NativeSession {
this.prune()
const sessionId = readString(sessionIdInput)
const session = this.sessions.get(sessionId)
if (!session) {
throw createHttpError('虎牙登录会话不存在或已过期', {
statusCode: 404,
errorCode: 'huya_native_session_not_found',
})
}
return session
}
private requireAuthenticatedSession(sessionIdInput: string): NativeSession {
const session = this.requireSession(sessionIdInput)
if (session.status !== 'authenticated' || !session.cookie) {
throw createHttpError('请先完成虎牙登录', {
statusCode: 409,
errorCode: 'huya_native_login_required',
})
}
return session
}
private prune(): void {
const now = this.now()
for (const [sessionId, session] of this.sessions) {
if (session.expiresAtMs <= now) {
session.cookie = ''
session.tickets.clear()
this.sessions.delete(sessionId)
void removeFingerprintState(session.stateDir)
}
}
for (const session of this.sessions.values()) {
for (const [ticket, item] of session.tickets) {
if (item.expiresAtMs <= now) session.tickets.delete(ticket)
}
}
if (this.sessions.size > MAX_SESSIONS) {
const overflow = this.sessions.size - MAX_SESSIONS
for (const [sessionId, session] of Array.from(this.sessions.entries()).slice(0, overflow)) {
session.cookie = ''
session.tickets.clear()
this.sessions.delete(sessionId)
void removeFingerprintState(session.stateDir)
}
}
}
private loginView(session: NativeSession): ConnectorLoginSession {
return {
sessionId: session.sessionId,
status: session.status,
qrContent: `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) : '',
nickname: session.nickname,
hasCredential: Boolean(session.cookie),
message: session.message,
}
}
private bindingView(session: NativeSession): ConnectorBindingSession {
const binding = session.binding
return {
sessionId: session.sessionId,
status: binding.status,
qrImage: binding.qrImage,
qrExpiresAt: binding.expiresAtMs ? iso(binding.expiresAtMs) : '',
bindGameAccount: binding.account,
bindGameRole: binding.role,
bindGameChannel: binding.channel,
message: binding.message,
}
}
private async withLock<T>(sessionIdInput: string, action: () => Promise<T>): Promise<T> {
const sessionId = readString(sessionIdInput)
const previous = this.locks.get(sessionId) || Promise.resolve()
let release: () => void = () => undefined
const gate = new Promise<void>((resolve) => {
release = resolve
})
const queued = previous.then(() => gate)
this.locks.set(sessionId, queued)
await previous.catch(() => undefined)
try {
return await action()
} finally {
release()
if (this.locks.get(sessionId) === queued) this.locks.delete(sessionId)
}
}
}
export const huyaNativeConnector = new HuyaNativeConnector()
export const HUYA_NATIVE_TICKET_PREFIX = NATIVE_TICKET_PREFIX
@@ -0,0 +1,178 @@
import assert from 'node:assert/strict'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { buildHuyaLoginContext, ensureHuyaWebCookieIdentity, x64hash128 } from './cookie.js'
import { HuyaNativeConnector } from './huya-native-connector.js'
import { parseBindStruct } from './huya-rpc.js'
const BIND_RESP_HEX =
'0a0100c816002a0001100120013a060016002602717136004612e6b3aae6958fe6849fe' +
'79a84e59b9ee5bf86560066000b4a06001612e6b3aae99ba8e585a8e698afe8b08ee8a8' +
'802604e6898b51360be88bb9e69e9c28694f53294600560066000b526a2595806c0b36' +
'0ce5928ce5b9b3e7b2bee88bb1460050070b'
test('QIMEI x64hash128 与 SDK 向量一致', () => {
assert.equal(x64hash128('', 31), '24700f9f1986800ab4fcc880530dd0ed')
assert.equal(x64hash128('a', 31), 'f8b09930c1df727ef75677a1d6ae5bd6')
assert.equal(x64hash128('abc', 31), '31844fd43df93041e52bcddd13312671')
})
test('登录 context 使用网页设备 Cookie 的 guiddata、yamid 和 guid', () => {
const identity = ensureHuyaWebCookieIdentity({
cookie:
'udb_guiddata=838ed427bbdb491d8e6f6782addb0364; ' +
'__yamid_new=CBE2807148F00001332411CB1E296A00; ' +
'guid=0a7dee10017ef16aad0229302a3029e3',
guid: 'ignored-because-existing',
})
assert.equal(
identity.context,
'WB-838ed427bbdb491d8e6f6782addb0364-CBE2807148F00001332411CB1E296A00-0a7dee10017ef16aad0229302a3029e3',
)
assert.equal(buildHuyaLoginContext(identity.cookie), identity.context)
assert.match(identity.cookie, /(?:^|; )udb_appid=5002(?:;|$)/)
})
test('真实虎牙绑定响应尾部新增 ZERO 字段仍可解析', () => {
const response = parseBindStruct(Buffer.from(BIND_RESP_HEX, 'hex'))
assert.equal(response.status, 200)
assert.equal(response.gameName, '和平精英')
assert.equal(response.accountData.gameAccount.nick, '泪敏感的回忆')
assert.equal(response.accountData.gameRole.roleName, '泪雨全是谎言')
assert.equal(response.accountData.gameRole.areaName, '手Q')
assert.equal(response.accountData.gameRole.platName, '苹果(iOS)')
})
test('内置 Connector 完成扫码、CK 兑换和一次性票据且不泄露 CK', async () => {
const requests: Array<{ url: string; body: string; headers: Record<string, string> }> = []
let stage = 0
let now = Date.now()
const fetchImpl = (async (urlInput: string | URL | Request, init?: RequestInit) => {
const url = String(urlInput)
const body = typeof init?.body === 'string' ? init.body : ''
const headers = Object.fromEntries(new Headers(init?.headers).entries())
requests.push({ url, body, headers })
if (url.includes('/web/middle/')) {
return new Response('<html></html>', {
status: 200,
headers: { 'content-type': 'text/html;charset=UTF-8' },
})
}
if (url.endsWith('/qrLgn/getQrId')) {
return new Response(JSON.stringify({ returnCode: 0, data: { qrId: 'qr-test-1' } }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}
if (url.includes('/qrLgn/getQrImg')) {
return new Response(Buffer.from('fake-png'), {
status: 200,
headers: { 'content-type': 'image/png' },
})
}
if (url.endsWith('/qrLgn/tryQrLogin')) {
const headers = new Headers({ 'content-type': 'application/json' })
if (stage >= 2) {
headers.append('set-cookie', 'udb_cred=server-issued-cred; Path=/; Domain=huya.com')
headers.append('set-cookie', 'udb_uid=123456; Path=/; Domain=huya.com')
headers.append('set-cookie', 'yyuid=123456; Path=/; Domain=huya.com')
headers.append('set-cookie', 'udb_passport=hy_123456; Path=/; Domain=huya.com')
headers.append('set-cookie', 'username=hy_123456; Path=/; Domain=huya.com')
}
return new Response(
JSON.stringify({
returnCode: 0,
data:
stage >= 2
? {
stage: 2,
uid: 123456,
passport: 'hy_123456',
nickname: '测试用户',
biztoken: 'secret-biztoken',
}
: { stage },
}),
{ 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: '0UnHUgv0_qmfD4KAKlwzhqTZaMGpYcbggchzyjwi8f6G4SHn50pqpnCn8hbYX2xJ2ahI0jmPutYTHv17Q0HdNPxBBhH9f3lfLj955gZ0iFULWVkn9LtfFJw_Qo4kgKr8OZHDqNnuwg612sGyflFn1dotW4-PHrIqa6GBd1hvMDgYhO03aCkuTa7UwhiUlpluU',
hdid: '28744de42736586c8a20e59b5ce9c5f5146fb5e8',
cookies: {
udb_appid: '5008',
game_did: '4p5Gmgx92o-TZA5lK3bgimTfc3p9Wbw6Vl2',
_qimei_uuid42: '1ab09100209100ee4d631bc545468c6addc489a95a',
__yamid_new: 'CBE2807148F00001332411CB1E296A00',
udb_guiddata: '838ed427bbdb491d8e6f6782addb0364',
guid: '0a7dee10017ef16aad0229302a3029e3',
},
source: 'fingerprint',
}),
now: () => now,
stateRoot: path.join(os.tmpdir(), 'huya-native-test'),
})
const created = await connector.createLoginSession({ taskId: 1, orderNo: 'ORDER-1' })
assert.equal(created.status, 'waiting_scan')
assert.equal(created.hasCredential, false)
assert.equal(
created.qrImage,
`data:image/png;base64,${Buffer.from('fake-png').toString('base64')}`,
)
const getQrId = requests.find((item) => item.url.endsWith('/qrLgn/getQrId'))
assert.ok(getQrId)
const envelope = JSON.parse(getQrId.body) as Record<string, unknown>
assert.match(String(envelope.sdid), /^csid_[0-9a-f]{32}$/)
assert.equal(
envelope.context,
'WB-838ed427bbdb491d8e6f6782addb0364-CBE2807148F00001332411CB1E296A00-0a7dee10017ef16aad0229302a3029e3',
)
assert.equal(getQrId.headers.reqid, envelope.requestId)
stage = 0
assert.equal((await connector.getLoginSession(created.sessionId)).status, 'waiting_scan')
stage = 1
assert.equal((await connector.getLoginSession(created.sessionId)).status, 'scanned')
stage = 2
const authenticated = await connector.getLoginSession(created.sessionId)
assert.equal(authenticated.status, 'authenticated')
assert.equal(authenticated.huyaUid, '123456')
assert.equal(authenticated.nickname, '测试用户')
assert.equal(JSON.stringify(authenticated).includes('secret-biztoken'), false)
assert.equal(JSON.stringify(authenticated).includes('server-issued-cred'), false)
const ticket = await connector.createBrowserTicket({
sessionId: created.sessionId,
targetUrl: 'https://www.huya.com/',
})
assert.match(ticket.ticket, /^native-ticket-/)
assert.equal(ticket.ticket.includes('secret-biztoken'), false)
const payload = await connector.consumeBrowserTicket(ticket.ticket)
assert.equal(payload.platform, 'huya')
assert.equal(
payload.cookies.find((item) => item.name === 'udb_cred')?.value,
'server-issued-cred',
)
await assert.rejects(() => connector.consumeBrowserTicket(ticket.ticket), /不存在或已过期/)
const expiring = await connector.createBrowserTicket({ sessionId: created.sessionId })
now += 2 * 60 * 1000 + 1
await assert.rejects(() => connector.consumeBrowserTicket(expiring.ticket), /不存在或已过期/)
await connector.revokeSession(created.sessionId)
await assert.rejects(() => connector.getLoginSession(created.sessionId), /不存在或已过期/)
})
@@ -0,0 +1,780 @@
/** 虎牙扫码登录 JSON 协议、活动绑定 WUP RPC 与 livelink 小程序码。 */
import crypto from 'node:crypto'
import { buildHuyaLoginContext, cookiePairs, cookieValue, normalizeCookiePairs } from './cookie.js'
import {
decodeWupResponse,
encodeWupRequest,
readWupStruct,
TAF_TYPE,
TafReader,
TafWriter,
type TafTypeValue,
type WupResponse,
} from './taf.js'
export const UDB_BASE = 'https://udblgn.huya.com'
export const CDNWS_HOST = 'cdnws.api.huya.com'
export const LIVELINK_BIND_API_BASE = 'https://livelinkbind.game.qq.com'
export const HTTP_HUYA_UA = 'webh5&0.0.1&websocket&&diypc_52775'
export const PC_UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) ' +
'Chrome/149.0.0.0 Safari/537.36'
const APP_SIGN_WEB = '1ce3bf682483d03f146f58232ec10635'
const WEB_USER_AGENT =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
'(KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36'
type FetchLike = typeof fetch
type JsonRecord = Record<string, unknown>
export type HuyaQrResponse = JsonRecord & {
data?: JsonRecord
message?: string
}
export class HuyaCookieJar {
private readonly values = new Map<string, string>()
header(): string {
return Array.from(this.values.entries())
.map(([key, value]) => `${key}=${value}`)
.join('; ')
}
pairs(): Array<[string, string]> {
return Array.from(this.values.entries())
}
clear(): void {
this.values.clear()
}
set(cookie: string): void {
for (const [key, value] of cookiePairs(cookie)) this.values.set(key, value)
}
absorb(headers: Headers): void {
const withGetSetCookie = headers as Headers & { getSetCookie?: () => string[] }
const setCookies =
typeof withGetSetCookie.getSetCookie === 'function'
? withGetSetCookie.getSetCookie()
: splitSetCookieHeader(headers.get('set-cookie') || '')
for (const raw of setCookies) {
const first = String(raw || '').split(';', 1)[0] || ''
const separator = first.indexOf('=')
if (separator <= 0) continue
const key = first.slice(0, separator).trim()
const value = first.slice(separator + 1).trim()
if (!key) continue
if (!value || /max-age=0/i.test(raw)) {
this.values.delete(key)
} else {
this.values.set(key, value)
}
}
}
}
function splitSetCookieHeader(value: string): string[] {
if (!value) return []
return value.split(/,(?=\s*[^;,=\s]+=)/g)
}
function buildHeaders(cookie?: string): Record<string, string> {
return cookie ? { cookie } : {}
}
export type HuyaQrRoleOptions = {
sdid: string
cookie?: string
context?: string
fetchImpl?: FetchLike
}
export class HuyaQrRole {
private readonly jar = new HuyaCookieJar()
private readonly context: string
private readonly guidData: string
private readonly fingerprintSdid: string
private readonly fetchImpl: FetchLike
private activeSdid: string
private readonly pageId: number
private requestCounter: number
constructor(options: HuyaQrRoleOptions) {
const initialCookie = String(options.cookie || '').trim()
if (initialCookie) this.jar.set(initialCookie)
this.fingerprintSdid = String(options.sdid || '').trim()
// 最新网页登录先用 csid 请求二维码,随后切换到 hydevice 高信任 sdid 轮询。
this.activeSdid = `csid_${crypto.randomBytes(16).toString('hex')}`
this.fetchImpl = options.fetchImpl || fetch
this.context =
String(options.context || '').trim() ||
buildHuyaLoginContext(initialCookie) ||
`WB-${crypto.randomUUID().replace(/-/g, '')}-CBC9F93DBEB000011DB11EC02A401BAD-`
this.guidData =
cookieValue(initialCookie, 'udb_guiddata') || crypto.randomBytes(16).toString('hex')
this.pageId = 40_000_000 + Math.floor(Math.random() * 1_000_000)
this.requestCounter = 40_000_000 + Math.floor(Math.random() * 1_000_000)
}
activateFingerprintSdid(): void {
if (this.fingerprintSdid) this.activeSdid = this.fingerprintSdid
}
setCookies(cookie: string): void {
this.jar.clear()
this.jar.set(cookie)
}
cookieHeader(): string {
return this.jar.header()
}
async bootstrapMiddle(): Promise<void> {
const url = `${UDB_BASE}/web/middle/2.6/${this.pageId}/https/${this.guidData}`
const response = await this.fetchImpl(url, {
method: 'GET',
headers: {
'user-agent': WEB_USER_AGENT,
accept:
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
referer: 'https://aq.huya.com/',
...buildHeaders(this.jar.header()),
},
signal: AbortSignal.timeout(20_000),
})
this.jar.absorb(response.headers)
await response.arrayBuffer().catch(() => undefined)
if (!response.ok) throw new Error(`虎牙设备引导接口失败:HTTP ${response.status}`)
}
async call(path: string, uri: string, data: JsonRecord): Promise<HuyaQrResponse> {
const requestId = this.requestCounter
const envelope = {
uri,
version: '2.6',
context: this.context,
appId: '5002',
appSign: APP_SIGN_WEB,
authId: '',
sdid: this.activeSdid,
lcid: '2052',
byPass: '3',
requestId: String(requestId),
data,
}
this.requestCounter = requestId + 120 + Math.floor(Math.random() * 281)
const headers: Record<string, string> = {
'user-agent': WEB_USER_AGENT,
origin: UDB_BASE,
'content-type': 'application/json;charset=UTF-8',
accept: '*/*',
context: this.context,
uri,
reqid: String(requestId),
lcid: '2052',
Referer: `${UDB_BASE}/web/middle/2.6/${this.pageId}/https/${this.context.split('-')[1] || ''}`,
...buildHeaders(this.jar.header()),
}
const response = await this.fetchImpl(`${UDB_BASE}${path}`, {
method: 'POST',
headers,
body: JSON.stringify(envelope),
signal: AbortSignal.timeout(20_000),
})
this.jar.absorb(response.headers)
const text = await response.text()
if (!response.ok) throw new Error(`虎牙登录接口失败:HTTP ${response.status}`)
try {
return (text ? JSON.parse(text) : {}) as HuyaQrResponse
} catch {
throw new Error('虎牙登录接口返回了无法解析的响应')
}
}
async getQrImg(qrid: string): Promise<string> {
const response = await this.fetchImpl(
`${UDB_BASE}/qrLgn/getQrImg?k=${encodeURIComponent(qrid)}&appId=5002`,
{
method: 'GET',
headers: {
'user-agent': WEB_USER_AGENT,
accept: 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
referer: 'https://aq.huya.com/',
...buildHeaders(this.jar.header()),
},
signal: AbortSignal.timeout(20_000),
},
)
this.jar.absorb(response.headers)
if (!response.ok) throw new Error(`虎牙登录二维码图片获取失败:HTTP ${response.status}`)
const contentType = String(response.headers.get('content-type') || 'image/png').split(';', 1)[0]
const buffer = Buffer.from(await response.arrayBuffer())
if (buffer.length === 0) throw new Error('虎牙登录二维码图片为空')
return `data:${contentType || 'image/png'};base64,${buffer.toString('base64')}`
}
async verifyCookie(): Promise<void> {
const response = await this.fetchImpl(`${UDB_BASE}/web/cookie/verify`, {
method: 'POST',
headers: {
'user-agent': WEB_USER_AGENT,
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}`)
}
}
export function webBehavior(page = 'https://www.huya.com/'): { behavior: string; page: string } {
// 2026-11-09 最新 HAR 的扫码请求:来源 Google、当前页虎牙首页、无鼠标轨迹。
const value = { furl: 'https://www.google.com/', curl: page, user_action: [] }
return {
behavior: encodeURIComponent(JSON.stringify(value)),
page: encodeURIComponent(page),
}
}
export type HuyaGameAccount = {
openId: string
appId: string
type: string
faceUrl: string
nick: string
accessToken: string
extendInfo: string
}
export type HuyaGameRole = {
roleId: string
roleName: string
areaName: string
platName: string
partitionName: string
gameSerial: string
extendInfo: string
}
export type HuyaGameAccountData = {
isBindAccount: number
isBindRole: number
isNeedActCheck: number
gameAccount: HuyaGameAccount
gameRole: HuyaGameRole
changeBindTime: number
}
export type HuyaBindResponse = {
status: number
msg: string
accountData: HuyaGameAccountData
gameName: string
gameAuthScene: string
changeBindDay: number
}
export type HuyaConfirmResponse = { status: number; msg: string }
export type HuyaLiveLinkResponse = {
status: number
msg: string
livelinkParam: Record<string, string>
}
export type HuyaUserProfile = { uid: number; nick: string; avatar: string; passport: string }
export type HuyaUserProfileResponse = { profiles: HuyaUserProfile[] }
function emptyAccount(): HuyaGameAccount {
return {
openId: '',
appId: '',
type: '',
faceUrl: '',
nick: '',
accessToken: '',
extendInfo: '',
}
}
function emptyRole(): HuyaGameRole {
return {
roleId: '',
roleName: '',
areaName: '',
platName: '',
partitionName: '',
gameSerial: '',
extendInfo: '',
}
}
function emptyAccountData(): HuyaGameAccountData {
return {
isBindAccount: 0,
isBindRole: 0,
isNeedActCheck: 0,
gameAccount: emptyAccount(),
gameRole: emptyRole(),
changeBindTime: 0,
}
}
function parseGameAccount(reader: TafReader): HuyaGameAccount {
return {
openId: reader.readString(0),
appId: reader.readString(1),
type: reader.readString(2),
faceUrl: reader.readString(3),
nick: reader.readString(4),
accessToken: reader.readString(5),
extendInfo: reader.readString(6),
}
}
function parseGameRole(reader: TafReader): HuyaGameRole {
return {
roleId: reader.readString(0),
roleName: reader.readString(1),
areaName: reader.readString(2),
platName: reader.readString(3),
partitionName: reader.readString(4),
gameSerial: reader.readString(5),
extendInfo: reader.readString(6),
}
}
function parseGameAccountData(reader: TafReader): HuyaGameAccountData {
return {
isBindAccount: reader.readInt32(0),
isBindRole: reader.readInt32(1),
isNeedActCheck: reader.readInt32(2),
gameAccount: reader.readStruct(3, parseGameAccount) || emptyAccount(),
gameRole: reader.readStruct(4, parseGameRole) || emptyRole(),
changeBindTime: reader.readInt32(5),
}
}
function parseBindResponse(reader: TafReader): HuyaBindResponse {
return {
status: reader.readInt32(0),
msg: reader.readString(1),
accountData: reader.readStruct(2, parseGameAccountData) || emptyAccountData(),
gameName: reader.readString(3),
gameAuthScene: reader.readString(4),
changeBindDay: reader.readInt32(5),
}
}
/** 解析 checkUserBindGameAccount 的 tRsp 裸结构,供集成回归和故障排查使用。 */
export function parseBindStruct(buffer: Buffer): HuyaBindResponse {
const reader = new TafReader(buffer)
const [tag, dataType] = reader.readHead()
if (tag !== 0 || dataType !== TAF_TYPE.STRUCT_BEGIN) {
throw new Error('虎牙绑定响应不是有效的 TAF 结构体')
}
return reader.readStructBody(parseBindResponse)
}
function parseConfirmResponse(reader: TafReader): HuyaConfirmResponse {
return { status: reader.readInt32(0), msg: reader.readString(1) }
}
function parseLiveLinkResponse(reader: TafReader): HuyaLiveLinkResponse {
return {
status: reader.readInt32(0),
msg: reader.readString(1),
livelinkParam: reader.readMap(2),
}
}
function parseProfile(reader: TafReader): HuyaUserProfile {
let uid = 0
let nick = ''
let avatar = ''
let passport = ''
while (reader.remaining > 0) {
const [tag, dataType] = reader.peekHead()
if (dataType === TAF_TYPE.STRUCT_END) {
break
}
reader.readHead()
if (tag === 0) uid = reader.readIntValue(dataType)
else if (tag === 1) nick = reader.readStringValue(dataType)
else if (tag === 2) avatar = reader.readStringValue(dataType)
else if (tag === 17) passport = reader.readStringValue(dataType)
else reader.skipValue(dataType)
}
return { uid, nick, avatar, passport }
}
function parseProfileBatch(reader: TafReader): HuyaUserProfileResponse {
const profiles =
reader.readList<HuyaUserProfile>(0, (itemReader, dataType) => {
if (dataType !== TAF_TYPE.STRUCT_BEGIN) {
itemReader.skipValue(dataType)
return null
}
return itemReader.readStructBody((envelopeReader) => {
const profile = envelopeReader.readStruct(0, parseProfile)
return profile || { uid: 0, nick: '', avatar: '', passport: '' }
})
}) || []
return { profiles }
}
export type HuyaRpcClientOptions = {
fetchImpl?: FetchLike
timeoutMs?: number
}
export class HuyaRpcClient {
private readonly fetchImpl: FetchLike
private readonly timeoutMs: number
constructor(options: HuyaRpcClientOptions = {}) {
this.fetchImpl = options.fetchImpl || fetch
this.timeoutMs = Math.max(1000, options.timeoutMs || 15_000)
}
async checkUserBindGameAccount(input: {
uid: number
cookie: string
bindActId: number
outerActId: string
isUseOuterActId?: number
gid?: number
scene?: string
}): Promise<HuyaBindResponse | null> {
const response = await this.callRpc(
'webActUI',
'checkUserBindGameAccount',
input.uid,
input.cookie,
(writer) => {
this.writeActivityUser(writer, input.uid, input.cookie)
writer.writeInt32(1, input.gid || 0)
writer.writeString(2, input.outerActId || '')
writer.writeInt32(3, 0)
writer.writeString(4, input.scene || '')
writer.writeInt32(5, input.bindActId || 0)
writer.writeInt32(6, input.isUseOuterActId ?? 1)
},
)
return response ? readWupStruct(response, parseBindResponse) : null
}
async confirmBindActAccount(input: {
uid: number
cookie: string
bindActId: number
outerActId: string
gid?: number
}): Promise<HuyaConfirmResponse | null> {
const response = await this.callRpc(
'webActUI',
'confirmBindActAccount',
input.uid,
input.cookie,
(writer) => {
this.writeActivityUser(writer, input.uid, input.cookie)
writer.writeInt32(1, input.gid || 0)
writer.writeString(2, input.outerActId || '')
writer.writeInt32(3, input.bindActId || 0)
},
)
return response ? readWupStruct(response, parseConfirmResponse) : null
}
async getLiveLinkParam(input: {
uid: number
cookie: string
bindActId: number
outerActId: number
gameAuthScene?: string
}): Promise<HuyaLiveLinkResponse | null> {
const response = await this.callRpc(
'webActUI',
'getLiveLinkParam',
input.uid,
input.cookie,
(writer) => {
this.writeActivityUser(writer, input.uid, input.cookie)
writer.writeInt32(1, 0)
writer.writeInt32(2, input.outerActId || 0)
writer.writeString(3, input.gameAuthScene || '')
writer.writeString(4, '')
writer.writeInt32(5, input.bindActId || 0)
},
)
return response ? readWupStruct(response, parseLiveLinkResponse) : null
}
async getUserProfileBatch(input: {
uid: number
cookie: string
targetUids: number[]
}): Promise<HuyaUserProfileResponse | null> {
const targetUids = input.targetUids.map((item) => Math.trunc(item)).filter((item) => item > 0)
if (targetUids.length === 0) return null
const response = await this.callRpc(
'huyauserui',
'getUserProfileBatch',
input.uid,
input.cookie,
(writer) => {
this.writeActivityUser(writer, 0, '')
writer.writeList(1, targetUids, (itemWriter, item) => itemWriter.writeInt64(0, item))
},
)
return response ? readWupStruct(response, parseProfileBatch) : null
}
private async callRpc(
servant: string,
func: string,
uid: number,
cookie: string,
writeRequest: (writer: TafWriter) => void,
): Promise<WupResponse | null> {
const body = encodeWupRequest({ servant, func, requestId: 1, writeRequest })
const baseinfo = generateRpcBaseinfo(uid, cookie)
const response = await this.fetchImpl(`https://${CDNWS_HOST}/?baseinfo=${baseinfo}`, {
method: 'POST',
headers: {
'user-agent': PC_UA,
origin: 'https://zt.huya.com',
referer: 'https://zt.huya.com/',
'content-type': 'application/octet-stream',
'accept-language': 'zh-CN,zh;q=0.9',
},
body: new Uint8Array(body),
signal: AbortSignal.timeout(this.timeoutMs),
})
if (!response.ok) throw new Error(`虎牙 WUP 请求失败:HTTP ${response.status}`)
const buffer = Buffer.from(await response.arrayBuffer())
return decodeWupResponse(buffer)
}
private writeActivityUser(writer: TafWriter, uid: number, cookie: string): void {
writer.writeStruct(0, (userWriter) => {
userWriter.writeInt64(0, uid)
userWriter.writeString(1, '')
userWriter.writeString(2, '')
userWriter.writeString(3, HTTP_HUYA_UA)
userWriter.writeString(4, normalizeHuyaCookie(cookie))
userWriter.writeInt32(5, 0)
userWriter.writeString(6, '')
userWriter.writeString(7, '')
})
}
}
function firstCookieValue(cookie: string, key: string): string {
for (const [itemKey, itemValue] of cookiePairs(cookie)) {
if (itemKey === key && itemValue) return itemValue
}
return ''
}
export function resolveCookieGuid(cookie: string): string {
const guid = firstCookieValue(cookie, 'guid')
if (guid) return guid
const udbGuid = firstCookieValue(cookie, 'udb_guiddata')
if (udbGuid) return udbGuid.slice(0, 32)
const seed = ['yyuid', 'udb_uid', 'udb_passport', 'username', 'udb_biztoken']
.map((key) => firstCookieValue(cookie, key))
.filter(Boolean)
.join('|')
return seed ? crypto.createHash('sha256').update(seed).digest('hex').slice(0, 32) : ''
}
export function normalizeHuyaCookie(cookie: string): string {
if (!cookie) return ''
const pairs = cookiePairs(normalizeCookiePairs(cookiePairs(cookie))).filter(
([key]) => key !== 'huya_ua',
)
if (!pairs.some(([key]) => key === 'guid')) {
const guid = resolveCookieGuid(cookie)
if (guid) pairs.push(['guid', guid])
}
return normalizeCookiePairs([['huya_ua', HTTP_HUYA_UA], ...pairs])
}
function generateRpcBaseinfo(uid: number, cookie: string): string {
const normalizedCookie = normalizeHuyaCookie(cookie)
const guid = resolveCookieGuid(cookie)
const traceId = crypto.randomBytes(8).toString('hex')
const writer = new TafWriter()
writer.writeInt64(0, uid)
writer.writeString(1, guid)
writer.writeString(2, HTTP_HUYA_UA)
writer.writeString(3, 'HUYA&ZH&2052')
writer.writeString(4, '')
writer.writeString(5, '')
writer.writeInt32(6, 0)
writer.writeString(7, '')
writer.writeString(8, normalizedCookie)
writer.writeString(9, `${traceId}:${traceId}:0:0`)
writer.writeMap(10, {})
return encodeURIComponent(writer.toBuffer().toString('base64'))
}
export function livelinkQrcodeImageSrc(qrcode: string): string {
const text = String(qrcode || '').trim()
if (!text) return ''
const lower = text.toLowerCase()
if (
lower.startsWith('https://') ||
lower.startsWith('http://') ||
lower.startsWith('data:image/')
) {
return text
}
return `data:${text.startsWith('iVBOR') ? 'image/png' : 'image/jpeg'};base64,${text}`
}
export async function getLiveLinkMiniQrcode(
bindPageUrl: string,
options: { fetchImpl?: FetchLike; timeoutMs?: number } = {},
): Promise<{ image: string; token: string } | null> {
const url = new URL(bindPageUrl)
const query = url.search.slice(1)
const params = url.searchParams
const suffix = Array.from(
{ length: 8 },
() => 'abcdefghijklmnopqrstuvwxyz0123456789'[Math.floor(Math.random() * 36)],
).join('')
const sessionId = `${Math.floor(Date.now() / 1000)}_${suffix}`
const fetchImpl = options.fetchImpl || fetch
const response = await fetchImpl(`${LIVELINK_BIND_API_BASE}/api/qrcode/getMiniAppQrcode`, {
method: 'POST',
headers: {
'user-agent': PC_UA,
origin: 'https://livelink.qq.com',
referer: 'https://livelink.qq.com/',
'content-type': 'application/json;charset=UTF-8',
accept: 'application/json, text/plain, */*',
'accept-language': 'zh-CN,zh;q=0.9',
},
body: JSON.stringify({ queryString: `${query}&sessionId=${sessionId}` }),
signal: AbortSignal.timeout(options.timeoutMs || 15_000),
})
if (!response.ok) return null
const payload = (await response.json().catch(() => null)) as {
iRet?: number
jData?: { qrcode?: string; qrcodeToken?: string }
} | null
if (!payload || payload.iRet !== 0) return null
const image = livelinkQrcodeImageSrc(String(payload.jData?.qrcode || ''))
if (!image) return null
return { image, token: String(payload.jData?.qrcodeToken || '') }
}
export async function getLiveLinkQrcodeStatus(
token: string,
options: { fetchImpl?: FetchLike; timeoutMs?: number } = {},
): Promise<{
isScan: boolean
isExpired: boolean
isCompleted: boolean
isFailure: boolean
} | null> {
const normalized = String(token || '').trim()
if (!normalized) return null
const fetchImpl = options.fetchImpl || fetch
const response = await fetchImpl(`${LIVELINK_BIND_API_BASE}/api/qrcode/poolingQrcodeStatus`, {
method: 'POST',
headers: {
'user-agent': PC_UA,
origin: 'https://livelink.qq.com',
referer: 'https://livelink.qq.com/',
'content-type': 'application/json;charset=UTF-8',
accept: 'application/json, text/plain, */*',
'accept-language': 'zh-CN,zh;q=0.9',
},
body: JSON.stringify({ qrcodeToken: normalized }),
signal: AbortSignal.timeout(options.timeoutMs || 15_000),
})
if (!response.ok) return null
const payload = (await response.json().catch(() => null)) as {
iRet?: number
jData?: {
isScan?: boolean
isExpired?: boolean
isCompleted?: boolean
isFailure?: boolean
}
} | null
if (!payload || payload.iRet !== 0) return null
return {
isScan: Boolean(payload.jData?.isScan),
isExpired: Boolean(payload.jData?.isExpired),
isCompleted: Boolean(payload.jData?.isCompleted),
isFailure: Boolean(payload.jData?.isFailure),
}
}
export function buildBindUrls(input: {
livelinkParam: Record<string, string>
bindActId: number
gameAuthScene?: string
nickName?: string
faceUrl?: string
redirectUrl?: string
}): {
actionUrl: string
qrUrl: string
pcUrl: string
huyaContainerUrl: string
exchangeQqCoinUrl: string
} {
const bindQuery: Record<string, string> = {
t: input.livelinkParam.t || '',
gameIdList: input.livelinkParam.gameIdList || '',
actId: input.livelinkParam.actId || '',
livePlatId: input.livelinkParam.livePlatId || '',
sig: input.livelinkParam.sig || '',
code: input.livelinkParam.code || '',
faceUrl: encodeURIComponent(input.faceUrl || ''),
nickName: encodeURIComponent(input.nickName || ''),
}
const joinQuery = (query: Record<string, string>) =>
Object.entries(query)
.map(([key, value]) => `${key}=${value}`)
.join('&')
const bindQueryText = joinQuery(bindQuery)
const miniPath = encodeURIComponent(`/pages/gameAccountBind/index?${bindQueryText}`)
const actionUrl =
'https://m.huya.com?hyaction=launchweixinminiprogram' +
`&path=${miniPath}&username=gh_49af166706ae`
const pcQuery = { ...bindQuery, redirectUrl: encodeURIComponent(input.redirectUrl || '') }
const pcUrl = `https://livelink.qq.com/act/a20200527midgroupage/pc/?${joinQuery(pcQuery)}`
const huyaParams = new URLSearchParams({
bActId: String(input.bindActId || 0),
isUseOuterActId: '1',
delayclose: '1000',
from: 'diy',
})
if (input.gameAuthScene) huyaParams.set('gameAuthScene', input.gameAuthScene)
const huyaBase = 'https://hd.huya.com/h5/livelinkV3/index.html'
const huyaContainerUrl = `${huyaBase}?${huyaParams.toString()}`
const exchangeParams = new URLSearchParams(huyaParams)
exchangeParams.set('extendParm', 'type=qqCoinDeliver')
return {
actionUrl,
qrUrl: pcUrl,
pcUrl,
huyaContainerUrl,
exchangeQqCoinUrl: `${huyaBase}?${exchangeParams.toString()}`,
}
}
export type { WupResponse, TafTypeValue }
@@ -0,0 +1,642 @@
/**
* 虎牙 TAF/WUP 二进制协议的最小实现。
*
* 只覆盖半自动履约需要的 JSON 登录与活动绑定 RPC,不引入 WSS、商城下单等
* 无关协议。编码规则与 live-hub-py 的 core/huya/taf_protocol.py、wup_protocol.py
* 保持一致。
*/
export const TAF_TYPE = {
INT8: 0x00,
INT16: 0x01,
INT32: 0x02,
INT64: 0x03,
FLOAT: 0x04,
DOUBLE: 0x05,
STRING1: 0x06,
STRING4: 0x07,
MAP: 0x08,
LIST: 0x09,
STRUCT_BEGIN: 0x0a,
STRUCT_END: 0x0b,
ZERO: 0x0c,
SIMPLE_LIST: 0x0d,
} as const
export type TafTypeValue = (typeof TAF_TYPE)[keyof typeof TAF_TYPE]
export class TafWriter {
private readonly chunks: Buffer[] = []
toBuffer(): Buffer {
return Buffer.concat(this.chunks)
}
writeHead(tag: number, dataType: TafTypeValue): void {
if (tag < 15) {
this.chunks.push(Buffer.from([(tag << 4) | dataType]))
return
}
this.chunks.push(Buffer.from([0xf0 | dataType, tag]))
}
writeInt8(tag: number, value: number): void {
if (value === 0) {
this.writeHead(tag, TAF_TYPE.ZERO)
return
}
const buffer = Buffer.allocUnsafe(1)
buffer.writeInt8(value, 0)
this.writeHead(tag, TAF_TYPE.INT8)
this.chunks.push(buffer)
}
writeInt16(tag: number, value: number): void {
if (value >= -128 && value <= 127) {
this.writeInt8(tag, value)
return
}
const buffer = Buffer.allocUnsafe(2)
buffer.writeInt16BE(value, 0)
this.writeHead(tag, TAF_TYPE.INT16)
this.chunks.push(buffer)
}
writeInt32(tag: number, value: number): void {
if (value >= -32768 && value <= 32767) {
this.writeInt16(tag, value)
return
}
const buffer = Buffer.allocUnsafe(4)
buffer.writeInt32BE(value, 0)
this.writeHead(tag, TAF_TYPE.INT32)
this.chunks.push(buffer)
}
writeInt64(tag: number, value: number | bigint): void {
const normalized = typeof value === 'bigint' ? value : BigInt(Math.trunc(value))
if (normalized >= -2147483648n && normalized <= 2147483647n) {
this.writeInt32(tag, Number(normalized))
return
}
const buffer = Buffer.allocUnsafe(8)
buffer.writeBigInt64BE(normalized, 0)
this.writeHead(tag, TAF_TYPE.INT64)
this.chunks.push(buffer)
}
writeString(tag: number, value: string): void {
const encoded = Buffer.from(String(value ?? ''), 'utf8')
if (encoded.length > 255) {
const length = Buffer.allocUnsafe(4)
length.writeUInt32BE(encoded.length, 0)
this.writeHead(tag, TAF_TYPE.STRING4)
this.chunks.push(length, encoded)
return
}
this.writeHead(tag, TAF_TYPE.STRING1)
this.chunks.push(Buffer.from([encoded.length]), encoded)
}
writeBytes(tag: number, value: Uint8Array): void {
this.writeHead(tag, TAF_TYPE.SIMPLE_LIST)
this.writeHead(0, TAF_TYPE.INT8)
this.writeInt32(0, value.byteLength)
this.chunks.push(Buffer.from(value))
}
writeBoolean(tag: number, value: boolean): void {
this.writeInt8(tag, value ? 1 : 0)
}
writeStructBegin(tag: number): void {
this.writeHead(tag, TAF_TYPE.STRUCT_BEGIN)
}
writeStructEnd(): void {
this.writeHead(0, TAF_TYPE.STRUCT_END)
}
writeStruct(tag: number, writeBody: (writer: TafWriter) => void): void {
this.writeStructBegin(tag)
writeBody(this)
this.writeStructEnd()
}
writeMap(tag: number, value: Record<string, string>): void {
this.writeHead(tag, TAF_TYPE.MAP)
this.writeInt32(0, Object.keys(value).length)
for (const [key, item] of Object.entries(value)) {
this.writeString(0, key)
this.writeString(1, item)
}
}
writeList<T>(tag: number, values: T[], writeItem: (writer: TafWriter, item: T) => void): void {
this.writeHead(tag, TAF_TYPE.LIST)
this.writeInt32(0, values.length)
for (const value of values) {
writeItem(this, value)
}
}
}
export class TafReader {
private offset = 0
constructor(private readonly buffer: Buffer) {}
get position(): number {
return this.offset
}
get remaining(): number {
return this.buffer.length - this.offset
}
peekHead(): [number, TafTypeValue] {
const position = this.offset
try {
return this.readHead()
} finally {
this.offset = position
}
}
readHead(): [number, TafTypeValue] {
this.require(1)
const first = this.buffer[this.offset++]!
let tag = (first >> 4) & 0x0f
const dataType = (first & 0x0f) as TafTypeValue
if (tag === 15) {
this.require(1)
tag = this.buffer[this.offset++]!
}
return [tag, dataType]
}
readIntValue(dataType: TafTypeValue): number {
if (dataType === TAF_TYPE.ZERO) return 0
if (dataType === TAF_TYPE.INT8) {
this.require(1)
return this.buffer.readInt8(this.offset++)
}
if (dataType === TAF_TYPE.INT16) {
this.require(2)
const value = this.buffer.readInt16BE(this.offset)
this.offset += 2
return value
}
if (dataType === TAF_TYPE.INT32) {
this.require(4)
const value = this.buffer.readInt32BE(this.offset)
this.offset += 4
return value
}
if (dataType === TAF_TYPE.INT64) {
this.require(8)
const value = this.buffer.readBigInt64BE(this.offset)
this.offset += 8
return Number(value)
}
throw new Error(`TAF 期望整数,实际 0x${dataType.toString(16)}`)
}
readInt32(tag: number, defaultValue = 0): number {
const dataType = this.findTag(tag)
return dataType === null ? defaultValue : this.readIntValue(dataType)
}
readInt64(tag: number, defaultValue = 0): number {
return this.readInt32(tag, defaultValue)
}
readBoolean(tag: number, defaultValue = false): boolean {
return this.readInt32(tag, defaultValue ? 1 : 0) !== 0
}
readString(tag: number, defaultValue = ''): string {
const dataType = this.findTag(tag)
if (dataType === null) return defaultValue
return this.readStringValue(dataType)
}
readStringValue(dataType: TafTypeValue): string {
if (dataType === TAF_TYPE.STRING1) {
this.require(1)
const length = this.buffer[this.offset++]!
this.require(length)
const value = this.buffer.toString('utf8', this.offset, this.offset + length)
this.offset += length
return value
}
if (dataType === TAF_TYPE.STRING4) {
this.require(4)
const length = this.buffer.readUInt32BE(this.offset)
this.offset += 4
this.require(length)
const value = this.buffer.toString('utf8', this.offset, this.offset + length)
this.offset += length
return value
}
throw new Error(`TAF 期望字符串,实际 0x${dataType.toString(16)}`)
}
readBytes(tag: number, defaultValue = Buffer.alloc(0)): Buffer {
const dataType = this.findTag(tag)
if (dataType === null) return defaultValue
if (dataType !== TAF_TYPE.SIMPLE_LIST) {
throw new Error(`TAF 期望字节数组,实际 0x${dataType.toString(16)}`)
}
this.readHead()
const length = this.readIntLength()
this.require(length)
const value = this.buffer.subarray(this.offset, this.offset + length)
this.offset += length
return value
}
readBytesRaw(length: number): Buffer {
this.require(length)
const value = this.buffer.subarray(this.offset, this.offset + length)
this.offset += length
return value
}
readMap(tag: number): Record<string, string> {
const dataType = this.findTag(tag)
if (dataType === null) return {}
if (dataType !== TAF_TYPE.MAP) {
throw new Error(`TAF 期望 map,实际 0x${dataType.toString(16)}`)
}
const count = this.readIntLength()
const result: Record<string, string> = {}
for (let index = 0; index < count; index += 1) {
const [, keyType] = this.readHead()
const key = this.readStringValue(keyType)
const [, valueType] = this.readHead()
const value = this.readValue(valueType)
result[key] = value === null || value === undefined ? '' : String(value)
}
return result
}
readList<T>(
tag: number,
readItem: (reader: TafReader, dataType: TafTypeValue) => T | null,
): T[] | null {
const dataType = this.findTag(tag)
if (dataType === null) return null
if (dataType !== TAF_TYPE.LIST) {
throw new Error(`TAF 期望 list,实际 0x${dataType.toString(16)}`)
}
const count = this.readIntLength()
const values: T[] = []
for (let index = 0; index < count; index += 1) {
const [, itemType] = this.readHead()
const item = readItem(this, itemType)
if (item !== null) values.push(item)
}
return values
}
readIntLength(): number {
const [, dataType] = this.readHead()
return this.readIntValue(dataType)
}
readValue(
dataType: TafTypeValue,
): string | number | boolean | Buffer | unknown[] | Record<string, unknown> | null {
if (
dataType === TAF_TYPE.ZERO ||
dataType === TAF_TYPE.INT8 ||
dataType === TAF_TYPE.INT16 ||
dataType === TAF_TYPE.INT32 ||
dataType === TAF_TYPE.INT64
) {
return this.readIntValue(dataType)
}
if (dataType === TAF_TYPE.STRING1 || dataType === TAF_TYPE.STRING4) {
return this.readStringValue(dataType)
}
if (dataType === TAF_TYPE.FLOAT) {
this.require(4)
const value = this.buffer.readFloatBE(this.offset)
this.offset += 4
return value
}
if (dataType === TAF_TYPE.DOUBLE) {
this.require(8)
const value = this.buffer.readDoubleBE(this.offset)
this.offset += 8
return value
}
if (dataType === TAF_TYPE.SIMPLE_LIST) {
this.readHead()
const length = this.readIntLength()
this.require(length)
const value = this.buffer.subarray(this.offset, this.offset + length)
this.offset += length
return value
}
if (dataType === TAF_TYPE.LIST) {
const count = this.readIntLength()
const values: unknown[] = []
for (let index = 0; index < count; index += 1) {
const [, itemType] = this.readHead()
values.push(this.readValue(itemType))
}
return values
}
if (dataType === TAF_TYPE.MAP) {
return this.readMapBody()
}
if (dataType === TAF_TYPE.STRUCT_BEGIN) {
this.skipStructBody()
return null
}
this.skipField(dataType)
return null
}
readStruct<T>(tag: number, readBody: (reader: TafReader) => T): T | null {
const dataType = this.findTag(tag)
if (dataType === null) return null
if (dataType !== TAF_TYPE.STRUCT_BEGIN) {
throw new Error(`TAF 期望结构体,实际 0x${dataType.toString(16)}`)
}
return this.readStructBody(readBody)
}
readStructBody<T>(readBody: (reader: TafReader) => T): T {
const value = readBody(this)
this.skipToStructEnd()
const [, dataType] = this.readHead()
if (dataType !== TAF_TYPE.STRUCT_END) {
throw new Error(`TAF 期望结构体结束,实际 0x${dataType.toString(16)}`)
}
return value
}
skipToStructEnd(): number {
let skipped = 0
while (skipped < 128) {
const position = this.offset
const [, dataType] = this.readHead()
if (dataType === TAF_TYPE.STRUCT_END) {
this.offset = position
return skipped
}
this.skipField(dataType)
skipped += 1
}
throw new Error('TAF 结构体尾部未解析字段过多')
}
skipValue(dataType: TafTypeValue): void {
this.skipField(dataType)
}
private readMapBody(): Record<string, unknown> {
const count = this.readIntLength()
const result: Record<string, unknown> = {}
for (let index = 0; index < count; index += 1) {
const [, keyType] = this.readHead()
const key = String(this.readValue(keyType) ?? '')
const [, valueType] = this.readHead()
result[key] = this.readValue(valueType)
}
return result
}
private findTag(targetTag: number): TafTypeValue | null {
while (this.remaining > 0) {
const position = this.offset
const [tag, dataType] = this.readHead()
if (tag === targetTag) return dataType
if (dataType === TAF_TYPE.STRUCT_END || tag > targetTag) {
this.offset = position
return null
}
this.skipField(dataType)
}
return null
}
private skipField(dataType: TafTypeValue): void {
if (dataType === TAF_TYPE.ZERO) return
if (dataType === TAF_TYPE.INT8) {
this.require(1)
this.offset += 1
return
}
if (dataType === TAF_TYPE.INT16) {
this.require(2)
this.offset += 2
return
}
if (dataType === TAF_TYPE.INT32 || dataType === TAF_TYPE.FLOAT) {
this.require(4)
this.offset += 4
return
}
if (dataType === TAF_TYPE.INT64 || dataType === TAF_TYPE.DOUBLE) {
this.require(8)
this.offset += 8
return
}
if (dataType === TAF_TYPE.STRING1) {
this.require(1)
const length = this.buffer[this.offset++]!
this.require(length)
this.offset += length
return
}
if (dataType === TAF_TYPE.STRING4) {
this.require(4)
const length = this.buffer.readUInt32BE(this.offset)
this.offset += 4
this.require(length)
this.offset += length
return
}
if (dataType === TAF_TYPE.MAP) {
this.skipMapBody()
return
}
if (dataType === TAF_TYPE.LIST) {
this.skipListBody()
return
}
if (dataType === TAF_TYPE.SIMPLE_LIST) {
this.readHead()
const length = this.readIntLength()
this.require(length)
this.offset += length
return
}
if (dataType === TAF_TYPE.STRUCT_BEGIN) {
this.skipStructBody()
return
}
if (dataType === TAF_TYPE.STRUCT_END) return
throw new Error(`未知 TAF 类型 0x${String(dataType)}`)
}
private skipMapBody(): void {
const count = this.readIntLength()
for (let index = 0; index < count; index += 1) {
const [, keyType] = this.readHead()
this.skipField(keyType)
const [, valueType] = this.readHead()
this.skipField(valueType)
}
}
private skipListBody(): void {
const count = this.readIntLength()
for (let index = 0; index < count; index += 1) {
const [, itemType] = this.readHead()
this.skipField(itemType)
}
}
private skipStructBody(): void {
while (this.remaining > 0) {
const [, dataType] = this.readHead()
if (dataType === TAF_TYPE.STRUCT_END) return
this.skipField(dataType)
}
throw new Error('TAF 结构体未正常结束')
}
private require(length: number): void {
if (length < 0 || this.offset + length > this.buffer.length) {
throw new Error('TAF 数据提前结束')
}
}
}
export type WupRequestInput = {
servant: string
func: string
requestId?: number
timeout?: number
writeRequest: (writer: TafWriter) => void
}
export function encodeWupRequest(input: WupRequestInput): Buffer {
const requestWriter = new TafWriter()
requestWriter.writeStruct(0, input.writeRequest)
const mapWriter = new TafWriter()
mapWriter.writeHead(0, TAF_TYPE.MAP)
mapWriter.writeInt32(0, 1)
mapWriter.writeString(0, 'tReq')
mapWriter.writeBytes(1, requestWriter.toBuffer())
const bodyWriter = new TafWriter()
bodyWriter.writeInt16(1, 3)
bodyWriter.writeInt8(2, 0)
bodyWriter.writeInt32(3, 0)
bodyWriter.writeInt32(4, input.requestId ?? 1)
bodyWriter.writeString(5, input.servant)
bodyWriter.writeString(6, input.func)
bodyWriter.writeBytes(7, mapWriter.toBuffer())
bodyWriter.writeInt32(8, input.timeout ?? 3000)
bodyWriter.writeMap(9, {})
bodyWriter.writeMap(10, {})
const body = bodyWriter.toBuffer()
const prefix = Buffer.allocUnsafe(4)
prefix.writeUInt32BE(4 + body.length, 0)
return Buffer.concat([prefix, body])
}
export type WupResponse = {
servant: string
func: string
newData: Record<string, Buffer>
}
function normalizeWupPayload(buffer: Buffer): Buffer {
if (buffer.length < 4) return buffer
const declaredLength = buffer.readUInt32BE(0)
if (declaredLength === buffer.length || declaredLength + 4 === buffer.length) {
return buffer.subarray(4)
}
return buffer
}
export function decodeWupResponse(input: Buffer): WupResponse {
const reader = new TafReader(normalizeWupPayload(input))
let servant = ''
let func = ''
let buffer: Buffer = Buffer.alloc(0)
while (reader.remaining > 0) {
const [tag, dataType] = reader.readHead()
if (tag > 10) break
if (tag === 1 || tag === 2 || tag === 3 || tag === 4 || tag === 8) {
reader.readIntValue(dataType)
continue
}
if (tag === 5) {
servant = reader.readStringValue(dataType)
continue
}
if (tag === 6) {
func = reader.readStringValue(dataType)
continue
}
if (tag === 7) {
if (dataType !== TAF_TYPE.SIMPLE_LIST) {
throw new Error('WUP sBuffer 类型无效')
}
reader.readHead()
const length = reader.readIntLength()
buffer = reader.readBytesRaw(length)
continue
}
if (tag === 9 || tag === 10) {
reader.readValue(dataType)
if (tag === 10) break
continue
}
reader.skipValue(dataType)
}
return { servant, func, newData: decodeWupNewData(buffer) }
}
function decodeWupNewData(buffer: Buffer): Record<string, Buffer> {
if (!buffer || buffer.length === 0) return {}
const reader = new TafReader(buffer)
const [tag, dataType] = reader.readHead()
if (tag !== 0 || dataType !== TAF_TYPE.MAP) return {}
const count = reader.readIntLength()
const result: Record<string, Buffer> = {}
for (let index = 0; index < count; index += 1) {
const [, keyType] = reader.readHead()
const key = reader.readStringValue(keyType)
const [, valueType] = reader.readHead()
if (valueType !== TAF_TYPE.SIMPLE_LIST) continue
reader.readHead()
const length = reader.readIntLength()
result[key] = reader.readBytesRaw(length)
}
return result
}
export function readWupStruct<T>(
response: WupResponse,
readBody: (reader: TafReader) => T,
): T | null {
const buffer = response.newData.tRsp || response.newData.tResp
if (!buffer || buffer.length === 0) return null
const reader = new TafReader(buffer)
const [, dataType] = reader.readHead()
if (dataType !== TAF_TYPE.STRUCT_BEGIN) return null
return reader.readStructBody(readBody)
}
@@ -26,6 +26,7 @@ import {
startHuyaBinding,
consumeHuyaBrowserTicket,
type ConnectorBindingSession,
type SemiAutoConnectorMode,
type ConnectorLoginSession,
} from './huya-connector-client.js'
@@ -64,6 +65,7 @@ type TaskRow = {
login_status: LoginStatus
binding_status: BindingStatus
execution_status: ExecutionStatus
connector_mode: SemiAutoConnectorMode
connector_session_id: string
huya_uid: string
platform_nickname: string
@@ -172,6 +174,7 @@ function mapTask(task: TaskRow) {
loginStatus: task.login_status,
bindingStatus: task.binding_status,
executionStatus: task.execution_status,
connectorMode: task.connector_mode,
displayStatus: displayStatus(task),
connectorReady: Boolean(task.connector_session_id),
huyaUid: task.huya_uid,
@@ -602,13 +605,13 @@ export async function startPublicLogin(orderNoInput: unknown, taskIdInput: unkno
task.login_status !== 'failed'
) {
try {
reusableSession = await getHuyaLoginSession(task.connector_session_id)
reusableSession = await getHuyaLoginSession(task.connector_session_id, task.connector_mode)
} catch {
// Connector 重启或短期会话被清理后,任务里的旧 ID 已不可用;重新创建前先尽力撤销。
await revokeHuyaSession(task.connector_session_id).catch(() => undefined)
await revokeHuyaSession(task.connector_session_id, task.connector_mode).catch(() => undefined)
}
} else if (task.connector_session_id) {
await revokeHuyaSession(task.connector_session_id).catch(() => undefined)
await revokeHuyaSession(task.connector_session_id, task.connector_mode).catch(() => undefined)
}
if (reusableSession) {
@@ -619,13 +622,14 @@ export async function startPublicLogin(orderNoInput: unknown, taskIdInput: unkno
login: {
status: mapLoginUpdate(reusableSession).status,
qrContent: reusableSession.qrContent,
qrImage: String(reusableSession.qrImage || ''),
expiresAt: reusableSession.expiresAt,
message: reusableSession.message,
},
}
}
const session = await createHuyaLoginSession({ taskId: task.id, orderNo })
const session = await createHuyaLoginSession({ taskId: task.id, orderNo }, task.connector_mode)
await withTransaction(async (client) => {
await client.query(
`
@@ -657,6 +661,7 @@ export async function startPublicLogin(orderNoInput: unknown, taskIdInput: unkno
login: {
status: mapLoginUpdate(session).status,
qrContent: session.qrContent,
qrImage: String(session.qrImage || ''),
expiresAt: session.expiresAt,
message: session.message,
},
@@ -670,7 +675,7 @@ export async function pollPublicLogin(orderNoInput: unknown, taskIdInput: unknow
if (!task.connector_session_id) {
return { task: mapTask(task), login: null }
}
const session = await getHuyaLoginSession(task.connector_session_id)
const session = await getHuyaLoginSession(task.connector_session_id, task.connector_mode)
await updateLoginTask(task, session, 'customer')
const updated = await loadTask(task.id)
return {
@@ -678,6 +683,7 @@ export async function pollPublicLogin(orderNoInput: unknown, taskIdInput: unknow
login: {
status: mapLoginUpdate(session).status,
qrContent: session.qrContent,
qrImage: String(session.qrImage || ''),
expiresAt: session.expiresAt,
message: session.message,
},
@@ -700,7 +706,7 @@ export async function startPublicBinding(orderNoInput: unknown, taskIdInput: unk
errorCode: 'semi_auto_login_required',
})
}
const session = await startHuyaBinding(task.connector_session_id)
const session = await startHuyaBinding(task.connector_session_id, task.connector_mode)
const binding = await updateBindingTask(task, session, 'customer')
const updated = await loadTask(task.id)
return { task: mapTask(updated), binding }
@@ -716,7 +722,7 @@ export async function pollPublicBinding(orderNoInput: unknown, taskIdInput: unkn
if (!task.connector_session_id || task.login_status !== 'authenticated') {
return { task: mapTask(task), binding: null }
}
const session = await getHuyaBinding(task.connector_session_id)
const session = await getHuyaBinding(task.connector_session_id, task.connector_mode)
const binding = await updateBindingTask(task, session, 'customer')
const updated = await loadTask(task.id)
return { task: mapTask(updated), binding }
@@ -738,7 +744,7 @@ export async function confirmPublicBinding(orderNoInput: unknown, taskIdInput: u
errorCode: 'semi_auto_binding_not_ready',
})
}
const session = await confirmHuyaBinding(task.connector_session_id)
const session = await confirmHuyaBinding(task.connector_session_id, task.connector_mode)
const binding = await updateBindingTask(task, session, 'customer')
const updated = await loadTask(task.id)
return { task: mapTask(updated), binding }
@@ -874,12 +880,12 @@ export async function syncAdminSemiAutoTask(taskIdInput: unknown) {
task.login_status !== 'failed' &&
task.login_status !== 'expired'
) {
const login = await getHuyaLoginSession(task.connector_session_id)
const login = await getHuyaLoginSession(task.connector_session_id, task.connector_mode)
await updateLoginTask(task, login, 'connector')
task = await loadTask(taskId)
}
if (task.login_status === 'authenticated' && task.binding_status !== 'confirmed') {
const binding = await getHuyaBinding(task.connector_session_id)
const binding = await getHuyaBinding(task.connector_session_id, task.connector_mode)
await updateBindingTask(task, binding, 'connector')
}
return getAdminSemiAutoTask(taskId)
@@ -912,10 +918,13 @@ export async function createAdminBrowserSession(
Boolean(item && typeof item === 'object'),
)?.url
const targetUrl = explicitTarget || String(fallbackTarget || '')
const ticket = await createHuyaBrowserTicket({
sessionId: task.connector_session_id,
targetUrl,
})
const ticket = await createHuyaBrowserTicket(
{
sessionId: task.connector_session_id,
targetUrl,
},
task.connector_mode,
)
await withTransaction(async (client) => {
await client.query(
`UPDATE semi_auto_tasks
@@ -1093,7 +1102,10 @@ export async function completeAdminSemiAutoTask(
}
await runManualFulfillmentPostCommit(completion.task.order_item_id)
if (completion.task.connector_session_id) {
await revokeHuyaSession(completion.task.connector_session_id).catch(() => undefined)
await revokeHuyaSession(
completion.task.connector_session_id,
completion.task.connector_mode,
).catch(() => undefined)
}
publishAdminOrderRealtimeChange(Number(completion.task.order_id))
return getAdminSemiAutoTask(taskId)
@@ -1137,7 +1149,7 @@ export async function cancelAdminSemiAutoTask(
})
})
if (task.connector_session_id) {
await revokeHuyaSession(task.connector_session_id).catch(() => undefined)
await revokeHuyaSession(task.connector_session_id, task.connector_mode).catch(() => undefined)
}
publishAdminOrderRealtimeChange(Number(task.order_id))
return getAdminSemiAutoTask(taskId)
@@ -359,13 +359,14 @@ test(
skuName: skuKey,
quantity: 1,
pageTitle: 'Mock 集成测试',
connectorMode: 'embedded',
})
orderId = result.orderId
configId = result.configId
assert.ok(result.orderItemId > 0)
assert.ok(result.fulfillmentId > 0)
assert.ok(result.taskId > 0)
assert.ok(['mock', 'external'].includes(result.connectorMode))
assert.equal(result.connectorMode, 'embedded')
assert.equal(result.customerPath, `/c/${encodeURIComponent(orderNo)}`)
const row = await query<{
@@ -374,6 +375,7 @@ test(
fulfillment_config_id: number
semi_auto_enabled: boolean
semi_auto_platform: string
connector_mode: string
login_status: string
binding_status: string
execution_status: string
@@ -385,6 +387,7 @@ test(
f.fulfillment_config_id,
cfg.semi_auto_enabled,
cfg.semi_auto_platform,
task.connector_mode,
task.login_status,
task.binding_status,
task.execution_status
@@ -400,6 +403,7 @@ test(
assert.equal(Number(row.rows[0]?.fulfillment_config_id), result.configId)
assert.equal(row.rows[0]?.semi_auto_enabled, true)
assert.equal(row.rows[0]?.semi_auto_platform, 'huya')
assert.equal(row.rows[0]?.connector_mode, 'embedded')
assert.equal(row.rows[0]?.login_status, 'waiting_login')
assert.equal(row.rows[0]?.binding_status, 'not_started')
assert.equal(row.rows[0]?.execution_status, 'waiting_customer')
@@ -413,3 +417,42 @@ test(
}
},
)
test(
'开发 Mock 可显式选择内置 Mock Connector',
{ 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 suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`
const orderNo = `IT-MOCK-SA-MOCK-${suffix}`
let orderId = 0
let configId = 0
try {
const result = await createSemiAutoMock({
orderNo,
skuName: `半自动Mock连接器-${suffix}`,
quantity: 1,
connectorMode: 'mock',
})
orderId = result.orderId
configId = result.configId
assert.equal(result.connectorMode, 'mock')
const row = await query<{ connector_mode: string }>(
'SELECT connector_mode FROM semi_auto_tasks WHERE id = $1',
[result.taskId],
)
assert.equal(row.rows[0]?.connector_mode, 'mock')
} 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])
}
delete process.env.ENABLE_DEV_MOCK
await closeDb()
}
},
)
+7
View File
@@ -62,9 +62,16 @@ export type RuntimeConfig = {
tokenTtlHours: number
}
semiAuto: {
/** 新建半自动任务的默认 Connector:内置真实实现、外部 Connector 或开发 Mock。 */
defaultConnectorMode: 'embedded' | 'external' | 'mock'
connectorBaseUrl: string
connectorApiKey: string
connectorTimeoutMs: number
huya: {
bindActId: number
outerActId: string
roomPid: string
}
}
admin: {
sessionSecret: string
@@ -124,7 +124,7 @@ export default function AdminDevMockPage() {
<section className="page-stack">
<PageHeader
title="开发 Mock"
description="一键造单、测领取页与 91 查单,无需真实 91 / affiliate-dash 环境。"
description="一键造单与测试数据;半自动履约可选择只 Mock 订单,登录、绑定走真实 Connector。"
extra={
<Typography.Text type="secondary">NODE_ENV={status?.nodeEnv || '-'}</Typography.Text>
}
@@ -134,8 +134,8 @@ export default function AdminDevMockPage() {
type="info"
showIcon
icon={<BugOutlined />}
message="Mock 会写入开发库真实订单/任务;当前仅保留 affiliate-dash / 电子凭证 / 91 查单。"
description="affiliate-dash 可用默认 UID 测试绑定与提交发货。"
message="Mock 会写入开发库真实订单/任务;除订单外,登录、绑定、票据注入和完成确认都可以走真实流程。"
description="默认使用 order_site 内置真实 Connector,不依赖 live-hub-py;只有显式选择内置 Mock 时才会 Mock 登录和绑定。"
/>
<Row gutter={[16, 16]}>
@@ -149,10 +149,12 @@ export default function AdminDevMockPage() {
</Col>
<Col xs={24} xl={12}>
<Card title="2. 半自动履约测试单(Mock Connector)" bordered={false}>
<Card title="2. 半自动履约测试单(可选 Mock 范围)" bordered={false}>
<SemiAutoMockForm
loading={semiAutoMutation.isPending}
connectorMode={status?.semiAuto?.connectorMode}
defaultConnectorMode={status?.semiAuto?.defaultConnectorMode}
externalConnectorConfigured={status?.semiAuto?.externalConnectorConfigured}
mockConnectorAvailable={status?.semiAuto?.mockConnectorAvailable}
onSubmit={(values) => semiAutoMutation.mutate(values)}
/>
</Card>
@@ -340,12 +342,22 @@ export default function AdminDevMockPage() {
</Col>
<Col span={24}>
<Alert
type={lastSemiAuto.connectorMode === 'mock' ? 'success' : 'warning'}
type={
lastSemiAuto.connectorMode === 'mock'
? 'warning'
: lastSemiAuto.connectorConfigured
? 'success'
: 'error'
}
showIcon
message={
lastSemiAuto.connectorMode === 'mock'
? '当前使用内置 Mock Connector'
: '当前使用外部 Connector'
: lastSemiAuto.connectorConfigured
? lastSemiAuto.connectorMode === 'external'
? '当前使用远程真实 Connector'
: '当前使用 order_site 内置真实 Connector'
: '当前选择远程真实 Connector,但尚未配置'
}
description={
<ul style={{ margin: 0, paddingLeft: 18 }}>
@@ -503,29 +515,70 @@ function KuaishouIndustryVoucherMockForm({
function SemiAutoMockForm({
loading,
connectorMode,
defaultConnectorMode,
externalConnectorConfigured,
mockConnectorAvailable,
onSubmit,
}: {
loading: boolean
connectorMode?: 'mock' | 'external'
defaultConnectorMode?: 'embedded' | 'external' | 'mock'
externalConnectorConfigured?: boolean
mockConnectorAvailable?: boolean
onSubmit: (values: {
orderNo?: string
skuName?: string
quantity?: number
pageTitle?: string
browserTargetUrl?: string
connectorMode?: 'embedded' | 'external' | 'mock'
}) => void
}) {
const [form] = Form.useForm()
const defaultMode =
defaultConnectorMode === 'mock' && mockConnectorAvailable
? 'mock'
: defaultConnectorMode === 'external'
? 'external'
: 'embedded'
const selectedMode =
(Form.useWatch('connectorMode', form) as 'embedded' | 'external' | 'mock' | undefined) ||
defaultMode
return (
<Form
form={form}
layout="vertical"
initialValues={{
quantity: 1,
pageTitle: '虎牙账号授权测试',
browserTargetUrl: 'https://www.huya.com/',
connectorMode: defaultMode,
}}
onFinish={onSubmit}
>
<Form.Item
label="Mock 范围"
name="connectorMode"
extra="只 Mock 订单时,登录、角色绑定和浏览器票据注入可走内置真实 Connector,也可显式使用远程 Connector。"
>
<Select
options={[
{
value: 'embedded',
label: '仅 Mock 订单(内置真实登录 / 绑定 / 注入)',
},
{
value: 'external',
label: '仅 Mock 订单(远程真实 Connector)',
},
{
value: 'mock',
label: '订单 + 登录绑定都 Mock(内置 Connector)',
disabled: mockConnectorAvailable === false,
},
]}
/>
</Form.Item>
<Form.Item
label="内部 SKU 名称(可空)"
name="skuName"
@@ -552,15 +605,23 @@ function SemiAutoMockForm({
<Input placeholder="https://www.huya.com/" />
</Form.Item>
<Alert
type={connectorMode === 'mock' ? 'success' : 'warning'}
type={
selectedMode === 'mock'
? 'warning'
: selectedMode === 'external' && !externalConnectorConfigured
? 'error'
: 'success'
}
showIcon
style={{ marginBottom: 12 }}
message={
connectorMode === 'mock'
? '内置 Mock Connector 已启用,登录和绑定会自动推进。'
: connectorMode === 'external'
? '当前配置为外部 Connector,请先确认外部服务已启动。'
: 'Connector 状态未知;生成后可在客户页验证。'
selectedMode === 'mock'
? '登录和绑定会使用内置 Mock Connector,仅用于联调页面。'
: selectedMode === 'external'
? externalConnectorConfigured
? '远程真实 Connector 已配置;只有订单由 Mock 生成。'
: '远程真实 Connector 未配置:请设置 SEMI_AUTO_CONNECTOR_BASE_URL 和 API_KEY。'
: '使用 order_site 内置真实 Connector;无需启动 live-hub-py,只有订单由 Mock 生成。'
}
/>
<Button type="primary" htmlType="submit" loading={loading} block>
@@ -268,6 +268,16 @@ export default function AdminSemiAutoTasksPage() {
width: 90,
render: (value: string) => <Tag color="purple">{PLATFORM_LABELS[value] || value}</Tag>,
},
{
title: 'Connector',
dataIndex: 'connectorMode',
width: 110,
render: (value: string) => (
<Tag color={value === 'mock' ? 'orange' : 'green'}>
{value === 'mock' ? '内置 Mock' : '真实'}
</Tag>
),
},
{
title: '客户登录',
key: 'login',
@@ -457,6 +467,15 @@ export default function AdminSemiAutoTasksPage() {
<Descriptions.Item label="平台">
{PLATFORM_LABELS[detail.task.platform] || detail.task.platform}
</Descriptions.Item>
<Descriptions.Item label="Connector">
<Tag color={detail.task.connectorMode === 'mock' ? 'orange' : 'green'}>
{detail.task.connectorMode === 'mock'
? '内置 Mock'
: detail.task.connectorMode === 'external'
? '远程真实'
: '内置真实'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="当前状态">
<Tag color={STATUS_COLORS[detail.task.displayStatus.key] || 'default'}>
{detail.task.displayStatus.label}
@@ -100,6 +100,10 @@ export default function CustomerFulfillmentPage() {
}, [orderNo, selectedTask?.id])
useEffect(() => {
if (loginInfo?.qrImage) {
setLoginQrImage(loginInfo.qrImage)
return
}
if (!loginInfo?.qrContent) {
setLoginQrImage('')
return
@@ -109,7 +113,7 @@ export default function CustomerFulfillmentPage() {
margin: 2,
color: { dark: '#111827', light: '#ffffff' },
}).then(setLoginQrImage)
}, [loginInfo?.qrContent])
}, [loginInfo?.qrContent, loginInfo?.qrImage])
useEffect(() => {
if (!selectedTask || !orderNo) return
+6 -3
View File
@@ -11,8 +11,9 @@ export type DevMockStatus = {
steps: string[]
}>
semiAuto?: {
connectorMode: 'mock' | 'external'
connectorConfigured: boolean
defaultConnectorMode: 'embedded' | 'external' | 'mock'
externalConnectorConfigured: boolean
mockConnectorAvailable: boolean
platform: string
}
}
@@ -59,7 +60,8 @@ export type DevMockSemiAutoResult = {
configId: number
taskId: number
skuKey: string
connectorMode: 'mock' | 'external'
connectorMode: 'embedded' | 'external' | 'mock'
connectorConfigured: boolean
customerPath: string
adminPath: string
tips: string[]
@@ -93,6 +95,7 @@ export function createSemiAutoDevMock(payload: {
pageTitle?: string
pageDescription?: string
browserTargetUrl?: string
connectorMode?: 'embedded' | 'external' | 'mock'
}) {
return apiPost<DevMockSemiAutoResult>('/api/v1/admin/dev-mock/semi-auto', payload)
}
@@ -15,6 +15,7 @@ export type SemiAutoTask = {
loginStatus: string
bindingStatus: string
executionStatus: string
connectorMode: 'embedded' | 'external' | 'mock'
displayStatus: SemiAutoDisplayStatus
connectorReady: boolean
huyaUid: string
@@ -29,6 +29,7 @@ export type CustomerSemiAutoTask = {
export type CustomerLoginInfo = {
status: string
qrContent: string
qrImage?: string
expiresAt: string
message: string
}
+4 -3
View File
@@ -118,9 +118,10 @@ services:
WORKSPACE_ROOT: /workspace
# ADMIN_* 仅走 env_file,避免 JSON 经 ${} 注入时被 YAML 破坏
KUASHOU_INDUSTRY_RATE_LIMIT_MAX: ${KUASHOU_INDUSTRY_RATE_LIMIT_MAX:-120}
# 默认使用 order_site 内置 Mock Connector;需要接外部 Connector 时显式覆盖。
SEMI_AUTO_CONNECTOR_BASE_URL: ${SEMI_AUTO_CONNECTOR_BASE_URL:-http://127.0.0.1:3000/api/v1/dev-mock/connector}
SEMI_AUTO_CONNECTOR_API_KEY: ${SEMI_AUTO_CONNECTOR_API_KEY:-local-semi-auto-connector-key}
# 默认使用 order_site 内置真实 Connector;只有 external 模式才读取远程地址与密钥。
SEMI_AUTO_DEFAULT_CONNECTOR_MODE: ${SEMI_AUTO_DEFAULT_CONNECTOR_MODE:-embedded}
SEMI_AUTO_CONNECTOR_BASE_URL: ${SEMI_AUTO_CONNECTOR_BASE_URL:-}
SEMI_AUTO_CONNECTOR_API_KEY: ${SEMI_AUTO_CONNECTOR_API_KEY:-}
SEMI_AUTO_CONNECTOR_TIMEOUT_MS: ${SEMI_AUTO_CONNECTOR_TIMEOUT_MS:-15000}
logging: *json-log-rotation
volumes:
+1
View File
@@ -139,6 +139,7 @@ services:
KUASHOU_INDUSTRY_RATE_LIMIT_MAX: ${KUASHOU_INDUSTRY_RATE_LIMIT_MAX:-120}
SEMI_AUTO_CONNECTOR_BASE_URL: ${SEMI_AUTO_CONNECTOR_BASE_URL:-}
SEMI_AUTO_CONNECTOR_API_KEY: ${SEMI_AUTO_CONNECTOR_API_KEY:-}
SEMI_AUTO_DEFAULT_CONNECTOR_MODE: ${SEMI_AUTO_DEFAULT_CONNECTOR_MODE:-embedded}
SEMI_AUTO_CONNECTOR_TIMEOUT_MS: ${SEMI_AUTO_CONNECTOR_TIMEOUT_MS:-15000}
logging: *json-log-rotation
volumes:
@@ -3,7 +3,7 @@ type: 权威架构与实施计划
description: 订单、内部 SKU、履约路由、履约执行、商品、成本与财务的唯一权威口径及重构计划。
tags: [订单, 内部SKU, 履约, 商品, 成本, 财务, 迁移]
status: authoritative
version: 2.7
version: 2.9
updated_at: 2026-11-09
---
@@ -12,7 +12,7 @@ updated_at: 2026-11-09
> 状态:**唯一权威文档**。
> 最后校准:2026-11-09。
> 生产基线:迁移 `151`。
> 开发基线:迁移 `153`(仅本地,尚未部署生产)。
> 开发基线:迁移 `155`(仅本地,尚未部署生产)。
>
> 从本文件生效起,订单、内部 SKU、履约渠道、履约载体、标准商品、成本和财务相关实现,
> 只能以本文件为业务口径。其他旧分析只作为历史资料,不再具备设计或验收效力。
@@ -286,8 +286,13 @@ manual fulfillment
- 是否启用半自动履约由 `manual_fulfillment_configs.semi_auto_*` 显式配置,不能按商品名或平台前缀猜测。
- 半自动任务必须绑定 `order_item_id`、`fulfillments.id` 和人工履约配置,不得伪造接单平台工单。
- 登录状态、绑定状态、执行状态必须正交保存于 `semi_auto_tasks`;不得写入 `material_json` 作为正式状态。
- 平台 CK 只能由外部 Connector 的短期凭证域托管;order_site 不保存、不返回、不记录原始 CK。
第一版 Connector 为单进程内存会话,重启即失效;多实例部署必须迁移到 Redis 或加密凭证存储。
- 平台 CK 只能由 Connector 的短期凭证域托管;order_site 数据库不保存、不返回、不记录原始 CK。
默认真实 Connector 内置于 order_site 进程,远程 Connector 仅是可选隔离部署方式;两者均为进程内存会话,
重启即失效。多实例或多副本部署必须迁移到 Redis 或加密凭证存储。
- 半自动任务通过 `connector_mode` 显式记录 Connector 来源:
`embedded` 为 order_site 内置真实实现并作为默认值,`external` 为显式配置的远程真实实现,
`mock` 为 order_site 内置开发 Mock 且仅在非生产环境可用。开发 Mock 造单时默认选择 `embedded`,
只有需要完全脱离虎牙接口联调页面时才显式选择 `mock`。
- 虎牙二维码登录有效期 `5` 分钟;登录成功后的 CK 会话有效期 `24` 小时;角色绑定二维码 `5` 分钟;
浏览器一次性注入票据 `2` 分钟且消费后立即失效。重新登录前必须撤销旧 Connector 会话。
- 浏览器扩展复用 live-hub-py 的 `web/extension/huya-exchange-helper`,不新增第二个扩展;
@@ -926,8 +931,9 @@ docs/archive/
## 十三、当前下一步
截至 2026-11-09,生产基线为 `151`,本地开发基线为 `153`。迁移 `152` 保护订单来源快照,
迁移 `153` 增加人工渠道半自动任务。后续顺序固定为:
截至 2026-11-09,生产基线为 `151`,本地开发基线为 `155`。迁移 `152` 保护订单来源快照,
迁移 `153` 增加人工渠道半自动任务,迁移 `154` 增加半自动任务的 Connector 来源,
迁移 `155` 将内置真实 Connector 设为默认并扩充来源约束。
1. 生产升级只按 [生产升级操作手册](生产升级操作手册.md) 执行。
2. 升级前备份必须完成隔离恢复验证;恢复验证未通过时禁止迁移。