实现内置虎牙扫码登录与绑定 Connector
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
# 快手行业电子凭证
|
||||
|
||||
@@ -41,3 +41,4 @@ send_code/
|
||||
/.deploy-state
|
||||
|
||||
artifacts/
|
||||
har/
|
||||
|
||||
@@ -5,4 +5,5 @@
|
||||
**/data
|
||||
**/package-lock.json
|
||||
**/.env*
|
||||
**/services/semi-auto/huya-native/assets/fingerprint/*-min.js
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -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 }
|
||||
Vendored
+1
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()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
+12
-6
@@ -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. 升级前备份必须完成隔离恢复验证;恢复验证未通过时禁止迁移。
|
||||
|
||||
Reference in New Issue
Block a user