运行视图不暴露模型配置、追踪标识和内部错误码,显式投影可避免每次轮询都读取无关列。
+ * + * @return Run 视图字段查询构造器 + */ + @SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。 + private static QueryWrapper runViewQuery() { + return QueryWrapper.create().select( + AgentRunEntity::getId, + AgentRunEntity::getProjectId, + AgentRunEntity::getTriggerType, + AgentRunEntity::getStatus, + AgentRunEntity::getPendingInterrupt, + AgentRunEntity::getErrorMessage, + AgentRunEntity::getStartedAt, + AgentRunEntity::getEndedAt); + } } diff --git a/server/src/main/java/tech/easyflow/manuagent/agent/RunRecoveryService.java b/server/src/main/java/tech/easyflow/manuagent/agent/RunRecoveryService.java index 12a6e2e..063c485 100644 --- a/server/src/main/java/tech/easyflow/manuagent/agent/RunRecoveryService.java +++ b/server/src/main/java/tech/easyflow/manuagent/agent/RunRecoveryService.java @@ -3,9 +3,9 @@ package tech.easyflow.manuagent.agent; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; import org.springframework.core.annotation.Order; -import org.springframework.jdbc.core.simple.JdbcClient; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import tech.easyflow.manuagent.mapper.AgentRunMapper; /** * 启动时终结因 JVM 中断而遗留的伪运行状态,并保留原业务阶段供继续执行。 @@ -14,15 +14,15 @@ import org.springframework.transaction.annotation.Transactional; @Order(0) public class RunRecoveryService implements ApplicationRunner { - private final JdbcClient jdbc; + private final AgentRunMapper runMapper; /** * 创建恢复服务。 * - * @param jdbc JDBC 客户端 + * @param runMapper Agent Run Mapper */ - public RunRecoveryService(JdbcClient jdbc) { - this.jdbc = jdbc; + public RunRecoveryService(AgentRunMapper runMapper) { + this.runMapper = runMapper; } /** @@ -33,12 +33,6 @@ public class RunRecoveryService implements ApplicationRunner { @Override @Transactional public void run(ApplicationArguments args) { - jdbc.sql(""" - UPDATE app.agent_run - SET status = 'INTERRUPTED', pending_interrupt = NULL, - error_code = 'PROCESS_RESTARTED', error_message = '服务重启,运行已中断', - ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP - WHERE status = 'RUNNING' - """).update(); + runMapper.interruptRunningAfterRestart(); } } diff --git a/server/src/main/java/tech/easyflow/manuagent/artifact/ArtifactService.java b/server/src/main/java/tech/easyflow/manuagent/artifact/ArtifactService.java index 0f7ae27..c1c4165 100644 --- a/server/src/main/java/tech/easyflow/manuagent/artifact/ArtifactService.java +++ b/server/src/main/java/tech/easyflow/manuagent/artifact/ArtifactService.java @@ -1,6 +1,9 @@ package tech.easyflow.manuagent.artifact; +import com.mybatisflex.core.query.QueryWrapper; import tech.easyflow.manuagent.common.ApiException; +import tech.easyflow.manuagent.entity.ArtifactEntity; +import tech.easyflow.manuagent.mapper.ArtifactMapper; import tech.easyflow.manuagent.project.ProjectFileService; import com.fasterxml.jackson.databind.JsonNode; import java.io.IOException; @@ -18,7 +21,6 @@ import java.util.UUID; import org.springframework.core.io.Resource; import org.springframework.core.io.UrlResource; import org.springframework.http.HttpStatus; -import org.springframework.jdbc.core.simple.JdbcClient; import org.springframework.stereotype.Service; /** @@ -27,19 +29,20 @@ import org.springframework.stereotype.Service; @Service public class ArtifactService { - private final JdbcClient jdbc; + private final ArtifactMapper artifactMapper; private final ProjectFileService fileService; private final DocxValidator docxValidator; /** * 创建产物服务。 * - * @param jdbc JDBC 客户端 + * @param artifactMapper 产物 Mapper * @param fileService 项目文件服务 * @param docxValidator DOCX 校验器 */ - public ArtifactService(JdbcClient jdbc, ProjectFileService fileService, DocxValidator docxValidator) { - this.jdbc = jdbc; + public ArtifactService( + ArtifactMapper artifactMapper, ProjectFileService fileService, DocxValidator docxValidator) { + this.artifactMapper = artifactMapper; this.fileService = fileService; this.docxValidator = docxValidator; } @@ -118,36 +121,20 @@ public class ArtifactService { throw new ApiException(HttpStatus.BAD_REQUEST, "ARTIFACT_EMPTY", "产物文件为空"); } String hash = sha256(path); - UUID id = jdbc.sql(""" - INSERT INTO app.artifact( - id, project_id, run_id, kind, name, relative_path, mime_type, - size_bytes, sha256, metadata_json) - VALUES (:id, :projectId, :runId, :kind, :name, :path, - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - :size, :sha256, CAST(:metadata AS jsonb)) - ON CONFLICT (project_id, relative_path) DO UPDATE SET - run_id = EXCLUDED.run_id, - kind = EXCLUDED.kind, - name = EXCLUDED.name, - mime_type = EXCLUDED.mime_type, - size_bytes = EXCLUDED.size_bytes, - sha256 = EXCLUDED.sha256, - metadata_json = EXCLUDED.metadata_json, - published_at = CURRENT_TIMESTAMP - RETURNING id - """) - .param("id", UUID.randomUUID()) - .param("projectId", projectId) - .param("runId", runId) - .param("kind", kind) - .param("name", name) - .param("path", relativePath) - .param("size", size) - .param("sha256", hash) - .param("metadata", metadata.toString()) - .query(UUID.class) - .single(); - return require(id); + // “项目 + 路径”必须原子 upsert,避免先查后写在并发重试时触发唯一约束竞态。 + ArtifactEntity entity = new ArtifactEntity(); + entity.setId(UUID.randomUUID()); + entity.setProjectId(projectId); + entity.setRunId(runId); + entity.setKind(kind); + entity.setName(name); + entity.setRelativePath(relativePath); + entity.setMimeType("application/vnd.openxmlformats-officedocument.wordprocessingml.document"); + entity.setSizeBytes(size); + entity.setSha256(hash); + entity.setMetadataJson(metadata.toString()); + ArtifactEntity stored = artifactMapper.upsert(entity); + return toArtifactView(stored); } catch (IOException | NoSuchAlgorithmException exception) { throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "ARTIFACT_PUBLISH_FAILED", "产物校验失败"); } @@ -175,10 +162,12 @@ public class ArtifactService { * @return 按发布时间倒序的产物 */ public List该字段集合与迁移前 JDBC 列表 SQL 保持一致。下载路径、MIME 类型和 SHA-256 + * 仅在下载场景读取,避免普通列表查询加载不参与响应的内部字段。
+ * + * @return 只包含产物接口视图字段的查询构造器 + */ + @SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。 + private static QueryWrapper artifactViewQuery() { + return QueryWrapper.create().select( + ArtifactEntity::getId, + ArtifactEntity::getProjectId, + ArtifactEntity::getRunId, + ArtifactEntity::getKind, + ArtifactEntity::getName, + ArtifactEntity::getSizeBytes, + ArtifactEntity::getMetadataJson, + ArtifactEntity::getPublishedAt); + } /** * 产物元数据。 diff --git a/server/src/main/java/tech/easyflow/manuagent/auth/UserService.java b/server/src/main/java/tech/easyflow/manuagent/auth/UserService.java index bfdccc5..6a6f5ee 100644 --- a/server/src/main/java/tech/easyflow/manuagent/auth/UserService.java +++ b/server/src/main/java/tech/easyflow/manuagent/auth/UserService.java @@ -1,19 +1,21 @@ package tech.easyflow.manuagent.auth; -import tech.easyflow.manuagent.common.ApiException; -import tech.easyflow.manuagent.config.AppProperties; +import com.mybatisflex.core.query.QueryWrapper; import java.util.UUID; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; +import org.springframework.core.annotation.Order; import org.springframework.http.HttpStatus; -import org.springframework.jdbc.core.simple.JdbcClient; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; -import org.springframework.core.annotation.Order; +import tech.easyflow.manuagent.common.ApiException; +import tech.easyflow.manuagent.config.AppProperties; +import tech.easyflow.manuagent.entity.AppUserEntity; +import tech.easyflow.manuagent.mapper.AppUserMapper; /** * 管理单管理员账户和当前用户标识。 @@ -22,19 +24,19 @@ import org.springframework.core.annotation.Order; @Order(1) public class UserService implements UserDetailsService, ApplicationRunner { - private final JdbcClient jdbc; + private final AppUserMapper userMapper; private final PasswordEncoder passwordEncoder; private final AppProperties properties; /** * 创建用户服务。 * - * @param jdbc JDBC 客户端 + * @param userMapper 用户表 Mapper * @param passwordEncoder 密码编码器 * @param properties 应用配置 */ - public UserService(JdbcClient jdbc, PasswordEncoder passwordEncoder, AppProperties properties) { - this.jdbc = jdbc; + public UserService(AppUserMapper userMapper, PasswordEncoder passwordEncoder, AppProperties properties) { + this.userMapper = userMapper; this.passwordEncoder = passwordEncoder; this.properties = properties; } @@ -46,18 +48,18 @@ public class UserService implements UserDetailsService, ApplicationRunner { */ @Override public void run(ApplicationArguments args) { - Integer count = jdbc.sql("SELECT count(*) FROM app.app_user").query(Integer.class).single(); - if (count == 0) { - jdbc.sql(""" - INSERT INTO app.app_user(id, username, password_hash, display_name) - VALUES (:id, :username, :password, :displayName) - """) - .param("id", UUID.randomUUID()) - .param("username", properties.adminUsername()) - .param("password", passwordEncoder.encode(properties.adminPassword())) - .param("displayName", "管理员") - .update(); + long count = userMapper.selectCountByQuery(QueryWrapper.create()); + if (count > 0) { + return; } + + // 首次启动时仍由应用层生成 UUID;selective insert 让 enabled 和时间字段沿用数据库默认值。 + AppUserEntity administrator = new AppUserEntity(); + administrator.setId(UUID.randomUUID()); + administrator.setUsername(properties.adminUsername()); + administrator.setPasswordHash(passwordEncoder.encode(properties.adminPassword())); + administrator.setDisplayName("管理员"); + userMapper.insertSelective(administrator); } /** @@ -68,16 +70,23 @@ public class UserService implements UserDetailsService, ApplicationRunner { * @throws UsernameNotFoundException 用户不存在或被禁用时抛出 */ @Override + @SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。 public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { - return jdbc.sql("SELECT username, password_hash, enabled FROM app.app_user WHERE username = :username") - .param("username", username) - .query((rs, rowNum) -> User.withUsername(rs.getString("username")) - .password(rs.getString("password_hash")) - .roles("ADMIN") - .disabled(!rs.getBoolean("enabled")) - .build()) - .optional() - .orElseThrow(() -> new UsernameNotFoundException("账户不存在")); + QueryWrapper query = QueryWrapper.create() + .select( + AppUserEntity::getUsername, + AppUserEntity::getPasswordHash, + AppUserEntity::getEnabled) + .where(AppUserEntity::getUsername).eq(username); + AppUserEntity entity = userMapper.selectOneByQuery(query); + if (entity == null) { + throw new UsernameNotFoundException("账户不存在"); + } + return User.withUsername(entity.getUsername()) + .password(entity.getPasswordHash()) + .roles("ADMIN") + .disabled(!Boolean.TRUE.equals(entity.getEnabled())) + .build(); } /** @@ -87,11 +96,15 @@ public class UserService implements UserDetailsService, ApplicationRunner { * @return 用户 UUID * @throws ApiException 用户不存在时抛出 */ + @SuppressWarnings("unchecked") // MyBatis-Flex 的 select(LambdaGetter业务 Mapper 统一放在 {@code tech.easyflow.manuagent.mapper} 包中。数据库连接、连接池和 + * Spring 事务管理器继续复用 Spring Boot 已配置的数据源,使 MyBatis-Flex 的 Mapper 调用、 + * 事件写入与应用服务的 {@code @Transactional} 边界共享同一物理事务。
+ */ +@Configuration +@MapperScan("tech.easyflow.manuagent.mapper") +public class MyBatisFlexConfiguration { +} diff --git a/server/src/main/java/tech/easyflow/manuagent/entity/AgentEventEntity.java b/server/src/main/java/tech/easyflow/manuagent/entity/AgentEventEntity.java new file mode 100644 index 0000000..7ed6e0a --- /dev/null +++ b/server/src/main/java/tech/easyflow/manuagent/entity/AgentEventEntity.java @@ -0,0 +1,55 @@ +package tech.easyflow.manuagent.entity; + +import com.mybatisflex.annotation.Column; +import com.mybatisflex.annotation.Id; +import com.mybatisflex.annotation.KeyType; +import com.mybatisflex.annotation.Table; +import java.time.OffsetDateTime; +import java.util.UUID; +import org.apache.ibatis.type.JdbcType; +import tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler; +import tech.easyflow.manuagent.typehandler.UuidTypeHandler; + +/** + * 映射 {@code app.agent_event} 表的持久化 Agent 事件。 + * + *事件 ID 由 PostgreSQL 的 BIGSERIAL 序列生成,业务代码通过 + * {@code INSERT ... RETURNING} 原子取得该 ID,确保游标回放顺序与数据库提交顺序一致。
+ */ +@Table(value = "agent_event", schema = "app") +public class AgentEventEntity { + + /** PostgreSQL 全局递增事件序号。 */ + @Id(keyType = KeyType.Auto) + private Long id; + /** 事件所属项目。 */ + @Column(typeHandler = UuidTypeHandler.class) + private UUID projectId; + /** 事件所属 Agent Run。 */ + @Column(typeHandler = UuidTypeHandler.class) + private UUID runId; + /** AG-UI 事件类型。 */ + private String eventType; + /** 用于外部追踪和去重的随机事件标识。 */ + private String eventId; + /** JSONB 格式的事件负载。 */ + @Column(value = "payload", jdbcType = JdbcType.OTHER, typeHandler = JsonbStringTypeHandler.class) + private String payloadJson; + /** 数据库记录的事件创建时间。 */ + private OffsetDateTime createdAt; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public UUID getProjectId() { return projectId; } + public void setProjectId(UUID projectId) { this.projectId = projectId; } + public UUID getRunId() { return runId; } + public void setRunId(UUID runId) { this.runId = runId; } + public String getEventType() { return eventType; } + public void setEventType(String eventType) { this.eventType = eventType; } + public String getEventId() { return eventId; } + public void setEventId(String eventId) { this.eventId = eventId; } + public String getPayloadJson() { return payloadJson; } + public void setPayloadJson(String payloadJson) { this.payloadJson = payloadJson; } + public OffsetDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/server/src/main/java/tech/easyflow/manuagent/entity/AgentRunEntity.java b/server/src/main/java/tech/easyflow/manuagent/entity/AgentRunEntity.java new file mode 100644 index 0000000..8f3b347 --- /dev/null +++ b/server/src/main/java/tech/easyflow/manuagent/entity/AgentRunEntity.java @@ -0,0 +1,86 @@ +package tech.easyflow.manuagent.entity; + +import com.mybatisflex.annotation.Column; +import com.mybatisflex.annotation.Id; +import com.mybatisflex.annotation.KeyType; +import com.mybatisflex.annotation.Table; +import com.mybatisflex.core.keygen.KeyGenerators; +import java.time.OffsetDateTime; +import java.util.UUID; +import org.apache.ibatis.type.JdbcType; +import tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler; +import tech.easyflow.manuagent.typehandler.UuidTypeHandler; + +/** + * 映射 {@code app.agent_run} 表的 Agent 运行状态实体。 + * + *运行终态切换并不依赖 BaseMapper 的无条件更新,而由 Mapper XML 使用 + * {@code WHERE status = 'RUNNING'} 实现乐观状态机,避免停止、完成和失败并发覆盖。
+ */ +@Table(value = "agent_run", schema = "app") +public class AgentRunEntity { + + /** Run 主键。 */ + @Id(keyType = KeyType.Generator, value = KeyGenerators.uuid) + @Column(typeHandler = UuidTypeHandler.class) + private UUID id; + /** 所属项目。 */ + @Column(typeHandler = UuidTypeHandler.class) + private UUID projectId; + /** 恢复、重试场景下关联的父 Run。 */ + @Column(typeHandler = UuidTypeHandler.class) + private UUID parentRunId; + /** 本次 Run 固定使用的模型配置。 */ + @Column(typeHandler = UuidTypeHandler.class) + private UUID modelConfigId; + /** INITIAL、RESUME 或 RETRY。 */ + private String triggerType; + /** 当前运行状态。 */ + private String status; + /** 等待用户确认时保存的 Ask JSON。 */ + @Column(jdbcType = JdbcType.OTHER, typeHandler = JsonbStringTypeHandler.class) + private String pendingInterrupt; + /** 跨日志追踪标识。 */ + private String traceId; + /** 失败或中断错误码。 */ + private String errorCode; + /** 面向用户的失败信息。 */ + private String errorMessage; + /** Run 开始时间。 */ + private OffsetDateTime startedAt; + /** 进入终态或等待态的时间。 */ + private OffsetDateTime endedAt; + /** 创建时间。 */ + private OffsetDateTime createdAt; + /** 最后更新时间。 */ + private OffsetDateTime updatedAt; + + public UUID getId() { return id; } + public void setId(UUID id) { this.id = id; } + public UUID getProjectId() { return projectId; } + public void setProjectId(UUID projectId) { this.projectId = projectId; } + public UUID getParentRunId() { return parentRunId; } + public void setParentRunId(UUID parentRunId) { this.parentRunId = parentRunId; } + public UUID getModelConfigId() { return modelConfigId; } + public void setModelConfigId(UUID modelConfigId) { this.modelConfigId = modelConfigId; } + public String getTriggerType() { return triggerType; } + public void setTriggerType(String triggerType) { this.triggerType = triggerType; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public String getPendingInterrupt() { return pendingInterrupt; } + public void setPendingInterrupt(String pendingInterrupt) { this.pendingInterrupt = pendingInterrupt; } + public String getTraceId() { return traceId; } + public void setTraceId(String traceId) { this.traceId = traceId; } + public String getErrorCode() { return errorCode; } + public void setErrorCode(String errorCode) { this.errorCode = errorCode; } + public String getErrorMessage() { return errorMessage; } + public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; } + public OffsetDateTime getStartedAt() { return startedAt; } + public void setStartedAt(OffsetDateTime startedAt) { this.startedAt = startedAt; } + public OffsetDateTime getEndedAt() { return endedAt; } + public void setEndedAt(OffsetDateTime endedAt) { this.endedAt = endedAt; } + public OffsetDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } + public OffsetDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/server/src/main/java/tech/easyflow/manuagent/entity/AppUserEntity.java b/server/src/main/java/tech/easyflow/manuagent/entity/AppUserEntity.java new file mode 100644 index 0000000..bcbd5ce --- /dev/null +++ b/server/src/main/java/tech/easyflow/manuagent/entity/AppUserEntity.java @@ -0,0 +1,112 @@ +package tech.easyflow.manuagent.entity; + +import com.mybatisflex.annotation.Id; +import com.mybatisflex.annotation.KeyType; +import com.mybatisflex.annotation.Table; +import com.mybatisflex.annotation.Column; +import com.mybatisflex.core.keygen.KeyGenerators; +import java.time.OffsetDateTime; +import java.util.UUID; +import tech.easyflow.manuagent.typehandler.UuidTypeHandler; + +/** + * 映射 {@code app.app_user} 表的管理员用户实体。 + * + *该实体只服务于数据库持久化,不直接作为 HTTP 接口的输入或输出。UUID 主键继续由应用层生成, + * MyBatis-Flex 的 UUID 生成器只会在主键为空时补值,因此既能保留调用方已生成的 UUID,也能避免 + * 遗漏主键造成数据库约束错误。写入时使用 selective insert 保留已有 UUID, + * 其余未赋值字段仍由数据库默认值负责填充。
+ */ +@Table(value = "app_user", schema = "app") +public class AppUserEntity { + + /** 用户主键。 */ + @Id(keyType = KeyType.Generator, value = KeyGenerators.uuid) + @Column(typeHandler = UuidTypeHandler.class) + private UUID id; + + /** 登录用户名。 */ + private String username; + + /** Spring Security 使用的密码摘要。 */ + private String passwordHash; + + /** 页面展示名称。 */ + private String displayName; + + /** 账户是否允许登录。 */ + private Boolean enabled; + + /** 最近一次登录时间。 */ + private OffsetDateTime lastLoginAt; + + /** 创建时间。 */ + private OffsetDateTime createdAt; + + /** 更新时间。 */ + private OffsetDateTime updatedAt; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPasswordHash() { + return passwordHash; + } + + public void setPasswordHash(String passwordHash) { + this.passwordHash = passwordHash; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public Boolean getEnabled() { + return enabled; + } + + public void setEnabled(Boolean enabled) { + this.enabled = enabled; + } + + public OffsetDateTime getLastLoginAt() { + return lastLoginAt; + } + + public void setLastLoginAt(OffsetDateTime lastLoginAt) { + this.lastLoginAt = lastLoginAt; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/server/src/main/java/tech/easyflow/manuagent/entity/ArtifactEntity.java b/server/src/main/java/tech/easyflow/manuagent/entity/ArtifactEntity.java new file mode 100644 index 0000000..03f587f --- /dev/null +++ b/server/src/main/java/tech/easyflow/manuagent/entity/ArtifactEntity.java @@ -0,0 +1,73 @@ +package tech.easyflow.manuagent.entity; + +import com.mybatisflex.annotation.Column; +import com.mybatisflex.annotation.Id; +import com.mybatisflex.annotation.KeyType; +import com.mybatisflex.annotation.Table; +import com.mybatisflex.core.keygen.KeyGenerators; +import java.time.OffsetDateTime; +import java.util.UUID; +import tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler; +import tech.easyflow.manuagent.typehandler.UuidTypeHandler; + +/** + * 映射 {@code app.artifact} 表的最终产物实体。 + */ +@Table(value = "artifact", schema = "app") +public class ArtifactEntity { + + /** 产物主键。 */ + @Id(keyType = KeyType.Generator, value = KeyGenerators.uuid) + @Column(typeHandler = UuidTypeHandler.class) + private UUID id; + /** 所属项目。 */ + @Column(typeHandler = UuidTypeHandler.class) + private UUID projectId; + /** 生成该产物的 Agent Run。 */ + @Column(typeHandler = UuidTypeHandler.class) + private UUID runId; + /** 产物业务类型。 */ + private String kind; + /** 下载文件名。 */ + private String name; + /** 相对于项目根目录的受控路径。 */ + private String relativePath; + /** 文件 MIME 类型。 */ + private String mimeType; + /** 文件字节数。 */ + private Long sizeBytes; + /** 文件内容 SHA-256。 */ + private String sha256; + /** 业务元数据 JSON。 */ + @Column(jdbcType = org.apache.ibatis.type.JdbcType.OTHER, typeHandler = JsonbStringTypeHandler.class) + private String metadataJson; + /** 最近发布时间。 */ + private OffsetDateTime publishedAt; + /** 首次创建时间。 */ + private OffsetDateTime createdAt; + + public UUID getId() { return id; } + public void setId(UUID id) { this.id = id; } + public UUID getProjectId() { return projectId; } + public void setProjectId(UUID projectId) { this.projectId = projectId; } + public UUID getRunId() { return runId; } + public void setRunId(UUID runId) { this.runId = runId; } + public String getKind() { return kind; } + public void setKind(String kind) { this.kind = kind; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getRelativePath() { return relativePath; } + public void setRelativePath(String relativePath) { this.relativePath = relativePath; } + public String getMimeType() { return mimeType; } + public void setMimeType(String mimeType) { this.mimeType = mimeType; } + public Long getSizeBytes() { return sizeBytes; } + public void setSizeBytes(Long sizeBytes) { this.sizeBytes = sizeBytes; } + public String getSha256() { return sha256; } + public void setSha256(String sha256) { this.sha256 = sha256; } + public String getMetadataJson() { return metadataJson; } + public void setMetadataJson(String metadataJson) { this.metadataJson = metadataJson; } + public OffsetDateTime getPublishedAt() { return publishedAt; } + public void setPublishedAt(OffsetDateTime publishedAt) { this.publishedAt = publishedAt; } + public OffsetDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/server/src/main/java/tech/easyflow/manuagent/entity/ModelAssignmentEntity.java b/server/src/main/java/tech/easyflow/manuagent/entity/ModelAssignmentEntity.java new file mode 100644 index 0000000..455b271 --- /dev/null +++ b/server/src/main/java/tech/easyflow/manuagent/entity/ModelAssignmentEntity.java @@ -0,0 +1,37 @@ +package tech.easyflow.manuagent.entity; + +import com.mybatisflex.annotation.Column; +import com.mybatisflex.annotation.Id; +import com.mybatisflex.annotation.KeyType; +import com.mybatisflex.annotation.Table; +import java.time.OffsetDateTime; +import java.util.UUID; +import tech.easyflow.manuagent.typehandler.UuidTypeHandler; + +/** + * 映射 {@code app.model_assignment} 表的 Agent 角色模型分配实体。 + */ +@Table(value = "model_assignment", schema = "app") +public class ModelAssignmentEntity { + + /** Agent 角色,同时也是业务主键。 */ + @Id(keyType = KeyType.None) + private String role; + /** 分配的模型配置。 */ + @Column(typeHandler = UuidTypeHandler.class) + private UUID modelConfigId; + /** 执行分配的用户。 */ + @Column(typeHandler = UuidTypeHandler.class) + private UUID assignedBy; + /** 最近更新时间。 */ + private OffsetDateTime updatedAt; + + public String getRole() { return role; } + public void setRole(String role) { this.role = role; } + public UUID getModelConfigId() { return modelConfigId; } + public void setModelConfigId(UUID modelConfigId) { this.modelConfigId = modelConfigId; } + public UUID getAssignedBy() { return assignedBy; } + public void setAssignedBy(UUID assignedBy) { this.assignedBy = assignedBy; } + public OffsetDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/server/src/main/java/tech/easyflow/manuagent/entity/ModelConfigEntity.java b/server/src/main/java/tech/easyflow/manuagent/entity/ModelConfigEntity.java new file mode 100644 index 0000000..a1421ee --- /dev/null +++ b/server/src/main/java/tech/easyflow/manuagent/entity/ModelConfigEntity.java @@ -0,0 +1,89 @@ +package tech.easyflow.manuagent.entity; + +import com.mybatisflex.annotation.Column; +import com.mybatisflex.annotation.Id; +import com.mybatisflex.annotation.KeyType; +import com.mybatisflex.annotation.Table; +import com.mybatisflex.core.keygen.KeyGenerators; +import java.time.OffsetDateTime; +import java.util.UUID; +import org.apache.ibatis.type.JdbcType; +import tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler; +import tech.easyflow.manuagent.typehandler.UuidTypeHandler; + +/** + * 映射 {@code app.model_config} 表的模型配置与加密密钥实体。 + * + *密钥字段始终保存 AES-GCM 密文;实体只在服务内部使用,严禁直接作为接口响应。
+ */ +@Table(value = "model_config", schema = "app") +public class ModelConfigEntity { + + /** 模型配置主键。 */ + @Id(keyType = KeyType.Generator, value = KeyGenerators.uuid) + @Column(typeHandler = UuidTypeHandler.class) + private UUID id; + /** 配置名称。 */ + private String name; + /** 模型服务商类型。 */ + private String provider; + /** OpenAI 兼容 API 根地址。 */ + private String baseUrl; + /** 上游模型标识。 */ + private String modelId; + /** AES-GCM 加密后的 API Key。 */ + private byte[] apiKeyCiphertext; + /** 仅用于界面展示的密钥尾号。 */ + private String apiKeyHint; + /** 密钥加密格式版本。 */ + private Short keyVersion; + /** 高级请求配置 JSON。 */ + @Column(jdbcType = JdbcType.OTHER, typeHandler = JsonbStringTypeHandler.class) + private String configJson; + /** 模型能力 JSON。 */ + @Column(jdbcType = JdbcType.OTHER, typeHandler = JsonbStringTypeHandler.class) + private String capabilitiesJson; + /** 是否启用。 */ + private Boolean enabled; + /** 是否为全局默认模型。 */ + @Column("is_default") + private Boolean defaultModel; + /** 创建用户。 */ + @Column(typeHandler = UuidTypeHandler.class) + private UUID createdBy; + /** 创建时间。 */ + private OffsetDateTime createdAt; + /** 更新时间。 */ + private OffsetDateTime updatedAt; + + public UUID getId() { return id; } + public void setId(UUID id) { this.id = id; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getProvider() { return provider; } + public void setProvider(String provider) { this.provider = provider; } + public String getBaseUrl() { return baseUrl; } + public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; } + public String getModelId() { return modelId; } + public void setModelId(String modelId) { this.modelId = modelId; } + public byte[] getApiKeyCiphertext() { return apiKeyCiphertext; } + public void setApiKeyCiphertext(byte[] apiKeyCiphertext) { this.apiKeyCiphertext = apiKeyCiphertext; } + public String getApiKeyHint() { return apiKeyHint; } + public void setApiKeyHint(String apiKeyHint) { this.apiKeyHint = apiKeyHint; } + public Short getKeyVersion() { return keyVersion; } + public void setKeyVersion(Short keyVersion) { this.keyVersion = keyVersion; } + public String getConfigJson() { return configJson; } + public void setConfigJson(String configJson) { this.configJson = configJson; } + public String getCapabilitiesJson() { return capabilitiesJson; } + public void setCapabilitiesJson(String capabilitiesJson) { this.capabilitiesJson = capabilitiesJson; } + public Boolean getEnabled() { return enabled; } + public void setEnabled(Boolean enabled) { this.enabled = enabled; } + public Boolean getDefaultModel() { return defaultModel; } + public void setDefaultModel(Boolean defaultModel) { this.defaultModel = defaultModel; } + public UUID getCreatedBy() { return createdBy; } + public void setCreatedBy(UUID createdBy) { this.createdBy = createdBy; } + public OffsetDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } + public OffsetDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/server/src/main/java/tech/easyflow/manuagent/entity/ProjectEntity.java b/server/src/main/java/tech/easyflow/manuagent/entity/ProjectEntity.java new file mode 100644 index 0000000..47a379a --- /dev/null +++ b/server/src/main/java/tech/easyflow/manuagent/entity/ProjectEntity.java @@ -0,0 +1,62 @@ +package tech.easyflow.manuagent.entity; + +import com.mybatisflex.annotation.Column; +import com.mybatisflex.annotation.Id; +import com.mybatisflex.annotation.KeyType; +import com.mybatisflex.annotation.Table; +import com.mybatisflex.core.keygen.KeyGenerators; +import java.time.OffsetDateTime; +import java.util.UUID; +import tech.easyflow.manuagent.typehandler.UuidTypeHandler; + +/** + * 映射 {@code app.project} 表的企业申报项目实体。 + */ +@Table(value = "project", schema = "app") +public class ProjectEntity { + + /** 项目主键。 */ + @Id(keyType = KeyType.Generator, value = KeyGenerators.uuid) + @Column(typeHandler = UuidTypeHandler.class) + private UUID id; + /** 企业名称。 */ + private String companyName; + /** 项目显示名称。 */ + private String projectName; + /** AgentScope/AG-UI 使用的线程标识。 */ + private String aguiThreadId; + /** 申报等级。 */ + private String applicationLevel; + /** 当前业务阶段。 */ + private String status; + /** 创建用户。 */ + @Column(typeHandler = UuidTypeHandler.class) + private UUID createdBy; + /** 业务版本号。 */ + private Long version; + /** 创建时间。 */ + private OffsetDateTime createdAt; + /** 更新时间。 */ + private OffsetDateTime updatedAt; + + public UUID getId() { return id; } + public void setId(UUID id) { this.id = id; } + public String getCompanyName() { return companyName; } + public void setCompanyName(String companyName) { this.companyName = companyName; } + public String getProjectName() { return projectName; } + public void setProjectName(String projectName) { this.projectName = projectName; } + public String getAguiThreadId() { return aguiThreadId; } + public void setAguiThreadId(String aguiThreadId) { this.aguiThreadId = aguiThreadId; } + public String getApplicationLevel() { return applicationLevel; } + public void setApplicationLevel(String applicationLevel) { this.applicationLevel = applicationLevel; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public UUID getCreatedBy() { return createdBy; } + public void setCreatedBy(UUID createdBy) { this.createdBy = createdBy; } + public Long getVersion() { return version; } + public void setVersion(Long version) { this.version = version; } + public OffsetDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } + public OffsetDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/server/src/main/java/tech/easyflow/manuagent/entity/ProjectFileEntity.java b/server/src/main/java/tech/easyflow/manuagent/entity/ProjectFileEntity.java new file mode 100644 index 0000000..39c5f38 --- /dev/null +++ b/server/src/main/java/tech/easyflow/manuagent/entity/ProjectFileEntity.java @@ -0,0 +1,82 @@ +package tech.easyflow.manuagent.entity; + +import com.mybatisflex.annotation.Column; +import com.mybatisflex.annotation.Id; +import com.mybatisflex.annotation.KeyType; +import com.mybatisflex.annotation.Table; +import com.mybatisflex.core.keygen.KeyGenerators; +import java.time.OffsetDateTime; +import java.util.UUID; +import tech.easyflow.manuagent.typehandler.UuidTypeHandler; + +/** + * 映射 {@code app.project_file} 表的项目材料实体。 + * + *实体保存文件的受控相对路径、完整性摘要及软删除状态;真实文件仍由 + * {@code ProjectFileService} 在项目工作区内管理。
+ */ +@Table(value = "project_file", schema = "app") +public class ProjectFileEntity { + + /** 文件主键。 */ + @Id(keyType = KeyType.Generator, value = KeyGenerators.uuid) + @Column(typeHandler = UuidTypeHandler.class) + private UUID id; + /** 所属项目主键。 */ + @Column(typeHandler = UuidTypeHandler.class) + private UUID projectId; + /** 用户上传时的原始文件名。 */ + private String originalName; + /** 工作区内实际保存的文件名。 */ + private String storedName; + /** 相对于项目根目录的受控路径。 */ + private String relativePath; + /** 内容检测得到的 MIME 类型。 */ + private String mimeType; + /** 小写文件扩展名。 */ + private String extension; + /** 文件字节数。 */ + private Long sizeBytes; + /** 文件内容 SHA-256。 */ + private String sha256; + /** 材料处理状态。 */ + private String status; + /** 上传用户主键。 */ + @Column(typeHandler = UuidTypeHandler.class) + private UUID uploadedBy; + /** 软删除时间,空值表示有效。 */ + private OffsetDateTime deletedAt; + /** 创建时间。 */ + private OffsetDateTime createdAt; + /** 更新时间。 */ + private OffsetDateTime updatedAt; + + public UUID getId() { return id; } + public void setId(UUID id) { this.id = id; } + public UUID getProjectId() { return projectId; } + public void setProjectId(UUID projectId) { this.projectId = projectId; } + public String getOriginalName() { return originalName; } + public void setOriginalName(String originalName) { this.originalName = originalName; } + public String getStoredName() { return storedName; } + public void setStoredName(String storedName) { this.storedName = storedName; } + public String getRelativePath() { return relativePath; } + public void setRelativePath(String relativePath) { this.relativePath = relativePath; } + public String getMimeType() { return mimeType; } + public void setMimeType(String mimeType) { this.mimeType = mimeType; } + public String getExtension() { return extension; } + public void setExtension(String extension) { this.extension = extension; } + public Long getSizeBytes() { return sizeBytes; } + public void setSizeBytes(Long sizeBytes) { this.sizeBytes = sizeBytes; } + public String getSha256() { return sha256; } + public void setSha256(String sha256) { this.sha256 = sha256; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public UUID getUploadedBy() { return uploadedBy; } + public void setUploadedBy(UUID uploadedBy) { this.uploadedBy = uploadedBy; } + public OffsetDateTime getDeletedAt() { return deletedAt; } + public void setDeletedAt(OffsetDateTime deletedAt) { this.deletedAt = deletedAt; } + public OffsetDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } + public OffsetDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/server/src/main/java/tech/easyflow/manuagent/entity/ProjectPlanEntity.java b/server/src/main/java/tech/easyflow/manuagent/entity/ProjectPlanEntity.java new file mode 100644 index 0000000..f9b8a16 --- /dev/null +++ b/server/src/main/java/tech/easyflow/manuagent/entity/ProjectPlanEntity.java @@ -0,0 +1,67 @@ +package tech.easyflow.manuagent.entity; + +import com.mybatisflex.annotation.Column; +import com.mybatisflex.annotation.Id; +import com.mybatisflex.annotation.KeyType; +import com.mybatisflex.annotation.Table; +import com.mybatisflex.core.keygen.KeyGenerators; +import java.time.OffsetDateTime; +import java.util.UUID; +import org.apache.ibatis.type.JdbcType; +import tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler; +import tech.easyflow.manuagent.typehandler.UuidTypeHandler; + +/** + * 映射 {@code app.project_plan} 表的不可变规划版本实体。 + */ +@Table(value = "project_plan", schema = "app") +public class ProjectPlanEntity { + + /** 规划主键。 */ + @Id(keyType = KeyType.Generator, value = KeyGenerators.uuid) + @Column(typeHandler = UuidTypeHandler.class) + private UUID id; + /** 所属项目。 */ + @Column(typeHandler = UuidTypeHandler.class) + private UUID projectId; + /** 项目内递增版本号。 */ + private Integer planVersion; + /** 草稿、已确认或已取代状态。 */ + private String status; + /** 完整规划 JSON。 */ + @Column(jdbcType = JdbcType.OTHER, typeHandler = JsonbStringTypeHandler.class) + private String planJson; + /** 创建用户。 */ + @Column(typeHandler = UuidTypeHandler.class) + private UUID createdBy; + /** 确认用户。 */ + @Column(typeHandler = UuidTypeHandler.class) + private UUID confirmedBy; + /** 确认时间。 */ + private OffsetDateTime confirmedAt; + /** 创建时间。 */ + private OffsetDateTime createdAt; + /** 更新时间。 */ + private OffsetDateTime updatedAt; + + public UUID getId() { return id; } + public void setId(UUID id) { this.id = id; } + public UUID getProjectId() { return projectId; } + public void setProjectId(UUID projectId) { this.projectId = projectId; } + public Integer getPlanVersion() { return planVersion; } + public void setPlanVersion(Integer planVersion) { this.planVersion = planVersion; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public String getPlanJson() { return planJson; } + public void setPlanJson(String planJson) { this.planJson = planJson; } + public UUID getCreatedBy() { return createdBy; } + public void setCreatedBy(UUID createdBy) { this.createdBy = createdBy; } + public UUID getConfirmedBy() { return confirmedBy; } + public void setConfirmedBy(UUID confirmedBy) { this.confirmedBy = confirmedBy; } + public OffsetDateTime getConfirmedAt() { return confirmedAt; } + public void setConfirmedAt(OffsetDateTime confirmedAt) { this.confirmedAt = confirmedAt; } + public OffsetDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } + public OffsetDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/server/src/main/java/tech/easyflow/manuagent/entity/SkillConfigEntity.java b/server/src/main/java/tech/easyflow/manuagent/entity/SkillConfigEntity.java new file mode 100644 index 0000000..369c177 --- /dev/null +++ b/server/src/main/java/tech/easyflow/manuagent/entity/SkillConfigEntity.java @@ -0,0 +1,66 @@ +package tech.easyflow.manuagent.entity; + +import com.mybatisflex.annotation.Column; +import com.mybatisflex.annotation.Id; +import com.mybatisflex.annotation.KeyType; +import com.mybatisflex.annotation.Table; +import java.time.OffsetDateTime; +import java.util.UUID; +import tech.easyflow.manuagent.typehandler.UuidTypeHandler; + +/** + * 映射应用自管的 {@code app.skill_config} 表。 + * + *Skill 正文和资源不属于该实体,它们继续由 AgentScope 的 PostgreSQL repository 管理。
+ */ +@Table(value = "skill_config", schema = "app") +public class SkillConfigEntity { + + /** Skill 标准名称,同时引用 AgentScope Skill 主表。 */ + @Id(keyType = KeyType.None) + private String skillName; + /** Skill 包版本。 */ + private String version; + /** BUILTIN 或 IMPORTED。 */ + private String sourceType; + /** 是否允许 Agent 使用。 */ + private Boolean enabled; + /** 是否禁止从界面编辑内容。 */ + private Boolean readOnly; + /** Skill 包内容摘要。 */ + private String checksum; + /** VALID 或 INVALID。 */ + private String validationStatus; + /** 校验失败说明。 */ + private String validationMessage; + /** 导入用户。 */ + @Column(typeHandler = UuidTypeHandler.class) + private UUID importedBy; + /** 创建时间。 */ + private OffsetDateTime createdAt; + /** 更新时间。 */ + private OffsetDateTime updatedAt; + + public String getSkillName() { return skillName; } + public void setSkillName(String skillName) { this.skillName = skillName; } + public String getVersion() { return version; } + public void setVersion(String version) { this.version = version; } + public String getSourceType() { return sourceType; } + public void setSourceType(String sourceType) { this.sourceType = sourceType; } + public Boolean getEnabled() { return enabled; } + public void setEnabled(Boolean enabled) { this.enabled = enabled; } + public Boolean getReadOnly() { return readOnly; } + public void setReadOnly(Boolean readOnly) { this.readOnly = readOnly; } + public String getChecksum() { return checksum; } + public void setChecksum(String checksum) { this.checksum = checksum; } + public String getValidationStatus() { return validationStatus; } + public void setValidationStatus(String validationStatus) { this.validationStatus = validationStatus; } + public String getValidationMessage() { return validationMessage; } + public void setValidationMessage(String validationMessage) { this.validationMessage = validationMessage; } + public UUID getImportedBy() { return importedBy; } + public void setImportedBy(UUID importedBy) { this.importedBy = importedBy; } + public OffsetDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } + public OffsetDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/server/src/main/java/tech/easyflow/manuagent/mapper/AgentEventMapper.java b/server/src/main/java/tech/easyflow/manuagent/mapper/AgentEventMapper.java new file mode 100644 index 0000000..82f255c --- /dev/null +++ b/server/src/main/java/tech/easyflow/manuagent/mapper/AgentEventMapper.java @@ -0,0 +1,26 @@ +package tech.easyflow.manuagent.mapper; + +import com.mybatisflex.core.BaseMapper; +import java.util.UUID; +import org.apache.ibatis.annotations.Param; +import tech.easyflow.manuagent.entity.AgentEventEntity; + +/** + * 提供 Agent 事件写入、游标回放及运行恢复所需的持久化能力。 + */ +public interface AgentEventMapper extends BaseMapper用户表只有简单单表操作,因此直接使用 {@link BaseMapper} 和 Lambda QueryWrapper, + * 不额外维护 Mapper XML。
+ */ +public interface AppUserMapper extends BaseMapper普通查询和条件更新继续复用 {@link BaseMapper};包含 PostgreSQL JSONB 参数的新增、更新 + * 使用 XML 显式声明 TypeHandler,避免写入行为依赖 MyBatis-Flex 全局表元数据的初始化顺序。
+ */ +public interface ModelConfigMapper extends BaseMapper普通列表、保存结果和默认模型切换只需要展示字段,因此明确排除 API Key 密文、 + * 密钥版本和创建人等内部字段。只有模型连接和 Agent 创建路径可以读取密文。
+ * + * @return 只包含模型接口展示字段的查询构造器 + */ + @SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。 + private static QueryWrapper modelViewQuery() { + return QueryWrapper.create().select( + ModelConfigEntity::getId, + ModelConfigEntity::getName, + ModelConfigEntity::getProvider, + ModelConfigEntity::getBaseUrl, + ModelConfigEntity::getModelId, + ModelConfigEntity::getApiKeyHint, + ModelConfigEntity::getConfigJson, + ModelConfigEntity::getCapabilitiesJson, + ModelConfigEntity::getEnabled, + ModelConfigEntity::getDefaultModel, + ModelConfigEntity::getUpdatedAt); } private String json(Object value) { @@ -374,12 +426,6 @@ public class ModelService implements ApplicationRunner { return "••••" + key.substring(Math.max(0, key.length() - 4)); } - private static final String MODEL_SELECT = """ - SELECT id, name, provider, base_url, model_id, api_key_hint, config_json, - capabilities_json, enabled, is_default, updated_at - FROM app.model_config - """; - /** * 模型编辑输入。 * diff --git a/server/src/main/java/tech/easyflow/manuagent/project/ProjectFileService.java b/server/src/main/java/tech/easyflow/manuagent/project/ProjectFileService.java index 46c4b9d..c4bb2a5 100644 --- a/server/src/main/java/tech/easyflow/manuagent/project/ProjectFileService.java +++ b/server/src/main/java/tech/easyflow/manuagent/project/ProjectFileService.java @@ -1,8 +1,11 @@ package tech.easyflow.manuagent.project; +import com.mybatisflex.core.query.QueryWrapper; import tech.easyflow.manuagent.auth.UserService; import tech.easyflow.manuagent.common.ApiException; import tech.easyflow.manuagent.config.AppProperties; +import tech.easyflow.manuagent.entity.ProjectFileEntity; +import tech.easyflow.manuagent.mapper.ProjectFileMapper; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; @@ -24,7 +27,6 @@ import org.apache.tika.Tika; import org.springframework.core.io.Resource; import org.springframework.core.io.UrlResource; import org.springframework.http.HttpStatus; -import org.springframework.jdbc.core.simple.JdbcClient; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; @@ -39,7 +41,7 @@ public class ProjectFileService { "pdf", "docx", "xls", "xlsx", "pptx", "csv", "txt", "md", "png", "jpg", "jpeg", "webp", "vsdx", "dwg"); - private final JdbcClient jdbc; + private final ProjectFileMapper fileMapper; private final UserService userService; private final ProjectService projectService; private final Path dataRoot; @@ -48,17 +50,17 @@ public class ProjectFileService { /** * 创建材料服务。 * - * @param jdbc JDBC 客户端 + * @param fileMapper 项目材料 Mapper * @param userService 用户服务 * @param projectService 项目服务 * @param properties 应用配置 */ public ProjectFileService( - JdbcClient jdbc, + ProjectFileMapper fileMapper, UserService userService, ProjectService projectService, AppProperties properties) { - this.jdbc = jdbc; + this.fileMapper = fileMapper; this.userService = userService; this.projectService = projectService; this.dataRoot = properties.dataRoot().toAbsolutePath().normalize(); @@ -142,24 +144,19 @@ public class ProjectFileService { Files.move(temporary, target); moved = true; UUID userId = userService.requireUserId(principal.getName()); - 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, :originalName, :storedName, :relativePath, :mimeType, - :extension, :sizeBytes, :sha256, :userId) - """) - .param("id", fileId) - .param("projectId", projectId) - .param("originalName", originalName) - .param("storedName", target.getFileName().toString()) - .param("relativePath", workspacePath) - .param("mimeType", mime) - .param("extension", extension) - .param("sizeBytes", Files.size(target)) - .param("sha256", HexFormat.of().formatHex(digest.digest())) - .param("userId", userId) - .update(); + // 数据库只保存受控路径和摘要;selective insert 继续使用状态、时间字段的数据库默认值。 + ProjectFileEntity entity = new ProjectFileEntity(); + entity.setId(fileId); + entity.setProjectId(projectId); + entity.setOriginalName(originalName); + entity.setStoredName(target.getFileName().toString()); + entity.setRelativePath(workspacePath); + entity.setMimeType(mime); + entity.setExtension(extension); + entity.setSizeBytes(Files.size(target)); + entity.setSha256(HexFormat.of().formatHex(digest.digest())); + entity.setUploadedBy(userId); + fileMapper.insertSelective(entity); return require(fileId); } catch (FileAlreadyExistsException exception) { cleanupFailedUpload(exception, temporary); @@ -197,16 +194,13 @@ public class ProjectFileService { */ public List该投影与迁移前 JDBC 列表和单条查询的显式字段保持一致,仅排除存储文件名、 + * 文件摘要、上传人和更新时间等当前接口不需要的内部列。查询条件和业务判断仍由 + * 调用方追加,因此本方法只承担 ORM 查询字段收敛,不改变任何业务语义。
+ * + * @return 只包含文件接口视图字段的查询构造器 + */ + @SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。 + private static QueryWrapper fileViewQuery() { + return QueryWrapper.create().select( + ProjectFileEntity::getId, + ProjectFileEntity::getProjectId, + ProjectFileEntity::getOriginalName, + ProjectFileEntity::getRelativePath, + ProjectFileEntity::getMimeType, + ProjectFileEntity::getExtension, + ProjectFileEntity::getSizeBytes, + ProjectFileEntity::getStatus, + ProjectFileEntity::getCreatedAt); + } + + /** + * 将持久化实体转换为稳定的接口视图,避免把数据库字段直接暴露给控制器。 + * + * @param entity 项目材料实体 + * @return 文件接口视图 + */ + private static FileView toFileView(ProjectFileEntity entity) { return new FileView( - rs.getObject("id", UUID.class), - rs.getObject("project_id", UUID.class), - rs.getString("original_name"), - rs.getString("relative_path"), - rs.getString("mime_type"), - rs.getString("extension"), - rs.getLong("size_bytes"), - rs.getString("status"), - rs.getObject("created_at", OffsetDateTime.class)); + entity.getId(), + entity.getProjectId(), + entity.getOriginalName(), + entity.getRelativePath(), + entity.getMimeType(), + entity.getExtension(), + entity.getSizeBytes() == null ? 0L : entity.getSizeBytes(), + entity.getStatus(), + entity.getCreatedAt()); } private String safeName(String originalName) { diff --git a/server/src/main/java/tech/easyflow/manuagent/project/ProjectService.java b/server/src/main/java/tech/easyflow/manuagent/project/ProjectService.java index c7d7e24..bece554 100644 --- a/server/src/main/java/tech/easyflow/manuagent/project/ProjectService.java +++ b/server/src/main/java/tech/easyflow/manuagent/project/ProjectService.java @@ -1,7 +1,12 @@ package tech.easyflow.manuagent.project; +import com.mybatisflex.core.query.QueryWrapper; import tech.easyflow.manuagent.auth.UserService; import tech.easyflow.manuagent.common.ApiException; +import tech.easyflow.manuagent.entity.ProjectEntity; +import tech.easyflow.manuagent.entity.ProjectPlanEntity; +import tech.easyflow.manuagent.mapper.ProjectMapper; +import tech.easyflow.manuagent.mapper.ProjectPlanMapper; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -10,7 +15,6 @@ import java.time.OffsetDateTime; import java.util.List; import java.util.UUID; import org.springframework.http.HttpStatus; -import org.springframework.jdbc.core.simple.JdbcClient; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -20,19 +24,26 @@ import org.springframework.transaction.annotation.Transactional; @Service public class ProjectService { - private final JdbcClient jdbc; + private final ProjectMapper projectMapper; + private final ProjectPlanMapper planMapper; private final UserService userService; private final ObjectMapper objectMapper; /** * 创建项目服务。 * - * @param jdbc JDBC 客户端 + * @param projectMapper 项目 Mapper + * @param planMapper 规划版本 Mapper * @param userService 用户服务 * @param objectMapper JSON 映射器 */ - public ProjectService(JdbcClient jdbc, UserService userService, ObjectMapper objectMapper) { - this.jdbc = jdbc; + public ProjectService( + ProjectMapper projectMapper, + ProjectPlanMapper planMapper, + UserService userService, + ObjectMapper objectMapper) { + this.projectMapper = projectMapper; + this.planMapper = planMapper; this.userService = userService; this.objectMapper = objectMapper; } @@ -51,18 +62,14 @@ public class ProjectService { UUID id = UUID.randomUUID(); UUID userId = userService.requireUserId(principal.getName()); String threadId = "project-" + id; - jdbc.sql(""" - INSERT INTO app.project( - id, company_name, project_name, agui_thread_id, application_level, created_by) - VALUES (:id, :companyName, :projectName, :threadId, :level, :userId) - """) - .param("id", id) - .param("companyName", companyName.trim()) - .param("projectName", companyName.trim()) - .param("threadId", threadId) - .param("level", level) - .param("userId", userId) - .update(); + ProjectEntity entity = new ProjectEntity(); + entity.setId(id); + entity.setCompanyName(companyName.trim()); + entity.setProjectName(companyName.trim()); + entity.setAguiThreadId(threadId); + entity.setApplicationLevel(level); + entity.setCreatedBy(userId); + projectMapper.insertSelective(entity); return require(id); } @@ -72,9 +79,10 @@ public class ProjectService { * @return 按更新时间倒序的项目 */ public List字段集合与迁移前 JDBC 查询保持一致,创建人只参与写入和审计,不属于当前项目接口响应, + * 因而不在普通列表和单条读取时加载。调用方继续负责追加排序或主键条件。
+ * + * @return 只包含项目接口视图字段的查询构造器 + */ + @SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。 + private static QueryWrapper projectViewQuery() { + return QueryWrapper.create().select( + ProjectEntity::getId, + ProjectEntity::getCompanyName, + ProjectEntity::getProjectName, + ProjectEntity::getAguiThreadId, + ProjectEntity::getApplicationLevel, + ProjectEntity::getStatus, + ProjectEntity::getVersion, + ProjectEntity::getCreatedAt, + ProjectEntity::getUpdatedAt); } private String normalizeLevel(String level) { @@ -272,12 +251,6 @@ public class ProjectService { return value; } - private static final String PROJECT_SELECT = """ - SELECT id, company_name, project_name, agui_thread_id, application_level, status, - version, created_at, updated_at - FROM app.project - """; - /** * 项目视图。 * diff --git a/server/src/main/java/tech/easyflow/manuagent/skill/SkillService.java b/server/src/main/java/tech/easyflow/manuagent/skill/SkillService.java index 24d7fe3..2c9c450 100644 --- a/server/src/main/java/tech/easyflow/manuagent/skill/SkillService.java +++ b/server/src/main/java/tech/easyflow/manuagent/skill/SkillService.java @@ -1,8 +1,12 @@ package tech.easyflow.manuagent.skill; +import com.mybatisflex.core.query.QueryWrapper; import tech.easyflow.manuagent.auth.UserService; import tech.easyflow.manuagent.common.ApiException; import tech.easyflow.manuagent.config.AppProperties; +import tech.easyflow.manuagent.entity.SkillConfigEntity; +import tech.easyflow.manuagent.mapper.SkillConfigMapper; +import tech.easyflow.manuagent.mapper.SkillViewRow; import io.agentscope.core.skill.AgentSkill; import io.agentscope.core.skill.repository.postgresql.PostgresSkillRepository; import java.io.IOException; @@ -18,7 +22,6 @@ import java.util.UUID; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import org.springframework.http.HttpStatus; -import org.springframework.jdbc.core.simple.JdbcClient; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; @@ -32,7 +35,7 @@ public class SkillService { private static final int MAX_ZIP_ENTRIES = 500; private static final long MAX_UNCOMPRESSED_BYTES = 20L * 1024 * 1024; - private final JdbcClient jdbc; + private final SkillConfigMapper skillMapper; private final PostgresSkillRepository repository; private final SkillPackageReader packageReader; private final UserService userService; @@ -41,19 +44,19 @@ public class SkillService { /** * 创建 Skill 服务。 * - * @param jdbc JDBC 客户端 + * @param skillMapper 应用 Skill 配置 Mapper * @param repository AgentScope PostgreSQL 仓库 * @param packageReader Skill 包读取器 * @param userService 用户服务 * @param properties 应用配置 */ public SkillService( - JdbcClient jdbc, + SkillConfigMapper skillMapper, PostgresSkillRepository repository, SkillPackageReader packageReader, UserService userService, AppProperties properties) { - this.jdbc = jdbc; + this.skillMapper = skillMapper; this.repository = repository; this.packageReader = packageReader; this.userService = userService; @@ -66,9 +69,9 @@ public class SkillService { * @return Skill 列表 */ public List当前 PostgreSQL 阶段使用 JDBC {@link Types#OTHER} 发送 JSON 文本,使驱动按目标列类型完成绑定, + * 避免在业务 SQL 中重复书写字符串拼接或手工创建驱动专有对象。未来适配国产数据库时,只需替换 + * 该类型处理器或按数据库方言提供对应实现,实体和服务层无需感知。
+ */ +@MappedTypes(String.class) +@MappedJdbcTypes(JdbcType.OTHER) +public class JsonbStringTypeHandler extends BaseTypeHandlerPostgreSQL 驱动读取 {@code uuid} 列时通常直接返回 {@link UUID},但不同驱动或查询表达式也可能 + * 返回字符串。该处理器同时兼容两种结果,并通过 {@link PreparedStatement#setObject(int, Object)} + * 保留数据库驱动对原生 UUID 类型的绑定能力,避免把 UUID 降级成易产生隐式转换的 VARCHAR。
+ */ +@MappedTypes(UUID.class) +@MappedJdbcTypes(value = JdbcType.OTHER, includeNullJdbcType = true) +public class UuidTypeHandler extends BaseTypeHandler该测试直接覆盖 MyBatis-Flex BaseMapper 插入、XML 状态更新和实体结果映射, + * 防止迁移后出现 UUID 主键未写入、JSONB Ask 丢失或终态被重复覆盖。
+ */ + @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(), new ObjectMapper()); + 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(new ObjectMapper().readTree( + store.requireWaiting(projectId, "material_check").pendingInterrupt())) + .isEqualTo(new ObjectMapper().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(new ObjectMapper().readTree(events.selectLatestMaterialResponseJson(projectId))) + .isEqualTo(new ObjectMapper().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(); + + UserService users = mock(UserService.class); + when(users.requireUserId("admin")).thenReturn(userId); + ProjectService service = new ProjectService( + projectMapper(), projectPlanMapper(), users, new ObjectMapper()); + ObjectMapper json = new ObjectMapper(); + + ProjectService.PlanView draft = service.saveDraftPlan( + projectId, json.createObjectNode().put("title", "第一版"), userId); + ProjectService.PlanView confirmed = service.confirmPlan( + projectId, + draft.id(), + json.createObjectNode().put("title", "确认版"), + () -> "admin"); + + 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(); + UserService users = mock(UserService.class); + when(users.requireUserId("admin")).thenReturn(userId); + AppProperties properties = new AppProperties( + temporaryDirectory, + temporaryDirectory.resolve("deepseek.key"), + temporaryDirectory.resolve("dashscope.key"), + "integration-master-key", + "admin", + "admin", + "https://default.example.test", + "default-model", + 131_072, + "runtime:test", + "bridge", + Duration.ofMinutes(1)); + ModelService service = new ModelService( + modelConfigMapper(), + modelAssignmentMapper(), + mock(AppUserMapper.class), + users, + new KeyCipher(properties), + properties, + new ObjectMapper()); + Map生产环境由 Spring Boot 扫描类型处理器与 mapper-locations;此处使用轻量 Bootstrap, + * 因而需要显式复现相同配置。
+ * + * @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/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/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/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/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/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/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/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/SkillConfigMapper.xml"; + try (InputStream input = Resources.getResourceAsStream(resource)) { + new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse(); + } + return bootstrap.getMapper(SkillConfigMapper.class); } } diff --git a/server/src/test/java/tech/easyflow/manuagent/MyBatisFlexContextTest.java b/server/src/test/java/tech/easyflow/manuagent/MyBatisFlexContextTest.java new file mode 100644 index 0000000..055c0d7 --- /dev/null +++ b/server/src/test/java/tech/easyflow/manuagent/MyBatisFlexContextTest.java @@ -0,0 +1,145 @@ +package tech.easyflow.manuagent; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.mybatisflex.spring.boot.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.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import tech.easyflow.manuagent.config.MyBatisFlexConfiguration; +import tech.easyflow.manuagent.entity.AgentEventEntity; +import tech.easyflow.manuagent.entity.ArtifactEntity; +import tech.easyflow.manuagent.entity.ProjectPlanEntity; +import tech.easyflow.manuagent.mapper.AgentEventMapper; +import tech.easyflow.manuagent.mapper.AgentRunMapper; +import tech.easyflow.manuagent.mapper.ArtifactMapper; +import tech.easyflow.manuagent.mapper.ModelConfigMapper; +import tech.easyflow.manuagent.mapper.ProjectPlanMapper; +import tech.easyflow.manuagent.mapper.SkillConfigMapper; + +/** + * 验证生产环境使用的 MyBatis-Flex 自动配置、Mapper 扫描和 XML 资源能够共同启动。 + * + *各 PostgreSQL 集成测试使用轻量 Bootstrap 单独加载 Mapper;本测试补充验证 Spring Boot + * 实际配置路径,防止 mapper-locations 拼写、Bean 扫描或 XML statement 命名错误只在部署时暴露。
+ */ +class MyBatisFlexContextTest { + + /** + * 加载最小 Spring 上下文并核对关键自定义 SQL statement。 + * + *测试 URL 不执行数据库连接;本用例只验证配置装配,实际 SQL 行为由 Testcontainers + * PostgreSQL 17 集成测试负责。
+ */ + @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.entity", + "mybatis-flex.type-handlers-package=tech.easyflow.manuagent.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.mapper.AgentEventMapper.insertReturning")).isTrue(); + assertThat(configuration.hasStatement( + "tech.easyflow.manuagent.mapper.AgentRunMapper.interruptRunningAfterRestart")).isTrue(); + assertThat(configuration.hasStatement( + "tech.easyflow.manuagent.mapper.ModelConfigMapper.insertModel")).isTrue(); + assertThat(configuration.hasStatement( + "tech.easyflow.manuagent.mapper.ModelConfigMapper.updateModel")).isTrue(); + assertThat(configuration.hasStatement( + "tech.easyflow.manuagent.mapper.ModelConfigMapper.clearDefault")).isTrue(); + assertThat(configuration.hasStatement( + "tech.easyflow.manuagent.mapper.ModelConfigMapper.setDefault")).isTrue(); + assertThat(configuration.hasStatement( + "tech.easyflow.manuagent.mapper.ProjectPlanMapper.confirmDraft")).isTrue(); + assertThat(configuration.hasStatement( + "tech.easyflow.manuagent.mapper.SkillConfigMapper.selectViews")).isTrue(); + assertThat(configuration.hasStatement( + "tech.easyflow.manuagent.mapper.SkillConfigMapper.updateEnabled")).isTrue(); + + // 自定义 INSERT/UPDATE ... RETURNING 也应沿用迁移前视图字段,避免回传内部列。 + String eventReturning = returningClause(configuration + .getMappedStatement("tech.easyflow.manuagent.mapper.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.mapper.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.mapper.ProjectPlanMapper.insertNextDraft") + .getBoundSql(Map.of("plan", new ProjectPlanEntity())) + .getSql()); + assertPlanViewProjection(draftReturning); + + String confirmReturning = returningClause(configuration + .getMappedStatement("tech.easyflow.manuagent.mapper.ProjectPlanMapper.confirmDraft") + .getBoundSql(Map.of()) + .getSql()); + assertPlanViewProjection(confirmReturning); + + String currentPlanSql = configuration + .getMappedStatement("tech.easyflow.manuagent.mapper.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"); + } +} diff --git a/server/src/test/java/tech/easyflow/manuagent/MyBatisFlexTransactionIntegrationTest.java b/server/src/test/java/tech/easyflow/manuagent/MyBatisFlexTransactionIntegrationTest.java new file mode 100644 index 0000000..96d5ef6 --- /dev/null +++ b/server/src/test/java/tech/easyflow/manuagent/MyBatisFlexTransactionIntegrationTest.java @@ -0,0 +1,167 @@ +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 com.fasterxml.jackson.databind.ObjectMapper; +import com.mybatisflex.spring.boot.FlexTransactionAutoConfiguration; +import com.mybatisflex.spring.boot.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.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration; +import org.springframework.boot.autoconfigure.transaction.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.auth.UserService; +import tech.easyflow.manuagent.config.AppProperties; +import tech.easyflow.manuagent.config.MyBatisFlexConfiguration; +import tech.easyflow.manuagent.mapper.AppUserMapper; +import tech.easyflow.manuagent.mapper.ModelAssignmentMapper; +import tech.easyflow.manuagent.mapper.ModelConfigMapper; +import tech.easyflow.manuagent.model.KeyCipher; +import tech.easyflow.manuagent.model.ModelService; + +/** + * 使用真实 Spring 事务代理、MyBatis-Flex Mapper 和 PostgreSQL 验证跨语句回滚。 + * + *轻量 Mapper Bootstrap 只能证明 SQL 可执行;本测试额外证明生产配置中的 Mapper 调用 + * 与 {@code @Transactional} 共享同一个数据库事务。
+ */ +@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(); + } + + /** + * 角色分配写入失败时,默认模型切换必须整体回滚,不能留下“没有默认模型”的中间状态。 + * + *目标模型刻意保持停用,用于验证 ORM 迁移没有新增原 JDBC 实现不存在的启用状态限制; + * 用户 ID 则使用数据库中不存在的值,让后续角色分配稳定触发外键错误。
+ */ + @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.entity", + "mybatis-flex.type-handlers-package=tech.easyflow.manuagent.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 disabledTargetId = 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', FALSE, FALSE) + """) + .param("currentId", currentDefaultId) + .param("currentName", "current-" + currentDefaultId) + .param("targetId", disabledTargetId) + .param("targetName", "target-" + disabledTargetId) + .update(); + UserService users = context.getBean(UserService.class); + when(users.requireUserId("admin")).thenReturn(missingUserId); + ModelService modelService = context.getBean(ModelService.class); + assertThat(context.getBeansOfType(PlatformTransactionManager.class)).hasSize(1); + assertThat(AopUtils.isAopProxy(modelService)).isTrue(); + + assertThatThrownBy(() -> modelService.setDefault(disabledTargetId, () -> "admin")) + .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", disabledTargetId) + .query(Boolean.class) + .single()).isFalse(); + }); + } + + /** + * 仅装配事务测试需要的服务边界,避免启动 Agent、文件系统和外部模型连接。 + */ + @Configuration(proxyBeanMethods = false) + static class TransactionTestConfiguration { + + /** 提供可按测试场景设置返回值的用户服务。 */ + @Bean + UserService userService() { + return mock(UserService.class); + } + + /** + * 装配真实 Mapper 驱动的模型服务;未参与本场景的密钥与应用配置依赖使用边界 Mock。 + */ + @Bean + ModelService modelService( + ModelConfigMapper modelMapper, + ModelAssignmentMapper assignmentMapper, + AppUserMapper userMapper, + UserService userService) { + return new ModelService( + modelMapper, + assignmentMapper, + userMapper, + userService, + mock(KeyCipher.class), + mock(AppProperties.class), + new ObjectMapper()); + } + } +} diff --git a/server/src/test/java/tech/easyflow/manuagent/agent/AgentEventServiceQueryTest.java b/server/src/test/java/tech/easyflow/manuagent/agent/AgentEventServiceQueryTest.java new file mode 100644 index 0000000..f92019c --- /dev/null +++ b/server/src/test/java/tech/easyflow/manuagent/agent/AgentEventServiceQueryTest.java @@ -0,0 +1,45 @@ +package tech.easyflow.manuagent.agent; + +import static org.assertj.core.api.Assertions.assertThat; +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 com.fasterxml.jackson.databind.ObjectMapper; +import com.mybatisflex.core.query.QueryWrapper; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import tech.easyflow.manuagent.entity.AgentEventEntity; +import tech.easyflow.manuagent.mapper.AgentEventMapper; + +/** + * 验证 Agent 事件回放查询在 ORM 迁移后保持原 JDBC 字段和游标语义。 + */ +class AgentEventServiceQueryTest { + + /** + * 事件回放只读取响应所需字段,不加载仅用于外部追踪的事件标识。 + */ + @Test + void shouldSelectOnlyEventViewColumnsWhenListingAfterCursor() { + AgentEventMapper mapper = mock(AgentEventMapper.class); + AgentEventEntity event = new AgentEventEntity(); + event.setId(1L); + event.setProjectId(UUID.randomUUID()); + event.setPayloadJson("{}"); + when(mapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of(event)); + AgentEventService service = new AgentEventService(mapper, new ObjectMapper()); + + service.listAfter(event.getProjectId(), 0L, 100); + + ArgumentCaptor