feat: 完善工作流 Public API 调用能力

- 支持 JSON 文件 URL 简写与 Multipart 单请求文件上传

- 完善执行拓扑、枚举状态、节点名称、恢复校验和安全错误响应

- 增加临时上传生命周期清理并升级 MinIO SDK

- 重构工作流接口调用说明弹窗的扁平响应式布局
This commit is contained in:
2026-08-09 21:27:30 +08:00
parent 0d14f1c165
commit 54d85ae460
61 changed files with 8131 additions and 161 deletions

View File

@@ -139,6 +139,18 @@ public class FileStorageManager implements FileStorageService {
return serviceForHandle(handle).saveRecoverable(file, handle);
}
/**
* 严格按句柄中的后端打开物理对象读取流。
*
* @param handle 物理对象句柄
* @return 文件输入流,由调用方关闭
* @throws IOException 无法读取物理对象时抛出
*/
@Override
public InputStream readRecoverable(FileStorageWriteHandle handle) throws IOException {
return serviceForHandle(handle).readRecoverable(handle);
}
/**
* 严格按句柄中的后端精确删除物理对象。
*

View File

@@ -91,6 +91,20 @@ public interface FileStorageService {
throw unsupportedRecoverableOperation("saveRecoverable");
}
/**
* 按可恢复句柄精确打开物理对象读取流。
*
* <p>该方法只接受由可信业务记录恢复出的句柄,不得直接使用外部传入的 locator。</p>
*
* @param handle 物理对象写入句柄
* @return 文件输入流,由调用方关闭
* @throws IOException 无法打开物理对象时抛出
* @throws UnsupportedOperationException 当前后端尚未实现可恢复读取时抛出
*/
default InputStream readRecoverable(FileStorageWriteHandle handle) throws IOException {
throw unsupportedRecoverableOperation("readRecoverable");
}
/**
* 精确且幂等地删除句柄对应的物理对象。
*

View File

@@ -272,6 +272,27 @@ public class LocalFileStorageServiceImpl implements FileStorageService {
}
}
/**
* 按句柄固化的本地根目录精确打开普通文件。
*
* @param handle 本地可恢复写句柄
* @return 文件输入流,由调用方关闭
* @throws IOException 文件不存在、不可读或路径不安全时抛出
*/
@Override
public InputStream readRecoverable(FileStorageWriteHandle handle) throws IOException {
requireLocalHandle(handle);
Path target = resolveControlledTarget(handle, false);
if (!Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("本地可恢复文件不存在: " + target);
}
if (Files.isSymbolicLink(target)
|| !Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("本地可恢复读取目标不是普通文件: " + target);
}
return Files.newInputStream(target);
}
/**
* 精确且幂等地删除句柄对应的最终文件与确定性暂存文件,并确认两者均不存在。
*

View File

@@ -1,7 +1,9 @@
package tech.easyflow.common.filestorage.impl;
import io.minio.GetObjectArgs;
import org.dromara.x.file.storage.core.FileInfo;
import org.dromara.x.file.storage.core.platform.FileStorage;
import org.dromara.x.file.storage.core.platform.MinioFileStorage;
import org.dromara.x.file.storage.core.recorder.FileRecorder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -198,6 +200,44 @@ public class XFIleStorageServiceImpl implements FileStorageService {
}
}
/**
* 按句柄固定的平台和对象键读取物理文件。
*
* <p>MinIO 使用已配置客户端直接读取,兼容私有桶和内网端点;其他平台仅在能够
* 精确推导公开 URL 时使用现有读取能力。</p>
*
* @param handle x-file-storage 可恢复写句柄
* @return 文件输入流,由调用方关闭
* @throws IOException 平台不支持精确读取或对象读取失败时抛出
*/
@Override
public InputStream readRecoverable(FileStorageWriteHandle handle) throws IOException {
FileStorage storage = requireStorage(handle);
requirePersistedBasePathSupport(storage, handle);
FileInfo fileInfo = toFileInfo(handle);
if (storage instanceof MinioFileStorage minioStorage) {
try {
return minioStorage.getClient().getObject(
GetObjectArgs.builder()
.bucket(minioStorage.getBucketName())
.object(storage.getFileKey(fileInfo))
.build());
} catch (Exception exception) {
throw new IOException("读取 MinIO 可恢复文件失败", exception);
}
}
String url = deriveUrlBestEffort(storage, fileInfo);
if (!StringUtils.hasText(url)) {
throw new IOException("当前 x-file-storage 平台不支持可恢复文件读取: "
+ storage.getClass().getName());
}
try {
return readStream(url);
} catch (RuntimeException exception) {
throw new IOException("读取 x-file-storage 可恢复文件失败", exception);
}
}
/**
* 直接调用句柄指定平台的物理删除与存在检查,绕过依赖 URL 记录的聚合删除路径。
*

View File

@@ -22,7 +22,7 @@ public class FileStorageManagerTest {
* 验证 prepare 使用当前后端,而后续操作在默认后端切换后仍按句柄后端路由。
*/
@Test
public void recoverableOperationsRouteByPreparedBackendAfterSwitch() {
public void recoverableOperationsRouteByPreparedBackendAfterSwitch() throws IOException {
RecordingStorage local = new RecordingStorage("local");
RecordingStorage xFile = new RecordingStorage("xFileStorage");
AtomicReference<String> current = new AtomicReference<>("local");
@@ -32,16 +32,20 @@ public class FileStorageManagerTest {
FileStorageWriteHandle handle = manager.prepareRecoverableWrite("skill-content/ab", "content.bin");
current.set("xFileStorage");
FileStorageWriteResult result = manager.saveRecoverable(null, handle);
InputStream inputStream = manager.readRecoverable(handle);
manager.deleteRecoverable(handle);
boolean exists = manager.existsRecoverable(handle);
assertEquals("local", handle.getBackend());
assertSame(local.result, result);
assertSame(local.recoverableInput, inputStream);
assertEquals(1, local.prepareCalls);
assertEquals(1, local.saveCalls);
assertEquals(1, local.readCalls);
assertEquals(1, local.deleteCalls);
assertEquals(1, local.existsCalls);
assertEquals(0, xFile.prepareCalls + xFile.saveCalls + xFile.deleteCalls + xFile.existsCalls);
assertEquals(0, xFile.prepareCalls + xFile.saveCalls + xFile.readCalls
+ xFile.deleteCalls + xFile.existsCalls);
assertFalse(exists);
}
@@ -53,10 +57,14 @@ public class FileStorageManagerTest {
private final String backend;
/** 固定结果。 */
private final FileStorageWriteResult result;
/** 固定可恢复读取流。 */
private final InputStream recoverableInput = InputStream.nullInputStream();
/** prepare 调用次数。 */
private int prepareCalls;
/** save 调用次数。 */
private int saveCalls;
/** read 调用次数。 */
private int readCalls;
/** delete 调用次数。 */
private int deleteCalls;
/** exists 调用次数。 */
@@ -99,6 +107,13 @@ public class FileStorageManagerTest {
return result;
}
/** {@inheritDoc} */
@Override
public InputStream readRecoverable(FileStorageWriteHandle handle) {
readCalls++;
return recoverableInput;
}
/** {@inheritDoc} */
@Override
public void deleteRecoverable(FileStorageWriteHandle handle) {

View File

@@ -51,6 +51,9 @@ public class LocalFileStorageServiceImplTest {
assertEquals(handle, FileStorageWriteHandle.decodeLocator(result.getLocator()));
assertTrue(service.existsRecoverable(handle));
assertArrayEquals(bytes, Files.readAllBytes(target));
try (InputStream inputStream = service.readRecoverable(handle)) {
assertArrayEquals(bytes, inputStream.readAllBytes());
}
assertFalse(Files.exists(changedRoot.toPath().resolve("skill-content/ab/content.bin")));
service.deleteRecoverable(handle);

View File

@@ -1,9 +1,15 @@
package tech.easyflow.common.filestorage.impl;
import io.minio.GetObjectArgs;
import io.minio.GetObjectResponse;
import io.minio.MinioClient;
import okhttp3.Headers;
import org.junit.Test;
import org.dromara.x.file.storage.core.FileInfo;
import org.dromara.x.file.storage.core.UploadPretreatment;
import org.dromara.x.file.storage.core.platform.FileStorage;
import org.dromara.x.file.storage.core.platform.FileStorageClientFactory;
import org.dromara.x.file.storage.core.platform.MinioFileStorage;
import org.dromara.x.file.storage.core.recorder.FileRecorder;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
@@ -17,6 +23,7 @@ import java.lang.reflect.Field;
import java.nio.file.Files;
import java.util.function.Consumer;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
@@ -102,6 +109,42 @@ public class XFIleStorageServiceImplTest {
assertTrue(platform.exists);
}
/**
* 验证 MinIO 可恢复读取使用已配置客户端和句柄中的精确对象键,不请求公开 URL。
*
* @throws Exception 测试替身配置或流读取失败
*/
@Test
public void recoverableReadUsesMinioClientAndExactObjectKey() throws Exception {
byte[] content = "managed-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("easyflow/");
platform.setDomain("http://127.0.0.1:39000/");
platform.setClientFactory(new FixedMinioClientFactory(client));
XFIleStorageServiceImpl service = createService(
new RecoverableStorageService(platform));
FileStorageWriteHandle handle = new FileStorageWriteHandle(
"xFileStorage",
"minio-main",
"easyflow/",
"workflow-api-upload/request",
"content.bin");
byte[] actual;
try (InputStream inputStream = service.readRecoverable(handle)) {
actual = inputStream.readAllBytes();
}
assertArrayEquals(content, actual);
assertEquals("easyflow", client.lastArgs.bucket());
assertEquals(
"easyflow/workflow-api-upload/request/content.bin",
client.lastArgs.object());
}
/**
* 验证 recorder 完全缺失目标记录时,精确删除仍直接作用于物理平台并成功。
*
@@ -401,6 +444,73 @@ public class XFIleStorageServiceImplTest {
}
}
/**
* 始终返回同一 MinIO 客户端的测试工厂。
*/
private static final class FixedMinioClientFactory
implements FileStorageClientFactory<MinioClient> {
/** 固定客户端。 */
private final MinioClient client;
/**
* 创建固定客户端工厂。
*
* @param client MinIO 客户端
*/
private FixedMinioClientFactory(MinioClient client) {
this.client = client;
}
/** {@inheritDoc} */
@Override
public String getPlatform() {
return "minio-main";
}
/** {@inheritDoc} */
@Override
public MinioClient getClient() {
return client;
}
}
/**
* 记录精确对象参数并返回内存内容的 MinIO 客户端替身。
*/
private static final class RecordingMinioClient extends MinioClient {
/** 固定返回内容。 */
private final byte[] content;
/** 最后一次读取参数。 */
private GetObjectArgs lastArgs;
/**
* 创建内存 MinIO 客户端替身。
*
* @param content 固定返回内容
*/
private RecordingMinioClient(byte[] content) {
super(MinioClient.builder()
.endpoint("http://127.0.0.1:39000")
.credentials("test-access-key", "test-secret-key")
.build());
this.content = content.clone();
}
/** {@inheritDoc} */
@Override
public GetObjectResponse getObject(GetObjectArgs args) {
this.lastArgs = args;
return new GetObjectResponse(
new Headers.Builder().build(),
args.bucket(),
null,
args.object(),
new ByteArrayInputStream(content));
}
}
/**
* 不访问真实网络、仅捕获上传参数的预处理器。
*/