refactor: 优化模型配置验证和测试逻辑

- 更新 `AiModelServiceImpl` 中的验证逻辑,改进错误信息
- 在 `pushAsrConfig` 方法中添加对空白 `apiKey` 的处理
- 添加单元测试以验证自定义 LLM 和 ASR 模型在没有 `apiKey` 时的行为
- 更新前端 `AiModels.tsx` 中的表单验证逻辑,移除 `apiKey` 的必填规则并添加警告提示
dev_na
chenhao 2026-04-22 09:53:34 +08:00
parent 6a08fb1a3b
commit 324e283f41
3 changed files with 83 additions and 7 deletions

View File

@ -718,12 +718,9 @@ public class AiModelServiceImpl implements AiModelService {
private void validateModel(AiModelDTO dto) {
if (dto == null) {
throw new RuntimeException("妯″瀷閰嶇疆涓嶈兘涓虹┖");
throw new RuntimeException("模型配置不能为空");
}
if ("custom".equals(normalizeProvider(dto.getProvider()))) {
if (dto.getApiKey() == null || dto.getApiKey().isBlank()) {
throw new RuntimeException("鏈湴妯″瀷蹇呴』濉啓API Key");
}
if (TYPE_ASR.equals(normalizeType(dto.getModelType()))) {
Map<String, Object> mediaConfig = dto.getMediaConfig() == null ? Collections.emptyMap() : dto.getMediaConfig();
if (readConfigString(mediaConfig.get("speakerModel")) == null) {
@ -810,6 +807,10 @@ public class AiModelServiceImpl implements AiModelService {
private void pushAsrConfig(AsrModel entity) {
if ("custom".equals(normalizeProvider(entity.getProvider()))) {
if (entity.getApiKey() == null || entity.getApiKey().isBlank()) {
log.info("Skip syncing local ASR profile because apiKey is blank, modelName={}", entity.getModelName());
return;
}
updateLocalProfile(entity);
return;
}

View File

@ -3,12 +3,15 @@ package com.imeeting.service.biz.impl;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.dto.biz.AiModelDTO;
import com.imeeting.entity.biz.AsrModel;
import com.imeeting.entity.biz.LlmModel;
import com.imeeting.mapper.biz.AsrModelMapper;
import com.imeeting.mapper.biz.LlmModelMapper;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.io.IOException;
import java.io.OutputStream;
@ -16,10 +19,17 @@ import java.lang.reflect.Field;
import java.net.InetSocketAddress;
import java.net.http.HttpClient;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.ArgumentMatchers.any;
class AiModelServiceImplTest {
@ -142,6 +152,67 @@ class AiModelServiceImplTest {
assertEquals(HttpClient.Version.HTTP_1_1, httpClient.version());
}
@Test
void saveModelShouldAllowCustomLlmWithoutApiKey() {
AsrModelMapper asrModelMapper = mock(AsrModelMapper.class);
LlmModelMapper llmModelMapper = mock(LlmModelMapper.class);
when(llmModelMapper.insert(any(LlmModel.class))).thenReturn(1);
AiModelServiceImpl service = new AiModelServiceImpl(
objectMapper,
asrModelMapper,
llmModelMapper
);
AiModelDTO dto = new AiModelDTO();
dto.setModelType("LLM");
dto.setModelName("custom-llm");
dto.setProvider("custom");
dto.setBaseUrl("http://127.0.0.1:9000");
dto.setApiPath("/v1/chat/completions");
dto.setModelCode("llm-test");
dto.setIsDefault(0);
dto.setStatus(1);
service.saveModel(dto);
ArgumentCaptor<LlmModel> captor = ArgumentCaptor.forClass(LlmModel.class);
verify(llmModelMapper, times(1)).insert(captor.capture());
assertNull(captor.getValue().getApiKey());
}
@Test
void saveModelShouldAllowCustomAsrWithoutApiKeyAndSkipSync() {
AsrModelMapper asrModelMapper = mock(AsrModelMapper.class);
LlmModelMapper llmModelMapper = mock(LlmModelMapper.class);
when(asrModelMapper.insert(any(AsrModel.class))).thenReturn(1);
AiModelServiceImpl service = new AiModelServiceImpl(
objectMapper,
asrModelMapper,
llmModelMapper
);
AiModelDTO dto = new AiModelDTO();
dto.setModelType("ASR");
dto.setModelName("custom-asr");
dto.setProvider("custom");
dto.setBaseUrl("http://127.0.0.1:9001");
dto.setModelCode("asr-test");
Map<String, Object> mediaConfig = new HashMap<>();
mediaConfig.put("speakerModel", "speaker-test");
mediaConfig.put("svThreshold", 0.45);
dto.setMediaConfig(mediaConfig);
dto.setIsDefault(0);
dto.setStatus(1);
service.saveModel(dto);
ArgumentCaptor<AsrModel> captor = ArgumentCaptor.forClass(AsrModel.class);
verify(asrModelMapper, times(1)).insert(captor.capture());
assertNull(captor.getValue().getApiKey());
}
private void captureRequest(HttpExchange exchange,
AtomicReference<String> requestPath,
AtomicReference<String> authorization,

View File

@ -302,17 +302,22 @@ const AiModels: React.FC = () => {
return;
}
const values = await form.validateFields(["provider", "baseUrl", "apiKey"]);
const values = await form.validateFields(["provider", "baseUrl"]);
if (String(values.provider || "").toLowerCase() !== "custom") {
message.warning("仅本地模型支持连通性测试");
return;
}
const { apiKey } = form.getFieldsValue(["apiKey"]);
if (!apiKey) {
message.warning("请先填写 API Key 后再测试连接");
return;
}
setConnectivityLoading(true);
try {
const res = await testLocalModelConnectivity({
baseUrl: values.baseUrl,
apiKey: values.apiKey,
apiKey,
});
const profile = (res as any)?.data?.data ?? (res as any)?.data ?? (res as any);
applyLocalProfile(profile as AiLocalProfileVO, values.baseUrl);
@ -506,7 +511,6 @@ const AiModels: React.FC = () => {
<Form.Item
name="apiKey"
label="API Key"
rules={isLocalProvider ? [{ required: true, message: "本地模型必须填写 API Key" }] : undefined}
>
<Input.Password />
</Form.Item>