"""账号「一键查询」:判定规则、失败隔离、只读性。 判定规则用的是**生产镜像库的真实返回样本**(见 `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, "status": 0, # 0=未达兑换上限(可兑换) "pic": "https://sta-op.douyucdn.cn/x.png", } #: 账号 558(已兑换)的同一商品项,status=1 为官方声明已达兑换上限 GOODS_OWNED = { **GOODS_AVAILABLE, "count": 0, "foreverCount": 0, "storeStatus": 0, "status": 1, } class TestOwnedJudgement: """「是否已获得 / 已达兑换上限」的判定。 最高裁判:官方商城商品 status == 1 表示「已达兑换上限」(限量 1 件已满)。 即使斗鱼官方兑换记录已被官方清理为空,status == 1 也必须断定已获得。 """ def test_owned_when_status_is_1_even_if_records_empty(self): """核心准入:即使官方兑换记录已被清理为空,只要商城 status==1 就必须判定已拥有。""" goods_blocked = {**GOODS_AVAILABLE, "status": 1} got = extract_high_value_goods([goods_blocked], [RABBIT], []) assert got[0]["owned"] is True assert "已达兑换上限" in got[0]["reason"] def test_not_owned_when_status_is_0_and_records_empty(self): """未兑换账号:status==0 且兑换记录为空,必须判定为未拥有(可兑换)。""" goods_free = {**GOODS_AVAILABLE, "status": 0} got = extract_high_value_goods([goods_free], [RABBIT], []) assert got[0]["owned"] is False 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_accessories_never_imply_suit_owned(self): """散件配件(手榴弹/头盔/背包)绝不能误判为拥有了高价值套装。""" got = extract_high_value_goods( [GOODS_AVAILABLE], [RABBIT], [ {"commodityName": "破片手榴弹-兔兔白日梦"}, {"commodityName": "兔兔白日梦头盔"}, ], ) assert got[0]["owned"] is False assert got[0]["obtained_names"] == [] def test_global_stock_never_implies_owned(self): """回归用例:全局库存为 0 或售罄,但 status==0 时,不能推出「这个账号已拥有」""" sold_out = { **GOODS_AVAILABLE, "count": 0, "foreverCount": 0, "storeStatus": 0, "status": 0, } got = extract_high_value_goods([sold_out], [RABBIT], []) assert got[0]["owned"] is False, "全局售罄但 status=0 时不等于该账号拥有" 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_suit_names(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 list_goods(self, **_): self._maybe("goods") return { "goods": [ GOODS_OWNED, {**GOODS_AVAILABLE, "commodityName": "套装-狂战萌兔", "status": 0}, ] } def query_limited_goods(self, **_): self._maybe("goods") return { "limited_goods": [ GOODS_OWNED, {**GOODS_AVAILABLE, "commodityName": "套装-狂战萌兔", "status": 0}, ] } 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_role_flag_is_the_only_signal(self): """**只看 is_bound_role** —— 这一列问的是「角色绑没绑」。 曾经用 is_bound_act 做兜底("避免漏掉已绑但没回角色名的"), 那是把"活动维度"当成"角色维度"。实测那些判为 bound 的账号 **100% 都有角色名**,证明 is_bound_role 本身就够用。 """ from web.backend.services.account_binding import ( BIND_BOUND, derive_from_bind_info, ) assert derive_from_bind_info({"is_bound_role": True}) == BIND_BOUND # 只有 act 没有 role → **推不出角色结论**,保持 unknown(别拿活动当角色) assert derive_from_bind_info({"is_bound_act": True}) is None assert derive_from_bind_info({"is_bound_act": "1"}) is None def test_unbound_when_role_flag_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_role": "0"}) == BIND_UNBOUND def test_opened_and_bound_are_independent(self): """领域事实:**「是否开通」与「是否绑定角色」互不相干**(用户明确指出)。 生产实测印证:**187 个账号「绑了角色」但「电竞手册未开通」**。 所以推导只看 is_bound_role,绝不去看"开通"相关的任何标志。 """ from web.backend.services.account_binding import ( BIND_BOUND, derive_from_bind_info, ) # 绑了角色、没开通 → bound(两者独立,不该因为"没开通"而变成别的) info = {"is_bound_role": True, "manual_type": 0, "expire_time": "0"} assert derive_from_bind_info(info) == BIND_BOUND 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", {}) class TestHighValueAccountFilters: """高价值账号筛选与自定义范围筛选测试。""" def setup_method(self): self.engine = get_test_engine() self.session = make_test_session(self.engine) self.admin = User(username="hv-admin", password_hash="h", role="super_admin") self.session.add(self.admin) self.session.commit() # 构造不同特征的账号 now = datetime.now(UTC) # 1. 优质皇冠号:双可换 + 积分 500 + 鱼翅 10 + 宝典开通 + CK valid self.acc_crown = Account( username="acc_crown", password="p", email="e1@test.com", email_password="p", overview_queried_at=now, high_value_owned=[], points=500, gold_balance=10, elite_opened=True, ck_check_status="valid", status="normal", ) # 2. 换过白日梦(可换萌兔)+ 积分 100 + 鱼翅 0 + 宝典未开通 self.acc_bairimeng_owned = Account( username="acc_bairimeng_owned", password="p", email="e2@test.com", email_password="p", overview_queried_at=now, high_value_owned=["套装-兔兔白日梦"], points=100, gold_balance=0, elite_opened=False, ck_check_status="valid", status="normal", ) # 3. 换过萌兔(可换白日梦)+ 积分 50 + 鱼翅 20 + 宝典开通 self.acc_mengtu_owned = Account( username="acc_mengtu_owned", password="p", email="e3@test.com", email_password="p", overview_queried_at=now, high_value_owned=["套装-狂战萌兔"], points=50, gold_balance=20, elite_opened=True, ck_check_status="valid", status="normal", ) # 4. 双已换 + 高价值已空 self.acc_both_owned = Account( username="acc_both_owned", password="p", email="e4@test.com", email_password="p", overview_queried_at=now, high_value_owned=["套装-兔兔白日梦", "套装-狂战萌兔"], points=0, gold_balance=0, elite_opened=False, ck_check_status="valid", status="normal", ) # 5. 未查询资料的账号 self.acc_unqueried = Account( username="acc_unqueried", password="p", email="e5@test.com", email_password="p", overview_queried_at=None, high_value_owned=None, points=None, gold_balance=None, elite_opened=None, ck_check_status="valid", status="normal", ) # 6. 高价值但 CK 坏掉(待抢救) self.acc_broken = Account( username="acc_broken", password="p", email="e6@test.com", email_password="p", overview_queried_at=now, high_value_owned=[], points=800, gold_balance=0, elite_opened=True, ck_check_status="invalid", status="normal", ) self.session.add_all( [ self.acc_crown, self.acc_bairimeng_owned, self.acc_mengtu_owned, self.acc_both_owned, self.acc_unqueried, self.acc_broken, ] ) self.session.commit() def _query(self, **kwargs): from web.backend.routers.accounts import list_accounts return list_accounts( assigned_only=False, has_cookie=False, search="acc_", page=1, page_size=20, db=self.session, current=self.admin, **kwargs, )["items"] def test_both_available_filter(self): items = self._query(high_value_filter="both_available") names = [it.username for it in items] assert "acc_crown" in names assert "acc_broken" in names assert "acc_bairimeng_owned" not in names assert "acc_mengtu_owned" not in names assert "acc_both_owned" not in names assert "acc_unqueried" not in names def test_can_bairimeng_filter(self): items = self._query(high_value_filter="can_bairimeng") names = [it.username for it in items] assert "acc_crown" in names assert "acc_mengtu_owned" in names # 拥有萌兔但没拥有白日梦,所以可以换白日梦 assert "acc_bairimeng_owned" not in names assert "acc_both_owned" not in names def test_can_mengtu_filter(self): items = self._query(high_value_filter="can_mengtu") names = [it.username for it in items] assert "acc_crown" in names assert "acc_bairimeng_owned" in names # 拥有白日梦但没拥有萌兔,所以可以换萌兔 assert "acc_mengtu_owned" not in names assert "acc_both_owned" not in names def test_numeric_ranges_and_opened_filter(self): # 积分 >= 300 且 鱼翅 >= 5 items = self._query(min_points=300, min_gold=5) names = [it.username for it in items] assert names == ["acc_crown"] # 浮点数鱼翅过滤(如前端「有鱼翅」快捷筛选传 0.01) items_float = self._query(min_gold=0.01) names_float = [it.username for it in items_float] assert "acc_crown" in names_float # 宝典已开通 items = self._query(elite_opened=True) names = [it.username for it in items] assert set(names) == {"acc_crown", "acc_mengtu_owned", "acc_broken"} def test_high_value_broken_filter(self): # 高价值但 CK 异常待抢救:两套都能换、只剩一套、或积分≥300 items = self._query(high_value_filter="high_value_broken") names = [it.username for it in items] assert names == ["acc_broken"] self.acc_bairimeng_owned.ck_check_status = "invalid" self.acc_both_owned.ck_check_status = "invalid" self.session.commit() names = [ it.username for it in self._query(high_value_filter="high_value_broken") ] assert "acc_bairimeng_owned" in names assert "acc_broken" in names assert "acc_both_owned" not in names def test_list_sorts_by_suit_availability(self): # 升序:两套都能换 → 只剩一套 → 都换过 → 没查过。同档按 id。 names = [it.username for it in self._query(sort="high_value", order="asc")] assert names == [ "acc_crown", "acc_broken", "acc_bairimeng_owned", "acc_mengtu_owned", "acc_both_owned", "acc_unqueried", ] names = [it.username for it in self._query(sort="high_value", order="desc")] assert names == [ "acc_unqueried", "acc_both_owned", "acc_mengtu_owned", "acc_bairimeng_owned", "acc_broken", "acc_crown", ] def test_list_sorts_by_official_exchange_count(self): from web.backend.services.account_events_service import ( CATEGORY_BUSINESS, EVENT_EXCHANGE, OFFICIAL_SYNC_SOURCE, emit, ) now = datetime.now(UTC) emit( self.session, account_id=self.acc_both_owned.id, event_type=EVENT_EXCHANGE, title="兑换 A", category=CATEGORY_BUSINESS, occurred_at=now, source=OFFICIAL_SYNC_SOURCE, ) emit( self.session, account_id=self.acc_both_owned.id, event_type=EVENT_EXCHANGE, title="兑换 B", category=CATEGORY_BUSINESS, occurred_at=now, source=OFFICIAL_SYNC_SOURCE, ) emit( self.session, account_id=self.acc_crown.id, event_type=EVENT_EXCHANGE, title="兑换 C", category=CATEGORY_BUSINESS, occurred_at=now, source=OFFICIAL_SYNC_SOURCE, ) # 当场记的不算进列表「兑换记录」,排序也不能把它算进去 emit( self.session, account_id=self.acc_unqueried.id, event_type=EVENT_EXCHANGE, title="兑换 当场", category=CATEGORY_BUSINESS, occurred_at=now, source="manual", ) self.session.commit() names = [it.username for it in self._query(sort="exchange", order="asc")] assert names == [ "acc_bairimeng_owned", "acc_mengtu_owned", "acc_unqueried", "acc_broken", "acc_crown", "acc_both_owned", ] names = [it.username for it in self._query(sort="exchange", order="desc")] assert names == [ "acc_both_owned", "acc_crown", "acc_bairimeng_owned", "acc_mengtu_owned", "acc_unqueried", "acc_broken", ] def test_list_sorts_points_status_bind_and_tag(self): self.acc_crown.status = "active" self.acc_crown.role_bind_status = "bound" self.acc_crown.tag = "b" self.acc_broken.status = "invalid" self.acc_broken.role_bind_status = "unbound" self.acc_broken.tag = "a" self.acc_unqueried.status = "imported" # 积分相同,鱼翅多的在积分降序里更靠前 self.acc_bairimeng_owned.points = 50 self.acc_bairimeng_owned.gold_balance = 1 self.session.commit() assert [it.username for it in self._query(sort="points", order="desc")] == [ "acc_broken", "acc_crown", "acc_mengtu_owned", "acc_bairimeng_owned", "acc_both_owned", "acc_unqueried", ] assert [it.username for it in self._query(sort="points", order="asc")] == [ "acc_both_owned", "acc_bairimeng_owned", "acc_mengtu_owned", "acc_crown", "acc_broken", "acc_unqueried", ] assert [it.username for it in self._query(sort="status", order="asc")][:3] == [ "acc_crown", "acc_broken", "acc_unqueried", ] assert [it.username for it in self._query(sort="bind", order="asc")][:2] == [ "acc_crown", "acc_broken", ] assert [it.username for it in self._query(sort="username", order="asc")] == [ "acc_bairimeng_owned", "acc_both_owned", "acc_broken", "acc_crown", "acc_mengtu_owned", "acc_unqueried", ] tagged = [it.username for it in self._query(sort="tag", order="asc")] assert tagged[-2:] == ["acc_broken", "acc_crown"] def test_summary_returns_high_value_breakdown(self): from web.backend.routers.accounts import accounts_summary res = accounts_summary(db=self.session, current=self.admin) hv = res.get("high_value") assert hv is not None assert hv["both_available"] == 2 # acc_crown + acc_broken assert hv["unqueried"] == 1 # acc_unqueried assert hv["high_points"] == 2 # 500, 800 assert hv["high_value_broken"] == 1 # acc_broken def test_helmet_and_backpack_never_match_over_suit(self): """当商品列表中头盔排在前面时,必须精准匹配到套装-兔兔白日梦,严禁命中头盔。""" items = [ {"commodityName": "兔兔白日梦头盔", "commodityId": "helmet_1", "status": 0}, {"commodityName": "兔兔白日梦背包", "commodityId": "bag_1", "status": 0}, {"commodityName": "套装-兔兔白日梦", "commodityId": "suit_1", "status": 1}, ] got = extract_high_value_goods(items, ["套装-兔兔白日梦"], []) assert len(got) == 1 assert got[0]["name"] == "套装-兔兔白日梦" assert got[0]["commodity_id"] == "suit_1" assert got[0]["owned"] is True assert got[0]["reason"] == "官方商城显示已达兑换上限" def test_null_high_value_owned_not_treated_as_both_available(self): """high_value_owned 为 None 的账号(如被重置过),绝不能误判为双套均可兑换。""" from datetime import UTC, datetime from web.backend.models import Account from web.backend.routers.accounts import accounts_summary acc = Account( username="acc_reset_null", password="p", email="e@test.com", email_password="ep", high_value_owned=None, overview_queried_at=datetime.now(UTC), # 虽然时间不为空,但字段是 None ) self.session.add(acc) self.session.commit() res = accounts_summary(db=self.session, current=self.admin) hv = res.get("high_value", {}) # 绝不计入 both_available,应计入 unqueried assert hv["both_available"] == 2 assert hv["unqueried"] == 2