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
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"]
+86
View File
@@ -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"))
+8
View File
@@ -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"),
]
+98
View File
@@ -15,10 +15,14 @@ from apps.accounts.serializers import InvitationSerializer
from apps.ai.models import AITask, QualityWord
from apps.assets.models import Asset
from apps.assets.review import poll_asset_review, submit_asset_for_review
from apps.billing.models import CreditLedger, QuotaPolicy
from apps.billing.services.ledger import adjust_credit
from apps.common.pagination import DefaultPagination
from .serializers import (
COST_ANOMALY_RATIO,
AdminLedgerSerializer,
AdminQuotaPolicySerializer,
AdminReviewAssetSerializer,
AdminTaskDetailSerializer,
AdminTaskSerializer,
@@ -399,3 +403,97 @@ def admin_task_retry(request, task_id):
target_name=task.task_type,
)
return Response({"retried": True, "task_id": str(task.id)})
# ─────────────────────────── 计费审计 + 额度策略 ───────────────────────────
@api_view(["GET"])
@permission_classes([IsPlatformAdmin])
def admin_ledgers(request):
"""全局信用流水浏览(?ledger_type= / ?team= 筛 + 分页)。"""
qs = CreditLedger.objects.select_related("team", "user").order_by("-created_at")
lt = request.query_params.get("ledger_type")
if lt in dict(CreditLedger.Type.choices):
qs = qs.filter(ledger_type=lt)
team_id = request.query_params.get("team")
if team_id:
qs = qs.filter(team_id=team_id)
paginator = DefaultPagination()
page = paginator.paginate_queryset(qs, request)
return paginator.get_paginated_response(AdminLedgerSerializer(page, many=True).data)
@api_view(["POST"])
@permission_classes([IsPlatformAdmin])
def admin_ledger_adjust(request):
"""手动调额(争议补偿):{team, amount, reason}。amount 可正可负,落 ADJUSTMENT 流水。"""
from decimal import InvalidOperation
team_id = request.data.get("team") or request.data.get("team_id")
team = Team.objects.filter(id=team_id).first()
if team is None:
return Response({"detail": "团队不存在"}, status=status.HTTP_404_NOT_FOUND)
try:
amount = Decimal(str(request.data.get("amount")))
except (InvalidOperation, TypeError, ValueError):
return Response({"amount": ["金额格式不正确"]}, status=status.HTTP_400_BAD_REQUEST)
if amount == 0:
return Response({"amount": ["调额金额不能为 0"]}, status=status.HTTP_400_BAD_REQUEST)
reason = str(request.data.get("reason") or "").strip()
try:
ledger = adjust_credit(team=team, amount=amount, reason=reason, operator=request.user)
except ValueError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
log_admin_action(
request,
"credit.adjust",
target_type="team",
target_id=team.id,
target_name=team.name,
after={"amount": str(amount), "balance_after": str(ledger.balance_after), "reason": reason},
)
return Response(AdminLedgerSerializer(ledger).data, status=status.HTTP_201_CREATED)
@api_view(["GET", "POST"])
@permission_classes([IsPlatformAdmin])
def admin_quota_policies(request):
"""GET 列额度策略(?team= 筛);POST 新建。team 必填;user/project 留空=团队级。"""
if request.method == "GET":
qs = QuotaPolicy.objects.select_related("team").order_by("-created_at")
team_id = request.query_params.get("team")
if team_id:
qs = qs.filter(team_id=team_id)
paginator = DefaultPagination()
page = paginator.paginate_queryset(qs, request)
return paginator.get_paginated_response(AdminQuotaPolicySerializer(page, many=True).data)
serializer = AdminQuotaPolicySerializer(data=request.data)
serializer.is_valid(raise_exception=True)
obj = serializer.save()
log_admin_action(
request,
"quota_policy.create",
target_type="quota_policy",
target_id=obj.id,
target_name=str(obj.team_id),
after=serializer.data,
)
return Response(AdminQuotaPolicySerializer(obj).data, status=status.HTTP_201_CREATED)
@api_view(["PATCH", "DELETE"])
@permission_classes([IsPlatformAdmin])
def admin_quota_policy_detail(request, policy_id):
obj = QuotaPolicy.objects.filter(id=policy_id).first()
if obj is None:
return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND)
if request.method == "DELETE":
log_admin_action(request, "quota_policy.delete", target_type="quota_policy", target_id=obj.id, target_name=str(obj.team_id))
obj.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
serializer = AdminQuotaPolicySerializer(obj, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
log_admin_action(request, "quota_policy.update", target_type="quota_policy", target_id=obj.id, target_name=str(obj.team_id), after=serializer.data)
return Response(AdminQuotaPolicySerializer(obj).data)
+61 -1
View File
@@ -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"])
+13
View File
@@ -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); }
+30
View File
@@ -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>
)}
</>
);
}
+23
View File
@@ -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;
+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`
- 类型/构建:`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` 全绿。