把生产库克隆到本地做部署演练时,拉了三列绑定状态的**真实取值全集**,发现 `esports_bind_status` 里存在 `game_not_bound`(生产 26 行),而我的映射表里没有它 —— 会被归成 `unknown`,可它明明在说「没绑定」。 第一版映射只覆盖了文档里举的几个例子。**教训:映射表必须拿真实取值全集核对。** 修法:Python 映射与迁移 `0044` 的 CASE 同步补上 `game_not_bound → unbound` (两者有逐字比对用例,不会分叉),并新增一条用**生产实测取值全集**驱动的用例 (17 + 11 + 9 个取值),以后往这三列加新取值时会被提醒确认语义, 而不是默默变成 `unknown`。 已在生产克隆库上验证:电竞列 unbound 从 0 变成 26(正是那批)。
459 lines
18 KiB
Python
459 lines
18 KiB
Python
"""S6:角色绑定状态三列拆解。
|
||
|
||
守住四件事:
|
||
|
||
1. **枚举收口**:三个绑定状态列只能有 `unknown/unbound/pending/bound` 四个值,
|
||
传旧自由文本直接报错(否则垃圾又会被写回去);
|
||
2. **不猜**:旧自由文本 → 新枚举只映射**明确陈述了绑定关系**的取值,
|
||
其余一律 `unknown`(`bind_status` 是覆盖式的,「最后一件事是查询」不代表没绑定);
|
||
3. **迁移与运行时同规则**:迁移 `0044` 的 CASE 必须与 `LEGACY_*_BIND_MAP` 一致;
|
||
4. **写入收口**:源码里不允许再出现 `account.bind_status = "..."` 这类自由文本写入
|
||
(这正是该列烂掉的方式)。
|
||
"""
|
||
|
||
from datetime import UTC, datetime
|
||
from typing import ClassVar
|
||
|
||
import pytest
|
||
|
||
from tests.support.db import get_test_engine, make_test_session
|
||
from web.backend.models import Account
|
||
from web.backend.services import account_binding as binding
|
||
|
||
NOW = datetime(2026, 10, 4, 12, 0, 0, tzinfo=UTC)
|
||
|
||
|
||
class _Base:
|
||
def setup_method(self):
|
||
self.engine = get_test_engine()
|
||
self.session = make_test_session(self.engine)
|
||
|
||
def teardown_method(self):
|
||
self.session.close()
|
||
|
||
def account(self, **kwargs) -> Account:
|
||
acc = Account(
|
||
username="bind-acc",
|
||
password="p",
|
||
email="b@e.com",
|
||
email_password="ep",
|
||
**kwargs,
|
||
)
|
||
self.session.add(acc)
|
||
self.session.commit()
|
||
return acc
|
||
|
||
|
||
class TestEnum(_Base):
|
||
def test_only_four_states(self):
|
||
assert set(binding.ALL_BIND_STATES) == {
|
||
"unknown",
|
||
"unbound",
|
||
"pending",
|
||
"bound",
|
||
}
|
||
|
||
def test_labels_cover_every_state(self):
|
||
assert set(binding.BIND_LABELS) == set(binding.ALL_BIND_STATES)
|
||
|
||
def test_set_bind_status_writes_and_reports_change(self):
|
||
acc = self.account()
|
||
assert (
|
||
binding.set_bind_status(acc, binding.SCOPE_ELITE, binding.BIND_BOUND)
|
||
is True
|
||
)
|
||
assert acc.role_bind_status == "bound"
|
||
# 幂等:同值重复设置不算变化
|
||
assert (
|
||
binding.set_bind_status(acc, binding.SCOPE_ELITE, binding.BIND_BOUND)
|
||
is False
|
||
)
|
||
assert (
|
||
binding.set_bind_status(acc, binding.SCOPE_ELITE, binding.BIND_UNBOUND)
|
||
is True
|
||
)
|
||
|
||
@pytest.mark.parametrize(
|
||
"legacy",
|
||
[
|
||
"bind_confirmed", # 旧自由文本
|
||
"game_queried",
|
||
"elite_opened",
|
||
"points_queried",
|
||
"",
|
||
"BOUND",
|
||
],
|
||
)
|
||
def test_set_bind_status_rejects_free_text(self, legacy):
|
||
"""传旧自由文本必须**报错**,不能静默写进去 —— 那是垃圾场重建的路径。"""
|
||
acc = self.account()
|
||
with pytest.raises(ValueError):
|
||
binding.set_bind_status(acc, binding.SCOPE_ELITE, legacy)
|
||
assert acc.role_bind_status == "unknown"
|
||
|
||
def test_each_scope_maps_to_its_own_column(self):
|
||
acc = self.account()
|
||
binding.set_bind_status(acc, binding.SCOPE_ELITE, binding.BIND_BOUND)
|
||
binding.set_bind_status(acc, binding.SCOPE_ESPORTS, binding.BIND_PENDING)
|
||
binding.set_bind_status(acc, binding.SCOPE_PEACE, binding.BIND_UNBOUND)
|
||
assert (
|
||
acc.role_bind_status,
|
||
acc.esports_bind_status,
|
||
acc.xpd_bind_status,
|
||
) == ("bound", "pending", "unbound")
|
||
# 三列互不覆盖(旧设计里它们会互相盖掉)
|
||
assert binding.get_bind_status(acc, binding.SCOPE_ELITE) == "bound"
|
||
|
||
def test_unknown_scope_is_rejected(self):
|
||
acc = self.account()
|
||
with pytest.raises(ValueError):
|
||
binding.set_bind_status(acc, "nope", binding.BIND_BOUND)
|
||
|
||
def test_bind_payload_carries_state_and_label(self):
|
||
acc = self.account()
|
||
binding.set_bind_status(acc, binding.SCOPE_ELITE, binding.BIND_BOUND)
|
||
payload = binding.bind_payload(acc)
|
||
assert payload["role_bind_status"] == "bound"
|
||
assert payload["role_bind_label"] == "已绑定"
|
||
assert payload["esports_bind_status"] == "unknown"
|
||
assert payload["xpd_bind_label"] == "未知"
|
||
assert set(payload) == {
|
||
f"{binding.SCOPE_PAYLOAD_PREFIX[scope]}_{suffix}"
|
||
for scope in binding.ALL_SCOPES
|
||
for suffix in ("bind_status", "bind_label")
|
||
}
|
||
|
||
|
||
class TestPayloadMatchesSchemas:
|
||
"""`bind_payload()` 的字段名必须与 API schema 声明的名字**逐字一致**。
|
||
|
||
这是被真实 bug 逼出来的:第一版按作用域名拼成了 `elite_bind_status` /
|
||
`peace_bind_status`,而 schema 声明的是 `role_bind_status` / `xpd_bind_status`。
|
||
Pydantic 拿到不认识的键会**静默用默认值**(或者在有 `**kwargs` 展开时直接报错),
|
||
表现是「接口永远返回 unknown」却没有任何报错。
|
||
"""
|
||
|
||
def test_keys_are_declared_by_schemas(self):
|
||
from web.backend.schemas import DouyuTaskAccountOut
|
||
|
||
payload = binding.bind_payload(Account())
|
||
declared = set(DouyuTaskAccountOut.model_fields)
|
||
missing = set(payload) - declared
|
||
assert not missing, f"bind_payload 产出了 schema 不认识的字段:{missing}"
|
||
|
||
def test_key_prefix_matches_column_name(self):
|
||
for scope, column in binding.SCOPE_COLUMNS.items():
|
||
prefix = binding.SCOPE_PAYLOAD_PREFIX[scope]
|
||
assert column == f"{prefix}_bind_status", (
|
||
f"{scope}: 列名 {column} 与载荷前缀 {prefix} 不一致"
|
||
)
|
||
|
||
def test_list_schema_declares_every_payload_field(self):
|
||
"""列表接口(AccountOut)也必须有这些字段,否则关键列会是空的。"""
|
||
from web.backend.schemas import AccountOut
|
||
|
||
payload = binding.bind_payload(Account())
|
||
declared = set(AccountOut.model_fields)
|
||
missing = set(payload) - declared
|
||
assert not missing, f"AccountOut 缺少绑定字段:{missing}"
|
||
|
||
|
||
class TestLegacyMapping:
|
||
"""只映射明确陈述绑定关系的取值,其余 unknown。"""
|
||
|
||
@pytest.mark.parametrize(
|
||
("scope", "legacy", "expected"),
|
||
[
|
||
# 明确陈述
|
||
(binding.SCOPE_ELITE, "bind_confirmed", "bound"),
|
||
(binding.SCOPE_ELITE, "game_not_bound", "unbound"),
|
||
(binding.SCOPE_ELITE, "bind_qr_generated", "pending"),
|
||
(binding.SCOPE_ESPORTS, "esports_bound", "bound"),
|
||
(binding.SCOPE_ESPORTS, "esports_bind_ready", "pending"),
|
||
(binding.SCOPE_ESPORTS, "esports_bind_confirming", "pending"),
|
||
(binding.SCOPE_ESPORTS, "esports_qr_created", "pending"),
|
||
(binding.SCOPE_ESPORTS, "esports_role_switching", "pending"),
|
||
(binding.SCOPE_PEACE, "xpd_bound", "bound"),
|
||
(binding.SCOPE_PEACE, "xpd_not_bound", "unbound"),
|
||
(binding.SCOPE_PEACE, "xpd_bind_qr_ready", "pending"),
|
||
# 什么都没说 → unknown(不猜)
|
||
(binding.SCOPE_ELITE, "game_queried", "unknown"),
|
||
(binding.SCOPE_ELITE, "change_time_queried", "unknown"),
|
||
(binding.SCOPE_ELITE, "elite_opened", "unknown"),
|
||
(binding.SCOPE_ELITE, "gold_recharged", "unknown"),
|
||
(binding.SCOPE_ELITE, "goods_locked", "unknown"),
|
||
(binding.SCOPE_ELITE, "csrf_token_ready", "unknown"),
|
||
(binding.SCOPE_ESPORTS, "esports_opened", "unknown"),
|
||
(binding.SCOPE_ESPORTS, "esports_points_queried", "unknown"),
|
||
(binding.SCOPE_PEACE, "xpd_goods_refreshed", "unknown"),
|
||
(binding.SCOPE_PEACE, "xpd_balance_queried", "unknown"),
|
||
# 空值
|
||
(binding.SCOPE_ELITE, "", "unknown"),
|
||
(binding.SCOPE_ELITE, None, "unknown"),
|
||
],
|
||
)
|
||
def test_mapping(self, scope, legacy, expected):
|
||
assert binding.map_legacy_bind_status(scope, legacy) == expected
|
||
|
||
def test_every_mapped_target_is_a_valid_state(self):
|
||
for scope, table in binding.LEGACY_MAPS.items():
|
||
for legacy, state in table.items():
|
||
assert state in binding.ALL_BIND_STATES, f"{scope}:{legacy}"
|
||
|
||
|
||
class TestMigrationMatchesRuntime(_Base):
|
||
"""迁移 `0044` 的 CASE 必须与 `LEGACY_*_BIND_MAP` 逐字对应。
|
||
|
||
分叉了不会报错,只会表现为「迁移刚跑完是 A、下一次任务又改成 B」。
|
||
这里把迁移里那三段 UPDATE 真跑一遍,与 Python 映射逐条比对。
|
||
"""
|
||
|
||
def _load_migration(self):
|
||
import importlib.util
|
||
from pathlib import Path
|
||
|
||
path = (
|
||
Path(__file__).resolve().parents[1]
|
||
/ "web/backend/migrations/versions/20261004_0044_bind_status_split.py"
|
||
)
|
||
spec = importlib.util.spec_from_file_location("mig_0044_bind_split", path)
|
||
assert spec and spec.loader
|
||
module = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(module)
|
||
return module
|
||
|
||
@staticmethod
|
||
def _equals_pairs(sql: str, column: str) -> dict[str, str]:
|
||
import re
|
||
|
||
return dict(re.findall(rf"WHEN {column} = '([a-z_]+)' THEN '([a-z_]+)'", sql))
|
||
|
||
@staticmethod
|
||
def _in_branch(sql: str, column: str) -> tuple[set[str], str]:
|
||
"""解析 `WHEN col IN ( ... ) THEN 'x'`(列表跨多行)。"""
|
||
import re
|
||
|
||
match = re.search(
|
||
rf"WHEN {column} IN \((.*?)\)\s*THEN '([a-z_]+)'", sql, re.DOTALL
|
||
)
|
||
if not match:
|
||
return set(), ""
|
||
values = set(re.findall(r"'([a-z_]+)'", match.group(1)))
|
||
return values, match.group(2)
|
||
|
||
def test_migration_sql_agrees_with_python_maps(self):
|
||
"""迁移里的 CASE 必须与 `LEGACY_*_BIND_MAP` 逐条对应。
|
||
|
||
做法是**解析迁移自己的 SQL 源码**(不是抄一份,也不是造数据跑):
|
||
旧取值最长 19 字符(`esports_bind_ready`),而列已收窄成 `String(16)`,
|
||
已经塞不进测试库了 —— 恰好说明「收窄必须放在归一化之后」是对的。
|
||
"""
|
||
import inspect
|
||
|
||
migration = self._load_migration()
|
||
|
||
# 精英:全部是 WHEN col = 'x' THEN 'y'
|
||
role_sql = inspect.getsource(migration.backfill_role_bind_status)
|
||
assert (
|
||
self._equals_pairs(role_sql, "bind_status") == binding.LEGACY_ROLE_BIND_MAP
|
||
)
|
||
|
||
# 电竞:等值分支 + 一个 IN 分支(都归 pending)
|
||
esports_sql = inspect.getsource(migration.normalise_esports_bind_status)
|
||
esports_map = self._equals_pairs(esports_sql, "esports_bind_status")
|
||
in_values, in_target = self._in_branch(esports_sql, "esports_bind_status")
|
||
assert in_target in binding.ALL_BIND_STATES
|
||
for value in in_values:
|
||
esports_map[value] = in_target
|
||
assert esports_map == binding.LEGACY_ESPORTS_BIND_MAP
|
||
|
||
# 小店:全部是等值分支
|
||
xpd_sql = inspect.getsource(migration.normalise_xpd_bind_status)
|
||
assert (
|
||
self._equals_pairs(xpd_sql, "xpd_bind_status")
|
||
== binding.LEGACY_XPD_BIND_MAP
|
||
)
|
||
|
||
def test_every_sql_branch_target_is_a_valid_state(self):
|
||
"""迁移不可能回填出枚举以外的值(否则运行时 get_bind_status 会兜成 unknown)。"""
|
||
import inspect
|
||
import re
|
||
|
||
migration = self._load_migration()
|
||
for func in (
|
||
migration.backfill_role_bind_status,
|
||
migration.normalise_esports_bind_status,
|
||
migration.normalise_xpd_bind_status,
|
||
):
|
||
targets = set(re.findall(r"THEN '([a-z_]+)'", inspect.getsource(func)))
|
||
assert targets, f"{func.__name__} 里没解析到 THEN 分支"
|
||
assert targets <= set(binding.ALL_BIND_STATES), targets
|
||
|
||
def test_migration_saves_esports_opened_before_normalising(self):
|
||
"""`upgrade()` 里必须先救 `esports_opened`、再归一化。
|
||
|
||
「电竞手册是否已开通」以前只寄生在 `esports_bind_status` 的 `esports_opened`
|
||
取值里,归一化会把它抹成 `unknown` —— **顺序反了就静默丢状态**。
|
||
"""
|
||
import inspect
|
||
|
||
migration = self._load_migration()
|
||
source = inspect.getsource(migration.upgrade)
|
||
save_pos = source.index("save_esports_opened")
|
||
normalise_pos = source.index("normalise_esports_bind_status")
|
||
assert save_pos < normalise_pos, (
|
||
"必须先救 esports_opened,再归一化 esports_bind_status"
|
||
)
|
||
|
||
|
||
class TestNoFreeTextWriters:
|
||
"""源码扫描:不允许再有人往绑定状态列写自由文本。
|
||
|
||
`bind_status` 就是这么烂掉的 —— 6 个文件、29 处写入、20 多种取值,
|
||
状态/动作/查询三种语义混在一个覆盖式列里。
|
||
"""
|
||
|
||
ALLOWED: ClassVar[frozenset[str]] = frozenset(
|
||
{
|
||
"web/backend/models.py",
|
||
"web/backend/services/account_binding.py",
|
||
}
|
||
)
|
||
|
||
def test_no_direct_bind_status_assignment(self):
|
||
import re
|
||
from pathlib import Path
|
||
|
||
root = Path(__file__).resolve().parents[1]
|
||
backend = root / "web" / "backend"
|
||
# ⚠️ 两个模式都**要求前面有个点**(属性赋值)。不带点会把关键字参数
|
||
# (`bind_status=bind_status,`)也算进来 —— 那是传参,不是写列。
|
||
patterns = (
|
||
re.compile(
|
||
r"\.(role_bind_status|esports_bind_status|xpd_bind_status)\s*=(?!=)"
|
||
),
|
||
# 旧的垃圾场列:任何人都不能再写
|
||
re.compile(r"\.bind_status\s*=(?!=)"),
|
||
)
|
||
offenders: list[str] = []
|
||
for path in backend.rglob("*.py"):
|
||
rel = path.relative_to(root).as_posix()
|
||
if rel in self.ALLOWED or "/migrations/" in rel or "huya" in rel:
|
||
continue
|
||
for lineno, line in enumerate(
|
||
path.read_text(encoding="utf-8").splitlines(), 1
|
||
):
|
||
stripped = line.lstrip()
|
||
if stripped.startswith("#"):
|
||
continue
|
||
# 过滤掉字典键("bind_status": ...)与比较
|
||
if any(p.search(line) for p in patterns):
|
||
offenders.append(f"{rel}:{lineno}: {stripped}")
|
||
|
||
assert not offenders, (
|
||
"绑定状态只能由 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}"
|