feat: 新增统一模型网关与模型管理工作区

- 新增 OpenAI 兼容统一模型调用链路、模型发布配置与批量发布能力

- 重构模型管理页面入口与统一网关工作区,更新服务商 logo 资源与模型 ID 文案

- 收口全新库初始化脚本,仅保留服务商种子并整理统一网关 migration
This commit is contained in:
2026-03-26 20:48:18 +08:00
parent b777cb3641
commit aaf4c61ff8
80 changed files with 4786 additions and 362 deletions

View File

@@ -7,6 +7,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import tech.easyflow.ai.dto.ModelInvokeConfigDtos;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.entity.ModelProvider;
import tech.easyflow.ai.entity.table.ModelTableDef;
@@ -69,6 +70,12 @@ public class ModelController extends BaseCurdController<ModelService, Model> {
return Result.ok(modelService.getList(entity));
}
@GetMapping("invokeList")
@SaCheckPermission("/api/v1/model/query")
public Result<List<Model>> invokeList() {
return Result.ok(modelService.listInvokeModels());
}
@PostMapping("/addAiLlm")
@SaCheckPermission("/api/v1/model/save")
public Result<Boolean> addAiLlm(Model entity) {
@@ -92,6 +99,31 @@ public class ModelController extends BaseCurdController<ModelService, Model> {
return Result.ok();
}
@PostMapping("/updateInvokeConfig")
@SaCheckPermission("/api/v1/model/save")
public Result<Model> updateInvokeConfig(@RequestBody ModelInvokeConfigDtos.UpdateRequest request) {
return Result.ok(modelService.updateInvokeConfig(
request.getId(),
request.getInvokeCode(),
request.getPublishEnabled()
));
}
@PostMapping("/batchUpdateInvokePublishStatus")
@SaCheckPermission("/api/v1/model/save")
public Result<List<Model>> batchUpdateInvokePublishStatus(@RequestBody ModelInvokeConfigDtos.BatchPublishRequest request) {
return Result.ok(modelService.batchUpdateInvokePublishStatus(
request.getIds(),
request.getPublishEnabled()
));
}
@Override
protected Result<?> onSaveOrUpdateBefore(Model entity, boolean isSave) {
modelService.validateForSaveOrUpdate(entity, isSave);
return super.onSaveOrUpdateBefore(entity, isSave);
}
@GetMapping("/selectLlmByProviderCategory")
@SaCheckPermission("/api/v1/model/query")
public Result<Map<String, List<Model>>> selectLlmByProviderCategory(Model entity, String sortKey, String sortType) {

View File

@@ -0,0 +1,176 @@
package tech.easyflow.publicapi.controller;
import cn.dev33.satoken.annotation.SaIgnore;
import cn.hutool.core.util.StrUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tech.easyflow.ai.invoke.exception.ModelInvokeException;
import tech.easyflow.ai.invoke.mapper.OpenAiProtocolMapper;
import tech.easyflow.ai.invoke.model.UnifiedChatChunk;
import tech.easyflow.ai.invoke.model.UnifiedChatRequest;
import tech.easyflow.ai.invoke.model.UnifiedChatResponse;
import tech.easyflow.ai.invoke.protocol.openai.OpenAiChatCompletionRequest;
import tech.easyflow.ai.invoke.protocol.openai.OpenAiErrorResponse;
import tech.easyflow.ai.invoke.provider.UnifiedChatChunkObserver;
import tech.easyflow.ai.invoke.service.UnifiedModelInvokeService;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.service.SysApiKeyService;
import javax.annotation.Resource;
import java.io.IOException;
import java.time.Duration;
@SaIgnore
@RestController
@RequestMapping("/v1")
public class PublicModelChatController {
private static final Logger log = LoggerFactory.getLogger(PublicModelChatController.class);
private static final long SSE_TIMEOUT = Duration.ofMinutes(10).toMillis();
@Resource
private SysApiKeyService sysApiKeyService;
@Resource
private UnifiedModelInvokeService unifiedModelInvokeService;
@Resource
private OpenAiProtocolMapper openAiProtocolMapper;
@Resource
private ObjectMapper objectMapper;
/**
* 统一模型调用OpenAI Chat Completions
*/
@PostMapping(
value = "/chat/completions",
produces = {
MediaType.APPLICATION_JSON_VALUE,
MediaType.TEXT_EVENT_STREAM_VALUE
}
)
public Object chatCompletions(@RequestBody String rawBody, HttpServletRequest request) {
try {
String apiKey = resolveApiKey(request.getHeader(HttpHeaders.AUTHORIZATION));
sysApiKeyService.checkApikeyPermission(apiKey, request.getRequestURI());
OpenAiChatCompletionRequest openAiRequest = openAiProtocolMapper.readRequest(rawBody);
UnifiedChatRequest unifiedRequest = openAiProtocolMapper.toUnifiedRequest(openAiRequest);
if (Boolean.TRUE.equals(unifiedRequest.getStream())) {
return createStreamEmitter(unifiedRequest);
}
UnifiedChatResponse response = unifiedModelInvokeService.chat(unifiedRequest);
return buildJsonResponse(openAiProtocolMapper.toOpenAiResponse(response));
} catch (ModelInvokeException e) {
return buildErrorResponse(e);
} catch (BusinessException e) {
return buildErrorResponse(mapBusinessException(e));
} catch (Exception e) {
log.error("chatCompletions unexpected error: {}", e.getMessage(), e);
return buildErrorResponse(new ModelInvokeException(
500,
"统一模型调用失败: " + e.getMessage(),
"api_error",
null,
"internal_error"
));
}
}
private SseEmitter createStreamEmitter(UnifiedChatRequest unifiedRequest) {
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT);
unifiedModelInvokeService.chatStream(unifiedRequest, new UnifiedChatChunkObserver() {
@Override
public void onChunk(UnifiedChatChunk chunk) {
try {
String payload = objectMapper.writeValueAsString(openAiProtocolMapper.toOpenAiChunk(chunk));
emitter.send(SseEmitter.event().data(payload));
} catch (IOException e) {
emitter.completeWithError(e);
}
}
@Override
public void onComplete() {
try {
emitter.send(SseEmitter.event().data("[DONE]"));
emitter.complete();
} catch (IOException e) {
emitter.completeWithError(e);
}
}
@Override
public void onError(Throwable throwable) {
log.error("chatCompletions stream error: {}", throwable.getMessage(), throwable);
emitter.completeWithError(throwable);
}
});
return emitter;
}
private ResponseEntity<String> buildJsonResponse(Object body) {
try {
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_JSON)
.body(objectMapper.writeValueAsString(body));
} catch (Exception e) {
throw new RuntimeException("序列化响应失败", e);
}
}
private ResponseEntity<String> buildErrorResponse(ModelInvokeException e) {
OpenAiErrorResponse response = new OpenAiErrorResponse();
OpenAiErrorResponse.Error error = new OpenAiErrorResponse.Error();
error.setMessage(e.getMessage());
error.setType(e.getType());
error.setParam(e.getParam());
error.setCode(e.getCode());
response.setError(error);
return ResponseEntity.status(e.getStatus())
.contentType(MediaType.APPLICATION_JSON)
.body(writeJson(response));
}
private String writeJson(Object body) {
try {
return objectMapper.writeValueAsString(body);
} catch (Exception ex) {
log.error("writeJson error: {}", ex.getMessage(), ex);
return "{\"error\":{\"message\":\"响应序列化失败\",\"type\":\"api_error\",\"code\":\"serialization_error\"}}";
}
}
private ModelInvokeException mapBusinessException(BusinessException e) {
String message = StrUtil.blankToDefault(e.getMessage(), "访问令牌校验失败");
if (StrUtil.containsAnyIgnoreCase(message, "apikey 不存在", "apikey 已过期", "已禁用")) {
return ModelInvokeException.unauthorized(message);
}
if (StrUtil.containsAnyIgnoreCase(message, "无权限", "接口不存在")) {
return ModelInvokeException.forbidden(message);
}
return ModelInvokeException.badRequest(message);
}
private String resolveApiKey(String authorizationHeader) {
if (StrUtil.isBlank(authorizationHeader)) {
throw ModelInvokeException.unauthorized("Authorization 不能为空");
}
String trimmed = authorizationHeader.trim();
if (StrUtil.startWithIgnoreCase(trimmed, "Bearer ")) {
trimmed = trimmed.substring(7).trim();
}
if (StrUtil.isBlank(trimmed)) {
throw ModelInvokeException.unauthorized("Authorization 无效");
}
return trimmed;
}
}