feat(#1894): 废弃标题库整合到文案库 - scripts 模型新增 title_text/title_category/title_config 字段 - 数据迁移脚本 009 - title_libraries API 标记 deprecated - 视频生成支持从 script 读取标题配置 - 补充单元测试

This commit is contained in:
xiaoxia
2026-09-15 12:35:42 +08:00
parent 352221f199
commit 19b0f9f38e
6 changed files with 409 additions and 2 deletions
+22 -1
View File
@@ -355,7 +355,28 @@ def create_preview_generation_task(
)
strategy_id = _resolve_strategy_id_from_template(request.template_id, db, user_id)
base_title_config = request.title_config or {}
# #1894: 如果传了 script_id,从 script 读取标题配置作为基础配置
base_title_config = {}
if request.script_id:
try:
from app.services.script_service import ScriptService
_script_svc = ScriptService(db)
base_title_config = _script_svc.get_title_config_for_script(request.script_id, user_id)
logger.info(
"[预览生成] 从 script 读取标题配置: script_id=%s title_config=%s",
request.script_id,
base_title_config,
)
except Exception:
logger.warning(
"[预览生成] 从 script 读取标题配置失败(不影响主流程): script_id=%s",
request.script_id,
exc_info=True,
)
# request.title_config 优先级高于 script 中的配置
if request.title_config:
base_title_config.update(request.title_config)
use_case = CreateGenerationTaskUseCase(generation_task_repository)
+24 -1
View File
@@ -582,7 +582,30 @@ def create_generation_task(
# 变体级独立配置:titles[]/voice_library_ids[]/cover_urls[]
# 长度1=所有变体共用,长度=count=每个变体独立,空数组=回退单值字段
variant_title_text = _variant_value(request.titles, task_index, "")
variant_title_config = dict(request.title_config or {})
# #1894: 如果传了 script_id,从 script 读取标题配置作为基础配置
script_title_config = {}
if request.script_id:
try:
from app.services.script_service import ScriptService
_script_svc = ScriptService(db)
script_title_config = _script_svc.get_title_config_for_script(request.script_id, user_id)
logger.info(
"[生成任务] 从 script 读取标题配置: script_id=%s title_config=%s",
request.script_id,
script_title_config,
)
except Exception:
logger.warning(
"[生成任务] 从 script 读取标题配置失败(不影响主流程): script_id=%s",
request.script_id,
exc_info=True,
)
# 优先级:request.titles > request.title_config > script.title_config
variant_title_config = dict(script_title_config)
if request.title_config:
variant_title_config.update(request.title_config)
if variant_title_text.strip():
variant_title_config["text"] = variant_title_text.strip()
variant_voice_library_id = _variant_value(request.voice_library_ids, task_index, request.voice_library_id)
+10
View File
@@ -43,6 +43,11 @@ class CreateGenerationTaskRequest(BaseModel):
default_factory=list,
description="POST /generation/variant-plans 返回的各变体 plan_id(长度须=count);为空则走服务端选片",
)
# ── 文案库关联(#1894 标题库整合到文案库)──
script_id: str = Field(
default="",
description="关联的文案(script)ID;非空时从 script 读取 title_text/title_config 作为标题配置",
)
# ── 标题配置(结构化)──
title_config: dict | None = Field(
default=None,
@@ -237,6 +242,11 @@ class CreatePreviewGenerationTaskRequest(BaseModel):
default_factory=dict,
description="标题配置(可选),渲染时烧录到预览视频中。支持字段: text/font/font_size/font_color/position/bold/stroke/shadow。N个变体时样式全局共用",
)
# #1894: 文案库关联,从 script 读取标题配置
script_id: str = Field(
default="",
description="关联的文案(script)ID;非空时从 script 读取 title_text/title_config 作为标题配置",
)
# ── 多变体独立配置(preview_count > 1)──
# 长度 1 = 所有变体共用;长度 = preview_count = 每个变体独立;空数组 = 回退单值字段
titles: list[str] = Field(
@@ -0,0 +1,41 @@
-- #1894 废弃标题库整合到文案库
-- scripts 表新增 title_text / title_category / title_config 字段
-- 从 title_libraries 迁移现有数据到 scripts
-- 创建时间: 2026-09-15
-- 1. scripts 表新增字段
ALTER TABLE scripts ADD COLUMN IF NOT EXISTS title_text VARCHAR(500) NOT NULL DEFAULT '';
ALTER TABLE scripts ADD COLUMN IF NOT EXISTS title_category VARCHAR(50) NOT NULL DEFAULT 'default';
ALTER TABLE scripts ADD COLUMN IF NOT EXISTS title_config JSON NOT NULL DEFAULT '{}';
CREATE INDEX IF NOT EXISTS idx_scripts_title_category ON scripts(title_category);
-- 2. 数据迁移:将 title_libraries 中的活跃数据迁移到 scripts
-- 映射关系:
-- title_libraries.name → scripts.title (文案库标题)
-- title_libraries.text → scripts.title_text (标题文本)
-- title_libraries.category → scripts.title_category
-- title_libraries.extra_meta → scripts.title_config (标题样式配置)
-- title_libraries.tags → scripts.tags
-- scripts.content → 空字符串(纯标题无口播内容)
-- scripts.segments → 空数组
--
-- 使用 'title_library_migration' 标签标记迁移来源,便于追溯
INSERT INTO scripts (id, user_id, title, content, segments, tags, title_text, title_category, title_config, created_at, updated_at)
SELECT
tl.id,
tl.user_id,
tl.name, -- title = 标题库的 name
'', -- content 留空
'[]'::jsonb, -- segments 留空
COALESCE(tl.tags, '[]'::jsonb) || '["title_library_migration"]'::jsonb, -- 追加迁移标签
COALESCE(tl.text, ''), -- title_text
COALESCE(tl.category, 'default'), -- title_category
COALESCE(tl.metadata, '{}'::jsonb), -- title_config (原 extra_meta)
tl.created_at,
tl.updated_at
FROM title_libraries tl
WHERE tl.is_active = TRUE
AND NOT EXISTS (
SELECT 1 FROM scripts s WHERE s.id = tl.id
);
+286
View File
@@ -0,0 +1,286 @@
"""#1894 废弃标题库整合到文案库 — 单元测试.
覆盖:
- ScriptModel 新增字段 (title_text, title_category, title_config)
- ScriptService 新增字段的 CRUD 操作
- _normalize_title_config 字段名归一化
- get_title_config_for_script 辅助方法
- list_scripts 按 title_category 筛选
"""
from __future__ import annotations
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
from app.services.script_service import ScriptNotFoundError, ScriptService
# ── helpers ──────────────────────────────────────────────────────────────────
def _make_mock_script(
script_id="s1",
user_id="u1",
title="测试文案",
content="正文内容",
segments=None,
tags=None,
title_text="",
title_category="default",
title_config=None,
):
m = MagicMock()
m.id = script_id
m.user_id = user_id
m.title = title
m.content = content
m.segments = segments if segments is not None else [{"text": "第一段", "duration": None}]
m.tags = tags if tags is not None else ["口播"]
m.title_text = title_text
m.title_category = title_category
m.title_config = title_config if title_config is not None else {}
m.created_at = datetime(2026, 9, 8, 12, 0, 0, tzinfo=timezone.utc)
m.updated_at = datetime(2026, 9, 8, 12, 0, 0, tzinfo=timezone.utc)
return m
def _make_service(db=None):
if db is None:
db = MagicMock()
return ScriptService(db), db
# ── _normalize_title_config ──────────────────────────────────────────────────
class TestNormalizeTitleConfig:
def test_empty_config(self):
assert ScriptService._normalize_title_config(None) == {}
assert ScriptService._normalize_title_config({}) == {}
def test_standard_fields_pass_through(self):
config = {"font": "思源黑体", "size": 48, "color": "#FFFFFF", "position": "top"}
result = ScriptService._normalize_title_config(config)
assert result == config
def test_legacy_font_size_mapped(self):
config = {"font_size": 36}
result = ScriptService._normalize_title_config(config)
assert result["size"] == 36
assert result["font_size"] == 36 # 旧字段保留
def test_legacy_font_preset_mapped(self):
config = {"font_preset": "bold"}
result = ScriptService._normalize_title_config(config)
assert result["font"] == "bold"
assert result["font_preset"] == "bold"
def test_legacy_font_color_mapped(self):
config = {"font_color": "#FF0000"}
result = ScriptService._normalize_title_config(config)
assert result["color"] == "#FF0000"
assert result["font_color"] == "#FF0000"
def test_standard_fields_not_overwritten_by_legacy(self):
"""如果同时存在标准字段和旧字段,标准字段优先."""
config = {"size": 48, "font_size": 36, "font": "思源黑体", "font_preset": "bold"}
result = ScriptService._normalize_title_config(config)
assert result["size"] == 48 # 标准字段优先
assert result["font"] == "思源黑体"
def test_does_not_mutate_original(self):
original = {"font_size": 36}
result = ScriptService._normalize_title_config(original)
assert "size" not in original # 原字典不被修改
assert "size" in result
# ── create with title fields ─────────────────────────────────────────────────
class TestCreateScriptWithTitleFields:
def test_create_with_title_text(self):
svc, db = _make_service()
with patch("app.services.script_service.ScriptModel") as MockModel:
instance = _make_mock_script(title_text="精选标题")
MockModel.return_value = instance
svc.create_script(user_id="u1", title="文案标题", title_text="精选标题")
call_kwargs = MockModel.call_args[1]
assert call_kwargs["title_text"] == "精选标题"
def test_create_with_title_category(self):
svc, db = _make_service()
with patch("app.services.script_service.ScriptModel") as MockModel:
instance = _make_mock_script(title_category="科技")
MockModel.return_value = instance
svc.create_script(user_id="u1", title="文案标题", title_category="科技")
call_kwargs = MockModel.call_args[1]
assert call_kwargs["title_category"] == "科技"
def test_create_with_title_config(self):
svc, db = _make_service()
config = {"font": "思源黑体", "size": 48, "color": "#FFFFFF"}
with patch("app.services.script_service.ScriptModel") as MockModel:
instance = _make_mock_script(title_config=config)
MockModel.return_value = instance
svc.create_script(user_id="u1", title="文案标题", title_config=config)
call_kwargs = MockModel.call_args[1]
assert call_kwargs["title_config"] == config
def test_create_title_config_normalized(self):
"""创建时 title_config 应自动归一化旧字段名."""
svc, db = _make_service()
legacy_config = {"font_size": 36, "font_preset": "bold", "font_color": "#FF0000"}
with patch("app.services.script_service.ScriptModel") as MockModel:
MockModel.return_value = _make_mock_script()
svc.create_script(user_id="u1", title="文案标题", title_config=legacy_config)
call_kwargs = MockModel.call_args[1]
assert call_kwargs["title_config"]["size"] == 36
assert call_kwargs["title_config"]["font"] == "bold"
assert call_kwargs["title_config"]["color"] == "#FF0000"
def test_create_defaults_for_title_fields(self):
svc, db = _make_service()
with patch("app.services.script_service.ScriptModel") as MockModel:
MockModel.return_value = _make_mock_script()
svc.create_script(user_id="u1", title="文案标题")
call_kwargs = MockModel.call_args[1]
assert call_kwargs["title_text"] == ""
assert call_kwargs["title_category"] == "default"
assert call_kwargs["title_config"] == {}
# ── update with title fields ─────────────────────────────────────────────────
class TestUpdateScriptWithTitleFields:
def _setup_mock_script(self, db):
mock_script = _make_mock_script()
chain = MagicMock()
chain.filter.return_value = chain
chain.first.return_value = mock_script
db.query.return_value = chain
return mock_script
def test_update_title_text(self):
svc, db = _make_service()
mock_script = self._setup_mock_script(db)
svc.update_script("s1", "u1", title_text="新标题文本")
assert mock_script.title_text == "新标题文本"
def test_update_title_category(self):
svc, db = _make_service()
mock_script = self._setup_mock_script(db)
svc.update_script("s1", "u1", title_category="娱乐")
assert mock_script.title_category == "娱乐"
def test_update_title_config_normalized(self):
svc, db = _make_service()
mock_script = self._setup_mock_script(db)
legacy = {"font_size": 42}
svc.update_script("s1", "u1", title_config=legacy)
assert mock_script.title_config["size"] == 42
def test_update_title_fields_partial(self):
"""只更新 title_text 不影响其他标题字段."""
svc, db = _make_service()
mock_script = self._setup_mock_script(db)
mock_script.title_category = "科技"
mock_script.title_config = {"font": "思源黑体"}
svc.update_script("s1", "u1", title_text="只改标题文本")
assert mock_script.title_text == "只改标题文本"
assert mock_script.title_category == "科技"
assert mock_script.title_config == {"font": "思源黑体"}
# ── get_title_config_for_script ──────────────────────────────────────────────
class TestGetTitleConfigForScript:
def test_returns_merged_config(self):
svc, db = _make_service()
config = {"font": "思源黑体", "size": 48}
mock_script = _make_mock_script(title_text="我的标题", title_config=config)
chain = MagicMock()
chain.filter.return_value = chain
chain.first.return_value = mock_script
db.query.return_value = chain
result = svc.get_title_config_for_script("s1", "u1")
assert result["text"] == "我的标题"
assert result["font"] == "思源黑体"
assert result["size"] == 48
def test_text_does_not_override_existing(self):
"""title_config 里已有的 text 不被 title_text 覆盖."""
svc, db = _make_service()
config = {"text": "config里的标题", "font": "思源黑体"}
mock_script = _make_mock_script(title_text="title_text里的标题", title_config=config)
chain = MagicMock()
chain.filter.return_value = chain
chain.first.return_value = mock_script
db.query.return_value = chain
result = svc.get_title_config_for_script("s1", "u1")
assert result["text"] == "config里的标题" # setdefault 不覆盖已有值
def test_empty_title_text(self):
svc, db = _make_service()
mock_script = _make_mock_script(title_text="", title_config={"font": "Arial"})
chain = MagicMock()
chain.filter.return_value = chain
chain.first.return_value = mock_script
db.query.return_value = chain
result = svc.get_title_config_for_script("s1", "u1")
assert "text" not in result # title_text 为空时不添加 text 字段
assert result["font"] == "Arial"
def test_script_not_found_raises(self):
svc, db = _make_service()
chain = MagicMock()
chain.filter.return_value = chain
chain.first.return_value = None
db.query.return_value = chain
with pytest.raises(ScriptNotFoundError):
svc.get_title_config_for_script("nonexistent", "u1")
# ── list with title_category filter ──────────────────────────────────────────
class TestListScriptsByTitleCategory:
def test_filter_by_title_category(self):
svc, db = _make_service()
items = [_make_mock_script("s1", title_category="科技")]
chain = MagicMock()
chain.filter.return_value = chain
chain.count.return_value = 1
chain.order_by.return_value = chain
chain.offset.return_value = chain
chain.limit.return_value = chain
chain.all.return_value = items
db.query.return_value = chain
result, total = svc.list_scripts("u1", title_category="科技")
assert total == 1
# filter 至少调用 2 次:user_id + title_category
assert chain.filter.call_count >= 2
def test_no_title_category_filter(self):
svc, db = _make_service()
chain = MagicMock()
chain.filter.return_value = chain
chain.count.return_value = 0
chain.order_by.return_value = chain
chain.offset.return_value = chain
chain.limit.return_value = chain
chain.all.return_value = []
db.query.return_value = chain
result, total = svc.list_scripts("u1")
# filter 只调用 1 次:仅 user_id
assert chain.filter.call_count == 1
+26
View File
@@ -70,12 +70,21 @@ class TestUpdateScriptRequest:
assert r.content is None
assert r.segments is None
assert r.tags is None
assert r.title_text is None
assert r.title_category is None
assert r.title_config is None
def test_partial_update(self):
r = UpdateScriptRequest(title="新标题")
assert r.title == "新标题"
assert r.content is None
def test_partial_update_title_fields(self):
r = UpdateScriptRequest(title_text="新标题文本", title_category="娱乐")
assert r.title_text == "新标题文本"
assert r.title_category == "娱乐"
assert r.title is None
class TestScriptResponse:
def test_response_construction(self):
@@ -87,11 +96,28 @@ class TestScriptResponse:
content="内容",
segments=[ScriptSegment(text="段1")],
tags=["t1"],
title_text="标题文案",
title_category="科技",
title_config={"font": "思源黑体", "size": 48},
created_at=now,
updated_at=now,
)
assert r.id == "s1"
assert len(r.segments) == 1
assert r.title_text == "标题文案"
assert r.title_category == "科技"
assert r.title_config["font"] == "思源黑体"
def test_response_defaults(self):
"""新字段有默认值,不传也能构造."""
now = datetime(2026, 9, 8, 12, 0, 0, tzinfo=timezone.utc)
r = ScriptResponse(
id="s1", user_id="u1", title="标题", content="",
segments=[], tags=[], created_at=now, updated_at=now,
)
assert r.title_text == ""
assert r.title_category == "default"
assert r.title_config == {}
class TestScriptListResponse: