chore: 项目环境升级为 JDK25,Spring 4.1,项目重构为多模块

This commit is contained in:
2026-09-08 15:02:18 +08:00
parent d392436620
commit 81a0d81f11
168 changed files with 2785 additions and 1375 deletions

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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:-}]"

View File

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

View File

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

View File

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

View File

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

View File

@@ -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("用户名或密码错误"));
}
}

View File

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

View File

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

View File

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

View File

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