session_store.py 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. import asyncio
  2. import time
  3. from dataclasses import dataclass
  4. from typing import Awaitable, Callable, Dict, Optional
  5. from fastapi import WebSocket
  6. @dataclass
  7. class SessionEntry:
  8. token: str
  9. expires_at: float # unix timestamp (seconds)
  10. class SessionStore:
  11. """
  12. 简单的会话存储,支持单用户单活 token。
  13. - 默认使用内存字典缓存当前进程内活跃会话
  14. - 同步使用 Redis 持久化 token,支持进程重启后继续校验
  15. """
  16. def __init__(
  17. self,
  18. default_ttl_seconds: int = 1800,
  19. redis_getter: Optional[Callable[[], Awaitable[object]]] = None,
  20. ) -> None:
  21. self._sessions: Dict[str, SessionEntry] = {}
  22. self._connections: Dict[str, WebSocket] = {}
  23. self._default_ttl = default_ttl_seconds
  24. # 保护连接映射的锁,避免并发覆盖
  25. self._lock = asyncio.Lock()
  26. # 获取 Redis 客户端的异步函数(来自 DatabaseManager.get_redis)
  27. self._redis_getter = redis_getter
  28. def _now(self) -> float:
  29. return time.time()
  30. async def _get_redis(self):
  31. """获取 Redis 客户端;如果未配置或出错则返回 None。"""
  32. if not self._redis_getter:
  33. return None
  34. try:
  35. redis = await self._redis_getter()
  36. return redis
  37. except Exception:
  38. # Redis 故障时退回内存模式,不影响主流程
  39. return None
  40. def _redis_key(self, user_id: str) -> str:
  41. return f"session:{user_id}"
  42. async def set_current_token(
  43. self,
  44. user_id: str,
  45. token: str,
  46. ttl_seconds: Optional[int] = None,
  47. ) -> None:
  48. """设置当前用户的会话 token,写入 Redis 并更新本地缓存。"""
  49. ttl = ttl_seconds or self._default_ttl
  50. expires_at = self._now() + ttl
  51. self._sessions[user_id] = SessionEntry(token=token, expires_at=expires_at)
  52. redis = await self._get_redis()
  53. if redis:
  54. # 使用 Redis 仅存储 token,过期时间通过 TTL 控制
  55. try:
  56. await redis.set(self._redis_key(user_id), token, ex=ttl)
  57. except Exception:
  58. # Redis 写入失败时不影响当前进程内会话
  59. pass
  60. async def get_current_token(self, user_id: str) -> Optional[SessionEntry]:
  61. """
  62. 获取当前用户的会话信息。
  63. 优先从内存缓存读取;若不存在则尝试从 Redis 恢复。
  64. """
  65. entry = self._sessions.get(user_id)
  66. now = self._now()
  67. if entry:
  68. if entry.expires_at < now:
  69. # 已过期,清理本地和 Redis
  70. self._sessions.pop(user_id, None)
  71. self._connections.pop(user_id, None)
  72. redis = await self._get_redis()
  73. if redis:
  74. try:
  75. await redis.delete(self._redis_key(user_id))
  76. except Exception:
  77. pass
  78. return None
  79. return entry
  80. # 内存中没有,尝试从 Redis 恢复(支持进程重启)
  81. redis = await self._get_redis()
  82. if not redis:
  83. return None
  84. try:
  85. key = self._redis_key(user_id)
  86. token = await redis.get(key)
  87. if not token:
  88. return None
  89. ttl = await redis.ttl(key)
  90. # ttl: 剩余秒数,-1=无过期,-2=不存在
  91. if ttl is None or ttl <= 0:
  92. await redis.delete(key)
  93. return None
  94. entry = SessionEntry(token=token, expires_at=now + ttl)
  95. self._sessions[user_id] = entry
  96. return entry
  97. except Exception:
  98. # Redis 故障时直接视为无会话
  99. return None
  100. async def validate(
  101. self,
  102. user_id: Optional[str],
  103. token: Optional[str],
  104. refresh: bool = False,
  105. ) -> bool:
  106. """校验 token 是否有效,可选滑动续期(刷新 TTL)。"""
  107. if not user_id or not token:
  108. return False
  109. entry = await self.get_current_token(user_id)
  110. if not entry:
  111. return False
  112. if entry.token != token:
  113. return False
  114. if refresh:
  115. # 滑动续期:刷新本地过期时间和 Redis TTL
  116. now = self._now()
  117. entry.expires_at = now + self._default_ttl
  118. self._sessions[user_id] = entry
  119. redis = await self._get_redis()
  120. if redis:
  121. try:
  122. await redis.expire(self._redis_key(user_id), self._default_ttl)
  123. except Exception:
  124. pass
  125. return True
  126. async def bind_connection(self, user_id: str, token: str, websocket: WebSocket) -> None:
  127. """
  128. 记录当前生效 token 对应的连接,用于后续定向下线。
  129. 只有当 token 等于当前存储的 token 时才绑定。
  130. """
  131. async with self._lock:
  132. entry = await self.get_current_token(user_id)
  133. if not entry or entry.token != token:
  134. return
  135. # 关闭旧连接
  136. prev_conn = self._connections.get(user_id)
  137. if prev_conn and prev_conn is not websocket:
  138. try:
  139. await prev_conn.close(code=4401, reason="session_invalidated")
  140. except Exception:
  141. pass
  142. self._connections[user_id] = websocket
  143. async def invalidate_connection(self, user_id: str) -> None:
  144. """
  145. 主动关闭某个用户的现有连接(用于新 token 签发后踢旧连)。
  146. """
  147. async with self._lock:
  148. conn = self._connections.get(user_id)
  149. if conn:
  150. try:
  151. await conn.close(code=4401, reason="session_invalidated")
  152. except Exception:
  153. pass
  154. self._connections.pop(user_id, None)