""" 统一配置管理系统 优先级规则: 1. 安全配置:仅环境变量(ADMIN_KEY, SESSION_SECRET_KEY) 2. 业务配置:数据库 > 默认值 配置分类: - 安全配置:仅从环境变量读取,不可热更新(ADMIN_KEY, SESSION_SECRET_KEY) - 业务配置:仅从数据库读取,支持热更新(API_KEY, BASE_URL, PROXY, 重试策略等) """ import os import shutil import yaml import secrets from pathlib import Path from typing import Optional, List from pydantic import BaseModel, Field, validator from dotenv import load_dotenv from core import storage # 加载 .env 文件 load_dotenv() def _parse_bool(value, default: bool) -> bool: if isinstance(value, bool): return value if value is None: return default if isinstance(value, (int, float)): return value != 0 if isinstance(value, str): lowered = value.strip().lower() if lowered in ("1", "true", "yes", "y", "on"): return True if lowered in ("0", "false", "no", "n", "off"): return False return default # ==================== 配置模型定义 ==================== class BasicConfig(BaseModel): """基础配置""" api_key: str = Field(default="", description="API访问密钥(留空则公开访问,多个密钥用逗号分隔)") base_url: str = Field(default="", description="服务器URL(留空则自动检测)") proxy_for_auth: str = Field(default="", description="账户操作代理地址(注册/登录/刷新,留空则不使用代理)") proxy_for_chat: str = Field(default="", description="对话操作代理地址(JWT/会话/消息,留空则不使用代理)") duckmail_base_url: str = Field(default="https://api.duckmail.sbs", description="DuckMail API地址") duckmail_api_key: str = Field(default="", description="DuckMail API key") duckmail_verify_ssl: bool = Field(default=True, description="DuckMail SSL校验") temp_mail_provider: str = Field(default="duckmail", description="临时邮箱提供商: duckmail/moemail/freemail/gptmail/cfmail") moemail_base_url: str = Field(default="https://moemail.app", description="Moemail API地址") moemail_api_key: str = Field(default="", description="Moemail API key") moemail_domain: str = Field(default="", description="Moemail 邮箱域名(可选,留空则随机选择)") freemail_base_url: str = Field(default="http://your-freemail-server.com", description="Freemail API地址") freemail_jwt_token: str = Field(default="", description="Freemail JWT Token") freemail_verify_ssl: bool = Field(default=True, description="Freemail SSL校验") freemail_domain: str = Field(default="", description="Freemail 邮箱域名(可选,留空则随机选择)") mail_proxy_enabled: bool = Field(default=False, description="是否启用临时邮箱代理(使用账户操作代理)") gptmail_base_url: str = Field(default="https://mail.chatgpt.org.uk", description="GPTMail API地址") gptmail_api_key: str = Field(default="gpt-test", description="GPTMail API key") gptmail_verify_ssl: bool = Field(default=True, description="GPTMail SSL校验") gptmail_domain: str = Field(default="", description="GPTMail 邮箱域名(可选,留空则随机选择)") cfmail_base_url: str = Field(default="", description="Cloudflare Mail API地址") cfmail_api_key: str = Field(default="", description="Cloudflare Mail 访问密码(x-custom-auth)") cfmail_verify_ssl: bool = Field(default=True, description="Cloudflare Mail SSL校验") cfmail_domain: str = Field(default="", description="Cloudflare Mail 邮箱域名(可选,留空随机)") browser_engine: str = Field(default="dp", description="浏览器引擎") browser_headless: bool = Field(default=False, description="自动化浏览器无头模式") refresh_window_hours: int = Field(default=1, ge=0, le=24, description="过期刷新窗口(小时)") register_default_count: int = Field(default=1, ge=1, description="默认注册数量") register_domain: str = Field(default="", description="DuckMail 域名(推荐)") image_expire_hours: int = Field(default=12, ge=-1, le=720, description="图片/视频过期时间(小时),-1为永不删除") class ImageGenerationConfig(BaseModel): """图片生成配置""" enabled: bool = Field(default=False, description="是否启用图片生成") supported_models: List[str] = Field( default=[], description="支持图片生成的模型列表" ) output_format: str = Field(default="base64", description="图片输出格式:base64 或 url") class VideoGenerationConfig(BaseModel): """视频生成配置""" output_format: str = Field(default="html", description="视频输出格式:html/url/markdown") @validator("output_format") def validate_output_format(cls, v): allowed = ["html", "url", "markdown"] if v not in allowed: raise ValueError(f"output_format 必须是 {allowed} 之一") return v class RetryConfig(BaseModel): """重试策略配置""" max_account_switch_tries: int = Field(default=5, ge=1, le=20, description="账户切换尝试次数") rate_limit_cooldown_seconds: int = Field(default=7200, ge=3600, le=43200, description="429冷却时间(秒)") text_rate_limit_cooldown_seconds: int = Field(default=7200, ge=3600, le=86400, description="对话配额冷却(秒)") images_rate_limit_cooldown_seconds: int = Field(default=14400, ge=3600, le=86400, description="绘图配额冷却(秒)") videos_rate_limit_cooldown_seconds: int = Field(default=14400, ge=3600, le=86400, description="视频配额冷却(秒)") session_cache_ttl_seconds: int = Field(default=3600, ge=0, le=86400, description="会话缓存时间(秒,0表示禁用缓存)") auto_refresh_accounts_seconds: int = Field(default=60, ge=0, le=600, description="自动刷新账号间隔(秒,0禁用)") # 定时刷新配置 scheduled_refresh_enabled: bool = Field(default=False, description="是否启用定时刷新任务") scheduled_refresh_cron: str = Field(default="08:00,20:00", description="刷新时间,如 '08:00,20:00' 或 '*/120'(每120分钟)") refresh_batch_size: int = Field(default=5, ge=1, le=20, description="每批刷新账号数") refresh_batch_interval_minutes: int = Field(default=30, ge=5, le=120, description="批次间等待时间(分钟)") refresh_cooldown_hours: float = Field(default=12.0, ge=1, le=48, description="同一账号刷新冷却期(小时)") # 向后兼容:旧配置可能只有这个字段,读取时自动转换为 */N cron 格式 scheduled_refresh_interval_minutes: int = Field(default=0, ge=0, le=720, description="(旧字段,已废弃) 定时刷新检测间隔") class QuotaLimitsConfig(BaseModel): """每日配额上限配置(基于 Google 官方限额,用于主动配额计数)""" enabled: bool = Field(default=True, description="是否启用主动配额计数") text_daily_limit: int = Field(default=120, ge=0, le=9999, description="对话每日上限(0=不限制)") images_daily_limit: int = Field(default=2, ge=0, le=9999, description="绘图每日上限(0=不限制)") videos_daily_limit: int = Field(default=1, ge=0, le=9999, description="视频每日上限(0=不限制)") class PublicDisplayConfig(BaseModel): """公开展示配置""" logo_url: str = Field(default="", description="Logo URL") chat_url: str = Field(default="", description="开始对话链接") class SessionConfig(BaseModel): """Session配置""" expire_hours: int = Field(default=24, ge=1, le=168, description="Session过期时间(小时)") class SecurityConfig(BaseModel): """安全配置(仅从环境变量读取,不可热更新)""" admin_key: str = Field(default="", description="管理员密钥(必需)") session_secret_key: str = Field(..., description="Session密钥") class AppConfig(BaseModel): """应用配置(统一管理)""" # 安全配置(仅从环境变量) security: SecurityConfig # 业务配置(环境变量 > 数据库 > 默认值) basic: BasicConfig image_generation: ImageGenerationConfig video_generation: VideoGenerationConfig = Field(default_factory=VideoGenerationConfig) retry: RetryConfig quota_limits: QuotaLimitsConfig = Field(default_factory=QuotaLimitsConfig) public_display: PublicDisplayConfig session: SessionConfig # ==================== 配置管理器 ==================== class ConfigManager: """配置管理器(单例)""" def __init__(self, yaml_path: str = None): # 自动检测环境并设置默认路径 if yaml_path is None: yaml_path = "" self.yaml_path = Path(yaml_path) self._config: Optional[AppConfig] = None self.load() def load(self): """ 加载配置 优先级规则: 1. 安全配置(ADMIN_KEY, SESSION_SECRET_KEY):仅从环境变量读取 2. 业务配置:数据库 > 默认值 """ # 1. 从数据库加载配置 yaml_data = self._load_yaml() # 2. 加载安全配置(仅从环境变量,不允许 Web 修改) security_config = SecurityConfig( admin_key=os.getenv("ADMIN_KEY", ""), session_secret_key=os.getenv("SESSION_SECRET_KEY", self._generate_secret()) ) # 3. 加载基础配置(数据库 > 默认值) basic_data = yaml_data.get("basic", {}) refresh_window_raw = basic_data.get("refresh_window_hours", 1) register_default_raw = basic_data.get("register_default_count", 1) register_domain_raw = basic_data.get("register_domain", "") duckmail_api_key_raw = basic_data.get("duckmail_api_key", "") # 兼容旧配置:如果存在旧的 proxy 字段,迁移到新字段 old_proxy = basic_data.get("proxy", "") old_proxy_for_auth_bool = basic_data.get("proxy_for_auth") old_proxy_for_chat_bool = basic_data.get("proxy_for_chat") # 新配置优先,如果没有新配置则从旧配置迁移 proxy_for_auth = basic_data.get("proxy_for_auth", "") proxy_for_chat = basic_data.get("proxy_for_chat", "") # 如果新配置为空且存在旧配置,则迁移 if not proxy_for_auth and old_proxy: # 如果旧配置中 proxy_for_auth 是布尔值且为 True,则使用旧的 proxy if isinstance(old_proxy_for_auth_bool, bool) and old_proxy_for_auth_bool: proxy_for_auth = old_proxy if not proxy_for_chat and old_proxy: # 如果旧配置中 proxy_for_chat 是布尔值且为 True,则使用旧的 proxy if isinstance(old_proxy_for_chat_bool, bool) and old_proxy_for_chat_bool: proxy_for_chat = old_proxy basic_config = BasicConfig( api_key=basic_data.get("api_key") or "", base_url=basic_data.get("base_url") or "", proxy_for_auth=str(proxy_for_auth or "").strip(), proxy_for_chat=str(proxy_for_chat or "").strip(), duckmail_base_url=basic_data.get("duckmail_base_url") or "https://api.duckmail.sbs", duckmail_api_key=str(duckmail_api_key_raw or "").strip(), duckmail_verify_ssl=_parse_bool(basic_data.get("duckmail_verify_ssl"), True), temp_mail_provider=basic_data.get("temp_mail_provider") or "moemail", moemail_base_url=basic_data.get("moemail_base_url") or "https://moemail.app", moemail_api_key=str(basic_data.get("moemail_api_key") or "").strip(), moemail_domain=str(basic_data.get("moemail_domain") or "").strip(), freemail_base_url=basic_data.get("freemail_base_url") or "http://your-freemail-server.com", freemail_jwt_token=str(basic_data.get("freemail_jwt_token") or "").strip(), freemail_verify_ssl=_parse_bool(basic_data.get("freemail_verify_ssl"), True), freemail_domain=str(basic_data.get("freemail_domain") or "").strip(), mail_proxy_enabled=_parse_bool(basic_data.get("mail_proxy_enabled"), False), gptmail_base_url=str(basic_data.get("gptmail_base_url") or "https://mail.chatgpt.org.uk").strip(), gptmail_api_key=str(basic_data.get("gptmail_api_key") or "gpt-test").strip(), gptmail_verify_ssl=_parse_bool(basic_data.get("gptmail_verify_ssl"), True), gptmail_domain=str(basic_data.get("gptmail_domain") or "").strip(), cfmail_base_url=str(basic_data.get("cfmail_base_url") or "").strip(), cfmail_api_key=str(basic_data.get("cfmail_api_key") or "").strip(), cfmail_verify_ssl=_parse_bool(basic_data.get("cfmail_verify_ssl"), True), cfmail_domain=str(basic_data.get("cfmail_domain") or "").strip(), browser_engine=basic_data.get("browser_engine") or "dp", browser_headless=_parse_bool(basic_data.get("browser_headless"), False), refresh_window_hours=int(refresh_window_raw), register_default_count=int(register_default_raw), register_domain=str(register_domain_raw or "").strip(), image_expire_hours=int(basic_data.get("image_expire_hours", 12)), ) # 4. 加载其他配置(从数据库,带容错处理) try: image_generation_config = ImageGenerationConfig( **yaml_data.get("image_generation", {}) ) except Exception as e: print(f"[WARN] 图片生成配置加载失败,使用默认值: {e}") image_generation_config = ImageGenerationConfig() # 加载视频生成配置 try: video_generation_config = VideoGenerationConfig( **yaml_data.get("video_generation", {}) ) except Exception as e: print(f"[WARN] 视频生成配置加载失败,使用默认值: {e}") video_generation_config = VideoGenerationConfig() # 加载重试配置(Pydantic 会自动验证范围) try: retry_config = RetryConfig(**yaml_data.get("retry", {})) except Exception as e: print(f"[WARN] 重试配置加载失败,使用默认值: {e}") retry_config = RetryConfig() # 加载配额上限配置 try: quota_limits_config = QuotaLimitsConfig(**yaml_data.get("quota_limits", {})) except Exception as e: print(f"[WARN] 配额上限配置加载失败,使用默认值: {e}") quota_limits_config = QuotaLimitsConfig() try: public_display_config = PublicDisplayConfig( **yaml_data.get("public_display", {}) ) except Exception as e: print(f"[WARN] 公开展示配置加载失败,使用默认值: {e}") public_display_config = PublicDisplayConfig() try: session_config = SessionConfig( **yaml_data.get("session", {}) ) except Exception as e: print(f"[WARN] Session配置加载失败,使用默认值: {e}") session_config = SessionConfig() # 5. 构建完整配置 self._config = AppConfig( security=security_config, basic=basic_config, image_generation=image_generation_config, video_generation=video_generation_config, retry=retry_config, quota_limits=quota_limits_config, public_display=public_display_config, session=session_config ) def _load_yaml(self) -> dict: """从数据库加载配置(允许空配置)。""" if storage.is_database_enabled(): try: data = storage.load_settings_sync() # 允许空库启动:None 可能是空配置或连接异常 if data is None: print("[WARN] 未读取到 settings(可能为空库或连接异常),将使用默认配置启动") return {} if isinstance(data, dict): return data return {} except RuntimeError: # 重新抛出 RuntimeError raise except Exception as e: print(f"[ERROR] 数据库加载失败: {e}") raise RuntimeError(f"数据库加载失败: {e}") print("[ERROR] 未启用数据库") raise RuntimeError("未配置 DATABASE_URL,应用无法启动") def _generate_secret(self) -> str: """生成随机密钥""" return secrets.token_urlsafe(32) def save_yaml(self, data: dict): """保存配置到数据库(先验证再保存)""" if not storage.is_database_enabled(): raise RuntimeError("Database is not enabled") # 先验证数据是否符合 Pydantic 模型要求 try: # 构建临时配置进行验证 security_config = SecurityConfig( admin_key=os.getenv("ADMIN_KEY", ""), session_secret_key=os.getenv("SESSION_SECRET_KEY", self._generate_secret()) ) basic_data = data.get("basic", {}) basic_config = BasicConfig(**basic_data) image_generation_config = ImageGenerationConfig( **data.get("image_generation", {}) ) video_generation_config = VideoGenerationConfig( **data.get("video_generation", {}) ) retry_config = RetryConfig(**data.get("retry", {})) quota_limits_config = QuotaLimitsConfig(**data.get("quota_limits", {})) public_display_config = PublicDisplayConfig( **data.get("public_display", {}) ) session_config = SessionConfig( **data.get("session", {}) ) # 验证通过,构建完整配置 test_config = AppConfig( security=security_config, basic=basic_config, image_generation=image_generation_config, video_generation=video_generation_config, retry=retry_config, quota_limits=quota_limits_config, public_display=public_display_config, session=session_config ) except Exception as e: # 验证失败,不保存到数据库 raise ValueError(f"配置验证失败: {str(e)}") # 验证通过后才保存到数据库 try: saved = storage.save_settings_sync(data) if saved: return except Exception as e: print(f"[WARN] 数据库保存失败: {e}") raise RuntimeError("Database write failed") def reload(self): """重新加载配置(热更新)""" self.load() @property def config(self) -> AppConfig: """获取配置""" return self._config # ==================== 便捷访问属性 ==================== @property def api_key(self) -> str: """API访问密钥""" return self._config.basic.api_key @property def admin_key(self) -> str: """管理员密钥""" return self._config.security.admin_key @property def session_secret_key(self) -> str: """Session密钥""" return self._config.security.session_secret_key @property def proxy_for_auth(self) -> str: """账户操作代理地址""" return self._config.basic.proxy_for_auth @property def proxy_for_chat(self) -> str: """对话操作代理地址""" return self._config.basic.proxy_for_chat @property def base_url(self) -> str: """服务器URL""" return self._config.basic.base_url @property def logo_url(self) -> str: """Logo URL""" return self._config.public_display.logo_url @property def chat_url(self) -> str: """开始对话链接""" return self._config.public_display.chat_url @property def image_generation_enabled(self) -> bool: """是否启用图片生成""" return self._config.image_generation.enabled @property def image_generation_models(self) -> List[str]: """支持图片生成的模型列表""" return self._config.image_generation.supported_models @property def image_output_format(self) -> str: """图片输出格式""" return self._config.image_generation.output_format @property def video_output_format(self) -> str: """视频输出格式""" return self._config.video_generation.output_format @property def session_expire_hours(self) -> int: """Session过期时间(小时)""" return self._config.session.expire_hours @property def max_account_switch_tries(self) -> int: """账户切换尝试次数""" return self._config.retry.max_account_switch_tries @property def rate_limit_cooldown_seconds(self) -> int: # 429 cooldown (seconds) if hasattr(self._config.retry, 'text_rate_limit_cooldown_seconds'): return self._config.retry.text_rate_limit_cooldown_seconds return self._config.retry.rate_limit_cooldown_seconds @property def text_rate_limit_cooldown_seconds(self) -> int: return self._config.retry.text_rate_limit_cooldown_seconds @property def images_rate_limit_cooldown_seconds(self) -> int: return self._config.retry.images_rate_limit_cooldown_seconds @property def videos_rate_limit_cooldown_seconds(self) -> int: return self._config.retry.videos_rate_limit_cooldown_seconds @property def session_cache_ttl_seconds(self) -> int: # Session cache TTL (seconds) return self._config.retry.session_cache_ttl_seconds @property def auto_refresh_accounts_seconds(self) -> int: # Auto refresh accounts interval (seconds) return self._config.retry.auto_refresh_accounts_seconds # ==================== 全局配置管理器 ==================== config_manager = ConfigManager() # 注意:不要直接引用 config_manager.config,因为 reload() 后引用会失效 # 应该始终通过 config_manager.config 访问配置 def get_config() -> AppConfig: """获取当前配置(支持热更新)""" return config_manager.config # 为了向后兼容,保留 config 变量,但使用属性访问 class _ConfigProxy: """配置代理,确保始终访问最新配置""" @property def basic(self): return config_manager.config.basic @property def security(self): return config_manager.config.security @property def image_generation(self): return config_manager.config.image_generation @property def video_generation(self): return config_manager.config.video_generation @property def retry(self): return config_manager.config.retry @property def quota_limits(self): return config_manager.config.quota_limits @property def public_display(self): return config_manager.config.public_display @property def session(self): return config_manager.config.session config = _ConfigProxy()