## 问题(用户:"这个做了吗?我看好像都是未知") **只做了一半**: - 列的拆分做了(S6):三列存在、写入收敛到 set_bind_status、迁移映射与运行时对齐 - 但**没有任何一处真的去查绑定状态**(grep -c bind_info douyu_runner_overview.py = 0) 那三列的值从来不是查来的,而是 S6 迁移时从旧的垃圾场列 bind_status **能推的推、推不出的留 unknown**。只把明确陈述了绑定事实的旧值映射过来 (bind_confirmed → bound、bind_qr_generated → pending、game_not_bound → unbound), 其余一律 unknown —— 因为旧列被"查限兑商品""送礼物"这类**无关动作**覆盖过, 从无关动作推绑定状态就是造假。当时留 unknown 是对的,但结果是: 真实生产分布(15182 个账号) 精英 unknown 14683 (96.7%) 电竞 14730 (97.0%) 小店 15045 (99.1%) ## 修法:真的去查,用唯一写入者写回 一键查询新增三个绑定查询(接口都已存在,活动别名走后台配置): bind_info / esports_bind_info / xpd_bind_info。 新增 derive_from_bind_info() 把返回推导成 4 态,**并做了实测校准**: 账号 列值(旧) is_bound_role 角色名 推导 51 unknown False (空) unbound 60 unknown True 唉清静的一生 bound 61 unknown True 柿子洋音 bound 62 unknown True FatalistIc bound **4 个里 3 个其实已绑定,全被 unknown 盖住了。** 两个刻意的设计: 1. **推导不出来时返回 None,且不动原值** —— "没查到"和"没绑定"必须分开, 写 unbound 就是编造结论(小店未开通、返回里没有标志时实测就是这种情况) 2. **不产出 pending** —— 它描述"正在绑定/待确认"这种**进行中的动作** (如刚生成过二维码),那是一次查询的副作用,不是查询能观测到的状态 3. need_bind_role **不是**"未绑定"的信号:实测**已绑定**的账号上它也是 1, 拿它当判据会把这 3 个已绑定的判成未绑 端到端实测(账号 51): 查询前: 精英=unknown 电竞=unknown 小店=unknown 查询后: 精英=unbound 电竞=unbound 小店=unknown ← 推不出来,正确保持原值 ## 显示:把"没查过"和"查了推不出"分开 三处都改了(用户要求):全 unknown 时不再显示「精? 电? 店?」, 而是 **未查询**(点一键查询就有了)/ **未能判定**(斗鱼这次没给结论)。 ## 验证(本地) TZ=Asia/Shanghai **615 passed / 6 skipped**;ruff 全绿;前端 build 通过、lint 0 errors。 新增 9 条用例(含"need_bind_role 不是信号"的反向回归)。
232 lines
9.6 KiB
Python
232 lines
9.6 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_act` 为真 → **bound**(活动维度已绑;两个都真才更稳,
|
||
但只给 act 时也算已绑,避免把"已绑但没回角色名"误报成未绑)
|
||
- 两个标志都在、且都为假 → **unbound**(明确说了没绑)
|
||
- 两个标志**都缺失**(接口没给 / 解析失败)→ **None**(推不出来,别猜)
|
||
|
||
⚠️ 这里刻意**不产出 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")
|
||
bound_act = flag("is_bound_act")
|
||
if bound_role is True or bound_act is True:
|
||
return BIND_BOUND
|
||
if bound_role is False or bound_act is False:
|
||
# 至少有一个标志明确为假 —— 接口给了结论,可以判"未绑定"
|
||
return BIND_UNBOUND
|
||
# 两个标志都缺失:接口没给结论,保持 unknown,别把"没查到"写成"没绑定"
|
||
return None
|