- Backend: FastAPI + MongoDB + Redis (카테고리, 할일 CRUD, 파일 첨부, 검색, 대시보드) - Frontend: Next.js 15 + Tailwind + React Query + Zustand - 통합 TodoModal: 생성/수정 모달 통합, 탭 구조 (기본/태그와 첨부) - 간트차트: 카테고리별 할일 타임라인 시각화 - TodoCard: 제목/카테고리/우선순위/태그/첨부 한줄 표시 - Docker Compose 배포 (Frontend:3010, Backend:8010, MongoDB:27021, Redis:6391) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
155 lines
5.8 KiB
Python
155 lines
5.8 KiB
Python
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)
|