完善微博半自动凭证与本地开发环境
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -66,6 +66,7 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
|
||||
connectorBaseUrl: '',
|
||||
connectorApiKey: '',
|
||||
connectorTimeoutMs: 15_000,
|
||||
credentialEncryptionKey: '',
|
||||
huya: {
|
||||
bindActId: 9271,
|
||||
outerActId: '17096',
|
||||
|
||||
@@ -21,7 +21,10 @@ const DEPLOYMENT_ONLY_ENV_NAMES = [
|
||||
'DB_BACKUP_OSS_PREFIX',
|
||||
'KUASHOU_INDUSTRY_RATE_LIMIT_MAX',
|
||||
'MINIO_API_PORT',
|
||||
'MINIO_API_PORT_NUMBER',
|
||||
'MINIO_BROWSER',
|
||||
'MINIO_CONSOLE_PORT',
|
||||
'MINIO_CONSOLE_PORT_NUMBER',
|
||||
'MINIO_ROOT_PASSWORD',
|
||||
'MINIO_ROOT_USER',
|
||||
'NPM_CONFIG_REGISTRY',
|
||||
|
||||
@@ -55,6 +55,7 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [
|
||||
stringEnv('SEMI_AUTO_CONNECTOR_API_KEY', ['semiAuto', 'connectorApiKey']),
|
||||
integerEnv('SEMI_AUTO_CONNECTOR_TIMEOUT_MS', ['semiAuto', 'connectorTimeoutMs']),
|
||||
stringEnv('SEMI_AUTO_DEFAULT_CONNECTOR_MODE', ['semiAuto', 'defaultConnectorMode']),
|
||||
stringEnv('SEMI_AUTO_CREDENTIAL_ENCRYPTION_KEY', ['semiAuto', 'credentialEncryptionKey']),
|
||||
integerEnv('HUYA_BIND_ACT_ID', ['semiAuto', 'huya', 'bindActId']),
|
||||
stringEnv('HUYA_OUTER_ACT_ID', ['semiAuto', 'huya', 'outerActId']),
|
||||
stringEnv('HUYA_ROOM_PID', ['semiAuto', 'huya', 'roomPid']),
|
||||
|
||||
@@ -142,6 +142,22 @@ test('validateRuntimeConfig rejects unknown semi-auto connector mode', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('validateRuntimeConfig requires a strong semi-auto credential key in production', () => {
|
||||
const issues = validateRuntimeConfig(
|
||||
createRuntimeConfig({
|
||||
semiAuto: {
|
||||
credentialEncryptionKey: 'short-key',
|
||||
},
|
||||
}),
|
||||
{ env: { NODE_ENV: 'production' } },
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
issues.map((issue) => issue.path),
|
||||
['semiAuto.credentialEncryptionKey'],
|
||||
)
|
||||
})
|
||||
|
||||
test('assertRuntimeConfigValid throws a readable validation error', () => {
|
||||
assert.throws(
|
||||
() => {
|
||||
@@ -208,6 +224,7 @@ function createRuntimeConfig(overrides: Record<string, unknown> = {}): RuntimeCo
|
||||
connectorBaseUrl: '',
|
||||
connectorApiKey: '',
|
||||
connectorTimeoutMs: 15000,
|
||||
credentialEncryptionKey: '0123456789abcdef0123456789abcdef',
|
||||
huya: {
|
||||
bindActId: 9271,
|
||||
outerActId: '17096',
|
||||
|
||||
@@ -85,6 +85,13 @@ export function validateRuntimeConfig(
|
||||
message: '仅支持 embedded、external 或 mock',
|
||||
})
|
||||
}
|
||||
const credentialEncryptionKey = String(config.semiAuto?.credentialEncryptionKey || '').trim()
|
||||
if (!productionLike && credentialEncryptionKey && credentialEncryptionKey.length < 32) {
|
||||
issues.push({
|
||||
path: 'semiAuto.credentialEncryptionKey',
|
||||
message: '长度不能少于 32 个字符',
|
||||
})
|
||||
}
|
||||
requireOptionalInteger(
|
||||
issues,
|
||||
'semiAuto.connectorTimeoutMs',
|
||||
@@ -134,6 +141,12 @@ export function validateRuntimeConfig(
|
||||
}
|
||||
validateRequiredHttpUrl(issues, 'orders.claimBaseUrl', config.orders?.claimBaseUrl)
|
||||
validateSecret(issues, 'admin.sessionSecret', config.admin?.sessionSecret, { minLength: 32 })
|
||||
validateSecret(
|
||||
issues,
|
||||
'semiAuto.credentialEncryptionKey',
|
||||
config.semiAuto?.credentialEncryptionKey,
|
||||
{ minLength: 32 },
|
||||
)
|
||||
validateAdminDefaultUsers(issues, config.admin?.defaultUsers)
|
||||
const allowedOrigins = config.cors?.allowedOrigins
|
||||
if (Array.isArray(allowedOrigins)) {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
-- 156_semi_auto_credentials.sql
|
||||
--
|
||||
-- 半自动履约平台账号凭证:
|
||||
-- 1. CK 是账号级凭证,挂到平台 UID,而不是只保存在进程内存或单次任务上;
|
||||
-- 2. 数据库只保存加密后的 Cookie 集合,任务仅引用 credential_id;
|
||||
-- 3. 记录凭证来源、更新时间和健康状态,供执行器失效判定与人工重登使用。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS semi_auto_credentials (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
platform TEXT NOT NULL CHECK (platform IN ('huya', 'weibo')),
|
||||
platform_uid TEXT NOT NULL,
|
||||
platform_nickname TEXT NOT NULL DEFAULT '',
|
||||
credential_ciphertext TEXT NOT NULL,
|
||||
credential_source TEXT NOT NULL DEFAULT ''
|
||||
CHECK (credential_source IN ('', 'qr', 'sms', 'web', 'app', 'import')),
|
||||
credential_status TEXT NOT NULL DEFAULT 'unknown'
|
||||
CHECK (credential_status IN ('unknown', 'valid', 'invalid', 'error')),
|
||||
credential_updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_checked_at TIMESTAMPTZ,
|
||||
last_check_error TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (platform, platform_uid)
|
||||
);
|
||||
|
||||
ALTER TABLE semi_auto_tasks
|
||||
ADD COLUMN IF NOT EXISTS credential_id BIGINT
|
||||
REFERENCES semi_auto_credentials(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_semi_auto_credentials_status
|
||||
ON semi_auto_credentials (platform, credential_status, updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_semi_auto_tasks_credential
|
||||
ON semi_auto_tasks (credential_id)
|
||||
WHERE credential_id IS NOT NULL;
|
||||
|
||||
COMMENT ON TABLE semi_auto_credentials IS
|
||||
'半自动履约平台账号级加密凭证;原始 CK 不得进入接口响应、日志或页面';
|
||||
COMMENT ON COLUMN semi_auto_credentials.credential_ciphertext IS
|
||||
'AES-256-GCM 加密后的 Cookie JSON;密钥来自 SEMI_AUTO_CREDENTIAL_ENCRYPTION_KEY';
|
||||
COMMENT ON COLUMN semi_auto_credentials.credential_source IS
|
||||
'凭证来源:qr / sms / web / app / import';
|
||||
COMMENT ON COLUMN semi_auto_credentials.credential_status IS
|
||||
'执行器最近一次检查结论:unknown / valid / invalid / error';
|
||||
COMMENT ON COLUMN semi_auto_tasks.credential_id IS
|
||||
'该订单当前使用的平台账号凭证;task 不保存 CK 明文';
|
||||
|
||||
INSERT INTO admin_permissions (
|
||||
permission_key, permission_name, permission_group, description
|
||||
) VALUES (
|
||||
'semi_auto.credential.export',
|
||||
'导出半自动履约账号 CK',
|
||||
'半自动履约',
|
||||
'受控导出单个半自动履约任务的账号级 Cookie;导出动作写入审计但不记录 CK'
|
||||
)
|
||||
ON CONFLICT (permission_key) DO UPDATE SET
|
||||
permission_name = EXCLUDED.permission_name,
|
||||
permission_group = EXCLUDED.permission_group,
|
||||
description = EXCLUDED.description,
|
||||
updated_at = NOW();
|
||||
|
||||
INSERT INTO admin_role_permissions (role_key, permission_key)
|
||||
SELECT 'admin', 'semi_auto.credential.export'
|
||||
FROM admin_roles
|
||||
WHERE role_key = 'admin'
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -6,12 +6,13 @@ import {
|
||||
completeAdminSemiAutoTask,
|
||||
consumeSemiAutoInjectionTicket,
|
||||
createAdminBrowserSession,
|
||||
exportAdminSemiAutoCredential,
|
||||
getAdminSemiAutoTask,
|
||||
listAdminSemiAutoTasks,
|
||||
syncAdminSemiAutoTask,
|
||||
} from '../../services/semi-auto/semi-auto-task-service.js'
|
||||
import { bodyOf, sessionOf } from './request-helpers.js'
|
||||
import { createJsonHandler, requireAdminRoles } from './session.js'
|
||||
import { createJsonHandler, requireAdminPermission, requireAdminRoles } from './session.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -35,6 +36,37 @@ router.get(
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/semi-auto/tasks/:taskId/credential/export',
|
||||
requireAdminPermission('semi_auto.credential.export'),
|
||||
createJsonHandler(
|
||||
(req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store, max-age=0')
|
||||
res.setHeader('Pragma', 'no-cache')
|
||||
res.setHeader('Expires', '0')
|
||||
return exportAdminSemiAutoCredential(req.params.taskId, sessionOf(req))
|
||||
},
|
||||
{
|
||||
successMessage: '账号 CK 已导出',
|
||||
errorMessage: '导出半自动履约账号 CK 失败',
|
||||
scope: '[admin/semi-auto/tasks/:taskId/credential/export]',
|
||||
audit: (req, data) => {
|
||||
const result =
|
||||
data && typeof data === 'object' ? (data as Record<string, unknown>) : undefined
|
||||
return {
|
||||
action: 'semi_auto_credential_exported',
|
||||
targetType: 'semi_auto_task',
|
||||
targetId: String(req.params.taskId || ''),
|
||||
data: {
|
||||
credentialId: Number(result?.credentialId || 0),
|
||||
platform: String(result?.platform || ''),
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/semi-auto/tasks/:taskId/claim',
|
||||
createJsonHandler((req) => claimAdminSemiAutoTask(req.params.taskId, sessionOf(req)), {
|
||||
|
||||
@@ -118,9 +118,10 @@ router.post(
|
||||
req.params.taskId,
|
||||
req.body?.mobile,
|
||||
req.body?.countryCode,
|
||||
req.body?.mfaId,
|
||||
),
|
||||
{
|
||||
successMessage: '验证码已发送',
|
||||
successMessage: '验证码请求已处理',
|
||||
errorMessage: '发送微博短信验证码失败',
|
||||
scope: '[customer-fulfillment/:orderNo/tasks/:taskId/weibo/sms/send]',
|
||||
},
|
||||
|
||||
@@ -35,6 +35,7 @@ export const ADMIN_PERMISSION_KEYS = [
|
||||
'consignment.manage',
|
||||
'consignment.review',
|
||||
'consignment.fulfill',
|
||||
'semi_auto.credential.export',
|
||||
] as const
|
||||
|
||||
export type AdminPermissionKey = (typeof ADMIN_PERMISSION_KEYS)[number]
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
decryptSemiAutoCredential,
|
||||
encryptSemiAutoCredential,
|
||||
isEncryptedSemiAutoCredential,
|
||||
} from './semi-auto-credential-crypto.js'
|
||||
import { buildSemiAutoCredentialCookieHeader } from './semi-auto-credential-service.js'
|
||||
|
||||
const SECRET = 'test-semi-auto-credential-encryption-key-123456'
|
||||
|
||||
test('半自动履约凭证使用 AES-GCM 加密并可还原', () => {
|
||||
const plaintext = JSON.stringify({
|
||||
cookies: [{ name: 'SUB', value: 'secret-cookie', domain: '.weibo.com' }],
|
||||
})
|
||||
const encrypted = encryptSemiAutoCredential(plaintext, SECRET)
|
||||
|
||||
assert.equal(isEncryptedSemiAutoCredential(encrypted), true)
|
||||
assert.notEqual(encrypted, plaintext)
|
||||
assert.equal(encrypted.includes('secret-cookie'), false)
|
||||
assert.equal(decryptSemiAutoCredential(encrypted, SECRET), plaintext)
|
||||
})
|
||||
|
||||
test('半自动履约凭证密文被篡改时解密失败', () => {
|
||||
const encrypted = encryptSemiAutoCredential('{"cookies":[]}', SECRET)
|
||||
const tampered = `${encrypted.slice(0, -1)}${encrypted.endsWith('A') ? 'B' : 'A'}`
|
||||
assert.throws(() => decryptSemiAutoCredential(tampered, SECRET))
|
||||
})
|
||||
|
||||
test('错误密钥不能解密半自动履约凭证', () => {
|
||||
const encrypted = encryptSemiAutoCredential('{"cookies":[]}', SECRET)
|
||||
assert.throws(() =>
|
||||
decryptSemiAutoCredential(encrypted, 'another-semi-auto-credential-encryption-key-123'),
|
||||
)
|
||||
})
|
||||
|
||||
test('账号 Cookie Header 按域名、路径和安全协议筛选', () => {
|
||||
const header = buildSemiAutoCredentialCookieHeader(
|
||||
[
|
||||
{
|
||||
name: 'SUB',
|
||||
value: 'weibo-session',
|
||||
domain: '.weibo.com',
|
||||
path: '/',
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
hostOnly: false,
|
||||
expiresAt: null,
|
||||
},
|
||||
{
|
||||
name: 'SUB',
|
||||
value: 'passport-session',
|
||||
domain: 'passport.weibo.com',
|
||||
path: '/sso',
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
hostOnly: true,
|
||||
expiresAt: null,
|
||||
},
|
||||
{
|
||||
name: 'INSECURE',
|
||||
value: 'must-not-leak',
|
||||
domain: '.weibo.com',
|
||||
path: '/',
|
||||
secure: false,
|
||||
httpOnly: false,
|
||||
hostOnly: false,
|
||||
expiresAt: null,
|
||||
},
|
||||
],
|
||||
'https://weibo.com/',
|
||||
)
|
||||
|
||||
assert.equal(header, 'SUB=weibo-session; INSECURE=must-not-leak')
|
||||
assert.equal(
|
||||
buildSemiAutoCredentialCookieHeader(
|
||||
[
|
||||
{
|
||||
name: 'SUB',
|
||||
value: 'weibo-session',
|
||||
domain: '.weibo.com',
|
||||
path: '/',
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
hostOnly: false,
|
||||
expiresAt: null,
|
||||
},
|
||||
],
|
||||
'http://weibo.com/',
|
||||
),
|
||||
'',
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
import { runtimeConfig } from '../../config/runtime.js'
|
||||
|
||||
const CIPHERTEXT_PREFIX = 'enc:v1:'
|
||||
|
||||
function resolveEncryptionSecret(override?: string): string {
|
||||
const explicit = String(override ?? runtimeConfig.semiAuto?.credentialEncryptionKey ?? '').trim()
|
||||
const fallback = String(runtimeConfig.admin?.sessionSecret || '').trim()
|
||||
const secret = explicit || fallback
|
||||
if (!secret) {
|
||||
throw new Error('缺少 SEMI_AUTO_CREDENTIAL_ENCRYPTION_KEY,无法加密半自动履约平台凭证')
|
||||
}
|
||||
return secret
|
||||
}
|
||||
|
||||
function deriveKey(secret: string): Buffer {
|
||||
return crypto.createHash('sha256').update(`order-site:semi-auto-credential:${secret}`).digest()
|
||||
}
|
||||
|
||||
export function isEncryptedSemiAutoCredential(value: unknown): boolean {
|
||||
return String(value || '').startsWith(CIPHERTEXT_PREFIX)
|
||||
}
|
||||
|
||||
export function encryptSemiAutoCredential(plaintext: string, secretOverride?: string): string {
|
||||
const text = String(plaintext || '')
|
||||
if (!text) return ''
|
||||
const secret = resolveEncryptionSecret(secretOverride)
|
||||
const nonce = crypto.randomBytes(12)
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', deriveKey(secret), nonce)
|
||||
const ciphertext = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()])
|
||||
const tag = cipher.getAuthTag()
|
||||
return `${CIPHERTEXT_PREFIX}${Buffer.concat([nonce, tag, ciphertext]).toString('base64url')}`
|
||||
}
|
||||
|
||||
export function decryptSemiAutoCredential(ciphertext: string, secretOverride?: string): string {
|
||||
const value = String(ciphertext || '')
|
||||
if (!value) return ''
|
||||
if (!value.startsWith(CIPHERTEXT_PREFIX)) {
|
||||
throw new Error('半自动履约凭证不是受支持的加密格式')
|
||||
}
|
||||
const raw = Buffer.from(value.slice(CIPHERTEXT_PREFIX.length), 'base64url')
|
||||
if (raw.length < 29) {
|
||||
throw new Error('半自动履约凭证密文长度无效')
|
||||
}
|
||||
const nonce = raw.subarray(0, 12)
|
||||
const tag = raw.subarray(12, 28)
|
||||
const ciphertextBytes = raw.subarray(28)
|
||||
const decipher = crypto.createDecipheriv(
|
||||
'aes-256-gcm',
|
||||
deriveKey(resolveEncryptionSecret(secretOverride)),
|
||||
nonce,
|
||||
)
|
||||
decipher.setAuthTag(tag)
|
||||
return Buffer.concat([decipher.update(ciphertextBytes), decipher.final()]).toString('utf8')
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import type { PoolClient } from 'pg'
|
||||
|
||||
import { query } from '../../db/client.js'
|
||||
import {
|
||||
decryptSemiAutoCredential,
|
||||
encryptSemiAutoCredential,
|
||||
} from './semi-auto-credential-crypto.js'
|
||||
|
||||
export type SemiAutoCredentialCookie = {
|
||||
name: string
|
||||
value: string
|
||||
domain: string
|
||||
path: string
|
||||
secure: boolean
|
||||
httpOnly: boolean
|
||||
hostOnly?: boolean
|
||||
expiresAt: string | null
|
||||
}
|
||||
|
||||
export type SemiAutoCredentialPayload = {
|
||||
platformUid: string
|
||||
nickname: string
|
||||
cookies: SemiAutoCredentialCookie[]
|
||||
}
|
||||
|
||||
export type SemiAutoCredentialSource = 'qr' | 'sms' | 'web' | 'app' | 'import'
|
||||
|
||||
export type SemiAutoCredentialRecord = SemiAutoCredentialPayload & {
|
||||
id: number
|
||||
platform: 'huya' | 'weibo'
|
||||
source: SemiAutoCredentialSource | ''
|
||||
status: 'unknown' | 'valid' | 'invalid' | 'error'
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
type CredentialRow = {
|
||||
id: number
|
||||
platform: 'huya' | 'weibo'
|
||||
platform_uid: string
|
||||
platform_nickname: string
|
||||
credential_ciphertext: string
|
||||
credential_source: SemiAutoCredentialSource | ''
|
||||
credential_status: 'unknown' | 'valid' | 'invalid' | 'error'
|
||||
credential_updated_at: string
|
||||
}
|
||||
|
||||
function normalizeCookieList(value: unknown): SemiAutoCredentialCookie[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value
|
||||
.filter((item): item is Record<string, unknown> =>
|
||||
Boolean(item && typeof item === 'object' && !Array.isArray(item)),
|
||||
)
|
||||
.map((item) => {
|
||||
const domain = String(item.domain || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
return {
|
||||
name: String(item.name || '').trim(),
|
||||
value: String(item.value || ''),
|
||||
domain,
|
||||
path: String(item.path || '/').trim() || '/',
|
||||
secure: item.secure === true,
|
||||
httpOnly: item.httpOnly === true,
|
||||
hostOnly: typeof item.hostOnly === 'boolean' ? item.hostOnly : !domain.startsWith('.'),
|
||||
expiresAt: item.expiresAt ? String(item.expiresAt) : null,
|
||||
}
|
||||
})
|
||||
.filter((item) => item.name && item.domain)
|
||||
}
|
||||
|
||||
export function buildSemiAutoCredentialCookieHeader(
|
||||
cookies: SemiAutoCredentialCookie[],
|
||||
requestUrlInput: unknown,
|
||||
): string {
|
||||
const requestUrl = new URL(String(requestUrlInput || '').trim())
|
||||
const hostname = requestUrl.hostname.toLowerCase()
|
||||
const requestPath = requestUrl.pathname || '/'
|
||||
const nowMs = Date.now()
|
||||
return cookies
|
||||
.filter((cookie) => {
|
||||
if (cookie.secure && requestUrl.protocol !== 'https:') return false
|
||||
if (cookie.expiresAt) {
|
||||
const expiresAtMs = Date.parse(cookie.expiresAt)
|
||||
if (Number.isFinite(expiresAtMs) && expiresAtMs <= nowMs) return false
|
||||
}
|
||||
const cookieDomain = cookie.domain.trim().toLowerCase().replace(/^\./, '')
|
||||
if (!cookieDomain) return false
|
||||
const hostOnly = cookie.hostOnly ?? !cookie.domain.startsWith('.')
|
||||
const domainMatches = hostOnly
|
||||
? hostname === cookieDomain
|
||||
: hostname === cookieDomain || hostname.endsWith(`.${cookieDomain}`)
|
||||
if (!domainMatches) return false
|
||||
const cookiePath = cookie.path || '/'
|
||||
return (
|
||||
requestPath === cookiePath ||
|
||||
requestPath.startsWith(cookiePath.endsWith('/') ? cookiePath : `${cookiePath}/`)
|
||||
)
|
||||
})
|
||||
.sort((left, right) => (right.path || '/').length - (left.path || '/').length)
|
||||
.map((cookie) => `${cookie.name}=${cookie.value}`)
|
||||
.join('; ')
|
||||
}
|
||||
|
||||
function decodeCredential(row: CredentialRow): SemiAutoCredentialRecord {
|
||||
const decrypted = decryptSemiAutoCredential(row.credential_ciphertext)
|
||||
const parsed = JSON.parse(decrypted) as Record<string, unknown>
|
||||
return {
|
||||
id: Number(row.id),
|
||||
platform: row.platform,
|
||||
platformUid: row.platform_uid,
|
||||
nickname: row.platform_nickname,
|
||||
cookies: normalizeCookieList(parsed.cookies),
|
||||
source: row.credential_source,
|
||||
status: row.credential_status,
|
||||
updatedAt: row.credential_updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
export async function upsertSemiAutoCredentialWithClient(
|
||||
client: Pick<PoolClient, 'query'>,
|
||||
input: {
|
||||
platform: 'huya' | 'weibo'
|
||||
source: SemiAutoCredentialSource
|
||||
payload: SemiAutoCredentialPayload
|
||||
},
|
||||
): Promise<number> {
|
||||
const platformUid = String(input.payload.platformUid || '').trim()
|
||||
if (!platformUid) {
|
||||
throw new Error('平台账号 UID 为空,无法保存半自动履约凭证')
|
||||
}
|
||||
const cookies = normalizeCookieList(input.payload.cookies)
|
||||
if (cookies.length === 0) {
|
||||
throw new Error('平台 Cookie 为空,无法保存半自动履约凭证')
|
||||
}
|
||||
const encrypted = encryptSemiAutoCredential(
|
||||
JSON.stringify({
|
||||
cookies,
|
||||
}),
|
||||
)
|
||||
const result = await client.query<{ id: number }>(
|
||||
`
|
||||
INSERT INTO semi_auto_credentials (
|
||||
platform, platform_uid, platform_nickname, credential_ciphertext,
|
||||
credential_source, credential_status, credential_updated_at,
|
||||
last_checked_at, last_check_error, created_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, 'valid', NOW(), NOW(), '', NOW(), NOW()
|
||||
)
|
||||
ON CONFLICT (platform, platform_uid) DO UPDATE
|
||||
SET platform_nickname = EXCLUDED.platform_nickname,
|
||||
credential_ciphertext = EXCLUDED.credential_ciphertext,
|
||||
credential_source = EXCLUDED.credential_source,
|
||||
credential_status = 'valid',
|
||||
credential_updated_at = NOW(),
|
||||
last_checked_at = NOW(),
|
||||
last_check_error = '',
|
||||
updated_at = NOW()
|
||||
RETURNING id
|
||||
`,
|
||||
[
|
||||
input.platform,
|
||||
platformUid,
|
||||
String(input.payload.nickname || '')
|
||||
.trim()
|
||||
.slice(0, 120),
|
||||
encrypted,
|
||||
input.source,
|
||||
],
|
||||
)
|
||||
return Number(result.rows[0]?.id || 0)
|
||||
}
|
||||
|
||||
export async function loadSemiAutoCredential(
|
||||
credentialIdInput: unknown,
|
||||
): Promise<SemiAutoCredentialRecord | null> {
|
||||
const credentialId = Number(credentialIdInput || 0)
|
||||
if (!Number.isInteger(credentialId) || credentialId <= 0) return null
|
||||
const result = await query<CredentialRow>(
|
||||
`
|
||||
SELECT
|
||||
id, platform, platform_uid, platform_nickname, credential_ciphertext,
|
||||
credential_source, credential_status, credential_updated_at
|
||||
FROM semi_auto_credentials
|
||||
WHERE id = $1
|
||||
LIMIT 1
|
||||
`,
|
||||
[credentialId],
|
||||
)
|
||||
const row = result.rows[0]
|
||||
return row ? decodeCredential(row) : null
|
||||
}
|
||||
|
||||
export async function markSemiAutoCredentialCheck(
|
||||
credentialIdInput: unknown,
|
||||
input: {
|
||||
status: 'valid' | 'invalid' | 'error'
|
||||
error?: string
|
||||
},
|
||||
): Promise<void> {
|
||||
const credentialId = Number(credentialIdInput || 0)
|
||||
if (!Number.isInteger(credentialId) || credentialId <= 0) return
|
||||
await query(
|
||||
`
|
||||
UPDATE semi_auto_credentials
|
||||
SET credential_status = $2,
|
||||
last_checked_at = NOW(),
|
||||
last_check_error = $3,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`,
|
||||
[
|
||||
credentialId,
|
||||
input.status,
|
||||
String(input.error || '')
|
||||
.trim()
|
||||
.slice(0, 1000),
|
||||
],
|
||||
)
|
||||
}
|
||||
@@ -32,14 +32,22 @@ import {
|
||||
consumeWeiboBrowserTicket,
|
||||
createWeiboBrowserTicket,
|
||||
createWeiboLoginSession,
|
||||
exportWeiboSessionCredential,
|
||||
getWeiboBinding,
|
||||
getWeiboCountryCodes,
|
||||
getWeiboLoginSession,
|
||||
loginWeiboSms,
|
||||
restoreWeiboSessionFromCredential,
|
||||
revokeWeiboSession,
|
||||
sendWeiboSmsCode,
|
||||
} from './weibo-connector-client.js'
|
||||
import { WEIBO_TICKET_PREFIX } from './weibo-native/weibo-native-connector.js'
|
||||
import {
|
||||
buildSemiAutoCredentialCookieHeader,
|
||||
loadSemiAutoCredential,
|
||||
markSemiAutoCredentialCheck,
|
||||
upsertSemiAutoCredentialWithClient,
|
||||
} from './semi-auto-credential-service.js'
|
||||
|
||||
const SEMI_AUTO_PLATFORMS = new Set(['huya', 'weibo'])
|
||||
type SemiAutoPlatform = 'huya' | 'weibo'
|
||||
@@ -78,6 +86,10 @@ type TaskRow = {
|
||||
execution_status: ExecutionStatus
|
||||
connector_mode: SemiAutoConnectorMode
|
||||
connector_session_id: string
|
||||
credential_id: number | null
|
||||
credential_status: 'unknown' | 'valid' | 'invalid' | 'error' | null
|
||||
credential_source: string | null
|
||||
credential_updated_at: string | null
|
||||
huya_uid: string
|
||||
platform_nickname: string
|
||||
bind_game_account: string
|
||||
@@ -187,7 +199,11 @@ function mapTask(task: TaskRow) {
|
||||
executionStatus: task.execution_status,
|
||||
connectorMode: task.connector_mode,
|
||||
displayStatus: displayStatus(task),
|
||||
connectorReady: Boolean(task.connector_session_id),
|
||||
connectorReady: Boolean(task.connector_session_id || task.credential_id),
|
||||
credentialReady: Boolean(task.credential_id),
|
||||
credentialStatus: task.credential_status || 'unknown',
|
||||
credentialSource: task.credential_source || '',
|
||||
credentialUpdatedAt: task.credential_updated_at,
|
||||
huyaUid: task.huya_uid,
|
||||
platformNickname: task.platform_nickname,
|
||||
bindGameAccount: task.bind_game_account,
|
||||
@@ -273,12 +289,16 @@ const TASK_SELECT = `
|
||||
cfg.platform_key AS manual_platform_key,
|
||||
cfg.cost_mode AS manual_cost_mode,
|
||||
cfg.credit_quantity AS manual_credit_quantity,
|
||||
cfg.semi_auto_config_json
|
||||
cfg.semi_auto_config_json,
|
||||
credential.credential_status,
|
||||
credential.credential_source,
|
||||
credential.credential_updated_at
|
||||
FROM semi_auto_tasks task
|
||||
JOIN orders o ON o.id = task.order_id
|
||||
JOIN order_items oi ON oi.id = task.order_item_id
|
||||
LEFT JOIN manual_fulfillment_configs cfg ON cfg.id = task.manual_fulfillment_config_id
|
||||
LEFT JOIN admin_users admin ON admin.id = task.assigned_admin_user_id
|
||||
LEFT JOIN semi_auto_credentials credential ON credential.id = task.credential_id
|
||||
`
|
||||
|
||||
async function loadTask(taskId: number): Promise<TaskRow> {
|
||||
@@ -364,6 +384,64 @@ async function getBindingForTask(
|
||||
: getHuyaBinding(sessionId, task.connector_mode)
|
||||
}
|
||||
|
||||
async function ensureConnectorSessionForTask(task: TaskRow): Promise<TaskRow> {
|
||||
if (task.execution_status === 'completed' || task.execution_status === 'cancelled') return task
|
||||
if (task.connector_session_id) {
|
||||
try {
|
||||
const session = await getLoginSessionForTask(task, task.connector_session_id)
|
||||
if (session.status === 'authenticated') return task
|
||||
} catch {
|
||||
// 进程重启或多实例切换后,内存会话会丢失;下面用已保存的账号 CK 恢复。
|
||||
}
|
||||
}
|
||||
if (task.platform !== 'weibo' || !task.credential_id) return task
|
||||
const credential = await loadSemiAutoCredential(task.credential_id)
|
||||
if (!credential || credential.platform !== 'weibo') return task
|
||||
try {
|
||||
const session = await restoreWeiboSessionFromCredential(
|
||||
{
|
||||
taskId: task.id,
|
||||
orderNo: task.platform_order_id,
|
||||
platformUid: credential.platformUid,
|
||||
nickname: credential.nickname,
|
||||
cookies: credential.cookies,
|
||||
},
|
||||
task.connector_mode,
|
||||
)
|
||||
await markSemiAutoCredentialCheck(task.credential_id, { status: 'valid' })
|
||||
await withTransaction(async (client) => {
|
||||
await client.query(
|
||||
`
|
||||
UPDATE semi_auto_tasks
|
||||
SET connector_session_id = $2,
|
||||
login_status = 'authenticated',
|
||||
huya_uid = CASE WHEN $3 <> '' THEN $3 ELSE huya_uid END,
|
||||
platform_nickname = CASE WHEN $4 <> '' THEN $4 ELSE platform_nickname END,
|
||||
last_error = '',
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`,
|
||||
[task.id, session.sessionId, session.huyaUid, session.nickname],
|
||||
)
|
||||
await appendTaskEvent(client, task.id, {
|
||||
eventType: 'credential_session_restored',
|
||||
actorType: 'connector',
|
||||
detail: {
|
||||
credentialId: task.credential_id,
|
||||
hasCredential: session.hasCredential,
|
||||
},
|
||||
})
|
||||
})
|
||||
return loadTask(task.id)
|
||||
} catch (error) {
|
||||
await markSemiAutoCredentialCheck(task.credential_id, {
|
||||
status: 'invalid',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}).catch(() => undefined)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
type SemiAutoCandidate = {
|
||||
order_id: number
|
||||
platform_order_id: string
|
||||
@@ -534,11 +612,23 @@ async function updateLoginTask(
|
||||
? 'waiting_operator'
|
||||
: 'waiting_customer'
|
||||
: task.execution_status
|
||||
const storedCredential =
|
||||
task.platform === 'weibo' && mapped.status === 'authenticated' && mapped.hasCredential
|
||||
? exportWeiboSessionCredential(session.sessionId, task.connector_mode)
|
||||
: null
|
||||
await withTransaction(async (client) => {
|
||||
const credentialId = storedCredential
|
||||
? await upsertSemiAutoCredentialWithClient(client, {
|
||||
platform: 'weibo',
|
||||
source: 'qr',
|
||||
payload: storedCredential,
|
||||
})
|
||||
: null
|
||||
await client.query(
|
||||
`
|
||||
UPDATE semi_auto_tasks
|
||||
SET login_status = $2,
|
||||
credential_id = COALESCE($8, credential_id),
|
||||
huya_uid = CASE WHEN $3 <> '' THEN $3 ELSE huya_uid END,
|
||||
platform_nickname = CASE WHEN $4 <> '' THEN $4 ELSE platform_nickname END,
|
||||
authenticated_at = CASE WHEN $2 = 'authenticated' THEN COALESCE(authenticated_at, NOW()) ELSE authenticated_at END,
|
||||
@@ -560,6 +650,7 @@ async function updateLoginTask(
|
||||
nextExecutionStatus,
|
||||
autoConfirmWeiboBinding,
|
||||
mapped.message,
|
||||
credentialId,
|
||||
],
|
||||
)
|
||||
await appendTaskEvent(client, task.id, {
|
||||
@@ -761,7 +852,7 @@ export async function startPublicLogin(orderNoInput: unknown, taskIdInput: unkno
|
||||
export async function pollPublicLogin(orderNoInput: unknown, taskIdInput: unknown) {
|
||||
const orderNo = normalizeOrderNo(orderNoInput)
|
||||
const taskId = normalizeId(taskIdInput)
|
||||
const task = await loadTaskForOrder(orderNo, taskId)
|
||||
const task = await ensureConnectorSessionForTask(await loadTaskForOrder(orderNo, taskId))
|
||||
if (!task.connector_session_id) {
|
||||
return { task: mapTask(task), login: null }
|
||||
}
|
||||
@@ -857,6 +948,7 @@ export async function sendPublicWeiboSmsCode(
|
||||
taskIdInput: unknown,
|
||||
mobileInput: unknown,
|
||||
countryCodeInput: unknown = '86',
|
||||
mfaIdInput: unknown = '',
|
||||
) {
|
||||
const orderNo = normalizeOrderNo(orderNoInput)
|
||||
const taskId = normalizeId(taskIdInput)
|
||||
@@ -868,18 +960,26 @@ export async function sendPublicWeiboSmsCode(
|
||||
errorCode: 'semi_auto_task_closed',
|
||||
})
|
||||
}
|
||||
await sendWeiboSmsCode(
|
||||
String(mobileInput ?? '').trim(),
|
||||
String(countryCodeInput ?? '86').trim() || '86',
|
||||
const result = await sendWeiboSmsCode(
|
||||
{
|
||||
taskId: task.id,
|
||||
mobile: String(mobileInput ?? '').trim(),
|
||||
countryCode: String(countryCodeInput ?? '86').trim() || '86',
|
||||
mfaId: String(mfaIdInput ?? '').trim(),
|
||||
},
|
||||
task.connector_mode,
|
||||
)
|
||||
return { ok: true, message: '验证码已发送,请注意查收' }
|
||||
return {
|
||||
ok: !result.mfaRequired,
|
||||
message: result.mfaRequired ? '需要完成微博安全验证' : '验证码已发送,请注意查收',
|
||||
...result,
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginPublicWeiboSms(
|
||||
orderNoInput: unknown,
|
||||
taskIdInput: unknown,
|
||||
input: { mobile?: unknown; code?: unknown; countryCode?: unknown } = {},
|
||||
input: { mobile?: unknown; code?: unknown; countryCode?: unknown; rid?: unknown } = {},
|
||||
) {
|
||||
const orderNo = normalizeOrderNo(orderNoInput)
|
||||
const taskId = normalizeId(taskIdInput)
|
||||
@@ -900,10 +1000,15 @@ export async function loginPublicWeiboSms(
|
||||
mobile: String(input.mobile ?? '').trim(),
|
||||
code: String(input.code ?? '').trim(),
|
||||
countryCode: String(input.countryCode ?? '86').trim() || '86',
|
||||
rid: String(input.rid ?? '').trim(),
|
||||
},
|
||||
task.connector_mode,
|
||||
)
|
||||
const mapped = mapLoginUpdate(session)
|
||||
const storedCredential =
|
||||
session.status === 'authenticated' && session.hasCredential
|
||||
? exportWeiboSessionCredential(sessionId, task.connector_mode)
|
||||
: null
|
||||
|
||||
if (session.status !== 'authenticated') {
|
||||
await withTransaction(async (client) => {
|
||||
@@ -936,10 +1041,18 @@ export async function loginPublicWeiboSms(
|
||||
|
||||
const nextExecutionStatus: ExecutionStatus = 'waiting_operator'
|
||||
await withTransaction(async (client) => {
|
||||
const credentialId = storedCredential
|
||||
? await upsertSemiAutoCredentialWithClient(client, {
|
||||
platform: 'weibo',
|
||||
source: 'sms',
|
||||
payload: storedCredential,
|
||||
})
|
||||
: null
|
||||
await client.query(
|
||||
`
|
||||
UPDATE semi_auto_tasks
|
||||
SET connector_session_id = $2,
|
||||
credential_id = COALESCE($5, credential_id),
|
||||
login_status = 'authenticated',
|
||||
binding_status = 'confirmed',
|
||||
execution_status = 'waiting_operator',
|
||||
@@ -955,7 +1068,7 @@ export async function loginPublicWeiboSms(
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`,
|
||||
[task.id, sessionId, mapped.uid, mapped.nickname],
|
||||
[task.id, sessionId, mapped.uid, mapped.nickname, credentialId],
|
||||
)
|
||||
await appendTaskEvent(client, task.id, {
|
||||
eventType: 'login_status_changed',
|
||||
@@ -1114,6 +1227,56 @@ export async function getAdminSemiAutoTask(taskIdInput: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportAdminSemiAutoCredential(taskIdInput: unknown, session: AdminSession) {
|
||||
const task = await loadTask(normalizeId(taskIdInput))
|
||||
const adminUserId = Number(session.userId || 0)
|
||||
if (task.assigned_admin_user_id && Number(task.assigned_admin_user_id) !== adminUserId) {
|
||||
throw createHttpError('该任务已被其他客服领取', {
|
||||
statusCode: 409,
|
||||
errorCode: 'semi_auto_task_claimed',
|
||||
})
|
||||
}
|
||||
if (!task.credential_id) {
|
||||
throw createHttpError('当前任务尚未保存账号 CK', {
|
||||
statusCode: 404,
|
||||
errorCode: 'semi_auto_credential_not_found',
|
||||
})
|
||||
}
|
||||
const credential = await loadSemiAutoCredential(task.credential_id)
|
||||
if (!credential || credential.platform !== task.platform) {
|
||||
throw createHttpError('账号 CK 不存在或与任务平台不一致', {
|
||||
statusCode: 404,
|
||||
errorCode: 'semi_auto_credential_not_found',
|
||||
})
|
||||
}
|
||||
const config = safeParseJson(task.semi_auto_config_json)
|
||||
const targets = Array.isArray(config.browserTargets) ? config.browserTargets : []
|
||||
const configuredTarget = String(
|
||||
targets.find((item): item is { url?: string } => Boolean(item && typeof item === 'object'))
|
||||
?.url || '',
|
||||
).trim()
|
||||
const targetUrl = /^https:\/\//i.test(configuredTarget)
|
||||
? configuredTarget
|
||||
: credential.platform === 'weibo'
|
||||
? 'https://weibo.com/'
|
||||
: ''
|
||||
const cookieHeader = targetUrl
|
||||
? buildSemiAutoCredentialCookieHeader(credential.cookies, targetUrl)
|
||||
: ''
|
||||
return {
|
||||
credentialId: credential.id,
|
||||
platform: credential.platform,
|
||||
platformUid: credential.platformUid,
|
||||
nickname: credential.nickname,
|
||||
source: credential.source,
|
||||
status: credential.status,
|
||||
updatedAt: credential.updatedAt,
|
||||
targetUrl,
|
||||
cookieHeader,
|
||||
cookies: credential.cookies,
|
||||
}
|
||||
}
|
||||
|
||||
export async function claimAdminSemiAutoTask(taskIdInput: unknown, session: AdminSession) {
|
||||
const taskId = normalizeId(taskIdInput)
|
||||
const task = await loadTask(taskId)
|
||||
@@ -1142,7 +1305,7 @@ export async function claimAdminSemiAutoTask(taskIdInput: unknown, session: Admi
|
||||
|
||||
export async function syncAdminSemiAutoTask(taskIdInput: unknown) {
|
||||
const taskId = normalizeId(taskIdInput)
|
||||
let task = await loadTask(taskId)
|
||||
let task = await ensureConnectorSessionForTask(await loadTask(taskId))
|
||||
if (!task.connector_session_id) return getAdminSemiAutoTask(taskId)
|
||||
if (
|
||||
task.login_status !== 'authenticated' &&
|
||||
@@ -1166,7 +1329,7 @@ export async function createAdminBrowserSession(
|
||||
session: AdminSession,
|
||||
) {
|
||||
const taskId = normalizeId(taskIdInput)
|
||||
const task = await loadTask(taskId)
|
||||
const task = await ensureConnectorSessionForTask(await loadTask(taskId))
|
||||
const adminUserId = Number(session.userId || 0)
|
||||
if (task.assigned_admin_user_id && Number(task.assigned_admin_user_id) !== adminUserId) {
|
||||
throw createHttpError('该任务已被其他客服领取', {
|
||||
@@ -1277,7 +1440,9 @@ export async function completeAdminSemiAutoTask(
|
||||
session: AdminSession,
|
||||
) {
|
||||
const taskId = normalizeId(taskIdInput)
|
||||
const current = await loadTask(taskId)
|
||||
let current = await loadTask(taskId)
|
||||
if (current.execution_status === 'completed') return getAdminSemiAutoTask(taskId)
|
||||
current = await ensureConnectorSessionForTask(current)
|
||||
if (current.execution_status === 'completed') return getAdminSemiAutoTask(taskId)
|
||||
if (current.execution_status === 'cancelled') {
|
||||
throw createHttpError('任务已取消,不能完成', {
|
||||
|
||||
@@ -340,6 +340,66 @@ test(
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
'微博账号 CK 以账号级密文保存并可解密加载',
|
||||
{ skip: !TEST_DATABASE_URL ? '未设置 SEMI_AUTO_TASK_TEST_DATABASE_URL' : false },
|
||||
async () => {
|
||||
process.env.DATABASE_URL = TEST_DATABASE_URL
|
||||
process.env.SEMI_AUTO_CREDENTIAL_ENCRYPTION_KEY =
|
||||
'integration-test-semi-auto-credential-key-123456'
|
||||
const { closeDb, query, withTransaction } = await import('../../db/client.js')
|
||||
const { loadSemiAutoCredential, upsertSemiAutoCredentialWithClient } =
|
||||
await import('./semi-auto-credential-service.js')
|
||||
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`
|
||||
let credentialId = 0
|
||||
try {
|
||||
credentialId = await withTransaction((client) =>
|
||||
upsertSemiAutoCredentialWithClient(client, {
|
||||
platform: 'weibo',
|
||||
source: 'sms',
|
||||
payload: {
|
||||
platformUid: `it-weibo-${suffix}`,
|
||||
nickname: '集成测试微博',
|
||||
cookies: [
|
||||
{
|
||||
name: 'SUB',
|
||||
value: `secret-sub-${suffix}`,
|
||||
domain: '.weibo.com',
|
||||
path: '/',
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
)
|
||||
const raw = await query<{
|
||||
credential_ciphertext: string
|
||||
credential_source: string
|
||||
credential_status: string
|
||||
}>(
|
||||
`SELECT credential_ciphertext, credential_source, credential_status
|
||||
FROM semi_auto_credentials WHERE id = $1`,
|
||||
[credentialId],
|
||||
)
|
||||
assert.equal(raw.rows[0]?.credential_source, 'sms')
|
||||
assert.equal(raw.rows[0]?.credential_status, 'valid')
|
||||
assert.ok(raw.rows[0]?.credential_ciphertext.startsWith('enc:v1:'))
|
||||
assert.equal(raw.rows[0]?.credential_ciphertext.includes(`secret-sub-${suffix}`), false)
|
||||
|
||||
const credential = await loadSemiAutoCredential(credentialId)
|
||||
assert.equal(credential?.platformUid, `it-weibo-${suffix}`)
|
||||
assert.equal(credential?.cookies[0]?.value, `secret-sub-${suffix}`)
|
||||
} finally {
|
||||
if (credentialId > 0) {
|
||||
await query('DELETE FROM semi_auto_credentials WHERE id = $1', [credentialId])
|
||||
}
|
||||
delete process.env.SEMI_AUTO_CREDENTIAL_ENCRYPTION_KEY
|
||||
await closeDb()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
test(
|
||||
'开发 Mock 可生成半自动履约测试单并回填任务',
|
||||
{ skip: !TEST_DATABASE_URL ? '未设置 SEMI_AUTO_TASK_TEST_DATABASE_URL' : false },
|
||||
|
||||
@@ -7,6 +7,9 @@ import type {
|
||||
SemiAutoConnectorMode,
|
||||
} from './huya-connector-client.js'
|
||||
import {
|
||||
type WeiboSerializedCookie,
|
||||
type WeiboStoredCredential,
|
||||
type WeiboSmsSendResult,
|
||||
weiboNativeConnector,
|
||||
type WeiboCountryCode,
|
||||
} from './weibo-native/weibo-native-connector.js'
|
||||
@@ -44,22 +47,55 @@ export async function getWeiboLoginSession(
|
||||
}
|
||||
|
||||
export async function sendWeiboSmsCode(
|
||||
mobile: string,
|
||||
countryCode: string,
|
||||
input: {
|
||||
taskId: number
|
||||
mobile: string
|
||||
countryCode: string
|
||||
mfaId?: string
|
||||
},
|
||||
mode: SemiAutoConnectorMode = 'embedded',
|
||||
): Promise<void> {
|
||||
): Promise<WeiboSmsSendResult> {
|
||||
assertEmbedded(mode)
|
||||
await weiboNativeConnector.sendSmsCode(mobile, countryCode)
|
||||
return weiboNativeConnector.sendSmsCode(input)
|
||||
}
|
||||
|
||||
export async function loginWeiboSms(
|
||||
input: { taskId: number; orderNo: string; mobile: string; code: string; countryCode?: string },
|
||||
input: {
|
||||
taskId: number
|
||||
orderNo: string
|
||||
mobile: string
|
||||
code: string
|
||||
countryCode?: string
|
||||
rid?: string
|
||||
},
|
||||
mode: SemiAutoConnectorMode = 'embedded',
|
||||
): Promise<{ sessionId: string; session: ConnectorLoginSession }> {
|
||||
assertEmbedded(mode)
|
||||
return weiboNativeConnector.loginWithSms(input)
|
||||
}
|
||||
|
||||
export function exportWeiboSessionCredential(
|
||||
sessionId: string,
|
||||
mode: SemiAutoConnectorMode = 'embedded',
|
||||
): WeiboStoredCredential {
|
||||
assertEmbedded(mode)
|
||||
return weiboNativeConnector.exportSessionCredential(sessionId)
|
||||
}
|
||||
|
||||
export async function restoreWeiboSessionFromCredential(
|
||||
input: {
|
||||
taskId: number
|
||||
orderNo: string
|
||||
platformUid: string
|
||||
nickname: string
|
||||
cookies: WeiboSerializedCookie[]
|
||||
},
|
||||
mode: SemiAutoConnectorMode = 'embedded',
|
||||
): Promise<ConnectorLoginSession> {
|
||||
assertEmbedded(mode)
|
||||
return weiboNativeConnector.restoreSessionFromCredential(input)
|
||||
}
|
||||
|
||||
export async function getWeiboCountryCodes(
|
||||
mode: SemiAutoConnectorMode = 'embedded',
|
||||
): Promise<WeiboCountryCode[]> {
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* - 登录后账号信息
|
||||
* - 一次性浏览器注入票据
|
||||
*
|
||||
* 手机号、验证码、Cookie 都只保存在内存会话中,不落库、不写日志。
|
||||
* 手机号和验证码只保存在短期内存会话中;登录成功后的账号 Cookie 由任务服务
|
||||
* 导出为账号级凭证,经 AES-256-GCM 加密后落库,不写日志。
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto'
|
||||
@@ -39,6 +40,7 @@ const SMS_COOKIE_TTL_MS = 10 * 60 * 1000
|
||||
const COUNTRY_CODE_TTL_MS = 60 * 60 * 1000
|
||||
const MAX_SESSIONS = 200
|
||||
const TICKET_PREFIX = 'weibo-ticket-'
|
||||
export const WEIBO_YIDUN_CAPTCHA_ID = '7cda0ba2785647ada4d6e946ddb2d465'
|
||||
|
||||
export type WeiboCountryCode = {
|
||||
code: string
|
||||
@@ -91,10 +93,205 @@ type WeiboAccount = {
|
||||
type WeiboTicket = {
|
||||
ticket: string
|
||||
targetUrl: string
|
||||
cookie: string
|
||||
cookies: WeiboSerializedCookie[]
|
||||
expiresAtMs: number
|
||||
}
|
||||
|
||||
export type WeiboSerializedCookie = {
|
||||
name: string
|
||||
value: string
|
||||
domain: string
|
||||
path: string
|
||||
secure: boolean
|
||||
httpOnly: boolean
|
||||
hostOnly?: boolean
|
||||
expiresAt: string | null
|
||||
}
|
||||
|
||||
type StoredWeiboCookie = WeiboSerializedCookie & {
|
||||
domain: string
|
||||
hostOnly: boolean
|
||||
expiresAtMs: number
|
||||
}
|
||||
|
||||
class WeiboCookieJar {
|
||||
private readonly values = new Map<string, StoredWeiboCookie>()
|
||||
|
||||
addFromResponse(headers: Headers, requestUrl: string): void {
|
||||
const url = new URL(requestUrl)
|
||||
for (const raw of readSetCookie(headers)) {
|
||||
this.addSetCookie(raw, url)
|
||||
}
|
||||
}
|
||||
|
||||
headerFor(requestUrl: string): string {
|
||||
const url = new URL(requestUrl)
|
||||
const nowMs = Date.now()
|
||||
const pairs: string[] = []
|
||||
for (const [key, cookie] of this.values) {
|
||||
if (cookie.expiresAtMs > 0 && cookie.expiresAtMs <= nowMs) {
|
||||
this.values.delete(key)
|
||||
continue
|
||||
}
|
||||
if (!this.matches(cookie, url)) continue
|
||||
pairs.push(`${cookie.name}=${cookie.value}`)
|
||||
}
|
||||
return pairs.join('; ')
|
||||
}
|
||||
|
||||
value(requestUrl: string, name: string): string {
|
||||
const url = new URL(requestUrl)
|
||||
for (const cookie of this.values.values()) {
|
||||
if (cookie.name === name && this.matches(cookie, url)) return cookie.value
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
has(requestUrl: string, name: string): boolean {
|
||||
return Boolean(this.value(requestUrl, name))
|
||||
}
|
||||
|
||||
serialize(requestUrl = WEIBO_BASE): WeiboSerializedCookie[] {
|
||||
const url = new URL(requestUrl)
|
||||
return Array.from(this.values.values())
|
||||
.filter((cookie) => this.matches(cookie, url))
|
||||
.map((cookie) => this.toSerialized(cookie))
|
||||
.filter((cookie): cookie is WeiboSerializedCookie => cookie !== null)
|
||||
}
|
||||
|
||||
serializeAll(): WeiboSerializedCookie[] {
|
||||
return Array.from(this.values.values())
|
||||
.map((cookie) => this.toSerialized(cookie))
|
||||
.filter((cookie): cookie is WeiboSerializedCookie => cookie !== null)
|
||||
}
|
||||
|
||||
static fromSerialized(cookies: WeiboSerializedCookie[]): WeiboCookieJar {
|
||||
const jar = new WeiboCookieJar()
|
||||
for (const cookie of cookies) {
|
||||
const domain = String(cookie.domain || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (!cookie.name || !domain) continue
|
||||
const expiresAtMs = cookie.expiresAt ? Date.parse(cookie.expiresAt) : 0
|
||||
jar.values.set(`${cookie.name}\u0000${domain}\u0000${cookie.path || '/'}`, {
|
||||
name: cookie.name,
|
||||
value: String(cookie.value || ''),
|
||||
domain,
|
||||
path: cookie.path || '/',
|
||||
secure: cookie.secure === true,
|
||||
httpOnly: cookie.httpOnly === true,
|
||||
expiresAt: cookie.expiresAt || null,
|
||||
hostOnly: typeof cookie.hostOnly === 'boolean' ? cookie.hostOnly : !domain.startsWith('.'),
|
||||
expiresAtMs: Number.isFinite(expiresAtMs) ? expiresAtMs : 0,
|
||||
})
|
||||
}
|
||||
return jar
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.values.clear()
|
||||
}
|
||||
|
||||
private toSerialized(cookie: StoredWeiboCookie): WeiboSerializedCookie | null {
|
||||
if (cookie.expiresAtMs > 0 && cookie.expiresAtMs <= Date.now()) return null
|
||||
return {
|
||||
name: cookie.name,
|
||||
value: cookie.value,
|
||||
domain: cookie.domain,
|
||||
path: cookie.path,
|
||||
secure: cookie.secure,
|
||||
httpOnly: cookie.httpOnly,
|
||||
hostOnly: cookie.hostOnly,
|
||||
expiresAt: cookie.expiresAtMs > 0 ? new Date(cookie.expiresAtMs).toISOString() : null,
|
||||
}
|
||||
}
|
||||
|
||||
private addSetCookie(raw: string, requestUrl: URL): void {
|
||||
const parts = String(raw || '')
|
||||
.split(';')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
const first = parts.shift() || ''
|
||||
const separator = first.indexOf('=')
|
||||
if (separator <= 0) return
|
||||
const name = first.slice(0, separator).trim()
|
||||
const value = first.slice(separator + 1).trim()
|
||||
if (!name) return
|
||||
|
||||
let domain = ''
|
||||
let cookiePath = ''
|
||||
let secure = false
|
||||
let httpOnly = false
|
||||
let expiresAtMs = 0
|
||||
for (const part of parts) {
|
||||
const index = part.indexOf('=')
|
||||
const key = (index >= 0 ? part.slice(0, index) : part).trim().toLowerCase()
|
||||
const attr = index >= 0 ? part.slice(index + 1).trim() : ''
|
||||
if (key === 'domain') domain = attr.toLowerCase()
|
||||
else if (key === 'path') cookiePath = attr || '/'
|
||||
else if (key === 'secure') secure = true
|
||||
else if (key === 'httponly') httpOnly = true
|
||||
else if (key === 'max-age') {
|
||||
const seconds = Number(attr)
|
||||
if (Number.isFinite(seconds)) {
|
||||
expiresAtMs = seconds <= 0 ? 1 : Date.now() + seconds * 1000
|
||||
}
|
||||
} else if (key === 'expires') {
|
||||
const parsed = Date.parse(attr)
|
||||
if (Number.isFinite(parsed)) expiresAtMs = parsed
|
||||
}
|
||||
}
|
||||
|
||||
const hostOnly = !domain
|
||||
const normalizedDomain = (domain || requestUrl.hostname).toLowerCase()
|
||||
if (
|
||||
!hostOnly &&
|
||||
requestUrl.hostname !== normalizedDomain.replace(/^\./, '') &&
|
||||
!requestUrl.hostname.endsWith(normalizedDomain)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const path = cookiePath || defaultCookiePath(requestUrl.pathname)
|
||||
const key = `${name}\u0000${normalizedDomain}\u0000${path}`
|
||||
if (expiresAtMs > 0 && expiresAtMs <= Date.now()) {
|
||||
this.values.delete(key)
|
||||
return
|
||||
}
|
||||
this.values.set(key, {
|
||||
name,
|
||||
value,
|
||||
domain: normalizedDomain,
|
||||
path,
|
||||
secure,
|
||||
httpOnly,
|
||||
expiresAt: expiresAtMs > 0 ? new Date(expiresAtMs).toISOString() : null,
|
||||
hostOnly,
|
||||
expiresAtMs,
|
||||
})
|
||||
}
|
||||
|
||||
private matches(cookie: StoredWeiboCookie, url: URL): boolean {
|
||||
if (cookie.secure && url.protocol !== 'https:') return false
|
||||
const hostname = url.hostname.toLowerCase()
|
||||
const cookieDomain = cookie.domain.replace(/^\./, '')
|
||||
const domainMatches = cookie.hostOnly
|
||||
? hostname === cookieDomain
|
||||
: hostname === cookieDomain || hostname.endsWith(`.${cookieDomain}`)
|
||||
if (!domainMatches) return false
|
||||
const requestPath = url.pathname || '/'
|
||||
return (
|
||||
requestPath === cookie.path ||
|
||||
requestPath.startsWith(cookie.path.endsWith('/') ? cookie.path : `${cookie.path}/`)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function defaultCookiePath(pathname: string): string {
|
||||
const value = String(pathname || '/')
|
||||
const index = value.lastIndexOf('/')
|
||||
return index <= 0 ? '/' : value.slice(0, index)
|
||||
}
|
||||
|
||||
type WeiboSession = {
|
||||
sessionId: string
|
||||
taskId: number
|
||||
@@ -105,11 +302,32 @@ type WeiboSession = {
|
||||
qrid: string
|
||||
qrImage: string
|
||||
message: string
|
||||
cookie: string
|
||||
cookies: WeiboCookieJar
|
||||
account: WeiboAccount | null
|
||||
tickets: Map<string, WeiboTicket>
|
||||
}
|
||||
|
||||
type SmsLoginState = {
|
||||
taskId: number
|
||||
mobile: string
|
||||
cookies: WeiboCookieJar
|
||||
mfaId: string
|
||||
createdAtMs: number
|
||||
expiresAtMs: number
|
||||
}
|
||||
|
||||
export type WeiboStoredCredential = {
|
||||
platformUid: string
|
||||
nickname: string
|
||||
cookies: WeiboSerializedCookie[]
|
||||
}
|
||||
|
||||
export type WeiboSmsSendResult = {
|
||||
mfaRequired: boolean
|
||||
mfaId?: string
|
||||
captchaId?: string
|
||||
}
|
||||
|
||||
export type WeiboNativeConnectorOptions = {
|
||||
fetchImpl?: FetchLike
|
||||
now?: () => number
|
||||
@@ -121,31 +339,6 @@ function iso(value: number): string {
|
||||
return new Date(value).toISOString()
|
||||
}
|
||||
|
||||
function normalizeCookiePairs(cookie: string): Array<[string, string]> {
|
||||
const pairs: Array<[string, string]> = []
|
||||
for (const part of String(cookie || '').split(';')) {
|
||||
const index = part.indexOf('=')
|
||||
if (index <= 0) continue
|
||||
const key = part.slice(0, index).trim()
|
||||
const value = part.slice(index + 1).trim()
|
||||
if (key) pairs.push([key, value])
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
function cookieValue(cookie: string, key: string): string {
|
||||
for (const [itemKey, itemValue] of normalizeCookiePairs(cookie)) {
|
||||
if (itemKey === key) return itemValue
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function buildCookieHeader(pairs: Map<string, string>): string {
|
||||
return Array.from(pairs.entries())
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('; ')
|
||||
}
|
||||
|
||||
function readSetCookie(headers: Headers): string[] {
|
||||
const withGetSetCookie = headers as Headers & { getSetCookie?: () => string[] }
|
||||
if (typeof withGetSetCookie.getSetCookie === 'function') {
|
||||
@@ -156,7 +349,7 @@ function readSetCookie(headers: Headers): string[] {
|
||||
|
||||
export class WeiboNativeConnector {
|
||||
private readonly sessions = new Map<string, WeiboSession>()
|
||||
private readonly smsCookies = new Map<string, { cookie: string; expiresAtMs: number }>()
|
||||
private readonly smsSessions = new Map<string, SmsLoginState>()
|
||||
private countryCodeCache: { items: WeiboCountryCode[]; expiresAtMs: number } | null = null
|
||||
private readonly fetchImpl: FetchLike
|
||||
private readonly now: () => number
|
||||
@@ -174,14 +367,16 @@ export class WeiboNativeConnector {
|
||||
}): Promise<ConnectorLoginSession> {
|
||||
this.prune()
|
||||
const sessionId = crypto.randomBytes(18).toString('base64url')
|
||||
const cookies = await this.createBootstrapSession()
|
||||
const response = await this.fetchImpl(
|
||||
`${PASSPORT_BASE}/sso/v2/qrcode/image?entry=miniblog&size=180`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: this.jsonHeaders(),
|
||||
headers: this.jsonHeaders(PASSPORT_BASE, cookies),
|
||||
signal: AbortSignal.timeout(this.timeoutMs),
|
||||
},
|
||||
)
|
||||
cookies.addFromResponse(response.headers, `${PASSPORT_BASE}/sso/v2/qrcode/image`)
|
||||
if (!response.ok) {
|
||||
throw createHttpError(`获取微博登录二维码失败:HTTP ${response.status}`, {
|
||||
statusCode: 502,
|
||||
@@ -211,7 +406,7 @@ export class WeiboNativeConnector {
|
||||
qrid: String(payload.data.qrid),
|
||||
qrImage,
|
||||
message: '请使用微博 App 扫码登录',
|
||||
cookie: '',
|
||||
cookies,
|
||||
account: null,
|
||||
tickets: new Map(),
|
||||
}
|
||||
@@ -229,9 +424,10 @@ export class WeiboNativeConnector {
|
||||
`&disp=popup&rid=norid&ver=${WEIBO_LOGIN_VER}`
|
||||
const response = await this.fetchImpl(checkUrl, {
|
||||
method: 'GET',
|
||||
headers: this.jsonHeaders(),
|
||||
headers: this.jsonHeaders(checkUrl, session.cookies),
|
||||
signal: AbortSignal.timeout(this.timeoutMs),
|
||||
})
|
||||
session.cookies.addFromResponse(response.headers, checkUrl)
|
||||
if (!response.ok) {
|
||||
session.status = 'failed'
|
||||
session.message = `微博扫码状态查询失败:HTTP ${response.status}`
|
||||
@@ -250,11 +446,7 @@ export class WeiboNativeConnector {
|
||||
session.message = '微博登录成功但缺少跳转地址'
|
||||
return this.loginView(session)
|
||||
}
|
||||
await this.finishLogin(
|
||||
session,
|
||||
loginUrl,
|
||||
buildCookieHeader(this.collectCookiePairs(response.headers)),
|
||||
)
|
||||
await this.finishLogin(session, loginUrl)
|
||||
return this.loginView(session)
|
||||
}
|
||||
if ([50114000, 50114001].includes(retcode)) {
|
||||
@@ -283,13 +475,15 @@ export class WeiboNativeConnector {
|
||||
return this.countryCodeCache.items
|
||||
}
|
||||
try {
|
||||
const cookies = await this.createBootstrapSession()
|
||||
const body = new URLSearchParams({ entry: 'miniblog', source: 'miniblog' })
|
||||
const response = await this.fetchImpl(`${PASSPORT_BASE}/sso/v2/web/config`, {
|
||||
method: 'POST',
|
||||
headers: this.formHeaders(),
|
||||
headers: this.formHeaders(`${PASSPORT_BASE}/sso/v2/web/config`, cookies),
|
||||
body,
|
||||
signal: AbortSignal.timeout(this.timeoutMs),
|
||||
})
|
||||
cookies.addFromResponse(response.headers, `${PASSPORT_BASE}/sso/v2/web/config`)
|
||||
if (response.ok) {
|
||||
const payload = (await response.json().catch(() => null)) as {
|
||||
retcode?: number
|
||||
@@ -319,30 +513,75 @@ export class WeiboNativeConnector {
|
||||
return FALLBACK_COUNTRY_CODES
|
||||
}
|
||||
|
||||
async sendSmsCode(mobileInput: string, countryCodeInput: unknown = '86'): Promise<void> {
|
||||
async sendSmsCode(input: {
|
||||
taskId: number
|
||||
mobile: string
|
||||
countryCode?: unknown
|
||||
mfaId?: string
|
||||
}): Promise<WeiboSmsSendResult> {
|
||||
this.prune()
|
||||
const mobile = this.normalizeMobile(mobileInput, countryCodeInput)
|
||||
const taskId = Number(input.taskId) || 0
|
||||
if (taskId <= 0) {
|
||||
throw createHttpError('半自动履约任务无效', {
|
||||
statusCode: 400,
|
||||
errorCode: 'weibo_task_id_required',
|
||||
})
|
||||
}
|
||||
const mobile = this.normalizeMobile(input.mobile, input.countryCode)
|
||||
const stateKey = this.smsStateKey(taskId, mobile)
|
||||
let state = this.smsSessions.get(stateKey)
|
||||
if (!state) {
|
||||
state = {
|
||||
taskId,
|
||||
mobile,
|
||||
cookies: await this.createBootstrapSession(),
|
||||
mfaId: '',
|
||||
createdAtMs: this.now(),
|
||||
expiresAtMs: this.now() + SMS_COOKIE_TTL_MS,
|
||||
}
|
||||
this.smsSessions.set(stateKey, state)
|
||||
}
|
||||
const requestedMfaId = String(input.mfaId || '').trim()
|
||||
if (requestedMfaId && requestedMfaId !== state.mfaId) {
|
||||
throw createHttpError('微博安全验证已过期,请重新获取验证码', {
|
||||
statusCode: 409,
|
||||
errorCode: 'weibo_sms_mfa_expired',
|
||||
})
|
||||
}
|
||||
const body = new URLSearchParams({
|
||||
entry: 'miniblog',
|
||||
mobile: encodeSsoField(mobile),
|
||||
el: '2',
|
||||
})
|
||||
if (requestedMfaId) body.set('mfa_id', requestedMfaId)
|
||||
const sendUrl = `${PASSPORT_BASE}/sso/v2/sms/send`
|
||||
const response = await this.fetchImpl(`${PASSPORT_BASE}/sso/v2/sms/send`, {
|
||||
method: 'POST',
|
||||
headers: this.formHeaders(),
|
||||
headers: this.formHeaders(sendUrl, state.cookies),
|
||||
body,
|
||||
signal: AbortSignal.timeout(this.timeoutMs),
|
||||
})
|
||||
state.cookies.addFromResponse(response.headers, sendUrl)
|
||||
const payload = (await response.json().catch(() => null)) as {
|
||||
retcode?: number
|
||||
msg?: string
|
||||
data?: { act?: string; mfa_id?: string }
|
||||
} | null
|
||||
if (Number(payload?.retcode) === 0 && String(payload?.data?.act || '') === 'mfa_1') {
|
||||
throw createHttpError('微博要求二次验证,请改用扫码登录', {
|
||||
statusCode: 409,
|
||||
errorCode: 'weibo_sms_mfa_required',
|
||||
})
|
||||
const mfaId = String(payload?.data?.mfa_id || '').trim()
|
||||
if (!mfaId) {
|
||||
throw createHttpError('微博要求安全验证,但未返回验证标识', {
|
||||
statusCode: 502,
|
||||
errorCode: 'weibo_sms_mfa_invalid',
|
||||
})
|
||||
}
|
||||
state.mfaId = mfaId
|
||||
state.expiresAtMs = this.now() + SMS_COOKIE_TTL_MS
|
||||
return {
|
||||
mfaRequired: true,
|
||||
mfaId,
|
||||
captchaId: WEIBO_YIDUN_CAPTCHA_ID,
|
||||
}
|
||||
}
|
||||
if (!response.ok || !payload || Number(payload.retcode) !== 20000000) {
|
||||
throw createHttpError(payload?.msg || `发送微博短信验证码失败:HTTP ${response.status}`, {
|
||||
@@ -350,13 +589,9 @@ export class WeiboNativeConnector {
|
||||
errorCode: 'weibo_sms_send_failed',
|
||||
})
|
||||
}
|
||||
const sendCookie = buildCookieHeader(this.collectCookiePairs(response.headers))
|
||||
if (sendCookie) {
|
||||
this.smsCookies.set(mobile, {
|
||||
cookie: sendCookie,
|
||||
expiresAtMs: this.now() + SMS_COOKIE_TTL_MS,
|
||||
})
|
||||
}
|
||||
state.mfaId = ''
|
||||
state.expiresAtMs = this.now() + SMS_COOKIE_TTL_MS
|
||||
return { mfaRequired: false }
|
||||
}
|
||||
|
||||
async loginWithSms(input: {
|
||||
@@ -365,6 +600,7 @@ export class WeiboNativeConnector {
|
||||
mobile: string
|
||||
code: string
|
||||
countryCode?: unknown
|
||||
rid?: string
|
||||
}): Promise<{ sessionId: string; session: ConnectorLoginSession }> {
|
||||
this.prune()
|
||||
const mobile = this.normalizeMobile(input.mobile, input.countryCode)
|
||||
@@ -375,6 +611,14 @@ export class WeiboNativeConnector {
|
||||
errorCode: 'weibo_sms_code_required',
|
||||
})
|
||||
}
|
||||
const stateKey = this.smsStateKey(Number(input.taskId) || 0, mobile)
|
||||
const state = this.smsSessions.get(stateKey)
|
||||
if (!state) {
|
||||
throw createHttpError('短信登录会话不存在或已过期,请重新获取验证码', {
|
||||
statusCode: 409,
|
||||
errorCode: 'weibo_sms_session_missing',
|
||||
})
|
||||
}
|
||||
const body = new URLSearchParams({
|
||||
entry: 'miniblog',
|
||||
source: 'miniblog',
|
||||
@@ -385,14 +629,16 @@ export class WeiboNativeConnector {
|
||||
scode: encodeSsoField(code),
|
||||
el: '2',
|
||||
disp: 'popup',
|
||||
rid: 'norid',
|
||||
rid: String(input.rid || '').trim() || 'norid',
|
||||
})
|
||||
const loginUrl = `${PASSPORT_BASE}/sso/v2/login`
|
||||
const response = await this.fetchImpl(`${PASSPORT_BASE}/sso/v2/login`, {
|
||||
method: 'POST',
|
||||
headers: this.formHeaders(),
|
||||
headers: this.formHeaders(loginUrl, state.cookies),
|
||||
body,
|
||||
signal: AbortSignal.timeout(this.timeoutMs),
|
||||
})
|
||||
state.cookies.addFromResponse(response.headers, loginUrl)
|
||||
const payload = (await response.json().catch(() => null)) as {
|
||||
retcode?: number
|
||||
msg?: string
|
||||
@@ -435,15 +681,12 @@ export class WeiboNativeConnector {
|
||||
qrid: '',
|
||||
qrImage: '',
|
||||
message: '微博登录成功',
|
||||
cookie: '',
|
||||
cookies: state.cookies,
|
||||
account: null,
|
||||
tickets: new Map(),
|
||||
}
|
||||
const pendingSmsCookie = this.smsCookies.get(mobile)
|
||||
this.smsCookies.delete(mobile)
|
||||
const responseCookie = buildCookieHeader(this.collectCookiePairs(response.headers))
|
||||
const seedCookie = [pendingSmsCookie?.cookie || '', responseCookie].filter(Boolean).join('; ')
|
||||
await this.finishLogin(session, location, seedCookie)
|
||||
this.smsSessions.delete(stateKey)
|
||||
await this.finishLogin(session, location)
|
||||
this.sessions.set(session.sessionId, session)
|
||||
return { sessionId: session.sessionId, session: this.loginView(session) }
|
||||
}
|
||||
@@ -464,7 +707,7 @@ export class WeiboNativeConnector {
|
||||
targetUrl?: string
|
||||
}): Promise<ConnectorBrowserTicket> {
|
||||
const session = this.requireSession(input.sessionId)
|
||||
if (!session.cookie) {
|
||||
if (!session.cookies.has(WEIBO_BASE, 'SUB')) {
|
||||
throw createHttpError('微博登录态不存在或已过期', {
|
||||
statusCode: 404,
|
||||
errorCode: 'weibo_credential_not_found',
|
||||
@@ -476,7 +719,7 @@ export class WeiboNativeConnector {
|
||||
session.tickets.set(ticket, {
|
||||
ticket,
|
||||
targetUrl,
|
||||
cookie: session.cookie,
|
||||
cookies: session.cookies.serialize(targetUrl),
|
||||
expiresAtMs,
|
||||
})
|
||||
return { ticket, expiresAt: iso(expiresAtMs), targetUrl }
|
||||
@@ -505,15 +748,7 @@ export class WeiboNativeConnector {
|
||||
platform: 'weibo',
|
||||
targetUrl: item.targetUrl,
|
||||
expiresAt: iso(item.expiresAtMs),
|
||||
cookies: normalizeCookiePairs(item.cookie)
|
||||
.filter(([name, value]) => name && value)
|
||||
.map(([name, value]) => ({
|
||||
name,
|
||||
value,
|
||||
domain: '.weibo.com',
|
||||
path: '/',
|
||||
secure: true,
|
||||
})),
|
||||
cookies: item.cookies,
|
||||
}
|
||||
}
|
||||
throw createHttpError('浏览器注入票据不存在或已过期', {
|
||||
@@ -526,46 +761,97 @@ export class WeiboNativeConnector {
|
||||
const sessionId = String(sessionIdInput || '').trim()
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session) return
|
||||
session.cookie = ''
|
||||
session.cookies.clear()
|
||||
session.tickets.clear()
|
||||
this.sessions.delete(sessionId)
|
||||
}
|
||||
|
||||
private async finishLogin(
|
||||
session: WeiboSession,
|
||||
loginUrl: string,
|
||||
seedCookie = '',
|
||||
): Promise<void> {
|
||||
const { cookie } = await this.followWithCookies(loginUrl, seedCookie)
|
||||
if (!cookie || !cookieValue(cookie, 'SUB')) {
|
||||
exportSessionCredential(sessionIdInput: string): WeiboStoredCredential {
|
||||
const session = this.requireSession(sessionIdInput)
|
||||
if (!session.account?.uid || !session.cookies.has(WEIBO_BASE, 'SUB')) {
|
||||
throw createHttpError('微博登录凭证尚未就绪', {
|
||||
statusCode: 409,
|
||||
errorCode: 'weibo_credential_not_ready',
|
||||
})
|
||||
}
|
||||
return {
|
||||
platformUid: session.account.uid,
|
||||
nickname: session.account.nickname,
|
||||
cookies: session.cookies.serializeAll(),
|
||||
}
|
||||
}
|
||||
|
||||
async restoreSessionFromCredential(input: {
|
||||
taskId: number
|
||||
orderNo: string
|
||||
platformUid: string
|
||||
nickname: string
|
||||
cookies: WeiboSerializedCookie[]
|
||||
}): Promise<ConnectorLoginSession> {
|
||||
this.prune()
|
||||
const cookies = WeiboCookieJar.fromSerialized(input.cookies)
|
||||
if (!cookies.has(WEIBO_BASE, 'SUB')) {
|
||||
throw createHttpError('微博 CK 已失效,请重新登录', {
|
||||
statusCode: 409,
|
||||
errorCode: 'weibo_credential_invalid',
|
||||
})
|
||||
}
|
||||
const account = await this.fetchAccount(cookies.headerFor(WEIBO_BASE))
|
||||
if (!account.uid) {
|
||||
throw createHttpError('微博 CK 已失效,请重新登录', {
|
||||
statusCode: 409,
|
||||
errorCode: 'weibo_credential_invalid',
|
||||
})
|
||||
}
|
||||
if (String(input.platformUid || '') && account.uid !== String(input.platformUid)) {
|
||||
throw createHttpError('微博 CK 与任务账号不一致,请重新登录', {
|
||||
statusCode: 409,
|
||||
errorCode: 'weibo_credential_account_mismatch',
|
||||
})
|
||||
}
|
||||
const nowMs = this.now()
|
||||
const session: WeiboSession = {
|
||||
sessionId: crypto.randomBytes(18).toString('base64url'),
|
||||
taskId: Number(input.taskId) || 0,
|
||||
orderNo: String(input.orderNo || ''),
|
||||
createdAtMs: nowMs,
|
||||
expiresAtMs: nowMs + CREDENTIAL_TTL_MS,
|
||||
status: 'authenticated',
|
||||
qrid: '',
|
||||
qrImage: '',
|
||||
message: '微博登录成功',
|
||||
cookies,
|
||||
account: {
|
||||
...account,
|
||||
nickname: account.nickname || String(input.nickname || ''),
|
||||
},
|
||||
tickets: new Map(),
|
||||
}
|
||||
this.sessions.set(session.sessionId, session)
|
||||
return this.loginView(session)
|
||||
}
|
||||
|
||||
private async finishLogin(session: WeiboSession, loginUrl: string): Promise<void> {
|
||||
await this.followWithCookies(loginUrl, session.cookies)
|
||||
if (!session.cookies.has(WEIBO_BASE, 'SUB')) {
|
||||
session.status = 'failed'
|
||||
session.message = '微博登录成功但未获取到登录 Cookie'
|
||||
return
|
||||
}
|
||||
session.cookie = cookie
|
||||
session.account = await this.fetchAccount(cookie)
|
||||
session.account = await this.fetchAccount(session.cookies.headerFor(WEIBO_BASE))
|
||||
if (!session.account.uid) {
|
||||
session.status = 'failed'
|
||||
session.message = '微博登录成功但账号信息读取失败'
|
||||
return
|
||||
}
|
||||
session.status = 'authenticated'
|
||||
session.message = '微博登录成功'
|
||||
}
|
||||
|
||||
private collectCookiePairs(headers: Headers): Map<string, string> {
|
||||
const pairs = new Map<string, string>()
|
||||
for (const raw of readSetCookie(headers)) {
|
||||
const first = String(raw || '').split(';', 1)[0] || ''
|
||||
const separator = first.indexOf('=')
|
||||
if (separator <= 0) continue
|
||||
const key = first.slice(0, separator).trim()
|
||||
const value = first.slice(separator + 1).trim()
|
||||
if (key) pairs.set(key, value)
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
private async followWithCookies(
|
||||
url: string,
|
||||
seedCookie = '',
|
||||
): Promise<{ cookie: string; finalUrl: string }> {
|
||||
const pairs = new Map<string, string>(normalizeCookiePairs(seedCookie))
|
||||
cookies: WeiboCookieJar,
|
||||
): Promise<{ finalUrl: string }> {
|
||||
let currentUrl = url
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
const response = await this.fetchImpl(currentUrl, {
|
||||
@@ -574,14 +860,12 @@ export class WeiboNativeConnector {
|
||||
'user-agent': PC_UA,
|
||||
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'accept-language': 'zh-CN,zh;q=0.9',
|
||||
...(pairs.size > 0 ? { cookie: buildCookieHeader(pairs) } : {}),
|
||||
cookie: cookies.headerFor(currentUrl),
|
||||
},
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(this.timeoutMs),
|
||||
})
|
||||
for (const [key, value] of this.collectCookiePairs(response.headers)) {
|
||||
pairs.set(key, value)
|
||||
}
|
||||
cookies.addFromResponse(response.headers, currentUrl)
|
||||
const location = response.headers.get('location')
|
||||
if (response.status >= 300 && response.status < 400 && location) {
|
||||
currentUrl = new URL(location, currentUrl).toString()
|
||||
@@ -590,7 +874,7 @@ export class WeiboNativeConnector {
|
||||
await response.arrayBuffer().catch(() => undefined)
|
||||
break
|
||||
}
|
||||
return { cookie: buildCookieHeader(pairs), finalUrl: currentUrl }
|
||||
return { finalUrl: currentUrl }
|
||||
}
|
||||
|
||||
private async fetchAccount(cookie: string): Promise<WeiboAccount> {
|
||||
@@ -654,7 +938,7 @@ export class WeiboNativeConnector {
|
||||
expiresAt: iso(session.createdAtMs + LOGIN_QR_TTL_MS),
|
||||
huyaUid: session.account?.uid || '',
|
||||
nickname: session.account?.nickname || '',
|
||||
hasCredential: Boolean(session.cookie),
|
||||
hasCredential: session.cookies.has(WEIBO_BASE, 'SUB'),
|
||||
message: session.message,
|
||||
}
|
||||
}
|
||||
@@ -706,7 +990,38 @@ export class WeiboNativeConnector {
|
||||
return `00${countryCode}${digits}`
|
||||
}
|
||||
|
||||
private jsonHeaders(): Record<string, string> {
|
||||
private async createBootstrapSession(): Promise<WeiboCookieJar> {
|
||||
const cookies = new WeiboCookieJar()
|
||||
let currentUrl = SIGNIN_REFERER
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
const response = await this.fetchImpl(currentUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'user-agent': PC_UA,
|
||||
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'accept-language': 'zh-CN,zh;q=0.9',
|
||||
...(cookies.headerFor(currentUrl) ? { cookie: cookies.headerFor(currentUrl) } : {}),
|
||||
},
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(this.timeoutMs),
|
||||
})
|
||||
cookies.addFromResponse(response.headers, currentUrl)
|
||||
const location = response.headers.get('location')
|
||||
if (response.status >= 300 && response.status < 400 && location) {
|
||||
currentUrl = new URL(location, currentUrl).toString()
|
||||
continue
|
||||
}
|
||||
await response.arrayBuffer().catch(() => undefined)
|
||||
break
|
||||
}
|
||||
return cookies
|
||||
}
|
||||
|
||||
private smsStateKey(taskId: number, mobile: string): string {
|
||||
return `${taskId}:${mobile}`
|
||||
}
|
||||
|
||||
private jsonHeaders(requestUrl: string, cookies: WeiboCookieJar): Record<string, string> {
|
||||
return {
|
||||
'user-agent': PC_UA,
|
||||
origin: PASSPORT_BASE,
|
||||
@@ -714,24 +1029,28 @@ export class WeiboNativeConnector {
|
||||
accept: 'application/json, text/plain, */*',
|
||||
'accept-language': 'zh-CN,zh;q=0.9',
|
||||
'x-requested-with': 'XMLHttpRequest',
|
||||
...(cookies.value(requestUrl, 'X-CSRF-TOKEN')
|
||||
? { 'x-csrf-token': cookies.value(requestUrl, 'X-CSRF-TOKEN') }
|
||||
: {}),
|
||||
...(cookies.headerFor(requestUrl) ? { cookie: cookies.headerFor(requestUrl) } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
private formHeaders(): Record<string, string> {
|
||||
private formHeaders(requestUrl: string, cookies: WeiboCookieJar): Record<string, string> {
|
||||
return {
|
||||
...this.jsonHeaders(),
|
||||
...this.jsonHeaders(requestUrl, cookies),
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
}
|
||||
}
|
||||
|
||||
private prune(): void {
|
||||
const nowMs = this.now()
|
||||
for (const [mobile, item] of Array.from(this.smsCookies.entries())) {
|
||||
if (item.expiresAtMs <= nowMs) this.smsCookies.delete(mobile)
|
||||
for (const [key, item] of Array.from(this.smsSessions.entries())) {
|
||||
if (item.expiresAtMs <= nowMs) this.smsSessions.delete(key)
|
||||
}
|
||||
for (const [sessionId, session] of Array.from(this.sessions.entries())) {
|
||||
if (session.expiresAtMs <= nowMs) {
|
||||
session.cookie = ''
|
||||
session.cookies.clear()
|
||||
session.tickets.clear()
|
||||
this.sessions.delete(sessionId)
|
||||
}
|
||||
@@ -739,7 +1058,7 @@ export class WeiboNativeConnector {
|
||||
if (this.sessions.size <= MAX_SESSIONS) return
|
||||
const overflow = this.sessions.size - MAX_SESSIONS
|
||||
for (const [sessionId, session] of Array.from(this.sessions.entries()).slice(0, overflow)) {
|
||||
session.cookie = ''
|
||||
session.cookies.clear()
|
||||
session.tickets.clear()
|
||||
this.sessions.delete(sessionId)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,15 @@ function createFetchMock(
|
||||
}
|
||||
|
||||
function defaultHandler(url: string): Response {
|
||||
if (url.includes('/sso/signin')) {
|
||||
return new Response('<html></html>', {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'text/html',
|
||||
'set-cookie': 'X-CSRF-TOKEN=CK-demo; Path=/sso',
|
||||
},
|
||||
})
|
||||
}
|
||||
if (url.includes('/sso/v2/web/config')) {
|
||||
return jsonResponse({
|
||||
retcode: 20000000,
|
||||
@@ -62,15 +71,26 @@ function defaultHandler(url: string): Response {
|
||||
return jsonResponse({ retcode: 20000000 })
|
||||
}
|
||||
if (url.includes('/sso/v2/login')) {
|
||||
return jsonResponse({
|
||||
retcode: 20000000,
|
||||
data: {
|
||||
data: {
|
||||
act: 'goto',
|
||||
location: 'https://passport.weibo.com/sso/v2/goto?ticket=demo',
|
||||
},
|
||||
},
|
||||
const headers = new Headers({
|
||||
'content-type': 'application/json',
|
||||
})
|
||||
headers.append('set-cookie', 'SUB=demo-sub; Path=/; Domain=.weibo.com')
|
||||
headers.append('set-cookie', 'PASSPORT_ONLY=demo-passport; Path=/sso')
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
retcode: 20000000,
|
||||
data: {
|
||||
data: {
|
||||
act: 'goto',
|
||||
location: 'https://passport.weibo.com/sso/v2/goto?ticket=demo',
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers,
|
||||
},
|
||||
)
|
||||
}
|
||||
if (url.includes('/sso/v2/goto')) {
|
||||
return new Response(null, {
|
||||
@@ -122,7 +142,7 @@ test('微博二维码未扫码时保持等待扫码状态', async () => {
|
||||
test('微博发送短信验证码使用大陆 11 位手机号', async () => {
|
||||
const { fetchImpl, calls } = createFetchMock(defaultHandler)
|
||||
const connector = new WeiboNativeConnector({ fetchImpl })
|
||||
await connector.sendSmsCode('18900762182')
|
||||
await connector.sendSmsCode({ taskId: 1, mobile: '18900762182' })
|
||||
|
||||
const call = calls.find((item) => item.url.includes('/sso/v2/sms/send'))
|
||||
assert.ok(call)
|
||||
@@ -136,7 +156,11 @@ test('微博发送短信验证码使用大陆 11 位手机号', async () => {
|
||||
test('微博短信发送支持其他国家码并按 00 前缀编码', async () => {
|
||||
const { fetchImpl, calls } = createFetchMock(defaultHandler)
|
||||
const connector = new WeiboNativeConnector({ fetchImpl })
|
||||
await connector.sendSmsCode('61234567', '852')
|
||||
await connector.sendSmsCode({
|
||||
taskId: 1,
|
||||
mobile: '61234567',
|
||||
countryCode: '852',
|
||||
})
|
||||
|
||||
const call = calls.find((item) => item.url.includes('/sso/v2/sms/send'))
|
||||
assert.ok(call)
|
||||
@@ -173,7 +197,7 @@ test('微博短信登录会带上发码响应下发的 Cookie', async () => {
|
||||
return defaultHandler(url)
|
||||
})
|
||||
const connector = new WeiboNativeConnector({ fetchImpl })
|
||||
await connector.sendSmsCode('13800138000')
|
||||
await connector.sendSmsCode({ taskId: 1, mobile: '13800138000' })
|
||||
const { session } = await connector.loginWithSms({
|
||||
taskId: 1,
|
||||
orderNo: 'MOCKWB1',
|
||||
@@ -191,6 +215,7 @@ test('微博短信登录会带上发码响应下发的 Cookie', async () => {
|
||||
test('微博短信登录建立会话并可生成一次性注入票据', async () => {
|
||||
const { fetchImpl, calls } = createFetchMock(defaultHandler)
|
||||
const connector = new WeiboNativeConnector({ fetchImpl })
|
||||
await connector.sendSmsCode({ taskId: 1, mobile: '138 0013 8000' })
|
||||
const { sessionId, session } = await connector.loginWithSms({
|
||||
taskId: 1,
|
||||
orderNo: 'MOCKWB1',
|
||||
@@ -222,3 +247,83 @@ test('微博短信登录建立会话并可生成一次性注入票据', async ()
|
||||
|
||||
await assert.rejects(() => connector.consumeBrowserTicket(ticket.ticket), /票据不存在或已过期/)
|
||||
})
|
||||
|
||||
test('微博短信发送遇到 mfa_1 时返回易盾挑战,完成挑战后可用 mfa_id 重发', async () => {
|
||||
let sendCount = 0
|
||||
const { fetchImpl, calls } = createFetchMock((url, init) => {
|
||||
if (url.includes('/sso/v2/sms/send')) {
|
||||
sendCount += 1
|
||||
const form = new URLSearchParams(String(init.body))
|
||||
if (!form.get('mfa_id')) {
|
||||
return jsonResponse({
|
||||
retcode: 0,
|
||||
msg: 'need mfa',
|
||||
data: { act: 'mfa_1', mfa_id: 'mfa-demo' },
|
||||
})
|
||||
}
|
||||
return jsonResponse({ retcode: 20000000, msg: '', data: [] })
|
||||
}
|
||||
return defaultHandler(url)
|
||||
})
|
||||
const connector = new WeiboNativeConnector({ fetchImpl })
|
||||
|
||||
const first = await connector.sendSmsCode({ taskId: 1, mobile: '13800138000' })
|
||||
assert.equal(first.mfaRequired, true)
|
||||
assert.equal(first.mfaId, 'mfa-demo')
|
||||
assert.equal(first.captchaId, '7cda0ba2785647ada4d6e946ddb2d465')
|
||||
|
||||
const second = await connector.sendSmsCode({
|
||||
taskId: 1,
|
||||
mobile: '13800138000',
|
||||
mfaId: 'mfa-demo',
|
||||
})
|
||||
assert.equal(second.mfaRequired, false)
|
||||
assert.equal(sendCount, 2)
|
||||
const secondForm = new URLSearchParams(
|
||||
String(calls.filter((call) => call.url.includes('/sso/v2/sms/send'))[1]?.init.body),
|
||||
)
|
||||
assert.equal(secondForm.get('mfa_id'), 'mfa-demo')
|
||||
})
|
||||
|
||||
test('微博 CK 可按账号级凭证导出并从序列化 Cookie 恢复会话', async () => {
|
||||
const { fetchImpl } = createFetchMock(defaultHandler)
|
||||
const connector = new WeiboNativeConnector({ fetchImpl })
|
||||
await connector.sendSmsCode({ taskId: 1, mobile: '13800138000' })
|
||||
const { sessionId } = await connector.loginWithSms({
|
||||
taskId: 1,
|
||||
orderNo: 'MOCKWB1',
|
||||
mobile: '13800138000',
|
||||
code: '123456',
|
||||
})
|
||||
const credential = connector.exportSessionCredential(sessionId)
|
||||
assert.equal(credential.platformUid, '123456')
|
||||
assert.ok(credential.cookies.some((cookie) => cookie.name === 'SUB'))
|
||||
assert.ok(
|
||||
credential.cookies.some(
|
||||
(cookie) =>
|
||||
cookie.name === 'PASSPORT_ONLY' &&
|
||||
cookie.domain === 'passport.weibo.com' &&
|
||||
cookie.path === '/sso',
|
||||
),
|
||||
)
|
||||
|
||||
const ticket = await connector.createBrowserTicket({
|
||||
sessionId,
|
||||
targetUrl: 'https://weibo.com/',
|
||||
})
|
||||
const injection = await connector.consumeBrowserTicket(ticket.ticket)
|
||||
assert.equal(
|
||||
injection.cookies.some((cookie) => cookie.name === 'PASSPORT_ONLY'),
|
||||
false,
|
||||
)
|
||||
|
||||
const restored = await connector.restoreSessionFromCredential({
|
||||
taskId: 2,
|
||||
orderNo: 'MOCKWB2',
|
||||
platformUid: credential.platformUid,
|
||||
nickname: credential.nickname,
|
||||
cookies: credential.cookies,
|
||||
})
|
||||
assert.equal(restored.status, 'authenticated')
|
||||
assert.equal(restored.hasCredential, true)
|
||||
})
|
||||
|
||||
@@ -67,6 +67,8 @@ export type RuntimeConfig = {
|
||||
connectorBaseUrl: string
|
||||
connectorApiKey: string
|
||||
connectorTimeoutMs: number
|
||||
/** 半自动平台账号 CK 的落库加密密钥;生产必填,本地缺省回退后台会话密钥。 */
|
||||
credentialEncryptionKey?: string
|
||||
huya: {
|
||||
bindActId: number
|
||||
outerActId: string
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
CheckCircleOutlined,
|
||||
ChromeOutlined,
|
||||
CopyOutlined,
|
||||
KeyOutlined,
|
||||
ReloadOutlined,
|
||||
UserSwitchOutlined,
|
||||
} from '@ant-design/icons'
|
||||
@@ -34,12 +35,14 @@ import {
|
||||
claimAdminSemiAutoTask,
|
||||
completeAdminSemiAutoTask,
|
||||
createAdminSemiAutoBrowserSession,
|
||||
exportAdminSemiAutoCredential,
|
||||
fetchAdminSemiAutoTask,
|
||||
fetchAdminSemiAutoTasks,
|
||||
syncAdminSemiAutoTask,
|
||||
} from '@/services/admin/semi-auto'
|
||||
import type { SemiAutoTask } from '@/types/admin/semi-auto'
|
||||
import type { SemiAutoCredentialExport, SemiAutoTask } from '@/types/admin/semi-auto'
|
||||
import type { UploadedFile } from '@/types/worker-platform'
|
||||
import { hasAdminPermission } from '@/utils/admin-auth'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
const { Text } = Typography
|
||||
@@ -79,6 +82,7 @@ export default function AdminSemiAutoTasksPage() {
|
||||
const [detailTaskId, setDetailTaskId] = useState<number | null>(null)
|
||||
const [completeTask, setCompleteTask] = useState<SemiAutoTask | null>(null)
|
||||
const [completeFiles, setCompleteFiles] = useState<UploadedFile[]>([])
|
||||
const [credentialExport, setCredentialExport] = useState<SemiAutoCredentialExport | null>(null)
|
||||
const [completeForm] = Form.useForm<CompleteFormValues>()
|
||||
|
||||
const params = useMemo(
|
||||
@@ -172,6 +176,18 @@ export default function AdminSemiAutoTasksPage() {
|
||||
message.error(error instanceof Error ? error.message : '打开浏览器登录态失败'),
|
||||
})
|
||||
|
||||
const credentialExportMutation = useMutation({
|
||||
mutationFn: (taskId: number) => exportAdminSemiAutoCredential(taskId),
|
||||
onSuccess: (response) => {
|
||||
if (!response.data) {
|
||||
message.error('账号 CK 导出失败')
|
||||
return
|
||||
}
|
||||
setCredentialExport(response.data)
|
||||
},
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : '导出账号 CK 失败'),
|
||||
})
|
||||
|
||||
const completeMutation = useMutation({
|
||||
mutationFn: async (values: CompleteFormValues) => {
|
||||
if (!completeTask) throw new Error('任务不存在')
|
||||
@@ -223,6 +239,25 @@ export default function AdminSemiAutoTasksPage() {
|
||||
.catch(() => message.error('复制失败,请手动复制'))
|
||||
}
|
||||
|
||||
function copyCredentialText(text: string, label: string) {
|
||||
void navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => message.success(`${label}已复制`))
|
||||
.catch(() => message.error('复制失败,请手动选择文本'))
|
||||
}
|
||||
|
||||
function confirmExportCredential(task: SemiAutoTask) {
|
||||
modal.confirm({
|
||||
title: '导出账号 CK',
|
||||
content:
|
||||
'CK 等同于平台登录态。导出后会显示在当前页面,请勿发给无关人员;关闭弹窗后不会保留在页面状态中。',
|
||||
okText: '确认导出',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: () => credentialExportMutation.mutateAsync(task.id),
|
||||
})
|
||||
}
|
||||
|
||||
function openComplete(task: SemiAutoTask) {
|
||||
setCompleteTask(task)
|
||||
setCompleteFiles([])
|
||||
@@ -281,11 +316,21 @@ export default function AdminSemiAutoTasksPage() {
|
||||
{
|
||||
title: '客户登录',
|
||||
key: 'login',
|
||||
width: 150,
|
||||
width: 180,
|
||||
render: (_, task) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Tag>{task.displayStatus.label}</Tag>
|
||||
<Text type="secondary">{task.huyaUid ? `UID ${task.huyaUid}` : '未获取 UID'}</Text>
|
||||
<Text type="secondary">
|
||||
CK:
|
||||
{task.credentialStatus === 'valid'
|
||||
? '有效'
|
||||
: task.credentialStatus === 'invalid'
|
||||
? '失效'
|
||||
: task.credentialStatus === 'error'
|
||||
? '检测异常'
|
||||
: '未保存'}
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
@@ -487,6 +532,32 @@ export default function AdminSemiAutoTasksPage() {
|
||||
<Descriptions.Item label="平台昵称">
|
||||
{detail.task.platformNickname || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="账号 CK">
|
||||
<Space size={6}>
|
||||
<Tag
|
||||
color={
|
||||
detail.task.credentialStatus === 'valid'
|
||||
? 'green'
|
||||
: detail.task.credentialStatus === 'invalid'
|
||||
? 'red'
|
||||
: 'default'
|
||||
}
|
||||
>
|
||||
{detail.task.credentialStatus === 'valid'
|
||||
? '有效'
|
||||
: detail.task.credentialStatus === 'invalid'
|
||||
? '失效'
|
||||
: detail.task.credentialStatus === 'error'
|
||||
? '检测异常'
|
||||
: '未保存'}
|
||||
</Tag>
|
||||
<Text type="secondary">
|
||||
{detail.task.credentialSource
|
||||
? `${detail.task.credentialSource} · ${formatAdminDateTime(detail.task.credentialUpdatedAt)}`
|
||||
: '-'}
|
||||
</Text>
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={detail.task.platform === 'weibo' ? '微博昵称' : '游戏账号'}>
|
||||
{detail.task.bindGameAccount || '-'}
|
||||
</Descriptions.Item>
|
||||
@@ -536,6 +607,17 @@ export default function AdminSemiAutoTasksPage() {
|
||||
>
|
||||
注入浏览器
|
||||
</Button>
|
||||
{detail.task.credentialReady &&
|
||||
hasAdminPermission('semi_auto.credential.export') ? (
|
||||
<Button
|
||||
danger
|
||||
icon={<KeyOutlined />}
|
||||
loading={credentialExportMutation.isPending}
|
||||
onClick={() => confirmExportCredential(detail.task)}
|
||||
>
|
||||
导出 CK
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
</Card>
|
||||
<Card size="small" title="时间线">
|
||||
@@ -560,6 +642,65 @@ export default function AdminSemiAutoTasksPage() {
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
open={Boolean(credentialExport)}
|
||||
title={
|
||||
credentialExport
|
||||
? `账号 CK · ${credentialExport.platform === 'weibo' ? '微博' : '平台'} ${credentialExport.platformUid}`
|
||||
: '账号 CK'
|
||||
}
|
||||
width={760}
|
||||
footer={null}
|
||||
onCancel={() => setCredentialExport(null)}
|
||||
>
|
||||
{credentialExport ? (
|
||||
<Space direction="vertical" size={14} style={{ width: '100%' }}>
|
||||
<Text type="danger">
|
||||
以下内容包含平台登录态,仅用于本次受控操作。关闭弹窗后请勿在聊天、日志或工单中留存。
|
||||
</Text>
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Text strong>{credentialExport.targetUrl || 'Cookie Header'}</Text>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => copyCredentialText(credentialExport.cookieHeader, 'Cookie Header')}
|
||||
>
|
||||
复制 Header
|
||||
</Button>
|
||||
</Space>
|
||||
<Input.TextArea
|
||||
value={credentialExport.cookieHeader}
|
||||
readOnly
|
||||
autoSize={{ minRows: 3, maxRows: 6 }}
|
||||
/>
|
||||
</Space>
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Text strong>完整域名 Cookie</Text>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() =>
|
||||
copyCredentialText(
|
||||
JSON.stringify(credentialExport.cookies, null, 2),
|
||||
'完整 Cookie JSON',
|
||||
)
|
||||
}
|
||||
>
|
||||
复制 JSON
|
||||
</Button>
|
||||
</Space>
|
||||
<Input.TextArea
|
||||
value={JSON.stringify(credentialExport.cookies, null, 2)}
|
||||
readOnly
|
||||
autoSize={{ minRows: 10, maxRows: 18 }}
|
||||
/>
|
||||
</Space>
|
||||
</Space>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={Boolean(completeTask)}
|
||||
title="确认完成半自动履约"
|
||||
|
||||
@@ -47,6 +47,7 @@ import type {
|
||||
CustomerSemiAutoTask,
|
||||
} from '@/types/customer-fulfillment'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import { runWeiboYidunChallenge } from '@/utils/weibo-yidun'
|
||||
|
||||
const { Text, Title, Paragraph } = Typography
|
||||
|
||||
@@ -301,17 +302,28 @@ export default function CustomerFulfillmentPage() {
|
||||
}
|
||||
setSmsSending(true)
|
||||
try {
|
||||
const response = await sendCustomerWeiboSmsCode(orderNo, selectedTask.id, phone, countryCode)
|
||||
let response = await sendCustomerWeiboSmsCode(orderNo, selectedTask.id, phone, countryCode)
|
||||
if (response.data.mfaRequired) {
|
||||
setSmsMfaRequired(true)
|
||||
if (!response.data.mfaId || !response.data.captchaId) {
|
||||
throw new Error('微博返回的安全验证参数不完整')
|
||||
}
|
||||
await runWeiboYidunChallenge(response.data.mfaId, response.data.captchaId)
|
||||
response = await sendCustomerWeiboSmsCode(
|
||||
orderNo,
|
||||
selectedTask.id,
|
||||
phone,
|
||||
countryCode,
|
||||
response.data.mfaId,
|
||||
)
|
||||
if (response.data.mfaRequired) {
|
||||
throw new Error('微博安全验证未通过,请重新获取验证码')
|
||||
}
|
||||
}
|
||||
message.success(response.data.message || '验证码已发送,请注意查收')
|
||||
setSmsMfaRequired(false)
|
||||
setSmsCountdown(60)
|
||||
} catch (error) {
|
||||
const errorCode = (error as { errorCode?: string } | null)?.errorCode || ''
|
||||
if (errorCode === 'weibo_sms_mfa_required') {
|
||||
setSmsMfaRequired(true)
|
||||
message.warning('微博要求二次验证,请改用扫码登录')
|
||||
return
|
||||
}
|
||||
message.error(error instanceof Error ? error.message : '发送微博验证码失败')
|
||||
} finally {
|
||||
setSmsSending(false)
|
||||
@@ -559,15 +571,10 @@ export default function CustomerFulfillmentPage() {
|
||||
/>
|
||||
{smsMfaRequired ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
type="info"
|
||||
showIcon
|
||||
message="微博要求二次验证"
|
||||
description="当前网络环境触发了微博人机验证,短信登录无法自动完成,请改用扫码登录。"
|
||||
action={
|
||||
<Button size="small" onClick={() => setLoginMode('qr')}>
|
||||
去扫码
|
||||
</Button>
|
||||
}
|
||||
message="正在等待微博安全验证"
|
||||
description="请在安全验证弹窗中完成验证,验证通过后会继续发送短信验证码。"
|
||||
/>
|
||||
) : null}
|
||||
<Space.Compact style={{ width: '100%', maxWidth: 520 }}>
|
||||
@@ -627,7 +634,7 @@ export default function CustomerFulfillmentPage() {
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
<Text type="secondary">
|
||||
如微博要求二次验证导致登录失败,请改用扫码登录。
|
||||
如安全验证弹窗未出现,请检查浏览器是否拦截脚本。
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { apiGet, apiPost } from '@/lib/http'
|
||||
import type { SemiAutoTaskDetail, SemiAutoTaskListResult } from '@/types/admin/semi-auto'
|
||||
import type {
|
||||
SemiAutoCredentialExport,
|
||||
SemiAutoTaskDetail,
|
||||
SemiAutoTaskListResult,
|
||||
} from '@/types/admin/semi-auto'
|
||||
|
||||
export function fetchAdminSemiAutoTasks(params: Record<string, unknown> = {}) {
|
||||
return apiGet<SemiAutoTaskListResult>('/api/v1/admin/semi-auto/tasks', params)
|
||||
@@ -9,6 +13,13 @@ export function fetchAdminSemiAutoTask(taskId: number) {
|
||||
return apiGet<SemiAutoTaskDetail>(`/api/v1/admin/semi-auto/tasks/${taskId}`)
|
||||
}
|
||||
|
||||
export function exportAdminSemiAutoCredential(taskId: number) {
|
||||
return apiPost<SemiAutoCredentialExport>(
|
||||
`/api/v1/admin/semi-auto/tasks/${taskId}/credential/export`,
|
||||
{},
|
||||
)
|
||||
}
|
||||
|
||||
export function claimAdminSemiAutoTask(taskId: number) {
|
||||
return apiPost<SemiAutoTaskDetail>(`/api/v1/admin/semi-auto/tasks/${taskId}/claim`, {})
|
||||
}
|
||||
|
||||
@@ -71,17 +71,25 @@ export function sendCustomerWeiboSmsCode(
|
||||
taskId: number,
|
||||
mobile: string,
|
||||
countryCode: string,
|
||||
mfaId = '',
|
||||
) {
|
||||
return apiPost<{ ok: boolean; message: string }>(
|
||||
`/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/weibo/sms/send`,
|
||||
{ mobile, countryCode },
|
||||
)
|
||||
return apiPost<{
|
||||
ok: boolean
|
||||
message: string
|
||||
mfaRequired: boolean
|
||||
mfaId?: string
|
||||
captchaId?: string
|
||||
}>(`/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/weibo/sms/send`, {
|
||||
mobile,
|
||||
countryCode,
|
||||
mfaId,
|
||||
})
|
||||
}
|
||||
|
||||
export function loginCustomerWeiboSms(
|
||||
orderNo: string,
|
||||
taskId: number,
|
||||
input: { mobile: string; code: string; countryCode: string },
|
||||
input: { mobile: string; code: string; countryCode: string; rid?: string },
|
||||
) {
|
||||
return apiPost<{ task: CustomerSemiAutoTask; login: CustomerLoginInfo }>(
|
||||
`/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/weibo/sms/login`,
|
||||
|
||||
@@ -18,6 +18,10 @@ export type SemiAutoTask = {
|
||||
connectorMode: 'embedded' | 'external' | 'mock'
|
||||
displayStatus: SemiAutoDisplayStatus
|
||||
connectorReady: boolean
|
||||
credentialReady: boolean
|
||||
credentialStatus: 'unknown' | 'valid' | 'invalid' | 'error'
|
||||
credentialSource: string
|
||||
credentialUpdatedAt: string | null
|
||||
huyaUid: string
|
||||
platformNickname: string
|
||||
bindGameAccount: string
|
||||
@@ -73,3 +77,25 @@ export type SemiAutoTaskDetail = {
|
||||
task: SemiAutoTask
|
||||
events: SemiAutoTaskEvent[]
|
||||
}
|
||||
|
||||
export type SemiAutoCredentialExport = {
|
||||
credentialId: number
|
||||
platform: SemiAutoPlatform
|
||||
platformUid: string
|
||||
nickname: string
|
||||
source: string
|
||||
status: 'unknown' | 'valid' | 'invalid' | 'error'
|
||||
updatedAt: string
|
||||
targetUrl: string
|
||||
cookieHeader: string
|
||||
cookies: Array<{
|
||||
name: string
|
||||
value: string
|
||||
domain: string
|
||||
path: string
|
||||
secure: boolean
|
||||
httpOnly: boolean
|
||||
hostOnly?: boolean
|
||||
expiresAt: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
type WeiboYidunCallback = (params: unknown, message: string, success: boolean) => void
|
||||
|
||||
type WeiboYidunWindow = Window & {
|
||||
ydInit?: (
|
||||
options: {
|
||||
geetestKey: string
|
||||
captchaId: string
|
||||
product: 'popup'
|
||||
lang: string
|
||||
},
|
||||
callback: WeiboYidunCallback,
|
||||
) => void
|
||||
}
|
||||
|
||||
const YIDUN_SDK_URL = 'https://passport.sinaimg.cn/js/yidun/v1/yidunsdk.min.js'
|
||||
let sdkPromise: Promise<void> | null = null
|
||||
|
||||
export function loadWeiboYidunSdk(): Promise<void> {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||
return Promise.reject(new Error('当前环境不支持微博安全验证'))
|
||||
}
|
||||
if ((window as WeiboYidunWindow).ydInit) return Promise.resolve()
|
||||
if (sdkPromise) return sdkPromise
|
||||
|
||||
sdkPromise = new Promise<void>((resolve, reject) => {
|
||||
const existing = document.querySelector<HTMLScriptElement>(`script[src="${YIDUN_SDK_URL}"]`)
|
||||
const script = existing || document.createElement('script')
|
||||
script.src = YIDUN_SDK_URL
|
||||
script.async = true
|
||||
script.onload = () => {
|
||||
if ((window as WeiboYidunWindow).ydInit) {
|
||||
resolve()
|
||||
} else {
|
||||
sdkPromise = null
|
||||
reject(new Error('微博安全验证组件初始化失败'))
|
||||
}
|
||||
}
|
||||
script.onerror = () => {
|
||||
sdkPromise = null
|
||||
reject(new Error('微博安全验证组件加载失败,请检查网络'))
|
||||
}
|
||||
if (!existing) document.head.appendChild(script)
|
||||
})
|
||||
return sdkPromise
|
||||
}
|
||||
|
||||
export async function runWeiboYidunChallenge(mfaId: string, captchaId: string): Promise<void> {
|
||||
await loadWeiboYidunSdk()
|
||||
const ydInit = (window as WeiboYidunWindow).ydInit
|
||||
if (!ydInit) throw new Error('微博安全验证组件不可用')
|
||||
if (!mfaId || !captchaId) throw new Error('微博安全验证参数无效')
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ydInit(
|
||||
{
|
||||
geetestKey: mfaId,
|
||||
captchaId,
|
||||
product: 'popup',
|
||||
lang: 'zh-CN',
|
||||
},
|
||||
(_params, message, success) => {
|
||||
if (success) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
reject(new Error(message === 'close' ? '已取消安全验证' : message || '微博安全验证失败'))
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user