feat: 완전한 문서 업로드 및 관리 시스템 구현

- 백엔드 API 완전 구현 (FastAPI + SQLAlchemy + PostgreSQL)
  - 사용자 인증 (JWT 토큰 기반)
  - 문서 CRUD (업로드, 조회, 목록, 삭제)
  - 하이라이트, 메모, 책갈피 관리
  - 태그 시스템 및 검색 기능
  - Pydantic v2 호환성 수정

- 프론트엔드 완전 구현 (Alpine.js + Tailwind CSS)
  - 로그인/로그아웃 기능
  - 문서 업로드 모달 (드래그앤드롭, 파일 검증)
  - 문서 목록 및 필터링
  - 뷰어 페이지 (하이라이트, 메모, 책갈피 UI)
  - 실시간 목록 새로고침

- 시스템 안정성 개선
  - Alpine.js 컴포넌트 간 안전한 통신 (이벤트 기반)
  - API 오류 처리 및 사용자 피드백
  - 파비콘 추가로 404 오류 해결

- 포트 구성: Frontend(24100), Backend(24102), DB(24101), Redis(24103)
This commit is contained in:
Hyungi Ahn
2025-08-22 06:42:26 +09:00
parent 3036b8f0fb
commit a42d193508
28 changed files with 1213 additions and 152 deletions

View File

