- 계약내역·계약 원가계산서(같은 엔진에 계약 직접비)를 읽기만 해 파생 — 계약 불변 - 제잡비 줄마다 계약잡비율 = 도급액 ÷ 계약 직접공사비 (구조로 읽음, 확인 대기) - 회차 목록 저장 · 기성(%) · 잔량 · 계약 수량 넘으면 알림 - 부가세: 직접입력 / 공급가액 / 재료비 / 재료비+산출경비 (설계 vat_base 같이 씀) - 원가계산서 조립·계약본 받기 조각을 떼어 기성이 같은 길로 받음 - 기성 탭 파일 · 사전 키(등록은 서브) · 시험 6건 · 전체 1717 통과 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
769 lines
39 KiB
Python
769 lines
39 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_Router_Terrain import router as b05_route_terrain_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_Stations import router as b06_section_stations_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 B08_Quantity.B08_Quantity_Router_StructureSheet import router as b08_structure_sheet_router
|
|
from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router
|
|
from B09_Estimation.B09_Estimation_Router_Contract import router as b09_contract_router
|
|
from B09_Estimation.B09_Estimation_Router_Execution import router as b09_execution_router
|
|
from B09_Estimation.B09_Estimation_Router_Progress import router as b09_progress_router
|
|
from B09_Estimation.B09_Estimation_Router_CostSheet import router as b09_cost_sheet_router
|
|
from B09_Estimation.B09_Estimation_Router_Edits import router as b09_edits_router
|
|
from B09_Estimation.B09_Estimation_Router_Factors import router as b09_factors_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,
|
|
)
|
|
|
|
# 개발환경 전용 — 「확정 없이 다음으로」. **문은 서버가 정본이다** — `ENVIRONMENT` 가
|
|
# 개발이 아니면 세 입구 모두 403 으로 거절한다(화면 단추 숨김은 보조).
|
|
from common_util.common_util_dev_unlock_router import router as dev_unlock_router
|
|
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__)
|
|
|
|
# ⚠ **리로드가 멈출 때 어디까지 갔는지 알려 주는 표식**(2026-09-12).
|
|
# uvicorn 리로더는 옛 워커를 내리고 **새 워커 프로세스를 다시 띄운다.** 그 워커는 이
|
|
# 파일을 처음부터 다시 읽으므로, 이 줄이 로그에 찍히면 **새 워커가 뜨기는 한 것**이고
|
|
# 안 찍히면 리로더가 옛 워커를 못 내려 **새 워커가 아예 시작도 못 한 것**이다.
|
|
# 둘은 고칠 자리가 전혀 다른데, 이 줄이 없으면 로그만 보고는 못 가린다.
|
|
logger.info("[boot] 워커 프로세스 시작 pid=%s", os.getpid())
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
# 프론트엔드 빌드 및 서빙 함수
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
# 개발 서버 포트. 고정해 두고, 앱이 내려갈 때 함께 정리한다.
|
|
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 _dev_port_listening(port: int) -> bool:
|
|
"""개발 서버(Vite)가 이미 그 포트를 물고 있나.
|
|
|
|
**리로드인지 첫 기동인지를 가르는 하나뿐인 표식**이다. uvicorn 리로더는 코드가 바뀌면
|
|
앱 프로세스만 다시 띄우고 Vite 자식은 그대로 두므로, 포트가 살아 있으면 리로드다.
|
|
환경변수 같은 별도 깃발을 두지 않는 까닭은 그것이 **실제와 어긋날 수 있기** 때문이다 —
|
|
포트는 어긋나지 않는다.
|
|
"""
|
|
import socket
|
|
|
|
probe = socket.socket()
|
|
probe.settimeout(0.5)
|
|
try:
|
|
probe.connect(("127.0.0.1", port))
|
|
return True
|
|
except OSError:
|
|
return False
|
|
finally:
|
|
probe.close()
|
|
|
|
|
|
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:
|
|
"""앱이 내려갈 때 개발 서버도 같이 내린다 — 유령 인스턴스를 남기지 않는다.
|
|
|
|
⚠⚠ **개발 중에는 내리지 않는다** (2026-09-12 실측으로 드러난 고리).
|
|
uvicorn 리로더는 `*.py` 가 바뀔 때마다 앱 프로세스를 갈아 끼운다. 그때 여기서
|
|
Vite 를 죽이면 **새 프로세스는 「Vite 가 없다」고 보고 `npm run build` 를 처음부터
|
|
다시 돌린다**(최대 300초 + B07 CAD). 그 사이 화면은 뜨지 않고 `/api/health` 는
|
|
`stale: true` 인 채로 옛 코드를 계속 내준다 — 그날 리로드가 두 번 멈췄고 한 번은
|
|
두 시간 반 동안 안 끝났다. **옛 화면을 보고 헛검증하게 만드는 자리다.**
|
|
⇒ Vite 는 **한 앱 프로세스가 아니라 개발 세션의 것**이다. 살려 두고 다음 기동이
|
|
그대로 물려쓴다(`_dev_port_listening`). 남은 유령은 다음 기동의
|
|
`_free_dev_port` 가 포트로 잡아 정리하므로 쌓이지 않는다.
|
|
"""
|
|
global _frontend_dev_process
|
|
if ENVIRONMENT == "development":
|
|
logger.info("[Frontend] 개발 서버는 살려 둔다 — 다음 기동이 그대로 물려쓴다")
|
|
return
|
|
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()
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
# 라이프사이클: 앱 시작/종료
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
|
|
#: 종료 한 걸음에 줄 시한(초). 짧게 둔다 — 종료는 **기다리는 곳이 아니라 끝내는 곳**이다.
|
|
_SHUTDOWN_STEP_TIMEOUT_S = 5.0
|
|
|
|
|
|
async def _cancel_tasks(*tasks: asyncio.Task) -> None:
|
|
"""취소를 걸고 **실제로 끝났는지 확인한다.**
|
|
|
|
`cancel()` 은 「끝내 달라」는 요청일 뿐이라 부르고 지나가면 그 태스크가 아직 도는 중일
|
|
수 있다. 여기서 한 번 거둬야 종료가 깔끔하다.
|
|
"""
|
|
for task in tasks:
|
|
task.cancel()
|
|
await asyncio.gather(*tasks, return_exceptions=True)
|
|
|
|
|
|
async def _shutdown_step(what: str, coro: Any) -> None:
|
|
"""종료 한 걸음 — **시한을 두고** 기다리고, 못 끝내면 넘어간다.
|
|
|
|
⚠ **종료는 어떤 경우에도 멈추면 안 된다** (2026-09-12 실측). 여기서 멈추면 uvicorn
|
|
리로더가 옛 프로세스를 못 내리고, 새 프로세스는 포트를 못 잡아 서버가 `stale` 인 채
|
|
**옛 코드를 계속 내준다** — 옛 화면을 보고 헛검증하게 되는 자리다.
|
|
못 끝낸 걸음은 **경고만 남기고 넘어간다.** 어차피 프로세스가 곧 사라지므로 남은
|
|
정리는 OS 가 한다. 무엇이 못 끝났는지는 로그에 이름으로 남아 다음에 바로 짚인다.
|
|
"""
|
|
try:
|
|
await asyncio.wait_for(coro, timeout=_SHUTDOWN_STEP_TIMEOUT_S)
|
|
logger.info("✓ 종료: %s", what)
|
|
except TimeoutError:
|
|
logger.warning("⚠ 종료: %s 가 %.0f초 안에 안 끝나 건너뜀", what, _SHUTDOWN_STEP_TIMEOUT_S)
|
|
except Exception as exc:
|
|
logger.warning("⚠ 종료: %s 중 예외 — %s", what, exc)
|
|
|
|
|
|
@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"),
|
|
)
|
|
|
|
# ⚠⚠ **리로드마다 프론트를 다시 빌드하지 않는다** (2026-09-12 실측으로 드러난 자리).
|
|
# uvicorn 리로더는 `*.py` 가 바뀔 때마다 이 lifespan 을 다시 탄다. 그때마다
|
|
# `npm run build`(최대 300초, B07 CAD 빌드까지 딸림)가 돌고 Vite 를 죽였다 살리느라
|
|
# **리로드가 몇 분씩 걸리거나 아예 멈춘다** — 그날 두 번 멈췄고 한 번은 두 시간 반 동안
|
|
# 안 끝나, `/api/health` 가 `stale: true` 인 채로 옛 코드를 계속 내주었다.
|
|
# ⇒ **옛 화면을 보고 헛검증하게 만드는 자리라 그냥 느린 것이 아니다.**
|
|
# 개발 중에는 Vite 가 HMR 로 이미 최신을 내주므로 다시 빌드할 까닭이 없다.
|
|
if ENVIRONMENT == "development" and _dev_port_listening(FRONTEND_DEV_PORT):
|
|
logger.info(
|
|
"[Frontend] 개발 서버가 %d 포트에 이미 살아 있음 — 빌드·재기동 건너뜀(리로드)",
|
|
FRONTEND_DEV_PORT,
|
|
)
|
|
else:
|
|
# 첫 기동(또는 프로덕션) — 여기서만 빌드한다.
|
|
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("앱 종료 중...")
|
|
await _shutdown_step(
|
|
"백그라운드 작업 정리", _cancel_tasks(cleanup_task, resource_task, temp_cleanup_task)
|
|
)
|
|
stop_frontend_dev()
|
|
await _shutdown_step("DB 풀 종료", close_db_pool())
|
|
logger.info("앱 종료 끝")
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
# 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_route_terrain_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_stations_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(b08_structure_sheet_router, dependencies=protected_with_company)
|
|
app.include_router(b09_estimation_router, dependencies=protected_with_company)
|
|
app.include_router(b09_cost_sheet_router, dependencies=protected_with_company)
|
|
app.include_router(b09_contract_router, dependencies=protected_with_company)
|
|
app.include_router(b09_execution_router, dependencies=protected_with_company)
|
|
app.include_router(b09_progress_router, dependencies=protected_with_company)
|
|
app.include_router(b09_edits_router, dependencies=protected_with_company)
|
|
app.include_router(b09_factors_router, dependencies=protected_with_company)
|
|
# 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근).
|
|
# 그 위에 서버가 환경까지 한 번 더 본다.
|
|
app.include_router(dev_unlock_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",
|
|
}
|
|
|
|
|
|
def _patch_windows_reload() -> None:
|
|
"""윈도우에서 uvicorn 리로더가 **영원히 멈추는 것**을 고친다 (2026-09-12 실측).
|
|
|
|
무엇이 잘못됐나
|
|
uvicorn 0.24 의 `BaseReload.restart()` 는 윈도우에서만 옛 워커를
|
|
`os.kill(worker_pid, CTRL_C_EVENT)` 로 내리려 한다. 그런데 CTRL_C_EVENT 는
|
|
**프로세스 그룹을 지정해 보낼 수 없다** — MSDN 이 「그룹 id 가 0 이 아니면 호출은
|
|
성공하지만 신호는 전달되지 않는다」고 못박아 두었다. 워커는 리로더의 평범한
|
|
자식이라 그룹 우두머리가 아니므로 그 신호는 **아무 데도 가지 않는다.**
|
|
바로 다음 줄 `join()` 이 영원히 안 풀려서 —
|
|
· 옛 워커는 살아서 **옛 코드로 요청을 계속 처리**하고
|
|
· 새 워커는 태어나지 않으며
|
|
· 그 뒤의 파일 변경은 **감지조차 안 된다**(리로더가 그 자리에 갇혀 있다)
|
|
「고쳤는데 화면이 안 바뀐다」로 보이는 자리라 그냥 느린 것이 아니다.
|
|
|
|
실측 (그 자리를 그대로 흉내 낸 부모·자식 한 쌍)
|
|
CTRL_C_EVENT → **8초 안에 안 풀림** (DETACHED · CREATE_NO_WINDOW ·
|
|
CREATE_NO_WINDOW+NEWGROUP 셋 모두) · `terminate()` → **0.00초** ·
|
|
CTRL_BREAK_EVENT(자식을 그룹 우두머리로) → **0.00초**
|
|
⇒ uvicorn 이 **리눅스에서 이미 쓰는** `terminate()` 가 윈도우에서도 즉시 먹는다.
|
|
그래서 여기서는 그 리눅스 경로를 그대로 쓴다(새 방식을 지어내지 않는다).
|
|
|
|
⚠ 개발 기동(`DEBUG`)에서만 갈아 끼운다. uvicorn 이 위쪽에서 고치면 **이 함수를 지우면
|
|
된다** — 다른 자리는 아무것도 안 건드린다.
|
|
"""
|
|
from uvicorn._subprocess import get_subprocess
|
|
from uvicorn.supervisors.basereload import BaseReload
|
|
|
|
def restart(self: BaseReload) -> None:
|
|
self.process.terminate()
|
|
self.process.join()
|
|
self.process = get_subprocess(config=self.config, target=self.target, sockets=self.sockets)
|
|
self.process.start()
|
|
|
|
BaseReload.restart = restart
|
|
logger.info("[reload] 윈도우 리로더를 terminate() 방식으로 갈아 끼움")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
|
|
import uvicorn
|
|
|
|
if DEBUG and sys.platform == "win32":
|
|
_patch_windows_reload()
|
|
|
|
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,
|
|
)
|