From 39b4467258b8e23acf54ebd317704e421259366d Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Fri, 19 Jun 2026 22:33:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20Phase=207=20=E8=AE=A1=E8=B4=B9?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1+4=E5=B1=82=E9=A2=9D=E5=BA=A6=E7=AD=96?= =?UTF-8?q?=E7=95=A5=20=E2=80=94=20=E6=B5=81=E6=B0=B4=E6=B5=8F=E8=A7=88/?= =?UTF-8?q?=E6=89=8B=E5=8A=A8=E8=B0=83=E9=A2=9D/=E9=A2=9D=E5=BA=A6?= =?UTF-8?q?=E7=AD=96=E7=95=A5CRUD+=E6=8B=A6=E6=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: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 --- core/backend/apps/accounts/audit.py | 43 ++- core/backend/apps/adminpanel/serializers.py | 23 ++ core/backend/apps/adminpanel/tests.py | 86 +++++ core/backend/apps/adminpanel/urls.py | 8 + core/backend/apps/adminpanel/views.py | 98 ++++++ core/backend/apps/billing/services/ledger.py | 62 +++- core/frontend/src/admin-page.css | 13 + core/frontend/src/api.ts | 30 ++ core/frontend/src/routes/admin/admin-app.tsx | 7 + .../src/routes/admin/admin-billing.tsx | 331 ++++++++++++++++++ core/frontend/src/types.ts | 23 ++ core/qa/visual-parity/_admin-p7.mjs | 102 ++++++ docs/todo/admin-后台-进度.md | 20 ++ 13 files changed, 833 insertions(+), 13 deletions(-) create mode 100644 core/frontend/src/routes/admin/admin-billing.tsx create mode 100644 core/qa/visual-parity/_admin-p7.mjs diff --git a/core/backend/apps/accounts/audit.py b/core/backend/apps/accounts/audit.py index 3e45ab7..904ab27 100644 --- a/core/backend/apps/accounts/audit.py +++ b/core/backend/apps/accounts/audit.py @@ -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 diff --git a/core/backend/apps/adminpanel/serializers.py b/core/backend/apps/adminpanel/serializers.py index 073866b..4f1638a 100644 --- a/core/backend/apps/adminpanel/serializers.py +++ b/core/backend/apps/adminpanel/serializers.py @@ -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"] diff --git a/core/backend/apps/adminpanel/tests.py b/core/backend/apps/adminpanel/tests.py index 366d6c6..30733f6 100644 --- a/core/backend/apps/adminpanel/tests.py +++ b/core/backend/apps/adminpanel/tests.py @@ -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")) diff --git a/core/backend/apps/adminpanel/urls.py b/core/backend/apps/adminpanel/urls.py index 173c2c7..e718e60 100644 --- a/core/backend/apps/adminpanel/urls.py +++ b/core/backend/apps/adminpanel/urls.py @@ -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//", admin_task_detail, name="admin-task-detail"), path("tasks//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//", admin_quota_policy_detail, name="admin-quota-policy-detail"), ] diff --git a/core/backend/apps/adminpanel/views.py b/core/backend/apps/adminpanel/views.py index 1b9659a..4ccb404 100644 --- a/core/backend/apps/adminpanel/views.py +++ b/core/backend/apps/adminpanel/views.py @@ -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) diff --git a/core/backend/apps/billing/services/ledger.py b/core/backend/apps/billing/services/ledger.py index ce23afe..3be6156 100644 --- a/core/backend/apps/billing/services/ledger.py +++ b/core/backend/apps/billing/services/ledger.py @@ -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"]) diff --git a/core/frontend/src/admin-page.css b/core/frontend/src/admin-page.css index 989940d..d743ad8 100644 --- a/core/frontend/src/admin-page.css +++ b/core/frontend/src/admin-page.css @@ -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); } diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index e88ac74..b3e69d0 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -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>(`/api/admin/ledgers/${q ? `?${q}` : ""}`); + }, + adjustCredit(payload: { team: string; amount: string; reason: string }) { + return request("/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>(`/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("/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(`/api/admin/quota-policies/${id}/`, { method: "PATCH", body: JSON.stringify(payload) }); + }, + deleteQuotaPolicy(id: string) { + return request(`/api/admin/quota-policies/${id}/`, { method: "DELETE" }); } }; diff --git a/core/frontend/src/routes/admin/admin-app.tsx b/core/frontend/src/routes/admin/admin-app.tsx index b8ba03c..f2812d9 100644 --- a/core/frontend/src/routes/admin/admin-app.tsx +++ b/core/frontend/src/routes/admin/admin-app.tsx @@ -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 ; } + if (section.slug === "billing") { + return ; + } + if (section.slug === "quota") { + return ; + } // 其余模块在各自阶段替换此占位为真实页面 return ; } diff --git a/core/frontend/src/routes/admin/admin-billing.tsx b/core/frontend/src/routes/admin/admin-billing.tsx new file mode 100644 index 0000000..99c221f --- /dev/null +++ b/core/frontend/src/routes/admin/admin-billing.tsx @@ -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 = { + recharge: "充值", reserve: "预扣", release: "释放", charge: "扣费", adjustment: "调额", refund: "退款" +}; +function ledgerPill(t: string) { + const cls = ["recharge", "refund", "release"].includes(t) ? "ok" : t === "adjustment" ? "info" : "neutral"; + return {LEDGER_LABEL[t] || t}; +} + +// 复用:加载团队下拉 +function useTeams() { + const [teams, setTeams] = useState([]); + useEffect(() => { + adminApi.teams({ page_size: 200 }).then((r) => setTeams(r.results)).catch(() => {}); + }, []); + return teams; +} + +// ─────────────────────────── 计费审计 ─────────────────────────── + +export function AdminLedgersPage({ notify }: { notify: Notify }) { + const [ledgers, setLedgers] = useState([]); + 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 ( + <> +
+
+

