diff --git a/tests/test_account_binding.py b/tests/test_account_binding.py index ce83e87..5377a02 100644 --- a/tests/test_account_binding.py +++ b/tests/test_account_binding.py @@ -354,3 +354,105 @@ class TestNoFreeTextWriters: "绑定状态只能由 account_binding.set_bind_status 写;" "旧 bind_status 列不允许任何写入方:\n" + "\n".join(offenders) ) + + +class TestRealProductionValuesCovered: + """用**生产库真实取值全集**核对映射表。 + + 这是被真实数据打脸换来的用例:第一版映射只覆盖了文档里举的例子, + 而生产的电竞列里还有 `game_not_bound`(26 行)—— 漏了它会被归成 + `unknown`,可它明明在说「没绑定」。 + + ⚠️ 下面的取值列表是从生产库 `GROUP BY` 拉出来的**全集**。以后往这三列加 + 新取值时,这里应该同步补上;新增取值如果不在映射里,用例会提醒你 + 确认它到底该归成哪种状态,而不是默默变成 `unknown`。 + """ + + #: 生产库实测(2026-10-04,15,182 个账号) + PRODUCTION_VALUES: ClassVar[dict[str, list[str | None]]] = { + "elite": [ + "goods_exchanged", + "limited_goods_queried", + "gift_donated", + "", + "change_time_queried", + "game_queried", + "bind_confirmed", + "gold_recharged", + "gold_balance_queried", + "points_queried", + "elite_status_queried", + "bind_qr_generated", + "game_not_bound", + "elite_qr_created", + "elite_opened", + "gold_api_order_created", + "gold_qr_created", + ], + "esports": [ + "", + "esports_gift_donated", + "esports_bound", + "esports_goods_exchanged", + "esports_points_queried", + "esports_bind_ready", + "game_not_bound", + "esports_goods_refreshed", + "esports_qr_created", + "esports_opened", + "esports_bind_confirming", + ], + "peace": [ + "", + None, + "xpd_purchase_records_queried", + "xpd_bound", + "xpd_balance_queried", + "xpd_goods_exchanged", + "xpd_bind_qr_ready", + "xpd_fragments_queried", + "xpd_goods_refreshed", + ], + } + + #: 明确陈述了绑定关系、必须映射成非 unknown 的取值 + MUST_BE_MEANINGFUL: ClassVar[dict[tuple[str, str], str]] = { + ("elite", "bind_confirmed"): "bound", + ("elite", "bind_qr_generated"): "pending", + ("elite", "game_not_bound"): "unbound", + ("esports", "esports_bound"): "bound", + ("esports", "esports_bind_ready"): "pending", + ("esports", "esports_bind_confirming"): "pending", + ("esports", "esports_qr_created"): "pending", + # ← 生产实测发现的那一条 + ("esports", "game_not_bound"): "unbound", + ("peace", "xpd_bound"): "bound", + ("peace", "xpd_bind_qr_ready"): "pending", + } + + @pytest.mark.parametrize( + ("scope", "legacy", "expected"), + [(*key, value) for key, value in MUST_BE_MEANINGFUL.items()], + ) + def test_meaningful_values_map_exactly(self, scope, legacy, expected): + assert binding.map_legacy_bind_status(scope, legacy) == expected + + def test_every_production_value_maps_to_a_valid_state(self): + for scope, values in self.PRODUCTION_VALUES.items(): + for value in values: + state = binding.map_legacy_bind_status(scope, value) + assert state in binding.ALL_BIND_STATES, f"{scope}/{value!r}" + + def test_no_production_value_is_silently_unknown_when_it_should_not_be(self): + """反向检查:该有含义的值不能全落成 unknown。""" + for scope, values in self.PRODUCTION_VALUES.items(): + meaningful = { + v for (s, v), _ in self.MUST_BE_MEANINGFUL.items() if s == scope + } + mapped = { + v + for v in values + if binding.map_legacy_bind_status(scope, v) != "unknown" + } + missing = meaningful - mapped + assert not missing, f"{scope} 里这些值被错归成 unknown:{missing}" diff --git a/web/backend/migrations/versions/20261004_0044_bind_status_split.py b/web/backend/migrations/versions/20261004_0044_bind_status_split.py index 151eb97..2f31495 100644 --- a/web/backend/migrations/versions/20261004_0044_bind_status_split.py +++ b/web/backend/migrations/versions/20261004_0044_bind_status_split.py @@ -87,6 +87,7 @@ def normalise_esports_bind_status(bind) -> None: 'esports_qr_created', 'esports_role_switching' ) THEN 'pending' + WHEN esports_bind_status = 'game_not_bound' THEN 'unbound' ELSE 'unknown' END """ diff --git a/web/backend/services/account_binding.py b/web/backend/services/account_binding.py index 82e1eeb..448c652 100644 --- a/web/backend/services/account_binding.py +++ b/web/backend/services/account_binding.py @@ -105,6 +105,11 @@ LEGACY_ESPORTS_BIND_MAP: dict[str, str] = { "esports_bind_confirming": BIND_PENDING, "esports_qr_created": BIND_PENDING, "esports_role_switching": BIND_PENDING, + # ⚠️ 这一条是**连生产库看真实数据**才发现的:电竞列里确实存在 + # `game_not_bound`(生产 26 行)。漏了它就会被归成 `unknown`, + # 而它明明在说「没绑定」。教训:映射表必须拿**真实取值全集**核对, + # 不能只看文档里举的几个例子。 + "game_not_bound": BIND_UNBOUND, } LEGACY_XPD_BIND_MAP: dict[str, str] = {