重构:使用 MyBatis-Flex 迁移应用 ORM
将应用自管表的 JdbcClient 数据访问迁移为实体、Mapper、构造器查询和必要的显式 SQL。 保留 AgentScope 自管表及原有业务语义,并补充事务、查询与数据库集成测试。
This commit is contained in:
@@ -3,13 +3,13 @@ package tech.easyflow.manuagent.agent;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
@@ -17,6 +17,8 @@ import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.publisher.Sinks;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
import tech.easyflow.manuagent.entity.AgentEventEntity;
|
||||
import tech.easyflow.manuagent.mapper.AgentEventMapper;
|
||||
|
||||
/**
|
||||
* 持久化并查询项目级 AG-UI 事件。
|
||||
@@ -24,18 +26,18 @@ import reactor.core.scheduler.Schedulers;
|
||||
@Service
|
||||
public class AgentEventService {
|
||||
|
||||
private final JdbcClient jdbc;
|
||||
private final AgentEventMapper eventMapper;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final Map<UUID, Sinks.Many<EventView>> liveStreams = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 创建事件服务。
|
||||
*
|
||||
* @param jdbc JDBC 客户端
|
||||
* @param eventMapper Agent 事件 Mapper
|
||||
* @param objectMapper JSON 映射器
|
||||
*/
|
||||
public AgentEventService(JdbcClient jdbc, ObjectMapper objectMapper) {
|
||||
this.jdbc = jdbc;
|
||||
public AgentEventService(AgentEventMapper eventMapper, ObjectMapper objectMapper) {
|
||||
this.eventMapper = eventMapper;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@@ -53,18 +55,14 @@ public class AgentEventService {
|
||||
ObjectNode object = value.isObject()
|
||||
? (ObjectNode) value
|
||||
: objectMapper.createObjectNode().set("value", value);
|
||||
EventView event = jdbc.sql("""
|
||||
INSERT INTO app.agent_event(project_id, run_id, event_type, event_id, payload)
|
||||
VALUES (:projectId, :runId, :eventType, :eventId, CAST(:payload AS jsonb))
|
||||
RETURNING id, project_id, run_id, event_type, payload, created_at
|
||||
""")
|
||||
.param("projectId", projectId)
|
||||
.param("runId", runId)
|
||||
.param("eventType", eventType)
|
||||
.param("eventId", UUID.randomUUID().toString())
|
||||
.param("payload", object.toString())
|
||||
.query(this::mapEvent)
|
||||
.single();
|
||||
AgentEventEntity entity = new AgentEventEntity();
|
||||
entity.setProjectId(projectId);
|
||||
entity.setRunId(runId);
|
||||
entity.setEventType(eventType);
|
||||
entity.setEventId(UUID.randomUUID().toString());
|
||||
entity.setPayloadJson(object.toString());
|
||||
// 写入与 RETURNING 必须由同一条 SQL 完成,以原子取得数据库分配的事件游标。
|
||||
EventView event = toEventView(eventMapper.insertReturning(entity));
|
||||
publishAfterCommit(event);
|
||||
return event;
|
||||
}
|
||||
@@ -77,19 +75,24 @@ public class AgentEventService {
|
||||
* @param limit 最大返回数量
|
||||
* @return 有序事件
|
||||
*/
|
||||
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
|
||||
public List<EventView> listAfter(UUID projectId, long afterId, int limit) {
|
||||
return jdbc.sql("""
|
||||
SELECT id, project_id, run_id, event_type, payload, created_at
|
||||
FROM app.agent_event
|
||||
WHERE project_id = :projectId AND id > :afterId
|
||||
ORDER BY id
|
||||
LIMIT :limit
|
||||
""")
|
||||
.param("projectId", projectId)
|
||||
.param("afterId", Math.max(0, afterId))
|
||||
.param("limit", Math.clamp(limit, 1, 1000))
|
||||
.query(this::mapEvent)
|
||||
.list();
|
||||
QueryWrapper query = QueryWrapper.create()
|
||||
.select(
|
||||
AgentEventEntity::getId,
|
||||
AgentEventEntity::getProjectId,
|
||||
AgentEventEntity::getRunId,
|
||||
AgentEventEntity::getEventType,
|
||||
AgentEventEntity::getPayloadJson,
|
||||
AgentEventEntity::getCreatedAt)
|
||||
.where(AgentEventEntity::getProjectId).eq(projectId)
|
||||
.and(AgentEventEntity::getId).gt(Math.max(0, afterId))
|
||||
.orderBy(AgentEventEntity::getId).asc()
|
||||
.limit(Math.clamp(limit, 1, 1000));
|
||||
return eventMapper.selectListByQuery(query)
|
||||
.stream()
|
||||
.map(this::toEventView)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,17 +160,23 @@ public class AgentEventService {
|
||||
}
|
||||
}
|
||||
|
||||
private EventView mapEvent(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
|
||||
/**
|
||||
* 将持久化实体转换成对外事件视图,并在边界处解析 JSONB 文本。
|
||||
*
|
||||
* @param entity 数据库事件实体
|
||||
* @return 可供 REST 与事件流输出的事件
|
||||
*/
|
||||
private EventView toEventView(AgentEventEntity entity) {
|
||||
try {
|
||||
return new EventView(
|
||||
rs.getLong("id"),
|
||||
rs.getObject("project_id", UUID.class),
|
||||
rs.getObject("run_id", UUID.class),
|
||||
rs.getString("event_type"),
|
||||
objectMapper.readTree(rs.getString("payload")),
|
||||
rs.getObject("created_at", OffsetDateTime.class));
|
||||
entity.getId(),
|
||||
entity.getProjectId(),
|
||||
entity.getRunId(),
|
||||
entity.getEventType(),
|
||||
objectMapper.readTree(entity.getPayloadJson()),
|
||||
entity.getCreatedAt());
|
||||
} catch (com.fasterxml.jackson.core.JsonProcessingException exception) {
|
||||
throw new java.sql.SQLException("Agent 事件 JSON 无法解析", exception);
|
||||
throw new IllegalStateException("Agent 事件 JSON 无法解析", exception);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,13 +20,13 @@ import java.util.concurrent.ExecutorService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import reactor.core.publisher.Sinks;
|
||||
import tech.easyflow.manuagent.mapper.AgentRunMapper;
|
||||
|
||||
/**
|
||||
* 驱动材料检验、规划 Ask 和自动编写 Run。
|
||||
@@ -35,7 +35,7 @@ import reactor.core.publisher.Sinks;
|
||||
public class AgentRunService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AgentRunService.class);
|
||||
private final JdbcClient jdbc;
|
||||
private final AgentRunMapper runMapper;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AgentExecutionService executionService;
|
||||
private final AgentOutputService outputService;
|
||||
@@ -52,7 +52,7 @@ public class AgentRunService {
|
||||
/**
|
||||
* 创建 Agent Run 服务。
|
||||
*
|
||||
* @param jdbc JDBC 客户端
|
||||
* @param runMapper Agent Run Mapper
|
||||
* @param objectMapper JSON 映射器
|
||||
* @param executionService Agent 执行服务
|
||||
* @param outputService Agent 结构化输出服务
|
||||
@@ -66,7 +66,7 @@ public class AgentRunService {
|
||||
* @param transactions 编程式事务模板
|
||||
*/
|
||||
public AgentRunService(
|
||||
JdbcClient jdbc,
|
||||
AgentRunMapper runMapper,
|
||||
ObjectMapper objectMapper,
|
||||
AgentExecutionService executionService,
|
||||
AgentOutputService outputService,
|
||||
@@ -78,7 +78,7 @@ public class AgentRunService {
|
||||
ArtifactService artifactService,
|
||||
ExecutorService applicationExecutor,
|
||||
TransactionTemplate transactions) {
|
||||
this.jdbc = jdbc;
|
||||
this.runMapper = runMapper;
|
||||
this.objectMapper = objectMapper;
|
||||
this.executionService = executionService;
|
||||
this.outputService = outputService;
|
||||
@@ -207,15 +207,7 @@ public class AgentRunService {
|
||||
if (run == null || !"RUNNING".equals(run.status())) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_ACTIVE", "当前没有正在执行的任务");
|
||||
}
|
||||
int updated = jdbc.sql("""
|
||||
UPDATE app.agent_run
|
||||
SET status = 'INTERRUPTED', pending_interrupt = NULL,
|
||||
error_code = 'USER_STOPPED', error_message = '用户已停止运行',
|
||||
ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :id AND status = 'RUNNING'
|
||||
""")
|
||||
.param("id", run.id())
|
||||
.update();
|
||||
int updated = runMapper.interruptRunning(run.id());
|
||||
requireTerminalUpdate(updated);
|
||||
eventService.append(projectId, run.id(), "RUN_FINISHED", Map.of("outcome", "CANCELLED"));
|
||||
onCommit(() -> {
|
||||
@@ -473,15 +465,7 @@ public class AgentRunService {
|
||||
private void finishWaiting(UUID projectId, UUID runId, JsonNode interrupt) {
|
||||
transactions.executeWithoutResult(status -> {
|
||||
eventService.append(projectId, runId, "ASK_REQUESTED", interrupt);
|
||||
int updated = jdbc.sql("""
|
||||
UPDATE app.agent_run
|
||||
SET status = 'WAITING_INPUT', pending_interrupt = CAST(:interrupt AS jsonb),
|
||||
ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :id AND status = 'RUNNING'
|
||||
""")
|
||||
.param("interrupt", interrupt.toString())
|
||||
.param("id", runId)
|
||||
.update();
|
||||
int updated = runMapper.waitForInput(runId, interrupt.toString());
|
||||
requireTerminalUpdate(updated);
|
||||
eventService.append(projectId, runId, "RUN_FINISHED", Map.of("outcome", "INTERRUPT"));
|
||||
});
|
||||
@@ -575,14 +559,7 @@ public class AgentRunService {
|
||||
ArtifactService.ArtifactView artifact = artifactService.publishCandidate(
|
||||
projectId, run.id(), run.startedAt().toInstant(), metadata);
|
||||
eventService.append(projectId, run.id(), "ARTIFACT_PUBLISHED", artifact);
|
||||
int updated = jdbc.sql("""
|
||||
UPDATE app.agent_run
|
||||
SET status = 'COMPLETED', pending_interrupt = NULL,
|
||||
ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :id AND status = 'RUNNING'
|
||||
""")
|
||||
.param("id", run.id())
|
||||
.update();
|
||||
int updated = runMapper.completeRunning(run.id());
|
||||
requireTerminalUpdate(updated);
|
||||
projectService.updateStatus(projectId, "DELIVERED");
|
||||
eventService.append(projectId, run.id(), "RUN_FINISHED", Map.of("outcome", "SUCCESS"));
|
||||
@@ -602,15 +579,7 @@ public class AgentRunService {
|
||||
: "Agent 执行失败,请稍后重试";
|
||||
try {
|
||||
transactions.executeWithoutResult(status -> {
|
||||
int updated = jdbc.sql("""
|
||||
UPDATE app.agent_run
|
||||
SET status = 'FAILED', pending_interrupt = NULL, error_code = 'AGENT_RUN_FAILED',
|
||||
error_message = :message, ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :id AND status = 'RUNNING'
|
||||
""")
|
||||
.param("message", message)
|
||||
.param("id", run.id())
|
||||
.update();
|
||||
int updated = runMapper.failRunning(run.id(), message);
|
||||
if (updated == 1) {
|
||||
eventService.append(run.projectId(), run.id(), "RUN_ERROR", Map.of(
|
||||
"code", "AGENT_RUN_FAILED", "message", message));
|
||||
|
||||
@@ -5,11 +5,15 @@ import tech.easyflow.manuagent.project.ProjectService;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.time.OffsetDateTime;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import java.util.UUID;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Service;
|
||||
import tech.easyflow.manuagent.entity.AgentRunEntity;
|
||||
import tech.easyflow.manuagent.entity.ModelConfigEntity;
|
||||
import tech.easyflow.manuagent.mapper.AgentEventMapper;
|
||||
import tech.easyflow.manuagent.mapper.AgentRunMapper;
|
||||
import tech.easyflow.manuagent.mapper.ModelConfigMapper;
|
||||
|
||||
/**
|
||||
* 集中读写 Agent Run 持久化状态。
|
||||
@@ -17,17 +21,27 @@ import org.springframework.stereotype.Service;
|
||||
@Service
|
||||
public class AgentRunStore {
|
||||
|
||||
private final JdbcClient jdbc;
|
||||
private final AgentRunMapper runMapper;
|
||||
private final AgentEventMapper eventMapper;
|
||||
private final ModelConfigMapper modelMapper;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* 创建 Run 状态存储。
|
||||
*
|
||||
* @param jdbc JDBC 客户端
|
||||
* @param runMapper Agent Run Mapper
|
||||
* @param eventMapper Agent 事件 Mapper
|
||||
* @param modelMapper 模型配置 Mapper
|
||||
* @param objectMapper JSON 映射器
|
||||
*/
|
||||
public AgentRunStore(JdbcClient jdbc, ObjectMapper objectMapper) {
|
||||
this.jdbc = jdbc;
|
||||
public AgentRunStore(
|
||||
AgentRunMapper runMapper,
|
||||
AgentEventMapper eventMapper,
|
||||
ModelConfigMapper modelMapper,
|
||||
ObjectMapper objectMapper) {
|
||||
this.runMapper = runMapper;
|
||||
this.eventMapper = eventMapper;
|
||||
this.modelMapper = modelMapper;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@@ -39,34 +53,36 @@ public class AgentRunStore {
|
||||
* @param parentRunId 父 Run ID
|
||||
* @return 新 Run
|
||||
*/
|
||||
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
|
||||
public AgentRunService.RunView create(UUID projectId, String triggerType, UUID parentRunId) {
|
||||
Integer active = jdbc.sql("""
|
||||
SELECT count(*) FROM app.agent_run
|
||||
WHERE project_id = :projectId AND status IN ('RUNNING', 'WAITING_INPUT')
|
||||
""")
|
||||
.param("projectId", projectId)
|
||||
.query(Integer.class)
|
||||
.single();
|
||||
QueryWrapper activeRuns = QueryWrapper.create()
|
||||
.where(AgentRunEntity::getProjectId).eq(projectId)
|
||||
.and(AgentRunEntity::getStatus).in("RUNNING", "WAITING_INPUT");
|
||||
long active = runMapper.selectCountByQuery(activeRuns);
|
||||
if (active > 0) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "RUN_ALREADY_ACTIVE", "项目已有正在执行或等待确认的任务");
|
||||
}
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID modelId = jdbc.sql("SELECT id FROM app.model_config WHERE is_default AND enabled")
|
||||
.query(UUID.class)
|
||||
.single();
|
||||
jdbc.sql("""
|
||||
INSERT INTO app.agent_run(
|
||||
id, project_id, parent_run_id, model_config_id, trigger_type, status, trace_id)
|
||||
VALUES (:id, :projectId, :parentRunId, :modelId, :triggerType, 'RUNNING', :traceId)
|
||||
""")
|
||||
.param("id", id)
|
||||
.param("projectId", projectId)
|
||||
.param("parentRunId", parentRunId)
|
||||
.param("modelId", modelId)
|
||||
.param("triggerType", triggerType)
|
||||
.param("traceId", UUID.randomUUID().toString())
|
||||
.update();
|
||||
return require(id);
|
||||
QueryWrapper defaultModel = QueryWrapper.create()
|
||||
.select(ModelConfigEntity::getId)
|
||||
.where(ModelConfigEntity::getDefaultModel).eq(true)
|
||||
.and(ModelConfigEntity::getEnabled).eq(true);
|
||||
ModelConfigEntity model = modelMapper.selectOneByQuery(defaultModel);
|
||||
if (model == null) {
|
||||
// 迁移前的强制单条查询在该数据库不变量失效时进入统一 500 路径,不能新增 409 业务语义。
|
||||
throw new IllegalStateException("数据库中不存在已启用的默认模型");
|
||||
}
|
||||
|
||||
// 应用层提前生成 Run 与追踪 ID;时间字段仍交由数据库默认值统一生成。
|
||||
AgentRunEntity entity = new AgentRunEntity();
|
||||
entity.setId(UUID.randomUUID());
|
||||
entity.setProjectId(projectId);
|
||||
entity.setParentRunId(parentRunId);
|
||||
entity.setModelConfigId(model.getId());
|
||||
entity.setTriggerType(triggerType);
|
||||
entity.setStatus("RUNNING");
|
||||
entity.setTraceId(UUID.randomUUID().toString());
|
||||
runMapper.insertSelective(entity);
|
||||
return require(entity.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,11 +92,11 @@ public class AgentRunStore {
|
||||
* @return 最近 Run;不存在时为空
|
||||
*/
|
||||
public AgentRunService.RunView latest(UUID projectId) {
|
||||
return jdbc.sql(RUN_SELECT + " WHERE project_id = :projectId ORDER BY created_at DESC LIMIT 1")
|
||||
.param("projectId", projectId)
|
||||
.query(AgentRunStore::mapRun)
|
||||
.optional()
|
||||
.orElse(null);
|
||||
QueryWrapper query = runViewQuery()
|
||||
.where(AgentRunEntity::getProjectId).eq(projectId)
|
||||
.orderBy(AgentRunEntity::getCreatedAt).desc()
|
||||
.limit(1);
|
||||
return toRunView(runMapper.selectOneByQuery(query));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,10 +106,14 @@ public class AgentRunStore {
|
||||
* @return Run
|
||||
*/
|
||||
public AgentRunService.RunView require(UUID id) {
|
||||
return jdbc.sql(RUN_SELECT + " WHERE id = :id")
|
||||
.param("id", id)
|
||||
.query(AgentRunStore::mapRun)
|
||||
.single();
|
||||
QueryWrapper query = runViewQuery()
|
||||
.where(AgentRunEntity::getId).eq(id);
|
||||
AgentRunService.RunView run = toRunView(runMapper.selectOneByQuery(query));
|
||||
if (run == null) {
|
||||
// 强制读取仅用于内部已知 ID;缺失表示持久化状态异常,而不是新增的 404 业务分支。
|
||||
throw new IllegalStateException("Agent Run 不存在: " + id);
|
||||
}
|
||||
return run;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,13 +122,7 @@ public class AgentRunStore {
|
||||
* @param runId Run ID
|
||||
*/
|
||||
public void completeWaiting(UUID runId) {
|
||||
int updated = jdbc.sql("""
|
||||
UPDATE app.agent_run
|
||||
SET status = 'COMPLETED', pending_interrupt = NULL, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :id AND status = 'WAITING_INPUT'
|
||||
""")
|
||||
.param("id", runId)
|
||||
.update();
|
||||
int updated = runMapper.completeWaiting(runId);
|
||||
if (updated != 1) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "ASK_ALREADY_RESPONDED", "该确认已处理,请刷新页面");
|
||||
}
|
||||
@@ -142,10 +156,11 @@ public class AgentRunStore {
|
||||
* @param runId Run ID
|
||||
*/
|
||||
public void ensureRunning(UUID runId) {
|
||||
String status = jdbc.sql("SELECT status FROM app.agent_run WHERE id = :id")
|
||||
.param("id", runId)
|
||||
.query(String.class)
|
||||
.single();
|
||||
String status = status(runId);
|
||||
if (status == null) {
|
||||
// 保持迁移前强制单条查询对缺失记录的未预期异常语义。
|
||||
throw new IllegalStateException("Agent Run 不存在: " + runId);
|
||||
}
|
||||
if (!"RUNNING".equals(status)) {
|
||||
throw new AgentExecutionService.RunInterruptedException();
|
||||
}
|
||||
@@ -158,11 +173,7 @@ public class AgentRunStore {
|
||||
* @return 是否已停止
|
||||
*/
|
||||
public boolean isInterrupted(UUID runId) {
|
||||
return jdbc.sql("SELECT status = 'INTERRUPTED' FROM app.agent_run WHERE id = :id")
|
||||
.param("id", runId)
|
||||
.query(Boolean.class)
|
||||
.optional()
|
||||
.orElse(false);
|
||||
return "INTERRUPTED".equals(status(runId));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,15 +184,8 @@ public class AgentRunStore {
|
||||
* @return 业务阶段
|
||||
*/
|
||||
public String interruptedPhase(AgentRunService.RunView run, ProjectService.ProjectView project) {
|
||||
return jdbc.sql("""
|
||||
SELECT payload ->> 'phase' FROM app.agent_event
|
||||
WHERE run_id = :runId AND event_type = 'RUN_STARTED'
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""")
|
||||
.param("runId", run.id())
|
||||
.query(String.class)
|
||||
.optional()
|
||||
.orElse(project.status());
|
||||
String phase = eventMapper.selectLatestStartedPhase(run.id());
|
||||
return phase == null ? project.status() : phase;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -191,43 +195,73 @@ public class AgentRunStore {
|
||||
* @return 材料确认 JSON
|
||||
*/
|
||||
public JsonNode latestMaterialResponse(UUID projectId) {
|
||||
return jdbc.sql("""
|
||||
SELECT payload::text FROM app.agent_event
|
||||
WHERE project_id = :projectId AND event_type = 'ASK_RESPONDED'
|
||||
AND jsonb_typeof(payload -> 'decisions') = 'array'
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""")
|
||||
.param("projectId", projectId)
|
||||
.query(String.class)
|
||||
.optional()
|
||||
.map(value -> {
|
||||
try {
|
||||
return objectMapper.readTree(value);
|
||||
} catch (JsonProcessingException exception) {
|
||||
throw new ApiException(
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
"MATERIAL_RESPONSE_INVALID",
|
||||
"材料确认记录无法读取");
|
||||
}
|
||||
})
|
||||
.orElseGet(objectMapper::createObjectNode);
|
||||
String value = eventMapper.selectLatestMaterialResponseJson(projectId);
|
||||
if (value == null) {
|
||||
return objectMapper.createObjectNode();
|
||||
}
|
||||
try {
|
||||
return objectMapper.readTree(value);
|
||||
} catch (JsonProcessingException exception) {
|
||||
throw new ApiException(
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
"MATERIAL_RESPONSE_INVALID",
|
||||
"材料确认记录无法读取");
|
||||
}
|
||||
}
|
||||
|
||||
private static AgentRunService.RunView mapRun(java.sql.ResultSet rs, int rowNum)
|
||||
throws java.sql.SQLException {
|
||||
/**
|
||||
* 将数据库实体转换为稳定的对外 Run 视图。
|
||||
*
|
||||
* @param entity Run 实体;不存在时为空
|
||||
* @return Run 视图;不存在时为空
|
||||
*/
|
||||
private static AgentRunService.RunView toRunView(AgentRunEntity entity) {
|
||||
if (entity == null) {
|
||||
return null;
|
||||
}
|
||||
return new AgentRunService.RunView(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getObject("project_id", UUID.class),
|
||||
rs.getString("trigger_type"),
|
||||
rs.getString("status"),
|
||||
rs.getString("pending_interrupt"),
|
||||
rs.getString("error_message"),
|
||||
rs.getObject("started_at", OffsetDateTime.class),
|
||||
rs.getObject("ended_at", OffsetDateTime.class));
|
||||
entity.getId(),
|
||||
entity.getProjectId(),
|
||||
entity.getTriggerType(),
|
||||
entity.getStatus(),
|
||||
entity.getPendingInterrupt(),
|
||||
entity.getErrorMessage(),
|
||||
entity.getStartedAt(),
|
||||
entity.getEndedAt());
|
||||
}
|
||||
|
||||
private static final String RUN_SELECT = """
|
||||
SELECT id, project_id, trigger_type, status, pending_interrupt, error_message, started_at, ended_at
|
||||
FROM app.agent_run
|
||||
""";
|
||||
/**
|
||||
* 使用 BaseMapper 主键查询读取 Run 状态。
|
||||
*
|
||||
* @param runId Run ID
|
||||
* @return 当前状态;Run 不存在时为空
|
||||
*/
|
||||
@SuppressWarnings("unchecked") // 这里只投影状态列,LambdaGetter 可变参数不会引入运行期类型风险。
|
||||
private String status(UUID runId) {
|
||||
QueryWrapper query = QueryWrapper.create()
|
||||
.select(AgentRunEntity::getStatus)
|
||||
.where(AgentRunEntity::getId).eq(runId);
|
||||
AgentRunEntity run = runMapper.selectOneByQuery(query);
|
||||
return run == null ? null : run.getStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 Agent Run 对外视图所需的最小字段投影。
|
||||
*
|
||||
* <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.ApplicationRunner;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import tech.easyflow.manuagent.mapper.AgentRunMapper;
|
||||
|
||||
/**
|
||||
* 启动时终结因 JVM 中断而遗留的伪运行状态,并保留原业务阶段供继续执行。
|
||||
@@ -14,15 +14,15 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
@Order(0)
|
||||
public class RunRecoveryService implements ApplicationRunner {
|
||||
|
||||
private final JdbcClient jdbc;
|
||||
private final AgentRunMapper runMapper;
|
||||
|
||||
/**
|
||||
* 创建恢复服务。
|
||||
*
|
||||
* @param jdbc JDBC 客户端
|
||||
* @param runMapper Agent Run Mapper
|
||||
*/
|
||||
public RunRecoveryService(JdbcClient jdbc) {
|
||||
this.jdbc = jdbc;
|
||||
public RunRecoveryService(AgentRunMapper runMapper) {
|
||||
this.runMapper = runMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,12 +33,6 @@ public class RunRecoveryService implements ApplicationRunner {
|
||||
@Override
|
||||
@Transactional
|
||||
public void run(ApplicationArguments args) {
|
||||
jdbc.sql("""
|
||||
UPDATE app.agent_run
|
||||
SET status = 'INTERRUPTED', pending_interrupt = NULL,
|
||||
error_code = 'PROCESS_RESTARTED', error_message = '服务重启,运行已中断',
|
||||
ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE status = 'RUNNING'
|
||||
""").update();
|
||||
runMapper.interruptRunningAfterRestart();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package tech.easyflow.manuagent.artifact;
|
||||
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import tech.easyflow.manuagent.common.ApiException;
|
||||
import tech.easyflow.manuagent.entity.ArtifactEntity;
|
||||
import tech.easyflow.manuagent.mapper.ArtifactMapper;
|
||||
import tech.easyflow.manuagent.project.ProjectFileService;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import java.io.IOException;
|
||||
@@ -18,7 +21,6 @@ import java.util.UUID;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.UrlResource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
@@ -27,19 +29,20 @@ import org.springframework.stereotype.Service;
|
||||
@Service
|
||||
public class ArtifactService {
|
||||
|
||||
private final JdbcClient jdbc;
|
||||
private final ArtifactMapper artifactMapper;
|
||||
private final ProjectFileService fileService;
|
||||
private final DocxValidator docxValidator;
|
||||
|
||||
/**
|
||||
* 创建产物服务。
|
||||
*
|
||||
* @param jdbc JDBC 客户端
|
||||
* @param artifactMapper 产物 Mapper
|
||||
* @param fileService 项目文件服务
|
||||
* @param docxValidator DOCX 校验器
|
||||
*/
|
||||
public ArtifactService(JdbcClient jdbc, ProjectFileService fileService, DocxValidator docxValidator) {
|
||||
this.jdbc = jdbc;
|
||||
public ArtifactService(
|
||||
ArtifactMapper artifactMapper, ProjectFileService fileService, DocxValidator docxValidator) {
|
||||
this.artifactMapper = artifactMapper;
|
||||
this.fileService = fileService;
|
||||
this.docxValidator = docxValidator;
|
||||
}
|
||||
@@ -118,36 +121,20 @@ public class ArtifactService {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "ARTIFACT_EMPTY", "产物文件为空");
|
||||
}
|
||||
String hash = sha256(path);
|
||||
UUID id = jdbc.sql("""
|
||||
INSERT INTO app.artifact(
|
||||
id, project_id, run_id, kind, name, relative_path, mime_type,
|
||||
size_bytes, sha256, metadata_json)
|
||||
VALUES (:id, :projectId, :runId, :kind, :name, :path,
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
:size, :sha256, CAST(:metadata AS jsonb))
|
||||
ON CONFLICT (project_id, relative_path) DO UPDATE SET
|
||||
run_id = EXCLUDED.run_id,
|
||||
kind = EXCLUDED.kind,
|
||||
name = EXCLUDED.name,
|
||||
mime_type = EXCLUDED.mime_type,
|
||||
size_bytes = EXCLUDED.size_bytes,
|
||||
sha256 = EXCLUDED.sha256,
|
||||
metadata_json = EXCLUDED.metadata_json,
|
||||
published_at = CURRENT_TIMESTAMP
|
||||
RETURNING id
|
||||
""")
|
||||
.param("id", UUID.randomUUID())
|
||||
.param("projectId", projectId)
|
||||
.param("runId", runId)
|
||||
.param("kind", kind)
|
||||
.param("name", name)
|
||||
.param("path", relativePath)
|
||||
.param("size", size)
|
||||
.param("sha256", hash)
|
||||
.param("metadata", metadata.toString())
|
||||
.query(UUID.class)
|
||||
.single();
|
||||
return require(id);
|
||||
// “项目 + 路径”必须原子 upsert,避免先查后写在并发重试时触发唯一约束竞态。
|
||||
ArtifactEntity entity = new ArtifactEntity();
|
||||
entity.setId(UUID.randomUUID());
|
||||
entity.setProjectId(projectId);
|
||||
entity.setRunId(runId);
|
||||
entity.setKind(kind);
|
||||
entity.setName(name);
|
||||
entity.setRelativePath(relativePath);
|
||||
entity.setMimeType("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
|
||||
entity.setSizeBytes(size);
|
||||
entity.setSha256(hash);
|
||||
entity.setMetadataJson(metadata.toString());
|
||||
ArtifactEntity stored = artifactMapper.upsert(entity);
|
||||
return toArtifactView(stored);
|
||||
} catch (IOException | NoSuchAlgorithmException exception) {
|
||||
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "ARTIFACT_PUBLISH_FAILED", "产物校验失败");
|
||||
}
|
||||
@@ -175,10 +162,12 @@ public class ArtifactService {
|
||||
* @return 按发布时间倒序的产物
|
||||
*/
|
||||
public List<ArtifactView> list(UUID projectId) {
|
||||
return jdbc.sql(ARTIFACT_SELECT + " WHERE project_id = :projectId ORDER BY published_at DESC")
|
||||
.param("projectId", projectId)
|
||||
.query(ArtifactService::mapArtifact)
|
||||
.list();
|
||||
QueryWrapper query = artifactViewQuery()
|
||||
.where(ArtifactEntity::getProjectId).eq(projectId)
|
||||
.orderBy(ArtifactEntity::getPublishedAt).desc();
|
||||
return artifactMapper.selectListByQuery(query).stream()
|
||||
.map(ArtifactService::toArtifactView)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -187,21 +176,28 @@ public class ArtifactService {
|
||||
* @param artifactId 产物 ID
|
||||
* @return 下载信息
|
||||
*/
|
||||
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
|
||||
public Download download(UUID artifactId) {
|
||||
StoredArtifact artifact = jdbc.sql("""
|
||||
SELECT project_id, name, relative_path, mime_type, size_bytes, sha256
|
||||
FROM app.artifact WHERE id = :id
|
||||
""")
|
||||
.param("id", artifactId)
|
||||
.query((rs, rowNum) -> new StoredArtifact(
|
||||
rs.getObject("project_id", UUID.class),
|
||||
rs.getString("name"),
|
||||
rs.getString("relative_path"),
|
||||
rs.getString("mime_type"),
|
||||
rs.getLong("size_bytes"),
|
||||
rs.getString("sha256")))
|
||||
.optional()
|
||||
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "ARTIFACT_NOT_FOUND", "产物不存在"));
|
||||
QueryWrapper query = QueryWrapper.create()
|
||||
.select(
|
||||
ArtifactEntity::getProjectId,
|
||||
ArtifactEntity::getName,
|
||||
ArtifactEntity::getRelativePath,
|
||||
ArtifactEntity::getMimeType,
|
||||
ArtifactEntity::getSizeBytes,
|
||||
ArtifactEntity::getSha256)
|
||||
.where(ArtifactEntity::getId).eq(artifactId);
|
||||
ArtifactEntity entity = artifactMapper.selectOneByQuery(query);
|
||||
if (entity == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "ARTIFACT_NOT_FOUND", "产物不存在");
|
||||
}
|
||||
StoredArtifact artifact = new StoredArtifact(
|
||||
entity.getProjectId(),
|
||||
entity.getName(),
|
||||
entity.getRelativePath(),
|
||||
entity.getMimeType(),
|
||||
entity.getSizeBytes() == null ? 0L : entity.getSizeBytes(),
|
||||
entity.getSha256());
|
||||
try {
|
||||
Path path = fileService.safeProjectPath(artifact.projectId(), artifact.relativePath());
|
||||
Resource resource = new UrlResource(path.toUri());
|
||||
@@ -238,30 +234,44 @@ public class ArtifactService {
|
||||
return HexFormat.of().formatHex(digest.digest());
|
||||
}
|
||||
|
||||
private ArtifactView require(UUID id) {
|
||||
return jdbc.sql(ARTIFACT_SELECT + " WHERE id = :id")
|
||||
.param("id", id)
|
||||
.query(ArtifactService::mapArtifact)
|
||||
.optional()
|
||||
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "ARTIFACT_NOT_FOUND", "产物不存在"));
|
||||
}
|
||||
|
||||
private static ArtifactView mapArtifact(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
|
||||
/**
|
||||
* 将产物实体转换成接口稳定视图。
|
||||
*
|
||||
* @param entity 产物实体
|
||||
* @return 产物接口视图
|
||||
*/
|
||||
private static ArtifactView toArtifactView(ArtifactEntity entity) {
|
||||
return new ArtifactView(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getObject("project_id", UUID.class),
|
||||
rs.getObject("run_id", UUID.class),
|
||||
rs.getString("kind"),
|
||||
rs.getString("name"),
|
||||
rs.getLong("size_bytes"),
|
||||
rs.getString("metadata_json"),
|
||||
rs.getObject("published_at", OffsetDateTime.class));
|
||||
entity.getId(),
|
||||
entity.getProjectId(),
|
||||
entity.getRunId(),
|
||||
entity.getKind(),
|
||||
entity.getName(),
|
||||
entity.getSizeBytes() == null ? 0L : entity.getSizeBytes(),
|
||||
entity.getMetadataJson(),
|
||||
entity.getPublishedAt());
|
||||
}
|
||||
|
||||
private static final String ARTIFACT_SELECT = """
|
||||
SELECT id, project_id, run_id, kind, name, size_bytes, metadata_json, published_at
|
||||
FROM app.artifact
|
||||
""";
|
||||
/**
|
||||
* 构造产物接口列表使用的最小字段投影。
|
||||
*
|
||||
* <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;
|
||||
|
||||
import tech.easyflow.manuagent.common.ApiException;
|
||||
import tech.easyflow.manuagent.config.AppProperties;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import java.util.UUID;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.security.core.userdetails.User;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import tech.easyflow.manuagent.common.ApiException;
|
||||
import tech.easyflow.manuagent.config.AppProperties;
|
||||
import tech.easyflow.manuagent.entity.AppUserEntity;
|
||||
import tech.easyflow.manuagent.mapper.AppUserMapper;
|
||||
|
||||
/**
|
||||
* 管理单管理员账户和当前用户标识。
|
||||
@@ -22,19 +24,19 @@ import org.springframework.core.annotation.Order;
|
||||
@Order(1)
|
||||
public class UserService implements UserDetailsService, ApplicationRunner {
|
||||
|
||||
private final JdbcClient jdbc;
|
||||
private final AppUserMapper userMapper;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final AppProperties properties;
|
||||
|
||||
/**
|
||||
* 创建用户服务。
|
||||
*
|
||||
* @param jdbc JDBC 客户端
|
||||
* @param userMapper 用户表 Mapper
|
||||
* @param passwordEncoder 密码编码器
|
||||
* @param properties 应用配置
|
||||
*/
|
||||
public UserService(JdbcClient jdbc, PasswordEncoder passwordEncoder, AppProperties properties) {
|
||||
this.jdbc = jdbc;
|
||||
public UserService(AppUserMapper userMapper, PasswordEncoder passwordEncoder, AppProperties properties) {
|
||||
this.userMapper = userMapper;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.properties = properties;
|
||||
}
|
||||
@@ -46,18 +48,18 @@ public class UserService implements UserDetailsService, ApplicationRunner {
|
||||
*/
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
Integer count = jdbc.sql("SELECT count(*) FROM app.app_user").query(Integer.class).single();
|
||||
if (count == 0) {
|
||||
jdbc.sql("""
|
||||
INSERT INTO app.app_user(id, username, password_hash, display_name)
|
||||
VALUES (:id, :username, :password, :displayName)
|
||||
""")
|
||||
.param("id", UUID.randomUUID())
|
||||
.param("username", properties.adminUsername())
|
||||
.param("password", passwordEncoder.encode(properties.adminPassword()))
|
||||
.param("displayName", "管理员")
|
||||
.update();
|
||||
long count = userMapper.selectCountByQuery(QueryWrapper.create());
|
||||
if (count > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 首次启动时仍由应用层生成 UUID;selective insert 让 enabled 和时间字段沿用数据库默认值。
|
||||
AppUserEntity administrator = new AppUserEntity();
|
||||
administrator.setId(UUID.randomUUID());
|
||||
administrator.setUsername(properties.adminUsername());
|
||||
administrator.setPasswordHash(passwordEncoder.encode(properties.adminPassword()));
|
||||
administrator.setDisplayName("管理员");
|
||||
userMapper.insertSelective(administrator);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,16 +70,23 @@ public class UserService implements UserDetailsService, ApplicationRunner {
|
||||
* @throws UsernameNotFoundException 用户不存在或被禁用时抛出
|
||||
*/
|
||||
@Override
|
||||
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
|
||||
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
||||
return jdbc.sql("SELECT username, password_hash, enabled FROM app.app_user WHERE username = :username")
|
||||
.param("username", username)
|
||||
.query((rs, rowNum) -> User.withUsername(rs.getString("username"))
|
||||
.password(rs.getString("password_hash"))
|
||||
.roles("ADMIN")
|
||||
.disabled(!rs.getBoolean("enabled"))
|
||||
.build())
|
||||
.optional()
|
||||
.orElseThrow(() -> new UsernameNotFoundException("账户不存在"));
|
||||
QueryWrapper query = QueryWrapper.create()
|
||||
.select(
|
||||
AppUserEntity::getUsername,
|
||||
AppUserEntity::getPasswordHash,
|
||||
AppUserEntity::getEnabled)
|
||||
.where(AppUserEntity::getUsername).eq(username);
|
||||
AppUserEntity entity = userMapper.selectOneByQuery(query);
|
||||
if (entity == null) {
|
||||
throw new UsernameNotFoundException("账户不存在");
|
||||
}
|
||||
return User.withUsername(entity.getUsername())
|
||||
.password(entity.getPasswordHash())
|
||||
.roles("ADMIN")
|
||||
.disabled(!Boolean.TRUE.equals(entity.getEnabled()))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,11 +96,15 @@ public class UserService implements UserDetailsService, ApplicationRunner {
|
||||
* @return 用户 UUID
|
||||
* @throws ApiException 用户不存在时抛出
|
||||
*/
|
||||
@SuppressWarnings("unchecked") // MyBatis-Flex 的 select(LambdaGetter<T>...) 使用泛型可变参数,调用本身类型安全。
|
||||
public UUID requireUserId(String username) {
|
||||
return jdbc.sql("SELECT id FROM app.app_user WHERE username = :username")
|
||||
.param("username", username)
|
||||
.query(UUID.class)
|
||||
.optional()
|
||||
.orElseThrow(() -> new ApiException(HttpStatus.UNAUTHORIZED, "USER_NOT_FOUND", "登录账户不存在"));
|
||||
QueryWrapper query = QueryWrapper.create()
|
||||
.select(AppUserEntity::getId)
|
||||
.where(AppUserEntity::getUsername).eq(username);
|
||||
AppUserEntity entity = userMapper.selectOneByQuery(query);
|
||||
if (entity == null) {
|
||||
throw new ApiException(HttpStatus.UNAUTHORIZED, "USER_NOT_FOUND", "登录账户不存在");
|
||||
}
|
||||
return entity.getId();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import tech.easyflow.manuagent.auth.UserService;
|
||||
import tech.easyflow.manuagent.common.ApiException;
|
||||
import tech.easyflow.manuagent.config.AppProperties;
|
||||
import tech.easyflow.manuagent.entity.AppUserEntity;
|
||||
import tech.easyflow.manuagent.entity.ModelAssignmentEntity;
|
||||
import tech.easyflow.manuagent.entity.ModelConfigEntity;
|
||||
import tech.easyflow.manuagent.mapper.AppUserMapper;
|
||||
import tech.easyflow.manuagent.mapper.ModelAssignmentMapper;
|
||||
import tech.easyflow.manuagent.mapper.ModelConfigMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
@@ -22,7 +29,6 @@ import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -33,7 +39,9 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
@Order(2)
|
||||
public class ModelService implements ApplicationRunner {
|
||||
|
||||
private final JdbcClient jdbc;
|
||||
private final ModelConfigMapper modelMapper;
|
||||
private final ModelAssignmentMapper assignmentMapper;
|
||||
private final AppUserMapper userMapper;
|
||||
private final UserService userService;
|
||||
private final KeyCipher keyCipher;
|
||||
private final AppProperties properties;
|
||||
@@ -43,19 +51,25 @@ public class ModelService implements ApplicationRunner {
|
||||
/**
|
||||
* 创建模型服务。
|
||||
*
|
||||
* @param jdbc JDBC 客户端
|
||||
* @param modelMapper 模型配置 Mapper
|
||||
* @param assignmentMapper 角色模型分配 Mapper
|
||||
* @param userMapper 用户 Mapper
|
||||
* @param userService 用户服务
|
||||
* @param keyCipher 密钥加密器
|
||||
* @param properties 应用配置
|
||||
* @param objectMapper JSON 映射器
|
||||
*/
|
||||
public ModelService(
|
||||
JdbcClient jdbc,
|
||||
ModelConfigMapper modelMapper,
|
||||
ModelAssignmentMapper assignmentMapper,
|
||||
AppUserMapper userMapper,
|
||||
UserService userService,
|
||||
KeyCipher keyCipher,
|
||||
AppProperties properties,
|
||||
ObjectMapper objectMapper) {
|
||||
this.jdbc = jdbc;
|
||||
this.modelMapper = modelMapper;
|
||||
this.assignmentMapper = assignmentMapper;
|
||||
this.userMapper = userMapper;
|
||||
this.userService = userService;
|
||||
this.keyCipher = keyCipher;
|
||||
this.properties = properties;
|
||||
@@ -70,8 +84,9 @@ public class ModelService implements ApplicationRunner {
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
@SuppressWarnings("unchecked") // MyBatis-Flex 的 select(LambdaGetter<T>...) 使用泛型可变参数,调用本身类型安全。
|
||||
public void run(ApplicationArguments args) {
|
||||
Integer count = jdbc.sql("SELECT count(*) FROM app.model_config").query(Integer.class).single();
|
||||
long count = modelMapper.selectCountByQuery(QueryWrapper.create());
|
||||
if (count > 0 || !Files.isRegularFile(properties.deepseekKeyFile())) {
|
||||
return;
|
||||
}
|
||||
@@ -80,35 +95,35 @@ public class ModelService implements ApplicationRunner {
|
||||
if (key.isBlank()) {
|
||||
return;
|
||||
}
|
||||
UUID adminId = jdbc.sql("SELECT id FROM app.app_user ORDER BY created_at LIMIT 1")
|
||||
.query(UUID.class)
|
||||
.single();
|
||||
QueryWrapper userQuery = QueryWrapper.create()
|
||||
.select(AppUserEntity::getId)
|
||||
.orderBy(AppUserEntity::getCreatedAt).asc()
|
||||
.limit(1);
|
||||
AppUserEntity administrator = userMapper.selectOneByQuery(userQuery);
|
||||
if (administrator == null) {
|
||||
throw new IllegalStateException("初始化默认模型前必须先创建管理员账户");
|
||||
}
|
||||
UUID adminId = administrator.getId();
|
||||
UUID modelId = UUID.randomUUID();
|
||||
jdbc.sql("""
|
||||
INSERT INTO app.model_config(
|
||||
id, name, provider, base_url, model_id, api_key_ciphertext, api_key_hint,
|
||||
key_version, config_json, capabilities_json, is_default, created_by)
|
||||
VALUES (:id, '默认编排模型', 'OPENAI_COMPATIBLE', :baseUrl, :modelId,
|
||||
:ciphertext, :hint, 1, CAST(:config AS jsonb), CAST(:capabilities AS jsonb), TRUE, :userId)
|
||||
""")
|
||||
.param("id", modelId)
|
||||
.param("baseUrl", properties.modelBaseUrl())
|
||||
.param("modelId", properties.modelId())
|
||||
.param("ciphertext", keyCipher.encrypt(key))
|
||||
.param("hint", hint(key))
|
||||
.param("config", "{\"timeoutSeconds\":120,\"reasoningEffort\":\"high\"}")
|
||||
.param("capabilities", json(Map.of(
|
||||
"toolCalling", true,
|
||||
"reasoning", true,
|
||||
"contextWindow", properties.modelContextWindow())))
|
||||
.param("userId", adminId)
|
||||
.update();
|
||||
ModelConfigEntity model = new ModelConfigEntity();
|
||||
model.setId(modelId);
|
||||
model.setName("默认编排模型");
|
||||
model.setProvider("OPENAI_COMPATIBLE");
|
||||
model.setBaseUrl(properties.modelBaseUrl());
|
||||
model.setModelId(properties.modelId());
|
||||
model.setApiKeyCiphertext(keyCipher.encrypt(key));
|
||||
model.setApiKeyHint(hint(key));
|
||||
model.setKeyVersion((short) 1);
|
||||
model.setConfigJson("{\"timeoutSeconds\":120,\"reasoningEffort\":\"high\"}");
|
||||
model.setCapabilitiesJson(json(Map.of(
|
||||
"toolCalling", true,
|
||||
"reasoning", true,
|
||||
"contextWindow", properties.modelContextWindow())));
|
||||
model.setDefaultModel(true);
|
||||
model.setCreatedBy(adminId);
|
||||
modelMapper.insertModel(model);
|
||||
for (String role : List.of("ORCHESTRATION", "WRITING", "REVIEW")) {
|
||||
jdbc.sql("INSERT INTO app.model_assignment(role, model_config_id, assigned_by) VALUES (:role, :id, :userId)")
|
||||
.param("role", role)
|
||||
.param("id", modelId)
|
||||
.param("userId", adminId)
|
||||
.update();
|
||||
upsertAssignment(role, modelId, adminId);
|
||||
}
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("无法读取默认模型 Key", exception);
|
||||
@@ -121,9 +136,12 @@ public class ModelService implements ApplicationRunner {
|
||||
* @return 模型列表
|
||||
*/
|
||||
public List<ModelView> list() {
|
||||
return jdbc.sql(MODEL_SELECT + " ORDER BY is_default DESC, updated_at DESC")
|
||||
.query(ModelService::mapModel)
|
||||
.list();
|
||||
QueryWrapper query = modelViewQuery()
|
||||
.orderBy(ModelConfigEntity::getDefaultModel).desc()
|
||||
.orderBy(ModelConfigEntity::getUpdatedAt).desc();
|
||||
return modelMapper.selectListByQuery(query).stream()
|
||||
.map(ModelService::toModelView)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,56 +161,21 @@ public class ModelService implements ApplicationRunner {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "MODEL_KEY_REQUIRED", "新增模型需要 API Key");
|
||||
}
|
||||
id = UUID.randomUUID();
|
||||
jdbc.sql("""
|
||||
INSERT INTO app.model_config(
|
||||
id, name, provider, base_url, model_id, api_key_ciphertext, api_key_hint,
|
||||
key_version, config_json, capabilities_json, created_by)
|
||||
VALUES (:id, :name, 'OPENAI_COMPATIBLE', :baseUrl, :modelId, :ciphertext,
|
||||
:hint, 1, CAST(:config AS jsonb), CAST(:capabilities AS jsonb), :userId)
|
||||
""")
|
||||
.param("id", id)
|
||||
.param("name", input.name().trim())
|
||||
.param("baseUrl", normalizeBaseUrl(input.baseUrl()))
|
||||
.param("modelId", input.modelId().trim())
|
||||
.param("ciphertext", keyCipher.encrypt(input.apiKey().trim()))
|
||||
.param("hint", hint(input.apiKey().trim()))
|
||||
.param("config", json(input.config()))
|
||||
.param("capabilities", json(input.capabilities()))
|
||||
.param("userId", userId)
|
||||
.update();
|
||||
ModelConfigEntity model = editableModel(id, input);
|
||||
model.setProvider("OPENAI_COMPATIBLE");
|
||||
model.setApiKeyCiphertext(keyCipher.encrypt(input.apiKey().trim()));
|
||||
model.setApiKeyHint(hint(input.apiKey().trim()));
|
||||
model.setKeyVersion((short) 1);
|
||||
model.setCreatedBy(userId);
|
||||
modelMapper.insertModel(model);
|
||||
} else {
|
||||
int updated = input.apiKey() == null || input.apiKey().isBlank()
|
||||
? jdbc.sql("""
|
||||
UPDATE app.model_config
|
||||
SET name = :name, base_url = :baseUrl, model_id = :modelId,
|
||||
config_json = CAST(:config AS jsonb), capabilities_json = CAST(:capabilities AS jsonb),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :id
|
||||
""")
|
||||
.param("name", input.name().trim())
|
||||
.param("baseUrl", normalizeBaseUrl(input.baseUrl()))
|
||||
.param("modelId", input.modelId().trim())
|
||||
.param("config", json(input.config()))
|
||||
.param("capabilities", json(input.capabilities()))
|
||||
.param("id", id)
|
||||
.update()
|
||||
: jdbc.sql("""
|
||||
UPDATE app.model_config
|
||||
SET name = :name, base_url = :baseUrl, model_id = :modelId,
|
||||
api_key_ciphertext = :ciphertext, api_key_hint = :hint, key_version = 1,
|
||||
config_json = CAST(:config AS jsonb), capabilities_json = CAST(:capabilities AS jsonb),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :id
|
||||
""")
|
||||
.param("name", input.name().trim())
|
||||
.param("baseUrl", normalizeBaseUrl(input.baseUrl()))
|
||||
.param("modelId", input.modelId().trim())
|
||||
.param("ciphertext", keyCipher.encrypt(input.apiKey().trim()))
|
||||
.param("hint", hint(input.apiKey().trim()))
|
||||
.param("config", json(input.config()))
|
||||
.param("capabilities", json(input.capabilities()))
|
||||
.param("id", id)
|
||||
.update();
|
||||
ModelConfigEntity model = editableModel(id, input);
|
||||
if (input.apiKey() != null && !input.apiKey().isBlank()) {
|
||||
model.setApiKeyCiphertext(keyCipher.encrypt(input.apiKey().trim()));
|
||||
model.setApiKeyHint(hint(input.apiKey().trim()));
|
||||
model.setKeyVersion((short) 1);
|
||||
}
|
||||
int updated = modelMapper.updateModel(model);
|
||||
if (updated != 1) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在");
|
||||
}
|
||||
@@ -210,23 +193,10 @@ public class ModelService implements ApplicationRunner {
|
||||
public void setDefault(UUID id, Principal principal) {
|
||||
require(id);
|
||||
UUID userId = userService.requireUserId(principal.getName());
|
||||
jdbc.sql("UPDATE app.model_config SET is_default = FALSE WHERE is_default").update();
|
||||
jdbc.sql("UPDATE app.model_config SET is_default = TRUE, updated_at = CURRENT_TIMESTAMP WHERE id = :id")
|
||||
.param("id", id)
|
||||
.update();
|
||||
modelMapper.clearDefault();
|
||||
modelMapper.setDefault(id);
|
||||
for (String role : List.of("ORCHESTRATION", "WRITING", "REVIEW")) {
|
||||
jdbc.sql("""
|
||||
INSERT INTO app.model_assignment(role, model_config_id, assigned_by)
|
||||
VALUES (:role, :id, :userId)
|
||||
ON CONFLICT (role) DO UPDATE
|
||||
SET model_config_id = EXCLUDED.model_config_id,
|
||||
assigned_by = EXCLUDED.assigned_by,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""")
|
||||
.param("role", role)
|
||||
.param("id", id)
|
||||
.param("userId", userId)
|
||||
.update();
|
||||
upsertAssignment(role, id, userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,36 +242,50 @@ public class ModelService implements ApplicationRunner {
|
||||
*
|
||||
* @return 默认模型机密配置
|
||||
*/
|
||||
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
|
||||
public ModelSecret defaultModelSecret() {
|
||||
UUID id = jdbc.sql("SELECT id FROM app.model_config WHERE is_default AND enabled")
|
||||
.query(UUID.class)
|
||||
.optional()
|
||||
.orElseThrow(() -> new ApiException(HttpStatus.CONFLICT, "MODEL_NOT_CONFIGURED", "请先配置可用模型"));
|
||||
return requireSecret(id);
|
||||
QueryWrapper query = QueryWrapper.create()
|
||||
.select(
|
||||
ModelConfigEntity::getId,
|
||||
ModelConfigEntity::getBaseUrl,
|
||||
ModelConfigEntity::getModelId,
|
||||
ModelConfigEntity::getApiKeyCiphertext,
|
||||
ModelConfigEntity::getCapabilitiesJson)
|
||||
.where(ModelConfigEntity::getDefaultModel).eq(true)
|
||||
.and(ModelConfigEntity::getEnabled).eq(true);
|
||||
ModelConfigEntity model = modelMapper.selectOneByQuery(query);
|
||||
if (model == null) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "MODEL_NOT_CONFIGURED", "请先配置可用模型");
|
||||
}
|
||||
return toModelSecret(model);
|
||||
}
|
||||
|
||||
private ModelView require(UUID id) {
|
||||
return jdbc.sql(MODEL_SELECT + " WHERE id = :id")
|
||||
.param("id", id)
|
||||
.query(ModelService::mapModel)
|
||||
.optional()
|
||||
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在"));
|
||||
QueryWrapper query = modelViewQuery()
|
||||
.where(ModelConfigEntity::getId).eq(id);
|
||||
ModelConfigEntity model = modelMapper.selectOneByQuery(query);
|
||||
if (model == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在");
|
||||
}
|
||||
return toModelView(model);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked") // 机密配置查询只投影固定列,LambdaGetter 可变参数不会引入运行期类型风险。
|
||||
private ModelSecret requireSecret(UUID id) {
|
||||
return jdbc.sql("""
|
||||
SELECT id, base_url, model_id, api_key_ciphertext, capabilities_json::text
|
||||
FROM app.model_config WHERE id = :id AND enabled
|
||||
""")
|
||||
.param("id", id)
|
||||
.query((rs, rowNum) -> new ModelSecret(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getString("base_url"),
|
||||
rs.getString("model_id"),
|
||||
keyCipher.decrypt(rs.getBytes("api_key_ciphertext")),
|
||||
contextWindow(parseCapabilities(rs.getString("capabilities_json")))))
|
||||
.optional()
|
||||
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在或已停用"));
|
||||
QueryWrapper query = QueryWrapper.create()
|
||||
.select(
|
||||
ModelConfigEntity::getId,
|
||||
ModelConfigEntity::getBaseUrl,
|
||||
ModelConfigEntity::getModelId,
|
||||
ModelConfigEntity::getApiKeyCiphertext,
|
||||
ModelConfigEntity::getCapabilitiesJson)
|
||||
.where(ModelConfigEntity::getId).eq(id)
|
||||
.and(ModelConfigEntity::getEnabled).eq(true);
|
||||
ModelConfigEntity model = modelMapper.selectOneByQuery(query);
|
||||
if (model == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在或已停用");
|
||||
}
|
||||
return toModelSecret(model);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -339,19 +323,87 @@ public class ModelService implements ApplicationRunner {
|
||||
}
|
||||
}
|
||||
|
||||
private static ModelView mapModel(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
|
||||
/**
|
||||
* 构造新增、更新共用的非敏感模型字段。
|
||||
*
|
||||
* @param id 模型主键
|
||||
* @param input 接口输入
|
||||
* @return 待持久化实体
|
||||
*/
|
||||
private ModelConfigEntity editableModel(UUID id, ModelInput input) {
|
||||
ModelConfigEntity model = new ModelConfigEntity();
|
||||
model.setId(id);
|
||||
model.setName(input.name().trim());
|
||||
model.setBaseUrl(normalizeBaseUrl(input.baseUrl()));
|
||||
model.setModelId(input.modelId().trim());
|
||||
model.setConfigJson(json(input.config()));
|
||||
model.setCapabilitiesJson(json(input.capabilities()));
|
||||
return model;
|
||||
}
|
||||
|
||||
/**
|
||||
* 原子插入或更新一个 Agent 角色的模型分配。
|
||||
*/
|
||||
private void upsertAssignment(String role, UUID modelId, UUID userId) {
|
||||
ModelAssignmentEntity assignment = new ModelAssignmentEntity();
|
||||
assignment.setRole(role);
|
||||
assignment.setModelConfigId(modelId);
|
||||
assignment.setAssignedBy(userId);
|
||||
assignmentMapper.upsert(assignment);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将包含密文的内部实体转换为模型调用所需的最小明文对象。
|
||||
*/
|
||||
private ModelSecret toModelSecret(ModelConfigEntity model) {
|
||||
return new ModelSecret(
|
||||
model.getId(),
|
||||
model.getBaseUrl(),
|
||||
model.getModelId(),
|
||||
keyCipher.decrypt(model.getApiKeyCiphertext()),
|
||||
contextWindow(parseCapabilities(model.getCapabilitiesJson())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将模型实体转换为永不包含密文或明文 Key 的接口视图。
|
||||
*/
|
||||
private static ModelView toModelView(ModelConfigEntity model) {
|
||||
return new ModelView(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getString("name"),
|
||||
rs.getString("provider"),
|
||||
rs.getString("base_url"),
|
||||
rs.getString("model_id"),
|
||||
rs.getString("api_key_hint"),
|
||||
rs.getString("config_json"),
|
||||
rs.getString("capabilities_json"),
|
||||
rs.getBoolean("enabled"),
|
||||
rs.getBoolean("is_default"),
|
||||
rs.getObject("updated_at", OffsetDateTime.class));
|
||||
model.getId(),
|
||||
model.getName(),
|
||||
model.getProvider(),
|
||||
model.getBaseUrl(),
|
||||
model.getModelId(),
|
||||
model.getApiKeyHint(),
|
||||
model.getConfigJson(),
|
||||
model.getCapabilitiesJson(),
|
||||
Boolean.TRUE.equals(model.getEnabled()),
|
||||
Boolean.TRUE.equals(model.getDefaultModel()),
|
||||
model.getUpdatedAt());
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造模型管理页面使用的安全字段投影。
|
||||
*
|
||||
* <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) {
|
||||
@@ -374,12 +426,6 @@ public class ModelService implements ApplicationRunner {
|
||||
return "••••" + key.substring(Math.max(0, key.length() - 4));
|
||||
}
|
||||
|
||||
private static final String MODEL_SELECT = """
|
||||
SELECT id, name, provider, base_url, model_id, api_key_hint, config_json,
|
||||
capabilities_json, enabled, is_default, updated_at
|
||||
FROM app.model_config
|
||||
""";
|
||||
|
||||
/**
|
||||
* 模型编辑输入。
|
||||
*
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package tech.easyflow.manuagent.project;
|
||||
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import tech.easyflow.manuagent.auth.UserService;
|
||||
import tech.easyflow.manuagent.common.ApiException;
|
||||
import tech.easyflow.manuagent.config.AppProperties;
|
||||
import tech.easyflow.manuagent.entity.ProjectFileEntity;
|
||||
import tech.easyflow.manuagent.mapper.ProjectFileMapper;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -24,7 +27,6 @@ import org.apache.tika.Tika;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.UrlResource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -39,7 +41,7 @@ public class ProjectFileService {
|
||||
"pdf", "docx", "xls", "xlsx", "pptx", "csv", "txt", "md",
|
||||
"png", "jpg", "jpeg", "webp", "vsdx", "dwg");
|
||||
|
||||
private final JdbcClient jdbc;
|
||||
private final ProjectFileMapper fileMapper;
|
||||
private final UserService userService;
|
||||
private final ProjectService projectService;
|
||||
private final Path dataRoot;
|
||||
@@ -48,17 +50,17 @@ public class ProjectFileService {
|
||||
/**
|
||||
* 创建材料服务。
|
||||
*
|
||||
* @param jdbc JDBC 客户端
|
||||
* @param fileMapper 项目材料 Mapper
|
||||
* @param userService 用户服务
|
||||
* @param projectService 项目服务
|
||||
* @param properties 应用配置
|
||||
*/
|
||||
public ProjectFileService(
|
||||
JdbcClient jdbc,
|
||||
ProjectFileMapper fileMapper,
|
||||
UserService userService,
|
||||
ProjectService projectService,
|
||||
AppProperties properties) {
|
||||
this.jdbc = jdbc;
|
||||
this.fileMapper = fileMapper;
|
||||
this.userService = userService;
|
||||
this.projectService = projectService;
|
||||
this.dataRoot = properties.dataRoot().toAbsolutePath().normalize();
|
||||
@@ -142,24 +144,19 @@ public class ProjectFileService {
|
||||
Files.move(temporary, target);
|
||||
moved = true;
|
||||
UUID userId = userService.requireUserId(principal.getName());
|
||||
jdbc.sql("""
|
||||
INSERT INTO app.project_file(
|
||||
id, project_id, original_name, stored_name, relative_path, mime_type,
|
||||
extension, size_bytes, sha256, uploaded_by)
|
||||
VALUES (:id, :projectId, :originalName, :storedName, :relativePath, :mimeType,
|
||||
:extension, :sizeBytes, :sha256, :userId)
|
||||
""")
|
||||
.param("id", fileId)
|
||||
.param("projectId", projectId)
|
||||
.param("originalName", originalName)
|
||||
.param("storedName", target.getFileName().toString())
|
||||
.param("relativePath", workspacePath)
|
||||
.param("mimeType", mime)
|
||||
.param("extension", extension)
|
||||
.param("sizeBytes", Files.size(target))
|
||||
.param("sha256", HexFormat.of().formatHex(digest.digest()))
|
||||
.param("userId", userId)
|
||||
.update();
|
||||
// 数据库只保存受控路径和摘要;selective insert 继续使用状态、时间字段的数据库默认值。
|
||||
ProjectFileEntity entity = new ProjectFileEntity();
|
||||
entity.setId(fileId);
|
||||
entity.setProjectId(projectId);
|
||||
entity.setOriginalName(originalName);
|
||||
entity.setStoredName(target.getFileName().toString());
|
||||
entity.setRelativePath(workspacePath);
|
||||
entity.setMimeType(mime);
|
||||
entity.setExtension(extension);
|
||||
entity.setSizeBytes(Files.size(target));
|
||||
entity.setSha256(HexFormat.of().formatHex(digest.digest()));
|
||||
entity.setUploadedBy(userId);
|
||||
fileMapper.insertSelective(entity);
|
||||
return require(fileId);
|
||||
} catch (FileAlreadyExistsException exception) {
|
||||
cleanupFailedUpload(exception, temporary);
|
||||
@@ -197,16 +194,13 @@ public class ProjectFileService {
|
||||
*/
|
||||
public List<FileView> list(UUID projectId) {
|
||||
projectService.require(projectId);
|
||||
return jdbc.sql("""
|
||||
SELECT id, project_id, original_name, relative_path, mime_type, extension,
|
||||
size_bytes, status, created_at
|
||||
FROM app.project_file
|
||||
WHERE project_id = :projectId AND deleted_at IS NULL
|
||||
ORDER BY relative_path
|
||||
""")
|
||||
.param("projectId", projectId)
|
||||
.query(ProjectFileService::mapFile)
|
||||
.list();
|
||||
QueryWrapper query = fileViewQuery()
|
||||
.where(ProjectFileEntity::getProjectId).eq(projectId)
|
||||
.and(ProjectFileEntity::getDeletedAt).isNull()
|
||||
.orderBy(ProjectFileEntity::getRelativePath).asc();
|
||||
return fileMapper.selectListByQuery(query).stream()
|
||||
.map(ProjectFileService::toFileView)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -216,18 +210,22 @@ public class ProjectFileService {
|
||||
* @param fileId 文件 ID
|
||||
* @return 文件资源
|
||||
*/
|
||||
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
|
||||
public Download download(UUID projectId, UUID fileId) {
|
||||
StoredFile stored = jdbc.sql("""
|
||||
SELECT original_name, relative_path, mime_type
|
||||
FROM app.project_file
|
||||
WHERE id = :fileId AND project_id = :projectId AND deleted_at IS NULL AND status = 'READY'
|
||||
""")
|
||||
.param("fileId", fileId)
|
||||
.param("projectId", projectId)
|
||||
.query((rs, rowNum) -> new StoredFile(
|
||||
rs.getString("original_name"), rs.getString("relative_path"), rs.getString("mime_type")))
|
||||
.optional()
|
||||
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "FILE_NOT_FOUND", "文件不存在"));
|
||||
QueryWrapper query = QueryWrapper.create()
|
||||
.select(
|
||||
ProjectFileEntity::getOriginalName,
|
||||
ProjectFileEntity::getRelativePath,
|
||||
ProjectFileEntity::getMimeType)
|
||||
.where(ProjectFileEntity::getId).eq(fileId)
|
||||
.and(ProjectFileEntity::getProjectId).eq(projectId)
|
||||
.and(ProjectFileEntity::getDeletedAt).isNull()
|
||||
.and(ProjectFileEntity::getStatus).eq("READY");
|
||||
ProjectFileEntity entity = fileMapper.selectOneByQuery(query);
|
||||
if (entity == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "FILE_NOT_FOUND", "文件不存在");
|
||||
}
|
||||
StoredFile stored = new StoredFile(entity.getOriginalName(), entity.getRelativePath(), entity.getMimeType());
|
||||
try {
|
||||
Resource resource = new UrlResource(safeProjectPath(projectId, stored.relativePath()).toUri());
|
||||
if (!resource.exists()) {
|
||||
@@ -314,28 +312,54 @@ public class ProjectFileService {
|
||||
}
|
||||
|
||||
private FileView require(UUID fileId) {
|
||||
return jdbc.sql("""
|
||||
SELECT id, project_id, original_name, relative_path, mime_type, extension,
|
||||
size_bytes, status, created_at
|
||||
FROM app.project_file WHERE id = :id
|
||||
""")
|
||||
.param("id", fileId)
|
||||
.query(ProjectFileService::mapFile)
|
||||
.optional()
|
||||
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "FILE_NOT_FOUND", "文件不存在"));
|
||||
ProjectFileEntity entity = fileMapper.selectOneByQuery(
|
||||
fileViewQuery().where(ProjectFileEntity::getId).eq(fileId));
|
||||
if (entity == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "FILE_NOT_FOUND", "文件不存在");
|
||||
}
|
||||
return toFileView(entity);
|
||||
}
|
||||
|
||||
private static FileView mapFile(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
|
||||
/**
|
||||
* 构造项目材料接口视图使用的最小字段投影。
|
||||
*
|
||||
* <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(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getObject("project_id", UUID.class),
|
||||
rs.getString("original_name"),
|
||||
rs.getString("relative_path"),
|
||||
rs.getString("mime_type"),
|
||||
rs.getString("extension"),
|
||||
rs.getLong("size_bytes"),
|
||||
rs.getString("status"),
|
||||
rs.getObject("created_at", OffsetDateTime.class));
|
||||
entity.getId(),
|
||||
entity.getProjectId(),
|
||||
entity.getOriginalName(),
|
||||
entity.getRelativePath(),
|
||||
entity.getMimeType(),
|
||||
entity.getExtension(),
|
||||
entity.getSizeBytes() == null ? 0L : entity.getSizeBytes(),
|
||||
entity.getStatus(),
|
||||
entity.getCreatedAt());
|
||||
}
|
||||
|
||||
private String safeName(String originalName) {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
package tech.easyflow.manuagent.project;
|
||||
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import tech.easyflow.manuagent.auth.UserService;
|
||||
import tech.easyflow.manuagent.common.ApiException;
|
||||
import tech.easyflow.manuagent.entity.ProjectEntity;
|
||||
import tech.easyflow.manuagent.entity.ProjectPlanEntity;
|
||||
import tech.easyflow.manuagent.mapper.ProjectMapper;
|
||||
import tech.easyflow.manuagent.mapper.ProjectPlanMapper;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -10,7 +15,6 @@ import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -20,19 +24,26 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
@Service
|
||||
public class ProjectService {
|
||||
|
||||
private final JdbcClient jdbc;
|
||||
private final ProjectMapper projectMapper;
|
||||
private final ProjectPlanMapper planMapper;
|
||||
private final UserService userService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* 创建项目服务。
|
||||
*
|
||||
* @param jdbc JDBC 客户端
|
||||
* @param projectMapper 项目 Mapper
|
||||
* @param planMapper 规划版本 Mapper
|
||||
* @param userService 用户服务
|
||||
* @param objectMapper JSON 映射器
|
||||
*/
|
||||
public ProjectService(JdbcClient jdbc, UserService userService, ObjectMapper objectMapper) {
|
||||
this.jdbc = jdbc;
|
||||
public ProjectService(
|
||||
ProjectMapper projectMapper,
|
||||
ProjectPlanMapper planMapper,
|
||||
UserService userService,
|
||||
ObjectMapper objectMapper) {
|
||||
this.projectMapper = projectMapper;
|
||||
this.planMapper = planMapper;
|
||||
this.userService = userService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
@@ -51,18 +62,14 @@ public class ProjectService {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID userId = userService.requireUserId(principal.getName());
|
||||
String threadId = "project-" + id;
|
||||
jdbc.sql("""
|
||||
INSERT INTO app.project(
|
||||
id, company_name, project_name, agui_thread_id, application_level, created_by)
|
||||
VALUES (:id, :companyName, :projectName, :threadId, :level, :userId)
|
||||
""")
|
||||
.param("id", id)
|
||||
.param("companyName", companyName.trim())
|
||||
.param("projectName", companyName.trim())
|
||||
.param("threadId", threadId)
|
||||
.param("level", level)
|
||||
.param("userId", userId)
|
||||
.update();
|
||||
ProjectEntity entity = new ProjectEntity();
|
||||
entity.setId(id);
|
||||
entity.setCompanyName(companyName.trim());
|
||||
entity.setProjectName(companyName.trim());
|
||||
entity.setAguiThreadId(threadId);
|
||||
entity.setApplicationLevel(level);
|
||||
entity.setCreatedBy(userId);
|
||||
projectMapper.insertSelective(entity);
|
||||
return require(id);
|
||||
}
|
||||
|
||||
@@ -72,9 +79,10 @@ public class ProjectService {
|
||||
* @return 按更新时间倒序的项目
|
||||
*/
|
||||
public List<ProjectView> list() {
|
||||
return jdbc.sql(PROJECT_SELECT + " ORDER BY updated_at DESC")
|
||||
.query(ProjectService::mapProject)
|
||||
.list();
|
||||
QueryWrapper query = projectViewQuery().orderBy(ProjectEntity::getUpdatedAt).desc();
|
||||
return projectMapper.selectListByQuery(query).stream()
|
||||
.map(ProjectService::toProjectView)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,11 +93,12 @@ public class ProjectService {
|
||||
* @throws ApiException 项目不存在时抛出
|
||||
*/
|
||||
public ProjectView require(UUID projectId) {
|
||||
return jdbc.sql(PROJECT_SELECT + " WHERE id = :id")
|
||||
.param("id", projectId)
|
||||
.query(ProjectService::mapProject)
|
||||
.optional()
|
||||
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在"));
|
||||
ProjectEntity entity = projectMapper.selectOneByQuery(
|
||||
projectViewQuery().where(ProjectEntity::getId).eq(projectId));
|
||||
if (entity == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在");
|
||||
}
|
||||
return toProjectView(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,26 +110,17 @@ public class ProjectService {
|
||||
@Transactional
|
||||
public void delete(UUID projectId) {
|
||||
require(projectId);
|
||||
boolean running = jdbc.sql("""
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM app.agent_run WHERE project_id = :projectId AND status = 'RUNNING'
|
||||
)
|
||||
""")
|
||||
.param("projectId", projectId)
|
||||
.query(Boolean.class)
|
||||
.single();
|
||||
if (running) {
|
||||
if (projectMapper.hasRunningRun(projectId)) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "PROJECT_RUN_ACTIVE", "请先停止正在执行的任务");
|
||||
}
|
||||
|
||||
for (String table : List.of("agent_event", "artifact", "project_plan", "project_file", "agent_run")) {
|
||||
jdbc.sql("DELETE FROM app." + table + " WHERE project_id = :projectId")
|
||||
.param("projectId", projectId)
|
||||
.update();
|
||||
}
|
||||
int deleted = jdbc.sql("DELETE FROM app.project WHERE id = :projectId")
|
||||
.param("projectId", projectId)
|
||||
.update();
|
||||
// 按外键依赖顺序删除,所有语句均受当前 Spring 事务保护。
|
||||
projectMapper.deleteEvents(projectId);
|
||||
projectMapper.deleteArtifacts(projectId);
|
||||
projectMapper.deletePlans(projectId);
|
||||
projectMapper.deleteFiles(projectId);
|
||||
projectMapper.deleteRuns(projectId);
|
||||
int deleted = projectMapper.deleteById(projectId);
|
||||
if (deleted != 1) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在");
|
||||
}
|
||||
@@ -133,14 +133,7 @@ public class ProjectService {
|
||||
* @param status 新阶段
|
||||
*/
|
||||
public void updateStatus(UUID projectId, String status) {
|
||||
int updated = jdbc.sql("""
|
||||
UPDATE app.project
|
||||
SET status = :status, version = version + 1, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :id
|
||||
""")
|
||||
.param("status", status)
|
||||
.param("id", projectId)
|
||||
.update();
|
||||
int updated = projectMapper.updateStatus(projectId, status);
|
||||
if (updated != 1) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在");
|
||||
}
|
||||
@@ -156,23 +149,14 @@ public class ProjectService {
|
||||
*/
|
||||
@Transactional
|
||||
public PlanView saveDraftPlan(UUID projectId, JsonNode plan, UUID userId) {
|
||||
Integer version = jdbc.sql("SELECT COALESCE(MAX(plan_version), 0) + 1 FROM app.project_plan WHERE project_id = :id")
|
||||
.param("id", projectId)
|
||||
.query(Integer.class)
|
||||
.single();
|
||||
UUID planId = UUID.randomUUID();
|
||||
jdbc.sql("""
|
||||
INSERT INTO app.project_plan(id, project_id, plan_version, status, plan_json, created_by)
|
||||
VALUES (:id, :projectId, :version, 'DRAFT', CAST(:plan AS jsonb), :userId)
|
||||
""")
|
||||
.param("id", planId)
|
||||
.param("projectId", projectId)
|
||||
.param("version", version)
|
||||
.param("plan", plan.toString())
|
||||
.param("userId", userId)
|
||||
.update();
|
||||
ProjectPlanEntity draft = new ProjectPlanEntity();
|
||||
draft.setId(UUID.randomUUID());
|
||||
draft.setProjectId(projectId);
|
||||
draft.setPlanJson(plan.toString());
|
||||
draft.setCreatedBy(userId);
|
||||
ProjectPlanEntity stored = planMapper.insertNextDraft(draft);
|
||||
updateStatus(projectId, "PLANNING");
|
||||
return requirePlan(planId);
|
||||
return toPlanView(stored);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,17 +166,8 @@ public class ProjectService {
|
||||
* @return 最新规划;不存在时返回空
|
||||
*/
|
||||
public PlanView currentPlan(UUID projectId) {
|
||||
return jdbc.sql("""
|
||||
SELECT id, project_id, plan_version, status, plan_json, confirmed_at, created_at
|
||||
FROM app.project_plan
|
||||
WHERE project_id = :projectId
|
||||
ORDER BY CASE status WHEN 'CONFIRMED' THEN 0 ELSE 1 END, plan_version DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
.param("projectId", projectId)
|
||||
.query(this::mapPlan)
|
||||
.optional()
|
||||
.orElse(null);
|
||||
ProjectPlanEntity entity = planMapper.selectCurrent(projectId);
|
||||
return entity == null ? null : toPlanView(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -207,61 +182,65 @@ public class ProjectService {
|
||||
@Transactional
|
||||
public PlanView confirmPlan(UUID projectId, UUID planId, JsonNode plan, Principal principal) {
|
||||
UUID userId = userService.requireUserId(principal.getName());
|
||||
int updated = jdbc.sql("""
|
||||
UPDATE app.project_plan
|
||||
SET status = 'CONFIRMED', plan_json = CAST(:plan AS jsonb), confirmed_by = :userId,
|
||||
confirmed_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :planId AND project_id = :projectId AND status = 'DRAFT'
|
||||
""")
|
||||
.param("plan", plan.toString())
|
||||
.param("userId", userId)
|
||||
.param("planId", planId)
|
||||
.param("projectId", projectId)
|
||||
.update();
|
||||
if (updated != 1) {
|
||||
ProjectPlanEntity confirmed = planMapper.confirmDraft(projectId, planId, plan.toString(), userId);
|
||||
if (confirmed == null) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "PLAN_ALREADY_CONFIRMED", "规划已确认或版本不存在");
|
||||
}
|
||||
updateStatus(projectId, "WRITING");
|
||||
return requirePlan(planId);
|
||||
return toPlanView(confirmed);
|
||||
}
|
||||
|
||||
private PlanView requirePlan(UUID planId) {
|
||||
return jdbc.sql("""
|
||||
SELECT id, project_id, plan_version, status, plan_json, confirmed_at, created_at
|
||||
FROM app.project_plan WHERE id = :id
|
||||
""")
|
||||
.param("id", planId)
|
||||
.query(this::mapPlan)
|
||||
.optional()
|
||||
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "PLAN_NOT_FOUND", "规划不存在"));
|
||||
}
|
||||
|
||||
private PlanView mapPlan(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
|
||||
/** 将规划实体解析成包含 JsonNode 的接口视图。 */
|
||||
private PlanView toPlanView(ProjectPlanEntity entity) {
|
||||
try {
|
||||
return new PlanView(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getObject("project_id", UUID.class),
|
||||
rs.getInt("plan_version"),
|
||||
rs.getString("status"),
|
||||
objectMapper.readTree(rs.getString("plan_json")),
|
||||
rs.getObject("confirmed_at", OffsetDateTime.class),
|
||||
rs.getObject("created_at", OffsetDateTime.class));
|
||||
entity.getId(),
|
||||
entity.getProjectId(),
|
||||
entity.getPlanVersion() == null ? 0 : entity.getPlanVersion(),
|
||||
entity.getStatus(),
|
||||
objectMapper.readTree(entity.getPlanJson()),
|
||||
entity.getConfirmedAt(),
|
||||
entity.getCreatedAt());
|
||||
} catch (JsonProcessingException exception) {
|
||||
throw new java.sql.SQLException("规划 JSON 无法解析", exception);
|
||||
// 迁移前 ResultSet 映射会将损坏的存量 JSON 作为未预期数据库读取异常处理,不新增业务错误码。
|
||||
throw new IllegalStateException("规划 JSON 无法解析", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static ProjectView mapProject(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
|
||||
/** 将项目实体转换为接口视图。 */
|
||||
private static ProjectView toProjectView(ProjectEntity entity) {
|
||||
return new ProjectView(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getString("company_name"),
|
||||
rs.getString("project_name"),
|
||||
rs.getString("agui_thread_id"),
|
||||
rs.getString("application_level"),
|
||||
rs.getString("status"),
|
||||
rs.getLong("version"),
|
||||
rs.getObject("created_at", OffsetDateTime.class),
|
||||
rs.getObject("updated_at", OffsetDateTime.class));
|
||||
entity.getId(),
|
||||
entity.getCompanyName(),
|
||||
entity.getProjectName(),
|
||||
entity.getAguiThreadId(),
|
||||
entity.getApplicationLevel(),
|
||||
entity.getStatus(),
|
||||
entity.getVersion() == null ? 0L : entity.getVersion(),
|
||||
entity.getCreatedAt(),
|
||||
entity.getUpdatedAt());
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造项目接口列表与详情共用的最小字段投影。
|
||||
*
|
||||
* <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) {
|
||||
@@ -272,12 +251,6 @@ public class ProjectService {
|
||||
return value;
|
||||
}
|
||||
|
||||
private static final String PROJECT_SELECT = """
|
||||
SELECT id, company_name, project_name, agui_thread_id, application_level, status,
|
||||
version, created_at, updated_at
|
||||
FROM app.project
|
||||
""";
|
||||
|
||||
/**
|
||||
* 项目视图。
|
||||
*
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package tech.easyflow.manuagent.skill;
|
||||
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import tech.easyflow.manuagent.auth.UserService;
|
||||
import tech.easyflow.manuagent.common.ApiException;
|
||||
import tech.easyflow.manuagent.config.AppProperties;
|
||||
import tech.easyflow.manuagent.entity.SkillConfigEntity;
|
||||
import tech.easyflow.manuagent.mapper.SkillConfigMapper;
|
||||
import tech.easyflow.manuagent.mapper.SkillViewRow;
|
||||
import io.agentscope.core.skill.AgentSkill;
|
||||
import io.agentscope.core.skill.repository.postgresql.PostgresSkillRepository;
|
||||
import java.io.IOException;
|
||||
@@ -18,7 +22,6 @@ import java.util.UUID;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -32,7 +35,7 @@ public class SkillService {
|
||||
private static final int MAX_ZIP_ENTRIES = 500;
|
||||
private static final long MAX_UNCOMPRESSED_BYTES = 20L * 1024 * 1024;
|
||||
|
||||
private final JdbcClient jdbc;
|
||||
private final SkillConfigMapper skillMapper;
|
||||
private final PostgresSkillRepository repository;
|
||||
private final SkillPackageReader packageReader;
|
||||
private final UserService userService;
|
||||
@@ -41,19 +44,19 @@ public class SkillService {
|
||||
/**
|
||||
* 创建 Skill 服务。
|
||||
*
|
||||
* @param jdbc JDBC 客户端
|
||||
* @param skillMapper 应用 Skill 配置 Mapper
|
||||
* @param repository AgentScope PostgreSQL 仓库
|
||||
* @param packageReader Skill 包读取器
|
||||
* @param userService 用户服务
|
||||
* @param properties 应用配置
|
||||
*/
|
||||
public SkillService(
|
||||
JdbcClient jdbc,
|
||||
SkillConfigMapper skillMapper,
|
||||
PostgresSkillRepository repository,
|
||||
SkillPackageReader packageReader,
|
||||
UserService userService,
|
||||
AppProperties properties) {
|
||||
this.jdbc = jdbc;
|
||||
this.skillMapper = skillMapper;
|
||||
this.repository = repository;
|
||||
this.packageReader = packageReader;
|
||||
this.userService = userService;
|
||||
@@ -66,9 +69,9 @@ public class SkillService {
|
||||
* @return Skill 列表
|
||||
*/
|
||||
public List<SkillView> list() {
|
||||
return jdbc.sql(SKILL_SELECT + " ORDER BY c.source_type, s.name")
|
||||
.query(SkillService::mapSkill)
|
||||
.list();
|
||||
return skillMapper.selectViews().stream()
|
||||
.map(SkillService::toSkillView)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,11 +81,11 @@ public class SkillService {
|
||||
* @return Skill 详情
|
||||
*/
|
||||
public SkillDetail require(String name) {
|
||||
SkillView view = jdbc.sql(SKILL_SELECT + " WHERE s.name = :name")
|
||||
.param("name", name)
|
||||
.query(SkillService::mapSkill)
|
||||
.optional()
|
||||
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在"));
|
||||
SkillViewRow row = skillMapper.selectView(name);
|
||||
if (row == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在");
|
||||
}
|
||||
SkillView view = toSkillView(row);
|
||||
AgentSkill skill = repository.getSkill(name);
|
||||
if (skill == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 内容不存在");
|
||||
@@ -116,13 +119,7 @@ public class SkillService {
|
||||
* @param enabled 是否启用
|
||||
*/
|
||||
public void setEnabled(String name, boolean enabled) {
|
||||
int updated = jdbc.sql("""
|
||||
UPDATE app.skill_config SET enabled = :enabled, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE skill_name = :name AND validation_status = 'VALID'
|
||||
""")
|
||||
.param("enabled", enabled)
|
||||
.param("name", name)
|
||||
.update();
|
||||
int updated = skillMapper.updateEnabled(name, enabled);
|
||||
if (updated != 1) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在或校验未通过");
|
||||
}
|
||||
@@ -134,14 +131,15 @@ public class SkillService {
|
||||
*
|
||||
* @return Skill 名称数组
|
||||
*/
|
||||
@SuppressWarnings("unchecked") // MyBatis-Flex 的 select(LambdaGetter<T>...) 使用泛型可变参数,调用本身类型安全。
|
||||
public String[] enabledNames() {
|
||||
return jdbc.sql("""
|
||||
SELECT skill_name FROM app.skill_config
|
||||
WHERE enabled AND validation_status = 'VALID'
|
||||
ORDER BY skill_name
|
||||
""")
|
||||
.query(String.class)
|
||||
.list()
|
||||
QueryWrapper query = QueryWrapper.create()
|
||||
.select(SkillConfigEntity::getSkillName)
|
||||
.where(SkillConfigEntity::getEnabled).eq(true)
|
||||
.and(SkillConfigEntity::getValidationStatus).eq("VALID")
|
||||
.orderBy(SkillConfigEntity::getSkillName).asc();
|
||||
return skillMapper.selectListByQuery(query).stream()
|
||||
.map(SkillConfigEntity::getSkillName)
|
||||
.toArray(String[]::new);
|
||||
}
|
||||
|
||||
@@ -171,17 +169,17 @@ public class SkillService {
|
||||
}
|
||||
repository.save(List.of(skillPackage.skill()), false);
|
||||
UUID userId = userService.requireUserId(principal.getName());
|
||||
jdbc.sql("""
|
||||
INSERT INTO app.skill_config(
|
||||
skill_name, version, source_type, enabled, read_only, checksum,
|
||||
validation_status, imported_by)
|
||||
VALUES (:name, :version, 'IMPORTED', FALSE, TRUE, :checksum, 'VALID', :userId)
|
||||
""")
|
||||
.param("name", name)
|
||||
.param("version", skillPackage.version())
|
||||
.param("checksum", skillPackage.checksum())
|
||||
.param("userId", userId)
|
||||
.update();
|
||||
SkillConfigEntity config = new SkillConfigEntity();
|
||||
config.setSkillName(name);
|
||||
config.setVersion(skillPackage.version());
|
||||
config.setSourceType("IMPORTED");
|
||||
config.setEnabled(false);
|
||||
config.setReadOnly(true);
|
||||
config.setChecksum(skillPackage.checksum());
|
||||
config.setValidationStatus("VALID");
|
||||
config.setImportedBy(userId);
|
||||
// Skill 名称由上传包提供,因此显式使用 WithPk 插入字符串主键。
|
||||
skillMapper.insertSelectiveWithPk(config);
|
||||
return require(name).view();
|
||||
} finally {
|
||||
deleteTree(temporary);
|
||||
@@ -197,13 +195,16 @@ public class SkillService {
|
||||
* @param name Skill 名称
|
||||
*/
|
||||
@Transactional
|
||||
@SuppressWarnings("unchecked") // MyBatis-Flex 的 select(LambdaGetter<T>...) 使用泛型可变参数,调用本身类型安全。
|
||||
public void deleteImported(String name) {
|
||||
String sourceType = jdbc.sql("SELECT source_type FROM app.skill_config WHERE skill_name = :name")
|
||||
.param("name", name)
|
||||
.query(String.class)
|
||||
.optional()
|
||||
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在"));
|
||||
if (!"IMPORTED".equals(sourceType)) {
|
||||
QueryWrapper query = QueryWrapper.create()
|
||||
.select(SkillConfigEntity::getSourceType)
|
||||
.where(SkillConfigEntity::getSkillName).eq(name);
|
||||
SkillConfigEntity config = skillMapper.selectOneByQuery(query);
|
||||
if (config == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在");
|
||||
}
|
||||
if (!"IMPORTED".equals(config.getSourceType())) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "BUILTIN_SKILL_READ_ONLY", "内置 Skill 不能删除");
|
||||
}
|
||||
repository.delete(name);
|
||||
@@ -311,26 +312,22 @@ public class SkillService {
|
||||
}
|
||||
}
|
||||
|
||||
private static SkillView mapSkill(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
|
||||
/**
|
||||
* 将跨 schema 的只读查询行转换成对外 Skill 视图。
|
||||
*/
|
||||
private static SkillView toSkillView(SkillViewRow row) {
|
||||
return new SkillView(
|
||||
rs.getString("name"),
|
||||
rs.getString("description"),
|
||||
rs.getString("version"),
|
||||
rs.getString("source_type"),
|
||||
rs.getBoolean("enabled"),
|
||||
rs.getBoolean("read_only"),
|
||||
rs.getString("validation_status"),
|
||||
rs.getString("validation_message"),
|
||||
rs.getObject("updated_at", OffsetDateTime.class));
|
||||
row.getName(),
|
||||
row.getDescription(),
|
||||
row.getVersion(),
|
||||
row.getSourceType(),
|
||||
Boolean.TRUE.equals(row.getEnabled()),
|
||||
Boolean.TRUE.equals(row.getReadOnly()),
|
||||
row.getValidationStatus(),
|
||||
row.getValidationMessage(),
|
||||
row.getUpdatedAt());
|
||||
}
|
||||
|
||||
private static final String SKILL_SELECT = """
|
||||
SELECT s.name, s.description, c.version, c.source_type, c.enabled, c.read_only,
|
||||
c.validation_status, c.validation_message, c.updated_at
|
||||
FROM agentscope.agentscope_skills s
|
||||
JOIN app.skill_config c ON c.skill_name = s.name
|
||||
""";
|
||||
|
||||
/**
|
||||
* Skill 列表视图。
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user