完善虎牙短信登录与图形安全验证

This commit is contained in:
yml
2026-11-14 02:11:23 +08:00
parent 9d738becd3
commit 2c2dc26a93
17 changed files with 2914 additions and 148 deletions
@@ -12,17 +12,23 @@ import { useParams } from 'react-router'
import {
fetchCustomerFulfillment,
loginCustomerHuyaSms,
loginCustomerWeiboSms,
markCustomerFulfillmentOpened,
pollCustomerLogin,
pollCustomerWeiboQrLogin,
sendCustomerHuyaSmsCode,
sendCustomerWeiboSmsCode,
startCustomerLogin,
startCustomerWeiboQrLogin,
verifyCustomerHuyaSmsChallenge,
type CustomerHuyaChallengeSolution,
type CustomerHuyaSmsChallenge,
} from '@/services/customer-fulfillment'
import type { CustomerLoginInfo, CustomerSemiAutoTask } from '@/types/customer-fulfillment'
import { formatAdminDateTime } from '@/utils/admin-time'
import { runWeiboYidunChallenge } from '@/utils/weibo-yidun'
import HuyaChallengeModal from './HuyaChallengeModal'
const POLL_INTERVAL_MS = 2000
@@ -43,12 +49,15 @@ export default function CustomerFulfillmentPage() {
const [smsLogging, setSmsLogging] = useState(false)
const [smsCountdown, setSmsCountdown] = useState(0)
const [smsMfaRequired, setSmsMfaRequired] = useState(false)
const [huyaChallenge, setHuyaChallenge] = useState<CustomerHuyaSmsChallenge | null>(null)
const [huyaChallengeLoading, setHuyaChallengeLoading] = useState(false)
const selectedTask = useMemo(
() => tasks.find((task) => task.id === selectedTaskId) || tasks[0] || null,
[selectedTaskId, tasks],
)
const isWeibo = selectedTask?.platform === 'weibo'
const isHuya = selectedTask?.platform === 'huya'
const load = useCallback(
async (silent = false) => {
@@ -83,6 +92,16 @@ export default function CustomerFulfillmentPage() {
void load()
}, [load])
useEffect(() => {
if (!selectedTask) return
setLoginMode('sms')
setMobile('')
setSmsCode('')
setSmsMfaRequired(false)
setSmsCountdown(0)
setHuyaChallenge(null)
}, [selectedTask?.id])
useEffect(() => {
if (!selectedTask) return
void markCustomerFulfillmentOpened(orderNo, selectedTask.id).catch(() => undefined)
@@ -109,6 +128,7 @@ export default function CustomerFulfillmentPage() {
setSmsCode('')
setSmsMfaRequired(false)
setSmsCountdown(0)
setHuyaChallenge(null)
}, [selectedTask?.id, selectedTask?.loginStatus])
useEffect(() => {
@@ -177,66 +197,83 @@ export default function CustomerFulfillmentPage() {
}
}
async function handleSendWeiboSms() {
async function handleSendSms() {
if (!selectedTask) return
const phone = mobile.trim()
if (!isValidWeiboMobile(phone)) {
if (!isValidMainlandMobile(phone)) {
message.warning('请输入正确的手机号')
return
}
setSmsSending(true)
try {
let response = await sendCustomerWeiboSmsCode(orderNo, selectedTask.id, phone, '86')
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,
'86',
response.data.mfaId,
)
if (selectedTask.platform === 'weibo') {
let response = await sendCustomerWeiboSmsCode(orderNo, selectedTask.id, phone, '86')
if (response.data.mfaRequired) {
throw new Error('微博安全验证未通过,请重新获取验证码')
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,
'86',
response.data.mfaId,
)
if (response.data.mfaRequired) {
throw new Error('微博安全验证未通过,请重新获取验证码')
}
}
message.success(response.data.message || '验证码已发送,请注意查收')
} else {
const response = await sendCustomerHuyaSmsCode(orderNo, selectedTask.id, phone)
if (response.data.challenge) {
setHuyaChallenge(response.data.challenge)
message.info('请先完成滑块安全验证')
return
}
message.success(response.data.message || '验证码已发送,请注意查收')
}
message.success(response.data.message || '验证码已发送,请注意查收')
setSmsMfaRequired(false)
setSmsCountdown(60)
} catch (error) {
message.error(error instanceof Error ? error.message : '发送微博验证码失败')
message.error(error instanceof Error ? error.message : '发送验证码失败')
} finally {
setSmsSending(false)
}
}
async function handleLoginWeiboSms() {
async function handleLoginSms() {
if (!selectedTask) return
const phone = mobile.trim()
if (!isValidWeiboMobile(phone)) {
if (!isValidMainlandMobile(phone)) {
message.warning('请输入正确的手机号')
return
}
if (!smsCode.trim()) {
message.warning('请输入微博短信验证码')
message.warning('请输入短信验证码')
return
}
setSmsLogging(true)
try {
const response = await loginCustomerWeiboSms(orderNo, selectedTask.id, {
mobile: phone,
code: smsCode.trim(),
countryCode: '86',
})
const response =
selectedTask.platform === 'weibo'
? await loginCustomerWeiboSms(orderNo, selectedTask.id, {
mobile: phone,
code: smsCode.trim(),
countryCode: '86',
})
: await loginCustomerHuyaSms(orderNo, selectedTask.id, {
mobile: phone,
code: smsCode.trim(),
})
setTasks((current) =>
current.map((task) => (task.id === response.data.task.id ? response.data.task : task)),
)
setLoginInfo(response.data.login)
message.success('微博登录成功,等待客服处理')
setHuyaChallenge(null)
message.success(`${isWeibo ? '微博' : '虎牙'}登录成功,等待客服处理`)
} catch (error) {
const errorCode = (error as { errorCode?: string } | null)?.errorCode || ''
if (errorCode === 'weibo_sms_mfa_required') {
@@ -244,12 +281,32 @@ export default function CustomerFulfillmentPage() {
message.warning('微博要求二次验证,请改用扫码登录')
return
}
message.error(error instanceof Error ? error.message : '微博短信登录失败')
message.error(error instanceof Error ? error.message : '短信登录失败')
} finally {
setSmsLogging(false)
}
}
async function handleVerifyHuyaChallenge(solution: CustomerHuyaChallengeSolution) {
if (!selectedTask || !huyaChallenge) return
setHuyaChallengeLoading(true)
try {
const response = await verifyCustomerHuyaSmsChallenge(orderNo, selectedTask.id, solution)
if (response.data.challenge) {
setHuyaChallenge(response.data.challenge)
message.warning('安全验证未通过,请重新完成')
return
}
setHuyaChallenge(null)
setSmsCountdown(60)
message.success(response.data.message || '验证码已发送,请注意查收')
} catch (error) {
message.error(error instanceof Error ? error.message : '虎牙安全验证失败')
} finally {
setHuyaChallengeLoading(false)
}
}
const config = selectedTask?.semiAutoConfig || {}
const displayTitle = isWeibo ? '微博账号登录授权' : '虎牙账号登录授权'
const loginQrVisible =
@@ -332,96 +389,88 @@ export default function CustomerFulfillmentPage() {
selectedTask?.loginStatus === 'failed' ||
selectedTask?.loginStatus === 'expired' ? (
<section className="customer-auth-section">
{isWeibo ? (
<Tabs
className="customer-auth-tabs"
activeKey={loginMode}
onChange={(key) => setLoginMode(key as 'sms' | 'qr')}
items={[
{
key: 'sms',
label: '手机号登录',
children: (
<div className="customer-auth-form">
<div className="customer-auth-field customer-auth-phone-field">
<span className="customer-auth-country-prefix">+86</span>
<Input
value={mobile}
onChange={(event) => setMobile(event.target.value)}
placeholder="请输入微博绑定手机号"
maxLength={11}
inputMode="numeric"
/>
<Button
className="customer-auth-send-code"
type="primary"
loading={smsSending}
disabled={smsCountdown > 0}
onClick={() => void handleSendWeiboSms()}
>
{smsCountdown > 0 ? `${smsCountdown}s` : '发送验证码'}
</Button>
</div>
{smsMfaRequired ? (
<div className="customer-auth-note">
请在安全验证弹窗中完成验证,通过后会继续发送短信验证码。
</div>
) : null}
<div className="customer-auth-field customer-auth-code-field">
<Input
value={smsCode}
onChange={(event) => setSmsCode(event.target.value)}
placeholder="请输入短信验证码"
maxLength={8}
prefix={<SafetyCertificateOutlined />}
onPressEnter={() => void handleLoginWeiboSms()}
/>
<Button
type="primary"
className="customer-auth-code-submit"
loading={smsLogging}
onClick={() => void handleLoginWeiboSms()}
>
登录微博
</Button>
<Tabs
className="customer-auth-tabs"
activeKey={loginMode}
onChange={(key) => setLoginMode(key as 'sms' | 'qr')}
items={[
{
key: 'sms',
label: '手机号登录',
children: (
<div className="customer-auth-form">
<div className="customer-auth-field customer-auth-phone-field">
<span className="customer-auth-country-prefix">+86</span>
<Input
value={mobile}
onChange={(event) => setMobile(event.target.value)}
placeholder={`请输入${isHuya ? '虎牙' : '微博'}绑定手机号`}
maxLength={11}
inputMode="numeric"
/>
<Button
className="customer-auth-send-code"
type="primary"
loading={smsSending}
disabled={smsCountdown > 0}
onClick={() => void handleSendSms()}
>
{smsCountdown > 0 ? `${smsCountdown}s` : '发送验证码'}
</Button>
</div>
{isWeibo && smsMfaRequired ? (
<div className="customer-auth-note">
请在安全验证弹窗中完成验证,通过后会继续发送短信验证码。
</div>
) : null}
<div className="customer-auth-field customer-auth-code-field">
<Input
value={smsCode}
onChange={(event) => setSmsCode(event.target.value)}
placeholder="请输入短信验证码"
maxLength={8}
prefix={<SafetyCertificateOutlined />}
onPressEnter={() => void handleLoginSms()}
/>
<Button
type="primary"
className="customer-auth-code-submit"
loading={smsLogging}
onClick={() => void handleLoginSms()}
>
登录{isHuya ? '虎牙' : '微博'}
</Button>
</div>
{isWeibo ? (
<span className="customer-auth-form-hint">
如安全验证弹窗未出现,请检查浏览器是否拦截脚本。
</span>
</div>
),
},
{
key: 'qr',
label: '扫码登录',
children: (
<div className="customer-auth-form">
<p className="customer-auth-form-copy">
点击下方按钮后,使用微博 App 扫码并确认登录。
</p>
<Button
type="primary"
size="large"
block
onClick={() => void handleStartLogin()}
>
获取登录二维码
</Button>
</div>
),
},
]}
/>
) : (
<div className="customer-auth-form">
<p className="customer-auth-form-copy">
{config.loginInstructions || '使用虎牙 App 扫码登录,请确认扫码账号属于本人。'}
</p>
<Button type="primary" size="large" block onClick={() => void handleStartLogin()}>
获取登录二维码
</Button>
</div>
)}
) : null}
</div>
),
},
{
key: 'qr',
label: '扫码登录',
children: (
<div className="customer-auth-form">
<p className="customer-auth-form-copy">
{config.loginInstructions ||
`点击下方按钮后,使用${isHuya ? '虎牙' : '微博'} App 扫码并确认登录。`}
</p>
<Button
type="primary"
size="large"
block
onClick={() => void handleStartLogin()}
>
获取登录二维码
</Button>
</div>
),
},
]}
/>
</section>
) : null}
@@ -486,6 +535,16 @@ export default function CustomerFulfillmentPage() {
</div>
) : null}
<HuyaChallengeModal
open={Boolean(huyaChallenge)}
challenge={huyaChallenge}
loading={huyaChallengeLoading}
onCancel={() => {
if (!huyaChallengeLoading) setHuyaChallenge(null)
}}
onSubmit={(solution) => void handleVerifyHuyaChallenge(solution)}
/>
<CustomerPrivacyNote />
</div>
</CustomerAuthFrame>
@@ -576,7 +635,7 @@ function CustomerPrivacyNote() {
)
}
function isValidWeiboMobile(value: string) {
function isValidMainlandMobile(value: string) {
const digits = value.replace(/\D/g, '')
if (!digits) return false
return /^1\d{10}$/.test(digits)
@@ -0,0 +1,244 @@
import { Button, Modal, Slider } from 'antd'
import {
useEffect,
useMemo,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
} from 'react'
import type {
CustomerHuyaChallengeSolution,
CustomerHuyaSmsChallenge,
} from '@/services/customer-fulfillment'
type Props = {
open: boolean
challenge: CustomerHuyaSmsChallenge | null
loading: boolean
onCancel: () => void
onSubmit: (solution: CustomerHuyaChallengeSolution) => void
}
const IMAGE_LEFT = 10
export default function HuyaChallengeModal(input: Props) {
const [containerWidth, setContainerWidth] = useState(0)
const [offset, setOffset] = useState(0)
const [angle, setAngle] = useState(0)
const [clickPoints, setClickPoints] = useState<Array<{ x: number; y: number }>>([])
const [dragging, setDragging] = useState(false)
const containerRef = useRef<HTMLDivElement | null>(null)
const dragRef = useRef({ startX: 0, startOffset: 0 })
useEffect(() => {
if (!input.open) return
setOffset(0)
setAngle(0)
setClickPoints([])
setDragging(false)
const updateWidth = () => {
setContainerWidth(containerRef.current?.clientWidth || 0)
}
updateWidth()
const observer = new ResizeObserver(updateWidth)
if (containerRef.current) observer.observe(containerRef.current)
window.addEventListener('resize', updateWidth)
return () => {
observer.disconnect()
window.removeEventListener('resize', updateWidth)
}
}, [input.open, input.challenge?.image, input.challenge?.type])
const challenge = input.challenge
const scale = challenge && containerWidth > 0 ? containerWidth / challenge.width : 1
const maxOffset = useMemo(() => {
if (!challenge || challenge.type !== 'slider') return 0
return Math.max(0, (challenge.width - IMAGE_LEFT - Math.max(1, challenge.puzzleWidth)) * scale)
}, [challenge, scale])
const distance = Math.round(IMAGE_LEFT + offset / Math.max(scale, 0.0001) || 0)
const clickLimit = challenge?.type === 'click' ? Math.max(1, challenge.wordSize) : 0
const canSubmit =
challenge?.type === 'slider'
? offset >= 1
: challenge?.type === 'click'
? clickPoints.length > 0
: Boolean(challenge)
function handlePointerDown(event: ReactPointerEvent<HTMLButtonElement>) {
if (input.loading) return
event.currentTarget.setPointerCapture(event.pointerId)
dragRef.current = { startX: event.clientX, startOffset: offset }
setDragging(true)
}
function handlePointerMove(event: ReactPointerEvent<HTMLButtonElement>) {
if (!dragging || input.loading) return
const next = dragRef.current.startOffset + event.clientX - dragRef.current.startX
setOffset(Math.max(0, Math.min(maxOffset, next)))
}
function finishDrag(event: ReactPointerEvent<HTMLButtonElement>) {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
setDragging(false)
}
function handleStageClick(event: ReactMouseEvent<HTMLDivElement>) {
if (!challenge || challenge.type !== 'click' || input.loading) return
const rect = event.currentTarget.getBoundingClientRect()
const x = Math.max(0, Math.min(challenge.width, (event.clientX - rect.left) / scale))
const y = Math.max(0, Math.min(challenge.height, (event.clientY - rect.top) / scale))
setClickPoints((current) => {
if (current.length >= clickLimit) return [{ x, y }]
return [...current, { x, y }]
})
}
function submit() {
if (!challenge) return
if (challenge.type === 'slider') {
input.onSubmit({ distance })
return
}
if (challenge.type === 'rotate') {
input.onSubmit({ angle })
return
}
input.onSubmit({ points: clickPoints })
}
return (
<Modal
open={input.open}
title="完成虎牙安全验证"
width={430}
centered
maskClosable={false}
keyboard={!input.loading}
closable={!input.loading}
onCancel={input.onCancel}
footer={[
<Button key="cancel" disabled={input.loading} onClick={input.onCancel}>
取消
</Button>,
<Button
key="submit"
type="primary"
loading={input.loading}
disabled={!challenge || !canSubmit}
onClick={submit}
>
完成验证并发送验证码
</Button>,
]}
>
<div className="huya-slider-challenge">
{challenge?.type === 'slider' ? <p>拖动下方滑块,将拼图对齐到缺口后发送验证码。</p> : null}
{challenge?.type === 'rotate' ? (
<p>拖动角度滑块,把图片方向调整正确后发送验证码。</p>
) : null}
{challenge?.type === 'click' ? (
<p>
{challenge.question || `请按顺序点击图中的 ${clickLimit} 个目标`}
<span className="huya-challenge-progress">
已选择 {clickPoints.length}/{clickLimit}
</span>
</p>
) : null}
{challenge ? (
<>
{challenge.type === 'click' && challenge.tipImage ? (
<img className="huya-click-tip" src={challenge.tipImage} alt="" draggable={false} />
) : null}
<div
ref={containerRef}
className={`huya-slider-stage ${challenge.type === 'click' ? 'is-click' : ''}`}
style={{ aspectRatio: `${challenge.width} / ${challenge.height}` }}
onClick={handleStageClick}
>
<img
className="huya-slider-background"
src={challenge.image}
alt=""
draggable={false}
/>
{challenge.type === 'slider' ? (
<img
className="huya-slider-puzzle"
src={challenge.puzzleImage}
alt=""
draggable={false}
style={{
left: IMAGE_LEFT * scale + offset,
top: challenge.puzzleY * scale,
width: Math.max(1, challenge.puzzleWidth) * scale,
}}
/>
) : null}
{challenge.type === 'rotate' ? (
<img
className="huya-slider-puzzle is-rotate"
src={challenge.puzzleImage}
alt=""
draggable={false}
style={{
left: challenge.puzzleX * scale,
top: challenge.puzzleY * scale,
width: challenge.puzzleWidth * scale,
height: challenge.puzzleHeight * scale,
transform: `rotate(${angle}deg)`,
}}
/>
) : null}
{challenge.type === 'click'
? clickPoints.map((point, index) => (
<span
key={`${point.x}-${point.y}-${index}`}
className="huya-click-point"
style={{ left: point.x * scale, top: point.y * scale }}
>
{index + 1}
</span>
))
: null}
</div>
{challenge.type === 'slider' ? (
<div className="huya-slider-track">
<div className="huya-slider-track-fill" style={{ width: offset + 28 }} />
<button
type="button"
className="huya-slider-handle"
style={{ transform: `translateX(${offset}px)` }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={finishDrag}
onPointerCancel={finishDrag}
>
拖动
</button>
</div>
) : null}
{challenge.type === 'rotate' ? (
<div className="huya-rotate-control">
<Slider min={0} max={359} value={angle} onChange={setAngle} />
<span>{angle}°</span>
</div>
) : null}
{challenge.type === 'click' && clickPoints.length > 0 ? (
<Button size="small" onClick={() => setClickPoints([])}>
重新点选
</Button>
) : null}
</>
) : null}
</div>
</Modal>
)
}