Files
live-hub-py/web/backend/services/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

154 lines
6.0 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.
"""角色绑定状态:把三个「垃圾场」列收敛成明确枚举。
## 背景:`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