feat: 支持知识库 CSV 大文件导入

- 增加 CSV 流式解析、表格语义分块和分页预览

- 增加快照两阶段清理、失败重试和格式校验

- 补充批量入口、管理端交互和回归测试
This commit is contained in:
2026-08-07 13:11:59 +08:00
parent 13dec6c216
commit 0d14f1c165
32 changed files with 5672 additions and 156 deletions

View File

@@ -0,0 +1,67 @@
package tech.easyflow.ai.documentimport.task;
import org.junit.Assert;
import org.junit.Test;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Set;
/**
* CSV 导入入口支持策略测试。
*/
public class CsvImportSupportPolicyTest {
/**
* 验证单文件、管理端批次和 Public API 批次三条入口都允许 CSV。
*
* @throws Exception 反射读取失败
*/
@Test
public void shouldAllowCsvAcrossAllKnowledgeImportEntrypoints()
throws Exception {
KnowledgeDocumentImportTaskAppService taskService =
new KnowledgeDocumentImportTaskAppService();
Method assertSupported = KnowledgeDocumentImportTaskAppService.class
.getDeclaredMethod("assertSupportedImportFile", String.class);
assertSupported.setAccessible(true);
Method normalizeExtension =
KnowledgeDocumentImportTaskAppService.class.getDeclaredMethod(
"normalizeFileExtension", String.class, String.class);
normalizeExtension.setAccessible(true);
String lowerCaseExtension = (String) normalizeExtension.invoke(
taskService, "results.csv", "/knowledge/results.csv");
String upperCaseExtension = (String) normalizeExtension.invoke(
taskService, "RESULTS.CSV", "/knowledge/RESULTS.CSV");
Assert.assertEquals("csv", lowerCaseExtension);
Assert.assertEquals("csv", upperCaseExtension);
assertSupported.invoke(taskService, lowerCaseExtension);
assertSupported.invoke(taskService, upperCaseExtension);
Assert.assertTrue(readSupportedExtensions(
DocumentImportBatchAppService.class).contains("csv"));
Assert.assertTrue(readSupportedExtensions(
KnowledgeImportBatchFacade.class).contains("csv"));
Assert.assertSame(
DocumentImportFormatPolicy.supportedExtensions(),
readSupportedExtensions(DocumentImportBatchAppService.class));
Assert.assertSame(
DocumentImportFormatPolicy.supportedExtensions(),
readSupportedExtensions(KnowledgeImportBatchFacade.class));
}
/**
* 读取入口类的支持扩展名集合。
*
* @param type 入口类
* @return 扩展名集合
* @throws Exception 字段读取失败
*/
@SuppressWarnings("unchecked")
private Set<String> readSupportedExtensions(Class<?> type)
throws Exception {
Field field = type.getDeclaredField("SUPPORTED_EXTENSIONS");
field.setAccessible(true);
return (Set<String>) field.get(null);
}
}

View File