计费审计

+
// {count} 条流水 · 全局信用流水 · 手动调额
+
+
+ +
+
+ +
+
+ {LEDGER_TABS.map((t) => ( + + ))} +
+
+ + {loading ? ( +

加载中…

// fetching ledgers

+ ) : ledgers.length === 0 ? ( +

暂无流水

// no ledgers

+ ) : ( +
+ + + + {ledgers.map((l) => ( + + + + + + + + + + ))} + +
团队用户类型金额余额后备注时间
{l.team_name || }{l.username || }{ledgerPill(l.ledger_type)}¥{l.amount}¥{l.balance_after}{l.reason || }{fmtDate(l.created_at)}
+
+ )} + + {modalOpen && ( +
{ if (e.target === e.currentTarget) setModalOpen(false); }}> +
+ ++ +
+
+
手动调额// credit adjust
+ +
+
+

为团队手动加 / 减额度(争议补偿)。金额可为负;调额后余额不得为负。会落一条调额流水。

+
+ + +
+
+ + setForm((f) => ({ ...f, amount: e.target.value }))} /> +
+
+ + setForm((f) => ({ ...f, reason: e.target.value }))} /> +
+
+
+ + +
+
+
+ )} + + ); +} + +// ─────────────────────────── 额度策略 ─────────────────────────── + +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([]); + const [count, setCount] = useState(0); + const [loading, setLoading] = useState(true); + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(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 ? 不限 : `¥${v}`); + + return ( + <> +
+
+

额度策略

+
// {count} 条 · 4 层额度(团队月度 / 项目 / 单任务)· 设了才拦,留空不限
+
+
+ +
+
+ + {loading ? ( +

加载中…

// fetching policies

+ ) : policies.length === 0 ? ( +

暂无额度策略

// 默认仅余额管控 · 点右上新建

+ ) : ( +
+ + + + {policies.map((p) => ( + + + + + + + + + ))} + +
团队月度上限项目上限单任务上限状态操作
{p.team_name || }{lim(p.monthly_limit)}{lim(p.project_limit)}{lim(p.per_task_limit)}{p.is_active ? 启用 : 停用} + + +
+
+ )} + + {modalOpen && ( +
{ if (e.target === e.currentTarget) setModalOpen(false); }}> +
+ ++ +
+
+
{editing.id ? "编辑额度策略" : "新建额度策略"}// quota policy
+ +
+
+

各上限留空 = 该维度不限。任一上限触发即拦截生成(在原余额管控之上叠加)。

+
+ + +
+
+
+ + setEditing((p) => ({ ...p, monthly_limit: e.target.value }))} /> +
+
+ + setEditing((p) => ({ ...p, per_task_limit: e.target.value }))} /> +
+
+
+ + setEditing((p) => ({ ...p, project_limit: e.target.value }))} /> +
+ +
+
+ + +
+
+
+ )} + + ); +} diff --git a/core/frontend/src/types.ts b/core/frontend/src/types.ts index 9bb08e7..0dd3e8a 100644 --- a/core/frontend/src/types.ts +++ b/core/frontend/src/types.ts @@ -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 = { count: number; diff --git a/core/qa/visual-parity/_admin-p7.mjs b/core/qa/visual-parity/_admin-p7.mjs new file mode 100644 index 0000000..cfcc14d --- /dev/null +++ b/core/qa/visual-parity/_admin-p7.mjs @@ -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); diff --git a/docs/todo/admin-后台-进度.md b/docs/todo/admin-后台-进度.md index bc79666..adf191d 100644 --- a/docs/todo/admin-后台-进度.md +++ b/docs/todo/admin-后台-进度.md @@ -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//`;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` 全绿。