Browse Source

修复 API Key 日志泄露、模型配置缓存、JWT 误报、IntentRouter JSON 解析异常、事务噪声及 MCP 超时重连

master
wanghanlin 2 days ago
parent
commit
f397fda3ca
  1. 14
      pom.xml
  2. 2
      src/main/java/com/wok/supportbot/SupportBotApplication.java
  3. 41
      src/main/java/com/wok/supportbot/config/McpClientManager.java
  4. 24
      src/main/java/com/wok/supportbot/config/ModelHealthService.java
  5. 28
      src/main/java/com/wok/supportbot/mcp/McpToolCallback.java
  6. 16
      src/main/java/com/wok/supportbot/rag/RerankerService.java
  7. 10
      src/main/java/com/wok/supportbot/security/JwtAuthFilter.java
  8. 61
      src/main/java/com/wok/supportbot/service/AiModelConfigService.java
  9. 53
      src/main/java/com/wok/supportbot/service/IntentRouter.java
  10. 2
      src/main/resources/application.yml

14
pom.xml

@ -93,9 +93,23 @@
</dependency>
<!-- Spring AI MCP Client(Model Context Protocol 客户端) -->
<!-- 排除旧版 MCP SDK 0.10.0,显式引入新版 0.18.3 修复 SSE 400 + 超时问题 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-client</artifactId>
<exclusions>
<exclusion>
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- 显式引入新版 MCP SDK(覆盖 spring-ai-mcp 的旧版 0.10.0) -->
<dependency>
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp</artifactId>
<version>0.18.3</version>
</dependency>
<!--Hutool 工具库-->

2
src/main/java/com/wok/supportbot/SupportBotApplication.java

