diff --git a/docs/设计-账号一键查询.md b/docs/设计-账号一键查询.md index 9991c47..b91fc94 100644 --- a/docs/设计-账号一键查询.md +++ b/docs/设计-账号一键查询.md @@ -15,33 +15,66 @@ ## 2. 关键实证发现(已核实,别再猜) -### 2.1 「是否已拥有某套装」能直接从官方查 +### 2.1 「是否已获得」的依据:**官方兑换记录**(⚠️ 修正过一次) -`query_limited_goods(manual_id, rid)` 返回的商品项里,**`count` / `foreverCount` / -`storeStatus` 是账号维度的**。实测同一个商品「套装-兔兔白日梦」: +**结论:用 `exchange_records(manual_id)` 判定,不要用 `query_limited_goods` 的库存字段。** -| 字段 | 账号 558(**已兑换**) | 账号 15407(未兑换) | +#### ❌ 被推翻的第一版规则 + +第一版拿 `query_limited_goods` 商品项里的 `count` / `storeStatus` 判断,依据是: + +| 字段 | 账号 558(兑换过) | 账号 15407(没兑换) | | --- | --- | --- | -| `count` | **0** | 96982 | -| `foreverCount` | **0** | 96982 | -| `storeStatus` | **0** | 1 | +| `count` | 0 | 96982 | +| `storeStatus` | 0 | 1 | -**判定规则**:`storeStatus == 0`(或 `count == 0`)→ **已拥有**;否则可兑换。 +**这是错的。** 上面两条记录分别取自 **2026-08-01** 和 **2026-10-04**, +相隔两个月 —— 我把**时间差异**当成了**账号差异**。 -⚠️ 注意 `storeStatus` 也可能是「全局售罄/下架」导致 0,所以展示时**不要只写"已拥有"**, -建议写「**不可再兑**(已拥有或已售罄)」,并把 `count` 一起显示,避免误判。 +在**同一时刻**同时查 558 / 607(都兑换过)和 15507(没有), +商品项的**所有字段完全一致**,`preExchange.isExists` 也都是 0。 +`count` 的取值分布是 0 / 98746 / 103350 / 109832 … 随时间变化, +**是全局库存/活动状态,不暴露账号维度信息**。 + +> 用全局字段判"这个账号有没有"必然给错结论 —— +> 而对高价值资产给错结论是本功能最不能接受的失败方式。 + +#### ✅ 正确依据 + +`exchange_records(manual_id)` —— 斗鱼官方的**账号级**兑换记录。同一时刻实测: + +| 账号 | 官方兑换记录 | 判定 | +| --- | --- | --- | +| 558 | `破片手榴弹-兔兔白日梦` | **已获得** | +| 607 | `破片手榴弹-兔兔白日梦` | **已获得** | +| 15507 | (0 条) | 未获得 | + +同时刻三者看到的兔兔白日梦库存**都是 96956**(全局),但兑换记录把账号区分得干干净净。 + +#### 两处信息各司其职 + +| 来源 | 用途 | +| --- | --- | +| `exchange_records` | **是否已获得**(账号级) | +| `query_limited_goods` | 当前积分价 / 库存 / 活动时间(**全局**,只用于"现在还能不能换、要多少分") | + +#### 名字按子串匹配 + +同一件东西有多个商品名 —— 实测既有「**套装**-兔兔白日梦」也有 +「**破片手榴弹**-兔兔白日梦」。所以按核心词(`兔兔白日梦`)子串匹配, +把整个名字家族都算上。 ### 2.2 两个套装的确切标识 -| 商品名 | `commodityId` | `score`(积分价) | `countLimited` | -| --- | --- | --- | --- | -| 套装-兔兔白日梦 | `20260120QYOOB_bag10` | 1280 | **1**(每账号限兑 1 次) | -| 套装-狂战萌兔 | 待取(同法从官方拉) | 待取 | 待取 | +| 核心词 | 出现过的完整商品名 | +| --- | --- | +| 兔兔白日梦 | `套装-兔兔白日梦`(`20260120QYOOB_bag10`,1280 分)、`破片手榴弹-兔兔白日梦` | +| 狂战萌兔 | 实测 607 的列表里是 `套装-狂战萌兔`(库存 2551) | 两者均带 `eliteLimited = 1` → **属于精英宝典**(与决策① 一致)。 -> 实现时**按 `commodityName` 匹配**(含「兔兔白日梦」「狂战萌兔」), -> 并把 `commodityName` 做成配置项,不要硬编码到代码里(以后可能加更多高价值商品)。 +> 实现按**核心词**匹配即可,不必写死完整商品名或 `commodityId`; +> 名单做成配置项,以后加更多高价值商品不用改代码。 ### 2.3 一个易踩的坑 @@ -97,9 +130,10 @@ "name": "套装-兔兔白日梦", "commodity_id": "20260120QYOOB_bag10", "score": 1280, - "owned": true, // storeStatus == 0 || count == 0 - "store_status": 0, - "count": 0, + "owned": true, // 来自官方兑换记录,不是库存字段 + "obtained_names": ["破片手榴弹-兔兔白日梦"], + "in_goods_list": true, // 当前限兑列表里有没有(全局信息) + "count": 96956, // 全局库存,仅展示 "start_time": "1769153181", "end_time": "1795967999" }, diff --git a/tests/test_account_overview.py b/tests/test_account_overview.py new file mode 100644 index 0000000..6009f5e --- /dev/null +++ b/tests/test_account_overview.py @@ -0,0 +1,386 @@ +"""账号「一键查询」:判定规则、失败隔离、只读性。 + +判定规则用的是**生产镜像库的真实返回样本**(见 `SAMPLES`), +不是我自己编的 JSON —— 这样官方字段改名/结构变动时用例才会红。 +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from tests.support.db import get_test_engine, make_test_session +from web.backend.models import Account, AccountEvent, DouyuTask, User +from web.backend.services.douyu_runner_overview import ( + DEFAULT_HIGH_VALUE_GOODS, + OverviewMixin, + _goods_config, + extract_high_value_goods, +) + +RABBIT = "兔兔白日梦" +RABBIT_ID = "20260120QYOOB_bag10" +#: 账号 15407(未兑换)的真实商品项,取自生产镜像库任务 253579 +GOODS_AVAILABLE = { + "commodityId": RABBIT_ID, + "commodityName": "套装-兔兔白日梦", + "count": 96982, + "countLimited": 1, + "foreverCount": 96982, + "openStatus": 1, + "score": 1280, + "startTime": "1769153181", + "endTime": "1795967999", + "storeStatus": 1, + "pic": "https://sta-op.douyucdn.cn/x.png", +} +#: 账号 558(已兑换)的同一商品项,只有这三项不同 +GOODS_OWNED = {**GOODS_AVAILABLE, "count": 0, "foreverCount": 0, "storeStatus": 0} + + +class TestOwnedJudgement: + """「是否已获得」的判定 —— 本功能的核心,也是最容易判错的地方。 + + ⚠️ **判定只看官方兑换记录**。第一版拿限兑商品列表的 `count`/`storeStatus` 判, + 依据是「558 兑换过 count=0 / 15407 没兑换 count=96982」—— + 那两条记录相隔两个月,我把**时间差异**当成了**账号差异**。 + + 同一时刻对比才发现:三个账号(两个兑换过、一个没有)的商品项**字段完全一致**, + `count`/`foreverCount`/`storeStatus` 是**全局库存**,不暴露账号维度信息。 + 改用官方兑换记录后完美区分(558/607 有「破片手榴弹-兔兔白日梦」,15507 是 0 条)。 + """ + + def test_owned_comes_from_official_records(self): + got = extract_high_value_goods( + [GOODS_AVAILABLE], [RABBIT], [{"commodityName": "破片手榴弹-兔兔白日梦"}] + ) + assert got[0]["owned"] is True + assert got[0]["obtained_names"] == ["破片手榴弹-兔兔白日梦"] + + def test_not_owned_when_records_empty(self): + got = extract_high_value_goods([GOODS_AVAILABLE], [RABBIT], []) + assert got[0]["owned"] is False + + def test_global_stock_never_implies_owned(self): + """**回归用例**:全局库存为 0 或售罄,都不能推出「这个账号已拥有」。 + + 这正是第一版踩的坑 —— 用全局字段判账号维度的事。 + """ + sold_out = {**GOODS_AVAILABLE, "count": 0, "foreverCount": 0, "storeStatus": 0} + got = extract_high_value_goods([sold_out], [RABBIT], []) + assert got[0]["owned"] is False, "全局售罄不等于该账号拥有" + + def test_stock_and_owned_are_independent(self): + """展示用的全局信息与账号判定互不影响。""" + sold_out = {**GOODS_AVAILABLE, "count": 0, "storeStatus": 0} + got = extract_high_value_goods( + [sold_out], [RABBIT], [{"commodityName": "套装-兔兔白日梦"}] + ) + assert got[0]["owned"] is True + assert got[0]["count"] == 0 # 全局库存如实展示 + + def test_records_match_by_substring_family(self): + """同一件东西有多个商品名(套装- / 破片手榴弹-),都要算数。""" + got = extract_high_value_goods( + [GOODS_AVAILABLE], + [RABBIT], + [{"commodityName": "破片手榴弹-兔兔白日梦"}, {"commodityName": "别的"}], + ) + assert got[0]["owned"] is True + assert len(got[0]["obtained_names"]) == 1 + + def test_missing_goods_is_not_silently_dropped(self): + """既不在商品列表、也不在兑换记录里的,必须留 missing。""" + got = extract_high_value_goods([GOODS_AVAILABLE], [RABBIT, "不存在的套装"], []) + assert len(got) == 2 + assert got[1]["missing"] is True + assert "都没有这个商品" in got[1]["reason"] + + def test_goods_in_records_but_not_list_is_not_missing(self): + """兑换记录里有、但当前限兑列表里没有 —— 不是 missing,是已获得。 + + 实测就有这种情况(活动窗口结束但记录仍在),不能误报成「没这个商品」。 + """ + got = extract_high_value_goods( + [], [RABBIT], [{"commodityName": "套装-兔兔白日梦"}] + ) + assert got[0]["owned"] is True + assert got[0]["missing"] is False + assert got[0]["in_goods_list"] is False + + def test_empty_list_and_no_records_keeps_all_names(self): + got = extract_high_value_goods([], DEFAULT_HIGH_VALUE_GOODS, []) + assert [g["name"] for g in got] == list(DEFAULT_HIGH_VALUE_GOODS) + assert all(g["missing"] for g in got) + + def test_both_suits(self): + items = [ + {**GOODS_AVAILABLE, "commodityName": "套装-兔兔白日梦"}, + {**GOODS_AVAILABLE, "commodityName": "套装-狂战萌兔", "commodityId": "X"}, + ] + got = extract_high_value_goods( + items, list(DEFAULT_HIGH_VALUE_GOODS), [{"commodityName": "套装-狂战萌兔"}] + ) + assert [g["owned"] for g in got] == [False, True] + + def test_tolerates_garbage(self): + got = extract_high_value_goods( + [None, "x", 1, GOODS_AVAILABLE], + [RABBIT], + [None, "y", {"commodityName": ""}], + ) + assert got[0]["owned"] is False + + def test_custom_goods_config_overrides_default(self): + assert _goods_config({"high_value_goods": ["A", "B"]}) == ["A", "B"] + assert _goods_config({"high_value_goods": "A,B"}) == ["A", "B"] + assert _goods_config({"high_value_goods": '["A", "B"]'}) == ["A", "B"] + assert _goods_config({"high_value_goods": "A,B"}) == ["A", "B"] + assert _goods_config({}) == list(DEFAULT_HIGH_VALUE_GOODS) + assert _goods_config({"high_value_goods": []}) == list(DEFAULT_HIGH_VALUE_GOODS) + + +class _FakeClient: + """最小假客户端:只实现聚合用到的几个方法。""" + + def __init__(self, *, fail: set[str] | None = None): + self.fail = fail or set() + + def _maybe(self, key: str): + if key in self.fail: + raise RuntimeError(f"模拟 {key} 失败") + + def acf_ccn(self, **_): + return "ctn" + + def query_limited_goods(self, **_): + self._maybe("goods") + return { + "limited_goods": [ + GOODS_OWNED, + {**GOODS_AVAILABLE, "commodityName": "套装-狂战萌兔"}, + ] + } + + def exchange_records(self, **_): + self._maybe("records") + return { + "records": [{"commodityName": "破片手榴弹-兔兔白日梦", "createTime": "1"}] + } + + +class _Harness(OverviewMixin): + """把聚合 handler 单独跑起来:假客户端 + 假的 refresh helper。""" + + def __init__(self, client: _FakeClient, *, fail_helpers: set[str] | None = None): + self.client = client + self.fail_helpers = fail_helpers or set() + self.marked: dict = {} + + def _client(self, cookie: str): + return self.client + + def _mark_task(self, db, task, status, message, result=None): + self.marked = { + "status": status, + "message": message, + "result": result, + } + + def _push_log(self, *a, **k): + pass + + def _refresh_account_gold_balance(self, client, account): + if "gold" in self.fail_helpers: + raise RuntimeError("模拟鱼翅查询失败") + return {"gold_balance": 38, "exchange_balance": 120} + + def _refresh_account_points(self, client, account, cookie, *, ctn=None): + if "points" in self.fail_helpers: + raise RuntimeError("模拟积分查询失败") + return { + "points": 990, + "points_query": {"raw": {"data": {"willExpireTime": "1795967999"}}}, + } + + def _refresh_elite_handbook(self, client, account, *, manual_id, rid=""): + if "elite" in self.fail_helpers: + raise RuntimeError("模拟精英查询失败") + return { + "elite_opened": True, + "elite_expire_time": 1795967999, + "elite_manual_score": 990, + } + + def _refresh_esports_handbook(self, client, account, *, manual_id): + if "esports" in self.fail_helpers: + raise RuntimeError("模拟电竞查询失败(账号没绑电竞手册)") + return {"esports_manual_type": 1, "esports_manual_score": 10} + + +class TestOverviewAggregation: + def setup_method(self): + self.engine = get_test_engine() + self.session = make_test_session(self.engine) + self.user = User(username="ov-admin", password_hash="h", role="super_admin") + self.session.add(self.user) + self.session.flush() + self.account = Account( + username="ov-acc", password="p", email="o@e.com", email_password="ep" + ) + self.session.add(self.account) + self.session.commit() + self.task = DouyuTask( + batch_id="ov-batch", + account_id=self.account.id, + task_type="query_account_overview", + handbook_scope="elite", + status="running", + message="", + created_by=self.user.id, + ) + self.session.add(self.task) + self.session.commit() + + def teardown_method(self): + self.session.close() + + def _run(self, client=None, **kw): + h = _Harness(client or _FakeClient(), **kw) + h._execute_query_account_overview( + self.session, + self.task, + self.account, + "cookie", + {"manual_id": "M1", "esports_manual_id": "M2", "rid": "R1"}, + ) + return h + + def test_aggregates_assets_and_open_states(self): + h = self._run() + r = h.marked["result"] + assert h.marked["status"] == "success" + assert r["assets"]["gold_balance"] == 38 + assert r["assets"]["exchange_balance"] == 120 + assert r["assets"]["points"] == 990 + assert r["assets"]["points_expire_at"] == "1795967999" + assert r["elite"]["opened"] is True + assert r["esports"]["opened"] is True + assert r["errors"] == {} + + def test_high_value_goods_are_flagged(self): + r = self._run().marked["result"] + goods = {g["name"]: g for g in r["high_value_goods"]} + assert goods["套装-兔兔白日梦"]["owned"] is True # 兑换记录里有 + assert goods["套装-狂战萌兔"]["owned"] is False # 记录里没有 + assert "兔兔白日梦" in r["high_value_goods"][0]["name"] + + def test_single_item_failure_does_not_fail_the_batch(self): + """账号没绑电竞手册是常态 —— 不该让整次查询失败。""" + r = self._run(fail_helpers={"esports"}).marked["result"] + assert r["assets"]["points"] == 990 # 其余照常 + assert "esports" in r["errors"] + assert r["high_value_goods"] # 关键信息仍在 + + def test_all_failures_still_return_success_with_errors(self): + r = self._run(fail_helpers={"gold", "points", "elite", "esports"}).marked[ + "result" + ] + assert set(r["errors"]) >= {"gold", "points", "elite", "esports"} + assert self._run(fail_helpers={"gold"}).marked["status"] == "success" + + def test_missing_manual_id_is_reported_not_crashed(self): + h = _Harness(_FakeClient()) + h._execute_query_account_overview( + self.session, self.task, self.account, "cookie", {} + ) + r = h.marked["result"] + assert "未配置精英宝典 manualID" in r["errors"]["elite"] + assert "未配置精英宝典 manualID" in r["errors"]["high_value_goods"] + assert h.marked["status"] == "success" + assert r["high_value_goods"] == [] + + def test_message_counts_owned_suits(self): + assert "兔兔白日梦" in self._run().marked["message"] + assert "已拥有 1 个" in self._run().marked["message"] + + def test_is_read_only_no_events_and_no_status_change(self): + """纯查询不是「事实」—— 不能进时间线,也不能改生命周期状态。""" + before = self.session.query(AccountEvent).count() + status_before = self.account.status + self._run() + self.session.commit() + assert self.session.query(AccountEvent).count() == before, "一键查询不该发事件" + self.session.refresh(self.account) + assert self.account.status == status_before + + +class TestOverviewTaskTypeWiring: + def test_declared_for_all_workbenches(self): + from web.backend.services.douyu_service import ( + DOUYU_HANDBOOK_TASK_TYPES, + SUPPORTED_DOUYU_TASK_TYPES, + ) + + assert "query_account_overview" in SUPPORTED_DOUYU_TASK_TYPES + for scope in ("elite", "esports", "peace"): + assert "query_account_overview" in DOUYU_HANDBOOK_TASK_TYPES[scope] + + def test_handler_is_dispatched(self): + from web.backend.services.douyu_runner import DouyuBatchRunner + + handler = getattr(DouyuBatchRunner, "_execute_query_account_overview", None) + assert callable(handler) + + def test_batch_creates_one_task_per_account(self): + """批量是天然支持的:`create_douyu_planned_tasks` 就是每账号一条任务。""" + from tests.support.db import get_test_engine as eng + from web.backend.services.douyu_service import create_douyu_planned_tasks + + session = make_test_session(eng()) + try: + # 自己造夹具,别依赖别的用例留下的数据(依赖就会 skip,skip 等于没测) + user = User( + username="ov-batch-admin", password_hash="h", role="super_admin" + ) + session.add(user) + session.flush() + accs = [ + Account( + username=f"ov-batch-{i}", + password="p", + email=f"b{i}@e.com", + email_password="ep", + # 必须带 CK:一键查询要用该账号的 CK 去查斗鱼, + # `visible_douyu_task_accounts` 会把没 CK 的账号过滤掉。 + cookie=f"acf_uid=1{i}; acf_username=u{i}; acf_ccn=c{i}", + ) + for i in range(3) + ] + session.add_all(accs) + session.commit() + batch_id, count = create_douyu_planned_tasks( + session, + [a.id for a in accs], + "query_account_overview", + "elite", + user.id, + ) + assert count == len(accs) + created = ( + session.query(DouyuTask) + .filter( + DouyuTask.batch_id == batch_id, + DouyuTask.task_type == "query_account_overview", + ) + .all() + ) + assert len(created) == len(accs) + finally: + session.close() + + +def test_queried_at_is_utc_isoformat(): + got = extract_high_value_goods([GOODS_OWNED], [RABBIT]) + assert got and isinstance(got, list) + # 顺手确认 UTC 约定没被破坏 + assert datetime.now(UTC).tzinfo is not None diff --git a/web/backend/services/douyu_runner.py b/web/backend/services/douyu_runner.py index 47dc404..33f9e22 100644 --- a/web/backend/services/douyu_runner.py +++ b/web/backend/services/douyu_runner.py @@ -21,6 +21,7 @@ from .douyu_runner_donate import DonateMixin from .douyu_runner_gold import GoldMixin from .douyu_runner_goods import GoodsMixin from .douyu_runner_manual import ManualMixin +from .douyu_runner_overview import OverviewMixin from .douyu_runner_xpd import XpdMixin from .douyu_service import update_account_profile_from_cookie @@ -29,6 +30,7 @@ class DouyuBatchRunner( DouyuBatchRunnerCore, BindMixin, ManualMixin, + OverviewMixin, GoldMixin, DonateMixin, GoodsMixin, @@ -111,6 +113,7 @@ class DouyuBatchRunner( "query_game_name": self._execute_query_game_name, "query_change_bind_time": self._execute_query_change_bind_time, "query_limited_goods": self._execute_query_limited_goods, + "query_account_overview": self._execute_query_account_overview, "query_gold_balance": self._execute_query_gold_balance, "query_exchange_records": self._execute_query_exchange_records, "prefetch_csrf_token": self._execute_prefetch_csrf_token, diff --git a/web/backend/services/douyu_runner_overview.py b/web/backend/services/douyu_runner_overview.py new file mode 100644 index 0000000..597de75 --- /dev/null +++ b/web/backend/services/douyu_runner_overview.py @@ -0,0 +1,328 @@ +"""一键查询:把账号的关键信息一次性从斗鱼官方拉全并汇总。 + +## 定位 + +一个**只读**聚合任务:鱼翅 / 积分(含到期时间)/ 精英宝典 / 电竞手册 / +小店 / 官方兑换记录 / **限兑商品(重点看高价值套装是否已拥有)**。 + +**不改任何账号状态、不发业务事件** —— 纯查询不是「事实」, +按既有设计例行查询不进时间线,否则查几次就把时间线刷满了。 + +## 为什么「是否已拥有」能直接查官方 + +`query_limited_goods` 返回的商品项里 `count` / `foreverCount` / `storeStatus` +是**账号维度**的(不是全局库存)—— 已用生产镜像库实测,见 +`extract_high_value_goods` 的文档。 + +## 诚实边界 + +**单项失败不整体失败**:账号没绑电竞手册是常态,不该让整次查询失败。 +失败写进 `errors[项]`,并在 UI 如实展示 —— +避免「没查到」被误读成「没有」。 +""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Any + +from loguru import logger +from sqlalchemy.orm import Session + +from core.douyu import DouyuActivityClient + +from ..models import Account, DouyuTask +from .douyu_runner_donate import _points_expire_at + +#: 默认关注的高价值商品。按**商品名包含**匹配 —— 官方列表里的名字带前缀 +#: (实际是「套装-兔兔白日梦」),所以配置里写核心词即可。 +DEFAULT_HIGH_VALUE_GOODS: tuple[str, ...] = ("兔兔白日梦", "狂战萌兔") + +#: 官方兑换记录默认取多少条(完整记录可能很多,一次性拉回来没必要) +EXCHANGE_RECORD_LIMIT = 20 + +#: 单项错误的摘要长度(避免把整个 traceback 塞进 task.result) +_ERROR_MAX = 200 + + +def _err(exc: BaseException) -> str: + return f"{type(exc).__name__}: {exc}"[:_ERROR_MAX] + + +def extract_high_value_goods( + items: list[dict[str, Any]] | None, + wanted: tuple[str, ...] | list[str] | None = DEFAULT_HIGH_VALUE_GOODS, + records: list[dict[str, Any]] | None = None, +) -> list[dict[str, Any]]: + """挑出高价值商品,并判定「该账号是否已获得」。 + + ## 判定依据:**官方兑换记录**,不是限兑商品列表 + + ⚠️ 这里推翻了本功能第一版的实现,记录一下踩的坑: + + 第一版拿 `query_limited_goods` 商品项里的 `count` / `storeStatus` 判断, + 依据是「账号 558(兑换过)count=0,账号 15407(没兑换)count=96982」。 + **那是错的** —— 两条记录取自 **2026-08-01** 和 **2026-10-04**,相隔两个月, + 我把**时间差异**当成了**账号差异**。 + + 在**同一时刻**对比 558 / 607(都兑换过)和 15507(没有), + 商品项的**所有字段完全一致**,`preExchange.isExists` 也都是 0 —— + 说明 `count` / `foreverCount` / `storeStatus` 是**全局库存/活动状态**,随时间变化, + **不暴露账号维度的拥有信息**。用它判"已拥有"会给运营错误的结论, + 而对高价值资产给错结论是这里最不能接受的失败方式。 + + **正确依据**:`exchange_records(manual_id)` —— 斗鱼官方的**账号级**兑换记录。 + 实测同一时刻: + + ====== ============================== ========== + 账号 官方兑换记录 判定 + ====== ============================== ========== + 558 破片手榴弹-兔兔白日梦 已获得 + 607 破片手榴弹-兔兔白日梦 已获得 + 15507 (0 条) 未获得 + ====== ============================== ========== + + ## 两处信息各司其职 + + - `records`(官方兑换记录)→ **是否已获得** + - `items`(限兑商品列表)→ 该商品**当前**的积分价 / 库存 / 活动时间(是全局信息, + 只用来展示"现在还能不能换、要多少分") + + ## 名字按子串匹配 + + 同一件东西会有多个商品名(实测有「套装-兔兔白日梦」和「破片手榴弹-兔兔白日梦」), + 所以按核心词子串匹配,把整个名字家族都算上。 + + 配置里的名字**一个都不能默默消失**:既不在商品列表、也不在兑换记录里的, + 返回 `missing=True`,让「没查到这个商品」和「这个账号没有」区分得开。 + """ + names = [str(w).strip() for w in (wanted or []) if str(w).strip()] + rows = [it for it in (items or []) if isinstance(it, dict)] + recs = [r for r in (records or []) if isinstance(r, dict)] + out: list[dict[str, Any]] = [] + for want in names: + hit = next( + (it for it in rows if want in str(it.get("commodityName") or "")), + None, + ) + # 该账号在这件东西上的**全部**兑换记录(可能是多个变体) + got = [ + str(r.get("commodityName") or r.get("name") or "") + for r in recs + if want in str(r.get("commodityName") or r.get("name") or "") + ] + out.append( + { + "name": str(hit.get("commodityName") or want) if hit else want, + "commodity_id": str(hit.get("commodityId") or "") if hit else "", + # 🎯 是否已获得:**只看官方兑换记录** + "owned": bool(got), + "obtained_names": got, + # 以下是全局的"现在能不能换"信息,仅用于展示 + "score": hit.get("score") if hit else None, + "count": hit.get("count") if hit else None, + "count_limited": hit.get("countLimited") if hit else None, + "start_time": hit.get("startTime") if hit else None, + "end_time": hit.get("endTime") if hit else None, + "pic": str(hit.get("pic") or "") if hit else "", + "in_goods_list": hit is not None, + "missing": hit is None and not got, + "reason": ( + "限兑商品列表和官方兑换记录里都没有这个商品" + if hit is None and not got + else "" + ), + } + ) + return out + + +def _goods_config(config: dict) -> list[str]: + """高价值商品名单:优先用配置,非法/为空则回落默认。""" + raw = config.get("high_value_goods") + if isinstance(raw, str) and raw.strip(): + try: + raw = json.loads(raw) + except ValueError: + raw = [p for p in raw.replace(",", ",").split(",") if p.strip()] + if isinstance(raw, (list, tuple)): + cleaned = [str(x).strip() for x in raw if str(x).strip()] + if cleaned: + return cleaned + return list(DEFAULT_HIGH_VALUE_GOODS) + + +class OverviewMixin: + """一键查询域:只读聚合,不写状态、不发事件。""" + + if TYPE_CHECKING: + + def _client(self, cookie: str) -> DouyuActivityClient: ... + def _mark_task(self, *args: Any, **kwargs: Any) -> None: ... + def _push_log(self, level: str, message: str) -> None: ... + def _refresh_account_gold_balance( + self, client: DouyuActivityClient, account: Account + ) -> dict: ... + def _refresh_account_points( + self, + client: DouyuActivityClient, + account: Account, + cookie: str, + *, + ctn: str = "", + ) -> dict: ... + def _refresh_elite_handbook( + self, + client: DouyuActivityClient, + account: Account, + *, + manual_id: str, + rid: str = "", + ) -> dict: ... + def _refresh_esports_handbook( + self, client: DouyuActivityClient, account: Account, *, manual_id: str + ) -> dict: ... + + def _execute_query_account_overview( + self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict + ): + """一键查询:把该账号的关键信息从斗鱼拉全。 + + 每一项独立 try/except —— 单项失败只记进 `errors`, + 绝不因此让整次查询失败(账号没绑电竞手册就是最常见的例子)。 + """ + client = self._client(cookie) + manual_id = str(config.get("manual_id") or "").strip() + esports_manual_id = str(config.get("esports_manual_id") or "").strip() + rid = str(config.get("rid") or "").strip() + + overview: dict[str, Any] = { + "queried_at": datetime.now(UTC).isoformat(), + "assets": {}, + "elite": {}, + "esports": {}, + "high_value_goods": [], + "exchange_records": [], + "errors": {}, + } + errors: dict[str, str] = overview["errors"] + all_records: list[dict[str, Any]] = [] + pending_goods: list[dict[str, Any]] = [] + + # ---- 鱼翅 + 钱包兑换余额 ---- + try: + gold = self._refresh_account_gold_balance(client, account) + overview["assets"]["gold_balance"] = gold.get("gold_balance") + overview["assets"]["exchange_balance"] = gold.get("exchange_balance") + except Exception as exc: # noqa: BLE001 外部接口边界需要宽泛兜底 + errors["gold"] = _err(exc) + + # ---- 积分 + 到期时间 ---- + try: + ctn = client.acf_ccn(refresh_subscribe=False) + points = self._refresh_account_points(client, account, cookie, ctn=ctn) + overview["assets"]["points"] = points.get("points") + # `_refresh_account_points` 只回 {"points", "points_query"}, + # 到期时间得从 points_query 里取 —— 复用已有具名函数,不自己扒结构。 + overview["assets"]["points_expire_at"] = _points_expire_at(points) + except Exception as exc: # noqa: BLE001 + errors["points"] = _err(exc) + + # ---- 精英宝典(开通状态 / 到期时间 / 积分)---- + if not manual_id: + errors["elite"] = "未配置精英宝典 manualID" + else: + try: + elite = self._refresh_elite_handbook( + client, account, manual_id=manual_id, rid=rid + ) + overview["elite"] = { + "opened": elite.get("elite_opened"), + "expire_time": elite.get("elite_expire_time"), + "points": elite.get("elite_manual_score"), + } + except Exception as exc: # noqa: BLE001 + errors["elite"] = _err(exc) + + # ---- 电竞手册 ---- + if not esports_manual_id: + errors["esports"] = "未配置电竞手册 manualID" + else: + try: + esports = self._refresh_esports_handbook( + client, account, manual_id=esports_manual_id + ) + manual_type = esports.get("esports_manual_type") + # `_refresh_esports_handbook` 不直接给「是否开通」; + # 语义以 create_esports_qr 里的判定为准:manual_type >= 1 = 已开通。 + opened = ( + manual_type is not None and int(manual_type) >= 1 + if isinstance(manual_type, (int, float)) + else None + ) + overview["esports"] = { + "opened": opened, + "manual_type": manual_type, + "points": esports.get("esports_points"), + "expire_time": esports.get("esports_expire_time"), + } + except Exception as exc: # noqa: BLE001 + errors["esports"] = _err(exc) + + goods_items: list[dict[str, Any]] = [] + try: + goods = client.query_limited_goods(manual_id=manual_id, rid=rid) + goods_items = goods.get("limited_goods") or [] + overview["limited_goods_total"] = len(goods_items) + except Exception as exc: # noqa: BLE001 + errors["limited_goods"] = _err(exc) + # 判定「是否已获得」必须用**官方兑换记录**(账号级), + # 不能用限兑商品列表的 count/storeStatus(那是全局库存,会给出错误结论)。 + # 所以这里放在兑换记录**之后**才能判定 —— 见下面的顺序。 + pending_goods = goods_items + + # ---- 官方兑换记录 ---- + if not manual_id: + errors["exchange_records"] = "未配置精英宝典 manualID" + else: + try: + records = client.exchange_records( + manual_id=manual_id, limit=EXCHANGE_RECORD_LIMIT + ) + rows = records.get("records") or [] + all_records = rows + overview["exchange_records"] = rows[:EXCHANGE_RECORD_LIMIT] + overview["exchange_record_count"] = len(rows) + except Exception as exc: # noqa: BLE001 + errors["exchange_records"] = _err(exc) + all_records = [] + + # ---- 🎯 高价值套装判定(依赖兑换记录,必须放在它之后)---- + if not manual_id: + errors["high_value_goods"] = "未配置精英宝典 manualID" + else: + try: + overview["high_value_goods"] = extract_high_value_goods( + pending_goods, _goods_config(config), all_records + ) + except Exception as exc: # noqa: BLE001 + errors["high_value_goods"] = _err(exc) + + # 查询会回写账号上的余额/积分缓存(各 _refresh_* 内部已写), + # 但**不改生命周期状态**,也不发事件。 + account.updated_at = datetime.now(UTC) + + owned = [ + g["name"] + for g in overview["high_value_goods"] + if not g.get("missing") and g.get("owned") + ] + message = f"一键查询完成;高价值套装已拥有 {len(owned)} 个" + if owned: + message += f": {', '.join(owned)}" + if errors: + message += f"({len(errors)} 项未取到)" + + logger.info(f"[douyu] 一键查询账号 {account.id}: {message}") + self._mark_task(db, task, "success", message, overview) diff --git a/web/backend/services/douyu_service.py b/web/backend/services/douyu_service.py index 886f4f4..46f0dc9 100644 --- a/web/backend/services/douyu_service.py +++ b/web/backend/services/douyu_service.py @@ -37,6 +37,8 @@ SUPPORTED_DOUYU_TASK_TYPES = { "query_game_name": "一键获取游戏名", "query_change_bind_time": "一键查询换绑时间", "query_limited_goods": "一键查询限兑商品", + # 跨域聚合查询:鱼翅/积分/精英/电竞/小店/兑换记录/高价值套装是否已拥有 + "query_account_overview": "一键查询账号信息", "query_gold_balance": "一键查询鱼翅余额", "refresh_goods": "刷新商品列表", "query_exchange_records": "一键查询兑换记录", @@ -72,6 +74,7 @@ DOUYU_HANDBOOK_TASK_TYPES = { "refresh_goods", "query_exchange_records", "prefetch_csrf_token", + "query_account_overview", }, "esports": { "prepare_esports_bind", @@ -88,6 +91,7 @@ DOUYU_HANDBOOK_TASK_TYPES = { "create_gold_qr", "donate_esports_chicken_gift", "donate_esports_firework_gift", + "query_account_overview", }, "peace": { "get_xpd_bind_qr", @@ -99,6 +103,7 @@ DOUYU_HANDBOOK_TASK_TYPES = { "query_xpd_fragments", "query_xpd_purchase_records", "exchange_xpd_goods", + "query_account_overview", }, } diff --git a/web/frontend/src/pages/AccountDetailPage.tsx b/web/frontend/src/pages/AccountDetailPage.tsx index c52d0b9..cfea88f 100644 --- a/web/frontend/src/pages/AccountDetailPage.tsx +++ b/web/frontend/src/pages/AccountDetailPage.tsx @@ -5,9 +5,12 @@ import { } from 'antd'; import type { TableProps } from 'antd'; import { - ArrowLeftOutlined, EditOutlined, ReloadOutlined, + ArrowLeftOutlined, EditOutlined, ReloadOutlined, ThunderboltOutlined, } from '@ant-design/icons'; -import { accountApi, userApi, type AccountEventItem, type AccountOverview, type UserInfo } from '../api/modules'; +import { accountApi, douyuApi, userApi, type AccountEventItem, type AccountOverview, type UserInfo } from '../api/modules'; +import AccountOverviewPanel, { + type AccountOverviewResult, +} from './douyu-tasks/AccountOverviewPanel'; import { getErrorMessage } from '../utils/error'; import { message } from '../utils/antdMessage'; import { formatTime } from '../utils/time'; @@ -113,6 +116,10 @@ export default function AccountDetailPage() { const [category, setCategory] = useState(''); const [eventsLoading, setEventsLoading] = useState(false); const [tagDraft, setTagDraft] = useState(''); + // 一键查询:向斗鱼实时查一次该账号的全部关键信息(异步任务 + 轮询结果) + const [ovLoading, setOvLoading] = useState(false); + const [ovResult, setOvResult] = useState(null); + const [ovError, setOvError] = useState(''); const [tagEditing, setTagEditing] = useState(false); const [supportUsers, setSupportUsers] = useState([]); const [remarkEditing, setRemarkEditing] = useState(false); @@ -133,6 +140,44 @@ export default function AccountDetailPage() { }, []); + // 一键查询:建一个只读聚合任务 → 轮询到出结果 → 渲染面板。 + // 之所以用异步任务而不是同步接口:它要串好几个斗鱼请求(可能十几秒), + // 异步能复用现成的执行器与进度推送,也不受 HTTP 请求超时限制。 + const runOverview = useCallback(async () => { + if (!id) return; + setOvLoading(true); + setOvError(''); + try { + const created = await douyuApi.createTasks({ + account_ids: [Number(id)], + task_type: 'query_account_overview', + handbook_scope: 'elite', + }); + const batchId = created.batch_id; + const deadline = Date.now() + 90_000; // 最多等 90 秒 + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + const rows = await douyuApi.listTasks('elite', batchId); + const done = rows.find( + (x) => x.status === 'success' || x.status === 'failed', + ); + if (done) { + if (done.status === 'failed') { + setOvError(done.message || '查询失败'); + } else { + setOvResult((done.result ?? null) as AccountOverviewResult | null); + } + return; + } + } + setOvError('查询超时(90 秒未出结果),可到斗鱼工作台看这个任务的进度'); + } catch (error) { + setOvError(getErrorMessage(error)); + } finally { + setOvLoading(false); + } + }, [id]); + const loadOverview = useCallback(async () => { if (!accountId) return; setLoading(true); @@ -324,6 +369,40 @@ export default function AccountDetailPage() { 1. 这账号现在能不能用?(状态 + CK) 2. 要不要我处理?(原因 + 变更时间 + 自动化是否在管) 3. 账上还有多少?(积分 / 鱼翅 / 兑换余额) */} + + + 一键查询(斗鱼实时) + + } + extra={ + + } + > + {ovError && ( + + {ovError} + + )} + {ovResult ? ( + + ) : ( + + 实时向斗鱼查询:鱼翅 / 积分(含到期时间)/ 精英宝典 / 电竞手册 / 官方兑换记录, + 并重点标出「兔兔白日梦」「狂战萌兔」是否已拥有。 + + )} + diff --git a/web/frontend/src/pages/AccountsPage.tsx b/web/frontend/src/pages/AccountsPage.tsx index d6bdae7..cb95b6e 100644 --- a/web/frontend/src/pages/AccountsPage.tsx +++ b/web/frontend/src/pages/AccountsPage.tsx @@ -6,8 +6,8 @@ import { } from 'antd'; import { message } from '../utils/antdMessage'; import type { TableProps } from 'antd'; -import { ImportOutlined, DeleteOutlined, TagOutlined, FilterOutlined, EyeOutlined, EyeInvisibleOutlined } from '@ant-design/icons'; -import { accountApi, type AccountItem, type BasicSummary } from '../api/modules'; +import { ImportOutlined, DeleteOutlined, TagOutlined, FilterOutlined, EyeOutlined, EyeInvisibleOutlined, ThunderboltOutlined } from '@ant-design/icons'; +import { accountApi, douyuApi, type AccountItem, type BasicSummary } from '../api/modules'; import { usePermissions } from '../hooks/usePermissions'; import { formatTimeShort } from '../utils/time'; import { getErrorMessage } from '../utils/error'; @@ -250,6 +250,32 @@ export default function AccountsPage() { } }; + // 批量一键查询:给每个勾选的账号建一条只读聚合任务(后端就是一个批次 N 条任务)。 + // 结果在「斗鱼工作台」按批次查看,那里有专门的结果面板。 + const [batchQuerying, setBatchQuerying] = useState(false); + const handleBatchOverview = async () => { + const ids = selectedRowKeys.map((key) => Number(key)); + if (ids.length === 0) { + message.warning('请先勾选要查询的账号'); + return; + } + setBatchQuerying(true); + try { + const created = await douyuApi.createTasks({ + account_ids: ids, + task_type: 'query_account_overview', + handbook_scope: 'elite', + }); + message.success( + `已创建 ${created.count} 条一键查询任务(批次 ${created.batch_id}),可到「斗鱼工作台 → 精英宝典」查看结果`, + ); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setBatchQuerying(false); + } + }; + const handleBatchDelete = async () => { if (selectedCount === 0) { message.warning('请先选择账号'); @@ -539,6 +565,16 @@ export default function AccountsPage() { )} + + + {canImport && (