diff --git a/apps/api/app/api/routes/edit_plans.py b/apps/api/app/api/routes/edit_plans.py old mode 100644 new mode 100755 index ffbc1f4aa..de0a5c5bf --- a/apps/api/app/api/routes/edit_plans.py +++ b/apps/api/app/api/routes/edit_plans.py @@ -365,36 +365,94 @@ def create_plan( current_user: AuthenticatedUser = Depends(get_current_user), project_repository: Any = Depends(get_project_repository), ) -> EditPlanResponse: - """创建剪辑计划""" + """创建剪辑计划 + + 基于模板自动生成片段结构: + - 模板存在时:从模板的 clip_configs 生成初始 clips + - 模板不存在时:降级为空计划(保持向后兼容) + - 用户传入的 config 与模板 config 合并(用户配置优先级更高) + - total_duration 自动根据 clips 总时长计算 + """ + from app.services import PlanGeneratorService + # 空串 project_id 统一为 "" project_id = (body.project_id or "").strip() # 项目鉴权 if project_id: check_project_access(project_id, current_user.user.id, project_repository) + + # 标准化用户传入的 config + normalized_config = normalize_plan_config(body.config or {}) + + template_svc = EditTemplateService(db) svc = EditPlanService(db) - # 标准化 config,填充 cover/title/subtitle/bgm 默认值 - normalized_config = normalize_plan_config(body.config) + + # 尝试从模板生成(模板不存在时降级为空计划) + template = None + clips = [] try: - created = svc.create_plan( + template = template_svc.get_template_or_raise(body.template_id) + except ValueError: + # 模板不存在,降级为普通空计划 + logger.info("模板不存在,创建空计划: template_id=%s", body.template_id) + plan = svc.create_plan( template_id=body.template_id, name=body.name, config=normalized_config, - total_duration=body.total_duration, project_id=project_id, created_by_user_id=current_user.user.id, + total_duration=body.total_duration if body.total_duration > 0 else 0.0, + ) + logger.info( + "创建空剪辑计划: id=%s name=%s by user=%s", + plan.id, + plan.name, + current_user.user.id, + ) + return _to_response(plan) + + # 模板存在,从模板生成计划+片段 + clip_configs = template_svc.list_clip_configs(body.template_id, skip=0, limit=200) + + generator = PlanGeneratorService(db) + try: + result = generator.generate_from_template( + template=template, + clip_configs=clip_configs, + asset_ids=[], + project_id=project_id, + created_by_user_id=current_user.user.id, + name=body.name, ) except ValueError as exc: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc), ) from exc + + plan = result["plan"] + clips = result["clips"] + + # 如果用户传入了自定义 config,合并覆盖模板配置 + if body.config: + base_config = template.config or {} + merged_config = {**base_config, **normalized_config} + # 重新标准化确保默认值填充正确 + merged_config = normalize_plan_config(merged_config) + plan = svc.update_plan( + plan.id, + config=merged_config, + total_duration=body.total_duration if body.total_duration > 0 else None, + ) + logger.info( - "创建剪辑计划: id=%s name=%s by user=%s", - created.id, - created.name, + "创建剪辑计划: id=%s name=%s clips=%d by user=%s", + plan.id, + plan.name, + len(clips), current_user.user.id, ) - return _to_response(created) + return _to_response(plan) @router.put("/{plan_id}", response_model=EditPlanResponse) diff --git a/tests/unit/test_edit_plans_api.py b/tests/unit/test_edit_plans_api.py index 23b638905..3314a3ce8 100755 --- a/tests/unit/test_edit_plans_api.py +++ b/tests/unit/test_edit_plans_api.py @@ -15,7 +15,7 @@ import os import sys from pathlib import Path from typing import Optional -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing") os.environ.setdefault("DATABASE_URL", "sqlite:///test.db") @@ -173,14 +173,54 @@ def client(): class TestCreatePlan: - def test_create_success(self, client): + """创建剪辑计划测试。 + + 注意:创建计划时会从模板生成 clips,这里 mock 掉模板服务和生成器, + 专注验证 API 层参数传递和响应格式。 + """ + + def _make_test_plan(self, plan_id="plan-001", template_id="tpl-001", name="测试计划"): + """构造一个测试用 EditPlan""" + return EditPlan.create( + template_id=template_id, + name=name, + config=normalize_plan_config({}), + total_duration=15.0, + ) + + @patch("app.api.routes.edit_plans.EditTemplateService") + @patch("app.services.PlanGeneratorService") + def test_create_success(self, mock_generator_cls, mock_template_svc_cls, client): c, repo = client + + # Setup mock 模板服务 + mock_template_svc = MagicMock() + mock_template_svc.get_template_or_raise.return_value = MagicMock( + id="tpl-001", + name="测试模板", + config={}, + ) + mock_template_svc.list_clip_configs.return_value = [] + mock_template_svc_cls.return_value = mock_template_svc + + # Setup mock 生成器 + mock_gen = MagicMock() + test_plan = self._make_test_plan(name="我的剪辑计划") + test_plan.status = EditPlanStatus.EDITING + # 把 plan 存到 stub repo,这样后续 update_plan 能找到 + repo.create(test_plan) + mock_gen.generate_from_template.return_value = { + "plan": test_plan, + "clips": [], + } + mock_generator_cls.return_value = mock_gen + resp = c.post( "/api/v1/edit-plans", json={ "template_id": "tpl-001", "name": "我的剪辑计划", - "config": {"bgm": "happy"}, + "config": {"bgm": {"enabled": True}}, "total_duration": 60.0, }, ) @@ -188,22 +228,71 @@ class TestCreatePlan: data = resp.json() assert data["name"] == "我的剪辑计划" assert data["template_id"] == "tpl-001" - assert data["status"] == "draft" - assert data["total_duration"] == 60.0 - assert data["config"] == normalize_plan_config({"bgm": "happy"}) assert "id" in data assert "created_at" in data - def test_create_minimal(self, client): + # 验证调用了生成器 + mock_gen.generate_from_template.assert_called_once() + call_kwargs = mock_gen.generate_from_template.call_args[1] + assert call_kwargs["template"].id == "tpl-001" + assert call_kwargs["name"] == "我的剪辑计划" + assert call_kwargs["created_by_user_id"] == "user-001" + + @patch("app.api.routes.edit_plans.EditTemplateService") + @patch("app.services.PlanGeneratorService") + def test_create_minimal(self, mock_generator_cls, mock_template_svc_cls, client): c, repo = client + + mock_template_svc = MagicMock() + mock_template_svc.get_template_or_raise.return_value = MagicMock( + id="tpl-001", + name="测试模板", + config={}, + ) + mock_template_svc.list_clip_configs.return_value = [] + mock_template_svc_cls.return_value = mock_template_svc + + mock_gen = MagicMock() + test_plan = self._make_test_plan() + test_plan.status = EditPlanStatus.EDITING + # 把 plan 存到 stub repo + repo.create(test_plan) + mock_gen.generate_from_template.return_value = { + "plan": test_plan, + "clips": [], + } + mock_generator_cls.return_value = mock_gen + resp = c.post( "/api/v1/edit-plans", json={"template_id": "tpl-001", "name": "最小计划"}, ) assert resp.status_code == 201 data = resp.json() - assert data["config"] == normalize_plan_config(None) - assert data["total_duration"] == 0.0 + assert "id" in data + + # 验证生成器被调用 + mock_gen.generate_from_template.assert_called_once() + + @patch("app.api.routes.edit_plans.EditTemplateService") + def test_create_template_not_found_falls_back_empty(self, mock_template_svc_cls, client): + """模板不存在时降级为空计划(向后兼容)""" + c, repo = client + + mock_template_svc = MagicMock() + mock_template_svc.get_template_or_raise.side_effect = ValueError("模板不存在") + mock_template_svc_cls.return_value = mock_template_svc + + resp = c.post( + "/api/v1/edit-plans", + json={"template_id": "nonexistent", "name": "测试"}, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["name"] == "测试" + assert data["template_id"] == "nonexistent" + # 空计划没有片段 + assert "clips" not in data or len(data.get("clips", [])) == 0 def test_create_empty_name_returns_422(self, client): c, repo = client