client-version-checker.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  1. import { getRedisClient } from "@/lib/redis/client";
  2. import { parseUserAgent, type ClientInfo } from "@/lib/ua-parser";
  3. import { isVersionGreater, isVersionLess } from "@/lib/version";
  4. import { getActiveUserVersions, type RawUserVersion } from "@/repository/client-versions";
  5. import { logger } from "@/lib/logger";
  6. /**
  7. * Redis Key 前缀
  8. */
  9. const REDIS_KEYS = {
  10. /** 用户当前版本: client_version:{clientType}:{userId} */
  11. userVersion: (clientType: string, userId: number) => `client_version:${clientType}:${userId}`,
  12. /** GA 版本缓存: ga_version:{clientType} */
  13. gaVersion: (clientType: string) => `ga_version:${clientType}`,
  14. };
  15. /**
  16. * TTL 配置(秒)
  17. */
  18. const TTL = {
  19. USER_VERSION: 7 * 24 * 60 * 60, // 7 天(匹配活跃窗口)
  20. GA_VERSION: 5 * 60, // 5 分钟
  21. };
  22. /**
  23. * GA 版本检测阈值(从环境变量读取,默认 2)
  24. *
  25. * 阈值定义:当某个版本的用户数 >= 该值时,该版本被视为 GA 版本
  26. *
  27. * 配置方式:设置环境变量 CLIENT_VERSION_GA_THRESHOLD
  28. * 有效范围:1-10(超出范围会被强制到边界)
  29. */
  30. const GA_THRESHOLD = (() => {
  31. const envValue = process.env.CLIENT_VERSION_GA_THRESHOLD;
  32. const parsed = envValue ? parseInt(envValue, 10) : 2; // 默认 2,与文档一致
  33. // 边界校验:范围 1-10
  34. if (isNaN(parsed) || parsed < 1) {
  35. logger.warn(
  36. { envValue, parsed },
  37. "[ClientVersionChecker] Invalid GA_THRESHOLD, using minimum value 1"
  38. );
  39. return 1;
  40. }
  41. if (parsed > 10) {
  42. logger.warn(
  43. { envValue, parsed },
  44. "[ClientVersionChecker] GA_THRESHOLD exceeds maximum, using 10"
  45. );
  46. return 10;
  47. }
  48. logger.info({ gaThreshold: parsed }, "[ClientVersionChecker] GA_THRESHOLD configured");
  49. return parsed;
  50. })();
  51. /**
  52. * 客户端版本统计信息
  53. */
  54. export interface ClientVersionStats {
  55. /**
  56. * 客户端类型
  57. *
  58. * 可能的值:
  59. * - "claude-vscode": VSCode 插件
  60. * - "claude-cli": 纯 CLI
  61. * - "claude-cli-unknown": 无法识别的旧版本
  62. * - "anthropic-sdk-typescript": SDK
  63. * - 其他客户端类型
  64. */
  65. clientType: string;
  66. /** 最新 GA 版本,无则为 null */
  67. gaVersion: string | null;
  68. /** 使用该客户端的总用户数 */
  69. totalUsers: number;
  70. /** 用户详情列表 */
  71. users: {
  72. userId: number;
  73. username: string;
  74. version: string;
  75. lastSeen: Date;
  76. isLatest: boolean; // 是否是最新版本
  77. needsUpgrade: boolean; // 是否需要升级
  78. }[];
  79. }
  80. /**
  81. * 客户端版本检测器
  82. *
  83. * 核心功能:
  84. * 1. 检测每种客户端的最新 GA 版本(1 个用户以上使用)
  85. * 2. 检查用户版本是否需要升级
  86. * 3. 追踪用户当前使用的版本
  87. *
  88. * 支持的客户端类型:
  89. * - claude-vscode: VSCode 插件(独立版本检测)
  90. * - claude-cli: 纯 CLI(独立版本检测)
  91. * - claude-cli-unknown: 无法识别的旧版本(独立版本检测)
  92. * - anthropic-sdk-typescript: SDK
  93. * - 其他客户端类型
  94. */
  95. export class ClientVersionChecker {
  96. /**
  97. * 从用户列表计算 GA 版本(内存计算,不查询数据库)
  98. *
  99. * @param users - 用户版本列表(包含 version 字段)
  100. * @returns GA 版本号,无则返回 null
  101. * @private
  102. */
  103. private static computeGAVersionFromUsers(
  104. users: Array<{ userId: number; version: string }>
  105. ): string | null {
  106. if (users.length === 0) {
  107. return null;
  108. }
  109. // 1. 统计每个版本的用户数(去重)
  110. const versionCounts = new Map<string, Set<number>>();
  111. for (const user of users) {
  112. if (!versionCounts.has(user.version)) {
  113. versionCounts.set(user.version, new Set());
  114. }
  115. versionCounts.get(user.version)!.add(user.userId);
  116. }
  117. // 2. 找到用户数 >= GA_THRESHOLD 的最新版本
  118. let gaVersion: string | null = null;
  119. for (const [version, userIds] of versionCounts.entries()) {
  120. if (userIds.size >= GA_THRESHOLD) {
  121. if (!gaVersion || isVersionGreater(version, gaVersion)) {
  122. gaVersion = version;
  123. }
  124. }
  125. }
  126. return gaVersion;
  127. }
  128. /**
  129. * 检测指定客户端的最新 GA 版本
  130. *
  131. * GA 版本定义:被 1 个或以上用户使用的最新版本
  132. * 活跃窗口:过去 7 天内有请求的用户
  133. *
  134. * @param clientType - 客户端类型(如 "claude-vscode"、"claude-cli"、"claude-cli-unknown")
  135. * @returns GA 版本号,无则返回 null
  136. *
  137. * @example
  138. * ```typescript
  139. * // VSCode 插件和 CLI 分别检测
  140. * const vscodeGA = await detectGAVersion("claude-vscode"); // "2.0.35"
  141. * const cliGA = await detectGAVersion("claude-cli"); // "2.0.33"
  142. * ```
  143. */
  144. static async detectGAVersion(clientType: string): Promise<string | null> {
  145. try {
  146. const redis = getRedisClient();
  147. // 1. 尝试从 Redis 读取缓存
  148. if (redis) {
  149. const cached = await redis.get(REDIS_KEYS.gaVersion(clientType));
  150. if (cached) {
  151. const data = JSON.parse(cached) as { version: string; userCount: number };
  152. logger.debug(
  153. { clientType, gaVersion: data.version },
  154. "[ClientVersionChecker] GA 版本缓存命中"
  155. );
  156. return data.version;
  157. }
  158. }
  159. // 2. 缓存未命中,查询数据库
  160. const activeUsers = await getActiveUserVersions(7);
  161. // 3. 解析所有 UA,过滤出指定客户端类型
  162. const clientUsers = activeUsers
  163. .map((user) => {
  164. const clientInfo = parseUserAgent(user.userAgent);
  165. return clientInfo && clientInfo.clientType === clientType
  166. ? { ...user, version: clientInfo.version }
  167. : null;
  168. })
  169. .filter((item): item is RawUserVersion & { version: string } => item !== null);
  170. if (clientUsers.length === 0) {
  171. logger.debug({ clientType }, "[ClientVersionChecker] 无活跃用户");
  172. return null;
  173. }
  174. // 4. 使用内存计算逻辑
  175. const gaVersion = this.computeGAVersionFromUsers(clientUsers);
  176. if (!gaVersion) {
  177. logger.debug({ clientType }, "[ClientVersionChecker] 无 GA 版本(暂无用户使用该版本)");
  178. return null;
  179. }
  180. // 5. 写入 Redis 缓存
  181. if (redis) {
  182. // 重新统计用户数(用于缓存)
  183. const versionCounts = new Map<string, Set<number>>();
  184. for (const user of clientUsers) {
  185. if (!versionCounts.has(user.version)) {
  186. versionCounts.set(user.version, new Set());
  187. }
  188. versionCounts.get(user.version)!.add(user.userId);
  189. }
  190. const cacheData = {
  191. version: gaVersion,
  192. userCount: versionCounts.get(gaVersion)!.size,
  193. updatedAt: Date.now(),
  194. };
  195. await redis.setex(
  196. REDIS_KEYS.gaVersion(clientType),
  197. TTL.GA_VERSION,
  198. JSON.stringify(cacheData)
  199. );
  200. logger.info(
  201. { clientType, gaVersion, userCount: cacheData.userCount },
  202. "[ClientVersionChecker] GA 版本已缓存"
  203. );
  204. }
  205. return gaVersion;
  206. } catch (error) {
  207. // Fail Open: 任何错误都返回 null
  208. logger.error({ error, clientType }, "[ClientVersionChecker] 检测 GA 版本失败");
  209. return null;
  210. }
  211. }
  212. /**
  213. * 检查用户版本是否需要升级
  214. *
  215. * @param clientType - 客户端类型
  216. * @param userVersion - 用户当前版本
  217. * @returns {needsUpgrade, gaVersion} - 是否需要升级及当前 GA 版本
  218. */
  219. static async shouldUpgrade(
  220. clientType: string,
  221. userVersion: string
  222. ): Promise<{ needsUpgrade: boolean; gaVersion: string | null }> {
  223. try {
  224. const gaVersion = await this.detectGAVersion(clientType);
  225. if (!gaVersion) {
  226. return { needsUpgrade: false, gaVersion: null }; // 无 GA 版本,放行
  227. }
  228. const needsUpgrade = isVersionLess(userVersion, gaVersion);
  229. return { needsUpgrade, gaVersion };
  230. } catch (error) {
  231. // Fail Open: 检查失败时放行
  232. logger.error({ error, clientType, userVersion }, "[ClientVersionChecker] 版本检查失败");
  233. return { needsUpgrade: false, gaVersion: null };
  234. }
  235. }
  236. /**
  237. * 更新用户当前使用的版本(异步,不阻塞主流程)
  238. *
  239. * @param userId - 用户 ID
  240. * @param clientType - 客户端类型
  241. * @param version - 版本号
  242. */
  243. static async updateUserVersion(
  244. userId: number,
  245. clientType: string,
  246. version: string
  247. ): Promise<void> {
  248. try {
  249. const redis = getRedisClient();
  250. if (!redis) {
  251. return; // Redis 不可用,跳过
  252. }
  253. const data = {
  254. version,
  255. lastSeen: Date.now(),
  256. };
  257. await redis.setex(
  258. REDIS_KEYS.userVersion(clientType, userId),
  259. TTL.USER_VERSION,
  260. JSON.stringify(data)
  261. );
  262. logger.debug({ userId, clientType, version }, "[ClientVersionChecker] 用户版本已更新");
  263. } catch (error) {
  264. // 非关键操作,仅记录日志
  265. logger.error(
  266. { error, userId, clientType, version },
  267. "[ClientVersionChecker] 更新用户版本失败"
  268. );
  269. }
  270. }
  271. /**
  272. * 获取所有客户端的版本统计(供前端使用)
  273. *
  274. * @returns 所有客户端的版本统计信息
  275. */
  276. static async getAllClientStats(): Promise<ClientVersionStats[]> {
  277. try {
  278. // 1. 查询活跃用户(一次性查询,避免 N+1)
  279. const activeUsers = await getActiveUserVersions(7);
  280. // 2. 解析 UA 并分组
  281. const clientGroups = new Map<string, Array<RawUserVersion & { clientInfo: ClientInfo }>>();
  282. for (const user of activeUsers) {
  283. const clientInfo = parseUserAgent(user.userAgent);
  284. if (!clientInfo) continue; // 解析失败,跳过
  285. if (!clientGroups.has(clientInfo.clientType)) {
  286. clientGroups.set(clientInfo.clientType, []);
  287. }
  288. clientGroups.get(clientInfo.clientType)!.push({ ...user, clientInfo });
  289. }
  290. // 3. 为每个客户端类型生成统计(使用内存计算,不再查询数据库)
  291. const stats: ClientVersionStats[] = [];
  292. for (const [clientType, users] of clientGroups.entries()) {
  293. // 去重:每个用户只保留最新版本
  294. const userMap = new Map<number, (typeof users)[0]>();
  295. for (const user of users) {
  296. const existing = userMap.get(user.userId);
  297. if (!existing) {
  298. userMap.set(user.userId, user);
  299. } else {
  300. if (isVersionGreater(user.clientInfo.version, existing.clientInfo.version)) {
  301. userMap.set(user.userId, user);
  302. }
  303. }
  304. }
  305. const uniqueUsers = Array.from(userMap.values());
  306. // 使用内存计算 GA 版本,避免重复查询数据库
  307. const usersWithVersion = uniqueUsers.map((u) => ({
  308. userId: u.userId,
  309. version: u.clientInfo.version,
  310. }));
  311. const gaVersion = this.computeGAVersionFromUsers(usersWithVersion);
  312. const userStats = uniqueUsers.map((user) => ({
  313. userId: user.userId,
  314. username: user.username,
  315. version: user.clientInfo.version,
  316. lastSeen: user.lastSeen,
  317. isLatest: gaVersion ? user.clientInfo.version === gaVersion : false,
  318. needsUpgrade: gaVersion ? isVersionLess(user.clientInfo.version, gaVersion) : false,
  319. }));
  320. stats.push({
  321. clientType,
  322. gaVersion,
  323. totalUsers: userStats.length,
  324. users: userStats,
  325. });
  326. }
  327. return stats;
  328. } catch (error) {
  329. logger.error({ error }, "[ClientVersionChecker] 获取客户端统计失败");
  330. return []; // Fail Open
  331. }
  332. }
  333. }