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

@@ -17,6 +17,7 @@ import tech.easyflow.common.web.exceptions.BusinessException;
import java.lang.reflect.Field;
import java.util.List;
import java.util.Map;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
@@ -82,6 +83,8 @@ public class TinyFlowServiceTest {
.thenReturn(nodeStateRepository);
when(chainStateRepository.load(EXECUTE_ID))
.thenReturn(chainState);
when(chainExecutor.getInstanceNodeNames(chainState))
.thenReturn(Map.of(NODE_ID, "文档解析"));
when(nodeStateRepository.load(EXECUTE_ID, NODE_ID))
.thenReturn(null);
TinyFlowService service = service(chainExecutor);
@@ -96,7 +99,12 @@ public class TinyFlowServiceTest {
Assert.assertEquals(
Integer.valueOf(NodeStatus.READY.getValue()),
result.getNodes().get(NODE_ID).getStatus());
Assert.assertEquals(
"文档解析",
result.getNodes().get(NODE_ID).getNodeName());
verify(chainStateRepository, times(1)).load(EXECUTE_ID);
verify(chainExecutor, times(1))
.getInstanceNodeNames(chainState);
verify(nodeStateRepository, times(1))
.load(EXECUTE_ID, NODE_ID);
}

View File

@@ -159,6 +159,47 @@ public class WorkflowRunningParameterResolverTest {
Assert.assertEquals("file", fields.get(0).get("type"));
}
/**
* multipart 文件字段名应从解析后的开始节点参数中按定义顺序返回。
*
* @throws Exception 反射注入失败
*/
@Test
public void testResolveFileParameterNamesShouldReturnStartFileFields()
throws Exception {
WorkflowRunningParameterResolver resolver = newResolver();
Assert.assertEquals(
List.of("attachments"),
List.copyOf(resolver.resolveFileParameterNames(
workflowContentWithStartParameters())));
}
/**
* 必填文件字段名应从开始节点参数定义中单独解析。
*
* @throws Exception 反射注入失败
*/
@Test
public void testResolveRequiredFileParameterNamesShouldKeepOrder()
throws Exception {
WorkflowRunningParameterResolver resolver = newResolver();
JSONObject startData = data("开始");
JSONArray parameters = startParameters();
parameters.getJSONObject(1).put("required", true);
startData.put("parameters", parameters);
String content = workflowJson(
array(
node("s1", "startNode", null, startData),
node("e1", "endNode", null, data("结束"))),
array(edge("e1", "s1", "e1")));
Assert.assertEquals(
List.of("attachments"),
List.copyOf(resolver
.resolveRequiredFileParameterNames(content)));
}
/**
* 文件参数运行值应统一归一化为数组并按 filePath 去重。
*
@@ -178,6 +219,89 @@ public class WorkflowRunningParameterResolverTest {
Assert.assertTrue(((List<?>) attachments).get(0) instanceof Map<?, ?>);
}
/**
* 文件参数应接受远程 URL 字符串数组并自动提取文件名。
*
* @throws Exception 反射注入失败
*/
@Test
public void testNormalizeRuntimeVariablesShouldAcceptRemoteFileUrls()
throws Exception {
WorkflowRunningParameterResolver resolver = newResolver();
String firstUrl = "https://files.example.com/contracts/"
+ "%E5%90%88%E5%90%8C%20v1.docx?signature=test";
String secondUrl = "https://files.example.com/contracts/report.pdf";
Map<String, Object> variables = new LinkedHashMap<>();
variables.put("attachments", List.of(firstUrl, secondUrl));
Map<String, Object> normalized = resolver.normalizeRuntimeVariables(
workflowContentWithStartParameters(),
variables);
List<?> attachments = (List<?>) normalized.get("attachments");
Assert.assertEquals(2, attachments.size());
Assert.assertEquals(
"合同 v1.docx",
((Map<?, ?>) attachments.get(0)).get("fileName"));
Assert.assertEquals(
firstUrl,
((Map<?, ?>) attachments.get(0)).get("filePath"));
Assert.assertEquals(
"report.pdf",
((Map<?, ?>) attachments.get(1)).get("fileName"));
}
/**
* 单个远程文件 URL 也应归一化为文件对象数组。
*
* @throws Exception 反射注入失败
*/
@Test
public void testNormalizeRuntimeVariablesShouldAcceptSingleRemoteFileUrl()
throws Exception {
WorkflowRunningParameterResolver resolver = newResolver();
Map<String, Object> variables = new LinkedHashMap<>();
variables.put(
"attachments",
"https://files.example.com/contracts/contract.docx");
Map<String, Object> normalized = resolver.normalizeRuntimeVariables(
workflowContentWithStartParameters(),
variables);
List<?> attachments = (List<?>) normalized.get("attachments");
Assert.assertEquals(1, attachments.size());
Assert.assertEquals(
"contract.docx",
((Map<?, ?>) attachments.get(0)).get("fileName"));
}
/**
* 无法从 URL 路径识别文件扩展名时应给出可恢复的格式提示。
*
* @throws Exception 反射注入失败
*/
@Test
public void testNormalizeRuntimeVariablesShouldRejectAmbiguousRemoteUrl()
throws Exception {
WorkflowRunningParameterResolver resolver = newResolver();
Map<String, Object> variables = new LinkedHashMap<>();
variables.put(
"attachments",
"https://files.example.com/download?id=contract");
BusinessException exception = Assert.assertThrows(
BusinessException.class,
() -> resolver.normalizeRuntimeVariables(
workflowContentWithStartParameters(),
variables));
Assert.assertEquals(
"文件参数 attachments 的 URL 路径无法识别带扩展名的文件名,"
+ "请改用包含 fileName 和 filePath 的文件对象",
exception.getMessage());
}
/**
* 多文件参数应按 filePath 去重并保留已有非文件变量。
*
@@ -270,6 +394,36 @@ public class WorkflowRunningParameterResolverTest {
}
}
/**
* 文件参数应拒绝超过十个文件的输入。
*
* @throws Exception 反射注入失败
*/
@Test
public void testNormalizeRuntimeVariablesShouldEnforceFileCountLimit()
throws Exception {
WorkflowRunningParameterResolver resolver = newResolver();
List<Map<String, Object>> files = new java.util.ArrayList<>();
for (int index = 0; index < 11; index++) {
files.add(fileValue(
"file-" + index + ".pdf",
"/files/file-" + index + ".pdf",
1L));
}
Map<String, Object> variables = new LinkedHashMap<>();
variables.put("attachments", files);
BusinessException exception = Assert.assertThrows(
BusinessException.class,
() -> resolver.normalizeRuntimeVariables(
workflowContentWithStartParameters(),
variables));
Assert.assertEquals(
"文件参数 attachments 最多上传 10 个文件",
exception.getMessage());
}
/**
* 旧版图片 URL 应归一化为 URL 图片描述。
*

View File

@@ -0,0 +1,28 @@
package tech.easyflow.ai.easyagentsflow.upload;
import org.junit.Test;
import org.mockito.Mockito;
/**
* {@link WorkflowApiUploadCleanupScheduler} 批量排空测试。
*/
public class WorkflowApiUploadCleanupSchedulerTest {
/**
* 验证一次调度会连续处理多批到期记录。
*/
@Test
public void cleanupShouldDrainMultipleBatches() {
WorkflowApiUploadLifecycleService lifecycleService =
Mockito.mock(WorkflowApiUploadLifecycleService.class);
Mockito.when(lifecycleService.cleanupExpired(100))
.thenReturn(100, 100, 20);
WorkflowApiUploadCleanupScheduler scheduler =
new WorkflowApiUploadCleanupScheduler(lifecycleService);
scheduler.cleanup();
Mockito.verify(lifecycleService, Mockito.times(3))
.cleanupExpired(100);
}
}

View File

@@ -0,0 +1,552 @@
package tech.easyflow.ai.easyagentsflow.upload;
import com.easyagents.flow.core.chain.repository.ChainStateRepository;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.mockito.Mockito;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
import tech.easyflow.common.cache.RedisLockExecutor;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
import tech.easyflow.common.filestorage.FileStorageWriteResult;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
/**
* {@link WorkflowApiUploadLifecycleService} multipart 文件生命周期测试。
*/
public class WorkflowApiUploadLifecycleServiceTest {
/**
* 验证同名文件 Part 会按顺序保存并注入文件对象数组。
*/
@Test
public void prepareShouldStoreRepeatedFilePartsInOrder() {
Fixture fixture = fixture();
MultipartFile first = file("first.pdf", "application/pdf", 10L);
MultipartFile second = file(
"second.docx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
20L);
Mockito.when(fixture.parameterResolver.resolveFileParameterNames("flow"))
.thenReturn(Set.of("documents"));
Mockito.when(fixture.parameterResolver.normalizeRuntimeVariables(
Mockito.eq("flow"),
Mockito.anyMap()))
.thenAnswer(invocation -> new LinkedHashMap<>(
invocation.getArgument(1)));
FileStorageWriteHandle firstHandle = handle("first.pdf");
FileStorageWriteHandle secondHandle = handle("second.docx");
Mockito.when(fixture.fileStorageService.prepareRecoverableWrite(
Mockito.anyString(),
Mockito.anyString()))
.thenReturn(firstHandle, secondHandle);
Mockito.when(fixture.fileStorageService.saveRecoverable(
first,
firstHandle))
.thenReturn(new FileStorageWriteResult(
"/files/first.pdf",
firstHandle.encodeLocator()));
Mockito.when(fixture.fileStorageService.saveRecoverable(
second,
secondHandle))
.thenReturn(new FileStorageWriteResult(
"/files/second.docx",
secondHandle.encodeLocator()));
WorkflowApiPreparedUpload prepared = fixture.service.prepare(
"flow",
Map.of("user_input", "解析"),
Map.of("documents", List.of(first, second)));
Assert.assertNotNull(prepared.getRequestId());
Assert.assertEquals("解析", prepared.getVariables().get("user_input"));
@SuppressWarnings("unchecked")
List<Map<String, Object>> documents =
(List<Map<String, Object>>) prepared.getVariables()
.get("documents");
Assert.assertEquals(2, documents.size());
Assert.assertEquals(
"/files/first.pdf",
documents.get(0).get("filePath"));
Assert.assertEquals(
"/files/second.docx",
documents.get(1).get("filePath"));
ArgumentCaptor<WorkflowApiUploadRecord> recordCaptor =
ArgumentCaptor.forClass(WorkflowApiUploadRecord.class);
Mockito.verify(fixture.uploadStore).create(
recordCaptor.capture());
Assert.assertEquals(
List.of("/files/first.pdf", "/files/second.docx"),
recordCaptor.getValue().getStoredFiles().stream()
.map(WorkflowApiStoredFile::filePath)
.toList());
InOrder writeOrder = Mockito.inOrder(
fixture.uploadStore,
fixture.fileStorageService);
writeOrder.verify(fixture.uploadStore)
.save(Mockito.any(WorkflowApiUploadRecord.class));
writeOrder.verify(fixture.fileStorageService)
.saveRecoverable(first, firstHandle);
}
/**
* 验证非法客户端 MIME 会按扩展名归一化,并同时用于存储和文件描述。
*/
@Test
public void prepareShouldNormalizeInvalidContentType() {
Fixture fixture = fixture();
MultipartFile file = file(
"C:\\fakepath\\report.docx",
"Other",
10L);
Mockito.when(fixture.parameterResolver
.resolveFileParameterNames("flow"))
.thenReturn(Set.of("documents"));
Mockito.when(fixture.parameterResolver.normalizeRuntimeVariables(
Mockito.eq("flow"),
Mockito.anyMap()))
.thenAnswer(invocation -> new LinkedHashMap<>(
invocation.getArgument(1)));
FileStorageWriteHandle handle = handle("report.docx");
Mockito.when(fixture.fileStorageService.prepareRecoverableWrite(
Mockito.anyString(),
Mockito.anyString()))
.thenReturn(handle);
Mockito.when(fixture.fileStorageService.saveRecoverable(
Mockito.any(MultipartFile.class),
Mockito.eq(handle)))
.thenReturn(new FileStorageWriteResult(
"/files/report.docx",
handle.encodeLocator()));
WorkflowApiPreparedUpload prepared = fixture.service.prepare(
"flow",
Map.of(),
Map.of("documents", List.of(file)));
ArgumentCaptor<MultipartFile> storedFile =
ArgumentCaptor.forClass(MultipartFile.class);
Mockito.verify(fixture.fileStorageService).saveRecoverable(
storedFile.capture(),
Mockito.eq(handle));
Assert.assertEquals(
"report.docx",
storedFile.getValue().getOriginalFilename());
Assert.assertEquals(
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
storedFile.getValue().getContentType());
@SuppressWarnings("unchecked")
List<Map<String, Object>> documents =
(List<Map<String, Object>>) prepared.getVariables()
.get("documents");
Assert.assertEquals(
storedFile.getValue().getContentType(),
documents.get(0).get("contentType"));
}
/**
* 验证对象存储超时返回可重试的 50301并补偿临时文件。
*/
@Test
public void prepareShouldTranslateStorageTimeoutAndCleanup() {
Fixture fixture = fixture();
MultipartFile file = file(
"report.pdf",
"application/pdf",
10L);
Mockito.when(fixture.parameterResolver
.resolveFileParameterNames("flow"))
.thenReturn(Set.of("documents"));
Mockito.when(fixture.parameterResolver.normalizeRuntimeVariables(
Mockito.eq("flow"),
Mockito.anyMap()))
.thenAnswer(invocation -> new LinkedHashMap<>(
invocation.getArgument(1)));
FileStorageWriteHandle handle = handle("report.pdf");
Mockito.when(fixture.fileStorageService.prepareRecoverableWrite(
Mockito.anyString(),
Mockito.anyString()))
.thenReturn(handle);
Mockito.when(fixture.fileStorageService.saveRecoverable(
file,
handle))
.thenThrow(new IllegalStateException(
"storage timeout",
new java.net.SocketTimeoutException("timeout")));
BusinessException exception = Assert.assertThrows(
BusinessException.class,
() -> fixture.service.prepare(
"flow",
Map.of(),
Map.of("documents", List.of(file))));
Assert.assertEquals(503, exception.getHttpStatus());
Assert.assertEquals(50301, exception.getErrorCode());
Assert.assertFalse(exception.getMessage().contains("Socket"));
Mockito.verify(fixture.fileStorageService)
.deleteRecoverable(handle);
Mockito.verify(fixture.uploadStore)
.remove(Mockito.any(WorkflowApiUploadRecord.class));
}
/**
* 验证存储鉴权等非暂时性错误返回安全的 50001并执行补偿。
*/
@Test
public void prepareShouldHidePermanentStorageFailureAndCleanup() {
Fixture fixture = fixture();
MultipartFile file = file(
"report.pdf",
"application/pdf",
10L);
Mockito.when(fixture.parameterResolver
.resolveFileParameterNames("flow"))
.thenReturn(Set.of("documents"));
Mockito.when(fixture.parameterResolver.normalizeRuntimeVariables(
Mockito.eq("flow"),
Mockito.anyMap()))
.thenAnswer(invocation -> new LinkedHashMap<>(
invocation.getArgument(1)));
FileStorageWriteHandle handle = handle("report.pdf");
Mockito.when(fixture.fileStorageService.prepareRecoverableWrite(
Mockito.anyString(),
Mockito.anyString()))
.thenReturn(handle);
Mockito.when(fixture.fileStorageService.saveRecoverable(
file,
handle))
.thenThrow(new IllegalStateException(
"AccessKey=secret, endpoint=http://internal:9000"));
BusinessException exception = Assert.assertThrows(
BusinessException.class,
() -> fixture.service.prepare(
"flow",
Map.of(),
Map.of("documents", List.of(file))));
Assert.assertEquals(500, exception.getHttpStatus());
Assert.assertEquals(50001, exception.getErrorCode());
Assert.assertFalse(exception.getMessage().contains("secret"));
Mockito.verify(fixture.fileStorageService)
.deleteRecoverable(handle);
Mockito.verify(fixture.uploadStore)
.remove(Mockito.any(WorkflowApiUploadRecord.class));
}
/**
* 验证未知文件 Part 在写入存储前被拒绝。
*/
@Test
public void prepareShouldRejectUnknownFilePartBeforeStorage() {
Fixture fixture = fixture();
MultipartFile file = file("data.pdf", "application/pdf", 10L);
Mockito.when(fixture.parameterResolver.resolveFileParameterNames("flow"))
.thenReturn(Set.of("documents"));
BusinessException exception = Assert.assertThrows(
BusinessException.class,
() -> fixture.service.prepare(
"flow",
Map.of(),
Map.of("unknown", List.of(file))));
Assert.assertTrue(exception.getMessage().contains("unknown"));
Mockito.verifyNoInteractions(fixture.fileStorageService);
Mockito.verifyNoInteractions(fixture.uploadStore);
}
/**
* 验证缺少开始节点必填文件字段时在存储前返回 40016。
*/
@Test
public void prepareShouldRejectMissingRequiredFileBeforeStorage() {
Fixture fixture = fixture();
MultipartFile file = file("data.pdf", "application/pdf", 10L);
Mockito.when(fixture.parameterResolver
.resolveFileParameterNames("flow"))
.thenReturn(Set.of("documents", "appendix"));
Mockito.when(fixture.parameterResolver
.resolveRequiredFileParameterNames("flow"))
.thenReturn(Set.of("documents", "appendix"));
BusinessException exception = Assert.assertThrows(
BusinessException.class,
() -> fixture.service.prepare(
"flow",
Map.of(),
Map.of("documents", List.of(file))));
Assert.assertEquals(40016, exception.getErrorCode());
Assert.assertTrue(exception.getMessage().contains("appendix"));
Mockito.verifyNoInteractions(fixture.fileStorageService);
Mockito.verifyNoInteractions(fixture.uploadStore);
}
/**
* 验证文件数量和大小限制统一转换为 41301。
*/
@Test
public void prepareShouldTranslateFileLimitToPayloadTooLarge() {
Fixture fixture = fixture();
MultipartFile file = file("data.pdf", "application/pdf", 10L);
Mockito.when(fixture.parameterResolver
.resolveFileParameterNames("flow"))
.thenReturn(Set.of("documents"));
Mockito.when(fixture.parameterResolver.normalizeRuntimeVariables(
Mockito.eq("flow"),
Mockito.anyMap()))
.thenThrow(new BusinessException(
"文件参数 documents 最多上传 10 个文件"));
BusinessException exception = Assert.assertThrows(
BusinessException.class,
() -> fixture.service.prepare(
"flow",
Map.of(),
Map.of("documents", List.of(file))));
Assert.assertEquals(413, exception.getHttpStatus());
Assert.assertEquals(41301, exception.getErrorCode());
Mockito.verifyNoInteractions(fixture.fileStorageService);
Mockito.verifyNoInteractions(fixture.uploadStore);
}
/**
* 验证文件写入后 Redis 更新失败时仍使用内存路径执行补偿删除。
*/
@Test
public void prepareShouldDeleteSavedFileWhenRecordUpdateFails() {
Fixture fixture = fixture();
MultipartFile file = file("data.pdf", "application/pdf", 10L);
Mockito.when(fixture.parameterResolver.resolveFileParameterNames("flow"))
.thenReturn(Set.of("documents"));
Mockito.when(fixture.parameterResolver.normalizeRuntimeVariables(
Mockito.eq("flow"),
Mockito.anyMap()))
.thenAnswer(invocation -> new LinkedHashMap<>(
invocation.getArgument(1)));
FileStorageWriteHandle handle = handle("data.pdf");
Mockito.when(fixture.fileStorageService.prepareRecoverableWrite(
Mockito.anyString(),
Mockito.anyString()))
.thenReturn(handle);
Mockito.when(fixture.fileStorageService.saveRecoverable(
file,
handle))
.thenReturn(new FileStorageWriteResult(
"/files/data.pdf",
handle.encodeLocator()));
Mockito.doNothing()
.doThrow(new IllegalStateException("Redis 写入失败"))
.when(fixture.uploadStore)
.save(Mockito.any(WorkflowApiUploadRecord.class));
IllegalStateException exception = Assert.assertThrows(
IllegalStateException.class,
() -> fixture.service.prepare(
"flow",
Map.of(),
Map.of("documents", List.of(file))));
Assert.assertTrue(exception.getMessage().contains("Redis"));
Mockito.verify(fixture.fileStorageService)
.deleteRecoverable(handle);
Mockito.verify(fixture.uploadStore)
.remove(Mockito.any(WorkflowApiUploadRecord.class));
}
/**
* 验证启动失败后的 abort 会幂等删除已保存文件和上传记录。
*/
@Test
public void abortShouldDeleteStoredFilesAndRecord() {
Fixture fixture = fixture();
WorkflowApiUploadRecord record = new WorkflowApiUploadRecord();
record.setRequestId("request-1");
FileStorageWriteHandle firstHandle = handle("a.pdf");
FileStorageWriteHandle secondHandle = handle("b.pdf");
record.setStoredFiles(List.of(
new WorkflowApiStoredFile(
"/files/a.pdf",
firstHandle.encodeLocator()),
new WorkflowApiStoredFile(
"/files/b.pdf",
secondHandle.encodeLocator())));
RedisLockExecutor.LockHandle handle =
Mockito.mock(RedisLockExecutor.LockHandle.class);
Mockito.when(fixture.redisLockExecutor.tryAcquire(
Mockito.anyString(),
Mockito.any(),
Mockito.any()))
.thenReturn(handle);
Mockito.when(fixture.uploadStore.find("request-1"))
.thenReturn(Optional.of(record));
fixture.service.abort("request-1");
Mockito.verify(fixture.fileStorageService)
.deleteRecoverable(firstHandle);
Mockito.verify(fixture.fileStorageService)
.deleteRecoverable(secondHandle);
Mockito.verify(fixture.uploadStore).remove(record);
Mockito.verify(handle).close();
}
/**
* 验证单条清理失败不会阻塞后续到期记录。
*/
@Test
public void cleanupExpiredShouldContinueAfterFailedRecord() {
Fixture fixture = fixture();
FileStorageWriteHandle failedFile = handle("failed.pdf");
FileStorageWriteHandle goodFile = handle("good.pdf");
WorkflowApiUploadRecord failedRecord =
storedRecord("failed", failedFile);
WorkflowApiUploadRecord goodRecord =
storedRecord("good", goodFile);
RedisLockExecutor.LockHandle failedLock =
Mockito.mock(RedisLockExecutor.LockHandle.class);
RedisLockExecutor.LockHandle goodLock =
Mockito.mock(RedisLockExecutor.LockHandle.class);
Mockito.when(fixture.uploadStore.claimExpired(
Mockito.anyLong(),
Mockito.anyLong()))
.thenReturn(
Optional.of("failed"),
Optional.of("good"),
Optional.empty());
Mockito.when(fixture.redisLockExecutor.tryAcquire(
Mockito.anyString(),
Mockito.any(),
Mockito.any()))
.thenReturn(failedLock, goodLock);
Mockito.when(fixture.uploadStore.find("failed"))
.thenReturn(Optional.of(failedRecord));
Mockito.when(fixture.uploadStore.find("good"))
.thenReturn(Optional.of(goodRecord));
Mockito.doThrow(new IllegalStateException("对象存储不可用"))
.when(fixture.fileStorageService)
.deleteRecoverable(failedFile);
int processed = fixture.service.cleanupExpired(10);
Assert.assertEquals(2, processed);
Mockito.verify(fixture.fileStorageService)
.deleteRecoverable(goodFile);
Mockito.verify(fixture.uploadStore).remove(goodRecord);
}
/**
* 创建 multipart 文件桩。
*
* @param name 文件名
* @param contentType MIME 类型
* @param size 文件大小
* @return multipart 文件桩
*/
private MultipartFile file(
String name,
String contentType,
long size) {
MultipartFile file = Mockito.mock(MultipartFile.class);
Mockito.when(file.isEmpty()).thenReturn(false);
Mockito.when(file.getOriginalFilename()).thenReturn(name);
Mockito.when(file.getContentType()).thenReturn(contentType);
Mockito.when(file.getSize()).thenReturn(size);
return file;
}
/**
* 创建可恢复文件存储句柄。
*
* @param filename 固定文件名
* @return 测试句柄
*/
private FileStorageWriteHandle handle(String filename) {
return new FileStorageWriteHandle(
"localFileStorage",
"",
"/tmp/easyflow-test",
"workflow-api-upload/test",
filename);
}
/**
* 创建包含单个临时文件的上传记录。
*
* @param requestId 请求 ID
* @param handle 文件句柄
* @return 上传记录
*/
private WorkflowApiUploadRecord storedRecord(
String requestId,
FileStorageWriteHandle handle) {
WorkflowApiUploadRecord record =
new WorkflowApiUploadRecord();
record.setRequestId(requestId);
record.setStoredFiles(List.of(
new WorkflowApiStoredFile(
"/files/" + handle.getFilename(),
handle.encodeLocator())));
return record;
}
/**
* 创建生命周期服务测试夹具。
*
* @return 测试夹具
*/
private Fixture fixture() {
WorkflowRunningParameterResolver parameterResolver =
Mockito.mock(WorkflowRunningParameterResolver.class);
FileStorageService fileStorageService =
Mockito.mock(FileStorageService.class);
WorkflowApiUploadStore uploadStore =
Mockito.mock(WorkflowApiUploadStore.class);
ChainStateRepository chainStateRepository =
Mockito.mock(ChainStateRepository.class);
RedisLockExecutor redisLockExecutor =
Mockito.mock(RedisLockExecutor.class);
return new Fixture(
new WorkflowApiUploadLifecycleService(
parameterResolver,
fileStorageService,
new WorkflowApiMultipartFileNormalizer(),
uploadStore,
chainStateRepository,
redisLockExecutor),
parameterResolver,
fileStorageService,
uploadStore,
redisLockExecutor);
}
/**
* 生命周期服务测试夹具。
*
* @param service 被测服务
* @param parameterResolver 参数解析器
* @param fileStorageService 文件存储
* @param uploadStore 上传记录存储
* @param redisLockExecutor 分布式锁执行器
*/
private record Fixture(
WorkflowApiUploadLifecycleService service,
WorkflowRunningParameterResolver parameterResolver,
FileStorageService fileStorageService,
WorkflowApiUploadStore uploadStore,
RedisLockExecutor redisLockExecutor) {
}
}

View File

@@ -0,0 +1,135 @@
package tech.easyflow.ai.easyagentsflow.upload;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.ArgumentMatchers;
import org.mockito.Mockito;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.data.redis.core.script.RedisScript;
import java.util.List;
/**
* {@link WorkflowApiUploadStore} Redis 原子性契约测试。
*/
public class WorkflowApiUploadStoreTest {
/**
* 验证记录与清理索引通过同槽 Lua 脚本原子创建。
*/
@Test
@SuppressWarnings({"unchecked", "rawtypes"})
public void createShouldUseSameSlotAtomicScript() {
StringRedisTemplate redisTemplate =
Mockito.mock(StringRedisTemplate.class);
Mockito.doReturn(1L).when(redisTemplate).execute(
ArgumentMatchers.<RedisScript<Long>>any(),
ArgumentMatchers.<List<String>>any(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString());
WorkflowApiUploadStore store = new WorkflowApiUploadStore(
redisTemplate,
new ObjectMapper());
WorkflowApiUploadRecord record = record("request-1", null);
store.create(record);
ArgumentCaptor<RedisScript<Long>> scriptCaptor =
ArgumentCaptor.forClass((Class) RedisScript.class);
ArgumentCaptor<List<String>> keysCaptor =
ArgumentCaptor.forClass((Class) List.class);
Mockito.verify(redisTemplate).execute(
scriptCaptor.capture(),
keysCaptor.capture(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString());
Assert.assertTrue(
scriptCaptor.getValue().getScriptAsString()
.contains("redis.call('zadd'"));
Assert.assertEquals(3, keysCaptor.getValue().size());
Assert.assertTrue(keysCaptor.getValue().stream()
.allMatch(key -> key.contains("{api-upload}")));
}
/**
* 验证重新绑定执行 ID 时会在同一脚本中清除旧索引。
*
* @throws Exception 上传记录序列化失败
*/
@Test
@SuppressWarnings({"unchecked", "rawtypes"})
public void bindExecutionShouldReplacePreviousIndexAtomically()
throws Exception {
StringRedisTemplate redisTemplate =
Mockito.mock(StringRedisTemplate.class);
ValueOperations<String, String> valueOperations =
Mockito.mock(ValueOperations.class);
Mockito.when(redisTemplate.opsForValue())
.thenReturn(valueOperations);
ObjectMapper objectMapper = new ObjectMapper();
WorkflowApiUploadRecord record =
record("request-1", "execution-old");
Mockito.when(valueOperations.get(
"easyflow:workflow:{api-upload}:record:request-1"))
.thenReturn(objectMapper.writeValueAsString(record));
Mockito.doReturn(1L).when(redisTemplate).execute(
ArgumentMatchers.<RedisScript<Long>>any(),
ArgumentMatchers.<List<String>>any(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString());
WorkflowApiUploadStore store = new WorkflowApiUploadStore(
redisTemplate,
objectMapper);
store.bindExecution("request-1", "execution-new");
ArgumentCaptor<RedisScript<Long>> scriptCaptor =
ArgumentCaptor.forClass((Class) RedisScript.class);
ArgumentCaptor<List<String>> keysCaptor =
ArgumentCaptor.forClass((Class) List.class);
Mockito.verify(redisTemplate).execute(
scriptCaptor.capture(),
keysCaptor.capture(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString());
Assert.assertEquals(
"easyflow:workflow:{api-upload}:execution:execution-new",
keysCaptor.getValue().get(1));
Assert.assertEquals(
"easyflow:workflow:{api-upload}:execution:execution-old",
keysCaptor.getValue().get(2));
Assert.assertTrue(
scriptCaptor.getValue().getScriptAsString()
.contains("redis.call('del', KEYS[3])"));
}
/**
* 创建测试上传记录。
*
* @param requestId 上传请求 ID
* @param executeId 执行 ID
* @return 上传记录
*/
private WorkflowApiUploadRecord record(
String requestId,
String executeId) {
WorkflowApiUploadRecord record = new WorkflowApiUploadRecord();
record.setRequestId(requestId);
record.setExecuteId(executeId);
record.setCleanupAt(1_000L);
return record;
}
}

View File

@@ -0,0 +1,145 @@
package tech.easyflow.ai.easyagentsflow.upload;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.Mockito;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Optional;
/**
* {@link WorkflowApiUploadedFileReader} 上传记录授权边界测试。
*/
public class WorkflowApiUploadedFileReaderTest {
private static final String REQUEST_ID =
"0123456789abcdef0123456789abcdef";
private static final String FILENAME =
"000-abcdefabcdefabcdefabcdefabcdefab.docx";
private static final String FILE_URL =
"http://127.0.0.1:39000/easyflow/attachment/"
+ "workflow-api-upload/" + REQUEST_ID + "/" + FILENAME;
/**
* 验证 URL、上传记录与恢复句柄完全匹配后按固定后端读取。
*
* @throws Exception 测试流读取失败时抛出
*/
@Test
public void shouldReadExactRecordedUploadByRecoverableHandle() throws Exception {
WorkflowApiUploadStore uploadStore = Mockito.mock(WorkflowApiUploadStore.class);
FileStorageService fileStorageService = Mockito.mock(FileStorageService.class);
WorkflowApiUploadedFileReader reader =
new WorkflowApiUploadedFileReader(uploadStore, fileStorageService);
FileStorageWriteHandle handle = handle();
WorkflowApiUploadRecord record = record(FILE_URL, handle);
byte[] content = "document-content".getBytes(StandardCharsets.UTF_8);
Mockito.when(uploadStore.find(REQUEST_ID)).thenReturn(Optional.of(record));
Mockito.when(fileStorageService.readRecoverable(handle))
.thenReturn(new ByteArrayInputStream(content));
Optional<InputStream> opened = reader.openVerified(FILE_URL);
Assert.assertTrue(opened.isPresent());
try (InputStream inputStream = opened.orElseThrow()) {
Assert.assertArrayEquals(content, inputStream.readAllBytes());
}
Mockito.verify(fileStorageService).readRecoverable(handle);
}
/**
* 验证普通远端 URL 不访问上传记录,也不获得内部读取权限。
*
* @throws IOException 路径解析失败时抛出
*/
@Test
public void shouldIgnoreOrdinaryRemoteUrl() throws IOException {
WorkflowApiUploadStore uploadStore = Mockito.mock(WorkflowApiUploadStore.class);
FileStorageService fileStorageService = Mockito.mock(FileStorageService.class);
WorkflowApiUploadedFileReader reader =
new WorkflowApiUploadedFileReader(uploadStore, fileStorageService);
Optional<InputStream> opened = reader.openVerified(
"http://127.0.0.1:39000/easyflow/attachment/ordinary.docx");
Assert.assertTrue(opened.isEmpty());
Mockito.verifyNoInteractions(uploadStore, fileStorageService);
}
/**
* 验证看似系统目录的 URL 在 Redis 记录不存在时明确判定为失效。
*/
@Test
public void shouldRejectManagedPathWhenUploadRecordExpired() {
WorkflowApiUploadStore uploadStore = Mockito.mock(WorkflowApiUploadStore.class);
FileStorageService fileStorageService = Mockito.mock(FileStorageService.class);
WorkflowApiUploadedFileReader reader =
new WorkflowApiUploadedFileReader(uploadStore, fileStorageService);
Mockito.when(uploadStore.find(REQUEST_ID)).thenReturn(Optional.empty());
IOException exception = Assert.assertThrows(
IOException.class,
() -> reader.openVerified(FILE_URL));
Assert.assertTrue(exception.getMessage().contains("已失效"));
Mockito.verifyNoInteractions(fileStorageService);
}
/**
* 验证同一请求 ID 下未记录的 URL 不能复用其他文件的存储 locator。
*/
@Test
public void shouldRejectUrlThatDoesNotExactlyMatchStoredFile() {
WorkflowApiUploadStore uploadStore = Mockito.mock(WorkflowApiUploadStore.class);
FileStorageService fileStorageService = Mockito.mock(FileStorageService.class);
WorkflowApiUploadedFileReader reader =
new WorkflowApiUploadedFileReader(uploadStore, fileStorageService);
Mockito.when(uploadStore.find(REQUEST_ID)).thenReturn(Optional.of(
record(FILE_URL + "?different=true", handle())));
IOException exception = Assert.assertThrows(
IOException.class,
() -> reader.openVerified(FILE_URL));
Assert.assertTrue(exception.getMessage().contains("不匹配"));
Mockito.verifyNoInteractions(fileStorageService);
}
/**
* 创建与系统上传目录一致的恢复句柄。
*
* @return 测试句柄
*/
private FileStorageWriteHandle handle() {
return new FileStorageWriteHandle(
"local",
"",
"/tmp/easyflow-test",
"workflow-api-upload/" + REQUEST_ID,
FILENAME);
}
/**
* 创建包含单个受管文件的上传记录。
*
* @param fileUrl 记录中的完整文件 URL
* @param handle 文件存储句柄
* @return 上传记录
*/
private WorkflowApiUploadRecord record(
String fileUrl,
FileStorageWriteHandle handle) {
WorkflowApiUploadRecord record = new WorkflowApiUploadRecord();
record.setRequestId(REQUEST_ID);
record.setStoredFiles(List.of(new WorkflowApiStoredFile(
fileUrl,
handle.encodeLocator())));
return record;
}
}

View File

@@ -2,6 +2,8 @@ package tech.easyflow.ai.node;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.Mockito;
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadedFileReader;
import tech.easyflow.ai.document.model.DocumentParseTaskInfo;
import tech.easyflow.ai.document.model.DocumentParseTaskStatus;
import tech.easyflow.ai.document.model.DocumentParsedResult;
@@ -14,13 +16,12 @@ import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.sun.net.httpserver.HttpServer;
import java.util.Optional;
/**
* {@link DocNodeFileContentExtractor} 单元测试。
@@ -175,42 +176,63 @@ public class DocNodeFileContentExtractorTest {
}
/**
* 验证远端素材 URL 的非桥接文件不会误走本地存储读取
* 验证普通远端素材 URL 的非桥接文件仍拒绝访问回环地址
*/
@Test
public void shouldReadRemoteUrlForUnsupportedType() {
public void shouldRejectLoopbackRemoteUrlForUnsupportedType() {
RecordingDocumentParseBridgeService bridgeService = new RecordingDocumentParseBridgeService();
HttpServer server;
try {
server = HttpServer.create(new InetSocketAddress(0), 0);
} catch (IOException e) {
throw new RuntimeException(e);
DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
bridgeService,
new FailingFileStorageService(),
new ReadingReaderManager()
);
RuntimeException exception = Assert.assertThrows(
RuntimeException.class,
() -> extractor.extract(buildFileValue(
"note.txt",
"http://127.0.0.1:39000/note.txt",
"text/plain")));
Throwable cause = exception;
while (cause != null
&& !(cause instanceof java.net.UnknownHostException)) {
cause = cause.getCause();
}
byte[] body = "remote text".getBytes(StandardCharsets.UTF_8);
server.createContext("/note.txt", exchange -> {
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.start();
try {
DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
Assert.assertNotNull(cause);
Assert.assertNull(bridgeService.lastSource);
}
/**
* 验证受管上传 URL 的非桥接文件通过记录校验后走内部存储读取。
*
* @throws IOException 测试流配置失败时抛出
*/
@Test
public void shouldReadVerifiedManagedUploadForUnsupportedType() throws IOException {
RecordingDocumentParseBridgeService bridgeService = new RecordingDocumentParseBridgeService();
WorkflowApiUploadedFileReader uploadedFileReader =
Mockito.mock(WorkflowApiUploadedFileReader.class);
String fileUrl = "http://127.0.0.1:39000/easyflow/attachment/"
+ "workflow-api-upload/0123456789abcdef0123456789abcdef/note.txt";
byte[] body = "managed text".getBytes(StandardCharsets.UTF_8);
Mockito.when(uploadedFileReader.isManagedPathCandidate(fileUrl))
.thenReturn(true);
Mockito.when(uploadedFileReader.openVerified(fileUrl))
.thenReturn(Optional.of(new ByteArrayInputStream(body)));
DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
bridgeService,
new FailingFileStorageService(),
new ReadingReaderManager()
);
new ReadingReaderManager(),
uploadedFileReader);
String content = extractor.extract(buildFileValue(
String content = extractor.extract(buildFileValue(
"note.txt",
"http://127.0.0.1:" + server.getAddress().getPort() + "/note.txt",
"text/plain"
));
fileUrl,
"text/plain"));
Assert.assertEquals("remote text", content);
Assert.assertNull(bridgeService.lastSource);
} finally {
server.stop(0);
}
Assert.assertEquals("managed text", content);
Assert.assertNull(bridgeService.lastSource);
}
/**