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:
@@ -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 토큰 스키마
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# 비밀번호 해싱 컨텍스트
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# 문서-태그 다대다 관계 테이블
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user