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:
seaislee1209
2026-06-19 22:33:52 +08:00
co-authored by Claude Opus 4.8
parent ca1e50d32c
commit 39b4467258
13 changed files with 833 additions and 13 deletions
+86
View File
@@ -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"))