diff --git a/tests/test_account_overview.py b/tests/test_account_overview.py index dfcb93f..e826908 100644 --- a/tests/test_account_overview.py +++ b/tests/test_account_overview.py @@ -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", {}) diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 0a646f1..2058f51 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -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 = ( diff --git a/web/backend/migrations/versions/20261004_0050_peace_enabled.py b/web/backend/migrations/versions/20261004_0050_peace_enabled.py new file mode 100644 index 0000000..3c67727 --- /dev/null +++ b/web/backend/migrations/versions/20261004_0050_peace_enabled.py @@ -0,0 +1,56 @@ +"""和平小店总开关:`douyu_config.peace_enabled`(默认关) + +小店目前是**关闭状态**,但一键查询仍会对每个账号发小店的请求,结果 100% 失败: + + Livelink登录态校验失败 + +实测 200 个账号里 `exchange_records_peace` 失败 195 次(100%)。这既淹掉真错误, +又让人以为出了故障。加一个总开关,关着时**完全跳过**小店的所有请求 +(不是"发出去再记个错")。 + +默认 False —— 与"小店当前关闭"的现状一致;将来恢复时在后台打开即可。 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision: str = "20261004_0050" +down_revision: str | None = "20261004_0049" +branch_labels: str | None = None +depends_on: str | None = None + +COLUMN = "peace_enabled" + + +def _column_exists(name: str) -> bool: + """可安全重跑(应用启动时会自动跑迁移)。""" + conn = op.get_bind() + count = conn.execute( + sa.text( + "SELECT COUNT(*) FROM information_schema.columns " + "WHERE table_schema = DATABASE() " + "AND table_name = 'douyu_config' AND column_name = :name" + ), + {"name": name}, + ).scalar() + return bool(count) + + +def upgrade() -> None: + if not _column_exists(COLUMN): + op.add_column( + "douyu_config", + sa.Column( + COLUMN, + sa.Boolean(), + nullable=False, + server_default=sa.text("0"), + comment="和平小店总开关;关着时一键查询完全跳过小店请求", + ), + ) + + +def downgrade() -> None: + op.drop_column("douyu_config", COLUMN) diff --git a/web/backend/models.py b/web/backend/models.py index 5b1fc71..4ec4a5d 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -445,6 +445,9 @@ class DouyuConfig(Base): esports_chicken_skin_id: Mapped[str] = mapped_column(String(64), default="0") esports_firework_gift_id: Mapped[str] = mapped_column(String(64), default="24767") esports_firework_skin_id: Mapped[str] = mapped_column(String(64), default="3850") + # 和平小店总开关。**默认关** —— 小店目前是关闭状态,关着时一键查询 + # 完全跳过它的所有请求(不再产生 100% 的 Livelink 报错噪音)。 + peace_enabled: Mapped[bool] = mapped_column(Boolean, default=False) xpd_act_alias: Mapped[str] = mapped_column(String(64), default="20260623KDQFH") xpd_act_id: Mapped[str] = mapped_column(String(64), default="46195") xpd_rid: Mapped[str] = mapped_column(String(64), default="9263298") diff --git a/web/backend/services/douyu_runner_manual.py b/web/backend/services/douyu_runner_manual.py index b166546..f277910 100644 --- a/web/backend/services/douyu_runner_manual.py +++ b/web/backend/services/douyu_runner_manual.py @@ -203,6 +203,12 @@ class ManualMixin: manual_type = self._to_int(result.get("manual_type")) manual_score = self._to_int(result.get("manual_score")) account.esports_points = manual_score + # 开通状态也要落库 —— 与精英对称。 + # 之前只有 elite 那支写 `account.elite_opened`,电竞的 `esports_opened` + # **只在"开通"任务里被设置**,于是详情页的电竞「已开通/未开通」 + # 根本不会被一键查询更新(用户报"电竞到期时间拿不到"时顺带查出来的)。 + # 判定口径与 create_esports_qr 一致:manual_type >= 1 即已开通。 + account.esports_opened = manual_type is not None and manual_type >= 1 # 到期时间也落库 —— 否则界面只能显示"已开通",看不到什么时候到期 expire_raw = result.get("expire_time") expire_int = ( diff --git a/web/backend/services/douyu_runner_overview.py b/web/backend/services/douyu_runner_overview.py index cd7c56a..3ced522 100644 --- a/web/backend/services/douyu_runner_overview.py +++ b/web/backend/services/douyu_runner_overview.py @@ -439,12 +439,25 @@ class OverviewMixin: # `bind_status` 能推的推、推不出的留 `unknown`,于是界面上 97% 显示"未知"。 # 实测校准:开发库 4 个账号里 3 个其实**已绑定**(`is_bound_role=True` 且有角色名), # 全被 unknown 盖住了。 + # 和平小店总开关:关着时**完全跳过**它的所有请求。 + # 小店目前是关闭状态,不跳过的话每个账号都会产生一条 + # "Livelink登录态校验失败"(实测 200 个里失败 195 次,100%), + # 既淹掉真错误、又让人以为出故障。跳过时**不记 errors** —— + # 这是配置成关的,不是失败。 + peace_on = bool(config.get("peace_enabled")) + if not peace_on: + overview["peace_skipped"] = ( + "和平小店已关闭(douyu_config.peace_enabled=False)" + ) + overview["bind"] = {} for scope, label, alias_key in ( ("elite", "精英宝典", "bind_act_alias"), ("esports", "电竞手册", "esports_act_alias"), ("peace", "和平小店", "xpd_act_alias"), ): + if scope == "peace" and not peace_on: + continue alias = str(config.get(alias_key) or "").strip() if not alias: errors[f"bind_{scope}"] = f"未配置{label}活动别名" @@ -480,6 +493,8 @@ class OverviewMixin: ("esports", "电竞手册", self._fetch_esports_records), ("peace", "和平小店", self._fetch_xpd_records), ): + if scope == "peace" and not peace_on: + continue key = f"exchange_records_{scope}" if not self._scope_configured(scope, manual_id, esports_manual_id, config): errors[key] = f"未配置{label}活动参数,跳过" diff --git a/web/backend/services/douyu_service.py b/web/backend/services/douyu_service.py index 46f0dc9..68afac0 100644 --- a/web/backend/services/douyu_service.py +++ b/web/backend/services/douyu_service.py @@ -129,6 +129,8 @@ DOUYU_CONFIG_DEFAULTS = { "gold_api_account_template_name": "斗鱼昵称", "gift_id": "23643", "skin_id": "2942", + # 和平小店总开关,默认关(小店当前关闭;关着时一键查询完全跳过它的请求) + "peace_enabled": False, "xpd_act_alias": "20260623KDQFH", "xpd_act_id": "46195", "xpd_rid": "9263298",