"""설정 모델 — 값은 전부 환경변수(최상위 .env 포함)에서 온다. ★ 환경변수 이름은 validation_alias 로 못 박는다. 필드명만 두면 `port` 가 흔한 `PORT` 를 주워 먹어 엉뚱한 포트로 뜬다. """ from functools import lru_cache from typing import Optional from pydantic import Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict import os # 레포 최상위 .env. 여기서 네 단계 위다 — 세 단계로 두면 solution/.env(없는 파일)를 본다. _REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) _DOTENV = os.path.join(_REPO_ROOT, ".env") APP_ENV = os.environ.get("APP_ENV", "local") # ★ APP_ENV=test 면 .env 를 읽지 않는다. 실키가 새면 테스트가 실제 외부 API 를 때린다. _ENV_FILE = None if APP_ENV == "test" else _DOTENV # ★ 테스트는 별도 DB. conftest 가 "이름에 test 없으면 중단" 으로 dev DB 를 지킨다. _DEFAULT_DB_NAME = "web4ai_test_db" if APP_ENV == "test" else "web4ai_db" _BASE = SettingsConfigDict(env_file=_ENV_FILE, env_file_encoding="utf-8", extra="ignore", case_sensitive=False) class WebServerConfig(BaseSettings): model_config = _BASE server_name: str = Field("Web4aiServer", validation_alias="WEB_SERVER_NAME") port: int = Field(9800, validation_alias="WEB_SERVER_PORT") process_count: int = Field(1, validation_alias="WEB_PROCESS_COUNT") is_ssl: bool = Field(False, validation_alias="WEB_IS_SSL") is_test: bool = Field(False, validation_alias="WEB_IS_TEST") # CORS 허용 오리진(쉼표로 여럿). vite 는 3000 이 막히면 3001, 3002… 로 옮겨 뜬다. client_url: str = Field( "http://localhost:3000,http://localhost:3001,http://localhost:3002," "http://localhost:3003,http://localhost:3004,http://localhost:3005", validation_alias="CLIENT_URL", ) landing_url: str = Field("", validation_alias="LANDING_URL") class LogConfig(BaseSettings): model_config = _BASE print_console: bool = Field(True, validation_alias="LOG_PRINT_CONSOLE") log_level: str = Field("debug", validation_alias="LOG_LEVEL") class MainDBConfig(BaseSettings): """DB read/write 분리. 읽기 접속을 안 주면 쓰기와 같은 곳을 본다(복제 없는 환경이 기본).""" model_config = _BASE db_type: str = Field("postgresql", validation_alias="DB_TYPE") name: str = Field(_DEFAULT_DB_NAME, validation_alias="DB_NAME") write_host: str = Field("127.0.0.1", validation_alias="DB_HOST") write_port: int = Field(5432, validation_alias="DB_PORT") write_id: str = Field("postgres", validation_alias="DB_USER") write_pw: str = Field("", validation_alias="DB_PASSWORD") read_host: Optional[str] = Field(None, validation_alias="DB_READ_HOST") read_port: Optional[int] = Field(None, validation_alias="DB_READ_PORT") read_id: Optional[str] = Field(None, validation_alias="DB_READ_USER") read_pw: Optional[str] = Field(None, validation_alias="DB_READ_PASSWORD") show_log: bool = Field(False, validation_alias="DB_SHOW_LOG") # 동시 커넥션 상한 = (pool_size + max_overflow) x 엔진수(R/W=2) x 워커수. # PostgreSQL max_connections 를 넘기면 안 된다. pool_size: int = Field(10, validation_alias="DB_POOL_SIZE") max_overflow: int = Field(20, validation_alias="DB_MAX_OVERFLOW") # ""/"disable"=로컬 · "require"|"verify-ca"|"verify-full"=관리형 DB sslmode: str = Field("", validation_alias="DB_SSLMODE") @model_validator(mode="after") def _read_falls_back_to_write(self): if self.read_host is None: self.read_host = self.write_host if self.read_port is None: self.read_port = self.write_port if self.read_id is None: self.read_id = self.write_id if self.read_pw is None: self.read_pw = self.write_pw return self class JwtToken(BaseSettings): model_config = _BASE access_key: str = Field("", validation_alias="JWT_ACCESS_SECRET") refresh_key: str = Field("", validation_alias="JWT_REFRESH_SECRET") access_expire_min: int = Field(30, validation_alias="JWT_ACCESS_EXPIRE_MIN") refresh_expire_day: int = Field(7, validation_alias="JWT_REFRESH_EXPIRE_DAY") class GoogleOAuthConfig(BaseSettings): """구글 로그인. client_id 가 비면 그 로그인 수단만 꺼진다 — 다른 외부 키들과 같은 규칙이다. ★ client_id 는 비밀이 아니다(프론트 번들에 그대로 들어간다). 서버가 이 값을 갖는 이유는 숨기려는 게 아니라 **수신자(aud) 대조** 때문이다 — 남의 앱에 발급된 구글 토큰을 그대로 들고 와도 우리 계정이 되지 않게 막는 유일한 검사다. ★ client_secret 은 쓰지 않는다. 프론트가 ID 토큰을 받아 오는 방식(GIS)이라 코드 교환이 없다.""" model_config = _BASE client_id: str = Field("", validation_alias="GOOGLE_CLIENT_ID") class ExternalApiConfig(BaseSettings): """키가 비면 그 어댑터만 비활성이다 — 부팅이 외부 계약에 묶이면 안 된다.""" model_config = _BASE perplexity_api_key: str = Field("", validation_alias="PERPLEXITY_API_KEY") # 동일 업소 검증 — 둘 다 있으면 카카오 우선. # 카카오: 15건 · 전화번호 O · 고유 id O · 행정구역 코드 O # 네이버: 5건 · 전화번호 X · 고유 id 불확실 · 행정구역 코드 X → AMBIGUOUS 가 는다 kakao_rest_api_key: str = Field("", validation_alias="KAKAO_REST_API_KEY") naver_client_id: str = Field("", validation_alias="NAVER_CLIENT_ID") naver_client_secret: str = Field("", validation_alias="NAVER_CLIENT_SECRET") gemini_api_key: str = Field("", validation_alias="GEMINI_API_KEY") # 3.7 기본: 라벨이 틀리면 사람 확인 큐 비용이 모델 값 차이(1건 $0.045 vs $0.018)보다 크다. gemini_vision_model: str = Field("gemini-3.7-flash", validation_alias="GEMINI_VISION_MODEL") gemini_text_model: str = Field("gemini-3.7-flash", validation_alias="GEMINI_TEXT_MODEL") # 이 값 미만이면 자동 반영하지 않고 사람 확인 큐(PENDING_REVIEW)에 남긴다. vision_confidence_threshold: float = Field(0.7, validation_alias="VISION_CONFIDENCE_THRESHOLD") tour_api_key: str = Field("", validation_alias="TOUR_API_KEY") # 발행할 때 이 숙소의 노래를 한 곡 만든다(services/song_service). 비면 그 단계만 건너뛴다. suno_api_key: str = Field("", validation_alias="SUNO_API_KEY") # ★ 콜백은 쓰지 않고 폴링한다 — 우리 백엔드는 로컬·사내망이라 Suno 가 부를 수 있는 주소가 아니다. # 그래도 API 가 필수로 요구하는 필드라 값을 들고 있는다(services/external/suno.py 주석). suno_callback_url: str = Field("", validation_alias="SUNO_CALLBACK_URL") # .env 를 요청마다 다시 읽지 않는다. 새 코드는 Depends(get_*) 로 주입받는다. @lru_cache def get_web_server_config() -> WebServerConfig: return WebServerConfig() @lru_cache def get_log_config() -> LogConfig: return LogConfig() @lru_cache def get_main_db_config() -> MainDBConfig: return MainDBConfig() @lru_cache def get_jwt_token_config() -> JwtToken: return JwtToken() @lru_cache def get_external_api_config() -> ExternalApiConfig: return ExternalApiConfig() @lru_cache def get_google_oauth_config() -> GoogleOAuthConfig: return GoogleOAuthConfig()