Spaces:
Paused
Paused
| from __future__ import annotations | |
| from dataclasses import dataclass | |
| import json | |
| import os | |
| import sys | |
| from pathlib import Path | |
| import time | |
| from services.storage.base import StorageBackend | |
| BASE_DIR = Path(__file__).resolve().parents[1] | |
| DATA_DIR = BASE_DIR / "data" | |
| CONFIG_FILE = BASE_DIR / "config.json" | |
| VERSION_FILE = BASE_DIR / "VERSION" | |
| BACKUP_STATE_FILE = DATA_DIR / "backup_state.json" | |
| DEFAULT_BACKUP_INCLUDE = { | |
| "config": True, | |
| "register": True, | |
| "cpa": True, | |
| "sub2api": True, | |
| "logs": True, | |
| "image_tasks": True, | |
| "accounts_snapshot": True, | |
| "auth_keys_snapshot": True, | |
| "images": False, | |
| } | |
| def _normalize_bool(value: object, default: bool = False) -> bool: | |
| if isinstance(value, str): | |
| lowered = value.strip().lower() | |
| if lowered in {"1", "true", "yes", "on"}: | |
| return True | |
| if lowered in {"0", "false", "no", "off"}: | |
| return False | |
| if value is None: | |
| return default | |
| return bool(value) | |
| def _normalize_positive_int(value: object, default: int, minimum: int = 0) -> int: | |
| try: | |
| normalized = int(value) | |
| except (TypeError, ValueError): | |
| normalized = default | |
| return max(minimum, normalized) | |
| def _normalize_backup_include(value: object) -> dict[str, bool]: | |
| source = value if isinstance(value, dict) else {} | |
| normalized = dict(DEFAULT_BACKUP_INCLUDE) | |
| for key in normalized: | |
| normalized[key] = _normalize_bool(source.get(key), normalized[key]) | |
| return normalized | |
| def _normalize_backup_settings(value: object) -> dict[str, object]: | |
| source = value if isinstance(value, dict) else {} | |
| return { | |
| "enabled": _normalize_bool(source.get("enabled"), False), | |
| "provider": "cloudflare_r2", | |
| "account_id": str(source.get("account_id") or "").strip(), | |
| "access_key_id": str(source.get("access_key_id") or "").strip(), | |
| "secret_access_key": str(source.get("secret_access_key") or "").strip(), | |
| "bucket": str(source.get("bucket") or "").strip(), | |
| "prefix": str(source.get("prefix") or "backups").strip().strip("/") or "backups", | |
| "interval_minutes": _normalize_positive_int(source.get("interval_minutes"), 360, 1), | |
| "rotation_keep": _normalize_positive_int(source.get("rotation_keep"), 10, 0), | |
| "encrypt": _normalize_bool(source.get("encrypt"), False), | |
| "passphrase": str(source.get("passphrase") or "").strip(), | |
| "include": _normalize_backup_include(source.get("include")), | |
| } | |
| def _normalize_backup_state(value: object) -> dict[str, object]: | |
| source = value if isinstance(value, dict) else {} | |
| return { | |
| "last_started_at": str(source.get("last_started_at") or "").strip() or None, | |
| "last_finished_at": str(source.get("last_finished_at") or "").strip() or None, | |
| "last_status": str(source.get("last_status") or "idle").strip() or "idle", | |
| "last_error": str(source.get("last_error") or "").strip() or None, | |
| "last_object_key": str(source.get("last_object_key") or "").strip() or None, | |
| } | |
| class LoadedSettings: | |
| auth_key: str | |
| refresh_account_interval_minute: int | |
| def _normalize_auth_key(value: object) -> str: | |
| return str(value or "").strip() | |
| def _is_invalid_auth_key(value: object) -> bool: | |
| return _normalize_auth_key(value) == "" | |
| def _read_json_object(path: Path, *, name: str) -> dict[str, object]: | |
| if not path.exists(): | |
| return {} | |
| if path.is_dir(): | |
| print( | |
| f"Warning: {name} at '{path}' is a directory, ignoring it and falling back to other configuration sources.", | |
| file=sys.stderr, | |
| ) | |
| return {} | |
| try: | |
| data = json.loads(path.read_text(encoding="utf-8")) | |
| except Exception: | |
| return {} | |
| return data if isinstance(data, dict) else {} | |
| def _load_settings() -> LoadedSettings: | |
| DATA_DIR.mkdir(parents=True, exist_ok=True) | |
| raw_config = _read_json_object(CONFIG_FILE, name="config.json") | |
| auth_key = _normalize_auth_key(os.getenv("CHATGPT2API_AUTH_KEY") or raw_config.get("auth-key")) | |
| if _is_invalid_auth_key(auth_key): | |
| raise ValueError( | |
| "❌ auth-key 未设置!\n" | |
| "请在环境变量 CHATGPT2API_AUTH_KEY 中设置,或者在 config.json 中填写 auth-key。" | |
| ) | |
| try: | |
| refresh_interval = int(raw_config.get("refresh_account_interval_minute", 5)) | |
| except (TypeError, ValueError): | |
| refresh_interval = 5 | |
| return LoadedSettings( | |
| auth_key=auth_key, | |
| refresh_account_interval_minute=refresh_interval, | |
| ) | |
| class ConfigStore: | |
| def __init__(self, path: Path): | |
| self.path = path | |
| DATA_DIR.mkdir(parents=True, exist_ok=True) | |
| self.data = self._load() | |
| self._storage_backend: StorageBackend | None = None | |
| if _is_invalid_auth_key(self.auth_key): | |
| raise ValueError( | |
| "❌ auth-key 未设置!\n" | |
| "请按以下任意一种方式解决:\n" | |
| "1. 在 Render 的 Environment 变量中添加:\n" | |
| " CHATGPT2API_AUTH_KEY = your_real_auth_key\n" | |
| "2. 或者在 config.json 中填写:\n" | |
| ' "auth-key": "your_real_auth_key"' | |
| ) | |
| def _load(self) -> dict[str, object]: | |
| return _read_json_object(self.path, name="config.json") | |
| def _save(self) -> None: | |
| self.path.write_text(json.dumps(self.data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") | |
| def auth_key(self) -> str: | |
| return _normalize_auth_key(os.getenv("CHATGPT2API_AUTH_KEY") or self.data.get("auth-key")) | |
| def accounts_file(self) -> Path: | |
| return DATA_DIR / "accounts.json" | |
| def refresh_account_interval_minute(self) -> int: | |
| try: | |
| return int(self.data.get("refresh_account_interval_minute", 5)) | |
| except (TypeError, ValueError): | |
| return 5 | |
| def image_retention_days(self) -> int: | |
| try: | |
| return max(1, int(self.data.get("image_retention_days", 30))) | |
| except (TypeError, ValueError): | |
| return 30 | |
| def image_poll_timeout_secs(self) -> int: | |
| try: | |
| return max(1, int(self.data.get("image_poll_timeout_secs", 120))) | |
| except (TypeError, ValueError): | |
| return 120 | |
| def image_account_concurrency(self) -> int: | |
| try: | |
| return max(1, int(self.data.get("image_account_concurrency", 3))) | |
| except (TypeError, ValueError): | |
| return 3 | |
| def auto_remove_invalid_accounts(self) -> bool: | |
| value = self.data.get("auto_remove_invalid_accounts", False) | |
| if isinstance(value, str): | |
| return value.strip().lower() in {"1", "true", "yes", "on"} | |
| return bool(value) | |
| def auto_remove_rate_limited_accounts(self) -> bool: | |
| value = self.data.get("auto_remove_rate_limited_accounts", False) | |
| if isinstance(value, str): | |
| return value.strip().lower() in {"1", "true", "yes", "on"} | |
| return bool(value) | |
| def log_levels(self) -> list[str]: | |
| levels = self.data.get("log_levels") | |
| if not isinstance(levels, list): | |
| return [] | |
| allowed = {"debug", "info", "warning", "error"} | |
| return [level for item in levels if (level := str(item or "").strip().lower()) in allowed] | |
| def sensitive_words(self) -> list[str]: | |
| words = self.data.get("sensitive_words") | |
| return [word for item in words if (word := str(item or "").strip())] if isinstance(words, list) else [] | |
| def ai_review(self) -> dict[str, object]: | |
| value = self.data.get("ai_review") | |
| return value if isinstance(value, dict) else {} | |
| def global_system_prompt(self) -> str: | |
| return str(self.data.get("global_system_prompt") or "").strip() | |
| def images_dir(self) -> Path: | |
| path = DATA_DIR / "images" | |
| path.mkdir(parents=True, exist_ok=True) | |
| return path | |
| def image_thumbnails_dir(self) -> Path: | |
| path = DATA_DIR / "image_thumbnails" | |
| path.mkdir(parents=True, exist_ok=True) | |
| return path | |
| def cleanup_old_images(self) -> int: | |
| cutoff = time.time() - self.image_retention_days * 86400 | |
| removed = 0 | |
| for path in self.images_dir.rglob("*"): | |
| if path.is_file() and path.stat().st_mtime < cutoff: | |
| path.unlink() | |
| removed += 1 | |
| for path in sorted((p for p in self.images_dir.rglob("*") if p.is_dir()), key=lambda p: len(p.parts), reverse=True): | |
| try: | |
| path.rmdir() | |
| except OSError: | |
| pass | |
| return removed | |
| def base_url(self) -> str: | |
| return str( | |
| os.getenv("CHATGPT2API_BASE_URL") | |
| or self.data.get("base_url") | |
| or "" | |
| ).strip().rstrip("/") | |
| def app_version(self) -> str: | |
| try: | |
| value = VERSION_FILE.read_text(encoding="utf-8").strip() | |
| except FileNotFoundError: | |
| return "0.0.0" | |
| return value or "0.0.0" | |
| def get(self) -> dict[str, object]: | |
| data = dict(self.data) | |
| data["refresh_account_interval_minute"] = self.refresh_account_interval_minute | |
| data["image_retention_days"] = self.image_retention_days | |
| data["image_poll_timeout_secs"] = self.image_poll_timeout_secs | |
| data["image_account_concurrency"] = self.image_account_concurrency | |
| data["auto_remove_invalid_accounts"] = self.auto_remove_invalid_accounts | |
| data["auto_remove_rate_limited_accounts"] = self.auto_remove_rate_limited_accounts | |
| data["log_levels"] = self.log_levels | |
| data["sensitive_words"] = self.sensitive_words | |
| data["ai_review"] = self.ai_review | |
| data["global_system_prompt"] = self.global_system_prompt | |
| data["backup"] = self.get_backup_settings() | |
| data.pop("auth-key", None) | |
| return data | |
| def get_proxy_settings(self) -> str: | |
| return str(self.data.get("proxy") or "").strip() | |
| def update(self, data: dict[str, object]) -> dict[str, object]: | |
| next_data = dict(self.data) | |
| next_data.update(dict(data or {})) | |
| if "backup" in next_data: | |
| next_data["backup"] = _normalize_backup_settings(next_data.get("backup")) | |
| next_data.pop("backup_state", None) | |
| self.data = next_data | |
| self._save() | |
| return self.get() | |
| def get_backup_settings(self) -> dict[str, object]: | |
| return _normalize_backup_settings(self.data.get("backup")) | |
| def get_storage_backend(self) -> StorageBackend: | |
| """获取存储后端实例(单例)""" | |
| if self._storage_backend is None: | |
| from services.storage.factory import create_storage_backend | |
| self._storage_backend = create_storage_backend(DATA_DIR) | |
| return self._storage_backend | |
| def load_backup_state() -> dict[str, object]: | |
| return _normalize_backup_state(_read_json_object(BACKUP_STATE_FILE, name="backup_state.json")) | |
| def save_backup_state(state: dict[str, object]) -> dict[str, object]: | |
| normalized = _normalize_backup_state(state) | |
| BACKUP_STATE_FILE.write_text(json.dumps(normalized, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") | |
| return normalized | |
| config = ConfigStore(CONFIG_FILE) | |