fix: 统一文档解析文件格式校验

- 统一知识库和工作流支持格式并增加前后端上传拦截

- 拒绝 XLS 与伪装 XLSX,避免空内容解析成功
This commit is contained in:
2026-09-02 19:15:01 +08:00
parent 36acf37976
commit c3673ece46
24 changed files with 658 additions and 101 deletions

View File

@@ -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);
}
}

View File

@@ -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()
+ " 文件"
);
}
}

View File

@@ -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);
}
}

View File

@@ -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()
+ " 文档导入"
);
}
}

View File

@@ -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()
+ " 文件"
);
}
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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 =

View File

@@ -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));
}

View File

@@ -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());
}
/**
* 创建带指定初始状态的设计器节点。
*

View File

@@ -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);
}
}
}

View File

@@ -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 的非桥接文件仍拒绝访问回环地址。
*/