import shutil import uuid from datetime import datetime, timezone from pathlib import Path from bson import ObjectId from fastapi import HTTPException, UploadFile from motor.motor_asyncio import AsyncIOMotorDatabase from app.config import get_settings from app.models.todo import Attachment class FileService: """파일 업로드/다운로드/삭제 서비스""" def __init__(self, db: AsyncIOMotorDatabase): self.collection = db["todos"] self.settings = get_settings() self.upload_dir = Path(self.settings.upload_dir) def _ensure_todo_dir(self, todo_id: str) -> Path: todo_dir = self.upload_dir / todo_id todo_dir.mkdir(parents=True, exist_ok=True) return todo_dir async def upload_files( self, todo_id: str, files: list[UploadFile] ) -> list[Attachment]: if not ObjectId.is_valid(todo_id): raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다") todo = await self.collection.find_one({"_id": ObjectId(todo_id)}) if not todo: raise HTTPException(status_code=404, detail="할일을 찾을 수 없습니다") existing_count = len(todo.get("attachments", [])) if existing_count + len(files) > self.settings.max_files_per_todo: raise HTTPException( status_code=400, detail=f"최대 {self.settings.max_files_per_todo}개의 파일만 첨부할 수 있습니다", ) todo_dir = self._ensure_todo_dir(todo_id) attachments: list[Attachment] = [] saved_paths: list[Path] = [] try: for file in files: content = await file.read() if len(content) > self.settings.max_file_size: raise HTTPException( status_code=400, detail=f"파일 '{file.filename}'이 최대 크기(10MB)를 초과합니다", ) file_id = str(uuid.uuid4()) ext = Path(file.filename or "").suffix stored_filename = f"{file_id}{ext}" file_path = todo_dir / stored_filename with open(file_path, "wb") as f: f.write(content) saved_paths.append(file_path) attachments.append( Attachment( id=file_id, filename=file.filename or "unknown", stored_filename=stored_filename, content_type=file.content_type or "application/octet-stream", size=len(content), uploaded_at=datetime.now(timezone.utc), ) ) await self.collection.update_one( {"_id": ObjectId(todo_id)}, { "$push": { "attachments": { "$each": [a.model_dump() for a in attachments] } }, "$set": {"updated_at": datetime.now(timezone.utc)}, }, ) except HTTPException: for p in saved_paths: p.unlink(missing_ok=True) raise except Exception: for p in saved_paths: p.unlink(missing_ok=True) raise HTTPException(status_code=500, detail="파일 업로드에 실패했습니다") return attachments async def delete_attachment(self, todo_id: str, attachment_id: str) -> None: if not ObjectId.is_valid(todo_id): raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다") todo = await self.collection.find_one({"_id": ObjectId(todo_id)}) if not todo: raise HTTPException(status_code=404, detail="할일을 찾을 수 없습니다") attachment = None for att in todo.get("attachments", []): if att["id"] == attachment_id: attachment = att break if not attachment: raise HTTPException(status_code=404, detail="첨부파일을 찾을 수 없습니다") file_path = self.upload_dir / todo_id / attachment["stored_filename"] if file_path.exists(): file_path.unlink() await self.collection.update_one( {"_id": ObjectId(todo_id)}, { "$pull": {"attachments": {"id": attachment_id}}, "$set": {"updated_at": datetime.now(timezone.utc)}, }, ) async def get_file_info( self, todo_id: str, attachment_id: str ) -> tuple[Path, str]: """파일 경로와 원본 파일명을 반환""" if not ObjectId.is_valid(todo_id): raise HTTPException(status_code=422, detail="유효하지 않은 ID 형식입니다") todo = await self.collection.find_one({"_id": ObjectId(todo_id)}) if not todo: raise HTTPException(status_code=404, detail="할일을 찾을 수 없습니다") for att in todo.get("attachments", []): if att["id"] == attachment_id: file_path = self.upload_dir / todo_id / att["stored_filename"] if not file_path.exists(): raise HTTPException( status_code=404, detail="파일이 디스크에서 찾을 수 없습니다" ) return file_path, att["filename"] raise HTTPException(status_code=404, detail="첨부파일을 찾을 수 없습니다") async def delete_all_todo_files(self, todo_id: str) -> None: """Todo 삭제 시 해당 Todo의 모든 파일 삭제""" todo_dir = self.upload_dir / todo_id if todo_dir.exists(): shutil.rmtree(todo_dir)