Files
live-hub-py/web/backend/migrations/versions/20261004_0044_bind_status_split.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

178 lines
6.2 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:`bind_status` 三列拆解
把三个语义混杂的自由文本列收敛成明确枚举:
| 列 | 作用域 | 取值 |
| --- | --- | --- |
| `accounts.role_bind_status`(**新增**) | 精英宝典角色 | unknown / unbound / pending / bound |
| `accounts.esports_bind_status`(收敛) | 电竞手册角色 | 同上 |
| `accounts.xpd_bind_status`(收敛) | 和平小店角色 | 同上 |
旧的 `accounts.bind_status` **不动**(保留原值便于人工比对,计划 S8 再 drop),
但代码里所有写入方在本次一并删除。
## 回填原则(设计文档 §5.5):不猜测
旧值是**覆盖式**的 —— `bind_confirmed` 会被之后任意一次查询
(例如 `limited_goods_queried`)盖掉,所以「最后一件事是查询」并不代表账号没绑定。
因此只映射**明确陈述了绑定关系**的取值,其余一律 `unknown`:
- `bind_confirmed` / `esports_bound` / `xpd_bound` → `bound`
(确认成功那一刻写的,而绑定不会被之后的查询撤销)
- `bind_qr_generated` / `esports_bind_ready` / `esports_bind_confirming` /
`esports_qr_created` / `esports_role_switching` / `xpd_bind_qr_ready` → `pending`
- `game_not_bound` / `xpd_not_bound` → `unbound`
- **其余全部 `unknown`**(含 `game_queried` / `points_queried` / `goods_locked` 等)
⚠️ 下面的 CASE **必须与 `services/account_binding.py` 里的
`LEGACY_*_BIND_MAP` 逐字对应**,有一条用例真跑这段 SQL 与它们比对。
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision: str = "20261004_0044"
down_revision: str | None = "20261004_0043"
branch_labels: str | None = None
depends_on: str | None = None
COLUMN = "role_bind_status"
def save_esports_opened(bind) -> None:
"""先把「电竞手册已开通」救到独立列。
⚠️ **必须在归一化 `esports_bind_status` 之前跑** —— 那个状态以前只寄生在
该列的 `esports_opened` 取值里,归一化会把它抹成 `unknown`,**静默丢状态**。
"""
bind.execute(
sa.text(
"""
UPDATE accounts
SET esports_opened = (esports_bind_status = 'esports_opened')
"""
)
)
def backfill_role_bind_status(bind) -> None:
"""精英:从旧的自由文本列回填(只映射明确陈述绑定关系的取值)。"""
bind.execute(
sa.text(
"""
UPDATE accounts
SET role_bind_status = CASE
WHEN bind_status = 'bind_confirmed' THEN 'bound'
WHEN bind_status = 'bind_qr_generated' THEN 'pending'
WHEN bind_status = 'game_not_bound' THEN 'unbound'
ELSE 'unknown'
END
"""
)
)
def normalise_esports_bind_status(bind) -> None:
bind.execute(
sa.text(
"""
UPDATE accounts
SET esports_bind_status = CASE
WHEN esports_bind_status = 'esports_bound' THEN 'bound'
WHEN esports_bind_status IN (
'esports_bind_ready',
'esports_bind_confirming',
'esports_qr_created',
'esports_role_switching'
) THEN 'pending'
ELSE 'unknown'
END
"""
)
)
def normalise_xpd_bind_status(bind) -> None:
bind.execute(
sa.text(
"""
UPDATE accounts
SET xpd_bind_status = CASE
WHEN xpd_bind_status = 'xpd_bound' THEN 'bound'
WHEN xpd_bind_status = 'xpd_bind_qr_ready' THEN 'pending'
WHEN xpd_bind_status = 'xpd_not_bound' THEN 'unbound'
ELSE 'unknown'
END
"""
)
)
def upgrade() -> None:
op.add_column(
"accounts",
sa.Column(
COLUMN,
sa.String(length=16),
nullable=False,
server_default="unknown",
comment="精英宝典角色绑定:unknown/unbound/pending/bound",
),
)
# 「电竞手册是否已开通」以前**只寄生在 esports_bind_status 里**(取值
# `esports_opened`)。归一化会把那个取值抹掉,所以必须先把它救到独立列里,
# **否则这一步会静默丢掉「已开通」这个状态**。
op.add_column(
"accounts",
sa.Column(
"esports_opened",
sa.Boolean(),
nullable=True,
comment="电竞手册是否已开通(与 elite_opened 对称)",
),
)
bind = op.get_bind()
# 顺序不能换:先救 esports_opened,再归一化
save_esports_opened(bind)
backfill_role_bind_status(bind)
normalise_esports_bind_status(bind)
normalise_xpd_bind_status(bind)
# 归一化之后把两列收窄成与模型一致的 `String(16)` + 服务器默认值。
# ⚠️ 必须放在归一化**之后**:旧取值最长 19 字符(`esports_bind_ready`),
# 先收窄会把它们截断成一堆废值。
for column in ("esports_bind_status", "xpd_bind_status"):
op.alter_column(
"accounts",
column,
existing_type=sa.String(length=32),
type_=sa.String(length=16),
existing_nullable=False,
existing_server_default=None,
server_default="unknown",
comment="角色绑定:unknown/unbound/pending/bound",
)
def downgrade() -> None:
"""回退只摘掉新列。
刻意**不还原**电竞/小店的旧自由文本取值:那些值(`esports_points_queried` 之类)
已经和信息量更大的动作痕迹一起废弃了,编不回去;而 `bind_status` 原列一直没动,
真要排查历史可以直接看它。
"""
for column in ("esports_bind_status", "xpd_bind_status"):
op.alter_column(
"accounts",
column,
existing_type=sa.String(length=16),
type_=sa.String(length=32),
existing_nullable=False,
existing_server_default="unknown",
server_default=None,
)
op.drop_column("accounts", "esports_opened")
op.drop_column("accounts", COLUMN)