From e968a8ddc144f8ce16727f253462fa685e3f2d66 Mon Sep 17 00:00:00 2001 From: Zhu Junhao <2661664053@qq.com> Date: Mon, 31 Aug 2026 12:13:06 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=EF=BC=9A=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=20MyBatis-Flex=20=E8=BF=81=E7=A7=BB=E5=BA=94=E7=94=A8=20ORM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将应用自管表的 JdbcClient 数据访问迁移为实体、Mapper、构造器查询和必要的显式 SQL。 保留 AgentScope 自管表及原有业务语义,并补充事务、查询与数据库集成测试。 --- server/pom.xml | 6 + .../manuagent/agent/AgentEventService.java | 83 +-- .../manuagent/agent/AgentRunService.java | 49 +- .../manuagent/agent/AgentRunStore.java | 230 ++++---- .../manuagent/agent/RunRecoveryService.java | 18 +- .../manuagent/artifact/ArtifactService.java | 158 +++--- .../easyflow/manuagent/auth/UserService.java | 79 +-- .../config/MyBatisFlexConfiguration.java | 16 + .../manuagent/entity/AgentEventEntity.java | 55 ++ .../manuagent/entity/AgentRunEntity.java | 86 +++ .../manuagent/entity/AppUserEntity.java | 112 ++++ .../manuagent/entity/ArtifactEntity.java | 73 +++ .../entity/ModelAssignmentEntity.java | 37 ++ .../manuagent/entity/ModelConfigEntity.java | 89 ++++ .../manuagent/entity/ProjectEntity.java | 62 +++ .../manuagent/entity/ProjectFileEntity.java | 82 +++ .../manuagent/entity/ProjectPlanEntity.java | 67 +++ .../manuagent/entity/SkillConfigEntity.java | 66 +++ .../manuagent/mapper/AgentEventMapper.java | 26 + .../manuagent/mapper/AgentRunMapper.java | 30 ++ .../manuagent/mapper/AppUserMapper.java | 13 + .../manuagent/mapper/ArtifactMapper.java | 19 + .../mapper/ModelAssignmentMapper.java | 14 + .../manuagent/mapper/ModelConfigMapper.java | 45 ++ .../manuagent/mapper/ProjectFileMapper.java | 10 + .../manuagent/mapper/ProjectMapper.java | 33 ++ .../manuagent/mapper/ProjectPlanMapper.java | 25 + .../manuagent/mapper/SkillConfigMapper.java | 28 + .../manuagent/mapper/SkillViewRow.java | 38 ++ .../manuagent/model/ModelService.java | 330 +++++++----- .../manuagent/project/ProjectFileService.java | 150 +++--- .../manuagent/project/ProjectService.java | 225 ++++---- .../manuagent/skill/SkillService.java | 119 +++-- .../typehandler/JsonbStringTypeHandler.java | 56 ++ .../typehandler/UuidTypeHandler.java | 98 ++++ server/src/main/resources/application.yml | 10 + .../resources/mapper/AgentEventMapper.xml | 57 ++ .../main/resources/mapper/AgentRunMapper.xml | 58 +++ .../main/resources/mapper/ArtifactMapper.xml | 37 ++ .../mapper/ModelAssignmentMapper.xml | 18 + .../resources/mapper/ModelConfigMapper.xml | 63 +++ .../main/resources/mapper/ProjectMapper.xml | 42 ++ .../resources/mapper/ProjectPlanMapper.xml | 50 ++ .../resources/mapper/SkillConfigMapper.xml | 35 ++ .../DatabaseAndEventIntegrationTest.java | 490 +++++++++++++++++- .../manuagent/MyBatisFlexContextTest.java | 145 ++++++ ...MyBatisFlexTransactionIntegrationTest.java | 167 ++++++ .../agent/AgentEventServiceQueryTest.java | 45 ++ .../agent/AgentRunStoreQueryTest.java | 88 ++++ .../artifact/ArtifactServiceQueryTest.java | 69 +++ .../manuagent/auth/UserServiceTest.java | 146 ++++++ .../model/ModelServiceQueryTest.java | 76 +++ .../project/ProjectFileServiceTest.java | 50 +- .../project/ProjectServiceQueryTest.java | 127 +++++ 54 files changed, 3702 insertions(+), 698 deletions(-) create mode 100644 server/src/main/java/tech/easyflow/manuagent/config/MyBatisFlexConfiguration.java create mode 100644 server/src/main/java/tech/easyflow/manuagent/entity/AgentEventEntity.java create mode 100644 server/src/main/java/tech/easyflow/manuagent/entity/AgentRunEntity.java create mode 100644 server/src/main/java/tech/easyflow/manuagent/entity/AppUserEntity.java create mode 100644 server/src/main/java/tech/easyflow/manuagent/entity/ArtifactEntity.java create mode 100644 server/src/main/java/tech/easyflow/manuagent/entity/ModelAssignmentEntity.java create mode 100644 server/src/main/java/tech/easyflow/manuagent/entity/ModelConfigEntity.java create mode 100644 server/src/main/java/tech/easyflow/manuagent/entity/ProjectEntity.java create mode 100644 server/src/main/java/tech/easyflow/manuagent/entity/ProjectFileEntity.java create mode 100644 server/src/main/java/tech/easyflow/manuagent/entity/ProjectPlanEntity.java create mode 100644 server/src/main/java/tech/easyflow/manuagent/entity/SkillConfigEntity.java create mode 100644 server/src/main/java/tech/easyflow/manuagent/mapper/AgentEventMapper.java create mode 100644 server/src/main/java/tech/easyflow/manuagent/mapper/AgentRunMapper.java create mode 100644 server/src/main/java/tech/easyflow/manuagent/mapper/AppUserMapper.java create mode 100644 server/src/main/java/tech/easyflow/manuagent/mapper/ArtifactMapper.java create mode 100644 server/src/main/java/tech/easyflow/manuagent/mapper/ModelAssignmentMapper.java create mode 100644 server/src/main/java/tech/easyflow/manuagent/mapper/ModelConfigMapper.java create mode 100644 server/src/main/java/tech/easyflow/manuagent/mapper/ProjectFileMapper.java create mode 100644 server/src/main/java/tech/easyflow/manuagent/mapper/ProjectMapper.java create mode 100644 server/src/main/java/tech/easyflow/manuagent/mapper/ProjectPlanMapper.java create mode 100644 server/src/main/java/tech/easyflow/manuagent/mapper/SkillConfigMapper.java create mode 100644 server/src/main/java/tech/easyflow/manuagent/mapper/SkillViewRow.java create mode 100644 server/src/main/java/tech/easyflow/manuagent/typehandler/JsonbStringTypeHandler.java create mode 100644 server/src/main/java/tech/easyflow/manuagent/typehandler/UuidTypeHandler.java create mode 100644 server/src/main/resources/mapper/AgentEventMapper.xml create mode 100644 server/src/main/resources/mapper/AgentRunMapper.xml create mode 100644 server/src/main/resources/mapper/ArtifactMapper.xml create mode 100644 server/src/main/resources/mapper/ModelAssignmentMapper.xml create mode 100644 server/src/main/resources/mapper/ModelConfigMapper.xml create mode 100644 server/src/main/resources/mapper/ProjectMapper.xml create mode 100644 server/src/main/resources/mapper/ProjectPlanMapper.xml create mode 100644 server/src/main/resources/mapper/SkillConfigMapper.xml create mode 100644 server/src/test/java/tech/easyflow/manuagent/MyBatisFlexContextTest.java create mode 100644 server/src/test/java/tech/easyflow/manuagent/MyBatisFlexTransactionIntegrationTest.java create mode 100644 server/src/test/java/tech/easyflow/manuagent/agent/AgentEventServiceQueryTest.java create mode 100644 server/src/test/java/tech/easyflow/manuagent/agent/AgentRunStoreQueryTest.java create mode 100644 server/src/test/java/tech/easyflow/manuagent/artifact/ArtifactServiceQueryTest.java create mode 100644 server/src/test/java/tech/easyflow/manuagent/auth/UserServiceTest.java create mode 100644 server/src/test/java/tech/easyflow/manuagent/model/ModelServiceQueryTest.java create mode 100644 server/src/test/java/tech/easyflow/manuagent/project/ProjectServiceQueryTest.java diff --git a/server/pom.xml b/server/pom.xml index 3011530..4b2a10c 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -19,6 +19,7 @@ 21 2.0.1 + 1.11.8 3.2.3 1.21.4 @@ -40,6 +41,11 @@ org.springframework.boot spring-boot-starter-jdbc + + com.mybatis-flex + mybatis-flex-spring-boot3-starter + ${mybatis-flex.version} + org.flywaydb flyway-database-postgresql diff --git a/server/src/main/java/tech/easyflow/manuagent/agent/AgentEventService.java b/server/src/main/java/tech/easyflow/manuagent/agent/AgentEventService.java index 87e067f..b6f0fdb 100644 --- a/server/src/main/java/tech/easyflow/manuagent/agent/AgentEventService.java +++ b/server/src/main/java/tech/easyflow/manuagent/agent/AgentEventService.java @@ -3,13 +3,13 @@ package tech.easyflow.manuagent.agent; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.mybatisflex.core.query.QueryWrapper; import java.time.OffsetDateTime; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; -import org.springframework.jdbc.core.simple.JdbcClient; import org.springframework.stereotype.Service; import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; @@ -17,6 +17,8 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.publisher.Sinks; import reactor.core.scheduler.Schedulers; +import tech.easyflow.manuagent.entity.AgentEventEntity; +import tech.easyflow.manuagent.mapper.AgentEventMapper; /** * 持久化并查询项目级 AG-UI 事件。 @@ -24,18 +26,18 @@ import reactor.core.scheduler.Schedulers; @Service public class AgentEventService { - private final JdbcClient jdbc; + private final AgentEventMapper eventMapper; private final ObjectMapper objectMapper; private final Map> liveStreams = new ConcurrentHashMap<>(); /** * 创建事件服务。 * - * @param jdbc JDBC 客户端 + * @param eventMapper Agent 事件 Mapper * @param objectMapper JSON 映射器 */ - public AgentEventService(JdbcClient jdbc, ObjectMapper objectMapper) { - this.jdbc = jdbc; + public AgentEventService(AgentEventMapper eventMapper, ObjectMapper objectMapper) { + this.eventMapper = eventMapper; this.objectMapper = objectMapper; } @@ -53,18 +55,14 @@ public class AgentEventService { ObjectNode object = value.isObject() ? (ObjectNode) value : objectMapper.createObjectNode().set("value", value); - EventView event = jdbc.sql(""" - INSERT INTO app.agent_event(project_id, run_id, event_type, event_id, payload) - VALUES (:projectId, :runId, :eventType, :eventId, CAST(:payload AS jsonb)) - RETURNING id, project_id, run_id, event_type, payload, created_at - """) - .param("projectId", projectId) - .param("runId", runId) - .param("eventType", eventType) - .param("eventId", UUID.randomUUID().toString()) - .param("payload", object.toString()) - .query(this::mapEvent) - .single(); + AgentEventEntity entity = new AgentEventEntity(); + entity.setProjectId(projectId); + entity.setRunId(runId); + entity.setEventType(eventType); + entity.setEventId(UUID.randomUUID().toString()); + entity.setPayloadJson(object.toString()); + // 写入与 RETURNING 必须由同一条 SQL 完成,以原子取得数据库分配的事件游标。 + EventView event = toEventView(eventMapper.insertReturning(entity)); publishAfterCommit(event); return event; } @@ -77,19 +75,24 @@ public class AgentEventService { * @param limit 最大返回数量 * @return 有序事件 */ + @SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。 public List listAfter(UUID projectId, long afterId, int limit) { - return jdbc.sql(""" - SELECT id, project_id, run_id, event_type, payload, created_at - FROM app.agent_event - WHERE project_id = :projectId AND id > :afterId - ORDER BY id - LIMIT :limit - """) - .param("projectId", projectId) - .param("afterId", Math.max(0, afterId)) - .param("limit", Math.clamp(limit, 1, 1000)) - .query(this::mapEvent) - .list(); + QueryWrapper query = QueryWrapper.create() + .select( + AgentEventEntity::getId, + AgentEventEntity::getProjectId, + AgentEventEntity::getRunId, + AgentEventEntity::getEventType, + AgentEventEntity::getPayloadJson, + AgentEventEntity::getCreatedAt) + .where(AgentEventEntity::getProjectId).eq(projectId) + .and(AgentEventEntity::getId).gt(Math.max(0, afterId)) + .orderBy(AgentEventEntity::getId).asc() + .limit(Math.clamp(limit, 1, 1000)); + return eventMapper.selectListByQuery(query) + .stream() + .map(this::toEventView) + .toList(); } /** @@ -157,17 +160,23 @@ public class AgentEventService { } } - private EventView mapEvent(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException { + /** + * 将持久化实体转换成对外事件视图,并在边界处解析 JSONB 文本。 + * + * @param entity 数据库事件实体 + * @return 可供 REST 与事件流输出的事件 + */ + private EventView toEventView(AgentEventEntity entity) { try { return new EventView( - rs.getLong("id"), - rs.getObject("project_id", UUID.class), - rs.getObject("run_id", UUID.class), - rs.getString("event_type"), - objectMapper.readTree(rs.getString("payload")), - rs.getObject("created_at", OffsetDateTime.class)); + entity.getId(), + entity.getProjectId(), + entity.getRunId(), + entity.getEventType(), + objectMapper.readTree(entity.getPayloadJson()), + entity.getCreatedAt()); } catch (com.fasterxml.jackson.core.JsonProcessingException exception) { - throw new java.sql.SQLException("Agent 事件 JSON 无法解析", exception); + throw new IllegalStateException("Agent 事件 JSON 无法解析", exception); } } diff --git a/server/src/main/java/tech/easyflow/manuagent/agent/AgentRunService.java b/server/src/main/java/tech/easyflow/manuagent/agent/AgentRunService.java index 7afc413..129b155 100644 --- a/server/src/main/java/tech/easyflow/manuagent/agent/AgentRunService.java +++ b/server/src/main/java/tech/easyflow/manuagent/agent/AgentRunService.java @@ -20,13 +20,13 @@ import java.util.concurrent.ExecutorService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; 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.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.transaction.support.TransactionTemplate; import reactor.core.publisher.Sinks; +import tech.easyflow.manuagent.mapper.AgentRunMapper; /** * 驱动材料检验、规划 Ask 和自动编写 Run。 @@ -35,7 +35,7 @@ import reactor.core.publisher.Sinks; public class AgentRunService { private static final Logger log = LoggerFactory.getLogger(AgentRunService.class); - private final JdbcClient jdbc; + private final AgentRunMapper runMapper; private final ObjectMapper objectMapper; private final AgentExecutionService executionService; private final AgentOutputService outputService; @@ -52,7 +52,7 @@ public class AgentRunService { /** * 创建 Agent Run 服务。 * - * @param jdbc JDBC 客户端 + * @param runMapper Agent Run Mapper * @param objectMapper JSON 映射器 * @param executionService Agent 执行服务 * @param outputService Agent 结构化输出服务 @@ -66,7 +66,7 @@ public class AgentRunService { * @param transactions 编程式事务模板 */ public AgentRunService( - JdbcClient jdbc, + AgentRunMapper runMapper, ObjectMapper objectMapper, AgentExecutionService executionService, AgentOutputService outputService, @@ -78,7 +78,7 @@ public class AgentRunService { ArtifactService artifactService, ExecutorService applicationExecutor, TransactionTemplate transactions) { - this.jdbc = jdbc; + this.runMapper = runMapper; this.objectMapper = objectMapper; this.executionService = executionService; this.outputService = outputService; @@ -207,15 +207,7 @@ public class AgentRunService { if (run == null || !"RUNNING".equals(run.status())) { throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_ACTIVE", "当前没有正在执行的任务"); } - int updated = jdbc.sql(""" - UPDATE app.agent_run - SET status = 'INTERRUPTED', pending_interrupt = NULL, - error_code = 'USER_STOPPED', error_message = '用户已停止运行', - ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP - WHERE id = :id AND status = 'RUNNING' - """) - .param("id", run.id()) - .update(); + int updated = runMapper.interruptRunning(run.id()); requireTerminalUpdate(updated); eventService.append(projectId, run.id(), "RUN_FINISHED", Map.of("outcome", "CANCELLED")); onCommit(() -> { @@ -473,15 +465,7 @@ public class AgentRunService { private void finishWaiting(UUID projectId, UUID runId, JsonNode interrupt) { transactions.executeWithoutResult(status -> { eventService.append(projectId, runId, "ASK_REQUESTED", interrupt); - int updated = jdbc.sql(""" - UPDATE app.agent_run - SET status = 'WAITING_INPUT', pending_interrupt = CAST(:interrupt AS jsonb), - ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP - WHERE id = :id AND status = 'RUNNING' - """) - .param("interrupt", interrupt.toString()) - .param("id", runId) - .update(); + int updated = runMapper.waitForInput(runId, interrupt.toString()); requireTerminalUpdate(updated); eventService.append(projectId, runId, "RUN_FINISHED", Map.of("outcome", "INTERRUPT")); }); @@ -575,14 +559,7 @@ public class AgentRunService { ArtifactService.ArtifactView artifact = artifactService.publishCandidate( projectId, run.id(), run.startedAt().toInstant(), metadata); eventService.append(projectId, run.id(), "ARTIFACT_PUBLISHED", artifact); - int updated = jdbc.sql(""" - UPDATE app.agent_run - SET status = 'COMPLETED', pending_interrupt = NULL, - ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP - WHERE id = :id AND status = 'RUNNING' - """) - .param("id", run.id()) - .update(); + int updated = runMapper.completeRunning(run.id()); requireTerminalUpdate(updated); projectService.updateStatus(projectId, "DELIVERED"); eventService.append(projectId, run.id(), "RUN_FINISHED", Map.of("outcome", "SUCCESS")); @@ -602,15 +579,7 @@ public class AgentRunService { : "Agent 执行失败,请稍后重试"; try { transactions.executeWithoutResult(status -> { - int updated = jdbc.sql(""" - UPDATE app.agent_run - SET status = 'FAILED', pending_interrupt = NULL, error_code = 'AGENT_RUN_FAILED', - error_message = :message, ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP - WHERE id = :id AND status = 'RUNNING' - """) - .param("message", message) - .param("id", run.id()) - .update(); + int updated = runMapper.failRunning(run.id(), message); if (updated == 1) { eventService.append(run.projectId(), run.id(), "RUN_ERROR", Map.of( "code", "AGENT_RUN_FAILED", "message", message)); diff --git a/server/src/main/java/tech/easyflow/manuagent/agent/AgentRunStore.java b/server/src/main/java/tech/easyflow/manuagent/agent/AgentRunStore.java index a18d0f7..62b9441 100644 --- a/server/src/main/java/tech/easyflow/manuagent/agent/AgentRunStore.java +++ b/server/src/main/java/tech/easyflow/manuagent/agent/AgentRunStore.java @@ -5,11 +5,15 @@ import tech.easyflow.manuagent.project.ProjectService; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import java.time.OffsetDateTime; +import com.mybatisflex.core.query.QueryWrapper; import java.util.UUID; import org.springframework.http.HttpStatus; -import org.springframework.jdbc.core.simple.JdbcClient; import org.springframework.stereotype.Service; +import tech.easyflow.manuagent.entity.AgentRunEntity; +import tech.easyflow.manuagent.entity.ModelConfigEntity; +import tech.easyflow.manuagent.mapper.AgentEventMapper; +import tech.easyflow.manuagent.mapper.AgentRunMapper; +import tech.easyflow.manuagent.mapper.ModelConfigMapper; /** * 集中读写 Agent Run 持久化状态。 @@ -17,17 +21,27 @@ import org.springframework.stereotype.Service; @Service public class AgentRunStore { - private final JdbcClient jdbc; + private final AgentRunMapper runMapper; + private final AgentEventMapper eventMapper; + private final ModelConfigMapper modelMapper; private final ObjectMapper objectMapper; /** * 创建 Run 状态存储。 * - * @param jdbc JDBC 客户端 + * @param runMapper Agent Run Mapper + * @param eventMapper Agent 事件 Mapper + * @param modelMapper 模型配置 Mapper * @param objectMapper JSON 映射器 */ - public AgentRunStore(JdbcClient jdbc, ObjectMapper objectMapper) { - this.jdbc = jdbc; + public AgentRunStore( + AgentRunMapper runMapper, + AgentEventMapper eventMapper, + ModelConfigMapper modelMapper, + ObjectMapper objectMapper) { + this.runMapper = runMapper; + this.eventMapper = eventMapper; + this.modelMapper = modelMapper; this.objectMapper = objectMapper; } @@ -39,34 +53,36 @@ public class AgentRunStore { * @param parentRunId 父 Run ID * @return 新 Run */ + @SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。 public AgentRunService.RunView create(UUID projectId, String triggerType, UUID parentRunId) { - Integer active = jdbc.sql(""" - SELECT count(*) FROM app.agent_run - WHERE project_id = :projectId AND status IN ('RUNNING', 'WAITING_INPUT') - """) - .param("projectId", projectId) - .query(Integer.class) - .single(); + QueryWrapper activeRuns = QueryWrapper.create() + .where(AgentRunEntity::getProjectId).eq(projectId) + .and(AgentRunEntity::getStatus).in("RUNNING", "WAITING_INPUT"); + long active = runMapper.selectCountByQuery(activeRuns); if (active > 0) { throw new ApiException(HttpStatus.CONFLICT, "RUN_ALREADY_ACTIVE", "项目已有正在执行或等待确认的任务"); } - UUID id = UUID.randomUUID(); - UUID modelId = jdbc.sql("SELECT id FROM app.model_config WHERE is_default AND enabled") - .query(UUID.class) - .single(); - jdbc.sql(""" - INSERT INTO app.agent_run( - id, project_id, parent_run_id, model_config_id, trigger_type, status, trace_id) - VALUES (:id, :projectId, :parentRunId, :modelId, :triggerType, 'RUNNING', :traceId) - """) - .param("id", id) - .param("projectId", projectId) - .param("parentRunId", parentRunId) - .param("modelId", modelId) - .param("triggerType", triggerType) - .param("traceId", UUID.randomUUID().toString()) - .update(); - return require(id); + QueryWrapper defaultModel = QueryWrapper.create() + .select(ModelConfigEntity::getId) + .where(ModelConfigEntity::getDefaultModel).eq(true) + .and(ModelConfigEntity::getEnabled).eq(true); + ModelConfigEntity model = modelMapper.selectOneByQuery(defaultModel); + if (model == null) { + // 迁移前的强制单条查询在该数据库不变量失效时进入统一 500 路径,不能新增 409 业务语义。 + throw new IllegalStateException("数据库中不存在已启用的默认模型"); + } + + // 应用层提前生成 Run 与追踪 ID;时间字段仍交由数据库默认值统一生成。 + AgentRunEntity entity = new AgentRunEntity(); + entity.setId(UUID.randomUUID()); + entity.setProjectId(projectId); + entity.setParentRunId(parentRunId); + entity.setModelConfigId(model.getId()); + entity.setTriggerType(triggerType); + entity.setStatus("RUNNING"); + entity.setTraceId(UUID.randomUUID().toString()); + runMapper.insertSelective(entity); + return require(entity.getId()); } /** @@ -76,11 +92,11 @@ public class AgentRunStore { * @return 最近 Run;不存在时为空 */ public AgentRunService.RunView latest(UUID projectId) { - return jdbc.sql(RUN_SELECT + " WHERE project_id = :projectId ORDER BY created_at DESC LIMIT 1") - .param("projectId", projectId) - .query(AgentRunStore::mapRun) - .optional() - .orElse(null); + QueryWrapper query = runViewQuery() + .where(AgentRunEntity::getProjectId).eq(projectId) + .orderBy(AgentRunEntity::getCreatedAt).desc() + .limit(1); + return toRunView(runMapper.selectOneByQuery(query)); } /** @@ -90,10 +106,14 @@ public class AgentRunStore { * @return Run */ public AgentRunService.RunView require(UUID id) { - return jdbc.sql(RUN_SELECT + " WHERE id = :id") - .param("id", id) - .query(AgentRunStore::mapRun) - .single(); + QueryWrapper query = runViewQuery() + .where(AgentRunEntity::getId).eq(id); + AgentRunService.RunView run = toRunView(runMapper.selectOneByQuery(query)); + if (run == null) { + // 强制读取仅用于内部已知 ID;缺失表示持久化状态异常,而不是新增的 404 业务分支。 + throw new IllegalStateException("Agent Run 不存在: " + id); + } + return run; } /** @@ -102,13 +122,7 @@ public class AgentRunStore { * @param runId Run ID */ public void completeWaiting(UUID runId) { - int updated = jdbc.sql(""" - UPDATE app.agent_run - SET status = 'COMPLETED', pending_interrupt = NULL, updated_at = CURRENT_TIMESTAMP - WHERE id = :id AND status = 'WAITING_INPUT' - """) - .param("id", runId) - .update(); + int updated = runMapper.completeWaiting(runId); if (updated != 1) { throw new ApiException(HttpStatus.CONFLICT, "ASK_ALREADY_RESPONDED", "该确认已处理,请刷新页面"); } @@ -142,10 +156,11 @@ public class AgentRunStore { * @param runId Run ID */ public void ensureRunning(UUID runId) { - String status = jdbc.sql("SELECT status FROM app.agent_run WHERE id = :id") - .param("id", runId) - .query(String.class) - .single(); + String status = status(runId); + if (status == null) { + // 保持迁移前强制单条查询对缺失记录的未预期异常语义。 + throw new IllegalStateException("Agent Run 不存在: " + runId); + } if (!"RUNNING".equals(status)) { throw new AgentExecutionService.RunInterruptedException(); } @@ -158,11 +173,7 @@ public class AgentRunStore { * @return 是否已停止 */ public boolean isInterrupted(UUID runId) { - return jdbc.sql("SELECT status = 'INTERRUPTED' FROM app.agent_run WHERE id = :id") - .param("id", runId) - .query(Boolean.class) - .optional() - .orElse(false); + return "INTERRUPTED".equals(status(runId)); } /** @@ -173,15 +184,8 @@ public class AgentRunStore { * @return 业务阶段 */ public String interruptedPhase(AgentRunService.RunView run, ProjectService.ProjectView project) { - return jdbc.sql(""" - SELECT payload ->> 'phase' FROM app.agent_event - WHERE run_id = :runId AND event_type = 'RUN_STARTED' - ORDER BY id DESC LIMIT 1 - """) - .param("runId", run.id()) - .query(String.class) - .optional() - .orElse(project.status()); + String phase = eventMapper.selectLatestStartedPhase(run.id()); + return phase == null ? project.status() : phase; } /** @@ -191,43 +195,73 @@ public class AgentRunStore { * @return 材料确认 JSON */ public JsonNode latestMaterialResponse(UUID projectId) { - return jdbc.sql(""" - SELECT payload::text FROM app.agent_event - WHERE project_id = :projectId AND event_type = 'ASK_RESPONDED' - AND jsonb_typeof(payload -> 'decisions') = 'array' - ORDER BY id DESC LIMIT 1 - """) - .param("projectId", projectId) - .query(String.class) - .optional() - .map(value -> { - try { - return objectMapper.readTree(value); - } catch (JsonProcessingException exception) { - throw new ApiException( - HttpStatus.INTERNAL_SERVER_ERROR, - "MATERIAL_RESPONSE_INVALID", - "材料确认记录无法读取"); - } - }) - .orElseGet(objectMapper::createObjectNode); + String value = eventMapper.selectLatestMaterialResponseJson(projectId); + if (value == null) { + return objectMapper.createObjectNode(); + } + try { + return objectMapper.readTree(value); + } catch (JsonProcessingException exception) { + throw new ApiException( + HttpStatus.INTERNAL_SERVER_ERROR, + "MATERIAL_RESPONSE_INVALID", + "材料确认记录无法读取"); + } } - private static AgentRunService.RunView mapRun(java.sql.ResultSet rs, int rowNum) - throws java.sql.SQLException { + /** + * 将数据库实体转换为稳定的对外 Run 视图。 + * + * @param entity Run 实体;不存在时为空 + * @return Run 视图;不存在时为空 + */ + private static AgentRunService.RunView toRunView(AgentRunEntity entity) { + if (entity == null) { + return null; + } return new AgentRunService.RunView( - rs.getObject("id", UUID.class), - rs.getObject("project_id", UUID.class), - rs.getString("trigger_type"), - rs.getString("status"), - rs.getString("pending_interrupt"), - rs.getString("error_message"), - rs.getObject("started_at", OffsetDateTime.class), - rs.getObject("ended_at", OffsetDateTime.class)); + entity.getId(), + entity.getProjectId(), + entity.getTriggerType(), + entity.getStatus(), + entity.getPendingInterrupt(), + entity.getErrorMessage(), + entity.getStartedAt(), + entity.getEndedAt()); } - private static final String RUN_SELECT = """ - SELECT id, project_id, trigger_type, status, pending_interrupt, error_message, started_at, ended_at - FROM app.agent_run - """; + /** + * 使用 BaseMapper 主键查询读取 Run 状态。 + * + * @param runId Run ID + * @return 当前状态;Run 不存在时为空 + */ + @SuppressWarnings("unchecked") // 这里只投影状态列,LambdaGetter 可变参数不会引入运行期类型风险。 + private String status(UUID runId) { + QueryWrapper query = QueryWrapper.create() + .select(AgentRunEntity::getStatus) + .where(AgentRunEntity::getId).eq(runId); + AgentRunEntity run = runMapper.selectOneByQuery(query); + return run == null ? null : run.getStatus(); + } + + /** + * 构造 Agent Run 对外视图所需的最小字段投影。 + * + *

运行视图不暴露模型配置、追踪标识和内部错误码,显式投影可避免每次轮询都读取无关列。

+ * + * @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 list(UUID projectId) { - return jdbc.sql(ARTIFACT_SELECT + " WHERE project_id = :projectId ORDER BY published_at DESC") - .param("projectId", projectId) - .query(ArtifactService::mapArtifact) - .list(); + QueryWrapper query = artifactViewQuery() + .where(ArtifactEntity::getProjectId).eq(projectId) + .orderBy(ArtifactEntity::getPublishedAt).desc(); + return artifactMapper.selectListByQuery(query).stream() + .map(ArtifactService::toArtifactView) + .toList(); } /** @@ -187,21 +176,28 @@ public class ArtifactService { * @param artifactId 产物 ID * @return 下载信息 */ + @SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。 public Download download(UUID artifactId) { - StoredArtifact artifact = jdbc.sql(""" - SELECT project_id, name, relative_path, mime_type, size_bytes, sha256 - FROM app.artifact WHERE id = :id - """) - .param("id", artifactId) - .query((rs, rowNum) -> new StoredArtifact( - rs.getObject("project_id", UUID.class), - rs.getString("name"), - rs.getString("relative_path"), - rs.getString("mime_type"), - rs.getLong("size_bytes"), - rs.getString("sha256"))) - .optional() - .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "ARTIFACT_NOT_FOUND", "产物不存在")); + QueryWrapper query = QueryWrapper.create() + .select( + ArtifactEntity::getProjectId, + ArtifactEntity::getName, + ArtifactEntity::getRelativePath, + ArtifactEntity::getMimeType, + ArtifactEntity::getSizeBytes, + ArtifactEntity::getSha256) + .where(ArtifactEntity::getId).eq(artifactId); + ArtifactEntity entity = artifactMapper.selectOneByQuery(query); + if (entity == null) { + throw new ApiException(HttpStatus.NOT_FOUND, "ARTIFACT_NOT_FOUND", "产物不存在"); + } + StoredArtifact artifact = new StoredArtifact( + entity.getProjectId(), + entity.getName(), + entity.getRelativePath(), + entity.getMimeType(), + entity.getSizeBytes() == null ? 0L : entity.getSizeBytes(), + entity.getSha256()); try { Path path = fileService.safeProjectPath(artifact.projectId(), artifact.relativePath()); Resource resource = new UrlResource(path.toUri()); @@ -238,30 +234,44 @@ public class ArtifactService { return HexFormat.of().formatHex(digest.digest()); } - private ArtifactView require(UUID id) { - return jdbc.sql(ARTIFACT_SELECT + " WHERE id = :id") - .param("id", id) - .query(ArtifactService::mapArtifact) - .optional() - .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "ARTIFACT_NOT_FOUND", "产物不存在")); - } - - private static ArtifactView mapArtifact(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException { + /** + * 将产物实体转换成接口稳定视图。 + * + * @param entity 产物实体 + * @return 产物接口视图 + */ + private static ArtifactView toArtifactView(ArtifactEntity entity) { return new ArtifactView( - rs.getObject("id", UUID.class), - rs.getObject("project_id", UUID.class), - rs.getObject("run_id", UUID.class), - rs.getString("kind"), - rs.getString("name"), - rs.getLong("size_bytes"), - rs.getString("metadata_json"), - rs.getObject("published_at", OffsetDateTime.class)); + entity.getId(), + entity.getProjectId(), + entity.getRunId(), + entity.getKind(), + entity.getName(), + entity.getSizeBytes() == null ? 0L : entity.getSizeBytes(), + entity.getMetadataJson(), + entity.getPublishedAt()); } - private static final String ARTIFACT_SELECT = """ - SELECT id, project_id, run_id, kind, name, size_bytes, metadata_json, published_at - FROM app.artifact - """; + /** + * 构造产物接口列表使用的最小字段投影。 + * + *

该字段集合与迁移前 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...) 使用泛型可变参数,调用本身类型安全。 public UUID requireUserId(String username) { - return jdbc.sql("SELECT id FROM app.app_user WHERE username = :username") - .param("username", username) - .query(UUID.class) - .optional() - .orElseThrow(() -> new ApiException(HttpStatus.UNAUTHORIZED, "USER_NOT_FOUND", "登录账户不存在")); + QueryWrapper query = QueryWrapper.create() + .select(AppUserEntity::getId) + .where(AppUserEntity::getUsername).eq(username); + AppUserEntity entity = userMapper.selectOneByQuery(query); + if (entity == null) { + throw new ApiException(HttpStatus.UNAUTHORIZED, "USER_NOT_FOUND", "登录账户不存在"); + } + return entity.getId(); } } diff --git a/server/src/main/java/tech/easyflow/manuagent/config/MyBatisFlexConfiguration.java b/server/src/main/java/tech/easyflow/manuagent/config/MyBatisFlexConfiguration.java new file mode 100644 index 0000000..751eebb --- /dev/null +++ b/server/src/main/java/tech/easyflow/manuagent/config/MyBatisFlexConfiguration.java @@ -0,0 +1,16 @@ +package tech.easyflow.manuagent.config; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +/** + * 配置 MyBatis-Flex Mapper 扫描。 + * + *

业务 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 { + + /** + * 插入事件并原子返回数据库生成的 BIGSERIAL 序号及创建时间。 + * + * @param event 待写入事件 + * @return 已持久化的完整事件 + */ + AgentEventEntity insertReturning(@Param("event") AgentEventEntity event); + + /** 查询指定 Run 最近一次 RUN_STARTED 事件中的业务阶段。 */ + String selectLatestStartedPhase(@Param("runId") UUID runId); + + /** 查询项目最近一次包含材料决策数组的 ASK_RESPONDED 事件负载。 */ + String selectLatestMaterialResponseJson(@Param("projectId") UUID projectId); +} diff --git a/server/src/main/java/tech/easyflow/manuagent/mapper/AgentRunMapper.java b/server/src/main/java/tech/easyflow/manuagent/mapper/AgentRunMapper.java new file mode 100644 index 0000000..777c253 --- /dev/null +++ b/server/src/main/java/tech/easyflow/manuagent/mapper/AgentRunMapper.java @@ -0,0 +1,30 @@ +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.AgentRunEntity; + +/** + * 提供 Agent Run 查询以及带前置状态条件的原子状态迁移。 + */ +public interface AgentRunMapper extends BaseMapper { + + /** 将等待输入的 Run 标记为已完成。 */ + int completeWaiting(@Param("runId") UUID runId); + + /** 将运行中的 Run 标记为用户中断。 */ + int interruptRunning(@Param("runId") UUID runId); + + /** 将运行中的 Run 切换为等待输入并保存 Ask。 */ + int waitForInput(@Param("runId") UUID runId, @Param("interruptJson") String interruptJson); + + /** 将运行中的 Run 标记为成功完成。 */ + int completeRunning(@Param("runId") UUID runId); + + /** 将运行中的 Run 标记为失败。 */ + int failRunning(@Param("runId") UUID runId, @Param("message") String message); + + /** 启动恢复时将所有遗留 RUNNING 状态标记为进程重启中断。 */ + int interruptRunningAfterRestart(); +} diff --git a/server/src/main/java/tech/easyflow/manuagent/mapper/AppUserMapper.java b/server/src/main/java/tech/easyflow/manuagent/mapper/AppUserMapper.java new file mode 100644 index 0000000..8fde491 --- /dev/null +++ b/server/src/main/java/tech/easyflow/manuagent/mapper/AppUserMapper.java @@ -0,0 +1,13 @@ +package tech.easyflow.manuagent.mapper; + +import com.mybatisflex.core.BaseMapper; +import tech.easyflow.manuagent.entity.AppUserEntity; + +/** + * 提供 {@code app.app_user} 表的 MyBatis-Flex 基础数据访问能力。 + * + *

用户表只有简单单表操作,因此直接使用 {@link BaseMapper} 和 Lambda QueryWrapper, + * 不额外维护 Mapper XML。

+ */ +public interface AppUserMapper extends BaseMapper { +} diff --git a/server/src/main/java/tech/easyflow/manuagent/mapper/ArtifactMapper.java b/server/src/main/java/tech/easyflow/manuagent/mapper/ArtifactMapper.java new file mode 100644 index 0000000..1c68b13 --- /dev/null +++ b/server/src/main/java/tech/easyflow/manuagent/mapper/ArtifactMapper.java @@ -0,0 +1,19 @@ +package tech.easyflow.manuagent.mapper; + +import com.mybatisflex.core.BaseMapper; +import org.apache.ibatis.annotations.Param; +import tech.easyflow.manuagent.entity.ArtifactEntity; + +/** + * 提供产物基础查询以及 PostgreSQL 原子 upsert 能力。 + */ +public interface ArtifactMapper extends BaseMapper { + + /** + * 按“项目 + 相对路径”插入或更新产物,并返回数据库中的完整记录。 + * + * @param artifact 待发布产物 + * @return 插入或更新后的产物记录 + */ + ArtifactEntity upsert(@Param("artifact") ArtifactEntity artifact); +} diff --git a/server/src/main/java/tech/easyflow/manuagent/mapper/ModelAssignmentMapper.java b/server/src/main/java/tech/easyflow/manuagent/mapper/ModelAssignmentMapper.java new file mode 100644 index 0000000..3862845 --- /dev/null +++ b/server/src/main/java/tech/easyflow/manuagent/mapper/ModelAssignmentMapper.java @@ -0,0 +1,14 @@ +package tech.easyflow.manuagent.mapper; + +import com.mybatisflex.core.BaseMapper; +import org.apache.ibatis.annotations.Param; +import tech.easyflow.manuagent.entity.ModelAssignmentEntity; + +/** + * 提供 Agent 角色模型分配及 PostgreSQL 原子 upsert。 + */ +public interface ModelAssignmentMapper extends BaseMapper { + + /** 按角色插入或更新模型分配。 */ + int upsert(@Param("assignment") ModelAssignmentEntity assignment); +} diff --git a/server/src/main/java/tech/easyflow/manuagent/mapper/ModelConfigMapper.java b/server/src/main/java/tech/easyflow/manuagent/mapper/ModelConfigMapper.java new file mode 100644 index 0000000..ee720a7 --- /dev/null +++ b/server/src/main/java/tech/easyflow/manuagent/mapper/ModelConfigMapper.java @@ -0,0 +1,45 @@ +package tech.easyflow.manuagent.mapper; + +import com.mybatisflex.core.BaseMapper; +import org.apache.ibatis.annotations.Param; +import tech.easyflow.manuagent.entity.ModelConfigEntity; + +/** + * 提供模型配置的 MyBatis-Flex CRUD 能力。 + * + *

普通查询和条件更新继续复用 {@link BaseMapper};包含 PostgreSQL JSONB 参数的新增、更新 + * 使用 XML 显式声明 TypeHandler,避免写入行为依赖 MyBatis-Flex 全局表元数据的初始化顺序。

+ */ +public interface ModelConfigMapper extends BaseMapper { + + /** + * 新增模型配置,并显式按 JSONB 类型绑定高级配置与能力声明。 + * + * @param model 待新增模型实体 + * @return 受影响行数 + */ + int insertModel(@Param("model") ModelConfigEntity model); + + /** + * 更新模型可编辑字段;实体未携带新密钥时保留数据库中的原密钥。 + * + * @param model 待更新模型实体 + * @return 受影响行数 + */ + int updateModel(@Param("model") ModelConfigEntity model); + + /** + * 清除当前默认模型标记,保持与迁移前 JDBC SQL 相同的更新范围。 + * + * @return 受影响行数 + */ + int clearDefault(); + + /** + * 将指定模型设为默认模型,并由数据库生成更新时间。 + * + * @param id 模型主键 + * @return 受影响行数 + */ + int setDefault(@Param("id") java.util.UUID id); +} diff --git a/server/src/main/java/tech/easyflow/manuagent/mapper/ProjectFileMapper.java b/server/src/main/java/tech/easyflow/manuagent/mapper/ProjectFileMapper.java new file mode 100644 index 0000000..1b6ceec --- /dev/null +++ b/server/src/main/java/tech/easyflow/manuagent/mapper/ProjectFileMapper.java @@ -0,0 +1,10 @@ +package tech.easyflow.manuagent.mapper; + +import com.mybatisflex.core.BaseMapper; +import tech.easyflow.manuagent.entity.ProjectFileEntity; + +/** + * 提供 {@code app.project_file} 表的单表持久化能力。 + */ +public interface ProjectFileMapper extends BaseMapper { +} diff --git a/server/src/main/java/tech/easyflow/manuagent/mapper/ProjectMapper.java b/server/src/main/java/tech/easyflow/manuagent/mapper/ProjectMapper.java new file mode 100644 index 0000000..b60c77e --- /dev/null +++ b/server/src/main/java/tech/easyflow/manuagent/mapper/ProjectMapper.java @@ -0,0 +1,33 @@ +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.ProjectEntity; + +/** + * 提供项目基础 CRUD、阶段更新和项目级联清理所需的显式 SQL 接口。 + */ +public interface ProjectMapper extends BaseMapper { + + /** 判断项目是否仍有运行中的 Agent。 */ + boolean hasRunningRun(@Param("projectId") UUID projectId); + + /** 删除项目事件。 */ + int deleteEvents(@Param("projectId") UUID projectId); + + /** 删除项目产物。 */ + int deleteArtifacts(@Param("projectId") UUID projectId); + + /** 删除项目规划。 */ + int deletePlans(@Param("projectId") UUID projectId); + + /** 删除项目材料元数据。 */ + int deleteFiles(@Param("projectId") UUID projectId); + + /** 删除项目运行记录。 */ + int deleteRuns(@Param("projectId") UUID projectId); + + /** 原子更新项目阶段并递增版本。 */ + int updateStatus(@Param("projectId") UUID projectId, @Param("status") String status); +} diff --git a/server/src/main/java/tech/easyflow/manuagent/mapper/ProjectPlanMapper.java b/server/src/main/java/tech/easyflow/manuagent/mapper/ProjectPlanMapper.java new file mode 100644 index 0000000..92513ec --- /dev/null +++ b/server/src/main/java/tech/easyflow/manuagent/mapper/ProjectPlanMapper.java @@ -0,0 +1,25 @@ +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.ProjectPlanEntity; + +/** + * 提供规划版本查询以及带条件的草稿写入、确认能力。 + */ +public interface ProjectPlanMapper extends BaseMapper { + + /** 插入项目下一版草稿并返回完整记录。 */ + ProjectPlanEntity insertNextDraft(@Param("plan") ProjectPlanEntity plan); + + /** 按“已确认优先、版本倒序”读取项目当前规划。 */ + ProjectPlanEntity selectCurrent(@Param("projectId") UUID projectId); + + /** 仅将仍处于 DRAFT 的指定版本确认,并返回确认后的记录。 */ + ProjectPlanEntity confirmDraft( + @Param("projectId") UUID projectId, + @Param("planId") UUID planId, + @Param("planJson") String planJson, + @Param("userId") UUID userId); +} diff --git a/server/src/main/java/tech/easyflow/manuagent/mapper/SkillConfigMapper.java b/server/src/main/java/tech/easyflow/manuagent/mapper/SkillConfigMapper.java new file mode 100644 index 0000000..ce1edd7 --- /dev/null +++ b/server/src/main/java/tech/easyflow/manuagent/mapper/SkillConfigMapper.java @@ -0,0 +1,28 @@ +package tech.easyflow.manuagent.mapper; + +import com.mybatisflex.core.BaseMapper; +import java.util.List; +import org.apache.ibatis.annotations.Param; +import tech.easyflow.manuagent.entity.SkillConfigEntity; + +/** + * 提供应用 Skill 配置 CRUD 及对 AgentScope Skill 元数据的只读联查。 + */ +public interface SkillConfigMapper extends BaseMapper { + + /** 列出全部 Skill 联合视图。 */ + List selectViews(); + + /** 按名称读取一个 Skill 联合视图。 */ + SkillViewRow selectView(@Param("name") String name); + + /** + * 按迁移前 SQL 的条件更新 Skill 启用状态,并由数据库生成更新时间。 + * + * @param name Skill 名称 + * @param enabled 是否启用 + * @return 受影响行数 + */ + int updateEnabled(@Param("name") String name, @Param("enabled") boolean enabled); + +} diff --git a/server/src/main/java/tech/easyflow/manuagent/mapper/SkillViewRow.java b/server/src/main/java/tech/easyflow/manuagent/mapper/SkillViewRow.java new file mode 100644 index 0000000..b8e9043 --- /dev/null +++ b/server/src/main/java/tech/easyflow/manuagent/mapper/SkillViewRow.java @@ -0,0 +1,38 @@ +package tech.easyflow.manuagent.mapper; + +import java.time.OffsetDateTime; + +/** + * 承载 AgentScope Skill 主表与应用 Skill 配置表只读联查结果。 + */ +public class SkillViewRow { + + private String name; + private String description; + private String version; + private String sourceType; + private Boolean enabled; + private Boolean readOnly; + private String validationStatus; + private String validationMessage; + private OffsetDateTime updatedAt; + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + 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 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 OffsetDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/server/src/main/java/tech/easyflow/manuagent/model/ModelService.java b/server/src/main/java/tech/easyflow/manuagent/model/ModelService.java index 4669940..46a6e1c 100644 --- a/server/src/main/java/tech/easyflow/manuagent/model/ModelService.java +++ b/server/src/main/java/tech/easyflow/manuagent/model/ModelService.java @@ -1,8 +1,15 @@ package tech.easyflow.manuagent.model; +import com.mybatisflex.core.query.QueryWrapper; import tech.easyflow.manuagent.auth.UserService; import tech.easyflow.manuagent.common.ApiException; import tech.easyflow.manuagent.config.AppProperties; +import tech.easyflow.manuagent.entity.AppUserEntity; +import tech.easyflow.manuagent.entity.ModelAssignmentEntity; +import tech.easyflow.manuagent.entity.ModelConfigEntity; +import tech.easyflow.manuagent.mapper.AppUserMapper; +import tech.easyflow.manuagent.mapper.ModelAssignmentMapper; +import tech.easyflow.manuagent.mapper.ModelConfigMapper; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; import java.net.URI; @@ -22,7 +29,6 @@ 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.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -33,7 +39,9 @@ import org.springframework.transaction.annotation.Transactional; @Order(2) public class ModelService implements ApplicationRunner { - private final JdbcClient jdbc; + private final ModelConfigMapper modelMapper; + private final ModelAssignmentMapper assignmentMapper; + private final AppUserMapper userMapper; private final UserService userService; private final KeyCipher keyCipher; private final AppProperties properties; @@ -43,19 +51,25 @@ public class ModelService implements ApplicationRunner { /** * 创建模型服务。 * - * @param jdbc JDBC 客户端 + * @param modelMapper 模型配置 Mapper + * @param assignmentMapper 角色模型分配 Mapper + * @param userMapper 用户 Mapper * @param userService 用户服务 * @param keyCipher 密钥加密器 * @param properties 应用配置 * @param objectMapper JSON 映射器 */ public ModelService( - JdbcClient jdbc, + ModelConfigMapper modelMapper, + ModelAssignmentMapper assignmentMapper, + AppUserMapper userMapper, UserService userService, KeyCipher keyCipher, AppProperties properties, ObjectMapper objectMapper) { - this.jdbc = jdbc; + this.modelMapper = modelMapper; + this.assignmentMapper = assignmentMapper; + this.userMapper = userMapper; this.userService = userService; this.keyCipher = keyCipher; this.properties = properties; @@ -70,8 +84,9 @@ public class ModelService implements ApplicationRunner { */ @Override @Transactional + @SuppressWarnings("unchecked") // MyBatis-Flex 的 select(LambdaGetter...) 使用泛型可变参数,调用本身类型安全。 public void run(ApplicationArguments args) { - Integer count = jdbc.sql("SELECT count(*) FROM app.model_config").query(Integer.class).single(); + long count = modelMapper.selectCountByQuery(QueryWrapper.create()); if (count > 0 || !Files.isRegularFile(properties.deepseekKeyFile())) { return; } @@ -80,35 +95,35 @@ public class ModelService implements ApplicationRunner { if (key.isBlank()) { return; } - UUID adminId = jdbc.sql("SELECT id FROM app.app_user ORDER BY created_at LIMIT 1") - .query(UUID.class) - .single(); + QueryWrapper userQuery = QueryWrapper.create() + .select(AppUserEntity::getId) + .orderBy(AppUserEntity::getCreatedAt).asc() + .limit(1); + AppUserEntity administrator = userMapper.selectOneByQuery(userQuery); + if (administrator == null) { + throw new IllegalStateException("初始化默认模型前必须先创建管理员账户"); + } + UUID adminId = administrator.getId(); UUID modelId = UUID.randomUUID(); - jdbc.sql(""" - INSERT INTO app.model_config( - id, name, provider, base_url, model_id, api_key_ciphertext, api_key_hint, - key_version, config_json, capabilities_json, is_default, created_by) - VALUES (:id, '默认编排模型', 'OPENAI_COMPATIBLE', :baseUrl, :modelId, - :ciphertext, :hint, 1, CAST(:config AS jsonb), CAST(:capabilities AS jsonb), TRUE, :userId) - """) - .param("id", modelId) - .param("baseUrl", properties.modelBaseUrl()) - .param("modelId", properties.modelId()) - .param("ciphertext", keyCipher.encrypt(key)) - .param("hint", hint(key)) - .param("config", "{\"timeoutSeconds\":120,\"reasoningEffort\":\"high\"}") - .param("capabilities", json(Map.of( - "toolCalling", true, - "reasoning", true, - "contextWindow", properties.modelContextWindow()))) - .param("userId", adminId) - .update(); + ModelConfigEntity model = new ModelConfigEntity(); + model.setId(modelId); + model.setName("默认编排模型"); + model.setProvider("OPENAI_COMPATIBLE"); + model.setBaseUrl(properties.modelBaseUrl()); + model.setModelId(properties.modelId()); + model.setApiKeyCiphertext(keyCipher.encrypt(key)); + model.setApiKeyHint(hint(key)); + model.setKeyVersion((short) 1); + model.setConfigJson("{\"timeoutSeconds\":120,\"reasoningEffort\":\"high\"}"); + model.setCapabilitiesJson(json(Map.of( + "toolCalling", true, + "reasoning", true, + "contextWindow", properties.modelContextWindow()))); + model.setDefaultModel(true); + model.setCreatedBy(adminId); + modelMapper.insertModel(model); for (String role : List.of("ORCHESTRATION", "WRITING", "REVIEW")) { - jdbc.sql("INSERT INTO app.model_assignment(role, model_config_id, assigned_by) VALUES (:role, :id, :userId)") - .param("role", role) - .param("id", modelId) - .param("userId", adminId) - .update(); + upsertAssignment(role, modelId, adminId); } } catch (IOException exception) { throw new IllegalStateException("无法读取默认模型 Key", exception); @@ -121,9 +136,12 @@ public class ModelService implements ApplicationRunner { * @return 模型列表 */ public List list() { - return jdbc.sql(MODEL_SELECT + " ORDER BY is_default DESC, updated_at DESC") - .query(ModelService::mapModel) - .list(); + QueryWrapper query = modelViewQuery() + .orderBy(ModelConfigEntity::getDefaultModel).desc() + .orderBy(ModelConfigEntity::getUpdatedAt).desc(); + return modelMapper.selectListByQuery(query).stream() + .map(ModelService::toModelView) + .toList(); } /** @@ -143,56 +161,21 @@ public class ModelService implements ApplicationRunner { throw new ApiException(HttpStatus.BAD_REQUEST, "MODEL_KEY_REQUIRED", "新增模型需要 API Key"); } id = UUID.randomUUID(); - jdbc.sql(""" - INSERT INTO app.model_config( - id, name, provider, base_url, model_id, api_key_ciphertext, api_key_hint, - key_version, config_json, capabilities_json, created_by) - VALUES (:id, :name, 'OPENAI_COMPATIBLE', :baseUrl, :modelId, :ciphertext, - :hint, 1, CAST(:config AS jsonb), CAST(:capabilities AS jsonb), :userId) - """) - .param("id", id) - .param("name", input.name().trim()) - .param("baseUrl", normalizeBaseUrl(input.baseUrl())) - .param("modelId", input.modelId().trim()) - .param("ciphertext", keyCipher.encrypt(input.apiKey().trim())) - .param("hint", hint(input.apiKey().trim())) - .param("config", json(input.config())) - .param("capabilities", json(input.capabilities())) - .param("userId", userId) - .update(); + ModelConfigEntity model = editableModel(id, input); + model.setProvider("OPENAI_COMPATIBLE"); + model.setApiKeyCiphertext(keyCipher.encrypt(input.apiKey().trim())); + model.setApiKeyHint(hint(input.apiKey().trim())); + model.setKeyVersion((short) 1); + model.setCreatedBy(userId); + modelMapper.insertModel(model); } else { - int updated = input.apiKey() == null || input.apiKey().isBlank() - ? jdbc.sql(""" - UPDATE app.model_config - SET name = :name, base_url = :baseUrl, model_id = :modelId, - config_json = CAST(:config AS jsonb), capabilities_json = CAST(:capabilities AS jsonb), - updated_at = CURRENT_TIMESTAMP - WHERE id = :id - """) - .param("name", input.name().trim()) - .param("baseUrl", normalizeBaseUrl(input.baseUrl())) - .param("modelId", input.modelId().trim()) - .param("config", json(input.config())) - .param("capabilities", json(input.capabilities())) - .param("id", id) - .update() - : jdbc.sql(""" - UPDATE app.model_config - SET name = :name, base_url = :baseUrl, model_id = :modelId, - api_key_ciphertext = :ciphertext, api_key_hint = :hint, key_version = 1, - config_json = CAST(:config AS jsonb), capabilities_json = CAST(:capabilities AS jsonb), - updated_at = CURRENT_TIMESTAMP - WHERE id = :id - """) - .param("name", input.name().trim()) - .param("baseUrl", normalizeBaseUrl(input.baseUrl())) - .param("modelId", input.modelId().trim()) - .param("ciphertext", keyCipher.encrypt(input.apiKey().trim())) - .param("hint", hint(input.apiKey().trim())) - .param("config", json(input.config())) - .param("capabilities", json(input.capabilities())) - .param("id", id) - .update(); + ModelConfigEntity model = editableModel(id, input); + if (input.apiKey() != null && !input.apiKey().isBlank()) { + model.setApiKeyCiphertext(keyCipher.encrypt(input.apiKey().trim())); + model.setApiKeyHint(hint(input.apiKey().trim())); + model.setKeyVersion((short) 1); + } + int updated = modelMapper.updateModel(model); if (updated != 1) { throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在"); } @@ -210,23 +193,10 @@ public class ModelService implements ApplicationRunner { public void setDefault(UUID id, Principal principal) { require(id); UUID userId = userService.requireUserId(principal.getName()); - jdbc.sql("UPDATE app.model_config SET is_default = FALSE WHERE is_default").update(); - jdbc.sql("UPDATE app.model_config SET is_default = TRUE, updated_at = CURRENT_TIMESTAMP WHERE id = :id") - .param("id", id) - .update(); + modelMapper.clearDefault(); + modelMapper.setDefault(id); for (String role : List.of("ORCHESTRATION", "WRITING", "REVIEW")) { - jdbc.sql(""" - INSERT INTO app.model_assignment(role, model_config_id, assigned_by) - VALUES (:role, :id, :userId) - ON CONFLICT (role) DO UPDATE - SET model_config_id = EXCLUDED.model_config_id, - assigned_by = EXCLUDED.assigned_by, - updated_at = CURRENT_TIMESTAMP - """) - .param("role", role) - .param("id", id) - .param("userId", userId) - .update(); + upsertAssignment(role, id, userId); } } @@ -272,36 +242,50 @@ public class ModelService implements ApplicationRunner { * * @return 默认模型机密配置 */ + @SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。 public ModelSecret defaultModelSecret() { - UUID id = jdbc.sql("SELECT id FROM app.model_config WHERE is_default AND enabled") - .query(UUID.class) - .optional() - .orElseThrow(() -> new ApiException(HttpStatus.CONFLICT, "MODEL_NOT_CONFIGURED", "请先配置可用模型")); - return requireSecret(id); + QueryWrapper query = QueryWrapper.create() + .select( + ModelConfigEntity::getId, + ModelConfigEntity::getBaseUrl, + ModelConfigEntity::getModelId, + ModelConfigEntity::getApiKeyCiphertext, + ModelConfigEntity::getCapabilitiesJson) + .where(ModelConfigEntity::getDefaultModel).eq(true) + .and(ModelConfigEntity::getEnabled).eq(true); + ModelConfigEntity model = modelMapper.selectOneByQuery(query); + if (model == null) { + throw new ApiException(HttpStatus.CONFLICT, "MODEL_NOT_CONFIGURED", "请先配置可用模型"); + } + return toModelSecret(model); } private ModelView require(UUID id) { - return jdbc.sql(MODEL_SELECT + " WHERE id = :id") - .param("id", id) - .query(ModelService::mapModel) - .optional() - .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在")); + QueryWrapper query = modelViewQuery() + .where(ModelConfigEntity::getId).eq(id); + ModelConfigEntity model = modelMapper.selectOneByQuery(query); + if (model == null) { + throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在"); + } + return toModelView(model); } + @SuppressWarnings("unchecked") // 机密配置查询只投影固定列,LambdaGetter 可变参数不会引入运行期类型风险。 private ModelSecret requireSecret(UUID id) { - return jdbc.sql(""" - SELECT id, base_url, model_id, api_key_ciphertext, capabilities_json::text - FROM app.model_config WHERE id = :id AND enabled - """) - .param("id", id) - .query((rs, rowNum) -> new ModelSecret( - rs.getObject("id", UUID.class), - rs.getString("base_url"), - rs.getString("model_id"), - keyCipher.decrypt(rs.getBytes("api_key_ciphertext")), - contextWindow(parseCapabilities(rs.getString("capabilities_json"))))) - .optional() - .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在或已停用")); + QueryWrapper query = QueryWrapper.create() + .select( + ModelConfigEntity::getId, + ModelConfigEntity::getBaseUrl, + ModelConfigEntity::getModelId, + ModelConfigEntity::getApiKeyCiphertext, + ModelConfigEntity::getCapabilitiesJson) + .where(ModelConfigEntity::getId).eq(id) + .and(ModelConfigEntity::getEnabled).eq(true); + ModelConfigEntity model = modelMapper.selectOneByQuery(query); + if (model == null) { + throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在或已停用"); + } + return toModelSecret(model); } /** @@ -339,19 +323,87 @@ public class ModelService implements ApplicationRunner { } } - private static ModelView mapModel(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException { + /** + * 构造新增、更新共用的非敏感模型字段。 + * + * @param id 模型主键 + * @param input 接口输入 + * @return 待持久化实体 + */ + private ModelConfigEntity editableModel(UUID id, ModelInput input) { + ModelConfigEntity model = new ModelConfigEntity(); + model.setId(id); + model.setName(input.name().trim()); + model.setBaseUrl(normalizeBaseUrl(input.baseUrl())); + model.setModelId(input.modelId().trim()); + model.setConfigJson(json(input.config())); + model.setCapabilitiesJson(json(input.capabilities())); + return model; + } + + /** + * 原子插入或更新一个 Agent 角色的模型分配。 + */ + private void upsertAssignment(String role, UUID modelId, UUID userId) { + ModelAssignmentEntity assignment = new ModelAssignmentEntity(); + assignment.setRole(role); + assignment.setModelConfigId(modelId); + assignment.setAssignedBy(userId); + assignmentMapper.upsert(assignment); + } + + /** + * 将包含密文的内部实体转换为模型调用所需的最小明文对象。 + */ + private ModelSecret toModelSecret(ModelConfigEntity model) { + return new ModelSecret( + model.getId(), + model.getBaseUrl(), + model.getModelId(), + keyCipher.decrypt(model.getApiKeyCiphertext()), + contextWindow(parseCapabilities(model.getCapabilitiesJson()))); + } + + /** + * 将模型实体转换为永不包含密文或明文 Key 的接口视图。 + */ + private static ModelView toModelView(ModelConfigEntity model) { return new ModelView( - rs.getObject("id", UUID.class), - rs.getString("name"), - rs.getString("provider"), - rs.getString("base_url"), - rs.getString("model_id"), - rs.getString("api_key_hint"), - rs.getString("config_json"), - rs.getString("capabilities_json"), - rs.getBoolean("enabled"), - rs.getBoolean("is_default"), - rs.getObject("updated_at", OffsetDateTime.class)); + model.getId(), + model.getName(), + model.getProvider(), + model.getBaseUrl(), + model.getModelId(), + model.getApiKeyHint(), + model.getConfigJson(), + model.getCapabilitiesJson(), + Boolean.TRUE.equals(model.getEnabled()), + Boolean.TRUE.equals(model.getDefaultModel()), + model.getUpdatedAt()); + } + + /** + * 构造模型管理页面使用的安全字段投影。 + * + *

普通列表、保存结果和默认模型切换只需要展示字段,因此明确排除 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 list(UUID projectId) { projectService.require(projectId); - return jdbc.sql(""" - SELECT id, project_id, original_name, relative_path, mime_type, extension, - size_bytes, status, created_at - FROM app.project_file - WHERE project_id = :projectId AND deleted_at IS NULL - ORDER BY relative_path - """) - .param("projectId", projectId) - .query(ProjectFileService::mapFile) - .list(); + QueryWrapper query = fileViewQuery() + .where(ProjectFileEntity::getProjectId).eq(projectId) + .and(ProjectFileEntity::getDeletedAt).isNull() + .orderBy(ProjectFileEntity::getRelativePath).asc(); + return fileMapper.selectListByQuery(query).stream() + .map(ProjectFileService::toFileView) + .toList(); } /** @@ -216,18 +210,22 @@ public class ProjectFileService { * @param fileId 文件 ID * @return 文件资源 */ + @SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。 public Download download(UUID projectId, UUID fileId) { - StoredFile stored = jdbc.sql(""" - SELECT original_name, relative_path, mime_type - FROM app.project_file - WHERE id = :fileId AND project_id = :projectId AND deleted_at IS NULL AND status = 'READY' - """) - .param("fileId", fileId) - .param("projectId", projectId) - .query((rs, rowNum) -> new StoredFile( - rs.getString("original_name"), rs.getString("relative_path"), rs.getString("mime_type"))) - .optional() - .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "FILE_NOT_FOUND", "文件不存在")); + QueryWrapper query = QueryWrapper.create() + .select( + ProjectFileEntity::getOriginalName, + ProjectFileEntity::getRelativePath, + ProjectFileEntity::getMimeType) + .where(ProjectFileEntity::getId).eq(fileId) + .and(ProjectFileEntity::getProjectId).eq(projectId) + .and(ProjectFileEntity::getDeletedAt).isNull() + .and(ProjectFileEntity::getStatus).eq("READY"); + ProjectFileEntity entity = fileMapper.selectOneByQuery(query); + if (entity == null) { + throw new ApiException(HttpStatus.NOT_FOUND, "FILE_NOT_FOUND", "文件不存在"); + } + StoredFile stored = new StoredFile(entity.getOriginalName(), entity.getRelativePath(), entity.getMimeType()); try { Resource resource = new UrlResource(safeProjectPath(projectId, stored.relativePath()).toUri()); if (!resource.exists()) { @@ -314,28 +312,54 @@ public class ProjectFileService { } private FileView require(UUID fileId) { - return jdbc.sql(""" - SELECT id, project_id, original_name, relative_path, mime_type, extension, - size_bytes, status, created_at - FROM app.project_file WHERE id = :id - """) - .param("id", fileId) - .query(ProjectFileService::mapFile) - .optional() - .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "FILE_NOT_FOUND", "文件不存在")); + ProjectFileEntity entity = fileMapper.selectOneByQuery( + fileViewQuery().where(ProjectFileEntity::getId).eq(fileId)); + if (entity == null) { + throw new ApiException(HttpStatus.NOT_FOUND, "FILE_NOT_FOUND", "文件不存在"); + } + return toFileView(entity); } - private static FileView mapFile(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException { + /** + * 构造项目材料接口视图使用的最小字段投影。 + * + *

该投影与迁移前 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 list() { - return jdbc.sql(PROJECT_SELECT + " ORDER BY updated_at DESC") - .query(ProjectService::mapProject) - .list(); + QueryWrapper query = projectViewQuery().orderBy(ProjectEntity::getUpdatedAt).desc(); + return projectMapper.selectListByQuery(query).stream() + .map(ProjectService::toProjectView) + .toList(); } /** @@ -85,11 +93,12 @@ public class ProjectService { * @throws ApiException 项目不存在时抛出 */ public ProjectView require(UUID projectId) { - return jdbc.sql(PROJECT_SELECT + " WHERE id = :id") - .param("id", projectId) - .query(ProjectService::mapProject) - .optional() - .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在")); + ProjectEntity entity = projectMapper.selectOneByQuery( + projectViewQuery().where(ProjectEntity::getId).eq(projectId)); + if (entity == null) { + throw new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在"); + } + return toProjectView(entity); } /** @@ -101,26 +110,17 @@ public class ProjectService { @Transactional public void delete(UUID projectId) { require(projectId); - boolean running = jdbc.sql(""" - SELECT EXISTS( - SELECT 1 FROM app.agent_run WHERE project_id = :projectId AND status = 'RUNNING' - ) - """) - .param("projectId", projectId) - .query(Boolean.class) - .single(); - if (running) { + if (projectMapper.hasRunningRun(projectId)) { throw new ApiException(HttpStatus.CONFLICT, "PROJECT_RUN_ACTIVE", "请先停止正在执行的任务"); } - for (String table : List.of("agent_event", "artifact", "project_plan", "project_file", "agent_run")) { - jdbc.sql("DELETE FROM app." + table + " WHERE project_id = :projectId") - .param("projectId", projectId) - .update(); - } - int deleted = jdbc.sql("DELETE FROM app.project WHERE id = :projectId") - .param("projectId", projectId) - .update(); + // 按外键依赖顺序删除,所有语句均受当前 Spring 事务保护。 + projectMapper.deleteEvents(projectId); + projectMapper.deleteArtifacts(projectId); + projectMapper.deletePlans(projectId); + projectMapper.deleteFiles(projectId); + projectMapper.deleteRuns(projectId); + int deleted = projectMapper.deleteById(projectId); if (deleted != 1) { throw new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在"); } @@ -133,14 +133,7 @@ public class ProjectService { * @param status 新阶段 */ public void updateStatus(UUID projectId, String status) { - int updated = jdbc.sql(""" - UPDATE app.project - SET status = :status, version = version + 1, updated_at = CURRENT_TIMESTAMP - WHERE id = :id - """) - .param("status", status) - .param("id", projectId) - .update(); + int updated = projectMapper.updateStatus(projectId, status); if (updated != 1) { throw new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在"); } @@ -156,23 +149,14 @@ public class ProjectService { */ @Transactional public PlanView saveDraftPlan(UUID projectId, JsonNode plan, UUID userId) { - Integer version = jdbc.sql("SELECT COALESCE(MAX(plan_version), 0) + 1 FROM app.project_plan WHERE project_id = :id") - .param("id", projectId) - .query(Integer.class) - .single(); - UUID planId = UUID.randomUUID(); - jdbc.sql(""" - INSERT INTO app.project_plan(id, project_id, plan_version, status, plan_json, created_by) - VALUES (:id, :projectId, :version, 'DRAFT', CAST(:plan AS jsonb), :userId) - """) - .param("id", planId) - .param("projectId", projectId) - .param("version", version) - .param("plan", plan.toString()) - .param("userId", userId) - .update(); + ProjectPlanEntity draft = new ProjectPlanEntity(); + draft.setId(UUID.randomUUID()); + draft.setProjectId(projectId); + draft.setPlanJson(plan.toString()); + draft.setCreatedBy(userId); + ProjectPlanEntity stored = planMapper.insertNextDraft(draft); updateStatus(projectId, "PLANNING"); - return requirePlan(planId); + return toPlanView(stored); } /** @@ -182,17 +166,8 @@ public class ProjectService { * @return 最新规划;不存在时返回空 */ public PlanView currentPlan(UUID projectId) { - return jdbc.sql(""" - SELECT id, project_id, plan_version, status, plan_json, confirmed_at, created_at - FROM app.project_plan - WHERE project_id = :projectId - ORDER BY CASE status WHEN 'CONFIRMED' THEN 0 ELSE 1 END, plan_version DESC - LIMIT 1 - """) - .param("projectId", projectId) - .query(this::mapPlan) - .optional() - .orElse(null); + ProjectPlanEntity entity = planMapper.selectCurrent(projectId); + return entity == null ? null : toPlanView(entity); } /** @@ -207,61 +182,65 @@ public class ProjectService { @Transactional public PlanView confirmPlan(UUID projectId, UUID planId, JsonNode plan, Principal principal) { UUID userId = userService.requireUserId(principal.getName()); - int updated = jdbc.sql(""" - UPDATE app.project_plan - SET status = 'CONFIRMED', plan_json = CAST(:plan AS jsonb), confirmed_by = :userId, - confirmed_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP - WHERE id = :planId AND project_id = :projectId AND status = 'DRAFT' - """) - .param("plan", plan.toString()) - .param("userId", userId) - .param("planId", planId) - .param("projectId", projectId) - .update(); - if (updated != 1) { + ProjectPlanEntity confirmed = planMapper.confirmDraft(projectId, planId, plan.toString(), userId); + if (confirmed == null) { throw new ApiException(HttpStatus.CONFLICT, "PLAN_ALREADY_CONFIRMED", "规划已确认或版本不存在"); } updateStatus(projectId, "WRITING"); - return requirePlan(planId); + return toPlanView(confirmed); } - private PlanView requirePlan(UUID planId) { - return jdbc.sql(""" - SELECT id, project_id, plan_version, status, plan_json, confirmed_at, created_at - FROM app.project_plan WHERE id = :id - """) - .param("id", planId) - .query(this::mapPlan) - .optional() - .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "PLAN_NOT_FOUND", "规划不存在")); - } - - private PlanView mapPlan(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException { + /** 将规划实体解析成包含 JsonNode 的接口视图。 */ + private PlanView toPlanView(ProjectPlanEntity entity) { try { return new PlanView( - rs.getObject("id", UUID.class), - rs.getObject("project_id", UUID.class), - rs.getInt("plan_version"), - rs.getString("status"), - objectMapper.readTree(rs.getString("plan_json")), - rs.getObject("confirmed_at", OffsetDateTime.class), - rs.getObject("created_at", OffsetDateTime.class)); + entity.getId(), + entity.getProjectId(), + entity.getPlanVersion() == null ? 0 : entity.getPlanVersion(), + entity.getStatus(), + objectMapper.readTree(entity.getPlanJson()), + entity.getConfirmedAt(), + entity.getCreatedAt()); } catch (JsonProcessingException exception) { - throw new java.sql.SQLException("规划 JSON 无法解析", exception); + // 迁移前 ResultSet 映射会将损坏的存量 JSON 作为未预期数据库读取异常处理,不新增业务错误码。 + throw new IllegalStateException("规划 JSON 无法解析", exception); } } - private static ProjectView mapProject(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException { + /** 将项目实体转换为接口视图。 */ + private static ProjectView toProjectView(ProjectEntity entity) { return new ProjectView( - rs.getObject("id", UUID.class), - rs.getString("company_name"), - rs.getString("project_name"), - rs.getString("agui_thread_id"), - rs.getString("application_level"), - rs.getString("status"), - rs.getLong("version"), - rs.getObject("created_at", OffsetDateTime.class), - rs.getObject("updated_at", OffsetDateTime.class)); + entity.getId(), + entity.getCompanyName(), + entity.getProjectName(), + entity.getAguiThreadId(), + entity.getApplicationLevel(), + entity.getStatus(), + entity.getVersion() == null ? 0L : entity.getVersion(), + entity.getCreatedAt(), + entity.getUpdatedAt()); + } + + /** + * 构造项目接口列表与详情共用的最小字段投影。 + * + *

字段集合与迁移前 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 list() { - return jdbc.sql(SKILL_SELECT + " ORDER BY c.source_type, s.name") - .query(SkillService::mapSkill) - .list(); + return skillMapper.selectViews().stream() + .map(SkillService::toSkillView) + .toList(); } /** @@ -78,11 +81,11 @@ public class SkillService { * @return Skill 详情 */ public SkillDetail require(String name) { - SkillView view = jdbc.sql(SKILL_SELECT + " WHERE s.name = :name") - .param("name", name) - .query(SkillService::mapSkill) - .optional() - .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在")); + SkillViewRow row = skillMapper.selectView(name); + if (row == null) { + throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在"); + } + SkillView view = toSkillView(row); AgentSkill skill = repository.getSkill(name); if (skill == null) { throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 内容不存在"); @@ -116,13 +119,7 @@ public class SkillService { * @param enabled 是否启用 */ public void setEnabled(String name, boolean enabled) { - int updated = jdbc.sql(""" - UPDATE app.skill_config SET enabled = :enabled, updated_at = CURRENT_TIMESTAMP - WHERE skill_name = :name AND validation_status = 'VALID' - """) - .param("enabled", enabled) - .param("name", name) - .update(); + int updated = skillMapper.updateEnabled(name, enabled); if (updated != 1) { throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在或校验未通过"); } @@ -134,14 +131,15 @@ public class SkillService { * * @return Skill 名称数组 */ + @SuppressWarnings("unchecked") // MyBatis-Flex 的 select(LambdaGetter...) 使用泛型可变参数,调用本身类型安全。 public String[] enabledNames() { - return jdbc.sql(""" - SELECT skill_name FROM app.skill_config - WHERE enabled AND validation_status = 'VALID' - ORDER BY skill_name - """) - .query(String.class) - .list() + QueryWrapper query = QueryWrapper.create() + .select(SkillConfigEntity::getSkillName) + .where(SkillConfigEntity::getEnabled).eq(true) + .and(SkillConfigEntity::getValidationStatus).eq("VALID") + .orderBy(SkillConfigEntity::getSkillName).asc(); + return skillMapper.selectListByQuery(query).stream() + .map(SkillConfigEntity::getSkillName) .toArray(String[]::new); } @@ -171,17 +169,17 @@ public class SkillService { } repository.save(List.of(skillPackage.skill()), false); UUID userId = userService.requireUserId(principal.getName()); - jdbc.sql(""" - INSERT INTO app.skill_config( - skill_name, version, source_type, enabled, read_only, checksum, - validation_status, imported_by) - VALUES (:name, :version, 'IMPORTED', FALSE, TRUE, :checksum, 'VALID', :userId) - """) - .param("name", name) - .param("version", skillPackage.version()) - .param("checksum", skillPackage.checksum()) - .param("userId", userId) - .update(); + SkillConfigEntity config = new SkillConfigEntity(); + config.setSkillName(name); + config.setVersion(skillPackage.version()); + config.setSourceType("IMPORTED"); + config.setEnabled(false); + config.setReadOnly(true); + config.setChecksum(skillPackage.checksum()); + config.setValidationStatus("VALID"); + config.setImportedBy(userId); + // Skill 名称由上传包提供,因此显式使用 WithPk 插入字符串主键。 + skillMapper.insertSelectiveWithPk(config); return require(name).view(); } finally { deleteTree(temporary); @@ -197,13 +195,16 @@ public class SkillService { * @param name Skill 名称 */ @Transactional + @SuppressWarnings("unchecked") // MyBatis-Flex 的 select(LambdaGetter...) 使用泛型可变参数,调用本身类型安全。 public void deleteImported(String name) { - String sourceType = jdbc.sql("SELECT source_type FROM app.skill_config WHERE skill_name = :name") - .param("name", name) - .query(String.class) - .optional() - .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在")); - if (!"IMPORTED".equals(sourceType)) { + QueryWrapper query = QueryWrapper.create() + .select(SkillConfigEntity::getSourceType) + .where(SkillConfigEntity::getSkillName).eq(name); + SkillConfigEntity config = skillMapper.selectOneByQuery(query); + if (config == null) { + throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在"); + } + if (!"IMPORTED".equals(config.getSourceType())) { throw new ApiException(HttpStatus.CONFLICT, "BUILTIN_SKILL_READ_ONLY", "内置 Skill 不能删除"); } repository.delete(name); @@ -311,26 +312,22 @@ public class SkillService { } } - private static SkillView mapSkill(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException { + /** + * 将跨 schema 的只读查询行转换成对外 Skill 视图。 + */ + private static SkillView toSkillView(SkillViewRow row) { return new SkillView( - rs.getString("name"), - rs.getString("description"), - rs.getString("version"), - rs.getString("source_type"), - rs.getBoolean("enabled"), - rs.getBoolean("read_only"), - rs.getString("validation_status"), - rs.getString("validation_message"), - rs.getObject("updated_at", OffsetDateTime.class)); + row.getName(), + row.getDescription(), + row.getVersion(), + row.getSourceType(), + Boolean.TRUE.equals(row.getEnabled()), + Boolean.TRUE.equals(row.getReadOnly()), + row.getValidationStatus(), + row.getValidationMessage(), + row.getUpdatedAt()); } - private static final String SKILL_SELECT = """ - SELECT s.name, s.description, c.version, c.source_type, c.enabled, c.read_only, - c.validation_status, c.validation_message, c.updated_at - FROM agentscope.agentscope_skills s - JOIN app.skill_config c ON c.skill_name = s.name - """; - /** * Skill 列表视图。 * diff --git a/server/src/main/java/tech/easyflow/manuagent/typehandler/JsonbStringTypeHandler.java b/server/src/main/java/tech/easyflow/manuagent/typehandler/JsonbStringTypeHandler.java new file mode 100644 index 0000000..01fca48 --- /dev/null +++ b/server/src/main/java/tech/easyflow/manuagent/typehandler/JsonbStringTypeHandler.java @@ -0,0 +1,56 @@ +package tech.easyflow.manuagent.typehandler; + +import java.sql.CallableStatement; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Types; +import org.apache.ibatis.type.BaseTypeHandler; +import org.apache.ibatis.type.JdbcType; +import org.apache.ibatis.type.MappedJdbcTypes; +import org.apache.ibatis.type.MappedTypes; + +/** + * 将 JSON 文本映射到数据库的原生 JSON/JSONB 列。 + * + *

当前 PostgreSQL 阶段使用 JDBC {@link Types#OTHER} 发送 JSON 文本,使驱动按目标列类型完成绑定, + * 避免在业务 SQL 中重复书写字符串拼接或手工创建驱动专有对象。未来适配国产数据库时,只需替换 + * 该类型处理器或按数据库方言提供对应实现,实体和服务层无需感知。

+ */ +@MappedTypes(String.class) +@MappedJdbcTypes(JdbcType.OTHER) +public class JsonbStringTypeHandler extends BaseTypeHandler { + + /** + * 以数据库原生扩展类型绑定非空 JSON 文本。 + * + * @param statement 预编译语句 + * @param index 参数位置 + * @param parameter JSON 文本 + * @param jdbcType MyBatis 推断的 JDBC 类型 + * @throws SQLException 参数绑定失败时抛出 + */ + @Override + public void setNonNullParameter( + PreparedStatement statement, int index, String parameter, JdbcType jdbcType) throws SQLException { + statement.setObject(index, parameter, Types.OTHER); + } + + /** 按列名读取 JSON 文本。 */ + @Override + public String getNullableResult(ResultSet resultSet, String columnName) throws SQLException { + return resultSet.getString(columnName); + } + + /** 按列序号读取 JSON 文本。 */ + @Override + public String getNullableResult(ResultSet resultSet, int columnIndex) throws SQLException { + return resultSet.getString(columnIndex); + } + + /** 从存储过程结果读取 JSON 文本。 */ + @Override + public String getNullableResult(CallableStatement statement, int columnIndex) throws SQLException { + return statement.getString(columnIndex); + } +} diff --git a/server/src/main/java/tech/easyflow/manuagent/typehandler/UuidTypeHandler.java b/server/src/main/java/tech/easyflow/manuagent/typehandler/UuidTypeHandler.java new file mode 100644 index 0000000..1b28fe4 --- /dev/null +++ b/server/src/main/java/tech/easyflow/manuagent/typehandler/UuidTypeHandler.java @@ -0,0 +1,98 @@ +package tech.easyflow.manuagent.typehandler; + +import java.sql.CallableStatement; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.UUID; +import org.apache.ibatis.type.BaseTypeHandler; +import org.apache.ibatis.type.JdbcType; +import org.apache.ibatis.type.MappedJdbcTypes; +import org.apache.ibatis.type.MappedTypes; + +/** + * 在 Java {@link UUID} 与数据库 UUID 值之间进行显式转换。 + * + *

PostgreSQL 驱动读取 {@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 { + + /** + * 将非空 UUID 作为驱动原生对象写入预编译语句。 + * + * @param statement 预编译语句 + * @param index 参数位置 + * @param parameter 待写入的 UUID + * @param jdbcType MyBatis 推断的 JDBC 类型 + * @throws SQLException 数据库驱动拒绝绑定参数时抛出 + */ + @Override + public void setNonNullParameter( + PreparedStatement statement, int index, UUID parameter, JdbcType jdbcType) throws SQLException { + statement.setObject(index, parameter); + } + + /** + * 按列名读取 UUID。 + * + * @param resultSet 查询结果集 + * @param columnName 列名 + * @return UUID,数据库值为空时返回 {@code null} + * @throws SQLException 读取或转换失败时抛出 + */ + @Override + public UUID getNullableResult(ResultSet resultSet, String columnName) throws SQLException { + return toUuid(resultSet.getObject(columnName)); + } + + /** + * 按列序号读取 UUID。 + * + * @param resultSet 查询结果集 + * @param columnIndex 列序号 + * @return UUID,数据库值为空时返回 {@code null} + * @throws SQLException 读取或转换失败时抛出 + */ + @Override + public UUID getNullableResult(ResultSet resultSet, int columnIndex) throws SQLException { + return toUuid(resultSet.getObject(columnIndex)); + } + + /** + * 从存储过程结果中读取 UUID。 + * + * @param statement 存储过程调用语句 + * @param columnIndex 列序号 + * @return UUID,数据库值为空时返回 {@code null} + * @throws SQLException 读取或转换失败时抛出 + */ + @Override + public UUID getNullableResult(CallableStatement statement, int columnIndex) throws SQLException { + return toUuid(statement.getObject(columnIndex)); + } + + /** + * 统一处理驱动返回的 UUID 对象或字符串。 + * + * @param value 驱动返回值 + * @return 规范化后的 UUID,空值返回 {@code null} + * @throws SQLException 返回值不是合法 UUID 时抛出并保留数据库访问语义 + */ + private UUID toUuid(Object value) throws SQLException { + if (value == null) { + return null; + } + if (value instanceof UUID uuid) { + return uuid; + } + try { + return UUID.fromString(value.toString()); + } catch (IllegalArgumentException exception) { + throw new SQLException("数据库返回了无法转换为 UUID 的值: " + value, exception); + } + } +} diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index 504bb8a..8bb514e 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -21,6 +21,16 @@ spring: jackson: default-property-inclusion: non_null +mybatis-flex: + mapper-locations: + - classpath*:/mapper/**/*.xml + type-aliases-package: tech.easyflow.manuagent.entity + type-handlers-package: tech.easyflow.manuagent.typehandler + configuration: + map-underscore-to-camel-case: true + cache-enabled: false + local-cache-scope: statement + server: port: 8080 servlet: diff --git a/server/src/main/resources/mapper/AgentEventMapper.xml b/server/src/main/resources/mapper/AgentEventMapper.xml new file mode 100644 index 0000000..21d8737 --- /dev/null +++ b/server/src/main/resources/mapper/AgentEventMapper.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/server/src/main/resources/mapper/AgentRunMapper.xml b/server/src/main/resources/mapper/AgentRunMapper.xml new file mode 100644 index 0000000..4a8414b --- /dev/null +++ b/server/src/main/resources/mapper/AgentRunMapper.xml @@ -0,0 +1,58 @@ + + + + + + + UPDATE app.agent_run + SET status = 'COMPLETED', pending_interrupt = NULL, updated_at = CURRENT_TIMESTAMP + WHERE id = #{runId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler} + AND status = 'WAITING_INPUT' + + + + UPDATE app.agent_run + SET status = 'INTERRUPTED', pending_interrupt = NULL, + error_code = 'USER_STOPPED', error_message = '用户已停止运行', + ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE id = #{runId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler} + AND status = 'RUNNING' + + + + UPDATE app.agent_run + SET status = 'WAITING_INPUT', + pending_interrupt = #{interruptJson, jdbcType=OTHER, + typeHandler=tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler}, + ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE id = #{runId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler} + AND status = 'RUNNING' + + + + UPDATE app.agent_run + SET status = 'COMPLETED', pending_interrupt = NULL, + ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE id = #{runId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler} + AND status = 'RUNNING' + + + + UPDATE app.agent_run + SET status = 'FAILED', pending_interrupt = NULL, + error_code = 'AGENT_RUN_FAILED', error_message = #{message}, + ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE id = #{runId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler} + AND status = 'RUNNING' + + + + 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' + + diff --git a/server/src/main/resources/mapper/ArtifactMapper.xml b/server/src/main/resources/mapper/ArtifactMapper.xml new file mode 100644 index 0000000..2788552 --- /dev/null +++ b/server/src/main/resources/mapper/ArtifactMapper.xml @@ -0,0 +1,37 @@ + + + + + + + diff --git a/server/src/main/resources/mapper/ModelAssignmentMapper.xml b/server/src/main/resources/mapper/ModelAssignmentMapper.xml new file mode 100644 index 0000000..4830f2e --- /dev/null +++ b/server/src/main/resources/mapper/ModelAssignmentMapper.xml @@ -0,0 +1,18 @@ + + + + + + + INSERT INTO app.model_assignment(role, model_config_id, assigned_by) + VALUES ( + #{assignment.role}, + #{assignment.modelConfigId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}, + #{assignment.assignedBy, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}) + ON CONFLICT (role) DO UPDATE SET + model_config_id = EXCLUDED.model_config_id, + assigned_by = EXCLUDED.assigned_by, + updated_at = CURRENT_TIMESTAMP + + diff --git a/server/src/main/resources/mapper/ModelConfigMapper.xml b/server/src/main/resources/mapper/ModelConfigMapper.xml new file mode 100644 index 0000000..042c109 --- /dev/null +++ b/server/src/main/resources/mapper/ModelConfigMapper.xml @@ -0,0 +1,63 @@ + + + + + + + INSERT INTO app.model_config( + id, name, provider, base_url, model_id, + api_key_ciphertext, api_key_hint, key_version, + config_json, capabilities_json, enabled, is_default, created_by) + VALUES ( + #{model.id, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}, + #{model.name}, + #{model.provider}, + #{model.baseUrl}, + #{model.modelId}, + #{model.apiKeyCiphertext}, + #{model.apiKeyHint}, + #{model.keyVersion}, + #{model.configJson, jdbcType=OTHER, typeHandler=tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler}, + #{model.capabilitiesJson, jdbcType=OTHER, typeHandler=tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler}, + COALESCE(#{model.enabled}, TRUE), + COALESCE(#{model.defaultModel}, FALSE), + #{model.createdBy, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}) + + + + + UPDATE app.model_config + SET name = #{model.name}, + base_url = #{model.baseUrl}, + model_id = #{model.modelId}, + config_json = #{model.configJson, jdbcType=OTHER, typeHandler=tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler}, + capabilities_json = #{model.capabilitiesJson, jdbcType=OTHER, typeHandler=tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler}, + + api_key_ciphertext = #{model.apiKeyCiphertext}, + api_key_hint = #{model.apiKeyHint}, + key_version = #{model.keyVersion}, + + updated_at = CURRENT_TIMESTAMP + WHERE id = #{model.id, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler} + + + + + UPDATE app.model_config + SET is_default = FALSE + WHERE is_default + + + + UPDATE app.model_config + SET is_default = TRUE, + updated_at = CURRENT_TIMESTAMP + WHERE id = #{id, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler} + + diff --git a/server/src/main/resources/mapper/ProjectMapper.xml b/server/src/main/resources/mapper/ProjectMapper.xml new file mode 100644 index 0000000..1c3c366 --- /dev/null +++ b/server/src/main/resources/mapper/ProjectMapper.xml @@ -0,0 +1,42 @@ + + + + + + + + + + DELETE FROM app.agent_event + WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler} + + + DELETE FROM app.artifact + WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler} + + + DELETE FROM app.project_plan + WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler} + + + DELETE FROM app.project_file + WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler} + + + DELETE FROM app.agent_run + WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler} + + + + UPDATE app.project + SET status = #{status}, version = version + 1, updated_at = CURRENT_TIMESTAMP + WHERE id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler} + + diff --git a/server/src/main/resources/mapper/ProjectPlanMapper.xml b/server/src/main/resources/mapper/ProjectPlanMapper.xml new file mode 100644 index 0000000..4e2a9af --- /dev/null +++ b/server/src/main/resources/mapper/ProjectPlanMapper.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + diff --git a/server/src/main/resources/mapper/SkillConfigMapper.xml b/server/src/main/resources/mapper/SkillConfigMapper.xml new file mode 100644 index 0000000..c498eea --- /dev/null +++ b/server/src/main/resources/mapper/SkillConfigMapper.xml @@ -0,0 +1,35 @@ + + + + + + + s.name, s.description, c.version, c.source_type, c.enabled, c.read_only, + c.validation_status, c.validation_message, c.updated_at + + + + + + + + + UPDATE app.skill_config + SET enabled = #{enabled}, + updated_at = CURRENT_TIMESTAMP + WHERE skill_name = #{name} + AND validation_status = 'VALID' + + + diff --git a/server/src/test/java/tech/easyflow/manuagent/DatabaseAndEventIntegrationTest.java b/server/src/test/java/tech/easyflow/manuagent/DatabaseAndEventIntegrationTest.java index 54058bd..625861c 100644 --- a/server/src/test/java/tech/easyflow/manuagent/DatabaseAndEventIntegrationTest.java +++ b/server/src/test/java/tech/easyflow/manuagent/DatabaseAndEventIntegrationTest.java @@ -5,20 +5,53 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import tech.easyflow.manuagent.agent.AgentEventService; +import tech.easyflow.manuagent.agent.AgentRunService; +import tech.easyflow.manuagent.agent.AgentRunStore; import tech.easyflow.manuagent.artifact.ArtifactService; import tech.easyflow.manuagent.artifact.DocxValidator; import tech.easyflow.manuagent.auth.UserService; +import tech.easyflow.manuagent.entity.AgentEventEntity; +import tech.easyflow.manuagent.entity.AppUserEntity; +import tech.easyflow.manuagent.mapper.AgentEventMapper; +import tech.easyflow.manuagent.mapper.AgentRunMapper; +import tech.easyflow.manuagent.mapper.AppUserMapper; +import tech.easyflow.manuagent.mapper.ArtifactMapper; +import tech.easyflow.manuagent.mapper.ProjectMapper; +import tech.easyflow.manuagent.mapper.ProjectPlanMapper; +import tech.easyflow.manuagent.mapper.ModelAssignmentMapper; +import tech.easyflow.manuagent.mapper.ModelConfigMapper; +import tech.easyflow.manuagent.mapper.SkillConfigMapper; +import tech.easyflow.manuagent.model.KeyCipher; +import tech.easyflow.manuagent.model.ModelService; +import tech.easyflow.manuagent.config.AppProperties; +import tech.easyflow.manuagent.skill.SkillPackageReader; +import tech.easyflow.manuagent.skill.SkillService; +import io.agentscope.core.skill.repository.postgresql.PostgresSkillRepository; +import tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler; +import tech.easyflow.manuagent.typehandler.UuidTypeHandler; import tech.easyflow.manuagent.project.ProjectService; import tech.easyflow.manuagent.project.ProjectFileService; import com.fasterxml.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; @@ -77,7 +110,7 @@ class DatabaseAndEventIntegrationTest { * 验证事件按项目全局 ID 增量回放且不重复。 */ @Test - void shouldReplayEventsAfterCursorInOrder() { + void shouldReplayEventsAfterCursorInOrder() throws Exception { JdbcClient jdbc = jdbc(); UUID userId = UUID.randomUUID(); UUID modelId = UUID.randomUUID(); @@ -87,7 +120,7 @@ class DatabaseAndEventIntegrationTest { .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', TRUE) + 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) @@ -99,7 +132,7 @@ class DatabaseAndEventIntegrationTest { """).param("id", runId).param("projectId", projectId).param("modelId", modelId) .param("trace", UUID.randomUUID().toString()).update(); - AgentEventService service = new AgentEventService(jdbc, new ObjectMapper()); + AgentEventService service = new AgentEventService(agentEventMapper(), new ObjectMapper()); 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(); @@ -152,7 +185,7 @@ class DatabaseAndEventIntegrationTest { Files.writeString(document, "first"); ProjectFileService files = mock(ProjectFileService.class); when(files.safeProjectPath(projectId, "artifacts/draft.docx")).thenReturn(document); - ArtifactService artifacts = new ArtifactService(jdbc, files, new DocxValidator()); + ArtifactService artifacts = new ArtifactService(artifactMapper(), files, new DocxValidator()); ObjectMapper mapper = new ObjectMapper(); ArtifactService.ArtifactView first = artifacts.publish( @@ -173,7 +206,7 @@ class DatabaseAndEventIntegrationTest { * 验证项目真删除会清除所有关联业务记录。 */ @Test - void shouldDeleteProjectRecords() { + void shouldDeleteProjectRecords() throws Exception { JdbcClient jdbc = jdbc(); UUID userId = UUID.randomUUID(); UUID projectId = UUID.randomUUID(); @@ -213,7 +246,8 @@ class DatabaseAndEventIntegrationTest { """).param("id", UUID.randomUUID()).param("projectId", projectId).param("runId", runId) .param("sha", "0".repeat(64)).update(); - ProjectService service = new ProjectService(jdbc, mock(UserService.class), new ObjectMapper()); + ProjectService service = new ProjectService( + projectMapper(), mock(ProjectPlanMapper.class), mock(UserService.class), new ObjectMapper()); service.delete(projectId); for (String table : List.of("agent_event", "artifact", "project_plan", "project_file", "agent_run")) { @@ -229,11 +263,453 @@ class DatabaseAndEventIntegrationTest { .single()).isZero(); } + /** + * 验证 Run 创建、等待确认、完成和中断均遵守数据库状态机条件。 + * + *

该测试直接覆盖 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 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), + () -> "admin"); + ModelService.ModelView updated = service.save( + created.id(), + new ModelService.ModelInput( + "测试模型更新", "https://model.example.test", "model-v2", "", + Map.of("timeoutSeconds", 120), capabilities), + () -> "admin"); + service.setDefault(created.id(), () -> "admin"); + ModelService.ModelSecret secret = service.defaultModelSecret(); + + assertThat(updated.name()).isEqualTo("测试模型更新"); + assertThat(updated.apiKeyHint()).endsWith("1234"); + assertThat(secret.apiKey()).isEqualTo("secret-1234"); + assertThat(secret.modelId()).isEqualTo("model-v2"); + assertThat(secret.contextWindow()).isEqualTo(65_536); + assertThat(jdbc().sql("SELECT COUNT(*) FROM app.model_assignment WHERE model_config_id = :id") + .param("id", created.id()).query(Long.class).single()).isEqualTo(3L); + } + + /** + * 验证 Skill 列表只读联查 AgentScope 表,而启停状态仅写应用自管表。 + * + * @throws Exception Mapper XML 初始化失败时抛出 + */ + @Test + void shouldReadAgentScopeSkillsAndUpdateApplicationConfiguration() throws Exception { + SkillConfigMapper mapper = skillConfigMapper(); + AppProperties properties = new AppProperties( + temporaryDirectory, + temporaryDirectory.resolve("deepseek.key"), + temporaryDirectory.resolve("dashscope.key"), + "integration-master-key", + "admin", + "admin", + "https://default.example.test", + "default-model", + 131_072, + "runtime:test", + "bridge", + Duration.ofMinutes(1)); + SkillService service = new SkillService( + mapper, + mock(PostgresSkillRepository.class), + mock(SkillPackageReader.class), + mock(UserService.class), + properties); + + List 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 JdbcClient.create(source); + return source; + } + + /** + * 创建带 UUID、JSONB 类型处理器和显式 XML 语句的产物 Mapper。 + * + *

生产环境由 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 queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class); + verify(mapper).selectListByQuery(queryCaptor.capture()); + String sql = queryCaptor.getValue().toSQL().toLowerCase(java.util.Locale.ROOT); + assertThat(sql) + .contains("project_id", "run_id", "event_type", "payload", "created_at") + .doesNotContain("event_id"); + } +} diff --git a/server/src/test/java/tech/easyflow/manuagent/agent/AgentRunStoreQueryTest.java b/server/src/test/java/tech/easyflow/manuagent/agent/AgentRunStoreQueryTest.java new file mode 100644 index 0000000..e33dba7 --- /dev/null +++ b/server/src/test/java/tech/easyflow/manuagent/agent/AgentRunStoreQueryTest.java @@ -0,0 +1,88 @@ +package tech.easyflow.manuagent.agent; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mybatisflex.core.query.QueryWrapper; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import tech.easyflow.manuagent.entity.AgentRunEntity; +import tech.easyflow.manuagent.common.ApiException; +import tech.easyflow.manuagent.mapper.AgentEventMapper; +import tech.easyflow.manuagent.mapper.AgentRunMapper; +import tech.easyflow.manuagent.mapper.ModelConfigMapper; + +/** + * 验证 Agent 执行热路径只读取判断运行状态所需的最小列。 + */ +class AgentRunStoreQueryTest { + + /** + * {@code ensureRunning} 可能被每个 Agent 检查点调用,因此不得加载 JSONB 和错误详情等整行数据。 + */ + @Test + void shouldSelectOnlyStatusWhenCheckingRunningState() { + AgentRunMapper runMapper = mock(AgentRunMapper.class); + AgentRunEntity running = new AgentRunEntity(); + running.setStatus("RUNNING"); + when(runMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(running); + AgentRunStore store = new AgentRunStore( + runMapper, + mock(AgentEventMapper.class), + mock(ModelConfigMapper.class), + new ObjectMapper()); + + store.ensureRunning(UUID.randomUUID()); + + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class); + verify(runMapper).selectOneByQuery(queryCaptor.capture()); + String sql = queryCaptor.getValue().toSQL().toLowerCase(java.util.Locale.ROOT); + assertThat(sql) + .contains("status") + .doesNotContain("pending_interrupt", "error_message", "trace_id"); + } + + /** + * 默认模型缺失属于数据库配置异常,不应在 ORM 迁移中新增 409 业务错误。 + */ + @Test + void shouldKeepMissingDefaultModelAsUnexpectedTechnicalFailure() { + AgentRunMapper runMapper = mock(AgentRunMapper.class); + when(runMapper.selectCountByQuery(any(QueryWrapper.class))).thenReturn(0L); + AgentRunStore store = new AgentRunStore( + runMapper, + mock(AgentEventMapper.class), + mock(ModelConfigMapper.class), + new ObjectMapper()); + + assertThatThrownBy(() -> store.create(UUID.randomUUID(), "INITIAL", null)) + .isInstanceOf(IllegalStateException.class) + .isNotInstanceOf(ApiException.class); + } + + /** + * 按 ID 强制读取或运行状态检查遇到不存在的 Run 时,不新增 404 或“已中断”业务语义。 + */ + @Test + void shouldKeepMissingRequiredRunAsUnexpectedTechnicalFailure() { + AgentRunStore store = new AgentRunStore( + mock(AgentRunMapper.class), + mock(AgentEventMapper.class), + mock(ModelConfigMapper.class), + new ObjectMapper()); + UUID runId = UUID.randomUUID(); + + assertThatThrownBy(() -> store.require(runId)) + .isInstanceOf(IllegalStateException.class) + .isNotInstanceOf(ApiException.class); + assertThatThrownBy(() -> store.ensureRunning(runId)) + .isInstanceOf(IllegalStateException.class) + .isNotInstanceOf(AgentExecutionService.RunInterruptedException.class); + } +} diff --git a/server/src/test/java/tech/easyflow/manuagent/artifact/ArtifactServiceQueryTest.java b/server/src/test/java/tech/easyflow/manuagent/artifact/ArtifactServiceQueryTest.java new file mode 100644 index 0000000..ad74a2d --- /dev/null +++ b/server/src/test/java/tech/easyflow/manuagent/artifact/ArtifactServiceQueryTest.java @@ -0,0 +1,69 @@ +package tech.easyflow.manuagent.artifact; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +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.common.ApiException; +import tech.easyflow.manuagent.entity.ArtifactEntity; +import tech.easyflow.manuagent.mapper.ArtifactMapper; +import tech.easyflow.manuagent.project.ProjectFileService; + +/** + * 验证产物查询在 MyBatis-Flex 迁移后保持原 JDBC SQL 的最小字段范围。 + */ +class ArtifactServiceQueryTest { + + /** + * 产物列表只应读取接口视图字段,不加载下载路径、MIME、摘要和创建时间。 + */ + @Test + void shouldSelectOnlyArtifactViewColumnsWhenListing() { + ArtifactMapper mapper = mock(ArtifactMapper.class); + ArtifactEntity artifact = new ArtifactEntity(); + artifact.setId(UUID.randomUUID()); + artifact.setProjectId(UUID.randomUUID()); + artifact.setSizeBytes(1L); + when(mapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of(artifact)); + ArtifactService service = new ArtifactService( + mapper, mock(ProjectFileService.class), mock(DocxValidator.class)); + + service.list(artifact.getProjectId()); + + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class); + verify(mapper).selectListByQuery(queryCaptor.capture()); + String sql = queryCaptor.getValue().toSQL().toLowerCase(java.util.Locale.ROOT); + assertThat(sql) + .contains("metadata_json", "published_at", "size_bytes") + .doesNotContain("relative_path", "mime_type", "sha256", "created_at"); + } + + /** + * 下载查询只应读取完整性校验和资源响应所需的六个字段。 + */ + @Test + void shouldSelectOnlyStoredArtifactColumnsWhenDownloading() { + ArtifactMapper mapper = mock(ArtifactMapper.class); + when(mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(null); + ArtifactService service = new ArtifactService( + mapper, mock(ProjectFileService.class), mock(DocxValidator.class)); + + assertThatThrownBy(() -> service.download(UUID.randomUUID())) + .isInstanceOf(ApiException.class); + + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class); + verify(mapper).selectOneByQuery(queryCaptor.capture()); + String sql = queryCaptor.getValue().toSQL().toLowerCase(java.util.Locale.ROOT); + assertThat(sql) + .contains("project_id", "relative_path", "mime_type", "size_bytes", "sha256") + .doesNotContain("metadata_json", "published_at", "created_at"); + } +} diff --git a/server/src/test/java/tech/easyflow/manuagent/auth/UserServiceTest.java b/server/src/test/java/tech/easyflow/manuagent/auth/UserServiceTest.java new file mode 100644 index 0000000..b4a38a5 --- /dev/null +++ b/server/src/test/java/tech/easyflow/manuagent/auth/UserServiceTest.java @@ -0,0 +1,146 @@ +package tech.easyflow.manuagent.auth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.mybatisflex.core.query.QueryWrapper; +import com.mybatisflex.core.FlexGlobalConfig; +import com.mybatisflex.core.mybatis.FlexConfiguration; +import javax.sql.DataSource; +import java.util.UUID; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; +import tech.easyflow.manuagent.common.ApiException; +import tech.easyflow.manuagent.config.AppProperties; +import tech.easyflow.manuagent.entity.AppUserEntity; +import tech.easyflow.manuagent.mapper.AppUserMapper; +import tech.easyflow.manuagent.typehandler.UuidTypeHandler; + +/** + * 验证 {@link UserService} 在迁移到 MyBatis-Flex 后保持原有管理员初始化与认证语义。 + */ +class UserServiceTest { + + private AppUserMapper mapper; + private PasswordEncoder passwordEncoder; + private AppProperties properties; + private UserService service; + + /** + * 为每个测试创建隔离的 Mapper 与安全组件替身。 + */ + @BeforeEach + void setUp() { + // 生产环境由 Spring Boot 在 Mapper 使用前注册 TypeHandler;纯单测需显式建立同等的元数据环境。 + Environment environment = new Environment( + "user-service-unit-test", new JdbcTransactionFactory(), mock(DataSource.class)); + FlexConfiguration configuration = new FlexConfiguration(environment); + configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class); + FlexGlobalConfig globalConfig = new FlexGlobalConfig(); + globalConfig.setConfiguration(configuration); + FlexGlobalConfig.setDefaultConfig(globalConfig); + + mapper = mock(AppUserMapper.class); + passwordEncoder = mock(PasswordEncoder.class); + properties = mock(AppProperties.class); + service = new UserService(mapper, passwordEncoder, properties); + } + + /** + * 首次启动时应创建一个启用的管理员,并保存编码后的密码。 + */ + @Test + void shouldCreateInitialAdministratorWhenUserTableIsEmpty() { + when(mapper.selectCountByQuery(any(QueryWrapper.class))).thenReturn(0L); + when(properties.adminUsername()).thenReturn("admin"); + when(properties.adminPassword()).thenReturn("plain-password"); + when(passwordEncoder.encode("plain-password")).thenReturn("encoded-password"); + + service.run(null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AppUserEntity.class); + verify(mapper).insertSelective(captor.capture()); + AppUserEntity created = captor.getValue(); + assertThat(created.getId()).isNotNull(); + assertThat(created.getUsername()).isEqualTo("admin"); + assertThat(created.getPasswordHash()).isEqualTo("encoded-password"); + assertThat(created.getDisplayName()).isEqualTo("管理员"); + } + + /** + * 已存在用户时不得重复创建默认管理员。 + */ + @Test + void shouldNotCreateAdministratorWhenAnyUserExists() { + when(mapper.selectCountByQuery(any(QueryWrapper.class))).thenReturn(1L); + + service.run(null); + + verify(mapper, never()).insertSelective(any(AppUserEntity.class)); + } + + /** + * 用户查询结果应转换为 Spring Security 用户详情,并保留禁用状态。 + */ + @Test + void shouldLoadSecurityUserAndResolveUserId() { + UUID userId = UUID.randomUUID(); + AppUserEntity entity = new AppUserEntity(); + entity.setId(userId); + entity.setUsername("admin"); + entity.setPasswordHash("encoded-password"); + entity.setEnabled(false); + when(mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(entity); + + var details = service.loadUserByUsername("admin"); + + assertThat(details.getUsername()).isEqualTo("admin"); + assertThat(details.getPassword()).isEqualTo("encoded-password"); + assertThat(details.isEnabled()).isFalse(); + assertThat(service.requireUserId("admin")).isEqualTo(userId); + } + + /** + * 登录认证查询应保持迁移前 SQL 的三个必要字段,避免读取展示名和审计时间。 + */ + @Test + void shouldSelectOnlyAuthenticationColumnsWhenLoadingUser() { + AppUserEntity entity = new AppUserEntity(); + entity.setUsername("admin"); + entity.setPasswordHash("encoded-password"); + entity.setEnabled(true); + when(mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(entity); + + service.loadUserByUsername("admin"); + + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class); + verify(mapper).selectOneByQuery(queryCaptor.capture()); + String sql = queryCaptor.getValue().toSQL().toLowerCase(java.util.Locale.ROOT); + assertThat(sql) + .contains("username", "password_hash", "enabled") + .doesNotContain("display_name", "last_login_at", "created_at", "updated_at"); + } + + /** + * 不存在的登录名应分别维持认证层和接口层原有的异常类型。 + */ + @Test + void shouldRejectMissingUser() { + when(mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(null); + + assertThatThrownBy(() -> service.loadUserByUsername("missing")) + .isInstanceOf(UsernameNotFoundException.class); + assertThatThrownBy(() -> service.requireUserId("missing")) + .isInstanceOf(ApiException.class); + } +} diff --git a/server/src/test/java/tech/easyflow/manuagent/model/ModelServiceQueryTest.java b/server/src/test/java/tech/easyflow/manuagent/model/ModelServiceQueryTest.java new file mode 100644 index 0000000..e973274 --- /dev/null +++ b/server/src/test/java/tech/easyflow/manuagent/model/ModelServiceQueryTest.java @@ -0,0 +1,76 @@ +package tech.easyflow.manuagent.model; + +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.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import tech.easyflow.manuagent.auth.UserService; +import tech.easyflow.manuagent.config.AppProperties; +import tech.easyflow.manuagent.entity.ModelConfigEntity; +import tech.easyflow.manuagent.mapper.AppUserMapper; +import tech.easyflow.manuagent.mapper.ModelAssignmentMapper; +import tech.easyflow.manuagent.mapper.ModelConfigMapper; + +/** + * 验证模型管理接口使用最小字段投影,不把加密 API Key 读入普通请求内存。 + */ +class ModelServiceQueryTest { + + /** + * 模型列表只需要页面展示字段,查询 SQL 不得包含密文和密钥版本列。 + */ + @Test + void shouldExcludeEncryptedKeyFromModelListQuery() { + ModelConfigMapper modelMapper = mock(ModelConfigMapper.class); + when(modelMapper.selectListByQuery(any(QueryWrapper.class))) + .thenReturn(List.of(modelViewEntity())); + ModelService service = new ModelService( + modelMapper, + mock(ModelAssignmentMapper.class), + mock(AppUserMapper.class), + mock(UserService.class), + mock(KeyCipher.class), + mock(AppProperties.class), + new ObjectMapper()); + + List models = service.list(); + + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class); + verify(modelMapper).selectListByQuery(queryCaptor.capture()); + String sql = queryCaptor.getValue().toSQL().toLowerCase(java.util.Locale.ROOT); + assertThat(models).hasSize(1); + assertThat(sql) + .contains("api_key_hint", "capabilities_json", "is_default") + .doesNotContain("api_key_ciphertext", "key_version"); + } + + /** + * 构造包含全部展示字段的实体,避免测试依赖数据库或模型密钥解密逻辑。 + * + * @return 模拟数据库返回的安全投影实体 + */ + private ModelConfigEntity modelViewEntity() { + ModelConfigEntity entity = new ModelConfigEntity(); + entity.setId(UUID.randomUUID()); + entity.setName("测试模型"); + entity.setProvider("OPENAI_COMPATIBLE"); + entity.setBaseUrl("https://example.test"); + entity.setModelId("model"); + entity.setApiKeyHint("••••1234"); + entity.setConfigJson("{}"); + entity.setCapabilitiesJson("{\"contextWindow\":8192}"); + entity.setEnabled(true); + entity.setDefaultModel(true); + entity.setUpdatedAt(OffsetDateTime.now()); + return entity; + } +} diff --git a/server/src/test/java/tech/easyflow/manuagent/project/ProjectFileServiceTest.java b/server/src/test/java/tech/easyflow/manuagent/project/ProjectFileServiceTest.java index 0c5b642..ea2960b 100644 --- a/server/src/test/java/tech/easyflow/manuagent/project/ProjectFileServiceTest.java +++ b/server/src/test/java/tech/easyflow/manuagent/project/ProjectFileServiceTest.java @@ -2,18 +2,25 @@ package tech.easyflow.manuagent.project; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +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.nio.file.Files; import java.nio.file.Path; import java.time.Duration; +import java.util.List; import java.util.UUID; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import org.springframework.jdbc.core.simple.JdbcClient; +import org.mockito.ArgumentCaptor; /** * 验证项目工作区文件操作。 @@ -35,7 +42,7 @@ class ProjectFileServiceTest { "test-master", "admin", "admin", "https://example.test", "model", 131_072, "runtime:test", "bridge", Duration.ofMinutes(1)); ProjectFileService service = new ProjectFileService( - mock(JdbcClient.class), mock(UserService.class), mock(ProjectService.class), properties); + mock(ProjectFileMapper.class), mock(UserService.class), mock(ProjectService.class), properties); UUID projectId = UUID.randomUUID(); Path file = service.projectRoot(projectId).resolve("inputs/company.txt"); Files.createDirectories(file.getParent()); @@ -72,17 +79,50 @@ class ProjectFileServiceTest { .isInstanceOf(ApiException.class); } + /** + * 文件列表应保持迁移前 SQL 的字段范围,避免加载存储文件名、摘要和上传人等内部列。 + */ + @Test + void shouldSelectOnlyFileViewColumnsWhenListing() { + ProjectFileMapper mapper = mock(ProjectFileMapper.class); + ProjectService projectService = mock(ProjectService.class); + ProjectFileEntity file = new ProjectFileEntity(); + file.setId(UUID.randomUUID()); + file.setProjectId(UUID.randomUUID()); + file.setSizeBytes(1L); + when(mapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of(file)); + ProjectFileService service = new ProjectFileService( + mapper, mock(UserService.class), projectService, properties()); + + service.list(file.getProjectId()); + + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class); + verify(mapper).selectListByQuery(queryCaptor.capture()); + String sql = queryCaptor.getValue().toSQL().toLowerCase(java.util.Locale.ROOT); + assertThat(sql) + .contains("original_name", "relative_path", "mime_type", "size_bytes", "created_at") + .doesNotContain("stored_name", "sha256", "uploaded_by", "updated_at"); + } + /** * 创建使用临时数据目录的文件服务。 * * @return 文件服务 */ private ProjectFileService service() { - AppProperties properties = new AppProperties( + return new ProjectFileService( + mock(ProjectFileMapper.class), mock(UserService.class), mock(ProjectService.class), properties()); + } + + /** + * 创建测试统一使用的应用配置。 + * + * @return 指向临时数据目录的配置 + */ + private AppProperties properties() { + return new AppProperties( temporaryDirectory, Path.of("deepseek"), Path.of("dashscope"), "test-master", "admin", "admin", "https://example.test", "model", 131_072, "runtime:test", "bridge", Duration.ofMinutes(1)); - return new ProjectFileService( - mock(JdbcClient.class), mock(UserService.class), mock(ProjectService.class), properties); } } diff --git a/server/src/test/java/tech/easyflow/manuagent/project/ProjectServiceQueryTest.java b/server/src/test/java/tech/easyflow/manuagent/project/ProjectServiceQueryTest.java new file mode 100644 index 0000000..5021ec2 --- /dev/null +++ b/server/src/test/java/tech/easyflow/manuagent/project/ProjectServiceQueryTest.java @@ -0,0 +1,127 @@ +package tech.easyflow.manuagent.project; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.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.auth.UserService; +import tech.easyflow.manuagent.entity.ProjectEntity; +import tech.easyflow.manuagent.entity.ProjectPlanEntity; +import tech.easyflow.manuagent.common.ApiException; +import tech.easyflow.manuagent.mapper.ProjectMapper; +import tech.easyflow.manuagent.mapper.ProjectPlanMapper; + +/** + * 验证项目查询在 MyBatis-Flex 迁移后保持原 JDBC SQL 的字段范围。 + */ +class ProjectServiceQueryTest { + + /** + * 项目列表只读取接口视图字段,不加载创建人等内部字段。 + */ + @Test + void shouldSelectOnlyProjectViewColumnsWhenListing() { + ProjectMapper mapper = mock(ProjectMapper.class); + ProjectEntity project = project(); + when(mapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of(project)); + ProjectService service = service(mapper); + + service.list(); + + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class); + verify(mapper).selectListByQuery(queryCaptor.capture()); + assertProjectViewProjection(queryCaptor.getValue()); + } + + /** + * 单项目读取与列表共用同一接口投影,且仍按主键精确过滤。 + */ + @Test + void shouldSelectOnlyProjectViewColumnsWhenRequiringProject() { + ProjectMapper mapper = mock(ProjectMapper.class); + ProjectEntity project = project(); + when(mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(project); + ProjectService service = service(mapper); + + ProjectService.ProjectView view = service.require(project.getId()); + + assertThat(view.id()).isEqualTo(project.getId()); + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class); + verify(mapper).selectOneByQuery(queryCaptor.capture()); + QueryWrapper query = queryCaptor.getValue(); + assertProjectViewProjection(query); + assertThat(query.toSQL().toLowerCase(java.util.Locale.ROOT)).contains("where", "id"); + } + + /** + * 数据库存量规划 JSON 损坏仍应进入统一未预期异常路径,不新增业务错误码。 + */ + @Test + void shouldKeepInvalidStoredPlanJsonAsUnexpectedTechnicalFailure() { + ProjectPlanMapper planMapper = mock(ProjectPlanMapper.class); + ProjectPlanEntity plan = new ProjectPlanEntity(); + plan.setPlanJson("{invalid-json"); + when(planMapper.selectCurrent(any(UUID.class))).thenReturn(plan); + ProjectService service = new ProjectService( + mock(ProjectMapper.class), planMapper, mock(UserService.class), new ObjectMapper()); + + assertThatThrownBy(() -> service.currentPlan(UUID.randomUUID())) + .isInstanceOf(IllegalStateException.class) + .isNotInstanceOf(ApiException.class); + } + + /** + * 创建满足项目接口映射要求的最小实体。 + * + * @return 项目实体 + */ + private ProjectEntity project() { + ProjectEntity project = new ProjectEntity(); + project.setId(UUID.randomUUID()); + project.setVersion(1L); + return project; + } + + /** + * 创建仅用于查询行为验证的项目服务。 + * + * @param mapper 待验证的项目 Mapper + * @return 项目服务 + */ + private ProjectService service(ProjectMapper mapper) { + return new ProjectService( + mapper, + mock(ProjectPlanMapper.class), + mock(UserService.class), + new ObjectMapper()); + } + + /** + * 断言查询只包含迁移前项目视图 SQL 使用的字段。 + * + * @param query 待检查的 MyBatis-Flex 查询 + */ + private void assertProjectViewProjection(QueryWrapper query) { + String sql = query.toSQL().toLowerCase(java.util.Locale.ROOT); + assertThat(sql) + .contains( + "company_name", + "project_name", + "agui_thread_id", + "application_level", + "status", + "version", + "created_at", + "updated_at") + .doesNotContain("created_by"); + } +}