from __future__ import annotations import os import sqlite3 import tempfile from pathlib import Path from threading import RLock from typing import Any from services.storage.account_pool_storage import AccountPoolStorageBackend, migrate_personal_accounts_to_shared_once from services.storage.base import StorageBackend from services.storage.database_storage import DatabaseStorageBackend from services.storage.git_storage import GitStorageBackend from services.storage.json_storage import JSONStorageBackend from services.sqlite_mirror_service import sqlite_mirror_service def _default_backend_type(data_dir: Path) -> str: if os.getenv("STORAGE_BACKEND") is not None: return os.getenv("STORAGE_BACKEND", "json").lower().strip() persist_dir = Path(os.getenv("CHATGPT2API_PERSIST_DIR", "").strip() or "/app/persist") if persist_dir.exists() or str(data_dir).startswith(str(persist_dir)): return "sqlite" return "json" def _sqlite_database_url(data_dir: Path) -> str: return f"sqlite:///{data_dir / 'accounts.db'}" def _sqlite_user_database_url(data_dir: Path) -> str: return f"sqlite:///{data_dir / 'users.db'}" def _is_local_accounts_database_url(database_url: str, data_dir: Path) -> bool: if not database_url.startswith("sqlite:///"): return False configured_path = Path(database_url.removeprefix("sqlite:///")) try: return configured_path.resolve(strict=False) == (data_dir / "accounts.db").resolve(strict=False) except OSError: return str(configured_path) == str(data_dir / "accounts.db") _LOCAL_SQLITE_SPLIT_LOCK = RLock() def _local_sqlite_split_enabled() -> bool: return os.getenv("CHATGPT2API_SPLIT_LOCAL_DATABASES", "true").strip().lower() not in {"0", "false", "no", "off"} def _sqlite_values(connection: sqlite3.Connection, query: str) -> set[object]: return {row[0] for row in connection.execute(query).fetchall()} def _migrate_local_sqlite_users_once(data_dir: Path) -> None: """Move user/shop tables from accounts.db to users.db, then remove the legacy copies. The migration is idempotent. Target rows win on natural-key conflicts so a partially migrated deployment never overwrites newer user balances or codes. """ marker_path = data_dir / ".local_user_database_split_migrated" accounts_path = data_dir / "accounts.db" users_path = data_dir / "users.db" backup_path = data_dir / "accounts.before-user-split.db" with _LOCAL_SQLITE_SPLIT_LOCK: if marker_path.exists() and users_path.exists(): return # Build/upgrade both schemas before copying rows. legacy_backend = DatabaseStorageBackend(f"sqlite:///{accounts_path}", table_scope="all") _migrate_git_to_database_if_empty(legacy_backend) user_backend = DatabaseStorageBackend(f"sqlite:///{users_path}", table_scope="users") legacy_backend.engine.dispose() user_backend.engine.dispose() source = sqlite3.connect(str(accounts_path), timeout=30) target = sqlite3.connect(str(users_path), timeout=30) try: for connection in (source, target): connection.execute("PRAGMA busy_timeout=30000") connection.execute("PRAGMA synchronous=FULL") connection.execute("PRAGMA journal_mode=DELETE") if not backup_path.exists(): backup = sqlite3.connect(str(backup_path)) try: source.backup(backup) finally: backup.close() target.execute("BEGIN IMMEDIATE") target.executemany( "INSERT OR IGNORE INTO auth_keys(key_id,data) VALUES(?,?)", source.execute("SELECT key_id,data FROM auth_keys ORDER BY id").fetchall(), ) target.executemany( 'INSERT OR IGNORE INTO shop_state("key",data) VALUES(?,?)', source.execute('SELECT "key",data FROM shop_state ORDER BY "key"').fetchall(), ) target.executemany( "INSERT OR IGNORE INTO redeem_codes(code_id,code_hash,batch_id,redeemed_by,status,data) VALUES(?,?,?,?,?,?)", source.execute( "SELECT code_id,code_hash,batch_id,redeemed_by,status,data FROM redeem_codes ORDER BY id" ).fetchall(), ) target.executemany( "INSERT OR IGNORE INTO shop_ledger(ledger_id,created_at,user_id,data) VALUES(?,?,?,?)", source.execute("SELECT ledger_id,created_at,user_id,data FROM shop_ledger ORDER BY id").fetchall(), ) target.commit() checks = [ ( _sqlite_values(source, "SELECT key_id FROM auth_keys"), _sqlite_values(target, "SELECT key_id FROM auth_keys"), "auth_keys", ), ( _sqlite_values(source, 'SELECT "key" FROM shop_state'), _sqlite_values(target, 'SELECT "key" FROM shop_state'), "shop_state", ), ( _sqlite_values(source, "SELECT code_hash FROM redeem_codes"), _sqlite_values(target, "SELECT code_hash FROM redeem_codes"), "redeem_codes", ), ( _sqlite_values(source, "SELECT ledger_id FROM shop_ledger"), _sqlite_values(target, "SELECT ledger_id FROM shop_ledger"), "shop_ledger", ), ] for source_keys, target_keys, table_name in checks: missing = source_keys - target_keys if missing: raise RuntimeError(f"local user database split verification failed for {table_name}: {len(missing)} rows missing") if target.execute("PRAGMA integrity_check").fetchone()[0] != "ok": raise RuntimeError("users.db failed integrity_check after migration") source.execute("BEGIN IMMEDIATE") for table_name in ("shop_ledger", "redeem_codes", "shop_state", "auth_keys"): source.execute(f'DROP TABLE IF EXISTS "{table_name}"') source.commit() source.execute("ANALYZE") source.execute("VACUUM") if source.execute("PRAGMA integrity_check").fetchone()[0] != "ok": raise RuntimeError("accounts.db failed integrity_check after user table removal") marker_path.write_text("completed\n", encoding="utf-8") print( "[storage] Split local SQLite storage: " f"accounts={accounts_path}, users={users_path}, backup={backup_path}" ) except Exception: target.rollback() source.rollback() raise finally: target.close() source.close() def _is_database_empty(backend: DatabaseStorageBackend) -> bool: return not backend.load_accounts() and not backend.load_auth_keys() and not backend.load_shop_state() def _shop_state_has_data(state: object) -> bool: if not isinstance(state, dict): return False return bool(state.get("codes") or state.get("ledger")) def _is_user_storage_empty(backend: StorageBackend) -> bool: return not backend.load_auth_keys() and not _shop_state_has_data(backend.load_shop_state()) def _merge_auth_keys_preserving_target(source_items: list[dict[str, Any]], target_items: list[dict[str, Any]]) -> list[dict[str, Any]]: merged: dict[str, dict[str, Any]] = {} for item in source_items: item_id = str(item.get("id") or "").strip() if item_id: merged[item_id] = dict(item) for item in target_items: item_id = str(item.get("id") or "").strip() if item_id: merged[item_id] = dict(item) return list(merged.values()) def _merge_shop_state_preserving_target(source_state: dict[str, Any], target_state: dict[str, Any]) -> dict[str, Any]: def merge_items(key: str, identity_keys: tuple[str, ...]) -> list[dict[str, Any]]: merged: dict[str, dict[str, Any]] = {} source_items = source_state.get(key) if isinstance(source_state.get(key), list) else [] target_items = target_state.get(key) if isinstance(target_state.get(key), list) else [] for item in [*source_items, *target_items]: if not isinstance(item, dict): continue item_id = "" for identity_key in identity_keys: item_id = str(item.get(identity_key) or "").strip() if item_id: break if item_id: merged[item_id] = dict(item) return list(merged.values()) return { "codes": merge_items("codes", ("id", "code_hash")), "ledger": sorted(merge_items("ledger", ("id",)), key=lambda item: str(item.get("time") or "")), } def _migrate_local_users_to_shared_once(source: StorageBackend, target: StorageBackend, marker_path: Path) -> None: if source is target: return if os.getenv("CHATGPT2API_AUTO_MIGRATE_USER_STORAGE", "").strip().lower() not in {"1", "true", "yes", "on"}: return if os.getenv("CHATGPT2API_SKIP_USER_STORAGE_MIGRATION", "").strip().lower() in {"1", "true", "yes", "on"}: return if marker_path.exists(): return try: source_auth_keys = source.load_auth_keys() source_shop_state = source.load_shop_state() except Exception as exc: print(f"[storage] shared user migration skipped, source load failed: {exc}") return if not source_auth_keys and not _shop_state_has_data(source_shop_state): return def merge(target_auth_keys: list[dict[str, Any]], target_shop_state: dict[str, Any]) -> tuple[list[dict[str, Any]], dict[str, Any]]: return ( _merge_auth_keys_preserving_target(source_auth_keys, target_auth_keys), _merge_shop_state_preserving_target( source_shop_state if isinstance(source_shop_state, dict) else {}, target_shop_state if isinstance(target_shop_state, dict) else {}, ), ) try: update_user_and_shop_state_locked = getattr(target, "update_user_and_shop_state_locked", None) if callable(update_user_and_shop_state_locked): def mutate( target_auth_keys: list[dict[str, Any]], target_shop_state: dict[str, Any], ) -> tuple[list[dict[str, Any]], dict[str, Any], dict[str, int]]: merged_auth_keys, merged_shop_state = merge(target_auth_keys, target_shop_state) result = { "source_auth_keys": len(source_auth_keys), "target_auth_keys": len(target_auth_keys), "merged_auth_keys": len(merged_auth_keys), "source_codes": len(source_shop_state.get("codes") or []) if isinstance(source_shop_state, dict) else 0, "merged_codes": len(merged_shop_state.get("codes") or []), } return merged_auth_keys, merged_shop_state, result result = update_user_and_shop_state_locked(mutate) else: target_auth_keys = target.load_auth_keys() target_shop_state = target.load_shop_state() merged_auth_keys, merged_shop_state = merge(target_auth_keys, target_shop_state) target.save_auth_keys(merged_auth_keys) target.save_shop_state(merged_shop_state) result = { "source_auth_keys": len(source_auth_keys), "target_auth_keys": len(target_auth_keys), "merged_auth_keys": len(merged_auth_keys), "source_codes": len(source_shop_state.get("codes") or []) if isinstance(source_shop_state, dict) else 0, "merged_codes": len(merged_shop_state.get("codes") or []), } marker_path.parent.mkdir(parents=True, exist_ok=True) marker_path.write_text("completed\n", encoding="utf-8") print( "[storage] Merged local user data to shared database: " f"source_auth_keys={result['source_auth_keys']}, " f"target_auth_keys={result['target_auth_keys']}, " f"merged_auth_keys={result['merged_auth_keys']}, " f"source_codes={result['source_codes']}, " f"merged_codes={result['merged_codes']}" ) except Exception as exc: print(f"[storage] shared user migration failed: {exc}") def _build_git_backend_from_env() -> GitStorageBackend | None: repo_url = os.getenv("GIT_REPO_URL", "").strip() token = os.getenv("GIT_TOKEN", "").strip() if not repo_url: return None branch = os.getenv("GIT_BRANCH", "main").strip() file_path = os.getenv("GIT_FILE_PATH", "accounts.json").strip() auth_keys_file_path = os.getenv("GIT_AUTH_KEYS_FILE_PATH", "auth_keys.json").strip() shop_state_file_path = os.getenv("GIT_SHOP_STATE_FILE_PATH", "shop_state.json").strip() cache_dir = Path(os.getenv("GIT_CACHE_DIR", "").strip() or (Path(tempfile.gettempdir()) / "chatgpt2api_git_cache")) return GitStorageBackend( repo_url=repo_url, token=token, branch=branch, file_path=file_path, auth_keys_file_path=auth_keys_file_path, shop_state_file_path=shop_state_file_path, local_cache_dir=cache_dir, ) def _migrate_git_to_database_if_empty(backend: DatabaseStorageBackend) -> None: if os.getenv("CHATGPT2API_SKIP_STORAGE_MIGRATION", "").strip().lower() in {"1", "true", "yes", "on"}: return try: if not _is_database_empty(backend): return except Exception as exc: print(f"[storage] skip migration, database check failed: {exc}") return git_backend = _build_git_backend_from_env() if git_backend is None: return try: accounts = git_backend.load_accounts() auth_keys = git_backend.load_auth_keys() shop_state = git_backend.load_shop_state() except Exception as exc: print(f"[storage] Git to SQLite migration skipped: {exc}") return if not accounts and not auth_keys and not shop_state: return print( "[storage] Migrating Git data to SQLite: " f"{len(accounts)} accounts, {len(auth_keys)} auth keys, shop_state={bool(shop_state)}" ) if accounts: backend.save_accounts(accounts) if auth_keys: backend.save_auth_keys(auth_keys) if shop_state: backend.save_shop_state(shop_state) def create_storage_backend(data_dir: Path) -> StorageBackend: """ 根据环境变量创建存储后端 环境变量: - STORAGE_BACKEND: json|sqlite|postgres|git (默认 json) - DATABASE_URL: 数据库连接字符串 (用于 sqlite/postgres) - GIT_REPO_URL: Git 仓库地址 (用于 git) - GIT_TOKEN: Git 访问令牌 (用于 git) - GIT_BRANCH: Git 分支 (默认 main) - GIT_FILE_PATH: Git 仓库中的文件路径 (默认 accounts.json) - GIT_CACHE_DIR: Git 本地缓存目录 (默认系统临时目录) """ backend_type = _default_backend_type(data_dir) print(f"[storage] Initializing storage backend: {backend_type}") if backend_type == "json": # 本地 JSON 文件存储 file_path = data_dir / "accounts.json" auth_keys_path = data_dir / "auth_keys.json" shop_state_path = data_dir / "shop_state.json" print(f"[storage] Using JSON storage: {file_path}") return JSONStorageBackend(file_path, auth_keys_path, shop_state_path) elif backend_type in ("sqlite", "postgres", "postgresql", "mysql", "database"): # 数据库存储 database_url = os.getenv("DATABASE_URL", "").strip() sqlite_data_dir = data_dir if not database_url: # 如果没有指定 DATABASE_URL,使用本地 SQLite sqlite_data_dir = sqlite_mirror_service.runtime_data_dir(data_dir) database_url = _sqlite_database_url(sqlite_data_dir) print(f"[storage] No DATABASE_URL provided, using local SQLite: {database_url}") else: if _is_local_accounts_database_url(database_url, data_dir): sqlite_data_dir = sqlite_mirror_service.runtime_data_dir(data_dir) database_url = _sqlite_database_url(sqlite_data_dir) print(f"[storage] Using database storage: {_mask_password(database_url)}") if ( backend_type in {"sqlite", "database"} and _local_sqlite_split_enabled() and _is_local_accounts_database_url(database_url, sqlite_data_dir) ): _migrate_local_sqlite_users_once(sqlite_data_dir) return DatabaseStorageBackend(database_url, table_scope="accounts") backend = DatabaseStorageBackend(database_url) if backend_type in {"sqlite", "database"}: _migrate_git_to_database_if_empty(backend) return backend elif backend_type == "git": # Git 仓库存储 repo_url = os.getenv("GIT_REPO_URL", "").strip() token = os.getenv("GIT_TOKEN", "").strip() branch = os.getenv("GIT_BRANCH", "main").strip() file_path = os.getenv("GIT_FILE_PATH", "accounts.json").strip() auth_keys_file_path = os.getenv("GIT_AUTH_KEYS_FILE_PATH", "auth_keys.json").strip() shop_state_file_path = os.getenv("GIT_SHOP_STATE_FILE_PATH", "shop_state.json").strip() if not repo_url: raise ValueError( "GIT_REPO_URL is required when using git storage backend. " "Please set GIT_REPO_URL environment variable." ) print(f"[storage] Using Git storage: {_mask_token(repo_url)}, branch: {branch}, file: {file_path}") cache_dir = Path(os.getenv("GIT_CACHE_DIR", "").strip() or (Path(tempfile.gettempdir()) / "chatgpt2api_git_cache")) return GitStorageBackend( repo_url=repo_url, token=token, branch=branch, file_path=file_path, auth_keys_file_path=auth_keys_file_path, shop_state_file_path=shop_state_file_path, local_cache_dir=cache_dir, ) else: raise ValueError( f"Unknown storage backend: {backend_type}. " f"Supported backends: json, sqlite, postgres, git" ) def create_user_storage_backend(data_dir: Path, fallback_backend: StorageBackend | None = None) -> StorageBackend: """Create the optional shared backend for users, credits, WeChat bindings, and redeem codes. A configured SHARED_USER_DATABASE_URL still takes precedence. Otherwise the default local SQLite deployment uses users.db, isolated from the account-pool accounts.db. """ database_url = os.getenv("SHARED_USER_DATABASE_URL", "").strip() if not database_url: sqlite_data_dir = sqlite_mirror_service.runtime_data_dir(data_dir) if _local_sqlite_split_enabled() and isinstance(fallback_backend, DatabaseStorageBackend): if fallback_backend.table_scope == "accounts" and _is_local_accounts_database_url( fallback_backend.database_url, sqlite_data_dir ): _migrate_local_sqlite_users_once(sqlite_data_dir) user_database_url = _sqlite_user_database_url(sqlite_data_dir) print(f"[storage] Using isolated local user SQLite: {user_database_url}") return DatabaseStorageBackend(user_database_url, table_scope="users") return fallback_backend or create_storage_backend(data_dir) print(f"[storage] Using shared user database storage: {_mask_password(database_url)}") backend = DatabaseStorageBackend(database_url) if fallback_backend is not None: migration_source = fallback_backend local_user_backend: DatabaseStorageBackend | None = None if ( isinstance(fallback_backend, DatabaseStorageBackend) and fallback_backend.table_scope == "accounts" and _is_local_accounts_database_url(fallback_backend.database_url, data_dir) ): local_user_backend = DatabaseStorageBackend(_sqlite_user_database_url(data_dir), table_scope="users") migration_source = local_user_backend try: _migrate_local_users_to_shared_once( migration_source, backend, data_dir / ".shared_user_storage_migrated", ) finally: if local_user_backend is not None: local_user_backend.engine.dispose() return backend def create_account_pool_storage_backend( data_dir: Path, site_storage: StorageBackend, user_storage: StorageBackend, ) -> StorageBackend: if not os.getenv("SHARED_USER_DATABASE_URL", "").strip() or site_storage is user_storage: return site_storage migrate_personal_accounts_to_shared_once( site_storage, user_storage, data_dir / ".shared_account_pool_storage_migrated", ) return AccountPoolStorageBackend(site_storage, user_storage) def _mask_password(url: str) -> str: """隐藏数据库连接字符串中的密码""" if "://" not in url: return url try: protocol, rest = url.split("://", 1) if "@" in rest: credentials, host = rest.split("@", 1) if ":" in credentials: username, _ = credentials.split(":", 1) return f"{protocol}://{username}:****@{host}" return url except Exception: return url def _mask_token(url: str) -> str: """隐藏 URL 中的 token""" if "@" in url and "://" in url: protocol, rest = url.split("://", 1) if "@" in rest: _, host = rest.split("@", 1) return f"{protocol}://****@{host}" return url