Files
Profeto/src/api/routes/matches.py
T
shangfangjian 49d78136a1 fix(P0-02): 积分榜改为追加快照(append-only) + available_at cutoff
去掉 uq_standings_league_season_team,改为 (league, season, team, available_at) 唯一;
每次采集 INSERT 新行(available_at=now),ON CONFLICT DO NOTHING,不覆盖旧行。

standings_slice(before):DISTINCT ON (team_id) WHERE available_at<=cutoff
ORDER available_at DESC;before=None → cutoff=now()。
公开 list_standings 取每队最新可用快照(子查询 max available_at)。

迁移 0023 + 切片/路由/docs 同步;测试 test_p0_standings_cutoff(5/5)。
284 测试全绿。
2026-09-22 02:45:50 +08:00

375 lines
15 KiB
Python

"""比赛/联赛查询路由。"""
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 load_only, 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, MatchStats, Prediction, Standing, Team
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),
):
"""比赛列表(游标分页)。
加载策略(列表 vs 详情):
- 列表:仅 selectinload 序列化需要的 3 个关系 + stats,且用 load_only 限定列
(League.code / Team.name,name_zh / MatchStats.home_xg,away_xg),避免传输全列;
同时一次性加载 stats 消除 N+1(m.stats.home_xg 此前触发懒加载)。
- 详情(/matches/{id}):保持完整 options(league/teams/stats 全列 + 最近预测)。
"""
q = select(Match).options(
selectinload(Match.league).load_only(League.code),
selectinload(Match.home_team).load_only(Team.name, Team.name_zh),
selectinload(Match.away_team).load_only(Team.name, Team.name_zh),
selectinload(Match.stats).load_only(MatchStats.home_xg, MatchStats.away_xg),
)
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 排序。
P0-02: standings 为追加快照,公开接口取每队 available_at 最新快照
(league_id, season, team_id 上按 available_at 取最新)。
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}
# P0-02: 子查询取每队最新 available_at 快照,再 JOIN 回主表拿完整行 + League
latest_per_team = (
select(
Standing.league_id, Standing.season, Standing.team_id,
func.max(Standing.available_at).label("max_available"),
)
.group_by(Standing.league_id, Standing.season, Standing.team_id)
.subquery("latest_per_team")
)
q = (
select(Standing, League)
.join(League, League.id == Standing.league_id)
.join(
latest_per_team,
(Standing.league_id == latest_per_team.c.league_id)
& (Standing.season == latest_per_team.c.season)
& (Standing.team_id == latest_per_team.c.team_id)
& (Standing.available_at == latest_per_team.c.max_available),
)
.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())}