此方法负责初始化插件信息管理系统,包括加载本地缓存和启动刷新任务。
* 如果本地缓存文件不存在或格式错误,会自动从网络获取最新数据。
- *
- *
返回已加载的所有JetBrains付费插件信息列表。
- * 该列表包含插件ID、名称、产品代码、定价模式和图标等信息。
- *
- *
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