diff --git a/easyflow-api/easyflow-api-public/pom.xml b/easyflow-api/easyflow-api-public/pom.xml index 0e7c2b75..fe516744 100644 --- a/easyflow-api/easyflow-api-public/pom.xml +++ b/easyflow-api/easyflow-api-public/pom.xml @@ -37,5 +37,11 @@ com.mysql mysql-connector-j + + org.springframework.boot + spring-boot-starter-test + ${spring-boot.version} + test + diff --git a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/controller/PublicWorkflowController.java b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/controller/PublicWorkflowController.java index 6044e0f7..8e8c5193 100644 --- a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/controller/PublicWorkflowController.java +++ b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/controller/PublicWorkflowController.java @@ -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 getByIdOrAlias( + public Result 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 runAsync(@JsonBody(value = "id", required = true) BigInteger id, - @JsonBody("variables") Map 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 normalized = + workflowRunningParameterResolver.normalizeRuntimeVariables( + workflow.getContent(), + metadata.getVariables()); + return executePublishedWorkflow( + workflow, + normalized, + apiKey, + topology, + null); + } + + /** + * 通过单个 multipart 请求上传文件并异步运行工作流。 + * + *

{@code metadata} Part 使用 JSON;工作流文件 Part 使用 + * {@code files.<开始节点文件参数名>},同名 Part 可重复上传多个文件。

+ * + * @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> 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 getChainStatus(@JsonBody(value = "executeId") String executeId, - @JsonBody("nodes") List nodes, - HttpServletRequest request) { + public Result getChainStatus( + @JsonBody(value = "executeId") String executeId, + @JsonBody("nodes") List 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 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 variables, + SysApiKey apiKey, + PublicWorkflowTopology topology, + Consumer beforeStart) { + Map 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, + "工作流已下线或当前执行状态不可恢复"); } } } diff --git a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/dto/PublicApiErrorDetail.java b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/dto/PublicApiErrorDetail.java new file mode 100644 index 00000000..9cb11194 --- /dev/null +++ b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/dto/PublicApiErrorDetail.java @@ -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 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 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 getExpected() { + return expected; + } + + /** + * 判断是否适合直接重试。 + * + * @return 是否可重试 + */ + public boolean isRetryable() { + return retryable; + } +} diff --git a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/dto/PublicWorkflowChainStatus.java b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/dto/PublicWorkflowChainStatus.java new file mode 100644 index 00000000..7b23e938 --- /dev/null +++ b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/dto/PublicWorkflowChainStatus.java @@ -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 result, + Map nodes, + PublicWorkflowStatusError error) implements Serializable { + + /** + * 创建不可变公共执行状态。 + */ + public PublicWorkflowChainStatus { + nodes = nodes == null + ? Map.of() + : Collections.unmodifiableMap( + new LinkedHashMap<>(nodes)); + } +} diff --git a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/dto/PublicWorkflowExecutionStatus.java b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/dto/PublicWorkflowExecutionStatus.java new file mode 100644 index 00000000..444231ff --- /dev/null +++ b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/dto/PublicWorkflowExecutionStatus.java @@ -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; + }; + } +} diff --git a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/dto/PublicWorkflowInfo.java b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/dto/PublicWorkflowInfo.java new file mode 100644 index 00000000..6622fb8f --- /dev/null +++ b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/dto/PublicWorkflowInfo.java @@ -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()); + } +} diff --git a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/dto/PublicWorkflowNodeStatus.java b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/dto/PublicWorkflowNodeStatus.java new file mode 100644 index 00000000..0916379a --- /dev/null +++ b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/dto/PublicWorkflowNodeStatus.java @@ -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 result, + List suspendForParameters) implements Serializable { +} diff --git a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/dto/PublicWorkflowRunMetadata.java b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/dto/PublicWorkflowRunMetadata.java new file mode 100644 index 00000000..b5b3810d --- /dev/null +++ b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/dto/PublicWorkflowRunMetadata.java @@ -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 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 getVariables() { + return variables; + } + + /** + * 设置普通运行变量。 + * + * @param variables 普通运行变量 + */ + public void setVariables(Map variables) { + this.variables = variables == null + ? new LinkedHashMap<>() + : new LinkedHashMap<>(variables); + } +} diff --git a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/dto/PublicWorkflowRunResult.java b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/dto/PublicWorkflowRunResult.java new file mode 100644 index 00000000..f26409b7 --- /dev/null +++ b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/dto/PublicWorkflowRunResult.java @@ -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 执行响应。 + * + *

继承原有 {@link Result} 并继续把执行 ID 放在 {@code data}, + * 新增 {@code workflow} 区块以保持旧调用方兼容。

