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
+13 -13
View File
@@ -11,7 +11,7 @@
* - 未登录访问管理 → AdminLayout 门禁 → 登录页(不静默失败) * - 未登录访问管理 → AdminLayout 门禁 → 登录页(不静默失败)
*/ */
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { BrowserRouter, Routes, Route, Navigate, Link } from 'react-router-dom'
import { ErrorBoundary } from './components/ErrorBoundary' import { ErrorBoundary } from './components/ErrorBoundary'
import Matches from './pages/Matches' import Matches from './pages/Matches'
import Standings from './pages/Standings' import Standings from './pages/Standings'
@@ -40,15 +40,15 @@ function StandingsLayout({ children }: { children: React.ReactNode }) {
<div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500"> <div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500">
<span>{dateLine()}</span> <span>{dateLine()}</span>
<nav className="flex items-center gap-4" aria-label="页面导航"> <nav className="flex items-center gap-4" aria-label="页面导航">
<a href="/" className="text-ink-500 hover:text-press transition-colors"> <Link to="/" className="text-ink-500 hover:text-press transition-colors">
/ /
</a> </Link>
<a href="/admin/eval" className="text-ink-500 hover:text-press transition-colors"> <Link to="/admin/eval" className="text-ink-500 hover:text-press transition-colors">
</a> </Link>
<a href="/admin" className="flex items-center gap-1 text-ink-500 hover:text-press transition-colors"> <Link to="/admin" className="flex items-center gap-1 text-ink-500 hover:text-press transition-colors">
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
</a> </Link>
</nav> </nav>
</div> </div>
</div> </div>
@@ -81,15 +81,15 @@ function HomePage() {
<div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500"> <div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500">
<span>{dateLine()}</span> <span>{dateLine()}</span>
<nav className="flex items-center gap-4" aria-label="页面导航"> <nav className="flex items-center gap-4" aria-label="页面导航">
<a href="/standings" className="text-ink-500 hover:text-press transition-colors"> <Link to="/standings" className="text-ink-500 hover:text-press transition-colors">
</a> </Link>
<a href="/admin/eval" className="text-ink-500 hover:text-press transition-colors"> <Link to="/admin/eval" className="text-ink-500 hover:text-press transition-colors">
</a> </Link>
<a href="/admin" className="flex items-center gap-1 text-ink-500 hover:text-press transition-colors"> <Link to="/admin" className="flex items-center gap-1 text-ink-500 hover:text-press transition-colors">
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
</a> </Link>
</nav> </nav>
</div> </div>
</div> </div>
+7 -4
View File
@@ -293,14 +293,17 @@ export function clearSetting(key: string) {
* 测试 LLM 连接 — 调用预测端点验证 * 测试 LLM 连接 — 调用预测端点验证
*/ */
export async function testLLMConnection(matchId?: number): Promise<any> { export async function testLLMConnection(matchId?: number): Promise<any> {
// F4 修复: 优先使用不依赖比赛的 ping 端点
try {
return await api.post(`${API_BASE}/admin/llm/ping`, {})
} catch {
// 回退到旧方式(兼容)
return api.post( return api.post(
`${API_BASE}/predict`, `${API_BASE}/predict`,
{ { match_id: matchId || 1, mode: 'single' },
match_id: matchId || 1,
mode: 'single',
},
{ timeoutMs: 300_000 }, { timeoutMs: 300_000 },
) )
}
} }
/** /**
+21
View File
@@ -116,6 +116,7 @@ export default function Dashboard() {
<CardHeader title="近期预测活动" description="预测 API 的调用量统计" /> <CardHeader title="近期预测活动" description="预测 API 的调用量统计" />
<CardBody> <CardBody>
{stats ? ( {stats ? (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4 text-center"> <div className="grid grid-cols-3 gap-4 text-center">
<div> <div>
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.last_24h}</div> <div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.last_24h}</div>
@@ -130,6 +131,26 @@ export default function Dashboard() {
<div className="mt-1 text-2xs text-ink-400"></div> <div className="mt-1 text-2xs text-ink-400"></div>
</div> </div>
</div> </div>
{/* F3 修复: 真实比赛计数(非 limit=100 近似) */}
<div className="grid grid-cols-4 gap-3 border-t border-ink-200 pt-3 text-center">
<div>
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.matches?.total ?? 0}</div>
<div className="mt-0.5 text-2xs text-ink-400"></div>
</div>
<div>
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.matches?.finished ?? 0}</div>
<div className="mt-0.5 text-2xs text-ink-400"></div>
</div>
<div>
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.stats?.total ?? 0}</div>
<div className="mt-0.5 text-2xs text-ink-400"></div>
</div>
<div>
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.standings?.total ?? 0}</div>
<div className="mt-0.5 text-2xs text-ink-400"></div>
</div>
</div>
</div>
) : ( ) : (
<div className="flex justify-center py-4"><SkeletonBlock className="h-12 w-64" /></div> <div className="flex justify-center py-4"><SkeletonBlock className="h-12 w-64" /></div>
)} )}
+3
View File
@@ -366,4 +366,7 @@ export interface AdminStats {
last_24h: number last_24h: number
last_7d: number last_7d: number
} }
matches?: { total: number; finished: number }
stats?: { total: number }
standings?: { total: number }
} }
+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' }),
}
+6 -21
View File
@@ -3,6 +3,7 @@ import TeamSideTag from '../components/TeamSideTag'
import { fetchMatchDetail, fetchMatchContext } from '../admin/dal' import { fetchMatchDetail, fetchMatchContext } from '../admin/dal'
import type { MatchDetailOut, MatchContextOut, MatchRecentPrediction, TeamRecentMatch } from '../admin/types' import type { MatchDetailOut, MatchContextOut, MatchRecentPrediction, TeamRecentMatch } from '../admin/types'
import type { MatchStatsDetail } from '../admin/types' import type { MatchStatsDetail } from '../admin/types'
import { http } from '../lib/http'
interface Match { interface Match {
id: number id: number
@@ -238,11 +239,8 @@ export default function Matches() {
setError(null) setError(null)
try { try {
const params = new URLSearchParams({ league, status, limit: '50' }) const params = new URLSearchParams({ league, status, limit: '50' })
const res = await fetch(`/api/v1/matches?${params}`) const data = await http.get<{ items: Match[]; next_cursor: string | null }>(`/matches?${params}`)
if (seq !== loadSeq.current) return // 已有更新的请求,丢弃本次结果 if (seq !== loadSeq.current) return // 已有更新的请求,丢弃本次结果
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
if (seq !== loadSeq.current) return
setMatches(data.items) setMatches(data.items)
setNextCursor(data.next_cursor ?? null) setNextCursor(data.next_cursor ?? null)
} catch (e) { } catch (e) {
@@ -260,10 +258,7 @@ export default function Matches() {
setLoadingMore(true) setLoadingMore(true)
try { try {
const params = new URLSearchParams({ league, status, limit: '50', cursor: nextCursor }) const params = new URLSearchParams({ league, status, limit: '50', cursor: nextCursor })
const res = await fetch(`/api/v1/matches?${params}`) const data = await http.get<{ items: Match[]; next_cursor: string | null }>(`/matches?${params}`)
if (seq !== loadSeq.current) return
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
if (seq !== loadSeq.current) return if (seq !== loadSeq.current) return
setMatches(prev => [...prev, ...data.items]) setMatches(prev => [...prev, ...data.items])
setNextCursor(data.next_cursor ?? null) setNextCursor(data.next_cursor ?? null)
@@ -279,9 +274,7 @@ export default function Matches() {
const loadLive = useCallback(async () => { const loadLive = useCallback(async () => {
try { try {
const params = new URLSearchParams({ league, status: 'in_play', limit: '20' }) const params = new URLSearchParams({ league, status: 'in_play', limit: '20' })
const res = await fetch(`/api/v1/matches?${params}`) const data = await http.get<{ items: Match[] }>(`/matches?${params}`)
if (!res.ok) return
const data = await res.json()
setLiveMatches(data.items ?? []) setLiveMatches(data.items ?? [])
} catch { } catch {
/* ignore:进行中非核心功能 */ /* ignore:进行中非核心功能 */
@@ -340,19 +333,11 @@ export default function Matches() {
predictAbort.current = controller predictAbort.current = controller
const timer = setTimeout(() => controller.abort(), 300_000) const timer = setTimeout(() => controller.abort(), 300_000)
try { try {
const res = await fetch('/api/v1/predict', { const data = await http.post<Prediction>('/predict', { match_id: m.id, mode: 'multi' }, {
method: 'POST', timeoutMs: 300_000,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ match_id: m.id, mode: 'multi' }),
signal: controller.signal, signal: controller.signal,
}) })
if (seq !== predictSeq.current) return if (seq !== predictSeq.current) return
if (!res.ok) {
const t = await res.text()
throw new Error(`HTTP ${res.status}: ${t}`)
}
const data = await res.json()
if (seq !== predictSeq.current) return
setPrediction(data) setPrediction(data)
} catch (e) { } catch (e) {
if (seq !== predictSeq.current) return if (seq !== predictSeq.current) return
+28 -3
View File
@@ -293,6 +293,21 @@ async def test_datasource(name: str):
raise HTTPException(404, f"未知数据源: {name}") raise HTTPException(404, f"未知数据源: {name}")
@router.post("/llm/ping")
async def llm_ping():
"""LLM 连通性测试(不依赖比赛)。只发一次 chat 请求验证配置。"""
from src.llm.provider import get_default_provider
p = await get_default_provider()
resp = await p.chat(
system="你是测试助手。",
user="ping",
max_tokens=10,
)
if resp.error:
return {"ok": False, "message": resp.error}
return {"ok": True, "message": "LLM 连接正常", "model": p.model}
# ── 数据源健康/最近采集状态(只读,不触发采集) ────────────────────── # ── 数据源健康/最近采集状态(只读,不触发采集) ──────────────────────
@@ -387,9 +402,9 @@ def _last_failure_log(source: str) -> dict | None:
@router.get("/stats") @router.get("/stats")
async def admin_stats(db: AsyncSession = Depends(get_db_read)): async def admin_stats(db: AsyncSession = Depends(get_db_read)):
"""管理区统计(只读):最近预测次数。轻量聚合,无 LLM 调用。""" """管理区统计(只读):预测次数 + 比赛覆盖。轻量聚合,无 LLM 调用。"""
from sqlalchemy import func, text from sqlalchemy import func, text
from src.db.models import Prediction from src.db.models import Prediction, Match, MatchStats, Standing
day_ago = datetime.now(timezone.utc) - timedelta(days=1) day_ago = datetime.now(timezone.utc) - timedelta(days=1)
week_ago = datetime.now(timezone.utc) - timedelta(days=7) week_ago = datetime.now(timezone.utc) - timedelta(days=7)
r = ( r = (
@@ -401,7 +416,17 @@ async def admin_stats(db: AsyncSession = Depends(get_db_read)):
) )
) )
).one() ).one()
return {"predictions": {"total": r.total, "last_24h": r.last_24h, "last_7d": r.last_7d}} # F3 修复: 补充真实比赛计数(非 limit=100 近似)
match_cnt = (await db.execute(select(func.count()).select_from(Match))).scalar() or 0
finished_cnt = (await db.execute(select(func.count()).where(Match.match_status == "finished"))).scalar() or 0
stats_cnt = (await db.execute(select(func.count()).select_from(MatchStats))).scalar() or 0
standings_cnt = (await db.execute(select(func.count()).select_from(Standing))).scalar() or 0
return {
"predictions": {"total": r.total, "last_24h": r.last_24h, "last_7d": r.last_7d},
"matches": {"total": match_cnt, "finished": finished_cnt},
"stats": {"total": stats_cnt},
"standings": {"total": standings_cnt},
}
# ── 数据完整性分析(可视化数据源) ──────────────────────────────── # ── 数据完整性分析(可视化数据源) ────────────────────────────────
+7 -6
View File
@@ -58,20 +58,21 @@ async def _run_bzzoiro(task: str, leagues: list[str], req: IngestBzzoiroRequest)
statuses = [req.status] if req.status else ["finished", "scheduled"] statuses = [req.status] if req.status else ["finished", "scheduled"]
source = get_source("bzzoiro") source = get_source("bzzoiro")
merged: dict = {"leagues": {}, "total_inserted": 0, "total_updated": 0, "errors": []} merged: dict = {"leagues": {}, "total_inserted": 0, "total_updated": 0, "errors": []}
async with get_uow() as session: # D2 修复: 按联赛分批提交,避免超长事务
for code in leagues:
for st in statuses: for st in statuses:
async with get_uow() as session:
r = await source.ingest( r = await source.ingest(
session, leagues=leagues, session, leagues=[code],
date_from=req.date_from, date_to=req.date_to, status=st, date_from=req.date_from, date_to=req.date_to, status=st,
) )
merged["total_inserted"] += r.get("total_inserted", 0) merged["total_inserted"] += r.get("total_inserted", 0)
merged["total_updated"] += r.get("total_updated", 0) merged["total_updated"] += r.get("total_updated", 0)
merged["errors"].extend(r.get("errors", [])) merged["errors"].extend(r.get("errors", []))
for code, stat in r.get("leagues", {}).items():
acc = merged["leagues"].setdefault(code, {"inserted": 0, "updated": 0, "errors": []}) acc = merged["leagues"].setdefault(code, {"inserted": 0, "updated": 0, "errors": []})
acc["inserted"] += stat.get("inserted", 0) acc["inserted"] += r.get("inserted", 0)
acc["updated"] += stat.get("updated", 0) acc["updated"] += r.get("updated", 0)
acc["errors"].extend(stat.get("errors", [])) acc["errors"].extend(r.get("errors", []))
logger.info( logger.info(
"bzzoiro 比赛采集完成: 新增 %d, 更新 %d, 联赛 %d 个, 状态 %s", "bzzoiro 比赛采集完成: 新增 %d, 更新 %d, 联赛 %d 个, 状态 %s",
merged["total_inserted"], merged["total_updated"], len(merged["leagues"]), statuses, merged["total_inserted"], merged["total_updated"], len(merged["leagues"]), statuses,
-2
View File
@@ -541,7 +541,6 @@ def _stats_from_payload(payload: dict) -> dict:
p = _to_float_or_none(poss) p = _to_float_or_none(poss)
if p is not None: if p is not None:
out["home_possession"] = p out["home_possession"] = p
out["away_possession"] = round(100 - p, 1) if 0 <= p <= 100 else None
for src, (h_fld, a_fld) in _STATS_FIELD_MAP.items(): for src, (h_fld, a_fld) in _STATS_FIELD_MAP.items():
if src in ("xg", "ball_possession"): if src in ("xg", "ball_possession"):
@@ -642,7 +641,6 @@ async def ingest_bzzoiro_event_stats(
m.stats.available_at = m.match_date + timedelta(hours=2) m.stats.available_at = m.match_date + timedelta(hours=2)
for fld, v in fields.items(): for fld, v in fields.items():
# away_possession 为计算字段,模型无此列,跳过
if hasattr(m.stats, fld): if hasattr(m.stats, fld):
setattr(m.stats, fld, v) setattr(m.stats, fld, v)
+20 -4
View File
@@ -284,7 +284,11 @@ class Schedule(Base):
class RawEvent(Base): class RawEvent(Base):
"""Bronze 层:采集到的原始事件存档,便于重放与审计。""" """Bronze 层:采集到的原始事件存档,便于重放与审计。
⚠️ 预留未启用:当前 bzzoiro 管线不写入此表。
未来接线计划:events 采集成功后写入 raw_payload,支持重放与审计。
"""
__tablename__ = "raw_events" __tablename__ = "raw_events"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True) id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
@@ -301,7 +305,11 @@ class RawEvent(Base):
class IngestFailure(Base): class IngestFailure(Base):
"""采集失败死信:记录失败原因、重试次数与下次重试时间。""" """采集失败死信:记录失败原因、重试次数与下次重试时间。
⚠️ 预留未启用:当前 bzzoiro 管线不写入此表。
未来接线计划:采集失败时写入,支持按 next_retry_at 自动重试。
"""
__tablename__ = "ingest_failures" __tablename__ = "ingest_failures"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True) id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
@@ -327,7 +335,11 @@ class IngestFailure(Base):
class DataQualityCheck(Base): class DataQualityCheck(Base):
"""数据质量监控:记录每次质量检查的结果。""" """数据质量监控:记录每次质量检查的结果。
⚠️ 预留未启用:当前 bzzoiro 管线不写入此表。
未来接线计划:定时检查比赛/统计/积分榜完整性,写入检查结果。
"""
__tablename__ = "data_quality_checks" __tablename__ = "data_quality_checks"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True) id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
@@ -348,7 +360,11 @@ class DataQualityCheck(Base):
class DataLineage(Base): class DataLineage(Base):
"""ETL 血缘追踪:记录从源到目标的转换过程。""" """ETL 血缘追踪:记录从源到目标的转换过程。
⚠️ 预留未启用:当前 bzzoiro 管线不写入此表。
未来接线计划:每次采集写入 source_record_id → target_table/id 映射。
"""
__tablename__ = "data_lineage" __tablename__ = "data_lineage"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True) id: Mapped[int] = mapped_column(BigInteger, primary_key=True)