@@ -0,0 +1,361 @@
package tech.easyflow.ai.documentimport.task;
import com.easyagents.rag.core.BgeM3ChunkSafety;
import com.easyagents.rag.ingestion.model.StrategyConfig;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.ai.documentimport.DocumentImportDtos;
import tech.easyflow.ai.entity.DocumentChunk;
import java.lang.reflect.Field;
import java.math.BigInteger;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.List;
/**
* {@link CsvTableSnapshotService} 流式解析与分块测试。
*/
public class CsvTableSnapshotServiceTest {
/**
* 验证引号逗号、跨行字段、尾空列和重复表头都能稳定解析并按行窗口分块。
*
* @throws Exception 反射注入失败
*/
@Test
public void shouldParseRfc4180AndBuildRowWindowChunks() throws Exception {
InMemoryFileStorageService storage = new InMemoryFileStorageService();
String sourcePath = "source/demo.csv";
String csv = "姓名,备注,姓名,\r\n"
+ "张三,\"第一行\n第二行\",A,\r\n"
+ "李四,\"含,逗号\",B,\r\n";
storage.put(sourcePath, csv.getBytes(StandardCharsets.UTF_8));
CsvTableSnapshotService service = createService(storage);
CsvTableSnapshotService.CsvParseResult result = service.parse(
sourcePath, BigInteger.valueOf(7), BigInteger.valueOf(9), "token-1");
Assert.assertEquals("UTF-8", result.getEncoding());
Assert.assertEquals(2L, result.getRowCount());
Assert.assertEquals(4, result.getColumnCount());
Assert.assertEquals(
List.of("姓名", "备注", "姓名_2", "列_4"), result.getHeaders());
tech.easyflow.ai.entity.Document document =
new tech.easyflow.ai.entity.Document();
document.setId(BigInteger.valueOf(9));
document.setCollectionId(BigInteger.valueOf(7));
document.setTitle("demo.csv");
document.setDocumentPath(sourcePath);
StrategyConfig strategy = StrategyConfig.defaults();
strategy.setStrategyCode("TABLE_ROW");
strategy.setRowsPerChunk(1);
DocumentImportDtos.PreviewSession session =
service.buildChunkSnapshot(
document, result.getManifestPath(), strategy);
Assert.assertEquals(Integer.valueOf(2), session.getTotalChunks());
Assert.assertEquals(2, session.getDocumentChunks().size());
Assert.assertTrue(session.getDocumentChunks().get(0).getContent()
.contains("第一行<br/>第二行"));
Assert.assertTrue(session.getDocumentChunks().get(1).getContent()
.contains("含,逗号"));
Assert.assertTrue(session.getDocumentChunks().stream()
.allMatch(chunk -> "TABLE_ROW".equals(
chunk.getOptions().get("chunkType"))));
}
/**
* 验证无 BOM 且非 UTF-8 的 CSV 会重新打开源文件并回退到 GB18030。
*
* @throws Exception 反射注入失败
*/
@Test
public void shouldFallbackToGb18030AfterStrictUtf8Failure() throws Exception {
InMemoryFileStorageService storage = new InMemoryFileStorageService();
String sourcePath = "source/gb.csv";
storage.put(
sourcePath,
"名称,说明\r\n测试,中文内容\r\n".getBytes(Charset.forName("GB18030")));
CsvTableSnapshotService service = createService(storage);
CsvTableSnapshotService.CsvParseResult result = service.parse(
sourcePath, BigInteger.valueOf(7), BigInteger.valueOf(10), "token-2");
Assert.assertEquals("GB18030", result.getEncoding());
Assert.assertEquals(1L, result.getRowCount());
}
/**
* 验证数据行列数与表头不一致时返回稳定失败码。
*
* @throws Exception 反射注入失败
*/
@Test
public void shouldRejectColumnMismatchWithStableFailureCode() throws Exception {
InMemoryFileStorageService storage = new InMemoryFileStorageService();
String sourcePath = "source/broken.csv";
storage.put(
sourcePath,
"a,b\r\n1,2,3\r\n".getBytes(StandardCharsets.UTF_8));
CsvTableSnapshotService service = createService(storage);
try {
service.parse(
sourcePath, BigInteger.valueOf(7), BigInteger.valueOf(11), "token-3");
Assert.fail("应拒绝列数不一致的 CSV");
} catch (CsvImportException error) {
Assert.assertEquals(
CsvTableSnapshotService.FAILURE_COLUMN_MISMATCH,
error.getFailureCode());
Assert.assertTrue(error.getMessage().contains("列数"));
}
}
/**
* 验证二进制伪装 CSV 中的 NUL 字节会被明确拒绝。
*
* @throws Exception 反射注入失败
*/
@Test
public void shouldRejectNulByteAsMalformedCsv() throws Exception {
InMemoryFileStorageService storage = new InMemoryFileStorageService();
String sourcePath = "source/binary.csv";
storage.put(
sourcePath,
"name,value\nalice,\0binary\n".getBytes(StandardCharsets.UTF_8));
CsvTableSnapshotService service = createService(storage);
try {
service.parse(
sourcePath, BigInteger.valueOf(7), BigInteger.valueOf(12), "token-4");
Assert.fail("包含 NUL 字节的文件不应进入后续阶段");
} catch (CsvImportException error) {
Assert.assertEquals(
CsvTableSnapshotService.FAILURE_MALFORMED,
error.getFailureCode());
}
}
/**
* 验证超长表格正文会生成多个不超过数据库上限的续片。
*/
@Test
public void shouldSplitOversizedWindowWithoutSilentTruncation() {
TabularRowWindowChunkBuilder builder =
new TabularRowWindowChunkBuilder();
String longValue = "".repeat(
TabularRowWindowChunkBuilder.MAX_CHUNK_CONTENT_CHARS + 100);
List<DocumentChunk> chunks = builder.build(
BigInteger.ONE,
BigInteger.valueOf(2),
"大表",
List.of("正文"),
List.of(new TabularRowWindowChunkBuilder.TabularRow(
2, List.of(longValue))),
1,
"TABLE_ROW");
Assert.assertTrue(chunks.size() > 1);
Assert.assertTrue(chunks.stream().allMatch(
chunk -> chunk.getContent().length()
<= TabularRowWindowChunkBuilder.MAX_CHUNK_CONTENT_CHARS));
Assert.assertTrue(chunks.stream().allMatch(
chunk -> BgeM3ChunkSafety.isWithinHardLimit(chunk.getContent())));
Assert.assertTrue(chunks.stream().allMatch(
chunk -> chunk.getContent().startsWith("# 大表")
&& chunk.getContent().contains("| 正文 |")));
Assert.assertTrue(chunks.stream().allMatch(
chunk -> Integer.valueOf(2).equals(
chunk.getOptions().get("rowStart"))
&& Integer.valueOf(2).equals(
chunk.getOptions().get("rowEnd"))));
long restoredChars = chunks.stream()
.map(DocumentChunk::getContent)
.flatMapToInt(String::chars)
.filter(value -> value == '长')
.count();
Assert.assertEquals(longValue.length(), restoredChars);
Assert.assertEquals(
chunks.size(), chunks.get(0).getOptions().get("partTotal"));
}
/**
* 验证超长记录优先按列边界续片,且每个续片都保留标题和列名。
*/
@Test
public void shouldSplitOversizedRowAtColumnBoundaries() {
TabularRowWindowChunkBuilder builder =
new TabularRowWindowChunkBuilder();
String longValue = "".repeat(20_000);
List<DocumentChunk> chunks = builder.build(
BigInteger.ONE,
BigInteger.valueOf(2),
"列边界表",
List.of("左列", "正文", "右列"),
List.of(new TabularRowWindowChunkBuilder.TabularRow(
8, List.of("LEFT_MARKER", longValue, "RIGHT_MARKER"))),
1,
"TABLE_ROW");
Assert.assertTrue(chunks.size() > 2);
Assert.assertTrue(chunks.stream().allMatch(
chunk -> chunk.getContent().startsWith("# 列边界表")
&& chunk.getContent().contains("\n| ")));
String allContent = chunks.stream()
.map(DocumentChunk::getContent)
.reduce("", String::concat);
Assert.assertEquals(1, occurrences(allContent, "LEFT_MARKER"));
Assert.assertEquals(1, occurrences(allContent, "RIGHT_MARKER"));
Assert.assertEquals(
longValue.length(),
allContent.chars().filter(value -> value == '长').count());
Assert.assertTrue(chunks.stream().allMatch(
chunk -> Integer.valueOf(8).equals(
chunk.getOptions().get("rowStart"))
&& Integer.valueOf(8).equals(
chunk.getOptions().get("rowEnd"))));
}
/**
* 验证行数未满时仍会在加入下一行超过 Token 上限前闭合窗口。
*
* @throws Exception 反射注入失败
*/
@Test
public void shouldCloseWindowBeforeNextRowExceedsTokenLimit()
throws Exception {
InMemoryFileStorageService storage =
new InMemoryFileStorageService();
String sourcePath = "source/token-window.csv";
String value = "".repeat(5_000);
storage.put(
sourcePath,
("正文\n" + value + "\n" + value + "\n")
.getBytes(StandardCharsets.UTF_8));
CsvTableSnapshotService service = createService(storage);
CsvTableSnapshotService.CsvParseResult result = service.parse(
sourcePath,
BigInteger.valueOf(7),
BigInteger.valueOf(13),
"token-window");
tech.easyflow.ai.entity.Document document =
new tech.easyflow.ai.entity.Document();
document.setId(BigInteger.valueOf(13));
document.setCollectionId(BigInteger.valueOf(7));
document.setTitle("token-window.csv");
StrategyConfig strategy = StrategyConfig.defaults();
strategy.setStrategyCode("TABLE_ROW");
strategy.setRowsPerChunk(10);
DocumentImportDtos.PreviewSession session =
service.buildChunkSnapshot(
document, result.getManifestPath(), strategy);
Assert.assertEquals(Integer.valueOf(2), session.getTotalChunks());
Assert.assertEquals(
List.of(2, 3),
session.getDocumentChunks().stream()
.map(chunk -> (Integer) chunk.getOptions().get("rowStart"))
.toList());
}
/**
* 验证 CSV 行分片删除失败时清单保持可重试。
*
* @throws Exception 反射注入失败
*/
@Test
public void shouldKeepCsvManifestWhenPartDeletionFails()
throws Exception {
InMemoryFileStorageService storage =
new InMemoryFileStorageService();
String sourcePath = "source/cleanup.csv";
storage.put(
sourcePath,
"a,b\n1,2\n".getBytes(StandardCharsets.UTF_8));
CsvTableSnapshotService service = createService(storage);
CsvTableSnapshotService.CsvParseResult result = service.parse(
sourcePath,
BigInteger.valueOf(7),
BigInteger.valueOf(14),
"cleanup");
String manifestPath = result.getManifestPath();
String partPath = storage.paths().stream()
.filter(path -> !path.equals(sourcePath))
.filter(path -> !path.equals(manifestPath))
.findFirst()
.orElseThrow();
storage.failDelete(partPath, 1);
try {
service.delete(manifestPath);
Assert.fail("CSV 行分片删除失败时应抛出异常");
} catch (IllegalStateException expected) {
Assert.assertTrue(expected.getMessage().contains("模拟删除失败"));
}
Assert.assertTrue(storage.contains(manifestPath));
Assert.assertTrue(storage.contains(partPath));
service.delete(manifestPath);
Assert.assertEquals(1, storage.size());
Assert.assertTrue(storage.contains(sourcePath));
}
/**
* 统计文本出现次数。
*
* @param content 完整文本
* @param target 目标文本
* @return 出现次数
*/
private int occurrences(String content, String target) {
int count = 0;
int offset = 0;
while ((offset = content.indexOf(target, offset)) >= 0) {
count++;
offset += target.length();
}
return count;
}
/**
* 创建完成依赖注入的被测服务。
*
* @param storage 内存文件存储
* @return CSV 服务
* @throws Exception 反射注入失败
*/
private CsvTableSnapshotService createService(
InMemoryFileStorageService storage) throws Exception {
DocumentImportChunkSnapshotService chunkSnapshotService =
new DocumentImportChunkSnapshotService();
setField(chunkSnapshotService, "storageService", storage);
CsvTableSnapshotService service = new CsvTableSnapshotService();
setField(service, "storageService", storage);
setField(service, "chunkSnapshotService", chunkSnapshotService);
return service;
}
/**
* 反射设置测试字段。
*
* @param target 目标对象
* @param fieldName 字段名
* @param value 字段值
* @throws Exception 字段不存在或不可访问
*/
private void setField(
Object target,
String fieldName,
Object value) throws Exception {
Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
}
}

