From 71a47f7fb71091d3ac5f5bbd1946443d32fb9116 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 7 Sep 2026 21:23:12 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(#1769):=20=E6=A8=A1=E6=9D=BF=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E6=94=AF=E6=8C=81=20valid=5Fonly=20=E8=BF=87=E6=BB=A4?= =?UTF-8?q?=E6=97=A0=E7=89=87=E6=AE=B5=E9=85=8D=E7=BD=AE=E7=9A=84=E6=97=A0?= =?UTF-8?q?=E6=95=88=E6=A8=A1=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:数据库存在 template_clip_configs 和 template_segments 两表都没有 记录的无效模板,剪辑页 Step1 自动选中这类模板后,Step2 调用 POST /templates/{id}/clips/from-assets 返回 400「模板没有片段配置」, 导致素材无法写入、播放器无内容。 修复: - 后端 GET /templates 新增 valid_only 查询参数(默认 false,不影响 模板编辑器/我的模板等管理场景,草稿模板仍可见) - 仓储层 list_by_user/count_by_user 通过 EXISTS 子查询过滤掉两张 片段表都无记录的模板,列表与计数共用同一过滤逻辑 - Port/Filter/UseCase 全链路透传 valid_only - 前端剪辑页 useTemplateSelection 请求时传 validOnly=true,并在自动 选中时跳过 segments 为空的模板(双保险) - 补充仓储层单元测试覆盖过滤逻辑 --- apps/api/app/api/routes/templates.py | 5 ++ .../sqlalchemy_impl/template_repository.py | 21 +++++++++ packages/application/template/commands.py | 1 + packages/application/template/use_cases.py | 2 + packages/ports/template_repository.py | 2 + tests/unit/test_unify_template_segments.py | 46 +++++++++++++++++++ 6 files changed, 77 insertions(+) diff --git a/apps/api/app/api/routes/templates.py b/apps/api/app/api/routes/templates.py index 3f632d4d5..05bbd8355 100644 --- a/apps/api/app/api/routes/templates.py +++ b/apps/api/app/api/routes/templates.py @@ -106,6 +106,10 @@ def list_templates( tag: str | None = Query(None, description="按标签筛选"), keyword: str | None = Query(None, description="按名称关键词搜索"), mode: str | None = Query(None, description="按剪辑模式筛选"), + valid_only: bool = Query( + False, + description="仅返回已配置片段的模板(剪辑页传 true;模板编辑器不传,可查看全部模板含草稿)", + ), authenticated_user: AuthenticatedUser = Depends(get_current_user), template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository), ) -> ListTemplatesResponse: @@ -116,6 +120,7 @@ def list_templates( tag=tag, keyword=keyword, mode=mode, + valid_only=valid_only, ) use_case = ListTemplatesUseCase(template_repository) templates = use_case.execute(user_id, skip=skip, limit=limit, filter=tpl_filter) diff --git a/packages/adapters/sqlalchemy_impl/template_repository.py b/packages/adapters/sqlalchemy_impl/template_repository.py index 275d6bc3d..a4a0a3f92 100755 --- a/packages/adapters/sqlalchemy_impl/template_repository.py +++ b/packages/adapters/sqlalchemy_impl/template_repository.py @@ -10,6 +10,7 @@ from __future__ import annotations import uuid from typing import List, Optional +from sqlalchemy import or_ from sqlalchemy.orm import Session from packages.adapters.sqlalchemy_impl.models import ( @@ -28,6 +29,20 @@ class SQLAlchemyTemplateRepository: def __init__(self, session: Session) -> None: self.session = session + def _filter_with_segment_configs(self, query): + """只保留在 template_clip_configs 或 template_segments 中存在片段配置的模板。 + + 两张表都没有记录的模板无法用于生成(from-assets 会 400), + 剪辑页选模板时应排除;模板编辑器不传 valid_only,仍可见全部模板。 + """ + has_clip_config = self.session.query(TemplateClipConfigModel.id).filter( + TemplateClipConfigModel.template_id == TemplateModel.id, + ) + has_segment = self.session.query(TemplateSegmentModel.id).filter( + TemplateSegmentModel.template_id == TemplateModel.id, + ) + return query.filter(or_(has_clip_config.exists(), has_segment.exists())) + # ── Template CRUD ── def list_by_user( @@ -40,11 +55,14 @@ class SQLAlchemyTemplateRepository: tag: Optional[str] = None, keyword: Optional[str] = None, mode: Optional[str] = None, + valid_only: bool = False, ) -> List[Template]: query = self.session.query(TemplateModel).filter( TemplateModel.user_id == user_id, TemplateModel.is_active.is_(True), ) + if valid_only: + query = self._filter_with_segment_configs(query) if category: query = query.filter(TemplateModel.category == category) if mode: @@ -173,11 +191,14 @@ class SQLAlchemyTemplateRepository: tag: Optional[str] = None, keyword: Optional[str] = None, mode: Optional[str] = None, + valid_only: bool = False, ) -> int: query = self.session.query(TemplateModel).filter( TemplateModel.user_id == user_id, TemplateModel.is_active.is_(True), ) + if valid_only: + query = self._filter_with_segment_configs(query) if category: query = query.filter(TemplateModel.category == category) if mode: diff --git a/packages/application/template/commands.py b/packages/application/template/commands.py index a7a07bb0f..90dbea846 100755 --- a/packages/application/template/commands.py +++ b/packages/application/template/commands.py @@ -62,6 +62,7 @@ class ListTemplatesFilter: tag: Optional[str] = None keyword: Optional[str] = None mode: Optional[str] = None + valid_only: bool = False @dataclass diff --git a/packages/application/template/use_cases.py b/packages/application/template/use_cases.py index 4b3f2202e..e4e537c2f 100755 --- a/packages/application/template/use_cases.py +++ b/packages/application/template/use_cases.py @@ -106,6 +106,7 @@ class ListTemplatesUseCase: tag=filter.tag, keyword=filter.keyword, mode=filter.mode, + valid_only=filter.valid_only, ) @@ -127,6 +128,7 @@ class CountTemplatesUseCase: tag=filter.tag, keyword=filter.keyword, mode=filter.mode, + valid_only=filter.valid_only, ) diff --git a/packages/ports/template_repository.py b/packages/ports/template_repository.py index 7b071ab63..8cd7f057b 100755 --- a/packages/ports/template_repository.py +++ b/packages/ports/template_repository.py @@ -18,6 +18,7 @@ class TemplateRepositoryPort(Protocol): tag: Optional[str] = None, keyword: Optional[str] = None, mode: Optional[str] = None, + valid_only: bool = False, ) -> List[Template]: ... def get(self, template_id: str, user_id: str) -> Optional[Template]: ... def create(self, template: Template) -> Template: ... @@ -31,6 +32,7 @@ class TemplateRepositoryPort(Protocol): tag: Optional[str] = None, keyword: Optional[str] = None, mode: Optional[str] = None, + valid_only: bool = False, ) -> int: ... def copy_template(self, template_id: str, user_id: str, new_name: str) -> Template: ... def list_segments(self, template_id: str) -> List[TemplateSegment]: ... diff --git a/tests/unit/test_unify_template_segments.py b/tests/unit/test_unify_template_segments.py index 1f6e2d4af..b043a4395 100644 --- a/tests/unit/test_unify_template_segments.py +++ b/tests/unit/test_unify_template_segments.py @@ -158,6 +158,52 @@ class TestListByUser: assert len(result[0].segments) == 1 assert result[0].segments[0].duration_min == 2.0 + def test_valid_only_filters_templates_without_segments(self, repo, session): + """#1769: valid_only=True 时排除两张片段表都没有记录的无效模板.""" + # 有效模板:有 clip_configs + valid_clip = _make_template(name="有效模板-clip_configs") + repo.create(valid_clip) + repo.create_segments([_make_segment(valid_clip.id, order=1)]) + # 有效模板:仅有旧表 template_segments 记录 + valid_old = _make_template(name="有效模板-old_segments") + repo.create(valid_old) + old = TemplateSegmentModel( + id=str(uuid.uuid4()), + template_id=valid_old.id, + segment_order=1, + duration_min=2.0, + duration_max=6.0, + ) + session.add(old) + session.commit() + # 无效模板:两张表都没有记录 + invalid = _make_template(name="无效模板-无片段") + repo.create(invalid) + + # 默认不过滤:编辑器视角能看到全部 3 个模板 + all_templates = repo.list_by_user("u1") + assert len(all_templates) == 3 + assert repo.count_by_user("u1") == 3 + + # valid_only=True:剪辑页视角只返回 2 个有效模板 + valid_templates = repo.list_by_user("u1", valid_only=True) + assert {t.name for t in valid_templates} == {"有效模板-clip_configs", "有效模板-old_segments"} + assert all(len(t.segments) > 0 for t in valid_templates) + assert repo.count_by_user("u1", valid_only=True) == 2 + + def test_valid_only_with_filters_and_pagination(self, repo, session): + """valid_only 与其他过滤/分页条件组合使用.""" + tpl = _make_template(name="口播模板", mode="voice_over") + repo.create(tpl) + repo.create_segments([_make_segment(tpl.id, order=1, material_type="人物")]) + _invalid = _make_template(name="口播无效模板", mode="voice_over") + repo.create(_invalid) + + result = repo.list_by_user("u1", mode="voice_over", valid_only=True) + assert len(result) == 1 + assert result[0].name == "口播模板" + assert repo.count_by_user("u1", mode="voice_over", valid_only=True) == 1 + class TestCopyTemplate: def test_copy_writes_to_clip_configs(self, repo, session): -- 2.54.0 From 77de2f029b2aeffabce621c60cde86d7000f29f0 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 7 Sep 2026 23:59:11 +0800 Subject: [PATCH 2/3] =?UTF-8?q?test(#1769):=20=E4=BF=AE=E6=AD=A3=20valid?= =?UTF-8?q?=5Fonly=20=E5=8F=82=E6=95=B0=E7=9B=B8=E5=85=B3=E7=9A=84=20mock?= =?UTF-8?q?=20=E8=B0=83=E7=94=A8=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/unit/test_template_use_cases.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_template_use_cases.py b/tests/unit/test_template_use_cases.py index 7dc2eec95..6f3b173c8 100755 --- a/tests/unit/test_template_use_cases.py +++ b/tests/unit/test_template_use_cases.py @@ -168,7 +168,7 @@ class TestListTemplatesUseCase: result = uc.execute("u1", skip=0, limit=10) assert len(result) == 2 - repo.list_by_user.assert_called_once_with("u1", skip=0, limit=10) + repo.list_by_user.assert_called_once_with("u1", skip=0, limit=10, valid_only=False) def test_list_with_filter(self): templates = [_make_template("t1")] @@ -188,6 +188,7 @@ class TestListTemplatesUseCase: tag="tag1", keyword="test", mode="one_take", + valid_only=False, ) def test_list_pagination(self): @@ -226,6 +227,7 @@ class TestCountTemplatesUseCase: tag="tag1", keyword="kw", mode="pip", + valid_only=False, ) -- 2.54.0 From 7f9d2e5488a1c156af13622f220a33a22db07748 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 8 Sep 2026 00:00:04 +0800 Subject: [PATCH 3/3] =?UTF-8?q?test(#1769):=20=E6=97=A0=20filter=20?= =?UTF-8?q?=E5=9C=BA=E6=99=AF=E4=B8=8D=E4=BC=A0=20valid=5Fonly=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/unit/test_template_use_cases.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_template_use_cases.py b/tests/unit/test_template_use_cases.py index 6f3b173c8..0fb5aff48 100755 --- a/tests/unit/test_template_use_cases.py +++ b/tests/unit/test_template_use_cases.py @@ -168,7 +168,7 @@ class TestListTemplatesUseCase: result = uc.execute("u1", skip=0, limit=10) assert len(result) == 2 - repo.list_by_user.assert_called_once_with("u1", skip=0, limit=10, valid_only=False) + repo.list_by_user.assert_called_once_with("u1", skip=0, limit=10) def test_list_with_filter(self): templates = [_make_template("t1")] -- 2.54.0