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

@@ -5,9 +5,12 @@ import cn.dev33.satoken.stp.StpUtil;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.MultipartHttpServletRequest;
import tech.easyflow.approval.annotation.RequirePublishedAccess;
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
@@ -16,6 +19,8 @@ import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiPreparedUpload;
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadLifecycleService;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.entity.WorkflowExecResult;
import tech.easyflow.ai.enums.PublishStatus;
@@ -29,12 +34,22 @@ import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.publicapi.dto.PublicWorkflowInfo;
import tech.easyflow.publicapi.dto.PublicWorkflowRunMetadata;
import tech.easyflow.publicapi.dto.PublicWorkflowRunResult;
import tech.easyflow.publicapi.dto.PublicWorkflowChainStatus;
import tech.easyflow.publicapi.dto.PublicWorkflowTopology;
import tech.easyflow.publicapi.service.WorkflowApiMultipartParameterMapper;
import tech.easyflow.publicapi.service.PublicWorkflowTopologyService;
import tech.easyflow.publicapi.service.PublicWorkflowStatusSanitizer;
import tech.easyflow.system.entity.SysApiKey;
import java.math.BigInteger;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
/**
* 工作流
@@ -57,23 +72,32 @@ public class PublicWorkflowController {
private WorkflowApiPermissionService workflowApiPermissionService;
@Resource
private WorkflowExecResultService workflowExecResultService;
@Resource
private WorkflowApiUploadLifecycleService workflowApiUploadLifecycleService;
@Resource
private PublicWorkflowTopologyService publicWorkflowTopologyService;
@Resource
private PublicWorkflowStatusSanitizer publicWorkflowStatusSanitizer;
@Resource
private WorkflowApiMultipartParameterMapper
workflowApiMultipartParameterMapper;
/**
* 通过id或别名获取工作流详情
*
* @param key id或者别名
* @return 工作流详情
* @return 工作流安全基础信息
*/
@GetMapping(value = "/getByIdOrAlias")
@RequirePublishedAccess(resourceType = "WORKFLOW", idExpr = "#key", denyMessage = "工作流尚未发布")
public Result<Workflow> getByIdOrAlias(
public Result<PublicWorkflowInfo> getByIdOrAlias(
@RequestParam
@NotBlank(message = "key不能为空") String key,
HttpServletRequest request) {
workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
Workflow workflow = workflowService.getPublishedDetail(key);
assertStrictPublishedWorkflow(workflow);
return Result.ok(workflow);
return Result.ok(PublicWorkflowInfo.from(workflow));
}
/**
@@ -102,38 +126,116 @@ public class PublicWorkflowController {
}
/**
* 运行工作流 - v2
* 使用 JSON 参数异步运行已发布工作流。
*
* @param metadata 工作流 ID 与运行变量
* @param request Servlet 请求
* @return 保留执行 ID 字符串并附带工作流拓扑的响应
*/
@PostMapping("/runAsync")
@RequirePublishedAccess(resourceType = "WORKFLOW", idExpr = "#id", denyMessage = "工作流尚未发布")
public Result<String> runAsync(@JsonBody(value = "id", required = true) BigInteger id,
@JsonBody("variables") Map<String, Object> variables,
HttpServletRequest request) {
@PostMapping(
value = "/runAsync",
consumes = MediaType.APPLICATION_JSON_VALUE)
@RequirePublishedAccess(
resourceType = "WORKFLOW",
idExpr = "#metadata.id",
denyMessage = "工作流尚未发布")
public PublicWorkflowRunResult runAsync(
@Valid @RequestBody PublicWorkflowRunMetadata metadata,
HttpServletRequest request) {
SysApiKey apiKey = workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
if (variables == null) {
variables = new HashMap<>();
Workflow workflow = loadExecutableWorkflow(metadata.getId());
PublicWorkflowTopology topology =
publicWorkflowTopologyService.resolve(workflow);
Map<String, Object> normalized =
workflowRunningParameterResolver.normalizeRuntimeVariables(
workflow.getContent(),
metadata.getVariables());
return executePublishedWorkflow(
workflow,
normalized,
apiKey,
topology,
null);
}
/**
* 通过单个 multipart 请求上传文件并异步运行工作流。
*
* <p>{@code metadata} Part 使用 JSON工作流文件 Part 使用
* {@code files.<开始节点文件参数名>},同名 Part 可重复上传多个文件。</p>
*
* @param metadata 工作流 ID 与普通运行变量
* @param multipartRequest multipart 请求
* @param request Servlet 请求
* @return 保留执行 ID 字符串并附带工作流拓扑的响应
*/
@PostMapping(
value = "/runAsync",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@RequirePublishedAccess(
resourceType = "WORKFLOW",
idExpr = "#metadata.id",
denyMessage = "工作流尚未发布")
public PublicWorkflowRunResult runAsyncMultipart(
@Valid
@RequestPart("metadata")
PublicWorkflowRunMetadata metadata,
MultipartHttpServletRequest multipartRequest,
HttpServletRequest request) {
SysApiKey apiKey =
workflowApiPermissionService.assertWorkflowApi(
request.getHeader("ApiKey"),
request.getRequestURI());
Workflow workflow = loadExecutableWorkflow(metadata.getId());
PublicWorkflowTopology topology =
publicWorkflowTopologyService.resolve(workflow);
Map<String, List<MultipartFile>> fileParts =
workflowApiMultipartParameterMapper.map(
multipartRequest.getMultiFileMap());
WorkflowApiPreparedUpload preparedUpload =
workflowApiUploadLifecycleService.prepare(
workflow.getContent(),
metadata.getVariables(),
fileParts);
try {
return executePublishedWorkflow(
workflow,
preparedUpload.getVariables(),
apiKey,
topology,
executeId ->
workflowApiUploadLifecycleService.bindExecution(
preparedUpload.getRequestId(),
executeId));
} catch (RuntimeException | Error error) {
try {
workflowApiUploadLifecycleService.abort(
preparedUpload.getRequestId());
} catch (RuntimeException cleanupError) {
error.addSuppressed(cleanupError);
}
throw error;
}
Workflow workflow = workflowService.getPublishedById(id);
assertStrictPublishedWorkflow(workflow);
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
variables = workflowRunningParameterResolver.normalizeRuntimeVariables(workflow.getContent(), variables);
variables.put(Constants.LOGIN_USER_KEY, buildApiKeyLoginAccount(apiKey));
variables.put(WorkFlowUtil.CREATED_KEY_MEMORY_KEY, WorkFlowUtil.API_KEY);
String executeId = chainExecutor.executeAsync(PublishedWorkflowDefinitionIds.published(id.toString()), variables);
return Result.ok(executeId);
}
/**
* 获取工作流运行状态 - v2
*/
@PostMapping("/getChainStatus")
public Result<ChainInfo> getChainStatus(@JsonBody(value = "executeId") String executeId,
@JsonBody("nodes") List<NodeInfo> nodes,
HttpServletRequest request) {
public Result<PublicWorkflowChainStatus> getChainStatus(
@JsonBody(value = "executeId") String executeId,
@JsonBody("nodes") List<NodeInfo> nodes,
HttpServletRequest request) {
SysApiKey apiKey = workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
assertApiKeyExecutionOwnership(apiKey, executeId);
ChainInfo res = tinyFlowService.getChainStatus(executeId, nodes);
return Result.ok(res);
if (res == null) {
throw new BusinessException(
404,
40402,
"执行记录不存在、已过期或不可访问");
}
return Result.ok(publicWorkflowStatusSanitizer.sanitize(res));
}
/**
@@ -146,7 +248,14 @@ public class PublicWorkflowController {
SysApiKey apiKey = workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
WorkflowExecResult execResult = assertApiKeyExecutionOwnership(apiKey, executeId);
assertWorkflowExecutionResumable(execResult);
chainExecutor.resumeAsync(executeId, confirmParams);
if (!chainExecutor.resumeAsyncIfSuspended(
executeId,
confirmParams)) {
throw new BusinessException(
409,
40901,
"当前执行状态不可恢复,仅暂停中的工作流允许恢复");
}
return Result.ok();
}
@@ -160,7 +269,10 @@ public class PublicWorkflowController {
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
Map<String, Object> res = workflowRunningParameterResolver.buildRunningParametersView(workflow);
if (res == null) {
return Result.fail(2, "节点配置错误,请检查! ");
throw new BusinessException(
500,
50001,
"工作流运行参数配置不可用");
}
return Result.ok(res);
}
@@ -181,6 +293,57 @@ public class PublicWorkflowController {
return account;
}
/**
* 加载并校验可执行的已发布工作流。
*
* @param id 工作流 ID
* @return 已发布工作流视图
*/
private Workflow loadExecutableWorkflow(BigInteger id) {
Workflow workflow = workflowService.getPublishedById(id);
assertStrictPublishedWorkflow(workflow);
workflowCheckService.checkOrThrow(
workflow.getContent(),
WorkflowCheckStage.PRE_EXECUTE,
workflow.getId());
return workflow;
}
/**
* 使用统一身份和发布快照启动工作流。
*
* @param workflow 已发布工作流
* @param variables 已归一化运行变量
* @param apiKey API Key 实体
* @param topology 对外公开拓扑
* @param beforeStart 首个节点启动前回调
* @return 执行响应
*/
private PublicWorkflowRunResult executePublishedWorkflow(
Workflow workflow,
Map<String, Object> variables,
SysApiKey apiKey,
PublicWorkflowTopology topology,
Consumer<String> beforeStart) {
Map<String, Object> executionVariables =
new LinkedHashMap<>();
if (variables != null) {
executionVariables.putAll(variables);
}
executionVariables.put(
Constants.LOGIN_USER_KEY,
buildApiKeyLoginAccount(apiKey));
executionVariables.put(
WorkFlowUtil.CREATED_KEY_MEMORY_KEY,
WorkFlowUtil.API_KEY);
String executeId = chainExecutor.executeAsync(
PublishedWorkflowDefinitionIds.published(
workflow.getId().toString()),
executionVariables,
beforeStart);
return PublicWorkflowRunResult.success(executeId, topology);
}
/**
* 校验工作流 Public API 只能访问严格已发布且存在发布快照的工作流。
*
@@ -189,7 +352,10 @@ public class PublicWorkflowController {
private void assertStrictPublishedWorkflow(Workflow workflow) {
if (workflow == null || !PublishStatus.PUBLISHED.getCode().equals(workflow.getPublishStatus())
|| workflow.getPublishedSnapshotJson() == null || workflow.getPublishedSnapshotJson().isEmpty()) {
throw new BusinessException("工作流尚未发布");
throw new BusinessException(
404,
40401,
"工作流不存在或当前不可公开调用");
}
}
@@ -202,17 +368,26 @@ public class PublicWorkflowController {
*/
private WorkflowExecResult assertApiKeyExecutionOwnership(SysApiKey apiKey, String executeId) {
if (executeId == null || executeId.isBlank()) {
throw new BusinessException("执行ID不能为空");
throw new BusinessException(
400,
40017,
"executeId 不能为空");
}
WorkflowExecResult execResult = workflowExecResultService.getByExecKey(executeId);
if (execResult == null) {
throw new BusinessException("工作流执行记录不存在,请稍后重试");
throw new BusinessException(
404,
40402,
"工作流执行记录不存在、已过期或不可访问");
}
if (!WorkFlowUtil.API_KEY.equals(execResult.getCreatedKey())
|| apiKey == null
|| apiKey.getId() == null
|| !String.valueOf(apiKey.getId()).equals(execResult.getCreatedBy())) {
throw new BusinessException("无权限访问当前工作流执行记录");
throw new BusinessException(
404,
40402,
"工作流执行记录不存在、已过期或不可访问");
}
return execResult;
}
@@ -224,12 +399,18 @@ public class PublicWorkflowController {
*/
private void assertWorkflowExecutionResumable(WorkflowExecResult execResult) {
if (execResult == null || execResult.getWorkflowId() == null) {
throw new BusinessException("工作流执行记录不存在,请稍后重试");
throw new BusinessException(
404,
40402,
"工作流执行记录不存在、已过期或不可访问");
}
Workflow workflow = workflowService.getById(execResult.getWorkflowId());
if (workflow == null || !PublishStatus.PUBLISHED.getCode().equals(workflow.getPublishStatus())
|| workflow.getPublishedSnapshotJson() == null || workflow.getPublishedSnapshotJson().isEmpty()) {
throw new BusinessException("工作流已下线或不可恢复执行");
throw new BusinessException(
409,
40901,
"工作流已下线或当前执行状态不可恢复");
}
}
}

View File

@@ -0,0 +1,100 @@
package tech.easyflow.publicapi.dto;
import java.io.Serializable;
import java.util.List;
/**
* Public API 可安全返回的机器可读错误详情。
*/
public class PublicApiErrorDetail implements Serializable {
private static final long serialVersionUID = 1L;
private final String requestId;
private final String location;
private final String field;
private final String actual;
private final List<String> expected;
private final boolean retryable;
/**
* 创建公共错误详情。
*
* @param requestId 请求关联标识
* @param location 错误位置
* @param field 错误字段
* @param actual 经脱敏和限长的实际值
* @param expected 合法值或格式
* @param retryable 是否适合直接重试
*/
public PublicApiErrorDetail(
String requestId,
String location,
String field,
String actual,
List<String> expected,
boolean retryable) {
this.requestId = requestId;
this.location = location;
this.field = field;
this.actual = actual;
this.expected = expected == null
? List.of()
: List.copyOf(expected);
this.retryable = retryable;
}
/**
* 获取请求关联标识。
*
* @return 请求关联标识
*/
public String getRequestId() {
return requestId;
}
/**
* 获取错误位置。
*
* @return 错误位置
*/
public String getLocation() {
return location;
}
/**
* 获取错误字段。
*
* @return 错误字段
*/
public String getField() {
return field;
}
/**
* 获取安全实际值。
*
* @return 实际值
*/
public String getActual() {
return actual;
}
/**
* 获取期望值。
*
* @return 不可变期望值列表
*/
public List<String> getExpected() {
return expected;
}
/**
* 判断是否适合直接重试。
*
* @return 是否可重试
*/
public boolean isRetryable() {
return retryable;
}
}

View File

@@ -0,0 +1,37 @@
package tech.easyflow.publicapi.dto;
import java.io.Serializable;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 工作流 Public API 执行状态。
*
* @param executeId 执行 ID
* @param status 可读工作流状态
* @param terminal 是否已经进入终态
* @param message 安全错误消息
* @param result 工作流执行结果
* @param nodes 节点 ID 到节点状态的映射
* @param error 安全错误对象
*/
public record PublicWorkflowChainStatus(
String executeId,
PublicWorkflowExecutionStatus status,
boolean terminal,
String message,
Map<String, Object> result,
Map<String, PublicWorkflowNodeStatus> nodes,
PublicWorkflowStatusError error) implements Serializable {
/**
* 创建不可变公共执行状态。
*/
public PublicWorkflowChainStatus {
nodes = nodes == null
? Map.of()
: Collections.unmodifiableMap(
new LinkedHashMap<>(nodes));
}
}

View File

@@ -0,0 +1,116 @@
package tech.easyflow.publicapi.dto;
import com.easyagents.flow.core.chain.ChainStatus;
import com.easyagents.flow.core.chain.NodeStatus;
import com.fasterxml.jackson.annotation.JsonValue;
/**
* 工作流 Public API 的可读执行状态。
*/
public enum PublicWorkflowExecutionStatus {
/** 尚未开始。 */
READY("ready", false),
/** 正在执行。 */
RUNNING("running", false),
/** 等待外部参数恢复。 */
SUSPENDED("suspended", false),
/** 执行发生暂态错误,运行时仍可能继续处理。 */
ERROR("error", false),
/** 已成功完成。 */
DONE("done", true),
/** 已失败结束。 */
FAILED("failed", true),
/** 已取消。 */
CANCELLED("cancelled", true),
/** 无法识别的状态。 */
UNKNOWN("unknown", false);
/** 对外字符串值。 */
private final String value;
/** 是否为终态。 */
private final boolean terminal;
/**
* 创建公开执行状态。
*
* @param value 对外字符串值
* @param terminal 是否为终态
*/
PublicWorkflowExecutionStatus(
String value,
boolean terminal) {
this.value = value;
this.terminal = terminal;
}
/**
* 获取 JSON 响应中的状态值。
*
* @return 小写状态值
*/
@JsonValue
public String getValue() {
return value;
}
/**
* 判断是否为终态。
*
* @return 已结束时返回 {@code true}
*/
public boolean isTerminal() {
return terminal;
}
/**
* 将内部工作流状态转换为公开枚举。
*
* @param status 内部数值状态
* @return 公开状态枚举
*/
public static PublicWorkflowExecutionStatus fromChainStatus(
Integer status) {
if (status == null) {
return UNKNOWN;
}
ChainStatus chainStatus = ChainStatus.fromValue(status);
if (chainStatus == null) {
return UNKNOWN;
}
return switch (chainStatus) {
case READY -> READY;
case RUNNING -> RUNNING;
case SUSPEND -> SUSPENDED;
case ERROR -> ERROR;
case SUCCEEDED -> DONE;
case FAILED -> FAILED;
case CANCELLED -> CANCELLED;
};
}
/**
* 将内部节点状态转换为公开枚举。
*
* @param status 内部数值状态
* @return 公开状态枚举
*/
public static PublicWorkflowExecutionStatus fromNodeStatus(
Integer status) {
if (status == null) {
return UNKNOWN;
}
NodeStatus nodeStatus = NodeStatus.fromValue(status);
if (nodeStatus == null) {
return UNKNOWN;
}
return switch (nodeStatus) {
case READY -> READY;
case RUNNING -> RUNNING;
case SUSPEND -> SUSPENDED;
case ERROR -> ERROR;
case SUCCEEDED -> DONE;
case FAILED -> FAILED;
};
}
}

View File

@@ -0,0 +1,53 @@
package tech.easyflow.publicapi.dto;
import tech.easyflow.ai.entity.Workflow;
import java.io.Serializable;
/**
* 工作流 Public API 的安全基础信息。
*
* @param id 工作流 ID
* @param alias 工作流别名
* @param title 工作流标题
* @param description 工作流描述
* @param icon 工作流图标
* @param revision 当前发布修订号
* @param publishedAt 发布时间ISO-8601 格式
*/
public record PublicWorkflowInfo(
String id,
String alias,
String title,
String description,
String icon,
Integer revision,
String publishedAt) implements Serializable {
/**
* 从已发布工作流视图创建安全基础信息。
*
* @param workflow 已发布工作流视图
* @return 安全基础信息
* @throws IllegalArgumentException 工作流为空时抛出
*/
public static PublicWorkflowInfo from(Workflow workflow) {
if (workflow == null) {
throw new IllegalArgumentException("workflow must not be null");
}
return new PublicWorkflowInfo(
workflow.getId() == null
? null
: workflow.getId().toString(),
workflow.getAlias(),
workflow.getTitle(),
workflow.getDescription(),
workflow.getIcon(),
workflow.getRevision(),
workflow.getPublishedAt() == null
? null
: workflow.getPublishedAt()
.toInstant()
.toString());
}
}

View File

@@ -0,0 +1,26 @@
package tech.easyflow.publicapi.dto;
import com.easyagents.flow.core.chain.Parameter;
import java.io.Serializable;
import java.util.List;
import java.util.Map;
/**
* 工作流 Public API 的节点执行状态。
*
* @param nodeId 节点 ID
* @param nodeName 节点名称
* @param status 可读节点状态
* @param message 安全错误消息
* @param result 节点执行结果
* @param suspendForParameters 暂停时等待补充的参数
*/
public record PublicWorkflowNodeStatus(
String nodeId,
String nodeName,
PublicWorkflowExecutionStatus status,
String message,
Map<String, Object> result,
List<Parameter> suspendForParameters) implements Serializable {
}

View File

@@ -0,0 +1,55 @@
package tech.easyflow.publicapi.dto;
import jakarta.validation.constraints.NotNull;
import java.math.BigInteger;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Public Workflow API JSON 与 Multipart 执行元数据。
*/
public class PublicWorkflowRunMetadata {
@NotNull(message = "metadata.id 不能为空")
private BigInteger id;
private Map<String, Object> variables = new LinkedHashMap<>();
/**
* 获取工作流 ID。
*
* @return 工作流 ID
*/
public BigInteger getId() {
return id;
}
/**
* 设置工作流 ID。
*
* @param id 工作流 ID
*/
public void setId(BigInteger id) {
this.id = id;
}
/**
* 获取普通运行变量。
*
* @return 普通运行变量
*/
public Map<String, Object> getVariables() {
return variables;
}
/**
* 设置普通运行变量。
*
* @param variables 普通运行变量
*/
public void setVariables(Map<String, Object> variables) {
this.variables = variables == null
? new LinkedHashMap<>()
: new LinkedHashMap<>(variables);
}
}

View File

@@ -0,0 +1,53 @@
package tech.easyflow.publicapi.dto;
import tech.easyflow.common.constant.enums.EnumRes;
import tech.easyflow.common.domain.Result;
/**
* Public Workflow API 执行响应。
*
* <p>继承原有 {@link Result} 并继续把执行 ID 放在 {@code data}
* 新增 {@code workflow} 区块以保持旧调用方兼容。</p>
*/
public class PublicWorkflowRunResult extends Result<String> {
private static final long serialVersionUID = 1L;
private PublicWorkflowTopology workflow;
/**
* 创建成功响应。
*
* @param executeId 工作流执行 ID
* @param workflow 已发布工作流拓扑
* @return 成功响应
*/
public static PublicWorkflowRunResult success(
String executeId,
PublicWorkflowTopology workflow) {
PublicWorkflowRunResult result = new PublicWorkflowRunResult();
result.setErrorCode(EnumRes.SUCCESS.getCode());
result.setMessage(EnumRes.SUCCESS.getMsg());
result.setData(executeId);
result.setWorkflow(workflow);
return result;
}
/**
* 获取已发布工作流拓扑。
*
* @return 工作流拓扑
*/
public PublicWorkflowTopology getWorkflow() {
return workflow;
}
/**
* 设置已发布工作流拓扑。
*
* @param workflow 工作流拓扑
*/
public void setWorkflow(PublicWorkflowTopology workflow) {
this.workflow = workflow;
}
}

View File

@@ -0,0 +1,84 @@
package tech.easyflow.publicapi.dto;
import java.io.Serializable;
/**
* 工作流公共执行状态中的安全错误信息。
*/
public class PublicWorkflowStatusError implements Serializable {
private static final long serialVersionUID = 1L;
private final String code;
private final String message;
private final String nodeId;
private final String nodeName;
private final boolean retryable;
/**
* 创建安全执行错误。
*
* @param code 稳定错误标识
* @param message 安全错误消息
* @param nodeId 失败节点 ID
* @param nodeName 失败节点名称
* @param retryable 当前状态是否仍可能恢复
*/
public PublicWorkflowStatusError(
String code,
String message,
String nodeId,
String nodeName,
boolean retryable) {
this.code = code;
this.message = message;
this.nodeId = nodeId;
this.nodeName = nodeName;
this.retryable = retryable;
}
/**
* 获取错误标识。
*
* @return 错误标识
*/
public String getCode() {
return code;
}
/**
* 获取安全消息。
*
* @return 安全消息
*/
public String getMessage() {
return message;
}
/**
* 获取失败节点 ID。
*
* @return 节点 ID
*/
public String getNodeId() {
return nodeId;
}
/**
* 获取失败节点名称。
*
* @return 节点名称
*/
public String getNodeName() {
return nodeName;
}
/**
* 判断当前状态是否仍可能恢复。
*
* @return 是否可重试
*/
public boolean isRetryable() {
return retryable;
}
}

View File

@@ -0,0 +1,166 @@
package tech.easyflow.publicapi.dto;
import java.io.Serializable;
import java.util.List;
/**
* Public Workflow API 对外公开的安全拓扑视图。
*
* @param workflowId 工作流 ID
* @param alias 工作流别名
* @param title 工作流标题
* @param description 工作流描述
* @param revision 发布内容修订号
* @param publishedAt 发布时间ISO-8601 格式
* @param nodes 按稳定拓扑顺序排列的节点
* @param edges 按发布定义顺序排列的边
* @param topologicalOrder 节点 ID 的稳定拓扑顺序
* @param topologyLevels 考虑循环体完成屏障后的可并行拓扑层级
* @param hasCycle 发布图中是否存在环
* @param unresolvedNodeIds 受环路影响而无法进入标准拓扑序的节点 ID
*/
public record PublicWorkflowTopology(
String workflowId,
String alias,
String title,
String description,
Integer revision,
String publishedAt,
List<Node> nodes,
List<Edge> edges,
List<String> topologicalOrder,
List<List<String>> topologyLevels,
boolean hasCycle,
List<String> unresolvedNodeIds) implements Serializable {
/**
* 创建不可变工作流拓扑。
*/
public PublicWorkflowTopology {
nodes = immutable(nodes);
edges = immutable(edges);
topologicalOrder = immutable(topologicalOrder);
topologyLevels = topologyLevels == null
? List.of()
: topologyLevels.stream()
.map(PublicWorkflowTopology::immutable)
.toList();
unresolvedNodeIds = immutable(unresolvedNodeIds);
}
/**
* 工作流公开节点。
*
* @param nodeId 节点 ID
* @param nodeType 节点类型
* @param nodeName 节点名称
* @param description 节点描述
* @param parentNodeId 父级容器节点 ID
* @param definitionIndex 节点在发布定义中的位置
* @param topologyIndex 节点在稳定拓扑序中的位置
* @param topologyLevel 节点所在拓扑层级
* @param inDegree 发布定义中的原始入度
* @param outDegree 发布定义中的原始出度
* @param startNode 是否开始节点
* @param endNode 是否结束节点
* @param predecessorNodeIds 直接前驱节点 ID
* @param successorNodeIds 直接后继节点 ID
* @param incomingEdgeIds 入边 ID
* @param outgoingEdgeIds 出边 ID
* @param inputParameters 节点输入参数元数据
* @param outputParameters 节点输出参数元数据
*/
public record Node(
String nodeId,
String nodeType,
String nodeName,
String description,
String parentNodeId,
int definitionIndex,
int topologyIndex,
int topologyLevel,
int inDegree,
int outDegree,
boolean startNode,
boolean endNode,
List<String> predecessorNodeIds,
List<String> successorNodeIds,
List<String> incomingEdgeIds,
List<String> outgoingEdgeIds,
List<Parameter> inputParameters,
List<Parameter> outputParameters) implements Serializable {
/**
* 创建不可变公开节点。
*/
public Node {
predecessorNodeIds = immutable(predecessorNodeIds);
successorNodeIds = immutable(successorNodeIds);
incomingEdgeIds = immutable(incomingEdgeIds);
outgoingEdgeIds = immutable(outgoingEdgeIds);
inputParameters = immutable(inputParameters);
outputParameters = immutable(outputParameters);
}
}
/**
* 工作流公开边。
*
* @param edgeId 边 ID
* @param edgeType 边类型
* @param label 边展示名称
* @param sourceNodeId 源节点 ID
* @param targetNodeId 目标节点 ID
* @param sourceHandle 源连接点
* @param targetHandle 目标连接点
* @param parentNodeId 所属父级容器节点 ID
* @param definitionIndex 边在发布定义中的位置
* @param dangling 是否引用了不存在的节点
*/
public record Edge(
String edgeId,
String edgeType,
String label,
String sourceNodeId,
String targetNodeId,
String sourceHandle,
String targetHandle,
String parentNodeId,
int definitionIndex,
boolean dangling) implements Serializable {
}
/**
* 节点输入或输出参数的安全元数据。
*
* @param parameterId 参数 ID
* @param name 参数名
* @param label 展示名称
* @param dataType 数据类型
* @param contentType 内容类型
* @param required 是否必填
* @param description 参数说明
* @param multipartPartName 开始节点文件参数对应的 multipart Part 名
*/
public record Parameter(
String parameterId,
String name,
String label,
String dataType,
String contentType,
boolean required,
String description,
String multipartPartName) implements Serializable {
}
/**
* 将列表转换为不可变副本。
*
* @param source 原列表
* @param <T> 元素类型
* @return 不可变列表
*/
private static <T> List<T> immutable(List<T> source) {
return source == null ? List.of() : List.copyOf(source);
}
}

View File

@@ -0,0 +1,545 @@
package tech.easyflow.publicapi.error;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.FieldError;
import org.springframework.web.ErrorResponse;
import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.multipart.MultipartException;
import org.springframework.web.multipart.support.MissingServletRequestPartException;
import tech.easyflow.common.web.error.RequestErrorProfile;
import tech.easyflow.common.web.error.RequestIdContext;
import tech.easyflow.common.web.error.WebErrorMapping;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.publicapi.dto.PublicApiErrorDetail;
import java.util.List;
import java.util.Locale;
import java.util.regex.Pattern;
/**
* 工作流 Public API 的稳定业务错误翻译及 {@code runAsync} 请求格式规则。
*/
public final class WorkflowRunAsyncErrorProfile
implements RequestErrorProfile {
/** 共享无状态实例。 */
public static final WorkflowRunAsyncErrorProfile INSTANCE =
new WorkflowRunAsyncErrorProfile();
private static final Pattern MULTIPART_BOUNDARY_PATTERN =
Pattern.compile(
"(?:^|;)\\s*boundary\\s*=\\s*(?:\"[^\"]+\"|[^;\\s]+)",
Pattern.CASE_INSENSITIVE);
private static final List<String> RUN_CONTENT_TYPES = List.of(
MediaType.APPLICATION_JSON_VALUE,
MediaType.MULTIPART_FORM_DATA_VALUE);
private static final int MAX_ACTUAL_LENGTH = 256;
private WorkflowRunAsyncErrorProfile() {
}
/**
* {@inheritDoc}
*/
@Override
public WebErrorMapping map(
HttpServletRequest request,
Exception exception) {
if (!isRunAsyncRequest(request)) {
if (exception instanceof BusinessException businessException) {
return business(request, businessException);
}
if (isClientRequestException(exception)) {
return null;
}
return internalError(request);
}
String requestContentType = request.getContentType();
if (exception instanceof MaxUploadSizeExceededException) {
return error(
request,
413,
41301,
"上传文件、文件数量或请求总量超过限制",
"body",
"files",
null,
List.of(),
false);
}
if (exception instanceof MissingServletRequestPartException missingPart) {
return missingPart(request, missingPart);
}
if (exception instanceof MethodArgumentNotValidException invalid) {
return validation(request, invalid);
}
if (exception instanceof HttpMediaTypeNotSupportedException unsupported) {
return unsupportedMediaType(
request,
requestContentType,
unsupported);
}
if (exception instanceof HttpMessageNotReadableException) {
return unreadableBody(request, requestContentType);
}
if (exception instanceof MultipartException) {
if (isMultipartFormData(requestContentType)
&& !hasBoundary(requestContentType)) {
return error(
request,
400,
40012,
"multipart/form-data 缺少 boundary请删除手工设置的 Content-Type让客户端自动生成",
"header",
"Content-Type",
requestContentType,
List.of("multipart/form-data; boundary=<客户端自动生成>"),
false);
}
return error(
request,
400,
40012,
"multipart/form-data 请求无法解析,请检查 boundary 与各 Part 格式",
"header",
"Content-Type",
requestContentType,
List.of("multipart/form-data; boundary=<客户端自动生成>"),
false);
}
if (exception instanceof BusinessException businessException) {
return business(request, businessException);
}
return internalError(request);
}
/**
* 判断非 runAsync 接口异常是否已具有明确的客户端错误语义。
*
* @param exception 原始异常
* @return 是否应交由全局 4xx 规则处理
*/
private boolean isClientRequestException(Exception exception) {
if (exception instanceof ConstraintViolationException
|| exception instanceof MethodArgumentNotValidException
|| exception instanceof MethodArgumentTypeMismatchException
|| exception instanceof HttpMessageNotReadableException) {
return true;
}
return exception instanceof ErrorResponse errorResponse
&& errorResponse.getStatusCode().is4xxClientError();
}
/**
* 创建不泄露内部实现的未知服务端错误。
*
* @param request 当前请求
* @return 50001 错误映射
*/
private WebErrorMapping internalError(
HttpServletRequest request) {
return error(
request,
500,
50001,
"服务暂时不可用,请稍后重试",
"dependency",
null,
null,
List.of(),
false);
}
/**
* 翻译缺失的 Multipart Part。
*
* @param request 当前请求
* @param exception 缺失 Part 异常
* @return 错误映射
*/
private WebErrorMapping missingPart(
HttpServletRequest request,
MissingServletRequestPartException exception) {
String partName = exception.getRequestPartName();
if ("metadata".equals(partName)) {
return error(
request,
400,
40013,
"当前使用 multipart/form-data但缺少 metadata Part如直接提交 JSON请将 Content-Type 设置为 application/json",
"part",
"metadata",
null,
List.of("application/json"),
false);
}
return error(
request,
400,
40016,
"缺少必要的文件 Part" + safeActual(partName),
"part",
partName,
null,
List.of("files.<开始节点参数名>"),
false);
}
/**
* 翻译请求 DTO 校验失败。
*
* @param request 当前请求
* @param exception 参数校验异常
* @return 错误映射
*/
private WebErrorMapping validation(
HttpServletRequest request,
MethodArgumentNotValidException exception) {
FieldError idError = exception.getBindingResult()
.getFieldErrors("id")
.stream()
.findFirst()
.orElse(null);
if (idError != null) {
return error(
request,
400,
40015,
"metadata.id 不能为空,请传入要执行的工作流 ID",
isMultipartFormData(request.getContentType())
? "part"
: "body",
"id",
null,
List.of("已发布工作流 ID"),
false);
}
FieldError first = exception.getBindingResult()
.getFieldError();
return error(
request,
400,
40017,
first == null || first.getDefaultMessage() == null
? "工作流运行参数不合法"
: first.getDefaultMessage(),
"field",
first == null ? null : first.getField(),
null,
List.of(),
false);
}
/**
* 翻译不支持的媒体类型。
*
* @param request 当前请求
* @param requestContentType 顶层请求内容类型
* @param exception 媒体类型异常
* @return 错误映射
*/
private WebErrorMapping unsupportedMediaType(
HttpServletRequest request,
String requestContentType,
HttpMediaTypeNotSupportedException exception) {
if (isMultipartFormData(requestContentType)) {
String actual = exception.getContentType() == null
? null
: exception.getContentType().toString();
return error(
request,
415,
41502,
"metadata Part 必须使用 application/json文件 Part 请使用 files.<开始节点参数名>",
"part",
"metadata",
actual,
List.of(MediaType.APPLICATION_JSON_VALUE),
false);
}
boolean missing = requestContentType == null
|| requestContentType.isBlank();
return error(
request,
415,
41501,
missing
? "缺少 Content-TypeJSON 调用请使用 application/json文件直传请使用 multipart/form-data"
: "Content-Type 不受支持JSON 调用请使用 application/json文件直传请使用 multipart/form-data",
"header",
"Content-Type",
requestContentType,
RUN_CONTENT_TYPES,
false);
}
/**
* 翻译无法读取的 JSON 请求体或 metadata Part。
*
* @param request 当前请求
* @param requestContentType 顶层请求内容类型
* @return 错误映射
*/
private WebErrorMapping unreadableBody(
HttpServletRequest request,
String requestContentType) {
if (isMultipartFormData(requestContentType)) {
return error(
request,
400,
40014,
"metadata Part 不是有效 JSON请检查文件内容或字段格式",
"part",
"metadata",
null,
List.of(MediaType.APPLICATION_JSON_VALUE),
false);
}
return error(
request,
400,
40011,
"请求头为 application/json但请求体不是有效 JSON如需上传文件请改用 multipart/form-data",
"body",
null,
requestContentType,
List.of(MediaType.APPLICATION_JSON_VALUE),
false);
}
/**
* 翻译工作流调用链抛出的安全业务异常。
*
* @param request 当前请求
* @param exception 业务异常
* @return 错误映射
*/
private WebErrorMapping business(
HttpServletRequest request,
BusinessException exception) {
int code = normalizeBusinessCode(exception);
String location = code == 40101 || code == 40102 || code == 40103
|| code == 40301 || code == 40302
? "auth"
: code == 50301 || code == 50001
? "dependency"
: code == 40016
? "part"
: "field";
String field = code == 40016
? "files.<开始节点参数名>"
: null;
return error(
request,
normalizeHttpStatus(code, exception.getHttpStatus()),
code,
publicBusinessMessage(code, exception.getMessage()),
location,
field,
null,
List.of(),
code == 50301);
}
/**
* 把尚未迁移的旧业务码归一化为工作流 Public API 稳定错误码。
*
* @param exception 业务异常
* @return 稳定错误码
*/
private int normalizeBusinessCode(BusinessException exception) {
int code = exception.getErrorCode();
if (isStableBusinessCode(code)) {
return code;
}
String message = exception.getMessage() == null
? ""
: exception.getMessage().toLowerCase(Locale.ROOT);
if (message.contains("执行")
&& (message.contains("不存在")
|| message.contains("已过期")
|| message.contains("不可访问"))) {
return 40402;
}
if (message.contains("工作流")
&& (message.contains("不存在")
|| message.contains("未发布")
|| message.contains("尚未发布")
|| message.contains("不可公开"))) {
return 40401;
}
return switch (exception.getHttpStatus()) {
case 401 -> message.contains("过期") ? 40103 : 40102;
case 403 -> message.contains("工作流") ? 40302 : 40301;
case 404 -> message.contains("执行") ? 40402 : 40401;
case 409 -> 40901;
case 413 -> 41301;
case 503 -> 50301;
case 500 -> 50001;
default -> 40017;
};
}
/**
* 判断业务异常是否已经使用本接口声明的稳定错误码。
*
* @param code 业务错误码
* @return 是否为稳定错误码
*/
private boolean isStableBusinessCode(int code) {
return (code >= 40011 && code <= 40017)
|| (code >= 40101 && code <= 40103)
|| (code >= 40301 && code <= 40302)
|| (code >= 40401 && code <= 40402)
|| code == 40901
|| code == 41301
|| (code >= 41501 && code <= 41502)
|| code == 50001
|| code == 50301;
}
/**
* 根据稳定错误码校正旧业务异常携带的 HTTP 状态。
*
* @param code 稳定错误码
* @param fallback 原始 HTTP 状态
* @return 对外 HTTP 状态
*/
private int normalizeHttpStatus(int code, int fallback) {
if (code >= 40011 && code <= 40017) {
return 400;
}
if (code >= 40101 && code <= 40103) {
return 401;
}
if (code >= 40301 && code <= 40302) {
return 403;
}
if (code >= 40401 && code <= 40402) {
return 404;
}
return switch (code) {
case 40901 -> 409;
case 41301 -> 413;
case 41501, 41502 -> 415;
case 50001 -> 500;
case 50301 -> 503;
default -> fallback;
};
}
/**
* 为需要隐藏资源状态或内部依赖详情的错误生成固定公共消息。
*
* @param code 稳定错误码
* @param originalMessage 原业务消息
* @return 可安全返回的消息
*/
private String publicBusinessMessage(
int code,
String originalMessage) {
return switch (code) {
case 40401 -> "工作流不存在或当前不可公开调用";
case 40402 -> "执行记录不存在、已过期或不可访问";
case 50001 -> "服务暂时不可用,请稍后重试";
case 50301 -> "必要依赖暂时不可用,请稍后重试";
default -> originalMessage == null || originalMessage.isBlank()
? "请求处理失败"
: originalMessage;
};
}
/**
* 创建统一错误映射。
*
* @param request 当前请求
* @param status HTTP 状态码
* @param code 业务错误码
* @param message 安全消息
* @param location 错误位置
* @param field 错误字段
* @param actual 实际值
* @param expected 期望值
* @param retryable 是否可重试
* @return 错误映射
*/
private WebErrorMapping error(
HttpServletRequest request,
int status,
int code,
String message,
String location,
String field,
String actual,
List<String> expected,
boolean retryable) {
return new WebErrorMapping(
status,
code,
message,
new PublicApiErrorDetail(
RequestIdContext.get(request),
location,
field,
safeActual(actual),
expected,
retryable));
}
/**
* 判断顶层内容类型是否为 Multipart 表单。
*
* @param contentType 内容类型
* @return 是否为 Multipart 表单
*/
private boolean isMultipartFormData(String contentType) {
return contentType != null
&& contentType.toLowerCase(Locale.ROOT)
.startsWith(MediaType.MULTIPART_FORM_DATA_VALUE);
}
/**
* 判断当前请求是否为两种媒体类型共用的异步执行入口。
*
* @param request 当前请求
* @return 是否为 runAsync
*/
private boolean isRunAsyncRequest(HttpServletRequest request) {
String uri = request.getRequestURI();
return uri != null
&& uri.endsWith("/public-api/workflow/runAsync");
}
/**
* 判断 Multipart 内容类型是否包含非空边界。
*
* @param contentType 内容类型
* @return 是否存在边界
*/
private boolean hasBoundary(String contentType) {
return contentType != null
&& MULTIPART_BOUNDARY_PATTERN.matcher(contentType).find();
}
/**
* 限制响应中的实际值长度并移除控制字符。
*
* @param actual 原始实际值
* @return 安全值
*/
private static String safeActual(String actual) {
if (actual == null) {
return null;
}
String safe = actual.replaceAll("[\\p{Cntrl}]", " ").trim();
return safe.length() <= MAX_ACTUAL_LENGTH
? safe
: safe.substring(0, MAX_ACTUAL_LENGTH);
}
}

View File

@@ -9,9 +9,13 @@ import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.util.ResponseUtil;
import tech.easyflow.common.web.error.RequestIdContext;
import tech.easyflow.publicapi.dto.PublicApiErrorDetail;
import tech.easyflow.system.entity.SysApiKey;
import tech.easyflow.system.service.SysApiKeyService;
import java.util.List;
/**
* Public API 访问令牌与接口权限拦截器。
*/
@@ -36,7 +40,16 @@ public class PublicApiInterceptor implements HandlerInterceptor {
String apiKey = request.getHeader("ApiKey");
if (apiKey == null || apiKey.isBlank()) {
Result<Void> failed = Result.fail(401, "密钥不正确");
Result<PublicApiErrorDetail> failed = Result.fail(
"缺少 ApiKey 请求头",
new PublicApiErrorDetail(
RequestIdContext.get(request),
"auth",
"ApiKey",
null,
List.of("有效的工作流 Public API Key"),
false));
failed.setErrorCode(40101);
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
ResponseUtil.renderJson(response, failed);
return false;

View File

@@ -0,0 +1,102 @@
package tech.easyflow.publicapi.interceptor;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import tech.easyflow.common.web.error.RequestErrorProfile;
import tech.easyflow.common.web.error.RequestIdContext;
import tech.easyflow.publicapi.error.WorkflowRunAsyncErrorProfile;
import java.io.IOException;
import java.util.UUID;
import java.util.regex.Pattern;
/**
* 在 Public API 进入 Spring MVC 前初始化请求关联标识和错误契约。
*/
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 20)
public class PublicApiRequestContextFilter
extends OncePerRequestFilter {
private static final Pattern VALID_REQUEST_ID = Pattern.compile(
"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}");
private static final String WORKFLOW_API_PATH_PREFIX =
"/public-api/workflow/";
/**
* {@inheritDoc}
*/
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String uri = request.getRequestURI();
return uri == null || !uri.contains("/public-api/");
}
/**
* {@inheritDoc}
*/
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String requestId = resolveRequestId(
request.getHeader(RequestIdContext.HEADER_NAME));
request.setAttribute(
RequestIdContext.ATTRIBUTE_NAME,
requestId);
response.setHeader(RequestIdContext.HEADER_NAME, requestId);
if (isWorkflowApi(request)) {
request.setAttribute(
RequestErrorProfile.ATTRIBUTE_NAME,
WorkflowRunAsyncErrorProfile.INSTANCE);
}
String previousRequestId = MDC.get(RequestIdContext.MDC_KEY);
MDC.put(RequestIdContext.MDC_KEY, requestId);
try {
filterChain.doFilter(request, response);
} finally {
if (previousRequestId == null) {
MDC.remove(RequestIdContext.MDC_KEY);
} else {
MDC.put(RequestIdContext.MDC_KEY, previousRequestId);
}
}
}
/**
* 校验调用方请求 ID非法时生成服务端 ID。
*
* @param supplied 调用方请求 ID
* @return 可安全进入响应头和日志的请求 ID
*/
private String resolveRequestId(String supplied) {
if (supplied != null) {
String trimmed = supplied.trim();
if (VALID_REQUEST_ID.matcher(trimmed).matches()) {
return trimmed;
}
}
return UUID.randomUUID().toString().replace("-", "");
}
/**
* 判断请求是否为工作流 Public API。
*
* @param request 当前请求
* @return 是否匹配目标接口
*/
private boolean isWorkflowApi(HttpServletRequest request) {
String uri = request.getRequestURI();
return uri != null && uri.contains(WORKFLOW_API_PATH_PREFIX);
}
}

View File

@@ -0,0 +1,141 @@
package tech.easyflow.publicapi.service;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
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 tech.easyflow.publicapi.dto.PublicWorkflowNodeStatus;
import tech.easyflow.publicapi.dto.PublicWorkflowStatusError;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 将内部工作流执行错误转换为 Public API 安全状态。
*/
@Service
public class PublicWorkflowStatusSanitizer {
private static final String CHAIN_FAILED_MESSAGE =
"工作流执行失败,请检查输入或稍后重试";
private static final String NODE_FAILED_MESSAGE =
"节点执行失败,请检查输入或稍后重试";
/**
* 复制执行状态并移除异常类名、底层地址和内部错误详情。
*
* @param source 内部执行状态
* @return 可公开状态
*/
public PublicWorkflowChainStatus sanitize(ChainInfo source) {
if (source == null) {
throw new IllegalArgumentException(
"source must not be null");
}
PublicWorkflowExecutionStatus chainStatus =
PublicWorkflowExecutionStatus.fromChainStatus(
source.getStatus());
Map<String, PublicWorkflowNodeStatus> safeNodes =
new LinkedHashMap<>();
PublicWorkflowStatusError firstNodeError = null;
if (source.getNodes() != null) {
for (Map.Entry<String, NodeInfo> entry
: source.getNodes().entrySet()) {
PublicWorkflowNodeStatus safeNode = copyNode(
entry.getValue());
safeNodes.put(entry.getKey(), safeNode);
if (firstNodeError == null
&& StringUtils.hasText(safeNode.message())) {
firstNodeError = new PublicWorkflowStatusError(
"NODE_EXECUTION_FAILED",
safeNode.message(),
safeNode.nodeId(),
safeNode.nodeName(),
isRetryable(safeNode.status()));
}
}
}
String message = null;
PublicWorkflowStatusError error = null;
if (StringUtils.hasText(source.getMessage())) {
message = chainMessage(chainStatus);
error = new PublicWorkflowStatusError(
"WORKFLOW_EXECUTION_FAILED",
message,
firstNodeError == null
? null
: firstNodeError.getNodeId(),
firstNodeError == null
? null
: firstNodeError.getNodeName(),
isRetryable(chainStatus));
} else if (firstNodeError != null) {
error = firstNodeError;
}
return new PublicWorkflowChainStatus(
source.getExecuteId(),
chainStatus,
chainStatus.isTerminal(),
message,
source.getResult(),
safeNodes,
error);
}
/**
* 复制并脱敏单个节点状态。
*
* @param source 内部节点状态
* @return 安全节点状态
*/
private PublicWorkflowNodeStatus copyNode(NodeInfo source) {
if (source == null) {
return new PublicWorkflowNodeStatus(
null,
null,
PublicWorkflowExecutionStatus.UNKNOWN,
null,
null,
null);
}
return new PublicWorkflowNodeStatus(
source.getNodeId(),
source.getNodeName(),
PublicWorkflowExecutionStatus.fromNodeStatus(
source.getStatus()),
StringUtils.hasText(source.getMessage())
? NODE_FAILED_MESSAGE
: null,
source.getResult(),
source.getSuspendForParameters());
}
/**
* 根据工作流状态生成安全消息。
*
* @param status 可读状态
* @return 安全消息
*/
private String chainMessage(
PublicWorkflowExecutionStatus status) {
if (status == PublicWorkflowExecutionStatus.CANCELLED) {
return "工作流执行已取消";
}
return CHAIN_FAILED_MESSAGE;
}
/**
* 判断执行状态是否仍可能由运行时继续处理。
*
* @param status 可读状态
* @return 是否可重试
*/
private boolean isRetryable(
PublicWorkflowExecutionStatus status) {
return status == PublicWorkflowExecutionStatus.ERROR;
}
}

View File

@@ -0,0 +1,684 @@
package tech.easyflow.publicapi.service;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONException;
import com.alibaba.fastjson2.JSONObject;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.publicapi.dto.PublicWorkflowTopology;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.PriorityQueue;
import java.util.Set;
/**
* 从已发布工作流快照生成对外安全的节点拓扑。
*/
@Service
public class PublicWorkflowTopologyService {
/**
* 构建已发布工作流的公开拓扑视图。
*
* @param workflow 已发布工作流
* @return 安全拓扑视图
*/
public PublicWorkflowTopology resolve(Workflow workflow) {
if (workflow == null || !StringUtils.hasText(workflow.getContent())) {
throw new BusinessException("已发布工作流缺少拓扑内容");
}
JSONObject root = parseContent(workflow.getContent());
JSONArray rawNodes = root.getJSONArray("nodes");
JSONArray rawEdges = root.getJSONArray("edges");
LinkedHashMap<String, NodeBuilder> nodeById =
parseNodes(rawNodes);
List<PublicWorkflowTopology.Edge> edges =
parseEdges(rawEdges, nodeById);
addLoopCompletionBarriers(nodeById);
TopologyOrder topology = resolveTopology(nodeById);
List<PublicWorkflowTopology.Node> nodes =
buildNodes(nodeById, topology);
return new PublicWorkflowTopology(
workflow.getId() == null
? null
: workflow.getId().toString(),
trimToNull(workflow.getAlias()),
trimToNull(workflow.getTitle()),
trimToNull(workflow.getDescription()),
workflow.getRevision(),
workflow.getPublishedAt() == null
? null
: workflow.getPublishedAt()
.toInstant()
.toString(),
nodes,
edges,
topology.order,
topology.levels,
!topology.unresolvedNodeIds.isEmpty(),
topology.unresolvedNodeIds);
}
/**
* 解析工作流 JSON 内容。
*
* @param content 工作流 JSON
* @return 根对象
*/
private JSONObject parseContent(String content) {
try {
JSONObject root = JSON.parseObject(content);
if (root == null) {
throw new BusinessException("已发布工作流拓扑为空");
}
return root;
} catch (JSONException error) {
throw new BusinessException(
400,
40031,
"已发布工作流拓扑解析失败",
error);
}
}
/**
* 解析并校验节点定义。
*
* @param rawNodes 原始节点数组
* @return 按发布定义顺序排列的节点构建器
*/
private LinkedHashMap<String, NodeBuilder> parseNodes(
JSONArray rawNodes) {
if (rawNodes == null || rawNodes.isEmpty()) {
throw new BusinessException("已发布工作流缺少节点");
}
LinkedHashMap<String, NodeBuilder> nodes =
new LinkedHashMap<>();
for (int index = 0; index < rawNodes.size(); index++) {
JSONObject rawNode = rawNodes.getJSONObject(index);
String nodeId = trimToNull(
rawNode == null ? null : rawNode.getString("id"));
if (!StringUtils.hasText(nodeId)) {
throw new BusinessException(
"已发布工作流存在缺少 ID 的节点");
}
if (nodes.containsKey(nodeId)) {
throw new BusinessException(
"已发布工作流存在重复节点 ID: " + nodeId);
}
JSONObject data = rawNode.getJSONObject("data");
String nodeType = trimToNull(rawNode.getString("type"));
nodes.put(nodeId, new NodeBuilder(
nodeId,
nodeType,
firstText(
data == null
? null
: data.getString("title"),
rawNode.getString("label"),
nodeId),
firstText(
data == null
? null
: data.getString("description"),
rawNode.getString("description")),
trimToNull(rawNode.getString("parentId")),
index,
parseParameters(
data == null
? null
: data.getJSONArray("parameters"),
"startNode".equals(nodeType)),
parseParameters(
data == null
? null
: data.getJSONArray("outputDefs"),
false)));
}
return nodes;
}
/**
* 解析公开边信息并建立节点邻接关系。
*
* @param rawEdges 原始边数组
* @param nodeById 节点索引
* @return 公开边列表
*/
private List<PublicWorkflowTopology.Edge> parseEdges(
JSONArray rawEdges,
Map<String, NodeBuilder> nodeById) {
if (rawEdges == null || rawEdges.isEmpty()) {
return List.of();
}
List<PublicWorkflowTopology.Edge> edges =
new ArrayList<>(rawEdges.size());
for (int index = 0; index < rawEdges.size(); index++) {
JSONObject rawEdge = rawEdges.getJSONObject(index);
if (rawEdge == null) {
continue;
}
JSONObject data = rawEdge.getJSONObject("data");
String edgeId = firstText(
rawEdge.getString("id"),
"edge-" + index);
String sourceId = trimToNull(
rawEdge.getString("source"));
String targetId = trimToNull(
rawEdge.getString("target"));
NodeBuilder source = nodeById.get(sourceId);
NodeBuilder target = nodeById.get(targetId);
boolean dangling = source == null || target == null;
if (!dangling) {
source.addOutgoing(edgeId, targetId);
target.addIncoming(edgeId, sourceId);
if (isSameScope(source, target)
|| isLoopEntry(source, target)) {
addTopologyDependency(source, target);
}
}
edges.add(new PublicWorkflowTopology.Edge(
edgeId,
trimToNull(rawEdge.getString("type")),
firstText(
rawEdge.getString("label"),
data == null
? null
: data.getString("label")),
sourceId,
targetId,
trimToNull(rawEdge.getString("sourceHandle")),
trimToNull(rawEdge.getString("targetHandle")),
firstText(
data == null
? null
: data.getString("parentNodeId"),
rawEdge.getString("parentId")),
index,
dangling));
}
return edges;
}
/**
* 为循环体末节点与循环后的节点增加结构化完成屏障。
*
* <p>运行时由循环体末节点隐式返回父循环节点,父循环完成全部迭代后
* 才会触发同作用域后继。公开拓扑使用末节点到后继的无环屏障表达这一约束。</p>
*
* @param nodeById 节点索引
*/
private void addLoopCompletionBarriers(
Map<String, NodeBuilder> nodeById) {
for (NodeBuilder loopNode : nodeById.values()) {
if (!"loopNode".equals(loopNode.nodeType)) {
continue;
}
List<NodeBuilder> downstream = loopNode
.topologySuccessorNodeIds.stream()
.map(nodeById::get)
.filter(Objects::nonNull)
.filter(target -> isSameScope(loopNode, target))
.toList();
if (downstream.isEmpty()) {
continue;
}
for (NodeBuilder exitNode :
resolveLoopScopeExitNodes(
loopNode.nodeId,
nodeById,
new HashSet<>())) {
for (NodeBuilder target : downstream) {
addTopologyDependency(exitNode, target);
}
}
}
}
/**
* 解析循环作用域完成时必须结束的叶子节点。
*
* <p>若作用域叶子本身是嵌套循环,则继续展开到嵌套循环体叶子,
* 确保外层后继等待完整嵌套作用域结束。</p>
*
* @param loopNodeId 循环节点 ID
* @param nodeById 节点索引
* @param visiting 正在展开的循环节点
* @return 实际完成屏障节点
*/
private List<NodeBuilder> resolveLoopScopeExitNodes(
String loopNodeId,
Map<String, NodeBuilder> nodeById,
Set<String> visiting) {
if (!visiting.add(loopNodeId)) {
return List.of();
}
List<NodeBuilder> exits = new ArrayList<>();
for (NodeBuilder candidate : nodeById.values()) {
if (!Objects.equals(loopNodeId, candidate.parentNodeId)
|| hasSameScopeSuccessor(candidate, nodeById)) {
continue;
}
if ("loopNode".equals(candidate.nodeType)) {
List<NodeBuilder> nested = resolveLoopScopeExitNodes(
candidate.nodeId,
nodeById,
visiting);
if (!nested.isEmpty()) {
exits.addAll(nested);
continue;
}
}
exits.add(candidate);
}
visiting.remove(loopNodeId);
return exits;
}
/**
* 判断节点是否存在同一父级作用域内的后继。
*
* @param node 当前节点
* @param nodeById 节点索引
* @return 是否存在同作用域后继
*/
private boolean hasSameScopeSuccessor(
NodeBuilder node,
Map<String, NodeBuilder> nodeById) {
for (String successorId : node.topologySuccessorNodeIds) {
NodeBuilder successor = nodeById.get(successorId);
if (successor != null && isSameScope(node, successor)) {
return true;
}
}
return false;
}
/**
* 判断两个节点是否位于同一父级作用域。
*
* @param source 源节点
* @param target 目标节点
* @return 是否同作用域
*/
private boolean isSameScope(
NodeBuilder source,
NodeBuilder target) {
return Objects.equals(
source.parentNodeId,
target.parentNodeId);
}
/**
* 判断连线是否为循环节点进入直属循环体的入口。
*
* @param source 源节点
* @param target 目标节点
* @return 是否循环入口
*/
private boolean isLoopEntry(
NodeBuilder source,
NodeBuilder target) {
return "loopNode".equals(source.nodeType)
&& source.nodeId.equals(target.parentNodeId);
}
/**
* 登记拓扑排序使用的依赖关系。
*
* @param source 源节点
* @param target 目标节点
*/
private void addTopologyDependency(
NodeBuilder source,
NodeBuilder target) {
if (source.topologySuccessorNodeIds.add(target.nodeId)) {
target.topologyPredecessorNodeIds.add(source.nodeId);
}
}
/**
* 对节点执行稳定 Kahn 拓扑排序。
*
* <p>同一层级节点按发布定义顺序稳定排列。若发布图意外存在环,
* 标准拓扑序之后按定义顺序追加受环路影响的未解析节点。</p>
*
* @param nodeById 节点索引
* @return 拓扑排序结果
*/
private TopologyOrder resolveTopology(
LinkedHashMap<String, NodeBuilder> nodeById) {
Map<String, Integer> remainingInDegree = new HashMap<>();
Map<String, Integer> levelById = new HashMap<>();
PriorityQueue<NodeBuilder> ready = new PriorityQueue<>(
Comparator.comparingInt(node -> node.definitionIndex));
for (NodeBuilder node : nodeById.values()) {
remainingInDegree.put(
node.nodeId,
node.topologyPredecessorNodeIds.size());
levelById.put(node.nodeId, 0);
if (node.topologyPredecessorNodeIds.isEmpty()) {
ready.add(node);
}
}
List<String> order = new ArrayList<>(nodeById.size());
Set<String> visited = new HashSet<>();
while (!ready.isEmpty()) {
NodeBuilder current = ready.poll();
if (!visited.add(current.nodeId)) {
continue;
}
order.add(current.nodeId);
int nextLevel = levelById.getOrDefault(current.nodeId, 0) + 1;
for (String successorId :
current.topologySuccessorNodeIds) {
levelById.compute(
successorId,
(key, value) -> Math.max(
value == null ? 0 : value,
nextLevel));
}
for (String edgeTargetId :
current.topologySuccessorNodeIds) {
int nextInDegree = remainingInDegree.computeIfPresent(
edgeTargetId,
(key, value) -> value - 1);
if (nextInDegree == 0) {
ready.add(nodeById.get(edgeTargetId));
}
}
}
List<String> unresolvedNodeIds = new ArrayList<>();
for (NodeBuilder node : nodeById.values()) {
if (!visited.contains(node.nodeId)) {
unresolvedNodeIds.add(node.nodeId);
order.add(node.nodeId);
levelById.put(node.nodeId, -1);
}
}
return new TopologyOrder(
order,
buildLevels(order, levelById),
unresolvedNodeIds,
levelById);
}
/**
* 构建可并行处理的拓扑层级。
*
* @param order 稳定拓扑顺序
* @param levelById 节点层级
* @return 按层级排列的节点 ID
*/
private List<List<String>> buildLevels(
List<String> order,
Map<String, Integer> levelById) {
int maxLevel = -1;
for (String nodeId : order) {
maxLevel = Math.max(
maxLevel,
levelById.getOrDefault(nodeId, -1));
}
if (maxLevel < 0) {
return List.of();
}
List<List<String>> levels = new ArrayList<>(maxLevel + 1);
for (int index = 0; index <= maxLevel; index++) {
levels.add(new ArrayList<>());
}
for (String nodeId : order) {
int level = levelById.getOrDefault(nodeId, -1);
if (level >= 0) {
levels.get(level).add(nodeId);
}
}
return levels;
}
/**
* 将内部节点构建器转换为公开节点 DTO。
*
* @param nodeById 节点索引
* @param topology 拓扑排序结果
* @return 按稳定拓扑顺序排列的公开节点
*/
private List<PublicWorkflowTopology.Node> buildNodes(
Map<String, NodeBuilder> nodeById,
TopologyOrder topology) {
List<PublicWorkflowTopology.Node> nodes =
new ArrayList<>(topology.order.size());
for (int topologyIndex = 0;
topologyIndex < topology.order.size();
topologyIndex++) {
NodeBuilder node = nodeById.get(
topology.order.get(topologyIndex));
nodes.add(new PublicWorkflowTopology.Node(
node.nodeId,
node.nodeType,
node.nodeName,
node.description,
node.parentNodeId,
node.definitionIndex,
topologyIndex,
topology.levelById.getOrDefault(node.nodeId, -1),
node.incomingEdgeIds.size(),
node.outgoingEdgeIds.size(),
"startNode".equals(node.nodeType),
"endNode".equals(node.nodeType),
new ArrayList<>(node.predecessorNodeIds),
new ArrayList<>(node.successorNodeIds),
node.incomingEdgeIds,
node.outgoingEdgeIds,
node.inputParameters,
node.outputParameters));
}
return nodes;
}
/**
* 解析参数数组中的安全元数据。
*
* @param rawParameters 原始参数数组
* @param multipartEnabled 是否为开始节点可上传输入参数
* @return 参数安全元数据
*/
private List<PublicWorkflowTopology.Parameter> parseParameters(
JSONArray rawParameters,
boolean multipartEnabled) {
if (rawParameters == null || rawParameters.isEmpty()) {
return List.of();
}
List<PublicWorkflowTopology.Parameter> parameters =
new ArrayList<>(rawParameters.size());
for (int index = 0; index < rawParameters.size(); index++) {
JSONObject parameter = rawParameters.getJSONObject(index);
if (parameter == null) {
continue;
}
String parameterName = firstText(
parameter.getString("name"),
parameter.getString("key"));
String dataType =
trimToNull(parameter.getString("dataType"));
String contentType = firstText(
parameter.getString("contentType"),
parameter.getString("type"),
parameter.getString("formType"));
parameters.add(new PublicWorkflowTopology.Parameter(
firstText(
parameter.getString("id"),
parameter.getString("key")),
parameterName,
firstText(
parameter.getString("formLabel"),
parameter.getString("label"),
parameter.getString("title"),
parameter.getString("name"),
parameter.getString("key")),
dataType,
contentType,
parameter.getBooleanValue("required"),
firstText(
parameter.getString("formDescription"),
parameter.getString("description")),
multipartEnabled
&& isFileParameter(dataType, contentType)
&& StringUtils.hasText(parameterName)
? WorkflowApiMultipartParameterMapper
.FILE_PART_PREFIX + parameterName
: null));
}
return parameters;
}
/**
* 判断公开参数元数据是否描述文件输入。
*
* @param dataType 参数数据类型
* @param contentType 参数内容类型
* @return 是否为文件参数
*/
private boolean isFileParameter(
String dataType,
String contentType) {
return "file".equalsIgnoreCase(dataType)
|| "file".equalsIgnoreCase(contentType);
}
/**
* 返回首个非空文本。
*
* @param values 候选文本
* @return 首个非空文本
*/
private String firstText(String... values) {
for (String value : values) {
String normalized = trimToNull(value);
if (normalized != null) {
return normalized;
}
}
return null;
}
/**
* 去除文本首尾空白并把空串转换为空值。
*
* @param value 原文本
* @return 归一化文本
*/
private String trimToNull(String value) {
if (!StringUtils.hasText(value)) {
return null;
}
return value.trim();
}
/**
* 拓扑排序使用的内部节点。
*/
private static final class NodeBuilder {
private final String nodeId;
private final String nodeType;
private final String nodeName;
private final String description;
private final String parentNodeId;
private final int definitionIndex;
private final List<PublicWorkflowTopology.Parameter> inputParameters;
private final List<PublicWorkflowTopology.Parameter> outputParameters;
private final LinkedHashSet<String> predecessorNodeIds =
new LinkedHashSet<>();
private final LinkedHashSet<String> successorNodeIds =
new LinkedHashSet<>();
private final List<String> incomingEdgeIds = new ArrayList<>();
private final List<String> outgoingEdgeIds = new ArrayList<>();
private final LinkedHashSet<String>
topologyPredecessorNodeIds = new LinkedHashSet<>();
private final LinkedHashSet<String>
topologySuccessorNodeIds = new LinkedHashSet<>();
/**
* 创建内部节点。
*
* @param nodeId 节点 ID
* @param nodeType 节点类型
* @param nodeName 节点名称
* @param description 节点描述
* @param parentNodeId 父节点 ID
* @param definitionIndex 发布定义位置
* @param inputParameters 输入参数
* @param outputParameters 输出参数
*/
private NodeBuilder(
String nodeId,
String nodeType,
String nodeName,
String description,
String parentNodeId,
int definitionIndex,
List<PublicWorkflowTopology.Parameter> inputParameters,
List<PublicWorkflowTopology.Parameter> outputParameters) {
this.nodeId = nodeId;
this.nodeType = nodeType;
this.nodeName = nodeName;
this.description = description;
this.parentNodeId = parentNodeId;
this.definitionIndex = definitionIndex;
this.inputParameters = inputParameters;
this.outputParameters = outputParameters;
}
/**
* 登记一条有效出边。
*
* @param edgeId 边 ID
* @param targetId 目标节点 ID
*/
private void addOutgoing(String edgeId, String targetId) {
outgoingEdgeIds.add(edgeId);
successorNodeIds.add(targetId);
}
/**
* 登记一条有效入边。
*
* @param edgeId 边 ID
* @param sourceId 源节点 ID
*/
private void addIncoming(String edgeId, String sourceId) {
incomingEdgeIds.add(edgeId);
predecessorNodeIds.add(sourceId);
}
}
/**
* 稳定拓扑排序内部结果。
*
* @param order 节点顺序
* @param levels 拓扑层级
* @param unresolvedNodeIds 受环路影响的未解析节点 ID
* @param levelById 节点层级索引
*/
private record TopologyOrder(
List<String> order,
List<List<String>> levels,
List<String> unresolvedNodeIds,
Map<String, Integer> levelById) {
}
}

View File

@@ -0,0 +1,71 @@
package tech.easyflow.publicapi.service;
import org.springframework.stereotype.Service;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Public Workflow multipart Part 与开始节点文件参数的映射器。
*/
@Service
public class WorkflowApiMultipartParameterMapper {
/** API 请求元数据 Part 名。 */
public static final String METADATA_PART_NAME = "metadata";
/** 工作流文件参数 Part 前缀。 */
public static final String FILE_PART_PREFIX = "files.";
/**
* 将 API multipart 文件 Part 映射为开始节点文件参数。
*
* <p>{@code metadata} 只属于 API 信封,不参与工作流变量映射;
* {@code files.<参数名>} 去除前缀后才作为开始节点参数名。</p>
*
* @param rawFileParts Servlet 解析出的原始文件 Part
* @return 以开始节点文件参数名分组的文件
* @throws BusinessException Part 未使用规定命名空间或参数名为空时抛出
*/
public Map<String, List<MultipartFile>> map(
MultiValueMap<String, MultipartFile> rawFileParts) {
if (rawFileParts == null || rawFileParts.isEmpty()) {
return Map.of();
}
Map<String, List<MultipartFile>> mapped = new LinkedHashMap<>();
for (Map.Entry<String, List<MultipartFile>> entry :
rawFileParts.entrySet()) {
String partName = entry.getKey();
if (METADATA_PART_NAME.equals(partName)) {
continue;
}
if (!StringUtils.hasText(partName)
|| !partName.startsWith(FILE_PART_PREFIX)) {
throw new BusinessException(
400,
40016,
"文件 Part 必须使用 files.<开始节点文件参数名>");
}
String parameterName =
partName.substring(FILE_PART_PREFIX.length());
if (!StringUtils.hasText(parameterName)
|| !parameterName.equals(parameterName.trim())) {
throw new BusinessException(
400,
40016,
"文件 Part 中的开始节点文件参数名无效");
}
mapped.put(
parameterName,
entry.getValue() == null
? List.of()
: new ArrayList<>(entry.getValue()));
}
return mapped;
}
}