- Mapped[list[dict] | dict | None]:multi 模式存专家报告列表, 历史数据/兼容路径可能存 dict;加注释说明形状来源 - 仅类型标注修正,列类型(JSONB)与数据、迁移均不变
407 lines
20 KiB
Python
407 lines
20 KiB
Python
"""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"),
|
|
)
|