- 前端在扫码支付弹窗中增加显眼充值金额卡片与多账号 Tabs 支付标注 - 前端增加常用与历史快捷皮肤选择胶囊栏,支持本地缓存记忆与一键切换 - 后端补给链首步引入 query_elite_status 并支持 require_opened 前置熔断校验 - 优化供给与预估花费文案展示,增加兑换结果大图预览与凭证复制功能 - 验证方式:全量 784 个 pytest 用例全部通过,前端 tsc/build 校验通过
280 lines
10 KiB
Python
280 lines
10 KiB
Python
"""补给链:缺多少充多少、该跳过的跳过、前置失败就别往下跑。
|
||
|
||
链上每一步都是**真花钱**的动作(充鱼翅、送精英令),所以两道运行期闸门
|
||
(`_skip_when` / `_depends_on`)必须有逆向用例守着:闸门失效 = 白花钱。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import UTC, datetime
|
||
|
||
from tests.support.db import get_test_engine, make_test_session
|
||
from web.backend.models import Account, DouyuTask, User
|
||
from web.backend.services import account_pool as pool
|
||
from web.backend.services.douyu_runner_core import DouyuBatchRunnerCore
|
||
|
||
NOW = datetime(2026, 10, 7, 16, 0, 0, tzinfo=UTC)
|
||
|
||
|
||
class TestBuildSupplyChain:
|
||
def _candidate(self, points: int, score: int = 1280):
|
||
return pool.make_candidate(
|
||
account_id=1, username="a", points=points, score=score
|
||
)
|
||
|
||
def test_tier_a_has_only_exchange(self):
|
||
chain = pool.build_supply_chain(self._candidate(5000), commodity_id="C1")
|
||
assert [s.task_type for s in chain] == ["exchange_goods"]
|
||
assert chain[0].payload == {"commodity_id": "C1", "num": 1}
|
||
|
||
def test_tier_b_refreshes_before_spending(self):
|
||
"""链首必须先查开通状态与余额 —— 否则未开通会误充值,旧值会多充钱。"""
|
||
chain = pool.build_supply_chain(self._candidate(300), commodity_id="C1")
|
||
assert [s.task_type for s in chain][:2] == [
|
||
"query_elite_status",
|
||
"query_gold_balance",
|
||
]
|
||
assert chain[0].payload == {"require_opened": True}
|
||
|
||
def test_tier_b_topup_amount_is_exact_gap(self):
|
||
# 缺口 980 分 → 98 个令 → 98 元
|
||
chain = pool.build_supply_chain(self._candidate(300), commodity_id="C1")
|
||
recharge = [s for s in chain if s.task_type == "create_gold_qr"]
|
||
assert [s.payload["amount"] for s in recharge] == [98]
|
||
donate = [s for s in chain if s.task_type == "donate_elite_gift"]
|
||
assert donate[0].payload["gift_count"] == 98
|
||
|
||
def test_tier_b_splits_recharge_above_800(self):
|
||
# 缺口 9000 分 → 900 个令 → 900 元 → 必须拆成 800 + 100
|
||
chain = pool.build_supply_chain(
|
||
self._candidate(0, score=9000), commodity_id="C1"
|
||
)
|
||
recharge = [s for s in chain if s.task_type == "create_gold_qr"]
|
||
assert [s.payload["amount"] for s in recharge] == [800, 100]
|
||
|
||
def test_recharge_step_skips_when_gold_already_enough(self):
|
||
chain = pool.build_supply_chain(self._candidate(300), commodity_id="C1")
|
||
recharge = next(s for s in chain if s.task_type == "create_gold_qr")
|
||
assert recharge.payload["_skip_when"] == {
|
||
"column": "gold_balance",
|
||
"op": ">=",
|
||
"value": 98,
|
||
}
|
||
|
||
def test_donate_step_skips_when_points_already_enough(self):
|
||
chain = pool.build_supply_chain(self._candidate(300), commodity_id="C1")
|
||
donate = next(s for s in chain if s.task_type == "donate_elite_gift")
|
||
assert donate.payload["_skip_when"] == {
|
||
"column": "points",
|
||
"op": ">=",
|
||
"value": 1280,
|
||
}
|
||
|
||
def test_exchange_is_last(self):
|
||
chain = pool.build_supply_chain(self._candidate(300), commodity_id="C1")
|
||
assert chain[-1].task_type == "exchange_goods"
|
||
|
||
def test_esports_uses_its_own_task_type(self):
|
||
chain = pool.build_supply_chain(
|
||
self._candidate(5000),
|
||
commodity_id="C1",
|
||
exchange_task_type="exchange_esports_goods",
|
||
)
|
||
assert chain[-1].task_type == "exchange_esports_goods"
|
||
|
||
|
||
class _GateBase:
|
||
def setup_method(self):
|
||
self.engine = get_test_engine()
|
||
self.session = make_test_session(self.engine)
|
||
self.user = User(username="gate-kf", password_hash="h", role="support")
|
||
self.session.add(self.user)
|
||
self.session.commit()
|
||
# `douyu_tasks.account_id` 有外键,任务必须挂在一个真实账号上
|
||
anchor = Account(
|
||
username="gate-anchor",
|
||
password="p",
|
||
email="e@e.com",
|
||
email_password="ep",
|
||
)
|
||
self.session.add(anchor)
|
||
self.session.commit()
|
||
self.anchor_id = anchor.id
|
||
# 只为调用 core 上的两个闸门方法:绕开 __init__(它要日志队列与事件循环)
|
||
self.runner = DouyuBatchRunnerCore.__new__(DouyuBatchRunnerCore)
|
||
self.runner.payload = {}
|
||
|
||
def teardown_method(self):
|
||
self.session.close()
|
||
|
||
def _account(self, **fields) -> Account:
|
||
account = Account(
|
||
username=f"gate-{fields.get('points')}",
|
||
password="p",
|
||
email="e@e.com",
|
||
email_password="ep",
|
||
**fields,
|
||
)
|
||
self.session.add(account)
|
||
self.session.commit()
|
||
return account
|
||
|
||
def _task(self, payload: dict) -> DouyuTask:
|
||
task = DouyuTask(
|
||
batch_id="b",
|
||
account_id=self.anchor_id,
|
||
task_type="create_gold_qr",
|
||
handbook_scope="elite",
|
||
status="planned",
|
||
result={"payload": payload},
|
||
created_by=self.user.id,
|
||
)
|
||
self.session.add(task)
|
||
self.session.commit()
|
||
return task
|
||
|
||
|
||
class TestSkipWhenGate(_GateBase):
|
||
def test_skips_when_condition_met(self):
|
||
account = self._account(points=5000, gold_balance=200)
|
||
task = self._task(
|
||
{"_skip_when": {"column": "gold_balance", "op": ">=", "value": 98}}
|
||
)
|
||
assert "跳过" in self.runner._skip_reason(account, task)
|
||
|
||
def test_runs_when_condition_not_met(self):
|
||
account = self._account(points=5000, gold_balance=10)
|
||
task = self._task(
|
||
{"_skip_when": {"column": "gold_balance", "op": ">=", "value": 98}}
|
||
)
|
||
assert self.runner._skip_reason(account, task) == ""
|
||
|
||
def test_missing_value_does_not_skip(self):
|
||
"""不知道余额时**不能跳** —— 跳了就等于静默不充值,兑换必然失败。"""
|
||
account = self._account(points=5000, gold_balance=None)
|
||
task = self._task(
|
||
{"_skip_when": {"column": "gold_balance", "op": ">=", "value": 98}}
|
||
)
|
||
assert self.runner._skip_reason(account, task) == ""
|
||
|
||
def test_unknown_column_does_not_skip(self):
|
||
account = self._account(points=5000)
|
||
task = self._task(
|
||
{"_skip_when": {"column": "password", "op": ">=", "value": 1}}
|
||
)
|
||
assert self.runner._skip_reason(account, task) == ""
|
||
|
||
def test_unknown_operator_does_not_skip(self):
|
||
account = self._account(points=5000)
|
||
task = self._task({"_skip_when": {"column": "points", "op": "!!", "value": 1}})
|
||
assert self.runner._skip_reason(account, task) == ""
|
||
|
||
def test_no_rule_does_not_skip(self):
|
||
account = self._account(points=5000)
|
||
assert self.runner._skip_reason(account, self._task({})) == ""
|
||
|
||
|
||
class TestDependencyGate(_GateBase):
|
||
def _previous(self, status: str) -> DouyuTask:
|
||
task = DouyuTask(
|
||
batch_id="b",
|
||
account_id=self.anchor_id,
|
||
task_type="create_gold_qr",
|
||
handbook_scope="elite",
|
||
status=status,
|
||
created_by=self.user.id,
|
||
)
|
||
self.session.add(task)
|
||
self.session.commit()
|
||
return task
|
||
|
||
def test_blocks_when_previous_failed(self):
|
||
previous = self._previous("failed")
|
||
task = self._task({"_depends_on": previous.id})
|
||
assert "前置任务" in self.runner._dependency_reason(self.session, task)
|
||
|
||
def test_passes_when_previous_succeeded(self):
|
||
previous = self._previous("success")
|
||
task = self._task({"_depends_on": previous.id})
|
||
assert self.runner._dependency_reason(self.session, task) == ""
|
||
|
||
def test_passes_when_previous_was_skipped(self):
|
||
"""前置被跳过 = 那一步本来就不需要做 → 不该阻塞后面的兑换。"""
|
||
previous = self._previous("skipped")
|
||
task = self._task({"_depends_on": previous.id})
|
||
assert self.runner._dependency_reason(self.session, task) == ""
|
||
|
||
def test_blocks_when_previous_in_manual_required(self):
|
||
previous = self._previous("manual_required")
|
||
task = self._task({"_depends_on": previous.id})
|
||
assert "前置任务" in self.runner._dependency_reason(self.session, task)
|
||
|
||
def test_no_dependency_runs(self):
|
||
assert self.runner._dependency_reason(self.session, self._task({})) == ""
|
||
|
||
|
||
class TestManualRequiredKeepsLease(_GateBase):
|
||
"""转人工时**不能把号还回去** —— 客服还在扫码,账号已经挂在这单上了。
|
||
|
||
链式执行会把后半段标成 `skipped`,那些终态任务同样会触发释放钩子;
|
||
所以判据是「**批次里还有 manual_required**」而不是只看当前这条任务的状态。
|
||
"""
|
||
|
||
def _lease(self, batch_id: str):
|
||
account = self._account(
|
||
points=5000,
|
||
cookie="PHPSESSID=x",
|
||
ck_check_status="valid",
|
||
elite_opened=True,
|
||
status="active",
|
||
)
|
||
lease = pool.lease_accounts(
|
||
self.session,
|
||
holder_user_id=self.user.id,
|
||
candidates=[
|
||
pool.make_candidate(
|
||
account_id=account.id,
|
||
username=account.username,
|
||
points=5000,
|
||
score=1280,
|
||
)
|
||
],
|
||
commodity_id="C1",
|
||
batch_id=batch_id,
|
||
)[0]
|
||
return lease
|
||
|
||
def _task_of(
|
||
self, batch_id: str, status: str, account_id: int | None = None
|
||
) -> DouyuTask:
|
||
task = DouyuTask(
|
||
batch_id=batch_id,
|
||
account_id=account_id or self.anchor_id,
|
||
task_type="exchange_goods",
|
||
handbook_scope="elite",
|
||
status=status,
|
||
created_by=self.user.id,
|
||
)
|
||
self.session.add(task)
|
||
self.session.commit()
|
||
return task
|
||
|
||
def test_lease_kept_while_waiting_for_manual(self):
|
||
lease = self._lease("batch-manual")
|
||
self._task_of("batch-manual", "manual_required", lease.account_id)
|
||
skipped = self._task_of("batch-manual", "skipped", lease.account_id)
|
||
|
||
self.runner._release_pool_lease(self.session, skipped)
|
||
|
||
self.session.rollback()
|
||
assert self.session.get(type(lease), lease.id).status == "active"
|
||
|
||
def test_lease_released_when_no_manual_waiting(self):
|
||
lease = self._lease("batch-done")
|
||
done = self._task_of("batch-done", "success", lease.account_id)
|
||
|
||
self.runner._release_pool_lease(self.session, done)
|
||
|
||
self.session.rollback()
|
||
assert self.session.get(type(lease), lease.id).status == "released"
|