## 现象(用户从日志里发现的)
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
1464 lines
55 KiB
Python
1464 lines
55 KiB
Python
"""账号「一键查询」:判定规则、失败隔离、只读性。
|
||
|
||
判定规则用的是**生产镜像库的真实返回样本**(见 `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
|