Files
xiaoxia-saas/apps/api/app/services/edit_template_service.py

814 lines
29 KiB
Python
Executable File
Raw Permalink 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.
"""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 (
SQLAlchemyEditPlanClipRepository,
SQLAlchemyEditPlanRepository,
SQLAlchemyEditTemplateRepository,
SQLAlchemyTemplateClipConfigRepository,
)
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
from packages.domain.template_clip_config import (
ClipType,
TemplateClipConfig,
TransitionEffect,
)
from packages.domain.template_clip_converter import (
clip_configs_to_snapshots,
clips_to_template_clip_configs,
filter_plan_config_to_template,
snapshots_to_template_clip_configs,
validate_template_name,
)
logger = logging.getLogger(__name__)
class EditTemplateService:
"""模板管理服务
职责:
- 模板 CRUD(创建、查询、更新、软删除)
- 模板片段配置管理(增删改查)
- 业务校验(名称去重、状态合法性等)
"""
def __init__(self, db: Session) -> None:
self._template_repo = SQLAlchemyEditTemplateRepository(db)
self._clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
self._plan_repo = SQLAlchemyEditPlanRepository(db)
self._plan_clip_repo = SQLAlchemyEditPlanClipRepository(db)
from packages.adapters.sqlalchemy_impl.template_version_repository import (
SQLAlchemyTemplateVersionRepository,
)
self._version_repo = SQLAlchemyTemplateVersionRepository(db)
self._db = 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",
editing_mode: str = "one_take",
config: Optional[dict[str, Any]] = None,
preview_url: str = "",
sort_weight: int = 0,
) -> EditTemplate:
"""创建模板
Raises:
ValueError: 名称为空或重复
"""
# 名称校验
clean_name = validate_template_name(name)
# 名称重复检查
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,
editing_mode=editing_mode,
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,
editing_mode: 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,
editing_mode=editing_mode.strip() if editing_mode is not None else existing.editing_mode,
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,
}
# ── 从剪辑计划保存为模板 ──────────────────────────────────────────────
def save_plan_as_template(
self,
plan_id: str,
name: str,
*,
description: str = "",
template_type: str = "custom",
preview_url: str = "",
) -> dict[str, Any]:
"""将剪辑计划保存为模板
将指定剪辑计划的配置和片段结构另存为一个新模板,
方便后续基于该模板快速创建新的剪辑计划。
转换规则:
- 计划名称 → 模板名称(调用方传入,支持自定义)
- 计划 config → 模板 config(整体迁移)
- 计划 editing_mode 从 config 中提取,默认 one_take
- 每个片段转换为模板片段配置:
- clip_type 直接映射
- order 保持不变
- duration → min_duration = max_duration = duration(固定时长)
- text_content → text_template
- transition_effect 直接映射
- playback_speed 等播放参数存入 config
- 不保留 asset_id(模板不绑定具体素材)
Args:
plan_id: 源剪辑计划 ID
name: 新模板名称
description: 模板描述
template_type: 模板类型,默认 custom(用户自定义)
preview_url: 预览图 URL
Returns:
dict: {"template": EditTemplate, "clip_configs": List[TemplateClipConfig]}
Raises:
ValueError: 计划不存在或名称为空/重复
"""
# 1. 读取源计划
plan = self._plan_repo.get(plan_id)
if plan is None:
raise ValueError(f"剪辑计划不存在: {plan_id}")
# 2. 读取所有片段(按 order 排序)
clips = self._plan_clip_repo.list_by_plan(plan_id)
clips.sort(key=lambda c: c.order)
# 3. 提取 editing_mode
editing_mode = plan.config.get("editing_mode", "one_take") if plan.config else "one_take"
# 4. 创建模板(复用 create_template 的校验逻辑,但手动构建避免重复查询)
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}")
# 从计划 config 中提取模板级配置,去掉运行时/素材相关字段
template_config = filter_plan_config_to_template(plan.config)
template = EditTemplate.create(
name=clean_name,
description=description,
template_type=template_type,
editing_mode=editing_mode,
config=template_config,
preview_url=preview_url,
)
created_template = self._template_repo.create(template)
logger.info(
"从剪辑计划创建模板: plan_id=%s template_id=%s name=%s clip_count=%d",
plan_id,
created_template.id,
clean_name,
len(clips),
)
# 5. 转换每个片段为模板片段配置
created_configs: List[TemplateClipConfig] = []
for clip_config_obj in clips_to_template_clip_configs(created_template.id, clips):
created = self._clip_config_repo.create(clip_config_obj)
created_configs.append(created)
return {
"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,
*,
change_note: str = "",
published_by: 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: 模板/草稿不存在,或草稿不属于该模板
"""
# 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. 提取模板配置(去掉草稿/运行时字段)
template_config = filter_plan_config_to_template(draft.config)
# 5. 事务更新
try:
# 5.0 先保存旧版快照(发布前的状态),用于回滚
old_version = template.version or 1
old_clip_configs = self._clip_config_repo.list_by_template(template_id)
old_clip_snapshots = clip_configs_to_snapshots(old_clip_configs)
from packages.domain.template_version import EditTemplateVersion
old_snapshot = EditTemplateVersion.create(
template_id=template_id,
version=old_version,
name=template.name,
editing_mode=template.editing_mode,
config=dict(template.config) if template.config else {},
clip_configs=old_clip_snapshots,
change_note=f"v{old_version} 快照(发布前)",
published_by=published_by,
)
self._version_repo.create(old_snapshot)
# 更新模板元信息
template.config = template_config
template.editing_mode = editing_mode
template.bump_version() # 版本号 +1
updated_template = self._template_repo.update(template)
# 批量删除旧的片段配置(外层事务统一提交)
self._clip_config_repo.delete_by_template(template_id, commit=False)
# 创建新的片段配置
created_configs: list[TemplateClipConfig] = []
for config_obj in clips_to_template_clip_configs(template_id, draft_clips):
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
# ── 版本历史与回滚 ────────────────────────────────────────────────────
def list_template_versions(self, template_id: str, limit: int = 50) -> list[Any]:
"""列出模板的发布版本历史(按版本号倒序)"""
self.get_template_or_raise(template_id) # 校验存在性
return self._version_repo.list_by_template(template_id, limit=limit)
def rollback_to_version(self, template_id: str, version: int) -> Any:
"""回滚模板到指定历史版本
流程:
1. 校验目标版本存在
2. 保存当前状态为新版本快照(当前版本号)
3. 用目标版本的快照覆盖模板 config + clip_configs
4. 版本号 +1(回滚本身也是一次发布)
Returns:
EditTemplate: 回滚后的模板
Raises:
ValueError: 模板/版本不存在
"""
template = self.get_template_or_raise(template_id)
# 1. 读取目标版本快照
target_version = self._version_repo.get_by_version(template_id, version)
if target_version is None:
raise ValueError(f"模板 {template_id} 不存在版本 {version}")
current_version = template.version or 1
try:
# 2. 先保存当前状态快照(当前版本号),确保回滚可撤销
old_clip_configs = self._clip_config_repo.list_by_template(template_id)
old_clip_snapshots = clip_configs_to_snapshots(old_clip_configs)
from packages.domain.template_version import EditTemplateVersion
current_snapshot = EditTemplateVersion.create(
template_id=template_id,
version=current_version,
name=template.name,
editing_mode=template.editing_mode,
config=dict(template.config) if template.config else {},
clip_configs=old_clip_snapshots,
change_note=f"v{current_version} 快照(回滚到 v{version} 前)",
published_by="rollback",
)
self._version_repo.create(current_snapshot)
# 3. 覆盖模板配置 + editing_mode + name + preview_url
template.config = dict(target_version.config)
template.editing_mode = target_version.editing_mode
if target_version.name:
template.name = target_version.name
template.bump_version() # 版本号 +1
updated_template = self._template_repo.update(template)
# 4. 先删后插 clip_configs(批量删除避免N+1
from packages.adapters.sqlalchemy_impl.models import (
TemplateClipConfigModel,
)
self._db.query(TemplateClipConfigModel).filter(TemplateClipConfigModel.template_id == template_id).delete(
synchronize_session=False
)
for config_obj in snapshots_to_template_clip_configs(template_id, target_version.clip_configs):
self._clip_config_repo.create(config_obj)
self._db.commit()
logger.info(
"模板回滚成功: template_id=%s from_v=%d to_v=%d new_v=%d",
template_id,
current_version,
version,
updated_template.version,
)
return updated_template
except Exception as exc:
self._db.rollback()
logger.error(
"模板回滚失败: template_id=%s target_version=%d error=%s",
template_id,
version,
exc,
)
raise