"""账号「一键查询」:判定规则、失败隔离、只读性。 判定规则用的是**生产镜像库的真实返回样本**(见 `SAMPLES`), 不是我自己编的 JSON —— 这样官方字段改名/结构变动时用例才会红。 """ from __future__ import annotations from datetime import UTC, datetime from pathlib import Path 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} # handler 按作用域抓官方记录(三套接口),测试里给最小实现 def _fetch_bind_info(self, client, scope, alias): # 测试里统一给"已绑定"(有角色名),避免依赖具体假客户端 return {"is_bound_role": True, "is_bound_act": True, "role_name": "某角色"} def _fetch_elite_records(self, client, config, manual_id, esports_manual_id): return client.exchange_records(manual_id=manual_id).get("records") or [] def _fetch_esports_records(self, client, config, manual_id, esports_manual_id): return [] def _fetch_xpd_records(self, client, config, manual_id, esports_manual_id): return [] 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", "xpd_act_alias": "A", "xpd_rid": "R", "xpd_act_id": "1", "bind_act_alias": "B", "esports_act_alias": "E", }, ) 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} def _fetch_bind_info(self, c, scope, alias): return { "is_bound_role": True, "is_bound_act": True, "role_name": "某角色", } def _fetch_elite_records(self, c, cfg, mid, emid): return c.exchange_records(manual_id=mid).get("records") or [] def _fetch_esports_records(self, c, cfg, mid, emid): return [] def _fetch_xpd_records(self, c, cfg, mid, emid): return [] 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", "xpd_act_alias": "A", "xpd_rid": "R", "xpd_act_id": "1", "bind_act_alias": "B", "esports_act_alias": "E", }, ) 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} def _fetch_bind_info(self, c, scope, alias): return { "is_bound_role": True, "is_bound_act": True, "role_name": "某角色", } def _fetch_elite_records(self, c, cfg, mid, emid): return c.exchange_records(manual_id=mid).get("records") or [] def _fetch_esports_records(self, c, cfg, mid, emid): return [] def _fetch_xpd_records(self, c, cfg, mid, emid): return [] 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", "xpd_act_alias": "A", "xpd_rid": "R", "xpd_act_id": "1", "bind_act_alias": "B", "esports_act_alias": "E", }, ) 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 class TestSummaryFeedsAccountList: """一键查询的结果要能反映到**账号列表**上。 用户报的原始问题:账号 6966677706 明明有官方兑换记录,列表却显示「未兑换」。 根因是**两个口径**: - 列表的「兑换记录」列来自我们自己的 `account_events`,而事件是**上线后** 才开始记的 → 上线前的兑换在列表里就是空的,看着像"从没兑换过"; - 一键查询拉的是**斗鱼官方**记录,本来能查到,但那份 JSON 有 1~2KB, 不能塞进列表(15000 个账号会把响应放大到几十 MB)。 所以加了这个**极小**汇总字段 `high_value_owned`:`{"owned": [...], "exchanges": N}`。 """ def setup_method(self): self.engine = get_test_engine() self.session = make_test_session(self.engine) self.admin = User(username="sum-admin", password_hash="h", role="super_admin") self.session.add(self.admin) self.session.flush() self.account = Account( username="sum-acc", password="p", email="s@e.com", email_password="ep", cookie="acf_uid=1; acf_ccn=c", high_value_owned=["套装-兔兔白日梦"], overview_queried_at=datetime.now(UTC), overview_snapshot={"high_value_goods": []}, ) self.session.add(self.account) self.session.commit() def teardown_method(self): self.session.close() def _list(self): from web.backend.routers.accounts import list_accounts return list_accounts( assigned_only=False, has_cookie=False, search="sum-acc", page=1, page_size=5, db=self.session, current=self.admin, )["items"] def test_list_serializes_owned_suits(self): """断言**序列化结果**,不是属性。 ⚠️ `AccountOut` 有一个**手写**的 `@model_serializer`,它逐个列出要输出的字段。 只在模型上声明字段、不写进那个列表 → **属性有值、JSON 里没有**。 只断言属性的测试会**假绿**(踩过),所以这里一律看 `model_dump()`。 """ dumped = self._list()[0].model_dump() assert dumped["high_value_owned"] == ["套装-兔兔白日梦"], ( "字段没出现在序列化结果里 —— 前端拿到的 JSON 就没有它" ) def test_list_serializes_queried_marker(self): """要能区分「查过但没兑换」和「还没查过」—— 否则列表会把未知当高价值。""" dumped = self._list()[0].model_dump() assert dumped["overview_queried_at"] is not None def test_list_still_does_not_carry_the_full_snapshot(self): """给列表加信息**不能**把 1~2KB 的整份快照带回去。""" dumped = self._list()[0].model_dump() assert "overview_snapshot" not in dumped, ( "列表带整份快照会让 15000 个账号的响应体积爆炸" ) def test_handler_writes_the_summary(self): """查询跑完必须写汇总,否则列表永远显示「未查询」。""" 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): return {"gold_balance": 1, "exchange_balance": 1} def _refresh_account_points(self, c, a, cookie, *, ctn=None): return { "points": 1, "points_query": {"raw": {"data": {"willExpireTime": "0"}}}, } def _refresh_elite_handbook(self, c, a, *, manual_id, rid=""): return { "elite_opened": True, "elite_expire_time": 0, "elite_manual_score": 0, } def _refresh_esports_handbook(self, c, a, *, manual_id): return {"esports_manual_type": 1, "esports_manual_score": 0} def _fetch_elite_records(self, c, cfg, mid, emid): # 精英恰好 1 条(与断言一致) return [ { "commodityName": "套装-兔兔白日梦", "commodityId": "C1", "score": "1280", } ] def _fetch_esports_records(self, c, cfg, mid, emid): return [] def _fetch_xpd_records(self, c, cfg, mid, emid): return [] task = DouyuTask( batch_id="sum", account_id=self.account.id, task_type="query_account_overview", handbook_scope="elite", status="running", message="", created_by=self.admin.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", "xpd_act_alias": "A", "xpd_rid": "R", "xpd_act_id": "1", "bind_act_alias": "B", "esports_act_alias": "E", }, ) self.session.commit() self.session.refresh(self.account) owned = self.account.high_value_owned assert owned is not None, "查询跑完必须写「已兑换过的高价值套装」" # _FakeClient 的兑换记录里有一条「破片手榴弹-兔兔白日梦」 assert "兔兔白日梦" in "".join(owned) class TestOverviewSummaryMigrationIsSafeToRerun: """迁移必须**可安全重跑** —— 应用启动时会自动跑它。 踩过的坑:回填时用了 `op.get_bind().commit()`, 打乱了 alembic 自己的版本记账。后果很隐蔽: - MySQL 的 DDL(ALTER TABLE)是**自动提交**的 → 列加上了 - 但版本号**没更新** → 下次启动又去 ADD COLUMN - → `Duplicate column name 'high_value_owned'`,**服务直接起不来** 仓库里正常工作的老迁移(0042 的 S1 回填)用的是 `op.get_bind().execute(...)` 且**不 commit**,提交由 alembic 负责。 """ MIGRATION = ( Path(__file__).resolve().parents[1] / "web/backend/migrations/versions/20261004_0048_high_value_owned.py" ) def _src(self) -> str: return self.MIGRATION.read_text(encoding="utf-8") def _code(self) -> str: """去掉注释后的源码。 必须去注释:本迁移的注释里**正解释着**「不要 conn.commit()」, 直接子串检查会命中注释里的那句话,用例就假红了。 """ lines = [] for line in self._src().splitlines(): stripped = line.lstrip() if stripped.startswith("#"): continue # 去掉行尾注释(这些迁移里没有字符串里带 # 的情况) if "#" in line: line = line[: line.index("#")] lines.append(line) return "\n".join(lines) def test_does_not_commit_the_alembic_bind(self): """在迁移里 commit 会破坏版本记账(本迁移踩过)。""" code = self._code() assert "conn.commit()" not in code, ( "迁移里不要 commit op.get_bind() —— 会让版本号不更新," "下次启动重复 ADD COLUMN 而把服务起不来" ) def test_guards_add_column_with_existence_check(self): """加列前必须检查列是否已存在,否则不可重入。""" src = self._src() assert "_column_exists" in src, "缺少列存在性检查" upgrade_body = src[src.index("def upgrade()") : src.index("def downgrade()")] assert "_column_exists" in upgrade_body, "upgrade 里没有用存在性检查守卫" def test_column_exists_helper_probes_information_schema(self): src = self._src() assert "information_schema.columns" in src, ( "存在性检查要查 information_schema,别用 try/except 猜" ) class TestOfficialExchangeBecomesAccountEvent: """斗鱼官方兑换记录要**补成账号事件**。 这是「账号全生命周期 + 兑换记录」的关键一步:官方记录**带真实兑换时间** (`time` 字段),补成事件后时间线才是完整的,列表的兑换记录聚合也自动准确 —— 不再需要单独维护一套"官方条数"的旁路。 """ def setup_method(self): self.engine = get_test_engine() self.session = make_test_session(self.engine) self.user = User(username="oe-admin", password_hash="h", role="super_admin") self.session.add(self.user) self.session.flush() self.account = Account( username="oe-acc", password="p", email="o@e.com", email_password="ep" ) self.session.add(self.account) self.session.commit() def teardown_method(self): self.session.close() def _record(self, *, commodity_id="C1", name="套装-兔兔白日梦", when=1788054985): from datetime import UTC from datetime import datetime as _dt from web.backend.services.business_events import record_official_exchange return record_official_exchange( self.session, self.account, commodity_id=commodity_id, commodity_name=name, score=1280, occurred_at=_dt.fromtimestamp(when, tz=UTC), show_name="某昵称(安卓-微信)", ) def test_writes_event_with_real_exchange_time(self): """时间必须是**真实兑换时刻**,不是"发现它的时刻"。""" from web.backend.services import account_events_service as svc assert self._record() is True self.session.commit() event = ( self.session.query(AccountEvent) .filter(AccountEvent.event_type == svc.EVENT_EXCHANGE) .one() ) assert event.occurred_at.year == 2026 assert event.occurred_at.month == 8 # 1788054985 → 2026-08-30 前后 assert event.amount == -1280 assert event.detail["source"] == "official_sync" assert event.source == "official_sync" def test_is_idempotent(self): """一键查询会被反复执行,同一条官方记录只能落一条事件。""" from web.backend.services import account_events_service as svc assert self._record() is True self.session.commit() assert self._record() is False, "同一条记录不该重复落" self.session.commit() assert ( self.session.query(AccountEvent) .filter(AccountEvent.event_type == svc.EVENT_EXCHANGE) .count() == 1 ) def test_different_commodity_is_a_new_event(self): assert self._record(commodity_id="C1") is True assert self._record(commodity_id="C2", name="套装-激萌罗小黑") is True self.session.commit() def test_missing_commodity_id_is_skipped(self): assert self._record(commodity_id="") is False class TestLatestExchangeFollowsEventTime: """「最近一次兑换」要按**事件时间**排,不能按插入 id。 踩过的坑:官方历史记录被补成事件时 `id` 是新的(最大), 但 `occurred_at` 是几个月前的。按 id 排会把一条**老**兑换显示成"最近一次" —— 实测把 08-30 的兔兔白日梦错报成了 10-01(那是另一次兑换)。 """ def setup_method(self): self.engine = get_test_engine() self.session = make_test_session(self.engine) self.admin = User(username="ord-admin", password_hash="h", role="super_admin") self.session.add(self.admin) self.session.flush() self.account = Account( username="ord-acc", password="p", email="d@e.com", email_password="ep", cookie="acf_uid=1; acf_ccn=c", ) self.session.add(self.account) self.session.commit() def teardown_method(self): self.session.close() def _add_exchange(self, title: str, when: datetime): from web.backend.services.account_events_service import ( CATEGORY_BUSINESS, EVENT_EXCHANGE, OFFICIAL_SYNC_SOURCE, emit, ) emit( self.session, account_id=self.account.id, event_type=EVENT_EXCHANGE, title=title, category=CATEGORY_BUSINESS, occurred_at=when, # 列表只聚合**官方**记录,所以要标成 official_sync 才会被算进去 source=OFFICIAL_SYNC_SOURCE, ) def _latest(self) -> str: from web.backend.routers.accounts import list_accounts item = list_accounts( assigned_only=False, has_cookie=False, search="ord-acc", page=1, page_size=5, db=self.session, current=self.admin, )["items"][0] return item.model_dump()["last_exchange_name"] def test_newer_event_inserted_first_still_wins(self): # 先插**新**的,再插**老**的:按 id 排会错,按时间排才对 self._add_exchange("兑换 新商品", datetime(2026, 10, 1, tzinfo=UTC)) self.session.commit() self._add_exchange("兑换 老商品", datetime(2026, 8, 30, tzinfo=UTC)) self.session.commit() assert "新商品" in self._latest(), ( "「最近一次」按 id 排了 —— 补录的历史事件 id 更大,会被错当成最近一次" ) class TestEsportsExpireTimePersisted: """电竞手册到期时间要**落库**(与精英对称)。 以前它只在 `_refresh_esports_handbook` 的返回值里带着、没写进账号, 所以界面上电竞手册只能显示「已开通」、看不到什么时候到期(用户反馈)。 """ def setup_method(self): self.engine = get_test_engine() self.session = make_test_session(self.engine) self.user = User(username="exp-admin", password_hash="h", role="super_admin") self.session.add(self.user) self.session.flush() self.account = Account( username="exp-acc", password="p", email="e@e.com", email_password="ep" ) self.session.add(self.account) self.session.commit() def teardown_method(self): self.session.close() def _refresh(self, expire_time): from web.backend.services.douyu_runner_manual import ManualMixin class _H(ManualMixin): def _client(self, cookie): return None def _push_log(self, *a, **k): pass def _to_int(self, value): try: return int(value) except (TypeError, ValueError): return None class _Client: @staticmethod def esports_user_info(*, manual_id): return {"manual_type": 1, "manual_score": 7, "expire_time": expire_time} _H()._refresh_esports_handbook(_Client(), self.account, manual_id="E") self.session.commit() self.session.refresh(self.account) def test_expire_time_is_written(self): self._refresh("1795967999") assert self.account.esports_expire_time == 1795967999 def test_no_expiry_stores_none_not_zero(self): """斗鱼用 0 / 空表示没有到期时间 —— 不能存成 0(界面会显示 1970/1/1)。""" self._refresh("0") assert self.account.esports_expire_time is None self._refresh("") assert self.account.esports_expire_time is None class TestDeriveFromBindInfo: """从斗鱼 `bind_info` 推导绑定状态。 **实测校准过**(开发库 4 个账号):`is_bound_role` 为真时账号确实有角色名, 而它们的列值全是 `unknown` —— 4 个里 3 个其实已绑定,被 unknown 盖住了。 ⚠️ `need_bind_role` **不是**"未绑定"的信号:实测**已绑定**的账号上它也是 1。 用它会把这 3 个已绑定的判成未绑 —— 幸好没拿它当判据。 """ def test_bound_when_role_flag_true(self): from web.backend.services.account_binding import ( BIND_BOUND, derive_from_bind_info, ) assert ( derive_from_bind_info({"is_bound_role": True, "role_name": "x"}) == BIND_BOUND ) def test_bound_when_act_flag_true(self): """只有 act 标志为真时也算已绑 —— 避免把"已绑但没回角色名"误报成未绑。""" from web.backend.services.account_binding import ( BIND_BOUND, derive_from_bind_info, ) assert derive_from_bind_info({"is_bound_act": True}) == BIND_BOUND def test_unbound_when_flags_explicitly_false(self): from web.backend.services.account_binding import ( BIND_UNBOUND, derive_from_bind_info, ) assert derive_from_bind_info({"is_bound_role": False}) == BIND_UNBOUND assert derive_from_bind_info({"is_bound_act": "0"}) == BIND_UNBOUND def test_none_when_no_conclusion(self): """标志缺失 → None(推不出来),**绝不能**当成 unbound。 这是"没查到"和"没绑定"的分界:写 unbound 就是编造结论。 小店未开通时实测就是这种情况(返回里没有这两个标志)。 """ from web.backend.services.account_binding import derive_from_bind_info assert derive_from_bind_info({}) is None assert derive_from_bind_info({"role_name": "x"}) is None assert derive_from_bind_info(None) is None def test_need_bind_role_is_not_a_signal(self): """回归:`need_bind_role` 在**已绑定**账号上也是 1,不能拿它判未绑。""" from web.backend.services.account_binding import ( BIND_BOUND, derive_from_bind_info, ) info = {"is_bound_role": True, "is_bound_act": True, "need_bind_role": 1} assert derive_from_bind_info(info) == BIND_BOUND class TestOverviewWritesBindStatus: """一键查询要把三个作用域的绑定状态**查出来并写回**。 这三个值以前从来没人查过,只是 S6 迁移时从旧的垃圾场列能推的推、 推不出的留 unknown —— 于是界面上 97% 显示"未知",对运营没用。 """ def setup_method(self): self.engine = get_test_engine() self.session = make_test_session(self.engine) self.user = User(username="bind-admin", password_hash="h", role="super_admin") self.session.add(self.user) self.session.flush() self.account = Account( username="bind-acc", password="p", email="b@e.com", email_password="ep", role_bind_status="unknown", esports_bind_status="unknown", xpd_bind_status="unknown", ) self.session.add(self.account) self.session.commit() def teardown_method(self): self.session.close() def _run(self, *, info_by_scope): from web.backend.services.douyu_runner_overview import OverviewMixin class _H(OverviewMixin): def _client(self, c): return _FakeClient() def _mark_task(self, *a, **k): pass def _push_log(self, *a, **k): pass def _fetch_bind_info(self, client, scope, alias): return info_by_scope[scope] def _refresh_account_gold_balance(self, c, a): return {"gold_balance": 1, "exchange_balance": 1} def _refresh_account_points(self, c, a, cookie, *, ctn=None): return {"points": 1, "points_query": {"raw": {"data": {}}}} 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} def _fetch_elite_records(self, c, cfg, mid, emid): return [] def _fetch_esports_records(self, c, cfg, mid, emid): return [] def _fetch_xpd_records(self, c, cfg, mid, emid): return [] task = DouyuTask( batch_id="bind", 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", "bind_act_alias": "B", "esports_act_alias": "S", "xpd_act_alias": "X", # 这个用例要覆盖三个作用域,显式打开小店(默认是关的) "peace_enabled": True, "xpd_rid": "R", "xpd_act_id": "1", }, ) self.session.commit() self.session.refresh(self.account) return task def test_writes_derived_status(self): self._run( info_by_scope={ "elite": {"is_bound_role": True, "role_name": "有角色"}, "esports": {"is_bound_role": False}, "peace": {"is_bound_role": False}, } ) assert self.account.role_bind_status == "bound" assert self.account.esports_bind_status == "unbound" assert self.account.xpd_bind_status == "unbound" def test_none_derivation_keeps_original_value(self): """推不出来时**不动原值** —— 不能把"没查到"写成"没绑定"。""" self._run( info_by_scope={ "elite": {"is_bound_role": True}, "esports": {}, # 标志缺失 → 推不出来 "peace": {}, } ) assert self.account.role_bind_status == "bound" assert self.account.esports_bind_status == "unknown", "推不出来应保持原值" assert self.account.xpd_bind_status == "unknown" class TestDuplicateEventIsQuiet: """撞唯一键 = 幂等命中,是**预期内的正常情况**,不该刷 WARNING。 踩过的坑:`emit()` 不做预检查、直接 INSERT,靠唯一索引去重;撞了就 打 WARNING 并把**整条 SQL + 参数**dump 出来。而一键查询会把官方兑换记录 补成事件、同一个账号会被反复查询 —— 于是每查一次就刷一片 WARNING: WARNING | 写入账号事件失败(account=60 exchange): (1062, "Duplicate entry 'official_exchange:elite:60:...' ...") [SQL: INSERT INTO account_events (...) VALUES (...)] [parameters: {...}] 数据没错(重复的被挡住了),但日志被淹、运维以为出事了(用户就是因此来问的)。 """ def setup_method(self): self.engine = get_test_engine() self.session = make_test_session(self.engine) self.user = User(username="dup-admin", password_hash="h", role="super_admin") self.session.add(self.user) self.session.flush() self.account = Account( username="dup-acc", password="p", email="d@e.com", email_password="ep" ) self.session.add(self.account) self.session.commit() def teardown_method(self): self.session.close() def _emit_once(self): from web.backend.services.account_events_service import emit return emit( self.session, account_id=self.account.id, event_type="exchange", title="兑换 某商品", category="business", source="official_sync", source_ref="official_exchange:elite:dup:commodity:20261001", ) def test_duplicate_returns_false_without_warning(self): from loguru import logger assert self._emit_once() is True self.session.commit() warnings: list[str] = [] sink_id = logger.add(lambda msg: warnings.append(str(msg)), level="WARNING") try: assert self._emit_once() is False, "重复写入应返回 False" finally: logger.remove(sink_id) assert not [w for w in warnings if "写入账号事件失败" in w], ( "撞唯一键是正常的幂等命中,不该打 WARNING(日志会被淹,且误导运维)" ) def test_duplicate_does_not_write_twice(self): from web.backend.services import account_events_service as svc self._emit_once() self.session.commit() self._emit_once() self.session.commit() assert ( self.session.query(AccountEvent) .filter(AccountEvent.event_type == svc.EVENT_EXCHANGE) .count() == 1 ) def test_duplicate_helper_only_matches_1062(self): """只有**确认**是唯一键冲突才降级;其它完整性错误仍要告警。""" from web.backend.services.account_events_service import _is_duplicate_entry class _Orig(Exception): pass dup = Exception("x") dup.orig = _Orig(1062, "Duplicate entry 'a' for key 'k'") # type: ignore[attr-defined] assert _is_duplicate_entry(dup) is True other = Exception("x") other.orig = _Orig(1048, "Column 'a' cannot be null") # type: ignore[attr-defined] assert _is_duplicate_entry(other) is False class TestPeaceScopeCanBeTurnedOff: """和平小店总开关:关着时**完全跳过**它的请求。 小店目前是关闭状态,但不跳过的话一键查询仍会对每个账号发小店请求、 结果 100% 失败: 实测 200 个账号里 exchange_records_peace 失败 195 次(100%) Livelink登录态校验失败 这既淹掉真错误、又让人以为出故障。关着时应当**不查、也不记 errors** —— 「配置成关的」和「查失败」是两回事。 """ def setup_method(self): self.engine = get_test_engine() self.session = make_test_session(self.engine) self.user = User(username="peace-admin", password_hash="h", role="super_admin") self.session.add(self.user) self.session.flush() self.account = Account( username="peace-acc", password="p", email="p@e.com", email_password="ep" ) self.session.add(self.account) self.session.commit() def teardown_method(self): self.session.close() def _run(self, *, peace_enabled: bool): from web.backend.services.douyu_runner_overview import OverviewMixin called: list[str] = [] marked: dict = {} class _H(OverviewMixin): def _client(self, c): return _FakeClient() def _mark_task(self, db, task, status, message, result=None): # 捕获结果:这个桩是 no-op,不捕获的话 task.result 永远是空 marked.update({"status": status, "result": result or {}}) def _push_log(self, *a, **k): pass def _fetch_bind_info(self, client, scope, alias): called.append(f"bind:{scope}") return {"is_bound_role": True} def _fetch_xpd_records(self, c, cfg, mid, emid): called.append("xpd_records") raise RuntimeError("Livelink登录态校验失败") def _refresh_account_gold_balance(self, c, a): return {"gold_balance": 1, "exchange_balance": 1} def _refresh_account_points(self, c, a, cookie, *, ctn=None): return {"points": 1, "points_query": {"raw": {"data": {}}}} 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} def _fetch_elite_records(self, c, cfg, mid, emid): return [] def _fetch_esports_records(self, c, cfg, mid, emid): return [] task = DouyuTask( batch_id="peace", 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", "bind_act_alias": "B", "esports_act_alias": "S", "xpd_act_alias": "X", "xpd_rid": "R", "xpd_act_id": "1", "peace_enabled": peace_enabled, }, ) self.session.commit() return marked, called def test_peace_requests_are_skipped_when_disabled(self): marked, called = self._run(peace_enabled=False) assert "xpd_records" not in called, "小店关着时不该发小店的请求" assert "bind:peace" not in called, "小店关着时也不该查小店的绑定" result = marked["result"] # 关键:**不记 errors** —— 这是配置成关的,不是失败 assert not [k for k in (result.get("errors") or {}) if "peace" in k], ( "小店是配置成关的,不该记成错误(那会淹掉真错误)" ) assert result.get("peace_skipped") def test_peace_still_fails_loudly_when_enabled(self): """打开开关后,小店的失败要**如实暴露** —— 别把开关当成遮羞布。""" marked, called = self._run(peace_enabled=True) assert "xpd_records" in called assert "exchange_records_peace" in marked["result"].get("errors", {})