完善微博半自动凭证与本地开发环境
This commit is contained in:
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user