From b6e367a640fa35bca2402f56ee3a2369c700b56a Mon Sep 17 00:00:00 2001 From: shangfangjian Date: Mon, 21 Sep 2026 03:06:52 +0800 Subject: [PATCH] =?UTF-8?q?Code=20Review=20=E7=AC=AC=E4=BA=8C=E8=BD=AE?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=E5=89=8D=E7=AB=AF=20HTTP=20=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=20+=20=E6=95=B0=E6=8D=AE=E9=87=87=E9=9B=86=E5=8F=AF?= =?UTF-8?q?=E9=9D=A0=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 中 全部替换为 D5: 删除 away_possession 死代码 - bzzoiro.py 中移除计算与注释 D1: 管线基础设施表文档化 - RawEvent/IngestFailure/DataQualityCheck/DataLineage 添加「预留未启用」注释 Co-Authored-By: new-provider/LongCat-2.0 <> --- frontend/src/App.tsx | 26 +++---- frontend/src/admin/dal.ts | 19 +++-- frontend/src/admin/pages/Dashboard.tsx | 43 ++++++++--- frontend/src/admin/types.ts | 3 + frontend/src/lib/http.ts | 100 +++++++++++++++++++++++++ frontend/src/pages/Matches.tsx | 27 ++----- src/api/routes/admin_settings.py | 31 +++++++- src/api/routes/ingest.py | 25 ++++--- src/data/bzzoiro.py | 2 - src/db/models.py | 24 +++++- 10 files changed, 226 insertions(+), 74 deletions(-) create mode 100644 frontend/src/lib/http.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 807a475..352d475 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,7 +11,7 @@ * - 未登录访问管理 → 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 Matches from './pages/Matches' import Standings from './pages/Standings' @@ -40,15 +40,15 @@ function StandingsLayout({ children }: { children: React.ReactNode }) {
{dateLine()}
@@ -81,15 +81,15 @@ function HomePage() {
{dateLine()}
diff --git a/frontend/src/admin/dal.ts b/frontend/src/admin/dal.ts index 0f496da..089b15a 100644 --- a/frontend/src/admin/dal.ts +++ b/frontend/src/admin/dal.ts @@ -293,14 +293,17 @@ export function clearSetting(key: string) { * 测试 LLM 连接 — 调用预测端点验证 */ export async function testLLMConnection(matchId?: number): Promise { - return api.post( - `${API_BASE}/predict`, - { - match_id: matchId || 1, - mode: 'single', - }, - { timeoutMs: 300_000 }, - ) + // F4 修复: 优先使用不依赖比赛的 ping 端点 + try { + return await api.post(`${API_BASE}/admin/llm/ping`, {}) + } catch { + // 回退到旧方式(兼容) + return api.post( + `${API_BASE}/predict`, + { match_id: matchId || 1, mode: 'single' }, + { timeoutMs: 300_000 }, + ) + } } /** diff --git a/frontend/src/admin/pages/Dashboard.tsx b/frontend/src/admin/pages/Dashboard.tsx index 552aaa0..7c3d604 100644 --- a/frontend/src/admin/pages/Dashboard.tsx +++ b/frontend/src/admin/pages/Dashboard.tsx @@ -116,18 +116,39 @@ export default function Dashboard() { {stats ? ( -
-
-
{stats.predictions.last_24h}
-
近 24 小时
+
+
+
+
{stats.predictions.last_24h}
+
近 24 小时
+
+
+
{stats.predictions.last_7d}
+
近 7 天
+
+
+
{stats.predictions.total}
+
累计
+
-
-
{stats.predictions.last_7d}
-
近 7 天
-
-
-
{stats.predictions.total}
-
累计
+ {/* F3 修复: 真实比赛计数(非 limit=100 近似) */} +
+
+
{stats.matches?.total ?? 0}
+
比赛总数
+
+
+
{stats.matches?.finished ?? 0}
+
已完赛
+
+
+
{stats.stats?.total ?? 0}
+
统计行
+
+
+
{stats.standings?.total ?? 0}
+
积分榜
+
) : ( diff --git a/frontend/src/admin/types.ts b/frontend/src/admin/types.ts index 2bf4c42..fe88226 100644 --- a/frontend/src/admin/types.ts +++ b/frontend/src/admin/types.ts @@ -366,4 +366,7 @@ export interface AdminStats { last_24h: number last_7d: number } + matches?: { total: number; finished: number } + stats?: { total: number } + standings?: { total: number } } diff --git a/frontend/src/lib/http.ts b/frontend/src/lib/http.ts new file mode 100644 index 0000000..3e57e18 --- /dev/null +++ b/frontend/src/lib/http.ts @@ -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(path: string, options: RequestOptions = {}): Promise { + 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 + } 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: (path: string, opts?: RequestOptions) => request(path, { ...opts }), + post: (path: string, body?: unknown, opts?: RequestOptions) => + request(path, { ...opts, method: 'POST', body: body ? JSON.stringify(body) : undefined }), + put: (path: string, body?: unknown, opts?: RequestOptions) => + request(path, { ...opts, method: 'PUT', body: body ? JSON.stringify(body) : undefined }), + delete: (path: string, opts?: RequestOptions) => request(path, { ...opts, method: 'DELETE' }), +} diff --git a/frontend/src/pages/Matches.tsx b/frontend/src/pages/Matches.tsx index 98e0ffa..bc24a4a 100644 --- a/frontend/src/pages/Matches.tsx +++ b/frontend/src/pages/Matches.tsx @@ -3,6 +3,7 @@ import TeamSideTag from '../components/TeamSideTag' import { fetchMatchDetail, fetchMatchContext } from '../admin/dal' import type { MatchDetailOut, MatchContextOut, MatchRecentPrediction, TeamRecentMatch } from '../admin/types' import type { MatchStatsDetail } from '../admin/types' +import { http } from '../lib/http' interface Match { id: number @@ -238,11 +239,8 @@ export default function Matches() { setError(null) try { 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 (!res.ok) throw new Error(`HTTP ${res.status}`) - const data = await res.json() - if (seq !== loadSeq.current) return setMatches(data.items) setNextCursor(data.next_cursor ?? null) } catch (e) { @@ -260,10 +258,7 @@ export default function Matches() { setLoadingMore(true) try { const params = new URLSearchParams({ league, status, limit: '50', cursor: nextCursor }) - const res = await fetch(`/api/v1/matches?${params}`) - if (seq !== loadSeq.current) return - if (!res.ok) throw new Error(`HTTP ${res.status}`) - const data = await res.json() + const data = await http.get<{ items: Match[]; next_cursor: string | null }>(`/matches?${params}`) if (seq !== loadSeq.current) return setMatches(prev => [...prev, ...data.items]) setNextCursor(data.next_cursor ?? null) @@ -279,9 +274,7 @@ export default function Matches() { const loadLive = useCallback(async () => { try { const params = new URLSearchParams({ league, status: 'in_play', limit: '20' }) - const res = await fetch(`/api/v1/matches?${params}`) - if (!res.ok) return - const data = await res.json() + const data = await http.get<{ items: Match[] }>(`/matches?${params}`) setLiveMatches(data.items ?? []) } catch { /* ignore:进行中非核心功能 */ @@ -340,19 +333,11 @@ export default function Matches() { predictAbort.current = controller const timer = setTimeout(() => controller.abort(), 300_000) try { - const res = await fetch('/api/v1/predict', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ match_id: m.id, mode: 'multi' }), + const data = await http.post('/predict', { match_id: m.id, mode: 'multi' }, { + timeoutMs: 300_000, signal: controller.signal, }) 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) } catch (e) { if (seq !== predictSeq.current) return diff --git a/src/api/routes/admin_settings.py b/src/api/routes/admin_settings.py index 2c45b03..642d91e 100644 --- a/src/api/routes/admin_settings.py +++ b/src/api/routes/admin_settings.py @@ -293,6 +293,21 @@ async def test_datasource(name: str): 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") async def admin_stats(db: AsyncSession = Depends(get_db_read)): - """管理区统计(只读):最近预测次数。轻量聚合,无 LLM 调用。""" + """管理区统计(只读):预测次数 + 比赛覆盖。轻量聚合,无 LLM 调用。""" 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) week_ago = datetime.now(timezone.utc) - timedelta(days=7) r = ( @@ -401,7 +416,17 @@ async def admin_stats(db: AsyncSession = Depends(get_db_read)): ) ) ).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}, + } # ── 数据完整性分析(可视化数据源) ──────────────────────────────── diff --git a/src/api/routes/ingest.py b/src/api/routes/ingest.py index c956de7..0799979 100644 --- a/src/api/routes/ingest.py +++ b/src/api/routes/ingest.py @@ -58,20 +58,21 @@ async def _run_bzzoiro(task: str, leagues: list[str], req: IngestBzzoiroRequest) statuses = [req.status] if req.status else ["finished", "scheduled"] source = get_source("bzzoiro") 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: - r = await source.ingest( - session, leagues=leagues, - date_from=req.date_from, date_to=req.date_to, status=st, - ) - merged["total_inserted"] += r.get("total_inserted", 0) - merged["total_updated"] += r.get("total_updated", 0) - merged["errors"].extend(r.get("errors", [])) - for code, stat in r.get("leagues", {}).items(): + async with get_uow() as session: + r = await source.ingest( + session, leagues=[code], + date_from=req.date_from, date_to=req.date_to, status=st, + ) + merged["total_inserted"] += r.get("total_inserted", 0) + merged["total_updated"] += r.get("total_updated", 0) + merged["errors"].extend(r.get("errors", [])) acc = merged["leagues"].setdefault(code, {"inserted": 0, "updated": 0, "errors": []}) - acc["inserted"] += stat.get("inserted", 0) - acc["updated"] += stat.get("updated", 0) - acc["errors"].extend(stat.get("errors", [])) + acc["inserted"] += r.get("inserted", 0) + acc["updated"] += r.get("updated", 0) + acc["errors"].extend(r.get("errors", [])) logger.info( "bzzoiro 比赛采集完成: 新增 %d, 更新 %d, 联赛 %d 个, 状态 %s", merged["total_inserted"], merged["total_updated"], len(merged["leagues"]), statuses, diff --git a/src/data/bzzoiro.py b/src/data/bzzoiro.py index 1bd2d36..68a25e9 100644 --- a/src/data/bzzoiro.py +++ b/src/data/bzzoiro.py @@ -541,7 +541,6 @@ def _stats_from_payload(payload: dict) -> dict: p = _to_float_or_none(poss) if p is not None: 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(): 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) for fld, v in fields.items(): - # away_possession 为计算字段,模型无此列,跳过 if hasattr(m.stats, fld): setattr(m.stats, fld, v) diff --git a/src/db/models.py b/src/db/models.py index e5145cb..4f0a72e 100644 --- a/src/db/models.py +++ b/src/db/models.py @@ -284,7 +284,11 @@ class Schedule(Base): class RawEvent(Base): - """Bronze 层:采集到的原始事件存档,便于重放与审计。""" + """Bronze 层:采集到的原始事件存档,便于重放与审计。 + + ⚠️ 预留未启用:当前 bzzoiro 管线不写入此表。 + 未来接线计划:events 采集成功后写入 raw_payload,支持重放与审计。 + """ __tablename__ = "raw_events" id: Mapped[int] = mapped_column(BigInteger, primary_key=True) @@ -301,7 +305,11 @@ class RawEvent(Base): class IngestFailure(Base): - """采集失败死信:记录失败原因、重试次数与下次重试时间。""" + """采集失败死信:记录失败原因、重试次数与下次重试时间。 + + ⚠️ 预留未启用:当前 bzzoiro 管线不写入此表。 + 未来接线计划:采集失败时写入,支持按 next_retry_at 自动重试。 + """ __tablename__ = "ingest_failures" id: Mapped[int] = mapped_column(BigInteger, primary_key=True) @@ -327,7 +335,11 @@ class IngestFailure(Base): class DataQualityCheck(Base): - """数据质量监控:记录每次质量检查的结果。""" + """数据质量监控:记录每次质量检查的结果。 + + ⚠️ 预留未启用:当前 bzzoiro 管线不写入此表。 + 未来接线计划:定时检查比赛/统计/积分榜完整性,写入检查结果。 + """ __tablename__ = "data_quality_checks" id: Mapped[int] = mapped_column(BigInteger, primary_key=True) @@ -348,7 +360,11 @@ class DataQualityCheck(Base): class DataLineage(Base): - """ETL 血缘追踪:记录从源到目标的转换过程。""" + """ETL 血缘追踪:记录从源到目标的转换过程。 + + ⚠️ 预留未启用:当前 bzzoiro 管线不写入此表。 + 未来接线计划:每次采集写入 source_record_id → target_table/id 映射。 + """ __tablename__ = "data_lineage" id: Mapped[int] = mapped_column(BigInteger, primary_key=True)