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:
@@ -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
135
app/api/dashboard.py
Normal 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,
|
||||
)
|
||||
@@ -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)],
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user