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")