diff --git a/tests/test_account_overview.py b/tests/test_account_overview.py
index 943714e..bef8fe1 100644
--- a/tests/test_account_overview.py
+++ b/tests/test_account_overview.py
@@ -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"
diff --git a/web/backend/services/account_binding.py b/web/backend/services/account_binding.py
index 448c652..490e09c 100644
--- a/web/backend/services/account_binding.py
+++ b/web/backend/services/account_binding.py
@@ -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
diff --git a/web/backend/services/douyu_runner_overview.py b/web/backend/services/douyu_runner_overview.py
index 3a89635..422e543 100644
--- a/web/backend/services/douyu_runner_overview.py
+++ b/web/backend/services/douyu_runner_overview.py
@@ -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 不同;
diff --git a/web/frontend/src/pages/AccountDetailPage.tsx b/web/frontend/src/pages/AccountDetailPage.tsx
index e3f29f5..8fc9dfa 100644
--- a/web/frontend/src/pages/AccountDetailPage.tsx
+++ b/web/frontend/src/pages/AccountDetailPage.tsx
@@ -418,18 +418,46 @@ export default function AccountDetailPage() {
- {/* 三个作用域各一列,取值 unknown/unbound/pending/bound */}
-
-
- 精英:{overview?.role_bind_label || '未知'}
-
-
- 电竞:{overview?.esports_bind_label || '未知'}
-
-
- 小店:{overview?.xpd_bind_label || '未知'}
-
-
+ {/* 三个作用域各一列,取值 unknown/unbound/pending/bound。
+ 全 unknown 时把「没查过」和「查了推不出」分开说 ——
+ 混成一个"未知"会让人不知道下一步该干什么。 */}
+ {[
+ overview?.role_bind_status,
+ overview?.esports_bind_status,
+ overview?.xpd_bind_status,
+ ].every((s) => !s || s === 'unknown') ? (
+
+ {overview?.overview_queried_at ? (
+ <>
+ 未能判定
+
+
+ 已查询过,但斗鱼这次没给出绑定结论
+
+ >
+ ) : (
+ <>
+ 未查询
+
+
+ 点上方「一键查询」即可查出来
+
+ >
+ )}
+
+ ) : (
+
+
+ 精英:{overview?.role_bind_label || '未知'}
+
+
+ 电竞:{overview?.esports_bind_label || '未知'}
+
+
+ 小店:{overview?.xpd_bind_label || '未知'}
+
+
+ )}
diff --git a/web/frontend/src/pages/AccountsPage.tsx b/web/frontend/src/pages/AccountsPage.tsx
index 75ee867..8aceca4 100644
--- a/web/frontend/src/pages/AccountsPage.tsx
+++ b/web/frontend/src/pages/AccountsPage.tsx
@@ -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 —;
+ const queried = Boolean(record.overview_queried_at);
+ return (
+
+ {queried ? '未能判定' : '未查询'}
+
+ );
}
return (