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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+15
-3
@@ -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')",
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user