Files
xiaoxia-saas/apps/api/app/services/edit_plan_service.py
T
xiaoxia 5a8158f181
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Failing after 23h40m36s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 23h40m37s
feat(P0-1,P0-2): generate接口自动兜底 + 错误提示改人话
P0-1: generate_plan 入口自动兜底
- draft 状态自动转 editing
- 0片段+有template_id时自动从模板复制片段配置

P0-2: 错误提示改人话
- '计划下没有片段,无法触发渲染' → '请先添加片段后再生成视频'
- 状态错误提示改为 '请先编辑并保存模板后再生成视频'
2026-07-08 13:13:51 +08:00

504 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
project_id: Optional[str] = None,
status: Optional[EditPlanStatus] = None,
skip: int = 0,
limit: int = 50,
) -> List[EditPlan]:
"""列出剪辑计划
Args:
template_id: 按模板 ID 筛选
project_id: 按项目 ID 筛选
status: 按状态筛选
skip: 分页偏移
limit: 每页数量
"""
if project_id:
return self._plan_repo.list_by_project(
project_id,
status=status,
skip=skip,
limit=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,
project_id: Optional[str] = None,
status: Optional[EditPlanStatus] = None,
) -> int:
"""统计计划数量
Note:
当指定 template_id/project_id 时,通过全量查询计算 total(repo 限制)。
"""
if project_id:
all_matching = self._plan_repo.list_by_project(
project_id,
status=status,
skip=0,
limit=10000,
)
return len(all_matching)
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,
project_id: str = "",
created_by_user_id: str = "",
) -> EditPlan:
"""创建剪辑计划
Raises:
ValueError: 参数校验失败
"""
plan = EditPlan.create(
template_id=template_id,
name=name,
config=config,
total_duration=total_duration,
project_id=project_id,
created_by_user_id=created_by_user_id,
)
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,
source_edit_plan_id=existing.source_edit_plan_id,
project_id=existing.project_id,
created_by_user_id=existing.created_by_user_id,
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, "请先编辑并保存模板后再生成视频"
# 检查是否有片段
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,
source_edit_plan_id=plan.source_edit_plan_id,
project_id=plan.project_id,
created_by_user_id=plan.created_by_user_id,
config=new_config,
created_at=plan.created_at,
updated_at=plan.updated_at,
)
return self._plan_repo.update(updated)