feat(admin): Phase 4 质量词 Admin(平台单层) — QualityWord + 生成侧读配置(无配置回落) + 质量词页

后端:apps/ai QualityWord(stage/slot/text/sort/enabled)+ migration;services.py quality_words/quality_suffix,
4 个 builder 接入(person/model_tryon/storyboard/video)无配置回落原写死值零回归;adminpanel CRUD + 审计。
前端:adminApi quality 系列;Admin 质量词页(按 stage 分组卡 + 编辑模式开关 + 行内增改删 chip)。
测试:adminpanel 23 + accounts 22 = 45 单测过(CRUD/权限/生成读配置+回落/停用词不取);
无头 e2e _admin-p4.mjs 4 断言过 + 0 console error + live 库零残留;tsc+build 绿。
注:apps.ai 3 个 image_edit 路由测试失败为进场前既有(已 stash 比对 HEAD 同样失败),非本 Phase 引入。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-19 21:52:41 +08:00
co-authored by Claude Opus 4.8
parent 6560633959
commit 01d4f235ed
14 changed files with 535 additions and 5 deletions
@@ -1,6 +1,14 @@
from rest_framework import serializers
from apps.accounts.models import Team, TeamMember, User
from apps.ai.models import QualityWord
class QualityWordSerializer(serializers.ModelSerializer):
class Meta:
model = QualityWord
fields = ["id", "stage", "slot", "text", "sort", "enabled", "created_at"]
read_only_fields = ["id", "created_at"]
class AdminTeamSerializer(serializers.ModelSerializer):
+61
View File
@@ -171,3 +171,64 @@ class AdminTeamUserApiTests(TestCase):
self.normal.post(f"/api/admin/users/{self.member.id}/reset-password/", {"password": "newpass789"}, format="json").status_code,
403,
)
class AdminQualityWordTests(TestCase):
"""Phase 4:质量词 CRUD + 权限 + 生成侧读配置(无配置回落写死值,零回归)。"""
def setUp(self):
self.admin = User.objects.create_user(username="padmin4", password="x", is_platform_admin=True)
self.normal = User.objects.create_user(username="normal4", password="x")
self.ac = APIClient()
self.ac.force_authenticate(self.admin)
self.nc = APIClient()
self.nc.force_authenticate(self.normal)
def test_crud_and_permission_and_audit(self):
self.assertEqual(self.nc.get("/api/admin/quality-words/").status_code, 403)
self.assertEqual(self.nc.post("/api/admin/quality-words/", {"stage": "person", "text": "x"}, format="json").status_code, 403)
r = self.ac.post("/api/admin/quality-words/", {"stage": "person", "text": "赛博朋克风"}, format="json")
self.assertEqual(r.status_code, 201)
wid = r.data["id"]
self.assertTrue(AdminAuditLog.objects.filter(action="quality_word.create").exists())
lst = self.ac.get("/api/admin/quality-words/")
self.assertEqual(lst.status_code, 200)
self.assertTrue(any(w["text"] == "赛博朋克风" for w in lst.data))
up = self.ac.patch(f"/api/admin/quality-words/{wid}/", {"text": "国潮风"}, format="json")
self.assertEqual(up.status_code, 200)
self.assertEqual(up.data["text"], "国潮风")
self.assertEqual(self.ac.delete(f"/api/admin/quality-words/{wid}/").status_code, 204)
def test_invalid_stage_rejected(self):
r = self.ac.post("/api/admin/quality-words/", {"stage": "bogus", "text": "x"}, format="json")
self.assertEqual(r.status_code, 400)
def test_generation_reads_config_with_fallback(self):
from apps.ai.models import QualityWord
from apps.ai.services import build_person_frontal_prompt
# 无配置 → 回落写死值
self.assertIn("纯色背景", build_person_frontal_prompt("小姐姐"))
# 配置后 → 生成用新词,描述仍保留
QualityWord.objects.create(stage="person", text="赛博朋克风", sort=0)
QualityWord.objects.create(stage="person", text="霓虹光", sort=1)
out = build_person_frontal_prompt("小姐姐")
self.assertIn("赛博朋克风", out)
self.assertIn("霓虹光", out)
self.assertIn("小姐姐", out)
self.assertNotIn("纯色背景", out)
def test_disabled_word_not_used(self):
from apps.ai.models import QualityWord
from apps.ai.services import quality_words
QualityWord.objects.create(stage="video", text="启用词", enabled=True)
QualityWord.objects.create(stage="video", text="停用词", enabled=False)
words = quality_words("video")
self.assertIn("启用词", words)
self.assertNotIn("停用词", words)
+4
View File
@@ -2,6 +2,8 @@ from django.urls import path
from .views import (
admin_invitations,
admin_quality_word_detail,
admin_quality_words,
admin_revoke_invitation,
admin_team_detail,
admin_team_toggle,
@@ -21,4 +23,6 @@ urlpatterns = [
path("users/", admin_users, name="admin-users"),
path("users/<uuid:user_id>/toggle/", admin_user_toggle, name="admin-user-toggle"),
path("users/<uuid:user_id>/reset-password/", admin_user_reset_password, name="admin-user-reset-password"),
path("quality-words/", admin_quality_words, name="admin-quality-words"),
path("quality-words/<uuid:word_id>/", admin_quality_word_detail, name="admin-quality-word-detail"),
]
+64 -1
View File
@@ -10,9 +10,15 @@ 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 QualityWord
from apps.common.pagination import DefaultPagination
from .serializers import AdminTeamMemberSerializer, AdminTeamSerializer, AdminUserSerializer
from .serializers import (
AdminTeamMemberSerializer,
AdminTeamSerializer,
AdminUserSerializer,
QualityWordSerializer,
)
def _team_qs():
@@ -199,3 +205,60 @@ def admin_user_reset_password(request, user_id):
target_name=user.username,
)
return Response(status=status.HTTP_204_NO_CONTENT)
# ─────────────────────────── 质量词(平台单层)───────────────────────────
@api_view(["GET", "POST"])
@permission_classes([IsPlatformAdmin])
def admin_quality_words(request):
"""GET 列全部质量词(配置量小,不分页,前端按 stage 分组);POST 新增一条。"""
if request.method == "GET":
qs = QualityWord.objects.all().order_by("stage", "sort", "created_at")
return Response(QualityWordSerializer(qs, many=True).data)
serializer = QualityWordSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
obj = serializer.save()
log_admin_action(
request,
"quality_word.create",
target_type="quality_word",
target_id=obj.id,
target_name=f"{obj.stage}:{obj.text}",
after={"stage": obj.stage, "text": obj.text},
)
return Response(QualityWordSerializer(obj).data, status=status.HTTP_201_CREATED)
@api_view(["PATCH", "DELETE"])
@permission_classes([IsPlatformAdmin])
def admin_quality_word_detail(request, word_id):
obj = QualityWord.objects.filter(id=word_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,
"quality_word.delete",
target_type="quality_word",
target_id=obj.id,
target_name=f"{obj.stage}:{obj.text}",
before={"stage": obj.stage, "text": obj.text},
)
obj.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
before_text = obj.text
serializer = QualityWordSerializer(obj, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
log_admin_action(
request,
"quality_word.update",
target_type="quality_word",
target_id=obj.id,
target_name=f"{obj.stage}:{obj.text}",
before={"text": before_text},
after={"text": obj.text},
)
return Response(QualityWordSerializer(obj).data)