在既有「管理员分配 → 客服导入工作台 → 手动选号」之外**新增**一条 「客服选皮肤 + 数量 → 系统分配账号 + 按需补积分 → 用完自动收回」的路径。 旧路径只做并集与可选参数扩展,行为不变(有用例守着)。 - 归属与使用权分离:新增 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
279 lines
10 KiB
Python
279 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):
|
||
"""链首必须先刷余额 —— 否则 `_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"
|