Files
live-hub-py/web/backend/services/account_binding.py
T
yml2213 8f231e6edf fix(binding): 角色绑定只看 is_bound_role —— 「是否开通」与「是否绑定角色」是两件事
用户明确指出:**精英/电竞是否开通,和是否绑定角色没关系**。

## 我确实混了(虽然这次没触发)

`derive_from_bind_info` 里有个兜底:

    if bound_role is True or bound_act is True: return BOUND

`role_bind_status` 这一列问的是「**角色**绑没绑」,而 `is_bound_act` 是
「**活动**维度」的标志 —— 拿它兜底就是把两件事混起来。
一旦有账号 `is_bound_act=True` 而角色未绑,就会报出"角色已绑定"的**错结论**。

## 生产数据证实了用户的说法

查已查询的 196 个账号:

    电竞 opened=False 且 bind=bound  →  **187 个**

也就是**187 个账号绑了角色、但电竞手册压根没开通** —— 两者完全独立。

同时数据也证明 `is_bound_role` 本身就够用(不需要那个兜底):

    判为 bound 的里面:精英 183 个全都有角色名(0 个没有)
                      电竞 187 个全都有角色名(0 个没有)

## 改法

去掉 `is_bound_act` 的参与,**只看 `is_bound_role`**:

    is_bound_role 真  → bound
    is_bound_role 假  → unbound
    缺失              → None(保持 unknown,别把"没查到"写成"没绑定")

语义上不再有"把活动当角色"的路径。

## 测试

把两条**编码了旧行为**的用例改掉,并补一条钉住领域事实:

- 只有 `is_bound_act` 没有 `is_bound_role` → **None**(推不出角色结论)
- `{"is_bound_role": True, "manual_type": 0, "expire_time": "0"}` → **bound**
  (绑了角色+没开通,两者独立,不该因为"没开通"而变成别的)

## 验证(本地)

TZ=Asia/Shanghai **620 passed / 6 skipped**;ruff 全绿。
2026-10-04 21:01:42 +08:00

