Files
todos2/backend/app/services/file_service.py
jungwoo choi 074b5133bf feat: 풀스택 할일관리 앱 구현 (통합 모달 + 간트차트)
- 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>
2026-02-12 15:45:03 +09:00

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)