重构插件上下文,新增多线程获取插件列表

This commit is contained in:
M0914
2025-10-09 15:05:27 +08:00
parent f2fb339e30
commit 6bc811332c
12 changed files with 922 additions and 282 deletions
@@ -7,6 +7,7 @@ import com.qiumo.help.context.AgentContextHolder;
import com.qiumo.help.context.CertificateContextHolder; import com.qiumo.help.context.CertificateContextHolder;
import com.qiumo.help.context.PluginsContextHolder; import com.qiumo.help.context.PluginsContextHolder;
import com.qiumo.help.context.ProductsContextHolder; import com.qiumo.help.context.ProductsContextHolder;
import com.qiumo.help.context.plugin.PluginConfig;
import java.net.InetAddress; import java.net.InetAddress;
import lombok.SneakyThrows; import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j; 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.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import javax.annotation.PreDestroy;
/** /**
* QiuMo JetBrains Help 应用程序主入口类 * QiuMo JetBrains Help 应用程序主入口类
* *
@@ -102,6 +105,7 @@ public class QiuMoJetbrainsHelpApplication {
* *
* <p>每天中午12点执行一次,从JetBrains官网获取最新的插件信息。 * <p>每天中午12点执行一次,从JetBrains官网获取最新的插件信息。
* 此任务采用异步执行方式,不会阻塞应用程序的正常运行。 * 此任务采用异步执行方式,不会阻塞应用程序的正常运行。
* 支持通过配置文件开关控制是否启用该定时任务。
* *
* <p>Cron表达式说明:"0 0 12 * * ?" * <p>Cron表达式说明:"0 0 12 * * ?"
* <ul> * <ul>
@@ -112,14 +116,38 @@ public class QiuMoJetbrainsHelpApplication {
* <li>* - 月份(每个月)</li> * <li>* - 月份(每个月)</li>
* <li>? - 星期(任意)</li> * <li>? - 星期(任意)</li>
* </ul> * </ul>
*
* <p>配置说明:
* <ul>
* <li>help.plugins.refresh-enabled: 控制定时任务是否启用</li>
* <li>help.plugins.page-size: 分页大小,建议不超过20</li>
* <li>help.plugins.thread-count: 并发线程数</li>
* <li>help.plugins.timeout: 请求超时时间</li>
* </ul>
*/ */
@Scheduled(cron = "0 0 12 * * ?") @Scheduled(cron = "0 0 12 * * ?")
public void refresh() { public void refresh() {
// 检查是否启用定时刷新功能
PluginConfig config = PluginConfig.getInstance();
if (!config.isRefreshEnabled()) {
log.info("插件定时刷新功能已禁用,跳过本次刷新任务");
return;
}
log.info("开始执行定时刷新插件信息任务..."); log.info("开始执行定时刷新插件信息任务...");
// 异步执行插件信息刷新任务,避免阻塞主线程 // 异步执行插件信息刷新任务,避免阻塞主线程
ThreadUtil.execute(PluginsContextHolder::refreshJsonFile); ThreadUtil.execute(PluginsContextHolder::refreshJsonFile);
} }
/**
* 应用关闭时的清理工作
*/
@PreDestroy
public void onDestroy() {
log.info("应用程序正在关闭,清理资源...");
PluginsContextHolder.shutdown();
}
/** /**
* 构建应用启动成功消息 * 构建应用启动成功消息
* *
@@ -1,26 +1,20 @@
package com.qiumo.help.context; package com.qiumo.help.context;
import cn.hutool.core.io.FileUtil; import com.qiumo.help.context.plugin.PluginConfig;
import cn.hutool.core.io.IORuntimeException; import com.qiumo.help.context.plugin.model.PluginCache;
import cn.hutool.core.io.IoUtil; import com.qiumo.help.context.plugin.model.PluginList;
import cn.hutool.core.text.CharSequenceUtil; import com.qiumo.help.context.plugin.service.PluginApiService;
import cn.hutool.core.util.StrUtil; import com.qiumo.help.context.plugin.service.PluginCacheService;
import cn.hutool.http.HttpUtil; import com.qiumo.help.context.plugin.service.PluginProcessService;
import cn.hutool.json.JSONUtil;
import com.qiumo.help.util.FileTools;
import java.util.stream.Collectors;
import lombok.AccessLevel; import lombok.AccessLevel;
import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import java.io.File; import java.util.List;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
/** /**
* JetBrains插件信息上下文管理器 * JetBrains插件信息上下文管理器
@@ -36,59 +30,29 @@ import java.util.concurrent.CompletableFuture;
* <li>为许可证生成提供插件产品代码</li> * <li>为许可证生成提供插件产品代码</li>
* </ul> * </ul>
* *
* <p>数据流程 * <p>架构优化
* <ol>
* <li>从本地JSON文件加载已缓存的插件信息</li>
* <li>定期调用JetBrains API获取最新插件列表</li>
* <li>过滤掉免费插件,只保留付费插件</li>
* <li>获取每个插件的详细信息(特别是产品代码)</li>
* <li>更新本地缓存文件</li>
* </ol>
*
* <p>API端点:
* <ul> * <ul>
* <li>插件列表:{@code https://plugins.jetbrains.com/api/searchPlugins}</li> * <li>配置管理 - {@link PluginConfig}: 统一管理所有配置参数</li>
* <li>插件详情:{@code https://plugins.jetbrains.com/api/plugins/{pluginId}}</li> * <li>网络服务 - {@link PluginApiService}: 处理所有API请求</li>
* </ul> * <li>缓存服务 - {@link PluginCacheService}: 管理本地文件缓存</li>
* * <li>业务处理 - {@link PluginProcessService}: 处理数据转换和过滤</li>
* <p>缓存策略:
* <ul>
* <li>本地JSON文件存储:{@code external/data/plugin.json}</li>
* <li>定时刷新:每天中午12点自动刷新一次</li>
* <li>增量更新:只获取新增的付费插件</li>
* </ul> * </ul>
* *
* @author QiuMo * @author QiuMo
* @version 1.0.0 * @version 2.0.0
* @since 1.0.0 * @since 1.0.0
*/ */
@Slf4j(topic = "插件上下文") @Slf4j(topic = "插件上下文")
@NoArgsConstructor(access = AccessLevel.PRIVATE) @NoArgsConstructor(access = AccessLevel.PRIVATE)
public class PluginsContextHolder { 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<PluginCache> pluginCacheList; private static List<PluginCache> pluginCacheList;
/** 插件JSON缓存文件对象 */ /** 线程池,用于并发请求插件数据 */
private static File pluginsJsonFile; private static ExecutorService executorService;
// ==================== 核心方法 ==================== // ==================== 核心方法 ====================
@@ -98,250 +62,136 @@ public class PluginsContextHolder {
* <p>此方法负责初始化插件信息管理系统,包括加载本地缓存和启动刷新任务。 * <p>此方法负责初始化插件信息管理系统,包括加载本地缓存和启动刷新任务。
* 如果本地缓存文件不存在或格式错误,会自动从网络获取最新数据。 * 如果本地缓存文件不存在或格式错误,会自动从网络获取最新数据。
* *
* <p>初始化过程:
* <ol>
* <li>获取或创建插件缓存JSON文件</li>
* <li>读取并验证JSON文件内容</li>
* <li>如果数据有效,解析为对象列表</li>
* <li>如果数据无效,初始化为空列表</li>
* <li>启动异步刷新任务获取最新数据</li>
* </ol>
*
* <p>注意事项:
* <ul>
* <li>此方法应在应用启动时调用,且只调用一次</li>
* <li>网络刷新任务是异步执行的,不会阻塞应用启动</li>
* <li>刷新失败不会影响应用正常运行</li>
* </ul>
*
* @throws IllegalArgumentException 当文件读取失败时抛出 * @throws IllegalArgumentException 当文件读取失败时抛出
*/ */
public static void init() { public static void init() {
log.info("开始初始化插件上下文..."); log.info("开始初始化插件上下文...");
// 获取或创建插件缓存文件
pluginsJsonFile = FileTools.getFileOrCreat(PLUGIN_JSON_FILE_NAME);
log.debug("插件缓存文件路径: {}", pluginsJsonFile.getAbsolutePath());
// 读取并验证JSON文件内容
String pluginJsonArray;
try { try {
pluginJsonArray = IoUtil.readUtf8(FileUtil.getInputStream(pluginsJsonFile)); // 从缓存加载插件数据
} catch (IORuntimeException e) { pluginCacheList = PluginCacheService.loadFromCache();
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);
log.info("插件上下文初始化成功!加载插件数量: {}", pluginCacheList.size()); log.info("插件上下文初始化成功!加载插件数量: {}", pluginCacheList.size());
// 启动异步刷新任务获取最新数据 // 启动异步刷新任务获取最新数据
refreshJsonFile(); refreshJsonFile();
} catch (Exception e) {
log.error("插件上下文初始化失败", e);
throw e;
} }
} }
/** /**
* 获取插件信息缓存列表 * 获取插件信息缓存列表
* *
* <p>返回已加载的所有JetBrains付费插件信息列表。
* 该列表包含插件ID、名称、产品代码、定价模式和图标等信息。
*
* <p>使用场景:
* <ul>
* <li>许可证生成时选择插件产品代码</li>
* <li>前端界面显示插件列表</li>
* <li>插件管理和统计功能</li>
* </ul>
*
* <p>注意事项:
* <ul>
* <li>返回的是实际的列表引用,修改会影响缓存</li>
* <li>列表中只包含付费插件,免费插件已被过滤</li>
* <li>需要在 {@link #init()} 方法调用后使用</li>
* </ul>
*
* @return 插件信息缓存列表,不为null * @return 插件信息缓存列表,不为null
*/ */
public static List<PluginCache> pluginCacheList() { public static List<PluginCache> pluginCacheList() {
return PluginsContextHolder.pluginCacheList; return pluginCacheList;
} }
/**
* 刷新插件信息文件
*
* <p>使用多线程分页方式从JetBrains插件市场获取所有插件信息。
* 该方法会根据配置文件中的参数来控制并发数量和分页大小。
*/
public static void refreshJsonFile() { 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 CompletableFuture
.supplyAsync(PluginsContextHolder::pluginList) .supplyAsync(() -> {
.thenApply(PluginsContextHolder::pluginListFilter) // 1. 从API获取所有插件
.thenApply(PluginsContextHolder::pluginConversion) return PluginApiService.fetchAllPlugins(executorService);
.thenAccept(PluginsContextHolder::overrideJsonFile) }, executorService)
.thenRun(() -> log.info("刷新成功!")) .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 -> { .exceptionally(throwable -> {
log.error("刷新失败!", throwable); log.error("多线程刷新失败!", throwable);
return null; return null;
}); });
} }
public static void overrideJsonFile(List<PluginCache> pluginCaches) { /**
log.info("源大小 => [{}], 新增大小 => [{}]", pluginCacheList.size(), pluginCaches.size()); * 保存新插件到缓存
pluginCacheList.addAll(pluginCaches); *
String jsonStr = JSONUtil.toJsonStr(pluginCacheList); * @param newPlugins 新获取的插件列表
*/
private static void saveNewPlugins(List<PluginCache> 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());
}
/**
* 初始化线程池
*
* @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 void shutdown() {
if (executorService != null && !executorService.isShutdown()) {
log.info("正在关闭插件刷新线程池...");
executorService.shutdown();
try { try {
FileUtil.writeString(JSONUtil.formatJsonStr(jsonStr), pluginsJsonFile, StandardCharsets.UTF_8); if (!executorService.awaitTermination(10, TimeUnit.SECONDS)) {
log.info("Json文件已覆写!"); executorService.shutdownNow();
} catch (IORuntimeException e) {
throw new IllegalArgumentException(CharSequenceUtil.format("{} 文件写入失败!", PLUGIN_JSON_FILE_NAME), e);
} }
log.info("插件刷新线程池已关闭");
} catch (InterruptedException e) {
executorService.shutdownNow();
Thread.currentThread().interrupt();
log.warn("线程池关闭被中断");
} }
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);
}
});
}
public static List<PluginList.Plugin> pluginListFilter(PluginList pluginList) {
List<PluginList.Plugin> 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<PluginCache> pluginConversion(List<PluginList.Plugin> pluginList) {
List<PluginCache> 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);
}
});
}
@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;
}
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<Plugin> 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;
} }
} }
} }
@@ -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;
/**
* 插件配置管理类
*
* <p>统一管理插件相关的所有配置项,避免配置获取逻辑散落在各处。
* 使用单例模式确保配置的一致性。
*
* @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();
}
}
@@ -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();
}
}
@@ -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;
}
}
@@ -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<Plugin> 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;
}
}
@@ -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服务类
*
* <p>负责与JetBrains插件市场API的所有网络交互,包括:
* <ul>
* <li>获取插件列表(支持分页)</li>
* <li>获取插件详细信息</li>
* <li>并发请求管理</li>
* </ul>
*
* @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<PluginList.Plugin> allPlugins = new ArrayList<>(firstPage.getPlugins());
List<CompletableFuture<PluginList>> futures = new ArrayList<>();
// 创建并发任务获取剩余页面
for (int page = 1; page < totalPages; page++) {
final int offset = page * config.getPageSize();
CompletableFuture<PluginList> 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<CompletableFuture<PluginList>> futures,
List<PluginList.Plugin> allPlugins,
int totalPages) {
try {
CompletableFuture<Void> allOf = CompletableFuture.allOf(
futures.toArray(new CompletableFuture[0])
);
// 添加超时控制
allOf.get(config.getTimeout() * totalPages / 1000, TimeUnit.SECONDS);
// 收集所有结果
AtomicInteger successCount = new AtomicInteger(1); // 包含第一页
for (CompletableFuture<PluginList> 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);
}
}
}
@@ -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;
/**
* 插件缓存服务类
*
* <p>负责插件数据的本地缓存管理,包括:
* <ul>
* <li>从本地文件加载缓存数据</li>
* <li>保存数据到本地文件</li>
* <li>缓存数据的合并和更新</li>
* </ul>
*
* @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<PluginCache> 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<PluginCache> 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<PluginCache> 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<PluginCache> mergeCache(List<PluginCache> existingCache, List<PluginCache> 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();
}
}
@@ -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;
/**
* 插件处理服务类
*
* <p>负责插件数据的业务逻辑处理,包括:
* <ul>
* <li>插件数据的过滤和转换</li>
* <li>去重和数据清洗</li>
* <li>业务规则应用</li>
* </ul>
*
* @author QiuMo
* @version 1.0.0
*/
@Slf4j
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class PluginProcessService {
/**
* 过滤插件列表
*
* <p>过滤条件:
* <ul>
* <li>排除已存在于缓存中的插件</li>
* <li>只保留付费插件(排除FREE类型)</li>
* </ul>
*
* @param pluginList 原始插件列表
* @param existingCache 现有缓存数据
* @return 过滤后的插件列表
*/
public static List<PluginList.Plugin> filterPlugins(PluginList pluginList, List<PluginCache> existingCache) {
if (pluginList == null || pluginList.getPlugins() == null) {
log.warn("插件列表为空,返回空结果");
return Collections.emptyList();
}
List<PluginList.Plugin> 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<PluginCache> convertToCache(List<PluginList.Plugin> pluginList) {
if (pluginList == null || pluginList.isEmpty()) {
log.info("没有需要转换的插件数据");
return Collections.emptyList();
}
List<PluginCache> 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<PluginCache> 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");
}
}
@@ -6,6 +6,7 @@ import cn.hutool.core.util.StrUtil;
import com.qiumo.help.context.LicenseContextHolder; import com.qiumo.help.context.LicenseContextHolder;
import com.qiumo.help.context.PluginsContextHolder; import com.qiumo.help.context.PluginsContextHolder;
import com.qiumo.help.context.ProductsContextHolder; import com.qiumo.help.context.ProductsContextHolder;
import com.qiumo.help.context.plugin.model.PluginCache;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@@ -121,7 +122,7 @@ public class DataController {
* @return JetBrains付费插件信息列表 * @return JetBrains付费插件信息列表
*/ */
@GetMapping("/plugins") @GetMapping("/plugins")
public List<PluginsContextHolder.PluginCache> getPlugins() { public List<PluginCache> getPlugins() {
log.debug("获取插件列表,插件数量: {}", PluginsContextHolder.pluginCacheList().size()); log.debug("获取插件列表,插件数量: {}", PluginsContextHolder.pluginCacheList().size());
return PluginsContextHolder.pluginCacheList(); return PluginsContextHolder.pluginCacheList();
} }
@@ -156,7 +157,7 @@ public class DataController {
List<String> pluginCodeList = PluginsContextHolder.pluginCacheList() List<String> pluginCodeList = PluginsContextHolder.pluginCacheList()
.stream() .stream()
.map(PluginsContextHolder.PluginCache::getProductCode) .map(PluginCache::getProductCode)
.filter(StrUtil::isNotBlank) .filter(StrUtil::isNotBlank)
.collect(Collectors.toList()); .collect(Collectors.toList());
@@ -193,7 +194,7 @@ public class DataController {
String productCode = PluginsContextHolder.pluginCacheList() String productCode = PluginsContextHolder.pluginCacheList()
.stream() .stream()
.filter(plugin -> Objects.equals(plugin.getId(), pluginId)) .filter(plugin -> Objects.equals(plugin.getId(), pluginId))
.map(PluginsContextHolder.PluginCache::getProductCode) .map(PluginCache::getProductCode)
.filter(StrUtil::isNotBlank) .filter(StrUtil::isNotBlank)
.findFirst() .findFirst()
.orElse(""); .orElse("");
@@ -6,6 +6,7 @@ import cn.hutool.core.util.StrUtil;
import com.qiumo.help.context.LicenseContextHolder; import com.qiumo.help.context.LicenseContextHolder;
import com.qiumo.help.context.PluginsContextHolder; import com.qiumo.help.context.PluginsContextHolder;
import com.qiumo.help.context.ProductsContextHolder; import com.qiumo.help.context.ProductsContextHolder;
import com.qiumo.help.context.plugin.model.PluginCache;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
@@ -178,7 +179,7 @@ public class LicenseCodeController {
// 获取所有付费插件代码 // 获取所有付费插件代码
List<String> pluginCodeList = PluginsContextHolder.pluginCacheList() List<String> pluginCodeList = PluginsContextHolder.pluginCacheList()
.stream() .stream()
.map(PluginsContextHolder.PluginCache::getProductCode) // 提取插件产品代码 .map(PluginCache::getProductCode) // 提取插件产品代码
.filter(StrUtil::isNotBlank) // 过滤空值 .filter(StrUtil::isNotBlank) // 过滤空值
.collect(Collectors.toList()); .collect(Collectors.toList());
+10 -4
View File
@@ -3,7 +3,13 @@ spring:
name: QiuMo-Jetbrains-Help name: QiuMo-Jetbrains-Help
server: server:
port: 10768 port: 10768
help: # 插件信息获取配置
default-license-name: QiuMo plugins:
default-assignee-name: 囚墨 # 是否启用定时刷新任务(true/false)
default-expiry-date: 2111-11-11 refresh-enabled: true
# 分页大小(每次请求获取的插件数量,建议不超过20)
page-size: 20
# 并发线程数(用于并行请求不同页面的插件数据)
thread-count: 20
# 请求超时时间(毫秒)
timeout: 30000