킹서버(o2oadmin@172.30.1.36)에 처음 올리면서, 서버에 올려야만 드러나는 결함 넷을 잡았다.
전부 "화면은 뜨는데 안 되는" 종류라 로컬에서는 끝까지 보이지 않는다.
- CORS 허용 오리진(client_url)만 env override 가 없었다. 도커가 굽던 config.local.toml 은
플레이스홀더라 허용 목록이 localhost:3000~3005 뿐이고, 배포 주소에서는 모든 API 호출이
프리플라이트에서 죽었다. 서버 로그에는 400 만 남아 원인이 CORS 라는 게 안 보인다
- .env 경로가 세 단계라 solution/.env(없는 파일)를 보고 있었다. 백엔드를 solution/ 아래로
옮길 때 안 고쳐진 자리. toml 이 값을 들고 있어 로컬에서 드러나지 않았다
- admin 의 "빌더 열기" 가 VITE_SOLUTION_URL 미주입으로 localhost:3000 을 가리켰다
- PUBLIC_API_BASE_URL 은 브라우저가 부르는 주소인데 기본값이 localhost 라 서버에서 즉시 틀린다
설정 — toml 층 제거, pydantic-settings 로 전환 (FastAPI 공식 방식)
- config_loader.py · config.{local,test}.toml.example 삭제, 기본값은 config_models 로
- BaseSettings + env_file. `_apply_*_env_override` 4개 제거 — 키를 손으로 나열하는 구조라
하나 빠뜨리면 조용히 틀렸고, 실제로 client_url 이 빠져 있었다
- 환경변수 이름은 validation_alias 로 못 박음. 필드명만 두면 `port` 가 흔한 `PORT` 를 먹는다
- 테스트 DB 분리(web4ai_test_db)는 config.test.toml 이 하던 몫이라 APP_ENV 기본값으로 이관
- lru_cache 로 .env 재읽기 방지. 새 코드는 Depends(get_*) 주입 가능
- 호출부 21개 파일 무변경 — server_configs 가 같은 이름을 계속 내보낸다
배포 — 킹서버는 :80 을 호스트 nginx 가 물고 있고 사내망에 열린 건 30xxx 뿐이다
- 컴포즈 포트를 전부 .env 변수로 추출(기본값은 기존 값 그대로, 로컬 무영향)
- 컨테이너 이름을 폴더 구조에 맞춤 — solution-backend·solution-worker·solution-frontend·
solution-site·admin-backend·admin-frontend. api·web·nginx 는 어느 폴더 코드인지
이름만으로 알 수 없었고, 백엔드 셋이 이미지 한 벌을 나눠 써서 특히 헷갈렸다
- worker 에 container_name 을 붙여 `-1` 접미사 제거(동시성은 WORKER_CONCURRENCY 가 맡는다)
- 어드민 앱·API 는 compose 프로필 뒤로 — 지금 안 쓴다. 켤 때 --profile admin
- deploy.sh: 서비스 하나를 지정해도 백엔드 형제를 함께 교체한다. 이미지 한 벌을 나눠 써서
하나만 바꾸면 옛 코드로 도는 컨테이너가 남는데 `ps` 로는 셋 다 살아 있다
- log.sh: 1=전체, 2번부터 개별. compose v2.20 이 커스텀 --format 을 파싱하지 못해 상태가
전부 "미기동" 으로 보이던 것도 --services --filter 로 교정
- docs/SERVERS.md 신설(접속·경로·포트·DB·sudo 없음), docs/DEVLOG.md 신설
정리
- 개발 전용 자동 로그인 제거 — 편의 하나에 검색 경로의 비동기 대기가 딸려 있었고,
평문 비밀번호를 .env 에 두라고 권하는 모양새였다
- API 이름을 디렉토리에 맞춤: 사장님/내부 → 솔루션 API · 어드민 API (21곳)
- .env.example 을 읽는 폴더 기준 구역으로 재편 (solution/backend · solution/frontend ·
solution/site · compose)
- AGENTS.md 에 negosium 브랜치·커밋 규약 명시
검증(킹서버 실측) — 컨테이너 4개 새 이름으로 기동, 솔루션 API·사장님 앱 200,
발행 사이트 404(발행물 없음, 정상), CORS 허용/차단 각 확인, toml 없이 부팅,
APP_ENV=test 시 web4ai_test_db·실키 미주입 확인.
147 lines
6.1 KiB
Python
147 lines
6.1 KiB
Python
"""설정 모델 — 값은 전부 환경변수(최상위 .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 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")
|
|
|
|
|
|
# .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()
|