242 lines
10 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.
"""角色绑定状态:把三个「垃圾场」列收敛成明确枚举。
## 背景:`bind_status` 崩掉的方式
历史上账号的绑定状态存在**一个自由文本列** `accounts.bind_status` 里,写入方遍布
6 个文件、29 处,取值 20 多种,而且**语义混杂**:
- 真正的状态:`bind_confirmed` / `game_not_bound`
- 动作痕迹:`bind_qr_generated` / `goods_locked` / `elite_opened`
- 纯查询痕迹:`points_queried` / `change_time_queried` / `csrf_token_ready`
因为它是**覆盖式**的,`bind_confirmed` 会被之后任意一次查询(例如
`limited_goods_queried`)盖掉 —— 于是「这个账号到底绑没绑」查不出来。
`esports_bind_status` / `xpd_bind_status` 是同一个病。
## 收敛规则(设计文档 §3.6)
1. **状态列只放「现在是什么」**:`unknown` / `unbound` / `pending` / `bound`;
2. **动作与查询痕迹一律不再写状态列** —— 动作已经进 `account_events`
(S5 的直发事件),查询痕迹本来就不该是状态;
3. 三列各管一个作用域,**一列一义**:
| 列 | 作用域 |
| --- | --- |
| `accounts.role_bind_status` | 精英宝典角色绑定 |
| `accounts.esports_bind_status` | 电竞手册角色绑定 |
| `accounts.xpd_bind_status` | 和平小店角色绑定 |
> ⚠️ `pending` 的含义是「角色已就绪 / 二维码已出,但**还没确认生效**」。
> 斗鱼侧的疑点宁可为 `unknown`,也不要冒充 `bound`。
"""
from __future__ import annotations
from ..models import Account
# ── 作用域 ──────────────────────────────────────────────
SCOPE_ELITE = "elite"
SCOPE_ESPORTS = "esports"
SCOPE_PEACE = "peace"
ALL_SCOPES = (SCOPE_ELITE, SCOPE_ESPORTS, SCOPE_PEACE)
# ── 取值域(**只有这四个**)────────────────────────────
BIND_UNKNOWN = "unknown" # 还没查过 / 旧数据不可信
BIND_UNBOUND = "unbound" # 明确没绑定
BIND_PENDING = "pending" # 角色就绪或二维码已出,尚未确认生效
BIND_BOUND = "bound" # 确认生效
ALL_BIND_STATES = (BIND_UNKNOWN, BIND_UNBOUND, BIND_PENDING, BIND_BOUND)
BIND_LABELS = {
BIND_UNKNOWN: "未知",
BIND_UNBOUND: "未绑定",
BIND_PENDING: "待确认",
BIND_BOUND: "已绑定",
}
#: 作用域 → `accounts` 上的列名
SCOPE_COLUMNS = {
SCOPE_ELITE: "role_bind_status",
SCOPE_ESPORTS: "esports_bind_status",
SCOPE_PEACE: "xpd_bind_status",
}
#: 作用域 → 接口载荷里的字段前缀。
#: ⚠️ **必须与列名一致**(`role_bind_status` / `esports_bind_status` / `xpd_bind_status`)。
#: 第一版这里按作用域名拼成了 `elite_bind_status` / `peace_bind_status`,
#: 与 schema 声明的字段名对不上 —— Pydantic 会静默用默认值,
#: 表现为「接口永远返回 unknown」,且**不报错**。有一条用例比对 schema 字段名。
SCOPE_PAYLOAD_PREFIX = {
SCOPE_ELITE: "role",
SCOPE_ESPORTS: "esports",
SCOPE_PEACE: "xpd",
}
SCOPE_LABELS = {
SCOPE_ELITE: "精英宝典角色",
SCOPE_ESPORTS: "电竞手册角色",
SCOPE_PEACE: "和平小店角色",
}
# ── 旧自由文本 → 新枚举 ─────────────────────────────────
#
# 只映射**无歧义**的取值;其余一律 `unknown`。判定标准是「这个旧值是不是
# 明确陈述了绑定关系」:
# - `bind_confirmed` 是「确认绑定成功」那一刻写的,而绑定不会被之后的查询撤销
# → 可以确信是 bound;
# - `game_not_bound` 明确说没绑 → unbound;
# - `bind_qr_generated` / `xpd_bind_qr_ready` 只是出了二维码 → pending;
# - `game_queried` / `points_queried` / `goods_locked` 之类**什么都没说**
# → unknown(设计文档 §5.5:不猜测性回填)。
#
# ⚠️ 这三张表**必须与迁移 `20261004_0044` 里的 CASE 逐字对应**,有一条用例
# 真跑那段 SQL 与它们比对(分叉了不会报错,只会让回填结果与运行时不一致)。
LEGACY_ROLE_BIND_MAP: dict[str, str] = {
"bind_confirmed": BIND_BOUND,
"bind_qr_generated": BIND_PENDING,
"game_not_bound": BIND_UNBOUND,
}
LEGACY_ESPORTS_BIND_MAP: dict[str, str] = {
"esports_bound": BIND_BOUND,
"esports_bind_ready": BIND_PENDING,
"esports_bind_confirming": BIND_PENDING,
"esports_qr_created": BIND_PENDING,
"esports_role_switching": BIND_PENDING,
# ⚠️ 这一条是**连生产库看真实数据**才发现的:电竞列里确实存在
# `game_not_bound`(生产 26 行)。漏了它就会被归成 `unknown`,
# 而它明明在说「没绑定」。教训:映射表必须拿**真实取值全集**核对,
# 不能只看文档里举的几个例子。
"game_not_bound": BIND_UNBOUND,
}
LEGACY_XPD_BIND_MAP: dict[str, str] = {
"xpd_bound": BIND_BOUND,
"xpd_bind_qr_ready": BIND_PENDING,
"xpd_not_bound": BIND_UNBOUND,
}
LEGACY_MAPS = {
SCOPE_ELITE: LEGACY_ROLE_BIND_MAP,
SCOPE_ESPORTS: LEGACY_ESPORTS_BIND_MAP,
SCOPE_PEACE: LEGACY_XPD_BIND_MAP,
}
def map_legacy_bind_status(scope: str, legacy: str | None) -> str:
"""旧自由文本 → 新枚举;认不出来就是 `unknown`(**不猜**)。"""
return LEGACY_MAPS.get(scope, {}).get((legacy or "").strip(), BIND_UNKNOWN)
def _assert_prefixes_match_columns() -> None:
"""自检:载荷前缀必须等于列名前缀(`role_bind_status` ↔ `role_bind_*`)。
这是被真实 bug 逼出来的 —— 前缀写错只会让接口静默返回默认值。
"""
for scope, column in SCOPE_COLUMNS.items():
expected = column.removesuffix("_bind_status")
assert SCOPE_PAYLOAD_PREFIX[scope] == expected, (
f"{scope}: 载荷前缀 {SCOPE_PAYLOAD_PREFIX[scope]!r} "
f"与列名 {column!r} 不一致"
)
_assert_prefixes_match_columns()
def get_bind_status(account: Account, scope: str) -> str:
column = SCOPE_COLUMNS.get(scope)
if not column:
raise ValueError(f"未知的绑定作用域: {scope}")
value = getattr(account, column, "") or ""
# 兼容:迁移之前的库或测试构造的对象可能还没有新列的值
return value if value in ALL_BIND_STATES else BIND_UNKNOWN
def set_bind_status(account: Account, scope: str, state: str) -> bool:
"""设置某个作用域的绑定状态,返回是否真的变化。
**这是三个绑定状态列的唯一写入口**(同 `account_lifecycle` 之于 `status`)。
`state` 必须是四个枚举值之一;传旧自由文本会直接报错,避免又把垃圾写回去。
"""
if state not in ALL_BIND_STATES:
raise ValueError(
f"绑定状态只能是 {ALL_BIND_STATES} 之一,收到 {state!r}。"
"动作/查询痕迹请进 account_events,不要写状态列。"
)
column = SCOPE_COLUMNS.get(scope)
if not column:
raise ValueError(f"未知的绑定作用域: {scope}")
if getattr(account, column, None) == state:
return False
setattr(account, column, state)
return True
def bind_payload(account: Account) -> dict[str, str]:
"""给接口/前端用的三列快照(值 + 中文标签)。"""
payload: dict[str, str] = {}
for scope in ALL_SCOPES:
prefix = SCOPE_PAYLOAD_PREFIX[scope]
state = get_bind_status(account, scope)
payload[f"{prefix}_bind_status"] = state
payload[f"{prefix}_bind_label"] = BIND_LABELS[state]
return payload
def derive_from_bind_info(info: dict | None) -> str | None:
"""从斗鱼 `bind_info` 的返回推导绑定状态;**推导不出来时返回 None**。
返回 None 表示「这次查询没能得出可靠结论」—— 调用方应当**保持原值或写
unknown**,绝不要把它当成 `unbound`。"没查到"和"没绑定"必须分开。
判定依据(字段含义见 `core/douyu/activity_client.py:_parse_bind_info`):
- `is_bound_role` 为真 → **bound**(已经绑了角色)
- `is_bound_role` 为假 → **unbound**(接口明确说了没绑)
- 缺失(接口没给 / 解析失败)→ **None**(推不出来,别猜)
⚠️ **只看 `is_bound_role`,不掺 `is_bound_act`。**
这一列问的是「**角色**绑没绑」,而**「活动是否开通/参与」和「是否绑定角色」
是两件独立的事**(用户明确指出的领域事实)。生产实测也印证了:
电竞 opened=False 且 bind=bound → 187 个账号
也就是说这 187 个账号**绑了角色、但手册压根没开通**。
曾经用 `is_bound_act` 做兜底("避免漏掉已绑但没回角色名的"),
那是把"活动维度"当成了"角色维度" —— 一旦有账号 `is_bound_act=True`
而角色未绑,就会报出"角色已绑定"的**错结论**。实测没触发过,
但语义是错的,所以去掉。(当时那些判为 bound 的账号 100% 都有角色名,
证明 `is_bound_role` 本身就够用。)
⚠️ 这里刻意**不产出 pending**:`pending` 描述的是"正在绑定/待确认"这种
**进行中的动作**(例如刚生成过绑定二维码),那是一次查询的**副作用**,
不是查询能观测到的状态。硬用查询结果去推 pending 就是编造。
"""
if not isinstance(info, dict):
return None
def flag(key: str) -> bool | None:
value = info.get(key)
if value is None:
return None
if isinstance(value, bool):
return value
text = str(value).strip().lower()
if text in {"1", "true", "yes", "y"}:
return True
if text in {"0", "false", "no", "n", ""}:
return False
return None
bound_role = flag("is_bound_role")
if bound_role is True:
return BIND_BOUND
if bound_role is False:
return BIND_UNBOUND
# 接口没给结论:保持 unknown,别把"没查到"写成"没绑定"
return None