diff --git a/.env.mac-docker.example b/.env.mac-docker.example index 2bfeb72e..6fe6204f 100644 --- a/.env.mac-docker.example +++ b/.env.mac-docker.example @@ -74,6 +74,7 @@ SEMI_AUTO_DEFAULT_CONNECTOR_MODE=embedded SEMI_AUTO_CONNECTOR_BASE_URL= SEMI_AUTO_CONNECTOR_API_KEY= SEMI_AUTO_CONNECTOR_TIMEOUT_MS=15000 +SEMI_AUTO_CREDENTIAL_ENCRYPTION_KEY=dev-semi-auto-credential-encryption-key # 快手行业电子凭证 KUASHOU_INDUSTRY_APP_KEY= diff --git a/.env.server.example b/.env.server.example index 9e7eff0a..005e490c 100644 --- a/.env.server.example +++ b/.env.server.example @@ -140,6 +140,7 @@ SEMI_AUTO_CONNECTOR_BASE_URL= SEMI_AUTO_CONNECTOR_API_KEY= SEMI_AUTO_DEFAULT_CONNECTOR_MODE=embedded SEMI_AUTO_CONNECTOR_TIMEOUT_MS=15000 +SEMI_AUTO_CREDENTIAL_ENCRYPTION_KEY=change-me-semi-auto-credential-encryption-key # 快手行业电子凭证 KUASHOU_INDUSTRY_APP_KEY= diff --git a/apps/backend/scripts/start-dev-container.sh b/apps/backend/scripts/start-dev-container.sh index 69990b1c..fa08c45d 100644 --- a/apps/backend/scripts/start-dev-container.sh +++ b/apps/backend/scripts/start-dev-container.sh @@ -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 diff --git a/apps/backend/src/config/defaults.ts b/apps/backend/src/config/defaults.ts index 106aa968..02154954 100644 --- a/apps/backend/src/config/defaults.ts +++ b/apps/backend/src/config/defaults.ts @@ -66,6 +66,7 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig { connectorBaseUrl: '', connectorApiKey: '', connectorTimeoutMs: 15_000, + credentialEncryptionKey: '', huya: { bindActId: 9271, outerActId: '17096', diff --git a/apps/backend/src/config/env-documentation.test.ts b/apps/backend/src/config/env-documentation.test.ts index 65a8dd84..15c3b361 100644 --- a/apps/backend/src/config/env-documentation.test.ts +++ b/apps/backend/src/config/env-documentation.test.ts @@ -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', diff --git a/apps/backend/src/config/env-overrides.ts b/apps/backend/src/config/env-overrides.ts index 29c17b9c..9d5f1c2c 100644 --- a/apps/backend/src/config/env-overrides.ts +++ b/apps/backend/src/config/env-overrides.ts @@ -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']), diff --git a/apps/backend/src/config/runtime-validation.test.ts b/apps/backend/src/config/runtime-validation.test.ts index 73aa66c7..361c517a 100644 --- a/apps/backend/src/config/runtime-validation.test.ts +++ b/apps/backend/src/config/runtime-validation.test.ts @@ -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 = {}): RuntimeCo connectorBaseUrl: '', connectorApiKey: '', connectorTimeoutMs: 15000, + credentialEncryptionKey: '0123456789abcdef0123456789abcdef', huya: { bindActId: 9271, outerActId: '17096', diff --git a/apps/backend/src/config/runtime-validation.ts b/apps/backend/src/config/runtime-validation.ts index e6ea59cd..ec39e76c 100644 --- a/apps/backend/src/config/runtime-validation.ts +++ b/apps/backend/src/config/runtime-validation.ts @@ -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)) { diff --git a/apps/backend/src/db/migrations/156_semi_auto_credentials.sql b/apps/backend/src/db/migrations/156_semi_auto_credentials.sql new file mode 100644 index 00000000..4363583d --- /dev/null +++ b/apps/backend/src/db/migrations/156_semi_auto_credentials.sql @@ -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; diff --git a/apps/backend/src/routes/admin/semi-auto.ts b/apps/backend/src/routes/admin/semi-auto.ts index b7aaa160..51dccb47 100644 --- a/apps/backend/src/routes/admin/semi-auto.ts +++ b/apps/backend/src/routes/admin/semi-auto.ts @@ -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) : 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)), { diff --git a/apps/backend/src/routes/customer-fulfillment.ts b/apps/backend/src/routes/customer-fulfillment.ts index f04f798d..a9ac63b0 100644 --- a/apps/backend/src/routes/customer-fulfillment.ts +++ b/apps/backend/src/routes/customer-fulfillment.ts @@ -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]', }, diff --git a/apps/backend/src/services/admin/admin-rbac-service.ts b/apps/backend/src/services/admin/admin-rbac-service.ts index f286b21c..e520f356 100644 --- a/apps/backend/src/services/admin/admin-rbac-service.ts +++ b/apps/backend/src/services/admin/admin-rbac-service.ts @@ -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] diff --git a/apps/backend/src/services/semi-auto/semi-auto-credential-crypto.test.ts b/apps/backend/src/services/semi-auto/semi-auto-credential-crypto.test.ts new file mode 100644 index 00000000..0136fc43 --- /dev/null +++ b/apps/backend/src/services/semi-auto/semi-auto-credential-crypto.test.ts @@ -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/', + ), + '', + ) +}) diff --git a/apps/backend/src/services/semi-auto/semi-auto-credential-crypto.ts b/apps/backend/src/services/semi-auto/semi-auto-credential-crypto.ts new file mode 100644 index 00000000..1c2a4902 --- /dev/null +++ b/apps/backend/src/services/semi-auto/semi-auto-credential-crypto.ts @@ -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') +} diff --git a/apps/backend/src/services/semi-auto/semi-auto-credential-service.ts b/apps/backend/src/services/semi-auto/semi-auto-credential-service.ts new file mode 100644 index 00000000..83a12ba1 --- /dev/null +++ b/apps/backend/src/services/semi-auto/semi-auto-credential-service.ts @@ -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 => + 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 + 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, + input: { + platform: 'huya' | 'weibo' + source: SemiAutoCredentialSource + payload: SemiAutoCredentialPayload + }, +): Promise { + 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 { + const credentialId = Number(credentialIdInput || 0) + if (!Number.isInteger(credentialId) || credentialId <= 0) return null + const result = await query( + ` + 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 { + 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), + ], + ) +} diff --git a/apps/backend/src/services/semi-auto/semi-auto-task-service.ts b/apps/backend/src/services/semi-auto/semi-auto-task-service.ts index 5f0471d0..3b60b4ee 100644 --- a/apps/backend/src/services/semi-auto/semi-auto-task-service.ts +++ b/apps/backend/src/services/semi-auto/semi-auto-task-service.ts @@ -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 { @@ -364,6 +384,64 @@ async function getBindingForTask( : getHuyaBinding(sessionId, task.connector_mode) } +async function ensureConnectorSessionForTask(task: TaskRow): Promise { + 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('任务已取消,不能完成', { diff --git a/apps/backend/src/services/semi-auto/semi-auto-task.integration.test.ts b/apps/backend/src/services/semi-auto/semi-auto-task.integration.test.ts index a8a4198b..3ec65fc5 100644 --- a/apps/backend/src/services/semi-auto/semi-auto-task.integration.test.ts +++ b/apps/backend/src/services/semi-auto/semi-auto-task.integration.test.ts @@ -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 }, diff --git a/apps/backend/src/services/semi-auto/weibo-connector-client.ts b/apps/backend/src/services/semi-auto/weibo-connector-client.ts index 8bc5809b..bc9d50d2 100644 --- a/apps/backend/src/services/semi-auto/weibo-connector-client.ts +++ b/apps/backend/src/services/semi-auto/weibo-connector-client.ts @@ -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 { +): Promise { 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 { + assertEmbedded(mode) + return weiboNativeConnector.restoreSessionFromCredential(input) +} + export async function getWeiboCountryCodes( mode: SemiAutoConnectorMode = 'embedded', ): Promise { diff --git a/apps/backend/src/services/semi-auto/weibo-native/weibo-native-connector.ts b/apps/backend/src/services/semi-auto/weibo-native/weibo-native-connector.ts index 4ec880a8..1893b9f1 100644 --- a/apps/backend/src/services/semi-auto/weibo-native/weibo-native-connector.ts +++ b/apps/backend/src/services/semi-auto/weibo-native/weibo-native-connector.ts @@ -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() + + 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 } +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 { - 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() - private readonly smsCookies = new Map() + private readonly smsSessions = new Map() private countryCodeCache: { items: WeiboCountryCode[]; expiresAtMs: number } | null = null private readonly fetchImpl: FetchLike private readonly now: () => number @@ -174,14 +367,16 @@ export class WeiboNativeConnector { }): Promise { 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 { + async sendSmsCode(input: { + taskId: number + mobile: string + countryCode?: unknown + mfaId?: string + }): Promise { 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 { 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 { - 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 { + 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 { + 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 { - const pairs = new Map() - 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(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 { @@ -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 { + private async createBootstrapSession(): Promise { + 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 { 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 { + private formHeaders(requestUrl: string, cookies: WeiboCookieJar): Record { 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) } diff --git a/apps/backend/src/services/semi-auto/weibo-native/weibo-native.test.ts b/apps/backend/src/services/semi-auto/weibo-native/weibo-native.test.ts index 6d39b357..89d8cc86 100644 --- a/apps/backend/src/services/semi-auto/weibo-native/weibo-native.test.ts +++ b/apps/backend/src/services/semi-auto/weibo-native/weibo-native.test.ts @@ -31,6 +31,15 @@ function createFetchMock( } function defaultHandler(url: string): Response { + if (url.includes('/sso/signin')) { + return new Response('', { + 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) +}) diff --git a/apps/backend/src/types/runtime-config.ts b/apps/backend/src/types/runtime-config.ts index e420ffc6..6bb89dd5 100644 --- a/apps/backend/src/types/runtime-config.ts +++ b/apps/backend/src/types/runtime-config.ts @@ -67,6 +67,8 @@ export type RuntimeConfig = { connectorBaseUrl: string connectorApiKey: string connectorTimeoutMs: number + /** 半自动平台账号 CK 的落库加密密钥;生产必填,本地缺省回退后台会话密钥。 */ + credentialEncryptionKey?: string huya: { bindActId: number outerActId: string diff --git a/apps/frontend/src/pages/admin/AdminSemiAutoTasksPage.tsx b/apps/frontend/src/pages/admin/AdminSemiAutoTasksPage.tsx index 1d1ece83..d0f42d78 100644 --- a/apps/frontend/src/pages/admin/AdminSemiAutoTasksPage.tsx +++ b/apps/frontend/src/pages/admin/AdminSemiAutoTasksPage.tsx @@ -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(null) const [completeTask, setCompleteTask] = useState(null) const [completeFiles, setCompleteFiles] = useState([]) + const [credentialExport, setCredentialExport] = useState(null) const [completeForm] = Form.useForm() 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) => ( {task.displayStatus.label} {task.huyaUid ? `UID ${task.huyaUid}` : '未获取 UID'} + + CK: + {task.credentialStatus === 'valid' + ? '有效' + : task.credentialStatus === 'invalid' + ? '失效' + : task.credentialStatus === 'error' + ? '检测异常' + : '未保存'} + ), }, @@ -487,6 +532,32 @@ export default function AdminSemiAutoTasksPage() { {detail.task.platformNickname || '-'} + + + + {detail.task.credentialStatus === 'valid' + ? '有效' + : detail.task.credentialStatus === 'invalid' + ? '失效' + : detail.task.credentialStatus === 'error' + ? '检测异常' + : '未保存'} + + + {detail.task.credentialSource + ? `${detail.task.credentialSource} · ${formatAdminDateTime(detail.task.credentialUpdatedAt)}` + : '-'} + + + {detail.task.bindGameAccount || '-'} @@ -536,6 +607,17 @@ export default function AdminSemiAutoTasksPage() { > 注入浏览器 + {detail.task.credentialReady && + hasAdminPermission('semi_auto.credential.export') ? ( + + ) : null} @@ -560,6 +642,65 @@ export default function AdminSemiAutoTasksPage() { )} + setCredentialExport(null)} + > + {credentialExport ? ( + + + 以下内容包含平台登录态,仅用于本次受控操作。关闭弹窗后请勿在聊天、日志或工单中留存。 + + + + {credentialExport.targetUrl || 'Cookie Header'} + + + + + + + 完整域名 Cookie + + + + + + ) : null} + + {smsMfaRequired ? ( setLoginMode('qr')}> - 去扫码 - - } + message="正在等待微博安全验证" + description="请在安全验证弹窗中完成验证,验证通过后会继续发送短信验证码。" /> ) : null} @@ -627,7 +634,7 @@ export default function CustomerFulfillmentPage() { - 如微博要求二次验证导致登录失败,请改用扫码登录。 + 如安全验证弹窗未出现,请检查浏览器是否拦截脚本。 ), diff --git a/apps/frontend/src/services/admin/semi-auto.ts b/apps/frontend/src/services/admin/semi-auto.ts index ece6c540..1ec458d9 100644 --- a/apps/frontend/src/services/admin/semi-auto.ts +++ b/apps/frontend/src/services/admin/semi-auto.ts @@ -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 = {}) { return apiGet('/api/v1/admin/semi-auto/tasks', params) @@ -9,6 +13,13 @@ export function fetchAdminSemiAutoTask(taskId: number) { return apiGet(`/api/v1/admin/semi-auto/tasks/${taskId}`) } +export function exportAdminSemiAutoCredential(taskId: number) { + return apiPost( + `/api/v1/admin/semi-auto/tasks/${taskId}/credential/export`, + {}, + ) +} + export function claimAdminSemiAutoTask(taskId: number) { return apiPost(`/api/v1/admin/semi-auto/tasks/${taskId}/claim`, {}) } diff --git a/apps/frontend/src/services/customer-fulfillment.ts b/apps/frontend/src/services/customer-fulfillment.ts index bd24406a..9ea205ad 100644 --- a/apps/frontend/src/services/customer-fulfillment.ts +++ b/apps/frontend/src/services/customer-fulfillment.ts @@ -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`, diff --git a/apps/frontend/src/types/admin/semi-auto.ts b/apps/frontend/src/types/admin/semi-auto.ts index c77963fb..b2de9aaf 100644 --- a/apps/frontend/src/types/admin/semi-auto.ts +++ b/apps/frontend/src/types/admin/semi-auto.ts @@ -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 + }> +} diff --git a/apps/frontend/src/utils/weibo-yidun.ts b/apps/frontend/src/utils/weibo-yidun.ts new file mode 100644 index 00000000..c6bdd06a --- /dev/null +++ b/apps/frontend/src/utils/weibo-yidun.ts @@ -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 | null = null + +export function loadWeiboYidunSdk(): Promise { + 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((resolve, reject) => { + const existing = document.querySelector(`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 { + await loadWeiboYidunSdk() + const ydInit = (window as WeiboYidunWindow).ydInit + if (!ydInit) throw new Error('微博安全验证组件不可用') + if (!mfaId || !captchaId) throw new Error('微博安全验证参数无效') + + await new Promise((resolve, reject) => { + ydInit( + { + geetestKey: mfaId, + captchaId, + product: 'popup', + lang: 'zh-CN', + }, + (_params, message, success) => { + if (success) { + resolve() + return + } + reject(new Error(message === 'close' ? '已取消安全验证' : message || '微博安全验证失败')) + }, + ) + }) +} diff --git a/deploy/docker/backend-dev.Dockerfile b/deploy/docker/backend-dev.Dockerfile index f0d5f491..e2b63a00 100644 --- a/deploy/docker/backend-dev.Dockerfile +++ b/deploy/docker/backend-dev.Dockerfile @@ -1,37 +1,12 @@ # 快手轻量开发镜像:只保留 Node 运行时和基础工具。 -# 基础镜像源可整体替换(.env 的 BASE_REGISTRY),原因见 backend.Dockerfile 顶部注释。 -ARG BASE_REGISTRY=docker.m.daocloud.io -FROM ${BASE_REGISTRY}/library/node:22-bookworm - -# 阿里云公共源(全国可达);也可构建时覆盖:--build-arg DEBIAN_MIRROR=mirrors.cloud.aliyuncs.com -ARG DEBIAN_MIRROR=mirrors.aliyun.com -ARG NPM_REGISTRY=https://registry.npmmirror.com +# 本地 dev 不写死 registry,交给 Docker Desktop 的默认 Docker Hub 与 mirrors。 +FROM node:22-bookworm-slim ENV DEBIAN_FRONTEND=noninteractive ENV TZ=Asia/Shanghai -ENV NPM_CONFIG_REGISTRY=${NPM_REGISTRY} WORKDIR /app -# bookworm 基础镜像可能尚无 ca-certificates;国内源常跳 HTTPS,需先跳过校验装证书 -RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ - --mount=type=cache,target=/var/lib/apt,sharing=locked \ - sed -i "s|http://deb.debian.org/debian|http://${DEBIAN_MIRROR}/debian|g" /etc/apt/sources.list.d/debian.sources \ - && sed -i "s|http://security.debian.org/debian-security|http://${DEBIAN_MIRROR}/debian-security|g" /etc/apt/sources.list.d/debian.sources \ - && apt-get -o Acquire::https::Verify-Peer=false -o Acquire::https::Verify-Host=false update \ - && apt-get -o Acquire::https::Verify-Peer=false -o Acquire::https::Verify-Host=false install -y --no-install-recommends \ - ca-certificates \ - && apt-get update \ - && apt-get install -y --no-install-recommends \ - curl tzdata \ - && ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime \ - && echo ${TZ} > /etc/timezone - -COPY apps/backend/package.json apps/backend/package-lock.json ./ -RUN --mount=type=cache,target=/root/.npm npm ci - -COPY apps/backend/ ./ - RUN mkdir -p /app/data EXPOSE 3000 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 7a5eb90b..75af6b4a 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -6,7 +6,7 @@ x-json-log-rotation: &json-log-rotation services: postgres: - image: ${BASE_REGISTRY:-docker.m.daocloud.io}/library/postgres:16-bookworm + image: postgres:16-bookworm command: [ "postgres", @@ -51,29 +51,35 @@ services: - postgres_dev_data:/var/lib/postgresql/data minio: - image: ${BASE_REGISTRY:-docker.m.daocloud.io}/minio/minio:latest + # MinIO 官方镜像已停止维护;使用固定版本的 Bitnami Legacy 构建,避免 latest 漂移。 + image: bitnamilegacy/minio:2025.7.23-debian-12-r5 + # 首次启动以 root 修正既有 dev volume 的权限,随后镜像脚本会降权为 minio 用户运行。 + user: "0" restart: unless-stopped logging: *json-log-rotation - command: server /data --address ":9000" --console-address ":9001" environment: TZ: ${TZ:-Asia/Shanghai} MINIO_ROOT_USER: ${STORAGE_ACCESS_KEY_ID:-minioadmin} MINIO_ROOT_PASSWORD: ${STORAGE_SECRET_ACCESS_KEY:-minioadmin} + MINIO_API_PORT_NUMBER: "9000" + MINIO_CONSOLE_PORT_NUMBER: "9001" + MINIO_BROWSER: "on" ports: - "${MINIO_API_PORT:-19000}:9000" - "${MINIO_CONSOLE_PORT:-19001}:9001" + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:9000/minio/health/live"] + interval: 5s + timeout: 5s + retries: 20 + start_period: 10s volumes: - - minio_dev_data:/data + - minio_dev_data:/bitnami/minio/data backend: build: context: . dockerfile: deploy/docker/backend-dev.Dockerfile - args: - # 基础镜像源(registry 前缀),与生产 compose 同一套语义。 - BASE_REGISTRY: ${BASE_REGISTRY:-docker.m.daocloud.io} - DEBIAN_MIRROR: ${DEBIAN_MIRROR:-mirrors.aliyun.com} - NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmmirror.com} restart: unless-stopped env_file: - .env @@ -83,10 +89,13 @@ services: postgres: condition: service_healthy minio: - condition: service_started + condition: service_healthy healthcheck: test: - ["CMD-SHELL", "curl -fsS http://127.0.0.1:3000/health/ready || exit 1"] + [ + "CMD-SHELL", + "node -e \"fetch('http://127.0.0.1:3000/health/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"", + ] interval: 5s timeout: 5s retries: 20 @@ -136,7 +145,7 @@ services: - ./apps/backend/data:/app/data frontend: - image: ${BASE_REGISTRY:-docker.m.daocloud.io}/library/node:22-bookworm-slim + image: node:22-bookworm-slim working_dir: /app restart: unless-stopped logging: *json-log-rotation @@ -147,7 +156,6 @@ services: environment: TZ: ${TZ:-Asia/Shanghai} CHOKIDAR_USEPOLLING: "true" - NPM_CONFIG_REGISTRY: https://registry.npmmirror.com VITE_API_TARGET: http://backend:3000 VITE_ALLOWED_HOSTS: ${VITE_ALLOWED_HOSTS:-localhost,127.0.0.1} VITE_WORKER_SITE_URL: ${WORKER_SITE_ADDR:-http://worker.localhost} @@ -160,7 +168,7 @@ services: # 打手端专用 dev server:同一份代码,只把「打手端 hostname」设成 IP, # 由 Caddy 的 8080 端口反代过来(前端按 hostname 判站点,见 utils/site.ts)。 frontend-worker: - image: ${BASE_REGISTRY:-docker.m.daocloud.io}/library/node:22-bookworm-slim + image: node:22-bookworm-slim working_dir: /app restart: unless-stopped logging: *json-log-rotation @@ -174,7 +182,6 @@ services: environment: TZ: ${TZ:-Asia/Shanghai} CHOKIDAR_USEPOLLING: "true" - NPM_CONFIG_REGISTRY: https://registry.npmmirror.com VITE_API_TARGET: http://backend:3000 VITE_ALLOWED_HOSTS: ${VITE_ALLOWED_HOSTS:-localhost,127.0.0.1} VITE_WORKER_SITE_URL: ${WORKER_SITE_ADDR_IP:-http://192.168.10.8} @@ -185,7 +192,7 @@ services: - frontend_worker_node_modules:/app/node_modules web: - image: ${BASE_REGISTRY:-docker.m.daocloud.io}/library/caddy:2-alpine + image: caddy:2-alpine restart: unless-stopped logging: *json-log-rotation depends_on: diff --git a/docs/生产升级操作手册.md b/docs/生产升级操作手册.md index 23a9368a..71b8e794 100644 --- a/docs/生产升级操作手册.md +++ b/docs/生产升级操作手册.md @@ -17,6 +17,24 @@ updated_at: 2026-11-08 > [部署启动与数据库重建](部署启动与数据库重建.md)。 > 只要包含迁移、表结构切换或派生数据重建,必须按本手册执行。 +> 2026-11-13 增量待发布基线:`dev_11.9_2` 从生产迁移 `151` 增量到 `156`。 +> 该次变更包含 `152_order_source_facts_immutable.sql`、`153_semi_auto_fulfillment.sql`、 +> `154_semi_auto_connector_mode.sql`、`155_semi_auto_connector_mode_embedded.sql`、 +> `156_semi_auto_credentials.sql`。在发布负责人确认目标提交并完成隔离恢复演练前,只作为开发基线, +> 不得按旧的 `147–150` 一条路径直接上线。 + +### 1.1 迁移 `151 → 156` 增量门禁 + +- 升级前必须确认生产最高迁移仍为 `151`;分支合并后重新核对迁移号,禁止回退或改写 `151` 及以前文件。 +- `156` 新增账号级加密凭证表、`semi_auto_tasks.credential_id` 和 + `semi_auto.credential.export` 权限;不迁移或清理订单、退款、资金流水等 A 级事实。 +- 发布环境必须预先配置稳定的 `SEMI_AUTO_CREDENTIAL_ENCRYPTION_KEY`(至少 32 字符)。 + 未配置或仍为示例值时,生产运行时校验会拒绝启动。 +- 迁移完成后至少验证 `semi_auto_credentials` 表、任务外键、索引和导出权限存在; + 用测试库完成 `001 → 156` 空库迁移,并在隔离库验证 `151 → 156`。 +- 该增量仍包含迁移,普通停机发布不能使用 `deploy/ubuntu-deploy.sh` 在旧后端运行时直接换库; + 必须沿用本手册的停写、最终备份、隔离恢复验证、迁移、新后端健康检查和验收流程。 + ## 一、本次升级基线 以下信息是 2026-11-08 的首次执行基线。后续每次升级必须由发布负责人更新, diff --git a/docs/统一订单履约与财务模型-权威版.md b/docs/统一订单履约与财务模型-权威版.md index ba9770f0..c70fb5cb 100644 --- a/docs/统一订单履约与财务模型-权威版.md +++ b/docs/统一订单履约与财务模型-权威版.md @@ -3,16 +3,16 @@ type: 权威架构与实施计划 description: 订单、内部 SKU、履约路由、履约执行、商品、成本与财务的唯一权威口径及重构计划。 tags: [订单, 内部SKU, 履约, 商品, 成本, 财务, 迁移] status: authoritative -version: 2.10 -updated_at: 2026-11-09 +version: 2.11 +updated_at: 2026-11-13 --- # 统一订单、履约与财务模型 > 状态:**唯一权威文档**。 -> 最后校准:2026-11-09。 +> 最后校准:2026-11-13。 > 生产基线:迁移 `151`。 -> 开发基线:迁移 `155`(仅本地,尚未部署生产)。 +> 开发基线:迁移 `156`(仅本地,尚未部署生产)。 > > 从本文件生效起,订单、内部 SKU、履约渠道、履约载体、标准商品、成本和财务相关实现, > 只能以本文件为业务口径。其他旧分析只作为历史资料,不再具备设计或验收效力。 @@ -286,14 +286,26 @@ manual fulfillment - 是否启用半自动履约由 `manual_fulfillment_configs.semi_auto_*` 显式配置,不能按商品名或平台前缀猜测。 - 半自动任务必须绑定 `order_item_id`、`fulfillments.id` 和人工履约配置,不得伪造接单平台工单。 - 登录状态、绑定状态、执行状态必须正交保存于 `semi_auto_tasks`;不得写入 `material_json` 作为正式状态。 -- 平台 CK 只能由 Connector 的短期凭证域托管;order_site 数据库不保存、不返回、不记录原始 CK。 - 默认真实 Connector 内置于 order_site 进程,远程 Connector 仅是可选隔离部署方式;两者均为进程内存会话, - 重启即失效。多实例或多副本部署必须迁移到 Redis 或加密凭证存储。 +- 平台 CK 是账号级凭证,唯一持久化真源为 `semi_auto_credentials`。Cookie 集合按平台 UID 唯一保存, + 使用 AES-256-GCM 加密后写入 `credential_ciphertext`,并记录来源、更新时间、健康状态和最近检查错误。 + `semi_auto_tasks.credential_id` 只引用账号凭证,任务表不保存 CK 明文。 +- Connector 会话仍是进程内短期缓存,但不再是唯一真源。进程重启、会话过期或实例切换后, + 执行器必须从账号级密文凭证恢复会话;恢复前校验平台 UID,恢复失败时更新凭证健康状态并要求重新登录。 +- 生产必须配置稳定且至少 32 字符的 `SEMI_AUTO_CREDENTIAL_ENCRYPTION_KEY`。该密钥不得与日志、 + 任务事件、接口响应或导出内容一起保存;未完成历史凭证重加密前不得直接更换密钥。 +- CK 不得进入普通任务 DTO、客户页面、任务事件、错误日志或审计明细。人工后台默认只展示平台 UID、 + 凭证来源、健康状态和更新时间;仅持有 `semi_auto.credential.export` 权限的角色可受控导出单个任务的 + 账号 Cookie,导出响应必须禁止缓存,审计只记录任务、凭证 ID 和平台,不记录 Cookie 内容。 +- 微博登录必须保留完整域名/路径 Cookie Jar,不能在跨域跳转后把所有同名 Cookie 合并成单个字符串。 + 浏览器注入只下发与目标 URL 匹配的 Cookie;账号级凭证保存完整集合,以便后续会话恢复和跨域重放。 +- 微博手机号登录首次发码返回 `mfa_1` 时,客户必须在浏览器完成网易易盾安全验证,再使用同一会话的 + `mfa_id` 重发短信;服务端不尝试绕过或伪造人机验证。 - 半自动任务通过 `connector_mode` 显式记录 Connector 来源: `embedded` 为 order_site 内置真实实现并作为默认值,`external` 为显式配置的远程真实实现, `mock` 为 order_site 内置开发 Mock 且仅在非生产环境可用。开发 Mock 造单时默认选择 `embedded`, 只有需要完全脱离虎牙接口联调页面时才显式选择 `mock`。 -- 虎牙二维码登录有效期 `5` 分钟;登录成功后的 CK 会话有效期 `24` 小时;角色绑定二维码 `5` 分钟; +- 虎牙二维码登录有效期 `5` 分钟;登录成功后的 Connector 内存会话有效期 `24` 小时, + 账号级 CK 由加密凭证保存并通过健康检查判定有效性;角色绑定二维码 `5` 分钟; 浏览器一次性注入票据 `2` 分钟且消费后立即失效。重新登录前必须撤销旧 Connector 会话。 - 虎牙账号已经绑定游戏角色时,客户必须确认当前角色是否为本次需要的角色:选择“使用当前角色”时 不再调用虎牙换绑接口;选择“角色不对,重新扫码换绑”时重新生成绑定二维码,并在确认新角色时 @@ -359,7 +371,7 @@ finance_journal_* -- 总账凭证,可重建 | 执行尝试 | 履约尝试服务 | `fulfillment_attempts` | 用最大 ID 猜当前任务 | | 当前异常 | 履约异常服务 | `fulfillment_issues` | 自由 JSON 字符串代替问题模型 | | 半自动任务 | 半自动任务服务 | `semi_auto_tasks` / `semi_auto_task_events` | 把登录/绑定/执行状态写进 `material_json` | -| 客户平台 CK | 外部平台 Connector | Connector 短期凭证域 | order_site 或浏览器页面持久化原始 CK | +| 客户平台 CK | 半自动凭证服务 | `semi_auto_credentials` 加密凭证 | 明文落库、写日志、普通接口返回或未审计导出 CK | | 标准商品 | 商品主数据服务 | `product_catalog` | 用商品来源反推渠道 | | 平台商品到财务商品 | 商品归一服务 | `channel_product_mappings` | 人工在多个页面重复维护 | | 成本规则 | 成本中心 | `cost_versions` | 业务表直接改金额 | @@ -530,7 +542,9 @@ has_active_cost 平台登录 → 角色绑定 → 客服处理 → 完成 ``` -页面只能展示客户完成操作所需的最小信息,不得展示 CK、内部成本或其他订单。后台不得展示原始 CK,只展示平台 UID、绑定角色、凭证有效性和一次性注入入口。 +页面只能展示客户完成操作所需的最小信息,不得展示 CK、内部成本或其他订单。后台默认只展示平台 UID、 +绑定角色、凭证来源、健康状态、更新时间和一次性注入入口;持有独立导出权限的角色可按任务受控查看 +账号 CK,导出动作必须留审计且不得复用普通详情接口。 ## 八、存量数据处理方案 @@ -934,9 +948,10 @@ docs/archive/ ## 十三、当前下一步 -截至 2026-11-09,生产基线为 `151`,本地开发基线为 `155`。迁移 `152` 保护订单来源快照, +截至 2026-11-13,生产基线为 `151`,本地开发基线为 `156`。迁移 `152` 保护订单来源快照, 迁移 `153` 增加人工渠道半自动任务,迁移 `154` 增加半自动任务的 Connector 来源, -迁移 `155` 将内置真实 Connector 设为默认并扩充来源约束。 +迁移 `155` 将内置真实 Connector 设为默认并扩充来源约束,迁移 `156` 增加账号级加密凭证、 +任务凭证引用和受控导出权限。 1. 生产升级只按 [生产升级操作手册](生产升级操作手册.md) 执行。 2. 升级前备份必须完成隔离恢复验证;恢复验证未通过时禁止迁移。 @@ -945,7 +960,7 @@ docs/archive/ 4. 执行 `finance:rebuild-derived` 的 dry-run,核对 A 级数据守恒后, 再按窗口执行 `--execute --confirm=REBUILD_DERIVED`,并验证第二次重建幂等。 5. 观察一个完整财务期间,确认页面、履约、事实和总账持续对平。 -6. 半自动履约第一版先接入虎牙扫码登录、角色绑定、一次性浏览器注入和人工完成确认; - 微博只保留平台扩展位,不提前写死平台专用状态。 +6. 半自动履约继续完善微博手机号登录、易盾二次验证、完整域名 Cookie 保存、会话恢复和受控导出; + 所有平台继续复用账号级凭证模型,不把平台专用 CK 状态写回订单或商品字段。 7. 观察期结束后,再单独发布兼容表、旧脚本别名和 `legacy_*` 字段的删除迁移。 diff --git a/docs/部署启动与数据库重建.md b/docs/部署启动与数据库重建.md index 00aa13ba..7fed6988 100644 --- a/docs/部署启动与数据库重建.md +++ b/docs/部署启动与数据库重建.md @@ -152,6 +152,7 @@ CLAIM_BASE_URL POSTGRES_PASSWORD DATABASE_URL ADMIN_SESSION_SECRET +SEMI_AUTO_CREDENTIAL_ENCRYPTION_KEY ADMIN_DEFAULT_USERS_JSON CORS_ALLOWED_ORIGINS STORAGE_MODE @@ -164,6 +165,10 @@ STORAGE_OSS_REGION 其中 `APP_DOMAIN`、`CORS_ALLOWED_ORIGINS` 与 `STORAGE_MODE=oss` / `STORAGE_OSS_*` 为生产必需:`docker-compose.yml:95` 的 `STORAGE_MODE` 生产默认即 `oss`,`apps/backend/src/config/runtime-validation.ts:107-114` 在生产环境强制校验 `STORAGE_OSS_*` 凭据并使用显式 CORS 域名白名单;`.env.server.example` 已给出这些变量的模板值,其中带 `change-me` 的占位会被 `deploy/ubuntu-deploy.sh` 的占位值检查拦住,需替换为真实值。 +`SEMI_AUTO_CREDENTIAL_ENCRYPTION_KEY` 用于加密半自动履约的账号级平台 CK,生产必需且至少 32 个字符。 +该密钥必须长期稳定保存;直接更换会导致已有 `semi_auto_credentials` 无法解密。需要轮换时,先实现并验证 +旧密钥解密、新密钥重加密的数据迁移,不能只修改环境变量。 + 容器内连接 Compose PostgreSQL 时,主机名用 `postgres`: ```text @@ -219,7 +224,7 @@ docker compose ps docker compose logs -f backend ``` -## 财务/成本上线后的初始化(当前开发基线 150) +## 财务/成本上线后的初始化(历史基线 150) 代码与迁移只负责建表;**存量订单回填、备用金账户、积分余额、商品目录、人工 SKU 不会自动创建**。 部署成功后按「回填 → 商品初始化 → 财务同步 → 验收」四步走: