""" Upload Controller for Generic File Upload System Handles HTTP requests for file upload, download, deletion, and metadata updates. """ import os import json from typing import List, Optional, Any from pathlib import Path from fastapi import APIRouter, Depends, File, UploadFile, Form from fastapi.responses import FileResponse from sqlalchemy.orm import Session from app.db import get_db from app.dependencies import get_current_user from app.models.user_model import User from app.schemas.response_schema import ApiResponse from app.schemas.generic_file_schema import ( GenericFileResponse, FileMetadataUpdate, UploadResultSchema, BatchUploadResponse ) from app.core.response_utils import success, fail from app.core.upload_enums import UploadContext from app.services.upload_service import UploadService from app.core.logging_config import get_logger from app.core.exceptions import ( ValidationException, ResourceNotFoundException, FileUploadException, BusinessException ) # Get logger logger = get_logger(__name__) # Create router router = APIRouter( prefix="/api", tags=["upload"], dependencies=[Depends(get_current_user)] ) # Initialize upload service upload_service = UploadService() @router.post("/upload", response_model=ApiResponse) async def upload_file( file: UploadFile = File(..., description="要上传的文件"), context: str = Form(..., description="上传上下文 (avatar, app_icon, knowledge_base, temp, attachment)"), metadata: Optional[str] = Form(None, description="文件元数据 (JSON格式)"), db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ) -> ApiResponse: """ 单文件上传接口 - **file**: 要上传的文件 - **context**: 上传上下文,决定文件存储位置和验证规则 - **metadata**: 可选的文件元数据,JSON格式字符串 返回上传成功的文件信息 """ logger.info(f"Upload request: filename={file.filename}, context={context}, user={current_user.id}") try: # Validate and parse context try: upload_context = UploadContext(context) except ValueError: logger.warning(f"Invalid upload context: {context}") raise ValidationException( f"无效的上传上下文: {context}. 允许的值: {', '.join([c.value for c in UploadContext])}", field="context" ) # Parse metadata if provided file_metadata = {} if metadata: try: file_metadata = json.loads(metadata) except json.JSONDecodeError: logger.warning(f"Invalid metadata JSON: {metadata}") raise ValidationException( "元数据必须是有效的JSON格式", field="metadata" ) # Upload file db_file = upload_service.upload_file( file=file, context=upload_context, metadata=file_metadata, current_user=current_user, db=db ) # Convert to response schema file_response = GenericFileResponse.model_validate(db_file) logger.info(f"Upload successful: {file.filename} (ID: {db_file.id})") return success(data=file_response.dict(), msg="文件上传成功") except BusinessException: # Business exceptions are handled by global exception handlers raise except Exception as e: logger.error(f"Upload failed: {str(e)}") # Wrap unknown exceptions as FileUploadException raise FileUploadException( f"文件上传失败: {str(e)}", cause=e ) @router.post("/upload/batch", response_model=ApiResponse) async def upload_files_batch( files: List[UploadFile] = File(..., description="要上传的文件列表"), context: str = Form(..., description="上传上下文 (avatar, app_icon, knowledge_base, temp, attachment)"), metadata: Optional[str] = Form(None, description="文件元数据 (JSON格式)"), db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ) -> ApiResponse: """ 批量文件上传接口 - **files**: 要上传的文件列表(最多20个) - **context**: 上传上下文,决定文件存储位置和验证规则 - **metadata**: 可选的文件元数据,JSON格式字符串,应用于所有文件 返回每个文件的上传结果 """ logger.info(f"Batch upload request: {len(files)} files, context={context}, user={current_user.id}") try: # Validate and parse context try: upload_context = UploadContext(context) except ValueError: logger.warning(f"Invalid upload context: {context}") raise ValidationException( f"无效的上传上下文: {context}. 允许的值: {', '.join([c.value for c in UploadContext])}", field="context" ) # Parse metadata if provided file_metadata = {} if metadata: try: file_metadata = json.loads(metadata) except json.JSONDecodeError: logger.warning(f"Invalid metadata JSON: {metadata}") raise ValidationException( "元数据必须是有效的JSON格式", field="metadata" ) # Upload files in batch upload_results = upload_service.upload_files_batch( files=files, context=upload_context, metadata=file_metadata, current_user=current_user, db=db ) # Convert results to response schemas result_schemas = [] for result in upload_results: result_schema = UploadResultSchema( success=result.success, file_id=result.file_id, file_name=result.file_name, error=result.error, file_info=None ) # If upload was successful, get file info if result.success and result.file_id: try: db_file = upload_service.get_file(result.file_id, current_user, db) result_schema.file_info = GenericFileResponse.model_validate(db_file) except Exception as e: logger.warning(f"Failed to get file info for {result.file_id}: {str(e)}") result_schemas.append(result_schema) # Create batch response batch_response = BatchUploadResponse( total=len(files), success_count=sum(1 for r in upload_results if r.success), failed_count=sum(1 for r in upload_results if not r.success), results=result_schemas ) logger.info(f"Batch upload completed: {batch_response.success_count}/{batch_response.total} successful") return success(data=batch_response.dict(), msg="批量上传完成") except BusinessException: # Business exceptions are handled by global exception handlers raise except Exception as e: logger.error(f"Batch upload failed: {str(e)}") # Wrap unknown exceptions as FileUploadException raise FileUploadException( f"批量上传失败: {str(e)}", cause=e ) @router.get("/files/{file_id}", response_model=Any) async def download_file( file_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ) -> Any: """ 文件下载接口 - **file_id**: 文件ID 返回文件内容供下载 """ logger.info(f"Download request: file_id={file_id}, user={current_user.id}") try: # Parse file_id import uuid try: file_uuid = uuid.UUID(file_id) except ValueError: logger.warning(f"Invalid file ID format: {file_id}") raise ValidationException( "无效的文件ID格式", field="file_id" ) # Get file from database db_file = upload_service.get_file(file_uuid, current_user, db) # Check if physical file exists storage_path = Path(db_file.storage_path) if not storage_path.exists(): logger.error(f"Physical file not found: {storage_path}") raise ResourceNotFoundException( "文件", str(file_uuid), context={"detail": "文件未找到(可能已被删除)"} ) # Return file response logger.info(f"Download successful: {db_file.file_name} (ID: {file_id})") return FileResponse( path=str(storage_path), filename=db_file.file_name, media_type=db_file.mime_type or "application/octet-stream" ) except BusinessException: # Business exceptions are handled by global exception handlers raise except Exception as e: logger.error(f"Download failed: {str(e)}") # Wrap unknown exceptions raise FileUploadException( f"文件下载失败: {str(e)}", cause=e ) @router.delete("/files/{file_id}", response_model=ApiResponse) async def delete_file( file_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ) -> ApiResponse: """ 文件删除接口 - **file_id**: 文件ID 删除文件(包括物理文件和数据库记录) """ logger.info(f"Delete request: file_id={file_id}, user={current_user.id}") try: # Parse file_id import uuid try: file_uuid = uuid.UUID(file_id) except ValueError: logger.warning(f"Invalid file ID format: {file_id}") raise ValidationException( "无效的文件ID格式", field="file_id" ) # Delete file upload_service.delete_file(file_uuid, current_user, db) logger.info(f"Delete successful: file_id={file_id}") return success(msg="文件删除成功") except BusinessException: # Business exceptions are handled by global exception handlers raise except Exception as e: logger.error(f"Delete failed: {str(e)}") # Wrap unknown exceptions raise FileUploadException( f"文件删除失败: {str(e)}", cause=e ) @router.put("/files/{file_id}", response_model=ApiResponse) async def update_file_metadata( file_id: str, update_data: FileMetadataUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ) -> ApiResponse: """ 文件元数据更新接口 - **file_id**: 文件ID - **update_data**: 要更新的元数据 更新文件的元数据(文件名、自定义元数据、公开状态) """ logger.info(f"Update metadata request: file_id={file_id}, user={current_user.id}") try: # Parse file_id import uuid try: file_uuid = uuid.UUID(file_id) except ValueError: logger.warning(f"Invalid file ID format: {file_id}") raise ValidationException( "无效的文件ID格式", field="file_id" ) # Convert update data to dict, excluding unset fields update_dict = update_data.dict(exclude_unset=True) if not update_dict: logger.warning(f"No fields to update for file: {file_id}") raise ValidationException( "没有提供要更新的字段", field="update_data" ) # Update file metadata updated_file = upload_service.update_file_metadata( file_uuid, update_dict, current_user, db ) # Convert to response schema file_response = GenericFileResponse.model_validate(updated_file) logger.info(f"Update metadata successful: file_id={file_id}") return success(data=file_response.dict(), msg="文件元数据更新成功") except BusinessException: # Business exceptions are handled by global exception handlers raise except Exception as e: logger.error(f"Update metadata failed: {str(e)}") # Wrap unknown exceptions raise FileUploadException( f"文件元数据更新失败: {str(e)}", cause=e )