View File

@@ -2,17 +2,13 @@ package tech.easyflow.ai.documentimport.task;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.Mockito;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.ai.documentimport.DocumentImportDtos;
import tech.easyflow.ai.entity.DocumentChunk;
import tech.easyflow.common.filestorage.FileStorageService;
import java.io.ByteArrayInputStream;
import java.lang.reflect.Field;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
/**
* {@link DocumentImportChunkSnapshotService} 持久化恢复测试。
@@ -26,19 +22,8 @@ public class DocumentImportChunkSnapshotServiceTest {
*/
@Test
public void shouldPersistAndRestoreChunkSnapshot() throws Exception {
String storedPath = "http://localhost/snapshots/9-chunks.json";
AtomicReference<byte[]> storedBytes = new AtomicReference<byte[]>();
FileStorageService storageService = Mockito.mock(FileStorageService.class);
Mockito.when(storageService.save(
Mockito.any(MultipartFile.class),
Mockito.anyString()
)).thenAnswer(invocation -> {
MultipartFile file = invocation.getArgument(0);
storedBytes.set(file.getBytes());
return storedPath;
});
Mockito.when(storageService.readStream(storedPath))
.thenAnswer(invocation -> new ByteArrayInputStream(storedBytes.get()));
InMemoryFileStorageService storageService =
new InMemoryFileStorageService();
DocumentImportChunkSnapshotService service = new DocumentImportChunkSnapshotService();
Field storageField = DocumentImportChunkSnapshotService.class
@@ -59,10 +44,108 @@ public class DocumentImportChunkSnapshotServiceTest {
String path = service.save(session);
DocumentImportDtos.PreviewSession restored = service.load(path);
Assert.assertEquals(storedPath, path);
Assert.assertTrue(path.endsWith("-manifest.json"));
Assert.assertEquals(session.getKnowledgeId(), restored.getKnowledgeId());
Assert.assertEquals(session.getDocumentId(), restored.getDocumentId());
Assert.assertEquals(1, restored.getDocumentChunks().size());
Assert.assertEquals("稳定分块", restored.getDocumentChunks().get(0).getContent());
}
/**
* 验证 V2 快照可分页并按小批顺序消费,删除时同时清理清单和分片。
*
* @throws Exception 反射注入异常
*/
@Test
public void shouldReadV2SnapshotByPageAndBatch() throws Exception {
InMemoryFileStorageService storageService =
new InMemoryFileStorageService();
DocumentImportChunkSnapshotService service =
new DocumentImportChunkSnapshotService();
Field storageField = DocumentImportChunkSnapshotService.class
.getDeclaredField("storageService");
storageField.setAccessible(true);
storageField.set(service, storageService);
DocumentImportDtos.PreviewSession session =
new DocumentImportDtos.PreviewSession();
session.setKnowledgeId(BigInteger.valueOf(7));
session.setDocumentId(BigInteger.valueOf(9));
List<DocumentChunk> chunks = new ArrayList<DocumentChunk>();
for (int index = 0; index < 5; index++) {
DocumentChunk chunk = new DocumentChunk();
chunk.setId(BigInteger.valueOf(100 + index));
chunk.setDocumentId(BigInteger.valueOf(9));
chunk.setDocumentCollectionId(BigInteger.valueOf(7));
chunk.setContent("chunk-" + index);
chunks.add(chunk);
}
session.setDocumentChunks(chunks);
String path = service.save(session);
List<DocumentChunk> page = service.loadPage(path, 2, 2);
List<String> consumed = new ArrayList<String>();
service.forEachBatch(path, 2, batch -> {
Assert.assertTrue(batch.size() <= 2);
for (DocumentChunk chunk : batch) {
consumed.add(chunk.getContent());
}
});
Assert.assertEquals(List.of("chunk-2", "chunk-3"),
page.stream().map(DocumentChunk::getContent).toList());
Assert.assertEquals(
List.of("chunk-0", "chunk-1", "chunk-2", "chunk-3", "chunk-4"),
consumed);
Assert.assertTrue(storageService.size() >= 2);
service.delete(path);
Assert.assertEquals(0, storageService.size());
}
/**
* 验证分片删除失败时保留清单,使后续重试仍能获取精确对象集合。
*
* @throws Exception 反射注入异常
*/
@Test
public void shouldKeepManifestWhenPartDeletionFails() throws Exception {
InMemoryFileStorageService storageService =
new InMemoryFileStorageService();
DocumentImportChunkSnapshotService service =
new DocumentImportChunkSnapshotService();
Field storageField = DocumentImportChunkSnapshotService.class
.getDeclaredField("storageService");
storageField.setAccessible(true);
storageField.set(service, storageService);
DocumentChunk chunk = new DocumentChunk();
chunk.setId(BigInteger.valueOf(21));
chunk.setDocumentId(BigInteger.valueOf(9));
chunk.setDocumentCollectionId(BigInteger.valueOf(7));
chunk.setContent("等待可靠清理");
DocumentImportDtos.PreviewSession session =
new DocumentImportDtos.PreviewSession();
session.setKnowledgeId(BigInteger.valueOf(7));
session.setDocumentId(BigInteger.valueOf(9));
session.setDocumentChunks(List.of(chunk));
String manifestPath = service.save(session);
String partPath = storageService.paths().stream()
.filter(path -> !path.equals(manifestPath))
.findFirst()
.orElseThrow();
storageService.failDelete(partPath, 1);
try {
service.delete(manifestPath);
Assert.fail("分片删除失败时应抛出异常");
} catch (IllegalStateException expected) {
Assert.assertTrue(expected.getMessage().contains("模拟删除失败"));
}
Assert.assertTrue(storageService.contains(manifestPath));
Assert.assertTrue(storageService.contains(partPath));
service.delete(manifestPath);
Assert.assertEquals(0, storageService.size());
}
}

