diff --git a/.gitignore b/.gitignore index fa136c5..c3b89ad 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,5 @@ __pycache__/ *.py[cod] .env.* !.env.example -test_data/ \ No newline at end of file +deepseek_key.txt +test_data/ diff --git a/README.md b/README.md index a3f0bc5..b80fdb3 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,14 @@ npm --prefix client run dev 打开 ,本地默认账号为 `admin / admin123`。 -项目根目录的 `deepseek_key.txt` 和 `dashscope_key.txt` 分别供模型与百炼知识库使用;模型、Skill 也可在页面内查看或配置。 +模型连接只能在“模型配置”页面新增并持久化到 PostgreSQL;API Key 会使用 `APP_MASTER_KEY` +环境变量提供的主密钥加密后保存。`dashscope_key.txt` 仅供百炼知识库使用,不参与模型配置。 + +启动后端前必须设置模型密钥加密主密钥: + +```powershell +$env:APP_MASTER_KEY = '<使用独立生成的高强度密钥>' +``` ## 验证 diff --git a/deepseek_key.txt b/deepseek_key.txt deleted file mode 100644 index 2f144b3..0000000 --- a/deepseek_key.txt +++ /dev/null @@ -1 +0,0 @@ -sk-8d1419754bce4306bc99854ee5ccd505 diff --git a/server/src/main/java/tech/easyflow/manuagent/agent/AgentController.java b/server/src/main/java/tech/easyflow/manuagent/agent/AgentController.java index 7136069..75e0851 100644 --- a/server/src/main/java/tech/easyflow/manuagent/agent/AgentController.java +++ b/server/src/main/java/tech/easyflow/manuagent/agent/AgentController.java @@ -1,6 +1,8 @@ package tech.easyflow.manuagent.agent; import com.fasterxml.jackson.databind.JsonNode; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; import java.security.Principal; import java.util.List; import java.util.UUID; @@ -95,8 +97,27 @@ public class AgentController { * @return 新恢复 Run */ @PostMapping("/runs/resume") - public AgentRunService.RunView resume(@PathVariable UUID projectId, Principal principal) { - return runService.resume(projectId, principal); + public AgentRunService.RunView resume( + @PathVariable UUID projectId, + @Valid @RequestBody(required = false) ResumeInput input, + Principal principal) { + return runService.resume(projectId, input == null ? null : input.modelConfigId(), principal); + } + + /** + * 将运行中的任务受控切换到替代模型。 + * + * @param projectId 项目 ID + * @param input 替代模型 + * @param principal 当前用户 + * @return 绑定替代模型的新恢复 Run + */ + @PostMapping("/runs/switch-model") + public AgentRunService.RunView switchModel( + @PathVariable UUID projectId, + @Valid @RequestBody ResumeInput input, + Principal principal) { + return runService.switchModel(projectId, input.modelConfigId(), principal); } /** @@ -138,4 +159,12 @@ public class AgentController { @RequestParam(defaultValue = "0") long after) { return eventService.streamAfter(projectId, after); } + + /** + * 恢复或切换任务时指定的模型。 + * + * @param modelConfigId 目标模型配置 ID + */ + public record ResumeInput(@NotNull UUID modelConfigId) { + } } diff --git a/server/src/main/java/tech/easyflow/manuagent/agent/AgentExecutionService.java b/server/src/main/java/tech/easyflow/manuagent/agent/AgentExecutionService.java index 9e5a21f..c5c38ff 100644 --- a/server/src/main/java/tech/easyflow/manuagent/agent/AgentExecutionService.java +++ b/server/src/main/java/tech/easyflow/manuagent/agent/AgentExecutionService.java @@ -88,7 +88,8 @@ public class AgentExecutionService { .runId(run.id().toString()) .messages(List.of(AguiMessage.userMessage(UUID.randomUUID().toString(), attemptPrompt))) .build(); - try (AgentFactory.AgentHandle handle = agentFactory.create(project.id(), skillService.enabledNames())) { + try (AgentFactory.AgentHandle handle = agentFactory.create( + project.id(), run.modelConfigId(), skillService.enabledNames())) { handle.adapter().run(input) .takeUntilOther(stopSignal) .bufferTimeout(64, Duration.ofMillis(120)) diff --git a/server/src/main/java/tech/easyflow/manuagent/agent/AgentFactory.java b/server/src/main/java/tech/easyflow/manuagent/agent/AgentFactory.java index 03f27c6..d1652ca 100644 --- a/server/src/main/java/tech/easyflow/manuagent/agent/AgentFactory.java +++ b/server/src/main/java/tech/easyflow/manuagent/agent/AgentFactory.java @@ -78,11 +78,13 @@ public class AgentFactory { * 创建开启 AG-UI 推理和工具事件的 Harness 适配器。 * * @param projectId 项目 ID + * @param modelConfigId Run 创建时绑定的模型配置 ID * @param enabledSkills 当前启用 Skill * @return 需要在流结束后关闭的 Agent 句柄 */ - public AgentHandle create(UUID projectId, String[] enabledSkills) { - ModelService.ModelSecret model = modelService.defaultModelSecret(); + public AgentHandle create(UUID projectId, UUID modelConfigId, String[] enabledSkills) { + // 每次重新建立 Agent 连接时按 Run 固定的模型 ID读取最新配置;全局默认模型只参与新 Run 的选择。 + ModelService.ModelSecret model = modelService.requireRuntimeModel(modelConfigId); OpenAIChatModel chatModel = OpenAIChatModel.builder() .apiKey(model.apiKey()) .baseUrl(model.baseUrl()) diff --git a/server/src/main/java/tech/easyflow/manuagent/agent/AgentRunService.java b/server/src/main/java/tech/easyflow/manuagent/agent/AgentRunService.java index 129b155..3eaaa96 100644 --- a/server/src/main/java/tech/easyflow/manuagent/agent/AgentRunService.java +++ b/server/src/main/java/tech/easyflow/manuagent/agent/AgentRunService.java @@ -228,6 +228,19 @@ public class AgentRunService { */ @Transactional public RunView resume(UUID projectId, Principal principal) { + return resume(projectId, null, principal); + } + + /** + * 从已中断 Run 的原阶段继续,并可显式选择本次恢复使用的模型。 + * + * @param projectId 项目 ID + * @param modelConfigId 替代模型;为空时使用当前默认模型 + * @param principal 当前用户 + * @return 新的恢复 Run + */ + @Transactional + public RunView resume(UUID projectId, UUID modelConfigId, Principal principal) { ProjectService.ProjectView project = projectService.require(projectId); UUID userId = userService.requireUserId(principal.getName()); RunView interrupted = latest(projectId); @@ -235,18 +248,71 @@ public class AgentRunService { throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_INTERRUPTED", "当前没有可继续的任务"); } String phase = runStore.interruptedPhase(interrupted, project); - RunView run = runStore.create(projectId, "RESUME", interrupted.id()); + RunView run = runStore.create(projectId, "RESUME", interrupted.id(), modelConfigId); projectService.updateStatus(projectId, phase); + scheduleResume(project, run, userId, phase); + return run; + } + + /** + * 将运行中的任务切换到替代模型,并以新的恢复 Run 保留完整审计边界。 + * + *

旧 Run 在事务内先进入中断状态,新 Run 再绑定目标模型;任一步失败都会整体回滚。 + * 目标 ID 可以与旧 Run 相同,以便模型配置被编辑后重新建立客户端并读取最新配置。 + * 提交后先取消旧模型流,再从原业务阶段启动新 Run,复用相同 threadId 和工作区。

