fix(P0-01): missing score 不得变 0:0 — score_status + CHECK 约束

新增 matches.score_status(known/missing/unknown):
- 替换 ck_matches_finished_has_score 为 ck_matches_score_integrity:
  known → 必须有分; missing/unknown → goals 必须 NULL(不伪造 0:0)
- normalize: 完赛缺分不再静默降级为 scheduled,改设 score_status=missing
- events ingest: 创建/更新 Match 同步 score_status(比分由缺变 known / 确认缺分 missing)
- slices(form/h2h/home_away)/backtest: 显式加 score_status='known' 过滤完赛样本
- 迁移 0022 回填现有数据(绝不 UPDATE goals=0)

测试 tests/test_p0_score_status.py(9/9):约束存在性/Match 构造/normalize 行为。
54 相关测试全绿。
This commit is contained in:
shangfangjian
2026-09-22 02:32:53 +08:00
parent f563d5cc99
commit 63caa6736c
9 changed files with 226 additions and 4 deletions
+15 -3
View File
@@ -88,6 +88,9 @@ class Match(Base):
index=True,
)
match_status: Mapped[str] = mapped_column(String(20), default="scheduled")
# P0-01:比分可信度标记。known=有可靠比分;missing=完赛但缺分(保留 NULL 不伪造 0:0);
# unknown=待定(无比分且未确认完赛)。禁止把缺分写成 0:0。
score_status: Mapped[str] = mapped_column(String(20), server_default="unknown", nullable=False)
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)
@@ -127,10 +130,19 @@ class Match(Base):
"match_date_date",
unique=True,
),
# DB-5: 数据库级约束 — 已完赛比赛必须有比分
# P0-01:比分可信度约束(替代原 ck_matches_finished_has_score):
# - score_status=known → 必须有比分(非 NULL)
# - score_status=missing → 必须 NULL(完赛缺分,禁止伪造 0:0)
# - score_status=unknown → 必须 NULL
CheckConstraint(
"match_status <> 'finished' OR (home_goals IS NOT NULL AND away_goals IS NOT NULL)",
name="ck_matches_finished_has_score",
"score_status IN ('known', 'missing', 'unknown')",
name="ck_matches_score_status_enum",
),
CheckConstraint(
"match_status <> 'finished'"
" OR (score_status = 'known' AND home_goals IS NOT NULL AND away_goals IS NOT NULL)"
" OR (score_status IN ('missing', 'unknown') AND home_goals IS NULL AND away_goals IS NULL)",
name="ck_matches_score_integrity",
),
CheckConstraint(
"match_status IN ('finished', 'scheduled', 'in_play', 'paused', 'postponed', 'cancelled', 'suspended')",