""" main.py FastAPI 애플리케이션 진입점 - 공통 설정 로드 (config_system, config_db) - 프론트엔드 자동 빌드 및 서빙 (npm run build) - 정적 파일 서빙 (프론트엔드 빌드 결과) - DB 풀 초기화/종료 - 라우터 등록 (페이지별 API) """ import asyncio import logging import os import subprocess from contextlib import asynccontextmanager from pathlib import Path from fastapi import Depends, FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from A06_Login.A06_Login_Router import router as a06_login_router from A07_Register.A07_Register_Router import router as a07_register_router from A08_Support.A08_Support_Router import router as a08_support_router from A09_Security.A09_Security_Router import router as a09_security_router from B01_Dashboard.B01_Dashboard_Router import router as b01_dashboard_router from B03_FileInput.B03_FileInput_Router import router as b03_file_input_router from B04_wf1_Surface.B04_wf1_Surface_Router import router as b04_surface_router from B05_wf2_Route.B05_wf2_Route_Router import router as b05_route_router from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Router import router as b06_section_router from common_util.common_util_auth import require_company, verify_session from common_util.common_util_resource_monitor import sample_resources_loop from config.config_db import close_db_pool, get_db_pool, init_db_pool # 설정 import from config.config_system import ( ADMIN_EMAIL, CORS_ORIGINS, DEBUG, ENVIRONMENT, LOG_LEVEL, SERVER_HOST, SERVER_PORT, STATIC_DIR, STATIC_URL, ) # 로깅 설정 logging.basicConfig(level=getattr(logging, LOG_LEVEL)) logger = logging.getLogger(__name__) # ───────────────────────────────────────────────────────────────────────── # 프론트엔드 빌드 및 서빙 함수 # ───────────────────────────────────────────────────────────────────────── def build_frontend() -> bool: """프론트엔드 빌드 (npm run build from project root)""" root_dir = Path(__file__).resolve().parent logger.info("[Frontend] npm run build 시작...") try: result = subprocess.run( "npm run build", shell=True, cwd=str(root_dir), capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=300, ) if result.returncode == 0: logger.info("✓ 프론트엔드 빌드 완료") return True else: logger.error(f"✗ 프론트엔드 빌드 실패:\n{result.stderr}") return False except subprocess.TimeoutExpired: logger.error("✗ 프론트엔드 빌드 타임아웃 (300초)") return False except Exception as e: logger.error(f"✗ 프론트엔드 빌드 중 예외: {e}") return False def serve_frontend_dev() -> None: """프론트엔드 개발 서버 실행 (npm run dev from project root) — 선택 사항""" root_dir = Path(__file__).resolve().parent if ENVIRONMENT == "production": logger.info("[Frontend] 프로덕션 환경: 개발 서버 실행 스킵") return logger.info("[Frontend] npm run dev 시작 (백그라운드)...") try: subprocess.Popen( "npm run dev", shell=True, cwd=str(root_dir), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) logger.info("✓ 프론트엔드 개발 서버 백그라운드 실행") except Exception as e: logger.warning(f"⚠ 프론트엔드 개발 서버 실행 실패: {e}") async def cleanup_expired_sessions() -> None: """매시간 만료된 세션과 사용 완료 OTP를 정리한다.""" while True: await asyncio.sleep(3600) pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: await cursor.execute("DELETE FROM sessions WHERE expires_at < CURRENT_TIMESTAMP") await cursor.execute( """DELETE FROM email_otps WHERE expires_at < CURRENT_TIMESTAMP OR consumed_at < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 1 DAY)""" ) await cursor.execute( """DELETE FROM login_logs WHERE (status = 'SUCCESS' AND login_at < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 1 YEAR)) OR (status = 'FAILURE' AND login_at < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 6 MONTH))""" ) await cursor.execute( """DELETE FROM activity_logs WHERE action_at < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 2 YEAR)""" ) await connection.commit() # ───────────────────────────────────────────────────────────────────────── # 라이프사이클: 앱 시작/종료 # ───────────────────────────────────────────────────────────────────────── @asynccontextmanager async def lifespan(app: FastAPI): """FastAPI 라이프사이클 관리""" # 시작 logger.info(f"[{ENVIRONMENT}] 앱 시작 중...") # 프론트엔드 빌드 (필수) build_frontend() # 개발 환경에서 개발 서버 실행 (선택사항) if ENVIRONMENT == "development": serve_frontend_dev() # DB 풀 초기화 await init_db_pool() logger.info("✓ DB 풀 초기화 완료") if ADMIN_EMAIL: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: await cursor.execute( "UPDATE users SET role = 'SYSTEM_ADMIN' WHERE email = %s", (ADMIN_EMAIL.lower(),) ) await connection.commit() cleanup_task = asyncio.create_task(cleanup_expired_sessions()) resource_task = asyncio.create_task(sample_resources_loop()) yield # 종료 logger.info("앱 종료 중...") cleanup_task.cancel() resource_task.cancel() await close_db_pool() logger.info("✓ DB 풀 종료 완료") # ───────────────────────────────────────────────────────────────────────── # FastAPI 앱 생성 # ───────────────────────────────────────────────────────────────────────── app = FastAPI( title="임도 설계 및 견적 자동화", description="Forest Road Design & Estimation API", version="0.1.0", debug=DEBUG, lifespan=lifespan, ) # ───────────────────────────────────────────────────────────────────────── # CORS 미들웨어 # ───────────────────────────────────────────────────────────────────────── app.add_middleware( CORSMiddleware, allow_origins=CORS_ORIGINS, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # ───────────────────────────────────────────────────────────────────────── # 정적 파일 서빙 (프론트엔드) # ───────────────────────────────────────────────────────────────────────── if os.path.isdir(STATIC_DIR): app.mount(STATIC_URL, StaticFiles(directory=STATIC_DIR), name="static") logger.info(f"✓ 정적 파일 서빙: {STATIC_URL} → {STATIC_DIR}") else: logger.warning(f"⚠ 정적 파일 디렉토리 없음: {STATIC_DIR}") # ───────────────────────────────────────────────────────────────────────── # 기본 엔드포인트 # ───────────────────────────────────────────────────────────────────────── @app.get("/") async def root(): """루트 엔드포인트 — 프론트엔드 진입점 리다이렉트""" return { "message": "임도 설계 및 견적 자동화 API", "version": "0.1.0", "frontend": f"http://{SERVER_HOST}:{SERVER_PORT}{STATIC_URL}/", } @app.get("/api/health") async def health(): """헬스 체크""" return {"status": "ok", "environment": ENVIRONMENT} # ───────────────────────────────────────────────────────────────────────── # 라우터 등록 (pages → API) # ───────────────────────────────────────────────────────────────────────── # 예: from A01_Home.A01_Home_Api_Fetch import router as a01_router # app.include_router(a01_router, prefix="/api/a01", tags=["A01_Home"]) # # 나중에 각 페이지별 라우터가 구현되면 여기에 등록. app.include_router(a06_login_router) app.include_router(a07_register_router) app.include_router(a08_support_router) app.include_router(a09_security_router) app.include_router(b01_dashboard_router) protected = [Depends(verify_session)] protected_with_company = [Depends(verify_session), Depends(require_company)] app.include_router(b03_file_input_router, dependencies=protected_with_company) app.include_router(b04_surface_router, dependencies=protected_with_company) app.include_router(b05_route_router, dependencies=protected_with_company) app.include_router(b06_section_router, dependencies=protected_with_company) # ───────────────────────────────────────────────────────────────────────── # 앱 실행 # ───────────────────────────────────────────────────────────────────────── if __name__ == "__main__": import uvicorn logger.info(f"Starting server on {SERVER_HOST}:{SERVER_PORT}") uvicorn.run( "main:app", host=SERVER_HOST, port=SERVER_PORT, reload=DEBUG, )