session_service.py 14 KB

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