881eea9195
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 36s
CI/CD Pipeline / Frontend Lint (push) Successful in 51s
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 56s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 9s
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m50s
fix(api+ci): DELETE 204响应体全修复 + isort/black整理 + CI环境兼容 + 测试修复
299 lines
11 KiB
Python
299 lines
11 KiB
Python
import logging
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
from app.api.routes._helpers import require_project_and_library
|
|
from app.auth import AuthenticatedUser, get_current_user
|
|
from app.config import get_settings
|
|
from app.core.celery_app import celery_app
|
|
from app.core.storage import OSSStorageService, get_storage_service
|
|
from app.dependencies import (
|
|
get_asset_library_repository,
|
|
get_asset_repository,
|
|
get_ingest_job_repository,
|
|
get_project_repository,
|
|
)
|
|
from app.schemas.upload import (
|
|
DirectUploadCompleteRequest,
|
|
DirectUploadCompleteResponse,
|
|
DirectUploadPrepareRequest,
|
|
DirectUploadPrepareResponse,
|
|
UploadAssetResponse,
|
|
)
|
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
|
|
|
from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
# 允许上传的文件 MIME 类型
|
|
ALLOWED_MIME_TYPES = frozenset(
|
|
{
|
|
# 视频
|
|
"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",
|
|
"audio/webm",
|
|
# 图片
|
|
"image/jpeg",
|
|
"image/png",
|
|
"image/gif",
|
|
"image/webp",
|
|
"image/bmp",
|
|
"image/tiff",
|
|
"image/svg+xml",
|
|
}
|
|
)
|
|
|
|
|
|
def _validate_mime_type(content_type: str | None) -> str:
|
|
"""验证并返回标准化的 MIME 类型,如果无效则抛出异常。"""
|
|
if not content_type:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Content-Type header is required",
|
|
)
|
|
|
|
# 处理带参数的类型,如 "video/mp4; charset=utf-8"
|
|
base_type = content_type.split(";")[0].strip().lower()
|
|
|
|
if base_type not in ALLOWED_MIME_TYPES:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
|
detail=f"File type '{base_type}' is not supported. Allowed types: video, audio, and image files.",
|
|
)
|
|
|
|
return base_type
|
|
|
|
|
|
def _submit_ingest_job(
|
|
project_id: str,
|
|
library_id: str,
|
|
storage_key: str,
|
|
ingest_job_repository: Any,
|
|
file_hash: str = "",
|
|
) -> Any:
|
|
use_case = SubmitIngestJobUseCase(ingest_job_repository)
|
|
job = use_case.execute(
|
|
SubmitIngestJobCommand(
|
|
project_id=project_id,
|
|
library_id=library_id,
|
|
storage_key=storage_key,
|
|
file_hash=file_hash,
|
|
)
|
|
)
|
|
celery_app.send_task("worker.ingest_asset", args=[job.id])
|
|
return job
|
|
|
|
|
|
@router.post("/direct/prepare", response_model=DirectUploadPrepareResponse)
|
|
async def prepare_direct_upload(
|
|
request: DirectUploadPrepareRequest,
|
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
project_repository: Any = Depends(get_project_repository),
|
|
asset_library_repository: Any = Depends(get_asset_library_repository),
|
|
storage_service: OSSStorageService = Depends(get_storage_service),
|
|
) -> DirectUploadPrepareResponse:
|
|
"""创建浏览器直传 OSS 的短期表单签名。"""
|
|
settings = get_settings()
|
|
max_size_bytes = settings.OSS_DIRECT_UPLOAD_MAX_MB * 1024 * 1024
|
|
if request.file_size > max_size_bytes:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
|
detail=f"File exceeds upload limit ({settings.OSS_DIRECT_UPLOAD_MAX_MB}MB)",
|
|
)
|
|
|
|
# P2-5: 服务端验证 MIME 类型
|
|
validated_content_type = _validate_mime_type(request.content_type)
|
|
|
|
require_project_and_library(
|
|
request.project_id,
|
|
request.library_id,
|
|
project_repository,
|
|
asset_library_repository,
|
|
)
|
|
|
|
file_id = uuid4().hex[:8]
|
|
safe_filename = request.filename.replace("/", "_").replace("\\", "_")
|
|
storage_key = f"uploads/{file_id}/{safe_filename}"
|
|
try:
|
|
payload = storage_service.create_direct_upload_post(
|
|
storage_key=storage_key,
|
|
content_type=validated_content_type,
|
|
max_size_bytes=max_size_bytes,
|
|
expires_seconds=settings.OSS_DIRECT_UPLOAD_EXPIRE_SECONDS,
|
|
)
|
|
except RuntimeError as error:
|
|
logger.error("OSS not configured for direct upload prepare: %s", error)
|
|
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(error)) from error
|
|
except Exception as error:
|
|
logger.exception("Unexpected error in direct upload prepare: %s", error)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to prepare upload: {type(error).__name__}",
|
|
) from error
|
|
|
|
return DirectUploadPrepareResponse(
|
|
upload_url=str(payload["url"]),
|
|
method=str(payload["method"]),
|
|
storage_key=str(payload["storage_key"]),
|
|
expires_at=str(payload["expires_at"]),
|
|
fields={str(key): str(value) for key, value in dict(payload["fields"]).items()},
|
|
max_size_bytes=max_size_bytes,
|
|
)
|
|
|
|
|
|
@router.post("/direct/complete", response_model=DirectUploadCompleteResponse)
|
|
async def complete_direct_upload(
|
|
request: DirectUploadCompleteRequest,
|
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
|
project_repository: Any = Depends(get_project_repository),
|
|
asset_library_repository: Any = Depends(get_asset_library_repository),
|
|
asset_repository: Any = Depends(get_asset_repository),
|
|
storage_service: OSSStorageService = Depends(get_storage_service),
|
|
) -> DirectUploadCompleteResponse:
|
|
"""确认浏览器直传完成并创建导入任务。"""
|
|
require_project_and_library(
|
|
request.project_id,
|
|
request.library_id,
|
|
project_repository,
|
|
asset_library_repository,
|
|
)
|
|
normalized_key = storage_service._normalize_storage_key(request.storage_key)
|
|
if not normalized_key.startswith("uploads/"):
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid upload key")
|
|
try:
|
|
file_exists = storage_service.file_exists(normalized_key)
|
|
except Exception as error:
|
|
logger.exception("OSS error checking file existence for key=%s: %s", normalized_key, error)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="Storage service unavailable",
|
|
) from error
|
|
if not file_exists:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Uploaded file not found")
|
|
|
|
# ── 素材去重检测:同素材库 + 同 file_hash 视为重复 ──
|
|
if request.file_hash:
|
|
existing = asset_repository.find_by_library_and_file_hash(
|
|
library_id=request.library_id,
|
|
file_hash=request.file_hash,
|
|
)
|
|
if existing is not None:
|
|
logger.info(
|
|
"素材去重命中: library=%s hash=%s existing_asset=%s",
|
|
request.library_id,
|
|
request.file_hash,
|
|
existing.id,
|
|
)
|
|
return DirectUploadCompleteResponse(
|
|
storage_key=normalized_key,
|
|
ingest_job_id="",
|
|
duplicated=True,
|
|
asset_id=existing.id,
|
|
)
|
|
|
|
job = _submit_ingest_job(
|
|
project_id=request.project_id,
|
|
library_id=request.library_id,
|
|
storage_key=normalized_key,
|
|
ingest_job_repository=ingest_job_repository,
|
|
file_hash=request.file_hash,
|
|
)
|
|
return DirectUploadCompleteResponse(storage_key=normalized_key, ingest_job_id=job.id)
|
|
|
|
|
|
@router.post(
|
|
"",
|
|
response_model=UploadAssetResponse,
|
|
summary="Upload Asset",
|
|
description="上传素材文件(multipart/form-data),支持视频、音频、图片。触发导入流水线自动处理。",
|
|
)
|
|
async def upload_asset(
|
|
project_id: str = Form(..., min_length=1, description="项目 ID"),
|
|
library_id: str = Form(..., min_length=1, description="素材库 ID"),
|
|
file: UploadFile = File(..., description="要上传的文件(视频、音频、图片等)"),
|
|
file_hash: str = Form(default="", description="文件 MD5 哈希,用于去重检测"),
|
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
|
project_repository: Any = Depends(get_project_repository),
|
|
asset_library_repository: Any = Depends(get_asset_library_repository),
|
|
asset_repository: Any = Depends(get_asset_repository),
|
|
storage_service: OSSStorageService = Depends(get_storage_service),
|
|
) -> UploadAssetResponse:
|
|
"""上传素材文件并触发导入流水线。"""
|
|
require_project_and_library(project_id, library_id, project_repository, asset_library_repository)
|
|
|
|
# ── 素材去重检测:上传前检查同素材库 + 同 file_hash ──
|
|
if file_hash:
|
|
existing = asset_repository.find_by_library_and_file_hash(
|
|
library_id=library_id,
|
|
file_hash=file_hash,
|
|
)
|
|
if existing is not None:
|
|
logger.info(
|
|
"素材去重命中(multipart): library=%s hash=%s existing_asset=%s",
|
|
library_id,
|
|
file_hash,
|
|
existing.id,
|
|
)
|
|
return UploadAssetResponse(
|
|
storage_key=existing.storage_key,
|
|
ingest_job_id="",
|
|
url="",
|
|
duplicated=True,
|
|
asset_id=existing.id,
|
|
)
|
|
|
|
# P2-5: 服务端验证 MIME 类型
|
|
validated_content_type = _validate_mime_type(file.content_type)
|
|
|
|
file_id = uuid4().hex[:8]
|
|
safe_filename = file.filename.replace("/", "_").replace("\\", "_") if file.filename else "unknown"
|
|
storage_key = f"uploads/{file_id}/{safe_filename}"
|
|
|
|
try:
|
|
file_url = storage_service.upload_file(
|
|
file.file,
|
|
storage_key,
|
|
content_type=validated_content_type,
|
|
)
|
|
except RuntimeError as error:
|
|
logger.error("OSS not configured for upload: %s", error)
|
|
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(error)) from error
|
|
except Exception as error:
|
|
logger.exception("Unexpected error uploading file to OSS: %s", error)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to upload file: {type(error).__name__}",
|
|
) from error
|
|
|
|
job = _submit_ingest_job(
|
|
project_id=project_id,
|
|
library_id=library_id,
|
|
storage_key=storage_key,
|
|
ingest_job_repository=ingest_job_repository,
|
|
file_hash=file_hash,
|
|
)
|
|
|
|
return UploadAssetResponse(
|
|
storage_key=storage_key,
|
|
ingest_job_id=job.id,
|
|
url=file_url,
|
|
)
|