chore: sync develop → main (PR#99/100/102 合并) #103
+210
-11
@@ -1,19 +1,218 @@
|
||||
# Changelog
|
||||
## [v0.1.88] - 2026-06-29
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
### Phase 2 前端优化 - 完成 ✅
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
**前端交互全面优化:**
|
||||
|
||||
- 素材上传添加 project_id 参数
|
||||
- Drager 组件显示上传列表
|
||||
- 按钮防重复提交
|
||||
- 前端交互状态反馈补充(P0 第一批)
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.87] - 2026-06-29
|
||||
|
||||
### Bug 修复
|
||||
|
||||
- Docker compose 修复 mem_limit 冲突
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.86] - 2026-06-29
|
||||
|
||||
### CI/CD 优化
|
||||
|
||||
- CI 优化
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.85] - 2026-06-29
|
||||
|
||||
### CI/CD 优化
|
||||
|
||||
- CI 优化
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.84] - 2026-06-29
|
||||
|
||||
### CI/CD 优化
|
||||
|
||||
- CI runner label 匹配修复
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.83] - 2026-06-29
|
||||
|
||||
### CI/CD 优化
|
||||
|
||||
- CI SSH debug 修正
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.82] - 2026-06-29
|
||||
|
||||
### CI/CD 优化
|
||||
|
||||
- CI SSH debug 修正
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.81] - 2026-06-29
|
||||
|
||||
### CI/CD 优化
|
||||
|
||||
- CI runner label 匹配修复
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.80] - 2026-06-28
|
||||
|
||||
### Bug 修复
|
||||
|
||||
- 修复 redirect_slashes + 标题字段匹配
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.79] - 2026-06-28
|
||||
|
||||
### Deployment
|
||||
|
||||
- Re-trigger deployment
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.78] - 2026-06-28
|
||||
|
||||
### Bug 修复
|
||||
|
||||
- 修复 500 错误
|
||||
- CORS 配置修复
|
||||
- redirect_slashes 禁用
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.77] - 2026-06-28
|
||||
|
||||
### Bug 修复
|
||||
|
||||
- 修复标题库新建/编辑 — 前后端字段名不匹配导致 422
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 功能合并(v0.1.77 ~ v0.1.88)
|
||||
|
||||
**新增功能 PR:**
|
||||
|
||||
- PR#74: Phase 1 核心重构 — 标题库 API、配音库 API、去 Project 层清理
|
||||
- PR#75: Phase 2 查重功能前端页面
|
||||
- PR#76: Phase 2 查重功能后端 API(5 个端点)
|
||||
- PR#77: Phase 2 订阅管理前端页面
|
||||
- PR#78: Phase 2 订阅管理后端 API(5 个端点)
|
||||
- PR#79: 修复一键生成页面废弃 API 调用
|
||||
- PR#80: 回退域对象 extra_meta → metadata
|
||||
- PR#81: 删除查重 API 错误的 204 返回
|
||||
- PR#82: 查重上传接口错误信息不再泄露内部异常(安全审计)
|
||||
- PR#83: 订阅 + 查重单元测试(63 用例)
|
||||
- PR#84: 订阅管理前端对接真实 API
|
||||
- PR#85: 禁用 redirect_slashes 修复 307 重定向
|
||||
- PR#90: 标题库字段名修复
|
||||
- PR#91: 标题/配音创建 500 修复 + CORS
|
||||
- PR#94: 素材库新建自动获取默认 project_id
|
||||
- PR#97: 前端交互状态反馈全面补充
|
||||
|
||||
---
|
||||
|
||||
|
||||
- Docker compose 修复 mem_limit 冲突
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.86] - 2026-06-29
|
||||
|
||||
### CI/CD 优化
|
||||
|
||||
- CI 优化
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.85] - 2026-06-29
|
||||
|
||||
### CI/CD 优化
|
||||
|
||||
- CI 优化
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.84] - 2026-06-29
|
||||
|
||||
### CI/CD 优化
|
||||
|
||||
- CI runner label 匹配修复
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.83] - 2026-06-29
|
||||
|
||||
### CI/CD 优化
|
||||
|
||||
- CI SSH debug 修正
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.82] - 2026-06-29
|
||||
|
||||
### CI/CD 优化
|
||||
|
||||
- CI SSH debug 修正
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.81] - 2026-06-29
|
||||
|
||||
### CI/CD 优化
|
||||
|
||||
- CI runner label 匹配修复
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.80] - 2026-06-28
|
||||
|
||||
### Bug 修复
|
||||
|
||||
- 修复 redirect_slashes + 标题字段匹配
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.79] - 2026-06-28
|
||||
|
||||
### Deployment
|
||||
|
||||
- Re-trigger deployment
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.78] - 2026-06-28
|
||||
|
||||
### Bug 修复
|
||||
|
||||
- 修复 500 错误
|
||||
- CORS 配置修复
|
||||
- redirect_slashes 禁用
|
||||
|
||||
---
|
||||
|
||||
## [v0.1.77] - 2026-06-28
|
||||
|
||||
### Bug 修复
|
||||
|
||||
- 修复标题库新建/编辑 — 前后端字段名不匹配导致 422
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.2.0] - 2026-06-19
|
||||
|
||||
### Phase 7: 核心视频剪辑业务 - 完成 ✅
|
||||
|
||||
**完成进度:** 100%
|
||||
**状态:** 已完成并验证
|
||||
|
||||
#### Added
|
||||
|
||||
**素材管理:**
|
||||
|
||||
@@ -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,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"],
|
||||
)
|
||||
|
||||
@@ -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
|
||||
],
|
||||
)
|
||||
@@ -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
|
||||
@@ -54,11 +54,20 @@ apiClient.interceptors.response.use(
|
||||
handled = true;
|
||||
} else {
|
||||
const status = error.response?.status;
|
||||
if (status && status >= 500) {
|
||||
if (status === 413) {
|
||||
message.error('文件过大,请缩小后重试');
|
||||
handled = true;
|
||||
} else if (status === 415) {
|
||||
message.error('不支持的文件格式');
|
||||
handled = true;
|
||||
} else if (status === 503) {
|
||||
message.error('服务暂不可用,请稍后再试');
|
||||
handled = true;
|
||||
} else if (status && status >= 500) {
|
||||
message.error('服务器繁忙,请稍后再试');
|
||||
handled = true;
|
||||
}
|
||||
// 4xx 且无具体信息时不弹通用提示,由各组件自行处理
|
||||
// 其他 4xx 且无具体信息时不弹通用提示,由各组件自行处理
|
||||
}
|
||||
|
||||
// 标记已展示过提示,组件 onError 可据此跳过重复 toast
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
deleteAsset,
|
||||
uploadAsset,
|
||||
} from '@/api/assets';
|
||||
import { getOrCreateDefaultProject } from '@/api/projects';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Dragger } = Upload;
|
||||
@@ -71,6 +72,7 @@ const AssetLibrary: React.FC = () => {
|
||||
const [newLibKind, setNewLibKind] = useState<'video' | 'voice' | 'image'>(
|
||||
'video'
|
||||
);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
// 获取素材库列表
|
||||
const { data: libraries = [], isLoading: libsLoading } = useQuery({
|
||||
@@ -125,10 +127,19 @@ const AssetLibrary: React.FC = () => {
|
||||
message.warning('请先选择素材库');
|
||||
return false;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('library_id', activeLibrary);
|
||||
await uploadMutation.mutateAsync(formData);
|
||||
setUploading(true);
|
||||
try {
|
||||
const project = await getOrCreateDefaultProject();
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('library_id', activeLibrary);
|
||||
formData.append('project_id', project.id);
|
||||
await uploadMutation.mutateAsync(formData);
|
||||
} catch {
|
||||
// uploadMutation.onError 已处理错误提示
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -205,12 +216,15 @@ const AssetLibrary: React.FC = () => {
|
||||
beforeUpload={handleUpload}
|
||||
showUploadList={false}
|
||||
multiple
|
||||
disabled={uploading || uploadMutation.isPending}
|
||||
style={{ marginBottom: 24 }}
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined />
|
||||
{uploading ? <Spin /> : <InboxOutlined />}
|
||||
</p>
|
||||
<p className="ant-upload-text">
|
||||
{uploading ? '正在上传,请稍候...' : '点击或拖拽文件到此区域上传'}
|
||||
</p>
|
||||
<p className="ant-upload-text">点击或拖拽文件到此区域上传</p>
|
||||
<p className="ant-upload-hint">
|
||||
支持 {kindLabel[currentLib?.kind || 'video']} 格式文件
|
||||
</p>
|
||||
|
||||
@@ -326,14 +326,14 @@ const GeneratePage: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
disabled={currentStep === 0}
|
||||
disabled={currentStep === 0 || generating}
|
||||
onClick={() => setCurrentStep((s) => s - 1)}
|
||||
>
|
||||
上一步
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={currentStep === steps.length - 1}
|
||||
disabled={currentStep === steps.length - 1 || generating}
|
||||
onClick={() => setCurrentStep((s) => s + 1)}
|
||||
>
|
||||
下一步
|
||||
|
||||
@@ -18,6 +18,7 @@ const Billing: React.FC = () => {
|
||||
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [autoRenewChecked, setAutoRenewChecked] = useState(false);
|
||||
const [autoRenewLoading, setAutoRenewLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
@@ -36,6 +37,7 @@ const Billing: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleToggleAutoRenew = async (checked: boolean) => {
|
||||
setAutoRenewLoading(true);
|
||||
try {
|
||||
const res = await toggleAutoRenew(checked);
|
||||
message.success(res.message);
|
||||
@@ -45,6 +47,8 @@ const Billing: React.FC = () => {
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (!err?.__msgShown) message.error('操作失败');
|
||||
} finally {
|
||||
setAutoRenewLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -94,6 +98,7 @@ const Billing: React.FC = () => {
|
||||
<Switch
|
||||
checked={autoRenewChecked}
|
||||
onChange={handleToggleAutoRenew}
|
||||
loading={autoRenewLoading}
|
||||
checkedChildren="开"
|
||||
unCheckedChildren="关"
|
||||
/>
|
||||
|
||||
@@ -158,6 +158,7 @@ const TemplateLibrary: React.FC = () => {
|
||||
<StarOutlined />
|
||||
)
|
||||
}
|
||||
disabled={favMutation.isPending}
|
||||
onClick={() => favMutation.mutate(template.id)}
|
||||
/>,
|
||||
]}
|
||||
|
||||
@@ -229,7 +229,21 @@ const TitleLibrary: React.FC = () => {
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : filteredTitles.length === 0 ? (
|
||||
<Empty description={searchText ? '未找到匹配的标题' : '暂无标题'} />
|
||||
<Empty description={searchText ? '未找到匹配的标题' : '暂无标题'}>
|
||||
{!searchText && (
|
||||
<Space>
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
新建标题
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ImportOutlined />}
|
||||
onClick={() => setImportModalOpen(true)}
|
||||
>
|
||||
批量导入
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</Empty>
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 {},
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -112,6 +112,7 @@ class FeatureFlags:
|
||||
name=FeatureScope.RECIPE_REUSE,
|
||||
description="配方复用功能",
|
||||
global_enabled=True,
|
||||
plan_overrides={"free": False}, # 仅基础版和高级版可用
|
||||
),
|
||||
]
|
||||
for flag in defaults:
|
||||
|
||||
@@ -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:
|
||||
...
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user