Files
Profeto/frontend/src/pages/matches/hooks/useMatchPredict.ts
T
shangfangjian 197641b9f7 fix(P1-async): 预测改为异步 POST+轮询,避免网关超时(Cloudflare 524)
后端:POST /predict 立即返回 job_id,后台 asyncio.create_task 执行;
新增 GET /predict/jobs/{job_id} 轮询状态(running/success/failed)。

前端: useMatchPredict 改为 POST 拿 job_id → 每 3s 轮询直到终态;
整体超时 5 分钟不变。避免多专家预测 60-180s 触发 Cloudflare 100s 超时(HTTP 524)。
2026-09-22 11:00:40 +08:00

122 lines
4.4 KiB
TypeScript

/**
* useMatchPredict: 预测流程状态机(发起/进行中/结果/失败/关闭中止)。
*
* D3: 从 Matches.tsx 拆出。语义不变:
* - 连点防护:同一场比赛预测中再次点击直接忽略
* - 竞态防护:递增序号,过期响应丢弃
* - 关闭弹窗 = 中止在途请求 + 序号失效(catch/then 不再写入)
* - 5 分钟超时,与 nginx 代理 300s 对齐
*/
import { useRef, useState } from 'react'
import { http } from '../../../lib/http'
import type { Match, Prediction } from '../types'
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
/** 把后端/网络错误翻译成用户可读文案 */
function readablePredictError(e: unknown): string {
if (e instanceof Error) {
const m = e.message
if (/429/.test(m)) {
// 429 来自后端限流(每分钟 10 次),非上游 LLM
return '操作过于频繁:每分钟最多 10 次预测。为保护 LLM 额度,请稍后再试。'
}
if (/502/.test(m)) return 'LLM 服务暂时不可用(502),请稍后重试'
if (/402|Payment Required|额度|余额/.test(m)) return 'LLM 额度不足(402),请检查 API Key 余额'
if (/400|已完赛/.test(m)) return '该比赛已完赛,不再支持预测'
if (/409|已结算/.test(m)) return '该预测已结算,不能重新预测'
if (/timeout|超时|timed out/i.test(m)) return '请求超时,请稍后重试'
return m
}
return String(e)
}
interface UseMatchPredictOptions {
/** 共享 error state(拆分前列表与预测共用同一个 error,行为保持一致) */
onError: (msg: string | null) => void
}
export function useMatchPredict({ onError }: UseMatchPredictOptions) {
const [predictingId, setPredictingId] = useState<number | null>(null)
const [prediction, setPrediction] = useState<Prediction | null>(null)
const [predictionFor, setPredictionFor] = useState<Match | null>(null)
const predictSeq = useRef(0)
// 预测请求控制器:关闭弹窗时中止
const predictAbort = useRef<AbortController | null>(null)
function closePredict() {
predictAbort.current?.abort()
predictSeq.current++ // 令中止请求的 catch/then 全部失效,不再写入错误
setPredictingId(null)
setPrediction(null)
setPredictionFor(null)
onError(null)
}
const predict = async (m: Match) => {
// 防连点:若该场比赛已在预测中,直接忽略
if (predictingId === m.id) return
const seq = ++predictSeq.current
setPredictingId(m.id)
onError(null)
setPrediction(null)
setPredictionFor(m)
// P1-async: 预测改为异步,POST 立即返回 job_id,轮询结果避免网关超时(Cloudflare 100s → 524)
const controller = new AbortController()
predictAbort.current = controller
const overallTimer = setTimeout(() => controller.abort(), 300_000)
try {
// 1) 发起预测,拿到 job_id
const started = await http.post<{ job_id: string; poll_url: string }>(
'/predict',
{ match_id: m.id, mode: 'multi' },
{ timeoutMs: 10_000, signal: controller.signal },
)
if (seq !== predictSeq.current) return
// 2) 轮询直到终态(success/failed)或整体超时
const deadline = Date.now() + 300_000
while (Date.now() < deadline) {
if (controller.signal.aborted) throw new DOMException('aborted', 'AbortError')
await sleep(3000)
const job = await http.get<{ status: string; result?: Prediction; error?: string }>(
`/predict/jobs/${started.job_id}`,
{ timeoutMs: 5000, signal: controller.signal },
)
if (seq !== predictSeq.current) return
if (job.status === 'success') {
setPrediction(job.result ?? null)
return
}
if (job.status === 'failed') {
onError(job.error || '预测失败')
return
}
// status === 'running' → 继续轮询
}
// 整体超时
onError('预测超时(5 分钟),请稍后重试')
} catch (e) {
if (seq !== predictSeq.current) return
onError(
e instanceof DOMException && e.name === 'AbortError'
? '预测超时(5 分钟),请稍后重试'
: readablePredictError(e),
)
} finally {
clearTimeout(overallTimer)
if (seq === predictSeq.current) setPredictingId(null)
}
}
return {
predictingId,
prediction,
predictionFor,
predict,
closePredict,
}
}