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:
co-authored by
Claude Opus 4.8
parent
6560633959
commit
01d4f235ed
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user