feat: 完善多模型配置与运行切换

将模型连接统一持久化管理,并支持 Agent 运行中切换模型以及中断后选择模型继续。补充配置校验、事务与前后端交互测试。
This commit is contained in:
Zhu Junhao
2026-09-03 10:46:25 +08:00
parent e968a8ddc1
commit 988c0fe555
36 changed files with 1715 additions and 190 deletions

View File

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

View File

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

View File

@@ -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())

View File

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

View File

@@ -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未指定模型时使用当前启用的默认模型。
*
* <p>模型在 Run 创建事务内完成解析并写入 {@code model_config_id}。后续默认模型切换
* 只影响新 Run不会悄悄改变已经开始的任务。</p>
*
* @param projectId 项目 ID
* @param triggerType 触发类型
* @param parentRunId 父 Run ID
* @param requestedModelId 用户显式选择的模型;为空时使用默认模型
* @return 新 Run
*/
@SuppressWarnings("unchecked") // 查询只投影模型 IDLambdaGetter 可变参数不会引入运行期类型风险。
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,

View File

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

View File

@@ -11,4 +11,7 @@ public interface ModelAssignmentMapper extends BaseMapper<ModelAssignmentEntity>
/** 按角色插入或更新模型分配。 */
int upsert(@Param("assignment") ModelAssignmentEntity assignment);
/** 删除指定模型遗留的角色分配。 */
int deleteByModelConfigId(@Param("modelConfigId") java.util.UUID modelConfigId);
}

View File

@@ -42,4 +42,10 @@ public interface ModelConfigMapper extends BaseMapper<ModelConfigEntity> {
* @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);
}

View File

@@ -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 {
}
/**
* 测试模型连接
* 使用管理页面当前草稿测试模型连接,但不保存草稿内容
*
* <p>已有模型且 API 地址未变化时允许不提交 API Key此时服务层只读取该模型已加密保存的密钥
* 新模型或修改 API 地址后的草稿必须提交 API Key避免把隐藏密钥转发到其他主机。</p>
*
* @param input 当前表单中的连接测试输入
* @return 测试结果
*/
@PostMapping("/test")
public ModelService.ConnectionResult testDraft(
@Valid @RequestBody ModelService.ConnectionTestInput input) {
return modelService.test(input);
}
/**
* 使用数据库中已保存的完整配置测试模型连接。
*
* <p>保留该接口以兼容已有调用方;管理页面使用 {@code POST /api/models/test}
* 测试未保存草稿。</p>
*
* @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) {
}
}

View File

@@ -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<T>...) 使用泛型可变参数,调用本身类型安全。
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);
}
}
/**
* 更新模型启用状态。
*
* <p>默认模型承担新 Run 的选择职责,不能直接停用;正在执行的 Run 也不能失去模型,
* 用户应先在项目页停止并用替代模型恢复,再停用旧模型。</p>
*
* @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 使用的非默认模型。
*
* <p>历史 Run 的模型引用属于审计事实,任何已有引用都会阻止删除;常规下线应使用停用。</p>
*
* @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));
}
/**
* 使用当前表单草稿测试连接,不将任何草稿字段写入数据库。
*
* <p>编辑已有模型时,空 API Key 表示复用数据库中的加密密钥;如果表单提供了新 Key
* 则仅在本次请求内使用它。新增模型没有数据库身份,必须显式提供 API Key。</p>
*
* @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仅供模型调用链使用。
*
* <p>该方法不会读取全局默认模型,因此管理员切换默认模型只会影响之后创建的 Run
* 如果同一配置被编辑,下一次 Agent 连接会自然读取更新后的地址、模型 ID和密钥。</p>
*
* @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 留空”的草稿测试临时使用。
*
* <p>查询只投影主键和密文字段,不要求模型处于启用状态,因为管理员需要先验证配置,
* 再决定是否重新启用。密钥只在服务端内存中短暂解密,永不写入响应或日志。</p>
*
* @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 兼容服务根地址。
*
* <p>模型地址最终会由服务端 HTTP 客户端主动访问,因此只允许具有明确主机的 HTTP(S) URI。
* 用户信息可能泄露凭据,查询参数和片段会在拼接 {@code /chat/completions} 时产生歧义,均直接拒绝。
* 动态模型供应商无法使用固定域名白名单,这里至少保证 URI 结构和请求语义稳定。</p>
*
* @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<String, Object> config,
Map<String, Object> 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) {
}
/**
* 对外模型视图。
*

View File

@@ -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

View File

@@ -15,4 +15,10 @@
assigned_by = EXCLUDED.assigned_by,
updated_at = CURRENT_TIMESTAMP
</insert>
<delete id="deleteByModelConfigId">
DELETE FROM app.model_assignment
WHERE model_config_id = #{modelConfigId,
typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
</delete>
</mapper>

View File

@@ -60,4 +60,16 @@
updated_at = CURRENT_TIMESTAMP
WHERE id = #{id, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
</update>
<update id="setEnabled">
UPDATE app.model_config
SET enabled = #{enabled},
updated_at = CURRENT_TIMESTAMP
WHERE id = #{id, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
</update>
<delete id="deleteModel">
DELETE FROM app.model_config
WHERE id = #{id, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
</delete>
</mapper>

View File

@@ -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<String, Object> 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));

View File

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

View File

@@ -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());
}
}

View File

@@ -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 正文。
*/

View File

@@ -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();
}
}
/**
* 网络故障和服务端错误允许重连,参数错误保持原始失败。
*/

View File

@@ -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<AgentRunEntity> entityCaptor = ArgumentCaptor.forClass(AgentRunEntity.class);
verify(runMapper).insertSelective(entityCaptor.capture());
assertThat(entityCaptor.getValue().getModelConfigId()).isEqualTo(modelId);
assertThat(run.modelConfigId()).isEqualTo(modelId);
}
/**

View File

@@ -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<PropertySource<?>> 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 {
}
}

View File

@@ -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 接口契约。
*
* <p>这些测试刻意放在控制器边界,防止前端请求方法或路径与后端映射再次发生漂移。</p>
*/
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);
}
}

View File

@@ -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 容器必须能够在生产构造器和包内测试构造器之间选择生产构造器。
*
* <p>该测试防止新增辅助构造器后,应用启动阶段退化为查找不存在的无参构造器。</p>
*/
@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<String> 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<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
org.mockito.Mockito.verify(client).send(captor.capture(), any(HttpResponse.BodyHandler.class));
return captor.getValue();
}
}

View File

@@ -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<ModelService.ModelView> models = service.list();

View File

@@ -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<String> 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());
}
}

View File

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