"""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' WHEN esports_bind_status = 'game_not_bound' THEN 'unbound' 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)