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 (