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
@@ -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