import logging from fastapi import HTTPException, UploadFile from common.db.run import select_run from common.db.file_data import insert_file, select_run_files, select_file, delete_file from integrations.azure_blob import AzureBlobUploader from models.file import FileListItem, FileType, FileUploadResponse logger = logging.getLogger(__name__) _MAX_UPLOAD_BYTES = 50 * 1024 * 1024 # 50MB async def upload_file_to_blob( content: bytes, file_name: str, group: str, category: str = "file", content_type: str | None = None, ) -> str: """Azure Blob에 파일을 업로드하고 public URL을 반환. DB는 건드리지 않음.""" uploader = AzureBlobUploader(group=group, category=category) return await uploader.upload_bytes(content=content, file_name=file_name, content_type=content_type) async def upload_analysis_file( analysis_run_id: str, content: bytes, file_name: str, file_type: str = "file", content_type: str | None = None, ) -> tuple[int, str]: """analysis_run에 딸린 파일 업로드. Blob 업로드 + file_data row 생성. (file_id, url) 반환.""" run = await select_run(analysis_run_id) if not run: raise HTTPException(status_code=404, detail="analysis_run not found") hospital_id = run["hospital_id"] public_url = await upload_file_to_blob( content=content, file_name=file_name, group=analysis_run_id, category=file_type, content_type=content_type, ) file_id = await insert_file( analysis_run_id=analysis_run_id, hospital_id=hospital_id, file_type=file_type, file_name=file_name, file_url=public_url, size_bytes=len(content), ) logger.info("uploaded analysis file run=%s file_id=%s url=%s", analysis_run_id, file_id, public_url) return file_id, public_url async def list_analysis_files(analysis_run_id: str) -> list[dict]: """analysis_run에 딸린 (삭제 안 된) 파일 목록.""" return await select_run_files(analysis_run_id) async def handle_analysis_file_upload( analysis_run_id: str, upload: UploadFile, file_type: FileType, ) -> FileUploadResponse: """multipart UploadFile 검증 + 업로드 + 응답 모델 생성.""" if not upload.filename: raise HTTPException(status_code=400, detail="filename is required") content = await upload.read() if not content: raise HTTPException(status_code=400, detail="empty file") if len(content) > _MAX_UPLOAD_BYTES: raise HTTPException(status_code=413, detail=f"file too large (max {_MAX_UPLOAD_BYTES} bytes)") file_id, public_url = await upload_analysis_file( analysis_run_id=analysis_run_id, content=content, file_name=upload.filename, file_type=file_type.value, content_type=upload.content_type, ) return FileUploadResponse( id=file_id, analysis_run_id=analysis_run_id, file_type=file_type, file_name=upload.filename, file_url=public_url, size_bytes=len(content), ) async def get_analysis_files_response(analysis_run_id: str) -> list[FileListItem]: """run 존재 확인 + 응답 모델 생성.""" if not await select_run(analysis_run_id): raise HTTPException(status_code=404, detail="analysis_run not found") rows = await list_analysis_files(analysis_run_id) return [FileListItem(**{**r, "created_at": str(r["created_at"])}) for r in rows] async def soft_delete_analysis_file(analysis_run_id: str, file_id: int) -> None: """analysis_run에 딸린 파일을 소프트 삭제. 멱등성 보장.""" row = await select_file(file_id, analysis_run_id) if not row: raise HTTPException(status_code=404, detail="file not found") await delete_file(file_id) logger.info("soft-deleted analysis file run=%s file_id=%s", analysis_run_id, file_id)