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
@@ -5,6 +5,7 @@ from rest_framework import serializers
from apps.accounts.models import Team, TeamMember, User
from apps.ai.models import AITask, QualityWord
from apps.assets.models import Asset
from apps.billing.models import CreditLedger, QuotaPolicy
# 成本异常阈值:实际成本 > 预估 × 此倍数(且预估 > 0)即标异常
COST_ANOMALY_RATIO = Decimal("1.5")
@@ -110,3 +111,25 @@ class AdminTaskDetailSerializer(AdminTaskSerializer):
"error_message", "submitted_at", "completed_at",
]
read_only_fields = fields
class AdminLedgerSerializer(serializers.ModelSerializer):
team_name = serializers.CharField(source="team.name", read_only=True, default=None)
username = serializers.CharField(source="user.username", read_only=True, default=None)
class Meta:
model = CreditLedger
fields = ["id", "team", "team_name", "username", "ledger_type", "amount", "balance_after", "reason", "created_at"]
read_only_fields = fields
class AdminQuotaPolicySerializer(serializers.ModelSerializer):
team_name = serializers.CharField(source="team.name", read_only=True, default=None)
class Meta:
model = QuotaPolicy
fields = [
"id", "team", "team_name", "user", "project",
"monthly_limit", "project_limit", "per_task_limit", "is_active", "created_at",
]
read_only_fields = ["id", "team_name", "created_at"]
+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"))
+8
View File
@@ -5,6 +5,10 @@ from .views import (
admin_asset_reviews_poll,
admin_asset_reviews_submit,
admin_invitations,
admin_ledger_adjust,
admin_ledgers,
admin_quota_policies,
admin_quota_policy_detail,
admin_task_detail,
admin_task_retry,
admin_tasks,
@@ -37,4 +41,8 @@ urlpatterns = [
path("tasks/", admin_tasks, name="admin-tasks"),
path("tasks/<uuid:task_id>/", admin_task_detail, name="admin-task-detail"),
path("tasks/<uuid:task_id>/retry/", admin_task_retry, name="admin-task-retry"),
path("ledgers/", admin_ledgers, name="admin-ledgers"),
path("ledgers/adjust/", admin_ledger_adjust, name="admin-ledger-adjust"),
path("quota-policies/", admin_quota_policies, name="admin-quota-policies"),
path("quota-policies/<uuid:policy_id>/", admin_quota_policy_detail, name="admin-quota-policy-detail"),
]
+98
View File
@@ -15,10 +15,14 @@ from apps.accounts.serializers import InvitationSerializer
from apps.ai.models import AITask, QualityWord
from apps.assets.models import Asset
from apps.assets.review import poll_asset_review, submit_asset_for_review
from apps.billing.models import CreditLedger, QuotaPolicy
from apps.billing.services.ledger import adjust_credit
from apps.common.pagination import DefaultPagination
from .serializers import (
COST_ANOMALY_RATIO,
AdminLedgerSerializer,
AdminQuotaPolicySerializer,
AdminReviewAssetSerializer,
AdminTaskDetailSerializer,
AdminTaskSerializer,
@@ -399,3 +403,97 @@ def admin_task_retry(request, task_id):
target_name=task.task_type,
)
return Response({"retried": True, "task_id": str(task.id)})
# ─────────────────────────── 计费审计 + 额度策略 ───────────────────────────
@api_view(["GET"])
@permission_classes([IsPlatformAdmin])
def admin_ledgers(request):
"""全局信用流水浏览(?ledger_type= / ?team= 筛 + 分页)。"""
qs = CreditLedger.objects.select_related("team", "user").order_by("-created_at")
lt = request.query_params.get("ledger_type")
if lt in dict(CreditLedger.Type.choices):
qs = qs.filter(ledger_type=lt)
team_id = request.query_params.get("team")
if team_id:
qs = qs.filter(team_id=team_id)
paginator = DefaultPagination()
page = paginator.paginate_queryset(qs, request)
return paginator.get_paginated_response(AdminLedgerSerializer(page, many=True).data)
@api_view(["POST"])
@permission_classes([IsPlatformAdmin])
def admin_ledger_adjust(request):
"""手动调额(争议补偿):{team, amount, reason}。amount 可正可负,落 ADJUSTMENT 流水。"""
from decimal import InvalidOperation
team_id = request.data.get("team") or request.data.get("team_id")
team = Team.objects.filter(id=team_id).first()
if team is None:
return Response({"detail": "团队不存在"}, status=status.HTTP_404_NOT_FOUND)
try:
amount = Decimal(str(request.data.get("amount")))
except (InvalidOperation, TypeError, ValueError):
return Response({"amount": ["金额格式不正确"]}, status=status.HTTP_400_BAD_REQUEST)
if amount == 0:
return Response({"amount": ["调额金额不能为 0"]}, status=status.HTTP_400_BAD_REQUEST)
reason = str(request.data.get("reason") or "").strip()
try:
ledger = adjust_credit(team=team, amount=amount, reason=reason, operator=request.user)
except ValueError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
log_admin_action(
request,
"credit.adjust",
target_type="team",
target_id=team.id,
target_name=team.name,
after={"amount": str(amount), "balance_after": str(ledger.balance_after), "reason": reason},
)
return Response(AdminLedgerSerializer(ledger).data, status=status.HTTP_201_CREATED)
@api_view(["GET", "POST"])
@permission_classes([IsPlatformAdmin])
def admin_quota_policies(request):
"""GET 列额度策略(?team= 筛);POST 新建。team 必填;user/project 留空=团队级。"""
if request.method == "GET":
qs = QuotaPolicy.objects.select_related("team").order_by("-created_at")
team_id = request.query_params.get("team")
if team_id:
qs = qs.filter(team_id=team_id)
paginator = DefaultPagination()
page = paginator.paginate_queryset(qs, request)
return paginator.get_paginated_response(AdminQuotaPolicySerializer(page, many=True).data)
serializer = AdminQuotaPolicySerializer(data=request.data)
serializer.is_valid(raise_exception=True)
obj = serializer.save()
log_admin_action(
request,
"quota_policy.create",
target_type="quota_policy",
target_id=obj.id,
target_name=str(obj.team_id),
after=serializer.data,
)
return Response(AdminQuotaPolicySerializer(obj).data, status=status.HTTP_201_CREATED)
@api_view(["PATCH", "DELETE"])
@permission_classes([IsPlatformAdmin])
def admin_quota_policy_detail(request, policy_id):
obj = QuotaPolicy.objects.filter(id=policy_id).first()
if obj is None:
return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND)
if request.method == "DELETE":
log_admin_action(request, "quota_policy.delete", target_type="quota_policy", target_id=obj.id, target_name=str(obj.team_id))
obj.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
serializer = AdminQuotaPolicySerializer(obj, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
log_admin_action(request, "quota_policy.update", target_type="quota_policy", target_id=obj.id, target_name=str(obj.team_id), after=serializer.data)
return Response(AdminQuotaPolicySerializer(obj).data)