Files
Aislo/main.py
T
eomsangdonandClaude Fable 5 8a0d640e0e refactor(B04/B05): 배수유역 분석을 B04로 이관, B05는 저장분 소비만
분석이 30초 걸리는데 B05는 일반 사용자 화면이다. 관리자 확인용 B04에서
한 번 돌려 저장하고, B05는 그 결과를 읽어 관 보충과 세부유역만 처리한다
(2026-07-31 사용자 지시).

노선 원천 변경
- B05 확정 경로 -> B03 업로드 계획 노선 파일(CSV). 분석이 노선 설계보다
  먼저 끝나 있어야 하기 때문. 샘플 planned_route_sample_epsg5187.csv 로 검증.
- common_util_route_geometry.py 신설 — RouteVertex/StructureCandidate/누가거리
  보간/세류 교차점/계획 노선 CSV 리더. B04와 B05가 같은 표현을 쓰도록 공용화.
  열 이름은 대소문자·한글 표기를 함께 받는다(B03이 여러 형식 수용 예정).

B04 (관리자 확인용, 신규)
- Engine_Watershed_{Grid,Stream,Descent,Flow,Expand,Export} — B05에서 git mv
- Engine_Watershed_Analyze.py — 1~8단계 오케스트레이션
- Router_Watershed.py — GET /drainage/primary-region
- UI_Watershed.ts — 2D 지도 GIS 레이어 그룹에 "배수유역" 토글 추가.
  격자/화살표/세류망/1차영역/2차유역/기본관을 겹쳐 그린다.
- 저장 위치 B05_wf2_Route/drainage -> B04_wf1_Surface/drainage
- 03_road_routing 단계 추가: B05가 세부유역을 나눌 최소 배열(셀->도로셀 귀속,
  유하장, 강도, 도로셀 제원, 셀 표고) + 계획도로선/기본배관/2차유역 기하

B05 (일반 사용자용, 축소)
- Engine_Drainage_Basin.py — B04 산출물 로더 + 관 보충(9) + 측구 라우팅/세부유역(10,11)
- Engine_Drainage.py 는 관경 산정만 남기고 322 -> 27줄
- Router_Drainage.py 509 -> 142줄. POST /drainage/basins 만 남김
- 화살표·격자·강도 띠 렌더 제거. 계획도로선/기본배관/2차유역만 받는다

삭제
- _legacy_watershed/ 4파일 (능선 행진 방식 원본 보관본)
- Engine_Watershed_Basin.py (B04 Analyze + B05 Drainage_Basin 으로 분할)
- GET /drainage/candidates 와 propose_structure_stations (구방식 후보 제안)

E2E 검증 (실데이터)
  B04 분석 28.2s -> 저장(geojson 11KB + npz 2.6MB)
  B05 로드 + 세부 설계 0.11s   <-- 30초가 0.1초로
  면적 457,404m2 로 B04 2차 유역과 정확히 일치
  관 편집 재산정 0.12s, 관 3개 -> 세부유역 3개, 면적 보존

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 20:57:09 +09:00

299 lines
13 KiB
Python

"""
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 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_wf1_Surface.B04_wf1_Surface_Router import router as b04_surface_router
from B04_wf1_Surface.B04_wf1_Surface_Router_Contour import router as b04_surface_contour_router
from B04_wf1_Surface.B04_wf1_Surface_Router_GIS import router as b04_surface_gis_router
from B04_wf1_Surface.B04_wf1_Surface_Router_GIS import tiles_router
from B04_wf1_Surface.B04_wf1_Surface_Router_Watershed import router as b04_watershed_router
from B05_wf2_Route.B05_wf2_Route_Router import router as b05_route_router
from B05_wf2_Route.B05_wf2_Route_Router_Drainage import router as b05_drainage_router
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Router import router as b06_section_router
from B07_wf4_DesignDetail.B07_wf4_DesignDetail_Router import router as b07_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__)
# ─────────────────────────────────────────────────────────────────────────
# 프론트엔드 빌드 및 서빙 함수
# ─────────────────────────────────────────────────────────────────────────
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 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 -- --host",
shell=True,
cwd=str(root_dir),
env=_frontend_process_env(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=["*"],
)
# ─────────────────────────────────────────────────────────────────────────
# 정적 파일 서빙 (프론트엔드)
# ─────────────────────────────────────────────────────────────────────────
app.mount(
STATIC_URL,
StaticFiles(directory=STATIC_DIR, check_dir=False),
name="static",
)
logger.info(f"✓ 정적 파일 서빙 경로 등록: {STATIC_URL}{STATIC_DIR}")
# B07 독립형 2D CAD 앱 — 내부 JSON 연동용 iframe
B07_CAD_DIST_DIR = str(Path(__file__).parent / "B07_wf4_DesignDetail" / "openwebcad" / "dist")
app.mount(
"/b07-cad",
StaticFiles(directory=B07_CAD_DIST_DIR, html=True, check_dir=False),
name="b07-cad",
)
logger.info(f"✓ B07 CAD 정적 서빙 경로 등록: /b07-cad → {B07_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(tiles_router, dependencies=protected_with_company)
app.include_router(b05_route_router, dependencies=protected_with_company)
app.include_router(b05_drainage_router, dependencies=protected_with_company)
app.include_router(b06_section_router, dependencies=protected_with_company)
app.include_router(b07_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,
)