- B09_wf6_Estimation -> B09_Estimation (폴더·파일·라우트 키/슬러그)
- B07_Quantity_UI_Page.ts 신설: 워크플로우 셸 + 좌측 [확정] 버튼
(renderPendingWorkflow에 leftPanel 옵션 추가로 공용 셸 재사용)
- B07_Quantity_Router.py 신설: POST /api/projects/{id}/quantity/confirm
-> complete_stage(4) 전이만 수행(본문 미구현), main.py 등록
- STAGE_KEYS 재정의: FILE_INPUT/PREPROCESS/PROFILE/SECTION/QUANTITY/DESIGN_DETAIL/ESTIMATION
- 스텝바 라벨·아이콘 4↔5 스왑(수량산출이 4차, 상세설계가 5차), A02 소개 문구 스왑
- 라우터 테이블에 b07-quantity 등록(플레이스홀더 제거), locale 키 5종 추가
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
380 lines
17 KiB
Python
380 lines
17 KiB
Python
"""
|
|
main.py
|
|
FastAPI 애플리케이션 진입점
|
|
|
|
- 공통 설정 로드 (config_system, config_db)
|
|
- 프론트엔드 자동 빌드 및 서빙 (npm run build)
|
|
- 정적 파일 서빙 (프론트엔드 빌드 결과)
|
|
- DB 풀 초기화/종료
|
|
- 라우터 등록 (페이지별 API)
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import signal
|
|
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 B02_ProjRegister.B02_ProjRegister_Router import router as b02_proj_register_router
|
|
from B03_FileInput.B03_FileInput_Router import router as b03_file_input_router
|
|
from B04_PreProcess.B04_PreProcess_Router import router as b04_surface_router
|
|
from B04_PreProcess.B04_PreProcess_Router_Basins import router as b04_basins_router
|
|
from B04_PreProcess.B04_PreProcess_Router_Contour import router as b04_surface_contour_router
|
|
from B04_PreProcess.B04_PreProcess_Router_GIS import router as b04_surface_gis_router
|
|
from B04_PreProcess.B04_PreProcess_Router_GIS import tiles_router
|
|
from B04_PreProcess.B04_PreProcess_Router_Inflow import router as b04_inflow_router
|
|
from B04_PreProcess.B04_PreProcess_Router_Watershed import router as b04_watershed_router
|
|
from B05_Profile.B05_Profile_Router import router as b05_route_router
|
|
from B06_Section.B06_Section_Router import router as b06_section_router
|
|
from B06_Section.B06_Section_Router_Confirm import (
|
|
router as b06_section_confirm_router,
|
|
)
|
|
from B07_Quantity.B07_Quantity_Router import router as b07_quantity_router
|
|
from B08_DesignDetail.B08_DesignDetail_Router import router as b08_design_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__)
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
# 프론트엔드 빌드 및 서빙 함수
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
# 개발 서버 포트. 고정해 두고, 앱이 내려갈 때 함께 정리한다.
|
|
FRONTEND_DEV_PORT = int(os.getenv("FRONTEND_DEV_PORT", "5173"))
|
|
# 띄운 개발 서버 핸들 — 종료 때 이 계통을 통째로 끝낸다.
|
|
_frontend_dev_process: subprocess.Popen | None = None
|
|
|
|
|
|
def _frontend_process_env(root_dir: Path) -> dict[str, str]:
|
|
"""config/node_modules를 사용하는 프론트엔드 프로세스 환경을 만든다."""
|
|
env = os.environ.copy()
|
|
node_modules = root_dir / "config" / "node_modules"
|
|
bin_dir = node_modules / ".bin"
|
|
env["PATH"] = f"{bin_dir}{os.pathsep}{env.get('PATH', '')}"
|
|
env["NODE_PATH"] = str(node_modules)
|
|
return env
|
|
|
|
|
|
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),
|
|
env=_frontend_process_env(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 _kill_process_tree(pid: int) -> None:
|
|
"""프로세스와 그 자식들을 함께 끝낸다.
|
|
|
|
`shell=True`로 띄우면 실제 계통이 cmd.exe → npm → node라 부모만 죽이면 node가 남는다.
|
|
남은 node가 포트를 계속 물고 있으면 다음 실행 때 Vite가 옆 포트로 올라가고, 사용자는
|
|
옛 화면을 보게 된다(2026-08-01 실제로 13개가 쌓였다).
|
|
"""
|
|
try:
|
|
if os.name == "nt":
|
|
subprocess.run(
|
|
["taskkill", "/T", "/F", "/PID", str(pid)],
|
|
check=False,
|
|
capture_output=True,
|
|
)
|
|
else:
|
|
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
|
except Exception as exc: # 이미 죽었으면 그것으로 됐다
|
|
logger.debug("[Frontend] 프로세스 정리 중 무시된 예외: %s", exc)
|
|
|
|
|
|
def _free_dev_port(port: int) -> None:
|
|
"""개발 서버 포트를 물고 있는 잔여 프로세스를 정리한다.
|
|
|
|
앞선 실행이 비정상 종료(강제 종료·전원 차단)했을 때를 대비한 청소다. 이걸 하지 않으면
|
|
Vite가 `--strictPort` 때문에 기동에 실패한다.
|
|
"""
|
|
if os.name != "nt":
|
|
return
|
|
try:
|
|
result = subprocess.run(
|
|
["netstat", "-ano", "-p", "TCP"], check=False, capture_output=True, text=True
|
|
)
|
|
except Exception:
|
|
return
|
|
needle = f":{port} "
|
|
for line in (result.stdout or "").splitlines():
|
|
if needle not in line or "LISTENING" not in line:
|
|
continue
|
|
pid = line.split()[-1]
|
|
if pid.isdigit() and int(pid) != os.getpid():
|
|
logger.info("[Frontend] 포트 %d를 물고 있던 잔여 프로세스 %s 정리", port, pid)
|
|
_kill_process_tree(int(pid))
|
|
|
|
|
|
def serve_frontend_dev() -> None:
|
|
"""프론트엔드 개발 서버 실행 (npm run dev from project root) — 선택 사항.
|
|
|
|
포트를 **고정**(`--strictPort`)한다. 예전에는 포트가 비어 있지 않으면 Vite가 조용히
|
|
옆 포트로 올라갔고, 그렇게 뜬 옛 인스턴스가 계속 살아남아 하루치 변경이 화면에 반영되지
|
|
않는 일이 있었다. 고정해 두면 포트가 막혀 있을 때 그 자리에서 드러난다.
|
|
"""
|
|
global _frontend_dev_process
|
|
root_dir = Path(__file__).resolve().parent
|
|
|
|
if ENVIRONMENT == "production":
|
|
logger.info("[Frontend] 프로덕션 환경: 개발 서버 실행 스킵")
|
|
return
|
|
|
|
_free_dev_port(FRONTEND_DEV_PORT)
|
|
logger.info("[Frontend] npm run dev 시작 (포트 %d 고정, 백그라운드)...", FRONTEND_DEV_PORT)
|
|
try:
|
|
_frontend_dev_process = subprocess.Popen(
|
|
f"npm run dev -- --host --port {FRONTEND_DEV_PORT} --strictPort",
|
|
shell=True,
|
|
cwd=str(root_dir),
|
|
env=_frontend_process_env(root_dir),
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
# POSIX에서 자식까지 한 번에 끝내려면 프로세스 그룹을 따로 잡아야 한다.
|
|
start_new_session=os.name != "nt",
|
|
)
|
|
logger.info("✓ 프론트엔드 개발 서버 백그라운드 실행 (pid %s)", _frontend_dev_process.pid)
|
|
except Exception as e:
|
|
logger.warning(f"⚠ 프론트엔드 개발 서버 실행 실패: {e}")
|
|
|
|
|
|
def stop_frontend_dev() -> None:
|
|
"""앱이 내려갈 때 개발 서버도 같이 내린다 — 유령 인스턴스를 남기지 않는다."""
|
|
global _frontend_dev_process
|
|
if _frontend_dev_process is None:
|
|
return
|
|
logger.info("[Frontend] 개발 서버 종료 (pid %s)", _frontend_dev_process.pid)
|
|
_kill_process_tree(_frontend_dev_process.pid)
|
|
_frontend_dev_process = None
|
|
# 셸을 거쳐 띄우므로 손자 프로세스가 남을 수 있다. 포트로 한 번 더 확인해 정리한다.
|
|
_free_dev_port(FRONTEND_DEV_PORT)
|
|
|
|
|
|
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()
|
|
stop_frontend_dev()
|
|
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=["*"],
|
|
)
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
# 정적 파일 서빙 (프론트엔드)
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
app.mount(
|
|
STATIC_URL,
|
|
StaticFiles(directory=STATIC_DIR, check_dir=False),
|
|
name="static",
|
|
)
|
|
logger.info(f"✓ 정적 파일 서빙 경로 등록: {STATIC_URL} → {STATIC_DIR}")
|
|
|
|
# B08 독립형 2D CAD 앱 — 내부 JSON 연동용 iframe
|
|
B08_CAD_DIST_DIR = str(Path(__file__).parent / "B08_DesignDetail" / "openwebcad" / "dist")
|
|
app.mount(
|
|
"/b08-cad",
|
|
StaticFiles(directory=B08_CAD_DIST_DIR, html=True, check_dir=False),
|
|
name="b08-cad",
|
|
)
|
|
logger.info(f"✓ B08 CAD 정적 서빙 경로 등록: /b08-cad → {B08_CAD_DIST_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)
|
|
app.include_router(b02_proj_register_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(b04_surface_contour_router, dependencies=protected_with_company)
|
|
app.include_router(b04_surface_gis_router, dependencies=protected_with_company)
|
|
app.include_router(b04_watershed_router, dependencies=protected_with_company)
|
|
app.include_router(b04_inflow_router, dependencies=protected_with_company)
|
|
app.include_router(b04_basins_router, dependencies=protected_with_company)
|
|
app.include_router(tiles_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)
|
|
app.include_router(b06_section_confirm_router, dependencies=protected_with_company)
|
|
app.include_router(b07_quantity_router, dependencies=protected_with_company)
|
|
app.include_router(b08_design_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,
|
|
access_log=False,
|
|
)
|