完善微博半自动凭证与本地开发环境
This commit is contained in:
@@ -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