Code Review 第二轮修复:前端 HTTP 统一 + 数据采集可靠性

F1: 统一前端 HTTP 客户端
- 新增 frontend/src/lib/http.ts(共享客户端,统一超时/错误/401 处理)
- Matches.tsx 的列表/分页/进行中/预测请求改用 http 客户端
- 公开站与 Admin 行为一致

D2: events 采集按联赛分批提交
- _run_bzzoiro 改为 per-league 独立事务,避免超长事务
- 单联赛失败不影响其他联赛

F3: 仪表盘真实计数
- admin_stats 补充 matches/stats/standings 真实聚合
- Dashboard 展示比赛总数/已完赛/统计行/积分榜

F4: LLM 连通性测试不依赖 match_id=1
- 新增 POST /admin/llm/ping 端点(只发一次 chat,不依赖比赛)
- testLLMConnection 优先调用 ping,失败回退旧方式

F5: 导航改用 React Router Link
- App.tsx 中 <a href> 全部替换为 <Link to>

D5: 删除 away_possession 死代码
- bzzoiro.py 中移除计算与注释

D1: 管线基础设施表文档化
- RawEvent/IngestFailure/DataQualityCheck/DataLineage 添加「预留未启用」注释

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
This commit is contained in:
shangfangjian
2026-09-21 03:06:52 +08:00
co-authored by new-provider/LongCat-2.0 <
parent 675e176603
commit b6e367a640
10 changed files with 226 additions and 74 deletions
+100
View File
@@ -0,0 +1,100 @@
/**
* 共享 HTTP 客户端(公开站 + Admin 统一)
*
* 特性:
* - 统一超时(默认 30s,可覆盖)
* - 统一错误处理(ApiError)
* - 401 自动广播(Admin 场景)
* - JSON/HTML 容错解析
* - 请求竞态防护(可选 signal)
*/
import { UNAUTHORIZED_EVENT } from '../admin/api'
const API_BASE = '/api/v1'
const DEFAULT_TIMEOUT = 30_000
export class ApiError extends Error {
constructor(
message: string,
public status: number,
public data?: unknown,
) {
super(message)
this.name = 'ApiError'
}
}
interface RequestOptions {
timeoutMs?: number
skipAuthHandling?: boolean
signal?: AbortSignal
method?: string
body?: string
}
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const url = path.startsWith('http') ? path : path.startsWith('/') ? path : `${API_BASE}${path}`
const { timeoutMs = DEFAULT_TIMEOUT, skipAuthHandling, signal } = options
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
// 如果外部传了 signal,也关联到内部 controller
if (signal) {
signal.addEventListener('abort', () => controller.abort(), { once: true })
}
try {
const res = await fetch(url, {
signal: controller.signal,
headers: { 'Content-Type': 'application/json' },
})
if (!res.ok) {
const rawText = await res.text()
let detail: unknown = rawText
try {
detail = JSON.parse(rawText)
} catch {
// 非 JSON(如 HTML 错误页),保留原始文本
}
let message =
detail && typeof detail === 'object' && detail !== null && 'detail' in detail
? String((detail as { detail: unknown }).detail)
: `HTTP ${res.status}: ${res.statusText}`
if (res.status === 401 && !skipAuthHandling) {
message += '\n登录已过期,请重新登录。'
window.dispatchEvent(new CustomEvent(UNAUTHORIZED_EVENT))
}
throw new ApiError(message, res.status, detail)
}
if (res.status === 204) {
return undefined as T
}
return res.json() as Promise<T>
} catch (err) {
if (err instanceof ApiError) throw err
if (err instanceof DOMException && err.name === 'AbortError') {
throw new ApiError('请求超时,请稍后重试', 0)
}
throw new ApiError(
err instanceof Error ? err.message : '网络错误,请检查连接',
0,
)
} finally {
clearTimeout(timer)
}
}
export const http = {
get: <T>(path: string, opts?: RequestOptions) => request<T>(path, { ...opts }),
post: <T>(path: string, body?: unknown, opts?: RequestOptions) =>
request<T>(path, { ...opts, method: 'POST', body: body ? JSON.stringify(body) : undefined }),
put: <T>(path: string, body?: unknown, opts?: RequestOptions) =>
request<T>(path, { ...opts, method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
delete: <T>(path: string, opts?: RequestOptions) => request<T>(path, { ...opts, method: 'DELETE' }),
}