feat: 剪辑计划编辑器后端 — 模板 CRUD + 分类 + 生成校验 #105

Merged
xiaoxia merged 3 commits from feat/editing-planner-backend into develop 2026-06-29 15:05:51 +08:00
12 changed files with 1600 additions and 0 deletions
@@ -0,0 +1,87 @@
"""Phase 3 - 剪辑计划模板:templates + template_segments + template_categories
Revision ID: 014
Revises: 013
Create Date: 2026-06-29
This migration creates three new tables:
1. templates — 剪辑计划模板主表
2. template_segments — 模板片段表
3. template_categories — 模板分类表
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers
revision = "014"
down_revision = "013"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
# ── 1. Create templates table ──
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS templates (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
name VARCHAR(200) NOT NULL,
mode VARCHAR(30) NOT NULL,
category VARCHAR(100) NOT NULL DEFAULT '',
tags JSONB NOT NULL DEFAULT '[]',
title_config JSONB NOT NULL DEFAULT '{}',
subtitle_config JSONB NOT NULL DEFAULT '{}',
bgm_config JSONB NOT NULL DEFAULT '{}',
estimated_duration FLOAT NOT NULL DEFAULT 0.0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
)
"""))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_templates_user_id ON templates(user_id)"
))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_templates_mode ON templates(mode)"
))
# ── 2. Create template_segments table ──
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS template_segments (
id VARCHAR(36) PRIMARY KEY,
template_id VARCHAR(36) NOT NULL,
segment_order INTEGER NOT NULL,
duration_min FLOAT NOT NULL,
duration_max FLOAT NOT NULL,
material_type VARCHAR(20),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
)
"""))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_template_segments_template_id "
"ON template_segments(template_id)"
))
# ── 3. Create template_categories table ──
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS template_categories (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
name VARCHAR(100) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
)
"""))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_template_categories_user_id "
"ON template_categories(user_id)"
))
def downgrade() -> None:
conn = op.get_bind()
conn.execute(sa.text("DROP TABLE IF EXISTS template_categories"))
conn.execute(sa.text("DROP TABLE IF EXISTS template_segments"))
conn.execute(sa.text("DROP TABLE IF EXISTS templates"))
+6
View File
@@ -8,6 +8,7 @@ from app.api.routes.duplication import router as duplication_router
from app.api.routes.generated_videos import router as generated_videos_router
from app.api.routes.recipes import router as recipes_router
from app.api.routes.subscription import router as subscription_router
from app.api.routes.templates import router as templates_router
from app.api.routes.titles import router as titles_router
from app.api.routes.voices import router as voices_router
from app.api.routes.generation_tasks import router as generation_tasks_router
@@ -104,3 +105,8 @@ api_router.include_router(
prefix="/recipes",
tags=["Recipe"],
)
api_router.include_router(
templates_router,
prefix="/templates",
tags=["Template"],
)
+289
View File
@@ -0,0 +1,289 @@
"""Template CRUD + generate + category routes."""
from __future__ import annotations
from typing import List
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session
from app.schemas.template import (
CategoryResponse,
CreateCategoryRequest,
CreateTemplateRequest,
ListCategoriesResponse,
ListTemplatesResponse,
SegmentResponse,
TemplateResponse,
UpdateTemplateRequest,
ValidateTemplateRequest,
ValidateTemplateResponse,
GenerateWarningResponse,
)
from packages.adapters.sqlalchemy_impl.template_repository import SQLAlchemyTemplateRepository
from packages.application.template.commands import (
CreateCategoryCommand,
CreateTemplateCommand,
SegmentCommand,
UpdateTemplateCommand,
ValidateTemplateCommand,
)
from packages.application.template.use_cases import (
CreateCategoryUseCase,
CreateTemplateUseCase,
DeleteCategoryUseCase,
DeleteTemplateUseCase,
GetTemplateUseCase,
ListCategoriesUseCase,
ListTemplatesUseCase,
NotFoundError,
UpdateTemplateUseCase,
ValidateTemplateUseCase,
ValidationError,
)
router = APIRouter()
def _get_template_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyTemplateRepository:
return SQLAlchemyTemplateRepository(session)
def _segment_to_response(seg) -> SegmentResponse:
return SegmentResponse(
id=seg.id,
template_id=seg.template_id,
segment_order=seg.segment_order,
duration_min=seg.duration_min,
duration_max=seg.duration_max,
material_type=seg.material_type,
created_at=seg.created_at,
updated_at=seg.updated_at,
)
def _to_response(template) -> TemplateResponse:
return TemplateResponse(
id=template.id,
user_id=template.user_id,
name=template.name,
mode=template.mode,
category=template.category,
tags=template.tags,
title_config=template.title_config,
subtitle_config=template.subtitle_config,
bgm_config=template.bgm_config,
estimated_duration=template.estimated_duration,
segments=[_segment_to_response(s) for s in getattr(template, "segments", [])],
is_active=template.is_active,
created_at=template.created_at,
updated_at=template.updated_at,
)
# ── Template CRUD ──
@router.get("", response_model=ListTemplatesResponse)
def list_templates(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> ListTemplatesResponse:
user_id = authenticated_user.user.id
use_case = ListTemplatesUseCase(template_repository)
templates = use_case.execute(user_id, skip=skip, limit=limit)
total = template_repository.count_by_user(user_id)
return ListTemplatesResponse(
items=[_to_response(t) for t in templates],
total=total,
)
@router.get("/{template_id}", response_model=TemplateResponse)
def get_template(
template_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> TemplateResponse:
user_id = authenticated_user.user.id
use_case = GetTemplateUseCase(template_repository)
template = use_case.execute(template_id, user_id)
if template is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
return _to_response(template)
@router.post("", response_model=TemplateResponse, status_code=status.HTTP_201_CREATED)
def create_template(
request: CreateTemplateRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> TemplateResponse:
user_id = authenticated_user.user.id
command = CreateTemplateCommand(
user_id=user_id,
name=request.name,
mode=request.mode,
category=request.category,
tags=request.tags,
title_config=request.title_config,
subtitle_config=request.subtitle_config,
bgm_config=request.bgm_config,
estimated_duration=request.estimated_duration,
segments=[
SegmentCommand(
segment_order=s.segment_order,
duration_min=s.duration_min,
duration_max=s.duration_max,
material_type=s.material_type,
)
for s in request.segments
],
)
use_case = CreateTemplateUseCase(template_repository)
try:
template = use_case.execute(command)
except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
return _to_response(template)
@router.patch("/{template_id}", response_model=TemplateResponse)
def update_template(
template_id: str,
request: UpdateTemplateRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> TemplateResponse:
user_id = authenticated_user.user.id
command = UpdateTemplateCommand(
template_id=template_id,
user_id=user_id,
name=request.name,
mode=request.mode,
category=request.category,
tags=request.tags,
title_config=request.title_config,
subtitle_config=request.subtitle_config,
bgm_config=request.bgm_config,
estimated_duration=request.estimated_duration,
segments=(
[
SegmentCommand(
segment_order=s.segment_order,
duration_min=s.duration_min,
duration_max=s.duration_max,
material_type=s.material_type,
)
for s in request.segments
]
if request.segments is not None
else None
),
)
use_case = UpdateTemplateUseCase(template_repository)
try:
template = use_case.execute(command)
except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
return _to_response(template)
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
def delete_template(
template_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> Response:
user_id = authenticated_user.user.id
use_case = DeleteTemplateUseCase(template_repository)
deleted = use_case.execute(template_id, user_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
return Response(status_code=204)
# ── Validate template ──
@router.post("/{template_id}/validate", response_model=ValidateTemplateResponse)
def validate_template(
template_id: str,
request: ValidateTemplateRequest = ValidateTemplateRequest(),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> ValidateTemplateResponse:
user_id = authenticated_user.user.id
command = ValidateTemplateCommand(
template_id=template_id,
user_id=user_id,
voiceover_duration=request.voiceover_duration,
)
use_case = ValidateTemplateUseCase(template_repository)
try:
result = use_case.execute(command)
except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
return ValidateTemplateResponse(
template=_to_response(result.template),
warnings=[
GenerateWarningResponse(code=w.code, message=w.message, details=w.details)
for w in result.warnings
],
)
# ── Category CRUD ──
@router.get("/categories/list", response_model=ListCategoriesResponse)
def list_categories(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> ListCategoriesResponse:
user_id = authenticated_user.user.id
use_case = ListCategoriesUseCase(template_repository)
categories = use_case.execute(user_id)
return ListCategoriesResponse(
items=[
CategoryResponse(id=c.id, user_id=c.user_id, name=c.name, created_at=c.created_at)
for c in categories
],
)
@router.post("/categories", response_model=CategoryResponse, status_code=status.HTTP_201_CREATED)
def create_category(
request: CreateCategoryRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> CategoryResponse:
user_id = authenticated_user.user.id
command = CreateCategoryCommand(user_id=user_id, name=request.name)
use_case = CreateCategoryUseCase(template_repository)
category = use_case.execute(command)
return CategoryResponse(
id=category.id, user_id=category.user_id, name=category.name, created_at=category.created_at,
)
@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
def delete_category(
category_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
) -> Response:
user_id = authenticated_user.user.id
use_case = DeleteCategoryUseCase(template_repository)
deleted = use_case.execute(category_id, user_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found")
return Response(status_code=204)
+111
View File
@@ -0,0 +1,111 @@
"""Template API schemas."""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
# ── Segment ──
class SegmentResponse(BaseModel):
id: str
template_id: str
segment_order: int
duration_min: float
duration_max: float
material_type: Optional[str] = None
created_at: datetime
updated_at: datetime
class SegmentRequest(BaseModel):
segment_order: int
duration_min: float
duration_max: float
material_type: Optional[str] = None
# ── Template Response ──
class TemplateResponse(BaseModel):
id: str
user_id: str
name: str
mode: str
category: str = ""
tags: List[str] = Field(default_factory=list)
title_config: Dict[str, Any] = Field(default_factory=dict)
subtitle_config: Dict[str, Any] = Field(default_factory=dict)
bgm_config: Dict[str, Any] = Field(default_factory=dict)
estimated_duration: float = 0.0
segments: List[SegmentResponse] = Field(default_factory=list)
is_active: bool = True
created_at: datetime
updated_at: datetime
class ListTemplatesResponse(BaseModel):
items: List[TemplateResponse]
total: int = 0
# ── Template Request ──
class CreateTemplateRequest(BaseModel):
name: str
mode: str
category: str = ""
tags: List[str] = Field(default_factory=list)
title_config: Dict[str, Any] = Field(default_factory=dict)
subtitle_config: Dict[str, Any] = Field(default_factory=dict)
bgm_config: Dict[str, Any] = Field(default_factory=dict)
estimated_duration: float = 0.0
segments: List[SegmentRequest] = Field(default_factory=list)
class UpdateTemplateRequest(BaseModel):
name: Optional[str] = None
mode: Optional[str] = None
category: Optional[str] = None
tags: Optional[List[str]] = None
title_config: Optional[Dict[str, Any]] = None
subtitle_config: Optional[Dict[str, Any]] = None
bgm_config: Optional[Dict[str, Any]] = None
estimated_duration: Optional[float] = None
segments: Optional[List[SegmentRequest]] = None
# ── Validate ──
class ValidateTemplateRequest(BaseModel):
voiceover_duration: Optional[float] = None # 配音实际时长(秒)
class GenerateWarningResponse(BaseModel):
code: str
message: str
details: Dict[str, Any] = Field(default_factory=dict)
class ValidateTemplateResponse(BaseModel):
template: TemplateResponse
warnings: List[GenerateWarningResponse] = Field(default_factory=list)
# ── Category ──
class CategoryResponse(BaseModel):
id: str
user_id: str
name: str
created_at: datetime
class CreateCategoryRequest(BaseModel):
name: str
class ListCategoriesResponse(BaseModel):
items: List[CategoryResponse]
@@ -280,3 +280,43 @@ class RecipeItemModel(Base):
position = Column(Integer, nullable=False, default=0)
extra_meta = Column('metadata', JSON, nullable=False, default=dict)
class TemplateModel(Base):
__tablename__ = "templates"
id = Column(String(36), primary_key=True)
user_id = Column(String(36), nullable=False, index=True)
name = Column(String(200), nullable=False)
mode = Column(String(30), nullable=False, index=True) # EditingMode 枚举值: pip / voice_pip / one_take / voice_over
category = Column(String(100), nullable=False, default="")
tags = Column(JSON, nullable=False, default=list)
title_config = Column(JSON, nullable=False, default=dict)
subtitle_config = Column(JSON, nullable=False, default=dict)
bgm_config = Column(JSON, nullable=False, default=dict)
estimated_duration = Column(Float, nullable=False, default=0.0)
is_active = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
class TemplateSegmentModel(Base):
__tablename__ = "template_segments"
id = Column(String(36), primary_key=True)
template_id = Column(String(36), nullable=False, index=True)
segment_order = Column(Integer, nullable=False)
duration_min = Column(Float, nullable=False)
duration_max = Column(Float, nullable=False)
material_type = Column(String(20), nullable=True) # 仅 voice_over 模式: 人物/场景
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
class TemplateCategoryModel(Base):
__tablename__ = "template_categories"
id = Column(String(36), primary_key=True)
user_id = Column(String(36), nullable=False, index=True)
name = Column(String(100), nullable=False)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
@@ -0,0 +1,278 @@
"""SQLAlchemy implementation of TemplateRepository."""
from __future__ import annotations
from typing import List, Optional
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import (
TemplateCategoryModel,
TemplateModel,
TemplateSegmentModel,
)
from packages.domain.template import Template, TemplateCategory, TemplateSegment
class SQLAlchemyTemplateRepository:
"""SQLAlchemy 剪辑计划模板仓储."""
def __init__(self, session: Session) -> None:
self.session = session
# ── Template CRUD ──
def list_by_user(
self,
user_id: str,
*,
skip: int = 0,
limit: int = 50,
) -> List[Template]:
models = (
self.session.query(TemplateModel)
.filter(
TemplateModel.user_id == user_id,
TemplateModel.is_active == True,
)
.order_by(TemplateModel.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
templates = [self._model_to_entity(m) for m in models]
# 批量加载所有 segments,避免 N+1 查询
if templates:
template_ids = [t.id for t in templates]
seg_models = (
self.session.query(TemplateSegmentModel)
.filter(TemplateSegmentModel.template_id.in_(template_ids))
.order_by(TemplateSegmentModel.segment_order)
.all()
)
# 按 template_id 分组
seg_map: dict[str, list] = {}
for sm in seg_models:
seg_map.setdefault(sm.template_id, []).append(
self._segment_model_to_entity(sm),
)
for t in templates:
t.segments = seg_map.get(t.id, [])
return templates
def get(self, template_id: str, user_id: str) -> Optional[Template]:
model = (
self.session.query(TemplateModel)
.filter(
TemplateModel.id == template_id,
TemplateModel.user_id == user_id,
)
.first()
)
if model is None:
return None
template = self._model_to_entity(model)
template.segments = self.list_segments(template.id)
return template
def create(self, template: Template) -> Template:
model = TemplateModel(
id=template.id,
user_id=template.user_id,
name=template.name,
mode=template.mode,
category=template.category,
tags=template.tags,
title_config=template.title_config,
subtitle_config=template.subtitle_config,
bgm_config=template.bgm_config,
estimated_duration=template.estimated_duration,
is_active=template.is_active,
)
self.session.add(model)
# flush 而非 commit,让 create + create_segments 在同一事务中提交
self.session.flush()
self.session.refresh(model)
result = self._model_to_entity(model)
result.segments = template.segments
return result
def update(self, template: Template) -> Template:
model = (
self.session.query(TemplateModel)
.filter(
TemplateModel.id == template.id,
TemplateModel.user_id == template.user_id,
)
.first()
)
if model is None:
raise ValueError(f"Template {template.id} not found")
model.name = template.name
model.mode = template.mode
model.category = template.category
model.tags = template.tags
model.title_config = template.title_config
model.subtitle_config = template.subtitle_config
model.bgm_config = template.bgm_config
model.estimated_duration = template.estimated_duration
model.is_active = template.is_active
self.session.commit()
self.session.refresh(model)
result = self._model_to_entity(model)
result.segments = template.segments
return result
def delete(self, template_id: str, user_id: str) -> bool:
model = (
self.session.query(TemplateModel)
.filter(
TemplateModel.id == template_id,
TemplateModel.user_id == user_id,
)
.first()
)
if model is None:
return False
model.is_active = False
# 级联清理关联的 segments,避免孤儿数据
self.session.query(TemplateSegmentModel).filter(
TemplateSegmentModel.template_id == template_id,
).delete(synchronize_session=False)
self.session.commit()
return True
def count_by_user(self, user_id: str) -> int:
return (
self.session.query(TemplateModel)
.filter(
TemplateModel.user_id == user_id,
TemplateModel.is_active == True,
)
.count()
)
# ── Segments ──
def list_segments(self, template_id: str) -> List[TemplateSegment]:
models = (
self.session.query(TemplateSegmentModel)
.filter(TemplateSegmentModel.template_id == template_id)
.order_by(TemplateSegmentModel.segment_order)
.all()
)
return [self._segment_model_to_entity(m) for m in models]
def create_segments(self, segments: List[TemplateSegment]) -> List[TemplateSegment]:
for seg in segments:
model = TemplateSegmentModel(
id=seg.id,
template_id=seg.template_id,
segment_order=seg.segment_order,
duration_min=seg.duration_min,
duration_max=seg.duration_max,
material_type=seg.material_type,
)
self.session.add(model)
self.session.commit()
return segments
def delete_segments_by_template(self, template_id: str) -> int:
count = (
self.session.query(TemplateSegmentModel)
.filter(TemplateSegmentModel.template_id == template_id)
.delete()
)
self.session.commit()
return count
# ── Categories ──
def list_categories(self, user_id: str) -> List[TemplateCategory]:
models = (
self.session.query(TemplateCategoryModel)
.filter(TemplateCategoryModel.user_id == user_id)
.order_by(TemplateCategoryModel.created_at)
.all()
)
return [self._category_model_to_entity(m) for m in models]
def create_category(self, category: TemplateCategory) -> TemplateCategory:
model = TemplateCategoryModel(
id=category.id,
user_id=category.user_id,
name=category.name,
)
self.session.add(model)
self.session.commit()
self.session.refresh(model)
return self._category_model_to_entity(model)
def get_category(self, category_id: str, user_id: str) -> Optional[TemplateCategory]:
model = (
self.session.query(TemplateCategoryModel)
.filter(
TemplateCategoryModel.id == category_id,
TemplateCategoryModel.user_id == user_id,
)
.first()
)
if model is None:
return None
return self._category_model_to_entity(model)
def delete_category(self, category_id: str, user_id: str) -> bool:
model = (
self.session.query(TemplateCategoryModel)
.filter(
TemplateCategoryModel.id == category_id,
TemplateCategoryModel.user_id == user_id,
)
.first()
)
if model is None:
return False
self.session.delete(model)
self.session.commit()
return True
# ── Mapping helpers ──
@staticmethod
def _model_to_entity(model: TemplateModel) -> Template:
return Template(
id=model.id,
user_id=model.user_id,
name=model.name,
mode=model.mode,
category=model.category or "",
tags=model.tags or [],
title_config=model.title_config or {},
subtitle_config=model.subtitle_config or {},
bgm_config=model.bgm_config or {},
estimated_duration=model.estimated_duration or 0.0,
is_active=model.is_active,
created_at=model.created_at,
updated_at=model.updated_at,
)
@staticmethod
def _segment_model_to_entity(model: TemplateSegmentModel) -> TemplateSegment:
return TemplateSegment(
id=model.id,
template_id=model.template_id,
segment_order=model.segment_order,
duration_min=model.duration_min,
duration_max=model.duration_max,
material_type=model.material_type,
created_at=model.created_at,
updated_at=model.updated_at,
)
@staticmethod
def _category_model_to_entity(model: TemplateCategoryModel) -> TemplateCategory:
return TemplateCategory(
id=model.id,
user_id=model.user_id,
name=model.name,
created_at=model.created_at,
)
+55
View File
@@ -0,0 +1,55 @@
"""Template commands."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import List, Optional
@dataclass
class SegmentCommand:
segment_order: int
duration_min: float
duration_max: float
material_type: Optional[str] = None
@dataclass
class CreateTemplateCommand:
user_id: str
name: str
mode: str
category: str = ""
tags: List[str] = field(default_factory=list)
title_config: dict = field(default_factory=dict)
subtitle_config: dict = field(default_factory=dict)
bgm_config: dict = field(default_factory=dict)
estimated_duration: float = 0.0
segments: List[SegmentCommand] = field(default_factory=list)
@dataclass
class UpdateTemplateCommand:
template_id: str
user_id: str
name: Optional[str] = None
mode: Optional[str] = None
category: Optional[str] = None
tags: Optional[List[str]] = None
title_config: Optional[dict] = None
subtitle_config: Optional[dict] = None
bgm_config: Optional[dict] = None
estimated_duration: Optional[float] = None
segments: Optional[List[SegmentCommand]] = None
@dataclass
class CreateCategoryCommand:
user_id: str
name: str
@dataclass
class ValidateTemplateCommand:
template_id: str
user_id: str
voiceover_duration: Optional[float] = None # 配音实际时长(用于偏差校验)
+256
View File
@@ -0,0 +1,256 @@
"""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 (
CreateCategoryCommand,
CreateTemplateCommand,
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,
) -> List[Template]:
return self.repository.list_by_user(user_id, skip=skip, limit=limit)
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)
# ── 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)
+47
View File
@@ -0,0 +1,47 @@
"""Template domain entities — 剪辑计划模板."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import List, Optional
@dataclass
class TemplateSegment:
"""模板中的单个片段."""
id: str
template_id: str
segment_order: int
duration_min: float
duration_max: float
material_type: Optional[str] = None # 仅 voice_over_mix: 人物/场景; 其他模式 null
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@dataclass
class Template:
"""剪辑计划模板."""
id: str
user_id: str
name: str
mode: str # EditingMode 枚举值: pip / voice_pip / one_take / voice_over
category: str = ""
tags: List[str] = field(default_factory=list)
title_config: dict = field(default_factory=dict)
subtitle_config: dict = field(default_factory=dict)
bgm_config: dict = field(default_factory=dict)
estimated_duration: float = 0.0
segments: List[TemplateSegment] = field(default_factory=list)
is_active: bool = True
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@dataclass
class TemplateCategory:
"""模板分类."""
id: str
user_id: str
name: str
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+22
View File
@@ -0,0 +1,22 @@
"""Template repository port (Protocol)."""
from __future__ import annotations
from typing import List, Optional, Protocol
from packages.domain.template import Template, TemplateCategory, TemplateSegment
class TemplateRepositoryPort(Protocol):
def list_by_user(self, user_id: str, *, skip: int = 0, limit: int = 50) -> List[Template]: ...
def get(self, template_id: str, user_id: str) -> Optional[Template]: ...
def create(self, template: Template) -> Template: ...
def update(self, template: Template) -> Template: ...
def delete(self, template_id: str, user_id: str) -> bool: ...
def count_by_user(self, user_id: str) -> int: ...
def list_segments(self, template_id: str) -> List[TemplateSegment]: ...
def create_segments(self, segments: List[TemplateSegment]) -> List[TemplateSegment]: ...
def delete_segments_by_template(self, template_id: str) -> int: ...
def list_categories(self, user_id: str) -> List[TemplateCategory]: ...
def create_category(self, category: TemplateCategory) -> TemplateCategory: ...
def get_category(self, category_id: str, user_id: str) -> Optional[TemplateCategory]: ...
def delete_category(self, category_id: str, user_id: str) -> bool: ...
+409
View File
@@ -0,0 +1,409 @@
"""
Template Use Cases 单元测试 — 剪辑计划模板 CRUD + 业务规则校验
"""
from unittest.mock import MagicMock, Mock
import pytest
from packages.application.template.commands import (
CreateCategoryCommand,
CreateTemplateCommand,
SegmentCommand,
UpdateTemplateCommand,
ValidateTemplateCommand,
)
from packages.application.template.use_cases import (
CreateCategoryUseCase,
CreateTemplateUseCase,
DeleteTemplateUseCase,
GetTemplateUseCase,
ListCategoriesUseCase,
ListTemplatesUseCase,
NotFoundError,
UpdateTemplateUseCase,
ValidateTemplateUseCase,
ValidationError,
)
from packages.domain.template import Template, TemplateCategory, TemplateSegment
def _make_repo():
"""创建一个 mock repository."""
repo = Mock()
repo.list_by_user = Mock(return_value=[])
repo.get = Mock(return_value=None)
repo.create = Mock()
repo.update = Mock()
repo.delete = Mock(return_value=False)
repo.count_by_user = Mock(return_value=0)
repo.list_segments = Mock(return_value=[])
repo.create_segments = Mock()
repo.delete_segments_by_template = Mock(return_value=0)
repo.list_categories = Mock(return_value=[])
repo.create_category = Mock()
repo.get_category = Mock(return_value=None)
repo.delete_category = Mock(return_value=False)
return repo
def _make_template(**kwargs) -> Template:
defaults = dict(
id="tmpl-001",
user_id="user-001",
name="测试模板",
mode="pip",
category="default",
tags=["test"],
title_config={"ai_auto_select": True},
subtitle_config={"enabled": True},
bgm_config={"enabled": False},
estimated_duration=60.0,
segments=[],
)
defaults.update(kwargs)
return Template(**defaults)
# ── CreateTemplateUseCase ──
class TestCreateTemplateUseCase:
@pytest.fixture
def repo(self):
return _make_repo()
@pytest.fixture
def use_case(self, repo):
return CreateTemplateUseCase(repo)
def test_create_basic_template(self, use_case, repo):
"""创建基础模板(无片段)."""
repo.create.side_effect = lambda t: t # 返回传入的 template
command = CreateTemplateCommand(
user_id="user-001",
name="画中画模板",
mode="pip",
category="vlog",
tags=["vlog", "pip"],
estimated_duration=90.0,
)
result = use_case.execute(command)
assert result.name == "画中画模板"
assert result.mode == "pip"
assert result.user_id == "user-001"
repo.create.assert_called_once()
def test_create_with_segments(self, use_case, repo):
"""创建模板并附带片段."""
repo.create.side_effect = lambda t: t
repo.create_segments.side_effect = lambda segs: segs
command = CreateTemplateCommand(
user_id="user-001",
name="口播混剪模板",
mode="voice_over",
segments=[
SegmentCommand(segment_order=1, duration_min=5, duration_max=15, material_type="人物"),
SegmentCommand(segment_order=2, duration_min=10, duration_max=30, material_type="场景"),
],
)
result = use_case.execute(command)
assert len(result.segments) == 2
assert result.segments[0].material_type == "人物"
repo.create_segments.assert_called_once()
def test_create_invalid_mode_raises(self, use_case):
"""无效剪辑模式应抛出 ValidationError."""
command = CreateTemplateCommand(
user_id="user-001",
name="无效模板",
mode="invalid_mode",
)
with pytest.raises(ValidationError, match="无效的剪辑模式"):
use_case.execute(command)
# ── UpdateTemplateUseCase ──
class TestUpdateTemplateUseCase:
@pytest.fixture
def repo(self):
return _make_repo()
@pytest.fixture
def use_case(self, repo):
return UpdateTemplateUseCase(repo)
def test_update_name(self, use_case, repo):
"""更新模板名称."""
existing = _make_template()
repo.get.return_value = existing
repo.update.side_effect = lambda t: t
command = UpdateTemplateCommand(
template_id="tmpl-001",
user_id="user-001",
name="新名称",
)
result = use_case.execute(command)
assert result.name == "新名称"
repo.update.assert_called_once()
def test_update_not_found_raises(self, use_case, repo):
"""模板不存在时抛出 NotFoundError."""
repo.get.return_value = None
command = UpdateTemplateCommand(
template_id="nonexistent",
user_id="user-001",
name="新名称",
)
with pytest.raises(NotFoundError):
use_case.execute(command)
def test_update_invalid_mode_raises(self, use_case, repo):
"""更新为无效模式时抛出 ValidationError."""
existing = _make_template()
repo.get.return_value = existing
command = UpdateTemplateCommand(
template_id="tmpl-001",
user_id="user-001",
mode="bad_mode",
)
with pytest.raises(ValidationError, match="无效的剪辑模式"):
use_case.execute(command)
def test_replace_segments(self, use_case, repo):
"""替换片段列表."""
existing = _make_template()
repo.get.return_value = existing
repo.update.side_effect = lambda t: t
repo.create_segments.side_effect = lambda segs: segs
command = UpdateTemplateCommand(
template_id="tmpl-001",
user_id="user-001",
segments=[
SegmentCommand(segment_order=1, duration_min=5, duration_max=20, material_type=None),
],
)
result = use_case.execute(command)
repo.delete_segments_by_template.assert_called_once_with("tmpl-001")
repo.create_segments.assert_called_once()
assert len(result.segments) == 1
# ── ValidateTemplateUseCase — 业务规则校验 ──
class TestValidateTemplateUseCase:
@pytest.fixture
def repo(self):
return _make_repo()
@pytest.fixture
def use_case(self, repo):
return ValidateTemplateUseCase(repo)
def test_one_take_with_one_segment_ok(self, use_case, repo):
"""一镜到底 + 恰好 1 个片段 → 通过."""
seg = TemplateSegment(
id="seg-001", template_id="tmpl-001", segment_order=1,
duration_min=0, duration_max=60,
)
template = _make_template(mode="one_take", segments=[seg])
repo.get.return_value = template
command = ValidateTemplateCommand(
template_id="tmpl-001", user_id="user-001",
)
result = use_case.execute(command)
assert result.template.mode == "one_take"
assert result.warnings == []
def test_one_take_with_two_segments_raises(self, use_case, repo):
"""一镜到底 + 2 个片段 → ValidationError."""
segs = [
TemplateSegment(id=f"seg-{i}", template_id="tmpl-001", segment_order=i,
duration_min=0, duration_max=30)
for i in (1, 2)
]
template = _make_template(mode="one_take", segments=segs)
repo.get.return_value = template
command = ValidateTemplateCommand(
template_id="tmpl-001", user_id="user-001",
)
with pytest.raises(ValidationError, match="一镜到底模式必须恰好有 1 个片段"):
use_case.execute(command)
def test_voice_over_all_segments_have_material_type_ok(self, use_case, repo):
"""口播+B-roll + 所有片段都有 material_type → 通过."""
segs = [
TemplateSegment(id="seg-1", template_id="tmpl-001", segment_order=1,
duration_min=5, duration_max=15, material_type="人物"),
TemplateSegment(id="seg-2", template_id="tmpl-001", segment_order=2,
duration_min=10, duration_max=30, material_type="场景"),
]
template = _make_template(mode="voice_over", segments=segs)
repo.get.return_value = template
command = ValidateTemplateCommand(
template_id="tmpl-001", user_id="user-001",
)
result = use_case.execute(command)
assert result.warnings == []
def test_voice_over_missing_material_type_raises(self, use_case, repo):
"""口播+B-roll + 某片段缺少 material_type → ValidationError."""
segs = [
TemplateSegment(id="seg-1", template_id="tmpl-001", segment_order=1,
duration_min=5, duration_max=15, material_type="人物"),
TemplateSegment(id="seg-2", template_id="tmpl-001", segment_order=2,
duration_min=10, duration_max=30, material_type=None), # 缺失
]
template = _make_template(mode="voice_over", segments=segs)
repo.get.return_value = template
command = ValidateTemplateCommand(
template_id="tmpl-001", user_id="user-001",
)
with pytest.raises(ValidationError, match="material_type"):
use_case.execute(command)
def test_voiceover_duration_within_tolerance_no_warning(self, use_case, repo):
"""配音时长在 ±30% 以内 → 无警告."""
template = _make_template(estimated_duration=60.0)
repo.get.return_value = template
command = ValidateTemplateCommand(
template_id="tmpl-001", user_id="user-001",
voiceover_duration=70.0, # 70/60 = 1.167, within ±30%
)
result = use_case.execute(command)
assert result.warnings == []
def test_voiceover_duration_exceeds_tolerance_warning(self, use_case, repo):
"""配音时长超过 ±30% → 警告."""
template = _make_template(estimated_duration=60.0)
repo.get.return_value = template
command = ValidateTemplateCommand(
template_id="tmpl-001", user_id="user-001",
voiceover_duration=100.0, # 100/60 = 1.667, exceeds +30%
)
result = use_case.execute(command)
assert len(result.warnings) == 1
assert result.warnings[0].code == "voiceover_duration_mismatch"
def test_voiceover_duration_too_short_warning(self, use_case, repo):
"""配音时长过短(< 70%)→ 警告."""
template = _make_template(estimated_duration=60.0)
repo.get.return_value = template
command = ValidateTemplateCommand(
template_id="tmpl-001", user_id="user-001",
voiceover_duration=30.0, # 30/60 = 0.5, below -30%
)
result = use_case.execute(command)
assert len(result.warnings) == 1
assert result.warnings[0].code == "voiceover_duration_mismatch"
def test_template_not_found_raises(self, use_case, repo):
"""模板不存在 → NotFoundError."""
repo.get.return_value = None
command = ValidateTemplateCommand(
template_id="nonexistent", user_id="user-001",
)
with pytest.raises(NotFoundError):
use_case.execute(command)
# ── Category Use Cases ──
class TestCategoryUseCases:
@pytest.fixture
def repo(self):
return _make_repo()
def test_create_category(self, repo):
repo.create_category.side_effect = lambda c: c
use_case = CreateCategoryUseCase(repo)
command = CreateCategoryCommand(user_id="user-001", name="Vlog")
result = use_case.execute(command)
assert result.name == "Vlog"
repo.create_category.assert_called_once()
def test_list_categories(self, repo):
categories = [
TemplateCategory(id="cat-1", user_id="user-001", name="Vlog"),
TemplateCategory(id="cat-2", user_id="user-001", name="教程"),
]
repo.list_categories.return_value = categories
use_case = ListCategoriesUseCase(repo)
result = use_case.execute("user-001")
assert len(result) == 2
assert result[0].name == "Vlog"
def test_delete_category_not_found(self, repo):
repo.delete_category.return_value = False
use_case = DeleteTemplateUseCase(repo)
result = use_case.execute("nonexistent", "user-001")
assert result is False
# ── ListTemplatesUseCase ──
class TestListTemplatesUseCase:
def test_list_returns_templates(self):
repo = _make_repo()
templates = [_make_template(id=f"t-{i}") for i in range(3)]
repo.list_by_user.return_value = templates
use_case = ListTemplatesUseCase(repo)
result = use_case.execute("user-001", skip=0, limit=50)
assert len(result) == 3
repo.list_by_user.assert_called_once_with("user-001", skip=0, limit=50)
# ── GetTemplateUseCase ──
class TestGetTemplateUseCase:
def test_get_existing(self):
repo = _make_repo()
template = _make_template()
repo.get.return_value = template
use_case = GetTemplateUseCase(repo)
result = use_case.execute("tmpl-001", "user-001")
assert result.id == "tmpl-001"
def test_get_nonexistent_returns_none(self):
repo = _make_repo()
repo.get.return_value = None
use_case = GetTemplateUseCase(repo)
result = use_case.execute("nonexistent", "user-001")
assert result is None