feat: implement Phase 4 SvelteKit frontend + backend enhancements

Backend:
- Add dashboard API (today stats, inbox count, law alerts, pipeline status)
- Add /api/documents/tree endpoint for sidebar domain/sub_group tree
- Migrate auth to HttpOnly cookie for refresh token (XSS defense)
- Add /api/auth/logout endpoint (cookie cleanup)
- Register dashboard router in main.py

Frontend (SvelteKit + Tailwind CSS v4):
- api.ts: fetch wrapper with refresh queue pattern, 401 single retry,
  forced logout on refresh failure
- Auth store: login/logout/refresh with memory-based access token
- UI store: toast system, sidebar state
- Login page with TOTP support
- Dashboard with 4 stat widgets + recent documents
- Document list with hybrid search (debounce, URL query state, mode select)
- Document detail with format-aware viewer (markdown/PDF/HWP/Synology/fallback)
- Metadata panel (AI summary, tags, processing history)
- Inbox triage UI (batch select, confirm dialog, domain override)
- Settings page (password change, TOTP status)

Infrastructure:
- Enable frontend service in docker-compose
- Caddy path routing (/api/* → fastapi, / → frontend) + gzip

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-03 06:46:19 +09:00
parent 46537ee11a
commit cfa95ff031
19 changed files with 1380 additions and 41 deletions

View File

@@ -1,14 +1,19 @@
"""인증 API — 로그인, 토큰 갱신, TOTP 검증"""
"""인증 API — 로그인, 토큰 갱신, TOTP 검증
access token: 응답 body (프론트에서 메모리 보관)
refresh token: HttpOnly cookie (XSS 방어)
"""
from datetime import datetime, timezone
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import (
REFRESH_TOKEN_EXPIRE_DAYS,
create_access_token,
create_refresh_token,
decode_token,
@@ -32,16 +37,11 @@ class LoginRequest(BaseModel):
totp_code: str | None = None
class TokenResponse(BaseModel):
class AccessTokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class RefreshRequest(BaseModel):
refresh_token: str
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str
@@ -58,15 +58,31 @@ class UserResponse(BaseModel):
from_attributes = True
# ─── 헬퍼 ───
def _set_refresh_cookie(response: Response, token: str):
"""refresh token을 HttpOnly cookie로 설정"""
response.set_cookie(
key="refresh_token",
value=token,
httponly=True,
secure=True,
samesite="strict",
max_age=REFRESH_TOKEN_EXPIRE_DAYS * 86400,
path="/api/auth",
)
# ─── 엔드포인트 ───
@router.post("/login", response_model=TokenResponse)
@router.post("/login", response_model=AccessTokenResponse)
async def login(
body: LoginRequest,
response: Response,
session: Annotated[AsyncSession, Depends(get_session)],
):
"""로그인 → JWT 발급"""
"""로그인 → access token(body) + refresh token(cookie)"""
result = await session.execute(
select(User).where(User.username == body.username)
)
@@ -101,19 +117,28 @@ async def login(
user.last_login_at = datetime.now(timezone.utc)
await session.commit()
return TokenResponse(
# refresh token → HttpOnly cookie
_set_refresh_cookie(response, create_refresh_token(user.username))
return AccessTokenResponse(
access_token=create_access_token(user.username),
refresh_token=create_refresh_token(user.username),
)
@router.post("/refresh", response_model=TokenResponse)
@router.post("/refresh", response_model=AccessTokenResponse)
async def refresh_token(
body: RefreshRequest,
response: Response,
session: Annotated[AsyncSession, Depends(get_session)],
refresh_token: str | None = Cookie(None),
):
"""리프레시 토큰으로 새 토큰 쌍 발급"""
payload = decode_token(body.refresh_token)
"""cookie의 refresh token으로 새 토큰 쌍 발급"""
if not refresh_token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="리프레시 토큰이 없습니다",
)
payload = decode_token(refresh_token)
if not payload or payload.get("type") != "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -131,12 +156,21 @@ async def refresh_token(
detail="유저를 찾을 수 없음",
)
return TokenResponse(
# 새 refresh token → cookie
_set_refresh_cookie(response, create_refresh_token(user.username))
return AccessTokenResponse(
access_token=create_access_token(user.username),
refresh_token=create_refresh_token(user.username),
)
@router.post("/logout")
async def logout(response: Response):
"""로그아웃 — refresh cookie 삭제"""
response.delete_cookie("refresh_token", path="/api/auth")
return {"message": "로그아웃 완료"}
@router.get("/me", response_model=UserResponse)
async def get_me(user: Annotated[User, Depends(get_current_user)]):
"""현재 로그인한 유저 정보"""

135
app/api/dashboard.py Normal file
View File

@@ -0,0 +1,135 @@
"""대시보드 위젯 데이터 API"""
from typing import Annotated
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from core.database import get_session
from models.document import Document
from models.queue import ProcessingQueue
from models.user import User
router = APIRouter()
class DomainCount(BaseModel):
domain: str | None
count: int
class RecentDocument(BaseModel):
id: int
title: str | None
file_format: str
ai_domain: str | None
created_at: str
class PipelineStatus(BaseModel):
stage: str
status: str
count: int
class DashboardResponse(BaseModel):
today_added: int
today_by_domain: list[DomainCount]
inbox_count: int
law_alerts: int
recent_documents: list[RecentDocument]
pipeline_status: list[PipelineStatus]
failed_count: int
total_documents: int
@router.get("/", response_model=DashboardResponse)
async def get_dashboard(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""대시보드 위젯 데이터 집계"""
# 오늘 추가된 문서
today_result = await session.execute(
select(Document.ai_domain, func.count(Document.id))
.where(func.date(Document.created_at) == func.current_date())
.group_by(Document.ai_domain)
)
today_rows = today_result.all()
today_added = sum(row[1] for row in today_rows)
# Inbox 미분류 수
inbox_result = await session.execute(
select(func.count(Document.id))
.where(Document.file_path.like("PKM/Inbox/%"))
)
inbox_count = inbox_result.scalar() or 0
# 법령 알림 (오늘)
law_result = await session.execute(
select(func.count(Document.id))
.where(
Document.source_channel == "law_monitor",
func.date(Document.created_at) == func.current_date(),
)
)
law_alerts = law_result.scalar() or 0
# 최근 문서 5건
recent_result = await session.execute(
select(Document)
.order_by(Document.created_at.desc())
.limit(5)
)
recent_docs = recent_result.scalars().all()
# 파이프라인 상태 (24h)
pipeline_result = await session.execute(
text("""
SELECT stage, status, COUNT(*)
FROM processing_queue
WHERE created_at > NOW() - INTERVAL '24 hours'
GROUP BY stage, status
""")
)
# 실패 건수
failed_result = await session.execute(
select(func.count())
.select_from(ProcessingQueue)
.where(ProcessingQueue.status == "failed")
)
failed_count = failed_result.scalar() or 0
# 전체 문서 수
total_result = await session.execute(select(func.count(Document.id)))
total_documents = total_result.scalar() or 0
return DashboardResponse(
today_added=today_added,
today_by_domain=[
DomainCount(domain=row[0], count=row[1]) for row in today_rows
],
inbox_count=inbox_count,
law_alerts=law_alerts,
recent_documents=[
RecentDocument(
id=doc.id,
title=doc.title,
file_format=doc.file_format,
ai_domain=doc.ai_domain,
created_at=doc.created_at.isoformat() if doc.created_at else "",
)
for doc in recent_docs
],
pipeline_status=[
PipelineStatus(stage=row[0], status=row[1], count=row[2])
for row in pipeline_result
],
failed_count=failed_count,
total_documents=total_documents,
)

View File

@@ -63,9 +63,52 @@ class DocumentUpdate(BaseModel):
data_origin: str | None = None
# ─── 스키마 (트리) ───
class SubGroupNode(BaseModel):
sub_group: str
count: int
class DomainNode(BaseModel):
domain: str
count: int
children: list[SubGroupNode]
# ─── 엔드포인트 ───
@router.get("/tree", response_model=list[DomainNode])
async def get_document_tree(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""도메인/sub_group 트리 (사이드바용)"""
from sqlalchemy import text as sql_text
result = await session.execute(
sql_text("""
SELECT ai_domain, ai_sub_group, COUNT(*)
FROM documents
WHERE ai_domain IS NOT NULL
GROUP BY ai_domain, ai_sub_group
ORDER BY ai_domain, ai_sub_group
""")
)
tree: dict[str, DomainNode] = {}
for domain, sub_group, count in result:
if domain not in tree:
tree[domain] = DomainNode(domain=domain, count=0, children=[])
tree[domain].count += count
if sub_group:
tree[domain].children.append(SubGroupNode(sub_group=sub_group, count=count))
return list(tree.values())
@router.get("/", response_model=DocumentListResponse)
async def list_documents(
user: Annotated[User, Depends(get_current_user)],

View File

@@ -7,6 +7,7 @@ from fastapi.responses import RedirectResponse
from sqlalchemy import func, select, text
from api.auth import router as auth_router
from api.dashboard import router as dashboard_router
from api.documents import router as documents_router
from api.search import router as search_router
from api.setup import router as setup_router
@@ -61,9 +62,10 @@ app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
app.include_router(documents_router, prefix="/api/documents", tags=["documents"])
app.include_router(search_router, prefix="/api/search", tags=["search"])
# TODO: Phase 3~4에서 추가
app.include_router(dashboard_router, prefix="/api/dashboard", tags=["dashboard"])
# TODO: Phase 5에서 추가
# app.include_router(tasks.router, prefix="/api/tasks", tags=["tasks"])
# app.include_router(dashboard.router, prefix="/api/dashboard", tags=["dashboard"])
# app.include_router(export.router, prefix="/api/export", tags=["export"])