chore: 项目环境升级为 JDK25,Spring 4.1,项目重构为多模块
This commit is contained in:
107
server/manuagent-web/pom.xml
Normal file
107
server/manuagent-web/pom.xml
Normal file
@@ -0,0 +1,107 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>manuagent-parent</artifactId>
|
||||
<version>0.1.0</version>
|
||||
</parent>
|
||||
<artifactId>manuagent-web</artifactId>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>manuagent-common</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>manuagent-agent</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>manuagent-admin</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-jdbc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-flyway</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.mybatis-flex</groupId>
|
||||
<artifactId>mybatis-flex-spring-boot4-starter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>tools.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-database-postgresql</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webmvc-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<build>
|
||||
<finalName>manuagent-web</finalName>
|
||||
<resources>
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>false</filtering>
|
||||
</resource>
|
||||
<resource>
|
||||
<directory>${project.basedir}/../src/main/resources</directory>
|
||||
<filtering>false</filtering>
|
||||
<includes><include>db/migration/**</include></includes>
|
||||
</resource>
|
||||
</resources>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,24 @@
|
||||
package tech.easyflow.manuagent;
|
||||
|
||||
import tech.easyflow.manuagent.agent.config.AgentProperties;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
|
||||
/**
|
||||
* 智造申报 Agent 服务入口。
|
||||
*/
|
||||
@SpringBootApplication
|
||||
@EnableConfigurationProperties({AgentProperties.class, tech.easyflow.manuagent.admin.config.AdminProperties.class})
|
||||
public class ManuAgentApplication {
|
||||
|
||||
/**
|
||||
* 启动 Spring Boot 应用。
|
||||
*
|
||||
* @param args 命令行参数
|
||||
*/
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(ManuAgentApplication.class, args);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package tech.easyflow.manuagent.web.agent;
|
||||
|
||||
import tech.easyflow.manuagent.agent.runtime.AgentRunService;
|
||||
import tech.easyflow.manuagent.agent.runtime.AgentEventService;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.security.Principal;
|
||||
import tech.easyflow.manuagent.admin.auth.UserService;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
/**
|
||||
* 提供可恢复的 Agent Run 与 AG-UI 事件流接口。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/projects/{projectId}")
|
||||
public class AgentController {
|
||||
|
||||
private final UserService userService;
|
||||
|
||||
private final AgentRunService runService;
|
||||
private final AgentEventService eventService;
|
||||
|
||||
/**
|
||||
* 创建 Agent 控制器。
|
||||
*
|
||||
* @param runService Run 服务
|
||||
* @param eventService 事件服务
|
||||
*/
|
||||
public AgentController(UserService userService, AgentRunService runService, AgentEventService eventService) {
|
||||
this.userService = userService;
|
||||
this.runService = runService;
|
||||
this.eventService = eventService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动材料检验与规划 Run。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param principal 当前用户
|
||||
* @return Run
|
||||
*/
|
||||
@PostMapping("/runs/material-check")
|
||||
public AgentRunService.RunView startMaterialCheck(@PathVariable UUID projectId, Principal principal) {
|
||||
return runService.startMaterialCheck(projectId, userService.requireUserId(principal.getName()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认材料检验结果并进入规划生成。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param response 材料缺口处理结果
|
||||
* @param principal 当前用户
|
||||
* @return 规划 Run
|
||||
*/
|
||||
@PostMapping("/material/confirm")
|
||||
public AgentRunService.RunView confirmMaterials(
|
||||
@PathVariable UUID projectId,
|
||||
@RequestBody JsonNode response,
|
||||
Principal principal) {
|
||||
return runService.confirmMaterials(projectId, response, userService.requireUserId(principal.getName()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动确认后的自动编写 Run。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @return Run
|
||||
*/
|
||||
@PostMapping("/runs/writing")
|
||||
public AgentRunService.RunView startWriting(@PathVariable UUID projectId) {
|
||||
return runService.startWriting(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 立即停止当前 Agent Run。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param principal 当前用户
|
||||
* @return 已中断 Run
|
||||
*/
|
||||
@PostMapping("/runs/stop")
|
||||
public AgentRunService.RunView stop(@PathVariable UUID projectId, Principal principal) {
|
||||
return runService.stop(projectId, userService.requireUserId(principal.getName()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 从已中断位置继续 Agent Run。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param principal 当前用户
|
||||
* @return 新恢复 Run
|
||||
*/
|
||||
@PostMapping("/runs/resume")
|
||||
public AgentRunService.RunView resume(
|
||||
@PathVariable UUID projectId,
|
||||
@Valid @RequestBody(required = false) ResumeInput input,
|
||||
Principal principal) {
|
||||
return runService.resume(projectId, input == null ? null : input.modelConfigId(), userService.requireUserId(principal.getName()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将运行中的任务受控切换到替代模型。
|
||||
*
|
||||
* @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(), userService.requireUserId(principal.getName()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回最近 Run。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @return 最近 Run;不存在时返回 204
|
||||
*/
|
||||
@GetMapping("/runs/latest")
|
||||
public ResponseEntity<AgentRunService.RunView> latest(@PathVariable UUID projectId) {
|
||||
AgentRunService.RunView run = runService.latest(projectId);
|
||||
return run == null ? ResponseEntity.noContent().build() : ResponseEntity.ok(run);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过游标读取持久化事件。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param after 最后已接收事件序号
|
||||
* @return 增量事件
|
||||
*/
|
||||
@GetMapping("/events")
|
||||
public List<AgentEventService.EventView> events(
|
||||
@PathVariable UUID projectId,
|
||||
@RequestParam(defaultValue = "0") long after) {
|
||||
return eventService.listAfter(projectId, after, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 以 NDJSON 持续输出新增事件;页面刷新可携带游标恢复。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param after 最后已接收事件序号
|
||||
* @return 持续增量事件流
|
||||
*/
|
||||
@GetMapping(path = "/events/stream", produces = MediaType.APPLICATION_NDJSON_VALUE)
|
||||
public Flux<AgentEventService.EventView> stream(
|
||||
@PathVariable UUID projectId,
|
||||
@RequestParam(defaultValue = "0") long after) {
|
||||
return eventService.streamAfter(projectId, after);
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复或切换任务时指定的模型。
|
||||
*
|
||||
* @param modelConfigId 目标模型配置 ID
|
||||
*/
|
||||
public record ResumeInput(@NotNull UUID modelConfigId) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package tech.easyflow.manuagent.web.artifact;
|
||||
|
||||
import tech.easyflow.manuagent.agent.artifact.ArtifactService;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 提供项目产物列表与下载接口。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
public class ArtifactController {
|
||||
|
||||
private final ArtifactService artifactService;
|
||||
|
||||
/**
|
||||
* 创建产物控制器。
|
||||
*
|
||||
* @param artifactService 产物服务
|
||||
*/
|
||||
public ArtifactController(ArtifactService artifactService) {
|
||||
this.artifactService = artifactService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出项目产物。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @return 产物列表
|
||||
*/
|
||||
@GetMapping("/projects/{projectId}/artifacts")
|
||||
public List<ArtifactService.ArtifactView> list(@PathVariable UUID projectId) {
|
||||
return artifactService.list(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载产物。
|
||||
*
|
||||
* @param artifactId 产物 ID
|
||||
* @return 文件响应
|
||||
*/
|
||||
@GetMapping("/artifacts/{artifactId}/download")
|
||||
public ResponseEntity<org.springframework.core.io.Resource> download(@PathVariable UUID artifactId) {
|
||||
ArtifactService.Download download = artifactService.download(artifactId);
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.parseMediaType(download.mimeType()))
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename*=UTF-8''" + URLEncoder.encode(download.name(), StandardCharsets.UTF_8))
|
||||
.body(download.resource());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package tech.easyflow.manuagent.web.auth;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import java.security.Principal;
|
||||
import java.util.Map;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
|
||||
import org.springframework.security.web.csrf.CsrfToken;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 管理登录状态和 CSRF Token。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
public class AuthController {
|
||||
|
||||
private final AuthenticationManager authenticationManager;
|
||||
private final HttpSessionSecurityContextRepository contextRepository = new HttpSessionSecurityContextRepository();
|
||||
|
||||
/**
|
||||
* 创建认证控制器。
|
||||
*
|
||||
* @param authenticationManager 认证管理器
|
||||
*/
|
||||
public AuthController(AuthenticationManager authenticationManager) {
|
||||
this.authenticationManager = authenticationManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回并初始化 CSRF Token。
|
||||
*
|
||||
* @param token 当前请求 Token
|
||||
* @return Token 数据
|
||||
*/
|
||||
@GetMapping("/csrf")
|
||||
public Map<String, String> csrf(CsrfToken token) {
|
||||
return Map.of("token", token.getToken(), "headerName", token.getHeaderName());
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用用户名和密码创建 Session。
|
||||
*
|
||||
* @param request 登录请求
|
||||
* @param servletRequest HTTP 请求
|
||||
* @param servletResponse HTTP 响应
|
||||
* @return 当前用户摘要
|
||||
*/
|
||||
@PostMapping("/login")
|
||||
public MeResponse login(
|
||||
@Valid @RequestBody LoginRequest request,
|
||||
HttpServletRequest servletRequest,
|
||||
HttpServletResponse servletResponse) {
|
||||
Authentication authentication = authenticationManager.authenticate(
|
||||
UsernamePasswordAuthenticationToken.unauthenticated(request.username(), request.password()));
|
||||
SecurityContext context = SecurityContextHolder.createEmptyContext();
|
||||
context.setAuthentication(authentication);
|
||||
SecurityContextHolder.setContext(context);
|
||||
contextRepository.saveContext(context, servletRequest, servletResponse);
|
||||
return new MeResponse(authentication.getName(), "管理员");
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回当前登录账户。
|
||||
*
|
||||
* @param principal 当前身份
|
||||
* @return 当前用户摘要
|
||||
*/
|
||||
@GetMapping("/me")
|
||||
public MeResponse me(Principal principal) {
|
||||
return new MeResponse(principal.getName(), "管理员");
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录请求。
|
||||
*
|
||||
* @param username 登录名
|
||||
* @param password 密码
|
||||
*/
|
||||
public record LoginRequest(@NotBlank String username, @NotBlank String password) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前用户摘要。
|
||||
*
|
||||
* @param username 登录名
|
||||
* @param displayName 显示名称
|
||||
*/
|
||||
public record MeResponse(String username, String displayName) {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package tech.easyflow.manuagent.web.common;
|
||||
|
||||
import tech.easyflow.manuagent.common.ApiException;
|
||||
import tech.easyflow.manuagent.common.ApiError;
|
||||
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
import java.util.UUID;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
|
||||
|
||||
/**
|
||||
* 将业务错误和未预期异常转换为明确的 HTTP 错误响应。
|
||||
*/
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
|
||||
/**
|
||||
* 处理已知业务异常。
|
||||
*
|
||||
* @param exception 业务异常
|
||||
* @return 对应状态码的错误响应
|
||||
*/
|
||||
@ExceptionHandler(ApiException.class)
|
||||
public ResponseEntity<ApiError> handleApiException(ApiException exception) {
|
||||
return ResponseEntity.status(exception.status())
|
||||
.body(error(exception.code(), exception.getMessage()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理输入校验失败。
|
||||
*
|
||||
* @param exception 参数校验异常
|
||||
* @return 400 错误响应
|
||||
*/
|
||||
@ExceptionHandler({MethodArgumentNotValidException.class, ConstraintViolationException.class})
|
||||
public ResponseEntity<ApiError> handleValidation(Exception exception) {
|
||||
String message = exception instanceof MethodArgumentNotValidException invalid
|
||||
? invalid.getBindingResult().getFieldErrors().stream()
|
||||
.findFirst()
|
||||
.map(error -> error.getField() + ":" + error.getDefaultMessage())
|
||||
.orElse("请求参数无效")
|
||||
: exception.getMessage();
|
||||
return ResponseEntity.badRequest().body(error("VALIDATION_FAILED", message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将错误凭据统一映射为 401。
|
||||
*
|
||||
* @param exception 认证异常
|
||||
* @return 401 错误响应
|
||||
*/
|
||||
@ExceptionHandler(AuthenticationException.class)
|
||||
public ResponseEntity<ApiError> handleAuthentication(AuthenticationException exception) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(error("AUTHENTICATION_FAILED", "用户名或密码错误"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将权限或 CSRF 拒绝统一映射为 403。
|
||||
*
|
||||
* @param exception 权限异常
|
||||
* @return 403 错误响应
|
||||
*/
|
||||
@ExceptionHandler(AccessDeniedException.class)
|
||||
public ResponseEntity<ApiError> handleAccessDenied(AccessDeniedException exception) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(error("ACCESS_DENIED", "当前请求无权执行"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理无法解析的请求体。
|
||||
*
|
||||
* @param exception JSON 读取异常
|
||||
* @return 400 错误响应
|
||||
*/
|
||||
@ExceptionHandler(HttpMessageNotReadableException.class)
|
||||
public ResponseEntity<ApiError> handleUnreadable(HttpMessageNotReadableException exception) {
|
||||
return ResponseEntity.badRequest().body(error("REQUEST_BODY_INVALID", "请求内容格式无效"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理数据库唯一性等并发冲突。
|
||||
*
|
||||
* @param exception 数据约束异常
|
||||
* @return 409 错误响应
|
||||
*/
|
||||
@ExceptionHandler(DataIntegrityViolationException.class)
|
||||
public ResponseEntity<ApiError> handleConflict(DataIntegrityViolationException exception) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(error("DATA_CONFLICT", "数据已发生变化,请刷新后重试"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 收敛流式响应中客户端主动断开产生的预期异常。
|
||||
*
|
||||
* @param exception 已提交响应无法继续写入的异常
|
||||
*/
|
||||
@ExceptionHandler(AsyncRequestNotUsableException.class)
|
||||
public void handleClientDisconnect(AsyncRequestNotUsableException exception) {
|
||||
log.debug("客户端已断开流式响应:{}", exception.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理未预期异常并保留完整堆栈。
|
||||
*
|
||||
* @param exception 未预期异常
|
||||
* @return 500 错误响应
|
||||
*/
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiError> handleUnexpected(Exception exception) {
|
||||
String traceId = traceId();
|
||||
log.error("未预期异常,traceId={}", traceId, exception);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(new ApiError("INTERNAL_ERROR", "服务处理失败,请稍后重试", traceId, java.time.Instant.now()));
|
||||
}
|
||||
|
||||
private ApiError error(String code, String message) {
|
||||
return new ApiError(code, message, traceId(), java.time.Instant.now());
|
||||
}
|
||||
|
||||
private String traceId() {
|
||||
String value = MDC.get("traceId");
|
||||
return value == null || value.isBlank() ? UUID.randomUUID().toString() : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package tech.easyflow.manuagent.web.common;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.util.UUID;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
/**
|
||||
* 为每个 HTTP 请求建立可回传、可检索的追踪编号。
|
||||
*/
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
public class TraceIdFilter extends OncePerRequestFilter {
|
||||
|
||||
/**
|
||||
* 在请求处理期间写入 MDC,并在响应中返回追踪编号。
|
||||
*
|
||||
* @param request HTTP 请求
|
||||
* @param response HTTP 响应
|
||||
* @param filterChain 后续过滤器链
|
||||
* @throws ServletException Servlet 处理失败
|
||||
* @throws IOException 网络读写失败
|
||||
*/
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
String traceId = UUID.randomUUID().toString();
|
||||
MDC.put("traceId", traceId);
|
||||
response.setHeader("X-Trace-Id", traceId);
|
||||
try {
|
||||
filterChain.doFilter(request, response);
|
||||
} finally {
|
||||
MDC.remove("traceId");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package tech.easyflow.manuagent.web.config;
|
||||
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.task.AsyncTaskExecutor;
|
||||
import org.springframework.core.task.support.TaskExecutorAdapter;
|
||||
|
||||
/**
|
||||
* 进程内基础设施配置。
|
||||
*/
|
||||
@Configuration
|
||||
public class InfraConfig {
|
||||
|
||||
/**
|
||||
* 创建适合阻塞模型及文件调用的虚拟线程执行器。
|
||||
*
|
||||
* @return 应用共享执行器
|
||||
*/
|
||||
@Bean(destroyMethod = "close")
|
||||
public ExecutorService applicationExecutor() {
|
||||
return Executors.newVirtualThreadPerTaskExecutor();
|
||||
}
|
||||
|
||||
/**
|
||||
* 复用虚拟线程处理 Spring MVC 的异步响应。
|
||||
*
|
||||
* @param applicationExecutor 应用共享执行器
|
||||
* @return MVC 异步执行器
|
||||
*/
|
||||
@Bean
|
||||
public AsyncTaskExecutor applicationTaskExecutor(ExecutorService applicationExecutor) {
|
||||
return new TaskExecutorAdapter(applicationExecutor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package tech.easyflow.manuagent.web.config;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* 配置 MyBatis-Flex Mapper 扫描。
|
||||
*
|
||||
* <p>业务 Mapper 随 Agent/Admin 领域装配。数据库连接、连接池和
|
||||
* Spring 事务管理器继续复用 Spring Boot 已配置的数据源,使 MyBatis-Flex 的 Mapper 调用、
|
||||
* 事件写入与应用服务的 {@code @Transactional} 边界共享同一物理事务。</p>
|
||||
*/
|
||||
@Configuration
|
||||
@MapperScan(basePackages = {"tech.easyflow.manuagent.agent", "tech.easyflow.manuagent.admin"},
|
||||
annotationClass = org.apache.ibatis.annotations.Mapper.class)
|
||||
public class MyBatisFlexConfiguration {
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package tech.easyflow.manuagent.web.config;
|
||||
|
||||
import tech.easyflow.manuagent.admin.auth.UserService;
|
||||
import tech.easyflow.manuagent.common.ApiError;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
|
||||
|
||||
/**
|
||||
* 单管理员 Cookie Session 安全配置。
|
||||
*/
|
||||
@Configuration
|
||||
public class SecurityConfig {
|
||||
|
||||
/**
|
||||
* 创建密码编码器。
|
||||
*
|
||||
* @return BCrypt 编码器
|
||||
*/
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建认证管理器。
|
||||
*
|
||||
* @param configuration Spring Security 认证配置
|
||||
* @return 认证管理器
|
||||
* @throws Exception 配置解析失败时抛出
|
||||
*/
|
||||
@Bean
|
||||
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
|
||||
return configuration.getAuthenticationManager();
|
||||
}
|
||||
|
||||
/**
|
||||
* 定义接口授权和 CSRF 规则。
|
||||
*
|
||||
* @param http HTTP 安全构建器
|
||||
* @param userService 用户加载服务
|
||||
* @param objectMapper JSON 映射器
|
||||
* @return 安全过滤链
|
||||
* @throws Exception 安全规则构建失败时抛出
|
||||
*/
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(
|
||||
HttpSecurity http,
|
||||
UserService userService,
|
||||
ObjectMapper objectMapper) throws Exception {
|
||||
CookieCsrfTokenRepository csrf = CookieCsrfTokenRepository.withHttpOnlyFalse();
|
||||
csrf.setCookiePath("/");
|
||||
return http
|
||||
.userDetailsService(userService)
|
||||
.csrf(configurer -> configurer
|
||||
.csrfTokenRepository(csrf)
|
||||
.ignoringRequestMatchers("/api/auth/login"))
|
||||
.authorizeHttpRequests(registry -> registry
|
||||
.requestMatchers("/api/auth/login", "/api/auth/csrf", "/", "/index.html", "/assets/**")
|
||||
.permitAll()
|
||||
.anyRequest().authenticated())
|
||||
.exceptionHandling(errors -> errors
|
||||
.authenticationEntryPoint((request, response, exception) -> writeError(
|
||||
response, objectMapper, 401, "AUTHENTICATION_REQUIRED", "请先登录"))
|
||||
.accessDeniedHandler((request, response, exception) -> writeError(
|
||||
response, objectMapper, 403, "ACCESS_DENIED", "当前请求无权执行")))
|
||||
.requestCache(cache -> cache.disable())
|
||||
.formLogin(form -> form.disable())
|
||||
.httpBasic(basic -> basic.disable())
|
||||
.logout(logout -> logout.logoutUrl("/api/auth/logout").logoutSuccessHandler((request, response, authentication) -> response.setStatus(204)))
|
||||
.build();
|
||||
}
|
||||
|
||||
private void writeError(
|
||||
jakarta.servlet.http.HttpServletResponse response,
|
||||
ObjectMapper objectMapper,
|
||||
int status,
|
||||
String code,
|
||||
String message) throws java.io.IOException {
|
||||
response.setStatus(status);
|
||||
response.setCharacterEncoding(java.nio.charset.StandardCharsets.UTF_8.name());
|
||||
response.setContentType(org.springframework.http.MediaType.APPLICATION_JSON_VALUE);
|
||||
objectMapper.writeValue(
|
||||
response.getOutputStream(),
|
||||
new ApiError(code, message, UUID.randomUUID().toString(), Instant.now()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package tech.easyflow.manuagent.web.config;
|
||||
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import tech.easyflow.manuagent.admin.auth.UserService;
|
||||
import tech.easyflow.manuagent.agent.runtime.RunRecoveryService;
|
||||
|
||||
/** Flyway 完成后由唯一应用入口执行恢复与账号初始化,保持原先顺序。 */
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
public class StartupConfiguration {
|
||||
@Bean
|
||||
ApplicationRunner initializeApplication(RunRecoveryService recovery, UserService users) {
|
||||
return args -> {
|
||||
recovery.recoverInterruptedRuns();
|
||||
users.initializeAdministrator();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package tech.easyflow.manuagent.web.model;
|
||||
|
||||
import tech.easyflow.manuagent.agent.model.ModelService;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.security.Principal;
|
||||
import tech.easyflow.manuagent.admin.auth.UserService;
|
||||
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;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 提供模型配置与连接测试接口。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/models")
|
||||
public class ModelController {
|
||||
|
||||
private final UserService userService;
|
||||
|
||||
private final ModelService modelService;
|
||||
|
||||
/**
|
||||
* 创建模型控制器。
|
||||
*
|
||||
* @param modelService 模型服务
|
||||
*/
|
||||
public ModelController(UserService userService, ModelService modelService) {
|
||||
this.userService = userService;
|
||||
this.modelService = modelService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出模型。
|
||||
*
|
||||
* @return 模型列表
|
||||
*/
|
||||
@GetMapping
|
||||
public List<ModelService.ModelView> list() {
|
||||
return modelService.list();
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增模型。
|
||||
*
|
||||
* @param input 模型输入
|
||||
* @param principal 当前用户
|
||||
* @return 新模型
|
||||
*/
|
||||
@PostMapping
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
public ModelService.ModelView create(@Valid @RequestBody ModelService.ModelInput input, Principal principal) {
|
||||
return modelService.save(null, input, userService.requireUserId(principal.getName()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新模型。
|
||||
*
|
||||
* @param id 模型 ID
|
||||
* @param input 模型输入
|
||||
* @param principal 当前用户
|
||||
* @return 更新后的模型
|
||||
*/
|
||||
@PutMapping("/{id}")
|
||||
public ModelService.ModelView update(
|
||||
@PathVariable UUID id,
|
||||
@Valid @RequestBody ModelService.ModelInput input,
|
||||
Principal principal) {
|
||||
return modelService.save(id, input, userService.requireUserId(principal.getName()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用管理页面当前草稿测试模型连接,但不保存草稿内容。
|
||||
*
|
||||
* <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 测试结果
|
||||
*/
|
||||
@PostMapping("/{id}/test")
|
||||
public ModelService.ConnectionResult test(@PathVariable UUID id) {
|
||||
return modelService.test(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置默认模型。
|
||||
*
|
||||
* @param id 模型 ID
|
||||
* @param principal 当前用户
|
||||
*/
|
||||
@PostMapping("/{id}/default")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void setDefault(@PathVariable UUID id, Principal principal) {
|
||||
modelService.setDefault(id, userService.requireUserId(principal.getName()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用或停用模型。
|
||||
*
|
||||
* @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) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package tech.easyflow.manuagent.web.project;
|
||||
|
||||
import tech.easyflow.manuagent.agent.project.ProjectFileService;
|
||||
import tech.easyflow.manuagent.agent.project.ProjectService;
|
||||
|
||||
import tech.easyflow.manuagent.agent.runtime.AgentRunService;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.Principal;
|
||||
import tech.easyflow.manuagent.admin.auth.UserService;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/**
|
||||
* 提供项目、规划和企业材料接口。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/projects")
|
||||
public class ProjectController {
|
||||
|
||||
private final UserService userService;
|
||||
|
||||
private final ProjectService projectService;
|
||||
private final ProjectFileService fileService;
|
||||
private final AgentRunService runService;
|
||||
|
||||
/**
|
||||
* 创建项目控制器。
|
||||
*
|
||||
* @param projectService 项目服务
|
||||
* @param fileService 文件服务
|
||||
* @param runService Agent Run 服务
|
||||
*/
|
||||
public ProjectController(UserService userService,
|
||||
ProjectService projectService,
|
||||
ProjectFileService fileService,
|
||||
AgentRunService runService) {
|
||||
this.userService = userService;
|
||||
this.projectService = projectService;
|
||||
this.fileService = fileService;
|
||||
this.runService = runService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出项目。
|
||||
*
|
||||
* @return 项目列表
|
||||
*/
|
||||
@GetMapping
|
||||
public List<ProjectService.ProjectView> list() {
|
||||
return projectService.list();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建项目。
|
||||
*
|
||||
* @param request 创建参数
|
||||
* @param principal 当前用户
|
||||
* @return 新项目
|
||||
*/
|
||||
@PostMapping
|
||||
public ProjectService.ProjectView create(@Valid @RequestBody CreateProjectRequest request, Principal principal) {
|
||||
ProjectService.ProjectView project = projectService.create(
|
||||
request.companyName(), request.applicationLevel(), userService.requireUserId(principal.getName()));
|
||||
fileService.ensureWorkspace(project.id());
|
||||
return project;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取项目详情。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @return 项目详情
|
||||
*/
|
||||
@GetMapping("/{projectId}")
|
||||
public ProjectService.ProjectView get(@PathVariable UUID projectId) {
|
||||
return projectService.require(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 真删除项目、业务记录和工作区文件。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
*/
|
||||
@DeleteMapping("/{projectId}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void delete(@PathVariable UUID projectId) {
|
||||
projectService.delete(projectId);
|
||||
fileService.deleteWorkspace(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回项目当前规划。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @return 当前规划;未生成时为空响应体
|
||||
*/
|
||||
@GetMapping("/{projectId}/plan")
|
||||
public ResponseEntity<ProjectService.PlanView> currentPlan(@PathVariable UUID projectId) {
|
||||
ProjectService.PlanView plan = projectService.currentPlan(projectId);
|
||||
return plan == null ? ResponseEntity.noContent().build() : ResponseEntity.ok(plan);
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认建设规划。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param request 确认参数
|
||||
* @param principal 当前用户
|
||||
* @return 已确认规划与已启动的编写 Run
|
||||
*/
|
||||
@PostMapping("/{projectId}/plan/confirm")
|
||||
public AgentRunService.ConfirmPlanResult confirmPlan(
|
||||
@PathVariable UUID projectId,
|
||||
@Valid @RequestBody ConfirmPlanRequest request,
|
||||
Principal principal) {
|
||||
return runService.confirmPlanAndStartWriting(projectId, request.planId(), request.plan(), userService.requireUserId(principal.getName()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传企业材料。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param file 上传文件
|
||||
* @param relativePath 文件夹内相对路径
|
||||
* @param principal 当前用户
|
||||
* @return 文件元数据
|
||||
*/
|
||||
@PostMapping(path = "/{projectId}/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ProjectFileService.FileView upload(
|
||||
@PathVariable UUID projectId,
|
||||
@RequestParam MultipartFile file,
|
||||
@RequestParam(required = false) String relativePath,
|
||||
Principal principal) {
|
||||
return fileService.upload(projectId, file, relativePath, userService.requireUserId(principal.getName()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出企业材料。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @return 文件列表
|
||||
*/
|
||||
@GetMapping("/{projectId}/files")
|
||||
public List<ProjectFileService.FileView> files(@PathVariable UUID projectId) {
|
||||
return fileService.list(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载企业材料。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param fileId 文件 ID
|
||||
* @return 文件响应
|
||||
*/
|
||||
@GetMapping("/{projectId}/files/{fileId}/download")
|
||||
public ResponseEntity<org.springframework.core.io.Resource> download(
|
||||
@PathVariable UUID projectId,
|
||||
@PathVariable UUID fileId) {
|
||||
ProjectFileService.Download download = fileService.download(projectId, fileId);
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.parseMediaType(download.mimeType()))
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename*=UTF-8''" + URLEncoder.encode(download.name(), StandardCharsets.UTF_8))
|
||||
.body(download.resource());
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回 document_view 生成的项目内预览图。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param path 工作区相对路径
|
||||
* @return 图片响应
|
||||
*/
|
||||
@GetMapping("/{projectId}/view-images")
|
||||
public ResponseEntity<org.springframework.core.io.Resource> preview(
|
||||
@PathVariable UUID projectId,
|
||||
@RequestParam String path) {
|
||||
ProjectFileService.Preview preview = fileService.preview(projectId, path);
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.parseMediaType(preview.mimeType()))
|
||||
.header(HttpHeaders.CACHE_CONTROL, "private, max-age=31536000, immutable")
|
||||
.body(preview.resource());
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目创建参数。
|
||||
*
|
||||
* @param companyName 企业名称
|
||||
* @param applicationLevel 申报等级
|
||||
*/
|
||||
public record CreateProjectRequest(@NotBlank String companyName, String applicationLevel) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 规划确认参数。
|
||||
*
|
||||
* @param planId 草稿规划 ID
|
||||
* @param plan 用户确认后的完整规划
|
||||
*/
|
||||
public record ConfirmPlanRequest(UUID planId, JsonNode plan) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package tech.easyflow.manuagent.web.skill;
|
||||
|
||||
import tech.easyflow.manuagent.agent.skill.SkillService;
|
||||
|
||||
import java.security.Principal;
|
||||
import tech.easyflow.manuagent.admin.auth.UserService;
|
||||
import java.util.List;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/**
|
||||
* 提供 Skill 导入、查看和启停接口。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/skills")
|
||||
public class SkillController {
|
||||
|
||||
private final UserService userService;
|
||||
|
||||
private final SkillService skillService;
|
||||
|
||||
/**
|
||||
* 创建 Skill 控制器。
|
||||
*
|
||||
* @param skillService Skill 服务
|
||||
*/
|
||||
public SkillController(UserService userService, SkillService skillService) {
|
||||
this.userService = userService;
|
||||
this.skillService = skillService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出 Skill。
|
||||
*
|
||||
* @return Skill 列表
|
||||
*/
|
||||
@GetMapping
|
||||
public List<SkillService.SkillView> list() {
|
||||
return skillService.list();
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 Skill 详情。
|
||||
*
|
||||
* @param name Skill 名称
|
||||
* @return Skill 详情
|
||||
*/
|
||||
@GetMapping("/{name}")
|
||||
public SkillService.SkillDetail get(@PathVariable String name) {
|
||||
return skillService.require(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取一个文本资源。
|
||||
*
|
||||
* @param name Skill 名称
|
||||
* @param path 资源路径
|
||||
* @return 资源正文
|
||||
*/
|
||||
@GetMapping("/{name}/resource")
|
||||
public String resource(@PathVariable String name, @RequestParam String path) {
|
||||
return skillService.resource(name, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入 Skill ZIP。
|
||||
*
|
||||
* @param file ZIP 文件
|
||||
* @param principal 当前用户
|
||||
* @return 导入后的 Skill
|
||||
*/
|
||||
@PostMapping(path = "/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
public SkillService.SkillView importZip(@RequestParam MultipartFile file, Principal principal) {
|
||||
return skillService.importZip(file, userService.requireUserId(principal.getName()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用 Skill。
|
||||
*
|
||||
* @param name Skill 名称
|
||||
*/
|
||||
@PostMapping("/{name}/enable")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void enable(@PathVariable String name) {
|
||||
skillService.setEnabled(name, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停用 Skill。
|
||||
*
|
||||
* @param name Skill 名称
|
||||
*/
|
||||
@PostMapping("/{name}/disable")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void disable(@PathVariable String name) {
|
||||
skillService.setEnabled(name, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户导入 Skill。
|
||||
*
|
||||
* @param name Skill 名称
|
||||
*/
|
||||
@DeleteMapping("/{name}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void delete(@PathVariable String name) {
|
||||
skillService.deleteImported(name);
|
||||
}
|
||||
}
|
||||
55
server/manuagent-web/src/main/resources/application.yml
Normal file
55
server/manuagent-web/src/main/resources/application.yml
Normal file
@@ -0,0 +1,55 @@
|
||||
spring:
|
||||
application:
|
||||
name: manu-agent
|
||||
datasource:
|
||||
url: jdbc:postgresql://localhost:54330/smart_factory_agent
|
||||
username: smart_factory
|
||||
password: smart_factory
|
||||
hikari:
|
||||
maximum-pool-size: 12
|
||||
minimum-idle: 2
|
||||
flyway:
|
||||
enabled: true
|
||||
validate-on-migrate: true
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 150MB
|
||||
max-request-size: 160MB
|
||||
threads:
|
||||
virtual:
|
||||
enabled: true
|
||||
jackson:
|
||||
default-property-inclusion: non_null
|
||||
|
||||
mybatis-flex:
|
||||
mapper-locations:
|
||||
- classpath*:/mapper/**/*.xml
|
||||
type-handlers-package: tech.easyflow.manuagent.common.typehandler
|
||||
configuration:
|
||||
map-underscore-to-camel-case: true
|
||||
cache-enabled: false
|
||||
local-cache-scope: statement
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
servlet:
|
||||
session:
|
||||
cookie:
|
||||
http-only: true
|
||||
same-site: lax
|
||||
secure: false
|
||||
|
||||
app:
|
||||
data-root: file:../data
|
||||
dashscope-key-file: ./dashscope_key.txt
|
||||
master-key: smart-factory-local-master-key
|
||||
admin-username: admin
|
||||
admin-password: admin123
|
||||
sandbox-image: agent-sandbox:0.1
|
||||
sandbox-network: bridge
|
||||
run-timeout: 60m
|
||||
max-concurrent-runs: 2
|
||||
|
||||
logging:
|
||||
pattern:
|
||||
level: "%5p [trace:%X{traceId:-}]"
|
||||
@@ -0,0 +1,742 @@
|
||||
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;
|
||||
|
||||
import tech.easyflow.manuagent.agent.runtime.AgentEventService;
|
||||
import tech.easyflow.manuagent.agent.runtime.AgentRunService;
|
||||
import tech.easyflow.manuagent.agent.runtime.AgentRunStore;
|
||||
import tech.easyflow.manuagent.agent.artifact.ArtifactService;
|
||||
import tech.easyflow.manuagent.agent.artifact.DocxValidator;
|
||||
import tech.easyflow.manuagent.agent.runtime.AgentEventEntity;
|
||||
import tech.easyflow.manuagent.admin.auth.AppUserEntity;
|
||||
import tech.easyflow.manuagent.agent.runtime.AgentEventMapper;
|
||||
import tech.easyflow.manuagent.agent.runtime.AgentRunMapper;
|
||||
import tech.easyflow.manuagent.admin.auth.AppUserMapper;
|
||||
import tech.easyflow.manuagent.agent.artifact.ArtifactMapper;
|
||||
import tech.easyflow.manuagent.agent.project.ProjectMapper;
|
||||
import tech.easyflow.manuagent.agent.project.ProjectPlanMapper;
|
||||
import tech.easyflow.manuagent.agent.model.ModelAssignmentMapper;
|
||||
import tech.easyflow.manuagent.agent.model.ModelConfigMapper;
|
||||
import tech.easyflow.manuagent.agent.skill.SkillConfigMapper;
|
||||
import tech.easyflow.manuagent.agent.model.KeyCipher;
|
||||
import tech.easyflow.manuagent.agent.model.ModelService;
|
||||
import tech.easyflow.manuagent.agent.config.AgentProperties;
|
||||
import tech.easyflow.manuagent.agent.skill.SkillPackageReader;
|
||||
import tech.easyflow.manuagent.agent.skill.SkillService;
|
||||
import io.agentscope.core.skill.repository.postgresql.PostgresSkillRepository;
|
||||
import tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler;
|
||||
import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler;
|
||||
import tech.easyflow.manuagent.agent.project.ProjectService;
|
||||
import tech.easyflow.manuagent.agent.project.ProjectFileService;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import com.mybatisflex.core.MybatisFlexBootstrap;
|
||||
import com.mybatisflex.core.datasource.FlexDataSource;
|
||||
import com.mybatisflex.core.mybatis.FlexConfiguration;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import com.mybatisflex.core.table.TableInfo;
|
||||
import com.mybatisflex.core.table.TableInfoFactory;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.io.InputStream;
|
||||
import java.sql.DriverManager;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.time.Duration;
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.apache.ibatis.builder.xml.XMLMapperBuilder;
|
||||
import org.apache.ibatis.io.Resources;
|
||||
import org.apache.ibatis.mapping.Environment;
|
||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.postgresql.ds.PGSimpleDataSource;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
|
||||
/**
|
||||
* 验证 PostgreSQL 17 全量迁移及事件游标回放。
|
||||
*/
|
||||
@Testcontainers
|
||||
class DatabaseAndEventIntegrationTest {
|
||||
|
||||
@Container
|
||||
private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:17-alpine");
|
||||
|
||||
@TempDir
|
||||
private Path temporaryDirectory;
|
||||
|
||||
/**
|
||||
* 在干净 PostgreSQL 17 实例执行并校验全部 Flyway 迁移。
|
||||
*/
|
||||
@BeforeAll
|
||||
static void migrate() {
|
||||
Flyway flyway = Flyway.configure()
|
||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||
.locations("classpath:db/migration")
|
||||
.load();
|
||||
flyway.migrate();
|
||||
flyway.validate();
|
||||
assertThat(flyway.migrate().migrationsExecuted).isZero();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证核心表、索引和约束已建立。
|
||||
*
|
||||
* @throws Exception 数据库访问失败时抛出
|
||||
*/
|
||||
@Test
|
||||
void shouldCreateCoreSchemaOnPostgres17() throws Exception {
|
||||
try (var connection = DriverManager.getConnection(
|
||||
POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword());
|
||||
var statement = connection.createStatement();
|
||||
var result = statement.executeQuery("""
|
||||
SELECT count(*) FROM information_schema.tables
|
||||
WHERE table_schema IN ('app', 'agentscope')
|
||||
""")) {
|
||||
assertThat(result.next()).isTrue();
|
||||
assertThat(result.getInt(1)).isEqualTo(12);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证事件按项目全局 ID 增量回放且不重复。
|
||||
*/
|
||||
@Test
|
||||
void shouldReplayEventsAfterCursorInOrder() throws Exception {
|
||||
JdbcClient jdbc = jdbc();
|
||||
UUID userId = UUID.randomUUID();
|
||||
UUID modelId = UUID.randomUUID();
|
||||
UUID projectId = UUID.randomUUID();
|
||||
UUID runId = UUID.randomUUID();
|
||||
jdbc.sql("INSERT INTO app.app_user(id, username, password_hash, display_name) VALUES (:id, :name, 'x', 'test')")
|
||||
.param("id", userId).param("name", "u-" + userId).update();
|
||||
jdbc.sql("""
|
||||
INSERT INTO app.model_config(id, name, provider, base_url, model_id, is_default)
|
||||
VALUES (:id, :name, 'OPENAI_COMPATIBLE', 'https://example.test', 'model', FALSE)
|
||||
""").param("id", modelId).param("name", "m-" + modelId).update();
|
||||
jdbc.sql("""
|
||||
INSERT INTO app.project(id, company_name, project_name, agui_thread_id, application_level, created_by)
|
||||
VALUES (:id, '企业', '项目', :thread, 'ADVANCED', :userId)
|
||||
""").param("id", projectId).param("thread", "t-" + projectId).param("userId", userId).update();
|
||||
jdbc.sql("""
|
||||
INSERT INTO app.agent_run(id, project_id, model_config_id, trigger_type, status, trace_id)
|
||||
VALUES (:id, :projectId, :modelId, 'INITIAL', 'RUNNING', :trace)
|
||||
""").param("id", runId).param("projectId", projectId).param("modelId", modelId)
|
||||
.param("trace", UUID.randomUUID().toString()).update();
|
||||
|
||||
AgentEventService service = new AgentEventService(agentEventMapper(), tools.jackson.databind.json.JsonMapper.builder().build());
|
||||
long first = service.append(projectId, runId, "RUN_STARTED", Map.of("phase", "MATERIAL_CHECK")).id();
|
||||
long second = service.append(projectId, runId, "TEXT_MESSAGE_CONTENT", Map.of("delta", "分析")).id();
|
||||
long third = service.append(projectId, runId, "TEXT_MESSAGE_CONTENT", Map.of("delta", "完成")).id();
|
||||
|
||||
assertThat(service.listAfter(projectId, first, 100))
|
||||
.extracting(AgentEventService.EventView::id)
|
||||
.containsExactly(second, third);
|
||||
|
||||
var next = service.streamAfter(projectId, third)
|
||||
.filter(event -> !"HEARTBEAT".equals(event.type()))
|
||||
.next()
|
||||
.toFuture();
|
||||
long pushed = service.append(projectId, runId, "TEXT_MESSAGE_CONTENT", Map.of("delta", "推送")).id();
|
||||
assertThat(next.orTimeout(2, TimeUnit.SECONDS).join().id()).isEqualTo(pushed);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证重试生成同一路径产物时更新登记信息,避免唯一约束导致成功 Run 被标记失败。
|
||||
*
|
||||
* @throws Exception 临时文件写入失败时抛出
|
||||
*/
|
||||
@Test
|
||||
void shouldReplaceArtifactMetadataForSameProjectPath() throws Exception {
|
||||
JdbcClient jdbc = jdbc();
|
||||
UUID userId = UUID.randomUUID();
|
||||
UUID modelId = UUID.randomUUID();
|
||||
UUID projectId = UUID.randomUUID();
|
||||
UUID firstRunId = UUID.randomUUID();
|
||||
UUID secondRunId = UUID.randomUUID();
|
||||
jdbc.sql("INSERT INTO app.app_user(id, username, password_hash, display_name) VALUES (:id, :name, 'x', 'test')")
|
||||
.param("id", userId).param("name", "u-" + userId).update();
|
||||
jdbc.sql("""
|
||||
INSERT INTO app.model_config(id, name, provider, base_url, model_id)
|
||||
VALUES (:id, :name, 'OPENAI_COMPATIBLE', 'https://example.test', 'model')
|
||||
""").param("id", modelId).param("name", "m-" + modelId).update();
|
||||
jdbc.sql("""
|
||||
INSERT INTO app.project(id, company_name, project_name, agui_thread_id, application_level, created_by)
|
||||
VALUES (:id, '企业', '项目', :thread, 'ADVANCED', :userId)
|
||||
""").param("id", projectId).param("thread", "t-" + projectId).param("userId", userId).update();
|
||||
for (UUID runId : List.of(firstRunId, secondRunId)) {
|
||||
jdbc.sql("""
|
||||
INSERT INTO app.agent_run(
|
||||
id, project_id, model_config_id, trigger_type, status, trace_id, ended_at)
|
||||
VALUES (:id, :projectId, :modelId, 'RETRY', 'COMPLETED', :trace, CURRENT_TIMESTAMP)
|
||||
""").param("id", runId).param("projectId", projectId).param("modelId", modelId)
|
||||
.param("trace", UUID.randomUUID().toString()).update();
|
||||
}
|
||||
|
||||
Path document = temporaryDirectory.resolve("draft.docx");
|
||||
Files.writeString(document, "first");
|
||||
ProjectFileService files = mock(ProjectFileService.class);
|
||||
when(files.safeProjectPath(projectId, "artifacts/draft.docx")).thenReturn(document);
|
||||
ArtifactService artifacts = new ArtifactService(artifactMapper(), files, new DocxValidator());
|
||||
ObjectMapper mapper = tools.jackson.databind.json.JsonMapper.builder().build();
|
||||
|
||||
ArtifactService.ArtifactView first = artifacts.publish(
|
||||
projectId, firstRunId, "DOCX", "draft.docx", "artifacts/draft.docx",
|
||||
mapper.createObjectNode().put("version", 1));
|
||||
Files.writeString(document, "second version");
|
||||
ArtifactService.ArtifactView second = artifacts.publish(
|
||||
projectId, secondRunId, "DOCX", "draft.docx", "artifacts/draft.docx",
|
||||
mapper.createObjectNode().put("version", 2));
|
||||
|
||||
assertThat(second.id()).isEqualTo(first.id());
|
||||
assertThat(second.runId()).isEqualTo(secondRunId);
|
||||
assertThat(second.sizeBytes()).isEqualTo(Files.size(document));
|
||||
assertThat(artifacts.list(projectId)).hasSize(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证项目真删除会清除所有关联业务记录。
|
||||
*/
|
||||
@Test
|
||||
void shouldDeleteProjectRecords() throws Exception {
|
||||
JdbcClient jdbc = jdbc();
|
||||
UUID userId = UUID.randomUUID();
|
||||
UUID projectId = UUID.randomUUID();
|
||||
UUID runId = UUID.randomUUID();
|
||||
jdbc.sql("INSERT INTO app.app_user(id, username, password_hash, display_name) VALUES (:id, :name, 'x', 'test')")
|
||||
.param("id", userId).param("name", "u-" + userId).update();
|
||||
jdbc.sql("""
|
||||
INSERT INTO app.project(id, company_name, project_name, agui_thread_id, application_level, created_by)
|
||||
VALUES (:id, '待删除企业', '待删除项目', :thread, 'ADVANCED', :userId)
|
||||
""").param("id", projectId).param("thread", "t-" + projectId).param("userId", userId).update();
|
||||
jdbc.sql("""
|
||||
INSERT INTO app.agent_run(id, project_id, trigger_type, status, trace_id, ended_at)
|
||||
VALUES (:id, :projectId, 'INITIAL', 'COMPLETED', :trace, CURRENT_TIMESTAMP)
|
||||
""").param("id", runId).param("projectId", projectId)
|
||||
.param("trace", UUID.randomUUID().toString()).update();
|
||||
jdbc.sql("""
|
||||
INSERT INTO app.agent_event(project_id, run_id, event_type, payload)
|
||||
VALUES (:projectId, :runId, 'RUN_FINISHED', '{}'::jsonb)
|
||||
""").param("projectId", projectId).param("runId", runId).update();
|
||||
jdbc.sql("""
|
||||
INSERT INTO app.project_plan(id, project_id, plan_version, status, plan_json, created_by)
|
||||
VALUES (:id, :projectId, 1, 'DRAFT', '{}'::jsonb, :userId)
|
||||
""").param("id", UUID.randomUUID()).param("projectId", projectId).param("userId", userId).update();
|
||||
jdbc.sql("""
|
||||
INSERT INTO app.project_file(
|
||||
id, project_id, original_name, stored_name, relative_path, mime_type,
|
||||
extension, size_bytes, sha256, uploaded_by)
|
||||
VALUES (:id, :projectId, 'input.txt', 'input.txt', 'inputs/input.txt',
|
||||
'text/plain', 'txt', 1, :sha, :userId)
|
||||
""").param("id", UUID.randomUUID()).param("projectId", projectId)
|
||||
.param("sha", "0".repeat(64)).param("userId", userId).update();
|
||||
jdbc.sql("""
|
||||
INSERT INTO app.artifact(
|
||||
id, project_id, run_id, kind, name, relative_path, mime_type, size_bytes, sha256)
|
||||
VALUES (:id, :projectId, :runId, 'OTHER', 'result.txt', 'artifacts/result.txt',
|
||||
'text/plain', 1, :sha)
|
||||
""").param("id", UUID.randomUUID()).param("projectId", projectId).param("runId", runId)
|
||||
.param("sha", "0".repeat(64)).update();
|
||||
|
||||
ProjectService service = new ProjectService(
|
||||
projectMapper(), mock(ProjectPlanMapper.class), tools.jackson.databind.json.JsonMapper.builder().build());
|
||||
service.delete(projectId);
|
||||
|
||||
for (String table : List.of("agent_event", "artifact", "project_plan", "project_file", "agent_run")) {
|
||||
Long count = jdbc.sql("SELECT COUNT(*) FROM app." + table + " WHERE project_id = :projectId")
|
||||
.param("projectId", projectId)
|
||||
.query(Long.class)
|
||||
.single();
|
||||
assertThat(count).as(table).isZero();
|
||||
}
|
||||
assertThat(jdbc.sql("SELECT COUNT(*) FROM app.project WHERE id = :projectId")
|
||||
.param("projectId", projectId)
|
||||
.query(Long.class)
|
||||
.single()).isZero();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Run 创建、等待确认、完成和中断均遵守数据库状态机条件。
|
||||
*
|
||||
* <p>该测试直接覆盖 MyBatis-Flex BaseMapper 插入、XML 状态更新和实体结果映射,
|
||||
* 防止迁移后出现 UUID 主键未写入、JSONB Ask 丢失或终态被重复覆盖。</p>
|
||||
*/
|
||||
@Test
|
||||
void shouldPersistAndTransitionAgentRunWithMybatisFlex() throws Exception {
|
||||
JdbcClient jdbc = jdbc();
|
||||
UUID userId = UUID.randomUUID();
|
||||
UUID modelId = UUID.randomUUID();
|
||||
UUID projectId = UUID.randomUUID();
|
||||
jdbc.sql("INSERT INTO app.app_user(id, username, password_hash, display_name) VALUES (:id, :name, 'x', 'test')")
|
||||
.param("id", userId).param("name", "run-u-" + userId).update();
|
||||
// 测试类共用同一容器;先释放其他用例留下的唯一默认模型,再建立本用例的确定性前置条件。
|
||||
jdbc.sql("UPDATE app.model_config SET is_default = FALSE WHERE is_default").update();
|
||||
jdbc.sql("""
|
||||
INSERT INTO app.model_config(id, name, provider, base_url, model_id, is_default)
|
||||
VALUES (:id, :name, 'OPENAI_COMPATIBLE', 'https://example.test', 'model', TRUE)
|
||||
""").param("id", modelId).param("name", "run-m-" + modelId).update();
|
||||
jdbc.sql("""
|
||||
INSERT INTO app.project(id, company_name, project_name, agui_thread_id, application_level, created_by)
|
||||
VALUES (:id, '企业', 'Run 迁移测试', :threadId, 'ADVANCED', :userId)
|
||||
""").param("id", projectId).param("threadId", "run-thread-" + projectId)
|
||||
.param("userId", userId).update();
|
||||
|
||||
AgentRunMapper mapper = agentRunMapper();
|
||||
AgentEventMapper events = agentEventMapper();
|
||||
AgentRunStore store = new AgentRunStore(mapper, events, modelConfigMapper(), tools.jackson.databind.json.JsonMapper.builder().build());
|
||||
AgentRunService.RunView initial = store.create(projectId, "INITIAL", null);
|
||||
assertThat(initial.status()).isEqualTo("RUNNING");
|
||||
assertThat(store.latest(projectId).id()).isEqualTo(initial.id());
|
||||
store.ensureRunning(initial.id());
|
||||
|
||||
String interrupt = "{\"kind\":\"material_check\",\"items\":[]}";
|
||||
assertThat(mapper.waitForInput(initial.id(), interrupt)).isEqualTo(1);
|
||||
assertThat(tools.jackson.databind.json.JsonMapper.builder().build().readTree(
|
||||
store.requireWaiting(projectId, "material_check").pendingInterrupt()))
|
||||
.isEqualTo(tools.jackson.databind.json.JsonMapper.builder().build().readTree(interrupt));
|
||||
store.completeWaiting(initial.id());
|
||||
assertThat(store.require(initial.id()).status()).isEqualTo("COMPLETED");
|
||||
|
||||
AgentRunService.RunView resumed = store.create(projectId, "RESUME", initial.id());
|
||||
AgentEventEntity started = new AgentEventEntity();
|
||||
started.setProjectId(projectId);
|
||||
started.setRunId(resumed.id());
|
||||
started.setEventType("RUN_STARTED");
|
||||
started.setEventId(UUID.randomUUID().toString());
|
||||
started.setPayloadJson("{\"phase\":\"PLANNING\"}");
|
||||
events.insertReturning(started);
|
||||
AgentEventEntity response = new AgentEventEntity();
|
||||
response.setProjectId(projectId);
|
||||
response.setRunId(resumed.id());
|
||||
response.setEventType("ASK_RESPONDED");
|
||||
response.setEventId(UUID.randomUUID().toString());
|
||||
response.setPayloadJson("{\"decisions\":[]}");
|
||||
events.insertReturning(response);
|
||||
assertThat(events.selectLatestStartedPhase(resumed.id())).isEqualTo("PLANNING");
|
||||
assertThat(tools.jackson.databind.json.JsonMapper.builder().build().readTree(events.selectLatestMaterialResponseJson(projectId)))
|
||||
.isEqualTo(tools.jackson.databind.json.JsonMapper.builder().build().readTree("{\"decisions\":[]}"));
|
||||
|
||||
assertThat(mapper.interruptRunning(resumed.id())).isEqualTo(1);
|
||||
assertThat(mapper.interruptRunning(resumed.id())).isZero();
|
||||
assertThat(store.isInterrupted(resumed.id())).isTrue();
|
||||
|
||||
AgentRunService.RunView restartCandidate = store.create(projectId, "RETRY", resumed.id());
|
||||
assertThat(mapper.interruptRunningAfterRestart()).isGreaterThanOrEqualTo(1);
|
||||
assertThat(mapper.selectOneById(restartCandidate.id()).getErrorCode()).isEqualTo("PROCESS_RESTARTED");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证规划草稿使用递增版本写入 JSONB,并且只有 DRAFT 可以原子确认。
|
||||
*
|
||||
* @throws Exception Mapper XML 初始化失败时抛出
|
||||
*/
|
||||
@Test
|
||||
void shouldSaveAndConfirmProjectPlanWithMyBatisFlex() throws Exception {
|
||||
JdbcClient jdbc = jdbc();
|
||||
UUID userId = UUID.randomUUID();
|
||||
UUID projectId = UUID.randomUUID();
|
||||
jdbc.sql("INSERT INTO app.app_user(id, username, password_hash, display_name) VALUES (:id, :name, 'x', 'test')")
|
||||
.param("id", userId).param("name", "plan-u-" + userId).update();
|
||||
jdbc.sql("""
|
||||
INSERT INTO app.project(id, company_name, project_name, agui_thread_id, application_level, created_by)
|
||||
VALUES (:id, '规划企业', '规划项目', :thread, 'ADVANCED', :userId)
|
||||
""").param("id", projectId).param("thread", "plan-t-" + projectId).param("userId", userId).update();
|
||||
|
||||
ProjectService service = new ProjectService(
|
||||
projectMapper(), projectPlanMapper(), tools.jackson.databind.json.JsonMapper.builder().build());
|
||||
ObjectMapper json = tools.jackson.databind.json.JsonMapper.builder().build();
|
||||
|
||||
ProjectService.PlanView draft = service.saveDraftPlan(
|
||||
projectId, json.createObjectNode().put("title", "第一版"), userId);
|
||||
ProjectService.PlanView confirmed = service.confirmPlan(
|
||||
projectId,
|
||||
draft.id(),
|
||||
json.createObjectNode().put("title", "确认版"),
|
||||
userId);
|
||||
|
||||
assertThat(draft.version()).isEqualTo(1);
|
||||
assertThat(service.currentPlan(projectId).id()).isEqualTo(draft.id());
|
||||
assertThat(confirmed.status()).isEqualTo("CONFIRMED");
|
||||
assertThat(confirmed.plan().path("title").asText()).isEqualTo("确认版");
|
||||
assertThat(service.require(projectId).status()).isEqualTo("WRITING");
|
||||
assertThat(service.require(projectId).version()).isEqualTo(2L);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证多个模型持久化、首模型自动默认、保留旧密钥更新以及默认模型切换。
|
||||
*
|
||||
* @throws Exception Mapper XML 初始化失败时抛出
|
||||
*/
|
||||
@Test
|
||||
void shouldManageEncryptedModelConfigurationWithMyBatisFlex() throws Exception {
|
||||
UUID userId = UUID.randomUUID();
|
||||
jdbc().sql("INSERT INTO app.app_user(id, username, password_hash, display_name) VALUES (:id, :name, 'x', 'test')")
|
||||
.param("id", userId).param("name", "model-u-" + userId).update();
|
||||
|
||||
AgentProperties properties = new AgentProperties(
|
||||
temporaryDirectory,
|
||||
temporaryDirectory.resolve("dashscope.key"),
|
||||
"integration-master-key",
|
||||
"runtime:test",
|
||||
"bridge",
|
||||
Duration.ofMinutes(1));
|
||||
ModelService service = new ModelService(
|
||||
modelConfigMapper(),
|
||||
modelAssignmentMapper(),
|
||||
agentRunMapper(),
|
||||
new KeyCipher(properties),
|
||||
tools.jackson.databind.json.JsonMapper.builder().build());
|
||||
Map<String, Object> capabilities = Map.of(
|
||||
"toolCalling", true, "reasoning", true, "contextWindow", 65_536);
|
||||
|
||||
ModelService.ModelView created = service.save(
|
||||
null,
|
||||
new ModelService.ModelInput(
|
||||
"测试模型", "https://model.example.test/", "model-v1", "secret-1234",
|
||||
Map.of("timeoutSeconds", 60), capabilities),
|
||||
userId);
|
||||
ModelService.ModelView updated = service.save(
|
||||
created.id(),
|
||||
new ModelService.ModelInput(
|
||||
"测试模型更新", "https://model.example.test", "model-v2", "",
|
||||
Map.of("timeoutSeconds", 120), capabilities),
|
||||
userId);
|
||||
ModelService.ModelView second = service.save(
|
||||
null,
|
||||
new ModelService.ModelInput(
|
||||
"第二测试模型", "https://second-model.example.test", "model-second", "secret-5678",
|
||||
Map.of("timeoutSeconds", 90), capabilities),
|
||||
userId);
|
||||
|
||||
assertThat(created.defaultModel()).isTrue();
|
||||
assertThat(second.defaultModel()).isFalse();
|
||||
|
||||
service.setDefault(second.id(), userId);
|
||||
ModelService.ModelSecret firstSecret = service.requireRuntimeModel(created.id());
|
||||
ModelService.ModelSecret defaultSecret = service.defaultModelSecret();
|
||||
|
||||
assertThat(updated.name()).isEqualTo("测试模型更新");
|
||||
assertThat(updated.apiKeyHint()).endsWith("1234");
|
||||
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", 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Skill 列表只读联查 AgentScope 表,而启停状态仅写应用自管表。
|
||||
*
|
||||
* @throws Exception Mapper XML 初始化失败时抛出
|
||||
*/
|
||||
@Test
|
||||
void shouldReadAgentScopeSkillsAndUpdateApplicationConfiguration() throws Exception {
|
||||
SkillConfigMapper mapper = skillConfigMapper();
|
||||
AgentProperties properties = new AgentProperties(
|
||||
temporaryDirectory,
|
||||
temporaryDirectory.resolve("dashscope.key"),
|
||||
"integration-master-key",
|
||||
"runtime:test",
|
||||
"bridge",
|
||||
Duration.ofMinutes(1));
|
||||
SkillService service = new SkillService(
|
||||
mapper,
|
||||
mock(PostgresSkillRepository.class),
|
||||
mock(SkillPackageReader.class),
|
||||
properties);
|
||||
|
||||
List<SkillService.SkillView> skills = service.list();
|
||||
assertThat(skills).isNotEmpty();
|
||||
String name = skills.getFirst().name();
|
||||
service.setEnabled(name, false);
|
||||
|
||||
assertThat(service.enabledNames()).doesNotContain(name);
|
||||
assertThat(jdbc().sql("SELECT enabled FROM app.skill_config WHERE skill_name = :name")
|
||||
.param("name", name).query(Boolean.class).single()).isFalse();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 MyBatis-Flex 可以在 app schema 中插入并通过 Lambda QueryWrapper 查询用户。
|
||||
*/
|
||||
@Test
|
||||
void shouldPersistAndQueryUserWithMyBatisFlex() {
|
||||
PGSimpleDataSource source = dataSource();
|
||||
AppUserMapper mapper = new MybatisFlexBootstrap()
|
||||
.setDataSource(source)
|
||||
.addMapper(AppUserMapper.class)
|
||||
.start()
|
||||
.getMapper(AppUserMapper.class);
|
||||
UUID userId = UUID.randomUUID();
|
||||
AppUserEntity user = new AppUserEntity();
|
||||
user.setId(userId);
|
||||
user.setUsername("flex-" + userId);
|
||||
user.setPasswordHash("encoded");
|
||||
user.setDisplayName("Flex 测试用户");
|
||||
|
||||
TableInfo tableInfo = TableInfoFactory.ofEntityClass(AppUserEntity.class);
|
||||
assertThat(tableInfo.getPrimaryColumns()).containsExactly("id");
|
||||
assertThat(tableInfo.getInsertPrimaryKeys()).containsExactly("id");
|
||||
|
||||
// 主键由应用层提前生成;Generator 策略必须保留已有值,其他空字段交给数据库默认值。
|
||||
assertThat(mapper.insertSelectiveWithPk(user)).isEqualTo(1);
|
||||
|
||||
QueryWrapper query = QueryWrapper.create()
|
||||
.where(AppUserEntity::getUsername).eq(user.getUsername());
|
||||
AppUserEntity loaded = mapper.selectOneByQuery(query);
|
||||
assertThat(loaded.getId()).isEqualTo(userId);
|
||||
assertThat(loaded.getDisplayName()).isEqualTo("Flex 测试用户");
|
||||
assertThat(loaded.getEnabled()).isTrue();
|
||||
}
|
||||
|
||||
private JdbcClient jdbc() {
|
||||
return JdbcClient.create(dataSource());
|
||||
}
|
||||
|
||||
private PGSimpleDataSource dataSource() {
|
||||
PGSimpleDataSource source = new PGSimpleDataSource();
|
||||
source.setURL(POSTGRES.getJdbcUrl());
|
||||
source.setUser(POSTGRES.getUsername());
|
||||
source.setPassword(POSTGRES.getPassword());
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带 UUID、JSONB 类型处理器和显式 XML 语句的产物 Mapper。
|
||||
*
|
||||
* <p>生产环境由 Spring Boot 扫描类型处理器与 mapper-locations;此处使用轻量 Bootstrap,
|
||||
* 因而需要显式复现相同配置。</p>
|
||||
*
|
||||
* @return 可访问 Testcontainers PostgreSQL 的产物 Mapper
|
||||
* @throws Exception XML 资源读取或 Mapper 初始化失败时抛出
|
||||
*/
|
||||
private ArtifactMapper artifactMapper() throws Exception {
|
||||
PGSimpleDataSource source = dataSource();
|
||||
FlexDataSource flexDataSource = new FlexDataSource("artifact-integration-test", source);
|
||||
Environment environment = new Environment(
|
||||
"artifact-integration-test", new JdbcTransactionFactory(), flexDataSource);
|
||||
FlexConfiguration configuration = new FlexConfiguration(environment);
|
||||
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
|
||||
configuration.getTypeHandlerRegistry().register(JsonbStringTypeHandler.class);
|
||||
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
|
||||
.setConfiguration(configuration)
|
||||
.setDataSource(flexDataSource)
|
||||
.addMapper(ArtifactMapper.class)
|
||||
.start();
|
||||
String resource = "mapper/agent/ArtifactMapper.xml";
|
||||
try (InputStream input = Resources.getResourceAsStream(resource)) {
|
||||
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
|
||||
}
|
||||
return bootstrap.getMapper(ArtifactMapper.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建加载事件原子写入和游标回放 SQL 的 Agent 事件 Mapper。
|
||||
*
|
||||
* @return Agent 事件 Mapper
|
||||
* @throws Exception XML 资源读取或 Mapper 初始化失败时抛出
|
||||
*/
|
||||
private AgentEventMapper agentEventMapper() throws Exception {
|
||||
PGSimpleDataSource source = dataSource();
|
||||
FlexDataSource flexDataSource = new FlexDataSource("agent-event-integration-test", source);
|
||||
Environment environment = new Environment(
|
||||
"agent-event-integration-test", new JdbcTransactionFactory(), flexDataSource);
|
||||
FlexConfiguration configuration = new FlexConfiguration(environment);
|
||||
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
|
||||
configuration.getTypeHandlerRegistry().register(JsonbStringTypeHandler.class);
|
||||
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
|
||||
.setConfiguration(configuration)
|
||||
.setDataSource(flexDataSource)
|
||||
.addMapper(AgentEventMapper.class)
|
||||
.start();
|
||||
String resource = "mapper/agent/AgentEventMapper.xml";
|
||||
try (InputStream input = Resources.getResourceAsStream(resource)) {
|
||||
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
|
||||
}
|
||||
return bootstrap.getMapper(AgentEventMapper.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建加载 Run 状态机 SQL 及 UUID、JSONB 类型处理器的 Agent Run Mapper。
|
||||
*
|
||||
* @return Agent Run Mapper
|
||||
* @throws Exception XML 资源读取或 Mapper 初始化失败时抛出
|
||||
*/
|
||||
private AgentRunMapper agentRunMapper() throws Exception {
|
||||
PGSimpleDataSource source = dataSource();
|
||||
FlexDataSource flexDataSource = new FlexDataSource("agent-run-integration-test", source);
|
||||
Environment environment = new Environment(
|
||||
"agent-run-integration-test", new JdbcTransactionFactory(), flexDataSource);
|
||||
FlexConfiguration configuration = new FlexConfiguration(environment);
|
||||
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
|
||||
configuration.getTypeHandlerRegistry().register(JsonbStringTypeHandler.class);
|
||||
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
|
||||
.setConfiguration(configuration)
|
||||
.setDataSource(flexDataSource)
|
||||
.addMapper(AgentRunMapper.class)
|
||||
.start();
|
||||
String resource = "mapper/agent/AgentRunMapper.xml";
|
||||
try (InputStream input = Resources.getResourceAsStream(resource)) {
|
||||
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
|
||||
}
|
||||
return bootstrap.getMapper(AgentRunMapper.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建加载了项目级联删除 XML 的项目 Mapper。
|
||||
*
|
||||
* @return 项目 Mapper
|
||||
* @throws Exception XML 资源读取或 Mapper 初始化失败时抛出
|
||||
*/
|
||||
private ProjectMapper projectMapper() throws Exception {
|
||||
PGSimpleDataSource source = dataSource();
|
||||
FlexDataSource flexDataSource = new FlexDataSource("project-integration-test", source);
|
||||
Environment environment = new Environment(
|
||||
"project-integration-test", new JdbcTransactionFactory(), flexDataSource);
|
||||
FlexConfiguration configuration = new FlexConfiguration(environment);
|
||||
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
|
||||
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
|
||||
.setConfiguration(configuration)
|
||||
.setDataSource(flexDataSource)
|
||||
.addMapper(ProjectMapper.class)
|
||||
.start();
|
||||
String resource = "mapper/agent/ProjectMapper.xml";
|
||||
try (InputStream input = Resources.getResourceAsStream(resource)) {
|
||||
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
|
||||
}
|
||||
return bootstrap.getMapper(ProjectMapper.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建加载了规划版本 SQL 与 JSONB 处理器的规划 Mapper。
|
||||
*
|
||||
* @return 规划 Mapper
|
||||
* @throws Exception XML 资源读取或 Mapper 初始化失败时抛出
|
||||
*/
|
||||
private ProjectPlanMapper projectPlanMapper() throws Exception {
|
||||
PGSimpleDataSource source = dataSource();
|
||||
FlexDataSource flexDataSource = new FlexDataSource("plan-integration-test", source);
|
||||
Environment environment = new Environment(
|
||||
"plan-integration-test", new JdbcTransactionFactory(), flexDataSource);
|
||||
FlexConfiguration configuration = new FlexConfiguration(environment);
|
||||
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
|
||||
configuration.getTypeHandlerRegistry().register(JsonbStringTypeHandler.class);
|
||||
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
|
||||
.setConfiguration(configuration)
|
||||
.setDataSource(flexDataSource)
|
||||
.addMapper(ProjectPlanMapper.class)
|
||||
.start();
|
||||
String resource = "mapper/agent/ProjectPlanMapper.xml";
|
||||
try (InputStream input = Resources.getResourceAsStream(resource)) {
|
||||
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
|
||||
}
|
||||
return bootstrap.getMapper(ProjectPlanMapper.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建使用 MyBatis-Flex Wrapper,并加载 JSONB 显式写入 SQL 的模型配置 Mapper。
|
||||
*
|
||||
* @return 模型配置 Mapper
|
||||
* @throws Exception XML 资源读取或 Mapper 初始化失败时抛出
|
||||
*/
|
||||
private ModelConfigMapper modelConfigMapper() throws Exception {
|
||||
PGSimpleDataSource source = dataSource();
|
||||
FlexDataSource flexDataSource = new FlexDataSource("model-config-integration-test", source);
|
||||
Environment environment = new Environment(
|
||||
"model-config-integration-test", new JdbcTransactionFactory(), flexDataSource);
|
||||
FlexConfiguration configuration = new FlexConfiguration(environment);
|
||||
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
|
||||
configuration.getTypeHandlerRegistry().register(JsonbStringTypeHandler.class);
|
||||
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
|
||||
.setConfiguration(configuration)
|
||||
.setDataSource(flexDataSource)
|
||||
.addMapper(ModelConfigMapper.class)
|
||||
.start();
|
||||
String resource = "mapper/agent/ModelConfigMapper.xml";
|
||||
try (InputStream input = Resources.getResourceAsStream(resource)) {
|
||||
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
|
||||
}
|
||||
return bootstrap.getMapper(ModelConfigMapper.class);
|
||||
}
|
||||
|
||||
/** 创建加载角色 upsert SQL 的模型分配 Mapper。 */
|
||||
private ModelAssignmentMapper modelAssignmentMapper() throws Exception {
|
||||
PGSimpleDataSource source = dataSource();
|
||||
FlexDataSource flexDataSource = new FlexDataSource("model-assignment-integration-test", source);
|
||||
Environment environment = new Environment(
|
||||
"model-assignment-integration-test", new JdbcTransactionFactory(), flexDataSource);
|
||||
FlexConfiguration configuration = new FlexConfiguration(environment);
|
||||
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
|
||||
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
|
||||
.setConfiguration(configuration)
|
||||
.setDataSource(flexDataSource)
|
||||
.addMapper(ModelAssignmentMapper.class)
|
||||
.start();
|
||||
String resource = "mapper/agent/ModelAssignmentMapper.xml";
|
||||
try (InputStream input = Resources.getResourceAsStream(resource)) {
|
||||
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
|
||||
}
|
||||
return bootstrap.getMapper(ModelAssignmentMapper.class);
|
||||
}
|
||||
|
||||
/** 创建加载 AgentScope 只读联查 SQL 的 Skill 配置 Mapper。 */
|
||||
private SkillConfigMapper skillConfigMapper() throws Exception {
|
||||
PGSimpleDataSource source = dataSource();
|
||||
FlexDataSource flexDataSource = new FlexDataSource("skill-config-integration-test", source);
|
||||
Environment environment = new Environment(
|
||||
"skill-config-integration-test", new JdbcTransactionFactory(), flexDataSource);
|
||||
FlexConfiguration configuration = new FlexConfiguration(environment);
|
||||
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
|
||||
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
|
||||
.setConfiguration(configuration)
|
||||
.setDataSource(flexDataSource)
|
||||
.addMapper(SkillConfigMapper.class)
|
||||
.start();
|
||||
String resource = "mapper/agent/SkillConfigMapper.xml";
|
||||
try (InputStream input = Resources.getResourceAsStream(resource)) {
|
||||
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
|
||||
}
|
||||
return bootstrap.getMapper(SkillConfigMapper.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package tech.easyflow.manuagent;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import tech.easyflow.manuagent.agent.config.AgentProperties;
|
||||
import tech.easyflow.manuagent.agent.model.KeyCipher;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* 验证模型密钥保护。
|
||||
*/
|
||||
class KeyCipherAndShellTest {
|
||||
|
||||
/**
|
||||
* 验证密钥可恢复且相同明文每次产生不同密文。
|
||||
*/
|
||||
@Test
|
||||
void shouldEncryptModelKeyWithRandomIv() {
|
||||
KeyCipher cipher = new KeyCipher(properties());
|
||||
byte[] first = cipher.encrypt("local-test-key");
|
||||
byte[] second = cipher.encrypt("local-test-key");
|
||||
|
||||
assertThat(first).isNotEqualTo(second);
|
||||
assertThat(cipher.decrypt(first)).isEqualTo("local-test-key");
|
||||
}
|
||||
|
||||
private AgentProperties properties() {
|
||||
return new AgentProperties(
|
||||
Path.of("data"), Path.of("dashscope"),
|
||||
"unit-test-master", "agent-sandbox:test", "bridge", Duration.ofMinutes(1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package tech.easyflow.manuagent;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.mybatisflex.spring.boot.v4.MybatisFlexAutoConfiguration;
|
||||
import java.util.Map;
|
||||
import org.apache.ibatis.session.SqlSessionFactory;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import tech.easyflow.manuagent.web.config.MyBatisFlexConfiguration;
|
||||
import tech.easyflow.manuagent.agent.runtime.AgentEventEntity;
|
||||
import tech.easyflow.manuagent.agent.artifact.ArtifactEntity;
|
||||
import tech.easyflow.manuagent.agent.project.ProjectPlanEntity;
|
||||
import tech.easyflow.manuagent.agent.runtime.AgentEventMapper;
|
||||
import tech.easyflow.manuagent.agent.runtime.AgentRunMapper;
|
||||
import tech.easyflow.manuagent.agent.artifact.ArtifactMapper;
|
||||
import tech.easyflow.manuagent.agent.model.ModelConfigMapper;
|
||||
import tech.easyflow.manuagent.agent.project.ProjectPlanMapper;
|
||||
import tech.easyflow.manuagent.agent.skill.SkillConfigMapper;
|
||||
|
||||
/**
|
||||
* 验证生产环境使用的 MyBatis-Flex 自动配置、Mapper 扫描和 XML 资源能够共同启动。
|
||||
*
|
||||
* <p>各 PostgreSQL 集成测试使用轻量 Bootstrap 单独加载 Mapper;本测试补充验证 Spring Boot
|
||||
* 实际配置路径,防止 mapper-locations 拼写、Bean 扫描或 XML statement 命名错误只在部署时暴露。</p>
|
||||
*/
|
||||
class MyBatisFlexContextTest {
|
||||
|
||||
/**
|
||||
* 加载最小 Spring 上下文并核对关键自定义 SQL statement。
|
||||
*
|
||||
* <p>测试 URL 不执行数据库连接;本用例只验证配置装配,实际 SQL 行为由 Testcontainers
|
||||
* PostgreSQL 17 集成测试负责。</p>
|
||||
*/
|
||||
@Test
|
||||
void shouldLoadMapperBeansAndXmlStatements() {
|
||||
new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(
|
||||
DataSourceAutoConfiguration.class,
|
||||
MybatisFlexAutoConfiguration.class))
|
||||
.withUserConfiguration(MyBatisFlexConfiguration.class)
|
||||
.withPropertyValues(
|
||||
"spring.datasource.url=jdbc:postgresql://127.0.0.1:1/config-only",
|
||||
"spring.datasource.username=test",
|
||||
"spring.datasource.password=test",
|
||||
"spring.datasource.hikari.initialization-fail-timeout=-1",
|
||||
"mybatis-flex.mapper-locations=classpath*:/mapper/**/*.xml",
|
||||
"mybatis-flex.type-aliases-package=tech.easyflow.manuagent.agent",
|
||||
"mybatis-flex.type-handlers-package=tech.easyflow.manuagent.common.typehandler",
|
||||
"mybatis-flex.configuration.map-underscore-to-camel-case=true",
|
||||
"mybatis-flex.configuration.cache-enabled=false",
|
||||
"mybatis-flex.configuration.local-cache-scope=statement")
|
||||
.run(context -> {
|
||||
assertThat(context.getStartupFailure()).isNull();
|
||||
assertThat(context.getBean(AgentEventMapper.class)).isNotNull();
|
||||
assertThat(context.getBean(AgentRunMapper.class)).isNotNull();
|
||||
assertThat(context.getBean(ArtifactMapper.class)).isNotNull();
|
||||
assertThat(context.getBean(ModelConfigMapper.class)).isNotNull();
|
||||
assertThat(context.getBean(ProjectPlanMapper.class)).isNotNull();
|
||||
assertThat(context.getBean(SkillConfigMapper.class)).isNotNull();
|
||||
|
||||
var configuration = context.getBean(SqlSessionFactory.class).getConfiguration();
|
||||
assertThat(configuration.hasStatement(
|
||||
"tech.easyflow.manuagent.agent.runtime.AgentEventMapper.insertReturning")).isTrue();
|
||||
assertThat(configuration.hasStatement(
|
||||
"tech.easyflow.manuagent.agent.runtime.AgentRunMapper.interruptRunningAfterRestart")).isTrue();
|
||||
assertThat(configuration.hasStatement(
|
||||
"tech.easyflow.manuagent.agent.model.ModelConfigMapper.insertModel")).isTrue();
|
||||
assertThat(configuration.hasStatement(
|
||||
"tech.easyflow.manuagent.agent.model.ModelConfigMapper.updateModel")).isTrue();
|
||||
assertThat(configuration.hasStatement(
|
||||
"tech.easyflow.manuagent.agent.model.ModelConfigMapper.clearDefault")).isTrue();
|
||||
assertThat(configuration.hasStatement(
|
||||
"tech.easyflow.manuagent.agent.model.ModelConfigMapper.setDefault")).isTrue();
|
||||
assertThat(configuration.hasStatement(
|
||||
"tech.easyflow.manuagent.agent.project.ProjectPlanMapper.confirmDraft")).isTrue();
|
||||
assertThat(configuration.hasStatement(
|
||||
"tech.easyflow.manuagent.agent.skill.SkillConfigMapper.selectViews")).isTrue();
|
||||
assertThat(configuration.hasStatement(
|
||||
"tech.easyflow.manuagent.agent.skill.SkillConfigMapper.updateEnabled")).isTrue();
|
||||
|
||||
// 自定义 INSERT/UPDATE ... RETURNING 也应沿用迁移前视图字段,避免回传内部列。
|
||||
String eventReturning = returningClause(configuration
|
||||
.getMappedStatement("tech.easyflow.manuagent.agent.runtime.AgentEventMapper.insertReturning")
|
||||
.getBoundSql(Map.of("event", new AgentEventEntity()))
|
||||
.getSql());
|
||||
assertThat(eventReturning)
|
||||
.contains("id", "project_id", "run_id", "event_type", "payload", "created_at")
|
||||
.doesNotContain("event_id");
|
||||
|
||||
String artifactReturning = returningClause(configuration
|
||||
.getMappedStatement("tech.easyflow.manuagent.agent.artifact.ArtifactMapper.upsert")
|
||||
.getBoundSql(Map.of("artifact", new ArtifactEntity()))
|
||||
.getSql());
|
||||
assertThat(artifactReturning)
|
||||
.contains("metadata_json", "published_at", "size_bytes")
|
||||
.doesNotContain("relative_path", "mime_type", "sha256", "created_at");
|
||||
|
||||
String draftReturning = returningClause(configuration
|
||||
.getMappedStatement("tech.easyflow.manuagent.agent.project.ProjectPlanMapper.insertNextDraft")
|
||||
.getBoundSql(Map.of("plan", new ProjectPlanEntity()))
|
||||
.getSql());
|
||||
assertPlanViewProjection(draftReturning);
|
||||
|
||||
String confirmReturning = returningClause(configuration
|
||||
.getMappedStatement("tech.easyflow.manuagent.agent.project.ProjectPlanMapper.confirmDraft")
|
||||
.getBoundSql(Map.of())
|
||||
.getSql());
|
||||
assertPlanViewProjection(confirmReturning);
|
||||
|
||||
String currentPlanSql = configuration
|
||||
.getMappedStatement("tech.easyflow.manuagent.agent.project.ProjectPlanMapper.selectCurrent")
|
||||
.getBoundSql(Map.of())
|
||||
.getSql()
|
||||
.toLowerCase(java.util.Locale.ROOT);
|
||||
assertPlanViewProjection(currentPlanSql.substring(0, currentPlanSql.indexOf("from")));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 截取自定义写语句的 RETURNING 字段部分,避免 INSERT/UPDATE 输入列干扰投影断言。
|
||||
*
|
||||
* @param sql 完整 Mapper SQL
|
||||
* @return 规范化为小写的 RETURNING 子句
|
||||
*/
|
||||
private static String returningClause(String sql) {
|
||||
String normalized = sql.toLowerCase(java.util.Locale.ROOT);
|
||||
int returning = normalized.lastIndexOf("returning");
|
||||
assertThat(returning).isGreaterThanOrEqualTo(0);
|
||||
return normalized.substring(returning);
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言规划查询仅返回接口视图所需字段。
|
||||
*
|
||||
* @param projection SELECT 或 RETURNING 字段片段
|
||||
*/
|
||||
private static void assertPlanViewProjection(String projection) {
|
||||
assertThat(projection)
|
||||
.contains("id", "project_id", "plan_version", "status", "plan_json", "confirmed_at", "created_at")
|
||||
.doesNotContain("created_by", "confirmed_by", "updated_at");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
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;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import com.mybatisflex.spring.boot.v4.FlexTransactionAutoConfiguration;
|
||||
import com.mybatisflex.spring.boot.v4.MybatisFlexAutoConfiguration;
|
||||
import java.util.UUID;
|
||||
import javax.sql.DataSource;
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.postgresql.util.PSQLException;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;
|
||||
import org.springframework.boot.jdbc.autoconfigure.DataSourceTransactionManagerAutoConfiguration;
|
||||
import org.springframework.boot.transaction.autoconfigure.TransactionAutoConfiguration;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.aop.support.AopUtils;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
import tech.easyflow.manuagent.agent.config.AgentProperties;
|
||||
import tech.easyflow.manuagent.web.config.MyBatisFlexConfiguration;
|
||||
import tech.easyflow.manuagent.admin.auth.AppUserMapper;
|
||||
import tech.easyflow.manuagent.agent.model.ModelAssignmentMapper;
|
||||
import tech.easyflow.manuagent.agent.model.ModelConfigMapper;
|
||||
import tech.easyflow.manuagent.agent.runtime.AgentRunMapper;
|
||||
import tech.easyflow.manuagent.agent.model.KeyCipher;
|
||||
import tech.easyflow.manuagent.agent.model.ModelService;
|
||||
|
||||
/**
|
||||
* 使用真实 Spring 事务代理、MyBatis-Flex Mapper 和 PostgreSQL 验证跨语句回滚。
|
||||
*
|
||||
* <p>轻量 Mapper Bootstrap 只能证明 SQL 可执行;本测试额外证明生产配置中的 Mapper 调用
|
||||
* 与 {@code @Transactional} 共享同一个数据库事务。</p>
|
||||
*/
|
||||
@Testcontainers
|
||||
class MyBatisFlexTransactionIntegrationTest {
|
||||
|
||||
/** 为事务测试提供隔离的 PostgreSQL 17 数据库。 */
|
||||
@Container
|
||||
private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:17-alpine");
|
||||
|
||||
/**
|
||||
* 在 Spring 上下文启动前建立与生产一致的应用表结构。
|
||||
*/
|
||||
@BeforeAll
|
||||
static void migrate() {
|
||||
Flyway.configure()
|
||||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||
.locations("classpath:db/migration")
|
||||
.load()
|
||||
.migrate();
|
||||
}
|
||||
|
||||
/**
|
||||
* 角色分配写入失败时,默认模型切换必须整体回滚,不能留下“没有默认模型”的中间状态。
|
||||
*
|
||||
* <p>目标模型刻意保持停用,用于验证 ORM 迁移没有新增原 JDBC 实现不存在的启用状态限制;
|
||||
* 用户 ID 则使用数据库中不存在的值,让后续角色分配稳定触发外键错误。</p>
|
||||
*/
|
||||
@Test
|
||||
void shouldRollbackDefaultModelSwitchWhenAssignmentFails() {
|
||||
new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(
|
||||
DataSourceAutoConfiguration.class,
|
||||
FlexTransactionAutoConfiguration.class,
|
||||
DataSourceTransactionManagerAutoConfiguration.class,
|
||||
TransactionAutoConfiguration.class,
|
||||
MybatisFlexAutoConfiguration.class))
|
||||
.withUserConfiguration(MyBatisFlexConfiguration.class, TransactionTestConfiguration.class)
|
||||
.withPropertyValues(
|
||||
"spring.datasource.url=" + POSTGRES.getJdbcUrl(),
|
||||
"spring.datasource.username=" + POSTGRES.getUsername(),
|
||||
"spring.datasource.password=" + POSTGRES.getPassword(),
|
||||
"mybatis-flex.mapper-locations=classpath*:/mapper/**/*.xml",
|
||||
"mybatis-flex.type-aliases-package=tech.easyflow.manuagent.agent",
|
||||
"mybatis-flex.type-handlers-package=tech.easyflow.manuagent.common.typehandler",
|
||||
"mybatis-flex.configuration.map-underscore-to-camel-case=true")
|
||||
.run(context -> {
|
||||
assertThat(context.getStartupFailure()).isNull();
|
||||
JdbcClient jdbc = JdbcClient.create(context.getBean(DataSource.class));
|
||||
UUID userId = UUID.randomUUID();
|
||||
UUID missingUserId = UUID.randomUUID();
|
||||
UUID currentDefaultId = UUID.randomUUID();
|
||||
UUID enabledTargetId = UUID.randomUUID();
|
||||
jdbc.sql("""
|
||||
INSERT INTO app.app_user(id, username, password_hash, display_name)
|
||||
VALUES (:id, :username, 'encoded', '事务测试用户')
|
||||
""")
|
||||
.param("id", userId)
|
||||
.param("username", "tx-" + userId)
|
||||
.update();
|
||||
jdbc.sql("""
|
||||
INSERT INTO app.model_config(
|
||||
id, name, provider, base_url, model_id, enabled, is_default)
|
||||
VALUES
|
||||
(:currentId, :currentName, 'OPENAI_COMPATIBLE', 'https://current.test',
|
||||
'current-model', TRUE, TRUE),
|
||||
(:targetId, :targetName, 'OPENAI_COMPATIBLE', 'https://target.test',
|
||||
'target-model', TRUE, FALSE)
|
||||
""")
|
||||
.param("currentId", currentDefaultId)
|
||||
.param("currentName", "current-" + currentDefaultId)
|
||||
.param("targetId", enabledTargetId)
|
||||
.param("targetName", "target-" + enabledTargetId)
|
||||
.update();
|
||||
|
||||
ModelService modelService = context.getBean(ModelService.class);
|
||||
assertThat(context.getBeansOfType(PlatformTransactionManager.class)).hasSize(1);
|
||||
assertThat(AopUtils.isAopProxy(modelService)).isTrue();
|
||||
|
||||
assertThatThrownBy(() -> modelService.setDefault(enabledTargetId, missingUserId))
|
||||
.hasRootCauseInstanceOf(PSQLException.class);
|
||||
|
||||
assertThat(jdbc.sql("SELECT is_default FROM app.model_config WHERE id = :id")
|
||||
.param("id", currentDefaultId)
|
||||
.query(Boolean.class)
|
||||
.single()).isTrue();
|
||||
assertThat(jdbc.sql("SELECT is_default FROM app.model_config WHERE id = :id")
|
||||
.param("id", enabledTargetId)
|
||||
.query(Boolean.class)
|
||||
.single()).isFalse();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅装配事务测试需要的服务边界,避免启动 Agent、文件系统和外部模型连接。
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
static class TransactionTestConfiguration {
|
||||
|
||||
|
||||
/**
|
||||
* 装配真实 Mapper 驱动的模型服务;未参与本场景的密钥与应用配置依赖使用边界 Mock。
|
||||
*/
|
||||
@Bean
|
||||
ModelService modelService(
|
||||
ModelConfigMapper modelMapper,
|
||||
ModelAssignmentMapper assignmentMapper,
|
||||
AgentRunMapper runMapper) {
|
||||
return new ModelService(
|
||||
modelMapper,
|
||||
assignmentMapper,
|
||||
runMapper,
|
||||
mock(KeyCipher.class),
|
||||
tools.jackson.databind.json.JsonMapper.builder().build());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package tech.easyflow.manuagent.web.auth;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
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 tech.easyflow.manuagent.web.common.GlobalExceptionHandler;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
/**
|
||||
* 验证登录边界返回稳定的认证错误码。
|
||||
*/
|
||||
class AuthControllerTest {
|
||||
|
||||
/**
|
||||
* 验证错误密码返回 401,且不会落入 500 兜底。
|
||||
*
|
||||
* @throws Exception MockMvc 执行失败时抛出
|
||||
*/
|
||||
@Test
|
||||
void shouldReturnUnauthorizedForWrongPassword() throws Exception {
|
||||
AuthenticationManager manager = mock(AuthenticationManager.class);
|
||||
when(manager.authenticate(any())).thenThrow(new BadCredentialsException("bad credentials"));
|
||||
MockMvc mvc = MockMvcBuilders.standaloneSetup(new AuthController(manager))
|
||||
.setControllerAdvice(new GlobalExceptionHandler())
|
||||
.build();
|
||||
|
||||
mvc.perform(post("/api/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"username\":\"admin\",\"password\":\"wrong-password\"}"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value("AUTHENTICATION_FAILED"))
|
||||
.andExpect(jsonPath("$.message").value("用户名或密码错误"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package tech.easyflow.manuagent.web.common;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
|
||||
|
||||
/**
|
||||
* 验证统一异常边界对流式连接终止的处理。
|
||||
*/
|
||||
class GlobalExceptionHandlerTest {
|
||||
|
||||
/**
|
||||
* 客户端断开已提交的流式响应时不再尝试写入 JSON 错误体。
|
||||
*/
|
||||
@Test
|
||||
void shouldIgnoreExpectedStreamDisconnect() {
|
||||
GlobalExceptionHandler handler = new GlobalExceptionHandler();
|
||||
|
||||
assertThatCode(() -> handler.handleClientDisconnect(
|
||||
new AsyncRequestNotUsableException("ServletOutputStream failed to flush")))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package tech.easyflow.manuagent.web.config;
|
||||
|
||||
import tech.easyflow.manuagent.agent.config.AgentProperties;
|
||||
|
||||
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=agent-sandbox: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(AgentProperties.class).masterKey())
|
||||
.isEqualTo("unit-test-master-key");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册应用配置属性,复用生产环境的 Spring Boot 配置绑定流程。
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableConfigurationProperties(AgentProperties.class)
|
||||
static class PropertiesConfiguration {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package tech.easyflow.manuagent.web.model;
|
||||
|
||||
import tech.easyflow.manuagent.agent.model.ModelService;
|
||||
|
||||
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(mock(tech.easyflow.manuagent.admin.auth.UserService.class), 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(mock(tech.easyflow.manuagent.admin.auth.UserService.class), 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package tech.easyflow.manuagent.web.project;
|
||||
|
||||
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 static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
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.setup.MockMvcBuilders;
|
||||
import tech.easyflow.manuagent.admin.auth.UserService;
|
||||
import tech.easyflow.manuagent.agent.project.ProjectFileService;
|
||||
import tech.easyflow.manuagent.agent.project.ProjectService;
|
||||
import tech.easyflow.manuagent.agent.runtime.AgentRunService;
|
||||
|
||||
class ProjectControllerTest {
|
||||
@Test
|
||||
void shouldResolveActorFromPrincipalInsteadOfClientInput() throws Exception {
|
||||
UUID actor = UUID.randomUUID();
|
||||
UUID projectId = UUID.randomUUID();
|
||||
UserService users = mock(UserService.class);
|
||||
ProjectService projects = mock(ProjectService.class);
|
||||
ProjectFileService files = mock(ProjectFileService.class);
|
||||
when(users.requireUserId("authenticated-user")).thenReturn(actor);
|
||||
when(projects.create(eq("Test"), eq("ADVANCED"), any())).thenReturn(
|
||||
new ProjectService.ProjectView(projectId, "Test", "Test", "thread", "ADVANCED",
|
||||
"MATERIAL_CHECK", 0, null, null));
|
||||
var mvc = MockMvcBuilders.standaloneSetup(
|
||||
new ProjectController(users, projects, files, mock(AgentRunService.class))).build();
|
||||
|
||||
mvc.perform(post("/api/projects").principal(() -> "authenticated-user")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"companyName":"Test","applicationLevel":"ADVANCED",
|
||||
"userId":"00000000-0000-0000-0000-000000000001"}
|
||||
"""))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(projects).create("Test", "ADVANCED", actor);
|
||||
verify(files).ensureWorkspace(projectId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user