+ */ +public class PublicWorkflowRunResult extends Result { + + 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; + } +} diff --git a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/dto/PublicWorkflowStatusError.java b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/dto/PublicWorkflowStatusError.java new file mode 100644 index 00000000..c5fa9977 --- /dev/null +++ b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/dto/PublicWorkflowStatusError.java @@ -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; + } +} diff --git a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/dto/PublicWorkflowTopology.java b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/dto/PublicWorkflowTopology.java new file mode 100644 index 00000000..cf0ae918 --- /dev/null +++ b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/dto/PublicWorkflowTopology.java @@ -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 nodes, + List edges, + List topologicalOrder, + List> topologyLevels, + boolean hasCycle, + List 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 predecessorNodeIds, + List successorNodeIds, + List incomingEdgeIds, + List outgoingEdgeIds, + List inputParameters, + List 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 元素类型 + * @return 不可变列表 + */ + private static List immutable(List source) { + return source == null ? List.of() : List.copyOf(source); + } +} diff --git a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/error/WorkflowRunAsyncErrorProfile.java b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/error/WorkflowRunAsyncErrorProfile.java new file mode 100644 index 00000000..03fc31af --- /dev/null +++ b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/error/WorkflowRunAsyncErrorProfile.java @@ -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 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-Type;JSON 调用请使用 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 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); + } +} diff --git a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/interceptor/PublicApiInterceptor.java b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/interceptor/PublicApiInterceptor.java index 7825c4ff..4fff8ee4 100644 --- a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/interceptor/PublicApiInterceptor.java +++ b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/interceptor/PublicApiInterceptor.java @@ -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 failed = Result.fail(401, "密钥不正确"); + Result 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; diff --git a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/interceptor/PublicApiRequestContextFilter.java b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/interceptor/PublicApiRequestContextFilter.java new file mode 100644 index 00000000..178b2112 --- /dev/null +++ b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/interceptor/PublicApiRequestContextFilter.java @@ -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); + } +} diff --git a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/service/PublicWorkflowStatusSanitizer.java b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/service/PublicWorkflowStatusSanitizer.java new file mode 100644 index 00000000..d601981b --- /dev/null +++ b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/service/PublicWorkflowStatusSanitizer.java @@ -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 safeNodes = + new LinkedHashMap<>(); + PublicWorkflowStatusError firstNodeError = null; + if (source.getNodes() != null) { + for (Map.Entry 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; + } +} diff --git a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/service/PublicWorkflowTopologyService.java b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/service/PublicWorkflowTopologyService.java new file mode 100644 index 00000000..fd8496dd --- /dev/null +++ b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/service/PublicWorkflowTopologyService.java @@ -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 nodeById = + parseNodes(rawNodes); + List edges = + parseEdges(rawEdges, nodeById); + addLoopCompletionBarriers(nodeById); + TopologyOrder topology = resolveTopology(nodeById); + List 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 parseNodes( + JSONArray rawNodes) { + if (rawNodes == null || rawNodes.isEmpty()) { + throw new BusinessException("已发布工作流缺少节点"); + } + LinkedHashMap 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 parseEdges( + JSONArray rawEdges, + Map nodeById) { + if (rawEdges == null || rawEdges.isEmpty()) { + return List.of(); + } + List 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; + } + + /** + * 为循环体末节点与循环后的节点增加结构化完成屏障。 + * + *

运行时由循环体末节点隐式返回父循环节点,父循环完成全部迭代后 + * 才会触发同作用域后继。公开拓扑使用末节点到后继的无环屏障表达这一约束。

+ * + * @param nodeById 节点索引 + */ + private void addLoopCompletionBarriers( + Map nodeById) { + for (NodeBuilder loopNode : nodeById.values()) { + if (!"loopNode".equals(loopNode.nodeType)) { + continue; + } + List 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); + } + } + } + } + + /** + * 解析循环作用域完成时必须结束的叶子节点。 + * + *

若作用域叶子本身是嵌套循环,则继续展开到嵌套循环体叶子, + * 确保外层后继等待完整嵌套作用域结束。

+ * + * @param loopNodeId 循环节点 ID + * @param nodeById 节点索引 + * @param visiting 正在展开的循环节点 + * @return 实际完成屏障节点 + */ + private List resolveLoopScopeExitNodes( + String loopNodeId, + Map nodeById, + Set visiting) { + if (!visiting.add(loopNodeId)) { + return List.of(); + } + List exits = new ArrayList<>(); + for (NodeBuilder candidate : nodeById.values()) { + if (!Objects.equals(loopNodeId, candidate.parentNodeId) + || hasSameScopeSuccessor(candidate, nodeById)) { + continue; + } + if ("loopNode".equals(candidate.nodeType)) { + List 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 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 拓扑排序。 + * + *

同一层级节点按发布定义顺序稳定排列。若发布图意外存在环, + * 标准拓扑序之后按定义顺序追加受环路影响的未解析节点。

+ * + * @param nodeById 节点索引 + * @return 拓扑排序结果 + */ + private TopologyOrder resolveTopology( + LinkedHashMap nodeById) { + Map remainingInDegree = new HashMap<>(); + Map levelById = new HashMap<>(); + PriorityQueue 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 order = new ArrayList<>(nodeById.size()); + Set 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 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> buildLevels( + List order, + Map levelById) { + int maxLevel = -1; + for (String nodeId : order) { + maxLevel = Math.max( + maxLevel, + levelById.getOrDefault(nodeId, -1)); + } + if (maxLevel < 0) { + return List.of(); + } + List> 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 buildNodes( + Map nodeById, + TopologyOrder topology) { + List 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 parseParameters( + JSONArray rawParameters, + boolean multipartEnabled) { + if (rawParameters == null || rawParameters.isEmpty()) { + return List.of(); + } + List 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 inputParameters; + private final List outputParameters; + private final LinkedHashSet predecessorNodeIds = + new LinkedHashSet<>(); + private final LinkedHashSet successorNodeIds = + new LinkedHashSet<>(); + private final List incomingEdgeIds = new ArrayList<>(); + private final List outgoingEdgeIds = new ArrayList<>(); + private final LinkedHashSet + topologyPredecessorNodeIds = new LinkedHashSet<>(); + private final LinkedHashSet + 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 inputParameters, + List 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 order, + List> levels, + List unresolvedNodeIds, + Map levelById) { + } +} diff --git a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/service/WorkflowApiMultipartParameterMapper.java b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/service/WorkflowApiMultipartParameterMapper.java new file mode 100644 index 00000000..7e8236f0 --- /dev/null +++ b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/service/WorkflowApiMultipartParameterMapper.java @@ -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 映射为开始节点文件参数。 + * + *

{@code metadata} 只属于 API 信封,不参与工作流变量映射; + * {@code files.<参数名>} 去除前缀后才作为开始节点参数名。

+ * + * @param rawFileParts Servlet 解析出的原始文件 Part + * @return 以开始节点文件参数名分组的文件 + * @throws BusinessException Part 未使用规定命名空间或参数名为空时抛出 + */ + public Map> map( + MultiValueMap rawFileParts) { + if (rawFileParts == null || rawFileParts.isEmpty()) { + return Map.of(); + } + Map> mapped = new LinkedHashMap<>(); + for (Map.Entry> 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; + } +} diff --git a/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/controller/PublicWorkflowControllerBehaviorTest.java b/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/controller/PublicWorkflowControllerBehaviorTest.java new file mode 100644 index 00000000..8cecbf26 --- /dev/null +++ b/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/controller/PublicWorkflowControllerBehaviorTest.java @@ -0,0 +1,191 @@ +package tech.easyflow.publicapi.controller; + +import com.easyagents.flow.core.chain.ChainStatus; +import com.easyagents.flow.core.chain.NodeStatus; +import com.easyagents.flow.core.chain.runtime.ChainExecutor; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; +import tech.easyflow.ai.easyagentsflow.entity.ChainInfo; +import tech.easyflow.ai.easyagentsflow.entity.NodeInfo; +import tech.easyflow.ai.easyagentsflow.service.TinyFlowService; +import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.ai.entity.WorkflowExecResult; +import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.ai.service.WorkflowApiPermissionService; +import tech.easyflow.ai.service.WorkflowExecResultService; +import tech.easyflow.ai.service.WorkflowService; +import tech.easyflow.ai.utils.WorkFlowUtil; +import tech.easyflow.common.domain.Result; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.publicapi.dto.PublicWorkflowChainStatus; +import tech.easyflow.publicapi.dto.PublicWorkflowExecutionStatus; +import tech.easyflow.publicapi.service.PublicWorkflowStatusSanitizer; +import tech.easyflow.system.entity.SysApiKey; + +import java.math.BigInteger; +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link PublicWorkflowController} 状态查询与恢复行为测试。 + */ +public class PublicWorkflowControllerBehaviorTest { + + private static final String EXECUTE_ID = "execute-1"; + + private PublicWorkflowController controller; + private ChainExecutor chainExecutor; + private TinyFlowService tinyFlowService; + private HttpServletRequest request; + + /** + * 创建通过 API Key 执行归属校验的控制器测试夹具。 + */ + @Before + public void setUp() { + controller = new PublicWorkflowController(); + chainExecutor = Mockito.mock(ChainExecutor.class); + tinyFlowService = Mockito.mock(TinyFlowService.class); + WorkflowApiPermissionService permissionService = + Mockito.mock(WorkflowApiPermissionService.class); + WorkflowExecResultService execResultService = + Mockito.mock(WorkflowExecResultService.class); + WorkflowService workflowService = + Mockito.mock(WorkflowService.class); + request = Mockito.mock(HttpServletRequest.class); + + SysApiKey apiKey = new SysApiKey(); + apiKey.setId(BigInteger.TEN); + when(request.getHeader("ApiKey")).thenReturn("api-key"); + when(request.getRequestURI()).thenReturn( + "/public-api/workflow/getChainStatus"); + when(permissionService.assertWorkflowApi(any(), anyString())) + .thenReturn(apiKey); + when(execResultService.getByExecKey(EXECUTE_ID)) + .thenReturn(executionRecord()); + when(workflowService.getById(BigInteger.ONE)) + .thenReturn(publishedWorkflow()); + + ReflectionTestUtils.setField( + controller, + "chainExecutor", + chainExecutor); + ReflectionTestUtils.setField( + controller, + "tinyFlowService", + tinyFlowService); + ReflectionTestUtils.setField( + controller, + "workflowApiPermissionService", + permissionService); + ReflectionTestUtils.setField( + controller, + "workflowExecResultService", + execResultService); + ReflectionTestUtils.setField( + controller, + "workflowService", + workflowService); + ReflectionTestUtils.setField( + controller, + "publicWorkflowStatusSanitizer", + new PublicWorkflowStatusSanitizer()); + } + + /** + * 验证非暂停状态恢复返回稳定冲突错误,且不会调用旧的无条件恢复入口。 + */ + @Test + public void resumeShouldRejectNonSuspendedExecution() { + when(request.getRequestURI()).thenReturn( + "/public-api/workflow/resume"); + when(chainExecutor.resumeAsyncIfSuspended( + EXECUTE_ID, + Map.of("approved", true))) + .thenReturn(false); + + try { + controller.resume( + EXECUTE_ID, + Map.of("approved", true), + request); + Assert.fail("非暂停状态必须拒绝恢复"); + } catch (BusinessException exception) { + Assert.assertEquals(409, exception.getHttpStatus()); + Assert.assertEquals(40901, exception.getErrorCode()); + } + + verify(chainExecutor).resumeAsyncIfSuspended( + EXECUTE_ID, + Map.of("approved", true)); + } + + /** + * 验证状态查询使用定义快照补齐节点名称并返回可读枚举。 + */ + @Test + public void statusShouldEnrichNodeNameAndReadableEnum() { + ChainInfo chainInfo = new ChainInfo(); + chainInfo.setExecuteId(EXECUTE_ID); + chainInfo.setStatus(ChainStatus.RUNNING.getValue()); + NodeInfo node = new NodeInfo(); + node.setNodeId("node-1"); + node.setNodeName("文档解析"); + node.setStatus(NodeStatus.RUNNING.getValue()); + chainInfo.setNodes(Map.of("node-1", node)); + when(tinyFlowService.getChainStatus( + EXECUTE_ID, + List.of(node))) + .thenReturn(chainInfo); + + Result result = + controller.getChainStatus( + EXECUTE_ID, + List.of(node), + request); + + Assert.assertEquals( + PublicWorkflowExecutionStatus.RUNNING, + result.getData().status()); + Assert.assertFalse(result.getData().terminal()); + Assert.assertEquals( + "文档解析", + result.getData().nodes().get("node-1").nodeName()); + } + + /** + * 创建属于当前 API Key 的执行记录。 + * + * @return 执行记录 + */ + private WorkflowExecResult executionRecord() { + WorkflowExecResult result = new WorkflowExecResult(); + result.setExecKey(EXECUTE_ID); + result.setWorkflowId(BigInteger.ONE); + result.setCreatedKey(WorkFlowUtil.API_KEY); + result.setCreatedBy(BigInteger.TEN.toString()); + return result; + } + + /** + * 创建可恢复校验使用的已发布工作流。 + * + * @return 已发布工作流 + */ + private Workflow publishedWorkflow() { + Workflow workflow = new Workflow(); + workflow.setId(BigInteger.ONE); + workflow.setPublishStatus(PublishStatus.PUBLISHED.getCode()); + workflow.setPublishedSnapshotJson(Map.of("nodes", List.of())); + return workflow; + } +} diff --git a/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/controller/PublicWorkflowControllerContractTest.java b/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/controller/PublicWorkflowControllerContractTest.java index 90baf433..2c7fe70e 100644 --- a/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/controller/PublicWorkflowControllerContractTest.java +++ b/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/controller/PublicWorkflowControllerContractTest.java @@ -1,9 +1,23 @@ package tech.easyflow.publicapi.controller; +import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.Assert; import org.junit.Test; import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; +import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.publicapi.dto.PublicWorkflowChainStatus; +import tech.easyflow.publicapi.dto.PublicWorkflowExecutionStatus; +import tech.easyflow.publicapi.dto.PublicWorkflowInfo; +import tech.easyflow.publicapi.dto.PublicWorkflowRunResult; +import tech.easyflow.publicapi.dto.PublicWorkflowTopology; + +import java.lang.reflect.Method; +import java.math.BigInteger; +import java.util.Arrays; +import java.util.List; +import java.util.Map; /** * {@link PublicWorkflowController} HTTP 响应契约测试。 @@ -23,4 +37,139 @@ public class PublicWorkflowControllerContractTest { requestMapping.produces() ); } + + /** + * 验证 multipart 调用复用 runAsync 路径并明确声明媒体类型。 + */ + @Test + public void shouldExposeMultipartRunAsyncOnCompatiblePath() { + Method multipartMethod = Arrays.stream( + PublicWorkflowController.class.getDeclaredMethods()) + .filter(method -> "runAsyncMultipart".equals( + method.getName())) + .findFirst() + .orElseThrow(); + PostMapping postMapping = + multipartMethod.getAnnotation(PostMapping.class); + + Assert.assertNotNull(postMapping); + Assert.assertArrayEquals( + new String[]{"/runAsync"}, + postMapping.value()); + Assert.assertArrayEquals( + new String[]{MediaType.MULTIPART_FORM_DATA_VALUE}, + postMapping.consumes()); + } + + /** + * 验证 JSON 调用显式声明媒体类型,避免与 Multipart 路由混淆。 + */ + @Test + public void shouldExposeJsonRunAsyncWithExplicitMediaType() { + Method jsonMethod = Arrays.stream( + PublicWorkflowController.class.getDeclaredMethods()) + .filter(method -> "runAsync".equals(method.getName())) + .findFirst() + .orElseThrow(); + PostMapping postMapping = + jsonMethod.getAnnotation(PostMapping.class); + + Assert.assertNotNull(postMapping); + Assert.assertArrayEquals( + new String[]{MediaType.APPLICATION_JSON_VALUE}, + postMapping.consumes()); + } + + /** + * 验证富响应继续把执行 ID 保存在 data 字符串中。 + * + * @throws Exception JSON 序列化失败 + */ + @Test + public void runResultShouldKeepLegacyExecuteIdData() + throws Exception { + PublicWorkflowTopology topology = + new PublicWorkflowTopology( + "1", + null, + "测试工作流", + null, + 1, + null, + List.of(), + List.of(), + List.of(), + List.of(), + false, + List.of()); + + PublicWorkflowRunResult result = + PublicWorkflowRunResult.success( + "execution-1", + topology); + + Assert.assertEquals("execution-1", result.getData()); + Assert.assertSame(topology, result.getWorkflow()); + Assert.assertEquals(0, result.getErrorCode()); + String json = new ObjectMapper().writeValueAsString(result); + Assert.assertTrue(json.contains("\"data\":\"execution-1\"")); + Assert.assertTrue(json.contains("\"workflow\"")); + } + + /** + * 验证详情接口 DTO 只公开调用所需的基础字段。 + * + * @throws Exception JSON 序列化失败 + */ + @Test + public void workflowInfoShouldExcludeInternalFields() + throws Exception { + Workflow workflow = new Workflow(); + workflow.setId(new BigInteger("9007199254740993")); + workflow.setAlias("document-parser"); + workflow.setTitle("文档解析"); + workflow.setContent("internal-content"); + workflow.setTenantId(BigInteger.TEN); + workflow.setDeptId(BigInteger.ONE); + workflow.setPublishedSnapshotJson( + Map.of("secret", "snapshot")); + + String json = new ObjectMapper().writeValueAsString( + PublicWorkflowInfo.from(workflow)); + + Assert.assertTrue(json.contains( + "\"id\":\"9007199254740993\"")); + Assert.assertTrue(json.contains( + "\"alias\":\"document-parser\"")); + Assert.assertFalse(json.contains("content")); + Assert.assertFalse(json.contains("tenantId")); + Assert.assertFalse(json.contains("deptId")); + Assert.assertFalse(json.contains("publishedSnapshotJson")); + Assert.assertFalse(json.contains("secret")); + } + + /** + * 验证链路状态以小写可读枚举返回,不再公开内部数值。 + * + * @throws Exception JSON 序列化失败 + */ + @Test + public void chainStatusShouldSerializeReadableEnum() + throws Exception { + PublicWorkflowChainStatus status = + new PublicWorkflowChainStatus( + "execute-1", + PublicWorkflowExecutionStatus.DONE, + true, + null, + Map.of("output", "ok"), + Map.of(), + null); + + String json = new ObjectMapper().writeValueAsString(status); + + Assert.assertTrue(json.contains("\"status\":\"done\"")); + Assert.assertTrue(json.contains("\"terminal\":true")); + Assert.assertFalse(json.contains("\"status\":20")); + } } diff --git a/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/controller/PublicWorkflowControllerRoutingTest.java b/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/controller/PublicWorkflowControllerRoutingTest.java new file mode 100644 index 00000000..5ca73408 --- /dev/null +++ b/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/controller/PublicWorkflowControllerRoutingTest.java @@ -0,0 +1,445 @@ +package tech.easyflow.publicapi.controller; + +import com.easyagents.flow.core.chain.runtime.ChainExecutor; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; +import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService; +import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver; +import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiPreparedUpload; +import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadLifecycleService; +import tech.easyflow.ai.entity.Workflow; +import tech.easyflow.ai.enums.PublishStatus; +import tech.easyflow.ai.service.WorkflowApiPermissionService; +import tech.easyflow.ai.service.WorkflowService; +import tech.easyflow.common.web.error.GlobalErrorResolver; +import tech.easyflow.publicapi.dto.PublicWorkflowTopology; +import tech.easyflow.publicapi.interceptor.PublicApiRequestContextFilter; +import tech.easyflow.publicapi.service.PublicWorkflowTopologyService; +import tech.easyflow.publicapi.service.WorkflowApiMultipartParameterMapper; +import tech.easyflow.system.entity.SysApiKey; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * {@link PublicWorkflowController} 真实 MVC 路由与错误契约测试。 + */ +public class PublicWorkflowControllerRoutingTest { + + private static final String RUN_PATH = + "/public-api/workflow/runAsync"; + + private MockMvc mockMvc; + private WorkflowService workflowService; + private WorkflowRunningParameterResolver parameterResolver; + private WorkflowApiMultipartParameterMapper multipartMapper; + private WorkflowApiUploadLifecycleService uploadLifecycleService; + + /** + * 创建控制器和全部最小依赖。 + */ + @Before + public void setUp() { + PublicWorkflowController controller = + new PublicWorkflowController(); + workflowService = Mockito.mock(WorkflowService.class); + WorkflowCheckService workflowCheckService = + Mockito.mock(WorkflowCheckService.class); + WorkflowApiPermissionService permissionService = + Mockito.mock(WorkflowApiPermissionService.class); + PublicWorkflowTopologyService topologyService = + Mockito.mock(PublicWorkflowTopologyService.class); + ChainExecutor chainExecutor = + Mockito.mock(ChainExecutor.class); + parameterResolver = Mockito.mock( + WorkflowRunningParameterResolver.class); + multipartMapper = Mockito.mock( + WorkflowApiMultipartParameterMapper.class); + uploadLifecycleService = Mockito.mock( + WorkflowApiUploadLifecycleService.class); + + Workflow workflow = publishedWorkflow(); + SysApiKey apiKey = new SysApiKey(); + apiKey.setId(BigInteger.TEN); + when(permissionService.assertWorkflowApi(any(), anyString())) + .thenReturn(apiKey); + when(workflowService.getPublishedById(BigInteger.ONE)) + .thenReturn(workflow); + when(workflowService.getPublishedDetail("document-parser")) + .thenReturn(workflow); + when(topologyService.resolve(workflow)) + .thenReturn(topology()); + when(parameterResolver.normalizeRuntimeVariables( + eq("{}"), + anyMap())) + .thenAnswer(invocation -> invocation.getArgument(1)); + when(chainExecutor.executeAsync( + anyString(), + anyMap(), + Mockito.>any())) + .thenAnswer(invocation -> { + Consumer beforeStart = + invocation.getArgument(2); + if (beforeStart != null) { + beforeStart.accept("execute-1"); + } + return "execute-1"; + }); + when(multipartMapper.map(any())) + .thenReturn(Map.of("file", List.of())); + when(uploadLifecycleService.prepare( + eq("{}"), + anyMap(), + anyMap())) + .thenReturn(new WorkflowApiPreparedUpload( + "upload-1", + Map.of())); + + ReflectionTestUtils.setField( + controller, + "workflowService", + workflowService); + ReflectionTestUtils.setField( + controller, + "workflowCheckService", + workflowCheckService); + ReflectionTestUtils.setField( + controller, + "workflowApiPermissionService", + permissionService); + ReflectionTestUtils.setField( + controller, + "publicWorkflowTopologyService", + topologyService); + ReflectionTestUtils.setField( + controller, + "chainExecutor", + chainExecutor); + ReflectionTestUtils.setField( + controller, + "workflowRunningParameterResolver", + parameterResolver); + ReflectionTestUtils.setField( + controller, + "workflowApiMultipartParameterMapper", + multipartMapper); + ReflectionTestUtils.setField( + controller, + "workflowApiUploadLifecycleService", + uploadLifecycleService); + + LocalValidatorFactoryBean validator = + new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setValidator(validator) + .setHandlerExceptionResolvers( + new GlobalErrorResolver()) + .addFilters(new PublicApiRequestContextFilter()) + .build(); + } + + /** + * 验证 application/json 只进入 JSON 调用链。 + * + * @throws Exception 请求执行失败 + */ + @Test + public void jsonRequestShouldUseJsonHandler() throws Exception { + mockMvc.perform(post(RUN_PATH) + .header("ApiKey", "key") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"id\":1,\"variables\":{}}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").value("execute-1")); + + verify(parameterResolver).normalizeRuntimeVariables( + eq("{}"), + anyMap()); + verify(uploadLifecycleService, never()).prepare( + anyString(), + anyMap(), + anyMap()); + } + + /** + * 验证 multipart/form-data 只进入文件直传调用链。 + * + * @throws Exception 请求执行失败 + */ + @Test + public void multipartRequestShouldUseMultipartHandler() + throws Exception { + MockMultipartFile metadata = new MockMultipartFile( + "metadata", + "metadata.json", + MediaType.APPLICATION_JSON_VALUE, + "{\"id\":1,\"variables\":{}}" + .getBytes(StandardCharsets.UTF_8)); + MockMultipartFile file = new MockMultipartFile( + "files.file", + "report.pdf", + MediaType.APPLICATION_PDF_VALUE, + "pdf".getBytes(StandardCharsets.UTF_8)); + + mockMvc.perform(multipart(RUN_PATH) + .file(metadata) + .file(file) + .header("ApiKey", "key")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").value("execute-1")); + + verify(multipartMapper).map(any()); + verify(uploadLifecycleService).prepare( + eq("{}"), + anyMap(), + anyMap()); + } + + /** + * 验证错误顶层媒体类型返回 41501。 + * + * @throws Exception 请求执行失败 + */ + @Test + public void unsupportedContentTypeShouldExplainExpectedTypes() + throws Exception { + mockMvc.perform(post(RUN_PATH) + .header("ApiKey", "key") + .header("X-Request-Id", "request-415") + .contentType(MediaType.TEXT_PLAIN) + .content("{}")) + .andExpect(status().isUnsupportedMediaType()) + .andExpect(jsonPath("$.errorCode").value(41501)) + .andExpect(jsonPath("$.data.field") + .value("Content-Type")) + .andExpect(jsonPath("$.data.requestId") + .value("request-415")) + .andExpect(header().string( + "X-Request-Id", + "request-415")); + } + + /** + * 验证缺少顶层媒体类型时返回 41501。 + * + * @throws Exception 请求执行失败 + */ + @Test + public void missingContentTypeShouldExplainExpectedTypes() + throws Exception { + mockMvc.perform(post(RUN_PATH) + .header("ApiKey", "key") + .content("{}")) + .andExpect(status().isUnsupportedMediaType()) + .andExpect(jsonPath("$.errorCode").value(41501)) + .andExpect(jsonPath("$.data.expected.length()") + .value(2)); + } + + /** + * 验证无效 JSON 返回 40011,不再误报 ID 为空。 + * + * @throws Exception 请求执行失败 + */ + @Test + public void malformedJsonShouldReturnBodyMismatchError() + throws Exception { + mockMvc.perform(post(RUN_PATH) + .header("ApiKey", "key") + .contentType(MediaType.APPLICATION_JSON) + .content("not-json")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value(40011)); + } + + /** + * 验证 Multipart 缺少 metadata 时返回 40013。 + * + * @throws Exception 请求执行失败 + */ + @Test + public void missingMetadataShouldReturnSpecificError() + throws Exception { + MockMultipartFile file = new MockMultipartFile( + "files.file", + "report.pdf", + MediaType.APPLICATION_PDF_VALUE, + "pdf".getBytes(StandardCharsets.UTF_8)); + + mockMvc.perform(multipart(RUN_PATH) + .file(file) + .header("ApiKey", "key")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value(40013)); + } + + /** + * 验证 metadata Part 媒体类型错误时返回 41502。 + * + * @throws Exception 请求执行失败 + */ + @Test + public void metadataWithWrongContentTypeShouldReturnSpecificError() + throws Exception { + MockMultipartFile metadata = new MockMultipartFile( + "metadata", + "metadata.txt", + MediaType.TEXT_PLAIN_VALUE, + "{\"id\":1,\"variables\":{}}" + .getBytes(StandardCharsets.UTF_8)); + + mockMvc.perform(multipart(RUN_PATH) + .file(metadata) + .header("ApiKey", "key")) + .andExpect(status().isUnsupportedMediaType()) + .andExpect(jsonPath("$.errorCode").value(41502)) + .andExpect(jsonPath("$.data.expected[0]") + .value(MediaType.APPLICATION_JSON_VALUE)); + } + + /** + * 验证 Multipart metadata 缺少工作流 ID 时返回 40015。 + * + * @throws Exception 请求执行失败 + */ + @Test + public void metadataWithoutIdShouldReturnSpecificError() + throws Exception { + MockMultipartFile metadata = new MockMultipartFile( + "metadata", + "metadata.json", + MediaType.APPLICATION_JSON_VALUE, + "{\"variables\":{}}" + .getBytes(StandardCharsets.UTF_8)); + + mockMvc.perform(multipart(RUN_PATH) + .file(metadata) + .header("ApiKey", "key")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value(40015)) + .andExpect(jsonPath("$.data.field").value("id")); + } + + /** + * 验证 metadata Part 内容不是合法 JSON 时返回 40014。 + * + * @throws Exception 请求执行失败 + */ + @Test + public void malformedMetadataShouldReturnSpecificError() + throws Exception { + MockMultipartFile metadata = new MockMultipartFile( + "metadata", + "metadata.json", + MediaType.APPLICATION_JSON_VALUE, + "not-json".getBytes(StandardCharsets.UTF_8)); + + mockMvc.perform(multipart(RUN_PATH) + .file(metadata) + .header("ApiKey", "key")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value(40014)); + } + + /** + * 验证详情接口只返回安全基础字段。 + * + * @throws Exception 请求执行失败 + */ + @Test + public void workflowDetailShouldReturnSafeInfo() + throws Exception { + mockMvc.perform(get( + "/public-api/workflow/getByIdOrAlias") + .header("ApiKey", "key") + .param("key", "document-parser")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value("1")) + .andExpect(jsonPath("$.data.content").doesNotExist()) + .andExpect(jsonPath("$.data.tenantId").doesNotExist()) + .andExpect(jsonPath("$.data.deptId").doesNotExist()) + .andExpect(jsonPath("$.data.publishedSnapshotJson") + .doesNotExist()); + } + + /** + * 验证运行参数解析失败时返回真实 HTTP 500 和稳定错误码。 + * + * @throws Exception 请求执行失败 + */ + @Test + public void invalidRunningParametersShouldReturnServerError() + throws Exception { + when(parameterResolver.buildRunningParametersView(any())) + .thenReturn(null); + + mockMvc.perform(get( + "/public-api/workflow/getRunningParameters") + .header("ApiKey", "key") + .param("id", "1")) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.errorCode").value(50001)); + } + + /** + * 创建已发布工作流桩。 + * + * @return 已发布工作流 + */ + private Workflow publishedWorkflow() { + Workflow workflow = new Workflow(); + workflow.setId(BigInteger.ONE); + workflow.setAlias("document-parser"); + workflow.setTitle("文档解析"); + workflow.setContent("{}"); + workflow.setPublishStatus(PublishStatus.PUBLISHED.getCode()); + workflow.setPublishedSnapshotJson(Map.of("nodes", List.of())); + return workflow; + } + + /** + * 创建最小公开拓扑。 + * + * @return 公开拓扑 + */ + private PublicWorkflowTopology topology() { + return new PublicWorkflowTopology( + "1", + null, + "测试工作流", + null, + 1, + null, + List.of(), + List.of(), + List.of(), + List.of(), + false, + List.of()); + } +} diff --git a/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/error/WorkflowRunAsyncErrorProfileTest.java b/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/error/WorkflowRunAsyncErrorProfileTest.java new file mode 100644 index 00000000..fcc1d3d0 --- /dev/null +++ b/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/error/WorkflowRunAsyncErrorProfileTest.java @@ -0,0 +1,330 @@ +package tech.easyflow.publicapi.error; + +import com.alibaba.fastjson.JSONObject; +import org.junit.Assert; +import org.junit.Test; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.multipart.MaxUploadSizeExceededException; +import org.springframework.web.multipart.MultipartException; +import org.springframework.web.multipart.support.MissingServletRequestPartException; +import org.springframework.web.servlet.ModelAndView; +import tech.easyflow.common.web.error.GlobalErrorResolver; +import tech.easyflow.common.web.error.RequestErrorProfile; +import tech.easyflow.common.web.error.RequestIdContext; +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.util.List; + +/** + * {@link WorkflowRunAsyncErrorProfile} 错误契约测试。 + */ +public class WorkflowRunAsyncErrorProfileTest { + + private final GlobalErrorResolver resolver = + new GlobalErrorResolver(); + + /** + * 验证缺少 Multipart boundary 时返回可执行修复信息。 + */ + @Test + public void shouldExplainMissingMultipartBoundary() { + Resolution resolution = resolve( + MediaType.MULTIPART_FORM_DATA_VALUE, + new MultipartException("failed to parse multipart")); + + Assert.assertEquals(400, resolution.response.getStatus()); + Assert.assertEquals( + 40012, + resolution.modelAndView.getModel().get("errorCode")); + Assert.assertTrue(String.valueOf( + resolution.modelAndView.getModel().get("message")) + .contains("boundary")); + Assert.assertEquals( + "request-1", + detail(resolution).getString("requestId")); + } + + /** + * 验证缺少 metadata Part 时不会再返回 ID 为空。 + */ + @Test + public void shouldExplainMissingMetadataPart() { + Resolution resolution = resolve( + "multipart/form-data; boundary=test", + new MissingServletRequestPartException("metadata")); + + Assert.assertEquals(400, resolution.response.getStatus()); + Assert.assertEquals( + 40013, + resolution.modelAndView.getModel().get("errorCode")); + Assert.assertTrue(String.valueOf( + resolution.modelAndView.getModel().get("message")) + .contains("metadata Part")); + } + + /** + * 验证不支持的顶层媒体类型会同时提示两种合法模式。 + */ + @Test + public void shouldExplainSupportedTopLevelMediaTypes() { + Resolution resolution = resolve( + MediaType.TEXT_PLAIN_VALUE, + new HttpMediaTypeNotSupportedException( + MediaType.TEXT_PLAIN, + List.of( + MediaType.APPLICATION_JSON, + MediaType.MULTIPART_FORM_DATA))); + + Assert.assertEquals(415, resolution.response.getStatus()); + Assert.assertEquals( + 41501, + resolution.modelAndView.getModel().get("errorCode")); + Assert.assertTrue(String.valueOf( + resolution.modelAndView.getModel().get("message")) + .contains("application/json")); + Assert.assertEquals( + MediaType.TEXT_PLAIN_VALUE, + detail(resolution).getString("actual")); + } + + /** + * 验证旧发布门禁异常统一转换为不可枚举的 40401。 + */ + @Test + public void shouldNormalizeLegacyUnpublishedWorkflowError() { + Resolution resolution = resolve( + MediaType.APPLICATION_JSON_VALUE, + new BusinessException("工作流尚未发布")); + + Assert.assertEquals(404, resolution.response.getStatus()); + Assert.assertEquals( + 40401, + resolution.modelAndView.getModel().get("errorCode")); + Assert.assertEquals( + "工作流不存在或当前不可公开调用", + resolution.modelAndView.getModel().get("message")); + } + + /** + * 验证服务端业务异常不会把内部依赖详情返回调用方。 + */ + @Test + public void shouldHideInternalBusinessErrorDetails() { + Resolution resolution = resolve( + MediaType.APPLICATION_JSON_VALUE, + new BusinessException( + 500, + 50001, + "minio endpoint=http://internal:9000 signature=secret")); + + Assert.assertEquals(500, resolution.response.getStatus()); + Assert.assertEquals( + "服务暂时不可用,请稍后重试", + resolution.modelAndView.getModel().get("message")); + } + + /** + * 验证状态查询接口沿用工作流 Public API 稳定鉴权错误码。 + */ + @Test + public void shouldNormalizeStatusApiAuthenticationError() { + Resolution resolution = resolve( + "/public-api/workflow/getChainStatus", + MediaType.APPLICATION_JSON_VALUE, + new BusinessException( + 401, + 401, + "apiKey 已过期")); + + Assert.assertEquals(401, resolution.response.getStatus()); + Assert.assertEquals( + 40103, + resolution.modelAndView.getModel().get("errorCode")); + } + + /** + * 验证工作流执行状态缺失不会被误判为工作流资源缺失。 + */ + @Test + public void shouldNormalizeMissingExecutionStateError() { + Resolution resolution = resolve( + "/public-api/workflow/getChainStatus", + MediaType.APPLICATION_JSON_VALUE, + new BusinessException( + 404, + 404, + "工作流执行状态不存在或已过期")); + + Assert.assertEquals(404, resolution.response.getStatus()); + Assert.assertEquals( + 40402, + resolution.modelAndView.getModel().get("errorCode")); + Assert.assertEquals( + "执行记录不存在、已过期或不可访问", + resolution.modelAndView.getModel().get("message")); + } + + /** + * 验证 API Key 无效和两层权限错误保持可区分。 + */ + @Test + public void shouldKeepAuthenticationAndAuthorizationCodesDistinct() { + assertBusinessCode( + new BusinessException( + 401, + 401, + "apiKey 不存在或已禁用"), + 401, + 40102); + assertBusinessCode( + new BusinessException( + 403, + 403, + "该apiKey无权限访问该接口"), + 403, + 40301); + assertBusinessCode( + new BusinessException( + 403, + 403, + "该apiKey无权限调用工作流 API"), + 403, + 40302); + } + + /** + * 验证 Multipart 请求超限返回 41301。 + */ + @Test + public void shouldTranslateMultipartUploadLimit() { + Resolution resolution = resolve( + "multipart/form-data; boundary=test", + new MaxUploadSizeExceededException(1024L)); + + Assert.assertEquals(413, resolution.response.getStatus()); + Assert.assertEquals( + 41301, + resolution.modelAndView.getModel().get("errorCode")); + } + + /** + * 验证状态查询中的未知异常也统一返回安全 50001。 + */ + @Test + public void shouldHideUnknownStatusApiFailure() { + Resolution resolution = resolve( + "/public-api/workflow/getChainStatus", + MediaType.APPLICATION_JSON_VALUE, + new IllegalStateException( + "redis endpoint=internal password=secret")); + + Assert.assertEquals(500, resolution.response.getStatus()); + Assert.assertEquals( + 50001, + resolution.modelAndView.getModel().get("errorCode")); + Assert.assertEquals( + "服务暂时不可用,请稍后重试", + resolution.modelAndView.getModel().get("message")); + Assert.assertEquals( + "request-1", + detail(resolution).getString("requestId")); + } + + /** + * 验证单个旧业务异常的稳定状态与错误码。 + * + * @param exception 旧业务异常 + * @param expectedStatus 期望 HTTP 状态 + * @param expectedCode 期望业务码 + */ + private void assertBusinessCode( + BusinessException exception, + int expectedStatus, + int expectedCode) { + Resolution resolution = resolve( + "/public-api/workflow/getChainStatus", + MediaType.APPLICATION_JSON_VALUE, + exception); + Assert.assertEquals( + expectedStatus, + resolution.response.getStatus()); + Assert.assertEquals( + expectedCode, + resolution.modelAndView.getModel().get("errorCode")); + } + + /** + * 执行异常解析并返回响应模型。 + * + * @param contentType 顶层媒体类型 + * @param exception 原始异常 + * @return 解析结果 + */ + private Resolution resolve( + String contentType, + Exception exception) { + return resolve( + "/public-api/workflow/runAsync", + contentType, + exception); + } + + /** + * 执行指定工作流接口的异常解析。 + * + * @param uri 请求地址 + * @param contentType 顶层媒体类型 + * @param exception 原始异常 + * @return 解析结果 + */ + private Resolution resolve( + String uri, + String contentType, + Exception exception) { + MockHttpServletRequest request = new MockHttpServletRequest( + "POST", + uri); + request.setContentType(contentType); + request.setAttribute( + RequestIdContext.ATTRIBUTE_NAME, + "request-1"); + request.setAttribute( + RequestErrorProfile.ATTRIBUTE_NAME, + WorkflowRunAsyncErrorProfile.INSTANCE); + MockHttpServletResponse response = + new MockHttpServletResponse(); + ModelAndView modelAndView = resolver.resolveException( + request, + response, + this, + exception); + return new Resolution(response, modelAndView); + } + + /** + * 获取错误详情 JSON。 + * + * @param resolution 解析结果 + * @return 错误详情 + */ + private JSONObject detail(Resolution resolution) { + Object data = resolution.modelAndView.getModel().get("data"); + return data instanceof JSONObject object + ? object + : JSONObject.parseObject(String.valueOf(data)); + } + + /** + * 错误解析结果。 + * + * @param response HTTP 响应 + * @param modelAndView JSON 视图模型 + */ + private record Resolution( + MockHttpServletResponse response, + ModelAndView modelAndView) { + } +} diff --git a/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/interceptor/PublicApiInterceptorTest.java b/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/interceptor/PublicApiInterceptorTest.java index d5fe4489..ae2b6a3d 100644 --- a/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/interceptor/PublicApiInterceptorTest.java +++ b/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/interceptor/PublicApiInterceptorTest.java @@ -37,6 +37,9 @@ public class PublicApiInterceptorTest { if ("getHeader".equals(method.getName())) { return null; } + if ("getAttribute".equals(method.getName())) { + return "request-1"; + } throw new AssertionError( "测试路径不应调用 HttpServletRequest." + method.getName()); @@ -66,8 +69,9 @@ public class PublicApiInterceptorTest { Assert.assertEquals( HttpServletResponse.SC_UNAUTHORIZED, status.get()); - Assert.assertTrue(body.toString().contains("\"errorCode\":401")); - Assert.assertTrue(body.toString().contains("密钥不正确")); + Assert.assertTrue(body.toString().contains("\"errorCode\":40101")); + Assert.assertTrue(body.toString().contains("缺少 ApiKey 请求头")); + Assert.assertTrue(body.toString().contains("request-1")); } /** diff --git a/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/interceptor/PublicApiRequestContextFilterTest.java b/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/interceptor/PublicApiRequestContextFilterTest.java new file mode 100644 index 00000000..a120f4fe --- /dev/null +++ b/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/interceptor/PublicApiRequestContextFilterTest.java @@ -0,0 +1,98 @@ +package tech.easyflow.publicapi.interceptor; + +import org.junit.Assert; +import org.junit.Test; +import org.slf4j.MDC; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import tech.easyflow.common.web.error.RequestErrorProfile; +import tech.easyflow.common.web.error.RequestIdContext; + +/** + * {@link PublicApiRequestContextFilter} 请求上下文测试。 + */ +public class PublicApiRequestContextFilterTest { + + /** + * 验证合法客户端请求 ID 会进入响应头,且请求结束后清理 MDC。 + * + * @throws Exception 过滤器执行失败 + */ + @Test + public void shouldKeepValidRequestIdAndClearMdc() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest( + "POST", + "/public-api/workflow/runAsync"); + request.addHeader(RequestIdContext.HEADER_NAME, "client-123"); + MockHttpServletResponse response = + new MockHttpServletResponse(); + + new PublicApiRequestContextFilter().doFilter( + request, + response, + new MockFilterChain()); + + Assert.assertEquals( + "client-123", + response.getHeader(RequestIdContext.HEADER_NAME)); + Assert.assertEquals( + "client-123", + request.getAttribute(RequestIdContext.ATTRIBUTE_NAME)); + Assert.assertNotNull(request.getAttribute( + RequestErrorProfile.ATTRIBUTE_NAME)); + Assert.assertNull(MDC.get(RequestIdContext.MDC_KEY)); + } + + /** + * 验证非法请求 ID 会被替换,避免响应头注入。 + * + * @throws Exception 过滤器执行失败 + */ + @Test + public void shouldReplaceInvalidRequestId() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest( + "POST", + "/public-api/workflow/runAsync"); + request.addHeader( + RequestIdContext.HEADER_NAME, + "bad\r\nX-Injected: yes"); + MockHttpServletResponse response = + new MockHttpServletResponse(); + + new PublicApiRequestContextFilter().doFilter( + request, + response, + new MockFilterChain()); + + String generated = response.getHeader( + RequestIdContext.HEADER_NAME); + Assert.assertNotNull(generated); + Assert.assertFalse(generated.contains("\r")); + Assert.assertFalse(generated.contains("\n")); + Assert.assertNotEquals("bad\r\nX-Injected: yes", generated); + } + + /** + * 验证后续工作流接口也会注册稳定业务错误翻译规则。 + * + * @throws Exception 过滤器执行失败 + */ + @Test + public void shouldAttachErrorProfileToWorkflowStatusApi() + throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest( + "POST", + "/public-api/workflow/getChainStatus"); + MockHttpServletResponse response = + new MockHttpServletResponse(); + + new PublicApiRequestContextFilter().doFilter( + request, + response, + new MockFilterChain()); + + Assert.assertNotNull(request.getAttribute( + RequestErrorProfile.ATTRIBUTE_NAME)); + } +} diff --git a/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/service/PublicWorkflowStatusSanitizerTest.java b/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/service/PublicWorkflowStatusSanitizerTest.java new file mode 100644 index 00000000..6640bda6 --- /dev/null +++ b/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/service/PublicWorkflowStatusSanitizerTest.java @@ -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()); + } +} diff --git a/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/service/PublicWorkflowTopologyServiceTest.java b/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/service/PublicWorkflowTopologyServiceTest.java new file mode 100644 index 00000000..0b4127c9 --- /dev/null +++ b/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/service/PublicWorkflowTopologyServiceTest.java @@ -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; + } +} diff --git a/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/service/WorkflowApiMultipartParameterMapperTest.java b/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/service/WorkflowApiMultipartParameterMapperTest.java new file mode 100644 index 00000000..122bb157 --- /dev/null +++ b/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/service/WorkflowApiMultipartParameterMapperTest.java @@ -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 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> 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 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 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); + } + } +} diff --git a/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/FileStorageManager.java b/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/FileStorageManager.java index 19e9c9ae..ce98bf94 100644 --- a/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/FileStorageManager.java +++ b/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/FileStorageManager.java @@ -139,6 +139,18 @@ public class FileStorageManager implements FileStorageService { return serviceForHandle(handle).saveRecoverable(file, handle); } + /** + * 严格按句柄中的后端打开物理对象读取流。 + * + * @param handle 物理对象句柄 + * @return 文件输入流,由调用方关闭 + * @throws IOException 无法读取物理对象时抛出 + */ + @Override + public InputStream readRecoverable(FileStorageWriteHandle handle) throws IOException { + return serviceForHandle(handle).readRecoverable(handle); + } + /** * 严格按句柄中的后端精确删除物理对象。 * diff --git a/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/FileStorageService.java b/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/FileStorageService.java index f17ed858..50d63fd3 100644 --- a/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/FileStorageService.java +++ b/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/FileStorageService.java @@ -91,6 +91,20 @@ public interface FileStorageService { throw unsupportedRecoverableOperation("saveRecoverable"); } + /** + * 按可恢复句柄精确打开物理对象读取流。 + * + *

该方法只接受由可信业务记录恢复出的句柄,不得直接使用外部传入的 locator。

+ * + * @param handle 物理对象写入句柄 + * @return 文件输入流,由调用方关闭 + * @throws IOException 无法打开物理对象时抛出 + * @throws UnsupportedOperationException 当前后端尚未实现可恢复读取时抛出 + */ + default InputStream readRecoverable(FileStorageWriteHandle handle) throws IOException { + throw unsupportedRecoverableOperation("readRecoverable"); + } + /** * 精确且幂等地删除句柄对应的物理对象。 * diff --git a/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/impl/LocalFileStorageServiceImpl.java b/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/impl/LocalFileStorageServiceImpl.java index ef92a88e..fd18a241 100644 --- a/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/impl/LocalFileStorageServiceImpl.java +++ b/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/impl/LocalFileStorageServiceImpl.java @@ -272,6 +272,27 @@ public class LocalFileStorageServiceImpl implements FileStorageService { } } + /** + * 按句柄固化的本地根目录精确打开普通文件。 + * + * @param handle 本地可恢复写句柄 + * @return 文件输入流,由调用方关闭 + * @throws IOException 文件不存在、不可读或路径不安全时抛出 + */ + @Override + public InputStream readRecoverable(FileStorageWriteHandle handle) throws IOException { + requireLocalHandle(handle); + Path target = resolveControlledTarget(handle, false); + if (!Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("本地可恢复文件不存在: " + target); + } + if (Files.isSymbolicLink(target) + || !Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("本地可恢复读取目标不是普通文件: " + target); + } + return Files.newInputStream(target); + } + /** * 精确且幂等地删除句柄对应的最终文件与确定性暂存文件,并确认两者均不存在。 * diff --git a/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/impl/XFIleStorageServiceImpl.java b/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/impl/XFIleStorageServiceImpl.java index 66ed08a5..06ff94da 100644 --- a/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/impl/XFIleStorageServiceImpl.java +++ b/easyflow-commons/easyflow-common-file-storage/src/main/java/tech/easyflow/common/filestorage/impl/XFIleStorageServiceImpl.java @@ -1,7 +1,9 @@ package tech.easyflow.common.filestorage.impl; +import io.minio.GetObjectArgs; import org.dromara.x.file.storage.core.FileInfo; import org.dromara.x.file.storage.core.platform.FileStorage; +import org.dromara.x.file.storage.core.platform.MinioFileStorage; import org.dromara.x.file.storage.core.recorder.FileRecorder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -198,6 +200,44 @@ public class XFIleStorageServiceImpl implements FileStorageService { } } + /** + * 按句柄固定的平台和对象键读取物理文件。 + * + *

MinIO 使用已配置客户端直接读取,兼容私有桶和内网端点;其他平台仅在能够 + * 精确推导公开 URL 时使用现有读取能力。

+ * + * @param handle x-file-storage 可恢复写句柄 + * @return 文件输入流,由调用方关闭 + * @throws IOException 平台不支持精确读取或对象读取失败时抛出 + */ + @Override + public InputStream readRecoverable(FileStorageWriteHandle handle) throws IOException { + FileStorage storage = requireStorage(handle); + requirePersistedBasePathSupport(storage, handle); + FileInfo fileInfo = toFileInfo(handle); + if (storage instanceof MinioFileStorage minioStorage) { + try { + return minioStorage.getClient().getObject( + GetObjectArgs.builder() + .bucket(minioStorage.getBucketName()) + .object(storage.getFileKey(fileInfo)) + .build()); + } catch (Exception exception) { + throw new IOException("读取 MinIO 可恢复文件失败", exception); + } + } + String url = deriveUrlBestEffort(storage, fileInfo); + if (!StringUtils.hasText(url)) { + throw new IOException("当前 x-file-storage 平台不支持可恢复文件读取: " + + storage.getClass().getName()); + } + try { + return readStream(url); + } catch (RuntimeException exception) { + throw new IOException("读取 x-file-storage 可恢复文件失败", exception); + } + } + /** * 直接调用句柄指定平台的物理删除与存在检查,绕过依赖 URL 记录的聚合删除路径。 * diff --git a/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/FileStorageManagerTest.java b/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/FileStorageManagerTest.java index 9ff4f662..3671a9dc 100644 --- a/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/FileStorageManagerTest.java +++ b/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/FileStorageManagerTest.java @@ -22,7 +22,7 @@ public class FileStorageManagerTest { * 验证 prepare 使用当前后端,而后续操作在默认后端切换后仍按句柄后端路由。 */ @Test - public void recoverableOperationsRouteByPreparedBackendAfterSwitch() { + public void recoverableOperationsRouteByPreparedBackendAfterSwitch() throws IOException { RecordingStorage local = new RecordingStorage("local"); RecordingStorage xFile = new RecordingStorage("xFileStorage"); AtomicReference current = new AtomicReference<>("local"); @@ -32,16 +32,20 @@ public class FileStorageManagerTest { FileStorageWriteHandle handle = manager.prepareRecoverableWrite("skill-content/ab", "content.bin"); current.set("xFileStorage"); FileStorageWriteResult result = manager.saveRecoverable(null, handle); + InputStream inputStream = manager.readRecoverable(handle); manager.deleteRecoverable(handle); boolean exists = manager.existsRecoverable(handle); assertEquals("local", handle.getBackend()); assertSame(local.result, result); + assertSame(local.recoverableInput, inputStream); assertEquals(1, local.prepareCalls); assertEquals(1, local.saveCalls); + assertEquals(1, local.readCalls); assertEquals(1, local.deleteCalls); assertEquals(1, local.existsCalls); - assertEquals(0, xFile.prepareCalls + xFile.saveCalls + xFile.deleteCalls + xFile.existsCalls); + assertEquals(0, xFile.prepareCalls + xFile.saveCalls + xFile.readCalls + + xFile.deleteCalls + xFile.existsCalls); assertFalse(exists); } @@ -53,10 +57,14 @@ public class FileStorageManagerTest { private final String backend; /** 固定结果。 */ private final FileStorageWriteResult result; + /** 固定可恢复读取流。 */ + private final InputStream recoverableInput = InputStream.nullInputStream(); /** prepare 调用次数。 */ private int prepareCalls; /** save 调用次数。 */ private int saveCalls; + /** read 调用次数。 */ + private int readCalls; /** delete 调用次数。 */ private int deleteCalls; /** exists 调用次数。 */ @@ -99,6 +107,13 @@ public class FileStorageManagerTest { return result; } + /** {@inheritDoc} */ + @Override + public InputStream readRecoverable(FileStorageWriteHandle handle) { + readCalls++; + return recoverableInput; + } + /** {@inheritDoc} */ @Override public void deleteRecoverable(FileStorageWriteHandle handle) { diff --git a/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/impl/LocalFileStorageServiceImplTest.java b/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/impl/LocalFileStorageServiceImplTest.java index f909c9ec..92fbbd4f 100644 --- a/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/impl/LocalFileStorageServiceImplTest.java +++ b/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/impl/LocalFileStorageServiceImplTest.java @@ -51,6 +51,9 @@ public class LocalFileStorageServiceImplTest { assertEquals(handle, FileStorageWriteHandle.decodeLocator(result.getLocator())); assertTrue(service.existsRecoverable(handle)); assertArrayEquals(bytes, Files.readAllBytes(target)); + try (InputStream inputStream = service.readRecoverable(handle)) { + assertArrayEquals(bytes, inputStream.readAllBytes()); + } assertFalse(Files.exists(changedRoot.toPath().resolve("skill-content/ab/content.bin"))); service.deleteRecoverable(handle); diff --git a/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/impl/XFIleStorageServiceImplTest.java b/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/impl/XFIleStorageServiceImplTest.java index 479055d4..c9a271d8 100644 --- a/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/impl/XFIleStorageServiceImplTest.java +++ b/easyflow-commons/easyflow-common-file-storage/src/test/java/tech/easyflow/common/filestorage/impl/XFIleStorageServiceImplTest.java @@ -1,9 +1,15 @@ package tech.easyflow.common.filestorage.impl; +import io.minio.GetObjectArgs; +import io.minio.GetObjectResponse; +import io.minio.MinioClient; +import okhttp3.Headers; import org.junit.Test; import org.dromara.x.file.storage.core.FileInfo; import org.dromara.x.file.storage.core.UploadPretreatment; import org.dromara.x.file.storage.core.platform.FileStorage; +import org.dromara.x.file.storage.core.platform.FileStorageClientFactory; +import org.dromara.x.file.storage.core.platform.MinioFileStorage; import org.dromara.x.file.storage.core.recorder.FileRecorder; import org.springframework.web.multipart.MultipartFile; import tech.easyflow.common.filestorage.FileStorageWriteHandle; @@ -17,6 +23,7 @@ import java.lang.reflect.Field; import java.nio.file.Files; import java.util.function.Consumer; +import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; @@ -102,6 +109,42 @@ public class XFIleStorageServiceImplTest { assertTrue(platform.exists); } + /** + * 验证 MinIO 可恢复读取使用已配置客户端和句柄中的精确对象键,不请求公开 URL。 + * + * @throws Exception 测试替身配置或流读取失败 + */ + @Test + public void recoverableReadUsesMinioClientAndExactObjectKey() throws Exception { + byte[] content = "managed-content".getBytes(java.nio.charset.StandardCharsets.UTF_8); + RecordingMinioClient client = new RecordingMinioClient(content); + MinioFileStorage platform = new MinioFileStorage(); + platform.setPlatform("minio-main"); + platform.setBucketName("easyflow"); + platform.setBasePath("easyflow/"); + platform.setDomain("http://127.0.0.1:39000/"); + platform.setClientFactory(new FixedMinioClientFactory(client)); + XFIleStorageServiceImpl service = createService( + new RecoverableStorageService(platform)); + FileStorageWriteHandle handle = new FileStorageWriteHandle( + "xFileStorage", + "minio-main", + "easyflow/", + "workflow-api-upload/request", + "content.bin"); + + byte[] actual; + try (InputStream inputStream = service.readRecoverable(handle)) { + actual = inputStream.readAllBytes(); + } + + assertArrayEquals(content, actual); + assertEquals("easyflow", client.lastArgs.bucket()); + assertEquals( + "easyflow/workflow-api-upload/request/content.bin", + client.lastArgs.object()); + } + /** * 验证 recorder 完全缺失目标记录时,精确删除仍直接作用于物理平台并成功。 * @@ -401,6 +444,73 @@ public class XFIleStorageServiceImplTest { } } + /** + * 始终返回同一 MinIO 客户端的测试工厂。 + */ + private static final class FixedMinioClientFactory + implements FileStorageClientFactory { + + /** 固定客户端。 */ + private final MinioClient client; + + /** + * 创建固定客户端工厂。 + * + * @param client MinIO 客户端 + */ + private FixedMinioClientFactory(MinioClient client) { + this.client = client; + } + + /** {@inheritDoc} */ + @Override + public String getPlatform() { + return "minio-main"; + } + + /** {@inheritDoc} */ + @Override + public MinioClient getClient() { + return client; + } + } + + /** + * 记录精确对象参数并返回内存内容的 MinIO 客户端替身。 + */ + private static final class RecordingMinioClient extends MinioClient { + + /** 固定返回内容。 */ + private final byte[] content; + /** 最后一次读取参数。 */ + private GetObjectArgs lastArgs; + + /** + * 创建内存 MinIO 客户端替身。 + * + * @param content 固定返回内容 + */ + private RecordingMinioClient(byte[] content) { + super(MinioClient.builder() + .endpoint("http://127.0.0.1:39000") + .credentials("test-access-key", "test-secret-key") + .build()); + this.content = content.clone(); + } + + /** {@inheritDoc} */ + @Override + public GetObjectResponse getObject(GetObjectArgs args) { + this.lastArgs = args; + return new GetObjectResponse( + new Headers.Builder().build(), + args.bucket(), + null, + args.object(), + new ByteArrayInputStream(content)); + } + } + /** * 不访问真实网络、仅捕获上传参数的预处理器。 */ diff --git a/easyflow-commons/easyflow-common-web/src/main/java/tech/easyflow/common/web/error/GlobalErrorResolver.java b/easyflow-commons/easyflow-common-web/src/main/java/tech/easyflow/common/web/error/GlobalErrorResolver.java index 099d10d4..b7b718de 100644 --- a/easyflow-commons/easyflow-common-web/src/main/java/tech/easyflow/common/web/error/GlobalErrorResolver.java +++ b/easyflow-commons/easyflow-common-web/src/main/java/tech/easyflow/common/web/error/GlobalErrorResolver.java @@ -45,7 +45,20 @@ public class GlobalErrorResolver implements HandlerExceptionResolver { @Override public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { Result error; - if (ex instanceof MissingServletRequestParameterException) { + WebErrorMapping profiledError = resolveProfiledError(request, ex); + if (profiledError != null) { + response.setStatus(profiledError.httpStatus()); + if (profiledError.httpStatus() >= 500) { + LOG.error( + "请求级错误契约处理到服务端异常,method={}, uri={}, requestId={}, errorCode={}", + request.getMethod(), + request.getRequestURI(), + RequestIdContext.get(request), + profiledError.errorCode(), + ex); + } + error = buildProfiledError(profiledError); + } else if (ex instanceof MissingServletRequestParameterException) { response.setStatus(HttpStatus.BAD_REQUEST.value()); error = Result.fail(400, ((MissingServletRequestParameterException) ex).getParameterName() + " 不能为空"); } else if (ex instanceof NotLoginException notLoginException) { @@ -98,6 +111,48 @@ public class GlobalErrorResolver implements HandlerExceptionResolver { .addAllObjects(object); } + /** + * 调用请求进入 MVC 前注册的错误契约。 + * + * @param request 当前请求 + * @param exception 原始异常 + * @return 受控错误映射;未注册或不处理时返回 {@code null} + */ + private WebErrorMapping resolveProfiledError( + HttpServletRequest request, + Exception exception) { + Object attribute = request.getAttribute( + RequestErrorProfile.ATTRIBUTE_NAME); + if (!(attribute instanceof RequestErrorProfile profile)) { + return null; + } + try { + return profile.map(request, exception); + } catch (RuntimeException mappingError) { + LOG.error( + "请求级错误契约映射失败,method={}, uri={}, requestId={}", + request.getMethod(), + request.getRequestURI(), + RequestIdContext.get(request), + mappingError); + return null; + } + } + + /** + * 将受控错误映射转换为统一响应对象。 + * + * @param mapping 错误映射 + * @return 统一错误响应 + */ + private Result buildProfiledError(WebErrorMapping mapping) { + Result result = Result.fail( + mapping.message(), + mapping.data()); + result.setErrorCode(mapping.errorCode()); + return result; + } + /** * 读取注解声明的 HTTP 状态。 * diff --git a/easyflow-commons/easyflow-common-web/src/main/java/tech/easyflow/common/web/error/RequestErrorProfile.java b/easyflow-commons/easyflow-common-web/src/main/java/tech/easyflow/common/web/error/RequestErrorProfile.java new file mode 100644 index 00000000..50bd3cca --- /dev/null +++ b/easyflow-commons/easyflow-common-web/src/main/java/tech/easyflow/common/web/error/RequestErrorProfile.java @@ -0,0 +1,25 @@ +package tech.easyflow.common.web.error; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * 为单个请求提供可选的异常到公共错误契约映射。 + */ +@FunctionalInterface +public interface RequestErrorProfile { + + /** Servlet 请求属性名。 */ + String ATTRIBUTE_NAME = + RequestErrorProfile.class.getName() + ".profile"; + + /** + * 将异常转换为受控错误响应。 + * + * @param request 当前请求 + * @param exception 原始异常 + * @return 错误映射;不处理该异常时返回 {@code null} + */ + WebErrorMapping map( + HttpServletRequest request, + Exception exception); +} diff --git a/easyflow-commons/easyflow-common-web/src/main/java/tech/easyflow/common/web/error/RequestIdContext.java b/easyflow-commons/easyflow-common-web/src/main/java/tech/easyflow/common/web/error/RequestIdContext.java new file mode 100644 index 00000000..b872e72a --- /dev/null +++ b/easyflow-commons/easyflow-common-web/src/main/java/tech/easyflow/common/web/error/RequestIdContext.java @@ -0,0 +1,36 @@ +package tech.easyflow.common.web.error; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * Web 请求关联标识的统一常量与读取入口。 + */ +public final class RequestIdContext { + + /** 对外请求关联标识响应头。 */ + public static final String HEADER_NAME = "X-Request-Id"; + /** Servlet 请求属性名。 */ + public static final String ATTRIBUTE_NAME = + RequestIdContext.class.getName() + ".requestId"; + /** 日志 MDC 字段名。 */ + public static final String MDC_KEY = "requestId"; + + private RequestIdContext() { + } + + /** + * 从 Servlet 请求中读取已初始化的请求关联标识。 + * + * @param request 当前请求 + * @return 请求关联标识;尚未初始化时返回 {@code null} + */ + public static String get(HttpServletRequest request) { + if (request == null) { + return null; + } + Object value = request.getAttribute(ATTRIBUTE_NAME); + return value instanceof String requestId && !requestId.isBlank() + ? requestId + : null; + } +} diff --git a/easyflow-commons/easyflow-common-web/src/main/java/tech/easyflow/common/web/error/WebErrorMapping.java b/easyflow-commons/easyflow-common-web/src/main/java/tech/easyflow/common/web/error/WebErrorMapping.java new file mode 100644 index 00000000..511ceccd --- /dev/null +++ b/easyflow-commons/easyflow-common-web/src/main/java/tech/easyflow/common/web/error/WebErrorMapping.java @@ -0,0 +1,16 @@ +package tech.easyflow.common.web.error; + +/** + * Web 异常的受控 HTTP 响应映射。 + * + * @param httpStatus HTTP 状态码 + * @param errorCode 稳定业务错误码 + * @param message 可安全展示的错误消息 + * @param data 可选的受控错误详情 + */ +public record WebErrorMapping( + int httpStatus, + int errorCode, + String message, + Object data) { +} diff --git a/easyflow-commons/easyflow-common-web/src/main/java/tech/easyflow/common/web/multipart/MultipartFileMetadataNormalizer.java b/easyflow-commons/easyflow-common-web/src/main/java/tech/easyflow/common/web/multipart/MultipartFileMetadataNormalizer.java new file mode 100644 index 00000000..6b07d8ec --- /dev/null +++ b/easyflow-commons/easyflow-common-web/src/main/java/tech/easyflow/common/web/multipart/MultipartFileMetadataNormalizer.java @@ -0,0 +1,96 @@ +package tech.easyflow.common.web.multipart; + +import org.springframework.http.InvalidMediaTypeException; +import org.springframework.http.MediaType; +import org.springframework.http.MediaTypeFactory; +import org.springframework.util.StringUtils; + +/** + * Multipart 文件名与内容类型的安全归一化工具。 + */ +public final class MultipartFileMetadataNormalizer { + + private static final int MAX_FILENAME_LENGTH = 255; + + private MultipartFileMetadataNormalizer() { + } + + /** + * 归一化文件 Part 的内容类型。 + * + *

合法且具体的客户端值优先;空值、占位值或非法值按文件扩展名推断, + * 无法推断时使用 {@code application/octet-stream}。

+ * + * @param originalFilename 原始文件名 + * @param declaredContentType 客户端声明的内容类型 + * @return 可安全用于存储请求的标准内容类型 + */ + public static String normalizeContentType( + String originalFilename, + String declaredContentType) { + MediaType declared = parseConcrete(declaredContentType); + if (declared != null) { + return declared.toString(); + } + return MediaTypeFactory + .getMediaType(sanitizeFilename(originalFilename)) + .filter(MediaType::isConcrete) + .orElse(MediaType.APPLICATION_OCTET_STREAM) + .toString(); + } + + /** + * 移除客户端目录片段、控制字符和超长内容。 + * + * @param originalFilename 原始文件名 + * @return 安全基础文件名 + */ + public static String sanitizeFilename(String originalFilename) { + String cleaned = StringUtils.cleanPath( + originalFilename == null ? "" : originalFilename); + String filename = StringUtils.getFilename(cleaned); + if (filename == null) { + filename = ""; + } + filename = filename.replaceAll("[\\p{Cntrl}]", "").trim(); + if (!StringUtils.hasText(filename) + || ".".equals(filename) + || "..".equals(filename)) { + return "file"; + } + if (filename.length() <= MAX_FILENAME_LENGTH) { + return filename; + } + int extensionStart = filename.lastIndexOf('.'); + if (extensionStart > 0) { + String extension = filename.substring(extensionStart); + if (extension.length() <= 17) { + return filename.substring( + 0, + MAX_FILENAME_LENGTH - extension.length()) + + extension; + } + } + return filename.substring(0, MAX_FILENAME_LENGTH); + } + + /** + * 解析合法且具体的媒体类型。 + * + * @param contentType 原始内容类型 + * @return 解析结果;值不可用时返回 {@code null} + */ + private static MediaType parseConcrete(String contentType) { + if (!StringUtils.hasText(contentType) + || "other".equalsIgnoreCase(contentType.trim())) { + return null; + } + try { + MediaType mediaType = MediaType.parseMediaType( + contentType.trim()); + return mediaType.isConcrete() ? mediaType : null; + } catch (InvalidMediaTypeException exception) { + return null; + } + } +} diff --git a/easyflow-commons/easyflow-common-web/src/test/java/tech/easyflow/common/web/multipart/MultipartFileMetadataNormalizerTest.java b/easyflow-commons/easyflow-common-web/src/test/java/tech/easyflow/common/web/multipart/MultipartFileMetadataNormalizerTest.java new file mode 100644 index 00000000..9f43f808 --- /dev/null +++ b/easyflow-commons/easyflow-common-web/src/test/java/tech/easyflow/common/web/multipart/MultipartFileMetadataNormalizerTest.java @@ -0,0 +1,89 @@ +package tech.easyflow.common.web.multipart; + +import org.junit.Assert; +import org.junit.Test; + +/** + * {@link MultipartFileMetadataNormalizer} 回归测试。 + */ +public class MultipartFileMetadataNormalizerTest { + + /** + * 验证合法媒体类型会被保留。 + */ + @Test + public void shouldKeepValidDeclaredContentType() { + Assert.assertEquals( + "application/pdf", + MultipartFileMetadataNormalizer.normalizeContentType( + "report.pdf", + "application/pdf")); + } + + /** + * 验证客户端占位值会按扩展名推断。 + */ + @Test + public void shouldInferContentTypeWhenClientSendsOther() { + Assert.assertEquals( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + MultipartFileMetadataNormalizer.normalizeContentType( + "report.docx", + "Other")); + } + + /** + * 验证空媒体类型也会按扩展名推断。 + */ + @Test + public void shouldInferContentTypeWhenClientOmitsIt() { + Assert.assertEquals( + "application/pdf", + MultipartFileMetadataNormalizer.normalizeContentType( + "report.pdf", + " ")); + } + + /** + * 验证非法媒体类型且无法推断时使用二进制兜底。 + */ + @Test + public void shouldFallbackToOctetStreamForUnknownFile() { + Assert.assertEquals( + "application/octet-stream", + MultipartFileMetadataNormalizer.normalizeContentType( + "payload.unknown-extension", + "invalid content type")); + } + + /** + * 验证文件名不会携带客户端目录片段。 + */ + @Test + public void shouldRemoveClientPathFromFilename() { + Assert.assertEquals( + "report.pdf", + MultipartFileMetadataNormalizer.sanitizeFilename( + "C:\\fakepath\\report.pdf")); + } + + /** + * 验证超长文件名截断后仍保留可用于 MIME 推断的扩展名。 + */ + @Test + public void shouldKeepExtensionWhenSanitizingLongFilename() { + String filename = "a".repeat(300) + ".pdf"; + + String sanitized = + MultipartFileMetadataNormalizer.sanitizeFilename( + filename); + + Assert.assertEquals(255, sanitized.length()); + Assert.assertTrue(sanitized.endsWith(".pdf")); + Assert.assertEquals( + "application/pdf", + MultipartFileMetadataNormalizer.normalizeContentType( + sanitized, + "Other")); + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/config/ChainExecutorConfig.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/config/ChainExecutorConfig.java index 6f68aef5..2d4d2f3e 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/config/ChainExecutorConfig.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/config/ChainExecutorConfig.java @@ -1,5 +1,6 @@ package tech.easyflow.ai.easyagentsflow.config; +import com.easyagents.flow.core.chain.event.ChainStatusChangeEvent; import com.easyagents.flow.core.chain.repository.ChainDefinitionRepository; import com.easyagents.flow.core.chain.repository.ChainDefinitionSnapshotRepository; import com.easyagents.flow.core.chain.repository.ChainStateRepository; @@ -13,6 +14,7 @@ import org.springframework.context.annotation.Configuration; import tech.easyflow.ai.easyagentsflow.listener.ChainErrorListenerForSave; import tech.easyflow.ai.easyagentsflow.listener.ChainEventListenerForSave; import tech.easyflow.ai.easyagentsflow.listener.NodeErrorListenerForSave; +import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadCleanupListener; import javax.annotation.Resource; import java.time.Duration; @@ -36,6 +38,8 @@ public class ChainExecutorConfig { @Resource private ChainEventListenerForSave chainEventListenerForSave; @Resource + private WorkflowApiUploadCleanupListener workflowApiUploadCleanupListener; + @Resource private WorkflowExecutionBudgetProperties workflowExecutionBudgetProperties; @Resource private WorkflowRuntimeProperties workflowRuntimeProperties; @@ -84,6 +88,9 @@ public class ChainExecutorConfig { */ private void saveStepsListeners(ChainExecutor chainExecutor) { chainExecutor.addEventListener(chainEventListenerForSave); + chainExecutor.addEventListener( + ChainStatusChangeEvent.class, + workflowApiUploadCleanupListener); chainExecutor.addErrorListener(new ChainErrorListenerForSave()); chainExecutor.addNodeErrorListener(new NodeErrorListenerForSave()); } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/TinyFlowService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/TinyFlowService.java index d4179aba..f32a14ef 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/TinyFlowService.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/TinyFlowService.java @@ -51,8 +51,17 @@ public class TinyFlowService { } ChainInfo res = getChainInfo(executeId, chainState); - if (nodes != null) { + if (nodes != null && !nodes.isEmpty()) { + Map resolvedNodeNames = + chainExecutor.getInstanceNodeNames(chainState); + Map nodeNames = resolvedNodeNames == null + ? Map.of() + : resolvedNodeNames; for (NodeInfo node : nodes) { + if (node != null + && StringUtil.noText(node.getNodeName())) { + node.setNodeName(nodeNames.get(node.getNodeId())); + } processNodeState(executeId, node, chainState, nodeStateRepository); res.getNodes().put(node.getNodeId(), node); } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/WorkflowRunningParameterResolver.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/WorkflowRunningParameterResolver.java index 9d939fb6..276ed97c 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/WorkflowRunningParameterResolver.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/service/WorkflowRunningParameterResolver.java @@ -12,6 +12,9 @@ import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.ai.entity.Workflow; import javax.annotation.Resource; +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collection; import java.util.LinkedHashMap; @@ -88,6 +91,53 @@ public class WorkflowRunningParameterResolver { } } + /** + * 解析开始节点中可通过 multipart 上传的文件参数名。 + * + * @param content 工作流内容 + * @return 保持开始节点定义顺序的文件参数名集合 + */ + public Set resolveFileParameterNames(String content) { + List startParameters = resolveStartParameters(content); + Set names = new LinkedHashSet<>(); + if (startParameters == null || startParameters.isEmpty()) { + return names; + } + for (Parameter parameter : startParameters) { + String name = trimToNull( + parameter == null ? null : parameter.getName()); + if (StringUtils.hasText(name) && isFileParameter(parameter)) { + names.add(name); + } + } + return names; + } + + /** + * 解析开始节点中必须提供值的文件参数名。 + * + * @param content 工作流内容 + * @return 保持开始节点定义顺序的必填文件参数名集合 + */ + public Set resolveRequiredFileParameterNames( + String content) { + List startParameters = resolveStartParameters(content); + Set names = new LinkedHashSet<>(); + if (startParameters == null || startParameters.isEmpty()) { + return names; + } + for (Parameter parameter : startParameters) { + String name = trimToNull( + parameter == null ? null : parameter.getName()); + if (StringUtils.hasText(name) + && isFileParameter(parameter) + && parameter.isRequired()) { + names.add(name); + } + } + return names; + } + /** * 归一化工作流运行时变量,确保文件参数统一为文件对象数组。 * @@ -508,9 +558,9 @@ public class WorkflowRunningParameterResolver { Set seenFilePaths = new LinkedHashSet<>(); long totalSize = 0L; for (Object candidate : candidates) { - if (!(candidate instanceof Map fileMap)) { - throw new BusinessException("文件参数 " + parameterName + " 的输入格式不正确,必须为文件对象或文件对象数组"); - } + Map fileMap = normalizeFileCandidate( + candidate, + parameterName); String fileName = trimObjectToNull(fileMap.get("fileName")); String filePath = trimObjectToNull(fileMap.get("filePath")); if (!StringUtils.hasText(fileName)) { @@ -541,6 +591,94 @@ public class WorkflowRunningParameterResolver { return normalized; } + /** + * 将文件对象或远程 URL 字符串转换为统一文件描述。 + * + * @param candidate 原始文件值 + * @param parameterName 工作流文件参数名 + * @return 可继续执行通用校验的文件描述 + * @throws BusinessException URL 无效或无法识别文件名时抛出 + */ + private Map normalizeFileCandidate( + Object candidate, + String parameterName) { + if (candidate instanceof Map fileMap) { + return fileMap; + } + if (!(candidate instanceof String stringValue)) { + throw new BusinessException( + "文件参数 " + parameterName + + " 的输入格式不正确,必须为文件 URL、文件对象或对应数组"); + } + String fileUrl = trimToNull(stringValue); + if (!isHttpUrl(fileUrl)) { + throw new BusinessException( + "文件参数 " + parameterName + + " 仅支持 HTTP/HTTPS 文件 URL"); + } + + Map normalized = new LinkedHashMap<>(); + normalized.put("fileName", resolveRemoteFileName( + fileUrl, + parameterName)); + normalized.put("filePath", fileUrl); + return normalized; + } + + /** + * 从远程 URL 路径中提取并解码文件名。 + * + * @param fileUrl 远程文件 URL + * @param parameterName 工作流文件参数名 + * @return 带扩展名的文件名 + * @throws BusinessException URL 无效或路径中没有可识别文件名时抛出 + */ + private String resolveRemoteFileName( + String fileUrl, + String parameterName) { + try { + URI uri = URI.create(fileUrl); + String rawPath = uri.getRawPath(); + int lastSlash = rawPath == null ? -1 : rawPath.lastIndexOf('/'); + String rawFileName = lastSlash < 0 + ? rawPath + : rawPath.substring(lastSlash + 1); + String fileName = StringUtils.hasText(rawFileName) + ? URLDecoder.decode( + rawFileName.replace("+", "%2B"), + StandardCharsets.UTF_8) + : null; + int lastDot = fileName == null ? -1 : fileName.lastIndexOf('.'); + String extension = lastDot < 0 + ? null + : fileName.substring(lastDot + 1); + if (!StringUtils.hasText(uri.getRawAuthority()) + || !StringUtils.hasText(fileName) + || lastDot <= 0 + || !StringUtils.hasText(extension) + || !extension.matches("[A-Za-z0-9]{1,16}") + || fileName.indexOf('/') >= 0 + || fileName.indexOf('\\') >= 0) { + throw invalidRemoteFileName(parameterName); + } + return fileName; + } catch (IllegalArgumentException exception) { + throw invalidRemoteFileName(parameterName); + } + } + + /** + * 构建无法从 URL 识别文件名时的统一业务异常。 + * + * @param parameterName 工作流文件参数名 + * @return 统一业务异常 + */ + private BusinessException invalidRemoteFileName(String parameterName) { + return new BusinessException( + "文件参数 " + parameterName + + " 的 URL 路径无法识别带扩展名的文件名,请改用包含 fileName 和 filePath 的文件对象"); + } + private void collectFileValues(Object value, List result) { if (value == null) { return; diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiMultipartFileNormalizer.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiMultipartFileNormalizer.java new file mode 100644 index 00000000..16fa4d91 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiMultipartFileNormalizer.java @@ -0,0 +1,135 @@ +package tech.easyflow.ai.easyagentsflow.upload; + +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; +import tech.easyflow.common.web.multipart.MultipartFileMetadataNormalizer; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.util.Objects; + +/** + * 为工作流 Public API 创建文件名和内容类型一致的 Multipart 文件视图。 + */ +@Component +public class WorkflowApiMultipartFileNormalizer { + + /** + * 归一化单个文件 Part,不读取或复制文件内容。 + * + * @param file 原始文件 Part + * @return 归一化文件视图;输入为空时返回 {@code null} + */ + public MultipartFile normalize(MultipartFile file) { + if (file == null) { + return null; + } + String filename = MultipartFileMetadataNormalizer.sanitizeFilename( + file.getOriginalFilename()); + String contentType = + MultipartFileMetadataNormalizer.normalizeContentType( + filename, + file.getContentType()); + if (Objects.equals(filename, file.getOriginalFilename()) + && Objects.equals(contentType, file.getContentType())) { + return file; + } + return new NormalizedMultipartFile( + file, + filename, + contentType); + } + + /** + * 仅覆盖安全元数据并委托文件内容访问的 Multipart 视图。 + */ + private static final class NormalizedMultipartFile + implements MultipartFile { + + private final MultipartFile delegate; + private final String originalFilename; + private final String contentType; + + /** + * 创建归一化文件视图。 + * + * @param delegate 原始文件 + * @param originalFilename 安全文件名 + * @param contentType 标准内容类型 + */ + private NormalizedMultipartFile( + MultipartFile delegate, + String originalFilename, + String contentType) { + this.delegate = delegate; + this.originalFilename = originalFilename; + this.contentType = contentType; + } + + /** + * {@inheritDoc} + */ + @Override + public String getName() { + return delegate.getName(); + } + + /** + * {@inheritDoc} + */ + @Override + public String getOriginalFilename() { + return originalFilename; + } + + /** + * {@inheritDoc} + */ + @Override + public String getContentType() { + return contentType; + } + + /** + * {@inheritDoc} + */ + @Override + public boolean isEmpty() { + return delegate.isEmpty(); + } + + /** + * {@inheritDoc} + */ + @Override + public long getSize() { + return delegate.getSize(); + } + + /** + * {@inheritDoc} + */ + @Override + public byte[] getBytes() throws IOException { + return delegate.getBytes(); + } + + /** + * {@inheritDoc} + */ + @Override + public InputStream getInputStream() throws IOException { + return delegate.getInputStream(); + } + + /** + * {@inheritDoc} + */ + @Override + public void transferTo(File destination) + throws IOException, IllegalStateException { + delegate.transferTo(destination); + } + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiPreparedUpload.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiPreparedUpload.java new file mode 100644 index 00000000..d010c58d --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiPreparedUpload.java @@ -0,0 +1,43 @@ +package tech.easyflow.ai.easyagentsflow.upload; + +import java.util.Map; + +/** + * Public Workflow API multipart 文件准备结果。 + */ +public class WorkflowApiPreparedUpload { + + private final String requestId; + private final Map variables; + + /** + * 创建文件准备结果。 + * + * @param requestId 临时上传请求 ID + * @param variables 已注入文件描述的工作流变量 + */ + public WorkflowApiPreparedUpload( + String requestId, + Map variables) { + this.requestId = requestId; + this.variables = variables; + } + + /** + * 获取临时上传请求 ID。 + * + * @return 临时上传请求 ID + */ + public String getRequestId() { + return requestId; + } + + /** + * 获取已注入文件描述的工作流变量。 + * + * @return 工作流变量 + */ + public Map getVariables() { + return variables; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiStoredFile.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiStoredFile.java new file mode 100644 index 00000000..a0634034 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiStoredFile.java @@ -0,0 +1,41 @@ +package tech.easyflow.ai.easyagentsflow.upload; + +import org.springframework.util.StringUtils; + +import java.io.Serializable; + +/** + * Public Workflow API 已准备或已写入的临时文件。 + * + * @param filePath 工作流运行时读取 URL;物理写入完成前为空 + * @param storageLocator 可恢复文件存储定位符 + */ +public record WorkflowApiStoredFile( + String filePath, + String storageLocator) implements Serializable { + + /** + * 创建临时文件记录。 + * + * @throws IllegalArgumentException 恢复定位符为空时抛出 + */ + public WorkflowApiStoredFile { + if (!StringUtils.hasText(storageLocator)) { + throw new IllegalArgumentException( + "工作流临时文件恢复定位符不能为空"); + } + } + + /** + * 返回写入完成后的临时文件记录。 + * + * @param resolvedFilePath 文件读取 URL + * @return 包含原恢复定位符的新记录 + */ + public WorkflowApiStoredFile withFilePath( + String resolvedFilePath) { + return new WorkflowApiStoredFile( + resolvedFilePath, + storageLocator); + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiUploadCleanupListener.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiUploadCleanupListener.java new file mode 100644 index 00000000..e8a834b7 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiUploadCleanupListener.java @@ -0,0 +1,42 @@ +package tech.easyflow.ai.easyagentsflow.upload; + +import com.easyagents.flow.core.chain.Chain; +import com.easyagents.flow.core.chain.Event; +import com.easyagents.flow.core.chain.event.ChainStatusChangeEvent; +import com.easyagents.flow.core.chain.listener.ChainEventListener; +import org.springframework.stereotype.Component; + +/** + * 工作流终态事件到临时上传清理队列的桥接监听器。 + */ +@Component +public class WorkflowApiUploadCleanupListener implements ChainEventListener { + + private final WorkflowApiUploadLifecycleService lifecycleService; + + /** + * 创建临时上传清理监听器。 + * + * @param lifecycleService 临时上传生命周期服务 + */ + public WorkflowApiUploadCleanupListener( + WorkflowApiUploadLifecycleService lifecycleService) { + this.lifecycleService = lifecycleService; + } + + /** + * 在工作流进入终态后触发异步清理登记。 + * + * @param event 工作流事件 + * @param chain 工作流实例 + */ + @Override + public void onEvent(Event event, Chain chain) { + if (event instanceof ChainStatusChangeEvent statusChangeEvent + && statusChangeEvent.getStatus() != null + && statusChangeEvent.getStatus().isTerminal()) { + lifecycleService.markExecutionTerminal( + chain.getStateInstanceId()); + } + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiUploadCleanupScheduler.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiUploadCleanupScheduler.java new file mode 100644 index 00000000..8253576a --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiUploadCleanupScheduler.java @@ -0,0 +1,67 @@ +package tech.easyflow.ai.easyagentsflow.upload; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +/** + * Public Workflow API 临时上传兜底清理任务。 + */ +@Component +public class WorkflowApiUploadCleanupScheduler { + + private static final Logger LOG = LoggerFactory.getLogger( + WorkflowApiUploadCleanupScheduler.class); + private static final int CLEANUP_BATCH_SIZE = 100; + private static final int CLEANUP_MAX_RECORDS_PER_RUN = 2_000; + private static final Duration CLEANUP_TIME_BUDGET = + Duration.ofSeconds(30); + + private final WorkflowApiUploadLifecycleService lifecycleService; + + /** + * 创建临时上传清理任务。 + * + * @param lifecycleService 临时上传生命周期服务 + */ + public WorkflowApiUploadCleanupScheduler( + WorkflowApiUploadLifecycleService lifecycleService) { + this.lifecycleService = lifecycleService; + } + + /** + * 定期清理终态、启动失败或状态已丢失的临时上传。 + */ + @Scheduled( + fixedDelayString = + "${easyflow.workflow.api-upload.cleanup-interval:1m}") + public void cleanup() { + try { + int processed = 0; + long deadline = System.nanoTime() + + CLEANUP_TIME_BUDGET.toNanos(); + while (processed < CLEANUP_MAX_RECORDS_PER_RUN + && System.nanoTime() < deadline) { + int batchSize = Math.min( + CLEANUP_BATCH_SIZE, + CLEANUP_MAX_RECORDS_PER_RUN - processed); + int batchProcessed = + lifecycleService.cleanupExpired(batchSize); + processed += batchProcessed; + if (batchProcessed < batchSize) { + break; + } + } + if (processed > 0) { + LOG.info( + "已处理 {} 条工作流 API 临时上传清理记录", + processed); + } + } catch (RuntimeException error) { + LOG.error("工作流 API 临时上传定时清理失败", error); + } + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiUploadLifecycleService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiUploadLifecycleService.java new file mode 100644 index 00000000..239fd5b7 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiUploadLifecycleService.java @@ -0,0 +1,688 @@ +package tech.easyflow.ai.easyagentsflow.upload; + +import com.easyagents.flow.core.chain.ChainState; +import com.easyagents.flow.core.chain.repository.ChainStateRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +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.net.ConnectException; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.net.http.HttpTimeoutException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.TimeoutException; + +/** + * Public Workflow API multipart 临时文件生命周期服务。 + * + *

上传文件在请求线程中写入统一存储,工作流运行或挂起期间保留, + * 工作流终态、启动失败或状态丢失后由幂等清理流程删除。

+ */ +@Service +public class WorkflowApiUploadLifecycleService { + + private static final Logger LOG = LoggerFactory.getLogger( + WorkflowApiUploadLifecycleService.class); + private static final Duration STAGED_RETENTION = Duration.ofHours(1); + private static final Duration TERMINAL_RETENTION = Duration.ofHours(1); + private static final Duration ACTIVE_RECHECK = Duration.ofHours(24); + private static final Duration CLEANUP_LOCK_WAIT = Duration.ZERO; + private static final Duration CLEANUP_LOCK_LEASE = Duration.ofMinutes(5); + private static final Duration CLEANUP_RETRY_DELAY = + Duration.ofMinutes(5); + private static final String STORAGE_PATH_PREFIX = + "workflow-api-upload/"; + private static final String CLEANUP_LOCK_PREFIX = + "easyflow:workflow:api-upload:cleanup-lock:"; + + private final WorkflowRunningParameterResolver parameterResolver; + private final FileStorageService fileStorageService; + private final WorkflowApiMultipartFileNormalizer fileNormalizer; + private final WorkflowApiUploadStore uploadStore; + private final ChainStateRepository chainStateRepository; + private final RedisLockExecutor redisLockExecutor; + + /** + * 创建工作流 API 临时文件生命周期服务。 + * + * @param parameterResolver 工作流运行参数解析器 + * @param fileStorageService 文件存储服务 + * @param fileNormalizer Multipart 文件元数据归一化器 + * @param uploadStore 临时上传记录存储 + * @param chainStateRepository 工作流状态仓储 + * @param redisLockExecutor Redis 分布式锁执行器 + */ + public WorkflowApiUploadLifecycleService( + WorkflowRunningParameterResolver parameterResolver, + @Qualifier("default") FileStorageService fileStorageService, + WorkflowApiMultipartFileNormalizer fileNormalizer, + WorkflowApiUploadStore uploadStore, + ChainStateRepository chainStateRepository, + RedisLockExecutor redisLockExecutor) { + this.parameterResolver = parameterResolver; + this.fileStorageService = fileStorageService; + this.fileNormalizer = fileNormalizer; + this.uploadStore = uploadStore; + this.chainStateRepository = chainStateRepository; + this.redisLockExecutor = redisLockExecutor; + } + + /** + * 校验、存储 multipart 文件并注入工作流变量。 + * + * @param workflowContent 已发布工作流内容 + * @param variables 普通运行变量 + * @param fileParts 以工作流文件参数名分组的 multipart 文件 + * @return 临时上传准备结果 + */ + public WorkflowApiPreparedUpload prepare( + String workflowContent, + Map variables, + Map> fileParts) { + if (fileParts == null || fileParts.isEmpty()) { + throw new BusinessException( + 400, + 40016, + "multipart 请求至少需要上传一个 files.<开始节点参数名> 文件 Part"); + } + + Map> normalizedFileParts = + normalizeFileParts(fileParts); + + Set fileParameterNames = + parameterResolver.resolveFileParameterNames(workflowContent); + Set requiredFileParameterNames = + parameterResolver.resolveRequiredFileParameterNames( + workflowContent); + Map baseVariables = new LinkedHashMap<>(); + if (variables != null) { + baseVariables.putAll(variables); + } + validateFileParts( + workflowContent, + baseVariables, + normalizedFileParts, + fileParameterNames, + requiredFileParameterNames); + + WorkflowApiUploadRecord record = new WorkflowApiUploadRecord(); + long now = System.currentTimeMillis(); + record.setRequestId(UUID.randomUUID().toString().replace("-", "")); + record.setCreatedAt(now); + record.setCleanupAt(now + STAGED_RETENTION.toMillis()); + + try { + uploadStore.create(record); + Map resolvedVariables = + storeFiles( + baseVariables, + normalizedFileParts, + record); + Map normalized = + parameterResolver.normalizeRuntimeVariables( + workflowContent, + resolvedVariables); + return new WorkflowApiPreparedUpload( + record.getRequestId(), + normalized); + } catch (RuntimeException | Error error) { + try { + cleanupPreparationFailure(record); + } catch (RuntimeException cleanupError) { + error.addSuppressed(cleanupError); + } + throw error; + } + } + + /** + * 在工作流首个节点启动前绑定执行 ID。 + * + * @param requestId 临时上传请求 ID + * @param executeId 工作流执行 ID + */ + public void bindExecution(String requestId, String executeId) { + uploadStore.bindExecution(requestId, executeId); + WorkflowApiUploadRecord record = uploadStore.find(requestId) + .orElseThrow(() -> new IllegalStateException( + "工作流临时上传记录不存在: " + requestId)); + uploadStore.schedule( + record, + System.currentTimeMillis() + ACTIVE_RECHECK.toMillis()); + } + + /** + * 标记工作流执行已进入终态,交由清理任务删除文件。 + * + * @param executeId 工作流执行 ID + */ + public void markExecutionTerminal(String executeId) { + uploadStore.findByExecutionId(executeId).ifPresent(record -> + uploadStore.schedule( + record, + System.currentTimeMillis() + + TERMINAL_RETENTION.toMillis())); + } + + /** + * 终止尚未成功启动的临时上传并立即清理。 + * + * @param requestId 临时上传请求 ID + */ + public void abort(String requestId) { + cleanupRequest(requestId, true); + } + + /** + * 清理一批到期上传记录。 + * + * @param limit 单次最大处理数量 + * @return 已领取并完成一次处理的上传记录数量 + */ + public int cleanupExpired(int limit) { + int processed = 0; + int safeLimit = Math.max(1, limit); + for (int index = 0; index < safeLimit; index++) { + long now = System.currentTimeMillis(); + Optional claimed = uploadStore.claimExpired( + now, + now + CLEANUP_RETRY_DELAY.toMillis()); + if (claimed.isEmpty()) { + break; + } + String requestId = claimed.get(); + processed++; + try { + cleanupRequest(requestId, false); + } catch (RuntimeException error) { + LOG.error( + "清理工作流 API 临时上传失败,requestId={}", + requestId, + error); + } + } + return processed; + } + + /** + * 校验 multipart 文件字段和既有变量冲突,并复用运行参数校验限制。 + * + * @param workflowContent 工作流内容 + * @param variables 普通变量 + * @param fileParts 文件 Part + * @param fileParameterNames 文件参数名 + * @param requiredFileParameterNames 必填文件参数名 + */ + private void validateFileParts( + String workflowContent, + Map variables, + Map> fileParts, + Set fileParameterNames, + Set requiredFileParameterNames) { + for (String requiredParameterName + : requiredFileParameterNames) { + List uploaded = + fileParts.get(requiredParameterName); + if (!hasValue(variables.get(requiredParameterName)) + && (uploaded == null || uploaded.isEmpty())) { + throw new BusinessException( + 400, + 40016, + "缺少必填文件参数 " + requiredParameterName + + ",请使用 files." + + requiredParameterName); + } + } + Map candidates = new LinkedHashMap<>(variables); + for (Map.Entry> entry : + fileParts.entrySet()) { + String parameterName = entry.getKey(); + if (!StringUtils.hasText(parameterName) + || !fileParameterNames.contains(parameterName)) { + throw new BusinessException( + 400, + 40016, + "文件 Part " + parameterName + + " 不是开始节点的文件参数"); + } + if (hasValue(variables.get(parameterName))) { + throw new BusinessException( + 400, + 40016, + "文件参数 " + parameterName + + " 不能同时通过 metadata 和文件 Part 传值"); + } + List files = entry.getValue(); + if (files == null || files.isEmpty()) { + throw new BusinessException( + 400, + 40016, + "文件参数 " + parameterName + " 不能为空"); + } + List> descriptors = + new ArrayList<>(files.size()); + for (int index = 0; index < files.size(); index++) { + MultipartFile file = files.get(index); + validateMultipartFile(file, parameterName); + descriptors.add(fileDescriptor( + file, + "multipart://" + parameterName + "/" + index)); + } + candidates.put(parameterName, descriptors); + } + try { + parameterResolver.normalizeRuntimeVariables( + workflowContent, + candidates); + } catch (BusinessException error) { + throw translateFileValidationFailure(error); + } + } + + /** + * 将运行参数解析器中的文件校验错误转换为稳定公共错误码。 + * + * @param error 原文件参数校验异常 + * @return 原异常或带稳定错误码的异常 + */ + private BusinessException translateFileValidationFailure( + BusinessException error) { + String message = error.getMessage(); + if (message == null || !message.startsWith("文件参数 ")) { + return error; + } + boolean limitExceeded = message.contains("超过") + || message.contains("最多上传"); + return new BusinessException( + limitExceeded ? 413 : 400, + limitExceeded ? 41301 : 40016, + message, + error); + } + + /** + * 将通过校验的文件写入统一存储。 + * + * @param variables 普通变量 + * @param fileParts 文件 Part + * @param record 上传记录 + * @return 已注入真实存储路径的变量 + */ + private Map storeFiles( + Map variables, + Map> fileParts, + WorkflowApiUploadRecord record) { + Map resolved = new LinkedHashMap<>(variables); + for (Map.Entry> entry : + fileParts.entrySet()) { + List> descriptors = + new ArrayList<>(entry.getValue().size()); + for (MultipartFile file : entry.getValue()) { + FileStorageWriteHandle writeHandle; + try { + writeHandle = fileStorageService.prepareRecoverableWrite( + STORAGE_PATH_PREFIX + + record.getRequestId(), + buildStorageFilename( + file, + record.getStoredFiles().size())); + } catch (RuntimeException error) { + throw translateStorageFailure(error); + } + String locator = writeHandle.encodeLocator(); + int storedFileIndex = record.getStoredFiles().size(); + record.getStoredFiles().add( + new WorkflowApiStoredFile(null, locator)); + // 先持久化精确 locator,物理写入中途退出后仍可由清理任务定位。 + uploadStore.save(record); + + FileStorageWriteResult writeResult; + try { + writeResult = fileStorageService.saveRecoverable( + file, + writeHandle); + } catch (RuntimeException error) { + throw translateStorageFailure(error); + } + if (!locator.equals(writeResult.getLocator())) { + throw new IllegalStateException( + "文件存储返回了不一致的恢复定位符"); + } + if (!StringUtils.hasText(writeResult.getUrl())) { + throw new IllegalStateException( + "文件存储未返回有效路径: " + + file.getOriginalFilename()); + } + record.getStoredFiles().set( + storedFileIndex, + record.getStoredFiles() + .get(storedFileIndex) + .withFilePath(writeResult.getUrl())); + uploadStore.save(record); + descriptors.add(fileDescriptor( + file, + writeResult.getUrl())); + } + resolved.put(entry.getKey(), descriptors); + } + return resolved; + } + + /** + * 构建不包含用户目录片段的稳定存储文件名。 + * + * @param file 上传文件 + * @param index 当前请求内文件序号 + * @return 安全存储文件名 + */ + private String buildStorageFilename( + MultipartFile file, + int index) { + String original = file.getOriginalFilename(); + String extension = ""; + int separator = original == null + ? -1 + : original.lastIndexOf('.'); + if (separator >= 0 && separator < original.length() - 1) { + String candidate = original.substring(separator + 1); + if (candidate.length() <= 16 + && candidate.matches("[A-Za-z0-9]+")) { + extension = "." + candidate.toLowerCase(Locale.ROOT); + } + } + return String.format( + Locale.ROOT, + "%03d-%s%s", + index, + UUID.randomUUID().toString().replace("-", ""), + extension); + } + + /** + * 校验单个 multipart 文件的基础元数据。 + * + * @param file 文件 + * @param parameterName 工作流文件参数名 + */ + private void validateMultipartFile( + MultipartFile file, + String parameterName) { + if (file == null || file.isEmpty()) { + throw new BusinessException( + 400, + 40016, + "文件参数 " + parameterName + " 包含空文件"); + } + if (!StringUtils.hasText(file.getOriginalFilename())) { + throw new BusinessException( + 400, + 40016, + "文件参数 " + parameterName + " 缺少文件名"); + } + } + + /** + * 归一化全部文件 Part 的文件名和内容类型。 + * + * @param fileParts 原始文件 Part + * @return 保持参数和文件顺序的归一化视图 + */ + private Map> normalizeFileParts( + Map> fileParts) { + Map> normalized = + new LinkedHashMap<>(); + for (Map.Entry> entry + : fileParts.entrySet()) { + List source = entry.getValue(); + if (source == null) { + normalized.put(entry.getKey(), List.of()); + continue; + } + normalized.put( + entry.getKey(), + source.stream() + .map(fileNormalizer::normalize) + .toList()); + } + return normalized; + } + + /** + * 将对象存储异常转换为不泄露底层配置的公共错误。 + * + * @param error 原始存储异常 + * @return 安全业务异常 + */ + private BusinessException translateStorageFailure( + RuntimeException error) { + if (isTransientStorageFailure(error)) { + return new BusinessException( + 503, + 50301, + "文件存储暂时不可用,请稍后重试", + error); + } + return new BusinessException( + 500, + 50001, + "文件存储处理失败,请联系管理员并提供 requestId", + error); + } + + /** + * 保守识别可直接重试的网络和超时故障。 + * + * @param error 原始异常 + * @return 是否为暂时性依赖故障 + */ + private boolean isTransientStorageFailure(Throwable error) { + Throwable current = error; + while (current != null) { + if (current instanceof ConnectException + || current instanceof SocketTimeoutException + || current instanceof UnknownHostException + || current instanceof HttpTimeoutException + || current instanceof TimeoutException) { + return true; + } + String className = current.getClass().getSimpleName(); + if ("InsufficientDataException".equals(className) + || "ServerException".equals(className)) { + return true; + } + String message = current.getMessage(); + if (message != null) { + String normalized = message.toLowerCase(Locale.ROOT); + if (normalized.contains("timeout") + || normalized.contains("timed out") + || normalized.contains("connection refused") + || normalized.contains("temporarily unavailable") + || normalized.contains("service unavailable")) { + return true; + } + } + current = current.getCause(); + } + return false; + } + + /** + * 构建工作流运行态文件描述。 + * + * @param file multipart 文件 + * @param filePath 存储路径或校验占位路径 + * @return 文件描述 + */ + private Map fileDescriptor( + MultipartFile file, + String filePath) { + Map descriptor = new LinkedHashMap<>(); + descriptor.put("fileName", file.getOriginalFilename()); + descriptor.put("filePath", filePath); + if (StringUtils.hasText(file.getContentType())) { + descriptor.put("contentType", file.getContentType()); + } + descriptor.put("size", file.getSize()); + return descriptor; + } + + /** + * 判断 metadata 中是否已经提供有效值。 + * + * @param value metadata 变量值 + * @return 是否存在有效值 + */ + private boolean hasValue(Object value) { + if (value == null) { + return false; + } + if (value instanceof String text) { + return StringUtils.hasText(text); + } + if (value instanceof Collection collection) { + return !collection.isEmpty(); + } + return true; + } + + /** + * 使用请求线程持有的最新恢复定位符清理准备阶段失败的文件。 + * + *

当 Redis 更新恰好失败时,重新读取的记录可能缺少最后一次状态, + * 因此必须优先使用内存中的记录执行补偿。

+ * + * @param record 请求线程持有的最新上传记录 + */ + private void cleanupPreparationFailure( + WorkflowApiUploadRecord record) { + try { + deleteFiles(record); + uploadStore.remove(record); + } catch (RuntimeException cleanupError) { + try { + uploadStore.save(record); + } catch (RuntimeException persistenceError) { + cleanupError.addSuppressed(persistenceError); + } + throw cleanupError; + } + } + + /** + * 在分布式锁下清理一条上传记录。 + * + * @param requestId 上传请求 ID + * @param force 是否忽略工作流运行状态立即清理 + * @return 是否成功删除记录 + */ + private boolean cleanupRequest(String requestId, boolean force) { + RedisLockExecutor.LockHandle handle = + redisLockExecutor.tryAcquire( + CLEANUP_LOCK_PREFIX + requestId, + CLEANUP_LOCK_WAIT, + CLEANUP_LOCK_LEASE); + if (handle == null) { + return false; + } + try (handle) { + WorkflowApiUploadRecord record = + uploadStore.find(requestId).orElse(null); + if (record == null) { + uploadStore.removeMissingIndex(requestId); + return false; + } + if (!force && shouldRetain(record)) { + uploadStore.schedule( + record, + System.currentTimeMillis() + + ACTIVE_RECHECK.toMillis()); + return false; + } + deleteFiles(record); + uploadStore.remove(record); + return true; + } + } + + /** + * 判断上传记录是否仍被运行中或挂起的工作流使用。 + * + * @param record 上传记录 + * @return 是否需要继续保留 + */ + private boolean shouldRetain(WorkflowApiUploadRecord record) { + if (!StringUtils.hasText(record.getExecuteId())) { + return false; + } + ChainState state = chainStateRepository.load(record.getExecuteId()); + return state != null + && state.getStatus() != null + && !state.getStatus().isTerminal(); + } + + /** + * 幂等删除记录中的全部临时文件。 + * + * @param record 上传记录 + */ + private void deleteFiles(WorkflowApiUploadRecord record) { + RuntimeException firstFailure = null; + for (WorkflowApiStoredFile storedFile : + record.getStoredFiles()) { + try { + fileStorageService.deleteRecoverable( + FileStorageWriteHandle.decodeLocator( + storedFile.storageLocator())); + } catch (RuntimeException error) { + firstFailure = appendFailure(firstFailure, error); + } + } + // 兼容开发阶段已经写入 Redis 的旧版 URL 记录。 + for (String filePath : record.getFilePaths()) { + try { + fileStorageService.delete(filePath); + } catch (RuntimeException error) { + firstFailure = appendFailure(firstFailure, error); + } + } + if (firstFailure != null) { + throw firstFailure; + } + } + + /** + * 聚合文件清理异常并保留全部失败原因。 + * + * @param firstFailure 首个异常 + * @param currentFailure 当前异常 + * @return 聚合后的首个异常 + */ + private RuntimeException appendFailure( + RuntimeException firstFailure, + RuntimeException currentFailure) { + if (firstFailure == null) { + return currentFailure; + } + firstFailure.addSuppressed(currentFailure); + return firstFailure; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiUploadRecord.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiUploadRecord.java new file mode 100644 index 00000000..d2e2436b --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiUploadRecord.java @@ -0,0 +1,133 @@ +package tech.easyflow.ai.easyagentsflow.upload; + +import java.util.ArrayList; +import java.util.List; + +/** + * Public Workflow API 临时上传记录。 + */ +public class WorkflowApiUploadRecord { + + private String requestId; + private String executeId; + private List storedFiles = new ArrayList<>(); + /** + * 兼容早期临时上传记录的旧版路径;新记录使用 {@link #storedFiles}。 + */ + private List filePaths = new ArrayList<>(); + private long createdAt; + private long cleanupAt; + + /** + * 获取上传请求 ID。 + * + * @return 上传请求 ID + */ + public String getRequestId() { + return requestId; + } + + /** + * 设置上传请求 ID。 + * + * @param requestId 上传请求 ID + */ + public void setRequestId(String requestId) { + this.requestId = requestId; + } + + /** + * 获取工作流执行 ID。 + * + * @return 工作流执行 ID + */ + public String getExecuteId() { + return executeId; + } + + /** + * 设置工作流执行 ID。 + * + * @param executeId 工作流执行 ID + */ + public void setExecuteId(String executeId) { + this.executeId = executeId; + } + + /** + * 获取带恢复定位符的临时文件。 + * + * @return 临时文件记录 + */ + public List getStoredFiles() { + return storedFiles; + } + + /** + * 设置带恢复定位符的临时文件。 + * + * @param storedFiles 临时文件记录 + */ + public void setStoredFiles( + List storedFiles) { + this.storedFiles = storedFiles == null + ? new ArrayList<>() + : new ArrayList<>(storedFiles); + } + + /** + * 获取旧版临时文件路径。 + * + * @return 临时文件路径 + */ + public List getFilePaths() { + return filePaths; + } + + /** + * 设置旧版临时文件路径。 + * + * @param filePaths 临时文件路径 + */ + public void setFilePaths(List filePaths) { + this.filePaths = filePaths == null + ? new ArrayList<>() + : new ArrayList<>(filePaths); + } + + /** + * 获取创建时间。 + * + * @return Unix 毫秒时间戳 + */ + public long getCreatedAt() { + return createdAt; + } + + /** + * 设置创建时间。 + * + * @param createdAt Unix 毫秒时间戳 + */ + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } + + /** + * 获取下次清理检查时间。 + * + * @return Unix 毫秒时间戳 + */ + public long getCleanupAt() { + return cleanupAt; + } + + /** + * 设置下次清理检查时间。 + * + * @param cleanupAt Unix 毫秒时间戳 + */ + public void setCleanupAt(long cleanupAt) { + this.cleanupAt = cleanupAt; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiUploadStore.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiUploadStore.java new file mode 100644 index 00000000..c6d96f3e --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiUploadStore.java @@ -0,0 +1,345 @@ +package tech.easyflow.ai.easyagentsflow.upload; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +/** + * Public Workflow API 临时上传记录的 Redis 存储。 + */ +@Component +public class WorkflowApiUploadStore { + + private static final String RECORD_KEY_PREFIX = + "easyflow:workflow:{api-upload}:record:"; + private static final String EXECUTION_KEY_PREFIX = + "easyflow:workflow:{api-upload}:execution:"; + private static final String CLEANUP_INDEX = + "easyflow:workflow:{api-upload}:cleanup"; + private static final Duration EXECUTION_INDEX_TTL = + Duration.ofDays(30); + private static final DefaultRedisScript SAVE_SCHEDULED_SCRIPT = + longScript( + "redis.call('set', KEYS[1], ARGV[1]); " + + "redis.call('zadd', KEYS[2], ARGV[2], ARGV[3]); " + + "if ARGV[4] == '1' " + + "and redis.call('get', KEYS[3]) == ARGV[3] then " + + "redis.call('pexpire', KEYS[3], ARGV[5]); end; " + + "return 1"); + private static final DefaultRedisScript BIND_EXECUTION_SCRIPT = + longScript( + "redis.call('set', KEYS[1], ARGV[1]); " + + "redis.call('psetex', KEYS[2], ARGV[3], ARGV[2]); " + + "if ARGV[4] == '1' and KEYS[3] ~= KEYS[2] " + + "and redis.call('get', KEYS[3]) == ARGV[2] then " + + "redis.call('del', KEYS[3]); end; " + + "return 1"); + private static final DefaultRedisScript + REMOVE_EXECUTION_INDEX_SCRIPT = + longScript( + "if redis.call('get', KEYS[1]) == ARGV[1] then " + + "return redis.call('del', KEYS[1]); end; " + + "return 0"); + private static final DefaultRedisScript CLAIM_EXPIRED_SCRIPT = + stringScript( + "local values = redis.call(" + + "'zrangebyscore', KEYS[1], '-inf', ARGV[1], " + + "'LIMIT', 0, 1); " + + "if #values == 0 then return nil; end; " + + "redis.call('zadd', KEYS[1], ARGV[2], values[1]); " + + "return values[1]"); + private static final DefaultRedisScript REMOVE_SCRIPT = + longScript( + "redis.call('del', KEYS[1]); " + + "if ARGV[2] == '1' " + + "and redis.call('get', KEYS[2]) == ARGV[1] then " + + "redis.call('del', KEYS[2]); end; " + + "redis.call('zrem', KEYS[3], ARGV[1]); " + + "return 1"); + + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + /** + * 创建临时上传记录存储。 + * + * @param redisTemplate Redis 模板 + * @param objectMapper JSON 映射器 + */ + public WorkflowApiUploadStore(StringRedisTemplate redisTemplate, + ObjectMapper objectMapper) { + this.redisTemplate = redisTemplate; + this.objectMapper = objectMapper; + } + + /** + * 新建临时上传记录并登记清理时间。 + * + * @param record 上传记录 + */ + public void create(WorkflowApiUploadRecord record) { + requireRecord(record); + saveScheduled(record); + } + + /** + * 保存上传记录的最新内容。 + * + * @param record 上传记录 + */ + public void save(WorkflowApiUploadRecord record) { + requireRecord(record); + redisTemplate.opsForValue().set( + recordKey(record.getRequestId()), + serialize(record)); + } + + /** + * 将上传请求绑定到工作流执行实例。 + * + * @param requestId 上传请求 ID + * @param executeId 工作流执行 ID + */ + public void bindExecution(String requestId, String executeId) { + if (!StringUtils.hasText(executeId)) { + throw new IllegalArgumentException("工作流执行 ID 不能为空"); + } + WorkflowApiUploadRecord record = find(requestId) + .orElseThrow(() -> new IllegalStateException( + "工作流临时上传记录不存在: " + requestId)); + String previousExecuteId = record.getExecuteId(); + record.setExecuteId(executeId); + redisTemplate.execute( + BIND_EXECUTION_SCRIPT, + Arrays.asList( + recordKey(requestId), + executionKey(executeId), + StringUtils.hasText(previousExecuteId) + ? executionKey(previousExecuteId) + : recordKey(requestId)), + serialize(record), + requestId, + String.valueOf(EXECUTION_INDEX_TTL.toMillis()), + StringUtils.hasText(previousExecuteId) ? "1" : "0"); + } + + /** + * 按上传请求 ID 查找记录。 + * + * @param requestId 上传请求 ID + * @return 上传记录 + */ + public Optional find(String requestId) { + if (!StringUtils.hasText(requestId)) { + return Optional.empty(); + } + String value = redisTemplate.opsForValue().get(recordKey(requestId)); + if (!StringUtils.hasText(value)) { + return Optional.empty(); + } + try { + return Optional.of(objectMapper.readValue( + value, + WorkflowApiUploadRecord.class)); + } catch (JsonProcessingException error) { + throw new IllegalStateException( + "读取工作流临时上传记录失败: " + requestId, + error); + } + } + + /** + * 按工作流执行 ID 查找上传记录。 + * + * @param executeId 工作流执行 ID + * @return 上传记录 + */ + public Optional findByExecutionId( + String executeId) { + if (!StringUtils.hasText(executeId)) { + return Optional.empty(); + } + String requestId = redisTemplate.opsForValue().get( + executionKey(executeId)); + Optional record = find(requestId); + if (StringUtils.hasText(requestId) + && (record.isEmpty() + || !executeId.equals(record.get().getExecuteId()))) { + redisTemplate.execute( + REMOVE_EXECUTION_INDEX_SCRIPT, + List.of(executionKey(executeId)), + requestId); + return Optional.empty(); + } + return record; + } + + /** + * 更新记录的下次清理检查时间。 + * + * @param record 上传记录 + * @param cleanupAt Unix 毫秒时间戳 + */ + public void schedule(WorkflowApiUploadRecord record, long cleanupAt) { + requireRecord(record); + record.setCleanupAt(cleanupAt); + saveScheduled(record); + } + + /** + * 原子领取一条到期上传请求,并提前设置失败重试时间。 + * + * @param now 当前 Unix 毫秒时间戳 + * @param retryAt 领取后默认重试时间 + * @return 领取到的上传请求 ID + */ + public Optional claimExpired( + long now, + long retryAt) { + String requestId = redisTemplate.execute( + CLAIM_EXPIRED_SCRIPT, + List.of(CLEANUP_INDEX), + String.valueOf(now), + String.valueOf(retryAt)); + return Optional.ofNullable(requestId); + } + + /** + * 删除上传记录、执行索引和清理索引。 + * + * @param record 上传记录 + */ + public void remove(WorkflowApiUploadRecord record) { + requireRecord(record); + boolean hasExecuteId = + StringUtils.hasText(record.getExecuteId()); + redisTemplate.execute( + REMOVE_SCRIPT, + Arrays.asList( + recordKey(record.getRequestId()), + hasExecuteId + ? executionKey(record.getExecuteId()) + : recordKey(record.getRequestId()), + CLEANUP_INDEX), + record.getRequestId(), + hasExecuteId ? "1" : "0"); + } + + /** + * 删除已经缺少详情记录的残留清理索引。 + * + * @param requestId 上传请求 ID + */ + public void removeMissingIndex(String requestId) { + if (StringUtils.hasText(requestId)) { + redisTemplate.opsForZSet().remove(CLEANUP_INDEX, requestId); + } + } + + /** + * 序列化上传记录。 + * + * @param record 上传记录 + * @return JSON 文本 + */ + private String serialize(WorkflowApiUploadRecord record) { + try { + return objectMapper.writeValueAsString(record); + } catch (JsonProcessingException error) { + throw new IllegalStateException( + "写入工作流临时上传记录失败: " + + record.getRequestId(), + error); + } + } + + /** + * 原子保存记录与清理索引,并续期执行索引。 + * + * @param record 上传记录 + */ + private void saveScheduled(WorkflowApiUploadRecord record) { + boolean hasExecuteId = + StringUtils.hasText(record.getExecuteId()); + redisTemplate.execute( + SAVE_SCHEDULED_SCRIPT, + Arrays.asList( + recordKey(record.getRequestId()), + CLEANUP_INDEX, + hasExecuteId + ? executionKey(record.getExecuteId()) + : recordKey(record.getRequestId())), + serialize(record), + String.valueOf(record.getCleanupAt()), + record.getRequestId(), + hasExecuteId ? "1" : "0", + String.valueOf(EXECUTION_INDEX_TTL.toMillis())); + } + + /** + * 创建返回 Long 的 Redis Lua 脚本。 + * + * @param text Lua 文本 + * @return Redis 脚本 + */ + private static DefaultRedisScript longScript( + String text) { + DefaultRedisScript script = new DefaultRedisScript<>(); + script.setScriptText(text); + script.setResultType(Long.class); + return script; + } + + /** + * 创建返回字符串的 Redis Lua 脚本。 + * + * @param text Lua 文本 + * @return Redis 脚本 + */ + private static DefaultRedisScript stringScript( + String text) { + DefaultRedisScript script = new DefaultRedisScript<>(); + script.setScriptText(text); + script.setResultType(String.class); + return script; + } + + /** + * 校验记录主键。 + * + * @param record 上传记录 + */ + private void requireRecord(WorkflowApiUploadRecord record) { + if (record == null || !StringUtils.hasText(record.getRequestId())) { + throw new IllegalArgumentException("工作流临时上传请求 ID 不能为空"); + } + } + + /** + * 构建记录 Redis Key。 + * + * @param requestId 上传请求 ID + * @return Redis Key + */ + private String recordKey(String requestId) { + return RECORD_KEY_PREFIX + requestId; + } + + /** + * 构建执行实例 Redis Key。 + * + * @param executeId 工作流执行 ID + * @return Redis Key + */ + private String executionKey(String executeId) { + return EXECUTION_KEY_PREFIX + executeId; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiUploadedFileReader.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiUploadedFileReader.java new file mode 100644 index 00000000..9b7802f5 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiUploadedFileReader.java @@ -0,0 +1,129 @@ +package tech.easyflow.ai.easyagentsflow.upload; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import tech.easyflow.common.filestorage.FileStorageService; +import tech.easyflow.common.filestorage.FileStorageWriteHandle; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 读取经过 Public Workflow API 上传记录验证的临时文件。 + * + *

外部文件描述只提供公开读取路径。仅当路径中的随机请求 ID、Redis 上传记录、 + * 完整文件 URL 和可恢复存储句柄全部匹配时,才允许绕过公网 URL 限制并直接读取物理对象。

+ */ +@Component +public class WorkflowApiUploadedFileReader { + + private static final String STORAGE_PATH_PREFIX = "workflow-api-upload/"; + private static final Pattern MANAGED_PATH_PATTERN = Pattern.compile( + "(?:^|/)workflow-api-upload/([0-9a-f]{32})/([^/]+)$"); + + private final WorkflowApiUploadStore uploadStore; + private final FileStorageService fileStorageService; + + /** + * 创建工作流 API 上传文件读取器。 + * + * @param uploadStore 临时上传记录存储 + * @param fileStorageService 默认文件存储路由 + */ + public WorkflowApiUploadedFileReader( + WorkflowApiUploadStore uploadStore, + @Qualifier("default") FileStorageService fileStorageService) { + this.uploadStore = uploadStore; + this.fileStorageService = fileStorageService; + } + + /** + * 在路径属于受管工作流上传文件时校验记录并打开物理对象。 + * + * @param filePath 工作流文件描述中的完整读取路径 + * @return 受管文件流;普通文件路径或普通远端 URL 返回空 + * @throws IOException 上传记录已失效、引用不匹配或物理对象无法读取时抛出 + */ + public Optional openVerified(String filePath) throws IOException { + ManagedPath managedPath = parseManagedPath(filePath).orElse(null); + if (managedPath == null) { + return Optional.empty(); + } + WorkflowApiUploadRecord record = uploadStore.find(managedPath.requestId()) + .orElseThrow(() -> new IOException("工作流上传文件已失效,请重新上传")); + WorkflowApiStoredFile storedFile = record.getStoredFiles().stream() + .filter(file -> file != null && filePath.equals(file.filePath())) + .findFirst() + .orElseThrow(() -> new IOException("工作流上传文件引用与上传记录不匹配")); + + final FileStorageWriteHandle handle; + try { + handle = FileStorageWriteHandle.decodeLocator(storedFile.storageLocator()); + } catch (IllegalArgumentException exception) { + throw new IOException("工作流上传文件存储定位符无效", exception); + } + String expectedStoragePath = STORAGE_PATH_PREFIX + managedPath.requestId() + "/"; + if (!expectedStoragePath.equals(handle.getPath()) + || !managedPath.filename().equals(handle.getFilename())) { + throw new IOException("工作流上传文件存储定位与上传请求不匹配"); + } + try { + return Optional.of(fileStorageService.readRecoverable(handle)); + } catch (RuntimeException exception) { + throw new IOException("读取工作流上传文件失败", exception); + } + } + + /** + * 判断路径结构是否属于系统生成的工作流 API 上传目录。 + * + *

该判断只用于选择 I/O 隔离通道,不能替代 {@link #openVerified(String)} 的授权校验。

+ * + * @param filePath 文件读取路径 + * @return 路径结构匹配时返回 true + */ + public boolean isManagedPathCandidate(String filePath) { + return parseManagedPath(filePath).isPresent(); + } + + /** + * 从 URL 或相对路径中解析受管请求 ID 与固定文件名。 + * + * @param filePath 原始文件路径 + * @return 受管路径信息 + */ + private Optional parseManagedPath(String filePath) { + if (!StringUtils.hasText(filePath)) { + return Optional.empty(); + } + final String path; + try { + URI uri = URI.create(filePath); + path = uri.getPath(); + } catch (IllegalArgumentException exception) { + return Optional.empty(); + } + if (!StringUtils.hasText(path)) { + return Optional.empty(); + } + Matcher matcher = MANAGED_PATH_PATTERN.matcher(path); + if (!matcher.find()) { + return Optional.empty(); + } + return Optional.of(new ManagedPath(matcher.group(1), matcher.group(2))); + } + + /** + * 系统受管上传路径中的可信定位片段。 + * + * @param requestId 随机上传请求 ID + * @param filename 系统生成的存储文件名 + */ + private record ManagedPath(String requestId, String filename) { + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/DocNodeFileContentExtractor.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/DocNodeFileContentExtractor.java index 3458a916..ce414d6b 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/DocNodeFileContentExtractor.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/node/DocNodeFileContentExtractor.java @@ -1,6 +1,7 @@ package tech.easyflow.ai.node; import com.easyagents.flow.core.util.IoBulkhead; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -12,6 +13,7 @@ import tech.easyflow.ai.document.exception.DocumentParseBridgeException; import tech.easyflow.ai.document.service.DocumentParseBridgeService; import tech.easyflow.ai.document.support.DocumentInputStreamSupport; import tech.easyflow.ai.document.support.DocumentParseSourceType; +import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadedFileReader; import tech.easyflow.common.filestorage.FileStorageService; import tech.easyflow.common.util.StringUtil; import tech.easyflow.common.web.exceptions.BusinessException; @@ -27,6 +29,7 @@ import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; /** @@ -49,6 +52,7 @@ public class DocNodeFileContentExtractor { private final DocumentParseBridgeService documentParseBridgeService; private final FileStorageService fileStorageService; private final ReaderManager readerManager; + private final WorkflowApiUploadedFileReader uploadedFileReader; /** * 创建文件内容提取器。 @@ -56,13 +60,31 @@ public class DocNodeFileContentExtractor { * @param documentParseBridgeService 统一文档解析桥接服务 * @param fileStorageService 文件存储服务 * @param readerManager 默认读取器管理器 + * @param uploadedFileReader 已验证的工作流 API 上传文件读取器 */ + @Autowired public DocNodeFileContentExtractor(DocumentParseBridgeService documentParseBridgeService, @Qualifier("default") FileStorageService fileStorageService, - ReaderManager readerManager) { + ReaderManager readerManager, + WorkflowApiUploadedFileReader uploadedFileReader) { this.documentParseBridgeService = documentParseBridgeService; this.fileStorageService = fileStorageService; this.readerManager = readerManager; + this.uploadedFileReader = uploadedFileReader; + } + + /** + * 创建不启用工作流 API 上传识别的提取器,供同包隔离测试使用。 + * + * @param documentParseBridgeService 统一文档解析桥接服务 + * @param fileStorageService 文件存储服务 + * @param readerManager 默认读取器管理器 + */ + DocNodeFileContentExtractor( + DocumentParseBridgeService documentParseBridgeService, + FileStorageService fileStorageService, + ReaderManager readerManager) { + this(documentParseBridgeService, fileStorageService, readerManager, null); } /** @@ -305,7 +327,9 @@ public class DocNodeFileContentExtractor { DocumentSourceRef sourceRef, Path target) throws IOException { String filePath = sourceRef.getFilePath(); boolean localStorage = StringUtil.hasText(filePath) - && !isRemoteUrl(filePath); + && (!isRemoteUrl(filePath) + || (uploadedFileReader != null + && uploadedFileReader.isManagedPathCandidate(filePath))); if (localStorage) { try (IoBulkhead.Permit ignored = IoBulkhead.storage().acquire("storage:document-read"); @@ -340,6 +364,14 @@ public class DocNodeFileContentExtractor { private InputStream openInputStream(DocumentSourceRef sourceRef) throws IOException { String filePath = sourceRef.getFilePath(); + if (uploadedFileReader != null && StringUtil.hasText(filePath)) { + Optional managed = uploadedFileReader.openVerified(filePath); + if (managed.isPresent()) { + return DocumentInputStreamSupport.limit( + managed.get(), + FILE_MAX_SINGLE_SIZE); + } + } if (StringUtil.hasText(filePath) && isRemoteUrl(filePath)) { return DocumentInputStreamSupport.openRemote(filePath, FILE_MAX_SINGLE_SIZE); } diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/TinyFlowServiceTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/TinyFlowServiceTest.java index 67ab4856..c8b2999f 100644 --- a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/TinyFlowServiceTest.java +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/TinyFlowServiceTest.java @@ -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); } diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/WorkflowRunningParameterResolverTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/WorkflowRunningParameterResolverTest.java index dbafc1c1..0940d2c8 100644 --- a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/WorkflowRunningParameterResolverTest.java +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/service/WorkflowRunningParameterResolverTest.java @@ -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 variables = new LinkedHashMap<>(); + variables.put("attachments", List.of(firstUrl, secondUrl)); + + Map 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 variables = new LinkedHashMap<>(); + variables.put( + "attachments", + "https://files.example.com/contracts/contract.docx"); + + Map 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 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> files = new java.util.ArrayList<>(); + for (int index = 0; index < 11; index++) { + files.add(fileValue( + "file-" + index + ".pdf", + "/files/file-" + index + ".pdf", + 1L)); + } + Map 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 图片描述。 * diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiUploadCleanupSchedulerTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiUploadCleanupSchedulerTest.java new file mode 100644 index 00000000..74cec3b4 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiUploadCleanupSchedulerTest.java @@ -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); + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiUploadLifecycleServiceTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiUploadLifecycleServiceTest.java new file mode 100644 index 00000000..0dc0e41e --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiUploadLifecycleServiceTest.java @@ -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> documents = + (List>) 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 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 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> documents = + (List>) 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) { + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiUploadStoreTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiUploadStoreTest.java new file mode 100644 index 00000000..73ed519a --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiUploadStoreTest.java @@ -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.>any(), + ArgumentMatchers.>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> scriptCaptor = + ArgumentCaptor.forClass((Class) RedisScript.class); + ArgumentCaptor> 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 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.>any(), + ArgumentMatchers.>any(), + ArgumentMatchers.anyString(), + ArgumentMatchers.anyString(), + ArgumentMatchers.anyString(), + ArgumentMatchers.anyString()); + WorkflowApiUploadStore store = new WorkflowApiUploadStore( + redisTemplate, + objectMapper); + + store.bindExecution("request-1", "execution-new"); + + ArgumentCaptor> scriptCaptor = + ArgumentCaptor.forClass((Class) RedisScript.class); + ArgumentCaptor> 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; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiUploadedFileReaderTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiUploadedFileReaderTest.java new file mode 100644 index 00000000..39ffdb26 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/upload/WorkflowApiUploadedFileReaderTest.java @@ -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 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 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; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/DocNodeFileContentExtractorTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/DocNodeFileContentExtractorTest.java index e54ae430..5712446c 100644 --- a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/DocNodeFileContentExtractorTest.java +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/node/DocNodeFileContentExtractorTest.java @@ -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); } /** diff --git a/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowList.vue b/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowList.vue index 82269169..f82a4430 100644 --- a/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowList.vue +++ b/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowList.vue @@ -511,16 +511,20 @@ function resolveApiFields(row: any): ApiFieldDoc[] { }) .filter(Boolean) as ApiFieldDoc[]; } -function buildExampleVariables(row: any) { +function isFileApiField(field: ApiFieldDoc) { + return ( + field.type.trim().toLowerCase() === 'file' || + field.type.trim().toLowerCase() === 'files' + ); +} +function buildExampleVariables(row: any, includeFileReferences = true) { const variables: Record = {}; for (const field of resolveApiFields(row)) { - if (field.type === 'file') { - variables[field.key] = [ - { - fileName: 'example.pdf', - filePath: 'https://example.com/example.pdf', - }, - ]; + if (isFileApiField(field)) { + if (!includeFileReferences) { + continue; + } + variables[field.key] = 'https://files.example.com/example.pdf'; continue; } if (field.type === 'checkbox') { @@ -541,12 +545,101 @@ function buildRunRequestExample(row: any) { 2, ); } -function buildRunResponseExample() { +function quoteShellFormValue(value: string) { + return `'${value.replaceAll(`'`, String.raw`'"'"'`)}'`; +} +function buildMultipartCurlExample(row: any, url: string) { + const fileFields = resolveApiFields(row).filter((field) => + isFileApiField(field), + ); + const metadata = JSON.stringify({ + id: row?.id, + variables: buildExampleVariables(row, false), + }); + const parts = [ + `curl --request POST '${url}'`, + ` --header 'ApiKey: '`, + ` --form ${quoteShellFormValue( + `metadata=${metadata};type=application/json`, + )}`, + ...fileFields.map( + (field) => + ` --form ${quoteShellFormValue(`files.${field.key}=@/path/to/example.pdf;type=application/pdf`)}`, + ), + ]; + return parts + .map((line, index) => (index < parts.length - 1 ? `${line} \\` : line)) + .join('\n'); +} +function buildRunResponseExample(row: any) { return JSON.stringify( { errorCode: 0, message: '成功', data: '执行ID', + workflow: { + workflowId: String(row?.id ?? ''), + alias: row?.alias || null, + title: row?.title || null, + description: row?.description || null, + revision: row?.revision ?? null, + publishedAt: row?.publishedAt || null, + nodes: [ + { + nodeId: '<节点 ID>', + nodeType: '<节点类型>', + nodeName: '<节点名称>', + description: null, + parentNodeId: null, + definitionIndex: 0, + topologyIndex: 0, + topologyLevel: 0, + inDegree: 0, + outDegree: 1, + startNode: true, + endNode: false, + predecessorNodeIds: [], + successorNodeIds: ['<后继节点 ID>'], + incomingEdgeIds: [], + outgoingEdgeIds: ['<边 ID>'], + inputParameters: [ + { + parameterId: '<参数 ID>', + name: 'documents', + label: '文档', + dataType: 'File', + contentType: 'file', + required: true, + description: null, + multipartPartName: 'files.documents', + }, + ], + outputParameters: [], + }, + ], + edges: [ + { + edgeId: '<边 ID>', + edgeType: '<边类型>', + label: null, + sourceNodeId: '<源节点 ID>', + targetNodeId: '<目标节点 ID>', + sourceHandle: null, + targetHandle: null, + parentNodeId: null, + definitionIndex: 0, + dangling: false, + }, + ], + topologicalOrder: ['<开始节点 ID>', '<处理节点 ID>', '<结束节点 ID>'], + topologyLevels: [ + ['<开始节点 ID>'], + ['<处理节点 ID>'], + ['<结束节点 ID>'], + ], + hasCycle: false, + unresolvedNodeIds: [], + }, }, null, 2, @@ -578,11 +671,18 @@ function handleApiDocClick(e: MouseEvent) { if (url) copyApiContent(url); } function apiUrlLine(method: string, url: string) { - const escaped = url.replace(/"/g, '"'); - return `\`${method}\` \`${url}\` `; } +function escapeMarkdownTableCell(value: unknown) { + return String(value ?? '') + .replaceAll('|', String.raw`\|`) + .replaceAll(/\r?\n/g, '
'); +} + +/* eslint-disable unicorn/prefer-single-call -- 文档按接口章节顺序增量组装,可读性优先。 */ const apiDocMarkdown = computed(() => { const row = apiInstructionRow.value; if (!row) return ''; @@ -625,12 +725,36 @@ const apiDocMarkdown = computed(() => { `异步执行工作流,立即返回执行 ID。工作流必须已发布且存在发布快照。`, ); lines.push(``); - lines.push(`### 请求体`); + lines.push(`### JSON 请求`); + lines.push(``); + lines.push( + `> 顶层 \`Content-Type\` 必须为 \`application/json\`。多数 HTTP 客户端在发送 JSON 对象时会自动设置;直接发送文本或文件内容时请显式声明。`, + ); lines.push(``); lines.push('```json'); lines.push(buildRunRequestExample(row)); lines.push('```'); lines.push(``); + if (fields.some((field) => isFileApiField(field))) { + lines.push(`### Multipart 文件直传`); + lines.push(``); + lines.push( + `文件参数可直接通过 \`multipart/form-data\` 上传,无需先获取文件地址。\`metadata\` 只承载 API 请求元数据;工作流文件统一使用 \`files.<开始节点文件参数名>\`,例如参数 \`documents\` 对应 Part \`files.documents\`。`, + ); + lines.push(``); + lines.push( + `> 请勿手工添加顶层 \`Content-Type: multipart/form-data\`。由 cURL、Postman 或 Hoppscotch 根据请求体自动生成包含 boundary 的请求头;\`metadata\` Part 必须使用 \`application/json\`。`, + ); + lines.push(``); + lines.push('```bash'); + lines.push(buildMultipartCurlExample(row, `${baseUrl}/runAsync`)); + lines.push('```'); + lines.push(``); + lines.push( + `同一文件参数需要上传多个文件时,重复传入同名 \`files.<参数名>\` Part。文件 Part 可声明合法 MIME;客户端发送空值、\`Other\` 或非法值时,服务端会按扩展名推断,仍无法识别时使用 \`application/octet-stream\`。`, + ); + lines.push(``); + } // 入参说明 lines.push(`### 入参说明`); @@ -649,9 +773,15 @@ const apiDocMarkdown = computed(() => { lines.push(`| 参数 | 类型 | 必填 | 说明 |`); lines.push(`| --- | --- | --- | --- |`); for (const f of fields) { - const desc = [f.label, f.description].filter(Boolean).join(' · '); + const fileDescription = isFileApiField(f) + ? 'JSON 可直接填写 HTTP/HTTPS 文件 URL;多文件使用 URL 数组。URL 路径需包含文件名和扩展名;继续兼容 fileName + filePath 文件对象' + : ''; + const desc = [f.label, f.description, fileDescription] + .filter(Boolean) + .join(' · '); + const type = isFileApiField(f) ? 'string / string[]' : f.type; lines.push( - `| ${f.key} | ${f.type} | ${f.required ? '是' : '否'} | ${desc} |`, + `| ${escapeMarkdownTableCell(f.key)} | ${escapeMarkdownTableCell(type)} | ${f.required ? '是' : '否'} | ${escapeMarkdownTableCell(desc)} |`, ); } lines.push(``); @@ -660,13 +790,17 @@ const apiDocMarkdown = computed(() => { lines.push(`### 响应`); lines.push(``); lines.push('```json'); - lines.push(buildRunResponseExample()); + lines.push(buildRunResponseExample(row)); lines.push('```'); lines.push(``); lines.push( `\`data\` 为 **执行 ID**(executeId),后续查询和恢复均需使用此 ID。`, ); lines.push(``); + lines.push( + `\`workflow.nodes\` 提供全部公开节点信息;\`topologicalOrder\` 提供稳定的节点 ID 拓扑顺序;\`topologyLevels\` 按可并行层级分组。节点配置中的提示词、脚本和连接密钥等运行配置不会返回。`, + ); + lines.push(``); lines.push(`---`); lines.push(``); @@ -686,7 +820,7 @@ const apiDocMarkdown = computed(() => { JSON.stringify( { executeId: '', - nodes: [{ nodeId: '<需要查询的节点 ID>' }], + nodes: [{ nodeId: '' }], }, null, 2, @@ -695,7 +829,7 @@ const apiDocMarkdown = computed(() => { lines.push('```'); lines.push(``); lines.push( - `> \`nodes\` 参数可选。不传则只返回工作流整体状态;需要节点详情时传入对象数组,每项至少包含 \`nodeId\`。`, + `> \`nodes\` 参数可选。不传则只返回工作流整体状态;需要节点详情时,可将 \`runAsync.workflow.nodes\` 中的 \`nodeId\` 组成对象数组传入,服务端会从该执行实例的定义快照补齐 \`nodeName\`。`, ); lines.push(``); lines.push(`### 响应示例`); @@ -708,10 +842,25 @@ const apiDocMarkdown = computed(() => { message: '成功', data: { executeId: 'abc5358c-a310-4caa-97ec-455062b2235e', - status: 20, - message: null, - result: { output: '工作流执行结果' }, - nodes: {}, + status: 'failed', + terminal: true, + message: '工作流执行失败,请检查输入或稍后重试', + result: null, + nodes: { + '<节点 ID>': { + nodeId: '<节点 ID>', + nodeName: '文档解析', + status: 'failed', + message: '节点执行失败,请检查输入或稍后重试', + }, + }, + error: { + code: 'WORKFLOW_EXECUTION_FAILED', + message: '工作流执行失败,请检查输入或稍后重试', + nodeId: '<节点 ID>', + nodeName: '文档解析', + retryable: false, + }, }, }, null, @@ -722,17 +871,20 @@ const apiDocMarkdown = computed(() => { lines.push(``); lines.push(`### 状态值说明`); lines.push(``); - lines.push(`| 数值 | 状态 | 说明 |`); + lines.push(`| status | terminal | 说明 |`); lines.push(`| --- | --- | --- |`); - lines.push(`| 0 | READY | 就绪,尚未开始 |`); - lines.push(`| 1 | RUNNING | 执行中 |`); - lines.push(`| 5 | SUSPEND | 挂起,等待确认节点恢复 |`); - lines.push(`| 10 | ERROR | 执行异常,可能仍在重试 |`); - lines.push(`| 20 | SUCCEEDED | 执行成功,终态 |`); - lines.push(`| 21 | FAILED | 执行失败,终态 |`); - lines.push(`| 22 | CANCELLED | 已取消,终态 |`); + lines.push(`| ready | false | 就绪,尚未开始 |`); + lines.push(`| running | false | 执行中 |`); + lines.push(`| suspended | false | 已暂停,等待确认参数恢复 |`); + lines.push(`| error | false | 执行异常,运行时可能仍在处理 |`); + lines.push(`| done | true | 执行成功 |`); + lines.push(`| failed | true | 执行失败 |`); + lines.push(`| cancelled | true | 已取消 |`); + lines.push(`| unknown | false | 无法识别的兼容状态 |`); lines.push(``); - lines.push(`轮询可在状态值为 \`20\`、\`21\` 或 \`22\` 时结束。`); + lines.push( + `调用方应优先根据 \`terminal\` 判断是否结束轮询;值为 \`true\` 时,状态为 \`done\`、\`failed\` 或 \`cancelled\`。`, + ); lines.push(``); // ---- 3. 恢复执行 ---- @@ -743,7 +895,7 @@ const apiDocMarkdown = computed(() => { lines.push(apiUrlLine('POST', `${baseUrl}/resume`)); lines.push(``); lines.push( - `当工作流包含**确认节点**时,执行到该节点后状态变为 \`5(SUSPEND)\`,需要调用此接口传入确认参数后恢复执行。若工作流不包含确认节点则无需调用。`, + `当工作流包含**确认节点**时,执行到该节点后状态变为 \`suspended\`,需要调用此接口传入确认参数后恢复执行。仅暂停中的执行实例允许恢复;若工作流不包含确认节点则无需调用。`, ); lines.push(``); lines.push(`### 请求体`); @@ -769,21 +921,43 @@ const apiDocMarkdown = computed(() => { lines.push(`## 错误处理`); lines.push(``); lines.push( - `当请求失败时,\`errorCode\` 不为 0,\`message\` 包含错误原因。常见错误:`, + `请求尚未受理时会返回真实的 HTTP 4xx/5xx,\`errorCode\` 为稳定业务码,\`data.requestId\` 可用于服务端排查。请求已返回 executeId 后的节点失败通过 \`getChainStatus.data.error\` 获取。`, ); lines.push(``); - lines.push(`| 场景 | 说明 |`); - lines.push(`| --- | --- |`); - lines.push(`| ApiKey 无效或过期 | 检查访问令牌状态与有效期 |`); + lines.push(`| HTTP | errorCode | 场景与处理 |`); + lines.push(`| --- | --- | --- |`); lines.push( - `| 未授权工作流 API 调用 | 在访问令牌中开启「工作流 API 调用授权」 |`, + `| 400 | 40011 | JSON 请求体无效;文件直传请改用 multipart/form-data |`, ); lines.push( - `| 工作流尚未发布 | 仅已发布且存在发布快照的工作流可通过 API 调用 |`, + `| 400 | 40012 | Multipart 缺少或无法解析 boundary;删除手工设置的顶层 Content-Type |`, + ); + lines.push(`| 400 | 40013 | 缺少 metadata Part |`); + lines.push(`| 400 | 40014 | metadata Part 不是有效 JSON |`); + lines.push(`| 400 | 40015 | metadata.id 缺失 |`); + lines.push(`| 400 | 40016 | files.<参数名> 与开始节点文件参数不匹配 |`); + lines.push( + `| 400 | 40017 | 其他工作流运行参数不合法,例如文件 URL 无法识别文件名或扩展名 |`, + ); + lines.push(`| 401 | 40101 | 缺少 ApiKey 请求头 |`); + lines.push(`| 401 | 40102 / 40103 | ApiKey 无效、禁用或过期 |`); + lines.push(`| 403 | 40301 / 40302 | 缺少接口权限或工作流调用权限 |`); + lines.push(`| 404 | 40401 | 工作流不存在、未发布或不可公开调用 |`); + lines.push(`| 404 | 40402 | 执行记录不存在、过期或不可访问 |`); + lines.push(`| 409 | 40901 | 当前执行状态不允许恢复 |`); + lines.push(`| 413 | 41301 | 文件、文件数量或请求总量超限 |`); + lines.push(`| 415 | 41501 | 顶层 Content-Type 缺失或不支持 |`); + lines.push(`| 415 | 41502 | metadata Part 未使用 application/json |`); + lines.push(`| 500 | 50001 | 服务端内部错误;携带 requestId 联系管理员 |`); + lines.push(`| 503 | 50301 | 文件存储暂时不可用,可稍后重试 |`); + lines.push(``); + lines.push( + `Hoppscotch 等工具若显示自动生成的 \`multipart/form-data\` 请求头,请保持自动模式,不要覆盖该请求头;覆盖后通常会丢失 boundary。`, ); return lines.join('\n'); }); +/* eslint-enable unicorn/prefer-single-call */ async function submitPublishAction(row: any) { if ( @@ -1189,11 +1363,26 @@ function handleHeaderButtonClick(data: any) { /> +
diff --git a/pom.xml b/pom.xml index ade1dee1..0c7f54df 100644 --- a/pom.xml +++ b/pom.xml @@ -46,7 +46,7 @@ 2.4 2.2.1 3.16.1 - 8.5.2 + 8.5.12 2.19.4 4.1.130.Final 7.9.1