Files
live-hub-py/tests/test_account_overview.py
T
yml2213 d285196287 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: []`),
  已获得/未获得判定全部正确
2026-10-04 19:07:35 +08:00

387 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""账号「一键查询」:判定规则、失败隔离、只读性。
判定规则用的是**生产镜像库的真实返回样本**(见 `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