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
+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)