"""6 张表 ORM: leagues / teams / matches / match_stats / predictions / injuries。""" from __future__ import annotations from datetime import date, datetime, timezone from sqlalchemy import ( Boolean, CheckConstraint, Date, DateTime, Float, ForeignKey, Index, Integer, String, Text, func, ) from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship from src.db.base import Base def _utcnow() -> datetime: return datetime.now(timezone.utc) class League(Base): __tablename__ = "leagues" id: Mapped[int] = mapped_column(Integer, primary_key=True) code: Mapped[str] = mapped_column(String(20), unique=True, nullable=False) name: Mapped[str] = mapped_column(String(100), nullable=False) country: Mapped[str | None] = mapped_column(String(50)) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow) matches: Mapped[list["Match"]] = relationship(back_populates="league") class Team(Base): __tablename__ = "teams" id: Mapped[int] = mapped_column(Integer, primary_key=True) name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False) name_zh: Mapped[str | None] = mapped_column(String(60)) team_type: Mapped[str] = mapped_column(String(20), default="club") created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow) home_matches: Mapped[list["Match"]] = relationship(foreign_keys="Match.home_team_id", back_populates="home_team") away_matches: Mapped[list["Match"]] = relationship(foreign_keys="Match.away_team_id", back_populates="away_team") class Match(Base): __tablename__ = "matches" id: Mapped[int] = mapped_column(Integer, primary_key=True) league_id: Mapped[int] = mapped_column(ForeignKey("leagues.id"), nullable=False) season: Mapped[str | None] = mapped_column(String(12)) home_team_id: Mapped[int] = mapped_column(ForeignKey("teams.id"), nullable=False) away_team_id: Mapped[int] = mapped_column(ForeignKey("teams.id"), nullable=False) match_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) match_date_date: Mapped[date] = mapped_column( "match_date_date", Date, nullable=False, index=True, ) match_status: Mapped[str] = mapped_column(String(20), default="scheduled") home_goals: Mapped[int | None] = mapped_column(Integer) away_goals: Mapped[int | None] = mapped_column(Integer) 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)) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow) league: Mapped[League] = relationship(back_populates="matches", lazy="selectin") # lazy="selectin": 这些关系在业务里几乎总是一起读取(切片/回测/展示)。 # 默认的 lazy="select" 在 async SQLAlchemy 下,于 session 之外或未显式 # eager-load 时访问会抛 MissingGreenlet —— 已因此导致 form/stats/h2h # 三个专家切片静默失败。统一改为预加载,从根上消除这类问题。 home_team: Mapped[Team] = relationship( foreign_keys=[home_team_id], back_populates="home_matches", lazy="selectin" ) away_team: Mapped[Team] = relationship( foreign_keys=[away_team_id], back_populates="away_matches", lazy="selectin" ) stats: Mapped["MatchStats | None"] = relationship( back_populates="match", cascade="all, delete-orphan", lazy="selectin" ) predictions: Mapped[list["Prediction"]] = relationship(back_populates="match", cascade="all, delete-orphan") __table_args__ = ( Index("ix_matches_league_date", "league_id", match_date.desc()), Index("ix_matches_home_date", "home_team_id", match_date.desc()), Index("ix_matches_away_date", "away_team_id", match_date.desc()), Index("ix_matches_status_date", "match_status", match_date.desc()), Index( "ix_matches_unique", "league_id", "home_team_id", "away_team_id", "match_date_date", unique=True, ), ) class MatchStats(Base): __tablename__ = "match_stats" match_id: Mapped[int] = mapped_column(ForeignKey("matches.id", ondelete="CASCADE"), primary_key=True) home_xg: Mapped[float | None] = mapped_column(Float) away_xg: Mapped[float | None] = mapped_column(Float) home_shots: Mapped[int | None] = mapped_column(Integer) away_shots: Mapped[int | None] = mapped_column(Integer) home_shots_on_target: Mapped[int | None] = mapped_column(Integer) away_shots_on_target: Mapped[int | None] = mapped_column(Integer) home_corners: Mapped[int | None] = mapped_column(Integer) away_corners: Mapped[int | None] = mapped_column(Integer) home_possession: Mapped[float | None] = mapped_column(Float) home_yellow_cards: Mapped[int | None] = mapped_column(Integer) 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) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow) # 数据血缘:追踪统计数据的来源和可用时间 source: Mapped[str | None] = mapped_column(String(30)) # bzzoiro / understat source_record_id: Mapped[str | None] = mapped_column(String(100)) retrieved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) available_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) match: Mapped[Match] = relationship(back_populates="stats") __table_args__ = ( # 数据血缘时间过滤查询用(按 available_at 取「赛前已可得」的统计) Index("ix_match_stats_available_at", "available_at"), ) class Injury(Base): """球员伤停记录(api-football 数据源)。""" __tablename__ = "injuries" 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) retrieved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow) team: Mapped["Team | None"] = relationship() __table_args__ = ( Index("ix_injuries_player_fixture", "player_id", "fixture_id", "injury_type", unique=True), Index("ix_injuries_team_date", "team_id", "injury_date"), ) class Prediction(Base): __tablename__ = "predictions" id: Mapped[int] = mapped_column(Integer, primary_key=True) match_id: Mapped[int] = mapped_column(ForeignKey("matches.id"), nullable=False) provider: Mapped[str] = mapped_column(String(30), nullable=False) model: Mapped[str] = mapped_column(String(80), nullable=False) prompt_version: Mapped[str] = mapped_column(String(20), nullable=False, default="v1") prompt_tokens: Mapped[int | None] = mapped_column(Integer) completion_tokens: Mapped[int | None] = mapped_column(Integer) latency_ms: Mapped[int | None] = mapped_column(Integer) pred_home_goals: Mapped[float | None] = mapped_column(Float) pred_away_goals: Mapped[float | None] = mapped_column(Float) pred_1x2: Mapped[str | None] = mapped_column(String(3)) subjective_confidence: Mapped[float | None] = mapped_column(Float) # LLM 主观置信度,非概率 reasoning: Mapped[str | None] = mapped_column(Text) raw_response: Mapped[dict | None] = mapped_column(JSONB) # multi-agent 模式: 各专家报告 mode: Mapped[str] = mapped_column(String(20), nullable=False, default="single") agent_outputs: Mapped[dict | None] = mapped_column(JSONB) # 预测状态: success / failed / degraded status: Mapped[str] = mapped_column(String(20), nullable=False, default="success") # 时间语义:区分比赛时间、预测创建时间、数据截止时间 match_kickoff_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) prediction_created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow) prediction_cutoff_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) input_hash: Mapped[str | None] = mapped_column(String(64)) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow) actual_home_goals: Mapped[int | None] = mapped_column(Integer) actual_away_goals: Mapped[int | None] = mapped_column(Integer) settled: Mapped[bool] = mapped_column(Boolean, default=False) match: Mapped[Match] = relationship(back_populates="predictions") __table_args__ = ( Index("ix_predictions_match", "match_id"), Index("ix_predictions_provider_model", "provider", "model"), # 数据截止时间过滤查询用(按 prediction_cutoff_at 取「赛前已生成」的预测) Index("ix_predictions_cutoff_at", "prediction_cutoff_at"), # 数据库级约束:最后一道防线 CheckConstraint("pred_home_goals >= 0", name="ck_pred_home_goals_nonneg"), CheckConstraint("pred_away_goals >= 0", name="ck_pred_away_goals_nonneg"), CheckConstraint("subjective_confidence >= 0 AND subjective_confidence <= 1", name="ck_confidence_range"), CheckConstraint("pred_1x2 IN ('1', 'X', '2')", name="ck_pred_1x2_enum"), CheckConstraint("mode IN ('single', 'multi')", name="ck_mode_enum"), CheckConstraint("status IN ('success', 'failed', 'degraded')", name="ck_status_enum"), )