client_version_service.py 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. """
  2. 客户端版本策略服务
  3. """
  4. from __future__ import annotations
  5. import re
  6. from typing import Dict, Any, Optional, Tuple
  7. from sqlalchemy.orm import Session
  8. from app.models.client_app_version import ClientAppVersion
  9. from app.models.system_config import SystemConfig
  10. DEFAULT_PLATFORM = "windows"
  11. DEFAULT_CHANNEL = "stable"
  12. DEFAULT_CHECK_INTERVAL_SECONDS = 60
  13. def _normalize_platform(platform: Optional[str]) -> str:
  14. value = (platform or "").strip().lower()
  15. if value in ("windows", "win32", "win", "darwin", "mac", "macos", "linux"):
  16. if value in ("darwin", "mac"):
  17. return "macos"
  18. if value in ("win32", "win"):
  19. return "windows"
  20. return value
  21. return DEFAULT_PLATFORM
  22. def _normalize_channel(channel: Optional[str]) -> str:
  23. value = (channel or "").strip().lower()
  24. return value if value else DEFAULT_CHANNEL
  25. def _parse_version(version: Optional[str]) -> Optional[Tuple[int, ...]]:
  26. text = (version or "").strip()
  27. if not text:
  28. return None
  29. parts = [p for p in re.split(r"[.\-+_]", text) if p != ""]
  30. parsed = []
  31. for p in parts:
  32. m = re.match(r"^\d+", p)
  33. if not m:
  34. break
  35. parsed.append(int(m.group(0)))
  36. if not parsed:
  37. return None
  38. return tuple(parsed)
  39. def _compare_version(a: Optional[str], b: Optional[str]) -> int:
  40. """
  41. 比较版本号:
  42. -1: a < b
  43. 0: a == b
  44. 1: a > b
  45. """
  46. ta = _parse_version(a)
  47. tb = _parse_version(b)
  48. if ta is None and tb is None:
  49. return 0
  50. if ta is None:
  51. return -1
  52. if tb is None:
  53. return 1
  54. max_len = max(len(ta), len(tb))
  55. aa = list(ta) + [0] * (max_len - len(ta))
  56. bb = list(tb) + [0] * (max_len - len(tb))
  57. if aa < bb:
  58. return -1
  59. if aa > bb:
  60. return 1
  61. return 0
  62. class ClientVersionService:
  63. def __init__(self, db: Session):
  64. self.db = db
  65. def get_check_interval_seconds(self) -> int:
  66. row = self.db.query(SystemConfig).filter(
  67. SystemConfig.config_key == "client_version_check_interval_seconds",
  68. SystemConfig.app_name.contains("backend-client"),
  69. SystemConfig.is_deleted == 0
  70. ).first()
  71. if not row or not row.config_value:
  72. return DEFAULT_CHECK_INTERVAL_SECONDS
  73. try:
  74. seconds = int(row.config_value)
  75. return seconds if seconds > 0 else DEFAULT_CHECK_INTERVAL_SECONDS
  76. except (TypeError, ValueError):
  77. return DEFAULT_CHECK_INTERVAL_SECONDS
  78. def get_latest_published(self, platform: Optional[str], channel: Optional[str]) -> Optional[ClientAppVersion]:
  79. pf = _normalize_platform(platform)
  80. ch = _normalize_channel(channel)
  81. rows = self.db.query(ClientAppVersion).filter(
  82. ClientAppVersion.platform == pf,
  83. ClientAppVersion.channel == ch,
  84. ClientAppVersion.status == "published",
  85. ClientAppVersion.is_deleted == 0
  86. ).all()
  87. if not rows:
  88. return None
  89. return max(rows, key=lambda r: _parse_version(r.version) or (0,))
  90. def build_check_result(
  91. self,
  92. client_version: Optional[str],
  93. platform: Optional[str],
  94. channel: Optional[str],
  95. ) -> Dict[str, Any]:
  96. latest = self.get_latest_published(platform, channel)
  97. interval = self.get_check_interval_seconds()
  98. if not latest:
  99. return {
  100. "latest_version": None,
  101. "latest_build": None,
  102. "min_supported_version": None,
  103. "has_update": False,
  104. "is_force_update": False,
  105. "update_title": None,
  106. "release_notes_html": "",
  107. "download_url": "",
  108. "published_at": None,
  109. "check_interval_seconds": interval,
  110. "platform": _normalize_platform(platform),
  111. "channel": _normalize_channel(channel),
  112. }
  113. current = (client_version or "").strip()
  114. latest_version = latest.version
  115. min_supported = (latest.min_supported_version or "").strip() or latest.version
  116. has_update = _compare_version(current, latest_version) < 0
  117. force_by_min = _compare_version(current, min_supported) < 0
  118. is_force = bool(latest.is_force_update or force_by_min)
  119. return {
  120. "latest_version": latest_version,
  121. "latest_build": latest.build_number,
  122. "min_supported_version": min_supported,
  123. "has_update": has_update,
  124. "is_force_update": bool(is_force and has_update),
  125. "update_title": latest.release_title or f"发现新版本 {latest_version}",
  126. "release_notes_html": latest.release_notes_html or "",
  127. "download_url": latest.download_url or "",
  128. "published_at": latest.published_at.isoformat() if latest.published_at else None,
  129. "check_interval_seconds": interval,
  130. "platform": latest.platform,
  131. "channel": latest.channel,
  132. }
  133. def should_block_client(
  134. self,
  135. client_version: Optional[str],
  136. platform: Optional[str],
  137. channel: Optional[str],
  138. ) -> Tuple[bool, Dict[str, Any]]:
  139. result = self.build_check_result(client_version, platform, channel)
  140. return bool(result.get("is_force_update")), result