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
This commit is contained in:
yml2213
2026-10-07 12:29:08 +08:00
parent 281769b440
commit 18bc7be4b1
38 changed files with 5143 additions and 35 deletions
+177
View File
@@ -0,0 +1,177 @@
"""租约兜底回收:漏释放、孤儿批次、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}
+56
View File
@@ -1031,6 +1031,62 @@ class TestOfficialExchangeBecomesAccountEvent:
assert event.detail["source"] == "official_sync"
assert event.source == "official_sync"
def test_subject_key_is_commodity_id(self):
"""D1:官方补录的事件必须写 `subject_key=商品ID`。
2026-10-07 实测:生产 10,612 条 `official_sync` 事件的 `subject_key`
**全是空串**(`record_official_exchange` 漏传),而 365 天同皮肤规则与
`ix_account_events_ledger` 都按 `subject_key` 判定 —— 不修会漏掉 87% 的历史。
断言直接查 **DB 行**(不是只看返回值),并顺带确认 `subject_type`
与 `record_exchange`(我方任务直发)统一为 `commodity`。
"""
from web.backend.services import account_events_service as svc
assert self._record(commodity_id="20260120QYOOB_bag10") is True
self.session.commit()
event = (
self.session.query(AccountEvent)
.filter(AccountEvent.event_type == svc.EVENT_EXCHANGE)
.one()
)
assert event.subject_key == "20260120QYOOB_bag10", (
"365 天规则按 subject_key 查;这里为空等于查不到历史"
)
assert event.subject_type == "commodity"
def test_subject_key_makes_365d_lookup_work(self):
"""按 `subject_key` 能查到、按空串查不到(反向验证 D1)。
`subject_key` 一旦再次漏写,这条与上面那条都会变红。
"""
from web.backend.services import account_events_service as svc
assert self._record(commodity_id="20260120QYOOB_bag10") is True
self.session.commit()
hit = (
self.session.query(AccountEvent)
.filter(
AccountEvent.account_id == self.account.id,
AccountEvent.event_type == svc.EVENT_EXCHANGE,
AccountEvent.source == svc.OFFICIAL_SYNC_SOURCE,
AccountEvent.subject_key == "20260120QYOOB_bag10",
)
.count()
)
assert hit == 1
empty = (
self.session.query(AccountEvent)
.filter(
AccountEvent.account_id == self.account.id,
AccountEvent.event_type == svc.EVENT_EXCHANGE,
AccountEvent.source == svc.OFFICIAL_SYNC_SOURCE,
AccountEvent.subject_key == "",
)
.count()
)
assert empty == 0
def test_is_idempotent(self):
"""一键查询会被反复执行,同一条官方记录只能落一条事件。"""
from web.backend.services import account_events_service as svc
+503
View File
@@ -0,0 +1,503 @@
"""账号池:候选筛选、两梯队排序、租约领取与释放。
分两层守:
- **纯函数**(充值量、排序、拆单)不碰数据库,直接喂数据断言 ——
这几条是业务规则的集中地,改错了必须立刻变红;
- **租约**(唯一约束、并发抢号、按任务释放)必须打真实 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) == []
@@ -0,0 +1,137 @@
"""D1 回填脚本:把官方补录事件的 `subject_key` 补上商品 ID。
脚本是一次性数据修复,但它是**生产数据**的修复 —— 写错了会污染台账,
所以守住三件事:幂等、只填空值、dry-run 不写库。
"""
from __future__ import annotations
import importlib.util
import sys
from datetime import UTC, datetime
from pathlib import Path
import pytest
from tests.support.db import get_test_engine, make_test_session
from web.backend.models import Account, AccountEvent
SCRIPT_PATH = (
Path(__file__).resolve().parents[1]
/ "scripts"
/ "backfill_official_exchange_subject_key.py"
)
NOW = datetime(2026, 10, 7, 3, 0, 0, tzinfo=UTC)
def _load_script():
spec = importlib.util.spec_from_file_location(
"backfill_official_exchange_subject_key", SCRIPT_PATH
)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
@pytest.fixture
def script():
return _load_script()
def _run(script, monkeypatch, *argv: str) -> int:
monkeypatch.setattr(sys, "argv", [str(SCRIPT_PATH), *argv])
return script.main()
class TestBackfillOfficialExchangeSubjectKey:
def setup_method(self):
self.engine = get_test_engine()
self.session = make_test_session(self.engine)
self.account = Account(
username="bf-acc", password="p", email="b@e.com", email_password="ep"
)
self.session.add(self.account)
self.session.flush()
self.account_id = self.account.id
def teardown_method(self):
self.session.close()
def _add_event(self, *, commodity_id: str | None, subject_key: str = "") -> int:
event = AccountEvent(
account_id=self.account_id,
event_type="exchange",
category="business",
occurred_at=NOW,
title="兑换 x",
amount=-1280,
subject_type="goods",
subject_key=subject_key,
detail=(
{"source": "official_sync", "commodity_id": commodity_id}
if commodity_id is not None
else {"source": "official_sync"}
),
source="official_sync",
)
self.session.add(event)
self.session.commit()
return event.id
def _reload(self, event_id: int) -> AccountEvent:
"""重新读一行 —— **必须先 rollback 结束当前事务**。
脚本用的是**另一个会话**,在 MySQL REPEATABLE READ 下,本会话若沿用旧快照,
`expire_all()` 也只会拿到事务开始时的老值:脚本改没改都显示没改,
于是「dry-run 不写库」这类断言会**假绿**。
"""
self.session.rollback()
row = self.session.get(AccountEvent, event_id)
assert row is not None
return row
def test_dry_run_writes_nothing(self, script, monkeypatch):
event_id = self._add_event(commodity_id="20260120QYOOB_bag10")
assert _run(script, monkeypatch, "--dry-run") == 0
assert self._reload(event_id).subject_key == "", "dry-run 不该写库"
def test_apply_fills_subject_key(self, script, monkeypatch):
event_id = self._add_event(commodity_id="20260120QYOOB_bag10")
assert _run(script, monkeypatch, "--apply") == 0
row = self._reload(event_id)
assert row.subject_key == "20260120QYOOB_bag10"
assert row.subject_type == "commodity"
def test_is_idempotent(self, script, monkeypatch):
event_id = self._add_event(commodity_id="20260120QYOOB_bag10")
assert _run(script, monkeypatch, "--apply") == 0
# 第二遍没有可回填的行,应当直接退出且不改动
assert _run(script, monkeypatch, "--apply") == 0
assert self._reload(event_id).subject_key == "20260120QYOOB_bag10"
def test_does_not_overwrite_existing_subject_key(self, script, monkeypatch):
"""只填空值 —— 已有值(哪怕看着不对)不由脚本判断。"""
event_id = self._add_event(
commodity_id="20260120QYOOB_bag10", subject_key="手工填的值"
)
assert _run(script, monkeypatch, "--apply") == 0
assert self._reload(event_id).subject_key == "手工填的值"
def test_skips_events_without_commodity_id(self, script, monkeypatch):
"""没有 `detail.commodity_id` 的行不动,也不该报失败退出码。"""
event_id = self._add_event(commodity_id=None)
# 没有可回填的行 → 正常退出(不因为「缺 commodity_id」而误报)
assert _run(script, monkeypatch, "--apply") == 0
assert self._reload(event_id).subject_key == ""
+342
View File
@@ -0,0 +1,342 @@
"""账号池接口:供给预览、分配下单、租约释放。
**这是新增的一条路**,所以用例同时守住「新增的能用」与「旧的没被动」:
- 池路径要能领到号、建出任务、并让租用人看得到自己的任务;
- `create_douyu_planned_tasks` 不传新参数时行为必须与以前一模一样。
"""
from __future__ import annotations
import asyncio
from datetime import UTC, datetime
import pytest
from fastapi import HTTPException
from tests.support.db import get_test_engine, make_test_session
from web.backend.models import (
Account,
AccountLease,
DouyuGoodsSnapshot,
DouyuTask,
User,
)
from web.backend.routers import douyu_pool as pool_router
from web.backend.schemas import DouyuPoolExchangeRequest
from web.backend.services import account_pool as pool
COMMODITY = "20260120QYOOB_bag10"
NOW = datetime(2026, 10, 7, 12, 0, 0, tzinfo=UTC)
class _ApiBase:
def setup_method(self, monkeypatch=None):
self.engine = get_test_engine()
self.session = make_test_session(self.engine)
self.admin = User(username="api-admin", password_hash="h", role="super_admin")
self.support = User(username="api-kf", password_hash="h", role="support")
self.other = User(username="api-kf2", password_hash="h", role="support")
self.session.add_all([self.admin, self.support, self.other])
self.session.flush()
self.admin_id = self.admin.id
self.support_id = self.support.id
self.other_id = self.other.id
self.session.add(
DouyuGoodsSnapshot(
commodity_id=COMMODITY,
name="套装-兔兔白日梦",
score=1280,
)
)
self.session.commit()
def teardown_method(self):
self.session.close()
def make_account(self, name, *, points=5000, opened=True):
account = Account(
username=name,
password="p",
email="e@e.com",
email_password="ep",
status="active",
ck_check_status="valid",
cookie="PHPSESSID=x",
elite_opened=opened,
points=points,
)
self.session.add(account)
self.session.commit()
return account
class TestSupply(_ApiBase):
def test_counts_and_channel(self):
self.make_account("rich", points=5000)
self.make_account("poor", points=0)
self.make_account("closed", opened=False, points=5000)
supply = pool_router.pool_supply(
scope="elite", commodity_id=COMMODITY, db=self.session, current=self.support
)
assert supply["zero_topup"] == 1
assert supply["after_topup"] == 2
assert supply["blocked"]["elite_not_opened"] == 1
assert supply["commodity"]["name"] == "套装-兔兔白日梦"
assert supply["commodity"]["score"] == 1280
# 生产当前是微信扫码 → 前端要提前告诉客服"这单得你去扫"
assert supply["recharge_channel"] == "wechat_qr"
assert supply["manual_payment_required"] is True
def test_missing_goods_is_404(self):
with pytest.raises(HTTPException) as exc:
pool_router.pool_supply(
scope="elite",
commodity_id="nope",
db=self.session,
current=self.support,
)
assert exc.value.status_code == 404
def test_unsupported_scope_is_400(self):
with pytest.raises(HTTPException) as exc:
pool_router.pool_supply(
scope="peace",
commodity_id=COMMODITY,
db=self.session,
current=self.support,
)
assert exc.value.status_code == 400
class TestExchange(_ApiBase):
def _patch_runner(self, monkeypatch, captured: list):
def fake_start(**kwargs):
captured.append(kwargs)
monkeypatch.setattr(pool_router, "start_douyu_batch", fake_start)
def _exchange(self, monkeypatch, **overrides):
captured: list = []
self._patch_runner(monkeypatch, captured)
payload = {
"scope": "elite",
"commodity_id": COMMODITY,
"quantity": 1,
}
payload.update(overrides)
result = asyncio.run(
pool_router.pool_exchange(
DouyuPoolExchangeRequest(**payload),
db=self.session,
current=self.support,
)
)
return result, captured
def test_allocates_creates_tasks_and_binds_batch(self, monkeypatch):
account = self.make_account("a1", points=5000)
result, captured = self._exchange(monkeypatch)
assert result.allocated == 1 and result.shortfall == 0
assert [a.account_id for a in result.accounts] == [account.id]
assert captured and captured[0]["batch_id"] == result.batch_id
lease = self.session.query(AccountLease).one()
assert lease.status == "active"
assert lease.batch_id == result.batch_id
assert lease.commodity_id == COMMODITY
task = self.session.query(DouyuTask).one()
assert task.task_type == "exchange_goods"
assert task.handbook_scope == "elite"
assert task.batch_id == result.batch_id
assert task.result["payload"]["commodity_id"] == COMMODITY
assert task.result["payload"]["num"] == 1
def test_task_is_visible_to_holder_not_to_account_owner(self, monkeypatch):
"""池化后账号归属是运营;任务必须写成**租用人**,否则客服看不到自己的单。"""
self.make_account("a2", points=5000)
result, _ = self._exchange(monkeypatch)
task = self.session.query(DouyuTask).one()
assert task.account_assigned_to == self.support_id
assert task.account_assigned_to != self.admin_id
assert result.batch_id
def test_shortfall_when_not_enough_accounts(self, monkeypatch):
self.make_account("only", points=5000)
result, _ = self._exchange(monkeypatch, quantity=3)
assert result.allocated == 1
assert result.shortfall == 2
assert any("可用账号不足" in reason for reason in result.reasons)
def test_allow_topup_false_excludes_tier_b(self, monkeypatch):
self.make_account("poor", points=0)
result, captured = self._exchange(monkeypatch, allow_topup=False)
assert result.allocated == 0
assert captured == [], "没领到号就不该启动批次"
assert any("未允许充值" in reason for reason in result.reasons)
def test_tier_b_reports_recharge_plan(self, monkeypatch):
self.make_account("poor", points=300)
result, _ = self._exchange(monkeypatch)
account = result.accounts[0]
assert account.tier == "B"
assert account.points == 300
assert account.topup_gifts == 98 # (1280-300)/10
assert account.topup_yuan == 98
assert account.recharge_orders == [98]
def test_tier_b_split_orders_above_800(self, monkeypatch):
"""单次 800 元上限:缺口超过时拆单,而不是被拒。"""
self.session.add(
DouyuGoodsSnapshot(
commodity_id="20260120QYOOB_big", name="仙剑奇侠传大礼包", score=9000
)
)
self.session.commit()
self.make_account("poor2", points=0)
result, _ = self._exchange(monkeypatch, commodity_id="20260120QYOOB_big")
account = result.accounts[0]
assert account.topup_yuan == 900
assert account.recharge_orders == [800, 100]
def test_tier_b_creates_full_supply_chain(self, monkeypatch):
"""B 梯队要排出完整补给链,且每一步都指向前一步(运行期的 `_depends_on`)。"""
self.make_account("chain", points=300)
result, _ = self._exchange(monkeypatch)
assert result.allocated == 1
tasks = self.session.query(DouyuTask).order_by(DouyuTask.id.asc()).all()
assert [t.task_type for t in tasks] == [
"query_gold_balance",
"query_points",
"create_gold_qr",
"donate_elite_gift",
"exchange_goods",
]
# 前两步(查余额)没有载荷 —— `create_douyu_task` 对空载荷存 None,与旧行为一致
payloads = [(t.result or {}).get("payload", {}) for t in tasks]
assert payloads[2]["amount"] == 98 # (1280-300)/10
assert payloads[2]["_skip_when"]["column"] == "gold_balance"
assert payloads[3]["gift_count"] == 98
assert payloads[3]["_skip_when"]["column"] == "points"
assert "_depends_on" not in payloads[0]
assert payloads[1]["_depends_on"] == tasks[0].id
assert payloads[2]["_depends_on"] == tasks[1].id
assert payloads[3]["_depends_on"] == tasks[2].id
assert payloads[4]["_depends_on"] == tasks[3].id
# 每条链上的任务都要让租用人看得到
assert {t.account_assigned_to for t in tasks} == {self.support_id}
def test_tier_a_creates_single_task(self, monkeypatch):
"""积分已够的账号**不生成任何充值/赠令步骤** —— 那是在花钱。"""
self.make_account("rich2", points=5000)
self._exchange(monkeypatch)
tasks = self.session.query(DouyuTask).all()
assert [t.task_type for t in tasks] == ["exchange_goods"]
def test_no_accounts_does_not_start_batch(self, monkeypatch):
result, captured = self._exchange(monkeypatch)
assert result.allocated == 0
assert result.shortfall == 1
assert captured == []
class TestLeaseEndpoints(_ApiBase):
def _lease(self, holder_id):
account = self.make_account(f"l{holder_id}", points=5000)
return pool.lease_accounts(
self.session,
holder_user_id=holder_id,
candidates=[
pool.make_candidate(
account_id=account.id,
username=account.username,
points=5000,
score=1280,
)
],
commodity_id=COMMODITY,
)[0]
def test_release_own_lease(self):
lease = self._lease(self.support_id)
result = pool_router.release_lease(
lease.id, db=self.session, current=self.support
)
assert result["released"] is True
self.session.expire_all()
assert (
self.session.query(AccountLease)
.filter(AccountLease.id == lease.id)
.one()
.status
== "released"
)
def test_cannot_release_someone_elses_lease(self):
lease = self._lease(self.support_id)
with pytest.raises(HTTPException) as exc:
pool_router.release_lease(lease.id, db=self.session, current=self.other)
assert exc.value.status_code == 403
def test_pool_admin_can_release_others(self):
lease = self._lease(self.support_id)
result = pool_router.release_lease(
lease.id, db=self.session, current=self.admin
)
assert result["released"] is True
def test_release_batch_requires_admin_permission(self):
lease = self._lease(self.support_id)
pool.bind_leases_to_batch(self.session, [lease], "batch-x")
result = pool_router.release_batch(
"batch-x", db=self.session, current=self.admin
)
assert result["released"] == 1
def test_list_leases_only_mine(self):
self._lease(self.support_id)
self._lease(self.other_id)
rows = pool_router.list_leases(
mine=True,
account_id=None,
include_finished=False,
db=self.session,
current=self.support,
)
assert len(rows) == 1
assert rows[0].status == "active"
class TestLegacyTaskCreationUnchanged(_ApiBase):
def test_override_defaults_to_account_owner(self):
"""不传新参数时 `account_assigned_to` 仍取账号归属 —— 旧路径行为不变。"""
from web.backend.services.douyu_service import create_douyu_planned_tasks
account = Account(
username="legacy",
password="p",
email="e@e.com",
email_password="ep",
status="active",
ck_check_status="valid",
cookie="PHPSESSID=x",
assigned_to=self.support_id,
)
self.session.add(account)
self.session.commit()
batch_id, count = create_douyu_planned_tasks(
self.session,
[account.id],
"exchange_goods",
"elite",
self.support_id,
{"commodity_id": COMMODITY},
)
assert count == 1
task = (
self.session.query(DouyuTask).filter(DouyuTask.batch_id == batch_id).one()
)
assert task.account_assigned_to == self.support_id
+1 -1
View File
@@ -26,7 +26,7 @@ TESTS_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = TESTS_DIR.parent
VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions"
HEAD_REVISION = "20261004_0051"
HEAD_REVISION = "20261007_0052"
# 迁移新增、但不声明在模型里的复合查询索引。
EXTRA_INDEXES = (
+217
View File
@@ -0,0 +1,217 @@
"""池化后的账号可见性与操作流水。
两条都是**新增**的语义,且直接决定客服能不能干活:
1. 客服租用的账号要能看到(否则刚做完的单查不了);
2. 流水要能看出「谁做的」(71% 的历史事件没有操作人,这是要补的)。
"""
from __future__ import annotations
from datetime import UTC, datetime
import pytest
from fastapi import HTTPException
from tests.support.db import get_test_engine, make_test_session
from web.backend.models import Account, AccountEvent, User
from web.backend.routers.accounts import account_events, list_accounts
from web.backend.services import account_pool as pool
NOW = datetime(2026, 10, 7, 15, 0, 0, tzinfo=UTC)
class _Base:
def setup_method(self):
self.engine = get_test_engine()
self.session = make_test_session(self.engine)
self.operator = User(username="vis-op", password_hash="h", role="operation")
self.support = User(username="vis-kf", password_hash="h", role="support")
self.session.add_all([self.operator, self.support])
self.session.flush()
self.operator_id = self.operator.id
self.support_id = self.support.id
def teardown_method(self):
self.session.close()
def make_account(self, name: str, *, assigned_to=None) -> Account:
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,
assigned_to=assigned_to,
)
self.session.add(account)
self.session.commit()
return account
def lease(self, account: Account, holder_id: int):
return pool.lease_accounts(
self.session,
holder_user_id=holder_id,
candidates=[
pool.make_candidate(
account_id=account.id,
username=account.username,
points=5000,
score=1280,
)
],
commodity_id="20260120QYOOB_bag10",
)[0]
class TestVisibleAccountsWithLease(_Base):
def _visible_names(self, user) -> set[str]:
rows = list_accounts(
db=self.session,
current=user,
page=None,
page_size=100,
search="",
tag=None,
status="",
ck_status="",
bind_status="",
has_cookie=False,
assign_status="",
assigned_user_id=None,
high_value_filter="",
min_points=None,
max_points=None,
min_gold=None,
max_gold=None,
elite_opened=None,
esports_opened=None,
role_bind_status="",
sort=None,
order=None,
include_sensitive=False,
)
items = rows["items"] if isinstance(rows, dict) else rows
# 走 `model_dump()`:只看属性会漏掉「模型上声明了、序列化里没列」这类
# Pydantic 静默丢字段(本仓库已经踩过三次)。
return {item.model_dump()["username"] for item in items}
def test_leased_account_is_visible_to_holder(self):
account = self.make_account("leased-vis", assigned_to=self.operator_id)
self.lease(account, self.support_id)
assert "leased-vis" in self._visible_names(self.support)
def test_unleased_operator_account_is_not_visible(self):
self.make_account("hidden-vis", assigned_to=self.operator_id)
assert "hidden-vis" not in self._visible_names(self.support)
def test_visibility_ends_after_release(self):
account = self.make_account("temp-vis", assigned_to=self.operator_id)
lease = self.lease(account, self.support_id)
pool.release_lease(self.session, lease, reason="done")
assert "temp-vis" not in self._visible_names(self.support)
class TestAccountEventActor(_Base):
def _add_event(self, account_id: int, *, actor_id=None, actor_type=""):
self.session.add(
AccountEvent(
account_id=account_id,
event_type="recharge",
category="business",
occurred_at=NOW,
title="充值 48",
amount=48,
subject_type="recharge",
detail={"channel": "wechat_qr"},
source="douyu_task",
actor_type=actor_type,
actor_id=actor_id,
)
)
self.session.commit()
def test_events_return_actor_name(self):
account = self.make_account("ev-1", assigned_to=self.support_id)
self._add_event(account.id, actor_id=self.support_id, actor_type="user")
result = account_events(
account.id,
page=1,
page_size=50,
event_type="",
category="",
actor_id=None,
db=self.session,
current=self.support,
)
assert result["items"][0]["actor_name"] == "vis-kf"
# 序列化结果里必须真有这两个字段(Pydantic/dict 静默丢字段在本仓库踩过)
assert result["items"][0]["actor_id"] == self.support_id
def test_filter_by_actor_id(self):
account = self.make_account("ev-2", assigned_to=self.operator_id)
self._add_event(account.id, actor_id=self.support_id, actor_type="user")
self._add_event(account.id, actor_id=self.operator_id, actor_type="user")
mine = account_events(
account.id,
page=1,
page_size=50,
event_type="",
category="",
actor_id=self.support_id,
db=self.session,
current=self.operator,
)
assert mine["total"] == 1
assert mine["items"][0]["actor_id"] == self.support_id
def test_actorless_event_still_returned(self):
"""历史事件没有操作人(71%)—— 不能因此被过滤掉或报错。"""
account = self.make_account("ev-3", assigned_to=self.support_id)
self._add_event(account.id)
result = account_events(
account.id,
page=1,
page_size=50,
event_type="",
category="",
actor_id=None,
db=self.session,
current=self.support,
)
assert result["total"] == 1
assert result["items"][0]["actor_name"] == ""
def test_timeline_of_leased_account_is_readable(self):
account = self.make_account("ev-4", assigned_to=self.operator_id)
self._add_event(account.id, actor_id=self.support_id, actor_type="user")
# 没有租约时客服看不到这个账号
with pytest.raises(HTTPException):
account_events(
account.id,
page=1,
page_size=50,
event_type="",
category="",
actor_id=None,
db=self.session,
current=self.support,
)
# 租下来就能看(这正是池化后客服复盘自己那单的路径)
self.lease(account, self.support_id)
result = account_events(
account.id,
page=1,
page_size=50,
event_type="",
category="",
actor_id=None,
db=self.session,
current=self.support,
)
assert result["total"] == 1
+278
View File
@@ -0,0 +1,278 @@
"""补给链:缺多少充多少、该跳过的跳过、前置失败就别往下跑。
链上每一步都是**真花钱**的动作(充鱼翅、送精英令),所以两道运行期闸门
(`_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"