完善微博半自动凭证与本地开发环境

This commit is contained in:
yml
2026-11-13 22:52:51 +08:00
parent 90173e3bd7
commit 654c087c1b
32 changed files with 1701 additions and 223 deletions
+4 -1
View File
@@ -1,5 +1,8 @@
#!/bin/sh
set -eu
npm install --no-fund --no-audit
if [ ! -f node_modules/.package-lock.json ] \
|| [ package-lock.json -nt node_modules/.package-lock.json ]; then
npm install --no-fund --no-audit
fi
exec npm run dev
+1
View File
@@ -66,6 +66,7 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
connectorBaseUrl: '',
connectorApiKey: '',
connectorTimeoutMs: 15_000,
credentialEncryptionKey: '',
huya: {
bindActId: 9271,
outerActId: '17096',
@@ -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',
+1
View File
@@ -55,6 +55,7 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [
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']),
@@ -142,6 +142,22 @@ test('validateRuntimeConfig rejects unknown semi-auto connector mode', () => {
)
})
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(
() => {
@@ -208,6 +224,7 @@ function createRuntimeConfig(overrides: Record<string, unknown> = {}): RuntimeCo
connectorBaseUrl: '',
connectorApiKey: '',
connectorTimeoutMs: 15000,
credentialEncryptionKey: '0123456789abcdef0123456789abcdef',
huya: {
bindActId: 9271,
outerActId: '17096',
@@ -85,6 +85,13 @@ export function validateRuntimeConfig(
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',
@@ -134,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,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;
+33 -1
View File
@@ -6,12 +6,13 @@ import {
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, requireAdminRoles } from './session.js'
import { createJsonHandler, requireAdminPermission, requireAdminRoles } from './session.js'
const router = Router()
@@ -35,6 +36,37 @@ router.get(
}),
)
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)), {
@@ -118,9 +118,10 @@ router.post(
req.params.taskId,
req.body?.mobile,
req.body?.countryCode,
req.body?.mfaId,
),
{
successMessage: '验证码已发送',
successMessage: '验证码请求已处理',
errorMessage: '发送微博短信验证码失败',
scope: '[customer-fulfillment/:orderNo/tasks/:taskId/weibo/sms/send]',
},
@@ -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]
@@ -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),
],
)
}
@@ -32,14 +32,22 @@ import {
consumeWeiboBrowserTicket,
createWeiboBrowserTicket,
createWeiboLoginSession,
exportWeiboSessionCredential,
getWeiboBinding,
getWeiboCountryCodes,
getWeiboLoginSession,
loginWeiboSms,
restoreWeiboSessionFromCredential,
revokeWeiboSession,
sendWeiboSmsCode,
} from './weibo-connector-client.js'
import { WEIBO_TICKET_PREFIX } from './weibo-native/weibo-native-connector.js'
import {
buildSemiAutoCredentialCookieHeader,
loadSemiAutoCredential,
markSemiAutoCredentialCheck,
upsertSemiAutoCredentialWithClient,
} from './semi-auto-credential-service.js'
const SEMI_AUTO_PLATFORMS = new Set(['huya', 'weibo'])
type SemiAutoPlatform = 'huya' | 'weibo'
@@ -78,6 +86,10 @@ type TaskRow = {
execution_status: ExecutionStatus
connector_mode: SemiAutoConnectorMode
connector_session_id: string
credential_id: number | null
credential_status: 'unknown' | 'valid' | 'invalid' | 'error' | null
credential_source: string | null
credential_updated_at: string | null
huya_uid: string
platform_nickname: string
bind_game_account: string
@@ -187,7 +199,11 @@ function mapTask(task: TaskRow) {
executionStatus: task.execution_status,
connectorMode: task.connector_mode,
displayStatus: displayStatus(task),
connectorReady: Boolean(task.connector_session_id),
connectorReady: Boolean(task.connector_session_id || task.credential_id),
credentialReady: Boolean(task.credential_id),
credentialStatus: task.credential_status || 'unknown',
credentialSource: task.credential_source || '',
credentialUpdatedAt: task.credential_updated_at,
huyaUid: task.huya_uid,
platformNickname: task.platform_nickname,
bindGameAccount: task.bind_game_account,
@@ -273,12 +289,16 @@ const TASK_SELECT = `
cfg.platform_key AS manual_platform_key,
cfg.cost_mode AS manual_cost_mode,
cfg.credit_quantity AS manual_credit_quantity,
cfg.semi_auto_config_json
cfg.semi_auto_config_json,
credential.credential_status,
credential.credential_source,
credential.credential_updated_at
FROM semi_auto_tasks task
JOIN orders o ON o.id = task.order_id
JOIN order_items oi ON oi.id = task.order_item_id
LEFT JOIN manual_fulfillment_configs cfg ON cfg.id = task.manual_fulfillment_config_id
LEFT JOIN admin_users admin ON admin.id = task.assigned_admin_user_id
LEFT JOIN semi_auto_credentials credential ON credential.id = task.credential_id
`
async function loadTask(taskId: number): Promise<TaskRow> {
@@ -364,6 +384,64 @@ async function getBindingForTask(
: getHuyaBinding(sessionId, task.connector_mode)
}
async function ensureConnectorSessionForTask(task: TaskRow): Promise<TaskRow> {
if (task.execution_status === 'completed' || task.execution_status === 'cancelled') return task
if (task.connector_session_id) {
try {
const session = await getLoginSessionForTask(task, task.connector_session_id)
if (session.status === 'authenticated') return task
} catch {
// 进程重启或多实例切换后,内存会话会丢失;下面用已保存的账号 CK 恢复。
}
}
if (task.platform !== 'weibo' || !task.credential_id) return task
const credential = await loadSemiAutoCredential(task.credential_id)
if (!credential || credential.platform !== 'weibo') return task
try {
const session = await restoreWeiboSessionFromCredential(
{
taskId: task.id,
orderNo: task.platform_order_id,
platformUid: credential.platformUid,
nickname: credential.nickname,
cookies: credential.cookies,
},
task.connector_mode,
)
await markSemiAutoCredentialCheck(task.credential_id, { status: 'valid' })
await withTransaction(async (client) => {
await client.query(
`
UPDATE semi_auto_tasks
SET connector_session_id = $2,
login_status = 'authenticated',
huya_uid = CASE WHEN $3 <> '' THEN $3 ELSE huya_uid END,
platform_nickname = CASE WHEN $4 <> '' THEN $4 ELSE platform_nickname END,
last_error = '',
updated_at = NOW()
WHERE id = $1
`,
[task.id, session.sessionId, session.huyaUid, session.nickname],
)
await appendTaskEvent(client, task.id, {
eventType: 'credential_session_restored',
actorType: 'connector',
detail: {
credentialId: task.credential_id,
hasCredential: session.hasCredential,
},
})
})
return loadTask(task.id)
} catch (error) {
await markSemiAutoCredentialCheck(task.credential_id, {
status: 'invalid',
error: error instanceof Error ? error.message : String(error),
}).catch(() => undefined)
throw error
}
}
type SemiAutoCandidate = {
order_id: number
platform_order_id: string
@@ -534,11 +612,23 @@ async function updateLoginTask(
? 'waiting_operator'
: 'waiting_customer'
: task.execution_status
const storedCredential =
task.platform === 'weibo' && mapped.status === 'authenticated' && mapped.hasCredential
? exportWeiboSessionCredential(session.sessionId, task.connector_mode)
: null
await withTransaction(async (client) => {
const credentialId = storedCredential
? await upsertSemiAutoCredentialWithClient(client, {
platform: 'weibo',
source: 'qr',
payload: storedCredential,
})
: null
await client.query(
`
UPDATE semi_auto_tasks
SET login_status = $2,
credential_id = COALESCE($8, credential_id),
huya_uid = CASE WHEN $3 <> '' THEN $3 ELSE huya_uid END,
platform_nickname = CASE WHEN $4 <> '' THEN $4 ELSE platform_nickname END,
authenticated_at = CASE WHEN $2 = 'authenticated' THEN COALESCE(authenticated_at, NOW()) ELSE authenticated_at END,
@@ -560,6 +650,7 @@ async function updateLoginTask(
nextExecutionStatus,
autoConfirmWeiboBinding,
mapped.message,
credentialId,
],
)
await appendTaskEvent(client, task.id, {
@@ -761,7 +852,7 @@ export async function startPublicLogin(orderNoInput: unknown, taskIdInput: unkno
export async function pollPublicLogin(orderNoInput: unknown, taskIdInput: unknown) {
const orderNo = normalizeOrderNo(orderNoInput)
const taskId = normalizeId(taskIdInput)
const task = await loadTaskForOrder(orderNo, taskId)
const task = await ensureConnectorSessionForTask(await loadTaskForOrder(orderNo, taskId))
if (!task.connector_session_id) {
return { task: mapTask(task), login: null }
}
@@ -857,6 +948,7 @@ export async function sendPublicWeiboSmsCode(
taskIdInput: unknown,
mobileInput: unknown,
countryCodeInput: unknown = '86',
mfaIdInput: unknown = '',
) {
const orderNo = normalizeOrderNo(orderNoInput)
const taskId = normalizeId(taskIdInput)
@@ -868,18 +960,26 @@ export async function sendPublicWeiboSmsCode(
errorCode: 'semi_auto_task_closed',
})
}
await sendWeiboSmsCode(
String(mobileInput ?? '').trim(),
String(countryCodeInput ?? '86').trim() || '86',
const result = await sendWeiboSmsCode(
{
taskId: task.id,
mobile: String(mobileInput ?? '').trim(),
countryCode: String(countryCodeInput ?? '86').trim() || '86',
mfaId: String(mfaIdInput ?? '').trim(),
},
task.connector_mode,
)
return { ok: true, message: '验证码已发送,请注意查收' }
return {
ok: !result.mfaRequired,
message: result.mfaRequired ? '需要完成微博安全验证' : '验证码已发送,请注意查收',
...result,
}
}
export async function loginPublicWeiboSms(
orderNoInput: unknown,
taskIdInput: unknown,
input: { mobile?: unknown; code?: unknown; countryCode?: unknown } = {},
input: { mobile?: unknown; code?: unknown; countryCode?: unknown; rid?: unknown } = {},
) {
const orderNo = normalizeOrderNo(orderNoInput)
const taskId = normalizeId(taskIdInput)
@@ -900,10 +1000,15 @@ export async function loginPublicWeiboSms(
mobile: String(input.mobile ?? '').trim(),
code: String(input.code ?? '').trim(),
countryCode: String(input.countryCode ?? '86').trim() || '86',
rid: String(input.rid ?? '').trim(),
},
task.connector_mode,
)
const mapped = mapLoginUpdate(session)
const storedCredential =
session.status === 'authenticated' && session.hasCredential
? exportWeiboSessionCredential(sessionId, task.connector_mode)
: null
if (session.status !== 'authenticated') {
await withTransaction(async (client) => {
@@ -936,10 +1041,18 @@ export async function loginPublicWeiboSms(
const nextExecutionStatus: ExecutionStatus = 'waiting_operator'
await withTransaction(async (client) => {
const credentialId = storedCredential
? await upsertSemiAutoCredentialWithClient(client, {
platform: 'weibo',
source: 'sms',
payload: storedCredential,
})
: null
await client.query(
`
UPDATE semi_auto_tasks
SET connector_session_id = $2,
credential_id = COALESCE($5, credential_id),
login_status = 'authenticated',
binding_status = 'confirmed',
execution_status = 'waiting_operator',
@@ -955,7 +1068,7 @@ export async function loginPublicWeiboSms(
updated_at = NOW()
WHERE id = $1
`,
[task.id, sessionId, mapped.uid, mapped.nickname],
[task.id, sessionId, mapped.uid, mapped.nickname, credentialId],
)
await appendTaskEvent(client, task.id, {
eventType: 'login_status_changed',
@@ -1114,6 +1227,56 @@ export async function getAdminSemiAutoTask(taskIdInput: unknown) {
}
}
export async function exportAdminSemiAutoCredential(taskIdInput: unknown, session: AdminSession) {
const task = await loadTask(normalizeId(taskIdInput))
const adminUserId = Number(session.userId || 0)
if (task.assigned_admin_user_id && Number(task.assigned_admin_user_id) !== adminUserId) {
throw createHttpError('该任务已被其他客服领取', {
statusCode: 409,
errorCode: 'semi_auto_task_claimed',
})
}
if (!task.credential_id) {
throw createHttpError('当前任务尚未保存账号 CK', {
statusCode: 404,
errorCode: 'semi_auto_credential_not_found',
})
}
const credential = await loadSemiAutoCredential(task.credential_id)
if (!credential || credential.platform !== task.platform) {
throw createHttpError('账号 CK 不存在或与任务平台不一致', {
statusCode: 404,
errorCode: 'semi_auto_credential_not_found',
})
}
const config = safeParseJson(task.semi_auto_config_json)
const targets = Array.isArray(config.browserTargets) ? config.browserTargets : []
const configuredTarget = String(
targets.find((item): item is { url?: string } => Boolean(item && typeof item === 'object'))
?.url || '',
).trim()
const targetUrl = /^https:\/\//i.test(configuredTarget)
? configuredTarget
: credential.platform === 'weibo'
? 'https://weibo.com/'
: ''
const cookieHeader = targetUrl
? buildSemiAutoCredentialCookieHeader(credential.cookies, targetUrl)
: ''
return {
credentialId: credential.id,
platform: credential.platform,
platformUid: credential.platformUid,
nickname: credential.nickname,
source: credential.source,
status: credential.status,
updatedAt: credential.updatedAt,
targetUrl,
cookieHeader,
cookies: credential.cookies,
}
}
export async function claimAdminSemiAutoTask(taskIdInput: unknown, session: AdminSession) {
const taskId = normalizeId(taskIdInput)
const task = await loadTask(taskId)
@@ -1142,7 +1305,7 @@ export async function claimAdminSemiAutoTask(taskIdInput: unknown, session: Admi
export async function syncAdminSemiAutoTask(taskIdInput: unknown) {
const taskId = normalizeId(taskIdInput)
let task = await loadTask(taskId)
let task = await ensureConnectorSessionForTask(await loadTask(taskId))
if (!task.connector_session_id) return getAdminSemiAutoTask(taskId)
if (
task.login_status !== 'authenticated' &&
@@ -1166,7 +1329,7 @@ export async function createAdminBrowserSession(
session: AdminSession,
) {
const taskId = normalizeId(taskIdInput)
const task = await loadTask(taskId)
const task = await ensureConnectorSessionForTask(await loadTask(taskId))
const adminUserId = Number(session.userId || 0)
if (task.assigned_admin_user_id && Number(task.assigned_admin_user_id) !== adminUserId) {
throw createHttpError('该任务已被其他客服领取', {
@@ -1277,7 +1440,9 @@ export async function completeAdminSemiAutoTask(
session: AdminSession,
) {
const taskId = normalizeId(taskIdInput)
const current = await loadTask(taskId)
let current = await loadTask(taskId)
if (current.execution_status === 'completed') return getAdminSemiAutoTask(taskId)
current = await ensureConnectorSessionForTask(current)
if (current.execution_status === 'completed') return getAdminSemiAutoTask(taskId)
if (current.execution_status === 'cancelled') {
throw createHttpError('任务已取消,不能完成', {
@@ -340,6 +340,66 @@ test(
},
)
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 },
@@ -7,6 +7,9 @@ import type {
SemiAutoConnectorMode,
} from './huya-connector-client.js'
import {
type WeiboSerializedCookie,
type WeiboStoredCredential,
type WeiboSmsSendResult,
weiboNativeConnector,
type WeiboCountryCode,
} from './weibo-native/weibo-native-connector.js'
@@ -44,22 +47,55 @@ export async function getWeiboLoginSession(
}
export async function sendWeiboSmsCode(
mobile: string,
countryCode: string,
input: {
taskId: number
mobile: string
countryCode: string
mfaId?: string
},
mode: SemiAutoConnectorMode = 'embedded',
): Promise<void> {
): Promise<WeiboSmsSendResult> {
assertEmbedded(mode)
await weiboNativeConnector.sendSmsCode(mobile, countryCode)
return weiboNativeConnector.sendSmsCode(input)
}
export async function loginWeiboSms(
input: { taskId: number; orderNo: string; mobile: string; code: string; countryCode?: string },
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[]> {
@@ -7,7 +7,8 @@
* - 登录后账号信息
* - 一次性浏览器注入票据
*
* 手机号、验证码、Cookie 都只保存在内存会话中,不落库、不写日志。
* 手机号和验证码只保存在短期内存会话中;登录成功后的账号 Cookie 由任务服务
* 导出为账号级凭证,经 AES-256-GCM 加密后落库,不写日志。
*/
import crypto from 'node:crypto'
@@ -39,6 +40,7 @@ const SMS_COOKIE_TTL_MS = 10 * 60 * 1000
const COUNTRY_CODE_TTL_MS = 60 * 60 * 1000
const MAX_SESSIONS = 200
const TICKET_PREFIX = 'weibo-ticket-'
export const WEIBO_YIDUN_CAPTCHA_ID = '7cda0ba2785647ada4d6e946ddb2d465'
export type WeiboCountryCode = {
code: string
@@ -91,10 +93,205 @@ type WeiboAccount = {
type WeiboTicket = {
ticket: string
targetUrl: string
cookie: string
cookies: WeiboSerializedCookie[]
expiresAtMs: number
}
export type WeiboSerializedCookie = {
name: string
value: string
domain: string
path: string
secure: boolean
httpOnly: boolean
hostOnly?: boolean
expiresAt: string | null
}
type StoredWeiboCookie = WeiboSerializedCookie & {
domain: string
hostOnly: boolean
expiresAtMs: number
}
class WeiboCookieJar {
private readonly values = new Map<string, StoredWeiboCookie>()
addFromResponse(headers: Headers, requestUrl: string): void {
const url = new URL(requestUrl)
for (const raw of readSetCookie(headers)) {
this.addSetCookie(raw, url)
}
}
headerFor(requestUrl: string): string {
const url = new URL(requestUrl)
const nowMs = Date.now()
const pairs: string[] = []
for (const [key, cookie] of this.values) {
if (cookie.expiresAtMs > 0 && cookie.expiresAtMs <= nowMs) {
this.values.delete(key)
continue
}
if (!this.matches(cookie, url)) continue
pairs.push(`${cookie.name}=${cookie.value}`)
}
return pairs.join('; ')
}
value(requestUrl: string, name: string): string {
const url = new URL(requestUrl)
for (const cookie of this.values.values()) {
if (cookie.name === name && this.matches(cookie, url)) return cookie.value
}
return ''
}
has(requestUrl: string, name: string): boolean {
return Boolean(this.value(requestUrl, name))
}
serialize(requestUrl = WEIBO_BASE): WeiboSerializedCookie[] {
const url = new URL(requestUrl)
return Array.from(this.values.values())
.filter((cookie) => this.matches(cookie, url))
.map((cookie) => this.toSerialized(cookie))
.filter((cookie): cookie is WeiboSerializedCookie => cookie !== null)
}
serializeAll(): WeiboSerializedCookie[] {
return Array.from(this.values.values())
.map((cookie) => this.toSerialized(cookie))
.filter((cookie): cookie is WeiboSerializedCookie => cookie !== null)
}
static fromSerialized(cookies: WeiboSerializedCookie[]): WeiboCookieJar {
const jar = new WeiboCookieJar()
for (const cookie of cookies) {
const domain = String(cookie.domain || '')
.trim()
.toLowerCase()
if (!cookie.name || !domain) continue
const expiresAtMs = cookie.expiresAt ? Date.parse(cookie.expiresAt) : 0
jar.values.set(`${cookie.name}\u0000${domain}\u0000${cookie.path || '/'}`, {
name: cookie.name,
value: String(cookie.value || ''),
domain,
path: cookie.path || '/',
secure: cookie.secure === true,
httpOnly: cookie.httpOnly === true,
expiresAt: cookie.expiresAt || null,
hostOnly: typeof cookie.hostOnly === 'boolean' ? cookie.hostOnly : !domain.startsWith('.'),
expiresAtMs: Number.isFinite(expiresAtMs) ? expiresAtMs : 0,
})
}
return jar
}
clear(): void {
this.values.clear()
}
private toSerialized(cookie: StoredWeiboCookie): WeiboSerializedCookie | null {
if (cookie.expiresAtMs > 0 && cookie.expiresAtMs <= Date.now()) return null
return {
name: cookie.name,
value: cookie.value,
domain: cookie.domain,
path: cookie.path,
secure: cookie.secure,
httpOnly: cookie.httpOnly,
hostOnly: cookie.hostOnly,
expiresAt: cookie.expiresAtMs > 0 ? new Date(cookie.expiresAtMs).toISOString() : null,
}
}
private addSetCookie(raw: string, requestUrl: URL): void {
const parts = String(raw || '')
.split(';')
.map((part) => part.trim())
.filter(Boolean)
const first = parts.shift() || ''
const separator = first.indexOf('=')
if (separator <= 0) return
const name = first.slice(0, separator).trim()
const value = first.slice(separator + 1).trim()
if (!name) return
let domain = ''
let cookiePath = ''
let secure = false
let httpOnly = false
let expiresAtMs = 0
for (const part of parts) {
const index = part.indexOf('=')
const key = (index >= 0 ? part.slice(0, index) : part).trim().toLowerCase()
const attr = index >= 0 ? part.slice(index + 1).trim() : ''
if (key === 'domain') domain = attr.toLowerCase()
else if (key === 'path') cookiePath = attr || '/'
else if (key === 'secure') secure = true
else if (key === 'httponly') httpOnly = true
else if (key === 'max-age') {
const seconds = Number(attr)
if (Number.isFinite(seconds)) {
expiresAtMs = seconds <= 0 ? 1 : Date.now() + seconds * 1000
}
} else if (key === 'expires') {
const parsed = Date.parse(attr)
if (Number.isFinite(parsed)) expiresAtMs = parsed
}
}
const hostOnly = !domain
const normalizedDomain = (domain || requestUrl.hostname).toLowerCase()
if (
!hostOnly &&
requestUrl.hostname !== normalizedDomain.replace(/^\./, '') &&
!requestUrl.hostname.endsWith(normalizedDomain)
) {
return
}
const path = cookiePath || defaultCookiePath(requestUrl.pathname)
const key = `${name}\u0000${normalizedDomain}\u0000${path}`
if (expiresAtMs > 0 && expiresAtMs <= Date.now()) {
this.values.delete(key)
return
}
this.values.set(key, {
name,
value,
domain: normalizedDomain,
path,
secure,
httpOnly,
expiresAt: expiresAtMs > 0 ? new Date(expiresAtMs).toISOString() : null,
hostOnly,
expiresAtMs,
})
}
private matches(cookie: StoredWeiboCookie, url: URL): boolean {
if (cookie.secure && url.protocol !== 'https:') return false
const hostname = url.hostname.toLowerCase()
const cookieDomain = cookie.domain.replace(/^\./, '')
const domainMatches = cookie.hostOnly
? hostname === cookieDomain
: hostname === cookieDomain || hostname.endsWith(`.${cookieDomain}`)
if (!domainMatches) return false
const requestPath = url.pathname || '/'
return (
requestPath === cookie.path ||
requestPath.startsWith(cookie.path.endsWith('/') ? cookie.path : `${cookie.path}/`)
)
}
}
function defaultCookiePath(pathname: string): string {
const value = String(pathname || '/')
const index = value.lastIndexOf('/')
return index <= 0 ? '/' : value.slice(0, index)
}
type WeiboSession = {
sessionId: string
taskId: number
@@ -105,11 +302,32 @@ type WeiboSession = {
qrid: string
qrImage: string
message: string
cookie: string
cookies: WeiboCookieJar
account: WeiboAccount | null
tickets: Map<string, WeiboTicket>
}
type SmsLoginState = {
taskId: number
mobile: string
cookies: WeiboCookieJar
mfaId: string
createdAtMs: number
expiresAtMs: number
}
export type WeiboStoredCredential = {
platformUid: string
nickname: string
cookies: WeiboSerializedCookie[]
}
export type WeiboSmsSendResult = {
mfaRequired: boolean
mfaId?: string
captchaId?: string
}
export type WeiboNativeConnectorOptions = {
fetchImpl?: FetchLike
now?: () => number
@@ -121,31 +339,6 @@ function iso(value: number): string {
return new Date(value).toISOString()
}
function normalizeCookiePairs(cookie: string): Array<[string, string]> {
const pairs: Array<[string, string]> = []
for (const part of String(cookie || '').split(';')) {
const index = part.indexOf('=')
if (index <= 0) continue
const key = part.slice(0, index).trim()
const value = part.slice(index + 1).trim()
if (key) pairs.push([key, value])
}
return pairs
}
function cookieValue(cookie: string, key: string): string {
for (const [itemKey, itemValue] of normalizeCookiePairs(cookie)) {
if (itemKey === key) return itemValue
}
return ''
}
function buildCookieHeader(pairs: Map<string, string>): string {
return Array.from(pairs.entries())
.map(([key, value]) => `${key}=${value}`)
.join('; ')
}
function readSetCookie(headers: Headers): string[] {
const withGetSetCookie = headers as Headers & { getSetCookie?: () => string[] }
if (typeof withGetSetCookie.getSetCookie === 'function') {
@@ -156,7 +349,7 @@ function readSetCookie(headers: Headers): string[] {
export class WeiboNativeConnector {
private readonly sessions = new Map<string, WeiboSession>()
private readonly smsCookies = new Map<string, { cookie: string; expiresAtMs: number }>()
private readonly smsSessions = new Map<string, SmsLoginState>()
private countryCodeCache: { items: WeiboCountryCode[]; expiresAtMs: number } | null = null
private readonly fetchImpl: FetchLike
private readonly now: () => number
@@ -174,14 +367,16 @@ export class WeiboNativeConnector {
}): Promise<ConnectorLoginSession> {
this.prune()
const sessionId = crypto.randomBytes(18).toString('base64url')
const cookies = await this.createBootstrapSession()
const response = await this.fetchImpl(
`${PASSPORT_BASE}/sso/v2/qrcode/image?entry=miniblog&size=180`,
{
method: 'GET',
headers: this.jsonHeaders(),
headers: this.jsonHeaders(PASSPORT_BASE, cookies),
signal: AbortSignal.timeout(this.timeoutMs),
},
)
cookies.addFromResponse(response.headers, `${PASSPORT_BASE}/sso/v2/qrcode/image`)
if (!response.ok) {
throw createHttpError(`获取微博登录二维码失败:HTTP ${response.status}`, {
statusCode: 502,
@@ -211,7 +406,7 @@ export class WeiboNativeConnector {
qrid: String(payload.data.qrid),
qrImage,
message: '请使用微博 App 扫码登录',
cookie: '',
cookies,
account: null,
tickets: new Map(),
}
@@ -229,9 +424,10 @@ export class WeiboNativeConnector {
`&disp=popup&rid=norid&ver=${WEIBO_LOGIN_VER}`
const response = await this.fetchImpl(checkUrl, {
method: 'GET',
headers: this.jsonHeaders(),
headers: this.jsonHeaders(checkUrl, session.cookies),
signal: AbortSignal.timeout(this.timeoutMs),
})
session.cookies.addFromResponse(response.headers, checkUrl)
if (!response.ok) {
session.status = 'failed'
session.message = `微博扫码状态查询失败:HTTP ${response.status}`
@@ -250,11 +446,7 @@ export class WeiboNativeConnector {
session.message = '微博登录成功但缺少跳转地址'
return this.loginView(session)
}
await this.finishLogin(
session,
loginUrl,
buildCookieHeader(this.collectCookiePairs(response.headers)),
)
await this.finishLogin(session, loginUrl)
return this.loginView(session)
}
if ([50114000, 50114001].includes(retcode)) {
@@ -283,13 +475,15 @@ export class WeiboNativeConnector {
return this.countryCodeCache.items
}
try {
const cookies = await this.createBootstrapSession()
const body = new URLSearchParams({ entry: 'miniblog', source: 'miniblog' })
const response = await this.fetchImpl(`${PASSPORT_BASE}/sso/v2/web/config`, {
method: 'POST',
headers: this.formHeaders(),
headers: this.formHeaders(`${PASSPORT_BASE}/sso/v2/web/config`, cookies),
body,
signal: AbortSignal.timeout(this.timeoutMs),
})
cookies.addFromResponse(response.headers, `${PASSPORT_BASE}/sso/v2/web/config`)
if (response.ok) {
const payload = (await response.json().catch(() => null)) as {
retcode?: number
@@ -319,30 +513,75 @@ export class WeiboNativeConnector {
return FALLBACK_COUNTRY_CODES
}
async sendSmsCode(mobileInput: string, countryCodeInput: unknown = '86'): Promise<void> {
async sendSmsCode(input: {
taskId: number
mobile: string
countryCode?: unknown
mfaId?: string
}): Promise<WeiboSmsSendResult> {
this.prune()
const mobile = this.normalizeMobile(mobileInput, countryCodeInput)
const taskId = Number(input.taskId) || 0
if (taskId <= 0) {
throw createHttpError('半自动履约任务无效', {
statusCode: 400,
errorCode: 'weibo_task_id_required',
})
}
const mobile = this.normalizeMobile(input.mobile, input.countryCode)
const stateKey = this.smsStateKey(taskId, mobile)
let state = this.smsSessions.get(stateKey)
if (!state) {
state = {
taskId,
mobile,
cookies: await this.createBootstrapSession(),
mfaId: '',
createdAtMs: this.now(),
expiresAtMs: this.now() + SMS_COOKIE_TTL_MS,
}
this.smsSessions.set(stateKey, state)
}
const requestedMfaId = String(input.mfaId || '').trim()
if (requestedMfaId && requestedMfaId !== state.mfaId) {
throw createHttpError('微博安全验证已过期,请重新获取验证码', {
statusCode: 409,
errorCode: 'weibo_sms_mfa_expired',
})
}
const body = new URLSearchParams({
entry: 'miniblog',
mobile: encodeSsoField(mobile),
el: '2',
})
if (requestedMfaId) body.set('mfa_id', requestedMfaId)
const sendUrl = `${PASSPORT_BASE}/sso/v2/sms/send`
const response = await this.fetchImpl(`${PASSPORT_BASE}/sso/v2/sms/send`, {
method: 'POST',
headers: this.formHeaders(),
headers: this.formHeaders(sendUrl, state.cookies),
body,
signal: AbortSignal.timeout(this.timeoutMs),
})
state.cookies.addFromResponse(response.headers, sendUrl)
const payload = (await response.json().catch(() => null)) as {
retcode?: number
msg?: string
data?: { act?: string; mfa_id?: string }
} | null
if (Number(payload?.retcode) === 0 && String(payload?.data?.act || '') === 'mfa_1') {
throw createHttpError('微博要求二次验证,请改用扫码登录', {
statusCode: 409,
errorCode: 'weibo_sms_mfa_required',
})
const mfaId = String(payload?.data?.mfa_id || '').trim()
if (!mfaId) {
throw createHttpError('微博要求安全验证,但未返回验证标识', {
statusCode: 502,
errorCode: 'weibo_sms_mfa_invalid',
})
}
state.mfaId = mfaId
state.expiresAtMs = this.now() + SMS_COOKIE_TTL_MS
return {
mfaRequired: true,
mfaId,
captchaId: WEIBO_YIDUN_CAPTCHA_ID,
}
}
if (!response.ok || !payload || Number(payload.retcode) !== 20000000) {
throw createHttpError(payload?.msg || `发送微博短信验证码失败:HTTP ${response.status}`, {
@@ -350,13 +589,9 @@ export class WeiboNativeConnector {
errorCode: 'weibo_sms_send_failed',
})
}
const sendCookie = buildCookieHeader(this.collectCookiePairs(response.headers))
if (sendCookie) {
this.smsCookies.set(mobile, {
cookie: sendCookie,
expiresAtMs: this.now() + SMS_COOKIE_TTL_MS,
})
}
state.mfaId = ''
state.expiresAtMs = this.now() + SMS_COOKIE_TTL_MS
return { mfaRequired: false }
}
async loginWithSms(input: {
@@ -365,6 +600,7 @@ export class WeiboNativeConnector {
mobile: string
code: string
countryCode?: unknown
rid?: string
}): Promise<{ sessionId: string; session: ConnectorLoginSession }> {
this.prune()
const mobile = this.normalizeMobile(input.mobile, input.countryCode)
@@ -375,6 +611,14 @@ export class WeiboNativeConnector {
errorCode: 'weibo_sms_code_required',
})
}
const stateKey = this.smsStateKey(Number(input.taskId) || 0, mobile)
const state = this.smsSessions.get(stateKey)
if (!state) {
throw createHttpError('短信登录会话不存在或已过期,请重新获取验证码', {
statusCode: 409,
errorCode: 'weibo_sms_session_missing',
})
}
const body = new URLSearchParams({
entry: 'miniblog',
source: 'miniblog',
@@ -385,14 +629,16 @@ export class WeiboNativeConnector {
scode: encodeSsoField(code),
el: '2',
disp: 'popup',
rid: 'norid',
rid: String(input.rid || '').trim() || 'norid',
})
const loginUrl = `${PASSPORT_BASE}/sso/v2/login`
const response = await this.fetchImpl(`${PASSPORT_BASE}/sso/v2/login`, {
method: 'POST',
headers: this.formHeaders(),
headers: this.formHeaders(loginUrl, state.cookies),
body,
signal: AbortSignal.timeout(this.timeoutMs),
})
state.cookies.addFromResponse(response.headers, loginUrl)
const payload = (await response.json().catch(() => null)) as {
retcode?: number
msg?: string
@@ -435,15 +681,12 @@ export class WeiboNativeConnector {
qrid: '',
qrImage: '',
message: '微博登录成功',
cookie: '',
cookies: state.cookies,
account: null,
tickets: new Map(),
}
const pendingSmsCookie = this.smsCookies.get(mobile)
this.smsCookies.delete(mobile)
const responseCookie = buildCookieHeader(this.collectCookiePairs(response.headers))
const seedCookie = [pendingSmsCookie?.cookie || '', responseCookie].filter(Boolean).join('; ')
await this.finishLogin(session, location, seedCookie)
this.smsSessions.delete(stateKey)
await this.finishLogin(session, location)
this.sessions.set(session.sessionId, session)
return { sessionId: session.sessionId, session: this.loginView(session) }
}
@@ -464,7 +707,7 @@ export class WeiboNativeConnector {
targetUrl?: string
}): Promise<ConnectorBrowserTicket> {
const session = this.requireSession(input.sessionId)
if (!session.cookie) {
if (!session.cookies.has(WEIBO_BASE, 'SUB')) {
throw createHttpError('微博登录态不存在或已过期', {
statusCode: 404,
errorCode: 'weibo_credential_not_found',
@@ -476,7 +719,7 @@ export class WeiboNativeConnector {
session.tickets.set(ticket, {
ticket,
targetUrl,
cookie: session.cookie,
cookies: session.cookies.serialize(targetUrl),
expiresAtMs,
})
return { ticket, expiresAt: iso(expiresAtMs), targetUrl }
@@ -505,15 +748,7 @@ export class WeiboNativeConnector {
platform: 'weibo',
targetUrl: item.targetUrl,
expiresAt: iso(item.expiresAtMs),
cookies: normalizeCookiePairs(item.cookie)
.filter(([name, value]) => name && value)
.map(([name, value]) => ({
name,
value,
domain: '.weibo.com',
path: '/',
secure: true,
})),
cookies: item.cookies,
}
}
throw createHttpError('浏览器注入票据不存在或已过期', {
@@ -526,46 +761,97 @@ export class WeiboNativeConnector {
const sessionId = String(sessionIdInput || '').trim()
const session = this.sessions.get(sessionId)
if (!session) return
session.cookie = ''
session.cookies.clear()
session.tickets.clear()
this.sessions.delete(sessionId)
}
private async finishLogin(
session: WeiboSession,
loginUrl: string,
seedCookie = '',
): Promise<void> {
const { cookie } = await this.followWithCookies(loginUrl, seedCookie)
if (!cookie || !cookieValue(cookie, 'SUB')) {
exportSessionCredential(sessionIdInput: string): WeiboStoredCredential {
const session = this.requireSession(sessionIdInput)
if (!session.account?.uid || !session.cookies.has(WEIBO_BASE, 'SUB')) {
throw createHttpError('微博登录凭证尚未就绪', {
statusCode: 409,
errorCode: 'weibo_credential_not_ready',
})
}
return {
platformUid: session.account.uid,
nickname: session.account.nickname,
cookies: session.cookies.serializeAll(),
}
}
async restoreSessionFromCredential(input: {
taskId: number
orderNo: string
platformUid: string
nickname: string
cookies: WeiboSerializedCookie[]
}): Promise<ConnectorLoginSession> {
this.prune()
const cookies = WeiboCookieJar.fromSerialized(input.cookies)
if (!cookies.has(WEIBO_BASE, 'SUB')) {
throw createHttpError('微博 CK 已失效,请重新登录', {
statusCode: 409,
errorCode: 'weibo_credential_invalid',
})
}
const account = await this.fetchAccount(cookies.headerFor(WEIBO_BASE))
if (!account.uid) {
throw createHttpError('微博 CK 已失效,请重新登录', {
statusCode: 409,
errorCode: 'weibo_credential_invalid',
})
}
if (String(input.platformUid || '') && account.uid !== String(input.platformUid)) {
throw createHttpError('微博 CK 与任务账号不一致,请重新登录', {
statusCode: 409,
errorCode: 'weibo_credential_account_mismatch',
})
}
const nowMs = this.now()
const session: WeiboSession = {
sessionId: crypto.randomBytes(18).toString('base64url'),
taskId: Number(input.taskId) || 0,
orderNo: String(input.orderNo || ''),
createdAtMs: nowMs,
expiresAtMs: nowMs + CREDENTIAL_TTL_MS,
status: 'authenticated',
qrid: '',
qrImage: '',
message: '微博登录成功',
cookies,
account: {
...account,
nickname: account.nickname || String(input.nickname || ''),
},
tickets: new Map(),
}
this.sessions.set(session.sessionId, session)
return this.loginView(session)
}
private async finishLogin(session: WeiboSession, loginUrl: string): Promise<void> {
await this.followWithCookies(loginUrl, session.cookies)
if (!session.cookies.has(WEIBO_BASE, 'SUB')) {
session.status = 'failed'
session.message = '微博登录成功但未获取到登录 Cookie'
return
}
session.cookie = cookie
session.account = await this.fetchAccount(cookie)
session.account = await this.fetchAccount(session.cookies.headerFor(WEIBO_BASE))
if (!session.account.uid) {
session.status = 'failed'
session.message = '微博登录成功但账号信息读取失败'
return
}
session.status = 'authenticated'
session.message = '微博登录成功'
}
private collectCookiePairs(headers: Headers): Map<string, string> {
const pairs = new Map<string, string>()
for (const raw of readSetCookie(headers)) {
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) pairs.set(key, value)
}
return pairs
}
private async followWithCookies(
url: string,
seedCookie = '',
): Promise<{ cookie: string; finalUrl: string }> {
const pairs = new Map<string, string>(normalizeCookiePairs(seedCookie))
cookies: WeiboCookieJar,
): Promise<{ finalUrl: string }> {
let currentUrl = url
for (let index = 0; index < 8; index += 1) {
const response = await this.fetchImpl(currentUrl, {
@@ -574,14 +860,12 @@ export class WeiboNativeConnector {
'user-agent': PC_UA,
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'accept-language': 'zh-CN,zh;q=0.9',
...(pairs.size > 0 ? { cookie: buildCookieHeader(pairs) } : {}),
cookie: cookies.headerFor(currentUrl),
},
redirect: 'manual',
signal: AbortSignal.timeout(this.timeoutMs),
})
for (const [key, value] of this.collectCookiePairs(response.headers)) {
pairs.set(key, value)
}
cookies.addFromResponse(response.headers, currentUrl)
const location = response.headers.get('location')
if (response.status >= 300 && response.status < 400 && location) {
currentUrl = new URL(location, currentUrl).toString()
@@ -590,7 +874,7 @@ export class WeiboNativeConnector {
await response.arrayBuffer().catch(() => undefined)
break
}
return { cookie: buildCookieHeader(pairs), finalUrl: currentUrl }
return { finalUrl: currentUrl }
}
private async fetchAccount(cookie: string): Promise<WeiboAccount> {
@@ -654,7 +938,7 @@ export class WeiboNativeConnector {
expiresAt: iso(session.createdAtMs + LOGIN_QR_TTL_MS),
huyaUid: session.account?.uid || '',
nickname: session.account?.nickname || '',
hasCredential: Boolean(session.cookie),
hasCredential: session.cookies.has(WEIBO_BASE, 'SUB'),
message: session.message,
}
}
@@ -706,7 +990,38 @@ export class WeiboNativeConnector {
return `00${countryCode}${digits}`
}
private jsonHeaders(): Record<string, string> {
private async createBootstrapSession(): Promise<WeiboCookieJar> {
const cookies = new WeiboCookieJar()
let currentUrl = SIGNIN_REFERER
for (let index = 0; index < 5; index += 1) {
const response = await this.fetchImpl(currentUrl, {
method: 'GET',
headers: {
'user-agent': PC_UA,
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'accept-language': 'zh-CN,zh;q=0.9',
...(cookies.headerFor(currentUrl) ? { cookie: cookies.headerFor(currentUrl) } : {}),
},
redirect: 'manual',
signal: AbortSignal.timeout(this.timeoutMs),
})
cookies.addFromResponse(response.headers, currentUrl)
const location = response.headers.get('location')
if (response.status >= 300 && response.status < 400 && location) {
currentUrl = new URL(location, currentUrl).toString()
continue
}
await response.arrayBuffer().catch(() => undefined)
break
}
return cookies
}
private smsStateKey(taskId: number, mobile: string): string {
return `${taskId}:${mobile}`
}
private jsonHeaders(requestUrl: string, cookies: WeiboCookieJar): Record<string, string> {
return {
'user-agent': PC_UA,
origin: PASSPORT_BASE,
@@ -714,24 +1029,28 @@ export class WeiboNativeConnector {
accept: 'application/json, text/plain, */*',
'accept-language': 'zh-CN,zh;q=0.9',
'x-requested-with': 'XMLHttpRequest',
...(cookies.value(requestUrl, 'X-CSRF-TOKEN')
? { 'x-csrf-token': cookies.value(requestUrl, 'X-CSRF-TOKEN') }
: {}),
...(cookies.headerFor(requestUrl) ? { cookie: cookies.headerFor(requestUrl) } : {}),
}
}
private formHeaders(): Record<string, string> {
private formHeaders(requestUrl: string, cookies: WeiboCookieJar): Record<string, string> {
return {
...this.jsonHeaders(),
...this.jsonHeaders(requestUrl, cookies),
'content-type': 'application/x-www-form-urlencoded',
}
}
private prune(): void {
const nowMs = this.now()
for (const [mobile, item] of Array.from(this.smsCookies.entries())) {
if (item.expiresAtMs <= nowMs) this.smsCookies.delete(mobile)
for (const [key, item] of Array.from(this.smsSessions.entries())) {
if (item.expiresAtMs <= nowMs) this.smsSessions.delete(key)
}
for (const [sessionId, session] of Array.from(this.sessions.entries())) {
if (session.expiresAtMs <= nowMs) {
session.cookie = ''
session.cookies.clear()
session.tickets.clear()
this.sessions.delete(sessionId)
}
@@ -739,7 +1058,7 @@ export class WeiboNativeConnector {
if (this.sessions.size <= MAX_SESSIONS) return
const overflow = this.sessions.size - MAX_SESSIONS
for (const [sessionId, session] of Array.from(this.sessions.entries()).slice(0, overflow)) {
session.cookie = ''
session.cookies.clear()
session.tickets.clear()
this.sessions.delete(sessionId)
}
@@ -31,6 +31,15 @@ function createFetchMock(
}
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,
@@ -62,15 +71,26 @@ function defaultHandler(url: string): Response {
return jsonResponse({ retcode: 20000000 })
}
if (url.includes('/sso/v2/login')) {
return jsonResponse({
retcode: 20000000,
data: {
data: {
act: 'goto',
location: 'https://passport.weibo.com/sso/v2/goto?ticket=demo',
},
},
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, {
@@ -122,7 +142,7 @@ test('微博二维码未扫码时保持等待扫码状态', async () => {
test('微博发送短信验证码使用大陆 11 位手机号', async () => {
const { fetchImpl, calls } = createFetchMock(defaultHandler)
const connector = new WeiboNativeConnector({ fetchImpl })
await connector.sendSmsCode('18900762182')
await connector.sendSmsCode({ taskId: 1, mobile: '18900762182' })
const call = calls.find((item) => item.url.includes('/sso/v2/sms/send'))
assert.ok(call)
@@ -136,7 +156,11 @@ test('微博发送短信验证码使用大陆 11 位手机号', async () => {
test('微博短信发送支持其他国家码并按 00 前缀编码', async () => {
const { fetchImpl, calls } = createFetchMock(defaultHandler)
const connector = new WeiboNativeConnector({ fetchImpl })
await connector.sendSmsCode('61234567', '852')
await connector.sendSmsCode({
taskId: 1,
mobile: '61234567',
countryCode: '852',
})
const call = calls.find((item) => item.url.includes('/sso/v2/sms/send'))
assert.ok(call)
@@ -173,7 +197,7 @@ test('微博短信登录会带上发码响应下发的 Cookie', async () => {
return defaultHandler(url)
})
const connector = new WeiboNativeConnector({ fetchImpl })
await connector.sendSmsCode('13800138000')
await connector.sendSmsCode({ taskId: 1, mobile: '13800138000' })
const { session } = await connector.loginWithSms({
taskId: 1,
orderNo: 'MOCKWB1',
@@ -191,6 +215,7 @@ test('微博短信登录会带上发码响应下发的 Cookie', async () => {
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',
@@ -222,3 +247,83 @@ test('微博短信登录建立会话并可生成一次性注入票据', async ()
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)
})
+2
View File
@@ -67,6 +67,8 @@ export type RuntimeConfig = {
connectorBaseUrl: string
connectorApiKey: string
connectorTimeoutMs: number
/** 半自动平台账号 CK 的落库加密密钥;生产必填,本地缺省回退后台会话密钥。 */
credentialEncryptionKey?: string
huya: {
bindActId: number
outerActId: string
@@ -2,6 +2,7 @@ import {
CheckCircleOutlined,
ChromeOutlined,
CopyOutlined,
KeyOutlined,
ReloadOutlined,
UserSwitchOutlined,
} from '@ant-design/icons'
@@ -34,12 +35,14 @@ import {
claimAdminSemiAutoTask,
completeAdminSemiAutoTask,
createAdminSemiAutoBrowserSession,
exportAdminSemiAutoCredential,
fetchAdminSemiAutoTask,
fetchAdminSemiAutoTasks,
syncAdminSemiAutoTask,
} from '@/services/admin/semi-auto'
import type { SemiAutoTask } from '@/types/admin/semi-auto'
import type { SemiAutoCredentialExport, SemiAutoTask } from '@/types/admin/semi-auto'
import type { UploadedFile } from '@/types/worker-platform'
import { hasAdminPermission } from '@/utils/admin-auth'
import { formatAdminDateTime } from '@/utils/admin-time'
const { Text } = Typography
@@ -79,6 +82,7 @@ export default function AdminSemiAutoTasksPage() {
const [detailTaskId, setDetailTaskId] = useState<number | null>(null)
const [completeTask, setCompleteTask] = useState<SemiAutoTask | null>(null)
const [completeFiles, setCompleteFiles] = useState<UploadedFile[]>([])
const [credentialExport, setCredentialExport] = useState<SemiAutoCredentialExport | null>(null)
const [completeForm] = Form.useForm<CompleteFormValues>()
const params = useMemo(
@@ -172,6 +176,18 @@ export default function AdminSemiAutoTasksPage() {
message.error(error instanceof Error ? error.message : '打开浏览器登录态失败'),
})
const credentialExportMutation = useMutation({
mutationFn: (taskId: number) => exportAdminSemiAutoCredential(taskId),
onSuccess: (response) => {
if (!response.data) {
message.error('账号 CK 导出失败')
return
}
setCredentialExport(response.data)
},
onError: (error) => message.error(error instanceof Error ? error.message : '导出账号 CK 失败'),
})
const completeMutation = useMutation({
mutationFn: async (values: CompleteFormValues) => {
if (!completeTask) throw new Error('任务不存在')
@@ -223,6 +239,25 @@ export default function AdminSemiAutoTasksPage() {
.catch(() => message.error('复制失败,请手动复制'))
}
function copyCredentialText(text: string, label: string) {
void navigator.clipboard
.writeText(text)
.then(() => message.success(`${label}已复制`))
.catch(() => message.error('复制失败,请手动选择文本'))
}
function confirmExportCredential(task: SemiAutoTask) {
modal.confirm({
title: '导出账号 CK',
content:
'CK 等同于平台登录态。导出后会显示在当前页面,请勿发给无关人员;关闭弹窗后不会保留在页面状态中。',
okText: '确认导出',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: () => credentialExportMutation.mutateAsync(task.id),
})
}
function openComplete(task: SemiAutoTask) {
setCompleteTask(task)
setCompleteFiles([])
@@ -281,11 +316,21 @@ export default function AdminSemiAutoTasksPage() {
{
title: '客户登录',
key: 'login',
width: 150,
width: 180,
render: (_, task) => (
<Space direction="vertical" size={2}>
<Tag>{task.displayStatus.label}</Tag>
<Text type="secondary">{task.huyaUid ? `UID ${task.huyaUid}` : '未获取 UID'}</Text>
<Text type="secondary">
CK:
{task.credentialStatus === 'valid'
? '有效'
: task.credentialStatus === 'invalid'
? '失效'
: task.credentialStatus === 'error'
? '检测异常'
: '未保存'}
</Text>
</Space>
),
},
@@ -487,6 +532,32 @@ export default function AdminSemiAutoTasksPage() {
<Descriptions.Item label="平台昵称">
{detail.task.platformNickname || '-'}
</Descriptions.Item>
<Descriptions.Item label="账号 CK">
<Space size={6}>
<Tag
color={
detail.task.credentialStatus === 'valid'
? 'green'
: detail.task.credentialStatus === 'invalid'
? 'red'
: 'default'
}
>
{detail.task.credentialStatus === 'valid'
? '有效'
: detail.task.credentialStatus === 'invalid'
? '失效'
: detail.task.credentialStatus === 'error'
? '检测异常'
: '未保存'}
</Tag>
<Text type="secondary">
{detail.task.credentialSource
? `${detail.task.credentialSource} · ${formatAdminDateTime(detail.task.credentialUpdatedAt)}`
: '-'}
</Text>
</Space>
</Descriptions.Item>
<Descriptions.Item label={detail.task.platform === 'weibo' ? '微博昵称' : '游戏账号'}>
{detail.task.bindGameAccount || '-'}
</Descriptions.Item>
@@ -536,6 +607,17 @@ export default function AdminSemiAutoTasksPage() {
>
注入浏览器
</Button>
{detail.task.credentialReady &&
hasAdminPermission('semi_auto.credential.export') ? (
<Button
danger
icon={<KeyOutlined />}
loading={credentialExportMutation.isPending}
onClick={() => confirmExportCredential(detail.task)}
>
导出 CK
</Button>
) : null}
</Space>
</Card>
<Card size="small" title="时间线">
@@ -560,6 +642,65 @@ export default function AdminSemiAutoTasksPage() {
)}
</Drawer>
<Modal
open={Boolean(credentialExport)}
title={
credentialExport
? `账号 CK · ${credentialExport.platform === 'weibo' ? '微博' : '平台'} ${credentialExport.platformUid}`
: '账号 CK'
}
width={760}
footer={null}
onCancel={() => setCredentialExport(null)}
>
{credentialExport ? (
<Space direction="vertical" size={14} style={{ width: '100%' }}>
<Text type="danger">
以下内容包含平台登录态,仅用于本次受控操作。关闭弹窗后请勿在聊天、日志或工单中留存。
</Text>
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Text strong>{credentialExport.targetUrl || 'Cookie Header'}</Text>
<Button
size="small"
icon={<CopyOutlined />}
onClick={() => copyCredentialText(credentialExport.cookieHeader, 'Cookie Header')}
>
复制 Header
</Button>
</Space>
<Input.TextArea
value={credentialExport.cookieHeader}
readOnly
autoSize={{ minRows: 3, maxRows: 6 }}
/>
</Space>
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Text strong>完整域名 Cookie</Text>
<Button
size="small"
icon={<CopyOutlined />}
onClick={() =>
copyCredentialText(
JSON.stringify(credentialExport.cookies, null, 2),
'完整 Cookie JSON',
)
}
>
复制 JSON
</Button>
</Space>
<Input.TextArea
value={JSON.stringify(credentialExport.cookies, null, 2)}
readOnly
autoSize={{ minRows: 10, maxRows: 18 }}
/>
</Space>
</Space>
) : null}
</Modal>
<Modal
open={Boolean(completeTask)}
title="确认完成半自动履约"
@@ -47,6 +47,7 @@ import type {
CustomerSemiAutoTask,
} from '@/types/customer-fulfillment'
import { formatAdminDateTime } from '@/utils/admin-time'
import { runWeiboYidunChallenge } from '@/utils/weibo-yidun'
const { Text, Title, Paragraph } = Typography
@@ -301,17 +302,28 @@ export default function CustomerFulfillmentPage() {
}
setSmsSending(true)
try {
const response = await sendCustomerWeiboSmsCode(orderNo, selectedTask.id, phone, countryCode)
let response = await sendCustomerWeiboSmsCode(orderNo, selectedTask.id, phone, countryCode)
if (response.data.mfaRequired) {
setSmsMfaRequired(true)
if (!response.data.mfaId || !response.data.captchaId) {
throw new Error('微博返回的安全验证参数不完整')
}
await runWeiboYidunChallenge(response.data.mfaId, response.data.captchaId)
response = await sendCustomerWeiboSmsCode(
orderNo,
selectedTask.id,
phone,
countryCode,
response.data.mfaId,
)
if (response.data.mfaRequired) {
throw new Error('微博安全验证未通过,请重新获取验证码')
}
}
message.success(response.data.message || '验证码已发送,请注意查收')
setSmsMfaRequired(false)
setSmsCountdown(60)
} catch (error) {
const errorCode = (error as { errorCode?: string } | null)?.errorCode || ''
if (errorCode === 'weibo_sms_mfa_required') {
setSmsMfaRequired(true)
message.warning('微博要求二次验证,请改用扫码登录')
return
}
message.error(error instanceof Error ? error.message : '发送微博验证码失败')
} finally {
setSmsSending(false)
@@ -559,15 +571,10 @@ export default function CustomerFulfillmentPage() {
/>
{smsMfaRequired ? (
<Alert
type="warning"
type="info"
showIcon
message="微博要求二次验证"
description="当前网络环境触发了微博人机验证,短信登录无法自动完成,请改用扫码登录。"
action={
<Button size="small" onClick={() => setLoginMode('qr')}>
去扫码
</Button>
}
message="正在等待微博安全验证"
description="请在安全验证弹窗中完成验证,验证通过后会继续发送短信验证码。"
/>
) : null}
<Space.Compact style={{ width: '100%', maxWidth: 520 }}>
@@ -627,7 +634,7 @@ export default function CustomerFulfillmentPage() {
</Button>
</Space.Compact>
<Text type="secondary">
如微博要求二次验证导致登录失败,请改用扫码登录。
如安全验证弹窗未出现,请检查浏览器是否拦截脚本。
</Text>
</Space>
),
+12 -1
View File
@@ -1,5 +1,9 @@
import { apiGet, apiPost } from '@/lib/http'
import type { SemiAutoTaskDetail, SemiAutoTaskListResult } from '@/types/admin/semi-auto'
import type {
SemiAutoCredentialExport,
SemiAutoTaskDetail,
SemiAutoTaskListResult,
} from '@/types/admin/semi-auto'
export function fetchAdminSemiAutoTasks(params: Record<string, unknown> = {}) {
return apiGet<SemiAutoTaskListResult>('/api/v1/admin/semi-auto/tasks', params)
@@ -9,6 +13,13 @@ export function fetchAdminSemiAutoTask(taskId: number) {
return apiGet<SemiAutoTaskDetail>(`/api/v1/admin/semi-auto/tasks/${taskId}`)
}
export function exportAdminSemiAutoCredential(taskId: number) {
return apiPost<SemiAutoCredentialExport>(
`/api/v1/admin/semi-auto/tasks/${taskId}/credential/export`,
{},
)
}
export function claimAdminSemiAutoTask(taskId: number) {
return apiPost<SemiAutoTaskDetail>(`/api/v1/admin/semi-auto/tasks/${taskId}/claim`, {})
}
@@ -71,17 +71,25 @@ export function sendCustomerWeiboSmsCode(
taskId: number,
mobile: string,
countryCode: string,
mfaId = '',
) {
return apiPost<{ ok: boolean; message: string }>(
`/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/weibo/sms/send`,
{ mobile, countryCode },
)
return apiPost<{
ok: boolean
message: string
mfaRequired: boolean
mfaId?: string
captchaId?: string
}>(`/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/weibo/sms/send`, {
mobile,
countryCode,
mfaId,
})
}
export function loginCustomerWeiboSms(
orderNo: string,
taskId: number,
input: { mobile: string; code: string; countryCode: string },
input: { mobile: string; code: string; countryCode: string; rid?: string },
) {
return apiPost<{ task: CustomerSemiAutoTask; login: CustomerLoginInfo }>(
`/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/weibo/sms/login`,
@@ -18,6 +18,10 @@ export type SemiAutoTask = {
connectorMode: 'embedded' | 'external' | 'mock'
displayStatus: SemiAutoDisplayStatus
connectorReady: boolean
credentialReady: boolean
credentialStatus: 'unknown' | 'valid' | 'invalid' | 'error'
credentialSource: string
credentialUpdatedAt: string | null
huyaUid: string
platformNickname: string
bindGameAccount: string
@@ -73,3 +77,25 @@ export type SemiAutoTaskDetail = {
task: SemiAutoTask
events: SemiAutoTaskEvent[]
}
export type SemiAutoCredentialExport = {
credentialId: number
platform: SemiAutoPlatform
platformUid: string
nickname: string
source: string
status: 'unknown' | 'valid' | 'invalid' | 'error'
updatedAt: string
targetUrl: string
cookieHeader: string
cookies: Array<{
name: string
value: string
domain: string
path: string
secure: boolean
httpOnly: boolean
hostOnly?: boolean
expiresAt: string | null
}>
}
+70
View File
@@ -0,0 +1,70 @@
type WeiboYidunCallback = (params: unknown, message: string, success: boolean) => void
type WeiboYidunWindow = Window & {
ydInit?: (
options: {
geetestKey: string
captchaId: string
product: 'popup'
lang: string
},
callback: WeiboYidunCallback,
) => void
}
const YIDUN_SDK_URL = 'https://passport.sinaimg.cn/js/yidun/v1/yidunsdk.min.js'
let sdkPromise: Promise<void> | null = null
export function loadWeiboYidunSdk(): Promise<void> {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return Promise.reject(new Error('当前环境不支持微博安全验证'))
}
if ((window as WeiboYidunWindow).ydInit) return Promise.resolve()
if (sdkPromise) return sdkPromise
sdkPromise = new Promise<void>((resolve, reject) => {
const existing = document.querySelector<HTMLScriptElement>(`script[src="${YIDUN_SDK_URL}"]`)
const script = existing || document.createElement('script')
script.src = YIDUN_SDK_URL
script.async = true
script.onload = () => {
if ((window as WeiboYidunWindow).ydInit) {
resolve()
} else {
sdkPromise = null
reject(new Error('微博安全验证组件初始化失败'))
}
}
script.onerror = () => {
sdkPromise = null
reject(new Error('微博安全验证组件加载失败,请检查网络'))
}
if (!existing) document.head.appendChild(script)
})
return sdkPromise
}
export async function runWeiboYidunChallenge(mfaId: string, captchaId: string): Promise<void> {
await loadWeiboYidunSdk()
const ydInit = (window as WeiboYidunWindow).ydInit
if (!ydInit) throw new Error('微博安全验证组件不可用')
if (!mfaId || !captchaId) throw new Error('微博安全验证参数无效')
await new Promise<void>((resolve, reject) => {
ydInit(
{
geetestKey: mfaId,
captchaId,
product: 'popup',
lang: 'zh-CN',
},
(_params, message, success) => {
if (success) {
resolve()
return
}
reject(new Error(message === 'close' ? '已取消安全验证' : message || '微博安全验证失败'))
},
)
})
}