在既有「管理员分配 → 客服导入工作台 → 手动选号」之外**新增**一条 「客服选皮肤 + 数量 → 系统分配账号 + 按需补积分 → 用完自动收回」的路径。 旧路径只做并集与可选参数扩展,行为不变(有用例守着)。 - 归属与使用权分离:新增 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
178 lines
6.4 KiB
Python
178 lines
6.4 KiB
Python
"""租约兜底回收:漏释放、孤儿批次、TTL 到期。
|
|
|
|
主释放路径是任务终态钩子(有单独用例);这里守的是**保险丝**:
|
|
进程重启、释放抛异常、任务行被清 —— 这三种情况下账号不能永远被锁着。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
from tests.support.db import get_test_engine, make_test_session
|
|
from web.backend.models import (
|
|
Account,
|
|
AccountLease,
|
|
AutomationJob,
|
|
DouyuTask,
|
|
User,
|
|
)
|
|
from web.backend.services import account_pool as pool
|
|
from web.backend.services.automation.jobs import account_lease_reaper as reaper
|
|
|
|
NOW = datetime(2026, 10, 7, 12, 0, 0, tzinfo=UTC)
|
|
|
|
|
|
class _ReaperBase:
|
|
def setup_method(self):
|
|
self.engine = get_test_engine()
|
|
self.session = make_test_session(self.engine)
|
|
self.user = User(username="reap-kf", password_hash="h", role="support")
|
|
self.session.add(self.user)
|
|
self.session.flush()
|
|
self.user_id = self.user.id
|
|
|
|
def teardown_method(self):
|
|
self.session.close()
|
|
|
|
def _lease(
|
|
self, name: str, *, batch_id: str = "", ttl_minutes: int = 60
|
|
) -> AccountLease:
|
|
account = Account(
|
|
username=name,
|
|
password="p",
|
|
email="e@e.com",
|
|
email_password="ep",
|
|
status="active",
|
|
ck_check_status="valid",
|
|
cookie="PHPSESSID=x",
|
|
elite_opened=True,
|
|
points=5000,
|
|
)
|
|
self.session.add(account)
|
|
self.session.commit()
|
|
lease = pool.lease_accounts(
|
|
self.session,
|
|
holder_user_id=self.user_id,
|
|
candidates=[
|
|
pool.make_candidate(
|
|
account_id=account.id,
|
|
username=name,
|
|
points=5000,
|
|
score=1280,
|
|
)
|
|
],
|
|
commodity_id="20260101X_bag1",
|
|
batch_id=batch_id,
|
|
ttl_minutes=ttl_minutes,
|
|
now=NOW,
|
|
)[0]
|
|
return lease
|
|
|
|
def _task(self, batch_id: str, account_id: int, status: str):
|
|
self.session.add(
|
|
DouyuTask(
|
|
batch_id=batch_id,
|
|
account_id=account_id,
|
|
task_type="exchange_goods",
|
|
handbook_scope="elite",
|
|
status=status,
|
|
created_by=self.user_id,
|
|
)
|
|
)
|
|
self.session.commit()
|
|
|
|
def _status(self, lease_id: int) -> str:
|
|
self.session.rollback()
|
|
lease = self.session.get(AccountLease, lease_id)
|
|
assert lease is not None
|
|
return lease.status
|
|
|
|
|
|
class TestReapMissedReleases(_ReaperBase):
|
|
def test_releases_when_batch_has_no_active_task(self):
|
|
lease = self._lease("m1", batch_id="batch-m1")
|
|
self._task("batch-m1", lease.account_id, "success")
|
|
|
|
assert pool.reap_missed_releases(self.session) == 1
|
|
assert self._status(lease.id) == "released"
|
|
|
|
def test_keeps_lease_while_batch_still_running(self):
|
|
lease = self._lease("m2", batch_id="batch-m2")
|
|
self._task("batch-m2", lease.account_id, "running")
|
|
|
|
assert pool.reap_missed_releases(self.session) == 0
|
|
assert self._status(lease.id) == "active"
|
|
|
|
def test_keeps_lease_when_batch_not_created_yet(self):
|
|
"""租约刚建、任务还没建出来(batch_id 为空)—— 不能当成漏释放。"""
|
|
lease = self._lease("m3", batch_id="")
|
|
assert pool.reap_missed_releases(self.session) == 0
|
|
assert self._status(lease.id) == "active"
|
|
|
|
def test_one_running_task_protects_the_whole_batch(self):
|
|
first = self._lease("m4a", batch_id="batch-m4")
|
|
second = self._lease("m4b", batch_id="batch-m4")
|
|
self._task("batch-m4", first.account_id, "success")
|
|
self._task("batch-m4", second.account_id, "pending")
|
|
|
|
# 批次里还有 pending → 两条都不动(正在跑的那条可能还没结束)
|
|
assert pool.reap_missed_releases(self.session) == 0
|
|
assert self._status(first.id) == "active"
|
|
|
|
|
|
class TestReaperJob(_ReaperBase):
|
|
def test_expires_ttl_leases(self):
|
|
lease = self._lease("j1", batch_id="batch-j1", ttl_minutes=1)
|
|
self._task("batch-j1", lease.account_id, "running")
|
|
|
|
job = AutomationJob(key=reaper.JOB_KEY, batch_size=100)
|
|
result = reaper.run_account_lease_reaper(
|
|
self.session, job, now=NOW + timedelta(minutes=5)
|
|
)
|
|
|
|
assert result["expired"] >= 1
|
|
assert self._status(lease.id) == "expired"
|
|
|
|
def test_releases_orphan_batch(self):
|
|
"""注册表里没有这个批次(模拟进程重启)→ 即使任务还是 running 也要收。"""
|
|
lease = self._lease("j2", batch_id="batch-gone")
|
|
self._task("batch-gone", lease.account_id, "running")
|
|
|
|
job = AutomationJob(key=reaper.JOB_KEY, batch_size=100)
|
|
result = reaper.run_account_lease_reaper(self.session, job)
|
|
|
|
assert result["orphan_batch"] >= 1
|
|
assert self._status(lease.id) == "expired"
|
|
|
|
def test_message_is_honest_about_counts(self):
|
|
self._lease("j3", batch_id="batch-j3", ttl_minutes=1)
|
|
job = AutomationJob(key=reaper.JOB_KEY, batch_size=100)
|
|
result = reaper.run_account_lease_reaper(self.session, job)
|
|
assert result["scanned"] == (
|
|
result["expired"] + result["missed_release"] + result["orphan_batch"]
|
|
)
|
|
assert "租约回收" in result["message"]
|
|
|
|
def test_idempotent_second_run_finds_nothing(self):
|
|
lease = self._lease("j4", batch_id="batch-gone")
|
|
self._task("batch-gone", lease.account_id, "running")
|
|
job = AutomationJob(key=reaper.JOB_KEY, batch_size=100)
|
|
reaper.run_account_lease_reaper(self.session, job)
|
|
second = reaper.run_account_lease_reaper(self.session, job)
|
|
assert second["scanned"] == 0
|
|
assert self._status(lease.id) == "expired"
|
|
|
|
def test_freed_account_becomes_available_again(self):
|
|
lease = self._lease("j5", batch_id="batch-gone")
|
|
account_id = lease.account_id
|
|
job = AutomationJob(key=reaper.JOB_KEY, batch_size=100)
|
|
reaper.run_account_lease_reaper(self.session, job)
|
|
|
|
candidates = pool.eligible_accounts(
|
|
self.session,
|
|
score=1280,
|
|
commodity_id="20260101X_bag1",
|
|
now=NOW + timedelta(minutes=5),
|
|
)
|
|
assert account_id in {c.account_id for c in candidates}
|