feat: 配方复用功能后端实现 #102

Merged
xiaoxia merged 1 commits from feat/recipe-reuse-backend into develop 2026-06-29 12:32:50 +08:00
13 changed files with 1082 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
"""Phase 2 - 配方复用:recipes + recipe_items
Revision ID: 013
Revises: 012
Create Date: 2026-06-29
This migration creates two new tables:
1. recipes — 配方主表
2. recipe_items — 配方素材项表
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers
revision = "013"
down_revision = "012"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
# ── 1. Create recipes table ──
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS recipes (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
name VARCHAR(200) NOT NULL,
description TEXT NOT NULL DEFAULT '',
template_id VARCHAR(36) NOT NULL DEFAULT '',
generation_params JSONB NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
)
"""))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_recipes_user_id ON recipes(user_id)"
))
# ── 2. Create recipe_items table ──
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS recipe_items (
id VARCHAR(36) PRIMARY KEY,
recipe_id VARCHAR(36) NOT NULL,
item_type VARCHAR(20) NOT NULL,
item_id VARCHAR(36) NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
metadata JSONB NOT NULL DEFAULT '{}'
)
"""))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_recipe_items_recipe_id ON recipe_items(recipe_id)"
))
def downgrade() -> None:
conn = op.get_bind()
conn.execute(sa.text("DROP TABLE IF EXISTS recipe_items"))
conn.execute(sa.text("DROP TABLE IF EXISTS recipes"))
+6
View File
@@ -6,6 +6,7 @@ from app.api.routes.chunked_upload import router as chunked_upload_router
from app.api.routes.classification_jobs import router as classification_jobs_router
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.titles import router as titles_router
from app.api.routes.voices import router as voices_router
@@ -98,3 +99,8 @@ api_router.include_router(
prefix="/subscription",
tags=["Subscription"],
)
api_router.include_router(
recipes_router,
prefix="/recipes",
tags=["Recipe"],
)
+216
View File
@@ -0,0 +1,216 @@
"""Recipe CRUD + use 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, get_user_repository
from app.schemas.recipe import (
CreateRecipeRequest,
ListRecipesResponse,
RecipeItemResponse,
RecipeResponse,
UpdateRecipeRequest,
UseRecipeResponse,
)
from packages.adapters.sqlalchemy_impl.recipe_repository import SQLAlchemyRecipeRepository
from packages.application.recipe.commands import (
CreateRecipeCommand,
RecipeItemCommand,
UpdateRecipeCommand,
)
from packages.application.recipe.use_cases import (
CreateRecipeUseCase,
DeleteRecipeUseCase,
FeatureDisabledError,
GetRecipeUseCase,
ListRecipesUseCase,
NotFoundError,
UpdateRecipeUseCase,
UseRecipeUseCase,
)
from packages.ports.user_repository import UserRepository
router = APIRouter()
def _get_recipe_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyRecipeRepository:
return SQLAlchemyRecipeRepository(session)
def _get_user_plan(user_id: str, user_repository: UserRepository) -> str:
user = user_repository.find_by_id(user_id)
if user is None:
return "free"
return getattr(user, "subscription_plan", "free") or "free"
def _item_to_response(item) -> RecipeItemResponse:
return RecipeItemResponse(
id=item.id,
recipe_id=item.recipe_id,
item_type=item.item_type,
item_id=item.item_id,
position=item.position,
metadata=item.metadata_,
)
def _to_response(recipe) -> RecipeResponse:
return RecipeResponse(
id=recipe.id,
user_id=recipe.user_id,
name=recipe.name,
description=recipe.description,
template_id=recipe.template_id,
generation_params=recipe.generation_params,
items=[_item_to_response(i) for i in getattr(recipe, "items", [])],
is_active=recipe.is_active,
metadata=recipe.metadata_,
created_at=recipe.created_at,
updated_at=recipe.updated_at,
)
@router.get("", response_model=ListRecipesResponse)
def list_recipes(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
recipe_repository: SQLAlchemyRecipeRepository = Depends(_get_recipe_repository),
) -> ListRecipesResponse:
user_id = authenticated_user.user.id
use_case = ListRecipesUseCase(recipe_repository)
recipes = use_case.execute(user_id, skip=skip, limit=limit)
total = recipe_repository.count_by_user(user_id)
return ListRecipesResponse(
items=[_to_response(r) for r in recipes],
total=total,
)
@router.get("/{recipe_id}", response_model=RecipeResponse)
def get_recipe(
recipe_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
recipe_repository: SQLAlchemyRecipeRepository = Depends(_get_recipe_repository),
) -> RecipeResponse:
user_id = authenticated_user.user.id
use_case = GetRecipeUseCase(recipe_repository)
recipe = use_case.execute(recipe_id, user_id)
if recipe is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found")
return _to_response(recipe)
@router.post("", response_model=RecipeResponse, status_code=status.HTTP_201_CREATED)
def create_recipe(
request: CreateRecipeRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
recipe_repository: SQLAlchemyRecipeRepository = Depends(_get_recipe_repository),
) -> RecipeResponse:
user_id = authenticated_user.user.id
command = CreateRecipeCommand(
user_id=user_id,
name=request.name,
description=request.description,
template_id=request.template_id,
generation_params=request.generation_params,
items=[
RecipeItemCommand(
item_type=ic.item_type,
item_id=ic.item_id,
position=ic.position,
metadata_=ic.metadata_,
)
for ic in request.items
],
metadata_=request.metadata_,
)
use_case = CreateRecipeUseCase(recipe_repository)
recipe = use_case.execute(command)
return _to_response(recipe)
@router.patch("/{recipe_id}", response_model=RecipeResponse)
def update_recipe(
recipe_id: str,
request: UpdateRecipeRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
recipe_repository: SQLAlchemyRecipeRepository = Depends(_get_recipe_repository),
) -> RecipeResponse:
user_id = authenticated_user.user.id
command = UpdateRecipeCommand(
recipe_id=recipe_id,
user_id=user_id,
name=request.name,
description=request.description,
template_id=request.template_id,
generation_params=request.generation_params,
items=(
[
RecipeItemCommand(
item_type=ic.item_type,
item_id=ic.item_id,
position=ic.position,
metadata_=ic.metadata_,
)
for ic in request.items
]
if request.items is not None
else None
),
metadata_=request.metadata_,
)
use_case = UpdateRecipeUseCase(recipe_repository)
try:
recipe = use_case.execute(command)
except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found")
return _to_response(recipe)
@router.delete("/{recipe_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
def delete_recipe(
recipe_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
recipe_repository: SQLAlchemyRecipeRepository = Depends(_get_recipe_repository),
) -> Response:
user_id = authenticated_user.user.id
use_case = DeleteRecipeUseCase(recipe_repository)
deleted = use_case.execute(recipe_id, user_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found")
return Response(status_code=204)
@router.post("/{recipe_id}/use", response_model=UseRecipeResponse)
def use_recipe(
recipe_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
recipe_repository: SQLAlchemyRecipeRepository = Depends(_get_recipe_repository),
user_repository: UserRepository = Depends(get_user_repository),
) -> UseRecipeResponse:
user_id = authenticated_user.user.id
plan_name = _get_user_plan(user_id, user_repository)
use_case = UseRecipeUseCase(recipe_repository)
try:
result = use_case.execute(recipe_id, user_id, user_plan=plan_name)
except FeatureDisabledError as exc:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(exc),
)
except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found")
return UseRecipeResponse(
recipe=_to_response(result.recipe),
warnings=[
{"item_type": w.item_type, "item_id": w.item_id, "position": w.position}
for w in result.warnings
],
)
+84
View File
@@ -0,0 +1,84 @@
"""Recipe API schemas."""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
# ── Response ──
class RecipeItemResponse(BaseModel):
id: str
recipe_id: str
item_type: str
item_id: str
position: int
metadata_: Dict[str, Any] = Field(default_factory=dict, alias="metadata")
class Config:
populate_by_name = True
class RecipeResponse(BaseModel):
id: str
user_id: str
name: str
description: str = ""
template_id: str = ""
generation_params: Dict[str, Any] = Field(default_factory=dict)
items: List[RecipeItemResponse] = Field(default_factory=list)
is_active: bool = True
metadata_: Dict[str, Any] = Field(default_factory=dict, alias="metadata")
created_at: datetime
updated_at: datetime
class Config:
populate_by_name = True
class ListRecipesResponse(BaseModel):
items: List[RecipeResponse]
total: int = 0
class UseRecipeResponse(BaseModel):
recipe: RecipeResponse
warnings: List[Dict[str, Any]] = Field(default_factory=list)
# ── Request ──
class RecipeItemRequest(BaseModel):
item_type: str
item_id: str
position: int = 0
metadata_: Dict[str, Any] = Field(default_factory=dict, alias="metadata")
class Config:
populate_by_name = True
class CreateRecipeRequest(BaseModel):
name: str
description: str = ""
template_id: str = ""
generation_params: Dict[str, Any] = Field(default_factory=dict)
items: List[RecipeItemRequest] = Field(default_factory=list)
metadata_: Dict[str, Any] = Field(default_factory=dict, alias="metadata")
class Config:
populate_by_name = True
class UpdateRecipeRequest(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
template_id: Optional[str] = None
generation_params: Optional[Dict[str, Any]] = None
items: Optional[List[RecipeItemRequest]] = None
metadata_: Optional[Dict[str, Any]] = Field(default=None, alias="metadata")
class Config:
populate_by_name = True
@@ -254,3 +254,29 @@ class DuplicationSegmentModel(Base):
matched_end = Column(Float, nullable=False)
similarity = Column(Float, nullable=False)
class RecipeModel(Base):
__tablename__ = "recipes"
id = Column(String(36), primary_key=True)
user_id = Column(String(36), nullable=False, index=True)
name = Column(String(200), nullable=False)
description = Column(Text, nullable=False, default="")
template_id = Column(String(36), nullable=False, default="")
generation_params = Column(JSON, nullable=False, default=dict)
is_active = Column(Boolean, nullable=False, default=True)
extra_meta = Column('metadata', JSON, nullable=False, default=dict)
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 RecipeItemModel(Base):
__tablename__ = "recipe_items"
id = Column(String(36), primary_key=True)
recipe_id = Column(String(36), nullable=False, index=True)
item_type = Column(String(20), nullable=False)
item_id = Column(String(36), nullable=False)
position = Column(Integer, nullable=False, default=0)
extra_meta = Column('metadata', JSON, nullable=False, default=dict)
@@ -0,0 +1,179 @@
"""SQLAlchemy implementation of RecipeRepository."""
from __future__ import annotations
from typing import List, Optional
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import RecipeModel, RecipeItemModel
from packages.domain.recipe import Recipe, RecipeItem
class SQLAlchemyRecipeRepository:
"""SQLAlchemy 配方仓储"""
def __init__(self, session: Session) -> None:
self.session = session
def list_by_user(
self,
user_id: str,
*,
skip: int = 0,
limit: int = 50,
) -> List[Recipe]:
models = (
self.session.query(RecipeModel)
.filter(
RecipeModel.user_id == user_id,
RecipeModel.is_active == True,
)
.order_by(RecipeModel.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
recipes = [self._model_to_entity(m) for m in models]
# Load items for each recipe
for recipe in recipes:
recipe.items = self.list_items(recipe.id)
return recipes
def get(self, recipe_id: str, user_id: str) -> Optional[Recipe]:
model = (
self.session.query(RecipeModel)
.filter(
RecipeModel.id == recipe_id,
RecipeModel.user_id == user_id,
)
.first()
)
if model is None:
return None
recipe = self._model_to_entity(model)
recipe.items = self.list_items(recipe.id)
return recipe
def create(self, recipe: Recipe) -> Recipe:
model = RecipeModel(
id=recipe.id,
user_id=recipe.user_id,
name=recipe.name,
description=recipe.description,
template_id=recipe.template_id,
generation_params=recipe.generation_params,
is_active=recipe.is_active,
extra_meta=recipe.metadata_,
)
self.session.add(model)
self.session.commit()
self.session.refresh(model)
result = self._model_to_entity(model)
result.items = recipe.items
return result
def update(self, recipe: Recipe) -> Recipe:
model = (
self.session.query(RecipeModel)
.filter(
RecipeModel.id == recipe.id,
RecipeModel.user_id == recipe.user_id,
)
.first()
)
if model is None:
raise ValueError(f"Recipe {recipe.id} not found")
model.name = recipe.name
model.description = recipe.description
model.template_id = recipe.template_id
model.generation_params = recipe.generation_params
model.is_active = recipe.is_active
model.extra_meta = recipe.metadata_
self.session.commit()
self.session.refresh(model)
result = self._model_to_entity(model)
result.items = recipe.items
return result
def delete(self, recipe_id: str, user_id: str) -> bool:
model = (
self.session.query(RecipeModel)
.filter(
RecipeModel.id == recipe_id,
RecipeModel.user_id == user_id,
)
.first()
)
if model is None:
return False
model.is_active = False
self.session.commit()
return True
def count_by_user(self, user_id: str, is_active: bool = True) -> int:
return (
self.session.query(RecipeModel)
.filter(
RecipeModel.user_id == user_id,
RecipeModel.is_active == is_active,
)
.count()
)
def list_items(self, recipe_id: str) -> List[RecipeItem]:
models = (
self.session.query(RecipeItemModel)
.filter(RecipeItemModel.recipe_id == recipe_id)
.order_by(RecipeItemModel.position)
.all()
)
return [self._item_model_to_entity(m) for m in models]
def create_items(self, items: List[RecipeItem]) -> List[RecipeItem]:
for item in items:
model = RecipeItemModel(
id=item.id,
recipe_id=item.recipe_id,
item_type=item.item_type,
item_id=item.item_id,
position=item.position,
extra_meta=item.metadata_,
)
self.session.add(model)
self.session.commit()
return items
def delete_items_by_recipe(self, recipe_id: str) -> int:
count = (
self.session.query(RecipeItemModel)
.filter(RecipeItemModel.recipe_id == recipe_id)
.delete()
)
self.session.commit()
return count
@staticmethod
def _model_to_entity(model: RecipeModel) -> Recipe:
return Recipe(
id=model.id,
user_id=model.user_id,
name=model.name,
description=model.description or "",
template_id=model.template_id or "",
generation_params=model.generation_params or {},
is_active=model.is_active,
metadata_=model.extra_meta or {},
created_at=model.created_at,
updated_at=model.updated_at,
)
@staticmethod
def _item_model_to_entity(model: RecipeItemModel) -> RecipeItem:
return RecipeItem(
id=model.id,
recipe_id=model.recipe_id,
item_type=model.item_type,
item_id=model.item_id,
position=model.position or 0,
metadata_=model.extra_meta or {},
)
+36
View File
@@ -0,0 +1,36 @@
"""Recipe commands."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import List, Optional
@dataclass
class RecipeItemCommand:
item_type: str
item_id: str
position: int = 0
metadata_: dict = field(default_factory=dict)
@dataclass
class CreateRecipeCommand:
user_id: str
name: str
description: str = ""
template_id: str = ""
generation_params: dict = field(default_factory=dict)
items: List[RecipeItemCommand] = field(default_factory=list)
metadata_: dict = field(default_factory=dict)
@dataclass
class UpdateRecipeCommand:
recipe_id: str
user_id: str
name: Optional[str] = None
description: Optional[str] = None
template_id: Optional[str] = None
generation_params: Optional[dict] = None
items: Optional[List[RecipeItemCommand]] = None
metadata_: Optional[dict] = None
+184
View File
@@ -0,0 +1,184 @@
"""Recipe use cases."""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from typing import List, Optional
from packages.adapters.sqlalchemy_impl.recipe_repository import SQLAlchemyRecipeRepository
from packages.application.recipe.commands import (
CreateRecipeCommand,
RecipeItemCommand,
UpdateRecipeCommand,
)
from packages.domain.recipe import Recipe, RecipeItem
from packages.infrastructure.feature_flags import FeatureScope, feature_flags
class NotFoundError(Exception):
pass
class FeatureDisabledError(Exception):
pass
@dataclass
class MissingAssetWarning:
"""使用配方时缺失的素材警告"""
item_type: str
item_id: str
position: int
class CreateRecipeUseCase:
def __init__(self, repository: SQLAlchemyRecipeRepository) -> None:
self.repository = repository
def execute(self, command: CreateRecipeCommand) -> Recipe:
recipe_id = uuid.uuid4().hex
recipe = Recipe(
id=recipe_id,
user_id=command.user_id,
name=command.name,
description=command.description,
template_id=command.template_id,
generation_params=command.generation_params,
metadata_=command.metadata_,
)
recipe = self.repository.create(recipe)
# Create items
if command.items:
items = [
RecipeItem(
id=uuid.uuid4().hex,
recipe_id=recipe.id,
item_type=ic.item_type,
item_id=ic.item_id,
position=ic.position,
metadata_=ic.metadata_,
)
for ic in command.items
]
self.repository.create_items(items)
recipe.items = items
return recipe
class ListRecipesUseCase:
def __init__(self, repository: SQLAlchemyRecipeRepository) -> None:
self.repository = repository
def execute(
self,
user_id: str,
*,
skip: int = 0,
limit: int = 50,
) -> List[Recipe]:
return self.repository.list_by_user(user_id, skip=skip, limit=limit)
class GetRecipeUseCase:
def __init__(self, repository: SQLAlchemyRecipeRepository) -> None:
self.repository = repository
def execute(self, recipe_id: str, user_id: str) -> Optional[Recipe]:
return self.repository.get(recipe_id, user_id)
class UpdateRecipeUseCase:
def __init__(self, repository: SQLAlchemyRecipeRepository) -> None:
self.repository = repository
def execute(self, command: UpdateRecipeCommand) -> Recipe:
existing = self.repository.get(command.recipe_id, command.user_id)
if existing is None:
raise NotFoundError(f"Recipe {command.recipe_id} not found")
if command.name is not None:
existing.name = command.name
if command.description is not None:
existing.description = command.description
if command.template_id is not None:
existing.template_id = command.template_id
if command.generation_params is not None:
existing.generation_params = command.generation_params
if command.metadata_ is not None:
existing.metadata_ = command.metadata_
self.repository.update(existing)
# Replace items if provided
if command.items is not None:
self.repository.delete_items_by_recipe(existing.id)
items = [
RecipeItem(
id=uuid.uuid4().hex,
recipe_id=existing.id,
item_type=ic.item_type,
item_id=ic.item_id,
position=ic.position,
metadata_=ic.metadata_,
)
for ic in command.items
]
self.repository.create_items(items)
existing.items = items
else:
existing.items = self.repository.list_items(existing.id)
return existing
class DeleteRecipeUseCase:
def __init__(self, repository: SQLAlchemyRecipeRepository) -> None:
self.repository = repository
def execute(self, recipe_id: str, user_id: str) -> bool:
return self.repository.delete(recipe_id, user_id)
@dataclass
class UseRecipeResult:
"""使用配方的结果"""
recipe: Recipe
warnings: List[MissingAssetWarning]
class UseRecipeUseCase:
"""使用配方 — 校验 Feature Flag + 检查素材可用性"""
def __init__(self, repository: SQLAlchemyRecipeRepository) -> None:
self.repository = repository
def execute(
self,
recipe_id: str,
user_id: str,
*,
user_plan: str = "free",
) -> UseRecipeResult:
# 1. 校验 Feature Flag(仅 basic/premium 可用)
if not feature_flags.is_enabled(
FeatureScope.RECIPE_REUSE,
user_plan=user_plan,
):
raise FeatureDisabledError(
"配方复用功能仅对基础版和高级版用户开放"
)
# 2. 获取配方
recipe = self.repository.get(recipe_id, user_id)
if recipe is None:
raise NotFoundError(f"Recipe {recipe_id} not found")
# 3. 校验引用的素材/标题/配音是否仍存在
warnings: List[MissingAssetWarning] = []
# Note: 实际项目中这里需要注入 asset/title/voice repository
# 来校验每个 item 是否仍然存在。当前版本返回空警告列表,
# 由调用方(路由层)决定是否传入额外的校验逻辑。
return UseRecipeResult(recipe=recipe, warnings=warnings)
+33
View File
@@ -0,0 +1,33 @@
"""Recipe domain entities."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import List
@dataclass
class RecipeItem:
"""配方中的单个素材/标题/配音项"""
id: str
recipe_id: str
item_type: str # asset / title / voice
item_id: str
position: int = 0
metadata_: dict = field(default_factory=dict)
@dataclass
class Recipe:
"""配方 — 一次「一键生成」的完整参数组合"""
id: str
user_id: str
name: str
description: str = ""
template_id: str = ""
generation_params: dict = field(default_factory=dict)
items: List[RecipeItem] = field(default_factory=list)
is_active: bool = True
metadata_: dict = field(default_factory=dict)
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+1
View File
@@ -112,6 +112,7 @@ class FeatureFlags:
name=FeatureScope.RECIPE_REUSE,
description="配方复用功能",
global_enabled=True,
plan_overrides={"free": False}, # 仅基础版和高级版可用
),
]
for flag in defaults:
+43
View File
@@ -0,0 +1,43 @@
"""Recipe repository port."""
from __future__ import annotations
from typing import List, Optional, Protocol
from packages.domain.recipe import Recipe, RecipeItem
class RecipeRepository(Protocol):
"""配方仓储接口"""
def list_by_user(
self,
user_id: str,
*,
skip: int = 0,
limit: int = 50,
) -> List[Recipe]:
...
def get(self, recipe_id: str, user_id: str) -> Optional[Recipe]:
...
def create(self, recipe: Recipe) -> Recipe:
...
def update(self, recipe: Recipe) -> Recipe:
...
def delete(self, recipe_id: str, user_id: str) -> bool:
...
def count_by_user(self, user_id: str, is_active: bool = True) -> int:
...
def list_items(self, recipe_id: str) -> List[RecipeItem]:
...
def create_items(self, items: List[RecipeItem]) -> List[RecipeItem]:
...
def delete_items_by_recipe(self, recipe_id: str) -> int:
...
+210
View File
@@ -0,0 +1,210 @@
"""Recipe use cases unit tests."""
from __future__ import annotations
from datetime import datetime, timezone
from unittest.mock import Mock
import pytest
from packages.application.recipe.commands import (
CreateRecipeCommand,
RecipeItemCommand,
UpdateRecipeCommand,
)
from packages.application.recipe.use_cases import (
CreateRecipeUseCase,
DeleteRecipeUseCase,
FeatureDisabledError,
GetRecipeUseCase,
ListRecipesUseCase,
NotFoundError,
UpdateRecipeUseCase,
UseRecipeUseCase,
)
from packages.domain.recipe import Recipe, RecipeItem
def _make_recipe(**kwargs) -> Recipe:
defaults = dict(
id="recipe001",
user_id="user001",
name="测试配方",
description="描述",
template_id="tpl001",
generation_params={"mode": "one_take"},
items=[],
is_active=True,
metadata_={},
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
defaults.update(kwargs)
return Recipe(**defaults)
def _make_item(**kwargs) -> RecipeItem:
defaults = dict(
id="item001",
recipe_id="recipe001",
item_type="asset",
item_id="asset001",
position=0,
metadata_={},
)
defaults.update(kwargs)
return RecipeItem(**defaults)
class TestCreateRecipeUseCase:
@pytest.fixture
def mock_repo(self):
repo = Mock()
repo.create = Mock(side_effect=lambda r: r)
repo.create_items = Mock(side_effect=lambda items: items)
return repo
def test_create_basic(self, mock_repo):
uc = CreateRecipeUseCase(mock_repo)
cmd = CreateRecipeCommand(
user_id="user001",
name="我的配方",
description="desc",
template_id="tpl001",
generation_params={"mode": "one_take"},
)
result = uc.execute(cmd)
assert result.name == "我的配方"
assert result.user_id == "user001"
mock_repo.create.assert_called_once()
def test_create_with_items(self, mock_repo):
uc = CreateRecipeUseCase(mock_repo)
cmd = CreateRecipeCommand(
user_id="user001",
name="带素材配方",
items=[
RecipeItemCommand(item_type="asset", item_id="a1", position=0),
RecipeItemCommand(item_type="title", item_id="t1", position=1),
RecipeItemCommand(item_type="voice", item_id="v1", position=2),
],
)
result = uc.execute(cmd)
assert len(result.items) == 3
mock_repo.create_items.assert_called_once()
items_arg = mock_repo.create_items.call_args[0][0]
assert items_arg[0].item_type == "asset"
assert items_arg[1].item_type == "title"
assert items_arg[2].item_type == "voice"
class TestListRecipesUseCase:
def test_list(self):
repo = Mock()
repo.list_by_user = Mock(return_value=[_make_recipe()])
uc = ListRecipesUseCase(repo)
result = uc.execute("user001", skip=0, limit=10)
assert len(result) == 1
repo.list_by_user.assert_called_once_with("user001", skip=0, limit=10)
class TestGetRecipeUseCase:
def test_get_found(self):
repo = Mock()
repo.get = Mock(return_value=_make_recipe())
uc = GetRecipeUseCase(repo)
result = uc.execute("recipe001", "user001")
assert result is not None
assert result.id == "recipe001"
def test_get_not_found(self):
repo = Mock()
repo.get = Mock(return_value=None)
uc = GetRecipeUseCase(repo)
result = uc.execute("recipe999", "user001")
assert result is None
class TestUpdateRecipeUseCase:
@pytest.fixture
def mock_repo(self):
repo = Mock()
repo.get = Mock(return_value=_make_recipe())
repo.update = Mock(side_effect=lambda r: r)
repo.list_items = Mock(return_value=[])
repo.delete_items_by_recipe = Mock(return_value=0)
repo.create_items = Mock(side_effect=lambda items: items)
return repo
def test_update_name(self, mock_repo):
uc = UpdateRecipeUseCase(mock_repo)
cmd = UpdateRecipeCommand(
recipe_id="recipe001",
user_id="user001",
name="新名字",
)
result = uc.execute(cmd)
assert result.name == "新名字"
def test_update_not_found(self):
repo = Mock()
repo.get = Mock(return_value=None)
uc = UpdateRecipeUseCase(repo)
cmd = UpdateRecipeCommand(recipe_id="xxx", user_id="user001", name="x")
with pytest.raises(NotFoundError):
uc.execute(cmd)
def test_update_replace_items(self, mock_repo):
uc = UpdateRecipeUseCase(mock_repo)
cmd = UpdateRecipeCommand(
recipe_id="recipe001",
user_id="user001",
items=[RecipeItemCommand(item_type="voice", item_id="v2", position=0)],
)
result = uc.execute(cmd)
mock_repo.delete_items_by_recipe.assert_called_once_with("recipe001")
mock_repo.create_items.assert_called_once()
assert len(result.items) == 1
class TestDeleteRecipeUseCase:
def test_delete_success(self):
repo = Mock()
repo.delete = Mock(return_value=True)
uc = DeleteRecipeUseCase(repo)
assert uc.execute("recipe001", "user001") is True
def test_delete_not_found(self):
repo = Mock()
repo.delete = Mock(return_value=False)
uc = DeleteRecipeUseCase(repo)
assert uc.execute("recipe999", "user001") is False
class TestUseRecipeUseCase:
def test_use_success_basic_plan(self):
repo = Mock()
repo.get = Mock(return_value=_make_recipe())
uc = UseRecipeUseCase(repo)
result = uc.execute("recipe001", "user001", user_plan="basic")
assert result.recipe.id == "recipe001"
assert result.warnings == []
def test_use_success_premium_plan(self):
repo = Mock()
repo.get = Mock(return_value=_make_recipe())
uc = UseRecipeUseCase(repo)
result = uc.execute("recipe001", "user001", user_plan="premium")
assert result.recipe.id == "recipe001"
def test_use_free_plan_forbidden(self):
repo = Mock()
uc = UseRecipeUseCase(repo)
with pytest.raises(FeatureDisabledError):
uc.execute("recipe001", "user001", user_plan="free")
def test_use_not_found(self):
repo = Mock()
repo.get = Mock(return_value=None)
uc = UseRecipeUseCase(repo)
with pytest.raises(NotFoundError):
uc.execute("recipe999", "user001", user_plan="basic")