9c6c477f55
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 2m22s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 2m24s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 37s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1561h35m7s
CI/CD Pipeline / Build Production Runtime Images (pull_request) Failing after 1561h35m8s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1561h35m7s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Failing after 1561h35m9s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1561h35m7s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1562h6m43s
P0 关键修复: - P0-1: 注册接口添加 RateLimitMiddleware 限流保护 - P0-3: /metrics 端点添加 JWT 认证(移除匿名访问) - P0-4: 修复 Celery 任务名冲突(generation_task vs generate_video) - P1-5: JWT logout token 黑名单机制 P1 修复: - P1-1: forgot_password 硬编码 localhost → 使用 settings.APP_BASE_URL - P1-2: generation.py 直接创建 DB 连接 → 使用依赖注入 - P1-6: Image.open() 未关闭 → 统一使用 with 语句 - P1-7: 订阅续费事务修复 P2 代码质量: - P2-1: 修复 EditingMode 枚举重复定义 → 统一引用 shared 包 - P2-2: 修复 SMTP_FRON_NAME → SMTP_FROM_NAME 拼写 - P2-3: UserModel subscription_quota 类型统一为 float - P2-4: .env.production DATABASE_MAX_OVERFLOW 30 → 10 - 清理 15 处 except:pass(保留 2 处有注释说明的) - 禁用 SVG 上传(XSS 风险) - 删除 decode_token_unsafe() 不安全函数 - 简化 /ready 端点 - 删除 8 处死代码、10 个空文件/模块 - 合并 3 对 100% 重复函数 - 对齐 6 个废弃环境变量 v2 修复(代码审查后): - 修复密码重置路由路径: /password/forgot → /forgot-password, /password/reset → /reset-password(与前端 API 对齐) - 合并 _check_project_access: asset_libraries.py 和 edit_plans.py 中的重复函数统一到 _helpers.py(含空字符串守卫 + 中文错误信息) - 顺手修复: HTTPException 统一从 fastapi 导入(替换 starlette 导入) - OSS_ENDPOINT 拼写修复拆分为单独 PR,本 PR 不包含
208 lines
7.0 KiB
Python
208 lines
7.0 KiB
Python
"""Recipe CRUD + use routes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
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 fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
|
from sqlalchemy.orm import Session
|
|
|
|
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
|
|
|
|
from app.api.routes._helpers import get_user_plan
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _get_recipe_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyRecipeRepository:
|
|
return SQLAlchemyRecipeRepository(session)
|
|
|
|
|
|
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_model=None)
|
|
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],
|
|
)
|