+
+
+
{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)