"""查重 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, 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() # 查重功能只接受视频文件 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( authenticated_user: AuthenticatedUser = Depends(get_current_user), duplication_repository: Any = Depends(get_duplication_repository), ) -> list[DuplicationRecordResponse]: """获取当前用户的查重记录列表。""" use_case = ListDuplicationRecordsUseCase(duplication_repository) records = use_case.execute(authenticated_user.user.id) 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_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 Response(status_code=204) @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: """重新提交查重。""" # 检查记录存在且属于当前用户 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) updated = use_case.execute(record_id) 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="已重新提交查重", )