P1-1 schema/ORM 漂移: - 新增 0006 迁移: 回填后 drop 幽灵列 predictions.cutoff_at, 并幂等重建 ix_match_stats_available_at / ix_predictions_cutoff_at - models.py 为 MatchStats / Prediction 声明上述两个索引, 使 autogenerate 不再误建议删除 P1-2 迁移对历史越界数据不安全: - 0004 在建 CHECK 约束前先 op.execute 清洗越界行 (负数进球/置信度越界/非法 1x2/mode),避免 ALTER 中途失败 导致迁移卡在半完成状态 P1-3 回测缓存污染: - predict_match / _predict_single 新增 use_cache 参数, use_cache=False 时既不读也不写进程内缓存 - run_backtest 显式传 use_cache=False,避免 settle 到旧记录 P1-5 understat 关系属性访问: - MatchRepository.find_by_teams_and_date 加 selectinload(Match.stats) P1-6 日期键构造不统一: - bzzoiro 抽取 _to_date()/_match_key() 统一去重键构造, 消除 str(date) 与 isoformat() 的隐式格式依赖
81 lines
3.3 KiB
Python
81 lines
3.3 KiB
Python
"""清理 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")
|