View File

@@ -0,0 +1,237 @@
package tech.easyflow.ai.documentimport.task;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.Test;
import org.mockito.Mockito;
import tech.easyflow.ai.entity.DocumentImportSnapshotCleanup;
import tech.easyflow.ai.mapper.DocumentImportSnapshotCleanupMapper;
import tech.easyflow.ai.mapper.DocumentMapper;
import java.lang.reflect.Field;
import java.math.BigInteger;
import java.util.Date;
import java.util.concurrent.atomic.AtomicReference;
/**
* {@link DocumentImportSnapshotCleanupService} 可靠清理测试。
*/
public class DocumentImportSnapshotCleanupServiceTest {
/**
* 验证分片和清单按持久化阶段顺序删除。
*
* @throws Exception 反射注入失败
*/
@Test
public void shouldPersistPhaseBeforeDeletingManifest()
throws Exception {
CleanupHarness harness = createHarness();
harness.service.scheduleChunkSnapshot(
"snapshot/chunk-manifest.json");
Mockito.verify(harness.chunkSnapshotService).deleteParts(
"snapshot/chunk-manifest.json");
Mockito.verify(harness.cleanupMapper).advanceToManifest(
Mockito.any(BigInteger.class),
Mockito.anyString(),
Mockito.any(Date.class));
Mockito.verify(harness.chunkSnapshotService).deleteManifest(
"snapshot/chunk-manifest.json");
Mockito.verify(harness.cleanupMapper).deleteCompleted(
Mockito.any(BigInteger.class),
Mockito.anyString());
Mockito.verify(harness.cleanupMapper, Mockito.never())
.releaseForRetry(
Mockito.any(BigInteger.class),
Mockito.anyString(),
Mockito.any(Date.class),
Mockito.anyString(),
Mockito.any(Date.class));
}
/**
* 验证分片删除失败后保留记录并安排重试。
*
* @throws Exception 反射注入失败
*/
@Test
public void shouldReleaseFailedCleanupForRetry()
throws Exception {
CleanupHarness harness = createHarness();
Mockito.doThrow(new IllegalStateException("对象存储暂不可用"))
.when(harness.chunkSnapshotService)
.deleteParts("snapshot/failing-manifest.json");
harness.service.scheduleChunkSnapshot(
"snapshot/failing-manifest.json");
Mockito.verify(harness.cleanupMapper).releaseForRetry(
Mockito.any(BigInteger.class),
Mockito.anyString(),
Mockito.any(Date.class),
Mockito.contains("对象存储暂不可用"),
Mockito.any(Date.class));
Mockito.verify(harness.chunkSnapshotService, Mockito.never())
.deleteManifest(Mockito.anyString());
Mockito.verify(harness.cleanupMapper, Mockito.never())
.deleteCompleted(
Mockito.any(BigInteger.class),
Mockito.anyString());
}
/**
* 验证 CSV 清单删除后原子清理文档中的旧快照指针。
*
* @throws Exception 反射注入失败
*/
@Test
public void shouldClearCsvPointerAfterManifestDeletion()
throws Exception {
CleanupHarness harness = createHarness();
BigInteger knowledgeId = BigInteger.valueOf(7);
BigInteger documentId = BigInteger.valueOf(9);
harness.service.scheduleCsvTableSnapshot(
knowledgeId,
documentId,
"snapshot/csv-manifest.json");
Mockito.verify(harness.csvTableSnapshotService).deleteParts(
"snapshot/csv-manifest.json");
Mockito.verify(harness.csvTableSnapshotService).deleteManifest(
"snapshot/csv-manifest.json");
Mockito.verify(harness.documentMapper).clearCsvSnapshotPath(
Mockito.eq(documentId),
Mockito.eq("snapshot/csv-manifest.json"),
Mockito.any(Date.class));
}
/**
* 创建带内存状态的清理服务测试夹具。
*
* @return 测试夹具
* @throws Exception 反射注入失败
*/
@SuppressWarnings("unchecked")
private CleanupHarness createHarness() throws Exception {
DocumentImportSnapshotCleanupService service =
new DocumentImportSnapshotCleanupService();
DocumentImportSnapshotCleanupMapper cleanupMapper =
Mockito.mock(DocumentImportSnapshotCleanupMapper.class);
DocumentImportChunkSnapshotService chunkSnapshotService =
Mockito.mock(DocumentImportChunkSnapshotService.class);
CsvTableSnapshotService csvTableSnapshotService =
Mockito.mock(CsvTableSnapshotService.class);
DocumentMapper documentMapper =
Mockito.mock(DocumentMapper.class);
AtomicReference<DocumentImportSnapshotCleanup> recordRef =
new AtomicReference<DocumentImportSnapshotCleanup>();
Mockito.when(cleanupMapper.insertIgnore(
Mockito.any(DocumentImportSnapshotCleanup.class)))
.thenAnswer(invocation -> {
recordRef.set(invocation.getArgument(0));
return 1;
});
Mockito.when(cleanupMapper.selectOneByQuery(
Mockito.any(QueryWrapper.class)))
.thenAnswer(invocation -> recordRef.get());
Mockito.when(cleanupMapper.claim(
Mockito.any(BigInteger.class),
Mockito.anyString(),
Mockito.any(Date.class),
Mockito.any(Date.class)))
.thenAnswer(invocation -> {
DocumentImportSnapshotCleanup record = recordRef.get();
record.setExecutionToken(invocation.getArgument(1));
record.setAttemptCount(
(record.getAttemptCount() == null
? 0
: record.getAttemptCount()) + 1);
return 1;
});
Mockito.when(cleanupMapper.advanceToManifest(
Mockito.any(BigInteger.class),
Mockito.anyString(),
Mockito.any(Date.class)))
.thenAnswer(invocation -> {
recordRef.get().setPhase("MANIFEST_PENDING");
return 1;
});
Mockito.when(cleanupMapper.deleteCompleted(
Mockito.any(BigInteger.class),
Mockito.anyString())).thenReturn(1);
Mockito.when(cleanupMapper.releaseForRetry(
Mockito.any(BigInteger.class),
Mockito.anyString(),
Mockito.any(Date.class),
Mockito.anyString(),
Mockito.any(Date.class))).thenReturn(1);
setField(service, "cleanupMapper", cleanupMapper);
setField(
service, "chunkSnapshotService", chunkSnapshotService);
setField(
service, "csvTableSnapshotService", csvTableSnapshotService);
setField(service, "documentMapper", documentMapper);
return new CleanupHarness(
service,
cleanupMapper,
chunkSnapshotService,
csvTableSnapshotService,
documentMapper);
}
/**
* 反射设置测试字段。
*
* @param target 目标对象
* @param fieldName 字段名
* @param value 字段值
* @throws Exception 字段不存在或不可访问
*/
private void setField(
Object target,
String fieldName,
Object value) throws Exception {
Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
}
/**
* 快照清理测试依赖集合。
*/
private static final class CleanupHarness {
private final DocumentImportSnapshotCleanupService service;
private final DocumentImportSnapshotCleanupMapper cleanupMapper;
private final DocumentImportChunkSnapshotService
chunkSnapshotService;
private final CsvTableSnapshotService csvTableSnapshotService;
private final DocumentMapper documentMapper;
/**
* 创建测试依赖集合。
*
* @param service 清理服务
* @param cleanupMapper 清理 Mapper
* @param chunkSnapshotService 分块快照服务
* @param csvTableSnapshotService CSV 快照服务
* @param documentMapper 文档 Mapper
*/
private CleanupHarness(
DocumentImportSnapshotCleanupService service,
DocumentImportSnapshotCleanupMapper cleanupMapper,
DocumentImportChunkSnapshotService chunkSnapshotService,
CsvTableSnapshotService csvTableSnapshotService,
DocumentMapper documentMapper) {
this.service = service;
this.cleanupMapper = cleanupMapper;
this.chunkSnapshotService = chunkSnapshotService;
this.csvTableSnapshotService = csvTableSnapshotService;
this.documentMapper = documentMapper;
}
}
}

