"""后台管理:管理区统计、数据完整性分析、数据质量检查。 所有接口需管理员鉴权(require_admin)。路由前缀 /api/v1/admin。 """ from __future__ import annotations import logging from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Depends from sqlalchemy import func, select from src.api.deps import require_admin from src.db.base import AsyncSession, get_db_read from src.db.models import DataQualityCheck, IngestFailure, League, Match, MatchStats, Prediction, Standing logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/v1/admin", tags=["admin"], dependencies=[Depends(require_admin)]) @router.get("/stats") async def admin_stats(db: AsyncSession = Depends(get_db_read)): """管理区统计(只读):预测次数 + 比赛覆盖。轻量聚合,无 LLM 调用。""" day_ago = datetime.now(timezone.utc) - timedelta(days=1) week_ago = datetime.now(timezone.utc) - timedelta(days=7) r = ( await db.execute( select( func.count().label("total"), func.count().filter(Prediction.created_at >= day_ago).label("last_24h"), func.count().filter(Prediction.created_at >= week_ago).label("last_7d"), ) ) ).one() # 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}, } # ── 数据完整性分析(可视化数据源) ──────────────────────────────── @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, } # ── 数据质量检查 API ──────────────────────────────────────────── @router.get("/data-quality") async def data_quality_checks(db: AsyncSession = Depends(get_db_read)): """数据质量检查结果(只读)。""" # 最近的失败记录 failures = ( await db.execute( select(IngestFailure) .where(IngestFailure.status.in_(["pending", "retrying"])) .order_by(IngestFailure.created_at.desc()) .limit(20) ) ).scalars().all() # 最近的质量检查 checks = ( await db.execute( select(DataQualityCheck) .order_by(DataQualityCheck.checked_at.desc()) .limit(20) ) ).scalars().all() return { "failures": [ { "id": f.id, "source": f.source_system, "entity_type": f.entity_type, "source_record_id": f.source_record_id, "error_type": f.error_type, "error_detail": f.error_detail, "retry_count": f.retry_count, "status": f.status, "created_at": f.created_at.isoformat() if f.created_at else None, } for f in failures ], "checks": [ { "id": c.id, "check_name": c.check_name, "entity_type": c.entity_type, "passed": c.passed, "severity": c.severity, "detail": c.detail, "checked_at": c.checked_at.isoformat() if c.checked_at else None, } for c in checks ], } @router.post("/data-quality/run") async def run_data_quality_check(db: AsyncSession = Depends(get_db_read)): """手动触发一次数据质量检查。""" checks = [] # 检查1: 已完赛但无统计的比赛 finished_no_stats = ( await db.execute( select(func.count()) .select_from(Match) .outerjoin(MatchStats, Match.id == MatchStats.match_id) .where(Match.match_status == "finished") .where(MatchStats.id.is_(None)) ) ).scalar() or 0 checks.append(DataQualityCheck( check_name="finished_without_stats", entity_type="match", actual_value=float(finished_no_stats), passed=finished_no_stats == 0, severity="warning" if finished_no_stats > 0 else "info", detail={"message": f"{finished_no_stats} 场已完赛比赛缺少统计数据"}, )) # 检查2: 积分榜缺失的联赛 leagues_without_standings = ( await db.execute( select(func.count()) .select_from(League) .outerjoin(Standing, League.id == Standing.league_id) .where(Standing.id.is_(None)) ) ).scalar() or 0 checks.append(DataQualityCheck( check_name="league_without_standings", entity_type="league", actual_value=float(leagues_without_standings), passed=leagues_without_standings == 0, severity="warning" if leagues_without_standings > 0 else "info", detail={"message": f"{leagues_without_standings} 个联赛缺少积分榜"}, )) for c in checks: db.add(c) await db.commit() return {"ok": True, "checks": [{"name": c.check_name, "passed": c.passed} for c in checks]} # ── 近似重名候选(只读,启发式,不做自动合并) ────────────────────── @router.get("/team-name-duplicates") async def team_name_duplicates(db: AsyncSession = Depends(get_db_read)): """只读列出近似重名候选(大小写变体/子串包含/前缀碰撞)。 启发式规则(命中任一即列为候选): - 大小写变体: lower(name) 相同但 name 不同 - 子串包含: A 是 B 的子串且 len(A) ≥ 5 - 前缀碰撞: 前 8 字符相同(忽略大小写) 仅作排查参考,合并需走人工 SQL(见 docs/05-data.md)。 """ teams = (await db.execute(select(Team.id, Team.name))).all() by_lower: dict[str, list[dict]] = {} for t in teams: key = (t.name or "").lower() by_lower.setdefault(key, []).append({"id": t.id, "name": t.name}) groups: list[dict] = [] # 规则1: 大小写变体(lower 相同但原名不同) for key, members in by_lower.items(): if len(members) > 1: groups.append({ "rule": "case_variant", "key": key, "members": members, }) # 规则2 & 3: 子串包含 / 前缀碰撞(仅在 lower 名不同的组间比较) distinct = [m for members in by_lower.values() for m in members] seen_pairs: set[tuple[int, int]] = set() for i, a in enumerate(distinct): na = (a["name"] or "").lower() for b in distinct[i + 1:]: nb = (b["name"] or "").lower() if na == nb: continue # 已被规则1覆盖 pair = (min(a["id"], b["id"]), max(a["id"], b["id"])) if pair in seen_pairs: continue hit = None if len(na) >= 5 and na in nb: hit = "substring" elif len(nb) >= 5 and nb in na: hit = "substring" elif len(na) >= 8 and len(nb) >= 8 and na[:8] == nb[:8]: hit = "prefix" if hit: seen_pairs.add(pair) groups.append({ "rule": hit, "members": [a, b], }) return { "count": len(groups), "hint": "命中任一启发式仅表示'可疑',合并前请人工确认是否同一球队", "groups": groups, }