""" config_system.py 백엔드 작동 제어 및 알고리즘 파라미터 모든 제어 변수는 여기서 관리. 코드 내 하드코딩 금지. """ import os from pathlib import Path from dotenv import load_dotenv PROJECT_ROOT = Path(__file__).resolve().parent.parent load_dotenv(PROJECT_ROOT / ".env") os.environ["PYTHONUTF8"] = "1" # ═════════════════════════════════════════════════════════════════════════ # ⚠ 0. 위험 스위치 — 프로젝트 하드 삭제 # ═════════════════════════════════════════════════════════════════════════ # 대시보드의 프로젝트 삭제 버튼이 무엇을 지우는지 결정한다. 눈에 띄라고 맨 위에 둔다. # # False (배포 기본값) # 지금까지의 동작. `projects.deleted_at`만 찍어 목록에서 감춘다. 사용자가 올린 # 라이다 원본은 나중에 다른 프로젝트 개발에 재활용할 수 있는 자산이라 남긴다. # # True (개발 PC 전용) # `projects` 행을 실제로 DELETE 하고(자식 테이블은 FK CASCADE로 함께 사라진다), # 영구저장소의 `storage/{회사}/{사용자}/{프로젝트ID}/` 폴더를 통째로 지운다. # B03 업로드 원본까지 없어진다. 되돌릴 수 없다. # # 개발 중에는 프로젝트를 반복 생성·삭제하는데 소프트 삭제만 하면 수십 GB 라이다 원본이 # 디스크에 계속 쌓인다. 정리 잡도 없다. 그래서 개발 PC의 `.env`에서만 True로 켠다 # (2026-08-08 사용자 지시). # # .env → PROJECT_DELETE_HARD_ENABLED=True # # 기본값이 False라 환경변수를 빠뜨린 배포 환경은 자동으로 안전한 쪽에 선다. PROJECT_DELETE_HARD_ENABLED = os.getenv("PROJECT_DELETE_HARD_ENABLED", "False").lower() == "true" # ───────────────────────────────────────────────────────────────────────── # 1. FastAPI 서버 # ───────────────────────────────────────────────────────────────────────── SERVER_HOST = os.getenv("SERVER_HOST", "0.0.0.0") SERVER_PORT = int(os.getenv("SERVER_PORT", "8000")) DEBUG = os.getenv("DEBUG", "False").lower() == "true" # 정적 파일 서빙 (프론트엔드 빌드 결과) STATIC_DIR = os.path.join(os.path.dirname(__file__), "node_modules", ".build") STATIC_URL = "/static" # ───────────────────────────────────────────────────────────────────────── # 2. 로깅 # ───────────────────────────────────────────────────────────────────────── LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO") # ───────────────────────────────────────────────────────────────────────── # 3. 데이터베이스 (MariaDB) # ───────────────────────────────────────────────────────────────────────── DB_HOST = os.getenv("DB_HOST", "localhost") DB_PORT = int(os.getenv("DB_PORT", "3306")) DB_NAME = os.getenv("DB_NAME", "aislo_db") DB_USER = os.getenv("DB_USER", "aislo") DB_PASSWORD = os.getenv("DB_PASSWORD", "aislo") # asyncmy 연결 풀 설정 DB_POOL_MIN = int(os.getenv("DB_POOL_MIN", "5")) DB_POOL_MAX = int(os.getenv("DB_POOL_MAX", "20")) # ───────────────────────────────────────────────────────────────────────── # 4. 파일 업로드 제한 # ───────────────────────────────────────────────────────────────────────── UPLOAD_MAX_MB = int(os.getenv("UPLOAD_MAX_MB", str(30 * 1024))) # 한 번에 보낼 수 있는 **파일 수**. 화면의 슬롯 수(5)와 다르다 — 계획노선 shapefile은 # 한 슬롯에 5개(.shp/.shx/.dbf/.cpg/.prj)가 한 벌로 들어오기 때문이다(2026-08-31). UPLOAD_MAX_FILES = int(os.getenv("UPLOAD_MAX_FILES", "10")) UPLOAD_CHUNK_SIZE_BYTES = int(os.getenv("UPLOAD_CHUNK_SIZE_BYTES", str(1024 * 1024 * 1024))) UPLOAD_ALLOWED_EXT = [ ".csv", ".shp", ".shx", ".dbf", ".cpg", ".las", ".laz", ".tif", ".tfw", ".prj", ".dxf", ] # 계획노선 shapefile 한 벌. GDAL이 열려면 같은 basename으로 한 폴더에 있어야 한다. SHAPEFILE_MEMBER_EXT = (".shp", ".shx", ".dbf", ".cpg", ".prj") SHAPEFILE_REQUIRED_EXT = (".shp", ".shx", ".dbf") CHUNK_TEMP_DIR = os.getenv("CHUNK_TEMP_DIR", "B03_FileInput/chunks_temp") CHUNK_RETENTION_HOURS = int(os.getenv("CHUNK_RETENTION_HOURS", "24")) # 프로젝트 생성 전 임시 보관함 — 계정에 묶어 자료를 먼저 올려 두는 공간. # 라이다 원본이 수십 GB라 보관 기간이 길면 디스크를 금방 채운다. 보관 기간과 정리 주기는 # 운영하며 조정할 값이라 여기서 관리한다(2026-08-08 사용자 지시). TEMP_UPLOAD_DIR_NAME = os.getenv("TEMP_UPLOAD_DIR_NAME", "tmp") TEMP_UPLOAD_RETENTION_DAYS = int(os.getenv("TEMP_UPLOAD_RETENTION_DAYS", "30")) TEMP_UPLOAD_CLEANUP_INTERVAL_HOURS = int(os.getenv("TEMP_UPLOAD_CLEANUP_INTERVAL_HOURS", "6")) MERGE_TIMEOUT_SECONDS = int(os.getenv("MERGE_TIMEOUT_SECONDS", "3600")) SEND_ANALYSIS_COMPLETION_EMAIL = ( os.getenv("SEND_ANALYSIS_COMPLETION_EMAIL", "True").lower() == "true" ) # WF1 자동 확정 후 B05 기본 경로 → B06 기본 횡단 설계까지 서버가 이어서 계산·확정한다 # (2026-08-04 사용자 확정). 프로젝트에 경로가 이미 있으면 체인은 스스로 건너뛴다. AUTO_DESIGN_CHAIN_ENABLED = os.getenv("AUTO_DESIGN_CHAIN_ENABLED", "True").lower() == "true" # ───────────────────────────────────────────────────────────────────────── # 5장(지형·설계 파라미터)은 파일이 700줄을 넘어 떼어냈다(2026-09-04). # 여기서 그대로 다시 내보내므로 `from config.config_system import …` 호출부는 불변이다. from config.config_system_design import * # noqa: E402,F401,F403 from config.config_system_design import _SCS_SOIL as _SCS_SOIL # noqa: E402 from config.config_system_terrain import * # noqa: E402,F401,F403 # ───────────────────────────────────────────────────────────────────────── # 6. 저장소 경로 # ───────────────────────────────────────────────────────────────────────── STORAGE_BASE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "storage") # 1:5,000 수치지형도 도엽 저장 폴더명 — 프로젝트 영구저장소 하위 # (storage/{회사}/{사용자}/{프로젝트ID}/B04_PreProcess/processed/map_sheets/) MAP_SHEETS_DIRNAME = "map_sheets" MAP_SHEETS_INDEX_FILENAME = "map_sheets_index.json" # 배경 지도(위성·하이브리드·백지도) 확보 범위 = 계획노선이 걸치는 **기준 도엽**의 도곽. # 1:5,000 도엽 1매는 약 2.2km × 2.3km다. 주변 도엽은 받지 않는다(2026-08-01 사용자 지시). # # 도엽 1매를 zoom 18로 받으면 한 변이 19타일(4,864px)이라 파일이 지나치게 커진다. # 아래 한도 안에 들어오는 가장 선명한 zoom을 자동으로 고른다(브이월드 타일 눈금 그대로). SURFACE_MAP_MAX_TILES_PER_SIDE = 16 SURFACE_MAP_MAX_ZOOM = 18 SURFACE_MAP_MIN_ZOOM = 14 # 브이월드 지도서비스 도엽 자동 다운로드 — 세션 만료 시 id/pw로 자동 재로그인 (.env) VWORLD_LOGIN_ID = os.getenv("VWORLD_LOGIN_ID", "") VWORLD_LOGIN_PW = os.getenv("VWORLD_LOGIN_PW", "") # 선택: 수동 발급 세션 쿠키 (예: "JSESSIONID=..; SSCSID=.."). 비어 있으면 자동 로그인 사용. VWORLD_SESSION_COOKIE = os.getenv("VWORLD_SESSION_COOKIE", "") # ───────────────────────────────────────────────────────────────────────── # B07 도면 작성 척도 (2026-08-29 사용자 확정) # 종단면도: 가로 1/1,000 · 세로 1/200 (세로 5배 과장) # 횡단면도: 가로·세로 1/100 # 도면 좌표는 종이 밀리미터다. 실거리 1 m가 종이에서 몇 mm인지 = 1000 / 축척분모. # ───────────────────────────────────────────────────────────────────────── DRAWING_SCALE_LONG_H = 1000 # 종단면도 가로 축척 분모 DRAWING_SCALE_LONG_V = 200 # 종단면도 세로 축척 분모 DRAWING_SCALE_CROSS = 100 # 횡단면도 가로·세로 축척 분모 DRAWING_SHEET = "A1" # 용지 규격 (840x594 mm 도각 템플릿) # 토적도(유토곡선)·유역도 척도 (2026-08-30 사용자 확정 — 납품 도면 표기 기준) # 유토곡선: 가로 1/2,000 · 세로 1/50,000. 세로축은 길이가 아니라 **토량(㎥)**이라 # 축척분모를 그대로 못 쓴다 — 종이 1 mm가 받는 토량(50 ㎥)으로 적는다. # 유역도 : 평면 1/6,000 (가로·세로 같은 배율) # A1 유효 작도영역 693x468 mm를 넘으면 늘리지 않고 경고만 남긴다(척도 보존). DRAWING_SCALE_MASSHAUL_H = 2000 # 유토곡선 가로 축척 분모 (자동 선정 실패 시 폴백) # 토적도 가로 축척 후보 — 노선 연장에 맞춰 **한 장에 들어가는 가장 큰 그림**을 고른다 # (2026-09-03 사용자 결정: 길이별 자동 축척). 도면 관행 축척만 둔다. DRAWING_SCALE_MASSHAUL_H_CANDIDATES = ( 500, 600, 1000, 1200, 1500, 2000, 2500, 3000, 4000, 5000, 6000, ) # A1 작도영역 가로(770 mm)에서 여백 2%와 좌측 행 이름칸·축 여유를 뺀 실제 가용 폭. DRAWING_MASSHAUL_USABLE_WIDTH_MM = 700.0 DRAWING_SCALE_MASSHAUL_V_M3_MM = 50.0 # 유토곡선 세로 — 종이 1 mm 당 토량(㎥) DRAWING_SCALE_BASIN = 6000 # 유역도 평면 축척 분모 (실거리 1 m = 1/6 mm) # 계획평면도·용지도 평면 축척 분모 — 지식DB 「설계제원_총괄」 측량·도면 기준 1/1,200. # 횡단면도와 같은 원칙: 축척은 줄이지 않고, 한 장에 안 들어가면 **장을 나눈다**. DRAWING_SCALE_PLAN = 1200 # 시스템 리소스 로그 (루트 log 폴더에 단일 파일, 1개월 보관) LOG_BASE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "log") RESOURCE_LOG_PATH = os.path.join(LOG_BASE_DIR, "system_resources.log") RESOURCE_SAMPLE_INTERVAL_SEC = 120 # 계측 주기: 2분 RESOURCE_LOG_RETENTION_DAYS = 30 # 로그 보관: 1개월 PROJECT_STORAGE_STAGE_DIRS = frozenset( { "B03_FileInput", "B04_PreProcess", "B05_Profile", "B06_Section", "B07_DesignDetail", "B08_Quantity", "B09_Estimation", } ) # 프로젝트별 저장 구조: storage/[고객사]/[사용자]/[프로젝트ID]/ def get_project_storage_path(company: str, user: str, project_id: str) -> str: """검증된 식별자로 프로젝트 저장 경로를 생성한다.""" segments = (company, user, project_id) for segment in segments: if ( not segment or segment in {".", ".."} or os.path.isabs(segment) or "/" in segment or "\\" in segment ): raise ValueError("저장 경로 식별자는 비어 있거나 경로 구분자를 포함할 수 없습니다.") storage_root = os.path.abspath(STORAGE_BASE_DIR) path = os.path.abspath(os.path.join(storage_root, *segments)) if os.path.commonpath((storage_root, path)) != storage_root: raise ValueError("프로젝트 저장 경로가 저장소 루트를 벗어났습니다.") os.makedirs(path, exist_ok=True) return path # ───────────────────────────────────────────────────────────────────────── # 7. 인증 및 이메일 # ───────────────────────────────────────────────────────────────────────── SESSION_COOKIE_NAME = os.getenv("SESSION_COOKIE_NAME", "session_id") DEVICE_TOKEN_COOKIE_NAME = os.getenv("DEVICE_TOKEN_COOKIE_NAME", "device_token") SESSION_MAX_AGE_SECONDS = int(os.getenv("SESSION_MAX_AGE_SECONDS", "43200")) SESSION_IDLE_TIMEOUT_SECONDS = int(os.getenv("SESSION_IDLE_TIMEOUT_SECONDS", "14400")) SESSION_COOKIE_SECURE = os.getenv("SESSION_COOKIE_SECURE", "True").lower() == "true" PASSWORD_BCRYPT_ROUNDS = int(os.getenv("PASSWORD_BCRYPT_ROUNDS", "12")) OTP_VALID_MINUTES = int(os.getenv("EMAIL_OTP_VALID_MINUTES", "5")) EMAIL_REVERIFY_DAYS = int(os.getenv("EMAIL_REVERIFY_DAYS", "90")) SMTP_HOST = os.getenv("SMTP_HOST", "smtp.gmail.com") SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) SMTP_USERNAME = os.getenv("SMTP_USERNAME", "") SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "") SMTP_USE_TLS = os.getenv("SMTP_USE_TLS", "True").lower() == "true" EMAIL_FROM_ADDRESS = os.getenv("EMAIL_FROM_ADDRESS", SMTP_USERNAME) EMAIL_FROM_NAME = os.getenv("EMAIL_FROM_NAME", "Aislo Support") EMAIL_RETRY_COUNT = int(os.getenv("EMAIL_RETRY_COUNT", "3")) EMAIL_RETRY_DELAY_SECONDS = int(os.getenv("EMAIL_RETRY_DELAY_SECONDS", "2")) EMAIL_NOTIFICATION_ENABLED = os.getenv("EMAIL_NOTIFICATION_ENABLED", "True").lower() == "true" ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", "") APP_PUBLIC_BASE_URL = os.getenv( "APP_PUBLIC_BASE_URL", os.getenv("AISLO_BASE_URL", "http://localhost:8000"), ).rstrip("/") # ───────────────────────────────────────────────────────────────────────── # 8. CORS (프론트엔드 도메인) # ───────────────────────────────────────────────────────────────────────── CORS_ORIGINS = os.getenv("CORS_ORIGINS", "http://localhost:5173,http://localhost:8000").split(",") # ───────────────────────────────────────────────────────────────────────── # 9. 환경 정보 # ───────────────────────────────────────────────────────────────────────── ENVIRONMENT = os.getenv("ENVIRONMENT", "development") # development, staging, production