753 lines
39 KiB
Java
753 lines
39 KiB
Java
package tech.easyflow.manuagent;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||
import static org.mockito.Mockito.mock;
|
||
import static org.mockito.Mockito.when;
|
||
|
||
import tech.easyflow.manuagent.agent.AgentEventService;
|
||
import tech.easyflow.manuagent.agent.AgentRunService;
|
||
import tech.easyflow.manuagent.agent.AgentRunStore;
|
||
import tech.easyflow.manuagent.artifact.ArtifactService;
|
||
import tech.easyflow.manuagent.artifact.DocxValidator;
|
||
import tech.easyflow.manuagent.auth.UserService;
|
||
import tech.easyflow.manuagent.entity.AgentEventEntity;
|
||
import tech.easyflow.manuagent.entity.AppUserEntity;
|
||
import tech.easyflow.manuagent.mapper.AgentEventMapper;
|
||
import tech.easyflow.manuagent.mapper.AgentRunMapper;
|
||
import tech.easyflow.manuagent.mapper.AppUserMapper;
|
||
import tech.easyflow.manuagent.mapper.ArtifactMapper;
|
||
import tech.easyflow.manuagent.mapper.ProjectMapper;
|
||
import tech.easyflow.manuagent.mapper.ProjectPlanMapper;
|
||
import tech.easyflow.manuagent.mapper.ModelAssignmentMapper;
|
||
import tech.easyflow.manuagent.mapper.ModelConfigMapper;
|
||
import tech.easyflow.manuagent.mapper.SkillConfigMapper;
|
||
import tech.easyflow.manuagent.model.KeyCipher;
|
||
import tech.easyflow.manuagent.model.ModelService;
|
||
import tech.easyflow.manuagent.config.AppProperties;
|
||
import tech.easyflow.manuagent.skill.SkillPackageReader;
|
||
import tech.easyflow.manuagent.skill.SkillService;
|
||
import io.agentscope.core.skill.repository.postgresql.PostgresSkillRepository;
|
||
import tech.easyflow.manuagent.typehandler.JsonbStringTypeHandler;
|
||
import tech.easyflow.manuagent.typehandler.UuidTypeHandler;
|
||
import tech.easyflow.manuagent.project.ProjectService;
|
||
import tech.easyflow.manuagent.project.ProjectFileService;
|
||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||
import com.mybatisflex.core.MybatisFlexBootstrap;
|
||
import com.mybatisflex.core.datasource.FlexDataSource;
|
||
import com.mybatisflex.core.mybatis.FlexConfiguration;
|
||
import com.mybatisflex.core.query.QueryWrapper;
|
||
import com.mybatisflex.core.table.TableInfo;
|
||
import com.mybatisflex.core.table.TableInfoFactory;
|
||
import java.nio.file.Files;
|
||
import java.nio.file.Path;
|
||
import java.io.InputStream;
|
||
import java.sql.DriverManager;
|
||
import java.util.List;
|
||
import java.util.Map;
|
||
import java.util.UUID;
|
||
import java.util.concurrent.TimeUnit;
|
||
import java.time.Duration;
|
||
import org.flywaydb.core.Flyway;
|
||
import org.apache.ibatis.builder.xml.XMLMapperBuilder;
|
||
import org.apache.ibatis.io.Resources;
|
||
import org.apache.ibatis.mapping.Environment;
|
||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||
import org.junit.jupiter.api.BeforeAll;
|
||
import org.junit.jupiter.api.Test;
|
||
import org.junit.jupiter.api.io.TempDir;
|
||
import org.postgresql.ds.PGSimpleDataSource;
|
||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||
import org.testcontainers.containers.PostgreSQLContainer;
|
||
import org.testcontainers.junit.jupiter.Container;
|
||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||
|
||
/**
|
||
* 验证 PostgreSQL 17 全量迁移及事件游标回放。
|
||
*/
|
||
@Testcontainers
|
||
class DatabaseAndEventIntegrationTest {
|
||
|
||
@Container
|
||
private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:17-alpine");
|
||
|
||
@TempDir
|
||
private Path temporaryDirectory;
|
||
|
||
/**
|
||
* 在干净 PostgreSQL 17 实例执行并校验全部 Flyway 迁移。
|
||
*/
|
||
@BeforeAll
|
||
static void migrate() {
|
||
Flyway flyway = Flyway.configure()
|
||
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
|
||
.locations("classpath:db/migration")
|
||
.load();
|
||
flyway.migrate();
|
||
flyway.validate();
|
||
assertThat(flyway.migrate().migrationsExecuted).isZero();
|
||
}
|
||
|
||
/**
|
||
* 验证核心表、索引和约束已建立。
|
||
*
|
||
* @throws Exception 数据库访问失败时抛出
|
||
*/
|
||
@Test
|
||
void shouldCreateCoreSchemaOnPostgres17() throws Exception {
|
||
try (var connection = DriverManager.getConnection(
|
||
POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword());
|
||
var statement = connection.createStatement();
|
||
var result = statement.executeQuery("""
|
||
SELECT count(*) FROM information_schema.tables
|
||
WHERE table_schema IN ('app', 'agentscope')
|
||
""")) {
|
||
assertThat(result.next()).isTrue();
|
||
assertThat(result.getInt(1)).isEqualTo(12);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 验证事件按项目全局 ID 增量回放且不重复。
|
||
*/
|
||
@Test
|
||
void shouldReplayEventsAfterCursorInOrder() throws Exception {
|
||
JdbcClient jdbc = jdbc();
|
||
UUID userId = UUID.randomUUID();
|
||
UUID modelId = UUID.randomUUID();
|
||
UUID projectId = UUID.randomUUID();
|
||
UUID runId = UUID.randomUUID();
|
||
jdbc.sql("INSERT INTO app.app_user(id, username, password_hash, display_name) VALUES (:id, :name, 'x', 'test')")
|
||
.param("id", userId).param("name", "u-" + userId).update();
|
||
jdbc.sql("""
|
||
INSERT INTO app.model_config(id, name, provider, base_url, model_id, is_default)
|
||
VALUES (:id, :name, 'OPENAI_COMPATIBLE', 'https://example.test', 'model', FALSE)
|
||
""").param("id", modelId).param("name", "m-" + modelId).update();
|
||
jdbc.sql("""
|
||
INSERT INTO app.project(id, company_name, project_name, agui_thread_id, application_level, created_by)
|
||
VALUES (:id, '企业', '项目', :thread, 'ADVANCED', :userId)
|
||
""").param("id", projectId).param("thread", "t-" + projectId).param("userId", userId).update();
|
||
jdbc.sql("""
|
||
INSERT INTO app.agent_run(id, project_id, model_config_id, trigger_type, status, trace_id)
|
||
VALUES (:id, :projectId, :modelId, 'INITIAL', 'RUNNING', :trace)
|
||
""").param("id", runId).param("projectId", projectId).param("modelId", modelId)
|
||
.param("trace", UUID.randomUUID().toString()).update();
|
||
|
||
AgentEventService service = new AgentEventService(agentEventMapper(), new ObjectMapper());
|
||
long first = service.append(projectId, runId, "RUN_STARTED", Map.of("phase", "MATERIAL_CHECK")).id();
|
||
long second = service.append(projectId, runId, "TEXT_MESSAGE_CONTENT", Map.of("delta", "分析")).id();
|
||
long third = service.append(projectId, runId, "TEXT_MESSAGE_CONTENT", Map.of("delta", "完成")).id();
|
||
|
||
assertThat(service.listAfter(projectId, first, 100))
|
||
.extracting(AgentEventService.EventView::id)
|
||
.containsExactly(second, third);
|
||
|
||
var next = service.streamAfter(projectId, third)
|
||
.filter(event -> !"HEARTBEAT".equals(event.type()))
|
||
.next()
|
||
.toFuture();
|
||
long pushed = service.append(projectId, runId, "TEXT_MESSAGE_CONTENT", Map.of("delta", "推送")).id();
|
||
assertThat(next.orTimeout(2, TimeUnit.SECONDS).join().id()).isEqualTo(pushed);
|
||
}
|
||
|
||
/**
|
||
* 验证重试生成同一路径产物时更新登记信息,避免唯一约束导致成功 Run 被标记失败。
|
||
*
|
||
* @throws Exception 临时文件写入失败时抛出
|
||
*/
|
||
@Test
|
||
void shouldReplaceArtifactMetadataForSameProjectPath() throws Exception {
|
||
JdbcClient jdbc = jdbc();
|
||
UUID userId = UUID.randomUUID();
|
||
UUID modelId = UUID.randomUUID();
|
||
UUID projectId = UUID.randomUUID();
|
||
UUID firstRunId = UUID.randomUUID();
|
||
UUID secondRunId = UUID.randomUUID();
|
||
jdbc.sql("INSERT INTO app.app_user(id, username, password_hash, display_name) VALUES (:id, :name, 'x', 'test')")
|
||
.param("id", userId).param("name", "u-" + userId).update();
|
||
jdbc.sql("""
|
||
INSERT INTO app.model_config(id, name, provider, base_url, model_id)
|
||
VALUES (:id, :name, 'OPENAI_COMPATIBLE', 'https://example.test', 'model')
|
||
""").param("id", modelId).param("name", "m-" + modelId).update();
|
||
jdbc.sql("""
|
||
INSERT INTO app.project(id, company_name, project_name, agui_thread_id, application_level, created_by)
|
||
VALUES (:id, '企业', '项目', :thread, 'ADVANCED', :userId)
|
||
""").param("id", projectId).param("thread", "t-" + projectId).param("userId", userId).update();
|
||
for (UUID runId : List.of(firstRunId, secondRunId)) {
|
||
jdbc.sql("""
|
||
INSERT INTO app.agent_run(
|
||
id, project_id, model_config_id, trigger_type, status, trace_id, ended_at)
|
||
VALUES (:id, :projectId, :modelId, 'RETRY', 'COMPLETED', :trace, CURRENT_TIMESTAMP)
|
||
""").param("id", runId).param("projectId", projectId).param("modelId", modelId)
|
||
.param("trace", UUID.randomUUID().toString()).update();
|
||
}
|
||
|
||
Path document = temporaryDirectory.resolve("draft.docx");
|
||
Files.writeString(document, "first");
|
||
ProjectFileService files = mock(ProjectFileService.class);
|
||
when(files.safeProjectPath(projectId, "artifacts/draft.docx")).thenReturn(document);
|
||
ArtifactService artifacts = new ArtifactService(artifactMapper(), files, new DocxValidator());
|
||
ObjectMapper mapper = new ObjectMapper();
|
||
|
||
ArtifactService.ArtifactView first = artifacts.publish(
|
||
projectId, firstRunId, "DOCX", "draft.docx", "artifacts/draft.docx",
|
||
mapper.createObjectNode().put("version", 1));
|
||
Files.writeString(document, "second version");
|
||
ArtifactService.ArtifactView second = artifacts.publish(
|
||
projectId, secondRunId, "DOCX", "draft.docx", "artifacts/draft.docx",
|
||
mapper.createObjectNode().put("version", 2));
|
||
|
||
assertThat(second.id()).isEqualTo(first.id());
|
||
assertThat(second.runId()).isEqualTo(secondRunId);
|
||
assertThat(second.sizeBytes()).isEqualTo(Files.size(document));
|
||
assertThat(artifacts.list(projectId)).hasSize(1);
|
||
}
|
||
|
||
/**
|
||
* 验证项目真删除会清除所有关联业务记录。
|
||
*/
|
||
@Test
|
||
void shouldDeleteProjectRecords() throws Exception {
|
||
JdbcClient jdbc = jdbc();
|
||
UUID userId = UUID.randomUUID();
|
||
UUID projectId = UUID.randomUUID();
|
||
UUID runId = UUID.randomUUID();
|
||
jdbc.sql("INSERT INTO app.app_user(id, username, password_hash, display_name) VALUES (:id, :name, 'x', 'test')")
|
||
.param("id", userId).param("name", "u-" + userId).update();
|
||
jdbc.sql("""
|
||
INSERT INTO app.project(id, company_name, project_name, agui_thread_id, application_level, created_by)
|
||
VALUES (:id, '待删除企业', '待删除项目', :thread, 'ADVANCED', :userId)
|
||
""").param("id", projectId).param("thread", "t-" + projectId).param("userId", userId).update();
|
||
jdbc.sql("""
|
||
INSERT INTO app.agent_run(id, project_id, trigger_type, status, trace_id, ended_at)
|
||
VALUES (:id, :projectId, 'INITIAL', 'COMPLETED', :trace, CURRENT_TIMESTAMP)
|
||
""").param("id", runId).param("projectId", projectId)
|
||
.param("trace", UUID.randomUUID().toString()).update();
|
||
jdbc.sql("""
|
||
INSERT INTO app.agent_event(project_id, run_id, event_type, payload)
|
||
VALUES (:projectId, :runId, 'RUN_FINISHED', '{}'::jsonb)
|
||
""").param("projectId", projectId).param("runId", runId).update();
|
||
jdbc.sql("""
|
||
INSERT INTO app.project_plan(id, project_id, plan_version, status, plan_json, created_by)
|
||
VALUES (:id, :projectId, 1, 'DRAFT', '{}'::jsonb, :userId)
|
||
""").param("id", UUID.randomUUID()).param("projectId", projectId).param("userId", userId).update();
|
||
jdbc.sql("""
|
||
INSERT INTO app.project_file(
|
||
id, project_id, original_name, stored_name, relative_path, mime_type,
|
||
extension, size_bytes, sha256, uploaded_by)
|
||
VALUES (:id, :projectId, 'input.txt', 'input.txt', 'inputs/input.txt',
|
||
'text/plain', 'txt', 1, :sha, :userId)
|
||
""").param("id", UUID.randomUUID()).param("projectId", projectId)
|
||
.param("sha", "0".repeat(64)).param("userId", userId).update();
|
||
jdbc.sql("""
|
||
INSERT INTO app.artifact(
|
||
id, project_id, run_id, kind, name, relative_path, mime_type, size_bytes, sha256)
|
||
VALUES (:id, :projectId, :runId, 'OTHER', 'result.txt', 'artifacts/result.txt',
|
||
'text/plain', 1, :sha)
|
||
""").param("id", UUID.randomUUID()).param("projectId", projectId).param("runId", runId)
|
||
.param("sha", "0".repeat(64)).update();
|
||
|
||
ProjectService service = new ProjectService(
|
||
projectMapper(), mock(ProjectPlanMapper.class), mock(UserService.class), new ObjectMapper());
|
||
service.delete(projectId);
|
||
|
||
for (String table : List.of("agent_event", "artifact", "project_plan", "project_file", "agent_run")) {
|
||
Long count = jdbc.sql("SELECT COUNT(*) FROM app." + table + " WHERE project_id = :projectId")
|
||
.param("projectId", projectId)
|
||
.query(Long.class)
|
||
.single();
|
||
assertThat(count).as(table).isZero();
|
||
}
|
||
assertThat(jdbc.sql("SELECT COUNT(*) FROM app.project WHERE id = :projectId")
|
||
.param("projectId", projectId)
|
||
.query(Long.class)
|
||
.single()).isZero();
|
||
}
|
||
|
||
/**
|
||
* 验证 Run 创建、等待确认、完成和中断均遵守数据库状态机条件。
|
||
*
|
||
* <p>该测试直接覆盖 MyBatis-Flex BaseMapper 插入、XML 状态更新和实体结果映射,
|
||
* 防止迁移后出现 UUID 主键未写入、JSONB Ask 丢失或终态被重复覆盖。</p>
|
||
*/
|
||
@Test
|
||
void shouldPersistAndTransitionAgentRunWithMybatisFlex() throws Exception {
|
||
JdbcClient jdbc = jdbc();
|
||
UUID userId = UUID.randomUUID();
|
||
UUID modelId = UUID.randomUUID();
|
||
UUID projectId = UUID.randomUUID();
|
||
jdbc.sql("INSERT INTO app.app_user(id, username, password_hash, display_name) VALUES (:id, :name, 'x', 'test')")
|
||
.param("id", userId).param("name", "run-u-" + userId).update();
|
||
// 测试类共用同一容器;先释放其他用例留下的唯一默认模型,再建立本用例的确定性前置条件。
|
||
jdbc.sql("UPDATE app.model_config SET is_default = FALSE WHERE is_default").update();
|
||
jdbc.sql("""
|
||
INSERT INTO app.model_config(id, name, provider, base_url, model_id, is_default)
|
||
VALUES (:id, :name, 'OPENAI_COMPATIBLE', 'https://example.test', 'model', TRUE)
|
||
""").param("id", modelId).param("name", "run-m-" + modelId).update();
|
||
jdbc.sql("""
|
||
INSERT INTO app.project(id, company_name, project_name, agui_thread_id, application_level, created_by)
|
||
VALUES (:id, '企业', 'Run 迁移测试', :threadId, 'ADVANCED', :userId)
|
||
""").param("id", projectId).param("threadId", "run-thread-" + projectId)
|
||
.param("userId", userId).update();
|
||
|
||
AgentRunMapper mapper = agentRunMapper();
|
||
AgentEventMapper events = agentEventMapper();
|
||
AgentRunStore store = new AgentRunStore(mapper, events, modelConfigMapper(), new ObjectMapper());
|
||
AgentRunService.RunView initial = store.create(projectId, "INITIAL", null);
|
||
assertThat(initial.status()).isEqualTo("RUNNING");
|
||
assertThat(store.latest(projectId).id()).isEqualTo(initial.id());
|
||
store.ensureRunning(initial.id());
|
||
|
||
String interrupt = "{\"kind\":\"material_check\",\"items\":[]}";
|
||
assertThat(mapper.waitForInput(initial.id(), interrupt)).isEqualTo(1);
|
||
assertThat(new ObjectMapper().readTree(
|
||
store.requireWaiting(projectId, "material_check").pendingInterrupt()))
|
||
.isEqualTo(new ObjectMapper().readTree(interrupt));
|
||
store.completeWaiting(initial.id());
|
||
assertThat(store.require(initial.id()).status()).isEqualTo("COMPLETED");
|
||
|
||
AgentRunService.RunView resumed = store.create(projectId, "RESUME", initial.id());
|
||
AgentEventEntity started = new AgentEventEntity();
|
||
started.setProjectId(projectId);
|
||
started.setRunId(resumed.id());
|
||
started.setEventType("RUN_STARTED");
|
||
started.setEventId(UUID.randomUUID().toString());
|
||
started.setPayloadJson("{\"phase\":\"PLANNING\"}");
|
||
events.insertReturning(started);
|
||
AgentEventEntity response = new AgentEventEntity();
|
||
response.setProjectId(projectId);
|
||
response.setRunId(resumed.id());
|
||
response.setEventType("ASK_RESPONDED");
|
||
response.setEventId(UUID.randomUUID().toString());
|
||
response.setPayloadJson("{\"decisions\":[]}");
|
||
events.insertReturning(response);
|
||
assertThat(events.selectLatestStartedPhase(resumed.id())).isEqualTo("PLANNING");
|
||
assertThat(new ObjectMapper().readTree(events.selectLatestMaterialResponseJson(projectId)))
|
||
.isEqualTo(new ObjectMapper().readTree("{\"decisions\":[]}"));
|
||
|
||
assertThat(mapper.interruptRunning(resumed.id())).isEqualTo(1);
|
||
assertThat(mapper.interruptRunning(resumed.id())).isZero();
|
||
assertThat(store.isInterrupted(resumed.id())).isTrue();
|
||
|
||
AgentRunService.RunView restartCandidate = store.create(projectId, "RETRY", resumed.id());
|
||
assertThat(mapper.interruptRunningAfterRestart()).isGreaterThanOrEqualTo(1);
|
||
assertThat(mapper.selectOneById(restartCandidate.id()).getErrorCode()).isEqualTo("PROCESS_RESTARTED");
|
||
}
|
||
|
||
/**
|
||
* 验证规划草稿使用递增版本写入 JSONB,并且只有 DRAFT 可以原子确认。
|
||
*
|
||
* @throws Exception Mapper XML 初始化失败时抛出
|
||
*/
|
||
@Test
|
||
void shouldSaveAndConfirmProjectPlanWithMyBatisFlex() throws Exception {
|
||
JdbcClient jdbc = jdbc();
|
||
UUID userId = UUID.randomUUID();
|
||
UUID projectId = UUID.randomUUID();
|
||
jdbc.sql("INSERT INTO app.app_user(id, username, password_hash, display_name) VALUES (:id, :name, 'x', 'test')")
|
||
.param("id", userId).param("name", "plan-u-" + userId).update();
|
||
jdbc.sql("""
|
||
INSERT INTO app.project(id, company_name, project_name, agui_thread_id, application_level, created_by)
|
||
VALUES (:id, '规划企业', '规划项目', :thread, 'ADVANCED', :userId)
|
||
""").param("id", projectId).param("thread", "plan-t-" + projectId).param("userId", userId).update();
|
||
|
||
UserService users = mock(UserService.class);
|
||
when(users.requireUserId("admin")).thenReturn(userId);
|
||
ProjectService service = new ProjectService(
|
||
projectMapper(), projectPlanMapper(), users, new ObjectMapper());
|
||
ObjectMapper json = new ObjectMapper();
|
||
|
||
ProjectService.PlanView draft = service.saveDraftPlan(
|
||
projectId, json.createObjectNode().put("title", "第一版"), userId);
|
||
ProjectService.PlanView confirmed = service.confirmPlan(
|
||
projectId,
|
||
draft.id(),
|
||
json.createObjectNode().put("title", "确认版"),
|
||
() -> "admin");
|
||
|
||
assertThat(draft.version()).isEqualTo(1);
|
||
assertThat(service.currentPlan(projectId).id()).isEqualTo(draft.id());
|
||
assertThat(confirmed.status()).isEqualTo("CONFIRMED");
|
||
assertThat(confirmed.plan().path("title").asText()).isEqualTo("确认版");
|
||
assertThat(service.require(projectId).status()).isEqualTo("WRITING");
|
||
assertThat(service.require(projectId).version()).isEqualTo(2L);
|
||
}
|
||
|
||
/**
|
||
* 验证多个模型持久化、首模型自动默认、保留旧密钥更新以及默认模型切换。
|
||
*
|
||
* @throws Exception Mapper XML 初始化失败时抛出
|
||
*/
|
||
@Test
|
||
void shouldManageEncryptedModelConfigurationWithMyBatisFlex() throws Exception {
|
||
UUID userId = UUID.randomUUID();
|
||
jdbc().sql("INSERT INTO app.app_user(id, username, password_hash, display_name) VALUES (:id, :name, 'x', 'test')")
|
||
.param("id", userId).param("name", "model-u-" + userId).update();
|
||
UserService users = mock(UserService.class);
|
||
when(users.requireUserId("admin")).thenReturn(userId);
|
||
AppProperties properties = new AppProperties(
|
||
temporaryDirectory,
|
||
temporaryDirectory.resolve("dashscope.key"),
|
||
"integration-master-key",
|
||
"admin",
|
||
"admin",
|
||
"runtime:test",
|
||
"bridge",
|
||
Duration.ofMinutes(1));
|
||
ModelService service = new ModelService(
|
||
modelConfigMapper(),
|
||
modelAssignmentMapper(),
|
||
agentRunMapper(),
|
||
users,
|
||
new KeyCipher(properties),
|
||
new ObjectMapper());
|
||
Map<String, Object> capabilities = Map.of(
|
||
"toolCalling", true, "reasoning", true, "contextWindow", 65_536);
|
||
|
||
ModelService.ModelView created = service.save(
|
||
null,
|
||
new ModelService.ModelInput(
|
||
"测试模型", "https://model.example.test/", "model-v1", "secret-1234",
|
||
Map.of("timeoutSeconds", 60), capabilities),
|
||
() -> "admin");
|
||
ModelService.ModelView updated = service.save(
|
||
created.id(),
|
||
new ModelService.ModelInput(
|
||
"测试模型更新", "https://model.example.test", "model-v2", "",
|
||
Map.of("timeoutSeconds", 120), capabilities),
|
||
() -> "admin");
|
||
ModelService.ModelView second = service.save(
|
||
null,
|
||
new ModelService.ModelInput(
|
||
"第二测试模型", "https://second-model.example.test", "model-second", "secret-5678",
|
||
Map.of("timeoutSeconds", 90), capabilities),
|
||
() -> "admin");
|
||
|
||
assertThat(created.defaultModel()).isTrue();
|
||
assertThat(second.defaultModel()).isFalse();
|
||
|
||
service.setDefault(second.id(), () -> "admin");
|
||
ModelService.ModelSecret firstSecret = service.requireRuntimeModel(created.id());
|
||
ModelService.ModelSecret defaultSecret = service.defaultModelSecret();
|
||
|
||
assertThat(updated.name()).isEqualTo("测试模型更新");
|
||
assertThat(updated.apiKeyHint()).endsWith("1234");
|
||
assertThat(firstSecret.apiKey()).isEqualTo("secret-1234");
|
||
assertThat(firstSecret.modelId()).isEqualTo("model-v2");
|
||
assertThat(defaultSecret.apiKey()).isEqualTo("secret-5678");
|
||
assertThat(defaultSecret.modelId()).isEqualTo("model-second");
|
||
assertThat(defaultSecret.contextWindow()).isEqualTo(65_536);
|
||
assertThat(jdbc().sql("SELECT COUNT(*) FROM app.model_assignment WHERE model_config_id = :id")
|
||
.param("id", second.id()).query(Long.class).single()).isEqualTo(3L);
|
||
|
||
assertThatThrownBy(() -> service.setEnabled(second.id(), false))
|
||
.isInstanceOfSatisfying(tech.easyflow.manuagent.common.ApiException.class, exception ->
|
||
assertThat(exception.code()).isEqualTo("DEFAULT_MODEL_REQUIRED"));
|
||
ModelService.ModelView disabled = service.setEnabled(created.id(), false);
|
||
assertThat(disabled.enabled()).isFalse();
|
||
|
||
// 已完成 Run 仍属于历史审计事实;即使模型已经停用,也必须阻止真删除。
|
||
UUID historyProjectId = UUID.randomUUID();
|
||
UUID historyRunId = UUID.randomUUID();
|
||
jdbc().sql("""
|
||
INSERT INTO app.project(id, company_name, project_name, agui_thread_id, application_level, created_by)
|
||
VALUES (:id, '模型历史企业', '模型历史测试', :threadId, 'ADVANCED', :userId)
|
||
""").param("id", historyProjectId).param("threadId", "thread-" + historyProjectId)
|
||
.param("userId", userId).update();
|
||
jdbc().sql("""
|
||
INSERT INTO app.agent_run(
|
||
id, project_id, model_config_id, trigger_type, status, trace_id, ended_at)
|
||
VALUES (:id, :projectId, :modelId, 'INITIAL', 'COMPLETED', :traceId, CURRENT_TIMESTAMP)
|
||
""").param("id", historyRunId).param("projectId", historyProjectId)
|
||
.param("modelId", created.id()).param("traceId", "trace-" + historyRunId).update();
|
||
|
||
assertThatThrownBy(() -> service.delete(created.id()))
|
||
.isInstanceOfSatisfying(tech.easyflow.manuagent.common.ApiException.class, exception ->
|
||
assertThat(exception.code()).isEqualTo("MODEL_HISTORY_EXISTS"));
|
||
|
||
// 清理本测试创建的引用后,再验证从未被历史 Run 使用的模型仍可按原有规则删除。
|
||
jdbc().sql("DELETE FROM app.agent_run WHERE id = :id").param("id", historyRunId).update();
|
||
jdbc().sql("DELETE FROM app.project WHERE id = :id").param("id", historyProjectId).update();
|
||
service.delete(created.id());
|
||
assertThat(jdbc().sql("SELECT COUNT(*) FROM app.model_config WHERE id = :id")
|
||
.param("id", created.id()).query(Long.class).single()).isZero();
|
||
}
|
||
|
||
/**
|
||
* 验证 Skill 列表只读联查 AgentScope 表,而启停状态仅写应用自管表。
|
||
*
|
||
* @throws Exception Mapper XML 初始化失败时抛出
|
||
*/
|
||
@Test
|
||
void shouldReadAgentScopeSkillsAndUpdateApplicationConfiguration() throws Exception {
|
||
SkillConfigMapper mapper = skillConfigMapper();
|
||
AppProperties properties = new AppProperties(
|
||
temporaryDirectory,
|
||
temporaryDirectory.resolve("dashscope.key"),
|
||
"integration-master-key",
|
||
"admin",
|
||
"admin",
|
||
"runtime:test",
|
||
"bridge",
|
||
Duration.ofMinutes(1));
|
||
SkillService service = new SkillService(
|
||
mapper,
|
||
mock(PostgresSkillRepository.class),
|
||
mock(SkillPackageReader.class),
|
||
mock(UserService.class),
|
||
properties);
|
||
|
||
List<SkillService.SkillView> skills = service.list();
|
||
assertThat(skills).isNotEmpty();
|
||
String name = skills.getFirst().name();
|
||
service.setEnabled(name, false);
|
||
|
||
assertThat(service.enabledNames()).doesNotContain(name);
|
||
assertThat(jdbc().sql("SELECT enabled FROM app.skill_config WHERE skill_name = :name")
|
||
.param("name", name).query(Boolean.class).single()).isFalse();
|
||
}
|
||
|
||
/**
|
||
* 验证 MyBatis-Flex 可以在 app schema 中插入并通过 Lambda QueryWrapper 查询用户。
|
||
*/
|
||
@Test
|
||
void shouldPersistAndQueryUserWithMyBatisFlex() {
|
||
PGSimpleDataSource source = dataSource();
|
||
AppUserMapper mapper = new MybatisFlexBootstrap()
|
||
.setDataSource(source)
|
||
.addMapper(AppUserMapper.class)
|
||
.start()
|
||
.getMapper(AppUserMapper.class);
|
||
UUID userId = UUID.randomUUID();
|
||
AppUserEntity user = new AppUserEntity();
|
||
user.setId(userId);
|
||
user.setUsername("flex-" + userId);
|
||
user.setPasswordHash("encoded");
|
||
user.setDisplayName("Flex 测试用户");
|
||
|
||
TableInfo tableInfo = TableInfoFactory.ofEntityClass(AppUserEntity.class);
|
||
assertThat(tableInfo.getPrimaryColumns()).containsExactly("id");
|
||
assertThat(tableInfo.getInsertPrimaryKeys()).containsExactly("id");
|
||
|
||
// 主键由应用层提前生成;Generator 策略必须保留已有值,其他空字段交给数据库默认值。
|
||
assertThat(mapper.insertSelectiveWithPk(user)).isEqualTo(1);
|
||
|
||
QueryWrapper query = QueryWrapper.create()
|
||
.where(AppUserEntity::getUsername).eq(user.getUsername());
|
||
AppUserEntity loaded = mapper.selectOneByQuery(query);
|
||
assertThat(loaded.getId()).isEqualTo(userId);
|
||
assertThat(loaded.getDisplayName()).isEqualTo("Flex 测试用户");
|
||
assertThat(loaded.getEnabled()).isTrue();
|
||
}
|
||
|
||
private JdbcClient jdbc() {
|
||
return JdbcClient.create(dataSource());
|
||
}
|
||
|
||
private PGSimpleDataSource dataSource() {
|
||
PGSimpleDataSource source = new PGSimpleDataSource();
|
||
source.setURL(POSTGRES.getJdbcUrl());
|
||
source.setUser(POSTGRES.getUsername());
|
||
source.setPassword(POSTGRES.getPassword());
|
||
return source;
|
||
}
|
||
|
||
/**
|
||
* 创建带 UUID、JSONB 类型处理器和显式 XML 语句的产物 Mapper。
|
||
*
|
||
* <p>生产环境由 Spring Boot 扫描类型处理器与 mapper-locations;此处使用轻量 Bootstrap,
|
||
* 因而需要显式复现相同配置。</p>
|
||
*
|
||
* @return 可访问 Testcontainers PostgreSQL 的产物 Mapper
|
||
* @throws Exception XML 资源读取或 Mapper 初始化失败时抛出
|
||
*/
|
||
private ArtifactMapper artifactMapper() throws Exception {
|
||
PGSimpleDataSource source = dataSource();
|
||
FlexDataSource flexDataSource = new FlexDataSource("artifact-integration-test", source);
|
||
Environment environment = new Environment(
|
||
"artifact-integration-test", new JdbcTransactionFactory(), flexDataSource);
|
||
FlexConfiguration configuration = new FlexConfiguration(environment);
|
||
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
|
||
configuration.getTypeHandlerRegistry().register(JsonbStringTypeHandler.class);
|
||
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
|
||
.setConfiguration(configuration)
|
||
.setDataSource(flexDataSource)
|
||
.addMapper(ArtifactMapper.class)
|
||
.start();
|
||
String resource = "mapper/ArtifactMapper.xml";
|
||
try (InputStream input = Resources.getResourceAsStream(resource)) {
|
||
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
|
||
}
|
||
return bootstrap.getMapper(ArtifactMapper.class);
|
||
}
|
||
|
||
/**
|
||
* 创建加载事件原子写入和游标回放 SQL 的 Agent 事件 Mapper。
|
||
*
|
||
* @return Agent 事件 Mapper
|
||
* @throws Exception XML 资源读取或 Mapper 初始化失败时抛出
|
||
*/
|
||
private AgentEventMapper agentEventMapper() throws Exception {
|
||
PGSimpleDataSource source = dataSource();
|
||
FlexDataSource flexDataSource = new FlexDataSource("agent-event-integration-test", source);
|
||
Environment environment = new Environment(
|
||
"agent-event-integration-test", new JdbcTransactionFactory(), flexDataSource);
|
||
FlexConfiguration configuration = new FlexConfiguration(environment);
|
||
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
|
||
configuration.getTypeHandlerRegistry().register(JsonbStringTypeHandler.class);
|
||
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
|
||
.setConfiguration(configuration)
|
||
.setDataSource(flexDataSource)
|
||
.addMapper(AgentEventMapper.class)
|
||
.start();
|
||
String resource = "mapper/AgentEventMapper.xml";
|
||
try (InputStream input = Resources.getResourceAsStream(resource)) {
|
||
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
|
||
}
|
||
return bootstrap.getMapper(AgentEventMapper.class);
|
||
}
|
||
|
||
/**
|
||
* 创建加载 Run 状态机 SQL 及 UUID、JSONB 类型处理器的 Agent Run Mapper。
|
||
*
|
||
* @return Agent Run Mapper
|
||
* @throws Exception XML 资源读取或 Mapper 初始化失败时抛出
|
||
*/
|
||
private AgentRunMapper agentRunMapper() throws Exception {
|
||
PGSimpleDataSource source = dataSource();
|
||
FlexDataSource flexDataSource = new FlexDataSource("agent-run-integration-test", source);
|
||
Environment environment = new Environment(
|
||
"agent-run-integration-test", new JdbcTransactionFactory(), flexDataSource);
|
||
FlexConfiguration configuration = new FlexConfiguration(environment);
|
||
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
|
||
configuration.getTypeHandlerRegistry().register(JsonbStringTypeHandler.class);
|
||
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
|
||
.setConfiguration(configuration)
|
||
.setDataSource(flexDataSource)
|
||
.addMapper(AgentRunMapper.class)
|
||
.start();
|
||
String resource = "mapper/AgentRunMapper.xml";
|
||
try (InputStream input = Resources.getResourceAsStream(resource)) {
|
||
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
|
||
}
|
||
return bootstrap.getMapper(AgentRunMapper.class);
|
||
}
|
||
|
||
/**
|
||
* 创建加载了项目级联删除 XML 的项目 Mapper。
|
||
*
|
||
* @return 项目 Mapper
|
||
* @throws Exception XML 资源读取或 Mapper 初始化失败时抛出
|
||
*/
|
||
private ProjectMapper projectMapper() throws Exception {
|
||
PGSimpleDataSource source = dataSource();
|
||
FlexDataSource flexDataSource = new FlexDataSource("project-integration-test", source);
|
||
Environment environment = new Environment(
|
||
"project-integration-test", new JdbcTransactionFactory(), flexDataSource);
|
||
FlexConfiguration configuration = new FlexConfiguration(environment);
|
||
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
|
||
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
|
||
.setConfiguration(configuration)
|
||
.setDataSource(flexDataSource)
|
||
.addMapper(ProjectMapper.class)
|
||
.start();
|
||
String resource = "mapper/ProjectMapper.xml";
|
||
try (InputStream input = Resources.getResourceAsStream(resource)) {
|
||
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
|
||
}
|
||
return bootstrap.getMapper(ProjectMapper.class);
|
||
}
|
||
|
||
/**
|
||
* 创建加载了规划版本 SQL 与 JSONB 处理器的规划 Mapper。
|
||
*
|
||
* @return 规划 Mapper
|
||
* @throws Exception XML 资源读取或 Mapper 初始化失败时抛出
|
||
*/
|
||
private ProjectPlanMapper projectPlanMapper() throws Exception {
|
||
PGSimpleDataSource source = dataSource();
|
||
FlexDataSource flexDataSource = new FlexDataSource("plan-integration-test", source);
|
||
Environment environment = new Environment(
|
||
"plan-integration-test", new JdbcTransactionFactory(), flexDataSource);
|
||
FlexConfiguration configuration = new FlexConfiguration(environment);
|
||
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
|
||
configuration.getTypeHandlerRegistry().register(JsonbStringTypeHandler.class);
|
||
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
|
||
.setConfiguration(configuration)
|
||
.setDataSource(flexDataSource)
|
||
.addMapper(ProjectPlanMapper.class)
|
||
.start();
|
||
String resource = "mapper/ProjectPlanMapper.xml";
|
||
try (InputStream input = Resources.getResourceAsStream(resource)) {
|
||
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
|
||
}
|
||
return bootstrap.getMapper(ProjectPlanMapper.class);
|
||
}
|
||
|
||
/**
|
||
* 创建使用 MyBatis-Flex Wrapper,并加载 JSONB 显式写入 SQL 的模型配置 Mapper。
|
||
*
|
||
* @return 模型配置 Mapper
|
||
* @throws Exception XML 资源读取或 Mapper 初始化失败时抛出
|
||
*/
|
||
private ModelConfigMapper modelConfigMapper() throws Exception {
|
||
PGSimpleDataSource source = dataSource();
|
||
FlexDataSource flexDataSource = new FlexDataSource("model-config-integration-test", source);
|
||
Environment environment = new Environment(
|
||
"model-config-integration-test", new JdbcTransactionFactory(), flexDataSource);
|
||
FlexConfiguration configuration = new FlexConfiguration(environment);
|
||
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
|
||
configuration.getTypeHandlerRegistry().register(JsonbStringTypeHandler.class);
|
||
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
|
||
.setConfiguration(configuration)
|
||
.setDataSource(flexDataSource)
|
||
.addMapper(ModelConfigMapper.class)
|
||
.start();
|
||
String resource = "mapper/ModelConfigMapper.xml";
|
||
try (InputStream input = Resources.getResourceAsStream(resource)) {
|
||
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
|
||
}
|
||
return bootstrap.getMapper(ModelConfigMapper.class);
|
||
}
|
||
|
||
/** 创建加载角色 upsert SQL 的模型分配 Mapper。 */
|
||
private ModelAssignmentMapper modelAssignmentMapper() throws Exception {
|
||
PGSimpleDataSource source = dataSource();
|
||
FlexDataSource flexDataSource = new FlexDataSource("model-assignment-integration-test", source);
|
||
Environment environment = new Environment(
|
||
"model-assignment-integration-test", new JdbcTransactionFactory(), flexDataSource);
|
||
FlexConfiguration configuration = new FlexConfiguration(environment);
|
||
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
|
||
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
|
||
.setConfiguration(configuration)
|
||
.setDataSource(flexDataSource)
|
||
.addMapper(ModelAssignmentMapper.class)
|
||
.start();
|
||
String resource = "mapper/ModelAssignmentMapper.xml";
|
||
try (InputStream input = Resources.getResourceAsStream(resource)) {
|
||
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
|
||
}
|
||
return bootstrap.getMapper(ModelAssignmentMapper.class);
|
||
}
|
||
|
||
/** 创建加载 AgentScope 只读联查 SQL 的 Skill 配置 Mapper。 */
|
||
private SkillConfigMapper skillConfigMapper() throws Exception {
|
||
PGSimpleDataSource source = dataSource();
|
||
FlexDataSource flexDataSource = new FlexDataSource("skill-config-integration-test", source);
|
||
Environment environment = new Environment(
|
||
"skill-config-integration-test", new JdbcTransactionFactory(), flexDataSource);
|
||
FlexConfiguration configuration = new FlexConfiguration(environment);
|
||
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
|
||
MybatisFlexBootstrap bootstrap = new MybatisFlexBootstrap()
|
||
.setConfiguration(configuration)
|
||
.setDataSource(flexDataSource)
|
||
.addMapper(SkillConfigMapper.class)
|
||
.start();
|
||
String resource = "mapper/SkillConfigMapper.xml";
|
||
try (InputStream input = Resources.getResourceAsStream(resource)) {
|
||
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
|
||
}
|
||
return bootstrap.getMapper(SkillConfigMapper.class);
|
||
}
|
||
}
|