Files
live-hub-py/tests/test_account_binding.py
T
yml2213 a64380fbcc feat(account): S6 bind_status 三列拆解 —— 四值枚举、唯一写入口、前端改读账号
`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 天档,
保守起见留待确认(影响的是在用页面显示,不是历史)。
2026-10-04 16:28:06 +08:00

321 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)
)