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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user