refactor: 以 bzzoiro 为唯一数据源的全面重构
数据源统一为 bzzoiro,移除 Understat 与 injuries:
- 删除 src/data/understat.py / injuries.py 及相关测试
- 删除 injuries 模型与表;扩展 match_stats(xG 之外增加 big_chances/fouls)
- 新增 standings 表(联赛积分榜:位置/积分/xG/走势/分区)
- matches 表增加 source_event_id 血缘列,支撑统计回填
采集管线(bzzoiro 三条管线):
- events:赛程/比分(/events/),记录 source_event_id
- standings:积分榜快照(/leagues/{id}/standings/)
- stats:已完赛比赛详细统计回填(/events/{id}/stats/)
预测增强:
- standings_slice 替代 injuries_slice;积分榜专家替代阵容完整性专家
- AGENT_META runtime_config 同步更新
管理后台:
- ingest 路由重写为单一 bzzoiro 入口 + task 参数(events/standings/stats/all)
- 新增 /admin/data-completeness 数据完整性分析 API
- 数据源状态页简化为 bzzoiro 单源
前端:
- 采集页重构为任务驱动(比赛/积分榜/统计回填/全量)
- 新增「数据完整性」可视化页(覆盖率矩阵/字段完整率/健康摘要)
- 新增主站积分榜页(/standings)与比赛详情完整统计面板
- agent 名称同步更新(injuries→standings)
迁移 0015_bzzoiro_single_source 已在容器内验证通过,后端测试全部通过。
Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
This commit is contained in:
co-authored by
new-provider/LongCat-2.0 <
parent
ec8f36abb2
commit
f05dc1ae15
@@ -28,33 +28,21 @@ from src.core.runtime_config import (
|
||||
set_runtime_value,
|
||||
)
|
||||
from src.db.base import AsyncSession, get_db_read
|
||||
from src.db.models import Injury, Match, MatchStats
|
||||
from src.db.models import League, Match, MatchStats, Standing
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/admin", tags=["admin"], dependencies=[Depends(require_admin)])
|
||||
|
||||
# ── 数据源元数据 ────────────────────────────────────────────────
|
||||
# ── 数据源元数据(bzzoiro 单一数据源) ────────────────────────────
|
||||
|
||||
_SOURCES: list[dict] = [
|
||||
{
|
||||
"name": "bzzoiro",
|
||||
"label": "Bzzoiro",
|
||||
"description": "历史赛程与比分数据,覆盖全球主要联赛",
|
||||
"description": "唯一数据源:赛程比分 + 积分榜 + 比赛详细统计(xG/射门/控球等)",
|
||||
"setting_keys": ["BZZOIRO_KEY", "BZZOIRO_BASE"],
|
||||
},
|
||||
{
|
||||
"name": "understat",
|
||||
"label": "Understat",
|
||||
"description": "xG(预期进球)进阶数据,无需 API Key,网页抓取",
|
||||
"setting_keys": [],
|
||||
},
|
||||
{
|
||||
"name": "injuries",
|
||||
"label": "Injuries (API-Football)",
|
||||
"description": "球员伤停信息,用于预测时考虑阵容完整性",
|
||||
"setting_keys": ["API_FOOTBALL_KEY"],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -63,9 +51,7 @@ class SettingUpdateIn(BaseModel):
|
||||
|
||||
|
||||
async def _last_ingestion(db: AsyncSession, source: str) -> datetime | None:
|
||||
"""各源最近一次采集时间(取自数据血缘字段,无记录返回 None)。"""
|
||||
if source == "injuries":
|
||||
return (await db.execute(select(func.max(Injury.retrieved_at)))).scalar()
|
||||
"""源最近一次采集时间(取自数据血缘字段,无记录返回 None)。"""
|
||||
return (
|
||||
await db.execute(
|
||||
select(func.max(MatchStats.retrieved_at)).where(MatchStats.source == source)
|
||||
@@ -303,20 +289,7 @@ async def test_datasource(name: str):
|
||||
params={"date_from": today, "date_to": today},
|
||||
)
|
||||
|
||||
if name == "understat":
|
||||
return await _probe(
|
||||
"https://understat.com/league/EPL/2025",
|
||||
headers={"User-Agent": "Mozilla/5.0", "Accept": "text/html"},
|
||||
)
|
||||
|
||||
# injuries (api-football)
|
||||
api_key = await get_runtime_value("API_FOOTBALL_KEY")
|
||||
if not api_key:
|
||||
return {"ok": False, "status": None, "latency_ms": 0, "detail": "API_FOOTBALL_KEY 未配置"}
|
||||
return await _probe(
|
||||
"https://v3.football.api-sports.io/status",
|
||||
headers={"x-apisports-key": api_key},
|
||||
)
|
||||
raise HTTPException(404, f"未知数据源: {name}")
|
||||
|
||||
|
||||
# ── 数据源健康/最近采集状态(只读,不触发采集) ──────────────────────
|
||||
@@ -324,16 +297,12 @@ async def test_datasource(name: str):
|
||||
|
||||
@router.get("/ingest/status")
|
||||
async def ingest_status(db: AsyncSession = Depends(get_db_read)):
|
||||
"""各数据源采集健康概览(只读,不触发任何采集)。
|
||||
|
||||
返回尽量可得的信息;基于现有表近似的数据会标明 approximation。
|
||||
失败追踪目前依赖系统日志缓冲,无专用采集失败表。
|
||||
"""
|
||||
# ── bzzoiro: 落库目标是 matches 表,无专用采集时间戳 ──
|
||||
# 近似:以 matches 表最大 match_date(已覆盖的最远比赛日) 与 created_at 作为参考
|
||||
"""数据源采集健康概览(bzzoiro 单源;只读,不触发任何采集)。"""
|
||||
bzzoiro_key = await get_runtime_value("BZZOIRO_KEY")
|
||||
bzzoiro_base = await get_runtime_value("BZZOIRO_BASE")
|
||||
row = (
|
||||
|
||||
# 比赛覆盖
|
||||
match_row = (
|
||||
await db.execute(
|
||||
select(
|
||||
func.count().label("cnt"),
|
||||
@@ -342,68 +311,39 @@ async def ingest_status(db: AsyncSession = Depends(get_db_read)):
|
||||
).where(Match.match_status == "finished")
|
||||
)
|
||||
).one()
|
||||
# 统计覆盖(精确 retrieved_at)
|
||||
stats_row = (
|
||||
await db.execute(
|
||||
select(
|
||||
func.count().label("cnt"),
|
||||
func.max(MatchStats.retrieved_at).label("latest_retrieved"),
|
||||
).where(MatchStats.source == "bzzoiro")
|
||||
)
|
||||
).one()
|
||||
# 积分榜覆盖
|
||||
standings_row = (
|
||||
await db.execute(select(func.count()).select_from(Standing))
|
||||
).scalar()
|
||||
|
||||
bzzoiro = {
|
||||
"name": "bzzoiro",
|
||||
"label": "Bzzoiro",
|
||||
"key_configured": bool(bzzoiro_key),
|
||||
"base_url": (bzzoiro_base.rstrip("/") if bzzoiro_base else None) or settings.BZZOIRO_BASE,
|
||||
"reachable": None, # 不主动探测
|
||||
"last_success_at": row.latest_row_at.isoformat() if row.latest_row_at else None,
|
||||
"latest_match_date": row.latest_match_date.isoformat() if row.latest_match_date else None,
|
||||
"recent_count": row.cnt or 0,
|
||||
"note": "approx:基于 matches.finished 表,last_success_at 为行写入时间而非精确采集完成时间",
|
||||
"last_success_at": (stats_row.latest_retrieved or match_row.latest_row_at),
|
||||
"last_success_at_iso": (
|
||||
stats_row.latest_retrieved or match_row.latest_row_at
|
||||
).isoformat() if (stats_row.latest_retrieved or match_row.latest_row_at) else None,
|
||||
"latest_match_date": match_row.latest_match_date.isoformat() if match_row.latest_match_date else None,
|
||||
"recent_count": match_row.cnt or 0,
|
||||
"stats_count": stats_row.cnt or 0,
|
||||
"standings_count": standings_row or 0,
|
||||
"note": "last_success_at 取 match_stats.retrieved_at(统计回填)与 matches.created_at(比赛行)的较大者",
|
||||
"last_failure": _last_failure_log("bzzoiro"),
|
||||
}
|
||||
|
||||
# ── understat: 落库到 match_stats(source=understat),有精确 retrieved_at ──
|
||||
row = (
|
||||
await db.execute(
|
||||
select(
|
||||
func.count().label("cnt"),
|
||||
func.max(MatchStats.retrieved_at).label("latest_retrieved"),
|
||||
).where(MatchStats.source == "understat")
|
||||
)
|
||||
).one()
|
||||
understat = {
|
||||
"name": "understat",
|
||||
"label": "Understat",
|
||||
"key_configured": True, # 无需 Key
|
||||
"reachable": None,
|
||||
"last_success_at": row.latest_retrieved.isoformat() if row.latest_retrieved else None,
|
||||
"recent_count": row.cnt or 0,
|
||||
"note": "基于 match_stats.source=understat 的 retrieved_at",
|
||||
"last_failure": _last_failure_log("understat"),
|
||||
}
|
||||
|
||||
# ── injuries: 落库到 injuries 表,有精确 retrieved_at;区分 Key/无数据/有数据 ──
|
||||
api_key = await get_runtime_value("API_FOOTBALL_KEY")
|
||||
row = (
|
||||
await db.execute(
|
||||
select(
|
||||
func.count().label("cnt"),
|
||||
func.max(Injury.retrieved_at).label("latest_retrieved"),
|
||||
)
|
||||
)
|
||||
).one()
|
||||
if not api_key:
|
||||
injuries_status, injuries_note = "key_not_configured", "API_FOOTBALL_KEY 未配置"
|
||||
elif not row.cnt:
|
||||
injuries_status, injuries_note = "no_data", "本地无伤停数据,请先采集"
|
||||
else:
|
||||
injuries_status, injuries_note = "has_data", f"共 {row.cnt} 条伤停记录"
|
||||
injuries = {
|
||||
"name": "injuries",
|
||||
"label": "Injuries (API-Football)",
|
||||
"key_configured": bool(api_key),
|
||||
"reachable": None,
|
||||
"status": injuries_status,
|
||||
"last_success_at": row.latest_retrieved.isoformat() if row.latest_retrieved else None,
|
||||
"recent_count": row.cnt or 0,
|
||||
"note": injuries_note,
|
||||
"last_failure": _last_failure_log("injuries"),
|
||||
}
|
||||
|
||||
return {"sources": [bzzoiro, understat, injuries]}
|
||||
return {"sources": [bzzoiro]}
|
||||
|
||||
|
||||
def _last_failure_log(source: str) -> dict | None:
|
||||
@@ -437,3 +377,130 @@ 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}}
|
||||
|
||||
|
||||
# ── 数据完整性分析(可视化数据源) ────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/data-completeness")
|
||||
async def data_completeness(db: AsyncSession = Depends(get_db_read)):
|
||||
"""按联赛统计数据完整性:比赛覆盖、字段覆盖、积分榜覆盖。
|
||||
|
||||
前端「数据完整性」页据此渲染,回答三个问题:
|
||||
1. 数据是否齐全(各联赛比赛/统计/积分榜量级)
|
||||
2. 字段是否齐全(每张统计表各字段非空率)
|
||||
3. 覆盖是否新鲜(最近一场/最近一次采集)
|
||||
"""
|
||||
from src.data.config import BZZOIRO_LEAGUE_IDS, LEAGUE_NAMES, LEAGUE_COUNTRIES
|
||||
|
||||
out_leagues: list[dict] = []
|
||||
for code, bzz_id in BZZOIRO_LEAGUE_IDS.items():
|
||||
# 比赛覆盖
|
||||
m = (
|
||||
await db.execute(
|
||||
select(
|
||||
func.count().label("total"),
|
||||
func.count().filter(Match.match_status == "finished").label("finished"),
|
||||
func.count().filter(Match.match_status == "scheduled").label("scheduled"),
|
||||
func.count().filter(Match.source_event_id.is_not(None)).label("with_source_id"),
|
||||
func.max(Match.match_date).label("latest_match"),
|
||||
func.min(Match.match_date).label("earliest_match"),
|
||||
)
|
||||
.select_from(Match)
|
||||
.join(League, League.id == Match.league_id)
|
||||
.where(League.code == code)
|
||||
)
|
||||
).one()
|
||||
# 统计字段覆盖(联表 matches)
|
||||
s = (
|
||||
await db.execute(
|
||||
select(
|
||||
func.count().label("rows"),
|
||||
func.count(MatchStats.home_xg).label("xg"),
|
||||
func.count(MatchStats.home_shots).label("shots"),
|
||||
func.count(MatchStats.home_possession).label("possession"),
|
||||
func.count(MatchStats.home_corners).label("corners"),
|
||||
func.count(MatchStats.home_fouls).label("fouls"),
|
||||
func.count(MatchStats.home_big_chances).label("big_chances"),
|
||||
func.count(MatchStats.home_yellow_cards).label("cards"),
|
||||
)
|
||||
.select_from(MatchStats)
|
||||
.join(Match, Match.id == MatchStats.match_id)
|
||||
.join(League, League.id == Match.league_id)
|
||||
.where(League.code == code)
|
||||
)
|
||||
).one()
|
||||
# 积分榜覆盖
|
||||
st = (
|
||||
await db.execute(
|
||||
select(
|
||||
func.count().label("rows"),
|
||||
func.max(Standing.retrieved_at).label("latest_retrieved"),
|
||||
)
|
||||
.select_from(Standing)
|
||||
.join(League, League.id == Standing.league_id)
|
||||
.where(League.code == code)
|
||||
)
|
||||
).one()
|
||||
|
||||
stats_rows = s.rows or 0
|
||||
pct = lambda n: round(n / stats_rows * 100, 1) if stats_rows else 0.0 # noqa: E731
|
||||
out_leagues.append(
|
||||
{
|
||||
"code": code,
|
||||
"name": LEAGUE_NAMES.get(code, code),
|
||||
"country": LEAGUE_COUNTRIES.get(code),
|
||||
"matches": {
|
||||
"total": m.total or 0,
|
||||
"finished": m.finished or 0,
|
||||
"scheduled": m.scheduled or 0,
|
||||
"with_source_id": m.with_source_id or 0,
|
||||
"earliest_match": m.earliest_match.isoformat() if m.earliest_match else None,
|
||||
"latest_match": m.latest_match.isoformat() if m.latest_match else None,
|
||||
},
|
||||
"stats": {
|
||||
"rows": stats_rows,
|
||||
"fields": {
|
||||
"xg": {"count": s.xg or 0, "pct": pct(s.xg or 0)},
|
||||
"shots": {"count": s.shots or 0, "pct": pct(s.shots or 0)},
|
||||
"possession": {"count": s.possession or 0, "pct": pct(s.possession or 0)},
|
||||
"corners": {"count": s.corners or 0, "pct": pct(s.corners or 0)},
|
||||
"fouls": {"count": s.fouls or 0, "pct": pct(s.fouls or 0)},
|
||||
"big_chances": {"count": s.big_chances or 0, "pct": pct(s.big_chances or 0)},
|
||||
"cards": {"count": s.cards or 0, "pct": pct(s.cards or 0)},
|
||||
},
|
||||
},
|
||||
"standings": {
|
||||
"rows": st.rows or 0,
|
||||
"latest_retrieved": st.latest_retrieved.isoformat() if st.latest_retrieved else None,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# 整体健康信号
|
||||
total_finished = sum(l["matches"]["finished"] for l in out_leagues)
|
||||
total_stats = sum(l["stats"]["rows"] for l in out_leagues)
|
||||
stats_coverage = round(total_stats / total_finished * 100, 1) if total_finished else 0.0
|
||||
issues: list[str] = []
|
||||
for l in out_leagues:
|
||||
if l["matches"]["finished"] == 0:
|
||||
issues.append(f"{l['name']}: 无已完赛比赛,请先运行「比赛数据」采集")
|
||||
elif l["stats"]["rows"] == 0:
|
||||
issues.append(f"{l['name']}: 已完赛 {l['matches']['finished']} 场但无统计回填,请运行「统计回填」采集")
|
||||
elif stats_coverage < 80:
|
||||
issues.append(f"{l['name']}: 统计覆盖率仅 {stats_coverage}%,建议增量回填")
|
||||
if l["standings"]["rows"] == 0:
|
||||
issues.append(f"{l['name']}: 无积分榜数据,请运行「积分榜」采集")
|
||||
if not issues:
|
||||
issues.append("各联赛数据完整度良好")
|
||||
|
||||
return {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"leagues": out_leagues,
|
||||
"totals": {
|
||||
"finished_matches": total_finished,
|
||||
"stats_rows": total_stats,
|
||||
"stats_coverage_pct": stats_coverage,
|
||||
},
|
||||
"issues": issues,
|
||||
}
|
||||
|
||||
+60
-87
@@ -1,4 +1,11 @@
|
||||
"""采集路由。"""
|
||||
"""采集路由(bzzoiro 单一数据源)。
|
||||
|
||||
任务类型:
|
||||
events — 比赛日程/比分(/events/)
|
||||
standings — 联赛积分榜(/leagues/{id}/standings/)
|
||||
stats — 已完赛比赛详细统计回填(/events/{id}/stats/)
|
||||
all — 依次执行以上三项
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
@@ -7,10 +14,10 @@ import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from src.api.deps import require_admin
|
||||
from src.api.schemas import IngestBzzoiroRequest, IngestResponse, IngestUnderstatRequest, IngestInjuriesRequest, IngestSimpleResponse
|
||||
from src.data.config import BZZOIRO_LEAGUE_IDS, FDCO_TO_UNDERSTAT
|
||||
from src.api.schemas import IngestBzzoiroRequest
|
||||
from src.data.config import BZZOIRO_LEAGUE_IDS
|
||||
from src.data.bzzoiro import ingest_bzzoiro_event_stats, ingest_bzzoiro_standings
|
||||
from src.data.sources import get_source
|
||||
from src.data.injuries import ingest_injuries
|
||||
from src.db.unit_of_work import get_uow
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -20,6 +27,8 @@ router = APIRouter(prefix="/api/v1", tags=["ingest"])
|
||||
# 后台采集任务注册表:持强引用防止被 GC
|
||||
_background_tasks: set[asyncio.Task] = set()
|
||||
|
||||
VALID_TASKS = {"events", "standings", "stats", "all"}
|
||||
|
||||
|
||||
def _spawn(coro) -> None:
|
||||
"""启动后台采集任务;异常已在任务内记录到系统日志。"""
|
||||
@@ -30,96 +39,60 @@ def _spawn(coro) -> None:
|
||||
|
||||
@router.post("/ingest/bzzoiro", dependencies=[Depends(require_admin)])
|
||||
async def ingest_bzzoiro_route(req: IngestBzzoiroRequest):
|
||||
"""触发 bzzoiro 采集。"""
|
||||
# 未指定联赛 = 采集全部已知联赛;未指定状态 = 已完赛 + 未开赛都采集
|
||||
"""触发 bzzoiro 采集(events / standings / stats / all)。"""
|
||||
if req.task not in VALID_TASKS:
|
||||
raise HTTPException(status_code=422, detail=f"未知任务类型: {req.task}(可选: {', '.join(sorted(VALID_TASKS))})")
|
||||
leagues = req.leagues or list(BZZOIRO_LEAGUE_IDS.keys())
|
||||
statuses = [req.status] if req.status else ["finished", "scheduled"]
|
||||
_spawn(_run_bzzoiro(leagues, req.date_from, req.date_to, statuses))
|
||||
task_label = {"events": "比赛数据", "standings": "积分榜", "stats": "统计回填", "all": "全量(比赛+积分榜+统计)"}[req.task]
|
||||
_spawn(_run_bzzoiro(req.task, leagues, req))
|
||||
return {
|
||||
"ok": True,
|
||||
"message": f"采集任务已启动(后台执行,状态: {', '.join(statuses)}),请在「系统日志」查看进度与结果",
|
||||
"message": f"采集任务已启动(后台执行,任务: {task_label}),请在「系统日志」查看进度与结果",
|
||||
}
|
||||
|
||||
|
||||
async def _run_bzzoiro(leagues: list[str], date_from: str | None, date_to: str | None, statuses: list[str]) -> None:
|
||||
async def _run_bzzoiro(task: str, leagues: list[str], req: IngestBzzoiroRequest) -> None:
|
||||
"""后台执行 bzzoiro 采集:上游限速时单次可能耗时数分钟,必须脱离请求生命周期。"""
|
||||
try:
|
||||
source = get_source("bzzoiro")
|
||||
merged: dict = {"leagues": {}, "total_inserted": 0, "total_updated": 0, "errors": []}
|
||||
async with get_uow() as session:
|
||||
for st in statuses:
|
||||
r = await source.ingest(
|
||||
session,
|
||||
leagues=leagues,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
status=st,
|
||||
if task in ("events", "all"):
|
||||
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:
|
||||
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():
|
||||
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", []))
|
||||
logger.info(
|
||||
"bzzoiro 比赛采集完成: 新增 %d, 更新 %d, 联赛 %d 个, 状态 %s",
|
||||
merged["total_inserted"], merged["total_updated"], len(merged["leagues"]), statuses,
|
||||
)
|
||||
if merged["errors"]:
|
||||
logger.warning("bzzoiro 比赛采集错误 %d 条: %s", len(merged["errors"]), merged["errors"][:3])
|
||||
|
||||
if task in ("standings", "all"):
|
||||
async with get_uow() as session:
|
||||
r = await ingest_bzzoiro_standings(session, leagues=leagues, season=req.season)
|
||||
if r["errors"]:
|
||||
logger.warning("bzzoiro 积分榜采集部分失败: %s", r["errors"][:3])
|
||||
else:
|
||||
logger.info("bzzoiro 积分榜采集完成: upsert %d 条", r["total_upserted"])
|
||||
|
||||
if task in ("stats", "all"):
|
||||
async with get_uow() as session:
|
||||
r = await ingest_bzzoiro_event_stats(
|
||||
session, leagues=leagues, limit=req.limit, only_missing=True
|
||||
)
|
||||
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():
|
||||
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", []))
|
||||
league_errors = {c: stat["errors"] for c, stat in merged["leagues"].items() if stat.get("errors")}
|
||||
logger.info(
|
||||
"bzzoiro 采集完成: 新增 %d, 更新 %d, 联赛 %d 个, 状态 %s",
|
||||
merged["total_inserted"], merged["total_updated"], len(merged["leagues"]), statuses,
|
||||
)
|
||||
if league_errors:
|
||||
sample = {c: errs[:1] for c, errs in list(league_errors.items())[:3]}
|
||||
logger.warning("bzzoiro 部分联赛存在错误: %s", sample)
|
||||
if merged["errors"]:
|
||||
logger.warning("bzzoiro 采集错误 %d 条: %s", len(merged["errors"]), merged["errors"][:3])
|
||||
logger.debug("bzzoiro 采集明细: leagues=%s", list(merged["leagues"].keys()))
|
||||
if r["errors"]:
|
||||
logger.warning("bzzoiro 统计回填错误 %d 条: %s", len(r["errors"]), r["errors"][:3])
|
||||
except Exception:
|
||||
logger.exception("bzzoiro 采集任务失败")
|
||||
|
||||
|
||||
@router.post("/ingest/understat", dependencies=[Depends(require_admin)])
|
||||
async def ingest_understat_route(req: IngestUnderstatRequest):
|
||||
"""触发 understat xG 回填。"""
|
||||
leagues_to_run = [req.league] if req.league else list(FDCO_TO_UNDERSTAT.keys())
|
||||
_spawn(_run_understat(leagues_to_run, req.season))
|
||||
return {"ok": True, "message": "xG 回填任务已启动(后台执行),请在「系统日志」查看结果"}
|
||||
|
||||
|
||||
async def _run_understat(leagues_to_run: list[str], season: int) -> None:
|
||||
try:
|
||||
source = get_source("understat")
|
||||
merged: dict = {"count": 0, "updated": 0, "skipped": 0, "unmatched": 0, "errors": []}
|
||||
async with get_uow() as session:
|
||||
for league in leagues_to_run:
|
||||
r = await source.ingest(session, league=league, season=season)
|
||||
for k in ("count", "updated", "skipped", "unmatched"):
|
||||
merged[k] += r.get(k, 0)
|
||||
merged["errors"].extend(r.get("errors", []))
|
||||
logger.info(
|
||||
"understat 回填完成: 联赛 %d 个, 更新 %d, 未匹配 %d, 错误 %d",
|
||||
len(leagues_to_run), merged["updated"], merged["unmatched"], len(merged["errors"]),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("understat 回填任务失败")
|
||||
|
||||
|
||||
@router.post("/ingest/injuries", dependencies=[Depends(require_admin)])
|
||||
async def ingest_injuries_route(req: IngestInjuriesRequest):
|
||||
"""触发伤停采集。"""
|
||||
_spawn(_run_injuries(req.date))
|
||||
return {"ok": True, "message": "伤停采集任务已启动(后台执行),请在「系统日志」查看结果"}
|
||||
|
||||
|
||||
async def _run_injuries(date: str | None) -> None:
|
||||
try:
|
||||
async with get_uow() as session:
|
||||
result = await ingest_injuries(session, date=date)
|
||||
logger.info(
|
||||
"injuries 采集完成: 新增 %d, 更新 %d, 错误 %d",
|
||||
result.get("count", 0), result.get("updated", 0), len(result.get("errors", [])),
|
||||
)
|
||||
if result.get("errors"):
|
||||
logger.warning("injuries 采集错误: %s", result["errors"][:3])
|
||||
except Exception:
|
||||
logger.exception("injuries 采集任务失败")
|
||||
logger.exception("bzzoiro 采集任务失败(task=%s)", task)
|
||||
|
||||
@@ -10,11 +10,28 @@ from sqlalchemy.orm import selectinload
|
||||
from src.api.deps import require_admin
|
||||
from src.api.schemas import MatchListOut, MatchOut, PredictionOut
|
||||
from src.db.base import AsyncSession, get_db_read
|
||||
from src.db.models import League, Match, Prediction
|
||||
from src.db.models import League, Match, Prediction, Standing
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["data"])
|
||||
|
||||
|
||||
def _stats_dict(stats) -> dict | None:
|
||||
"""把 MatchStats ORM 对象序列化为前端可读的扁平 dict。"""
|
||||
if stats is None:
|
||||
return None
|
||||
return {
|
||||
"home_xg": stats.home_xg, "away_xg": stats.away_xg,
|
||||
"home_shots": stats.home_shots, "away_shots": stats.away_shots,
|
||||
"home_shots_on_target": stats.home_shots_on_target, "away_shots_on_target": stats.away_shots_on_target,
|
||||
"home_corners": stats.home_corners, "away_corners": stats.away_corners,
|
||||
"home_possession": stats.home_possession,
|
||||
"home_yellow_cards": stats.home_yellow_cards, "away_yellow_cards": stats.away_yellow_cards,
|
||||
"home_red_cards": stats.home_red_cards, "away_red_cards": stats.away_red_cards,
|
||||
"home_big_chances": stats.home_big_chances, "away_big_chances": stats.away_big_chances,
|
||||
"home_fouls": stats.home_fouls, "away_fouls": stats.away_fouls,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/leagues", response_model=list[dict], dependencies=[Depends(require_admin)])
|
||||
async def list_leagues(db: AsyncSession = Depends(get_db_read)):
|
||||
stmt = select(League).order_by(League.name)
|
||||
@@ -155,6 +172,7 @@ async def get_match(match_id: int, db: AsyncSession = Depends(get_db_read)):
|
||||
match_stage=m.match_stage,
|
||||
home_xg=m.stats.home_xg if m.stats else None,
|
||||
away_xg=m.stats.away_xg if m.stats else None,
|
||||
stats=_stats_dict(m.stats) if m.stats else None,
|
||||
recent_predictions=[
|
||||
PredictionOut(
|
||||
id=p.id, match_id=p.match_id, provider=p.provider, model=p.model,
|
||||
@@ -246,3 +264,81 @@ async def match_context(match_id: int, db: AsyncSession = Depends(get_db_read)):
|
||||
"away_recent": [_row_to_dict(r) for r in away_recent],
|
||||
"h2h": [_row_to_dict(r) for r in h2h],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/standings")
|
||||
async def list_standings(
|
||||
league: str | None = Query(None, description="联赛代码,如 E0;空 = 全部联赛"),
|
||||
season: str | None = Query(None, description="赛季标签,如 2026-2027;空 = 各联赛最新赛季"),
|
||||
db: AsyncSession = Depends(get_db_read),
|
||||
):
|
||||
"""联赛积分榜(只读)。按联赛分组,每张榜按 position 排序。
|
||||
|
||||
season 为空时返回每个联赛最新采集到的赛季榜单(适合前端"查看最新积分榜")。
|
||||
"""
|
||||
# 取每个联赛最新赛季(当 season 为空时)
|
||||
latest_seasons: dict[int, str] = {}
|
||||
if season is None:
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(Standing.league_id, func.max(Standing.season).label("latest"))
|
||||
.group_by(Standing.league_id)
|
||||
)
|
||||
).all()
|
||||
latest_seasons = {r.league_id: r.latest for r in rows}
|
||||
|
||||
q = (
|
||||
select(Standing, League)
|
||||
.join(League, League.id == Standing.league_id)
|
||||
.order_by(League.name.asc(), Standing.position.asc())
|
||||
)
|
||||
if league:
|
||||
q = q.where(League.code == league)
|
||||
if season:
|
||||
q = q.where(Standing.season == season)
|
||||
else:
|
||||
# 多联赛时只保留各联赛最新赛季
|
||||
if latest_seasons:
|
||||
q = q.where(
|
||||
or_(
|
||||
*(
|
||||
(Standing.league_id == lid) & (Standing.season == ls)
|
||||
for lid, ls in latest_seasons.items()
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
rows = (await db.execute(q)).all()
|
||||
|
||||
# 按联赛分组
|
||||
grouped: dict[str, dict] = {}
|
||||
for standing, lg in rows:
|
||||
key = lg.code
|
||||
if key not in grouped:
|
||||
grouped[key] = {
|
||||
"league_code": lg.code,
|
||||
"league_name": lg.name,
|
||||
"season": standing.season,
|
||||
"retrieved_at": standing.retrieved_at.isoformat() if standing.retrieved_at else None,
|
||||
"rows": [],
|
||||
}
|
||||
grouped[key]["rows"].append(
|
||||
{
|
||||
"position": standing.position,
|
||||
"team": standing.team.name_zh or standing.team.name if standing.team else "?",
|
||||
"team_en": standing.team.name if standing.team else "?",
|
||||
"played": standing.played,
|
||||
"won": standing.won,
|
||||
"drawn": standing.drawn,
|
||||
"lost": standing.lost,
|
||||
"goals_for": standing.goals_for,
|
||||
"goals_against": standing.goals_against,
|
||||
"goal_diff": standing.goal_diff,
|
||||
"points": standing.points,
|
||||
"xg_for": standing.xg_for,
|
||||
"xg_against": standing.xg_against,
|
||||
"form": standing.form,
|
||||
"zone": standing.zone,
|
||||
}
|
||||
)
|
||||
return {"leagues": list(grouped.values())}
|
||||
|
||||
Reference in New Issue
Block a user