feat: 剪辑计划编辑器后端 — 模板 CRUD + 分类 + 生成校验 #105
@@ -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"))
|
||||
@@ -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"],
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 # 配音实际时长(用于偏差校验)
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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: ...
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user