把生产库克隆到本地做部署演练时,拉了三列绑定状态的**真实取值全集**,发现 `esports_bind_status` 里存在 `game_not_bound`(生产 26 行),而我的映射表里没有它 —— 会被归成 `unknown`,可它明明在说「没绑定」。 第一版映射只覆盖了文档里举的几个例子。**教训:映射表必须拿真实取值全集核对。** 修法:Python 映射与迁移 `0044` 的 CASE 同步补上 `game_not_bound → unbound` (两者有逐字比对用例,不会分叉),并新增一条用**生产实测取值全集**驱动的用例 (17 + 11 + 9 个取值),以后往这三列加新取值时会被提醒确认语义, 而不是默默变成 `unknown`。 已在生产克隆库上验证:电竞列 unbound 从 0 变成 26(正是那批)。
179 lines
6.3 KiB
Python
179 lines
6.3 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'
|
||
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)
|