Files
live-hub-py/tests/test_account_overview.py
T
yml2213 631e33a4f6 fix(events): 撞唯一键是幂等命中,不该刷 WARNING + dump 整条 SQL
## 现象(用户从日志里发现的)

    WARNING | 写入账号事件失败(account=60 exchange):
    (1062, "Duplicate entry 'official_exchange:elite:60:20260120QYOOB_bag360:...'")
    [SQL: INSERT INTO account_events (...) VALUES (...)]
    [parameters: {...}]

## 分析:**不是 bug,是幂等机制在工作,但日志级别错了**

`emit()` 刻意**不做预检查**,直接 INSERT,靠 `source_ref` 的唯一索引去重;
撞了就 catch(SAVEPOINT 只回滚这一条)。所以:

- **数据是对的** —— 重复的被挡住,`exchange_events_added` 只计新写入的
- **日志是错的** —— 撞唯一键被当成异常打 WARNING,还把整条 SQL + 参数 dump 出来

而"撞唯一键"在这里是**预期内的正常情况**:一键查询会把斗鱼官方兑换记录补成事件,
同一个账号会被反复查询 —— 第二次开始每条记录都会撞。一次查询能刷几十屏,
既淹掉真日志、又让人以为出事了(用户就是因此来问的)。

## 修法

区分「唯一键冲突」与「真错误」:

- 确认是 **1062**(并带 `Duplicate entry` 字样)→ 降为 **DEBUG**,一句话说明"已存在,跳过"
- 其它完整性错误(如非空约束 1048)→ **仍然是 WARNING**

刻意**不把 IntegrityError 整个静音** —— 那会把真问题一起藏起来。
新增 `_is_duplicate_entry()` 只在两种信号都对上时才降级。

## 测试(+3)

- 重复写入返回 False,且**不产生**任何 "写入账号事件失败" 的 WARNING
  (用 loguru 的临时 sink 真的抓日志来断言,不是看代码)
- 重复写入不会写进第二条
- `_is_duplicate_entry` 只认 1062,1048(非空约束)仍然返回 False
2026-10-04 20:35:24 +08:00

