diff --git a/alembic/versions/0004_snapshot_and_constraints.py b/alembic/versions/0004_snapshot_and_constraints.py index d586dbc..6d1118f 100644 --- a/alembic/versions/0004_snapshot_and_constraints.py +++ b/alembic/versions/0004_snapshot_and_constraints.py @@ -26,6 +26,21 @@ def upgrade() -> None: op.add_column('predictions', sa.Column('input_hash', sa.String(length=64), nullable=True)) # 3. 新增 CHECK 约束 + # 注意: 对已有数据行加 CHECK 约束时,若存在越界数据 ALTER TABLE 会中途失败, + # 导致迁移卡在半完成状态。这里先做一次性清洗(把越界值收敛到合法域), + # 再建约束,保证在非空库上也能成功。 + op.execute("UPDATE predictions SET pred_home_goals = 0 WHERE pred_home_goals IS NOT NULL AND pred_home_goals < 0") + op.execute("UPDATE predictions SET pred_away_goals = 0 WHERE pred_away_goals IS NOT NULL AND pred_away_goals < 0") + op.execute( + "UPDATE predictions SET subjective_confidence = " + "CASE WHEN subjective_confidence < 0 THEN 0 " + " WHEN subjective_confidence > 1 THEN 1 ELSE subjective_confidence END " + "WHERE subjective_confidence IS NOT NULL " + " AND (subjective_confidence < 0 OR subjective_confidence > 1)" + ) + op.execute("UPDATE predictions SET pred_1x2 = NULL WHERE pred_1x2 IS NOT NULL AND pred_1x2 NOT IN ('1', 'X', '2')") + op.execute("UPDATE predictions SET mode = 'single' WHERE mode IS NOT NULL AND mode NOT IN ('single', 'multi')") + op.create_check_constraint('ck_pred_home_goals_nonneg', 'predictions', 'pred_home_goals >= 0') op.create_check_constraint('ck_pred_away_goals_nonneg', 'predictions', 'pred_away_goals >= 0') op.create_check_constraint('ck_confidence_range', 'predictions', 'subjective_confidence >= 0 AND subjective_confidence <= 1') diff --git a/alembic/versions/0006_schema_model_drift_cleanup.py b/alembic/versions/0006_schema_model_drift_cleanup.py new file mode 100644 index 0000000..abcaa03 --- /dev/null +++ b/alembic/versions/0006_schema_model_drift_cleanup.py @@ -0,0 +1,80 @@ +"""清理 schema 与 ORM 模型的漂移 + +Revision ID: 0006_schema_model_drift_cleanup +Revises: 0005_prediction_status_and_stats_provenance +Create Date: 2026-09-15 + +背景(见代码审查报告 P1-1): + 0005 迁移在 predictions 表留下了 `cutoff_at` 列(删除语句被注释掉), + 但 ORM 模型 `Prediction` 中并无该字段 —— 这是一处会长期存在的 schema 漂移, + 而 alembic autogenerate 会持续建议 drop 它,造成噪声。 + + 同时 0005 创建的两个索引 `ix_match_stats_available_at` 与 + `ix_predictions_cutoff_at` 未在 ORM 声明,autogenerate 会建议删除它们 —— + 一旦被误删,数据血缘相关的时间过滤查询会退化为全表扫描。 + +本迁移做两件事: + 1. drop 掉幽灵列 predictions.cutoff_at(数据已由 prediction_cutoff_at 承载, + 迁移前先把非空值回填过去,避免丢数据) + 2. 显式重建这两个索引(幂等:先 drop if exists 再 create),使 DB 状态与 + 修正后的 ORM 声明一致 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = '0006_schema_model_drift_cleanup' +down_revision: Union[str, None] = '0005_prediction_status_and_stats_provenance' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + # --- 1. 幽灵列 cutoff_at: 先把数据回填到 prediction_cutoff_at 再删除 --- + pred_cols = {c["name"] for c in inspector.get_columns("predictions")} + if "cutoff_at" in pred_cols: + if "prediction_cutoff_at" in pred_cols: + # 仅回填尚未有值的行,避免覆盖更权威的数据 + op.execute( + "UPDATE predictions " + "SET prediction_cutoff_at = cutoff_at " + "WHERE prediction_cutoff_at IS NULL AND cutoff_at IS NOT NULL" + ) + op.drop_column("predictions", "cutoff_at") + + # --- 2. 与 ORM 声明对齐的索引(幂等重建) --- + stats_idx = {i["name"] for i in inspector.get_indexes("match_stats")} + if "ix_match_stats_available_at" not in stats_idx: + op.create_index("ix_match_stats_available_at", "match_stats", ["available_at"]) + + pred_idx = {i["name"] for i in inspector.get_indexes("predictions")} + if "ix_predictions_cutoff_at" not in pred_idx: + op.create_index( + "ix_predictions_cutoff_at", "predictions", ["prediction_cutoff_at"] + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + # 恢复幽灵列(与 0005 的最终状态一致:列存在但无值) + pred_cols = {c["name"] for c in inspector.get_columns("predictions")} + if "cutoff_at" not in pred_cols: + op.add_column( + "predictions", + sa.Column("cutoff_at", sa.DateTime(timezone=True), nullable=True), + ) + + stats_idx = {i["name"] for i in inspector.get_indexes("match_stats")} + if "ix_match_stats_available_at" in stats_idx: + op.drop_index("ix_match_stats_available_at", table_name="match_stats") + + pred_idx = {i["name"] for i in inspector.get_indexes("predictions")} + if "ix_predictions_cutoff_at" in pred_idx: + op.drop_index("ix_predictions_cutoff_at", table_name="predictions") diff --git a/src/db/models.py b/src/db/models.py index fe28fc0..a42f4a0 100644 --- a/src/db/models.py +++ b/src/db/models.py @@ -133,6 +133,11 @@ class MatchStats(Base): match: Mapped[Match] = relationship(back_populates="stats") + __table_args__ = ( + # 数据血缘时间过滤查询用(按 available_at 取「赛前已可得」的统计) + Index("ix_match_stats_available_at", "available_at"), + ) + class Injury(Base): """球员伤停记录(api-football 数据源)。""" @@ -195,6 +200,8 @@ class Prediction(Base): __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"),