"""比赛/联赛查询路由。""" from __future__ import annotations from datetime import datetime, timedelta from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import func, or_, select from sqlalchemy.orm import selectinload 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, 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]) async def list_leagues(db: AsyncSession = Depends(get_db_read)): """联赛列表(公开只读,P1-3: 公开站联赛筛选需要;仅返回展示字段)。""" stmt = select(League).order_by(League.name) result = await db.execute(stmt) leagues = result.scalars().all() return [{"id": l.id, "code": l.code, "name": l.name, "country": l.country} for l in leagues] @router.get("/matches", response_model=MatchListOut) async def list_matches( league: str | None = None, status: str | None = None, date: str | None = None, cursor: str | None = None, limit: int = Query(50, ge=1, le=100), db: AsyncSession = Depends(get_db_read), ): """比赛列表(游标分页)。""" q = select(Match).options(selectinload(Match.league), selectinload(Match.home_team), selectinload(Match.away_team)) if cursor: try: # 用 | 分隔,避免 isoformat 含 _ 时解析失败 last_date_str, last_id_str = cursor.split("|", 1) last_date = datetime.fromisoformat(last_date_str) last_id = int(last_id_str) # 游标方向必须与排序方向一致: # - scheduled(ASC):取「更大」的未开赛场次 # - 其它(DESC):取「更小」的已赛场次 if status == "scheduled": q = q.where( (Match.match_date > last_date) | ((Match.match_date == last_date) & (Match.id > last_id)) ) else: q = q.where( (Match.match_date < last_date) | ((Match.match_date == last_date) & (Match.id < last_id)) ) except (ValueError, AttributeError): pass if league: stmt = select(League.id).where(League.code == league) league_id = (await db.execute(stmt)).scalar_one_or_none() if league_id is None: return MatchListOut(items=[], next_cursor=None, has_more=False) q = q.where(Match.league_id == league_id) if status: q = q.where(Match.match_status == status) if date: try: d = datetime.strptime(date, "%Y-%m-%d") except ValueError: raise HTTPException(400, "date 格式应为 YYYY-MM-DD") # date 是用户本地日期(默认北京 UTC+8);match_date 存 UTC,需转换: # 本地 00:00 (UTC+8) = UTC 前一天 16:00;本地 24:00 = UTC 当天 16:00 from datetime import timezone as tz_mod tz_cn = tz_mod(timedelta(hours=8)) local_start = d.replace(tzinfo=tz_cn) local_end = local_start + timedelta(days=1) q = q.where(Match.match_date >= local_start, Match.match_date < local_end) # 未开赛按日期正序(最近的排最前,便于预测);其余按日期倒序(最新赛果在前) if status == "scheduled": order = (Match.match_date.asc(), Match.id.asc()) else: order = (Match.match_date.desc(), Match.id.desc()) rows = (await db.execute(q.order_by(*order).limit(limit + 1))).scalars().all() has_more = len(rows) > limit rows = rows[:limit] items = [] for m in rows: items.append(MatchOut( id=m.id, league_code=m.league.code if m.league else None, season=m.season, home_team=m.home_team.name if m.home_team else "?", away_team=m.away_team.name if m.away_team else "?", home_team_zh=m.home_team.name_zh if m.home_team else None, away_team_zh=m.away_team.name_zh if m.away_team else None, match_date=m.match_date, match_status=m.match_status, home_goals=m.home_goals, away_goals=m.away_goals, 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, )) next_cursor = None if has_more and items: last = rows[-1] next_cursor = f"{last.match_date.isoformat()}|{last.id}" return MatchListOut(items=items, next_cursor=next_cursor, has_more=has_more) @router.get("/matches/{match_id}", response_model=MatchOut) async def get_match(match_id: int, db: AsyncSession = Depends(get_db_read)): stmt = ( select(Match) .options( selectinload(Match.league), selectinload(Match.home_team), selectinload(Match.away_team), selectinload(Match.stats), ) .where(Match.id == match_id) ) m = (await db.execute(stmt)).scalar_one_or_none() if m is None: raise HTTPException(404, "match not found") # 最近预测(倒序,最多 5 条)——复用 PredictionOut 结构,只读,不触发 LLM preds = ( await db.execute( select(Prediction) .where(Prediction.match_id == match_id) .order_by(Prediction.created_at.desc()) .limit(5) ) ).scalars().all() return MatchOut( id=m.id, league_code=m.league.code if m.league else None, season=m.season, home_team=m.home_team.name if m.home_team else "?", away_team=m.away_team.name if m.away_team else "?", home_team_zh=m.home_team.name_zh if m.home_team else None, away_team_zh=m.away_team.name_zh if m.away_team else None, match_date=m.match_date, match_status=m.match_status, home_goals=m.home_goals, away_goals=m.away_goals, 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, prompt_version=p.prompt_version, mode=p.mode or "single", pred_home_goals=p.pred_home_goals, pred_away_goals=p.pred_away_goals, alt_pred_home_goals=p.alt_pred_home_goals, alt_pred_away_goals=p.alt_pred_away_goals, pred_1x2=p.pred_1x2, subjective_confidence=p.subjective_confidence, reasoning=p.reasoning, status=p.status or "success", agent_outputs=p.agent_outputs, agent_weights=p.agent_weights, created_at=p.created_at, actual_home_goals=p.actual_home_goals, actual_away_goals=p.actual_away_goals, settled=p.settled, ) for p in preds ], ) @router.get("/matches/{match_id}/context") async def match_context(match_id: int, db: AsyncSession = Depends(get_db_read)): """比赛上下文(公开只读,P1-2: 公开站详情页需要;不触发 LLM):双方近况 + 历史交锋。 全部基于现有数据聚合: - recent_home / recent客队:该队最近 5 场已完赛(进球/结果) - h2h:双方最近 5 次交手 若数据不足,对应列表为空(前端展示空态)。 """ m = ( await db.execute( select(Match) .options(selectinload(Match.home_team), selectinload(Match.away_team)) .where(Match.id == match_id) ) ).scalar_one_or_none() if m is None: raise HTTPException(404, "match not found") home_id = m.home_team_id away_id = m.away_team_id def _row_to_dict(row): return { "match_date": row.match_date.isoformat() if row.match_date else None, "home_team": row.home_team.name_zh or row.home_team.name if row.home_team else None, "away_team": row.away_team.name_zh or row.away_team.name if row.away_team else None, "home_goals": row.home_goals, "away_goals": row.away_goals, } # 主队近况(已完赛,含主/客场) home_recent = ( await db.execute( select(Match) .where(Match.match_status == "finished", Match.home_team_id == home_id) .order_by(Match.match_date.desc()) .limit(5) .options(selectinload(Match.home_team), selectinload(Match.away_team)) ) ).scalars().all() # 客队近况 away_recent = ( await db.execute( select(Match) .where(Match.match_status == "finished", Match.away_team_id == away_id) .order_by(Match.match_date.desc()) .limit(5) .options(selectinload(Match.home_team), selectinload(Match.away_team)) ) ).scalars().all() # 历史交锋(双方已完赛) h2h = ( await db.execute( select(Match) .where( Match.match_status == "finished", or_( (Match.home_team_id == home_id) & (Match.away_team_id == away_id), (Match.home_team_id == away_id) & (Match.away_team_id == home_id), ), ) .order_by(Match.match_date.desc()) .limit(5) .options(selectinload(Match.home_team), selectinload(Match.away_team)) ) ).scalars().all() return { "home_recent": [_row_to_dict(r) for r in home_recent], "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())}