wx_qr_session.py 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. """web-admin 扫码登录会话:短码映射 + 状态机;优先 Redis,否则进程内存。"""
  2. from __future__ import annotations
  3. import json
  4. import secrets
  5. import time
  6. import uuid
  7. from typing import Any, Dict, Optional
  8. from app.core.config import settings
  9. _PREFIX = "wxqr:sess:"
  10. _SHORT_PREFIX = "wxqr:short:"
  11. _memory: Dict[str, Dict[str, Any]] = {}
  12. _short_map: Dict[str, str] = {}
  13. def _memory_clear_for_tests() -> None:
  14. _memory.clear()
  15. _short_map.clear()
  16. def _ttl() -> int:
  17. return int(getattr(settings, "WX_QR_SESSION_TTL_SEC", 180) or 180)
  18. def _redis():
  19. """复用 auth 侧 Redis 客户端模式;无 REDIS_HOST 返回 None。"""
  20. host = (settings.REDIS_HOST or "").strip()
  21. if not host:
  22. return None
  23. try:
  24. import redis
  25. return redis.Redis(
  26. host=host,
  27. port=int(settings.REDIS_PORT or 6379),
  28. db=int(settings.REDIS_DB or 0),
  29. password=settings.REDIS_PASSWORD or None,
  30. decode_responses=True,
  31. socket_timeout=2,
  32. )
  33. except Exception:
  34. return None
  35. def create_session(*, ttl_sec: Optional[int] = None) -> tuple:
  36. ttl = int(ttl_sec if ttl_sec is not None else _ttl())
  37. session_id = str(uuid.uuid4())
  38. short = secrets.token_urlsafe(16)[:32]
  39. now = int(time.time())
  40. data = {
  41. "sessionId": session_id,
  42. "shortCode": short,
  43. "status": "pending",
  44. "message": "",
  45. "createdAt": now,
  46. "expiresAt": now + ttl,
  47. "tokens": None,
  48. }
  49. r = _redis()
  50. if r:
  51. pipe = r.pipeline()
  52. pipe.setex(_PREFIX + session_id, ttl, json.dumps(data, ensure_ascii=False))
  53. pipe.setex(_SHORT_PREFIX + short, ttl, session_id)
  54. pipe.execute()
  55. else:
  56. _memory[session_id] = data
  57. _short_map[short] = session_id
  58. return session_id, short
  59. def resolve_session_id(short_or_id: str) -> Optional[str]:
  60. key = (short_or_id or "").strip()
  61. if not key:
  62. return None
  63. r = _redis()
  64. if r:
  65. sid = r.get(_SHORT_PREFIX + key)
  66. if sid:
  67. return sid
  68. if r.exists(_PREFIX + key):
  69. return key
  70. return None
  71. if key in _short_map:
  72. return _short_map[key]
  73. if key in _memory:
  74. return key
  75. return None
  76. def get_session(session_id: str) -> Optional[Dict[str, Any]]:
  77. sid = (session_id or "").strip()
  78. if not sid:
  79. return None
  80. r = _redis()
  81. if r:
  82. raw = r.get(_PREFIX + sid)
  83. if not raw:
  84. return None
  85. data = json.loads(raw)
  86. else:
  87. data = _memory.get(sid)
  88. if not data:
  89. return None
  90. data = dict(data)
  91. if int(time.time()) >= int(data.get("expiresAt") or 0):
  92. data["status"] = "expired"
  93. return data
  94. return data
  95. def _save(data: Dict[str, Any]) -> None:
  96. sid = data["sessionId"]
  97. ttl = max(1, int(data["expiresAt"]) - int(time.time()))
  98. r = _redis()
  99. if r:
  100. r.setex(_PREFIX + sid, ttl, json.dumps(data, ensure_ascii=False))
  101. else:
  102. _memory[sid] = data
  103. def mark_scanned(session_id: str) -> bool:
  104. data = get_session(session_id)
  105. if not data or data["status"] in ("expired", "success", "consumed", "failed"):
  106. return False
  107. if data["status"] == "pending":
  108. data["status"] = "scanned"
  109. _save(data)
  110. return True
  111. def mark_success(session_id: str, tokens: Dict[str, Any]) -> bool:
  112. data = get_session(session_id)
  113. if not data or data["status"] in ("expired", "consumed", "failed"):
  114. return False
  115. data["status"] = "success"
  116. data["tokens"] = tokens
  117. data["message"] = ""
  118. _save(data)
  119. return True
  120. def mark_failed(session_id: str, message: str) -> bool:
  121. data = get_session(session_id)
  122. if not data or data["status"] in ("expired", "consumed", "success"):
  123. return False
  124. data["status"] = "failed"
  125. data["message"] = message or "登录失败"
  126. data["tokens"] = None
  127. _save(data)
  128. return True
  129. def consume_success(session_id: str) -> Optional[Dict[str, Any]]:
  130. data = get_session(session_id)
  131. if not data or data["status"] != "success":
  132. return None
  133. tokens = data.get("tokens")
  134. data["status"] = "consumed"
  135. data["tokens"] = None
  136. _save(data)
  137. return tokens
  138. def invalidate(session_id: str) -> None:
  139. data = get_session(session_id)
  140. if not data:
  141. return
  142. data["status"] = "expired"
  143. data["tokens"] = None
  144. _save(data)