Spaces:
Sleeping
Sleeping
| """ | |
| 统一配置管理系统 | |
| 优先级规则: | |
| 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") | |
| 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() | |
| def config(self) -> AppConfig: | |
| """获取配置""" | |
| return self._config | |
| # ==================== 便捷访问属性 ==================== | |
| def api_key(self) -> str: | |
| """API访问密钥""" | |
| return self._config.basic.api_key | |
| def admin_key(self) -> str: | |
| """管理员密钥""" | |
| return self._config.security.admin_key | |
| def session_secret_key(self) -> str: | |
| """Session密钥""" | |
| return self._config.security.session_secret_key | |
| def proxy_for_auth(self) -> str: | |
| """账户操作代理地址""" | |
| return self._config.basic.proxy_for_auth | |
| def proxy_for_chat(self) -> str: | |
| """对话操作代理地址""" | |
| return self._config.basic.proxy_for_chat | |
| def base_url(self) -> str: | |
| """服务器URL""" | |
| return self._config.basic.base_url | |
| def logo_url(self) -> str: | |
| """Logo URL""" | |
| return self._config.public_display.logo_url | |
| def chat_url(self) -> str: | |
| """开始对话链接""" | |
| return self._config.public_display.chat_url | |
| def image_generation_enabled(self) -> bool: | |
| """是否启用图片生成""" | |
| return self._config.image_generation.enabled | |
| def image_generation_models(self) -> List[str]: | |
| """支持图片生成的模型列表""" | |
| return self._config.image_generation.supported_models | |
| def image_output_format(self) -> str: | |
| """图片输出格式""" | |
| return self._config.image_generation.output_format | |
| def video_output_format(self) -> str: | |
| """视频输出格式""" | |
| return self._config.video_generation.output_format | |
| def session_expire_hours(self) -> int: | |
| """Session过期时间(小时)""" | |
| return self._config.session.expire_hours | |
| def max_account_switch_tries(self) -> int: | |
| """账户切换尝试次数""" | |
| return self._config.retry.max_account_switch_tries | |
| 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 | |
| def text_rate_limit_cooldown_seconds(self) -> int: | |
| return self._config.retry.text_rate_limit_cooldown_seconds | |
| def images_rate_limit_cooldown_seconds(self) -> int: | |
| return self._config.retry.images_rate_limit_cooldown_seconds | |
| def videos_rate_limit_cooldown_seconds(self) -> int: | |
| return self._config.retry.videos_rate_limit_cooldown_seconds | |
| def session_cache_ttl_seconds(self) -> int: | |
| # Session cache TTL (seconds) | |
| return self._config.retry.session_cache_ttl_seconds | |
| 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: | |
| """配置代理,确保始终访问最新配置""" | |
| def basic(self): | |
| return config_manager.config.basic | |
| def security(self): | |
| return config_manager.config.security | |
| def image_generation(self): | |
| return config_manager.config.image_generation | |
| def video_generation(self): | |
| return config_manager.config.video_generation | |
| def retry(self): | |
| return config_manager.config.retry | |
| def quota_limits(self): | |
| return config_manager.config.quota_limits | |
| def public_display(self): | |
| return config_manager.config.public_display | |
| def session(self): | |
| return config_manager.config.session | |
| config = _ConfigProxy() | |