feat(admin): Phase 7 计费审计+4层额度策略 — 流水浏览/手动调额/额度策略CRUD+拦截
后端:adminpanel ledgers(全局流水+筛)+ adjust(手动调额,落 ADJUSTMENT)+ quota-policies CRUD; 额度拦截 _enforce_quota_policy(单任务/月度/项目)接入 reserve_credit,仅团队有 active 策略才生效(无策略零回归); adjust_credit helper。修真 bug:log_admin_action 加 savepoint + JSON 安全化(UUID/Decimal), 修复传 serializer.data 致审计报错污染外层事务 → TransactionManagementError。 前端:adminApi 计费/额度系列;计费审计页(流水表+手动调额弹窗)+ 额度策略页(CRUD 弹窗,团队下拉+三类上限+启用)。 测试:adminpanel 44 + billing + accounts = 70 单测过(调额±/超额拒/额度拦截 per_task+monthly/无策略零回归); 无头 e2e _admin-p7.mjs 6 断言过 + 0 console error(一次性团队,不动 demo 余额);tsc+build 绿。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ca1e50d32c
commit
39b4467258
@@ -1,3 +1,4 @@
|
||||
from decimal import Decimal
|
||||
from unittest.mock import patch
|
||||
|
||||
from rest_framework.test import APIClient
|
||||
@@ -367,3 +368,88 @@ class AdminTaskMonitorTests(TestCase):
|
||||
|
||||
def test_retry_requires_admin(self):
|
||||
self.assertEqual(self.nc.post(f"/api/admin/tasks/{self.t_failed.id}/retry/").status_code, 403)
|
||||
|
||||
|
||||
class AdminBillingTests(TestCase):
|
||||
"""Phase 7:计费审计(流水浏览/手动调额)+ 4 层额度策略(CRUD + 拦截生效)+ 权限。"""
|
||||
|
||||
def setUp(self):
|
||||
from apps.billing.models import CreditAccount, CreditLedger
|
||||
|
||||
self.admin = User.objects.create_user(username="padmin7", password="x", is_platform_admin=True)
|
||||
self.normal = User.objects.create_user(username="normal7", password="x")
|
||||
self.team = Team.objects.create(name="BillTeam", owner=self.normal)
|
||||
TeamMember.objects.create(team=self.team, user=self.normal, role=TeamMember.Role.OWNER)
|
||||
self.account = CreditAccount.objects.create(team=self.team, balance="100.0000")
|
||||
CreditLedger.objects.create(team=self.team, ledger_type=CreditLedger.Type.RECHARGE, amount="100", balance_after="100", reason="init")
|
||||
CreditLedger.objects.create(team=self.team, ledger_type=CreditLedger.Type.CHARGE, amount="10", balance_after="90", reason="gen")
|
||||
self.ac = APIClient()
|
||||
self.ac.force_authenticate(self.admin)
|
||||
self.nc = APIClient()
|
||||
self.nc.force_authenticate(self.normal)
|
||||
|
||||
def test_ledger_browse_permission_and_filter(self):
|
||||
self.assertEqual(self.nc.get("/api/admin/ledgers/").status_code, 403)
|
||||
r = self.ac.get("/api/admin/ledgers/")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertGreaterEqual(r.data["count"], 2)
|
||||
charge = self.ac.get("/api/admin/ledgers/?ledger_type=charge")
|
||||
self.assertTrue(all(item["ledger_type"] == "charge" for item in charge.data["results"]))
|
||||
|
||||
def test_adjust_positive_updates_balance_and_audit(self):
|
||||
r = self.ac.post("/api/admin/ledgers/adjust/", {"team": str(self.team.id), "amount": "50", "reason": "补偿"}, format="json")
|
||||
self.assertEqual(r.status_code, 201)
|
||||
self.assertEqual(r.data["ledger_type"], "adjustment")
|
||||
self.account.refresh_from_db()
|
||||
self.assertEqual(self.account.balance, Decimal("150.0000"))
|
||||
self.assertTrue(AdminAuditLog.objects.filter(action="credit.adjust").exists())
|
||||
|
||||
def test_adjust_negative(self):
|
||||
r = self.ac.post("/api/admin/ledgers/adjust/", {"team": str(self.team.id), "amount": "-30", "reason": "扣"}, format="json")
|
||||
self.assertEqual(r.status_code, 201)
|
||||
self.account.refresh_from_db()
|
||||
self.assertEqual(self.account.balance, Decimal("70.0000"))
|
||||
|
||||
def test_adjust_beyond_balance_rejected(self):
|
||||
r = self.ac.post("/api/admin/ledgers/adjust/", {"team": str(self.team.id), "amount": "-9999", "reason": "x"}, format="json")
|
||||
self.assertEqual(r.status_code, 400)
|
||||
|
||||
def test_adjust_requires_admin(self):
|
||||
self.assertEqual(
|
||||
self.nc.post("/api/admin/ledgers/adjust/", {"team": str(self.team.id), "amount": "1"}, format="json").status_code,
|
||||
403,
|
||||
)
|
||||
|
||||
def test_quota_policy_crud(self):
|
||||
self.assertEqual(self.nc.get("/api/admin/quota-policies/").status_code, 403)
|
||||
c = self.ac.post("/api/admin/quota-policies/", {"team": str(self.team.id), "per_task_limit": "5", "is_active": True}, format="json")
|
||||
self.assertEqual(c.status_code, 201)
|
||||
pid = c.data["id"]
|
||||
self.assertEqual(self.ac.get(f"/api/admin/quota-policies/?team={self.team.id}").data["count"], 1)
|
||||
up = self.ac.patch(f"/api/admin/quota-policies/{pid}/", {"per_task_limit": "9"}, format="json")
|
||||
self.assertEqual(up.data["per_task_limit"], "9.0000")
|
||||
self.assertEqual(self.ac.delete(f"/api/admin/quota-policies/{pid}/").status_code, 204)
|
||||
|
||||
def test_quota_enforcement_per_task_and_guard(self):
|
||||
from apps.billing.models import QuotaPolicy
|
||||
from apps.billing.services.ledger import _enforce_quota_policy
|
||||
|
||||
# 无策略 → 不拦截(零回归)
|
||||
_enforce_quota_policy(team=self.team, project=None, amount=Decimal("999"))
|
||||
policy = QuotaPolicy.objects.create(team=self.team, per_task_limit=Decimal("1"), is_active=True)
|
||||
with self.assertRaises(ValueError):
|
||||
_enforce_quota_policy(team=self.team, project=None, amount=Decimal("5"))
|
||||
_enforce_quota_policy(team=self.team, project=None, amount=Decimal("1")) # 限内放行
|
||||
# 停用策略 → 不拦截
|
||||
policy.is_active = False
|
||||
policy.save(update_fields=["is_active"])
|
||||
_enforce_quota_policy(team=self.team, project=None, amount=Decimal("999"))
|
||||
|
||||
def test_quota_enforcement_monthly(self):
|
||||
from apps.billing.models import QuotaPolicy
|
||||
from apps.billing.services.ledger import _enforce_quota_policy
|
||||
|
||||
QuotaPolicy.objects.create(team=self.team, monthly_limit=Decimal("10"), is_active=True)
|
||||
# setUp 本月已有 CHARGE 10 → 再 +1 超 10 → 拦
|
||||
with self.assertRaises(ValueError):
|
||||
_enforce_quota_policy(team=self.team, project=None, amount=Decimal("1"))
|
||||
|
||||
Reference in New Issue
Block a user