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:
shangfangjian
2026-09-20 19:13:07 +08:00
co-authored by new-provider/LongCat-2.0 <
parent ec8f36abb2
commit f05dc1ae15
41 changed files with 1603 additions and 1885 deletions
+39 -29
View File
@@ -1,4 +1,7 @@
"""6 张表 ORM: leagues / teams / matches / match_stats / predictions / injuries。"""
"""ORM 模型: leagues / teams / matches / match_stats / standings / predictions。
数据源统一为 bzzoiro(单一数据源),伤停(injuries)与 Understat 已移除。
"""
from __future__ import annotations
from datetime import date, datetime, timezone
@@ -16,7 +19,6 @@ from sqlalchemy import (
String,
Text,
UniqueConstraint,
and_,
func,
)
from sqlalchemy.dialects.postgresql import JSONB
@@ -75,6 +77,8 @@ class Match(Base):
home_ht_goals: Mapped[int | None] = mapped_column(Integer)
away_ht_goals: Mapped[int | None] = mapped_column(Integer)
match_stage: Mapped[str | None] = mapped_column(String(100))
# 数据血缘:bzzoiro 上游事件 ID,用于 /events/{id}/stats/ 统计回填
source_event_id: Mapped[int | None] = mapped_column(BigInteger, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
@@ -127,6 +131,11 @@ class MatchStats(Base):
away_yellow_cards: Mapped[int | None] = mapped_column(Integer)
home_red_cards: Mapped[int | None] = mapped_column(Integer)
away_red_cards: Mapped[int | None] = mapped_column(Integer)
# bzzoiro /events/{id}/stats/ 扩展字段
home_big_chances: Mapped[int | None] = mapped_column(Integer)
away_big_chances: Mapped[int | None] = mapped_column(Integer)
home_fouls: Mapped[int | None] = mapped_column(Integer)
away_fouls: Mapped[int | None] = mapped_column(Integer)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
# 数据血缘:追踪统计数据的来源和可用时间
source: Mapped[str | None] = mapped_column(String(30)) # bzzoiro / understat
@@ -146,39 +155,40 @@ class MatchStats(Base):
)
class Injury(Base):
"""球员伤停记录(api-football 数据源)。"""
__tablename__ = "injuries"
class Standing(Base):
"""联赛积分榜快照(bzzoiro /leagues/{id}/standings/)。
同一联赛同一赛季只保留最新快照:重新采集时按 (league_id, season, team_id)
upsert。zone 来自 bzzoiro 分区(如 champions_league / europa_league / relegation)。
"""
__tablename__ = "standings"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
player_id: Mapped[int | None] = mapped_column(Integer, index=True)
player_name: Mapped[str] = mapped_column(String(120), nullable=False)
team_id: Mapped[int | None] = mapped_column(ForeignKey("teams.id"), index=True)
fixture_id: Mapped[int | None] = mapped_column(Integer)
league_id: Mapped[int | None] = mapped_column(Integer)
injury_type: Mapped[str | None] = mapped_column(String(50)) # Missing Fixture / Suspended
reason: Mapped[str | None] = mapped_column(String(200))
injury_date: Mapped[date | None] = mapped_column(Date, index=True)
return_date: Mapped[date | None] = mapped_column(Date)
league_id: Mapped[int] = mapped_column(ForeignKey("leagues.id"), nullable=False)
season: Mapped[str] = mapped_column(String(12), nullable=False)
team_id: Mapped[int] = mapped_column(ForeignKey("teams.id"), nullable=False)
position: Mapped[int] = mapped_column(Integer, nullable=False)
played: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
won: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
drawn: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
lost: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
goals_for: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
goals_against: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
goal_diff: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
points: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
xg_for: Mapped[float | None] = mapped_column(Float)
xg_against: Mapped[float | None] = mapped_column(Float)
form: Mapped[str | None] = mapped_column(String(20)) # 近期赛果串,如 "WWDLW"
zone: Mapped[str | None] = mapped_column(String(50)) # champions_league / relegation 等
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
retrieved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
team: Mapped["Team | None"] = relationship()
league: Mapped[League] = relationship()
team: Mapped[Team] = relationship(lazy="selectin")
__table_args__ = (
# Fix 4: partial unique index — 只在 player_id 和 fixture_id 都非空时强制唯一
# PostgreSQL 中 NULL != NULL,普通唯一索引无法防止 NULL 重复
Index(
"ix_injuries_player_fixture",
"player_id",
"fixture_id",
"injury_type",
unique=True,
postgresql_where=and_(
player_id.is_not(None),
fixture_id.is_not(None),
),
),
Index("ix_injuries_team_date", "team_id", "injury_date"),
UniqueConstraint("league_id", "season", "team_id", name="uq_standings_league_season_team"),
Index("ix_standings_league_season_pos", "league_id", "season", "position"),
)