heartbeat_service.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. """
  2. 心跳服务
  3. """
  4. from datetime import datetime, timedelta
  5. from typing import Dict, Any, Tuple, Optional
  6. from sqlalchemy.orm import Session
  7. from app.models.user import User
  8. from app.utils.timezone import beijing_now_naive
  9. from app.models.session import Session as SessionModel
  10. from app.models.user_software_subscription import UserSoftwareSubscription
  11. from app.models.software import Software
  12. from app.models.system_config import SystemConfig
  13. from app.utils.history import create_session_history
  14. from app.core.logger import logger
  15. DEFAULT_SOFTWARE_CODE = "buff-xp-steam-sh"
  16. def _get_subscription_expire(db: Session, user_id: int, software_code: str) -> Optional[datetime]:
  17. """获取用户在某软件下的到期时间,优先订阅表,兼容 user.expire_time"""
  18. code = (software_code or "").strip() or DEFAULT_SOFTWARE_CODE
  19. sw = db.query(Software).filter(Software.code == code, Software.is_deleted == 0).first()
  20. if not sw:
  21. return None
  22. sub = db.query(UserSoftwareSubscription).filter(
  23. UserSoftwareSubscription.user_id == user_id,
  24. UserSoftwareSubscription.software_id == sw.id,
  25. UserSoftwareSubscription.is_deleted == 0
  26. ).first()
  27. if sub and sub.expire_time is not None:
  28. return sub.expire_time
  29. user = db.query(User).filter(User.id == user_id, User.is_deleted == 0).first()
  30. return user.expire_time if user else None
  31. class HeartbeatService:
  32. """心跳服务"""
  33. def __init__(self, db: Session):
  34. self.db = db
  35. def heartbeat(self, session_id: str, machine_code: str,
  36. software_code: str = None) -> Tuple[bool, str, dict]:
  37. """
  38. 心跳检测。按 software_code 返回该软件下的到期/剩余天数。
  39. Args:
  40. session_id: 会话ID
  41. machine_code: 机器码
  42. software_code: 软件特征码(可选,默认 buff-xp-steam-sh)
  43. Returns:
  44. (success, message, data)
  45. """
  46. try:
  47. session = self.db.query(SessionModel).filter(
  48. SessionModel.session_id == session_id,
  49. SessionModel.is_active == 1,
  50. SessionModel.expires_at > beijing_now_naive()
  51. ).first()
  52. if not session:
  53. return False, "会话已过期,请重新登录", {}
  54. if session.machine_code != machine_code:
  55. return False, "账号已在其他设备登录,如非本人操作请联系客服", {}
  56. user = self.db.query(User).filter(
  57. User.id == session.user_id,
  58. User.is_deleted == 0
  59. ).first()
  60. if not user:
  61. return False, "用户不存在", {}
  62. if user.status != 1:
  63. return False, "账号已被禁用", {}
  64. expire_time = _get_subscription_expire(self.db, user.id, software_code or "")
  65. if expire_time and expire_time < beijing_now_naive():
  66. return False, "您的卡密已到期,请前往续费或联系客户支持", {}
  67. config = self.get_client_config()
  68. renewal_threshold = config.get('session_renewal_threshold', 1800)
  69. extension_duration = config.get('session_extension_duration', 3600)
  70. remaining_seconds = int((session.expires_at - beijing_now_naive()).total_seconds())
  71. renewed = False
  72. if remaining_seconds < renewal_threshold:
  73. expires_at_before = session.expires_at
  74. create_session_history(self.db, session, 'RENEW')
  75. session.expires_at = beijing_now_naive() + timedelta(seconds=extension_duration)
  76. self.db.flush()
  77. renewed = True
  78. logger.info(f"Session自动续期: session_id={session_id}, "
  79. f"原过期时间={expires_at_before.isoformat()}, "
  80. f"新过期时间={session.expires_at.isoformat()}")
  81. remaining_seconds = int((session.expires_at - beijing_now_naive()).total_seconds())
  82. days_remaining = 0
  83. if expire_time:
  84. days_remaining = (expire_time - beijing_now_naive()).days
  85. logger.debug(f"心跳检测成功: session_id={session_id}, renewed={renewed}")
  86. return True, "成功", {
  87. "config": config,
  88. "user_info": {
  89. "expire_time": expire_time.isoformat() if expire_time else None,
  90. "days_remaining": days_remaining
  91. },
  92. "session_info": {
  93. "expires_at": session.expires_at.isoformat(),
  94. "remaining_seconds": remaining_seconds,
  95. "renewed": renewed
  96. }
  97. }
  98. except Exception as e:
  99. logger.exception(f"心跳检测失败: {e}")
  100. return False, f"心跳检测失败: {str(e)}", {}
  101. def get_client_config(self) -> Dict[str, Any]:
  102. """获取客户端配置"""
  103. config_map = {
  104. 'heartbeat_interval_seconds': ('int', 10),
  105. 'client_heartbeat_interval_seconds': ('int', 20),
  106. 'max_heartbeat_failures': ('int', 20),
  107. 'timestamp_tolerance_seconds': ('int', 300),
  108. 'prevent_duplicate_login': ('bool', True),
  109. 'session_renewal_threshold': ('int', 1800),
  110. 'session_extension_duration': ('int', 3600),
  111. 'session_default_expiry_seconds': ('int', 86400),
  112. 'max_login_attempts': ('int', 5),
  113. 'login_lockout_minutes': ('int', 60),
  114. 'announcement_cache_seconds': ('int', 300),
  115. 'key_sync_interval_seconds': ('int', 60),
  116. 'key_rotation_check_interval_minutes': ('int', 30),
  117. 'max_key_rotation_failures': ('int', 20),
  118. }
  119. result = {}
  120. for key, (config_type, default) in config_map.items():
  121. value = self.db.query(SystemConfig).filter(
  122. SystemConfig.config_key == key,
  123. SystemConfig.app_name.contains('backend-client'),
  124. SystemConfig.is_deleted == 0
  125. ).first()
  126. if value:
  127. try:
  128. if config_type == 'int':
  129. result[key] = int(value.config_value)
  130. elif config_type == 'bool':
  131. result[key] = value.config_value.lower() == 'true'
  132. else:
  133. result[key] = value.config_value
  134. except (ValueError, AttributeError):
  135. result[key] = default
  136. else:
  137. result[key] = default
  138. # 客户端心跳间隔优先使用 client_heartbeat_interval_seconds,兼容旧 key
  139. result['heartbeat_interval_seconds'] = result.get(
  140. 'client_heartbeat_interval_seconds'
  141. ) or result.get('heartbeat_interval_seconds', 20)
  142. return result
  143. def get_config_value(self, config_key: str, app_name: str = 'backend-client',
  144. config_type: str = 'string', default: Any = None) -> Any:
  145. """获取单个配置值"""
  146. config = self.db.query(SystemConfig).filter(
  147. SystemConfig.config_key == config_key,
  148. SystemConfig.app_name.contains(app_name),
  149. SystemConfig.is_deleted == 0
  150. ).first()
  151. if not config:
  152. return default
  153. try:
  154. if config_type == 'int':
  155. return int(config.config_value)
  156. elif config_type == 'bool':
  157. return config.config_value.lower() == 'true'
  158. else:
  159. return config.config_value
  160. except (ValueError, AttributeError):
  161. return default