#456 修复创建剪辑计划时传template_id不生成clips的问题 #460

Merged
auto-approve-bot merged 2 commits from feature/456-create-plan-from-template-auto-clips into develop 2026-07-17 12:32:14 +08:00
2 changed files with 165 additions and 18 deletions
+67 -9
View File
@@ -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)
+98 -9
View File
@@ -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