Files
video-gen/video-gen-api/app/api/v3/videos.py
T
root f5d7cfadb4 1、图片生成同步接口超时风险
2、图片返回的 URL 是本地路径
3、视频生成中媒体文件重复下载
4、幂等性检查无数据库唯一约束
5、幂等键冲突返回 409 改为返回已有任务信息
6、虚拟素材库配额校验 TOCTOU
7、项目级联删除与独立素材删除任务并发冲突
2026-08-06 18:57:14 +08:00

168 lines
5.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import logging
import time
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db
from app.models.api.api_generation_task import ApiGenerationTask
from app.schemas.api_v3.video import (
ApiVideoCreateRequest,
ApiVideoCreateResponse,
ApiVideoStatusResponse,
)
from app.services.api_v3 import auth_service, generation_service, task_service
from app.services.resource_signed_url_service import build_resource_signed_url
logger = logging.getLogger("videogen")
router = APIRouter(prefix="/videos", tags=["api-v3-videos"])
async def _validate_request(
db: AsyncSession,
key_context: auth_service.ApiKeyContext,
req: ApiVideoCreateRequest,
) -> ApiGenerationTask | None:
"""请求层校验:参数、权限、幂等性。
Returns:
None = 校验通过,继续创建
ApiGenerationTask = 幂等请求,返回已有任务
"""
# 模型权限校验
allowed_model_names = {m.get("model_name", "") for m in key_context.callable_models}
if allowed_model_names and req.model not in allowed_model_names:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"无权使用模型 {req.model}",
)
# 幂等性检查
if req.idempotency_key:
result = await db.execute(
select(ApiGenerationTask).where(
ApiGenerationTask.api_key_id == key_context.api_key.id,
ApiGenerationTask.external_idempotency_key == req.idempotency_key,
ApiGenerationTask.deleted_at.is_(None),
).limit(1)
)
existing_task = result.scalar_one_or_none()
if existing_task:
logger.info(
"Idempotent request: returning existing task %s for key=%s",
existing_task.id, req.idempotency_key,
)
return existing_task
return None
def _map_status(internal_status: str) -> str:
"""将内部状态映射为 API 状态。"""
status_map = {
"pending": "queued",
"queued": "queued",
"generating": "running",
"processing": "running",
"completed": "succeeded",
"failed": "failed",
"timeout": "expired",
}
return status_map.get(internal_status, internal_status)
@router.post(
"",
response_model=ApiVideoCreateResponse,
summary="创建视频生成任务",
)
async def create_video(
req: ApiVideoCreateRequest,
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
db: AsyncSession = Depends(get_db),
) -> ApiVideoCreateResponse:
"""创建视频生成任务(异步)。
幂等性说明:如果 idempotency_key 已存在,直接返回已有任务 ID(不会重复创建)。
"""
try:
# 路由层校验:权限、幂等性
existing_task = await _validate_request(db, key_context, req)
if existing_task:
logger.info(
"Idempotent request: returning existing task %s for key=%s",
existing_task.id, req.idempotency_key,
)
return ApiVideoCreateResponse(id=f"zc-{existing_task.id}")
# 调用服务层创建任务
result = await generation_service.submit_video_generation(
db=db,
key=key_context.api_key,
callable_models=key_context.callable_models,
req=req,
)
return ApiVideoCreateResponse(id=f"zc-{result.id}")
except HTTPException:
raise
except Exception as exc:
logger.exception("API video creation failed")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"创建视频任务失败: {str(exc)[:200]}",
)
@router.get(
"/{task_id}",
response_model=ApiVideoStatusResponse,
summary="查询视频任务状态",
)
async def get_video_status(
task_id: str,
key_context: auth_service.ApiKeyContext = Depends(auth_service.get_api_key_dependency),
db: AsyncSession = Depends(get_db),
) -> ApiVideoStatusResponse:
"""查询视频任务状态。"""
# 去掉 zc- 前缀
if task_id.startswith("zc-"):
task_id = task_id[3:]
task = await task_service.get_task(db, task_id, key_context.api_key.id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"任务 {task_id} 不存在或不属于当前 API Key",
)
now = int(time.time())
# 构建 content(成功时返回完整视频URL,包含 BASE_URL
content = None
if task.status == "completed" and task.video_url:
from app.schemas.api_v3.video import ApiVideoContent
from app.config import settings
# 拼接完整 URL
video_url = build_resource_signed_url(task.video_url)
if video_url and not video_url.startswith(("http://", "https://")):
base = settings.BASE_URL.rstrip("/")
if video_url.startswith("/"):
video_url = f"{base}{video_url}"
else:
video_url = f"{base}/{video_url}"
content = ApiVideoContent(video_url=video_url)
return ApiVideoStatusResponse(
id=f"zc-{task.id}",
model=task.model_name,
status=_map_status(task.status),
created_at=int(task.created_at.timestamp()) if task.created_at else now,
updated_at=int(task.updated_at.timestamp()) if task.updated_at else now,
content=content,
duration=task.duration,
ratio=task.aspect_ratio,
resolution=task.resolution,
error=task.error_message if task.status in ("failed", "timeout") else None,
)