feat(phase2): 模板编辑器草稿 + 路由层 + 发布流程 #639
@@ -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",
|
||||
|
||||
Executable
+353
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user