Files
xiaoxia-saas/apps/api/app/services/edit_template_service.py
T
灵应 b7bb572b46
Deploy / Build Production Runtime Images (push) Has been skipped
Deploy / Deploy Production (push) Has been skipped
Deploy / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 185h28m1s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 185h28m5s
Deploy / Deploy Staging (push) Failing after 185h28m32s
CI/CD Pipeline / Frontend Lint (push) Failing after 185h28m58s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 185h29m4s
feat(phase8): 任务 2.06 — EditTemplateService + EditPlanService 服务层
- 新增 EditTemplateService:模板 CRUD、名称去重、片段配置管理、重排序
- 新增 EditPlanService:计划 CRUD、状态机流转、片段管理、素材分配、生成流程
- 重构 edit_templates 路由:使用 EditTemplateService 封装业务逻辑
- 重构 edit_plans 路由:使用 EditPlanService 封装业务逻辑
- 新增 77 个单元测试(33 EditTemplateService + 44 EditPlanService)
- 使用 stub 仓储注入,不依赖数据库
2026-07-01 19:24:31 +08:00

397 lines
14 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.
"""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,
}