`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 天档,
保守起见留待确认(影响的是在用页面显示,不是历史)。
178 lines
6.2 KiB
Python
178 lines
6.2 KiB
Python
"""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)
|