diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a46e975f..d8e383e22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 **素材管理:** diff --git a/alembic/versions/013_add_recipe_tables.py b/alembic/versions/013_add_recipe_tables.py new file mode 100644 index 000000000..bd1357d27 --- /dev/null +++ b/alembic/versions/013_add_recipe_tables.py @@ -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")) diff --git a/apps/api/app/api/router.py b/apps/api/app/api/router.py index 1848cdd7b..93b94fd24 100644 --- a/apps/api/app/api/router.py +++ b/apps/api/app/api/router.py @@ -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"], +) diff --git a/apps/api/app/api/routes/recipes.py b/apps/api/app/api/routes/recipes.py new file mode 100644 index 000000000..f5c7d97e9 --- /dev/null +++ b/apps/api/app/api/routes/recipes.py @@ -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 + ], + ) diff --git a/apps/api/app/schemas/recipe.py b/apps/api/app/schemas/recipe.py new file mode 100644 index 000000000..4dfddec5b --- /dev/null +++ b/apps/api/app/schemas/recipe.py @@ -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 diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 042e54a64..a6de7f408 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -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 diff --git a/apps/web/src/pages/assets/AssetLibrary.tsx b/apps/web/src/pages/assets/AssetLibrary.tsx index e3506f61a..173f620b4 100644 --- a/apps/web/src/pages/assets/AssetLibrary.tsx +++ b/apps/web/src/pages/assets/AssetLibrary.tsx @@ -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 }} >

- + {uploading ? : } +

+

+ {uploading ? '正在上传,请稍候...' : '点击或拖拽文件到此区域上传'}

-

点击或拖拽文件到此区域上传

支持 {kindLabel[currentLib?.kind || 'video']} 格式文件

diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index 2002645ae..8f5fc8279 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -326,14 +326,14 @@ const GeneratePage: React.FC = () => { }} > + + + )} + ) : ( +``` + +获取 access_token 请参见 [登录接口](#post-apiv1autologin)。 + +### 错误响应格式 + +```json +{ + "detail": "错误描述信息" +} +``` + +### HTTP 状态码 + +| 状态码 | 说明 | +|--------|------| +| 200 | 请求成功 | +| 201 | 资源创建成功 | +| 204 | 请求成功(无返回内容) | +| 400 | 请求参数错误 | +| 401 | 未认证或 token 无效 | +| 403 | 无权限访问 | +| 404 | 资源不存在 | +| 409 | 资源冲突 | +| 413 | 文件过大 | +| 415 | 不支持的文件类型 | +| 422 | 请求格式正确但无法处理 | +| 429 | 配额超限 | +| 500 | 服务器内部错误 | +| 503 | 服务暂不可用 | + +--- + +## 1. 认证 (Auth) + +**基础路径**: `/api/v1/auth` + +### POST /register + +用户注册。 + +**认证**: 否 + +**请求 Body**: +```json +{ + "email": "user@example.com", + "password": "your_password", + "username": "username", + "display_name": "显示名称" +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| email | string | 是 | 邮箱地址 | +| password | string | 是 | 密码(建议至少 8 位) | +| username | string | 是 | 用户名(唯一) | +| display_name | string | 否 | 显示名称,默认同 username | + +**响应** `201 Created`: +```json +{ + "user_id": "用户ID", + "email": "user@example.com", + "username": "username", + "display_name": "显示名称", + "message": "注册成功!" +} +``` + +--- + +### POST /login + +用户登录。 + +**认证**: 否 + +**请求 Body**: +```json +{ + "email": "user@example.com", + "password": "your_password" +} +``` + +**响应** `200 OK`: +```json +{ + "access_token": "eyJhbGciOiJIUzI1NiIs...", + "refresh_token": "eyJhbGciOiJIUzI1NiIs...", + "token_type": "bearer", + "user_id": "用户ID", + "email": "user@example.com", + "username": "username", + "display_name": "显示名称", + "expires_in": 3600 +} +``` + +--- + +### POST /refresh + +刷新访问令牌。 + +**认证**: 否 + +**请求 Body**: +```json +{ + "refresh_token": "eyJhbGciOiJIUzI1NiIs..." +} +``` + +**响应** `200 OK`: 同登录响应 + +--- + +### GET /verify-email + +验证邮箱(通过链接点击)。 + +**认证**: 否 + +**查询参数**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| token | string | 是 | 邮箱验证 token | + +**响应** `200 OK`: +```json +{ + "message": "邮箱验证成功" +} +``` + +--- + +### POST /verify-email + +验证邮箱(通过 API)。 + +**认证**: 否 + +**请求 Body**: +```json +{ + "token": "邮箱验证 token" +} +``` + +**响应** `200 OK`: +```json +{ + "message": "邮箱验证成功" +} +``` + +--- + +### POST /password/forgot + +请求密码重置。 + +**认证**: 否 + +**请求 Body**: +```json +{ + "email": "user@example.com" +} +``` + +**响应** `202 Accepted`: +```json +{ + "message": "如果账户存在,密码重置邮件已发送" +} +``` + +--- + +### POST /password/reset + +重置密码。 + +**认证**: 否 + +**请求 Body**: +```json +{ + "token": "密码重置 token", + "new_password": "new_password123" +} +``` + +**响应** `200 OK`: +```json +{ + "message": "密码重置成功" +} +``` + +--- + +### GET /me + +获取当前登录用户信息。 + +**认证**: 是 + +**响应** `200 OK`: +```json +{ + "user_id": "用户ID", + "email": "user@example.com", + "username": "username", + "display_name": "显示名称", + "email_verified": true +} +``` + +--- + +## 2. 项目 (Project) + +**基础路径**: `/api/v1/projects` + +### GET / + +获取用户的所有项目列表。 + +**认证**: 是 + +**响应** `200 OK`: +```json +{ + "items": [ + { + "id": "项目ID", + "owner_user_id": "拥有者ID", + "name": "项目名称", + "description": "项目描述", + "shared_users": [] + } + ] +} +``` + +--- + +### POST / + +创建新项目。 + +**认证**: 是 + +**请求 Body**: +```json +{ + "name": "项目名称", + "description": "项目描述(可选)" +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| name | string | 是 | 项目名称(1-100字符) | +| description | string | 否 | 项目描述(最多500字符) | + +**响应** `200 OK`: +```json +{ + "id": "项目ID", + "owner_user_id": "拥有者ID", + "name": "项目名称", + "description": "项目描述", + "shared_users": [] +} +``` + +--- + +### GET /{project_id} + +获取指定项目详情。 + +**认证**: 是 + +**路径参数**: +| 参数 | 类型 | 说明 | +|------|------|------| +| project_id | string | 项目 ID | + +**响应** `200 OK`: +```json +{ + "id": "项目ID", + "owner_user_id": "拥有者ID", + "name": "项目名称", + "description": "项目描述", + "shared_users": [] +} +``` + +--- + +## 3. 任务中心 (TaskCenter) + +**基础路径**: `/api/v1` + +### GET /projects/{project_id}/tasks + +获取项目的所有任务(导入任务 + 生成任务)。 + +**认证**: 是 + +**路径参数**: +| 参数 | 类型 | 说明 | +|------|------|------| +| project_id | string | 项目 ID | + +**响应** `200 OK`: +```json +{ + "items": [ + { + "id": "task:任务ID", + "task_type": "ingest 或 generation", + "project_id": "项目ID", + "status": "pending | processing | completed | failed", + "progress": 0.0, + "current_step": "当前步骤描述", + "error_message": "", + "user_message": "", + "retryable": false, + "source_id": "原始任务ID", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z" + } + ] +} +``` + +--- + +### POST /tasks/{task_type}/{source_id}/retry + +重试失败的任务。 + +**认证**: 是 + +**路径参数**: +| 参数 | 类型 | 说明 | +|------|------|------| +| task_type | string | 任务类型:`ingest` 或 `generation` | +| source_id | string | 原始任务 ID | + +**响应** `200 OK`: 返回重试后的任务信息(同上) + +--- + +## 4. 素材诊断 (AssetDiagnosis) + +**基础路径**: `/api/v1` + +### GET /projects/{project_id}/asset-diagnosis + +获取项目的素材健康度诊断。 + +**认证**: 是 + +**路径参数**: +| 参数 | 类型 | 说明 | +|------|------|------| +| project_id | string | 项目 ID | + +**响应** `200 OK`: +```json +{ + "project_id": "项目ID", + "readiness_score": 75, + "readiness_label": "素材充足", + "total_assets": 10, + "ready_assets": 8, + "video_assets": 5, + "image_assets": 2, + "voice_assets": 1, + "total_duration_seconds": 120.5, + "estimated_video_count": 3, + "used_assets": 2, + "unused_assets": 6, + "pending_review_assets": 0, + "smart_views": [ + { + "key": "recommended", + "label": "推荐素材", + "count": 5, + "description": "已导入完成、可参与生成的视频素材" + } + ], + "gaps": [ + { + "key": "missing_voice", + "severity": "info", + "message": "暂未配置配音素材", + "recommendation": "如果本项目需要口播/旁白,请上传配音素材" + } + ] +} +``` + +--- + +## 5. 素材库 (AssetLibrary) + +**基础路径**: `/api/v1/asset-libraries` + +### GET / + +获取素材库列表。 + +**认证**: 是 + +**查询参数**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| project_id | string | 否 | 按项目 ID 筛选 | + +**响应** `200 OK`: +```json +{ + "items": [ + { + "id": "素材库ID", + "project_id": "项目ID", + "name": "素材库名称", + "kind": "video | voice | image", + "asset_count": 10, + "total_size": 104857600 + } + ] +} +``` + +--- + +### POST / + +创建素材库。 + +**认证**: 是 + +**请求 Body**: +```json +{ + "project_id": "项目ID", + "name": "素材库名称", + "kind": "video" +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| project_id | string | 是 | 所属项目 ID | +| name | string | 是 | 素材库名称(1-100字符) | +| kind | string | 是 | 类型:`video`、`voice` 或 `image` | + +**响应** `200 OK`: +```json +{ + "id": "素材库ID", + "project_id": "项目ID", + "name": "素材库名称", + "kind": "video", + "asset_count": 0, + "total_size": 0 +} +``` + +--- + +## 6. 素材 (Asset) + +**基础路径**: `/api/v1/assets` + +### GET / + +获取素材列表。 + +**认证**: 是 + +**查询参数**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| library_id | string | 是 | 素材库 ID | + +**响应** `200 OK`: +```json +{ + "items": [ + { + "id": "素材ID", + "project_id": "项目ID", + "library_id": "素材库ID", + "name": "素材名称.mp4", + "storage_key": "uploads/xxx/素材名称.mp4", + "mime_type": "video/mp4", + "metadata": {}, + "file_size": 10485760, + "thumbnail_url": "https://...", + "duration": 120.5, + "width": 1920, + "height": 1080, + "fps": 30.0, + "codec": "h264", + "status": "ready", + "classification_status": "completed", + "quality_score": 85.0, + "uploaded_by_user_id": "用户ID" + } + ] +} +``` + +--- + +### POST / + +创建素材记录(通常由上传流程自动调用)。 + +**认证**: 是 + +**请求 Body**: +```json +{ + "project_id": "项目ID", + "library_id": "素材库ID", + "name": "素材名称.mp4", + "storage_key": "uploads/xxx/素材名称.mp4", + "mime_type": "video/mp4", + "metadata": {}, + "file_size": 10485760, + "thumbnail_url": "https://...", + "duration": 120.5, + "width": 1920, + "height": 1080, + "fps": 30.0, + "codec": "h264", + "status": "uploading", + "classification_status": "pending", + "quality_score": 85.0 +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| project_id | string | 是 | 项目 ID | +| library_id | string | 是 | 素材库 ID | +| name | string | 是 | 素材名称(1-100字符) | +| storage_key | string | 是 | 存储路径 | +| mime_type | string | 是 | MIME 类型 | +| metadata | object | 否 | 元数据 | +| file_size | integer | 否 | 文件大小(字节) | +| thumbnail_url | string | 否 | 缩略图 URL | +| duration | float | 否 | 时长(秒) | +| width | integer | 否 | 宽度 | +| height | integer | 否 | 高度 | +| fps | float | 否 | 帧率 | +| codec | string | 否 | 编码格式 | +| status | string | 否 | 状态(默认 uploading) | +| classification_status | string | 否 | 分类状态(默认 pending) | +| quality_score | float | 否 | 质量分数(0-100) | + +**响应** `200 OK`: 返回创建的素材对象 + +--- + +### PATCH /{asset_id}/review + +更新素材审核状态。 + +**认证**: 是 + +**路径参数**: +| 参数 | 类型 | 说明 | +|------|------|------| +| asset_id | string | 素材 ID | + +**请求 Body**: +```json +{ + "review_status": "pending_review | approved | rejected" +} +``` + +**响应** `200 OK`: 返回更新的素材对象 + +--- + +## 7. 导入任务 (IngestJob) + +**基础路径**: `/api/v1/ingest-jobs` + +### GET /{job_id} + +获取导入任务详情。 + +**认证**: 否(内部 API) + +**路径参数**: +| 参数 | 类型 | 说明 | +|------|------|------| +| job_id | string | 任务 ID | + +**响应** `200 OK`: +```json +{ + "id": "任务ID", + "project_id": "项目ID", + "library_id": "素材库ID", + "storage_key": "uploads/xxx/file.mp4", + "status": "completed", + "error_message": "", + "result_asset_id": "素材ID" +} +``` + +--- + +### POST / + +提交新的导入任务。 + +**认证**: 否(内部 API) + +**请求 Body**: +```json +{ + "project_id": "项目ID", + "library_id": "素材库ID", + "storage_key": "uploads/xxx/file.mp4" +} +``` + +**响应** `200 OK`: 返回创建的任务对象 + +--- + +## 8. 分类任务 (ClassificationJob) + +**基础路径**: `/api/v1/classification-jobs` + +### GET /{job_id} + +获取分类任务详情。 + +**认证**: 否(内部 API) + +**路径参数**: +| 参数 | 类型 | 说明 | +|------|------|------| +| job_id | string | 任务 ID | + +**响应** `200 OK`: +```json +{ + "id": "任务ID", + "project_id": "项目ID", + "asset_id": "素材ID", + "status": "completed", + "classification": "video", + "confidence": 0.95, + "error_message": "" +} +``` + +--- + +### POST / + +提交新的分类任务。 + +**认证**: 否(内部 API) + +**请求 Body**: +```json +{ + "project_id": "项目ID", + "asset_id": "素材ID" +} +``` + +**响应** `200 OK`: 返回创建的任务对象 + +--- + +## 9. 上传 (Upload) + +**基础路径**: `/api/v1/upload` + +### POST /direct/prepare + +准备直传 OSS(获取表单签名)。 + +**认证**: 是 + +**请求 Body**: +```json +{ + "project_id": "项目ID", + "library_id": "素材库ID", + "filename": "video.mp4", + "content_type": "video/mp4", + "file_size": 104857600 +} +``` + +**响应** `200 OK`: +```json +{ + "upload_url": "https://oss.example.com/...", + "method": "POST", + "storage_key": "uploads/xxx/video.mp4", + "expires_at": "2024-01-01T12:00:00Z", + "fields": { + "OSSAccessKeyId": "...", + "policy": "...", + "signature": "...", + "key": "uploads/xxx/video.mp4" + }, + "max_size_bytes": 104857600 +} +``` + +--- + +### POST /direct/complete + +确认直传完成并创建导入任务。 + +**认证**: 是 + +**请求 Body**: +```json +{ + "project_id": "项目ID", + "library_id": "素材库ID", + "storage_key": "uploads/xxx/video.mp4" +} +``` + +**响应** `200 OK`: +```json +{ + "storage_key": "uploads/xxx/video.mp4", + "ingest_job_id": "导入任务ID" +} +``` + +--- + +### POST / + +上传素材文件(multipart/form-data)。 + +**认证**: 是 + +**表单字段**: +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| project_id | string | 是 | 项目 ID | +| library_id | string | 是 | 素材库 ID | +| file | file | 是 | 上传的文件 | + +**响应** `200 OK`: +```json +{ + "storage_key": "uploads/xxx/video.mp4", + "ingest_job_id": "导入任务ID", + "url": "https://oss.example.com/uploads/xxx/video.mp4" +} +``` + +**支持的文件类型**: +- 视频: `video/mp4`, `video/mpeg`, `video/quicktime`, `video/x-msvideo`, `video/webm`, `video/x-matroska`, `video/3gpp` +- 音频: `audio/mpeg`, `audio/wav`, `audio/ogg`, `audio/flac`, `audio/aac`, `audio/mp3`, `audio/x-m4a` +- 图片: `image/jpeg`, `image/png`, `image/gif`, `image/webp`, `image/bmp`, `image/svg+xml`, `image/tiff` + +--- + +## 10. 分片上传 (ChunkedUpload) + +**基础路径**: `/api/v1/upload/chunk` + +适用于大文件上传(最大 2GB)。 + +### POST /init + +初始化分片上传。 + +**认证**: 是 + +**请求 Body**: +```json +{ + "filename": "large_video.mp4", + "file_size": 1073741824, + "total_chunks": 200, + "content_type": "video/mp4", + "project_id": "项目ID", + "library_id": "素材库ID" +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| filename | string | 是 | 文件名(1-255字符) | +| file_size | integer | 是 | 文件大小(最大 2GB) | +| total_chunks | integer | 是 | 分片总数 | +| content_type | string | 否 | 文件 MIME 类型 | +| project_id | string | 是 | 项目 ID | +| library_id | string | 是 | 素材库 ID | + +**响应** `200 OK`: +```json +{ + "upload_id": "上传ID", + "chunk_size": 5242880, + "total_chunks": 200, + "filename": "large_video.mp4", + "expires_at": "2024-01-02T00:00:00Z" +} +``` + +--- + +### POST /{upload_id}/{chunk_index} + +上传单个分片。 + +**认证**: 是 + +**路径参数**: +| 参数 | 类型 | 说明 | +|------|------|------| +| upload_id | string | 上传 ID | +| chunk_index | integer | 分片索引(从 0 开始) | + +**请求体**: 二进制文件数据 + +**响应** `200 OK`: +```json +{ + "message": "Chunk uploaded successfully", + "chunk_index": 0, + "uploaded_chunks": 1, + "total_chunks": 200 +} +``` + +--- + +### GET /{upload_id}/status + +获取上传状态(支持断点续传)。 + +**认证**: 是 + +**路径参数**: +| 参数 | 类型 | 说明 | +|------|------|------| +| upload_id | string | 上传 ID | + +**响应** `200 OK`: +```json +{ + "upload_id": "上传ID", + "filename": "large_video.mp4", + "file_size": 1073741824, + "total_chunks": 200, + "uploaded_chunks": [0, 1, 2, 3], + "status": "uploading", + "created_at": "2024-01-01T00:00:00Z", + "expires_at": "2024-01-02T00:00:00Z" +} +``` + +--- + +### POST /{upload_id}/complete + +完成分片上传,合并文件并创建导入任务。 + +**认证**: 是 + +**路径参数**: +| 参数 | 类型 | 说明 | +|------|------|------| +| upload_id | string | 上传 ID | + +**请求 Body**: +```json +{ + "project_id": "项目ID", + "library_id": "素材库ID" +} +``` + +**响应** `200 OK`: +```json +{ + "storage_key": "uploads/xxx/large_video.mp4", + "ingest_job_id": "导入任务ID", + "url": "https://oss.example.com/uploads/xxx/large_video.mp4" +} +``` + +--- + +## 11. 生成任务 (Generation) + +**基础路径**: `/api/v1/generation` + +### POST /tasks + +创建视频生成任务。 + +**认证**: 是 + +**请求 Body**: +```json +{ + "project_id": "项目ID", + "asset_library_id": "素材库ID", + "strategy_id": "", + "voice_library_id": "" +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| project_id | string | 是 | 项目 ID | +| asset_library_id | string | 是 | 素材库 ID | +| strategy_id | string | 否 | 生成策略 ID | +| voice_library_id | string | 否 | 配音库 ID | + +**响应** `200 OK`: +```json +{ + "id": "任务ID", + "project_id": "项目ID", + "asset_library_id": "素材库ID", + "strategy_id": "", + "voice_library_id": "", + "status": "pending", + "progress": 0.0, + "result_count": 0, + "error_message": "" +} +``` + +--- + +### GET /tasks/{task_id} + +获取生成任务详情。 + +**认证**: 是 + +**路径参数**: +| 参数 | 类型 | 说明 | +|------|------|------| +| task_id | string | 任务 ID | + +**响应** `200 OK`: 返回任务详情(同上) + +--- + +### GET /tasks/{task_id}/results + +获取生成任务的成片列表。 + +**认证**: 是 + +**路径参数**: +| 参数 | 类型 | 说明 | +|------|------|------| +| task_id | string | 任务 ID | + +**响应** `200 OK`: +```json +{ + "items": [ + { + "id": "成片ID", + "project_id": "项目ID", + "generation_task_id": "任务ID", + "name": "成片名称.mp4", + "file_url": "uploads/xxx/output.mp4", + "file_size": 5242880, + "duration": 30.0, + "thumbnail_url": "https://...", + "width": 1920, + "height": 1080, + "fps": 30.0, + "status": "completed", + "review_status": "pending_review", + "generation_params": {}, + "download_url": "https://..." + } + ] +} +``` + +--- + +## 12. 生成视频 (GeneratedVideo) + +**基础路径**: `/api/v1/generated-videos` + +### GET / + +获取成片列表。 + +**认证**: 是 + +**查询参数**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| project_id | string | 否 | 按项目 ID 筛选 | + +**响应** `200 OK`: +```json +{ + "items": [ + { + "id": "成片ID", + "project_id": "项目ID", + "generation_task_id": "任务ID", + "name": "成片名称.mp4", + "file_url": "uploads/xxx/output.mp4", + "file_size": 5242880, + "duration": 30.0, + "thumbnail_url": "https://...", + "width": 1920, + "height": 1080, + "fps": 30.0, + "status": "completed", + "review_status": "pending_review", + "generation_params": {}, + "download_url": "https://..." + } + ] +} +``` + +--- + +### GET /{video_id} + +获取成片详情。 + +**认证**: 是 + +**路径参数**: +| 参数 | 类型 | 说明 | +|------|------|------| +| video_id | string | 成片 ID | + +**响应** `200 OK`: 返回成片详情 + +--- + +### PATCH /{video_id}/review + +更新成片审核状态。 + +**认证**: 是 + +**路径参数**: +| 参数 | 类型 | 说明 | +|------|------|------| +| video_id | string | 成片 ID | + +**请求 Body**: +```json +{ + "review_status": "pending_review | approved | rejected" +} +``` + +**响应** `200 OK`: 返回更新后的成片对象 + +--- + +### GET /{video_id}/download-url + +获取成片下载链接。 + +**认证**: 是 + +**路径参数**: +| 参数 | 类型 | 说明 | +|------|------|------| +| video_id | string | 成片 ID | + +**响应** `200 OK`: +```json +{ + "video_id": "成片ID", + "download_url": "https://oss.example.com/..." +} +``` + +--- + +## 13. 标题库 (TitleLibrary) + +**基础路径**: `/api/v1/titles` + +### GET / + +获取标题列表。 + +**认证**: 是 + +**查询参数**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| category | string | 否 | 按分类筛选 | +| skip | integer | 否 | 跳过数量(默认 0) | +| limit | integer | 否 | 返回数量(默认 50,最大 200) | + +**响应** `200 OK`: +```json +{ + "items": [ + { + "id": "标题ID", + "user_id": "用户ID", + "name": "标题名称", + "text": "标题文本内容", + "category": "default", + "description": "描述", + "tags": ["标签1", "标签2"], + "usage_count": 5, + "is_active": true, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z" + } + ], + "total": 100 +} +``` + +--- + +### GET /{title_id} + +获取标题详情。 + +**认证**: 是 + +**路径参数**: +| 参数 | 类型 | 说明 | +|------|------|------| +| title_id | string | 标题 ID | + +**响应** `200 OK`: 返回标题详情 + +--- + +### POST / + +创建标题。 + +**认证**: 是 + +**请求 Body**: +```json +{ + "name": "标题名称", + "text": "标题文本内容", + "category": "default", + "description": "描述", + "tags": ["标签1", "标签2"] +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| name | string | 是 | 标题名称(1-255字符) | +| text | string | 是 | 标题文本(1-500字符) | +| category | string | 否 | 分类(默认 default) | +| description | string | 否 | 描述 | +| tags | array | 否 | 标签列表 | + +**响应** `201 Created`: 返回创建的标题对象 + +**配额限制**: +- free: 10 条 +- standard: 50 条 +- pro: 200 条 +- enterprise: 无限制 + +--- + +### PUT /{title_id} + +更新标题。 + +**认证**: 是 + +**路径参数**: +| 参数 | 类型 | 说明 | +|------|------|------| +| title_id | string | 标题 ID | + +**请求 Body**: +```json +{ + "name": "新标题名称", + "text": "新标题文本", + "category": "new_category", + "description": "新描述", + "tags": ["新标签"] +} +``` + +**响应** `200 OK`: 返回更新后的标题对象 + +--- + +### DELETE /{title_id} + +删除标题。 + +**认证**: 是 + +**路径参数**: +| 参数 | 类型 | 说明 | +|------|------|------| +| title_id | string | 标题 ID | + +**响应** `204 No Content` + +--- + +## 14. 配音库 (VoiceLibrary) + +**基础路径**: `/api/v1/voices` + +### GET / + +获取配音列表。 + +**认证**: 是 + +**查询参数**: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| status | string | 否 | 按状态筛选 | +| skip | integer | 否 | 跳过数量(默认 0) | +| limit | integer | 否 | 返回数量(默认 50,最大 200) | + +**响应** `200 OK`: +```json +{ + "items": [ + { + "id": "配音ID", + "user_id": "用户ID", + "name": "配音名称", + "text": "配音文本", + "voice_provider": "elevenlabs", + "voice_id": "voice_xxx", + "voice_name": "中文男声", + "audio_url": "https://...", + "duration": 10.5, + "file_size": 102400, + "status": "completed", + "project_id": null, + "tags": ["旁白"], + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z" + } + ], + "total": 20 +} +``` + +--- + +### GET /{voice_id} + +获取配音详情。 + +**认证**: 是 + +**路径参数**: +| 参数 | 类型 | 说明 | +|------|------|------| +| voice_id | string | 配音 ID | + +**响应** `200 OK`: 返回配音详情 + +--- + +### POST / + +创建配音。 + +**认证**: 是 + +**请求 Body**: +```json +{ + "name": "配音名称", + "text": "配音文本内容", + "voice_provider": "elevenlabs", + "voice_id": "voice_xxx", + "voice_name": "中文男声", + "audio_url": "https://...", + "duration": 10.5, + "file_size": 102400, + "status": "completed", + "project_id": null, + "tags": ["旁白"] +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| name | string | 是 | 配音名称(1-255字符) | +| text | string | 否 | 配音文本 | +| voice_provider | string | 否 | 语音服务商 | +| voice_id | string | 否 | 语音 ID | +| voice_name | string | 否 | 语音名称 | +| audio_url | string | 否 | 音频 URL | +| duration | float | 否 | 时长(秒) | +| file_size | integer | 否 | 文件大小 | +| status | string | 否 | 状态(默认 completed) | +| project_id | string | 否 | 关联项目 ID | +| tags | array | 否 | 标签列表 | + +**响应** `201 Created`: 返回创建的配音对象 + +--- + +### PUT /{voice_id} + +更新配音。 + +**认证**: 是 + +**路径参数**: +| 参数 | 类型 | 说明 | +|------|------|------| +| voice_id | string | 配音 ID | + +**请求 Body**: 同创建(所有字段可选) + +**响应** `200 OK`: 返回更新后的配音对象 + +--- + +### DELETE /{voice_id} + +删除配音。 + +**认证**: 是 + +**路径参数**: +| 参数 | 类型 | 说明 | +|------|------|------| +| voice_id | string | 配音 ID | + +**响应** `204 No Content` + +--- + +## 15. 查重 (Duplication) + +**基础路径**: `/api/v1/duplication` + +### POST /upload + +上传视频进行查重检测。 + +**认证**: 是 + +**请求**: multipart/form-data + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| file | file | 是 | 视频文件 | + +**响应** `200 OK`: +```json +{ + "id": "查重记录ID", + "status": "pending", + "message": "文件 \"video.mp4\" 已上传,正在查重中..." +} +``` + +**限制**: +- 仅支持视频文件: `mp4`, `mpeg`, `mov`, `avi`, `webm`, `mkv`, `3gp` +- 最大文件大小: 100MB + +--- + +### GET /records + +获取查重记录列表。 + +**认证**: 是 + +**响应** `200 OK`: +```json +[ + { + "id": "记录ID", + "filename": "video.mp4", + "file_size": 5242880, + "duration_seconds": 30.0, + "status": "completed", + "duplicate_rate": 0.15, + "duplicate_count": 2, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:01:00Z" + } +] +``` + +--- + +### GET /records/{record_id} + +获取查重记录详情(含重复片段)。 + +**认证**: 是 + +**路径参数**: +| 参数 | 类型 | 说明 | +|------|------|------| +| record_id | string | 查重记录 ID | + +**响应** `200 OK`: +```json +{ + "id": "记录ID", + "filename": "video.mp4", + "file_size": 5242880, + "duration_seconds": 30.0, + "status": "completed", + "duplicate_rate": 0.15, + "duplicate_count": 2, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:01:00Z", + "segments": [ + { + "id": "片段ID", + "source_start": 5.0, + "source_end": 10.5, + "matched_video_id": "视频ID", + "matched_video_name": "匹配视频名称", + "matched_start": 0.0, + "matched_end": 5.5, + "similarity": 0.95 + } + ] +} +``` + +--- + +### DELETE /records/{record_id} + +删除查重记录。 + +**认证**: 是 + +**路径参数**: +| 参数 | 类型 | 说明 | +|------|------|------| +| record_id | string | 查重记录 ID | + +**响应** `204 No Content` + +--- + +### POST /records/{record_id}/retry + +重新提交查重。 + +**认证**: 是 + +**路径参数**: +| 参数 | 类型 | 说明 | +|------|------|------| +| record_id | string | 查重记录 ID | + +**响应** `200 OK`: +```json +{ + "id": "记录ID", + "status": "pending", + "message": "已重新提交查重" +} +``` + +--- + +## 16. 订阅 (Subscription) + +**基础路径**: `/api/v1/subscription` + +### GET /current + +获取当前订阅信息。 + +**认证**: 是 + +**响应** `200 OK`: +```json +{ + "id": "sub-xxx", + "plan_id": "pro", + "plan_name": "专业版", + "status": "active", + "billing_cycle": "monthly", + "current_period_start": "2024-01-01T00:00:00Z", + "current_period_end": "2024-02-01T00:00:00Z", + "amount": 299.0, + "auto_renew": true, + "created_at": "2024-01-01T00:00:00Z" +} +``` + +--- + +### GET /billing-records + +获取账单记录列表。 + +**认证**: 是 + +**响应** `200 OK`: +```json +[] +``` + +--- + +### POST /change-plan + +变更订阅套餐(升级/降级)。 + +**认证**: 是 + +**请求 Body**: +```json +{ + "target_plan_id": "pro", + "billing_cycle": "monthly" +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| target_plan_id | string | 是 | 目标套餐: `free`, `standard`, `pro`, `enterprise` | +| billing_cycle | string | 是 | 计费周期: `monthly`, `yearly` | + +**响应** `200 OK`: +```json +{ + "success": true, + "message": "套餐已成功变更为 专业版", + "new_subscription": { ... } +} +``` + +--- + +### POST /cancel + +取消订阅。 + +**认证**: 是 + +**响应** `200 OK`: +```json +{ + "success": true, + "message": "订阅已取消,当前周期结束后停止服务" +} +``` + +--- + +### POST /toggle-auto-renew + +切换自动续费。 + +**认证**: 是 + +**请求 Body**: +```json +{ + "enabled": false +} +``` + +**响应** `200 OK`: +```json +{ + "success": true, + "message": "已关闭自动续费" +} +``` + +--- + +## 附录 A: 套餐配额 + +| 套餐 | 最大项目数 | 存储空间 | 标题库配额 | 配音库配额 | +|------|-----------|----------|-----------|-----------| +| free | 3 | 10 GB | 10 条 | 5 条 | +| standard | 10 | 50 GB | 50 条 | 20 条 | +| pro | 无限制 | 100 GB | 200 条 | 100 条 | +| enterprise | 无限制 | 1000 GB | 无限制 | 无限制 | + +--- + +## 附录 B: 套餐价格 + +| 套餐 | 月付 | 年付 | +|------|------|------| +| free | ¥0 | ¥0 | +| standard | ¥99 | ¥999 | +| pro | ¥299 | ¥2999 | +| enterprise | ¥999 | ¥9999 | + +--- + +*文档版本: v0.1.88 | 更新于: 2026-06-29* diff --git a/packages/adapters/sqlalchemy_impl/models.py b/packages/adapters/sqlalchemy_impl/models.py index b8c877099..f9ded57a3 100755 --- a/packages/adapters/sqlalchemy_impl/models.py +++ b/packages/adapters/sqlalchemy_impl/models.py @@ -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) + diff --git a/packages/adapters/sqlalchemy_impl/recipe_repository.py b/packages/adapters/sqlalchemy_impl/recipe_repository.py new file mode 100644 index 000000000..3397e4439 --- /dev/null +++ b/packages/adapters/sqlalchemy_impl/recipe_repository.py @@ -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 {}, + ) diff --git a/packages/application/recipe/__init__.py b/packages/application/recipe/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/packages/application/recipe/commands.py b/packages/application/recipe/commands.py new file mode 100644 index 000000000..fe7535fd1 --- /dev/null +++ b/packages/application/recipe/commands.py @@ -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 diff --git a/packages/application/recipe/use_cases.py b/packages/application/recipe/use_cases.py new file mode 100644 index 000000000..0eba4bfe3 --- /dev/null +++ b/packages/application/recipe/use_cases.py @@ -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) diff --git a/packages/domain/recipe.py b/packages/domain/recipe.py new file mode 100644 index 000000000..c43c550ca --- /dev/null +++ b/packages/domain/recipe.py @@ -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)) diff --git a/packages/infrastructure/feature_flags.py b/packages/infrastructure/feature_flags.py index cefe46eb8..84e0cf681 100644 --- a/packages/infrastructure/feature_flags.py +++ b/packages/infrastructure/feature_flags.py @@ -112,6 +112,7 @@ class FeatureFlags: name=FeatureScope.RECIPE_REUSE, description="配方复用功能", global_enabled=True, + plan_overrides={"free": False}, # 仅基础版和高级版可用 ), ] for flag in defaults: diff --git a/packages/ports/recipe_repository.py b/packages/ports/recipe_repository.py new file mode 100644 index 000000000..a17f7cff2 --- /dev/null +++ b/packages/ports/recipe_repository.py @@ -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: + ... diff --git a/tests/unit/test_recipe_use_cases.py b/tests/unit/test_recipe_use_cases.py new file mode 100644 index 000000000..ac6aeacea --- /dev/null +++ b/tests/unit/test_recipe_use_cases.py @@ -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")