"""账号「一键查询」:判定规则、失败隔离、只读性。 判定规则用的是**生产镜像库的真实返回样本**(见 `SAMPLES`), 不是我自己编的 JSON —— 这样官方字段改名/结构变动时用例才会红。 """ from __future__ import annotations from datetime import UTC, datetime import pytest 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 class TestTimestampNormalisation: """斗鱼用 `0` / `"0"` / 空串表示「没有这个时间」。 直接透传的后果:前端把 `0` 当 Unix 时间戳 → 显示 **1970/1/1 08:00:00**, 运营会以为"到期时间是 1970 年"。(这个 bug 真出现过,用户报的。) """ @pytest.mark.parametrize("bad", [0, "0", "", " ", None, -1, "-1", False]) def test_no_value_becomes_none(self, bad): from web.backend.services.douyu_runner_overview import _ts assert _ts(bad) is None, f"{bad!r} 应归成 None,否则会显示成 1970" @pytest.mark.parametrize("good", [1795967999, "1795967999", 1795967999000]) def test_real_timestamps_pass_through(self, good): from web.backend.services.douyu_runner_overview import _ts assert _ts(good) == good class TestSnapshotIsWrittenToAccount: """结果要落到**账号**上,不是只留在任务里。 任务会被保留策略清掉,而「这个账号现在什么情况」是账号的信息 —— 应该在账号管理里看,不该依赖一条日志的寿命。 """ def setup_method(self): self.engine = get_test_engine() self.session = make_test_session(self.engine) self.user = User(username="snap-admin", password_hash="h", role="super_admin") self.session.add(self.user) self.session.flush() self.account = Account( username="snap-acc", password="p", email="s@e.com", email_password="ep" ) self.session.add(self.account) self.session.commit() def teardown_method(self): self.session.close() def _run(self, client=None): from web.backend.services.douyu_runner_overview import OverviewMixin class _H(OverviewMixin): def _client(self, cookie): return client or _FakeClient() def _mark_task(self, *a, **k): pass def _push_log(self, *a, **k): pass def _refresh_account_gold_balance(self, c, a): return {"gold_balance": 7, "exchange_balance": 1} def _refresh_account_points(self, c, a, cookie, *, ctn=None): # 斗鱼返回 0 表示没有到期时间 —— 这里模拟它 return { "points": 5, "points_query": {"raw": {"data": {"willExpireTime": "0"}}}, } def _refresh_elite_handbook(self, c, a, *, manual_id, rid=""): return { "elite_opened": False, "elite_expire_time": 0, "elite_manual_score": 0, } def _refresh_esports_handbook(self, c, a, *, manual_id): return {"esports_manual_type": 0, "esports_manual_score": 0} task = DouyuTask( batch_id="snap", account_id=self.account.id, task_type="query_account_overview", handbook_scope="elite", status="running", message="", created_by=self.user.id, ) self.session.add(task) self.session.commit() _H()._execute_query_account_overview( self.session, task, self.account, "c", {"manual_id": "M", "esports_manual_id": "E", "rid": "R"}, ) self.session.commit() self.session.refresh(self.account) return task def test_snapshot_and_time_are_stored_on_account(self): self._run() assert self.account.overview_snapshot is not None assert self.account.overview_queried_at is not None assert self.account.overview_snapshot["assets"]["gold_balance"] == 7 def test_no_expiry_is_stored_as_none_not_zero(self): """回归:`willExpireTime = "0"` 不能原样落库成 0(会显示 1970)。""" self._run() snap = self.account.overview_snapshot assert snap["assets"]["points_expire_at"] is None assert snap["elite"]["expire_time"] is None def test_snapshot_is_overwritten_not_appended(self): self._run() first = dict(self.account.overview_snapshot) self._run() self.session.refresh(self.account) assert self.account.overview_snapshot["assets"]["gold_balance"] == 7 assert self.account.overview_snapshot is not None assert first["assets"]["gold_balance"] == 7 class TestSnapshotExposedOnDetailOnly: """快照必须出现在**详情**接口,**不能**出现在列表接口。 这里踩过一个坑:字段被加到了列表接口而不是详情接口,后果有两层 —— 1. 详情不返回 `overview_queried_at` → 前端轮询永远检测不到变化 → 一直刷请求到 120 秒超时(用户报的「点一键查询就一直循环」); 2. 列表**每个账号**都带一整份 JSON 快照 → 15000 个账号的响应体积爆炸。 所以两头都要钉住。 """ def setup_method(self): self.engine = get_test_engine() self.session = make_test_session(self.engine) self.admin = User(username="snapx-admin", password_hash="h", role="super_admin") self.session.add(self.admin) self.session.flush() self.account = Account( username="snapx-acc", password="p", email="x@e.com", email_password="ep", cookie="acf_uid=1; acf_ccn=c", overview_snapshot={"assets": {"gold_balance": 3}}, overview_queried_at=datetime.now(UTC), ) self.session.add(self.account) self.session.commit() def teardown_method(self): self.session.close() def test_detail_returns_snapshot_and_timestamp(self): from web.backend.routers.accounts import account_overview result = account_overview(self.account.id, db=self.session, current=self.admin) assert "overview_snapshot" in result, "详情必须返回快照,否则前端没法展示" assert "overview_queried_at" in result, ( "详情必须返回查询时间 —— 前端靠它判断「查完了没有」," "不返回就会一直轮询到超时" ) assert result["overview_queried_at"] is not None def test_list_does_not_carry_the_snapshot(self): """列表里带整份快照会让 15000 个账号的响应爆炸。""" from web.backend.routers.accounts import list_accounts # 直接调用时**未显式传**的参数拿到的是 FastAPI 的 `Query` 对象,而它是 # truthy 的(`assigned_only=Query(False)` 会被当成 True,只返回分配给 # 自己的账号)—— 所以关键参数都要显式传。 # 另外测试库里有几百个账号,必须用 search 收敛到夹具本身,否则会翻页。 result = list_accounts( assigned_only=False, has_cookie=False, search="snapx-acc", page=1, page_size=20, db=self.session, current=self.admin, ) items = result["items"] if isinstance(result, dict) else result assert items, "夹具账号应出现在列表里" for item in items: assert "overview_snapshot" not in item, ( "列表接口不该返回完整快照(每个账号一份 JSON,体积爆炸)" ) class TestOverviewRefreshesAccountFields: """查询的职责是**刷新账号字段**,不是另开一份副本。 用户反馈很直接:「一键查询这也太重复了,直接更新账号信息不行吗? 那建立账号信息表有啥用」。 对 —— 鱼翅 / 积分 / 交易余额 / 开通状态账号表本来就有对应列, 查询把新值写进去、由已有界面展示即可。这里钉住写回行为, 尤其是**积分到期时间**:它有 `accounts.points_expire_at` 列, 但之前忘了写(也没在界面上显示)。 """ def setup_method(self): self.engine = get_test_engine() self.session = make_test_session(self.engine) self.user = User(username="rf-admin", password_hash="h", role="super_admin") self.session.add(self.user) self.session.flush() self.account = Account( username="rf-acc", password="p", email="r@e.com", email_password="ep" ) self.session.add(self.account) self.session.commit() def teardown_method(self): self.session.close() def _run(self, *, expire_at: str = "1795967999"): from web.backend.services.douyu_runner_overview import OverviewMixin class _H(OverviewMixin): def _client(self, cookie): return _FakeClient() def _mark_task(self, *a, **k): pass def _push_log(self, *a, **k): pass def _refresh_account_gold_balance(self, c, a): a.gold_balance = 88 a.exchange_balance = 9 return {"gold_balance": 88, "exchange_balance": 9} def _refresh_account_points(self, c, a, cookie, *, ctn=None): a.points = 777 return { "points": 777, "points_query": {"raw": {"data": {"willExpireTime": expire_at}}}, } def _refresh_elite_handbook(self, c, a, *, manual_id, rid=""): a.elite_opened = True return { "elite_opened": True, "elite_expire_time": 0, "elite_manual_score": 5, } def _refresh_esports_handbook(self, c, a, *, manual_id): a.esports_opened = True return {"esports_manual_type": 1, "esports_manual_score": 3} task = DouyuTask( batch_id="rf", account_id=self.account.id, task_type="query_account_overview", handbook_scope="elite", status="running", message="", created_by=self.user.id, ) self.session.add(task) self.session.commit() _H()._execute_query_account_overview( self.session, task, self.account, "c", {"manual_id": "M", "esports_manual_id": "E", "rid": "R"}, ) self.session.commit() self.session.refresh(self.account) def test_assets_are_written_to_account_columns(self): self._run() assert self.account.points == 777 assert self.account.gold_balance == 88 assert self.account.exchange_balance == 9 assert self.account.elite_opened is True assert self.account.esports_opened is True def test_points_expire_at_is_written(self): """积分到期时间有专门的列,查询必须写它(之前漏了)。""" self._run(expire_at="1795967999") assert self.account.points_expire_at is not None assert self.account.points_expire_at.year != 1970 def test_no_expiry_clears_the_column_not_zero(self): """斗鱼用 0 表示没有到期时间 —— 不能存成 0(会显示 1970/1/1)。""" self._run(expire_at="0") assert self.account.points_expire_at is None assert self.account.overview_snapshot["assets"]["points_expire_at"] is None