구조물 원단위의 `destination == "material"` 성분만 모아 자재별 합산 후 할증률을 한 번만 적용. 열은 순수량·할증률·합계 + 관급구분·설치주체·비고이며 금액은 없음(B09 경계). - 할증률은 코드가 아니라 데이터 — `resources/data_material_surcharge/` (품셈 1-3-1 재료 할증률 19종 + sha256 매니페스트). 실무 관측값은 `observed_practice` 로 분리(법대로 원칙). - 표에 없는 자재는 0 % 로 넘기지 않고 「할증률 미확보」로 표시. 이름 조회는 정확 일치 — 부분일치면 `막자갈` 이 `자갈` 할증을 뭄. - 이중계상 방어 ㉠ — 앞 단계 `surcharge_applied` 깃발을 실제로 읽어 경고. 자재총괄 응답은 `True`, 원단위표는 `False` 로 어느 쪽 값인지 명시. - 관급/사급 이름은 B09 와 동일(`owner_supplied`/`contractor_supplied`). 관급 줄에만 설치 주체(`install_by`)를 붙이고, 미지정은 기본값으로 때우지 않고 드러냄 — 안전관리비 대상액이 「도급자설치 관급금액」이라서임. - 라우터 `GET /quantity/material-summary` 신설, 화면에 「구조물 원단위」· 「자재총괄」 탭 추가. `design_owner` 가 붙은 타입(측구)은 중복 계상 방지로 제외. 검증 — 전용 테스트 23건 통과, 전체 회귀 473 passed(기존 B05 깨짐 1건 제외). 공용 브라우저 실조작으로 탭 5장·머리글·값 4줄·미확보 안내 확인. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
616 lines
29 KiB
Python
616 lines
29 KiB
Python
"""
|
|
main.py
|
|
FastAPI 애플리케이션 진입점
|
|
|
|
- 공통 설정 로드 (config_system, config_db)
|
|
- 프론트엔드 자동 빌드 및 서빙 (npm run build)
|
|
- 정적 파일 서빙 (프론트엔드 빌드 결과)
|
|
- DB 풀 초기화/종료
|
|
- 라우터 등록 (페이지별 API)
|
|
"""
|
|
|
|
import asyncio
|
|
import locale
|
|
import logging
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
import time
|
|
from contextlib import asynccontextmanager
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from fastapi import Depends, FastAPI
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.middleware.gzip import GZipMiddleware
|
|
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_Router_Corridor import router as b05_corridor_router
|
|
from B05_Profile.B05_Profile_Router_Lifecycle import router as b05_route_lifecycle_router
|
|
from B05_Profile.B05_Profile_Router_Replan import router as b05_route_replan_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 B06_Section.B06_Section_Router_HaulPlan import (
|
|
router as b06_section_haul_plan_router,
|
|
)
|
|
from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router
|
|
from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_router
|
|
from B08_Quantity.B08_Quantity_Router import router as b08_quantity_router
|
|
from B08_Quantity.B08_Quantity_Router_Earthwork import router as b08_earthwork_router
|
|
from B08_Quantity.B08_Quantity_Router_Material import router as b08_material_router
|
|
from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router
|
|
from common_util.common_util_audit import note_api_call, record_call_burst
|
|
from common_util.common_util_auth import (
|
|
require_company,
|
|
require_project_access,
|
|
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,
|
|
SESSION_COOKIE_NAME,
|
|
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:
|
|
# 한글 Windows의 netstat는 콘솔 코드페이지(cp949)로 찍는다 — 기본 utf-8로 읽으면
|
|
# 한글 헤더에서 UnicodeDecodeError가 나고, 그 예외가 읽기 스레드에서 터져
|
|
# 포트 정리가 통째로 죽는다(2026-08-16 실제 발생). 시스템 인코딩으로 읽고,
|
|
# 깨지는 바이트는 넘긴다 — 우리가 볼 것은 숫자 포트·PID뿐이다.
|
|
result = subprocess.run(
|
|
["netstat", "-ano", "-p", "TCP"],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding=locale.getpreferredencoding(False),
|
|
errors="replace",
|
|
)
|
|
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}] 앱 시작 중...")
|
|
|
|
# 지금 도는 코드가 언제 것인지 못박아 둔다 — `/api/health` 가 `stale` 로 알린다(7-2).
|
|
global _STARTED_AT, _CODE_MTIME_AT_START
|
|
_STARTED_AT = time.time()
|
|
if DEBUG:
|
|
_CODE_MTIME_AT_START = _newest_source_mtime()
|
|
logger.info(
|
|
"[reload] 감시 폴더 %d개, 소스 최신 수정 %s",
|
|
len(_reload_dirs()),
|
|
datetime.fromtimestamp(_CODE_MTIME_AT_START).isoformat(timespec="seconds"),
|
|
)
|
|
|
|
# 프론트엔드 빌드 (필수)
|
|
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(),)
|
|
)
|
|
# 시스템 관리 회사(관리자 계정이 속한 회사) 소속은 전원 시스템관리자다
|
|
# (2026-09-06 사용자 확정) — 역할을 따로 고를 일이 없다.
|
|
await cursor.execute(
|
|
"""UPDATE users u
|
|
JOIN users a ON a.email = %s AND a.deleted_at IS NULL
|
|
AND a.company_id IS NOT NULL
|
|
SET u.role = 'SYSTEM_ADMIN'
|
|
WHERE u.company_id = a.company_id AND u.deleted_at IS NULL""",
|
|
(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=["*"],
|
|
)
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
# 응답 압축 (2026-09-06 실측으로 도입)
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
# 화면 한 번 여는 데 수십 MB 가 오간다 — 종횡단 상세 1.56MB → 0.34MB(압축 13ms),
|
|
# 배수유역 151KB → 37KB, 지형 도엽 GeoJSON 은 훨씬 크다. 사무실 랜에서는 티가 덜 나지만
|
|
# 현장에서 인터넷으로 열면 이 차이가 곧 대기 시간이다.
|
|
#
|
|
# 딱 하나 예외 — 3D 예상형상(코리도)은 숫자 배열이라 절반밖에 안 줄면서 압축에만 493ms 가
|
|
# 든다(18.6MB → 9.4MB). 그 자리는 「덜 보내기」가 아니라 「안 보내기」로 푼다.
|
|
#
|
|
# 압축 강도는 **1**(가장 약하게). 기본값 9 로 두었더니 등고선 도엽 36.8MB 를 누르느라
|
|
# 요청 하나가 9.4초 걸렸다(2026-09-06 실측). 1 로 낮춰도 줄어드는 양은 거의 같고
|
|
# (66MB 기준 level 1 은 24.0MB, level 6 은 23.9MB) 시간만 1/3 이 된다.
|
|
_COMPRESS_MIN_BYTES = 1024
|
|
_COMPRESS_LEVEL = 1
|
|
|
|
|
|
def _skips_compression(path: str) -> bool:
|
|
"""압축에서 뺄 경로인가 — 지금은 코리도 하나뿐."""
|
|
return path.endswith("/corridor")
|
|
|
|
|
|
class _SelectiveGZipMiddleware(GZipMiddleware):
|
|
"""경로 몇 개만 빼고 압축한다. 나머지 동작은 표준 미들웨어 그대로."""
|
|
|
|
async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
|
|
if scope["type"] == "http" and _skips_compression(scope.get("path", "")):
|
|
await self.app(scope, receive, send)
|
|
return
|
|
await super().__call__(scope, receive, send)
|
|
|
|
|
|
app.add_middleware(
|
|
_SelectiveGZipMiddleware,
|
|
minimum_size=_COMPRESS_MIN_BYTES,
|
|
compresslevel=_COMPRESS_LEVEL,
|
|
)
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
# 호출량 감시 (2026-09-06 사용자 지시 — 보안)
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
# 계산 결과는 화면에 나가도 된다는 방침이라, 남는 위험은 입력을 바꿔가며 출력을 긁어 모으는
|
|
# 것이다. **막지는 않고** 상한을 넘은 세션만 시스템 로그에 한 줄 남긴다 — 자세한 이유는
|
|
# `common_util_audit.note_api_call` 머리 참조. 세는 값은 메모리에 있어 요청당 비용이 없다.
|
|
@app.middleware("http")
|
|
async def watch_call_volume(request, call_next): # type: ignore[no-untyped-def]
|
|
if request.url.path.startswith("/api/"):
|
|
session_id = request.cookies.get(SESSION_COOKIE_NAME)
|
|
if note_api_call(session_id) and session_id:
|
|
asyncio.create_task(record_call_burst(session_id, request))
|
|
return await call_next(request)
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
# 정적 파일 서빙 (프론트엔드)
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
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_DesignDetail" / "openwebcad" / "dist")
|
|
|
|
|
|
class _NoCacheHtmlStatic(StaticFiles):
|
|
"""`index.html` 만 캐시하지 않는다 (2026-09-06).
|
|
|
|
캐드를 새로 빌드해도 브라우저가 옛 `index.html` 을 들고 있어 **옛 화면이 그대로**
|
|
남았다(파일 이름에 해시가 붙는 자바스크립트는 새 이름이라 문제가 없다).
|
|
"""
|
|
|
|
def file_response(self, *args, **kwargs): # type: ignore[override]
|
|
response = super().file_response(*args, **kwargs)
|
|
if str(getattr(response, "path", "")).endswith(".html"):
|
|
response.headers["Cache-Control"] = "no-store"
|
|
return response
|
|
|
|
|
|
app.mount(
|
|
"/b07-cad",
|
|
_NoCacheHtmlStatic(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}/",
|
|
}
|
|
|
|
|
|
#: 이 프로세스가 뜬 시각과, 뜰 때 본 **가장 최근 소스 수정 시각**. 기동 때 채운다
|
|
#: (`_reload_dirs` 가 아래에 정의돼 있어 모듈 로드 시점에는 못 부른다).
|
|
#: 자동 리로드가 조용히 죽는 일이 잦아(오늘만 세 번, 매번 틀린 결론을 냄) 「지금 도는 서버가
|
|
#: 옛 코드인가」를 한 번에 가릴 수 있게 남긴다(2026-09-07, PLAN 7-2).
|
|
_STARTED_AT = 0.0
|
|
_CODE_MTIME_AT_START = 0.0
|
|
|
|
|
|
def _newest_source_mtime() -> float:
|
|
"""감시 대상 폴더의 `.py` 중 가장 최근 수정 시각. 못 읽으면 0."""
|
|
newest = 0.0
|
|
for folder in _reload_dirs():
|
|
for path in Path(folder).rglob("*.py"):
|
|
try:
|
|
newest = max(newest, path.stat().st_mtime)
|
|
except OSError:
|
|
continue
|
|
return newest
|
|
|
|
|
|
@app.get("/api/health")
|
|
async def health():
|
|
"""헬스 체크.
|
|
|
|
개발 모드에서는 **코드가 최신인지**도 함께 알린다 — `stale: true` 면 소스가 더 새로우니
|
|
(자동 리로드가 안 붙은 것) 재시작 전에는 어떤 측정도 믿으면 안 된다.
|
|
"""
|
|
body: dict[str, Any] = {"status": "ok", "environment": ENVIRONMENT}
|
|
if DEBUG:
|
|
newest = _newest_source_mtime()
|
|
body["started_at"] = datetime.fromtimestamp(_STARTED_AT).isoformat(timespec="seconds")
|
|
body["code_mtime"] = datetime.fromtimestamp(_CODE_MTIME_AT_START).isoformat(
|
|
timespec="seconds"
|
|
)
|
|
body["source_mtime"] = datetime.fromtimestamp(newest).isoformat(timespec="seconds")
|
|
# 1초 여유 — 저장 직후의 미세한 시각차로 헛경고가 뜨지 않게.
|
|
body["stale"] = newest > _CODE_MTIME_AT_START + 1.0
|
|
return body
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
# 라우터 등록 (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)]
|
|
# 프로젝트 경로가 붙는 라우터는 그 프로젝트가 내 회사 것인지도 함께 본다
|
|
# (2026-09-01 — 지금까지 id만 알면 남의 회사 자료에 닿았다).
|
|
protected_with_company = [
|
|
Depends(verify_session),
|
|
Depends(require_company),
|
|
Depends(require_project_access),
|
|
]
|
|
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_route_lifecycle_router, dependencies=protected_with_company)
|
|
app.include_router(b05_corridor_router, dependencies=protected_with_company)
|
|
app.include_router(b05_route_replan_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(b06_section_haul_plan_router, dependencies=protected_with_company)
|
|
app.include_router(b07_design_router, dependencies=protected_with_company)
|
|
app.include_router(b07_frame_router, dependencies=protected_with_company)
|
|
app.include_router(b08_quantity_router, dependencies=protected_with_company)
|
|
app.include_router(b08_earthwork_router, dependencies=protected_with_company)
|
|
app.include_router(b08_material_router, dependencies=protected_with_company)
|
|
app.include_router(b09_estimation_router, dependencies=protected_with_company)
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
# 앱 실행
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
def _reload_dirs() -> list[str]:
|
|
"""자동 리로드가 **훑을 폴더만** 골라 준다 — 폴더째 주면 서버가 쉬는 중에도 CPU를 먹는다.
|
|
|
|
uvicorn 의 StatReload 는 0.25초마다 지정 폴더를 통째로 훑는다. 아무것도 안 주면
|
|
현재 폴더가 대상이 되는데, 여기에는 `storage`(27GB · 프로젝트 산출물)와 `tmp`·`venv`·
|
|
`node_modules` 가 함께 들어 있다. 그래서 **요청이 하나도 없는 서버가 CPU 70%** 를 물고
|
|
있었고, 창 둘이 각각 그러니 기기가 늘 140% 눌린 채였다(2026-09-07 실측). 속도 계측이
|
|
회차마다 3배씩 흔들린 원인도 이것이다.
|
|
|
|
파이썬이 실제로 사는 폴더만 준다 — 화면 코드(TS)는 vite 가 따로 본다.
|
|
"""
|
|
root = Path(__file__).resolve().parent
|
|
# ⚠ 루트와 `config` 는 넣지 않는다 — uvicorn 은 준 폴더를 `rglob("*.py")` 로 **재귀**로
|
|
# 훑는다. 루트를 주면 storage·venv(파이썬 파일만 7,530개)를 도로 다 훑고, `config` 안에는
|
|
# `node_modules` 가 들어 있어 역시 무겁다. 그 대신 **`main.py` 와 `config/` 를 고칠 때는
|
|
# 서버를 손으로 다시 띄울 것** (둘 다 자주 고치는 자리가 아니다).
|
|
watched: list[str] = []
|
|
for entry in sorted(root.iterdir()):
|
|
if not entry.is_dir() or entry.name.startswith(".") or entry.name in _RELOAD_SKIP:
|
|
continue
|
|
# 링크(정션)는 통째로 건너뛴다. `node_modules` 안에 **저장소 루트를 되가리키는 정션**이
|
|
# 있어(`B07_DesignDetail/openwebcad/node_modules/forest-road-webapp` → 저장소 루트,
|
|
# npm 이 만든 것) 리로더가 그리로 되돌아가 `venv`·`storage` 를 다시 훑고 또 되감겼다.
|
|
# 실측(2026-09-07) — 감시 폴더를 훑으면 파일 **486,809개**, 한 바퀴 **1,045초**.
|
|
# 파이썬 `rglob` 은 정션을 따라 들어가고 PowerShell 은 안 따라가 눈으로는 안 보였다.
|
|
if entry.is_symlink():
|
|
continue
|
|
# 의존성 트리가 **한 겹 아래에 숨어 있는** 경우도 뺀다 — 위 정션이 그 안에 있었다.
|
|
if (entry / "node_modules").is_dir() or any(entry.glob("*/node_modules")):
|
|
continue
|
|
if any(entry.rglob("*.py")):
|
|
watched.append(str(entry))
|
|
return watched
|
|
|
|
|
|
# 파이썬이 있어도 훑지 않을 폴더 — 산출물·의존성·백업.
|
|
_RELOAD_SKIP = {
|
|
"storage",
|
|
"tmp",
|
|
"venv",
|
|
"node_modules",
|
|
"__pycache__",
|
|
"log",
|
|
"docs",
|
|
"resources",
|
|
"scratch",
|
|
"graphify-out",
|
|
"0_old",
|
|
"db_management",
|
|
"migrations",
|
|
"ui_template",
|
|
}
|
|
|
|
|
|
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,
|
|
# 폴더를 좁히지 않으면 리로더가 storage(27GB)까지 0.25초마다 훑는다 — 위 주석 참조.
|
|
reload_dirs=_reload_dirs() if DEBUG else None,
|
|
access_log=False,
|
|
)
|