From 63caa6736cf2aae779c346374f36bc81d2e90319 Mon Sep 17 00:00:00 2001 From: shangfangjian Date: Tue, 22 Sep 2026 02:32:53 +0800 Subject: [PATCH] =?UTF-8?q?fix(P0-01):=20missing=20score=20=E4=B8=8D?= =?UTF-8?q?=E5=BE=97=E5=8F=98=200:0=20=E2=80=94=20score=5Fstatus=20+=20CHE?= =?UTF-8?q?CK=20=E7=BA=A6=E6=9D=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 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 相关测试全绿。 --- alembic/versions/0022_match_score_status.py | 63 ++++++++++ src/data/bzzoiro_events.py | 11 ++ src/data/normalize.py | 10 +- src/db/models.py | 18 ++- src/llm/backtest.py | 1 + src/llm/slices/form.py | 1 + src/llm/slices/h2h.py | 1 + src/llm/slices/home_away.py | 1 + tests/test_p0_score_status.py | 124 ++++++++++++++++++++ 9 files changed, 226 insertions(+), 4 deletions(-) create mode 100644 alembic/versions/0022_match_score_status.py create mode 100644 tests/test_p0_score_status.py diff --git a/alembic/versions/0022_match_score_status.py b/alembic/versions/0022_match_score_status.py new file mode 100644 index 0000000..258b94a --- /dev/null +++ b/alembic/versions/0022_match_score_status.py @@ -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') diff --git a/src/data/bzzoiro_events.py b/src/data/bzzoiro_events.py index 77270bb..ced8dcc 100644 --- a/src/data/bzzoiro_events.py +++ b/src/data/bzzoiro_events.py @@ -202,6 +202,7 @@ class BzzoiroSource: match_date=nm.date, match_date_date=_to_date(nm.date), match_status=nm.match_status, + score_status=nm.score_status, home_goals=nm.home_goals, away_goals=nm.away_goals, home_ht_goals=nm.home_ht_goals, @@ -238,6 +239,16 @@ class BzzoiroSource: existing_match.away_goals = nm.away_goals existing_match.home_ht_goals = nm.home_ht_goals existing_match.away_ht_goals = nm.away_ht_goals + # 比分由缺变有 → 标记 known + existing_match.score_status = "known" + changed = True + elif ( + nm.match_status == "finished" + and nm.home_goals is None + and existing_match.score_status == "unknown" + ): + # 确认完赛仍缺分 → 标记 missing(不伪造 0:0) + existing_match.score_status = "missing" changed = True if existing_match.match_stage is None and nm.match_stage: existing_match.match_stage = nm.match_stage diff --git a/src/data/normalize.py b/src/data/normalize.py index 5f36bc4..245f1f8 100644 --- a/src/data/normalize.py +++ b/src/data/normalize.py @@ -35,6 +35,8 @@ class NormalizedMatch: home_team: str away_team: str match_status: str = "finished" + # P0-01:比分可信度。known=可靠比分;missing=完赛缺分;unknown=待定。 + score_status: str = "unknown" home_goals: int | None = None away_goals: int | None = None season_label: str = "" @@ -217,5 +219,11 @@ def normalize_bzzoiro(raw: dict, league_type: str) -> NormalizedMatch | None: m.away_red_cards = _to_int(raw.get("away_red_cards", raw.get("red_cards_away"))) if m.match_status == "finished" and m.home_goals is None: - m.match_status = "scheduled" + # P0-01: 完赛缺分不再静默降级为 scheduled(那会丢失「已完赛」事实); + # 保留 status=finished,score_status=missing,goals=NULL(禁止伪造 0:0)。 + m.score_status = "missing" + elif m.home_goals is not None and m.away_goals is not None: + m.score_status = "known" + else: + m.score_status = "unknown" return m diff --git a/src/db/models.py b/src/db/models.py index a62f546..b4b4e38 100644 --- a/src/db/models.py +++ b/src/db/models.py @@ -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')", diff --git a/src/llm/backtest.py b/src/llm/backtest.py index a7c7272..a0c6bf4 100644 --- a/src/llm/backtest.py +++ b/src/llm/backtest.py @@ -130,6 +130,7 @@ async def _get_historical_matches( selectinload(Match.away_team), ) .where(Match.match_status == "finished") + .where(Match.score_status == "known") .where(Match.home_goals.is_not(None)) .where(Match.away_goals.is_not(None)) ) diff --git a/src/llm/slices/form.py b/src/llm/slices/form.py index 73df214..fdc7203 100644 --- a/src/llm/slices/form.py +++ b/src/llm/slices/form.py @@ -74,6 +74,7 @@ async def _get_form(db, team_id: int, before, *, limit: int = 5) -> list[Match]: selectinload(Match.away_team), ) .where(Match.match_status == "finished") + .where(Match.score_status == "known") .where(Match.home_goals.is_not(None)) .where((Match.home_team_id == team_id) | (Match.away_team_id == team_id)) .order_by(Match.match_date.desc()) diff --git a/src/llm/slices/h2h.py b/src/llm/slices/h2h.py index 51dc906..01eeecc 100644 --- a/src/llm/slices/h2h.py +++ b/src/llm/slices/h2h.py @@ -74,6 +74,7 @@ async def _get_h2h(db, home_id: int, away_id: int, before, *, limit: int = 5) -> selectinload(Match.away_team), ) .where(Match.match_status == "finished") + .where(Match.score_status == "known") .where(Match.home_goals.is_not(None)) .where( ((Match.home_team_id == home_id) & (Match.away_team_id == away_id)) diff --git a/src/llm/slices/home_away.py b/src/llm/slices/home_away.py index 88061bc..c7bfcd5 100644 --- a/src/llm/slices/home_away.py +++ b/src/llm/slices/home_away.py @@ -68,6 +68,7 @@ async def _get_home_away(db, team_id: int, side: str, before, *, limit: int = 10 selectinload(Match.away_team), ) .where(Match.match_status == "finished") + .where(Match.score_status == "known") .where(Match.home_goals.is_not(None)) .order_by(Match.match_date.desc()) .limit(limit) diff --git a/tests/test_p0_score_status.py b/tests/test_p0_score_status.py new file mode 100644 index 0000000..f99b142 --- /dev/null +++ b/tests/test_p0_score_status.py @@ -0,0 +1,124 @@ +"""P0-01 回归测试: missing score 不得变 0:0。 + +运行: pytest tests/test_p0_score_status.py -v +(无需真实 PG;用 fake DB + 模型元数据断言。) +""" +import pytest + +from src.db.models import League, Match, Team + + +# ── fake DB(对齐现有测试约定) ──────────────────────────────────── +class _FakeResult: + def __init__(self, items): self._items = list(items) + def scalars(self): return self + def all(self): return list(self._items) + def scalar_one_or_none(self): return self._items[0] if self._items else None + + +class _FakeDB: + def __init__(self): self.added = [] + def add(self, obj): self.added.append(obj) + async def execute(self, stmt): return _FakeResult([]) + async def flush(self): + for o in self.added: + if getattr(o, "id", None) is None: + o.id = 1 + + +def _league(lid=1): + lg = League(id=lid, code="E0", name="Test", country="X") + return lg + + +def _teams(): + return Team(id=10, name="Arsenal FC", name_zh="阿森纳"), Team(id=20, name="Chelsea FC", name_zh="切尔西") + + +class TestScoreStatusConstraintPresence: + """模型必须定义 score_status 相关 CHECK 约束。""" + + def test_score_status_column_exists(self): + cols = {c.name for c in Match.__table__.columns} + assert "score_status" in cols + + def test_score_integrity_check_exists(self): + names = {c.name for c in Match.__table__.constraints if c.name} + # 新约束 ck_matches_score_integrity 必须存在 + assert any("score_integrity" in n for n in names), \ + f"ck_matches_score_integrity 未找到,现有约束: {names}" + + def test_old_finished_has_score_check_removed(self): + names = {c.name for c in Match.__table__.constraints} + assert "ck_matches_finished_has_score" not in names, \ + "旧约束 ck_matches_finished_has_score 应已被替换" + + +class TestMatchAcceptsMissingScore: + """Match 对象层面: 完赛 + score_status=missing + goals=NULL 必须可构造。""" + + def test_construct_finished_missing_null_goals(self): + home, away = _teams() + m = Match( + id=1, league_id=_league().id, home_team_id=home.id, away_team_id=away.id, + match_date="2026-01-01 15:00:00+00:00", + match_status="finished", score_status="missing", + home_goals=None, away_goals=None, + ) + assert m.home_goals is None + assert m.away_goals is None + assert m.score_status == "missing" + + def test_add_to_fake_db(self): + db = _FakeDB() + home, away = _teams() + m = Match( + league_id=_league().id, home_team_id=home.id, away_team_id=away.id, + match_date="2026-01-01 15:00:00+00:00", + match_status="finished", score_status="missing", + home_goals=None, away_goals=None, + ) + db.add(m) + + def test_server_default_is_unknown(self): + """score_status 列的 server_default 必须为 unknown(DB 插入未显式赋值时兜底)。""" + col = Match.__table__.c.score_status + assert col.server_default is not None + assert "unknown" in str(col.server_default.arg) + + +class TestNormalizeNoDowngrade: + """normalize_bzzoiro 不得把完赛缺分静默降级为 scheduled。""" + + def _raw(self, status="finished", home_score=None, away_score=None): + return { + "event_date": "2026-01-01 15:00:00", + "status": status, + "home_team": "Arsenal", + "away_team": "Chelsea", + "home_score": home_score, + "away_score": away_score, + } + + def test_finished_missing_score_keeps_finished(self): + from src.data.normalize import normalize_bzzoiro + m = normalize_bzzoiro(self._raw("finished", None, None), "E0") + assert m is not None + assert m.match_status == "finished", "完赛缺分不得降级为 scheduled" + assert m.score_status == "missing" + assert m.home_goals is None + assert m.away_goals is None + + def test_finished_with_score_is_known(self): + from src.data.normalize import normalize_bzzoiro + m = normalize_bzzoiro(self._raw("finished", 2, 1), "E0") + assert m.match_status == "finished" + assert m.score_status == "known" + assert m.home_goals == 2 and m.away_goals == 1 + + def test_scheduled_no_score_is_unknown(self): + from src.data.normalize import normalize_bzzoiro + m = normalize_bzzoiro(self._raw("scheduled", None, None), "E0") + assert m.match_status == "scheduled" + assert m.score_status == "unknown" + assert m.home_goals is None