feat(overview): 关掉和平小店查询 + 修电竞「已开通」不落库

## ① 和平小店总开关(默认关)

小店目前是**关闭状态**,但不跳过的话一键查询仍会对每个账号发小店请求,
结果 100% 失败 —— 实测 200 个账号里 `exchange_records_peace` 失败 **195 次(100%)**:

    Livelink登录态校验失败

100% 意味着不是账号问题。这既淹掉真错误、又让人以为出故障。

新增 `douyu_config.peace_enabled`(迁移 `0050`,**默认 False**)。
关着时**完全跳过**小店的所有请求(绑定 + 兑换记录),并且**不记 errors** ——
「配置成关的」和「查失败」是两回事,混在一起会让 errors 失去意义。

将来小店恢复,在后台打开这个开关即可;打开后小店失败仍会**如实暴露**
(有用例专门钉住这一点,别把开关当成遮羞布)。

## ② 修:电竞手册「已开通」不会被一键查询更新

用户问"电竞到期时间为什么拿不到",查下去发现**两层**:

**第一层不是 bug**:那 200 个账号 `esports_opened` 全是 0(没开通电竞手册),
所以 `expireTime="0"`、没有到期时间可存。注意
**"电竞已绑定角色"≠"电竞手册已开通"**。实测 `opened_but_no_expire = 0`,没有异常。

**第二层是真 bug(不对称)**:

    _refresh_elite_handbook   → 写 account.elite_opened  ✅
    _refresh_esports_handbook → 不写 account.esports_opened ❌

`esports_opened` 只在"开通"任务里被设置,所以**详情页电竞的「已开通/未开通」
根本不会被一键查询更新**。已改成与精英对称(口径同 create_esports_qr:
`manual_type >= 1` 即已开通)。

## 测试(+2)

- 小店关着时:**不发**小店请求、**不查**小店绑定、**不记** peace 相关的 errors,
  且结果里标明 `peace_skipped`
- 小店打开时:失败**仍然如实暴露**(反向用例,防止开关变成遮羞布)

## 验证(本地)

TZ=Asia/Shanghai **620 passed / 6 skipped**;ruff 全绿。
This commit is contained in:
yml2213
2026-10-04 20:59:17 +08:00
parent 631e33a4f6
commit ccd05cba9c
7 changed files with 209 additions and 1 deletions
+126
View File
@@ -1339,6 +1339,8 @@ class TestOverviewWritesBindStatus:
"bind_act_alias": "B",
"esports_act_alias": "S",
"xpd_act_alias": "X",
# 这个用例要覆盖三个作用域,显式打开小店(默认是关的)
"peace_enabled": True,
"xpd_rid": "R",
"xpd_act_id": "1",
},
@@ -1461,3 +1463,127 @@ class TestDuplicateEventIsQuiet:
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", {})
+1 -1
View File
@@ -26,7 +26,7 @@ TESTS_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = TESTS_DIR.parent
VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions"
HEAD_REVISION = "20261004_0049"
HEAD_REVISION = "20261004_0050"
# 迁移新增、但不声明在模型里的复合查询索引。
EXTRA_INDEXES = (