Files
Aislo/main.py
T

544 lines
29 KiB
Python

"""
main.py
FastAPI 애플리케이션 진입점
- 공통 설정 로드 (config_system, config_db)
- 프론트엔드 자동 빌드 및 서빙 (npm run build)
- 정적 파일 서빙 (프론트엔드 빌드 결과)
- DB 풀 초기화/종료
- 라우터 등록 (페이지별 API)
"""
import asyncio
import logging
import os
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_Repository import ProjectNotFoundError
from B03_FileInput.B03_FileInput_Router import router as b03_file_input_router
from B03_FileInput.B03_FileInput_Router_Errors import project_not_found_response
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_DesignSave import router as b06_design_save_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 B06_Section.B06_Section_Router_CrossTable import router as b06_section_cross_table_router
from B06_Section.B06_Section_Router_Calc import router as b06_calc_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 B07_DesignDetail.B07_DesignDetail_Router_PlanKinds import router as b07_plan_kinds_router
from B07_DesignDetail.B07_DesignDetail_Router_Reset import router as b07_reset_router
from B07_DesignDetail.B07_DesignDetail_Router_Support_Scale import router as b07_scale_router
from B08_Quantity.B08_Quantity_Router_Earthwork import router as b08_earthwork_router
from B08_Quantity.B08_Quantity_Router_Hydro import router as b08_hydro_router
from B08_Quantity.B08_Quantity_Router_Options import router as b08_options_router
from B08_Quantity.B08_Quantity_Router_Summary import router as b08_summary_router
from B08_Quantity.B08_Quantity_Router_Material import router as b08_material_router
from B08_Quantity.B08_Quantity_Router_Milestone import router as b08_milestone_router
from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router
from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router
from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router
from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_master_template_router
from M02_MasterTemplete.M02_MasterTemplete_Router_Combos import router as m02_combos_router
from M02_MasterTemplete.M02_MasterTemplete_Router_Drawing import router as m02_drawing_router
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import router as m02_items_router
from M02_MasterTemplete.M02_MasterTemplete_Router_Layers import router as m02_layers_router
from M02_MasterTemplete.M02_Project_Items_Router import router as m02_project_items_router
from M02_MasterTemplete.StructOptions.M02_SO_Router import read_router as m02_so_read_router
from M02_MasterTemplete.StructOptions.M02_SO_Router import router as m02_so_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,
require_system_admin,
verify_session,
)
# 개발환경 전용 — 「확정 없이 다음으로」. **문은 서버가 정본이다** — `ENVIRONMENT` 가
# 개발이 아니면 세 입구 모두 403 으로 거절한다(화면 단추 숨김은 보조).
from common_util.common_util_dev_unlock_router import router as dev_unlock_router
from common_util.common_util_help_router import router as help_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
from config.config_dev_server import (
FRONTEND_DEV_PORT,
build_frontend,
dev_port_listening,
patch_windows_reload,
reload_dirs,
reload_excludes,
serve_frontend_dev,
stop_frontend_dev,
)
# 설정 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())
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,
)
app.add_exception_handler(ProjectNotFoundError, project_not_found_response) # 지워진 프로젝트 = 404
# ─────────────────────────────────────────────────────────────────────────
# 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_design_save_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(b06_section_cross_table_router, dependencies=protected_with_company)
app.include_router(b06_calc_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(b07_plan_kinds_router, dependencies=protected_with_company)
app.include_router(b07_scale_router, dependencies=protected_with_company)
app.include_router(b07_reset_router, dependencies=protected_with_company)
app.include_router(b08_hydro_router, dependencies=protected_with_company)
app.include_router(b08_earthwork_router, dependencies=protected_with_company)
app.include_router(b08_summary_router, dependencies=protected_with_company)
app.include_router(b08_material_router, dependencies=protected_with_company)
app.include_router(b08_options_router, dependencies=protected_with_company)
app.include_router(b08_milestone_router, dependencies=protected_with_company)
app.include_router(b09_estimation_router, dependencies=protected_with_company)
# B08 수량·B09 원가계산 라우터 13 개는 끊음 — 코드는 `old_code/` 에 있고 화면은 빈 틀만 뜬다(PLAN 7-3).
# 마스터 데이터 — 회사·프로젝트가 아니라 시스템 관리자만 본다.
system_admin_only = [Depends(verify_session), Depends(require_system_admin)]
app.include_router(m01_master_data_router, dependencies=system_admin_only)
# 단가산출 흐름도 시험 길 — 폴더째 지우면 이 줄도 지움
app.include_router(
__import__("M02_MasterTemplete.DataFlow.M02_DF_Router", fromlist=["router"]).router,
dependencies=system_admin_only,
)
# 마스터 템플릿 — 시스템 층은 시스템 관리자만 · 층(회사·개인·프로젝트)·도면 길은 로그인만(권한은 길 안에서).
app.include_router(m02_master_template_router, dependencies=system_admin_only)
# 구조물 옵션 보이기 · 그룹(PLAN 46) — 고쳐 씀은 시스템 관리자 · 읽기는 로그인만
app.include_router(m02_so_router, dependencies=system_admin_only)
app.include_router(m02_so_read_router, dependencies=protected)
app.include_router(m02_layers_router, dependencies=protected)
app.include_router(m02_items_router, dependencies=protected)
app.include_router(m02_combos_router, dependencies=protected)
app.include_router(m02_drawing_router, dependencies=protected)
app.include_router(m02_labor_router, dependencies=protected)
# 프로젝트 집계 항목 복사본(PLAN 25-2b) — 로그인만 · 프로젝트 권한은 길 안에서.
app.include_router(m02_project_items_router, dependencies=protected)
# 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근).
# 그 위에 서버가 환경까지 한 번 더 본다.
app.include_router(dev_unlock_router, dependencies=protected_with_company)
# [?] 설명서 글(54-2) — 로그인만 · 파일을 부를 때마다 읽음.
app.include_router(help_router, dependencies=protected)
# ─────────────────────────────────────────────────────────────────────────
# 앱 실행
# ─────────────────────────────────────────────────────────────────────────
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)까지 훑는다 — config_dev_server 참조.
reload_dirs=reload_dirs() if DEBUG else None,
reload_excludes=reload_excludes() if DEBUG else None,
access_log=False,
)