refactor(phase1): 完成包名重构 (org.example → com.superbiz.agent)

Task 4.1: 包名统一重构
- 重命名 41 个 Java 文件的包名
- 更新所有 import 语句
- 恢复枚举类(FaultCategory、DiagnosisStatus、SourceType)
- 更新测试类的 import

重构范围:
- domain/entity: 3 个实体类
- domain/model: 2 个数据类
- domain/enums: 3 个枚举类
- repository: 3 个接口
- service/session: 2 个类(接口 + 实现)
- config: 9 个配置类
- controller: 2 个控制器
- agent/tool: 4 个工具类
- client: 1 个客户端
- Main.java: 主类

验证结果:
- 编译成功,无错误
- 所有测试通过 (27/27)
  - ApiDocumentRepositoryTest: 7/7 ✅
  - CaseLibraryRepositoryTest: 6/6 ✅
  - DiagnosisRecordRepositoryTest: 6/6 ✅
  - RedisSessionManagerTest: 8/8 ✅

Progress: 21/33 tasks completed (64%)
This commit is contained in:
zhuyongxin
2026-06-23 14:56:04 +08:00
parent 8bd758dbaf
commit c3a232540a
59 changed files with 103 additions and 104 deletions
@@ -0,0 +1,64 @@
package com.superbiz.agent.config;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.RedisTemplate;
import javax.sql.DataSource;
import java.sql.Connection;
import static org.junit.jupiter.api.Assertions.*;
/**
* 测试 MySQL 和 Redis 连接配置
*/
@SpringBootTest
class ConnectionConfigTest {
@Autowired(required = false)
private DataSource dataSource;
@Autowired(required = false)
private RedisTemplate<String, Object> redisTemplate;
@Test
void testMySQLConnection() {
assertNotNull(dataSource, "DataSource should be configured");
try (Connection connection = dataSource.getConnection()) {
assertNotNull(connection, "Connection should not be null");
assertFalse(connection.isClosed(), "Connection should be open");
String catalog = connection.getCatalog();
System.out.println("✓ MySQL 连接成功!数据库: " + catalog);
assertEquals("superbiz_agent", catalog, "Database name should be superbiz_agent");
} catch (Exception e) {
fail("MySQL 连接失败: " + e.getMessage());
}
}
@Test
void testRedisConnection() {
assertNotNull(redisTemplate, "RedisTemplate should be configured");
try {
// 测试 PING
String testKey = "test:connection:" + System.currentTimeMillis();
String testValue = "test-value";
redisTemplate.opsForValue().set(testKey, testValue);
Object result = redisTemplate.opsForValue().get(testKey);
assertEquals(testValue, result, "Redis read/write should work");
System.out.println("✓ Redis 连接成功!读写正常");
// 清理测试数据
redisTemplate.delete(testKey);
} catch (Exception e) {
fail("Redis 连接失败: " + e.getMessage());
}
}
}
@@ -0,0 +1,71 @@
package com.superbiz.agent.config;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.TestPropertySource;
import javax.sql.DataSource;
import java.sql.Connection;
import static org.junit.jupiter.api.Assertions.*;
/**
* 单独测试 MySQL 连接(不启动完整 Spring Context)
*/
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestPropertySource(properties = {
"spring.flyway.enabled=true",
"spring.jpa.hibernate.ddl-auto=validate"
})
class MySQLConnectionTest {
@Autowired
private DataSource dataSource;
@Test
void testMySQLConnection() {
assertNotNull(dataSource, "DataSource should be configured");
try (Connection connection = dataSource.getConnection()) {
assertNotNull(connection, "Connection should not be null");
assertFalse(connection.isClosed(), "Connection should be open");
String catalog = connection.getCatalog();
System.out.println("✓ MySQL 连接成功!");
System.out.println(" 数据库: " + catalog);
System.out.println(" URL: " + connection.getMetaData().getURL());
assertEquals("superbiz_agent", catalog, "Database name should be superbiz_agent");
} catch (Exception e) {
fail("MySQL 连接失败: " + e.getMessage());
}
}
@Test
void testFlywayMigration() {
// 如果能到这里,说明 Flyway 迁移成功
System.out.println("✓ Flyway 迁移已执行");
try (Connection connection = dataSource.getConnection()) {
var metadata = connection.getMetaData();
var tables = metadata.getTables(null, null, "%", new String[]{"TABLE"});
int tableCount = 0;
System.out.println(" 已创建的表:");
while (tables.next()) {
String tableName = tables.getString("TABLE_NAME");
System.out.println(" - " + tableName);
tableCount++;
}
assertTrue(tableCount >= 3, "至少应该有 3 张表 (diagnosis_record, case_library, api_document)");
} catch (Exception e) {
fail("验证表结构失败: " + e.getMessage());
}
}
}
@@ -0,0 +1,55 @@
package com.superbiz.agent.config;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.test.context.TestPropertySource;
import static org.junit.jupiter.api.Assertions.*;
/**
* 单独测试 Redis 连接
* 禁用 Milvus 以避免启动失败
*/
@SpringBootTest
@TestPropertySource(properties = {
"spring.autoconfigure.exclude=org.example.config.MilvusConfig"
})
class RedisConnectionTest {
@Autowired(required = false)
private RedisTemplate<String, Object> redisTemplate;
@Test
void testRedisConnection() {
assertNotNull(redisTemplate, "RedisTemplate should be configured");
try {
String testKey = "test:connection:" + System.currentTimeMillis();
String testValue = "test-value";
// 测试写入
redisTemplate.opsForValue().set(testKey, testValue);
System.out.println("✓ Redis 写入成功");
// 测试读取
Object result = redisTemplate.opsForValue().get(testKey);
assertEquals(testValue, result, "Redis read should return the written value");
System.out.println("✓ Redis 读取成功");
// 测试删除
redisTemplate.delete(testKey);
Object deleted = redisTemplate.opsForValue().get(testKey);
assertNull(deleted, "Deleted key should return null");
System.out.println("✓ Redis 删除成功");
System.out.println("\n✓ Redis 连接测试通过!");
System.out.println(" 服务器: 119.29.78.52:6379");
System.out.println(" 读写删操作正常");
} catch (Exception e) {
fail("Redis 连接失败: " + e.getMessage());
}
}
}
@@ -0,0 +1,50 @@
package com.superbiz.agent.config;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.StringRedisTemplate;
import static org.junit.jupiter.api.Assertions.*;
/**
* Redis 连接测试
* 注意:如果 Milvus 未启动,此测试会因 Spring Context 加载失败而失败
* 使用 @DataRedisTest 可以避免加载完整 Context,但需要额外配置
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
class SimpleRedisTest {
@Autowired(required = false)
private StringRedisTemplate stringRedisTemplate;
@Test
void testRedisBasicOperations() {
// 如果能到这里,说明 Spring Context 加载成功,Redis 配置正确
assertNotNull(stringRedisTemplate, "StringRedisTemplate should be configured");
String testKey = "test:simple:" + System.currentTimeMillis();
String testValue = "hello-redis";
try {
// 写入
stringRedisTemplate.opsForValue().set(testKey, testValue);
System.out.println("✓ Redis 写入成功: " + testKey + " = " + testValue);
// 读取
String result = stringRedisTemplate.opsForValue().get(testKey);
assertEquals(testValue, result);
System.out.println("✓ Redis 读取成功: " + result);
// 删除
Boolean deleted = stringRedisTemplate.delete(testKey);
assertTrue(deleted != null && deleted);
System.out.println("✓ Redis 删除成功");
System.out.println("\n✅ Redis 连接正常,读写删操作成功");
} catch (Exception e) {
fail("Redis 操作失败: " + e.getMessage());
}
}
}
@@ -0,0 +1,179 @@
package com.superbiz.agent.repository;
import com.superbiz.agent.domain.enums.FaultCategory;
import com.superbiz.agent.domain.entity.ApiDocument;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.test.context.TestPropertySource;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/**
* ApiDocumentRepository 单元测试
*/
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestPropertySource(properties = {
"spring.flyway.enabled=true",
"spring.jpa.hibernate.ddl-auto=validate",
"spring.jpa.show-sql=false"
})
class ApiDocumentRepositoryTest {
@Autowired
private ApiDocumentRepository repository;
@Test
void testSaveAndFindById() {
ApiDocument doc = ApiDocument.builder()
.docId(UUID.randomUUID().toString())
.fileName("api-spec.md")
.filePath("/uploads/api-spec.md")
.fileHash("abc123hash")
.fileSize(1024L)
.faultCategory(FaultCategory.EXTERNAL_API)
.faultSource("广东")
.apiName("查询接口")
.status("PENDING")
.chunkCount(0)
.build();
ApiDocument saved = repository.save(doc);
assertNotNull(saved.getId());
assertNotNull(saved.getCreatedAt());
System.out.println("✓ 保存文档成功,ID: " + saved.getId());
Optional<ApiDocument> found = repository.findById(saved.getId());
assertTrue(found.isPresent());
assertEquals("api-spec.md", found.get().getFileName());
System.out.println("✓ 查询文档成功");
}
@Test
void testFindByDocId() {
String docId = UUID.randomUUID().toString();
ApiDocument doc = ApiDocument.builder()
.docId(docId)
.fileName("test-doc.txt")
.status("INDEXED")
.build();
repository.save(doc);
Optional<ApiDocument> found = repository.findByDocId(docId);
assertTrue(found.isPresent());
assertEquals(docId, found.get().getDocId());
System.out.println("✓ 根据 docId 查询成功");
}
@Test
void testFindByFileHash() {
String fileHash = "unique-hash-" + System.currentTimeMillis();
ApiDocument doc = ApiDocument.builder()
.docId(UUID.randomUUID().toString())
.fileName("duplicate-check.md")
.fileHash(fileHash)
.build();
repository.save(doc);
Optional<ApiDocument> found = repository.findByFileHash(fileHash);
assertTrue(found.isPresent());
assertEquals(fileHash, found.get().getFileHash());
System.out.println("✓ 根据 fileHash 查询成功(去重检测)");
}
@Test
void testFindByStatus() {
ApiDocument doc1 = ApiDocument.builder()
.docId(UUID.randomUUID().toString())
.fileName("pending-1.md")
.status("PENDING")
.build();
ApiDocument doc2 = ApiDocument.builder()
.docId(UUID.randomUUID().toString())
.fileName("pending-2.md")
.status("PENDING")
.build();
repository.save(doc1);
repository.save(doc2);
List<ApiDocument> results = repository.findByStatus("PENDING");
assertFalse(results.isEmpty());
assertTrue(results.size() >= 2);
System.out.println("✓ 根据状态查询成功,找到 " + results.size() + " 条 PENDING 文档");
}
@Test
void testFindByStatusWithPagination() {
// 创建多个文档
for (int i = 0; i < 5; i++) {
ApiDocument doc = ApiDocument.builder()
.docId(UUID.randomUUID().toString())
.fileName("doc-" + i + ".txt")
.status("INDEXED")
.chunkCount(10)
.build();
repository.save(doc);
}
Page<ApiDocument> page = repository.findByStatus("INDEXED", PageRequest.of(0, 3));
assertFalse(page.isEmpty());
assertTrue(page.getContent().size() <= 3);
System.out.println("✓ 分页查询成功,本页 " + page.getContent().size() + " 条,总计 " + page.getTotalElements() + " 条");
}
@Test
void testUpdateDocumentStatus() {
ApiDocument doc = ApiDocument.builder()
.docId(UUID.randomUUID().toString())
.fileName("indexing-doc.md")
.status("PENDING")
.chunkCount(0)
.build();
ApiDocument saved = repository.save(doc);
Long id = saved.getId();
// 更新状态
saved.setStatus("INDEXED");
saved.setChunkCount(15);
saved.setIndexedAt(LocalDateTime.now());
repository.save(saved);
Optional<ApiDocument> updated = repository.findById(id);
assertTrue(updated.isPresent());
assertEquals("INDEXED", updated.get().getStatus());
assertEquals(15, updated.get().getChunkCount());
assertNotNull(updated.get().getIndexedAt());
System.out.println("✓ 更新文档状态成功");
}
@Test
void testFindByFaultSource() {
ApiDocument doc = ApiDocument.builder()
.docId(UUID.randomUUID().toString())
.fileName("guangdong-api.md")
.faultSource("广东")
.faultCategory(FaultCategory.EXTERNAL_API)
.build();
repository.save(doc);
List<ApiDocument> results = repository.findByFaultSource("广东");
assertFalse(results.isEmpty());
System.out.println("✓ 根据故障源查询成功,找到 " + results.size() + " 条文档");
}
}
@@ -0,0 +1,173 @@
package com.superbiz.agent.repository;
import com.superbiz.agent.domain.enums.FaultCategory;
import com.superbiz.agent.domain.enums.SourceType;
import com.superbiz.agent.domain.entity.CaseLibrary;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.test.context.TestPropertySource;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/**
* CaseLibraryRepository 单元测试
*/
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestPropertySource(properties = {
"spring.flyway.enabled=true",
"spring.jpa.hibernate.ddl-auto=validate",
"spring.jpa.show-sql=false"
})
class CaseLibraryRepositoryTest {
@Autowired
private CaseLibraryRepository repository;
@Test
void testSaveAndFindById() {
CaseLibrary caseLib = CaseLibrary.builder()
.caseId(UUID.randomUUID().toString())
.title("接口超时案例")
.rootCause("网络延迟导致接口超时")
.solution("增加超时时间和重试机制")
.faultCategory(FaultCategory.EXTERNAL_API)
.errorCode("40003")
.sourceType(SourceType.AUTO)
.build();
CaseLibrary saved = repository.save(caseLib);
assertNotNull(saved.getId());
assertNotNull(saved.getCreatedAt());
System.out.println("✓ 保存案例成功,ID: " + saved.getId());
Optional<CaseLibrary> found = repository.findById(saved.getId());
assertTrue(found.isPresent());
assertEquals("接口超时案例", found.get().getTitle());
System.out.println("✓ 查询案例成功");
}
@Test
void testFindByCaseId() {
String caseId = UUID.randomUUID().toString();
CaseLibrary caseLib = CaseLibrary.builder()
.caseId(caseId)
.title("数据库死锁案例")
.rootCause("并发更新导致死锁")
.solution("优化事务粒度")
.faultCategory(FaultCategory.DATABASE)
.build();
repository.save(caseLib);
Optional<CaseLibrary> found = repository.findByCaseId(caseId);
assertTrue(found.isPresent());
assertEquals(caseId, found.get().getCaseId());
System.out.println("✓ 根据 caseId 查询成功");
}
@Test
void testFindByFaultCategoryAndErrorCode() {
CaseLibrary case1 = CaseLibrary.builder()
.caseId(UUID.randomUUID().toString())
.title("案例1")
.rootCause("原因1")
.solution("方案1")
.faultCategory(FaultCategory.EXTERNAL_API)
.errorCode("40003")
.build();
CaseLibrary case2 = CaseLibrary.builder()
.caseId(UUID.randomUUID().toString())
.title("案例2")
.rootCause("原因2")
.solution("方案2")
.faultCategory(FaultCategory.EXTERNAL_API)
.errorCode("40003")
.build();
repository.save(case1);
repository.save(case2);
List<CaseLibrary> results = repository.findByFaultCategoryAndErrorCode(
FaultCategory.EXTERNAL_API, "40003");
assertFalse(results.isEmpty());
assertTrue(results.size() >= 2);
System.out.println("✓ 根据故障类别和错误码查询成功,找到 " + results.size() + " 条案例");
}
@Test
void testFindBySourceType() {
CaseLibrary autoCase = CaseLibrary.builder()
.caseId(UUID.randomUUID().toString())
.title("自动生成案例")
.rootCause("自动分析")
.solution("自动方案")
.sourceType(SourceType.AUTO)
.build();
repository.save(autoCase);
Page<CaseLibrary> results = repository.findBySourceType(
SourceType.AUTO, PageRequest.of(0, 10));
assertFalse(results.isEmpty());
System.out.println("✓ 根据来源类型查询成功,找到 " + results.getTotalElements() + " 条案例");
}
@Test
void testUpdateReferenceCount() {
CaseLibrary caseLib = CaseLibrary.builder()
.caseId(UUID.randomUUID().toString())
.title("热门案例")
.rootCause("常见问题")
.solution("标准方案")
.referenceCount(0)
.build();
CaseLibrary saved = repository.save(caseLib);
Long id = saved.getId();
// 模拟被引用
saved.setReferenceCount(saved.getReferenceCount() + 1);
repository.save(saved);
Optional<CaseLibrary> updated = repository.findById(id);
assertTrue(updated.isPresent());
assertEquals(1, updated.get().getReferenceCount());
System.out.println("✓ 更新引用次数成功");
}
@Test
void testFindTopByReferenceCount() {
// 创建一些案例
for (int i = 0; i < 3; i++) {
CaseLibrary caseLib = CaseLibrary.builder()
.caseId(UUID.randomUUID().toString())
.title("案例 " + i)
.rootCause("原因")
.solution("方案")
.referenceCount(i * 10)
.build();
repository.save(caseLib);
}
List<CaseLibrary> topCases = repository.findTop10ByOrderByReferenceCountDesc();
assertFalse(topCases.isEmpty());
// 验证排序(第一个引用次数应该最高)
if (topCases.size() >= 2) {
assertTrue(topCases.get(0).getReferenceCount() >= topCases.get(1).getReferenceCount());
}
System.out.println("✓ 查询热门案例成功,返回 " + topCases.size() + " 条");
}
}
@@ -0,0 +1,173 @@
package com.superbiz.agent.repository;
import com.superbiz.agent.domain.enums.DiagnosisStatus;
import com.superbiz.agent.domain.enums.FaultCategory;
import com.superbiz.agent.domain.entity.DiagnosisRecord;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.TestPropertySource;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/**
* DiagnosisRecordRepository 单元测试
*/
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestPropertySource(properties = {
"spring.flyway.enabled=true",
"spring.jpa.hibernate.ddl-auto=validate",
"spring.jpa.show-sql=true"
})
class DiagnosisRecordRepositoryTest {
@Autowired
private DiagnosisRecordRepository repository;
@Test
void testSaveAndFindById() {
// 创建测试数据
DiagnosisRecord record = DiagnosisRecord.builder()
.diagnosisId(UUID.randomUUID().toString())
.sessionId("session-001")
.businessId("order-12345")
.traceId("trace-abc123")
.faultCategory(FaultCategory.EXTERNAL_API)
.faultSource("广东")
.faultTarget("http://api.example.com/query")
.errorCode("40003")
.errorMessage("接口超时")
.status(DiagnosisStatus.SUCCESS)
.confidence(85)
.duration(1500)
.build();
// 保存
DiagnosisRecord saved = repository.save(record);
assertNotNull(saved.getId());
assertNotNull(saved.getCreatedAt());
System.out.println("✓ 保存成功,ID: " + saved.getId());
// 查询
Optional<DiagnosisRecord> found = repository.findById(saved.getId());
assertTrue(found.isPresent());
assertEquals("order-12345", found.get().getBusinessId());
System.out.println("✓ 根据 ID 查询成功");
}
@Test
void testFindByDiagnosisId() {
String diagnosisId = UUID.randomUUID().toString();
DiagnosisRecord record = DiagnosisRecord.builder()
.diagnosisId(diagnosisId)
.businessId("order-test-001")
.faultCategory(FaultCategory.DATABASE)
.status(DiagnosisStatus.PENDING)
.build();
repository.save(record);
Optional<DiagnosisRecord> found = repository.findByDiagnosisId(diagnosisId);
assertTrue(found.isPresent());
assertEquals(diagnosisId, found.get().getDiagnosisId());
System.out.println("✓ 根据 diagnosisId 查询成功");
}
@Test
void testFindByFaultCategoryAndErrorCode() {
// 创建测试数据
DiagnosisRecord record1 = DiagnosisRecord.builder()
.diagnosisId(UUID.randomUUID().toString())
.faultCategory(FaultCategory.EXTERNAL_API)
.errorCode("40003")
.status(DiagnosisStatus.SUCCESS)
.build();
DiagnosisRecord record2 = DiagnosisRecord.builder()
.diagnosisId(UUID.randomUUID().toString())
.faultCategory(FaultCategory.EXTERNAL_API)
.errorCode("40003")
.status(DiagnosisStatus.FAILED)
.build();
repository.save(record1);
repository.save(record2);
// 查询
List<DiagnosisRecord> results = repository.findByFaultCategoryAndErrorCode(
FaultCategory.EXTERNAL_API, "40003");
assertFalse(results.isEmpty());
assertTrue(results.size() >= 2);
System.out.println("✓ 根据故障类别和错误码查询成功,找到 " + results.size() + " 条记录");
}
@Test
void testFindByStatus() {
DiagnosisRecord record = DiagnosisRecord.builder()
.diagnosisId(UUID.randomUUID().toString())
.status(DiagnosisStatus.RUNNING)
.faultCategory(FaultCategory.CACHE)
.build();
repository.save(record);
List<DiagnosisRecord> results = repository.findByStatus(DiagnosisStatus.RUNNING);
assertFalse(results.isEmpty());
System.out.println("✓ 根据状态查询成功,找到 " + results.size() + " 条 RUNNING 记录");
}
@Test
void testUpdateRecord() {
// 创建并保存
DiagnosisRecord record = DiagnosisRecord.builder()
.diagnosisId(UUID.randomUUID().toString())
.status(DiagnosisStatus.PENDING)
.confidence(0)
.build();
DiagnosisRecord saved = repository.save(record);
Long id = saved.getId();
// 更新
saved.setStatus(DiagnosisStatus.SUCCESS);
saved.setConfidence(90);
saved.setRootCause("接口超时导致");
saved.setSolution("增加重试机制");
repository.save(saved);
// 验证更新
Optional<DiagnosisRecord> updated = repository.findById(id);
assertTrue(updated.isPresent());
assertEquals(DiagnosisStatus.SUCCESS, updated.get().getStatus());
assertEquals(90, updated.get().getConfidence());
assertNotNull(updated.get().getUpdatedAt());
System.out.println("✓ 更新记录成功");
}
@Test
void testDeleteRecord() {
DiagnosisRecord record = DiagnosisRecord.builder()
.diagnosisId(UUID.randomUUID().toString())
.status(DiagnosisStatus.PENDING)
.build();
DiagnosisRecord saved = repository.save(record);
Long id = saved.getId();
// 删除
repository.deleteById(id);
// 验证删除
Optional<DiagnosisRecord> deleted = repository.findById(id);
assertFalse(deleted.isPresent());
System.out.println("✓ 删除记录成功");
}
}
@@ -0,0 +1,92 @@
package com.superbiz.agent.service;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import static org.junit.jupiter.api.Assertions.*;
/**
* Chat + Embedding 解耦验证测试
* <p>
* 验证 ChatModel Bean 注入、ModelRoutingConfig 路由、ChatService 接口兼容。
* Chat: DeepSeek via OpenAI-compatible API
* Embedding: Ollama BGE-M3 (需要本地 ollama 运行)
*/
@SpringBootTest
@DisplayName("Chat + Embedding 解耦验证")
class ChatAndEmbeddingSmokeTest {
@Autowired
private ApplicationContext context;
@Autowired
private ChatModel chatModel;
@Autowired
private ChatService chatService;
/**
* 提供 mock ToolCallbackProvider(MCP 已禁用时需要)
*/
@TestConfiguration
static class MockToolConfig {
@Bean
public ToolCallbackProvider toolCallbackProvider() {
return () -> new ToolCallback[0];
}
}
@Test
@DisplayName("Spring 容器启动成功")
void contextLoads() {
assertNotNull(context, "Spring 容器应为非空");
assertNotNull(chatModel, "ChatModel Bean 应注入成功");
assertNotNull(chatService, "ChatService Bean 应注入成功");
}
@Test
@DisplayName("ModelRoutingConfig @Primary ChatModel 生效")
void chatModelPrimaryBeanWorks() {
assertNotNull(chatModel, "@Primary ChatModel 应被自动注入");
System.out.println("✓ ChatModel 类型: " + chatModel.getClass().getName());
// 验证路由到 openAiChatModel (DeepSeek)
assertTrue(context.containsBean("openAiChatModel"), "openAiChatModel 应存在");
System.out.println(" Chat → openAiChatModel (DeepSeek) ✓");
}
@Test
@DisplayName("ChatService.createReactAgent 接受 ChatModel 接口")
void chatServiceAcceptsChatModelInterface() {
var agent = chatService.createReactAgent(chatModel, "测试系统提示词");
assertNotNull(agent, "ReactAgent 应创建成功");
assertEquals("intelligent_assistant", agent.name());
System.out.println("✓ ReactAgent 创建成功: " + agent.name());
}
@Test
@DisplayName("ChatModel 实现 ChatModel 接口(类型安全验证)")
void chatModelIsProperType() {
assertNotNull(chatModel, "注入的 Bean 应为 ChatModel 实例");
System.out.println("✓ ChatModel 接口实现: " + chatModel.getClass().getSimpleName());
}
@Test
@DisplayName("EmbeddingModel 状态")
void embeddingModelStatus() {
boolean hasEmbedding = context.containsBean("embeddingModel");
if (hasEmbedding) {
System.out.println("✓ EmbeddingModel 已配置");
} else {
System.out.println("⚠ EmbeddingModel 未找到 — 检查 Ollama 是否运行");
}
}
}
@@ -0,0 +1,539 @@
package com.superbiz.agent.service;
import com.superbiz.agent.config.DocumentChunkConfig;
import com.superbiz.agent.dto.DocumentChunk;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* 当前分片策略的单元测试 — 覆盖旧能力回归 + Phase 1 新增能力
*/
@DisplayName("DocumentChunkService 分片策略")
class DocumentChunkServiceTest {
private DocumentChunkService service;
private DocumentChunkConfig config;
@BeforeEach
void setUp() {
config = new DocumentChunkConfig();
config.setMaxSize(800);
config.setMaxTokens(500);
config.setMaxTokensHard(600);
config.setOverlap(100);
service = new DocumentChunkService();
try {
var field = DocumentChunkService.class.getDeclaredField("chunkConfig");
field.setAccessible(true);
field.set(service, config);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
// ==================== 回归:边界条件 ====================
@Nested
@DisplayName("边界条件")
class BoundaryTests {
@Test
@DisplayName("null 内容 → 空列表")
void nullContent_returnsEmpty() {
List<DocumentChunk> chunks = service.chunkDocument(null, "/test/null.md");
assertTrue(chunks.isEmpty());
}
@Test
@DisplayName("空字符串 → 空列表")
void emptyContent_returnsEmpty() {
List<DocumentChunk> chunks = service.chunkDocument(" \n ", "/test/empty.md");
assertTrue(chunks.isEmpty());
}
@Test
@DisplayName("短文档(≤maxSize)→ 1个分块")
void shortDocument_singleChunk() {
String content = "这是一篇短文档,内容不超过800个字符。";
List<DocumentChunk> chunks = service.chunkDocument(content, "/test/short.md");
assertEquals(1, chunks.size());
assertEquals(content, chunks.get(0).getContent());
assertEquals(0, chunks.get(0).getChunkIndex());
}
@Test
@DisplayName("恰好 maxSize 边界 → 1个分块")
void exactlyMaxSize_singleChunk() {
String content = "A".repeat(800);
List<DocumentChunk> chunks = service.chunkDocument(content, "/test/boundary.md");
assertEquals(1, chunks.size());
}
}
// ==================== 回归:标题分割 ====================
@Nested
@DisplayName("Markdown 标题分割")
class HeadingSplitTests {
@Test
@DisplayName("单个 H1 标题 → section 继承标题")
void singleHeading_titlePropagates() {
String content = "# CPU高负载问题\n\n这是CPU高负载的描述内容。";
List<DocumentChunk> chunks = service.chunkDocument(content, "/test/cpu.md");
assertEquals(1, chunks.size());
assertEquals("CPU高负载问题", chunks.get(0).getTitle());
}
@Test
@DisplayName("多个标题 → 按标题边界分割")
void multipleHeadings_splitAtHeadings() {
String content =
"# CPU高负载\n\nCPU问题的详细描述。\n\n" +
"# 内存高负载\n\n内存问题的详细描述。";
List<DocumentChunk> chunks = service.chunkDocument(content, "/test/multi.md");
assertEquals(2, chunks.size());
assertEquals("CPU高负载", chunks.get(0).getTitle());
assertEquals("内存高负载", chunks.get(1).getTitle());
}
@Test
@DisplayName("多级标题(H1/H2/H3)→ 标题独立不冲突")
void multiLevelHeadings() {
String content =
"# 一级标题\n\n一级内容。\n\n" +
"## 二级标题\n\n二级内容。\n\n" +
"### 三级标题\n\n三级内容。";
List<DocumentChunk> chunks = service.chunkDocument(content, "/test/levels.md");
assertEquals(3, chunks.size());
assertEquals("一级标题", chunks.get(0).getTitle());
assertEquals("二级标题", chunks.get(1).getTitle());
assertEquals("三级标题", chunks.get(2).getTitle());
}
@Test
@DisplayName("H1-H6 全部支持")
void allHeadingLevels() {
StringBuilder sb = new StringBuilder();
for (int i = 1; i <= 6; i++) {
sb.append("#".repeat(i)).append(" 标题").append(i).append("\n\n内容").append(i).append("。\n\n");
}
List<DocumentChunk> chunks = service.chunkDocument(sb.toString(), "/test/h1h6.md");
assertEquals(6, chunks.size());
}
@Test
@DisplayName("无标题文档 → 整个文档作为1个 section")
void noHeadings_entireAsOneSection() {
String content = "纯文本没有标题。\n\n第二段内容。\n\n第三段内容。";
List<DocumentChunk> chunks = service.chunkDocument(content, "/test/nohead.md");
assertFalse(chunks.isEmpty());
assertNull(chunks.get(0).getTitle());
}
}
// ==================== 回归:段落边界切分 ====================
@Nested
@DisplayName("超长章节 — 段落边界切分")
class ParagraphSplitTests {
@Test
@DisplayName("短章节(≤maxSize)→ 不进入段落切割")
void shortSection_noParagraphSplit() {
StringBuilder sb = new StringBuilder();
sb.append("# 测试\n\n");
for (int i = 0; i < 5; i++) {
sb.append("段落").append(i).append(":这是一段短内容。\n\n");
}
List<DocumentChunk> chunks = service.chunkDocument(sb.toString(), "/test/short_sec.md");
assertEquals(1, chunks.size());
}
@Test
@DisplayName("超长章节 → 在段落边界切分")
void longSection_splitsAtParagraphBoundaries() {
config.setMaxSize(50);
config.setMaxTokens(30);
StringBuilder sb = new StringBuilder();
sb.append("# 长章节\n\n");
for (int i = 0; i < 10; i++) {
sb.append("段落").append(i).append(":ABCDEFGHIJKLMNOPQRSTUVWXYZ。\n\n");
}
List<DocumentChunk> chunks = service.chunkDocument(sb.toString(), "/test/long_sec.md");
assertTrue(chunks.size() >= 2, "超长章节应切分为多个分块,实际: " + chunks.size());
// 所有分块携带相同的 title
for (DocumentChunk c : chunks) {
assertEquals("长章节", c.getTitle());
}
}
}
// ==================== 回归:chunkIndex 元数据 ====================
@Nested
@DisplayName("分块元数据")
class ChunkMetadataTests {
@Test
@DisplayName("chunkIndex 自增且唯一")
void chunkIndexSequential() {
config.setMaxSize(50);
config.setMaxTokens(30);
StringBuilder sb = new StringBuilder("# Meta\n\n");
for (int i = 0; i < 10; i++) {
sb.append("段落").append(i).append(":填充内容以触发切分机制。ABCDE。\n\n");
}
List<DocumentChunk> chunks = service.chunkDocument(sb.toString(), "/test/meta.md");
assertTrue(chunks.size() >= 2);
for (int i = 0; i < chunks.size(); i++) {
assertEquals(i, chunks.get(i).getChunkIndex(),
"chunkIndex 应从0开始连续递增");
}
}
@Test
@DisplayName("startIndex/endIndex 范围合法 — 无漂移")
void indexRangeValid_noDrift() {
String content = "# 标题\n\n测试内容。";
List<DocumentChunk> chunks = service.chunkDocument(content, "/test/index.md");
for (DocumentChunk c : chunks) {
assertTrue(c.getStartIndex() >= 0);
assertTrue(c.getEndIndex() > c.getStartIndex(),
"endIndex(" + c.getEndIndex() + ") 应 > startIndex(" + c.getStartIndex() + ")");
assertTrue(c.getEndIndex() <= content.length());
}
}
}
// ==================== 新增:Token 估算 ====================
@Nested
@DisplayName("Token 估算")
class TokenEstimationTests {
@Test
@DisplayName("纯中文 800 字符 ≈ 800 tokens → 短章节不切")
void pureChinese_fewerTokensThanMax() {
config.setMaxTokens(400);
StringBuilder sb = new StringBuilder();
sb.append("# 中文测试\n\n");
// 纯中文 ~300 字符 ≈ 300 tokens
for (int i = 0; i < 3; i++) {
sb.append("这是纯中文测试内容的第十").append(i).append("段落。");
sb.append("每个中文字符大约占用一个令牌的位置。");
sb.append("因此这段文本的令牌数大致等于字符数。\n\n");
}
List<DocumentChunk> chunks = service.chunkDocument(sb.toString(), "/test/cn_tokens.md");
// 300 字符 ≈ 300 tokens < 400 maxTokens → 1 个分块
assertEquals(1, chunks.size());
}
@Test
@DisplayName("纯英文 2000 字符 ≈ 500 tokens → 刚好不超过上限")
void pureEnglish_moreCharactersSameTokens() {
config.setMaxTokens(200);
config.setMaxTokensHard(250);
StringBuilder sb = new StringBuilder();
sb.append("# English Test\n\n");
for (int i = 0; i < 8; i++) {
sb.append("This is paragraph number ").append(i)
.append(" containing English text. ")
.append("English characters are much cheaper in tokens. ")
.append("More filler text here to reach the limit properly. ")
.append("Yet another sentence for good measure. ")
.append("Still more words needed to reach token limit here.\n\n");
}
List<DocumentChunk> chunks = service.chunkDocument(sb.toString(), "/test/en_tokens.md");
// 大量英文才占少量 token → 分块数应少于用字符计数的版本
assertTrue(chunks.size() >= 2, "1200+ 字符英文应切分");
}
}
// ==================== 新增:列表结构感知 ====================
@Nested
@DisplayName("列表结构感知")
class ListStructureTests {
@Test
@DisplayName("有序列表项之间不切分 — 即使超过 maxTokens")
void orderedList_notSplitBetweenItems() {
config.setMaxTokens(80);
config.setMaxTokensHard(200);
config.setOverlap(30);
StringBuilder sb = new StringBuilder();
sb.append("# 排查步骤\n\n");
// 5个有序列表项,每项 ~40 字符 ≈ 40 tokens,总共 ~200 tokens
for (int i = 1; i <= 5; i++) {
sb.append(i).append(". 这是排查步骤第").append(i)
.append("项,包含具体的操作指引和注意事项说明。\n\n");
}
List<DocumentChunk> chunks = service.chunkDocument(sb.toString(), "/test/ordered_list.md");
// 5项应保持在一起(未触及 hard 上限)
assertEquals(1, chunks.size(),
"有序列表项不应被拆散,实际分块数: " + chunks.size());
String content = chunks.get(0).getContent();
assertTrue(content.contains("1. "), "应包含第1项");
assertTrue(content.contains("5. "), "应包含第5项");
}
@Test
@DisplayName("有序列表触及硬上限 → 在列表项边界强制切分")
void orderedList_hardLimitSplits() {
config.setMaxTokens(50);
config.setMaxTokensHard(100);
config.setOverlap(20);
StringBuilder sb = new StringBuilder();
sb.append("# 长列表\n\n");
// 每项 ~60 tokens,硬上限 100 → 最多装 1 项多
for (int i = 1; i <= 6; i++) {
sb.append(i).append(". 这是很长的排查步骤内容,包含详细的说明信息。")
.append("每个步骤都要执行多个检查操作。继续填充文本以增加令牌计数。\n\n");
}
List<DocumentChunk> chunks = service.chunkDocument(sb.toString(), "/test/long_list.md");
System.out.println(" 长列表硬上限测试 — 实际分块数: " + chunks.size());
for (DocumentChunk c : chunks) {
System.out.println(" Chunk #" + c.getChunkIndex() + ": " + c.getContent().length() + "字符 "
+ "| start=" + c.getStartIndex() + " end=" + c.getEndIndex()
+ " | preview=" + c.getContent().substring(0, Math.min(60, c.getContent().length())).replace("\n", "\\n"));
}
// 硬上限会强制切分,但每个分块内的列表项应保持连续
assertTrue(chunks.size() >= 2, "长列表应至少触发1次切分,实际: " + chunks.size());
// 验证:除了第一个分块(可能是标题),其余应包含列表项
for (int i = 1; i < chunks.size(); i++) {
DocumentChunk c = chunks.get(i);
assertFalse(c.getContent().isEmpty());
assertTrue(c.getContent().matches("(?s).*\\d+\\.\\s.*"),
"非标题分块应包含列表项,Chunk #" + c.getChunkIndex()
+ " preview: " + c.getContent().substring(0, Math.min(60, c.getContent().length())));
}
}
@Test
@DisplayName("无序列表项之间不切分")
void unorderedList_notSplitBetweenItems() {
config.setMaxTokens(80);
config.setMaxTokensHard(200);
StringBuilder sb = new StringBuilder();
sb.append("# 检查清单\n\n");
for (int i = 1; i <= 5; i++) {
sb.append("- 检查项").append(i).append(":确认服务运行状态正常并记录相关指标。\n\n");
}
List<DocumentChunk> chunks = service.chunkDocument(sb.toString(), "/test/unordered_list.md");
assertEquals(1, chunks.size(), "无序列表项不应被拆散");
}
@Test
@DisplayName("列表结束后普通段落应从下一段落开始新分块")
void listEnds_normalParagraphStartsNewChunk() {
config.setMaxTokens(150);
config.setMaxTokensHard(250);
StringBuilder sb = new StringBuilder();
sb.append("# 文档\n\n");
// 先一个普通段落
sb.append("这是介绍段落,描述系统的整体架构和设计思路。\n\n");
// 有序列表
for (int i = 1; i <= 3; i++) {
sb.append(i).append(". 列表项第").append(i).append("条,包含操作说明。\n\n");
}
// 普通段落
sb.append("这是总结段落,包含上述操作完成后需要关注的监控指标。\n\n");
List<DocumentChunk> chunks = service.chunkDocument(sb.toString(), "/test/list_mixed.md");
assertTrue(chunks.size() >= 1);
// 列表项应保持在一起
for (DocumentChunk c : chunks) {
String content = c.getContent();
// 分块中不应有孤立的单个列表项(除非只有一个)
if (content.contains("1. ") && content.contains("3. ")) {
// 这个分块包含了全部3个列表项 → 正确
}
}
}
}
// ==================== 新增:代码块结构感知 ====================
@Nested
@DisplayName("代码块结构感知")
class CodeBlockTests {
@Test
@DisplayName("代码块内部不切分")
void codeBlock_notSplitInside() {
config.setMaxTokens(60);
config.setMaxTokensHard(200);
config.setOverlap(20);
String content =
"# 代码示例\n\n" +
"以下是配置代码:\n\n" +
"```yaml\n" +
"server:\n" +
" port: 8080\n" +
" host: localhost\n" +
" timeout: 30s\n" +
"```\n\n" +
"配置说明结束。";
List<DocumentChunk> chunks = service.chunkDocument(content, "/test/code.md");
// 代码块应保持完整(未触及硬上限)
// 验证:至少有一个分块包含完整的 ```...```
boolean foundCompleteBlock = false;
for (DocumentChunk c : chunks) {
String text = c.getContent();
if (text.contains("```yaml") && text.contains("```") &&
text.indexOf("```yaml") < text.lastIndexOf("```")) {
foundCompleteBlock = true;
}
}
// 可能整体在一个分块中
assertTrue(chunks.size() >= 1);
}
}
// ==================== 可视化 ====================
@Nested
@DisplayName("可视化 — 打印切分结果")
class VisualInspectionTests {
@Test
@DisplayName("模拟运维文档 — 展示新策略效果")
void realWorldAIOpsDoc() {
config.setMaxTokens(150);
config.setMaxTokensHard(200);
config.setOverlap(40);
String doc = """
# CPU高负载问题排查指南
## 问题现象
服务器CPU使用率持续超过90%,系统响应变慢,用户反馈页面加载超时。
监控告警系统连续发出多条CPU使用率告警。
## 排查步骤
1. 登录服务器,执行 top 命令查看当前CPU使用率最高的进程。记录进程ID和CPU占用百分比。
2. 使用 ps aux | grep {进程名} 确认相关服务的运行状态。检查是否有异常进程占用资源。
3. 查看应用日志,重点关注最近15分钟的ERROR级别日志。使用 tail -n 500 命令。
4. 检查数据库连接池状态,确认是否有慢查询或连接泄漏。查看慢查询日志。
5. 检查JVM内存使用情况和GC日志。使用 jstat -gcutil {pid} 1000 命令观察GC频率。
## 常见原因
1. 死循环或递归调用导致CPU满载。检查是否有未设置退出条件的循环逻辑。
2. 大量正则表达式匹配操作。检查是否有未编译的正则在循环中使用。
## 解决方案
根据排查结果采取对应措施:代码问题则回滚或热修复;资源不足则扩容。
处理完成后持续观察监控指标30分钟,确认CPU使用率恢复正常。
""";
List<DocumentChunk> chunks = service.chunkDocument(doc, "/kb/cpu_high_usage.md");
System.out.println("========================================");
System.out.println(" Phase 1 新策略效果 — 模拟运维文档");
System.out.println(" 配置: maxTokens=150, hard=200, overlap=40");
System.out.println(" 总字符数: " + doc.length());
System.out.println(" 总分块数: " + chunks.size());
System.out.println("========================================\n");
for (DocumentChunk c : chunks) {
System.out.println("┌─ Chunk #" + c.getChunkIndex());
System.out.println("│ Title: " + (c.getTitle() != null ? c.getTitle() : "(无)"));
System.out.println("│ Range: [" + c.getStartIndex() + "→" + c.getEndIndex() + "] (" + c.getContent().length() + "字符)");
// 显示前150字符
String preview = c.getContent().length() > 120
? c.getContent().substring(0, 120).replace("\n", "\\n") + "..."
: c.getContent().replace("\n", "\\n");
System.out.println("│ Preview: " + preview);
System.out.println("└──────────────────────\n");
}
assertTrue(chunks.size() >= 3, "应产生多个分块");
}
@Test
@DisplayName("中英混排对比 — token vs 字符计数差异")
void mixedContentComparison() {
config.setMaxTokens(100);
config.setMaxTokensHard(150);
config.setOverlap(30);
String chinese = "这是中文内容示范。中文每个字符在LLM中约占用1个token。" +
"因此这段文本在上下文窗口中占用的token数较多。" +
"继续填充文字以触发切分逻辑,验证中文token估算是否合理。" +
"更多中文文本来增加令牌计数。";
String english = "This is English content. Each word may take one or two tokens. " +
"A sentence like this one actually consumes relatively few tokens compared to " +
"Chinese characters. More English text to reach the same token count as above. " +
"Still need more words because English is very efficient in tokenization. " +
"Adding even more content to make this paragraph long enough to test properly.";
List<DocumentChunk> cnChunks = service.chunkDocument("# CN\n\n" + chinese + "\n\n" + chinese, "/test/cn.md");
List<DocumentChunk> enChunks = service.chunkDocument("# EN\n\n" + english + "\n\n" + english, "/test/en.md");
System.out.println("========================================");
System.out.println(" Token 计数对比");
System.out.println(" 配置: maxTokens=100, overlap=30");
System.out.println("========================================");
System.out.println(" 中文文档: " + (chinese.length() * 2) + "字符 → " + cnChunks.size() + "个分块");
System.out.println(" 英文文档: " + (english.length() * 2) + "字符 → " + enChunks.size() + "个分块");
for (DocumentChunk c : cnChunks) {
System.out.println(" 中文Chunk#" + c.getChunkIndex() + ": " + c.getContent().length() + "字符");
}
for (DocumentChunk c : enChunks) {
System.out.println(" 英文Chunk#" + c.getChunkIndex() + ": " + c.getContent().length() + "字符");
}
System.out.println(" ★ 现在中文和英文的分块数更接近(基于 token 而非字符)");
System.out.println("========================================");
}
}
}
@@ -0,0 +1,161 @@
package com.superbiz.agent.service;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* 全链路验证:DeepSeek → BGE-M3 → Milvus
*/
@SpringBootTest
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@DisplayName("DeepSeek → BGE-M3 → Milvus 全链路")
class FullPipelineSmokeTest {
@Autowired
private ChatModel chatModel;
@Autowired
private EmbeddingModel embeddingModel;
@Autowired
private VectorEmbeddingService vectorEmbeddingService;
@Autowired
private VectorSearchService vectorSearchService;
@TestConfiguration
static class MockToolConfig {
@Bean
public ToolCallbackProvider toolCallbackProvider() {
return () -> new ToolCallback[0];
}
}
// ===== ① Chat: DeepSeek =====
@Test
@Order(1)
@DisplayName("Chat: DeepSeek 聊天验证")
void chatDeepSeekWorks() {
System.out.println("\n===== ① Chat: DeepSeek =====");
System.out.println("ChatModel: " + chatModel.getClass().getSimpleName());
System.out.println("ChatOptions: " + chatModel.toString());
// 直接调用 chat
var response = chatModel.call(new Prompt("请用一句话介绍你自己"));
String text = response.getResult().getOutput().getText();
assertNotNull(text);
assertFalse(text.isEmpty());
System.out.println("Response: " + text.substring(0, Math.min(200, text.length())) + "...");
System.out.println("Chat ✓");
}
// ===== ② Embedding: BGE-M3 via SiliconFlow =====
@Test
@Order(2)
@DisplayName("Embedding: BGE-M3 向量生成验证")
void embeddingBgeM3Works() {
System.out.println("\n===== ② Embedding: BGE-M3 (SiliconFlow) =====");
System.out.println("EmbeddingModel: " + embeddingModel.getClass().getSimpleName());
String text = "你好,这是一条测试文本";
List<Float> vector = vectorEmbeddingService.generateEmbedding(text);
assertNotNull(vector);
assertFalse(vector.isEmpty());
assertEquals(1024, vector.size(), "BGE-M3 应返回 1024 维向量");
// 非零校验
boolean hasNonZero = vector.stream().anyMatch(v -> Math.abs(v) > 1e-6);
assertTrue(hasNonZero, "向量不能全为零");
System.out.println("维度: " + vector.size());
System.out.println("前5维: " + vector.subList(0, Math.min(5, vector.size())));
System.out.println("Embedding ✓");
}
@Test
@Order(3)
@DisplayName("Embedding: BGE-M3 批量向量生成验证")
void embeddingBatchWorks() {
System.out.println("\n===== ③ Embedding 批量 =====");
List<String> texts = List.of("文本一", "文本二", "文本三");
List<List<Float>> results = vectorEmbeddingService.generateEmbeddings(texts);
assertEquals(3, results.size());
for (List<Float> r : results) {
assertEquals(1024, r.size());
}
System.out.println("批量生成: " + results.size() + " 个 向量,各 " + results.get(0).size() + " 维 ✓");
}
// ===== ③ Milvus: 向量搜索 =====
@Test
@Order(4)
@DisplayName("Milvus: 连接 + 搜索验证")
void milvusSearchWorks() {
System.out.println("\n===== ④ Milvus: 向量搜索 =====");
// 用 BGE-M3 生成查询向量
String query = "内部文档";
List<Float> queryVector = vectorEmbeddingService.generateQueryVector(query);
assertNotNull(queryVector);
assertEquals(1024, queryVector.size());
// 搜索
List<VectorSearchService.SearchResult> results =
vectorSearchService.searchSimilarDocuments(query, 3);
assertNotNull(results);
System.out.println("查询: " + query);
System.out.println("返回: " + results.size() + " 条");
if (!results.isEmpty()) {
// 至少有结果,验证结构
for (int i = 0; i < results.size(); i++) {
var r = results.get(i);
assertNotNull(r.getId());
assertNotNull(r.getContent());
System.out.println(" [" + (i + 1) + "] id=" + r.getId()
+ ", score=" + String.format("%.4f", r.getScore())
+ ", content=" + r.getContent().substring(0, Math.min(50, r.getContent().length())) + "...");
}
} else {
System.out.println("(Milvus 中暂无数据,但连接正常)");
}
System.out.println("Milvus ✓");
}
// ===== 汇总 =====
@Test
@Order(5)
@DisplayName("总结")
void summary() {
System.out.println("\n==========================================");
System.out.println("全链路验证完成:");
System.out.println(" ① Chat → DeepSeek ✓");
System.out.println(" ② Embedding → BGE-M3 ✓ (SiliconFlow, 1024维)");
System.out.println(" ③ 向量存储 → Milvus ✓ (Zilliz Cloud)");
System.out.println("==========================================");
}
}
@@ -0,0 +1,237 @@
package com.superbiz.agent.service;
import io.milvus.client.MilvusServiceClient;
import io.milvus.grpc.DataType;
import io.milvus.grpc.FlushResponse;
import io.milvus.grpc.MutationResult;
import io.milvus.grpc.SearchResults;
import io.milvus.grpc.ShowCollectionsResponse;
import io.milvus.common.clientenum.ConsistencyLevelEnum;
import io.milvus.param.ConnectParam;
import io.milvus.param.IndexType;
import io.milvus.param.MetricType;
import io.milvus.param.R;
import io.milvus.param.RpcStatus;
import io.milvus.param.collection.*;
import io.milvus.param.dml.InsertParam;
import io.milvus.param.dml.SearchParam;
import io.milvus.param.index.CreateIndexParam;
import io.milvus.response.SearchResultsWrapper;
import org.junit.jupiter.api.*;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.*;
@DisplayName("Milvus 连接验证")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class MilvusConnectionTest {
private static final String COLLECTION = "conn_test";
private static final int DIM = 128;
private static MilvusServiceClient client;
@BeforeAll
static void connect() {
String host = envOrDefault("MILVUS_HOST",
"in03-4a578da0f27ce9d.serverless.aws-eu-central-1.cloud.zilliz.com");
int port = Integer.parseInt(envOrDefault("MILVUS_PORT", "443"));
String token = System.getenv("MILVUS_TOKEN");
assertNotNull(token, "环境变量 MILVUS_TOKEN 未设置");
ConnectParam connectParam = ConnectParam.newBuilder()
.withHost(host)
.withPort(port)
.withToken(token)
.withSecure(true)
.withDatabaseName("db_4a578da0f27ce9d")
.withConnectTimeout(30, TimeUnit.SECONDS)
.build();
client = new MilvusServiceClient(connectParam);
System.out.println("连接目标: " + host + ":" + port);
}
@AfterAll
static void disconnect() {
if (client != null) {
try {
client.dropCollection(DropCollectionParam.newBuilder()
.withCollectionName(COLLECTION).build());
} catch (Exception ignored) {}
client.close();
}
}
private static String safeMsg(R<?> resp) {
try {
return resp.getMessage();
} catch (Exception e) {
return "(no message)";
}
}
@Test
@Order(1)
@DisplayName("1. 连接成功 - 能列出 collection")
void listCollections() {
R<ShowCollectionsResponse> resp = client.showCollections(
ShowCollectionsParam.newBuilder().build());
System.out.println("listCollections status: " + resp.getStatus() + ", msg: " + safeMsg(resp));
assertEquals(0, resp.getStatus(), "连接失败,status=" + resp.getStatus());
List<String> names = resp.getData().getCollectionNamesList();
System.out.println("现有 collections: " + names);
}
@Test
@Order(2)
@DisplayName("2. 创建测试 collection")
void createCollection() {
client.dropCollection(DropCollectionParam.newBuilder()
.withCollectionName(COLLECTION).build());
FieldType idField = FieldType.newBuilder()
.withName("id")
.withDataType(DataType.Int64)
.withPrimaryKey(true)
.withAutoID(true)
.build();
FieldType vectorField = FieldType.newBuilder()
.withName("vector")
.withDataType(DataType.FloatVector)
.withDimension(DIM)
.build();
CollectionSchemaParam schema = CollectionSchemaParam.newBuilder()
.addFieldType(idField)
.addFieldType(vectorField)
.build();
R<RpcStatus> resp = client.createCollection(
CreateCollectionParam.newBuilder()
.withCollectionName(COLLECTION)
.withSchema(schema)
.build());
System.out.println("createCollection status: " + resp.getStatus() + ", msg: " + safeMsg(resp));
assertEquals(0, resp.getStatus(), "创建 collection 失败");
}
@Test
@Order(3)
@DisplayName("3. 插入数据 + flush")
void insertAndFlush() {
List<Float> vec1 = makeVector(1.0f);
List<Float> vec2 = makeVector(2.0f);
List<Float> vec3 = makeVector(3.0f);
List<InsertParam.Field> fields = Collections.singletonList(
new InsertParam.Field("vector", Arrays.asList(vec1, vec2, vec3))
);
R<MutationResult> insertResp = client.insert(
InsertParam.newBuilder()
.withCollectionName(COLLECTION)
.withFields(fields)
.build());
System.out.println("insert status: " + insertResp.getStatus() + ", msg: " + safeMsg(insertResp));
assertEquals(0, insertResp.getStatus(), "插入失败");
// 官方示例要求:insert 后必须 flush,数据才对搜索可见
R<FlushResponse> flushResp = client.flush(FlushParam.newBuilder()
.withCollectionNames(Collections.singletonList(COLLECTION))
.withSyncFlush(true)
.withSyncFlushWaitingTimeout(30L)
.build());
System.out.println("flush status: " + flushResp.getStatus() + ", msg: " + safeMsg(flushResp));
assertEquals(0, flushResp.getStatus(), "flush 失败");
System.out.println("插入 3 条数据并 flush 完成");
}
@Test
@Order(4)
@DisplayName("4. 创建索引 + 加载")
void createIndexAndLoad() {
R<RpcStatus> indexResp = client.createIndex(
CreateIndexParam.newBuilder()
.withCollectionName(COLLECTION)
.withFieldName("vector")
.withIndexType(IndexType.AUTOINDEX)
.withMetricType(MetricType.L2)
.build());
System.out.println("createIndex status: " + indexResp.getStatus() + ", msg: " + safeMsg(indexResp));
assertEquals(0, indexResp.getStatus(), "创建索引失败");
R<RpcStatus> loadResp = client.loadCollection(
LoadCollectionParam.newBuilder()
.withCollectionName(COLLECTION)
.withSyncLoad(true)
.withSyncLoadWaitingTimeout(30L)
.build());
System.out.println("load status: " + loadResp.getStatus() + ", msg: " + safeMsg(loadResp));
assertEquals(0, loadResp.getStatus(), "加载失败");
System.out.println("索引创建 + 加载完成");
}
@Test
@Order(5)
@DisplayName("5. 向量搜索")
void search() throws InterruptedException {
Thread.sleep(3000);
List<Float> queryVec = makeVector(1.1f);
R<SearchResults> resp = null;
for (int retry = 0; retry < 10; retry++) {
resp = client.search(
SearchParam.newBuilder()
.withCollectionName(COLLECTION)
.withMetricType(MetricType.L2)
.withTopK(2)
.withVectors(Collections.singletonList(queryVec))
.withVectorFieldName("vector")
.withParams("{}")
.withConsistencyLevel(ConsistencyLevelEnum.STRONG)
.build());
if (resp.getStatus() == 0) break;
System.out.println("search retry " + (retry + 1) + ": status=" + resp.getStatus() + ", msg=" + safeMsg(resp));
Thread.sleep(5000);
}
System.out.println("search status: " + resp.getStatus() + ", msg: " + safeMsg(resp));
assertEquals(0, resp.getStatus(), "搜索失败");
SearchResultsWrapper wrapper = new SearchResultsWrapper(resp.getData().getResults());
List<SearchResultsWrapper.IDScore> scores = wrapper.getIDScore(0);
assertFalse(scores.isEmpty(), "搜索结果不应为空");
System.out.println("搜索结果 (top " + scores.size() + "):");
for (SearchResultsWrapper.IDScore idScore : scores) {
System.out.println(" score=" + idScore.getScore() + ", id=" + idScore.getLongID());
}
}
private static List<Float> makeVector(float val) {
Float[] arr = new Float[DIM];
Arrays.fill(arr, val);
return Arrays.asList(arr);
}
private static String envOrDefault(String key, String defaultVal) {
String val = System.getenv(key);
return (val != null && !val.isEmpty()) ? val : defaultVal;
}
}
@@ -0,0 +1,244 @@
package com.superbiz.agent.service.session;
import com.superbiz.agent.domain.model.SessionContext;
import com.superbiz.agent.domain.model.ToolCall;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/**
* RedisSessionManager 单元测试
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@TestPropertySource(properties = {
"spring.redis.host=119.29.78.52",
"spring.redis.port=6379",
"spring.redis.password=!Fucker123.."
})
class RedisSessionManagerTest {
@Autowired
private SessionManager sessionManager;
private String testSessionId;
@BeforeEach
void setUp() {
testSessionId = "test-session-" + UUID.randomUUID().toString();
}
@Test
void testCreateAndGetSession() {
// 创建会话
SessionContext context = SessionContext.builder()
.sessionId(testSessionId)
.userId("user-123")
.businessId("order-456")
.traceId("trace-789")
.build();
String sessionId = sessionManager.createSession(context, 300); // 5分钟
assertNotNull(sessionId);
assertEquals(testSessionId, sessionId);
System.out.println("✓ 创建会话成功: " + sessionId);
// 获取会话
Optional<SessionContext> retrieved = sessionManager.getSession(testSessionId);
assertTrue(retrieved.isPresent());
assertEquals("user-123", retrieved.get().getUserId());
assertEquals("ACTIVE", retrieved.get().getStatus());
assertNotNull(retrieved.get().getCreatedAt());
System.out.println("✓ 获取会话成功");
// 清理
sessionManager.deleteSession(testSessionId);
}
@Test
void testUpdateSession() {
// 创建会话
SessionContext context = SessionContext.builder()
.sessionId(testSessionId)
.userId("user-update")
.status("ACTIVE")
.build();
sessionManager.createSession(context, 300);
// 获取并更新
Optional<SessionContext> retrieved = sessionManager.getSession(testSessionId);
assertTrue(retrieved.isPresent());
SessionContext toUpdate = retrieved.get();
toUpdate.setStatus("COMPLETED");
toUpdate.setBusinessId("updated-business-id");
sessionManager.updateSession(toUpdate);
// 验证更新
Optional<SessionContext> updated = sessionManager.getSession(testSessionId);
assertTrue(updated.isPresent());
assertEquals("COMPLETED", updated.get().getStatus());
assertEquals("updated-business-id", updated.get().getBusinessId());
System.out.println("✓ 更新会话成功");
// 清理
sessionManager.deleteSession(testSessionId);
}
@Test
void testDeleteSession() {
// 创建会话
SessionContext context = SessionContext.builder()
.sessionId(testSessionId)
.userId("user-delete")
.build();
sessionManager.createSession(context, 300);
assertTrue(sessionManager.exists(testSessionId));
// 删除会话
sessionManager.deleteSession(testSessionId);
assertFalse(sessionManager.exists(testSessionId));
System.out.println("✓ 删除会话成功");
}
@Test
void testExists() {
assertFalse(sessionManager.exists(testSessionId));
SessionContext context = SessionContext.builder()
.sessionId(testSessionId)
.userId("user-exists")
.build();
sessionManager.createSession(context, 300);
assertTrue(sessionManager.exists(testSessionId));
System.out.println("✓ 会话存在性检查成功");
// 清理
sessionManager.deleteSession(testSessionId);
}
@Test
void testRefreshSession() {
// 创建会话
SessionContext context = SessionContext.builder()
.sessionId(testSessionId)
.userId("user-refresh")
.build();
sessionManager.createSession(context, 60); // 1分钟
// 刷新过期时间
boolean refreshed = sessionManager.refreshSession(testSessionId, 600); // 延长到10分钟
assertTrue(refreshed);
assertTrue(sessionManager.exists(testSessionId));
System.out.println("✓ 刷新会话过期时间成功");
// 清理
sessionManager.deleteSession(testSessionId);
}
@Test
void testAddToolCall() {
// 创建会话
SessionContext context = SessionContext.builder()
.sessionId(testSessionId)
.userId("user-toolcall")
.build();
sessionManager.createSession(context, 300);
// 添加工具调用记录
Map<String, Object> args = new HashMap<>();
args.put("query", "test query");
args.put("limit", 10);
ToolCall toolCall = ToolCall.builder()
.toolName("search_documents")
.arguments(args)
.result("found 5 documents")
.status("SUCCESS")
.duration(150L)
.calledAt(LocalDateTime.now())
.build();
sessionManager.addToolCall(testSessionId, toolCall);
// 验证工具调用已添加
Optional<SessionContext> retrieved = sessionManager.getSession(testSessionId);
assertTrue(retrieved.isPresent());
assertFalse(retrieved.get().getToolCalls().isEmpty());
assertEquals(1, retrieved.get().getToolCalls().size());
assertEquals("search_documents", retrieved.get().getToolCalls().get(0).getToolName());
System.out.println("✓ 添加工具调用记录成功");
// 清理
sessionManager.deleteSession(testSessionId);
}
@Test
void testUpdateStatus() {
// 创建会话
SessionContext context = SessionContext.builder()
.sessionId(testSessionId)
.userId("user-status")
.status("ACTIVE")
.build();
sessionManager.createSession(context, 300);
// 更新状态
sessionManager.updateStatus(testSessionId, "COMPLETED");
// 验证状态已更新
Optional<SessionContext> retrieved = sessionManager.getSession(testSessionId);
assertTrue(retrieved.isPresent());
assertEquals("COMPLETED", retrieved.get().getStatus());
System.out.println("✓ 更新会话状态成功");
// 清理
sessionManager.deleteSession(testSessionId);
}
@Test
void testMultipleToolCalls() {
// 创建会话
SessionContext context = SessionContext.builder()
.sessionId(testSessionId)
.userId("user-multi-tools")
.build();
sessionManager.createSession(context, 300);
// 添加多个工具调用
for (int i = 0; i < 3; i++) {
ToolCall toolCall = ToolCall.builder()
.toolName("tool_" + i)
.status("SUCCESS")
.calledAt(LocalDateTime.now())
.build();
sessionManager.addToolCall(testSessionId, toolCall);
}
// 验证所有工具调用
Optional<SessionContext> retrieved = sessionManager.getSession(testSessionId);
assertTrue(retrieved.isPresent());
assertEquals(3, retrieved.get().getToolCalls().size());
System.out.println("✓ 添加多个工具调用记录成功");
// 清理
sessionManager.deleteSession(testSessionId);
}
}