숙박 사이트를 빌드할 때 SiteOntology(o2o-site-ontology)에 이 가게 프로필을 보내 검색 키워드를 받고, 이 가게의 확인된 자료로 거른 것만 `<meta name="keywords">` 와 제목 업종어 자리에 싣는다. 실측(2026-09-14, 스테이머뭄 프로필): 추천 10건 중 `군산 독채 마당 펜션`·`군산 독채 복층 펜션`· `군산 커플 프라이빗 펜션` 이 status=ok 로 왔다 — SiteOntology 사실 필터는 수용 인원과 일부 시설만 본다. 사전에는 `선유도 독채펜션`(다른 권역)·`군산 펜션 최저가`(가격 주장)도 있다. 메타 태그와 제목은 AI 검색이 그대로 읽는 자리라, 키워드의 모든 낱말이 이 가게 자료에 있을 때만 싣는다. SiteOntology 쪽 함정도 실측으로 막았다 — 없는 regionId 는 500(외래키), 해석 안 된 query 도 201 로 입력 문자열 검색 결과를 준다. - services/external/site_ontology.py: publish(generate:false) → match 두 번 호출. 500 이면 지역 없이 재시도, resolved 가 우리 place_id 가 아니면 실패로 본다 - services/seo_keywords.py: 스냅샷 → 프로필(있음=features · 없음=뺌 · 모름=unverified), 낱말 대조 거르기, 업종어뿐인 단어 제외, 제목은 유형 레인 코어 중 시·군 이름을 품고 예약·추천이 없는 것. 숙박만 - services/build_service.py: 스냅샷 직후 호출해 snapshot["seo"] 에 싣는다(= 발행 기록). 실패해도 발행 계속 - services/site_payload.py · shared site-payload.ts: 선택 필드 `seo` — 옛 payload·목업은 그대로 - site/src/seo/meta.ts · head.ts: 제목 `<상호> · <대표 키워드>`(15자 미만이면 예전 제목), keywords 태그는 키워드가 있을 때만(빈 태그를 만들지 않는다) - config_models.py · .env.example: SITE_ONTOLOGY_URL — 비우면 호출하지 않는다 - docs/ARCHITECTURE.md 발행 파이프라인 · docs/DEVLOG.md pytest tests/test_seo_keywords.py 16 passed · 백엔드 전체 650 passed(실패 2건은 이전부터: test_rate_limit_closes_the_tap · test_사이트_디렉터리_밖의_thumbs_에_올린다) site tsc·eslint 통과 · vitest 63 passed · frontend·admin tsc 통과 실제 발행 한 바퀴(로컬 SiteOntology :3100): 스테이머뭄 → `<title>스테이머뭄 · 군산 독채펜션</title>` + keywords 9건, 렌더 게이트 불일치 0건. SiteOntology 없는 payload 는 제목·head 가 예전 그대로 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LBR4o9Nth3g4eoQEnL1iat
173 lines
7.7 KiB
Python
173 lines
7.7 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 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")
|
|
# 발행 사이트 메타 키워드(keywords · 제목)를 받아 오는 사내 서비스(o2o-site-ontology). 비면 그 단계만
|
|
# 건너뛴다 — 제목·메타가 예전 그대로 나간다(services/seo_keywords).
|
|
site_ontology_url: str = Field("", validation_alias="SITE_ONTOLOGY_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()
|