814 lines
29 KiB
Python
Executable File
814 lines
29 KiB
Python
Executable File
"""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
|