feat(account): 一键查询补上「角色绑定状态」—— 以前从来没人查过,97% 显示"未知"

## 问题(用户:"这个做了吗?我看好像都是未知")

**只做了一半**:

- 列的拆分做了(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 不是信号"的反向回归)。
This commit is contained in:
yml2213
2026-10-04 20:22:54 +08:00
parent 85a6186993
commit 6b5271c983
5 changed files with 362 additions and 17 deletions
+214
View File
@@ -220,6 +220,10 @@ class _Harness(OverviewMixin):
return {"esports_manual_type": 1, "esports_manual_score": 10}
# handler 按作用域抓官方记录(三套接口),测试里给最小实现
def _fetch_bind_info(self, client, scope, alias):
# 测试里统一给"已绑定"(有角色名),避免依赖具体假客户端
return {"is_bound_role": True, "is_bound_act": True, "role_name": "某角色"}
def _fetch_elite_records(self, client, config, manual_id, esports_manual_id):
return client.exchange_records(manual_id=manual_id).get("records") or []
@@ -271,6 +275,8 @@ class TestOverviewAggregation:
"xpd_act_alias": "A",
"xpd_rid": "R",
"xpd_act_id": "1",
"bind_act_alias": "B",
"esports_act_alias": "E",
},
)
return h
@@ -481,6 +487,13 @@ class TestSnapshotIsWrittenToAccount:
def _refresh_esports_handbook(self, c, a, *, manual_id):
return {"esports_manual_type": 0, "esports_manual_score": 0}
def _fetch_bind_info(self, c, scope, alias):
return {
"is_bound_role": True,
"is_bound_act": True,
"role_name": "某角色",
}
def _fetch_elite_records(self, c, cfg, mid, emid):
return c.exchange_records(manual_id=mid).get("records") or []
@@ -513,6 +526,8 @@ class TestSnapshotIsWrittenToAccount:
"xpd_act_alias": "A",
"xpd_rid": "R",
"xpd_act_id": "1",
"bind_act_alias": "B",
"esports_act_alias": "E",
},
)
self.session.commit()
@@ -675,6 +690,13 @@ class TestOverviewRefreshesAccountFields:
a.esports_opened = True
return {"esports_manual_type": 1, "esports_manual_score": 3}
def _fetch_bind_info(self, c, scope, alias):
return {
"is_bound_role": True,
"is_bound_act": True,
"role_name": "某角色",
}
def _fetch_elite_records(self, c, cfg, mid, emid):
return c.exchange_records(manual_id=mid).get("records") or []
@@ -707,6 +729,8 @@ class TestOverviewRefreshesAccountFields:
"xpd_act_alias": "A",
"xpd_rid": "R",
"xpd_act_id": "1",
"bind_act_alias": "B",
"esports_act_alias": "E",
},
)
self.session.commit()
@@ -878,6 +902,8 @@ class TestSummaryFeedsAccountList:
"xpd_act_alias": "A",
"xpd_rid": "R",
"xpd_act_id": "1",
"bind_act_alias": "B",
"esports_act_alias": "E",
},
)
self.session.commit()
@@ -1157,3 +1183,191 @@ class TestEsportsExpireTimePersisted:
assert self.account.esports_expire_time is None
self._refresh("")
assert self.account.esports_expire_time is None
class TestDeriveFromBindInfo:
"""从斗鱼 `bind_info` 推导绑定状态。
**实测校准过**(开发库 4 个账号):`is_bound_role` 为真时账号确实有角色名,
而它们的列值全是 `unknown` —— 4 个里 3 个其实已绑定,被 unknown 盖住了。
⚠️ `need_bind_role` **不是**"未绑定"的信号:实测**已绑定**的账号上它也是 1。
用它会把这 3 个已绑定的判成未绑 —— 幸好没拿它当判据。
"""
def test_bound_when_role_flag_true(self):
from web.backend.services.account_binding import (
BIND_BOUND,
derive_from_bind_info,
)
assert (
derive_from_bind_info({"is_bound_role": True, "role_name": "x"})
== BIND_BOUND
)
def test_bound_when_act_flag_true(self):
"""只有 act 标志为真时也算已绑 —— 避免把"已绑但没回角色名"误报成未绑。"""
from web.backend.services.account_binding import (
BIND_BOUND,
derive_from_bind_info,
)
assert derive_from_bind_info({"is_bound_act": True}) == BIND_BOUND
def test_unbound_when_flags_explicitly_false(self):
from web.backend.services.account_binding import (
BIND_UNBOUND,
derive_from_bind_info,
)
assert derive_from_bind_info({"is_bound_role": False}) == BIND_UNBOUND
assert derive_from_bind_info({"is_bound_act": "0"}) == BIND_UNBOUND
def test_none_when_no_conclusion(self):
"""标志缺失 → None(推不出来),**绝不能**当成 unbound。
这是"没查到"和"没绑定"的分界:写 unbound 就是编造结论。
小店未开通时实测就是这种情况(返回里没有这两个标志)。
"""
from web.backend.services.account_binding import derive_from_bind_info
assert derive_from_bind_info({}) is None
assert derive_from_bind_info({"role_name": "x"}) is None
assert derive_from_bind_info(None) is None
def test_need_bind_role_is_not_a_signal(self):
"""回归:`need_bind_role` 在**已绑定**账号上也是 1,不能拿它判未绑。"""
from web.backend.services.account_binding import (
BIND_BOUND,
derive_from_bind_info,
)
info = {"is_bound_role": True, "is_bound_act": True, "need_bind_role": 1}
assert derive_from_bind_info(info) == BIND_BOUND
class TestOverviewWritesBindStatus:
"""一键查询要把三个作用域的绑定状态**查出来并写回**。
这三个值以前从来没人查过,只是 S6 迁移时从旧的垃圾场列能推的推、
推不出的留 unknown —— 于是界面上 97% 显示"未知",对运营没用。
"""
def setup_method(self):
self.engine = get_test_engine()
self.session = make_test_session(self.engine)
self.user = User(username="bind-admin", password_hash="h", role="super_admin")
self.session.add(self.user)
self.session.flush()
self.account = Account(
username="bind-acc",
password="p",
email="b@e.com",
email_password="ep",
role_bind_status="unknown",
esports_bind_status="unknown",
xpd_bind_status="unknown",
)
self.session.add(self.account)
self.session.commit()
def teardown_method(self):
self.session.close()
def _run(self, *, info_by_scope):
from web.backend.services.douyu_runner_overview import OverviewMixin
class _H(OverviewMixin):
def _client(self, c):
return _FakeClient()
def _mark_task(self, *a, **k):
pass
def _push_log(self, *a, **k):
pass
def _fetch_bind_info(self, client, scope, alias):
return info_by_scope[scope]
def _refresh_account_gold_balance(self, c, a):
return {"gold_balance": 1, "exchange_balance": 1}
def _refresh_account_points(self, c, a, cookie, *, ctn=None):
return {"points": 1, "points_query": {"raw": {"data": {}}}}
def _refresh_elite_handbook(self, c, a, *, manual_id, rid=""):
return {
"elite_opened": False,
"elite_expire_time": 0,
"elite_manual_score": 0,
}
def _refresh_esports_handbook(self, c, a, *, manual_id):
return {"esports_manual_type": 0, "esports_manual_score": 0}
def _fetch_elite_records(self, c, cfg, mid, emid):
return []
def _fetch_esports_records(self, c, cfg, mid, emid):
return []
def _fetch_xpd_records(self, c, cfg, mid, emid):
return []
task = DouyuTask(
batch_id="bind",
account_id=self.account.id,
task_type="query_account_overview",
handbook_scope="elite",
status="running",
message="",
created_by=self.user.id,
)
self.session.add(task)
self.session.commit()
_H()._execute_query_account_overview(
self.session,
task,
self.account,
"c",
{
"manual_id": "M",
"esports_manual_id": "E",
"rid": "R",
"bind_act_alias": "B",
"esports_act_alias": "S",
"xpd_act_alias": "X",
"xpd_rid": "R",
"xpd_act_id": "1",
},
)
self.session.commit()
self.session.refresh(self.account)
return task
def test_writes_derived_status(self):
self._run(
info_by_scope={
"elite": {"is_bound_role": True, "role_name": "有角色"},
"esports": {"is_bound_role": False},
"peace": {"is_bound_role": False},
}
)
assert self.account.role_bind_status == "bound"
assert self.account.esports_bind_status == "unbound"
assert self.account.xpd_bind_status == "unbound"
def test_none_derivation_keeps_original_value(self):
"""推不出来时**不动原值** —— 不能把"没查到"写成"没绑定"。"""
self._run(
info_by_scope={
"elite": {"is_bound_role": True},
"esports": {}, # 标志缺失 → 推不出来
"peace": {},
}
)
assert self.account.role_bind_status == "bound"
assert self.account.esports_bind_status == "unknown", "推不出来应保持原值"
assert self.account.xpd_bind_status == "unknown"
+45
View File
@@ -184,3 +184,48 @@ def bind_payload(account: Account) -> dict[str, str]:
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
@@ -33,6 +33,7 @@ from sqlalchemy.orm import Session
from core.douyu import DouyuActivityClient
from ..models import Account, DouyuTask
from .account_binding import derive_from_bind_info, set_bind_status
from .business_events import record_official_exchange
from .douyu_runner_donate import _parse_expire_at, _points_expire_at
@@ -270,6 +271,16 @@ class OverviewMixin:
for k in ("xpd_act_alias", "xpd_rid", "xpd_act_id")
)
def _fetch_bind_info(
self, client: DouyuActivityClient, scope: str, alias: str
) -> dict[str, Any]:
"""按作用域查绑定信息(三个接口不同,收敛在一处)。"""
if scope == "elite":
return client.bind_info(alias)
if scope == "esports":
return client.esports_bind_info(alias)
return client.xpd_bind_info(act_alias=alias)
def _fetch_elite_records(
self,
client: DouyuActivityClient,
@@ -422,6 +433,36 @@ class OverviewMixin:
# 所以这里放在兑换记录**之后**才能判定 —— 见下面的顺序。
pending_goods = goods_items
# ---- 角色绑定状态(三个作用域都查)----
#
# 这三个值以前**从来没人查过** —— 只是 S6 迁移时从旧的垃圾场列
# `bind_status` 能推的推、推不出的留 `unknown`,于是界面上 97% 显示"未知"。
# 实测校准:开发库 4 个账号里 3 个其实**已绑定**(`is_bound_role=True` 且有角色名),
# 全被 unknown 盖住了。
overview["bind"] = {}
for scope, label, alias_key in (
("elite", "精英宝典", "bind_act_alias"),
("esports", "电竞手册", "esports_act_alias"),
("peace", "和平小店", "xpd_act_alias"),
):
alias = str(config.get(alias_key) or "").strip()
if not alias:
errors[f"bind_{scope}"] = f"未配置{label}活动别名"
continue
try:
info = self._fetch_bind_info(client, scope, alias)
derived = derive_from_bind_info(info)
overview["bind"][scope] = {
"derived": derived,
"role_name": str(info.get("role_name") or info.get("role") or ""),
}
if derived is not None:
# 唯一写入者;推不出来(None)时**不动原值** —— 宁可留 unknown,
# 也不要把"没查到"写成"没绑定"
set_bind_status(account, scope, derived)
except Exception as exc: # noqa: BLE001 外部接口边界需要宽泛兜底
errors[f"bind_{scope}"] = _err(exc)
# ---- 官方兑换记录(**三个作用域都拉**)----
#
# 精英宝典 / 电竞手册 用的是同一个接口,只是 manual_id 不同;
+40 -12
View File
@@ -418,18 +418,46 @@ export default function AccountDetailPage() {
</Space>
</Descriptions.Item>
<Descriptions.Item label="角色绑定">
{/* 三个作用域各一列,取值 unknown/unbound/pending/bound */}
<Space size={4} wrap>
<Tag color={BIND_TONES[overview?.role_bind_status ?? 'unknown']}>
精英:{overview?.role_bind_label || '未知'}
</Tag>
<Tag color={BIND_TONES[overview?.esports_bind_status ?? 'unknown']}>
电竞:{overview?.esports_bind_label || '未知'}
</Tag>
<Tag color={BIND_TONES[overview?.xpd_bind_status ?? 'unknown']}>
小店:{overview?.xpd_bind_label || '未知'}
</Tag>
</Space>
{/* 三个作用域各一列,取值 unknown/unbound/pending/bound。
全 unknown 时把「没查过」和「查了推不出」分开说 ——
混成一个"未知"会让人不知道下一步该干什么。 */}
{[
overview?.role_bind_status,
overview?.esports_bind_status,
overview?.xpd_bind_status,
].every((s) => !s || s === 'unknown') ? (
<Text type="secondary">
{overview?.overview_queried_at ? (
<>
未能判定
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
已查询过,但斗鱼这次没给出绑定结论
</Text>
</>
) : (
<>
未查询
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
点上方「一键查询」即可查出来
</Text>
</>
)}
</Text>
) : (
<Space size={4} wrap>
<Tag color={BIND_TONES[overview?.role_bind_status ?? 'unknown']}>
精英:{overview?.role_bind_label || '未知'}
</Tag>
<Tag color={BIND_TONES[overview?.esports_bind_status ?? 'unknown']}>
电竞:{overview?.esports_bind_label || '未知'}
</Tag>
<Tag color={BIND_TONES[overview?.xpd_bind_status ?? 'unknown']}>
小店:{overview?.xpd_bind_label || '未知'}
</Tag>
</Space>
)}
</Descriptions.Item>
<Descriptions.Item label="归属">
+22 -5
View File
@@ -447,12 +447,29 @@ export default function AccountsPage() {
dataIndex: 'role_bind_status',
width: 150,
render: (_, record: AccountItem) => {
// 三个作用域全都没查过时,显示「—」而不是「精? 电? 店?」——
// 满屏问号只是噪声,不如一眼看出「这账号还没查过绑定」。
const allUnknown = [record.role_bind_status, record.esports_bind_status, record.xpd_bind_status]
.every((s) => !s || s === 'unknown');
// 三个作用域全都 unknown 时不再显示「精? 电? 店?」(满屏问号只是噪声),
// 但要把两种情况**分开**:
// 没查过 → 「未查询」(有救,点一键查询就有了)
// 查过但推不出 → 「未能判定」(接口没给结论)
// 混成一个词会让人不知道下一步该干什么。
const allUnknown = [
record.role_bind_status,
record.esports_bind_status,
record.xpd_bind_status,
].every((s) => !s || s === 'unknown');
if (allUnknown) {
return <Tooltip title="还没有查询过绑定"><Text type="secondary">—</Text></Tooltip>;
const queried = Boolean(record.overview_queried_at);
return (
<Tooltip
title={
queried
? '已查询过,但斗鱼这次没给出绑定结论(既非已绑也非未绑)'
: '还没查询过绑定 —— 点工具栏「批量一键查询」'
}
>
<Text type="secondary">{queried ? '未能判定' : '未查询'}</Text>
</Tooltip>
);
}
return (
<Space size={2} wrap>