From 6bc811332c23c23780fdca2adcf337e18ca7f249 Mon Sep 17 00:00:00 2001 From: M0914 <2625053283@qq.com> Date: Thu, 9 Oct 2025 15:05:27 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E6=8F=92=E4=BB=B6=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E6=96=87=EF=BC=8C=E6=96=B0=E5=A2=9E=E5=A4=9A=E7=BA=BF?= =?UTF-8?q?=E7=A8=8B=E8=8E=B7=E5=8F=96=E6=8F=92=E4=BB=B6=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../help/QiuMoJetbrainsHelpApplication.java | 34 +- .../help/context/PluginsContextHolder.java | 392 ++++++------------ .../help/context/plugin/PluginConfig.java | 111 +++++ .../context/plugin/model/PluginCache.java | 47 +++ .../help/context/plugin/model/PluginInfo.java | 32 ++ .../help/context/plugin/model/PluginList.java | 75 ++++ .../plugin/service/PluginApiService.java | 204 +++++++++ .../plugin/service/PluginCacheService.java | 127 ++++++ .../plugin/service/PluginProcessService.java | 158 +++++++ .../qiumo/help/controller/DataController.java | 7 +- .../controller/LicenseCodeController.java | 3 +- src/main/resources/application.yml | 14 +- 12 files changed, 922 insertions(+), 282 deletions(-) create mode 100644 src/main/java/com/qiumo/help/context/plugin/PluginConfig.java create mode 100644 src/main/java/com/qiumo/help/context/plugin/model/PluginCache.java create mode 100644 src/main/java/com/qiumo/help/context/plugin/model/PluginInfo.java create mode 100644 src/main/java/com/qiumo/help/context/plugin/model/PluginList.java create mode 100644 src/main/java/com/qiumo/help/context/plugin/service/PluginApiService.java create mode 100644 src/main/java/com/qiumo/help/context/plugin/service/PluginCacheService.java create mode 100644 src/main/java/com/qiumo/help/context/plugin/service/PluginProcessService.java diff --git a/src/main/java/com/qiumo/help/QiuMoJetbrainsHelpApplication.java b/src/main/java/com/qiumo/help/QiuMoJetbrainsHelpApplication.java index 1de7f47..831ca2c 100644 --- a/src/main/java/com/qiumo/help/QiuMoJetbrainsHelpApplication.java +++ b/src/main/java/com/qiumo/help/QiuMoJetbrainsHelpApplication.java @@ -7,6 +7,7 @@ import com.qiumo.help.context.AgentContextHolder; import com.qiumo.help.context.CertificateContextHolder; import com.qiumo.help.context.PluginsContextHolder; import com.qiumo.help.context.ProductsContextHolder; +import com.qiumo.help.context.plugin.PluginConfig; import java.net.InetAddress; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; @@ -24,6 +25,8 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import javax.annotation.PreDestroy; + /** * QiuMo JetBrains Help 应用程序主入口类 * @@ -99,11 +102,12 @@ public class QiuMoJetbrainsHelpApplication { /** * 定时刷新插件信息任务 - * + * *

每天中午12点执行一次,从JetBrains官网获取最新的插件信息。 * 此任务采用异步执行方式,不会阻塞应用程序的正常运行。 - * - *

Cron表达式说明:"0 0 12 * * ?" + * 支持通过配置文件开关控制是否启用该定时任务。 + * + *

Cron表达式说明:"0 0 12 * * ?" *

+ * + *

配置说明: + *

*/ @Scheduled(cron = "0 0 12 * * ?") public void refresh() { + // 检查是否启用定时刷新功能 + PluginConfig config = PluginConfig.getInstance(); + if (!config.isRefreshEnabled()) { + log.info("插件定时刷新功能已禁用,跳过本次刷新任务"); + return; + } + log.info("开始执行定时刷新插件信息任务..."); // 异步执行插件信息刷新任务,避免阻塞主线程 ThreadUtil.execute(PluginsContextHolder::refreshJsonFile); } + + /** + * 应用关闭时的清理工作 + */ + @PreDestroy + public void onDestroy() { + log.info("应用程序正在关闭,清理资源..."); + PluginsContextHolder.shutdown(); + } /** * 构建应用启动成功消息 diff --git a/src/main/java/com/qiumo/help/context/PluginsContextHolder.java b/src/main/java/com/qiumo/help/context/PluginsContextHolder.java index 540346c..2db82be 100644 --- a/src/main/java/com/qiumo/help/context/PluginsContextHolder.java +++ b/src/main/java/com/qiumo/help/context/PluginsContextHolder.java @@ -1,33 +1,27 @@ package com.qiumo.help.context; -import cn.hutool.core.io.FileUtil; -import cn.hutool.core.io.IORuntimeException; -import cn.hutool.core.io.IoUtil; -import cn.hutool.core.text.CharSequenceUtil; -import cn.hutool.core.util.StrUtil; -import cn.hutool.http.HttpUtil; -import cn.hutool.json.JSONUtil; -import com.qiumo.help.util.FileTools; -import java.util.stream.Collectors; +import com.qiumo.help.context.plugin.PluginConfig; +import com.qiumo.help.context.plugin.model.PluginCache; +import com.qiumo.help.context.plugin.model.PluginList; +import com.qiumo.help.context.plugin.service.PluginApiService; +import com.qiumo.help.context.plugin.service.PluginCacheService; +import com.qiumo.help.context.plugin.service.PluginProcessService; import lombok.AccessLevel; -import lombok.Data; import lombok.NoArgsConstructor; -import lombok.experimental.Accessors; import lombok.extern.slf4j.Slf4j; -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.*; +import java.util.List; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; /** * JetBrains插件信息上下文管理器 - * + * *

此类负责管理JetBrains插件市场中付费插件的信息,包括插件ID、名称、产品代码、定价模式等。 * 插件信息通过JetBrains官方API实时获取,并缓存到本地JSON文件中,用于许可证生成时的插件选择。 - * + * *

主要功能: *

- * - *

数据流程: - *

    - *
  1. 从本地JSON文件加载已缓存的插件信息
  2. - *
  3. 定期调用JetBrains API获取最新插件列表
  4. - *
  5. 过滤掉免费插件,只保留付费插件
  6. - *
  7. 获取每个插件的详细信息(特别是产品代码)
  8. - *
  9. 更新本地缓存文件
  10. - *
- * - *

API端点: + * + *

架构优化: *

- * - *

缓存策略: - *

- * + * * @author QiuMo - * @version 1.0.0 + * @version 2.0.0 * @since 1.0.0 */ - @Slf4j(topic = "插件上下文") @NoArgsConstructor(access = AccessLevel.PRIVATE) public class PluginsContextHolder { - // ==================== 常量定义 ==================== - - /** JetBrains插件市场基础URL */ - private static final String PLUGIN_BASIC_URL = "https://plugins.jetbrains.com"; - - /** 插件列表API地址,获取所有插件基本信息 */ - private static final String PLUGIN_LIST_URL = - PLUGIN_BASIC_URL + "/api/searchPlugins?max=10000&offset=0&orderBy=name"; - - /** 插件详情API地址模板,用于获取单个插件的详细信息 */ - private static final String PLUGIN_INFO_URL = PLUGIN_BASIC_URL + "/api/plugins/"; - - /** 插件信息缓存文件路径 */ - private static final String PLUGIN_JSON_FILE_NAME = "external/data/plugin.json"; - // ==================== 静态字段 ==================== - + /** 插件信息缓存列表,存储所有已加载的付费插件信息 */ private static List pluginCacheList; - /** 插件JSON缓存文件对象 */ - private static File pluginsJsonFile; + /** 线程池,用于并发请求插件数据 */ + private static ExecutorService executorService; // ==================== 核心方法 ==================== /** * 初始化插件上下文 - * + * *

此方法负责初始化插件信息管理系统,包括加载本地缓存和启动刷新任务。 * 如果本地缓存文件不存在或格式错误,会自动从网络获取最新数据。 - * - *

初始化过程: - *

    - *
  1. 获取或创建插件缓存JSON文件
  2. - *
  3. 读取并验证JSON文件内容
  4. - *
  5. 如果数据有效,解析为对象列表
  6. - *
  7. 如果数据无效,初始化为空列表
  8. - *
  9. 启动异步刷新任务获取最新数据
  10. - *
- * - *

注意事项: - *

- * + * * @throws IllegalArgumentException 当文件读取失败时抛出 */ public static void init() { log.info("开始初始化插件上下文..."); - - // 获取或创建插件缓存文件 - pluginsJsonFile = FileTools.getFileOrCreat(PLUGIN_JSON_FILE_NAME); - log.debug("插件缓存文件路径: {}", pluginsJsonFile.getAbsolutePath()); - // 读取并验证JSON文件内容 - String pluginJsonArray; try { - pluginJsonArray = IoUtil.readUtf8(FileUtil.getInputStream(pluginsJsonFile)); - } catch (IORuntimeException e) { - throw new IllegalArgumentException( - CharSequenceUtil.format("{} 文件读取失败!", PLUGIN_JSON_FILE_NAME), e); - } - - // 检查JSON数据的有效性 - if (CharSequenceUtil.isBlank(pluginJsonArray) || !JSONUtil.isTypeJSON(pluginJsonArray)) { - log.warn("插件缓存文件为空或格式错误,初始化为空列表"); - pluginCacheList = new ArrayList<>(); - // 启动异步刷新任务获取数据 - refreshJsonFile(); - } else { - // 解析JSON数据为插件对象列表 - pluginCacheList = JSONUtil.toList(pluginJsonArray, PluginCache.class); + // 从缓存加载插件数据 + pluginCacheList = PluginCacheService.loadFromCache(); log.info("插件上下文初始化成功!加载插件数量: {}", pluginCacheList.size()); - + // 启动异步刷新任务获取最新数据 refreshJsonFile(); + + } catch (Exception e) { + log.error("插件上下文初始化失败", e); + throw e; } } /** * 获取插件信息缓存列表 - * - *

返回已加载的所有JetBrains付费插件信息列表。 - * 该列表包含插件ID、名称、产品代码、定价模式和图标等信息。 - * - *

使用场景: - *

- * - *

注意事项: - *

- * + * * @return 插件信息缓存列表,不为null */ public static List pluginCacheList() { - return PluginsContextHolder.pluginCacheList; + return pluginCacheList; } + /** + * 刷新插件信息文件 + * + *

使用多线程分页方式从JetBrains插件市场获取所有插件信息。 + * 该方法会根据配置文件中的参数来控制并发数量和分页大小。 + */ public static void refreshJsonFile() { - log.info("从'JetBrains.com'刷新中..."); + PluginConfig config = PluginConfig.getInstance(); + + // 检查是否启用刷新功能 + if (!config.isRefreshEnabled()) { + log.info("插件刷新功能已禁用,跳过刷新任务"); + return; + } + + log.info("开始多线程分页刷新插件信息..."); + log.info("刷新配置 -> 分页大小: {}, 并发线程数: {}, 超时时间: {}ms", + config.getPageSize(), config.getThreadCount(), config.getTimeout()); + + // 初始化线程池 + initExecutorService(config.getThreadCount()); + + // 启动异步刷新任务 CompletableFuture - .supplyAsync(PluginsContextHolder::pluginList) - .thenApply(PluginsContextHolder::pluginListFilter) - .thenApply(PluginsContextHolder::pluginConversion) - .thenAccept(PluginsContextHolder::overrideJsonFile) - .thenRun(() -> log.info("刷新成功!")) + .supplyAsync(() -> { + // 1. 从API获取所有插件 + return PluginApiService.fetchAllPlugins(executorService); + }, executorService) + .thenApply(pluginList -> { + // 2. 过滤插件(排除已存在和免费的) + return PluginProcessService.filterPlugins(pluginList, pluginCacheList); + }) + .thenApply(filteredList -> { + // 3. 转换为缓存对象 + return PluginProcessService.convertToCache(filteredList); + }) + .thenAccept(newPlugins -> { + // 4. 保存到缓存 + saveNewPlugins(newPlugins); + }) + .thenRun(() -> log.info("多线程刷新成功!")) .exceptionally(throwable -> { - log.error("刷新失败!", throwable); + log.error("多线程刷新失败!", throwable); return null; }); } - public static void overrideJsonFile(List pluginCaches) { - log.info("源大小 => [{}], 新增大小 => [{}]", pluginCacheList.size(), pluginCaches.size()); - pluginCacheList.addAll(pluginCaches); - String jsonStr = JSONUtil.toJsonStr(pluginCacheList); - try { - FileUtil.writeString(JSONUtil.formatJsonStr(jsonStr), pluginsJsonFile, StandardCharsets.UTF_8); - log.info("Json文件已覆写!"); - } catch (IORuntimeException e) { - throw new IllegalArgumentException(CharSequenceUtil.format("{} 文件写入失败!", PLUGIN_JSON_FILE_NAME), e); + /** + * 保存新插件到缓存 + * + * @param newPlugins 新获取的插件列表 + */ + private static void saveNewPlugins(List newPlugins) { + if (newPlugins == null || newPlugins.isEmpty()) { + log.info("没有新的插件需要保存"); + return; } + log.info("源大小 => [{}], 新增大小 => [{}]", pluginCacheList.size(), newPlugins.size()); + + // 合并到内存缓存 + pluginCacheList = PluginCacheService.mergeCache(pluginCacheList, newPlugins); + + // 保存到文件 + PluginCacheService.saveToCache(pluginCacheList); + + log.info("插件缓存已更新,当前总数: {}", pluginCacheList.size()); } - public static PluginList pluginList() { - return HttpUtil.createGet(PLUGIN_LIST_URL) - .thenFunction(response -> { - try (InputStream is = response.bodyStream()) { - if (!response.isOk()) { - throw new IllegalArgumentException( - CharSequenceUtil.format("{} 请求失败! = {}", PLUGIN_LIST_URL, response)); - } - PluginList pluginList = JSONUtil.toBean(IoUtil.readUtf8(is), PluginList.class); - log.info("获取大小 => [{}]", pluginList.getTotal()); - return pluginList; - } catch (IOException e) { - throw new IllegalArgumentException(CharSequenceUtil.format("{} 请求IO读取失败!", PLUGIN_LIST_URL), - e); - } + /** + * 初始化线程池 + * + * @param threadCount 线程数量 + */ + private static void initExecutorService(int threadCount) { + if (executorService == null || executorService.isShutdown()) { + executorService = Executors.newFixedThreadPool(threadCount, r -> { + Thread thread = new Thread(r, "PluginRefresh-"); + thread.setDaemon(true); + return thread; }); + log.debug("线程池已创建,线程数: {}", threadCount); + } } - public static List pluginListFilter(PluginList pluginList) { - List plugins = pluginList.getPlugins() - .stream() - .filter(plugin -> !PluginsContextHolder.pluginCacheList.contains(new PluginCache().setId(plugin.getId()))) - .filter(plugin -> !CharSequenceUtil.equals(plugin.getPricingModel(), "FREE")) - .collect(Collectors.toList()); - log.info("过滤后大小 => [{}]", plugins.size()); - return plugins; - } - - public static List pluginConversion(List pluginList) { - List list = pluginList - .stream() - .parallel() - .map(plugin -> { - String productCode = pluginInfo(plugin).getPurchaseInfo().getProductCode(); - return new PluginCache() - .setId(plugin.getId()) - .setProductCode(productCode) - .setName(plugin.getName()) - .setPricingModel(plugin.getPricingModel()) - .setIcon(StrUtil.isNotBlank(plugin.getIcon()) ? PLUGIN_BASIC_URL + plugin.getIcon() : null) - ; - }) - .collect(Collectors.toList()); - log.info("转换后大小 => [{}]", list.size()); - return list; - } - - public static PluginInfo pluginInfo(PluginList.Plugin plugin) { - return HttpUtil.createGet(PLUGIN_INFO_URL + plugin.getId()) - .thenFunction(response -> { - try (InputStream is = response.bodyStream()) { - if (!response.isOk()) { - throw new IllegalArgumentException( - CharSequenceUtil.format("{} 请求失败! = {}", PLUGIN_INFO_URL, response)); - } - PluginInfo pluginInfo = JSONUtil.toBean(IoUtil.readUtf8(is), PluginInfo.class); - log.info("已抓取 => ID = [{}], 名称 = [{}], Code = [{}]", pluginInfo.getId(), plugin.getName(), - pluginInfo.getPurchaseInfo().getProductCode()); - return pluginInfo; - } catch (IOException e) { - throw new IllegalArgumentException(CharSequenceUtil.format("{} 请求IO读取失败!", PLUGIN_LIST_URL), - e); + /** + * 清理资源 + */ + public static void shutdown() { + if (executorService != null && !executorService.isShutdown()) { + log.info("正在关闭插件刷新线程池..."); + executorService.shutdown(); + try { + if (!executorService.awaitTermination(10, TimeUnit.SECONDS)) { + executorService.shutdownNow(); } - }); - } - - - @Data - @Accessors(chain = true) - public static class PluginCache { - - private Long id; - private String productCode; - private String name; - private String pricingModel; - private String icon; - - @Override - public final boolean equals(Object o) { - if (this == o) { - return true; + log.info("插件刷新线程池已关闭"); + } catch (InterruptedException e) { + executorService.shutdownNow(); + Thread.currentThread().interrupt(); + log.warn("线程池关闭被中断"); } - if (!(o instanceof PluginCache)) { - return false; - } - - return id.equals(((PluginCache) o).id); - } - - @Override - public int hashCode() { - return id.hashCode(); } } - - @Data - @Accessors(chain = true) - public static class PluginInfo { - - private Long id; - - private PurchaseInfo purchaseInfo; - - @Data - @Accessors(chain = true) - public static class PurchaseInfo { - - private String productCode; - } - } - - @Data - @Accessors(chain = true) - public static class PluginList { - - private List plugins; - private Long total; - - - @Data - @Accessors(chain = true) - public static class Plugin { - - private Long id; - private String name; - private String preview; - private Integer downloads; - private String pricingModel; - private String organization; - private String icon; - private String previewImage; - private Double rating; - private VendorInfo vendorInfo; - } - - @Data - @Accessors(chain = true) - public static class VendorInfo { - - private String name; - private Boolean isVerified; - } - } -} +} \ No newline at end of file diff --git a/src/main/java/com/qiumo/help/context/plugin/PluginConfig.java b/src/main/java/com/qiumo/help/context/plugin/PluginConfig.java new file mode 100644 index 0000000..ec20af4 --- /dev/null +++ b/src/main/java/com/qiumo/help/context/plugin/PluginConfig.java @@ -0,0 +1,111 @@ +package com.qiumo.help.context.plugin; + +import cn.hutool.extra.spring.SpringUtil; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.env.Environment; + +/** + * 插件配置管理类 + * + *

统一管理插件相关的所有配置项,避免配置获取逻辑散落在各处。 + * 使用单例模式确保配置的一致性。 + * + * @author QiuMo + * @version 1.0.0 + */ +@Slf4j +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class PluginConfig { + + // ==================== 常量定义 ==================== + + /** JetBrains插件市场基础URL */ + public static final String PLUGIN_BASIC_URL = "https://plugins.jetbrains.com"; + + /** 插件列表API地址模板 */ + public static final String PLUGIN_LIST_URL_TEMPLATE = + PLUGIN_BASIC_URL + "/api/searchPlugins?max=%d&offset=%d&orderBy=name"; + + /** 插件详情API地址模板 */ + public static final String PLUGIN_INFO_URL = PLUGIN_BASIC_URL + "/api/plugins/"; + + /** 插件信息缓存文件路径 */ + public static final String PLUGIN_JSON_FILE_NAME = "external/data/plugin.json"; + + // ==================== 配置字段 ==================== + + /** 是否启用刷新功能 */ + private boolean refreshEnabled; + + /** 分页大小 */ + private int pageSize; + + /** 线程数量 */ + private int threadCount; + + /** 请求超时时间(毫秒) */ + private int timeout; + + // ==================== 单例实现 ==================== + + private static volatile PluginConfig instance; + + /** + * 获取配置实例 + * + * @return 配置实例 + */ + public static PluginConfig getInstance() { + if (instance == null) { + synchronized (PluginConfig.class) { + if (instance == null) { + instance = new PluginConfig(); + instance.loadConfig(); + } + } + } + return instance; + } + + /** + * 从Spring环境中加载配置 + */ + private void loadConfig() { + try { + Environment environment = SpringUtil.getBean(Environment.class); + + this.refreshEnabled = environment.getProperty("help.plugins.refresh-enabled", Boolean.class, true); + this.pageSize = environment.getProperty("help.plugins.page-size", Integer.class, 20); + this.threadCount = environment.getProperty("help.plugins.thread-count", Integer.class, 5); + this.timeout = environment.getProperty("help.plugins.timeout", Integer.class, 30000); + + log.debug("插件配置加载完成 -> 刷新启用: {}, 分页大小: {}, 线程数: {}, 超时: {}ms", + refreshEnabled, pageSize, threadCount, timeout); + + } catch (Exception e) { + log.warn("加载插件配置失败,使用默认值", e); + setDefaultValues(); + } + } + + /** + * 设置默认配置值 + */ + private void setDefaultValues() { + this.refreshEnabled = true; + this.pageSize = 20; + this.threadCount = 20; + this.timeout = 30000; + } + + /** + * 重新加载配置 + */ + public void reload() { + loadConfig(); + } +} diff --git a/src/main/java/com/qiumo/help/context/plugin/model/PluginCache.java b/src/main/java/com/qiumo/help/context/plugin/model/PluginCache.java new file mode 100644 index 0000000..811c4fa --- /dev/null +++ b/src/main/java/com/qiumo/help/context/plugin/model/PluginCache.java @@ -0,0 +1,47 @@ +package com.qiumo.help.context.plugin.model; + +import lombok.Data; +import lombok.experimental.Accessors; + +/** + * 插件缓存数据模型 + * + * @author QiuMo + * @version 1.0.0 + */ +@Data +@Accessors(chain = true) +public class PluginCache { + + /** 插件ID */ + private Long id; + + /** 产品代码 */ + private String productCode; + + /** 插件名称 */ + private String name; + + /** 定价模式 */ + private String pricingModel; + + /** 插件图标URL */ + private String icon; + + @Override + public final boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof PluginCache)) { + return false; + } + + return id.equals(((PluginCache) o).id); + } + + @Override + public int hashCode() { + return id.hashCode(); + } +} \ No newline at end of file diff --git a/src/main/java/com/qiumo/help/context/plugin/model/PluginInfo.java b/src/main/java/com/qiumo/help/context/plugin/model/PluginInfo.java new file mode 100644 index 0000000..e9d8408 --- /dev/null +++ b/src/main/java/com/qiumo/help/context/plugin/model/PluginInfo.java @@ -0,0 +1,32 @@ +package com.qiumo.help.context.plugin.model; + +import lombok.Data; +import lombok.experimental.Accessors; + +/** + * 插件详细信息数据模型 + * + * @author QiuMo + * @version 1.0.0 + */ +@Data +@Accessors(chain = true) +public class PluginInfo { + + /** 插件ID */ + private Long id; + + /** 购买信息 */ + private PurchaseInfo purchaseInfo; + + /** + * 购买信息 + */ + @Data + @Accessors(chain = true) + public static class PurchaseInfo { + + /** 产品代码 */ + private String productCode; + } +} \ No newline at end of file diff --git a/src/main/java/com/qiumo/help/context/plugin/model/PluginList.java b/src/main/java/com/qiumo/help/context/plugin/model/PluginList.java new file mode 100644 index 0000000..3eaf565 --- /dev/null +++ b/src/main/java/com/qiumo/help/context/plugin/model/PluginList.java @@ -0,0 +1,75 @@ +package com.qiumo.help.context.plugin.model; + +import lombok.Data; +import lombok.experimental.Accessors; + +import java.util.List; + +/** + * 插件列表数据模型 + * + * @author QiuMo + * @version 1.0.0 + */ +@Data +@Accessors(chain = true) +public class PluginList { + + /** 插件列表 */ + private List plugins; + + /** 插件总数 */ + private Long total; + + /** + * 插件基本信息 + */ + @Data + @Accessors(chain = true) + public static class Plugin { + + /** 插件ID */ + private Long id; + + /** 插件名称 */ + private String name; + + /** 插件预览描述 */ + private String preview; + + /** 下载次数 */ + private Integer downloads; + + /** 定价模式(FREE/FREEMIUM/PAID) */ + private String pricingModel; + + /** 组织名称 */ + private String organization; + + /** 插件图标路径 */ + private String icon; + + /** 预览图片路径 */ + private String previewImage; + + /** 评分 */ + private Double rating; + + /** 开发商信息 */ + private VendorInfo vendorInfo; + } + + /** + * 开发商信息 + */ + @Data + @Accessors(chain = true) + public static class VendorInfo { + + /** 开发商名称 */ + private String name; + + /** 是否为认证开发商 */ + private Boolean isVerified; + } +} \ No newline at end of file diff --git a/src/main/java/com/qiumo/help/context/plugin/service/PluginApiService.java b/src/main/java/com/qiumo/help/context/plugin/service/PluginApiService.java new file mode 100644 index 0000000..01d47a7 --- /dev/null +++ b/src/main/java/com/qiumo/help/context/plugin/service/PluginApiService.java @@ -0,0 +1,204 @@ +package com.qiumo.help.context.plugin.service; + +import cn.hutool.core.io.IoUtil; +import cn.hutool.core.text.CharSequenceUtil; +import cn.hutool.http.HttpUtil; +import cn.hutool.json.JSONUtil; +import com.qiumo.help.context.plugin.PluginConfig; +import com.qiumo.help.context.plugin.model.PluginInfo; +import com.qiumo.help.context.plugin.model.PluginList; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 插件API服务类 + * + *

负责与JetBrains插件市场API的所有网络交互,包括: + *

+ * + * @author QiuMo + * @version 1.0.0 + */ +@Slf4j +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class PluginApiService { + + private static final PluginConfig config = PluginConfig.getInstance(); + + /** + * 使用多线程分页获取所有插件信息 + * + * @param executorService 线程池 + * @return 包含所有插件的PluginList对象 + * @throws RuntimeException 当无法获取插件数据时 + */ + public static PluginList fetchAllPlugins(ExecutorService executorService) { + log.info("开始多线程获取插件列表,分页大小: {}, 线程数: {}", + config.getPageSize(), config.getThreadCount()); + + // 首先获取第一页,确定总数 + PluginList firstPage = fetchPluginPage(0, config.getPageSize()); + if (firstPage == null || firstPage.getTotal() == null) { + throw new RuntimeException("无法获取插件总数"); + } + + long totalPlugins = firstPage.getTotal(); + int totalPages = (int) ((totalPlugins + config.getPageSize() - 1) / config.getPageSize()); + + log.info("插件总数: {}, 预计需要 {} 页", totalPlugins, totalPages); + + // 创建结果收集器 + List allPlugins = new ArrayList<>(firstPage.getPlugins()); + List> futures = new ArrayList<>(); + + // 创建并发任务获取剩余页面 + for (int page = 1; page < totalPages; page++) { + final int offset = page * config.getPageSize(); + + CompletableFuture future = CompletableFuture.supplyAsync(() -> { + try { + return fetchPluginPage(offset, config.getPageSize()); + } catch (Exception e) { + log.error("获取插件页面失败 (offset: {})", offset, e); + return null; + } + }, executorService); + + futures.add(future); + } + + // 等待所有页面获取完成并收集结果 + collectResults(futures, allPlugins, totalPages); + + // 返回合并结果 + PluginList result = new PluginList(); + result.setPlugins(allPlugins); + result.setTotal((long) allPlugins.size()); + + return result; + } + + /** + * 获取指定页面的插件信息 + * + * @param offset 偏移量 + * @param pageSize 页面大小 + * @return 插件列表页面数据 + */ + public static PluginList fetchPluginPage(int offset, int pageSize) { + String url = String.format(PluginConfig.PLUGIN_LIST_URL_TEMPLATE, pageSize, offset); + log.debug("请求插件页面: offset={}, pageSize={}, url={}", offset, pageSize, url); + + try { + return HttpUtil.createGet(url) + .timeout(config.getTimeout()) + .thenFunction(response -> { + try (InputStream is = response.bodyStream()) { + if (!response.isOk()) { + throw new IllegalArgumentException( + String.format("请求失败! URL: %s, Response: %s", url, response)); + } + + PluginList pluginList = JSONUtil.toBean(IoUtil.readUtf8(is), PluginList.class); + log.debug("成功获取页面 offset={}, 获取插件数: {}", offset, + pluginList.getPlugins() != null ? pluginList.getPlugins().size() : 0); + return pluginList; + + } catch (IOException e) { + throw new IllegalArgumentException( + String.format("请求IO读取失败! URL: %s", url), e); + } + }); + } catch (Exception e) { + log.error("获取插件页面失败: offset={}, pageSize={}", offset, pageSize, e); + return null; + } + } + + /** + * 获取插件详细信息 + * + * @param plugin 插件基本信息 + * @return 插件详细信息 + * @throws IllegalArgumentException 当请求失败时 + */ + public static PluginInfo fetchPluginInfo(PluginList.Plugin plugin) { + String url = PluginConfig.PLUGIN_INFO_URL + plugin.getId(); + + return HttpUtil.createGet(url) + .timeout(config.getTimeout()) + .thenFunction(response -> { + try (InputStream is = response.bodyStream()) { + if (!response.isOk()) { + throw new IllegalArgumentException( + CharSequenceUtil.format("{} 请求失败! = {}", url, response)); + } + + PluginInfo pluginInfo = JSONUtil.toBean(IoUtil.readUtf8(is), PluginInfo.class); + log.debug("已抓取 => ID = [{}], 名称 = [{}], Code = [{}]", + pluginInfo.getId(), plugin.getName(), + pluginInfo.getPurchaseInfo().getProductCode()); + return pluginInfo; + + } catch (IOException e) { + throw new IllegalArgumentException( + CharSequenceUtil.format("{} 请求IO读取失败!", url), e); + } + }); + } + + /** + * 收集并发请求的结果 + * + * @param futures 异步任务列表 + * @param allPlugins 结果收集器 + * @param totalPages 总页数 + */ + private static void collectResults(List> futures, + List allPlugins, + int totalPages) { + try { + CompletableFuture allOf = CompletableFuture.allOf( + futures.toArray(new CompletableFuture[0]) + ); + + // 添加超时控制 + allOf.get(config.getTimeout() * totalPages / 1000, TimeUnit.SECONDS); + + // 收集所有结果 + AtomicInteger successCount = new AtomicInteger(1); // 包含第一页 + for (CompletableFuture future : futures) { + PluginList pageResult = future.get(); + if (pageResult != null && pageResult.getPlugins() != null) { + allPlugins.addAll(pageResult.getPlugins()); + successCount.incrementAndGet(); + } else { + log.warn("某一页插件获取失败,跳过该页"); + } + } + + log.info("多线程获取完成,成功获取 {} 页,总插件数: {}", + successCount.get(), allPlugins.size()); + + } catch (TimeoutException e) { + log.error("获取插件超时,已获取部分结果,插件数: {}", allPlugins.size()); + } catch (Exception e) { + log.error("获取插件过程中发生异常", e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/qiumo/help/context/plugin/service/PluginCacheService.java b/src/main/java/com/qiumo/help/context/plugin/service/PluginCacheService.java new file mode 100644 index 0000000..7af2dc4 --- /dev/null +++ b/src/main/java/com/qiumo/help/context/plugin/service/PluginCacheService.java @@ -0,0 +1,127 @@ +package com.qiumo.help.context.plugin.service; + +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.io.IORuntimeException; +import cn.hutool.core.io.IoUtil; +import cn.hutool.core.text.CharSequenceUtil; +import cn.hutool.json.JSONUtil; +import com.qiumo.help.context.plugin.PluginConfig; +import com.qiumo.help.context.plugin.model.PluginCache; +import com.qiumo.help.util.FileTools; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** + * 插件缓存服务类 + * + *

负责插件数据的本地缓存管理,包括: + *

    + *
  • 从本地文件加载缓存数据
  • + *
  • 保存数据到本地文件
  • + *
  • 缓存数据的合并和更新
  • + *
+ * + * @author QiuMo + * @version 1.0.0 + */ +@Slf4j +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class PluginCacheService { + + private static File cacheFile; + + /** + * 初始化缓存服务 + * + * @return 缓存文件对象 + */ + public static File initCacheFile() { + if (cacheFile == null) { + cacheFile = FileTools.getFileOrCreat(PluginConfig.PLUGIN_JSON_FILE_NAME); + log.debug("插件缓存文件路径: {}", cacheFile.getAbsolutePath()); + } + return cacheFile; + } + + /** + * 从缓存文件加载插件数据 + * + * @return 插件缓存列表 + * @throws IllegalArgumentException 当文件读取失败时 + */ + public static List loadFromCache() { + File file = initCacheFile(); + + try { + String jsonContent = IoUtil.readUtf8(FileUtil.getInputStream(file)); + + if (CharSequenceUtil.isBlank(jsonContent) || !JSONUtil.isTypeJSON(jsonContent)) { + log.warn("插件缓存文件为空或格式错误,返回空列表"); + return new ArrayList<>(); + } + + List cacheList = JSONUtil.toList(jsonContent, PluginCache.class); + log.info("从缓存加载插件数据成功,插件数量: {}", cacheList.size()); + return cacheList; + + } catch (IORuntimeException e) { + throw new IllegalArgumentException( + CharSequenceUtil.format("{} 文件读取失败!", PluginConfig.PLUGIN_JSON_FILE_NAME), e); + } + } + + /** + * 保存插件数据到缓存文件 + * + * @param pluginCaches 要保存的插件数据列表 + * @throws IllegalArgumentException 当文件写入失败时 + */ + public static void saveToCache(List pluginCaches) { + File file = initCacheFile(); + + try { + String jsonStr = JSONUtil.toJsonStr(pluginCaches); + String formattedJson = JSONUtil.formatJsonStr(jsonStr); + + FileUtil.writeString(formattedJson, file, StandardCharsets.UTF_8); + log.info("插件数据保存到缓存成功,插件数量: {}", pluginCaches.size()); + + } catch (IORuntimeException e) { + throw new IllegalArgumentException( + CharSequenceUtil.format("{} 文件写入失败!", PluginConfig.PLUGIN_JSON_FILE_NAME), e); + } + } + + /** + * 合并新数据到现有缓存 + * + * @param existingCache 现有缓存数据 + * @param newData 新的插件数据 + * @return 合并后的数据列表 + */ + public static List mergeCache(List existingCache, List newData) { + if (existingCache == null) { + existingCache = new ArrayList<>(); + } + + log.info("合并缓存数据 -> 原有数量: {}, 新增数量: {}", existingCache.size(), newData.size()); + + existingCache.addAll(newData); + return existingCache; + } + + /** + * 获取缓存文件对象 + * + * @return 缓存文件对象 + */ + public static File getCacheFile() { + return initCacheFile(); + } +} \ No newline at end of file diff --git a/src/main/java/com/qiumo/help/context/plugin/service/PluginProcessService.java b/src/main/java/com/qiumo/help/context/plugin/service/PluginProcessService.java new file mode 100644 index 0000000..fbb8731 --- /dev/null +++ b/src/main/java/com/qiumo/help/context/plugin/service/PluginProcessService.java @@ -0,0 +1,158 @@ +package com.qiumo.help.context.plugin.service; + +import cn.hutool.core.text.CharSequenceUtil; +import cn.hutool.core.util.StrUtil; +import com.qiumo.help.context.plugin.PluginConfig; +import com.qiumo.help.context.plugin.model.PluginCache; +import com.qiumo.help.context.plugin.model.PluginInfo; +import com.qiumo.help.context.plugin.model.PluginList; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 插件处理服务类 + * + *

负责插件数据的业务逻辑处理,包括: + *

    + *
  • 插件数据的过滤和转换
  • + *
  • 去重和数据清洗
  • + *
  • 业务规则应用
  • + *
+ * + * @author QiuMo + * @version 1.0.0 + */ +@Slf4j +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class PluginProcessService { + + /** + * 过滤插件列表 + * + *

过滤条件: + *

    + *
  • 排除已存在于缓存中的插件
  • + *
  • 只保留付费插件(排除FREE类型)
  • + *
+ * + * @param pluginList 原始插件列表 + * @param existingCache 现有缓存数据 + * @return 过滤后的插件列表 + */ + public static List filterPlugins(PluginList pluginList, List existingCache) { + if (pluginList == null || pluginList.getPlugins() == null) { + log.warn("插件列表为空,返回空结果"); + return Collections.emptyList(); + } + + List filteredPlugins = pluginList.getPlugins() + .stream() + .filter(plugin -> !isPluginExists(plugin, existingCache)) + .filter(plugin -> !isFreePlugin(plugin)) + .collect(Collectors.toList()); + + log.info("插件过滤完成 -> 原始数量: {}, 过滤后数量: {}", + pluginList.getPlugins().size(), filteredPlugins.size()); + + return filteredPlugins; + } + + /** + * 将插件基本信息转换为缓存对象 + * + * @param pluginList 插件基本信息列表 + * @return 插件缓存对象列表 + */ + public static List convertToCache(List pluginList) { + if (pluginList == null || pluginList.isEmpty()) { + log.info("没有需要转换的插件数据"); + return Collections.emptyList(); + } + + List cacheList = pluginList + .parallelStream() + .map(PluginProcessService::convertSinglePlugin) + .filter(cache -> cache != null) + .collect(Collectors.toList()); + + log.info("插件转换完成 -> 转换数量: {}", cacheList.size()); + return cacheList; + } + + /** + * 转换单个插件信息 + * + * @param plugin 插件基本信息 + * @return 插件缓存对象,如果转换失败返回null + */ + private static PluginCache convertSinglePlugin(PluginList.Plugin plugin) { + try { + PluginInfo pluginInfo = PluginApiService.fetchPluginInfo(plugin); + if (pluginInfo == null || pluginInfo.getPurchaseInfo() == null) { + log.warn("插件详情获取失败,跳过插件: {}", plugin.getName()); + return null; + } + + String productCode = pluginInfo.getPurchaseInfo().getProductCode(); + if (CharSequenceUtil.isBlank(productCode)) { + log.warn("插件产品代码为空,跳过插件: {}", plugin.getName()); + return null; + } + + return new PluginCache() + .setId(plugin.getId()) + .setProductCode(productCode) + .setName(plugin.getName()) + .setPricingModel(plugin.getPricingModel()) + .setIcon(buildIconUrl(plugin.getIcon())); + + } catch (Exception e) { + log.error("转换插件信息失败: {} (ID: {})", plugin.getName(), plugin.getId(), e); + return null; + } + } + + /** + * 构建插件图标完整URL + * + * @param iconPath 图标路径 + * @return 完整的图标URL,如果路径为空则返回null + */ + private static String buildIconUrl(String iconPath) { + if (StrUtil.isBlank(iconPath)) { + return null; + } + return PluginConfig.PLUGIN_BASIC_URL + iconPath; + } + + /** + * 检查插件是否已存在于缓存中 + * + * @param plugin 插件基本信息 + * @param existingCache 现有缓存 + * @return 如果存在返回true,否则返回false + */ + private static boolean isPluginExists(PluginList.Plugin plugin, List existingCache) { + if (existingCache == null || existingCache.isEmpty()) { + return false; + } + + PluginCache targetCache = new PluginCache().setId(plugin.getId()); + return existingCache.contains(targetCache); + } + + /** + * 检查是否为免费插件 + * + * @param plugin 插件基本信息 + * @return 如果是免费插件返回true,否则返回false + */ + private static boolean isFreePlugin(PluginList.Plugin plugin) { + return CharSequenceUtil.equals(plugin.getPricingModel(), "FREE"); + } +} \ No newline at end of file diff --git a/src/main/java/com/qiumo/help/controller/DataController.java b/src/main/java/com/qiumo/help/controller/DataController.java index 267bb5d..7d4aa9f 100644 --- a/src/main/java/com/qiumo/help/controller/DataController.java +++ b/src/main/java/com/qiumo/help/controller/DataController.java @@ -6,6 +6,7 @@ import cn.hutool.core.util.StrUtil; import com.qiumo.help.context.LicenseContextHolder; import com.qiumo.help.context.PluginsContextHolder; import com.qiumo.help.context.ProductsContextHolder; +import com.qiumo.help.context.plugin.model.PluginCache; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -121,7 +122,7 @@ public class DataController { * @return JetBrains付费插件信息列表 */ @GetMapping("/plugins") - public List getPlugins() { + public List getPlugins() { log.debug("获取插件列表,插件数量: {}", PluginsContextHolder.pluginCacheList().size()); return PluginsContextHolder.pluginCacheList(); } @@ -156,7 +157,7 @@ public class DataController { List pluginCodeList = PluginsContextHolder.pluginCacheList() .stream() - .map(PluginsContextHolder.PluginCache::getProductCode) + .map(PluginCache::getProductCode) .filter(StrUtil::isNotBlank) .collect(Collectors.toList()); @@ -193,7 +194,7 @@ public class DataController { String productCode = PluginsContextHolder.pluginCacheList() .stream() .filter(plugin -> Objects.equals(plugin.getId(), pluginId)) - .map(PluginsContextHolder.PluginCache::getProductCode) + .map(PluginCache::getProductCode) .filter(StrUtil::isNotBlank) .findFirst() .orElse(""); diff --git a/src/main/java/com/qiumo/help/controller/LicenseCodeController.java b/src/main/java/com/qiumo/help/controller/LicenseCodeController.java index 7753e9d..829b7f4 100644 --- a/src/main/java/com/qiumo/help/controller/LicenseCodeController.java +++ b/src/main/java/com/qiumo/help/controller/LicenseCodeController.java @@ -6,6 +6,7 @@ import cn.hutool.core.util.StrUtil; import com.qiumo.help.context.LicenseContextHolder; import com.qiumo.help.context.PluginsContextHolder; import com.qiumo.help.context.ProductsContextHolder; +import com.qiumo.help.context.plugin.model.PluginCache; import java.util.Collection; import java.util.List; import java.util.Objects; @@ -178,7 +179,7 @@ public class LicenseCodeController { // 获取所有付费插件代码 List pluginCodeList = PluginsContextHolder.pluginCacheList() .stream() - .map(PluginsContextHolder.PluginCache::getProductCode) // 提取插件产品代码 + .map(PluginCache::getProductCode) // 提取插件产品代码 .filter(StrUtil::isNotBlank) // 过滤空值 .collect(Collectors.toList()); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 53febc9..a2f28dd 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,7 +3,13 @@ spring: name: QiuMo-Jetbrains-Help server: port: 10768 -help: - default-license-name: QiuMo - default-assignee-name: 囚墨 - default-expiry-date: 2111-11-11 + # 插件信息获取配置 + plugins: + # 是否启用定时刷新任务(true/false) + refresh-enabled: true + # 分页大小(每次请求获取的插件数量,建议不超过20) + page-size: 20 + # 并发线程数(用于并行请求不同页面的插件数据) + thread-count: 20 + # 请求超时时间(毫秒) + timeout: 30000