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:
seaislee1209
2026-06-19 22:45:14 +08:00
co-authored by Claude Opus 4.8
parent 39b4467258
commit 4ce7e957d2
13 changed files with 673 additions and 5 deletions
+33 -1
View File
@@ -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"]
+78
View File
@@ -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)
+10
View File
@@ -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"),
]
+89 -1
View File
@@ -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),
),
]
+2
View File
@@ -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:
+3 -3
View File
@@ -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 一律