web2api / core /config /repository.py
ohmyapi's picture
feat: align hosted Space deployment with latest upstream
77169b4
"""
配置持久化:默认使用 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