mateclaw/mateclaw-server/src/main/java/vip/mate/tool/search/SearchProviderRegistry.java
倪程伟 53a12ee18d
feat(plugin/settings): search-provider catalog endpoint + grouped settings UI + plugin config form
Adds a read-only GET /api/v1/settings/search-providers catalog (admin-gated, no secrets), grouped collapsible provider cards, and a schema-driven plugin config form. Breaks a SystemSettingService<->PluginManager circular dependency via parameter @Lazy (with a context smoke test), and fixes PluginManager.updateConfig to merge instead of overwrite so omitted/blank secret fields are preserved. Hardens plugin search-provider id validation (reject-not-trim, case-insensitive conflict) and insulates the provider bridge hot path from throwing plugin code.
2026-07-04 12:06:33 +08:00

174 lines
6.9 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package vip.mate.tool.search;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.system.model.SystemSettingsDTO;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 搜索提供商注册表 — 收集所有 {@link SearchProvider} 实现,提供优先级排序与自动探测
*
* <p>provider 自动探测机制:
* <ol>
* <li>用户显式配置的 primary provider → 直接使用</li>
* <li>按 autoDetectOrder 遍历,优先选有 credential 的 provider</li>
* <li>如果没有有 credential 的 provider回退到第一个可用的 keyless provider</li>
* </ol>
*
* @author MateClaw Team
*/
@Slf4j
@Component
public class SearchProviderRegistry {
private final List<SearchProvider> sortedProviders;
private final Map<String, SearchProvider> providerMap;
/** 插件注册的 provider运行时可变与 Spring 注入的内置 provider 合并成完整视图 */
private final ConcurrentHashMap<String, SearchProvider> pluginProviders = new ConcurrentHashMap<>();
/**
* 注册写锁:大小写不敏感的冲突检测是"先检查后插入",两个并发注册大小写变体
* "Foo"/"foo")可能双双通过检查后各自落入不同 key——写路径必须原子化。
* 读路径getById/allSorted/resolve仍走无锁的 ConcurrentHashMap。
*/
private final Object registrationLock = new Object();
public SearchProviderRegistry(List<SearchProvider> providers) {
this.sortedProviders = providers.stream()
.sorted(Comparator.comparingInt(SearchProvider::autoDetectOrder))
.toList();
this.providerMap = providers.stream()
.collect(Collectors.toMap(SearchProvider::id, Function.identity()));
log.info("注册搜索提供商 {} 个: {}", sortedProviders.size(),
sortedProviders.stream().map(p -> p.id() + "(order=" + p.autoDetectOrder() + ")").toList());
}
/**
* 注册一个插件提供的 provider。
*
* <p>id 规则:不允许为空或含首尾空白(拒绝而非 trim——注册键必须与
* {@code provider.id()} 完全一致,反注册才能对得上);存储与查找大小写敏感,
* 但冲突检测大小写不敏感,防止 "Serper" 这类变体在 UI 上与内置 "serper" 混淆。
*
* @throws IllegalArgumentException id 为空、含首尾空白,或与内置/已注册插件 provider 冲突
*/
public void registerPluginProvider(SearchProvider provider) {
String id = provider.id();
if (id == null || id.isBlank()) {
throw new IllegalArgumentException("Search provider id must not be blank");
}
if (!id.equals(id.trim())) {
throw new IllegalArgumentException(
"Search provider id must not contain leading/trailing whitespace: '" + id + "'");
}
synchronized (registrationLock) {
if (containsIgnoreCase(providerMap.keySet(), id)) {
throw new IllegalArgumentException(
"Search provider id conflicts with a built-in provider: " + id);
}
if (containsIgnoreCase(pluginProviders.keySet(), id)) {
throw new IllegalArgumentException(
"Search provider id already registered by another plugin: " + id);
}
pluginProviders.put(id, provider);
}
log.info("插件搜索提供商已注册: {} (order={})", id, provider.autoDetectOrder());
}
private static boolean containsIgnoreCase(Set<String> ids, String candidate) {
return ids.stream().anyMatch(existing -> existing.equalsIgnoreCase(candidate));
}
/** 反注册插件 providerdisable / rollback 路径调用id 不存在时静默) */
public void unregisterPluginProvider(String id) {
if (pluginProviders.remove(id) != null) {
log.info("插件搜索提供商已反注册: {}", id);
}
}
/** 判断某个 id 是否由插件注册(而非内置 Spring bean */
public boolean isPluginProvider(String id) {
return pluginProviders.containsKey(id);
}
/** 按 ID 获取指定 provider内置优先其次插件注册区 */
public SearchProvider getById(String id) {
SearchProvider builtin = providerMap.get(id);
return builtin != null ? builtin : pluginProviders.get(id);
}
/**
* 获取按 autoDetectOrder 排序的全部 provider内置 + 插件)。
* <p>有插件注册时每次调用重新合并排序——provider 总数 &lt;10无需缓存。
*/
public List<SearchProvider> allSorted() {
if (pluginProviders.isEmpty()) {
return sortedProviders;
}
List<SearchProvider> merged = new ArrayList<>(sortedProviders);
merged.addAll(pluginProviders.values());
merged.sort(Comparator.comparingInt(SearchProvider::autoDetectOrder));
return merged;
}
/**
* 根据当前配置,解析应使用的 provider。
*
* <p>解析策略:
* <ol>
* <li>如果用户配置了 primary provider 且该 provider 可用 → 选中</li>
* <li>否则按 autoDetectOrder 遍历,跳过 keyless先找有 credential 的</li>
* <li>如果没找到 → 回退到第一个可用的 keyless provider</li>
* </ol>
*
* @return 选中的 provider或 null完全无可用 provider
*/
public ResolvedProvider resolve(SystemSettingsDTO config) {
// 1. 用户显式配置的 primary provider
String configuredId = config.getSearchProvider();
if (configuredId != null && !configuredId.isBlank()) {
SearchProvider configured = getById(configuredId);
if (configured != null && configured.isAvailable(config)) {
return new ResolvedProvider(configured, "configured");
}
}
// 2. 按优先级遍历,先找有 credential 的
SearchProvider keylessFallback = null;
for (SearchProvider p : allSorted()) {
if (!p.requiresCredential()) {
// 记住第一个可用的 keyless provider
if (keylessFallback == null && p.isAvailable(config)) {
keylessFallback = p;
}
continue;
}
if (p.isAvailable(config)) {
return new ResolvedProvider(p, "auto-detect");
}
}
// 3. 回退到 keyless
if (keylessFallback != null) {
return new ResolvedProvider(keylessFallback, "keyless-fallback");
}
return null;
}
/**
* 解析结果
*/
public record ResolvedProvider(SearchProvider provider, String source) {
}
}