849 lines
29 KiB
Python
849 lines
29 KiB
Python
"""
|
|
统一存储服务 (Professional Storage Service)
|
|
支持 Local (TOML), Redis, MySQL, PostgreSQL
|
|
|
|
特性:
|
|
- 全异步 I/O (Async I/O)
|
|
- 连接池管理 (Connection Pooling)
|
|
- 分布式/本地锁 (Distributed/Local Locking)
|
|
- 内存优化 (序列化性能优化)
|
|
"""
|
|
|
|
import abc
|
|
import os
|
|
import asyncio
|
|
import hashlib
|
|
import time
|
|
import tomllib
|
|
import re
|
|
from typing import Any, Dict, Optional
|
|
from pathlib import Path
|
|
from enum import Enum
|
|
|
|
try:
|
|
import fcntl
|
|
except ImportError: # pragma: no cover - non-posix platforms
|
|
fcntl = None
|
|
from contextlib import asynccontextmanager
|
|
|
|
import orjson
|
|
import aiofiles
|
|
from app.core.logger import logger
|
|
|
|
# 数据目录(支持通过环境变量覆盖)
|
|
DEFAULT_DATA_DIR = Path(__file__).parent.parent.parent / "data"
|
|
DATA_DIR = Path(os.getenv("DATA_DIR", str(DEFAULT_DATA_DIR))).expanduser()
|
|
|
|
# 配置文件路径
|
|
CONFIG_FILE = DATA_DIR / "config.toml"
|
|
TOKEN_FILE = DATA_DIR / "token.json"
|
|
LOCK_DIR = DATA_DIR / ".locks"
|
|
|
|
|
|
# JSON 序列化优化助手函数
|
|
def json_dumps(obj: Any) -> str:
|
|
return orjson.dumps(obj).decode("utf-8")
|
|
|
|
|
|
def json_loads(obj: str | bytes) -> Any:
|
|
return orjson.loads(obj)
|
|
|
|
|
|
_TOML_BARE_KEY_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
|
|
|
|
def _toml_escape_string(value: str) -> str:
|
|
"""转义 TOML 字符串。"""
|
|
return (
|
|
value.replace("\\", "\\\\")
|
|
.replace('"', '\\"')
|
|
.replace("\n", "\\n")
|
|
.replace("\r", "\\r")
|
|
.replace("\t", "\\t")
|
|
)
|
|
|
|
|
|
def _toml_format_key(key: Any) -> str:
|
|
"""格式化 TOML 键名,必要时自动加引号。"""
|
|
text = str(key)
|
|
if _TOML_BARE_KEY_RE.fullmatch(text):
|
|
return text
|
|
return f'"{_toml_escape_string(text)}"'
|
|
|
|
|
|
def _toml_format_value(value: Any) -> str:
|
|
"""将 Python 值序列化为 TOML 字面量。"""
|
|
if isinstance(value, bool):
|
|
return "true" if value else "false"
|
|
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
return str(value)
|
|
if isinstance(value, str):
|
|
return f'"{_toml_escape_string(value)}"'
|
|
if isinstance(value, list):
|
|
return "[" + ", ".join(_toml_format_value(item) for item in value) + "]"
|
|
if isinstance(value, dict):
|
|
parts = [
|
|
f"{_toml_format_key(key)} = {_toml_format_value(item)}"
|
|
for key, item in value.items()
|
|
]
|
|
return "{ " + ", ".join(parts) + " }"
|
|
if value is None:
|
|
return '""'
|
|
return f'"{_toml_escape_string(str(value))}"'
|
|
|
|
|
|
class StorageError(Exception):
|
|
"""存储服务基础异常"""
|
|
|
|
pass
|
|
|
|
|
|
class BaseStorage(abc.ABC):
|
|
"""存储基类"""
|
|
|
|
@abc.abstractmethod
|
|
async def load_config(self) -> Dict[str, Any]:
|
|
"""加载配置"""
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
async def save_config(self, data: Dict[str, Any]):
|
|
"""保存配置"""
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
async def load_tokens(self) -> Dict[str, Any]:
|
|
"""加载所有 Token"""
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
async def save_tokens(self, data: Dict[str, Any]):
|
|
"""保存所有 Token"""
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
async def close(self):
|
|
"""关闭资源"""
|
|
pass
|
|
|
|
@asynccontextmanager
|
|
async def acquire_lock(self, name: str, timeout: int = 10):
|
|
"""
|
|
获取锁 (互斥访问)
|
|
用于读写操作的临界区保护
|
|
|
|
Args:
|
|
name: 锁名称
|
|
timeout: 超时时间 (秒)
|
|
"""
|
|
# 默认空实现,用于 fallback
|
|
yield
|
|
|
|
async def verify_connection(self) -> bool:
|
|
"""健康检查"""
|
|
return True
|
|
|
|
|
|
class LocalStorage(BaseStorage):
|
|
"""
|
|
本地文件存储
|
|
- 使用 aiofiles 进行异步 I/O
|
|
- 使用 asyncio.Lock 进行进程内并发控制
|
|
- 如果需要多进程安全,需要系统级文件锁 (fcntl)
|
|
"""
|
|
|
|
def __init__(self):
|
|
self._lock = asyncio.Lock()
|
|
|
|
@asynccontextmanager
|
|
async def acquire_lock(self, name: str, timeout: int = 10):
|
|
if fcntl is None:
|
|
try:
|
|
async with asyncio.timeout(timeout):
|
|
async with self._lock:
|
|
yield
|
|
except asyncio.TimeoutError:
|
|
logger.warning(f"LocalStorage: 获取锁 '{name}' 超时 ({timeout}s)")
|
|
raise StorageError(f"无法获取锁 '{name}'")
|
|
return
|
|
|
|
lock_path = LOCK_DIR / f"{name}.lock"
|
|
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
fd = None
|
|
locked = False
|
|
start = time.monotonic()
|
|
|
|
async with self._lock:
|
|
try:
|
|
fd = open(lock_path, "a+")
|
|
while True:
|
|
try:
|
|
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
locked = True
|
|
break
|
|
except BlockingIOError:
|
|
if time.monotonic() - start >= timeout:
|
|
raise StorageError(f"无法获取锁 '{name}'")
|
|
await asyncio.sleep(0.05)
|
|
yield
|
|
except StorageError:
|
|
logger.warning(f"LocalStorage: 获取锁 '{name}' 超时 ({timeout}s)")
|
|
raise
|
|
finally:
|
|
if fd:
|
|
if locked:
|
|
try:
|
|
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
fd.close()
|
|
except Exception:
|
|
pass
|
|
|
|
async def load_config(self) -> Dict[str, Any]:
|
|
if not CONFIG_FILE.exists():
|
|
return {}
|
|
try:
|
|
async with aiofiles.open(CONFIG_FILE, "rb") as f:
|
|
content = await f.read()
|
|
return tomllib.loads(content.decode("utf-8"))
|
|
except Exception as e:
|
|
logger.error(f"LocalStorage: 加载配置失败: {e}")
|
|
return {}
|
|
|
|
async def save_config(self, data: Dict[str, Any]):
|
|
try:
|
|
lines = []
|
|
for section, items in data.items():
|
|
if not isinstance(items, dict):
|
|
continue
|
|
lines.append(f"[{_toml_format_key(section)}]")
|
|
for key, val in items.items():
|
|
lines.append(
|
|
f"{_toml_format_key(key)} = {_toml_format_value(val)}"
|
|
)
|
|
lines.append("")
|
|
|
|
content = "\n".join(lines)
|
|
|
|
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
async with aiofiles.open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
|
await f.write(content)
|
|
except Exception as e:
|
|
logger.error(f"LocalStorage: 保存配置失败: {e}")
|
|
raise StorageError(f"保存配置失败: {e}")
|
|
|
|
async def load_tokens(self) -> Dict[str, Any]:
|
|
if not TOKEN_FILE.exists():
|
|
return {}
|
|
try:
|
|
async with aiofiles.open(TOKEN_FILE, "rb") as f:
|
|
content = await f.read()
|
|
return json_loads(content)
|
|
except Exception as e:
|
|
logger.error(f"LocalStorage: 加载 Token 失败: {e}")
|
|
return {}
|
|
|
|
async def save_tokens(self, data: Dict[str, Any]):
|
|
try:
|
|
TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
temp_path = TOKEN_FILE.with_suffix(".tmp")
|
|
|
|
# 原子写操作: 写入临时文件 -> 重命名
|
|
async with aiofiles.open(temp_path, "wb") as f:
|
|
await f.write(orjson.dumps(data, option=orjson.OPT_INDENT_2))
|
|
|
|
# 使用 os.replace 保证原子性
|
|
os.replace(temp_path, TOKEN_FILE)
|
|
|
|
except Exception as e:
|
|
logger.error(f"LocalStorage: 保存 Token 失败: {e}")
|
|
raise StorageError(f"保存 Token 失败: {e}")
|
|
|
|
async def close(self):
|
|
pass
|
|
|
|
|
|
class RedisStorage(BaseStorage):
|
|
"""
|
|
Redis 存储
|
|
- 使用 redis-py 异步客户端 (自带连接池)
|
|
- 支持分布式锁 (redis.lock)
|
|
- 扁平化数据结构优化性能
|
|
"""
|
|
|
|
def __init__(self, url: str):
|
|
try:
|
|
from redis import asyncio as aioredis
|
|
except ImportError:
|
|
raise ImportError("需要安装 redis 包: pip install redis")
|
|
|
|
# 显式配置连接池
|
|
# 使用 decode_responses=True 简化字符串处理,但在处理复杂对象时使用 orjson
|
|
self.redis = aioredis.from_url(
|
|
url, decode_responses=True, health_check_interval=30
|
|
)
|
|
self.config_key = "grok2api:config" # Hash: section.key -> value_json
|
|
self.key_pools = "grok2api:pools" # Set: pool_names
|
|
self.prefix_pool_set = "grok2api:pool:" # Set: pool -> token_ids
|
|
self.prefix_token_hash = "grok2api:token:" # Hash: token_id -> token_data
|
|
self.lock_prefix = "grok2api:lock:"
|
|
|
|
@asynccontextmanager
|
|
async def acquire_lock(self, name: str, timeout: int = 10):
|
|
# 使用 Redis 分布式锁
|
|
lock_key = f"{self.lock_prefix}{name}"
|
|
lock = self.redis.lock(lock_key, timeout=timeout, blocking_timeout=5)
|
|
acquired = False
|
|
try:
|
|
acquired = await lock.acquire()
|
|
if not acquired:
|
|
raise StorageError(f"RedisStorage: 无法获取锁 '{name}'")
|
|
yield
|
|
finally:
|
|
if acquired:
|
|
try:
|
|
await lock.release()
|
|
except Exception:
|
|
# 锁可能已过期或被意外释放,忽略异常
|
|
pass
|
|
|
|
async def verify_connection(self) -> bool:
|
|
try:
|
|
return await self.redis.ping()
|
|
except Exception:
|
|
return False
|
|
|
|
async def load_config(self) -> Dict[str, Any]:
|
|
"""从 Redis Hash 加载配置"""
|
|
try:
|
|
raw_data = await self.redis.hgetall(self.config_key)
|
|
if not raw_data:
|
|
return None
|
|
|
|
config = {}
|
|
for composite_key, val_str in raw_data.items():
|
|
if "." not in composite_key:
|
|
continue
|
|
section, key = composite_key.split(".", 1)
|
|
|
|
if section not in config:
|
|
config[section] = {}
|
|
|
|
try:
|
|
val = json_loads(val_str)
|
|
except Exception:
|
|
val = val_str
|
|
config[section][key] = val
|
|
return config
|
|
except Exception as e:
|
|
logger.error(f"RedisStorage: 加载配置失败: {e}")
|
|
return None
|
|
|
|
async def save_config(self, data: Dict[str, Any]):
|
|
"""保存配置到 Redis Hash"""
|
|
if not data:
|
|
return
|
|
try:
|
|
mapping = {}
|
|
for section, items in data.items():
|
|
if not isinstance(items, dict):
|
|
continue
|
|
for key, val in items.items():
|
|
composite_key = f"{section}.{key}"
|
|
mapping[composite_key] = json_dumps(val)
|
|
|
|
if mapping:
|
|
await self.redis.hset(self.config_key, mapping=mapping)
|
|
except Exception as e:
|
|
logger.error(f"RedisStorage: 保存配置失败: {e}")
|
|
raise
|
|
|
|
async def load_tokens(self) -> Dict[str, Any]:
|
|
"""加载所有 Token"""
|
|
try:
|
|
pool_names = await self.redis.smembers(self.key_pools)
|
|
if not pool_names:
|
|
return None
|
|
|
|
pools = {}
|
|
async with self.redis.pipeline() as pipe:
|
|
for pool_name in pool_names:
|
|
# 获取该池下所有 Token ID
|
|
pipe.smembers(f"{self.prefix_pool_set}{pool_name}")
|
|
pool_tokens_res = await pipe.execute()
|
|
|
|
# 收集所有 Token ID 以便批量查询
|
|
all_token_ids = []
|
|
pool_map = {} # pool_name -> list[token_id]
|
|
|
|
for i, pool_name in enumerate(pool_names):
|
|
tids = list(pool_tokens_res[i])
|
|
pool_map[pool_name] = tids
|
|
all_token_ids.extend(tids)
|
|
|
|
if not all_token_ids:
|
|
return {name: [] for name in pool_names}
|
|
|
|
# 批量获取 Token 详情 (Hash)
|
|
async with self.redis.pipeline() as pipe:
|
|
for tid in all_token_ids:
|
|
pipe.hgetall(f"{self.prefix_token_hash}{tid}")
|
|
token_data_list = await pipe.execute()
|
|
|
|
# 重组数据结构
|
|
token_lookup = {}
|
|
for i, tid in enumerate(all_token_ids):
|
|
t_data = token_data_list[i]
|
|
if not t_data:
|
|
continue
|
|
|
|
# 恢复 tags (JSON -> List)
|
|
if "tags" in t_data:
|
|
try:
|
|
t_data["tags"] = json_loads(t_data["tags"])
|
|
except Exception:
|
|
t_data["tags"] = []
|
|
|
|
# 类型转换 (Redis 返回全 string)
|
|
for int_field in [
|
|
"quota",
|
|
"created_at",
|
|
"use_count",
|
|
"fail_count",
|
|
"last_used_at",
|
|
"last_fail_at",
|
|
"last_sync_at",
|
|
]:
|
|
if t_data.get(int_field) and t_data[int_field] != "None":
|
|
try:
|
|
t_data[int_field] = int(t_data[int_field])
|
|
except Exception:
|
|
pass
|
|
|
|
token_lookup[tid] = t_data
|
|
|
|
# 按 Pool 分组返回
|
|
for pool_name in pool_names:
|
|
pools[pool_name] = []
|
|
for tid in pool_map[pool_name]:
|
|
if tid in token_lookup:
|
|
pools[pool_name].append(token_lookup[tid])
|
|
|
|
return pools
|
|
|
|
except Exception as e:
|
|
logger.error(f"RedisStorage: 加载 Token 失败: {e}")
|
|
return None
|
|
|
|
async def save_tokens(self, data: Dict[str, Any]):
|
|
"""保存所有 Token"""
|
|
if data is None:
|
|
return
|
|
try:
|
|
new_pools = set(data.keys()) if isinstance(data, dict) else set()
|
|
pool_tokens_map = {}
|
|
new_token_ids = set()
|
|
|
|
for pool_name, tokens in (data or {}).items():
|
|
tids_in_pool = []
|
|
for t in tokens:
|
|
token_str = t.get("token")
|
|
if not token_str:
|
|
continue
|
|
tids_in_pool.append(token_str)
|
|
new_token_ids.add(token_str)
|
|
pool_tokens_map[pool_name] = tids_in_pool
|
|
|
|
existing_pools = await self.redis.smembers(self.key_pools)
|
|
existing_pools = set(existing_pools) if existing_pools else set()
|
|
|
|
existing_token_ids = set()
|
|
if existing_pools:
|
|
async with self.redis.pipeline() as pipe:
|
|
for pool_name in existing_pools:
|
|
pipe.smembers(f"{self.prefix_pool_set}{pool_name}")
|
|
pool_tokens_res = await pipe.execute()
|
|
for tokens in pool_tokens_res:
|
|
existing_token_ids.update(list(tokens or []))
|
|
|
|
tokens_to_delete = existing_token_ids - new_token_ids
|
|
all_pools = existing_pools.union(new_pools)
|
|
|
|
async with self.redis.pipeline() as pipe:
|
|
# Reset pool index
|
|
pipe.delete(self.key_pools)
|
|
if new_pools:
|
|
pipe.sadd(self.key_pools, *new_pools)
|
|
|
|
# Reset pool sets
|
|
for pool_name in all_pools:
|
|
pipe.delete(f"{self.prefix_pool_set}{pool_name}")
|
|
for pool_name, tids_in_pool in pool_tokens_map.items():
|
|
if tids_in_pool:
|
|
pipe.sadd(f"{self.prefix_pool_set}{pool_name}", *tids_in_pool)
|
|
|
|
# Remove deleted token hashes
|
|
for token_str in tokens_to_delete:
|
|
pipe.delete(f"{self.prefix_token_hash}{token_str}")
|
|
|
|
# Upsert token hashes
|
|
for pool_name, tokens in (data or {}).items():
|
|
for t in tokens:
|
|
token_str = t.get("token")
|
|
if not token_str:
|
|
continue
|
|
t_flat = t.copy()
|
|
if "tags" in t_flat:
|
|
t_flat["tags"] = json_dumps(t_flat["tags"])
|
|
status = t_flat.get("status")
|
|
if isinstance(status, str) and status.startswith(
|
|
"TokenStatus."
|
|
):
|
|
t_flat["status"] = status.split(".", 1)[1].lower()
|
|
elif isinstance(status, Enum):
|
|
t_flat["status"] = status.value
|
|
t_flat = {k: str(v) for k, v in t_flat.items() if v is not None}
|
|
pipe.hset(
|
|
f"{self.prefix_token_hash}{token_str}", mapping=t_flat
|
|
)
|
|
|
|
await pipe.execute()
|
|
|
|
except Exception as e:
|
|
logger.error(f"RedisStorage: 保存 Token 失败: {e}")
|
|
raise
|
|
|
|
async def close(self):
|
|
try:
|
|
await self.redis.close()
|
|
except (RuntimeError, asyncio.CancelledError, Exception):
|
|
# 忽略关闭时的 Event loop is closed 错误
|
|
pass
|
|
|
|
|
|
class SQLStorage(BaseStorage):
|
|
"""
|
|
SQL 数据库存储 (MySQL/PgSQL)
|
|
- 使用 SQLAlchemy 异步引擎
|
|
- 自动 Schema 初始化
|
|
- 内置连接池 (QueuePool)
|
|
"""
|
|
|
|
def __init__(self, url: str):
|
|
try:
|
|
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
|
except ImportError:
|
|
raise ImportError(
|
|
"需要安装 sqlalchemy 和 async 驱动: pip install sqlalchemy[asyncio]"
|
|
)
|
|
|
|
self.dialect = url.split(":", 1)[0].split("+", 1)[0].lower()
|
|
|
|
# 配置 robust 的连接池
|
|
self.engine = create_async_engine(
|
|
url,
|
|
echo=False,
|
|
pool_size=20,
|
|
max_overflow=10,
|
|
pool_recycle=3600,
|
|
pool_pre_ping=True,
|
|
)
|
|
self.async_session = async_sessionmaker(self.engine, expire_on_commit=False)
|
|
self._initialized = False
|
|
|
|
async def _ensure_schema(self):
|
|
"""确保数据库表存在"""
|
|
if self._initialized:
|
|
return
|
|
try:
|
|
async with self.engine.begin() as conn:
|
|
from sqlalchemy import text
|
|
|
|
# Tokens 表 (通用 SQL)
|
|
await conn.execute(
|
|
text("""
|
|
CREATE TABLE IF NOT EXISTS tokens (
|
|
token VARCHAR(512) PRIMARY KEY,
|
|
pool_name VARCHAR(64) NOT NULL,
|
|
data TEXT,
|
|
updated_at BIGINT
|
|
)
|
|
""")
|
|
)
|
|
|
|
# 配置表
|
|
await conn.execute(
|
|
text("""
|
|
CREATE TABLE IF NOT EXISTS app_config (
|
|
section VARCHAR(64) NOT NULL,
|
|
key_name VARCHAR(64) NOT NULL,
|
|
value TEXT,
|
|
PRIMARY KEY (section, key_name)
|
|
)
|
|
""")
|
|
)
|
|
|
|
# 索引
|
|
try:
|
|
await conn.execute(
|
|
text("CREATE INDEX idx_tokens_pool ON tokens (pool_name)")
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
# 尝试兼容旧表结构
|
|
try:
|
|
if self.dialect in ("mysql", "mariadb"):
|
|
await conn.execute(
|
|
text("ALTER TABLE tokens MODIFY token VARCHAR(512)")
|
|
)
|
|
await conn.execute(text("ALTER TABLE tokens MODIFY data TEXT"))
|
|
elif self.dialect in ("postgres", "postgresql", "pgsql"):
|
|
await conn.execute(
|
|
text(
|
|
"ALTER TABLE tokens ALTER COLUMN token TYPE VARCHAR(512)"
|
|
)
|
|
)
|
|
await conn.execute(
|
|
text("ALTER TABLE tokens ALTER COLUMN data TYPE TEXT")
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
self._initialized = True
|
|
except Exception as e:
|
|
logger.error(f"SQLStorage: Schema 初始化失败: {e}")
|
|
raise
|
|
|
|
@asynccontextmanager
|
|
async def acquire_lock(self, name: str, timeout: int = 10):
|
|
# SQL 分布式锁: MySQL GET_LOCK / PG advisory_lock
|
|
from sqlalchemy import text
|
|
|
|
lock_name = f"g2a:{hashlib.sha1(name.encode('utf-8')).hexdigest()[:24]}"
|
|
if self.dialect in ("mysql", "mariadb"):
|
|
async with self.async_session() as session:
|
|
res = await session.execute(
|
|
text("SELECT GET_LOCK(:name, :timeout)"),
|
|
{"name": lock_name, "timeout": timeout},
|
|
)
|
|
got = res.scalar()
|
|
if got != 1:
|
|
raise StorageError(f"SQLStorage: 无法获取锁 '{name}'")
|
|
try:
|
|
yield
|
|
finally:
|
|
try:
|
|
await session.execute(
|
|
text("SELECT RELEASE_LOCK(:name)"), {"name": lock_name}
|
|
)
|
|
await session.commit()
|
|
except Exception:
|
|
pass
|
|
elif self.dialect in ("postgres", "postgresql", "pgsql"):
|
|
lock_key = int.from_bytes(
|
|
hashlib.sha256(name.encode("utf-8")).digest()[:8], "big", signed=False
|
|
)
|
|
async with self.async_session() as session:
|
|
start = time.monotonic()
|
|
while True:
|
|
res = await session.execute(
|
|
text("SELECT pg_try_advisory_lock(:key)"), {"key": lock_key}
|
|
)
|
|
if res.scalar():
|
|
break
|
|
if time.monotonic() - start >= timeout:
|
|
raise StorageError(f"SQLStorage: 无法获取锁 '{name}'")
|
|
await asyncio.sleep(0.1)
|
|
try:
|
|
yield
|
|
finally:
|
|
try:
|
|
await session.execute(
|
|
text("SELECT pg_advisory_unlock(:key)"), {"key": lock_key}
|
|
)
|
|
await session.commit()
|
|
except Exception:
|
|
pass
|
|
else:
|
|
yield
|
|
|
|
async def load_config(self) -> Dict[str, Any]:
|
|
await self._ensure_schema()
|
|
from sqlalchemy import text
|
|
|
|
try:
|
|
async with self.async_session() as session:
|
|
res = await session.execute(
|
|
text("SELECT section, key_name, value FROM app_config")
|
|
)
|
|
rows = res.fetchall()
|
|
if not rows:
|
|
return None
|
|
|
|
config = {}
|
|
for section, key, val_str in rows:
|
|
if section not in config:
|
|
config[section] = {}
|
|
try:
|
|
val = json_loads(val_str)
|
|
except Exception:
|
|
val = val_str
|
|
config[section][key] = val
|
|
return config
|
|
except Exception as e:
|
|
logger.error(f"SQLStorage: 加载配置失败: {e}")
|
|
return None
|
|
|
|
async def save_config(self, data: Dict[str, Any]):
|
|
await self._ensure_schema()
|
|
from sqlalchemy import text
|
|
|
|
try:
|
|
async with self.async_session() as session:
|
|
for section, items in data.items():
|
|
if not isinstance(items, dict):
|
|
continue
|
|
for key, val in items.items():
|
|
val_str = json_dumps(val)
|
|
|
|
# Upsert 逻辑 (简单实现: Delete + Insert)
|
|
await session.execute(
|
|
text(
|
|
"DELETE FROM app_config WHERE section=:s AND key_name=:k"
|
|
),
|
|
{"s": section, "k": key},
|
|
)
|
|
await session.execute(
|
|
text(
|
|
"INSERT INTO app_config (section, key_name, value) VALUES (:s, :k, :v)"
|
|
),
|
|
{"s": section, "k": key, "v": val_str},
|
|
)
|
|
await session.commit()
|
|
except Exception as e:
|
|
logger.error(f"SQLStorage: 保存配置失败: {e}")
|
|
raise
|
|
|
|
async def load_tokens(self) -> Dict[str, Any]:
|
|
await self._ensure_schema()
|
|
from sqlalchemy import text
|
|
|
|
try:
|
|
async with self.async_session() as session:
|
|
res = await session.execute(text("SELECT pool_name, data FROM tokens"))
|
|
rows = res.fetchall()
|
|
if not rows:
|
|
return None
|
|
|
|
pools = {}
|
|
for pool_name, data_json in rows:
|
|
if pool_name not in pools:
|
|
pools[pool_name] = []
|
|
|
|
try:
|
|
if isinstance(data_json, str):
|
|
t_data = json_loads(data_json)
|
|
else:
|
|
t_data = data_json
|
|
pools[pool_name].append(t_data)
|
|
except Exception:
|
|
pass
|
|
return pools
|
|
except Exception as e:
|
|
logger.error(f"SQLStorage: 加载 Token 失败: {e}")
|
|
return None
|
|
|
|
async def save_tokens(self, data: Dict[str, Any]):
|
|
await self._ensure_schema()
|
|
from sqlalchemy import text
|
|
|
|
try:
|
|
async with self.async_session() as session:
|
|
await session.execute(text("DELETE FROM tokens"))
|
|
|
|
params = []
|
|
for pool_name, tokens in data.items():
|
|
for t in tokens:
|
|
params.append(
|
|
{
|
|
"token": t.get("token"),
|
|
"pool_name": pool_name,
|
|
"data": json_dumps(t),
|
|
"updated_at": 0,
|
|
}
|
|
)
|
|
|
|
if params:
|
|
# 批量插入
|
|
await session.execute(
|
|
text(
|
|
"INSERT INTO tokens (token, pool_name, data, updated_at) VALUES (:token, :pool_name, :data, :updated_at)"
|
|
),
|
|
params,
|
|
)
|
|
await session.commit()
|
|
except Exception as e:
|
|
logger.error(f"SQLStorage: 保存 Token 失败: {e}")
|
|
raise
|
|
|
|
async def close(self):
|
|
await self.engine.dispose()
|
|
|
|
|
|
class StorageFactory:
|
|
"""存储后端工厂"""
|
|
|
|
_instance: Optional[BaseStorage] = None
|
|
|
|
@staticmethod
|
|
def _normalize_sql_url(storage_type: str, url: str) -> str:
|
|
if not url or "://" not in url:
|
|
return url
|
|
if storage_type == "mysql":
|
|
if url.startswith("mysql://"):
|
|
return f"mysql+aiomysql://{url[len('mysql://') :]}"
|
|
if url.startswith("mariadb://"):
|
|
return f"mariadb+aiomysql://{url[len('mariadb://') :]}"
|
|
if storage_type == "pgsql":
|
|
if url.startswith("postgres://"):
|
|
return f"postgresql+asyncpg://{url[len('postgres://') :]}"
|
|
if url.startswith("postgresql://"):
|
|
return f"postgresql+asyncpg://{url[len('postgresql://') :]}"
|
|
if url.startswith("pgsql://"):
|
|
return f"postgresql+asyncpg://{url[len('pgsql://') :]}"
|
|
return url
|
|
|
|
@classmethod
|
|
def get_storage(cls) -> BaseStorage:
|
|
"""获取全局存储实例 (单例)"""
|
|
if cls._instance:
|
|
return cls._instance
|
|
|
|
storage_type = os.getenv("SERVER_STORAGE_TYPE", "local").lower()
|
|
storage_url = os.getenv("SERVER_STORAGE_URL", "")
|
|
|
|
logger.info(f"StorageFactory: 初始化存储后端: {storage_type}")
|
|
|
|
if storage_type == "redis":
|
|
if not storage_url:
|
|
raise ValueError("Redis 存储需要设置 SERVER_STORAGE_URL")
|
|
cls._instance = RedisStorage(storage_url)
|
|
|
|
elif storage_type in ("mysql", "pgsql"):
|
|
if not storage_url:
|
|
raise ValueError("SQL 存储需要设置 SERVER_STORAGE_URL")
|
|
storage_url = cls._normalize_sql_url(storage_type, storage_url)
|
|
cls._instance = SQLStorage(storage_url)
|
|
|
|
else:
|
|
cls._instance = LocalStorage()
|
|
|
|
return cls._instance
|
|
|
|
|
|
def get_storage() -> BaseStorage:
|
|
return StorageFactory.get_storage()
|