"""hyungi_Document_Server — FastAPI 엔트리포인트""" from contextlib import asynccontextmanager from fastapi import FastAPI, Request 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 from core.config import settings from core.database import async_session, engine, init_db from models.user import User @asynccontextmanager async def lifespan(app: FastAPI): """앱 시작/종료 시 실행되는 lifespan 핸들러""" from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger from workers.daily_digest import run as daily_digest_run from workers.file_watcher import watch_inbox from workers.law_monitor import run as law_monitor_run from workers.mailplus_archive import run as mailplus_run from workers.queue_consumer import consume_queue # 시작: DB 연결 확인 await init_db() # NAS 마운트 확인 (NFS 미마운트 시 로컬 빈 디렉토리에 쓰는 것 방지) from pathlib import Path nas_check = Path(settings.nas_mount_path) / "PKM" if not nas_check.is_dir(): raise RuntimeError( f"NAS 마운트 확인 실패: {nas_check} 디렉토리 없음. " f"NFS 마운트 상태를 확인하세요." ) # APScheduler: 백그라운드 작업 scheduler = AsyncIOScheduler(timezone="Asia/Seoul") # 상시 실행 scheduler.add_job(consume_queue, "interval", minutes=1, id="queue_consumer") scheduler.add_job(watch_inbox, "interval", minutes=5, id="file_watcher") # 일일 스케줄 (KST) scheduler.add_job(law_monitor_run, CronTrigger(hour=7), id="law_monitor") scheduler.add_job(mailplus_run, CronTrigger(hour=7), id="mailplus_morning") scheduler.add_job(mailplus_run, CronTrigger(hour=18), id="mailplus_evening") scheduler.add_job(daily_digest_run, CronTrigger(hour=20), id="daily_digest") scheduler.start() yield # 종료: 스케줄러 → DB 순서로 정리 scheduler.shutdown(wait=False) await engine.dispose() app = FastAPI( title="hyungi_Document_Server", description="Self-hosted PKM 웹 애플리케이션 API", version="2.0.0", lifespan=lifespan, ) # ─── 라우터 등록 ─── app.include_router(setup_router, prefix="/api/setup", tags=["setup"]) 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"]) 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(export.router, prefix="/api/export", tags=["export"]) # ─── 셋업 미들웨어: 유저 0명이면 /setup으로 리다이렉트 ─── SETUP_BYPASS_PREFIXES = ( "/api/setup", "/setup", "/health", "/docs", "/openapi.json", "/redoc", ) @app.middleware("http") async def setup_redirect_middleware(request: Request, call_next): path = request.url.path # 바이패스 경로는 항상 통과 if any(path.startswith(p) for p in SETUP_BYPASS_PREFIXES): return await call_next(request) # 유저 존재 여부 확인 try: async with async_session() as session: result = await session.execute(select(func.count(User.id))) user_count = result.scalar() if user_count == 0: return RedirectResponse(url="/setup") except Exception: pass # DB 연결 실패 시 통과 (health에서 확인 가능) return await call_next(request) # ─── 셋업 페이지 라우트 (API가 아닌 HTML 페이지) ─── @app.get("/setup") async def setup_page_redirect(request: Request): """셋업 위자드 페이지로 포워딩""" from api.setup import setup_page from core.database import get_session async for session in get_session(): return await setup_page(request, session) @app.get("/health") async def health_check(): """헬스체크 — DB 연결 상태 포함""" db_ok = False try: async with engine.connect() as conn: await conn.execute(text("SELECT 1")) db_ok = True except Exception: pass return { "status": "ok" if db_ok else "degraded", "version": "2.0.0", "database": "connected" if db_ok else "disconnected", }