feat(account): 账号「一键查询」(异步 + 批量)—— 含一次重要的判定修正
按 `docs/设计-账号一键查询.md` 实现。异步任务、支持批量(后端每账号一条任务)。 ## 功能 - 新任务类型 `query_account_overview`:**只读**聚合查询,不改账号状态、不发事件 (纯查询不是「事实」,按既有设计例行查询不进时间线,否则会刷屏) - 一次拉全:鱼翅 / 钱包兑换余额 / 积分(含到期)/ 精英宝典 / 电竞手册 / **官方兑换记录** / 限兑商品,并**重点标出「兔兔白日梦」「狂战萌兔」是否已获得** - **单项失败不整体失败**:账号没绑电竞手册是常态,失败只写进 `errors` 并在 UI **如实展示** —— 避免「没查到」被读成「没有」 - 批量:`create_douyu_planned_tasks` 本来就是每账号一条任务,前端加「批量一键查询」 - 前端:详情页一键查询卡片 + 结果面板(两个套装单独高亮);任务页结果自动弹面板; 三个工作台的快捷操作/类型集合都加了 ## ⚠️ 修正:判定「是否已获得」用错了依据 设计文档第一版说「`storeStatus == 0` → 已拥有」,依据是 「账号 558(兑换过)count=0,账号 15407(没兑换)count=96982」。 **这是错的。** 那两条记录分别取自 **2026-08-01** 和 **2026-10-04**,相隔两个月 —— 我把**时间差异**当成了**账号差异**。 **同一时刻**同时查 558 / 607(都兑换过)和 15507(没有), 商品项的**所有字段完全一致**,`preExchange.isExists` 也都是 0; `count` 的取值分布是 0/98746/103350/109832…,**随时间变化 = 全局库存**。 用它判「这个账号有没有」必然给错结论 —— 而对高价值资产给错结论是本功能最不能接受的。 **正确依据:`exchange_records(manual_id)`**(斗鱼官方的**账号级**兑换记录)。 真实斗鱼实测: | 账号 | 官方兑换记录 | 判定 | | --- | --- | --- | | 558 | 破片手榴弹-兔兔白日梦 | **已获得 ✅** | | 607 | 破片手榴弹-兔兔白日梦 | **已获得 ✅** | | 15507 | (0 条) | 未获得 ✅ | 同时刻三者看到的兔兔白日梦库存**都是 96956**(全局),兑换记录却区分得干干净净。 顺带发现:同一件东西有多个商品名(`套装-兔兔白日梦`、`破片手榴弹-兔兔白日梦`), 所以按**核心词子串**匹配整个名字家族。 商品列表的 `score`/`count`/活动时间仍展示,但明确是**全局**信息 (「现在还能不能换、要多少分」),与「该账号是否已获得」分开。 ## 测试 `tests/test_account_overview.py` 22 条,含**回归用例**: `test_global_stock_never_implies_owned`(全局售罄不能推出账号拥有)—— 正是第一版踩的坑。另有失败隔离、只读性(不发事件/不改状态)、 批量每账号一条任务、`missing` 不被静默丢弃。 ## 验证 - `TZ=Asia/Shanghai` **574 passed / 6 skipped**;`TZ=UTC` 27 passed - ruff 全绿;前端 build 通过、lint 0 errors - **真实斗鱼端到端**:三个账号各跑一次真查询,8 项全部成功(`errors: []`), 已获得/未获得判定全部正确
This commit is contained in:
+53
-19
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AccountOverviewResult | null>(null);
|
||||
const [ovError, setOvError] = useState('');
|
||||
const [tagEditing, setTagEditing] = useState(false);
|
||||
const [supportUsers, setSupportUsers] = useState<UserInfo[]>([]);
|
||||
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. 账上还有多少?(积分 / 鱼翅 / 兑换余额) */}
|
||||
<Card
|
||||
size="small"
|
||||
style={{ marginBottom: 12 }}
|
||||
title={
|
||||
<Space>
|
||||
<ThunderboltOutlined />
|
||||
<span>一键查询(斗鱼实时)</span>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ThunderboltOutlined />}
|
||||
loading={ovLoading}
|
||||
onClick={runOverview}
|
||||
>
|
||||
查询该账号全部信息
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{ovError && (
|
||||
<Text type="danger" style={{ display: 'block', marginBottom: 8 }}>
|
||||
{ovError}
|
||||
</Text>
|
||||
)}
|
||||
{ovResult ? (
|
||||
<AccountOverviewPanel result={ovResult} />
|
||||
) : (
|
||||
<Text type="secondary">
|
||||
实时向斗鱼查询:鱼翅 / 积分(含到期时间)/ 精英宝典 / 电竞手册 / 官方兑换记录,
|
||||
并重点标出「兔兔白日梦」「狂战萌兔」是否已拥有。
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
<Card size="small" style={{ marginBottom: 12 }}>
|
||||
<Space size={24} wrap align="start">
|
||||
<Space direction="vertical" size={4}>
|
||||
|
||||
@@ -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() {
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
<Tooltip title="实时向斗鱼查这些账号的鱼翅/积分/开通状态/兑换记录,并标出高价值套装是否已拥有">
|
||||
<Button
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
loading={batchQuerying}
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={handleBatchOverview}
|
||||
>
|
||||
批量一键查询
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{canImport && (
|
||||
<Button type="primary" icon={<ImportOutlined />} onClick={() => { setImportTag(''); setImportOpen(true); }}>
|
||||
批量导入
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { Alert, Card, Descriptions, Empty, Space, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
/** 一键查询的结果结构(后端 `query_account_overview` 写入 task.result)。 */
|
||||
export interface HighValueGoods {
|
||||
name: string;
|
||||
commodity_id?: string;
|
||||
score?: number | null;
|
||||
count?: number | null;
|
||||
store_status?: number | null;
|
||||
count_limited?: number | null;
|
||||
start_time?: string | number | null;
|
||||
end_time?: string | number | null;
|
||||
pic?: string;
|
||||
/** **是否已获得** —— 依据是斗鱼官方的账号级兑换记录,不是全局库存 */
|
||||
owned?: boolean | null;
|
||||
/** 官方兑换记录里命中的商品名(可能是「破片手榴弹-兔兔白日梦」这类变体) */
|
||||
obtained_names?: string[];
|
||||
/** 该商品当前是否在限兑列表里(全局信息,与"是否已获得"无关) */
|
||||
in_goods_list?: boolean;
|
||||
/** 限兑列表和兑换记录里都没有这个商品 */
|
||||
missing?: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface AccountOverviewResult {
|
||||
queried_at?: string;
|
||||
assets?: {
|
||||
gold_balance?: number | null;
|
||||
exchange_balance?: number | null;
|
||||
points?: number | null;
|
||||
points_expire_at?: string | number | null;
|
||||
};
|
||||
elite?: { opened?: boolean | null; expire_time?: number | null; points?: number | null };
|
||||
esports?: {
|
||||
opened?: boolean | null;
|
||||
manual_type?: number | null;
|
||||
points?: number | null;
|
||||
expire_time?: string | number | null;
|
||||
};
|
||||
high_value_goods?: HighValueGoods[];
|
||||
limited_goods_total?: number;
|
||||
exchange_records?: Array<Record<string, unknown>>;
|
||||
exchange_record_count?: number;
|
||||
/** 单项失败:**必须展示**,否则「没查到」会被读成「没有」 */
|
||||
errors?: Record<string, string>;
|
||||
}
|
||||
|
||||
const ERROR_LABELS: Record<string, string> = {
|
||||
gold: '鱼翅',
|
||||
points: '积分',
|
||||
elite: '精英宝典',
|
||||
esports: '电竞手册',
|
||||
high_value_goods: '高价值套装',
|
||||
exchange_records: '兑换记录',
|
||||
};
|
||||
|
||||
/** 时间戳(秒/毫秒/字符串)→ 可读文本;认不出来就原样显示。 */
|
||||
function formatTime(value: string | number | null | undefined): string {
|
||||
if (value === null || value === undefined || value === '') return '-';
|
||||
const text = String(value).trim();
|
||||
let seconds: number | null = null;
|
||||
if (/^\d+$/.test(text)) {
|
||||
seconds = Number(text);
|
||||
if (seconds > 10_000_000_000) seconds /= 1000;
|
||||
}
|
||||
if (seconds !== null) {
|
||||
const d = new Date(seconds * 1000);
|
||||
if (!Number.isNaN(d.getTime())) return d.toLocaleString('zh-CN');
|
||||
return text;
|
||||
}
|
||||
const d = new Date(text);
|
||||
return Number.isNaN(d.getTime()) ? text : d.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
function yesNo(value: boolean | null | undefined): string {
|
||||
if (value === true) return '已开通';
|
||||
if (value === false) return '未开通';
|
||||
return '未知';
|
||||
}
|
||||
|
||||
/**
|
||||
* 高价值套装卡片。
|
||||
*
|
||||
* ⚠️ 措辞刻意不用「已拥有」:`storeStatus=0` 也可能是**全局售罄/活动下架**,
|
||||
* 我们没法只凭它断言账号真的拿到了。所以写「不可再兑」并把 count 一起显示,
|
||||
* 让运营自己判断 —— 这比给一个可能错的结论好。
|
||||
*/
|
||||
function HighValueCard({ goods }: { goods: HighValueGoods }) {
|
||||
if (goods.missing) {
|
||||
return (
|
||||
<Card size="small" style={{ width: 280, borderStyle: 'dashed' }}>
|
||||
<Space direction="vertical" size={2}>
|
||||
<Text strong>{goods.name}</Text>
|
||||
<Tag color="default">未查到该商品</Tag>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{goods.reason || '限兑列表和官方兑换记录里都没有它'}
|
||||
</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
// ⚠️ 「是否已获得」只看官方兑换记录。`count`/`store_status` 是**全局库存**,
|
||||
// 同一时刻不同账号看到的都一样,**不能**用来判断这个账号有没有。
|
||||
const owned = goods.owned === true;
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
style={{ width: 280, borderColor: owned ? '#52c41a' : undefined }}
|
||||
title={<Text strong>{goods.name}</Text>}
|
||||
extra={
|
||||
<Tag color={owned ? 'green' : 'default'}>{owned ? '✅ 已获得' : '未获得'}</Tag>
|
||||
}
|
||||
>
|
||||
<Descriptions size="small" column={1} colon={false}>
|
||||
{owned && (
|
||||
<Descriptions.Item label="记录">
|
||||
{(goods.obtained_names || []).join('、') || '-'}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label="积分价">{goods.score ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="当前库存">
|
||||
{goods.count ?? '-'}
|
||||
{goods.count === 0 ? '(已售罄)' : ''}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="活动时间">
|
||||
{formatTime(goods.start_time)} ~ {formatTime(goods.end_time)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{!goods.in_goods_list && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
当前限兑列表里没有它(活动窗口可能已结束),但兑换记录仍在。
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AccountOverviewPanel({ result }: { result: AccountOverviewResult }) {
|
||||
if (!result) return <Empty description="暂无结果" />;
|
||||
const assets = result.assets || {};
|
||||
const errors = result.errors || {};
|
||||
const goods = result.high_value_goods || [];
|
||||
const records = result.exchange_records || [];
|
||||
|
||||
const recordColumns: ColumnsType<Record<string, unknown>> = [
|
||||
{
|
||||
title: '商品',
|
||||
dataIndex: 'commodityName',
|
||||
key: 'commodityName',
|
||||
render: (v: unknown) => String(v ?? '-'),
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
key: 'time',
|
||||
render: (_, row) =>
|
||||
formatTime(
|
||||
(row.createTime ?? row.create_time ?? row.time) as string | number | null,
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
{Object.keys(errors).length > 0 && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={`有 ${Object.keys(errors).length} 项没取到(不代表没有,只是这次没查到)`}
|
||||
description={
|
||||
<ul style={{ margin: 0, paddingLeft: 18 }}>
|
||||
{Object.entries(errors).map(([key, msg]) => (
|
||||
<li key={key}>
|
||||
<Text strong>{ERROR_LABELS[key] || key}</Text>:{msg}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card size="small" title="资产">
|
||||
<Descriptions size="small" column={2}>
|
||||
<Descriptions.Item label="鱼翅">
|
||||
{assets.gold_balance ?? '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="钱包兑换余额">
|
||||
{assets.exchange_balance ?? '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="积分">{assets.points ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="积分到期">
|
||||
{formatTime(assets.points_expire_at)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="开通状态">
|
||||
<Descriptions size="small" column={2}>
|
||||
<Descriptions.Item label="精英宝典">
|
||||
<Tag color={result.elite?.opened ? 'green' : 'default'}>
|
||||
{yesNo(result.elite?.opened)}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="宝典到期">
|
||||
{formatTime(result.elite?.expire_time)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="电竞手册">
|
||||
<Tag color={result.esports?.opened ? 'green' : 'default'}>
|
||||
{yesNo(result.esports?.opened)}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="电竞积分">
|
||||
{result.esports?.points ?? '-'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="🎯 高价值套装">
|
||||
{goods.length === 0 ? (
|
||||
<Text type="secondary">没有配置关注的高价值套装,或本次没取到</Text>
|
||||
) : (
|
||||
<Space wrap size={12}>
|
||||
{goods.map((g) => (
|
||||
<HighValueCard key={g.commodity_id || g.name} goods={g} />
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
size="small"
|
||||
title={`兑换记录${result.exchange_record_count ? `(${result.exchange_record_count} 条)` : ''}`}
|
||||
>
|
||||
{records.length === 0 ? (
|
||||
<Text type="secondary">官方暂无兑换记录</Text>
|
||||
) : (
|
||||
<Table
|
||||
size="small"
|
||||
rowKey={(_, i) => String(i)}
|
||||
columns={recordColumns}
|
||||
dataSource={records}
|
||||
pagination={{ pageSize: 10, size: 'small' }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
查询时间:{formatTime(result.queried_at)}
|
||||
</Text>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
CheckCircleOutlined, ColumnWidthOutlined, CopyOutlined, CreditCardOutlined, ExclamationCircleOutlined, FieldTimeOutlined, FilterOutlined, GiftOutlined,
|
||||
ImportOutlined, QrcodeOutlined, ReloadOutlined,
|
||||
SearchOutlined, SettingOutlined, ShoppingOutlined, StopOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
accountApi,
|
||||
@@ -18,6 +19,9 @@ import {
|
||||
} from '../../api/modules';
|
||||
import ExchangeResultImage from '../../components/ExchangeResultImage';
|
||||
import QrActions from '../../components/QrActions';
|
||||
import AccountOverviewPanel, {
|
||||
type AccountOverviewResult,
|
||||
} from './AccountOverviewPanel';
|
||||
import { usePermissions } from '../../hooks/usePermissions';
|
||||
import { useWebSocketLogs } from '../../hooks/useWebSocketLogs';
|
||||
import {
|
||||
@@ -149,6 +153,9 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
||||
const autoOpenedPayTaskIds = useRef<Set<number>>(new Set());
|
||||
const autoCopiedExchangeTaskIds = useRef<Set<number>>(new Set());
|
||||
const autoNotifiedConfirmFailedTaskIds = useRef<Set<number>>(new Set());
|
||||
// 一键查询:任务跑完后自动把结果面板弹出来(去重,同一个任务只弹一次)
|
||||
const [ovPanelTask, setOvPanelTask] = useState<DouyuTaskItem | null>(null);
|
||||
const shownOvTaskIds = useRef<Set<number>>(new Set());
|
||||
const [confirmFailTip, setConfirmFailTip] = useState<{ taskId: number; message: string } | null>(null);
|
||||
const [confirmFailCountdown, setConfirmFailCountdown] = useState(0);
|
||||
const [exchangePreview, setExchangePreview] = useState<{ task: DouyuTaskItem; url: string } | null>(null);
|
||||
@@ -642,6 +649,22 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 一键查询的结果是一大块结构化信息,不适合塞进列表单元格 ——
|
||||
// 跑完就弹面板,弹过就不再弹(任务会被反复轮询到,不去重会一直弹)。
|
||||
const finished = tasks.filter(
|
||||
(task) =>
|
||||
task.task_type === 'query_account_overview'
|
||||
&& task.status === 'success'
|
||||
&& task.result,
|
||||
);
|
||||
const fresh = finished.find((task) => !shownOvTaskIds.current.has(task.id));
|
||||
if (fresh) {
|
||||
shownOvTaskIds.current.add(fresh.id);
|
||||
setOvPanelTask(fresh);
|
||||
}
|
||||
}, [tasks]);
|
||||
|
||||
// qrTask 已由 useMemo 从 tasks 派生,切换 Tab / 后端 progress 写入后自动更新,无需手动跟随
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2537,6 +2560,19 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
|
||||
</Text>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={<Space><ThunderboltOutlined /><span>一键查询结果</span></Space>}
|
||||
open={Boolean(ovPanelTask)}
|
||||
onCancel={() => setOvPanelTask(null)}
|
||||
footer={<Button onClick={() => setOvPanelTask(null)}>关闭</Button>}
|
||||
width={780}
|
||||
styles={{ body: { maxHeight: '70vh', overflowY: 'auto' } }}
|
||||
>
|
||||
{ovPanelTask?.result ? (
|
||||
<AccountOverviewPanel result={ovPanelTask.result as AccountOverviewResult} />
|
||||
) : null}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
CheckCircleOutlined, CreditCardOutlined, FieldTimeOutlined, GiftOutlined,
|
||||
ProfileOutlined,
|
||||
QrcodeOutlined, ReloadOutlined, SearchOutlined, ShoppingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { DouyuGoodsItem, DouyuTaskItem } from '../../api/modules';
|
||||
@@ -38,6 +39,7 @@ export const ELITE_QUICK_ACTIONS = [
|
||||
{ key: 'query_limited_goods', icon: <SearchOutlined /> },
|
||||
{ key: 'query_gold_balance', icon: <SearchOutlined /> },
|
||||
{ key: 'lock_goods', icon: <ShoppingOutlined /> },
|
||||
{ key: 'query_account_overview', icon: <ProfileOutlined /> },
|
||||
{ key: 'exchange_goods', icon: <ShoppingOutlined /> },
|
||||
{ key: 'create_gold_qr', icon: <CreditCardOutlined /> },
|
||||
{ key: 'donate_elite_gift', icon: <GiftOutlined /> },
|
||||
@@ -52,6 +54,7 @@ export const ESPORTS_QUICK_ACTIONS = [
|
||||
{ key: 'query_limited_goods', icon: <SearchOutlined /> },
|
||||
{ key: 'query_gold_balance', icon: <SearchOutlined /> },
|
||||
{ key: 'refresh_esports_goods', icon: <ReloadOutlined /> },
|
||||
{ key: 'query_account_overview', icon: <ProfileOutlined /> },
|
||||
{ key: 'exchange_esports_goods', icon: <ShoppingOutlined /> },
|
||||
{ key: 'create_gold_qr', icon: <CreditCardOutlined /> },
|
||||
{ key: 'donate_esports_chicken_gift', icon: <GiftOutlined /> },
|
||||
@@ -66,6 +69,7 @@ export const PEACE_QUICK_ACTIONS = [
|
||||
{ key: 'query_xpd_balance', icon: <SearchOutlined /> },
|
||||
{ key: 'query_xpd_fragments', icon: <SearchOutlined /> },
|
||||
{ key: 'query_xpd_purchase_records', icon: <FieldTimeOutlined /> },
|
||||
{ key: 'query_account_overview', icon: <ProfileOutlined /> },
|
||||
{ key: 'refresh_xpd_goods', icon: <ReloadOutlined /> },
|
||||
{ key: 'exchange_xpd_goods', icon: <ShoppingOutlined /> },
|
||||
];
|
||||
@@ -88,6 +92,7 @@ export const ELITE_TASK_TYPES = new Set([
|
||||
'refresh_goods',
|
||||
'query_exchange_records',
|
||||
'prefetch_csrf_token',
|
||||
'query_account_overview',
|
||||
]);
|
||||
export const ESPORTS_TASK_TYPES = new Set([
|
||||
'prepare_esports_bind',
|
||||
@@ -104,6 +109,7 @@ export const ESPORTS_TASK_TYPES = new Set([
|
||||
'create_gold_qr',
|
||||
'donate_esports_chicken_gift',
|
||||
'donate_esports_firework_gift',
|
||||
'query_account_overview',
|
||||
]);
|
||||
export const PEACE_TASK_TYPES = new Set([
|
||||
'get_xpd_bind_qr',
|
||||
@@ -115,6 +121,7 @@ export const PEACE_TASK_TYPES = new Set([
|
||||
'query_xpd_fragments',
|
||||
'query_xpd_purchase_records',
|
||||
'exchange_xpd_goods',
|
||||
'query_account_overview',
|
||||
]);
|
||||
export const DOUYU_GOLD_AMOUNT_STORAGE_KEY = 'douyu_task_gold_amount';
|
||||
export const DOUYU_GIFT_COUNT_STORAGE_KEY = 'douyu_task_gift_count';
|
||||
|
||||
Reference in New Issue
Block a user