#456 修复创建剪辑计划时传template_id不生成clips的问题 #460
Regular → Executable
+67
-9
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user