diff --git a/apps/api/app/api/routes/edit_plans.py b/apps/api/app/api/routes/edit_plans.py
index c3cc7692d..5b074fce4 100644
--- a/apps/api/app/api/routes/edit_plans.py
+++ b/apps/api/app/api/routes/edit_plans.py
@@ -8,6 +8,8 @@ RESTful CRUD for EditPlan:
- DELETE /api/v1/edit-plans/{id} 删除
- POST /api/v1/edit-plans/{id}/generate 触发剪辑渲染生成(任务 2.05)
- GET /api/v1/edit-plans/{id}/generation-status 查询生成进度(任务 2.05)
+
+业务逻辑委托给 EditPlanService 服务层。
"""
from __future__ import annotations
@@ -19,14 +21,11 @@ from typing import Any, List, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
from app.dependencies import get_db_session
+from app.services import EditPlanService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
-from packages.adapters.sqlalchemy_impl import SQLAlchemyEditPlanRepository
-from packages.adapters.sqlalchemy_impl.edit_plan_clip_repository import (
- SQLAlchemyEditPlanClipRepository,
-)
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
@@ -35,7 +34,6 @@ from packages.application.generation_tasks import (
CreateGenerationTaskUseCase,
)
from packages.domain.edit_plan import EditPlan, EditPlanStatus
-from packages.domain.edit_plan_clip import EditPlanClipStatus
logger = logging.getLogger(__name__)
@@ -136,30 +134,6 @@ def _to_response(p: EditPlan) -> EditPlanResponse:
)
-def _apply_status_transition(plan: EditPlan, target_status_str: str) -> None:
- """通过状态机方法流转状态,非法流转抛出 ValueError"""
- try:
- target = EditPlanStatus(target_status_str)
- except ValueError:
- raise ValueError(
- f"无效的状态值: {target_status_str},"
- f"可选值: draft, editing, rendering, completed, failed"
- )
-
- if target == plan.status:
- return # 已是目标状态,无需流转
-
- # 根据目标状态选择对应的状态机方法
- transition_map = {
- EditPlanStatus.EDITING: plan.start_editing,
- EditPlanStatus.RENDERING: plan.start_rendering,
- EditPlanStatus.COMPLETED: plan.mark_completed,
- EditPlanStatus.FAILED: plan.mark_failed,
- EditPlanStatus.DRAFT: plan.reset_to_draft,
- }
- transition_map[target]()
-
-
# ── Routes ────────────────────────────────────────────────────────────────────
@@ -177,7 +151,7 @@ def list_plans(
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditPlanListResponse:
"""获取剪辑计划列表(支持分页、按模板/状态筛选)"""
- repo = SQLAlchemyEditPlanRepository(db)
+ svc = EditPlanService(db)
# 解析状态筛选
status_enum: Optional[EditPlanStatus] = None
@@ -194,26 +168,16 @@ def list_plans(
)
skip = (page - 1) * page_size
-
- # 根据是否有 template_id 选择查询方法
- if template_id:
- plans = repo.list_by_template(
- template_id,
- status=status_enum,
- skip=skip,
- limit=page_size,
- )
- # count() 不支持 template_id 筛选,通过全量查询计算 total
- all_matching = repo.list_by_template(
- template_id,
- status=status_enum,
- skip=0,
- limit=10000,
- )
- total = len(all_matching)
- else:
- plans = repo.list_all(status=status_enum, skip=skip, limit=page_size)
- total = repo.count(status=status_enum)
+ plans = svc.list_plans(
+ template_id=template_id,
+ status=status_enum,
+ skip=skip,
+ limit=page_size,
+ )
+ total = svc.count_plans(
+ template_id=template_id,
+ status=status_enum,
+ )
return EditPlanListResponse(
items=[_to_response(p) for p in plans],
@@ -230,12 +194,13 @@ def get_plan(
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditPlanResponse:
"""获取单个剪辑计划详情"""
- repo = SQLAlchemyEditPlanRepository(db)
- plan = repo.get(plan_id)
- if plan is None:
+ svc = EditPlanService(db)
+ try:
+ plan = svc.get_plan_or_raise(plan_id)
+ except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
- detail=f"剪辑计划不存在: {plan_id}",
+ detail=str(exc),
)
return _to_response(plan)
@@ -247,9 +212,9 @@ def create_plan(
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditPlanResponse:
"""创建剪辑计划"""
- repo = SQLAlchemyEditPlanRepository(db)
+ svc = EditPlanService(db)
try:
- plan = EditPlan.create(
+ created = svc.create_plan(
template_id=body.template_id,
name=body.name,
config=body.config,
@@ -260,7 +225,6 @@ def create_plan(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
- created = repo.create(plan)
logger.info(
"创建剪辑计划: id=%s name=%s by user=%s",
created.id,
@@ -278,49 +242,39 @@ def update_plan(
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditPlanResponse:
"""更新剪辑计划(支持状态机流转)"""
- repo = SQLAlchemyEditPlanRepository(db)
- existing = repo.get(plan_id)
- if existing is None:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail=f"剪辑计划不存在: {plan_id}",
- )
+ svc = EditPlanService(db)
# 基础字段更新
- new_name = body.name.strip() if body.name is not None else existing.name
- new_config = body.config if body.config is not None else existing.config
- new_total_duration = body.total_duration if body.total_duration is not None else existing.total_duration
-
- # 状态机流转
- new_status = existing.status
- if body.status is not None:
- try:
- _apply_status_transition(existing, body.status)
- new_status = existing.status
- except ValueError as exc:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=str(exc),
+ try:
+ if body.name is not None or body.config is not None or body.total_duration is not None:
+ svc.update_plan(
+ plan_id,
+ name=body.name,
+ config=body.config,
+ total_duration=body.total_duration,
)
- try:
- updated = EditPlan(
- id=existing.id,
- template_id=existing.template_id,
- name=new_name,
- status=new_status,
- total_duration=new_total_duration,
- config=new_config,
- created_at=existing.created_at,
- updated_at=existing.updated_at,
- )
- except (ValueError, TypeError) as exc:
+ # 状态机流转
+ if body.status is not None:
+ try:
+ target_status = EditPlanStatus(body.status)
+ except ValueError:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=(
+ f"无效的状态值: {body.status},"
+ f"可选值: draft, editing, rendering, completed, failed"
+ ),
+ )
+ svc.transition_status(plan_id, target_status)
+ except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
- result = repo.update(updated)
+ # 返回最新状态
+ result = svc.get_plan_or_raise(plan_id)
logger.info("更新剪辑计划: id=%s by user=%s", plan_id, current_user.user.id)
return _to_response(result)
@@ -332,15 +286,13 @@ def delete_plan(
current_user: AuthenticatedUser = Depends(get_current_user),
) -> None:
"""删除剪辑计划"""
- repo = SQLAlchemyEditPlanRepository(db)
- existing = repo.get(plan_id)
- if existing is None:
+ svc = EditPlanService(db)
+ deleted = svc.delete_plan(plan_id)
+ if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
-
- repo.delete(plan_id)
logger.info(
"删除剪辑计划: id=%s by user=%s",
plan_id,
@@ -367,43 +319,23 @@ def generate_plan(
4. 调度 Celery 任务 worker.render_edit_plan
5. 将计划状态流转为 rendering
"""
- plan_repo = SQLAlchemyEditPlanRepository(db)
- clip_repo = SQLAlchemyEditPlanClipRepository(db)
- gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
+ svc = EditPlanService(db)
- plan = plan_repo.get(plan_id)
- if plan is None:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail=f"剪辑计划不存在: {plan_id}",
- )
-
- # 验证状态必须为 editing
- if plan.status != EditPlanStatus.EDITING:
+ # 检查是否可生成
+ can_gen, reason = svc.can_generate(plan_id)
+ if not can_gen:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
- detail=(
- f"只有 editing 状态的计划才能触发生成,当前状态: {plan.status.value}。"
- f"请先将计划状态流转为 editing"
- ),
- )
-
- # 检查片段
- clips = clip_repo.list_by_plan(plan_id, skip=0, limit=10000)
- if not clips:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="剪辑计划没有片段,请先添加片段再触发生成",
+ detail=reason,
)
# 将 pending 片段标记为 ready
- pending_clips = [c for c in clips if c.status == EditPlanClipStatus.PENDING]
- for clip in pending_clips:
- clip.mark_ready()
- clip_repo.update(clip)
+ clip_count = svc.mark_clips_ready(plan_id)
# 创建 GenerationTask
+ gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
+ plan = svc.get_plan_or_raise(plan_id)
gen_task = gen_task_use_case.execute(
CreateGenerationTaskCommand(
project_id="",
@@ -413,28 +345,30 @@ def generate_plan(
)
# 将 generation_task_id 存入 plan config
- plan.config["generation_task_id"] = gen_task.id
+ svc.update_plan_config(plan_id, {"generation_task_id": gen_task.id})
# 流转状态为 rendering
- plan.start_rendering()
- plan_repo.update(plan)
+ svc.transition_status(plan_id, EditPlanStatus.RENDERING)
# 调度 Celery 任务
celery_app.send_task("worker.render_edit_plan", args=[plan_id])
+ # 获取最新状态
+ updated_plan = svc.get_plan_or_raise(plan_id)
+
logger.info(
"触发剪辑计划生成: plan_id=%s gen_task_id=%s clips=%d by user=%s",
plan_id,
gen_task.id,
- len(clips),
+ clip_count,
current_user.user.id,
)
return EditPlanGenerateResponse(
plan_id=plan_id,
- plan_status=plan.status.value if hasattr(plan.status, "value") else plan.status,
+ plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
generation_task_id=gen_task.id,
- clip_count=len(clips),
+ clip_count=clip_count,
)
@@ -451,18 +385,11 @@ def get_generation_status(
返回计划状态、关联的 GenerationTask ID、以及每个片段的状态。
"""
- plan_repo = SQLAlchemyEditPlanRepository(db)
- clip_repo = SQLAlchemyEditPlanClipRepository(db)
+ svc = EditPlanService(db)
+ gen_status = svc.get_generation_status(plan_id)
- plan = plan_repo.get(plan_id)
- if plan is None:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail=f"剪辑计划不存在: {plan_id}",
- )
-
- clips = clip_repo.list_by_plan(plan_id, skip=0, limit=10000)
- generation_task_id = plan.config.get("generation_task_id")
+ plan = gen_status["plan"]
+ clips = gen_status["clips"]
clip_items = [
ClipStatusItem(
@@ -480,6 +407,6 @@ def get_generation_status(
return EditPlanGenerationStatusResponse(
plan_id=plan_id,
plan_status=plan.status.value if hasattr(plan.status, "value") else plan.status,
- generation_task_id=generation_task_id,
+ generation_task_id=gen_status["generation_task_id"],
clips=clip_items,
)
diff --git a/apps/api/app/api/routes/edit_templates.py b/apps/api/app/api/routes/edit_templates.py
index c27e207f8..c331a23c4 100644
--- a/apps/api/app/api/routes/edit_templates.py
+++ b/apps/api/app/api/routes/edit_templates.py
@@ -6,6 +6,8 @@ RESTful CRUD for EditTemplate:
- POST /api/v1/edit-templates 创建(管理员)
- PUT /api/v1/edit-templates/{id} 更新
- DELETE /api/v1/edit-templates/{id} 删除(软删除 → inactive)
+
+业务逻辑委托给 EditTemplateService 服务层。
"""
from __future__ import annotations
@@ -16,11 +18,11 @@ from typing import Any, List, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session
+from app.services import EditTemplateService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
-from packages.adapters.sqlalchemy_impl import SQLAlchemyEditTemplateRepository
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
logger = logging.getLogger(__name__)
@@ -115,7 +117,7 @@ def list_templates(
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditTemplateListResponse:
"""获取模板列表(支持分页、按类型/状态筛选)"""
- repo = SQLAlchemyEditTemplateRepository(db)
+ svc = EditTemplateService(db)
# 解析状态筛选
status_enum: Optional[EditTemplateStatus] = None
@@ -129,13 +131,16 @@ def list_templates(
)
skip = (page - 1) * page_size
- templates = repo.list_all(
+ templates = svc.list_templates(
template_type=template_type,
status=status_enum,
skip=skip,
limit=page_size,
)
- total = repo.count(template_type=template_type, status=status_enum)
+ total = svc.count_templates(
+ template_type=template_type,
+ status=status_enum,
+ )
return EditTemplateListResponse(
items=[_to_response(t) for t in templates],
@@ -152,12 +157,13 @@ def get_template(
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditTemplateResponse:
"""获取单个模板详情"""
- repo = SQLAlchemyEditTemplateRepository(db)
- template = repo.get(template_id)
- if template is None:
+ svc = EditTemplateService(db)
+ try:
+ template = svc.get_template_or_raise(template_id)
+ except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
- detail=f"模板不存在: {template_id}",
+ detail=str(exc),
)
return _to_response(template)
@@ -169,9 +175,9 @@ def create_template(
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditTemplateResponse:
"""创建模板(管理员)"""
- repo = SQLAlchemyEditTemplateRepository(db)
+ svc = EditTemplateService(db)
try:
- template = EditTemplate.create(
+ created = svc.create_template(
name=body.name,
description=body.description,
template_type=body.template_type,
@@ -184,7 +190,6 @@ def create_template(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
- created = repo.create(template)
logger.info("创建模板: id=%s name=%s by user=%s", created.id, created.name, current_user.user.id)
return _to_response(created)
@@ -197,25 +202,13 @@ def update_template(
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditTemplateResponse:
"""更新模板"""
- repo = SQLAlchemyEditTemplateRepository(db)
- existing = repo.get(template_id)
- if existing is None:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail=f"模板不存在: {template_id}",
- )
+ svc = EditTemplateService(db)
- # 部分更新:仅覆盖 body 中提供的字段
- new_name = body.name if body.name is not None else existing.name
- new_description = body.description if body.description is not None else existing.description
- new_template_type = body.template_type if body.template_type is not None else existing.template_type
- new_config = body.config if body.config is not None else existing.config
- new_preview_url = body.preview_url if body.preview_url is not None else existing.preview_url
- new_sort_weight = body.sort_weight if body.sort_weight is not None else existing.sort_weight
- new_status = existing.status
+ # 解析状态
+ status_enum: Optional[EditTemplateStatus] = None
if body.status is not None:
try:
- new_status = EditTemplateStatus(body.status)
+ status_enum = EditTemplateStatus(body.status)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -223,25 +216,21 @@ def update_template(
)
try:
- updated = EditTemplate(
- id=existing.id,
- name=new_name.strip() if new_name else existing.name,
- description=new_description.strip() if new_description is not None else existing.description,
- template_type=new_template_type.strip() if new_template_type else existing.template_type,
- config=new_config,
- preview_url=new_preview_url.strip() if new_preview_url is not None else existing.preview_url,
- sort_weight=new_sort_weight,
- status=new_status,
- created_at=existing.created_at,
- updated_at=existing.updated_at,
+ result = svc.update_template(
+ template_id,
+ name=body.name,
+ description=body.description,
+ template_type=body.template_type,
+ config=body.config,
+ preview_url=body.preview_url,
+ sort_weight=body.sort_weight,
+ status=status_enum,
)
- except (ValueError, TypeError) as exc:
+ except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
-
- result = repo.update(updated)
logger.info("更新模板: id=%s by user=%s", template_id, current_user.user.id)
return _to_response(result)
@@ -253,15 +242,12 @@ def delete_template(
current_user: AuthenticatedUser = Depends(get_current_user),
) -> None:
"""删除模板(软删除 → 设为 inactive)"""
- repo = SQLAlchemyEditTemplateRepository(db)
- existing = repo.get(template_id)
- if existing is None:
+ svc = EditTemplateService(db)
+ try:
+ svc.deactivate_template(template_id)
+ except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
- detail=f"模板不存在: {template_id}",
+ detail=str(exc),
)
-
- # 软删除:将状态设为 inactive
- existing.deactivate()
- repo.update(existing)
logger.info("删除模板(软删除): id=%s by user=%s", template_id, current_user.user.id)
diff --git a/apps/api/app/services/__init__.py b/apps/api/app/services/__init__.py
new file mode 100644
index 000000000..b05a02004
--- /dev/null
+++ b/apps/api/app/services/__init__.py
@@ -0,0 +1,9 @@
+"""Service layer exports for Phase 8 模板编排引擎."""
+
+from .edit_plan_service import EditPlanService
+from .edit_template_service import EditTemplateService
+
+__all__ = [
+ "EditPlanService",
+ "EditTemplateService",
+]
diff --git a/apps/api/app/services/edit_plan_service.py b/apps/api/app/services/edit_plan_service.py
new file mode 100644
index 000000000..f4a91ccc4
--- /dev/null
+++ b/apps/api/app/services/edit_plan_service.py
@@ -0,0 +1,473 @@
+"""EditPlanService — 剪辑计划管理业务逻辑.
+
+封装 EditPlan 和 EditPlanClip 的 CRUD 操作、状态机流转、
+以及渲染生成流程,提供统一的业务接口供 API 路由层调用。
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any, Dict, List, Optional
+
+from sqlalchemy.orm import Session
+
+from packages.adapters.sqlalchemy_impl import (
+ SQLAlchemyEditPlanClipRepository,
+ SQLAlchemyEditPlanRepository,
+ SQLAlchemyGenerationTaskRepository,
+)
+from packages.domain.edit_plan import EditPlan, EditPlanStatus
+from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
+from packages.domain.generation_task import GenerationTaskStatus
+
+logger = logging.getLogger(__name__)
+
+
+class EditPlanService:
+ """剪辑计划管理服务
+
+ 职责:
+ - 剪辑计划 CRUD(创建、查询、更新、删除)
+ - 剪辑片段管理(增删改查、分配素材)
+ - 状态机流转(draft → editing → rendering → completed/failed)
+ - 渲染生成流程(触发 Celery 任务、查询进度)
+ """
+
+ def __init__(self, db: Session) -> None:
+ self._plan_repo = SQLAlchemyEditPlanRepository(db)
+ self._clip_repo = SQLAlchemyEditPlanClipRepository(db)
+ self._generation_task_repo = SQLAlchemyGenerationTaskRepository(db)
+
+ # ── 剪辑计划 CRUD ──────────────────────────────────────────────────────
+
+ def list_plans(
+ self,
+ *,
+ template_id: Optional[str] = None,
+ status: Optional[EditPlanStatus] = None,
+ skip: int = 0,
+ limit: int = 50,
+ ) -> List[EditPlan]:
+ """列出剪辑计划
+
+ Args:
+ template_id: 按模板 ID 筛选
+ status: 按状态筛选
+ skip: 分页偏移
+ limit: 每页数量
+ """
+ if template_id:
+ return self._plan_repo.list_by_template(
+ template_id,
+ status=status,
+ skip=skip,
+ limit=limit,
+ )
+ return self._plan_repo.list_all(status=status, skip=skip, limit=limit)
+
+ def count_plans(
+ self,
+ *,
+ template_id: Optional[str] = None,
+ status: Optional[EditPlanStatus] = None,
+ ) -> int:
+ """统计计划数量
+
+ Note:
+ 当指定 template_id 时,通过全量查询计算 total(repo 限制)。
+ """
+ if template_id:
+ all_matching = self._plan_repo.list_by_template(
+ template_id,
+ status=status,
+ skip=0,
+ limit=10000,
+ )
+ return len(all_matching)
+ return self._plan_repo.count(status=status)
+
+ def get_plan(self, plan_id: str) -> Optional[EditPlan]:
+ """获取计划详情"""
+ return self._plan_repo.get(plan_id)
+
+ def get_plan_or_raise(self, plan_id: str) -> EditPlan:
+ """获取计划,不存在则抛出 ValueError"""
+ plan = self._plan_repo.get(plan_id)
+ if plan is None:
+ raise ValueError(f"剪辑计划不存在: {plan_id}")
+ return plan
+
+ def create_plan(
+ self,
+ template_id: str,
+ name: str,
+ *,
+ config: Optional[dict[str, Any]] = None,
+ total_duration: float = 0.0,
+ ) -> EditPlan:
+ """创建剪辑计划
+
+ Raises:
+ ValueError: 参数校验失败
+ """
+ plan = EditPlan.create(
+ template_id=template_id,
+ name=name,
+ config=config,
+ total_duration=total_duration,
+ )
+ created = self._plan_repo.create(plan)
+ logger.info("创建剪辑计划: id=%s name=%s", created.id, created.name)
+ return created
+
+ def update_plan(
+ self,
+ plan_id: str,
+ *,
+ name: Optional[str] = None,
+ config: Optional[dict[str, Any]] = None,
+ total_duration: Optional[float] = None,
+ ) -> EditPlan:
+ """更新计划基础字段
+
+ Raises:
+ ValueError: 计划不存在
+ """
+ existing = self.get_plan_or_raise(plan_id)
+
+ updated = EditPlan(
+ id=existing.id,
+ template_id=existing.template_id,
+ name=name.strip() if name is not None else existing.name,
+ status=existing.status,
+ total_duration=total_duration if total_duration is not None else existing.total_duration,
+ config=config if config is not None else existing.config,
+ created_at=existing.created_at,
+ updated_at=existing.updated_at,
+ )
+ result = self._plan_repo.update(updated)
+ logger.info("更新剪辑计划: id=%s", plan_id)
+ return result
+
+ def delete_plan(self, plan_id: str) -> bool:
+ """删除剪辑计划及其所有片段
+
+ Returns:
+ bool: 是否删除成功
+ """
+ existing = self._plan_repo.get(plan_id)
+ if existing is None:
+ return False
+
+ # 先删除所有片段
+ self._clip_repo.delete_by_plan(plan_id)
+ # 再删除计划
+ self._plan_repo.delete(plan_id)
+ logger.info("删除剪辑计划: id=%s", plan_id)
+ return True
+
+ # ── 状态机流转 ──────────────────────────────────────────────────────────
+
+ def transition_status(self, plan_id: str, target_status: EditPlanStatus) -> EditPlan:
+ """流转计划状态
+
+ 状态流转规则:
+ - draft → editing (start_editing)
+ - editing → rendering (start_rendering)
+ - rendering → completed (mark_completed)
+ - rendering → failed (mark_failed)
+ - failed → draft (reset_to_draft)
+
+ Raises:
+ ValueError: 计划不存在或状态流转非法
+ """
+ plan = self.get_plan_or_raise(plan_id)
+
+ # 如果已是目标状态,直接返回
+ if plan.status == target_status:
+ return plan
+
+ # 根据目标状态调用对应的状态机方法
+ transition_map = {
+ EditPlanStatus.EDITING: plan.start_editing,
+ EditPlanStatus.RENDERING: plan.start_rendering,
+ EditPlanStatus.COMPLETED: plan.mark_completed,
+ EditPlanStatus.FAILED: plan.mark_failed,
+ EditPlanStatus.DRAFT: plan.reset_to_draft,
+ }
+
+ transition_fn = transition_map.get(target_status)
+ if transition_fn is None:
+ raise ValueError(f"无效的目标状态: {target_status}")
+
+ transition_fn()
+ result = self._plan_repo.update(plan)
+ logger.info(
+ "状态流转: plan_id=%s %s → %s",
+ plan_id,
+ plan.status,
+ target_status,
+ )
+ return result
+
+ # ── 剪辑片段管理 ────────────────────────────────────────────────────────
+
+ def list_clips(
+ self,
+ plan_id: str,
+ *,
+ status: Optional[EditPlanClipStatus] = None,
+ skip: int = 0,
+ limit: int = 100,
+ ) -> List[EditPlanClip]:
+ """列出计划的片段"""
+ # 确保计划存在
+ self.get_plan_or_raise(plan_id)
+ return self._clip_repo.list_by_plan(plan_id, status=status, skip=skip, limit=limit)
+
+ def count_clips(
+ self,
+ plan_id: str,
+ *,
+ status: Optional[EditPlanClipStatus] = None,
+ ) -> int:
+ """统计片段数量"""
+ return self._clip_repo.count(plan_id=plan_id, status=status)
+
+ def get_clip(self, clip_id: str) -> Optional[EditPlanClip]:
+ """获取片段详情"""
+ return self._clip_repo.get(clip_id)
+
+ def get_clip_or_raise(self, clip_id: str) -> EditPlanClip:
+ """获取片段,不存在则抛出 ValueError"""
+ clip = self._clip_repo.get(clip_id)
+ if clip is None:
+ raise ValueError(f"片段不存在: {clip_id}")
+ return clip
+
+ def create_clip(
+ self,
+ plan_id: str,
+ clip_type: str,
+ order: int,
+ *,
+ template_clip_config_id: str = "",
+ asset_id: str = "",
+ text_content: str = "",
+ start_time: float = 0.0,
+ duration: float = 0.0,
+ transition_effect: str = "cut",
+ config: Optional[dict[str, Any]] = None,
+ ) -> EditPlanClip:
+ """创建片段
+
+ Raises:
+ ValueError: 计划不存在或参数校验失败
+ """
+ # 确保计划存在
+ self.get_plan_or_raise(plan_id)
+
+ clip = EditPlanClip.create(
+ plan_id=plan_id,
+ clip_type=clip_type,
+ order=order,
+ template_clip_config_id=template_clip_config_id,
+ asset_id=asset_id,
+ text_content=text_content,
+ start_time=start_time,
+ duration=duration,
+ transition_effect=transition_effect,
+ config=config,
+ )
+ created = self._clip_repo.create(clip)
+ logger.info(
+ "创建片段: id=%s plan_id=%s clip_type=%s order=%d",
+ created.id,
+ plan_id,
+ created.clip_type,
+ created.order,
+ )
+ return created
+
+ def update_clip(
+ self,
+ clip_id: str,
+ *,
+ clip_type: Optional[str] = None,
+ order: Optional[int] = None,
+ asset_id: Optional[str] = None,
+ text_content: Optional[str] = None,
+ start_time: Optional[float] = None,
+ duration: Optional[float] = None,
+ transition_effect: Optional[str] = None,
+ config: Optional[dict[str, Any]] = None,
+ ) -> EditPlanClip:
+ """更新片段
+
+ Raises:
+ ValueError: 片段不存在
+ """
+ existing = self.get_clip_or_raise(clip_id)
+
+ updated = EditPlanClip(
+ id=existing.id,
+ plan_id=existing.plan_id,
+ clip_type=clip_type.strip() if clip_type is not None else existing.clip_type,
+ order=order if order is not None else existing.order,
+ template_clip_config_id=existing.template_clip_config_id,
+ asset_id=asset_id.strip() if asset_id is not None else existing.asset_id,
+ text_content=text_content.strip() if text_content is not None else existing.text_content,
+ start_time=start_time if start_time is not None else existing.start_time,
+ duration=duration if duration is not None else existing.duration,
+ transition_effect=transition_effect.strip() if transition_effect is not None else existing.transition_effect,
+ status=existing.status,
+ config=config if config is not None else existing.config,
+ created_at=existing.created_at,
+ updated_at=existing.updated_at,
+ )
+ result = self._clip_repo.update(updated)
+ logger.info("更新片段: id=%s", clip_id)
+ return result
+
+ def assign_asset(self, clip_id: str, asset_id: str) -> EditPlanClip:
+ """为片段分配素材
+
+ Raises:
+ ValueError: 片段不存在或 asset_id 为空
+ """
+ clip = self.get_clip_or_raise(clip_id)
+ clip.assign_asset(asset_id)
+ result = self._clip_repo.update(clip)
+ logger.info("分配素材: clip_id=%s asset_id=%s", clip_id, asset_id)
+ return result
+
+ def delete_clip(self, clip_id: str) -> bool:
+ """删除片段
+
+ Returns:
+ bool: 是否删除成功
+ """
+ deleted = self._clip_repo.delete(clip_id)
+ if deleted:
+ logger.info("删除片段: id=%s", clip_id)
+ return deleted
+
+ def delete_all_clips(self, plan_id: str) -> int:
+ """删除计划下所有片段
+
+ Returns:
+ int: 删除的片段数量
+ """
+ count = self._clip_repo.delete_by_plan(plan_id)
+ logger.info("删除所有片段: plan_id=%s count=%d", plan_id, count)
+ return count
+
+ # ── 渲染生成流程 ────────────────────────────────────────────────────────
+
+ def get_plan_with_clips(self, plan_id: str) -> Dict[str, Any]:
+ """获取计划及其所有片段
+
+ Returns:
+ dict: {"plan": EditPlan, "clips": List[EditPlanClip]}
+ """
+ plan = self.get_plan_or_raise(plan_id)
+ clips = self._clip_repo.list_by_plan(plan_id)
+ return {
+ "plan": plan,
+ "clips": clips,
+ }
+
+ def get_generation_status(self, plan_id: str) -> Dict[str, Any]:
+ """获取渲染进度状态
+
+ Returns:
+ dict: {
+ "plan": EditPlan,
+ "clips": List[EditPlanClip],
+ "generation_task_id": Optional[str],
+ "generation_task_status": Optional[str],
+ }
+
+ Raises:
+ ValueError: 计划不存在
+ """
+ plan = self.get_plan_or_raise(plan_id)
+ clips = self._clip_repo.list_by_plan(plan_id)
+
+ # 从 plan.config 中获取 generation_task_id
+ generation_task_id = plan.config.get("generation_task_id")
+ generation_task_status = None
+
+ if generation_task_id:
+ task = self._generation_task_repo.get(generation_task_id)
+ if task:
+ generation_task_status = task.status.value if hasattr(task.status, "value") else task.status
+
+ return {
+ "plan": plan,
+ "clips": clips,
+ "generation_task_id": generation_task_id,
+ "generation_task_status": generation_task_status,
+ }
+
+ def can_generate(self, plan_id: str) -> tuple[bool, str]:
+ """检查是否可以触发渲染
+
+ Returns:
+ tuple: (can_generate, reason)
+ """
+ plan = self.get_plan_or_raise(plan_id)
+
+ # 检查状态
+ if plan.status != EditPlanStatus.EDITING:
+ return False, f"只有 editing 状态的计划可以触发渲染,当前状态: {plan.status}"
+
+ # 检查是否有片段
+ clips = self._clip_repo.list_by_plan(plan_id)
+ if not clips:
+ return False, "计划下没有片段,无法触发渲染"
+
+ return True, ""
+
+ def mark_clips_ready(self, plan_id: str) -> int:
+ """将所有 pending 状态的片段标记为 ready
+
+ Returns:
+ int: 标记的片段数量
+ """
+ clips = self._clip_repo.list_by_plan(
+ plan_id,
+ status=EditPlanClipStatus.PENDING,
+ )
+ count = 0
+ for clip in clips:
+ clip.mark_ready()
+ self._clip_repo.update(clip)
+ count += 1
+ logger.info("标记片段就绪: plan_id=%s count=%d", plan_id, count)
+ return count
+
+ def update_plan_config(self, plan_id: str, config_updates: Dict[str, Any]) -> EditPlan:
+ """更新计划配置(合并更新)
+
+ Args:
+ plan_id: 计划 ID
+ config_updates: 要合并的配置
+
+ Returns:
+ 更新后的计划
+ """
+ plan = self.get_plan_or_raise(plan_id)
+ new_config = {**plan.config, **config_updates}
+
+ updated = EditPlan(
+ id=plan.id,
+ template_id=plan.template_id,
+ name=plan.name,
+ status=plan.status,
+ total_duration=plan.total_duration,
+ config=new_config,
+ created_at=plan.created_at,
+ updated_at=plan.updated_at,
+ )
+ return self._plan_repo.update(updated)
diff --git a/apps/api/app/services/edit_template_service.py b/apps/api/app/services/edit_template_service.py
new file mode 100644
index 000000000..0ee6ea35a
--- /dev/null
+++ b/apps/api/app/services/edit_template_service.py
@@ -0,0 +1,396 @@
+"""EditTemplateService — 模板管理业务逻辑.
+
+封装 EditTemplate 和 TemplateClipConfig 的 CRUD 操作,
+提供统一的业务接口供 API 路由层调用。
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any, List, Optional
+
+from sqlalchemy.orm import Session
+
+from packages.adapters.sqlalchemy_impl import (
+ SQLAlchemyEditTemplateRepository,
+ SQLAlchemyTemplateClipConfigRepository,
+)
+from packages.domain.edit_template import EditTemplate, EditTemplateStatus
+from packages.domain.template_clip_config import (
+ ClipType,
+ TemplateClipConfig,
+ TransitionEffect,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class EditTemplateService:
+ """模板管理服务
+
+ 职责:
+ - 模板 CRUD(创建、查询、更新、软删除)
+ - 模板片段配置管理(增删改查)
+ - 业务校验(名称去重、状态合法性等)
+ """
+
+ def __init__(self, db: Session) -> None:
+ self._template_repo = SQLAlchemyEditTemplateRepository(db)
+ self._clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
+
+ # ── 模板 CRUD ──────────────────────────────────────────────────────────
+
+ def list_templates(
+ self,
+ *,
+ template_type: Optional[str] = None,
+ status: Optional[EditTemplateStatus] = None,
+ active_only: bool = False,
+ skip: int = 0,
+ limit: int = 50,
+ ) -> List[EditTemplate]:
+ """列出模板
+
+ Args:
+ template_type: 按类型筛选
+ status: 按状态筛选
+ active_only: 仅返回激活模板
+ skip: 分页偏移
+ limit: 每页数量
+ """
+ if active_only:
+ return self._template_repo.list_active(
+ template_type=template_type,
+ skip=skip,
+ limit=limit,
+ )
+ return self._template_repo.list_all(
+ template_type=template_type,
+ status=status,
+ skip=skip,
+ limit=limit,
+ )
+
+ def count_templates(
+ self,
+ *,
+ template_type: Optional[str] = None,
+ status: Optional[EditTemplateStatus] = None,
+ ) -> int:
+ """统计模板数量"""
+ return self._template_repo.count(
+ template_type=template_type,
+ status=status,
+ )
+
+ def get_template(self, template_id: str) -> Optional[EditTemplate]:
+ """获取模板详情"""
+ return self._template_repo.get(template_id)
+
+ def get_template_or_raise(self, template_id: str) -> EditTemplate:
+ """获取模板,不存在则抛出 ValueError"""
+ template = self._template_repo.get(template_id)
+ if template is None:
+ raise ValueError(f"模板不存在: {template_id}")
+ return template
+
+ def create_template(
+ self,
+ name: str,
+ *,
+ description: str = "",
+ template_type: str = "default",
+ config: Optional[dict[str, Any]] = None,
+ preview_url: str = "",
+ sort_weight: int = 0,
+ ) -> EditTemplate:
+ """创建模板
+
+ Raises:
+ ValueError: 名称为空或重复
+ """
+ # 名称校验
+ clean_name = name.strip()
+ if not clean_name:
+ raise ValueError("模板名称不能为空")
+
+ # 名称重复检查
+ existing = self._template_repo.list_all(skip=0, limit=1000)
+ for t in existing:
+ if t.name == clean_name and t.status == EditTemplateStatus.ACTIVE:
+ raise ValueError(f"模板名称已存在: {clean_name}")
+
+ template = EditTemplate.create(
+ name=clean_name,
+ description=description,
+ template_type=template_type,
+ config=config,
+ preview_url=preview_url,
+ sort_weight=sort_weight,
+ )
+ created = self._template_repo.create(template)
+ logger.info("创建模板: id=%s name=%s", created.id, created.name)
+ return created
+
+ def update_template(
+ self,
+ template_id: str,
+ *,
+ name: Optional[str] = None,
+ description: Optional[str] = None,
+ template_type: Optional[str] = None,
+ config: Optional[dict[str, Any]] = None,
+ preview_url: Optional[str] = None,
+ sort_weight: Optional[int] = None,
+ status: Optional[EditTemplateStatus] = None,
+ ) -> EditTemplate:
+ """更新模板
+
+ Raises:
+ ValueError: 模板不存在或名称重复
+ """
+ existing = self.get_template_or_raise(template_id)
+
+ # 名称重复检查(排除自身)
+ new_name = name.strip() if name is not None else existing.name
+ if name is not None and new_name != existing.name:
+ all_templates = self._template_repo.list_all(skip=0, limit=1000)
+ for t in all_templates:
+ if (
+ t.id != template_id
+ and t.name == new_name
+ and t.status == EditTemplateStatus.ACTIVE
+ ):
+ raise ValueError(f"模板名称已存在: {new_name}")
+
+ # 构建更新后的实体
+ updated = EditTemplate(
+ id=existing.id,
+ name=new_name,
+ description=description.strip() if description is not None else existing.description,
+ template_type=template_type.strip() if template_type is not None else existing.template_type,
+ config=config if config is not None else existing.config,
+ preview_url=preview_url.strip() if preview_url is not None else existing.preview_url,
+ sort_weight=sort_weight if sort_weight is not None else existing.sort_weight,
+ status=status if status is not None else existing.status,
+ created_at=existing.created_at,
+ updated_at=existing.updated_at,
+ )
+ result = self._template_repo.update(updated)
+ logger.info("更新模板: id=%s", template_id)
+ return result
+
+ def deactivate_template(self, template_id: str) -> EditTemplate:
+ """软删除模板(设为 inactive)
+
+ Raises:
+ ValueError: 模板不存在
+ """
+ existing = self.get_template_or_raise(template_id)
+ existing.deactivate()
+ result = self._template_repo.update(existing)
+ logger.info("停用模板: id=%s", template_id)
+ return result
+
+ # ── 模板片段配置管理 ────────────────────────────────────────────────────
+
+ def list_clip_configs(
+ self,
+ template_id: str,
+ *,
+ clip_type: Optional[ClipType] = None,
+ skip: int = 0,
+ limit: int = 100,
+ ) -> List[TemplateClipConfig]:
+ """列出模板的片段配置"""
+ # 确保模板存在
+ self.get_template_or_raise(template_id)
+ return self._clip_config_repo.list_by_template(
+ template_id,
+ clip_type=clip_type,
+ skip=skip,
+ limit=limit,
+ )
+
+ def get_clip_config(self, config_id: str) -> Optional[TemplateClipConfig]:
+ """获取片段配置详情"""
+ return self._clip_config_repo.get(config_id)
+
+ def get_clip_config_or_raise(self, config_id: str) -> TemplateClipConfig:
+ """获取片段配置,不存在则抛出 ValueError"""
+ config = self._clip_config_repo.get(config_id)
+ if config is None:
+ raise ValueError(f"片段配置不存在: {config_id}")
+ return config
+
+ def create_clip_config(
+ self,
+ template_id: str,
+ clip_type: ClipType | str,
+ order: int,
+ *,
+ min_duration: float = 0.0,
+ max_duration: float = 0.0,
+ text_template: str = "",
+ material_requirements: Optional[dict[str, Any]] = None,
+ transition_effect: TransitionEffect | str = TransitionEffect.CUT,
+ config: Optional[dict[str, Any]] = None,
+ ) -> TemplateClipConfig:
+ """创建片段配置
+
+ Raises:
+ ValueError: 模板不存在或参数校验失败
+ """
+ # 确保模板存在
+ self.get_template_or_raise(template_id)
+
+ clip_config = TemplateClipConfig.create(
+ template_id=template_id,
+ clip_type=clip_type,
+ order=order,
+ min_duration=min_duration,
+ max_duration=max_duration,
+ text_template=text_template,
+ material_requirements=material_requirements,
+ transition_effect=transition_effect,
+ config=config,
+ )
+ created = self._clip_config_repo.create(clip_config)
+ logger.info(
+ "创建片段配置: id=%s template_id=%s clip_type=%s order=%d",
+ created.id,
+ template_id,
+ created.clip_type,
+ created.order,
+ )
+ return created
+
+ def update_clip_config(
+ self,
+ config_id: str,
+ *,
+ clip_type: Optional[ClipType | str] = None,
+ order: Optional[int] = None,
+ min_duration: Optional[float] = None,
+ max_duration: Optional[float] = None,
+ text_template: Optional[str] = None,
+ material_requirements: Optional[dict[str, Any]] = None,
+ transition_effect: Optional[TransitionEffect | str] = None,
+ config: Optional[dict[str, Any]] = None,
+ ) -> TemplateClipConfig:
+ """更新片段配置
+
+ Raises:
+ ValueError: 配置不存在或参数校验失败
+ """
+ existing = self.get_clip_config_or_raise(config_id)
+
+ # 解析枚举类型
+ new_clip_type = ClipType(clip_type) if clip_type is not None else existing.clip_type
+ new_transition = (
+ TransitionEffect(transition_effect)
+ if transition_effect is not None
+ else existing.transition_effect
+ )
+
+ updated = TemplateClipConfig(
+ id=existing.id,
+ template_id=existing.template_id,
+ clip_type=new_clip_type,
+ order=order if order is not None else existing.order,
+ min_duration=min_duration if min_duration is not None else existing.min_duration,
+ max_duration=max_duration if max_duration is not None else existing.max_duration,
+ text_template=text_template.strip() if text_template is not None else existing.text_template,
+ material_requirements=material_requirements if material_requirements is not None else existing.material_requirements,
+ transition_effect=new_transition,
+ config=config if config is not None else existing.config,
+ created_at=existing.created_at,
+ updated_at=existing.updated_at,
+ )
+ result = self._clip_config_repo.update(updated)
+ logger.info("更新片段配置: id=%s", config_id)
+ return result
+
+ def delete_clip_config(self, config_id: str) -> bool:
+ """删除片段配置
+
+ Returns:
+ bool: 是否删除成功
+ """
+ deleted = self._clip_config_repo.delete(config_id)
+ if deleted:
+ logger.info("删除片段配置: id=%s", config_id)
+ return deleted
+
+ def reorder_clip_configs(
+ self,
+ template_id: str,
+ config_ids: List[str],
+ ) -> List[TemplateClipConfig]:
+ """重新排序片段配置
+
+ Args:
+ template_id: 模板 ID
+ config_ids: 按新顺序排列的配置 ID 列表
+
+ Returns:
+ 更新后的配置列表
+
+ Raises:
+ ValueError: 模板不存在或配置 ID 不匹配
+ """
+ # 确保模板存在
+ self.get_template_or_raise(template_id)
+
+ # 获取当前配置
+ current_configs = self._clip_config_repo.list_by_template(template_id)
+ current_ids = {c.id for c in current_configs}
+
+ # 校验 ID 列表
+ if set(config_ids) != current_ids:
+ raise ValueError("配置 ID 列表与模板下的配置不匹配")
+
+ # 更新 order
+ results = []
+ for new_order, config_id in enumerate(config_ids):
+ config = self._clip_config_repo.get(config_id)
+ if config is None:
+ continue
+ updated = TemplateClipConfig(
+ id=config.id,
+ template_id=config.template_id,
+ clip_type=config.clip_type,
+ order=new_order,
+ min_duration=config.min_duration,
+ max_duration=config.max_duration,
+ text_template=config.text_template,
+ material_requirements=config.material_requirements,
+ transition_effect=config.transition_effect,
+ config=config.config,
+ created_at=config.created_at,
+ updated_at=config.updated_at,
+ )
+ results.append(self._clip_config_repo.update(updated))
+
+ logger.info(
+ "重排序片段配置: template_id=%s count=%d",
+ template_id,
+ len(config_ids),
+ )
+ return results
+
+ def get_template_with_configs(
+ self,
+ template_id: str,
+ ) -> dict:
+ """获取模板及其所有片段配置
+
+ Returns:
+ dict: {"template": EditTemplate, "clip_configs": List[TemplateClipConfig]}
+ """
+ template = self.get_template_or_raise(template_id)
+ clip_configs = self._clip_config_repo.list_by_template(template_id)
+ return {
+ "template": template,
+ "clip_configs": clip_configs,
+ }
diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.tsx b/apps/web/src/pages/editing-planner/EditingPlanner.tsx
index 6577f4c00..4364950e4 100644
--- a/apps/web/src/pages/editing-planner/EditingPlanner.tsx
+++ b/apps/web/src/pages/editing-planner/EditingPlanner.tsx
@@ -30,6 +30,7 @@ import {
import MediaPanel from "./components/MediaPanel";
import TimelinePanel from "./components/TimelinePanel";
import ClipPropertiesPanel from "./components/ClipPropertiesPanel";
+import PreviewPlayer from "./components/PreviewPlayer";
import SaveModal from "./components/SaveModal";
import GenerateModal from "./components/GenerateModal";
@@ -535,18 +536,26 @@ const EditingPlanner: React.FC = () => {
onBatchAddAssets={handleBatchAddAssets}
/>
- {/* 中间:时间线 */}
-
添加片段后预览
+