调整项目结构

This commit is contained in:
2026-08-29 14:02:28 +08:00
parent c56aa6e752
commit c13302c0cb
52 changed files with 55270 additions and 124 deletions

View File

@@ -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) {
}
}