diff --git a/.env.mac-docker.example b/.env.mac-docker.example index 6fe6204f..2bfeb72e 100644 --- a/.env.mac-docker.example +++ b/.env.mac-docker.example @@ -74,7 +74,6 @@ 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 005e490c..9e7eff0a 100644 --- a/.env.server.example +++ b/.env.server.example @@ -140,7 +140,6 @@ 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/src/config/defaults.ts b/apps/backend/src/config/defaults.ts index 02154954..106aa968 100644 --- a/apps/backend/src/config/defaults.ts +++ b/apps/backend/src/config/defaults.ts @@ -66,7 +66,6 @@ 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-overrides.ts b/apps/backend/src/config/env-overrides.ts index 9d5f1c2c..29c17b9c 100644 --- a/apps/backend/src/config/env-overrides.ts +++ b/apps/backend/src/config/env-overrides.ts @@ -55,7 +55,6 @@ 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 361c517a..73aa66c7 100644 --- a/apps/backend/src/config/runtime-validation.test.ts +++ b/apps/backend/src/config/runtime-validation.test.ts @@ -142,22 +142,6 @@ 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( () => { @@ -224,7 +208,6 @@ 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 ec39e76c..e6ea59cd 100644 --- a/apps/backend/src/config/runtime-validation.ts +++ b/apps/backend/src/config/runtime-validation.ts @@ -85,13 +85,6 @@ 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', @@ -141,12 +134,6 @@ 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/161_semi_auto_fulfillment.sql b/apps/backend/src/db/migrations/161_semi_auto_fulfillment.sql index bb2b84e2..b4f0b784 100644 --- a/apps/backend/src/db/migrations/161_semi_auto_fulfillment.sql +++ b/apps/backend/src/db/migrations/161_semi_auto_fulfillment.sql @@ -1,10 +1,13 @@ -- 161_semi_auto_fulfillment.sql -- --- 人工履约渠道下的半自动任务: --- 1. 人工 SKU 配置可显式启用虎牙/微博等半自动平台; --- 2. 半自动任务绑定订单项和正式履约,不另建接单平台工单; --- 3. 登录、绑定、执行状态正交保存,客户链接只按订单号展示必要信息; --- 4. 任务只保存平台账号快照;后续迁移增加账号级加密 CK 与 credential_id。 +-- 人工履约渠道下的半自动履约与账号凭证: +-- 1. 人工 SKU 配置可显式启用虎牙/微博半自动平台; +-- 2. 任务绑定订单项、正式履约和账号凭证,不另建接单平台工单; +-- 3. 登录、绑定、执行状态正交保存; +-- 4. Connector 默认使用 order_site 内置真实实现; +-- 5. 平台 CK 以账号级 Cookie JSON 保存在内部凭证表,任务只引用 credential_id。 +-- +-- 本文件合并了未上生产的原 161–165,空库直接得到最终结构。 ALTER TABLE manual_fulfillment_configs ADD COLUMN IF NOT EXISTS semi_auto_enabled BOOLEAN NOT NULL DEFAULT FALSE, @@ -23,6 +26,24 @@ BEGIN END IF; END $$; +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_payload_json 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) +); + CREATE TABLE IF NOT EXISTS semi_auto_tasks ( id BIGSERIAL PRIMARY KEY, order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE, @@ -30,7 +51,11 @@ CREATE TABLE IF NOT EXISTS semi_auto_tasks ( fulfillment_id BIGINT REFERENCES fulfillments(id) ON DELETE SET NULL, manual_fulfillment_config_id BIGINT REFERENCES manual_fulfillment_configs(id) ON DELETE SET NULL, + credential_id BIGINT REFERENCES semi_auto_credentials(id) ON DELETE SET NULL, platform TEXT NOT NULL CHECK (platform IN ('huya', 'weibo')), + connector_mode TEXT NOT NULL DEFAULT 'embedded' + CONSTRAINT chk_semi_auto_tasks_connector_mode + CHECK (connector_mode IN ('embedded', 'external', 'mock')), login_status TEXT NOT NULL DEFAULT 'waiting_login' CHECK (login_status IN ( 'waiting_login', 'qr_pending', 'scanned', 'authenticated', 'failed', 'expired' @@ -67,6 +92,8 @@ CREATE TABLE IF NOT EXISTS semi_auto_tasks ( updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); +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_status ON semi_auto_tasks (execution_status, platform, updated_at DESC, id DESC); CREATE INDEX IF NOT EXISTS idx_semi_auto_tasks_order @@ -77,6 +104,9 @@ CREATE INDEX IF NOT EXISTS idx_semi_auto_tasks_operator CREATE INDEX IF NOT EXISTS idx_semi_auto_tasks_connector ON semi_auto_tasks (connector_session_id) WHERE connector_session_id <> ''; +CREATE INDEX IF NOT EXISTS idx_semi_auto_tasks_credential + ON semi_auto_tasks (credential_id) + WHERE credential_id IS NOT NULL; CREATE TABLE IF NOT EXISTS semi_auto_task_events ( id BIGSERIAL PRIMARY KEY, @@ -98,11 +128,43 @@ CREATE TABLE IF NOT EXISTS semi_auto_task_events ( CREATE INDEX IF NOT EXISTS idx_semi_auto_task_events_task ON semi_auto_task_events (task_id, id DESC); +COMMENT ON TABLE semi_auto_credentials IS + '半自动履约平台账号级 CK;仅供内部执行器使用,日志和普通接口不得返回原始值'; +COMMENT ON COLUMN semi_auto_credentials.credential_payload_json IS + 'Cookie JSON;包含域名、路径、值、Secure、HttpOnly 和过期时间'; +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 TABLE semi_auto_tasks IS - '人工履约渠道下的半自动任务;绑定订单项和正式履约,不承载平台 CK'; + '人工履约渠道下的半自动任务;绑定订单项、正式履约和账号凭证'; +COMMENT ON COLUMN semi_auto_tasks.connector_mode IS + 'Connector 来源;embedded 为内置真实实现,external 为远程实现,mock 仅开发环境可用'; COMMENT ON COLUMN semi_auto_tasks.connector_session_id IS - '外部平台 Connector 的短期登录/绑定会话 ID;CK 仅保存在 Connector 加密凭证域'; + '平台 Connector 的短期登录/绑定会话 ID;账号 CK 保存在 semi_auto_credentials'; +COMMENT ON COLUMN semi_auto_tasks.credential_id IS + '该订单当前使用的平台账号凭证;任务不保存 CK 明文'; COMMENT ON COLUMN semi_auto_tasks.completion_evidence_json IS '可选交付证据(截图 object_key、备注、外部单号等),不得保存 Cookie 或令牌'; COMMENT ON COLUMN manual_fulfillment_configs.semi_auto_config_json IS '半自动履约展示与目标页配置;不得保存平台 CK 或运行时状态'; + +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/db/migrations/162_semi_auto_connector_mode.sql b/apps/backend/src/db/migrations/162_semi_auto_connector_mode.sql deleted file mode 100644 index 0fc98727..00000000 --- a/apps/backend/src/db/migrations/162_semi_auto_connector_mode.sql +++ /dev/null @@ -1,23 +0,0 @@ --- 162_semi_auto_connector_mode.sql --- --- 半自动任务记录登录/绑定/票据注入使用的 Connector 来源: --- external:配置的远程 Connector,作为首版默认值; --- mock:order_site 内置开发 Mock Connector,仅开发环境可用。 - -ALTER TABLE semi_auto_tasks - ADD COLUMN IF NOT EXISTS connector_mode TEXT NOT NULL DEFAULT 'external'; - -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint - WHERE conname = 'chk_semi_auto_tasks_connector_mode' - ) THEN - ALTER TABLE semi_auto_tasks - ADD CONSTRAINT chk_semi_auto_tasks_connector_mode - CHECK (connector_mode IN ('external', 'mock')); - END IF; -END $$; - -COMMENT ON COLUMN semi_auto_tasks.connector_mode IS - 'Connector 来源;external 为远程真实实现,mock 仅开发环境可用'; diff --git a/apps/backend/src/db/migrations/163_semi_auto_connector_mode_embedded.sql b/apps/backend/src/db/migrations/163_semi_auto_connector_mode_embedded.sql deleted file mode 100644 index a499c8a6..00000000 --- a/apps/backend/src/db/migrations/163_semi_auto_connector_mode_embedded.sql +++ /dev/null @@ -1,25 +0,0 @@ --- 163_semi_auto_connector_mode_embedded.sql --- --- 内置真实 Connector 成为默认实现,不再依赖外部 live-hub-py 进程: --- embedded:order_site 进程内真实虎牙 Connector,默认值; --- external:显式配置的远程 Connector,保留用于隔离部署; --- mock:order_site 内置开发 Mock Connector,仅开发环境可用。 - -ALTER TABLE semi_auto_tasks - ALTER COLUMN connector_mode SET DEFAULT 'embedded'; - -ALTER TABLE semi_auto_tasks - DROP CONSTRAINT IF EXISTS chk_semi_auto_tasks_connector_mode; - --- 162 创建的历史任务均由远程 Connector 完成;切换到内置实现后仍需继续可操作。 -UPDATE semi_auto_tasks -SET connector_mode = 'embedded', - updated_at = NOW() -WHERE connector_mode = 'external'; - -ALTER TABLE semi_auto_tasks - ADD CONSTRAINT chk_semi_auto_tasks_connector_mode - CHECK (connector_mode IN ('embedded', 'external', 'mock')); - -COMMENT ON COLUMN semi_auto_tasks.connector_mode IS - 'Connector 来源;embedded 为内置真实实现,external 为远程实现,mock 仅开发环境可用'; diff --git a/apps/backend/src/db/migrations/164_semi_auto_credentials.sql b/apps/backend/src/db/migrations/164_semi_auto_credentials.sql deleted file mode 100644 index 05c4a788..00000000 --- a/apps/backend/src/db/migrations/164_semi_auto_credentials.sql +++ /dev/null @@ -1,65 +0,0 @@ --- 164_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 51dccb47..102aceb5 100644 --- a/apps/backend/src/routes/admin/semi-auto.ts +++ b/apps/backend/src/routes/admin/semi-auto.ts @@ -9,6 +9,7 @@ import { exportAdminSemiAutoCredential, getAdminSemiAutoTask, listAdminSemiAutoTasks, + requestAdminSemiAutoCustomerReauth, syncAdminSemiAutoTask, } from '../../services/semi-auto/semi-auto-task-service.js' import { bodyOf, sessionOf } from './request-helpers.js' @@ -91,6 +92,24 @@ router.post( }), ) +router.post( + '/semi-auto/tasks/:taskId/reauthorize', + createJsonHandler( + (req) => requestAdminSemiAutoCustomerReauth(req.params.taskId, sessionOf(req)), + { + successMessage: '已重新发起客户授权', + errorMessage: '重新发起客户授权失败', + scope: '[admin/semi-auto/tasks/:taskId/reauthorize]', + audit: (req) => ({ + action: 'semi_auto_task_reauth_requested', + targetType: 'semi_auto_task', + targetId: String(req.params.taskId || ''), + data: {}, + }), + }, + ), +) + router.post( '/semi-auto/tasks/:taskId/browser-session', createJsonHandler( diff --git a/apps/backend/src/services/semi-auto/huya-connector-client.ts b/apps/backend/src/services/semi-auto/huya-connector-client.ts index cdf5ea30..d835f454 100644 --- a/apps/backend/src/services/semi-auto/huya-connector-client.ts +++ b/apps/backend/src/services/semi-auto/huya-connector-client.ts @@ -9,6 +9,7 @@ import { isDevMockEnabled } from '../dev-mock/dev-mock-enabled.js' import { HUYA_NATIVE_TICKET_PREFIX, huyaNativeConnector, + type HuyaStoredCredential, } from './huya-native/huya-native-connector.js' export type ConnectorLoginSession = { @@ -275,6 +276,19 @@ export async function createHuyaBrowserTicket( ) } +export function exportHuyaSessionCredential( + sessionId: string, + mode: SemiAutoConnectorMode = defaultSemiAutoConnectorMode(), +): HuyaStoredCredential { + if (mode !== 'embedded') { + throw createHttpError('当前虎牙 Connector 模式不支持直接导出 CK', { + statusCode: 409, + errorCode: 'semi_auto_credential_export_unsupported', + }) + } + return huyaNativeConnector.exportSessionCredential(sessionId) +} + export function inferSemiAutoConnectorModeFromTicket(ticket: string): SemiAutoConnectorMode { const normalized = ticket.trim() if (normalized.startsWith('mock-ticket-')) return 'mock' diff --git a/apps/backend/src/services/semi-auto/huya-native/huya-native-connector.ts b/apps/backend/src/services/semi-auto/huya-native/huya-native-connector.ts index 52f0bb8f..90cb3cd9 100644 --- a/apps/backend/src/services/semi-auto/huya-native/huya-native-connector.ts +++ b/apps/backend/src/services/semi-auto/huya-native/huya-native-connector.ts @@ -107,6 +107,21 @@ export type HuyaNativeConnectorOptions = { rpcTimeoutMs?: number } +export type HuyaStoredCredential = { + platformUid: string + nickname: string + cookies: Array<{ + name: string + value: string + domain: string + path: string + secure: boolean + httpOnly: boolean + hostOnly?: boolean + expiresAt: string | null + }> +} + function defaultBindingState(): NativeBindState { return { status: 'not_started', @@ -545,6 +560,26 @@ export class HuyaNativeConnector { }) } + exportSessionCredential(sessionIdInput: string): HuyaStoredCredential { + const session = this.requireAuthenticatedSession(sessionIdInput) + return { + platformUid: String(session.uid), + nickname: session.nickname, + cookies: cookiePairs(session.cookie) + .filter(([name, value]) => name && value) + .map(([name, value]) => ({ + name, + value, + domain: '.huya.com', + path: '/', + secure: true, + httpOnly: false, + hostOnly: false, + expiresAt: null, + })), + } + } + async consumeBrowserTicket(ticketInput: string): Promise { this.prune() const ticket = readString(ticketInput) diff --git a/apps/backend/src/services/semi-auto/huya-native/huya-native.test.ts b/apps/backend/src/services/semi-auto/huya-native/huya-native.test.ts index 3f7e45c8..81fcadcf 100644 --- a/apps/backend/src/services/semi-auto/huya-native/huya-native.test.ts +++ b/apps/backend/src/services/semi-auto/huya-native/huya-native.test.ts @@ -167,6 +167,14 @@ test('内置 Connector 完成扫码、CK 兑换和一次性票据且不泄露 CK assert.equal(JSON.stringify(authenticated).includes('secret-biztoken'), false) assert.equal(JSON.stringify(authenticated).includes('server-issued-cred'), false) + const credential = connector.exportSessionCredential(created.sessionId) + assert.equal(credential.platformUid, '123456') + assert.equal(credential.nickname, '测试用户') + assert.equal( + credential.cookies.find((item) => item.name === 'udb_cred')?.value, + 'server-issued-cred', + ) + const ticket = await connector.createBrowserTicket({ sessionId: created.sessionId, targetUrl: 'https://www.huya.com/', 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 deleted file mode 100644 index 0136fc43..00000000 --- a/apps/backend/src/services/semi-auto/semi-auto-credential-crypto.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -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 deleted file mode 100644 index 1c2a4902..00000000 --- a/apps/backend/src/services/semi-auto/semi-auto-credential-crypto.ts +++ /dev/null @@ -1,56 +0,0 @@ -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 index 83a12ba1..8108391f 100644 --- a/apps/backend/src/services/semi-auto/semi-auto-credential-service.ts +++ b/apps/backend/src/services/semi-auto/semi-auto-credential-service.ts @@ -1,10 +1,6 @@ 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 @@ -38,7 +34,7 @@ type CredentialRow = { platform: 'huya' | 'weibo' platform_uid: string platform_nickname: string - credential_ciphertext: string + credential_payload_json: string credential_source: SemiAutoCredentialSource | '' credential_status: 'unknown' | 'valid' | 'invalid' | 'error' credential_updated_at: string @@ -102,8 +98,7 @@ export function buildSemiAutoCredentialCookieHeader( } function decodeCredential(row: CredentialRow): SemiAutoCredentialRecord { - const decrypted = decryptSemiAutoCredential(row.credential_ciphertext) - const parsed = JSON.parse(decrypted) as Record + const parsed = JSON.parse(row.credential_payload_json) as Record return { id: Number(row.id), platform: row.platform, @@ -132,15 +127,11 @@ export async function upsertSemiAutoCredentialWithClient( if (cookies.length === 0) { throw new Error('平台 Cookie 为空,无法保存半自动履约凭证') } - const encrypted = encryptSemiAutoCredential( - JSON.stringify({ - cookies, - }), - ) + const payloadJson = JSON.stringify({ cookies }) const result = await client.query<{ id: number }>( ` INSERT INTO semi_auto_credentials ( - platform, platform_uid, platform_nickname, credential_ciphertext, + platform, platform_uid, platform_nickname, credential_payload_json, credential_source, credential_status, credential_updated_at, last_checked_at, last_check_error, created_at, updated_at ) VALUES ( @@ -148,7 +139,7 @@ export async function upsertSemiAutoCredentialWithClient( ) ON CONFLICT (platform, platform_uid) DO UPDATE SET platform_nickname = EXCLUDED.platform_nickname, - credential_ciphertext = EXCLUDED.credential_ciphertext, + credential_payload_json = EXCLUDED.credential_payload_json, credential_source = EXCLUDED.credential_source, credential_status = 'valid', credential_updated_at = NOW(), @@ -163,7 +154,7 @@ export async function upsertSemiAutoCredentialWithClient( String(input.payload.nickname || '') .trim() .slice(0, 120), - encrypted, + payloadJson, input.source, ], ) @@ -178,7 +169,7 @@ export async function loadSemiAutoCredential( const result = await query( ` SELECT - id, platform, platform_uid, platform_nickname, credential_ciphertext, + id, platform, platform_uid, platform_nickname, credential_payload_json, credential_source, credential_status, credential_updated_at FROM semi_auto_credentials WHERE id = $1 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 3b60b4ee..5a12539e 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 @@ -19,6 +19,7 @@ import { import { createHuyaBrowserTicket, createHuyaLoginSession, + exportHuyaSessionCredential, getHuyaBinding, getHuyaLoginSession, revokeHuyaSession, @@ -164,6 +165,9 @@ function normalizeOrderNo(value: unknown): string { function displayStatus(task: TaskRow) { if (task.execution_status === 'completed') return { key: 'completed', label: '已完成' } if (task.execution_status === 'cancelled') return { key: 'cancelled', label: '已取消' } + if (task.credential_status === 'invalid' || task.credential_status === 'error') { + return { key: 'credential_expired', label: '登录已失效,需重新授权' } + } if ( task.execution_status === 'failed' || task.login_status === 'failed' || @@ -384,6 +388,66 @@ async function getBindingForTask( : getHuyaBinding(sessionId, task.connector_mode) } +function errorCodeOf(error: unknown) { + return error && typeof error === 'object' + ? String((error as { errorCode?: unknown }).errorCode || '').trim() + : '' +} + +function isInvalidCredentialError(error: unknown) { + return ['weibo_credential_invalid', 'weibo_credential_account_mismatch'].includes( + errorCodeOf(error), + ) +} + +async function resetTaskForCustomerLogin( + task: TaskRow, + input: { + actorType: TaskActorType + actorId?: string + reason: string + eventType: string + }, +) { + if (task.execution_status === 'completed' || task.execution_status === 'cancelled') { + return task + } + await withTransaction(async (client) => { + await client.query( + ` + UPDATE semi_auto_tasks + SET connector_session_id = '', + login_status = 'waiting_login', + binding_status = 'not_started', + execution_status = 'waiting_customer', + customer_link_opened_at = NULL, + auth_started_at = NULL, + authenticated_at = NULL, + binding_started_at = NULL, + bound_at = NULL, + last_error = $2, + updated_at = NOW() + WHERE id = $1 + `, + [task.id, input.reason], + ) + await appendTaskEvent(client, task.id, { + eventType: input.eventType, + actorType: input.actorType, + ...(input.actorId ? { actorId: input.actorId } : {}), + fromLoginStatus: task.login_status, + toLoginStatus: 'waiting_login', + fromBindingStatus: task.binding_status, + toBindingStatus: 'not_started', + fromExecutionStatus: task.execution_status, + toExecutionStatus: 'waiting_customer', + detail: { reason: input.reason }, + }) + }) + publishAdminOrderRealtimeChange(Number(task.order_id)) + return loadTask(task.id) +} + async function ensureConnectorSessionForTask(task: TaskRow): Promise { if (task.execution_status === 'completed' || task.execution_status === 'cancelled') return task if (task.connector_session_id) { @@ -434,10 +498,18 @@ async function ensureConnectorSessionForTask(task: TaskRow): Promise { }) return loadTask(task.id) } catch (error) { + const credentialInvalid = isInvalidCredentialError(error) await markSemiAutoCredentialCheck(task.credential_id, { - status: 'invalid', + status: credentialInvalid ? 'invalid' : 'error', error: error instanceof Error ? error.message : String(error), }).catch(() => undefined) + if (credentialInvalid) { + return resetTaskForCustomerLogin(task, { + actorType: 'connector', + reason: '平台登录凭证已失效,需客户重新登录', + eventType: 'credential_invalidated', + }) + } throw error } } @@ -613,13 +685,17 @@ async function updateLoginTask( : 'waiting_customer' : task.execution_status const storedCredential = - task.platform === 'weibo' && mapped.status === 'authenticated' && mapped.hasCredential - ? exportWeiboSessionCredential(session.sessionId, task.connector_mode) + mapped.status === 'authenticated' && mapped.hasCredential + ? task.platform === 'weibo' + ? exportWeiboSessionCredential(session.sessionId, task.connector_mode) + : task.connector_mode === 'embedded' + ? exportHuyaSessionCredential(session.sessionId, task.connector_mode) + : null : null await withTransaction(async (client) => { const credentialId = storedCredential ? await upsertSemiAutoCredentialWithClient(client, { - platform: 'weibo', + platform: task.platform, source: 'qr', payload: storedCredential, }) @@ -1259,7 +1335,7 @@ export async function exportAdminSemiAutoCredential(taskIdInput: unknown, sessio ? configuredTarget : credential.platform === 'weibo' ? 'https://weibo.com/' - : '' + : 'https://www.huya.com/' const cookieHeader = targetUrl ? buildSemiAutoCredentialCookieHeader(credential.cookies, targetUrl) : '' @@ -1323,6 +1399,43 @@ export async function syncAdminSemiAutoTask(taskIdInput: unknown) { return getAdminSemiAutoTask(taskId) } +export async function requestAdminSemiAutoCustomerReauth( + taskIdInput: unknown, + session: AdminSession, +) { + const taskId = normalizeId(taskIdInput) + const task = await loadTask(taskId) + if (task.execution_status === 'completed') { + throw createHttpError('已完成任务不能重新授权', { + statusCode: 409, + errorCode: 'semi_auto_task_completed', + }) + } + if (task.execution_status === 'cancelled') { + throw createHttpError('已取消任务不能重新授权', { + statusCode: 409, + errorCode: 'semi_auto_task_cancelled', + }) + } + 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.connector_session_id) { + await revokeSessionForTask(task, task.connector_session_id).catch(() => undefined) + } + await resetTaskForCustomerLogin(task, { + actorType: 'admin', + actorId: String(adminUserId), + reason: '客服已重新发送授权链接,等待客户重新登录', + eventType: 'customer_reauth_requested', + }) + return getAdminSemiAutoTask(taskId) +} + export async function createAdminBrowserSession( taskIdInput: unknown, targetUrlInput: unknown, 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 3ec65fc5..0203dde3 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 @@ -15,13 +15,18 @@ test( async () => { process.env.DATABASE_URL = TEST_DATABASE_URL const { closeDb, query } = await import('../../db/client.js') - const { ensureSemiAutoTasksForOrder, listAdminSemiAutoTasks, listPublicSemiAutoTasks } = - await import('./semi-auto-task-service.js') + const { + ensureSemiAutoTasksForOrder, + listAdminSemiAutoTasks, + listPublicSemiAutoTasks, + requestAdminSemiAutoCustomerReauth, + } = await import('./semi-auto-task-service.js') const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}` const orderNo = `IT-SEMI-AUTO-${suffix}` const skuKey = `半自动测试-${suffix}` let orderId = 0 let configId = 0 + let credentialId = 0 try { const order = await query<{ id: number }>( ` @@ -102,8 +107,61 @@ test( assert.equal(tasks[0]?.orderNo, orderNo) assert.equal(tasks[0]?.quantity, 1) assert.equal(tasks[0]?.semiAutoConfig.pageTitle, '虎牙授权测试') + + const taskId = Number(tasks[0]?.id || 0) + const credential = await query<{ id: number }>( + ` + INSERT INTO semi_auto_credentials ( + platform, platform_uid, platform_nickname, credential_payload_json, + credential_source, credential_status, credential_updated_at, + last_checked_at, last_check_error, created_at, updated_at + ) VALUES ( + 'huya', $1, '集成测试虎牙', '{"cookies":[]}', + 'qr', 'invalid', NOW(), NOW(), 'CK 已失效', NOW(), NOW() + ) RETURNING id + `, + [`it-huya-${suffix}`], + ) + credentialId = Number(credential.rows[0]?.id || 0) + await query( + ` + UPDATE semi_auto_tasks + SET credential_id = $2, + connector_session_id = $3, + login_status = 'authenticated', + binding_status = 'confirmed', + execution_status = 'waiting_operator', + customer_link_opened_at = NOW(), + authenticated_at = NOW(), + bound_at = NOW(), + updated_at = NOW() + WHERE id = $1 + `, + [taskId, credentialId, `it-huya-session-${suffix}`], + ) + + const reauthorized = await requestAdminSemiAutoCustomerReauth(taskId, { + userId: 1, + role: 'support', + displayName: '集成测试客服', + } as never) + assert.equal(reauthorized.task.loginStatus, 'waiting_login') + assert.equal(reauthorized.task.bindingStatus, 'not_started') + assert.equal(reauthorized.task.executionStatus, 'waiting_customer') + assert.equal(reauthorized.task.customerLinkOpenedAt, null) + const retainedCredential = await query<{ credential_id: number }>( + 'SELECT credential_id FROM semi_auto_tasks WHERE id = $1', + [taskId], + ) + assert.equal(Number(retainedCredential.rows[0]?.credential_id), credentialId) + assert.ok( + reauthorized.events.some((event) => event.eventType === 'customer_reauth_requested'), + ) } finally { if (orderId > 0) await query('DELETE FROM orders WHERE id = $1', [orderId]) + if (credentialId > 0) { + await query('DELETE FROM semi_auto_credentials WHERE id = $1', [credentialId]) + } if (configId > 0) { await query('DELETE FROM manual_fulfillment_configs WHERE id = $1', [configId]) } @@ -341,12 +399,10 @@ test( ) test( - '微博账号 CK 以账号级密文保存并可解密加载', + '微博账号 CK 以账号级 Cookie JSON 保存并可加载', { 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') @@ -374,18 +430,17 @@ test( }), ) const raw = await query<{ - credential_ciphertext: string + credential_payload_json: string credential_source: string credential_status: string }>( - `SELECT credential_ciphertext, credential_source, credential_status + `SELECT credential_payload_json, 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) + assert.match(raw.rows[0]?.credential_payload_json || '', /"cookies"/) const credential = await loadSemiAutoCredential(credentialId) assert.equal(credential?.platformUid, `it-weibo-${suffix}`) @@ -394,7 +449,6 @@ test( if (credentialId > 0) { await query('DELETE FROM semi_auto_credentials WHERE id = $1', [credentialId]) } - delete process.env.SEMI_AUTO_CREDENTIAL_ENCRYPTION_KEY await closeDb() } }, 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 1893b9f1..e8711548 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 @@ -8,12 +8,13 @@ * - 一次性浏览器注入票据 * * 手机号和验证码只保存在短期内存会话中;登录成功后的账号 Cookie 由任务服务 - * 导出为账号级凭证,经 AES-256-GCM 加密后落库,不写日志。 + * 导出为账号级凭证写入内部凭证表,不写日志。 */ import crypto from 'node:crypto' import { createHttpError } from '../../../utils/http.js' +import { logWarn } from '../../../utils/logger.js' import type { ConnectorBindingSession, ConnectorBrowserTicket, @@ -23,8 +24,10 @@ import type { const PASSPORT_BASE = 'https://passport.weibo.com' const WEIBO_BASE = 'https://weibo.com' +const WEIBO_RETURN_URL = 'https://weibo.com/' const WEIBO_LOGIN_URL = - 'https://weibo.com/newlogin?tabtype=weibo&gid=102803&openLoginLayer=0&url=https%3A%2F%2Fweibo.com%2F' + `https://weibo.com/newlogin?tabtype=weibo&gid=102803&openLoginLayer=0` + + `&url=${WEIBO_RETURN_URL}` const WEIBO_LOGIN_VER = '20250520' const SIGNIN_REFERER = `${PASSPORT_BASE}/sso/signin?entry=miniblog&source=miniblog&disp=popup` + @@ -312,6 +315,7 @@ type SmsLoginState = { mobile: string cookies: WeiboCookieJar mfaId: string + rid: string createdAtMs: number expiresAtMs: number } @@ -536,6 +540,7 @@ export class WeiboNativeConnector { mobile, cookies: await this.createBootstrapSession(), mfaId: '', + rid: crypto.randomBytes(22).toString('base64url'), createdAtMs: this.now(), expiresAtMs: this.now() + SMS_COOKIE_TTL_MS, } @@ -629,7 +634,7 @@ export class WeiboNativeConnector { scode: encodeSsoField(code), el: '2', disp: 'popup', - rid: String(input.rid || '').trim() || 'norid', + rid: String(input.rid || '').trim() || state.rid, }) const loginUrl = `${PASSPORT_BASE}/sso/v2/login` const response = await this.fetchImpl(`${PASSPORT_BASE}/sso/v2/login`, { @@ -642,34 +647,49 @@ export class WeiboNativeConnector { const payload = (await response.json().catch(() => null)) as { retcode?: number msg?: string - data?: { data?: { act?: string; location?: string; mfa_id?: string } } + data?: { + act?: string + location?: string + mfa_id?: string + data?: { act?: string; location?: string; mfa_id?: string } + } } | null if (!response.ok || !payload) { + logWarn('[weibo/sms-login]', '微博短信登录请求失败', { + taskId: Number(input.taskId) || 0, + status: response.status, + hasPayload: Boolean(payload), + }) throw createHttpError(`微博短信登录失败:HTTP ${response.status}`, { statusCode: 502, errorCode: 'weibo_sms_login_failed', }) } - const act = String(payload.data?.data?.act || '') + const act = String(payload.data?.data?.act || payload.data?.act || '') if (act === 'mfa_1' || act === 'mfa_2') { throw createHttpError('微博要求二次验证,请在微博 App 完成验证后重试', { statusCode: 409, errorCode: 'weibo_sms_mfa_required', }) } - if (act !== 'goto') { + const location = String(payload.data?.data?.location || payload.data?.location || '').trim() + if (!location) { + logWarn('[weibo/sms-login]', '微博短信登录未通过', { + taskId: Number(input.taskId) || 0, + status: response.status, + retcode: Number(payload.retcode) || 0, + act, + message: String(payload.msg || '') + .trim() + .slice(0, 200), + dataKeys: payload.data ? Object.keys(payload.data) : [], + nestedDataKeys: payload.data?.data ? Object.keys(payload.data.data) : [], + }) throw createHttpError(payload.msg || '微博短信登录失败,请检查验证码', { statusCode: 409, errorCode: 'weibo_sms_login_failed', }) } - const location = String(payload.data?.data?.location || '').trim() - if (!location) { - throw createHttpError('微博登录成功但缺少跳转地址', { - statusCode: 502, - errorCode: 'weibo_sms_login_failed', - }) - } const nowMs = this.now() const session: WeiboSession = { sessionId: crypto.randomBytes(18).toString('base64url'), @@ -832,14 +852,33 @@ export class WeiboNativeConnector { } private async finishLogin(session: WeiboSession, loginUrl: string): Promise { - await this.followWithCookies(loginUrl, session.cookies) + const { finalUrl, body } = await this.followWithCookies(loginUrl, session.cookies) if (!session.cookies.has(WEIBO_BASE, 'SUB')) { session.status = 'failed' session.message = '微博登录成功但未获取到登录 Cookie' return } - session.account = await this.fetchAccount(session.cookies.headerFor(WEIBO_BASE)) + let homeStatus = 0 + let homeBody = '' + session.account = this.parseAccountFromHome(body) + if (!session.account) { + const home = await this.fetchHomePage(session.cookies) + homeStatus = home.status + homeBody = home.body + session.account = this.parseAccountFromHome(homeBody) + } + if (!session.account) { + session.account = await this.fetchAccount(session.cookies.headerFor(WEIBO_BASE)) + } if (!session.account.uid) { + logWarn('[weibo/account]', '微博账号信息读取失败', { + finalUrl, + followedBodyLength: body.length, + followedHasConfig: body.includes('window.$CONFIG'), + homeStatus, + homeBodyLength: homeBody.length, + homeHasConfig: homeBody.includes('window.$CONFIG'), + }) session.status = 'failed' session.message = '微博登录成功但账号信息读取失败' return @@ -851,9 +890,10 @@ export class WeiboNativeConnector { private async followWithCookies( url: string, cookies: WeiboCookieJar, - ): Promise<{ finalUrl: string }> { + ): Promise<{ finalUrl: string; body: string }> { let currentUrl = url - for (let index = 0; index < 8; index += 1) { + let body = '' + for (let index = 0; index < 12; index += 1) { const response = await this.fetchImpl(currentUrl, { method: 'GET', headers: { @@ -871,10 +911,96 @@ export class WeiboNativeConnector { currentUrl = new URL(location, currentUrl).toString() continue } - await response.arrayBuffer().catch(() => undefined) + body = await response.text().catch(() => '') break } - return { finalUrl: currentUrl } + return { finalUrl: currentUrl, body } + } + + private async fetchHomePage(cookies: WeiboCookieJar): Promise<{ status: number; body: string }> { + try { + const response = await this.fetchImpl(`${WEIBO_BASE}/`, { + 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', + referer: WEIBO_BASE, + cookie: cookies.headerFor(WEIBO_BASE), + }, + redirect: 'follow', + signal: AbortSignal.timeout(this.timeoutMs), + }) + cookies.addFromResponse(response.headers, response.url || `${WEIBO_BASE}/`) + return { + status: response.status, + body: await response.text().catch(() => ''), + } + } catch { + return { status: 0, body: '' } + } + } + + private parseAccountFromHome(htmlInput: string): WeiboAccount | null { + const html = String(htmlInput || '') + const markerIndex = html.indexOf('window.$CONFIG') + if (markerIndex < 0) return null + const start = html.indexOf('{', markerIndex) + if (start < 0) return null + + let depth = 0 + let inString = false + let escaped = false + let end = -1 + for (let index = start; index < html.length; index += 1) { + const char = html[index] + if (inString) { + if (escaped) { + escaped = false + } else if (char === '\\') { + escaped = true + } else if (char === '"') { + inString = false + } + continue + } + if (char === '"') { + inString = true + continue + } + if (char === '{') depth += 1 + if (char === '}') { + depth -= 1 + if (depth === 0) { + end = index + 1 + break + } + } + } + if (end <= start) return null + + try { + const config = JSON.parse(html.slice(start, end)) as { + uid?: number | string + user?: { + id?: number | string + idstr?: number | string + screen_name?: string + avatar_large?: string + avatar_hd?: string + } + } + const user = config.user + const uid = String(user?.idstr || user?.id || config.uid || '') + if (!uid) return null + return { + uid, + nickname: String(user?.screen_name || ''), + avatar: String(user?.avatar_large || user?.avatar_hd || ''), + } + } catch { + return null + } } private async fetchAccount(cookie: string): Promise { 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 89d8cc86..19ce8831 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 @@ -102,10 +102,21 @@ function defaultHandler(url: string): Response { }) } if (url === 'https://weibo.com/') { - return new Response('', { - status: 200, - headers: { 'content-type': 'text/html' }, - }) + return new Response( + ``, + { + status: 200, + headers: { 'content-type': 'text/html' }, + }, + ) } if (url.includes('/ajax/side/info')) { return jsonResponse({ @@ -226,6 +237,10 @@ test('微博短信登录建立会话并可生成一次性注入票据', async () assert.equal(session.status, 'authenticated') assert.equal(session.huyaUid, '123456') assert.equal(session.nickname, '微博测试账号') + assert.equal( + calls.some((call) => call.url.includes('/ajax/side/info')), + false, + ) const loginCall = calls.find((item) => item.url.includes('/sso/v2/login')) assert.ok(loginCall) @@ -233,6 +248,19 @@ test('微博短信登录建立会话并可生成一次性注入票据', async () assert.equal(form.get('type'), '2') assert.equal(form.get('username'), 'T3Gs3iR_9fN7A9t') assert.equal(form.get('scode'), 'T3r81iMS') + assert.equal( + form.get('url'), + 'https://weibo.com/newlogin?tabtype=weibo&gid=102803&openLoginLayer=0&url=https://weibo.com/', + ) + assert.equal(form.get('rid')?.length, 30) + assert.notEqual(form.get('rid'), 'norid') + const loginHeaders = loginCall.init.headers as Record + assert.equal( + loginHeaders.referer, + 'https://passport.weibo.com/sso/signin?entry=miniblog&source=miniblog&disp=popup' + + '&url=https%3A%2F%2Fweibo.com%2Fnewlogin%3Ftabtype%3Dweibo%26gid%3D102803' + + '%26openLoginLayer%3D0%26url%3Dhttps%3A%2F%2Fweibo.com%2F&from=weibopro', + ) const ticket = await connector.createBrowserTicket({ sessionId, @@ -248,6 +276,63 @@ test('微博短信登录建立会话并可生成一次性注入票据', async () await assert.rejects(() => connector.consumeBrowserTicket(ticket.ticket), /票据不存在或已过期/) }) +test('微博短信登录兼容成功响应省略 act、直接返回 data.location', async () => { + const { fetchImpl } = createFetchMock((url) => { + if (url.includes('/sso/v2/login')) { + return jsonResponse({ + retcode: 20000000, + msg: '', + data: { + location: 'https://passport.weibo.com/sso/v2/goto?ticket=demo-flat', + }, + }) + } + return defaultHandler(url) + }) + const connector = new WeiboNativeConnector({ fetchImpl }) + await connector.sendSmsCode({ taskId: 1, mobile: '13800138000' }) + const { session } = await connector.loginWithSms({ + taskId: 1, + orderNo: 'MOCKWB-FLAT', + mobile: '13800138000', + code: '123456', + }) + + assert.equal(session.status, 'authenticated') + assert.equal(session.hasCredential, true) +}) + +test('微博登录落地页无账号配置时会显式请求首页读取账号', async () => { + const { fetchImpl, calls } = createFetchMock((url) => { + if (url.includes('/sso/v2/goto')) { + return new Response(null, { + status: 302, + headers: { location: 'https://weibo.com/intermediate' }, + }) + } + if (url === 'https://weibo.com/intermediate') { + return new Response('intermediate', { + status: 200, + headers: { 'content-type': 'text/html' }, + }) + } + return defaultHandler(url) + }) + const connector = new WeiboNativeConnector({ fetchImpl }) + await connector.sendSmsCode({ taskId: 1, mobile: '13800138000' }) + const { session } = await connector.loginWithSms({ + taskId: 1, + orderNo: 'MOCKWB-HOME', + mobile: '13800138000', + code: '123456', + }) + + assert.equal(session.status, 'authenticated') + assert.equal(session.huyaUid, '123456') + assert.ok(calls.some((call) => call.url === 'https://weibo.com/intermediate')) + assert.ok(calls.some((call) => call.url === 'https://weibo.com/')) +}) + test('微博短信发送遇到 mfa_1 时返回易盾挑战,完成挑战后可用 mfa_id 重发', async () => { let sendCount = 0 const { fetchImpl, calls } = createFetchMock((url, init) => { diff --git a/apps/backend/src/types/runtime-config.ts b/apps/backend/src/types/runtime-config.ts index 6bb89dd5..e420ffc6 100644 --- a/apps/backend/src/types/runtime-config.ts +++ b/apps/backend/src/types/runtime-config.ts @@ -67,8 +67,6 @@ 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 d0f42d78..672ffa02 100644 --- a/apps/frontend/src/pages/admin/AdminSemiAutoTasksPage.tsx +++ b/apps/frontend/src/pages/admin/AdminSemiAutoTasksPage.tsx @@ -2,16 +2,22 @@ import { CheckCircleOutlined, ChromeOutlined, CopyOutlined, + DownOutlined, KeyOutlined, + LinkOutlined, + MoreOutlined, ReloadOutlined, + SendOutlined, UserSwitchOutlined, } from '@ant-design/icons' import { + Alert, App, Button, Card, Descriptions, Drawer, + Dropdown, Form, Input, InputNumber, @@ -29,7 +35,6 @@ import { useEffect, useMemo, useState } from 'react' import ImageUpload from '@/components/files/ImageUpload' import PageHeader from '@/components/admin/PageHeader' import PercentTable from '@/components/common/PercentTable' -import { uploadFile } from '@/services/files' import { cancelAdminSemiAutoTask, claimAdminSemiAutoTask, @@ -38,9 +43,10 @@ import { exportAdminSemiAutoCredential, fetchAdminSemiAutoTask, fetchAdminSemiAutoTasks, + requestAdminSemiAutoCustomerReauth, syncAdminSemiAutoTask, } from '@/services/admin/semi-auto' -import type { SemiAutoCredentialExport, SemiAutoTask } from '@/types/admin/semi-auto' +import type { 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' @@ -54,6 +60,7 @@ const STATUS_COLORS: Record = { waiting_binding: 'gold', binding_confirm: 'orange', waiting_operator: 'volcano', + credential_expired: 'red', processing: 'processing', completed: 'success', failed: 'error', @@ -82,7 +89,6 @@ 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( @@ -169,7 +175,7 @@ export default function AdminSemiAutoTasksPage() { return } window.postMessage({ type: 'ORDER_SITE_SEMI_AUTO_INJECT', ticket }, window.location.origin) - message.info('正在通过虎牙履约助手注入登录态,请稍候') + message.info('正在注入浏览器登录态,请稍候') void refreshAll() }, onError: (error) => @@ -179,13 +185,32 @@ export default function AdminSemiAutoTasksPage() { const credentialExportMutation = useMutation({ mutationFn: (taskId: number) => exportAdminSemiAutoCredential(taskId), onSuccess: (response) => { - if (!response.data) { - message.error('账号 CK 导出失败') + const text = + String(response.data?.cookieHeader || '').trim() || + (response.data?.cookies || []).map((cookie) => `${cookie.name}=${cookie.value}`).join('; ') + if (!text) { + message.error('当前任务没有可复制的 CK') return } - setCredentialExport(response.data) + void navigator.clipboard + .writeText(text) + .then(() => message.success('最终 CK 已复制')) + .catch(() => message.error('复制失败,请检查浏览器剪贴板权限')) }, - onError: (error) => message.error(error instanceof Error ? error.message : '导出账号 CK 失败'), + onError: (error) => message.error(error instanceof Error ? error.message : '复制账号 CK 失败'), + }) + + const reauthMutation = useMutation({ + mutationFn: (task: SemiAutoTask) => requestAdminSemiAutoCustomerReauth(task.id), + onSuccess: async (response, task) => { + const copied = await copyCustomerLink(response.data?.task || task, '') + message.success( + copied ? '已重新发起授权,客户链接已复制' : '已重新发起授权,请手动复制客户链接', + ) + await refreshAll() + }, + onError: (error) => + message.error(error instanceof Error ? error.message : '重新发起客户授权失败'), }) const completeMutation = useMutation({ @@ -231,33 +256,114 @@ export default function AdminSemiAutoTasksPage() { ]) } - function copyCustomerLink(task: SemiAutoTask) { + async function copyCustomerLink(task: SemiAutoTask, successMessage = '客户授权链接已复制') { const url = `${window.location.origin}/c/${encodeURIComponent(task.orderNo)}` - void navigator.clipboard - .writeText(url) - .then(() => message.success('客户授权链接已复制')) - .catch(() => message.error('复制失败,请手动复制')) + try { + await navigator.clipboard.writeText(url) + if (successMessage) message.success(successMessage) + return true + } catch { + message.error('复制失败,请手动复制客户链接') + return false + } } - function copyCredentialText(text: string, label: string) { - void navigator.clipboard - .writeText(text) - .then(() => message.success(`${label}已复制`)) - .catch(() => message.error('复制失败,请手动选择文本')) - } - - function confirmExportCredential(task: SemiAutoTask) { + function confirmReauth(task: SemiAutoTask) { modal.confirm({ - title: '导出账号 CK', - content: - 'CK 等同于平台登录态。导出后会显示在当前页面,请勿发给无关人员;关闭弹窗后不会保留在页面状态中。', - okText: '确认导出', + title: '重新发送客户授权', + content: '当前登录状态会失效,客户需要打开新的授权链接重新登录。原授权记录会保留。', + okText: '重新发送并复制链接', + cancelText: '取消', + onOk: () => reauthMutation.mutateAsync(task), + }) + } + + function confirmCopyCredential(task: SemiAutoTask) { + modal.confirm({ + title: '复制最终 CK', + content: 'CK 等同于平台登录态。复制后请勿发给无关人员,也不要保存到聊天记录或工单备注。', + okText: '确认复制', okButtonProps: { danger: true }, cancelText: '取消', onOk: () => credentialExportMutation.mutateAsync(task.id), }) } + function credentialMenu(task: SemiAutoTask) { + const canExport = hasAdminPermission('semi_auto.credential.export') + return { + items: [ + ...(canExport + ? [ + { + key: 'copy', + icon: , + label: '复制最终 CK', + disabled: !task.credentialReady || credentialExportMutation.isPending, + }, + ] + : []), + { + key: 'inject', + icon: , + label: '注入浏览器', + disabled: task.bindingStatus !== 'confirmed' || injectionMutation.isPending, + }, + ], + onClick: ({ key }: { key: string }) => { + if (key === 'copy') { + confirmCopyCredential(task) + return + } + if (key === 'inject') injectionMutation.mutate(task) + }, + } + } + + function taskMenu(task: SemiAutoTask) { + const active = task.executionStatus !== 'completed' && task.executionStatus !== 'cancelled' + return { + items: [ + { + key: 'copy', + icon: , + label: '复制客户链接', + }, + { + key: 'sync', + icon: , + label: '刷新客户状态', + disabled: syncMutation.isPending, + }, + ...(!task.assignedAdminUserId + ? [ + { + key: 'claim', + icon: , + label: '领取任务', + disabled: claimMutation.isPending, + }, + ] + : []), + ...(active + ? [ + { + key: 'cancel', + danger: true, + label: '取消任务', + }, + ] + : []), + ], + onClick: ({ key }: { key: string }) => { + if (key === 'copy') void copyCustomerLink(task) + if (key === 'sync') syncMutation.mutate(task.id) + if (key === 'claim') claimMutation.mutate(task.id) + if (key === 'cancel') confirmCancel(task) + }, + } + } + function openComplete(task: SemiAutoTask) { setCompleteTask(task) setCompleteFiles([]) @@ -300,17 +406,14 @@ export default function AdminSemiAutoTasksPage() { { title: '平台', dataIndex: 'platform', - width: 90, - render: (value: string) => {PLATFORM_LABELS[value] || value}, - }, - { - title: 'Connector', - dataIndex: 'connectorMode', - width: 110, - render: (value: string) => ( - - {value === 'mock' ? '内置 Mock' : '真实'} - + width: 120, + render: (value: string, task) => ( + + {PLATFORM_LABELS[value] || value} + + {task.connectorMode === 'mock' ? 'Mock' : '真实'} + + ), }, { @@ -365,58 +468,46 @@ export default function AdminSemiAutoTasksPage() { { title: '操作', key: 'actions', - width: 360, + width: 340, render: (_, task) => ( - - - {!task.assignedAdminUserId ? ( + {taskNeedsCustomerReauth(task) ? ( ) : null} - - {task.executionStatus !== 'completed' && task.executionStatus !== 'cancelled' ? ( - <> - - - + + + + {task.executionStatus !== 'completed' && + task.executionStatus !== 'cancelled' && + !taskNeedsCustomerReauth(task) ? ( + ) : null} + + + ), }, @@ -582,6 +673,14 @@ export default function AdminSemiAutoTasksPage() { {formatAdminDateTime(detail.task.updatedAt)} + {taskNeedsCustomerReauth(detail.task) ? ( + + ) : null} {detail.task.lastError ? ( {detail.task.lastError} @@ -589,7 +688,17 @@ export default function AdminSemiAutoTasksPage() { ) : null} - + ) : null} + - - {detail.task.credentialReady && - hasAdminPermission('semi_auto.credential.export') ? ( - - ) : null} + @@ -642,65 +737,6 @@ export default function AdminSemiAutoTasksPage() { )} - setCredentialExport(null)} - > - {credentialExport ? ( - - - 以下内容包含平台登录态,仅用于本次受控操作。关闭弹窗后请勿在聊天、日志或工单中留存。 - - - - {credentialExport.targetUrl || 'Cookie Header'} - - - - - - - 完整域名 Cookie - - - - - - ) : null} - - item?.url)?.url || '') } + +function taskNeedsCustomerReauth(task: SemiAutoTask) { + if (task.executionStatus === 'completed' || task.executionStatus === 'cancelled') return false + return ( + task.credentialStatus === 'invalid' || + task.credentialStatus === 'error' || + task.executionStatus === 'failed' || + task.loginStatus === 'failed' || + task.loginStatus === 'expired' + ) +} diff --git a/apps/frontend/src/pages/customer-fulfillment/CustomerFulfillmentPage.tsx b/apps/frontend/src/pages/customer-fulfillment/CustomerFulfillmentPage.tsx index 651a1a1d..e6c38145 100644 --- a/apps/frontend/src/pages/customer-fulfillment/CustomerFulfillmentPage.tsx +++ b/apps/frontend/src/pages/customer-fulfillment/CustomerFulfillmentPage.tsx @@ -2,28 +2,12 @@ import { CheckCircleFilled, LoadingOutlined, QrcodeOutlined, + ReloadOutlined, SafetyCertificateOutlined, } from '@ant-design/icons' -import { - Alert, - App, - Button, - Card, - Col, - Image, - Input, - Result, - Row, - Select, - Space, - Spin, - Steps, - Tabs, - Tag, - Typography, -} from 'antd' +import { App, Button, Image, Input, Select, Space, Spin, Tabs } from 'antd' import QRCode from 'qrcode' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useParams } from 'react-router' import { @@ -49,8 +33,6 @@ import type { import { formatAdminDateTime } from '@/utils/admin-time' import { runWeiboYidunChallenge } from '@/utils/weibo-yidun' -const { Text, Title, Paragraph } = Typography - const POLL_INTERVAL_MS = 2000 const DEFAULT_WEIBO_COUNTRY_CODES: CustomerWeiboCountryCode[] = [ @@ -131,6 +113,30 @@ export default function CustomerFulfillmentPage() { void markCustomerFulfillmentOpened(orderNo, selectedTask.id).catch(() => undefined) }, [orderNo, selectedTask?.id]) + useEffect(() => { + if (!selectedTask || !orderNo) return + if ( + selectedTask.executionStatus === 'completed' || + selectedTask.executionStatus === 'cancelled' + ) { + return + } + const timer = window.setInterval(() => { + void load(true) + }, POLL_INTERVAL_MS) + return () => window.clearInterval(timer) + }, [load, orderNo, selectedTask?.executionStatus, selectedTask?.id]) + + useEffect(() => { + if (!selectedTask) return + if (!['waiting_login', 'failed', 'expired'].includes(selectedTask.loginStatus)) return + setLoginInfo(null) + setBindingInfo(null) + setSmsCode('') + setSmsMfaRequired(false) + setSmsCountdown(0) + }, [selectedTask?.id, selectedTask?.loginStatus]) + useEffect(() => { if (!selectedTask || selectedTask.platform !== 'weibo' || !orderNo) return let cancelled = false @@ -402,58 +408,8 @@ export default function CustomerFulfillmentPage() { } } - if (loading) { - return ( -
- } tip="正在读取订单履约信息..." /> -
- ) - } - - if (loadError || tasks.length === 0) { - return ( -
- -
- ) - } - - if (selectedTask?.executionStatus === 'completed') { - return ( -
- } - title="履约已完成" - subTitle={ - selectedTask.completionNote - ? selectedTask.completionNote - : isWeibo - ? '客服已完成本订单的微博发帖操作,请到微博账号中确认。' - : '客服已完成本订单的充值或赠送操作,请到平台账号中确认。' - } - extra={ - selectedTask.completedAt - ? `完成时间:${formatAdminDateTime(selectedTask.completedAt)}` - : undefined - } - /> -
- ) - } - const config = selectedTask?.semiAutoConfig || {} const title = config.pageTitle || (isWeibo ? '微博账号授权' : '账号授权与角色绑定') - const description = - config.pageDescription || - (isWeibo - ? '请使用本人微博账号完成登录,登录成功后客服会处理您的订单。' - : '请按页面指引完成平台登录和游戏角色绑定,客服随后会处理您的订单。') - const currentStep = resolveStep(selectedTask, bindingInfo) const loginQrVisible = selectedTask && ['qr_pending', 'scanned'].includes(selectedTask.loginStatus) const bindingQrVisible = @@ -469,21 +425,69 @@ export default function CustomerFulfillmentPage() { bindingInfo?.hasExistingBinding === true && bindingInfo?.rebindRequested !== true - return ( -
- -
- - {title} - - - {description} - + if (loading) { + return ( + +
+ } /> + 正在读取订单履约信息...
+
+ ) + } + if (loadError || tasks.length === 0) { + return ( + +
+

订单信息读取失败

+

{loadError || '该订单没有需要客户授权或绑定的任务。'}

+
+
+ ) + } + + if (selectedTask?.executionStatus === 'completed') { + return ( + + +
+
+ +
+

履约已完成

+

+ {selectedTask.completionNote || + (isWeibo + ? '客服已完成本订单的微博发帖操作,请到微博账号中确认。' + : '客服已完成本订单的充值或赠送操作,请到平台账号中确认。')} +

+ {selectedTask.completedAt ? ( + + 完成时间:{formatAdminDateTime(selectedTask.completedAt)} + + ) : null} +
+ +
+ ) + } + + return ( + +
{tasks.length > 1 ? ( - - +
+ 本订单需要处理 +
{tasks.map((task) => (
+
) : null} - - - - - 订单号 - - {selectedTask?.orderNo} - - 商品 - {selectedTask?.productName || selectedTask?.skuName} - {selectedTask?.platformLabel || selectedTask?.platform} - {selectedTask?.displayStatus.label} - - - - }, - { title: '客服处理', icon: }, - { title: '完成' }, - ] - : [ - { title: '登录平台', icon: }, - { title: '扫码绑定', icon: }, - { title: '客服处理', icon: }, - { title: '完成' }, - ] - } - /> - - - - - {selectedTask && selectedTask.executionStatus === 'failed' ? ( - - ) : null} + {selectedTask?.loginStatus === 'waiting_login' || selectedTask?.loginStatus === 'failed' || selectedTask?.loginStatus === 'expired' ? ( - +
+
+ + + +
+

{isWeibo ? '登录微博账号' : '登录平台账号'}

+

+ {config.loginInstructions || + (isWeibo + ? '使用微博绑定手机号接收验证码,或使用微博 App 扫码登录。' + : '使用虎牙 App 扫码登录,请确认扫码账号属于本人。')} +

+
+
{isWeibo ? ( setLoginMode(key as 'sms' | 'qr')} items={[ @@ -559,29 +535,12 @@ export default function CustomerFulfillmentPage() { key: 'sms', label: '手机号登录', children: ( - - - {smsMfaRequired ? ( - - ) : null} - +
+
setMobile(event.target.value)} - placeholder="手机号" + placeholder="请输入微博绑定手机号" maxLength={countryCode === '86' ? 11 : 15} inputMode="numeric" - style={{ flex: 1 }} /> - - +
+ {smsMfaRequired ? ( +
+ 请在安全验证弹窗中完成验证,通过后会继续发送短信验证码。 +
+ ) : null} +
setSmsCode(event.target.value)} @@ -632,159 +598,279 @@ export default function CustomerFulfillmentPage() { > 登录微博 - - +
+ 如安全验证弹窗未出现,请检查浏览器是否拦截脚本。 - - + +
), }, { key: 'qr', label: '扫码登录', children: ( - - - - +
), }, ]} /> ) : ( - - - - +
)} - + ) : null} {loginQrVisible ? ( - - +
+
+ + + +
+

{isWeibo ? '使用微博 App 扫码' : '使用虎牙 App 扫码'}

+

二维码仅用于本次订单授权,请勿转发给他人。

+
+
+
{loginQrImage ? ( - {isWeibo +
+ {isWeibo +
) : ( - +
+ + 正在生成二维码... +
)} - {loginInfo?.message || '等待扫码'} +

{loginInfo?.message || '等待扫码'}

{selectedTask.loginStatus === 'scanned' ? ( - + {isWeibo ? '已扫码,请在微博客户端确认登录' : '已扫码,请在 App 确认'} - + ) : null} - - - + +
+
) : null} {bindingQrVisible ? ( - - +
+
+ + + +
+

+ {selectedTask.bindingStatus === 'role_ready' ? '确认绑定角色' : '扫码绑定角色'} +

+

+ {selectedTask.bindingStatus === 'role_ready' + ? '当前账号已有绑定角色,请确认是否继续使用;如需更换,请重新扫码换绑。' + : '请使用虎牙 App 扫码完成游戏角色绑定。'} +

+
+
+
{bindingQrPending && bindingInfo?.qrImage ? ( - 游戏角色绑定二维码 +
+ 游戏角色绑定二维码 +
) : bindingQrPending ? ( - +
+ + 正在生成绑定二维码... +
) : null} - +

{bindingInfo?.message || config.bindingInstructions || - '请使用虎牙 App 扫码完成游戏角色绑定。'} - + '扫码完成后,请回到本页面确认角色。'} +

{bindingRole ? ( - +
+ {roleChoiceRequired ? '当前已绑定角色' : '已识别角色'} + {bindingRole} + {[bindingAccount, bindingChannel].filter(Boolean).join(' · ')} +
) : null} {selectedTask.bindingStatus === 'role_ready' ? ( - +
+ {roleChoiceRequired ? ( - - 请确认这是本次需要的游戏角色;角色不正确时请重新扫码换绑。 - - ) : null} - - {roleChoiceRequired ? ( - - ) : null} - - + ) : null} +
) : null} -
- +
+
) : null} {selectedTask?.bindingStatus === 'confirmed' && selectedTask.executionStatus !== 'completed' ? ( - - - +
+
+ +
+

{isWeibo ? '微博账号已授权' : '账号角色已绑定'}

+

+ {isWeibo + ? '客服正在使用本次授权完成微博发帖,页面会自动更新。' + : '客服正在处理本订单,页面会自动更新。'} +

+ +
) : null} -
+ + +
+ + ) +} + +function CustomerAuthFrame(input: { + platform?: CustomerSemiAutoTask['platform'] + eyebrow: string + title: string + children: ReactNode +}) { + const platformClass = + input.platform === 'weibo' ? 'is-weibo' : input.platform === 'huya' ? 'is-huya' : '' + return ( +
+
+
+
+ + + +
+ {input.eyebrow} +

{input.title}

+
+
+
+
温馨提示:请使用本人账号完成授权
+
{input.children}
+
+
+ ) +} + +function CustomerTaskFacts(input: { task: CustomerSemiAutoTask | null }) { + const task = input.task + if (!task) return null + const statusTone = + task.displayStatus.key === 'credential_expired' || task.displayStatus.key === 'failed' + ? 'is-danger' + : task.bindingStatus === 'confirmed' + ? 'is-success' + : 'is-pending' + return ( +
+
+ 订单号 + {task.orderNo} +
+
+ 商品 + + {task.productName || task.skuName} × {task.quantity} + +
+
+ 平台 + {task.platformLabel || task.platform} +
+ {task.huyaUid ? ( +
+ 平台账号 ID + {task.huyaUid} +
+ ) : null} + {task.platform === 'weibo' && task.platformNickname ? ( +
+ 账号昵称 + {task.platformNickname} +
+ ) : null} + {task.platform === 'huya' && task.bindGameRole ? ( +
+ 绑定角色 + {task.bindGameRole} +
+ ) : null} + {task.platform === 'huya' && task.bindGameChannel ? ( +
+ 区服 / 渠道 + {task.bindGameChannel} +
+ ) : null} +
+ 状态 + + {task.displayStatus.label} + +
+
+ ) +} + +function CustomerPrivacyNote() { + return ( +
+ 资料仅用于本次订单履约,请勿向他人泄露验证码或账号信息。
) } @@ -795,16 +881,3 @@ function isValidWeiboMobile(countryCode: string, value: string) { if (countryCode === '86') return /^1\d{10}$/.test(digits) return digits.length >= 5 && digits.length <= 15 } - -function resolveStep(task: CustomerSemiAutoTask | null, binding: CustomerBindingInfo | null) { - if (!task) return 0 - if (task.platform === 'weibo') { - if (task.executionStatus === 'completed') return 2 - if (task.bindingStatus === 'confirmed') return 1 - return 0 - } - if (task.executionStatus === 'completed') return 3 - if (task.bindingStatus === 'confirmed') return 2 - if (task.loginStatus === 'authenticated' || binding) return 1 - return 0 -} diff --git a/apps/frontend/src/services/admin/semi-auto.ts b/apps/frontend/src/services/admin/semi-auto.ts index 1ec458d9..dc8bf0d9 100644 --- a/apps/frontend/src/services/admin/semi-auto.ts +++ b/apps/frontend/src/services/admin/semi-auto.ts @@ -28,6 +28,10 @@ export function syncAdminSemiAutoTask(taskId: number) { return apiPost(`/api/v1/admin/semi-auto/tasks/${taskId}/sync`, {}) } +export function requestAdminSemiAutoCustomerReauth(taskId: number) { + return apiPost(`/api/v1/admin/semi-auto/tasks/${taskId}/reauthorize`, {}) +} + export function createAdminSemiAutoBrowserSession(taskId: number, targetUrl?: string) { return apiPost<{ ticket: string; expiresAt: string; targetUrl: string }>( `/api/v1/admin/semi-auto/tasks/${taskId}/browser-session`, diff --git a/apps/frontend/src/styles/client.css b/apps/frontend/src/styles/client.css index 5dadeaba..f25d9e4d 100644 --- a/apps/frontend/src/styles/client.css +++ b/apps/frontend/src/styles/client.css @@ -1436,25 +1436,660 @@ } } -.customer-fulfillment-page { +.customer-auth-page { + --auth-accent: #ff8a00; + --auth-accent-hover: #ffa12f; + --auth-panel: #07142e; + --auth-panel-soft: #0d1d3d; + --auth-line: rgba(255, 255, 255, 0.11); + --auth-text: #f8fafc; + --auth-muted: #a6b0c3; min-height: 100vh; - padding: clamp(18px, 3vw, 42px) clamp(14px, 4vw, 64px); - background: - radial-gradient(circle at 10% 0%, rgba(249, 115, 22, 0.14), transparent 30%), - linear-gradient(180deg, #fffaf5 0%, #f8fafc 45%, #f1f5f9 100%); + display: flex; + justify-content: center; + overflow-x: hidden; + padding: clamp(20px, 4vw, 48px) 16px; + color: var(--auth-text); + background: #202124; } -.customer-fulfillment-page > * { - width: min(1080px, 100%); - margin-inline: auto; +.customer-auth-page.is-weibo { + --auth-accent: #ff5c9a; + --auth-accent-hover: #ff78aa; } -.customer-fulfillment-centered { +.customer-auth-shell { + width: min(760px, 100%); + min-width: 0; + align-self: flex-start; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + background: var(--auth-panel); + box-shadow: 0 28px 70px rgba(0, 0, 0, 0.34); +} + +.customer-auth-hero { + min-height: 176px; + display: flex; + align-items: center; + padding: 34px 38px; + background-image: + linear-gradient( + 90deg, + rgba(3, 18, 55, 0.97) 0%, + rgba(5, 23, 65, 0.83) 48%, + rgba(7, 20, 43, 0.45) 100% + ), + url('https://game.gtimg.cn/images/gp/web201908/inside_kv.jpg'); + background-position: center; + background-size: cover; +} + +.customer-auth-brand { + display: flex; + align-items: center; + gap: 18px; + min-width: 0; +} + +.customer-auth-logo { + width: 62px; + height: 62px; + flex: none; display: grid; place-items: center; + border: 1px solid rgba(255, 255, 255, 0.22); + border-radius: 8px; + color: var(--auth-accent); + font-size: 34px; + background: rgba(3, 12, 34, 0.58); + box-shadow: inset 0 0 24px rgba(255, 138, 0, 0.06); } -.customer-fulfillment-centered .ant-result, -.customer-fulfillment-centered .ant-spin-nested-loading { - width: min(720px, 100%); +.customer-auth-eyebrow { + display: block; + margin-bottom: 4px; + color: rgba(255, 255, 255, 0.78); + font-size: 15px; + font-weight: 500; +} + +.customer-auth-brand h1 { + margin: 0; + color: #fff; + font-size: clamp(26px, 4.2vw, 38px); + line-height: 1.25; + font-weight: 700; + letter-spacing: 0; + overflow-wrap: anywhere; +} + +.customer-auth-warning { + padding: 19px 24px; + color: #ff6975; + text-align: center; + font-size: 20px; + font-weight: 700; + background: #06122b; + border-bottom: 1px solid var(--auth-line); +} + +.customer-auth-main { + min-width: 0; + color: var(--auth-text); + background: var(--auth-panel); +} + +.customer-auth-content-stack { + min-width: 0; + display: flex; + flex-direction: column; +} + +.customer-auth-task-switcher { + display: flex; + align-items: flex-start; + gap: 16px; + padding: 20px 24px; + border-bottom: 1px solid var(--auth-line); +} + +.customer-auth-task-switcher > span { + flex: none; + padding-top: 5px; + color: var(--auth-muted); + font-size: 13px; +} + +.customer-auth-task-switcher > div { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.customer-auth-task-switcher .ant-btn { + height: auto; + padding: 6px 10px; + color: #dce6f8; + white-space: normal; + text-align: left; + border-color: rgba(255, 255, 255, 0.16); + background: rgba(255, 255, 255, 0.04); +} + +.customer-auth-task-switcher .ant-btn-primary { + color: #111827; + border-color: var(--auth-accent); +} + +.customer-auth-facts { + min-width: 0; + padding: 0 26px; +} + +.customer-auth-facts > div { + display: grid; + grid-template-columns: 132px minmax(0, 1fr); + align-items: center; + gap: 18px; + min-height: 74px; + padding: 16px 4px; + border-bottom: 1px solid var(--auth-line); +} + +.customer-auth-facts > div > span { + color: #c0c8d8; + font-size: 16px; +} + +.customer-auth-facts > div > strong { + min-width: 0; + justify-self: end; + color: #f8fafc; + font-size: 18px; + line-height: 1.5; + font-weight: 600; + text-align: right; + word-break: break-all; + overflow-wrap: anywhere; +} + +.customer-auth-section { + min-width: 0; + padding: 28px 26px 30px; + border-bottom: 1px solid var(--auth-line); +} + +.customer-auth-section-heading { + display: flex; + align-items: flex-start; + gap: 14px; +} + +.customer-auth-section-icon { + width: 42px; + height: 42px; + flex: none; + display: grid; + place-items: center; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 7px; + color: var(--auth-accent); + font-size: 22px; + background: rgba(255, 255, 255, 0.035); +} + +.customer-auth-section-heading h2, +.customer-auth-state h2 { + margin: 0 0 8px; + color: #fff; + font-size: 23px; + line-height: 1.35; + font-weight: 700; +} + +.customer-auth-section-heading p, +.customer-auth-state p, +.customer-auth-form-copy { + margin: 0; + color: var(--auth-muted); + font-size: 15px; + line-height: 1.75; +} + +.customer-auth-tabs { + margin-top: 22px; +} + +.customer-auth-tabs .ant-tabs-nav { + margin-bottom: 18px; +} + +.customer-auth-tabs .ant-tabs-nav::before { + border-bottom-color: var(--auth-line); +} + +.customer-auth-tabs .ant-tabs-tab { + padding: 12px 30px; + color: var(--auth-muted); + background: rgba(255, 255, 255, 0.025); + border: 1px solid var(--auth-line) !important; + border-radius: 4px 4px 0 0; +} + +.customer-auth-tabs .ant-tabs-tab + .ant-tabs-tab { + margin-left: 4px; +} + +.customer-auth-tabs .ant-tabs-tab.ant-tabs-tab-active { + border-color: rgba(255, 255, 255, 0.18) !important; + background: rgba(255, 255, 255, 0.09); +} + +.customer-auth-tabs .ant-tabs-tab-btn { + color: inherit !important; + font-size: 16px; +} + +.customer-auth-tabs .ant-tabs-ink-bar { + background: var(--auth-accent); +} + +.customer-auth-form { + display: flex; + flex-direction: column; + gap: 14px; + padding-top: 18px; +} + +.customer-auth-inline-input { + display: grid; + grid-template-columns: 104px minmax(0, 1fr) 126px; + overflow: hidden; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 5px; + background: rgba(6, 18, 43, 0.7); +} + +.customer-auth-inline-input.is-code { + grid-template-columns: minmax(0, 1fr) 126px; +} + +.customer-auth-inline-input > * { + min-width: 0; +} + +.customer-auth-inline-input .ant-select-selector, +.customer-auth-inline-input .ant-input, +.customer-auth-inline-input .ant-input-affix-wrapper { + color: #f8fafc !important; + border: 0 !important; + border-radius: 0 !important; + background: rgba(6, 18, 43, 0.7) !important; + box-shadow: none !important; +} + +.customer-auth-inline-input .ant-select-selector { + height: 48px !important; + padding-inline: 14px !important; + align-items: center; +} + +.customer-auth-inline-input .ant-select-selection-item { + color: #f8fafc !important; +} + +.customer-auth-inline-input .ant-input { + height: 48px; + padding-inline: 16px; + font-size: 15px; +} + +.customer-auth-inline-input .ant-input::placeholder { + color: #7f8ca5; +} + +.customer-auth-inline-input .ant-select, +.customer-auth-inline-input .ant-input { + border-right: 1px solid var(--auth-line) !important; +} + +.customer-auth-inline-input .ant-btn { + height: 48px; + color: #fff; + border: 0; + border-radius: 0; + background: rgba(48, 75, 124, 0.55); +} + +.customer-auth-inline-input .ant-btn-primary { + color: #111827; + background: var(--auth-accent); +} + +.customer-auth-option-hint { + color: var(--auth-muted); +} + +.customer-auth-note { + padding: 11px 14px; + color: #fbbf24; + font-size: 13px; + line-height: 1.6; + border: 1px solid rgba(251, 191, 36, 0.48); + border-radius: 4px; + background: rgba(251, 191, 36, 0.06); +} + +.customer-auth-form-hint { + color: #7f8ca5; + font-size: 12px; +} + +.customer-auth-page .ant-btn-primary { + color: #111827; + border-color: var(--auth-accent); + background: var(--auth-accent); + font-weight: 700; + box-shadow: none; +} + +.customer-auth-page .ant-btn-primary:hover { + color: #111827 !important; + border-color: var(--auth-accent-hover) !important; + background: var(--auth-accent-hover) !important; +} + +.customer-auth-page .customer-auth-secondary-button { + color: #fff; + border-color: rgba(148, 163, 184, 0.42); + background: rgba(19, 39, 77, 0.52); +} + +.customer-auth-page .customer-auth-secondary-button:hover { + color: #fff !important; + border-color: rgba(196, 210, 236, 0.72) !important; + background: rgba(31, 55, 102, 0.72) !important; +} + +.customer-auth-qr { + display: flex; + flex-direction: column; + align-items: center; + gap: 13px; + padding-top: 22px; + text-align: center; +} + +.customer-auth-qr > p { + margin: 0; + color: var(--auth-muted); + line-height: 1.7; +} + +.customer-auth-qr-image, +.customer-auth-qr-placeholder { + display: grid; + place-items: center; + width: 252px; + min-height: 252px; + padding: 10px; + border-radius: 6px; + background: #fff; +} + +.customer-auth-qr-placeholder { + color: #64748b; + font-size: 13px; + background: rgba(255, 255, 255, 0.94); +} + +.customer-auth-role-card { + width: min(100%, 580px); + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 4px 18px; + padding: 15px 18px; + text-align: left; + border: 1px solid rgba(255, 138, 0, 0.42); + border-radius: 5px; + background: rgba(255, 138, 0, 0.06); +} + +.customer-auth-role-card span { + color: var(--auth-muted); + font-size: 13px; +} + +.customer-auth-role-card strong { + justify-self: end; + color: #fff; + font-size: 17px; +} + +.customer-auth-role-card small { + grid-column: 1 / -1; + color: var(--auth-muted); + font-size: 12px; +} + +.customer-auth-button-stack { + width: min(100%, 580px); + display: flex; + flex-direction: column; + gap: 12px; +} + +.customer-auth-button-stack .ant-btn, +.customer-auth-form > .ant-btn { + height: 50px; + font-size: 17px; +} + +.customer-auth-status-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 34px; + padding: 4px 15px; + color: #fff; + font-size: 15px !important; + font-weight: 600 !important; + text-align: center; + border-radius: 999px; + background: #3169aa; +} + +.customer-auth-status-pill.is-pending, +.customer-auth-status-pill.is-scanned { + background: #3a6fa8; +} + +.customer-auth-status-pill.is-success { + background: #237a57; +} + +.customer-auth-status-pill.is-danger { + background: #a33a48; +} + +.customer-auth-state { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 48px 26px; + text-align: center; +} + +.customer-auth-state h2 { + margin: 0; +} + +.customer-auth-state p { + max-width: 560px; +} + +.customer-auth-state-icon { + width: 58px; + height: 58px; + display: grid; + place-items: center; + color: var(--auth-accent); + font-size: 29px; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 50%; + background: rgba(255, 255, 255, 0.045); +} + +.customer-auth-state.is-success .customer-auth-state-icon { + color: #52d39a; +} + +.customer-auth-state.is-error .customer-auth-state-icon { + color: #ff6975; +} + +.customer-auth-state-time { + color: var(--auth-muted); + font-size: 12px; +} + +.customer-auth-loading { + min-height: 240px; + display: grid; + place-items: center; + align-content: center; + gap: 14px; + color: var(--auth-muted); +} + +.customer-auth-privacy { + margin: 24px 26px 28px; + padding: 14px 18px; + color: #d8b46a; + font-size: 14px; + line-height: 1.65; + text-align: center; + border: 1px solid rgba(216, 180, 106, 0.64); + border-radius: 5px; + background: rgba(0, 0, 0, 0.08); +} + +.customer-auth-page .ant-spin-dot-item { + background: var(--auth-accent); +} + +@media (max-width: 640px) { + .customer-auth-page { + padding: 0; + background: var(--auth-panel); + } + + .customer-auth-shell { + width: 100%; + min-height: 100vh; + border: 0; + border-radius: 0; + box-shadow: none; + } + + .customer-auth-hero { + min-height: 150px; + padding: 26px 22px; + background-position: 58% center; + } + + .customer-auth-logo { + width: 54px; + height: 54px; + font-size: 29px; + } + + .customer-auth-brand { + gap: 14px; + } + + .customer-auth-eyebrow { + font-size: 13px; + } + + .customer-auth-brand h1 { + font-size: 26px; + } + + .customer-auth-warning { + padding: 16px 14px; + font-size: 17px; + } + + .customer-auth-facts { + padding-inline: 20px; + } + + .customer-auth-facts > div { + grid-template-columns: 92px minmax(0, 1fr); + gap: 12px; + min-height: 68px; + padding-block: 14px; + } + + .customer-auth-facts > div > span { + font-size: 14px; + } + + .customer-auth-facts > div > strong { + font-size: 16px; + } + + .customer-auth-section { + padding: 24px 18px 27px; + } + + .customer-auth-section-heading h2, + .customer-auth-state h2 { + font-size: 20px; + } + + .customer-auth-section-heading p, + .customer-auth-state p, + .customer-auth-form-copy { + font-size: 14px; + } + + .customer-auth-section-icon { + width: 38px; + height: 38px; + font-size: 20px; + } + + .customer-auth-inline-input, + .customer-auth-inline-input.is-code { + grid-template-columns: 94px minmax(0, 1fr); + } + + .customer-auth-inline-input > .ant-btn { + grid-column: 1 / -1; + border-top: 1px solid var(--auth-line); + } + + .customer-auth-inline-input.is-code { + grid-template-columns: minmax(0, 1fr); + } + + .customer-auth-inline-input.is-code > .ant-btn { + grid-column: auto; + } + + .customer-auth-qr-image, + .customer-auth-qr-placeholder { + width: 232px; + min-height: 232px; + } + + .customer-auth-privacy { + margin: 20px 18px 24px; + font-size: 13px; + } } diff --git a/docs/生产升级操作手册.md b/docs/生产升级操作手册.md index c82c9965..41305769 100644 --- a/docs/生产升级操作手册.md +++ b/docs/生产升级操作手册.md @@ -17,24 +17,23 @@ updated_at: 2026-11-08 > [部署启动与数据库重建](部署启动与数据库重建.md)。 > 只要包含迁移、表结构切换或派生数据重建,必须按本手册执行。 -> 2026-11-13 增量待发布基线:`dev_11.13` 从生产迁移 `151` 增量到 `164`。 +> 2026-11-13 增量待发布基线:`dev_11.13` 从生产迁移 `151` 增量到 `161`。 > 该次变更包含 `152_order_source_facts_immutable.sql`、 > `158_consignment_worker_name_sync.sql`、`159_consignment_platform.sql`、 -> `160_consignment_auto_match_setting.sql`、`161_semi_auto_fulfillment.sql`、 -> `162_semi_auto_connector_mode.sql`、`163_semi_auto_connector_mode_embedded.sql`、 -> `164_semi_auto_credentials.sql`。在发布负责人确认目标提交并完成隔离恢复演练前,只作为开发基线, +> `160_consignment_auto_match_setting.sql`、`161_semi_auto_fulfillment.sql`。 +> 在发布负责人确认目标提交并完成隔离恢复演练前,只作为开发基线, > 不得按旧的 `147–150` 一条路径直接上线。 -### 1.1 迁移 `151 → 164` 增量门禁 +### 1.1 迁移 `151 → 161` 增量门禁 - 升级前必须确认生产最高迁移仍为 `151`;分支合并后重新核对迁移号,禁止回退或改写 `151` 及以前文件。 -- 合并后半自动迁移已顺延到 `161–164`,不得在已应用 main `158–160` 的库上新增 `153–156`。 -- `164` 新增账号级加密凭证表、`semi_auto_tasks.credential_id` 和 - `semi_auto.credential.export` 权限;不迁移或清理订单、退款、资金流水等 A 级事实。 -- 发布环境必须预先配置稳定的 `SEMI_AUTO_CREDENTIAL_ENCRYPTION_KEY`(至少 32 字符)。 - 未配置或仍为示例值时,生产运行时校验会拒绝启动。 +- 半自动迁移已收敛为单个 `161_semi_auto_fulfillment.sql`,不得在已应用 main `158–160` + 的库上重新引入 `153–156` 或 `162–165`。 +- `161` 一次新增账号级 Cookie JSON 凭证表、`semi_auto_tasks.credential_id`、 + 最终 Connector 来源约束和 `semi_auto.credential.export` 权限; + 不迁移或清理订单、退款、资金流水等 A 级事实。 - 迁移完成后至少验证 `semi_auto_credentials` 表、任务外键、索引和导出权限存在; - 用测试库完成 `001 → 164` 空库迁移,并在隔离库验证 `151 → 164`。 + 用测试库完成 `001 → 161` 空库迁移,并在隔离库验证 `151 → 161`。 - 该增量仍包含迁移,普通停机发布不能使用 `deploy/ubuntu-deploy.sh` 在旧后端运行时直接换库; 必须沿用本手册的停写、最终备份、隔离恢复验证、迁移、新后端健康检查和验收流程。 diff --git a/docs/统一订单履约与财务模型-权威版.md b/docs/统一订单履约与财务模型-权威版.md index 1b2e22dd..30cda53b 100644 --- a/docs/统一订单履约与财务模型-权威版.md +++ b/docs/统一订单履约与财务模型-权威版.md @@ -12,7 +12,7 @@ updated_at: 2026-11-13 > 状态:**唯一权威文档**。 > 最后校准:2026-11-13。 > 生产基线:迁移 `151`。 -> 开发基线:迁移 `164`(仅本地,尚未部署生产)。 +> 开发基线:迁移 `161`(仅本地,尚未部署生产)。 > > 从本文件生效起,订单、内部 SKU、履约渠道、履约载体、标准商品、成本和财务相关实现, > 只能以本文件为业务口径。其他旧分析只作为历史资料,不再具备设计或验收效力。 @@ -287,12 +287,13 @@ manual fulfillment - 半自动任务必须绑定 `order_item_id`、`fulfillments.id` 和人工履约配置,不得伪造接单平台工单。 - 登录状态、绑定状态、执行状态必须正交保存于 `semi_auto_tasks`;不得写入 `material_json` 作为正式状态。 - 平台 CK 是账号级凭证,唯一持久化真源为 `semi_auto_credentials`。Cookie 集合按平台 UID 唯一保存, - 使用 AES-256-GCM 加密后写入 `credential_ciphertext`,并记录来源、更新时间、健康状态和最近检查错误。 + 以 Cookie JSON 写入 `credential_payload_json`,并记录来源、更新时间、健康状态和最近检查错误。 `semi_auto_tasks.credential_id` 只引用账号凭证,任务表不保存 CK 明文。 - Connector 会话仍是进程内短期缓存,但不再是唯一真源。进程重启、会话过期或实例切换后, 执行器必须从账号级密文凭证恢复会话;恢复前校验平台 UID,恢复失败时更新凭证健康状态并要求重新登录。 -- 生产必须配置稳定且至少 32 字符的 `SEMI_AUTO_CREDENTIAL_ENCRYPTION_KEY`。该密钥不得与日志、 - 任务事件、接口响应或导出内容一起保存;未完成历史凭证重加密前不得直接更换密钥。 +- 平台明确返回 CK 失效或账号不一致时,当前任务必须退出“已授权”状态,清空当前 Connector 会话并回到 + `waiting_customer / waiting_login / not_started`;账号级凭证和历史事件保留用于审计。客服可对该任务 + 执行“重新发送授权”,重置当前任务并复制客户 `/c/<平台订单号>` 链接,客户重新登录后再绑定新凭证。 - CK 不得进入普通任务 DTO、客户页面、任务事件、错误日志或审计明细。人工后台默认只展示平台 UID、 凭证来源、健康状态和更新时间;仅持有 `semi_auto.credential.export` 权限的角色可受控导出单个任务的 账号 Cookie,导出响应必须禁止缓存,审计只记录任务、凭证 ID 和平台,不记录 Cookie 内容。 @@ -305,7 +306,7 @@ manual fulfillment `mock` 为 order_site 内置开发 Mock 且仅在非生产环境可用。开发 Mock 造单时默认选择 `embedded`, 只有需要完全脱离虎牙接口联调页面时才显式选择 `mock`。 - 虎牙二维码登录有效期 `5` 分钟;登录成功后的 Connector 内存会话有效期 `24` 小时, - 账号级 CK 由加密凭证保存并通过健康检查判定有效性;角色绑定二维码 `5` 分钟; + 账号级 CK 由内部凭证表保存并通过健康检查判定有效性;角色绑定二维码 `5` 分钟; 浏览器一次性注入票据 `2` 分钟且消费后立即失效。重新登录前必须撤销旧 Connector 会话。 - 虎牙账号已经绑定游戏角色时,客户必须确认当前角色是否为本次需要的角色:选择“使用当前角色”时 不再调用虎牙换绑接口;选择“角色不对,重新扫码换绑”时重新生成绑定二维码,并在确认新角色时 @@ -371,7 +372,7 @@ finance_journal_* -- 总账凭证,可重建 | 执行尝试 | 履约尝试服务 | `fulfillment_attempts` | 用最大 ID 猜当前任务 | | 当前异常 | 履约异常服务 | `fulfillment_issues` | 自由 JSON 字符串代替问题模型 | | 半自动任务 | 半自动任务服务 | `semi_auto_tasks` / `semi_auto_task_events` | 把登录/绑定/执行状态写进 `material_json` | -| 客户平台 CK | 半自动凭证服务 | `semi_auto_credentials` 加密凭证 | 明文落库、写日志、普通接口返回或未审计导出 CK | +| 客户平台 CK | 半自动凭证服务 | `semi_auto_credentials.credential_payload_json` | 写日志、普通接口返回或未审计导出 CK | | 标准商品 | 商品主数据服务 | `product_catalog` | 用商品来源反推渠道 | | 平台商品到财务商品 | 商品归一服务 | `channel_product_mappings` | 人工在多个页面重复维护 | | 成本规则 | 成本中心 | `cost_versions` | 业务表直接改金额 | @@ -948,11 +949,10 @@ docs/archive/ ## 十三、当前下一步 -截至 2026-11-13,生产基线为 `151`,集成分支开发基线为 `164`。迁移 `152` 保护订单来源快照, +截至 2026-11-13,生产基线为 `151`,集成分支开发基线为 `161`。迁移 `152` 保护订单来源快照, 迁移 `158–160` 收口寄卖登记人同步、寄卖平台与自动匹配设置; -迁移 `161` 增加人工渠道半自动任务,迁移 `162` 增加 Connector 来源, -迁移 `163` 将内置真实 Connector 设为默认并扩充来源约束,迁移 `164` 增加账号级加密凭证、 -任务凭证引用和受控导出权限。 +迁移 `161` 一次建立人工渠道半自动任务、内置 Connector 默认值、账号级 Cookie JSON 凭证、 +任务凭证引用、受控导出权限和最终约束。 1. 生产升级只按 [生产升级操作手册](生产升级操作手册.md) 执行。 2. 升级前备份必须完成隔离恢复验证;恢复验证未通过时禁止迁移。 diff --git a/docs/部署启动与数据库重建.md b/docs/部署启动与数据库重建.md index 7fed6988..883835e8 100644 --- a/docs/部署启动与数据库重建.md +++ b/docs/部署启动与数据库重建.md @@ -152,7 +152,6 @@ CLAIM_BASE_URL POSTGRES_PASSWORD DATABASE_URL ADMIN_SESSION_SECRET -SEMI_AUTO_CREDENTIAL_ENCRYPTION_KEY ADMIN_DEFAULT_USERS_JSON CORS_ALLOWED_ORIGINS STORAGE_MODE @@ -165,10 +164,6 @@ 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