S6 的展示层部分(数据模型部分见上一个提交)。目标:客服打开账号页要能一眼回答
「这账号能不能用、要不要我处理、账上还有多少」。
## 账号列表:加关键列 + 三个筛选
新增四列,**全部直接来自 `accounts` 行**(不 JOIN、不聚合 —— 列表是全站最热接口):
| 列 | 内容 |
| --- | --- |
| **CK** | 有无 CK / 正常 / 已失效 / 检测异常 + 最近检测时间 |
| **绑定** | 精/电/店 三个作用域各一个小标签(✓ / … / ✗ / ?) |
| **积分 / 鱼翅** | 两行小字 |
新增筛选:**CK 健康**、**角色绑定**(三个作用域任一命中即算)。
`AccountOut` 相应补齐 `has_cookie` / `ck_check_status` / `ck_checked_at` /
`points` / `gold_balance` / 三组绑定状态与标签。
## 账号详情页:生命周期主页
顶部新增**生命周期状态卡**,把之前散落的信息聚成一眼可读的几格:
- **生命周期状态**:状态标签 + 不参与自动化标记 + 变更时间与原因
- **CK**:有无 / 健康 / 最近检测时间
- **角色绑定**:精/电/店三格
- **账上还有多少**:积分 / 鱼翅 / 兑换余额
- **归属与标注**:分配客服(下拉)+ 标签(就地编辑)—— 之前详情页只能看不能改
业务台账补上**登录成功次数**与**最近登录**(`stats` 里本来就有,只是没展示)。
时间线已有分类筛选,生命周期操作入口(停用/归档/恢复/自动化)S3 已加。
## ⚠️ 顺带修掉一个静默 bug
`bind_payload()` 的字段名按**作用域名**拼成了 `elite_bind_status` / `peace_bind_status`,
而 schema 声明的是 `role_bind_status` / `xpd_bind_status`。Pydantic 默认
`extra='ignore'`,于是多出来的键**被静默丢弃**、声明过的字段保持默认值 ——
**接口永远返回 unknown 而不报任何错**(这正是它躲过前面几轮测试的原因)。
修法:前缀改为由**列名**推导(`SCOPE_PAYLOAD_PREFIX`),并在模块导入时自检
「载荷前缀 == 列名前缀」。另加两条用例:`bind_payload` 的键必须都被
`DouyuTaskAccountOut` / `AccountOut` 声明(这是能抓到这类错的那种用例)。
顺带发现我自己之前那条 payload 用例**把错误的前缀当成了预期**,一并修正。
## 自己踩的坑
详情页加客服列表时又把 `useEffect` 写在了早期 `return` 之后 ——
和本次会话前面修 `LoginTasksPage` 的是同一个错。eslint(`react-hooks/rules-of-hooks`)
当场抓住,已上移。**`tsc` 与 `vite build` 都不会报这个**。
另外:列表关键列那处编辑我曾以为已生效(脚本打印了成功),实际没落到文件里 ——
后面是靠「读回文件确认」和用例失败才发现。已改为每次都读回核对。
## 验证
- `TZ=Asia/Shanghai`:**497 passed / 6 skipped**(S6 前半 490 → +7)
- `TZ=UTC`(相关文件)通过;ruff 全绿;前端 build 通过、lint **0 errors**
- **真实开发库端到端**:列表与工作台接口都返回了真实值
(`has_cookie=True` / `ck_check_status=valid` / `role_bind_label=未知`),
修 bug 前这两处返回的是被丢弃后的默认值
未做:`bind_status` 旧列待 S8 drop;保留策略里「状态派生类查询」已可下探到 7 天档(待确认)。
357 lines
14 KiB
Python
357 lines
14 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)
|
||
)
|