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

@@ -7,9 +7,9 @@ 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
from ..core.database import get_db
from ..core.security import verify_token, get_user_id_from_token
from ..models.user import User
# HTTP Bearer 토큰 스키마

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

View File

@@ -6,7 +6,7 @@ from sqlalchemy.orm import DeclarativeBase
from sqlalchemy import MetaData
from typing import AsyncGenerator
from src.core.config import settings
from .config import settings
# SQLAlchemy 메타데이터 설정
@@ -57,7 +57,7 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
async def init_db() -> None:
"""데이터베이스 초기화"""
from src.models import user, document, highlight, note, bookmark, tag
from ..models import user, document, highlight, note, bookmark
async with engine.begin() as conn:
# 모든 테이블 생성
@@ -69,8 +69,8 @@ async def init_db() -> None:
async def create_admin_user() -> None:
"""관리자 계정 생성 (존재하지 않을 경우)"""
from src.models.user import User
from src.core.security import get_password_hash
from ..models.user import User
from .security import get_password_hash
from sqlalchemy import select
async with AsyncSessionLocal() as session:

View File

@@ -7,7 +7,7 @@ from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import HTTPException, status
from src.core.config import settings
from .config import settings
# 비밀번호 해싱 컨텍스트

View File

@@ -7,9 +7,9 @@ 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
from .core.config import settings
from .core.database import init_db
from .api.routes import auth, users, documents, highlights, notes, bookmarks, search
@asynccontextmanager

View File

@@ -1,11 +1,11 @@
"""
모델 패키지 초기화
"""
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
from .user import User
from .document import Document, Tag
from .highlight import Highlight
from .note import Note
from .bookmark import Bookmark
__all__ = [
"User",

View File

@@ -7,7 +7,7 @@ from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import uuid
from src.core.database import Base
from ..core.database import Base
class Bookmark(Base):

View File

@@ -7,7 +7,7 @@ from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import uuid
from src.core.database import Base
from ..core.database import Base
# 문서-태그 다대다 관계 테이블

View File

@@ -7,7 +7,7 @@ from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import uuid
from src.core.database import Base
from ..core.database import Base
class Highlight(Base):

View File

@@ -7,7 +7,7 @@ from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import uuid
from src.core.database import Base
from ..core.database import Base
class Note(Base):

View File

@@ -6,7 +6,7 @@ from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.sql import func
import uuid
from src.core.database import Base
from ..core.database import Base
class User(Base):

View File

@@ -4,6 +4,7 @@
from pydantic import BaseModel, EmailStr
from typing import Optional
from datetime import datetime
from uuid import UUID
class LoginRequest(BaseModel):
@@ -27,7 +28,7 @@ class RefreshTokenRequest(BaseModel):
class UserInfo(BaseModel):
"""사용자 정보"""
id: str
id: UUID
email: str
full_name: Optional[str] = None
is_active: bool