""" 系统配置 支持两种配置来源: 1. 环境变量 / .env 文件(传统方式,向后兼容) 2. 数据库 tenant_configs 表(新方式,支持热更新) 配置优先级:数据库 > 环境变量 > 默认值 """ import os import json from functools import lru_cache from typing import Optional, Any from pydantic import Field, field_validator from pydantic_settings import BaseSettings class Settings(BaseSettings): """系统配置""" # 应用基础配置 APP_NAME: str = "KaoPeiLian" APP_VERSION: str = "1.0.0" # DEBUG 模式:生产环境必须设置为 False # 通过环境变量 DEBUG=false 或在 .env 文件中设置 DEBUG: bool = Field(default=False, description="调试模式,生产环境必须设置为 False") # 租户配置(用于多租户部署) TENANT_CODE: str = Field(default="demo", description="租户编码,如 hua, yy, hl") # 服务器配置 HOST: str = Field(default="0.0.0.0") PORT: int = Field(default=8000) # 数据库配置 DATABASE_URL: Optional[str] = Field(default=None) MYSQL_HOST: str = Field(default="localhost") MYSQL_PORT: int = Field(default=3306) MYSQL_USER: str = Field(default="root") MYSQL_PASSWORD: str = Field(default="password") MYSQL_DATABASE: str = Field(default="kaopeilian") @property def database_url(self) -> str: """构建数据库连接URL""" if self.DATABASE_URL: return self.DATABASE_URL # 使用urllib.parse.quote_plus来正确编码特殊字符 import urllib.parse password = urllib.parse.quote_plus(self.MYSQL_PASSWORD) return f"mysql+aiomysql://{self.MYSQL_USER}:{password}@{self.MYSQL_HOST}:{self.MYSQL_PORT}/{self.MYSQL_DATABASE}?charset=utf8mb4" # Redis配置 REDIS_URL: str = Field(default="redis://localhost:6379/0") # JWT配置 # 安全警告:必须在生产环境设置 SECRET_KEY 环境变量 # 可以使用命令生成:python -c "import secrets; print(secrets.token_urlsafe(32))" SECRET_KEY: str = Field( default="INSECURE-DEFAULT-KEY-CHANGE-IN-PRODUCTION", description="JWT 密钥,生产环境必须通过环境变量设置安全的随机密钥" ) ALGORITHM: str = Field(default="HS256") ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=30) REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7) # 跨域配置 CORS_ORIGINS: list[str] = Field( default=[ "http://localhost:3000", "http://localhost:3001", "http://localhost:5173", "http://127.0.0.1:3000", "http://127.0.0.1:3001", "http://127.0.0.1:5173", ] ) @field_validator('CORS_ORIGINS', mode='before') @classmethod def parse_cors_origins(cls, v): """解析 CORS_ORIGINS 环境变量(支持 JSON 格式字符串)""" if isinstance(v, str): try: return json.loads(v) except json.JSONDecodeError: # 如果不是 JSON 格式,尝试按逗号分割 return [origin.strip() for origin in v.split(',')] return v # 日志配置 LOG_LEVEL: str = Field(default="INFO") LOG_FORMAT: str = Field(default="text") # text 或 json LOG_DIR: str = Field(default="logs") # 上传配置 UPLOAD_DIR: str = Field(default="uploads") MAX_UPLOAD_SIZE: int = Field(default=15 * 1024 * 1024) # 15MB @property def UPLOAD_PATH(self) -> str: """获取上传文件的完整路径""" import os return os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), self.UPLOAD_DIR) # Coze 平台配置(陪练对话、播课等) COZE_API_BASE: Optional[str] = Field(default="https://api.coze.cn") COZE_WORKSPACE_ID: Optional[str] = Field(default=None) COZE_API_TOKEN: Optional[str] = Field(default="pat_Sa5OiuUl0gDflnKstQTToIz0sSMshBV06diX0owOeuI1ZK1xDLH5YZH9fSeuKLIi") COZE_TRAINING_BOT_ID: Optional[str] = Field(default=None) COZE_CHAT_BOT_ID: Optional[str] = Field(default=None) COZE_PRACTICE_BOT_ID: Optional[str] = Field(default="7560643598174683145") # 陪练专用Bot ID # 播课工作流配置(多租户需在环境变量中覆盖,参见:应用配置清单.md) COZE_BROADCAST_WORKFLOW_ID: str = Field(default="7577983042284486666") # 默认:演示版播课工作流 COZE_BROADCAST_SPACE_ID: str = Field(default="7474971491470688296") # 播课工作流空间ID COZE_BROADCAST_BOT_ID: Optional[str] = Field(default=None) # 播课工作流专用Bot ID # OAuth配置(可选) COZE_OAUTH_CLIENT_ID: Optional[str] = Field(default=None) COZE_OAUTH_PUBLIC_KEY_ID: Optional[str] = Field(default=None) COZE_OAUTH_PRIVATE_KEY_PATH: Optional[str] = Field(default=None) # WebSocket语音配置 COZE_WS_BASE_URL: str = Field(default="wss://ws.coze.cn") COZE_AUDIO_FORMAT: str = Field(default="pcm") # 音频格式 COZE_SAMPLE_RATE: int = Field(default=16000) # 采样率(Hz) COZE_AUDIO_CHANNELS: int = Field(default=1) # 声道数(单声道) COZE_AUDIO_BIT_DEPTH: int = Field(default=16) # 位深度 # 服务器公开访问域名 PUBLIC_DOMAIN: str = Field(default="http://aiedu.ireborn.com.cn") # 言迹智能工牌API配置 YANJI_API_BASE: str = Field(default="https://open.yanjiai.com") # 正式环境 YANJI_CLIENT_ID: str = Field(default="1Fld4LCWt2vpJNG5") YANJI_CLIENT_SECRET: str = Field(default="XE8w413qNtJBOdWc2aCezV0yMIHpUuTZ") YANJI_TENANT_ID: str = Field(default="516799409476866048") YANJI_ESTATE_ID: str = Field(default="516799468310364162") # SCRM 系统对接 API Key(用于内部服务间调用) SCRM_API_KEY: str = Field(default="scrm-kpl-api-key-2026-ruixiaomei") # AI 服务配置(知识点分析 V2 使用) # 首选服务商:4sapi.com(国内优化) AI_PRIMARY_API_KEY: str = Field(default="sk-9yMCXjRGANbacz20kJY8doSNy6Rf446aYwmgGIuIXQ7DAyBw") # 测试阶段 Key AI_PRIMARY_BASE_URL: str = Field(default="https://4sapi.com/v1") # 备选服务商:OpenRouter(模型全,稳定性好) AI_FALLBACK_API_KEY: str = Field(default="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") # 测试阶段 Key AI_FALLBACK_BASE_URL: str = Field(default="https://openrouter.ai/api/v1") # 默认模型 AI_DEFAULT_MODEL: str = Field(default="gemini-3-flash-preview") # 请求超时(秒) AI_TIMEOUT: float = Field(default=120.0) model_config = { "env_file": ".env", "env_file_encoding": "utf-8", "case_sensitive": True, "extra": "allow", # 允许额外的环境变量 } @lru_cache() def get_settings() -> Settings: """获取系统配置(缓存)""" return Settings() settings = get_settings() def check_security_settings() -> list[str]: """ 检查安全配置 返回安全警告列表,生产环境应确保列表为空 """ warnings = [] # 检查 DEBUG 模式 if settings.DEBUG: warnings.append( "⚠️ DEBUG 模式已开启。生产环境请设置 DEBUG=false" ) # 检查 SECRET_KEY if settings.SECRET_KEY == "INSECURE-DEFAULT-KEY-CHANGE-IN-PRODUCTION": warnings.append( "⚠️ 使用默认 SECRET_KEY 不安全。生产环境请设置安全的 SECRET_KEY 环境变量。" "生成命令:python -c \"import secrets; print(secrets.token_urlsafe(32))\"" ) elif len(settings.SECRET_KEY) < 32: warnings.append( "⚠️ SECRET_KEY 长度不足 32 字符,安全性较弱" ) # 检查数据库密码 if settings.MYSQL_PASSWORD in ["password", "123456", "root", ""]: warnings.append( "⚠️ 数据库密码不安全,请使用强密码" ) return warnings def print_security_warnings(): """打印安全警告(应用启动时调用)""" import logging logger = logging.getLogger(__name__) warnings = check_security_settings() if warnings: logger.warning("=" * 60) logger.warning("安全配置警告:") for warning in warnings: logger.warning(warning) logger.warning("=" * 60) else: logger.info("✅ 安全配置检查通过") # ============================================ # 动态配置获取(支持从数据库读取) # ============================================ class DynamicConfig: """ 动态配置管理器 用于在运行时从数据库获取配置,支持热更新。 向后兼容:如果数据库不可用,回退到环境变量配置。 """ _tenant_loader = None _initialized = False @classmethod async def init(cls, redis_url: Optional[str] = None): """ 初始化动态配置管理器 Args: redis_url: Redis URL(可选,用于缓存) """ if cls._initialized: return try: from app.core.tenant_config import TenantConfigManager if redis_url: await TenantConfigManager.init_redis(redis_url) cls._initialized = True except Exception as e: import logging logging.getLogger(__name__).warning(f"动态配置初始化失败: {e}") @classmethod async def get(cls, key: str, default: Any = None, tenant_code: Optional[str] = None) -> Any: """ 获取配置值 Args: key: 配置键(如 AI_PRIMARY_API_KEY) default: 默认值 tenant_code: 租户编码(可选,默认使用环境变量中的 TENANT_CODE) Returns: 配置值 """ # 确定租户编码 if tenant_code is None: tenant_code = settings.TENANT_CODE # 配置键到分组的映射 config_mapping = { # 数据库 "MYSQL_HOST": ("database", "MYSQL_HOST"), "MYSQL_PORT": ("database", "MYSQL_PORT"), "MYSQL_USER": ("database", "MYSQL_USER"), "MYSQL_PASSWORD": ("database", "MYSQL_PASSWORD"), "MYSQL_DATABASE": ("database", "MYSQL_DATABASE"), # Redis "REDIS_HOST": ("redis", "REDIS_HOST"), "REDIS_PORT": ("redis", "REDIS_PORT"), "REDIS_DB": ("redis", "REDIS_DB"), # 安全 "SECRET_KEY": ("security", "SECRET_KEY"), "CORS_ORIGINS": ("security", "CORS_ORIGINS"), # Coze "COZE_PRACTICE_BOT_ID": ("coze", "COZE_PRACTICE_BOT_ID"), "COZE_BROADCAST_WORKFLOW_ID": ("coze", "COZE_BROADCAST_WORKFLOW_ID"), "COZE_BROADCAST_SPACE_ID": ("coze", "COZE_BROADCAST_SPACE_ID"), "COZE_OAUTH_CLIENT_ID": ("coze", "COZE_OAUTH_CLIENT_ID"), "COZE_OAUTH_PUBLIC_KEY_ID": ("coze", "COZE_OAUTH_PUBLIC_KEY_ID"), # AI "AI_PRIMARY_API_KEY": ("ai", "AI_PRIMARY_API_KEY"), "AI_PRIMARY_BASE_URL": ("ai", "AI_PRIMARY_BASE_URL"), "AI_FALLBACK_API_KEY": ("ai", "AI_FALLBACK_API_KEY"), "AI_FALLBACK_BASE_URL": ("ai", "AI_FALLBACK_BASE_URL"), "AI_DEFAULT_MODEL": ("ai", "AI_DEFAULT_MODEL"), "AI_TIMEOUT": ("ai", "AI_TIMEOUT"), # 言迹 "YANJI_CLIENT_ID": ("yanji", "YANJI_CLIENT_ID"), "YANJI_CLIENT_SECRET": ("yanji", "YANJI_CLIENT_SECRET"), "YANJI_TENANT_ID": ("yanji", "YANJI_TENANT_ID"), "YANJI_ESTATE_ID": ("yanji", "YANJI_ESTATE_ID"), } # 尝试从数据库获取 if cls._initialized and key in config_mapping: try: from app.core.tenant_config import TenantConfigManager config_group, config_key = config_mapping[key] loader = TenantConfigManager.get_loader(tenant_code) value = await loader.get_config(config_group, config_key) if value is not None: return value except Exception: pass # 回退到环境变量 / Settings env_value = getattr(settings, key, None) if env_value is not None: return env_value return default @classmethod async def is_feature_enabled(cls, feature_code: str, tenant_code: Optional[str] = None) -> bool: """ 检查功能是否启用 Args: feature_code: 功能编码 tenant_code: 租户编码 Returns: 是否启用 """ if tenant_code is None: tenant_code = settings.TENANT_CODE if cls._initialized: try: from app.core.tenant_config import TenantConfigManager loader = TenantConfigManager.get_loader(tenant_code) return await loader.is_feature_enabled(feature_code) except Exception: pass return True # 默认启用 @classmethod async def refresh_cache(cls, tenant_code: Optional[str] = None): """ 刷新配置缓存 Args: tenant_code: 租户编码(为空则刷新所有) """ if not cls._initialized: return try: from app.core.tenant_config import TenantConfigManager if tenant_code: await TenantConfigManager.refresh_tenant_cache(tenant_code) else: await TenantConfigManager.refresh_all_cache() except Exception: pass