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 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 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 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 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 config, Map 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) { } }