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)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.1.15 on 2026-06-19 13:43
|
||||
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ai', '0008_aitask_ai_aitask_team_id_570138_idx'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='QualityWord',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('stage', models.CharField(choices=[('person', '人物立绘'), ('model_tryon', '模特上身图'), ('storyboard', '故事板'), ('video', '视频')], max_length=24)),
|
||||
('slot', models.CharField(default='quality', max_length=24)),
|
||||
('text', models.CharField(max_length=200)),
|
||||
('sort', models.IntegerField(default=0)),
|
||||
('enabled', models.BooleanField(default=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['stage', 'sort', 'created_at'],
|
||||
'indexes': [models.Index(fields=['stage', 'slot'], name='ai_qualityw_stage_891a12_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -107,3 +107,28 @@ class AITask(TeamOwnedModel):
|
||||
def __str__(self) -> str:
|
||||
return f"{self.task_type}:{self.status}:{self.id}"
|
||||
|
||||
|
||||
class QualityWord(TimeStampedModel):
|
||||
"""平台单层质量词配置:按生成阶段(stage)+ 槽位(slot)挂若干质量词,
|
||||
生成侧拼提示词时优先读取本配置;**无配置则回落各 builder 的写死值**(零回归)。
|
||||
全平台共用一套(不挂 team),团队级覆盖以后再说。"""
|
||||
|
||||
class Stage(models.TextChoices):
|
||||
PERSON = "person", "人物立绘"
|
||||
MODEL_TRYON = "model_tryon", "模特上身图"
|
||||
STORYBOARD = "storyboard", "故事板"
|
||||
VIDEO = "video", "视频"
|
||||
|
||||
stage = models.CharField(max_length=24, choices=Stage.choices)
|
||||
slot = models.CharField(max_length=24, default="quality")
|
||||
text = models.CharField(max_length=200)
|
||||
sort = models.IntegerField(default=0)
|
||||
enabled = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["stage", "sort", "created_at"]
|
||||
indexes = [models.Index(fields=["stage", "slot"])]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.stage}/{self.slot}/{self.text}"
|
||||
|
||||
|
||||
@@ -599,6 +599,26 @@ def _product_cover_url(product) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def quality_words(stage: str, slot: str = "quality") -> list[str]:
|
||||
"""平台单层质量词配置(QualityWord)。无配置/表不存在 → 返回 [],调用方回落写死值,保证零回归。"""
|
||||
try:
|
||||
from apps.ai.models import QualityWord
|
||||
|
||||
return list(
|
||||
QualityWord.objects.filter(stage=stage, slot=slot, enabled=True)
|
||||
.order_by("sort", "created_at")
|
||||
.values_list("text", flat=True)
|
||||
)
|
||||
except Exception: # noqa: BLE001 — 配置读取失败绝不阻断生成
|
||||
return []
|
||||
|
||||
|
||||
def quality_suffix(stage: str, fallback: str, slot: str = "quality", sep: str = ",") -> str:
|
||||
"""取该阶段配置的质量词拼成尾串;无配置回落到 fallback(各 builder 的原写死值)。"""
|
||||
words = quality_words(stage, slot)
|
||||
return sep.join(words) if words else fallback
|
||||
|
||||
|
||||
def build_product_triview_prompt_refs(product, base_prompt: str = "") -> str:
|
||||
"""商品三视图 image_edit 提示词(refs 版):参考图1=商品真实主图,锁包装一致性。"""
|
||||
name = (getattr(product, "title", "") or "商品").strip()
|
||||
@@ -630,7 +650,7 @@ def build_model_tryon_prompt_refs(product, has_model: bool, base_prompt: str = "
|
||||
"请生成一位真人模特自然地展示/佩戴/使用参考图1中商品的电商效果图。",
|
||||
"商品的外形、品牌文字、配色、Logo 必须与参考图1高度一致,不要改动或重新设计。",
|
||||
]
|
||||
lines.append("自然光、真实质感、干净背景、电商主图构图,人物与商品比例真实协调。")
|
||||
lines.append(quality_suffix("model_tryon", "自然光、真实质感、干净背景、电商主图构图,人物与商品比例真实协调。"))
|
||||
if base_prompt and base_prompt.strip():
|
||||
lines.append(base_prompt.strip())
|
||||
return " ".join(lines)
|
||||
@@ -662,7 +682,8 @@ def build_person_frontal_prompt(description: str = "") -> str:
|
||||
用户钦定格式:电商真人模特,氛围正面全身照,<描述>,自然妆容,柔和影棚光,真实质感,单人,纯色背景。"""
|
||||
desc = (description or "").strip()
|
||||
inner = f"{desc}," if desc else ""
|
||||
return f"电商真人模特,氛围正面全身照,{inner}自然妆容,柔和影棚光,真实质感,单人,纯色背景"
|
||||
tail = quality_suffix("person", "自然妆容,柔和影棚光,真实质感,单人,纯色背景")
|
||||
return f"电商真人模特,氛围正面全身照,{inner}{tail}"
|
||||
|
||||
|
||||
def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "", group_id: str | None = None) -> AITask:
|
||||
@@ -896,7 +917,7 @@ def build_storyboard_frame_prompt(project, version, segment) -> str:
|
||||
]
|
||||
if version.prompt:
|
||||
lines.append(version.prompt.strip())
|
||||
lines.append("电商竖屏 9:16 导演故事板,真实清新、生活化,画面清晰,可直接指导视频生成。")
|
||||
lines.append(quality_suffix("storyboard", "电商竖屏 9:16 导演故事板,真实清新、生活化,画面清晰,可直接指导视频生成。"))
|
||||
return "\n".join(line for line in lines if line)
|
||||
|
||||
|
||||
@@ -923,7 +944,10 @@ def build_video_segment_prompt(project, video_segment, scene, refs, user_prompt:
|
||||
if extra:
|
||||
script_text = (script_text + "\n" + extra).strip() if script_text else extra
|
||||
lines.append("【脚本】" + (script_text or f"第 {video_segment.sort_order + 1} 段"))
|
||||
lines.append(f"{video_segment.target_duration_seconds}s · 9:16 竖屏电商带货短视频,镜头稳定,商品露出清晰,节奏有转化感。")
|
||||
lines.append(
|
||||
f"{video_segment.target_duration_seconds}s · "
|
||||
+ quality_suffix("video", "9:16 竖屏电商带货短视频,镜头稳定,商品露出清晰,节奏有转化感。")
|
||||
)
|
||||
return "\n".join(line for line in lines if line)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user