feat: 完善工作流 Public API 调用能力
- 支持 JSON 文件 URL 简写与 Multipart 单请求文件上传 - 完善执行拓扑、枚举状态、节点名称、恢复校验和安全错误响应 - 增加临时上传生命周期清理并升级 MinIO SDK - 重构工作流接口调用说明弹窗的扁平响应式布局
This commit is contained in:
@@ -5,9 +5,12 @@ import cn.dev33.satoken.stp.StpUtil;
|
||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.multipart.MultipartHttpServletRequest;
|
||||
import tech.easyflow.approval.annotation.RequirePublishedAccess;
|
||||
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
|
||||
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
|
||||
@@ -16,6 +19,8 @@ import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
||||
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
|
||||
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiPreparedUpload;
|
||||
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadLifecycleService;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.ai.entity.WorkflowExecResult;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
@@ -29,12 +34,22 @@ import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
import tech.easyflow.publicapi.dto.PublicWorkflowInfo;
|
||||
import tech.easyflow.publicapi.dto.PublicWorkflowRunMetadata;
|
||||
import tech.easyflow.publicapi.dto.PublicWorkflowRunResult;
|
||||
import tech.easyflow.publicapi.dto.PublicWorkflowChainStatus;
|
||||
import tech.easyflow.publicapi.dto.PublicWorkflowTopology;
|
||||
import tech.easyflow.publicapi.service.WorkflowApiMultipartParameterMapper;
|
||||
import tech.easyflow.publicapi.service.PublicWorkflowTopologyService;
|
||||
import tech.easyflow.publicapi.service.PublicWorkflowStatusSanitizer;
|
||||
import tech.easyflow.system.entity.SysApiKey;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* 工作流
|
||||
@@ -57,23 +72,32 @@ public class PublicWorkflowController {
|
||||
private WorkflowApiPermissionService workflowApiPermissionService;
|
||||
@Resource
|
||||
private WorkflowExecResultService workflowExecResultService;
|
||||
@Resource
|
||||
private WorkflowApiUploadLifecycleService workflowApiUploadLifecycleService;
|
||||
@Resource
|
||||
private PublicWorkflowTopologyService publicWorkflowTopologyService;
|
||||
@Resource
|
||||
private PublicWorkflowStatusSanitizer publicWorkflowStatusSanitizer;
|
||||
@Resource
|
||||
private WorkflowApiMultipartParameterMapper
|
||||
workflowApiMultipartParameterMapper;
|
||||
|
||||
/**
|
||||
* 通过id或别名获取工作流详情
|
||||
*
|
||||
* @param key id或者别名
|
||||
* @return 工作流详情
|
||||
* @return 工作流安全基础信息
|
||||
*/
|
||||
@GetMapping(value = "/getByIdOrAlias")
|
||||
@RequirePublishedAccess(resourceType = "WORKFLOW", idExpr = "#key", denyMessage = "工作流尚未发布")
|
||||
public Result<Workflow> getByIdOrAlias(
|
||||
public Result<PublicWorkflowInfo> getByIdOrAlias(
|
||||
@RequestParam
|
||||
@NotBlank(message = "key不能为空") String key,
|
||||
HttpServletRequest request) {
|
||||
workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
|
||||
Workflow workflow = workflowService.getPublishedDetail(key);
|
||||
assertStrictPublishedWorkflow(workflow);
|
||||
return Result.ok(workflow);
|
||||
return Result.ok(PublicWorkflowInfo.from(workflow));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,38 +126,116 @@ public class PublicWorkflowController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行工作流 - v2
|
||||
* 使用 JSON 参数异步运行已发布工作流。
|
||||
*
|
||||
* @param metadata 工作流 ID 与运行变量
|
||||
* @param request Servlet 请求
|
||||
* @return 保留执行 ID 字符串并附带工作流拓扑的响应
|
||||
*/
|
||||
@PostMapping("/runAsync")
|
||||
@RequirePublishedAccess(resourceType = "WORKFLOW", idExpr = "#id", denyMessage = "工作流尚未发布")
|
||||
public Result<String> runAsync(@JsonBody(value = "id", required = true) BigInteger id,
|
||||
@JsonBody("variables") Map<String, Object> variables,
|
||||
HttpServletRequest request) {
|
||||
@PostMapping(
|
||||
value = "/runAsync",
|
||||
consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
@RequirePublishedAccess(
|
||||
resourceType = "WORKFLOW",
|
||||
idExpr = "#metadata.id",
|
||||
denyMessage = "工作流尚未发布")
|
||||
public PublicWorkflowRunResult runAsync(
|
||||
@Valid @RequestBody PublicWorkflowRunMetadata metadata,
|
||||
HttpServletRequest request) {
|
||||
SysApiKey apiKey = workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
|
||||
if (variables == null) {
|
||||
variables = new HashMap<>();
|
||||
Workflow workflow = loadExecutableWorkflow(metadata.getId());
|
||||
PublicWorkflowTopology topology =
|
||||
publicWorkflowTopologyService.resolve(workflow);
|
||||
Map<String, Object> normalized =
|
||||
workflowRunningParameterResolver.normalizeRuntimeVariables(
|
||||
workflow.getContent(),
|
||||
metadata.getVariables());
|
||||
return executePublishedWorkflow(
|
||||
workflow,
|
||||
normalized,
|
||||
apiKey,
|
||||
topology,
|
||||
null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过单个 multipart 请求上传文件并异步运行工作流。
|
||||
*
|
||||
* <p>{@code metadata} Part 使用 JSON;工作流文件 Part 使用
|
||||
* {@code files.<开始节点文件参数名>},同名 Part 可重复上传多个文件。</p>
|
||||
*
|
||||
* @param metadata 工作流 ID 与普通运行变量
|
||||
* @param multipartRequest multipart 请求
|
||||
* @param request Servlet 请求
|
||||
* @return 保留执行 ID 字符串并附带工作流拓扑的响应
|
||||
*/
|
||||
@PostMapping(
|
||||
value = "/runAsync",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@RequirePublishedAccess(
|
||||
resourceType = "WORKFLOW",
|
||||
idExpr = "#metadata.id",
|
||||
denyMessage = "工作流尚未发布")
|
||||
public PublicWorkflowRunResult runAsyncMultipart(
|
||||
@Valid
|
||||
@RequestPart("metadata")
|
||||
PublicWorkflowRunMetadata metadata,
|
||||
MultipartHttpServletRequest multipartRequest,
|
||||
HttpServletRequest request) {
|
||||
SysApiKey apiKey =
|
||||
workflowApiPermissionService.assertWorkflowApi(
|
||||
request.getHeader("ApiKey"),
|
||||
request.getRequestURI());
|
||||
Workflow workflow = loadExecutableWorkflow(metadata.getId());
|
||||
PublicWorkflowTopology topology =
|
||||
publicWorkflowTopologyService.resolve(workflow);
|
||||
Map<String, List<MultipartFile>> fileParts =
|
||||
workflowApiMultipartParameterMapper.map(
|
||||
multipartRequest.getMultiFileMap());
|
||||
WorkflowApiPreparedUpload preparedUpload =
|
||||
workflowApiUploadLifecycleService.prepare(
|
||||
workflow.getContent(),
|
||||
metadata.getVariables(),
|
||||
fileParts);
|
||||
try {
|
||||
return executePublishedWorkflow(
|
||||
workflow,
|
||||
preparedUpload.getVariables(),
|
||||
apiKey,
|
||||
topology,
|
||||
executeId ->
|
||||
workflowApiUploadLifecycleService.bindExecution(
|
||||
preparedUpload.getRequestId(),
|
||||
executeId));
|
||||
} catch (RuntimeException | Error error) {
|
||||
try {
|
||||
workflowApiUploadLifecycleService.abort(
|
||||
preparedUpload.getRequestId());
|
||||
} catch (RuntimeException cleanupError) {
|
||||
error.addSuppressed(cleanupError);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
Workflow workflow = workflowService.getPublishedById(id);
|
||||
assertStrictPublishedWorkflow(workflow);
|
||||
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
|
||||
variables = workflowRunningParameterResolver.normalizeRuntimeVariables(workflow.getContent(), variables);
|
||||
variables.put(Constants.LOGIN_USER_KEY, buildApiKeyLoginAccount(apiKey));
|
||||
variables.put(WorkFlowUtil.CREATED_KEY_MEMORY_KEY, WorkFlowUtil.API_KEY);
|
||||
String executeId = chainExecutor.executeAsync(PublishedWorkflowDefinitionIds.published(id.toString()), variables);
|
||||
return Result.ok(executeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工作流运行状态 - v2
|
||||
*/
|
||||
@PostMapping("/getChainStatus")
|
||||
public Result<ChainInfo> getChainStatus(@JsonBody(value = "executeId") String executeId,
|
||||
@JsonBody("nodes") List<NodeInfo> nodes,
|
||||
HttpServletRequest request) {
|
||||
public Result<PublicWorkflowChainStatus> getChainStatus(
|
||||
@JsonBody(value = "executeId") String executeId,
|
||||
@JsonBody("nodes") List<NodeInfo> nodes,
|
||||
HttpServletRequest request) {
|
||||
SysApiKey apiKey = workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
|
||||
assertApiKeyExecutionOwnership(apiKey, executeId);
|
||||
ChainInfo res = tinyFlowService.getChainStatus(executeId, nodes);
|
||||
return Result.ok(res);
|
||||
if (res == null) {
|
||||
throw new BusinessException(
|
||||
404,
|
||||
40402,
|
||||
"执行记录不存在、已过期或不可访问");
|
||||
}
|
||||
return Result.ok(publicWorkflowStatusSanitizer.sanitize(res));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,7 +248,14 @@ public class PublicWorkflowController {
|
||||
SysApiKey apiKey = workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
|
||||
WorkflowExecResult execResult = assertApiKeyExecutionOwnership(apiKey, executeId);
|
||||
assertWorkflowExecutionResumable(execResult);
|
||||
chainExecutor.resumeAsync(executeId, confirmParams);
|
||||
if (!chainExecutor.resumeAsyncIfSuspended(
|
||||
executeId,
|
||||
confirmParams)) {
|
||||
throw new BusinessException(
|
||||
409,
|
||||
40901,
|
||||
"当前执行状态不可恢复,仅暂停中的工作流允许恢复");
|
||||
}
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@@ -160,7 +269,10 @@ public class PublicWorkflowController {
|
||||
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
|
||||
Map<String, Object> res = workflowRunningParameterResolver.buildRunningParametersView(workflow);
|
||||
if (res == null) {
|
||||
return Result.fail(2, "节点配置错误,请检查! ");
|
||||
throw new BusinessException(
|
||||
500,
|
||||
50001,
|
||||
"工作流运行参数配置不可用");
|
||||
}
|
||||
return Result.ok(res);
|
||||
}
|
||||
@@ -181,6 +293,57 @@ public class PublicWorkflowController {
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载并校验可执行的已发布工作流。
|
||||
*
|
||||
* @param id 工作流 ID
|
||||
* @return 已发布工作流视图
|
||||
*/
|
||||
private Workflow loadExecutableWorkflow(BigInteger id) {
|
||||
Workflow workflow = workflowService.getPublishedById(id);
|
||||
assertStrictPublishedWorkflow(workflow);
|
||||
workflowCheckService.checkOrThrow(
|
||||
workflow.getContent(),
|
||||
WorkflowCheckStage.PRE_EXECUTE,
|
||||
workflow.getId());
|
||||
return workflow;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用统一身份和发布快照启动工作流。
|
||||
*
|
||||
* @param workflow 已发布工作流
|
||||
* @param variables 已归一化运行变量
|
||||
* @param apiKey API Key 实体
|
||||
* @param topology 对外公开拓扑
|
||||
* @param beforeStart 首个节点启动前回调
|
||||
* @return 执行响应
|
||||
*/
|
||||
private PublicWorkflowRunResult executePublishedWorkflow(
|
||||
Workflow workflow,
|
||||
Map<String, Object> variables,
|
||||
SysApiKey apiKey,
|
||||
PublicWorkflowTopology topology,
|
||||
Consumer<String> beforeStart) {
|
||||
Map<String, Object> executionVariables =
|
||||
new LinkedHashMap<>();
|
||||
if (variables != null) {
|
||||
executionVariables.putAll(variables);
|
||||
}
|
||||
executionVariables.put(
|
||||
Constants.LOGIN_USER_KEY,
|
||||
buildApiKeyLoginAccount(apiKey));
|
||||
executionVariables.put(
|
||||
WorkFlowUtil.CREATED_KEY_MEMORY_KEY,
|
||||
WorkFlowUtil.API_KEY);
|
||||
String executeId = chainExecutor.executeAsync(
|
||||
PublishedWorkflowDefinitionIds.published(
|
||||
workflow.getId().toString()),
|
||||
executionVariables,
|
||||
beforeStart);
|
||||
return PublicWorkflowRunResult.success(executeId, topology);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验工作流 Public API 只能访问严格已发布且存在发布快照的工作流。
|
||||
*
|
||||
@@ -189,7 +352,10 @@ public class PublicWorkflowController {
|
||||
private void assertStrictPublishedWorkflow(Workflow workflow) {
|
||||
if (workflow == null || !PublishStatus.PUBLISHED.getCode().equals(workflow.getPublishStatus())
|
||||
|| workflow.getPublishedSnapshotJson() == null || workflow.getPublishedSnapshotJson().isEmpty()) {
|
||||
throw new BusinessException("工作流尚未发布");
|
||||
throw new BusinessException(
|
||||
404,
|
||||
40401,
|
||||
"工作流不存在或当前不可公开调用");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,17 +368,26 @@ public class PublicWorkflowController {
|
||||
*/
|
||||
private WorkflowExecResult assertApiKeyExecutionOwnership(SysApiKey apiKey, String executeId) {
|
||||
if (executeId == null || executeId.isBlank()) {
|
||||
throw new BusinessException("执行ID不能为空");
|
||||
throw new BusinessException(
|
||||
400,
|
||||
40017,
|
||||
"executeId 不能为空");
|
||||
}
|
||||
WorkflowExecResult execResult = workflowExecResultService.getByExecKey(executeId);
|
||||
if (execResult == null) {
|
||||
throw new BusinessException("工作流执行记录不存在,请稍后重试");
|
||||
throw new BusinessException(
|
||||
404,
|
||||
40402,
|
||||
"工作流执行记录不存在、已过期或不可访问");
|
||||
}
|
||||
if (!WorkFlowUtil.API_KEY.equals(execResult.getCreatedKey())
|
||||
|| apiKey == null
|
||||
|| apiKey.getId() == null
|
||||
|| !String.valueOf(apiKey.getId()).equals(execResult.getCreatedBy())) {
|
||||
throw new BusinessException("无权限访问当前工作流执行记录");
|
||||
throw new BusinessException(
|
||||
404,
|
||||
40402,
|
||||
"工作流执行记录不存在、已过期或不可访问");
|
||||
}
|
||||
return execResult;
|
||||
}
|
||||
@@ -224,12 +399,18 @@ public class PublicWorkflowController {
|
||||
*/
|
||||
private void assertWorkflowExecutionResumable(WorkflowExecResult execResult) {
|
||||
if (execResult == null || execResult.getWorkflowId() == null) {
|
||||
throw new BusinessException("工作流执行记录不存在,请稍后重试");
|
||||
throw new BusinessException(
|
||||
404,
|
||||
40402,
|
||||
"工作流执行记录不存在、已过期或不可访问");
|
||||
}
|
||||
Workflow workflow = workflowService.getById(execResult.getWorkflowId());
|
||||
if (workflow == null || !PublishStatus.PUBLISHED.getCode().equals(workflow.getPublishStatus())
|
||||
|| workflow.getPublishedSnapshotJson() == null || workflow.getPublishedSnapshotJson().isEmpty()) {
|
||||
throw new BusinessException("工作流已下线或不可恢复执行");
|
||||
throw new BusinessException(
|
||||
409,
|
||||
40901,
|
||||
"工作流已下线或当前执行状态不可恢复");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package tech.easyflow.publicapi.dto;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Public API 可安全返回的机器可读错误详情。
|
||||
*/
|
||||
public class PublicApiErrorDetail implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final String requestId;
|
||||
private final String location;
|
||||
private final String field;
|
||||
private final String actual;
|
||||
private final List<String> expected;
|
||||
private final boolean retryable;
|
||||
|
||||
/**
|
||||
* 创建公共错误详情。
|
||||
*
|
||||
* @param requestId 请求关联标识
|
||||
* @param location 错误位置
|
||||
* @param field 错误字段
|
||||
* @param actual 经脱敏和限长的实际值
|
||||
* @param expected 合法值或格式
|
||||
* @param retryable 是否适合直接重试
|
||||
*/
|
||||
public PublicApiErrorDetail(
|
||||
String requestId,
|
||||
String location,
|
||||
String field,
|
||||
String actual,
|
||||
List<String> expected,
|
||||
boolean retryable) {
|
||||
this.requestId = requestId;
|
||||
this.location = location;
|
||||
this.field = field;
|
||||
this.actual = actual;
|
||||
this.expected = expected == null
|
||||
? List.of()
|
||||
: List.copyOf(expected);
|
||||
this.retryable = retryable;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取请求关联标识。
|
||||
*
|
||||
* @return 请求关联标识
|
||||
*/
|
||||
public String getRequestId() {
|
||||
return requestId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取错误位置。
|
||||
*
|
||||
* @return 错误位置
|
||||
*/
|
||||
public String getLocation() {
|
||||
return location;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取错误字段。
|
||||
*
|
||||
* @return 错误字段
|
||||
*/
|
||||
public String getField() {
|
||||
return field;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取安全实际值。
|
||||
*
|
||||
* @return 实际值
|
||||
*/
|
||||
public String getActual() {
|
||||
return actual;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取期望值。
|
||||
*
|
||||
* @return 不可变期望值列表
|
||||
*/
|
||||
public List<String> getExpected() {
|
||||
return expected;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否适合直接重试。
|
||||
*
|
||||
* @return 是否可重试
|
||||
*/
|
||||
public boolean isRetryable() {
|
||||
return retryable;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package tech.easyflow.publicapi.dto;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 工作流 Public API 执行状态。
|
||||
*
|
||||
* @param executeId 执行 ID
|
||||
* @param status 可读工作流状态
|
||||
* @param terminal 是否已经进入终态
|
||||
* @param message 安全错误消息
|
||||
* @param result 工作流执行结果
|
||||
* @param nodes 节点 ID 到节点状态的映射
|
||||
* @param error 安全错误对象
|
||||
*/
|
||||
public record PublicWorkflowChainStatus(
|
||||
String executeId,
|
||||
PublicWorkflowExecutionStatus status,
|
||||
boolean terminal,
|
||||
String message,
|
||||
Map<String, Object> result,
|
||||
Map<String, PublicWorkflowNodeStatus> nodes,
|
||||
PublicWorkflowStatusError error) implements Serializable {
|
||||
|
||||
/**
|
||||
* 创建不可变公共执行状态。
|
||||
*/
|
||||
public PublicWorkflowChainStatus {
|
||||
nodes = nodes == null
|
||||
? Map.of()
|
||||
: Collections.unmodifiableMap(
|
||||
new LinkedHashMap<>(nodes));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package tech.easyflow.publicapi.dto;
|
||||
|
||||
import com.easyagents.flow.core.chain.Parameter;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 工作流 Public API 的节点执行状态。
|
||||
*
|
||||
* @param nodeId 节点 ID
|
||||
* @param nodeName 节点名称
|
||||
* @param status 可读节点状态
|
||||
* @param message 安全错误消息
|
||||
* @param result 节点执行结果
|
||||
* @param suspendForParameters 暂停时等待补充的参数
|
||||
*/
|
||||
public record PublicWorkflowNodeStatus(
|
||||
String nodeId,
|
||||
String nodeName,
|
||||
PublicWorkflowExecutionStatus status,
|
||||
String message,
|
||||
Map<String, Object> result,
|
||||
List<Parameter> suspendForParameters) implements Serializable {
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package tech.easyflow.publicapi.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Public Workflow API JSON 与 Multipart 执行元数据。
|
||||
*/
|
||||
public class PublicWorkflowRunMetadata {
|
||||
|
||||
@NotNull(message = "metadata.id 不能为空")
|
||||
private BigInteger id;
|
||||
private Map<String, Object> variables = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* 获取工作流 ID。
|
||||
*
|
||||
* @return 工作流 ID
|
||||
*/
|
||||
public BigInteger getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置工作流 ID。
|
||||
*
|
||||
* @param id 工作流 ID
|
||||
*/
|
||||
public void setId(BigInteger id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取普通运行变量。
|
||||
*
|
||||
* @return 普通运行变量
|
||||
*/
|
||||
public Map<String, Object> getVariables() {
|
||||
return variables;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置普通运行变量。
|
||||
*
|
||||
* @param variables 普通运行变量
|
||||
*/
|
||||
public void setVariables(Map<String, Object> variables) {
|
||||
this.variables = variables == null
|
||||
? new LinkedHashMap<>()
|
||||
: new LinkedHashMap<>(variables);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package tech.easyflow.publicapi.dto;
|
||||
|
||||
import tech.easyflow.common.constant.enums.EnumRes;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
|
||||
/**
|
||||
* Public Workflow API 执行响应。
|
||||
*
|
||||
* <p>继承原有 {@link Result} 并继续把执行 ID 放在 {@code data},
|
||||
* 新增 {@code workflow} 区块以保持旧调用方兼容。</p>
|
||||
*/
|
||||
public class PublicWorkflowRunResult extends Result<String> {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private PublicWorkflowTopology workflow;
|
||||
|
||||
/**
|
||||
* 创建成功响应。
|
||||
*
|
||||
* @param executeId 工作流执行 ID
|
||||
* @param workflow 已发布工作流拓扑
|
||||
* @return 成功响应
|
||||
*/
|
||||
public static PublicWorkflowRunResult success(
|
||||
String executeId,
|
||||
PublicWorkflowTopology workflow) {
|
||||
PublicWorkflowRunResult result = new PublicWorkflowRunResult();
|
||||
result.setErrorCode(EnumRes.SUCCESS.getCode());
|
||||
result.setMessage(EnumRes.SUCCESS.getMsg());
|
||||
result.setData(executeId);
|
||||
result.setWorkflow(workflow);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已发布工作流拓扑。
|
||||
*
|
||||
* @return 工作流拓扑
|
||||
*/
|
||||
public PublicWorkflowTopology getWorkflow() {
|
||||
return workflow;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置已发布工作流拓扑。
|
||||
*
|
||||
* @param workflow 工作流拓扑
|
||||
*/
|
||||
public void setWorkflow(PublicWorkflowTopology workflow) {
|
||||
this.workflow = workflow;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package tech.easyflow.publicapi.dto;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Public Workflow API 对外公开的安全拓扑视图。
|
||||
*
|
||||
* @param workflowId 工作流 ID
|
||||
* @param alias 工作流别名
|
||||
* @param title 工作流标题
|
||||
* @param description 工作流描述
|
||||
* @param revision 发布内容修订号
|
||||
* @param publishedAt 发布时间,ISO-8601 格式
|
||||
* @param nodes 按稳定拓扑顺序排列的节点
|
||||
* @param edges 按发布定义顺序排列的边
|
||||
* @param topologicalOrder 节点 ID 的稳定拓扑顺序
|
||||
* @param topologyLevels 考虑循环体完成屏障后的可并行拓扑层级
|
||||
* @param hasCycle 发布图中是否存在环
|
||||
* @param unresolvedNodeIds 受环路影响而无法进入标准拓扑序的节点 ID
|
||||
*/
|
||||
public record PublicWorkflowTopology(
|
||||
String workflowId,
|
||||
String alias,
|
||||
String title,
|
||||
String description,
|
||||
Integer revision,
|
||||
String publishedAt,
|
||||
List<Node> nodes,
|
||||
List<Edge> edges,
|
||||
List<String> topologicalOrder,
|
||||
List<List<String>> topologyLevels,
|
||||
boolean hasCycle,
|
||||
List<String> unresolvedNodeIds) implements Serializable {
|
||||
|
||||
/**
|
||||
* 创建不可变工作流拓扑。
|
||||
*/
|
||||
public PublicWorkflowTopology {
|
||||
nodes = immutable(nodes);
|
||||
edges = immutable(edges);
|
||||
topologicalOrder = immutable(topologicalOrder);
|
||||
topologyLevels = topologyLevels == null
|
||||
? List.of()
|
||||
: topologyLevels.stream()
|
||||
.map(PublicWorkflowTopology::immutable)
|
||||
.toList();
|
||||
unresolvedNodeIds = immutable(unresolvedNodeIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作流公开节点。
|
||||
*
|
||||
* @param nodeId 节点 ID
|
||||
* @param nodeType 节点类型
|
||||
* @param nodeName 节点名称
|
||||
* @param description 节点描述
|
||||
* @param parentNodeId 父级容器节点 ID
|
||||
* @param definitionIndex 节点在发布定义中的位置
|
||||
* @param topologyIndex 节点在稳定拓扑序中的位置
|
||||
* @param topologyLevel 节点所在拓扑层级
|
||||
* @param inDegree 发布定义中的原始入度
|
||||
* @param outDegree 发布定义中的原始出度
|
||||
* @param startNode 是否开始节点
|
||||
* @param endNode 是否结束节点
|
||||
* @param predecessorNodeIds 直接前驱节点 ID
|
||||
* @param successorNodeIds 直接后继节点 ID
|
||||
* @param incomingEdgeIds 入边 ID
|
||||
* @param outgoingEdgeIds 出边 ID
|
||||
* @param inputParameters 节点输入参数元数据
|
||||
* @param outputParameters 节点输出参数元数据
|
||||
*/
|
||||
public record Node(
|
||||
String nodeId,
|
||||
String nodeType,
|
||||
String nodeName,
|
||||
String description,
|
||||
String parentNodeId,
|
||||
int definitionIndex,
|
||||
int topologyIndex,
|
||||
int topologyLevel,
|
||||
int inDegree,
|
||||
int outDegree,
|
||||
boolean startNode,
|
||||
boolean endNode,
|
||||
List<String> predecessorNodeIds,
|
||||
List<String> successorNodeIds,
|
||||
List<String> incomingEdgeIds,
|
||||
List<String> outgoingEdgeIds,
|
||||
List<Parameter> inputParameters,
|
||||
List<Parameter> outputParameters) implements Serializable {
|
||||
|
||||
/**
|
||||
* 创建不可变公开节点。
|
||||
*/
|
||||
public Node {
|
||||
predecessorNodeIds = immutable(predecessorNodeIds);
|
||||
successorNodeIds = immutable(successorNodeIds);
|
||||
incomingEdgeIds = immutable(incomingEdgeIds);
|
||||
outgoingEdgeIds = immutable(outgoingEdgeIds);
|
||||
inputParameters = immutable(inputParameters);
|
||||
outputParameters = immutable(outputParameters);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作流公开边。
|
||||
*
|
||||
* @param edgeId 边 ID
|
||||
* @param edgeType 边类型
|
||||
* @param label 边展示名称
|
||||
* @param sourceNodeId 源节点 ID
|
||||
* @param targetNodeId 目标节点 ID
|
||||
* @param sourceHandle 源连接点
|
||||
* @param targetHandle 目标连接点
|
||||
* @param parentNodeId 所属父级容器节点 ID
|
||||
* @param definitionIndex 边在发布定义中的位置
|
||||
* @param dangling 是否引用了不存在的节点
|
||||
*/
|
||||
public record Edge(
|
||||
String edgeId,
|
||||
String edgeType,
|
||||
String label,
|
||||
String sourceNodeId,
|
||||
String targetNodeId,
|
||||
String sourceHandle,
|
||||
String targetHandle,
|
||||
String parentNodeId,
|
||||
int definitionIndex,
|
||||
boolean dangling) implements Serializable {
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点输入或输出参数的安全元数据。
|
||||
*
|
||||
* @param parameterId 参数 ID
|
||||
* @param name 参数名
|
||||
* @param label 展示名称
|
||||
* @param dataType 数据类型
|
||||
* @param contentType 内容类型
|
||||
* @param required 是否必填
|
||||
* @param description 参数说明
|
||||
* @param multipartPartName 开始节点文件参数对应的 multipart Part 名
|
||||
*/
|
||||
public record Parameter(
|
||||
String parameterId,
|
||||
String name,
|
||||
String label,
|
||||
String dataType,
|
||||
String contentType,
|
||||
boolean required,
|
||||
String description,
|
||||
String multipartPartName) implements Serializable {
|
||||
}
|
||||
|
||||
/**
|
||||
* 将列表转换为不可变副本。
|
||||
*
|
||||
* @param source 原列表
|
||||
* @param <T> 元素类型
|
||||
* @return 不可变列表
|
||||
*/
|
||||
private static <T> List<T> immutable(List<T> source) {
|
||||
return source == null ? List.of() : List.copyOf(source);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
package tech.easyflow.publicapi.error;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.ErrorResponse;
|
||||
import org.springframework.web.HttpMediaTypeNotSupportedException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||
import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
||||
import org.springframework.web.multipart.MultipartException;
|
||||
import org.springframework.web.multipart.support.MissingServletRequestPartException;
|
||||
import tech.easyflow.common.web.error.RequestErrorProfile;
|
||||
import tech.easyflow.common.web.error.RequestIdContext;
|
||||
import tech.easyflow.common.web.error.WebErrorMapping;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.publicapi.dto.PublicApiErrorDetail;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 工作流 Public API 的稳定业务错误翻译及 {@code runAsync} 请求格式规则。
|
||||
*/
|
||||
public final class WorkflowRunAsyncErrorProfile
|
||||
implements RequestErrorProfile {
|
||||
|
||||
/** 共享无状态实例。 */
|
||||
public static final WorkflowRunAsyncErrorProfile INSTANCE =
|
||||
new WorkflowRunAsyncErrorProfile();
|
||||
|
||||
private static final Pattern MULTIPART_BOUNDARY_PATTERN =
|
||||
Pattern.compile(
|
||||
"(?:^|;)\\s*boundary\\s*=\\s*(?:\"[^\"]+\"|[^;\\s]+)",
|
||||
Pattern.CASE_INSENSITIVE);
|
||||
private static final List<String> RUN_CONTENT_TYPES = List.of(
|
||||
MediaType.APPLICATION_JSON_VALUE,
|
||||
MediaType.MULTIPART_FORM_DATA_VALUE);
|
||||
private static final int MAX_ACTUAL_LENGTH = 256;
|
||||
|
||||
private WorkflowRunAsyncErrorProfile() {
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public WebErrorMapping map(
|
||||
HttpServletRequest request,
|
||||
Exception exception) {
|
||||
if (!isRunAsyncRequest(request)) {
|
||||
if (exception instanceof BusinessException businessException) {
|
||||
return business(request, businessException);
|
||||
}
|
||||
if (isClientRequestException(exception)) {
|
||||
return null;
|
||||
}
|
||||
return internalError(request);
|
||||
}
|
||||
String requestContentType = request.getContentType();
|
||||
if (exception instanceof MaxUploadSizeExceededException) {
|
||||
return error(
|
||||
request,
|
||||
413,
|
||||
41301,
|
||||
"上传文件、文件数量或请求总量超过限制",
|
||||
"body",
|
||||
"files",
|
||||
null,
|
||||
List.of(),
|
||||
false);
|
||||
}
|
||||
if (exception instanceof MissingServletRequestPartException missingPart) {
|
||||
return missingPart(request, missingPart);
|
||||
}
|
||||
if (exception instanceof MethodArgumentNotValidException invalid) {
|
||||
return validation(request, invalid);
|
||||
}
|
||||
if (exception instanceof HttpMediaTypeNotSupportedException unsupported) {
|
||||
return unsupportedMediaType(
|
||||
request,
|
||||
requestContentType,
|
||||
unsupported);
|
||||
}
|
||||
if (exception instanceof HttpMessageNotReadableException) {
|
||||
return unreadableBody(request, requestContentType);
|
||||
}
|
||||
if (exception instanceof MultipartException) {
|
||||
if (isMultipartFormData(requestContentType)
|
||||
&& !hasBoundary(requestContentType)) {
|
||||
return error(
|
||||
request,
|
||||
400,
|
||||
40012,
|
||||
"multipart/form-data 缺少 boundary;请删除手工设置的 Content-Type,让客户端自动生成",
|
||||
"header",
|
||||
"Content-Type",
|
||||
requestContentType,
|
||||
List.of("multipart/form-data; boundary=<客户端自动生成>"),
|
||||
false);
|
||||
}
|
||||
return error(
|
||||
request,
|
||||
400,
|
||||
40012,
|
||||
"multipart/form-data 请求无法解析,请检查 boundary 与各 Part 格式",
|
||||
"header",
|
||||
"Content-Type",
|
||||
requestContentType,
|
||||
List.of("multipart/form-data; boundary=<客户端自动生成>"),
|
||||
false);
|
||||
}
|
||||
if (exception instanceof BusinessException businessException) {
|
||||
return business(request, businessException);
|
||||
}
|
||||
return internalError(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断非 runAsync 接口异常是否已具有明确的客户端错误语义。
|
||||
*
|
||||
* @param exception 原始异常
|
||||
* @return 是否应交由全局 4xx 规则处理
|
||||
*/
|
||||
private boolean isClientRequestException(Exception exception) {
|
||||
if (exception instanceof ConstraintViolationException
|
||||
|| exception instanceof MethodArgumentNotValidException
|
||||
|| exception instanceof MethodArgumentTypeMismatchException
|
||||
|| exception instanceof HttpMessageNotReadableException) {
|
||||
return true;
|
||||
}
|
||||
return exception instanceof ErrorResponse errorResponse
|
||||
&& errorResponse.getStatusCode().is4xxClientError();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建不泄露内部实现的未知服务端错误。
|
||||
*
|
||||
* @param request 当前请求
|
||||
* @return 50001 错误映射
|
||||
*/
|
||||
private WebErrorMapping internalError(
|
||||
HttpServletRequest request) {
|
||||
return error(
|
||||
request,
|
||||
500,
|
||||
50001,
|
||||
"服务暂时不可用,请稍后重试",
|
||||
"dependency",
|
||||
null,
|
||||
null,
|
||||
List.of(),
|
||||
false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 翻译缺失的 Multipart Part。
|
||||
*
|
||||
* @param request 当前请求
|
||||
* @param exception 缺失 Part 异常
|
||||
* @return 错误映射
|
||||
*/
|
||||
private WebErrorMapping missingPart(
|
||||
HttpServletRequest request,
|
||||
MissingServletRequestPartException exception) {
|
||||
String partName = exception.getRequestPartName();
|
||||
if ("metadata".equals(partName)) {
|
||||
return error(
|
||||
request,
|
||||
400,
|
||||
40013,
|
||||
"当前使用 multipart/form-data,但缺少 metadata Part;如直接提交 JSON,请将 Content-Type 设置为 application/json",
|
||||
"part",
|
||||
"metadata",
|
||||
null,
|
||||
List.of("application/json"),
|
||||
false);
|
||||
}
|
||||
return error(
|
||||
request,
|
||||
400,
|
||||
40016,
|
||||
"缺少必要的文件 Part:" + safeActual(partName),
|
||||
"part",
|
||||
partName,
|
||||
null,
|
||||
List.of("files.<开始节点参数名>"),
|
||||
false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 翻译请求 DTO 校验失败。
|
||||
*
|
||||
* @param request 当前请求
|
||||
* @param exception 参数校验异常
|
||||
* @return 错误映射
|
||||
*/
|
||||
private WebErrorMapping validation(
|
||||
HttpServletRequest request,
|
||||
MethodArgumentNotValidException exception) {
|
||||
FieldError idError = exception.getBindingResult()
|
||||
.getFieldErrors("id")
|
||||
.stream()
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (idError != null) {
|
||||
return error(
|
||||
request,
|
||||
400,
|
||||
40015,
|
||||
"metadata.id 不能为空,请传入要执行的工作流 ID",
|
||||
isMultipartFormData(request.getContentType())
|
||||
? "part"
|
||||
: "body",
|
||||
"id",
|
||||
null,
|
||||
List.of("已发布工作流 ID"),
|
||||
false);
|
||||
}
|
||||
FieldError first = exception.getBindingResult()
|
||||
.getFieldError();
|
||||
return error(
|
||||
request,
|
||||
400,
|
||||
40017,
|
||||
first == null || first.getDefaultMessage() == null
|
||||
? "工作流运行参数不合法"
|
||||
: first.getDefaultMessage(),
|
||||
"field",
|
||||
first == null ? null : first.getField(),
|
||||
null,
|
||||
List.of(),
|
||||
false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 翻译不支持的媒体类型。
|
||||
*
|
||||
* @param request 当前请求
|
||||
* @param requestContentType 顶层请求内容类型
|
||||
* @param exception 媒体类型异常
|
||||
* @return 错误映射
|
||||
*/
|
||||
private WebErrorMapping unsupportedMediaType(
|
||||
HttpServletRequest request,
|
||||
String requestContentType,
|
||||
HttpMediaTypeNotSupportedException exception) {
|
||||
if (isMultipartFormData(requestContentType)) {
|
||||
String actual = exception.getContentType() == null
|
||||
? null
|
||||
: exception.getContentType().toString();
|
||||
return error(
|
||||
request,
|
||||
415,
|
||||
41502,
|
||||
"metadata Part 必须使用 application/json;文件 Part 请使用 files.<开始节点参数名>",
|
||||
"part",
|
||||
"metadata",
|
||||
actual,
|
||||
List.of(MediaType.APPLICATION_JSON_VALUE),
|
||||
false);
|
||||
}
|
||||
boolean missing = requestContentType == null
|
||||
|| requestContentType.isBlank();
|
||||
return error(
|
||||
request,
|
||||
415,
|
||||
41501,
|
||||
missing
|
||||
? "缺少 Content-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<String> expected,
|
||||
boolean retryable) {
|
||||
return new WebErrorMapping(
|
||||
status,
|
||||
code,
|
||||
message,
|
||||
new PublicApiErrorDetail(
|
||||
RequestIdContext.get(request),
|
||||
location,
|
||||
field,
|
||||
safeActual(actual),
|
||||
expected,
|
||||
retryable));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断顶层内容类型是否为 Multipart 表单。
|
||||
*
|
||||
* @param contentType 内容类型
|
||||
* @return 是否为 Multipart 表单
|
||||
*/
|
||||
private boolean isMultipartFormData(String contentType) {
|
||||
return contentType != null
|
||||
&& contentType.toLowerCase(Locale.ROOT)
|
||||
.startsWith(MediaType.MULTIPART_FORM_DATA_VALUE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前请求是否为两种媒体类型共用的异步执行入口。
|
||||
*
|
||||
* @param request 当前请求
|
||||
* @return 是否为 runAsync
|
||||
*/
|
||||
private boolean isRunAsyncRequest(HttpServletRequest request) {
|
||||
String uri = request.getRequestURI();
|
||||
return uri != null
|
||||
&& uri.endsWith("/public-api/workflow/runAsync");
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 Multipart 内容类型是否包含非空边界。
|
||||
*
|
||||
* @param contentType 内容类型
|
||||
* @return 是否存在边界
|
||||
*/
|
||||
private boolean hasBoundary(String contentType) {
|
||||
return contentType != null
|
||||
&& MULTIPART_BOUNDARY_PATTERN.matcher(contentType).find();
|
||||
}
|
||||
|
||||
/**
|
||||
* 限制响应中的实际值长度并移除控制字符。
|
||||
*
|
||||
* @param actual 原始实际值
|
||||
* @return 安全值
|
||||
*/
|
||||
private static String safeActual(String actual) {
|
||||
if (actual == null) {
|
||||
return null;
|
||||
}
|
||||
String safe = actual.replaceAll("[\\p{Cntrl}]", " ").trim();
|
||||
return safe.length() <= MAX_ACTUAL_LENGTH
|
||||
? safe
|
||||
: safe.substring(0, MAX_ACTUAL_LENGTH);
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,13 @@ import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.util.ResponseUtil;
|
||||
import tech.easyflow.common.web.error.RequestIdContext;
|
||||
import tech.easyflow.publicapi.dto.PublicApiErrorDetail;
|
||||
import tech.easyflow.system.entity.SysApiKey;
|
||||
import tech.easyflow.system.service.SysApiKeyService;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Public API 访问令牌与接口权限拦截器。
|
||||
*/
|
||||
@@ -36,7 +40,16 @@ public class PublicApiInterceptor implements HandlerInterceptor {
|
||||
String apiKey = request.getHeader("ApiKey");
|
||||
|
||||
if (apiKey == null || apiKey.isBlank()) {
|
||||
Result<Void> failed = Result.fail(401, "密钥不正确");
|
||||
Result<PublicApiErrorDetail> failed = Result.fail(
|
||||
"缺少 ApiKey 请求头",
|
||||
new PublicApiErrorDetail(
|
||||
RequestIdContext.get(request),
|
||||
"auth",
|
||||
"ApiKey",
|
||||
null,
|
||||
List.of("有效的工作流 Public API Key"),
|
||||
false));
|
||||
failed.setErrorCode(40101);
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
ResponseUtil.renderJson(response, failed);
|
||||
return false;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package tech.easyflow.publicapi.service;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
|
||||
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
|
||||
import tech.easyflow.publicapi.dto.PublicWorkflowChainStatus;
|
||||
import tech.easyflow.publicapi.dto.PublicWorkflowExecutionStatus;
|
||||
import tech.easyflow.publicapi.dto.PublicWorkflowNodeStatus;
|
||||
import tech.easyflow.publicapi.dto.PublicWorkflowStatusError;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 将内部工作流执行错误转换为 Public API 安全状态。
|
||||
*/
|
||||
@Service
|
||||
public class PublicWorkflowStatusSanitizer {
|
||||
|
||||
private static final String CHAIN_FAILED_MESSAGE =
|
||||
"工作流执行失败,请检查输入或稍后重试";
|
||||
private static final String NODE_FAILED_MESSAGE =
|
||||
"节点执行失败,请检查输入或稍后重试";
|
||||
|
||||
/**
|
||||
* 复制执行状态并移除异常类名、底层地址和内部错误详情。
|
||||
*
|
||||
* @param source 内部执行状态
|
||||
* @return 可公开状态
|
||||
*/
|
||||
public PublicWorkflowChainStatus sanitize(ChainInfo source) {
|
||||
if (source == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"source must not be null");
|
||||
}
|
||||
PublicWorkflowExecutionStatus chainStatus =
|
||||
PublicWorkflowExecutionStatus.fromChainStatus(
|
||||
source.getStatus());
|
||||
|
||||
Map<String, PublicWorkflowNodeStatus> safeNodes =
|
||||
new LinkedHashMap<>();
|
||||
PublicWorkflowStatusError firstNodeError = null;
|
||||
if (source.getNodes() != null) {
|
||||
for (Map.Entry<String, NodeInfo> entry
|
||||
: source.getNodes().entrySet()) {
|
||||
PublicWorkflowNodeStatus safeNode = copyNode(
|
||||
entry.getValue());
|
||||
safeNodes.put(entry.getKey(), safeNode);
|
||||
if (firstNodeError == null
|
||||
&& StringUtils.hasText(safeNode.message())) {
|
||||
firstNodeError = new PublicWorkflowStatusError(
|
||||
"NODE_EXECUTION_FAILED",
|
||||
safeNode.message(),
|
||||
safeNode.nodeId(),
|
||||
safeNode.nodeName(),
|
||||
isRetryable(safeNode.status()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String message = null;
|
||||
PublicWorkflowStatusError error = null;
|
||||
if (StringUtils.hasText(source.getMessage())) {
|
||||
message = chainMessage(chainStatus);
|
||||
error = new PublicWorkflowStatusError(
|
||||
"WORKFLOW_EXECUTION_FAILED",
|
||||
message,
|
||||
firstNodeError == null
|
||||
? null
|
||||
: firstNodeError.getNodeId(),
|
||||
firstNodeError == null
|
||||
? null
|
||||
: firstNodeError.getNodeName(),
|
||||
isRetryable(chainStatus));
|
||||
} else if (firstNodeError != null) {
|
||||
error = firstNodeError;
|
||||
}
|
||||
return new PublicWorkflowChainStatus(
|
||||
source.getExecuteId(),
|
||||
chainStatus,
|
||||
chainStatus.isTerminal(),
|
||||
message,
|
||||
source.getResult(),
|
||||
safeNodes,
|
||||
error);
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制并脱敏单个节点状态。
|
||||
*
|
||||
* @param source 内部节点状态
|
||||
* @return 安全节点状态
|
||||
*/
|
||||
private PublicWorkflowNodeStatus copyNode(NodeInfo source) {
|
||||
if (source == null) {
|
||||
return new PublicWorkflowNodeStatus(
|
||||
null,
|
||||
null,
|
||||
PublicWorkflowExecutionStatus.UNKNOWN,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
return new PublicWorkflowNodeStatus(
|
||||
source.getNodeId(),
|
||||
source.getNodeName(),
|
||||
PublicWorkflowExecutionStatus.fromNodeStatus(
|
||||
source.getStatus()),
|
||||
StringUtils.hasText(source.getMessage())
|
||||
? NODE_FAILED_MESSAGE
|
||||
: null,
|
||||
source.getResult(),
|
||||
source.getSuspendForParameters());
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据工作流状态生成安全消息。
|
||||
*
|
||||
* @param status 可读状态
|
||||
* @return 安全消息
|
||||
*/
|
||||
private String chainMessage(
|
||||
PublicWorkflowExecutionStatus status) {
|
||||
if (status == PublicWorkflowExecutionStatus.CANCELLED) {
|
||||
return "工作流执行已取消";
|
||||
}
|
||||
return CHAIN_FAILED_MESSAGE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断执行状态是否仍可能由运行时继续处理。
|
||||
*
|
||||
* @param status 可读状态
|
||||
* @return 是否可重试
|
||||
*/
|
||||
private boolean isRetryable(
|
||||
PublicWorkflowExecutionStatus status) {
|
||||
return status == PublicWorkflowExecutionStatus.ERROR;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,684 @@
|
||||
package tech.easyflow.publicapi.service;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONArray;
|
||||
import com.alibaba.fastjson2.JSONException;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.publicapi.dto.PublicWorkflowTopology;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.PriorityQueue;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 从已发布工作流快照生成对外安全的节点拓扑。
|
||||
*/
|
||||
@Service
|
||||
public class PublicWorkflowTopologyService {
|
||||
|
||||
/**
|
||||
* 构建已发布工作流的公开拓扑视图。
|
||||
*
|
||||
* @param workflow 已发布工作流
|
||||
* @return 安全拓扑视图
|
||||
*/
|
||||
public PublicWorkflowTopology resolve(Workflow workflow) {
|
||||
if (workflow == null || !StringUtils.hasText(workflow.getContent())) {
|
||||
throw new BusinessException("已发布工作流缺少拓扑内容");
|
||||
}
|
||||
JSONObject root = parseContent(workflow.getContent());
|
||||
JSONArray rawNodes = root.getJSONArray("nodes");
|
||||
JSONArray rawEdges = root.getJSONArray("edges");
|
||||
|
||||
LinkedHashMap<String, NodeBuilder> nodeById =
|
||||
parseNodes(rawNodes);
|
||||
List<PublicWorkflowTopology.Edge> edges =
|
||||
parseEdges(rawEdges, nodeById);
|
||||
addLoopCompletionBarriers(nodeById);
|
||||
TopologyOrder topology = resolveTopology(nodeById);
|
||||
List<PublicWorkflowTopology.Node> nodes =
|
||||
buildNodes(nodeById, topology);
|
||||
|
||||
return new PublicWorkflowTopology(
|
||||
workflow.getId() == null
|
||||
? null
|
||||
: workflow.getId().toString(),
|
||||
trimToNull(workflow.getAlias()),
|
||||
trimToNull(workflow.getTitle()),
|
||||
trimToNull(workflow.getDescription()),
|
||||
workflow.getRevision(),
|
||||
workflow.getPublishedAt() == null
|
||||
? null
|
||||
: workflow.getPublishedAt()
|
||||
.toInstant()
|
||||
.toString(),
|
||||
nodes,
|
||||
edges,
|
||||
topology.order,
|
||||
topology.levels,
|
||||
!topology.unresolvedNodeIds.isEmpty(),
|
||||
topology.unresolvedNodeIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析工作流 JSON 内容。
|
||||
*
|
||||
* @param content 工作流 JSON
|
||||
* @return 根对象
|
||||
*/
|
||||
private JSONObject parseContent(String content) {
|
||||
try {
|
||||
JSONObject root = JSON.parseObject(content);
|
||||
if (root == null) {
|
||||
throw new BusinessException("已发布工作流拓扑为空");
|
||||
}
|
||||
return root;
|
||||
} catch (JSONException error) {
|
||||
throw new BusinessException(
|
||||
400,
|
||||
40031,
|
||||
"已发布工作流拓扑解析失败",
|
||||
error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析并校验节点定义。
|
||||
*
|
||||
* @param rawNodes 原始节点数组
|
||||
* @return 按发布定义顺序排列的节点构建器
|
||||
*/
|
||||
private LinkedHashMap<String, NodeBuilder> parseNodes(
|
||||
JSONArray rawNodes) {
|
||||
if (rawNodes == null || rawNodes.isEmpty()) {
|
||||
throw new BusinessException("已发布工作流缺少节点");
|
||||
}
|
||||
LinkedHashMap<String, NodeBuilder> nodes =
|
||||
new LinkedHashMap<>();
|
||||
for (int index = 0; index < rawNodes.size(); index++) {
|
||||
JSONObject rawNode = rawNodes.getJSONObject(index);
|
||||
String nodeId = trimToNull(
|
||||
rawNode == null ? null : rawNode.getString("id"));
|
||||
if (!StringUtils.hasText(nodeId)) {
|
||||
throw new BusinessException(
|
||||
"已发布工作流存在缺少 ID 的节点");
|
||||
}
|
||||
if (nodes.containsKey(nodeId)) {
|
||||
throw new BusinessException(
|
||||
"已发布工作流存在重复节点 ID: " + nodeId);
|
||||
}
|
||||
JSONObject data = rawNode.getJSONObject("data");
|
||||
String nodeType = trimToNull(rawNode.getString("type"));
|
||||
nodes.put(nodeId, new NodeBuilder(
|
||||
nodeId,
|
||||
nodeType,
|
||||
firstText(
|
||||
data == null
|
||||
? null
|
||||
: data.getString("title"),
|
||||
rawNode.getString("label"),
|
||||
nodeId),
|
||||
firstText(
|
||||
data == null
|
||||
? null
|
||||
: data.getString("description"),
|
||||
rawNode.getString("description")),
|
||||
trimToNull(rawNode.getString("parentId")),
|
||||
index,
|
||||
parseParameters(
|
||||
data == null
|
||||
? null
|
||||
: data.getJSONArray("parameters"),
|
||||
"startNode".equals(nodeType)),
|
||||
parseParameters(
|
||||
data == null
|
||||
? null
|
||||
: data.getJSONArray("outputDefs"),
|
||||
false)));
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析公开边信息并建立节点邻接关系。
|
||||
*
|
||||
* @param rawEdges 原始边数组
|
||||
* @param nodeById 节点索引
|
||||
* @return 公开边列表
|
||||
*/
|
||||
private List<PublicWorkflowTopology.Edge> parseEdges(
|
||||
JSONArray rawEdges,
|
||||
Map<String, NodeBuilder> nodeById) {
|
||||
if (rawEdges == null || rawEdges.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<PublicWorkflowTopology.Edge> edges =
|
||||
new ArrayList<>(rawEdges.size());
|
||||
for (int index = 0; index < rawEdges.size(); index++) {
|
||||
JSONObject rawEdge = rawEdges.getJSONObject(index);
|
||||
if (rawEdge == null) {
|
||||
continue;
|
||||
}
|
||||
JSONObject data = rawEdge.getJSONObject("data");
|
||||
String edgeId = firstText(
|
||||
rawEdge.getString("id"),
|
||||
"edge-" + index);
|
||||
String sourceId = trimToNull(
|
||||
rawEdge.getString("source"));
|
||||
String targetId = trimToNull(
|
||||
rawEdge.getString("target"));
|
||||
NodeBuilder source = nodeById.get(sourceId);
|
||||
NodeBuilder target = nodeById.get(targetId);
|
||||
boolean dangling = source == null || target == null;
|
||||
if (!dangling) {
|
||||
source.addOutgoing(edgeId, targetId);
|
||||
target.addIncoming(edgeId, sourceId);
|
||||
if (isSameScope(source, target)
|
||||
|| isLoopEntry(source, target)) {
|
||||
addTopologyDependency(source, target);
|
||||
}
|
||||
}
|
||||
edges.add(new PublicWorkflowTopology.Edge(
|
||||
edgeId,
|
||||
trimToNull(rawEdge.getString("type")),
|
||||
firstText(
|
||||
rawEdge.getString("label"),
|
||||
data == null
|
||||
? null
|
||||
: data.getString("label")),
|
||||
sourceId,
|
||||
targetId,
|
||||
trimToNull(rawEdge.getString("sourceHandle")),
|
||||
trimToNull(rawEdge.getString("targetHandle")),
|
||||
firstText(
|
||||
data == null
|
||||
? null
|
||||
: data.getString("parentNodeId"),
|
||||
rawEdge.getString("parentId")),
|
||||
index,
|
||||
dangling));
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为循环体末节点与循环后的节点增加结构化完成屏障。
|
||||
*
|
||||
* <p>运行时由循环体末节点隐式返回父循环节点,父循环完成全部迭代后
|
||||
* 才会触发同作用域后继。公开拓扑使用末节点到后继的无环屏障表达这一约束。</p>
|
||||
*
|
||||
* @param nodeById 节点索引
|
||||
*/
|
||||
private void addLoopCompletionBarriers(
|
||||
Map<String, NodeBuilder> nodeById) {
|
||||
for (NodeBuilder loopNode : nodeById.values()) {
|
||||
if (!"loopNode".equals(loopNode.nodeType)) {
|
||||
continue;
|
||||
}
|
||||
List<NodeBuilder> downstream = loopNode
|
||||
.topologySuccessorNodeIds.stream()
|
||||
.map(nodeById::get)
|
||||
.filter(Objects::nonNull)
|
||||
.filter(target -> isSameScope(loopNode, target))
|
||||
.toList();
|
||||
if (downstream.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
for (NodeBuilder exitNode :
|
||||
resolveLoopScopeExitNodes(
|
||||
loopNode.nodeId,
|
||||
nodeById,
|
||||
new HashSet<>())) {
|
||||
for (NodeBuilder target : downstream) {
|
||||
addTopologyDependency(exitNode, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析循环作用域完成时必须结束的叶子节点。
|
||||
*
|
||||
* <p>若作用域叶子本身是嵌套循环,则继续展开到嵌套循环体叶子,
|
||||
* 确保外层后继等待完整嵌套作用域结束。</p>
|
||||
*
|
||||
* @param loopNodeId 循环节点 ID
|
||||
* @param nodeById 节点索引
|
||||
* @param visiting 正在展开的循环节点
|
||||
* @return 实际完成屏障节点
|
||||
*/
|
||||
private List<NodeBuilder> resolveLoopScopeExitNodes(
|
||||
String loopNodeId,
|
||||
Map<String, NodeBuilder> nodeById,
|
||||
Set<String> visiting) {
|
||||
if (!visiting.add(loopNodeId)) {
|
||||
return List.of();
|
||||
}
|
||||
List<NodeBuilder> exits = new ArrayList<>();
|
||||
for (NodeBuilder candidate : nodeById.values()) {
|
||||
if (!Objects.equals(loopNodeId, candidate.parentNodeId)
|
||||
|| hasSameScopeSuccessor(candidate, nodeById)) {
|
||||
continue;
|
||||
}
|
||||
if ("loopNode".equals(candidate.nodeType)) {
|
||||
List<NodeBuilder> nested = resolveLoopScopeExitNodes(
|
||||
candidate.nodeId,
|
||||
nodeById,
|
||||
visiting);
|
||||
if (!nested.isEmpty()) {
|
||||
exits.addAll(nested);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
exits.add(candidate);
|
||||
}
|
||||
visiting.remove(loopNodeId);
|
||||
return exits;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断节点是否存在同一父级作用域内的后继。
|
||||
*
|
||||
* @param node 当前节点
|
||||
* @param nodeById 节点索引
|
||||
* @return 是否存在同作用域后继
|
||||
*/
|
||||
private boolean hasSameScopeSuccessor(
|
||||
NodeBuilder node,
|
||||
Map<String, NodeBuilder> nodeById) {
|
||||
for (String successorId : node.topologySuccessorNodeIds) {
|
||||
NodeBuilder successor = nodeById.get(successorId);
|
||||
if (successor != null && isSameScope(node, successor)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断两个节点是否位于同一父级作用域。
|
||||
*
|
||||
* @param source 源节点
|
||||
* @param target 目标节点
|
||||
* @return 是否同作用域
|
||||
*/
|
||||
private boolean isSameScope(
|
||||
NodeBuilder source,
|
||||
NodeBuilder target) {
|
||||
return Objects.equals(
|
||||
source.parentNodeId,
|
||||
target.parentNodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断连线是否为循环节点进入直属循环体的入口。
|
||||
*
|
||||
* @param source 源节点
|
||||
* @param target 目标节点
|
||||
* @return 是否循环入口
|
||||
*/
|
||||
private boolean isLoopEntry(
|
||||
NodeBuilder source,
|
||||
NodeBuilder target) {
|
||||
return "loopNode".equals(source.nodeType)
|
||||
&& source.nodeId.equals(target.parentNodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 登记拓扑排序使用的依赖关系。
|
||||
*
|
||||
* @param source 源节点
|
||||
* @param target 目标节点
|
||||
*/
|
||||
private void addTopologyDependency(
|
||||
NodeBuilder source,
|
||||
NodeBuilder target) {
|
||||
if (source.topologySuccessorNodeIds.add(target.nodeId)) {
|
||||
target.topologyPredecessorNodeIds.add(source.nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 对节点执行稳定 Kahn 拓扑排序。
|
||||
*
|
||||
* <p>同一层级节点按发布定义顺序稳定排列。若发布图意外存在环,
|
||||
* 标准拓扑序之后按定义顺序追加受环路影响的未解析节点。</p>
|
||||
*
|
||||
* @param nodeById 节点索引
|
||||
* @return 拓扑排序结果
|
||||
*/
|
||||
private TopologyOrder resolveTopology(
|
||||
LinkedHashMap<String, NodeBuilder> nodeById) {
|
||||
Map<String, Integer> remainingInDegree = new HashMap<>();
|
||||
Map<String, Integer> levelById = new HashMap<>();
|
||||
PriorityQueue<NodeBuilder> ready = new PriorityQueue<>(
|
||||
Comparator.comparingInt(node -> node.definitionIndex));
|
||||
for (NodeBuilder node : nodeById.values()) {
|
||||
remainingInDegree.put(
|
||||
node.nodeId,
|
||||
node.topologyPredecessorNodeIds.size());
|
||||
levelById.put(node.nodeId, 0);
|
||||
if (node.topologyPredecessorNodeIds.isEmpty()) {
|
||||
ready.add(node);
|
||||
}
|
||||
}
|
||||
|
||||
List<String> order = new ArrayList<>(nodeById.size());
|
||||
Set<String> visited = new HashSet<>();
|
||||
while (!ready.isEmpty()) {
|
||||
NodeBuilder current = ready.poll();
|
||||
if (!visited.add(current.nodeId)) {
|
||||
continue;
|
||||
}
|
||||
order.add(current.nodeId);
|
||||
int nextLevel = levelById.getOrDefault(current.nodeId, 0) + 1;
|
||||
for (String successorId :
|
||||
current.topologySuccessorNodeIds) {
|
||||
levelById.compute(
|
||||
successorId,
|
||||
(key, value) -> Math.max(
|
||||
value == null ? 0 : value,
|
||||
nextLevel));
|
||||
}
|
||||
for (String edgeTargetId :
|
||||
current.topologySuccessorNodeIds) {
|
||||
int nextInDegree = remainingInDegree.computeIfPresent(
|
||||
edgeTargetId,
|
||||
(key, value) -> value - 1);
|
||||
if (nextInDegree == 0) {
|
||||
ready.add(nodeById.get(edgeTargetId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<String> unresolvedNodeIds = new ArrayList<>();
|
||||
for (NodeBuilder node : nodeById.values()) {
|
||||
if (!visited.contains(node.nodeId)) {
|
||||
unresolvedNodeIds.add(node.nodeId);
|
||||
order.add(node.nodeId);
|
||||
levelById.put(node.nodeId, -1);
|
||||
}
|
||||
}
|
||||
return new TopologyOrder(
|
||||
order,
|
||||
buildLevels(order, levelById),
|
||||
unresolvedNodeIds,
|
||||
levelById);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建可并行处理的拓扑层级。
|
||||
*
|
||||
* @param order 稳定拓扑顺序
|
||||
* @param levelById 节点层级
|
||||
* @return 按层级排列的节点 ID
|
||||
*/
|
||||
private List<List<String>> buildLevels(
|
||||
List<String> order,
|
||||
Map<String, Integer> levelById) {
|
||||
int maxLevel = -1;
|
||||
for (String nodeId : order) {
|
||||
maxLevel = Math.max(
|
||||
maxLevel,
|
||||
levelById.getOrDefault(nodeId, -1));
|
||||
}
|
||||
if (maxLevel < 0) {
|
||||
return List.of();
|
||||
}
|
||||
List<List<String>> levels = new ArrayList<>(maxLevel + 1);
|
||||
for (int index = 0; index <= maxLevel; index++) {
|
||||
levels.add(new ArrayList<>());
|
||||
}
|
||||
for (String nodeId : order) {
|
||||
int level = levelById.getOrDefault(nodeId, -1);
|
||||
if (level >= 0) {
|
||||
levels.get(level).add(nodeId);
|
||||
}
|
||||
}
|
||||
return levels;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将内部节点构建器转换为公开节点 DTO。
|
||||
*
|
||||
* @param nodeById 节点索引
|
||||
* @param topology 拓扑排序结果
|
||||
* @return 按稳定拓扑顺序排列的公开节点
|
||||
*/
|
||||
private List<PublicWorkflowTopology.Node> buildNodes(
|
||||
Map<String, NodeBuilder> nodeById,
|
||||
TopologyOrder topology) {
|
||||
List<PublicWorkflowTopology.Node> nodes =
|
||||
new ArrayList<>(topology.order.size());
|
||||
for (int topologyIndex = 0;
|
||||
topologyIndex < topology.order.size();
|
||||
topologyIndex++) {
|
||||
NodeBuilder node = nodeById.get(
|
||||
topology.order.get(topologyIndex));
|
||||
nodes.add(new PublicWorkflowTopology.Node(
|
||||
node.nodeId,
|
||||
node.nodeType,
|
||||
node.nodeName,
|
||||
node.description,
|
||||
node.parentNodeId,
|
||||
node.definitionIndex,
|
||||
topologyIndex,
|
||||
topology.levelById.getOrDefault(node.nodeId, -1),
|
||||
node.incomingEdgeIds.size(),
|
||||
node.outgoingEdgeIds.size(),
|
||||
"startNode".equals(node.nodeType),
|
||||
"endNode".equals(node.nodeType),
|
||||
new ArrayList<>(node.predecessorNodeIds),
|
||||
new ArrayList<>(node.successorNodeIds),
|
||||
node.incomingEdgeIds,
|
||||
node.outgoingEdgeIds,
|
||||
node.inputParameters,
|
||||
node.outputParameters));
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析参数数组中的安全元数据。
|
||||
*
|
||||
* @param rawParameters 原始参数数组
|
||||
* @param multipartEnabled 是否为开始节点可上传输入参数
|
||||
* @return 参数安全元数据
|
||||
*/
|
||||
private List<PublicWorkflowTopology.Parameter> parseParameters(
|
||||
JSONArray rawParameters,
|
||||
boolean multipartEnabled) {
|
||||
if (rawParameters == null || rawParameters.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<PublicWorkflowTopology.Parameter> parameters =
|
||||
new ArrayList<>(rawParameters.size());
|
||||
for (int index = 0; index < rawParameters.size(); index++) {
|
||||
JSONObject parameter = rawParameters.getJSONObject(index);
|
||||
if (parameter == null) {
|
||||
continue;
|
||||
}
|
||||
String parameterName = firstText(
|
||||
parameter.getString("name"),
|
||||
parameter.getString("key"));
|
||||
String dataType =
|
||||
trimToNull(parameter.getString("dataType"));
|
||||
String contentType = firstText(
|
||||
parameter.getString("contentType"),
|
||||
parameter.getString("type"),
|
||||
parameter.getString("formType"));
|
||||
parameters.add(new PublicWorkflowTopology.Parameter(
|
||||
firstText(
|
||||
parameter.getString("id"),
|
||||
parameter.getString("key")),
|
||||
parameterName,
|
||||
firstText(
|
||||
parameter.getString("formLabel"),
|
||||
parameter.getString("label"),
|
||||
parameter.getString("title"),
|
||||
parameter.getString("name"),
|
||||
parameter.getString("key")),
|
||||
dataType,
|
||||
contentType,
|
||||
parameter.getBooleanValue("required"),
|
||||
firstText(
|
||||
parameter.getString("formDescription"),
|
||||
parameter.getString("description")),
|
||||
multipartEnabled
|
||||
&& isFileParameter(dataType, contentType)
|
||||
&& StringUtils.hasText(parameterName)
|
||||
? WorkflowApiMultipartParameterMapper
|
||||
.FILE_PART_PREFIX + parameterName
|
||||
: null));
|
||||
}
|
||||
return parameters;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断公开参数元数据是否描述文件输入。
|
||||
*
|
||||
* @param dataType 参数数据类型
|
||||
* @param contentType 参数内容类型
|
||||
* @return 是否为文件参数
|
||||
*/
|
||||
private boolean isFileParameter(
|
||||
String dataType,
|
||||
String contentType) {
|
||||
return "file".equalsIgnoreCase(dataType)
|
||||
|| "file".equalsIgnoreCase(contentType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回首个非空文本。
|
||||
*
|
||||
* @param values 候选文本
|
||||
* @return 首个非空文本
|
||||
*/
|
||||
private String firstText(String... values) {
|
||||
for (String value : values) {
|
||||
String normalized = trimToNull(value);
|
||||
if (normalized != null) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 去除文本首尾空白并把空串转换为空值。
|
||||
*
|
||||
* @param value 原文本
|
||||
* @return 归一化文本
|
||||
*/
|
||||
private String trimToNull(String value) {
|
||||
if (!StringUtils.hasText(value)) {
|
||||
return null;
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 拓扑排序使用的内部节点。
|
||||
*/
|
||||
private static final class NodeBuilder {
|
||||
|
||||
private final String nodeId;
|
||||
private final String nodeType;
|
||||
private final String nodeName;
|
||||
private final String description;
|
||||
private final String parentNodeId;
|
||||
private final int definitionIndex;
|
||||
private final List<PublicWorkflowTopology.Parameter> inputParameters;
|
||||
private final List<PublicWorkflowTopology.Parameter> outputParameters;
|
||||
private final LinkedHashSet<String> predecessorNodeIds =
|
||||
new LinkedHashSet<>();
|
||||
private final LinkedHashSet<String> successorNodeIds =
|
||||
new LinkedHashSet<>();
|
||||
private final List<String> incomingEdgeIds = new ArrayList<>();
|
||||
private final List<String> outgoingEdgeIds = new ArrayList<>();
|
||||
private final LinkedHashSet<String>
|
||||
topologyPredecessorNodeIds = new LinkedHashSet<>();
|
||||
private final LinkedHashSet<String>
|
||||
topologySuccessorNodeIds = new LinkedHashSet<>();
|
||||
|
||||
/**
|
||||
* 创建内部节点。
|
||||
*
|
||||
* @param nodeId 节点 ID
|
||||
* @param nodeType 节点类型
|
||||
* @param nodeName 节点名称
|
||||
* @param description 节点描述
|
||||
* @param parentNodeId 父节点 ID
|
||||
* @param definitionIndex 发布定义位置
|
||||
* @param inputParameters 输入参数
|
||||
* @param outputParameters 输出参数
|
||||
*/
|
||||
private NodeBuilder(
|
||||
String nodeId,
|
||||
String nodeType,
|
||||
String nodeName,
|
||||
String description,
|
||||
String parentNodeId,
|
||||
int definitionIndex,
|
||||
List<PublicWorkflowTopology.Parameter> inputParameters,
|
||||
List<PublicWorkflowTopology.Parameter> outputParameters) {
|
||||
this.nodeId = nodeId;
|
||||
this.nodeType = nodeType;
|
||||
this.nodeName = nodeName;
|
||||
this.description = description;
|
||||
this.parentNodeId = parentNodeId;
|
||||
this.definitionIndex = definitionIndex;
|
||||
this.inputParameters = inputParameters;
|
||||
this.outputParameters = outputParameters;
|
||||
}
|
||||
|
||||
/**
|
||||
* 登记一条有效出边。
|
||||
*
|
||||
* @param edgeId 边 ID
|
||||
* @param targetId 目标节点 ID
|
||||
*/
|
||||
private void addOutgoing(String edgeId, String targetId) {
|
||||
outgoingEdgeIds.add(edgeId);
|
||||
successorNodeIds.add(targetId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 登记一条有效入边。
|
||||
*
|
||||
* @param edgeId 边 ID
|
||||
* @param sourceId 源节点 ID
|
||||
*/
|
||||
private void addIncoming(String edgeId, String sourceId) {
|
||||
incomingEdgeIds.add(edgeId);
|
||||
predecessorNodeIds.add(sourceId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 稳定拓扑排序内部结果。
|
||||
*
|
||||
* @param order 节点顺序
|
||||
* @param levels 拓扑层级
|
||||
* @param unresolvedNodeIds 受环路影响的未解析节点 ID
|
||||
* @param levelById 节点层级索引
|
||||
*/
|
||||
private record TopologyOrder(
|
||||
List<String> order,
|
||||
List<List<String>> levels,
|
||||
List<String> unresolvedNodeIds,
|
||||
Map<String, Integer> levelById) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package tech.easyflow.publicapi.service;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Public Workflow multipart Part 与开始节点文件参数的映射器。
|
||||
*/
|
||||
@Service
|
||||
public class WorkflowApiMultipartParameterMapper {
|
||||
|
||||
/** API 请求元数据 Part 名。 */
|
||||
public static final String METADATA_PART_NAME = "metadata";
|
||||
/** 工作流文件参数 Part 前缀。 */
|
||||
public static final String FILE_PART_PREFIX = "files.";
|
||||
|
||||
/**
|
||||
* 将 API multipart 文件 Part 映射为开始节点文件参数。
|
||||
*
|
||||
* <p>{@code metadata} 只属于 API 信封,不参与工作流变量映射;
|
||||
* {@code files.<参数名>} 去除前缀后才作为开始节点参数名。</p>
|
||||
*
|
||||
* @param rawFileParts Servlet 解析出的原始文件 Part
|
||||
* @return 以开始节点文件参数名分组的文件
|
||||
* @throws BusinessException Part 未使用规定命名空间或参数名为空时抛出
|
||||
*/
|
||||
public Map<String, List<MultipartFile>> map(
|
||||
MultiValueMap<String, MultipartFile> rawFileParts) {
|
||||
if (rawFileParts == null || rawFileParts.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<String, List<MultipartFile>> mapped = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, List<MultipartFile>> entry :
|
||||
rawFileParts.entrySet()) {
|
||||
String partName = entry.getKey();
|
||||
if (METADATA_PART_NAME.equals(partName)) {
|
||||
continue;
|
||||
}
|
||||
if (!StringUtils.hasText(partName)
|
||||
|| !partName.startsWith(FILE_PART_PREFIX)) {
|
||||
throw new BusinessException(
|
||||
400,
|
||||
40016,
|
||||
"文件 Part 必须使用 files.<开始节点文件参数名>");
|
||||
}
|
||||
String parameterName =
|
||||
partName.substring(FILE_PART_PREFIX.length());
|
||||
if (!StringUtils.hasText(parameterName)
|
||||
|| !parameterName.equals(parameterName.trim())) {
|
||||
throw new BusinessException(
|
||||
400,
|
||||
40016,
|
||||
"文件 Part 中的开始节点文件参数名无效");
|
||||
}
|
||||
mapped.put(
|
||||
parameterName,
|
||||
entry.getValue() == null
|
||||
? List.of()
|
||||
: new ArrayList<>(entry.getValue()));
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
}
|
||||
@@ -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<PublicWorkflowChainStatus> 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;
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.<Consumer<String>>any()))
|
||||
.thenAnswer(invocation -> {
|
||||
Consumer<String> 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());
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package tech.easyflow.publicapi.service;
|
||||
|
||||
import com.easyagents.flow.core.chain.ChainStatus;
|
||||
import com.easyagents.flow.core.chain.NodeStatus;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
|
||||
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
|
||||
import tech.easyflow.publicapi.dto.PublicWorkflowChainStatus;
|
||||
import tech.easyflow.publicapi.dto.PublicWorkflowExecutionStatus;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* {@link PublicWorkflowStatusSanitizer} 公共错误脱敏测试。
|
||||
*/
|
||||
public class PublicWorkflowStatusSanitizerTest {
|
||||
|
||||
private final PublicWorkflowStatusSanitizer sanitizer =
|
||||
new PublicWorkflowStatusSanitizer();
|
||||
|
||||
/**
|
||||
* 验证内部异常类名和依赖详情不会进入公共状态。
|
||||
*/
|
||||
@Test
|
||||
public void shouldHideInternalExecutionErrorDetails() {
|
||||
ChainInfo source = new ChainInfo();
|
||||
source.setExecuteId("execute-1");
|
||||
source.setStatus(ChainStatus.FAILED.getValue());
|
||||
source.setMessage(
|
||||
"io.minio.errors.ErrorResponseException --> signature mismatch at http://internal:9000");
|
||||
NodeInfo node = new NodeInfo();
|
||||
node.setNodeId("node-1");
|
||||
node.setNodeName("文档解析");
|
||||
node.setStatus(NodeStatus.FAILED.getValue());
|
||||
node.setMessage("java.lang.IllegalStateException --> bucket-secret");
|
||||
source.setNodes(Map.of("node-1", node));
|
||||
|
||||
PublicWorkflowChainStatus result = sanitizer.sanitize(source);
|
||||
|
||||
Assert.assertEquals(
|
||||
"工作流执行失败,请检查输入或稍后重试",
|
||||
result.message());
|
||||
Assert.assertFalse(result.message().contains("minio"));
|
||||
Assert.assertEquals(
|
||||
"节点执行失败,请检查输入或稍后重试",
|
||||
result.nodes().get("node-1").message());
|
||||
Assert.assertEquals("node-1", result.error().getNodeId());
|
||||
Assert.assertFalse(result.error().isRetryable());
|
||||
Assert.assertEquals(
|
||||
PublicWorkflowExecutionStatus.FAILED,
|
||||
result.status());
|
||||
Assert.assertTrue(result.terminal());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证成功状态保持原有响应且不增加错误对象。
|
||||
*/
|
||||
@Test
|
||||
public void shouldKeepSuccessfulStatusWithoutError() {
|
||||
ChainInfo source = new ChainInfo();
|
||||
source.setExecuteId("execute-1");
|
||||
source.setStatus(ChainStatus.SUCCEEDED.getValue());
|
||||
source.setResult(Map.of("output", "ok"));
|
||||
|
||||
PublicWorkflowChainStatus result = sanitizer.sanitize(source);
|
||||
|
||||
Assert.assertEquals(source.getResult(), result.result());
|
||||
Assert.assertEquals(
|
||||
PublicWorkflowExecutionStatus.DONE,
|
||||
result.status());
|
||||
Assert.assertTrue(result.terminal());
|
||||
Assert.assertNull(result.message());
|
||||
Assert.assertNull(result.error());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证节点名称保持不变,并将节点状态转换为可读枚举。
|
||||
*/
|
||||
@Test
|
||||
public void shouldKeepNodeNameAndReadableStatus() {
|
||||
ChainInfo source = new ChainInfo();
|
||||
source.setExecuteId("execute-1");
|
||||
source.setStatus(ChainStatus.RUNNING.getValue());
|
||||
NodeInfo node = new NodeInfo();
|
||||
node.setNodeId("node-1");
|
||||
node.setNodeName("文档解析");
|
||||
node.setStatus(NodeStatus.RUNNING.getValue());
|
||||
source.setNodes(Map.of("node-1", node));
|
||||
|
||||
PublicWorkflowChainStatus result = sanitizer.sanitize(source);
|
||||
|
||||
Assert.assertEquals(
|
||||
PublicWorkflowExecutionStatus.RUNNING,
|
||||
result.status());
|
||||
Assert.assertFalse(result.terminal());
|
||||
Assert.assertEquals(
|
||||
"文档解析",
|
||||
result.nodes().get("node-1").nodeName());
|
||||
Assert.assertEquals(
|
||||
PublicWorkflowExecutionStatus.RUNNING,
|
||||
result.nodes().get("node-1").status());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package tech.easyflow.publicapi.service;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.publicapi.dto.PublicWorkflowTopology;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* {@link PublicWorkflowTopologyService} 公开拓扑契约测试。
|
||||
*/
|
||||
public class PublicWorkflowTopologyServiceTest {
|
||||
|
||||
private final PublicWorkflowTopologyService service =
|
||||
new PublicWorkflowTopologyService();
|
||||
|
||||
/**
|
||||
* 验证分支工作流返回稳定拓扑序、并行层级和完整邻接信息。
|
||||
*/
|
||||
@Test
|
||||
public void resolveShouldReturnStableTopologyAndNodeMetadata() {
|
||||
Workflow workflow = workflow("""
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "branch-b",
|
||||
"type": "codeNode",
|
||||
"data": {
|
||||
"title": "分支 B",
|
||||
"description": "处理 B",
|
||||
"parameters": [
|
||||
{
|
||||
"id": "input-1",
|
||||
"name": "content",
|
||||
"formLabel": "内容",
|
||||
"dataType": "String",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"script": "private-secret-script"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "start",
|
||||
"type": "startNode",
|
||||
"data": {"title": "开始"}
|
||||
},
|
||||
{
|
||||
"id": "branch-a",
|
||||
"type": "llmNode",
|
||||
"data": {
|
||||
"title": "分支 A",
|
||||
"systemPrompt": "private-secret-prompt"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "isolated",
|
||||
"type": "codeNode",
|
||||
"data": {"title": "孤立节点"}
|
||||
},
|
||||
{
|
||||
"id": "end",
|
||||
"type": "endNode",
|
||||
"data": {
|
||||
"title": "结束",
|
||||
"outputDefs": [
|
||||
{
|
||||
"id": "output-1",
|
||||
"name": "answer",
|
||||
"dataType": "String"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "start-b",
|
||||
"source": "start",
|
||||
"target": "branch-b"
|
||||
},
|
||||
{
|
||||
"id": "start-a",
|
||||
"source": "start",
|
||||
"target": "branch-a"
|
||||
},
|
||||
{
|
||||
"id": "b-end",
|
||||
"source": "branch-b",
|
||||
"target": "end"
|
||||
},
|
||||
{
|
||||
"id": "a-end",
|
||||
"source": "branch-a",
|
||||
"target": "end"
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
PublicWorkflowTopology topology = service.resolve(workflow);
|
||||
|
||||
Assert.assertEquals(
|
||||
List.of(
|
||||
"start",
|
||||
"branch-b",
|
||||
"branch-a",
|
||||
"isolated",
|
||||
"end"),
|
||||
topology.topologicalOrder());
|
||||
Assert.assertEquals(
|
||||
List.of(
|
||||
List.of("start", "isolated"),
|
||||
List.of("branch-b", "branch-a"),
|
||||
List.of("end")),
|
||||
topology.topologyLevels());
|
||||
Assert.assertFalse(topology.hasCycle());
|
||||
Assert.assertEquals(5, topology.nodes().size());
|
||||
Assert.assertEquals(4, topology.edges().size());
|
||||
|
||||
PublicWorkflowTopology.Node start = topology.nodes().get(0);
|
||||
Assert.assertTrue(start.startNode());
|
||||
Assert.assertEquals(
|
||||
List.of("branch-b", "branch-a"),
|
||||
start.successorNodeIds());
|
||||
Assert.assertEquals(
|
||||
List.of("start-b", "start-a"),
|
||||
start.outgoingEdgeIds());
|
||||
Assert.assertFalse(topology.nodes().get(3).startNode());
|
||||
Assert.assertFalse(topology.nodes().get(3).endNode());
|
||||
|
||||
PublicWorkflowTopology.Node branchB = topology.nodes().get(1);
|
||||
Assert.assertEquals(1, branchB.topologyLevel());
|
||||
Assert.assertEquals(1, branchB.inputParameters().size());
|
||||
Assert.assertEquals(
|
||||
"content",
|
||||
branchB.inputParameters().get(0).name());
|
||||
|
||||
String serialized = JSON.toJSONString(topology);
|
||||
Assert.assertFalse(serialized.contains("private-secret-script"));
|
||||
Assert.assertFalse(serialized.contains("private-secret-prompt"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证意外环路会显式标记,并且响应仍包含全部节点。
|
||||
*/
|
||||
@Test
|
||||
public void resolveShouldExposeCycleWithoutDroppingNodes() {
|
||||
Workflow workflow = workflow("""
|
||||
{
|
||||
"nodes": [
|
||||
{"id": "a", "type": "codeNode", "data": {"title": "A"}},
|
||||
{"id": "b", "type": "codeNode", "data": {"title": "B"}},
|
||||
{"id": "c", "type": "endNode", "data": {"title": "C"}}
|
||||
],
|
||||
"edges": [
|
||||
{"id": "a-b", "source": "a", "target": "b"},
|
||||
{"id": "b-a", "source": "b", "target": "a"},
|
||||
{"id": "b-c", "source": "b", "target": "c"}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
PublicWorkflowTopology topology = service.resolve(workflow);
|
||||
|
||||
Assert.assertTrue(topology.hasCycle());
|
||||
Assert.assertEquals(
|
||||
List.of("a", "b", "c"),
|
||||
topology.unresolvedNodeIds());
|
||||
Assert.assertEquals(
|
||||
List.of("a", "b", "c"),
|
||||
topology.topologicalOrder());
|
||||
Assert.assertEquals(-1, topology.nodes().get(0).topologyLevel());
|
||||
Assert.assertEquals(3, topology.nodes().size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证循环体完成后才会进入循环节点的同作用域后继。
|
||||
*/
|
||||
@Test
|
||||
public void resolveShouldPlaceLoopBodyBeforeLoopDownstream() {
|
||||
Workflow workflow = workflow("""
|
||||
{
|
||||
"nodes": [
|
||||
{"id": "after", "type": "codeNode", "data": {"title": "循环后"}},
|
||||
{"id": "inside-end", "type": "codeNode", "parentId": "loop", "data": {"title": "循环体末节点"}},
|
||||
{"id": "start", "type": "startNode", "data": {"title": "开始"}},
|
||||
{"id": "loop", "type": "loopNode", "data": {"title": "循环"}},
|
||||
{"id": "inside-start", "type": "codeNode", "parentId": "loop", "data": {"title": "循环体入口"}},
|
||||
{"id": "end", "type": "endNode", "data": {"title": "结束"}}
|
||||
],
|
||||
"edges": [
|
||||
{"id": "start-loop", "source": "start", "target": "loop"},
|
||||
{"id": "loop-inside", "source": "loop", "target": "inside-start"},
|
||||
{"id": "inside-next", "source": "inside-start", "target": "inside-end"},
|
||||
{"id": "loop-after", "source": "loop", "target": "after"},
|
||||
{"id": "after-end", "source": "after", "target": "end"}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
PublicWorkflowTopology topology = service.resolve(workflow);
|
||||
|
||||
Assert.assertEquals(
|
||||
List.of(
|
||||
"start",
|
||||
"loop",
|
||||
"inside-start",
|
||||
"inside-end",
|
||||
"after",
|
||||
"end"),
|
||||
topology.topologicalOrder());
|
||||
Assert.assertEquals(
|
||||
List.of(
|
||||
List.of("start"),
|
||||
List.of("loop"),
|
||||
List.of("inside-start"),
|
||||
List.of("inside-end"),
|
||||
List.of("after"),
|
||||
List.of("end")),
|
||||
topology.topologyLevels());
|
||||
Assert.assertFalse(topology.hasCycle());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证开始节点文件参数公开无歧义的 multipart Part 名。
|
||||
*/
|
||||
@Test
|
||||
public void resolveShouldExposeNamespacedMultipartPartName() {
|
||||
Workflow workflow = workflow("""
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "start",
|
||||
"type": "startNode",
|
||||
"data": {
|
||||
"title": "开始",
|
||||
"parameters": [
|
||||
{
|
||||
"id": "file-1",
|
||||
"name": "metadata",
|
||||
"dataType": "File",
|
||||
"contentType": "file"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{"id": "end", "type": "endNode", "data": {"title": "结束"}}
|
||||
],
|
||||
"edges": [
|
||||
{"id": "start-end", "source": "start", "target": "end"}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
PublicWorkflowTopology topology = service.resolve(workflow);
|
||||
|
||||
PublicWorkflowTopology.Parameter parameter =
|
||||
topology.nodes().get(0).inputParameters().get(0);
|
||||
Assert.assertEquals("metadata", parameter.name());
|
||||
Assert.assertEquals(
|
||||
"files.metadata",
|
||||
parameter.multipartPartName());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建测试工作流。
|
||||
*
|
||||
* @param content 发布快照内容
|
||||
* @return 测试工作流
|
||||
*/
|
||||
private Workflow workflow(String content) {
|
||||
Workflow workflow = new Workflow();
|
||||
workflow.setId(BigInteger.valueOf(101));
|
||||
workflow.setAlias("public-demo");
|
||||
workflow.setTitle("公开工作流");
|
||||
workflow.setDescription("公开描述");
|
||||
workflow.setRevision(7);
|
||||
workflow.setPublishedAt(Date.from(
|
||||
Instant.parse("2026-08-07T00:00:00Z")));
|
||||
workflow.setContent(content);
|
||||
return workflow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package tech.easyflow.publicapi.service;
|
||||
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* {@link WorkflowApiMultipartParameterMapper} 参数映射测试。
|
||||
*/
|
||||
public class WorkflowApiMultipartParameterMapperTest {
|
||||
|
||||
private final WorkflowApiMultipartParameterMapper mapper =
|
||||
new WorkflowApiMultipartParameterMapper();
|
||||
|
||||
/**
|
||||
* 验证 API metadata 与同名工作流文件参数互不冲突。
|
||||
*/
|
||||
@Test
|
||||
public void mapShouldSeparateApiMetadataAndWorkflowParameter() {
|
||||
MultiValueMap<String, MultipartFile> parts =
|
||||
new LinkedMultiValueMap<>();
|
||||
parts.add(
|
||||
"metadata",
|
||||
file("metadata", "metadata.json", "{}"));
|
||||
parts.add(
|
||||
"files.metadata",
|
||||
file("files.metadata", "one.pdf", "one"));
|
||||
parts.add(
|
||||
"files.metadata",
|
||||
file("files.metadata", "two.pdf", "two"));
|
||||
|
||||
Map<String, List<MultipartFile>> mapped = mapper.map(parts);
|
||||
|
||||
Assert.assertEquals(1, mapped.size());
|
||||
Assert.assertEquals(2, mapped.get("metadata").size());
|
||||
Assert.assertEquals(
|
||||
"one.pdf",
|
||||
mapped.get("metadata").get(0).getOriginalFilename());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证未使用文件命名空间的 Part 会被明确拒绝。
|
||||
*/
|
||||
@Test
|
||||
public void mapShouldRejectUnnamespacedFilePart() {
|
||||
MultiValueMap<String, MultipartFile> parts =
|
||||
new LinkedMultiValueMap<>();
|
||||
parts.add(
|
||||
"documents",
|
||||
file("documents", "one.pdf", "one"));
|
||||
|
||||
BusinessException exception = Assert.assertThrows(
|
||||
BusinessException.class,
|
||||
() -> mapper.map(parts));
|
||||
|
||||
Assert.assertTrue(
|
||||
exception.getMessage().contains("files.<"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Part 参数名不会被隐式 trim 后误映射到另一个工作流参数。
|
||||
*/
|
||||
@Test
|
||||
public void mapShouldRejectWhitespaceAlteredParameterName() {
|
||||
MultiValueMap<String, MultipartFile> parts =
|
||||
new LinkedMultiValueMap<>();
|
||||
parts.add(
|
||||
"files. metadata",
|
||||
file("files. metadata", "one.pdf", "one"));
|
||||
|
||||
BusinessException exception = Assert.assertThrows(
|
||||
BusinessException.class,
|
||||
() -> mapper.map(parts));
|
||||
|
||||
Assert.assertTrue(
|
||||
exception.getMessage().contains("参数名无效"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建测试文件 Part。
|
||||
*
|
||||
* @param partName Part 名
|
||||
* @param filename 文件名
|
||||
* @param content 文件内容
|
||||
* @return 测试文件
|
||||
*/
|
||||
private MultipartFile file(
|
||||
String partName,
|
||||
String filename,
|
||||
String content) {
|
||||
return new TestMultipartFile(
|
||||
partName,
|
||||
filename,
|
||||
"application/octet-stream",
|
||||
content.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
/**
|
||||
* 无需 Spring Test 依赖的最小 MultipartFile 测试实现。
|
||||
*/
|
||||
private record TestMultipartFile(
|
||||
String name,
|
||||
String originalFilename,
|
||||
String contentType,
|
||||
byte[] bytes) implements MultipartFile {
|
||||
|
||||
/**
|
||||
* 获取 Part 名。
|
||||
*
|
||||
* @return Part 名
|
||||
*/
|
||||
@Override
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取原始文件名。
|
||||
*
|
||||
* @return 原始文件名
|
||||
*/
|
||||
@Override
|
||||
public String getOriginalFilename() {
|
||||
return originalFilename;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取内容类型。
|
||||
*
|
||||
* @return 内容类型
|
||||
*/
|
||||
@Override
|
||||
public String getContentType() {
|
||||
return contentType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文件是否为空。
|
||||
*
|
||||
* @return 是否为空
|
||||
*/
|
||||
@Override
|
||||
public boolean isEmpty() {
|
||||
return bytes.length == 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件字节数。
|
||||
*
|
||||
* @return 文件字节数
|
||||
*/
|
||||
@Override
|
||||
public long getSize() {
|
||||
return bytes.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件内容。
|
||||
*
|
||||
* @return 文件内容副本
|
||||
*/
|
||||
@Override
|
||||
public byte[] getBytes() {
|
||||
return bytes.clone();
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开文件内容流。
|
||||
*
|
||||
* @return 文件内容流
|
||||
*/
|
||||
@Override
|
||||
public InputStream getInputStream() {
|
||||
return new ByteArrayInputStream(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将测试文件写入目标文件。
|
||||
*
|
||||
* @param destination 目标文件
|
||||
* @throws IOException 写入失败
|
||||
*/
|
||||
@Override
|
||||
public void transferTo(File destination) throws IOException {
|
||||
Files.write(destination.toPath(), bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user