개발 중 로그인마다 인증 코드를 기다리던 것을 끄는 스위치. AUTH_OTP_DISABLED 가 true 이고 ENVIRONMENT 가 development 일 때만 듣는다 — 운영에서는 켜져 있어도 무시된다. - 로그인: 새 브라우저·주기 재인증 둘 다 코드 없이 통과, 생략마다 경고 로그 - 회원가입: 흐름은 그대로 두고 인증 코드를 서버 로그에 기록 - .env 에 AUTH_OTP_DISABLED=True (배포 전 False 로 되돌릴 것) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TJC56e4osweKJ4vafm9ReM
284 lines
17 KiB
Python
284 lines
17 KiB
Python
"""
|
||
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"))
|
||
# 시스템 로그(누가 무엇을 했나) 보관 기간 — 사고 추적에 1년 (2026-09-06 사용자 확정).
|
||
AUDIT_LOG_RETENTION_DAYS = int(os.getenv("AUDIT_LOG_RETENTION_DAYS", "365"))
|
||
|
||
# 한 세션이 한 시간에 부를 수 있는 API 횟수 — 넘으면 **막지 않고 시스템 로그에만** 남긴다
|
||
# (2026-09-06 사용자 지시). 화면 한 번 여는 데 약 180회가 나가므로, 사람이 쉬지 않고
|
||
# 화면을 열어도 한 시간에 수천 회다. 그 몇 배를 넘으면 사람이 아니라고 본다.
|
||
API_CALL_HOURLY_LIMIT = int(os.getenv("API_CALL_HOURLY_LIMIT", "20000"))
|
||
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
|
||
# 표준 횡단면도 — 상세도라 지식DB에 지정 축척이 없다. 본 그림 1/50, 측구 부분확대도
|
||
# 1/10 (2026-09-04). 노폭 4 m 기준 본 그림이 A1 작도영역에 여유 있게 든다.
|
||
DRAWING_SCALE_CROSS_STANDARD = 50
|
||
DRAWING_SCALE_DITCH_DETAIL = 10
|
||
|
||
# 시스템 리소스 로그 (루트 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"))
|
||
|
||
# `sessions.last_activity_at` 을 다시 쓰는 최소 간격(초). 유휴 판정이 4시간 단위라 초 단위로
|
||
# 정확할 이유가 없고, 요청마다 쓰면 원격 DB 왕복이 약 20ms 씩 붙는다(2026-09-06 실측).
|
||
SESSION_ACTIVITY_WRITE_INTERVAL_SECONDS = int(
|
||
os.getenv("SESSION_ACTIVITY_WRITE_INTERVAL_SECONDS", "60")
|
||
)
|
||
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
|
||
|
||
# 개발 단계에서 로그인 이메일 인증(OTP)을 건너뛴다 (2026-09-13 사용자 지시).
|
||
# ⚠ 운영에서는 절대 켜지 않는다 — ENVIRONMENT 가 development 일 때만 듣는다.
|
||
# 가입 인증 코드는 건너뛰지 않고 서버 로그(tmp/server.log)에 찍어 메일 없이 진행한다.
|
||
AUTH_OTP_DISABLED = (
|
||
os.getenv("AUTH_OTP_DISABLED", "False").lower() == "true" and ENVIRONMENT == "development"
|
||
)
|