Files
live-hub-py/tests/test_account_overview.py
T
yml2213 f101414f5e feat(accounts): 新增登录异常分型统计、多选/全量导出、筛选记忆及页面组件模块化拆分
- 新增登录失败异常分型统计:支持「必须绑定手机号」、「极验滑块」、「凭证错误」、「邮箱取码失败」、「邮箱配额已满」、「网络异常」分型识别、生命周期联动(待人工)与一键筛选;
- 新增账号名单与明细导出功能:支持勾选多选或按当前筛选条件全选,可导出纯用户名名单 (TXT) 或 UTF-8 BOM 结构化明细报表 (CSV),并支持一键复制到剪贴板;
- 优化筛选条件记忆机制:支持页面切换/返回后自动保留所有筛选与排序状态,新增顶部「重置全部筛选」快捷按钮;
- 模块化拆分 AccountsPage:将 2100+ 行的大文件拆解至 pages/accounts/ 子目录(包含状态卡片、筛选面板、工具栏、操作弹窗与表格构建器),提升可维护性;
- 验证方式:新增 5 个后端自动化单测 (test_account_failure_and_export.py),全量 694 个测试用例通过,前端 TypeScript 编译与 pre-commit 代码规范检查全部通过。
2026-10-07 17:23:39 +08:00

2020 lines
75 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""账号「一键查询」:判定规则、失败隔离、只读性。
判定规则用的是**生产镜像库的真实返回样本**(见 `SAMPLES`),
不是我自己编的 JSON —— 这样官方字段改名/结构变动时用例才会红。
"""
from __future__ import annotations
from datetime import UTC, datetime
from 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