"""애플리케이션 설정 — 환경변수(.env)에서 로드. pydantic-settings 로 타입 검증. 누락 시 조용한 실패 없이 명확하게 동작. 시간 윈도우(투표 오픈/마감), 스케줄러 시각, 외부 연동 키를 모두 여기서 관리. """ from __future__ import annotations from functools import lru_cache from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", extra="ignore" ) # ── 데이터베이스 ───────────────────────────────────────── # 외부 DB 연결: DB_HOST 가 설정되면 아래 DB_* 항목으로 접속 URL 을 조합한다 # (비밀번호 특수문자는 SQLAlchemy 가 안전하게 인코딩). DB_HOST 미설정 시 # database_url(또는 sqlite 등 직접 지정값)을 그대로 사용. db_host: str = "" db_port: int = 5432 db_name: str = "triplepick" db_user: str = "triplepick" db_password: str = "triplepick" # 직접 지정용 폴백 (DB_HOST 미설정 시 사용). 로컬 sqlite 테스트 등. database_url: str = ( "postgresql+asyncpg://triplepick:triplepick@db:5432/triplepick" ) def sqlalchemy_url(self): # noqa: ANN201 """엔진 생성용 URL. DB_HOST 가 있으면 DB_* 로 조합(특수문자 안전).""" if self.db_host: from sqlalchemy import URL return URL.create( "postgresql+asyncpg", username=self.db_user, password=self.db_password, host=self.db_host, port=self.db_port, database=self.db_name, ) return self.database_url # ── 일반 ──────────────────────────────────────────────── timezone: str = "Asia/Seoul" # 모든 경기 시각의 기준 (KST) cors_origins: str = "*" # 콤마구분. nginx 프록시 사용 시 동일 출처라 보통 불필요. public_origin: str = "http://localhost:8080" # 공유 딥링크 베이스 # ── 투표 윈도우 (도메인 규칙) ───────────────────────────── # 오픈 = 킥오프 - VOTE_OPEN_HOURS_BEFORE (D-7 = 168h) vote_open_hours_before: int = 168 # 마감 = 킥오프 5분 전 (경기 시작 5분 전까지 투표). lock 오프셋(분). vote_lock_minutes_before: int = 5 # 데모: True 면 오픈 게이트 무시(항상 투표 가능). 운영 배포 시 False. demo_force_open: bool = True # ── 스케줄러 (별도 워커 프로세스 = "스케줄링 서버") ─────── # 경기 일정 동적 수집: 매일 KST 09:00 외부 소스 크롤링 → 경기·투표시간 갱신. schedule_sync_hour: int = 9 schedule_sync_minute: int = 0 # 소스: openfootball(키 불필요·기본) | football-data(토큰 필요) | fallback(하드코딩만) schedule_source: str = "openfootball" schedule_url: str = ( "https://raw.githubusercontent.com/openfootball/worldcup.json/master/2026/worldcup.json" ) schedule_group: str = "Group A" # (구) openfootball 단일 그룹 라벨 — 폴백용 # 전체 조별리그(12개조 72경기) 일정을 불러올지. 결과/스코어는 전 경기 표시. schedule_all_groups: bool = True # AI 예측·투표 대상 조(이 조만 예측 생성·투표 가능). 그 외는 일정/결과만 표시. featured_group: str = "A" football_data_token: str = "" # football-data 사용 시 토큰 football_data_competition: str = "WC" # 결과(스코어) 소스 — 일정 소스와 분리. 빈값이면 자동 결정(아래 effective). # openfootball 은 결과 미게시라, 자동 종료를 쓰려면 football-data 가 필요하다. result_source: str = "" @property def effective_result_source(self) -> str: # 명시값 우선. 없으면: 전체 조 모드는 football-data 기반이므로 결과도 동일 소스로 # 자동 정렬(openfootball 폴백 시 녹아웃·타 조 결과가 영영 안 들어오는 함정 방지). if self.result_source: return self.result_source.lower() if self.schedule_all_groups: return "football-data" return self.schedule_source.lower() # ── 멀티리그 (wc=월드컵 축구 · kbo · mlb) ──────────────── # 활성 리그 (콤마구분). 야구 리그는 워커가 각자 소스에서 일정·결과를 동기화. leagues: str = "wc,kbo,mlb" # 야구 일정 수집 윈도우 — 오늘 기준 미래 며칠치. # (투표 오픈·AI 예측 생성은 리그 공통: vote_open_hours_before / ai_generate_lookahead_hours) baseball_days_ahead: int = 7 # 네이버 스포츠 비공식 API (KBO 일정·결과·프리뷰·문자중계) — 비공식, 로컬용. naver_api_base: str = "https://api-gw.sports.naver.com" # MLB 공식 Stats API (키 불필요). mlb_api_base: str = "https://statsapi.mlb.com/api" @property def league_list(self) -> list[str]: return [x.strip() for x in self.leagues.split(",") if x.strip()] # AI 예측 생성 전체 스위치 — False 면 워커가 AI 호출을 전혀 하지 않음(로컬 테스트). ai_enabled: bool = True # 매일 AI 예측 생성 시각 (KST). 기능정의서: 매일 0시 1회 생성. ai_generate_hour: int = 0 ai_generate_minute: int = 5 # 투표 오픈(D-2) 선행 생성 시간(h). 1일 1회 생성이므로 다음 주기 전 오픈할 # 경기를 미리 채우려면 ~24h 이상 필요. 30h = 하루치 + 여유. (only_missing 이라 총 호출 수 동일) # 킥오프 N시간 전부터 생성 — 야구 선발투수 예고(경기 전날 저녁) 이후 시점. ai_generate_lookahead_hours: int = 22 # ── 결과 자동 정산(관리자 입력 불필요) ─────────────────── # 킥오프 + 이 시간(시) 경과 후, 외부 소스에서 스코어를 자동 수집해 채점·집계·메일. result_settle_hours: int = 3 # 정산/메일 점검 주기(초). status_tick 와 함께 도는 별도 폴링. settle_tick_seconds: int = 300 # 종료 경기 결과 재확인 기간(일). 최근 이 기간 내 종료 경기는 매 틱 외부 소스와 # 대조해, 소스가 스코어를 정정(예: 잠정값→확정값)하면 자동 갱신·재채점한다. # 0이면 재확인 비활성(첫 정산값 고정). 오래된 경기는 대상에서 빠져 부하 bounded. result_recheck_days: int = 3 # 결과 메일을 '맞춘 사람(승패 적중)'에게만 보낼지 여부. False면 구독자 전체. result_email_correct_only: bool = True # 결과 메일: 결과 확정 후 추가 지연(분). 자동정산은 이미 킥오프+Nh라 기본 0. result_email_delay_minutes: int = 0 # 상태 전이 틱 주기(초): scheduled→open→locked 자동 갱신. status_tick_seconds: int = 60 # ── 관리자 ────────────────────────────────────────────── admin_api_token: str = "change-me-admin-token" # ── 외부 연동: AI 3모델 (실연동) ───────────────────────── openai_api_key: str = "" openai_model: str = "gpt-4o" anthropic_api_key: str = "" anthropic_model: str = "claude-opus-4-8" google_api_key: str = "" google_model: str = "gemini-2.5-flash" # ── 외부 연동: 축구 데이터 (API-Football 무료 티어) ─────── # 키 미설정 시 데이터 수집/주입 전부 no-op → 기존(이름만) 예측으로 폴백. football_api_key: str = "" football_api_base: str = "https://v3.football.api-sports.io" # 팀당 1회만 수집(fetch-once): 과거 시즌 폼·스쿼드·H2H 는 변하지 않고, 2026 # 진행 결과(승패·스코어)는 build 시 우리 DB 에서 라이브로 읽는다. 한 번 캐시되면 # 다시 받지 않으므로, 전 팀이 채워지면 이후 잡은 호출 0(사실상 수집 자동 종료). # 하루 호출 예산(무료 100/일 보호). 팀당 ~3콜이라 90이면 하루 ~30팀 → # 48팀이 약 2일에 채워짐. 예산 소진 시 남은 팀은 다음 날 잡이 이어서 누적 수집. football_daily_call_budget: int = 90 # API 호출 간 최소 간격(초) — 무료 10req/분 제한 회피. football_call_interval_sec: float = 7.0 # 수집 잡 시각(KST) — 예측 생성(00:05)보다 앞서 캐시를 채워둔다. football_refresh_hour: int = 0 football_refresh_minute: int = 0 # ── 외부 연동: 이메일 ───────────────────────────────────── # 1순위: Azure Communication Services(ACS) Email — endpoint + accesskey. # AZURE_ACS_SENDER 는 검증된 MailFrom 주소(예: donotreply@triplepick.o2o.kr). azure_acs_endpoint: str = "" azure_acs_accesskey: str = "" azure_acs_sender: str = "" # 2순위(폴백): SMTP — ACS 미설정 시 사용. smtp_host: str = "" smtp_port: int = 587 smtp_user: str = "" smtp_password: str = "" smtp_from: str = "TriplePick " smtp_starttls: bool = True @property def acs_configured(self) -> bool: return bool( self.azure_acs_endpoint and self.azure_acs_accesskey and self.azure_acs_sender ) @property def cors_origin_list(self) -> list[str]: if self.cors_origins.strip() == "*": return ["*"] return [o.strip() for o in self.cors_origins.split(",") if o.strip()] @lru_cache def get_settings() -> Settings: return Settings() settings = get_settings()