feat(#1894): 废弃标题库整合到文案库 - scripts 模型新增 title_text/title_category/title_config 字段 - 数据迁移脚本 009 - title_libraries API 标记 deprecated - 视频生成支持从 script 读取标题配置 - 补充单元测试
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user