""" 统一存储服务 (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()