콘솔에 발급된 설정이 설계와 어긋나 있었다. 클라이언트 시크릿이 켜져 있고 Redirect URI 가
프론트 경로(/auth/kakao/callback)인데, 코드는 "프론트가 액세스 토큰을 들고 온다" 는
전제였다 — 시크릿을 번들에 구울 수 없으므로 그 흐름으로는 토큰을 받을 수 없다.
- services/external/kakao_identity: access_token 검증 → **인가 코드 교환**으로 교체.
★ 우리 REST 키·시크릿으로 교환한 토큰이라 정의상 우리 앱 것이다 — 구글의 aud 대조에
해당하는 검사(KAKAO_LOGIN_APP_ID)가 구조적으로 필요 없어져 설정 칸째로 지웠다.
★ 로그인 앱과 챗봇 앱이 다르면 로그인은 되는데 채널 매칭만 조용히 안 된다 —
REST 키가 어긋나면 경고 로그를 남긴다.
★ 미인증 이메일은 신원으로 쓰지 않는다(is_email_valid·is_email_verified 둘 다). 이 값으로
auth_service 가 다른 수단 가입과의 충돌을 판정한다.
- config/config_models: KAKAO_LOGIN_APP_ID → REST_API_KEY · CLIENT_SECRET · REDIRECT_URI
- router/v1/auth: Req_KakaoLogin {access_token} → {code, redirect_uri}
- frontend: lib/kakaoIdentity(인가 URL · sessionStorage state) · KakaoSignInButton ·
pages/KakaoCallbackPage(코드는 1회용이라 ref 로 한 번만 보낸다) · routes
★ 로그인 폼에서 카카오를 구글 위에 둔다 — 이 수단으로 들어와야 6자리 코드 없이 채널이 이어진다
- compose · nginx/Dockerfile: REST 키·Redirect URI 만 프론트로 흘려보낸다. 시크릿은 안 나간다
- 어드민 키는 저장하지 않는다 — 탈퇴까지 되는 키인데 로그인에는 쓰이지 않는다
- api/generated: orval 재생성. 카카오 외에도 오래 밀려 있던 문구·agent 경로가 함께 따라왔다
테스트 8건 추가(tests/test_kakao_identity.py), 관련 85 passed
(test_auth::test_google_login_is_off_when_client_id_is_empty 는 기존 실패)
lint(react-router typegen + tsc + eslint) 통과
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
194 lines
8.4 KiB
Python
194 lines
8.4 KiB
Python
"""설정 모델 — 값은 전부 환경변수(최상위 .env 포함)에서 온다."""
|
|
|
|
from functools import lru_cache
|
|
from typing import Optional
|
|
|
|
from pydantic import Field, model_validator
|
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
|
|
import os
|
|
|
|
# 레포 최상위 .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 를 읽지 않는다.
|
|
_ENV_FILE = None if APP_ENV == "test" else _DOTENV
|
|
|
|
# 테스트는 별도 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 허용 오리진(쉼표로 여럿).
|
|
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 워커수.
|
|
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):
|
|
"""구글 로그인."""
|
|
|
|
model_config = _BASE
|
|
|
|
client_id: str = Field("", validation_alias="GOOGLE_CLIENT_ID")
|
|
|
|
|
|
class KakaoLoginConfig(BaseSettings):
|
|
"""카카오 로그인. 값이 비면 그 로그인 수단만 꺼진다 — 다른 외부 키들과 같은 규칙이다.
|
|
|
|
★★ **챗봇에 물린 앱과 같은 앱이어야 한다.** 회원번호(`id`)가 챗봇 웹훅의
|
|
`user.properties.appUserId` 와 같은 값이라, 그래야 카톡 채널 발화자와 로그인 계정이
|
|
바로 이어진다(6자리 코드 절차가 필요 없어진다). 앱이 다르면 **로그인은 되는데 매칭만
|
|
조용히 안 된다** — 증상이 안 보이는 종류라, `kakao_identity` 가 값이 어긋나면 경고를 남긴다.
|
|
|
|
★ 그래서 `rest_api_key` 는 `agent_config.KAKAO_BOT_REST_API_KEY` 와 **같은 값**이다.
|
|
AGENTS.md 의 "두 곳에 같은 값을 적지 않는다" 를 여기서만 깨는 이유: 챗봇 설정과 로그인
|
|
설정은 수명이 다르고(한쪽만 갈아 끼우는 일이 실제로 생긴다), 한 칸으로 합치면 어긋났을 때
|
|
**어긋났다는 사실 자체를 볼 수 없다.** 따로 두고 대조해 경고를 내는 쪽을 택했다.
|
|
|
|
★ `client_secret` 은 비밀이다. 그래서 프론트가 토큰을 받아 오는 방식을 못 쓰고
|
|
**인가 코드를 서버가 교환**한다 — 시크릿을 브라우저에 둘 수 없기 때문이다."""
|
|
|
|
model_config = _BASE
|
|
|
|
rest_api_key: str = Field("", validation_alias="KAKAO_LOGIN_REST_API_KEY")
|
|
client_secret: str = Field("", validation_alias="KAKAO_LOGIN_CLIENT_SECRET")
|
|
# ★ 콘솔에 등록한 값과 **글자 하나까지** 같아야 한다. 다르면 KOE006 이고 화면에는 그냥
|
|
# "로그인 실패" 로만 보인다. 프론트의 인가 요청과 서버의 교환이 같은 값을 써야 하므로
|
|
# compose 가 이 값을 VITE_KAKAO_REDIRECT_URI 로 흘려보낸다.
|
|
redirect_uri: str = Field("", validation_alias="KAKAO_LOGIN_REDIRECT_URI")
|
|
|
|
|
|
class ExternalApiConfig(BaseSettings):
|
|
"""키가 비면 그 어댑터만 비활성이다 — 부팅이 외부 계약에 묶이면 안 된다."""
|
|
|
|
model_config = _BASE
|
|
|
|
perplexity_api_key: str = Field("", validation_alias="PERPLEXITY_API_KEY")
|
|
# 동일 업소 검증 — 둘 다 있으면 카카오 우선.
|
|
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")
|
|
llm_provider: str = Field("openai", validation_alias="LLM_PROVIDER")
|
|
openai_api_key: str = Field("", validation_alias="OPENAI_API_KEY")
|
|
openai_text_model: str = Field("gpt-5.6-luna", validation_alias="OPENAI_TEXT_MODEL")
|
|
openai_vision_model: str = Field("gpt-5.6-luna", validation_alias="OPENAI_VISION_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 가 부를 수 있는 주소가 아니다.
|
|
suno_callback_url: str = Field("", validation_alias="SUNO_CALLBACK_URL")
|
|
# 발행 사이트 메타 키워드(keywords · 제목)를 받아 오는 사내 서비스(o2o-site-ontology).
|
|
site_ontology_url: str = Field("", validation_alias="SITE_ONTOLOGY_URL")
|
|
|
|
|
|
# .env 를 요청마다 다시 읽지 않는다.
|
|
@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()
|
|
|
|
|
|
@lru_cache
|
|
def get_kakao_login_config() -> KakaoLoginConfig:
|
|
return KakaoLoginConfig()
|