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
+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)