@@ -6,15 +6,15 @@ 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 (
from ...core.database import get_db
from ...core.security import verify_password, create_access_token, create_refresh_token, get_password_hash
from ...core.config import settings
from ...models.user import User
from ...schemas.auth import (
LoginRequest, TokenResponse, RefreshTokenRequest,
UserInfo, ChangePasswordRequest, CreateUserRequest
)
from src.api.dependencies import get_current_active_user, get_current_admin_user
from ..dependencies import get_current_active_user, get_current_admin_user
router = APIRouter()
@@ -71,7 +71,7 @@ async def refresh_token(
db: AsyncSession = Depends(get_db)
):
"""토큰 갱신"""
from src.core.security import verify_token
from ...core.security import verify_token
try:
# 리프레시 토큰 검증
@@ -116,7 +116,7 @@ async def get_current_user_info(
current_user: User = Depends(get_current_active_user)
):
"""현재 사용자 정보 조회"""
return UserInfo.from_orm(current_user)
return UserInfo.model_validate(current_user)
@router.put("/change-password")

View File

@@ -8,11 +8,11 @@ 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 ...core.database import get_db
from ...models.user import User
from ...models.document import Document
from ...models.bookmark import Bookmark
from ..dependencies import get_current_active_user
from pydantic import BaseModel

View File

@@ -11,11 +11,11 @@ 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 ...core.database import get_db
from ...core.config import settings
from ...models.user import User
from ...models.document import Document, Tag
from ..dependencies import get_current_active_user, get_current_admin_user
from pydantic import BaseModel
from datetime import datetime
@@ -110,9 +110,24 @@ async def list_documents(
# 응답 데이터 변환
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]
doc_data = DocumentResponse(
id=str(doc.id),
title=doc.title,
description=doc.description,
html_path=doc.html_path,
pdf_path=doc.pdf_path,
thumbnail_path=doc.thumbnail_path,
file_size=doc.file_size,
page_count=doc.page_count,
language=doc.language,
is_public=doc.is_public,
is_processed=doc.is_processed,
created_at=doc.created_at,
updated_at=doc.updated_at,
document_date=doc.document_date,
uploader_name=doc.uploader.full_name or doc.uploader.email,
tags=[tag.name for tag in doc.tags]
)
response_data.append(doc_data)
return response_data
@@ -123,8 +138,9 @@ async def upload_document(
title: str = Form(...),
description: Optional[str] = Form(None),
document_date: Optional[str] = Form(None),
language: Optional[str] = Form("ko"),
is_public: bool = Form(False),
tags: Optional[str] = Form(None), # 쉼표로 구분된 태그
tags: Optional[List[str]] = Form(None), # 태그 리스트
html_file: UploadFile = File(...),
pdf_file: Optional[UploadFile] = File(None),
current_user: User = Depends(get_current_active_user),
@@ -172,7 +188,8 @@ async def upload_document(
description=description,
html_path=html_path,
pdf_path=pdf_path,
file_size=len(await html_file.read()) if html_file else None,
language=language,
file_size=len(content), # HTML 파일 크기
uploaded_by=current_user.id,
original_filename=html_file.filename,
is_public=is_public,
@@ -201,14 +218,34 @@ async def upload_document(
document.tags.append(tag)
await db.commit()
await db.refresh(document)
# 문서 정보를 다시 로드 (태그 포함)
result = await db.execute(
select(Document)
.options(selectinload(Document.tags))
.where(Document.id == document.id)
)
document_with_tags = result.scalar_one()
# 응답 데이터 생성
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
return DocumentResponse(
id=str(document_with_tags.id),
title=document_with_tags.title,
description=document_with_tags.description,
html_path=document_with_tags.html_path,
pdf_path=document_with_tags.pdf_path,
thumbnail_path=document_with_tags.thumbnail_path,
file_size=document_with_tags.file_size,
page_count=document_with_tags.page_count,
language=document_with_tags.language,
is_public=document_with_tags.is_public,
is_processed=document_with_tags.is_processed,
created_at=document_with_tags.created_at,
updated_at=document_with_tags.updated_at,
document_date=document_with_tags.document_date,
uploader_name=current_user.full_name or current_user.email,
tags=[tag.name for tag in document_with_tags.tags]
)
except Exception as e:
# 파일 정리
@@ -250,11 +287,24 @@ async def get_document(
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
return DocumentResponse(
id=str(document.id),
title=document.title,
description=document.description,
html_path=document.html_path,
pdf_path=document.pdf_path,
thumbnail_path=document.thumbnail_path,
file_size=document.file_size,
page_count=document.page_count,
language=document.language,
is_public=document.is_public,
is_processed=document.is_processed,
created_at=document.created_at,
updated_at=document.updated_at,
document_date=document.document_date,
uploader_name=document.uploader.full_name or document.uploader.email,
tags=[tag.name for tag in document.tags]
)
@router.delete("/{document_id}")
@@ -307,7 +357,6 @@ async def list_tags(
# 각 태그의 문서 수 계산
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:
@@ -318,7 +367,15 @@ async def list_tags(
)
)
doc_result = await db.execute(doc_query)
tag_data.document_count = len(doc_result.scalars().all())
document_count = len(doc_result.scalars().all())
tag_data = TagResponse(
id=str(tag.id),
name=tag.name,
color=tag.color,
description=tag.description,
document_count=document_count
)
response_data.append(tag_data)
return response_data
@@ -353,7 +410,10 @@ async def create_tag(
await db.commit()
await db.refresh(tag)
response_data = TagResponse.from_orm(tag)
response_data.document_count = 0
return response_data
return TagResponse(
id=str(tag.id),
name=tag.name,
color=tag.color,
description=tag.description,
document_count=0
)

View File

@@ -8,12 +8,12 @@ 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 ...core.database import get_db
from ...models.user import User
from ...models.document import Document
from ...models.highlight import Highlight
from ...models.note import Note
from ..dependencies import get_current_active_user
from pydantic import BaseModel

View File

@@ -8,12 +8,12 @@ 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 ...core.database import get_db
from ...models.user import User
from ...models.highlight import Highlight
from ...models.note import Note
from ...models.document import Document
from ..dependencies import get_current_active_user
from pydantic import BaseModel

View File

@@ -8,12 +8,12 @@ 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 ...core.database import get_db
from ...models.user import User
from ...models.document import Document, Tag
from ...models.highlight import Highlight
from ...models.note import Note
from ..dependencies import get_current_active_user
from pydantic import BaseModel

View File

@@ -6,10 +6,10 @@ 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 ...core.database import get_db
from ...models.user import User
from ...schemas.auth import UserInfo
from ..dependencies import get_current_active_user, get_current_admin_user
from pydantic import BaseModel