session_store.py 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. import asyncio
  2. import logging
  3. import time
  4. from dataclasses import dataclass
  5. from typing import Awaitable, Callable, Dict, Optional
  6. from fastapi import WebSocket
  7. logger = logging.getLogger(__name__)
  8. @dataclass
  9. class SessionEntry:
  10. token: str
  11. expires_at: float # unix timestamp (seconds)
  12. class SessionStore:
  13. """
  14. 简单的会话存储,支持单用户单活 token。
  15. - 多进程(gunicorn workers)下必须以 Redis 为权威来源;内存仅作缓存。
  16. - Redis 不可用时回退为单进程内存模式(仅适用于单 worker)。
  17. """
  18. def __init__(
  19. self,
  20. default_ttl_seconds: int = 1800,
  21. redis_getter: Optional[Callable[[], Awaitable[object]]] = None,
  22. ) -> None:
  23. self._sessions: Dict[str, SessionEntry] = {}
  24. self._connections: Dict[str, WebSocket] = {}
  25. self._default_ttl = default_ttl_seconds
  26. # 保护连接映射的锁,避免并发覆盖
  27. self._lock = asyncio.Lock()
  28. # 获取 Redis 客户端的异步函数(来自 DatabaseManager.get_redis)
  29. self._redis_getter = redis_getter
  30. def _now(self) -> float:
  31. return time.time()
  32. async def _get_redis(self):
  33. """获取 Redis 客户端;如果未配置或出错则返回 None。"""
  34. if not self._redis_getter:
  35. return None
  36. try:
  37. redis = await self._redis_getter()
  38. return redis
  39. except Exception:
  40. # Redis 故障时退回内存模式,不影响主流程
  41. return None
  42. def _redis_key(self, user_id: str) -> str:
  43. return f"session:{user_id}"
  44. async def set_current_token(
  45. self,
  46. user_id: str,
  47. token: str,
  48. ttl_seconds: Optional[int] = None,
  49. ) -> None:
  50. """设置当前用户的会话 token,写入 Redis 并更新本地缓存。"""
  51. ttl = ttl_seconds or self._default_ttl
  52. expires_at = self._now() + ttl
  53. self._sessions[user_id] = SessionEntry(token=token, expires_at=expires_at)
  54. redis = await self._get_redis()
  55. if redis:
  56. try:
  57. await redis.set(self._redis_key(user_id), token, ex=ttl)
  58. except Exception as e:
  59. logger.warning(
  60. "session_store: Redis SET failed user_id=%s (multi-worker needs Redis): %s",
  61. user_id,
  62. e,
  63. )
  64. async def get_current_token(self, user_id: str) -> Optional[SessionEntry]:
  65. """
  66. 获取当前用户的会话信息。
  67. Redis 可用时以 Redis 为准并回填本地缓存,保证多 worker 间 token 一致。
  68. """
  69. now = self._now()
  70. redis = await self._get_redis()
  71. if redis:
  72. try:
  73. key = self._redis_key(user_id)
  74. token = await redis.get(key)
  75. if not token:
  76. self._sessions.pop(user_id, None)
  77. self._connections.pop(user_id, None)
  78. return None
  79. ttl = await redis.ttl(key)
  80. # -2: 不存在(竞态)
  81. if ttl == -2:
  82. self._sessions.pop(user_id, None)
  83. return None
  84. # -1: 未设置过期时间,用默认 TTL 估算本地过期时间
  85. if ttl == -1:
  86. expires_at = now + float(self._default_ttl)
  87. elif ttl is None or ttl <= 0:
  88. try:
  89. await redis.delete(key)
  90. except Exception:
  91. pass
  92. self._sessions.pop(user_id, None)
  93. self._connections.pop(user_id, None)
  94. return None
  95. else:
  96. expires_at = now + float(ttl)
  97. entry = SessionEntry(token=token, expires_at=expires_at)
  98. self._sessions[user_id] = entry
  99. return entry
  100. except Exception as e:
  101. logger.warning(
  102. "session_store: Redis read failed user_id=%s, fallback to memory: %s",
  103. user_id,
  104. e,
  105. )
  106. entry = self._sessions.get(user_id)
  107. if not entry:
  108. return None
  109. if entry.expires_at < now:
  110. self._sessions.pop(user_id, None)
  111. self._connections.pop(user_id, None)
  112. redis = await self._get_redis()
  113. if redis:
  114. try:
  115. await redis.delete(self._redis_key(user_id))
  116. except Exception:
  117. pass
  118. return None
  119. return entry
  120. async def validate(
  121. self,
  122. user_id: Optional[str],
  123. token: Optional[str],
  124. refresh: bool = False,
  125. ) -> bool:
  126. """校验 token 是否有效,可选滑动续期(刷新 TTL)。"""
  127. if not user_id or not token:
  128. return False
  129. entry = await self.get_current_token(user_id)
  130. if not entry:
  131. return False
  132. if entry.token != token:
  133. return False
  134. if refresh:
  135. # 滑动续期:刷新本地过期时间和 Redis TTL
  136. now = self._now()
  137. entry.expires_at = now + self._default_ttl
  138. self._sessions[user_id] = entry
  139. redis = await self._get_redis()
  140. if redis:
  141. try:
  142. await redis.expire(self._redis_key(user_id), self._default_ttl)
  143. except Exception:
  144. pass
  145. return True
  146. async def bind_connection(self, user_id: str, token: str, websocket: WebSocket) -> None:
  147. """
  148. 记录当前生效 token 对应的连接,用于后续定向下线。
  149. 只有当 token 等于当前存储的 token 时才绑定。
  150. """
  151. async with self._lock:
  152. entry = await self.get_current_token(user_id)
  153. if not entry or entry.token != token:
  154. return
  155. # 关闭旧连接
  156. prev_conn = self._connections.get(user_id)
  157. if prev_conn and prev_conn is not websocket:
  158. try:
  159. await prev_conn.close(code=4401, reason="session_invalidated")
  160. except Exception:
  161. pass
  162. self._connections[user_id] = websocket
  163. async def invalidate_connection(self, user_id: str) -> None:
  164. """
  165. 主动关闭某个用户的现有连接(用于新 token 签发后踢旧连)。
  166. """
  167. async with self._lock:
  168. conn = self._connections.get(user_id)
  169. if conn:
  170. try:
  171. await conn.close(code=4401, reason="session_invalidated")
  172. except Exception:
  173. pass
  174. self._connections.pop(user_id, None)