Browse Source

fix(model-config): AI 大模型配置管理全面排查修复(20 项)

后端修复:
- 修复 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 状态(🟡 未检测)展示
dev-mcp
wanghanlin 4 weeks ago
parent
commit
43449bc59a
  1. 7
      src/main/java/com/wok/supportbot/config/ChatModelFactory.java
  2. 2
      src/main/java/com/wok/supportbot/config/EmbeddingModelFactory.java
  3. 1
      src/main/java/com/wok/supportbot/config/ModelConfigLoader.java
  4. 13
      src/main/java/com/wok/supportbot/config/ModelHealthService.java
  5. 19
      src/main/java/com/wok/supportbot/controller/AiModelConfigController.java
  6. 105
      src/main/java/com/wok/supportbot/service/AiModelConfigService.java
  7. 46
      src/main/resources/static/components/ModelConfigManager.js
  8. 6
      src/main/resources/static/js/api.js

7
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<String, Object> result = new LinkedHashMap<>();

2
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);

1
src/main/java/com/wok/supportbot/config/ModelConfigLoader.java

@ -30,6 +30,7 @@ public class ModelConfigLoader implements ApplicationListener<ApplicationReadyEv
checkAppTypeConfig("CHAT");
checkAppTypeConfig("EMBEDDING");
checkAppTypeConfig("RAG_REWRITE");
checkAppTypeConfig("RERANK");
log.info("========== AI 模型配置校验完成 ==========");
} catch (Exception e) {
log.warn("模型配置校验异常(不影响启动): {}", e.getMessage());

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

@ -125,7 +125,8 @@ public class ModelHealthService {
return embeddingModelFactory.testConnection(config);
} else if ("RERANK".equals(appType)) {
// RERANK 暂无通用测试方式标记为 UNKNOWN
return Map.of("success", true, "latencyMs", 0);
return Map.of("success", false, "status", "UNKNOWN",
"errorMessage", "RERANK 类型暂不支持自动探活");
} else {
// CHAT / RAG_REWRITE
return chatModelFactory.testConnection(config);
@ -140,7 +141,13 @@ public class ModelHealthService {
*/
private void updateHealthStatus(Long configId, Map<String, Object> 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<String, Object> 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);

19
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
* 导出所有配置为 JSONAPI Key 始终脱敏
*/
@GetMapping("/model-config/export")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> exportConfigs(
@RequestParam(defaultValue = "true") boolean maskApiKey) {
public ResponseEntity<Map<String, Object>> exportConfigs() {
try {
List<AiModelConfig> configs = aiModelConfigService.exportConfigs(maskApiKey);
List<AiModelConfig> 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();

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

@ -42,39 +42,45 @@ public class AiModelConfigService {
* @return 分页结果
*/
public Map<String, Object> listConfigs(String appType, int page, int size) {
// 构建查询条件
StringBuilder whereClause = new StringBuilder("WHERE is_delete = false ");
List<Object> params = new ArrayList<>();
// 构建查询条件共用基础条件
LambdaQueryWrapper<AiModelConfig> 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<Object> queryParams = new ArrayList<>(params);
queryParams.add(size);
queryParams.add((page - 1) * size);
List<Map<String, Object>> records = jdbcTemplate.queryForList(listSql, queryParams.toArray());
// 查询列表带排序和分页
LambdaQueryWrapper<AiModelConfig> 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<AiModelConfig> records = aiModelConfigMapper.selectList(listWrapper);
// 脱敏 API Key 并格式化结果
// 脱敏 API Key
List<Map<String, Object>> formattedRecords = new ArrayList<>();
for (Map<String, Object> record : records) {
Map<String, Object> 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<String, Object> 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<AiModelConfig> updateWrapper = new LambdaUpdateWrapper<>();
updateWrapper.eq(AiModelConfig::getId, id)
.set(AiModelConfig::getPriority, priority);

46
src/main/resources/static/components/ModelConfigManager.js

@ -148,6 +148,10 @@ export default {
<div style="font-size:22px;font-weight:700;color:#dc2626;">{{ healthSummary.offline }}</div>
<div style="font-size:11px;color:#991b1b;">🔴 离线</div>
</div>
<div v-if="healthSummary.unknown" style="padding:10px 16px;background:#fffbeb;border:1px solid #fde68a;border-radius:8px;min-width:100px;text-align:center;">
<div style="font-size:22px;font-weight:700;color:#d97706;">{{ healthSummary.unknown }}</div>
<div style="font-size:11px;color:#92400e;">🟡 未检测</div>
</div>
<div style="padding:10px 16px;background:#f9fafb;border:1px solid #e5e7eb;border-radius:8px;min-width:100px;text-align:center;">
<div style="font-size:22px;font-weight:700;color:#374151;">{{ healthSummary.total }}</div>
<div style="font-size:11px;color:#6b7280;">📊 总计</div>
@ -186,7 +190,7 @@ export default {
<!-- F4: 健康状态指示 -->
<span v-if="healthMap[item.id]" :title="getHealthTooltip(item.id)"
:style="{ color: healthMap[item.id].status === 'ONLINE' ? '#16a34a' : '#dc2626' }">
{{ healthMap[item.id].status === 'ONLINE' ? '🟢' : '🔴' }}
{{ healthMap[item.id].status === 'ONLINE' ? '🟢' : healthMap[item.id].status === 'UNKNOWN' ? '🟡' : '🔴' }}
<span v-if="healthMap[item.id].latencyMs" style="font-size:10px;">{{ healthMap[item.id].latencyMs }}ms</span>
</span>
<span v-else style="color:#d1d5db;" title="未检测">🟡</span>
@ -245,8 +249,8 @@ export default {
<!-- 健康指示器 -->
<span v-if="c.is_active && healthMap[c.id]"
:title="getHealthTooltip(c.id)"
:style="{ cursor: 'help', color: healthMap[c.id].status === 'ONLINE' ? '#16a34a' : '#dc2626' }">
{{ 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' ? '●' : '●' }}
<span v-if="healthMap[c.id].latencyMs" style="font-size:10px;">{{ healthMap[c.id].latencyMs }}ms</span>
</span>
<span v-else-if="c.is_active" style="color:#d1d5db;cursor:help;" title="未检测"></span>
@ -467,7 +471,7 @@ export default {
<!-- F7: 导入弹窗 -->
<div class="modal-overlay" :class="{ active: showImportDialog }">
<div class="modal-box" style="max-width:600px;">
<button class="modal-close" @click="showImportDialog = false">×</button>
<button class="modal-close" @click="closeImportDialog">×</button>
<h2>📥 导入模型配置</h2>
<div style="margin:16px 0;">
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:8px;">选择 JSON 文件</label>
@ -499,7 +503,7 @@ export default {
</div>
</div>
<div style="display:flex;gap:10px;margin-top:20px;justify-content:flex-end;">
<button class="btn btn-outline" @click="showImportDialog = false">关闭</button>
<button class="btn btn-outline" @click="closeImportDialog">关闭</button>
<button class="btn btn-primary" @click="doImport" :disabled="!importPreview">📥 确认导入</button>
</div>
</div>
@ -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

6
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')
}
/**

Loading…
Cancel
Save