session_service.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  1. """
  2. Session服务 - 用户会话管理
  3. """
  4. from datetime import datetime, timedelta, timezone
  5. BEIJING_TZ = timezone(timedelta(hours=8))
  6. from typing import Optional, Tuple
  7. from sqlalchemy.orm import Session as DBSession
  8. from sqlalchemy import and_, or_
  9. from app.database import get_db
  10. from app.models.session import Session
  11. from app.models.user import User
  12. from app.core.logger import logger
  13. from app.utils.timezone import beijing_now, beijing_now_naive
  14. # 会话配置(硬编码)
  15. SESSION_TIMEOUT_HOURS = 24 # 会话超时时间(小时)
  16. PREVENT_DUPLICATE_LOGIN = True # 是否防止重复登录
  17. MAX_SESSIONS_PER_USER = 1 # 每个用户最大会话数
  18. SESSION_EXTEND_ENABLED = True # 是否启用心跳延长会话
  19. SESSION_EXTEND_THRESHOLD_HOURS = 4 # 会话延长阈值(小时)
  20. SESSION_EXTEND_HOURS = 2 # 每次延长的小时数
  21. class SessionService:
  22. """Session服务类"""
  23. @staticmethod
  24. def get_session_config(db: DBSession = None) -> dict:
  25. """获取Session相关配置"""
  26. return {
  27. "session_timeout_hours": SESSION_TIMEOUT_HOURS,
  28. "prevent_duplicate_login": PREVENT_DUPLICATE_LOGIN,
  29. "max_sessions_per_user": MAX_SESSIONS_PER_USER,
  30. "session_extend_enabled": SESSION_EXTEND_ENABLED,
  31. "session_extend_threshold_hours": SESSION_EXTEND_THRESHOLD_HOURS,
  32. "session_extend_hours": SESSION_EXTEND_HOURS,
  33. }
  34. @staticmethod
  35. def create_session(
  36. db: DBSession,
  37. user_id: int,
  38. machine_code: Optional[str] = None,
  39. ip_address: Optional[str] = None,
  40. user_agent: Optional[str] = None
  41. ) -> Tuple[bool, str, Optional[str]]:
  42. """
  43. 创建新的用户会话
  44. Returns:
  45. tuple: (是否成功, 消息, session_id)
  46. """
  47. try:
  48. config = SessionService.get_session_config(db)
  49. # 检查用户是否存在且状态正常
  50. user = db.query(User).filter(
  51. and_(
  52. User.id == user_id,
  53. User.status == 1,
  54. User.is_deleted == 0
  55. )
  56. ).first()
  57. if not user:
  58. return False, "用户不存在或已被禁用", None
  59. # 如果启用单点登录,先清理该用户的所有其他会话
  60. if config["prevent_duplicate_login"]:
  61. SessionService.deactivate_user_sessions(db, user_id)
  62. # 生成新的会话ID
  63. session_id = Session.generate_session_id()
  64. # 计算过期时间
  65. expires_at = beijing_now_naive() + timedelta(hours=config["session_timeout_hours"])
  66. # 创建新会话
  67. new_session = Session(
  68. session_id=session_id,
  69. user_id=user_id,
  70. machine_code=machine_code,
  71. ip_address=ip_address,
  72. user_agent=user_agent,
  73. is_active=1,
  74. expires_at=expires_at,
  75. created_at=beijing_now_naive(),
  76. updated_at=beijing_now_naive(),
  77. last_activity=beijing_now_naive()
  78. )
  79. db.add(new_session)
  80. db.commit()
  81. db.refresh(new_session)
  82. logger.info(f"用户 {user_id} 创建新会话: {session_id}")
  83. return True, "会话创建成功", session_id
  84. except Exception as e:
  85. db.rollback()
  86. error_msg = f"创建会话失败: {str(e)}"
  87. logger.exception("创建会话失败")
  88. return False, error_msg, None
  89. @staticmethod
  90. def validate_session(db: DBSession, session_id: str, update_activity: bool = True, allow_expired_extend: bool = False) -> Tuple[bool, Optional[Session], str]:
  91. """
  92. 验证会话是否有效
  93. Args:
  94. allow_expired_extend: 是否允许延长已过期的活跃Session(用于心跳场景)
  95. Returns:
  96. tuple: (是否有效, Session对象, 错误消息)
  97. """
  98. try:
  99. session = db.query(Session).filter(
  100. and_(
  101. Session.session_id == session_id,
  102. Session.is_active == 1
  103. )
  104. ).first()
  105. if not session:
  106. return False, None, "会话不存在"
  107. # 检查用户是否还存在且状态正常
  108. user = db.query(User).filter(
  109. and_(
  110. User.id == session.user_id,
  111. User.status == 1,
  112. User.is_deleted == 0
  113. )
  114. ).first()
  115. if not user:
  116. # 用户不存在或被禁用,停用会话
  117. session.deactivate()
  118. db.commit()
  119. return False, None, "用户不存在或已被禁用"
  120. # 检查用户是否已过期
  121. if user.expire_time:
  122. # 确保比较的是同一时区的datetime
  123. current_time = beijing_now()
  124. if user.expire_time.tzinfo is None:
  125. # 数据库中为 naive 北京时间,补时区后与 beijing_now() 比较
  126. expire_time = user.expire_time.replace(tzinfo=BEIJING_TZ)
  127. else:
  128. expire_time = user.expire_time
  129. if expire_time < current_time:
  130. session.deactivate()
  131. db.commit()
  132. return False, None, "用户卡密已过期"
  133. # 检查单点登录:如果启用了单点登录,验证当前Session是否是唯一的活跃Session
  134. config = SessionService.get_session_config(db)
  135. if config.get("prevent_duplicate_login", True):
  136. # 获取用户的所有活跃Session
  137. active_sessions = SessionService.get_user_active_sessions(db, user.id)
  138. active_session_ids = [s.session_id for s in active_sessions]
  139. # 如果当前Session不在活跃列表中,说明被其他地方登录踢掉了
  140. if session.session_id not in active_session_ids:
  141. logger.warning(f"用户 {user.username} 的Session {session_id[:8]}... 已被其他地方登录踢掉")
  142. return False, None, "账号在其他地方登录"
  143. # 检查是否过期
  144. is_expired = session.is_expired()
  145. if is_expired:
  146. # 如果允许延长已过期Session(心跳场景),尝试延长
  147. if allow_expired_extend and SessionService.should_extend_session(db, session):
  148. logger.info(f"Session {session_id[:8]}... 已过期但允许延长,执行延长操作")
  149. SessionService.extend_session_on_heartbeat(db, session)
  150. # 重新检查是否仍然过期
  151. is_expired = session.is_expired()
  152. # 如果仍然过期,则停用会话
  153. if is_expired:
  154. session.deactivate()
  155. db.commit()
  156. return False, None, "会话已过期"
  157. # 更新活动时间
  158. if update_activity:
  159. session.update_activity()
  160. # 检查是否需要基于心跳延长session(正常情况下的延长)
  161. if not allow_expired_extend: # 避免重复延长
  162. SessionService.extend_session_on_heartbeat(db, session)
  163. db.commit()
  164. return True, session, ""
  165. except Exception as e:
  166. logger.exception("验证会话失败")
  167. return False, None, f"验证会话异常: {str(e)}"
  168. @staticmethod
  169. def deactivate_session(db: DBSession, session_id: str) -> bool:
  170. """停用指定会话"""
  171. try:
  172. session = db.query(Session).filter(Session.session_id == session_id).first()
  173. if session:
  174. session.deactivate()
  175. db.commit()
  176. logger.info(f"会话 {session_id} 已停用")
  177. return True
  178. return False
  179. except Exception as e:
  180. db.rollback()
  181. logger.exception("停用会话失败")
  182. return False
  183. @staticmethod
  184. def deactivate_user_sessions(db: DBSession, user_id: int, exclude_session_id: Optional[str] = None) -> int:
  185. """
  186. 停用指定用户的所有会话
  187. Args:
  188. exclude_session_id: 排除的会话ID(用于单点登录时保留当前会话)
  189. Returns:
  190. int: 停用的会话数量
  191. """
  192. try:
  193. query = db.query(Session).filter(
  194. and_(
  195. Session.user_id == user_id,
  196. Session.is_active == 1
  197. )
  198. )
  199. if exclude_session_id:
  200. query = query.filter(Session.session_id != exclude_session_id)
  201. sessions = query.all()
  202. count = len(sessions)
  203. for session in sessions:
  204. session.deactivate()
  205. if count > 0:
  206. db.commit()
  207. logger.info(f"用户 {user_id} 的 {count} 个会话已停用")
  208. return count
  209. except Exception as e:
  210. db.rollback()
  211. logger.exception("停用用户会话失败")
  212. return 0
  213. @staticmethod
  214. def cleanup_expired_sessions(db: DBSession) -> int:
  215. """
  216. 清理过期的会话
  217. 注意:为了保持用户一致在线体验,我们不清理活跃的过期会话,
  218. 只清理长期非活跃的旧会话记录(保留30天用于审计)
  219. Returns:
  220. int: 清理的会话数量
  221. """
  222. try:
  223. from app.utils.history import create_session_history
  224. # 先写历史表,再改/删:查出即将被删除的会话,逐条写入 sessions_his
  225. old_sessions = beijing_now_naive() - timedelta(days=30)
  226. sessions_to_clean = db.query(Session).filter(
  227. and_(
  228. Session.is_active == 0,
  229. Session.updated_at < old_sessions
  230. )
  231. ).all()
  232. for s in sessions_to_clean:
  233. create_session_history(db, s, "DELETE")
  234. # 清理长期非活跃会话(保留最近30天的数据用于审计)
  235. old_count = db.query(Session).filter(
  236. and_(
  237. Session.is_active == 0,
  238. Session.updated_at < old_sessions
  239. )
  240. ).delete()
  241. # 注意:我们不清理活跃的过期会话,让用户保持在线
  242. # 只有当用户主动登出或心跳失败时,才会标记为非活跃
  243. if old_count > 0:
  244. db.commit()
  245. logger.info(f"清理了 {old_count} 个30天前的非活跃会话记录")
  246. return old_count
  247. except Exception as e:
  248. db.rollback()
  249. logger.error(f"清理过期会话失败: {e}")
  250. return 0
  251. @staticmethod
  252. def get_user_active_sessions(db: DBSession, user_id: int) -> list:
  253. """获取用户的所有活跃会话"""
  254. try:
  255. sessions = db.query(Session).filter(
  256. and_(
  257. Session.user_id == user_id,
  258. Session.is_active == 1,
  259. Session.expires_at > beijing_now_naive()
  260. )
  261. ).order_by(Session.created_at.desc()).all()
  262. return sessions
  263. except Exception as e:
  264. logger.exception("获取用户活跃会话失败")
  265. return []
  266. @staticmethod
  267. def should_extend_session(db: DBSession, session: Session) -> bool:
  268. """
  269. 检查是否应该延长session(基于心跳)
  270. Returns:
  271. bool: 是否需要延长
  272. """
  273. try:
  274. config = SessionService.get_session_config(db)
  275. # 检查是否启用心跳延长功能
  276. if not config.get("session_extend_enabled", True):
  277. return False
  278. # 计算距离过期还有多少小时
  279. current_time = beijing_now()
  280. if session.expires_at.tzinfo is None:
  281. # 数据库中为 naive 北京时间,补时区后与 beijing_now() 比较
  282. expires_at = session.expires_at.replace(tzinfo=BEIJING_TZ)
  283. else:
  284. expires_at = session.expires_at
  285. time_until_expiry = expires_at - current_time
  286. hours_until_expiry = time_until_expiry.total_seconds() / 3600
  287. # 如果距离过期时间小于阈值,则需要延长
  288. threshold_hours = config.get("session_extend_threshold_hours", 4)
  289. return hours_until_expiry <= threshold_hours
  290. except Exception as e:
  291. logger.exception("检查是否需要延长session失败")
  292. return False
  293. @staticmethod
  294. def extend_session_on_heartbeat(db: DBSession, session: Session) -> bool:
  295. """
  296. 基于心跳延长session(如果需要)
  297. Args:
  298. session: Session对象
  299. Returns:
  300. bool: 是否执行了延长操作
  301. """
  302. try:
  303. # 检查是否需要延长
  304. if not SessionService.should_extend_session(db, session):
  305. return False
  306. config = SessionService.get_session_config(db)
  307. extend_hours = config.get("session_extend_hours", 2)
  308. # 执行延长
  309. session.expires_at = beijing_now_naive() + timedelta(hours=extend_hours)
  310. session.update_activity()
  311. db.commit()
  312. logger.info(f"心跳延长session {session.session_id[:8]}... {extend_hours}小时,新过期时间: {session.expires_at}")
  313. return True
  314. except Exception as e:
  315. db.rollback()
  316. logger.exception("心跳延长session失败")
  317. return False
  318. @staticmethod
  319. def extend_session(db: DBSession, session_id: str, hours: int = 24) -> bool:
  320. """
  321. 延长会话过期时间
  322. Args:
  323. hours: 延长的小时数
  324. Returns:
  325. bool: 是否成功
  326. """
  327. try:
  328. session = db.query(Session).filter(
  329. and_(
  330. Session.session_id == session_id,
  331. Session.is_active == 1
  332. )
  333. ).first()
  334. if not session:
  335. return False
  336. # 延长过期时间
  337. session.expires_at = beijing_now_naive() + timedelta(hours=hours)
  338. session.update_activity()
  339. db.commit()
  340. logger.info(f"会话 {session_id} 过期时间已延长 {hours} 小时")
  341. return True
  342. except Exception as e:
  343. db.rollback()
  344. logger.exception("延长会话失败")
  345. return False