feat(admin): Phase 9 收尾治理 — 全局项目监控 + 数据完整性体检
后端:adminpanel projects(全局项目流水线监控+状态筛)+ integrity(数据体检:无账户团队/无图商品/过期邀请码/ 无文件资产/失败项目/卡 reserved 计数 + totals);AdminProjectSerializer;IsPlatformAdmin。 前端:adminApi adminProjects/integrity;Admin 治理页(体检卡片超0高亮 + 项目监控表状态筛)。 测试:adminpanel 53 单测过(项目列/筛/体检报告);无头 e2e _admin-p9.mjs 4 断言过 + 0 console error(全只读);tsc+build 绿。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
4ce7e957d2
commit
cdfcffcecd
@@ -6,6 +6,7 @@ from apps.accounts.models import Team, TeamMember, User
|
||||
from apps.ai.models import AITask, ModelConfig, ModelProvider, QualityWord
|
||||
from apps.assets.models import Asset
|
||||
from apps.billing.models import CreditLedger, QuotaPolicy
|
||||
from apps.projects.models import Project
|
||||
|
||||
# 成本异常阈值:实际成本 > 预估 × 此倍数(且预估 > 0)即标异常
|
||||
COST_ANOMALY_RATIO = Decimal("1.5")
|
||||
@@ -165,3 +166,13 @@ class AdminModelConfigSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
# is_default 只经 set-default 端点改,不在普通编辑里直接写
|
||||
read_only_fields = ["id", "provider_name", "is_default", "created_at"]
|
||||
|
||||
|
||||
class AdminProjectSerializer(serializers.ModelSerializer):
|
||||
team_name = serializers.CharField(source="team.name", read_only=True, default=None)
|
||||
product_title = serializers.CharField(source="product.title", read_only=True, default=None)
|
||||
|
||||
class Meta:
|
||||
model = Project
|
||||
fields = ["id", "name", "team", "team_name", "product_title", "status", "current_stage", "created_at"]
|
||||
read_only_fields = fields
|
||||
|
||||
@@ -531,3 +531,47 @@ class AdminModelProviderTests(TestCase):
|
||||
def test_write_requires_admin(self):
|
||||
self.assertEqual(self.nc.post("/api/admin/providers/", {"name": "x", "display_name": "x"}, format="json").status_code, 403)
|
||||
self.assertEqual(self.nc.post(f"/api/admin/models/{self.m1.id}/set-default/").status_code, 403)
|
||||
|
||||
|
||||
class AdminGovernanceTests(TestCase):
|
||||
"""Phase 9:收尾治理(全局项目监控 + 数据完整性体检)+ 权限。"""
|
||||
|
||||
def setUp(self):
|
||||
from apps.products.models import Product
|
||||
from apps.projects.models import Project
|
||||
|
||||
self.admin = User.objects.create_user(username="padmin9", password="x", is_platform_admin=True)
|
||||
self.normal = User.objects.create_user(username="normal9", password="x")
|
||||
self.team = Team.objects.create(name="GovTeam", owner=self.normal)
|
||||
TeamMember.objects.create(team=self.team, user=self.normal, role=TeamMember.Role.OWNER)
|
||||
self.product = Product.objects.create(team=self.team, title="无图商品", created_by=self.normal)
|
||||
self.proj_ok = Project.objects.create(team=self.team, name="P-ok", product=self.product, status=Project.Status.SCRIPTING, created_by=self.normal)
|
||||
self.proj_failed = Project.objects.create(team=self.team, name="P-failed", product=self.product, status=Project.Status.FAILED, created_by=self.normal)
|
||||
self.ac = APIClient()
|
||||
self.ac.force_authenticate(self.admin)
|
||||
self.nc = APIClient()
|
||||
self.nc.force_authenticate(self.normal)
|
||||
|
||||
def test_projects_list_permission_and_filter(self):
|
||||
self.assertEqual(self.nc.get("/api/admin/projects/").status_code, 403)
|
||||
r = self.ac.get("/api/admin/projects/")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertGreaterEqual(r.data["count"], 2)
|
||||
failed = self.ac.get("/api/admin/projects/?status=failed")
|
||||
self.assertTrue(all(p["status"] == "failed" for p in failed.data["results"]))
|
||||
# 行带团队名 + 商品名
|
||||
row = next(p for p in r.data["results"] if p["name"] == "P-ok")
|
||||
self.assertEqual(row["team_name"], "GovTeam")
|
||||
self.assertEqual(row["product_title"], "无图商品")
|
||||
|
||||
def test_integrity_report(self):
|
||||
self.assertEqual(self.nc.get("/api/admin/integrity/").status_code, 403)
|
||||
r = self.ac.get("/api/admin/integrity/")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
checks = {c["key"]: c["count"] for c in r.data["checks"]}
|
||||
self.assertIn("products_without_image", checks)
|
||||
self.assertGreaterEqual(checks["products_without_image"], 1) # 我们建了一个无图商品
|
||||
self.assertGreaterEqual(checks["failed_projects"], 1)
|
||||
self.assertGreaterEqual(checks["teams_without_account"], 1) # GovTeam 无 credit_account
|
||||
self.assertIn("totals", r.data)
|
||||
self.assertGreaterEqual(r.data["totals"]["projects"], 2)
|
||||
|
||||
@@ -10,6 +10,8 @@ from .views import (
|
||||
admin_model_detail,
|
||||
admin_model_set_default,
|
||||
admin_models,
|
||||
admin_integrity,
|
||||
admin_projects,
|
||||
admin_provider_detail,
|
||||
admin_providers,
|
||||
admin_quota_policies,
|
||||
@@ -55,4 +57,6 @@ urlpatterns = [
|
||||
path("models/", admin_models, name="admin-models"),
|
||||
path("models/<uuid:model_id>/", admin_model_detail, name="admin-model-detail"),
|
||||
path("models/<uuid:model_id>/set-default/", admin_model_set_default, name="admin-model-set-default"),
|
||||
path("projects/", admin_projects, name="admin-projects"),
|
||||
path("integrity/", admin_integrity, name="admin-integrity"),
|
||||
]
|
||||
|
||||
@@ -18,12 +18,15 @@ 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.products.models import Product
|
||||
from apps.projects.models import Project
|
||||
|
||||
from .serializers import (
|
||||
COST_ANOMALY_RATIO,
|
||||
AdminLedgerSerializer,
|
||||
AdminModelConfigSerializer,
|
||||
AdminModelProviderSerializer,
|
||||
AdminProjectSerializer,
|
||||
AdminQuotaPolicySerializer,
|
||||
AdminReviewAssetSerializer,
|
||||
AdminTaskDetailSerializer,
|
||||
@@ -585,3 +588,40 @@ def admin_model_set_default(request, model_id):
|
||||
obj.save(update_fields=["is_default", "updated_at"])
|
||||
log_admin_action(request, "model.set_default", target_type="model_config", target_id=obj.id, target_name=f"{obj.capability}:{obj.name}")
|
||||
return Response(AdminModelConfigSerializer(ModelConfig.objects.select_related("provider").get(id=obj.id)).data)
|
||||
|
||||
|
||||
# ─────────────────────────── 收尾治理(项目监控 + 数据完整性)───────────────────────────
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsPlatformAdmin])
|
||||
def admin_projects(request):
|
||||
"""全局项目流水线监控(?status= / ?search= 筛 + 分页)。"""
|
||||
qs = Project.objects.select_related("team", "product").order_by("-created_at")
|
||||
st = request.query_params.get("status")
|
||||
if st in dict(Project.Status.choices):
|
||||
qs = qs.filter(status=st)
|
||||
search = (request.query_params.get("search") or "").strip()
|
||||
if search:
|
||||
qs = qs.filter(Q(name__icontains=search) | Q(team__name__icontains=search))
|
||||
paginator = DefaultPagination()
|
||||
page = paginator.paginate_queryset(qs, request)
|
||||
return paginator.get_paginated_response(AdminProjectSerializer(page, many=True).data)
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsPlatformAdmin])
|
||||
def admin_integrity(request):
|
||||
"""数据完整性 / 治理体检(只读):统计潜在孤儿 / 异常记录数,供平台超管巡检。"""
|
||||
from django.utils import timezone
|
||||
|
||||
now = timezone.now()
|
||||
checks = [
|
||||
{"key": "teams_without_account", "label": "无信用账户的团队", "count": Team.objects.filter(credit_account__isnull=True).count()},
|
||||
{"key": "products_without_image", "label": "无图商品(无主图且无图册)", "count": Product.objects.filter(cover_asset__isnull=True, images__isnull=True).distinct().count()},
|
||||
{"key": "expired_pending_invites", "label": "已过期但未标记的邀请码", "count": Invitation.objects.filter(status=Invitation.Status.PENDING, expires_at__lt=now).count()},
|
||||
{"key": "assets_without_files", "label": "无文件的资产(未删除)", "count": Asset.objects.filter(is_deleted=False, files__isnull=True).distinct().count()},
|
||||
{"key": "failed_projects", "label": "失败的项目", "count": Project.objects.filter(status=Project.Status.FAILED).count()},
|
||||
{"key": "stale_reserved_tasks", "label": "卡在 reserved 的任务", "count": AITask.objects.filter(status=AITask.Status.RESERVED).count()},
|
||||
]
|
||||
return Response({"checks": checks, "totals": {"teams": Team.objects.count(), "users": User.objects.count(), "projects": Project.objects.count(), "products": Product.objects.count()}})
|
||||
|
||||
Reference in New Issue
Block a user