+ * + * @param projectId 项目 ID + * @param modelConfigId 替代模型 ID + * @param principal 当前用户 + * @return 绑定替代模型的新恢复 Run + */ + @Transactional + public RunView switchModel(UUID projectId, UUID modelConfigId, Principal principal) { + ProjectService.ProjectView project = projectService.require(projectId); + UUID userId = userService.requireUserId(principal.getName()); + RunView current = latest(projectId); + if (current == null || !"RUNNING".equals(current.status())) { + throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_ACTIVE", "当前没有正在执行的任务"); + } + String phase = runStore.interruptedPhase(current, project); + requireTerminalUpdate(runMapper.interruptRunning(current.id())); + eventService.append(projectId, current.id(), "RUN_FINISHED", Map.of( + "outcome", "CANCELLED", "reason", "MODEL_SWITCH")); + + RunView replacement = runStore.create(projectId, "RESUME", current.id(), modelConfigId); + eventService.append(projectId, replacement.id(), "MODEL_SWITCHED", Map.of( + "fromModelConfigId", current.modelConfigId(), + "toModelConfigId", replacement.modelConfigId())); + projectService.updateStatus(projectId, phase); + + onCommit(() -> { + RunControl control = activeRuns.get(current.id()); + if (control != null) { + control.cancel(); + } + }); + scheduleResume(project, replacement, userId, phase); + return replacement; + } + + /** + * 按中断前业务阶段注册恢复任务,所有调用方必须处于创建恢复 Run 的事务中。 + */ + private void scheduleResume( + ProjectService.ProjectView project, + RunView run, + UUID userId, + String phase) { switch (phase) { case "MATERIAL_CHECK" -> afterCommit( run.id(), () -> executeMaterialRun(project, run, true)); case "PLANNING" -> { - JsonNode materialResponse = runStore.latestMaterialResponse(projectId); + JsonNode materialResponse = runStore.latestMaterialResponse(project.id()); afterCommit(run.id(), () -> executePlanningRun( project, run, userId, materialResponse, true)); } case "WRITING" -> { - ProjectService.PlanView plan = projectService.currentPlan(projectId); + ProjectService.PlanView plan = projectService.currentPlan(project.id()); if (plan == null || !"CONFIRMED".equals(plan.status())) { throw new ApiException(HttpStatus.CONFLICT, "PLAN_NOT_CONFIRMED", "无法恢复:建设规划尚未确认"); } @@ -255,7 +321,6 @@ public class AgentRunService { default -> throw new ApiException( HttpStatus.CONFLICT, "RUN_PHASE_UNKNOWN", "无法识别中断前的执行阶段"); } - return run; } /** @@ -608,6 +673,7 @@ public class AgentRunService { * * @param id Run ID * @param projectId 项目 ID + * @param modelConfigId 本次 Run 固定绑定的模型配置 ID * @param triggerType 触发类型 * @param status 运行状态 * @param pendingInterrupt 待处理 Ask JSON @@ -618,6 +684,7 @@ public class AgentRunService { public record RunView( UUID id, UUID projectId, + UUID modelConfigId, String triggerType, String status, String pendingInterrupt, diff --git a/server/src/main/java/tech/easyflow/manuagent/agent/AgentRunStore.java b/server/src/main/java/tech/easyflow/manuagent/agent/AgentRunStore.java index 62b9441..83252c5 100644 --- a/server/src/main/java/tech/easyflow/manuagent/agent/AgentRunStore.java +++ b/server/src/main/java/tech/easyflow/manuagent/agent/AgentRunStore.java @@ -55,6 +55,27 @@ public class AgentRunStore { */ @SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。 public AgentRunService.RunView create(UUID projectId, String triggerType, UUID parentRunId) { + return create(projectId, triggerType, parentRunId, null); + } + + /** + * 创建绑定指定模型的新 Run;未指定模型时使用当前启用的默认模型。 + * + *

模型在 Run 创建事务内完成解析并写入 {@code model_config_id}。后续默认模型切换 + * 只影响新 Run,不会悄悄改变已经开始的任务。

+ * + * @param projectId 项目 ID + * @param triggerType 触发类型 + * @param parentRunId 父 Run ID + * @param requestedModelId 用户显式选择的模型;为空时使用默认模型 + * @return 新 Run + */ + @SuppressWarnings("unchecked") // 查询只投影模型 ID,LambdaGetter 可变参数不会引入运行期类型风险。 + public AgentRunService.RunView create( + UUID projectId, + String triggerType, + UUID parentRunId, + UUID requestedModelId) { QueryWrapper activeRuns = QueryWrapper.create() .where(AgentRunEntity::getProjectId).eq(projectId) .and(AgentRunEntity::getStatus).in("RUNNING", "WAITING_INPUT"); @@ -62,14 +83,20 @@ public class AgentRunStore { if (active > 0) { throw new ApiException(HttpStatus.CONFLICT, "RUN_ALREADY_ACTIVE", "项目已有正在执行或等待确认的任务"); } - QueryWrapper defaultModel = QueryWrapper.create() - .select(ModelConfigEntity::getId) - .where(ModelConfigEntity::getDefaultModel).eq(true) - .and(ModelConfigEntity::getEnabled).eq(true); - ModelConfigEntity model = modelMapper.selectOneByQuery(defaultModel); + QueryWrapper modelQuery = QueryWrapper.create() + .select(ModelConfigEntity::getId); + if (requestedModelId == null) { + modelQuery.where(ModelConfigEntity::getDefaultModel).eq(true); + } else { + modelQuery.where(ModelConfigEntity::getId).eq(requestedModelId); + } + modelQuery.and(ModelConfigEntity::getEnabled).eq(true); + ModelConfigEntity model = modelMapper.selectOneByQuery(modelQuery); if (model == null) { - // 迁移前的强制单条查询在该数据库不变量失效时进入统一 500 路径,不能新增 409 业务语义。 - throw new IllegalStateException("数据库中不存在已启用的默认模型"); + if (requestedModelId == null) { + throw new ApiException(HttpStatus.CONFLICT, "MODEL_NOT_CONFIGURED", "请先配置并启用默认模型"); + } + throw new ApiException(HttpStatus.CONFLICT, "MODEL_NOT_AVAILABLE", "选择的模型不存在或已停用"); } // 应用层提前生成 Run 与追踪 ID;时间字段仍交由数据库默认值统一生成。 @@ -222,6 +249,7 @@ public class AgentRunStore { return new AgentRunService.RunView( entity.getId(), entity.getProjectId(), + entity.getModelConfigId(), entity.getTriggerType(), entity.getStatus(), entity.getPendingInterrupt(), @@ -257,6 +285,7 @@ public class AgentRunStore { return QueryWrapper.create().select( AgentRunEntity::getId, AgentRunEntity::getProjectId, + AgentRunEntity::getModelConfigId, AgentRunEntity::getTriggerType, AgentRunEntity::getStatus, AgentRunEntity::getPendingInterrupt, diff --git a/server/src/main/java/tech/easyflow/manuagent/config/AppProperties.java b/server/src/main/java/tech/easyflow/manuagent/config/AppProperties.java index a6b5d54..195eebe 100644 --- a/server/src/main/java/tech/easyflow/manuagent/config/AppProperties.java +++ b/server/src/main/java/tech/easyflow/manuagent/config/AppProperties.java @@ -8,14 +8,10 @@ import org.springframework.boot.context.properties.ConfigurationProperties; * 应用自身的运行配置。 * * @param dataRoot 项目材料、工作区和产物根目录 - * @param deepseekKeyFile DeepSeek Key 文件 * @param dashscopeKeyFile 百炼 Key 文件 * @param masterKey 模型密钥加密主密钥 * @param adminUsername 本地管理员用户名 * @param adminPassword 本地管理员初始密码 - * @param modelBaseUrl 默认模型端点 - * @param modelId 默认模型标识 - * @param modelContextWindow 默认模型上下文窗口 * @param sandboxImage Agent Docker 运行镜像 * @param sandboxNetwork Agent Docker 网络 * @param runTimeout 单次 Agent 运行超时 @@ -23,14 +19,10 @@ import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties(prefix = "app") public record AppProperties( Path dataRoot, - Path deepseekKeyFile, Path dashscopeKeyFile, String masterKey, String adminUsername, String adminPassword, - String modelBaseUrl, - String modelId, - int modelContextWindow, String sandboxImage, String sandboxNetwork, Duration runTimeout) { diff --git a/server/src/main/java/tech/easyflow/manuagent/mapper/ModelAssignmentMapper.java b/server/src/main/java/tech/easyflow/manuagent/mapper/ModelAssignmentMapper.java index 3862845..fdb3c90 100644 --- a/server/src/main/java/tech/easyflow/manuagent/mapper/ModelAssignmentMapper.java +++ b/server/src/main/java/tech/easyflow/manuagent/mapper/ModelAssignmentMapper.java @@ -11,4 +11,7 @@ public interface ModelAssignmentMapper extends BaseMapper /** 按角色插入或更新模型分配。 */ int upsert(@Param("assignment") ModelAssignmentEntity assignment); + + /** 删除指定模型遗留的角色分配。 */ + int deleteByModelConfigId(@Param("modelConfigId") java.util.UUID modelConfigId); } diff --git a/server/src/main/java/tech/easyflow/manuagent/mapper/ModelConfigMapper.java b/server/src/main/java/tech/easyflow/manuagent/mapper/ModelConfigMapper.java index ee720a7..f5b60cd 100644 --- a/server/src/main/java/tech/easyflow/manuagent/mapper/ModelConfigMapper.java +++ b/server/src/main/java/tech/easyflow/manuagent/mapper/ModelConfigMapper.java @@ -42,4 +42,10 @@ public interface ModelConfigMapper extends BaseMapper { * @return 受影响行数 */ int setDefault(@Param("id") java.util.UUID id); + + /** 按主键更新模型启用状态,并刷新数据库更新时间。 */ + int setEnabled(@Param("id") java.util.UUID id, @Param("enabled") boolean enabled); + + /** 删除不再被 Run 或角色分配引用的模型。 */ + int deleteModel(@Param("id") java.util.UUID id); } diff --git a/server/src/main/java/tech/easyflow/manuagent/model/ModelController.java b/server/src/main/java/tech/easyflow/manuagent/model/ModelController.java index 0e8dddd..3ae5e04 100644 --- a/server/src/main/java/tech/easyflow/manuagent/model/ModelController.java +++ b/server/src/main/java/tech/easyflow/manuagent/model/ModelController.java @@ -1,11 +1,14 @@ package tech.easyflow.manuagent.model; import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; import java.security.Principal; import java.util.List; import java.util.UUID; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; @@ -72,7 +75,25 @@ public class ModelController { } /** - * 测试模型连接。 + * 使用管理页面当前草稿测试模型连接,但不保存草稿内容。 + * + *

已有模型且 API 地址未变化时允许不提交 API Key,此时服务层只读取该模型已加密保存的密钥; + * 新模型或修改 API 地址后的草稿必须提交 API Key,避免把隐藏密钥转发到其他主机。

+ * + * @param input 当前表单中的连接测试输入 + * @return 测试结果 + */ + @PostMapping("/test") + public ModelService.ConnectionResult testDraft( + @Valid @RequestBody ModelService.ConnectionTestInput input) { + return modelService.test(input); + } + + /** + * 使用数据库中已保存的完整配置测试模型连接。 + * + *

保留该接口以兼容已有调用方;管理页面使用 {@code POST /api/models/test} + * 测试未保存草稿。

* * @param id 模型 ID * @return 测试结果 @@ -93,4 +114,37 @@ public class ModelController { public void setDefault(@PathVariable UUID id, Principal principal) { modelService.setDefault(id, principal); } + + /** + * 启用或停用模型。 + * + * @param id 模型 ID + * @param input 状态输入 + * @return 更新后的模型 + */ + @PatchMapping("/{id}/enabled") + public ModelService.ModelView setEnabled( + @PathVariable UUID id, + @Valid @RequestBody EnabledInput input) { + return modelService.setEnabled(id, input.enabled()); + } + + /** + * 删除从未被历史 Run 引用的非默认模型。 + * + * @param id 模型 ID + */ + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@PathVariable UUID id) { + modelService.delete(id); + } + + /** + * 模型启用状态输入。 + * + * @param enabled 是否启用 + */ + public record EnabledInput(@NotNull Boolean enabled) { + } } diff --git a/server/src/main/java/tech/easyflow/manuagent/model/ModelService.java b/server/src/main/java/tech/easyflow/manuagent/model/ModelService.java index 46a6e1c..d0ed79c 100644 --- a/server/src/main/java/tech/easyflow/manuagent/model/ModelService.java +++ b/server/src/main/java/tech/easyflow/manuagent/model/ModelService.java @@ -3,11 +3,10 @@ package tech.easyflow.manuagent.model; import com.mybatisflex.core.query.QueryWrapper; import tech.easyflow.manuagent.auth.UserService; import tech.easyflow.manuagent.common.ApiException; -import tech.easyflow.manuagent.config.AppProperties; -import tech.easyflow.manuagent.entity.AppUserEntity; import tech.easyflow.manuagent.entity.ModelAssignmentEntity; import tech.easyflow.manuagent.entity.ModelConfigEntity; -import tech.easyflow.manuagent.mapper.AppUserMapper; +import tech.easyflow.manuagent.entity.AgentRunEntity; +import tech.easyflow.manuagent.mapper.AgentRunMapper; import tech.easyflow.manuagent.mapper.ModelAssignmentMapper; import tech.easyflow.manuagent.mapper.ModelConfigMapper; import com.fasterxml.jackson.databind.ObjectMapper; @@ -16,18 +15,16 @@ 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.Locale; 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 jakarta.validation.constraints.Size; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -36,15 +33,13 @@ import org.springframework.transaction.annotation.Transactional; * 管理 OpenAI 兼容模型配置、密钥和连接测试。 */ @Service -@Order(2) -public class ModelService implements ApplicationRunner { +public class ModelService { private final ModelConfigMapper modelMapper; private final ModelAssignmentMapper assignmentMapper; - private final AppUserMapper userMapper; + private final AgentRunMapper runMapper; private final UserService userService; private final KeyCipher keyCipher; - private final AppProperties properties; private final ObjectMapper objectMapper; private final HttpClient httpClient; @@ -53,81 +48,55 @@ public class ModelService implements ApplicationRunner { * * @param modelMapper 模型配置 Mapper * @param assignmentMapper 角色模型分配 Mapper - * @param userMapper 用户 Mapper + * @param runMapper Agent Run Mapper * @param userService 用户服务 * @param keyCipher 密钥加密器 - * @param properties 应用配置 * @param objectMapper JSON 映射器 */ + @Autowired public ModelService( ModelConfigMapper modelMapper, ModelAssignmentMapper assignmentMapper, - AppUserMapper userMapper, + AgentRunMapper runMapper, UserService userService, KeyCipher keyCipher, - AppProperties properties, ObjectMapper objectMapper) { - this.modelMapper = modelMapper; - this.assignmentMapper = assignmentMapper; - this.userMapper = userMapper; - this.userService = userService; - this.keyCipher = keyCipher; - this.properties = properties; - this.objectMapper = objectMapper; - this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build(); + this( + modelMapper, + assignmentMapper, + runMapper, + userService, + keyCipher, + objectMapper, + HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build()); } /** - * 从项目根目录 Key 文件初始化默认模型。 + * 创建可替换 HTTP 客户端的模型服务,仅供同包测试隔离外部网络边界。 * - * @param args 启动参数 + * @param modelMapper 模型配置 Mapper + * @param assignmentMapper 角色模型分配 Mapper + * @param runMapper Agent Run Mapper + * @param userService 用户服务 + * @param keyCipher 密钥加密器 + * @param objectMapper JSON 映射器 + * @param httpClient 模型连接使用的 HTTP 客户端 */ - @Override - @Transactional - @SuppressWarnings("unchecked") // MyBatis-Flex 的 select(LambdaGetter...) 使用泛型可变参数,调用本身类型安全。 - public void run(ApplicationArguments args) { - long count = modelMapper.selectCountByQuery(QueryWrapper.create()); - if (count > 0 || !Files.isRegularFile(properties.deepseekKeyFile())) { - return; - } - try { - String key = Files.readString(properties.deepseekKeyFile(), StandardCharsets.UTF_8).trim(); - if (key.isBlank()) { - return; - } - QueryWrapper userQuery = QueryWrapper.create() - .select(AppUserEntity::getId) - .orderBy(AppUserEntity::getCreatedAt).asc() - .limit(1); - AppUserEntity administrator = userMapper.selectOneByQuery(userQuery); - if (administrator == null) { - throw new IllegalStateException("初始化默认模型前必须先创建管理员账户"); - } - UUID adminId = administrator.getId(); - UUID modelId = UUID.randomUUID(); - ModelConfigEntity model = new ModelConfigEntity(); - model.setId(modelId); - model.setName("默认编排模型"); - model.setProvider("OPENAI_COMPATIBLE"); - model.setBaseUrl(properties.modelBaseUrl()); - model.setModelId(properties.modelId()); - model.setApiKeyCiphertext(keyCipher.encrypt(key)); - model.setApiKeyHint(hint(key)); - model.setKeyVersion((short) 1); - model.setConfigJson("{\"timeoutSeconds\":120,\"reasoningEffort\":\"high\"}"); - model.setCapabilitiesJson(json(Map.of( - "toolCalling", true, - "reasoning", true, - "contextWindow", properties.modelContextWindow()))); - model.setDefaultModel(true); - model.setCreatedBy(adminId); - modelMapper.insertModel(model); - for (String role : List.of("ORCHESTRATION", "WRITING", "REVIEW")) { - upsertAssignment(role, modelId, adminId); - } - } catch (IOException exception) { - throw new IllegalStateException("无法读取默认模型 Key", exception); - } + ModelService( + ModelConfigMapper modelMapper, + ModelAssignmentMapper assignmentMapper, + AgentRunMapper runMapper, + UserService userService, + KeyCipher keyCipher, + ObjectMapper objectMapper, + HttpClient httpClient) { + this.modelMapper = modelMapper; + this.assignmentMapper = assignmentMapper; + this.runMapper = runMapper; + this.userService = userService; + this.keyCipher = keyCipher; + this.objectMapper = objectMapper; + this.httpClient = httpClient; } /** @@ -160,14 +129,23 @@ public class ModelService implements ApplicationRunner { if (input.apiKey() == null || input.apiKey().isBlank()) { throw new ApiException(HttpStatus.BAD_REQUEST, "MODEL_KEY_REQUIRED", "新增模型需要 API Key"); } + QueryWrapper defaultQuery = QueryWrapper.create() + .where(ModelConfigEntity::getDefaultModel).eq(true); + boolean firstDefault = modelMapper.selectCountByQuery(defaultQuery) == 0; id = UUID.randomUUID(); ModelConfigEntity model = editableModel(id, input); model.setProvider("OPENAI_COMPATIBLE"); model.setApiKeyCiphertext(keyCipher.encrypt(input.apiKey().trim())); model.setApiKeyHint(hint(input.apiKey().trim())); model.setKeyVersion((short) 1); + model.setDefaultModel(firstDefault); model.setCreatedBy(userId); modelMapper.insertModel(model); + if (firstDefault) { + for (String role : List.of("ORCHESTRATION", "WRITING", "REVIEW")) { + upsertAssignment(role, id, userId); + } + } } else { ModelConfigEntity model = editableModel(id, input); if (input.apiKey() != null && !input.apiKey().isBlank()) { @@ -191,15 +169,67 @@ public class ModelService implements ApplicationRunner { */ @Transactional public void setDefault(UUID id, Principal principal) { - require(id); + ModelView target = require(id); + if (!target.enabled()) { + throw new ApiException(HttpStatus.CONFLICT, "MODEL_DISABLED", "停用模型不能设为默认模型"); + } UUID userId = userService.requireUserId(principal.getName()); modelMapper.clearDefault(); - modelMapper.setDefault(id); + if (modelMapper.setDefault(id) != 1) { + throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在"); + } for (String role : List.of("ORCHESTRATION", "WRITING", "REVIEW")) { upsertAssignment(role, id, userId); } } + /** + * 更新模型启用状态。 + * + *

默认模型承担新 Run 的选择职责,不能直接停用;正在执行的 Run 也不能失去模型, + * 用户应先在项目页停止并用替代模型恢复,再停用旧模型。

+ * + * @param id 模型 ID + * @param enabled 新启用状态 + * @return 更新后的安全模型视图 + */ + @Transactional + public ModelView setEnabled(UUID id, boolean enabled) { + ModelView current = require(id); + if (!enabled && current.defaultModel()) { + throw new ApiException(HttpStatus.CONFLICT, "DEFAULT_MODEL_REQUIRED", "请先设置新的默认模型"); + } + if (!enabled && countRuns(id, true) > 0) { + throw new ApiException(HttpStatus.CONFLICT, "MODEL_IN_USE", "模型正在被运行中的任务使用"); + } + if (modelMapper.setEnabled(id, enabled) != 1) { + throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在"); + } + return require(id); + } + + /** + * 真删除从未被历史 Run 使用的非默认模型。 + * + *

历史 Run 的模型引用属于审计事实,任何已有引用都会阻止删除;常规下线应使用停用。

+ * + * @param id 模型 ID + */ + @Transactional + public void delete(UUID id) { + ModelView current = require(id); + if (current.defaultModel()) { + throw new ApiException(HttpStatus.CONFLICT, "DEFAULT_MODEL_REQUIRED", "默认模型不能删除"); + } + if (countRuns(id, false) > 0) { + throw new ApiException(HttpStatus.CONFLICT, "MODEL_HISTORY_EXISTS", "模型已有任务记录,请改为停用"); + } + assignmentMapper.deleteByModelConfigId(id); + if (modelMapper.deleteModel(id) != 1) { + throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在"); + } + } + /** * 使用最小 Chat Completion 请求测试连接。 * @@ -207,7 +237,40 @@ public class ModelService implements ApplicationRunner { * @return 测试结果 */ public ConnectionResult test(UUID id) { - ModelSecret model = requireSecret(id); + return testConnection(requireRuntimeModel(id)); + } + + /** + * 使用当前表单草稿测试连接,不将任何草稿字段写入数据库。 + * + *

编辑已有模型时,空 API Key 表示复用数据库中的加密密钥;如果表单提供了新 Key, + * 则仅在本次请求内使用它。新增模型没有数据库身份,必须显式提供 API Key。

+ * + * @param input 当前模型连接草稿 + * @return 测试结果 + */ + public ConnectionResult test(ConnectionTestInput input) { + String baseUrl = normalizeBaseUrl(input.baseUrl()); + String apiKey = input.apiKey() == null ? "" : input.apiKey().trim(); + if (apiKey.isBlank()) { + apiKey = storedApiKey(input.id(), baseUrl); + } + ModelSecret draft = new ModelSecret( + input.id(), + baseUrl, + input.modelId().trim(), + apiKey, + 0); + return testConnection(draft); + } + + /** + * 向 OpenAI 兼容接口发送最小 Chat Completion 请求。 + * + * @param model 已解析出明文密钥的临时连接配置 + * @return 测试结果 + */ + private ConnectionResult testConnection(ModelSecret model) { String requestJson = json(Map.of( "model", model.modelId(), "messages", List.of(Map.of("role", "user", "content", "回复 OK")), @@ -271,7 +334,16 @@ public class ModelService implements ApplicationRunner { } @SuppressWarnings("unchecked") // 机密配置查询只投影固定列,LambdaGetter 可变参数不会引入运行期类型风险。 - private ModelSecret requireSecret(UUID id) { + /** + * 按 Run 已绑定的模型 ID读取当前启用配置及明文 Key,仅供模型调用链使用。 + * + *

该方法不会读取全局默认模型,因此管理员切换默认模型只会影响之后创建的 Run; + * 如果同一配置被编辑,下一次 Agent 连接会自然读取更新后的地址、模型 ID和密钥。

+ * + * @param id Run 绑定的模型配置 ID + * @return 可直接创建模型客户端的机密配置 + */ + public ModelSecret requireRuntimeModel(UUID id) { QueryWrapper query = QueryWrapper.create() .select( ModelConfigEntity::getId, @@ -323,6 +395,41 @@ public class ModelService implements ApplicationRunner { } } + /** + * 读取已有模型的保存密钥,供“API Key 留空”的草稿测试临时使用。 + * + *

查询只投影主键和密文字段,不要求模型处于启用状态,因为管理员需要先验证配置, + * 再决定是否重新启用。密钥只在服务端内存中短暂解密,永不写入响应或日志。

+ * + * @param id 已保存模型 ID;新增草稿没有 ID + * @param targetBaseUrl 已规范化的草稿 API 地址 + * @return 已解密 API Key + */ + @SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。 + private String storedApiKey(UUID id, String targetBaseUrl) { + if (id == null) { + throw new ApiException(HttpStatus.BAD_REQUEST, "MODEL_KEY_REQUIRED", "测试新模型需要 API Key"); + } + QueryWrapper query = QueryWrapper.create() + .select( + ModelConfigEntity::getId, + ModelConfigEntity::getBaseUrl, + ModelConfigEntity::getApiKeyCiphertext) + .where(ModelConfigEntity::getId).eq(id); + ModelConfigEntity model = modelMapper.selectOneByQuery(query); + if (model == null) { + throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在"); + } + // 隐藏密钥只能发往它原本绑定的地址,草稿换址必须由用户显式提供新密钥。 + if (!normalizeBaseUrl(model.getBaseUrl()).equals(targetBaseUrl)) { + throw new ApiException( + HttpStatus.BAD_REQUEST, + "MODEL_KEY_REQUIRED_FOR_NEW_BASE_URL", + "API 地址变更后需要重新输入 API Key"); + } + return keyCipher.decrypt(model.getApiKeyCiphertext()); + } + /** * 构造新增、更新共用的非敏感模型字段。 * @@ -352,6 +459,18 @@ public class ModelService implements ApplicationRunner { assignmentMapper.upsert(assignment); } + /** + * 统计模型的 Run 引用;停用检查只关心正在执行的 Run,删除检查覆盖全部历史记录。 + */ + private long countRuns(UUID modelId, boolean runningOnly) { + QueryWrapper query = QueryWrapper.create() + .where(AgentRunEntity::getModelConfigId).eq(modelId); + if (runningOnly) { + query.and(AgentRunEntity::getStatus).eq("RUNNING"); + } + return runMapper.selectCountByQuery(query); + } + /** * 将包含密文的内部实体转换为模型调用所需的最小明文对象。 */ @@ -414,14 +533,52 @@ public class ModelService implements ApplicationRunner { } } + /** + * 校验并规范化用户提供的 OpenAI 兼容服务根地址。 + * + *

模型地址最终会由服务端 HTTP 客户端主动访问,因此只允许具有明确主机的 HTTP(S) URI。 + * 用户信息可能泄露凭据,查询参数和片段会在拼接 {@code /chat/completions} 时产生歧义,均直接拒绝。 + * 动态模型供应商无法使用固定域名白名单,这里至少保证 URI 结构和请求语义稳定。

+ * + * @param baseUrl 用户输入的服务根地址 + * @return 去除末尾斜杠后的规范地址 + * @throws ApiException 地址结构或协议不符合要求时抛出 + */ private String normalizeBaseUrl(String baseUrl) { String value = baseUrl.trim(); + try { + URI uri = URI.create(value); + String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT); + boolean httpScheme = "http".equals(scheme) || "https".equals(scheme); + boolean stableRequestTarget = uri.getHost() != null + && !uri.getHost().isBlank() + && uri.getUserInfo() == null + && uri.getRawQuery() == null + && uri.getRawFragment() == null; + if (!httpScheme || uri.isOpaque() || !stableRequestTarget) { + throw invalidBaseUrl(); + } + } catch (IllegalArgumentException exception) { + throw invalidBaseUrl(); + } while (value.endsWith("/")) { value = value.substring(0, value.length() - 1); } return value; } + /** + * 构造不回显原始地址的统一校验异常,避免地址中意外携带的凭据进入日志或接口响应。 + * + * @return 模型地址校验异常 + */ + private ApiException invalidBaseUrl() { + return new ApiException( + HttpStatus.BAD_REQUEST, + "MODEL_BASE_URL_INVALID", + "模型 API 地址必须是有效的 HTTP 或 HTTPS 地址,且不能包含用户信息、查询参数或片段"); + } + private static String hint(String key) { return "••••" + key.substring(Math.max(0, key.length() - 4)); } @@ -437,14 +594,29 @@ public class ModelService implements ApplicationRunner { * @param capabilities 能力声明 */ public record ModelInput( - @NotBlank String name, - @NotBlank String baseUrl, - @NotBlank String modelId, - String apiKey, + @NotBlank @Size(max = 100) String name, + @NotBlank @Size(max = 500) String baseUrl, + @NotBlank @Size(max = 255) String modelId, + @Size(max = 4096) String apiKey, Map config, Map capabilities) { } + /** + * 不落库的模型连接测试输入。 + * + * @param id 已有模型 ID;新增草稿为 {@code null} + * @param baseUrl 当前表单中的 API 地址 + * @param modelId 当前表单中的模型标识 + * @param apiKey 当前表单中的新密钥;已有模型且 API 地址未变化时留空可复用保存密钥 + */ + public record ConnectionTestInput( + UUID id, + @NotBlank @Size(max = 500) String baseUrl, + @NotBlank @Size(max = 255) String modelId, + @Size(max = 4096) String apiKey) { + } + /** * 对外模型视图。 * diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index 8bb514e..7d54aad 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -42,14 +42,10 @@ server: app: data-root: file:../data - deepseek-key-file: ./deepseek_key.txt dashscope-key-file: ./dashscope_key.txt master-key: smart-factory-local-master-key admin-username: admin admin-password: admin123 - model-base-url: https://api.deepseek.com - model-id: deepseek-v4-flash - model-context-window: 131072 sandbox-image: smart-factory-agent-runtime:0.1.0 sandbox-network: bridge run-timeout: 60m diff --git a/server/src/main/resources/mapper/ModelAssignmentMapper.xml b/server/src/main/resources/mapper/ModelAssignmentMapper.xml index 4830f2e..4bc5192 100644 --- a/server/src/main/resources/mapper/ModelAssignmentMapper.xml +++ b/server/src/main/resources/mapper/ModelAssignmentMapper.xml @@ -15,4 +15,10 @@ assigned_by = EXCLUDED.assigned_by, updated_at = CURRENT_TIMESTAMP + + + DELETE FROM app.model_assignment + WHERE model_config_id = #{modelConfigId, + typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler} + diff --git a/server/src/main/resources/mapper/ModelConfigMapper.xml b/server/src/main/resources/mapper/ModelConfigMapper.xml index 042c109..7359f79 100644 --- a/server/src/main/resources/mapper/ModelConfigMapper.xml +++ b/server/src/main/resources/mapper/ModelConfigMapper.xml @@ -60,4 +60,16 @@ updated_at = CURRENT_TIMESTAMP WHERE id = #{id, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler} + + + UPDATE app.model_config + SET enabled = #{enabled}, + updated_at = CURRENT_TIMESTAMP + WHERE id = #{id, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler} + + + + DELETE FROM app.model_config + WHERE id = #{id, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler} + diff --git a/server/src/test/java/tech/easyflow/manuagent/DatabaseAndEventIntegrationTest.java b/server/src/test/java/tech/easyflow/manuagent/DatabaseAndEventIntegrationTest.java index 625861c..abdc05c 100644 --- a/server/src/test/java/tech/easyflow/manuagent/DatabaseAndEventIntegrationTest.java +++ b/server/src/test/java/tech/easyflow/manuagent/DatabaseAndEventIntegrationTest.java @@ -1,6 +1,7 @@ package tech.easyflow.manuagent; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -373,7 +374,7 @@ class DatabaseAndEventIntegrationTest { } /** - * 验证模型新增、保留旧密钥更新、默认分配及密钥解密读取。 + * 验证多个模型持久化、首模型自动默认、保留旧密钥更新以及默认模型切换。 * * @throws Exception Mapper XML 初始化失败时抛出 */ @@ -386,24 +387,19 @@ class DatabaseAndEventIntegrationTest { when(users.requireUserId("admin")).thenReturn(userId); AppProperties properties = new AppProperties( temporaryDirectory, - temporaryDirectory.resolve("deepseek.key"), temporaryDirectory.resolve("dashscope.key"), "integration-master-key", "admin", "admin", - "https://default.example.test", - "default-model", - 131_072, "runtime:test", "bridge", Duration.ofMinutes(1)); ModelService service = new ModelService( modelConfigMapper(), modelAssignmentMapper(), - mock(AppUserMapper.class), + agentRunMapper(), users, new KeyCipher(properties), - properties, new ObjectMapper()); Map capabilities = Map.of( "toolCalling", true, "reasoning", true, "contextWindow", 65_536); @@ -420,16 +416,61 @@ class DatabaseAndEventIntegrationTest { "测试模型更新", "https://model.example.test", "model-v2", "", Map.of("timeoutSeconds", 120), capabilities), () -> "admin"); - service.setDefault(created.id(), () -> "admin"); - ModelService.ModelSecret secret = service.defaultModelSecret(); + ModelService.ModelView second = service.save( + null, + new ModelService.ModelInput( + "第二测试模型", "https://second-model.example.test", "model-second", "secret-5678", + Map.of("timeoutSeconds", 90), capabilities), + () -> "admin"); + + assertThat(created.defaultModel()).isTrue(); + assertThat(second.defaultModel()).isFalse(); + + service.setDefault(second.id(), () -> "admin"); + ModelService.ModelSecret firstSecret = service.requireRuntimeModel(created.id()); + ModelService.ModelSecret defaultSecret = service.defaultModelSecret(); assertThat(updated.name()).isEqualTo("测试模型更新"); assertThat(updated.apiKeyHint()).endsWith("1234"); - assertThat(secret.apiKey()).isEqualTo("secret-1234"); - assertThat(secret.modelId()).isEqualTo("model-v2"); - assertThat(secret.contextWindow()).isEqualTo(65_536); + assertThat(firstSecret.apiKey()).isEqualTo("secret-1234"); + assertThat(firstSecret.modelId()).isEqualTo("model-v2"); + assertThat(defaultSecret.apiKey()).isEqualTo("secret-5678"); + assertThat(defaultSecret.modelId()).isEqualTo("model-second"); + assertThat(defaultSecret.contextWindow()).isEqualTo(65_536); assertThat(jdbc().sql("SELECT COUNT(*) FROM app.model_assignment WHERE model_config_id = :id") - .param("id", created.id()).query(Long.class).single()).isEqualTo(3L); + .param("id", second.id()).query(Long.class).single()).isEqualTo(3L); + + assertThatThrownBy(() -> service.setEnabled(second.id(), false)) + .isInstanceOfSatisfying(tech.easyflow.manuagent.common.ApiException.class, exception -> + assertThat(exception.code()).isEqualTo("DEFAULT_MODEL_REQUIRED")); + ModelService.ModelView disabled = service.setEnabled(created.id(), false); + assertThat(disabled.enabled()).isFalse(); + + // 已完成 Run 仍属于历史审计事实;即使模型已经停用,也必须阻止真删除。 + UUID historyProjectId = UUID.randomUUID(); + UUID historyRunId = UUID.randomUUID(); + jdbc().sql(""" + INSERT INTO app.project(id, company_name, project_name, agui_thread_id, application_level, created_by) + VALUES (:id, '模型历史企业', '模型历史测试', :threadId, 'ADVANCED', :userId) + """).param("id", historyProjectId).param("threadId", "thread-" + historyProjectId) + .param("userId", userId).update(); + jdbc().sql(""" + INSERT INTO app.agent_run( + id, project_id, model_config_id, trigger_type, status, trace_id, ended_at) + VALUES (:id, :projectId, :modelId, 'INITIAL', 'COMPLETED', :traceId, CURRENT_TIMESTAMP) + """).param("id", historyRunId).param("projectId", historyProjectId) + .param("modelId", created.id()).param("traceId", "trace-" + historyRunId).update(); + + assertThatThrownBy(() -> service.delete(created.id())) + .isInstanceOfSatisfying(tech.easyflow.manuagent.common.ApiException.class, exception -> + assertThat(exception.code()).isEqualTo("MODEL_HISTORY_EXISTS")); + + // 清理本测试创建的引用后,再验证从未被历史 Run 使用的模型仍可按原有规则删除。 + jdbc().sql("DELETE FROM app.agent_run WHERE id = :id").param("id", historyRunId).update(); + jdbc().sql("DELETE FROM app.project WHERE id = :id").param("id", historyProjectId).update(); + service.delete(created.id()); + assertThat(jdbc().sql("SELECT COUNT(*) FROM app.model_config WHERE id = :id") + .param("id", created.id()).query(Long.class).single()).isZero(); } /** @@ -442,14 +483,10 @@ class DatabaseAndEventIntegrationTest { SkillConfigMapper mapper = skillConfigMapper(); AppProperties properties = new AppProperties( temporaryDirectory, - temporaryDirectory.resolve("deepseek.key"), temporaryDirectory.resolve("dashscope.key"), "integration-master-key", "admin", "admin", - "https://default.example.test", - "default-model", - 131_072, "runtime:test", "bridge", Duration.ofMinutes(1)); diff --git a/server/src/test/java/tech/easyflow/manuagent/KeyCipherAndShellTest.java b/server/src/test/java/tech/easyflow/manuagent/KeyCipherAndShellTest.java index 01fe2f6..49348a9 100644 --- a/server/src/test/java/tech/easyflow/manuagent/KeyCipherAndShellTest.java +++ b/server/src/test/java/tech/easyflow/manuagent/KeyCipherAndShellTest.java @@ -28,8 +28,8 @@ class KeyCipherAndShellTest { private AppProperties properties() { return new AppProperties( - Path.of("data"), Path.of("deepseek"), Path.of("dashscope"), - "unit-test-master", "admin", "admin123", "https://api.example.test", "model", - 131_072, "smart-factory-agent-runtime:test", "bridge", Duration.ofMinutes(1)); + Path.of("data"), Path.of("dashscope"), + "unit-test-master", "admin", "admin123", + "smart-factory-agent-runtime:test", "bridge", Duration.ofMinutes(1)); } } diff --git a/server/src/test/java/tech/easyflow/manuagent/MyBatisFlexTransactionIntegrationTest.java b/server/src/test/java/tech/easyflow/manuagent/MyBatisFlexTransactionIntegrationTest.java index 96d5ef6..c4afeb9 100644 --- a/server/src/test/java/tech/easyflow/manuagent/MyBatisFlexTransactionIntegrationTest.java +++ b/server/src/test/java/tech/easyflow/manuagent/MyBatisFlexTransactionIntegrationTest.java @@ -33,6 +33,7 @@ import tech.easyflow.manuagent.config.MyBatisFlexConfiguration; import tech.easyflow.manuagent.mapper.AppUserMapper; import tech.easyflow.manuagent.mapper.ModelAssignmentMapper; import tech.easyflow.manuagent.mapper.ModelConfigMapper; +import tech.easyflow.manuagent.mapper.AgentRunMapper; import tech.easyflow.manuagent.model.KeyCipher; import tech.easyflow.manuagent.model.ModelService; @@ -91,7 +92,7 @@ class MyBatisFlexTransactionIntegrationTest { UUID userId = UUID.randomUUID(); UUID missingUserId = UUID.randomUUID(); UUID currentDefaultId = UUID.randomUUID(); - UUID disabledTargetId = UUID.randomUUID(); + UUID enabledTargetId = UUID.randomUUID(); jdbc.sql(""" INSERT INTO app.app_user(id, username, password_hash, display_name) VALUES (:id, :username, 'encoded', '事务测试用户') @@ -106,12 +107,12 @@ class MyBatisFlexTransactionIntegrationTest { (:currentId, :currentName, 'OPENAI_COMPATIBLE', 'https://current.test', 'current-model', TRUE, TRUE), (:targetId, :targetName, 'OPENAI_COMPATIBLE', 'https://target.test', - 'target-model', FALSE, FALSE) + 'target-model', TRUE, FALSE) """) .param("currentId", currentDefaultId) .param("currentName", "current-" + currentDefaultId) - .param("targetId", disabledTargetId) - .param("targetName", "target-" + disabledTargetId) + .param("targetId", enabledTargetId) + .param("targetName", "target-" + enabledTargetId) .update(); UserService users = context.getBean(UserService.class); when(users.requireUserId("admin")).thenReturn(missingUserId); @@ -119,7 +120,7 @@ class MyBatisFlexTransactionIntegrationTest { assertThat(context.getBeansOfType(PlatformTransactionManager.class)).hasSize(1); assertThat(AopUtils.isAopProxy(modelService)).isTrue(); - assertThatThrownBy(() -> modelService.setDefault(disabledTargetId, () -> "admin")) + assertThatThrownBy(() -> modelService.setDefault(enabledTargetId, () -> "admin")) .hasRootCauseInstanceOf(PSQLException.class); assertThat(jdbc.sql("SELECT is_default FROM app.model_config WHERE id = :id") @@ -127,7 +128,7 @@ class MyBatisFlexTransactionIntegrationTest { .query(Boolean.class) .single()).isTrue(); assertThat(jdbc.sql("SELECT is_default FROM app.model_config WHERE id = :id") - .param("id", disabledTargetId) + .param("id", enabledTargetId) .query(Boolean.class) .single()).isFalse(); }); @@ -152,15 +153,14 @@ class MyBatisFlexTransactionIntegrationTest { ModelService modelService( ModelConfigMapper modelMapper, ModelAssignmentMapper assignmentMapper, - AppUserMapper userMapper, + AgentRunMapper runMapper, UserService userService) { return new ModelService( modelMapper, assignmentMapper, - userMapper, + runMapper, userService, mock(KeyCipher.class), - mock(AppProperties.class), new ObjectMapper()); } } diff --git a/server/src/test/java/tech/easyflow/manuagent/agent/AgentExecutionServiceTest.java b/server/src/test/java/tech/easyflow/manuagent/agent/AgentExecutionServiceTest.java index 35590b2..d53448c 100644 --- a/server/src/test/java/tech/easyflow/manuagent/agent/AgentExecutionServiceTest.java +++ b/server/src/test/java/tech/easyflow/manuagent/agent/AgentExecutionServiceTest.java @@ -1,14 +1,62 @@ package tech.easyflow.manuagent.agent; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.agentscope.core.agui.adapter.AguiAgentAdapter; +import java.util.UUID; import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import tech.easyflow.manuagent.project.ProjectFileService; +import tech.easyflow.manuagent.project.ProjectService; +import tech.easyflow.manuagent.skill.SkillService; /** * 验证 Agent 事件持久化的精简规则。 */ class AgentExecutionServiceTest { + /** + * Run 在创建时已经固化模型配置 ID;执行和模型重连都必须沿用该 ID, + * 不能在工厂内部重新读取可能已经变化的全局默认模型。 + */ + @Test + void shouldCreateAgentWithModelBoundToRun() { + UUID projectId = UUID.randomUUID(); + UUID runId = UUID.randomUUID(); + UUID modelId = UUID.randomUUID(); + AgentFactory factory = mock(AgentFactory.class); + AgentFactory.AgentHandle handle = mock(AgentFactory.AgentHandle.class); + AguiAgentAdapter adapter = mock(AguiAgentAdapter.class); + SkillService skillService = mock(SkillService.class); + when(handle.adapter()).thenReturn(adapter); + when(adapter.run(any())).thenReturn(Flux.empty()); + when(skillService.enabledNames()).thenReturn(new String[] {"document"}); + when(factory.create(eq(projectId), eq(modelId), any(String[].class))).thenReturn(handle); + + AgentExecutionService service = new AgentExecutionService( + new ObjectMapper(), + factory, + mock(AgentEventService.class), + mock(ProjectFileService.class), + skillService); + ProjectService.ProjectView project = mock(ProjectService.ProjectView.class); + when(project.id()).thenReturn(projectId); + when(project.threadId()).thenReturn("project-" + projectId); + AgentRunService.RunView run = new AgentRunService.RunView( + runId, projectId, modelId, "INITIAL", "RUNNING", null, null, null, null); + + service.execute(project, run, "执行测试", Mono.never(), () -> { }, () -> false); + + verify(factory).create(eq(projectId), eq(modelId), any(String[].class)); + } + /** * 验证文档视觉结果保留图片路径元数据并丢弃 Base64 正文。 */ diff --git a/server/src/test/java/tech/easyflow/manuagent/agent/AgentRunServiceTest.java b/server/src/test/java/tech/easyflow/manuagent/agent/AgentRunServiceTest.java index efb93ea..55f6c9a 100644 --- a/server/src/test/java/tech/easyflow/manuagent/agent/AgentRunServiceTest.java +++ b/server/src/test/java/tech/easyflow/manuagent/agent/AgentRunServiceTest.java @@ -1,20 +1,87 @@ package tech.easyflow.manuagent.agent; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import io.agentscope.core.model.transport.HttpTransportException; import io.agentscope.core.skill.AgentSkill; import java.nio.file.Path; +import java.time.OffsetDateTime; import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ExecutorService; import org.junit.jupiter.api.Test; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionTemplate; +import tech.easyflow.manuagent.artifact.ArtifactService; +import tech.easyflow.manuagent.auth.UserService; +import tech.easyflow.manuagent.mapper.AgentRunMapper; +import tech.easyflow.manuagent.project.ProjectFileService; +import tech.easyflow.manuagent.project.ProjectService; /** * 验证 Agent Run 的模型重连边界。 */ class AgentRunServiceTest { + /** + * 运行中模型配置被编辑后,用户应能选择同一模型 ID 创建新的恢复 Run, + * 使新 Agent 客户端重新读取数据库中的最新地址、模型标识和密钥。 + */ + @Test + void shouldRestartRunningTaskWhenSameModelConfigurationWasUpdated() { + UUID projectId = UUID.randomUUID(); + UUID modelId = UUID.randomUUID(); + UUID currentRunId = UUID.randomUUID(); + UUID replacementRunId = UUID.randomUUID(); + OffsetDateTime now = OffsetDateTime.now(); + ProjectService.ProjectView project = new ProjectService.ProjectView( + projectId, "测试企业", "测试项目", "thread-1", "ADVANCED", "MATERIAL_CHECK", 0L, now, now); + AgentRunService.RunView current = new AgentRunService.RunView( + currentRunId, projectId, modelId, "INITIAL", "RUNNING", null, null, now, null); + AgentRunService.RunView replacement = new AgentRunService.RunView( + replacementRunId, projectId, modelId, "RESUME", "RUNNING", null, null, now, null); + + AgentRunMapper runMapper = mock(AgentRunMapper.class); + AgentRunStore runStore = mock(AgentRunStore.class); + ProjectService projectService = mock(ProjectService.class); + UserService userService = mock(UserService.class); + when(projectService.require(projectId)).thenReturn(project); + when(userService.requireUserId("admin")).thenReturn(UUID.randomUUID()); + when(runStore.latest(projectId)).thenReturn(current); + when(runStore.interruptedPhase(current, project)).thenReturn("MATERIAL_CHECK"); + when(runMapper.interruptRunning(currentRunId)).thenReturn(1); + when(runStore.create(projectId, "RESUME", currentRunId, modelId)).thenReturn(replacement); + + AgentRunService service = new AgentRunService( + runMapper, + new ObjectMapper(), + mock(AgentExecutionService.class), + mock(AgentOutputService.class), + runStore, + mock(AgentEventService.class), + projectService, + mock(ProjectFileService.class), + userService, + mock(ArtifactService.class), + mock(ExecutorService.class), + mock(TransactionTemplate.class)); + + // 服务会注册“提交后取消旧流并启动新流”的回调;测试只验证注册前的事务内状态转换。 + TransactionSynchronizationManager.initSynchronization(); + try { + assertThat(service.switchModel(projectId, modelId, () -> "admin")).isEqualTo(replacement); + verify(runMapper).interruptRunning(currentRunId); + verify(runStore).create(projectId, "RESUME", currentRunId, modelId); + } finally { + TransactionSynchronizationManager.clearSynchronization(); + } + } + /** * 网络故障和服务端错误允许重连,参数错误保持原始失败。 */ diff --git a/server/src/test/java/tech/easyflow/manuagent/agent/AgentRunStoreQueryTest.java b/server/src/test/java/tech/easyflow/manuagent/agent/AgentRunStoreQueryTest.java index e33dba7..4b4514d 100644 --- a/server/src/test/java/tech/easyflow/manuagent/agent/AgentRunStoreQueryTest.java +++ b/server/src/test/java/tech/easyflow/manuagent/agent/AgentRunStoreQueryTest.java @@ -14,6 +14,7 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import tech.easyflow.manuagent.entity.AgentRunEntity; import tech.easyflow.manuagent.common.ApiException; +import tech.easyflow.manuagent.entity.ModelConfigEntity; import tech.easyflow.manuagent.mapper.AgentEventMapper; import tech.easyflow.manuagent.mapper.AgentRunMapper; import tech.easyflow.manuagent.mapper.ModelConfigMapper; @@ -49,10 +50,11 @@ class AgentRunStoreQueryTest { } /** - * 默认模型缺失属于数据库配置异常,不应在 ORM 迁移中新增 409 业务错误。 + * 数据库允许在首次启动时没有模型,因此启动 Run 时应返回可操作的业务错误, + * 不能把正常的“尚未配置”状态暴露为服务端技术异常。 */ @Test - void shouldKeepMissingDefaultModelAsUnexpectedTechnicalFailure() { + void shouldReportMissingDefaultModelAsConfigurationConflict() { AgentRunMapper runMapper = mock(AgentRunMapper.class); when(runMapper.selectCountByQuery(any(QueryWrapper.class))).thenReturn(0L); AgentRunStore store = new AgentRunStore( @@ -62,8 +64,49 @@ class AgentRunStoreQueryTest { new ObjectMapper()); assertThatThrownBy(() -> store.create(UUID.randomUUID(), "INITIAL", null)) - .isInstanceOf(IllegalStateException.class) - .isNotInstanceOf(ApiException.class); + .isInstanceOfSatisfying(ApiException.class, exception -> { + assertThat(exception.status().value()).isEqualTo(409); + assertThat(exception.code()).isEqualTo("MODEL_NOT_CONFIGURED"); + }); + } + + /** + * 用户恢复任务时可以显式选择替代模型;新 Run 必须保存该模型 ID, + * 不能再次回退到随后可能变化的全局默认模型。 + */ + @Test + void shouldCreateRunWithExplicitEnabledModel() { + UUID projectId = UUID.randomUUID(); + UUID modelId = UUID.randomUUID(); + AgentRunMapper runMapper = mock(AgentRunMapper.class); + ModelConfigMapper modelMapper = mock(ModelConfigMapper.class); + when(runMapper.selectCountByQuery(any(QueryWrapper.class))).thenReturn(0L); + + ModelConfigEntity selectedModel = new ModelConfigEntity(); + selectedModel.setId(modelId); + selectedModel.setEnabled(true); + when(modelMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(selectedModel); + + AgentRunEntity storedRun = new AgentRunEntity(); + storedRun.setId(UUID.randomUUID()); + storedRun.setProjectId(projectId); + storedRun.setModelConfigId(modelId); + storedRun.setTriggerType("RESUME"); + storedRun.setStatus("RUNNING"); + when(runMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(storedRun); + + AgentRunStore store = new AgentRunStore( + runMapper, + mock(AgentEventMapper.class), + modelMapper, + new ObjectMapper()); + + AgentRunService.RunView run = store.create(projectId, "RESUME", null, modelId); + + ArgumentCaptor entityCaptor = ArgumentCaptor.forClass(AgentRunEntity.class); + verify(runMapper).insertSelective(entityCaptor.capture()); + assertThat(entityCaptor.getValue().getModelConfigId()).isEqualTo(modelId); + assertThat(run.modelConfigId()).isEqualTo(modelId); } /** diff --git a/server/src/test/java/tech/easyflow/manuagent/config/AppPropertiesValidationTest.java b/server/src/test/java/tech/easyflow/manuagent/config/AppPropertiesValidationTest.java new file mode 100644 index 0000000..e62e7f0 --- /dev/null +++ b/server/src/test/java/tech/easyflow/manuagent/config/AppPropertiesValidationTest.java @@ -0,0 +1,72 @@ +package tech.easyflow.manuagent.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.core.env.PropertySource; +import org.springframework.core.io.ClassPathResource; +import org.springframework.context.annotation.Configuration; + +/** + * 验证应用主密钥沿用本地配置文件的读取方式。 + */ +class AppPropertiesValidationTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withUserConfiguration(PropertiesConfiguration.class) + .withPropertyValues( + "app.data-root=file:../data", + "app.dashscope-key-file=./dashscope_key.txt", + "app.master-key=unit-test-master-key", + "app.admin-username=admin", + "app.admin-password=admin123", + "app.sandbox-image=runtime:test", + "app.sandbox-network=bridge", + "app.run-timeout=1m"); + + /** + * 默认应用配置必须直接提供主密钥,使本地启动不依赖额外环境变量。 + * + * @throws IOException application.yml 无法读取时抛出 + */ + @Test + void shouldProvideMasterKeyInApplicationConfiguration() throws IOException { + YamlPropertySourceLoader loader = new YamlPropertySourceLoader(); + List> sources = loader.load( + "application.yml", + new ClassPathResource("application.yml")); + + assertThat(sources) + .extracting(source -> source.getProperty("app.master-key")) + .singleElement() + .isInstanceOf(String.class) + .asString() + .isNotBlank() + .doesNotContain("APP_MASTER_KEY"); + } + + /** + * 提供非空加密主密钥后,配置属性应可以正常绑定并供密钥组件使用。 + */ + @Test + void shouldBindConfiguredMasterKey() { + contextRunner.run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBean(AppProperties.class).masterKey()) + .isEqualTo("unit-test-master-key"); + }); + } + + /** + * 注册应用配置属性,复用生产环境的 Spring Boot 配置绑定流程。 + */ + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(AppProperties.class) + static class PropertiesConfiguration { + } +} diff --git a/server/src/test/java/tech/easyflow/manuagent/model/ModelControllerTest.java b/server/src/test/java/tech/easyflow/manuagent/model/ModelControllerTest.java new file mode 100644 index 0000000..7a4676d --- /dev/null +++ b/server/src/test/java/tech/easyflow/manuagent/model/ModelControllerTest.java @@ -0,0 +1,76 @@ +package tech.easyflow.manuagent.model; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +/** + * 验证模型管理页面依赖的 HTTP 接口契约。 + * + *

这些测试刻意放在控制器边界,防止前端请求方法或路径与后端映射再次发生漂移。

+ */ +class ModelControllerTest { + + /** + * 测试连接必须接收当前表单草稿,使用户无需先持久化可能无效的配置。 + * + * @throws Exception MockMvc 执行失败时抛出 + */ + @Test + void shouldTestCurrentModelDraftWithoutSavingIt() throws Exception { + ModelService service = mock(ModelService.class); + when(service.test(any(ModelService.ConnectionTestInput.class))) + .thenReturn(new ModelService.ConnectionResult(true, 12L, "连接正常")); + MockMvc mvc = MockMvcBuilders.standaloneSetup(new ModelController(service)).build(); + + mvc.perform(post("/api/models/test") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "id": "01cdd509-a79a-4503-839f-160b32d518e2", + "baseUrl": "https://draft.example.test/v1", + "modelId": "draft-model", + "apiKey": "draft-secret" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value("连接正常")); + + verify(service).test(new ModelService.ConnectionTestInput( + UUID.fromString("01cdd509-a79a-4503-839f-160b32d518e2"), + "https://draft.example.test/v1", + "draft-model", + "draft-secret")); + } + + /** + * 停用操作必须由 PATCH 状态接口处理,不能落入静态资源处理器并返回 404。 + * + * @throws Exception MockMvc 执行失败时抛出 + */ + @Test + void shouldRouteDisableRequestToModelService() throws Exception { + UUID id = UUID.fromString("01cdd509-a79a-4503-839f-160b32d518e2"); + ModelService service = mock(ModelService.class); + MockMvc mvc = MockMvcBuilders.standaloneSetup(new ModelController(service)).build(); + + mvc.perform(patch("/api/models/{id}/enabled", id) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"enabled\":false}")) + .andExpect(status().isOk()); + + verify(service).setEnabled(id, false); + } +} diff --git a/server/src/test/java/tech/easyflow/manuagent/model/ModelServiceConnectionTest.java b/server/src/test/java/tech/easyflow/manuagent/model/ModelServiceConnectionTest.java new file mode 100644 index 0000000..3acd3d7 --- /dev/null +++ b/server/src/test/java/tech/easyflow/manuagent/model/ModelServiceConnectionTest.java @@ -0,0 +1,201 @@ +package tech.easyflow.manuagent.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import tech.easyflow.manuagent.auth.UserService; +import tech.easyflow.manuagent.common.ApiException; +import tech.easyflow.manuagent.entity.ModelConfigEntity; +import tech.easyflow.manuagent.mapper.AgentRunMapper; +import tech.easyflow.manuagent.mapper.ModelAssignmentMapper; +import tech.easyflow.manuagent.mapper.ModelConfigMapper; + +/** + * 验证模型草稿连接测试的密钥选择和外部请求边界。 + */ +class ModelServiceConnectionTest { + + /** + * Spring 容器必须能够在生产构造器和包内测试构造器之间选择生产构造器。 + * + *

该测试防止新增辅助构造器后,应用启动阶段退化为查找不存在的无参构造器。

+ */ + @Test + void shouldCreateModelServiceBeanWithProductionConstructor() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + context.registerBean(ModelConfigMapper.class, () -> mock(ModelConfigMapper.class)); + context.registerBean(ModelAssignmentMapper.class, () -> mock(ModelAssignmentMapper.class)); + context.registerBean(AgentRunMapper.class, () -> mock(AgentRunMapper.class)); + context.registerBean(UserService.class, () -> mock(UserService.class)); + context.registerBean(KeyCipher.class, () -> mock(KeyCipher.class)); + context.registerBean(ObjectMapper.class, () -> new ObjectMapper()); + context.register(ModelService.class); + + context.refresh(); + + assertThat(context.getBean(ModelService.class)).isNotNull(); + } + } + + /** + * 表单提供新密钥时,应直接测试草稿地址,且不能读取或修改数据库模型配置。 + * + * @throws Exception HTTP 客户端桩配置或调用失败时抛出 + */ + @Test + void shouldTestDraftWithSubmittedKeyWithoutReadingDatabase() throws Exception { + ModelConfigMapper modelMapper = mock(ModelConfigMapper.class); + HttpClient httpClient = successfulHttpClient(); + ModelService service = service(modelMapper, mock(KeyCipher.class), httpClient); + + ModelService.ConnectionResult result = service.test(new ModelService.ConnectionTestInput( + UUID.randomUUID(), + "https://draft.example.test/v1/", + "draft-model", + "draft-secret")); + + assertThat(result.success()).isTrue(); + HttpRequest request = sentRequest(httpClient); + assertThat(request.uri()).isEqualTo(URI.create("https://draft.example.test/v1/chat/completions")); + assertThat(request.headers().firstValue("Authorization")).contains("Bearer draft-secret"); + verifyNoInteractions(modelMapper); + } + + /** + * 新模型没有数据库 ID 和保存密钥,API Key 留空时应在发起网络请求前返回明确错误。 + */ + @Test + void shouldRequireKeyWhenTestingNewModelDraft() { + ModelConfigMapper modelMapper = mock(ModelConfigMapper.class); + HttpClient httpClient = mock(HttpClient.class); + ModelService service = service(modelMapper, mock(KeyCipher.class), httpClient); + + assertThatThrownBy(() -> service.test(new ModelService.ConnectionTestInput( + null, + "https://draft.example.test/v1", + "draft-model", + ""))) + .isInstanceOf(ApiException.class) + .hasMessage("测试新模型需要 API Key"); + + verifyNoInteractions(modelMapper, httpClient); + } + + /** + * 草稿改到其他 API 主机时不能转发数据库中的隐藏密钥,必须要求用户重新输入 Key。 + */ + @Test + void shouldNotForwardStoredKeyToChangedBaseUrl() { + UUID modelId = UUID.randomUUID(); + ModelConfigEntity saved = new ModelConfigEntity(); + saved.setId(modelId); + saved.setBaseUrl("https://saved.example.test/v1"); + saved.setApiKeyCiphertext("encrypted-secret".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + ModelConfigMapper modelMapper = mock(ModelConfigMapper.class); + when(modelMapper.selectOneByQuery(any())).thenReturn(saved); + HttpClient httpClient = mock(HttpClient.class); + ModelService service = service(modelMapper, mock(KeyCipher.class), httpClient); + + assertThatThrownBy(() -> service.test(new ModelService.ConnectionTestInput( + modelId, + "https://attacker.example.test/v1", + "draft-model", + ""))) + .isInstanceOf(ApiException.class) + .hasMessage("API 地址变更后需要重新输入 API Key"); + + verifyNoInteractions(httpClient); + } + + /** + * 草稿地址与保存地址一致时,允许安全复用保存密钥,避免用户为普通模型参数调整重复输入 Key。 + * + * @throws Exception HTTP 客户端桩配置或调用失败时抛出 + */ + @Test + void shouldReuseStoredKeyWhenBaseUrlIsUnchanged() throws Exception { + UUID modelId = UUID.randomUUID(); + byte[] ciphertext = "encrypted-secret".getBytes(java.nio.charset.StandardCharsets.UTF_8); + ModelConfigEntity saved = new ModelConfigEntity(); + saved.setId(modelId); + saved.setBaseUrl("https://saved.example.test/v1"); + saved.setApiKeyCiphertext(ciphertext); + ModelConfigMapper modelMapper = mock(ModelConfigMapper.class); + when(modelMapper.selectOneByQuery(any())).thenReturn(saved); + KeyCipher keyCipher = mock(KeyCipher.class); + when(keyCipher.decrypt(ciphertext)).thenReturn("stored-secret"); + HttpClient httpClient = successfulHttpClient(); + ModelService service = service(modelMapper, keyCipher, httpClient); + + ModelService.ConnectionResult result = service.test(new ModelService.ConnectionTestInput( + modelId, + "https://saved.example.test/v1/", + "updated-model-id", + "")); + + assertThat(result.success()).isTrue(); + assertThat(sentRequest(httpClient).headers().firstValue("Authorization")) + .contains("Bearer stored-secret"); + } + + /** + * 创建仅替换外部依赖的模型服务,生产逻辑仍由真实 {@link ModelService} 执行。 + * + * @param modelMapper 模型配置 Mapper 桩 + * @param keyCipher 密钥组件桩 + * @param httpClient HTTP 客户端桩 + * @return 待测试模型服务 + */ + private ModelService service(ModelConfigMapper modelMapper, KeyCipher keyCipher, HttpClient httpClient) { + return new ModelService( + modelMapper, + mock(ModelAssignmentMapper.class), + mock(AgentRunMapper.class), + mock(UserService.class), + keyCipher, + new ObjectMapper(), + httpClient); + } + + /** + * 创建固定返回 HTTP 200 的客户端,避免测试访问真实模型服务。 + * + * @return HTTP 客户端桩 + * @throws Exception 配置泛型 send 方法桩时抛出 + */ + @SuppressWarnings("unchecked") + private HttpClient successfulHttpClient() throws Exception { + HttpClient client = mock(HttpClient.class); + HttpResponse response = mock(HttpResponse.class); + when(response.statusCode()).thenReturn(200); + when(client.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))).thenReturn(response); + return client; + } + + /** + * 捕获服务发送的请求,以结果状态而非内部调用顺序验证连接目标和认证头。 + * + * @param client HTTP 客户端桩 + * @return 捕获到的请求 + * @throws Exception Mockito 验证泛型 send 方法时抛出 + */ + @SuppressWarnings("unchecked") + private HttpRequest sentRequest(HttpClient client) throws Exception { + ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class); + org.mockito.Mockito.verify(client).send(captor.capture(), any(HttpResponse.BodyHandler.class)); + return captor.getValue(); + } +} diff --git a/server/src/test/java/tech/easyflow/manuagent/model/ModelServiceQueryTest.java b/server/src/test/java/tech/easyflow/manuagent/model/ModelServiceQueryTest.java index e973274..b41c50f 100644 --- a/server/src/test/java/tech/easyflow/manuagent/model/ModelServiceQueryTest.java +++ b/server/src/test/java/tech/easyflow/manuagent/model/ModelServiceQueryTest.java @@ -12,6 +12,7 @@ import java.time.OffsetDateTime; import java.util.List; import java.util.UUID; import org.junit.jupiter.api.Test; +import org.springframework.boot.ApplicationRunner; import org.mockito.ArgumentCaptor; import tech.easyflow.manuagent.auth.UserService; import tech.easyflow.manuagent.config.AppProperties; @@ -19,12 +20,21 @@ import tech.easyflow.manuagent.entity.ModelConfigEntity; import tech.easyflow.manuagent.mapper.AppUserMapper; import tech.easyflow.manuagent.mapper.ModelAssignmentMapper; import tech.easyflow.manuagent.mapper.ModelConfigMapper; +import tech.easyflow.manuagent.mapper.AgentRunMapper; /** * 验证模型管理接口使用最小字段投影,不把加密 API Key 读入普通请求内存。 */ class ModelServiceQueryTest { + /** + * 模型必须完全由管理接口和数据库维护,服务启动不得再通过 Key 文件自动灌入默认模型。 + */ + @Test + void shouldNotInitializeModelConfigurationFromApplicationFiles() { + assertThat(ApplicationRunner.class.isAssignableFrom(ModelService.class)).isFalse(); + } + /** * 模型列表只需要页面展示字段,查询 SQL 不得包含密文和密钥版本列。 */ @@ -36,10 +46,9 @@ class ModelServiceQueryTest { ModelService service = new ModelService( modelMapper, mock(ModelAssignmentMapper.class), - mock(AppUserMapper.class), + mock(AgentRunMapper.class), mock(UserService.class), mock(KeyCipher.class), - mock(AppProperties.class), new ObjectMapper()); List models = service.list(); diff --git a/server/src/test/java/tech/easyflow/manuagent/model/ModelServiceValidationTest.java b/server/src/test/java/tech/easyflow/manuagent/model/ModelServiceValidationTest.java new file mode 100644 index 0000000..1ca05b3 --- /dev/null +++ b/server/src/test/java/tech/easyflow/manuagent/model/ModelServiceValidationTest.java @@ -0,0 +1,93 @@ +package tech.easyflow.manuagent.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; +import tech.easyflow.manuagent.auth.UserService; +import tech.easyflow.manuagent.common.ApiException; +import tech.easyflow.manuagent.mapper.AgentRunMapper; +import tech.easyflow.manuagent.mapper.ModelAssignmentMapper; +import tech.easyflow.manuagent.mapper.ModelConfigMapper; + +/** + * 验证模型配置在进入数据库和外部 HTTP 客户端之前具有明确、可审计的输入边界。 + */ +class ModelServiceValidationTest { + + /** + * 验证名称、地址、模型标识和 API Key 的长度限制,防止超长请求占用内存或触发数据库截断异常。 + */ + @Test + void shouldBoundModelInputTextFields() { + Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + ModelService.ModelInput input = new ModelService.ModelInput( + "名".repeat(101), + "https://example.test/" + "a".repeat(500), + "m".repeat(256), + "k".repeat(4097), + Map.of(), + Map.of("contextWindow", 8_192)); + + // 一次构造四个越界字段,并按属性名断言,确保每个 HTTP 入参都真正受到约束。 + Set invalidProperties = validator.validate(input).stream() + .map(violation -> violation.getPropertyPath().toString()) + .collect(Collectors.toSet()); + + assertThat(invalidProperties).containsExactlyInAnyOrder("name", "baseUrl", "modelId", "apiKey"); + } + + /** + * 验证模型地址拒绝非 HTTP(S) 协议、缺失主机以及可能改变请求语义的用户信息、查询和片段。 + */ + @Test + void shouldRejectUnsafeOrAmbiguousBaseUrls() { + ModelService service = service(); + + for (String baseUrl : Set.of( + "file:///etc/passwd", + "https:///v1", + "https://user:password@example.test/v1", + "https://example.test/v1?tenant=other", + "https://example.test/v1#fragment")) { + ModelService.ModelInput input = new ModelService.ModelInput( + "测试模型", + baseUrl, + "model-v1", + "secret", + Map.of(), + Map.of("contextWindow", 8_192)); + + assertThatThrownBy(() -> service.save(null, input, () -> "admin")) + .as("地址应被拒绝:%s", baseUrl) + .isInstanceOfSatisfying(ApiException.class, exception -> + assertThat(exception.code()).isEqualTo("MODEL_BASE_URL_INVALID")); + } + } + + /** + * 创建只执行输入校验所需路径的模型服务;无效 URL 应在任何持久化写入之前被拒绝。 + * + * @return 配置了当前用户的模型服务 + */ + private ModelService service() { + UserService userService = mock(UserService.class); + when(userService.requireUserId("admin")).thenReturn(UUID.randomUUID()); + return new ModelService( + mock(ModelConfigMapper.class), + mock(ModelAssignmentMapper.class), + mock(AgentRunMapper.class), + userService, + mock(KeyCipher.class), + new ObjectMapper()); + } +} diff --git a/server/src/test/java/tech/easyflow/manuagent/project/ProjectFileServiceTest.java b/server/src/test/java/tech/easyflow/manuagent/project/ProjectFileServiceTest.java index ea2960b..951abec 100644 --- a/server/src/test/java/tech/easyflow/manuagent/project/ProjectFileServiceTest.java +++ b/server/src/test/java/tech/easyflow/manuagent/project/ProjectFileServiceTest.java @@ -38,9 +38,9 @@ class ProjectFileServiceTest { @Test void shouldDeleteProjectWorkspace() throws Exception { AppProperties properties = new AppProperties( - temporaryDirectory, Path.of("deepseek"), Path.of("dashscope"), - "test-master", "admin", "admin", "https://example.test", "model", - 131_072, "runtime:test", "bridge", Duration.ofMinutes(1)); + temporaryDirectory, Path.of("dashscope"), + "test-master", "admin", "admin", + "runtime:test", "bridge", Duration.ofMinutes(1)); ProjectFileService service = new ProjectFileService( mock(ProjectFileMapper.class), mock(UserService.class), mock(ProjectService.class), properties); UUID projectId = UUID.randomUUID(); @@ -121,8 +121,8 @@ class ProjectFileServiceTest { */ private AppProperties properties() { return new AppProperties( - temporaryDirectory, Path.of("deepseek"), Path.of("dashscope"), - "test-master", "admin", "admin", "https://example.test", "model", - 131_072, "runtime:test", "bridge", Duration.ofMinutes(1)); + temporaryDirectory, Path.of("dashscope"), + "test-master", "admin", "admin", + "runtime:test", "bridge", Duration.ofMinutes(1)); } } diff --git a/web-ui/index.html b/web-ui/index.html index 92a01de..5488852 100644 --- a/web-ui/index.html +++ b/web-ui/index.html @@ -4,6 +4,7 @@ + 智造申报 Agent diff --git a/web-ui/public/favicon.svg b/web-ui/public/favicon.svg new file mode 100644 index 0000000..d558a7a --- /dev/null +++ b/web-ui/public/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/web-ui/src/pages/ModelsPage.test.ts b/web-ui/src/pages/ModelsPage.test.ts new file mode 100644 index 0000000..56b93b2 --- /dev/null +++ b/web-ui/src/pages/ModelsPage.test.ts @@ -0,0 +1,138 @@ +// @vitest-environment jsdom + +import { flushPromises, shallowMount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ModelsPage from './ModelsPage.vue' + +const apiMock = vi.fn() + +vi.mock('element-plus', () => ({ + ElMessage: { success: vi.fn(), error: vi.fn() }, + ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined) } +})) + +vi.mock('../api', () => ({ + api: (...args: unknown[]) => apiMock(...args) +})) + +describe('ModelsPage', () => { + beforeEach(() => { + // 避免把 mockReset() 返回的 mock 函数误交给 Vitest 作为测试清理回调。 + apiMock.mockReset() + }) + + /** 统一注册页面使用的 Element Plus 浅层桩,测试输出不应包含组件解析警告。 */ + function mountModelsPage() { + return shallowMount(ModelsPage, { + global: { + renderStubDefaultSlot: true, + stubs: { + 'el-button': true, + 'el-input': true, + 'el-input-number': true + } + } + }) + } + + it('数据库为空时提供新增模型入口', async () => { + apiMock.mockResolvedValueOnce([]) + + const wrapper = mountModelsPage() + await flushPromises() + + expect(wrapper.text()).toContain('新增模型') + expect(wrapper.text()).toContain('暂无模型') + }) + + it('展示多个 OpenAI 兼容模型及其状态', async () => { + apiMock.mockResolvedValueOnce([ + { + id: 'model-a', name: '编排模型', provider: 'OPENAI_COMPATIBLE', + baseUrl: 'https://a.example.test', modelId: 'model-a', apiKeyHint: '••••1234', + configJson: '{}', capabilitiesJson: '{"contextWindow":65536}', enabled: true, defaultModel: true + }, + { + id: 'model-b', name: '备用模型', provider: 'OPENAI_COMPATIBLE', + baseUrl: 'https://b.example.test', modelId: 'model-b', apiKeyHint: '••••5678', + configJson: '{}', capabilitiesJson: '{"contextWindow":131072}', enabled: false, defaultModel: false + } + ]) + + const wrapper = mountModelsPage() + await flushPromises() + + expect(wrapper.text()).toContain('编排模型') + expect(wrapper.text()).toContain('备用模型') + expect(wrapper.text()).toContain('OpenAI 兼容') + expect(wrapper.text()).toContain('已停用') + expect(wrapper.text()).not.toContain('DeepSeek') + }) + + it('测试连接时提交当前表单草稿而不是数据库旧配置', async () => { + apiMock + .mockResolvedValueOnce([{ + id: 'model-a', name: '编排模型', provider: 'OPENAI_COMPATIBLE', + baseUrl: 'https://old.example.test/v1', modelId: 'old-model', apiKeyHint: '••••1234', + configJson: '{}', capabilitiesJson: '{"contextWindow":65536}', enabled: true, defaultModel: false + }]) + .mockResolvedValueOnce({ success: true, latencyMs: 12, message: '连接正常' }) + + const wrapper = mountModelsPage() + await flushPromises() + const inputs = wrapper.findAllComponents({ name: 'ElInput' }) + + // 依次修改 API 地址、API Key 和模型 ID,确保请求使用尚未保存的表单值。 + inputs[2].vm.$emit('update:modelValue', 'https://draft.example.test/v1') + inputs[3].vm.$emit('update:modelValue', 'draft-secret') + inputs[4].vm.$emit('update:modelValue', 'draft-model') + await wrapper.vm.$nextTick() + const testButton = wrapper.findAllComponents({ name: 'ElButton' }) + .find(button => button.text() === '测试连接') + await testButton!.trigger('click') + await flushPromises() + + expect(apiMock).toHaveBeenNthCalledWith(2, '/api/models/test', { + method: 'POST', + body: JSON.stringify({ + id: 'model-a', + baseUrl: 'https://draft.example.test/v1', + modelId: 'draft-model', + apiKey: 'draft-secret' + }) + }) + expect(wrapper.text()).toContain('连接正常') + + // 成功标记只对应发起请求时的草稿,继续编辑后必须立即失效。 + inputs[4].vm.$emit('update:modelValue', 'changed-after-test') + await wrapper.vm.$nextTick() + expect(wrapper.text()).not.toContain('连接正常') + }) + + it('停用模型时调用 PATCH 启用状态接口', async () => { + apiMock + .mockResolvedValueOnce([{ + id: 'model-a', name: '备用模型', provider: 'OPENAI_COMPATIBLE', + baseUrl: 'https://a.example.test/v1', modelId: 'model-a', apiKeyHint: '••••1234', + configJson: '{}', capabilitiesJson: '{"contextWindow":65536}', enabled: true, defaultModel: false + }]) + .mockResolvedValueOnce({ + id: 'model-a', name: '备用模型', provider: 'OPENAI_COMPATIBLE', + baseUrl: 'https://a.example.test/v1', modelId: 'model-a', apiKeyHint: '••••1234', + configJson: '{}', capabilitiesJson: '{"contextWindow":65536}', enabled: false, defaultModel: false + }) + .mockResolvedValueOnce([]) + + const wrapper = mountModelsPage() + await flushPromises() + const disableButton = wrapper.findAllComponents({ name: 'ElButton' }) + .find(button => button.text() === '停用') + await disableButton!.trigger('click') + await flushPromises() + + expect(apiMock).toHaveBeenNthCalledWith(2, '/api/models/model-a/enabled', { + method: 'PATCH', + body: JSON.stringify({ enabled: false }) + }) + }) +}) diff --git a/web-ui/src/pages/ModelsPage.vue b/web-ui/src/pages/ModelsPage.vue index 6dcc41b..d83bd25 100644 --- a/web-ui/src/pages/ModelsPage.vue +++ b/web-ui/src/pages/ModelsPage.vue @@ -1,7 +1,7 @@