调整项目结构
This commit is contained in:
@@ -0,0 +1,452 @@
|
||||
package tech.easyflow.manuagent.model;
|
||||
|
||||
import tech.easyflow.manuagent.auth.UserService;
|
||||
import tech.easyflow.manuagent.common.ApiException;
|
||||
import tech.easyflow.manuagent.config.AppProperties;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.security.Principal;
|
||||
import java.time.Duration;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* 管理 OpenAI 兼容模型配置、密钥和连接测试。
|
||||
*/
|
||||
@Service
|
||||
@Order(2)
|
||||
public class ModelService implements ApplicationRunner {
|
||||
|
||||
private final JdbcClient jdbc;
|
||||
private final UserService userService;
|
||||
private final KeyCipher keyCipher;
|
||||
private final AppProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final HttpClient httpClient;
|
||||
|
||||
/**
|
||||
* 创建模型服务。
|
||||
*
|
||||
* @param jdbc JDBC 客户端
|
||||
* @param userService 用户服务
|
||||
* @param keyCipher 密钥加密器
|
||||
* @param properties 应用配置
|
||||
* @param objectMapper JSON 映射器
|
||||
*/
|
||||
public ModelService(
|
||||
JdbcClient jdbc,
|
||||
UserService userService,
|
||||
KeyCipher keyCipher,
|
||||
AppProperties properties,
|
||||
ObjectMapper objectMapper) {
|
||||
this.jdbc = jdbc;
|
||||
this.userService = userService;
|
||||
this.keyCipher = keyCipher;
|
||||
this.properties = properties;
|
||||
this.objectMapper = objectMapper;
|
||||
this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从项目根目录 Key 文件初始化默认模型。
|
||||
*
|
||||
* @param args 启动参数
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public void run(ApplicationArguments args) {
|
||||
Integer count = jdbc.sql("SELECT count(*) FROM app.model_config").query(Integer.class).single();
|
||||
if (count > 0 || !Files.isRegularFile(properties.deepseekKeyFile())) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
String key = Files.readString(properties.deepseekKeyFile(), StandardCharsets.UTF_8).trim();
|
||||
if (key.isBlank()) {
|
||||
return;
|
||||
}
|
||||
UUID adminId = jdbc.sql("SELECT id FROM app.app_user ORDER BY created_at LIMIT 1")
|
||||
.query(UUID.class)
|
||||
.single();
|
||||
UUID modelId = UUID.randomUUID();
|
||||
jdbc.sql("""
|
||||
INSERT INTO app.model_config(
|
||||
id, name, provider, base_url, model_id, api_key_ciphertext, api_key_hint,
|
||||
key_version, config_json, capabilities_json, is_default, created_by)
|
||||
VALUES (:id, '默认编排模型', 'OPENAI_COMPATIBLE', :baseUrl, :modelId,
|
||||
:ciphertext, :hint, 1, CAST(:config AS jsonb), CAST(:capabilities AS jsonb), TRUE, :userId)
|
||||
""")
|
||||
.param("id", modelId)
|
||||
.param("baseUrl", properties.modelBaseUrl())
|
||||
.param("modelId", properties.modelId())
|
||||
.param("ciphertext", keyCipher.encrypt(key))
|
||||
.param("hint", hint(key))
|
||||
.param("config", "{\"timeoutSeconds\":120,\"reasoningEffort\":\"high\"}")
|
||||
.param("capabilities", json(Map.of(
|
||||
"toolCalling", true,
|
||||
"reasoning", true,
|
||||
"contextWindow", properties.modelContextWindow())))
|
||||
.param("userId", adminId)
|
||||
.update();
|
||||
for (String role : List.of("ORCHESTRATION", "WRITING", "REVIEW")) {
|
||||
jdbc.sql("INSERT INTO app.model_assignment(role, model_config_id, assigned_by) VALUES (:role, :id, :userId)")
|
||||
.param("role", role)
|
||||
.param("id", modelId)
|
||||
.param("userId", adminId)
|
||||
.update();
|
||||
}
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("无法读取默认模型 Key", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出模型配置,永不返回明文密钥。
|
||||
*
|
||||
* @return 模型列表
|
||||
*/
|
||||
public List<ModelView> list() {
|
||||
return jdbc.sql(MODEL_SELECT + " ORDER BY is_default DESC, updated_at DESC")
|
||||
.query(ModelService::mapModel)
|
||||
.list();
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存新增或已有模型配置。
|
||||
*
|
||||
* @param id 可选模型 ID
|
||||
* @param input 模型输入
|
||||
* @param principal 当前用户
|
||||
* @return 保存后的模型
|
||||
*/
|
||||
@Transactional
|
||||
public ModelView save(UUID id, ModelInput input, Principal principal) {
|
||||
UUID userId = userService.requireUserId(principal.getName());
|
||||
contextWindow(input.capabilities());
|
||||
if (id == null) {
|
||||
if (input.apiKey() == null || input.apiKey().isBlank()) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "MODEL_KEY_REQUIRED", "新增模型需要 API Key");
|
||||
}
|
||||
id = UUID.randomUUID();
|
||||
jdbc.sql("""
|
||||
INSERT INTO app.model_config(
|
||||
id, name, provider, base_url, model_id, api_key_ciphertext, api_key_hint,
|
||||
key_version, config_json, capabilities_json, created_by)
|
||||
VALUES (:id, :name, 'OPENAI_COMPATIBLE', :baseUrl, :modelId, :ciphertext,
|
||||
:hint, 1, CAST(:config AS jsonb), CAST(:capabilities AS jsonb), :userId)
|
||||
""")
|
||||
.param("id", id)
|
||||
.param("name", input.name().trim())
|
||||
.param("baseUrl", normalizeBaseUrl(input.baseUrl()))
|
||||
.param("modelId", input.modelId().trim())
|
||||
.param("ciphertext", keyCipher.encrypt(input.apiKey().trim()))
|
||||
.param("hint", hint(input.apiKey().trim()))
|
||||
.param("config", json(input.config()))
|
||||
.param("capabilities", json(input.capabilities()))
|
||||
.param("userId", userId)
|
||||
.update();
|
||||
} else {
|
||||
int updated = input.apiKey() == null || input.apiKey().isBlank()
|
||||
? jdbc.sql("""
|
||||
UPDATE app.model_config
|
||||
SET name = :name, base_url = :baseUrl, model_id = :modelId,
|
||||
config_json = CAST(:config AS jsonb), capabilities_json = CAST(:capabilities AS jsonb),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :id
|
||||
""")
|
||||
.param("name", input.name().trim())
|
||||
.param("baseUrl", normalizeBaseUrl(input.baseUrl()))
|
||||
.param("modelId", input.modelId().trim())
|
||||
.param("config", json(input.config()))
|
||||
.param("capabilities", json(input.capabilities()))
|
||||
.param("id", id)
|
||||
.update()
|
||||
: jdbc.sql("""
|
||||
UPDATE app.model_config
|
||||
SET name = :name, base_url = :baseUrl, model_id = :modelId,
|
||||
api_key_ciphertext = :ciphertext, api_key_hint = :hint, key_version = 1,
|
||||
config_json = CAST(:config AS jsonb), capabilities_json = CAST(:capabilities AS jsonb),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :id
|
||||
""")
|
||||
.param("name", input.name().trim())
|
||||
.param("baseUrl", normalizeBaseUrl(input.baseUrl()))
|
||||
.param("modelId", input.modelId().trim())
|
||||
.param("ciphertext", keyCipher.encrypt(input.apiKey().trim()))
|
||||
.param("hint", hint(input.apiKey().trim()))
|
||||
.param("config", json(input.config()))
|
||||
.param("capabilities", json(input.capabilities()))
|
||||
.param("id", id)
|
||||
.update();
|
||||
if (updated != 1) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在");
|
||||
}
|
||||
}
|
||||
return require(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将模型设置为所有角色默认模型。
|
||||
*
|
||||
* @param id 模型 ID
|
||||
* @param principal 当前用户
|
||||
*/
|
||||
@Transactional
|
||||
public void setDefault(UUID id, Principal principal) {
|
||||
require(id);
|
||||
UUID userId = userService.requireUserId(principal.getName());
|
||||
jdbc.sql("UPDATE app.model_config SET is_default = FALSE WHERE is_default").update();
|
||||
jdbc.sql("UPDATE app.model_config SET is_default = TRUE, updated_at = CURRENT_TIMESTAMP WHERE id = :id")
|
||||
.param("id", id)
|
||||
.update();
|
||||
for (String role : List.of("ORCHESTRATION", "WRITING", "REVIEW")) {
|
||||
jdbc.sql("""
|
||||
INSERT INTO app.model_assignment(role, model_config_id, assigned_by)
|
||||
VALUES (:role, :id, :userId)
|
||||
ON CONFLICT (role) DO UPDATE
|
||||
SET model_config_id = EXCLUDED.model_config_id,
|
||||
assigned_by = EXCLUDED.assigned_by,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""")
|
||||
.param("role", role)
|
||||
.param("id", id)
|
||||
.param("userId", userId)
|
||||
.update();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用最小 Chat Completion 请求测试连接。
|
||||
*
|
||||
* @param id 模型 ID
|
||||
* @return 测试结果
|
||||
*/
|
||||
public ConnectionResult test(UUID id) {
|
||||
ModelSecret model = requireSecret(id);
|
||||
String requestJson = json(Map.of(
|
||||
"model", model.modelId(),
|
||||
"messages", List.of(Map.of("role", "user", "content", "回复 OK")),
|
||||
"max_tokens", 8,
|
||||
"stream", false));
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(model.baseUrl() + "/chat/completions"))
|
||||
.timeout(Duration.ofSeconds(30))
|
||||
.header("Authorization", "Bearer " + model.apiKey())
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(requestJson))
|
||||
.build();
|
||||
long started = System.nanoTime();
|
||||
try {
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
long elapsed = Duration.ofNanos(System.nanoTime() - started).toMillis();
|
||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||
throw new ApiException(HttpStatus.BAD_GATEWAY, "MODEL_CONNECTION_FAILED",
|
||||
"模型连接失败,服务返回 HTTP " + response.statusCode());
|
||||
}
|
||||
return new ConnectionResult(true, elapsed, "连接正常");
|
||||
} catch (IOException exception) {
|
||||
throw new ApiException(HttpStatus.BAD_GATEWAY, "MODEL_CONNECTION_FAILED", "无法连接模型服务");
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new ApiException(HttpStatus.SERVICE_UNAVAILABLE, "MODEL_CONNECTION_INTERRUPTED", "模型连接测试已中断");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前默认模型及明文 Key,仅供模型调用。
|
||||
*
|
||||
* @return 默认模型机密配置
|
||||
*/
|
||||
public ModelSecret defaultModelSecret() {
|
||||
UUID id = jdbc.sql("SELECT id FROM app.model_config WHERE is_default AND enabled")
|
||||
.query(UUID.class)
|
||||
.optional()
|
||||
.orElseThrow(() -> new ApiException(HttpStatus.CONFLICT, "MODEL_NOT_CONFIGURED", "请先配置可用模型"));
|
||||
return requireSecret(id);
|
||||
}
|
||||
|
||||
private ModelView require(UUID id) {
|
||||
return jdbc.sql(MODEL_SELECT + " WHERE id = :id")
|
||||
.param("id", id)
|
||||
.query(ModelService::mapModel)
|
||||
.optional()
|
||||
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在"));
|
||||
}
|
||||
|
||||
private ModelSecret requireSecret(UUID id) {
|
||||
return jdbc.sql("""
|
||||
SELECT id, base_url, model_id, api_key_ciphertext, capabilities_json::text
|
||||
FROM app.model_config WHERE id = :id AND enabled
|
||||
""")
|
||||
.param("id", id)
|
||||
.query((rs, rowNum) -> new ModelSecret(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getString("base_url"),
|
||||
rs.getString("model_id"),
|
||||
keyCipher.decrypt(rs.getBytes("api_key_ciphertext")),
|
||||
contextWindow(parseCapabilities(rs.getString("capabilities_json")))))
|
||||
.optional()
|
||||
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在或已停用"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取并校验模型上下文窗口。
|
||||
*
|
||||
* @param capabilities 模型能力配置
|
||||
* @return 上下文 Token 上限
|
||||
*/
|
||||
private int contextWindow(Map<String, Object> capabilities) {
|
||||
Object value = capabilities == null ? null : capabilities.get("contextWindow");
|
||||
if (!(value instanceof Number number) || number.intValue() < 8_192) {
|
||||
throw new ApiException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"MODEL_CONTEXT_WINDOW_INVALID",
|
||||
"上下文窗口不能小于 8192 Token");
|
||||
}
|
||||
return number.intValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析数据库中的模型能力配置。
|
||||
*
|
||||
* @param json 能力 JSON
|
||||
* @return 能力键值
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> parseCapabilities(String json) {
|
||||
try {
|
||||
return objectMapper.readValue(json, Map.class);
|
||||
} catch (IOException exception) {
|
||||
throw new ApiException(
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
"MODEL_CAPABILITIES_INVALID",
|
||||
"模型能力配置无法读取");
|
||||
}
|
||||
}
|
||||
|
||||
private static ModelView mapModel(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
|
||||
return new ModelView(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getString("name"),
|
||||
rs.getString("provider"),
|
||||
rs.getString("base_url"),
|
||||
rs.getString("model_id"),
|
||||
rs.getString("api_key_hint"),
|
||||
rs.getString("config_json"),
|
||||
rs.getString("capabilities_json"),
|
||||
rs.getBoolean("enabled"),
|
||||
rs.getBoolean("is_default"),
|
||||
rs.getObject("updated_at", OffsetDateTime.class));
|
||||
}
|
||||
|
||||
private String json(Object value) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(value == null ? Map.of() : value);
|
||||
} catch (IOException exception) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "INVALID_MODEL_CONFIG", "模型配置无法序列化");
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeBaseUrl(String baseUrl) {
|
||||
String value = baseUrl.trim();
|
||||
while (value.endsWith("/")) {
|
||||
value = value.substring(0, value.length() - 1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static String hint(String key) {
|
||||
return "••••" + key.substring(Math.max(0, key.length() - 4));
|
||||
}
|
||||
|
||||
private static final String MODEL_SELECT = """
|
||||
SELECT id, name, provider, base_url, model_id, api_key_hint, config_json,
|
||||
capabilities_json, enabled, is_default, updated_at
|
||||
FROM app.model_config
|
||||
""";
|
||||
|
||||
/**
|
||||
* 模型编辑输入。
|
||||
*
|
||||
* @param name 配置名称
|
||||
* @param baseUrl API 地址
|
||||
* @param modelId 模型标识
|
||||
* @param apiKey 新密钥;空值表示保留
|
||||
* @param config 高级配置
|
||||
* @param capabilities 能力声明
|
||||
*/
|
||||
public record ModelInput(
|
||||
@NotBlank String name,
|
||||
@NotBlank String baseUrl,
|
||||
@NotBlank String modelId,
|
||||
String apiKey,
|
||||
Map<String, Object> config,
|
||||
Map<String, Object> capabilities) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 对外模型视图。
|
||||
*
|
||||
* @param id 模型 ID
|
||||
* @param name 配置名称
|
||||
* @param provider 服务商
|
||||
* @param baseUrl API 地址
|
||||
* @param modelId 模型标识
|
||||
* @param apiKeyHint 密钥遮罩
|
||||
* @param configJson 高级配置
|
||||
* @param capabilitiesJson 能力配置
|
||||
* @param enabled 是否启用
|
||||
* @param defaultModel 是否默认
|
||||
* @param updatedAt 更新时间
|
||||
*/
|
||||
public record ModelView(
|
||||
UUID id,
|
||||
String name,
|
||||
String provider,
|
||||
String baseUrl,
|
||||
String modelId,
|
||||
String apiKeyHint,
|
||||
String configJson,
|
||||
String capabilitiesJson,
|
||||
boolean enabled,
|
||||
boolean defaultModel,
|
||||
OffsetDateTime updatedAt) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部模型机密配置。
|
||||
*
|
||||
* @param id 模型 ID
|
||||
* @param baseUrl API 地址
|
||||
* @param modelId 模型标识
|
||||
* @param apiKey 明文密钥
|
||||
* @param contextWindow 上下文 Token 上限
|
||||
*/
|
||||
public record ModelSecret(UUID id, String baseUrl, String modelId, String apiKey, int contextWindow) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接测试结果。
|
||||
*
|
||||
* @param success 是否成功
|
||||
* @param latencyMs 往返耗时
|
||||
* @param message 状态说明
|
||||
*/
|
||||
public record ConnectionResult(boolean success, long latencyMs, String message) {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user