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:
yml2213
2026-10-04 19:07:35 +08:00
parent 64dc9a9116
commit d285196287
10 changed files with 1191 additions and 23 deletions
+386
View File
@@ -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