用户明确指出:**精英/电竞是否开通,和是否绑定角色没关系**。
## 我确实混了(虽然这次没触发)
`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 全绿。
242 lines
10 KiB
Python
242 lines
10 KiB
Python
"""角色绑定状态:把三个「垃圾场」列收敛成明确枚举。
|
||
|
||
## 背景:`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
|