| """ |
| 配置持久化:默认使用 SQLite;提供 DATABASE_URL / WEB2API_DATABASE_URL 时切换到 PostgreSQL。 |
| 表结构:proxy_group, account(含 name, type, auth JSON),以及 app_setting。 |
| """ |
|
|
| from __future__ import annotations |
|
|
| import os |
| import sqlite3 |
| from pathlib import Path |
| from typing import Any |
|
|
| from core.config.schema import AccountConfig, ProxyGroupConfig, account_from_row |
| from core.config.settings import coerce_bool, get_database_url |
|
|
|
|
| DB_FILENAME = "db.sqlite3" |
| DB_PATH_ENV_KEY = "WEB2API_DB_PATH" |
| APP_SETTING_AUTH_API_KEY = "auth.api_key" |
| APP_SETTING_AUTH_CONFIG_SECRET_HASH = "auth.config_secret_hash" |
| APP_SETTING_ENABLE_PRO_MODELS = "claude.enable_pro_models" |
|
|
|
|
| def _get_db_path() -> Path: |
| """SQLite 文件路径。""" |
| configured = os.environ.get(DB_PATH_ENV_KEY, "").strip() |
| if configured: |
| return Path(configured).expanduser() |
| return Path(__file__).resolve().parent.parent.parent / DB_FILENAME |
|
|
|
|
| def create_config_repository( |
| db_path: Path | None = None, |
| database_url: str | None = None, |
| ) -> "ConfigRepository": |
| resolved_database_url = ( |
| get_database_url().strip() if database_url is None else database_url.strip() |
| ) |
| return ConfigRepository( |
| _PostgresConfigRepository(resolved_database_url) |
| if resolved_database_url |
| else _SqliteConfigRepository(db_path or _get_db_path()) |
| ) |
|
|
|
|
| class _RepositoryBase: |
| def init_schema(self) -> None: |
| raise NotImplementedError |
|
|
| def load_groups(self) -> list[ProxyGroupConfig]: |
| raise NotImplementedError |
|
|
| def save_groups(self, groups: list[ProxyGroupConfig]) -> None: |
| raise NotImplementedError |
|
|
| def update_account_unfreeze_at( |
| self, |
| fingerprint_id: str, |
| account_name: str, |
| unfreeze_at: int | None, |
| ) -> None: |
| raise NotImplementedError |
|
|
| def load_raw(self) -> list[dict[str, Any]]: |
| """与前端/API 一致的原始列表格式。""" |
| groups = self.load_groups() |
| return [ |
| { |
| "proxy_host": g.proxy_host, |
| "proxy_user": g.proxy_user, |
| "proxy_pass": g.proxy_pass, |
| "fingerprint_id": g.fingerprint_id, |
| "use_proxy": g.use_proxy, |
| "timezone": g.timezone, |
| "accounts": [ |
| { |
| "name": a.name, |
| "type": a.type, |
| "auth": a.auth, |
| "enabled": a.enabled, |
| "unfreeze_at": a.unfreeze_at, |
| } |
| for a in g.accounts |
| ], |
| } |
| for g in groups |
| ] |
|
|
| def load_app_settings(self) -> dict[str, str]: |
| raise NotImplementedError |
|
|
| def get_app_setting(self, key: str) -> str | None: |
| value = self.load_app_settings().get(key) |
| return value if value is not None else None |
|
|
| def set_app_setting(self, key: str, value: str | None) -> None: |
| raise NotImplementedError |
|
|
| def save_raw(self, raw: list[dict[str, Any]]) -> None: |
| """从 API/前端原始格式写入并保存。""" |
| groups = _raw_to_groups(raw) |
| self.save_groups(groups) |
|
|
|
|
| class _SqliteConfigRepository(_RepositoryBase): |
| def __init__(self, db_path: Path | None = None) -> None: |
| self._db_path = db_path or _get_db_path() |
| self._schema_initialized = False |
|
|
| def _conn(self) -> sqlite3.Connection: |
| self._db_path.parent.mkdir(parents=True, exist_ok=True) |
| return sqlite3.connect(self._db_path) |
|
|
| def _init_tables(self, conn: sqlite3.Connection) -> None: |
| conn.execute( |
| """ |
| CREATE TABLE IF NOT EXISTS proxy_group ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| proxy_host TEXT NOT NULL, |
| proxy_user TEXT NOT NULL, |
| proxy_pass TEXT NOT NULL, |
| fingerprint_id TEXT NOT NULL DEFAULT '', |
| use_proxy INTEGER NOT NULL DEFAULT 1, |
| timezone TEXT |
| ) |
| """ |
| ) |
| conn.execute( |
| """ |
| CREATE TABLE IF NOT EXISTS account ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| proxy_group_id INTEGER NOT NULL, |
| name TEXT NOT NULL, |
| type TEXT NOT NULL, |
| auth TEXT NOT NULL DEFAULT '{}', |
| enabled INTEGER NOT NULL DEFAULT 1, |
| FOREIGN KEY (proxy_group_id) REFERENCES proxy_group(id) ON DELETE CASCADE |
| ) |
| """ |
| ) |
| conn.execute( |
| "CREATE INDEX IF NOT EXISTS ix_account_proxy_group_id ON account(proxy_group_id)" |
| ) |
| conn.execute("CREATE INDEX IF NOT EXISTS ix_account_type ON account(type)") |
| conn.execute( |
| """ |
| CREATE TABLE IF NOT EXISTS app_setting ( |
| key TEXT PRIMARY KEY, |
| value TEXT NOT NULL DEFAULT '' |
| ) |
| """ |
| ) |
| try: |
| conn.execute("ALTER TABLE account ADD COLUMN unfreeze_at INTEGER") |
| except sqlite3.OperationalError: |
| pass |
| try: |
| conn.execute( |
| "ALTER TABLE account ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1" |
| ) |
| except sqlite3.OperationalError: |
| pass |
| try: |
| conn.execute( |
| "ALTER TABLE proxy_group ADD COLUMN use_proxy INTEGER NOT NULL DEFAULT 1" |
| ) |
| except sqlite3.OperationalError: |
| pass |
| try: |
| conn.execute("ALTER TABLE proxy_group ADD COLUMN timezone TEXT") |
| except sqlite3.OperationalError: |
| pass |
| conn.commit() |
|
|
| def _ensure_schema(self) -> None: |
| if self._schema_initialized: |
| return |
| conn = self._conn() |
| try: |
| self._init_tables(conn) |
| self._schema_initialized = True |
| finally: |
| conn.close() |
|
|
| def init_schema(self) -> None: |
| self._ensure_schema() |
|
|
| def load_groups(self) -> list[ProxyGroupConfig]: |
| self._ensure_schema() |
| conn = self._conn() |
| try: |
| groups: list[ProxyGroupConfig] = [] |
| group_rows = conn.execute( |
| """ |
| SELECT id, proxy_host, proxy_user, proxy_pass, fingerprint_id, use_proxy, timezone |
| FROM proxy_group ORDER BY id ASC |
| """ |
| ).fetchall() |
| accounts_by_group: dict[int, list[AccountConfig]] = {} |
| for gid, name, type_, auth_json, enabled, unfreeze_at in conn.execute( |
| """ |
| SELECT proxy_group_id, name, type, auth, enabled, unfreeze_at |
| FROM account ORDER BY proxy_group_id ASC, id ASC |
| """ |
| ).fetchall(): |
| accounts_by_group.setdefault(int(gid), []).append( |
| account_from_row( |
| name, |
| type_, |
| auth_json or "{}", |
| enabled=bool(enabled) if enabled is not None else True, |
| unfreeze_at=unfreeze_at, |
| ) |
| ) |
| for gid, proxy_host, proxy_user, proxy_pass, fingerprint_id, use_proxy, timezone in group_rows: |
| groups.append( |
| ProxyGroupConfig( |
| proxy_host=proxy_host, |
| proxy_user=proxy_user, |
| proxy_pass=proxy_pass, |
| fingerprint_id=fingerprint_id or "", |
| use_proxy=bool(use_proxy), |
| timezone=timezone, |
| accounts=accounts_by_group.get(int(gid), []), |
| ) |
| ) |
| return groups |
| finally: |
| conn.close() |
|
|
| def save_groups(self, groups: list[ProxyGroupConfig]) -> None: |
| self._ensure_schema() |
| conn = self._conn() |
| try: |
| conn.execute("DELETE FROM account") |
| conn.execute("DELETE FROM proxy_group") |
| for group in groups: |
| cur = conn.execute( |
| """ |
| INSERT INTO proxy_group (proxy_host, proxy_user, proxy_pass, fingerprint_id, use_proxy, timezone) |
| VALUES (?, ?, ?, ?, ?, ?) |
| """, |
| ( |
| group.proxy_host, |
| group.proxy_user, |
| group.proxy_pass, |
| group.fingerprint_id, |
| 1 if group.use_proxy else 0, |
| group.timezone, |
| ), |
| ) |
| gid = cur.lastrowid |
| for account in group.accounts: |
| conn.execute( |
| """ |
| INSERT INTO account (proxy_group_id, name, type, auth, enabled, unfreeze_at) |
| VALUES (?, ?, ?, ?, ?, ?) |
| """, |
| ( |
| gid, |
| account.name, |
| account.type, |
| account.auth_json(), |
| 1 if account.enabled else 0, |
| account.unfreeze_at, |
| ), |
| ) |
| conn.commit() |
| finally: |
| conn.close() |
|
|
| def update_account_unfreeze_at( |
| self, |
| fingerprint_id: str, |
| account_name: str, |
| unfreeze_at: int | None, |
| ) -> None: |
| self._ensure_schema() |
| conn = self._conn() |
| try: |
| conn.execute( |
| """ |
| UPDATE account SET unfreeze_at = ? |
| WHERE proxy_group_id = (SELECT id FROM proxy_group WHERE fingerprint_id = ?) |
| AND name = ? |
| """, |
| (unfreeze_at, fingerprint_id, account_name), |
| ) |
| conn.commit() |
| finally: |
| conn.close() |
|
|
| def load_app_settings(self) -> dict[str, str]: |
| self._ensure_schema() |
| conn = self._conn() |
| try: |
| rows = conn.execute( |
| "SELECT key, value FROM app_setting ORDER BY key ASC" |
| ).fetchall() |
| return {str(key): str(value) for key, value in rows} |
| finally: |
| conn.close() |
|
|
| def set_app_setting(self, key: str, value: str | None) -> None: |
| self._ensure_schema() |
| conn = self._conn() |
| try: |
| if value is None: |
| conn.execute("DELETE FROM app_setting WHERE key = ?", (key,)) |
| else: |
| conn.execute( |
| """ |
| INSERT INTO app_setting (key, value) VALUES (?, ?) |
| ON CONFLICT(key) DO UPDATE SET value = excluded.value |
| """, |
| (key, value), |
| ) |
| conn.commit() |
| finally: |
| conn.close() |
|
|
|
|
| class _PostgresConfigRepository(_RepositoryBase): |
| def __init__(self, database_url: str) -> None: |
| self._database_url = database_url |
|
|
| def _conn(self) -> Any: |
| import psycopg |
|
|
| return psycopg.connect(self._database_url) |
|
|
| def init_schema(self) -> None: |
| with self._conn() as conn: |
| with conn.cursor() as cur: |
| cur.execute( |
| """ |
| CREATE TABLE IF NOT EXISTS proxy_group ( |
| id BIGSERIAL PRIMARY KEY, |
| proxy_host TEXT NOT NULL, |
| proxy_user TEXT NOT NULL, |
| proxy_pass TEXT NOT NULL, |
| fingerprint_id TEXT NOT NULL DEFAULT '', |
| use_proxy BOOLEAN NOT NULL DEFAULT TRUE, |
| timezone TEXT |
| ) |
| """ |
| ) |
| cur.execute( |
| """ |
| CREATE TABLE IF NOT EXISTS account ( |
| id BIGSERIAL PRIMARY KEY, |
| proxy_group_id BIGINT NOT NULL REFERENCES proxy_group(id) ON DELETE CASCADE, |
| name TEXT NOT NULL, |
| type TEXT NOT NULL, |
| auth TEXT NOT NULL DEFAULT '{}', |
| enabled BOOLEAN NOT NULL DEFAULT TRUE, |
| unfreeze_at BIGINT |
| ) |
| """ |
| ) |
| cur.execute( |
| "CREATE INDEX IF NOT EXISTS ix_account_proxy_group_id ON account(proxy_group_id)" |
| ) |
| cur.execute("CREATE INDEX IF NOT EXISTS ix_account_type ON account(type)") |
| cur.execute( |
| """ |
| CREATE TABLE IF NOT EXISTS app_setting ( |
| key TEXT PRIMARY KEY, |
| value TEXT NOT NULL DEFAULT '' |
| ) |
| """ |
| ) |
|
|
| def load_groups(self) -> list[ProxyGroupConfig]: |
| groups: list[ProxyGroupConfig] = [] |
| with self._conn() as conn: |
| with conn.cursor() as cur: |
| cur.execute( |
| """ |
| SELECT id, proxy_host, proxy_user, proxy_pass, fingerprint_id, use_proxy, timezone |
| FROM proxy_group ORDER BY id ASC |
| """ |
| ) |
| group_rows = cur.fetchall() |
| cur.execute( |
| """ |
| SELECT proxy_group_id, name, type, auth, enabled, unfreeze_at |
| FROM account ORDER BY proxy_group_id ASC, id ASC |
| """ |
| ) |
| accounts_by_group: dict[int, list[AccountConfig]] = {} |
| for gid, name, type_, auth_json, enabled, unfreeze_at in cur.fetchall(): |
| accounts_by_group.setdefault(int(gid), []).append( |
| account_from_row( |
| name, |
| type_, |
| auth_json or "{}", |
| enabled=bool(enabled) if enabled is not None else True, |
| unfreeze_at=unfreeze_at, |
| ) |
| ) |
| for row in group_rows: |
| ( |
| gid, |
| proxy_host, |
| proxy_user, |
| proxy_pass, |
| fingerprint_id, |
| use_proxy, |
| timezone, |
| ) = row |
| groups.append( |
| ProxyGroupConfig( |
| proxy_host=proxy_host, |
| proxy_user=proxy_user, |
| proxy_pass=proxy_pass, |
| fingerprint_id=fingerprint_id or "", |
| use_proxy=bool(use_proxy), |
| timezone=timezone, |
| accounts=accounts_by_group.get(int(gid), []), |
| ) |
| ) |
| return groups |
|
|
| def save_groups(self, groups: list[ProxyGroupConfig]) -> None: |
| with self._conn() as conn: |
| with conn.cursor() as cur: |
| cur.execute("DELETE FROM account") |
| cur.execute("DELETE FROM proxy_group") |
| for group in groups: |
| cur.execute( |
| """ |
| INSERT INTO proxy_group (proxy_host, proxy_user, proxy_pass, fingerprint_id, use_proxy, timezone) |
| VALUES (%s, %s, %s, %s, %s, %s) |
| RETURNING id |
| """, |
| ( |
| group.proxy_host, |
| group.proxy_user, |
| group.proxy_pass, |
| group.fingerprint_id, |
| group.use_proxy, |
| group.timezone, |
| ), |
| ) |
| gid = cur.fetchone()[0] |
| for account in group.accounts: |
| cur.execute( |
| """ |
| INSERT INTO account (proxy_group_id, name, type, auth, enabled, unfreeze_at) |
| VALUES (%s, %s, %s, %s, %s, %s) |
| """, |
| ( |
| gid, |
| account.name, |
| account.type, |
| account.auth_json(), |
| account.enabled, |
| account.unfreeze_at, |
| ), |
| ) |
|
|
| def update_account_unfreeze_at( |
| self, |
| fingerprint_id: str, |
| account_name: str, |
| unfreeze_at: int | None, |
| ) -> None: |
| with self._conn() as conn: |
| with conn.cursor() as cur: |
| cur.execute( |
| """ |
| UPDATE account SET unfreeze_at = %s |
| WHERE proxy_group_id = ( |
| SELECT id FROM proxy_group WHERE fingerprint_id = %s ORDER BY id ASC LIMIT 1 |
| ) |
| AND name = %s |
| """, |
| (unfreeze_at, fingerprint_id, account_name), |
| ) |
|
|
| def load_app_settings(self) -> dict[str, str]: |
| with self._conn() as conn: |
| with conn.cursor() as cur: |
| cur.execute("SELECT key, value FROM app_setting ORDER BY key ASC") |
| return {str(key): str(value) for key, value in cur.fetchall()} |
|
|
| def set_app_setting(self, key: str, value: str | None) -> None: |
| with self._conn() as conn: |
| with conn.cursor() as cur: |
| if value is None: |
| cur.execute("DELETE FROM app_setting WHERE key = %s", (key,)) |
| else: |
| cur.execute( |
| """ |
| INSERT INTO app_setting (key, value) VALUES (%s, %s) |
| ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value |
| """, |
| (key, value), |
| ) |
|
|
|
|
| class ConfigRepository(_RepositoryBase): |
| """配置读写入口。""" |
|
|
| def __init__(self, backend: _RepositoryBase) -> None: |
| self._backend = backend |
|
|
| def init_schema(self) -> None: |
| self._backend.init_schema() |
|
|
| def load_groups(self) -> list[ProxyGroupConfig]: |
| return self._backend.load_groups() |
|
|
| def save_groups(self, groups: list[ProxyGroupConfig]) -> None: |
| self._backend.save_groups(groups) |
|
|
| def load_raw(self) -> list[dict[str, Any]]: |
| return self._backend.load_raw() |
|
|
| def load_app_settings(self) -> dict[str, str]: |
| return self._backend.load_app_settings() |
|
|
| def get_app_setting(self, key: str) -> str | None: |
| return self._backend.get_app_setting(key) |
|
|
| def set_app_setting(self, key: str, value: str | None) -> None: |
| self._backend.set_app_setting(key, value) |
|
|
| def save_raw(self, raw: list[dict[str, Any]]) -> None: |
| self._backend.save_raw(raw) |
|
|
| def update_account_unfreeze_at( |
| self, |
| fingerprint_id: str, |
| account_name: str, |
| unfreeze_at: int | None, |
| ) -> None: |
| self._backend.update_account_unfreeze_at( |
| fingerprint_id, |
| account_name, |
| unfreeze_at, |
| ) |
|
|
|
|
| def _raw_to_groups(raw: list[dict[str, Any]]) -> list[ProxyGroupConfig]: |
| """将 API 原始列表转为 ProxyGroupConfig 列表。""" |
| groups: list[ProxyGroupConfig] = [] |
| for group in raw: |
| accounts: list[AccountConfig] = [] |
| for account in group.get("accounts", []): |
| name = str(account.get("name", "")).strip() |
| type_ = str(account.get("type", "")).strip() or "claude" |
| auth = account.get("auth") |
| if isinstance(auth, dict): |
| pass |
| elif isinstance(auth, str): |
| try: |
| import json |
|
|
| auth = json.loads(auth) or {} |
| except Exception: |
| auth = {} |
| else: |
| auth = {} |
| if name: |
| enabled = coerce_bool(account.get("enabled", True), True) |
| unfreeze_at = account.get("unfreeze_at") |
| if isinstance(unfreeze_at, (int, float)): |
| unfreeze_at = int(unfreeze_at) |
| else: |
| unfreeze_at = None |
| accounts.append( |
| AccountConfig( |
| name=name, |
| type=type_, |
| auth=auth, |
| enabled=enabled, |
| unfreeze_at=unfreeze_at, |
| ) |
| ) |
| groups.append( |
| ProxyGroupConfig( |
| proxy_host=str(group.get("proxy_host", "")), |
| proxy_user=str(group.get("proxy_user", "")), |
| proxy_pass=str(group.get("proxy_pass", "")), |
| fingerprint_id=str(group.get("fingerprint_id", "")), |
| use_proxy=coerce_bool(group.get("use_proxy", True), True), |
| timezone=group.get("timezone"), |
| accounts=accounts, |
| ) |
| ) |
| return groups |
|
|