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:
@@ -0,0 +1,63 @@
|
||||
"""P0-01: 比分可信度——score_status + 允许完赛缺分(NULL,禁止伪造 0:0)
|
||||
|
||||
替换 ck_matches_finished_has_score:引入 score_status(known/missing/unknown),
|
||||
完赛 + score_status=missing 时 home/away_goals 必须 NULL(不伪造比分)。
|
||||
|
||||
Revision ID: 0022_match_score_status
|
||||
Revises: 0021_match_source_event_id_unique
|
||||
Create Date: 2026-09-22
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '0022_match_score_status'
|
||||
down_revision: Union[str, None] = '0021_match_source_event_id_unique'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1) 新增 score_status 列(默认 unknown)
|
||||
op.add_column(
|
||||
'matches',
|
||||
sa.Column('score_status', sa.String(20), server_default='unknown', nullable=False),
|
||||
)
|
||||
|
||||
# 2) 按现有数据回填 score_status(绝不写 goals=0):
|
||||
# - 有比分(两列均非 NULL) → known
|
||||
# - 无比分 + 完赛 → missing(缺分)
|
||||
# - 其余 → unknown
|
||||
op.execute(
|
||||
"UPDATE matches SET score_status = 'known'"
|
||||
" WHERE home_goals IS NOT NULL AND away_goals IS NOT NULL"
|
||||
)
|
||||
op.execute(
|
||||
"UPDATE matches SET score_status = 'missing'"
|
||||
" WHERE match_status = 'finished' AND home_goals IS NULL AND away_goals IS NULL"
|
||||
)
|
||||
|
||||
# 3) 删除旧约束,加新约束
|
||||
op.drop_constraint('ck_matches_finished_has_score', 'matches', type_='check')
|
||||
op.create_check_constraint(
|
||||
'ck_matches_score_status_enum', 'matches',
|
||||
"score_status IN ('known', 'missing', 'unknown')",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
'ck_matches_score_integrity', 'matches',
|
||||
"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)",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint('ck_matches_score_integrity', 'matches', type_='check')
|
||||
op.drop_constraint('ck_matches_score_status_enum', 'matches', type_='check')
|
||||
op.create_check_constraint(
|
||||
'ck_matches_finished_has_score', 'matches',
|
||||
"match_status <> 'finished' OR (home_goals IS NOT NULL AND away_goals IS NOT NULL)",
|
||||
)
|
||||
op.remove_column('matches', 'score_status')
|
||||
Reference in New Issue
Block a user