feat: 完善工作流 Public API 调用能力
- 支持 JSON 文件 URL 简写与 Multipart 单请求文件上传 - 完善执行拓扑、枚举状态、节点名称、恢复校验和安全错误响应 - 增加临时上传生命周期清理并升级 MinIO SDK - 重构工作流接口调用说明弹窗的扁平响应式布局
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
package tech.easyflow.publicapi.service;
|
||||
|
||||
import com.easyagents.flow.core.chain.ChainStatus;
|
||||
import com.easyagents.flow.core.chain.NodeStatus;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
|
||||
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
|
||||
import tech.easyflow.publicapi.dto.PublicWorkflowChainStatus;
|
||||
import tech.easyflow.publicapi.dto.PublicWorkflowExecutionStatus;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* {@link PublicWorkflowStatusSanitizer} 公共错误脱敏测试。
|
||||
*/
|
||||
public class PublicWorkflowStatusSanitizerTest {
|
||||
|
||||
private final PublicWorkflowStatusSanitizer sanitizer =
|
||||
new PublicWorkflowStatusSanitizer();
|
||||
|
||||
/**
|
||||
* 验证内部异常类名和依赖详情不会进入公共状态。
|
||||
*/
|
||||
@Test
|
||||
public void shouldHideInternalExecutionErrorDetails() {
|
||||
ChainInfo source = new ChainInfo();
|
||||
source.setExecuteId("execute-1");
|
||||
source.setStatus(ChainStatus.FAILED.getValue());
|
||||
source.setMessage(
|
||||
"io.minio.errors.ErrorResponseException --> signature mismatch at http://internal:9000");
|
||||
NodeInfo node = new NodeInfo();
|
||||
node.setNodeId("node-1");
|
||||
node.setNodeName("文档解析");
|
||||
node.setStatus(NodeStatus.FAILED.getValue());
|
||||
node.setMessage("java.lang.IllegalStateException --> bucket-secret");
|
||||
source.setNodes(Map.of("node-1", node));
|
||||
|
||||
PublicWorkflowChainStatus result = sanitizer.sanitize(source);
|
||||
|
||||
Assert.assertEquals(
|
||||
"工作流执行失败,请检查输入或稍后重试",
|
||||
result.message());
|
||||
Assert.assertFalse(result.message().contains("minio"));
|
||||
Assert.assertEquals(
|
||||
"节点执行失败,请检查输入或稍后重试",
|
||||
result.nodes().get("node-1").message());
|
||||
Assert.assertEquals("node-1", result.error().getNodeId());
|
||||
Assert.assertFalse(result.error().isRetryable());
|
||||
Assert.assertEquals(
|
||||
PublicWorkflowExecutionStatus.FAILED,
|
||||
result.status());
|
||||
Assert.assertTrue(result.terminal());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证成功状态保持原有响应且不增加错误对象。
|
||||
*/
|
||||
@Test
|
||||
public void shouldKeepSuccessfulStatusWithoutError() {
|
||||
ChainInfo source = new ChainInfo();
|
||||
source.setExecuteId("execute-1");
|
||||
source.setStatus(ChainStatus.SUCCEEDED.getValue());
|
||||
source.setResult(Map.of("output", "ok"));
|
||||
|
||||
PublicWorkflowChainStatus result = sanitizer.sanitize(source);
|
||||
|
||||
Assert.assertEquals(source.getResult(), result.result());
|
||||
Assert.assertEquals(
|
||||
PublicWorkflowExecutionStatus.DONE,
|
||||
result.status());
|
||||
Assert.assertTrue(result.terminal());
|
||||
Assert.assertNull(result.message());
|
||||
Assert.assertNull(result.error());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证节点名称保持不变,并将节点状态转换为可读枚举。
|
||||
*/
|
||||
@Test
|
||||
public void shouldKeepNodeNameAndReadableStatus() {
|
||||
ChainInfo source = new ChainInfo();
|
||||
source.setExecuteId("execute-1");
|
||||
source.setStatus(ChainStatus.RUNNING.getValue());
|
||||
NodeInfo node = new NodeInfo();
|
||||
node.setNodeId("node-1");
|
||||
node.setNodeName("文档解析");
|
||||
node.setStatus(NodeStatus.RUNNING.getValue());
|
||||
source.setNodes(Map.of("node-1", node));
|
||||
|
||||
PublicWorkflowChainStatus result = sanitizer.sanitize(source);
|
||||
|
||||
Assert.assertEquals(
|
||||
PublicWorkflowExecutionStatus.RUNNING,
|
||||
result.status());
|
||||
Assert.assertFalse(result.terminal());
|
||||
Assert.assertEquals(
|
||||
"文档解析",
|
||||
result.nodes().get("node-1").nodeName());
|
||||
Assert.assertEquals(
|
||||
PublicWorkflowExecutionStatus.RUNNING,
|
||||
result.nodes().get("node-1").status());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package tech.easyflow.publicapi.service;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.publicapi.dto.PublicWorkflowTopology;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* {@link PublicWorkflowTopologyService} 公开拓扑契约测试。
|
||||
*/
|
||||
public class PublicWorkflowTopologyServiceTest {
|
||||
|
||||
private final PublicWorkflowTopologyService service =
|
||||
new PublicWorkflowTopologyService();
|
||||
|
||||
/**
|
||||
* 验证分支工作流返回稳定拓扑序、并行层级和完整邻接信息。
|
||||
*/
|
||||
@Test
|
||||
public void resolveShouldReturnStableTopologyAndNodeMetadata() {
|
||||
Workflow workflow = workflow("""
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "branch-b",
|
||||
"type": "codeNode",
|
||||
"data": {
|
||||
"title": "分支 B",
|
||||
"description": "处理 B",
|
||||
"parameters": [
|
||||
{
|
||||
"id": "input-1",
|
||||
"name": "content",
|
||||
"formLabel": "内容",
|
||||
"dataType": "String",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"script": "private-secret-script"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "start",
|
||||
"type": "startNode",
|
||||
"data": {"title": "开始"}
|
||||
},
|
||||
{
|
||||
"id": "branch-a",
|
||||
"type": "llmNode",
|
||||
"data": {
|
||||
"title": "分支 A",
|
||||
"systemPrompt": "private-secret-prompt"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "isolated",
|
||||
"type": "codeNode",
|
||||
"data": {"title": "孤立节点"}
|
||||
},
|
||||
{
|
||||
"id": "end",
|
||||
"type": "endNode",
|
||||
"data": {
|
||||
"title": "结束",
|
||||
"outputDefs": [
|
||||
{
|
||||
"id": "output-1",
|
||||
"name": "answer",
|
||||
"dataType": "String"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "start-b",
|
||||
"source": "start",
|
||||
"target": "branch-b"
|
||||
},
|
||||
{
|
||||
"id": "start-a",
|
||||
"source": "start",
|
||||
"target": "branch-a"
|
||||
},
|
||||
{
|
||||
"id": "b-end",
|
||||
"source": "branch-b",
|
||||
"target": "end"
|
||||
},
|
||||
{
|
||||
"id": "a-end",
|
||||
"source": "branch-a",
|
||||
"target": "end"
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
PublicWorkflowTopology topology = service.resolve(workflow);
|
||||
|
||||
Assert.assertEquals(
|
||||
List.of(
|
||||
"start",
|
||||
"branch-b",
|
||||
"branch-a",
|
||||
"isolated",
|
||||
"end"),
|
||||
topology.topologicalOrder());
|
||||
Assert.assertEquals(
|
||||
List.of(
|
||||
List.of("start", "isolated"),
|
||||
List.of("branch-b", "branch-a"),
|
||||
List.of("end")),
|
||||
topology.topologyLevels());
|
||||
Assert.assertFalse(topology.hasCycle());
|
||||
Assert.assertEquals(5, topology.nodes().size());
|
||||
Assert.assertEquals(4, topology.edges().size());
|
||||
|
||||
PublicWorkflowTopology.Node start = topology.nodes().get(0);
|
||||
Assert.assertTrue(start.startNode());
|
||||
Assert.assertEquals(
|
||||
List.of("branch-b", "branch-a"),
|
||||
start.successorNodeIds());
|
||||
Assert.assertEquals(
|
||||
List.of("start-b", "start-a"),
|
||||
start.outgoingEdgeIds());
|
||||
Assert.assertFalse(topology.nodes().get(3).startNode());
|
||||
Assert.assertFalse(topology.nodes().get(3).endNode());
|
||||
|
||||
PublicWorkflowTopology.Node branchB = topology.nodes().get(1);
|
||||
Assert.assertEquals(1, branchB.topologyLevel());
|
||||
Assert.assertEquals(1, branchB.inputParameters().size());
|
||||
Assert.assertEquals(
|
||||
"content",
|
||||
branchB.inputParameters().get(0).name());
|
||||
|
||||
String serialized = JSON.toJSONString(topology);
|
||||
Assert.assertFalse(serialized.contains("private-secret-script"));
|
||||
Assert.assertFalse(serialized.contains("private-secret-prompt"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证意外环路会显式标记,并且响应仍包含全部节点。
|
||||
*/
|
||||
@Test
|
||||
public void resolveShouldExposeCycleWithoutDroppingNodes() {
|
||||
Workflow workflow = workflow("""
|
||||
{
|
||||
"nodes": [
|
||||
{"id": "a", "type": "codeNode", "data": {"title": "A"}},
|
||||
{"id": "b", "type": "codeNode", "data": {"title": "B"}},
|
||||
{"id": "c", "type": "endNode", "data": {"title": "C"}}
|
||||
],
|
||||
"edges": [
|
||||
{"id": "a-b", "source": "a", "target": "b"},
|
||||
{"id": "b-a", "source": "b", "target": "a"},
|
||||
{"id": "b-c", "source": "b", "target": "c"}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
PublicWorkflowTopology topology = service.resolve(workflow);
|
||||
|
||||
Assert.assertTrue(topology.hasCycle());
|
||||
Assert.assertEquals(
|
||||
List.of("a", "b", "c"),
|
||||
topology.unresolvedNodeIds());
|
||||
Assert.assertEquals(
|
||||
List.of("a", "b", "c"),
|
||||
topology.topologicalOrder());
|
||||
Assert.assertEquals(-1, topology.nodes().get(0).topologyLevel());
|
||||
Assert.assertEquals(3, topology.nodes().size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证循环体完成后才会进入循环节点的同作用域后继。
|
||||
*/
|
||||
@Test
|
||||
public void resolveShouldPlaceLoopBodyBeforeLoopDownstream() {
|
||||
Workflow workflow = workflow("""
|
||||
{
|
||||
"nodes": [
|
||||
{"id": "after", "type": "codeNode", "data": {"title": "循环后"}},
|
||||
{"id": "inside-end", "type": "codeNode", "parentId": "loop", "data": {"title": "循环体末节点"}},
|
||||
{"id": "start", "type": "startNode", "data": {"title": "开始"}},
|
||||
{"id": "loop", "type": "loopNode", "data": {"title": "循环"}},
|
||||
{"id": "inside-start", "type": "codeNode", "parentId": "loop", "data": {"title": "循环体入口"}},
|
||||
{"id": "end", "type": "endNode", "data": {"title": "结束"}}
|
||||
],
|
||||
"edges": [
|
||||
{"id": "start-loop", "source": "start", "target": "loop"},
|
||||
{"id": "loop-inside", "source": "loop", "target": "inside-start"},
|
||||
{"id": "inside-next", "source": "inside-start", "target": "inside-end"},
|
||||
{"id": "loop-after", "source": "loop", "target": "after"},
|
||||
{"id": "after-end", "source": "after", "target": "end"}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
PublicWorkflowTopology topology = service.resolve(workflow);
|
||||
|
||||
Assert.assertEquals(
|
||||
List.of(
|
||||
"start",
|
||||
"loop",
|
||||
"inside-start",
|
||||
"inside-end",
|
||||
"after",
|
||||
"end"),
|
||||
topology.topologicalOrder());
|
||||
Assert.assertEquals(
|
||||
List.of(
|
||||
List.of("start"),
|
||||
List.of("loop"),
|
||||
List.of("inside-start"),
|
||||
List.of("inside-end"),
|
||||
List.of("after"),
|
||||
List.of("end")),
|
||||
topology.topologyLevels());
|
||||
Assert.assertFalse(topology.hasCycle());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证开始节点文件参数公开无歧义的 multipart Part 名。
|
||||
*/
|
||||
@Test
|
||||
public void resolveShouldExposeNamespacedMultipartPartName() {
|
||||
Workflow workflow = workflow("""
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "start",
|
||||
"type": "startNode",
|
||||
"data": {
|
||||
"title": "开始",
|
||||
"parameters": [
|
||||
{
|
||||
"id": "file-1",
|
||||
"name": "metadata",
|
||||
"dataType": "File",
|
||||
"contentType": "file"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{"id": "end", "type": "endNode", "data": {"title": "结束"}}
|
||||
],
|
||||
"edges": [
|
||||
{"id": "start-end", "source": "start", "target": "end"}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
PublicWorkflowTopology topology = service.resolve(workflow);
|
||||
|
||||
PublicWorkflowTopology.Parameter parameter =
|
||||
topology.nodes().get(0).inputParameters().get(0);
|
||||
Assert.assertEquals("metadata", parameter.name());
|
||||
Assert.assertEquals(
|
||||
"files.metadata",
|
||||
parameter.multipartPartName());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建测试工作流。
|
||||
*
|
||||
* @param content 发布快照内容
|
||||
* @return 测试工作流
|
||||
*/
|
||||
private Workflow workflow(String content) {
|
||||
Workflow workflow = new Workflow();
|
||||
workflow.setId(BigInteger.valueOf(101));
|
||||
workflow.setAlias("public-demo");
|
||||
workflow.setTitle("公开工作流");
|
||||
workflow.setDescription("公开描述");
|
||||
workflow.setRevision(7);
|
||||
workflow.setPublishedAt(Date.from(
|
||||
Instant.parse("2026-08-07T00:00:00Z")));
|
||||
workflow.setContent(content);
|
||||
return workflow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package tech.easyflow.publicapi.service;
|
||||
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* {@link WorkflowApiMultipartParameterMapper} 参数映射测试。
|
||||
*/
|
||||
public class WorkflowApiMultipartParameterMapperTest {
|
||||
|
||||
private final WorkflowApiMultipartParameterMapper mapper =
|
||||
new WorkflowApiMultipartParameterMapper();
|
||||
|
||||
/**
|
||||
* 验证 API metadata 与同名工作流文件参数互不冲突。
|
||||
*/
|
||||
@Test
|
||||
public void mapShouldSeparateApiMetadataAndWorkflowParameter() {
|
||||
MultiValueMap<String, MultipartFile> parts =
|
||||
new LinkedMultiValueMap<>();
|
||||
parts.add(
|
||||
"metadata",
|
||||
file("metadata", "metadata.json", "{}"));
|
||||
parts.add(
|
||||
"files.metadata",
|
||||
file("files.metadata", "one.pdf", "one"));
|
||||
parts.add(
|
||||
"files.metadata",
|
||||
file("files.metadata", "two.pdf", "two"));
|
||||
|
||||
Map<String, List<MultipartFile>> mapped = mapper.map(parts);
|
||||
|
||||
Assert.assertEquals(1, mapped.size());
|
||||
Assert.assertEquals(2, mapped.get("metadata").size());
|
||||
Assert.assertEquals(
|
||||
"one.pdf",
|
||||
mapped.get("metadata").get(0).getOriginalFilename());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证未使用文件命名空间的 Part 会被明确拒绝。
|
||||
*/
|
||||
@Test
|
||||
public void mapShouldRejectUnnamespacedFilePart() {
|
||||
MultiValueMap<String, MultipartFile> parts =
|
||||
new LinkedMultiValueMap<>();
|
||||
parts.add(
|
||||
"documents",
|
||||
file("documents", "one.pdf", "one"));
|
||||
|
||||
BusinessException exception = Assert.assertThrows(
|
||||
BusinessException.class,
|
||||
() -> mapper.map(parts));
|
||||
|
||||
Assert.assertTrue(
|
||||
exception.getMessage().contains("files.<"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Part 参数名不会被隐式 trim 后误映射到另一个工作流参数。
|
||||
*/
|
||||
@Test
|
||||
public void mapShouldRejectWhitespaceAlteredParameterName() {
|
||||
MultiValueMap<String, MultipartFile> parts =
|
||||
new LinkedMultiValueMap<>();
|
||||
parts.add(
|
||||
"files. metadata",
|
||||
file("files. metadata", "one.pdf", "one"));
|
||||
|
||||
BusinessException exception = Assert.assertThrows(
|
||||
BusinessException.class,
|
||||
() -> mapper.map(parts));
|
||||
|
||||
Assert.assertTrue(
|
||||
exception.getMessage().contains("参数名无效"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建测试文件 Part。
|
||||
*
|
||||
* @param partName Part 名
|
||||
* @param filename 文件名
|
||||
* @param content 文件内容
|
||||
* @return 测试文件
|
||||
*/
|
||||
private MultipartFile file(
|
||||
String partName,
|
||||
String filename,
|
||||
String content) {
|
||||
return new TestMultipartFile(
|
||||
partName,
|
||||
filename,
|
||||
"application/octet-stream",
|
||||
content.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
/**
|
||||
* 无需 Spring Test 依赖的最小 MultipartFile 测试实现。
|
||||
*/
|
||||
private record TestMultipartFile(
|
||||
String name,
|
||||
String originalFilename,
|
||||
String contentType,
|
||||
byte[] bytes) implements MultipartFile {
|
||||
|
||||
/**
|
||||
* 获取 Part 名。
|
||||
*
|
||||
* @return Part 名
|
||||
*/
|
||||
@Override
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取原始文件名。
|
||||
*
|
||||
* @return 原始文件名
|
||||
*/
|
||||
@Override
|
||||
public String getOriginalFilename() {
|
||||
return originalFilename;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取内容类型。
|
||||
*
|
||||
* @return 内容类型
|
||||
*/
|
||||
@Override
|
||||
public String getContentType() {
|
||||
return contentType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文件是否为空。
|
||||
*
|
||||
* @return 是否为空
|
||||
*/
|
||||
@Override
|
||||
public boolean isEmpty() {
|
||||
return bytes.length == 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件字节数。
|
||||
*
|
||||
* @return 文件字节数
|
||||
*/
|
||||
@Override
|
||||
public long getSize() {
|
||||
return bytes.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件内容。
|
||||
*
|
||||
* @return 文件内容副本
|
||||
*/
|
||||
@Override
|
||||
public byte[] getBytes() {
|
||||
return bytes.clone();
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开文件内容流。
|
||||
*
|
||||
* @return 文件内容流
|
||||
*/
|
||||
@Override
|
||||
public InputStream getInputStream() {
|
||||
return new ByteArrayInputStream(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将测试文件写入目标文件。
|
||||
*
|
||||
* @param destination 目标文件
|
||||
* @throws IOException 写入失败
|
||||
*/
|
||||
@Override
|
||||
public void transferTo(File destination) throws IOException {
|
||||
Files.write(destination.toPath(), bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user