Files
Aislo/main.py
T
eomsangdonandClaude Fable 5 50e5626a95 feat(B05): 구조물 타입 레지스트리·정본 CRUD 백엔드 추가
임도 구조물 전체(구조물군 B~G)를 수동 배치할 수 있도록 백엔드 기반을 만든다.
타입 정의를 코드가 아닌 데이터(JSON 레지스트리)로 두어, 구조물 목록이 바뀌어도
코드를 고치지 않고 화면 폼까지 따라오게 했다.

- B05_Profile_Structure_Types.json: 타입 레지스트리 정본 (B~G 32종 + 호환 4종).
  배치형태(point/interval/site)·옵션 스키마·마크 스타일·drawing_views 메타 포함.
- B05_Profile_Structures_Schema.py: 타입·인스턴스 Pydantic 모델.
  배치형태별 위치 필드 배타 검증, side·offset_m·placement_source·status·revision.
- B05_Profile_Structures_Repository.py: structures.json 단일 정본 읽기·쓰기.
  atomic_write_json 재사용, base_revision 불일치 시 충돌 예외,
  설계 영향 변경만 후속 단계를 무효화하도록 판정하는 지문 비교.
- B05_Profile_Structures_Migration.py: 기존 비정규 측점 이관.
  배관은 pipe_points.json 정본이라 제외, 기성막이→기슭막이, 대피로→대피소 통합,
  나머지는 이름을 살려 기타. 같은 입력이면 같은 결과.
- B05_Profile_Structures_Router.py: GET /structure-types, 구조물 목록 조회·저장.
  판번호 충돌 409, 미등록·타 정본 관리 타입 400, 저장 후 B06 이후 STALE 전파.

테스트 45건(tmp/tests) 통과. 기존 배관·배수유역·종단 편집 코드는 건드리지 않았다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 22:19:00 +09:00

391 lines
18 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 B03_FileInput.B03_FileInput_Router_Temp import (
attach_router as b03_temp_attach_router,
)
from B03_FileInput.B03_FileInput_Router_Temp import router as b03_temp_upload_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 B05_Profile.B05_Profile_Structures_Router import router as b05_structures_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 common_util.common_util_temp_cleanup import cleanup_expired_temp_uploads_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())
temp_cleanup_task = asyncio.create_task(cleanup_expired_temp_uploads_loop())
yield
# 종료
logger.info("앱 종료 중...")
cleanup_task.cancel()
resource_task.cancel()
temp_cleanup_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(b03_temp_upload_router, dependencies=protected)
app.include_router(b03_temp_attach_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(b05_structures_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,
)