1464 lines
55 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,
"pic": "https://sta-op.douyucdn.cn/x.png",
}
#: 账号 558(已兑换)的同一商品项,只有这三项不同
GOODS_OWNED = {**GOODS_AVAILABLE, "count": 0, "foreverCount": 0, "storeStatus": 0}
class TestOwnedJudgement:
"""「是否已获得」的判定 —— 本功能的核心,也是最容易判错的地方。
⚠️ **判定只看官方兑换记录**。第一版拿限兑商品列表的 `count`/`storeStatus` 判,
依据是「558 兑换过 count=0 / 15407 没兑换 count=96982」——
那两条记录相隔两个月,我把**时间差异**当成了**账号差异**。
同一时刻对比才发现:三个账号(两个兑换过、一个没有)的商品项**字段完全一致**,
`count`/`foreverCount`/`storeStatus` 是**全局库存**,不暴露账号维度信息。
改用官方兑换记录后完美区分(558/607 有「破片手榴弹-兔兔白日梦」,15507 是 0 条)。
"""
def test_owned_comes_from_official_records(self):
got = extract_high_value_goods(
[GOODS_AVAILABLE], [RABBIT], [{"commodityName": "破片手榴弹-兔兔白日梦"}]
)
assert got[0]["owned"] is True
assert got[0]["obtained_names"] == ["破片手榴弹-兔兔白日梦"]
def test_not_owned_when_records_empty(self):
got = extract_high_value_goods([GOODS_AVAILABLE], [RABBIT], [])
assert got[0]["owned"] is False
def test_global_stock_never_implies_owned(self):
"""**回归用例**:全局库存为 0 或售罄,都不能推出「这个账号已拥有」。
这正是第一版踩的坑 —— 用全局字段判账号维度的事。
"""
sold_out = {**GOODS_AVAILABLE, "count": 0, "foreverCount": 0, "storeStatus": 0}
got = extract_high_value_goods([sold_out], [RABBIT], [])
assert got[0]["owned"] is False, "全局售罄不等于该账号拥有"
def test_stock_and_owned_are_independent(self):
"""展示用的全局信息与账号判定互不影响。"""
sold_out = {**GOODS_AVAILABLE, "count": 0, "storeStatus": 0}
got = extract_high_value_goods(
[sold_out], [RABBIT], [{"commodityName": "套装-兔兔白日梦"}]
)
assert got[0]["owned"] is True
assert got[0]["count"] == 0 # 全局库存如实展示
def test_records_match_by_substring_family(self):
"""同一件东西有多个商品名(套装- / 破片手榴弹-),都要算数。"""
got = extract_high_value_goods(
[GOODS_AVAILABLE],
[RABBIT],
[{"commodityName": "破片手榴弹-兔兔白日梦"}, {"commodityName": "别的"}],
)
assert got[0]["owned"] is True
assert len(got[0]["obtained_names"]) == 1
def test_missing_goods_is_not_silently_dropped(self):
"""既不在商品列表、也不在兑换记录里的,必须留 missing。"""
got = extract_high_value_goods([GOODS_AVAILABLE], [RABBIT, "不存在的套装"], [])
assert len(got) == 2
assert got[1]["missing"] is True
assert "都没有这个商品" in got[1]["reason"]
def test_goods_in_records_but_not_list_is_not_missing(self):
"""兑换记录里有、但当前限兑列表里没有 —— 不是 missing,是已获得。
实测就有这种情况(活动窗口结束但记录仍在),不能误报成「没这个商品」。
"""
got = extract_high_value_goods(
[], [RABBIT], [{"commodityName": "套装-兔兔白日梦"}]
)
assert got[0]["owned"] is True
assert got[0]["missing"] is False
assert got[0]["in_goods_list"] is False
def test_empty_list_and_no_records_keeps_all_names(self):
got = extract_high_value_goods([], DEFAULT_HIGH_VALUE_GOODS, [])
assert [g["name"] for g in got] == list(DEFAULT_HIGH_VALUE_GOODS)
assert all(g["missing"] for g in got)
def test_both_suits(self):
items = [
{**GOODS_AVAILABLE, "commodityName": "套装-兔兔白日梦"},
{**GOODS_AVAILABLE, "commodityName": "套装-狂战萌兔", "commodityId": "X"},
]
got = extract_high_value_goods(
items, list(DEFAULT_HIGH_VALUE_GOODS), [{"commodityName": "套装-狂战萌兔"}]
)
assert [g["owned"] for g in got] == [False, True]
def test_tolerates_garbage(self):
got = extract_high_value_goods(
[None, "x", 1, GOODS_AVAILABLE],
[RABBIT],
[None, "y", {"commodityName": ""}],
)
assert got[0]["owned"] is False
def test_custom_goods_config_overrides_default(self):
assert _goods_config({"high_value_goods": ["A", "B"]}) == ["A", "B"]
assert _goods_config({"high_value_goods": "A,B"}) == ["A", "B"]
assert _goods_config({"high_value_goods": '["A", "B"]'}) == ["A", "B"]
assert _goods_config({"high_value_goods": "A,B"}) == ["A", "B"]
assert _goods_config({}) == list(DEFAULT_HIGH_VALUE_GOODS)
assert _goods_config({"high_value_goods": []}) == list(DEFAULT_HIGH_VALUE_GOODS)
class _FakeClient:
"""最小假客户端:只实现聚合用到的几个方法。"""
def __init__(self, *, fail: set[str] | None = None):
self.fail = fail or set()
def _maybe(self, key: str):
if key in self.fail:
raise RuntimeError(f"模拟 {key} 失败")
def acf_ccn(self, **_):
return "ctn"
def query_limited_goods(self, **_):
self._maybe("goods")
return {
"limited_goods": [
GOODS_OWNED,
{**GOODS_AVAILABLE, "commodityName": "套装-狂战萌兔"},
]
}
def exchange_records(self, **_):
self._maybe("records")
return {
"records": [{"commodityName": "破片手榴弹-兔兔白日梦", "createTime": "1"}]
}
class _Harness(OverviewMixin):
"""把聚合 handler 单独跑起来:假客户端 + 假的 refresh helper。"""
def __init__(self, client: _FakeClient, *, fail_helpers: set[str] | None = None):
self.client = client
self.fail_helpers = fail_helpers or set()
self.marked: dict = {}
def _client(self, cookie: str):
return self.client
def _mark_task(self, db, task, status, message, result=None):
self.marked = {
"status": status,
"message": message,
"result": result,
}
def _push_log(self, *a, **k):
pass
def _refresh_account_gold_balance(self, client, account):
if "gold" in self.fail_helpers:
raise RuntimeError("模拟鱼翅查询失败")
return {"gold_balance": 38, "exchange_balance": 120}
def _refresh_account_points(self, client, account, cookie, *, ctn=None):
if "points" in self.fail_helpers:
raise RuntimeError("模拟积分查询失败")
return {
"points": 990,
"points_query": {"raw": {"data": {"willExpireTime": "1795967999"}}},
}
def _refresh_elite_handbook(self, client, account, *, manual_id, rid=""):
if "elite" in self.fail_helpers:
raise RuntimeError("模拟精英查询失败")
return {
"elite_opened": True,
"elite_expire_time": 1795967999,
"elite_manual_score": 990,
}
def _refresh_esports_handbook(self, client, account, *, manual_id):
if "esports" in self.fail_helpers:
raise RuntimeError("模拟电竞查询失败(账号没绑电竞手册)")
return {"esports_manual_type": 1, "esports_manual_score": 10}
# handler 按作用域抓官方记录(三套接口),测试里给最小实现
def _fetch_bind_info(self, client, scope, alias):
# 测试里统一给"已绑定"(有角色名),避免依赖具体假客户端
return {"is_bound_role": True, "is_bound_act": True, "role_name": "某角色"}
def _fetch_elite_records(self, client, config, manual_id, esports_manual_id):
return client.exchange_records(manual_id=manual_id).get("records") or []
def _fetch_esports_records(self, client, config, manual_id, esports_manual_id):
return []
def _fetch_xpd_records(self, client, config, manual_id, esports_manual_id):
return []
class TestOverviewAggregation:
def setup_method(self):
self.engine = get_test_engine()
self.session = make_test_session(self.engine)
self.user = User(username="ov-admin", password_hash="h", role="super_admin")
self.session.add(self.user)
self.session.flush()
self.account = Account(
username="ov-acc", password="p", email="o@e.com", email_password="ep"
)
self.session.add(self.account)
self.session.commit()
self.task = DouyuTask(
batch_id="ov-batch",
account_id=self.account.id,
task_type="query_account_overview",
handbook_scope="elite",
status="running",
message="",
created_by=self.user.id,
)
self.session.add(self.task)
self.session.commit()
def teardown_method(self):
self.session.close()
def _run(self, client=None, **kw):
h = _Harness(client or _FakeClient(), **kw)
h._execute_query_account_overview(
self.session,
self.task,
self.account,
"cookie",
{
"manual_id": "M1",
"esports_manual_id": "M2",
"rid": "R1",
"xpd_act_alias": "A",
"xpd_rid": "R",
"xpd_act_id": "1",
"bind_act_alias": "B",
"esports_act_alias": "E",
},
)
return h
def test_aggregates_assets_and_open_states(self):
h = self._run()
r = h.marked["result"]
assert h.marked["status"] == "success"
assert r["assets"]["gold_balance"] == 38
assert r["assets"]["exchange_balance"] == 120
assert r["assets"]["points"] == 990
assert r["assets"]["points_expire_at"] == "1795967999"
assert r["elite"]["opened"] is True
assert r["esports"]["opened"] is True
assert r["errors"] == {}
def test_high_value_goods_are_flagged(self):
r = self._run().marked["result"]
goods = {g["name"]: g for g in r["high_value_goods"]}
assert goods["套装-兔兔白日梦"]["owned"] is True # 兑换记录里有
assert goods["套装-狂战萌兔"]["owned"] is False # 记录里没有
assert "兔兔白日梦" in r["high_value_goods"][0]["name"]
def test_single_item_failure_does_not_fail_the_batch(self):
"""账号没绑电竞手册是常态 —— 不该让整次查询失败。"""
r = self._run(fail_helpers={"esports"}).marked["result"]
assert r["assets"]["points"] == 990 # 其余照常
assert "esports" in r["errors"]
assert r["high_value_goods"] # 关键信息仍在
def test_all_failures_still_return_success_with_errors(self):
r = self._run(fail_helpers={"gold", "points", "elite", "esports"}).marked[
"result"
]
assert set(r["errors"]) >= {"gold", "points", "elite", "esports"}
assert self._run(fail_helpers={"gold"}).marked["status"] == "success"
def test_missing_manual_id_is_reported_not_crashed(self):
h = _Harness(_FakeClient())
h._execute_query_account_overview(
self.session, self.task, self.account, "cookie", {}
)
r = h.marked["result"]
assert "未配置精英宝典 manualID" in r["errors"]["elite"]
assert "未配置精英宝典 manualID" in r["errors"]["high_value_goods"]
assert h.marked["status"] == "success"
assert r["high_value_goods"] == []
def test_message_counts_owned_suits(self):
assert "兔兔白日梦" in self._run().marked["message"]
assert "已拥有 1 个" in self._run().marked["message"]
def test_is_read_only_no_events_and_no_status_change(self):
"""纯查询不是「事实」—— 不能进时间线,也不能改生命周期状态。"""
before = self.session.query(AccountEvent).count()
status_before = self.account.status
self._run()
self.session.commit()
assert self.session.query(AccountEvent).count() == before, "一键查询不该发事件"
self.session.refresh(self.account)
assert self.account.status == status_before
class TestOverviewTaskTypeWiring:
def test_declared_for_all_workbenches(self):
from web.backend.services.douyu_service import (
DOUYU_HANDBOOK_TASK_TYPES,
SUPPORTED_DOUYU_TASK_TYPES,
)
assert "query_account_overview" in SUPPORTED_DOUYU_TASK_TYPES
for scope in ("elite", "esports", "peace"):
assert "query_account_overview" in DOUYU_HANDBOOK_TASK_TYPES[scope]
def test_handler_is_dispatched(self):
from web.backend.services.douyu_runner import DouyuBatchRunner
handler = getattr(DouyuBatchRunner, "_execute_query_account_overview", None)
assert callable(handler)
def test_batch_creates_one_task_per_account(self):
"""批量是天然支持的:`create_douyu_planned_tasks` 就是每账号一条任务。"""
from tests.support.db import get_test_engine as eng
from web.backend.services.douyu_service import create_douyu_planned_tasks
session = make_test_session(eng())
try:
# 自己造夹具,别依赖别的用例留下的数据(依赖就会 skip,skip 等于没测)
user = User(
username="ov-batch-admin", password_hash="h", role="super_admin"
)
session.add(user)
session.flush()
accs = [
Account(
username=f"ov-batch-{i}",
password="p",
email=f"b{i}@e.com",
email_password="ep",
# 必须带 CK:一键查询要用该账号的 CK 去查斗鱼,
# `visible_douyu_task_accounts` 会把没 CK 的账号过滤掉。
cookie=f"acf_uid=1{i}; acf_username=u{i}; acf_ccn=c{i}",
)
for i in range(3)
]
session.add_all(accs)
session.commit()
batch_id, count = create_douyu_planned_tasks(
session,
[a.id for a in accs],
"query_account_overview",
"elite",
user.id,
)
assert count == len(accs)
created = (
session.query(DouyuTask)
.filter(
DouyuTask.batch_id == batch_id,
DouyuTask.task_type == "query_account_overview",
)
.all()
)
assert len(created) == len(accs)
finally:
session.close()
def test_queried_at_is_utc_isoformat():
got = extract_high_value_goods([GOODS_OWNED], [RABBIT])
assert got and isinstance(got, list)
# 顺手确认 UTC 约定没被破坏
assert datetime.now(UTC).tzinfo is not None
class TestTimestampNormalisation:
"""斗鱼用 `0` / `"0"` / 空串表示「没有这个时间」。
直接透传的后果:前端把 `0` 当 Unix 时间戳 → 显示 **1970/1/1 08:00:00**,
运营会以为"到期时间是 1970 年"。(这个 bug 真出现过,用户报的。)
"""
@pytest.mark.parametrize("bad", [0, "0", "", " ", None, -1, "-1", False])
def test_no_value_becomes_none(self, bad):
from web.backend.services.douyu_runner_overview import _ts
assert _ts(bad) is None, f"{bad!r} 应归成 None,否则会显示成 1970"
@pytest.mark.parametrize("good", [1795967999, "1795967999", 1795967999000])
def test_real_timestamps_pass_through(self, good):
from web.backend.services.douyu_runner_overview import _ts
assert _ts(good) == good
class TestSnapshotIsWrittenToAccount:
"""结果要落到**账号**上,不是只留在任务里。
任务会被保留策略清掉,而「这个账号现在什么情况」是账号的信息 ——
应该在账号管理里看,不该依赖一条日志的寿命。
"""
def setup_method(self):
self.engine = get_test_engine()
self.session = make_test_session(self.engine)
self.user = User(username="snap-admin", password_hash="h", role="super_admin")
self.session.add(self.user)
self.session.flush()
self.account = Account(
username="snap-acc", password="p", email="s@e.com", email_password="ep"
)
self.session.add(self.account)
self.session.commit()
def teardown_method(self):
self.session.close()
def _run(self, client=None):
from web.backend.services.douyu_runner_overview import OverviewMixin
class _H(OverviewMixin):
def _client(self, cookie):
return client or _FakeClient()
def _mark_task(self, *a, **k):
pass
def _push_log(self, *a, **k):
pass
def _refresh_account_gold_balance(self, c, a):
return {"gold_balance": 7, "exchange_balance": 1}
def _refresh_account_points(self, c, a, cookie, *, ctn=None):
# 斗鱼返回 0 表示没有到期时间 —— 这里模拟它
return {
"points": 5,
"points_query": {"raw": {"data": {"willExpireTime": "0"}}},
}
def _refresh_elite_handbook(self, c, a, *, manual_id, rid=""):
return {
"elite_opened": False,
"elite_expire_time": 0,
"elite_manual_score": 0,
}
def _refresh_esports_handbook(self, c, a, *, manual_id):
return {"esports_manual_type": 0, "esports_manual_score": 0}
def _fetch_bind_info(self, c, scope, alias):
return {
"is_bound_role": True,
"is_bound_act": True,
"role_name": "某角色",
}
def _fetch_elite_records(self, c, cfg, mid, emid):
return c.exchange_records(manual_id=mid).get("records") or []
def _fetch_esports_records(self, c, cfg, mid, emid):
return []
def _fetch_xpd_records(self, c, cfg, mid, emid):
return []
task = DouyuTask(
batch_id="snap",
account_id=self.account.id,
task_type="query_account_overview",
handbook_scope="elite",
status="running",
message="",
created_by=self.user.id,
)
self.session.add(task)
self.session.commit()
_H()._execute_query_account_overview(
self.session,
task,
self.account,
"c",
{
"manual_id": "M",
"esports_manual_id": "E",
"rid": "R",
"xpd_act_alias": "A",
"xpd_rid": "R",
"xpd_act_id": "1",
"bind_act_alias": "B",
"esports_act_alias": "E",
},
)
self.session.commit()
self.session.refresh(self.account)
return task
def test_snapshot_and_time_are_stored_on_account(self):
self._run()
assert self.account.overview_snapshot is not None
assert self.account.overview_queried_at is not None
assert self.account.overview_snapshot["assets"]["gold_balance"] == 7
def test_no_expiry_is_stored_as_none_not_zero(self):
"""回归:`willExpireTime = "0"` 不能原样落库成 0(会显示 1970)。"""
self._run()
snap = self.account.overview_snapshot
assert snap["assets"]["points_expire_at"] is None
assert snap["elite"]["expire_time"] is None
def test_snapshot_is_overwritten_not_appended(self):
self._run()
first = dict(self.account.overview_snapshot)
self._run()
self.session.refresh(self.account)
assert self.account.overview_snapshot["assets"]["gold_balance"] == 7
assert self.account.overview_snapshot is not None
assert first["assets"]["gold_balance"] == 7
class TestSnapshotExposedOnDetailOnly:
"""快照必须出现在**详情**接口,**不能**出现在列表接口。
这里踩过一个坑:字段被加到了列表接口而不是详情接口,后果有两层 ——
1. 详情不返回 `overview_queried_at` → 前端轮询永远检测不到变化 →
一直刷请求到 120 秒超时(用户报的「点一键查询就一直循环」);
2. 列表**每个账号**都带一整份 JSON 快照 → 15000 个账号的响应体积爆炸。
所以两头都要钉住。
"""
def setup_method(self):
self.engine = get_test_engine()
self.session = make_test_session(self.engine)
self.admin = User(username="snapx-admin", password_hash="h", role="super_admin")
self.session.add(self.admin)
self.session.flush()
self.account = Account(
username="snapx-acc",
password="p",
email="x@e.com",
email_password="ep",
cookie="acf_uid=1; acf_ccn=c",
overview_snapshot={"assets": {"gold_balance": 3}},
overview_queried_at=datetime.now(UTC),
)
self.session.add(self.account)
self.session.commit()
def teardown_method(self):
self.session.close()
def test_detail_returns_snapshot_and_timestamp(self):
from web.backend.routers.accounts import account_overview
result = account_overview(self.account.id, db=self.session, current=self.admin)
assert "overview_snapshot" in result, "详情必须返回快照,否则前端没法展示"
assert "overview_queried_at" in result, (
"详情必须返回查询时间 —— 前端靠它判断「查完了没有」,"
"不返回就会一直轮询到超时"
)
assert result["overview_queried_at"] is not None
def test_list_does_not_carry_the_snapshot(self):
"""列表里带整份快照会让 15000 个账号的响应爆炸。"""
from web.backend.routers.accounts import list_accounts
# 直接调用时**未显式传**的参数拿到的是 FastAPI 的 `Query` 对象,而它是
# truthy 的(`assigned_only=Query(False)` 会被当成 True,只返回分配给
# 自己的账号)—— 所以关键参数都要显式传。
# 另外测试库里有几百个账号,必须用 search 收敛到夹具本身,否则会翻页。
result = list_accounts(
assigned_only=False,
has_cookie=False,
search="snapx-acc",
page=1,
page_size=20,
db=self.session,
current=self.admin,
)
items = result["items"] if isinstance(result, dict) else result
assert items, "夹具账号应出现在列表里"
for item in items:
assert "overview_snapshot" not in item, (
"列表接口不该返回完整快照(每个账号一份 JSON,体积爆炸)"
)
class TestOverviewRefreshesAccountFields:
"""查询的职责是**刷新账号字段**,不是另开一份副本。
用户反馈很直接:「一键查询这也太重复了,直接更新账号信息不行吗?
那建立账号信息表有啥用」。
对 —— 鱼翅 / 积分 / 交易余额 / 开通状态账号表本来就有对应列,
查询把新值写进去、由已有界面展示即可。这里钉住写回行为,
尤其是**积分到期时间**:它有 `accounts.points_expire_at` 列,
但之前忘了写(也没在界面上显示)。
"""
def setup_method(self):
self.engine = get_test_engine()
self.session = make_test_session(self.engine)
self.user = User(username="rf-admin", password_hash="h", role="super_admin")
self.session.add(self.user)
self.session.flush()
self.account = Account(
username="rf-acc", password="p", email="r@e.com", email_password="ep"
)
self.session.add(self.account)
self.session.commit()
def teardown_method(self):
self.session.close()
def _run(self, *, expire_at: str = "1795967999"):
from web.backend.services.douyu_runner_overview import OverviewMixin
class _H(OverviewMixin):
def _client(self, cookie):
return _FakeClient()
def _mark_task(self, *a, **k):
pass
def _push_log(self, *a, **k):
pass
def _refresh_account_gold_balance(self, c, a):
a.gold_balance = 88
a.exchange_balance = 9
return {"gold_balance": 88, "exchange_balance": 9}
def _refresh_account_points(self, c, a, cookie, *, ctn=None):
a.points = 777
return {
"points": 777,
"points_query": {"raw": {"data": {"willExpireTime": expire_at}}},
}
def _refresh_elite_handbook(self, c, a, *, manual_id, rid=""):
a.elite_opened = True
return {
"elite_opened": True,
"elite_expire_time": 0,
"elite_manual_score": 5,
}
def _refresh_esports_handbook(self, c, a, *, manual_id):
a.esports_opened = True
return {"esports_manual_type": 1, "esports_manual_score": 3}
def _fetch_bind_info(self, c, scope, alias):
return {
"is_bound_role": True,
"is_bound_act": True,
"role_name": "某角色",
}
def _fetch_elite_records(self, c, cfg, mid, emid):
return c.exchange_records(manual_id=mid).get("records") or []
def _fetch_esports_records(self, c, cfg, mid, emid):
return []
def _fetch_xpd_records(self, c, cfg, mid, emid):
return []
task = DouyuTask(
batch_id="rf",
account_id=self.account.id,
task_type="query_account_overview",
handbook_scope="elite",
status="running",
message="",
created_by=self.user.id,
)
self.session.add(task)
self.session.commit()
_H()._execute_query_account_overview(
self.session,
task,
self.account,
"c",
{
"manual_id": "M",
"esports_manual_id": "E",
"rid": "R",
"xpd_act_alias": "A",
"xpd_rid": "R",
"xpd_act_id": "1",
"bind_act_alias": "B",
"esports_act_alias": "E",
},
)
self.session.commit()
self.session.refresh(self.account)
def test_assets_are_written_to_account_columns(self):
self._run()
assert self.account.points == 777
assert self.account.gold_balance == 88
assert self.account.exchange_balance == 9
assert self.account.elite_opened is True
assert self.account.esports_opened is True
def test_points_expire_at_is_written(self):
"""积分到期时间有专门的列,查询必须写它(之前漏了)。"""
self._run(expire_at="1795967999")
assert self.account.points_expire_at is not None
assert self.account.points_expire_at.year != 1970
def test_no_expiry_clears_the_column_not_zero(self):
"""斗鱼用 0 表示没有到期时间 —— 不能存成 0(会显示 1970/1/1)。"""
self._run(expire_at="0")
assert self.account.points_expire_at is None
assert self.account.overview_snapshot["assets"]["points_expire_at"] is None
class TestSummaryFeedsAccountList:
"""一键查询的结果要能反映到**账号列表**上。
用户报的原始问题:账号 6966677706 明明有官方兑换记录,列表却显示「未兑换」。
根因是**两个口径**:
- 列表的「兑换记录」列来自我们自己的 `account_events`,而事件是**上线后**
才开始记的 → 上线前的兑换在列表里就是空的,看着像"从没兑换过";
- 一键查询拉的是**斗鱼官方**记录,本来能查到,但那份 JSON 有 1~2KB,
不能塞进列表(15000 个账号会把响应放大到几十 MB)。
所以加了这个**极小**汇总字段 `high_value_owned`:`{"owned": [...], "exchanges": N}`。
"""
def setup_method(self):
self.engine = get_test_engine()
self.session = make_test_session(self.engine)
self.admin = User(username="sum-admin", password_hash="h", role="super_admin")
self.session.add(self.admin)
self.session.flush()
self.account = Account(
username="sum-acc",
password="p",
email="s@e.com",
email_password="ep",
cookie="acf_uid=1; acf_ccn=c",
high_value_owned=["套装-兔兔白日梦"],
overview_queried_at=datetime.now(UTC),
overview_snapshot={"high_value_goods": []},
)
self.session.add(self.account)
self.session.commit()
def teardown_method(self):
self.session.close()
def _list(self):
from web.backend.routers.accounts import list_accounts
return list_accounts(
assigned_only=False,
has_cookie=False,
search="sum-acc",
page=1,
page_size=5,
db=self.session,
current=self.admin,
)["items"]
def test_list_serializes_owned_suits(self):
"""断言**序列化结果**,不是属性。
⚠️ `AccountOut` 有一个**手写**的 `@model_serializer`,它逐个列出要输出的字段。
只在模型上声明字段、不写进那个列表 → **属性有值、JSON 里没有**。
只断言属性的测试会**假绿**(踩过),所以这里一律看 `model_dump()`。
"""
dumped = self._list()[0].model_dump()
assert dumped["high_value_owned"] == ["套装-兔兔白日梦"], (
"字段没出现在序列化结果里 —— 前端拿到的 JSON 就没有它"
)
def test_list_serializes_queried_marker(self):
"""要能区分「查过但没兑换」和「还没查过」—— 否则列表会把未知当高价值。"""
dumped = self._list()[0].model_dump()
assert dumped["overview_queried_at"] is not None
def test_list_still_does_not_carry_the_full_snapshot(self):
"""给列表加信息**不能**把 1~2KB 的整份快照带回去。"""
dumped = self._list()[0].model_dump()
assert "overview_snapshot" not in dumped, (
"列表带整份快照会让 15000 个账号的响应体积爆炸"
)
def test_handler_writes_the_summary(self):
"""查询跑完必须写汇总,否则列表永远显示「未查询」。"""
from web.backend.services.douyu_runner_overview import OverviewMixin
class _H(OverviewMixin):
def _client(self, cookie):
return _FakeClient()
def _mark_task(self, *a, **k):
pass
def _push_log(self, *a, **k):
pass
def _refresh_account_gold_balance(self, c, a):
return {"gold_balance": 1, "exchange_balance": 1}
def _refresh_account_points(self, c, a, cookie, *, ctn=None):
return {
"points": 1,
"points_query": {"raw": {"data": {"willExpireTime": "0"}}},
}
def _refresh_elite_handbook(self, c, a, *, manual_id, rid=""):
return {
"elite_opened": True,
"elite_expire_time": 0,
"elite_manual_score": 0,
}
def _refresh_esports_handbook(self, c, a, *, manual_id):
return {"esports_manual_type": 1, "esports_manual_score": 0}
def _fetch_elite_records(self, c, cfg, mid, emid):
# 精英恰好 1 条(与断言一致)
return [
{
"commodityName": "套装-兔兔白日梦",
"commodityId": "C1",
"score": "1280",
}
]
def _fetch_esports_records(self, c, cfg, mid, emid):
return []
def _fetch_xpd_records(self, c, cfg, mid, emid):
return []
task = DouyuTask(
batch_id="sum",
account_id=self.account.id,
task_type="query_account_overview",
handbook_scope="elite",
status="running",
message="",
created_by=self.admin.id,
)
self.session.add(task)
self.session.commit()
_H()._execute_query_account_overview(
self.session,
task,
self.account,
"c",
{
"manual_id": "M",
"esports_manual_id": "E",
"rid": "R",
"xpd_act_alias": "A",
"xpd_rid": "R",
"xpd_act_id": "1",
"bind_act_alias": "B",
"esports_act_alias": "E",
},
)
self.session.commit()
self.session.refresh(self.account)
owned = self.account.high_value_owned
assert owned is not None, "查询跑完必须写「已兑换过的高价值套装」"
# _FakeClient 的兑换记录里有一条「破片手榴弹-兔兔白日梦」
assert "兔兔白日梦" in "".join(owned)
class TestOverviewSummaryMigrationIsSafeToRerun:
"""迁移必须**可安全重跑** —— 应用启动时会自动跑它。
踩过的坑:回填时用了 `op.get_bind().commit()`,
打乱了 alembic 自己的版本记账。后果很隐蔽:
- MySQL 的 DDL(ALTER TABLE)是**自动提交**的 → 列加上了
- 但版本号**没更新** → 下次启动又去 ADD COLUMN
- → `Duplicate column name 'high_value_owned'`,**服务直接起不来**
仓库里正常工作的老迁移(0042 的 S1 回填)用的是
`op.get_bind().execute(...)` 且**不 commit**,提交由 alembic 负责。
"""
MIGRATION = (
Path(__file__).resolve().parents[1]
/ "web/backend/migrations/versions/20261004_0048_high_value_owned.py"
)
def _src(self) -> str:
return self.MIGRATION.read_text(encoding="utf-8")
def _code(self) -> str:
"""去掉注释后的源码。
必须去注释:本迁移的注释里**正解释着**「不要 conn.commit()」,
直接子串检查会命中注释里的那句话,用例就假红了。
"""
lines = []
for line in self._src().splitlines():
stripped = line.lstrip()
if stripped.startswith("#"):
continue
# 去掉行尾注释(这些迁移里没有字符串里带 # 的情况)
if "#" in line:
line = line[: line.index("#")]
lines.append(line)
return "\n".join(lines)
def test_does_not_commit_the_alembic_bind(self):
"""在迁移里 commit 会破坏版本记账(本迁移踩过)。"""
code = self._code()
assert "conn.commit()" not in code, (
"迁移里不要 commit op.get_bind() —— 会让版本号不更新,"
"下次启动重复 ADD COLUMN 而把服务起不来"
)
def test_guards_add_column_with_existence_check(self):
"""加列前必须检查列是否已存在,否则不可重入。"""
src = self._src()
assert "_column_exists" in src, "缺少列存在性检查"
upgrade_body = src[src.index("def upgrade()") : src.index("def downgrade()")]
assert "_column_exists" in upgrade_body, "upgrade 里没有用存在性检查守卫"
def test_column_exists_helper_probes_information_schema(self):
src = self._src()
assert "information_schema.columns" in src, (
"存在性检查要查 information_schema,别用 try/except 猜"
)
class TestOfficialExchangeBecomesAccountEvent:
"""斗鱼官方兑换记录要**补成账号事件**。
这是「账号全生命周期 + 兑换记录」的关键一步:官方记录**带真实兑换时间**
(`time` 字段),补成事件后时间线才是完整的,列表的兑换记录聚合也自动准确 ——
不再需要单独维护一套"官方条数"的旁路。
"""
def setup_method(self):
self.engine = get_test_engine()
self.session = make_test_session(self.engine)
self.user = User(username="oe-admin", password_hash="h", role="super_admin")
self.session.add(self.user)
self.session.flush()
self.account = Account(
username="oe-acc", password="p", email="o@e.com", email_password="ep"
)
self.session.add(self.account)
self.session.commit()
def teardown_method(self):
self.session.close()
def _record(self, *, commodity_id="C1", name="套装-兔兔白日梦", when=1788054985):
from datetime import UTC
from datetime import datetime as _dt
from web.backend.services.business_events import record_official_exchange
return record_official_exchange(
self.session,
self.account,
commodity_id=commodity_id,
commodity_name=name,
score=1280,
occurred_at=_dt.fromtimestamp(when, tz=UTC),
show_name="某昵称(安卓-微信)",
)
def test_writes_event_with_real_exchange_time(self):
"""时间必须是**真实兑换时刻**,不是"发现它的时刻"。"""
from web.backend.services import account_events_service as svc
assert self._record() is True
self.session.commit()
event = (
self.session.query(AccountEvent)
.filter(AccountEvent.event_type == svc.EVENT_EXCHANGE)
.one()
)
assert event.occurred_at.year == 2026
assert event.occurred_at.month == 8 # 1788054985 → 2026-08-30 前后
assert event.amount == -1280
assert event.detail["source"] == "official_sync"
assert event.source == "official_sync"
def test_is_idempotent(self):
"""一键查询会被反复执行,同一条官方记录只能落一条事件。"""
from web.backend.services import account_events_service as svc
assert self._record() is True
self.session.commit()
assert self._record() is False, "同一条记录不该重复落"
self.session.commit()
assert (
self.session.query(AccountEvent)
.filter(AccountEvent.event_type == svc.EVENT_EXCHANGE)
.count()
== 1
)
def test_different_commodity_is_a_new_event(self):
assert self._record(commodity_id="C1") is True
assert self._record(commodity_id="C2", name="套装-激萌罗小黑") is True
self.session.commit()
def test_missing_commodity_id_is_skipped(self):
assert self._record(commodity_id="") is False
class TestLatestExchangeFollowsEventTime:
"""「最近一次兑换」要按**事件时间**排,不能按插入 id。
踩过的坑:官方历史记录被补成事件时 `id` 是新的(最大),
但 `occurred_at` 是几个月前的。按 id 排会把一条**老**兑换显示成"最近一次" ——
实测把 08-30 的兔兔白日梦错报成了 10-01(那是另一次兑换)。
"""
def setup_method(self):
self.engine = get_test_engine()
self.session = make_test_session(self.engine)
self.admin = User(username="ord-admin", password_hash="h", role="super_admin")
self.session.add(self.admin)
self.session.flush()
self.account = Account(
username="ord-acc",
password="p",
email="d@e.com",
email_password="ep",
cookie="acf_uid=1; acf_ccn=c",
)
self.session.add(self.account)
self.session.commit()
def teardown_method(self):
self.session.close()
def _add_exchange(self, title: str, when: datetime):
from web.backend.services.account_events_service import (
CATEGORY_BUSINESS,
EVENT_EXCHANGE,
OFFICIAL_SYNC_SOURCE,
emit,
)
emit(
self.session,
account_id=self.account.id,
event_type=EVENT_EXCHANGE,
title=title,
category=CATEGORY_BUSINESS,
occurred_at=when,
# 列表只聚合**官方**记录,所以要标成 official_sync 才会被算进去
source=OFFICIAL_SYNC_SOURCE,
)
def _latest(self) -> str:
from web.backend.routers.accounts import list_accounts
item = list_accounts(
assigned_only=False,
has_cookie=False,
search="ord-acc",
page=1,
page_size=5,
db=self.session,
current=self.admin,
)["items"][0]
return item.model_dump()["last_exchange_name"]
def test_newer_event_inserted_first_still_wins(self):
# 先插**新**的,再插**老**的:按 id 排会错,按时间排才对
self._add_exchange("兑换 新商品", datetime(2026, 10, 1, tzinfo=UTC))
self.session.commit()
self._add_exchange("兑换 老商品", datetime(2026, 8, 30, tzinfo=UTC))
self.session.commit()
assert "新商品" in self._latest(), (
"「最近一次」按 id 排了 —— 补录的历史事件 id 更大,会被错当成最近一次"
)
class TestEsportsExpireTimePersisted:
"""电竞手册到期时间要**落库**(与精英对称)。
以前它只在 `_refresh_esports_handbook` 的返回值里带着、没写进账号,
所以界面上电竞手册只能显示「已开通」、看不到什么时候到期(用户反馈)。
"""
def setup_method(self):
self.engine = get_test_engine()
self.session = make_test_session(self.engine)
self.user = User(username="exp-admin", password_hash="h", role="super_admin")
self.session.add(self.user)
self.session.flush()
self.account = Account(
username="exp-acc", password="p", email="e@e.com", email_password="ep"
)
self.session.add(self.account)
self.session.commit()
def teardown_method(self):
self.session.close()
def _refresh(self, expire_time):
from web.backend.services.douyu_runner_manual import ManualMixin
class _H(ManualMixin):
def _client(self, cookie):
return None
def _push_log(self, *a, **k):
pass
def _to_int(self, value):
try:
return int(value)
except (TypeError, ValueError):
return None
class _Client:
@staticmethod
def esports_user_info(*, manual_id):
return {"manual_type": 1, "manual_score": 7, "expire_time": expire_time}
_H()._refresh_esports_handbook(_Client(), self.account, manual_id="E")
self.session.commit()
self.session.refresh(self.account)
def test_expire_time_is_written(self):
self._refresh("1795967999")
assert self.account.esports_expire_time == 1795967999
def test_no_expiry_stores_none_not_zero(self):
"""斗鱼用 0 / 空表示没有到期时间 —— 不能存成 0(界面会显示 1970/1/1)。"""
self._refresh("0")
assert self.account.esports_expire_time is None
self._refresh("")
assert self.account.esports_expire_time is None
class TestDeriveFromBindInfo:
"""从斗鱼 `bind_info` 推导绑定状态。
**实测校准过**(开发库 4 个账号):`is_bound_role` 为真时账号确实有角色名,
而它们的列值全是 `unknown` —— 4 个里 3 个其实已绑定,被 unknown 盖住了。
⚠️ `need_bind_role` **不是**"未绑定"的信号:实测**已绑定**的账号上它也是 1。
用它会把这 3 个已绑定的判成未绑 —— 幸好没拿它当判据。
"""
def test_bound_when_role_flag_true(self):
from web.backend.services.account_binding import (
BIND_BOUND,
derive_from_bind_info,
)
assert (
derive_from_bind_info({"is_bound_role": True, "role_name": "x"})
== BIND_BOUND
)
def test_bound_when_act_flag_true(self):
"""只有 act 标志为真时也算已绑 —— 避免把"已绑但没回角色名"误报成未绑。"""
from web.backend.services.account_binding import (
BIND_BOUND,
derive_from_bind_info,
)
assert derive_from_bind_info({"is_bound_act": True}) == BIND_BOUND
def test_unbound_when_flags_explicitly_false(self):
from web.backend.services.account_binding import (
BIND_UNBOUND,
derive_from_bind_info,
)
assert derive_from_bind_info({"is_bound_role": False}) == BIND_UNBOUND
assert derive_from_bind_info({"is_bound_act": "0"}) == BIND_UNBOUND
def test_none_when_no_conclusion(self):
"""标志缺失 → None(推不出来),**绝不能**当成 unbound。
这是"没查到"和"没绑定"的分界:写 unbound 就是编造结论。
小店未开通时实测就是这种情况(返回里没有这两个标志)。
"""
from web.backend.services.account_binding import derive_from_bind_info
assert derive_from_bind_info({}) is None
assert derive_from_bind_info({"role_name": "x"}) is None
assert derive_from_bind_info(None) is None
def test_need_bind_role_is_not_a_signal(self):
"""回归:`need_bind_role` 在**已绑定**账号上也是 1,不能拿它判未绑。"""
from web.backend.services.account_binding import (
BIND_BOUND,
derive_from_bind_info,
)
info = {"is_bound_role": True, "is_bound_act": True, "need_bind_role": 1}
assert derive_from_bind_info(info) == BIND_BOUND
class TestOverviewWritesBindStatus:
"""一键查询要把三个作用域的绑定状态**查出来并写回**。
这三个值以前从来没人查过,只是 S6 迁移时从旧的垃圾场列能推的推、
推不出的留 unknown —— 于是界面上 97% 显示"未知",对运营没用。
"""
def setup_method(self):
self.engine = get_test_engine()
self.session = make_test_session(self.engine)
self.user = User(username="bind-admin", password_hash="h", role="super_admin")
self.session.add(self.user)
self.session.flush()
self.account = Account(
username="bind-acc",
password="p",
email="b@e.com",
email_password="ep",
role_bind_status="unknown",
esports_bind_status="unknown",
xpd_bind_status="unknown",
)
self.session.add(self.account)
self.session.commit()
def teardown_method(self):
self.session.close()
def _run(self, *, info_by_scope):
from web.backend.services.douyu_runner_overview import OverviewMixin
class _H(OverviewMixin):
def _client(self, c):
return _FakeClient()
def _mark_task(self, *a, **k):
pass
def _push_log(self, *a, **k):
pass
def _fetch_bind_info(self, client, scope, alias):
return info_by_scope[scope]
def _refresh_account_gold_balance(self, c, a):
return {"gold_balance": 1, "exchange_balance": 1}
def _refresh_account_points(self, c, a, cookie, *, ctn=None):
return {"points": 1, "points_query": {"raw": {"data": {}}}}
def _refresh_elite_handbook(self, c, a, *, manual_id, rid=""):
return {
"elite_opened": False,
"elite_expire_time": 0,
"elite_manual_score": 0,
}
def _refresh_esports_handbook(self, c, a, *, manual_id):
return {"esports_manual_type": 0, "esports_manual_score": 0}
def _fetch_elite_records(self, c, cfg, mid, emid):
return []
def _fetch_esports_records(self, c, cfg, mid, emid):
return []
def _fetch_xpd_records(self, c, cfg, mid, emid):
return []
task = DouyuTask(
batch_id="bind",
account_id=self.account.id,
task_type="query_account_overview",
handbook_scope="elite",
status="running",
message="",
created_by=self.user.id,
)
self.session.add(task)
self.session.commit()
_H()._execute_query_account_overview(
self.session,
task,
self.account,
"c",
{
"manual_id": "M",
"esports_manual_id": "E",
"rid": "R",
"bind_act_alias": "B",
"esports_act_alias": "S",
"xpd_act_alias": "X",
"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