🎉 Initial commit: Document Server MVP
✨ Features implemented: - FastAPI backend with JWT authentication - PostgreSQL database with async SQLAlchemy - HTML document viewer with smart highlighting - Note system connected to highlights (1:1 relationship) - Bookmark system for quick navigation - Integrated search (documents + notes) - Tag system for document organization - Docker containerization with Nginx 🔧 Technical stack: - Backend: FastAPI + PostgreSQL + Redis - Frontend: Alpine.js + Tailwind CSS - Authentication: JWT tokens - File handling: HTML + PDF support - Search: Full-text search with relevance scoring 📋 Core functionality: - Text selection → Highlight creation - Highlight → Note attachment - Note management with search/filtering - Bookmark creation at scroll positions - Document upload with metadata - User management (admin creates accounts)
This commit is contained in:
3
backend/src/__init__.py
Normal file
3
backend/src/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Document Server Backend Package
|
||||
"""
|
||||
3
backend/src/api/__init__.py
Normal file
3
backend/src/api/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
API 패키지 초기화
|
||||
"""
|
||||
88
backend/src/api/dependencies.py
Normal file
88
backend/src/api/dependencies.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
API 의존성
|
||||
"""
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import Optional
|
||||
|
||||
from src.core.database import get_db
|
||||
from src.core.security import verify_token, get_user_id_from_token
|
||||
from src.models.user import User
|
||||
|
||||
|
||||
# HTTP Bearer 토큰 스키마
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> User:
|
||||
"""현재 로그인된 사용자 가져오기"""
|
||||
try:
|
||||
# 토큰에서 사용자 ID 추출
|
||||
user_id = get_user_id_from_token(credentials.credentials)
|
||||
|
||||
# 데이터베이스에서 사용자 조회
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Inactive user"
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials"
|
||||
)
|
||||
|
||||
|
||||
async def get_current_active_user(
|
||||
current_user: User = Depends(get_current_user)
|
||||
) -> User:
|
||||
"""활성 사용자 확인"""
|
||||
if not current_user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Inactive user"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
async def get_current_admin_user(
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
) -> User:
|
||||
"""관리자 권한 확인"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
async def get_optional_current_user(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> Optional[User]:
|
||||
"""선택적 사용자 인증 (토큰이 없어도 됨)"""
|
||||
if not credentials:
|
||||
return None
|
||||
|
||||
try:
|
||||
return await get_current_user(credentials, db)
|
||||
except HTTPException:
|
||||
return None
|
||||
3
backend/src/api/routes/__init__.py
Normal file
3
backend/src/api/routes/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
API 라우터 패키지 초기화
|
||||
"""
|
||||
190
backend/src/api/routes/auth.py
Normal file
190
backend/src/api/routes/auth.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
인증 관련 API 라우터
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update
|
||||
from datetime import datetime
|
||||
|
||||
from src.core.database import get_db
|
||||
from src.core.security import verify_password, create_access_token, create_refresh_token, get_password_hash
|
||||
from src.core.config import settings
|
||||
from src.models.user import User
|
||||
from src.schemas.auth import (
|
||||
LoginRequest, TokenResponse, RefreshTokenRequest,
|
||||
UserInfo, ChangePasswordRequest, CreateUserRequest
|
||||
)
|
||||
from src.api.dependencies import get_current_active_user, get_current_admin_user
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(
|
||||
login_data: LoginRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""사용자 로그인"""
|
||||
# 사용자 조회
|
||||
result = await db.execute(
|
||||
select(User).where(User.email == login_data.email)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
# 사용자 존재 및 비밀번호 확인
|
||||
if not user or not verify_password(login_data.password, user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect email or password"
|
||||
)
|
||||
|
||||
# 비활성 사용자 확인
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Inactive user"
|
||||
)
|
||||
|
||||
# 토큰 생성
|
||||
access_token = create_access_token(data={"sub": str(user.id)})
|
||||
refresh_token = create_refresh_token(data={"sub": str(user.id)})
|
||||
|
||||
# 마지막 로그인 시간 업데이트
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == user.id)
|
||||
.values(last_login=datetime.utcnow())
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
async def refresh_token(
|
||||
refresh_data: RefreshTokenRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""토큰 갱신"""
|
||||
from src.core.security import verify_token
|
||||
|
||||
try:
|
||||
# 리프레시 토큰 검증
|
||||
payload = verify_token(refresh_data.refresh_token, token_type="refresh")
|
||||
user_id = payload.get("sub")
|
||||
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token"
|
||||
)
|
||||
|
||||
# 사용자 존재 확인
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found or inactive"
|
||||
)
|
||||
|
||||
# 새 토큰 생성
|
||||
access_token = create_access_token(data={"sub": str(user.id)})
|
||||
new_refresh_token = create_refresh_token(data={"sub": str(user.id)})
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=new_refresh_token,
|
||||
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
)
|
||||
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserInfo)
|
||||
async def get_current_user_info(
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
"""현재 사용자 정보 조회"""
|
||||
return UserInfo.from_orm(current_user)
|
||||
|
||||
|
||||
@router.put("/change-password")
|
||||
async def change_password(
|
||||
password_data: ChangePasswordRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""비밀번호 변경"""
|
||||
# 현재 비밀번호 확인
|
||||
if not verify_password(password_data.current_password, current_user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Incorrect current password"
|
||||
)
|
||||
|
||||
# 새 비밀번호 해싱 및 업데이트
|
||||
new_hashed_password = get_password_hash(password_data.new_password)
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == current_user.id)
|
||||
.values(hashed_password=new_hashed_password)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Password changed successfully"}
|
||||
|
||||
|
||||
@router.post("/create-user", response_model=UserInfo)
|
||||
async def create_user(
|
||||
user_data: CreateUserRequest,
|
||||
admin_user: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""새 사용자 생성 (관리자 전용)"""
|
||||
# 이메일 중복 확인
|
||||
result = await db.execute(
|
||||
select(User).where(User.email == user_data.email)
|
||||
)
|
||||
existing_user = result.scalar_one_or_none()
|
||||
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email already registered"
|
||||
)
|
||||
|
||||
# 새 사용자 생성
|
||||
new_user = User(
|
||||
email=user_data.email,
|
||||
hashed_password=get_password_hash(user_data.password),
|
||||
full_name=user_data.full_name,
|
||||
is_admin=user_data.is_admin,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
db.add(new_user)
|
||||
await db.commit()
|
||||
await db.refresh(new_user)
|
||||
|
||||
return UserInfo.from_orm(new_user)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
"""로그아웃 (클라이언트에서 토큰 삭제)"""
|
||||
# 실제로는 클라이언트에서 토큰을 삭제하면 됨
|
||||
# 필요시 토큰 블랙리스트 구현 가능
|
||||
return {"message": "Logged out successfully"}
|
||||
300
backend/src/api/routes/bookmarks.py
Normal file
300
backend/src/api/routes/bookmarks.py
Normal file
@@ -0,0 +1,300 @@
|
||||
"""
|
||||
책갈피 관리 API 라우터
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete, and_
|
||||
from sqlalchemy.orm import joinedload
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from src.core.database import get_db
|
||||
from src.models.user import User
|
||||
from src.models.document import Document
|
||||
from src.models.bookmark import Bookmark
|
||||
from src.api.dependencies import get_current_active_user
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class CreateBookmarkRequest(BaseModel):
|
||||
"""책갈피 생성 요청"""
|
||||
document_id: str
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
page_number: Optional[int] = None
|
||||
scroll_position: int = 0
|
||||
element_id: Optional[str] = None
|
||||
element_selector: Optional[str] = None
|
||||
|
||||
|
||||
class UpdateBookmarkRequest(BaseModel):
|
||||
"""책갈피 업데이트 요청"""
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
page_number: Optional[int] = None
|
||||
scroll_position: Optional[int] = None
|
||||
element_id: Optional[str] = None
|
||||
element_selector: Optional[str] = None
|
||||
|
||||
|
||||
class BookmarkResponse(BaseModel):
|
||||
"""책갈피 응답"""
|
||||
id: str
|
||||
document_id: str
|
||||
title: str
|
||||
description: Optional[str]
|
||||
page_number: Optional[int]
|
||||
scroll_position: int
|
||||
element_id: Optional[str]
|
||||
element_selector: Optional[str]
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime]
|
||||
document_title: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/", response_model=BookmarkResponse)
|
||||
async def create_bookmark(
|
||||
bookmark_data: CreateBookmarkRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""책갈피 생성"""
|
||||
# 문서 존재 및 권한 확인
|
||||
result = await db.execute(select(Document).where(Document.id == bookmark_data.document_id))
|
||||
document = result.scalar_one_or_none()
|
||||
|
||||
if not document:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Document not found"
|
||||
)
|
||||
|
||||
# 문서 접근 권한 확인
|
||||
if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions to access this document"
|
||||
)
|
||||
|
||||
# 책갈피 생성
|
||||
bookmark = Bookmark(
|
||||
user_id=current_user.id,
|
||||
document_id=bookmark_data.document_id,
|
||||
title=bookmark_data.title,
|
||||
description=bookmark_data.description,
|
||||
page_number=bookmark_data.page_number,
|
||||
scroll_position=bookmark_data.scroll_position,
|
||||
element_id=bookmark_data.element_id,
|
||||
element_selector=bookmark_data.element_selector
|
||||
)
|
||||
|
||||
db.add(bookmark)
|
||||
await db.commit()
|
||||
await db.refresh(bookmark)
|
||||
|
||||
# 응답 데이터 생성
|
||||
response_data = BookmarkResponse.from_orm(bookmark)
|
||||
response_data.document_title = document.title
|
||||
|
||||
return response_data
|
||||
|
||||
|
||||
@router.get("/", response_model=List[BookmarkResponse])
|
||||
async def list_user_bookmarks(
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
document_id: Optional[str] = None,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""사용자의 모든 책갈피 조회"""
|
||||
query = (
|
||||
select(Bookmark)
|
||||
.options(joinedload(Bookmark.document))
|
||||
.where(Bookmark.user_id == current_user.id)
|
||||
)
|
||||
|
||||
if document_id:
|
||||
query = query.where(Bookmark.document_id == document_id)
|
||||
|
||||
query = query.order_by(Bookmark.created_at.desc()).offset(skip).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
bookmarks = result.scalars().all()
|
||||
|
||||
# 응답 데이터 변환
|
||||
response_data = []
|
||||
for bookmark in bookmarks:
|
||||
bookmark_data = BookmarkResponse.from_orm(bookmark)
|
||||
bookmark_data.document_title = bookmark.document.title
|
||||
response_data.append(bookmark_data)
|
||||
|
||||
return response_data
|
||||
|
||||
|
||||
@router.get("/document/{document_id}", response_model=List[BookmarkResponse])
|
||||
async def get_document_bookmarks(
|
||||
document_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""특정 문서의 책갈피 목록 조회"""
|
||||
# 문서 존재 및 권한 확인
|
||||
result = await db.execute(select(Document).where(Document.id == document_id))
|
||||
document = result.scalar_one_or_none()
|
||||
|
||||
if not document:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Document not found"
|
||||
)
|
||||
|
||||
# 문서 접근 권한 확인
|
||||
if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions to access this document"
|
||||
)
|
||||
|
||||
# 사용자의 책갈피만 조회
|
||||
result = await db.execute(
|
||||
select(Bookmark)
|
||||
.options(joinedload(Bookmark.document))
|
||||
.where(
|
||||
and_(
|
||||
Bookmark.document_id == document_id,
|
||||
Bookmark.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
.order_by(Bookmark.page_number, Bookmark.scroll_position)
|
||||
)
|
||||
bookmarks = result.scalars().all()
|
||||
|
||||
# 응답 데이터 변환
|
||||
response_data = []
|
||||
for bookmark in bookmarks:
|
||||
bookmark_data = BookmarkResponse.from_orm(bookmark)
|
||||
bookmark_data.document_title = bookmark.document.title
|
||||
response_data.append(bookmark_data)
|
||||
|
||||
return response_data
|
||||
|
||||
|
||||
@router.get("/{bookmark_id}", response_model=BookmarkResponse)
|
||||
async def get_bookmark(
|
||||
bookmark_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""책갈피 상세 조회"""
|
||||
result = await db.execute(
|
||||
select(Bookmark)
|
||||
.options(joinedload(Bookmark.document))
|
||||
.where(Bookmark.id == bookmark_id)
|
||||
)
|
||||
bookmark = result.scalar_one_or_none()
|
||||
|
||||
if not bookmark:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Bookmark not found"
|
||||
)
|
||||
|
||||
# 소유자 확인
|
||||
if bookmark.user_id != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
|
||||
response_data = BookmarkResponse.from_orm(bookmark)
|
||||
response_data.document_title = bookmark.document.title
|
||||
|
||||
return response_data
|
||||
|
||||
|
||||
@router.put("/{bookmark_id}", response_model=BookmarkResponse)
|
||||
async def update_bookmark(
|
||||
bookmark_id: str,
|
||||
bookmark_data: UpdateBookmarkRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""책갈피 업데이트"""
|
||||
result = await db.execute(
|
||||
select(Bookmark)
|
||||
.options(joinedload(Bookmark.document))
|
||||
.where(Bookmark.id == bookmark_id)
|
||||
)
|
||||
bookmark = result.scalar_one_or_none()
|
||||
|
||||
if not bookmark:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Bookmark not found"
|
||||
)
|
||||
|
||||
# 소유자 확인
|
||||
if bookmark.user_id != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
|
||||
# 업데이트
|
||||
if bookmark_data.title is not None:
|
||||
bookmark.title = bookmark_data.title
|
||||
if bookmark_data.description is not None:
|
||||
bookmark.description = bookmark_data.description
|
||||
if bookmark_data.page_number is not None:
|
||||
bookmark.page_number = bookmark_data.page_number
|
||||
if bookmark_data.scroll_position is not None:
|
||||
bookmark.scroll_position = bookmark_data.scroll_position
|
||||
if bookmark_data.element_id is not None:
|
||||
bookmark.element_id = bookmark_data.element_id
|
||||
if bookmark_data.element_selector is not None:
|
||||
bookmark.element_selector = bookmark_data.element_selector
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(bookmark)
|
||||
|
||||
response_data = BookmarkResponse.from_orm(bookmark)
|
||||
response_data.document_title = bookmark.document.title
|
||||
|
||||
return response_data
|
||||
|
||||
|
||||
@router.delete("/{bookmark_id}")
|
||||
async def delete_bookmark(
|
||||
bookmark_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""책갈피 삭제"""
|
||||
result = await db.execute(select(Bookmark).where(Bookmark.id == bookmark_id))
|
||||
bookmark = result.scalar_one_or_none()
|
||||
|
||||
if not bookmark:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Bookmark not found"
|
||||
)
|
||||
|
||||
# 소유자 확인
|
||||
if bookmark.user_id != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
|
||||
# 책갈피 삭제
|
||||
await db.execute(delete(Bookmark).where(Bookmark.id == bookmark_id))
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Bookmark deleted successfully"}
|
||||
359
backend/src/api/routes/documents.py
Normal file
359
backend/src/api/routes/documents.py
Normal file
@@ -0,0 +1,359 @@
|
||||
"""
|
||||
문서 관리 API 라우터
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete, and_, or_
|
||||
from sqlalchemy.orm import selectinload
|
||||
from typing import List, Optional
|
||||
import os
|
||||
import uuid
|
||||
import aiofiles
|
||||
from pathlib import Path
|
||||
|
||||
from src.core.database import get_db
|
||||
from src.core.config import settings
|
||||
from src.models.user import User
|
||||
from src.models.document import Document, Tag
|
||||
from src.api.dependencies import get_current_active_user, get_current_admin_user
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class DocumentResponse(BaseModel):
|
||||
"""문서 응답"""
|
||||
id: str
|
||||
title: str
|
||||
description: Optional[str]
|
||||
html_path: str
|
||||
pdf_path: Optional[str]
|
||||
thumbnail_path: Optional[str]
|
||||
file_size: Optional[int]
|
||||
page_count: Optional[int]
|
||||
language: str
|
||||
is_public: bool
|
||||
is_processed: bool
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime]
|
||||
document_date: Optional[datetime]
|
||||
uploader_name: Optional[str]
|
||||
tags: List[str] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TagResponse(BaseModel):
|
||||
"""태그 응답"""
|
||||
id: str
|
||||
name: str
|
||||
color: str
|
||||
description: Optional[str]
|
||||
document_count: int = 0
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CreateTagRequest(BaseModel):
|
||||
"""태그 생성 요청"""
|
||||
name: str
|
||||
color: str = "#3B82F6"
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_model=List[DocumentResponse])
|
||||
async def list_documents(
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
tag: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""문서 목록 조회"""
|
||||
query = select(Document).options(
|
||||
selectinload(Document.uploader),
|
||||
selectinload(Document.tags)
|
||||
)
|
||||
|
||||
# 권한 필터링 (관리자가 아니면 공개 문서 + 자신이 업로드한 문서만)
|
||||
if not current_user.is_admin:
|
||||
query = query.where(
|
||||
or_(
|
||||
Document.is_public == True,
|
||||
Document.uploaded_by == current_user.id
|
||||
)
|
||||
)
|
||||
|
||||
# 태그 필터링
|
||||
if tag:
|
||||
query = query.join(Document.tags).where(Tag.name == tag)
|
||||
|
||||
# 검색 필터링
|
||||
if search:
|
||||
query = query.where(
|
||||
or_(
|
||||
Document.title.ilike(f"%{search}%"),
|
||||
Document.description.ilike(f"%{search}%")
|
||||
)
|
||||
)
|
||||
|
||||
query = query.order_by(Document.created_at.desc()).offset(skip).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
documents = result.scalars().all()
|
||||
|
||||
# 응답 데이터 변환
|
||||
response_data = []
|
||||
for doc in documents:
|
||||
doc_data = DocumentResponse.from_orm(doc)
|
||||
doc_data.uploader_name = doc.uploader.full_name or doc.uploader.email
|
||||
doc_data.tags = [tag.name for tag in doc.tags]
|
||||
response_data.append(doc_data)
|
||||
|
||||
return response_data
|
||||
|
||||
|
||||
@router.post("/", response_model=DocumentResponse)
|
||||
async def upload_document(
|
||||
title: str = Form(...),
|
||||
description: Optional[str] = Form(None),
|
||||
document_date: Optional[str] = Form(None),
|
||||
is_public: bool = Form(False),
|
||||
tags: Optional[str] = Form(None), # 쉼표로 구분된 태그
|
||||
html_file: UploadFile = File(...),
|
||||
pdf_file: Optional[UploadFile] = File(None),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""문서 업로드"""
|
||||
# 파일 확장자 확인
|
||||
if not html_file.filename.lower().endswith(('.html', '.htm')):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Only HTML files are allowed for the main document"
|
||||
)
|
||||
|
||||
if pdf_file and not pdf_file.filename.lower().endswith('.pdf'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Only PDF files are allowed for the original document"
|
||||
)
|
||||
|
||||
# 고유 파일명 생성
|
||||
doc_id = str(uuid.uuid4())
|
||||
html_filename = f"{doc_id}.html"
|
||||
pdf_filename = f"{doc_id}.pdf" if pdf_file else None
|
||||
|
||||
# 파일 저장 경로
|
||||
html_path = os.path.join(settings.UPLOAD_DIR, "documents", html_filename)
|
||||
pdf_path = os.path.join(settings.UPLOAD_DIR, "documents", pdf_filename) if pdf_file else None
|
||||
|
||||
try:
|
||||
# HTML 파일 저장
|
||||
async with aiofiles.open(html_path, 'wb') as f:
|
||||
content = await html_file.read()
|
||||
await f.write(content)
|
||||
|
||||
# PDF 파일 저장 (있는 경우)
|
||||
if pdf_file and pdf_path:
|
||||
async with aiofiles.open(pdf_path, 'wb') as f:
|
||||
content = await pdf_file.read()
|
||||
await f.write(content)
|
||||
|
||||
# 문서 메타데이터 생성
|
||||
document = Document(
|
||||
id=doc_id,
|
||||
title=title,
|
||||
description=description,
|
||||
html_path=html_path,
|
||||
pdf_path=pdf_path,
|
||||
file_size=len(await html_file.read()) if html_file else None,
|
||||
uploaded_by=current_user.id,
|
||||
original_filename=html_file.filename,
|
||||
is_public=is_public,
|
||||
document_date=datetime.fromisoformat(document_date) if document_date else None
|
||||
)
|
||||
|
||||
db.add(document)
|
||||
await db.flush() # ID 생성을 위해
|
||||
|
||||
# 태그 처리
|
||||
if tags:
|
||||
tag_names = [tag.strip() for tag in tags.split(',') if tag.strip()]
|
||||
for tag_name in tag_names:
|
||||
# 기존 태그 찾기 또는 생성
|
||||
result = await db.execute(select(Tag).where(Tag.name == tag_name))
|
||||
tag = result.scalar_one_or_none()
|
||||
|
||||
if not tag:
|
||||
tag = Tag(
|
||||
name=tag_name,
|
||||
created_by=current_user.id
|
||||
)
|
||||
db.add(tag)
|
||||
await db.flush()
|
||||
|
||||
document.tags.append(tag)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(document)
|
||||
|
||||
# 응답 데이터 생성
|
||||
response_data = DocumentResponse.from_orm(document)
|
||||
response_data.uploader_name = current_user.full_name or current_user.email
|
||||
response_data.tags = [tag.name for tag in document.tags]
|
||||
|
||||
return response_data
|
||||
|
||||
except Exception as e:
|
||||
# 파일 정리
|
||||
if os.path.exists(html_path):
|
||||
os.remove(html_path)
|
||||
if pdf_path and os.path.exists(pdf_path):
|
||||
os.remove(pdf_path)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to upload document: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{document_id}", response_model=DocumentResponse)
|
||||
async def get_document(
|
||||
document_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""문서 상세 조회"""
|
||||
result = await db.execute(
|
||||
select(Document)
|
||||
.options(selectinload(Document.uploader), selectinload(Document.tags))
|
||||
.where(Document.id == document_id)
|
||||
)
|
||||
document = result.scalar_one_or_none()
|
||||
|
||||
if not document:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Document not found"
|
||||
)
|
||||
|
||||
# 권한 확인
|
||||
if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
|
||||
response_data = DocumentResponse.from_orm(document)
|
||||
response_data.uploader_name = document.uploader.full_name or document.uploader.email
|
||||
response_data.tags = [tag.name for tag in document.tags]
|
||||
|
||||
return response_data
|
||||
|
||||
|
||||
@router.delete("/{document_id}")
|
||||
async def delete_document(
|
||||
document_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""문서 삭제"""
|
||||
result = await db.execute(select(Document).where(Document.id == document_id))
|
||||
document = result.scalar_one_or_none()
|
||||
|
||||
if not document:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Document not found"
|
||||
)
|
||||
|
||||
# 권한 확인 (업로더 또는 관리자만)
|
||||
if document.uploaded_by != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
|
||||
# 파일 삭제
|
||||
if document.html_path and os.path.exists(document.html_path):
|
||||
os.remove(document.html_path)
|
||||
if document.pdf_path and os.path.exists(document.pdf_path):
|
||||
os.remove(document.pdf_path)
|
||||
if document.thumbnail_path and os.path.exists(document.thumbnail_path):
|
||||
os.remove(document.thumbnail_path)
|
||||
|
||||
# 데이터베이스에서 삭제
|
||||
await db.execute(delete(Document).where(Document.id == document_id))
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Document deleted successfully"}
|
||||
|
||||
|
||||
@router.get("/tags/", response_model=List[TagResponse])
|
||||
async def list_tags(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""태그 목록 조회"""
|
||||
result = await db.execute(select(Tag).order_by(Tag.name))
|
||||
tags = result.scalars().all()
|
||||
|
||||
# 각 태그의 문서 수 계산
|
||||
response_data = []
|
||||
for tag in tags:
|
||||
tag_data = TagResponse.from_orm(tag)
|
||||
# 문서 수 계산 (권한 고려)
|
||||
doc_query = select(Document).join(Document.tags).where(Tag.id == tag.id)
|
||||
if not current_user.is_admin:
|
||||
doc_query = doc_query.where(
|
||||
or_(
|
||||
Document.is_public == True,
|
||||
Document.uploaded_by == current_user.id
|
||||
)
|
||||
)
|
||||
doc_result = await db.execute(doc_query)
|
||||
tag_data.document_count = len(doc_result.scalars().all())
|
||||
response_data.append(tag_data)
|
||||
|
||||
return response_data
|
||||
|
||||
|
||||
@router.post("/tags/", response_model=TagResponse)
|
||||
async def create_tag(
|
||||
tag_data: CreateTagRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""태그 생성"""
|
||||
# 중복 확인
|
||||
result = await db.execute(select(Tag).where(Tag.name == tag_data.name))
|
||||
existing_tag = result.scalar_one_or_none()
|
||||
|
||||
if existing_tag:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Tag already exists"
|
||||
)
|
||||
|
||||
# 태그 생성
|
||||
tag = Tag(
|
||||
name=tag_data.name,
|
||||
color=tag_data.color,
|
||||
description=tag_data.description,
|
||||
created_by=current_user.id
|
||||
)
|
||||
|
||||
db.add(tag)
|
||||
await db.commit()
|
||||
await db.refresh(tag)
|
||||
|
||||
response_data = TagResponse.from_orm(tag)
|
||||
response_data.document_count = 0
|
||||
|
||||
return response_data
|
||||
340
backend/src/api/routes/highlights.py
Normal file
340
backend/src/api/routes/highlights.py
Normal file
@@ -0,0 +1,340 @@
|
||||
"""
|
||||
하이라이트 관리 API 라우터
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete, and_
|
||||
from sqlalchemy.orm import selectinload
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from src.core.database import get_db
|
||||
from src.models.user import User
|
||||
from src.models.document import Document
|
||||
from src.models.highlight import Highlight
|
||||
from src.models.note import Note
|
||||
from src.api.dependencies import get_current_active_user
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class CreateHighlightRequest(BaseModel):
|
||||
"""하이라이트 생성 요청"""
|
||||
document_id: str
|
||||
start_offset: int
|
||||
end_offset: int
|
||||
selected_text: str
|
||||
element_selector: Optional[str] = None
|
||||
start_container_xpath: Optional[str] = None
|
||||
end_container_xpath: Optional[str] = None
|
||||
highlight_color: str = "#FFFF00"
|
||||
highlight_type: str = "highlight"
|
||||
note_content: Optional[str] = None # 바로 메모 추가
|
||||
|
||||
|
||||
class UpdateHighlightRequest(BaseModel):
|
||||
"""하이라이트 업데이트 요청"""
|
||||
highlight_color: Optional[str] = None
|
||||
highlight_type: Optional[str] = None
|
||||
|
||||
|
||||
class HighlightResponse(BaseModel):
|
||||
"""하이라이트 응답"""
|
||||
id: str
|
||||
document_id: str
|
||||
start_offset: int
|
||||
end_offset: int
|
||||
selected_text: str
|
||||
element_selector: Optional[str]
|
||||
start_container_xpath: Optional[str]
|
||||
end_container_xpath: Optional[str]
|
||||
highlight_color: str
|
||||
highlight_type: str
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime]
|
||||
note: Optional[dict] = None # 연결된 메모 정보
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/", response_model=HighlightResponse)
|
||||
async def create_highlight(
|
||||
highlight_data: CreateHighlightRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""하이라이트 생성 (메모 포함 가능)"""
|
||||
# 문서 존재 및 권한 확인
|
||||
result = await db.execute(select(Document).where(Document.id == highlight_data.document_id))
|
||||
document = result.scalar_one_or_none()
|
||||
|
||||
if not document:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Document not found"
|
||||
)
|
||||
|
||||
# 문서 접근 권한 확인
|
||||
if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions to access this document"
|
||||
)
|
||||
|
||||
# 하이라이트 생성
|
||||
highlight = Highlight(
|
||||
user_id=current_user.id,
|
||||
document_id=highlight_data.document_id,
|
||||
start_offset=highlight_data.start_offset,
|
||||
end_offset=highlight_data.end_offset,
|
||||
selected_text=highlight_data.selected_text,
|
||||
element_selector=highlight_data.element_selector,
|
||||
start_container_xpath=highlight_data.start_container_xpath,
|
||||
end_container_xpath=highlight_data.end_container_xpath,
|
||||
highlight_color=highlight_data.highlight_color,
|
||||
highlight_type=highlight_data.highlight_type
|
||||
)
|
||||
|
||||
db.add(highlight)
|
||||
await db.flush() # ID 생성을 위해
|
||||
|
||||
# 메모가 있으면 함께 생성
|
||||
note = None
|
||||
if highlight_data.note_content:
|
||||
note = Note(
|
||||
highlight_id=highlight.id,
|
||||
content=highlight_data.note_content
|
||||
)
|
||||
db.add(note)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(highlight)
|
||||
|
||||
# 응답 데이터 생성
|
||||
response_data = HighlightResponse.from_orm(highlight)
|
||||
if note:
|
||||
response_data.note = {
|
||||
"id": str(note.id),
|
||||
"content": note.content,
|
||||
"tags": note.tags,
|
||||
"created_at": note.created_at.isoformat(),
|
||||
"updated_at": note.updated_at.isoformat() if note.updated_at else None
|
||||
}
|
||||
|
||||
return response_data
|
||||
|
||||
|
||||
@router.get("/document/{document_id}", response_model=List[HighlightResponse])
|
||||
async def get_document_highlights(
|
||||
document_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""특정 문서의 하이라이트 목록 조회"""
|
||||
# 문서 존재 및 권한 확인
|
||||
result = await db.execute(select(Document).where(Document.id == document_id))
|
||||
document = result.scalar_one_or_none()
|
||||
|
||||
if not document:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Document not found"
|
||||
)
|
||||
|
||||
# 문서 접근 권한 확인
|
||||
if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions to access this document"
|
||||
)
|
||||
|
||||
# 사용자의 하이라이트만 조회
|
||||
result = await db.execute(
|
||||
select(Highlight)
|
||||
.options(selectinload(Highlight.note))
|
||||
.where(
|
||||
and_(
|
||||
Highlight.document_id == document_id,
|
||||
Highlight.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
.order_by(Highlight.start_offset)
|
||||
)
|
||||
highlights = result.scalars().all()
|
||||
|
||||
# 응답 데이터 변환
|
||||
response_data = []
|
||||
for highlight in highlights:
|
||||
highlight_data = HighlightResponse.from_orm(highlight)
|
||||
if highlight.note:
|
||||
highlight_data.note = {
|
||||
"id": str(highlight.note.id),
|
||||
"content": highlight.note.content,
|
||||
"tags": highlight.note.tags,
|
||||
"created_at": highlight.note.created_at.isoformat(),
|
||||
"updated_at": highlight.note.updated_at.isoformat() if highlight.note.updated_at else None
|
||||
}
|
||||
response_data.append(highlight_data)
|
||||
|
||||
return response_data
|
||||
|
||||
|
||||
@router.get("/{highlight_id}", response_model=HighlightResponse)
|
||||
async def get_highlight(
|
||||
highlight_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""하이라이트 상세 조회"""
|
||||
result = await db.execute(
|
||||
select(Highlight)
|
||||
.options(selectinload(Highlight.note))
|
||||
.where(Highlight.id == highlight_id)
|
||||
)
|
||||
highlight = result.scalar_one_or_none()
|
||||
|
||||
if not highlight:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Highlight not found"
|
||||
)
|
||||
|
||||
# 소유자 확인
|
||||
if highlight.user_id != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
|
||||
response_data = HighlightResponse.from_orm(highlight)
|
||||
if highlight.note:
|
||||
response_data.note = {
|
||||
"id": str(highlight.note.id),
|
||||
"content": highlight.note.content,
|
||||
"tags": highlight.note.tags,
|
||||
"created_at": highlight.note.created_at.isoformat(),
|
||||
"updated_at": highlight.note.updated_at.isoformat() if highlight.note.updated_at else None
|
||||
}
|
||||
|
||||
return response_data
|
||||
|
||||
|
||||
@router.put("/{highlight_id}", response_model=HighlightResponse)
|
||||
async def update_highlight(
|
||||
highlight_id: str,
|
||||
highlight_data: UpdateHighlightRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""하이라이트 업데이트"""
|
||||
result = await db.execute(
|
||||
select(Highlight)
|
||||
.options(selectinload(Highlight.note))
|
||||
.where(Highlight.id == highlight_id)
|
||||
)
|
||||
highlight = result.scalar_one_or_none()
|
||||
|
||||
if not highlight:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Highlight not found"
|
||||
)
|
||||
|
||||
# 소유자 확인
|
||||
if highlight.user_id != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
|
||||
# 업데이트
|
||||
if highlight_data.highlight_color:
|
||||
highlight.highlight_color = highlight_data.highlight_color
|
||||
if highlight_data.highlight_type:
|
||||
highlight.highlight_type = highlight_data.highlight_type
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(highlight)
|
||||
|
||||
response_data = HighlightResponse.from_orm(highlight)
|
||||
if highlight.note:
|
||||
response_data.note = {
|
||||
"id": str(highlight.note.id),
|
||||
"content": highlight.note.content,
|
||||
"tags": highlight.note.tags,
|
||||
"created_at": highlight.note.created_at.isoformat(),
|
||||
"updated_at": highlight.note.updated_at.isoformat() if highlight.note.updated_at else None
|
||||
}
|
||||
|
||||
return response_data
|
||||
|
||||
|
||||
@router.delete("/{highlight_id}")
|
||||
async def delete_highlight(
|
||||
highlight_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""하이라이트 삭제 (연결된 메모도 함께 삭제)"""
|
||||
result = await db.execute(select(Highlight).where(Highlight.id == highlight_id))
|
||||
highlight = result.scalar_one_or_none()
|
||||
|
||||
if not highlight:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Highlight not found"
|
||||
)
|
||||
|
||||
# 소유자 확인
|
||||
if highlight.user_id != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
|
||||
# 하이라이트 삭제 (CASCADE로 메모도 함께 삭제됨)
|
||||
await db.execute(delete(Highlight).where(Highlight.id == highlight_id))
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Highlight deleted successfully"}
|
||||
|
||||
|
||||
@router.get("/", response_model=List[HighlightResponse])
|
||||
async def list_user_highlights(
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
document_id: Optional[str] = None,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""사용자의 모든 하이라이트 조회"""
|
||||
query = select(Highlight).options(selectinload(Highlight.note)).where(
|
||||
Highlight.user_id == current_user.id
|
||||
)
|
||||
|
||||
if document_id:
|
||||
query = query.where(Highlight.document_id == document_id)
|
||||
|
||||
query = query.order_by(Highlight.created_at.desc()).offset(skip).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
highlights = result.scalars().all()
|
||||
|
||||
# 응답 데이터 변환
|
||||
response_data = []
|
||||
for highlight in highlights:
|
||||
highlight_data = HighlightResponse.from_orm(highlight)
|
||||
if highlight.note:
|
||||
highlight_data.note = {
|
||||
"id": str(highlight.note.id),
|
||||
"content": highlight.note.content,
|
||||
"tags": highlight.note.tags,
|
||||
"created_at": highlight.note.created_at.isoformat(),
|
||||
"updated_at": highlight.note.updated_at.isoformat() if highlight.note.updated_at else None
|
||||
}
|
||||
response_data.append(highlight_data)
|
||||
|
||||
return response_data
|
||||
404
backend/src/api/routes/notes.py
Normal file
404
backend/src/api/routes/notes.py
Normal file
@@ -0,0 +1,404 @@
|
||||
"""
|
||||
메모 관리 API 라우터
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete, and_, or_
|
||||
from sqlalchemy.orm import selectinload, joinedload
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from src.core.database import get_db
|
||||
from src.models.user import User
|
||||
from src.models.highlight import Highlight
|
||||
from src.models.note import Note
|
||||
from src.models.document import Document
|
||||
from src.api.dependencies import get_current_active_user
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class CreateNoteRequest(BaseModel):
|
||||
"""메모 생성 요청"""
|
||||
highlight_id: str
|
||||
content: str
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class UpdateNoteRequest(BaseModel):
|
||||
"""메모 업데이트 요청"""
|
||||
content: Optional[str] = None
|
||||
tags: Optional[List[str]] = None
|
||||
is_private: Optional[bool] = None
|
||||
|
||||
|
||||
class NoteResponse(BaseModel):
|
||||
"""메모 응답"""
|
||||
id: str
|
||||
highlight_id: str
|
||||
content: str
|
||||
is_private: bool
|
||||
tags: Optional[List[str]]
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime]
|
||||
# 연결된 하이라이트 정보
|
||||
highlight: dict
|
||||
# 문서 정보
|
||||
document: dict
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/", response_model=NoteResponse)
|
||||
async def create_note(
|
||||
note_data: CreateNoteRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""메모 생성 (하이라이트에 연결)"""
|
||||
# 하이라이트 존재 및 소유권 확인
|
||||
result = await db.execute(
|
||||
select(Highlight)
|
||||
.options(joinedload(Highlight.document))
|
||||
.where(Highlight.id == note_data.highlight_id)
|
||||
)
|
||||
highlight = result.scalar_one_or_none()
|
||||
|
||||
if not highlight:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Highlight not found"
|
||||
)
|
||||
|
||||
# 하이라이트 소유자 확인
|
||||
if highlight.user_id != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
|
||||
# 이미 메모가 있는지 확인
|
||||
existing_note = await db.execute(
|
||||
select(Note).where(Note.highlight_id == note_data.highlight_id)
|
||||
)
|
||||
if existing_note.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Note already exists for this highlight"
|
||||
)
|
||||
|
||||
# 메모 생성
|
||||
note = Note(
|
||||
highlight_id=note_data.highlight_id,
|
||||
content=note_data.content,
|
||||
tags=note_data.tags or []
|
||||
)
|
||||
|
||||
db.add(note)
|
||||
await db.commit()
|
||||
await db.refresh(note)
|
||||
|
||||
# 응답 데이터 생성
|
||||
response_data = NoteResponse.from_orm(note)
|
||||
response_data.highlight = {
|
||||
"id": str(highlight.id),
|
||||
"selected_text": highlight.selected_text,
|
||||
"highlight_color": highlight.highlight_color,
|
||||
"start_offset": highlight.start_offset,
|
||||
"end_offset": highlight.end_offset
|
||||
}
|
||||
response_data.document = {
|
||||
"id": str(highlight.document.id),
|
||||
"title": highlight.document.title
|
||||
}
|
||||
|
||||
return response_data
|
||||
|
||||
|
||||
@router.get("/", response_model=List[NoteResponse])
|
||||
async def list_user_notes(
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
document_id: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""사용자의 모든 메모 조회 (검색 가능)"""
|
||||
query = (
|
||||
select(Note)
|
||||
.options(
|
||||
joinedload(Note.highlight).joinedload(Highlight.document)
|
||||
)
|
||||
.join(Highlight)
|
||||
.where(Highlight.user_id == current_user.id)
|
||||
)
|
||||
|
||||
# 문서 필터링
|
||||
if document_id:
|
||||
query = query.where(Highlight.document_id == document_id)
|
||||
|
||||
# 태그 필터링
|
||||
if tag:
|
||||
query = query.where(Note.tags.contains([tag]))
|
||||
|
||||
# 검색 필터링 (메모 내용 + 하이라이트된 텍스트)
|
||||
if search:
|
||||
query = query.where(
|
||||
or_(
|
||||
Note.content.ilike(f"%{search}%"),
|
||||
Highlight.selected_text.ilike(f"%{search}%")
|
||||
)
|
||||
)
|
||||
|
||||
query = query.order_by(Note.created_at.desc()).offset(skip).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
notes = result.scalars().all()
|
||||
|
||||
# 응답 데이터 변환
|
||||
response_data = []
|
||||
for note in notes:
|
||||
note_data = NoteResponse.from_orm(note)
|
||||
note_data.highlight = {
|
||||
"id": str(note.highlight.id),
|
||||
"selected_text": note.highlight.selected_text,
|
||||
"highlight_color": note.highlight.highlight_color,
|
||||
"start_offset": note.highlight.start_offset,
|
||||
"end_offset": note.highlight.end_offset
|
||||
}
|
||||
note_data.document = {
|
||||
"id": str(note.highlight.document.id),
|
||||
"title": note.highlight.document.title
|
||||
}
|
||||
response_data.append(note_data)
|
||||
|
||||
return response_data
|
||||
|
||||
|
||||
@router.get("/{note_id}", response_model=NoteResponse)
|
||||
async def get_note(
|
||||
note_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""메모 상세 조회"""
|
||||
result = await db.execute(
|
||||
select(Note)
|
||||
.options(
|
||||
joinedload(Note.highlight).joinedload(Highlight.document)
|
||||
)
|
||||
.where(Note.id == note_id)
|
||||
)
|
||||
note = result.scalar_one_or_none()
|
||||
|
||||
if not note:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Note not found"
|
||||
)
|
||||
|
||||
# 소유자 확인
|
||||
if note.highlight.user_id != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
|
||||
response_data = NoteResponse.from_orm(note)
|
||||
response_data.highlight = {
|
||||
"id": str(note.highlight.id),
|
||||
"selected_text": note.highlight.selected_text,
|
||||
"highlight_color": note.highlight.highlight_color,
|
||||
"start_offset": note.highlight.start_offset,
|
||||
"end_offset": note.highlight.end_offset
|
||||
}
|
||||
response_data.document = {
|
||||
"id": str(note.highlight.document.id),
|
||||
"title": note.highlight.document.title
|
||||
}
|
||||
|
||||
return response_data
|
||||
|
||||
|
||||
@router.put("/{note_id}", response_model=NoteResponse)
|
||||
async def update_note(
|
||||
note_id: str,
|
||||
note_data: UpdateNoteRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""메모 업데이트"""
|
||||
result = await db.execute(
|
||||
select(Note)
|
||||
.options(
|
||||
joinedload(Note.highlight).joinedload(Highlight.document)
|
||||
)
|
||||
.where(Note.id == note_id)
|
||||
)
|
||||
note = result.scalar_one_or_none()
|
||||
|
||||
if not note:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Note not found"
|
||||
)
|
||||
|
||||
# 소유자 확인
|
||||
if note.highlight.user_id != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
|
||||
# 업데이트
|
||||
if note_data.content is not None:
|
||||
note.content = note_data.content
|
||||
if note_data.tags is not None:
|
||||
note.tags = note_data.tags
|
||||
if note_data.is_private is not None:
|
||||
note.is_private = note_data.is_private
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(note)
|
||||
|
||||
response_data = NoteResponse.from_orm(note)
|
||||
response_data.highlight = {
|
||||
"id": str(note.highlight.id),
|
||||
"selected_text": note.highlight.selected_text,
|
||||
"highlight_color": note.highlight.highlight_color,
|
||||
"start_offset": note.highlight.start_offset,
|
||||
"end_offset": note.highlight.end_offset
|
||||
}
|
||||
response_data.document = {
|
||||
"id": str(note.highlight.document.id),
|
||||
"title": note.highlight.document.title
|
||||
}
|
||||
|
||||
return response_data
|
||||
|
||||
|
||||
@router.delete("/{note_id}")
|
||||
async def delete_note(
|
||||
note_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""메모 삭제 (하이라이트는 유지)"""
|
||||
result = await db.execute(
|
||||
select(Note)
|
||||
.options(joinedload(Note.highlight))
|
||||
.where(Note.id == note_id)
|
||||
)
|
||||
note = result.scalar_one_or_none()
|
||||
|
||||
if not note:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Note not found"
|
||||
)
|
||||
|
||||
# 소유자 확인
|
||||
if note.highlight.user_id != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
|
||||
# 메모만 삭제 (하이라이트는 유지)
|
||||
await db.execute(delete(Note).where(Note.id == note_id))
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Note deleted successfully"}
|
||||
|
||||
|
||||
@router.get("/document/{document_id}", response_model=List[NoteResponse])
|
||||
async def get_document_notes(
|
||||
document_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""특정 문서의 모든 메모 조회"""
|
||||
# 문서 존재 및 권한 확인
|
||||
result = await db.execute(select(Document).where(Document.id == document_id))
|
||||
document = result.scalar_one_or_none()
|
||||
|
||||
if not document:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Document not found"
|
||||
)
|
||||
|
||||
# 문서 접근 권한 확인
|
||||
if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions to access this document"
|
||||
)
|
||||
|
||||
# 해당 문서의 사용자 메모 조회
|
||||
result = await db.execute(
|
||||
select(Note)
|
||||
.options(
|
||||
joinedload(Note.highlight).joinedload(Highlight.document)
|
||||
)
|
||||
.join(Highlight)
|
||||
.where(
|
||||
and_(
|
||||
Highlight.document_id == document_id,
|
||||
Highlight.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
.order_by(Highlight.start_offset)
|
||||
)
|
||||
notes = result.scalars().all()
|
||||
|
||||
# 응답 데이터 변환
|
||||
response_data = []
|
||||
for note in notes:
|
||||
note_data = NoteResponse.from_orm(note)
|
||||
note_data.highlight = {
|
||||
"id": str(note.highlight.id),
|
||||
"selected_text": note.highlight.selected_text,
|
||||
"highlight_color": note.highlight.highlight_color,
|
||||
"start_offset": note.highlight.start_offset,
|
||||
"end_offset": note.highlight.end_offset
|
||||
}
|
||||
note_data.document = {
|
||||
"id": str(note.highlight.document.id),
|
||||
"title": note.highlight.document.title
|
||||
}
|
||||
response_data.append(note_data)
|
||||
|
||||
return response_data
|
||||
|
||||
|
||||
@router.get("/tags/popular")
|
||||
async def get_popular_note_tags(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""인기 메모 태그 조회"""
|
||||
# 사용자의 메모에서 태그 빈도 계산
|
||||
result = await db.execute(
|
||||
select(Note)
|
||||
.join(Highlight)
|
||||
.where(Highlight.user_id == current_user.id)
|
||||
)
|
||||
notes = result.scalars().all()
|
||||
|
||||
# 태그 빈도 계산
|
||||
tag_counts = {}
|
||||
for note in notes:
|
||||
if note.tags:
|
||||
for tag in note.tags:
|
||||
tag_counts[tag] = tag_counts.get(tag, 0) + 1
|
||||
|
||||
# 빈도순 정렬
|
||||
popular_tags = sorted(tag_counts.items(), key=lambda x: x[1], reverse=True)[:10]
|
||||
|
||||
return [{"tag": tag, "count": count} for tag, count in popular_tags]
|
||||
354
backend/src/api/routes/search.py
Normal file
354
backend/src/api/routes/search.py
Normal file
@@ -0,0 +1,354 @@
|
||||
"""
|
||||
검색 API 라우터
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, or_, and_, text
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
from src.core.database import get_db
|
||||
from src.models.user import User
|
||||
from src.models.document import Document, Tag
|
||||
from src.models.highlight import Highlight
|
||||
from src.models.note import Note
|
||||
from src.api.dependencies import get_current_active_user
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SearchResult(BaseModel):
|
||||
"""검색 결과"""
|
||||
type: str # "document", "note", "highlight"
|
||||
id: str
|
||||
title: str
|
||||
content: str
|
||||
document_id: str
|
||||
document_title: str
|
||||
created_at: datetime
|
||||
relevance_score: float = 0.0
|
||||
highlight_info: Optional[Dict[str, Any]] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
"""검색 응답"""
|
||||
query: str
|
||||
total_results: int
|
||||
results: List[SearchResult]
|
||||
facets: Dict[str, List[Dict[str, Any]]] = {}
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_model=SearchResponse)
|
||||
async def search_all(
|
||||
q: str = Query(..., description="검색어"),
|
||||
type_filter: Optional[str] = Query(None, description="검색 타입 필터: document, note, highlight"),
|
||||
document_id: Optional[str] = Query(None, description="특정 문서 내 검색"),
|
||||
tag: Optional[str] = Query(None, description="태그 필터"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""통합 검색 (문서 + 메모 + 하이라이트)"""
|
||||
results = []
|
||||
|
||||
# 1. 문서 검색
|
||||
if not type_filter or type_filter == "document":
|
||||
document_results = await search_documents(q, document_id, tag, current_user, db)
|
||||
results.extend(document_results)
|
||||
|
||||
# 2. 메모 검색
|
||||
if not type_filter or type_filter == "note":
|
||||
note_results = await search_notes(q, document_id, tag, current_user, db)
|
||||
results.extend(note_results)
|
||||
|
||||
# 3. 하이라이트 검색
|
||||
if not type_filter or type_filter == "highlight":
|
||||
highlight_results = await search_highlights(q, document_id, current_user, db)
|
||||
results.extend(highlight_results)
|
||||
|
||||
# 관련성 점수로 정렬
|
||||
results.sort(key=lambda x: x.relevance_score, reverse=True)
|
||||
|
||||
# 페이지네이션
|
||||
total_results = len(results)
|
||||
paginated_results = results[skip:skip + limit]
|
||||
|
||||
# 패싯 정보 생성
|
||||
facets = await generate_search_facets(results, current_user, db)
|
||||
|
||||
return SearchResponse(
|
||||
query=q,
|
||||
total_results=total_results,
|
||||
results=paginated_results,
|
||||
facets=facets
|
||||
)
|
||||
|
||||
|
||||
async def search_documents(
|
||||
query: str,
|
||||
document_id: Optional[str],
|
||||
tag: Optional[str],
|
||||
current_user: User,
|
||||
db: AsyncSession
|
||||
) -> List[SearchResult]:
|
||||
"""문서 검색"""
|
||||
query_obj = select(Document).options(
|
||||
selectinload(Document.uploader),
|
||||
selectinload(Document.tags)
|
||||
)
|
||||
|
||||
# 권한 필터링
|
||||
if not current_user.is_admin:
|
||||
query_obj = query_obj.where(
|
||||
or_(
|
||||
Document.is_public == True,
|
||||
Document.uploaded_by == current_user.id
|
||||
)
|
||||
)
|
||||
|
||||
# 특정 문서 필터
|
||||
if document_id:
|
||||
query_obj = query_obj.where(Document.id == document_id)
|
||||
|
||||
# 태그 필터
|
||||
if tag:
|
||||
query_obj = query_obj.join(Document.tags).where(Tag.name == tag)
|
||||
|
||||
# 텍스트 검색
|
||||
search_condition = or_(
|
||||
Document.title.ilike(f"%{query}%"),
|
||||
Document.description.ilike(f"%{query}%")
|
||||
)
|
||||
query_obj = query_obj.where(search_condition)
|
||||
|
||||
result = await db.execute(query_obj)
|
||||
documents = result.scalars().all()
|
||||
|
||||
search_results = []
|
||||
for doc in documents:
|
||||
# 관련성 점수 계산 (제목 매치가 더 높은 점수)
|
||||
score = 0.0
|
||||
if query.lower() in doc.title.lower():
|
||||
score += 2.0
|
||||
if doc.description and query.lower() in doc.description.lower():
|
||||
score += 1.0
|
||||
|
||||
search_results.append(SearchResult(
|
||||
type="document",
|
||||
id=str(doc.id),
|
||||
title=doc.title,
|
||||
content=doc.description or "",
|
||||
document_id=str(doc.id),
|
||||
document_title=doc.title,
|
||||
created_at=doc.created_at,
|
||||
relevance_score=score
|
||||
))
|
||||
|
||||
return search_results
|
||||
|
||||
|
||||
async def search_notes(
|
||||
query: str,
|
||||
document_id: Optional[str],
|
||||
tag: Optional[str],
|
||||
current_user: User,
|
||||
db: AsyncSession
|
||||
) -> List[SearchResult]:
|
||||
"""메모 검색"""
|
||||
query_obj = (
|
||||
select(Note)
|
||||
.options(
|
||||
joinedload(Note.highlight).joinedload(Highlight.document)
|
||||
)
|
||||
.join(Highlight)
|
||||
.where(Highlight.user_id == current_user.id)
|
||||
)
|
||||
|
||||
# 특정 문서 필터
|
||||
if document_id:
|
||||
query_obj = query_obj.where(Highlight.document_id == document_id)
|
||||
|
||||
# 태그 필터
|
||||
if tag:
|
||||
query_obj = query_obj.where(Note.tags.contains([tag]))
|
||||
|
||||
# 텍스트 검색 (메모 내용 + 하이라이트된 텍스트)
|
||||
search_condition = or_(
|
||||
Note.content.ilike(f"%{query}%"),
|
||||
Highlight.selected_text.ilike(f"%{query}%")
|
||||
)
|
||||
query_obj = query_obj.where(search_condition)
|
||||
|
||||
result = await db.execute(query_obj)
|
||||
notes = result.scalars().all()
|
||||
|
||||
search_results = []
|
||||
for note in notes:
|
||||
# 관련성 점수 계산
|
||||
score = 0.0
|
||||
if query.lower() in note.content.lower():
|
||||
score += 2.0
|
||||
if query.lower() in note.highlight.selected_text.lower():
|
||||
score += 1.5
|
||||
|
||||
search_results.append(SearchResult(
|
||||
type="note",
|
||||
id=str(note.id),
|
||||
title=f"메모: {note.highlight.selected_text[:50]}...",
|
||||
content=note.content,
|
||||
document_id=str(note.highlight.document.id),
|
||||
document_title=note.highlight.document.title,
|
||||
created_at=note.created_at,
|
||||
relevance_score=score,
|
||||
highlight_info={
|
||||
"highlight_id": str(note.highlight.id),
|
||||
"selected_text": note.highlight.selected_text,
|
||||
"start_offset": note.highlight.start_offset,
|
||||
"end_offset": note.highlight.end_offset
|
||||
}
|
||||
))
|
||||
|
||||
return search_results
|
||||
|
||||
|
||||
async def search_highlights(
|
||||
query: str,
|
||||
document_id: Optional[str],
|
||||
current_user: User,
|
||||
db: AsyncSession
|
||||
) -> List[SearchResult]:
|
||||
"""하이라이트 검색"""
|
||||
query_obj = (
|
||||
select(Highlight)
|
||||
.options(joinedload(Highlight.document))
|
||||
.where(Highlight.user_id == current_user.id)
|
||||
)
|
||||
|
||||
# 특정 문서 필터
|
||||
if document_id:
|
||||
query_obj = query_obj.where(Highlight.document_id == document_id)
|
||||
|
||||
# 텍스트 검색
|
||||
query_obj = query_obj.where(Highlight.selected_text.ilike(f"%{query}%"))
|
||||
|
||||
result = await db.execute(query_obj)
|
||||
highlights = result.scalars().all()
|
||||
|
||||
search_results = []
|
||||
for highlight in highlights:
|
||||
# 관련성 점수 계산
|
||||
score = 1.0 if query.lower() in highlight.selected_text.lower() else 0.5
|
||||
|
||||
search_results.append(SearchResult(
|
||||
type="highlight",
|
||||
id=str(highlight.id),
|
||||
title=f"하이라이트: {highlight.selected_text[:50]}...",
|
||||
content=highlight.selected_text,
|
||||
document_id=str(highlight.document.id),
|
||||
document_title=highlight.document.title,
|
||||
created_at=highlight.created_at,
|
||||
relevance_score=score,
|
||||
highlight_info={
|
||||
"highlight_id": str(highlight.id),
|
||||
"selected_text": highlight.selected_text,
|
||||
"start_offset": highlight.start_offset,
|
||||
"end_offset": highlight.end_offset,
|
||||
"highlight_color": highlight.highlight_color
|
||||
}
|
||||
))
|
||||
|
||||
return search_results
|
||||
|
||||
|
||||
async def generate_search_facets(
|
||||
results: List[SearchResult],
|
||||
current_user: User,
|
||||
db: AsyncSession
|
||||
) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""검색 결과 패싯 생성"""
|
||||
facets = {}
|
||||
|
||||
# 타입별 개수
|
||||
type_counts = {}
|
||||
for result in results:
|
||||
type_counts[result.type] = type_counts.get(result.type, 0) + 1
|
||||
|
||||
facets["types"] = [
|
||||
{"name": type_name, "count": count}
|
||||
for type_name, count in type_counts.items()
|
||||
]
|
||||
|
||||
# 문서별 개수
|
||||
document_counts = {}
|
||||
for result in results:
|
||||
doc_title = result.document_title
|
||||
document_counts[doc_title] = document_counts.get(doc_title, 0) + 1
|
||||
|
||||
facets["documents"] = [
|
||||
{"name": doc_title, "count": count}
|
||||
for doc_title, count in sorted(document_counts.items(), key=lambda x: x[1], reverse=True)[:10]
|
||||
]
|
||||
|
||||
return facets
|
||||
|
||||
|
||||
@router.get("/suggestions")
|
||||
async def get_search_suggestions(
|
||||
q: str = Query(..., min_length=2, description="검색어 (최소 2글자)"),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""검색어 자동완성 제안"""
|
||||
suggestions = []
|
||||
|
||||
# 문서 제목에서 제안
|
||||
doc_result = await db.execute(
|
||||
select(Document.title)
|
||||
.where(
|
||||
and_(
|
||||
Document.title.ilike(f"%{q}%"),
|
||||
or_(
|
||||
Document.is_public == True,
|
||||
Document.uploaded_by == current_user.id
|
||||
) if not current_user.is_admin else text("true")
|
||||
)
|
||||
)
|
||||
.limit(5)
|
||||
)
|
||||
doc_titles = doc_result.scalars().all()
|
||||
suggestions.extend([{"text": title, "type": "document"} for title in doc_titles])
|
||||
|
||||
# 태그에서 제안
|
||||
tag_result = await db.execute(
|
||||
select(Tag.name)
|
||||
.where(Tag.name.ilike(f"%{q}%"))
|
||||
.limit(5)
|
||||
)
|
||||
tag_names = tag_result.scalars().all()
|
||||
suggestions.extend([{"text": name, "type": "tag"} for name in tag_names])
|
||||
|
||||
# 메모 태그에서 제안
|
||||
note_result = await db.execute(
|
||||
select(Note.tags)
|
||||
.join(Highlight)
|
||||
.where(Highlight.user_id == current_user.id)
|
||||
)
|
||||
notes = note_result.scalars().all()
|
||||
|
||||
note_tags = set()
|
||||
for note in notes:
|
||||
if note and isinstance(note, list):
|
||||
for tag in note:
|
||||
if q.lower() in tag.lower():
|
||||
note_tags.add(tag)
|
||||
|
||||
suggestions.extend([{"text": tag, "type": "note_tag"} for tag in list(note_tags)[:5]])
|
||||
|
||||
return {"suggestions": suggestions[:10]}
|
||||
176
backend/src/api/routes/users.py
Normal file
176
backend/src/api/routes/users.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
사용자 관리 API 라우터
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update, delete
|
||||
from typing import List
|
||||
|
||||
from src.core.database import get_db
|
||||
from src.models.user import User
|
||||
from src.schemas.auth import UserInfo
|
||||
from src.api.dependencies import get_current_active_user, get_current_admin_user
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class UpdateProfileRequest(BaseModel):
|
||||
"""프로필 업데이트 요청"""
|
||||
full_name: str = None
|
||||
theme: str = None
|
||||
language: str = None
|
||||
timezone: str = None
|
||||
|
||||
|
||||
class UpdateUserRequest(BaseModel):
|
||||
"""사용자 정보 업데이트 요청 (관리자용)"""
|
||||
full_name: str = None
|
||||
is_active: bool = None
|
||||
is_admin: bool = None
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/profile", response_model=UserInfo)
|
||||
async def get_profile(
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
"""현재 사용자 프로필 조회"""
|
||||
return UserInfo.from_orm(current_user)
|
||||
|
||||
|
||||
@router.put("/profile", response_model=UserInfo)
|
||||
async def update_profile(
|
||||
profile_data: UpdateProfileRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""프로필 업데이트"""
|
||||
update_data = {}
|
||||
|
||||
if profile_data.full_name is not None:
|
||||
update_data["full_name"] = profile_data.full_name
|
||||
if profile_data.theme is not None:
|
||||
update_data["theme"] = profile_data.theme
|
||||
if profile_data.language is not None:
|
||||
update_data["language"] = profile_data.language
|
||||
if profile_data.timezone is not None:
|
||||
update_data["timezone"] = profile_data.timezone
|
||||
|
||||
if update_data:
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == current_user.id)
|
||||
.values(**update_data)
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
|
||||
return UserInfo.from_orm(current_user)
|
||||
|
||||
|
||||
@router.get("/", response_model=List[UserInfo])
|
||||
async def list_users(
|
||||
admin_user: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""사용자 목록 조회 (관리자 전용)"""
|
||||
result = await db.execute(select(User).order_by(User.created_at.desc()))
|
||||
users = result.scalars().all()
|
||||
return [UserInfo.from_orm(user) for user in users]
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserInfo)
|
||||
async def get_user(
|
||||
user_id: str,
|
||||
admin_user: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""특정 사용자 조회 (관리자 전용)"""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
return UserInfo.from_orm(user)
|
||||
|
||||
|
||||
@router.put("/{user_id}", response_model=UserInfo)
|
||||
async def update_user(
|
||||
user_id: str,
|
||||
user_data: UpdateUserRequest,
|
||||
admin_user: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""사용자 정보 업데이트 (관리자 전용)"""
|
||||
# 사용자 존재 확인
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
# 자기 자신의 관리자 권한은 제거할 수 없음
|
||||
if user.id == admin_user.id and user_data.is_admin is False:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot remove admin privileges from yourself"
|
||||
)
|
||||
|
||||
# 업데이트할 데이터 준비
|
||||
update_data = {}
|
||||
if user_data.full_name is not None:
|
||||
update_data["full_name"] = user_data.full_name
|
||||
if user_data.is_active is not None:
|
||||
update_data["is_active"] = user_data.is_active
|
||||
if user_data.is_admin is not None:
|
||||
update_data["is_admin"] = user_data.is_admin
|
||||
|
||||
if update_data:
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == user_id)
|
||||
.values(**update_data)
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
return UserInfo.from_orm(user)
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: str,
|
||||
admin_user: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""사용자 삭제 (관리자 전용)"""
|
||||
# 사용자 존재 확인
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
# 자기 자신은 삭제할 수 없음
|
||||
if user.id == admin_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot delete yourself"
|
||||
)
|
||||
|
||||
# 사용자 삭제
|
||||
await db.execute(delete(User).where(User.id == user_id))
|
||||
await db.commit()
|
||||
|
||||
return {"message": "User deleted successfully"}
|
||||
52
backend/src/core/config.py
Normal file
52
backend/src/core/config.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
애플리케이션 설정
|
||||
"""
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import List
|
||||
import os
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""애플리케이션 설정 클래스"""
|
||||
|
||||
# 기본 설정
|
||||
APP_NAME: str = "Document Server"
|
||||
DEBUG: bool = True
|
||||
VERSION: str = "0.1.0"
|
||||
|
||||
# 데이터베이스 설정
|
||||
DATABASE_URL: str = "postgresql+asyncpg://docuser:docpass@localhost:24101/document_db"
|
||||
|
||||
# Redis 설정
|
||||
REDIS_URL: str = "redis://localhost:24103/0"
|
||||
|
||||
# JWT 설정
|
||||
SECRET_KEY: str = "your-secret-key-change-this-in-production"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
|
||||
# CORS 설정
|
||||
ALLOWED_HOSTS: List[str] = ["http://localhost:24100", "http://127.0.0.1:24100"]
|
||||
|
||||
# 파일 업로드 설정
|
||||
UPLOAD_DIR: str = "uploads"
|
||||
MAX_FILE_SIZE: int = 100 * 1024 * 1024 # 100MB
|
||||
ALLOWED_EXTENSIONS: List[str] = [".html", ".htm", ".pdf"]
|
||||
|
||||
# 관리자 계정 설정 (초기 설정용)
|
||||
ADMIN_EMAIL: str = "admin@document-server.local"
|
||||
ADMIN_PASSWORD: str = "admin123" # 프로덕션에서는 반드시 변경
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = True
|
||||
|
||||
|
||||
# 설정 인스턴스 생성
|
||||
settings = Settings()
|
||||
|
||||
# 업로드 디렉토리 생성
|
||||
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
|
||||
os.makedirs(f"{settings.UPLOAD_DIR}/documents", exist_ok=True)
|
||||
os.makedirs(f"{settings.UPLOAD_DIR}/thumbnails", exist_ok=True)
|
||||
94
backend/src/core/database.py
Normal file
94
backend/src/core/database.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
데이터베이스 설정 및 연결
|
||||
"""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from sqlalchemy import MetaData
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from src.core.config import settings
|
||||
|
||||
|
||||
# SQLAlchemy 메타데이터 설정
|
||||
metadata = MetaData(
|
||||
naming_convention={
|
||||
"ix": "ix_%(column_0_label)s",
|
||||
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
||||
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
||||
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
||||
"pk": "pk_%(table_name)s"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""SQLAlchemy Base 클래스"""
|
||||
metadata = metadata
|
||||
|
||||
|
||||
# 비동기 데이터베이스 엔진 생성
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DEBUG,
|
||||
future=True,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=300,
|
||||
)
|
||||
|
||||
# 비동기 세션 팩토리
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""데이터베이스 세션 의존성"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""데이터베이스 초기화"""
|
||||
from src.models import user, document, highlight, note, bookmark, tag
|
||||
|
||||
async with engine.begin() as conn:
|
||||
# 모든 테이블 생성
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
# 관리자 계정 생성
|
||||
await create_admin_user()
|
||||
|
||||
|
||||
async def create_admin_user() -> None:
|
||||
"""관리자 계정 생성 (존재하지 않을 경우)"""
|
||||
from src.models.user import User
|
||||
from src.core.security import get_password_hash
|
||||
from sqlalchemy import select
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
# 관리자 계정 존재 확인
|
||||
result = await session.execute(
|
||||
select(User).where(User.email == settings.ADMIN_EMAIL)
|
||||
)
|
||||
admin_user = result.scalar_one_or_none()
|
||||
|
||||
if not admin_user:
|
||||
# 관리자 계정 생성
|
||||
admin_user = User(
|
||||
email=settings.ADMIN_EMAIL,
|
||||
hashed_password=get_password_hash(settings.ADMIN_PASSWORD),
|
||||
is_active=True,
|
||||
is_admin=True,
|
||||
full_name="Administrator"
|
||||
)
|
||||
session.add(admin_user)
|
||||
await session.commit()
|
||||
print(f"관리자 계정이 생성되었습니다: {settings.ADMIN_EMAIL}")
|
||||
87
backend/src/core/security.py
Normal file
87
backend/src/core/security.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
보안 관련 유틸리티
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Union
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from src.core.config import settings
|
||||
|
||||
|
||||
# 비밀번호 해싱 컨텍스트
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""비밀번호 검증"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""비밀번호 해싱"""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""액세스 토큰 생성"""
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
|
||||
to_encode.update({"exp": expire, "type": "access"})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def create_refresh_token(data: dict) -> str:
|
||||
"""리프레시 토큰 생성"""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
to_encode.update({"exp": expire, "type": "refresh"})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def verify_token(token: str, token_type: str = "access") -> dict:
|
||||
"""토큰 검증"""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
|
||||
# 토큰 타입 확인
|
||||
if payload.get("type") != token_type:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token type"
|
||||
)
|
||||
|
||||
# 만료 시간 확인
|
||||
exp = payload.get("exp")
|
||||
if exp is None or datetime.utcnow() > datetime.fromtimestamp(exp):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token expired"
|
||||
)
|
||||
|
||||
return payload
|
||||
|
||||
except JWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials"
|
||||
)
|
||||
|
||||
|
||||
def get_user_id_from_token(token: str) -> str:
|
||||
"""토큰에서 사용자 ID 추출"""
|
||||
payload = verify_token(token)
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials"
|
||||
)
|
||||
return user_id
|
||||
72
backend/src/main.py
Normal file
72
backend/src/main.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
Document Server - FastAPI Main Application
|
||||
"""
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from contextlib import asynccontextmanager
|
||||
import uvicorn
|
||||
|
||||
from src.core.config import settings
|
||||
from src.core.database import init_db
|
||||
from src.api.routes import auth, users, documents, highlights, notes, bookmarks, search
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""애플리케이션 시작/종료 시 실행되는 함수"""
|
||||
# 시작 시 데이터베이스 초기화
|
||||
await init_db()
|
||||
yield
|
||||
# 종료 시 정리 작업 (필요시)
|
||||
|
||||
|
||||
# FastAPI 앱 생성
|
||||
app = FastAPI(
|
||||
title="Document Server",
|
||||
description="HTML Document Management and Viewer System",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# CORS 설정
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.ALLOWED_HOSTS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 정적 파일 서빙 (업로드된 파일들)
|
||||
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
||||
|
||||
# API 라우터 등록
|
||||
app.include_router(auth.router, prefix="/api/auth", tags=["인증"])
|
||||
app.include_router(users.router, prefix="/api/users", tags=["사용자"])
|
||||
app.include_router(documents.router, prefix="/api/documents", tags=["문서"])
|
||||
app.include_router(highlights.router, prefix="/api/highlights", tags=["하이라이트"])
|
||||
app.include_router(notes.router, prefix="/api/notes", tags=["메모"])
|
||||
app.include_router(bookmarks.router, prefix="/api/bookmarks", tags=["책갈피"])
|
||||
app.include_router(search.router, prefix="/api/search", tags=["검색"])
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""루트 엔드포인트"""
|
||||
return {"message": "Document Server API", "version": "0.1.0"}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""헬스체크 엔드포인트"""
|
||||
return {"status": "healthy"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(
|
||||
"src.main:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
reload=True if settings.DEBUG else False,
|
||||
)
|
||||
17
backend/src/models/__init__.py
Normal file
17
backend/src/models/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""
|
||||
모델 패키지 초기화
|
||||
"""
|
||||
from src.models.user import User
|
||||
from src.models.document import Document, Tag
|
||||
from src.models.highlight import Highlight
|
||||
from src.models.note import Note
|
||||
from src.models.bookmark import Bookmark
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"Document",
|
||||
"Tag",
|
||||
"Highlight",
|
||||
"Note",
|
||||
"Bookmark",
|
||||
]
|
||||
42
backend/src/models/bookmark.py
Normal file
42
backend/src/models/bookmark.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
책갈피 모델
|
||||
"""
|
||||
from sqlalchemy import Column, String, DateTime, Text, Integer, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
import uuid
|
||||
|
||||
from src.core.database import Base
|
||||
|
||||
|
||||
class Bookmark(Base):
|
||||
"""책갈피 테이블"""
|
||||
__tablename__ = "bookmarks"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
|
||||
# 연결 정보
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
document_id = Column(UUID(as_uuid=True), ForeignKey("documents.id"), nullable=False)
|
||||
|
||||
# 책갈피 정보
|
||||
title = Column(String(200), nullable=False) # 책갈피 제목
|
||||
description = Column(Text, nullable=True) # 설명
|
||||
|
||||
# 위치 정보
|
||||
page_number = Column(Integer, nullable=True) # 페이지 번호 (추정)
|
||||
scroll_position = Column(Integer, default=0) # 스크롤 위치 (픽셀)
|
||||
element_id = Column(String(100), nullable=True) # 특정 요소 ID
|
||||
element_selector = Column(Text, nullable=True) # CSS 선택자
|
||||
|
||||
# 메타데이터
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# 관계
|
||||
user = relationship("User", backref="bookmarks")
|
||||
document = relationship("Document", back_populates="bookmarks")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Bookmark(title='{self.title}', document='{self.document_id}')>"
|
||||
81
backend/src/models/document.py
Normal file
81
backend/src/models/document.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""
|
||||
문서 모델
|
||||
"""
|
||||
from sqlalchemy import Column, String, DateTime, Text, Integer, Boolean, ForeignKey, Table
|
||||
from sqlalchemy.dialects.postgresql import UUID, ARRAY
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
import uuid
|
||||
|
||||
from src.core.database import Base
|
||||
|
||||
|
||||
# 문서-태그 다대다 관계 테이블
|
||||
document_tags = Table(
|
||||
'document_tags',
|
||||
Base.metadata,
|
||||
Column('document_id', UUID(as_uuid=True), ForeignKey('documents.id'), primary_key=True),
|
||||
Column('tag_id', UUID(as_uuid=True), ForeignKey('tags.id'), primary_key=True)
|
||||
)
|
||||
|
||||
|
||||
class Document(Base):
|
||||
"""문서 테이블"""
|
||||
__tablename__ = "documents"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
title = Column(String(500), nullable=False, index=True)
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# 파일 정보
|
||||
html_path = Column(String(1000), nullable=False) # HTML 파일 경로
|
||||
pdf_path = Column(String(1000), nullable=True) # PDF 원본 경로 (선택)
|
||||
thumbnail_path = Column(String(1000), nullable=True) # 썸네일 경로
|
||||
|
||||
# 메타데이터
|
||||
file_size = Column(Integer, nullable=True) # 바이트 단위
|
||||
page_count = Column(Integer, nullable=True) # 페이지 수 (추정)
|
||||
language = Column(String(10), default="ko") # 문서 언어
|
||||
|
||||
# 업로드 정보
|
||||
uploaded_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
original_filename = Column(String(500), nullable=True)
|
||||
|
||||
# 상태
|
||||
is_public = Column(Boolean, default=False) # 공개 여부
|
||||
is_processed = Column(Boolean, default=True) # 처리 완료 여부
|
||||
|
||||
# 시간 정보
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
document_date = Column(DateTime(timezone=True), nullable=True) # 문서 작성일 (사용자 입력)
|
||||
|
||||
# 관계
|
||||
uploader = relationship("User", backref="uploaded_documents")
|
||||
tags = relationship("Tag", secondary=document_tags, back_populates="documents")
|
||||
highlights = relationship("Highlight", back_populates="document", cascade="all, delete-orphan")
|
||||
bookmarks = relationship("Bookmark", back_populates="document", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Document(title='{self.title}', id='{self.id}')>"
|
||||
|
||||
|
||||
class Tag(Base):
|
||||
"""태그 테이블"""
|
||||
__tablename__ = "tags"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name = Column(String(100), unique=True, nullable=False, index=True)
|
||||
color = Column(String(7), default="#3B82F6") # HEX 색상 코드
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# 메타데이터
|
||||
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# 관계
|
||||
creator = relationship("User", backref="created_tags")
|
||||
documents = relationship("Document", secondary=document_tags, back_populates="tags")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Tag(name='{self.name}', color='{self.color}')>"
|
||||
47
backend/src/models/highlight.py
Normal file
47
backend/src/models/highlight.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
하이라이트 모델
|
||||
"""
|
||||
from sqlalchemy import Column, String, DateTime, Text, Integer, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
import uuid
|
||||
|
||||
from src.core.database import Base
|
||||
|
||||
|
||||
class Highlight(Base):
|
||||
"""하이라이트 테이블"""
|
||||
__tablename__ = "highlights"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
|
||||
# 연결 정보
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
document_id = Column(UUID(as_uuid=True), ForeignKey("documents.id"), nullable=False)
|
||||
|
||||
# 텍스트 위치 정보
|
||||
start_offset = Column(Integer, nullable=False) # 시작 위치
|
||||
end_offset = Column(Integer, nullable=False) # 끝 위치
|
||||
selected_text = Column(Text, nullable=False) # 선택된 텍스트 (검색용)
|
||||
|
||||
# DOM 위치 정보 (정확한 복원을 위해)
|
||||
element_selector = Column(Text, nullable=True) # CSS 선택자
|
||||
start_container_xpath = Column(Text, nullable=True) # 시작 컨테이너 XPath
|
||||
end_container_xpath = Column(Text, nullable=True) # 끝 컨테이너 XPath
|
||||
|
||||
# 스타일 정보
|
||||
highlight_color = Column(String(7), default="#FFFF00") # HEX 색상 코드
|
||||
highlight_type = Column(String(20), default="highlight") # highlight, underline, etc.
|
||||
|
||||
# 메타데이터
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# 관계
|
||||
user = relationship("User", backref="highlights")
|
||||
document = relationship("Document", back_populates="highlights")
|
||||
note = relationship("Note", back_populates="highlight", uselist=False, cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Highlight(id='{self.id}', text='{self.selected_text[:50]}...')>"
|
||||
47
backend/src/models/note.py
Normal file
47
backend/src/models/note.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
메모 모델
|
||||
"""
|
||||
from sqlalchemy import Column, String, DateTime, Text, Boolean, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID, ARRAY
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
import uuid
|
||||
|
||||
from src.core.database import Base
|
||||
|
||||
|
||||
class Note(Base):
|
||||
"""메모 테이블 (하이라이트와 1:1 관계)"""
|
||||
__tablename__ = "notes"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
|
||||
# 연결 정보
|
||||
highlight_id = Column(UUID(as_uuid=True), ForeignKey("highlights.id"), nullable=False, unique=True)
|
||||
|
||||
# 메모 내용
|
||||
content = Column(Text, nullable=False)
|
||||
is_private = Column(Boolean, default=True) # 개인 메모 여부
|
||||
|
||||
# 태그 (메모 분류용)
|
||||
tags = Column(ARRAY(String), nullable=True) # ["중요", "질문", "아이디어"]
|
||||
|
||||
# 메타데이터
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# 관계
|
||||
highlight = relationship("Highlight", back_populates="note")
|
||||
|
||||
@property
|
||||
def user_id(self):
|
||||
"""하이라이트를 통해 사용자 ID 가져오기"""
|
||||
return self.highlight.user_id if self.highlight else None
|
||||
|
||||
@property
|
||||
def document_id(self):
|
||||
"""하이라이트를 통해 문서 ID 가져오기"""
|
||||
return self.highlight.document_id if self.highlight else None
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Note(id='{self.id}', content='{self.content[:50]}...')>"
|
||||
34
backend/src/models/user.py
Normal file
34
backend/src/models/user.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
사용자 모델
|
||||
"""
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.sql import func
|
||||
import uuid
|
||||
|
||||
from src.core.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""사용자 테이블"""
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
email = Column(String(255), unique=True, index=True, nullable=False)
|
||||
hashed_password = Column(String(255), nullable=False)
|
||||
full_name = Column(String(255), nullable=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_admin = Column(Boolean, default=False)
|
||||
|
||||
# 메타데이터
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
last_login = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# 사용자 설정
|
||||
theme = Column(String(50), default="light") # light, dark
|
||||
language = Column(String(10), default="ko") # ko, en
|
||||
timezone = Column(String(50), default="Asia/Seoul")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User(email='{self.email}', full_name='{self.full_name}')>"
|
||||
56
backend/src/schemas/auth.py
Normal file
56
backend/src/schemas/auth.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
인증 관련 스키마
|
||||
"""
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""로그인 요청"""
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
"""토큰 응답"""
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int # 초 단위
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
"""토큰 갱신 요청"""
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class UserInfo(BaseModel):
|
||||
"""사용자 정보"""
|
||||
id: str
|
||||
email: str
|
||||
full_name: Optional[str] = None
|
||||
is_active: bool
|
||||
is_admin: bool
|
||||
theme: str
|
||||
language: str
|
||||
timezone: str
|
||||
created_at: datetime
|
||||
last_login: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
"""비밀번호 변경 요청"""
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class CreateUserRequest(BaseModel):
|
||||
"""사용자 생성 요청 (관리자용)"""
|
||||
email: EmailStr
|
||||
password: str
|
||||
full_name: Optional[str] = None
|
||||
is_admin: bool = False
|
||||
Reference in New Issue
Block a user