fix: 统一文档解析文件格式校验
- 统一知识库和工作流支持格式并增加前后端上传拦截 - 拒绝 XLS 与伪装 XLSX,避免空内容解析成功
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
package tech.easyflow.ai.document.support;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 知识库导入与工作流文档解析共用的文件格式策略。
|
||||
*
|
||||
* @author Codex
|
||||
* @since 2026-09-02
|
||||
*/
|
||||
public final class DocumentParseFilePolicy {
|
||||
|
||||
private static final List<String> SUPPORTED_EXTENSION_ORDER =
|
||||
List.of("txt", "pdf", "docx", "md", "pptx", "xlsx", "csv");
|
||||
private static final Set<String> SUPPORTED_EXTENSIONS =
|
||||
Set.copyOf(SUPPORTED_EXTENSION_ORDER);
|
||||
private static final String SUPPORTED_TYPE_LABEL =
|
||||
SUPPORTED_EXTENSION_ORDER.stream()
|
||||
.map(extension -> extension.toUpperCase(Locale.ROOT))
|
||||
.collect(Collectors.joining("、"));
|
||||
|
||||
/**
|
||||
* 禁止实例化格式策略工具类。
|
||||
*/
|
||||
private DocumentParseFilePolicy() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回只读的支持格式集合。
|
||||
*
|
||||
* @return 支持的文件扩展名
|
||||
*/
|
||||
public static Set<String> supportedExtensions() {
|
||||
return SUPPORTED_EXTENSIONS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回面向用户展示的支持格式列表。
|
||||
*
|
||||
* @return 大写扩展名列表
|
||||
*/
|
||||
public static String supportedTypeLabel() {
|
||||
return SUPPORTED_TYPE_LABEL;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断扩展名是否属于文档解析支持范围。
|
||||
*
|
||||
* @param extension 文件扩展名
|
||||
* @return 支持时返回 {@code true}
|
||||
*/
|
||||
public static boolean isSupportedExtension(String extension) {
|
||||
return SUPPORTED_EXTENSIONS.contains(normalizeExtension(extension));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文件名是否属于文档解析支持范围。
|
||||
*
|
||||
* @param fileName 文件名
|
||||
* @return 支持时返回 {@code true}
|
||||
*/
|
||||
public static boolean isSupportedFileName(String fileName) {
|
||||
return isSupportedExtension(extensionOf(fileName));
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取文件名中的小写扩展名。
|
||||
*
|
||||
* @param fileName 文件名
|
||||
* @return 小写扩展名;无扩展名时返回空字符串
|
||||
*/
|
||||
public static String extensionOf(String fileName) {
|
||||
String normalizedName = fileName == null ? "" : fileName.trim();
|
||||
int dotIndex = normalizedName.lastIndexOf('.');
|
||||
return dotIndex < 0 || dotIndex == normalizedName.length() - 1
|
||||
? ""
|
||||
: normalizeExtension(normalizedName.substring(dotIndex + 1));
|
||||
}
|
||||
|
||||
private static String normalizeExtension(String extension) {
|
||||
return extension == null ? "" : extension.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import tech.easyflow.ai.document.support.DocumentParseFilePolicy;
|
||||
import tech.easyflow.ai.documentimport.DocumentImportBatchCreateContext;
|
||||
import tech.easyflow.ai.documentimport.DocumentImportBatchDtos;
|
||||
import tech.easyflow.ai.documentimport.DocumentImportBatchRetryResult;
|
||||
@@ -63,7 +64,7 @@ public class DocumentImportBatchAppService {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(DocumentImportBatchAppService.class);
|
||||
private static final Set<String> SUPPORTED_EXTENSIONS =
|
||||
DocumentImportFormatPolicy.supportedExtensions();
|
||||
DocumentParseFilePolicy.supportedExtensions();
|
||||
private static final Duration BATCH_MUTATION_LOCK_LEASE = Duration.ofMinutes(30);
|
||||
private static final Duration RECOVERY_DISPATCH_LEASE = Duration.ofMinutes(2);
|
||||
private static final Duration RECOVERY_LEASE_RENEW_INTERVAL =
|
||||
@@ -1456,7 +1457,11 @@ public class DocumentImportBatchAppService {
|
||||
int dotIndex = fileName == null ? -1 : fileName.lastIndexOf('.');
|
||||
String extension = dotIndex < 0 ? "" : fileName.substring(dotIndex + 1).toLowerCase(Locale.ROOT);
|
||||
if (!SUPPORTED_EXTENSIONS.contains(extension)) {
|
||||
throw new BusinessException("暂不支持该文件格式");
|
||||
throw new BusinessException(
|
||||
"暂不支持该文件格式,仅支持 "
|
||||
+ DocumentParseFilePolicy.supportedTypeLabel()
|
||||
+ " 文件"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
package tech.easyflow.ai.documentimport.task;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 知识库文档导入格式统一策略。
|
||||
*
|
||||
* @author Codex
|
||||
* @since 2026-08-04
|
||||
*/
|
||||
public final class DocumentImportFormatPolicy {
|
||||
|
||||
private static final Set<String> SUPPORTED_EXTENSIONS =
|
||||
Set.of("txt", "pdf", "docx", "md", "pptx", "xlsx", "csv");
|
||||
|
||||
/**
|
||||
* 禁止实例化格式策略工具类。
|
||||
*/
|
||||
private DocumentImportFormatPolicy() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回只读的支持格式集合。
|
||||
*
|
||||
* @return 支持的文件扩展名
|
||||
*/
|
||||
public static Set<String> supportedExtensions() {
|
||||
return SUPPORTED_EXTENSIONS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断扩展名是否属于知识库导入支持范围。
|
||||
*
|
||||
* @param extension 已转换为小写的文件扩展名
|
||||
* @return 支持时返回 {@code true}
|
||||
*/
|
||||
public static boolean isSupported(String extension) {
|
||||
return SUPPORTED_EXTENSIONS.contains(extension);
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import tech.easyflow.ai.document.model.DocumentSourceRef;
|
||||
import tech.easyflow.ai.document.exception.DocumentParseBridgeException;
|
||||
import tech.easyflow.ai.document.service.DocumentParseBridgeService;
|
||||
import tech.easyflow.ai.document.support.DocumentInputStreamSupport;
|
||||
import tech.easyflow.ai.document.support.DocumentParseFilePolicy;
|
||||
import tech.easyflow.ai.documentimport.DocumentImportDtos;
|
||||
import tech.easyflow.ai.documentimport.DocumentImportKeys;
|
||||
import tech.easyflow.ai.documentimport.DocumentImportPreviewService;
|
||||
@@ -4600,8 +4601,12 @@ public class KnowledgeDocumentImportTaskAppService {
|
||||
}
|
||||
|
||||
private void assertSupportedImportFile(String fileExt) {
|
||||
if (!DocumentImportFormatPolicy.isSupported(fileExt)) {
|
||||
throw new BusinessException("当前仅支持 pdf/docx/txt/md/pptx/xlsx/csv 文档导入");
|
||||
if (!DocumentParseFilePolicy.isSupportedExtension(fileExt)) {
|
||||
throw new BusinessException(
|
||||
"当前仅支持 "
|
||||
+ DocumentParseFilePolicy.supportedTypeLabel()
|
||||
+ " 文档导入"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import org.slf4j.LoggerFactory;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import tech.easyflow.ai.document.support.DocumentParseFilePolicy;
|
||||
import tech.easyflow.ai.documentimport.DocumentImportBatchCreateContext;
|
||||
import tech.easyflow.ai.documentimport.DocumentImportBatchDtos;
|
||||
import tech.easyflow.ai.documentimport.DocumentImportBatchRetryResult;
|
||||
@@ -60,7 +61,7 @@ public class KnowledgeImportBatchFacade {
|
||||
private static final Duration INCOMPLETE_SUBMISSION_TIMEOUT =
|
||||
Duration.ofMinutes(30);
|
||||
private static final Set<String> SUPPORTED_EXTENSIONS =
|
||||
DocumentImportFormatPolicy.supportedExtensions();
|
||||
DocumentParseFilePolicy.supportedExtensions();
|
||||
|
||||
private final DocumentImportBatchAppService batchAppService;
|
||||
private final DocumentImportBatchTracker batchTracker;
|
||||
@@ -889,7 +890,13 @@ public class KnowledgeImportBatchFacade {
|
||||
*/
|
||||
private void assertSupportedExtension(String fileName) {
|
||||
if (!SUPPORTED_EXTENSIONS.contains(extension(fileName))) {
|
||||
throw new BusinessException(415, 41502, "暂不支持该文件格式");
|
||||
throw new BusinessException(
|
||||
415,
|
||||
41502,
|
||||
"暂不支持该文件格式,仅支持 "
|
||||
+ DocumentParseFilePolicy.supportedTypeLabel()
|
||||
+ " 文件"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package tech.easyflow.ai.easyagentsflow.service;
|
||||
|
||||
import com.easyagents.document.core.exception.DocumentParseException;
|
||||
import com.easyagents.flow.core.chain.ChainState;
|
||||
import com.easyagents.flow.core.chain.ExceptionSummary;
|
||||
import com.easyagents.flow.core.chain.NodeState;
|
||||
@@ -165,6 +166,10 @@ public class TinyFlowService {
|
||||
String rootMessage = StringUtil.hasText(error.getRootCauseMessage())
|
||||
? error.getRootCauseMessage()
|
||||
: error.getMessage();
|
||||
if (DocumentParseException.class.getName().equals(rootClass)
|
||||
&& StringUtil.hasText(rootMessage)) {
|
||||
return rootMessage;
|
||||
}
|
||||
if (StringUtil.noText(rootClass)) {
|
||||
return rootMessage;
|
||||
}
|
||||
|
||||
@@ -26,10 +26,6 @@ public class DefaultReadService implements ReadDocService {
|
||||
@Override
|
||||
public String read(String fileName, InputStream is) {
|
||||
String suffix = DocUtil.getSuffix(fileName);
|
||||
if ("pdf".equals(suffix)) {
|
||||
return DocUtil.readPdfFile(is);
|
||||
} else {
|
||||
return DocUtil.readWordFile(suffix, is);
|
||||
}
|
||||
return DocUtil.readPreviewContent(suffix, is);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import tech.easyflow.ai.document.model.DocumentSourceRef;
|
||||
import tech.easyflow.ai.document.exception.DocumentParseBridgeException;
|
||||
import tech.easyflow.ai.document.service.DocumentParseBridgeService;
|
||||
import tech.easyflow.ai.document.support.DocumentInputStreamSupport;
|
||||
import tech.easyflow.ai.document.support.DocumentParseFilePolicy;
|
||||
import tech.easyflow.ai.document.support.DocumentParseSourceType;
|
||||
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadedFileReader;
|
||||
import tech.easyflow.common.filestorage.FileStorageService;
|
||||
@@ -206,6 +207,16 @@ public class DocNodeFileContentExtractor {
|
||||
if (!StringUtil.hasText(sourceRef.getFilePath())) {
|
||||
throw new BusinessException("文件输入缺少 filePath");
|
||||
}
|
||||
if (!DocumentParseFilePolicy.isSupportedFileName(
|
||||
sourceRef.getFileName())) {
|
||||
throw new BusinessException(
|
||||
"文件“"
|
||||
+ sourceRef.getFileName()
|
||||
+ "”格式不支持,文档解析仅支持 "
|
||||
+ DocumentParseFilePolicy.supportedTypeLabel()
|
||||
+ " 文件"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void collectFileValues(Object value, List<Object> result) {
|
||||
@@ -285,8 +296,12 @@ public class DocNodeFileContentExtractor {
|
||||
"document:default-reader");
|
||||
InputStream inputStream =
|
||||
Files.newInputStream(temporaryFile)) {
|
||||
return readerManager.getReader().read(
|
||||
String content = readerManager.getReader().read(
|
||||
sourceRef.getFileName(), inputStream);
|
||||
if (!StringUtil.hasText(content)) {
|
||||
throw new BusinessException("文档解析结果为空");
|
||||
}
|
||||
return content;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
DocumentInputStreamSupport.SizeLimitExceededException sizeError =
|
||||
|
||||
@@ -2,6 +2,7 @@ package tech.easyflow.ai.documentimport.task;
|
||||
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.ai.document.support.DocumentParseFilePolicy;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
@@ -20,6 +21,13 @@ public class CsvImportSupportPolicyTest {
|
||||
@Test
|
||||
public void shouldAllowCsvAcrossAllKnowledgeImportEntrypoints()
|
||||
throws Exception {
|
||||
Assert.assertFalse(
|
||||
DocumentParseFilePolicy.isSupportedFileName("legacy.xls"));
|
||||
Assert.assertTrue(
|
||||
DocumentParseFilePolicy.isSupportedFileName("REPORT.XLSX"));
|
||||
Assert.assertEquals(
|
||||
"TXT、PDF、DOCX、MD、PPTX、XLSX、CSV",
|
||||
DocumentParseFilePolicy.supportedTypeLabel());
|
||||
KnowledgeDocumentImportTaskAppService taskService =
|
||||
new KnowledgeDocumentImportTaskAppService();
|
||||
Method assertSupported = KnowledgeDocumentImportTaskAppService.class
|
||||
@@ -43,10 +51,10 @@ public class CsvImportSupportPolicyTest {
|
||||
Assert.assertTrue(readSupportedExtensions(
|
||||
KnowledgeImportBatchFacade.class).contains("csv"));
|
||||
Assert.assertSame(
|
||||
DocumentImportFormatPolicy.supportedExtensions(),
|
||||
DocumentParseFilePolicy.supportedExtensions(),
|
||||
readSupportedExtensions(DocumentImportBatchAppService.class));
|
||||
Assert.assertSame(
|
||||
DocumentImportFormatPolicy.supportedExtensions(),
|
||||
DocumentParseFilePolicy.supportedExtensions(),
|
||||
readSupportedExtensions(KnowledgeImportBatchFacade.class));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package tech.easyflow.ai.easyagentsflow.service;
|
||||
|
||||
import com.easyagents.document.core.exception.DocumentParseException;
|
||||
import com.easyagents.flow.core.chain.ChainState;
|
||||
import com.easyagents.flow.core.chain.ChainStatus;
|
||||
import com.easyagents.flow.core.chain.ExceptionSummary;
|
||||
@@ -191,6 +192,40 @@ public class TinyFlowServiceTest {
|
||||
result.getNodes().get(NODE_ID).getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证文档格式错误只展示可操作提示,不暴露底层异常类名。
|
||||
*
|
||||
* @throws Exception 测试依赖注入失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldExposeDocumentParseMessageWithoutExceptionClass()
|
||||
throws Exception {
|
||||
ChainExecutor chainExecutor = mock(ChainExecutor.class);
|
||||
ChainStateRepository chainStateRepository =
|
||||
mock(ChainStateRepository.class);
|
||||
NodeStateRepository nodeStateRepository =
|
||||
mock(NodeStateRepository.class);
|
||||
String message = "文件不是标准 XLSX,请另存为 XLSX 后重试";
|
||||
ExceptionSummary error = new ExceptionSummary(
|
||||
new RuntimeException(
|
||||
"文档解析失败",
|
||||
new DocumentParseException(message)));
|
||||
ChainState chainState = new ChainState();
|
||||
chainState.setStatus(ChainStatus.FAILED);
|
||||
chainState.setError(error);
|
||||
when(chainExecutor.getChainStateRepository())
|
||||
.thenReturn(chainStateRepository);
|
||||
when(chainExecutor.getNodeStateRepository())
|
||||
.thenReturn(nodeStateRepository);
|
||||
when(chainStateRepository.load(EXECUTE_ID))
|
||||
.thenReturn(chainState);
|
||||
TinyFlowService service = service(chainExecutor);
|
||||
|
||||
ChainInfo result = service.getChainStatus(EXECUTE_ID, null);
|
||||
|
||||
Assert.assertEquals(message, result.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带指定初始状态的设计器节点。
|
||||
*
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package tech.easyflow.ai.node;
|
||||
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* {@link DefaultReadService} 测试。
|
||||
*/
|
||||
public class DefaultReadServiceTest {
|
||||
|
||||
/**
|
||||
* 验证知识库与工作流共同允许的纯文本格式可以被读取。
|
||||
*/
|
||||
@Test
|
||||
public void shouldReadSupportedPlainTextFormats() {
|
||||
DefaultReadService service = new DefaultReadService();
|
||||
|
||||
for (String fileName : new String[] {"notes.txt", "README.md", "table.csv"}) {
|
||||
String content = service.read(
|
||||
fileName,
|
||||
new ByteArrayInputStream(
|
||||
"标题,内容".getBytes(StandardCharsets.UTF_8)
|
||||
)
|
||||
);
|
||||
|
||||
Assert.assertEquals("标题,内容", content);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,10 +117,10 @@ public class DocNodeFileContentExtractorTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证非桥接类型文件会继续走默认读取器。
|
||||
* 验证支持的纯文本文件会继续走默认读取器。
|
||||
*/
|
||||
@Test
|
||||
public void shouldUseDefaultReaderForUnsupportedType() {
|
||||
public void shouldUseDefaultReaderForPlainText() {
|
||||
RecordingDocumentParseBridgeService bridgeService = new RecordingDocumentParseBridgeService();
|
||||
DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
|
||||
bridgeService,
|
||||
@@ -134,6 +134,36 @@ public class DocNodeFileContentExtractorTest {
|
||||
Assert.assertNull(bridgeService.lastSource);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证旧版 XLS 在进入默认读取器前被明确拒绝。
|
||||
*/
|
||||
@Test
|
||||
public void shouldRejectLegacyXlsBeforeDefaultReader() {
|
||||
RecordingDocumentParseBridgeService bridgeService =
|
||||
new RecordingDocumentParseBridgeService();
|
||||
DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
|
||||
bridgeService,
|
||||
new FakeFileStorageService(),
|
||||
new FakeReaderManager("should not be used")
|
||||
);
|
||||
|
||||
BusinessException error = Assert.assertThrows(
|
||||
BusinessException.class,
|
||||
() -> extractor.extract(buildFileValue(
|
||||
"test.xls",
|
||||
"/files/test.xls",
|
||||
"application/vnd.ms-excel"
|
||||
))
|
||||
);
|
||||
|
||||
Assert.assertEquals(
|
||||
"文件“test.xls”格式不支持,文档解析仅支持 "
|
||||
+ "TXT、PDF、DOCX、MD、PPTX、XLSX、CSV 文件",
|
||||
error.getMessage()
|
||||
);
|
||||
Assert.assertNull(bridgeService.lastSource);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证缺少 filePath 时会抛出明确异常。
|
||||
*/
|
||||
@@ -176,6 +206,29 @@ public class DocNodeFileContentExtractorTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证默认读取器返回空内容时节点不会伪装成功。
|
||||
*/
|
||||
@Test
|
||||
public void shouldFailWhenDefaultReaderResultIsEmpty() {
|
||||
DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
|
||||
new RecordingDocumentParseBridgeService(),
|
||||
new FakeFileStorageService(),
|
||||
new FakeReaderManager(" ")
|
||||
);
|
||||
|
||||
BusinessException error = Assert.assertThrows(
|
||||
BusinessException.class,
|
||||
() -> extractor.extract(buildFileValue(
|
||||
"empty.txt",
|
||||
"/files/empty.txt",
|
||||
"text/plain"
|
||||
))
|
||||
);
|
||||
|
||||
Assert.assertEquals("文档解析结果为空", error.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证普通远端素材 URL 的非桥接文件仍拒绝访问回环地址。
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user