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"])
|
||||
|
||||
@@ -256,3 +256,16 @@
|
||||
overflow: auto;
|
||||
}
|
||||
.admin-json-err { color: var(--accent-crimson); background: var(--crimson-bg); border-color: var(--crimson-bd); }
|
||||
|
||||
/* ── 计费 / 额度策略 ── */
|
||||
.admin-switch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--accent-black);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.admin-switch-row input { width: 14px; height: 14px; accent-color: var(--heat); }
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type {
|
||||
AdminLedger,
|
||||
AdminQualityWord,
|
||||
AdminQuotaPolicy,
|
||||
AdminReviewAsset,
|
||||
AdminTask,
|
||||
AdminTaskDetail,
|
||||
@@ -618,5 +620,33 @@ export const adminApi = {
|
||||
},
|
||||
retryTask(id: string) {
|
||||
return request<{ retried: boolean; task_id: string }>(`/api/admin/tasks/${id}/retry/`, { method: "POST" });
|
||||
},
|
||||
ledgers(params?: { ledger_type?: string; team?: string; page?: number; page_size?: number }) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.ledger_type) qs.set("ledger_type", params.ledger_type);
|
||||
if (params?.team) qs.set("team", params.team);
|
||||
if (params?.page) qs.set("page", String(params.page));
|
||||
if (params?.page_size) qs.set("page_size", String(params.page_size));
|
||||
const q = qs.toString();
|
||||
return request<Paginated<AdminLedger>>(`/api/admin/ledgers/${q ? `?${q}` : ""}`);
|
||||
},
|
||||
adjustCredit(payload: { team: string; amount: string; reason: string }) {
|
||||
return request<AdminLedger>("/api/admin/ledgers/adjust/", { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
quotaPolicies(params?: { team?: string; page_size?: number }) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.team) qs.set("team", params.team);
|
||||
if (params?.page_size) qs.set("page_size", String(params.page_size));
|
||||
const q = qs.toString();
|
||||
return request<Paginated<AdminQuotaPolicy>>(`/api/admin/quota-policies/${q ? `?${q}` : ""}`);
|
||||
},
|
||||
createQuotaPolicy(payload: { team: string; monthly_limit?: string | null; project_limit?: string | null; per_task_limit?: string | null; is_active?: boolean }) {
|
||||
return request<AdminQuotaPolicy>("/api/admin/quota-policies/", { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
updateQuotaPolicy(id: string, payload: { monthly_limit?: string | null; project_limit?: string | null; per_task_limit?: string | null; is_active?: boolean }) {
|
||||
return request<AdminQuotaPolicy>(`/api/admin/quota-policies/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||
},
|
||||
deleteQuotaPolicy(id: string) {
|
||||
return request<void>(`/api/admin/quota-policies/${id}/`, { method: "DELETE" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { IconKitSvg } from "../../components/IconKitSvg";
|
||||
import { CornerMarks, Decorations, ToastLike } from "../../components/app-shell";
|
||||
import type { Team, User } from "../../types";
|
||||
import type { NavigateFn } from "../route-config";
|
||||
import { AdminLedgersPage, AdminQuotaPage } from "./admin-billing";
|
||||
import { AdminInvitesPage } from "./admin-invites";
|
||||
import { AdminQualityPage } from "./admin-quality";
|
||||
import { AdminReviewsPage } from "./admin-reviews";
|
||||
@@ -186,6 +187,12 @@ function AdminSectionView({ section, navigateAdmin, notify }: { section: AdminSe
|
||||
if (section.slug === "tasks") {
|
||||
return <AdminTasksPage notify={notify} />;
|
||||
}
|
||||
if (section.slug === "billing") {
|
||||
return <AdminLedgersPage notify={notify} />;
|
||||
}
|
||||
if (section.slug === "quota") {
|
||||
return <AdminQuotaPage notify={notify} />;
|
||||
}
|
||||
// 其余模块在各自阶段替换此占位为真实页面
|
||||
return <AdminPlaceholder section={section} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Gauge, Wallet, X } from "lucide-react";
|
||||
import { adminApi } from "../../api";
|
||||
import { IconKitSvg } from "../../components/IconKitSvg";
|
||||
import type { AdminLedger, AdminQuotaPolicy, AdminTeam } from "../../types";
|
||||
|
||||
type Notify = (type: "success" | "error" | "info", text: string) => void;
|
||||
|
||||
function fmtDate(iso: string) {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}/${p(d.getMonth() + 1)}/${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
const LEDGER_TABS = [
|
||||
{ key: "", label: "全部" },
|
||||
{ key: "recharge", label: "充值" },
|
||||
{ key: "charge", label: "扣费" },
|
||||
{ key: "adjustment", label: "调额" },
|
||||
{ key: "refund", label: "退款" }
|
||||
];
|
||||
const LEDGER_LABEL: Record<string, string> = {
|
||||
recharge: "充值", reserve: "预扣", release: "释放", charge: "扣费", adjustment: "调额", refund: "退款"
|
||||
};
|
||||
function ledgerPill(t: string) {
|
||||
const cls = ["recharge", "refund", "release"].includes(t) ? "ok" : t === "adjustment" ? "info" : "neutral";
|
||||
return <span className={`pill ${cls}`}><span className="dot" />{LEDGER_LABEL[t] || t}</span>;
|
||||
}
|
||||
|
||||
// 复用:加载团队下拉
|
||||
function useTeams() {
|
||||
const [teams, setTeams] = useState<AdminTeam[]>([]);
|
||||
useEffect(() => {
|
||||
adminApi.teams({ page_size: 200 }).then((r) => setTeams(r.results)).catch(() => {});
|
||||
}, []);
|
||||
return teams;
|
||||
}
|
||||
|
||||
// ─────────────────────────── 计费审计 ───────────────────────────
|
||||
|
||||
export function AdminLedgersPage({ notify }: { notify: Notify }) {
|
||||
const [ledgers, setLedgers] = useState<AdminLedger[]>([]);
|
||||
const [count, setCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tab, setTab] = useState("");
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [form, setForm] = useState({ team: "", amount: "", reason: "" });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const teams = useTeams();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await adminApi.ledgers({ ledger_type: tab || undefined, page_size: 80 });
|
||||
setLedgers(res.results);
|
||||
setCount(res.count);
|
||||
} catch {
|
||||
notify("error", "加载流水失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tab]);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
async function doAdjust() {
|
||||
if (saving) return;
|
||||
if (!form.team) { notify("error", "请选择团队"); return; }
|
||||
if (!form.amount || Number(form.amount) === 0) { notify("error", "请输入非 0 金额"); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
await adminApi.adjustCredit({ team: form.team, amount: form.amount, reason: form.reason });
|
||||
notify("success", "已调额并入账");
|
||||
setModalOpen(false);
|
||||
setForm({ team: "", amount: "", reason: "" });
|
||||
await load();
|
||||
} catch (e) {
|
||||
notify("error", e instanceof Error ? e.message : "调额失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>计费审计</h1>
|
||||
<div className="sub"><span className="mono">// {count} 条流水</span> · 全局信用流水 · 手动调额</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-primary" type="button" onClick={() => setModalOpen(true)}>+ 手动调额</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-toolbar">
|
||||
<div className="tabs-sub">
|
||||
{LEDGER_TABS.map((t) => (
|
||||
<button key={t.key || "all"} type="button" className={`tab-sub${tab === t.key ? " active" : ""}`} onClick={() => setTab(t.key)}>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="creditCard" size={24} /></div><h3>加载中…</h3><p>// fetching ledgers</p></div>
|
||||
) : ledgers.length === 0 ? (
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="creditCard" size={24} /></div><h3>暂无流水</h3><p>// no ledgers</p></div>
|
||||
) : (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="t admin-table">
|
||||
<thead><tr><th>团队</th><th>用户</th><th>类型</th><th>金额</th><th>余额后</th><th>备注</th><th>时间</th></tr></thead>
|
||||
<tbody>
|
||||
{ledgers.map((l) => (
|
||||
<tr key={l.id}>
|
||||
<td>{l.team_name || <span className="muted">—</span>}</td>
|
||||
<td>{l.username || <span className="muted">—</span>}</td>
|
||||
<td>{ledgerPill(l.ledger_type)}</td>
|
||||
<td className="num mono">¥{l.amount}</td>
|
||||
<td className="num mono">¥{l.balance_after}</td>
|
||||
<td>{l.reason || <span className="muted">—</span>}</td>
|
||||
<td className="mono col-time">{fmtDate(l.created_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modalOpen && (
|
||||
<div className="modal-bg show" onClick={(e) => { if (e.target === e.currentTarget) setModalOpen(false); }}>
|
||||
<div className="modal" role="dialog" aria-modal="true" aria-label="手动调额">
|
||||
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
||||
<div className="modal-h">
|
||||
<div className="ic-m"><Wallet size={16} /></div>
|
||||
<div className="ti">手动调额<span>// credit adjust</span></div>
|
||||
<button className="x modal-x" type="button" aria-label="关闭" onClick={() => setModalOpen(false)}><X size={14} /></button>
|
||||
</div>
|
||||
<div className="modal-b">
|
||||
<p className="admin-modal-desc">为团队手动加 / 减额度(争议补偿)。金额可为负;调额后余额不得为负。会落一条调额流水。</p>
|
||||
<div className="field">
|
||||
<label className="field-label">团队 <span className="req">*</span></label>
|
||||
<select className="select" value={form.team} onChange={(e) => setForm((f) => ({ ...f, team: e.target.value }))}>
|
||||
<option value="">选择团队…</option>
|
||||
{teams.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">金额 <span className="field-hint">正=加,负=减</span></label>
|
||||
<input className="input" type="text" placeholder="如 100 或 -50" value={form.amount} onChange={(e) => setForm((f) => ({ ...f, amount: e.target.value }))} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">备注</label>
|
||||
<input className="input" type="text" placeholder="调额原因(可选)" value={form.reason} onChange={(e) => setForm((f) => ({ ...f, reason: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-f">
|
||||
<button className="btn" type="button" onClick={() => setModalOpen(false)}>取消</button>
|
||||
<button className="btn btn-primary" type="button" disabled={saving} onClick={() => void doAdjust()}>{saving ? "处理中…" : "确认调额"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────── 额度策略 ───────────────────────────
|
||||
|
||||
const EMPTY_POLICY = { id: "", team: "", monthly_limit: "", project_limit: "", per_task_limit: "", is_active: true };
|
||||
|
||||
export function AdminQuotaPage({ notify }: { notify: Notify }) {
|
||||
const [policies, setPolicies] = useState<AdminQuotaPolicy[]>([]);
|
||||
const [count, setCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<typeof EMPTY_POLICY>(EMPTY_POLICY);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const teams = useTeams();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await adminApi.quotaPolicies({ page_size: 100 });
|
||||
setPolicies(res.results);
|
||||
setCount(res.count);
|
||||
} catch {
|
||||
notify("error", "加载额度策略失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
function openNew() { setEditing(EMPTY_POLICY); setModalOpen(true); }
|
||||
function openEdit(p: AdminQuotaPolicy) {
|
||||
setEditing({
|
||||
id: p.id, team: p.team,
|
||||
monthly_limit: p.monthly_limit ?? "", project_limit: p.project_limit ?? "", per_task_limit: p.per_task_limit ?? "",
|
||||
is_active: p.is_active
|
||||
});
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (saving) return;
|
||||
if (!editing.team) { notify("error", "请选择团队"); return; }
|
||||
setSaving(true);
|
||||
const payload = {
|
||||
monthly_limit: editing.monthly_limit === "" ? null : editing.monthly_limit,
|
||||
project_limit: editing.project_limit === "" ? null : editing.project_limit,
|
||||
per_task_limit: editing.per_task_limit === "" ? null : editing.per_task_limit,
|
||||
is_active: editing.is_active
|
||||
};
|
||||
try {
|
||||
if (editing.id) {
|
||||
await adminApi.updateQuotaPolicy(editing.id, payload);
|
||||
} else {
|
||||
await adminApi.createQuotaPolicy({ team: editing.team, ...payload });
|
||||
}
|
||||
notify("success", "已保存额度策略");
|
||||
setModalOpen(false);
|
||||
await load();
|
||||
} catch (e) {
|
||||
notify("error", e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function del(p: AdminQuotaPolicy) {
|
||||
try {
|
||||
await adminApi.deleteQuotaPolicy(p.id);
|
||||
notify("success", "已删除策略");
|
||||
await load();
|
||||
} catch {
|
||||
notify("error", "删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
const lim = (v: string | null) => (v == null ? <span className="muted">不限</span> : `¥${v}`);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>额度策略</h1>
|
||||
<div className="sub"><span className="mono">// {count} 条</span> · 4 层额度(团队月度 / 项目 / 单任务)· 设了才拦,留空不限</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-primary" type="button" onClick={openNew}>+ 新建策略</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="gauge" size={24} /></div><h3>加载中…</h3><p>// fetching policies</p></div>
|
||||
) : policies.length === 0 ? (
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="gauge" size={24} /></div><h3>暂无额度策略</h3><p>// 默认仅余额管控 · 点右上新建</p></div>
|
||||
) : (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="t admin-table">
|
||||
<thead><tr><th>团队</th><th>月度上限</th><th>项目上限</th><th>单任务上限</th><th>状态</th><th className="col-actions">操作</th></tr></thead>
|
||||
<tbody>
|
||||
{policies.map((p) => (
|
||||
<tr key={p.id}>
|
||||
<td>{p.team_name || <span className="muted">—</span>}</td>
|
||||
<td className="num mono">{lim(p.monthly_limit)}</td>
|
||||
<td className="num mono">{lim(p.project_limit)}</td>
|
||||
<td className="num mono">{lim(p.per_task_limit)}</td>
|
||||
<td>{p.is_active ? <span className="pill ok"><span className="dot" />启用</span> : <span className="pill neutral"><span className="dot" />停用</span>}</td>
|
||||
<td className="col-actions">
|
||||
<button className="btn btn-sm btn-ghost" type="button" onClick={() => openEdit(p)}>编辑</button>
|
||||
<button className="btn btn-sm btn-ghost danger" type="button" onClick={() => del(p)}>删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modalOpen && (
|
||||
<div className="modal-bg show" onClick={(e) => { if (e.target === e.currentTarget) setModalOpen(false); }}>
|
||||
<div className="modal" role="dialog" aria-modal="true" aria-label="额度策略">
|
||||
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
||||
<div className="modal-h">
|
||||
<div className="ic-m"><Gauge size={16} /></div>
|
||||
<div className="ti">{editing.id ? "编辑额度策略" : "新建额度策略"}<span>// quota policy</span></div>
|
||||
<button className="x modal-x" type="button" aria-label="关闭" onClick={() => setModalOpen(false)}><X size={14} /></button>
|
||||
</div>
|
||||
<div className="modal-b">
|
||||
<p className="admin-modal-desc">各上限留空 = 该维度不限。任一上限触发即拦截生成(在原余额管控之上叠加)。</p>
|
||||
<div className="field">
|
||||
<label className="field-label">团队 <span className="req">*</span></label>
|
||||
<select className="select" value={editing.team} disabled={Boolean(editing.id)} onChange={(e) => setEditing((p) => ({ ...p, team: e.target.value }))}>
|
||||
<option value="">选择团队…</option>
|
||||
{teams.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label className="field-label">月度上限</label>
|
||||
<input className="input" type="text" placeholder="留空不限" value={editing.monthly_limit} onChange={(e) => setEditing((p) => ({ ...p, monthly_limit: e.target.value }))} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">单任务上限</label>
|
||||
<input className="input" type="text" placeholder="留空不限" value={editing.per_task_limit} onChange={(e) => setEditing((p) => ({ ...p, per_task_limit: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">项目上限</label>
|
||||
<input className="input" type="text" placeholder="留空不限" value={editing.project_limit} onChange={(e) => setEditing((p) => ({ ...p, project_limit: e.target.value }))} />
|
||||
</div>
|
||||
<label className="admin-switch-row">
|
||||
<input type="checkbox" checked={editing.is_active} onChange={(e) => setEditing((p) => ({ ...p, is_active: e.target.checked }))} />
|
||||
<span>启用此策略</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="modal-f">
|
||||
<button className="btn" type="button" onClick={() => setModalOpen(false)}>取消</button>
|
||||
<button className="btn btn-primary" type="button" disabled={saving} onClick={() => void save()}>{saving ? "保存中…" : "保存"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -115,6 +115,29 @@ export type AdminTaskDetail = AdminTask & {
|
||||
submitted_at: string | null;
|
||||
completed_at: string | null;
|
||||
};
|
||||
export type AdminLedger = {
|
||||
id: string;
|
||||
team: string;
|
||||
team_name: string | null;
|
||||
username: string | null;
|
||||
ledger_type: string;
|
||||
amount: string;
|
||||
balance_after: string;
|
||||
reason: string;
|
||||
created_at: string;
|
||||
};
|
||||
export type AdminQuotaPolicy = {
|
||||
id: string;
|
||||
team: string;
|
||||
team_name: string | null;
|
||||
user: string | null;
|
||||
project: string | null;
|
||||
monthly_limit: string | null;
|
||||
project_limit: string | null;
|
||||
per_task_limit: string | null;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type Paginated<T> = {
|
||||
count: number;
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Phase 7 e2e:计费审计(流水 + 手动调额)+ 额度策略(CRUD)。
|
||||
// 用一次性 P7 团队做调额/建策略,绝不动 demo 团队余额。建的策略跑完删。
|
||||
import { chromium } from "playwright";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const BASE = process.env.BASE || "http://127.0.0.1:5188";
|
||||
const API = process.env.API || "http://127.0.0.1:8010";
|
||||
const OUT = path.resolve("output/admin");
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
const stamp = Date.now();
|
||||
|
||||
async function apiLogin(u, p) {
|
||||
const res = await fetch(`${API}/api/auth/login/`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: u, password: p }) });
|
||||
return res.json();
|
||||
}
|
||||
const admin = await apiLogin("admin", "admin123");
|
||||
const authH = { "Content-Type": "application/json", Authorization: `Token ${admin.token}` };
|
||||
// 一次性团队
|
||||
const code = (await (await fetch(`${API}/api/admin/invitations/`, { method: "POST", headers: authH, body: "{}" })).json()).code;
|
||||
const reg = await (await fetch(`${API}/api/auth/register/`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: `p7o-${stamp}`, password: "ownerpass1", team_name: `P7Team${stamp}`, invite_code: code }) })).json();
|
||||
const teamId = reg.team.id;
|
||||
|
||||
const r = { billing: {}, adjust: {}, quota: {}, consoleErrors: [], pass: false };
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const hook = (p, tag) => {
|
||||
p.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push(`${tag}:${m.text()}`); });
|
||||
p.on("pageerror", (e) => r.consoleErrors.push(`${tag}:PAGEERR:${e.message}`));
|
||||
};
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
await ctx.addInitScript((tk) => localStorage.setItem("airshelf_token", tk), admin.token);
|
||||
const p = await ctx.newPage(); hook(p, "billing");
|
||||
|
||||
// ── 计费审计 + 手动调额 ──
|
||||
await p.goto(BASE + "/admin/billing", { waitUntil: "load" });
|
||||
await p.waitForSelector(".admin-app", { timeout: 12000 });
|
||||
await p.waitForFunction(() => document.querySelector(".admin-table") || (document.querySelector(".empty-state.show h3") || {}).textContent !== "加载中…", { timeout: 20000 }).catch(() => {});
|
||||
r.billing.title = (await p.locator(".content h1").first().innerText().catch(() => "")).trim();
|
||||
r.billing.adjustBtn = (await p.locator(".page-head .actions .btn-primary").count()) >= 1;
|
||||
r.billing.rows = await p.locator(".admin-table tbody tr").count();
|
||||
await p.screenshot({ path: path.join(OUT, "p7-ledgers.png"), fullPage: true });
|
||||
|
||||
await p.locator(".page-head .actions .btn-primary").click();
|
||||
await p.waitForSelector(".modal", { timeout: 6000 });
|
||||
await p.locator(".modal .select").selectOption(teamId);
|
||||
await p.locator('.modal input[placeholder*="100"]').fill("88");
|
||||
await p.locator('.modal input[placeholder*="原因"]').fill("e2e 调额");
|
||||
await p.locator(".modal-f .btn-primary").click();
|
||||
await p.waitForTimeout(1200);
|
||||
const ledgers = await (await fetch(`${API}/api/admin/ledgers/?team=${teamId}&ledger_type=adjustment`, { headers: authH })).json();
|
||||
r.adjust.persisted = ledgers.count >= 1 && ledgers.results.some((l) => l.amount.startsWith("88"));
|
||||
|
||||
// ── 额度策略 CRUD ──
|
||||
await p.goto(BASE + "/admin/quota", { waitUntil: "load" });
|
||||
await p.waitForSelector(".admin-app", { timeout: 12000 });
|
||||
await p.waitForFunction(() => document.querySelector(".admin-table") || (document.querySelector(".empty-state.show h3") || {}).textContent !== "加载中…", { timeout: 20000 }).catch(() => {});
|
||||
r.quota.title = (await p.locator(".content h1").first().innerText().catch(() => "")).trim();
|
||||
r.quota.newBtn = (await p.locator(".page-head .actions .btn-primary").count()) >= 1;
|
||||
|
||||
await p.locator(".page-head .actions .btn-primary").click();
|
||||
await p.waitForSelector(".modal", { timeout: 6000 });
|
||||
await p.locator(".modal .select").selectOption(teamId);
|
||||
await p.locator(".modal .input").nth(1).fill("5"); // 单任务上限(field-row 第二个)
|
||||
await p.locator(".modal-f .btn-primary").click();
|
||||
await p.waitForTimeout(1200);
|
||||
const pols = await (await fetch(`${API}/api/admin/quota-policies/?team=${teamId}`, { headers: authH })).json();
|
||||
r.quota.created = pols.count >= 1;
|
||||
await p.screenshot({ path: path.join(OUT, "p7-quota.png"), fullPage: true });
|
||||
|
||||
// UI 删除我们建的策略(清理)
|
||||
if (r.quota.created) {
|
||||
// 该团队是新建的,本页仅它一条相关;直接删页面上属于它的行(按团队名定位)
|
||||
const rowDel = p.locator(`.admin-table tbody tr:has-text("P7Team${stamp}") .btn-ghost.danger`).first();
|
||||
if (await rowDel.count()) {
|
||||
await rowDel.click();
|
||||
await p.waitForTimeout(1000);
|
||||
}
|
||||
const after = await (await fetch(`${API}/api/admin/quota-policies/?team=${teamId}`, { headers: authH })).json();
|
||||
r.quota.deleted = after.count === 0;
|
||||
}
|
||||
|
||||
await ctx.close();
|
||||
await browser.close();
|
||||
// 兜底清理:删该团队残留策略
|
||||
const left = await (await fetch(`${API}/api/admin/quota-policies/?team=${teamId}`, { headers: authH })).json();
|
||||
for (const pol of left.results || []) {
|
||||
await fetch(`${API}/api/admin/quota-policies/${pol.id}/`, { method: "DELETE", headers: authH });
|
||||
}
|
||||
|
||||
const checks = {
|
||||
billingPage: r.billing.title === "计费审计" && r.billing.adjustBtn === true && r.billing.rows >= 1,
|
||||
adjustPersisted: r.adjust.persisted === true,
|
||||
quotaPage: r.quota.title === "额度策略" && r.quota.newBtn === true,
|
||||
quotaCreated: r.quota.created === true,
|
||||
quotaDeleted: r.quota.deleted === true,
|
||||
zeroConsoleErrors: r.consoleErrors.length === 0,
|
||||
};
|
||||
r.checks = checks;
|
||||
r.pass = Object.values(checks).every(Boolean);
|
||||
console.log(JSON.stringify(r, null, 2));
|
||||
fs.writeFileSync(path.join(OUT, "p7-summary.json"), JSON.stringify(r, null, 2));
|
||||
process.exit(r.pass ? 0 : 1);
|
||||
@@ -164,3 +164,23 @@
|
||||
- 无头 e2e:`_admin-p6.mjs`(5188;wrapper 建 1 条 failed+异常任务,跑完删)—— **6 断言全过 + 0 console error**:页面+3tab+异常chip、60 行、详情抽屉含 JSON、失败 tab(43 条真实失败)首行有重投、异常筛精确 1 条;**不点真重投**。截图 `output/admin/p6-*.png`。
|
||||
- 类型/构建:`tsc --noEmit` + `npm run build` 全绿。
|
||||
- 视觉:任务表 + 详情抽屉(成本⚠ / 错误红框 / payload),符合 restraint。
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 7 · 计费审计 + 4 层额度策略(Admin)— 完成 2026-06-19
|
||||
|
||||
**后端**
|
||||
- 计费:`GET ledgers/`(全局信用流水 + type/team 筛分页)+ `POST ledgers/adjust/`(手动调额,落 ADJUSTMENT 流水 + 更新余额,调后不许为负);`adjust_credit` helper(billing/services/ledger.py)。
|
||||
- 额度策略:`GET/POST quota-policies/` + `PATCH/DELETE quota-policies/<id>/`;AdminQuotaPolicySerializer。
|
||||
- **额度拦截落地**:`_enforce_quota_policy`(单任务/团队月度/项目 三类限额)接入 `reserve_credit`,**仅当团队有 active 团队级策略才生效 → 无策略零回归**。
|
||||
- **审计 helper 加固**:`log_admin_action` 用 savepoint 包裹 + `_json_safe`(UUID/Decimal/datetime 转换),修复「传 serializer.data(含 UUID)致 JSONField 报错 → 吞异常但污染外层事务 → 后续查询 TransactionManagementError」的真 bug。
|
||||
|
||||
**前端**
|
||||
- `adminApi` ledgers/adjustCredit/quotaPolicies/create/update/delete;`AdminLedger`/`AdminQuotaPolicy` 类型。
|
||||
- Admin「计费审计」页(类型筛 + 流水表 + 手动调额弹窗:团队下拉/金额±/备注)+「额度策略」页(策略表 + 新建/编辑/删除弹窗:团队下拉 + 月度/项目/单任务上限 + 启用开关)。
|
||||
- admin-page.css 补开关行(仅 token)。
|
||||
|
||||
**测试底座(全过)**
|
||||
- 后端单测:`apps.adminpanel` **44 项 + billing + accounts = 70 OK**(流水筛/调额±+审计/超额拒/额度策略 CRUD/**额度拦截 per_task+monthly 生效 + 无策略·停用不拦(零回归)**/权限 403);billing 既有测试全绿(reserve_credit 改动零回归)。
|
||||
- 无头 e2e:`_admin-p7.mjs`(5188;用一次性 P7 团队,不动 demo 余额;建的策略跑完删)—— **6 断言全过 + 0 console error**:计费页(80 流水)+ 手动调额→API 验持久化、额度策略页 + 新建→API 验 + UI 删→API 验空。截图 `output/admin/p7-*.png`。
|
||||
- 类型/构建:`tsc --noEmit` + `npm run build` 全绿。
|
||||
|
||||
Reference in New Issue
Block a user