feat(admin): Phase 8 模型供应商管理 — provider/model CRUD + 启停 + 定价 + 设默认
后端:ModelConfig.is_default + migration;get_default_model 优先 is_default 否则回落最早 active(零回归); adminpanel providers CRUD(api_key write-only 不回传)+ models CRUD(?provider/capability 筛)+ set-default(同 capability 清旧); IsPlatformAdmin + 审计。 前端:adminApi providers/models/setDefault;Admin 模型供应商页(供应商表+模型表+启停+定价+设默认+供应商/模型弹窗)。 测试:adminpanel 51 单测过(api_key 隐藏+入库/CRUD/set-default 改 get_default_model); apps.ai 仍仅 3 个进场前既有失败(零回归);无头 e2e _admin-p8.mjs 5 断言过 + 0 console error (用一次性禁用 provider+model capability=export,无副作用,跑完删);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
39b4467258
commit
4ce7e957d2
@@ -3,7 +3,7 @@ from decimal import Decimal
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.accounts.models import Team, TeamMember, User
|
||||
from apps.ai.models import AITask, QualityWord
|
||||
from apps.ai.models import AITask, ModelConfig, ModelProvider, QualityWord
|
||||
from apps.assets.models import Asset
|
||||
from apps.billing.models import CreditLedger, QuotaPolicy
|
||||
|
||||
@@ -133,3 +133,35 @@ class AdminQuotaPolicySerializer(serializers.ModelSerializer):
|
||||
"monthly_limit", "project_limit", "per_task_limit", "is_active", "created_at",
|
||||
]
|
||||
read_only_fields = ["id", "team_name", "created_at"]
|
||||
|
||||
|
||||
class AdminModelProviderSerializer(serializers.ModelSerializer):
|
||||
has_api_key = serializers.SerializerMethodField()
|
||||
model_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = ModelProvider
|
||||
fields = ["id", "name", "display_name", "status", "base_url", "api_key", "metadata", "has_api_key", "model_count", "created_at"]
|
||||
# api_key 只写不读(密钥不回传前端)
|
||||
extra_kwargs = {"api_key": {"write_only": True, "required": False}}
|
||||
read_only_fields = ["id", "has_api_key", "model_count", "created_at"]
|
||||
|
||||
def get_has_api_key(self, obj) -> bool:
|
||||
return bool(obj.api_key)
|
||||
|
||||
def get_model_count(self, obj) -> int:
|
||||
anno = getattr(obj, "model_count_anno", None)
|
||||
return anno if anno is not None else obj.models.count()
|
||||
|
||||
|
||||
class AdminModelConfigSerializer(serializers.ModelSerializer):
|
||||
provider_name = serializers.CharField(source="provider.name", read_only=True, default=None)
|
||||
|
||||
class Meta:
|
||||
model = ModelConfig
|
||||
fields = [
|
||||
"id", "provider", "provider_name", "name", "display_name", "capability",
|
||||
"endpoint", "unit_price", "status", "is_default", "rate_limit_per_minute", "metadata", "created_at",
|
||||
]
|
||||
# is_default 只经 set-default 端点改,不在普通编辑里直接写
|
||||
read_only_fields = ["id", "provider_name", "is_default", "created_at"]
|
||||
|
||||
@@ -453,3 +453,81 @@ class AdminBillingTests(TestCase):
|
||||
# setUp 本月已有 CHARGE 10 → 再 +1 超 10 → 拦
|
||||
with self.assertRaises(ValueError):
|
||||
_enforce_quota_policy(team=self.team, project=None, amount=Decimal("1"))
|
||||
|
||||
|
||||
class AdminModelProviderTests(TestCase):
|
||||
"""Phase 8:模型供应商 / 模型 CRUD + 启停 + 定价 + 设默认(改变 get_default_model)+ 权限。"""
|
||||
|
||||
def setUp(self):
|
||||
from apps.ai.models import ModelConfig, ModelProvider
|
||||
|
||||
self.admin = User.objects.create_user(username="padmin8", password="x", is_platform_admin=True)
|
||||
self.normal = User.objects.create_user(username="normal8", password="x")
|
||||
self.prov = ModelProvider.objects.create(name="prov8", display_name="P8", api_key="secret-key")
|
||||
self.m1 = ModelConfig.objects.create(provider=self.prov, name="m8a", display_name="M8A", capability=ModelConfig.Capability.IMAGE)
|
||||
self.m2 = ModelConfig.objects.create(provider=self.prov, name="m8b", display_name="M8B", capability=ModelConfig.Capability.IMAGE)
|
||||
self.ac = APIClient()
|
||||
self.ac.force_authenticate(self.admin)
|
||||
self.nc = APIClient()
|
||||
self.nc.force_authenticate(self.normal)
|
||||
|
||||
def test_providers_list_permission_and_api_key_hidden(self):
|
||||
self.assertEqual(self.nc.get("/api/admin/providers/").status_code, 403)
|
||||
r = self.ac.get("/api/admin/providers/")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
row = next(p for p in r.data if p["name"] == "prov8")
|
||||
self.assertNotIn("api_key", row) # 密钥不回传
|
||||
self.assertTrue(row["has_api_key"])
|
||||
self.assertEqual(row["model_count"], 2)
|
||||
|
||||
def test_provider_create_key_writeonly_persisted(self):
|
||||
from apps.ai.models import ModelProvider
|
||||
|
||||
r = self.ac.post("/api/admin/providers/", {"name": "newp8", "display_name": "New", "api_key": "k123"}, format="json")
|
||||
self.assertEqual(r.status_code, 201)
|
||||
self.assertTrue(r.data["has_api_key"])
|
||||
self.assertNotIn("api_key", r.data)
|
||||
self.assertEqual(ModelProvider.objects.get(name="newp8").api_key, "k123")
|
||||
|
||||
def test_provider_toggle_status_and_delete(self):
|
||||
r = self.ac.patch(f"/api/admin/providers/{self.prov.id}/", {"status": "disabled"}, format="json")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(r.data["status"], "disabled")
|
||||
|
||||
def test_models_list_filter(self):
|
||||
r = self.ac.get(f"/api/admin/models/?provider={self.prov.id}")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(len(r.data), 2)
|
||||
cap = self.ac.get("/api/admin/models/?capability=image")
|
||||
self.assertTrue(all(m["capability"] == "image" for m in cap.data))
|
||||
|
||||
def test_model_create_update_delete(self):
|
||||
c = self.ac.post(
|
||||
"/api/admin/models/",
|
||||
{"provider": str(self.prov.id), "name": "m8c", "display_name": "C", "capability": "text"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(c.status_code, 201)
|
||||
mid = c.data["id"]
|
||||
up = self.ac.patch(f"/api/admin/models/{mid}/", {"unit_price": "2.5", "status": "disabled"}, format="json")
|
||||
self.assertEqual(up.data["unit_price"], "2.5000")
|
||||
self.assertEqual(up.data["status"], "disabled")
|
||||
self.assertEqual(self.ac.delete(f"/api/admin/models/{mid}/").status_code, 204)
|
||||
|
||||
def test_set_default_changes_get_default_model(self):
|
||||
from apps.ai.services import get_default_model
|
||||
|
||||
r = self.ac.post(f"/api/admin/models/{self.m2.id}/set-default/")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTrue(r.data["is_default"])
|
||||
self.assertEqual(get_default_model("image").id, self.m2.id)
|
||||
# 改设 m1 → m2 清默认
|
||||
self.ac.post(f"/api/admin/models/{self.m1.id}/set-default/")
|
||||
self.assertEqual(get_default_model("image").id, self.m1.id)
|
||||
self.m2.refresh_from_db()
|
||||
self.assertFalse(self.m2.is_default)
|
||||
self.assertTrue(AdminAuditLog.objects.filter(action="model.set_default").exists())
|
||||
|
||||
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)
|
||||
|
||||
@@ -7,6 +7,11 @@ from .views import (
|
||||
admin_invitations,
|
||||
admin_ledger_adjust,
|
||||
admin_ledgers,
|
||||
admin_model_detail,
|
||||
admin_model_set_default,
|
||||
admin_models,
|
||||
admin_provider_detail,
|
||||
admin_providers,
|
||||
admin_quota_policies,
|
||||
admin_quota_policy_detail,
|
||||
admin_task_detail,
|
||||
@@ -45,4 +50,9 @@ urlpatterns = [
|
||||
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"),
|
||||
path("providers/", admin_providers, name="admin-providers"),
|
||||
path("providers/<uuid:provider_id>/", admin_provider_detail, name="admin-provider-detail"),
|
||||
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"),
|
||||
]
|
||||
|
||||
@@ -12,7 +12,7 @@ from apps.accounts.audit import log_admin_action
|
||||
from apps.accounts.models import Invitation, Team, User
|
||||
from apps.accounts.permissions import IsPlatformAdmin
|
||||
from apps.accounts.serializers import InvitationSerializer
|
||||
from apps.ai.models import AITask, QualityWord
|
||||
from apps.ai.models import AITask, ModelConfig, ModelProvider, 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
|
||||
@@ -22,6 +22,8 @@ from apps.common.pagination import DefaultPagination
|
||||
from .serializers import (
|
||||
COST_ANOMALY_RATIO,
|
||||
AdminLedgerSerializer,
|
||||
AdminModelConfigSerializer,
|
||||
AdminModelProviderSerializer,
|
||||
AdminQuotaPolicySerializer,
|
||||
AdminReviewAssetSerializer,
|
||||
AdminTaskDetailSerializer,
|
||||
@@ -497,3 +499,89 @@ def admin_quota_policy_detail(request, policy_id):
|
||||
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)
|
||||
|
||||
|
||||
# ─────────────────────────── 模型供应商 / 模型 ───────────────────────────
|
||||
|
||||
|
||||
@api_view(["GET", "POST"])
|
||||
@permission_classes([IsPlatformAdmin])
|
||||
def admin_providers(request):
|
||||
if request.method == "GET":
|
||||
qs = ModelProvider.objects.annotate(model_count_anno=Count("models", distinct=True)).order_by("created_at")
|
||||
return Response(AdminModelProviderSerializer(qs, many=True).data)
|
||||
serializer = AdminModelProviderSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
obj = serializer.save()
|
||||
log_admin_action(request, "provider.create", target_type="model_provider", target_id=obj.id, target_name=obj.name)
|
||||
return Response(AdminModelProviderSerializer(obj).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
@api_view(["PATCH", "DELETE"])
|
||||
@permission_classes([IsPlatformAdmin])
|
||||
def admin_provider_detail(request, provider_id):
|
||||
obj = ModelProvider.objects.filter(id=provider_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, "provider.delete", target_type="model_provider", target_id=obj.id, target_name=obj.name)
|
||||
obj.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
serializer = AdminModelProviderSerializer(obj, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
log_admin_action(request, "provider.update", target_type="model_provider", target_id=obj.id, target_name=obj.name)
|
||||
return Response(AdminModelProviderSerializer(ModelProvider.objects.annotate(model_count_anno=Count("models", distinct=True)).get(id=obj.id)).data)
|
||||
|
||||
|
||||
@api_view(["GET", "POST"])
|
||||
@permission_classes([IsPlatformAdmin])
|
||||
def admin_models(request):
|
||||
if request.method == "GET":
|
||||
qs = ModelConfig.objects.select_related("provider").order_by("provider__name", "capability", "created_at")
|
||||
prov = request.query_params.get("provider")
|
||||
if prov:
|
||||
qs = qs.filter(provider_id=prov)
|
||||
cap = request.query_params.get("capability")
|
||||
if cap in dict(ModelConfig.Capability.choices):
|
||||
qs = qs.filter(capability=cap)
|
||||
return Response(AdminModelConfigSerializer(qs, many=True).data)
|
||||
serializer = AdminModelConfigSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
obj = serializer.save()
|
||||
log_admin_action(request, "model.create", target_type="model_config", target_id=obj.id, target_name=f"{obj.provider_id}:{obj.name}")
|
||||
return Response(AdminModelConfigSerializer(obj).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
@api_view(["PATCH", "DELETE"])
|
||||
@permission_classes([IsPlatformAdmin])
|
||||
def admin_model_detail(request, model_id):
|
||||
obj = ModelConfig.objects.select_related("provider").filter(id=model_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, "model.delete", target_type="model_config", target_id=obj.id, target_name=obj.name)
|
||||
obj.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
serializer = AdminModelConfigSerializer(obj, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
log_admin_action(request, "model.update", target_type="model_config", target_id=obj.id, target_name=obj.name)
|
||||
return Response(AdminModelConfigSerializer(ModelConfig.objects.select_related("provider").get(id=obj.id)).data)
|
||||
|
||||
|
||||
@api_view(["POST"])
|
||||
@permission_classes([IsPlatformAdmin])
|
||||
def admin_model_set_default(request, model_id):
|
||||
"""把某模型设为其 capability 的默认模型(同 capability 其余清默认)。"""
|
||||
from django.db import transaction
|
||||
|
||||
obj = ModelConfig.objects.filter(id=model_id).first()
|
||||
if obj is None:
|
||||
return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
with transaction.atomic():
|
||||
ModelConfig.objects.filter(capability=obj.capability).exclude(id=obj.id).update(is_default=False)
|
||||
obj.is_default = True
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.15 on 2026-06-19 14:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ai', '0009_qualityword'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='modelconfig',
|
||||
name='is_default',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -42,6 +42,8 @@ class ModelConfig(TimeStampedModel):
|
||||
unit_price = models.DecimalField(max_digits=12, decimal_places=4, default=0)
|
||||
status = models.CharField(max_length=24, choices=Status.choices, default=Status.ACTIVE)
|
||||
rate_limit_per_minute = models.PositiveIntegerField(default=60)
|
||||
# 平台超管显式钦定的「该能力默认模型」。get_default_model 优先取它,未设则回落最早 active(零回归)。
|
||||
is_default = models.BooleanField(default=False)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -37,12 +37,12 @@ from apps.projects.models import (
|
||||
|
||||
|
||||
def get_default_model(capability: str) -> ModelConfig:
|
||||
return (
|
||||
qs = (
|
||||
ModelConfig.objects.select_related("provider")
|
||||
.filter(capability=capability, status=ModelConfig.Status.ACTIVE, provider__status="active")
|
||||
.order_by("created_at")
|
||||
.first()
|
||||
)
|
||||
# 优先平台超管钦定的默认模型;未钦定则回落「最早创建的 active」(原行为,零回归)
|
||||
return qs.filter(is_default=True).order_by("created_at").first() or qs.order_by("created_at").first()
|
||||
|
||||
|
||||
# 火山官方直连(SeeDream 生图 / Seedance 视频 / 豆包文本)走 ARK SDK;其余 provider 一律
|
||||
|
||||
Reference in New Issue
Block a user