合并 dev_11.9_2 到 dev_11.13

This commit is contained in:
yml
2026-11-13 22:52:59 +08:00
78 changed files with 12616 additions and 109 deletions
+8
View File
@@ -7,6 +7,8 @@ import affiliateDashRouter from './routes/affiliate-dash.js'
import orderServiceRouter from './routes/order-service.js'
import filesRouter from './routes/files.js'
import claimsRouter from './routes/claims.js'
import customerFulfillmentRouter from './routes/customer-fulfillment.js'
import devMockConnectorRouter from './routes/dev-mock-connector.js'
import dcAuthRouter from './routes/dcauth.js'
import douyinMashidaRouter from './routes/douyin-mashida.js'
import kuaishouIndustryRouter from './routes/kuaishou-industry.js'
@@ -14,6 +16,7 @@ import open91Router from './routes/open-91.js'
import pddExtensionRouter from './routes/pdd-extension.js'
import redeemRouter from './routes/redeem.js'
import shortLinksRouter from './routes/short-links.js'
import semiAutoInjectionsRouter from './routes/semi-auto-injections.js'
import workerRouter from './routes/worker.js'
import { accessLogMiddleware } from './middleware/access-log.js'
import { createCorsMiddleware } from './middleware/cors.js'
@@ -52,6 +55,9 @@ export function createApp({ startupState, isShutdownStarted, config }: CreateApp
)
app.use(express.urlencoded({ extended: true }))
// 本地开发用内置 Connector;生产环境由路由内部直接返回 404。
app.use('/api/v1/dev-mock/connector', devMockConnectorRouter)
app.get('/health', (_req, res) => {
res.json(
buildSuccessPayload(
@@ -112,6 +118,8 @@ export function createApp({ startupState, isShutdownStarted, config }: CreateApp
app.use('/api/v1/open/kuaishou-industry', kuaishouIndustryRouter)
app.use('/api/v1/redeem', redeemRouter)
app.use('/api/v1/claim', claimsRouter)
app.use('/api/v1/customer-fulfillment', customerFulfillmentRouter)
app.use('/api/v1/semi-auto/injections', semiAutoInjectionsRouter)
app.use('/api/v1/o', orderServiceRouter)
app.use('/api/v1/pdd/extension', pddExtensionRouter)
app.use('/api/v1/files', filesRouter)
+13
View File
@@ -61,6 +61,19 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
tokenTtlHours: 0,
},
semiAuto: {
defaultConnectorMode: 'embedded',
connectorBaseUrl: '',
connectorApiKey: '',
connectorTimeoutMs: 15_000,
credentialEncryptionKey: '',
huya: {
bindActId: 9271,
outerActId: '17096',
roomPid: '1199650619883',
},
},
admin: {
sessionSecret: '',
// 后台:24h 空闲过期,登录后最多 7 天必须重新登录。
@@ -21,7 +21,10 @@ const DEPLOYMENT_ONLY_ENV_NAMES = [
'DB_BACKUP_OSS_PREFIX',
'KUASHOU_INDUSTRY_RATE_LIMIT_MAX',
'MINIO_API_PORT',
'MINIO_API_PORT_NUMBER',
'MINIO_BROWSER',
'MINIO_CONSOLE_PORT',
'MINIO_CONSOLE_PORT_NUMBER',
'MINIO_ROOT_PASSWORD',
'MINIO_ROOT_USER',
'NPM_CONFIG_REGISTRY',
+8
View File
@@ -51,6 +51,14 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [
stringEnv('STORAGE_OSS_SECRET_ACCESS_KEY', ['storage', 'oss', 'secretAccessKey']),
stringEnv('STORAGE_OSS_REGION', ['storage', 'oss', 'region']),
integerEnv('CLAIM_TOKEN_TTL_HOURS', ['orders', 'tokenTtlHours']),
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']),
stringEnv('SEMI_AUTO_CREDENTIAL_ENCRYPTION_KEY', ['semiAuto', 'credentialEncryptionKey']),
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,38 @@ 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('validateRuntimeConfig requires a strong semi-auto credential key in production', () => {
const issues = validateRuntimeConfig(
createRuntimeConfig({
semiAuto: {
credentialEncryptionKey: 'short-key',
},
}),
{ env: { NODE_ENV: 'production' } },
)
assert.deepEqual(
issues.map((issue) => issue.path),
['semiAuto.credentialEncryptionKey'],
)
})
test('assertRuntimeConfigValid throws a readable validation error', () => {
assert.throws(
() => {
@@ -187,6 +219,18 @@ function createRuntimeConfig(overrides: Record<string, unknown> = {}): RuntimeCo
claimBaseUrl: 'https://order.example.test/#/claim',
tokenTtlHours: 24,
},
semiAuto: {
defaultConnectorMode: 'embedded',
connectorBaseUrl: '',
connectorApiKey: '',
connectorTimeoutMs: 15000,
credentialEncryptionKey: '0123456789abcdef0123456789abcdef',
huya: {
bindActId: 9271,
outerActId: '17096',
roomPid: '1199650619883',
},
},
admin: {
sessionSecret: '0123456789abcdef0123456789abcdef',
sessionIdleTtlHours: 24,
@@ -77,6 +77,27 @@ export function validateRuntimeConfig(
config.database?.slowQueryThresholdMs,
{ 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',
})
}
const credentialEncryptionKey = String(config.semiAuto?.credentialEncryptionKey || '').trim()
if (!productionLike && credentialEncryptionKey && credentialEncryptionKey.length < 32) {
issues.push({
path: 'semiAuto.credentialEncryptionKey',
message: '长度不能少于 32 个字符',
})
}
requireOptionalInteger(
issues,
'semiAuto.connectorTimeoutMs',
config.semiAuto?.connectorTimeoutMs,
{ min: 1 },
)
requireInteger(issues, 'storage.maxUploadSizeMb', config.storage?.maxUploadSizeMb, {
min: 1,
max: 100,
@@ -120,6 +141,12 @@ export function validateRuntimeConfig(
}
validateRequiredHttpUrl(issues, 'orders.claimBaseUrl', config.orders?.claimBaseUrl)
validateSecret(issues, 'admin.sessionSecret', config.admin?.sessionSecret, { minLength: 32 })
validateSecret(
issues,
'semiAuto.credentialEncryptionKey',
config.semiAuto?.credentialEncryptionKey,
{ minLength: 32 },
)
validateAdminDefaultUsers(issues, config.admin?.defaultUsers)
const allowedOrigins = config.cors?.allowedOrigins
if (Array.isArray(allowedOrigins)) {
@@ -0,0 +1,108 @@
-- 153_semi_auto_fulfillment.sql
--
-- 人工履约渠道下的半自动任务:
-- 1. 人工 SKU 配置可显式启用虎牙/微博等半自动平台;
-- 2. 半自动任务绑定订单项和正式履约,不另建接单平台工单;
-- 3. 登录、绑定、执行状态正交保存,客户链接只按订单号展示必要信息;
-- 4. CK 不进入 order_site;由外部 Connector 加密托管,任务只保存平台账号快照。
ALTER TABLE manual_fulfillment_configs
ADD COLUMN IF NOT EXISTS semi_auto_enabled BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS semi_auto_platform TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS semi_auto_config_json JSONB NOT NULL DEFAULT '{}'::jsonb;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'chk_manual_fulfillment_configs_semi_auto_platform'
) THEN
ALTER TABLE manual_fulfillment_configs
ADD CONSTRAINT chk_manual_fulfillment_configs_semi_auto_platform
CHECK (semi_auto_platform IN ('', 'huya', 'weibo'));
END IF;
END $$;
CREATE TABLE IF NOT EXISTS semi_auto_tasks (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
order_item_id BIGINT NOT NULL UNIQUE REFERENCES order_items(id) ON DELETE CASCADE,
fulfillment_id BIGINT REFERENCES fulfillments(id) ON DELETE SET NULL,
manual_fulfillment_config_id BIGINT
REFERENCES manual_fulfillment_configs(id) ON DELETE SET NULL,
platform TEXT NOT NULL CHECK (platform IN ('huya', 'weibo')),
login_status TEXT NOT NULL DEFAULT 'waiting_login'
CHECK (login_status IN (
'waiting_login', 'qr_pending', 'scanned', 'authenticated', 'failed', 'expired'
)),
binding_status TEXT NOT NULL DEFAULT 'not_started'
CHECK (binding_status IN (
'not_started', 'qr_pending', 'scanned', 'role_ready', 'confirmed', 'failed', 'expired'
)),
execution_status TEXT NOT NULL DEFAULT 'waiting_customer'
CHECK (execution_status IN (
'waiting_customer', 'waiting_operator', 'processing', 'completed', 'failed', 'cancelled'
)),
connector_session_id TEXT NOT NULL DEFAULT '',
huya_uid TEXT NOT NULL DEFAULT '',
platform_nickname TEXT NOT NULL DEFAULT '',
bind_game_account TEXT NOT NULL DEFAULT '',
bind_game_role TEXT NOT NULL DEFAULT '',
bind_game_channel TEXT NOT NULL DEFAULT '',
target_quantity INTEGER NOT NULL DEFAULT 1 CHECK (target_quantity > 0),
completed_quantity INTEGER NOT NULL DEFAULT 0
CHECK (completed_quantity >= 0 AND completed_quantity <= target_quantity),
assigned_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL,
customer_link_opened_at TIMESTAMPTZ,
auth_started_at TIMESTAMPTZ,
authenticated_at TIMESTAMPTZ,
binding_started_at TIMESTAMPTZ,
bound_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
completion_note TEXT NOT NULL DEFAULT '',
external_order_no TEXT NOT NULL DEFAULT '',
completion_evidence_json JSONB NOT NULL DEFAULT '{}'::jsonb,
last_error TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_semi_auto_tasks_status
ON semi_auto_tasks (execution_status, platform, updated_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_semi_auto_tasks_order
ON semi_auto_tasks (order_id, id);
CREATE INDEX IF NOT EXISTS idx_semi_auto_tasks_operator
ON semi_auto_tasks (assigned_admin_user_id, execution_status, updated_at DESC)
WHERE assigned_admin_user_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_semi_auto_tasks_connector
ON semi_auto_tasks (connector_session_id)
WHERE connector_session_id <> '';
CREATE TABLE IF NOT EXISTS semi_auto_task_events (
id BIGSERIAL PRIMARY KEY,
task_id BIGINT NOT NULL REFERENCES semi_auto_tasks(id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
actor_type TEXT NOT NULL DEFAULT 'system'
CHECK (actor_type IN ('system', 'customer', 'admin', 'connector')),
actor_id TEXT NOT NULL DEFAULT '',
from_login_status TEXT,
to_login_status TEXT,
from_binding_status TEXT,
to_binding_status TEXT,
from_execution_status TEXT,
to_execution_status TEXT,
detail_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_semi_auto_task_events_task
ON semi_auto_task_events (task_id, id DESC);
COMMENT ON TABLE semi_auto_tasks IS
'人工履约渠道下的半自动任务;绑定订单项和正式履约,不承载平台 CK';
COMMENT ON COLUMN semi_auto_tasks.connector_session_id IS
'外部平台 Connector 的短期登录/绑定会话 ID;CK 仅保存在 Connector 加密凭证域';
COMMENT ON COLUMN semi_auto_tasks.completion_evidence_json IS
'可选交付证据(截图 object_key、备注、外部单号等),不得保存 Cookie 或令牌';
COMMENT ON COLUMN manual_fulfillment_configs.semi_auto_config_json IS
'半自动履约展示与目标页配置;不得保存平台 CK 或运行时状态';
@@ -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 仅开发环境可用';
@@ -0,0 +1,65 @@
-- 156_semi_auto_credentials.sql
--
-- 半自动履约平台账号凭证:
-- 1. CK 是账号级凭证,挂到平台 UID,而不是只保存在进程内存或单次任务上;
-- 2. 数据库只保存加密后的 Cookie 集合,任务仅引用 credential_id;
-- 3. 记录凭证来源、更新时间和健康状态,供执行器失效判定与人工重登使用。
CREATE TABLE IF NOT EXISTS semi_auto_credentials (
id BIGSERIAL PRIMARY KEY,
platform TEXT NOT NULL CHECK (platform IN ('huya', 'weibo')),
platform_uid TEXT NOT NULL,
platform_nickname TEXT NOT NULL DEFAULT '',
credential_ciphertext TEXT NOT NULL,
credential_source TEXT NOT NULL DEFAULT ''
CHECK (credential_source IN ('', 'qr', 'sms', 'web', 'app', 'import')),
credential_status TEXT NOT NULL DEFAULT 'unknown'
CHECK (credential_status IN ('unknown', 'valid', 'invalid', 'error')),
credential_updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_checked_at TIMESTAMPTZ,
last_check_error TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (platform, platform_uid)
);
ALTER TABLE semi_auto_tasks
ADD COLUMN IF NOT EXISTS credential_id BIGINT
REFERENCES semi_auto_credentials(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_semi_auto_credentials_status
ON semi_auto_credentials (platform, credential_status, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_semi_auto_tasks_credential
ON semi_auto_tasks (credential_id)
WHERE credential_id IS NOT NULL;
COMMENT ON TABLE semi_auto_credentials IS
'半自动履约平台账号级加密凭证;原始 CK 不得进入接口响应、日志或页面';
COMMENT ON COLUMN semi_auto_credentials.credential_ciphertext IS
'AES-256-GCM 加密后的 Cookie JSON;密钥来自 SEMI_AUTO_CREDENTIAL_ENCRYPTION_KEY';
COMMENT ON COLUMN semi_auto_credentials.credential_source IS
'凭证来源:qr / sms / web / app / import';
COMMENT ON COLUMN semi_auto_credentials.credential_status IS
'执行器最近一次检查结论:unknown / valid / invalid / error';
COMMENT ON COLUMN semi_auto_tasks.credential_id IS
'该订单当前使用的平台账号凭证;task 不保存 CK 明文';
INSERT INTO admin_permissions (
permission_key, permission_name, permission_group, description
) VALUES (
'semi_auto.credential.export',
'导出半自动履约账号 CK',
'半自动履约',
'受控导出单个半自动履约任务的账号级 Cookie;导出动作写入审计但不记录 CK'
)
ON CONFLICT (permission_key) DO UPDATE SET
permission_name = EXCLUDED.permission_name,
permission_group = EXCLUDED.permission_group,
description = EXCLUDED.description,
updated_at = NOW();
INSERT INTO admin_role_permissions (role_key, permission_key)
SELECT 'admin', 'semi_auto.credential.export'
FROM admin_roles
WHERE role_key = 'admin'
ON CONFLICT DO NOTHING;
+2
View File
@@ -16,6 +16,7 @@ import ordersRouter from './admin/orders.js'
import notificationsRouter from './admin/notifications.js'
import realtimeRouter from './admin/realtime.js'
import rbacRouter from './admin/rbac.js'
import semiAutoRouter from './admin/semi-auto.js'
import platformConfigRouter from './admin/platform-config.js'
import { requireAdminSession } from './admin/session.js'
import tasksRouter from './admin/tasks.js'
@@ -36,6 +37,7 @@ router.use(fulfillmentChannelRouter)
router.use(fundRouter)
router.use(usersRouter)
router.use(rbacRouter)
router.use(semiAutoRouter)
router.use(auditLogsRouter)
router.use(platformConfigRouter)
router.use(kuaishouIndustryRouter)
+21
View File
@@ -4,6 +4,7 @@ import {
buildOpen91QueryMock,
createAffiliateDashMockClaim,
createKuaishouIndustryVoucherMockData,
createSemiAutoMock,
getDevMockStatus,
isDevMockEnabled,
} from '../../services/dev-mock/dev-mock-service.js'
@@ -75,6 +76,26 @@ router.post(
}),
)
router.post(
'/dev-mock/semi-auto',
createJsonHandler((req) => createSemiAutoMock(req.body || {}), {
successMessage: '半自动履约 Mock 已生成',
errorMessage: '生成半自动履约 Mock 失败',
scope: '[admin/dev-mock/semi-auto]',
audit: (_req, data) => ({
action: 'dev_mock_create_semi_auto',
targetType: 'dev_mock',
targetId: String((data as { orderNo?: string })?.orderNo || ''),
data: {
platform: 'semi_auto',
orderNo: (data as { orderNo?: string })?.orderNo,
skuKey: (data as { skuKey?: string })?.skuKey,
connectorMode: (data as { connectorMode?: string })?.connectorMode,
},
}),
}),
)
router.post(
'/dev-mock/open91/query',
createJsonHandler((req) => buildOpen91QueryMock(req.body || {}), {
+157
View File
@@ -0,0 +1,157 @@
import { Router } from 'express'
import {
cancelAdminSemiAutoTask,
claimAdminSemiAutoTask,
completeAdminSemiAutoTask,
consumeSemiAutoInjectionTicket,
createAdminBrowserSession,
exportAdminSemiAutoCredential,
getAdminSemiAutoTask,
listAdminSemiAutoTasks,
syncAdminSemiAutoTask,
} from '../../services/semi-auto/semi-auto-task-service.js'
import { bodyOf, sessionOf } from './request-helpers.js'
import { createJsonHandler, requireAdminPermission, requireAdminRoles } from './session.js'
const router = Router()
router.use('/semi-auto', requireAdminRoles(['admin', 'operator', 'support']))
router.get(
'/semi-auto/tasks',
createJsonHandler((req) => listAdminSemiAutoTasks(req.query as Record<string, unknown>), {
successMessage: 'ok',
errorMessage: '读取半自动履约任务失败',
scope: '[admin/semi-auto/tasks]',
}),
)
router.get(
'/semi-auto/tasks/:taskId',
createJsonHandler((req) => getAdminSemiAutoTask(req.params.taskId), {
successMessage: 'ok',
errorMessage: '读取半自动履约任务详情失败',
scope: '[admin/semi-auto/tasks/:taskId]',
}),
)
router.post(
'/semi-auto/tasks/:taskId/credential/export',
requireAdminPermission('semi_auto.credential.export'),
createJsonHandler(
(req, res) => {
res.setHeader('Cache-Control', 'no-store, max-age=0')
res.setHeader('Pragma', 'no-cache')
res.setHeader('Expires', '0')
return exportAdminSemiAutoCredential(req.params.taskId, sessionOf(req))
},
{
successMessage: '账号 CK 已导出',
errorMessage: '导出半自动履约账号 CK 失败',
scope: '[admin/semi-auto/tasks/:taskId/credential/export]',
audit: (req, data) => {
const result =
data && typeof data === 'object' ? (data as Record<string, unknown>) : undefined
return {
action: 'semi_auto_credential_exported',
targetType: 'semi_auto_task',
targetId: String(req.params.taskId || ''),
data: {
credentialId: Number(result?.credentialId || 0),
platform: String(result?.platform || ''),
},
}
},
},
),
)
router.post(
'/semi-auto/tasks/:taskId/claim',
createJsonHandler((req) => claimAdminSemiAutoTask(req.params.taskId, sessionOf(req)), {
successMessage: '任务已领取',
errorMessage: '领取半自动履约任务失败',
scope: '[admin/semi-auto/tasks/:taskId/claim]',
audit: (req) => ({
action: 'semi_auto_task_claimed',
targetType: 'semi_auto_task',
targetId: String(req.params.taskId || ''),
data: {},
}),
}),
)
router.post(
'/semi-auto/tasks/:taskId/sync',
createJsonHandler((req) => syncAdminSemiAutoTask(req.params.taskId), {
successMessage: '状态已刷新',
errorMessage: '刷新半自动履约状态失败',
scope: '[admin/semi-auto/tasks/:taskId/sync]',
}),
)
router.post(
'/semi-auto/tasks/:taskId/browser-session',
createJsonHandler(
(req) => createAdminBrowserSession(req.params.taskId, bodyOf(req).targetUrl, sessionOf(req)),
{
successMessage: '注入票据已生成',
errorMessage: '生成浏览器登录态失败',
scope: '[admin/semi-auto/tasks/:taskId/browser-session]',
audit: (req) => ({
action: 'semi_auto_browser_session_created',
targetType: 'semi_auto_task',
targetId: String(req.params.taskId || ''),
data: {},
}),
},
),
)
router.get(
'/semi-auto/injections/:ticket',
createJsonHandler((req) => consumeSemiAutoInjectionTicket(req.params.ticket), {
successMessage: 'ok',
errorMessage: '消费浏览器注入票据失败',
scope: '[admin/semi-auto/injections/:ticket]',
}),
)
router.post(
'/semi-auto/tasks/:taskId/complete',
createJsonHandler(
(req) => completeAdminSemiAutoTask(req.params.taskId, bodyOf(req), sessionOf(req)),
{
successMessage: '半自动履约已完成',
errorMessage: '完成半自动履约失败',
scope: '[admin/semi-auto/tasks/:taskId/complete]',
audit: (req) => ({
action: 'semi_auto_task_completed',
targetType: 'semi_auto_task',
targetId: String(req.params.taskId || ''),
data: {},
}),
},
),
)
router.post(
'/semi-auto/tasks/:taskId/cancel',
createJsonHandler(
(req) => cancelAdminSemiAutoTask(req.params.taskId, bodyOf(req), sessionOf(req)),
{
successMessage: '半自动履约任务已取消',
errorMessage: '取消半自动履约任务失败',
scope: '[admin/semi-auto/tasks/:taskId/cancel]',
audit: (req) => ({
action: 'semi_auto_task_cancelled',
targetType: 'semi_auto_task',
targetId: String(req.params.taskId || ''),
data: {},
}),
},
),
)
export default router
@@ -0,0 +1,163 @@
import { Router } from 'express'
import {
confirmPublicBinding,
getPublicWeiboCountryCodes,
listPublicSemiAutoTasks,
loginPublicWeiboSms,
markCustomerLinkOpened,
pollPublicBinding,
pollPublicLogin,
pollPublicWeiboQrLogin,
sendPublicWeiboSmsCode,
startPublicBinding,
startPublicLogin,
startPublicWeiboQrLogin,
} from '../services/semi-auto/semi-auto-task-service.js'
import { createRateLimitMiddleware, getParamRateLimitKey } from '../middleware/rate-limit.js'
import { createRouteHandler } from '../utils/http.js'
const router = Router()
const readLimit = createRateLimitMiddleware({
scope: 'customer-fulfillment:read',
windowMs: 60_000,
max: 240,
key: getParamRateLimitKey('orderNo'),
})
const writeLimit = createRateLimitMiddleware({
scope: 'customer-fulfillment:write',
windowMs: 60_000,
max: 60,
key: getParamRateLimitKey('orderNo'),
})
router.get(
'/:orderNo',
readLimit,
createRouteHandler((req) => listPublicSemiAutoTasks(req.params.orderNo), {
errorMessage: '读取订单履约信息失败',
scope: '[customer-fulfillment/:orderNo]',
}),
)
router.post(
'/:orderNo/tasks/:taskId/open',
writeLimit,
createRouteHandler((req) => markCustomerLinkOpened(req.params.orderNo, req.params.taskId), {
successMessage: 'ok',
errorMessage: '记录客户访问失败',
scope: '[customer-fulfillment/:orderNo/tasks/:taskId/open]',
}),
)
router.post(
'/:orderNo/tasks/:taskId/login/start',
writeLimit,
createRouteHandler((req) => startPublicLogin(req.params.orderNo, req.params.taskId), {
successMessage: '登录会话已创建',
errorMessage: '开始平台登录失败',
scope: '[customer-fulfillment/:orderNo/tasks/:taskId/login/start]',
}),
)
router.get(
'/:orderNo/tasks/:taskId/login/status',
readLimit,
createRouteHandler((req) => pollPublicLogin(req.params.orderNo, req.params.taskId), {
errorMessage: '刷新平台登录状态失败',
scope: '[customer-fulfillment/:orderNo/tasks/:taskId/login/status]',
}),
)
router.post(
'/:orderNo/tasks/:taskId/binding/start',
writeLimit,
createRouteHandler((req) => startPublicBinding(req.params.orderNo, req.params.taskId, req.body), {
successMessage: '绑定会话已创建',
errorMessage: '开始账号绑定失败',
scope: '[customer-fulfillment/:orderNo/tasks/:taskId/binding/start]',
}),
)
router.get(
'/:orderNo/tasks/:taskId/binding/status',
readLimit,
createRouteHandler((req) => pollPublicBinding(req.params.orderNo, req.params.taskId), {
errorMessage: '刷新账号绑定状态失败',
scope: '[customer-fulfillment/:orderNo/tasks/:taskId/binding/status]',
}),
)
router.post(
'/:orderNo/tasks/:taskId/binding/confirm',
writeLimit,
createRouteHandler((req) => confirmPublicBinding(req.params.orderNo, req.params.taskId), {
successMessage: '绑定已确认',
errorMessage: '确认账号绑定失败',
scope: '[customer-fulfillment/:orderNo/tasks/:taskId/binding/confirm]',
}),
)
router.get(
'/:orderNo/tasks/:taskId/weibo/config',
readLimit,
createRouteHandler((req) => getPublicWeiboCountryCodes(req.params.orderNo, req.params.taskId), {
errorMessage: '获取微博国家码列表失败',
scope: '[customer-fulfillment/:orderNo/tasks/:taskId/weibo/config]',
}),
)
router.post(
'/:orderNo/tasks/:taskId/weibo/sms/send',
writeLimit,
createRouteHandler(
(req) =>
sendPublicWeiboSmsCode(
req.params.orderNo,
req.params.taskId,
req.body?.mobile,
req.body?.countryCode,
req.body?.mfaId,
),
{
successMessage: '验证码请求已处理',
errorMessage: '发送微博短信验证码失败',
scope: '[customer-fulfillment/:orderNo/tasks/:taskId/weibo/sms/send]',
},
),
)
router.post(
'/:orderNo/tasks/:taskId/weibo/sms/login',
writeLimit,
createRouteHandler(
(req) => loginPublicWeiboSms(req.params.orderNo, req.params.taskId, req.body || {}),
{
successMessage: '微博登录成功',
errorMessage: '微博短信登录失败',
scope: '[customer-fulfillment/:orderNo/tasks/:taskId/weibo/sms/login]',
},
),
)
router.get(
'/:orderNo/tasks/:taskId/weibo/qrcode',
writeLimit,
createRouteHandler((req) => startPublicWeiboQrLogin(req.params.orderNo, req.params.taskId), {
successMessage: '登录二维码已生成',
errorMessage: '获取微博登录二维码失败',
scope: '[customer-fulfillment/:orderNo/tasks/:taskId/weibo/qrcode]',
}),
)
router.get(
'/:orderNo/tasks/:taskId/weibo/qrcode/status',
readLimit,
createRouteHandler((req) => pollPublicWeiboQrLogin(req.params.orderNo, req.params.taskId), {
errorMessage: '刷新微博扫码登录状态失败',
scope: '[customer-fulfillment/:orderNo/tasks/:taskId/weibo/qrcode/status]',
}),
)
export default router
@@ -0,0 +1,141 @@
import type { Request, Response } from 'express'
import { Router } from 'express'
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,
createMockBrowserTicket,
createMockLoginSession,
getMockBinding,
getMockLoginSession,
listMockSessions,
resetMockSessions,
revokeMockSession,
setMockBindingStatus,
setMockLoginStatus,
startMockBinding,
} from '../services/dev-mock/mock-semi-auto-connector.js'
const router = Router()
function bodyOf(req: Request): Record<string, unknown> {
return req.body && typeof req.body === 'object' && !Array.isArray(req.body)
? (req.body as Record<string, unknown>)
: {}
}
function sendDetail(res: Response, statusCode: number, detail: string) {
res.status(statusCode).json({ detail })
}
function handle(action: (req: Request) => unknown) {
return (req: Request, res: Response) => {
try {
res.json(action(req))
} catch (error) {
const statusCode =
error && typeof error === 'object' && 'statusCode' in error
? Number((error as { statusCode?: unknown }).statusCode) || 500
: 500
const detail =
error instanceof Error ? error.message : String(error || 'Mock Connector 请求失败')
sendDetail(res, statusCode, detail)
}
}
}
router.use((req, res, next) => {
if (!isDevMockEnabled()) {
sendDetail(res, 404, '开发 Mock Connector 未启用')
return
}
const key = String(req.header('x-semi-auto-key') || '').trim()
if (!key || key !== DEV_MOCK_CONNECTOR_API_KEY) {
sendDetail(res, 401, '半自动履约 Connector 鉴权失败')
return
}
next()
})
router.post(
'/api/internal/huya-semi-auto/sessions',
handle((req) => {
const body = bodyOf(req)
return createMockLoginSession({
taskId: body.task_id ?? body.taskId,
orderNo: body.order_no ?? body.orderNo,
})
}),
)
router.get(
'/api/internal/huya-semi-auto/sessions/:sessionId',
handle((req) => getMockLoginSession(req.params.sessionId)),
)
router.delete(
'/api/internal/huya-semi-auto/sessions/:sessionId',
handle((req) => revokeMockSession(req.params.sessionId)),
)
router.post(
'/api/internal/huya-semi-auto/sessions/:sessionId/binding',
handle((req) => startMockBinding(req.params.sessionId)),
)
router.get(
'/api/internal/huya-semi-auto/sessions/:sessionId/binding',
handle((req) => getMockBinding(req.params.sessionId)),
)
router.post(
'/api/internal/huya-semi-auto/sessions/:sessionId/binding/confirm',
handle((req) => confirmMockBinding(req.params.sessionId)),
)
router.post(
'/api/internal/huya-semi-auto/sessions/:sessionId/browser-ticket',
handle((req) => {
const body = bodyOf(req)
return createMockBrowserTicket({
sessionId: req.params.sessionId,
targetUrl: body.targetUrl,
})
}),
)
router.get(
'/api/internal/huya-semi-auto/browser-tickets/:ticket',
handle((req) => consumeMockBrowserTicket(req.params.ticket)),
)
// 测试辅助:不经过轮询直接推进状态,方便验证失败、过期和异常分支。
router.get(
'/__control/sessions',
handle(() => listMockSessions()),
)
router.post(
'/__control/sessions/:sessionId/login',
handle((req) => {
const body = bodyOf(req)
return setMockLoginStatus(req.params.sessionId, body.status)
}),
)
router.post(
'/__control/sessions/:sessionId/binding',
handle((req) => {
const body = bodyOf(req)
return setMockBindingStatus(req.params.sessionId, body.status)
}),
)
router.post(
'/__control/reset',
handle(() => resetMockSessions()),
)
export default router
@@ -0,0 +1,23 @@
import { Router } from 'express'
import { consumeSemiAutoInjectionTicket } from '../services/semi-auto/semi-auto-task-service.js'
import { createRateLimitMiddleware } from '../middleware/rate-limit.js'
import { createRouteHandler } from '../utils/http.js'
const router = Router()
router.get(
'/:ticket',
createRateLimitMiddleware({
scope: 'semi-auto-injection-consume',
windowMs: 60_000,
max: 30,
}),
createRouteHandler((req) => consumeSemiAutoInjectionTicket(req.params.ticket), {
successMessage: 'ok',
errorMessage: '消费浏览器注入票据失败',
scope: '[semi-auto-injections/:ticket]',
}),
)
export default router
@@ -35,6 +35,7 @@ export const ADMIN_PERMISSION_KEYS = [
'consignment.manage',
'consignment.review',
'consignment.fulfill',
'semi_auto.credential.export',
] as const
export type AdminPermissionKey = (typeof ADMIN_PERMISSION_KEYS)[number]
@@ -41,6 +41,8 @@ import { productSourceFilterSql } from './cost-center-mappers.js'
export const MANUAL_PLATFORM_KEYS = ['nanhang', 'lewan', 'rock', 'fund_cash', 'on_account'] as const
export type ManualPlatformKey = (typeof MANUAL_PLATFORM_KEYS)[number]
export const SEMI_AUTO_PLATFORM_KEYS = ['huya', 'weibo'] as const
export type SemiAutoPlatform = (typeof SEMI_AUTO_PLATFORM_KEYS)[number]
/**
* 积分换算率(分/积分),**按平台不同**:
@@ -109,6 +111,9 @@ type ManualSkuRow = {
config_canonical_product_id: number | null
config_enabled: boolean | null
config_channels_json: unknown
config_semi_auto_enabled: boolean | null
config_semi_auto_platform: string | null
config_semi_auto_config_json: unknown
}
type ManualChannelConfig = {
@@ -344,7 +349,10 @@ export async function listManualSkus(input: Record<string, unknown> = {}) {
cfg.cost_mode AS config_cost_mode,
COALESCE(cfg.canonical_product_id, p.id) AS config_canonical_product_id,
cfg.enabled AS config_enabled,
cfg.channels_json AS config_channels_json
cfg.channels_json AS config_channels_json,
cfg.semi_auto_enabled AS config_semi_auto_enabled,
cfg.semi_auto_platform AS config_semi_auto_platform,
cfg.semi_auto_config_json AS config_semi_auto_config_json
FROM product_catalog p
JOIN internal_skus product_sku ON product_sku.id = p.internal_sku_id
LEFT JOIN LATERAL (
@@ -458,6 +466,7 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
)
const enabled = payload.enabled === false ? false : true
const note = optionalText(payload.note, 300)
const semiAuto = normalizeSemiAutoConfig(payload)
// 消耗积分为 0 = 成本不固定,客服确认履约时手填。
const costMode = primaryChannel.costMode
@@ -546,7 +555,11 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
`UPDATE manual_fulfillment_configs
SET sku_key = $2, canonical_product_id = $3, platform_key = $4,
credit_quantity = $5, cost_mode = $6, enabled = $7, note = $8,
channels_json = $9::jsonb, updated_at = $10
channels_json = $9::jsonb,
semi_auto_enabled = $10,
semi_auto_platform = $11,
semi_auto_config_json = $12::jsonb,
updated_at = $13
WHERE id = $1
RETURNING *`,
[
@@ -559,14 +572,22 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
enabled,
note,
JSON.stringify(channels),
semiAuto.enabled,
semiAuto.platform,
JSON.stringify(semiAuto.config),
now,
],
)
: await client.query(
`INSERT INTO manual_fulfillment_configs (
sku_key, canonical_product_id, platform_key, credit_quantity,
cost_mode, enabled, note, channels_json, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $9)
cost_mode, enabled, note, channels_json,
semi_auto_enabled, semi_auto_platform, semi_auto_config_json,
created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8::jsonb,
$9, $10, $11::jsonb, $12, $12
)
RETURNING *`,
[
skuKey,
@@ -577,6 +598,9 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
enabled,
note,
JSON.stringify(channels),
semiAuto.enabled,
semiAuto.platform,
JSON.stringify(semiAuto.config),
now,
],
)
@@ -1046,6 +1070,9 @@ export async function getManualFulfillmentConfig(skuKey: string) {
enabled: boolean
note: string
channels_json: unknown
semi_auto_enabled: boolean
semi_auto_platform: string
semi_auto_config_json: unknown
created_at: string
updated_at: string
}>(
@@ -1174,6 +1201,9 @@ function mapManualSku(row: ManualSkuRow, unitPrices: Record<ManualPlatformKey, n
? Number(row.config_canonical_product_id)
: null,
enabled: row.config_enabled !== false,
semiAutoEnabled: row.config_semi_auto_enabled === true,
semiAutoPlatform: String(row.config_semi_auto_platform || ''),
semiAutoConfig: normalizeStoredSemiAutoConfig(row.config_semi_auto_config_json),
channels: mapConfiguredChannels(channels, unitPrices),
}
: null,
@@ -1191,6 +1221,9 @@ function mapManualConfig(
enabled: boolean
note: string
channels_json: unknown
semi_auto_enabled: boolean
semi_auto_platform: string
semi_auto_config_json: unknown
created_at: string
updated_at: string
},
@@ -1211,6 +1244,9 @@ function mapManualConfig(
costFen: calculateManualCostFen(creditQuantity, unitPriceFen),
creditUnitPriceFen: unitPriceFen,
enabled: row.enabled,
semiAutoEnabled: row.semi_auto_enabled === true,
semiAutoPlatform: String(row.semi_auto_platform || ''),
semiAutoConfig: normalizeStoredSemiAutoConfig(row.semi_auto_config_json),
channels: mapConfiguredChannels(channels, unitPrices),
note: row.note || '',
createdAt: row.created_at,
@@ -1218,6 +1254,73 @@ function mapManualConfig(
}
}
function normalizeSemiAutoConfig(payload: JsonObject): {
enabled: boolean
platform: SemiAutoPlatform | ''
config: Record<string, unknown>
} {
const enabled = payload.semiAutoEnabled === true || payload.semi_auto_enabled === true
const platform = String(payload.semiAutoPlatform ?? payload.semi_auto_platform ?? '')
.trim()
.toLowerCase()
if (!enabled) {
return { enabled: false, platform: '', config: {} }
}
if (!(SEMI_AUTO_PLATFORM_KEYS as readonly string[]).includes(platform)) {
throw createHttpError('半自动履约平台无效', {
statusCode: 400,
errorCode: 'manual_config_semi_auto_platform_invalid',
})
}
const rawConfig = payload.semiAutoConfig ?? payload.semi_auto_config
const config =
rawConfig && typeof rawConfig === 'object' && !Array.isArray(rawConfig)
? (rawConfig as Record<string, unknown>)
: {}
return {
enabled: true,
platform: platform as SemiAutoPlatform,
config: {
pageTitle: String(config.pageTitle || '')
.trim()
.slice(0, 60),
pageDescription: String(config.pageDescription || '')
.trim()
.slice(0, 500),
loginInstructions: String(config.loginInstructions || '')
.trim()
.slice(0, 1000),
bindingInstructions: String(config.bindingInstructions || '')
.trim()
.slice(0, 1000),
browserTargets: Array.isArray(config.browserTargets)
? config.browserTargets
.filter((item): item is Record<string, unknown> =>
Boolean(item && typeof item === 'object'),
)
.slice(0, 10)
.map((item) => ({
key: String(item.key || '')
.trim()
.slice(0, 40),
label: String(item.label || '')
.trim()
.slice(0, 60),
url: String(item.url || '')
.trim()
.slice(0, 1000),
}))
.filter((item) => item.key && item.url)
: [],
},
}
}
function normalizeStoredSemiAutoConfig(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
return value as Record<string, unknown>
}
function normalizePlatformKey(value: unknown): ManualPlatformKey {
const text = String(value || '').trim()
if (!MANUAL_PLATFORM_KEYS.includes(text as ManualPlatformKey)) {
@@ -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',
})
}
}
@@ -8,22 +8,37 @@ import { createOrder, findOrderByPlatformOrderId } from '../../repositories/orde
import { replaceOrderItems } from '../../repositories/order-item-repo.js'
import { createTask, updateTask } from '../../repositories/task-repo.js'
import { upsertKuaishouIndustryVoucher } from '../../repositories/kuaishou-industry-voucher-repo.js'
import { query, withTransaction } from '../../db/client.js'
import {
getFulfillmentProfileByKey,
upsertFulfillmentProfile,
} from '../../repositories/fulfillment-profile-repo.js'
import { buildOrderClaimUrl, createTaskClaimToken } from '../claim/claim-service.js'
import { assertValidClaimUid, normalizeClaimUid } from '../claim/claim-identity.js'
import { saveManualFulfillmentConfig } from '../cost-center/manual-fulfillment-config-service.js'
import {
assignFulfillmentChannelsWithExecutor,
attachFulfillmentConfigurationsWithExecutor,
} from '../fulfillment/fulfillment-state-service.js'
import { containsCjk } from '../fulfillment/internal-sku-resolver.js'
import { OPEN_91_PLATFORM, OPEN_91_PROVIDER, assertOpen91Config } from '../open-91/config.js'
import {
getKuaishouIndustrySourceConfig,
listKuaishouIndustryShopConfigs,
} from '../platforms/kuaishou-industry/source-config-service.js'
import { isProductionLike } from '../../config/runtime-validation.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'
@@ -51,27 +66,21 @@ export type DevMockKuaishouIndustryVoucherResult = {
}>
}
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 type DevMockSemiAutoResult = {
platform: 'semi_auto'
semiAutoPlatform: 'huya' | 'weibo'
orderId: number
orderNo: string
orderItemId: number
fulfillmentId: number
configId: number
taskId: number
skuKey: string
connectorMode: SemiAutoConnectorMode
connectorConfigured: boolean
customerPath: string
adminPath: string
tips: string[]
}
export function getDevMockStatus() {
@@ -87,9 +96,22 @@ export function getDevMockStatus() {
description: '本站 claim + mock 绑定二维码/提交发货(不调真实 affiliate-dash 平台)',
steps: ['uid', 'bind', 'submitted', 'completed', 'failed'],
},
{
key: 'semi_auto',
name: '半自动履约',
description:
'创建人工渠道半自动测试单;可选择仅 Mock 订单,登录、绑定和票据注入走真实 Connector',
steps: ['create_order', 'login', 'binding', 'browser_ticket', 'complete'],
},
]
: [],
defaultUid: '10001',
semiAuto: {
defaultConnectorMode: defaultSemiAutoConnectorMode(),
externalConnectorConfigured: isExternalSemiAutoConnectorConfigured(),
mockConnectorAvailable: enabled,
platform: 'huya',
},
}
}
@@ -284,9 +306,260 @@ export async function createAffiliateDashMockClaim(
}
/**
* 生成电子凭证列表测试数据,不调用快手接口。
* 覆盖未使用、已核销、已销毁和发码失败等运营页面常见状态。
* 生成半自动履约测试单:人工 SKU 配置、订单、履约和半自动任务都走真实领域服务。
* 平台登录、绑定和浏览器票据按 connectorMode 选择真实 Connector 或内置 Mock。
*/
export async function createSemiAutoMock(
input: {
platform?: unknown
orderNo?: unknown
skuName?: unknown
quantity?: unknown
pageTitle?: unknown
pageDescription?: unknown
browserTargetUrl?: unknown
connectorMode?: unknown
} = {},
): Promise<DevMockSemiAutoResult> {
assertDevMockEnabled()
const platform = normalizeMockSemiAutoPlatform(input.platform)
const orderNo = String(
input.orderNo || `${platform === 'weibo' ? 'MOCKWB' : 'MOCKSA'}${Date.now()}`,
).trim()
const skuKey = normalizeMockSemiAutoSku(input.skuName, platform)
const quantity = normalizeMockSemiAutoQuantity(input.quantity)
// 微博目前只支持内置 Connector,忽略其他模式,避免生成必然失败的测试单。
const connectorMode =
platform === 'weibo' ? 'embedded' : normalizeMockSemiAutoConnectorMode(input.connectorMode)
const pageTitle = String(
input.pageTitle || (platform === 'weibo' ? '微博发帖授权测试' : '虎牙账号授权测试'),
)
.trim()
.slice(0, 60)
const pageDescription = String(
input.pageDescription ||
(platform === 'weibo'
? '开发 Mock:完成微博登录即视为授权,客服在后台完成发帖履约。'
: '开发 Mock:完成登录和角色绑定后,客服在后台完成履约。'),
)
.trim()
.slice(0, 500)
const browserTargetUrl = String(
input.browserTargetUrl ||
(platform === 'weibo' ? 'https://weibo.com/' : 'https://www.huya.com/'),
)
.trim()
.slice(0, 1000)
await assertOrderNoAvailable(orderNo)
const config = await saveManualFulfillmentConfig({
skuKey,
enabled: true,
note: '开发 Mock 半自动履约',
channels: [{ platformKey: 'fund_cash', creditQuantity: 100, enabled: true }],
semiAutoEnabled: true,
semiAutoPlatform: platform,
semiAutoConfig: {
pageTitle,
pageDescription,
loginInstructions:
platform === 'weibo'
? '真实登录:使用微博绑定手机号接收验证码登录,或使用微博 App 扫码登录。登录仅用于本次订单履约,不会在页面展示账号 Cookie。'
: connectorMode === 'mock'
? 'Mock 登录:点击获取二维码后,页面轮询两次会自动模拟登录成功。'
: '真实登录:点击获取二维码后,请使用虎牙 App 扫码并在手机上确认登录。',
bindingInstructions:
platform === 'weibo'
? '微博登录成功即完成授权,无需再绑定游戏角色。'
: connectorMode === 'mock'
? 'Mock 绑定:页面轮询两次后会自动识别角色,点击确认即可。'
: '真实绑定:扫码后等待 Connector 识别角色,核对账号和区服后点击确认绑定。',
...(platform === 'weibo' ? { loginMethods: ['sms', 'qr'] } : {}),
browserTargets: [
{
key: platform,
label: platform === 'weibo' ? '微博' : '虎牙',
url: browserTargetUrl,
},
],
},
})
const configId = Number(config.id || 0)
const canonicalProductId = config.canonicalProductId ? Number(config.canonicalProductId) : null
if (!configId) {
throw createHttpError('半自动履约 Mock 配置创建失败', {
statusCode: 500,
errorCode: 'dev_mock_semi_auto_config_failed',
})
}
const now = nowIso()
const order = await createBaseOrder({
orderNo,
productNo: skuKey,
productName: skuKey,
now,
platformTag: 'semi-auto',
totalAmount: 100 * quantity,
})
const [orderItem] = await replaceOrderItems(order.id, [
{
skuCode: skuKey,
skuName: skuKey,
quantity,
specJson: JSON.stringify({
source: 'dev_mock',
skuNick: skuKey,
semiAuto: { platform, mock: true },
}),
itemSnapshotJson: JSON.stringify({
source: 'dev_mock',
skuKey,
platform,
mock: true,
}),
createdAt: now,
updatedAt: now,
},
])
const orderItemId = Number(orderItem?.id || 0)
if (!orderItemId) {
throw createHttpError('半自动履约 Mock 订单项创建失败', {
statusCode: 500,
errorCode: 'dev_mock_semi_auto_item_failed',
})
}
const fulfillmentResult = await query<{ id: number }>(
'SELECT id FROM fulfillments WHERE order_item_id = $1 LIMIT 1',
[orderItemId],
)
const fulfillmentId = Number(fulfillmentResult.rows[0]?.id || 0)
if (!fulfillmentId) {
throw createHttpError('半自动履约 Mock 履约单创建失败', {
statusCode: 500,
errorCode: 'dev_mock_semi_auto_fulfillment_failed',
})
}
await withTransaction(async (client) => {
await assignFulfillmentChannelsWithExecutor(client, {
assignments: [{ orderItemId, channel: 'manual', source: 'manual_confirm' }],
eventType: 'dev_mock_semi_auto_channel_assigned',
reason: 'dev_mock',
actor: { type: 'system' },
})
await attachFulfillmentConfigurationsWithExecutor(client, {
mappings: [
{
orderItemId,
configId,
...(canonicalProductId ? { canonicalProductId } : {}),
},
],
expected: { status: 'assigned', channel: 'manual' },
onlyWhenConfigNull: true,
})
})
const ensured = await ensureSemiAutoTasksForOrder(orderNo)
let taskId = Number(ensured.createdIds[0] || 0)
if (!taskId) {
const existing = await query<{ id: number }>(
'SELECT id FROM semi_auto_tasks WHERE order_item_id = $1 LIMIT 1',
[orderItemId],
)
taskId = Number(existing.rows[0]?.id || 0)
}
if (!taskId) {
throw createHttpError('半自动履约 Mock 任务创建失败', {
statusCode: 500,
errorCode: 'dev_mock_semi_auto_task_failed',
})
}
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',
semiAutoPlatform: platform,
orderId: Number(order.id),
orderNo,
orderItemId,
fulfillmentId,
configId,
taskId,
skuKey,
connectorMode,
connectorConfigured,
customerPath: `/c/${encodeURIComponent(orderNo)}`,
adminPath: `/admin/semi-auto-tasks?keyword=${encodeURIComponent(orderNo)}`,
tips:
platform === 'weibo'
? [
'完成履约仍走真实确认流程,会写入成本快照和财务确认。',
'微博使用内置 Connector:短信验证码会真实发送到客户手机号;扫码登录需在微博 App 中确认。',
'后台领取任务后生成 weibo-ticket-* 浏览器票据,扩展消费票据后写入微博 Cookie。',
'微博登录成功即视为授权完成,任务会直接进入等待客服处理。',
]
: [
'完成履约仍走真实确认流程,会写入成本快照和财务确认。',
connectorMode === 'mock'
? '当前使用内置 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 normalizeMockSemiAutoPlatform(value: unknown): 'huya' | 'weibo' {
return String(value || '')
.trim()
.toLowerCase() === 'weibo'
? 'weibo'
: 'huya'
}
function normalizeMockSemiAutoConnectorMode(value: unknown): SemiAutoConnectorMode {
const mode = String(value || '')
.trim()
.toLowerCase()
if (mode === 'external' || mode === 'mock') return mode
return 'embedded'
}
function normalizeMockSemiAutoSku(value: unknown, platform: 'huya' | 'weibo' = 'huya') {
const prefix = platform === 'weibo' ? '微博半自动' : '虎牙半自动'
const raw = String(value || '').trim()
const withCjk = raw
? containsCjk(raw)
? raw
: `${prefix}-${raw}`
: `${prefix}测试-${Date.now()}`
return withCjk.slice(0, 48)
}
function normalizeMockSemiAutoQuantity(value: unknown) {
const parsed = Math.trunc(Number(value || 1))
if (!Number.isFinite(parsed) || parsed < 1) return 1
return Math.min(10, parsed)
}
export async function createKuaishouIndustryVoucherMockData(
input: {
sellerId?: unknown
@@ -499,7 +772,9 @@ async function createBaseOrder(input: {
productName: string
now: string
platformTag: string
totalAmount?: number
}) {
const totalAmount = input.totalAmount ?? 100
const order = await createOrder({
provider: OPEN_91_PROVIDER,
platform: OPEN_91_PLATFORM,
@@ -511,7 +786,7 @@ async function createBaseOrder(input: {
buyerId: 'mock-buyer',
buyerName: 'mock 领取客户',
receiverContact: '',
totalAmount: 100,
totalAmount,
currency: 'CNY',
rawPayloadJson: JSON.stringify({
source: OPEN_91_PROVIDER,
@@ -523,7 +798,7 @@ async function createBaseOrder(input: {
productNo: input.productNo,
productName: input.productName,
buyNum: 1,
maxAmount: '1.00',
maxAmount: (totalAmount / 100).toFixed(2),
},
}),
paidAt: input.now,
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
confirmMockBinding,
consumeMockBrowserTicket,
createMockBrowserTicket,
createMockLoginSession,
getMockBinding,
getMockLoginSession,
resetMockSessions,
revokeMockSession,
setMockBindingStatus,
setMockLoginStatus,
startMockBinding,
} from './mock-semi-auto-connector.js'
test.beforeEach(() => resetMockSessions())
test('Mock Connector 可按轮询自动完成登录、绑定和一次性票据消费', () => {
const created = createMockLoginSession({ taskId: 101, orderNo: 'MOCK-SA-1' })
assert.equal(created.status, 'waiting_scan')
assert.equal(created.hasCredential, false)
assert.match(created.qrContent, /^mock-huya-login:\/\//)
const scanned = getMockLoginSession(created.sessionId)
assert.equal(scanned.status, 'scanned')
assert.equal(scanned.hasCredential, false)
const authenticated = getMockLoginSession(created.sessionId)
assert.equal(authenticated.status, 'authenticated')
assert.equal(authenticated.hasCredential, true)
assert.ok(authenticated.huyaUid)
const binding = startMockBinding(created.sessionId)
assert.equal(binding.status, 'qr_pending')
assert.ok(binding.qrImage.startsWith('data:image/svg+xml;base64,'))
assert.equal(getMockBinding(created.sessionId).status, 'scanned')
const roleReady = getMockBinding(created.sessionId)
assert.equal(roleReady.status, 'role_ready')
assert.ok(roleReady.bindGameRole)
const confirmed = confirmMockBinding(created.sessionId)
assert.equal(confirmed.status, 'confirmed')
const ticket = createMockBrowserTicket({
sessionId: created.sessionId,
targetUrl: 'https://www.huya.com/mock',
})
assert.equal(ticket.targetUrl, 'https://www.huya.com/mock')
const payload = consumeMockBrowserTicket(ticket.ticket)
assert.equal(payload.platform, 'huya')
assert.ok(payload.cookies.length >= 1)
assert.equal(payload.cookies[0]?.domain, '.huya.com')
assert.throws(() => consumeMockBrowserTicket(ticket.ticket), /已被消费/)
})
test('Mock Connector 支持控制状态、撤销会话和重置', () => {
const created = createMockLoginSession({ taskId: 202, orderNo: 'MOCK-SA-2' })
const authenticated = setMockLoginStatus(created.sessionId, 'authenticated')
assert.equal(authenticated.status, 'authenticated')
assert.equal(authenticated.hasCredential, true)
const confirmed = setMockBindingStatus(created.sessionId, 'confirmed')
assert.equal(confirmed.status, 'confirmed')
assert.ok(confirmed.bindGameRole)
revokeMockSession(created.sessionId)
assert.throws(() => getMockLoginSession(created.sessionId), /不存在或已失效/)
const second = createMockLoginSession({ taskId: 203, orderNo: 'MOCK-SA-3' })
const reset = resetMockSessions()
assert.equal(reset.cleared, 1)
assert.throws(() => getMockLoginSession(second.sessionId), /不存在或已失效/)
})
@@ -0,0 +1,405 @@
/**
* 开发环境内置的半自动履约 Connector。
*
* 只用于本地和测试环境:模拟虎牙登录、角色绑定、一次性浏览器注入票据,
* 不接触真实平台 Cookie。生产环境由 `isDevMockEnabled()` 拦截。
*/
import crypto from 'node:crypto'
import type {
ConnectorBindingSession,
ConnectorBrowserTicket,
ConnectorInjectionPayload,
ConnectorLoginSession,
} from '../semi-auto/huya-connector-client.js'
import { createHttpError } from '../../utils/http.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
type MockLoginStatus = ConnectorLoginSession['status']
type MockBindingStatus = ConnectorBindingSession['status']
type MockTicket = {
ticket: string
targetUrl: string
expiresAtMs: number
consumed: boolean
}
type MockSession = {
sessionId: string
taskId: number
orderNo: string
createdAtMs: number
loginStatus: MockLoginStatus
loginPolls: number
loginExpiresAtMs: number
credentialExpiresAtMs: number
qrContent: string
huyaUid: string
nickname: string
hasCredential: boolean
bindingStatus: MockBindingStatus
bindingPolls: number
bindingQrExpiresAtMs: number
bindGameAccount: string
bindGameRole: string
bindGameChannel: string
message: string
tickets: Map<string, MockTicket>
}
const sessions = new Map<string, MockSession>()
function buildMockUid(sessionId: string) {
return `mock-huya-${sessionId.slice(-8)}`
}
function buildMockBindingQr(sessionId: string) {
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="320" viewBox="0 0 320 320">
<rect width="320" height="320" fill="#ffffff"/>
<rect x="24" y="24" width="272" height="272" fill="none" stroke="#111827" stroke-width="6"/>
<text x="160" y="142" text-anchor="middle" font-family="Arial, sans-serif" font-size="28" fill="#111827">MOCK BIND</text>
<text x="160" y="182" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" fill="#4b5563">${escapeXml(sessionId.slice(-12))}</text>
<text x="160" y="218" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" fill="#6b7280">虎牙角色绑定测试二维码</text>
</svg>
`.trim()
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
}
function escapeXml(value: string) {
return value.replace(/[<>&'"]/g, (char) => {
const entities: Record<string, string> = {
'<': '&lt;',
'>': '&gt;',
'&': '&amp;',
"'": '&apos;',
'"': '&quot;',
}
return entities[char] || char
})
}
function requireSession(sessionIdInput: unknown): MockSession {
const sessionId = String(sessionIdInput || '').trim()
const session = sessions.get(sessionId)
if (!session) {
throw createHttpError('Mock Connector 会话不存在或已失效', {
statusCode: 404,
errorCode: 'mock_semi_auto_session_not_found',
})
}
expireSessionIfNeeded(session)
return session
}
function expireSessionIfNeeded(session: MockSession) {
const now = Date.now()
if (session.loginStatus === 'authenticated') {
if (session.credentialExpiresAtMs <= now) {
session.loginStatus = 'expired'
session.hasCredential = false
session.message = 'Mock 登录凭证已过期'
}
return
}
if (session.loginExpiresAtMs <= now && session.loginStatus !== 'expired') {
session.loginStatus = 'expired'
session.hasCredential = false
session.message = 'Mock 登录二维码已过期'
}
}
function loginView(session: MockSession): ConnectorLoginSession {
return {
sessionId: session.sessionId,
status: session.loginStatus,
qrContent: session.qrContent,
expiresAt: new Date(session.loginExpiresAtMs).toISOString(),
huyaUid: session.huyaUid,
nickname: session.nickname,
hasCredential: session.hasCredential,
message: session.message,
}
}
function bindingView(session: MockSession): ConnectorBindingSession {
return {
sessionId: session.sessionId,
status: session.bindingStatus,
qrImage: session.bindingStatus === 'not_started' ? '' : buildMockBindingQr(session.sessionId),
qrExpiresAt: new Date(session.bindingQrExpiresAtMs).toISOString(),
bindGameAccount: session.bindGameAccount,
bindGameRole: session.bindGameRole,
bindGameChannel: session.bindGameChannel,
message: session.message,
}
}
function markAuthenticated(session: MockSession) {
session.loginStatus = 'authenticated'
session.hasCredential = true
session.huyaUid = buildMockUid(session.sessionId)
session.nickname = 'Mock虎牙用户'
session.credentialExpiresAtMs = Date.now() + CREDENTIAL_TTL_MS
session.message = 'Mock 登录成功'
}
function markRoleReady(session: MockSession) {
session.bindingStatus = 'role_ready'
session.bindGameAccount = `mock-account-${session.sessionId.slice(-6)}`
session.bindGameRole = 'Mock测试角色'
session.bindGameChannel = '虎牙'
session.message = 'Mock 已识别游戏角色,请确认绑定'
}
export function createMockLoginSession(input: {
taskId?: unknown
orderNo?: unknown
}): ConnectorLoginSession {
const sessionId = crypto.randomUUID()
const now = Date.now()
const session: MockSession = {
sessionId,
taskId: Number(input.taskId || 0),
orderNo: String(input.orderNo || '').trim(),
createdAtMs: now,
loginStatus: 'waiting_scan',
loginPolls: 0,
loginExpiresAtMs: now + LOGIN_QR_TTL_MS,
credentialExpiresAtMs: 0,
qrContent: `mock-huya-login://${sessionId}`,
huyaUid: '',
nickname: '',
hasCredential: false,
bindingStatus: 'not_started',
bindingPolls: 0,
bindingQrExpiresAtMs: 0,
bindGameAccount: '',
bindGameRole: '',
bindGameChannel: '',
message: 'Mock 登录二维码已生成,轮询后会自动模拟扫码成功',
tickets: new Map(),
}
sessions.set(sessionId, session)
return loginView(session)
}
export function getMockLoginSession(sessionIdInput: unknown): ConnectorLoginSession {
const session = requireSession(sessionIdInput)
if (session.loginStatus === 'waiting_scan' || session.loginStatus === 'scanned') {
session.loginPolls += 1
if (session.loginPolls >= 2) {
markAuthenticated(session)
} else if (session.loginPolls >= 1) {
session.loginStatus = 'scanned'
session.message = 'Mock 已扫码,等待确认登录'
}
}
return loginView(session)
}
export function startMockBinding(sessionIdInput: unknown): ConnectorBindingSession {
const session = requireSession(sessionIdInput)
if (session.loginStatus !== 'authenticated') {
throw createHttpError('Mock 登录尚未完成,不能开始绑定', {
statusCode: 409,
errorCode: 'mock_semi_auto_login_required',
})
}
const now = Date.now()
session.bindingStatus = 'qr_pending'
session.bindingPolls = 0
session.bindingQrExpiresAtMs = now + BINDING_QR_TTL_MS
session.bindGameAccount = ''
session.bindGameRole = ''
session.bindGameChannel = ''
session.message = 'Mock 绑定二维码已生成,轮询后会自动识别角色'
return bindingView(session)
}
export function getMockBinding(sessionIdInput: unknown): ConnectorBindingSession {
const session = requireSession(sessionIdInput)
if (session.bindingStatus === 'qr_pending' || session.bindingStatus === 'scanned') {
if (session.bindingQrExpiresAtMs <= Date.now()) {
session.bindingStatus = 'expired'
session.message = 'Mock 绑定二维码已过期'
return bindingView(session)
}
session.bindingPolls += 1
if (session.bindingPolls >= 2) {
markRoleReady(session)
} else if (session.bindingPolls >= 1) {
session.bindingStatus = 'scanned'
session.message = 'Mock 已扫码,正在识别角色'
}
}
return bindingView(session)
}
export function confirmMockBinding(sessionIdInput: unknown): ConnectorBindingSession {
const session = requireSession(sessionIdInput)
if (session.bindingStatus !== 'role_ready') {
throw createHttpError('Mock 角色尚未就绪,不能确认绑定', {
statusCode: 409,
errorCode: 'mock_semi_auto_binding_not_ready',
})
}
session.bindingStatus = 'confirmed'
session.message = 'Mock 角色绑定已确认'
return bindingView(session)
}
export function createMockBrowserTicket(input: {
sessionId?: unknown
targetUrl?: unknown
}): ConnectorBrowserTicket {
const session = requireSession(input.sessionId)
if (session.bindingStatus !== 'confirmed') {
throw createHttpError('Mock 角色尚未绑定,不能生成浏览器票据', {
statusCode: 409,
errorCode: 'mock_semi_auto_binding_required',
})
}
const ticket = `mock-ticket-${crypto.randomUUID()}`
const targetUrl = String(input.targetUrl || 'https://www.huya.com/').trim()
const expiresAtMs = Date.now() + BROWSER_TICKET_TTL_MS
session.tickets.set(ticket, { ticket, targetUrl, expiresAtMs, consumed: false })
return {
ticket,
expiresAt: new Date(expiresAtMs).toISOString(),
targetUrl,
}
}
export function consumeMockBrowserTicket(ticketInput: unknown): ConnectorInjectionPayload {
const ticket = String(ticketInput || '').trim()
for (const session of sessions.values()) {
const item = session.tickets.get(ticket)
if (!item) continue
if (item.consumed) {
throw createHttpError('Mock 注入票据已被消费', {
statusCode: 410,
errorCode: 'mock_semi_auto_ticket_consumed',
})
}
if (item.expiresAtMs <= Date.now()) {
throw createHttpError('Mock 注入票据已过期', {
statusCode: 410,
errorCode: 'mock_semi_auto_ticket_expired',
})
}
item.consumed = true
return {
platform: 'huya',
targetUrl: item.targetUrl,
expiresAt: new Date(item.expiresAtMs).toISOString(),
cookies: [
{
name: 'mock_huya_uid',
value: session.huyaUid || buildMockUid(session.sessionId),
domain: '.huya.com',
path: '/',
secure: true,
},
{
name: 'mock_huya_session',
value: session.sessionId,
domain: '.huya.com',
path: '/',
secure: true,
},
],
}
}
throw createHttpError('Mock 注入票据不存在', {
statusCode: 404,
errorCode: 'mock_semi_auto_ticket_not_found',
})
}
export function revokeMockSession(sessionIdInput: unknown) {
const sessionId = String(sessionIdInput || '').trim()
sessions.delete(sessionId)
return { ok: true }
}
export function setMockLoginStatus(
sessionIdInput: unknown,
statusInput: unknown,
): ConnectorLoginSession {
const session = requireSession(sessionIdInput)
const status = String(statusInput || '').trim() as MockLoginStatus
if (!['waiting_scan', 'scanned', 'authenticated', 'expired', 'failed'].includes(status)) {
throw createHttpError('Mock 登录状态无效', {
statusCode: 400,
errorCode: 'mock_semi_auto_login_status_invalid',
})
}
session.loginStatus = status
if (status === 'authenticated') {
markAuthenticated(session)
} else {
session.hasCredential = false
session.message = `Mock 登录状态已切换为 ${status}`
}
return loginView(session)
}
export function setMockBindingStatus(
sessionIdInput: unknown,
statusInput: unknown,
): ConnectorBindingSession {
const session = requireSession(sessionIdInput)
const status = String(statusInput || '').trim() as MockBindingStatus
if (
![
'not_started',
'qr_pending',
'scanned',
'role_ready',
'confirmed',
'expired',
'failed',
].includes(status)
) {
throw createHttpError('Mock 绑定状态无效', {
statusCode: 400,
errorCode: 'mock_semi_auto_binding_status_invalid',
})
}
session.bindingStatus = status
if (status === 'role_ready' || status === 'confirmed') {
if (!session.bindGameRole) markRoleReady(session)
session.bindingStatus = status
} else {
session.bindGameAccount = ''
session.bindGameRole = ''
session.bindGameChannel = ''
}
session.message = `Mock 绑定状态已切换为 ${status}`
return bindingView(session)
}
export function listMockSessions() {
return [...sessions.values()].map((session) => ({
sessionId: session.sessionId,
taskId: session.taskId,
orderNo: session.orderNo,
loginStatus: session.loginStatus,
bindingStatus: session.bindingStatus,
hasCredential: session.hasCredential,
ticketCount: session.tickets.size,
createdAt: new Date(session.createdAtMs).toISOString(),
}))
}
export function resetMockSessions() {
const count = sessions.size
sessions.clear()
return { cleared: count }
}
@@ -66,10 +66,19 @@ test('人工确认必须单事务:扣款与快照同事务,且使用 with-cl
const end = source.indexOf('async function resolveFundAccountId')
const body = source.slice(start, end)
assert.ok(body.includes('withTransaction('), '人工确认必须包在事务里')
const executeStart = body.indexOf('const execute = async (client: PoolClient)')
const executeEnd = body.indexOf('const result = options.client', executeStart)
const transactionalBody = body.slice(executeStart, executeEnd)
assert.ok(executeStart >= 0 && executeEnd > executeStart, '人工确认必须抽出事务执行体')
assert.ok(
body.indexOf('withTransaction(') < body.indexOf('writeFundExpense(client'),
'备用金扣款必须在同一事务内执行',
body.includes(
'options.client ? await execute(options.client) : await withTransaction(execute)',
),
'默认路径必须包在事务里,外部事务必须复用调用方 client',
)
assert.ok(
transactionalBody.includes('writeFundExpense(client'),
'备用金扣款必须在调用方事务内执行',
)
assert.ok(
body.includes('consumePlatformCreditWithClient(client'),
@@ -65,10 +65,13 @@ type ManualConfiguredChannel = {
enabled: boolean
}
async function loadManualPlatformUnitPrices(): Promise<Record<ManualExecutionPlatformKey, number>> {
const result = await query<{ profile_key: string; config_json: Record<string, unknown> }>(
`SELECT profile_key, config_json FROM fulfillment_profiles WHERE profile_key LIKE 'manual_%'`,
)
async function loadManualPlatformUnitPrices(
client?: Pick<PoolClient, 'query'>,
): Promise<Record<ManualExecutionPlatformKey, number>> {
const sql = `SELECT profile_key, config_json FROM fulfillment_profiles WHERE profile_key LIKE 'manual_%'`
const result = client
? await client.query<{ profile_key: string; config_json: Record<string, unknown> }>(sql)
: await query<{ profile_key: string; config_json: Record<string, unknown> }>(sql)
const prices: Record<ManualExecutionPlatformKey, number> = {
nanhang: 1,
lewan: 1,
@@ -299,17 +302,26 @@ export async function listManualFulfillmentPending(
* 固定成本(消耗积分 > 0):直接用配置值,零输入。
* 成本不固定(消耗积分 = 0):客服必须填实际成本金额。
*/
export async function confirmManualFulfillment(payload: JsonObject, session: AdminSession) {
export type ConfirmManualFulfillmentOptions = {
/** 由调用方提供事务连接;此时调用方负责提交/回滚和提交后的报表刷新。 */
client?: PoolClient
}
export async function confirmManualFulfillment(
payload: JsonObject,
session: AdminSession,
options: ConfirmManualFulfillmentOptions = {},
) {
const orderItemId = normalizeRequiredId(
payload.orderItemId ?? payload.order_item_id,
'请选择订单项',
)
const now = nowIso()
const platformUnitPrices = await loadManualPlatformUnitPrices()
// 钱与成本必须同一事务:先锁订单项、复查未履约,再扣款、最后写快照。
// 任何一步失败(余额不足、成本版本缺失、重复快照)都整体回滚,不会留下「扣了钱没记成本」。
const result = await withTransaction(async (client) => {
const execute = async (client: PoolClient) => {
const now = nowIso()
const platformUnitPrices = await loadManualPlatformUnitPrices(client)
// 配置可能晚于订单创建:确认前按 SKU 再匹配一次(与定时 rematch 同口径),
// 保证「配置好之后的新单」不需要人工再点一次「重新匹配」。
await matchManualFulfillmentConfigWithClient(client, orderItemId)
@@ -716,14 +728,26 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
onAccountBillId,
costPending: onAccountCostPending,
}
})
}
await scheduleVoucherConsumeForOrderItems([orderItemId], 'order_received').catch(() => undefined)
// 成本已落快照,立刻重算该业务日的事实与总账;失败不阻断确认(定时任务会兜底)。
await refreshFactsForOrderItem(orderItemId)
const result = options.client ? await execute(options.client) : await withTransaction(execute)
if (!options.client) {
await runManualFulfillmentPostCommit(orderItemId)
}
return result
}
/**
* 成本事务提交后的非关键旁路动作。
*
* 调用方若把 confirmManualFulfillment 纳入更大的事务,必须在提交后显式调用本函数。
* 这里的失败只影响报表时效,不反写已经提交的成本事实。
*/
export async function runManualFulfillmentPostCommit(orderItemId: number): Promise<void> {
await scheduleVoucherConsumeForOrderItems([orderItemId], 'order_received').catch(() => undefined)
await refreshFactsForOrderItem(orderItemId)
}
/**
* 人工确认后按「订单业务日期」重算销售事实与总账。
*
@@ -191,8 +191,13 @@ export async function ensureCarrierForOrderItem(input: {
}
if (channel === 'manual') {
const config = await query<{ id: number; canonical_product_id: number | null }>(
`SELECT id, canonical_product_id
const config = await query<{
id: number
canonical_product_id: number | null
semi_auto_enabled: boolean
semi_auto_platform: string
}>(
`SELECT id, canonical_product_id, semi_auto_enabled, semi_auto_platform
FROM manual_fulfillment_configs
WHERE canonicalize_internal_sku(sku_key) = resolve_internal_sku($1, $2, $3)
AND enabled = TRUE
@@ -213,6 +218,14 @@ export async function ensureCarrierForOrderItem(input: {
],
onlyWhenCanonicalProductNull: true,
})
if (
row.semi_auto_enabled === true &&
['huya', 'weibo'].includes(String(row.semi_auto_platform || ''))
) {
const { ensureSemiAutoTasksForOrder } =
await import('../semi-auto/semi-auto-task-service.js')
await ensureSemiAutoTasksForOrder(order.platform_order_id)
}
return { ready: true, created: false, reason: 'manual_config_ready', message: '' }
}
return {
@@ -0,0 +1,73 @@
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
}
})
test('重新扫码换绑会向 Connector 传递 force_rebind', async () => {
const originalFetch = globalThis.fetch
let requestBody: Record<string, unknown> = {}
globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => {
requestBody = JSON.parse(String(init?.body || '{}')) as Record<string, unknown>
return new Response(
JSON.stringify({
sessionId: 'session-1',
status: 'qr_pending',
qrImage: 'data:image/png;base64,AA==',
qrExpiresAt: '2026-11-09T00:00:00Z',
bindGameAccount: '',
bindGameRole: '',
bindGameChannel: '',
message: '请扫码换绑',
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)
}) as typeof fetch
try {
await connector.startHuyaBinding('session-1', 'external', { forceRebind: true })
assert.deepEqual(requestBody, { force_rebind: true })
} finally {
globalThis.fetch = originalFetch
}
})
@@ -0,0 +1,310 @@
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
hasCredential: boolean
message: string
}
export type ConnectorBindingSession = {
sessionId: string
status:
| 'not_started'
| 'qr_pending'
| 'scanned'
| 'role_ready'
| 'confirmed'
| 'expired'
| 'failed'
qrImage: string
qrExpiresAt: string
bindGameAccount: string
bindGameRole: string
bindGameChannel: string
hasExistingBinding?: boolean
rebindRequested?: boolean
message: string
}
export type ConnectorBrowserTicket = {
ticket: string
expiresAt: string
targetUrl: string
}
export type ConnectorInjectionPayload = {
platform: 'huya' | 'weibo'
targetUrl: string
expiresAt: string
cookies: Array<{
name: string
value: string
domain: string
path: string
secure: boolean
}>
}
type ConnectorRequestOptions = {
method?: 'GET' | 'POST' | 'DELETE'
body?: unknown
}
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 externalConnectorApiKey() {
return String(runtimeConfig.semiAuto?.connectorApiKey || '').trim()
}
export function isExternalSemiAutoConnectorConfigured() {
return Boolean(externalConnectorBaseUrl() && externalConnectorApiKey())
}
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()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
const requestInit: RequestInit = {
method: options.method || 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-Semi-Auto-Key': endpoint.apiKey,
},
signal: controller.signal,
}
if (options.body !== undefined) requestInit.body = JSON.stringify(options.body)
const response = await fetch(`${endpoint.baseUrl}${path}`, requestInit)
const text = await response.text()
let payload: unknown = null
try {
payload = text ? JSON.parse(text) : null
} catch {
payload = null
}
if (!response.ok) {
const detail =
payload && typeof payload === 'object'
? String((payload as Record<string, unknown>).detail || '')
: ''
throw createHttpError(detail || `半自动履约 Connector 请求失败(${response.status})`, {
statusCode: 502,
errorCode: 'semi_auto_connector_request_failed',
})
}
return payload as T
} catch (error) {
if (error && typeof error === 'object' && 'statusCode' in error) throw error
if (error instanceof Error && error.name === 'AbortError') {
throw createHttpError('半自动履约 Connector 请求超时', {
statusCode: 504,
errorCode: 'semi_auto_connector_timeout',
})
}
throw createHttpError(
error instanceof Error ? error.message : '半自动履约 Connector 请求失败',
{
statusCode: 502,
errorCode: 'semi_auto_connector_request_failed',
},
)
} finally {
clearTimeout(timer)
}
}
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',
{
method: 'POST',
body: {
task_id: input.taskId,
order_no: input.orderNo,
},
},
mode,
)
}
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(),
options: { forceRebind?: boolean } = {},
): Promise<ConnectorBindingSession> {
if (mode === 'embedded') {
return huyaNativeConnector.startBinding(
sessionId,
options.forceRebind ? { forceRebind: true } : {},
)
}
return callConnector<ConnectorBindingSession>(
`/api/internal/huya-semi-auto/sessions/${encodeURIComponent(sessionId)}/binding`,
{ method: 'POST', body: { force_rebind: Boolean(options.forceRebind) } },
mode,
)
}
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,
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
},
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 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,
mode: SemiAutoConnectorMode = defaultSemiAutoConnectorMode(),
): Promise<void> {
if (mode === 'embedded') {
await huyaNativeConnector.revokeSession(sessionId)
return
}
await callConnector<{ ok: boolean }>(
`/api/internal/huya-semi-auto/sessions/${encodeURIComponent(sessionId)}`,
{ method: 'DELETE' },
mode,
)
}
@@ -0,0 +1,560 @@
// Android WebView 环境仿真 (默认 M2102J2SC; 可传设备覆盖参数实现一号一设备)
// overrides = {model, androidVer, vendor, appVer, screenWidth, screenHeight}
function makeEnv(overrides) {
const ov = overrides || {}
const MODEL = ov.model || 'M2102J2SC'
const ANDROID_VER = ov.androidVer || '11'
const VENDOR = ov.vendor || 'xiaomi'
const APP_VER = ov.appVer || '13.4.22'
const UA =
ov.userAgent ||
'Mozilla/5.0 (Linux; Android ' +
ANDROID_VER +
'; ' +
MODEL +
' Build/RKQ1.200826.002; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 ' +
'Chrome/149.0.7827.159 Mobile Safari/537.36 huya adr/' +
APP_VER +
'/' +
VENDOR +
'/30'
// 视口按物理分辨率/3 仿真 (393x851 ≈ 1080x2120 / 2.75)
const VW = ov.screenWidth ? Math.round(ov.screenWidth / 2.75) : 393
const VH = ov.screenHeight ? Math.round(ov.screenHeight / 2.75) : 851
const W = globalThis
W.self = W
// Node 18/20 do not expose a browser navigator; create one before defining
// the properties consumed by hydevice. Newer Node versions already expose
// a Navigator instance, which is retained.
W.navigator = W.navigator || {}
W.screen = {
width: VW,
height: VH,
availWidth: VW,
availHeight: VH,
colorDepth: 24,
pixelDepth: 24,
availLeft: 0,
availTop: 0,
orientation: { type: 'portrait-primary', angle: 0 },
}
W.devicePixelRatio = 2.75
W.innerWidth = VW
W.innerHeight = Math.max(VH - 114, 500)
W.outerWidth = VW
W.outerHeight = VH
W.location = {
href: 'https://aq.huya.com/p/safe_auth/pt_auth.html',
protocol: 'https:',
host: 'aq.huya.com',
hostname: 'aq.huya.com',
port: '',
pathname: '/p/safe_auth/pt_auth.html',
search: '',
hash: '',
origin: 'https://aq.huya.com',
toString() {
return this.href
},
}
W.history = { length: 2, state: null, pushState() {}, replaceState() {}, back() {}, go() {} }
const navProps = {
userAgent: UA,
appName: 'Netscape',
appVersion: UA.replace(/^Mozilla\//, ''),
platform: 'Linux armv8l',
language: 'zh-CN',
languages: ['zh-CN', 'zh', 'en'],
cookieEnabled: true,
doNotTrack: null,
maxTouchPoints: 5,
hardwareConcurrency: 8,
deviceMemory: 8,
vendor: 'Google Inc.',
productSub: '20030107',
webdriver: false,
pdfViewerEnabled: false,
javaEnabled() {
return false
},
getBattery() {
return Promise.resolve({ charging: true, level: 0.9 })
},
userAgentData: {
brands: [
{ brand: 'Chromium', version: '149' },
{ brand: 'Google Chrome', version: '149' },
{ brand: 'Not?A_Brand', version: '24' },
],
mobile: true,
platform: 'Android',
getHighEntropyValues() {
return Promise.resolve({
architecture: 'arm',
bitness: '64',
model: MODEL,
platformVersion: ANDROID_VER + '.0.0',
uaFullVersion: '149.0.7827.159',
})
},
},
connection: { effectiveType: '4g', downlink: 10, rtt: 50, saveData: false },
mediaDevices: {
enumerateDevices() {
return Promise.resolve([])
},
getUserMedia() {
return Promise.reject(new Error('not supported'))
},
},
plugins: {
length: 0,
item() {
return null
},
namedItem() {
return null
},
},
mimeTypes: { length: 0 },
}
for (const [k, v] of Object.entries(navProps)) {
try {
Object.defineProperty(W.navigator, k, { value: v, configurable: true })
} catch (e) {
try {
W.navigator[k] = v
} catch (_) {}
}
}
// canvas 2d/webgl 最小仿真
const glConsts = {
RENDERER: 0x1f01,
VENDOR: 0x1f00,
VERSION: 0x1f02,
SHADING_LANGUAGE_VERSION: 0x8b8c,
MAX_TEXTURE_SIZE: 0x0d33,
MAX_RENDERBUFFER_SIZE: 0x84e8,
MAX_VIEWPORT_DIMS: 0x0d3a,
}
function fakeGL() {
return new Proxy(
{
getExtension() {
return { UNMASKED_VENDOR_WEBGL: 0x9245, UNMASKED_RENDERER_WEBGL: 0x9246 }
},
getParameter(p) {
if (p === 0x9245) return 'Qualcomm'
if (p === 0x9246) return 'Adreno (TM) 650'
if (p === glConsts.MAX_TEXTURE_SIZE) return 16384
if (p === glConsts.MAX_VIEWPORT_DIMS) return new Int32Array([16384, 16384])
return 'WebKit WebGL'
},
getContextAttributes() {
return { alpha: true, antialias: true, depth: true }
},
createBuffer() {
return {}
},
bindBuffer() {},
bufferData() {},
createProgram() {
return {}
},
attachShader() {},
linkProgram() {},
useProgram() {},
createShader() {
return {}
},
shaderSource() {},
compileShader() {},
getShaderParameter() {
return true
},
getProgramParameter() {
return true
},
getSupportedExtensions() {
return ['WEBGL_debug_renderer_info']
},
readPixels(x, y, w, h, t, f, buf) {
if (buf) buf.fill(128)
},
},
{
get(t, p) {
if (p in t) return t[p]
if (typeof p === 'string' && p.startsWith('on')) return null
if (typeof p === 'string') return () => null
return undefined
},
},
)
}
const ctx2d = new Proxy(
{
fillRect() {},
clearRect() {},
getImageData(x, y, w, h) {
return { data: new Uint8ClampedArray(w * h * 4).fill(128), width: w, height: h }
},
putImageData() {},
createImageData(w, h) {
return { data: new Uint8ClampedArray(w * h * 4), width: w, height: h }
},
setTransform() {},
transform() {},
translate() {},
scale() {},
rotate() {},
save() {},
restore() {},
beginPath() {},
closePath() {},
moveTo() {},
lineTo() {},
stroke() {},
fill() {},
arc() {},
rect() {},
clip() {},
createLinearGradient() {
return { addColorStop() {} }
},
createRadialGradient() {
return { addColorStop() {} }
},
fillText() {},
strokeText() {},
measureText(t) {
return { width: String(t).length * 7 }
},
isPointInPath() {
return false
},
drawImage() {},
arcTo() {},
bezierCurveTo() {},
quadraticCurveTo() {},
createPattern() {
return {}
},
setLineDash() {},
getLineDash() {
return []
},
},
{
get(t, p) {
if (p in t) return t[p]
if (p === 'canvas') return {}
return undefined
},
},
)
const canvasProto = {
getContext(type) {
if (type === '2d') return ctx2d
if (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl') return fakeGL()
return null
},
toDataURL() {
return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='
},
toBlob(cb) {
cb && cb(null)
},
addEventListener() {},
removeEventListener() {},
setAttribute() {},
getBoundingClientRect() {
return { left: 0, top: 0, width: 300, height: 150 }
},
style: {},
width: 300,
height: 150,
}
// hydevice writes browser device state through document.cookie. Keep a small
// in-memory jar so Node has the same read/append semantics as a WebView.
const cookieJar = {}
const document = {
createElement(tag) {
const el = Object.assign({}, canvasProto, {
tagName: String(tag || 'div').toUpperCase(),
style: {},
children: [],
setAttribute() {},
getAttribute() {
return null
},
appendChild(c) {
this.children.push(c)
},
removeChild() {},
addEventListener() {},
removeEventListener() {},
classList: {
add() {},
remove() {},
contains() {
return false
},
},
attachShadow() {
return { appendChild() {} }
},
getBoundingClientRect() {
return { left: 0, top: 0, right: 100, bottom: 30, width: 100, height: 30 }
},
})
return el
},
createEvent() {
return { initEvent() {} }
},
createElementNS(ns, tag) {
return this.createElement(tag)
},
createTextNode(t) {
return { textContent: t }
},
createDocumentFragment() {
return { appendChild() {} }
},
getElementById() {
return null
},
querySelector() {
return null
},
querySelectorAll() {
return []
},
getElementsByTagName(t) {
if (String(t).toLowerCase() === 'head') return [this.head]
return []
},
documentElement: {
setAttribute() {},
getAttribute() {
return null
},
style: {},
removeChild() {},
},
head: { appendChild() {}, removeChild() {} },
body: {
appendChild() {},
removeChild() {},
insertBefore() {},
contains() {
return false
},
},
referrer: '',
title: 'pt_auth',
domain: 'aq.huya.com',
readyState: 'complete',
visibilityState: 'visible',
addEventListener() {},
removeEventListener() {},
}
document.location = W.location
Object.defineProperty(document, 'cookie', {
configurable: true,
get() {
return Object.entries(cookieJar)
.map(([k, v]) => `${k}=${v}`)
.join('; ')
},
set(value) {
const first = String(value || '').split(';', 1)[0]
const index = first.indexOf('=')
if (index > 0) cookieJar[first.slice(0, index).trim()] = first.slice(index + 1).trim()
},
})
W.__huyaCookieJar = cookieJar
W.document = document
W.localStorage = (() => {
const m = {}
return {
getItem: (k) => m[k] ?? null,
setItem: (k, v) => (m[k] = String(v)),
removeItem: (k) => delete m[k],
clear: () => {},
key: (i) => Object.keys(m)[i] ?? null,
get length() {
return Object.keys(m).length
},
}
})()
W.sessionStorage = (() => {
const m = {}
return {
getItem: (k) => m[k] ?? null,
setItem: (k, v) => (m[k] = String(v)),
removeItem: (k) => delete m[k],
clear: () => {},
key: (i) => null,
get length() {
return 0
},
}
})()
W.indexedDB = {
open() {
return {
onsuccess: null,
onerror: null,
onupgradeneeded: null,
result: {
objectStoreNames: {
contains() {
return false
},
},
createObjectStore() {
return { createIndex() {} }
},
},
set onsuccess(f) {
setTimeout(() => {}, 0)
},
}
},
}
W.performance = W.performance || {
now: () => Date.now(),
timeOrigin: Date.now(),
timing: { navigationStart: Date.now() },
getEntriesByType() {
return []
},
mark() {},
measure() {},
}
W.AudioContext = class {
constructor() {
this.sampleRate = 48000
this.destination = {}
}
createOscillator() {
return {
type: '',
frequency: { value: 0, setValueAtTime() {} },
connect() {},
start() {},
stop() {},
}
}
createDynamicsCompressor() {
return {
threshold: { value: 0 },
knee: { value: 0 },
ratio: { value: 0 },
attack: { value: 0 },
release: { value: 0 },
connect() {},
}
}
createAnalyser() {
return {
frequencyBinCount: 1024,
getFloatFrequencyData(a) {
a && a.fill(-100)
},
}
}
createGain() {
return { gain: { value: 1, setValueAtTime() {} }, connect() {} }
}
createScriptProcessor() {
return { connect() {}, onaudioprocess: null }
}
decodeAudioData() {
return Promise.resolve({
getChannelData() {
return new Float32Array(5000)
},
})
}
resume() {
return Promise.resolve()
}
close() {
return Promise.resolve()
}
}
W.webkitAudioContext = W.AudioContext
W.OfflineAudioContext = W.AudioContext
W.WebGLRenderingContext = {
RENDERER: glConsts.RENDERER,
VENDOR: glConsts.VENDOR,
VERSION: glConsts.VERSION,
SHADING_LANGUAGE_VERSION: glConsts.SHADING_LANGUAGE_VERSION,
MAX_TEXTURE_SIZE: glConsts.MAX_TEXTURE_SIZE,
}
W.RTCPeerConnection = class {
createDataChannel() {
return {}
}
createOffer() {
return Promise.resolve({})
}
setLocalDescription() {
return Promise.resolve()
}
close() {}
}
W.webkitRTCPeerConnection = W.RTCPeerConnection
W.Notification = { permission: 'default' }
W.permissions = {
query() {
return Promise.resolve({ state: 'prompt' })
},
}
W.matchMedia = () => ({
matches: false,
media: '',
addListener() {},
removeListener() {},
addEventListener() {},
removeEventListener() {},
})
W.getComputedStyle = () =>
new Proxy(
{ getPropertyValue: () => '' },
{
get(t, p) {
if (p in t) return t[p]
return () => ''
},
},
)
W.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 16)
W.cancelAnimationFrame = clearTimeout
W.requestIdleCallback = (cb) =>
setTimeout(() => cb({ didTimeout: false, timeRemaining: () => 16 }), 1)
W.cancelIdleCallback = clearTimeout
W.chrome = W.chrome || {
runtime: {},
app: { isInstalled: false },
csi() {
return {}
},
loadTimes() {
return {}
},
}
W.HTMLCanvasElement = W.HTMLCanvasElement || function HTMLCanvasElement() {}
W.HTMLElement = W.HTMLElement || function HTMLElement() {}
W.Element = W.Element || function Element() {}
W.addEventListener = () => {}
W.removeEventListener = () => {}
W.dispatchEvent = () => true
W.crypto = require('crypto').webcrypto
W.TextEncoder = require('util').TextEncoder
W.TextDecoder = require('util').TextDecoder
}
module.exports = { makeEnv }
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
{
"type": "commonjs"
}
@@ -0,0 +1,169 @@
// hydevice 指纹运行器: 仿真 Android WebView 环境 -> 真实请求 df/token + df/collect -> 输出 sdid
// argv: [stateDir, deviceJson] — deviceJson 为账号画像派生的设备覆盖参数 (一号一设备)
process.on('uncaughtException', (e) => {
console.error('[uncaught]', String(e && e.message).slice(0, 120))
})
const fs = require('fs')
const path = require('path')
const https = require('https')
globalThis.window = globalThis
const stateDir = process.argv[2] || '.'
let _deviceOverrides = null
// Prefer an explicitly supplied JSON path; otherwise use the device hint that
// get_huya_sdid writes into the per-account state directory.
try {
const hintPath =
process.argv[3] && process.argv[3].endsWith('.json')
? process.argv[3]
: path.join(stateDir, 'device.json')
_deviceOverrides = JSON.parse(fs.readFileSync(hintPath, 'utf8'))
} catch (e) {}
require(path.join(__dirname, 'env.js')).makeEnv(_deviceOverrides)
// ---- localStorage 持久化(设备稳定性) ----
try {
fs.mkdirSync(stateDir, { recursive: true })
} catch (e) {}
const lsFile = path.join(stateDir, 'localstorage.json')
let _ls = {}
try {
_ls = JSON.parse(fs.readFileSync(lsFile, 'utf8'))
} catch (e) {}
globalThis.localStorage = {
getItem: (k) => (k in _ls ? _ls[k] : null),
setItem: (k, v) => {
_ls[k] = String(v)
try {
fs.writeFileSync(lsFile, JSON.stringify(_ls))
} catch (e) {}
},
removeItem: (k) => {
delete _ls[k]
try {
fs.writeFileSync(lsFile, JSON.stringify(_ls))
} catch (e) {}
},
clear: () => {
_ls = {}
try {
fs.writeFileSync(lsFile, JSON.stringify(_ls))
} catch (e) {}
},
key: (i) => Object.keys(_ls)[i] || null,
get length() {
return Object.keys(_ls).length
},
}
globalThis.sessionStorage = {
getItem: () => null,
setItem() {},
removeItem() {},
clear() {},
key: () => null,
get length() {
return 0
},
}
// ---- 真实 XHR ----
function xhr(url, body, headers) {
return new Promise((resolve) => {
const u = new URL(url)
const data = body == null ? null : Buffer.from(String(body), 'utf8')
const req = https.request(
{
hostname: u.hostname,
port: 443,
path: u.pathname + u.search,
method: 'POST',
headers: Object.assign(
{
accept: 'application/json, text/javascript, */*; q=0.01',
'content-type': 'application/json',
origin: 'https://aq.huya.com',
referer: 'https://aq.huya.com/',
'user-agent': globalThis.navigator.userAgent,
'accept-language': 'zh-CN,zh;q=0.9',
},
headers || {},
),
timeout: 15000,
},
(res) => {
let chunks = []
res.on('data', (c) => chunks.push(c))
res.on('end', () =>
resolve({ status: res.statusCode, text: Buffer.concat(chunks).toString('utf8') }),
)
},
)
req.on('timeout', () => {
req.destroy()
resolve({ status: 0, text: '' })
})
req.on('error', () => resolve({ status: 0, text: '' }))
if (data) req.write(data)
req.end()
})
}
globalThis.XMLHttpRequest = class {
open(m, u) {
this._m = m
this._u = u
this._h = {}
}
setRequestHeader(k, v) {
this._h[k] = v
}
getAllResponseHeaders() {
return ''
}
addEventListener(t, f) {
if (t === 'load' || t === 'readystatechange') this._on = f
}
send(d) {
xhr(this._u, d, this._h).then((r) => {
this.status = r.status
this.readyState = 4
this.responseText = r.text
this.response = r.text
const done = () => {
if (String(this._u).includes('/collect')) {
try {
const j = JSON.parse(r.text)
if (j && j.data) {
if (j.data.sdid) console.log('__SDID__' + j.data.sdid)
if (j.data.hdid) console.log('__HDID__' + j.data.hdid)
if (globalThis.document && globalThis.document.cookie) {
console.log('__COOKIES__' + globalThis.document.cookie)
}
setTimeout(() => process.exit(0), 200)
}
} catch (e) {}
}
try {
this.onreadystatechange && this.onreadystatechange()
} catch (e) {}
try {
this._on && this._on()
} catch (e) {}
try {
this.onload && this.onload()
} catch (e) {}
}
setTimeout(done, 30)
})
}
}
eval(fs.readFileSync(path.join(__dirname, 'hydevice-prod-1.2.45-min.js'), 'utf8'))
try {
globalThis.initFingerprint(process.argv[3] || '5008')
} catch (e) {
console.error('[init]', e.message.slice(0, 120))
}
setTimeout(() => {
console.error('__TIMEOUT__')
process.exit(2)
}, 25000)
@@ -0,0 +1,497 @@
/** 虎牙 Cookie 规范化、网页字段补齐与 QIMEI 指纹生成。 */
import crypto from 'node:crypto'
export type CookiePair = [string, string]
const MASK_64 = 0xffffffffffffffffn
const QIMEI_EPOCH_1582 = -12216643543000n
const GAME_DID_CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_'
const DEFAULT_QIMEI_FEATURE = {
screen: { resolution: '800x600', availResolution: '800x600' },
navigator: {
deviceMemory: 16,
pdfViewerEnabled: true,
cookieEnabled: true,
productSub: '20030107',
hardwareConcurrency: 10,
},
webgl: 'Google Inc. (Apple)~ANGLE (Apple, ANGLE Metal Renderer: Apple M4, Unspecified Version)',
timezoneOffset: -480,
}
const DEVICE_GPU_FEATURES: Record<string, [string, string]> = {
xiaomi: ['Qualcomm', 'Adreno (TM) 650'],
redmi: ['Qualcomm', 'Adreno (TM) 650'],
vivo: ['Qualcomm', 'Adreno (TM) 650'],
iqoo: ['Qualcomm', 'Adreno (TM) 650'],
oppo: ['Qualcomm', 'Adreno (TM) 650'],
oneplus: ['Qualcomm', 'Adreno (TM) 650'],
samsung: ['ARM', 'Mali-G78'],
honor: ['ARM', 'Mali-G78'],
motorola: ['Qualcomm', 'Adreno (TM) 642L'],
realme: ['Qualcomm', 'Adreno (TM) 650'],
nubia: ['Qualcomm', 'Adreno (TM) 650'],
gionee: ['Qualcomm', 'Adreno (TM) 506'],
}
const COOKIE_FILL_MAP: CookiePair[] = [
['SoundValue', '0.50'],
['alphaValue', '0.80'],
['isInLiveRoom', 'true'],
['udb_passdata', '3'],
['udb_accdata', 'undefined'],
]
export function cookiePairs(cookie: string): CookiePair[] {
const pairs: CookiePair[] = []
for (const part of String(cookie || '').split(';')) {
const item = part.trim()
if (!item || !item.includes('=')) continue
const separator = item.indexOf('=')
const key = item.slice(0, separator).trim()
if (!key) continue
pairs.push([key, item.slice(separator + 1).trim()])
}
return pairs
}
export function normalizeCookiePairs(pairs: CookiePair[]): string {
const orderedKeys: string[] = []
const values = new Map<string, string>()
for (const [rawKey, rawValue] of pairs) {
const key = String(rawKey || '').trim()
if (!key) continue
if (!values.has(key)) orderedKeys.push(key)
values.set(key, rawValue === null || rawValue === undefined ? '' : String(rawValue).trim())
}
return orderedKeys.map((key) => `${key}=${values.get(key) || ''}`).join('; ')
}
export function cookieValue(cookie: string, key: string): string {
let value = ''
for (const [itemKey, itemValue] of cookiePairs(cookie)) {
if (itemKey === key) value = itemValue
}
return value
}
export function buildHuyaLoginContext(cookie: string): string {
const guidData = cookieValue(cookie, 'udb_guiddata')
const yamid = cookieValue(cookie, '__yamid_new')
const guid = cookieValue(cookie, 'guid')
if (!guidData || !yamid || !guid) return ''
return `WB-${guidData}-${yamid}-${guid}`
}
function replaceCookie(pairs: CookiePair[], key: string, value: string): void {
const next = String(value ?? '').trim()
if (!next) return
for (let index = pairs.length - 1; index >= 0; index -= 1) {
if (pairs[index]?.[0] === key) pairs.splice(index, 1)
}
pairs.push([key, next])
}
function addCookie(pairs: CookiePair[], key: string, value: string): void {
const next = String(value ?? '').trim()
if (!next) return
if (pairs.some(([itemKey]) => itemKey === key)) return
pairs.push([key, next])
}
export function mergeHuyaWebCookie(input: {
cookie: string
biztoken: string
sdid: string
hdid: string
deviceInfo: Record<string, string>
uid: number
username?: string
includeTransient?: boolean
}): string {
const pairs = cookiePairs(input.cookie)
const username = String(input.username || '').trim()
// 扫码成功时服务端会通过 Set-Cookie 下发独立的 udb_cred;只有缺失时才用
// biztoken 兜底,不能覆盖服务端签发的真实值。
if (!cookieValue(input.cookie, 'udb_cred')) {
replaceCookie(pairs, 'udb_cred', input.biztoken)
}
if (input.uid) {
replaceCookie(pairs, 'udb_uid', String(input.uid))
replaceCookie(pairs, 'yyuid', String(input.uid))
}
replaceCookie(pairs, 'udb_biztoken', input.biztoken)
replaceCookie(pairs, 'udb_version', '1.0')
replaceCookie(pairs, 'udb_origin', '0')
replaceCookie(pairs, 'udb_status', '1')
replaceCookie(pairs, 'sdid', input.sdid)
replaceCookie(pairs, 'hdid', input.hdid)
if (username.startsWith('hy_')) {
replaceCookie(pairs, 'udb_passport', username)
replaceCookie(pairs, 'username', username)
}
const optionalKeys = [
'guid',
'udb_guiddata',
'__yamid_new',
'game_did',
'_qimei_uuid42',
'_qimei_fingerprint',
'_qimei_h38',
'udb_anouid',
'udb_anobiztoken',
'__yasmid',
'__yamid_tt1',
'SoundValue',
'alphaValue',
'isInLiveRoom',
'udb_deviceid',
'udb_passdata',
'_rep_cnt',
'Hm_lvt_51700b6c722f5bb4cf39906a596ea41f',
'HMACCOUNT',
'udb_appid',
'rep_cnt',
'udb_accdata',
'h_unt',
'__yaoldyyuid',
'_yasids',
'huya_flash_rep_cnt',
'huyasp_rep_cnt',
'huyawap_rep_cnt',
'huya_hd_rep_cnt',
'Hm_lpvt_51700b6c722f5bb4cf39906a596ea41f',
'huya_web_rep_cnt',
]
for (const key of optionalKeys) {
const value = String(input.deviceInfo[key] || '').trim()
if (!value) continue
for (let index = pairs.length - 1; index >= 0; index -= 1) {
if (pairs[index]?.[0] === key) pairs.splice(index, 1)
}
pairs.push([key, value])
}
const webGuideData = String(input.deviceInfo.web_guiddata || input.deviceInfo.udb_guiddata || '')
if (webGuideData) replaceCookie(pairs, 'udb_guiddata', webGuideData)
const webDeviceId = String(input.deviceInfo.web_device_id || '')
if (/^w_\d{19}$/.test(webDeviceId)) replaceCookie(pairs, 'udb_deviceid', webDeviceId)
return normalizeCookiePairs(
input.includeTransient === false
? pairs.filter(([key]) => key !== 'web_qrlogin_confirm_id')
: pairs,
)
}
export type QimeiFeature = {
screen: { resolution: string; availResolution: string }
navigator: {
deviceMemory: number | string
pdfViewerEnabled: boolean
cookieEnabled: boolean
productSub: string
hardwareConcurrency: number | string
}
webgl: string
timezoneOffset: number
}
export function qimeiFeatureFromDeviceProfile(
deviceInfo: Record<string, unknown> | null | undefined,
): QimeiFeature {
const info = deviceInfo || {}
const width = String(info.width || '1080')
const height = String(info.height || '2400')
const vendor = String(info.vendor || '')
.trim()
.toLowerCase()
const model = String(info.model || 'Android WebView').trim()
const [gpuVendor, renderer] = DEVICE_GPU_FEATURES[vendor] || ['Qualcomm', `${model} Graphics`]
return {
screen: { resolution: `${width}x${height}`, availResolution: `${width}x${height}` },
navigator: {
deviceMemory: (info.device_memory as number | string | undefined) || 8,
pdfViewerEnabled: false,
cookieEnabled: true,
productSub: '20030107',
hardwareConcurrency: (info.hardware_concurrency as number | string | undefined) || 8,
},
webgl: `Google Inc. (${gpuVendor})~ANGLE (${gpuVendor}, ${renderer})`,
timezoneOffset: -480,
}
}
function jsText(value: unknown): string {
if (value === null || value === undefined) return ''
if (typeof value === 'boolean') return value ? 'true' : 'false'
return String(value)
}
function qimeiFeatureString(feature: QimeiFeature): string {
return (
[
feature.screen.resolution,
feature.screen.availResolution,
feature.navigator.deviceMemory,
feature.navigator.pdfViewerEnabled,
feature.navigator.cookieEnabled,
feature.navigator.productSub,
feature.navigator.hardwareConcurrency,
feature.webgl,
feature.timezoneOffset,
]
.map(jsText)
.join('###') + '###'
)
}
function rotl64(value: bigint, shift: number): bigint {
const normalizedShift = shift % 64
if (normalizedShift === 0) return value & MASK_64
return ((value << BigInt(normalizedShift)) | (value >> BigInt(64 - normalizedShift))) & MASK_64
}
function fmix64(value: bigint): bigint {
let next = value
next ^= next >> 33n
next = (next * 0xff51afd7ed558ccdn) & MASK_64
next ^= next >> 33n
next = (next * 0xc4ceb9fe1a85ec53n) & MASK_64
next ^= next >> 33n
return next & MASK_64
}
export function x64hash128(value: string, seed = 0): string {
const utf16 = Buffer.from(value, 'utf16le')
const data = Buffer.alloc(utf16.length / 2)
for (let index = 0; index < data.length; index += 1) data[index] = utf16[index * 2] || 0
const length = data.length
let h1 = BigInt(seed) & MASK_64
let h2 = BigInt(seed) & MASK_64
const c1 = 0x87c37b91114253d5n
const c2 = 0x4cf5ad432745937fn
for (let offset = 0; offset < length - (length % 16); offset += 16) {
let k1 = data.readBigUInt64LE(offset)
let k2 = data.readBigUInt64LE(offset + 8)
k1 = (rotl64((k1 * c1) & MASK_64, 31) * c2) & MASK_64
h1 ^= k1
h1 = (rotl64(h1, 27) + h2) & MASK_64
h1 = (h1 * 5n + 0x52dce729n) & MASK_64
k2 = (rotl64((k2 * c2) & MASK_64, 33) * c1) & MASK_64
h2 ^= k2
h2 = (rotl64(h2, 31) + h1) & MASK_64
h2 = (h2 * 5n + 0x38495ab5n) & MASK_64
}
const tailStart = length - (length % 16)
const tail = data.subarray(tailStart)
const tailLength = tail.length
let k1 = 0n
let k2 = 0n
for (let index = tailLength - 1; index > 7; index -= 1) {
k2 ^= BigInt(tail[index] || 0) << BigInt(8 * (index - 8))
}
if (tailLength >= 9) h2 ^= (rotl64((k2 * c2) & MASK_64, 33) * c1) & MASK_64
for (let index = Math.min(tailLength, 8) - 1; index > 0; index -= 1) {
k1 ^= BigInt(tail[index] || 0) << BigInt(8 * index)
}
if (tailLength >= 1) {
k1 ^= BigInt(tail[0] || 0) << 32n
h1 ^= (rotl64((k1 * c1) & MASK_64, 31) * c2) & MASK_64
}
h1 ^= BigInt(length)
h2 ^= BigInt(length)
h1 = (h1 + h2) & MASK_64
h2 = (h2 + h1) & MASK_64
h1 = fmix64(h1)
h2 = fmix64(h2)
h1 = (h1 + h2) & MASK_64
h2 = (h2 + h1) & MASK_64
return `${h1.toString(16).padStart(16, '0')}${h2.toString(16).padStart(16, '0')}`
}
export function qimeiFingerprintGenerate(feature?: QimeiFeature): string {
return x64hash128(qimeiFeatureString(feature || DEFAULT_QIMEI_FEATURE), 31)
}
function crc32(buffer: Buffer): number {
let crc = 0xffffffff
for (const byte of buffer) {
crc ^= byte
for (let bit = 0; bit < 8; bit += 1) {
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0)
}
}
return (crc ^ 0xffffffff) >>> 0
}
function returnBase16(value: bigint): string {
if (value <= 0n) return ''
return value.toString(16).toUpperCase()
}
function generateBits(value: bigint, startBit: number, endBit: number): string {
const hex = returnBase16(value)
let output = ''
for (let index = Math.floor(startBit / 4); index <= Math.floor(endBit / 4); index += 1) {
output += hex[index] || '0'
}
return output
}
function randomInt(maxExclusive: number): number {
return Math.floor(Math.random() * maxExclusive)
}
function yamidNewGenerate32(): string {
const nowMs = BigInt(Date.now())
const value = nowMs - QIMEI_EPOCH_1582
return [
generateBits(value, 0, 31),
generateBits(value, 32, 47),
`${generateBits(value, 48, 59)}1`,
generateBits(BigInt(randomInt(4095)), 0, 7),
generateBits(BigInt(randomInt(4095)), 0, 7),
generateBits(BigInt(randomInt(8191)), 0, 7) +
generateBits(BigInt(randomInt(8191)), 8, 15) +
generateBits(BigInt(randomInt(8191)), 0, 7) +
generateBits(BigInt(randomInt(8191)), 8, 15) +
generateBits(BigInt(randomInt(8191)), 0, 15),
].join('')
}
function gameDidGenerate(length = 35): string {
let output = ''
for (let index = 0; index < length; index += 1) {
output += GAME_DID_CHARS[randomInt(GAME_DID_CHARS.length - 1)] || ''
}
return output
}
export function qimeiUuid42(
nowMs = Date.now(),
options: { cookieEnabled?: boolean; randomValue?: number } = {},
): string {
const current = new Date(nowMs)
const yearHex = (current.getFullYear() % 100).toString(16).padStart(2, '0')
const monthHex = (current.getMonth() + 1).toString(16)
const dateHex = [
current.getDate(),
current.getHours(),
current.getMinutes(),
current.getSeconds(),
]
.map((value) => value.toString(16).padStart(2, '0'))
.join('')
const prefix = yearHex + monthHex + dateHex + (options.cookieEnabled === false ? '000' : '100')
const randomSource = () => options.randomValue ?? Math.random()
const randomSegment = Array.from({ length: 20 }, () =>
Math.min(15, Math.max(0, Math.trunc(randomSource() * 16))).toString(16),
).join('')
const template = `${prefix}----${randomSegment}----`
const replacement = crc32(Buffer.from(template, 'ascii')).toString(16).padStart(8, '0')
return prefix + replacement.slice(0, 4) + randomSegment + replacement.slice(4)
}
export function fillWebCookieFields(input: {
cookie: string
guid: string
udbGuiddata?: string
uid?: number | string
keepGuid?: boolean
qimeiFeature?: QimeiFeature
}): string {
const pairs = new Map<string, string>()
for (const [key, value] of cookiePairs(input.cookie)) pairs.set(key, value)
const add = (key: string, value: string) => {
if (value && !pairs.get(key)) pairs.set(key, value)
}
const uid = Number(input.uid || 0) || 0
if (input.guid) {
if (!(input.keepGuid !== false && pairs.get('guid'))) add('guid', input.guid)
} else {
add(
'guid',
`0a${crypto.randomBytes(5).toString('hex')}966a${crypto.randomBytes(8).toString('hex')}`,
)
}
add('udb_guiddata', input.udbGuiddata || crypto.randomBytes(16).toString('hex'))
add('udb_appid', '5002')
add('_qimei_uuid42', qimeiUuid42())
add('__yamid_new', yamidNewGenerate32())
add('game_did', gameDidGenerate())
add('_qimei_fingerprint', qimeiFingerprintGenerate(input.qimeiFeature))
add('_qimei_h38', pairs.get('_qimei_uuid42') || '')
const yasmid = Math.random().toFixed(17)
add('__yasmid', yasmid)
add('__yamid_tt1', yasmid)
add('_rep_cnt', String(randomInt(10)))
add('rep_cnt', String(randomInt(10)))
add('h_unt', String(Math.floor(Date.now() / 1000)))
if (uid) add('__yaoldyyuid', String(uid))
add('_yasids', `__rootsid%3D${yamidNewGenerate32()}`)
add('Hm_lvt_51700b6c722f5bb4cf39906a596ea41f', String(Math.floor(Date.now() / 1000)))
add('Hm_lpvt_51700b6c722f5bb4cf39906a596ea41f', String(Math.floor(Date.now() / 1000)))
add('HMACCOUNT', crypto.randomBytes(8).toString('hex').toUpperCase())
add('huya_flash_rep_cnt', String(1 + randomInt(400)))
add('huyasp_rep_cnt', String(1 + randomInt(40)))
add('huya_hd_rep_cnt', String(1 + randomInt(40)))
add('huya_web_rep_cnt', String(300 + randomInt(601)))
for (const [key, value] of COOKIE_FILL_MAP) add(key, value)
return Array.from(pairs.entries())
.map(([key, value]) => `${key}=${value}`)
.join('; ')
}
export type HuyaWebCookieIdentity = {
cookie: string
context: string
guid: string
udbGuiddata: string
yamidNew: string
}
/** 为网页扫码登录补齐稳定的设备身份字段,并生成服务端要求的 WB context。 */
export function ensureHuyaWebCookieIdentity(input: {
cookie?: string
guid?: string
udbGuiddata?: string
uid?: number | string
qimeiFeature?: QimeiFeature
}): HuyaWebCookieIdentity {
const fillInput: Parameters<typeof fillWebCookieFields>[0] = {
cookie: String(input.cookie || ''),
guid: String(input.guid || ''),
}
if (input.udbGuiddata) fillInput.udbGuiddata = input.udbGuiddata
if (input.uid !== undefined) fillInput.uid = input.uid
if (input.qimeiFeature) fillInput.qimeiFeature = input.qimeiFeature
const cookie = fillWebCookieFields(fillInput)
const pairs = cookiePairs(cookie)
const set = (key: string, value: string) => {
const next = value.trim()
if (!next) return
for (let index = pairs.length - 1; index >= 0; index -= 1) {
if (pairs[index]?.[0] === key) pairs.splice(index, 1)
}
pairs.push([key, next])
}
set('udb_appid', '5002')
const normalized = normalizeCookiePairs(pairs)
return {
cookie: normalized,
context: buildHuyaLoginContext(normalized),
guid: cookieValue(normalized, 'guid'),
udbGuiddata: cookieValue(normalized, 'udb_guiddata'),
yamidNew: cookieValue(normalized, '__yamid_new'),
}
}
@@ -0,0 +1,132 @@
/** 通过随项目携带的 hydevice runner 获取虎牙高信任 sdid。 */
import { execFile } from 'node:child_process'
import fs from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
const execFileAsync = promisify(execFile)
const FINGERPRINT_DIR = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'assets/fingerprint',
)
const RUNNER_PATH = path.join(FINGERPRINT_DIR, 'runner.js')
const SDID_PREFIX = '__SDID__'
const HDID_PREFIX = '__HDID__'
const COOKIE_PREFIX = '__COOKIES__'
export type HuyaFingerprintResult = {
sdid: string
hdid: string
cookies: Record<string, string>
source: 'fingerprint' | 'fallback'
message?: string
}
export type FingerprintResolver = (
stateDir: string,
appId?: string,
) => Promise<HuyaFingerprintResult>
function parseCookieText(text: string): Record<string, string> {
const cookies: Record<string, string> = {}
for (const part of text.split(';')) {
const item = part.trim()
if (!item || !item.includes('=')) continue
const separator = item.indexOf('=')
const key = item.slice(0, separator).trim()
const value = item.slice(separator + 1).trim()
if (key && value) cookies[key] = value
}
return cookies
}
async function runFingerprintRunner(
stateDir: string,
appId = '5008',
): Promise<HuyaFingerprintResult | null> {
await fs.mkdir(stateDir, { recursive: true })
try {
const { stdout, stderr } = await execFileAsync(
process.execPath,
[RUNNER_PATH, stateDir, appId],
{
cwd: FINGERPRINT_DIR,
timeout: 45_000,
maxBuffer: 2 * 1024 * 1024,
},
)
let sdid = ''
let hdid = ''
let cookies: Record<string, string> = {}
for (const rawLine of stdout.split(/\r?\n/)) {
const line = rawLine.trim()
if (line.startsWith(SDID_PREFIX) && line.length > SDID_PREFIX.length + 20) {
sdid = line.slice(SDID_PREFIX.length)
} else if (line.startsWith(HDID_PREFIX) && line.length > HDID_PREFIX.length + 20) {
hdid = line.slice(HDID_PREFIX.length)
} else if (line.startsWith(COOKIE_PREFIX)) {
cookies = parseCookieText(line.slice(COOKIE_PREFIX.length))
}
}
if (sdid) return { sdid, hdid, cookies, source: 'fingerprint' }
const detail = stderr.trim().split(/\r?\n/).pop() || 'runner 未返回 sdid'
throw new Error(detail.slice(0, 160))
} catch (error) {
if (error instanceof Error) throw error
throw new Error(String(error), { cause: error })
}
}
async function fallbackFingerprint(): Promise<HuyaFingerprintResult | null> {
try {
const tokenResponse = await fetch('https://df.huya.com/web/df/token', {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({ encryptVersion: '1.0.1', fingerprintVersion: '1.2.45' }),
signal: AbortSignal.timeout(20_000),
})
if (!tokenResponse.ok) return null
const tokenPayload = (await tokenResponse.json()) as { data?: { token?: string } }
const token = String(tokenPayload.data?.token || '').trim()
if (!token) return null
const collectResponse = await fetch('https://df.huya.com/web/df/collect', {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({ token }),
signal: AbortSignal.timeout(20_000),
})
if (!collectResponse.ok) return null
const collectPayload = (await collectResponse.json()) as {
data?: { sdid?: string; hdid?: string }
}
const sdid = String(collectPayload.data?.sdid || '').trim()
if (!sdid) return null
return {
sdid,
hdid: String(collectPayload.data?.hdid || '').trim(),
cookies: {},
source: 'fallback',
message: '低信任指纹(降级)',
}
} catch {
return null
}
}
export const getHuyaFingerprint: FingerprintResolver = async (stateDir, appId = '5008') => {
try {
const result = await runFingerprintRunner(stateDir, appId)
if (result) return result
} catch {
// runner 失败时使用低信任降级,保持与现有 Connector 一致的可恢复行为。
}
const fallback = await fallbackFingerprint()
if (fallback) return fallback
throw new Error('获取虎牙设备指纹失败')
}
export async function removeFingerprintState(stateDir: string): Promise<void> {
await fs.rm(stateDir, { recursive: true, force: true }).catch(() => undefined)
}
@@ -0,0 +1,760 @@
/**
* 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,
hasExistingHuyaBinding,
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
hasExistingBinding: boolean
rebindRequested: boolean
rebindScanned: boolean
baselineRoleKey: 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: '',
hasExistingBinding: false,
rebindRequested: false,
rebindScanned: false,
baselineRoleKey: '',
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 bindingRoleKey(role: { account: string; role: string; channel: string }): string {
return [role.account, role.role, role.channel].join('\u0000')
}
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,
options: { forceRebind?: boolean } = {},
): 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)
const hasExistingBinding = hasExistingHuyaBinding(roleResponse)
session.binding.hasExistingBinding = hasExistingBinding
if (role.role && !options.forceRebind) {
this.markRoleReady(session, role, hasExistingBinding && !session.binding.rebindRequested)
if (session.binding.rebindRequested) {
session.binding.message = '已识别待换绑角色,请确认换绑'
}
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',
})
}
if (options.forceRebind) {
session.binding.rebindRequested = true
session.binding.rebindScanned = false
session.binding.baselineRoleKey = bindingRoleKey(role)
}
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 = options.forceRebind
? '请使用虎牙 App 扫码选择需要换绑的游戏角色'
: '请使用虎牙 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()
let qrCompleted = false
if (['qr_pending', 'scanned'].includes(session.binding.status) && 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 = '已扫码,等待绑定完成'
}
if (qrStatus.isScan || qrStatus.isCompleted) {
session.binding.rebindScanned = true
}
qrCompleted = qrStatus.isCompleted
}
}
if (session.binding.rebindRequested && !session.binding.rebindScanned) {
return this.bindingView(session)
}
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)
const hasExistingBinding = hasExistingHuyaBinding(response)
session.binding.hasExistingBinding = hasExistingBinding
if (role.role) {
const rebindRequested = session.binding.rebindRequested
const roleKey = bindingRoleKey(role)
const changedRole =
!session.binding.baselineRoleKey || roleKey !== session.binding.baselineRoleKey
if (rebindRequested && !changedRole) {
if (qrCompleted) {
session.binding.rebindRequested = false
session.binding.rebindScanned = false
session.binding.baselineRoleKey = ''
this.markRoleReady(session, role, hasExistingBinding)
session.binding.message = '扫码后仍识别到原绑定角色,请确认使用或重新换绑'
} else {
session.binding.status = 'scanned'
session.binding.message = '已扫码,正在等待换绑角色生效'
}
return this.bindingView(session)
}
this.markRoleReady(session, role, hasExistingBinding && !rebindRequested)
if (rebindRequested) {
session.binding.message = '已识别换绑角色,请确认换绑'
}
} 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 rebindRequested = session.binding.rebindRequested
if (
rebindRequested &&
session.binding.baselineRoleKey &&
bindingRoleKey(role) === session.binding.baselineRoleKey
) {
throw createHttpError('换绑角色尚未生效,请稍后重试', {
statusCode: 409,
errorCode: 'huya_bind_rebind_not_ready',
})
}
const useExistingBinding = hasExistingHuyaBinding(response) && !rebindRequested
if (!useExistingBinding) {
const confirmed = await rpc.confirmBindActAccount({
uid: session.uid,
cookie: session.cookie,
bindActId,
outerActId,
})
if (!confirmed || confirmed.status !== 200) {
throw createHttpError(confirmed?.msg || '确认虎牙绑定失败', {
statusCode: 409,
errorCode: 'huya_bind_confirm_failed',
})
}
}
this.markRoleReady(session, role, useExistingBinding)
session.binding.status = 'confirmed'
session.binding.hasExistingBinding = true
session.binding.rebindRequested = false
session.binding.rebindScanned = false
session.binding.baselineRoleKey = ''
session.binding.message = useExistingBinding
? '当前虎牙账号已有绑定角色,已确认直接使用'
: rebindRequested
? '虎牙角色换绑已确认'
: '虎牙角色绑定已确认'
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 },
existingBinding = false,
): void {
session.binding.status = 'role_ready'
session.binding.account = role.account
session.binding.role = role.role
session.binding.channel = role.channel
session.binding.message = existingBinding
? '当前账号已有绑定角色,请确认直接使用'
: '绑定角色已识别,请确认绑定'
}
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,
hasExistingBinding: binding.hasExistingBinding,
rebindRequested: binding.rebindRequested,
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,190 @@
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 { hasExistingHuyaBinding, 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('已绑定账号和角色时直接使用当前绑定,不再发起换绑', () => {
const response = parseBindStruct(Buffer.from(BIND_RESP_HEX, 'hex'))
assert.equal(hasExistingHuyaBinding(response), true)
response.accountData.isBindRole = 0
assert.equal(hasExistingHuyaBinding(response), false)
response.accountData.isBindRole = 1
response.status = 500
assert.equal(hasExistingHuyaBinding(response), false)
})
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,786 @@
/** 虎牙扫码登录 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 function hasExistingHuyaBinding(response: HuyaBindResponse | null): boolean {
if (!response || response.status !== 200) return false
return Boolean(response.accountData.isBindAccount && response.accountData.isBindRole)
}
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)
}
@@ -0,0 +1,94 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
decryptSemiAutoCredential,
encryptSemiAutoCredential,
isEncryptedSemiAutoCredential,
} from './semi-auto-credential-crypto.js'
import { buildSemiAutoCredentialCookieHeader } from './semi-auto-credential-service.js'
const SECRET = 'test-semi-auto-credential-encryption-key-123456'
test('半自动履约凭证使用 AES-GCM 加密并可还原', () => {
const plaintext = JSON.stringify({
cookies: [{ name: 'SUB', value: 'secret-cookie', domain: '.weibo.com' }],
})
const encrypted = encryptSemiAutoCredential(plaintext, SECRET)
assert.equal(isEncryptedSemiAutoCredential(encrypted), true)
assert.notEqual(encrypted, plaintext)
assert.equal(encrypted.includes('secret-cookie'), false)
assert.equal(decryptSemiAutoCredential(encrypted, SECRET), plaintext)
})
test('半自动履约凭证密文被篡改时解密失败', () => {
const encrypted = encryptSemiAutoCredential('{"cookies":[]}', SECRET)
const tampered = `${encrypted.slice(0, -1)}${encrypted.endsWith('A') ? 'B' : 'A'}`
assert.throws(() => decryptSemiAutoCredential(tampered, SECRET))
})
test('错误密钥不能解密半自动履约凭证', () => {
const encrypted = encryptSemiAutoCredential('{"cookies":[]}', SECRET)
assert.throws(() =>
decryptSemiAutoCredential(encrypted, 'another-semi-auto-credential-encryption-key-123'),
)
})
test('账号 Cookie Header 按域名、路径和安全协议筛选', () => {
const header = buildSemiAutoCredentialCookieHeader(
[
{
name: 'SUB',
value: 'weibo-session',
domain: '.weibo.com',
path: '/',
secure: true,
httpOnly: true,
hostOnly: false,
expiresAt: null,
},
{
name: 'SUB',
value: 'passport-session',
domain: 'passport.weibo.com',
path: '/sso',
secure: true,
httpOnly: true,
hostOnly: true,
expiresAt: null,
},
{
name: 'INSECURE',
value: 'must-not-leak',
domain: '.weibo.com',
path: '/',
secure: false,
httpOnly: false,
hostOnly: false,
expiresAt: null,
},
],
'https://weibo.com/',
)
assert.equal(header, 'SUB=weibo-session; INSECURE=must-not-leak')
assert.equal(
buildSemiAutoCredentialCookieHeader(
[
{
name: 'SUB',
value: 'weibo-session',
domain: '.weibo.com',
path: '/',
secure: true,
httpOnly: true,
hostOnly: false,
expiresAt: null,
},
],
'http://weibo.com/',
),
'',
)
})
@@ -0,0 +1,56 @@
import crypto from 'node:crypto'
import { runtimeConfig } from '../../config/runtime.js'
const CIPHERTEXT_PREFIX = 'enc:v1:'
function resolveEncryptionSecret(override?: string): string {
const explicit = String(override ?? runtimeConfig.semiAuto?.credentialEncryptionKey ?? '').trim()
const fallback = String(runtimeConfig.admin?.sessionSecret || '').trim()
const secret = explicit || fallback
if (!secret) {
throw new Error('缺少 SEMI_AUTO_CREDENTIAL_ENCRYPTION_KEY,无法加密半自动履约平台凭证')
}
return secret
}
function deriveKey(secret: string): Buffer {
return crypto.createHash('sha256').update(`order-site:semi-auto-credential:${secret}`).digest()
}
export function isEncryptedSemiAutoCredential(value: unknown): boolean {
return String(value || '').startsWith(CIPHERTEXT_PREFIX)
}
export function encryptSemiAutoCredential(plaintext: string, secretOverride?: string): string {
const text = String(plaintext || '')
if (!text) return ''
const secret = resolveEncryptionSecret(secretOverride)
const nonce = crypto.randomBytes(12)
const cipher = crypto.createCipheriv('aes-256-gcm', deriveKey(secret), nonce)
const ciphertext = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
return `${CIPHERTEXT_PREFIX}${Buffer.concat([nonce, tag, ciphertext]).toString('base64url')}`
}
export function decryptSemiAutoCredential(ciphertext: string, secretOverride?: string): string {
const value = String(ciphertext || '')
if (!value) return ''
if (!value.startsWith(CIPHERTEXT_PREFIX)) {
throw new Error('半自动履约凭证不是受支持的加密格式')
}
const raw = Buffer.from(value.slice(CIPHERTEXT_PREFIX.length), 'base64url')
if (raw.length < 29) {
throw new Error('半自动履约凭证密文长度无效')
}
const nonce = raw.subarray(0, 12)
const tag = raw.subarray(12, 28)
const ciphertextBytes = raw.subarray(28)
const decipher = crypto.createDecipheriv(
'aes-256-gcm',
deriveKey(resolveEncryptionSecret(secretOverride)),
nonce,
)
decipher.setAuthTag(tag)
return Buffer.concat([decipher.update(ciphertextBytes), decipher.final()]).toString('utf8')
}
@@ -0,0 +1,219 @@
import type { PoolClient } from 'pg'
import { query } from '../../db/client.js'
import {
decryptSemiAutoCredential,
encryptSemiAutoCredential,
} from './semi-auto-credential-crypto.js'
export type SemiAutoCredentialCookie = {
name: string
value: string
domain: string
path: string
secure: boolean
httpOnly: boolean
hostOnly?: boolean
expiresAt: string | null
}
export type SemiAutoCredentialPayload = {
platformUid: string
nickname: string
cookies: SemiAutoCredentialCookie[]
}
export type SemiAutoCredentialSource = 'qr' | 'sms' | 'web' | 'app' | 'import'
export type SemiAutoCredentialRecord = SemiAutoCredentialPayload & {
id: number
platform: 'huya' | 'weibo'
source: SemiAutoCredentialSource | ''
status: 'unknown' | 'valid' | 'invalid' | 'error'
updatedAt: string
}
type CredentialRow = {
id: number
platform: 'huya' | 'weibo'
platform_uid: string
platform_nickname: string
credential_ciphertext: string
credential_source: SemiAutoCredentialSource | ''
credential_status: 'unknown' | 'valid' | 'invalid' | 'error'
credential_updated_at: string
}
function normalizeCookieList(value: unknown): SemiAutoCredentialCookie[] {
if (!Array.isArray(value)) return []
return value
.filter((item): item is Record<string, unknown> =>
Boolean(item && typeof item === 'object' && !Array.isArray(item)),
)
.map((item) => {
const domain = String(item.domain || '')
.trim()
.toLowerCase()
return {
name: String(item.name || '').trim(),
value: String(item.value || ''),
domain,
path: String(item.path || '/').trim() || '/',
secure: item.secure === true,
httpOnly: item.httpOnly === true,
hostOnly: typeof item.hostOnly === 'boolean' ? item.hostOnly : !domain.startsWith('.'),
expiresAt: item.expiresAt ? String(item.expiresAt) : null,
}
})
.filter((item) => item.name && item.domain)
}
export function buildSemiAutoCredentialCookieHeader(
cookies: SemiAutoCredentialCookie[],
requestUrlInput: unknown,
): string {
const requestUrl = new URL(String(requestUrlInput || '').trim())
const hostname = requestUrl.hostname.toLowerCase()
const requestPath = requestUrl.pathname || '/'
const nowMs = Date.now()
return cookies
.filter((cookie) => {
if (cookie.secure && requestUrl.protocol !== 'https:') return false
if (cookie.expiresAt) {
const expiresAtMs = Date.parse(cookie.expiresAt)
if (Number.isFinite(expiresAtMs) && expiresAtMs <= nowMs) return false
}
const cookieDomain = cookie.domain.trim().toLowerCase().replace(/^\./, '')
if (!cookieDomain) return false
const hostOnly = cookie.hostOnly ?? !cookie.domain.startsWith('.')
const domainMatches = hostOnly
? hostname === cookieDomain
: hostname === cookieDomain || hostname.endsWith(`.${cookieDomain}`)
if (!domainMatches) return false
const cookiePath = cookie.path || '/'
return (
requestPath === cookiePath ||
requestPath.startsWith(cookiePath.endsWith('/') ? cookiePath : `${cookiePath}/`)
)
})
.sort((left, right) => (right.path || '/').length - (left.path || '/').length)
.map((cookie) => `${cookie.name}=${cookie.value}`)
.join('; ')
}
function decodeCredential(row: CredentialRow): SemiAutoCredentialRecord {
const decrypted = decryptSemiAutoCredential(row.credential_ciphertext)
const parsed = JSON.parse(decrypted) as Record<string, unknown>
return {
id: Number(row.id),
platform: row.platform,
platformUid: row.platform_uid,
nickname: row.platform_nickname,
cookies: normalizeCookieList(parsed.cookies),
source: row.credential_source,
status: row.credential_status,
updatedAt: row.credential_updated_at,
}
}
export async function upsertSemiAutoCredentialWithClient(
client: Pick<PoolClient, 'query'>,
input: {
platform: 'huya' | 'weibo'
source: SemiAutoCredentialSource
payload: SemiAutoCredentialPayload
},
): Promise<number> {
const platformUid = String(input.payload.platformUid || '').trim()
if (!platformUid) {
throw new Error('平台账号 UID 为空,无法保存半自动履约凭证')
}
const cookies = normalizeCookieList(input.payload.cookies)
if (cookies.length === 0) {
throw new Error('平台 Cookie 为空,无法保存半自动履约凭证')
}
const encrypted = encryptSemiAutoCredential(
JSON.stringify({
cookies,
}),
)
const result = await client.query<{ id: number }>(
`
INSERT INTO semi_auto_credentials (
platform, platform_uid, platform_nickname, credential_ciphertext,
credential_source, credential_status, credential_updated_at,
last_checked_at, last_check_error, created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, 'valid', NOW(), NOW(), '', NOW(), NOW()
)
ON CONFLICT (platform, platform_uid) DO UPDATE
SET platform_nickname = EXCLUDED.platform_nickname,
credential_ciphertext = EXCLUDED.credential_ciphertext,
credential_source = EXCLUDED.credential_source,
credential_status = 'valid',
credential_updated_at = NOW(),
last_checked_at = NOW(),
last_check_error = '',
updated_at = NOW()
RETURNING id
`,
[
input.platform,
platformUid,
String(input.payload.nickname || '')
.trim()
.slice(0, 120),
encrypted,
input.source,
],
)
return Number(result.rows[0]?.id || 0)
}
export async function loadSemiAutoCredential(
credentialIdInput: unknown,
): Promise<SemiAutoCredentialRecord | null> {
const credentialId = Number(credentialIdInput || 0)
if (!Number.isInteger(credentialId) || credentialId <= 0) return null
const result = await query<CredentialRow>(
`
SELECT
id, platform, platform_uid, platform_nickname, credential_ciphertext,
credential_source, credential_status, credential_updated_at
FROM semi_auto_credentials
WHERE id = $1
LIMIT 1
`,
[credentialId],
)
const row = result.rows[0]
return row ? decodeCredential(row) : null
}
export async function markSemiAutoCredentialCheck(
credentialIdInput: unknown,
input: {
status: 'valid' | 'invalid' | 'error'
error?: string
},
): Promise<void> {
const credentialId = Number(credentialIdInput || 0)
if (!Number.isInteger(credentialId) || credentialId <= 0) return
await query(
`
UPDATE semi_auto_credentials
SET credential_status = $2,
last_checked_at = NOW(),
last_check_error = $3,
updated_at = NOW()
WHERE id = $1
`,
[
credentialId,
input.status,
String(input.error || '')
.trim()
.slice(0, 1000),
],
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,518 @@
/**
* 半自动任务真实 PostgreSQL 集成测试。
*
* SEMI_AUTO_TASK_TEST_DATABASE_URL=postgres://... \
* npx tsx --test src/services/semi-auto/semi-auto-task.integration.test.ts
*/
import assert from 'node:assert/strict'
import test from 'node:test'
const TEST_DATABASE_URL = String(process.env.SEMI_AUTO_TASK_TEST_DATABASE_URL || '').trim()
test(
'人工渠道半自动任务可由客户入口和后台列表幂等回填',
{ skip: !TEST_DATABASE_URL ? '未设置 SEMI_AUTO_TASK_TEST_DATABASE_URL' : false },
async () => {
process.env.DATABASE_URL = TEST_DATABASE_URL
const { closeDb, query } = await import('../../db/client.js')
const { ensureSemiAutoTasksForOrder, listAdminSemiAutoTasks, listPublicSemiAutoTasks } =
await import('./semi-auto-task-service.js')
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`
const orderNo = `IT-SEMI-AUTO-${suffix}`
const skuKey = `半自动测试-${suffix}`
let orderId = 0
let configId = 0
try {
const order = await query<{ id: number }>(
`
INSERT INTO orders (
provider, platform, shop_id, shop_name, platform_order_id,
order_status, pay_status, total_amount, paid_at, created_at, updated_at
) VALUES (
'91kaquan', 'kuaishou-91', $1, '半自动测试店', $2,
'paid', 'paid', 1000, NOW(), NOW(), NOW()
) RETURNING id
`,
[`semi-auto-shop-${suffix}`, orderNo],
)
orderId = Number(order.rows[0]?.id || 0)
assert.ok(orderId > 0)
const item = await query<{ id: number }>(
`
INSERT INTO order_items (
order_id, sku_code, sku_name, quantity, spec_json, item_snapshot_json,
revenue_amount_fen, revenue_basis, created_at, updated_at
) VALUES (
$1, $2, $3, 1, $4::jsonb, $4::jsonb,
1000, 'order_total', NOW(), NOW()
) RETURNING id
`,
[orderId, `semi-${suffix}`, skuKey, JSON.stringify({ skuNick: skuKey })],
)
const orderItemId = Number(item.rows[0]?.id || 0)
assert.ok(orderItemId > 0)
const fulfillment = await query<{ id: number }>(
'SELECT id FROM fulfillments WHERE order_item_id = $1 LIMIT 1',
[orderItemId],
)
const fulfillmentId = Number(fulfillment.rows[0]?.id || 0)
assert.ok(fulfillmentId > 0)
const config = await query<{ id: number }>(
`
INSERT INTO manual_fulfillment_configs (
sku_key, platform_key, credit_quantity, cost_mode, enabled, note,
channels_json, semi_auto_enabled, semi_auto_platform,
semi_auto_config_json, created_at, updated_at
) VALUES (
$1, 'fund_cash', 100, 'fixed', TRUE, '集成测试',
'[{"platformKey":"fund_cash","creditQuantity":100,"costMode":"fixed","enabled":true}]'::jsonb,
TRUE, 'huya', '{"pageTitle":"虎牙授权测试"}'::jsonb,
NOW(), NOW()
) RETURNING id
`,
[skuKey],
)
configId = Number(config.rows[0]?.id || 0)
assert.ok(configId > 0)
await query(
`
UPDATE fulfillments
SET channel = 'manual', status = 'assigned', fulfillment_config_id = $2,
source = 'manual_confirm', updated_at = NOW()
WHERE id = $1
`,
[fulfillmentId, configId],
)
// 客服工作台先打开时也必须看到任务,不能依赖客户先访问 `/c/订单号`。
const adminList = await listAdminSemiAutoTasks({ keyword: orderNo })
assert.equal(adminList.pagination.total, 1)
assert.equal(adminList.items[0]?.orderNo, orderNo)
const first = await ensureSemiAutoTasksForOrder(orderNo)
const second = await ensureSemiAutoTasksForOrder(orderNo)
assert.equal(first.createdIds.length, 0)
assert.equal(second.createdIds.length, 0)
const tasks = await listPublicSemiAutoTasks(orderNo)
assert.equal(tasks.length, 1)
assert.equal(tasks[0]?.orderItemId, orderItemId)
assert.equal(tasks[0]?.platform, 'huya')
assert.equal(tasks[0]?.orderNo, orderNo)
assert.equal(tasks[0]?.quantity, 1)
assert.equal(tasks[0]?.semiAutoConfig.pageTitle, '虎牙授权测试')
} 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])
}
await closeDb()
}
},
)
test(
'半自动完成失败会连同财务确认一起回滚,成功后重复完成不重复扣成本',
{ skip: !TEST_DATABASE_URL ? '未设置 SEMI_AUTO_TASK_TEST_DATABASE_URL' : false },
async () => {
process.env.DATABASE_URL = TEST_DATABASE_URL
const { closeDb, query } = await import('../../db/client.js')
const { completeAdminSemiAutoTask, ensureSemiAutoTasksForOrder } =
await import('./semi-auto-task-service.js')
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`
const orderNo = `IT-SEMI-AUTO-ATOMIC-${suffix}`
const skuKey = `半自动原子性-${suffix}`
const triggerName = `trg_semi_auto_atomic_${suffix}`
const functionName = `fn_semi_auto_atomic_${suffix}`
let orderId = 0
let configId = 0
let adminId = 0
let triggerCreated = false
try {
const admin = await query<{ id: number }>(
`INSERT INTO admin_users (username, password_hash, role, status, created_at, updated_at)
VALUES ($1, 'x', 'support', 'active', NOW(), NOW()) RETURNING id`,
[`it_semi_auto_admin_${suffix}`],
)
adminId = Number(admin.rows[0]?.id || 0)
assert.ok(adminId > 0)
const order = await query<{ id: number }>(
`
INSERT INTO orders (
provider, platform, shop_id, shop_name, platform_order_id,
order_status, pay_status, total_amount, paid_at, created_at, updated_at
) VALUES (
'91kaquan', 'kuaishou-91', $1, '半自动原子性测试店', $2,
'paid', 'paid', 1000, NOW(), NOW(), NOW()
) RETURNING id
`,
[`semi-auto-atomic-shop-${suffix}`, orderNo],
)
orderId = Number(order.rows[0]?.id || 0)
assert.ok(orderId > 0)
const item = await query<{ id: number }>(
`
INSERT INTO order_items (
order_id, sku_code, sku_name, quantity, spec_json, item_snapshot_json,
revenue_amount_fen, revenue_basis, created_at, updated_at
) VALUES (
$1, $2, $3, 1, $4::jsonb, $4::jsonb,
1000, 'order_total', NOW(), NOW()
) RETURNING id
`,
[orderId, `semi-atomic-${suffix}`, skuKey, JSON.stringify({ skuNick: skuKey })],
)
const orderItemId = Number(item.rows[0]?.id || 0)
assert.ok(orderItemId > 0)
const fulfillment = await query<{ id: number }>(
'SELECT id FROM fulfillments WHERE order_item_id = $1 LIMIT 1',
[orderItemId],
)
const fulfillmentId = Number(fulfillment.rows[0]?.id || 0)
assert.ok(fulfillmentId > 0)
const config = await query<{ id: number }>(
`
INSERT INTO manual_fulfillment_configs (
sku_key, platform_key, credit_quantity, cost_mode, enabled, note,
channels_json, semi_auto_enabled, semi_auto_platform,
semi_auto_config_json, created_at, updated_at
) VALUES (
$1, 'on_account', 0, 'variable', TRUE, '原子性集成测试',
'[{"platformKey":"on_account","creditQuantity":0,"costMode":"variable","enabled":true}]'::jsonb,
TRUE, 'huya', '{}'::jsonb, NOW(), NOW()
) RETURNING id
`,
[skuKey],
)
configId = Number(config.rows[0]?.id || 0)
assert.ok(configId > 0)
await query(
`
UPDATE fulfillments
SET channel = 'manual', status = 'assigned', fulfillment_config_id = $2,
source = 'manual_confirm', updated_at = NOW()
WHERE id = $1
`,
[fulfillmentId, configId],
)
const ensured = await ensureSemiAutoTasksForOrder(orderNo)
const taskId = Number(ensured.createdIds[0] || 0)
assert.ok(taskId > 0)
await query(
`
UPDATE semi_auto_tasks
SET connector_session_id = $2,
login_status = 'authenticated',
binding_status = 'confirmed',
execution_status = 'waiting_operator',
authenticated_at = NOW(),
bound_at = NOW(),
updated_at = NOW()
WHERE id = $1
`,
[taskId, `it-session-${suffix}`],
)
await query(
`
CREATE OR REPLACE FUNCTION ${functionName}() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF NEW.execution_status = 'completed' THEN
RAISE EXCEPTION 'forced semi auto completion failure';
END IF;
RETURN NEW;
END;
$$
`,
)
await query(
`
CREATE TRIGGER ${triggerName}
BEFORE UPDATE OF execution_status ON semi_auto_tasks
FOR EACH ROW EXECUTE FUNCTION ${functionName}()
`,
)
triggerCreated = true
const session = {
userId: adminId,
role: 'support',
displayName: '半自动原子性客服',
} as never
await assert.rejects(
() =>
completeAdminSemiAutoTask(
taskId,
{ costFen: 123, note: '触发任务更新失败以验证回滚' },
session,
),
/forced semi auto completion failure/,
)
const rolledBack = await query<{
execution_status: string
snapshot_count: number
bill_count: number
fulfillment_channel: string
}>(
`
SELECT
task.execution_status,
(SELECT COUNT(*)::int FROM cost_snapshots cs WHERE cs.order_item_id = $2) AS snapshot_count,
(SELECT COUNT(*)::int FROM on_account_bills oab WHERE oab.order_item_id = $2) AS bill_count,
f.channel AS fulfillment_channel
FROM semi_auto_tasks task
JOIN fulfillments f ON f.id = task.fulfillment_id
WHERE task.id = $1
`,
[taskId, orderItemId],
)
assert.equal(rolledBack.rows[0]?.execution_status, 'waiting_operator')
assert.equal(Number(rolledBack.rows[0]?.snapshot_count), 0)
assert.equal(Number(rolledBack.rows[0]?.bill_count), 0)
assert.equal(rolledBack.rows[0]?.fulfillment_channel, 'manual')
await query(`DROP TRIGGER ${triggerName} ON semi_auto_tasks`)
triggerCreated = false
await query(`DROP FUNCTION ${functionName}()`)
const completed = await completeAdminSemiAutoTask(
taskId,
{ costFen: 123, note: '事务重试成功' },
session,
)
assert.equal(completed.task.executionStatus, 'completed')
const afterCompletion = await query<{ snapshot_count: number; bill_count: number }>(
`
SELECT
(SELECT COUNT(*)::int FROM cost_snapshots cs WHERE cs.order_item_id = $1) AS snapshot_count,
(SELECT COUNT(*)::int FROM on_account_bills oab WHERE oab.order_item_id = $1) AS bill_count
`,
[orderItemId],
)
assert.equal(Number(afterCompletion.rows[0]?.snapshot_count), 1)
assert.equal(Number(afterCompletion.rows[0]?.bill_count), 1)
await completeAdminSemiAutoTask(taskId, { costFen: 123 }, session)
const afterDuplicate = await query<{ snapshot_count: number }>(
'SELECT COUNT(*)::int AS snapshot_count FROM cost_snapshots WHERE order_item_id = $1',
[orderItemId],
)
assert.equal(Number(afterDuplicate.rows[0]?.snapshot_count), 1)
} finally {
if (triggerCreated) {
await query(`DROP TRIGGER IF EXISTS ${triggerName} ON semi_auto_tasks`)
}
await query(`DROP FUNCTION IF EXISTS ${functionName}()`)
if (orderId > 0) {
await query(
'DELETE FROM cost_snapshots WHERE order_item_id IN (SELECT id FROM order_items WHERE order_id = $1)',
[orderId],
)
await query(
'DELETE FROM on_account_bills WHERE order_item_id IN (SELECT id FROM order_items WHERE order_id = $1)',
[orderId],
)
await query(
'DELETE FROM cost_exceptions WHERE subject_id IN (SELECT id FROM order_items WHERE order_id = $1)',
[orderId],
)
await query('DELETE FROM orders WHERE id = $1', [orderId])
}
// 已过账总账凭证按财务规则不可物理删除;测试只清理可重建销售投影。
await query(
`DELETE FROM sales_daily_facts
WHERE stat_date = (NOW() AT TIME ZONE 'Asia/Shanghai')::date`,
)
if (configId > 0) {
await query('DELETE FROM manual_fulfillment_configs WHERE id = $1', [configId])
}
if (adminId > 0) {
await query('DELETE FROM admin_users WHERE id = $1', [adminId])
}
await closeDb()
}
},
)
test(
'微博账号 CK 以账号级密文保存并可解密加载',
{ skip: !TEST_DATABASE_URL ? '未设置 SEMI_AUTO_TASK_TEST_DATABASE_URL' : false },
async () => {
process.env.DATABASE_URL = TEST_DATABASE_URL
process.env.SEMI_AUTO_CREDENTIAL_ENCRYPTION_KEY =
'integration-test-semi-auto-credential-key-123456'
const { closeDb, query, withTransaction } = await import('../../db/client.js')
const { loadSemiAutoCredential, upsertSemiAutoCredentialWithClient } =
await import('./semi-auto-credential-service.js')
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`
let credentialId = 0
try {
credentialId = await withTransaction((client) =>
upsertSemiAutoCredentialWithClient(client, {
platform: 'weibo',
source: 'sms',
payload: {
platformUid: `it-weibo-${suffix}`,
nickname: '集成测试微博',
cookies: [
{
name: 'SUB',
value: `secret-sub-${suffix}`,
domain: '.weibo.com',
path: '/',
secure: true,
httpOnly: true,
},
],
},
}),
)
const raw = await query<{
credential_ciphertext: string
credential_source: string
credential_status: string
}>(
`SELECT credential_ciphertext, credential_source, credential_status
FROM semi_auto_credentials WHERE id = $1`,
[credentialId],
)
assert.equal(raw.rows[0]?.credential_source, 'sms')
assert.equal(raw.rows[0]?.credential_status, 'valid')
assert.ok(raw.rows[0]?.credential_ciphertext.startsWith('enc:v1:'))
assert.equal(raw.rows[0]?.credential_ciphertext.includes(`secret-sub-${suffix}`), false)
const credential = await loadSemiAutoCredential(credentialId)
assert.equal(credential?.platformUid, `it-weibo-${suffix}`)
assert.equal(credential?.cookies[0]?.value, `secret-sub-${suffix}`)
} finally {
if (credentialId > 0) {
await query('DELETE FROM semi_auto_credentials WHERE id = $1', [credentialId])
}
delete process.env.SEMI_AUTO_CREDENTIAL_ENCRYPTION_KEY
await closeDb()
}
},
)
test(
'开发 Mock 可生成半自动履约测试单并回填任务',
{ 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-${suffix}`
const skuKey = `半自动Mock-${suffix}`
let orderId = 0
let configId = 0
try {
const result = await createSemiAutoMock({
orderNo,
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.equal(result.connectorMode, 'embedded')
assert.equal(result.customerPath, `/c/${encodeURIComponent(orderNo)}`)
const row = await query<{
channel: string
fulfillment_status: string
fulfillment_config_id: number
semi_auto_enabled: boolean
semi_auto_platform: string
connector_mode: string
login_status: string
binding_status: string
execution_status: string
}>(
`
SELECT
f.channel,
f.status AS fulfillment_status,
f.fulfillment_config_id,
cfg.semi_auto_enabled,
cfg.semi_auto_platform,
task.connector_mode,
task.login_status,
task.binding_status,
task.execution_status
FROM semi_auto_tasks task
JOIN fulfillments f ON f.id = task.fulfillment_id
JOIN manual_fulfillment_configs cfg ON cfg.id = task.manual_fulfillment_config_id
WHERE task.order_item_id = $1
`,
[result.orderItemId],
)
assert.equal(row.rows[0]?.channel, 'manual')
assert.equal(row.rows[0]?.fulfillment_status, 'assigned')
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')
} 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()
}
},
)
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()
}
},
)
@@ -0,0 +1,136 @@
import { createHttpError } from '../../utils/http.js'
import type {
ConnectorBindingSession,
ConnectorBrowserTicket,
ConnectorInjectionPayload,
ConnectorLoginSession,
SemiAutoConnectorMode,
} from './huya-connector-client.js'
import {
type WeiboSerializedCookie,
type WeiboStoredCredential,
type WeiboSmsSendResult,
weiboNativeConnector,
type WeiboCountryCode,
} from './weibo-native/weibo-native-connector.js'
/**
* 微博半自动 Connector 客户端。
*
* Mock 闭环阶段只实现 embedded(order_site 进程内);external 保留接口,
* 后续接生产微博 Connector 时再补远程请求。
*/
function assertEmbedded(mode: SemiAutoConnectorMode) {
if (mode !== 'embedded') {
throw createHttpError('微博半自动履约目前仅支持内置 Connector', {
statusCode: 503,
errorCode: 'weibo_connector_mode_unsupported',
})
}
}
export async function createWeiboLoginSession(
input: { taskId: number; orderNo: string },
mode: SemiAutoConnectorMode = 'embedded',
): Promise<ConnectorLoginSession> {
assertEmbedded(mode)
return weiboNativeConnector.createQrLoginSession(input)
}
export async function getWeiboLoginSession(
sessionId: string,
mode: SemiAutoConnectorMode = 'embedded',
): Promise<ConnectorLoginSession> {
assertEmbedded(mode)
return weiboNativeConnector.getQrLoginSession(sessionId)
}
export async function sendWeiboSmsCode(
input: {
taskId: number
mobile: string
countryCode: string
mfaId?: string
},
mode: SemiAutoConnectorMode = 'embedded',
): Promise<WeiboSmsSendResult> {
assertEmbedded(mode)
return weiboNativeConnector.sendSmsCode(input)
}
export async function loginWeiboSms(
input: {
taskId: number
orderNo: string
mobile: string
code: string
countryCode?: string
rid?: string
},
mode: SemiAutoConnectorMode = 'embedded',
): Promise<{ sessionId: string; session: ConnectorLoginSession }> {
assertEmbedded(mode)
return weiboNativeConnector.loginWithSms(input)
}
export function exportWeiboSessionCredential(
sessionId: string,
mode: SemiAutoConnectorMode = 'embedded',
): WeiboStoredCredential {
assertEmbedded(mode)
return weiboNativeConnector.exportSessionCredential(sessionId)
}
export async function restoreWeiboSessionFromCredential(
input: {
taskId: number
orderNo: string
platformUid: string
nickname: string
cookies: WeiboSerializedCookie[]
},
mode: SemiAutoConnectorMode = 'embedded',
): Promise<ConnectorLoginSession> {
assertEmbedded(mode)
return weiboNativeConnector.restoreSessionFromCredential(input)
}
export async function getWeiboCountryCodes(
mode: SemiAutoConnectorMode = 'embedded',
): Promise<WeiboCountryCode[]> {
assertEmbedded(mode)
return weiboNativeConnector.getCountryCodes()
}
export async function getWeiboBinding(
sessionId: string,
mode: SemiAutoConnectorMode = 'embedded',
): Promise<ConnectorBindingSession> {
assertEmbedded(mode)
return weiboNativeConnector.getBinding(sessionId)
}
export async function createWeiboBrowserTicket(
input: { sessionId: string; targetUrl?: string },
mode: SemiAutoConnectorMode = 'embedded',
): Promise<ConnectorBrowserTicket> {
assertEmbedded(mode)
return weiboNativeConnector.createBrowserTicket(input)
}
export async function consumeWeiboBrowserTicket(
ticket: string,
mode: SemiAutoConnectorMode = 'embedded',
): Promise<ConnectorInjectionPayload> {
assertEmbedded(mode)
return weiboNativeConnector.consumeBrowserTicket(ticket)
}
export async function revokeWeiboSession(
sessionId: string,
mode: SemiAutoConnectorMode = 'embedded',
): Promise<void> {
assertEmbedded(mode)
await weiboNativeConnector.revokeSession(sessionId)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,329 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { WEIBO_TICKET_PREFIX, WeiboNativeConnector } from './weibo-native-connector.js'
type CapturedCall = { url: string; init: RequestInit }
function jsonResponse(payload: unknown): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}
function createFetchMock(
handler: (url: string, init: RequestInit) => Response | Promise<Response>,
) {
const calls: CapturedCall[] = []
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: String(input.url)
const requestInit = init || {}
calls.push({ url, init: requestInit })
return handler(url, requestInit)
}) as typeof fetch
return { fetchImpl, calls }
}
function defaultHandler(url: string): Response {
if (url.includes('/sso/signin')) {
return new Response('<html></html>', {
status: 200,
headers: {
'content-type': 'text/html',
'set-cookie': 'X-CSRF-TOKEN=CK-demo; Path=/sso',
},
})
}
if (url.includes('/sso/v2/web/config')) {
return jsonResponse({
retcode: 20000000,
data: {
country_code: [
{ country_code: '86', local: { zh_CN: '中国' } },
{ country_code: '852', local: { zh_CN: '中国香港' } },
{ country_code: '886', local: { zh_CN: '中国台湾' } },
],
},
})
}
if (url.includes('/sso/v2/qrcode/image')) {
return jsonResponse({
retcode: 20000000,
data: { qrid: 'qr-1', image: 'https://v2.qr.weibo.cn/qr-demo.png' },
})
}
if (url.includes('/sso/v2/qrcode/check')) {
return jsonResponse({ retcode: 50114001, msg: '未使用', data: null })
}
if (url.startsWith('https://v2.qr.weibo.cn/')) {
return new Response(Buffer.from('fake-png'), {
status: 200,
headers: { 'content-type': 'image/png' },
})
}
if (url.includes('/sso/v2/sms/send')) {
return jsonResponse({ retcode: 20000000 })
}
if (url.includes('/sso/v2/login')) {
const headers = new Headers({
'content-type': 'application/json',
})
headers.append('set-cookie', 'SUB=demo-sub; Path=/; Domain=.weibo.com')
headers.append('set-cookie', 'PASSPORT_ONLY=demo-passport; Path=/sso')
return new Response(
JSON.stringify({
retcode: 20000000,
data: {
data: {
act: 'goto',
location: 'https://passport.weibo.com/sso/v2/goto?ticket=demo',
},
},
}),
{
status: 200,
headers,
},
)
}
if (url.includes('/sso/v2/goto')) {
return new Response(null, {
status: 302,
headers: {
location: 'https://weibo.com/',
'set-cookie': 'SUB=demo-sub; Path=/; Domain=.weibo.com',
},
})
}
if (url === 'https://weibo.com/') {
return new Response('<html></html>', {
status: 200,
headers: { 'content-type': 'text/html' },
})
}
if (url.includes('/ajax/side/info')) {
return jsonResponse({
data: {
user: {
id: 123456,
screen_name: '微博测试账号',
avatar_large: 'https://tvax1.sinaimg.cn/demo.jpg',
},
},
})
}
return new Response('not found', { status: 404 })
}
test('微博二维码会话解析 qrid 并把图片转成 base64', async () => {
const { fetchImpl, calls } = createFetchMock(defaultHandler)
const connector = new WeiboNativeConnector({ fetchImpl })
const session = await connector.createQrLoginSession({ taskId: 1, orderNo: 'MOCKWB1' })
assert.equal(session.status, 'waiting_scan')
assert.match(session.qrImage || '', /^data:image\/png;base64,/)
assert.ok(calls.some((call) => call.url.includes('qrcode/image?entry=miniblog&size=180')))
})
test('微博二维码未扫码时保持等待扫码状态', async () => {
const { fetchImpl } = createFetchMock(defaultHandler)
const connector = new WeiboNativeConnector({ fetchImpl })
const session = await connector.createQrLoginSession({ taskId: 1, orderNo: 'MOCKWB1' })
const polled = await connector.getQrLoginSession(session.sessionId)
assert.equal(polled.status, 'waiting_scan')
})
test('微博发送短信验证码使用大陆 11 位手机号', async () => {
const { fetchImpl, calls } = createFetchMock(defaultHandler)
const connector = new WeiboNativeConnector({ fetchImpl })
await connector.sendSmsCode({ taskId: 1, mobile: '18900762182' })
const call = calls.find((item) => item.url.includes('/sso/v2/sms/send'))
assert.ok(call)
const form = new URLSearchParams(String(call.init.body))
assert.equal(form.get('entry'), 'miniblog')
assert.equal(form.get('mobile'), 'T3JH3iRZfvXRi9K')
assert.equal(form.get('mfa_id'), null)
assert.equal(form.get('el'), '2')
})
test('微博短信发送支持其他国家码并按 00 前缀编码', async () => {
const { fetchImpl, calls } = createFetchMock(defaultHandler)
const connector = new WeiboNativeConnector({ fetchImpl })
await connector.sendSmsCode({
taskId: 1,
mobile: '61234567',
countryCode: '852',
})
const call = calls.find((item) => item.url.includes('/sso/v2/sms/send'))
assert.ok(call)
const form = new URLSearchParams(String(call.init.body))
assert.equal(form.get('mobile'), 'TiNs13aS9CXDe9Nva3')
})
test('微博国家码列表来自 web config 并缓存', async () => {
const { fetchImpl, calls } = createFetchMock(defaultHandler)
const connector = new WeiboNativeConnector({ fetchImpl })
const first = await connector.getCountryCodes()
const second = await connector.getCountryCodes()
assert.deepEqual(first, [
{ code: '86', name: '中国' },
{ code: '852', name: '中国香港' },
{ code: '886', name: '中国台湾' },
])
assert.deepEqual(second, first)
assert.equal(calls.filter((call) => call.url.includes('/sso/v2/web/config')).length, 1)
})
test('微博短信登录会带上发码响应下发的 Cookie', async () => {
const { fetchImpl, calls } = createFetchMock((url) => {
if (url.includes('/sso/v2/sms/send')) {
return new Response(JSON.stringify({ retcode: 20000000 }), {
status: 200,
headers: {
'content-type': 'application/json',
'set-cookie': 'SCF=send-cookie; Path=/',
},
})
}
return defaultHandler(url)
})
const connector = new WeiboNativeConnector({ fetchImpl })
await connector.sendSmsCode({ taskId: 1, mobile: '13800138000' })
const { session } = await connector.loginWithSms({
taskId: 1,
orderNo: 'MOCKWB1',
mobile: '13800138000',
code: '123456',
})
assert.equal(session.status, 'authenticated')
const redirectCall = calls.find((item) => item.url.includes('/sso/v2/goto'))
assert.ok(redirectCall)
const headers = redirectCall.init.headers as Record<string, string>
assert.match(headers.cookie || '', /SCF=send-cookie/)
})
test('微博短信登录建立会话并可生成一次性注入票据', async () => {
const { fetchImpl, calls } = createFetchMock(defaultHandler)
const connector = new WeiboNativeConnector({ fetchImpl })
await connector.sendSmsCode({ taskId: 1, mobile: '138 0013 8000' })
const { sessionId, session } = await connector.loginWithSms({
taskId: 1,
orderNo: 'MOCKWB1',
mobile: '138 0013 8000',
code: '123456',
})
assert.equal(session.status, 'authenticated')
assert.equal(session.huyaUid, '123456')
assert.equal(session.nickname, '微博测试账号')
const loginCall = calls.find((item) => item.url.includes('/sso/v2/login'))
assert.ok(loginCall)
const form = new URLSearchParams(String(loginCall.init.body))
assert.equal(form.get('type'), '2')
assert.equal(form.get('username'), 'T3Gs3iR_9fN7A9t')
assert.equal(form.get('scode'), 'T3r81iMS')
const ticket = await connector.createBrowserTicket({
sessionId,
targetUrl: 'https://weibo.com/',
})
assert.ok(ticket.ticket.startsWith(WEIBO_TICKET_PREFIX))
const payload = await connector.consumeBrowserTicket(ticket.ticket)
assert.equal(payload.platform, 'weibo')
assert.equal(payload.targetUrl, 'https://weibo.com/')
assert.ok(payload.cookies.some((cookie) => cookie.name === 'SUB' && cookie.value === 'demo-sub'))
await assert.rejects(() => connector.consumeBrowserTicket(ticket.ticket), /票据不存在或已过期/)
})
test('微博短信发送遇到 mfa_1 时返回易盾挑战,完成挑战后可用 mfa_id 重发', async () => {
let sendCount = 0
const { fetchImpl, calls } = createFetchMock((url, init) => {
if (url.includes('/sso/v2/sms/send')) {
sendCount += 1
const form = new URLSearchParams(String(init.body))
if (!form.get('mfa_id')) {
return jsonResponse({
retcode: 0,
msg: 'need mfa',
data: { act: 'mfa_1', mfa_id: 'mfa-demo' },
})
}
return jsonResponse({ retcode: 20000000, msg: '', data: [] })
}
return defaultHandler(url)
})
const connector = new WeiboNativeConnector({ fetchImpl })
const first = await connector.sendSmsCode({ taskId: 1, mobile: '13800138000' })
assert.equal(first.mfaRequired, true)
assert.equal(first.mfaId, 'mfa-demo')
assert.equal(first.captchaId, '7cda0ba2785647ada4d6e946ddb2d465')
const second = await connector.sendSmsCode({
taskId: 1,
mobile: '13800138000',
mfaId: 'mfa-demo',
})
assert.equal(second.mfaRequired, false)
assert.equal(sendCount, 2)
const secondForm = new URLSearchParams(
String(calls.filter((call) => call.url.includes('/sso/v2/sms/send'))[1]?.init.body),
)
assert.equal(secondForm.get('mfa_id'), 'mfa-demo')
})
test('微博 CK 可按账号级凭证导出并从序列化 Cookie 恢复会话', async () => {
const { fetchImpl } = createFetchMock(defaultHandler)
const connector = new WeiboNativeConnector({ fetchImpl })
await connector.sendSmsCode({ taskId: 1, mobile: '13800138000' })
const { sessionId } = await connector.loginWithSms({
taskId: 1,
orderNo: 'MOCKWB1',
mobile: '13800138000',
code: '123456',
})
const credential = connector.exportSessionCredential(sessionId)
assert.equal(credential.platformUid, '123456')
assert.ok(credential.cookies.some((cookie) => cookie.name === 'SUB'))
assert.ok(
credential.cookies.some(
(cookie) =>
cookie.name === 'PASSPORT_ONLY' &&
cookie.domain === 'passport.weibo.com' &&
cookie.path === '/sso',
),
)
const ticket = await connector.createBrowserTicket({
sessionId,
targetUrl: 'https://weibo.com/',
})
const injection = await connector.consumeBrowserTicket(ticket.ticket)
assert.equal(
injection.cookies.some((cookie) => cookie.name === 'PASSPORT_ONLY'),
false,
)
const restored = await connector.restoreSessionFromCredential({
taskId: 2,
orderNo: 'MOCKWB2',
platformUid: credential.platformUid,
nickname: credential.nickname,
cookies: credential.cookies,
})
assert.equal(restored.status, 'authenticated')
assert.equal(restored.hasCredential, true)
})
+14
View File
@@ -61,6 +61,20 @@ export type RuntimeConfig = {
claimBaseUrl: string
tokenTtlHours: number
}
semiAuto: {
/** 新建半自动任务的默认 Connector:内置真实实现、外部 Connector 或开发 Mock。 */
defaultConnectorMode: 'embedded' | 'external' | 'mock'
connectorBaseUrl: string
connectorApiKey: string
connectorTimeoutMs: number
/** 半自动平台账号 CK 的落库加密密钥;生产必填,本地缺省回退后台会话密钥。 */
credentialEncryptionKey?: string
huya: {
bindActId: number
outerActId: string
roomPid: string
}
}
admin: {
sessionSecret: string
/** 后台空闲多久未访问即失效(每次访问滑动续期)。 */