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:
@@ -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}
|
||||
@@ -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
|
||||
|
||||
@@ -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 == ""
|
||||
@@ -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
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user