View File

@@ -0,0 +1,148 @@
package tech.easyflow.ai.documentimport.task;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.common.filestorage.FileStorageService;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 文档导入测试使用的内存文件存储。
*/
final class InMemoryFileStorageService implements FileStorageService {
private final AtomicInteger sequence = new AtomicInteger();
private final Map<String, byte[]> files = new LinkedHashMap<String, byte[]>();
private String failingDeletePath;
private int remainingDeleteFailures;
/**
* 保存到默认测试目录。
*
* @param file 上传文件
* @return 测试路径
*/
@Override
public String save(MultipartFile file) {
return save(file, "default");
}
/**
* 保存到指定测试目录。
*
* @param file 上传文件
* @param prePath 测试目录
* @return 测试路径
*/
@Override
public String save(MultipartFile file, String prePath) {
try {
String path = prePath + "/" + sequence.incrementAndGet()
+ "-" + file.getOriginalFilename();
files.put(path, file.getBytes());
return path;
} catch (IOException error) {
throw new IllegalStateException("测试文件保存失败", error);
}
}
/**
* 删除测试对象。
*
* @param path 测试路径
*/
@Override
public void delete(String path) {
if (remainingDeleteFailures > 0
&& path != null
&& path.equals(failingDeletePath)) {
remainingDeleteFailures--;
throw new IllegalStateException(
"模拟删除失败: " + path);
}
files.remove(path);
}
/**
* 打开测试对象。
*
* @param path 测试路径
* @return 输入流
* @throws IOException 对象不存在
*/
@Override
public InputStream readStream(String path) throws IOException {
byte[] bytes = files.get(path);
if (bytes == null) {
throw new IOException("测试对象不存在: " + path);
}
return new ByteArrayInputStream(bytes);
}
/**
* 返回测试对象大小。
*
* @param path 测试路径
* @return 字节数
*/
@Override
public long getFileSize(String path) {
byte[] bytes = files.get(path);
return bytes == null ? -1L : bytes.length;
}
/**
* 直接放入一个源文件。
*
* @param path 测试路径
* @param bytes 文件内容
*/
void put(String path, byte[] bytes) {
files.put(path, bytes);
}
/**
* 返回当前对象数量。
*
* @return 对象数量
*/
int size() {
return files.size();
}
/**
* 判断测试对象是否存在。
*
* @param path 测试路径
* @return 是否存在
*/
boolean contains(String path) {
return files.containsKey(path);
}
/**
* 返回当前全部测试路径快照。
*
* @return 路径集合
*/
Set<String> paths() {
return new LinkedHashSet<String>(files.keySet());
}
/**
* 配置指定路径接下来若干次删除失败。
*
* @param path 测试路径
* @param failureCount 失败次数
*/
void failDelete(String path, int failureCount) {
failingDeletePath = path;
remainingDeleteFailures = Math.max(0, failureCount);
}
}

