From 43449bc59abc750cf873cd191ac56863b5736ea3 Mon Sep 17 00:00:00 2001 From: wanghanlin <1533525126@qq.com> Date: Wed, 1 Jul 2026 17:50:54 +0800 Subject: [PATCH] =?UTF-8?q?fix(model-config):=20AI=20=E5=A4=A7=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E9=85=8D=E7=BD=AE=E7=AE=A1=E7=90=86=E5=85=A8=E9=9D=A2?= =?UTF-8?q?=E6=8E=92=E6=9F=A5=E4=BF=AE=E5=A4=8D=EF=BC=8820=20=E9=A1=B9?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端修复: - 修复 updateConfig 激活逻辑竞态条件:appType 变更时只停用目标类型的活跃配置 - 修复 getConfigDetail 直接修改 MyBatis 缓存实体的安全隐患,改为防御性拷贝 - 修复 persistExtraConfig 异常被静默吞掉的问题,改为抛出异常触发事务回滚 - 修复 deleteConfig 缺失 @Transactional 注解 - 修复 updatePriority 未校验配置存在性 - 修复 updateConfig 空 apiKey(trim 后)会覆盖已有值的问题 - createConfig 增加 provider 字段非空校验 - 导入接口增加 100 条数据量限制,导出接口移除 maskApiKey 参数强制脱敏 - ChatModelFactory.testConnection 使用专用线程池替代 ForkJoinPool.commonPool - EmbeddingModelFactory RetryTemplate 显式设置最大重试次数 - ModelConfigLoader 增加 RERANK 类型启动校验 - RERANK 健康状态从误报 ONLINE 改为 UNKNOWN - listConfigs 从原生 SQL 重构为 MyBatis Plus LambdaQueryWrapper 前端修复: - 新建模式切换提供商时始终重置 model_name 为新提供商默认模型 - 编辑模式切换提供商时清理不属于新提供商的无效 model_name - extraConfig 始终发送(含空对象),确保后端能清空已废弃的高级参数 - 关闭导入弹窗时重置预览和结果状态 - 健康看板支持 UNKNOWN 状态(🟡 未检测)展示 --- .../supportbot/config/ChatModelFactory.java | 7 +- .../config/EmbeddingModelFactory.java | 2 + .../supportbot/config/ModelConfigLoader.java | 1 + .../supportbot/config/ModelHealthService.java | 13 ++- .../controller/AiModelConfigController.java | 19 +++- .../service/AiModelConfigService.java | 105 +++++++++++------- .../static/components/ModelConfigManager.js | 46 +++++--- src/main/resources/static/js/api.js | 6 +- 8 files changed, 135 insertions(+), 64 deletions(-) diff --git a/src/main/java/com/wok/supportbot/config/ChatModelFactory.java b/src/main/java/com/wok/supportbot/config/ChatModelFactory.java index d450043..e6190ef 100644 --- a/src/main/java/com/wok/supportbot/config/ChatModelFactory.java +++ b/src/main/java/com/wok/supportbot/config/ChatModelFactory.java @@ -22,6 +22,8 @@ import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; /** @@ -237,6 +239,9 @@ public class ChatModelFactory { /** 连接测试超时时间(秒) */ private static final int TEST_CONNECTION_TIMEOUT_SECONDS = 10; + /** 连接测试专用线程池,避免占用 ForkJoinPool.commonPool() */ + private final ExecutorService testConnectionExecutor = Executors.newFixedThreadPool(3); + /** * 测试指定配置的连接可用性 * 创建临时 ChatModel(不走缓存),发送一条简单 prompt 验证 API Key 和模型是否可用 @@ -270,7 +275,7 @@ public class ChatModelFactory { result.put("errorMessage", e.getMessage()); } return result; - }); + }, testConnectionExecutor); return future.get(TEST_CONNECTION_TIMEOUT_SECONDS, TimeUnit.SECONDS); } catch (java.util.concurrent.TimeoutException e) { Map result = new LinkedHashMap<>(); diff --git a/src/main/java/com/wok/supportbot/config/EmbeddingModelFactory.java b/src/main/java/com/wok/supportbot/config/EmbeddingModelFactory.java index 43c4498..b9216b8 100644 --- a/src/main/java/com/wok/supportbot/config/EmbeddingModelFactory.java +++ b/src/main/java/com/wok/supportbot/config/EmbeddingModelFactory.java @@ -205,6 +205,8 @@ public class EmbeddingModelFactory { */ private RetryTemplate createRetryTemplate() { RetryTemplate retryTemplate = new RetryTemplate(); + // 显式设置最大重试次数,避免依赖隐式默认值 + retryTemplate.setRetryPolicy(new org.springframework.retry.policy.SimpleRetryPolicy(3)); ExponentialBackOffPolicy backOffPolicy = new ExponentialBackOffPolicy(); backOffPolicy.setInitialInterval(1000); backOffPolicy.setMultiplier(2); diff --git a/src/main/java/com/wok/supportbot/config/ModelConfigLoader.java b/src/main/java/com/wok/supportbot/config/ModelConfigLoader.java index b857c8e..b79275e 100644 --- a/src/main/java/com/wok/supportbot/config/ModelConfigLoader.java +++ b/src/main/java/com/wok/supportbot/config/ModelConfigLoader.java @@ -30,6 +30,7 @@ public class ModelConfigLoader implements ApplicationListener result) { HealthStatus status = new HealthStatus(); - status.setStatus(Boolean.TRUE.equals(result.get("success")) ? "ONLINE" : "OFFLINE"); + // 支持显式 UNKNOWN 状态(如 RERANK 类型暂不支持探活) + Object explicitStatus = result.get("status"); + if ("UNKNOWN".equals(explicitStatus)) { + status.setStatus("UNKNOWN"); + } else { + status.setStatus(Boolean.TRUE.equals(result.get("success")) ? "ONLINE" : "OFFLINE"); + } Object latency = result.get("latencyMs"); status.setLatencyMs(latency instanceof Number ? ((Number) latency).longValue() : null); status.setLastCheckTime(new Date()); @@ -204,9 +211,11 @@ public class ModelHealthService { // 添加汇总统计 long online = healthCache.values().stream().filter(s -> "ONLINE".equals(s.getStatus())).count(); long offline = healthCache.values().stream().filter(s -> "OFFLINE".equals(s.getStatus())).count(); + long unknown = healthCache.values().stream().filter(s -> "UNKNOWN".equals(s.getStatus())).count(); Map summary = new LinkedHashMap<>(); summary.put("online", online); summary.put("offline", offline); + summary.put("unknown", unknown); summary.put("total", (long) healthCache.size()); result.put("_summary", summary); diff --git a/src/main/java/com/wok/supportbot/controller/AiModelConfigController.java b/src/main/java/com/wok/supportbot/controller/AiModelConfigController.java index a1e5bf2..ec1394c 100644 --- a/src/main/java/com/wok/supportbot/controller/AiModelConfigController.java +++ b/src/main/java/com/wok/supportbot/controller/AiModelConfigController.java @@ -173,6 +173,12 @@ public class AiModelConfigController { "message", "API Key 不能为空" )); } + if (config.getProvider() == null || config.getProvider().trim().isEmpty()) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "message", "提供商不能为空" + )); + } AiModelConfig created = aiModelConfigService.createConfig(config); refreshCache(); @@ -435,16 +441,13 @@ public class AiModelConfigController { // ==================== F7: 配置导入/导出 ==================== /** - * 导出所有配置为 JSON - * - * @param maskApiKey 是否脱敏 API Key(默认 true) + * 导出所有配置为 JSON(API Key 始终脱敏) */ @GetMapping("/model-config/export") @PreAuthorize("hasRole('admin')") - public ResponseEntity> exportConfigs( - @RequestParam(defaultValue = "true") boolean maskApiKey) { + public ResponseEntity> exportConfigs() { try { - List configs = aiModelConfigService.exportConfigs(maskApiKey); + List configs = aiModelConfigService.exportConfigs(true); return ResponseEntity.ok(Map.of( "success", true, "data", configs, @@ -471,6 +474,10 @@ public class AiModelConfigController { return ResponseEntity.badRequest().body(Map.of( "success", false, "message", "导入数据不能为空")); } + if (rawConfigs.size() > 100) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, "message", "单次导入不能超过 100 条配置")); + } String onConflict = body.getOrDefault("onConflict", "skip").toString(); diff --git a/src/main/java/com/wok/supportbot/service/AiModelConfigService.java b/src/main/java/com/wok/supportbot/service/AiModelConfigService.java index 7a7c91e..038aed1 100644 --- a/src/main/java/com/wok/supportbot/service/AiModelConfigService.java +++ b/src/main/java/com/wok/supportbot/service/AiModelConfigService.java @@ -42,39 +42,45 @@ public class AiModelConfigService { * @return 分页结果 */ public Map listConfigs(String appType, int page, int size) { - // 构建查询条件 - StringBuilder whereClause = new StringBuilder("WHERE is_delete = false "); - List params = new ArrayList<>(); - + // 构建查询条件(共用基础条件) + LambdaQueryWrapper baseCondition = new LambdaQueryWrapper<>(); if (appType != null && !appType.isEmpty()) { - whereClause.append(" AND app_type = ? "); - params.add(appType); + baseCondition.eq(AiModelConfig::getAppType, appType); } - // 查询总数 - String countSql = "SELECT COUNT(*) FROM ai_model_config " + whereClause; - Long total = jdbcTemplate.queryForObject(countSql, Long.class, params.toArray()); + // 查询总数(不含排序和分页) + Long total = aiModelConfigMapper.selectCount(baseCondition); if (total == null) total = 0L; - // 查询列表(按 priority 降序、create_time 降序) - String listSql = "SELECT * FROM ai_model_config " + whereClause + - " ORDER BY priority DESC, create_time DESC LIMIT ? OFFSET ?"; - - List queryParams = new ArrayList<>(params); - queryParams.add(size); - queryParams.add((page - 1) * size); - - List> records = jdbcTemplate.queryForList(listSql, queryParams.toArray()); + // 查询列表(带排序和分页) + LambdaQueryWrapper listWrapper = new LambdaQueryWrapper<>(); + if (appType != null && !appType.isEmpty()) { + listWrapper.eq(AiModelConfig::getAppType, appType); + } + listWrapper.orderByDesc(AiModelConfig::getPriority) + .orderByDesc(AiModelConfig::getCreateTime); + listWrapper.last("LIMIT " + size + " OFFSET " + ((page - 1) * size)); + List records = aiModelConfigMapper.selectList(listWrapper); - // 脱敏 API Key 并格式化结果 + // 脱敏 API Key List> formattedRecords = new ArrayList<>(); - for (Map record : records) { - Map formatted = new LinkedHashMap<>(record); - // API Key 脱敏 - String apiKey = (String) record.get("api_key"); - if (apiKey != null) { - formatted.put("api_key", maskApiKey(apiKey)); - } + for (AiModelConfig record : records) { + Map formatted = new LinkedHashMap<>(); + formatted.put("id", record.getId()); + formatted.put("name", record.getName()); + formatted.put("app_type", record.getAppType()); + formatted.put("provider", record.getProvider()); + formatted.put("model_name", record.getModelName()); + formatted.put("temperature", record.getTemperature()); + formatted.put("max_tokens", record.getMaxTokens()); + formatted.put("base_url", record.getBaseUrl()); + formatted.put("api_key", maskApiKey(record.getApiKey())); + formatted.put("extra_config", record.getExtraConfig()); + formatted.put("is_active", record.getIsActive()); + formatted.put("priority", record.getPriority()); + formatted.put("description", record.getDescription()); + formatted.put("create_time", record.getCreateTime()); + formatted.put("update_time", record.getUpdateTime()); formattedRecords.add(formatted); } @@ -98,9 +104,27 @@ public class AiModelConfigService { public AiModelConfig getConfigDetail(Long id) { AiModelConfig config = aiModelConfigMapper.selectById(id); if (config != null) { - config.setApiKey(maskApiKey(config.getApiKey())); + // 防御性拷贝:不直接修改 MyBatis 一级缓存中的实体对象,避免污染后续查询 + AiModelConfig masked = AiModelConfig.builder() + .id(config.getId()) + .name(config.getName()) + .appType(config.getAppType()) + .provider(config.getProvider()) + .apiKey(maskApiKey(config.getApiKey())) + .modelName(config.getModelName()) + .temperature(config.getTemperature()) + .maxTokens(config.getMaxTokens()) + .baseUrl(config.getBaseUrl()) + .extraConfig(config.getExtraConfig()) + .isActive(config.getIsActive()) + .priority(config.getPriority()) + .description(config.getDescription()) + .createTime(config.getCreateTime()) + .updateTime(config.getUpdateTime()) + .build(); + return masked; } - return config; + return null; } // ==================== 获取活跃配置 ==================== @@ -197,22 +221,21 @@ public class AiModelConfigService { .build(); validateModelTypeMatch(merged); - // 如果要激活此配置,先禁用同类型的其他配置 + // 如果要激活此配置,先禁用目标类型的其他配置 + // 目标类型 = 新 appType(如有修改)或原 appType if (Boolean.TRUE.equals(config.getIsActive())) { - deactivateByAppType(existing.getAppType()); - } - - // 如果 app_type 被修改且新配置为活跃,需要禁用新类型的其他配置 - if (config.getAppType() != null && !config.getAppType().equals(existing.getAppType()) - && Boolean.TRUE.equals(config.getIsActive())) { - deactivateByAppType(config.getAppType()); + String targetAppType = config.getAppType() != null ? config.getAppType() : existing.getAppType(); + deactivateByAppType(targetAppType); } // 设置 ID 确保更新正确 config.setId(id); - // 防御:trim API Key 前后空白 + // 防御:trim API Key 前后空白,空字符串置为 null 避免覆盖已有值 if (config.getApiKey() != null) { config.setApiKey(config.getApiKey().trim()); + if (config.getApiKey().isEmpty()) { + config.setApiKey(null); + } } aiModelConfigMapper.updateById(config); // JSONB 字段 MyBatis Plus typeHandler 可能不触发,用 JdbcTemplate 显式写入保证可靠 @@ -278,6 +301,7 @@ public class AiModelConfigService { * * @param id 配置ID */ + @Transactional(rollbackFor = Exception.class) public void deleteConfig(Long id) { AiModelConfig config = aiModelConfigMapper.selectById(id); if (config == null) { @@ -362,7 +386,8 @@ public class AiModelConfigService { json, id); log.debug("持久化 extraConfig: id={}, extraConfig={}", id, json); } catch (Exception e) { - log.warn("持久化 extraConfig 失败: id={}, error={}", id, e.getMessage()); + log.error("持久化 extraConfig 失败: id={}, error={}", id, e.getMessage()); + throw new RuntimeException("持久化扩展配置失败: " + e.getMessage(), e); } } @@ -450,6 +475,10 @@ public class AiModelConfigService { * @param priority 新优先级值 */ public void updatePriority(Long id, Integer priority) { + AiModelConfig config = aiModelConfigMapper.selectById(id); + if (config == null) { + throw new RuntimeException("配置不存在"); + } LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); updateWrapper.eq(AiModelConfig::getId, id) .set(AiModelConfig::getPriority, priority); diff --git a/src/main/resources/static/components/ModelConfigManager.js b/src/main/resources/static/components/ModelConfigManager.js index 97c3600..f113712 100644 --- a/src/main/resources/static/components/ModelConfigManager.js +++ b/src/main/resources/static/components/ModelConfigManager.js @@ -148,6 +148,10 @@ export default {
{{ healthSummary.offline }}
🔴 离线
+
+
{{ healthSummary.unknown }}
+
🟡 未检测
+
{{ healthSummary.total }}
📊 总计
@@ -186,7 +190,7 @@ export default { - {{ healthMap[item.id].status === 'ONLINE' ? '🟢' : '🔴' }} + {{ healthMap[item.id].status === 'ONLINE' ? '🟢' : healthMap[item.id].status === 'UNKNOWN' ? '🟡' : '🔴' }} {{ healthMap[item.id].latencyMs }}ms 🟡 @@ -245,8 +249,8 @@ export default { - {{ healthMap[c.id].status === 'ONLINE' ? '●' : '●' }} + :style="{ cursor: 'help', color: healthMap[c.id].status === 'ONLINE' ? '#16a34a' : healthMap[c.id].status === 'UNKNOWN' ? '#d97706' : '#dc2626' }"> + {{ healthMap[c.id].status === 'ONLINE' ? '●' : healthMap[c.id].status === 'UNKNOWN' ? '●' : '●' }} {{ healthMap[c.id].latencyMs }}ms @@ -467,7 +471,7 @@ export default { @@ -593,8 +597,18 @@ export default { const models = isEmbedding ? (defaults.embeddingModels || []) : (defaults.chatModels || []) const defaultModel = isEmbedding ? (defaults.defaultEmbeddingModel || '') : (defaults.defaultChatModel || '') - if (editModal.value.mode === 'add' && !editModal.value.form.model_name) { - editModal.value.form.model_name = defaultModel + // 新建模式:始终重置 model_name 为新提供商的默认模型 + if (editModal.value.mode === 'add') { + editModal.value.form.model_name = defaultModel || '' + } else { + // 编辑模式:如果当前 model_name 不在新提供商的模型列表中,重置为默认模型 + const currentModel = editModal.value.form.model_name + if (currentModel && currentModel !== '__custom__' && models.length > 0) { + const found = models.find(m => m.name === currentModel) + if (!found) { + editModal.value.form.model_name = defaultModel || '' + } + } } // F5: 使用对象格式的模型列表 @@ -671,7 +685,7 @@ export default { function getHealthTooltip(configId) { const h = healthMap.value[configId] if (!h) return '未检测' - let tip = h.status === 'ONLINE' ? '在线' : '离线' + let tip = h.status === 'ONLINE' ? '在线' : h.status === 'UNKNOWN' ? '未支持自动检测' : '离线' if (h.latencyMs) tip += ` · 延迟 ${h.latencyMs}ms` if (h.lastCheckTime) tip += ` · 最后检测 ${new Date(h.lastCheckTime).toLocaleTimeString()}` if (h.errorMessage) tip += ` · ${h.errorMessage}` @@ -793,7 +807,7 @@ export default { isActive: form.is_active, description: form.description } - // 构建 extraConfig + // 构建 extraConfig(始终发送,以便后端清空已废弃的高级参数) const extraConfig = {} if (form.app_type === 'EMBEDDING') { extraConfig.dimensions = form.embeddingDimensions || 1024 @@ -805,9 +819,7 @@ export default { if (form.presencePenalty != null) extraConfig.presencePenalty = form.presencePenalty if (form.stopSequences && form.stopSequences.trim()) extraConfig.stopSequences = form.stopSequences.trim() - if (Object.keys(extraConfig).length > 0) { - data.extraConfig = extraConfig - } + data.extraConfig = extraConfig return data } @@ -973,7 +985,7 @@ export default { async function exportConfigs() { try { - const json = await api.exportModelConfigs(true) + const json = await api.exportModelConfigs() if (json.success) { const blob = new Blob([JSON.stringify(json.data, null, 2)], { type: 'application/json' }) const url = URL.createObjectURL(blob) @@ -1029,6 +1041,12 @@ export default { } } + function closeImportDialog() { + showImportDialog.value = false + importPreview.value = null + importResult.value = null + } + // ==================== 工具函数 ==================== function getAppTypeLabel(appType) { @@ -1076,7 +1094,7 @@ export default { fallbackChains, onDragStart, onDrop, deactivateConfig, // F7 showImportDialog, importPreview, importConflict, importResult, - onImportFileSelect, doImport, exportConfigs, + onImportFileSelect, doImport, closeImportDialog, exportConfigs, // 基础 load, openAddModal, openEditModal, closeEditModal, saveConfig, activate, remove, onProviderChange, getAppTypeLabel, getAppTypeBadgeClass, getProviderLabel, formatDate diff --git a/src/main/resources/static/js/api.js b/src/main/resources/static/js/api.js index 4e66195..bf8d5ed 100644 --- a/src/main/resources/static/js/api.js +++ b/src/main/resources/static/js/api.js @@ -627,10 +627,10 @@ export function deactivateModelConfig(id) { // ==================== F7: 配置导入/导出 ==================== /** - * 导出所有模型配置 + * 导出所有模型配置(API Key 始终脱敏) */ -export function exportModelConfigs(maskApiKey = true) { - return getJSON(`/model-config/export?maskApiKey=${maskApiKey}`) +export function exportModelConfigs() { + return getJSON('/model-config/export') } /**