From 19b0f9f38ecbac09b5f32d8277d7ec8ed09f6488 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 15 Sep 2026 12:35:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(#1894):=20=E5=BA=9F=E5=BC=83=E6=A0=87?= =?UTF-8?q?=E9=A2=98=E5=BA=93=E6=95=B4=E5=90=88=E5=88=B0=E6=96=87=E6=A1=88?= =?UTF-8?q?=E5=BA=93=20-=20scripts=20=E6=A8=A1=E5=9E=8B=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=20title=5Ftext/title=5Fcategory/title=5Fconfig=20=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=20-=20=E6=95=B0=E6=8D=AE=E8=BF=81=E7=A7=BB=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=20009=20-=20title=5Flibraries=20API=20=E6=A0=87?= =?UTF-8?q?=E8=AE=B0=20deprecated=20-=20=E8=A7=86=E9=A2=91=E7=94=9F?= =?UTF-8?q?=E6=88=90=E6=94=AF=E6=8C=81=E4=BB=8E=20script=20=E8=AF=BB?= =?UTF-8?q?=E5=8F=96=E6=A0=87=E9=A2=98=E9=85=8D=E7=BD=AE=20-=20=E8=A1=A5?= =?UTF-8?q?=E5=85=85=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/app/api/routes/generation_preview.py | 23 +- apps/api/app/api/routes/generation_tasks.py | 25 +- apps/api/app/schemas/generation_task.py | 10 + .../009_merge_title_library_into_scripts.sql | 41 +++ tests/unit/test_script_title_integration.py | 286 ++++++++++++++++++ tests/unit/test_scripts_routes.py | 26 ++ 6 files changed, 409 insertions(+), 2 deletions(-) create mode 100644 migrations/009_merge_title_library_into_scripts.sql create mode 100644 tests/unit/test_script_title_integration.py diff --git a/apps/api/app/api/routes/generation_preview.py b/apps/api/app/api/routes/generation_preview.py index eb2302ce7..2bf2cb11f 100755 --- a/apps/api/app/api/routes/generation_preview.py +++ b/apps/api/app/api/routes/generation_preview.py @@ -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) diff --git a/apps/api/app/api/routes/generation_tasks.py b/apps/api/app/api/routes/generation_tasks.py index d1e301ae1..ea25de342 100755 --- a/apps/api/app/api/routes/generation_tasks.py +++ b/apps/api/app/api/routes/generation_tasks.py @@ -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) diff --git a/apps/api/app/schemas/generation_task.py b/apps/api/app/schemas/generation_task.py index b8ce60828..2b1557a9f 100755 --- a/apps/api/app/schemas/generation_task.py +++ b/apps/api/app/schemas/generation_task.py @@ -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( diff --git a/migrations/009_merge_title_library_into_scripts.sql b/migrations/009_merge_title_library_into_scripts.sql new file mode 100644 index 000000000..49e7aebdc --- /dev/null +++ b/migrations/009_merge_title_library_into_scripts.sql @@ -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 + ); diff --git a/tests/unit/test_script_title_integration.py b/tests/unit/test_script_title_integration.py new file mode 100644 index 000000000..dd4dfa2f6 --- /dev/null +++ b/tests/unit/test_script_title_integration.py @@ -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 diff --git a/tests/unit/test_scripts_routes.py b/tests/unit/test_scripts_routes.py index a047d0035..4444a19a3 100644 --- a/tests/unit/test_scripts_routes.py +++ b/tests/unit/test_scripts_routes.py @@ -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: