"""ORM 模型: leagues / teams / matches / match_stats / standings / predictions。 数据源统一为 bzzoiro(单一数据源),伤停(injuries)与 Understat 已移除。 """ from __future__ import annotations from datetime import date, datetime, timezone from sqlalchemy import ( BigInteger, Boolean, CheckConstraint, Date, DateTime, Float, ForeignKey, Index, Integer, String, Text, UniqueConstraint, 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)) # 数据血缘: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) 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="select" ) 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, ), # DB-5: 数据库级约束 — 已完赛比赛必须有比分 CheckConstraint( "match_status <> 'finished' OR (home_goals IS NOT NULL AND away_goals IS NOT NULL)", name="ck_matches_finished_has_score", ), CheckConstraint( "match_status IN ('finished', 'scheduled', 'in_play', 'paused', 'postponed', 'cancelled', 'suspended')", name="ck_matches_status_enum", ), # 半场进球 ≤ 全场进球 CheckConstraint( "home_ht_goals IS NULL OR home_goals IS NULL OR home_ht_goals <= home_goals", name="ck_matches_home_ht_le_full", ), CheckConstraint( "away_ht_goals IS NULL OR away_goals IS NULL OR away_ht_goals <= away_goals", name="ck_matches_away_ht_le_full", ), ) 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) # 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 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)) # xG 数据血缘:单独追踪 xG 字段的来源与更新时间(xG 可能独立于其他统计被更新) xg_source: Mapped[str | None] = mapped_column(String(30)) xg_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) xg_source_record_id: Mapped[str | None] = mapped_column(String(100)) match: Mapped[Match] = relationship(back_populates="stats") __table_args__ = ( # 数据血缘时间过滤查询用(按 available_at 取「赛前已可得」的统计) Index("ix_match_stats_available_at", "available_at"), ) 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) 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) league: Mapped[League] = relationship() team: Mapped[Team] = relationship(lazy="selectin") __table_args__ = ( UniqueConstraint("league_id", "season", "team_id", name="uq_standings_league_season_team"), Index("ix_standings_league_season_pos", "league_id", "season", "position"), ) class Prediction(Base): __tablename__ = "predictions" id: Mapped[int] = mapped_column(Integer, primary_key=True) match_id: Mapped[int] = mapped_column(ForeignKey("matches.id", ondelete="CASCADE"), 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) # 备选比分(次可能比分,可空) alt_pred_home_goals: Mapped[int | None] = mapped_column(Integer) alt_pred_away_goals: Mapped[int | None] = mapped_column(Integer) 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") # D5: multi-agent 模式存各专家报告列表(list[dict]);历史数据/兼容路径可能存 dict。 # 仅修正类型标注与真实 JSON 形状一致,列类型(JSONB)与数据不变。 agent_outputs: Mapped[list[dict] | dict | None] = mapped_column(JSONB) # Fix: agent_weights 独立持久化到列(原本只在 raw_response 中) agent_weights: Mapped[dict | None] = mapped_column(JSONB) # 预测状态: success / failed / degraded status: Mapped[str] = mapped_column(String(20), nullable=False, default="success") # Fix: run_type 区分实盘(live)与回测(backtest),避免回测覆盖实盘预测 run_type: Mapped[str] = mapped_column(String(10), nullable=False, default="live") # 时间语义:区分比赛时间、预测创建时间、数据截止时间 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__ = ( # Fix: 唯一约束增加 mode + run_type,允许 live 与 backtest 共存 # 防止回测覆盖未结算的实盘预测(后续 settle 会污染评估数据) UniqueConstraint( "match_id", "provider", "model", "mode", "run_type", name="uq_predictions_match_provider_model_mode_run_type", ), 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', 'baseline')", name="ck_mode_enum"), CheckConstraint("status IN ('success', 'failed', 'degraded')", name="ck_status_enum"), CheckConstraint("run_type IN ('live', 'backtest')", name="ck_run_type_enum"), ) class AppSetting(Base): """后台管理的运行时设置(如数据源 API Key),读取时优先于 .env 默认值。""" __tablename__ = "app_settings" key: Mapped[str] = mapped_column(String(100), primary_key=True) value: Mapped[str] = mapped_column(Text, nullable=False) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow) class Schedule(Base): """定时采集任务配置。""" __tablename__ = "schedules" id: Mapped[str] = mapped_column(String(50), primary_key=True) task: Mapped[str] = mapped_column(String(20), nullable=False) # events / standings / stats / all cron: Mapped[str] = mapped_column(String(100), nullable=False) # cron 表达式 leagues: Mapped[str | None] = mapped_column(Text) # 逗号分隔的联赛代码,空=全部 enabled: Mapped[bool] = mapped_column(Boolean, default=True) last_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) last_status: Mapped[str | None] = mapped_column(String(20)) # success / failed created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow) # ── 数据管线基础设施(对应迁移 0008) ────────────────────────────────── # Bronze 层、死信、质量监控、血缘追踪 4 张表。 class RawEvent(Base): """Bronze 层:采集到的原始事件存档,便于重放与审计。 ⚠️ 预留未启用:当前 bzzoiro 管线不写入此表。 未来接线计划:events 采集成功后写入 raw_payload,支持重放与审计。 """ __tablename__ = "raw_events" id: Mapped[int] = mapped_column(BigInteger, primary_key=True) source_system: Mapped[str] = mapped_column(String(50), nullable=False) source_record_id: Mapped[str] = mapped_column(String(100), nullable=False) raw_payload: Mapped[dict] = mapped_column(JSONB, nullable=False) ingested_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) ingest_batch_id: Mapped[str | None] = mapped_column(String(36)) __table_args__ = ( UniqueConstraint("source_system", "source_record_id", name="uq_raw_event"), Index("ix_raw_event_batch", "ingest_batch_id"), ) class IngestFailure(Base): """采集失败死信:记录失败原因、重试次数与下次重试时间。 bzzoiro 三条管线(events / standings / stats)抓取失败时经由 bzzoiro._safe_write_ingest_failure 写入本表(尽力而为,写入失败 不影响采集主流程)。admin 可通过 /admin/schedules/ingest-failures 查看与重试。 """ __tablename__ = "ingest_failures" id: Mapped[int] = mapped_column(BigInteger, primary_key=True) source_system: Mapped[str] = mapped_column(String(50), nullable=False) entity_type: Mapped[str] = mapped_column(String(50), nullable=False) source_record_id: Mapped[str | None] = mapped_column(String(100)) error_type: Mapped[str] = mapped_column(String(50), nullable=False) error_detail: Mapped[str | None] = mapped_column(Text) raw_payload: Mapped[dict | None] = mapped_column(JSONB) retry_count: Mapped[int] = mapped_column(Integer, server_default="0") next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) status: Mapped[str] = mapped_column(String(20), server_default="pending") created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) __table_args__ = ( Index("ix_ingest_failure_status", "status", "next_retry_at"), CheckConstraint( "status IN ('pending', 'retrying', 'resolved', 'abandoned')", name="ck_ingest_failure_status", ), ) class DataQualityCheck(Base): """数据质量监控:记录每次质量检查的结果。 ⚠️ 预留未启用:当前 bzzoiro 管线不写入此表。 未来接线计划:定时检查比赛/统计/积分榜完整性,写入检查结果。 """ __tablename__ = "data_quality_checks" id: Mapped[int] = mapped_column(BigInteger, primary_key=True) check_name: Mapped[str] = mapped_column(String(100), nullable=False) entity_type: Mapped[str] = mapped_column(String(50), nullable=False) entity_id: Mapped[int | None] = mapped_column(Integer) expected_value: Mapped[float | None] = mapped_column(Float) actual_value: Mapped[float] = mapped_column(Float, nullable=False) passed: Mapped[bool] = mapped_column(Boolean, nullable=False) severity: Mapped[str] = mapped_column(String(10), server_default="warning") detail: Mapped[dict | None] = mapped_column(JSONB) checked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) __table_args__ = ( Index("ix_dqc_checked_at", "checked_at"), Index("ix_dqc_entity", "entity_type", "entity_id"), ) class DataLineage(Base): """ETL 血缘追踪:记录从源到目标的转换过程。 ⚠️ 预留未启用:当前 bzzoiro 管线不写入此表。 未来接线计划:每次采集写入 source_record_id → target_table/id 映射。 """ __tablename__ = "data_lineage" id: Mapped[int] = mapped_column(BigInteger, primary_key=True) source_system: Mapped[str] = mapped_column(String(50), nullable=False) source_record_id: Mapped[str] = mapped_column(String(100), nullable=False) target_table: Mapped[str] = mapped_column(String(50), nullable=False) target_id: Mapped[int | None] = mapped_column(Integer) transform_name: Mapped[str] = mapped_column(String(50), nullable=False) transform_detail: Mapped[dict | None] = mapped_column(JSONB) batch_id: Mapped[str | None] = mapped_column(String(36)) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) __table_args__ = ( Index("ix_lineage_source", "source_system", "source_record_id"), Index("ix_lineage_target", "target_table", "target_id"), Index("ix_lineage_batch", "batch_id"), )