`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 天档,
保守起见留待确认(影响的是在用页面显示,不是历史)。
154 lines
6.0 KiB
Python
154 lines
6.0 KiB
Python
"""角色绑定状态:把三个「垃圾场」列收敛成明确枚举。
|
||
|
||
## 背景:`bind_status` 崩掉的方式
|
||
|
||
历史上账号的绑定状态存在**一个自由文本列** `accounts.bind_status` 里,写入方遍布
|
||
6 个文件、29 处,取值 20 多种,而且**语义混杂**:
|
||
|
||
- 真正的状态:`bind_confirmed` / `game_not_bound`
|
||
- 动作痕迹:`bind_qr_generated` / `goods_locked` / `elite_opened`
|
||
- 纯查询痕迹:`points_queried` / `change_time_queried` / `csrf_token_ready`
|
||
|
||
因为它是**覆盖式**的,`bind_confirmed` 会被之后任意一次查询(例如
|
||
`limited_goods_queried`)盖掉 —— 于是「这个账号到底绑没绑」查不出来。
|
||
`esports_bind_status` / `xpd_bind_status` 是同一个病。
|
||
|
||
## 收敛规则(设计文档 §3.6)
|
||
|
||
1. **状态列只放「现在是什么」**:`unknown` / `unbound` / `pending` / `bound`;
|
||
2. **动作与查询痕迹一律不再写状态列** —— 动作已经进 `account_events`
|
||
(S5 的直发事件),查询痕迹本来就不该是状态;
|
||
3. 三列各管一个作用域,**一列一义**:
|
||
|
||
| 列 | 作用域 |
|
||
| --- | --- |
|
||
| `accounts.role_bind_status` | 精英宝典角色绑定 |
|
||
| `accounts.esports_bind_status` | 电竞手册角色绑定 |
|
||
| `accounts.xpd_bind_status` | 和平小店角色绑定 |
|
||
|
||
> ⚠️ `pending` 的含义是「角色已就绪 / 二维码已出,但**还没确认生效**」。
|
||
> 斗鱼侧的疑点宁可为 `unknown`,也不要冒充 `bound`。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from ..models import Account
|
||
|
||
# ── 作用域 ──────────────────────────────────────────────
|
||
SCOPE_ELITE = "elite"
|
||
SCOPE_ESPORTS = "esports"
|
||
SCOPE_PEACE = "peace"
|
||
ALL_SCOPES = (SCOPE_ELITE, SCOPE_ESPORTS, SCOPE_PEACE)
|
||
|
||
# ── 取值域(**只有这四个**)────────────────────────────
|
||
BIND_UNKNOWN = "unknown" # 还没查过 / 旧数据不可信
|
||
BIND_UNBOUND = "unbound" # 明确没绑定
|
||
BIND_PENDING = "pending" # 角色就绪或二维码已出,尚未确认生效
|
||
BIND_BOUND = "bound" # 确认生效
|
||
|
||
ALL_BIND_STATES = (BIND_UNKNOWN, BIND_UNBOUND, BIND_PENDING, BIND_BOUND)
|
||
|
||
BIND_LABELS = {
|
||
BIND_UNKNOWN: "未知",
|
||
BIND_UNBOUND: "未绑定",
|
||
BIND_PENDING: "待确认",
|
||
BIND_BOUND: "已绑定",
|
||
}
|
||
|
||
#: 作用域 → `accounts` 上的列名
|
||
SCOPE_COLUMNS = {
|
||
SCOPE_ELITE: "role_bind_status",
|
||
SCOPE_ESPORTS: "esports_bind_status",
|
||
SCOPE_PEACE: "xpd_bind_status",
|
||
}
|
||
|
||
SCOPE_LABELS = {
|
||
SCOPE_ELITE: "精英宝典角色",
|
||
SCOPE_ESPORTS: "电竞手册角色",
|
||
SCOPE_PEACE: "和平小店角色",
|
||
}
|
||
|
||
# ── 旧自由文本 → 新枚举 ─────────────────────────────────
|
||
#
|
||
# 只映射**无歧义**的取值;其余一律 `unknown`。判定标准是「这个旧值是不是
|
||
# 明确陈述了绑定关系」:
|
||
# - `bind_confirmed` 是「确认绑定成功」那一刻写的,而绑定不会被之后的查询撤销
|
||
# → 可以确信是 bound;
|
||
# - `game_not_bound` 明确说没绑 → unbound;
|
||
# - `bind_qr_generated` / `xpd_bind_qr_ready` 只是出了二维码 → pending;
|
||
# - `game_queried` / `points_queried` / `goods_locked` 之类**什么都没说**
|
||
# → unknown(设计文档 §5.5:不猜测性回填)。
|
||
#
|
||
# ⚠️ 这三张表**必须与迁移 `20261004_0044` 里的 CASE 逐字对应**,有一条用例
|
||
# 真跑那段 SQL 与它们比对(分叉了不会报错,只会让回填结果与运行时不一致)。
|
||
|
||
LEGACY_ROLE_BIND_MAP: dict[str, str] = {
|
||
"bind_confirmed": BIND_BOUND,
|
||
"bind_qr_generated": BIND_PENDING,
|
||
"game_not_bound": BIND_UNBOUND,
|
||
}
|
||
|
||
LEGACY_ESPORTS_BIND_MAP: dict[str, str] = {
|
||
"esports_bound": BIND_BOUND,
|
||
"esports_bind_ready": BIND_PENDING,
|
||
"esports_bind_confirming": BIND_PENDING,
|
||
"esports_qr_created": BIND_PENDING,
|
||
"esports_role_switching": BIND_PENDING,
|
||
}
|
||
|
||
LEGACY_XPD_BIND_MAP: dict[str, str] = {
|
||
"xpd_bound": BIND_BOUND,
|
||
"xpd_bind_qr_ready": BIND_PENDING,
|
||
"xpd_not_bound": BIND_UNBOUND,
|
||
}
|
||
|
||
LEGACY_MAPS = {
|
||
SCOPE_ELITE: LEGACY_ROLE_BIND_MAP,
|
||
SCOPE_ESPORTS: LEGACY_ESPORTS_BIND_MAP,
|
||
SCOPE_PEACE: LEGACY_XPD_BIND_MAP,
|
||
}
|
||
|
||
|
||
def map_legacy_bind_status(scope: str, legacy: str | None) -> str:
|
||
"""旧自由文本 → 新枚举;认不出来就是 `unknown`(**不猜**)。"""
|
||
return LEGACY_MAPS.get(scope, {}).get((legacy or "").strip(), BIND_UNKNOWN)
|
||
|
||
|
||
def get_bind_status(account: Account, scope: str) -> str:
|
||
column = SCOPE_COLUMNS.get(scope)
|
||
if not column:
|
||
raise ValueError(f"未知的绑定作用域: {scope}")
|
||
value = getattr(account, column, "") or ""
|
||
# 兼容:迁移之前的库或测试构造的对象可能还没有新列的值
|
||
return value if value in ALL_BIND_STATES else BIND_UNKNOWN
|
||
|
||
|
||
def set_bind_status(account: Account, scope: str, state: str) -> bool:
|
||
"""设置某个作用域的绑定状态,返回是否真的变化。
|
||
|
||
**这是三个绑定状态列的唯一写入口**(同 `account_lifecycle` 之于 `status`)。
|
||
`state` 必须是四个枚举值之一;传旧自由文本会直接报错,避免又把垃圾写回去。
|
||
"""
|
||
if state not in ALL_BIND_STATES:
|
||
raise ValueError(
|
||
f"绑定状态只能是 {ALL_BIND_STATES} 之一,收到 {state!r}。"
|
||
"动作/查询痕迹请进 account_events,不要写状态列。"
|
||
)
|
||
column = SCOPE_COLUMNS.get(scope)
|
||
if not column:
|
||
raise ValueError(f"未知的绑定作用域: {scope}")
|
||
if getattr(account, column, None) == state:
|
||
return False
|
||
setattr(account, column, state)
|
||
return True
|
||
|
||
|
||
def bind_payload(account: Account) -> dict[str, str]:
|
||
"""给接口/前端用的三列快照(值 + 中文标签)。"""
|
||
payload: dict[str, str] = {}
|
||
for scope in ALL_SCOPES:
|
||
state = get_bind_status(account, scope)
|
||
payload[f"{scope}_bind_status"] = state
|
||
payload[f"{scope}_bind_label"] = BIND_LABELS[state]
|
||
return payload
|