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, } @dataclass(frozen=True) 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") @property def auth_key(self) -> str: return _normalize_auth_key(os.getenv("CHATGPT2API_AUTH_KEY") or self.data.get("auth-key")) @property def accounts_file(self) -> Path: return DATA_DIR / "accounts.json" @property def refresh_account_interval_minute(self) -> int: try: return int(self.data.get("refresh_account_interval_minute", 5)) except (TypeError, ValueError): return 5 @property def image_retention_days(self) -> int: try: return max(1, int(self.data.get("image_retention_days", 30))) except (TypeError, ValueError): return 30 @property 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 @property def image_account_concurrency(self) -> int: try: return max(1, int(self.data.get("image_account_concurrency", 3))) except (TypeError, ValueError): return 3 @property 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) @property 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) @property 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] @property 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 [] @property def ai_review(self) -> dict[str, object]: value = self.data.get("ai_review") return value if isinstance(value, dict) else {} @property def global_system_prompt(self) -> str: return str(self.data.get("global_system_prompt") or "").strip() @property def images_dir(self) -> Path: path = DATA_DIR / "images" path.mkdir(parents=True, exist_ok=True) return path @property 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 @property def base_url(self) -> str: return str( os.getenv("CHATGPT2API_BASE_URL") or self.data.get("base_url") or "" ).strip().rstrip("/") @property 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)