"""角色绑定状态:把三个「垃圾场」列收敛成明确枚举。 ## 背景:`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