重构:使用 MyBatis-Flex 迁移应用 ORM
将应用自管表的 JdbcClient 数据访问迁移为实体、Mapper、构造器查询和必要的显式 SQL。 保留 AgentScope 自管表及原有业务语义,并补充事务、查询与数据库集成测试。
This commit is contained in:
@@ -19,6 +19,7 @@
|
|||||||
<properties>
|
<properties>
|
||||||
<java.version>21</java.version>
|
<java.version>21</java.version>
|
||||||
<agentscope.version>2.0.1</agentscope.version>
|
<agentscope.version>2.0.1</agentscope.version>
|
||||||
|
<mybatis-flex.version>1.11.8</mybatis-flex.version>
|
||||||
<tika.version>3.2.3</tika.version>
|
<tika.version>3.2.3</tika.version>
|
||||||
<testcontainers.version>1.21.4</testcontainers.version>
|
<testcontainers.version>1.21.4</testcontainers.version>
|
||||||
</properties>
|
</properties>
|
||||||
@@ -40,6 +41,11 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-jdbc</artifactId>
|
<artifactId>spring-boot-starter-jdbc</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.mybatis-flex</groupId>
|
||||||
|
<artifactId>mybatis-flex-spring-boot3-starter</artifactId>
|
||||||
|
<version>${mybatis-flex.version}</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.flywaydb</groupId>
|
<groupId>org.flywaydb</groupId>
|
||||||
<artifactId>flyway-database-postgresql</artifactId>
|
<artifactId>flyway-database-postgresql</artifactId>
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ package tech.easyflow.manuagent.agent;
|
|||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.support.TransactionSynchronization;
|
import org.springframework.transaction.support.TransactionSynchronization;
|
||||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||||
@@ -17,6 +17,8 @@ import reactor.core.publisher.Flux;
|
|||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
import reactor.core.publisher.Sinks;
|
import reactor.core.publisher.Sinks;
|
||||||
import reactor.core.scheduler.Schedulers;
|
import reactor.core.scheduler.Schedulers;
|
||||||
|
import tech.easyflow.manuagent.entity.AgentEventEntity;
|
||||||
|
import tech.easyflow.manuagent.mapper.AgentEventMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 持久化并查询项目级 AG-UI 事件。
|
* 持久化并查询项目级 AG-UI 事件。
|
||||||
@@ -24,18 +26,18 @@ import reactor.core.scheduler.Schedulers;
|
|||||||
@Service
|
@Service
|
||||||
public class AgentEventService {
|
public class AgentEventService {
|
||||||
|
|
||||||
private final JdbcClient jdbc;
|
private final AgentEventMapper eventMapper;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final Map<UUID, Sinks.Many<EventView>> liveStreams = new ConcurrentHashMap<>();
|
private final Map<UUID, Sinks.Many<EventView>> liveStreams = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建事件服务。
|
* 创建事件服务。
|
||||||
*
|
*
|
||||||
* @param jdbc JDBC 客户端
|
* @param eventMapper Agent 事件 Mapper
|
||||||
* @param objectMapper JSON 映射器
|
* @param objectMapper JSON 映射器
|
||||||
*/
|
*/
|
||||||
public AgentEventService(JdbcClient jdbc, ObjectMapper objectMapper) {
|
public AgentEventService(AgentEventMapper eventMapper, ObjectMapper objectMapper) {
|
||||||
this.jdbc = jdbc;
|
this.eventMapper = eventMapper;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,18 +55,14 @@ public class AgentEventService {
|
|||||||
ObjectNode object = value.isObject()
|
ObjectNode object = value.isObject()
|
||||||
? (ObjectNode) value
|
? (ObjectNode) value
|
||||||
: objectMapper.createObjectNode().set("value", value);
|
: objectMapper.createObjectNode().set("value", value);
|
||||||
EventView event = jdbc.sql("""
|
AgentEventEntity entity = new AgentEventEntity();
|
||||||
INSERT INTO app.agent_event(project_id, run_id, event_type, event_id, payload)
|
entity.setProjectId(projectId);
|
||||||
VALUES (:projectId, :runId, :eventType, :eventId, CAST(:payload AS jsonb))
|
entity.setRunId(runId);
|
||||||
RETURNING id, project_id, run_id, event_type, payload, created_at
|
entity.setEventType(eventType);
|
||||||
""")
|
entity.setEventId(UUID.randomUUID().toString());
|
||||||
.param("projectId", projectId)
|
entity.setPayloadJson(object.toString());
|
||||||
.param("runId", runId)
|
// 写入与 RETURNING 必须由同一条 SQL 完成,以原子取得数据库分配的事件游标。
|
||||||
.param("eventType", eventType)
|
EventView event = toEventView(eventMapper.insertReturning(entity));
|
||||||
.param("eventId", UUID.randomUUID().toString())
|
|
||||||
.param("payload", object.toString())
|
|
||||||
.query(this::mapEvent)
|
|
||||||
.single();
|
|
||||||
publishAfterCommit(event);
|
publishAfterCommit(event);
|
||||||
return event;
|
return event;
|
||||||
}
|
}
|
||||||
@@ -77,19 +75,24 @@ public class AgentEventService {
|
|||||||
* @param limit 最大返回数量
|
* @param limit 最大返回数量
|
||||||
* @return 有序事件
|
* @return 有序事件
|
||||||
*/
|
*/
|
||||||
|
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
|
||||||
public List<EventView> listAfter(UUID projectId, long afterId, int limit) {
|
public List<EventView> listAfter(UUID projectId, long afterId, int limit) {
|
||||||
return jdbc.sql("""
|
QueryWrapper query = QueryWrapper.create()
|
||||||
SELECT id, project_id, run_id, event_type, payload, created_at
|
.select(
|
||||||
FROM app.agent_event
|
AgentEventEntity::getId,
|
||||||
WHERE project_id = :projectId AND id > :afterId
|
AgentEventEntity::getProjectId,
|
||||||
ORDER BY id
|
AgentEventEntity::getRunId,
|
||||||
LIMIT :limit
|
AgentEventEntity::getEventType,
|
||||||
""")
|
AgentEventEntity::getPayloadJson,
|
||||||
.param("projectId", projectId)
|
AgentEventEntity::getCreatedAt)
|
||||||
.param("afterId", Math.max(0, afterId))
|
.where(AgentEventEntity::getProjectId).eq(projectId)
|
||||||
.param("limit", Math.clamp(limit, 1, 1000))
|
.and(AgentEventEntity::getId).gt(Math.max(0, afterId))
|
||||||
.query(this::mapEvent)
|
.orderBy(AgentEventEntity::getId).asc()
|
||||||
.list();
|
.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 {
|
try {
|
||||||
return new EventView(
|
return new EventView(
|
||||||
rs.getLong("id"),
|
entity.getId(),
|
||||||
rs.getObject("project_id", UUID.class),
|
entity.getProjectId(),
|
||||||
rs.getObject("run_id", UUID.class),
|
entity.getRunId(),
|
||||||
rs.getString("event_type"),
|
entity.getEventType(),
|
||||||
objectMapper.readTree(rs.getString("payload")),
|
objectMapper.readTree(entity.getPayloadJson()),
|
||||||
rs.getObject("created_at", OffsetDateTime.class));
|
entity.getCreatedAt());
|
||||||
} catch (com.fasterxml.jackson.core.JsonProcessingException exception) {
|
} catch (com.fasterxml.jackson.core.JsonProcessingException exception) {
|
||||||
throw new java.sql.SQLException("Agent 事件 JSON 无法解析", exception);
|
throw new IllegalStateException("Agent 事件 JSON 无法解析", exception);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,13 +20,13 @@ import java.util.concurrent.ExecutorService;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.transaction.support.TransactionSynchronization;
|
import org.springframework.transaction.support.TransactionSynchronization;
|
||||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||||
import org.springframework.transaction.support.TransactionTemplate;
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
import reactor.core.publisher.Sinks;
|
import reactor.core.publisher.Sinks;
|
||||||
|
import tech.easyflow.manuagent.mapper.AgentRunMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 驱动材料检验、规划 Ask 和自动编写 Run。
|
* 驱动材料检验、规划 Ask 和自动编写 Run。
|
||||||
@@ -35,7 +35,7 @@ import reactor.core.publisher.Sinks;
|
|||||||
public class AgentRunService {
|
public class AgentRunService {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(AgentRunService.class);
|
private static final Logger log = LoggerFactory.getLogger(AgentRunService.class);
|
||||||
private final JdbcClient jdbc;
|
private final AgentRunMapper runMapper;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final AgentExecutionService executionService;
|
private final AgentExecutionService executionService;
|
||||||
private final AgentOutputService outputService;
|
private final AgentOutputService outputService;
|
||||||
@@ -52,7 +52,7 @@ public class AgentRunService {
|
|||||||
/**
|
/**
|
||||||
* 创建 Agent Run 服务。
|
* 创建 Agent Run 服务。
|
||||||
*
|
*
|
||||||
* @param jdbc JDBC 客户端
|
* @param runMapper Agent Run Mapper
|
||||||
* @param objectMapper JSON 映射器
|
* @param objectMapper JSON 映射器
|
||||||
* @param executionService Agent 执行服务
|
* @param executionService Agent 执行服务
|
||||||
* @param outputService Agent 结构化输出服务
|
* @param outputService Agent 结构化输出服务
|
||||||
@@ -66,7 +66,7 @@ public class AgentRunService {
|
|||||||
* @param transactions 编程式事务模板
|
* @param transactions 编程式事务模板
|
||||||
*/
|
*/
|
||||||
public AgentRunService(
|
public AgentRunService(
|
||||||
JdbcClient jdbc,
|
AgentRunMapper runMapper,
|
||||||
ObjectMapper objectMapper,
|
ObjectMapper objectMapper,
|
||||||
AgentExecutionService executionService,
|
AgentExecutionService executionService,
|
||||||
AgentOutputService outputService,
|
AgentOutputService outputService,
|
||||||
@@ -78,7 +78,7 @@ public class AgentRunService {
|
|||||||
ArtifactService artifactService,
|
ArtifactService artifactService,
|
||||||
ExecutorService applicationExecutor,
|
ExecutorService applicationExecutor,
|
||||||
TransactionTemplate transactions) {
|
TransactionTemplate transactions) {
|
||||||
this.jdbc = jdbc;
|
this.runMapper = runMapper;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.executionService = executionService;
|
this.executionService = executionService;
|
||||||
this.outputService = outputService;
|
this.outputService = outputService;
|
||||||
@@ -207,15 +207,7 @@ public class AgentRunService {
|
|||||||
if (run == null || !"RUNNING".equals(run.status())) {
|
if (run == null || !"RUNNING".equals(run.status())) {
|
||||||
throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_ACTIVE", "当前没有正在执行的任务");
|
throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_ACTIVE", "当前没有正在执行的任务");
|
||||||
}
|
}
|
||||||
int updated = jdbc.sql("""
|
int updated = runMapper.interruptRunning(run.id());
|
||||||
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();
|
|
||||||
requireTerminalUpdate(updated);
|
requireTerminalUpdate(updated);
|
||||||
eventService.append(projectId, run.id(), "RUN_FINISHED", Map.of("outcome", "CANCELLED"));
|
eventService.append(projectId, run.id(), "RUN_FINISHED", Map.of("outcome", "CANCELLED"));
|
||||||
onCommit(() -> {
|
onCommit(() -> {
|
||||||
@@ -473,15 +465,7 @@ public class AgentRunService {
|
|||||||
private void finishWaiting(UUID projectId, UUID runId, JsonNode interrupt) {
|
private void finishWaiting(UUID projectId, UUID runId, JsonNode interrupt) {
|
||||||
transactions.executeWithoutResult(status -> {
|
transactions.executeWithoutResult(status -> {
|
||||||
eventService.append(projectId, runId, "ASK_REQUESTED", interrupt);
|
eventService.append(projectId, runId, "ASK_REQUESTED", interrupt);
|
||||||
int updated = jdbc.sql("""
|
int updated = runMapper.waitForInput(runId, interrupt.toString());
|
||||||
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();
|
|
||||||
requireTerminalUpdate(updated);
|
requireTerminalUpdate(updated);
|
||||||
eventService.append(projectId, runId, "RUN_FINISHED", Map.of("outcome", "INTERRUPT"));
|
eventService.append(projectId, runId, "RUN_FINISHED", Map.of("outcome", "INTERRUPT"));
|
||||||
});
|
});
|
||||||
@@ -575,14 +559,7 @@ public class AgentRunService {
|
|||||||
ArtifactService.ArtifactView artifact = artifactService.publishCandidate(
|
ArtifactService.ArtifactView artifact = artifactService.publishCandidate(
|
||||||
projectId, run.id(), run.startedAt().toInstant(), metadata);
|
projectId, run.id(), run.startedAt().toInstant(), metadata);
|
||||||
eventService.append(projectId, run.id(), "ARTIFACT_PUBLISHED", artifact);
|
eventService.append(projectId, run.id(), "ARTIFACT_PUBLISHED", artifact);
|
||||||
int updated = jdbc.sql("""
|
int updated = runMapper.completeRunning(run.id());
|
||||||
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();
|
|
||||||
requireTerminalUpdate(updated);
|
requireTerminalUpdate(updated);
|
||||||
projectService.updateStatus(projectId, "DELIVERED");
|
projectService.updateStatus(projectId, "DELIVERED");
|
||||||
eventService.append(projectId, run.id(), "RUN_FINISHED", Map.of("outcome", "SUCCESS"));
|
eventService.append(projectId, run.id(), "RUN_FINISHED", Map.of("outcome", "SUCCESS"));
|
||||||
@@ -602,15 +579,7 @@ public class AgentRunService {
|
|||||||
: "Agent 执行失败,请稍后重试";
|
: "Agent 执行失败,请稍后重试";
|
||||||
try {
|
try {
|
||||||
transactions.executeWithoutResult(status -> {
|
transactions.executeWithoutResult(status -> {
|
||||||
int updated = jdbc.sql("""
|
int updated = runMapper.failRunning(run.id(), message);
|
||||||
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();
|
|
||||||
if (updated == 1) {
|
if (updated == 1) {
|
||||||
eventService.append(run.projectId(), run.id(), "RUN_ERROR", Map.of(
|
eventService.append(run.projectId(), run.id(), "RUN_ERROR", Map.of(
|
||||||
"code", "AGENT_RUN_FAILED", "message", message));
|
"code", "AGENT_RUN_FAILED", "message", message));
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ import tech.easyflow.manuagent.project.ProjectService;
|
|||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import java.time.OffsetDateTime;
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
|
||||||
import org.springframework.stereotype.Service;
|
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 持久化状态。
|
* 集中读写 Agent Run 持久化状态。
|
||||||
@@ -17,17 +21,27 @@ import org.springframework.stereotype.Service;
|
|||||||
@Service
|
@Service
|
||||||
public class AgentRunStore {
|
public class AgentRunStore {
|
||||||
|
|
||||||
private final JdbcClient jdbc;
|
private final AgentRunMapper runMapper;
|
||||||
|
private final AgentEventMapper eventMapper;
|
||||||
|
private final ModelConfigMapper modelMapper;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建 Run 状态存储。
|
* 创建 Run 状态存储。
|
||||||
*
|
*
|
||||||
* @param jdbc JDBC 客户端
|
* @param runMapper Agent Run Mapper
|
||||||
|
* @param eventMapper Agent 事件 Mapper
|
||||||
|
* @param modelMapper 模型配置 Mapper
|
||||||
* @param objectMapper JSON 映射器
|
* @param objectMapper JSON 映射器
|
||||||
*/
|
*/
|
||||||
public AgentRunStore(JdbcClient jdbc, ObjectMapper objectMapper) {
|
public AgentRunStore(
|
||||||
this.jdbc = jdbc;
|
AgentRunMapper runMapper,
|
||||||
|
AgentEventMapper eventMapper,
|
||||||
|
ModelConfigMapper modelMapper,
|
||||||
|
ObjectMapper objectMapper) {
|
||||||
|
this.runMapper = runMapper;
|
||||||
|
this.eventMapper = eventMapper;
|
||||||
|
this.modelMapper = modelMapper;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,34 +53,36 @@ public class AgentRunStore {
|
|||||||
* @param parentRunId 父 Run ID
|
* @param parentRunId 父 Run ID
|
||||||
* @return 新 Run
|
* @return 新 Run
|
||||||
*/
|
*/
|
||||||
|
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
|
||||||
public AgentRunService.RunView create(UUID projectId, String triggerType, UUID parentRunId) {
|
public AgentRunService.RunView create(UUID projectId, String triggerType, UUID parentRunId) {
|
||||||
Integer active = jdbc.sql("""
|
QueryWrapper activeRuns = QueryWrapper.create()
|
||||||
SELECT count(*) FROM app.agent_run
|
.where(AgentRunEntity::getProjectId).eq(projectId)
|
||||||
WHERE project_id = :projectId AND status IN ('RUNNING', 'WAITING_INPUT')
|
.and(AgentRunEntity::getStatus).in("RUNNING", "WAITING_INPUT");
|
||||||
""")
|
long active = runMapper.selectCountByQuery(activeRuns);
|
||||||
.param("projectId", projectId)
|
|
||||||
.query(Integer.class)
|
|
||||||
.single();
|
|
||||||
if (active > 0) {
|
if (active > 0) {
|
||||||
throw new ApiException(HttpStatus.CONFLICT, "RUN_ALREADY_ACTIVE", "项目已有正在执行或等待确认的任务");
|
throw new ApiException(HttpStatus.CONFLICT, "RUN_ALREADY_ACTIVE", "项目已有正在执行或等待确认的任务");
|
||||||
}
|
}
|
||||||
UUID id = UUID.randomUUID();
|
QueryWrapper defaultModel = QueryWrapper.create()
|
||||||
UUID modelId = jdbc.sql("SELECT id FROM app.model_config WHERE is_default AND enabled")
|
.select(ModelConfigEntity::getId)
|
||||||
.query(UUID.class)
|
.where(ModelConfigEntity::getDefaultModel).eq(true)
|
||||||
.single();
|
.and(ModelConfigEntity::getEnabled).eq(true);
|
||||||
jdbc.sql("""
|
ModelConfigEntity model = modelMapper.selectOneByQuery(defaultModel);
|
||||||
INSERT INTO app.agent_run(
|
if (model == null) {
|
||||||
id, project_id, parent_run_id, model_config_id, trigger_type, status, trace_id)
|
// 迁移前的强制单条查询在该数据库不变量失效时进入统一 500 路径,不能新增 409 业务语义。
|
||||||
VALUES (:id, :projectId, :parentRunId, :modelId, :triggerType, 'RUNNING', :traceId)
|
throw new IllegalStateException("数据库中不存在已启用的默认模型");
|
||||||
""")
|
}
|
||||||
.param("id", id)
|
|
||||||
.param("projectId", projectId)
|
// 应用层提前生成 Run 与追踪 ID;时间字段仍交由数据库默认值统一生成。
|
||||||
.param("parentRunId", parentRunId)
|
AgentRunEntity entity = new AgentRunEntity();
|
||||||
.param("modelId", modelId)
|
entity.setId(UUID.randomUUID());
|
||||||
.param("triggerType", triggerType)
|
entity.setProjectId(projectId);
|
||||||
.param("traceId", UUID.randomUUID().toString())
|
entity.setParentRunId(parentRunId);
|
||||||
.update();
|
entity.setModelConfigId(model.getId());
|
||||||
return require(id);
|
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;不存在时为空
|
* @return 最近 Run;不存在时为空
|
||||||
*/
|
*/
|
||||||
public AgentRunService.RunView latest(UUID projectId) {
|
public AgentRunService.RunView latest(UUID projectId) {
|
||||||
return jdbc.sql(RUN_SELECT + " WHERE project_id = :projectId ORDER BY created_at DESC LIMIT 1")
|
QueryWrapper query = runViewQuery()
|
||||||
.param("projectId", projectId)
|
.where(AgentRunEntity::getProjectId).eq(projectId)
|
||||||
.query(AgentRunStore::mapRun)
|
.orderBy(AgentRunEntity::getCreatedAt).desc()
|
||||||
.optional()
|
.limit(1);
|
||||||
.orElse(null);
|
return toRunView(runMapper.selectOneByQuery(query));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -90,10 +106,14 @@ public class AgentRunStore {
|
|||||||
* @return Run
|
* @return Run
|
||||||
*/
|
*/
|
||||||
public AgentRunService.RunView require(UUID id) {
|
public AgentRunService.RunView require(UUID id) {
|
||||||
return jdbc.sql(RUN_SELECT + " WHERE id = :id")
|
QueryWrapper query = runViewQuery()
|
||||||
.param("id", id)
|
.where(AgentRunEntity::getId).eq(id);
|
||||||
.query(AgentRunStore::mapRun)
|
AgentRunService.RunView run = toRunView(runMapper.selectOneByQuery(query));
|
||||||
.single();
|
if (run == null) {
|
||||||
|
// 强制读取仅用于内部已知 ID;缺失表示持久化状态异常,而不是新增的 404 业务分支。
|
||||||
|
throw new IllegalStateException("Agent Run 不存在: " + id);
|
||||||
|
}
|
||||||
|
return run;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -102,13 +122,7 @@ public class AgentRunStore {
|
|||||||
* @param runId Run ID
|
* @param runId Run ID
|
||||||
*/
|
*/
|
||||||
public void completeWaiting(UUID runId) {
|
public void completeWaiting(UUID runId) {
|
||||||
int updated = jdbc.sql("""
|
int updated = runMapper.completeWaiting(runId);
|
||||||
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();
|
|
||||||
if (updated != 1) {
|
if (updated != 1) {
|
||||||
throw new ApiException(HttpStatus.CONFLICT, "ASK_ALREADY_RESPONDED", "该确认已处理,请刷新页面");
|
throw new ApiException(HttpStatus.CONFLICT, "ASK_ALREADY_RESPONDED", "该确认已处理,请刷新页面");
|
||||||
}
|
}
|
||||||
@@ -142,10 +156,11 @@ public class AgentRunStore {
|
|||||||
* @param runId Run ID
|
* @param runId Run ID
|
||||||
*/
|
*/
|
||||||
public void ensureRunning(UUID runId) {
|
public void ensureRunning(UUID runId) {
|
||||||
String status = jdbc.sql("SELECT status FROM app.agent_run WHERE id = :id")
|
String status = status(runId);
|
||||||
.param("id", runId)
|
if (status == null) {
|
||||||
.query(String.class)
|
// 保持迁移前强制单条查询对缺失记录的未预期异常语义。
|
||||||
.single();
|
throw new IllegalStateException("Agent Run 不存在: " + runId);
|
||||||
|
}
|
||||||
if (!"RUNNING".equals(status)) {
|
if (!"RUNNING".equals(status)) {
|
||||||
throw new AgentExecutionService.RunInterruptedException();
|
throw new AgentExecutionService.RunInterruptedException();
|
||||||
}
|
}
|
||||||
@@ -158,11 +173,7 @@ public class AgentRunStore {
|
|||||||
* @return 是否已停止
|
* @return 是否已停止
|
||||||
*/
|
*/
|
||||||
public boolean isInterrupted(UUID runId) {
|
public boolean isInterrupted(UUID runId) {
|
||||||
return jdbc.sql("SELECT status = 'INTERRUPTED' FROM app.agent_run WHERE id = :id")
|
return "INTERRUPTED".equals(status(runId));
|
||||||
.param("id", runId)
|
|
||||||
.query(Boolean.class)
|
|
||||||
.optional()
|
|
||||||
.orElse(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -173,15 +184,8 @@ public class AgentRunStore {
|
|||||||
* @return 业务阶段
|
* @return 业务阶段
|
||||||
*/
|
*/
|
||||||
public String interruptedPhase(AgentRunService.RunView run, ProjectService.ProjectView project) {
|
public String interruptedPhase(AgentRunService.RunView run, ProjectService.ProjectView project) {
|
||||||
return jdbc.sql("""
|
String phase = eventMapper.selectLatestStartedPhase(run.id());
|
||||||
SELECT payload ->> 'phase' FROM app.agent_event
|
return phase == null ? project.status() : phase;
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -191,43 +195,73 @@ public class AgentRunStore {
|
|||||||
* @return 材料确认 JSON
|
* @return 材料确认 JSON
|
||||||
*/
|
*/
|
||||||
public JsonNode latestMaterialResponse(UUID projectId) {
|
public JsonNode latestMaterialResponse(UUID projectId) {
|
||||||
return jdbc.sql("""
|
String value = eventMapper.selectLatestMaterialResponseJson(projectId);
|
||||||
SELECT payload::text FROM app.agent_event
|
if (value == null) {
|
||||||
WHERE project_id = :projectId AND event_type = 'ASK_RESPONDED'
|
return objectMapper.createObjectNode();
|
||||||
AND jsonb_typeof(payload -> 'decisions') = 'array'
|
}
|
||||||
ORDER BY id DESC LIMIT 1
|
try {
|
||||||
""")
|
return objectMapper.readTree(value);
|
||||||
.param("projectId", projectId)
|
} catch (JsonProcessingException exception) {
|
||||||
.query(String.class)
|
throw new ApiException(
|
||||||
.optional()
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
.map(value -> {
|
"MATERIAL_RESPONSE_INVALID",
|
||||||
try {
|
"材料确认记录无法读取");
|
||||||
return objectMapper.readTree(value);
|
}
|
||||||
} catch (JsonProcessingException exception) {
|
|
||||||
throw new ApiException(
|
|
||||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
"MATERIAL_RESPONSE_INVALID",
|
|
||||||
"材料确认记录无法读取");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.orElseGet(objectMapper::createObjectNode);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
return new AgentRunService.RunView(
|
||||||
rs.getObject("id", UUID.class),
|
entity.getId(),
|
||||||
rs.getObject("project_id", UUID.class),
|
entity.getProjectId(),
|
||||||
rs.getString("trigger_type"),
|
entity.getTriggerType(),
|
||||||
rs.getString("status"),
|
entity.getStatus(),
|
||||||
rs.getString("pending_interrupt"),
|
entity.getPendingInterrupt(),
|
||||||
rs.getString("error_message"),
|
entity.getErrorMessage(),
|
||||||
rs.getObject("started_at", OffsetDateTime.class),
|
entity.getStartedAt(),
|
||||||
rs.getObject("ended_at", OffsetDateTime.class));
|
entity.getEndedAt());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final String RUN_SELECT = """
|
/**
|
||||||
SELECT id, project_id, trigger_type, status, pending_interrupt, error_message, started_at, ended_at
|
* 使用 BaseMapper 主键查询读取 Run 状态。
|
||||||
FROM app.agent_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 对外视图所需的最小字段投影。
|
||||||
|
*
|
||||||
|
* <p>运行视图不暴露模型配置、追踪标识和内部错误码,显式投影可避免每次轮询都读取无关列。</p>
|
||||||
|
*
|
||||||
|
* @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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ package tech.easyflow.manuagent.agent;
|
|||||||
import org.springframework.boot.ApplicationArguments;
|
import org.springframework.boot.ApplicationArguments;
|
||||||
import org.springframework.boot.ApplicationRunner;
|
import org.springframework.boot.ApplicationRunner;
|
||||||
import org.springframework.core.annotation.Order;
|
import org.springframework.core.annotation.Order;
|
||||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import tech.easyflow.manuagent.mapper.AgentRunMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动时终结因 JVM 中断而遗留的伪运行状态,并保留原业务阶段供继续执行。
|
* 启动时终结因 JVM 中断而遗留的伪运行状态,并保留原业务阶段供继续执行。
|
||||||
@@ -14,15 +14,15 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
@Order(0)
|
@Order(0)
|
||||||
public class RunRecoveryService implements ApplicationRunner {
|
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) {
|
public RunRecoveryService(AgentRunMapper runMapper) {
|
||||||
this.jdbc = jdbc;
|
this.runMapper = runMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,12 +33,6 @@ public class RunRecoveryService implements ApplicationRunner {
|
|||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public void run(ApplicationArguments args) {
|
public void run(ApplicationArguments args) {
|
||||||
jdbc.sql("""
|
runMapper.interruptRunningAfterRestart();
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package tech.easyflow.manuagent.artifact;
|
package tech.easyflow.manuagent.artifact;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import tech.easyflow.manuagent.common.ApiException;
|
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 tech.easyflow.manuagent.project.ProjectFileService;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -18,7 +21,6 @@ import java.util.UUID;
|
|||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.core.io.UrlResource;
|
import org.springframework.core.io.UrlResource;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,19 +29,20 @@ import org.springframework.stereotype.Service;
|
|||||||
@Service
|
@Service
|
||||||
public class ArtifactService {
|
public class ArtifactService {
|
||||||
|
|
||||||
private final JdbcClient jdbc;
|
private final ArtifactMapper artifactMapper;
|
||||||
private final ProjectFileService fileService;
|
private final ProjectFileService fileService;
|
||||||
private final DocxValidator docxValidator;
|
private final DocxValidator docxValidator;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建产物服务。
|
* 创建产物服务。
|
||||||
*
|
*
|
||||||
* @param jdbc JDBC 客户端
|
* @param artifactMapper 产物 Mapper
|
||||||
* @param fileService 项目文件服务
|
* @param fileService 项目文件服务
|
||||||
* @param docxValidator DOCX 校验器
|
* @param docxValidator DOCX 校验器
|
||||||
*/
|
*/
|
||||||
public ArtifactService(JdbcClient jdbc, ProjectFileService fileService, DocxValidator docxValidator) {
|
public ArtifactService(
|
||||||
this.jdbc = jdbc;
|
ArtifactMapper artifactMapper, ProjectFileService fileService, DocxValidator docxValidator) {
|
||||||
|
this.artifactMapper = artifactMapper;
|
||||||
this.fileService = fileService;
|
this.fileService = fileService;
|
||||||
this.docxValidator = docxValidator;
|
this.docxValidator = docxValidator;
|
||||||
}
|
}
|
||||||
@@ -118,36 +121,20 @@ public class ArtifactService {
|
|||||||
throw new ApiException(HttpStatus.BAD_REQUEST, "ARTIFACT_EMPTY", "产物文件为空");
|
throw new ApiException(HttpStatus.BAD_REQUEST, "ARTIFACT_EMPTY", "产物文件为空");
|
||||||
}
|
}
|
||||||
String hash = sha256(path);
|
String hash = sha256(path);
|
||||||
UUID id = jdbc.sql("""
|
// “项目 + 路径”必须原子 upsert,避免先查后写在并发重试时触发唯一约束竞态。
|
||||||
INSERT INTO app.artifact(
|
ArtifactEntity entity = new ArtifactEntity();
|
||||||
id, project_id, run_id, kind, name, relative_path, mime_type,
|
entity.setId(UUID.randomUUID());
|
||||||
size_bytes, sha256, metadata_json)
|
entity.setProjectId(projectId);
|
||||||
VALUES (:id, :projectId, :runId, :kind, :name, :path,
|
entity.setRunId(runId);
|
||||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
entity.setKind(kind);
|
||||||
:size, :sha256, CAST(:metadata AS jsonb))
|
entity.setName(name);
|
||||||
ON CONFLICT (project_id, relative_path) DO UPDATE SET
|
entity.setRelativePath(relativePath);
|
||||||
run_id = EXCLUDED.run_id,
|
entity.setMimeType("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
|
||||||
kind = EXCLUDED.kind,
|
entity.setSizeBytes(size);
|
||||||
name = EXCLUDED.name,
|
entity.setSha256(hash);
|
||||||
mime_type = EXCLUDED.mime_type,
|
entity.setMetadataJson(metadata.toString());
|
||||||
size_bytes = EXCLUDED.size_bytes,
|
ArtifactEntity stored = artifactMapper.upsert(entity);
|
||||||
sha256 = EXCLUDED.sha256,
|
return toArtifactView(stored);
|
||||||
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);
|
|
||||||
} catch (IOException | NoSuchAlgorithmException exception) {
|
} catch (IOException | NoSuchAlgorithmException exception) {
|
||||||
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "ARTIFACT_PUBLISH_FAILED", "产物校验失败");
|
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "ARTIFACT_PUBLISH_FAILED", "产物校验失败");
|
||||||
}
|
}
|
||||||
@@ -175,10 +162,12 @@ public class ArtifactService {
|
|||||||
* @return 按发布时间倒序的产物
|
* @return 按发布时间倒序的产物
|
||||||
*/
|
*/
|
||||||
public List<ArtifactView> list(UUID projectId) {
|
public List<ArtifactView> list(UUID projectId) {
|
||||||
return jdbc.sql(ARTIFACT_SELECT + " WHERE project_id = :projectId ORDER BY published_at DESC")
|
QueryWrapper query = artifactViewQuery()
|
||||||
.param("projectId", projectId)
|
.where(ArtifactEntity::getProjectId).eq(projectId)
|
||||||
.query(ArtifactService::mapArtifact)
|
.orderBy(ArtifactEntity::getPublishedAt).desc();
|
||||||
.list();
|
return artifactMapper.selectListByQuery(query).stream()
|
||||||
|
.map(ArtifactService::toArtifactView)
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -187,21 +176,28 @@ public class ArtifactService {
|
|||||||
* @param artifactId 产物 ID
|
* @param artifactId 产物 ID
|
||||||
* @return 下载信息
|
* @return 下载信息
|
||||||
*/
|
*/
|
||||||
|
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
|
||||||
public Download download(UUID artifactId) {
|
public Download download(UUID artifactId) {
|
||||||
StoredArtifact artifact = jdbc.sql("""
|
QueryWrapper query = QueryWrapper.create()
|
||||||
SELECT project_id, name, relative_path, mime_type, size_bytes, sha256
|
.select(
|
||||||
FROM app.artifact WHERE id = :id
|
ArtifactEntity::getProjectId,
|
||||||
""")
|
ArtifactEntity::getName,
|
||||||
.param("id", artifactId)
|
ArtifactEntity::getRelativePath,
|
||||||
.query((rs, rowNum) -> new StoredArtifact(
|
ArtifactEntity::getMimeType,
|
||||||
rs.getObject("project_id", UUID.class),
|
ArtifactEntity::getSizeBytes,
|
||||||
rs.getString("name"),
|
ArtifactEntity::getSha256)
|
||||||
rs.getString("relative_path"),
|
.where(ArtifactEntity::getId).eq(artifactId);
|
||||||
rs.getString("mime_type"),
|
ArtifactEntity entity = artifactMapper.selectOneByQuery(query);
|
||||||
rs.getLong("size_bytes"),
|
if (entity == null) {
|
||||||
rs.getString("sha256")))
|
throw new ApiException(HttpStatus.NOT_FOUND, "ARTIFACT_NOT_FOUND", "产物不存在");
|
||||||
.optional()
|
}
|
||||||
.orElseThrow(() -> 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 {
|
try {
|
||||||
Path path = fileService.safeProjectPath(artifact.projectId(), artifact.relativePath());
|
Path path = fileService.safeProjectPath(artifact.projectId(), artifact.relativePath());
|
||||||
Resource resource = new UrlResource(path.toUri());
|
Resource resource = new UrlResource(path.toUri());
|
||||||
@@ -238,30 +234,44 @@ public class ArtifactService {
|
|||||||
return HexFormat.of().formatHex(digest.digest());
|
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)
|
* @param entity 产物实体
|
||||||
.optional()
|
* @return 产物接口视图
|
||||||
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "ARTIFACT_NOT_FOUND", "产物不存在"));
|
*/
|
||||||
}
|
private static ArtifactView toArtifactView(ArtifactEntity entity) {
|
||||||
|
|
||||||
private static ArtifactView mapArtifact(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
|
|
||||||
return new ArtifactView(
|
return new ArtifactView(
|
||||||
rs.getObject("id", UUID.class),
|
entity.getId(),
|
||||||
rs.getObject("project_id", UUID.class),
|
entity.getProjectId(),
|
||||||
rs.getObject("run_id", UUID.class),
|
entity.getRunId(),
|
||||||
rs.getString("kind"),
|
entity.getKind(),
|
||||||
rs.getString("name"),
|
entity.getName(),
|
||||||
rs.getLong("size_bytes"),
|
entity.getSizeBytes() == null ? 0L : entity.getSizeBytes(),
|
||||||
rs.getString("metadata_json"),
|
entity.getMetadataJson(),
|
||||||
rs.getObject("published_at", OffsetDateTime.class));
|
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
|
*
|
||||||
""";
|
* <p>该字段集合与迁移前 JDBC 列表 SQL 保持一致。下载路径、MIME 类型和 SHA-256
|
||||||
|
* 仅在下载场景读取,避免普通列表查询加载不参与响应的内部字段。</p>
|
||||||
|
*
|
||||||
|
* @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);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 产物元数据。
|
* 产物元数据。
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
package tech.easyflow.manuagent.auth;
|
package tech.easyflow.manuagent.auth;
|
||||||
|
|
||||||
import tech.easyflow.manuagent.common.ApiException;
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import tech.easyflow.manuagent.config.AppProperties;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.springframework.boot.ApplicationArguments;
|
import org.springframework.boot.ApplicationArguments;
|
||||||
import org.springframework.boot.ApplicationRunner;
|
import org.springframework.boot.ApplicationRunner;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
import org.springframework.http.HttpStatus;
|
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.User;
|
||||||
import org.springframework.security.core.userdetails.UserDetails;
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Service;
|
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)
|
@Order(1)
|
||||||
public class UserService implements UserDetailsService, ApplicationRunner {
|
public class UserService implements UserDetailsService, ApplicationRunner {
|
||||||
|
|
||||||
private final JdbcClient jdbc;
|
private final AppUserMapper userMapper;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final AppProperties properties;
|
private final AppProperties properties;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建用户服务。
|
* 创建用户服务。
|
||||||
*
|
*
|
||||||
* @param jdbc JDBC 客户端
|
* @param userMapper 用户表 Mapper
|
||||||
* @param passwordEncoder 密码编码器
|
* @param passwordEncoder 密码编码器
|
||||||
* @param properties 应用配置
|
* @param properties 应用配置
|
||||||
*/
|
*/
|
||||||
public UserService(JdbcClient jdbc, PasswordEncoder passwordEncoder, AppProperties properties) {
|
public UserService(AppUserMapper userMapper, PasswordEncoder passwordEncoder, AppProperties properties) {
|
||||||
this.jdbc = jdbc;
|
this.userMapper = userMapper;
|
||||||
this.passwordEncoder = passwordEncoder;
|
this.passwordEncoder = passwordEncoder;
|
||||||
this.properties = properties;
|
this.properties = properties;
|
||||||
}
|
}
|
||||||
@@ -46,18 +48,18 @@ public class UserService implements UserDetailsService, ApplicationRunner {
|
|||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public void run(ApplicationArguments args) {
|
public void run(ApplicationArguments args) {
|
||||||
Integer count = jdbc.sql("SELECT count(*) FROM app.app_user").query(Integer.class).single();
|
long count = userMapper.selectCountByQuery(QueryWrapper.create());
|
||||||
if (count == 0) {
|
if (count > 0) {
|
||||||
jdbc.sql("""
|
return;
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 首次启动时仍由应用层生成 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 用户不存在或被禁用时抛出
|
* @throws UsernameNotFoundException 用户不存在或被禁用时抛出
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
|
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
|
||||||
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
||||||
return jdbc.sql("SELECT username, password_hash, enabled FROM app.app_user WHERE username = :username")
|
QueryWrapper query = QueryWrapper.create()
|
||||||
.param("username", username)
|
.select(
|
||||||
.query((rs, rowNum) -> User.withUsername(rs.getString("username"))
|
AppUserEntity::getUsername,
|
||||||
.password(rs.getString("password_hash"))
|
AppUserEntity::getPasswordHash,
|
||||||
.roles("ADMIN")
|
AppUserEntity::getEnabled)
|
||||||
.disabled(!rs.getBoolean("enabled"))
|
.where(AppUserEntity::getUsername).eq(username);
|
||||||
.build())
|
AppUserEntity entity = userMapper.selectOneByQuery(query);
|
||||||
.optional()
|
if (entity == null) {
|
||||||
.orElseThrow(() -> new UsernameNotFoundException("账户不存在"));
|
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
|
* @return 用户 UUID
|
||||||
* @throws ApiException 用户不存在时抛出
|
* @throws ApiException 用户不存在时抛出
|
||||||
*/
|
*/
|
||||||
|
@SuppressWarnings("unchecked") // MyBatis-Flex 的 select(LambdaGetter<T>...) 使用泛型可变参数,调用本身类型安全。
|
||||||
public UUID requireUserId(String username) {
|
public UUID requireUserId(String username) {
|
||||||
return jdbc.sql("SELECT id FROM app.app_user WHERE username = :username")
|
QueryWrapper query = QueryWrapper.create()
|
||||||
.param("username", username)
|
.select(AppUserEntity::getId)
|
||||||
.query(UUID.class)
|
.where(AppUserEntity::getUsername).eq(username);
|
||||||
.optional()
|
AppUserEntity entity = userMapper.selectOneByQuery(query);
|
||||||
.orElseThrow(() -> new ApiException(HttpStatus.UNAUTHORIZED, "USER_NOT_FOUND", "登录账户不存在"));
|
if (entity == null) {
|
||||||
|
throw new ApiException(HttpStatus.UNAUTHORIZED, "USER_NOT_FOUND", "登录账户不存在");
|
||||||
|
}
|
||||||
|
return entity.getId();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package tech.easyflow.manuagent.config;
|
||||||
|
|
||||||
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置 MyBatis-Flex Mapper 扫描。
|
||||||
|
*
|
||||||
|
* <p>业务 Mapper 统一放在 {@code tech.easyflow.manuagent.mapper} 包中。数据库连接、连接池和
|
||||||
|
* Spring 事务管理器继续复用 Spring Boot 已配置的数据源,使 MyBatis-Flex 的 Mapper 调用、
|
||||||
|
* 事件写入与应用服务的 {@code @Transactional} 边界共享同一物理事务。</p>
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@MapperScan("tech.easyflow.manuagent.mapper")
|
||||||
|
public class MyBatisFlexConfiguration {
|
||||||
|
}
|
||||||
@@ -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 事件。
|
||||||
|
*
|
||||||
|
* <p>事件 ID 由 PostgreSQL 的 BIGSERIAL 序列生成,业务代码通过
|
||||||
|
* {@code INSERT ... RETURNING} 原子取得该 ID,确保游标回放顺序与数据库提交顺序一致。</p>
|
||||||
|
*/
|
||||||
|
@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; }
|
||||||
|
}
|
||||||
@@ -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 运行状态实体。
|
||||||
|
*
|
||||||
|
* <p>运行终态切换并不依赖 BaseMapper 的无条件更新,而由 Mapper XML 使用
|
||||||
|
* {@code WHERE status = 'RUNNING'} 实现乐观状态机,避免停止、完成和失败并发覆盖。</p>
|
||||||
|
*/
|
||||||
|
@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; }
|
||||||
|
}
|
||||||
@@ -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} 表的管理员用户实体。
|
||||||
|
*
|
||||||
|
* <p>该实体只服务于数据库持久化,不直接作为 HTTP 接口的输入或输出。UUID 主键继续由应用层生成,
|
||||||
|
* MyBatis-Flex 的 UUID 生成器只会在主键为空时补值,因此既能保留调用方已生成的 UUID,也能避免
|
||||||
|
* 遗漏主键造成数据库约束错误。写入时使用 selective insert 保留已有 UUID,
|
||||||
|
* 其余未赋值字段仍由数据库默认值负责填充。</p>
|
||||||
|
*/
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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} 表的模型配置与加密密钥实体。
|
||||||
|
*
|
||||||
|
* <p>密钥字段始终保存 AES-GCM 密文;实体只在服务内部使用,严禁直接作为接口响应。</p>
|
||||||
|
*/
|
||||||
|
@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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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} 表的项目材料实体。
|
||||||
|
*
|
||||||
|
* <p>实体保存文件的受控相对路径、完整性摘要及软删除状态;真实文件仍由
|
||||||
|
* {@code ProjectFileService} 在项目工作区内管理。</p>
|
||||||
|
*/
|
||||||
|
@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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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} 表。
|
||||||
|
*
|
||||||
|
* <p>Skill 正文和资源不属于该实体,它们继续由 AgentScope 的 PostgreSQL repository 管理。</p>
|
||||||
|
*/
|
||||||
|
@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; }
|
||||||
|
}
|
||||||
@@ -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<AgentEventEntity> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插入事件并原子返回数据库生成的 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);
|
||||||
|
}
|
||||||
@@ -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<AgentRunEntity> {
|
||||||
|
|
||||||
|
/** 将等待输入的 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();
|
||||||
|
}
|
||||||
@@ -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 基础数据访问能力。
|
||||||
|
*
|
||||||
|
* <p>用户表只有简单单表操作,因此直接使用 {@link BaseMapper} 和 Lambda QueryWrapper,
|
||||||
|
* 不额外维护 Mapper XML。</p>
|
||||||
|
*/
|
||||||
|
public interface AppUserMapper extends BaseMapper<AppUserEntity> {
|
||||||
|
}
|
||||||
@@ -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<ArtifactEntity> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按“项目 + 相对路径”插入或更新产物,并返回数据库中的完整记录。
|
||||||
|
*
|
||||||
|
* @param artifact 待发布产物
|
||||||
|
* @return 插入或更新后的产物记录
|
||||||
|
*/
|
||||||
|
ArtifactEntity upsert(@Param("artifact") ArtifactEntity artifact);
|
||||||
|
}
|
||||||
@@ -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<ModelAssignmentEntity> {
|
||||||
|
|
||||||
|
/** 按角色插入或更新模型分配。 */
|
||||||
|
int upsert(@Param("assignment") ModelAssignmentEntity assignment);
|
||||||
|
}
|
||||||
@@ -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 能力。
|
||||||
|
*
|
||||||
|
* <p>普通查询和条件更新继续复用 {@link BaseMapper};包含 PostgreSQL JSONB 参数的新增、更新
|
||||||
|
* 使用 XML 显式声明 TypeHandler,避免写入行为依赖 MyBatis-Flex 全局表元数据的初始化顺序。</p>
|
||||||
|
*/
|
||||||
|
public interface ModelConfigMapper extends BaseMapper<ModelConfigEntity> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增模型配置,并显式按 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);
|
||||||
|
}
|
||||||
@@ -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<ProjectFileEntity> {
|
||||||
|
}
|
||||||
@@ -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<ProjectEntity> {
|
||||||
|
|
||||||
|
/** 判断项目是否仍有运行中的 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);
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
|
|
||||||
|
/** 插入项目下一版草稿并返回完整记录。 */
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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<SkillConfigEntity> {
|
||||||
|
|
||||||
|
/** 列出全部 Skill 联合视图。 */
|
||||||
|
List<SkillViewRow> 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);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
package tech.easyflow.manuagent.model;
|
package tech.easyflow.manuagent.model;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import tech.easyflow.manuagent.auth.UserService;
|
import tech.easyflow.manuagent.auth.UserService;
|
||||||
import tech.easyflow.manuagent.common.ApiException;
|
import tech.easyflow.manuagent.common.ApiException;
|
||||||
import tech.easyflow.manuagent.config.AppProperties;
|
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 com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
@@ -22,7 +29,6 @@ import org.springframework.boot.ApplicationArguments;
|
|||||||
import org.springframework.boot.ApplicationRunner;
|
import org.springframework.boot.ApplicationRunner;
|
||||||
import org.springframework.core.annotation.Order;
|
import org.springframework.core.annotation.Order;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -33,7 +39,9 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
@Order(2)
|
@Order(2)
|
||||||
public class ModelService implements ApplicationRunner {
|
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 UserService userService;
|
||||||
private final KeyCipher keyCipher;
|
private final KeyCipher keyCipher;
|
||||||
private final AppProperties properties;
|
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 userService 用户服务
|
||||||
* @param keyCipher 密钥加密器
|
* @param keyCipher 密钥加密器
|
||||||
* @param properties 应用配置
|
* @param properties 应用配置
|
||||||
* @param objectMapper JSON 映射器
|
* @param objectMapper JSON 映射器
|
||||||
*/
|
*/
|
||||||
public ModelService(
|
public ModelService(
|
||||||
JdbcClient jdbc,
|
ModelConfigMapper modelMapper,
|
||||||
|
ModelAssignmentMapper assignmentMapper,
|
||||||
|
AppUserMapper userMapper,
|
||||||
UserService userService,
|
UserService userService,
|
||||||
KeyCipher keyCipher,
|
KeyCipher keyCipher,
|
||||||
AppProperties properties,
|
AppProperties properties,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper) {
|
||||||
this.jdbc = jdbc;
|
this.modelMapper = modelMapper;
|
||||||
|
this.assignmentMapper = assignmentMapper;
|
||||||
|
this.userMapper = userMapper;
|
||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
this.keyCipher = keyCipher;
|
this.keyCipher = keyCipher;
|
||||||
this.properties = properties;
|
this.properties = properties;
|
||||||
@@ -70,8 +84,9 @@ public class ModelService implements ApplicationRunner {
|
|||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
|
@SuppressWarnings("unchecked") // MyBatis-Flex 的 select(LambdaGetter<T>...) 使用泛型可变参数,调用本身类型安全。
|
||||||
public void run(ApplicationArguments args) {
|
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())) {
|
if (count > 0 || !Files.isRegularFile(properties.deepseekKeyFile())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -80,35 +95,35 @@ public class ModelService implements ApplicationRunner {
|
|||||||
if (key.isBlank()) {
|
if (key.isBlank()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
UUID adminId = jdbc.sql("SELECT id FROM app.app_user ORDER BY created_at LIMIT 1")
|
QueryWrapper userQuery = QueryWrapper.create()
|
||||||
.query(UUID.class)
|
.select(AppUserEntity::getId)
|
||||||
.single();
|
.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();
|
UUID modelId = UUID.randomUUID();
|
||||||
jdbc.sql("""
|
ModelConfigEntity model = new ModelConfigEntity();
|
||||||
INSERT INTO app.model_config(
|
model.setId(modelId);
|
||||||
id, name, provider, base_url, model_id, api_key_ciphertext, api_key_hint,
|
model.setName("默认编排模型");
|
||||||
key_version, config_json, capabilities_json, is_default, created_by)
|
model.setProvider("OPENAI_COMPATIBLE");
|
||||||
VALUES (:id, '默认编排模型', 'OPENAI_COMPATIBLE', :baseUrl, :modelId,
|
model.setBaseUrl(properties.modelBaseUrl());
|
||||||
:ciphertext, :hint, 1, CAST(:config AS jsonb), CAST(:capabilities AS jsonb), TRUE, :userId)
|
model.setModelId(properties.modelId());
|
||||||
""")
|
model.setApiKeyCiphertext(keyCipher.encrypt(key));
|
||||||
.param("id", modelId)
|
model.setApiKeyHint(hint(key));
|
||||||
.param("baseUrl", properties.modelBaseUrl())
|
model.setKeyVersion((short) 1);
|
||||||
.param("modelId", properties.modelId())
|
model.setConfigJson("{\"timeoutSeconds\":120,\"reasoningEffort\":\"high\"}");
|
||||||
.param("ciphertext", keyCipher.encrypt(key))
|
model.setCapabilitiesJson(json(Map.of(
|
||||||
.param("hint", hint(key))
|
"toolCalling", true,
|
||||||
.param("config", "{\"timeoutSeconds\":120,\"reasoningEffort\":\"high\"}")
|
"reasoning", true,
|
||||||
.param("capabilities", json(Map.of(
|
"contextWindow", properties.modelContextWindow())));
|
||||||
"toolCalling", true,
|
model.setDefaultModel(true);
|
||||||
"reasoning", true,
|
model.setCreatedBy(adminId);
|
||||||
"contextWindow", properties.modelContextWindow())))
|
modelMapper.insertModel(model);
|
||||||
.param("userId", adminId)
|
|
||||||
.update();
|
|
||||||
for (String role : List.of("ORCHESTRATION", "WRITING", "REVIEW")) {
|
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)")
|
upsertAssignment(role, modelId, adminId);
|
||||||
.param("role", role)
|
|
||||||
.param("id", modelId)
|
|
||||||
.param("userId", adminId)
|
|
||||||
.update();
|
|
||||||
}
|
}
|
||||||
} catch (IOException exception) {
|
} catch (IOException exception) {
|
||||||
throw new IllegalStateException("无法读取默认模型 Key", exception);
|
throw new IllegalStateException("无法读取默认模型 Key", exception);
|
||||||
@@ -121,9 +136,12 @@ public class ModelService implements ApplicationRunner {
|
|||||||
* @return 模型列表
|
* @return 模型列表
|
||||||
*/
|
*/
|
||||||
public List<ModelView> list() {
|
public List<ModelView> list() {
|
||||||
return jdbc.sql(MODEL_SELECT + " ORDER BY is_default DESC, updated_at DESC")
|
QueryWrapper query = modelViewQuery()
|
||||||
.query(ModelService::mapModel)
|
.orderBy(ModelConfigEntity::getDefaultModel).desc()
|
||||||
.list();
|
.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");
|
throw new ApiException(HttpStatus.BAD_REQUEST, "MODEL_KEY_REQUIRED", "新增模型需要 API Key");
|
||||||
}
|
}
|
||||||
id = UUID.randomUUID();
|
id = UUID.randomUUID();
|
||||||
jdbc.sql("""
|
ModelConfigEntity model = editableModel(id, input);
|
||||||
INSERT INTO app.model_config(
|
model.setProvider("OPENAI_COMPATIBLE");
|
||||||
id, name, provider, base_url, model_id, api_key_ciphertext, api_key_hint,
|
model.setApiKeyCiphertext(keyCipher.encrypt(input.apiKey().trim()));
|
||||||
key_version, config_json, capabilities_json, created_by)
|
model.setApiKeyHint(hint(input.apiKey().trim()));
|
||||||
VALUES (:id, :name, 'OPENAI_COMPATIBLE', :baseUrl, :modelId, :ciphertext,
|
model.setKeyVersion((short) 1);
|
||||||
:hint, 1, CAST(:config AS jsonb), CAST(:capabilities AS jsonb), :userId)
|
model.setCreatedBy(userId);
|
||||||
""")
|
modelMapper.insertModel(model);
|
||||||
.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();
|
|
||||||
} else {
|
} else {
|
||||||
int updated = input.apiKey() == null || input.apiKey().isBlank()
|
ModelConfigEntity model = editableModel(id, input);
|
||||||
? jdbc.sql("""
|
if (input.apiKey() != null && !input.apiKey().isBlank()) {
|
||||||
UPDATE app.model_config
|
model.setApiKeyCiphertext(keyCipher.encrypt(input.apiKey().trim()));
|
||||||
SET name = :name, base_url = :baseUrl, model_id = :modelId,
|
model.setApiKeyHint(hint(input.apiKey().trim()));
|
||||||
config_json = CAST(:config AS jsonb), capabilities_json = CAST(:capabilities AS jsonb),
|
model.setKeyVersion((short) 1);
|
||||||
updated_at = CURRENT_TIMESTAMP
|
}
|
||||||
WHERE id = :id
|
int updated = modelMapper.updateModel(model);
|
||||||
""")
|
|
||||||
.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();
|
|
||||||
if (updated != 1) {
|
if (updated != 1) {
|
||||||
throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在");
|
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) {
|
public void setDefault(UUID id, Principal principal) {
|
||||||
require(id);
|
require(id);
|
||||||
UUID userId = userService.requireUserId(principal.getName());
|
UUID userId = userService.requireUserId(principal.getName());
|
||||||
jdbc.sql("UPDATE app.model_config SET is_default = FALSE WHERE is_default").update();
|
modelMapper.clearDefault();
|
||||||
jdbc.sql("UPDATE app.model_config SET is_default = TRUE, updated_at = CURRENT_TIMESTAMP WHERE id = :id")
|
modelMapper.setDefault(id);
|
||||||
.param("id", id)
|
|
||||||
.update();
|
|
||||||
for (String role : List.of("ORCHESTRATION", "WRITING", "REVIEW")) {
|
for (String role : List.of("ORCHESTRATION", "WRITING", "REVIEW")) {
|
||||||
jdbc.sql("""
|
upsertAssignment(role, id, userId);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,36 +242,50 @@ public class ModelService implements ApplicationRunner {
|
|||||||
*
|
*
|
||||||
* @return 默认模型机密配置
|
* @return 默认模型机密配置
|
||||||
*/
|
*/
|
||||||
|
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
|
||||||
public ModelSecret defaultModelSecret() {
|
public ModelSecret defaultModelSecret() {
|
||||||
UUID id = jdbc.sql("SELECT id FROM app.model_config WHERE is_default AND enabled")
|
QueryWrapper query = QueryWrapper.create()
|
||||||
.query(UUID.class)
|
.select(
|
||||||
.optional()
|
ModelConfigEntity::getId,
|
||||||
.orElseThrow(() -> new ApiException(HttpStatus.CONFLICT, "MODEL_NOT_CONFIGURED", "请先配置可用模型"));
|
ModelConfigEntity::getBaseUrl,
|
||||||
return requireSecret(id);
|
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) {
|
private ModelView require(UUID id) {
|
||||||
return jdbc.sql(MODEL_SELECT + " WHERE id = :id")
|
QueryWrapper query = modelViewQuery()
|
||||||
.param("id", id)
|
.where(ModelConfigEntity::getId).eq(id);
|
||||||
.query(ModelService::mapModel)
|
ModelConfigEntity model = modelMapper.selectOneByQuery(query);
|
||||||
.optional()
|
if (model == null) {
|
||||||
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在"));
|
throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在");
|
||||||
|
}
|
||||||
|
return toModelView(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked") // 机密配置查询只投影固定列,LambdaGetter 可变参数不会引入运行期类型风险。
|
||||||
private ModelSecret requireSecret(UUID id) {
|
private ModelSecret requireSecret(UUID id) {
|
||||||
return jdbc.sql("""
|
QueryWrapper query = QueryWrapper.create()
|
||||||
SELECT id, base_url, model_id, api_key_ciphertext, capabilities_json::text
|
.select(
|
||||||
FROM app.model_config WHERE id = :id AND enabled
|
ModelConfigEntity::getId,
|
||||||
""")
|
ModelConfigEntity::getBaseUrl,
|
||||||
.param("id", id)
|
ModelConfigEntity::getModelId,
|
||||||
.query((rs, rowNum) -> new ModelSecret(
|
ModelConfigEntity::getApiKeyCiphertext,
|
||||||
rs.getObject("id", UUID.class),
|
ModelConfigEntity::getCapabilitiesJson)
|
||||||
rs.getString("base_url"),
|
.where(ModelConfigEntity::getId).eq(id)
|
||||||
rs.getString("model_id"),
|
.and(ModelConfigEntity::getEnabled).eq(true);
|
||||||
keyCipher.decrypt(rs.getBytes("api_key_ciphertext")),
|
ModelConfigEntity model = modelMapper.selectOneByQuery(query);
|
||||||
contextWindow(parseCapabilities(rs.getString("capabilities_json")))))
|
if (model == null) {
|
||||||
.optional()
|
throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在或已停用");
|
||||||
.orElseThrow(() -> 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(
|
return new ModelView(
|
||||||
rs.getObject("id", UUID.class),
|
model.getId(),
|
||||||
rs.getString("name"),
|
model.getName(),
|
||||||
rs.getString("provider"),
|
model.getProvider(),
|
||||||
rs.getString("base_url"),
|
model.getBaseUrl(),
|
||||||
rs.getString("model_id"),
|
model.getModelId(),
|
||||||
rs.getString("api_key_hint"),
|
model.getApiKeyHint(),
|
||||||
rs.getString("config_json"),
|
model.getConfigJson(),
|
||||||
rs.getString("capabilities_json"),
|
model.getCapabilitiesJson(),
|
||||||
rs.getBoolean("enabled"),
|
Boolean.TRUE.equals(model.getEnabled()),
|
||||||
rs.getBoolean("is_default"),
|
Boolean.TRUE.equals(model.getDefaultModel()),
|
||||||
rs.getObject("updated_at", OffsetDateTime.class));
|
model.getUpdatedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造模型管理页面使用的安全字段投影。
|
||||||
|
*
|
||||||
|
* <p>普通列表、保存结果和默认模型切换只需要展示字段,因此明确排除 API Key 密文、
|
||||||
|
* 密钥版本和创建人等内部字段。只有模型连接和 Agent 创建路径可以读取密文。</p>
|
||||||
|
*
|
||||||
|
* @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) {
|
private String json(Object value) {
|
||||||
@@ -374,12 +426,6 @@ public class ModelService implements ApplicationRunner {
|
|||||||
return "••••" + key.substring(Math.max(0, key.length() - 4));
|
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
|
|
||||||
""";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 模型编辑输入。
|
* 模型编辑输入。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package tech.easyflow.manuagent.project;
|
package tech.easyflow.manuagent.project;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import tech.easyflow.manuagent.auth.UserService;
|
import tech.easyflow.manuagent.auth.UserService;
|
||||||
import tech.easyflow.manuagent.common.ApiException;
|
import tech.easyflow.manuagent.common.ApiException;
|
||||||
import tech.easyflow.manuagent.config.AppProperties;
|
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.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.nio.charset.StandardCharsets;
|
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.Resource;
|
||||||
import org.springframework.core.io.UrlResource;
|
import org.springframework.core.io.UrlResource;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
@@ -39,7 +41,7 @@ public class ProjectFileService {
|
|||||||
"pdf", "docx", "xls", "xlsx", "pptx", "csv", "txt", "md",
|
"pdf", "docx", "xls", "xlsx", "pptx", "csv", "txt", "md",
|
||||||
"png", "jpg", "jpeg", "webp", "vsdx", "dwg");
|
"png", "jpg", "jpeg", "webp", "vsdx", "dwg");
|
||||||
|
|
||||||
private final JdbcClient jdbc;
|
private final ProjectFileMapper fileMapper;
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final ProjectService projectService;
|
private final ProjectService projectService;
|
||||||
private final Path dataRoot;
|
private final Path dataRoot;
|
||||||
@@ -48,17 +50,17 @@ public class ProjectFileService {
|
|||||||
/**
|
/**
|
||||||
* 创建材料服务。
|
* 创建材料服务。
|
||||||
*
|
*
|
||||||
* @param jdbc JDBC 客户端
|
* @param fileMapper 项目材料 Mapper
|
||||||
* @param userService 用户服务
|
* @param userService 用户服务
|
||||||
* @param projectService 项目服务
|
* @param projectService 项目服务
|
||||||
* @param properties 应用配置
|
* @param properties 应用配置
|
||||||
*/
|
*/
|
||||||
public ProjectFileService(
|
public ProjectFileService(
|
||||||
JdbcClient jdbc,
|
ProjectFileMapper fileMapper,
|
||||||
UserService userService,
|
UserService userService,
|
||||||
ProjectService projectService,
|
ProjectService projectService,
|
||||||
AppProperties properties) {
|
AppProperties properties) {
|
||||||
this.jdbc = jdbc;
|
this.fileMapper = fileMapper;
|
||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
this.projectService = projectService;
|
this.projectService = projectService;
|
||||||
this.dataRoot = properties.dataRoot().toAbsolutePath().normalize();
|
this.dataRoot = properties.dataRoot().toAbsolutePath().normalize();
|
||||||
@@ -142,24 +144,19 @@ public class ProjectFileService {
|
|||||||
Files.move(temporary, target);
|
Files.move(temporary, target);
|
||||||
moved = true;
|
moved = true;
|
||||||
UUID userId = userService.requireUserId(principal.getName());
|
UUID userId = userService.requireUserId(principal.getName());
|
||||||
jdbc.sql("""
|
// 数据库只保存受控路径和摘要;selective insert 继续使用状态、时间字段的数据库默认值。
|
||||||
INSERT INTO app.project_file(
|
ProjectFileEntity entity = new ProjectFileEntity();
|
||||||
id, project_id, original_name, stored_name, relative_path, mime_type,
|
entity.setId(fileId);
|
||||||
extension, size_bytes, sha256, uploaded_by)
|
entity.setProjectId(projectId);
|
||||||
VALUES (:id, :projectId, :originalName, :storedName, :relativePath, :mimeType,
|
entity.setOriginalName(originalName);
|
||||||
:extension, :sizeBytes, :sha256, :userId)
|
entity.setStoredName(target.getFileName().toString());
|
||||||
""")
|
entity.setRelativePath(workspacePath);
|
||||||
.param("id", fileId)
|
entity.setMimeType(mime);
|
||||||
.param("projectId", projectId)
|
entity.setExtension(extension);
|
||||||
.param("originalName", originalName)
|
entity.setSizeBytes(Files.size(target));
|
||||||
.param("storedName", target.getFileName().toString())
|
entity.setSha256(HexFormat.of().formatHex(digest.digest()));
|
||||||
.param("relativePath", workspacePath)
|
entity.setUploadedBy(userId);
|
||||||
.param("mimeType", mime)
|
fileMapper.insertSelective(entity);
|
||||||
.param("extension", extension)
|
|
||||||
.param("sizeBytes", Files.size(target))
|
|
||||||
.param("sha256", HexFormat.of().formatHex(digest.digest()))
|
|
||||||
.param("userId", userId)
|
|
||||||
.update();
|
|
||||||
return require(fileId);
|
return require(fileId);
|
||||||
} catch (FileAlreadyExistsException exception) {
|
} catch (FileAlreadyExistsException exception) {
|
||||||
cleanupFailedUpload(exception, temporary);
|
cleanupFailedUpload(exception, temporary);
|
||||||
@@ -197,16 +194,13 @@ public class ProjectFileService {
|
|||||||
*/
|
*/
|
||||||
public List<FileView> list(UUID projectId) {
|
public List<FileView> list(UUID projectId) {
|
||||||
projectService.require(projectId);
|
projectService.require(projectId);
|
||||||
return jdbc.sql("""
|
QueryWrapper query = fileViewQuery()
|
||||||
SELECT id, project_id, original_name, relative_path, mime_type, extension,
|
.where(ProjectFileEntity::getProjectId).eq(projectId)
|
||||||
size_bytes, status, created_at
|
.and(ProjectFileEntity::getDeletedAt).isNull()
|
||||||
FROM app.project_file
|
.orderBy(ProjectFileEntity::getRelativePath).asc();
|
||||||
WHERE project_id = :projectId AND deleted_at IS NULL
|
return fileMapper.selectListByQuery(query).stream()
|
||||||
ORDER BY relative_path
|
.map(ProjectFileService::toFileView)
|
||||||
""")
|
.toList();
|
||||||
.param("projectId", projectId)
|
|
||||||
.query(ProjectFileService::mapFile)
|
|
||||||
.list();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -216,18 +210,22 @@ public class ProjectFileService {
|
|||||||
* @param fileId 文件 ID
|
* @param fileId 文件 ID
|
||||||
* @return 文件资源
|
* @return 文件资源
|
||||||
*/
|
*/
|
||||||
|
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
|
||||||
public Download download(UUID projectId, UUID fileId) {
|
public Download download(UUID projectId, UUID fileId) {
|
||||||
StoredFile stored = jdbc.sql("""
|
QueryWrapper query = QueryWrapper.create()
|
||||||
SELECT original_name, relative_path, mime_type
|
.select(
|
||||||
FROM app.project_file
|
ProjectFileEntity::getOriginalName,
|
||||||
WHERE id = :fileId AND project_id = :projectId AND deleted_at IS NULL AND status = 'READY'
|
ProjectFileEntity::getRelativePath,
|
||||||
""")
|
ProjectFileEntity::getMimeType)
|
||||||
.param("fileId", fileId)
|
.where(ProjectFileEntity::getId).eq(fileId)
|
||||||
.param("projectId", projectId)
|
.and(ProjectFileEntity::getProjectId).eq(projectId)
|
||||||
.query((rs, rowNum) -> new StoredFile(
|
.and(ProjectFileEntity::getDeletedAt).isNull()
|
||||||
rs.getString("original_name"), rs.getString("relative_path"), rs.getString("mime_type")))
|
.and(ProjectFileEntity::getStatus).eq("READY");
|
||||||
.optional()
|
ProjectFileEntity entity = fileMapper.selectOneByQuery(query);
|
||||||
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "FILE_NOT_FOUND", "文件不存在"));
|
if (entity == null) {
|
||||||
|
throw new ApiException(HttpStatus.NOT_FOUND, "FILE_NOT_FOUND", "文件不存在");
|
||||||
|
}
|
||||||
|
StoredFile stored = new StoredFile(entity.getOriginalName(), entity.getRelativePath(), entity.getMimeType());
|
||||||
try {
|
try {
|
||||||
Resource resource = new UrlResource(safeProjectPath(projectId, stored.relativePath()).toUri());
|
Resource resource = new UrlResource(safeProjectPath(projectId, stored.relativePath()).toUri());
|
||||||
if (!resource.exists()) {
|
if (!resource.exists()) {
|
||||||
@@ -314,28 +312,54 @@ public class ProjectFileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private FileView require(UUID fileId) {
|
private FileView require(UUID fileId) {
|
||||||
return jdbc.sql("""
|
ProjectFileEntity entity = fileMapper.selectOneByQuery(
|
||||||
SELECT id, project_id, original_name, relative_path, mime_type, extension,
|
fileViewQuery().where(ProjectFileEntity::getId).eq(fileId));
|
||||||
size_bytes, status, created_at
|
if (entity == null) {
|
||||||
FROM app.project_file WHERE id = :id
|
throw new ApiException(HttpStatus.NOT_FOUND, "FILE_NOT_FOUND", "文件不存在");
|
||||||
""")
|
}
|
||||||
.param("id", fileId)
|
return toFileView(entity);
|
||||||
.query(ProjectFileService::mapFile)
|
|
||||||
.optional()
|
|
||||||
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "FILE_NOT_FOUND", "文件不存在"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static FileView mapFile(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
|
/**
|
||||||
|
* 构造项目材料接口视图使用的最小字段投影。
|
||||||
|
*
|
||||||
|
* <p>该投影与迁移前 JDBC 列表和单条查询的显式字段保持一致,仅排除存储文件名、
|
||||||
|
* 文件摘要、上传人和更新时间等当前接口不需要的内部列。查询条件和业务判断仍由
|
||||||
|
* 调用方追加,因此本方法只承担 ORM 查询字段收敛,不改变任何业务语义。</p>
|
||||||
|
*
|
||||||
|
* @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(
|
return new FileView(
|
||||||
rs.getObject("id", UUID.class),
|
entity.getId(),
|
||||||
rs.getObject("project_id", UUID.class),
|
entity.getProjectId(),
|
||||||
rs.getString("original_name"),
|
entity.getOriginalName(),
|
||||||
rs.getString("relative_path"),
|
entity.getRelativePath(),
|
||||||
rs.getString("mime_type"),
|
entity.getMimeType(),
|
||||||
rs.getString("extension"),
|
entity.getExtension(),
|
||||||
rs.getLong("size_bytes"),
|
entity.getSizeBytes() == null ? 0L : entity.getSizeBytes(),
|
||||||
rs.getString("status"),
|
entity.getStatus(),
|
||||||
rs.getObject("created_at", OffsetDateTime.class));
|
entity.getCreatedAt());
|
||||||
}
|
}
|
||||||
|
|
||||||
private String safeName(String originalName) {
|
private String safeName(String originalName) {
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
package tech.easyflow.manuagent.project;
|
package tech.easyflow.manuagent.project;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import tech.easyflow.manuagent.auth.UserService;
|
import tech.easyflow.manuagent.auth.UserService;
|
||||||
import tech.easyflow.manuagent.common.ApiException;
|
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.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
@@ -10,7 +15,6 @@ import java.time.OffsetDateTime;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -20,19 +24,26 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
@Service
|
@Service
|
||||||
public class ProjectService {
|
public class ProjectService {
|
||||||
|
|
||||||
private final JdbcClient jdbc;
|
private final ProjectMapper projectMapper;
|
||||||
|
private final ProjectPlanMapper planMapper;
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建项目服务。
|
* 创建项目服务。
|
||||||
*
|
*
|
||||||
* @param jdbc JDBC 客户端
|
* @param projectMapper 项目 Mapper
|
||||||
|
* @param planMapper 规划版本 Mapper
|
||||||
* @param userService 用户服务
|
* @param userService 用户服务
|
||||||
* @param objectMapper JSON 映射器
|
* @param objectMapper JSON 映射器
|
||||||
*/
|
*/
|
||||||
public ProjectService(JdbcClient jdbc, UserService userService, ObjectMapper objectMapper) {
|
public ProjectService(
|
||||||
this.jdbc = jdbc;
|
ProjectMapper projectMapper,
|
||||||
|
ProjectPlanMapper planMapper,
|
||||||
|
UserService userService,
|
||||||
|
ObjectMapper objectMapper) {
|
||||||
|
this.projectMapper = projectMapper;
|
||||||
|
this.planMapper = planMapper;
|
||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
@@ -51,18 +62,14 @@ public class ProjectService {
|
|||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
UUID userId = userService.requireUserId(principal.getName());
|
UUID userId = userService.requireUserId(principal.getName());
|
||||||
String threadId = "project-" + id;
|
String threadId = "project-" + id;
|
||||||
jdbc.sql("""
|
ProjectEntity entity = new ProjectEntity();
|
||||||
INSERT INTO app.project(
|
entity.setId(id);
|
||||||
id, company_name, project_name, agui_thread_id, application_level, created_by)
|
entity.setCompanyName(companyName.trim());
|
||||||
VALUES (:id, :companyName, :projectName, :threadId, :level, :userId)
|
entity.setProjectName(companyName.trim());
|
||||||
""")
|
entity.setAguiThreadId(threadId);
|
||||||
.param("id", id)
|
entity.setApplicationLevel(level);
|
||||||
.param("companyName", companyName.trim())
|
entity.setCreatedBy(userId);
|
||||||
.param("projectName", companyName.trim())
|
projectMapper.insertSelective(entity);
|
||||||
.param("threadId", threadId)
|
|
||||||
.param("level", level)
|
|
||||||
.param("userId", userId)
|
|
||||||
.update();
|
|
||||||
return require(id);
|
return require(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,9 +79,10 @@ public class ProjectService {
|
|||||||
* @return 按更新时间倒序的项目
|
* @return 按更新时间倒序的项目
|
||||||
*/
|
*/
|
||||||
public List<ProjectView> list() {
|
public List<ProjectView> list() {
|
||||||
return jdbc.sql(PROJECT_SELECT + " ORDER BY updated_at DESC")
|
QueryWrapper query = projectViewQuery().orderBy(ProjectEntity::getUpdatedAt).desc();
|
||||||
.query(ProjectService::mapProject)
|
return projectMapper.selectListByQuery(query).stream()
|
||||||
.list();
|
.map(ProjectService::toProjectView)
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -85,11 +93,12 @@ public class ProjectService {
|
|||||||
* @throws ApiException 项目不存在时抛出
|
* @throws ApiException 项目不存在时抛出
|
||||||
*/
|
*/
|
||||||
public ProjectView require(UUID projectId) {
|
public ProjectView require(UUID projectId) {
|
||||||
return jdbc.sql(PROJECT_SELECT + " WHERE id = :id")
|
ProjectEntity entity = projectMapper.selectOneByQuery(
|
||||||
.param("id", projectId)
|
projectViewQuery().where(ProjectEntity::getId).eq(projectId));
|
||||||
.query(ProjectService::mapProject)
|
if (entity == null) {
|
||||||
.optional()
|
throw new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在");
|
||||||
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在"));
|
}
|
||||||
|
return toProjectView(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -101,26 +110,17 @@ public class ProjectService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public void delete(UUID projectId) {
|
public void delete(UUID projectId) {
|
||||||
require(projectId);
|
require(projectId);
|
||||||
boolean running = jdbc.sql("""
|
if (projectMapper.hasRunningRun(projectId)) {
|
||||||
SELECT EXISTS(
|
|
||||||
SELECT 1 FROM app.agent_run WHERE project_id = :projectId AND status = 'RUNNING'
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
.param("projectId", projectId)
|
|
||||||
.query(Boolean.class)
|
|
||||||
.single();
|
|
||||||
if (running) {
|
|
||||||
throw new ApiException(HttpStatus.CONFLICT, "PROJECT_RUN_ACTIVE", "请先停止正在执行的任务");
|
throw new ApiException(HttpStatus.CONFLICT, "PROJECT_RUN_ACTIVE", "请先停止正在执行的任务");
|
||||||
}
|
}
|
||||||
|
|
||||||
for (String table : List.of("agent_event", "artifact", "project_plan", "project_file", "agent_run")) {
|
// 按外键依赖顺序删除,所有语句均受当前 Spring 事务保护。
|
||||||
jdbc.sql("DELETE FROM app." + table + " WHERE project_id = :projectId")
|
projectMapper.deleteEvents(projectId);
|
||||||
.param("projectId", projectId)
|
projectMapper.deleteArtifacts(projectId);
|
||||||
.update();
|
projectMapper.deletePlans(projectId);
|
||||||
}
|
projectMapper.deleteFiles(projectId);
|
||||||
int deleted = jdbc.sql("DELETE FROM app.project WHERE id = :projectId")
|
projectMapper.deleteRuns(projectId);
|
||||||
.param("projectId", projectId)
|
int deleted = projectMapper.deleteById(projectId);
|
||||||
.update();
|
|
||||||
if (deleted != 1) {
|
if (deleted != 1) {
|
||||||
throw new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在");
|
throw new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在");
|
||||||
}
|
}
|
||||||
@@ -133,14 +133,7 @@ public class ProjectService {
|
|||||||
* @param status 新阶段
|
* @param status 新阶段
|
||||||
*/
|
*/
|
||||||
public void updateStatus(UUID projectId, String status) {
|
public void updateStatus(UUID projectId, String status) {
|
||||||
int updated = jdbc.sql("""
|
int updated = projectMapper.updateStatus(projectId, status);
|
||||||
UPDATE app.project
|
|
||||||
SET status = :status, version = version + 1, updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = :id
|
|
||||||
""")
|
|
||||||
.param("status", status)
|
|
||||||
.param("id", projectId)
|
|
||||||
.update();
|
|
||||||
if (updated != 1) {
|
if (updated != 1) {
|
||||||
throw new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在");
|
throw new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在");
|
||||||
}
|
}
|
||||||
@@ -156,23 +149,14 @@ public class ProjectService {
|
|||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public PlanView saveDraftPlan(UUID projectId, JsonNode plan, UUID userId) {
|
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")
|
ProjectPlanEntity draft = new ProjectPlanEntity();
|
||||||
.param("id", projectId)
|
draft.setId(UUID.randomUUID());
|
||||||
.query(Integer.class)
|
draft.setProjectId(projectId);
|
||||||
.single();
|
draft.setPlanJson(plan.toString());
|
||||||
UUID planId = UUID.randomUUID();
|
draft.setCreatedBy(userId);
|
||||||
jdbc.sql("""
|
ProjectPlanEntity stored = planMapper.insertNextDraft(draft);
|
||||||
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();
|
|
||||||
updateStatus(projectId, "PLANNING");
|
updateStatus(projectId, "PLANNING");
|
||||||
return requirePlan(planId);
|
return toPlanView(stored);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -182,17 +166,8 @@ public class ProjectService {
|
|||||||
* @return 最新规划;不存在时返回空
|
* @return 最新规划;不存在时返回空
|
||||||
*/
|
*/
|
||||||
public PlanView currentPlan(UUID projectId) {
|
public PlanView currentPlan(UUID projectId) {
|
||||||
return jdbc.sql("""
|
ProjectPlanEntity entity = planMapper.selectCurrent(projectId);
|
||||||
SELECT id, project_id, plan_version, status, plan_json, confirmed_at, created_at
|
return entity == null ? null : toPlanView(entity);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -207,61 +182,65 @@ public class ProjectService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public PlanView confirmPlan(UUID projectId, UUID planId, JsonNode plan, Principal principal) {
|
public PlanView confirmPlan(UUID projectId, UUID planId, JsonNode plan, Principal principal) {
|
||||||
UUID userId = userService.requireUserId(principal.getName());
|
UUID userId = userService.requireUserId(principal.getName());
|
||||||
int updated = jdbc.sql("""
|
ProjectPlanEntity confirmed = planMapper.confirmDraft(projectId, planId, plan.toString(), userId);
|
||||||
UPDATE app.project_plan
|
if (confirmed == null) {
|
||||||
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) {
|
|
||||||
throw new ApiException(HttpStatus.CONFLICT, "PLAN_ALREADY_CONFIRMED", "规划已确认或版本不存在");
|
throw new ApiException(HttpStatus.CONFLICT, "PLAN_ALREADY_CONFIRMED", "规划已确认或版本不存在");
|
||||||
}
|
}
|
||||||
updateStatus(projectId, "WRITING");
|
updateStatus(projectId, "WRITING");
|
||||||
return requirePlan(planId);
|
return toPlanView(confirmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
private PlanView requirePlan(UUID planId) {
|
/** 将规划实体解析成包含 JsonNode 的接口视图。 */
|
||||||
return jdbc.sql("""
|
private PlanView toPlanView(ProjectPlanEntity entity) {
|
||||||
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 {
|
|
||||||
try {
|
try {
|
||||||
return new PlanView(
|
return new PlanView(
|
||||||
rs.getObject("id", UUID.class),
|
entity.getId(),
|
||||||
rs.getObject("project_id", UUID.class),
|
entity.getProjectId(),
|
||||||
rs.getInt("plan_version"),
|
entity.getPlanVersion() == null ? 0 : entity.getPlanVersion(),
|
||||||
rs.getString("status"),
|
entity.getStatus(),
|
||||||
objectMapper.readTree(rs.getString("plan_json")),
|
objectMapper.readTree(entity.getPlanJson()),
|
||||||
rs.getObject("confirmed_at", OffsetDateTime.class),
|
entity.getConfirmedAt(),
|
||||||
rs.getObject("created_at", OffsetDateTime.class));
|
entity.getCreatedAt());
|
||||||
} catch (JsonProcessingException exception) {
|
} 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(
|
return new ProjectView(
|
||||||
rs.getObject("id", UUID.class),
|
entity.getId(),
|
||||||
rs.getString("company_name"),
|
entity.getCompanyName(),
|
||||||
rs.getString("project_name"),
|
entity.getProjectName(),
|
||||||
rs.getString("agui_thread_id"),
|
entity.getAguiThreadId(),
|
||||||
rs.getString("application_level"),
|
entity.getApplicationLevel(),
|
||||||
rs.getString("status"),
|
entity.getStatus(),
|
||||||
rs.getLong("version"),
|
entity.getVersion() == null ? 0L : entity.getVersion(),
|
||||||
rs.getObject("created_at", OffsetDateTime.class),
|
entity.getCreatedAt(),
|
||||||
rs.getObject("updated_at", OffsetDateTime.class));
|
entity.getUpdatedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造项目接口列表与详情共用的最小字段投影。
|
||||||
|
*
|
||||||
|
* <p>字段集合与迁移前 JDBC 查询保持一致,创建人只参与写入和审计,不属于当前项目接口响应,
|
||||||
|
* 因而不在普通列表和单条读取时加载。调用方继续负责追加排序或主键条件。</p>
|
||||||
|
*
|
||||||
|
* @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) {
|
private String normalizeLevel(String level) {
|
||||||
@@ -272,12 +251,6 @@ public class ProjectService {
|
|||||||
return value;
|
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
|
|
||||||
""";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 项目视图。
|
* 项目视图。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
package tech.easyflow.manuagent.skill;
|
package tech.easyflow.manuagent.skill;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import tech.easyflow.manuagent.auth.UserService;
|
import tech.easyflow.manuagent.auth.UserService;
|
||||||
import tech.easyflow.manuagent.common.ApiException;
|
import tech.easyflow.manuagent.common.ApiException;
|
||||||
import tech.easyflow.manuagent.config.AppProperties;
|
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.AgentSkill;
|
||||||
import io.agentscope.core.skill.repository.postgresql.PostgresSkillRepository;
|
import io.agentscope.core.skill.repository.postgresql.PostgresSkillRepository;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -18,7 +22,6 @@ import java.util.UUID;
|
|||||||
import java.util.zip.ZipEntry;
|
import java.util.zip.ZipEntry;
|
||||||
import java.util.zip.ZipInputStream;
|
import java.util.zip.ZipInputStream;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
@@ -32,7 +35,7 @@ public class SkillService {
|
|||||||
private static final int MAX_ZIP_ENTRIES = 500;
|
private static final int MAX_ZIP_ENTRIES = 500;
|
||||||
private static final long MAX_UNCOMPRESSED_BYTES = 20L * 1024 * 1024;
|
private static final long MAX_UNCOMPRESSED_BYTES = 20L * 1024 * 1024;
|
||||||
|
|
||||||
private final JdbcClient jdbc;
|
private final SkillConfigMapper skillMapper;
|
||||||
private final PostgresSkillRepository repository;
|
private final PostgresSkillRepository repository;
|
||||||
private final SkillPackageReader packageReader;
|
private final SkillPackageReader packageReader;
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
@@ -41,19 +44,19 @@ public class SkillService {
|
|||||||
/**
|
/**
|
||||||
* 创建 Skill 服务。
|
* 创建 Skill 服务。
|
||||||
*
|
*
|
||||||
* @param jdbc JDBC 客户端
|
* @param skillMapper 应用 Skill 配置 Mapper
|
||||||
* @param repository AgentScope PostgreSQL 仓库
|
* @param repository AgentScope PostgreSQL 仓库
|
||||||
* @param packageReader Skill 包读取器
|
* @param packageReader Skill 包读取器
|
||||||
* @param userService 用户服务
|
* @param userService 用户服务
|
||||||
* @param properties 应用配置
|
* @param properties 应用配置
|
||||||
*/
|
*/
|
||||||
public SkillService(
|
public SkillService(
|
||||||
JdbcClient jdbc,
|
SkillConfigMapper skillMapper,
|
||||||
PostgresSkillRepository repository,
|
PostgresSkillRepository repository,
|
||||||
SkillPackageReader packageReader,
|
SkillPackageReader packageReader,
|
||||||
UserService userService,
|
UserService userService,
|
||||||
AppProperties properties) {
|
AppProperties properties) {
|
||||||
this.jdbc = jdbc;
|
this.skillMapper = skillMapper;
|
||||||
this.repository = repository;
|
this.repository = repository;
|
||||||
this.packageReader = packageReader;
|
this.packageReader = packageReader;
|
||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
@@ -66,9 +69,9 @@ public class SkillService {
|
|||||||
* @return Skill 列表
|
* @return Skill 列表
|
||||||
*/
|
*/
|
||||||
public List<SkillView> list() {
|
public List<SkillView> list() {
|
||||||
return jdbc.sql(SKILL_SELECT + " ORDER BY c.source_type, s.name")
|
return skillMapper.selectViews().stream()
|
||||||
.query(SkillService::mapSkill)
|
.map(SkillService::toSkillView)
|
||||||
.list();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,11 +81,11 @@ public class SkillService {
|
|||||||
* @return Skill 详情
|
* @return Skill 详情
|
||||||
*/
|
*/
|
||||||
public SkillDetail require(String name) {
|
public SkillDetail require(String name) {
|
||||||
SkillView view = jdbc.sql(SKILL_SELECT + " WHERE s.name = :name")
|
SkillViewRow row = skillMapper.selectView(name);
|
||||||
.param("name", name)
|
if (row == null) {
|
||||||
.query(SkillService::mapSkill)
|
throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在");
|
||||||
.optional()
|
}
|
||||||
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在"));
|
SkillView view = toSkillView(row);
|
||||||
AgentSkill skill = repository.getSkill(name);
|
AgentSkill skill = repository.getSkill(name);
|
||||||
if (skill == null) {
|
if (skill == null) {
|
||||||
throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 内容不存在");
|
throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 内容不存在");
|
||||||
@@ -116,13 +119,7 @@ public class SkillService {
|
|||||||
* @param enabled 是否启用
|
* @param enabled 是否启用
|
||||||
*/
|
*/
|
||||||
public void setEnabled(String name, boolean enabled) {
|
public void setEnabled(String name, boolean enabled) {
|
||||||
int updated = jdbc.sql("""
|
int updated = skillMapper.updateEnabled(name, enabled);
|
||||||
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();
|
|
||||||
if (updated != 1) {
|
if (updated != 1) {
|
||||||
throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在或校验未通过");
|
throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在或校验未通过");
|
||||||
}
|
}
|
||||||
@@ -134,14 +131,15 @@ public class SkillService {
|
|||||||
*
|
*
|
||||||
* @return Skill 名称数组
|
* @return Skill 名称数组
|
||||||
*/
|
*/
|
||||||
|
@SuppressWarnings("unchecked") // MyBatis-Flex 的 select(LambdaGetter<T>...) 使用泛型可变参数,调用本身类型安全。
|
||||||
public String[] enabledNames() {
|
public String[] enabledNames() {
|
||||||
return jdbc.sql("""
|
QueryWrapper query = QueryWrapper.create()
|
||||||
SELECT skill_name FROM app.skill_config
|
.select(SkillConfigEntity::getSkillName)
|
||||||
WHERE enabled AND validation_status = 'VALID'
|
.where(SkillConfigEntity::getEnabled).eq(true)
|
||||||
ORDER BY skill_name
|
.and(SkillConfigEntity::getValidationStatus).eq("VALID")
|
||||||
""")
|
.orderBy(SkillConfigEntity::getSkillName).asc();
|
||||||
.query(String.class)
|
return skillMapper.selectListByQuery(query).stream()
|
||||||
.list()
|
.map(SkillConfigEntity::getSkillName)
|
||||||
.toArray(String[]::new);
|
.toArray(String[]::new);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,17 +169,17 @@ public class SkillService {
|
|||||||
}
|
}
|
||||||
repository.save(List.of(skillPackage.skill()), false);
|
repository.save(List.of(skillPackage.skill()), false);
|
||||||
UUID userId = userService.requireUserId(principal.getName());
|
UUID userId = userService.requireUserId(principal.getName());
|
||||||
jdbc.sql("""
|
SkillConfigEntity config = new SkillConfigEntity();
|
||||||
INSERT INTO app.skill_config(
|
config.setSkillName(name);
|
||||||
skill_name, version, source_type, enabled, read_only, checksum,
|
config.setVersion(skillPackage.version());
|
||||||
validation_status, imported_by)
|
config.setSourceType("IMPORTED");
|
||||||
VALUES (:name, :version, 'IMPORTED', FALSE, TRUE, :checksum, 'VALID', :userId)
|
config.setEnabled(false);
|
||||||
""")
|
config.setReadOnly(true);
|
||||||
.param("name", name)
|
config.setChecksum(skillPackage.checksum());
|
||||||
.param("version", skillPackage.version())
|
config.setValidationStatus("VALID");
|
||||||
.param("checksum", skillPackage.checksum())
|
config.setImportedBy(userId);
|
||||||
.param("userId", userId)
|
// Skill 名称由上传包提供,因此显式使用 WithPk 插入字符串主键。
|
||||||
.update();
|
skillMapper.insertSelectiveWithPk(config);
|
||||||
return require(name).view();
|
return require(name).view();
|
||||||
} finally {
|
} finally {
|
||||||
deleteTree(temporary);
|
deleteTree(temporary);
|
||||||
@@ -197,13 +195,16 @@ public class SkillService {
|
|||||||
* @param name Skill 名称
|
* @param name Skill 名称
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
|
@SuppressWarnings("unchecked") // MyBatis-Flex 的 select(LambdaGetter<T>...) 使用泛型可变参数,调用本身类型安全。
|
||||||
public void deleteImported(String name) {
|
public void deleteImported(String name) {
|
||||||
String sourceType = jdbc.sql("SELECT source_type FROM app.skill_config WHERE skill_name = :name")
|
QueryWrapper query = QueryWrapper.create()
|
||||||
.param("name", name)
|
.select(SkillConfigEntity::getSourceType)
|
||||||
.query(String.class)
|
.where(SkillConfigEntity::getSkillName).eq(name);
|
||||||
.optional()
|
SkillConfigEntity config = skillMapper.selectOneByQuery(query);
|
||||||
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在"));
|
if (config == null) {
|
||||||
if (!"IMPORTED".equals(sourceType)) {
|
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 不能删除");
|
throw new ApiException(HttpStatus.CONFLICT, "BUILTIN_SKILL_READ_ONLY", "内置 Skill 不能删除");
|
||||||
}
|
}
|
||||||
repository.delete(name);
|
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(
|
return new SkillView(
|
||||||
rs.getString("name"),
|
row.getName(),
|
||||||
rs.getString("description"),
|
row.getDescription(),
|
||||||
rs.getString("version"),
|
row.getVersion(),
|
||||||
rs.getString("source_type"),
|
row.getSourceType(),
|
||||||
rs.getBoolean("enabled"),
|
Boolean.TRUE.equals(row.getEnabled()),
|
||||||
rs.getBoolean("read_only"),
|
Boolean.TRUE.equals(row.getReadOnly()),
|
||||||
rs.getString("validation_status"),
|
row.getValidationStatus(),
|
||||||
rs.getString("validation_message"),
|
row.getValidationMessage(),
|
||||||
rs.getObject("updated_at", OffsetDateTime.class));
|
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 列表视图。
|
* Skill 列表视图。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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 列。
|
||||||
|
*
|
||||||
|
* <p>当前 PostgreSQL 阶段使用 JDBC {@link Types#OTHER} 发送 JSON 文本,使驱动按目标列类型完成绑定,
|
||||||
|
* 避免在业务 SQL 中重复书写字符串拼接或手工创建驱动专有对象。未来适配国产数据库时,只需替换
|
||||||
|
* 该类型处理器或按数据库方言提供对应实现,实体和服务层无需感知。</p>
|
||||||
|
*/
|
||||||
|
@MappedTypes(String.class)
|
||||||
|
@MappedJdbcTypes(JdbcType.OTHER)
|
||||||
|
public class JsonbStringTypeHandler extends BaseTypeHandler<String> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 以数据库原生扩展类型绑定非空 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 值之间进行显式转换。
|
||||||
|
*
|
||||||
|
* <p>PostgreSQL 驱动读取 {@code uuid} 列时通常直接返回 {@link UUID},但不同驱动或查询表达式也可能
|
||||||
|
* 返回字符串。该处理器同时兼容两种结果,并通过 {@link PreparedStatement#setObject(int, Object)}
|
||||||
|
* 保留数据库驱动对原生 UUID 类型的绑定能力,避免把 UUID 降级成易产生隐式转换的 VARCHAR。</p>
|
||||||
|
*/
|
||||||
|
@MappedTypes(UUID.class)
|
||||||
|
@MappedJdbcTypes(value = JdbcType.OTHER, includeNullJdbcType = true)
|
||||||
|
public class UuidTypeHandler extends BaseTypeHandler<UUID> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将非空 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,16 @@ spring:
|
|||||||
jackson:
|
jackson:
|
||||||
default-property-inclusion: non_null
|
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:
|
server:
|
||||||
port: 8080
|
port: 8080
|
||||||
servlet:
|
servlet:
|
||||||
|
|||||||
57
server/src/main/resources/mapper/AgentEventMapper.xml
Normal file
57
server/src/main/resources/mapper/AgentEventMapper.xml
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<!DOCTYPE mapper
|
||||||
|
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||||
|
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="tech.easyflow.manuagent.mapper.AgentEventMapper">
|
||||||
|
|
||||||
|
<!-- 显式结果映射避免 payload 列与实体 payloadJson 属性名称不同而丢失事件负载。 -->
|
||||||
|
<resultMap id="agentEventResultMap" type="tech.easyflow.manuagent.entity.AgentEventEntity">
|
||||||
|
<id property="id" column="id"/>
|
||||||
|
<result property="projectId" column="project_id"
|
||||||
|
typeHandler="tech.easyflow.manuagent.typehandler.UuidTypeHandler"/>
|
||||||
|
<result property="runId" column="run_id"
|
||||||
|
typeHandler="tech.easyflow.manuagent.typehandler.UuidTypeHandler"/>
|
||||||
|
<result property="eventType" column="event_type"/>
|
||||||
|
<result property="eventId" column="event_id"/>
|
||||||
|
<result property="payloadJson" column="payload"
|
||||||
|
typeHandler="tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler"/>
|
||||||
|
<result property="createdAt" column="created_at"/>
|
||||||
|
</resultMap>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
PostgreSQL INSERT ... RETURNING 同时完成写入和序号读取,不使用“先插入、再查最大值”
|
||||||
|
这种在并发场景下会取错事件的实现。affectData 保留正确的事务与缓存语义。
|
||||||
|
-->
|
||||||
|
<select id="insertReturning" resultMap="agentEventResultMap" affectData="true" flushCache="true">
|
||||||
|
INSERT INTO app.agent_event(project_id, run_id, event_type, event_id, payload)
|
||||||
|
VALUES (
|
||||||
|
#{event.projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler},
|
||||||
|
#{event.runId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler},
|
||||||
|
#{event.eventType},
|
||||||
|
#{event.eventId},
|
||||||
|
#{event.payloadJson, jdbcType=OTHER,
|
||||||
|
typeHandler=tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler})
|
||||||
|
<!-- 与迁移前 JDBC 返回字段一致;event_id 已完成持久化,但无需再次回传给业务层。 -->
|
||||||
|
RETURNING id, project_id, run_id, event_type, payload, created_at
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- PostgreSQL JSONB 运算仅封装在数据库适配层,业务服务不感知方言细节。 -->
|
||||||
|
<select id="selectLatestStartedPhase" resultType="string">
|
||||||
|
SELECT payload ->> 'phase'
|
||||||
|
FROM app.agent_event
|
||||||
|
WHERE run_id = #{runId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
|
||||||
|
AND event_type = 'RUN_STARTED'
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="selectLatestMaterialResponseJson" resultType="string">
|
||||||
|
SELECT payload::text
|
||||||
|
FROM app.agent_event
|
||||||
|
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
|
||||||
|
AND event_type = 'ASK_RESPONDED'
|
||||||
|
AND jsonb_typeof(payload -> 'decisions') = 'array'
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1
|
||||||
|
</select>
|
||||||
|
</mapper>
|
||||||
58
server/src/main/resources/mapper/AgentRunMapper.xml
Normal file
58
server/src/main/resources/mapper/AgentRunMapper.xml
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<!DOCTYPE mapper
|
||||||
|
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||||
|
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="tech.easyflow.manuagent.mapper.AgentRunMapper">
|
||||||
|
|
||||||
|
<!-- 以下更新均将“当前状态”写进 WHERE,更新行数就是状态机竞争结果。 -->
|
||||||
|
<update id="completeWaiting">
|
||||||
|
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>
|
||||||
|
|
||||||
|
<update id="interruptRunning">
|
||||||
|
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>
|
||||||
|
|
||||||
|
<update id="waitForInput">
|
||||||
|
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>
|
||||||
|
|
||||||
|
<update id="completeRunning">
|
||||||
|
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>
|
||||||
|
|
||||||
|
<update id="failRunning">
|
||||||
|
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>
|
||||||
|
|
||||||
|
<update id="interruptRunningAfterRestart">
|
||||||
|
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>
|
||||||
|
</mapper>
|
||||||
37
server/src/main/resources/mapper/ArtifactMapper.xml
Normal file
37
server/src/main/resources/mapper/ArtifactMapper.xml
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<!DOCTYPE mapper
|
||||||
|
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||||
|
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="tech.easyflow.manuagent.mapper.ArtifactMapper">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
PostgreSQL 的 INSERT ... RETURNING 属于会修改数据的查询语句。
|
||||||
|
affectData 与 flushCache 确保 MyBatis 按 DML 事务语义处理并清理一级缓存。
|
||||||
|
-->
|
||||||
|
<select id="upsert"
|
||||||
|
resultType="tech.easyflow.manuagent.entity.ArtifactEntity"
|
||||||
|
affectData="true"
|
||||||
|
flushCache="true">
|
||||||
|
INSERT INTO app.artifact(
|
||||||
|
id, project_id, run_id, kind, name, relative_path, mime_type,
|
||||||
|
size_bytes, sha256, metadata_json)
|
||||||
|
VALUES (
|
||||||
|
#{artifact.id, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler},
|
||||||
|
#{artifact.projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler},
|
||||||
|
#{artifact.runId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler},
|
||||||
|
#{artifact.kind}, #{artifact.name}, #{artifact.relativePath}, #{artifact.mimeType},
|
||||||
|
#{artifact.sizeBytes}, #{artifact.sha256},
|
||||||
|
#{artifact.metadataJson, jdbcType=OTHER, typeHandler=tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler})
|
||||||
|
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, project_id, run_id, kind, name, size_bytes, metadata_json, published_at
|
||||||
|
</select>
|
||||||
|
</mapper>
|
||||||
18
server/src/main/resources/mapper/ModelAssignmentMapper.xml
Normal file
18
server/src/main/resources/mapper/ModelAssignmentMapper.xml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||||
|
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="tech.easyflow.manuagent.mapper.ModelAssignmentMapper">
|
||||||
|
|
||||||
|
<!-- 角色为主键,单语句 upsert 避免并发设置默认模型时出现先查后写竞态。 -->
|
||||||
|
<insert id="upsert">
|
||||||
|
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
|
||||||
|
</insert>
|
||||||
|
</mapper>
|
||||||
63
server/src/main/resources/mapper/ModelConfigMapper.xml
Normal file
63
server/src/main/resources/mapper/ModelConfigMapper.xml
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||||
|
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="tech.easyflow.manuagent.mapper.ModelConfigMapper">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
JSONB 参数必须显式使用 JsonbStringTypeHandler。这样即使 Lambda Wrapper 在 Spring 初始化前
|
||||||
|
触发了 MyBatis-Flex 的全局 TableInfo 缓存,模型写入仍不会退化为 VARCHAR 参数绑定。
|
||||||
|
-->
|
||||||
|
<insert id="insertModel">
|
||||||
|
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})
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
API Key 为空表示保留已有密钥;更新时间统一由数据库生成,避免应用时钟和数据库时钟混用。
|
||||||
|
-->
|
||||||
|
<update id="updateModel">
|
||||||
|
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},
|
||||||
|
<if test="model.apiKeyCiphertext != null">
|
||||||
|
api_key_ciphertext = #{model.apiKeyCiphertext},
|
||||||
|
api_key_hint = #{model.apiKeyHint},
|
||||||
|
key_version = #{model.keyVersion},
|
||||||
|
</if>
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = #{model.id, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<!-- 以下两条语句保持迁移前的执行顺序和条件,不额外引入模型启用状态判断。 -->
|
||||||
|
<update id="clearDefault">
|
||||||
|
UPDATE app.model_config
|
||||||
|
SET is_default = FALSE
|
||||||
|
WHERE is_default
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="setDefault">
|
||||||
|
UPDATE app.model_config
|
||||||
|
SET is_default = TRUE,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = #{id, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
|
||||||
|
</update>
|
||||||
|
</mapper>
|
||||||
42
server/src/main/resources/mapper/ProjectMapper.xml
Normal file
42
server/src/main/resources/mapper/ProjectMapper.xml
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||||
|
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="tech.easyflow.manuagent.mapper.ProjectMapper">
|
||||||
|
|
||||||
|
<!-- 项目删除前必须先阻止仍在运行的任务。 -->
|
||||||
|
<select id="hasRunningRun" resultType="boolean">
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1 FROM app.agent_run
|
||||||
|
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
|
||||||
|
AND status = 'RUNNING'
|
||||||
|
)
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 以下删除顺序与外键依赖顺序一致,并由 ProjectService 的 Spring 事务统一提交或回滚。 -->
|
||||||
|
<delete id="deleteEvents">
|
||||||
|
DELETE FROM app.agent_event
|
||||||
|
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
|
||||||
|
</delete>
|
||||||
|
<delete id="deleteArtifacts">
|
||||||
|
DELETE FROM app.artifact
|
||||||
|
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
|
||||||
|
</delete>
|
||||||
|
<delete id="deletePlans">
|
||||||
|
DELETE FROM app.project_plan
|
||||||
|
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
|
||||||
|
</delete>
|
||||||
|
<delete id="deleteFiles">
|
||||||
|
DELETE FROM app.project_file
|
||||||
|
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
|
||||||
|
</delete>
|
||||||
|
<delete id="deleteRuns">
|
||||||
|
DELETE FROM app.agent_run
|
||||||
|
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
<update id="updateStatus">
|
||||||
|
UPDATE app.project
|
||||||
|
SET status = #{status}, version = version + 1, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
|
||||||
|
</update>
|
||||||
|
</mapper>
|
||||||
50
server/src/main/resources/mapper/ProjectPlanMapper.xml
Normal file
50
server/src/main/resources/mapper/ProjectPlanMapper.xml
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||||
|
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="tech.easyflow.manuagent.mapper.ProjectPlanMapper">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
版本号计算与写入保持在同一条 PostgreSQL 语句内;唯一约束继续作为并发冲突的最终保护。
|
||||||
|
-->
|
||||||
|
<select id="insertNextDraft"
|
||||||
|
resultType="tech.easyflow.manuagent.entity.ProjectPlanEntity"
|
||||||
|
affectData="true"
|
||||||
|
flushCache="true">
|
||||||
|
INSERT INTO app.project_plan(id, project_id, plan_version, status, plan_json, created_by)
|
||||||
|
SELECT
|
||||||
|
#{plan.id, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler},
|
||||||
|
#{plan.projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler},
|
||||||
|
COALESCE(MAX(plan_version), 0) + 1,
|
||||||
|
'DRAFT',
|
||||||
|
#{plan.planJson, jdbcType=OTHER, typeHandler=tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler},
|
||||||
|
#{plan.createdBy, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
|
||||||
|
FROM app.project_plan
|
||||||
|
WHERE project_id = #{plan.projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
|
||||||
|
RETURNING id, project_id, plan_version, status, plan_json, confirmed_at, created_at
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="selectCurrent" resultType="tech.easyflow.manuagent.entity.ProjectPlanEntity">
|
||||||
|
SELECT id, project_id, plan_version, status, plan_json, confirmed_at, created_at
|
||||||
|
FROM app.project_plan
|
||||||
|
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
|
||||||
|
ORDER BY CASE status WHEN 'CONFIRMED' THEN 0 ELSE 1 END, plan_version DESC
|
||||||
|
LIMIT 1
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 条件更新和 RETURNING 在同一语句中完成,避免确认状态检查与写入之间出现竞态。 -->
|
||||||
|
<select id="confirmDraft"
|
||||||
|
resultType="tech.easyflow.manuagent.entity.ProjectPlanEntity"
|
||||||
|
affectData="true"
|
||||||
|
flushCache="true">
|
||||||
|
UPDATE app.project_plan
|
||||||
|
SET status = 'CONFIRMED',
|
||||||
|
plan_json = #{planJson, jdbcType=OTHER, typeHandler=tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler},
|
||||||
|
confirmed_by = #{userId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler},
|
||||||
|
confirmed_at = CURRENT_TIMESTAMP,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = #{planId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
|
||||||
|
AND project_id = #{projectId, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
|
||||||
|
AND status = 'DRAFT'
|
||||||
|
RETURNING id, project_id, plan_version, status, plan_json, confirmed_at, created_at
|
||||||
|
</select>
|
||||||
|
</mapper>
|
||||||
35
server/src/main/resources/mapper/SkillConfigMapper.xml
Normal file
35
server/src/main/resources/mapper/SkillConfigMapper.xml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||||
|
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="tech.easyflow.manuagent.mapper.SkillConfigMapper">
|
||||||
|
|
||||||
|
<!-- AgentScope 表严格只读;应用只在 app.skill_config 保存启停、来源和校验状态。 -->
|
||||||
|
<sql id="skillViewColumns">
|
||||||
|
s.name, s.description, c.version, c.source_type, c.enabled, c.read_only,
|
||||||
|
c.validation_status, c.validation_message, c.updated_at
|
||||||
|
</sql>
|
||||||
|
|
||||||
|
<select id="selectViews" resultType="tech.easyflow.manuagent.mapper.SkillViewRow">
|
||||||
|
SELECT <include refid="skillViewColumns"/>
|
||||||
|
FROM agentscope.agentscope_skills s
|
||||||
|
JOIN app.skill_config c ON c.skill_name = s.name
|
||||||
|
ORDER BY c.source_type, s.name
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="selectView" resultType="tech.easyflow.manuagent.mapper.SkillViewRow">
|
||||||
|
SELECT <include refid="skillViewColumns"/>
|
||||||
|
FROM agentscope.agentscope_skills s
|
||||||
|
JOIN app.skill_config c ON c.skill_name = s.name
|
||||||
|
WHERE s.name = #{name}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 保持迁移前 JDBC SQL 的过滤条件和数据库时间戳语义。 -->
|
||||||
|
<update id="updateEnabled">
|
||||||
|
UPDATE app.skill_config
|
||||||
|
SET enabled = #{enabled},
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE skill_name = #{name}
|
||||||
|
AND validation_status = 'VALID'
|
||||||
|
</update>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
@@ -5,20 +5,53 @@ import static org.mockito.Mockito.mock;
|
|||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
import tech.easyflow.manuagent.agent.AgentEventService;
|
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.ArtifactService;
|
||||||
import tech.easyflow.manuagent.artifact.DocxValidator;
|
import tech.easyflow.manuagent.artifact.DocxValidator;
|
||||||
import tech.easyflow.manuagent.auth.UserService;
|
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.ProjectService;
|
||||||
import tech.easyflow.manuagent.project.ProjectFileService;
|
import tech.easyflow.manuagent.project.ProjectFileService;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
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.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.io.InputStream;
|
||||||
import java.sql.DriverManager;
|
import java.sql.DriverManager;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.time.Duration;
|
||||||
import org.flywaydb.core.Flyway;
|
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.BeforeAll;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
@@ -77,7 +110,7 @@ class DatabaseAndEventIntegrationTest {
|
|||||||
* 验证事件按项目全局 ID 增量回放且不重复。
|
* 验证事件按项目全局 ID 增量回放且不重复。
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void shouldReplayEventsAfterCursorInOrder() {
|
void shouldReplayEventsAfterCursorInOrder() throws Exception {
|
||||||
JdbcClient jdbc = jdbc();
|
JdbcClient jdbc = jdbc();
|
||||||
UUID userId = UUID.randomUUID();
|
UUID userId = UUID.randomUUID();
|
||||||
UUID modelId = UUID.randomUUID();
|
UUID modelId = UUID.randomUUID();
|
||||||
@@ -87,7 +120,7 @@ class DatabaseAndEventIntegrationTest {
|
|||||||
.param("id", userId).param("name", "u-" + userId).update();
|
.param("id", userId).param("name", "u-" + userId).update();
|
||||||
jdbc.sql("""
|
jdbc.sql("""
|
||||||
INSERT INTO app.model_config(id, name, provider, base_url, model_id, is_default)
|
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();
|
""").param("id", modelId).param("name", "m-" + modelId).update();
|
||||||
jdbc.sql("""
|
jdbc.sql("""
|
||||||
INSERT INTO app.project(id, company_name, project_name, agui_thread_id, application_level, created_by)
|
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("id", runId).param("projectId", projectId).param("modelId", modelId)
|
||||||
.param("trace", UUID.randomUUID().toString()).update();
|
.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 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 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();
|
long third = service.append(projectId, runId, "TEXT_MESSAGE_CONTENT", Map.of("delta", "完成")).id();
|
||||||
@@ -152,7 +185,7 @@ class DatabaseAndEventIntegrationTest {
|
|||||||
Files.writeString(document, "first");
|
Files.writeString(document, "first");
|
||||||
ProjectFileService files = mock(ProjectFileService.class);
|
ProjectFileService files = mock(ProjectFileService.class);
|
||||||
when(files.safeProjectPath(projectId, "artifacts/draft.docx")).thenReturn(document);
|
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();
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
|
||||||
ArtifactService.ArtifactView first = artifacts.publish(
|
ArtifactService.ArtifactView first = artifacts.publish(
|
||||||
@@ -173,7 +206,7 @@ class DatabaseAndEventIntegrationTest {
|
|||||||
* 验证项目真删除会清除所有关联业务记录。
|
* 验证项目真删除会清除所有关联业务记录。
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void shouldDeleteProjectRecords() {
|
void shouldDeleteProjectRecords() throws Exception {
|
||||||
JdbcClient jdbc = jdbc();
|
JdbcClient jdbc = jdbc();
|
||||||
UUID userId = UUID.randomUUID();
|
UUID userId = UUID.randomUUID();
|
||||||
UUID projectId = UUID.randomUUID();
|
UUID projectId = UUID.randomUUID();
|
||||||
@@ -213,7 +246,8 @@ class DatabaseAndEventIntegrationTest {
|
|||||||
""").param("id", UUID.randomUUID()).param("projectId", projectId).param("runId", runId)
|
""").param("id", UUID.randomUUID()).param("projectId", projectId).param("runId", runId)
|
||||||
.param("sha", "0".repeat(64)).update();
|
.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);
|
service.delete(projectId);
|
||||||
|
|
||||||
for (String table : List.of("agent_event", "artifact", "project_plan", "project_file", "agent_run")) {
|
for (String table : List.of("agent_event", "artifact", "project_plan", "project_file", "agent_run")) {
|
||||||
@@ -229,11 +263,453 @@ class DatabaseAndEventIntegrationTest {
|
|||||||
.single()).isZero();
|
.single()).isZero();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 Run 创建、等待确认、完成和中断均遵守数据库状态机条件。
|
||||||
|
*
|
||||||
|
* <p>该测试直接覆盖 MyBatis-Flex BaseMapper 插入、XML 状态更新和实体结果映射,
|
||||||
|
* 防止迁移后出现 UUID 主键未写入、JSONB Ask 丢失或终态被重复覆盖。</p>
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void shouldPersistAndTransitionAgentRunWithMybatisFlex() throws Exception {
|
||||||
|
JdbcClient jdbc = jdbc();
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
UUID modelId = UUID.randomUUID();
|
||||||
|
UUID projectId = UUID.randomUUID();
|
||||||
|
jdbc.sql("INSERT INTO app.app_user(id, username, password_hash, display_name) VALUES (:id, :name, 'x', 'test')")
|
||||||
|
.param("id", userId).param("name", "run-u-" + userId).update();
|
||||||
|
// 测试类共用同一容器;先释放其他用例留下的唯一默认模型,再建立本用例的确定性前置条件。
|
||||||
|
jdbc.sql("UPDATE app.model_config SET is_default = FALSE WHERE is_default").update();
|
||||||
|
jdbc.sql("""
|
||||||
|
INSERT INTO app.model_config(id, name, provider, base_url, model_id, is_default)
|
||||||
|
VALUES (:id, :name, 'OPENAI_COMPATIBLE', 'https://example.test', 'model', TRUE)
|
||||||
|
""").param("id", modelId).param("name", "run-m-" + modelId).update();
|
||||||
|
jdbc.sql("""
|
||||||
|
INSERT INTO app.project(id, company_name, project_name, agui_thread_id, application_level, created_by)
|
||||||
|
VALUES (:id, '企业', 'Run 迁移测试', :threadId, 'ADVANCED', :userId)
|
||||||
|
""").param("id", projectId).param("threadId", "run-thread-" + projectId)
|
||||||
|
.param("userId", userId).update();
|
||||||
|
|
||||||
|
AgentRunMapper mapper = agentRunMapper();
|
||||||
|
AgentEventMapper events = agentEventMapper();
|
||||||
|
AgentRunStore store = new AgentRunStore(mapper, events, modelConfigMapper(), 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<String, Object> capabilities = Map.of(
|
||||||
|
"toolCalling", true, "reasoning", true, "contextWindow", 65_536);
|
||||||
|
|
||||||
|
ModelService.ModelView created = service.save(
|
||||||
|
null,
|
||||||
|
new ModelService.ModelInput(
|
||||||
|
"测试模型", "https://model.example.test/", "model-v1", "secret-1234",
|
||||||
|
Map.of("timeoutSeconds", 60), capabilities),
|
||||||
|
() -> "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<SkillService.SkillView> skills = service.list();
|
||||||
|
assertThat(skills).isNotEmpty();
|
||||||
|
String name = skills.getFirst().name();
|
||||||
|
service.setEnabled(name, false);
|
||||||
|
|
||||||
|
assertThat(service.enabledNames()).doesNotContain(name);
|
||||||
|
assertThat(jdbc().sql("SELECT enabled FROM app.skill_config WHERE skill_name = :name")
|
||||||
|
.param("name", name).query(Boolean.class).single()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 MyBatis-Flex 可以在 app schema 中插入并通过 Lambda QueryWrapper 查询用户。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void shouldPersistAndQueryUserWithMyBatisFlex() {
|
||||||
|
PGSimpleDataSource source = dataSource();
|
||||||
|
AppUserMapper mapper = new MybatisFlexBootstrap()
|
||||||
|
.setDataSource(source)
|
||||||
|
.addMapper(AppUserMapper.class)
|
||||||
|
.start()
|
||||||
|
.getMapper(AppUserMapper.class);
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
AppUserEntity user = new AppUserEntity();
|
||||||
|
user.setId(userId);
|
||||||
|
user.setUsername("flex-" + userId);
|
||||||
|
user.setPasswordHash("encoded");
|
||||||
|
user.setDisplayName("Flex 测试用户");
|
||||||
|
|
||||||
|
TableInfo tableInfo = TableInfoFactory.ofEntityClass(AppUserEntity.class);
|
||||||
|
assertThat(tableInfo.getPrimaryColumns()).containsExactly("id");
|
||||||
|
assertThat(tableInfo.getInsertPrimaryKeys()).containsExactly("id");
|
||||||
|
|
||||||
|
// 主键由应用层提前生成;Generator 策略必须保留已有值,其他空字段交给数据库默认值。
|
||||||
|
assertThat(mapper.insertSelectiveWithPk(user)).isEqualTo(1);
|
||||||
|
|
||||||
|
QueryWrapper query = QueryWrapper.create()
|
||||||
|
.where(AppUserEntity::getUsername).eq(user.getUsername());
|
||||||
|
AppUserEntity loaded = mapper.selectOneByQuery(query);
|
||||||
|
assertThat(loaded.getId()).isEqualTo(userId);
|
||||||
|
assertThat(loaded.getDisplayName()).isEqualTo("Flex 测试用户");
|
||||||
|
assertThat(loaded.getEnabled()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
private JdbcClient jdbc() {
|
private JdbcClient jdbc() {
|
||||||
|
return JdbcClient.create(dataSource());
|
||||||
|
}
|
||||||
|
|
||||||
|
private PGSimpleDataSource dataSource() {
|
||||||
PGSimpleDataSource source = new PGSimpleDataSource();
|
PGSimpleDataSource source = new PGSimpleDataSource();
|
||||||
source.setURL(POSTGRES.getJdbcUrl());
|
source.setURL(POSTGRES.getJdbcUrl());
|
||||||
source.setUser(POSTGRES.getUsername());
|
source.setUser(POSTGRES.getUsername());
|
||||||
source.setPassword(POSTGRES.getPassword());
|
source.setPassword(POSTGRES.getPassword());
|
||||||
return JdbcClient.create(source);
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建带 UUID、JSONB 类型处理器和显式 XML 语句的产物 Mapper。
|
||||||
|
*
|
||||||
|
* <p>生产环境由 Spring Boot 扫描类型处理器与 mapper-locations;此处使用轻量 Bootstrap,
|
||||||
|
* 因而需要显式复现相同配置。</p>
|
||||||
|
*
|
||||||
|
* @return 可访问 Testcontainers PostgreSQL 的产物 Mapper
|
||||||
|
* @throws Exception XML 资源读取或 Mapper 初始化失败时抛出
|
||||||
|
*/
|
||||||
|
private ArtifactMapper artifactMapper() throws Exception {
|
||||||
|
PGSimpleDataSource source = dataSource();
|
||||||
|
FlexDataSource flexDataSource = new FlexDataSource("artifact-integration-test", source);
|
||||||
|
Environment environment = new Environment(
|
||||||
|
"artifact-integration-test", new JdbcTransactionFactory(), flexDataSource);
|
||||||
|
FlexConfiguration configuration = new FlexConfiguration(environment);
|
||||||
|
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
|
||||||
|
configuration.getTypeHandlerRegistry().register(JsonbStringTypeHandler.class);
|
||||||
|
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
|
||||||
|
.setConfiguration(configuration)
|
||||||
|
.setDataSource(flexDataSource)
|
||||||
|
.addMapper(ArtifactMapper.class)
|
||||||
|
.start();
|
||||||
|
String resource = "mapper/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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 资源能够共同启动。
|
||||||
|
*
|
||||||
|
* <p>各 PostgreSQL 集成测试使用轻量 Bootstrap 单独加载 Mapper;本测试补充验证 Spring Boot
|
||||||
|
* 实际配置路径,防止 mapper-locations 拼写、Bean 扫描或 XML statement 命名错误只在部署时暴露。</p>
|
||||||
|
*/
|
||||||
|
class MyBatisFlexContextTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载最小 Spring 上下文并核对关键自定义 SQL statement。
|
||||||
|
*
|
||||||
|
* <p>测试 URL 不执行数据库连接;本用例只验证配置装配,实际 SQL 行为由 Testcontainers
|
||||||
|
* PostgreSQL 17 集成测试负责。</p>
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void shouldLoadMapperBeansAndXmlStatements() {
|
||||||
|
new ApplicationContextRunner()
|
||||||
|
.withConfiguration(AutoConfigurations.of(
|
||||||
|
DataSourceAutoConfiguration.class,
|
||||||
|
MybatisFlexAutoConfiguration.class))
|
||||||
|
.withUserConfiguration(MyBatisFlexConfiguration.class)
|
||||||
|
.withPropertyValues(
|
||||||
|
"spring.datasource.url=jdbc:postgresql://127.0.0.1:1/config-only",
|
||||||
|
"spring.datasource.username=test",
|
||||||
|
"spring.datasource.password=test",
|
||||||
|
"spring.datasource.hikari.initialization-fail-timeout=-1",
|
||||||
|
"mybatis-flex.mapper-locations=classpath*:/mapper/**/*.xml",
|
||||||
|
"mybatis-flex.type-aliases-package=tech.easyflow.manuagent.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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 验证跨语句回滚。
|
||||||
|
*
|
||||||
|
* <p>轻量 Mapper Bootstrap 只能证明 SQL 可执行;本测试额外证明生产配置中的 Mapper 调用
|
||||||
|
* 与 {@code @Transactional} 共享同一个数据库事务。</p>
|
||||||
|
*/
|
||||||
|
@Testcontainers
|
||||||
|
class MyBatisFlexTransactionIntegrationTest {
|
||||||
|
|
||||||
|
/** 为事务测试提供隔离的 PostgreSQL 17 数据库。 */
|
||||||
|
@Container
|
||||||
|
private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:17-alpine");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在 Spring 上下文启动前建立与生产一致的应用表结构。
|
||||||
|
*/
|
||||||
|
@BeforeAll
|
||||||
|
static void migrate() {
|
||||||
|
Flyway.configure()
|
||||||
|
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||||
|
.locations("classpath:db/migration")
|
||||||
|
.load()
|
||||||
|
.migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色分配写入失败时,默认模型切换必须整体回滚,不能留下“没有默认模型”的中间状态。
|
||||||
|
*
|
||||||
|
* <p>目标模型刻意保持停用,用于验证 ORM 迁移没有新增原 JDBC 实现不存在的启用状态限制;
|
||||||
|
* 用户 ID 则使用数据库中不存在的值,让后续角色分配稳定触发外键错误。</p>
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void shouldRollbackDefaultModelSwitchWhenAssignmentFails() {
|
||||||
|
new ApplicationContextRunner()
|
||||||
|
.withConfiguration(AutoConfigurations.of(
|
||||||
|
DataSourceAutoConfiguration.class,
|
||||||
|
FlexTransactionAutoConfiguration.class,
|
||||||
|
DataSourceTransactionManagerAutoConfiguration.class,
|
||||||
|
TransactionAutoConfiguration.class,
|
||||||
|
MybatisFlexAutoConfiguration.class))
|
||||||
|
.withUserConfiguration(MyBatisFlexConfiguration.class, TransactionTestConfiguration.class)
|
||||||
|
.withPropertyValues(
|
||||||
|
"spring.datasource.url=" + POSTGRES.getJdbcUrl(),
|
||||||
|
"spring.datasource.username=" + POSTGRES.getUsername(),
|
||||||
|
"spring.datasource.password=" + POSTGRES.getPassword(),
|
||||||
|
"mybatis-flex.mapper-locations=classpath*:/mapper/**/*.xml",
|
||||||
|
"mybatis-flex.type-aliases-package=tech.easyflow.manuagent.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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<QueryWrapper> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<QueryWrapper> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<QueryWrapper> 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<QueryWrapper> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AppUserEntity> 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<QueryWrapper> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ModelService.ModelView> models = service.list();
|
||||||
|
|
||||||
|
ArgumentCaptor<QueryWrapper> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,18 +2,25 @@ package tech.easyflow.manuagent.project;
|
|||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
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.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.auth.UserService;
|
||||||
import tech.easyflow.manuagent.common.ApiException;
|
import tech.easyflow.manuagent.common.ApiException;
|
||||||
import tech.easyflow.manuagent.config.AppProperties;
|
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.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
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",
|
"test-master", "admin", "admin", "https://example.test", "model",
|
||||||
131_072, "runtime:test", "bridge", Duration.ofMinutes(1));
|
131_072, "runtime:test", "bridge", Duration.ofMinutes(1));
|
||||||
ProjectFileService service = new ProjectFileService(
|
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();
|
UUID projectId = UUID.randomUUID();
|
||||||
Path file = service.projectRoot(projectId).resolve("inputs/company.txt");
|
Path file = service.projectRoot(projectId).resolve("inputs/company.txt");
|
||||||
Files.createDirectories(file.getParent());
|
Files.createDirectories(file.getParent());
|
||||||
@@ -72,17 +79,50 @@ class ProjectFileServiceTest {
|
|||||||
.isInstanceOf(ApiException.class);
|
.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<QueryWrapper> 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 文件服务
|
* @return 文件服务
|
||||||
*/
|
*/
|
||||||
private ProjectFileService service() {
|
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"),
|
temporaryDirectory, Path.of("deepseek"), Path.of("dashscope"),
|
||||||
"test-master", "admin", "admin", "https://example.test", "model",
|
"test-master", "admin", "admin", "https://example.test", "model",
|
||||||
131_072, "runtime:test", "bridge", Duration.ofMinutes(1));
|
131_072, "runtime:test", "bridge", Duration.ofMinutes(1));
|
||||||
return new ProjectFileService(
|
|
||||||
mock(JdbcClient.class), mock(UserService.class), mock(ProjectService.class), properties);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<QueryWrapper> 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<QueryWrapper> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user