"""账号池接口:供给预览、分配下单、租约释放。 **这是新增的一条路**,所以用例同时守住「新增的能用」与「旧的没被动」: - 池路径要能领到号、建出任务、并让租用人看得到自己的任务; - `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_elite_status", "query_gold_balance", "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