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>
This commit is contained in:
154
backend/app/services/file_service.py
Normal file
154
backend/app/services/file_service.py
Normal file
@ -0,0 +1,154 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user