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

This commit is contained in:
yml
2026-11-13 22:52:51 +08:00
parent 90173e3bd7
commit 654c087c1b
32 changed files with 1701 additions and 223 deletions
@@ -2,6 +2,7 @@ import {
CheckCircleOutlined,
ChromeOutlined,
CopyOutlined,
KeyOutlined,
ReloadOutlined,
UserSwitchOutlined,
} from '@ant-design/icons'
@@ -34,12 +35,14 @@ import {
claimAdminSemiAutoTask,
completeAdminSemiAutoTask,
createAdminSemiAutoBrowserSession,
exportAdminSemiAutoCredential,
fetchAdminSemiAutoTask,
fetchAdminSemiAutoTasks,
syncAdminSemiAutoTask,
} from '@/services/admin/semi-auto'
import type { SemiAutoTask } from '@/types/admin/semi-auto'
import type { SemiAutoCredentialExport, SemiAutoTask } from '@/types/admin/semi-auto'
import type { UploadedFile } from '@/types/worker-platform'
import { hasAdminPermission } from '@/utils/admin-auth'
import { formatAdminDateTime } from '@/utils/admin-time'
const { Text } = Typography
@@ -79,6 +82,7 @@ export default function AdminSemiAutoTasksPage() {
const [detailTaskId, setDetailTaskId] = useState<number | null>(null)
const [completeTask, setCompleteTask] = useState<SemiAutoTask | null>(null)
const [completeFiles, setCompleteFiles] = useState<UploadedFile[]>([])
const [credentialExport, setCredentialExport] = useState<SemiAutoCredentialExport | null>(null)
const [completeForm] = Form.useForm<CompleteFormValues>()
const params = useMemo(
@@ -172,6 +176,18 @@ export default function AdminSemiAutoTasksPage() {
message.error(error instanceof Error ? error.message : '打开浏览器登录态失败'),
})
const credentialExportMutation = useMutation({
mutationFn: (taskId: number) => exportAdminSemiAutoCredential(taskId),
onSuccess: (response) => {
if (!response.data) {
message.error('账号 CK 导出失败')
return
}
setCredentialExport(response.data)
},
onError: (error) => message.error(error instanceof Error ? error.message : '导出账号 CK 失败'),
})
const completeMutation = useMutation({
mutationFn: async (values: CompleteFormValues) => {
if (!completeTask) throw new Error('任务不存在')
@@ -223,6 +239,25 @@ export default function AdminSemiAutoTasksPage() {
.catch(() => message.error('复制失败,请手动复制'))
}
function copyCredentialText(text: string, label: string) {
void navigator.clipboard
.writeText(text)
.then(() => message.success(`${label}已复制`))
.catch(() => message.error('复制失败,请手动选择文本'))
}
function confirmExportCredential(task: SemiAutoTask) {
modal.confirm({
title: '导出账号 CK',
content:
'CK 等同于平台登录态。导出后会显示在当前页面,请勿发给无关人员;关闭弹窗后不会保留在页面状态中。',
okText: '确认导出',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: () => credentialExportMutation.mutateAsync(task.id),
})
}
function openComplete(task: SemiAutoTask) {
setCompleteTask(task)
setCompleteFiles([])
@@ -281,11 +316,21 @@ export default function AdminSemiAutoTasksPage() {
{
title: '客户登录',
key: 'login',
width: 150,
width: 180,
render: (_, task) => (
<Space direction="vertical" size={2}>
<Tag>{task.displayStatus.label}</Tag>
<Text type="secondary">{task.huyaUid ? `UID ${task.huyaUid}` : '未获取 UID'}</Text>
<Text type="secondary">
CK:
{task.credentialStatus === 'valid'
? '有效'
: task.credentialStatus === 'invalid'
? '失效'
: task.credentialStatus === 'error'
? '检测异常'
: '未保存'}
</Text>
</Space>
),
},
@@ -487,6 +532,32 @@ export default function AdminSemiAutoTasksPage() {
<Descriptions.Item label="平台昵称">
{detail.task.platformNickname || '-'}
</Descriptions.Item>
<Descriptions.Item label="账号 CK">
<Space size={6}>
<Tag
color={
detail.task.credentialStatus === 'valid'
? 'green'
: detail.task.credentialStatus === 'invalid'
? 'red'
: 'default'
}
>
{detail.task.credentialStatus === 'valid'
? '有效'
: detail.task.credentialStatus === 'invalid'
? '失效'
: detail.task.credentialStatus === 'error'
? '检测异常'
: '未保存'}
</Tag>
<Text type="secondary">
{detail.task.credentialSource
? `${detail.task.credentialSource} · ${formatAdminDateTime(detail.task.credentialUpdatedAt)}`
: '-'}
</Text>
</Space>
</Descriptions.Item>
<Descriptions.Item label={detail.task.platform === 'weibo' ? '微博昵称' : '游戏账号'}>
{detail.task.bindGameAccount || '-'}
</Descriptions.Item>
@@ -536,6 +607,17 @@ export default function AdminSemiAutoTasksPage() {
>
注入浏览器
</Button>
{detail.task.credentialReady &&
hasAdminPermission('semi_auto.credential.export') ? (
<Button
danger
icon={<KeyOutlined />}
loading={credentialExportMutation.isPending}
onClick={() => confirmExportCredential(detail.task)}
>
导出 CK
</Button>
) : null}
</Space>
</Card>
<Card size="small" title="时间线">
@@ -560,6 +642,65 @@ export default function AdminSemiAutoTasksPage() {
)}
</Drawer>
<Modal
open={Boolean(credentialExport)}
title={
credentialExport
? `账号 CK · ${credentialExport.platform === 'weibo' ? '微博' : '平台'} ${credentialExport.platformUid}`
: '账号 CK'
}
width={760}
footer={null}
onCancel={() => setCredentialExport(null)}
>
{credentialExport ? (
<Space direction="vertical" size={14} style={{ width: '100%' }}>
<Text type="danger">
以下内容包含平台登录态,仅用于本次受控操作。关闭弹窗后请勿在聊天、日志或工单中留存。
</Text>
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Text strong>{credentialExport.targetUrl || 'Cookie Header'}</Text>
<Button
size="small"
icon={<CopyOutlined />}
onClick={() => copyCredentialText(credentialExport.cookieHeader, 'Cookie Header')}
>
复制 Header
</Button>
</Space>
<Input.TextArea
value={credentialExport.cookieHeader}
readOnly
autoSize={{ minRows: 3, maxRows: 6 }}
/>
</Space>
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Text strong>完整域名 Cookie</Text>
<Button
size="small"
icon={<CopyOutlined />}
onClick={() =>
copyCredentialText(
JSON.stringify(credentialExport.cookies, null, 2),
'完整 Cookie JSON',
)
}
>
复制 JSON
</Button>
</Space>
<Input.TextArea
value={JSON.stringify(credentialExport.cookies, null, 2)}
readOnly
autoSize={{ minRows: 10, maxRows: 18 }}
/>
</Space>
</Space>
) : null}
</Modal>
<Modal
open={Boolean(completeTask)}
title="确认完成半自动履约"
@@ -47,6 +47,7 @@ import type {
CustomerSemiAutoTask,
} from '@/types/customer-fulfillment'
import { formatAdminDateTime } from '@/utils/admin-time'
import { runWeiboYidunChallenge } from '@/utils/weibo-yidun'
const { Text, Title, Paragraph } = Typography
@@ -301,17 +302,28 @@ export default function CustomerFulfillmentPage() {
}
setSmsSending(true)
try {
const response = await sendCustomerWeiboSmsCode(orderNo, selectedTask.id, phone, countryCode)
let response = await sendCustomerWeiboSmsCode(orderNo, selectedTask.id, phone, countryCode)
if (response.data.mfaRequired) {
setSmsMfaRequired(true)
if (!response.data.mfaId || !response.data.captchaId) {
throw new Error('微博返回的安全验证参数不完整')
}
await runWeiboYidunChallenge(response.data.mfaId, response.data.captchaId)
response = await sendCustomerWeiboSmsCode(
orderNo,
selectedTask.id,
phone,
countryCode,
response.data.mfaId,
)
if (response.data.mfaRequired) {
throw new Error('微博安全验证未通过,请重新获取验证码')
}
}
message.success(response.data.message || '验证码已发送,请注意查收')
setSmsMfaRequired(false)
setSmsCountdown(60)
} catch (error) {
const errorCode = (error as { errorCode?: string } | null)?.errorCode || ''
if (errorCode === 'weibo_sms_mfa_required') {
setSmsMfaRequired(true)
message.warning('微博要求二次验证,请改用扫码登录')
return
}
message.error(error instanceof Error ? error.message : '发送微博验证码失败')
} finally {
setSmsSending(false)
@@ -559,15 +571,10 @@ export default function CustomerFulfillmentPage() {
/>
{smsMfaRequired ? (
<Alert
type="warning"
type="info"
showIcon
message="微博要求二次验证"
description="当前网络环境触发了微博人机验证,短信登录无法自动完成,请改用扫码登录。"
action={
<Button size="small" onClick={() => setLoginMode('qr')}>
去扫码
</Button>
}
message="正在等待微博安全验证"
description="请在安全验证弹窗中完成验证,验证通过后会继续发送短信验证码。"
/>
) : null}
<Space.Compact style={{ width: '100%', maxWidth: 520 }}>
@@ -627,7 +634,7 @@ export default function CustomerFulfillmentPage() {
</Button>
</Space.Compact>
<Text type="secondary">
如微博要求二次验证导致登录失败,请改用扫码登录。
如安全验证弹窗未出现,请检查浏览器是否拦截脚本。
</Text>
</Space>
),
+12 -1
View File
@@ -1,5 +1,9 @@
import { apiGet, apiPost } from '@/lib/http'
import type { SemiAutoTaskDetail, SemiAutoTaskListResult } from '@/types/admin/semi-auto'
import type {
SemiAutoCredentialExport,
SemiAutoTaskDetail,
SemiAutoTaskListResult,
} from '@/types/admin/semi-auto'
export function fetchAdminSemiAutoTasks(params: Record<string, unknown> = {}) {
return apiGet<SemiAutoTaskListResult>('/api/v1/admin/semi-auto/tasks', params)
@@ -9,6 +13,13 @@ export function fetchAdminSemiAutoTask(taskId: number) {
return apiGet<SemiAutoTaskDetail>(`/api/v1/admin/semi-auto/tasks/${taskId}`)
}
export function exportAdminSemiAutoCredential(taskId: number) {
return apiPost<SemiAutoCredentialExport>(
`/api/v1/admin/semi-auto/tasks/${taskId}/credential/export`,
{},
)
}
export function claimAdminSemiAutoTask(taskId: number) {
return apiPost<SemiAutoTaskDetail>(`/api/v1/admin/semi-auto/tasks/${taskId}/claim`, {})
}
@@ -71,17 +71,25 @@ export function sendCustomerWeiboSmsCode(
taskId: number,
mobile: string,
countryCode: string,
mfaId = '',
) {
return apiPost<{ ok: boolean; message: string }>(
`/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/weibo/sms/send`,
{ mobile, countryCode },
)
return apiPost<{
ok: boolean
message: string
mfaRequired: boolean
mfaId?: string
captchaId?: string
}>(`/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/weibo/sms/send`, {
mobile,
countryCode,
mfaId,
})
}
export function loginCustomerWeiboSms(
orderNo: string,
taskId: number,
input: { mobile: string; code: string; countryCode: string },
input: { mobile: string; code: string; countryCode: string; rid?: string },
) {
return apiPost<{ task: CustomerSemiAutoTask; login: CustomerLoginInfo }>(
`/api/v1/customer-fulfillment/${encodeURIComponent(orderNo)}/tasks/${taskId}/weibo/sms/login`,
@@ -18,6 +18,10 @@ export type SemiAutoTask = {
connectorMode: 'embedded' | 'external' | 'mock'
displayStatus: SemiAutoDisplayStatus
connectorReady: boolean
credentialReady: boolean
credentialStatus: 'unknown' | 'valid' | 'invalid' | 'error'
credentialSource: string
credentialUpdatedAt: string | null
huyaUid: string
platformNickname: string
bindGameAccount: string
@@ -73,3 +77,25 @@ export type SemiAutoTaskDetail = {
task: SemiAutoTask
events: SemiAutoTaskEvent[]
}
export type SemiAutoCredentialExport = {
credentialId: number
platform: SemiAutoPlatform
platformUid: string
nickname: string
source: string
status: 'unknown' | 'valid' | 'invalid' | 'error'
updatedAt: string
targetUrl: string
cookieHeader: string
cookies: Array<{
name: string
value: string
domain: string
path: string
secure: boolean
httpOnly: boolean
hostOnly?: boolean
expiresAt: string | null
}>
}
+70
View File
@@ -0,0 +1,70 @@
type WeiboYidunCallback = (params: unknown, message: string, success: boolean) => void
type WeiboYidunWindow = Window & {
ydInit?: (
options: {
geetestKey: string
captchaId: string
product: 'popup'
lang: string
},
callback: WeiboYidunCallback,
) => void
}
const YIDUN_SDK_URL = 'https://passport.sinaimg.cn/js/yidun/v1/yidunsdk.min.js'
let sdkPromise: Promise<void> | null = null
export function loadWeiboYidunSdk(): Promise<void> {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return Promise.reject(new Error('当前环境不支持微博安全验证'))
}
if ((window as WeiboYidunWindow).ydInit) return Promise.resolve()
if (sdkPromise) return sdkPromise
sdkPromise = new Promise<void>((resolve, reject) => {
const existing = document.querySelector<HTMLScriptElement>(`script[src="${YIDUN_SDK_URL}"]`)
const script = existing || document.createElement('script')
script.src = YIDUN_SDK_URL
script.async = true
script.onload = () => {
if ((window as WeiboYidunWindow).ydInit) {
resolve()
} else {
sdkPromise = null
reject(new Error('微博安全验证组件初始化失败'))
}
}
script.onerror = () => {
sdkPromise = null
reject(new Error('微博安全验证组件加载失败,请检查网络'))
}
if (!existing) document.head.appendChild(script)
})
return sdkPromise
}
export async function runWeiboYidunChallenge(mfaId: string, captchaId: string): Promise<void> {
await loadWeiboYidunSdk()
const ydInit = (window as WeiboYidunWindow).ydInit
if (!ydInit) throw new Error('微博安全验证组件不可用')
if (!mfaId || !captchaId) throw new Error('微博安全验证参数无效')
await new Promise<void>((resolve, reject) => {
ydInit(
{
geetestKey: mfaId,
captchaId,
product: 'popup',
lang: 'zh-CN',
},
(_params, message, success) => {
if (success) {
resolve()
return
}
reject(new Error(message === 'close' ? '已取消安全验证' : message || '微博安全验证失败'))
},
)
})
}