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
@@ -10,6 +10,21 @@ def _client_ip(request):
|
||||
return request.META.get("REMOTE_ADDR") or None
|
||||
|
||||
|
||||
def _json_safe(value):
|
||||
"""把任意值压成 JSONField 可存的结构(UUID/Decimal/datetime 等转字符串)。
|
||||
失败兜底成 {_repr}。审计字段绝不能因不可序列化而抛错、污染外层事务。"""
|
||||
import json
|
||||
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return json.loads(json.dumps(value, cls=DjangoJSONEncoder))
|
||||
except Exception: # noqa: BLE001
|
||||
return {"_repr": str(value)}
|
||||
|
||||
|
||||
def log_admin_action(
|
||||
request,
|
||||
action,
|
||||
@@ -21,22 +36,26 @@ def log_admin_action(
|
||||
after=None,
|
||||
):
|
||||
"""记录一条平台超管操作审计。action 用动词短语(如 'invite.issue' / 'team.disable')。
|
||||
operator_name 取当前登录用户名快照。任何异常吞掉,不影响业务返回。"""
|
||||
operator_name 取当前登录用户名快照。**用 savepoint 包裹**:审计写入失败只回滚自身,
|
||||
绝不把调用方所在事务标脏(否则后续查询会 TransactionManagementError)。"""
|
||||
from django.db import transaction
|
||||
|
||||
from .models import AdminAuditLog
|
||||
|
||||
try:
|
||||
user = getattr(request, "user", None)
|
||||
operator = user if (user is not None and getattr(user, "is_authenticated", False)) else None
|
||||
AdminAuditLog.objects.create(
|
||||
operator=operator,
|
||||
operator_name=(getattr(operator, "username", "") or ""),
|
||||
action=action,
|
||||
target_type=target_type or "",
|
||||
target_id=str(target_id or ""),
|
||||
target_name=target_name or "",
|
||||
before=before,
|
||||
after=after,
|
||||
ip_address=_client_ip(request),
|
||||
)
|
||||
with transaction.atomic():
|
||||
AdminAuditLog.objects.create(
|
||||
operator=operator,
|
||||
operator_name=(getattr(operator, "username", "") or ""),
|
||||
action=action,
|
||||
target_type=target_type or "",
|
||||
target_id=str(target_id or ""),
|
||||
target_name=target_name or "",
|
||||
before=_json_safe(before),
|
||||
after=_json_safe(after),
|
||||
ip_address=_client_ip(request),
|
||||
)
|
||||
except Exception: # noqa: BLE001 — 审计失败不应阻断主流程
|
||||
pass
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -4,7 +4,7 @@ from django.db import transaction
|
||||
from django.db.models import Sum
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.billing.models import CreditAccount, CreditLedger, CreditReservation
|
||||
from apps.billing.models import CreditAccount, CreditLedger, CreditReservation, QuotaPolicy
|
||||
|
||||
|
||||
def _enforce_member_monthly_limit(*, team, user, amount: Decimal) -> None:
|
||||
@@ -34,6 +34,65 @@ def _enforce_member_monthly_limit(*, team, user, amount: Decimal) -> None:
|
||||
raise ValueError(f"成员本月额度不足:限额 ¥{limit},本月已用 ¥{charged}(另有在途 ¥{reserved})")
|
||||
|
||||
|
||||
def _enforce_quota_policy(*, team, project, amount: Decimal) -> None:
|
||||
"""平台 4 层额度策略(QuotaPolicy)。**仅当团队存在 active 团队级策略时才生效** ——
|
||||
无策略的团队行为完全不变(零回归)。检查 单任务 / 团队月度 / 项目 三类限额(任一为 None 即该维度不限)。"""
|
||||
policy = (
|
||||
team.quota_policies.filter(is_active=True, user__isnull=True, project__isnull=True)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
if policy is None:
|
||||
return
|
||||
if policy.per_task_limit is not None and amount > policy.per_task_limit:
|
||||
raise ValueError(f"单任务额度超限:上限 ¥{policy.per_task_limit}")
|
||||
now = timezone.now()
|
||||
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
if policy.monthly_limit is not None:
|
||||
charged = (
|
||||
CreditLedger.objects.filter(team=team, ledger_type=CreditLedger.Type.CHARGE, created_at__gte=month_start)
|
||||
.aggregate(s=Sum("amount"))["s"] or Decimal("0")
|
||||
)
|
||||
reserved = (
|
||||
CreditReservation.objects.filter(team=team, status=CreditReservation.Status.ACTIVE)
|
||||
.aggregate(s=Sum("amount"))["s"] or Decimal("0")
|
||||
)
|
||||
if charged + reserved + amount > policy.monthly_limit:
|
||||
raise ValueError(f"团队本月额度超限:上限 ¥{policy.monthly_limit}")
|
||||
if policy.project_limit is not None and project is not None:
|
||||
p_charged = (
|
||||
CreditLedger.objects.filter(team=team, project=project, ledger_type=CreditLedger.Type.CHARGE)
|
||||
.aggregate(s=Sum("amount"))["s"] or Decimal("0")
|
||||
)
|
||||
p_reserved = (
|
||||
CreditReservation.objects.filter(team=team, project=project, status=CreditReservation.Status.ACTIVE)
|
||||
.aggregate(s=Sum("amount"))["s"] or Decimal("0")
|
||||
)
|
||||
if p_charged + p_reserved + amount > policy.project_limit:
|
||||
raise ValueError(f"项目额度超限:上限 ¥{policy.project_limit}")
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def adjust_credit(*, team, amount: Decimal, reason: str = "", operator=None) -> CreditLedger:
|
||||
"""平台超管手动调额(争议 / 补偿)。amount 可正可负;落 ADJUSTMENT 流水并更新余额。
|
||||
调额后余额不得为负(否则拒绝)。"""
|
||||
account, _ = CreditAccount.objects.select_for_update().get_or_create(team=team)
|
||||
new_balance = account.balance + amount
|
||||
if new_balance < 0:
|
||||
raise ValueError("调额后余额为负,已拒绝")
|
||||
account.balance = new_balance
|
||||
account.save(update_fields=["balance", "updated_at"])
|
||||
return CreditLedger.objects.create(
|
||||
team=team,
|
||||
user=operator,
|
||||
ledger_type=CreditLedger.Type.ADJUSTMENT,
|
||||
amount=amount,
|
||||
balance_after=new_balance,
|
||||
reason=reason or "平台手动调额",
|
||||
metadata={"kind": "admin_adjust"},
|
||||
)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def reserve_credit(*, team, user, task, amount: Decimal) -> CreditReservation:
|
||||
account, _ = CreditAccount.objects.select_for_update().get_or_create(team=team)
|
||||
@@ -41,6 +100,7 @@ def reserve_credit(*, team, user, task, amount: Decimal) -> CreditReservation:
|
||||
if available < amount:
|
||||
raise ValueError("insufficient credit")
|
||||
_enforce_member_monthly_limit(team=team, user=user, amount=amount)
|
||||
_enforce_quota_policy(team=team, project=task.project, amount=amount)
|
||||
|
||||
account.reserved_balance += amount
|
||||
account.save(update_fields=["reserved_balance", "updated_at"])
|
||||
|
||||
Reference in New Issue
Block a user