Files
live-hub-py/tests/test_supply_chain.py
T
yml2213 18bc7be4b1 feat(pool): 账号自动分配与回收(租约 + 补给链 + 独立工作台)
在既有「管理员分配 → 客服导入工作台 → 手动选号」之外**新增**一条
「客服选皮肤 + 数量 → 系统分配账号 + 按需补积分 → 用完自动收回」的路径。
旧路径只做并集与可选参数扩展,行为不变(有用例守着)。

- 归属与使用权分离:新增 account_leases(生成列 + 唯一索引,并发抢号由数据库裁决),
  assigned_to 收窄为管理员/运营的维护责任;客服可见范围扩为「名下 ∪ 租用中」
- account_pool 服务:候选筛选、两梯队排序(先花快过期的 / 先选充值少的)、
  供给与成本预估、租约领取/释放/回收
- 补给链:查余额 → 充鱼翅(单次 ≤800 元,超限拆单)→ 赠精英令 → 兑换;
  运行期两道闸门:_skip_when(余额已够就别再花钱)、_depends_on(前置未成功就跳过)
- 转人工:鱼翅支付等 10 分钟未到账转 manual_required,租约先不还;
  account_lease_reaper 兜 TTL 到期 / 漏释放 / 孤儿批次
- 运营流水:业务事件补 actor_id = task.created_by;账号详情页展示操作人、渠道
  与租赁记录
- 新工作台:/douyu/pool + 菜单「账号分配台」(新建,不改造旧工作台)
- 修复 D1:官方补录的兑换事件漏写 subject_key(10,612 条主键为空),
  会让 365 天同皮肤规则漏判 87% 的历史;补代码 + 回填脚本
- 迁移 20261007_0052;权限 account:pool_use / account:pool_admin

测试:766 passed, 6 skipped(改动前基线 727);ruff 与 tsc -b 干净。
设计稿与实施记录:docs/设计-账号自动分配与回收.md
2026-10-07 12:29:08 +08:00

279 lines
10 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.
"""补给链:缺多少充多少、该跳过的跳过、前置失败就别往下跑。
链上每一步都是**真花钱**的动作(充鱼翅、送精英令),所以两道运行期闸门
(`_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):
"""链首必须先刷余额 —— 否则 `_skip_when` 读的是旧值,会多充钱。"""
chain = pool.build_supply_chain(self._candidate(300), commodity_id="C1")
assert [s.task_type for s in chain][:2] == [
"query_gold_balance",
"query_points",
]
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"