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
+31 -12
View File
@@ -10,6 +10,21 @@ def _client_ip(request):
return request.META.get("REMOTE_ADDR") or None 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( def log_admin_action(
request, request,
action, action,
@@ -21,22 +36,26 @@ def log_admin_action(
after=None, after=None,
): ):
"""记录一条平台超管操作审计。action 用动词短语(如 'invite.issue' / 'team.disable')。 """记录一条平台超管操作审计。action 用动词短语(如 'invite.issue' / 'team.disable')。
operator_name 取当前登录用户名快照。任何异常吞掉,不影响业务返回。""" operator_name 取当前登录用户名快照。**用 savepoint 包裹**:审计写入失败只回滚自身,
绝不把调用方所在事务标脏(否则后续查询会 TransactionManagementError)。"""
from django.db import transaction
from .models import AdminAuditLog from .models import AdminAuditLog
try: try:
user = getattr(request, "user", None) user = getattr(request, "user", None)
operator = user if (user is not None and getattr(user, "is_authenticated", False)) else None operator = user if (user is not None and getattr(user, "is_authenticated", False)) else None
AdminAuditLog.objects.create( with transaction.atomic():
operator=operator, AdminAuditLog.objects.create(
operator_name=(getattr(operator, "username", "") or ""), operator=operator,
action=action, operator_name=(getattr(operator, "username", "") or ""),
target_type=target_type or "", action=action,
target_id=str(target_id or ""), target_type=target_type or "",
target_name=target_name or "", target_id=str(target_id or ""),
before=before, target_name=target_name or "",
after=after, before=_json_safe(before),
ip_address=_client_ip(request), after=_json_safe(after),
) ip_address=_client_ip(request),
)
except Exception: # noqa: BLE001 — 审计失败不应阻断主流程 except Exception: # noqa: BLE001 — 审计失败不应阻断主流程
pass pass
@@ -5,6 +5,7 @@ from rest_framework import serializers
from apps.accounts.models import Team, TeamMember, User from apps.accounts.models import Team, TeamMember, User
from apps.ai.models import AITask, QualityWord from apps.ai.models import AITask, QualityWord
from apps.assets.models import Asset from apps.assets.models import Asset
from apps.billing.models import CreditLedger, QuotaPolicy
# 成本异常阈值:实际成本 > 预估 × 此倍数(且预估 > 0)即标异常 # 成本异常阈值:实际成本 > 预估 × 此倍数(且预估 > 0)即标异常
COST_ANOMALY_RATIO = Decimal("1.5") COST_ANOMALY_RATIO = Decimal("1.5")
@@ -110,3 +111,25 @@ class AdminTaskDetailSerializer(AdminTaskSerializer):
"error_message", "submitted_at", "completed_at", "error_message", "submitted_at", "completed_at",
] ]
read_only_fields = fields 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 unittest.mock import patch
from rest_framework.test import APIClient from rest_framework.test import APIClient
@@ -367,3 +368,88 @@ class AdminTaskMonitorTests(TestCase):
def test_retry_requires_admin(self): def test_retry_requires_admin(self):
self.assertEqual(self.nc.post(f"/api/admin/tasks/{self.t_failed.id}/retry/").status_code, 403) 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_poll,
admin_asset_reviews_submit, admin_asset_reviews_submit,
admin_invitations, admin_invitations,
admin_ledger_adjust,
admin_ledgers,
admin_quota_policies,
admin_quota_policy_detail,
admin_task_detail, admin_task_detail,
admin_task_retry, admin_task_retry,
admin_tasks, admin_tasks,
@@ -37,4 +41,8 @@ urlpatterns = [
path("tasks/", admin_tasks, name="admin-tasks"), path("tasks/", admin_tasks, name="admin-tasks"),
path("tasks/<uuid:task_id>/", admin_task_detail, name="admin-task-detail"), 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("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.ai.models import AITask, QualityWord
from apps.assets.models import Asset from apps.assets.models import Asset
from apps.assets.review import poll_asset_review, submit_asset_for_review 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 apps.common.pagination import DefaultPagination
from .serializers import ( from .serializers import (
COST_ANOMALY_RATIO, COST_ANOMALY_RATIO,
AdminLedgerSerializer,
AdminQuotaPolicySerializer,
AdminReviewAssetSerializer, AdminReviewAssetSerializer,
AdminTaskDetailSerializer, AdminTaskDetailSerializer,
AdminTaskSerializer, AdminTaskSerializer,
@@ -399,3 +403,97 @@ def admin_task_retry(request, task_id):
target_name=task.task_type, target_name=task.task_type,
) )
return Response({"retried": True, "task_id": str(task.id)}) 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)
+61 -1
View File
@@ -4,7 +4,7 @@ from django.db import transaction
from django.db.models import Sum from django.db.models import Sum
from django.utils import timezone 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: 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})") 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 @transaction.atomic
def reserve_credit(*, team, user, task, amount: Decimal) -> CreditReservation: def reserve_credit(*, team, user, task, amount: Decimal) -> CreditReservation:
account, _ = CreditAccount.objects.select_for_update().get_or_create(team=team) 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: if available < amount:
raise ValueError("insufficient credit") raise ValueError("insufficient credit")
_enforce_member_monthly_limit(team=team, user=user, amount=amount) _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.reserved_balance += amount
account.save(update_fields=["reserved_balance", "updated_at"]) account.save(update_fields=["reserved_balance", "updated_at"])
+13
View File
@@ -256,3 +256,16 @@
overflow: auto; overflow: auto;
} }
.admin-json-err { color: var(--accent-crimson); background: var(--crimson-bg); border-color: var(--crimson-bd); } .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); }
+30
View File
@@ -1,5 +1,7 @@
import type { import type {
AdminLedger,
AdminQualityWord, AdminQualityWord,
AdminQuotaPolicy,
AdminReviewAsset, AdminReviewAsset,
AdminTask, AdminTask,
AdminTaskDetail, AdminTaskDetail,
@@ -618,5 +620,33 @@ export const adminApi = {
}, },
retryTask(id: string) { retryTask(id: string) {
return request<{ retried: boolean; task_id: string }>(`/api/admin/tasks/${id}/retry/`, { method: "POST" }); 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 { CornerMarks, Decorations, ToastLike } from "../../components/app-shell";
import type { Team, User } from "../../types"; import type { Team, User } from "../../types";
import type { NavigateFn } from "../route-config"; import type { NavigateFn } from "../route-config";
import { AdminLedgersPage, AdminQuotaPage } from "./admin-billing";
import { AdminInvitesPage } from "./admin-invites"; import { AdminInvitesPage } from "./admin-invites";
import { AdminQualityPage } from "./admin-quality"; import { AdminQualityPage } from "./admin-quality";
import { AdminReviewsPage } from "./admin-reviews"; import { AdminReviewsPage } from "./admin-reviews";
@@ -186,6 +187,12 @@ function AdminSectionView({ section, navigateAdmin, notify }: { section: AdminSe
if (section.slug === "tasks") { if (section.slug === "tasks") {
return <AdminTasksPage notify={notify} />; return <AdminTasksPage notify={notify} />;
} }
if (section.slug === "billing") {
return <AdminLedgersPage notify={notify} />;
}
if (section.slug === "quota") {
return <AdminQuotaPage notify={notify} />;
}
// 其余模块在各自阶段替换此占位为真实页面 // 其余模块在各自阶段替换此占位为真实页面
return <AdminPlaceholder section={section} />; 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>
)}
</>
);
}
+23
View File
@@ -115,6 +115,29 @@ export type AdminTaskDetail = AdminTask & {
submitted_at: string | null; submitted_at: string | null;
completed_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> = { export type Paginated<T> = {
count: number; count: number;
+102
View File
@@ -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);
+20
View File
@@ -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`。 - 无头 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` 全绿。 - 类型/构建:`tsc --noEmit` + `npm run build` 全绿。
- 视觉:任务表 + 详情抽屉(成本⚠ / 错误红框 / payload),符合 restraint。 - 视觉:任务表 + 详情抽屉(成本⚠ / 错误红框 / 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` 全绿。