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.support.TransactionSynchronizationManager;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import tech.easyflow.ai.document.support.DocumentParseFilePolicy;
|
||||||
import tech.easyflow.ai.documentimport.DocumentImportBatchCreateContext;
|
import tech.easyflow.ai.documentimport.DocumentImportBatchCreateContext;
|
||||||
import tech.easyflow.ai.documentimport.DocumentImportBatchDtos;
|
import tech.easyflow.ai.documentimport.DocumentImportBatchDtos;
|
||||||
import tech.easyflow.ai.documentimport.DocumentImportBatchRetryResult;
|
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 Logger LOG = LoggerFactory.getLogger(DocumentImportBatchAppService.class);
|
||||||
private static final Set<String> SUPPORTED_EXTENSIONS =
|
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 BATCH_MUTATION_LOCK_LEASE = Duration.ofMinutes(30);
|
||||||
private static final Duration RECOVERY_DISPATCH_LEASE = Duration.ofMinutes(2);
|
private static final Duration RECOVERY_DISPATCH_LEASE = Duration.ofMinutes(2);
|
||||||
private static final Duration RECOVERY_LEASE_RENEW_INTERVAL =
|
private static final Duration RECOVERY_LEASE_RENEW_INTERVAL =
|
||||||
@@ -1456,7 +1457,11 @@ public class DocumentImportBatchAppService {
|
|||||||
int dotIndex = fileName == null ? -1 : fileName.lastIndexOf('.');
|
int dotIndex = fileName == null ? -1 : fileName.lastIndexOf('.');
|
||||||
String extension = dotIndex < 0 ? "" : fileName.substring(dotIndex + 1).toLowerCase(Locale.ROOT);
|
String extension = dotIndex < 0 ? "" : fileName.substring(dotIndex + 1).toLowerCase(Locale.ROOT);
|
||||||
if (!SUPPORTED_EXTENSIONS.contains(extension)) {
|
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.exception.DocumentParseBridgeException;
|
||||||
import tech.easyflow.ai.document.service.DocumentParseBridgeService;
|
import tech.easyflow.ai.document.service.DocumentParseBridgeService;
|
||||||
import tech.easyflow.ai.document.support.DocumentInputStreamSupport;
|
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.DocumentImportDtos;
|
||||||
import tech.easyflow.ai.documentimport.DocumentImportKeys;
|
import tech.easyflow.ai.documentimport.DocumentImportKeys;
|
||||||
import tech.easyflow.ai.documentimport.DocumentImportPreviewService;
|
import tech.easyflow.ai.documentimport.DocumentImportPreviewService;
|
||||||
@@ -4600,8 +4601,12 @@ public class KnowledgeDocumentImportTaskAppService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void assertSupportedImportFile(String fileExt) {
|
private void assertSupportedImportFile(String fileExt) {
|
||||||
if (!DocumentImportFormatPolicy.isSupported(fileExt)) {
|
if (!DocumentParseFilePolicy.isSupportedExtension(fileExt)) {
|
||||||
throw new BusinessException("当前仅支持 pdf/docx/txt/md/pptx/xlsx/csv 文档导入");
|
throw new BusinessException(
|
||||||
|
"当前仅支持 "
|
||||||
|
+ DocumentParseFilePolicy.supportedTypeLabel()
|
||||||
|
+ " 文档导入"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import org.slf4j.LoggerFactory;
|
|||||||
import org.springframework.dao.DuplicateKeyException;
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import tech.easyflow.ai.document.support.DocumentParseFilePolicy;
|
||||||
import tech.easyflow.ai.documentimport.DocumentImportBatchCreateContext;
|
import tech.easyflow.ai.documentimport.DocumentImportBatchCreateContext;
|
||||||
import tech.easyflow.ai.documentimport.DocumentImportBatchDtos;
|
import tech.easyflow.ai.documentimport.DocumentImportBatchDtos;
|
||||||
import tech.easyflow.ai.documentimport.DocumentImportBatchRetryResult;
|
import tech.easyflow.ai.documentimport.DocumentImportBatchRetryResult;
|
||||||
@@ -60,7 +61,7 @@ public class KnowledgeImportBatchFacade {
|
|||||||
private static final Duration INCOMPLETE_SUBMISSION_TIMEOUT =
|
private static final Duration INCOMPLETE_SUBMISSION_TIMEOUT =
|
||||||
Duration.ofMinutes(30);
|
Duration.ofMinutes(30);
|
||||||
private static final Set<String> SUPPORTED_EXTENSIONS =
|
private static final Set<String> SUPPORTED_EXTENSIONS =
|
||||||
DocumentImportFormatPolicy.supportedExtensions();
|
DocumentParseFilePolicy.supportedExtensions();
|
||||||
|
|
||||||
private final DocumentImportBatchAppService batchAppService;
|
private final DocumentImportBatchAppService batchAppService;
|
||||||
private final DocumentImportBatchTracker batchTracker;
|
private final DocumentImportBatchTracker batchTracker;
|
||||||
@@ -889,7 +890,13 @@ public class KnowledgeImportBatchFacade {
|
|||||||
*/
|
*/
|
||||||
private void assertSupportedExtension(String fileName) {
|
private void assertSupportedExtension(String fileName) {
|
||||||
if (!SUPPORTED_EXTENSIONS.contains(extension(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;
|
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.ChainState;
|
||||||
import com.easyagents.flow.core.chain.ExceptionSummary;
|
import com.easyagents.flow.core.chain.ExceptionSummary;
|
||||||
import com.easyagents.flow.core.chain.NodeState;
|
import com.easyagents.flow.core.chain.NodeState;
|
||||||
@@ -165,6 +166,10 @@ public class TinyFlowService {
|
|||||||
String rootMessage = StringUtil.hasText(error.getRootCauseMessage())
|
String rootMessage = StringUtil.hasText(error.getRootCauseMessage())
|
||||||
? error.getRootCauseMessage()
|
? error.getRootCauseMessage()
|
||||||
: error.getMessage();
|
: error.getMessage();
|
||||||
|
if (DocumentParseException.class.getName().equals(rootClass)
|
||||||
|
&& StringUtil.hasText(rootMessage)) {
|
||||||
|
return rootMessage;
|
||||||
|
}
|
||||||
if (StringUtil.noText(rootClass)) {
|
if (StringUtil.noText(rootClass)) {
|
||||||
return rootMessage;
|
return rootMessage;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,10 +26,6 @@ public class DefaultReadService implements ReadDocService {
|
|||||||
@Override
|
@Override
|
||||||
public String read(String fileName, InputStream is) {
|
public String read(String fileName, InputStream is) {
|
||||||
String suffix = DocUtil.getSuffix(fileName);
|
String suffix = DocUtil.getSuffix(fileName);
|
||||||
if ("pdf".equals(suffix)) {
|
return DocUtil.readPreviewContent(suffix, is);
|
||||||
return DocUtil.readPdfFile(is);
|
|
||||||
} else {
|
|
||||||
return DocUtil.readWordFile(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.exception.DocumentParseBridgeException;
|
||||||
import tech.easyflow.ai.document.service.DocumentParseBridgeService;
|
import tech.easyflow.ai.document.service.DocumentParseBridgeService;
|
||||||
import tech.easyflow.ai.document.support.DocumentInputStreamSupport;
|
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.document.support.DocumentParseSourceType;
|
||||||
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadedFileReader;
|
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadedFileReader;
|
||||||
import tech.easyflow.common.filestorage.FileStorageService;
|
import tech.easyflow.common.filestorage.FileStorageService;
|
||||||
@@ -206,6 +207,16 @@ public class DocNodeFileContentExtractor {
|
|||||||
if (!StringUtil.hasText(sourceRef.getFilePath())) {
|
if (!StringUtil.hasText(sourceRef.getFilePath())) {
|
||||||
throw new BusinessException("文件输入缺少 filePath");
|
throw new BusinessException("文件输入缺少 filePath");
|
||||||
}
|
}
|
||||||
|
if (!DocumentParseFilePolicy.isSupportedFileName(
|
||||||
|
sourceRef.getFileName())) {
|
||||||
|
throw new BusinessException(
|
||||||
|
"文件“"
|
||||||
|
+ sourceRef.getFileName()
|
||||||
|
+ "”格式不支持,文档解析仅支持 "
|
||||||
|
+ DocumentParseFilePolicy.supportedTypeLabel()
|
||||||
|
+ " 文件"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void collectFileValues(Object value, List<Object> result) {
|
private void collectFileValues(Object value, List<Object> result) {
|
||||||
@@ -285,8 +296,12 @@ public class DocNodeFileContentExtractor {
|
|||||||
"document:default-reader");
|
"document:default-reader");
|
||||||
InputStream inputStream =
|
InputStream inputStream =
|
||||||
Files.newInputStream(temporaryFile)) {
|
Files.newInputStream(temporaryFile)) {
|
||||||
return readerManager.getReader().read(
|
String content = readerManager.getReader().read(
|
||||||
sourceRef.getFileName(), inputStream);
|
sourceRef.getFileName(), inputStream);
|
||||||
|
if (!StringUtil.hasText(content)) {
|
||||||
|
throw new BusinessException("文档解析结果为空");
|
||||||
|
}
|
||||||
|
return content;
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
DocumentInputStreamSupport.SizeLimitExceededException sizeError =
|
DocumentInputStreamSupport.SizeLimitExceededException sizeError =
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package tech.easyflow.ai.documentimport.task;
|
|||||||
|
|
||||||
import org.junit.Assert;
|
import org.junit.Assert;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
|
import tech.easyflow.ai.document.support.DocumentParseFilePolicy;
|
||||||
|
|
||||||
import java.lang.reflect.Field;
|
import java.lang.reflect.Field;
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
@@ -20,6 +21,13 @@ public class CsvImportSupportPolicyTest {
|
|||||||
@Test
|
@Test
|
||||||
public void shouldAllowCsvAcrossAllKnowledgeImportEntrypoints()
|
public void shouldAllowCsvAcrossAllKnowledgeImportEntrypoints()
|
||||||
throws Exception {
|
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 =
|
KnowledgeDocumentImportTaskAppService taskService =
|
||||||
new KnowledgeDocumentImportTaskAppService();
|
new KnowledgeDocumentImportTaskAppService();
|
||||||
Method assertSupported = KnowledgeDocumentImportTaskAppService.class
|
Method assertSupported = KnowledgeDocumentImportTaskAppService.class
|
||||||
@@ -43,10 +51,10 @@ public class CsvImportSupportPolicyTest {
|
|||||||
Assert.assertTrue(readSupportedExtensions(
|
Assert.assertTrue(readSupportedExtensions(
|
||||||
KnowledgeImportBatchFacade.class).contains("csv"));
|
KnowledgeImportBatchFacade.class).contains("csv"));
|
||||||
Assert.assertSame(
|
Assert.assertSame(
|
||||||
DocumentImportFormatPolicy.supportedExtensions(),
|
DocumentParseFilePolicy.supportedExtensions(),
|
||||||
readSupportedExtensions(DocumentImportBatchAppService.class));
|
readSupportedExtensions(DocumentImportBatchAppService.class));
|
||||||
Assert.assertSame(
|
Assert.assertSame(
|
||||||
DocumentImportFormatPolicy.supportedExtensions(),
|
DocumentParseFilePolicy.supportedExtensions(),
|
||||||
readSupportedExtensions(KnowledgeImportBatchFacade.class));
|
readSupportedExtensions(KnowledgeImportBatchFacade.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package tech.easyflow.ai.easyagentsflow.service;
|
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.ChainState;
|
||||||
import com.easyagents.flow.core.chain.ChainStatus;
|
import com.easyagents.flow.core.chain.ChainStatus;
|
||||||
import com.easyagents.flow.core.chain.ExceptionSummary;
|
import com.easyagents.flow.core.chain.ExceptionSummary;
|
||||||
@@ -191,6 +192,40 @@ public class TinyFlowServiceTest {
|
|||||||
result.getNodes().get(NODE_ID).getMessage());
|
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
|
@Test
|
||||||
public void shouldUseDefaultReaderForUnsupportedType() {
|
public void shouldUseDefaultReaderForPlainText() {
|
||||||
RecordingDocumentParseBridgeService bridgeService = new RecordingDocumentParseBridgeService();
|
RecordingDocumentParseBridgeService bridgeService = new RecordingDocumentParseBridgeService();
|
||||||
DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
|
DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
|
||||||
bridgeService,
|
bridgeService,
|
||||||
@@ -134,6 +134,36 @@ public class DocNodeFileContentExtractorTest {
|
|||||||
Assert.assertNull(bridgeService.lastSource);
|
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 时会抛出明确异常。
|
* 验证缺少 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 的非桥接文件仍拒绝访问回环地址。
|
* 验证普通远端素材 URL 的非桥接文件仍拒绝访问回环地址。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import DragFileUpload from './DragFileUpload.vue';
|
||||||
|
|
||||||
|
vi.mock('@easyflow/hooks', () => ({
|
||||||
|
useAppConfig: () => ({ apiURL: '' }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@easyflow/locales', () => ({
|
||||||
|
$t: (key: string) => key,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@easyflow/stores', () => ({
|
||||||
|
useAccessStore: () => ({ accessToken: 'test-token' }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('#/locales', () => ({
|
||||||
|
$t: (key: string, params?: Record<string, string>) =>
|
||||||
|
key === 'message.upload.unsupportedFileType'
|
||||||
|
? `“${params?.fileName}”格式不支持,仅支持 ${params?.types} 文件。`
|
||||||
|
: key,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('drag file upload', () => {
|
||||||
|
it('uses the shared document formats and rejects XLS drops', async () => {
|
||||||
|
const wrapper = mount(DragFileUpload);
|
||||||
|
const upload = wrapper.getComponent({ name: 'ElUpload' });
|
||||||
|
|
||||||
|
expect(upload.props('accept')).toBe('.txt,.pdf,.docx,.md,.pptx,.xlsx,.csv');
|
||||||
|
const beforeUpload = upload.props('beforeUpload') as (
|
||||||
|
file: File,
|
||||||
|
) => Promise<boolean>;
|
||||||
|
|
||||||
|
await expect(beforeUpload(new File(['legacy'], 'test.xls'))).resolves.toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ import { useAccessStore } from '@easyflow/stores';
|
|||||||
import { UploadFilled } from '@element-plus/icons-vue';
|
import { UploadFilled } from '@element-plus/icons-vue';
|
||||||
import { ElIcon, ElMessage, ElUpload } from 'element-plus';
|
import { ElIcon, ElMessage, ElUpload } from 'element-plus';
|
||||||
|
|
||||||
|
import { DocumentParseFilePolicy } from '#/utils/document-parse-file-policy';
|
||||||
import {
|
import {
|
||||||
normalizeUploadError,
|
normalizeUploadError,
|
||||||
resolveUploadPath,
|
resolveUploadPath,
|
||||||
@@ -28,7 +29,6 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['success', 'error', 'onChange']);
|
const emit = defineEmits(['success', 'error', 'onChange']);
|
||||||
const MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024;
|
const MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024;
|
||||||
const ACCEPTED_FILE_TYPES = '.txt,.pdf,.docx,.md,.pptx,.xlsx';
|
|
||||||
const accessStore = useAccessStore();
|
const accessStore = useAccessStore();
|
||||||
const headers = ref({
|
const headers = ref({
|
||||||
'easyflow-token': accessStore.accessToken,
|
'easyflow-token': accessStore.accessToken,
|
||||||
@@ -54,12 +54,18 @@ const handleError: UploadProps['onError'] = (error) => {
|
|||||||
emit('error', normalizedError);
|
emit('error', normalizedError);
|
||||||
};
|
};
|
||||||
|
|
||||||
const beforeUpload: UploadProps['beforeUpload'] = (rawFile) => {
|
const beforeUpload: UploadProps['beforeUpload'] = async (rawFile) => {
|
||||||
if (rawFile.size <= MAX_FILE_SIZE_BYTES) {
|
try {
|
||||||
return true;
|
await DocumentParseFilePolicy.validateFiles([rawFile]);
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.warning(error?.message || $t('message.notSupported'));
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
if (rawFile.size > MAX_FILE_SIZE_BYTES) {
|
||||||
ElMessage.warning($t('message.upload.fileTooLarge'));
|
ElMessage.warning($t('message.upload.fileTooLarge'));
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 文件状态变化回调
|
// 文件状态变化回调
|
||||||
@@ -97,7 +103,7 @@ defineExpose({
|
|||||||
drag
|
drag
|
||||||
:headers="headers"
|
:headers="headers"
|
||||||
:action="`${apiURL}${props.action}`"
|
:action="`${apiURL}${props.action}`"
|
||||||
:accept="ACCEPTED_FILE_TYPES"
|
:accept="DocumentParseFilePolicy.accept"
|
||||||
:before-upload="beforeUpload"
|
:before-upload="beforeUpload"
|
||||||
:on-success="handleSuccess"
|
:on-success="handleSuccess"
|
||||||
:on-error="handleError"
|
:on-error="handleError"
|
||||||
@@ -111,7 +117,9 @@ defineExpose({
|
|||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<span class="text-base">{{ $t('message.upload.title') }}</span>
|
<span class="text-base">{{ $t('message.upload.title') }}</span>
|
||||||
<span class="text-muted-foreground text-sm">{{
|
<span class="text-muted-foreground text-sm">{{
|
||||||
$t('message.upload.description')
|
$t('message.upload.description', {
|
||||||
|
types: DocumentParseFilePolicy.supportedTypeLabel,
|
||||||
|
})
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
</ElUpload>
|
</ElUpload>
|
||||||
|
|||||||
@@ -79,7 +79,7 @@
|
|||||||
"progressUpload": "Progress of file upload",
|
"progressUpload": "Progress of file upload",
|
||||||
"fileSize": "File size",
|
"fileSize": "File size",
|
||||||
"batchUploadTitle": "Select files or drop them here",
|
"batchUploadTitle": "Select files or drop them here",
|
||||||
"batchUploadDescription": "TXT, PDF, DOCX, MD, PPTX and XLSX. Up to 100MB per file and 1GB per folder.",
|
"batchUploadDescription": "Supported formats: {types}. Up to 100MB per file and 1GB per folder.",
|
||||||
"batchUploadTip": "When upload completes, choose manual or automatic import.",
|
"batchUploadTip": "When upload completes, choose manual or automatic import.",
|
||||||
"selectFolder": "Select Folder",
|
"selectFolder": "Select Folder",
|
||||||
"manualImport": "Manual Import",
|
"manualImport": "Manual Import",
|
||||||
@@ -95,8 +95,8 @@
|
|||||||
"singleFileLimit": "Each file must be no larger than 100MB",
|
"singleFileLimit": "Each file must be no larger than 100MB",
|
||||||
"folderSizeLimit": "The folder must be no larger than 1GB",
|
"folderSizeLimit": "The folder must be no larger than 1GB",
|
||||||
"fileCountLimit": "A batch can contain up to 2000 files",
|
"fileCountLimit": "A batch can contain up to 2000 files",
|
||||||
"noSupportedFiles": "No supported documents found",
|
"noSupportedFiles": "No supported documents found. Supported formats: {types}.",
|
||||||
"unsupportedSkipped": "Unsupported files were skipped",
|
"unsupportedSkipped": "Unsupported files were skipped. Supported formats: {types}.",
|
||||||
"createBatchFailed": "Failed to create upload batch",
|
"createBatchFailed": "Failed to create upload batch",
|
||||||
"cancelBatchFailed": "Failed to cancel the upload batch. Please retry.",
|
"cancelBatchFailed": "Failed to cancel the upload batch. Please retry.",
|
||||||
"uploadFailed": "Upload failed. Please retry.",
|
"uploadFailed": "Upload failed. Please retry.",
|
||||||
|
|||||||
@@ -27,8 +27,13 @@
|
|||||||
"copyFail": "Copy fail",
|
"copyFail": "Copy fail",
|
||||||
"upload": {
|
"upload": {
|
||||||
"title": "Click or drag and drop files here to upload",
|
"title": "Click or drag and drop files here to upload",
|
||||||
"description": "TXT, PDF, DOCX, MD, PPTX, and XLSX files are supported, up to 100 MB each.",
|
"description": "Supported formats: {types}. Up to 100 MB per file.",
|
||||||
"fileTooLarge": "Each file must not exceed 100 MB"
|
"supportedTypes": "Supported formats: {types}.",
|
||||||
|
"singleFileLimit": "Up to 100 MB per file.",
|
||||||
|
"fileTooLarge": "Each file must not exceed 100 MB",
|
||||||
|
"unsupportedFileType": "“{fileName}” is not supported. Supported formats: {types}.",
|
||||||
|
"legacyExcelAsXlsx": "“{fileName}” is not a standard XLSX file. It may be a legacy XLS or an encrypted file. Remove protection and save it as XLSX in Excel/WPS; renaming the extension does not work.",
|
||||||
|
"invalidXlsx": "“{fileName}” is not a standard XLSX file. Its content does not match the extension or the file is damaged. Save it as XLSX in Excel/WPS and try again."
|
||||||
},
|
},
|
||||||
"uploadFileFirst": "Please upload the file first",
|
"uploadFileFirst": "Please upload the file first",
|
||||||
"deleteModelAlert": "This operation will delete the large model. Are you sure to delete it?",
|
"deleteModelAlert": "This operation will delete the large model. Are you sure to delete it?",
|
||||||
|
|||||||
@@ -79,7 +79,7 @@
|
|||||||
"progressUpload": "文件上传进度",
|
"progressUpload": "文件上传进度",
|
||||||
"fileSize": "文件大小",
|
"fileSize": "文件大小",
|
||||||
"batchUploadTitle": "点击选择文件,或将文件拖到这里上传",
|
"batchUploadTitle": "点击选择文件,或将文件拖到这里上传",
|
||||||
"batchUploadDescription": "支持 TXT、PDF、DOCX、MD、PPTX、XLSX;单个文件不超过 100MB,文件夹总大小不超过 1GB。",
|
"batchUploadDescription": "支持 {types} 文件;单个文件不超过 100MB,文件夹总大小不超过 1GB。",
|
||||||
"batchUploadTip": "上传完成后,可选择手动导入或自动导入。",
|
"batchUploadTip": "上传完成后,可选择手动导入或自动导入。",
|
||||||
"selectFolder": "选择文件夹",
|
"selectFolder": "选择文件夹",
|
||||||
"manualImport": "手动导入",
|
"manualImport": "手动导入",
|
||||||
@@ -95,8 +95,8 @@
|
|||||||
"singleFileLimit": "单个文件不能超过 100MB",
|
"singleFileLimit": "单个文件不能超过 100MB",
|
||||||
"folderSizeLimit": "文件夹总大小不能超过 1GB",
|
"folderSizeLimit": "文件夹总大小不能超过 1GB",
|
||||||
"fileCountLimit": "单批次文件数不能超过 2000",
|
"fileCountLimit": "单批次文件数不能超过 2000",
|
||||||
"noSupportedFiles": "未找到支持的文档",
|
"noSupportedFiles": "未找到支持的文档,仅支持 {types} 文件",
|
||||||
"unsupportedSkipped": "已跳过不支持的文件",
|
"unsupportedSkipped": "已跳过不支持的文件;仅支持 {types} 文件",
|
||||||
"createBatchFailed": "创建上传批次失败",
|
"createBatchFailed": "创建上传批次失败",
|
||||||
"cancelBatchFailed": "取消上传批次失败,请重试",
|
"cancelBatchFailed": "取消上传批次失败,请重试",
|
||||||
"uploadFailed": "文件上传失败,请重试",
|
"uploadFailed": "文件上传失败,请重试",
|
||||||
|
|||||||
@@ -27,8 +27,13 @@
|
|||||||
"copyFail": "复制失败",
|
"copyFail": "复制失败",
|
||||||
"upload": {
|
"upload": {
|
||||||
"title": "点击或将文件拖拽到这里上传",
|
"title": "点击或将文件拖拽到这里上传",
|
||||||
"description": "支持 TXT、PDF、DOCX、MD、PPTX、XLSX,单个文件不超过 100 MB。",
|
"description": "支持 {types} 文件,单个文件不超过 100 MB。",
|
||||||
"fileTooLarge": "单个文件大小不能超过 100 MB"
|
"supportedTypes": "支持 {types} 文件",
|
||||||
|
"singleFileLimit": "单个文件不超过 100 MB。",
|
||||||
|
"fileTooLarge": "单个文件大小不能超过 100 MB",
|
||||||
|
"unsupportedFileType": "“{fileName}”格式不支持,仅支持 {types} 文件。",
|
||||||
|
"legacyExcelAsXlsx": "“{fileName}”不是标准 XLSX,可能是旧版 XLS 或已加密文件。请解除保护后用 Excel/WPS 另存为 XLSX(修改文件后缀无效)",
|
||||||
|
"invalidXlsx": "“{fileName}”不是标准 XLSX,文件内容与扩展名不一致或文件已损坏。请用 Excel/WPS 另存为 XLSX 后重试"
|
||||||
},
|
},
|
||||||
"uploadFileFirst": "请先上传文件",
|
"uploadFileFirst": "请先上传文件",
|
||||||
"deleteModelAlert": "该操作会删除大模型,确定删除吗?",
|
"deleteModelAlert": "该操作会删除大模型,确定删除吗?",
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { DocumentParseFilePolicy } from './document-parse-file-policy';
|
||||||
|
|
||||||
|
vi.mock('#/locales', () => ({
|
||||||
|
$t: (key: string, params?: Record<string, string>) => {
|
||||||
|
if (key === 'message.upload.unsupportedFileType') {
|
||||||
|
return `“${params?.fileName}”格式不支持,仅支持 ${params?.types} 文件。`;
|
||||||
|
}
|
||||||
|
if (key === 'message.upload.legacyExcelAsXlsx') {
|
||||||
|
return `“${params?.fileName}”不是标准 XLSX,可能是旧版 XLS 或已加密文件。请解除保护后用 Excel/WPS 另存为 XLSX(修改文件后缀无效)`;
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('document parse file policy', () => {
|
||||||
|
it('provides one accept list for knowledge and workflow uploads', () => {
|
||||||
|
expect(DocumentParseFilePolicy.accept).toBe(
|
||||||
|
'.txt,.pdf,.docx,.md,.pptx,.xlsx,.csv',
|
||||||
|
);
|
||||||
|
expect(DocumentParseFilePolicy.supports('REPORT.XLSX')).toBe(true);
|
||||||
|
expect(DocumentParseFilePolicy.supports('legacy.xls')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a standard XLSX container signature', async () => {
|
||||||
|
const file = new File([new Uint8Array([80, 75, 3, 4, 0])], '清单.XLSX');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
DocumentParseFilePolicy.validateFiles([file]),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unsupported extensions with the supported type list', async () => {
|
||||||
|
await expect(
|
||||||
|
DocumentParseFilePolicy.validateFiles([new File(['xls'], 'test.xls')]),
|
||||||
|
).rejects.toThrow(
|
||||||
|
'“test.xls”格式不支持,仅支持 TXT、PDF、DOCX、MD、PPTX、XLSX、CSV 文件。',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects legacy XLS content disguised as XLSX', async () => {
|
||||||
|
const file = new File(
|
||||||
|
[new Uint8Array([208, 207, 17, 224, 161, 177, 26, 225])],
|
||||||
|
'整理发布清单.xlsx',
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(DocumentParseFilePolicy.validateFiles([file])).rejects.toThrow(
|
||||||
|
'“整理发布清单.xlsx”不是标准 XLSX,可能是旧版 XLS 或已加密文件。请解除保护后用 Excel/WPS 另存为 XLSX(修改文件后缀无效)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
|
const OLE2_SIGNATURE = [208, 207, 17, 224, 161, 177, 26, 225];
|
||||||
|
|
||||||
|
const supportedExtensions = Object.freeze([
|
||||||
|
'txt',
|
||||||
|
'pdf',
|
||||||
|
'docx',
|
||||||
|
'md',
|
||||||
|
'pptx',
|
||||||
|
'xlsx',
|
||||||
|
'csv',
|
||||||
|
]);
|
||||||
|
const supportedExtensionSet = new Set(supportedExtensions);
|
||||||
|
const supportedTypeLabel = supportedExtensions
|
||||||
|
.map((extension) => extension.toUpperCase())
|
||||||
|
.join('、');
|
||||||
|
|
||||||
|
function extensionOf(fileName: string) {
|
||||||
|
const normalizedName = String(fileName || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const dotIndex = normalizedName.lastIndexOf('.');
|
||||||
|
return dotIndex === -1 ? '' : normalizedName.slice(dotIndex + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function supports(fileName: string) {
|
||||||
|
return supportedExtensionSet.has(extensionOf(fileName));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateFiles(files: File[]) {
|
||||||
|
for (const file of files) {
|
||||||
|
const extension = extensionOf(file.name);
|
||||||
|
if (!supportedExtensionSet.has(extension)) {
|
||||||
|
throw new Error(
|
||||||
|
$t('message.upload.unsupportedFileType', {
|
||||||
|
fileName: file.name,
|
||||||
|
types: supportedTypeLabel,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (extension !== 'xlsx') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const prefix = new Uint8Array(await file.slice(0, 8).arrayBuffer());
|
||||||
|
if (hasZipSignature(prefix)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const messageKey = startsWith(prefix, OLE2_SIGNATURE)
|
||||||
|
? 'message.upload.legacyExcelAsXlsx'
|
||||||
|
: 'message.upload.invalidXlsx';
|
||||||
|
throw new Error($t(messageKey, { fileName: file.name }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识库导入与工作流文档解析共用的文件格式策略。
|
||||||
|
*/
|
||||||
|
export const DocumentParseFilePolicy = Object.freeze({
|
||||||
|
accept: supportedExtensions.map((extension) => `.${extension}`).join(','),
|
||||||
|
supportedExtensions,
|
||||||
|
supportedTypeLabel,
|
||||||
|
supports,
|
||||||
|
validateFiles,
|
||||||
|
});
|
||||||
|
|
||||||
|
function hasZipSignature(prefix: Uint8Array) {
|
||||||
|
return (
|
||||||
|
prefix.length >= 4 &&
|
||||||
|
prefix[0] === 80 &&
|
||||||
|
prefix[1] === 75 &&
|
||||||
|
((prefix[2] === 3 && prefix[3] === 4) ||
|
||||||
|
(prefix[2] === 5 && prefix[3] === 6) ||
|
||||||
|
(prefix[2] === 7 && prefix[3] === 8))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startsWith(prefix: Uint8Array, signature: number[]) {
|
||||||
|
return (
|
||||||
|
prefix.length >= signature.length &&
|
||||||
|
signature.every((value, index) => prefix[index] === value)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { shallowMount } from '@vue/test-utils';
|
||||||
|
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import ImportKnowledgeFileContainer from './ImportKnowledgeFileContainer.vue';
|
||||||
|
|
||||||
|
vi.mock('@easyflow/hooks', () => ({
|
||||||
|
useAppConfig: () => ({ apiURL: '' }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@easyflow/locales', () => ({
|
||||||
|
$t: (key: string, params?: Record<string, string>) =>
|
||||||
|
key === 'documentCollection.importDoc.batchUploadDescription'
|
||||||
|
? `支持 ${params?.types} 文件`
|
||||||
|
: key,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@easyflow/stores', () => ({
|
||||||
|
useAccessStore: () => ({ accessToken: 'test-token' }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('#/api/request', () => ({ api: {} }));
|
||||||
|
|
||||||
|
vi.mock('#/locales', () => ({
|
||||||
|
$t: (key: string) => key,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('element-plus/es/components/table-v2/index.mjs', () => ({
|
||||||
|
ElAutoResizer: { template: '<div><slot :height="300" :width="600" /></div>' },
|
||||||
|
ElTableV2: { template: '<div />' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('element-plus/es/components/table-v2/style/css.mjs', () => ({}));
|
||||||
|
|
||||||
|
describe('import knowledge file container', () => {
|
||||||
|
it('uses the shared document formats for file and folder selection', () => {
|
||||||
|
const wrapper = shallowMount(ImportKnowledgeFileContainer, {
|
||||||
|
props: { batchMode: true },
|
||||||
|
});
|
||||||
|
const inputs = wrapper.findAll('input[type="file"]');
|
||||||
|
|
||||||
|
expect(inputs).toHaveLength(2);
|
||||||
|
for (const input of inputs) {
|
||||||
|
expect(input.attributes('accept')).toBe(
|
||||||
|
'.txt,.pdf,.docx,.md,.pptx,.xlsx,.csv',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(wrapper.text()).toContain(
|
||||||
|
'支持 TXT、PDF、DOCX、MD、PPTX、XLSX、CSV 文件',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
import { formatFileSize } from '#/api/common/file';
|
import { formatFileSize } from '#/api/common/file';
|
||||||
import { api } from '#/api/request';
|
import { api } from '#/api/request';
|
||||||
import DragFileUpload from '#/components/upload/DragFileUpload.vue';
|
import DragFileUpload from '#/components/upload/DragFileUpload.vue';
|
||||||
|
import { DocumentParseFilePolicy } from '#/utils/document-parse-file-policy';
|
||||||
|
|
||||||
import { resolveDocumentUploadResponse } from './document-import-upload-response';
|
import { resolveDocumentUploadResponse } from './document-import-upload-response';
|
||||||
|
|
||||||
@@ -80,16 +81,6 @@ const emit = defineEmits<{
|
|||||||
const MAX_FILE_COUNT = 2000;
|
const MAX_FILE_COUNT = 2000;
|
||||||
const MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024;
|
const MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024;
|
||||||
const MAX_TOTAL_SIZE_BYTES = 1024 * 1024 * 1024;
|
const MAX_TOTAL_SIZE_BYTES = 1024 * 1024 * 1024;
|
||||||
const SUPPORTED_EXTENSIONS = new Set([
|
|
||||||
'csv',
|
|
||||||
'docx',
|
|
||||||
'md',
|
|
||||||
'pdf',
|
|
||||||
'pptx',
|
|
||||||
'txt',
|
|
||||||
'xlsx',
|
|
||||||
]);
|
|
||||||
|
|
||||||
const fileData = ref<LegacyFileInfo[]>([]);
|
const fileData = ref<LegacyFileInfo[]>([]);
|
||||||
const filesPath = ref<any[]>([]);
|
const filesPath = ref<any[]>([]);
|
||||||
const dragUploadRef = ref<InstanceType<typeof DragFileUpload>>();
|
const dragUploadRef = ref<InstanceType<typeof DragFileUpload>>();
|
||||||
@@ -331,8 +322,7 @@ async function prepareBatch(files: File[]) {
|
|||||||
ignoredCount++;
|
ignoredCount++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const extension = file.name.split('.').pop()?.toLowerCase() || '';
|
if (!DocumentParseFilePolicy.supports(file.name)) {
|
||||||
if (!SUPPORTED_EXTENSIONS.has(extension)) {
|
|
||||||
ignoredCount++;
|
ignoredCount++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -344,7 +334,11 @@ async function prepareBatch(files: File[]) {
|
|||||||
accepted.push(file);
|
accepted.push(file);
|
||||||
}
|
}
|
||||||
if (accepted.length === 0) {
|
if (accepted.length === 0) {
|
||||||
ElMessage.warning($t('documentCollection.importDoc.noSupportedFiles'));
|
ElMessage.warning(
|
||||||
|
$t('documentCollection.importDoc.noSupportedFiles', {
|
||||||
|
types: DocumentParseFilePolicy.supportedTypeLabel,
|
||||||
|
}),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (accepted.length > MAX_FILE_COUNT) {
|
if (accepted.length > MAX_FILE_COUNT) {
|
||||||
@@ -356,7 +350,17 @@ async function prepareBatch(files: File[]) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ignoredCount > 0) {
|
if (ignoredCount > 0) {
|
||||||
ElMessage.info($t('documentCollection.importDoc.unsupportedSkipped'));
|
ElMessage.info(
|
||||||
|
$t('documentCollection.importDoc.unsupportedSkipped', {
|
||||||
|
types: DocumentParseFilePolicy.supportedTypeLabel,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await DocumentParseFilePolicy.validateFiles(accepted);
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.warning(error?.message || $t('message.notSupported'));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
batchFiles.value = await Promise.all(
|
batchFiles.value = await Promise.all(
|
||||||
@@ -538,7 +542,7 @@ async function createClientFileKey(relativePath: string) {
|
|||||||
class="native-file-input"
|
class="native-file-input"
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
accept=".txt,.pdf,.docx,.md,.pptx,.xlsx,.csv"
|
:accept="DocumentParseFilePolicy.accept"
|
||||||
@change="handleNativeSelection"
|
@change="handleNativeSelection"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
@@ -546,6 +550,7 @@ async function createClientFileKey(relativePath: string) {
|
|||||||
class="native-file-input"
|
class="native-file-input"
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
|
:accept="DocumentParseFilePolicy.accept"
|
||||||
webkitdirectory
|
webkitdirectory
|
||||||
@change="handleNativeSelection"
|
@change="handleNativeSelection"
|
||||||
/>
|
/>
|
||||||
@@ -564,7 +569,11 @@ async function createClientFileKey(relativePath: string) {
|
|||||||
{{ $t('documentCollection.importDoc.batchUploadTitle') }}
|
{{ $t('documentCollection.importDoc.batchUploadTitle') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="batch-drop-zone__description">
|
<div class="batch-drop-zone__description">
|
||||||
{{ $t('documentCollection.importDoc.batchUploadDescription') }}
|
{{
|
||||||
|
$t('documentCollection.importDoc.batchUploadDescription', {
|
||||||
|
types: DocumentParseFilePolicy.supportedTypeLabel,
|
||||||
|
})
|
||||||
|
}}
|
||||||
</div>
|
</div>
|
||||||
<ElButton
|
<ElButton
|
||||||
:icon="FolderOpened"
|
:icon="FolderOpened"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { ElButton, ElIcon, ElLink, ElMessage } from 'element-plus';
|
|||||||
|
|
||||||
import { api } from '#/api/request';
|
import { api } from '#/api/request';
|
||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
|
import { DocumentParseFilePolicy } from '#/utils/document-parse-file-policy';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
appendWorkflowFileValues,
|
appendWorkflowFileValues,
|
||||||
@@ -19,7 +20,6 @@ import {
|
|||||||
normalizeWorkflowFileValues,
|
normalizeWorkflowFileValues,
|
||||||
validateWorkflowFileSelection,
|
validateWorkflowFileSelection,
|
||||||
validateWorkflowFileValues,
|
validateWorkflowFileValues,
|
||||||
WORKFLOW_FILE_LIMITS,
|
|
||||||
} from './workflowFileValue';
|
} from './workflowFileValue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -50,9 +50,6 @@ const fileInputRef = ref<HTMLInputElement | null>(null);
|
|||||||
const currentFiles = computed(() =>
|
const currentFiles = computed(() =>
|
||||||
normalizeWorkflowFileValues(props.modelValue),
|
normalizeWorkflowFileValues(props.modelValue),
|
||||||
);
|
);
|
||||||
const maxSingleFileSizeText = formatWorkflowFileSize(
|
|
||||||
WORKFLOW_FILE_LIMITS.maxSingleSize,
|
|
||||||
).replace('.0 ', ' ');
|
|
||||||
|
|
||||||
function triggerSelectFile() {
|
function triggerSelectFile() {
|
||||||
if (props.disabled || uploadLoading.value) {
|
if (props.disabled || uploadLoading.value) {
|
||||||
@@ -69,6 +66,7 @@ async function uploadFiles(files: File[]) {
|
|||||||
uploadLoading.value = true;
|
uploadLoading.value = true;
|
||||||
try {
|
try {
|
||||||
validateWorkflowFileSelection(currentFiles.value, files);
|
validateWorkflowFileSelection(currentFiles.value, files);
|
||||||
|
await DocumentParseFilePolicy.validateFiles(files);
|
||||||
const uploadedFiles = [];
|
const uploadedFiles = [];
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const res = await api.upload(
|
const res = await api.upload(
|
||||||
@@ -134,6 +132,7 @@ function removeFile(filePath: string) {
|
|||||||
ref="fileInputRef"
|
ref="fileInputRef"
|
||||||
class="workflow-file-input__native"
|
class="workflow-file-input__native"
|
||||||
type="file"
|
type="file"
|
||||||
|
:accept="DocumentParseFilePolicy.accept"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
multiple
|
multiple
|
||||||
@change="handleNativeFileChange"
|
@change="handleNativeFileChange"
|
||||||
@@ -167,7 +166,16 @@ function removeFile(filePath: string) {
|
|||||||
: '拖入文件或点击上传'
|
: '拖入文件或点击上传'
|
||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
<small>单个文件不超过 {{ maxSingleFileSizeText }}</small>
|
<small class="workflow-file-input__hint">
|
||||||
|
<span>
|
||||||
|
{{
|
||||||
|
$t('message.upload.supportedTypes', {
|
||||||
|
types: DocumentParseFilePolicy.supportedTypeLabel,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<span>{{ $t('message.upload.singleFileLimit') }}</span>
|
||||||
|
</small>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -287,13 +295,15 @@ function removeFile(filePath: string) {
|
|||||||
|
|
||||||
.workflow-file-input__dropzone-copy {
|
.workflow-file-input__dropzone-copy {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-flow: row wrap;
|
flex-direction: column;
|
||||||
gap: var(--space-1) var(--space-2);
|
gap: var(--space-1);
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-file-input__dropzone-copy small {
|
.workflow-file-input__hint {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--el-text-color-placeholder);
|
color: var(--el-text-color-placeholder);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,62 @@
|
|||||||
import { mount } from '@vue/test-utils';
|
import { flushPromises, mount } from '@vue/test-utils';
|
||||||
import { defineComponent, nextTick, ref } from 'vue';
|
import { defineComponent, nextTick, ref } from 'vue';
|
||||||
|
|
||||||
import { describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import WorkflowFileInput from '../WorkflowFileInput.vue';
|
import WorkflowFileInput from '../WorkflowFileInput.vue';
|
||||||
|
|
||||||
|
const requestMocks = vi.hoisted(() => ({
|
||||||
|
upload: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('#/api/request', () => ({
|
||||||
|
api: {
|
||||||
|
upload: requestMocks.upload,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('#/locales', () => ({
|
||||||
|
$t: (key: string, params?: Record<string, string>) => {
|
||||||
|
if (key === 'message.upload.supportedTypes') {
|
||||||
|
return `支持 ${params?.types} 文件`;
|
||||||
|
}
|
||||||
|
if (key === 'message.upload.singleFileLimit') {
|
||||||
|
return '单个文件不超过 100 MB。';
|
||||||
|
}
|
||||||
|
if (key === 'message.upload.unsupportedFileType') {
|
||||||
|
return `“${params?.fileName}”格式不支持,仅支持 ${params?.types} 文件。`;
|
||||||
|
}
|
||||||
|
if (key === 'message.upload.legacyExcelAsXlsx') {
|
||||||
|
return `“${params?.fileName}”不是标准 XLSX`;
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
describe('workflow file input', () => {
|
describe('workflow file input', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('limits the file chooser and shows the supported formats', () => {
|
||||||
|
const wrapper = mount(WorkflowFileInput);
|
||||||
|
|
||||||
|
expect(wrapper.get('input[type="file"]').attributes('accept')).toBe(
|
||||||
|
'.txt,.pdf,.docx,.md,.pptx,.xlsx,.csv',
|
||||||
|
);
|
||||||
|
expect(wrapper.text()).toContain(
|
||||||
|
'支持 TXT、PDF、DOCX、MD、PPTX、XLSX、CSV 文件',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
wrapper
|
||||||
|
.findAll('.workflow-file-input__hint > span')
|
||||||
|
.map((item) => item.text()),
|
||||||
|
).toEqual([
|
||||||
|
'支持 TXT、PDF、DOCX、MD、PPTX、XLSX、CSV 文件',
|
||||||
|
'单个文件不超过 100 MB。',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('shows the upload area again after the uploaded file is deleted', async () => {
|
it('shows the upload area again after the uploaded file is deleted', async () => {
|
||||||
const Host = defineComponent({
|
const Host = defineComponent({
|
||||||
components: { WorkflowFileInput },
|
components: { WorkflowFileInput },
|
||||||
@@ -55,4 +106,39 @@ describe('workflow file input', () => {
|
|||||||
wrapper.get('.workflow-file-input__upload-trigger').attributes(),
|
wrapper.get('.workflow-file-input__upload-trigger').attributes(),
|
||||||
).toHaveProperty('disabled');
|
).toHaveProperty('disabled');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects legacy XLS content before starting upload', async () => {
|
||||||
|
requestMocks.upload.mockReset();
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const wrapper = mount(WorkflowFileInput);
|
||||||
|
const input = wrapper.get('input[type="file"]');
|
||||||
|
const file = new File(
|
||||||
|
[new Uint8Array([208, 207, 17, 224, 161, 177, 26, 225])],
|
||||||
|
'legacy.xlsx',
|
||||||
|
);
|
||||||
|
Object.defineProperty(input.element, 'files', {
|
||||||
|
configurable: true,
|
||||||
|
value: [file],
|
||||||
|
});
|
||||||
|
|
||||||
|
await input.trigger('change');
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(requestMocks.upload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a dragged legacy XLS file before starting upload', async () => {
|
||||||
|
requestMocks.upload.mockReset();
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const wrapper = mount(WorkflowFileInput);
|
||||||
|
|
||||||
|
await wrapper.get('.workflow-file-input__dropzone').trigger('drop', {
|
||||||
|
dataTransfer: {
|
||||||
|
files: [new File(['legacy'], 'test.xls')],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(requestMocks.upload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user