Files
Aislo/config/config_system.py
T
eomsangdonandClaude Opus 5 1230961c54 chore(auth): 개발 단계 이메일 인증 생략 스위치 추가
개발 중 로그인마다 인증 코드를 기다리던 것을 끄는 스위치.
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
2026-09-13 11:32:22 +09:00

284 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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"
)