- GET /matches/{id}/context 移除 require_admin:公开站详情页「近况/交锋」
数据来源,匿名 401 会导致前端静默空态;响应结构不变,不触发 LLM
- GET /leagues 改公开只读:公开站联赛筛选动态加载;仅返回
id/code/name/country 四个展示字段,不含配置或密钥信息
- 前端新增 useLeagues hook:优先请求 /api/v1/leagues,失败/空回退
本地五大联赛常量(已知代码保留中文标签,新联赛按 API 名称追加)
- 新增 tests/test_public_readonly_api.py(6 项):匿名 200/404、响应形状、
路由依赖声明检查(matches 路由无 require_admin)+ 判别力守卫
(ingest 路由必须检出 require_admin,防检测器恒真)
345 lines
13 KiB
Python
345 lines
13 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 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())}
|