heartbeat_service.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. """
  2. 心跳服务
  3. """
  4. import json
  5. from datetime import datetime, timedelta
  6. from typing import Dict, Any, Tuple, Optional
  7. from sqlalchemy.orm import Session
  8. from sqlalchemy import or_
  9. from app.models.user import User
  10. from app.utils.timezone import beijing_now_naive
  11. from app.models.session import Session as SessionModel
  12. from app.models.user_software_subscription import UserSoftwareSubscription
  13. from app.models.software import Software
  14. from app.models.system_config import SystemConfig
  15. from app.services.client_version_service import ClientVersionService
  16. from app.utils.history import create_session_history
  17. from app.core.logger import logger
  18. DEFAULT_SOFTWARE_CODE = "buff-xp-steam-sh"
  19. def _get_subscription_expire(db: Session, user_id: int, software_code: str) -> Optional[datetime]:
  20. """获取用户在某软件下的到期时间,优先订阅表,兼容 user.expire_time"""
  21. code = (software_code or "").strip() or DEFAULT_SOFTWARE_CODE
  22. sw = db.query(Software).filter(Software.code == code, Software.is_deleted == 0).first()
  23. if not sw:
  24. return None
  25. sub = db.query(UserSoftwareSubscription).filter(
  26. UserSoftwareSubscription.user_id == user_id,
  27. UserSoftwareSubscription.software_id == sw.id,
  28. UserSoftwareSubscription.is_deleted == 0
  29. ).first()
  30. if sub and sub.expire_time is not None:
  31. return sub.expire_time
  32. user = db.query(User).filter(User.id == user_id, User.is_deleted == 0).first()
  33. return user.expire_time if user else None
  34. class HeartbeatService:
  35. """心跳服务"""
  36. def __init__(self, db: Session):
  37. self.db = db
  38. def heartbeat(self, session_id: str, machine_code: str,
  39. software_code: str = None,
  40. client_version: str = "",
  41. platform: str = "",
  42. channel: str = "") -> Tuple[bool, str, dict]:
  43. """
  44. 心跳检测。按 software_code 返回该软件下的到期/剩余天数。
  45. Args:
  46. session_id: 会话ID
  47. machine_code: 机器码
  48. software_code: 软件特征码(可选,默认 buff-xp-steam-sh)
  49. Returns:
  50. (success, message, data)
  51. """
  52. try:
  53. session = self.db.query(SessionModel).filter(
  54. SessionModel.session_id == session_id,
  55. SessionModel.is_active == 1,
  56. SessionModel.expires_at > beijing_now_naive()
  57. ).first()
  58. if not session:
  59. return False, "会话已过期,请重新登录", {}
  60. if session.machine_code != machine_code:
  61. return False, "账号已在其他设备登录,如非本人操作请联系客服", {}
  62. user = self.db.query(User).filter(
  63. User.id == session.user_id,
  64. User.is_deleted == 0
  65. ).first()
  66. if not user:
  67. return False, "用户不存在", {}
  68. if user.status != 1:
  69. return False, "账号已被禁用", {}
  70. expire_time = _get_subscription_expire(self.db, user.id, software_code or "")
  71. if expire_time and expire_time < beijing_now_naive():
  72. return False, "您的卡密已到期,请前往续费或联系客户支持", {}
  73. version_service = ClientVersionService(self.db)
  74. should_block, version_check = version_service.should_block_client(
  75. client_version=client_version,
  76. platform=platform,
  77. channel=channel,
  78. )
  79. if should_block:
  80. return False, "客户端版本过低,请更新到最新版本后继续使用", {
  81. "version_check": version_check
  82. }
  83. config = self.get_client_config()
  84. renewal_threshold = config.get('session_renewal_threshold', 1800)
  85. extension_duration = config.get('session_extension_duration', 3600)
  86. remaining_seconds = int((session.expires_at - beijing_now_naive()).total_seconds())
  87. renewed = False
  88. if remaining_seconds < renewal_threshold:
  89. expires_at_before = session.expires_at
  90. create_session_history(self.db, session, 'RENEW')
  91. session.expires_at = beijing_now_naive() + timedelta(seconds=extension_duration)
  92. self.db.flush()
  93. renewed = True
  94. logger.info(f"Session自动续期: session_id={session_id}, "
  95. f"原过期时间={expires_at_before.isoformat()}, "
  96. f"新过期时间={session.expires_at.isoformat()}")
  97. remaining_seconds = int((session.expires_at - beijing_now_naive()).total_seconds())
  98. days_remaining = 0
  99. if expire_time:
  100. days_remaining = (expire_time - beijing_now_naive()).days
  101. logger.debug(f"心跳检测成功: session_id={session_id}, renewed={renewed}")
  102. return True, "成功", {
  103. "config": config,
  104. "version_check": version_check,
  105. "user_info": {
  106. "expire_time": expire_time.isoformat() if expire_time else None,
  107. "days_remaining": days_remaining
  108. },
  109. "session_info": {
  110. "expires_at": session.expires_at.isoformat(),
  111. "remaining_seconds": remaining_seconds,
  112. "renewed": renewed
  113. }
  114. }
  115. except Exception as e:
  116. logger.exception(f"心跳检测失败: {e}")
  117. return False, f"心跳检测失败: {str(e)}", {}
  118. def get_client_config(self) -> Dict[str, Any]:
  119. """获取客户端配置"""
  120. config_map = {
  121. 'heartbeat_interval_seconds': ('int', 10),
  122. 'client_heartbeat_interval_seconds': ('int', 20),
  123. 'max_heartbeat_failures': ('int', 20),
  124. 'timestamp_tolerance_seconds': ('int', 300),
  125. 'prevent_duplicate_login': ('bool', True),
  126. 'session_renewal_threshold': ('int', 1800),
  127. 'session_extension_duration': ('int', 3600),
  128. 'session_default_expiry_seconds': ('int', 864000),
  129. 'max_login_attempts': ('int', 5),
  130. 'login_lockout_minutes': ('int', 60),
  131. 'announcement_cache_seconds': ('int', 300),
  132. 'key_sync_interval_seconds': ('int', 60),
  133. 'key_rotation_check_interval_minutes': ('int', 30),
  134. 'max_key_rotation_failures': ('int', 20),
  135. # BUFF 扫货相关配置(来自 backend-selection)
  136. 'buff_sweep_proxy_retry_times': ('int', 1),
  137. 'buff_sweep_proxy_retry_interval_ms': ('int', 800),
  138. 'buff_sweep_steam_listings_page_size': ('int', 10),
  139. 'buff_sweep_steam_listings_max_scan_per_item': ('int', 60),
  140. 'buff_sweep_pause_every_items': ('int', 0),
  141. 'buff_sweep_pause_seconds': ('int', 0),
  142. # 客户端:每个选品饰品扫描+并发阶段结束后,再处理下一饰品前暂停秒数(非「每页 render」)
  143. 'buff_sweep_pause_after_render_seconds': ('int', 0),
  144. 'buff_sweep_proxy_rotate_every_items': ('int', 0),
  145. # 新增:扫货线程数最大数量
  146. 'buff_sweep_max_worker_count': ('int', 32),
  147. # 新增:Steam卡价比例最小值
  148. 'buff_steam_card_ratio_min': ('int', 4),
  149. # 新增:选品数量最大值
  150. 'buff_selection_max_quantity': ('int', 300),
  151. }
  152. def parse_config_value(raw_value: Any, config_type: str) -> Any:
  153. """按配置类型解析字符串值,解析失败时返回原始值。"""
  154. try:
  155. if raw_value is None:
  156. return None
  157. text = str(raw_value).strip()
  158. if config_type == 'int':
  159. return int(text)
  160. if config_type == 'bool':
  161. return text.lower() in ('true', '1', 'yes', 'on')
  162. if config_type == 'json':
  163. return json.loads(text) if text else None
  164. return raw_value
  165. except Exception:
  166. return raw_value
  167. result = {key: default for key, (_, default) in config_map.items()}
  168. # 同步 backend-client / backend-selection 两类配置到客户端
  169. # 冲突优先级:backend-selection > backend-client(更贴近选品/扫货业务域)
  170. rows = self.db.query(SystemConfig).filter(
  171. SystemConfig.is_deleted == 0,
  172. or_(
  173. SystemConfig.app_name == 'backend-client',
  174. SystemConfig.app_name == 'backend-selection'
  175. )
  176. ).all()
  177. priority = {'backend-client': 1, 'backend-selection': 2}
  178. # 同 app 下多行同 key 时按 id 稳定覆盖(后者 id 更大优先);表上 (config_key,is_deleted) 唯一,但历史数据可能重复
  179. rows.sort(key=lambda row: (priority.get(row.app_name or '', 0), row.id or 0))
  180. for row in rows:
  181. row_type = (row.config_type or 'string').lower()
  182. parsed = parse_config_value(row.config_value, row_type)
  183. result[row.config_key] = parsed
  184. # 客户端心跳间隔优先使用 client_heartbeat_interval_seconds,兼容旧 key
  185. result['heartbeat_interval_seconds'] = result.get(
  186. 'client_heartbeat_interval_seconds'
  187. ) or result.get('heartbeat_interval_seconds', 20)
  188. return result
  189. def get_config_value(self, config_key: str, app_name: str = 'backend-client',
  190. config_type: str = 'string', default: Any = None) -> Any:
  191. """获取单个配置值"""
  192. config = self.db.query(SystemConfig).filter(
  193. SystemConfig.config_key == config_key,
  194. SystemConfig.app_name.contains(app_name),
  195. SystemConfig.is_deleted == 0
  196. ).first()
  197. if not config:
  198. return default
  199. try:
  200. if config_type == 'int':
  201. return int(config.config_value)
  202. elif config_type == 'bool':
  203. return config.config_value.lower() == 'true'
  204. else:
  205. return config.config_value
  206. except (ValueError, AttributeError):
  207. return default