From 7e1490d5f8fdc0ff43cb24636fb94c734219b7a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Sat, 29 Aug 2026 15:42:07 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=AF=E6=8C=81=E5=8F=AF=E4=BF=A1?= =?UTF-8?q?=E5=86=85=E9=83=A8=E6=96=87=E4=BB=B6=E5=BC=95=E7=94=A8=E8=AF=BB?= =?UTF-8?q?=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 通过服务端文件记录和存储配置恢复可信物理读取句柄 - 文档解析与工作流文档节点优先读取已确认的内部存储对象 - 补齐路径校验、记录异常和读取边界测试 --- .../filestorage/FileStorageManager.java | 14 ++ .../filestorage/FileStorageService.java | 16 ++ .../impl/XFIleStorageServiceImpl.java | 164 +++++++++++++++++- .../filestorage/FileStorageManagerTest.java | 35 ++++ .../impl/XFIleStorageServiceImplTest.java | 137 ++++++++++++++- .../support/DocumentSourceLoader.java | 10 +- .../ai/node/DocNodeFileContentExtractor.java | 35 +++- .../support/DocumentSourceLoaderTest.java | 57 ++++++ .../node/DocNodeFileContentExtractorTest.java | 60 +++++++ 9 files changed, 516 insertions(+), 12 deletions(-) diff --git a/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/FileStorageManager.java b/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/FileStorageManager.java index ce98bf94..5f68e125 100644 --- a/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/FileStorageManager.java +++ b/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/FileStorageManager.java @@ -11,6 +11,7 @@ import java.io.File; import java.io.IOException; import java.io.InputStream; import java.util.Objects; +import java.util.Optional; import java.util.function.Function; import java.util.function.Supplier; @@ -151,6 +152,19 @@ public class FileStorageManager implements FileStorageService { return serviceForHandle(handle).readRecoverable(handle); } + /** + * 使用当前后端解析服务端可信文件引用。 + * + * @param reference 文件 URL 或其他后端可识别引用 + * @return 可信文件的物理读取句柄;引用无法确认时为空 + * @throws IOException 文件记录无法安全解析时抛出 + */ + @Override + public Optional resolveTrustedFile(String reference) + throws IOException { + return currentService().resolveTrustedFile(reference); + } + /** * 严格按句柄中的后端精确删除物理对象。 * diff --git a/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/FileStorageService.java b/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/FileStorageService.java index 50d63fd3..92c54663 100644 --- a/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/FileStorageService.java +++ b/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/FileStorageService.java @@ -5,6 +5,7 @@ import org.springframework.web.multipart.MultipartFile; import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.util.Optional; /** * EasyFlow 文件存储统一接口。 @@ -105,6 +106,21 @@ public interface FileStorageService { throw unsupportedRecoverableOperation("readRecoverable"); } + /** + * 将服务端可信文件引用解析为物理读取句柄。 + * + *

实现必须以服务端持久化记录或存储平台配置为信任来源,并要求外部引用与可信来源 + * 精确匹配;不得仅根据客户端传入的 URL、路径或 locator 构造句柄。

+ * + * @param reference 文件 URL 或其他后端可识别引用 + * @return 可信文件的物理读取句柄;引用无法确认时为空 + * @throws IOException 文件记录损坏或存储配置不兼容时抛出 + */ + default Optional resolveTrustedFile(String reference) + throws IOException { + return Optional.empty(); + } + /** * 精确且幂等地删除句柄对应的物理对象。 * diff --git a/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/impl/XFIleStorageServiceImpl.java b/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/impl/XFIleStorageServiceImpl.java index 710f1bcd..d1182c0a 100644 --- a/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/impl/XFIleStorageServiceImpl.java +++ b/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/impl/XFIleStorageServiceImpl.java @@ -21,6 +21,7 @@ import java.io.*; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.Objects; +import java.util.Optional; /** * 基于 x-file-storage 的 EasyFlow 文件存储实现。 @@ -268,6 +269,47 @@ public class XFIleStorageServiceImpl implements FileStorageService { } } + /** + * 使用 x-file-storage 文件记录或服务端平台配置恢复可信物理读取句柄。 + * + *

优先使用 recorder 的精确记录。记录不存在时,仅允许与已配置平台 domain、basePath + * 及重建后的完整 URL 完全一致的引用。其他 URL 返回空,由上层继续执行公网地址安全校验。

+ * + * @param reference 文件 URL + * @return 可信文件的物理读取句柄;引用无法确认时为空 + * @throws IOException 文件记录损坏或平台配置不兼容时抛出 + */ + @Override + public Optional resolveTrustedFile(String reference) + throws IOException { + if (!StringUtils.hasText(reference)) { + return Optional.empty(); + } + FileInfo fileInfo = null; + try { + fileInfo = fileStorageService.getFileInfoByUrl(reference); + } catch (RuntimeException exception) { + // 存储平台配置本身仍可提供精确可信边界,记录器异常不应阻断内部对象读取。 + LOG.warn("查询 x-file-storage 文件记录失败,继续按服务端存储配置识别,reference={}", + reference, exception); + } + if (fileInfo != null) { + if (!reference.equals(fileInfo.getUrl())) { + throw new IOException("x-file-storage 文件记录 URL 与请求引用不一致"); + } + try { + FileStorageWriteHandle handle = handleFromFileInfo(fileInfo); + FileStorage storage = requireStorage(handle); + requirePersistedBasePathSupport(storage, handle); + verifyRecordedLocation(fileInfo, handle); + return Optional.of(handle); + } catch (RuntimeException exception) { + throw new IOException("x-file-storage 文件记录无法恢复为安全读取位置", exception); + } + } + return resolveConfiguredStorageReference(reference); + } + /** * 直接调用句柄指定平台的物理删除与存在检查,绕过依赖 URL 记录的聚合删除路径。 * @@ -391,6 +433,108 @@ public class XFIleStorageServiceImpl implements FileStorageService { } } + /** + * 校验 recorder 中的物理定位字段可由恢复句柄无损重建。 + * + * @param fileInfo 服务端文件记录 + * @param handle 恢复出的物理读取句柄 + */ + private void verifyRecordedLocation(FileInfo fileInfo, FileStorageWriteHandle handle) { + String actualBasePath = fileInfo.getBasePath() == null ? "" : fileInfo.getBasePath(); + String actualPath = fileInfo.getPath() == null ? "" : fileInfo.getPath(); + if (!handle.getPlatform().equals(fileInfo.getPlatform()) + || !handle.getBasePath().equals(actualBasePath) + || !physicalPath(handle).equals(actualPath) + || !handle.getFilename().equals(fileInfo.getFilename())) { + throw new IllegalStateException("x-file-storage 文件记录包含非规范物理位置"); + } + } + + /** + * 从 FileInfo 的物理定位字段构造严格校验的读取句柄。 + * + * @param fileInfo 服务端文件信息 + * @return 可信物理读取句柄 + */ + private FileStorageWriteHandle handleFromFileInfo(FileInfo fileInfo) { + String basePath = fileInfo.getBasePath() == null ? "" : fileInfo.getBasePath(); + return new FileStorageWriteHandle( + RECOVERABLE_BACKEND, + fileInfo.getPlatform(), + basePath, + recordedRelativePath(basePath, fileInfo.getPath()), + fileInfo.getFilename()); + } + + /** + * 按服务端配置的平台 domain 与 basePath 识别内部存储 URL。 + * + *

解析后会再次通过平台自身的 getFileKey 重建完整 URL 并进行精确比较,避免仅凭 + * host 或字符串前缀放行其他私网目标。

+ * + * @param reference 待识别 URL + * @return 精确匹配配置的读取句柄;不匹配任何平台时为空 + * @throws IOException 匹配平台前缀但路径无法安全恢复时抛出 + */ + private Optional resolveConfiguredStorageReference( + String reference) throws IOException { + if (fileStorageService.getFileStorageList() == null) { + return Optional.empty(); + } + for (FileStorage storage : fileStorageService.getFileStorageList()) { + String domain = readDomainBestEffort(storage); + if (!StringUtils.hasText(domain)) { + continue; + } + String basePath = readRequiredBasePath(storage); + String prefix = domain + basePath; + if (!reference.startsWith(prefix)) { + continue; + } + try { + String remainder = reference.substring(prefix.length()); + int filenameIndex = remainder.lastIndexOf('/') + 1; + FileInfo fileInfo = new FileInfo() + .setUrl(reference) + .setPlatform(storage.getPlatform()) + .setBasePath(basePath) + .setPath(remainder.substring(0, filenameIndex)) + .setFilename(remainder.substring(filenameIndex)); + FileStorageWriteHandle handle = handleFromFileInfo(fileInfo); + requirePersistedBasePathSupport(storage, handle); + verifyRecordedLocation(fileInfo, handle); + if (!reference.equals(deriveUrlBestEffort(storage, toFileInfo(handle)))) { + throw new IllegalArgumentException("重建 URL 与请求引用不一致"); + } + return Optional.of(handle); + } catch (RuntimeException exception) { + throw new IOException("服务端存储 URL 无法恢复为安全读取位置", exception); + } + } + return Optional.empty(); + } + + /** + * 将 recorder 保存的 x-file-storage 物理路径还原为句柄相对路径。 + * + * @param basePath 平台基础路径 + * @param recordedPath recorder 中保存的物理目录 + * @return 不带前导斜杠的相对目录 + */ + private String recordedRelativePath(String basePath, String recordedPath) { + String path = recordedPath == null ? "" : recordedPath; + if (basePath.isEmpty() || basePath.endsWith("/")) { + if (path.startsWith("/")) { + throw new IllegalArgumentException("文件记录路径与平台基础路径格式不一致"); + } + return path; + } + if (!path.startsWith("/")) { + throw new IllegalArgumentException("文件记录路径缺少必要的前导斜杠"); + } + return path.substring(1); + } + /** * 构造仅包含精确物理定位字段的 FileInfo。 * @@ -487,16 +631,26 @@ public class XFIleStorageServiceImpl implements FileStorageService { * @return 可推导 URL;平台不支持时返回 null */ private String deriveUrlBestEffort(FileStorage storage, FileInfo fileInfo) { + String domain = readDomainBestEffort(storage); + if (domain == null) { + return null; + } + return domain + storage.getFileKey(fileInfo); + } + + /** + * 使用平台公开的 getDomain 方法读取文件访问域名。 + * + * @param storage 具体平台存储 + * @return 平台访问域名;平台不支持时返回 null + */ + private String readDomainBestEffort(FileStorage storage) { try { Method method = storage.getClass().getMethod("getDomain"); if (!String.class.equals(method.getReturnType())) { return null; } - String domain = (String) method.invoke(storage); - if (domain == null) { - return null; - } - return domain + storage.getFileKey(fileInfo); + return (String) method.invoke(storage); } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException | RuntimeException exception) { LOG.debug("当前 x-file-storage 平台无法推导 recorder URL: {}", storage.getClass().getName()); return null; diff --git a/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/FileStorageManagerTest.java b/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/FileStorageManagerTest.java index 3671a9dc..54970eb7 100644 --- a/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/FileStorageManagerTest.java +++ b/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/FileStorageManagerTest.java @@ -7,6 +7,7 @@ import java.io.File; import java.io.IOException; import java.io.InputStream; import java.util.Map; +import java.util.Optional; import java.util.concurrent.atomic.AtomicReference; import static org.junit.Assert.assertEquals; @@ -49,6 +50,27 @@ public class FileStorageManagerTest { assertFalse(exists); } + /** + * 验证服务端文件记录解析使用当前配置的具体存储后端。 + * + * @throws IOException 文件记录解析失败时抛出 + */ + @Test + public void recordedFileResolutionUsesCurrentBackend() throws IOException { + RecordingStorage local = new RecordingStorage("local"); + RecordingStorage xFile = new RecordingStorage("xFileStorage"); + FileStorageManager manager = new FileStorageManager( + () -> "xFileStorage", + backend -> Map.of("local", local, "xFileStorage", xFile).get(backend)); + + Optional resolved = manager.resolveTrustedFile( + "http://127.0.0.1:39000/easyflow/attachment/demo.pdf"); + + assertSame(xFile.recordedHandle, resolved.orElseThrow()); + assertEquals(1, xFile.resolveCalls); + assertEquals(0, local.resolveCalls); + } + /** * 可记录可恢复调用的存储测试替身。 */ @@ -57,6 +79,8 @@ public class FileStorageManagerTest { private final String backend; /** 固定结果。 */ private final FileStorageWriteResult result; + /** 固定服务端文件记录句柄。 */ + private final FileStorageWriteHandle recordedHandle; /** 固定可恢复读取流。 */ private final InputStream recoverableInput = InputStream.nullInputStream(); /** prepare 调用次数。 */ @@ -69,6 +93,8 @@ public class FileStorageManagerTest { private int deleteCalls; /** exists 调用次数。 */ private int existsCalls; + /** 服务端文件记录解析调用次数。 */ + private int resolveCalls; /** * 创建指定名称的存储替身。 @@ -80,6 +106,8 @@ public class FileStorageManagerTest { FileStorageWriteHandle handle = new FileStorageWriteHandle( backend, "", "/tmp/easyflow", "skill-content", "content.bin"); this.result = new FileStorageWriteResult("/files/content.bin", handle.encodeLocator()); + this.recordedHandle = new FileStorageWriteHandle( + backend, "", "/tmp/easyflow", "attachment", "demo.pdf"); } /** {@inheritDoc} */ @@ -114,6 +142,13 @@ public class FileStorageManagerTest { return recoverableInput; } + /** {@inheritDoc} */ + @Override + public Optional resolveTrustedFile(String reference) { + resolveCalls++; + return Optional.of(recordedHandle); + } + /** {@inheritDoc} */ @Override public void deleteRecoverable(FileStorageWriteHandle handle) { diff --git a/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/impl/XFIleStorageServiceImplTest.java b/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/impl/XFIleStorageServiceImplTest.java index 193e1585..f0ccf434 100644 --- a/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/impl/XFIleStorageServiceImplTest.java +++ b/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/impl/XFIleStorageServiceImplTest.java @@ -212,6 +212,123 @@ public class XFIleStorageServiceImplTest { client.lastArgs.object()); } + /** + * 验证 recorder 登记的回环地址附件可恢复为可信句柄并通过 MinIO 客户端直读。 + * + * @throws Exception 测试替身配置或流读取失败 + */ + @Test + public void recordedLoopbackUrlUsesExactMinioObject() throws Exception { + byte[] content = "workflow-content".getBytes(java.nio.charset.StandardCharsets.UTF_8); + RecordingMinioClient client = new RecordingMinioClient(content); + MinioFileStorage platform = new MinioFileStorage(); + platform.setPlatform("minio-main"); + platform.setBucketName("easyflow"); + platform.setBasePath("attachment"); + platform.setDomain("http://127.0.0.1:39000/easyflow/"); + platform.setClientFactory(new FixedMinioClientFactory(client)); + RecoverableStorageService delegate = new RecoverableStorageService(platform); + String fileUrl = "http://127.0.0.1:39000/easyflow/attachment/1/2026/8/26/" + + "d6186b17-4ab7-4f99-9299-b19df7ff0a3b/投标文件否决(废标)违规事项汇总.pdf"; + delegate.recordedFileInfo = new FileInfo() + .setUrl(fileUrl) + .setPlatform("minio-main") + .setBasePath("attachment") + .setPath("/1/2026/8/26/d6186b17-4ab7-4f99-9299-b19df7ff0a3b/") + .setFilename("投标文件否决(废标)违规事项汇总.pdf"); + XFIleStorageServiceImpl service = createService(delegate); + + FileStorageWriteHandle handle = service.resolveTrustedFile(fileUrl).orElseThrow(); + byte[] actual; + try (InputStream inputStream = service.readRecoverable(handle)) { + actual = inputStream.readAllBytes(); + } + + assertEquals("attachment", handle.getBasePath()); + assertEquals( + "1/2026/8/26/d6186b17-4ab7-4f99-9299-b19df7ff0a3b/", + handle.getPath()); + assertArrayEquals(content, actual); + assertEquals("easyflow", client.lastArgs.bucket()); + assertEquals( + "attachment/1/2026/8/26/d6186b17-4ab7-4f99-9299-b19df7ff0a3b/" + + "投标文件否决(废标)违规事项汇总.pdf", + client.lastArgs.object()); + } + + /** + * 验证 recorder 没有记录时,服务端配置的存储 URL 仍可通过 MinIO 客户端直读。 + * + * @throws Exception 测试替身配置或流读取失败 + */ + @Test + public void configuredStorageUrlWithoutRecorderUsesExactMinioObject() throws Exception { + byte[] content = "configured-content".getBytes(java.nio.charset.StandardCharsets.UTF_8); + RecordingMinioClient client = new RecordingMinioClient(content); + MinioFileStorage platform = new MinioFileStorage(); + platform.setPlatform("minio-main"); + platform.setBucketName("easyflow"); + platform.setBasePath("attachment"); + platform.setDomain("http://127.0.0.1:39000/easyflow/"); + platform.setClientFactory(new FixedMinioClientFactory(client)); + XFIleStorageServiceImpl service = createService(new RecoverableStorageService(platform)); + String fileUrl = "http://127.0.0.1:39000/easyflow/attachment/1/2026/8/26/" + + "0f0db465-7fa0-46b1-9fae-4f5a8c85f881/投标文件否决(废标)违规事项汇总.pdf"; + + FileStorageWriteHandle handle = service.resolveTrustedFile(fileUrl).orElseThrow(); + byte[] actual; + try (InputStream inputStream = service.readRecoverable(handle)) { + actual = inputStream.readAllBytes(); + } + + assertArrayEquals(content, actual); + assertEquals( + "attachment/1/2026/8/26/0f0db465-7fa0-46b1-9fae-4f5a8c85f881/" + + "投标文件否决(废标)违规事项汇总.pdf", + client.lastArgs.object()); + } + + /** + * 验证未配置为存储地址的回环 URL 不会被识别为可信附件。 + * + * @throws Exception 测试替身注入失败 + */ + @Test + public void unconfiguredLoopbackUrlIsNotResolved() throws Exception { + RecoverablePlatform platform = new RecoverablePlatform( + "minio-main", "attachment", "http://127.0.0.1:39000/easyflow/"); + XFIleStorageServiceImpl service = createService(new RecoverableStorageService(platform)); + + assertTrue(service.resolveTrustedFile( + "http://127.0.0.1:39000/other/unconfigured.pdf").isEmpty()); + } + + /** + * 验证 recorder 中非规范物理路径会失败关闭。 + * + * @throws Exception 测试替身注入失败 + */ + @Test + public void corruptedRecordedLocationIsRejected() throws Exception { + RecoverablePlatform platform = new RecoverablePlatform( + "minio-main", "attachment", "http://127.0.0.1:39000/easyflow/"); + RecoverableStorageService delegate = new RecoverableStorageService(platform); + String fileUrl = "http://127.0.0.1:39000/easyflow/attachment/demo.pdf"; + delegate.recordedFileInfo = new FileInfo() + .setUrl(fileUrl) + .setPlatform("minio-main") + .setBasePath("attachment") + .setPath("missing-leading-slash/") + .setFilename("demo.pdf"); + XFIleStorageServiceImpl service = createService(delegate); + + IOException exception = assertThrows( + IOException.class, + () -> service.resolveTrustedFile(fileUrl)); + + assertTrue(exception.getMessage().contains("无法恢复")); + } + /** * 验证 recorder 完全缺失目标记录时,精确删除仍直接作用于物理平台并成功。 * @@ -471,6 +588,8 @@ public class XFIleStorageServiceImplTest { private int recorderDeleteCalls; /** recorder 删除是否抛出异常。 */ private boolean recorderDeleteThrows; + /** recorder 返回的服务端文件记录。 */ + private FileInfo recordedFileInfo; /** * 创建聚合服务替身。 @@ -479,10 +598,17 @@ public class XFIleStorageServiceImplTest { */ private RecoverableStorageService(FileStorage platform) { this.platform = platform; + setFileStorageList(new java.util.concurrent.CopyOnWriteArrayList<>( + java.util.List.of(platform))); setFileRecorder(new FileRecorder() { @Override public boolean save(FileInfo fileInfo) { return true; } @Override public void update(FileInfo fileInfo) { } - @Override public FileInfo getByUrl(String url) { return null; } + @Override public FileInfo getByUrl(String url) { + return recordedFileInfo != null + && url.equals(recordedFileInfo.getUrl()) + ? recordedFileInfo + : null; + } @Override public boolean delete(String url) { recorderDeleteCalls++; if (recorderDeleteThrows) { @@ -506,6 +632,15 @@ public class XFIleStorageServiceImplTest { return platform.getPlatform().equals(name) ? (T) platform : null; } + /** {@inheritDoc} */ + @Override + public FileInfo getFileInfoByUrl(String url) { + return recordedFileInfo != null + && url.equals(recordedFileInfo.getUrl()) + ? recordedFileInfo + : null; + } + /** {@inheritDoc} */ @Override public org.dromara.x.file.storage.core.upload.UploadPretreatment of(Object file) { diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/document/support/DocumentSourceLoader.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/document/support/DocumentSourceLoader.java index dcfdd63b..0559d7fe 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/document/support/DocumentSourceLoader.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/document/support/DocumentSourceLoader.java @@ -10,6 +10,7 @@ import tech.easyflow.ai.document.exception.DocumentParseBridgeException; import tech.easyflow.ai.document.model.DocumentSourceRef; import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadedFileReader; import tech.easyflow.common.filestorage.FileStorageService; +import tech.easyflow.common.filestorage.FileStorageWriteHandle; import tech.easyflow.common.filestorage.utils.PathGeneratorUtil; import java.io.IOException; @@ -164,7 +165,7 @@ public class DocumentSourceLoader { } /** - * 优先打开经过上传记录验证的受管 URL,再执行普通公网 URL 校验与下载。 + * 优先打开经过上传记录验证的受管 URL 和服务端已登记附件,再执行普通公网 URL 校验与下载。 * * @param remoteUrl 远端 URL * @param maxBytes 最大允许读取字节数 @@ -176,6 +177,13 @@ public class DocumentSourceLoader { if (managed.isPresent()) { return DocumentInputStreamSupport.limit(managed.get(), maxBytes); } + Optional trusted = + fileStorageService.resolveTrustedFile(remoteUrl); + if (trusted.isPresent()) { + return DocumentInputStreamSupport.limit( + fileStorageService.readRecoverable(trusted.get()), + maxBytes); + } return DocumentInputStreamSupport.openRemote(remoteUrl, maxBytes); } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/DocNodeFileContentExtractor.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/DocNodeFileContentExtractor.java index ce414d6b..1a6e5f32 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/DocNodeFileContentExtractor.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/DocNodeFileContentExtractor.java @@ -15,6 +15,7 @@ import tech.easyflow.ai.document.support.DocumentInputStreamSupport; import tech.easyflow.ai.document.support.DocumentParseSourceType; import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadedFileReader; import tech.easyflow.common.filestorage.FileStorageService; +import tech.easyflow.common.filestorage.FileStorageWriteHandle; import tech.easyflow.common.util.StringUtil; import tech.easyflow.common.web.exceptions.BusinessException; @@ -326,20 +327,29 @@ public class DocNodeFileContentExtractor { private void copySourceToTemporaryFile( DocumentSourceRef sourceRef, Path target) throws IOException { String filePath = sourceRef.getFilePath(); + boolean managedUpload = StringUtil.hasText(filePath) + && uploadedFileReader != null + && uploadedFileReader.isManagedPathCandidate(filePath); + Optional trustedFile = Optional.empty(); + if (StringUtil.hasText(filePath) + && isRemoteUrl(filePath) + && !managedUpload) { + trustedFile = fileStorageService.resolveTrustedFile(filePath); + } boolean localStorage = StringUtil.hasText(filePath) && (!isRemoteUrl(filePath) - || (uploadedFileReader != null - && uploadedFileReader.isManagedPathCandidate(filePath))); + || managedUpload + || trustedFile.isPresent()); if (localStorage) { try (IoBulkhead.Permit ignored = IoBulkhead.storage().acquire("storage:document-read"); - InputStream inputStream = openInputStream(sourceRef); + InputStream inputStream = openInputStream(sourceRef, trustedFile); OutputStream outputStream = Files.newOutputStream(target)) { copy(inputStream, outputStream); } return; } - try (InputStream inputStream = openInputStream(sourceRef); + try (InputStream inputStream = openInputStream(sourceRef, trustedFile); OutputStream outputStream = Files.newOutputStream(target)) { copy(inputStream, outputStream); } @@ -362,7 +372,17 @@ public class DocNodeFileContentExtractor { } } - private InputStream openInputStream(DocumentSourceRef sourceRef) throws IOException { + /** + * 按可信受管上传、服务端文件记录、本地路径和普通公网 URL 的顺序打开源流。 + * + * @param sourceRef 文档源 + * @param trustedFile 已通过服务端记录或存储配置确认的物理读取句柄 + * @return 受实际字节数限制的输入流 + * @throws IOException 文件无法安全读取时抛出 + */ + private InputStream openInputStream( + DocumentSourceRef sourceRef, + Optional trustedFile) throws IOException { String filePath = sourceRef.getFilePath(); if (uploadedFileReader != null && StringUtil.hasText(filePath)) { Optional managed = uploadedFileReader.openVerified(filePath); @@ -372,6 +392,11 @@ public class DocNodeFileContentExtractor { FILE_MAX_SINGLE_SIZE); } } + if (trustedFile.isPresent()) { + return DocumentInputStreamSupport.limit( + fileStorageService.readRecoverable(trustedFile.get()), + FILE_MAX_SINGLE_SIZE); + } if (StringUtil.hasText(filePath) && isRemoteUrl(filePath)) { return DocumentInputStreamSupport.openRemote(filePath, FILE_MAX_SINGLE_SIZE); } diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/document/support/DocumentSourceLoaderTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/document/support/DocumentSourceLoaderTest.java index 2c8046e0..d94b92ee 100644 --- a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/document/support/DocumentSourceLoaderTest.java +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/document/support/DocumentSourceLoaderTest.java @@ -7,6 +7,7 @@ import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadedFileReader; import tech.easyflow.ai.document.exception.DocumentParseBridgeException; import tech.easyflow.ai.document.model.DocumentSourceRef; import tech.easyflow.common.filestorage.FileStorageService; +import tech.easyflow.common.filestorage.FileStorageWriteHandle; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -73,6 +74,24 @@ public class DocumentSourceLoaderTest { } } + /** + * 验证服务端已登记的普通附件 URL 在公网地址校验前通过物理句柄直读。 + */ + @Test + public void shouldLoadRecordedInternalStorageUrlBeforeRemoteAddressGuard() { + String fileUrl = "http://127.0.0.1:39000/easyflow/attachment/1/2026/8/26/demo.pdf"; + byte[] body = "recorded-pdf".getBytes(StandardCharsets.UTF_8); + DocumentSourceLoader loader = new DocumentSourceLoader( + new RecordedFileStorageService(fileUrl, body)); + DocumentSourceRef sourceRef = new DocumentSourceRef(); + sourceRef.setFileName("demo.pdf"); + sourceRef.setFilePath(fileUrl); + + LoadedDocumentSource loadedSource = loader.load(sourceRef); + + Assert.assertArrayEquals(body, loadedSource.getContentBytes()); + } + /** * 验证已通过上传记录校验的内网存储 URL 会走恢复句柄读取。 * @@ -217,4 +236,42 @@ public class DocumentSourceLoaderTest { return 0L; } } + + /** + * 仅允许通过服务端记录句柄读取内容的存储测试替身。 + */ + private static class RecordedFileStorageService + extends FailingFileStorageService { + /** 允许解析的精确 URL。 */ + private final String recordedUrl; + /** 固定文件内容。 */ + private final byte[] content; + /** 固定可信读取句柄。 */ + private final FileStorageWriteHandle handle = new FileStorageWriteHandle( + "recorded", "", "/storage", "attachment", "demo.pdf"); + + /** + * 创建服务端记录存储替身。 + * + * @param recordedUrl 允许解析的精确 URL + * @param content 固定文件内容 + */ + private RecordedFileStorageService(String recordedUrl, byte[] content) { + this.recordedUrl = recordedUrl; + this.content = content.clone(); + } + + /** {@inheritDoc} */ + @Override + public Optional resolveTrustedFile(String reference) { + return recordedUrl.equals(reference) ? Optional.of(handle) : Optional.empty(); + } + + /** {@inheritDoc} */ + @Override + public InputStream readRecoverable(FileStorageWriteHandle requestedHandle) { + Assert.assertSame(handle, requestedHandle); + return new ByteArrayInputStream(content); + } + } } diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/DocNodeFileContentExtractorTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/DocNodeFileContentExtractorTest.java index 5712446c..5c154279 100644 --- a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/DocNodeFileContentExtractorTest.java +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/DocNodeFileContentExtractorTest.java @@ -10,6 +10,7 @@ import tech.easyflow.ai.document.model.DocumentParsedResult; import tech.easyflow.ai.document.model.DocumentSourceRef; import tech.easyflow.ai.document.service.DocumentParseBridgeService; import tech.easyflow.common.filestorage.FileStorageService; +import tech.easyflow.common.filestorage.FileStorageWriteHandle; import tech.easyflow.common.web.exceptions.BusinessException; import java.io.ByteArrayInputStream; @@ -203,6 +204,27 @@ public class DocNodeFileContentExtractorTest { Assert.assertNull(bridgeService.lastSource); } + /** + * 验证默认读取器也能通过服务端文件记录读取内部附件 URL。 + */ + @Test + public void shouldReadRecordedInternalUrlForUnsupportedType() { + RecordingDocumentParseBridgeService bridgeService = new RecordingDocumentParseBridgeService(); + String fileUrl = "http://127.0.0.1:39000/easyflow/attachment/1/2026/8/26/note.txt"; + DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor( + bridgeService, + new RecordedFileStorageService(fileUrl, "recorded text"), + new ReadingReaderManager()); + + String content = extractor.extract(buildFileValue( + "note.txt", + fileUrl, + "text/plain")); + + Assert.assertEquals("recorded text", content); + Assert.assertNull(bridgeService.lastSource); + } + /** * 验证受管上传 URL 的非桥接文件通过记录校验后走内部存储读取。 * @@ -574,4 +596,42 @@ public class DocNodeFileContentExtractorTest { return 0L; } } + + /** + * 仅允许通过服务端记录句柄读取内容的存储测试替身。 + */ + private static class RecordedFileStorageService + extends FailingFileStorageService { + /** 允许解析的精确 URL。 */ + private final String recordedUrl; + /** 固定内容。 */ + private final byte[] content; + /** 固定可信读取句柄。 */ + private final FileStorageWriteHandle handle = new FileStorageWriteHandle( + "recorded", "", "/storage", "attachment", "note.txt"); + + /** + * 创建服务端记录存储替身。 + * + * @param recordedUrl 允许解析的精确 URL + * @param content 固定文本内容 + */ + private RecordedFileStorageService(String recordedUrl, String content) { + this.recordedUrl = recordedUrl; + this.content = content.getBytes(StandardCharsets.UTF_8); + } + + /** {@inheritDoc} */ + @Override + public Optional resolveTrustedFile(String reference) { + return recordedUrl.equals(reference) ? Optional.of(handle) : Optional.empty(); + } + + /** {@inheritDoc} */ + @Override + public InputStream readRecoverable(FileStorageWriteHandle requestedHandle) { + Assert.assertSame(handle, requestedHandle); + return new ByteArrayInputStream(content); + } + } }