531aacb57e
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
304 lines
10 KiB
Python
304 lines
10 KiB
Python
"""查重 API 路由。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from typing import Any
|
||
from uuid import uuid4
|
||
|
||
from app.auth import AuthenticatedUser, get_current_user
|
||
from app.core.storage import OSSStorageService, get_storage_service
|
||
from app.dependencies import get_duplication_repository
|
||
from app.schemas.duplication import (
|
||
DuplicateSegmentResponse,
|
||
DuplicationDetailResponse,
|
||
DuplicationRecordResponse,
|
||
DuplicationUploadResponse,
|
||
)
|
||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile, status
|
||
|
||
from packages.application import (
|
||
DeleteDuplicationRecordUseCase,
|
||
GetDuplicationDetailUseCase,
|
||
ListDuplicationRecordsUseCase,
|
||
RetryDuplicationUseCase,
|
||
UploadForDuplicationCommand,
|
||
UploadForDuplicationUseCase,
|
||
)
|
||
from packages.domain.duplication import DuplicationRecord
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter(
|
||
tags=["查重"],
|
||
)
|
||
|
||
# 查重功能只接受视频文件
|
||
ALLOWED_VIDEO_MIME_TYPES = frozenset(
|
||
{
|
||
"video/mp4",
|
||
"video/mpeg",
|
||
"video/quicktime",
|
||
"video/x-msvideo",
|
||
"video/webm",
|
||
"video/x-matroska",
|
||
"video/3gpp",
|
||
}
|
||
)
|
||
|
||
|
||
def _validate_video_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_VIDEO_MIME_TYPES:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
||
detail="只支持视频文件。支持的类型: mp4, mpeg, mov, avi, webm, mkv, 3gp",
|
||
)
|
||
|
||
return base_type
|
||
|
||
|
||
def _to_record_response(record: DuplicationRecord) -> DuplicationRecordResponse:
|
||
return DuplicationRecordResponse(
|
||
id=record.id,
|
||
filename=record.filename,
|
||
file_size=record.file_size,
|
||
duration_seconds=record.duration_seconds,
|
||
status=record.status,
|
||
duplicate_rate=record.duplicate_rate,
|
||
duplicate_count=record.duplicate_count,
|
||
created_at=record.created_at.isoformat(),
|
||
updated_at=record.updated_at.isoformat(),
|
||
)
|
||
|
||
|
||
def _to_detail_response(record: DuplicationRecord) -> DuplicationDetailResponse:
|
||
return DuplicationDetailResponse(
|
||
id=record.id,
|
||
filename=record.filename,
|
||
file_size=record.file_size,
|
||
duration_seconds=record.duration_seconds,
|
||
status=record.status,
|
||
duplicate_rate=record.duplicate_rate,
|
||
duplicate_count=record.duplicate_count,
|
||
created_at=record.created_at.isoformat(),
|
||
updated_at=record.updated_at.isoformat(),
|
||
segments=[
|
||
DuplicateSegmentResponse(
|
||
id=seg.id,
|
||
source_start=seg.source_start,
|
||
source_end=seg.source_end,
|
||
matched_video_id=seg.matched_video_id,
|
||
matched_video_name=seg.matched_video_name,
|
||
matched_start=seg.matched_start,
|
||
matched_end=seg.matched_end,
|
||
similarity=seg.similarity,
|
||
)
|
||
for seg in record.segments
|
||
],
|
||
)
|
||
|
||
|
||
@router.post("/upload", response_model=DuplicationUploadResponse)
|
||
async def upload_for_duplication(
|
||
file: UploadFile = File(..., description="要查重的视频文件"),
|
||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||
duplication_repository: Any = Depends(get_duplication_repository),
|
||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||
) -> DuplicationUploadResponse:
|
||
"""上传视频进行查重。"""
|
||
if file.filename is None:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="文件名不能为空",
|
||
)
|
||
|
||
# P0-1: 验证 MIME 类型(只接受视频文件)
|
||
validated_content_type = _validate_video_mime_type(file.content_type)
|
||
|
||
# P0-2: 验证文件大小(参考 OSS_DIRECT_UPLOAD_MAX_MB)
|
||
from app.config import get_settings
|
||
|
||
settings = get_settings()
|
||
max_size_bytes = settings.OSS_DIRECT_UPLOAD_MAX_MB * 1024 * 1024
|
||
|
||
# 先检查 Content-Length header(如果可用)
|
||
if file.size is not None and file.size > max_size_bytes:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||
detail=f"文件超过上传限制 ({settings.OSS_DIRECT_UPLOAD_MAX_MB}MB)",
|
||
)
|
||
|
||
# 读取文件内容并上传到 OSS
|
||
file_id = uuid4().hex[:8]
|
||
safe_filename = file.filename.replace("/", "_").replace("\\", "_")
|
||
storage_key = f"duplication/{file_id}/{safe_filename}"
|
||
|
||
try:
|
||
content = await file.read()
|
||
file_size = len(content)
|
||
|
||
# 再次检查实际文件大小
|
||
if file_size > max_size_bytes:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||
detail=f"文件超过上传限制 ({settings.OSS_DIRECT_UPLOAD_MAX_MB}MB)",
|
||
)
|
||
except HTTPException:
|
||
raise
|
||
except Exception as exc:
|
||
logger.error("读取查重文件失败: %s", exc, exc_info=True)
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="文件读取失败,请稍后重试",
|
||
) from exc
|
||
|
||
try:
|
||
storage_service.upload_file(
|
||
content,
|
||
storage_key,
|
||
content_type=validated_content_type,
|
||
)
|
||
except Exception as exc:
|
||
logger.error("查重文件上传 OSS 失败: %s", exc, exc_info=True)
|
||
raise HTTPException(
|
||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||
detail="文件上传失败,请稍后重试",
|
||
) from exc
|
||
|
||
use_case = UploadForDuplicationUseCase(duplication_repository)
|
||
record = use_case.execute(
|
||
UploadForDuplicationCommand(
|
||
user_id=authenticated_user.user.id,
|
||
filename=file.filename,
|
||
file_size=file_size,
|
||
storage_key=storage_key,
|
||
)
|
||
)
|
||
|
||
logger.info(
|
||
"Duplication upload: record=%s file=%s user=%s",
|
||
record.id,
|
||
file.filename,
|
||
authenticated_user.user.id,
|
||
)
|
||
|
||
return DuplicationUploadResponse(
|
||
id=record.id,
|
||
status=record.status,
|
||
message=f'文件 "{file.filename}" 已上传,正在查重中...',
|
||
)
|
||
|
||
|
||
@router.get("/records", response_model=list[DuplicationRecordResponse])
|
||
def list_duplication_records(
|
||
offset: int = Query(0, ge=0, description="分页偏移量"),
|
||
limit: int = Query(50, ge=1, le=200, description="每页数量,最大 200"),
|
||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||
duplication_repository: Any = Depends(get_duplication_repository),
|
||
) -> list[DuplicationRecordResponse]:
|
||
"""
|
||
获取当前用户的查重记录列表。
|
||
|
||
支持分页:通过 offset 和 limit 参数控制。
|
||
返回按创建时间倒序排列的记录。
|
||
"""
|
||
use_case = ListDuplicationRecordsUseCase(duplication_repository)
|
||
records = use_case.execute(user_id=authenticated_user.user.id, offset=offset, limit=limit)
|
||
return [_to_record_response(r) for r in records]
|
||
|
||
|
||
@router.get("/records/{record_id}", response_model=DuplicationDetailResponse)
|
||
def get_duplication_detail(
|
||
record_id: str,
|
||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||
duplication_repository: Any = Depends(get_duplication_repository),
|
||
) -> DuplicationDetailResponse:
|
||
"""获取查重记录详情(含重复片段)。"""
|
||
use_case = GetDuplicationDetailUseCase(duplication_repository)
|
||
record = use_case.execute(record_id)
|
||
if record is None:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail=f"查重记录 {record_id} 不存在",
|
||
)
|
||
if record.user_id != authenticated_user.user.id:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail=f"查重记录 {record_id} 不存在",
|
||
)
|
||
return _to_detail_response(record)
|
||
|
||
|
||
@router.delete(
|
||
"/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response
|
||
)
|
||
def delete_duplication_record(
|
||
record_id: str,
|
||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||
duplication_repository: Any = Depends(get_duplication_repository),
|
||
) -> Response:
|
||
"""删除查重记录。"""
|
||
# 检查记录是否存在且属于当前用户
|
||
detail_uc = GetDuplicationDetailUseCase(duplication_repository)
|
||
record = detail_uc.execute(record_id)
|
||
if record is None or record.user_id != authenticated_user.user.id:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail=f"查重记录 {record_id} 不存在",
|
||
)
|
||
|
||
use_case = DeleteDuplicationRecordUseCase(duplication_repository)
|
||
use_case.execute(record_id)
|
||
return
|
||
|
||
|
||
@router.post("/records/{record_id}/retry", response_model=DuplicationUploadResponse)
|
||
def retry_duplication(
|
||
record_id: str,
|
||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||
duplication_repository: Any = Depends(get_duplication_repository),
|
||
) -> DuplicationUploadResponse:
|
||
"""
|
||
重新提交查重。
|
||
|
||
仅 failed 状态的记录允许重试,其他状态返回 400。
|
||
"""
|
||
# 检查记录存在且属于当前用户
|
||
detail_uc = GetDuplicationDetailUseCase(duplication_repository)
|
||
record = detail_uc.execute(record_id)
|
||
if record is None or record.user_id != authenticated_user.user.id:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail=f"查重记录 {record_id} 不存在",
|
||
)
|
||
|
||
use_case = RetryDuplicationUseCase(duplication_repository)
|
||
try:
|
||
updated = use_case.execute(record_id)
|
||
except ValueError as e:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail=str(e),
|
||
) from e
|
||
if updated is None:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail=f"查重记录 {record_id} 不存在",
|
||
)
|
||
|
||
return DuplicationUploadResponse(
|
||
id=updated.id,
|
||
status=updated.status,
|
||
message="已重新提交查重",
|
||
)
|