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:
92
backend/app/services/search_service.py
Normal file
92
backend/app/services/search_service.py
Normal file
@ -0,0 +1,92 @@
|
||||
import math
|
||||
|
||||
from bson import ObjectId
|
||||
from fastapi import HTTPException
|
||||
from motor.motor_asyncio import AsyncIOMotorDatabase
|
||||
|
||||
from app.models.todo import TodoResponse, SearchResponse
|
||||
|
||||
|
||||
class SearchService:
|
||||
"""MongoDB text index 기반 검색 로직"""
|
||||
|
||||
def __init__(self, db: AsyncIOMotorDatabase):
|
||||
self.collection = db["todos"]
|
||||
self.categories = db["categories"]
|
||||
|
||||
async def _populate_categories_bulk(self, todo_docs: list[dict]) -> list[TodoResponse]:
|
||||
"""여러 Todo 문서의 카테고리 정보를 일괄 조회하여 TodoResponse 리스트를 반환한다."""
|
||||
# 카테고리 ID 수집
|
||||
cat_ids = set()
|
||||
for doc in todo_docs:
|
||||
cat_id = doc.get("category_id")
|
||||
if cat_id:
|
||||
try:
|
||||
cat_ids.add(ObjectId(cat_id))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 카테고리 일괄 조회
|
||||
cat_map: dict[str, dict] = {}
|
||||
if cat_ids:
|
||||
async for cat in self.categories.find({"_id": {"$in": list(cat_ids)}}):
|
||||
cat_map[str(cat["_id"])] = cat
|
||||
|
||||
# TodoResponse 변환
|
||||
result = []
|
||||
for doc in todo_docs:
|
||||
cat_id = str(doc["category_id"]) if doc.get("category_id") else None
|
||||
cat = cat_map.get(cat_id) if cat_id else None
|
||||
|
||||
result.append(TodoResponse(
|
||||
id=str(doc["_id"]),
|
||||
title=doc["title"],
|
||||
content=doc.get("content"),
|
||||
completed=doc["completed"],
|
||||
priority=doc["priority"],
|
||||
category_id=cat_id,
|
||||
category_name=cat["name"] if cat else None,
|
||||
category_color=cat["color"] if cat else None,
|
||||
tags=doc.get("tags", []),
|
||||
due_date=doc.get("due_date"),
|
||||
created_at=doc["created_at"],
|
||||
updated_at=doc["updated_at"],
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
async def search(self, query: str, page: int = 1, limit: int = 20) -> SearchResponse:
|
||||
"""F-017: 전문 검색
|
||||
MongoDB text index를 활용한 전문 검색, text score 기준 정렬, 페이지네이션
|
||||
"""
|
||||
if not query or not query.strip():
|
||||
raise HTTPException(status_code=422, detail="검색어를 입력해주세요")
|
||||
|
||||
query = query.strip()
|
||||
|
||||
# MongoDB text search
|
||||
search_filter = {"$text": {"$search": query}}
|
||||
|
||||
# 총 개수 조회
|
||||
total = await self.collection.count_documents(search_filter)
|
||||
|
||||
# text score 기준 정렬 + 페이지네이션
|
||||
skip = (page - 1) * limit
|
||||
cursor = self.collection.find(
|
||||
search_filter,
|
||||
{"score": {"$meta": "textScore"}},
|
||||
).sort(
|
||||
[("score", {"$meta": "textScore"})]
|
||||
).skip(skip).limit(limit)
|
||||
|
||||
docs = await cursor.to_list(limit)
|
||||
|
||||
items = await self._populate_categories_bulk(docs)
|
||||
|
||||
return SearchResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
query=query,
|
||||
page=page,
|
||||
limit=limit,
|
||||
)
|
||||
Reference in New Issue
Block a user