@ -4,6 +4,7 @@ import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.transaction.annotation.EnableTransactionManagement;
/**
* 主启动类
@ -13,6 +14,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
@EnableAsync
@EnableTransactionManagement
public class SupportBotApplication {
public static void main(String[] args) {

41
src/main/java/com/wok/supportbot/config/McpClientManager.java

@ -6,6 +6,8 @@ import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport;
import io.modelcontextprotocol.client.transport.ServerParameters;
import io.modelcontextprotocol.client.transport.StdioClientTransport;
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper;
import io.modelcontextprotocol.spec.McpSchema;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
@ -91,6 +93,12 @@ public class McpClientManager {
@Value("${mcp.client.init-timeout:15s}")
private String initTimeoutStr;
/**
* 共享的 JSON 映射器实例Jackson 实现 StdioClientTransport 等组件使用
* 新版 SDK0.12+要求显式传入 McpJsonMapper
*/
private final McpJsonMapper mcpJsonMapper = new JacksonMcpJsonMapper(new com.fasterxml.jackson.databind.ObjectMapper());
/**
* 应用启动时自动加载所有已启用的 MCP Server 配置并建立连接
* 确保第一次对话请求时 clientCache 已就绪MCP 工具可以被注册到 ChatClient
@ -422,7 +430,7 @@ public class McpClientManager {
paramsBuilder.env(envStr);
}
StdioClientTransport transport = new StdioClientTransport(paramsBuilder.build());
StdioClientTransport transport = new StdioClientTransport(paramsBuilder.build(), mcpJsonMapper);
client = buildSyncClient(transport);
log.info("正在初始化 stdio MCP 客户端: id={}, command={}", config.getId(), command);
@ -577,6 +585,37 @@ public class McpClientManager {
}
}
/**
* 尝试重新建立指定配置的 MCP 客户端连接用于调用失败时的自动恢复
* 先关闭并移除旧客户端再通过懒加载重新创建
*
* @param configId 配置ID
* @return 重建后的 MCP Client 实例失败返回 null
*/
public McpSyncClient reconnect(Long configId) {
String key = configId.toString();
// 先移除旧客户端同时关闭底层连接
McpSyncClient old = clientCache.remove(key);
if (old != null) {
try {
old.close();
} catch (Exception e) {
log.debug("关闭旧 MCP 客户端时出错: configId={}, error={}", configId, e.getMessage());
}
log.info("MCP 客户端已移除,准备重建: configId={}", configId);
}
// 从不可用集合中移除允许重新尝试
unavailableConfigs.remove(key);
// 通过懒加载重建客户端
McpSyncClient newClient = getClient(configId);
if (newClient != null) {
log.info("MCP 客户端重建成功: configId={}", configId);
} else {
log.warn("MCP 客户端重建失败: configId={}", configId);
}
return newClient;
}
/**
* 测试指定配置的 MCP Server 连接
* 创建临时客户端 -> initialize -> listTools -> close不影响正常缓存

24
src/main/java/com/wok/supportbot/config/ModelHealthService.java

@ -50,6 +50,12 @@ public class ModelHealthService {
/** 单配置探活超时时间(秒) */
private static final int PROBE_TIMEOUT_SECONDS = 5;
/** 空配置跳过阈值:连续 N 次返回 0 行后跳过该 appType */
private static final int EMPTY_SKIP_THRESHOLD = 3;
/** 记录各 appType 连续空查询次数,达到阈值后跳过直到下次配置变更 */
private final ConcurrentHashMap<String, Integer> emptyCountMap = new ConcurrentHashMap<>();
/**
* 应用销毁时关闭线程池防止资源泄漏
*/
@ -87,7 +93,23 @@ public class ModelHealthService {
for (String appType : appTypes) {
try {
// 跳过连续多次空查询的 appType如未配置的 RERANK减少无效 DB 查询
int emptyCount = emptyCountMap.getOrDefault(appType, 0);
if (emptyCount >= EMPTY_SKIP_THRESHOLD) {
log.debug("跳过空配置 appType [{}] 的探活(已连续 {} 次返回空)", appType, emptyCount);
continue;
}
List<AiModelConfig> configs = configService.getAllActiveConfigs(appType);
if (configs.isEmpty()) {
// 本次查询为空递增计数
int newCount = emptyCountMap.merge(appType, 1, Integer::sum);
log.debug("appType [{}] 无活跃配置(连续 {} 次)", appType, newCount);
continue;
}
// 有配置则重置计数
emptyCountMap.remove(appType);
for (AiModelConfig config : configs) {
Future<Map<String, Object>> future = probeExecutor.submit(() -> probeConfig(config));
pendingTasks.add(Map.entry(config.getId(), future));
@ -183,6 +205,8 @@ public class ModelHealthService {
try {
AiModelConfig config = configService.getConfigWithFullKey(configId);
if (config != null) {
// 有新配置激活重置该 appType 的空查询计数
emptyCountMap.remove(config.getAppType());
Map<String, Object> result = probeConfig(config);
updateHealthStatus(configId, result);
}

28
src/main/java/com/wok/supportbot/mcp/McpToolCallback.java

@ -219,6 +219,34 @@ public class McpToolCallback implements ToolCallback {
} catch (Exception e) {
long latency = System.currentTimeMillis() - startTime;
log.error("MCP 工具调用失败: tool={}, latency={}ms, error={}", originalToolName, latency, e.getMessage());
// 如果是超时异常可能因 SSE 会话过期导致尝试重连后重试一次
if (e instanceof java.util.concurrent.TimeoutException
|| e.getMessage() != null && e.getMessage().contains("TimeoutException")) {
log.info("MCP 工具调用超时,尝试重连后重试: tool={}, serverId={}", originalToolName, mcpServerConfigId);
McpSyncClient reconnected = mcpClientManager.reconnect(mcpServerConfigId);
if (reconnected != null) {
try {
Map<String, Object> retryArgs = OBJECT_MAPPER.readValue(toolInput, new TypeReference<>() {});
McpSchema.CallToolRequest retryRequest = new McpSchema.CallToolRequest(originalToolName, retryArgs);
McpSchema.CallToolResult retryResult = reconnected.callTool(retryRequest);
long retryLatency = System.currentTimeMillis() - startTime;
log.info("MCP 工具重试成功: tool={}, latency={}ms", originalToolName, retryLatency);
String retryResultStr = retryResult.content() != null ? String.valueOf(retryResult.content()) : "";
boolean retryIsError = retryResult.isError() != null && retryResult.isError();
EVENTS.get().add(new ToolCallEvent(originalToolName, toolInput, retryResultStr, retryLatency, retryIsError));
if (retryResult.isError() != null && retryResult.isError()) {
return "{\"error\": \"工具执行返回错误: " + escapeJson(String.valueOf(retryResult.content())) + "\"}";
}
return ModelOptionsUtils.toJsonString(retryResult.content());
} catch (Exception retryEx) {
log.error("MCP 工具重试仍然失败: tool={}, error={}", originalToolName, retryEx.getMessage());
}
}
}
// 记录失败事件
EVENTS.get().add(new ToolCallEvent(originalToolName, toolInput, e.getMessage(), latency, true));
return "{\"error\": \"" + escapeJson(e.getMessage()) + "\"}";

16
src/main/java/com/wok/supportbot/rag/RerankerService.java

@ -11,6 +11,7 @@ import org.springframework.web.client.RestTemplate;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
/**
@ -28,6 +29,11 @@ public class RerankerService {
/** HTTP 超时时间(秒) */
private static final int TIMEOUT_SECONDS = 3;
/** RERANK 活跃配置本地缓存,避免每次 Rerank 调用都查询 ai_model_config 表 */
private volatile AiModelConfig cachedRerankConfig;
private volatile long lastCacheTime = 0;
private static final long CACHE_TTL_MS = 60_000; // 缓存 1 分钟兼顾时效性与性能
/**
* 对候选文档执行重排序
* - RERANK 配置 调用对应提供商的 Rerank API
@ -63,14 +69,20 @@ public class RerankerService {
}
/**
* 获取 RERANK 类型的活跃配置
* 获取 RERANK 类型的活跃配置带本地缓存TTL 1 分钟
*/
private AiModelConfig getActiveRerankConfig() {
long now = System.currentTimeMillis();
if (cachedRerankConfig != null && (now - lastCacheTime) < CACHE_TTL_MS) {
return cachedRerankConfig;
}
LambdaQueryWrapper<AiModelConfig> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(AiModelConfig::getAppType, "RERANK")
.eq(AiModelConfig::getIsActive, true)
.last("LIMIT 1");
return aiModelConfigMapper.selectOne(wrapper);
cachedRerankConfig = aiModelConfigMapper.selectOne(wrapper);
lastCacheTime = now;
return cachedRerankConfig;
}
/**

10
src/main/java/com/wok/supportbot/security/JwtAuthFilter.java

@ -62,6 +62,16 @@ public class JwtAuthFilter extends OncePerRequestFilter {
filterChain.doFilter(request, response);
}
/**
* 跳过 SDK 路径 这些路径由 SdkAuthFilter 使用独立的 jwt.sdk-secret 验签
* 不应由本 Filter 用管理后台密钥重复验证避免产生误报 WARN 日志
*/
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getServletPath();
return path.startsWith("/ai/");
}
/**
* Authorization 头提取 Bearer Token
*/

61
src/main/java/com/wok/supportbot/service/AiModelConfigService.java

@ -13,6 +13,7 @@ import org.springframework.transaction.annotation.Transactional;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/**
* AI 模型配置管理服务
@ -31,6 +32,9 @@ public class AiModelConfigService {
/** 复用的 JSON 序列化器,避免每次调用 persistExtraConfig 都新建实例 */
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
/** 活跃配置本地缓存:appType → AiModelConfig(含完整 API Key),写操作时自动失效 */
private final ConcurrentHashMap<String, AiModelConfig> activeConfigCache = new ConcurrentHashMap<>();
// ==================== 分页列表 ====================
/**
@ -150,11 +154,32 @@ public class AiModelConfigService {
/**
* 获取指定应用类型的活跃配置含完整 API Key仅供内部调用使用
* 使用本地缓存减少对 ai_model_config 表的重复查询
*
* @param appType 应用类型
* @return 活跃配置含完整 API Key
*/
public AiModelConfig getActiveConfigWithFullKey(String appType) {
// 先查缓存命中则直接返回防御性拷贝避免缓存被外部修改污染
AiModelConfig cached = activeConfigCache.get(appType);
if (cached != null) {
return AiModelConfig.builder()
.id(cached.getId())
.name(cached.getName())
.appType(cached.getAppType())
.provider(cached.getProvider())
.apiKey(cached.getApiKey())
.modelName(cached.getModelName())
.temperature(cached.getTemperature())
.maxTokens(cached.getMaxTokens())
.baseUrl(cached.getBaseUrl())
.extraConfig(cached.getExtraConfig() != null ? new LinkedHashMap<>(cached.getExtraConfig()) : null)
.isActive(cached.getIsActive())
.priority(cached.getPriority())
.description(cached.getDescription())
.build();
}
LambdaQueryWrapper<AiModelConfig> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(AiModelConfig::getAppType, appType)
.eq(AiModelConfig::getIsActive, true)
@ -164,9 +189,31 @@ public class AiModelConfigService {
if (config != null && config.getApiKey() != null) {
config.setApiKey(config.getApiKey().trim());
}
// 写入缓存只缓存非 null 结果避免缓存"查无此配置"导致后续永远不查 DB
if (config != null) {
activeConfigCache.put(appType, config);
}
return config;
}
/**
* 清除指定 appType 的活跃配置缓存配置变更时调用
*/
public void evictActiveConfigCache(String appType) {
activeConfigCache.remove(appType);
log.debug("清除活跃配置缓存: appType={}", appType);
}
/**
* 清除所有活跃配置缓存
*/
public void evictAllActiveConfigCache() {
activeConfigCache.clear();
log.debug("清除所有活跃配置缓存");
}
// ==================== 新建配置 ====================
/**
@ -191,6 +238,8 @@ public class AiModelConfigService {
aiModelConfigMapper.insert(config);
// JSONB 字段 MyBatis Plus typeHandler 可能不触发 JdbcTemplate 显式写入保证可靠
persistExtraConfig(config.getId(), config.getExtraConfig());
// 清除该 appType 的活跃配置缓存
evictActiveConfigCache(config.getAppType());
log.info("新建 AI 模型配置: name={}, appType={}, modelName={}",
config.getName(), config.getAppType(), config.getModelName());
return config;
@ -245,6 +294,9 @@ public class AiModelConfigService {
persistExtraConfig(id, config.getExtraConfig());
}
log.info("更新 AI 模型配置: id={}", id);
// 清除该 appType 的活跃配置缓存更新后 appType 可能与原来不同
String evictAppType = config.getAppType() != null ? config.getAppType() : existing.getAppType();
evictActiveConfigCache(evictAppType);
AiModelConfig updated = aiModelConfigMapper.selectById(id);
// 返回前脱敏避免明文 api_key 暴露给前端与列表/详情接口保持一致
if (updated != null) {
@ -284,6 +336,8 @@ public class AiModelConfigService {
log.info("激活 AI 模型配置: id={}, appType={}, modelName={}, priority={}",
id, config.getAppType(), config.getModelName(), newPriority);
// 清除该 appType 的活跃配置缓存
evictActiveConfigCache(config.getAppType());
}
/**
@ -491,10 +545,15 @@ public class AiModelConfigService {
* 停用指定配置 Fallback 链中移除
*/
public void deactivateConfig(Long id) {
AiModelConfig config = aiModelConfigMapper.selectById(id);
LambdaUpdateWrapper<AiModelConfig> updateWrapper = new LambdaUpdateWrapper<>();
updateWrapper.eq(AiModelConfig::getId, id)
.set(AiModelConfig::getIsActive, false);
aiModelConfigMapper.update(null, updateWrapper);
// 清除该 appType 的活跃配置缓存
if (config != null) {
evictActiveConfigCache(config.getAppType());
}
log.info("停用 AI 模型配置: id={}", id);
}
@ -601,6 +660,8 @@ public class AiModelConfigService {
result.put("errors", errors);
log.info("导入配置完成: added={}, overwritten={}, skipped={}, errors={}",
added, overwritten, skipped, errors.size());
// 导入可能改变任意 appType 的活跃配置清除全部缓存
evictAllActiveConfigCache();
return result;
}
}

53
src/main/java/com/wok/supportbot/service/IntentRouter.java

@ -1,6 +1,7 @@
package com.wok.supportbot.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.wok.supportbot.config.ChatModelFactory;
import lombok.AllArgsConstructor;
import lombok.Data;
@ -25,8 +26,6 @@ public class IntentRouter {
@Autowired
private ChatModelFactory chatModelFactory;
private final ObjectMapper objectMapper = new ObjectMapper();
/** 意图分类 Prompt 模板 */
private static final String INTENT_PROMPT_TEMPLATE = """
你是一个意图分类器根据用户问题判断其属于以下哪个意图
@ -83,29 +82,59 @@ public class IntentRouter {
// ==================== 解析方法 ====================
/** 从 LLM 原始响应中提取 intent 字段值 */
private static final Pattern INTENT_PATTERN = Pattern.compile("\"intent\"\\s*:\\s*\"(FAQ|RAG|CHITCHAT)\"");
/** 从 LLM 原始响应中提取 confidence 字段值 */
private static final Pattern CONFIDENCE_PATTERN = Pattern.compile("\"confidence\"\\s*:\\s*(0?\\.\\d+|1\\.0|[01])");
/**
* 解析 LLM 返回的 JSON 意图分类结果
* 解析 LLM 返回的意图分类结果
* 不依赖 JSON 结构完整性使用正则直接从原始文本提取 intent confidence 字段值
* 可正确处理 LLM 输出的各种畸变重复拼接缺少逗号额外文本BOM/零宽字符等
* 如果解析失败默认返回 RAG降级到现有流程
*/
private IntentResult parseIntentResponse(String response) {
try {
// 清理可能的 markdown 代码块包裹
String cleaned = response.trim();
// 1. 预处理去除 BOM零宽字符等不可见干扰字符
String cleaned = response.trim()
.replace("", "")
.replace("​", "")
.replace("‌", "")
.replace("‍", "")
.replace(" ", " ")
.replace("‎", "")
.replace("‏", "")
.replace("⁠", "");
// 2. 去除 markdown 代码块包裹
if (cleaned.startsWith("```")) {
cleaned = cleaned.replaceAll("^```(?:json)?\\s*", "").replaceAll("\\s*```$", "");
}
IntentResult result = objectMapper.readValue(cleaned, IntentResult.class);
// 3. 正则提取 intent取第一个匹配应对 LLM 重复拼接 JSON 的情况
Matcher intentMatcher = INTENT_PATTERN.matcher(cleaned);
String intent = intentMatcher.find() ? intentMatcher.group(1) : null;
// 4. 正则提取 confidence取第一个匹配
Matcher confidenceMatcher = CONFIDENCE_PATTERN.matcher(cleaned);
double confidence = confidenceMatcher.find() ? Double.parseDouble(confidenceMatcher.group(1)) : 0.5;
if (intent == null) {
log.warn("未能从 LLM 响应中提取到 intent 字段,降级为 RAG");
return new IntentResult("RAG", 0.0);
}
// 校验意图类型有效性
if (result.getIntent() == null || !isValidIntent(result.getIntent())) {
log.warn("无效的意图类型: {}, 降级为 RAG", result.getIntent());
if (!isValidIntent(intent)) {
log.warn("无效的意图类型: {}, 降级为 RAG", intent);
return new IntentResult("RAG", 0.5);
}
return result;
return new IntentResult(intent, confidence);
} catch (Exception e) {
log.warn("意图分类 JSON 解析失败,降级为 RAG: response={}", response, e);
String truncated = response != null && response.length() > 200
? response.substring(0, 200) + "..."
: response;
log.warn("意图分类解析异常,降级为 RAG: rawResponse={}", truncated, e);
return new IntentResult("RAG", 0.0);
}
}

2
src/main/resources/application.yml

@ -37,7 +37,7 @@ mybatis-plus:
type-aliases-package: com.wok.supportbot.entity
mapper-locations: classpath*:mapper/**/*.xml
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
map-underscore-to-camel-case: true
global-config:
db-config:

Loading…
Cancel
Save