""" Bloom Ware 統一配置管理中心 所有環境變數與敏感資訊的單一真理來源(Single Source of Truth) """ import os import json import base64 from typing import Optional, Dict, Any from dotenv import load_dotenv # 載入 .env 檔案(僅開發環境需要,Render 會自動注入環境變數) load_dotenv() class Settings: """統一配置管理中心""" # ===== 環境檢測 ===== ENVIRONMENT: str = os.getenv("ENVIRONMENT", "development") IS_PRODUCTION: bool = ENVIRONMENT == "production" # ===== Firebase 配置 ===== FIREBASE_PROJECT_ID: str = os.getenv("FIREBASE_PROJECT_ID", "") # Firebase 憑證:支援三種方式 _firebase_creds_json: Optional[str] = os.getenv("FIREBASE_CREDENTIALS_JSON") _firebase_creds_base64: Optional[str] = os.getenv("FIREBASE_SERVICE_ACCOUNT_JSON_BASE64") _firebase_service_account_path: Optional[str] = os.getenv("FIREBASE_SERVICE_ACCOUNT_PATH") @classmethod def get_firebase_credentials(cls) -> Dict[str, Any]: """ 取得 Firebase 憑證 優先順序: 1. 環境變數 FIREBASE_CREDENTIALS_JSON(生產環境,JSON 字串) 2. 環境變數 FIREBASE_SERVICE_ACCOUNT_JSON_BASE64(base64 編碼的 JSON) 3. 檔案路徑 FIREBASE_SERVICE_ACCOUNT_PATH(開發環境) Returns: dict: Firebase Service Account 憑證字典 Raises: ValueError: 當所有方式都未設定時 """ # 方式 1: 直接 JSON 字串 if cls._firebase_creds_json: try: return json.loads(cls._firebase_creds_json) except json.JSONDecodeError as e: raise ValueError(f"FIREBASE_CREDENTIALS_JSON 格式錯誤: {e}") # 方式 2: Base64 編碼的 JSON elif cls._firebase_creds_base64: try: decoded_bytes = base64.b64decode(cls._firebase_creds_base64) decoded_str = decoded_bytes.decode('utf-8') return json.loads(decoded_str) except Exception as e: raise ValueError(f"FIREBASE_SERVICE_ACCOUNT_JSON_BASE64 解碼失敗: {e}") # 方式 3: 從檔案讀取 elif cls._firebase_service_account_path: try: with open(cls._firebase_service_account_path, 'r', encoding='utf-8') as f: return json.load(f) except FileNotFoundError: raise ValueError(f"Firebase 憑證檔案不存在: {cls._firebase_service_account_path}") except json.JSONDecodeError as e: raise ValueError(f"Firebase 憑證檔案格式錯誤: {e}") # 三種方式都沒設定 else: raise ValueError( "Firebase 憑證未設定!\n" "請設定以下其中一項:\n" "1. FIREBASE_CREDENTIALS_JSON(JSON 字串)\n" "2. FIREBASE_SERVICE_ACCOUNT_JSON_BASE64(base64 編碼)\n" "3. FIREBASE_SERVICE_ACCOUNT_PATH(檔案路徑)" ) # ===== OpenAI 配置 ===== OPENAI_API_KEY: str = os.getenv("OPENAI_API_KEY", "") OPENAI_MODEL: str = os.getenv("OPENAI_MODEL", "gpt-5-nano") OPENAI_TIMEOUT: int = int(os.getenv("OPENAI_TIMEOUT", "30")) # ===== Google OAuth 配置 ===== GOOGLE_CLIENT_ID: str = os.getenv("GOOGLE_CLIENT_ID", "") GOOGLE_CLIENT_SECRET: str = os.getenv("GOOGLE_CLIENT_SECRET", "") GOOGLE_REDIRECT_URI: str = os.getenv( "GOOGLE_REDIRECT_URI", "http://localhost:8080/auth/google/callback" # 開發環境預設值 ) # ===== 第三方 API Keys ===== WEATHER_API_KEY: str = os.getenv("WEATHER_API_KEY", "") NEWSDATA_API_KEY: str = os.getenv("NEWSDATA_API_KEY", "") EXCHANGE_API_KEY: str = os.getenv("EXCHANGE_API_KEY", "") # ===== JWT 認證配置 ===== JWT_SECRET_KEY: str = os.getenv("JWT_SECRET_KEY", "") ACCESS_TOKEN_EXPIRE_MINUTES: int = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "30")) # ===== 伺服器配置 ===== HOST: str = os.getenv("HOST", "0.0.0.0") PORT: int = int(os.getenv("PORT", "8080")) # Render 會自動設為 10000 # ===== GPT 意圖檢測配置 ===== USE_GPT_INTENT: bool = os.getenv("USE_GPT_INTENT", "true").lower() == "true" GPT_INTENT_MODEL: str = os.getenv("GPT_INTENT_MODEL", "gpt-5-nano") # ===== 背景任務開關 ===== ENABLE_BACKGROUND_JOBS: bool = os.getenv("ENABLE_BACKGROUND_JOBS", "true").lower() == "true" # ===== 環境感知參數 ===== ENV_CONTEXT_DISTANCE_THRESHOLD: float = float(os.getenv("ENV_CONTEXT_DISTANCE_THRESHOLD", "100")) ENV_CONTEXT_HEADING_THRESHOLD: float = float(os.getenv("ENV_CONTEXT_HEADING_THRESHOLD", "25")) ENV_CONTEXT_TTL_SECONDS: float = float(os.getenv("ENV_CONTEXT_TTL_SECONDS", "300")) # ===== CORS 安全設定 ===== # 生產環境應設定具體的允許來源,多個來源用逗號分隔 # 例如:CORS_ORIGINS=https://example.com,https://app.example.com _cors_origins_raw: str = os.getenv("CORS_ORIGINS", "*") @classmethod def get_cors_origins(cls) -> list: """取得 CORS 允許的來源列表""" if cls._cors_origins_raw == "*": return ["*"] return [origin.strip() for origin in cls._cors_origins_raw.split(",") if origin.strip()] # ===== 安全性設定 ===== # 登入失敗封鎖閾值 FAILED_LOGIN_THRESHOLD: int = int(os.getenv("FAILED_LOGIN_THRESHOLD", "5")) # 封鎖時間(秒) LOGIN_BLOCK_DURATION: int = int(os.getenv("LOGIN_BLOCK_DURATION", "900")) # 15 分鐘 # JWT Secret 最小長度 JWT_SECRET_MIN_LENGTH: int = 32 # ===== 效能調優常數 ===== # WebSocket 會話超時(秒) WEBSOCKET_SESSION_TIMEOUT: int = int(os.getenv("WEBSOCKET_SESSION_TIMEOUT", "1800")) # 30 分鐘 # 定期清理間隔(秒) CLEANUP_INTERVAL: int = int(os.getenv("CLEANUP_INTERVAL", "1800")) # 30 分鐘 # 記憶重要性閾值 MEMORY_IMPORTANCE_THRESHOLD: float = float(os.getenv("MEMORY_IMPORTANCE_THRESHOLD", "0.6")) # 意圖快取 TTL(秒) INTENT_CACHE_TTL: int = int(os.getenv("INTENT_CACHE_TTL", "300")) # 5 分鐘 # 對話歷史載入限制 CHAT_HISTORY_LIMIT: int = int(os.getenv("CHAT_HISTORY_LIMIT", "12")) # 關懷模式對話歷史限制 CARE_MODE_HISTORY_LIMIT: int = int(os.getenv("CARE_MODE_HISTORY_LIMIT", "3")) @classmethod def validate(cls) -> bool: """ 驗證必要配置是否已設定 Returns: bool: 所有必要配置是否完整 """ required_fields = [ ("FIREBASE_PROJECT_ID", cls.FIREBASE_PROJECT_ID), ("OPENAI_API_KEY", cls.OPENAI_API_KEY), ("GOOGLE_CLIENT_ID", cls.GOOGLE_CLIENT_ID), ("GOOGLE_CLIENT_SECRET", cls.GOOGLE_CLIENT_SECRET), ("JWT_SECRET_KEY", cls.JWT_SECRET_KEY), ] missing_fields = [name for name, value in required_fields if not value] if missing_fields: import logging logger = logging.getLogger("core.config") logger.error(f"⚠️ 缺少必要環境變數: {', '.join(missing_fields)}") logger.error("請檢查以下選項:") logger.error("1. 環境變數是否正確設定") logger.error("2. .env 檔案是否存在且格式正確") logger.error("3. 生產環境中是否在部署平台設定了環境變數") return False # 驗證 Firebase 憑證 try: cls.get_firebase_credentials() except ValueError as e: import logging logger = logging.getLogger("core.config") logger.error(f"⚠️ Firebase 憑證驗證失敗: {e}") logger.error("請檢查 FIREBASE_CREDENTIALS_JSON 或 FIREBASE_SERVICE_ACCOUNT_PATH") return False # 驗證 OpenAI API Key 格式(基本檢查) if not cls.OPENAI_API_KEY.startswith("sk-"): import logging logger = logging.getLogger("core.config") logger.warning("⚠️ OpenAI API Key 格式可能不正確(應以 'sk-' 開頭)") # 驗證 JWT Secret 長度(強制檢查) if len(cls.JWT_SECRET_KEY) < cls.JWT_SECRET_MIN_LENGTH: import logging logger = logging.getLogger("core.config") logger.error(f"❌ JWT Secret Key 長度必須至少 {cls.JWT_SECRET_MIN_LENGTH} 個字符") if cls.IS_PRODUCTION: return False logger.warning("⚠️ 開發環境允許繼續,但生產環境將拒絕啟動") # 生產環境 CORS 檢查 if cls.IS_PRODUCTION and cls._cors_origins_raw == "*": import logging logger = logging.getLogger("core.config") logger.warning("⚠️ 生產環境建議設定具體的 CORS_ORIGINS,而非 '*'") return True @classmethod def print_summary(cls) -> None: """列印當前配置摘要(隱藏敏感資訊)""" import logging logger = logging.getLogger("core.config") logger.info("\n" + "=" * 60) logger.info("📋 Bloom Ware 配置摘要") logger.info("=" * 60) logger.info(f"環境模式: {cls.ENVIRONMENT}") logger.info(f"是否為生產環境: {cls.IS_PRODUCTION}") logger.info(f"Firebase 專案 ID: {cls.FIREBASE_PROJECT_ID}") # 判斷 Firebase 憑證來源 if cls._firebase_creds_json: firebase_source = "環境變數 (JSON)" elif cls._firebase_creds_base64: firebase_source = "環境變數 (Base64)" elif cls._firebase_service_account_path: firebase_source = "檔案" else: firebase_source = "未設定 ❌" logger.info(f"Firebase 憑證來源: {firebase_source}") logger.info(f"OpenAI 模型: {cls.OPENAI_MODEL}") logger.info(f"OpenAI Timeout: {cls.OPENAI_TIMEOUT}s") logger.info(f"Google OAuth 回調 URI: {cls.GOOGLE_REDIRECT_URI}") logger.info(f"JWT Token 有效期: {cls.ACCESS_TOKEN_EXPIRE_MINUTES} 分鐘") logger.info(f"伺服器監聽: {cls.HOST}:{cls.PORT}") logger.info(f"使用 GPT 意圖檢測: {cls.USE_GPT_INTENT}") logger.info(f"Weather API Key: {'已設定 ✅' if cls.WEATHER_API_KEY else '未設定 ❌'}") logger.info(f"NewsData API Key: {'已設定 ✅' if cls.NEWSDATA_API_KEY else '未設定 ❌'}") logger.info(f"Exchange API Key: {'已設定 ✅' if cls.EXCHANGE_API_KEY else '未設定 ❌'}") logger.info(f"環境節流距離: {cls.ENV_CONTEXT_DISTANCE_THRESHOLD} m") logger.info(f"環境節流方位差: {cls.ENV_CONTEXT_HEADING_THRESHOLD}°") logger.info(f"環境快取 TTL: {cls.ENV_CONTEXT_TTL_SECONDS} 秒") logger.info("=" * 60 + "\n") # 建立全域設定實例(單例模式) settings = Settings() # 啟動時驗證配置(僅在非測試環境) if __name__ != "__main__": import logging logger = logging.getLogger("core.config") if not settings.validate(): logger.warning("⚠️ 配置驗證失敗,部分功能可能無法正常運作") # 開發環境下列印配置摘要 if not settings.IS_PRODUCTION and os.getenv("BLOOMWARE_SHOW_CONFIG", "false").lower() == "true": settings.print_summary()