合并 dev_11.9_2 到 dev_11.13
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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 || {}), {
|
||||
|
||||
@@ -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> = {
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'&': '&',
|
||||
"'": ''',
|
||||
'"': '"',
|
||||
}
|
||||
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 }
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "commonjs"
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// hydevice 指纹运行器: 仿真 Android WebView 环境 -> 真实请求 df/token + df/collect -> 输出 sdid
|
||||
// argv: [stateDir, deviceJson] — deviceJson 为账号画像派生的设备覆盖参数 (一号一设备)
|
||||
process.on('uncaughtException', (e) => {
|
||||
console.error('[uncaught]', String(e && e.message).slice(0, 120))
|
||||
})
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const https = require('https')
|
||||
|
||||
globalThis.window = globalThis
|
||||
const stateDir = process.argv[2] || '.'
|
||||
let _deviceOverrides = null
|
||||
// Prefer an explicitly supplied JSON path; otherwise use the device hint that
|
||||
// get_huya_sdid writes into the per-account state directory.
|
||||
try {
|
||||
const hintPath =
|
||||
process.argv[3] && process.argv[3].endsWith('.json')
|
||||
? process.argv[3]
|
||||
: path.join(stateDir, 'device.json')
|
||||
_deviceOverrides = JSON.parse(fs.readFileSync(hintPath, 'utf8'))
|
||||
} catch (e) {}
|
||||
require(path.join(__dirname, 'env.js')).makeEnv(_deviceOverrides)
|
||||
|
||||
// ---- localStorage 持久化(设备稳定性) ----
|
||||
try {
|
||||
fs.mkdirSync(stateDir, { recursive: true })
|
||||
} catch (e) {}
|
||||
const lsFile = path.join(stateDir, 'localstorage.json')
|
||||
let _ls = {}
|
||||
try {
|
||||
_ls = JSON.parse(fs.readFileSync(lsFile, 'utf8'))
|
||||
} catch (e) {}
|
||||
globalThis.localStorage = {
|
||||
getItem: (k) => (k in _ls ? _ls[k] : null),
|
||||
setItem: (k, v) => {
|
||||
_ls[k] = String(v)
|
||||
try {
|
||||
fs.writeFileSync(lsFile, JSON.stringify(_ls))
|
||||
} catch (e) {}
|
||||
},
|
||||
removeItem: (k) => {
|
||||
delete _ls[k]
|
||||
try {
|
||||
fs.writeFileSync(lsFile, JSON.stringify(_ls))
|
||||
} catch (e) {}
|
||||
},
|
||||
clear: () => {
|
||||
_ls = {}
|
||||
try {
|
||||
fs.writeFileSync(lsFile, JSON.stringify(_ls))
|
||||
} catch (e) {}
|
||||
},
|
||||
key: (i) => Object.keys(_ls)[i] || null,
|
||||
get length() {
|
||||
return Object.keys(_ls).length
|
||||
},
|
||||
}
|
||||
globalThis.sessionStorage = {
|
||||
getItem: () => null,
|
||||
setItem() {},
|
||||
removeItem() {},
|
||||
clear() {},
|
||||
key: () => null,
|
||||
get length() {
|
||||
return 0
|
||||
},
|
||||
}
|
||||
|
||||
// ---- 真实 XHR ----
|
||||
function xhr(url, body, headers) {
|
||||
return new Promise((resolve) => {
|
||||
const u = new URL(url)
|
||||
const data = body == null ? null : Buffer.from(String(body), 'utf8')
|
||||
const req = https.request(
|
||||
{
|
||||
hostname: u.hostname,
|
||||
port: 443,
|
||||
path: u.pathname + u.search,
|
||||
method: 'POST',
|
||||
headers: Object.assign(
|
||||
{
|
||||
accept: 'application/json, text/javascript, */*; q=0.01',
|
||||
'content-type': 'application/json',
|
||||
origin: 'https://aq.huya.com',
|
||||
referer: 'https://aq.huya.com/',
|
||||
'user-agent': globalThis.navigator.userAgent,
|
||||
'accept-language': 'zh-CN,zh;q=0.9',
|
||||
},
|
||||
headers || {},
|
||||
),
|
||||
timeout: 15000,
|
||||
},
|
||||
(res) => {
|
||||
let chunks = []
|
||||
res.on('data', (c) => chunks.push(c))
|
||||
res.on('end', () =>
|
||||
resolve({ status: res.statusCode, text: Buffer.concat(chunks).toString('utf8') }),
|
||||
)
|
||||
},
|
||||
)
|
||||
req.on('timeout', () => {
|
||||
req.destroy()
|
||||
resolve({ status: 0, text: '' })
|
||||
})
|
||||
req.on('error', () => resolve({ status: 0, text: '' }))
|
||||
if (data) req.write(data)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
globalThis.XMLHttpRequest = class {
|
||||
open(m, u) {
|
||||
this._m = m
|
||||
this._u = u
|
||||
this._h = {}
|
||||
}
|
||||
setRequestHeader(k, v) {
|
||||
this._h[k] = v
|
||||
}
|
||||
getAllResponseHeaders() {
|
||||
return ''
|
||||
}
|
||||
addEventListener(t, f) {
|
||||
if (t === 'load' || t === 'readystatechange') this._on = f
|
||||
}
|
||||
send(d) {
|
||||
xhr(this._u, d, this._h).then((r) => {
|
||||
this.status = r.status
|
||||
this.readyState = 4
|
||||
this.responseText = r.text
|
||||
this.response = r.text
|
||||
const done = () => {
|
||||
if (String(this._u).includes('/collect')) {
|
||||
try {
|
||||
const j = JSON.parse(r.text)
|
||||
if (j && j.data) {
|
||||
if (j.data.sdid) console.log('__SDID__' + j.data.sdid)
|
||||
if (j.data.hdid) console.log('__HDID__' + j.data.hdid)
|
||||
if (globalThis.document && globalThis.document.cookie) {
|
||||
console.log('__COOKIES__' + globalThis.document.cookie)
|
||||
}
|
||||
setTimeout(() => process.exit(0), 200)
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
try {
|
||||
this.onreadystatechange && this.onreadystatechange()
|
||||
} catch (e) {}
|
||||
try {
|
||||
this._on && this._on()
|
||||
} catch (e) {}
|
||||
try {
|
||||
this.onload && this.onload()
|
||||
} catch (e) {}
|
||||
}
|
||||
setTimeout(done, 30)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
eval(fs.readFileSync(path.join(__dirname, 'hydevice-prod-1.2.45-min.js'), 'utf8'))
|
||||
try {
|
||||
globalThis.initFingerprint(process.argv[3] || '5008')
|
||||
} catch (e) {
|
||||
console.error('[init]', e.message.slice(0, 120))
|
||||
}
|
||||
setTimeout(() => {
|
||||
console.error('__TIMEOUT__')
|
||||
process.exit(2)
|
||||
}, 25000)
|
||||
@@ -0,0 +1,497 @@
|
||||
/** 虎牙 Cookie 规范化、网页字段补齐与 QIMEI 指纹生成。 */
|
||||
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
export type CookiePair = [string, string]
|
||||
|
||||
const MASK_64 = 0xffffffffffffffffn
|
||||
const QIMEI_EPOCH_1582 = -12216643543000n
|
||||
const GAME_DID_CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_'
|
||||
|
||||
const DEFAULT_QIMEI_FEATURE = {
|
||||
screen: { resolution: '800x600', availResolution: '800x600' },
|
||||
navigator: {
|
||||
deviceMemory: 16,
|
||||
pdfViewerEnabled: true,
|
||||
cookieEnabled: true,
|
||||
productSub: '20030107',
|
||||
hardwareConcurrency: 10,
|
||||
},
|
||||
webgl: 'Google Inc. (Apple)~ANGLE (Apple, ANGLE Metal Renderer: Apple M4, Unspecified Version)',
|
||||
timezoneOffset: -480,
|
||||
}
|
||||
|
||||
const DEVICE_GPU_FEATURES: Record<string, [string, string]> = {
|
||||
xiaomi: ['Qualcomm', 'Adreno (TM) 650'],
|
||||
redmi: ['Qualcomm', 'Adreno (TM) 650'],
|
||||
vivo: ['Qualcomm', 'Adreno (TM) 650'],
|
||||
iqoo: ['Qualcomm', 'Adreno (TM) 650'],
|
||||
oppo: ['Qualcomm', 'Adreno (TM) 650'],
|
||||
oneplus: ['Qualcomm', 'Adreno (TM) 650'],
|
||||
samsung: ['ARM', 'Mali-G78'],
|
||||
honor: ['ARM', 'Mali-G78'],
|
||||
motorola: ['Qualcomm', 'Adreno (TM) 642L'],
|
||||
realme: ['Qualcomm', 'Adreno (TM) 650'],
|
||||
nubia: ['Qualcomm', 'Adreno (TM) 650'],
|
||||
gionee: ['Qualcomm', 'Adreno (TM) 506'],
|
||||
}
|
||||
|
||||
const COOKIE_FILL_MAP: CookiePair[] = [
|
||||
['SoundValue', '0.50'],
|
||||
['alphaValue', '0.80'],
|
||||
['isInLiveRoom', 'true'],
|
||||
['udb_passdata', '3'],
|
||||
['udb_accdata', 'undefined'],
|
||||
]
|
||||
|
||||
export function cookiePairs(cookie: string): CookiePair[] {
|
||||
const pairs: CookiePair[] = []
|
||||
for (const part of String(cookie || '').split(';')) {
|
||||
const item = part.trim()
|
||||
if (!item || !item.includes('=')) continue
|
||||
const separator = item.indexOf('=')
|
||||
const key = item.slice(0, separator).trim()
|
||||
if (!key) continue
|
||||
pairs.push([key, item.slice(separator + 1).trim()])
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
export function normalizeCookiePairs(pairs: CookiePair[]): string {
|
||||
const orderedKeys: string[] = []
|
||||
const values = new Map<string, string>()
|
||||
for (const [rawKey, rawValue] of pairs) {
|
||||
const key = String(rawKey || '').trim()
|
||||
if (!key) continue
|
||||
if (!values.has(key)) orderedKeys.push(key)
|
||||
values.set(key, rawValue === null || rawValue === undefined ? '' : String(rawValue).trim())
|
||||
}
|
||||
return orderedKeys.map((key) => `${key}=${values.get(key) || ''}`).join('; ')
|
||||
}
|
||||
|
||||
export function cookieValue(cookie: string, key: string): string {
|
||||
let value = ''
|
||||
for (const [itemKey, itemValue] of cookiePairs(cookie)) {
|
||||
if (itemKey === key) value = itemValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function buildHuyaLoginContext(cookie: string): string {
|
||||
const guidData = cookieValue(cookie, 'udb_guiddata')
|
||||
const yamid = cookieValue(cookie, '__yamid_new')
|
||||
const guid = cookieValue(cookie, 'guid')
|
||||
if (!guidData || !yamid || !guid) return ''
|
||||
return `WB-${guidData}-${yamid}-${guid}`
|
||||
}
|
||||
|
||||
function replaceCookie(pairs: CookiePair[], key: string, value: string): void {
|
||||
const next = String(value ?? '').trim()
|
||||
if (!next) return
|
||||
for (let index = pairs.length - 1; index >= 0; index -= 1) {
|
||||
if (pairs[index]?.[0] === key) pairs.splice(index, 1)
|
||||
}
|
||||
pairs.push([key, next])
|
||||
}
|
||||
|
||||
function addCookie(pairs: CookiePair[], key: string, value: string): void {
|
||||
const next = String(value ?? '').trim()
|
||||
if (!next) return
|
||||
if (pairs.some(([itemKey]) => itemKey === key)) return
|
||||
pairs.push([key, next])
|
||||
}
|
||||
|
||||
export function mergeHuyaWebCookie(input: {
|
||||
cookie: string
|
||||
biztoken: string
|
||||
sdid: string
|
||||
hdid: string
|
||||
deviceInfo: Record<string, string>
|
||||
uid: number
|
||||
username?: string
|
||||
includeTransient?: boolean
|
||||
}): string {
|
||||
const pairs = cookiePairs(input.cookie)
|
||||
const username = String(input.username || '').trim()
|
||||
|
||||
// 扫码成功时服务端会通过 Set-Cookie 下发独立的 udb_cred;只有缺失时才用
|
||||
// biztoken 兜底,不能覆盖服务端签发的真实值。
|
||||
if (!cookieValue(input.cookie, 'udb_cred')) {
|
||||
replaceCookie(pairs, 'udb_cred', input.biztoken)
|
||||
}
|
||||
if (input.uid) {
|
||||
replaceCookie(pairs, 'udb_uid', String(input.uid))
|
||||
replaceCookie(pairs, 'yyuid', String(input.uid))
|
||||
}
|
||||
replaceCookie(pairs, 'udb_biztoken', input.biztoken)
|
||||
replaceCookie(pairs, 'udb_version', '1.0')
|
||||
replaceCookie(pairs, 'udb_origin', '0')
|
||||
replaceCookie(pairs, 'udb_status', '1')
|
||||
replaceCookie(pairs, 'sdid', input.sdid)
|
||||
replaceCookie(pairs, 'hdid', input.hdid)
|
||||
if (username.startsWith('hy_')) {
|
||||
replaceCookie(pairs, 'udb_passport', username)
|
||||
replaceCookie(pairs, 'username', username)
|
||||
}
|
||||
|
||||
const optionalKeys = [
|
||||
'guid',
|
||||
'udb_guiddata',
|
||||
'__yamid_new',
|
||||
'game_did',
|
||||
'_qimei_uuid42',
|
||||
'_qimei_fingerprint',
|
||||
'_qimei_h38',
|
||||
'udb_anouid',
|
||||
'udb_anobiztoken',
|
||||
'__yasmid',
|
||||
'__yamid_tt1',
|
||||
'SoundValue',
|
||||
'alphaValue',
|
||||
'isInLiveRoom',
|
||||
'udb_deviceid',
|
||||
'udb_passdata',
|
||||
'_rep_cnt',
|
||||
'Hm_lvt_51700b6c722f5bb4cf39906a596ea41f',
|
||||
'HMACCOUNT',
|
||||
'udb_appid',
|
||||
'rep_cnt',
|
||||
'udb_accdata',
|
||||
'h_unt',
|
||||
'__yaoldyyuid',
|
||||
'_yasids',
|
||||
'huya_flash_rep_cnt',
|
||||
'huyasp_rep_cnt',
|
||||
'huyawap_rep_cnt',
|
||||
'huya_hd_rep_cnt',
|
||||
'Hm_lpvt_51700b6c722f5bb4cf39906a596ea41f',
|
||||
'huya_web_rep_cnt',
|
||||
]
|
||||
for (const key of optionalKeys) {
|
||||
const value = String(input.deviceInfo[key] || '').trim()
|
||||
if (!value) continue
|
||||
for (let index = pairs.length - 1; index >= 0; index -= 1) {
|
||||
if (pairs[index]?.[0] === key) pairs.splice(index, 1)
|
||||
}
|
||||
pairs.push([key, value])
|
||||
}
|
||||
const webGuideData = String(input.deviceInfo.web_guiddata || input.deviceInfo.udb_guiddata || '')
|
||||
if (webGuideData) replaceCookie(pairs, 'udb_guiddata', webGuideData)
|
||||
const webDeviceId = String(input.deviceInfo.web_device_id || '')
|
||||
if (/^w_\d{19}$/.test(webDeviceId)) replaceCookie(pairs, 'udb_deviceid', webDeviceId)
|
||||
|
||||
return normalizeCookiePairs(
|
||||
input.includeTransient === false
|
||||
? pairs.filter(([key]) => key !== 'web_qrlogin_confirm_id')
|
||||
: pairs,
|
||||
)
|
||||
}
|
||||
|
||||
export type QimeiFeature = {
|
||||
screen: { resolution: string; availResolution: string }
|
||||
navigator: {
|
||||
deviceMemory: number | string
|
||||
pdfViewerEnabled: boolean
|
||||
cookieEnabled: boolean
|
||||
productSub: string
|
||||
hardwareConcurrency: number | string
|
||||
}
|
||||
webgl: string
|
||||
timezoneOffset: number
|
||||
}
|
||||
|
||||
export function qimeiFeatureFromDeviceProfile(
|
||||
deviceInfo: Record<string, unknown> | null | undefined,
|
||||
): QimeiFeature {
|
||||
const info = deviceInfo || {}
|
||||
const width = String(info.width || '1080')
|
||||
const height = String(info.height || '2400')
|
||||
const vendor = String(info.vendor || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
const model = String(info.model || 'Android WebView').trim()
|
||||
const [gpuVendor, renderer] = DEVICE_GPU_FEATURES[vendor] || ['Qualcomm', `${model} Graphics`]
|
||||
return {
|
||||
screen: { resolution: `${width}x${height}`, availResolution: `${width}x${height}` },
|
||||
navigator: {
|
||||
deviceMemory: (info.device_memory as number | string | undefined) || 8,
|
||||
pdfViewerEnabled: false,
|
||||
cookieEnabled: true,
|
||||
productSub: '20030107',
|
||||
hardwareConcurrency: (info.hardware_concurrency as number | string | undefined) || 8,
|
||||
},
|
||||
webgl: `Google Inc. (${gpuVendor})~ANGLE (${gpuVendor}, ${renderer})`,
|
||||
timezoneOffset: -480,
|
||||
}
|
||||
}
|
||||
|
||||
function jsText(value: unknown): string {
|
||||
if (value === null || value === undefined) return ''
|
||||
if (typeof value === 'boolean') return value ? 'true' : 'false'
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function qimeiFeatureString(feature: QimeiFeature): string {
|
||||
return (
|
||||
[
|
||||
feature.screen.resolution,
|
||||
feature.screen.availResolution,
|
||||
feature.navigator.deviceMemory,
|
||||
feature.navigator.pdfViewerEnabled,
|
||||
feature.navigator.cookieEnabled,
|
||||
feature.navigator.productSub,
|
||||
feature.navigator.hardwareConcurrency,
|
||||
feature.webgl,
|
||||
feature.timezoneOffset,
|
||||
]
|
||||
.map(jsText)
|
||||
.join('###') + '###'
|
||||
)
|
||||
}
|
||||
|
||||
function rotl64(value: bigint, shift: number): bigint {
|
||||
const normalizedShift = shift % 64
|
||||
if (normalizedShift === 0) return value & MASK_64
|
||||
return ((value << BigInt(normalizedShift)) | (value >> BigInt(64 - normalizedShift))) & MASK_64
|
||||
}
|
||||
|
||||
function fmix64(value: bigint): bigint {
|
||||
let next = value
|
||||
next ^= next >> 33n
|
||||
next = (next * 0xff51afd7ed558ccdn) & MASK_64
|
||||
next ^= next >> 33n
|
||||
next = (next * 0xc4ceb9fe1a85ec53n) & MASK_64
|
||||
next ^= next >> 33n
|
||||
return next & MASK_64
|
||||
}
|
||||
|
||||
export function x64hash128(value: string, seed = 0): string {
|
||||
const utf16 = Buffer.from(value, 'utf16le')
|
||||
const data = Buffer.alloc(utf16.length / 2)
|
||||
for (let index = 0; index < data.length; index += 1) data[index] = utf16[index * 2] || 0
|
||||
const length = data.length
|
||||
let h1 = BigInt(seed) & MASK_64
|
||||
let h2 = BigInt(seed) & MASK_64
|
||||
const c1 = 0x87c37b91114253d5n
|
||||
const c2 = 0x4cf5ad432745937fn
|
||||
|
||||
for (let offset = 0; offset < length - (length % 16); offset += 16) {
|
||||
let k1 = data.readBigUInt64LE(offset)
|
||||
let k2 = data.readBigUInt64LE(offset + 8)
|
||||
k1 = (rotl64((k1 * c1) & MASK_64, 31) * c2) & MASK_64
|
||||
h1 ^= k1
|
||||
h1 = (rotl64(h1, 27) + h2) & MASK_64
|
||||
h1 = (h1 * 5n + 0x52dce729n) & MASK_64
|
||||
k2 = (rotl64((k2 * c2) & MASK_64, 33) * c1) & MASK_64
|
||||
h2 ^= k2
|
||||
h2 = (rotl64(h2, 31) + h1) & MASK_64
|
||||
h2 = (h2 * 5n + 0x38495ab5n) & MASK_64
|
||||
}
|
||||
|
||||
const tailStart = length - (length % 16)
|
||||
const tail = data.subarray(tailStart)
|
||||
const tailLength = tail.length
|
||||
let k1 = 0n
|
||||
let k2 = 0n
|
||||
for (let index = tailLength - 1; index > 7; index -= 1) {
|
||||
k2 ^= BigInt(tail[index] || 0) << BigInt(8 * (index - 8))
|
||||
}
|
||||
if (tailLength >= 9) h2 ^= (rotl64((k2 * c2) & MASK_64, 33) * c1) & MASK_64
|
||||
for (let index = Math.min(tailLength, 8) - 1; index > 0; index -= 1) {
|
||||
k1 ^= BigInt(tail[index] || 0) << BigInt(8 * index)
|
||||
}
|
||||
if (tailLength >= 1) {
|
||||
k1 ^= BigInt(tail[0] || 0) << 32n
|
||||
h1 ^= (rotl64((k1 * c1) & MASK_64, 31) * c2) & MASK_64
|
||||
}
|
||||
|
||||
h1 ^= BigInt(length)
|
||||
h2 ^= BigInt(length)
|
||||
h1 = (h1 + h2) & MASK_64
|
||||
h2 = (h2 + h1) & MASK_64
|
||||
h1 = fmix64(h1)
|
||||
h2 = fmix64(h2)
|
||||
h1 = (h1 + h2) & MASK_64
|
||||
h2 = (h2 + h1) & MASK_64
|
||||
return `${h1.toString(16).padStart(16, '0')}${h2.toString(16).padStart(16, '0')}`
|
||||
}
|
||||
|
||||
export function qimeiFingerprintGenerate(feature?: QimeiFeature): string {
|
||||
return x64hash128(qimeiFeatureString(feature || DEFAULT_QIMEI_FEATURE), 31)
|
||||
}
|
||||
|
||||
function crc32(buffer: Buffer): number {
|
||||
let crc = 0xffffffff
|
||||
for (const byte of buffer) {
|
||||
crc ^= byte
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0)
|
||||
}
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0
|
||||
}
|
||||
|
||||
function returnBase16(value: bigint): string {
|
||||
if (value <= 0n) return ''
|
||||
return value.toString(16).toUpperCase()
|
||||
}
|
||||
|
||||
function generateBits(value: bigint, startBit: number, endBit: number): string {
|
||||
const hex = returnBase16(value)
|
||||
let output = ''
|
||||
for (let index = Math.floor(startBit / 4); index <= Math.floor(endBit / 4); index += 1) {
|
||||
output += hex[index] || '0'
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
function randomInt(maxExclusive: number): number {
|
||||
return Math.floor(Math.random() * maxExclusive)
|
||||
}
|
||||
|
||||
function yamidNewGenerate32(): string {
|
||||
const nowMs = BigInt(Date.now())
|
||||
const value = nowMs - QIMEI_EPOCH_1582
|
||||
return [
|
||||
generateBits(value, 0, 31),
|
||||
generateBits(value, 32, 47),
|
||||
`${generateBits(value, 48, 59)}1`,
|
||||
generateBits(BigInt(randomInt(4095)), 0, 7),
|
||||
generateBits(BigInt(randomInt(4095)), 0, 7),
|
||||
generateBits(BigInt(randomInt(8191)), 0, 7) +
|
||||
generateBits(BigInt(randomInt(8191)), 8, 15) +
|
||||
generateBits(BigInt(randomInt(8191)), 0, 7) +
|
||||
generateBits(BigInt(randomInt(8191)), 8, 15) +
|
||||
generateBits(BigInt(randomInt(8191)), 0, 15),
|
||||
].join('')
|
||||
}
|
||||
|
||||
function gameDidGenerate(length = 35): string {
|
||||
let output = ''
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
output += GAME_DID_CHARS[randomInt(GAME_DID_CHARS.length - 1)] || ''
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
export function qimeiUuid42(
|
||||
nowMs = Date.now(),
|
||||
options: { cookieEnabled?: boolean; randomValue?: number } = {},
|
||||
): string {
|
||||
const current = new Date(nowMs)
|
||||
const yearHex = (current.getFullYear() % 100).toString(16).padStart(2, '0')
|
||||
const monthHex = (current.getMonth() + 1).toString(16)
|
||||
const dateHex = [
|
||||
current.getDate(),
|
||||
current.getHours(),
|
||||
current.getMinutes(),
|
||||
current.getSeconds(),
|
||||
]
|
||||
.map((value) => value.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
const prefix = yearHex + monthHex + dateHex + (options.cookieEnabled === false ? '000' : '100')
|
||||
const randomSource = () => options.randomValue ?? Math.random()
|
||||
const randomSegment = Array.from({ length: 20 }, () =>
|
||||
Math.min(15, Math.max(0, Math.trunc(randomSource() * 16))).toString(16),
|
||||
).join('')
|
||||
const template = `${prefix}----${randomSegment}----`
|
||||
const replacement = crc32(Buffer.from(template, 'ascii')).toString(16).padStart(8, '0')
|
||||
return prefix + replacement.slice(0, 4) + randomSegment + replacement.slice(4)
|
||||
}
|
||||
|
||||
export function fillWebCookieFields(input: {
|
||||
cookie: string
|
||||
guid: string
|
||||
udbGuiddata?: string
|
||||
uid?: number | string
|
||||
keepGuid?: boolean
|
||||
qimeiFeature?: QimeiFeature
|
||||
}): string {
|
||||
const pairs = new Map<string, string>()
|
||||
for (const [key, value] of cookiePairs(input.cookie)) pairs.set(key, value)
|
||||
const add = (key: string, value: string) => {
|
||||
if (value && !pairs.get(key)) pairs.set(key, value)
|
||||
}
|
||||
const uid = Number(input.uid || 0) || 0
|
||||
|
||||
if (input.guid) {
|
||||
if (!(input.keepGuid !== false && pairs.get('guid'))) add('guid', input.guid)
|
||||
} else {
|
||||
add(
|
||||
'guid',
|
||||
`0a${crypto.randomBytes(5).toString('hex')}966a${crypto.randomBytes(8).toString('hex')}`,
|
||||
)
|
||||
}
|
||||
add('udb_guiddata', input.udbGuiddata || crypto.randomBytes(16).toString('hex'))
|
||||
add('udb_appid', '5002')
|
||||
add('_qimei_uuid42', qimeiUuid42())
|
||||
add('__yamid_new', yamidNewGenerate32())
|
||||
add('game_did', gameDidGenerate())
|
||||
add('_qimei_fingerprint', qimeiFingerprintGenerate(input.qimeiFeature))
|
||||
add('_qimei_h38', pairs.get('_qimei_uuid42') || '')
|
||||
|
||||
const yasmid = Math.random().toFixed(17)
|
||||
add('__yasmid', yasmid)
|
||||
add('__yamid_tt1', yasmid)
|
||||
add('_rep_cnt', String(randomInt(10)))
|
||||
add('rep_cnt', String(randomInt(10)))
|
||||
add('h_unt', String(Math.floor(Date.now() / 1000)))
|
||||
if (uid) add('__yaoldyyuid', String(uid))
|
||||
add('_yasids', `__rootsid%3D${yamidNewGenerate32()}`)
|
||||
add('Hm_lvt_51700b6c722f5bb4cf39906a596ea41f', String(Math.floor(Date.now() / 1000)))
|
||||
add('Hm_lpvt_51700b6c722f5bb4cf39906a596ea41f', String(Math.floor(Date.now() / 1000)))
|
||||
add('HMACCOUNT', crypto.randomBytes(8).toString('hex').toUpperCase())
|
||||
add('huya_flash_rep_cnt', String(1 + randomInt(400)))
|
||||
add('huyasp_rep_cnt', String(1 + randomInt(40)))
|
||||
add('huya_hd_rep_cnt', String(1 + randomInt(40)))
|
||||
add('huya_web_rep_cnt', String(300 + randomInt(601)))
|
||||
for (const [key, value] of COOKIE_FILL_MAP) add(key, value)
|
||||
|
||||
return Array.from(pairs.entries())
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('; ')
|
||||
}
|
||||
|
||||
export type HuyaWebCookieIdentity = {
|
||||
cookie: string
|
||||
context: string
|
||||
guid: string
|
||||
udbGuiddata: string
|
||||
yamidNew: string
|
||||
}
|
||||
|
||||
/** 为网页扫码登录补齐稳定的设备身份字段,并生成服务端要求的 WB context。 */
|
||||
export function ensureHuyaWebCookieIdentity(input: {
|
||||
cookie?: string
|
||||
guid?: string
|
||||
udbGuiddata?: string
|
||||
uid?: number | string
|
||||
qimeiFeature?: QimeiFeature
|
||||
}): HuyaWebCookieIdentity {
|
||||
const fillInput: Parameters<typeof fillWebCookieFields>[0] = {
|
||||
cookie: String(input.cookie || ''),
|
||||
guid: String(input.guid || ''),
|
||||
}
|
||||
if (input.udbGuiddata) fillInput.udbGuiddata = input.udbGuiddata
|
||||
if (input.uid !== undefined) fillInput.uid = input.uid
|
||||
if (input.qimeiFeature) fillInput.qimeiFeature = input.qimeiFeature
|
||||
const cookie = fillWebCookieFields(fillInput)
|
||||
const pairs = cookiePairs(cookie)
|
||||
const set = (key: string, value: string) => {
|
||||
const next = value.trim()
|
||||
if (!next) return
|
||||
for (let index = pairs.length - 1; index >= 0; index -= 1) {
|
||||
if (pairs[index]?.[0] === key) pairs.splice(index, 1)
|
||||
}
|
||||
pairs.push([key, next])
|
||||
}
|
||||
set('udb_appid', '5002')
|
||||
const normalized = normalizeCookiePairs(pairs)
|
||||
return {
|
||||
cookie: normalized,
|
||||
context: buildHuyaLoginContext(normalized),
|
||||
guid: cookieValue(normalized, 'guid'),
|
||||
udbGuiddata: cookieValue(normalized, 'udb_guiddata'),
|
||||
yamidNew: cookieValue(normalized, '__yamid_new'),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/** 通过随项目携带的 hydevice runner 获取虎牙高信任 sdid。 */
|
||||
|
||||
import { execFile } from 'node:child_process'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { promisify } from 'node:util'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const FINGERPRINT_DIR = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'assets/fingerprint',
|
||||
)
|
||||
const RUNNER_PATH = path.join(FINGERPRINT_DIR, 'runner.js')
|
||||
const SDID_PREFIX = '__SDID__'
|
||||
const HDID_PREFIX = '__HDID__'
|
||||
const COOKIE_PREFIX = '__COOKIES__'
|
||||
|
||||
export type HuyaFingerprintResult = {
|
||||
sdid: string
|
||||
hdid: string
|
||||
cookies: Record<string, string>
|
||||
source: 'fingerprint' | 'fallback'
|
||||
message?: string
|
||||
}
|
||||
|
||||
export type FingerprintResolver = (
|
||||
stateDir: string,
|
||||
appId?: string,
|
||||
) => Promise<HuyaFingerprintResult>
|
||||
|
||||
function parseCookieText(text: string): Record<string, string> {
|
||||
const cookies: Record<string, string> = {}
|
||||
for (const part of text.split(';')) {
|
||||
const item = part.trim()
|
||||
if (!item || !item.includes('=')) continue
|
||||
const separator = item.indexOf('=')
|
||||
const key = item.slice(0, separator).trim()
|
||||
const value = item.slice(separator + 1).trim()
|
||||
if (key && value) cookies[key] = value
|
||||
}
|
||||
return cookies
|
||||
}
|
||||
|
||||
async function runFingerprintRunner(
|
||||
stateDir: string,
|
||||
appId = '5008',
|
||||
): Promise<HuyaFingerprintResult | null> {
|
||||
await fs.mkdir(stateDir, { recursive: true })
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(
|
||||
process.execPath,
|
||||
[RUNNER_PATH, stateDir, appId],
|
||||
{
|
||||
cwd: FINGERPRINT_DIR,
|
||||
timeout: 45_000,
|
||||
maxBuffer: 2 * 1024 * 1024,
|
||||
},
|
||||
)
|
||||
let sdid = ''
|
||||
let hdid = ''
|
||||
let cookies: Record<string, string> = {}
|
||||
for (const rawLine of stdout.split(/\r?\n/)) {
|
||||
const line = rawLine.trim()
|
||||
if (line.startsWith(SDID_PREFIX) && line.length > SDID_PREFIX.length + 20) {
|
||||
sdid = line.slice(SDID_PREFIX.length)
|
||||
} else if (line.startsWith(HDID_PREFIX) && line.length > HDID_PREFIX.length + 20) {
|
||||
hdid = line.slice(HDID_PREFIX.length)
|
||||
} else if (line.startsWith(COOKIE_PREFIX)) {
|
||||
cookies = parseCookieText(line.slice(COOKIE_PREFIX.length))
|
||||
}
|
||||
}
|
||||
if (sdid) return { sdid, hdid, cookies, source: 'fingerprint' }
|
||||
const detail = stderr.trim().split(/\r?\n/).pop() || 'runner 未返回 sdid'
|
||||
throw new Error(detail.slice(0, 160))
|
||||
} catch (error) {
|
||||
if (error instanceof Error) throw error
|
||||
throw new Error(String(error), { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
async function fallbackFingerprint(): Promise<HuyaFingerprintResult | null> {
|
||||
try {
|
||||
const tokenResponse = await fetch('https://df.huya.com/web/df/token', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
||||
body: JSON.stringify({ encryptVersion: '1.0.1', fingerprintVersion: '1.2.45' }),
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
})
|
||||
if (!tokenResponse.ok) return null
|
||||
const tokenPayload = (await tokenResponse.json()) as { data?: { token?: string } }
|
||||
const token = String(tokenPayload.data?.token || '').trim()
|
||||
if (!token) return null
|
||||
const collectResponse = await fetch('https://df.huya.com/web/df/collect', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
})
|
||||
if (!collectResponse.ok) return null
|
||||
const collectPayload = (await collectResponse.json()) as {
|
||||
data?: { sdid?: string; hdid?: string }
|
||||
}
|
||||
const sdid = String(collectPayload.data?.sdid || '').trim()
|
||||
if (!sdid) return null
|
||||
return {
|
||||
sdid,
|
||||
hdid: String(collectPayload.data?.hdid || '').trim(),
|
||||
cookies: {},
|
||||
source: 'fallback',
|
||||
message: '低信任指纹(降级)',
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const getHuyaFingerprint: FingerprintResolver = async (stateDir, appId = '5008') => {
|
||||
try {
|
||||
const result = await runFingerprintRunner(stateDir, appId)
|
||||
if (result) return result
|
||||
} catch {
|
||||
// runner 失败时使用低信任降级,保持与现有 Connector 一致的可恢复行为。
|
||||
}
|
||||
const fallback = await fallbackFingerprint()
|
||||
if (fallback) return fallback
|
||||
throw new Error('获取虎牙设备指纹失败')
|
||||
}
|
||||
|
||||
export async function removeFingerprintState(stateDir: string): Promise<void> {
|
||||
await fs.rm(stateDir, { recursive: true, force: true }).catch(() => undefined)
|
||||
}
|
||||
@@ -0,0 +1,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)
|
||||
})
|
||||
@@ -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
|
||||
/** 后台空闲多久未访问即失效(每次访问滑动续期)。 */
|
||||
|
||||
Reference in New Issue
Block a user