Files
live-hub-py/tests/test_account_pool.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

504 lines
19 KiB
Python

"""账号池:候选筛选、两梯队排序、租约领取与释放。
分两层守:
- **纯函数**(充值量、排序、拆单)不碰数据库,直接喂数据断言 ——
这几条是业务规则的集中地,改错了必须立刻变红;
- **租约**(唯一约束、并发抢号、按任务释放)必须打真实 MySQL ——
「一个账号同时只能有一个有效租约」是**数据库索引**给的保证,
用内存假库测不出并发下的双发。
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import pytest
from tests.support.db import get_test_engine, make_test_session
from web.backend.models import Account, AccountEvent, AccountLease, DouyuTask, User
from web.backend.services import account_pool as pool
NOW = datetime(2026, 10, 7, 10, 0, 0, tzinfo=UTC)
COMMODITY = "20260120QYOOB_bag10"
# ── 纯函数 ────────────────────────────────────────────────────────────────
class TestTopupMath:
def test_rounds_gap_up_to_whole_gifts(self):
assert pool.topup_gifts_for(300, 1280) == 98 # 缺口 980 → 98 个令
assert pool.topup_gifts_for(1279, 1280) == 1
assert pool.topup_gifts_for(1280, 1280) == 0
assert pool.topup_gifts_for(2000, 1280) == 0
def test_none_points_is_treated_as_zero(self):
assert pool.topup_gifts_for(None, 20) == 2
def test_exact_multiple_does_not_add_a_gift(self):
"""缺口正好是 10 的倍数时不能多给一个令(多买就是浪费钱)。"""
assert pool.topup_gifts_for(0, 100) == 10
class TestRechargeSplit:
def test_no_split_below_limit(self):
assert pool.recharge_amounts_for(0) == []
assert pool.recharge_amounts_for(98) == [98]
assert pool.recharge_amounts_for(800) == [800]
def test_splits_above_limit(self):
assert pool.recharge_amounts_for(1700) == [800, 800, 100]
assert pool.recharge_amounts_for(1600) == [800, 800]
def test_validate_rejects_over_limit(self):
assert pool.validate_recharge_amount(800) == 800
with pytest.raises(ValueError, match="800"):
pool.validate_recharge_amount(801)
with pytest.raises(ValueError):
pool.validate_recharge_amount(0)
class TestRanking:
def _cand(self, account_id, points, score=1280, expire=None):
return pool.make_candidate(
account_id=account_id,
username=f"acc{account_id}",
points=points,
score=score,
points_expire_at=expire,
)
def test_tier_a_before_tier_b(self):
rich = self._cand(1, 2000)
poor = self._cand(2, 0)
ranked = pool.rank_candidates([poor, rich])
assert [c.account_id for c in ranked] == [1, 2]
assert ranked[0].tier == "A" and ranked[1].tier == "B"
def test_tier_a_spends_expiring_points_first(self):
"""A 梯队先花**快过期**的积分 —— 那是已经买过、不用就作废的钱。"""
soon = self._cand(1, 2000, expire=NOW + timedelta(days=1))
later = self._cand(2, 2000, expire=NOW + timedelta(days=30))
never = self._cand(3, 2000, expire=None)
ranked = pool.rank_candidates([never, later, soon])
assert [c.account_id for c in ranked] == [1, 2, 3]
def test_tier_a_then_prefers_smaller_balance(self):
"""都没有到期时间时先用小余额,把大额账号留给更贵的商品。"""
small = self._cand(1, 1300)
big = self._cand(2, 9999)
ranked = pool.rank_candidates([big, small])
assert [c.account_id for c in ranked] == [1, 2]
def test_tier_b_prefers_least_topup(self):
"""B 梯队先选**充值最少**的,省真金白银。"""
near = self._cand(1, 1200) # 只差 8 个令
far = self._cand(2, 0) # 差 128 个令
ranked = pool.rank_candidates([far, near])
assert [c.account_id for c in ranked] == [1, 2]
assert ranked[0].topup_gifts == 8
def test_order_is_stable_for_equal_candidates(self):
same = [self._cand(i, 0) for i in (5, 3, 9)]
assert [c.account_id for c in pool.rank_candidates(same)] == [3, 5, 9]
# ── 数据库 ────────────────────────────────────────────────────────────────
class _PoolBase:
def setup_method(self):
self.engine = get_test_engine()
self.session = make_test_session(self.engine)
self.admin = User(username="pool-admin", password_hash="h", role="super_admin")
self.support = User(username="pool-kf", password_hash="h", role="support")
self.session.add_all([self.admin, self.support])
self.session.flush()
self.admin_id = self.admin.id
self.support_id = self.support.id
def teardown_method(self):
self.session.close()
def make_account(
self,
name: str,
*,
points: int | None = 0,
opened: bool = True,
ck: bool = True,
status: str = "active",
assigned_to: int | None = None,
expire_at: datetime | None = None,
auto_managed: bool = True,
) -> Account:
account = Account(
username=name,
password="p",
email="e@e.com",
email_password="ep",
status=status,
ck_check_status="valid" if ck else "",
cookie="PHPSESSID=x" if ck else "",
elite_opened=opened,
points=points,
assigned_to=assigned_to,
points_expire_at=expire_at,
auto_managed=auto_managed,
)
self.session.add(account)
self.session.commit()
return account
def add_exchange(self, account_id: int, commodity_id: str, days_ago: int = 1):
self.session.add(
AccountEvent(
account_id=account_id,
event_type="exchange",
category="business",
occurred_at=NOW - timedelta(days=days_ago),
title="兑换",
subject_type="commodity",
subject_key=commodity_id,
source="official_sync",
)
)
self.session.commit()
def eligible(self, *, score=1280, limit=None):
return pool.eligible_accounts(
self.session,
score=score,
commodity_id=COMMODITY,
now=NOW,
limit=limit,
)
class TestEligibilityFilters(_PoolBase):
def test_unassigned_and_operator_owned_are_pool_members(self):
operator = User(username="pool-op", password_hash="h", role="operation")
self.session.add(operator)
self.session.flush()
free = self.make_account("free")
owned = self.make_account("owned", assigned_to=operator.id)
ids = {c.account_id for c in self.eligible()}
assert {free.id, owned.id} <= ids
def test_support_owned_accounts_are_not_in_pool(self):
"""第 1 条:不跨归属 —— 客服名下账号不进池。"""
mine = self.make_account("kf-owned", assigned_to=self.support_id)
assert mine.id not in {c.account_id for c in self.eligible()}
def test_not_opened_is_excluded(self):
"""开通精英宝典要人工扫码,未开通的账号进池也白进。"""
closed = self.make_account("closed", opened=False, points=5000)
assert closed.id not in {c.account_id for c in self.eligible()}
def test_unknown_opened_is_excluded(self):
"""`NULL` = 没查过,不能当「已开通」放行。"""
unknown = self.make_account("unknown")
unknown.elite_opened = None
self.session.commit()
assert unknown.id not in {c.account_id for c in self.eligible()}
def test_exchanged_within_cooldown_is_excluded(self):
done = self.make_account("done", points=5000)
self.add_exchange(done.id, COMMODITY, days_ago=10)
assert done.id not in {c.account_id for c in self.eligible()}
def test_exchange_outside_cooldown_is_allowed_again(self):
old = self.make_account("old", points=5000)
self.add_exchange(old.id, COMMODITY, days_ago=pool.EXCHANGE_COOLDOWN_DAYS + 1)
assert old.id in {c.account_id for c in self.eligible()}
def test_other_commodity_history_does_not_block(self):
other = self.make_account("other", points=5000)
self.add_exchange(other.id, "20260120QYOOB_bag5", days_ago=1)
assert other.id in {c.account_id for c in self.eligible()}
def test_leased_account_is_excluded(self):
account = self.make_account("leased", points=5000)
pool.lease_accounts(
self.session,
holder_user_id=self.support_id,
candidates=[
pool.make_candidate(
account_id=account.id,
username=account.username,
points=5000,
score=1280,
)
],
commodity_id=COMMODITY,
now=NOW,
)
assert account.id not in {c.account_id for c in self.eligible()}
def test_no_ck_or_inactive_is_excluded(self):
no_ck = self.make_account("no-ck", ck=False, points=5000)
inactive = self.make_account("inactive", status="imported", points=5000)
ids = {c.account_id for c in self.eligible()}
assert no_ck.id not in ids and inactive.id not in ids
def test_auto_managed_false_is_excluded(self):
special = self.make_account("special", points=5000, auto_managed=False)
assert special.id not in {c.account_id for c in self.eligible()}
def test_unknown_points_is_excluded(self):
"""积分没查过 → 充值量算不出来,先别放出去。"""
unknown = self.make_account("no-points", points=None)
assert unknown.id not in {c.account_id for c in self.eligible()}
def test_tiers_are_assigned_by_score(self):
rich = self.make_account("rich", points=5000)
poor = self.make_account("poor", points=100)
by_id = {c.account_id: c for c in self.eligible(score=1280)}
assert by_id[rich.id].tier == "A"
assert by_id[poor.id].tier == "B"
assert by_id[poor.id].topup_gifts == 118 # (1280-100)/10
class TestSupplyEstimate(_PoolBase):
def test_counts_add_up(self):
self.make_account("rich", points=5000)
poor = self.make_account("poor", points=0)
done = self.make_account("done", points=5000)
self.add_exchange(done.id, COMMODITY, days_ago=1)
self.make_account("closed", opened=False, points=5000)
supply = pool.estimate_supply(
self.session, score=1280, commodity_id=COMMODITY, now=NOW
)
assert supply["zero_topup"] == 1 # rich
assert supply["after_topup"] == 2 # rich + poor
assert supply["blocked"]["elite_not_opened"] == 1
assert supply["blocked"]["redeemed_within_cooldown"] == 1
# poor 差 128 个令;rich 已够,不产生缺口
assert supply["cost"]["est_gifts"] == 128
assert poor.id # 保留引用,避免上面的构造被优化掉
class TestLeasing(_PoolBase):
def test_lease_creates_active_row(self):
account = self.make_account("a1", points=5000)
leases = pool.lease_accounts(
self.session,
holder_user_id=self.support_id,
candidates=[
pool.make_candidate(
account_id=account.id, username="a1", points=5000, score=1280
)
],
commodity_id=COMMODITY,
now=NOW,
)
assert len(leases) == 1
assert leases[0].status == "active"
assert leases[0].expires_at == NOW + timedelta(
minutes=pool.DEFAULT_LEASE_TTL_MINUTES
)
def test_second_lease_on_same_account_is_rejected(self):
"""唯一索引裁决:同一账号第二个租约必须失败(这是并发安全的地基)。"""
account = self.make_account("a2", points=5000)
candidate = pool.make_candidate(
account_id=account.id, username="a2", points=5000, score=1280
)
first = pool.lease_accounts(
self.session,
holder_user_id=self.support_id,
candidates=[candidate],
commodity_id=COMMODITY,
now=NOW,
)
assert len(first) == 1
second = pool.lease_accounts(
self.session,
holder_user_id=self.admin_id,
candidates=[candidate],
commodity_id=COMMODITY,
now=NOW,
)
assert second == [], "同一个账号不该被第二个租约抢到"
def test_concurrent_lease_from_two_sessions_only_one_wins(self):
"""两个独立会话抢同一账号 —— 真实并发形态。"""
account = self.make_account("a3", points=5000)
candidate = pool.make_candidate(
account_id=account.id, username="a3", points=5000, score=1280
)
other = make_test_session(self.engine)
try:
first = pool.lease_accounts(
self.session,
holder_user_id=self.support_id,
candidates=[candidate],
commodity_id=COMMODITY,
now=NOW,
)
second = pool.lease_accounts(
other,
holder_user_id=self.admin_id,
candidates=[candidate],
commodity_id=COMMODITY,
now=NOW,
)
finally:
other.close()
assert len(first) == 1
assert second == []
assert (
self.session.query(AccountLease)
.filter(AccountLease.account_id == account.id)
.count()
== 1
)
def test_quantity_stops_early(self):
accounts = [self.make_account(f"q{i}", points=5000) for i in range(3)]
candidates = [
pool.make_candidate(
account_id=a.id, username=a.username, points=5000, score=1280
)
for a in accounts
]
leases = pool.lease_accounts(
self.session,
holder_user_id=self.support_id,
candidates=candidates,
commodity_id=COMMODITY,
quantity=2,
now=NOW,
)
assert len(leases) == 2
def test_release_for_task_only_frees_its_own_account(self):
"""一个批次里每个账号各有一条租约,不能因为一条任务结束就整批释放。"""
accounts = [self.make_account(f"t{i}", points=5000) for i in range(2)]
candidates = [
pool.make_candidate(
account_id=a.id, username=a.username, points=5000, score=1280
)
for a in accounts
]
leases = pool.lease_accounts(
self.session,
holder_user_id=self.support_id,
candidates=candidates,
commodity_id=COMMODITY,
batch_id="batch-1",
now=NOW,
)
pool.bind_leases_to_batch(self.session, leases, "batch-1")
task = DouyuTask(
batch_id="batch-1",
account_id=accounts[0].id,
task_type="exchange_goods",
handbook_scope="elite",
status="success",
created_by=self.support_id,
)
self.session.add(task)
self.session.commit()
assert pool.release_for_task(self.session, task, reason="task_success") is True
self.session.expire_all()
states = {
lease.account_id: lease.status
for lease in self.session.query(AccountLease)
.filter(AccountLease.batch_id == "batch-1")
.all()
}
assert states[accounts[0].id] == "released"
assert states[accounts[1].id] == "active", "同批次另一个账号的租约不该被释放"
def test_release_is_idempotent(self):
account = self.make_account("i1", points=5000)
lease = pool.lease_accounts(
self.session,
holder_user_id=self.support_id,
candidates=[
pool.make_candidate(
account_id=account.id, username="i1", points=5000, score=1280
)
],
commodity_id=COMMODITY,
now=NOW,
)[0]
assert pool.release_lease(self.session, lease, reason="done") is True
assert pool.release_lease(self.session, lease, reason="done") is False
def test_released_account_becomes_available_again(self):
account = self.make_account("r1", points=5000)
lease = pool.lease_accounts(
self.session,
holder_user_id=self.support_id,
candidates=[
pool.make_candidate(
account_id=account.id, username="r1", points=5000, score=1280
)
],
commodity_id=COMMODITY,
now=NOW,
)[0]
assert account.id not in {c.account_id for c in self.eligible()}
pool.release_lease(self.session, lease, reason="done")
assert account.id in {c.account_id for c in self.eligible()}
def test_reap_expired_only_touches_past_due(self):
due = self.make_account("due", points=5000)
live = self.make_account("live", points=5000)
candidates = [
pool.make_candidate(
account_id=a.id, username=a.username, points=5000, score=1280
)
for a in (due, live)
]
pool.lease_accounts(
self.session,
holder_user_id=self.support_id,
candidates=[candidates[0]],
commodity_id=COMMODITY,
ttl_minutes=1,
now=NOW,
)
pool.lease_accounts(
self.session,
holder_user_id=self.support_id,
candidates=[candidates[1]],
commodity_id=COMMODITY,
ttl_minutes=600,
now=NOW,
)
reaped = pool.reap_expired(self.session, now=NOW + timedelta(minutes=5))
assert reaped == 1
self.session.expire_all()
rows = {
lease.account_id: lease.status
for lease in self.session.query(AccountLease).all()
}
assert rows[due.id] == "expired"
assert rows[live.id] == "active"
def test_leased_account_ids_lists_holder_only(self):
account = self.make_account("h1", points=5000)
pool.lease_accounts(
self.session,
holder_user_id=self.support_id,
candidates=[
pool.make_candidate(
account_id=account.id, username="h1", points=5000, score=1280
)
],
commodity_id=COMMODITY,
now=NOW,
)
assert pool.leased_account_ids(
self.session, holder_user_id=self.support_id
) == [account.id] or account.id in pool.leased_account_ids(
self.session, holder_user_id=self.support_id
)
assert pool.leased_account_ids(self.session, holder_user_id=self.admin_id) == []