Files
xiaoxia-saas/packages/application/template/use_cases.py
T
xiaoxia 7cffb193eb
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 36s
CI/CD Pipeline / Unit Tests (push) Successful in 1m8s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m19s
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 / Integration Tests (push) Successful in 1m20s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
feat: 模板与剪辑计划后端补齐(复制/筛选/标签/使用统计) (#288)
feat: 模板与剪辑计划后端补齐
2026-07-14 09:51:05 +08:00

334 lines
11 KiB
Python
Executable File

"""Template use cases."""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from typing import List, Optional
from packages.application.template.commands import (
CopyTemplateCommand,
CreateCategoryCommand,
CreateTemplateCommand,
ListTemplatesFilter,
UpdateTemplateCommand,
ValidateTemplateCommand,
)
from packages.domain.editing_mode import EditingMode
from packages.domain.template import Template, TemplateCategory, TemplateSegment
from packages.ports.template_repository import TemplateRepositoryPort
class NotFoundError(Exception):
pass
class ValidationError(Exception):
"""业务规则校验失败."""
pass
VALID_MODES = {m.value for m in EditingMode}
VALID_MATERIAL_TYPES = {"人物", "场景"}
@dataclass
class GenerateWarning:
"""生成时的警告信息."""
code: str # voiceover_duration_mismatch / missing_material_type / ...
message: str
details: dict = field(default_factory=dict)
@dataclass
class ValidateResult:
"""模板校验结果."""
template: Template
warnings: List[GenerateWarning] = field(default_factory=list)
# ── Template CRUD ──
class CreateTemplateUseCase:
def __init__(self, repository: TemplateRepositoryPort) -> None:
self.repository = repository
def execute(self, command: CreateTemplateCommand) -> Template:
if command.mode not in VALID_MODES:
raise ValidationError(f"无效的剪辑模式: {command.mode},可选值: {VALID_MODES}")
template_id = uuid.uuid4().hex
template = Template(
id=template_id,
user_id=command.user_id,
name=command.name,
mode=command.mode,
category=command.category,
tags=command.tags,
title_config=command.title_config,
subtitle_config=command.subtitle_config,
bgm_config=command.bgm_config,
estimated_duration=command.estimated_duration,
)
template = self.repository.create(template)
# 始终调用 create_segments 以确保在同一事务中提交
segments = [
TemplateSegment(
id=uuid.uuid4().hex,
template_id=template.id,
segment_order=seg.segment_order,
duration_min=seg.duration_min,
duration_max=seg.duration_max,
material_type=seg.material_type,
)
for seg in command.segments
]
self.repository.create_segments(segments)
template.segments = segments
return template
class ListTemplatesUseCase:
def __init__(self, repository: TemplateRepositoryPort) -> None:
self.repository = repository
def execute(
self,
user_id: str,
*,
skip: int = 0,
limit: int = 50,
filter: Optional[ListTemplatesFilter] = None,
) -> List[Template]:
if filter is None:
return self.repository.list_by_user(user_id, skip=skip, limit=limit)
return self.repository.list_by_user(
user_id,
skip=skip,
limit=limit,
category=filter.category,
tag=filter.tag,
keyword=filter.keyword,
mode=filter.mode,
)
class CountTemplatesUseCase:
def __init__(self, repository: TemplateRepositoryPort) -> None:
self.repository = repository
def execute(
self,
user_id: str,
*,
filter: Optional[ListTemplatesFilter] = None,
) -> int:
if filter is None:
return self.repository.count_by_user(user_id)
return self.repository.count_by_user(
user_id,
category=filter.category,
tag=filter.tag,
keyword=filter.keyword,
mode=filter.mode,
)
class GetTemplateUseCase:
def __init__(self, repository: TemplateRepositoryPort) -> None:
self.repository = repository
def execute(self, template_id: str, user_id: str) -> Optional[Template]:
return self.repository.get(template_id, user_id)
class UpdateTemplateUseCase:
def __init__(self, repository: TemplateRepositoryPort) -> None:
self.repository = repository
def execute(self, command: UpdateTemplateCommand) -> Template:
existing = self.repository.get(command.template_id, command.user_id)
if existing is None:
raise NotFoundError(f"Template {command.template_id} not found")
if command.mode is not None and command.mode not in VALID_MODES:
raise ValidationError(f"无效的剪辑模式: {command.mode}")
if command.name is not None:
existing.name = command.name
if command.mode is not None:
existing.mode = command.mode
if command.category is not None:
existing.category = command.category
if command.tags is not None:
existing.tags = command.tags
if command.title_config is not None:
existing.title_config = command.title_config
if command.subtitle_config is not None:
existing.subtitle_config = command.subtitle_config
if command.bgm_config is not None:
existing.bgm_config = command.bgm_config
if command.estimated_duration is not None:
existing.estimated_duration = command.estimated_duration
self.repository.update(existing)
# Replace segments if provided
if command.segments is not None:
self.repository.delete_segments_by_template(existing.id)
segments = [
TemplateSegment(
id=uuid.uuid4().hex,
template_id=existing.id,
segment_order=seg.segment_order,
duration_min=seg.duration_min,
duration_max=seg.duration_max,
material_type=seg.material_type,
)
for seg in command.segments
]
self.repository.create_segments(segments)
existing.segments = segments
else:
existing.segments = self.repository.list_segments(existing.id)
return existing
class DeleteTemplateUseCase:
def __init__(self, repository: TemplateRepositoryPort) -> None:
self.repository = repository
def execute(self, template_id: str, user_id: str) -> bool:
return self.repository.delete(template_id, user_id)
class CopyTemplateUseCase:
def __init__(self, repository: TemplateRepositoryPort) -> None:
self.repository = repository
def execute(self, command: CopyTemplateCommand) -> Template:
existing = self.repository.get(command.template_id, command.user_id)
if existing is None:
raise NotFoundError(f"Template {command.template_id} not found")
if not command.new_name or not command.new_name.strip():
raise ValidationError("新模板名称不能为空")
return self.repository.copy_template(
command.template_id,
command.user_id,
command.new_name.strip(),
)
# ── Validate template ──
class ValidateTemplateUseCase:
"""校验模板业务规则."""
def __init__(self, repository: TemplateRepositoryPort) -> None:
self.repository = repository
def execute(self, command: ValidateTemplateCommand) -> ValidateResult:
template = self.repository.get(command.template_id, command.user_id)
if template is None:
raise NotFoundError(f"Template {command.template_id} not found")
warnings: List[GenerateWarning] = []
# 业务规则 1: one_take 必须恰好 1 个片段
if template.mode == EditingMode.ONE_TAKE.value:
if len(template.segments) != 1:
raise ValidationError(f"一镜到底模式必须恰好有 1 个片段,当前有 {len(template.segments)} 个")
# 业务规则 2: voice_over 每个片段必须有 material_type
if template.mode == EditingMode.VOICE_OVER.value:
for seg in template.segments:
if not seg.material_type or seg.material_type not in VALID_MATERIAL_TYPES:
raise ValidationError(
f"口播+B-roll模式下每个片段必须指定 material_type(人物/场景),"
f"片段 {seg.segment_order} 的 material_type 无效: {seg.material_type}"
)
# 业务规则 3: 配音时长偏差 ±30% 警告
if command.voiceover_duration is not None and template.estimated_duration > 0:
ratio = command.voiceover_duration / template.estimated_duration
if ratio < 0.7 or ratio > 1.3:
warnings.append(
GenerateWarning(
code="voiceover_duration_mismatch",
message=(
f"配音时长 ({command.voiceover_duration:.1f}s) "
f"与预估时长 ({template.estimated_duration:.1f}s) "
f"偏差超过 ±30%,可能影响剪辑效果"
),
details={
"voiceover_duration": command.voiceover_duration,
"estimated_duration": template.estimated_duration,
"ratio": round(ratio, 3),
},
)
)
return ValidateResult(template=template, warnings=warnings)
# ── Category CRUD ──
class CreateCategoryUseCase:
def __init__(self, repository: TemplateRepositoryPort) -> None:
self.repository = repository
def execute(self, command: CreateCategoryCommand) -> TemplateCategory:
category = TemplateCategory(
id=uuid.uuid4().hex,
user_id=command.user_id,
name=command.name,
)
return self.repository.create_category(category)
class ListCategoriesUseCase:
def __init__(self, repository: TemplateRepositoryPort) -> None:
self.repository = repository
def execute(self, user_id: str) -> List[TemplateCategory]:
return self.repository.list_categories(user_id)
class DeleteCategoryUseCase:
def __init__(self, repository: TemplateRepositoryPort) -> None:
self.repository = repository
def execute(self, category_id: str, user_id: str) -> bool:
return self.repository.delete_category(category_id, user_id)
# ── Tags ──
class ListTagsUseCase:
def __init__(self, repository: TemplateRepositoryPort) -> None:
self.repository = repository
def execute(self, user_id: str) -> List[str]:
return self.repository.list_tags(user_id)
# ── Usage Stats ──
class GetTemplateUsageUseCase:
def __init__(self, repository: TemplateRepositoryPort) -> None:
self.repository = repository
def execute(self, template_id: str) -> int:
return self.repository.get_usage_count(template_id)