feat(phase2): 模板编辑器草稿 + 路由层 + 发布流程 #639

Merged
auto-approve-bot merged 1 commits from feat/phase2-template-editor into develop 2026-07-20 13:31:51 +08:00
4 changed files with 874 additions and 0 deletions
+6
View File
@@ -16,6 +16,7 @@ from app.api.routes.subscription import router as subscription_router
from app.api.routes.tags import router as tags_router
from app.api.routes.task_center import router as task_center_router
from app.api.routes.templates import router as templates_router
from app.api.routes.templates_editor import router as templates_editor_router
from app.api.routes.titles import router as titles_router
from app.api.routes.tts import router as tts_router
from app.api.routes.upload import router as upload_router
@@ -119,6 +120,11 @@ api_router.include_router(
prefix="/templates",
tags=["Template"],
)
api_router.include_router(
templates_editor_router,
prefix="/templates/{template_id}/editor",
tags=["TemplateEditor"],
)
api_router.include_router(
edit_plans_router,
prefix="/edit-plans",
+353
View File
@@ -0,0 +1,353 @@
"""模板编辑器 API — 剪辑计划收敛为模板编辑器内部概念.
挂载路径: /api/v1/templates/{template_id}/editor/
核心设计:
- 每个模板有且仅有一个"草稿"剪辑计划 (config.is_template_draft=True)
- 首次访问自动创建草稿
- 内部复用 EditPlanService 的业务逻辑,不重复实现
- 原 /edit-plans/ 路由继续保留作为兼容层
当前已实现端点(Phase 2 第一步):
- GET / 获取编辑器草稿详情
- PUT / 更新草稿基本信息
- POST /publish 发布草稿到模板(草稿→正式模板)
- GET /clips 获取草稿片段列表
- POST /clips 创建片段
- PUT /clips/{id} 更新片段
- DELETE /clips/{id} 删除片段
后续子路由(BGM/转场/滤镜/AI/导出等)将逐步迁移。
"""
from __future__ import annotations
import logging
from typing import Any, List, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session
from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Template Editor"])
# ── Request / Response Schemas ──────────────────────────────────────────────
class EditorDraftResponse(BaseModel):
"""模板编辑器草稿详情响应"""
plan_id: str
template_id: str
name: str
status: str
config: dict[str, Any]
total_duration: float
clip_count: int
is_draft: bool = True
class EditorUpdateRequest(BaseModel):
"""更新草稿请求"""
name: Optional[str] = Field(default=None, min_length=1, max_length=200)
config: Optional[dict[str, Any]] = Field(default=None)
total_duration: Optional[float] = Field(default=None, ge=0.0)
class EditorClipResponse(BaseModel):
"""片段响应"""
id: str
plan_id: str
clip_type: str
order: int
duration: float
text_content: str = ""
transition_effect: str = "cut"
playback_speed: float = 1.0
config: dict[str, Any] = Field(default_factory=dict)
class EditorClipListResponse(BaseModel):
"""片段列表响应"""
items: List[EditorClipResponse]
total: int
class EditorClipCreateRequest(BaseModel):
"""创建片段请求"""
clip_type: str = Field(..., min_length=1, max_length=32)
order: int = Field(..., ge=0)
duration: float = Field(..., gt=0.0)
text_content: str = Field(default="", max_length=2000)
transition_effect: str = Field(default="cut", max_length=32)
config: dict[str, Any] = Field(default_factory=dict)
class EditorClipUpdateRequest(BaseModel):
"""更新片段请求"""
order: Optional[int] = Field(default=None, ge=0)
duration: Optional[float] = Field(default=None, gt=0.0)
text_content: Optional[str] = Field(default=None, max_length=2000)
transition_effect: Optional[str] = Field(default=None, max_length=32)
playback_speed: Optional[float] = Field(default=None, gt=0.0)
config: Optional[dict[str, Any]] = None
class EditorPublishResponse(BaseModel):
"""发布草稿响应"""
template_id: str
status: str = "published"
clip_count: int
# ── Dependencies ────────────────────────────────────────────────────────────
def get_editor_services(
db: Session = Depends(get_db_session),
) -> tuple[EditTemplateService, EditPlanService]:
"""获取模板编辑器所需的两个服务"""
return EditTemplateService(db), EditPlanService(db)
def get_draft_plan_id(
template_id: str,
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> str:
"""
路径依赖:根据 template_id 获取或创建草稿,返回 plan_id。
这是模板编辑器路由的核心依赖——所有编辑器端点都先经过这里,
确保 template_id → plan_id 的映射始终存在。
"""
tpl_svc, _ = services
draft = tpl_svc.get_or_create_draft(
template_id,
user_id=str(current_user.user_id),
)
return draft.id
# ── 草稿核心端点 ────────────────────────────────────────────────────────────
@router.get("", response_model=EditorDraftResponse)
def get_editor_draft(
template_id: str,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
):
"""获取模板编辑器草稿详情
首次访问时自动创建草稿。
"""
_, plan_svc = services
plan = plan_svc.get_plan_or_raise(plan_id)
clips = plan_svc.list_clips(plan_id)
return EditorDraftResponse(
plan_id=plan.id,
template_id=plan.template_id,
name=plan.name,
status=plan.status.value if hasattr(plan.status, "value") else str(plan.status),
config=plan.config or {},
total_duration=plan.total_duration,
clip_count=len(clips),
)
@router.put("", response_model=EditorDraftResponse)
def update_editor_draft(
template_id: str,
req: EditorUpdateRequest,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
):
"""更新模板编辑器草稿"""
_, plan_svc = services
plan = plan_svc.update_plan(
plan_id,
name=req.name,
config=req.config,
total_duration=req.total_duration,
)
clips = plan_svc.list_clips(plan_id)
return EditorDraftResponse(
plan_id=plan.id,
template_id=plan.template_id,
name=plan.name,
status=plan.status.value if hasattr(plan.status, "value") else str(plan.status),
config=plan.config or {},
total_duration=plan.total_duration,
clip_count=len(clips),
)
@router.post("/publish", response_model=EditorPublishResponse, status_code=status.HTTP_200_OK)
def publish_draft_to_template(
template_id: str,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
):
"""将草稿发布(同步)到正式模板
草稿的 config 和 clips 会同步覆盖到模板,事务保证一致性。
"""
tpl_svc, plan_svc = services
try:
tpl = tpl_svc.publish_template_from_draft(template_id, plan_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
clips = plan_svc.list_clips(plan_id)
return EditorPublishResponse(
template_id=tpl.id,
status="published",
clip_count=len(clips),
)
# ── 片段管理端点 ────────────────────────────────────────────────────────────
@router.get("/clips", response_model=EditorClipListResponse)
def list_draft_clips(
template_id: str,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
skip: int = Query(default=0, ge=0),
limit: int = Query(default=100, ge=1, le=500),
_: AuthenticatedUser = Depends(get_current_user),
):
"""获取草稿的片段列表"""
_, plan_svc = services
clips = plan_svc.list_clips(plan_id, skip=skip, limit=limit)
total = plan_svc.count_clips(plan_id)
return EditorClipListResponse(
items=[
EditorClipResponse(
id=c.id,
plan_id=c.plan_id,
clip_type=c.clip_type.value if hasattr(c.clip_type, "value") else str(c.clip_type),
order=c.order,
duration=c.duration,
text_content=c.text_content or "",
transition_effect=(
c.transition_effect.value if hasattr(c.transition_effect, "value") else str(c.transition_effect)
),
playback_speed=c.playback_speed or 1.0,
config=c.config or {},
)
for c in clips
],
total=total,
)
@router.post("/clips", response_model=EditorClipResponse, status_code=status.HTTP_201_CREATED)
def create_draft_clip(
template_id: str,
req: EditorClipCreateRequest,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
):
"""在草稿中创建新片段"""
_, plan_svc = services
try:
clip = plan_svc.create_clip(
plan_id,
clip_type=req.clip_type,
order=req.order,
duration=req.duration,
text_content=req.text_content,
transition_effect=req.transition_effect,
config=req.config,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return EditorClipResponse(
id=clip.id,
plan_id=clip.plan_id,
clip_type=clip.clip_type.value if hasattr(clip.clip_type, "value") else str(clip.clip_type),
order=clip.order,
duration=clip.duration,
text_content=clip.text_content or "",
transition_effect=(
clip.transition_effect.value if hasattr(clip.transition_effect, "value") else str(clip.transition_effect)
),
playback_speed=clip.playback_speed or 1.0,
config=clip.config or {},
)
@router.put("/clips/{clip_id}", response_model=EditorClipResponse)
def update_draft_clip(
template_id: str,
clip_id: str,
req: EditorClipUpdateRequest,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
):
"""更新草稿中的片段"""
_, plan_svc = services
try:
clip = plan_svc.update_clip(
clip_id,
order=req.order,
duration=req.duration,
text_content=req.text_content,
transition_effect=req.transition_effect,
playback_speed=req.playback_speed,
config=req.config,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return EditorClipResponse(
id=clip.id,
plan_id=clip.plan_id,
clip_type=clip.clip_type.value if hasattr(clip.clip_type, "value") else str(clip.clip_type),
order=clip.order,
duration=clip.duration,
text_content=clip.text_content or "",
transition_effect=(
clip.transition_effect.value if hasattr(clip.transition_effect, "value") else str(clip.transition_effect)
),
playback_speed=clip.playback_speed or 1.0,
config=clip.config or {},
)
@router.delete("/clips/{clip_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_draft_clip(
template_id: str,
clip_id: str,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
):
"""删除草稿中的片段"""
_, plan_svc = services
success = plan_svc.delete_clip(clip_id)
if not success:
raise HTTPException(status_code=404, detail="片段不存在")
return None
@@ -533,3 +533,249 @@ class EditTemplateService:
"template": created_template,
"clip_configs": created_configs,
}
# ── 模板草稿(编辑器)相关 ──────────────────────────────────────────────────
def get_template_draft(self, template_id: str) -> Optional[Any]:
"""获取模板的草稿剪辑计划
通过 template_id + config.is_template_draft=True 标记查找。
每个模板有且仅有一个草稿计划。
Args:
template_id: 模板 ID
Returns:
EditPlan | None: 草稿剪辑计划,不存在则返回 None
"""
from packages.domain.edit_plan import EditPlan # noqa: F401
plans = self._plan_repo.list_by_template(template_id, limit=50)
for plan in plans:
config = plan.config or {}
if config.get("is_template_draft") is True:
return plan
return None
def create_template_draft(
self,
template_id: str,
user_id: str,
*,
project_id: str = "",
) -> Any:
"""基于模板创建草稿剪辑计划
草稿与普通剪辑计划的区别:
- config.is_template_draft = True
- 不绑定具体素材(空素材列表)
- 用于模板编辑器的编辑上下文
Args:
template_id: 模板 ID
user_id: 创建者用户 ID
project_id: 所属项目 ID(可选)
Returns:
EditPlan: 创建的草稿剪辑计划
Raises:
ValueError: 模板不存在,或草稿已存在
"""
from .plan_generator_service import PlanGeneratorService
# 检查模板是否存在
template = self.get_template_or_raise(template_id)
# 检查草稿是否已存在
existing = self.get_template_draft(template_id)
if existing is not None:
raise ValueError(f"模板草稿已存在: {template_id}")
# 读取模板片段配置
clip_configs = self.list_clip_configs(template_id)
# 基于模板生成计划(空素材)
generator = PlanGeneratorService(self._db)
result = generator.generate_from_template(
template=template,
clip_configs=clip_configs,
asset_ids=[],
project_id=project_id,
created_by_user_id=user_id,
name=f"{template.name} - 草稿",
)
plan = result["plan"]
# 标记为模板草稿
plan_config = plan.config or {}
plan_config["is_template_draft"] = True
plan.config = plan_config
plan = self._plan_repo.update(plan)
logger.info(
"创建模板草稿: template_id=%s draft_plan_id=%s user_id=%s",
template_id,
plan.id,
user_id,
)
return plan
def get_or_create_draft(
self,
template_id: str,
user_id: str,
*,
project_id: str = "",
) -> Any:
"""获取或创建模板草稿
首次访问模板编辑器时自动创建草稿。
Args:
template_id: 模板 ID
user_id: 操作用户 ID
project_id: 所属项目 ID(可选)
Returns:
EditPlan: 草稿剪辑计划
"""
draft = self.get_template_draft(template_id)
if draft is not None:
return draft
return self.create_template_draft(template_id, user_id, project_id=project_id)
def publish_template_from_draft(
self,
template_id: str,
draft_plan_id: str,
) -> Any:
"""将草稿剪辑计划的内容发布(同步)到模板
将草稿的配置和片段结构同步到模板,相当于"保存"编辑结果。
使用事务保证一致性,失败则回滚。
同步规则:
- 草稿 plan.config → template.config(过滤掉草稿特有字段)
- 草稿 clips → template_clip_configs(先删后插)
- 草稿 editing_mode → template.editing_mode
- 不更新模板名称、描述等元信息(由专门的接口处理)
Args:
template_id: 模板 ID
draft_plan_id: 草稿剪辑计划 ID
Returns:
EditTemplate: 更新后的模板
Raises:
ValueError: 模板/草稿不存在,或草稿不属于该模板
"""
from packages.domain.template_clip_config import TemplateClipConfig
# 1. 校验模板和草稿
template = self.get_template_or_raise(template_id)
draft = self._plan_repo.get(draft_plan_id)
if draft is None:
raise ValueError(f"草稿计划不存在: {draft_plan_id}")
if draft.template_id != template_id:
raise ValueError(f"草稿不属于该模板: plan_template_id={draft.template_id}")
config = draft.config or {}
if config.get("is_template_draft") is not True:
raise ValueError("指定的计划不是模板草稿")
# 2. 读取草稿片段
draft_clips = self._plan_clip_repo.list_by_plan(draft_plan_id)
draft_clips.sort(key=lambda c: c.order)
# 3. 提取 editing_mode
editing_mode = config.get("editing_mode", "one_take")
# 4. 提取模板配置(去掉草稿/运行时字段)
draft_config = draft.config or {}
template_config: dict[str, Any] = {}
skip_keys = {
"is_template_draft",
"asset_ids",
"source_edit_plan_id",
"generation_task_id",
}
for key, value in draft_config.items():
if key not in skip_keys:
template_config[key] = value
# 5. 事务更新
try:
# 更新模板元信息
template.config = template_config
template.editing_mode = editing_mode
updated_template = self._template_repo.update(template)
# 删除旧的片段配置
old_configs = self._clip_config_repo.list_by_template(template_id)
for cfg in old_configs:
self._clip_config_repo.delete(cfg.id)
# 创建新的片段配置
created_configs: list[TemplateClipConfig] = []
for clip in draft_clips:
clip_config: dict[str, Any] = {}
# 播放速度存入 config
if clip.playback_speed and clip.playback_speed != 1.0:
clip_config["playback_speed"] = clip.playback_speed
# 片段自有 config 合并
if clip.config:
clip_config.update(clip.config)
# 去掉素材相关字段
clip_config.pop("asset_info", None)
clip_config.pop("source_asset_id", None)
# 转场效果兼容校验
try:
from packages.domain.template_clip_config import (
TransitionEffect,
)
transition = TransitionEffect(clip.transition_effect)
except (ValueError, ImportError):
transition = TransitionEffect.CUT # type: ignore
# 片段类型兼容校验
try:
from packages.domain.template_clip_config import ClipType
clip_type = ClipType(clip.clip_type)
except (ValueError, ImportError):
clip_type = ClipType.MAIN # type: ignore
config_obj = TemplateClipConfig.create(
template_id=template_id,
clip_type=clip_type,
order=clip.order,
min_duration=clip.duration,
max_duration=clip.duration,
text_template=clip.text_content or "",
transition_effect=transition,
config=clip_config,
)
created = self._clip_config_repo.create(config_obj)
created_configs.append(created)
self._db.commit()
logger.info(
"发布模板草稿: template_id=%s draft_plan_id=%s clip_count=%d",
template_id,
draft_plan_id,
len(created_configs),
)
return updated_template
except Exception as exc:
self._db.rollback()
logger.error(
"发布模板草稿失败: template_id=%s draft_plan_id=%s error=%s",
template_id,
draft_plan_id,
exc,
)
raise
+269
View File
@@ -490,6 +490,24 @@ class StubEditPlanRepository:
self._plans[plan.id] = plan
return plan
def list_by_template(
self,
template_id: str,
*,
status: Optional[str] = None,
skip: int = 0,
limit: int = 50,
) -> List[EditPlan]:
items = [p for p in self._plans.values() if p.template_id == template_id]
if status:
items = [p for p in items if p.status.value == status]
items.sort(key=lambda p: p.created_at, reverse=True)
return items[skip : skip + limit]
def update(self, plan: EditPlan) -> EditPlan:
self._plans[plan.id] = plan
return plan
class StubEditPlanClipRepository:
"""内存中的 EditPlanClip 仓储 stub"""
@@ -751,3 +769,254 @@ class TestSavePlanAsTemplate:
result = svc.save_plan_as_template(plan.id, name="类型兼容模板")
assert result["clip_configs"][0].clip_type == ClipType.MAIN
class TestTemplateDraft:
"""模板草稿相关功能测试"""
def test_get_template_draft_not_found(self):
"""没有草稿时返回None"""
svc = _make_service_with_plan_stubs()
# 先创建一个模板
tpl = svc.create_template(name="测试模板", editing_mode="one_take")
result = svc.get_template_draft(tpl.id)
assert result is None
def test_get_template_draft_found(self):
"""能正确找到标记了is_template_draft的草稿"""
svc = _make_service_with_plan_stubs()
from packages.domain.edit_plan import EditPlan
tpl = svc.create_template(name="测试模板", editing_mode="one_take")
# 普通计划(不是草稿)
normal_plan = EditPlan.create(template_id=tpl.id, name="普通计划", config={"key": "value"})
normal_plan.id = "plan-normal"
svc._plan_repo.create(normal_plan)
# 草稿计划
draft_plan = EditPlan.create(
template_id=tpl.id,
name="草稿计划",
config={"is_template_draft": True, "other": "data"},
)
draft_plan.id = "plan-draft"
svc._plan_repo.create(draft_plan)
result = svc.get_template_draft(tpl.id)
assert result is not None
assert result.id == "plan-draft"
assert result.config["is_template_draft"] is True
def test_create_template_draft_success(self, monkeypatch):
"""创建草稿成功,标记is_template_draft=True"""
svc = _make_service_with_plan_stubs()
from packages.domain.edit_plan import EditPlan
tpl = svc.create_template(name="草稿测试模板", editing_mode="one_take")
# mock PlanGeneratorService.generate_from_template
called_with = {}
def fake_generate(self, template, clip_configs, asset_ids, **kwargs):
called_with["template"] = template
called_with["clip_configs"] = clip_configs
called_with["asset_ids"] = asset_ids
called_with["kwargs"] = kwargs
plan = EditPlan.create(
template_id=template.id,
name=kwargs.get("name", "测试草稿"),
config={"editing_mode": "one_take"},
)
plan.id = "plan-new-draft"
svc._plan_repo.create(plan)
return {"plan": plan, "clips": []}
from app.services import plan_generator_service
monkeypatch.setattr(
plan_generator_service.PlanGeneratorService,
"generate_from_template",
fake_generate,
)
draft = svc.create_template_draft(tpl.id, "user-001")
assert draft is not None
assert draft.id == "plan-new-draft"
assert draft.config.get("is_template_draft") is True
assert called_with["asset_ids"] == []
assert called_with["kwargs"]["created_by_user_id"] == "user-001"
def test_create_template_draft_duplicate_raises(self):
"""草稿已存在时抛ValueError"""
svc = _make_service_with_plan_stubs()
from packages.domain.edit_plan import EditPlan
tpl = svc.create_template(name="重复草稿模板", editing_mode="one_take")
# 预先创建一个草稿
draft = EditPlan.create(
template_id=tpl.id,
name="已存在草稿",
config={"is_template_draft": True},
)
draft.id = "plan-existing"
svc._plan_repo.create(draft)
import pytest
with pytest.raises(ValueError, match="草稿已存在"):
svc.create_template_draft(tpl.id, "user-001")
def test_get_or_create_draft_creates_when_missing(self, monkeypatch):
"""草稿不存在时自动创建"""
svc = _make_service_with_plan_stubs()
from packages.domain.edit_plan import EditPlan
tpl = svc.create_template(name="自动创建模板", editing_mode="one_take")
def fake_generate(self, template, clip_configs, asset_ids, **kwargs):
plan = EditPlan.create(
template_id=template.id,
name="自动创建草稿",
config={"editing_mode": "one_take"},
)
plan.id = "plan-auto"
svc._plan_repo.create(plan)
return {"plan": plan, "clips": []}
from app.services import plan_generator_service
monkeypatch.setattr(
plan_generator_service.PlanGeneratorService,
"generate_from_template",
fake_generate,
)
# 第一次调用:创建
draft1 = svc.get_or_create_draft(tpl.id, "user-001")
assert draft1 is not None
assert draft1.config.get("is_template_draft") is True
# 第二次调用:返回已存在的
draft2 = svc.get_or_create_draft(tpl.id, "user-001")
assert draft2.id == draft1.id
def test_publish_template_from_draft_success(self):
"""草稿发布成功,同步config和clips到模板"""
svc = _make_service_with_plan_stubs()
from packages.domain.edit_plan import EditPlan
from packages.domain.edit_plan_clip import EditPlanClip
# 创建模板和初始片段配置
tpl = svc.create_template(
name="发布测试模板",
editing_mode="one_take",
config={"original": "value"},
)
svc.create_clip_config(
tpl.id,
clip_type="main",
order=0,
min_duration=5.0,
max_duration=5.0,
)
# 创建草稿
draft = EditPlan.create(
template_id=tpl.id,
name="发布草稿",
config={
"is_template_draft": True,
"editing_mode": "pip",
"bgm": "song.mp3",
"title": "新标题",
},
)
draft.id = "plan-publish"
svc._plan_repo.create(draft)
# 草稿的片段
clip1 = EditPlanClip.create(
plan_id=draft.id,
clip_type="main",
order=0,
duration=8.0,
text_content="第一段",
transition_effect="fade",
config={"playback_speed": 1.5},
)
clip1.id = "clip-pub-1"
svc._plan_clip_repo.create(clip1)
clip2 = EditPlanClip.create(
plan_id=draft.id,
clip_type="main",
order=1,
duration=12.0,
text_content="第二段",
)
clip2.id = "clip-pub-2"
svc._plan_clip_repo.create(clip2)
# 发布
result = svc.publish_template_from_draft(tpl.id, draft.id)
assert result is not None
assert result.editing_mode == "pip"
assert result.config.get("original") is None # 旧配置被覆盖
assert result.config.get("bgm") == "song.mp3"
assert result.config.get("is_template_draft") is None # 草稿标记不带过去
# 检查片段配置已更新
new_configs = svc.list_clip_configs(tpl.id)
assert len(new_configs) == 2
assert new_configs[0].order == 0
assert new_configs[0].min_duration == 8.0
assert new_configs[0].text_template == "第一段"
assert new_configs[0].config.get("playback_speed") == 1.5
assert new_configs[1].order == 1
assert new_configs[1].max_duration == 12.0
def test_publish_template_wrong_template_raises(self):
"""草稿不属于指定模板时抛错"""
svc = _make_service_with_plan_stubs()
from packages.domain.edit_plan import EditPlan
tpl1 = svc.create_template(name="模板1", editing_mode="one_take")
tpl2 = svc.create_template(name="模板2", editing_mode="one_take")
# 草稿属于tpl1
draft = EditPlan.create(
template_id=tpl1.id,
name="草稿",
config={"is_template_draft": True},
)
draft.id = "plan-wrong-tpl"
svc._plan_repo.create(draft)
import pytest
with pytest.raises(ValueError, match="不属于该模板"):
svc.publish_template_from_draft(tpl2.id, draft.id)
def test_publish_template_not_draft_raises(self):
"""不是草稿的计划不能发布"""
svc = _make_service_with_plan_stubs()
from packages.domain.edit_plan import EditPlan
tpl = svc.create_template(name="非草稿模板", editing_mode="one_take")
normal_plan = EditPlan.create(
template_id=tpl.id,
name="普通计划",
config={"is_template_draft": False},
)
normal_plan.id = "plan-not-draft"
svc._plan_repo.create(normal_plan)
import pytest
with pytest.raises(ValueError, match="不是模板草稿"):
svc.publish_template_from_draft(tpl.id, normal_plan.id)