"""会话存储:管理 Cloudflare 验证通过后的浏览器会话状态 支持两种存储后端: - Redis(推荐生产环境使用,支持多实例共享) - 进程内存(默认回退,仅单实例可用) 会话包含浏览器存储状态(Cookie、localStorage 等)和 cf_clearance 值, 设置 TTL 自动过期,过期后需要重新通过 Cloudflare 挑战。 """ from __future__ import annotations import json from dataclasses import asdict, dataclass from datetime import UTC, datetime, timedelta from typing import Any from app.core.config import Settings try: from redis.asyncio import Redis except Exception: # pragma: no cover - optional until dependencies are installed. Redis = Any # type: ignore[misc,assignment] @dataclass(slots=True) class SessionState: """会话状态数据""" session_id: str user_agent: str proxy_key: str storage_state: dict[str, Any] cf_clearance: str | None created_at: str expires_at: str @property def is_expired(self) -> bool: """会话是否已过期""" return datetime.now(UTC) >= datetime.fromisoformat(self.expires_at) class SessionStore: """会话存储,支持 Redis 和进程内存两种后端""" def __init__(self, settings: Settings): self._settings = settings self._redis: Redis | None = None self._memory: dict[str, SessionState] = {} async def start(self) -> None: """初始化存储后端:配置了 Redis 时连接 Redis,否则使用进程内存""" if not self._settings.redis_host: return from redis.asyncio import Redis as AsyncRedis self._redis = AsyncRedis( host=self._settings.redis_host, port=self._settings.redis_port, password=self._settings.redis_password, db=self._settings.redis_db, decode_responses=True, ) await self._redis.ping() async def close(self) -> None: """关闭存储后端连接""" if self._redis is not None: await self._redis.aclose() self._redis = None @property def redis_enabled(self) -> bool: """Redis 是否已连接""" return self._redis is not None def _session_key(self, session_name: str) -> str: """生成 Redis/内存中的存储键""" return f"{self._settings.session_namespace}:session:{session_name}" async def get_session(self, session_name: str) -> SessionState | None: """获取会话状态,过期会话自动失效并返回 None""" key = self._session_key(session_name) if self._redis is not None: raw = await self._redis.get(key) if not raw: return None session = SessionState(**json.loads(raw)) if session.is_expired: await self.invalidate_session(session_name) return None return session session = self._memory.get(key) if session and session.is_expired: self._memory.pop(key, None) return None return session async def save_session( self, session_name: str, session_id: str, user_agent: str, proxy_key: str, storage_state: dict[str, Any], cf_clearance: str | None, ) -> SessionState: """保存会话状态 创建带 TTL 的会话记录,Redis 后端会自动在 TTL 到期后删除。 """ now = datetime.now(UTC) state = SessionState( session_id=session_id, user_agent=user_agent, proxy_key=proxy_key, storage_state=storage_state, cf_clearance=cf_clearance, created_at=now.isoformat(), expires_at=(now + timedelta(seconds=self._settings.session_ttl_seconds)).isoformat(), ) key = self._session_key(session_name) if self._redis is not None: ttl = self._settings.session_ttl_seconds await self._redis.set(key, json.dumps(asdict(state)), ex=ttl) return state self._memory[key] = state return state async def invalidate_session(self, session_name: str) -> None: """使会话失效,删除存储中的会话记录""" key = self._session_key(session_name) if self._redis is not None: await self._redis.delete(key) return self._memory.pop(key, None)