`accounts.bind_status` 是个**语义垃圾场**:一个自由文本列,6 个文件、**29 处写入**、
20 多种取值,混了三种东西 —— 真状态(`bind_confirmed`)、动作痕迹(`goods_locked`)、
纯查询痕迹(`points_queried`)。它是**覆盖式**的,所以 `bind_confirmed` 会被之后
任意一次查询盖掉 —— 「这个账号到底绑没绑」根本查不出来。
`esports_bind_status` / `xpd_bind_status` 是同一个病。
## 收敛成四值 + 三列一义
`unknown` / `unbound` / `pending` / `bound`,每个作用域一列:
| 列 | 作用域 |
| --- | --- |
| `accounts.role_bind_status`(**新增**) | 精英宝典角色 |
| `accounts.esports_bind_status`(收敛) | 电竞手册角色 |
| `accounts.xpd_bind_status`(收敛) | 和平小店角色 |
- 新增 `services/account_binding.py`:唯一写入口 `set_bind_status()`,
传旧自由文本直接 `ValueError`(否则垃圾会被写回去);`map_legacy_bind_status()` 负责旧值映射。
- **29 处自由文本写入全部删除或改走枚举**:真状态走枚举,动作/查询痕迹**不再写状态列**
(动作已由 S5 的直发事件记录,查询痕迹本来就不该是状态)。
- `accounts.bind_status` 保留原值便于比对,**已无任何写入方**,待 S8 drop。
- 一条**源码扫描用例**禁止任何地方再出现 `account.bind_status = "..."` 这类写入。
## 回填不猜(设计文档 §5.5)
只映射**明确陈述了绑定关系**的取值(`bind_confirmed`/`esports_bound`/`xpd_bound` → bound,
`*_qr_*`/`*_ready` → pending,`game_not_bound`/`xpd_not_bound` → unbound),
**其余一律 unknown**。因为列是覆盖式的,「最后一件事是查询」并不代表没绑定。
迁移 SQL 与 Python 映射有一致性用例:**解析迁移源码里的 CASE 逐条比对**
(已反向验证:故意改错一条映射,用例立刻红)。
## ⚠️ 顺带救回一个差点被我自己丢掉的状态
「**电竞手册是否已开通**」以前**只寄生在 `esports_bind_status` 的 `esports_opened`
取值里**(`elite_opened` 有独立布尔列,电竞没有)。归一化会把那个取值抹成 `unknown`
→ **静默丢状态**。所以:
- 新增 `accounts.esports_opened` 布尔列(与 `elite_opened` 对称),开通任务成功时置位;
- 迁移里**必须先 `save_esports_opened()` 再归一化**,顺序用例钉住;
- 详情页「电竞手册」开通状态改读 `esports_opened`。
## 前端不再从任务日志反推账号状态
工作台原来从**当前加载的那一页任务**里挑每个账号最新的一条来推绑定状态
(`latestConfirmBindTaskByAccount` 等)。两个问题:① 支撑任务一旦翻出窗口,
**显示的状态就是错的**;② 这让任务日志变成「账号状态库」,于是它永远不敢清理。
现在状态读账号字段,任务日志只补「扫码流程中的瞬时信息」(如确认前的
`pending_role_name`)。删掉了两个不再需要的派生与对应的 `clearPending` 绕行逻辑。
## 踩到并修掉的东西
- **模型与迁移不一致**:我把模型收窄成 `String(16)`,迁移却留着 `String(32)` ——
测试库按模型建、生产按迁移建,两边列宽不同。已让迁移也收窄,且**收窄必须在
归一化之后**(旧取值最长 19 字符,先收窄会截断成废值)。downgrade 也补齐了反向操作,
已实测 `downgrade -1 && upgrade head` 干净。
- **parity 用例第一版是抄了一份 SQL**(迁移改了用例也不会红,等于没测)——
改成解析迁移源码;后来又因为列已收窄、19 字符的旧值塞不进测试库,
干脆改成源码级解析比对(不造数据)。
## 验证
- `TZ=Asia/Shanghai`:**490 passed / 6 skipped**(S5 后 450 → 净增 40)
- `TZ=UTC`(相关文件)106 passed;迁移链 3 passed;ruff 全绿
- 前端 `tsc -b && vite build` 通过、`npm run lint` **0 errors**
- 真实开发库:迁移重放后三列均为 `varchar(16) NOT NULL DEFAULT 'unknown'`;
12 个账号的旧值 `elite_status_queried`(纯查询痕迹)→ **unknown**(没有猜成 bound)
未做(后续):S6 的**账号页重构**(列表关键列 / 生命周期主页)保留;
`bind_status` 旧列待 S8 drop;保留策略里「状态派生类查询」现在技术上可下探到 7 天档,
保守起见留待确认(影响的是在用页面显示,不是历史)。
321 lines
12 KiB
Python
321 lines
12 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["elite_bind_status"] == "bound"
|
||
assert payload["elite_bind_label"] == "已绑定"
|
||
assert payload["esports_bind_status"] == "unknown"
|
||
assert payload["peace_bind_label"] == "未知"
|
||
assert set(payload) == {
|
||
f"{scope}_{suffix}"
|
||
for scope in binding.ALL_SCOPES
|
||
for suffix in ("bind_status", "bind_label")
|
||
}
|
||
|
||
|
||
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"
|
||
patterns = (
|
||
re.compile(
|
||
r"\.(role_bind_status|esports_bind_status|xpd_bind_status)\s*=(?!=)"
|
||
),
|
||
# 旧的垃圾场列:任何人都不能再写
|
||
re.compile(r"\bbind_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)
|
||
)
|