Files
live-hub-py/web/backend/migrations/versions/20261004_0044_bind_status_split.py
T
yml2213 4c1fe0c84f fix(account): 补上电竞绑定映射漏掉的 game_not_bound(连生产库才发现的)
把生产库克隆到本地做部署演练时,拉了三列绑定状态的**真实取值全集**,发现
`esports_bind_status` 里存在 `game_not_bound`(生产 26 行),而我的映射表里没有它
—— 会被归成 `unknown`,可它明明在说「没绑定」。

第一版映射只覆盖了文档里举的几个例子。**教训:映射表必须拿真实取值全集核对。**

修法:Python 映射与迁移 `0044` 的 CASE 同步补上 `game_not_bound → unbound`
(两者有逐字比对用例,不会分叉),并新增一条用**生产实测取值全集**驱动的用例
(17 + 11 + 9 个取值),以后往这三列加新取值时会被提醒确认语义,
而不是默默变成 `unknown`。

已在生产克隆库上验证:电竞列 unbound 从 0 变成 26(正是那批)。
2026-10-04 17:49:02 +08:00

179 lines
6.3 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'
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)