흐름 강도 계산은 이미 trace_flow()가 원본 1m 격자에서 하고 있었고 03_road_routing.npz에
저장까지 되어 있었다. 이번 작업은 그 원본값을 밖으로 꺼내 화면에 그리는 것이다.
- 강도 곡선 출력 간격을 5m -> 1m로 바꿔 계산 원본을 그대로 내보낸다(구간 합, 평균 아님).
구간 기준을 round -> floor로 바로잡았다. 도로 셀 누가거리가 전부 0.5 배수라
numpy의 짝수 반올림이 짝수 칸에 3배를 몰아 2m 주기 빗살을 만들고 있었다
(도로가 격자축과 나란한 구간은 홀수 칸이 전부 0이라 색이 점선으로 나왔다).
- find_inflow_hotspots(): 도로를 시점·기본 관(세류 교차)·종점으로 잘라 구역을 만들고
구역마다 floor(길이 / 관 최대간격) + 3개를 뽑는다. 강도 최대점부터 고르되 고른 지점
양 편측으로 관 최소간격만큼을 후보에서 빼 한쪽 쏠림을 막는다. 관 쪽 경계에서도 같은
거리만큼 물러난다 - 그러지 않으면 그 관이 이미 받는 물 전부가 관 옆 칸에서 다시
최대값으로 잡혀, 정작 관이 없어 보충이 필요한 자리가 뽑히지 못한다.
- GET /api/projects/{id}/drainage/road-inflow 신설(B04_wf1_Surface_Router_Inflow.py).
저장된 road_slot으로 그 1m 구간에 귀속된 셀 마스크를 만들어 외곽선만 돌려준다.
새 그래프 탐색 없음. npz는 mtime 키로 프로세스 캐시에 둔다.
- 응답에 schema_version을 넣어 형식이 바뀌면 옛 저장분을 무시하고 다시 계산하게 했다.
- 프론트: B04_wf1_Surface_UI_FlowStrength.ts 신설. 계획선 위에 1m 구간 로그 레인보우와
유입 집중점 마커(크기=강도, 번호)를 얹고, 마커를 고르면 기여 셀 외곽선을 겹쳐 그린다.
마커 안쪽만 선택 대상이며 팬은 가운데 버튼 전용이라는 규칙은 그대로 둔다.
좌표 변환은 MapRender에 모았다(normalizedToScreen / lonLatToScreen / metricToScreen).
- 사이드 패널: 지면 필터·서피스·스무딩을 "지표면 분석" 한 컨테이너로 모으고 스무딩을
드롭다운(적용/미적용)으로 바꿨다. 포인트·모델 표시 옵션은 "모델 표시 옵션" 하나로
합치고 맨 아래에 점 크기·밀도를 [값][게이지] 형태로 놓았다.
- DRAINAGE_ROAD_WIDTH_M 주석에 설계 도로폭이 아니라 D8 대각 통과를 막는 해석용 굽기
폭임을 명시했다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
301 lines
13 KiB
Python
301 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_Inflow import router as b04_inflow_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(b04_inflow_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,
|
|
)
|