View File

@@ -24,6 +24,7 @@ import tech.easyflow.ai.document.exception.DocumentParseBridgeException;
import tech.easyflow.ai.document.model.DocumentParseArtifacts;
import tech.easyflow.ai.document.model.DocumentParsedResult;
import tech.easyflow.ai.document.model.DocumentSourceRef;
import tech.easyflow.ai.documentimport.DocumentImportDtos;
import tech.easyflow.ai.documentimport.DocumentImportKeys;
import tech.easyflow.ai.entity.DocumentChunk;
import tech.easyflow.ai.entity.DocumentCollection;
@@ -68,6 +69,46 @@ import java.util.concurrent.atomic.AtomicReference;
*/
public class KnowledgeDocumentImportTaskAppServiceTest {
/**
* 验证预览翻页直接读取分片快照,避免恢复完整会话。
*
* @throws Exception 反射调用失败
*/
@Test
public void loadPreviewPageShouldReadOnlyRequestedSnapshotRange()
throws Exception {
KnowledgeDocumentImportTaskAppService service =
new KnowledgeDocumentImportTaskAppService();
DocumentImportChunkSnapshotService snapshotService =
Mockito.mock(DocumentImportChunkSnapshotService.class);
setField(
service,
"documentImportChunkSnapshotService",
snapshotService);
DocumentChunk chunk = new DocumentChunk();
chunk.setId(BigInteger.valueOf(9001));
Mockito.when(snapshotService.loadPage("snapshot.json", 20, 20))
.thenReturn(List.of(chunk));
DocumentImportDtos.PreviewSession session =
new DocumentImportDtos.PreviewSession();
session.setChunkSnapshotPath("snapshot.json");
Method method = KnowledgeDocumentImportTaskAppService.class
.getDeclaredMethod(
"loadPreviewPage",
DocumentImportDtos.PreviewSession.class,
int.class,
int.class);
method.setAccessible(true);
@SuppressWarnings("unchecked")
List<DocumentChunk> result = (List<DocumentChunk>) method.invoke(
service, session, 2, 20);
Assert.assertEquals(List.of(chunk), result);
Mockito.verify(snapshotService).loadPage("snapshot.json", 20, 20);
}
/**
* 验证待处理任务重新投递只更新必要字段,避免自定义查询结果覆盖非空列。
*