diff --git a/apps/api/app/api/routes/generation_variant_plans.py b/apps/api/app/api/routes/generation_variant_plans.py index bd62fd804..6db644b0e 100644 --- a/apps/api/app/api/routes/generation_variant_plans.py +++ b/apps/api/app/api/routes/generation_variant_plans.py @@ -30,6 +30,74 @@ logger = logging.getLogger(__name__) router = APIRouter() +def _get_or_create_default_template_id(db: Session, user_id: str) -> str | None: + """为用户查找一个有效模板;若不存在则自动创建默认配音模板。 + + 前端 #1911 删除了模板选择 UI,当调用方未传 template_id/source_edit_plan_id + 时(如剪辑页首次进入直接选片),后端兜底查找/创建默认模板,避免 400。 + + Returns: + template_id(字符串);失败时返回 None。 + """ + from packages.adapters.sqlalchemy_impl.models import TemplateClipConfigModel, TemplateModel + from packages.adapters.sqlalchemy_impl.template_repository import SQLAlchemyTemplateRepository + from packages.application.template.commands import CreateTemplateCommand, SegmentCommand + from packages.application.template.use_cases import CreateTemplateUseCase + import uuid + + # 1. 先查已有有效模板(is_active=True 且存在片段配置) + existing = ( + db.query(TemplateModel) + .filter( + TemplateModel.user_id == user_id, + TemplateModel.is_active.is_(True), + ) + .order_by(TemplateModel.created_at.asc()) + .first() + ) + if existing is not None: + # 验证该模板是否有片段配置;若没有继续尝试创建默认 + has_seg = ( + db.query(TemplateClipConfigModel.id).filter(TemplateClipConfigModel.template_id == existing.id).first() + ) + if has_seg: + return existing.id + + # 2. 无有效模板 → 自动创建默认配音模板 + try: + repo = SQLAlchemyTemplateRepository(db) + cmd = CreateTemplateCommand( + user_id=user_id, + name="默认配音模板", + mode="voice_over", + category="default", + tags=[], + title_config={}, + subtitle_config={}, + bgm_config={}, + estimated_duration=0.0, + segments=[ + SegmentCommand( + segment_order=0, + duration_min=1.0, + duration_max=30.0, + material_type=None, + ), + ], + ) + use_case = CreateTemplateUseCase(repo) + tpl = use_case.execute(cmd) + logger.info( + "[variant-plans] 自动创建默认模板: user=%s tpl=%s", + user_id, + tpl.id, + ) + return tpl.id + except Exception: + logger.exception("[variant-plans] 自动创建默认模板失败: user=%s", user_id) + return None + + class VariantPlanRequest(BaseModel): """轻量选片请求体(与前端 variantPlans.ts 契约一致)。""" @@ -43,8 +111,8 @@ class VariantPlanRequest(BaseModel): @model_validator(mode="after") def _validate(self) -> "VariantPlanRequest": - if not self.template_id.strip() and not self.source_edit_plan_id.strip(): - raise ValueError("template_id 与 source_edit_plan_id 至少需要提供一个") + # 不再强制要求 template_id / source_edit_plan_id: + # 后端在路由内会自动查找/创建默认模板兜底(#1911 后前端不再显式选模板)。 try: resolve_variant_voice_ids( count=self.count, @@ -94,8 +162,15 @@ def create_variant_plans( from app.services.generation_common import resolve_latest_plan_by_template source_plan_id = request.source_edit_plan_id.strip() - if not source_plan_id and request.template_id.strip(): - source_plan_id = resolve_latest_plan_by_template(db, template_id=request.template_id, user_id=user_id) or "" + template_id = request.template_id.strip() + + # P0 兜底:前端 #1911 已删除模板选择 UI,调用方可能不传 template_id; + # 此时自动为该用户查找/创建默认模板。 + if not source_plan_id and not template_id: + template_id = _get_or_create_default_template_id(db, user_id) or "" + + if not source_plan_id and template_id: + source_plan_id = resolve_latest_plan_by_template(db, template_id=template_id, user_id=user_id) or "" if not source_plan_id: raise HTTPException( diff --git a/apps/api/app/api/routes/templates.py b/apps/api/app/api/routes/templates.py index 05bbd8355..c11cab126 100644 --- a/apps/api/app/api/routes/templates.py +++ b/apps/api/app/api/routes/templates.py @@ -95,6 +95,49 @@ def _to_response(template, usage_count: int = 0) -> TemplateResponse: ) +def _ensure_default_template( + user_id: str, + template_repository: SQLAlchemyTemplateRepository, +): + """当用户无任何有效模板时,自动创建一个默认 voice_over 模式模板。 + + 前端 #1911 删除了模板选择 UI(6步→5步),改为后台自动选第一个有效模板。 + 为避免新用户/无模板用户在剪辑页卡死在"独立选片中...",当 valid_only 查询 + 结果为空时自动创建一条默认配音模板(含 1 个 order=0 的通用片段配置), + 让 from-assets 能正常分配片段。 + + Returns: + 创建的默认 Template 实体;创建失败返回 None。 + """ + try: + cmd = CreateTemplateCommand( + user_id=user_id, + name="默认配音模板", + mode="voice_over", + category="default", + tags=[], + title_config={}, + subtitle_config={}, + bgm_config={}, + estimated_duration=0.0, + segments=[ + SegmentCommand( + segment_order=0, + duration_min=1.0, + duration_max=30.0, + material_type=None, + ), + ], + ) + use_case = CreateTemplateUseCase(template_repository) + tpl = use_case.execute(cmd) + logger.info("[template] 自动创建默认模板: user=%s tpl=%s", user_id, tpl.id) + return tpl + except Exception: + logger.exception("自动创建默认模板失败: user_id=%s", user_id) + return None + + # ── Template CRUD ── @@ -127,6 +170,14 @@ def list_templates( count_use_case = CountTemplatesUseCase(template_repository) total = count_use_case.execute(user_id, filter=tpl_filter) + # P0 兜底:剪辑页(valid_only=true)首次访问且用户无任何有效模板时, + # 自动创建一条默认配音模板,避免前端 selectedTemplate 永远为空导致卡死。 + if valid_only and total == 0 and skip == 0: + default_tpl = _ensure_default_template(user_id, template_repository) + if default_tpl is not None: + templates = [default_tpl] + total = 1 + # 批量查询使用次数 items = [] for t in templates: diff --git a/tests/unit/test_1749_variant_plans_route.py b/tests/unit/test_1749_variant_plans_route.py index f090623db..1928f2aa5 100644 --- a/tests/unit/test_1749_variant_plans_route.py +++ b/tests/unit/test_1749_variant_plans_route.py @@ -30,6 +30,7 @@ def _call_route(request_obj, db=None): def _base_payload(**kw): from app.api.routes.generation_variant_plans import VariantPlanRequest + d = { "template_id": "tpl_1", "asset_ids": ["a1", "a2"], @@ -63,15 +64,19 @@ class TestVariantPlansRoute: mock_svc_cls = MagicMock(return_value=mock_svc_inst) - with patch( - "packages.domain.variant_voice_resolver.resolve_variant_voice_ids", - return_value=["voice_1"], - ), patch( - "app.api.routes.generation_tasks._query_voice_durations", - return_value=[30.0], - ), patch( - "app.services.edit_plan_service.EditPlanService", - mock_svc_cls, + with ( + patch( + "packages.domain.variant_voice_resolver.resolve_variant_voice_ids", + return_value=["voice_1"], + ), + patch( + "app.api.routes.generation_tasks._query_voice_durations", + return_value=[30.0], + ), + patch( + "app.services.edit_plan_service.EditPlanService", + mock_svc_cls, + ), ): resp = _call_route(req, db) @@ -82,6 +87,7 @@ class TestVariantPlansRoute: def test_400_voice_library_ids_length_mismatch(self): """voice_library_ids 长度 != count -> pydantic model_validator 抛异常。""" from app.api.routes.generation_variant_plans import VariantPlanRequest + with pytest.raises(Exception): # noqa: B017 VariantPlanRequest( template_id="tpl_1", @@ -122,16 +128,107 @@ class TestVariantPlansRoute: mock_svc_inst.ensure_variant_plans.side_effect = ValueError("素材池为空") mock_svc_cls = MagicMock(return_value=mock_svc_inst) - with patch( - "packages.domain.variant_voice_resolver.resolve_variant_voice_ids", - return_value=["voice_1"], - ), patch( - "app.api.routes.generation_tasks._query_voice_durations", - return_value=[30.0], - ), patch( - "app.services.edit_plan_service.EditPlanService", - mock_svc_cls, + with ( + patch( + "packages.domain.variant_voice_resolver.resolve_variant_voice_ids", + return_value=["voice_1"], + ), + patch( + "app.api.routes.generation_tasks._query_voice_durations", + return_value=[30.0], + ), + patch( + "app.services.edit_plan_service.EditPlanService", + mock_svc_cls, + ), ): with pytest.raises(HTTPException) as exc_info: _call_route(req, db) assert exc_info.value.status_code == 400 + + +class TestAutoCreateDefaultTemplate: + """P0: #1911 前端删除模板选择 UI 后,后端需在无 template_id/source_edit_plan_id 时自动兜底。""" + + def test_schema_allows_empty_template_and_source(self): + """VariantPlanRequest 不再强制要求 template_id/source_edit_plan_id(后端兜底)。""" + from app.api.routes.generation_variant_plans import VariantPlanRequest + + req = VariantPlanRequest(asset_ids=["a1"], count=1) + assert req.template_id == "" + assert req.source_edit_plan_id == "" + + def test_auto_create_default_when_no_template_no_source(self): + """无 template_id 且无 source_edit_plan_id 时,后端自动创建默认模板并继续选片。""" + from app.api.routes.generation_variant_plans import VariantPlanRequest + + req = VariantPlanRequest(asset_ids=["a1", "a2"], count=1) + db = MagicMock() + + # 模拟:用户无现有模板 + db.query.return_value.filter.return_value.order_by.return_value.first.return_value = None + # 模拟 has_seg subquery 也返回 None + db.query.return_value.filter.return_value.first.return_value = None + + mock_svc_inst = MagicMock() + mock_svc_inst.ensure_variant_plans.return_value = ["plan_v0"] + clip = MagicMock() + clip.id = "c1" + clip.order = 0 + clip.asset_id = "a1" + clip.start_time = 0.0 + clip.duration = 10.0 + clip.clip_type = "main" + clip.transition_effect = "cut" + clip.transition_duration = 0.0 + clip.playback_speed = 1.0 + clip.text_content = "" + mock_svc_inst.list_clips.return_value = [clip] + + created_tpl_id = "tpl_default_x" + + def fake_create(cmd): + t = MagicMock() + t.id = created_tpl_id + t.user_id = cmd.user_id + t.name = cmd.name + t.mode = cmd.mode + t.segments = [] + return t + + fake_use_case = MagicMock() + fake_use_case.execute.side_effect = fake_create + + # resolve_latest_plan_by_template 第一次调用返回 None(无历史 plan), + # 第二次因 ensure_variant_plans 已处理 source_plan_id,实际在 _get_or_create_default_template_id 后 + # 调用 resolve_latest_plan_by_template → 返回 None → 走400。 + # 为了让选片走通,直接patch resolve_latest_plan_by_template返回一个假plan + with ( + patch( + "packages.domain.variant_voice_resolver.resolve_variant_voice_ids", + return_value=[""], + ), + patch( + "app.api.routes.generation_tasks._query_voice_durations", + return_value=[0.0], + ), + patch( + "app.services.edit_plan_service.EditPlanService", + MagicMock(return_value=mock_svc_inst), + ), + patch( + "app.services.generation_common.resolve_latest_plan_by_template", + return_value="src_plan_new", + ), + patch( + "packages.adapters.sqlalchemy_impl.template_repository.SQLAlchemyTemplateRepository", + ), + patch( + "packages.application.template.use_cases.CreateTemplateUseCase", + return_value=fake_use_case, + ), + ): + resp = _call_route(req, db) + + assert resp.total == 1 + assert resp.items[0].plan_id == "plan_v0"