发布 v1.10 #5
@@ -37,5 +37,11 @@
|
|||||||
<groupId>com.mysql</groupId>
|
<groupId>com.mysql</groupId>
|
||||||
<artifactId>mysql-connector-j</artifactId>
|
<artifactId>mysql-connector-j</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<version>${spring-boot.version}</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import cn.dev33.satoken.stp.StpUtil;
|
|||||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.web.bind.annotation.*;
|
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.approval.annotation.RequirePublishedAccess;
|
||||||
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
|
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
|
||||||
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
|
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.WorkflowCheckService;
|
||||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
||||||
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
|
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.Workflow;
|
||||||
import tech.easyflow.ai.entity.WorkflowExecResult;
|
import tech.easyflow.ai.entity.WorkflowExecResult;
|
||||||
import tech.easyflow.ai.enums.PublishStatus;
|
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.satoken.util.SaTokenUtil;
|
||||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
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 tech.easyflow.system.entity.SysApiKey;
|
||||||
|
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 工作流
|
* 工作流
|
||||||
@@ -57,23 +72,32 @@ public class PublicWorkflowController {
|
|||||||
private WorkflowApiPermissionService workflowApiPermissionService;
|
private WorkflowApiPermissionService workflowApiPermissionService;
|
||||||
@Resource
|
@Resource
|
||||||
private WorkflowExecResultService workflowExecResultService;
|
private WorkflowExecResultService workflowExecResultService;
|
||||||
|
@Resource
|
||||||
|
private WorkflowApiUploadLifecycleService workflowApiUploadLifecycleService;
|
||||||
|
@Resource
|
||||||
|
private PublicWorkflowTopologyService publicWorkflowTopologyService;
|
||||||
|
@Resource
|
||||||
|
private PublicWorkflowStatusSanitizer publicWorkflowStatusSanitizer;
|
||||||
|
@Resource
|
||||||
|
private WorkflowApiMultipartParameterMapper
|
||||||
|
workflowApiMultipartParameterMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通过id或别名获取工作流详情
|
* 通过id或别名获取工作流详情
|
||||||
*
|
*
|
||||||
* @param key id或者别名
|
* @param key id或者别名
|
||||||
* @return 工作流详情
|
* @return 工作流安全基础信息
|
||||||
*/
|
*/
|
||||||
@GetMapping(value = "/getByIdOrAlias")
|
@GetMapping(value = "/getByIdOrAlias")
|
||||||
@RequirePublishedAccess(resourceType = "WORKFLOW", idExpr = "#key", denyMessage = "工作流尚未发布")
|
@RequirePublishedAccess(resourceType = "WORKFLOW", idExpr = "#key", denyMessage = "工作流尚未发布")
|
||||||
public Result<Workflow> getByIdOrAlias(
|
public Result<PublicWorkflowInfo> getByIdOrAlias(
|
||||||
@RequestParam
|
@RequestParam
|
||||||
@NotBlank(message = "key不能为空") String key,
|
@NotBlank(message = "key不能为空") String key,
|
||||||
HttpServletRequest request) {
|
HttpServletRequest request) {
|
||||||
workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
|
workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
|
||||||
Workflow workflow = workflowService.getPublishedDetail(key);
|
Workflow workflow = workflowService.getPublishedDetail(key);
|
||||||
assertStrictPublishedWorkflow(workflow);
|
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")
|
@PostMapping(
|
||||||
@RequirePublishedAccess(resourceType = "WORKFLOW", idExpr = "#id", denyMessage = "工作流尚未发布")
|
value = "/runAsync",
|
||||||
public Result<String> runAsync(@JsonBody(value = "id", required = true) BigInteger id,
|
consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||||
@JsonBody("variables") Map<String, Object> variables,
|
@RequirePublishedAccess(
|
||||||
HttpServletRequest request) {
|
resourceType = "WORKFLOW",
|
||||||
|
idExpr = "#metadata.id",
|
||||||
|
denyMessage = "工作流尚未发布")
|
||||||
|
public PublicWorkflowRunResult runAsync(
|
||||||
|
@Valid @RequestBody PublicWorkflowRunMetadata metadata,
|
||||||
|
HttpServletRequest request) {
|
||||||
SysApiKey apiKey = workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
|
SysApiKey apiKey = workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
|
||||||
if (variables == null) {
|
Workflow workflow = loadExecutableWorkflow(metadata.getId());
|
||||||
variables = new HashMap<>();
|
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
|
* 获取工作流运行状态 - v2
|
||||||
*/
|
*/
|
||||||
@PostMapping("/getChainStatus")
|
@PostMapping("/getChainStatus")
|
||||||
public Result<ChainInfo> getChainStatus(@JsonBody(value = "executeId") String executeId,
|
public Result<PublicWorkflowChainStatus> getChainStatus(
|
||||||
@JsonBody("nodes") List<NodeInfo> nodes,
|
@JsonBody(value = "executeId") String executeId,
|
||||||
HttpServletRequest request) {
|
@JsonBody("nodes") List<NodeInfo> nodes,
|
||||||
|
HttpServletRequest request) {
|
||||||
SysApiKey apiKey = workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
|
SysApiKey apiKey = workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
|
||||||
assertApiKeyExecutionOwnership(apiKey, executeId);
|
assertApiKeyExecutionOwnership(apiKey, executeId);
|
||||||
ChainInfo res = tinyFlowService.getChainStatus(executeId, nodes);
|
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());
|
SysApiKey apiKey = workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
|
||||||
WorkflowExecResult execResult = assertApiKeyExecutionOwnership(apiKey, executeId);
|
WorkflowExecResult execResult = assertApiKeyExecutionOwnership(apiKey, executeId);
|
||||||
assertWorkflowExecutionResumable(execResult);
|
assertWorkflowExecutionResumable(execResult);
|
||||||
chainExecutor.resumeAsync(executeId, confirmParams);
|
if (!chainExecutor.resumeAsyncIfSuspended(
|
||||||
|
executeId,
|
||||||
|
confirmParams)) {
|
||||||
|
throw new BusinessException(
|
||||||
|
409,
|
||||||
|
40901,
|
||||||
|
"当前执行状态不可恢复,仅暂停中的工作流允许恢复");
|
||||||
|
}
|
||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +269,10 @@ public class PublicWorkflowController {
|
|||||||
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
|
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
|
||||||
Map<String, Object> res = workflowRunningParameterResolver.buildRunningParametersView(workflow);
|
Map<String, Object> res = workflowRunningParameterResolver.buildRunningParametersView(workflow);
|
||||||
if (res == null) {
|
if (res == null) {
|
||||||
return Result.fail(2, "节点配置错误,请检查! ");
|
throw new BusinessException(
|
||||||
|
500,
|
||||||
|
50001,
|
||||||
|
"工作流运行参数配置不可用");
|
||||||
}
|
}
|
||||||
return Result.ok(res);
|
return Result.ok(res);
|
||||||
}
|
}
|
||||||
@@ -181,6 +293,57 @@ public class PublicWorkflowController {
|
|||||||
return account;
|
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 只能访问严格已发布且存在发布快照的工作流。
|
* 校验工作流 Public API 只能访问严格已发布且存在发布快照的工作流。
|
||||||
*
|
*
|
||||||
@@ -189,7 +352,10 @@ public class PublicWorkflowController {
|
|||||||
private void assertStrictPublishedWorkflow(Workflow workflow) {
|
private void assertStrictPublishedWorkflow(Workflow workflow) {
|
||||||
if (workflow == null || !PublishStatus.PUBLISHED.getCode().equals(workflow.getPublishStatus())
|
if (workflow == null || !PublishStatus.PUBLISHED.getCode().equals(workflow.getPublishStatus())
|
||||||
|| workflow.getPublishedSnapshotJson() == null || workflow.getPublishedSnapshotJson().isEmpty()) {
|
|| 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) {
|
private WorkflowExecResult assertApiKeyExecutionOwnership(SysApiKey apiKey, String executeId) {
|
||||||
if (executeId == null || executeId.isBlank()) {
|
if (executeId == null || executeId.isBlank()) {
|
||||||
throw new BusinessException("执行ID不能为空");
|
throw new BusinessException(
|
||||||
|
400,
|
||||||
|
40017,
|
||||||
|
"executeId 不能为空");
|
||||||
}
|
}
|
||||||
WorkflowExecResult execResult = workflowExecResultService.getByExecKey(executeId);
|
WorkflowExecResult execResult = workflowExecResultService.getByExecKey(executeId);
|
||||||
if (execResult == null) {
|
if (execResult == null) {
|
||||||
throw new BusinessException("工作流执行记录不存在,请稍后重试");
|
throw new BusinessException(
|
||||||
|
404,
|
||||||
|
40402,
|
||||||
|
"工作流执行记录不存在、已过期或不可访问");
|
||||||
}
|
}
|
||||||
if (!WorkFlowUtil.API_KEY.equals(execResult.getCreatedKey())
|
if (!WorkFlowUtil.API_KEY.equals(execResult.getCreatedKey())
|
||||||
|| apiKey == null
|
|| apiKey == null
|
||||||
|| apiKey.getId() == null
|
|| apiKey.getId() == null
|
||||||
|| !String.valueOf(apiKey.getId()).equals(execResult.getCreatedBy())) {
|
|| !String.valueOf(apiKey.getId()).equals(execResult.getCreatedBy())) {
|
||||||
throw new BusinessException("无权限访问当前工作流执行记录");
|
throw new BusinessException(
|
||||||
|
404,
|
||||||
|
40402,
|
||||||
|
"工作流执行记录不存在、已过期或不可访问");
|
||||||
}
|
}
|
||||||
return execResult;
|
return execResult;
|
||||||
}
|
}
|
||||||
@@ -224,12 +399,18 @@ public class PublicWorkflowController {
|
|||||||
*/
|
*/
|
||||||
private void assertWorkflowExecutionResumable(WorkflowExecResult execResult) {
|
private void assertWorkflowExecutionResumable(WorkflowExecResult execResult) {
|
||||||
if (execResult == null || execResult.getWorkflowId() == null) {
|
if (execResult == null || execResult.getWorkflowId() == null) {
|
||||||
throw new BusinessException("工作流执行记录不存在,请稍后重试");
|
throw new BusinessException(
|
||||||
|
404,
|
||||||
|
40402,
|
||||||
|
"工作流执行记录不存在、已过期或不可访问");
|
||||||
}
|
}
|
||||||
Workflow workflow = workflowService.getById(execResult.getWorkflowId());
|
Workflow workflow = workflowService.getById(execResult.getWorkflowId());
|
||||||
if (workflow == null || !PublishStatus.PUBLISHED.getCode().equals(workflow.getPublishStatus())
|
if (workflow == null || !PublishStatus.PUBLISHED.getCode().equals(workflow.getPublishStatus())
|
||||||
|| workflow.getPublishedSnapshotJson() == null || workflow.getPublishedSnapshotJson().isEmpty()) {
|
|| 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 org.springframework.web.servlet.HandlerInterceptor;
|
||||||
import tech.easyflow.common.domain.Result;
|
import tech.easyflow.common.domain.Result;
|
||||||
import tech.easyflow.common.util.ResponseUtil;
|
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.entity.SysApiKey;
|
||||||
import tech.easyflow.system.service.SysApiKeyService;
|
import tech.easyflow.system.service.SysApiKeyService;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public API 访问令牌与接口权限拦截器。
|
* Public API 访问令牌与接口权限拦截器。
|
||||||
*/
|
*/
|
||||||
@@ -36,7 +40,16 @@ public class PublicApiInterceptor implements HandlerInterceptor {
|
|||||||
String apiKey = request.getHeader("ApiKey");
|
String apiKey = request.getHeader("ApiKey");
|
||||||
|
|
||||||
if (apiKey == null || apiKey.isBlank()) {
|
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);
|
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||||
ResponseUtil.renderJson(response, failed);
|
ResponseUtil.renderJson(response, failed);
|
||||||
return false;
|
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;
|
package tech.easyflow.publicapi.controller;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.junit.Assert;
|
import org.junit.Assert;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
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 响应契约测试。
|
* {@link PublicWorkflowController} HTTP 响应契约测试。
|
||||||
@@ -23,4 +37,139 @@ public class PublicWorkflowControllerContractTest {
|
|||||||
requestMapping.produces()
|
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())) {
|
if ("getHeader".equals(method.getName())) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if ("getAttribute".equals(method.getName())) {
|
||||||
|
return "request-1";
|
||||||
|
}
|
||||||
throw new AssertionError(
|
throw new AssertionError(
|
||||||
"测试路径不应调用 HttpServletRequest."
|
"测试路径不应调用 HttpServletRequest."
|
||||||
+ method.getName());
|
+ method.getName());
|
||||||
@@ -66,8 +69,9 @@ public class PublicApiInterceptorTest {
|
|||||||
Assert.assertEquals(
|
Assert.assertEquals(
|
||||||
HttpServletResponse.SC_UNAUTHORIZED,
|
HttpServletResponse.SC_UNAUTHORIZED,
|
||||||
status.get());
|
status.get());
|
||||||
Assert.assertTrue(body.toString().contains("\"errorCode\":401"));
|
Assert.assertTrue(body.toString().contains("\"errorCode\":40101"));
|
||||||
Assert.assertTrue(body.toString().contains("密钥不正确"));
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -139,6 +139,18 @@ public class FileStorageManager implements FileStorageService {
|
|||||||
return serviceForHandle(handle).saveRecoverable(file, handle);
|
return serviceForHandle(handle).saveRecoverable(file, handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 严格按句柄中的后端打开物理对象读取流。
|
||||||
|
*
|
||||||
|
* @param handle 物理对象句柄
|
||||||
|
* @return 文件输入流,由调用方关闭
|
||||||
|
* @throws IOException 无法读取物理对象时抛出
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public InputStream readRecoverable(FileStorageWriteHandle handle) throws IOException {
|
||||||
|
return serviceForHandle(handle).readRecoverable(handle);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 严格按句柄中的后端精确删除物理对象。
|
* 严格按句柄中的后端精确删除物理对象。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -91,6 +91,20 @@ public interface FileStorageService {
|
|||||||
throw unsupportedRecoverableOperation("saveRecoverable");
|
throw unsupportedRecoverableOperation("saveRecoverable");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按可恢复句柄精确打开物理对象读取流。
|
||||||
|
*
|
||||||
|
* <p>该方法只接受由可信业务记录恢复出的句柄,不得直接使用外部传入的 locator。</p>
|
||||||
|
*
|
||||||
|
* @param handle 物理对象写入句柄
|
||||||
|
* @return 文件输入流,由调用方关闭
|
||||||
|
* @throws IOException 无法打开物理对象时抛出
|
||||||
|
* @throws UnsupportedOperationException 当前后端尚未实现可恢复读取时抛出
|
||||||
|
*/
|
||||||
|
default InputStream readRecoverable(FileStorageWriteHandle handle) throws IOException {
|
||||||
|
throw unsupportedRecoverableOperation("readRecoverable");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 精确且幂等地删除句柄对应的物理对象。
|
* 精确且幂等地删除句柄对应的物理对象。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -272,6 +272,27 @@ public class LocalFileStorageServiceImpl implements FileStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按句柄固化的本地根目录精确打开普通文件。
|
||||||
|
*
|
||||||
|
* @param handle 本地可恢复写句柄
|
||||||
|
* @return 文件输入流,由调用方关闭
|
||||||
|
* @throws IOException 文件不存在、不可读或路径不安全时抛出
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public InputStream readRecoverable(FileStorageWriteHandle handle) throws IOException {
|
||||||
|
requireLocalHandle(handle);
|
||||||
|
Path target = resolveControlledTarget(handle, false);
|
||||||
|
if (!Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
|
||||||
|
throw new IOException("本地可恢复文件不存在: " + target);
|
||||||
|
}
|
||||||
|
if (Files.isSymbolicLink(target)
|
||||||
|
|| !Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) {
|
||||||
|
throw new IOException("本地可恢复读取目标不是普通文件: " + target);
|
||||||
|
}
|
||||||
|
return Files.newInputStream(target);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 精确且幂等地删除句柄对应的最终文件与确定性暂存文件,并确认两者均不存在。
|
* 精确且幂等地删除句柄对应的最终文件与确定性暂存文件,并确认两者均不存在。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package tech.easyflow.common.filestorage.impl;
|
package tech.easyflow.common.filestorage.impl;
|
||||||
|
|
||||||
|
import io.minio.GetObjectArgs;
|
||||||
import org.dromara.x.file.storage.core.FileInfo;
|
import org.dromara.x.file.storage.core.FileInfo;
|
||||||
import org.dromara.x.file.storage.core.platform.FileStorage;
|
import org.dromara.x.file.storage.core.platform.FileStorage;
|
||||||
|
import org.dromara.x.file.storage.core.platform.MinioFileStorage;
|
||||||
import org.dromara.x.file.storage.core.recorder.FileRecorder;
|
import org.dromara.x.file.storage.core.recorder.FileRecorder;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -198,6 +200,44 @@ public class XFIleStorageServiceImpl implements FileStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按句柄固定的平台和对象键读取物理文件。
|
||||||
|
*
|
||||||
|
* <p>MinIO 使用已配置客户端直接读取,兼容私有桶和内网端点;其他平台仅在能够
|
||||||
|
* 精确推导公开 URL 时使用现有读取能力。</p>
|
||||||
|
*
|
||||||
|
* @param handle x-file-storage 可恢复写句柄
|
||||||
|
* @return 文件输入流,由调用方关闭
|
||||||
|
* @throws IOException 平台不支持精确读取或对象读取失败时抛出
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public InputStream readRecoverable(FileStorageWriteHandle handle) throws IOException {
|
||||||
|
FileStorage storage = requireStorage(handle);
|
||||||
|
requirePersistedBasePathSupport(storage, handle);
|
||||||
|
FileInfo fileInfo = toFileInfo(handle);
|
||||||
|
if (storage instanceof MinioFileStorage minioStorage) {
|
||||||
|
try {
|
||||||
|
return minioStorage.getClient().getObject(
|
||||||
|
GetObjectArgs.builder()
|
||||||
|
.bucket(minioStorage.getBucketName())
|
||||||
|
.object(storage.getFileKey(fileInfo))
|
||||||
|
.build());
|
||||||
|
} catch (Exception exception) {
|
||||||
|
throw new IOException("读取 MinIO 可恢复文件失败", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String url = deriveUrlBestEffort(storage, fileInfo);
|
||||||
|
if (!StringUtils.hasText(url)) {
|
||||||
|
throw new IOException("当前 x-file-storage 平台不支持可恢复文件读取: "
|
||||||
|
+ storage.getClass().getName());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return readStream(url);
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
throw new IOException("读取 x-file-storage 可恢复文件失败", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 直接调用句柄指定平台的物理删除与存在检查,绕过依赖 URL 记录的聚合删除路径。
|
* 直接调用句柄指定平台的物理删除与存在检查,绕过依赖 URL 记录的聚合删除路径。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ public class FileStorageManagerTest {
|
|||||||
* 验证 prepare 使用当前后端,而后续操作在默认后端切换后仍按句柄后端路由。
|
* 验证 prepare 使用当前后端,而后续操作在默认后端切换后仍按句柄后端路由。
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
public void recoverableOperationsRouteByPreparedBackendAfterSwitch() {
|
public void recoverableOperationsRouteByPreparedBackendAfterSwitch() throws IOException {
|
||||||
RecordingStorage local = new RecordingStorage("local");
|
RecordingStorage local = new RecordingStorage("local");
|
||||||
RecordingStorage xFile = new RecordingStorage("xFileStorage");
|
RecordingStorage xFile = new RecordingStorage("xFileStorage");
|
||||||
AtomicReference<String> current = new AtomicReference<>("local");
|
AtomicReference<String> current = new AtomicReference<>("local");
|
||||||
@@ -32,16 +32,20 @@ public class FileStorageManagerTest {
|
|||||||
FileStorageWriteHandle handle = manager.prepareRecoverableWrite("skill-content/ab", "content.bin");
|
FileStorageWriteHandle handle = manager.prepareRecoverableWrite("skill-content/ab", "content.bin");
|
||||||
current.set("xFileStorage");
|
current.set("xFileStorage");
|
||||||
FileStorageWriteResult result = manager.saveRecoverable(null, handle);
|
FileStorageWriteResult result = manager.saveRecoverable(null, handle);
|
||||||
|
InputStream inputStream = manager.readRecoverable(handle);
|
||||||
manager.deleteRecoverable(handle);
|
manager.deleteRecoverable(handle);
|
||||||
boolean exists = manager.existsRecoverable(handle);
|
boolean exists = manager.existsRecoverable(handle);
|
||||||
|
|
||||||
assertEquals("local", handle.getBackend());
|
assertEquals("local", handle.getBackend());
|
||||||
assertSame(local.result, result);
|
assertSame(local.result, result);
|
||||||
|
assertSame(local.recoverableInput, inputStream);
|
||||||
assertEquals(1, local.prepareCalls);
|
assertEquals(1, local.prepareCalls);
|
||||||
assertEquals(1, local.saveCalls);
|
assertEquals(1, local.saveCalls);
|
||||||
|
assertEquals(1, local.readCalls);
|
||||||
assertEquals(1, local.deleteCalls);
|
assertEquals(1, local.deleteCalls);
|
||||||
assertEquals(1, local.existsCalls);
|
assertEquals(1, local.existsCalls);
|
||||||
assertEquals(0, xFile.prepareCalls + xFile.saveCalls + xFile.deleteCalls + xFile.existsCalls);
|
assertEquals(0, xFile.prepareCalls + xFile.saveCalls + xFile.readCalls
|
||||||
|
+ xFile.deleteCalls + xFile.existsCalls);
|
||||||
assertFalse(exists);
|
assertFalse(exists);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,10 +57,14 @@ public class FileStorageManagerTest {
|
|||||||
private final String backend;
|
private final String backend;
|
||||||
/** 固定结果。 */
|
/** 固定结果。 */
|
||||||
private final FileStorageWriteResult result;
|
private final FileStorageWriteResult result;
|
||||||
|
/** 固定可恢复读取流。 */
|
||||||
|
private final InputStream recoverableInput = InputStream.nullInputStream();
|
||||||
/** prepare 调用次数。 */
|
/** prepare 调用次数。 */
|
||||||
private int prepareCalls;
|
private int prepareCalls;
|
||||||
/** save 调用次数。 */
|
/** save 调用次数。 */
|
||||||
private int saveCalls;
|
private int saveCalls;
|
||||||
|
/** read 调用次数。 */
|
||||||
|
private int readCalls;
|
||||||
/** delete 调用次数。 */
|
/** delete 调用次数。 */
|
||||||
private int deleteCalls;
|
private int deleteCalls;
|
||||||
/** exists 调用次数。 */
|
/** exists 调用次数。 */
|
||||||
@@ -99,6 +107,13 @@ public class FileStorageManagerTest {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} */
|
||||||
|
@Override
|
||||||
|
public InputStream readRecoverable(FileStorageWriteHandle handle) {
|
||||||
|
readCalls++;
|
||||||
|
return recoverableInput;
|
||||||
|
}
|
||||||
|
|
||||||
/** {@inheritDoc} */
|
/** {@inheritDoc} */
|
||||||
@Override
|
@Override
|
||||||
public void deleteRecoverable(FileStorageWriteHandle handle) {
|
public void deleteRecoverable(FileStorageWriteHandle handle) {
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ public class LocalFileStorageServiceImplTest {
|
|||||||
assertEquals(handle, FileStorageWriteHandle.decodeLocator(result.getLocator()));
|
assertEquals(handle, FileStorageWriteHandle.decodeLocator(result.getLocator()));
|
||||||
assertTrue(service.existsRecoverable(handle));
|
assertTrue(service.existsRecoverable(handle));
|
||||||
assertArrayEquals(bytes, Files.readAllBytes(target));
|
assertArrayEquals(bytes, Files.readAllBytes(target));
|
||||||
|
try (InputStream inputStream = service.readRecoverable(handle)) {
|
||||||
|
assertArrayEquals(bytes, inputStream.readAllBytes());
|
||||||
|
}
|
||||||
assertFalse(Files.exists(changedRoot.toPath().resolve("skill-content/ab/content.bin")));
|
assertFalse(Files.exists(changedRoot.toPath().resolve("skill-content/ab/content.bin")));
|
||||||
|
|
||||||
service.deleteRecoverable(handle);
|
service.deleteRecoverable(handle);
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
package tech.easyflow.common.filestorage.impl;
|
package tech.easyflow.common.filestorage.impl;
|
||||||
|
|
||||||
|
import io.minio.GetObjectArgs;
|
||||||
|
import io.minio.GetObjectResponse;
|
||||||
|
import io.minio.MinioClient;
|
||||||
|
import okhttp3.Headers;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
import org.dromara.x.file.storage.core.FileInfo;
|
import org.dromara.x.file.storage.core.FileInfo;
|
||||||
import org.dromara.x.file.storage.core.UploadPretreatment;
|
import org.dromara.x.file.storage.core.UploadPretreatment;
|
||||||
import org.dromara.x.file.storage.core.platform.FileStorage;
|
import org.dromara.x.file.storage.core.platform.FileStorage;
|
||||||
|
import org.dromara.x.file.storage.core.platform.FileStorageClientFactory;
|
||||||
|
import org.dromara.x.file.storage.core.platform.MinioFileStorage;
|
||||||
import org.dromara.x.file.storage.core.recorder.FileRecorder;
|
import org.dromara.x.file.storage.core.recorder.FileRecorder;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
|
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
|
||||||
@@ -17,6 +23,7 @@ import java.lang.reflect.Field;
|
|||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertArrayEquals;
|
||||||
import static org.junit.Assert.assertEquals;
|
import static org.junit.Assert.assertEquals;
|
||||||
import static org.junit.Assert.assertFalse;
|
import static org.junit.Assert.assertFalse;
|
||||||
import static org.junit.Assert.assertNull;
|
import static org.junit.Assert.assertNull;
|
||||||
@@ -102,6 +109,42 @@ public class XFIleStorageServiceImplTest {
|
|||||||
assertTrue(platform.exists);
|
assertTrue(platform.exists);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 MinIO 可恢复读取使用已配置客户端和句柄中的精确对象键,不请求公开 URL。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试替身配置或流读取失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void recoverableReadUsesMinioClientAndExactObjectKey() throws Exception {
|
||||||
|
byte[] content = "managed-content".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
RecordingMinioClient client = new RecordingMinioClient(content);
|
||||||
|
MinioFileStorage platform = new MinioFileStorage();
|
||||||
|
platform.setPlatform("minio-main");
|
||||||
|
platform.setBucketName("easyflow");
|
||||||
|
platform.setBasePath("easyflow/");
|
||||||
|
platform.setDomain("http://127.0.0.1:39000/");
|
||||||
|
platform.setClientFactory(new FixedMinioClientFactory(client));
|
||||||
|
XFIleStorageServiceImpl service = createService(
|
||||||
|
new RecoverableStorageService(platform));
|
||||||
|
FileStorageWriteHandle handle = new FileStorageWriteHandle(
|
||||||
|
"xFileStorage",
|
||||||
|
"minio-main",
|
||||||
|
"easyflow/",
|
||||||
|
"workflow-api-upload/request",
|
||||||
|
"content.bin");
|
||||||
|
|
||||||
|
byte[] actual;
|
||||||
|
try (InputStream inputStream = service.readRecoverable(handle)) {
|
||||||
|
actual = inputStream.readAllBytes();
|
||||||
|
}
|
||||||
|
|
||||||
|
assertArrayEquals(content, actual);
|
||||||
|
assertEquals("easyflow", client.lastArgs.bucket());
|
||||||
|
assertEquals(
|
||||||
|
"easyflow/workflow-api-upload/request/content.bin",
|
||||||
|
client.lastArgs.object());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证 recorder 完全缺失目标记录时,精确删除仍直接作用于物理平台并成功。
|
* 验证 recorder 完全缺失目标记录时,精确删除仍直接作用于物理平台并成功。
|
||||||
*
|
*
|
||||||
@@ -401,6 +444,73 @@ public class XFIleStorageServiceImplTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 始终返回同一 MinIO 客户端的测试工厂。
|
||||||
|
*/
|
||||||
|
private static final class FixedMinioClientFactory
|
||||||
|
implements FileStorageClientFactory<MinioClient> {
|
||||||
|
|
||||||
|
/** 固定客户端。 */
|
||||||
|
private final MinioClient client;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建固定客户端工厂。
|
||||||
|
*
|
||||||
|
* @param client MinIO 客户端
|
||||||
|
*/
|
||||||
|
private FixedMinioClientFactory(MinioClient client) {
|
||||||
|
this.client = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} */
|
||||||
|
@Override
|
||||||
|
public String getPlatform() {
|
||||||
|
return "minio-main";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} */
|
||||||
|
@Override
|
||||||
|
public MinioClient getClient() {
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录精确对象参数并返回内存内容的 MinIO 客户端替身。
|
||||||
|
*/
|
||||||
|
private static final class RecordingMinioClient extends MinioClient {
|
||||||
|
|
||||||
|
/** 固定返回内容。 */
|
||||||
|
private final byte[] content;
|
||||||
|
/** 最后一次读取参数。 */
|
||||||
|
private GetObjectArgs lastArgs;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建内存 MinIO 客户端替身。
|
||||||
|
*
|
||||||
|
* @param content 固定返回内容
|
||||||
|
*/
|
||||||
|
private RecordingMinioClient(byte[] content) {
|
||||||
|
super(MinioClient.builder()
|
||||||
|
.endpoint("http://127.0.0.1:39000")
|
||||||
|
.credentials("test-access-key", "test-secret-key")
|
||||||
|
.build());
|
||||||
|
this.content = content.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} */
|
||||||
|
@Override
|
||||||
|
public GetObjectResponse getObject(GetObjectArgs args) {
|
||||||
|
this.lastArgs = args;
|
||||||
|
return new GetObjectResponse(
|
||||||
|
new Headers.Builder().build(),
|
||||||
|
args.bucket(),
|
||||||
|
null,
|
||||||
|
args.object(),
|
||||||
|
new ByteArrayInputStream(content));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 不访问真实网络、仅捕获上传参数的预处理器。
|
* 不访问真实网络、仅捕获上传参数的预处理器。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -45,7 +45,20 @@ public class GlobalErrorResolver implements HandlerExceptionResolver {
|
|||||||
@Override
|
@Override
|
||||||
public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
|
public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
|
||||||
Result<?> error;
|
Result<?> error;
|
||||||
if (ex instanceof MissingServletRequestParameterException) {
|
WebErrorMapping profiledError = resolveProfiledError(request, ex);
|
||||||
|
if (profiledError != null) {
|
||||||
|
response.setStatus(profiledError.httpStatus());
|
||||||
|
if (profiledError.httpStatus() >= 500) {
|
||||||
|
LOG.error(
|
||||||
|
"请求级错误契约处理到服务端异常,method={}, uri={}, requestId={}, errorCode={}",
|
||||||
|
request.getMethod(),
|
||||||
|
request.getRequestURI(),
|
||||||
|
RequestIdContext.get(request),
|
||||||
|
profiledError.errorCode(),
|
||||||
|
ex);
|
||||||
|
}
|
||||||
|
error = buildProfiledError(profiledError);
|
||||||
|
} else if (ex instanceof MissingServletRequestParameterException) {
|
||||||
response.setStatus(HttpStatus.BAD_REQUEST.value());
|
response.setStatus(HttpStatus.BAD_REQUEST.value());
|
||||||
error = Result.fail(400, ((MissingServletRequestParameterException) ex).getParameterName() + " 不能为空");
|
error = Result.fail(400, ((MissingServletRequestParameterException) ex).getParameterName() + " 不能为空");
|
||||||
} else if (ex instanceof NotLoginException notLoginException) {
|
} else if (ex instanceof NotLoginException notLoginException) {
|
||||||
@@ -98,6 +111,48 @@ public class GlobalErrorResolver implements HandlerExceptionResolver {
|
|||||||
.addAllObjects(object);
|
.addAllObjects(object);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用请求进入 MVC 前注册的错误契约。
|
||||||
|
*
|
||||||
|
* @param request 当前请求
|
||||||
|
* @param exception 原始异常
|
||||||
|
* @return 受控错误映射;未注册或不处理时返回 {@code null}
|
||||||
|
*/
|
||||||
|
private WebErrorMapping resolveProfiledError(
|
||||||
|
HttpServletRequest request,
|
||||||
|
Exception exception) {
|
||||||
|
Object attribute = request.getAttribute(
|
||||||
|
RequestErrorProfile.ATTRIBUTE_NAME);
|
||||||
|
if (!(attribute instanceof RequestErrorProfile profile)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return profile.map(request, exception);
|
||||||
|
} catch (RuntimeException mappingError) {
|
||||||
|
LOG.error(
|
||||||
|
"请求级错误契约映射失败,method={}, uri={}, requestId={}",
|
||||||
|
request.getMethod(),
|
||||||
|
request.getRequestURI(),
|
||||||
|
RequestIdContext.get(request),
|
||||||
|
mappingError);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将受控错误映射转换为统一响应对象。
|
||||||
|
*
|
||||||
|
* @param mapping 错误映射
|
||||||
|
* @return 统一错误响应
|
||||||
|
*/
|
||||||
|
private Result<?> buildProfiledError(WebErrorMapping mapping) {
|
||||||
|
Result<Object> result = Result.fail(
|
||||||
|
mapping.message(),
|
||||||
|
mapping.data());
|
||||||
|
result.setErrorCode(mapping.errorCode());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 读取注解声明的 HTTP 状态。
|
* 读取注解声明的 HTTP 状态。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package tech.easyflow.common.web.error;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为单个请求提供可选的异常到公共错误契约映射。
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface RequestErrorProfile {
|
||||||
|
|
||||||
|
/** Servlet 请求属性名。 */
|
||||||
|
String ATTRIBUTE_NAME =
|
||||||
|
RequestErrorProfile.class.getName() + ".profile";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将异常转换为受控错误响应。
|
||||||
|
*
|
||||||
|
* @param request 当前请求
|
||||||
|
* @param exception 原始异常
|
||||||
|
* @return 错误映射;不处理该异常时返回 {@code null}
|
||||||
|
*/
|
||||||
|
WebErrorMapping map(
|
||||||
|
HttpServletRequest request,
|
||||||
|
Exception exception);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package tech.easyflow.common.web.error;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web 请求关联标识的统一常量与读取入口。
|
||||||
|
*/
|
||||||
|
public final class RequestIdContext {
|
||||||
|
|
||||||
|
/** 对外请求关联标识响应头。 */
|
||||||
|
public static final String HEADER_NAME = "X-Request-Id";
|
||||||
|
/** Servlet 请求属性名。 */
|
||||||
|
public static final String ATTRIBUTE_NAME =
|
||||||
|
RequestIdContext.class.getName() + ".requestId";
|
||||||
|
/** 日志 MDC 字段名。 */
|
||||||
|
public static final String MDC_KEY = "requestId";
|
||||||
|
|
||||||
|
private RequestIdContext() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 Servlet 请求中读取已初始化的请求关联标识。
|
||||||
|
*
|
||||||
|
* @param request 当前请求
|
||||||
|
* @return 请求关联标识;尚未初始化时返回 {@code null}
|
||||||
|
*/
|
||||||
|
public static String get(HttpServletRequest request) {
|
||||||
|
if (request == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Object value = request.getAttribute(ATTRIBUTE_NAME);
|
||||||
|
return value instanceof String requestId && !requestId.isBlank()
|
||||||
|
? requestId
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package tech.easyflow.common.web.error;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web 异常的受控 HTTP 响应映射。
|
||||||
|
*
|
||||||
|
* @param httpStatus HTTP 状态码
|
||||||
|
* @param errorCode 稳定业务错误码
|
||||||
|
* @param message 可安全展示的错误消息
|
||||||
|
* @param data 可选的受控错误详情
|
||||||
|
*/
|
||||||
|
public record WebErrorMapping(
|
||||||
|
int httpStatus,
|
||||||
|
int errorCode,
|
||||||
|
String message,
|
||||||
|
Object data) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package tech.easyflow.common.web.multipart;
|
||||||
|
|
||||||
|
import org.springframework.http.InvalidMediaTypeException;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.MediaTypeFactory;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multipart 文件名与内容类型的安全归一化工具。
|
||||||
|
*/
|
||||||
|
public final class MultipartFileMetadataNormalizer {
|
||||||
|
|
||||||
|
private static final int MAX_FILENAME_LENGTH = 255;
|
||||||
|
|
||||||
|
private MultipartFileMetadataNormalizer() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 归一化文件 Part 的内容类型。
|
||||||
|
*
|
||||||
|
* <p>合法且具体的客户端值优先;空值、占位值或非法值按文件扩展名推断,
|
||||||
|
* 无法推断时使用 {@code application/octet-stream}。</p>
|
||||||
|
*
|
||||||
|
* @param originalFilename 原始文件名
|
||||||
|
* @param declaredContentType 客户端声明的内容类型
|
||||||
|
* @return 可安全用于存储请求的标准内容类型
|
||||||
|
*/
|
||||||
|
public static String normalizeContentType(
|
||||||
|
String originalFilename,
|
||||||
|
String declaredContentType) {
|
||||||
|
MediaType declared = parseConcrete(declaredContentType);
|
||||||
|
if (declared != null) {
|
||||||
|
return declared.toString();
|
||||||
|
}
|
||||||
|
return MediaTypeFactory
|
||||||
|
.getMediaType(sanitizeFilename(originalFilename))
|
||||||
|
.filter(MediaType::isConcrete)
|
||||||
|
.orElse(MediaType.APPLICATION_OCTET_STREAM)
|
||||||
|
.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除客户端目录片段、控制字符和超长内容。
|
||||||
|
*
|
||||||
|
* @param originalFilename 原始文件名
|
||||||
|
* @return 安全基础文件名
|
||||||
|
*/
|
||||||
|
public static String sanitizeFilename(String originalFilename) {
|
||||||
|
String cleaned = StringUtils.cleanPath(
|
||||||
|
originalFilename == null ? "" : originalFilename);
|
||||||
|
String filename = StringUtils.getFilename(cleaned);
|
||||||
|
if (filename == null) {
|
||||||
|
filename = "";
|
||||||
|
}
|
||||||
|
filename = filename.replaceAll("[\\p{Cntrl}]", "").trim();
|
||||||
|
if (!StringUtils.hasText(filename)
|
||||||
|
|| ".".equals(filename)
|
||||||
|
|| "..".equals(filename)) {
|
||||||
|
return "file";
|
||||||
|
}
|
||||||
|
if (filename.length() <= MAX_FILENAME_LENGTH) {
|
||||||
|
return filename;
|
||||||
|
}
|
||||||
|
int extensionStart = filename.lastIndexOf('.');
|
||||||
|
if (extensionStart > 0) {
|
||||||
|
String extension = filename.substring(extensionStart);
|
||||||
|
if (extension.length() <= 17) {
|
||||||
|
return filename.substring(
|
||||||
|
0,
|
||||||
|
MAX_FILENAME_LENGTH - extension.length())
|
||||||
|
+ extension;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filename.substring(0, MAX_FILENAME_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析合法且具体的媒体类型。
|
||||||
|
*
|
||||||
|
* @param contentType 原始内容类型
|
||||||
|
* @return 解析结果;值不可用时返回 {@code null}
|
||||||
|
*/
|
||||||
|
private static MediaType parseConcrete(String contentType) {
|
||||||
|
if (!StringUtils.hasText(contentType)
|
||||||
|
|| "other".equalsIgnoreCase(contentType.trim())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
MediaType mediaType = MediaType.parseMediaType(
|
||||||
|
contentType.trim());
|
||||||
|
return mediaType.isConcrete() ? mediaType : null;
|
||||||
|
} catch (InvalidMediaTypeException exception) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package tech.easyflow.common.web.multipart;
|
||||||
|
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link MultipartFileMetadataNormalizer} 回归测试。
|
||||||
|
*/
|
||||||
|
public class MultipartFileMetadataNormalizerTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证合法媒体类型会被保留。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldKeepValidDeclaredContentType() {
|
||||||
|
Assert.assertEquals(
|
||||||
|
"application/pdf",
|
||||||
|
MultipartFileMetadataNormalizer.normalizeContentType(
|
||||||
|
"report.pdf",
|
||||||
|
"application/pdf"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证客户端占位值会按扩展名推断。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldInferContentTypeWhenClientSendsOther() {
|
||||||
|
Assert.assertEquals(
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
MultipartFileMetadataNormalizer.normalizeContentType(
|
||||||
|
"report.docx",
|
||||||
|
"Other"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证空媒体类型也会按扩展名推断。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldInferContentTypeWhenClientOmitsIt() {
|
||||||
|
Assert.assertEquals(
|
||||||
|
"application/pdf",
|
||||||
|
MultipartFileMetadataNormalizer.normalizeContentType(
|
||||||
|
"report.pdf",
|
||||||
|
" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证非法媒体类型且无法推断时使用二进制兜底。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldFallbackToOctetStreamForUnknownFile() {
|
||||||
|
Assert.assertEquals(
|
||||||
|
"application/octet-stream",
|
||||||
|
MultipartFileMetadataNormalizer.normalizeContentType(
|
||||||
|
"payload.unknown-extension",
|
||||||
|
"invalid content type"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证文件名不会携带客户端目录片段。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldRemoveClientPathFromFilename() {
|
||||||
|
Assert.assertEquals(
|
||||||
|
"report.pdf",
|
||||||
|
MultipartFileMetadataNormalizer.sanitizeFilename(
|
||||||
|
"C:\\fakepath\\report.pdf"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证超长文件名截断后仍保留可用于 MIME 推断的扩展名。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldKeepExtensionWhenSanitizingLongFilename() {
|
||||||
|
String filename = "a".repeat(300) + ".pdf";
|
||||||
|
|
||||||
|
String sanitized =
|
||||||
|
MultipartFileMetadataNormalizer.sanitizeFilename(
|
||||||
|
filename);
|
||||||
|
|
||||||
|
Assert.assertEquals(255, sanitized.length());
|
||||||
|
Assert.assertTrue(sanitized.endsWith(".pdf"));
|
||||||
|
Assert.assertEquals(
|
||||||
|
"application/pdf",
|
||||||
|
MultipartFileMetadataNormalizer.normalizeContentType(
|
||||||
|
sanitized,
|
||||||
|
"Other"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package tech.easyflow.ai.easyagentsflow.config;
|
package tech.easyflow.ai.easyagentsflow.config;
|
||||||
|
|
||||||
|
import com.easyagents.flow.core.chain.event.ChainStatusChangeEvent;
|
||||||
import com.easyagents.flow.core.chain.repository.ChainDefinitionRepository;
|
import com.easyagents.flow.core.chain.repository.ChainDefinitionRepository;
|
||||||
import com.easyagents.flow.core.chain.repository.ChainDefinitionSnapshotRepository;
|
import com.easyagents.flow.core.chain.repository.ChainDefinitionSnapshotRepository;
|
||||||
import com.easyagents.flow.core.chain.repository.ChainStateRepository;
|
import com.easyagents.flow.core.chain.repository.ChainStateRepository;
|
||||||
@@ -13,6 +14,7 @@ import org.springframework.context.annotation.Configuration;
|
|||||||
import tech.easyflow.ai.easyagentsflow.listener.ChainErrorListenerForSave;
|
import tech.easyflow.ai.easyagentsflow.listener.ChainErrorListenerForSave;
|
||||||
import tech.easyflow.ai.easyagentsflow.listener.ChainEventListenerForSave;
|
import tech.easyflow.ai.easyagentsflow.listener.ChainEventListenerForSave;
|
||||||
import tech.easyflow.ai.easyagentsflow.listener.NodeErrorListenerForSave;
|
import tech.easyflow.ai.easyagentsflow.listener.NodeErrorListenerForSave;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadCleanupListener;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
@@ -36,6 +38,8 @@ public class ChainExecutorConfig {
|
|||||||
@Resource
|
@Resource
|
||||||
private ChainEventListenerForSave chainEventListenerForSave;
|
private ChainEventListenerForSave chainEventListenerForSave;
|
||||||
@Resource
|
@Resource
|
||||||
|
private WorkflowApiUploadCleanupListener workflowApiUploadCleanupListener;
|
||||||
|
@Resource
|
||||||
private WorkflowExecutionBudgetProperties workflowExecutionBudgetProperties;
|
private WorkflowExecutionBudgetProperties workflowExecutionBudgetProperties;
|
||||||
@Resource
|
@Resource
|
||||||
private WorkflowRuntimeProperties workflowRuntimeProperties;
|
private WorkflowRuntimeProperties workflowRuntimeProperties;
|
||||||
@@ -84,6 +88,9 @@ public class ChainExecutorConfig {
|
|||||||
*/
|
*/
|
||||||
private void saveStepsListeners(ChainExecutor chainExecutor) {
|
private void saveStepsListeners(ChainExecutor chainExecutor) {
|
||||||
chainExecutor.addEventListener(chainEventListenerForSave);
|
chainExecutor.addEventListener(chainEventListenerForSave);
|
||||||
|
chainExecutor.addEventListener(
|
||||||
|
ChainStatusChangeEvent.class,
|
||||||
|
workflowApiUploadCleanupListener);
|
||||||
chainExecutor.addErrorListener(new ChainErrorListenerForSave());
|
chainExecutor.addErrorListener(new ChainErrorListenerForSave());
|
||||||
chainExecutor.addNodeErrorListener(new NodeErrorListenerForSave());
|
chainExecutor.addNodeErrorListener(new NodeErrorListenerForSave());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,8 +51,17 @@ public class TinyFlowService {
|
|||||||
}
|
}
|
||||||
ChainInfo res = getChainInfo(executeId, chainState);
|
ChainInfo res = getChainInfo(executeId, chainState);
|
||||||
|
|
||||||
if (nodes != null) {
|
if (nodes != null && !nodes.isEmpty()) {
|
||||||
|
Map<String, String> resolvedNodeNames =
|
||||||
|
chainExecutor.getInstanceNodeNames(chainState);
|
||||||
|
Map<String, String> nodeNames = resolvedNodeNames == null
|
||||||
|
? Map.of()
|
||||||
|
: resolvedNodeNames;
|
||||||
for (NodeInfo node : nodes) {
|
for (NodeInfo node : nodes) {
|
||||||
|
if (node != null
|
||||||
|
&& StringUtil.noText(node.getNodeName())) {
|
||||||
|
node.setNodeName(nodeNames.get(node.getNodeId()));
|
||||||
|
}
|
||||||
processNodeState(executeId, node, chainState, nodeStateRepository);
|
processNodeState(executeId, node, chainState, nodeStateRepository);
|
||||||
res.getNodes().put(node.getNodeId(), node);
|
res.getNodes().put(node.getNodeId(), node);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import tech.easyflow.common.web.exceptions.BusinessException;
|
|||||||
import tech.easyflow.ai.entity.Workflow;
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URLDecoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
@@ -88,6 +91,53 @@ public class WorkflowRunningParameterResolver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析开始节点中可通过 multipart 上传的文件参数名。
|
||||||
|
*
|
||||||
|
* @param content 工作流内容
|
||||||
|
* @return 保持开始节点定义顺序的文件参数名集合
|
||||||
|
*/
|
||||||
|
public Set<String> resolveFileParameterNames(String content) {
|
||||||
|
List<Parameter> startParameters = resolveStartParameters(content);
|
||||||
|
Set<String> names = new LinkedHashSet<>();
|
||||||
|
if (startParameters == null || startParameters.isEmpty()) {
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
for (Parameter parameter : startParameters) {
|
||||||
|
String name = trimToNull(
|
||||||
|
parameter == null ? null : parameter.getName());
|
||||||
|
if (StringUtils.hasText(name) && isFileParameter(parameter)) {
|
||||||
|
names.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析开始节点中必须提供值的文件参数名。
|
||||||
|
*
|
||||||
|
* @param content 工作流内容
|
||||||
|
* @return 保持开始节点定义顺序的必填文件参数名集合
|
||||||
|
*/
|
||||||
|
public Set<String> resolveRequiredFileParameterNames(
|
||||||
|
String content) {
|
||||||
|
List<Parameter> startParameters = resolveStartParameters(content);
|
||||||
|
Set<String> names = new LinkedHashSet<>();
|
||||||
|
if (startParameters == null || startParameters.isEmpty()) {
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
for (Parameter parameter : startParameters) {
|
||||||
|
String name = trimToNull(
|
||||||
|
parameter == null ? null : parameter.getName());
|
||||||
|
if (StringUtils.hasText(name)
|
||||||
|
&& isFileParameter(parameter)
|
||||||
|
&& parameter.isRequired()) {
|
||||||
|
names.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 归一化工作流运行时变量,确保文件参数统一为文件对象数组。
|
* 归一化工作流运行时变量,确保文件参数统一为文件对象数组。
|
||||||
*
|
*
|
||||||
@@ -508,9 +558,9 @@ public class WorkflowRunningParameterResolver {
|
|||||||
Set<String> seenFilePaths = new LinkedHashSet<>();
|
Set<String> seenFilePaths = new LinkedHashSet<>();
|
||||||
long totalSize = 0L;
|
long totalSize = 0L;
|
||||||
for (Object candidate : candidates) {
|
for (Object candidate : candidates) {
|
||||||
if (!(candidate instanceof Map<?, ?> fileMap)) {
|
Map<?, ?> fileMap = normalizeFileCandidate(
|
||||||
throw new BusinessException("文件参数 " + parameterName + " 的输入格式不正确,必须为文件对象或文件对象数组");
|
candidate,
|
||||||
}
|
parameterName);
|
||||||
String fileName = trimObjectToNull(fileMap.get("fileName"));
|
String fileName = trimObjectToNull(fileMap.get("fileName"));
|
||||||
String filePath = trimObjectToNull(fileMap.get("filePath"));
|
String filePath = trimObjectToNull(fileMap.get("filePath"));
|
||||||
if (!StringUtils.hasText(fileName)) {
|
if (!StringUtils.hasText(fileName)) {
|
||||||
@@ -541,6 +591,94 @@ public class WorkflowRunningParameterResolver {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将文件对象或远程 URL 字符串转换为统一文件描述。
|
||||||
|
*
|
||||||
|
* @param candidate 原始文件值
|
||||||
|
* @param parameterName 工作流文件参数名
|
||||||
|
* @return 可继续执行通用校验的文件描述
|
||||||
|
* @throws BusinessException URL 无效或无法识别文件名时抛出
|
||||||
|
*/
|
||||||
|
private Map<?, ?> normalizeFileCandidate(
|
||||||
|
Object candidate,
|
||||||
|
String parameterName) {
|
||||||
|
if (candidate instanceof Map<?, ?> fileMap) {
|
||||||
|
return fileMap;
|
||||||
|
}
|
||||||
|
if (!(candidate instanceof String stringValue)) {
|
||||||
|
throw new BusinessException(
|
||||||
|
"文件参数 " + parameterName
|
||||||
|
+ " 的输入格式不正确,必须为文件 URL、文件对象或对应数组");
|
||||||
|
}
|
||||||
|
String fileUrl = trimToNull(stringValue);
|
||||||
|
if (!isHttpUrl(fileUrl)) {
|
||||||
|
throw new BusinessException(
|
||||||
|
"文件参数 " + parameterName
|
||||||
|
+ " 仅支持 HTTP/HTTPS 文件 URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> normalized = new LinkedHashMap<>();
|
||||||
|
normalized.put("fileName", resolveRemoteFileName(
|
||||||
|
fileUrl,
|
||||||
|
parameterName));
|
||||||
|
normalized.put("filePath", fileUrl);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从远程 URL 路径中提取并解码文件名。
|
||||||
|
*
|
||||||
|
* @param fileUrl 远程文件 URL
|
||||||
|
* @param parameterName 工作流文件参数名
|
||||||
|
* @return 带扩展名的文件名
|
||||||
|
* @throws BusinessException URL 无效或路径中没有可识别文件名时抛出
|
||||||
|
*/
|
||||||
|
private String resolveRemoteFileName(
|
||||||
|
String fileUrl,
|
||||||
|
String parameterName) {
|
||||||
|
try {
|
||||||
|
URI uri = URI.create(fileUrl);
|
||||||
|
String rawPath = uri.getRawPath();
|
||||||
|
int lastSlash = rawPath == null ? -1 : rawPath.lastIndexOf('/');
|
||||||
|
String rawFileName = lastSlash < 0
|
||||||
|
? rawPath
|
||||||
|
: rawPath.substring(lastSlash + 1);
|
||||||
|
String fileName = StringUtils.hasText(rawFileName)
|
||||||
|
? URLDecoder.decode(
|
||||||
|
rawFileName.replace("+", "%2B"),
|
||||||
|
StandardCharsets.UTF_8)
|
||||||
|
: null;
|
||||||
|
int lastDot = fileName == null ? -1 : fileName.lastIndexOf('.');
|
||||||
|
String extension = lastDot < 0
|
||||||
|
? null
|
||||||
|
: fileName.substring(lastDot + 1);
|
||||||
|
if (!StringUtils.hasText(uri.getRawAuthority())
|
||||||
|
|| !StringUtils.hasText(fileName)
|
||||||
|
|| lastDot <= 0
|
||||||
|
|| !StringUtils.hasText(extension)
|
||||||
|
|| !extension.matches("[A-Za-z0-9]{1,16}")
|
||||||
|
|| fileName.indexOf('/') >= 0
|
||||||
|
|| fileName.indexOf('\\') >= 0) {
|
||||||
|
throw invalidRemoteFileName(parameterName);
|
||||||
|
}
|
||||||
|
return fileName;
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
throw invalidRemoteFileName(parameterName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建无法从 URL 识别文件名时的统一业务异常。
|
||||||
|
*
|
||||||
|
* @param parameterName 工作流文件参数名
|
||||||
|
* @return 统一业务异常
|
||||||
|
*/
|
||||||
|
private BusinessException invalidRemoteFileName(String parameterName) {
|
||||||
|
return new BusinessException(
|
||||||
|
"文件参数 " + parameterName
|
||||||
|
+ " 的 URL 路径无法识别带扩展名的文件名,请改用包含 fileName 和 filePath 的文件对象");
|
||||||
|
}
|
||||||
|
|
||||||
private void collectFileValues(Object value, List<Object> result) {
|
private void collectFileValues(Object value, List<Object> result) {
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.upload;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import tech.easyflow.common.web.multipart.MultipartFileMetadataNormalizer;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为工作流 Public API 创建文件名和内容类型一致的 Multipart 文件视图。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class WorkflowApiMultipartFileNormalizer {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 归一化单个文件 Part,不读取或复制文件内容。
|
||||||
|
*
|
||||||
|
* @param file 原始文件 Part
|
||||||
|
* @return 归一化文件视图;输入为空时返回 {@code null}
|
||||||
|
*/
|
||||||
|
public MultipartFile normalize(MultipartFile file) {
|
||||||
|
if (file == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String filename = MultipartFileMetadataNormalizer.sanitizeFilename(
|
||||||
|
file.getOriginalFilename());
|
||||||
|
String contentType =
|
||||||
|
MultipartFileMetadataNormalizer.normalizeContentType(
|
||||||
|
filename,
|
||||||
|
file.getContentType());
|
||||||
|
if (Objects.equals(filename, file.getOriginalFilename())
|
||||||
|
&& Objects.equals(contentType, file.getContentType())) {
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
return new NormalizedMultipartFile(
|
||||||
|
file,
|
||||||
|
filename,
|
||||||
|
contentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅覆盖安全元数据并委托文件内容访问的 Multipart 视图。
|
||||||
|
*/
|
||||||
|
private static final class NormalizedMultipartFile
|
||||||
|
implements MultipartFile {
|
||||||
|
|
||||||
|
private final MultipartFile delegate;
|
||||||
|
private final String originalFilename;
|
||||||
|
private final String contentType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建归一化文件视图。
|
||||||
|
*
|
||||||
|
* @param delegate 原始文件
|
||||||
|
* @param originalFilename 安全文件名
|
||||||
|
* @param contentType 标准内容类型
|
||||||
|
*/
|
||||||
|
private NormalizedMultipartFile(
|
||||||
|
MultipartFile delegate,
|
||||||
|
String originalFilename,
|
||||||
|
String contentType) {
|
||||||
|
this.delegate = delegate;
|
||||||
|
this.originalFilename = originalFilename;
|
||||||
|
this.contentType = contentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return delegate.getName();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public String getOriginalFilename() {
|
||||||
|
return originalFilename;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public String getContentType() {
|
||||||
|
return contentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean isEmpty() {
|
||||||
|
return delegate.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public long getSize() {
|
||||||
|
return delegate.getSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public byte[] getBytes() throws IOException {
|
||||||
|
return delegate.getBytes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public InputStream getInputStream() throws IOException {
|
||||||
|
return delegate.getInputStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void transferTo(File destination)
|
||||||
|
throws IOException, IllegalStateException {
|
||||||
|
delegate.transferTo(destination);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.upload;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public Workflow API multipart 文件准备结果。
|
||||||
|
*/
|
||||||
|
public class WorkflowApiPreparedUpload {
|
||||||
|
|
||||||
|
private final String requestId;
|
||||||
|
private final Map<String, Object> variables;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建文件准备结果。
|
||||||
|
*
|
||||||
|
* @param requestId 临时上传请求 ID
|
||||||
|
* @param variables 已注入文件描述的工作流变量
|
||||||
|
*/
|
||||||
|
public WorkflowApiPreparedUpload(
|
||||||
|
String requestId,
|
||||||
|
Map<String, Object> variables) {
|
||||||
|
this.requestId = requestId;
|
||||||
|
this.variables = variables;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取临时上传请求 ID。
|
||||||
|
*
|
||||||
|
* @return 临时上传请求 ID
|
||||||
|
*/
|
||||||
|
public String getRequestId() {
|
||||||
|
return requestId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取已注入文件描述的工作流变量。
|
||||||
|
*
|
||||||
|
* @return 工作流变量
|
||||||
|
*/
|
||||||
|
public Map<String, Object> getVariables() {
|
||||||
|
return variables;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.upload;
|
||||||
|
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public Workflow API 已准备或已写入的临时文件。
|
||||||
|
*
|
||||||
|
* @param filePath 工作流运行时读取 URL;物理写入完成前为空
|
||||||
|
* @param storageLocator 可恢复文件存储定位符
|
||||||
|
*/
|
||||||
|
public record WorkflowApiStoredFile(
|
||||||
|
String filePath,
|
||||||
|
String storageLocator) implements Serializable {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建临时文件记录。
|
||||||
|
*
|
||||||
|
* @throws IllegalArgumentException 恢复定位符为空时抛出
|
||||||
|
*/
|
||||||
|
public WorkflowApiStoredFile {
|
||||||
|
if (!StringUtils.hasText(storageLocator)) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"工作流临时文件恢复定位符不能为空");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回写入完成后的临时文件记录。
|
||||||
|
*
|
||||||
|
* @param resolvedFilePath 文件读取 URL
|
||||||
|
* @return 包含原恢复定位符的新记录
|
||||||
|
*/
|
||||||
|
public WorkflowApiStoredFile withFilePath(
|
||||||
|
String resolvedFilePath) {
|
||||||
|
return new WorkflowApiStoredFile(
|
||||||
|
resolvedFilePath,
|
||||||
|
storageLocator);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.upload;
|
||||||
|
|
||||||
|
import com.easyagents.flow.core.chain.Chain;
|
||||||
|
import com.easyagents.flow.core.chain.Event;
|
||||||
|
import com.easyagents.flow.core.chain.event.ChainStatusChangeEvent;
|
||||||
|
import com.easyagents.flow.core.chain.listener.ChainEventListener;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流终态事件到临时上传清理队列的桥接监听器。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class WorkflowApiUploadCleanupListener implements ChainEventListener {
|
||||||
|
|
||||||
|
private final WorkflowApiUploadLifecycleService lifecycleService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建临时上传清理监听器。
|
||||||
|
*
|
||||||
|
* @param lifecycleService 临时上传生命周期服务
|
||||||
|
*/
|
||||||
|
public WorkflowApiUploadCleanupListener(
|
||||||
|
WorkflowApiUploadLifecycleService lifecycleService) {
|
||||||
|
this.lifecycleService = lifecycleService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在工作流进入终态后触发异步清理登记。
|
||||||
|
*
|
||||||
|
* @param event 工作流事件
|
||||||
|
* @param chain 工作流实例
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void onEvent(Event event, Chain chain) {
|
||||||
|
if (event instanceof ChainStatusChangeEvent statusChangeEvent
|
||||||
|
&& statusChangeEvent.getStatus() != null
|
||||||
|
&& statusChangeEvent.getStatus().isTerminal()) {
|
||||||
|
lifecycleService.markExecutionTerminal(
|
||||||
|
chain.getStateInstanceId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.upload;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public Workflow API 临时上传兜底清理任务。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class WorkflowApiUploadCleanupScheduler {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(
|
||||||
|
WorkflowApiUploadCleanupScheduler.class);
|
||||||
|
private static final int CLEANUP_BATCH_SIZE = 100;
|
||||||
|
private static final int CLEANUP_MAX_RECORDS_PER_RUN = 2_000;
|
||||||
|
private static final Duration CLEANUP_TIME_BUDGET =
|
||||||
|
Duration.ofSeconds(30);
|
||||||
|
|
||||||
|
private final WorkflowApiUploadLifecycleService lifecycleService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建临时上传清理任务。
|
||||||
|
*
|
||||||
|
* @param lifecycleService 临时上传生命周期服务
|
||||||
|
*/
|
||||||
|
public WorkflowApiUploadCleanupScheduler(
|
||||||
|
WorkflowApiUploadLifecycleService lifecycleService) {
|
||||||
|
this.lifecycleService = lifecycleService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定期清理终态、启动失败或状态已丢失的临时上传。
|
||||||
|
*/
|
||||||
|
@Scheduled(
|
||||||
|
fixedDelayString =
|
||||||
|
"${easyflow.workflow.api-upload.cleanup-interval:1m}")
|
||||||
|
public void cleanup() {
|
||||||
|
try {
|
||||||
|
int processed = 0;
|
||||||
|
long deadline = System.nanoTime()
|
||||||
|
+ CLEANUP_TIME_BUDGET.toNanos();
|
||||||
|
while (processed < CLEANUP_MAX_RECORDS_PER_RUN
|
||||||
|
&& System.nanoTime() < deadline) {
|
||||||
|
int batchSize = Math.min(
|
||||||
|
CLEANUP_BATCH_SIZE,
|
||||||
|
CLEANUP_MAX_RECORDS_PER_RUN - processed);
|
||||||
|
int batchProcessed =
|
||||||
|
lifecycleService.cleanupExpired(batchSize);
|
||||||
|
processed += batchProcessed;
|
||||||
|
if (batchProcessed < batchSize) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (processed > 0) {
|
||||||
|
LOG.info(
|
||||||
|
"已处理 {} 条工作流 API 临时上传清理记录",
|
||||||
|
processed);
|
||||||
|
}
|
||||||
|
} catch (RuntimeException error) {
|
||||||
|
LOG.error("工作流 API 临时上传定时清理失败", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,688 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.upload;
|
||||||
|
|
||||||
|
import com.easyagents.flow.core.chain.ChainState;
|
||||||
|
import com.easyagents.flow.core.chain.repository.ChainStateRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
||||||
|
import tech.easyflow.common.cache.RedisLockExecutor;
|
||||||
|
import tech.easyflow.common.filestorage.FileStorageService;
|
||||||
|
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
|
||||||
|
import tech.easyflow.common.filestorage.FileStorageWriteResult;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
|
import java.net.ConnectException;
|
||||||
|
import java.net.SocketTimeoutException;
|
||||||
|
import java.net.UnknownHostException;
|
||||||
|
import java.net.http.HttpTimeoutException;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.TimeoutException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public Workflow API multipart 临时文件生命周期服务。
|
||||||
|
*
|
||||||
|
* <p>上传文件在请求线程中写入统一存储,工作流运行或挂起期间保留,
|
||||||
|
* 工作流终态、启动失败或状态丢失后由幂等清理流程删除。</p>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class WorkflowApiUploadLifecycleService {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(
|
||||||
|
WorkflowApiUploadLifecycleService.class);
|
||||||
|
private static final Duration STAGED_RETENTION = Duration.ofHours(1);
|
||||||
|
private static final Duration TERMINAL_RETENTION = Duration.ofHours(1);
|
||||||
|
private static final Duration ACTIVE_RECHECK = Duration.ofHours(24);
|
||||||
|
private static final Duration CLEANUP_LOCK_WAIT = Duration.ZERO;
|
||||||
|
private static final Duration CLEANUP_LOCK_LEASE = Duration.ofMinutes(5);
|
||||||
|
private static final Duration CLEANUP_RETRY_DELAY =
|
||||||
|
Duration.ofMinutes(5);
|
||||||
|
private static final String STORAGE_PATH_PREFIX =
|
||||||
|
"workflow-api-upload/";
|
||||||
|
private static final String CLEANUP_LOCK_PREFIX =
|
||||||
|
"easyflow:workflow:api-upload:cleanup-lock:";
|
||||||
|
|
||||||
|
private final WorkflowRunningParameterResolver parameterResolver;
|
||||||
|
private final FileStorageService fileStorageService;
|
||||||
|
private final WorkflowApiMultipartFileNormalizer fileNormalizer;
|
||||||
|
private final WorkflowApiUploadStore uploadStore;
|
||||||
|
private final ChainStateRepository chainStateRepository;
|
||||||
|
private final RedisLockExecutor redisLockExecutor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建工作流 API 临时文件生命周期服务。
|
||||||
|
*
|
||||||
|
* @param parameterResolver 工作流运行参数解析器
|
||||||
|
* @param fileStorageService 文件存储服务
|
||||||
|
* @param fileNormalizer Multipart 文件元数据归一化器
|
||||||
|
* @param uploadStore 临时上传记录存储
|
||||||
|
* @param chainStateRepository 工作流状态仓储
|
||||||
|
* @param redisLockExecutor Redis 分布式锁执行器
|
||||||
|
*/
|
||||||
|
public WorkflowApiUploadLifecycleService(
|
||||||
|
WorkflowRunningParameterResolver parameterResolver,
|
||||||
|
@Qualifier("default") FileStorageService fileStorageService,
|
||||||
|
WorkflowApiMultipartFileNormalizer fileNormalizer,
|
||||||
|
WorkflowApiUploadStore uploadStore,
|
||||||
|
ChainStateRepository chainStateRepository,
|
||||||
|
RedisLockExecutor redisLockExecutor) {
|
||||||
|
this.parameterResolver = parameterResolver;
|
||||||
|
this.fileStorageService = fileStorageService;
|
||||||
|
this.fileNormalizer = fileNormalizer;
|
||||||
|
this.uploadStore = uploadStore;
|
||||||
|
this.chainStateRepository = chainStateRepository;
|
||||||
|
this.redisLockExecutor = redisLockExecutor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验、存储 multipart 文件并注入工作流变量。
|
||||||
|
*
|
||||||
|
* @param workflowContent 已发布工作流内容
|
||||||
|
* @param variables 普通运行变量
|
||||||
|
* @param fileParts 以工作流文件参数名分组的 multipart 文件
|
||||||
|
* @return 临时上传准备结果
|
||||||
|
*/
|
||||||
|
public WorkflowApiPreparedUpload prepare(
|
||||||
|
String workflowContent,
|
||||||
|
Map<String, Object> variables,
|
||||||
|
Map<String, List<MultipartFile>> fileParts) {
|
||||||
|
if (fileParts == null || fileParts.isEmpty()) {
|
||||||
|
throw new BusinessException(
|
||||||
|
400,
|
||||||
|
40016,
|
||||||
|
"multipart 请求至少需要上传一个 files.<开始节点参数名> 文件 Part");
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, List<MultipartFile>> normalizedFileParts =
|
||||||
|
normalizeFileParts(fileParts);
|
||||||
|
|
||||||
|
Set<String> fileParameterNames =
|
||||||
|
parameterResolver.resolveFileParameterNames(workflowContent);
|
||||||
|
Set<String> requiredFileParameterNames =
|
||||||
|
parameterResolver.resolveRequiredFileParameterNames(
|
||||||
|
workflowContent);
|
||||||
|
Map<String, Object> baseVariables = new LinkedHashMap<>();
|
||||||
|
if (variables != null) {
|
||||||
|
baseVariables.putAll(variables);
|
||||||
|
}
|
||||||
|
validateFileParts(
|
||||||
|
workflowContent,
|
||||||
|
baseVariables,
|
||||||
|
normalizedFileParts,
|
||||||
|
fileParameterNames,
|
||||||
|
requiredFileParameterNames);
|
||||||
|
|
||||||
|
WorkflowApiUploadRecord record = new WorkflowApiUploadRecord();
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
record.setRequestId(UUID.randomUUID().toString().replace("-", ""));
|
||||||
|
record.setCreatedAt(now);
|
||||||
|
record.setCleanupAt(now + STAGED_RETENTION.toMillis());
|
||||||
|
|
||||||
|
try {
|
||||||
|
uploadStore.create(record);
|
||||||
|
Map<String, Object> resolvedVariables =
|
||||||
|
storeFiles(
|
||||||
|
baseVariables,
|
||||||
|
normalizedFileParts,
|
||||||
|
record);
|
||||||
|
Map<String, Object> normalized =
|
||||||
|
parameterResolver.normalizeRuntimeVariables(
|
||||||
|
workflowContent,
|
||||||
|
resolvedVariables);
|
||||||
|
return new WorkflowApiPreparedUpload(
|
||||||
|
record.getRequestId(),
|
||||||
|
normalized);
|
||||||
|
} catch (RuntimeException | Error error) {
|
||||||
|
try {
|
||||||
|
cleanupPreparationFailure(record);
|
||||||
|
} catch (RuntimeException cleanupError) {
|
||||||
|
error.addSuppressed(cleanupError);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在工作流首个节点启动前绑定执行 ID。
|
||||||
|
*
|
||||||
|
* @param requestId 临时上传请求 ID
|
||||||
|
* @param executeId 工作流执行 ID
|
||||||
|
*/
|
||||||
|
public void bindExecution(String requestId, String executeId) {
|
||||||
|
uploadStore.bindExecution(requestId, executeId);
|
||||||
|
WorkflowApiUploadRecord record = uploadStore.find(requestId)
|
||||||
|
.orElseThrow(() -> new IllegalStateException(
|
||||||
|
"工作流临时上传记录不存在: " + requestId));
|
||||||
|
uploadStore.schedule(
|
||||||
|
record,
|
||||||
|
System.currentTimeMillis() + ACTIVE_RECHECK.toMillis());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记工作流执行已进入终态,交由清理任务删除文件。
|
||||||
|
*
|
||||||
|
* @param executeId 工作流执行 ID
|
||||||
|
*/
|
||||||
|
public void markExecutionTerminal(String executeId) {
|
||||||
|
uploadStore.findByExecutionId(executeId).ifPresent(record ->
|
||||||
|
uploadStore.schedule(
|
||||||
|
record,
|
||||||
|
System.currentTimeMillis()
|
||||||
|
+ TERMINAL_RETENTION.toMillis()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 终止尚未成功启动的临时上传并立即清理。
|
||||||
|
*
|
||||||
|
* @param requestId 临时上传请求 ID
|
||||||
|
*/
|
||||||
|
public void abort(String requestId) {
|
||||||
|
cleanupRequest(requestId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理一批到期上传记录。
|
||||||
|
*
|
||||||
|
* @param limit 单次最大处理数量
|
||||||
|
* @return 已领取并完成一次处理的上传记录数量
|
||||||
|
*/
|
||||||
|
public int cleanupExpired(int limit) {
|
||||||
|
int processed = 0;
|
||||||
|
int safeLimit = Math.max(1, limit);
|
||||||
|
for (int index = 0; index < safeLimit; index++) {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
Optional<String> claimed = uploadStore.claimExpired(
|
||||||
|
now,
|
||||||
|
now + CLEANUP_RETRY_DELAY.toMillis());
|
||||||
|
if (claimed.isEmpty()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
String requestId = claimed.get();
|
||||||
|
processed++;
|
||||||
|
try {
|
||||||
|
cleanupRequest(requestId, false);
|
||||||
|
} catch (RuntimeException error) {
|
||||||
|
LOG.error(
|
||||||
|
"清理工作流 API 临时上传失败,requestId={}",
|
||||||
|
requestId,
|
||||||
|
error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验 multipart 文件字段和既有变量冲突,并复用运行参数校验限制。
|
||||||
|
*
|
||||||
|
* @param workflowContent 工作流内容
|
||||||
|
* @param variables 普通变量
|
||||||
|
* @param fileParts 文件 Part
|
||||||
|
* @param fileParameterNames 文件参数名
|
||||||
|
* @param requiredFileParameterNames 必填文件参数名
|
||||||
|
*/
|
||||||
|
private void validateFileParts(
|
||||||
|
String workflowContent,
|
||||||
|
Map<String, Object> variables,
|
||||||
|
Map<String, List<MultipartFile>> fileParts,
|
||||||
|
Set<String> fileParameterNames,
|
||||||
|
Set<String> requiredFileParameterNames) {
|
||||||
|
for (String requiredParameterName
|
||||||
|
: requiredFileParameterNames) {
|
||||||
|
List<MultipartFile> uploaded =
|
||||||
|
fileParts.get(requiredParameterName);
|
||||||
|
if (!hasValue(variables.get(requiredParameterName))
|
||||||
|
&& (uploaded == null || uploaded.isEmpty())) {
|
||||||
|
throw new BusinessException(
|
||||||
|
400,
|
||||||
|
40016,
|
||||||
|
"缺少必填文件参数 " + requiredParameterName
|
||||||
|
+ ",请使用 files."
|
||||||
|
+ requiredParameterName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Map<String, Object> candidates = new LinkedHashMap<>(variables);
|
||||||
|
for (Map.Entry<String, List<MultipartFile>> entry :
|
||||||
|
fileParts.entrySet()) {
|
||||||
|
String parameterName = entry.getKey();
|
||||||
|
if (!StringUtils.hasText(parameterName)
|
||||||
|
|| !fileParameterNames.contains(parameterName)) {
|
||||||
|
throw new BusinessException(
|
||||||
|
400,
|
||||||
|
40016,
|
||||||
|
"文件 Part " + parameterName
|
||||||
|
+ " 不是开始节点的文件参数");
|
||||||
|
}
|
||||||
|
if (hasValue(variables.get(parameterName))) {
|
||||||
|
throw new BusinessException(
|
||||||
|
400,
|
||||||
|
40016,
|
||||||
|
"文件参数 " + parameterName
|
||||||
|
+ " 不能同时通过 metadata 和文件 Part 传值");
|
||||||
|
}
|
||||||
|
List<MultipartFile> files = entry.getValue();
|
||||||
|
if (files == null || files.isEmpty()) {
|
||||||
|
throw new BusinessException(
|
||||||
|
400,
|
||||||
|
40016,
|
||||||
|
"文件参数 " + parameterName + " 不能为空");
|
||||||
|
}
|
||||||
|
List<Map<String, Object>> descriptors =
|
||||||
|
new ArrayList<>(files.size());
|
||||||
|
for (int index = 0; index < files.size(); index++) {
|
||||||
|
MultipartFile file = files.get(index);
|
||||||
|
validateMultipartFile(file, parameterName);
|
||||||
|
descriptors.add(fileDescriptor(
|
||||||
|
file,
|
||||||
|
"multipart://" + parameterName + "/" + index));
|
||||||
|
}
|
||||||
|
candidates.put(parameterName, descriptors);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
parameterResolver.normalizeRuntimeVariables(
|
||||||
|
workflowContent,
|
||||||
|
candidates);
|
||||||
|
} catch (BusinessException error) {
|
||||||
|
throw translateFileValidationFailure(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将运行参数解析器中的文件校验错误转换为稳定公共错误码。
|
||||||
|
*
|
||||||
|
* @param error 原文件参数校验异常
|
||||||
|
* @return 原异常或带稳定错误码的异常
|
||||||
|
*/
|
||||||
|
private BusinessException translateFileValidationFailure(
|
||||||
|
BusinessException error) {
|
||||||
|
String message = error.getMessage();
|
||||||
|
if (message == null || !message.startsWith("文件参数 ")) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
boolean limitExceeded = message.contains("超过")
|
||||||
|
|| message.contains("最多上传");
|
||||||
|
return new BusinessException(
|
||||||
|
limitExceeded ? 413 : 400,
|
||||||
|
limitExceeded ? 41301 : 40016,
|
||||||
|
message,
|
||||||
|
error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将通过校验的文件写入统一存储。
|
||||||
|
*
|
||||||
|
* @param variables 普通变量
|
||||||
|
* @param fileParts 文件 Part
|
||||||
|
* @param record 上传记录
|
||||||
|
* @return 已注入真实存储路径的变量
|
||||||
|
*/
|
||||||
|
private Map<String, Object> storeFiles(
|
||||||
|
Map<String, Object> variables,
|
||||||
|
Map<String, List<MultipartFile>> fileParts,
|
||||||
|
WorkflowApiUploadRecord record) {
|
||||||
|
Map<String, Object> resolved = new LinkedHashMap<>(variables);
|
||||||
|
for (Map.Entry<String, List<MultipartFile>> entry :
|
||||||
|
fileParts.entrySet()) {
|
||||||
|
List<Map<String, Object>> descriptors =
|
||||||
|
new ArrayList<>(entry.getValue().size());
|
||||||
|
for (MultipartFile file : entry.getValue()) {
|
||||||
|
FileStorageWriteHandle writeHandle;
|
||||||
|
try {
|
||||||
|
writeHandle = fileStorageService.prepareRecoverableWrite(
|
||||||
|
STORAGE_PATH_PREFIX
|
||||||
|
+ record.getRequestId(),
|
||||||
|
buildStorageFilename(
|
||||||
|
file,
|
||||||
|
record.getStoredFiles().size()));
|
||||||
|
} catch (RuntimeException error) {
|
||||||
|
throw translateStorageFailure(error);
|
||||||
|
}
|
||||||
|
String locator = writeHandle.encodeLocator();
|
||||||
|
int storedFileIndex = record.getStoredFiles().size();
|
||||||
|
record.getStoredFiles().add(
|
||||||
|
new WorkflowApiStoredFile(null, locator));
|
||||||
|
// 先持久化精确 locator,物理写入中途退出后仍可由清理任务定位。
|
||||||
|
uploadStore.save(record);
|
||||||
|
|
||||||
|
FileStorageWriteResult writeResult;
|
||||||
|
try {
|
||||||
|
writeResult = fileStorageService.saveRecoverable(
|
||||||
|
file,
|
||||||
|
writeHandle);
|
||||||
|
} catch (RuntimeException error) {
|
||||||
|
throw translateStorageFailure(error);
|
||||||
|
}
|
||||||
|
if (!locator.equals(writeResult.getLocator())) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"文件存储返回了不一致的恢复定位符");
|
||||||
|
}
|
||||||
|
if (!StringUtils.hasText(writeResult.getUrl())) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"文件存储未返回有效路径: "
|
||||||
|
+ file.getOriginalFilename());
|
||||||
|
}
|
||||||
|
record.getStoredFiles().set(
|
||||||
|
storedFileIndex,
|
||||||
|
record.getStoredFiles()
|
||||||
|
.get(storedFileIndex)
|
||||||
|
.withFilePath(writeResult.getUrl()));
|
||||||
|
uploadStore.save(record);
|
||||||
|
descriptors.add(fileDescriptor(
|
||||||
|
file,
|
||||||
|
writeResult.getUrl()));
|
||||||
|
}
|
||||||
|
resolved.put(entry.getKey(), descriptors);
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建不包含用户目录片段的稳定存储文件名。
|
||||||
|
*
|
||||||
|
* @param file 上传文件
|
||||||
|
* @param index 当前请求内文件序号
|
||||||
|
* @return 安全存储文件名
|
||||||
|
*/
|
||||||
|
private String buildStorageFilename(
|
||||||
|
MultipartFile file,
|
||||||
|
int index) {
|
||||||
|
String original = file.getOriginalFilename();
|
||||||
|
String extension = "";
|
||||||
|
int separator = original == null
|
||||||
|
? -1
|
||||||
|
: original.lastIndexOf('.');
|
||||||
|
if (separator >= 0 && separator < original.length() - 1) {
|
||||||
|
String candidate = original.substring(separator + 1);
|
||||||
|
if (candidate.length() <= 16
|
||||||
|
&& candidate.matches("[A-Za-z0-9]+")) {
|
||||||
|
extension = "." + candidate.toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String.format(
|
||||||
|
Locale.ROOT,
|
||||||
|
"%03d-%s%s",
|
||||||
|
index,
|
||||||
|
UUID.randomUUID().toString().replace("-", ""),
|
||||||
|
extension);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验单个 multipart 文件的基础元数据。
|
||||||
|
*
|
||||||
|
* @param file 文件
|
||||||
|
* @param parameterName 工作流文件参数名
|
||||||
|
*/
|
||||||
|
private void validateMultipartFile(
|
||||||
|
MultipartFile file,
|
||||||
|
String parameterName) {
|
||||||
|
if (file == null || file.isEmpty()) {
|
||||||
|
throw new BusinessException(
|
||||||
|
400,
|
||||||
|
40016,
|
||||||
|
"文件参数 " + parameterName + " 包含空文件");
|
||||||
|
}
|
||||||
|
if (!StringUtils.hasText(file.getOriginalFilename())) {
|
||||||
|
throw new BusinessException(
|
||||||
|
400,
|
||||||
|
40016,
|
||||||
|
"文件参数 " + parameterName + " 缺少文件名");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 归一化全部文件 Part 的文件名和内容类型。
|
||||||
|
*
|
||||||
|
* @param fileParts 原始文件 Part
|
||||||
|
* @return 保持参数和文件顺序的归一化视图
|
||||||
|
*/
|
||||||
|
private Map<String, List<MultipartFile>> normalizeFileParts(
|
||||||
|
Map<String, List<MultipartFile>> fileParts) {
|
||||||
|
Map<String, List<MultipartFile>> normalized =
|
||||||
|
new LinkedHashMap<>();
|
||||||
|
for (Map.Entry<String, List<MultipartFile>> entry
|
||||||
|
: fileParts.entrySet()) {
|
||||||
|
List<MultipartFile> source = entry.getValue();
|
||||||
|
if (source == null) {
|
||||||
|
normalized.put(entry.getKey(), List.of());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
normalized.put(
|
||||||
|
entry.getKey(),
|
||||||
|
source.stream()
|
||||||
|
.map(fileNormalizer::normalize)
|
||||||
|
.toList());
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将对象存储异常转换为不泄露底层配置的公共错误。
|
||||||
|
*
|
||||||
|
* @param error 原始存储异常
|
||||||
|
* @return 安全业务异常
|
||||||
|
*/
|
||||||
|
private BusinessException translateStorageFailure(
|
||||||
|
RuntimeException error) {
|
||||||
|
if (isTransientStorageFailure(error)) {
|
||||||
|
return new BusinessException(
|
||||||
|
503,
|
||||||
|
50301,
|
||||||
|
"文件存储暂时不可用,请稍后重试",
|
||||||
|
error);
|
||||||
|
}
|
||||||
|
return new BusinessException(
|
||||||
|
500,
|
||||||
|
50001,
|
||||||
|
"文件存储处理失败,请联系管理员并提供 requestId",
|
||||||
|
error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保守识别可直接重试的网络和超时故障。
|
||||||
|
*
|
||||||
|
* @param error 原始异常
|
||||||
|
* @return 是否为暂时性依赖故障
|
||||||
|
*/
|
||||||
|
private boolean isTransientStorageFailure(Throwable error) {
|
||||||
|
Throwable current = error;
|
||||||
|
while (current != null) {
|
||||||
|
if (current instanceof ConnectException
|
||||||
|
|| current instanceof SocketTimeoutException
|
||||||
|
|| current instanceof UnknownHostException
|
||||||
|
|| current instanceof HttpTimeoutException
|
||||||
|
|| current instanceof TimeoutException) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
String className = current.getClass().getSimpleName();
|
||||||
|
if ("InsufficientDataException".equals(className)
|
||||||
|
|| "ServerException".equals(className)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
String message = current.getMessage();
|
||||||
|
if (message != null) {
|
||||||
|
String normalized = message.toLowerCase(Locale.ROOT);
|
||||||
|
if (normalized.contains("timeout")
|
||||||
|
|| normalized.contains("timed out")
|
||||||
|
|| normalized.contains("connection refused")
|
||||||
|
|| normalized.contains("temporarily unavailable")
|
||||||
|
|| normalized.contains("service unavailable")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current = current.getCause();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建工作流运行态文件描述。
|
||||||
|
*
|
||||||
|
* @param file multipart 文件
|
||||||
|
* @param filePath 存储路径或校验占位路径
|
||||||
|
* @return 文件描述
|
||||||
|
*/
|
||||||
|
private Map<String, Object> fileDescriptor(
|
||||||
|
MultipartFile file,
|
||||||
|
String filePath) {
|
||||||
|
Map<String, Object> descriptor = new LinkedHashMap<>();
|
||||||
|
descriptor.put("fileName", file.getOriginalFilename());
|
||||||
|
descriptor.put("filePath", filePath);
|
||||||
|
if (StringUtils.hasText(file.getContentType())) {
|
||||||
|
descriptor.put("contentType", file.getContentType());
|
||||||
|
}
|
||||||
|
descriptor.put("size", file.getSize());
|
||||||
|
return descriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断 metadata 中是否已经提供有效值。
|
||||||
|
*
|
||||||
|
* @param value metadata 变量值
|
||||||
|
* @return 是否存在有效值
|
||||||
|
*/
|
||||||
|
private boolean hasValue(Object value) {
|
||||||
|
if (value == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (value instanceof String text) {
|
||||||
|
return StringUtils.hasText(text);
|
||||||
|
}
|
||||||
|
if (value instanceof Collection<?> collection) {
|
||||||
|
return !collection.isEmpty();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用请求线程持有的最新恢复定位符清理准备阶段失败的文件。
|
||||||
|
*
|
||||||
|
* <p>当 Redis 更新恰好失败时,重新读取的记录可能缺少最后一次状态,
|
||||||
|
* 因此必须优先使用内存中的记录执行补偿。</p>
|
||||||
|
*
|
||||||
|
* @param record 请求线程持有的最新上传记录
|
||||||
|
*/
|
||||||
|
private void cleanupPreparationFailure(
|
||||||
|
WorkflowApiUploadRecord record) {
|
||||||
|
try {
|
||||||
|
deleteFiles(record);
|
||||||
|
uploadStore.remove(record);
|
||||||
|
} catch (RuntimeException cleanupError) {
|
||||||
|
try {
|
||||||
|
uploadStore.save(record);
|
||||||
|
} catch (RuntimeException persistenceError) {
|
||||||
|
cleanupError.addSuppressed(persistenceError);
|
||||||
|
}
|
||||||
|
throw cleanupError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在分布式锁下清理一条上传记录。
|
||||||
|
*
|
||||||
|
* @param requestId 上传请求 ID
|
||||||
|
* @param force 是否忽略工作流运行状态立即清理
|
||||||
|
* @return 是否成功删除记录
|
||||||
|
*/
|
||||||
|
private boolean cleanupRequest(String requestId, boolean force) {
|
||||||
|
RedisLockExecutor.LockHandle handle =
|
||||||
|
redisLockExecutor.tryAcquire(
|
||||||
|
CLEANUP_LOCK_PREFIX + requestId,
|
||||||
|
CLEANUP_LOCK_WAIT,
|
||||||
|
CLEANUP_LOCK_LEASE);
|
||||||
|
if (handle == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try (handle) {
|
||||||
|
WorkflowApiUploadRecord record =
|
||||||
|
uploadStore.find(requestId).orElse(null);
|
||||||
|
if (record == null) {
|
||||||
|
uploadStore.removeMissingIndex(requestId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!force && shouldRetain(record)) {
|
||||||
|
uploadStore.schedule(
|
||||||
|
record,
|
||||||
|
System.currentTimeMillis()
|
||||||
|
+ ACTIVE_RECHECK.toMillis());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
deleteFiles(record);
|
||||||
|
uploadStore.remove(record);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断上传记录是否仍被运行中或挂起的工作流使用。
|
||||||
|
*
|
||||||
|
* @param record 上传记录
|
||||||
|
* @return 是否需要继续保留
|
||||||
|
*/
|
||||||
|
private boolean shouldRetain(WorkflowApiUploadRecord record) {
|
||||||
|
if (!StringUtils.hasText(record.getExecuteId())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ChainState state = chainStateRepository.load(record.getExecuteId());
|
||||||
|
return state != null
|
||||||
|
&& state.getStatus() != null
|
||||||
|
&& !state.getStatus().isTerminal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 幂等删除记录中的全部临时文件。
|
||||||
|
*
|
||||||
|
* @param record 上传记录
|
||||||
|
*/
|
||||||
|
private void deleteFiles(WorkflowApiUploadRecord record) {
|
||||||
|
RuntimeException firstFailure = null;
|
||||||
|
for (WorkflowApiStoredFile storedFile :
|
||||||
|
record.getStoredFiles()) {
|
||||||
|
try {
|
||||||
|
fileStorageService.deleteRecoverable(
|
||||||
|
FileStorageWriteHandle.decodeLocator(
|
||||||
|
storedFile.storageLocator()));
|
||||||
|
} catch (RuntimeException error) {
|
||||||
|
firstFailure = appendFailure(firstFailure, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 兼容开发阶段已经写入 Redis 的旧版 URL 记录。
|
||||||
|
for (String filePath : record.getFilePaths()) {
|
||||||
|
try {
|
||||||
|
fileStorageService.delete(filePath);
|
||||||
|
} catch (RuntimeException error) {
|
||||||
|
firstFailure = appendFailure(firstFailure, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (firstFailure != null) {
|
||||||
|
throw firstFailure;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 聚合文件清理异常并保留全部失败原因。
|
||||||
|
*
|
||||||
|
* @param firstFailure 首个异常
|
||||||
|
* @param currentFailure 当前异常
|
||||||
|
* @return 聚合后的首个异常
|
||||||
|
*/
|
||||||
|
private RuntimeException appendFailure(
|
||||||
|
RuntimeException firstFailure,
|
||||||
|
RuntimeException currentFailure) {
|
||||||
|
if (firstFailure == null) {
|
||||||
|
return currentFailure;
|
||||||
|
}
|
||||||
|
firstFailure.addSuppressed(currentFailure);
|
||||||
|
return firstFailure;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.upload;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public Workflow API 临时上传记录。
|
||||||
|
*/
|
||||||
|
public class WorkflowApiUploadRecord {
|
||||||
|
|
||||||
|
private String requestId;
|
||||||
|
private String executeId;
|
||||||
|
private List<WorkflowApiStoredFile> storedFiles = new ArrayList<>();
|
||||||
|
/**
|
||||||
|
* 兼容早期临时上传记录的旧版路径;新记录使用 {@link #storedFiles}。
|
||||||
|
*/
|
||||||
|
private List<String> filePaths = new ArrayList<>();
|
||||||
|
private long createdAt;
|
||||||
|
private long cleanupAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取上传请求 ID。
|
||||||
|
*
|
||||||
|
* @return 上传请求 ID
|
||||||
|
*/
|
||||||
|
public String getRequestId() {
|
||||||
|
return requestId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置上传请求 ID。
|
||||||
|
*
|
||||||
|
* @param requestId 上传请求 ID
|
||||||
|
*/
|
||||||
|
public void setRequestId(String requestId) {
|
||||||
|
this.requestId = requestId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取工作流执行 ID。
|
||||||
|
*
|
||||||
|
* @return 工作流执行 ID
|
||||||
|
*/
|
||||||
|
public String getExecuteId() {
|
||||||
|
return executeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置工作流执行 ID。
|
||||||
|
*
|
||||||
|
* @param executeId 工作流执行 ID
|
||||||
|
*/
|
||||||
|
public void setExecuteId(String executeId) {
|
||||||
|
this.executeId = executeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取带恢复定位符的临时文件。
|
||||||
|
*
|
||||||
|
* @return 临时文件记录
|
||||||
|
*/
|
||||||
|
public List<WorkflowApiStoredFile> getStoredFiles() {
|
||||||
|
return storedFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置带恢复定位符的临时文件。
|
||||||
|
*
|
||||||
|
* @param storedFiles 临时文件记录
|
||||||
|
*/
|
||||||
|
public void setStoredFiles(
|
||||||
|
List<WorkflowApiStoredFile> storedFiles) {
|
||||||
|
this.storedFiles = storedFiles == null
|
||||||
|
? new ArrayList<>()
|
||||||
|
: new ArrayList<>(storedFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取旧版临时文件路径。
|
||||||
|
*
|
||||||
|
* @return 临时文件路径
|
||||||
|
*/
|
||||||
|
public List<String> getFilePaths() {
|
||||||
|
return filePaths;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置旧版临时文件路径。
|
||||||
|
*
|
||||||
|
* @param filePaths 临时文件路径
|
||||||
|
*/
|
||||||
|
public void setFilePaths(List<String> filePaths) {
|
||||||
|
this.filePaths = filePaths == null
|
||||||
|
? new ArrayList<>()
|
||||||
|
: new ArrayList<>(filePaths);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取创建时间。
|
||||||
|
*
|
||||||
|
* @return Unix 毫秒时间戳
|
||||||
|
*/
|
||||||
|
public long getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置创建时间。
|
||||||
|
*
|
||||||
|
* @param createdAt Unix 毫秒时间戳
|
||||||
|
*/
|
||||||
|
public void setCreatedAt(long createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取下次清理检查时间。
|
||||||
|
*
|
||||||
|
* @return Unix 毫秒时间戳
|
||||||
|
*/
|
||||||
|
public long getCleanupAt() {
|
||||||
|
return cleanupAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置下次清理检查时间。
|
||||||
|
*
|
||||||
|
* @param cleanupAt Unix 毫秒时间戳
|
||||||
|
*/
|
||||||
|
public void setCleanupAt(long cleanupAt) {
|
||||||
|
this.cleanupAt = cleanupAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.upload;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.data.redis.core.script.DefaultRedisScript;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public Workflow API 临时上传记录的 Redis 存储。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class WorkflowApiUploadStore {
|
||||||
|
|
||||||
|
private static final String RECORD_KEY_PREFIX =
|
||||||
|
"easyflow:workflow:{api-upload}:record:";
|
||||||
|
private static final String EXECUTION_KEY_PREFIX =
|
||||||
|
"easyflow:workflow:{api-upload}:execution:";
|
||||||
|
private static final String CLEANUP_INDEX =
|
||||||
|
"easyflow:workflow:{api-upload}:cleanup";
|
||||||
|
private static final Duration EXECUTION_INDEX_TTL =
|
||||||
|
Duration.ofDays(30);
|
||||||
|
private static final DefaultRedisScript<Long> SAVE_SCHEDULED_SCRIPT =
|
||||||
|
longScript(
|
||||||
|
"redis.call('set', KEYS[1], ARGV[1]); "
|
||||||
|
+ "redis.call('zadd', KEYS[2], ARGV[2], ARGV[3]); "
|
||||||
|
+ "if ARGV[4] == '1' "
|
||||||
|
+ "and redis.call('get', KEYS[3]) == ARGV[3] then "
|
||||||
|
+ "redis.call('pexpire', KEYS[3], ARGV[5]); end; "
|
||||||
|
+ "return 1");
|
||||||
|
private static final DefaultRedisScript<Long> BIND_EXECUTION_SCRIPT =
|
||||||
|
longScript(
|
||||||
|
"redis.call('set', KEYS[1], ARGV[1]); "
|
||||||
|
+ "redis.call('psetex', KEYS[2], ARGV[3], ARGV[2]); "
|
||||||
|
+ "if ARGV[4] == '1' and KEYS[3] ~= KEYS[2] "
|
||||||
|
+ "and redis.call('get', KEYS[3]) == ARGV[2] then "
|
||||||
|
+ "redis.call('del', KEYS[3]); end; "
|
||||||
|
+ "return 1");
|
||||||
|
private static final DefaultRedisScript<Long>
|
||||||
|
REMOVE_EXECUTION_INDEX_SCRIPT =
|
||||||
|
longScript(
|
||||||
|
"if redis.call('get', KEYS[1]) == ARGV[1] then "
|
||||||
|
+ "return redis.call('del', KEYS[1]); end; "
|
||||||
|
+ "return 0");
|
||||||
|
private static final DefaultRedisScript<String> CLAIM_EXPIRED_SCRIPT =
|
||||||
|
stringScript(
|
||||||
|
"local values = redis.call("
|
||||||
|
+ "'zrangebyscore', KEYS[1], '-inf', ARGV[1], "
|
||||||
|
+ "'LIMIT', 0, 1); "
|
||||||
|
+ "if #values == 0 then return nil; end; "
|
||||||
|
+ "redis.call('zadd', KEYS[1], ARGV[2], values[1]); "
|
||||||
|
+ "return values[1]");
|
||||||
|
private static final DefaultRedisScript<Long> REMOVE_SCRIPT =
|
||||||
|
longScript(
|
||||||
|
"redis.call('del', KEYS[1]); "
|
||||||
|
+ "if ARGV[2] == '1' "
|
||||||
|
+ "and redis.call('get', KEYS[2]) == ARGV[1] then "
|
||||||
|
+ "redis.call('del', KEYS[2]); end; "
|
||||||
|
+ "redis.call('zrem', KEYS[3], ARGV[1]); "
|
||||||
|
+ "return 1");
|
||||||
|
|
||||||
|
private final StringRedisTemplate redisTemplate;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建临时上传记录存储。
|
||||||
|
*
|
||||||
|
* @param redisTemplate Redis 模板
|
||||||
|
* @param objectMapper JSON 映射器
|
||||||
|
*/
|
||||||
|
public WorkflowApiUploadStore(StringRedisTemplate redisTemplate,
|
||||||
|
ObjectMapper objectMapper) {
|
||||||
|
this.redisTemplate = redisTemplate;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新建临时上传记录并登记清理时间。
|
||||||
|
*
|
||||||
|
* @param record 上传记录
|
||||||
|
*/
|
||||||
|
public void create(WorkflowApiUploadRecord record) {
|
||||||
|
requireRecord(record);
|
||||||
|
saveScheduled(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存上传记录的最新内容。
|
||||||
|
*
|
||||||
|
* @param record 上传记录
|
||||||
|
*/
|
||||||
|
public void save(WorkflowApiUploadRecord record) {
|
||||||
|
requireRecord(record);
|
||||||
|
redisTemplate.opsForValue().set(
|
||||||
|
recordKey(record.getRequestId()),
|
||||||
|
serialize(record));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将上传请求绑定到工作流执行实例。
|
||||||
|
*
|
||||||
|
* @param requestId 上传请求 ID
|
||||||
|
* @param executeId 工作流执行 ID
|
||||||
|
*/
|
||||||
|
public void bindExecution(String requestId, String executeId) {
|
||||||
|
if (!StringUtils.hasText(executeId)) {
|
||||||
|
throw new IllegalArgumentException("工作流执行 ID 不能为空");
|
||||||
|
}
|
||||||
|
WorkflowApiUploadRecord record = find(requestId)
|
||||||
|
.orElseThrow(() -> new IllegalStateException(
|
||||||
|
"工作流临时上传记录不存在: " + requestId));
|
||||||
|
String previousExecuteId = record.getExecuteId();
|
||||||
|
record.setExecuteId(executeId);
|
||||||
|
redisTemplate.execute(
|
||||||
|
BIND_EXECUTION_SCRIPT,
|
||||||
|
Arrays.asList(
|
||||||
|
recordKey(requestId),
|
||||||
|
executionKey(executeId),
|
||||||
|
StringUtils.hasText(previousExecuteId)
|
||||||
|
? executionKey(previousExecuteId)
|
||||||
|
: recordKey(requestId)),
|
||||||
|
serialize(record),
|
||||||
|
requestId,
|
||||||
|
String.valueOf(EXECUTION_INDEX_TTL.toMillis()),
|
||||||
|
StringUtils.hasText(previousExecuteId) ? "1" : "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按上传请求 ID 查找记录。
|
||||||
|
*
|
||||||
|
* @param requestId 上传请求 ID
|
||||||
|
* @return 上传记录
|
||||||
|
*/
|
||||||
|
public Optional<WorkflowApiUploadRecord> find(String requestId) {
|
||||||
|
if (!StringUtils.hasText(requestId)) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
String value = redisTemplate.opsForValue().get(recordKey(requestId));
|
||||||
|
if (!StringUtils.hasText(value)) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Optional.of(objectMapper.readValue(
|
||||||
|
value,
|
||||||
|
WorkflowApiUploadRecord.class));
|
||||||
|
} catch (JsonProcessingException error) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"读取工作流临时上传记录失败: " + requestId,
|
||||||
|
error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按工作流执行 ID 查找上传记录。
|
||||||
|
*
|
||||||
|
* @param executeId 工作流执行 ID
|
||||||
|
* @return 上传记录
|
||||||
|
*/
|
||||||
|
public Optional<WorkflowApiUploadRecord> findByExecutionId(
|
||||||
|
String executeId) {
|
||||||
|
if (!StringUtils.hasText(executeId)) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
String requestId = redisTemplate.opsForValue().get(
|
||||||
|
executionKey(executeId));
|
||||||
|
Optional<WorkflowApiUploadRecord> record = find(requestId);
|
||||||
|
if (StringUtils.hasText(requestId)
|
||||||
|
&& (record.isEmpty()
|
||||||
|
|| !executeId.equals(record.get().getExecuteId()))) {
|
||||||
|
redisTemplate.execute(
|
||||||
|
REMOVE_EXECUTION_INDEX_SCRIPT,
|
||||||
|
List.of(executionKey(executeId)),
|
||||||
|
requestId);
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新记录的下次清理检查时间。
|
||||||
|
*
|
||||||
|
* @param record 上传记录
|
||||||
|
* @param cleanupAt Unix 毫秒时间戳
|
||||||
|
*/
|
||||||
|
public void schedule(WorkflowApiUploadRecord record, long cleanupAt) {
|
||||||
|
requireRecord(record);
|
||||||
|
record.setCleanupAt(cleanupAt);
|
||||||
|
saveScheduled(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原子领取一条到期上传请求,并提前设置失败重试时间。
|
||||||
|
*
|
||||||
|
* @param now 当前 Unix 毫秒时间戳
|
||||||
|
* @param retryAt 领取后默认重试时间
|
||||||
|
* @return 领取到的上传请求 ID
|
||||||
|
*/
|
||||||
|
public Optional<String> claimExpired(
|
||||||
|
long now,
|
||||||
|
long retryAt) {
|
||||||
|
String requestId = redisTemplate.execute(
|
||||||
|
CLAIM_EXPIRED_SCRIPT,
|
||||||
|
List.of(CLEANUP_INDEX),
|
||||||
|
String.valueOf(now),
|
||||||
|
String.valueOf(retryAt));
|
||||||
|
return Optional.ofNullable(requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除上传记录、执行索引和清理索引。
|
||||||
|
*
|
||||||
|
* @param record 上传记录
|
||||||
|
*/
|
||||||
|
public void remove(WorkflowApiUploadRecord record) {
|
||||||
|
requireRecord(record);
|
||||||
|
boolean hasExecuteId =
|
||||||
|
StringUtils.hasText(record.getExecuteId());
|
||||||
|
redisTemplate.execute(
|
||||||
|
REMOVE_SCRIPT,
|
||||||
|
Arrays.asList(
|
||||||
|
recordKey(record.getRequestId()),
|
||||||
|
hasExecuteId
|
||||||
|
? executionKey(record.getExecuteId())
|
||||||
|
: recordKey(record.getRequestId()),
|
||||||
|
CLEANUP_INDEX),
|
||||||
|
record.getRequestId(),
|
||||||
|
hasExecuteId ? "1" : "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除已经缺少详情记录的残留清理索引。
|
||||||
|
*
|
||||||
|
* @param requestId 上传请求 ID
|
||||||
|
*/
|
||||||
|
public void removeMissingIndex(String requestId) {
|
||||||
|
if (StringUtils.hasText(requestId)) {
|
||||||
|
redisTemplate.opsForZSet().remove(CLEANUP_INDEX, requestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 序列化上传记录。
|
||||||
|
*
|
||||||
|
* @param record 上传记录
|
||||||
|
* @return JSON 文本
|
||||||
|
*/
|
||||||
|
private String serialize(WorkflowApiUploadRecord record) {
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(record);
|
||||||
|
} catch (JsonProcessingException error) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"写入工作流临时上传记录失败: "
|
||||||
|
+ record.getRequestId(),
|
||||||
|
error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原子保存记录与清理索引,并续期执行索引。
|
||||||
|
*
|
||||||
|
* @param record 上传记录
|
||||||
|
*/
|
||||||
|
private void saveScheduled(WorkflowApiUploadRecord record) {
|
||||||
|
boolean hasExecuteId =
|
||||||
|
StringUtils.hasText(record.getExecuteId());
|
||||||
|
redisTemplate.execute(
|
||||||
|
SAVE_SCHEDULED_SCRIPT,
|
||||||
|
Arrays.asList(
|
||||||
|
recordKey(record.getRequestId()),
|
||||||
|
CLEANUP_INDEX,
|
||||||
|
hasExecuteId
|
||||||
|
? executionKey(record.getExecuteId())
|
||||||
|
: recordKey(record.getRequestId())),
|
||||||
|
serialize(record),
|
||||||
|
String.valueOf(record.getCleanupAt()),
|
||||||
|
record.getRequestId(),
|
||||||
|
hasExecuteId ? "1" : "0",
|
||||||
|
String.valueOf(EXECUTION_INDEX_TTL.toMillis()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建返回 Long 的 Redis Lua 脚本。
|
||||||
|
*
|
||||||
|
* @param text Lua 文本
|
||||||
|
* @return Redis 脚本
|
||||||
|
*/
|
||||||
|
private static DefaultRedisScript<Long> longScript(
|
||||||
|
String text) {
|
||||||
|
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
|
||||||
|
script.setScriptText(text);
|
||||||
|
script.setResultType(Long.class);
|
||||||
|
return script;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建返回字符串的 Redis Lua 脚本。
|
||||||
|
*
|
||||||
|
* @param text Lua 文本
|
||||||
|
* @return Redis 脚本
|
||||||
|
*/
|
||||||
|
private static DefaultRedisScript<String> stringScript(
|
||||||
|
String text) {
|
||||||
|
DefaultRedisScript<String> script = new DefaultRedisScript<>();
|
||||||
|
script.setScriptText(text);
|
||||||
|
script.setResultType(String.class);
|
||||||
|
return script;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验记录主键。
|
||||||
|
*
|
||||||
|
* @param record 上传记录
|
||||||
|
*/
|
||||||
|
private void requireRecord(WorkflowApiUploadRecord record) {
|
||||||
|
if (record == null || !StringUtils.hasText(record.getRequestId())) {
|
||||||
|
throw new IllegalArgumentException("工作流临时上传请求 ID 不能为空");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建记录 Redis Key。
|
||||||
|
*
|
||||||
|
* @param requestId 上传请求 ID
|
||||||
|
* @return Redis Key
|
||||||
|
*/
|
||||||
|
private String recordKey(String requestId) {
|
||||||
|
return RECORD_KEY_PREFIX + requestId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建执行实例 Redis Key。
|
||||||
|
*
|
||||||
|
* @param executeId 工作流执行 ID
|
||||||
|
* @return Redis Key
|
||||||
|
*/
|
||||||
|
private String executionKey(String executeId) {
|
||||||
|
return EXECUTION_KEY_PREFIX + executeId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.upload;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import tech.easyflow.common.filestorage.FileStorageService;
|
||||||
|
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取经过 Public Workflow API 上传记录验证的临时文件。
|
||||||
|
*
|
||||||
|
* <p>外部文件描述只提供公开读取路径。仅当路径中的随机请求 ID、Redis 上传记录、
|
||||||
|
* 完整文件 URL 和可恢复存储句柄全部匹配时,才允许绕过公网 URL 限制并直接读取物理对象。</p>
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class WorkflowApiUploadedFileReader {
|
||||||
|
|
||||||
|
private static final String STORAGE_PATH_PREFIX = "workflow-api-upload/";
|
||||||
|
private static final Pattern MANAGED_PATH_PATTERN = Pattern.compile(
|
||||||
|
"(?:^|/)workflow-api-upload/([0-9a-f]{32})/([^/]+)$");
|
||||||
|
|
||||||
|
private final WorkflowApiUploadStore uploadStore;
|
||||||
|
private final FileStorageService fileStorageService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建工作流 API 上传文件读取器。
|
||||||
|
*
|
||||||
|
* @param uploadStore 临时上传记录存储
|
||||||
|
* @param fileStorageService 默认文件存储路由
|
||||||
|
*/
|
||||||
|
public WorkflowApiUploadedFileReader(
|
||||||
|
WorkflowApiUploadStore uploadStore,
|
||||||
|
@Qualifier("default") FileStorageService fileStorageService) {
|
||||||
|
this.uploadStore = uploadStore;
|
||||||
|
this.fileStorageService = fileStorageService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在路径属于受管工作流上传文件时校验记录并打开物理对象。
|
||||||
|
*
|
||||||
|
* @param filePath 工作流文件描述中的完整读取路径
|
||||||
|
* @return 受管文件流;普通文件路径或普通远端 URL 返回空
|
||||||
|
* @throws IOException 上传记录已失效、引用不匹配或物理对象无法读取时抛出
|
||||||
|
*/
|
||||||
|
public Optional<InputStream> openVerified(String filePath) throws IOException {
|
||||||
|
ManagedPath managedPath = parseManagedPath(filePath).orElse(null);
|
||||||
|
if (managedPath == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
WorkflowApiUploadRecord record = uploadStore.find(managedPath.requestId())
|
||||||
|
.orElseThrow(() -> new IOException("工作流上传文件已失效,请重新上传"));
|
||||||
|
WorkflowApiStoredFile storedFile = record.getStoredFiles().stream()
|
||||||
|
.filter(file -> file != null && filePath.equals(file.filePath()))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new IOException("工作流上传文件引用与上传记录不匹配"));
|
||||||
|
|
||||||
|
final FileStorageWriteHandle handle;
|
||||||
|
try {
|
||||||
|
handle = FileStorageWriteHandle.decodeLocator(storedFile.storageLocator());
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
throw new IOException("工作流上传文件存储定位符无效", exception);
|
||||||
|
}
|
||||||
|
String expectedStoragePath = STORAGE_PATH_PREFIX + managedPath.requestId() + "/";
|
||||||
|
if (!expectedStoragePath.equals(handle.getPath())
|
||||||
|
|| !managedPath.filename().equals(handle.getFilename())) {
|
||||||
|
throw new IOException("工作流上传文件存储定位与上传请求不匹配");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Optional.of(fileStorageService.readRecoverable(handle));
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
throw new IOException("读取工作流上传文件失败", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断路径结构是否属于系统生成的工作流 API 上传目录。
|
||||||
|
*
|
||||||
|
* <p>该判断只用于选择 I/O 隔离通道,不能替代 {@link #openVerified(String)} 的授权校验。</p>
|
||||||
|
*
|
||||||
|
* @param filePath 文件读取路径
|
||||||
|
* @return 路径结构匹配时返回 true
|
||||||
|
*/
|
||||||
|
public boolean isManagedPathCandidate(String filePath) {
|
||||||
|
return parseManagedPath(filePath).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 URL 或相对路径中解析受管请求 ID 与固定文件名。
|
||||||
|
*
|
||||||
|
* @param filePath 原始文件路径
|
||||||
|
* @return 受管路径信息
|
||||||
|
*/
|
||||||
|
private Optional<ManagedPath> parseManagedPath(String filePath) {
|
||||||
|
if (!StringUtils.hasText(filePath)) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
final String path;
|
||||||
|
try {
|
||||||
|
URI uri = URI.create(filePath);
|
||||||
|
path = uri.getPath();
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
if (!StringUtils.hasText(path)) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
Matcher matcher = MANAGED_PATH_PATTERN.matcher(path);
|
||||||
|
if (!matcher.find()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return Optional.of(new ManagedPath(matcher.group(1), matcher.group(2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统受管上传路径中的可信定位片段。
|
||||||
|
*
|
||||||
|
* @param requestId 随机上传请求 ID
|
||||||
|
* @param filename 系统生成的存储文件名
|
||||||
|
*/
|
||||||
|
private record ManagedPath(String requestId, String filename) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package tech.easyflow.ai.node;
|
package tech.easyflow.ai.node;
|
||||||
|
|
||||||
import com.easyagents.flow.core.util.IoBulkhead;
|
import com.easyagents.flow.core.util.IoBulkhead;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -12,6 +13,7 @@ import tech.easyflow.ai.document.exception.DocumentParseBridgeException;
|
|||||||
import tech.easyflow.ai.document.service.DocumentParseBridgeService;
|
import tech.easyflow.ai.document.service.DocumentParseBridgeService;
|
||||||
import tech.easyflow.ai.document.support.DocumentInputStreamSupport;
|
import tech.easyflow.ai.document.support.DocumentInputStreamSupport;
|
||||||
import tech.easyflow.ai.document.support.DocumentParseSourceType;
|
import tech.easyflow.ai.document.support.DocumentParseSourceType;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadedFileReader;
|
||||||
import tech.easyflow.common.filestorage.FileStorageService;
|
import tech.easyflow.common.filestorage.FileStorageService;
|
||||||
import tech.easyflow.common.util.StringUtil;
|
import tech.easyflow.common.util.StringUtil;
|
||||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
@@ -27,6 +29,7 @@ import java.util.LinkedHashMap;
|
|||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,6 +52,7 @@ public class DocNodeFileContentExtractor {
|
|||||||
private final DocumentParseBridgeService documentParseBridgeService;
|
private final DocumentParseBridgeService documentParseBridgeService;
|
||||||
private final FileStorageService fileStorageService;
|
private final FileStorageService fileStorageService;
|
||||||
private final ReaderManager readerManager;
|
private final ReaderManager readerManager;
|
||||||
|
private final WorkflowApiUploadedFileReader uploadedFileReader;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建文件内容提取器。
|
* 创建文件内容提取器。
|
||||||
@@ -56,13 +60,31 @@ public class DocNodeFileContentExtractor {
|
|||||||
* @param documentParseBridgeService 统一文档解析桥接服务
|
* @param documentParseBridgeService 统一文档解析桥接服务
|
||||||
* @param fileStorageService 文件存储服务
|
* @param fileStorageService 文件存储服务
|
||||||
* @param readerManager 默认读取器管理器
|
* @param readerManager 默认读取器管理器
|
||||||
|
* @param uploadedFileReader 已验证的工作流 API 上传文件读取器
|
||||||
*/
|
*/
|
||||||
|
@Autowired
|
||||||
public DocNodeFileContentExtractor(DocumentParseBridgeService documentParseBridgeService,
|
public DocNodeFileContentExtractor(DocumentParseBridgeService documentParseBridgeService,
|
||||||
@Qualifier("default") FileStorageService fileStorageService,
|
@Qualifier("default") FileStorageService fileStorageService,
|
||||||
ReaderManager readerManager) {
|
ReaderManager readerManager,
|
||||||
|
WorkflowApiUploadedFileReader uploadedFileReader) {
|
||||||
this.documentParseBridgeService = documentParseBridgeService;
|
this.documentParseBridgeService = documentParseBridgeService;
|
||||||
this.fileStorageService = fileStorageService;
|
this.fileStorageService = fileStorageService;
|
||||||
this.readerManager = readerManager;
|
this.readerManager = readerManager;
|
||||||
|
this.uploadedFileReader = uploadedFileReader;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建不启用工作流 API 上传识别的提取器,供同包隔离测试使用。
|
||||||
|
*
|
||||||
|
* @param documentParseBridgeService 统一文档解析桥接服务
|
||||||
|
* @param fileStorageService 文件存储服务
|
||||||
|
* @param readerManager 默认读取器管理器
|
||||||
|
*/
|
||||||
|
DocNodeFileContentExtractor(
|
||||||
|
DocumentParseBridgeService documentParseBridgeService,
|
||||||
|
FileStorageService fileStorageService,
|
||||||
|
ReaderManager readerManager) {
|
||||||
|
this(documentParseBridgeService, fileStorageService, readerManager, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -305,7 +327,9 @@ public class DocNodeFileContentExtractor {
|
|||||||
DocumentSourceRef sourceRef, Path target) throws IOException {
|
DocumentSourceRef sourceRef, Path target) throws IOException {
|
||||||
String filePath = sourceRef.getFilePath();
|
String filePath = sourceRef.getFilePath();
|
||||||
boolean localStorage = StringUtil.hasText(filePath)
|
boolean localStorage = StringUtil.hasText(filePath)
|
||||||
&& !isRemoteUrl(filePath);
|
&& (!isRemoteUrl(filePath)
|
||||||
|
|| (uploadedFileReader != null
|
||||||
|
&& uploadedFileReader.isManagedPathCandidate(filePath)));
|
||||||
if (localStorage) {
|
if (localStorage) {
|
||||||
try (IoBulkhead.Permit ignored =
|
try (IoBulkhead.Permit ignored =
|
||||||
IoBulkhead.storage().acquire("storage:document-read");
|
IoBulkhead.storage().acquire("storage:document-read");
|
||||||
@@ -340,6 +364,14 @@ public class DocNodeFileContentExtractor {
|
|||||||
|
|
||||||
private InputStream openInputStream(DocumentSourceRef sourceRef) throws IOException {
|
private InputStream openInputStream(DocumentSourceRef sourceRef) throws IOException {
|
||||||
String filePath = sourceRef.getFilePath();
|
String filePath = sourceRef.getFilePath();
|
||||||
|
if (uploadedFileReader != null && StringUtil.hasText(filePath)) {
|
||||||
|
Optional<InputStream> managed = uploadedFileReader.openVerified(filePath);
|
||||||
|
if (managed.isPresent()) {
|
||||||
|
return DocumentInputStreamSupport.limit(
|
||||||
|
managed.get(),
|
||||||
|
FILE_MAX_SINGLE_SIZE);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (StringUtil.hasText(filePath) && isRemoteUrl(filePath)) {
|
if (StringUtil.hasText(filePath) && isRemoteUrl(filePath)) {
|
||||||
return DocumentInputStreamSupport.openRemote(filePath, FILE_MAX_SINGLE_SIZE);
|
return DocumentInputStreamSupport.openRemote(filePath, FILE_MAX_SINGLE_SIZE);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import tech.easyflow.common.web.exceptions.BusinessException;
|
|||||||
|
|
||||||
import java.lang.reflect.Field;
|
import java.lang.reflect.Field;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.times;
|
import static org.mockito.Mockito.times;
|
||||||
@@ -82,6 +83,8 @@ public class TinyFlowServiceTest {
|
|||||||
.thenReturn(nodeStateRepository);
|
.thenReturn(nodeStateRepository);
|
||||||
when(chainStateRepository.load(EXECUTE_ID))
|
when(chainStateRepository.load(EXECUTE_ID))
|
||||||
.thenReturn(chainState);
|
.thenReturn(chainState);
|
||||||
|
when(chainExecutor.getInstanceNodeNames(chainState))
|
||||||
|
.thenReturn(Map.of(NODE_ID, "文档解析"));
|
||||||
when(nodeStateRepository.load(EXECUTE_ID, NODE_ID))
|
when(nodeStateRepository.load(EXECUTE_ID, NODE_ID))
|
||||||
.thenReturn(null);
|
.thenReturn(null);
|
||||||
TinyFlowService service = service(chainExecutor);
|
TinyFlowService service = service(chainExecutor);
|
||||||
@@ -96,7 +99,12 @@ public class TinyFlowServiceTest {
|
|||||||
Assert.assertEquals(
|
Assert.assertEquals(
|
||||||
Integer.valueOf(NodeStatus.READY.getValue()),
|
Integer.valueOf(NodeStatus.READY.getValue()),
|
||||||
result.getNodes().get(NODE_ID).getStatus());
|
result.getNodes().get(NODE_ID).getStatus());
|
||||||
|
Assert.assertEquals(
|
||||||
|
"文档解析",
|
||||||
|
result.getNodes().get(NODE_ID).getNodeName());
|
||||||
verify(chainStateRepository, times(1)).load(EXECUTE_ID);
|
verify(chainStateRepository, times(1)).load(EXECUTE_ID);
|
||||||
|
verify(chainExecutor, times(1))
|
||||||
|
.getInstanceNodeNames(chainState);
|
||||||
verify(nodeStateRepository, times(1))
|
verify(nodeStateRepository, times(1))
|
||||||
.load(EXECUTE_ID, NODE_ID);
|
.load(EXECUTE_ID, NODE_ID);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,6 +159,47 @@ public class WorkflowRunningParameterResolverTest {
|
|||||||
Assert.assertEquals("file", fields.get(0).get("type"));
|
Assert.assertEquals("file", fields.get(0).get("type"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* multipart 文件字段名应从解析后的开始节点参数中按定义顺序返回。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射注入失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testResolveFileParameterNamesShouldReturnStartFileFields()
|
||||||
|
throws Exception {
|
||||||
|
WorkflowRunningParameterResolver resolver = newResolver();
|
||||||
|
|
||||||
|
Assert.assertEquals(
|
||||||
|
List.of("attachments"),
|
||||||
|
List.copyOf(resolver.resolveFileParameterNames(
|
||||||
|
workflowContentWithStartParameters())));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 必填文件字段名应从开始节点参数定义中单独解析。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射注入失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testResolveRequiredFileParameterNamesShouldKeepOrder()
|
||||||
|
throws Exception {
|
||||||
|
WorkflowRunningParameterResolver resolver = newResolver();
|
||||||
|
JSONObject startData = data("开始");
|
||||||
|
JSONArray parameters = startParameters();
|
||||||
|
parameters.getJSONObject(1).put("required", true);
|
||||||
|
startData.put("parameters", parameters);
|
||||||
|
String content = workflowJson(
|
||||||
|
array(
|
||||||
|
node("s1", "startNode", null, startData),
|
||||||
|
node("e1", "endNode", null, data("结束"))),
|
||||||
|
array(edge("e1", "s1", "e1")));
|
||||||
|
|
||||||
|
Assert.assertEquals(
|
||||||
|
List.of("attachments"),
|
||||||
|
List.copyOf(resolver
|
||||||
|
.resolveRequiredFileParameterNames(content)));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 文件参数运行值应统一归一化为数组并按 filePath 去重。
|
* 文件参数运行值应统一归一化为数组并按 filePath 去重。
|
||||||
*
|
*
|
||||||
@@ -178,6 +219,89 @@ public class WorkflowRunningParameterResolverTest {
|
|||||||
Assert.assertTrue(((List<?>) attachments).get(0) instanceof Map<?, ?>);
|
Assert.assertTrue(((List<?>) attachments).get(0) instanceof Map<?, ?>);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件参数应接受远程 URL 字符串数组并自动提取文件名。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射注入失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testNormalizeRuntimeVariablesShouldAcceptRemoteFileUrls()
|
||||||
|
throws Exception {
|
||||||
|
WorkflowRunningParameterResolver resolver = newResolver();
|
||||||
|
String firstUrl = "https://files.example.com/contracts/"
|
||||||
|
+ "%E5%90%88%E5%90%8C%20v1.docx?signature=test";
|
||||||
|
String secondUrl = "https://files.example.com/contracts/report.pdf";
|
||||||
|
Map<String, Object> variables = new LinkedHashMap<>();
|
||||||
|
variables.put("attachments", List.of(firstUrl, secondUrl));
|
||||||
|
|
||||||
|
Map<String, Object> normalized = resolver.normalizeRuntimeVariables(
|
||||||
|
workflowContentWithStartParameters(),
|
||||||
|
variables);
|
||||||
|
|
||||||
|
List<?> attachments = (List<?>) normalized.get("attachments");
|
||||||
|
Assert.assertEquals(2, attachments.size());
|
||||||
|
Assert.assertEquals(
|
||||||
|
"合同 v1.docx",
|
||||||
|
((Map<?, ?>) attachments.get(0)).get("fileName"));
|
||||||
|
Assert.assertEquals(
|
||||||
|
firstUrl,
|
||||||
|
((Map<?, ?>) attachments.get(0)).get("filePath"));
|
||||||
|
Assert.assertEquals(
|
||||||
|
"report.pdf",
|
||||||
|
((Map<?, ?>) attachments.get(1)).get("fileName"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个远程文件 URL 也应归一化为文件对象数组。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射注入失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testNormalizeRuntimeVariablesShouldAcceptSingleRemoteFileUrl()
|
||||||
|
throws Exception {
|
||||||
|
WorkflowRunningParameterResolver resolver = newResolver();
|
||||||
|
Map<String, Object> variables = new LinkedHashMap<>();
|
||||||
|
variables.put(
|
||||||
|
"attachments",
|
||||||
|
"https://files.example.com/contracts/contract.docx");
|
||||||
|
|
||||||
|
Map<String, Object> normalized = resolver.normalizeRuntimeVariables(
|
||||||
|
workflowContentWithStartParameters(),
|
||||||
|
variables);
|
||||||
|
|
||||||
|
List<?> attachments = (List<?>) normalized.get("attachments");
|
||||||
|
Assert.assertEquals(1, attachments.size());
|
||||||
|
Assert.assertEquals(
|
||||||
|
"contract.docx",
|
||||||
|
((Map<?, ?>) attachments.get(0)).get("fileName"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 无法从 URL 路径识别文件扩展名时应给出可恢复的格式提示。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射注入失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testNormalizeRuntimeVariablesShouldRejectAmbiguousRemoteUrl()
|
||||||
|
throws Exception {
|
||||||
|
WorkflowRunningParameterResolver resolver = newResolver();
|
||||||
|
Map<String, Object> variables = new LinkedHashMap<>();
|
||||||
|
variables.put(
|
||||||
|
"attachments",
|
||||||
|
"https://files.example.com/download?id=contract");
|
||||||
|
|
||||||
|
BusinessException exception = Assert.assertThrows(
|
||||||
|
BusinessException.class,
|
||||||
|
() -> resolver.normalizeRuntimeVariables(
|
||||||
|
workflowContentWithStartParameters(),
|
||||||
|
variables));
|
||||||
|
|
||||||
|
Assert.assertEquals(
|
||||||
|
"文件参数 attachments 的 URL 路径无法识别带扩展名的文件名,"
|
||||||
|
+ "请改用包含 fileName 和 filePath 的文件对象",
|
||||||
|
exception.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 多文件参数应按 filePath 去重并保留已有非文件变量。
|
* 多文件参数应按 filePath 去重并保留已有非文件变量。
|
||||||
*
|
*
|
||||||
@@ -270,6 +394,36 @@ public class WorkflowRunningParameterResolverTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件参数应拒绝超过十个文件的输入。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射注入失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testNormalizeRuntimeVariablesShouldEnforceFileCountLimit()
|
||||||
|
throws Exception {
|
||||||
|
WorkflowRunningParameterResolver resolver = newResolver();
|
||||||
|
List<Map<String, Object>> files = new java.util.ArrayList<>();
|
||||||
|
for (int index = 0; index < 11; index++) {
|
||||||
|
files.add(fileValue(
|
||||||
|
"file-" + index + ".pdf",
|
||||||
|
"/files/file-" + index + ".pdf",
|
||||||
|
1L));
|
||||||
|
}
|
||||||
|
Map<String, Object> variables = new LinkedHashMap<>();
|
||||||
|
variables.put("attachments", files);
|
||||||
|
|
||||||
|
BusinessException exception = Assert.assertThrows(
|
||||||
|
BusinessException.class,
|
||||||
|
() -> resolver.normalizeRuntimeVariables(
|
||||||
|
workflowContentWithStartParameters(),
|
||||||
|
variables));
|
||||||
|
|
||||||
|
Assert.assertEquals(
|
||||||
|
"文件参数 attachments 最多上传 10 个文件",
|
||||||
|
exception.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 旧版图片 URL 应归一化为 URL 图片描述。
|
* 旧版图片 URL 应归一化为 URL 图片描述。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.upload;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link WorkflowApiUploadCleanupScheduler} 批量排空测试。
|
||||||
|
*/
|
||||||
|
public class WorkflowApiUploadCleanupSchedulerTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证一次调度会连续处理多批到期记录。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void cleanupShouldDrainMultipleBatches() {
|
||||||
|
WorkflowApiUploadLifecycleService lifecycleService =
|
||||||
|
Mockito.mock(WorkflowApiUploadLifecycleService.class);
|
||||||
|
Mockito.when(lifecycleService.cleanupExpired(100))
|
||||||
|
.thenReturn(100, 100, 20);
|
||||||
|
WorkflowApiUploadCleanupScheduler scheduler =
|
||||||
|
new WorkflowApiUploadCleanupScheduler(lifecycleService);
|
||||||
|
|
||||||
|
scheduler.cleanup();
|
||||||
|
|
||||||
|
Mockito.verify(lifecycleService, Mockito.times(3))
|
||||||
|
.cleanupExpired(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,552 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.upload;
|
||||||
|
|
||||||
|
import com.easyagents.flow.core.chain.repository.ChainStateRepository;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.InOrder;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
||||||
|
import tech.easyflow.common.cache.RedisLockExecutor;
|
||||||
|
import tech.easyflow.common.filestorage.FileStorageService;
|
||||||
|
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
|
||||||
|
import tech.easyflow.common.filestorage.FileStorageWriteResult;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link WorkflowApiUploadLifecycleService} multipart 文件生命周期测试。
|
||||||
|
*/
|
||||||
|
public class WorkflowApiUploadLifecycleServiceTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证同名文件 Part 会按顺序保存并注入文件对象数组。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void prepareShouldStoreRepeatedFilePartsInOrder() {
|
||||||
|
Fixture fixture = fixture();
|
||||||
|
MultipartFile first = file("first.pdf", "application/pdf", 10L);
|
||||||
|
MultipartFile second = file(
|
||||||
|
"second.docx",
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
20L);
|
||||||
|
Mockito.when(fixture.parameterResolver.resolveFileParameterNames("flow"))
|
||||||
|
.thenReturn(Set.of("documents"));
|
||||||
|
Mockito.when(fixture.parameterResolver.normalizeRuntimeVariables(
|
||||||
|
Mockito.eq("flow"),
|
||||||
|
Mockito.anyMap()))
|
||||||
|
.thenAnswer(invocation -> new LinkedHashMap<>(
|
||||||
|
invocation.getArgument(1)));
|
||||||
|
FileStorageWriteHandle firstHandle = handle("first.pdf");
|
||||||
|
FileStorageWriteHandle secondHandle = handle("second.docx");
|
||||||
|
Mockito.when(fixture.fileStorageService.prepareRecoverableWrite(
|
||||||
|
Mockito.anyString(),
|
||||||
|
Mockito.anyString()))
|
||||||
|
.thenReturn(firstHandle, secondHandle);
|
||||||
|
Mockito.when(fixture.fileStorageService.saveRecoverable(
|
||||||
|
first,
|
||||||
|
firstHandle))
|
||||||
|
.thenReturn(new FileStorageWriteResult(
|
||||||
|
"/files/first.pdf",
|
||||||
|
firstHandle.encodeLocator()));
|
||||||
|
Mockito.when(fixture.fileStorageService.saveRecoverable(
|
||||||
|
second,
|
||||||
|
secondHandle))
|
||||||
|
.thenReturn(new FileStorageWriteResult(
|
||||||
|
"/files/second.docx",
|
||||||
|
secondHandle.encodeLocator()));
|
||||||
|
|
||||||
|
WorkflowApiPreparedUpload prepared = fixture.service.prepare(
|
||||||
|
"flow",
|
||||||
|
Map.of("user_input", "解析"),
|
||||||
|
Map.of("documents", List.of(first, second)));
|
||||||
|
|
||||||
|
Assert.assertNotNull(prepared.getRequestId());
|
||||||
|
Assert.assertEquals("解析", prepared.getVariables().get("user_input"));
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<Map<String, Object>> documents =
|
||||||
|
(List<Map<String, Object>>) prepared.getVariables()
|
||||||
|
.get("documents");
|
||||||
|
Assert.assertEquals(2, documents.size());
|
||||||
|
Assert.assertEquals(
|
||||||
|
"/files/first.pdf",
|
||||||
|
documents.get(0).get("filePath"));
|
||||||
|
Assert.assertEquals(
|
||||||
|
"/files/second.docx",
|
||||||
|
documents.get(1).get("filePath"));
|
||||||
|
|
||||||
|
ArgumentCaptor<WorkflowApiUploadRecord> recordCaptor =
|
||||||
|
ArgumentCaptor.forClass(WorkflowApiUploadRecord.class);
|
||||||
|
Mockito.verify(fixture.uploadStore).create(
|
||||||
|
recordCaptor.capture());
|
||||||
|
Assert.assertEquals(
|
||||||
|
List.of("/files/first.pdf", "/files/second.docx"),
|
||||||
|
recordCaptor.getValue().getStoredFiles().stream()
|
||||||
|
.map(WorkflowApiStoredFile::filePath)
|
||||||
|
.toList());
|
||||||
|
InOrder writeOrder = Mockito.inOrder(
|
||||||
|
fixture.uploadStore,
|
||||||
|
fixture.fileStorageService);
|
||||||
|
writeOrder.verify(fixture.uploadStore)
|
||||||
|
.save(Mockito.any(WorkflowApiUploadRecord.class));
|
||||||
|
writeOrder.verify(fixture.fileStorageService)
|
||||||
|
.saveRecoverable(first, firstHandle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证非法客户端 MIME 会按扩展名归一化,并同时用于存储和文件描述。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void prepareShouldNormalizeInvalidContentType() {
|
||||||
|
Fixture fixture = fixture();
|
||||||
|
MultipartFile file = file(
|
||||||
|
"C:\\fakepath\\report.docx",
|
||||||
|
"Other",
|
||||||
|
10L);
|
||||||
|
Mockito.when(fixture.parameterResolver
|
||||||
|
.resolveFileParameterNames("flow"))
|
||||||
|
.thenReturn(Set.of("documents"));
|
||||||
|
Mockito.when(fixture.parameterResolver.normalizeRuntimeVariables(
|
||||||
|
Mockito.eq("flow"),
|
||||||
|
Mockito.anyMap()))
|
||||||
|
.thenAnswer(invocation -> new LinkedHashMap<>(
|
||||||
|
invocation.getArgument(1)));
|
||||||
|
FileStorageWriteHandle handle = handle("report.docx");
|
||||||
|
Mockito.when(fixture.fileStorageService.prepareRecoverableWrite(
|
||||||
|
Mockito.anyString(),
|
||||||
|
Mockito.anyString()))
|
||||||
|
.thenReturn(handle);
|
||||||
|
Mockito.when(fixture.fileStorageService.saveRecoverable(
|
||||||
|
Mockito.any(MultipartFile.class),
|
||||||
|
Mockito.eq(handle)))
|
||||||
|
.thenReturn(new FileStorageWriteResult(
|
||||||
|
"/files/report.docx",
|
||||||
|
handle.encodeLocator()));
|
||||||
|
|
||||||
|
WorkflowApiPreparedUpload prepared = fixture.service.prepare(
|
||||||
|
"flow",
|
||||||
|
Map.of(),
|
||||||
|
Map.of("documents", List.of(file)));
|
||||||
|
|
||||||
|
ArgumentCaptor<MultipartFile> storedFile =
|
||||||
|
ArgumentCaptor.forClass(MultipartFile.class);
|
||||||
|
Mockito.verify(fixture.fileStorageService).saveRecoverable(
|
||||||
|
storedFile.capture(),
|
||||||
|
Mockito.eq(handle));
|
||||||
|
Assert.assertEquals(
|
||||||
|
"report.docx",
|
||||||
|
storedFile.getValue().getOriginalFilename());
|
||||||
|
Assert.assertEquals(
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
storedFile.getValue().getContentType());
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<Map<String, Object>> documents =
|
||||||
|
(List<Map<String, Object>>) prepared.getVariables()
|
||||||
|
.get("documents");
|
||||||
|
Assert.assertEquals(
|
||||||
|
storedFile.getValue().getContentType(),
|
||||||
|
documents.get(0).get("contentType"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证对象存储超时返回可重试的 50301,并补偿临时文件。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void prepareShouldTranslateStorageTimeoutAndCleanup() {
|
||||||
|
Fixture fixture = fixture();
|
||||||
|
MultipartFile file = file(
|
||||||
|
"report.pdf",
|
||||||
|
"application/pdf",
|
||||||
|
10L);
|
||||||
|
Mockito.when(fixture.parameterResolver
|
||||||
|
.resolveFileParameterNames("flow"))
|
||||||
|
.thenReturn(Set.of("documents"));
|
||||||
|
Mockito.when(fixture.parameterResolver.normalizeRuntimeVariables(
|
||||||
|
Mockito.eq("flow"),
|
||||||
|
Mockito.anyMap()))
|
||||||
|
.thenAnswer(invocation -> new LinkedHashMap<>(
|
||||||
|
invocation.getArgument(1)));
|
||||||
|
FileStorageWriteHandle handle = handle("report.pdf");
|
||||||
|
Mockito.when(fixture.fileStorageService.prepareRecoverableWrite(
|
||||||
|
Mockito.anyString(),
|
||||||
|
Mockito.anyString()))
|
||||||
|
.thenReturn(handle);
|
||||||
|
Mockito.when(fixture.fileStorageService.saveRecoverable(
|
||||||
|
file,
|
||||||
|
handle))
|
||||||
|
.thenThrow(new IllegalStateException(
|
||||||
|
"storage timeout",
|
||||||
|
new java.net.SocketTimeoutException("timeout")));
|
||||||
|
|
||||||
|
BusinessException exception = Assert.assertThrows(
|
||||||
|
BusinessException.class,
|
||||||
|
() -> fixture.service.prepare(
|
||||||
|
"flow",
|
||||||
|
Map.of(),
|
||||||
|
Map.of("documents", List.of(file))));
|
||||||
|
|
||||||
|
Assert.assertEquals(503, exception.getHttpStatus());
|
||||||
|
Assert.assertEquals(50301, exception.getErrorCode());
|
||||||
|
Assert.assertFalse(exception.getMessage().contains("Socket"));
|
||||||
|
Mockito.verify(fixture.fileStorageService)
|
||||||
|
.deleteRecoverable(handle);
|
||||||
|
Mockito.verify(fixture.uploadStore)
|
||||||
|
.remove(Mockito.any(WorkflowApiUploadRecord.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证存储鉴权等非暂时性错误返回安全的 50001,并执行补偿。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void prepareShouldHidePermanentStorageFailureAndCleanup() {
|
||||||
|
Fixture fixture = fixture();
|
||||||
|
MultipartFile file = file(
|
||||||
|
"report.pdf",
|
||||||
|
"application/pdf",
|
||||||
|
10L);
|
||||||
|
Mockito.when(fixture.parameterResolver
|
||||||
|
.resolveFileParameterNames("flow"))
|
||||||
|
.thenReturn(Set.of("documents"));
|
||||||
|
Mockito.when(fixture.parameterResolver.normalizeRuntimeVariables(
|
||||||
|
Mockito.eq("flow"),
|
||||||
|
Mockito.anyMap()))
|
||||||
|
.thenAnswer(invocation -> new LinkedHashMap<>(
|
||||||
|
invocation.getArgument(1)));
|
||||||
|
FileStorageWriteHandle handle = handle("report.pdf");
|
||||||
|
Mockito.when(fixture.fileStorageService.prepareRecoverableWrite(
|
||||||
|
Mockito.anyString(),
|
||||||
|
Mockito.anyString()))
|
||||||
|
.thenReturn(handle);
|
||||||
|
Mockito.when(fixture.fileStorageService.saveRecoverable(
|
||||||
|
file,
|
||||||
|
handle))
|
||||||
|
.thenThrow(new IllegalStateException(
|
||||||
|
"AccessKey=secret, endpoint=http://internal:9000"));
|
||||||
|
|
||||||
|
BusinessException exception = Assert.assertThrows(
|
||||||
|
BusinessException.class,
|
||||||
|
() -> fixture.service.prepare(
|
||||||
|
"flow",
|
||||||
|
Map.of(),
|
||||||
|
Map.of("documents", List.of(file))));
|
||||||
|
|
||||||
|
Assert.assertEquals(500, exception.getHttpStatus());
|
||||||
|
Assert.assertEquals(50001, exception.getErrorCode());
|
||||||
|
Assert.assertFalse(exception.getMessage().contains("secret"));
|
||||||
|
Mockito.verify(fixture.fileStorageService)
|
||||||
|
.deleteRecoverable(handle);
|
||||||
|
Mockito.verify(fixture.uploadStore)
|
||||||
|
.remove(Mockito.any(WorkflowApiUploadRecord.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证未知文件 Part 在写入存储前被拒绝。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void prepareShouldRejectUnknownFilePartBeforeStorage() {
|
||||||
|
Fixture fixture = fixture();
|
||||||
|
MultipartFile file = file("data.pdf", "application/pdf", 10L);
|
||||||
|
Mockito.when(fixture.parameterResolver.resolveFileParameterNames("flow"))
|
||||||
|
.thenReturn(Set.of("documents"));
|
||||||
|
|
||||||
|
BusinessException exception = Assert.assertThrows(
|
||||||
|
BusinessException.class,
|
||||||
|
() -> fixture.service.prepare(
|
||||||
|
"flow",
|
||||||
|
Map.of(),
|
||||||
|
Map.of("unknown", List.of(file))));
|
||||||
|
|
||||||
|
Assert.assertTrue(exception.getMessage().contains("unknown"));
|
||||||
|
Mockito.verifyNoInteractions(fixture.fileStorageService);
|
||||||
|
Mockito.verifyNoInteractions(fixture.uploadStore);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证缺少开始节点必填文件字段时在存储前返回 40016。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void prepareShouldRejectMissingRequiredFileBeforeStorage() {
|
||||||
|
Fixture fixture = fixture();
|
||||||
|
MultipartFile file = file("data.pdf", "application/pdf", 10L);
|
||||||
|
Mockito.when(fixture.parameterResolver
|
||||||
|
.resolveFileParameterNames("flow"))
|
||||||
|
.thenReturn(Set.of("documents", "appendix"));
|
||||||
|
Mockito.when(fixture.parameterResolver
|
||||||
|
.resolveRequiredFileParameterNames("flow"))
|
||||||
|
.thenReturn(Set.of("documents", "appendix"));
|
||||||
|
|
||||||
|
BusinessException exception = Assert.assertThrows(
|
||||||
|
BusinessException.class,
|
||||||
|
() -> fixture.service.prepare(
|
||||||
|
"flow",
|
||||||
|
Map.of(),
|
||||||
|
Map.of("documents", List.of(file))));
|
||||||
|
|
||||||
|
Assert.assertEquals(40016, exception.getErrorCode());
|
||||||
|
Assert.assertTrue(exception.getMessage().contains("appendix"));
|
||||||
|
Mockito.verifyNoInteractions(fixture.fileStorageService);
|
||||||
|
Mockito.verifyNoInteractions(fixture.uploadStore);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证文件数量和大小限制统一转换为 41301。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void prepareShouldTranslateFileLimitToPayloadTooLarge() {
|
||||||
|
Fixture fixture = fixture();
|
||||||
|
MultipartFile file = file("data.pdf", "application/pdf", 10L);
|
||||||
|
Mockito.when(fixture.parameterResolver
|
||||||
|
.resolveFileParameterNames("flow"))
|
||||||
|
.thenReturn(Set.of("documents"));
|
||||||
|
Mockito.when(fixture.parameterResolver.normalizeRuntimeVariables(
|
||||||
|
Mockito.eq("flow"),
|
||||||
|
Mockito.anyMap()))
|
||||||
|
.thenThrow(new BusinessException(
|
||||||
|
"文件参数 documents 最多上传 10 个文件"));
|
||||||
|
|
||||||
|
BusinessException exception = Assert.assertThrows(
|
||||||
|
BusinessException.class,
|
||||||
|
() -> fixture.service.prepare(
|
||||||
|
"flow",
|
||||||
|
Map.of(),
|
||||||
|
Map.of("documents", List.of(file))));
|
||||||
|
|
||||||
|
Assert.assertEquals(413, exception.getHttpStatus());
|
||||||
|
Assert.assertEquals(41301, exception.getErrorCode());
|
||||||
|
Mockito.verifyNoInteractions(fixture.fileStorageService);
|
||||||
|
Mockito.verifyNoInteractions(fixture.uploadStore);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证文件写入后 Redis 更新失败时仍使用内存路径执行补偿删除。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void prepareShouldDeleteSavedFileWhenRecordUpdateFails() {
|
||||||
|
Fixture fixture = fixture();
|
||||||
|
MultipartFile file = file("data.pdf", "application/pdf", 10L);
|
||||||
|
Mockito.when(fixture.parameterResolver.resolveFileParameterNames("flow"))
|
||||||
|
.thenReturn(Set.of("documents"));
|
||||||
|
Mockito.when(fixture.parameterResolver.normalizeRuntimeVariables(
|
||||||
|
Mockito.eq("flow"),
|
||||||
|
Mockito.anyMap()))
|
||||||
|
.thenAnswer(invocation -> new LinkedHashMap<>(
|
||||||
|
invocation.getArgument(1)));
|
||||||
|
FileStorageWriteHandle handle = handle("data.pdf");
|
||||||
|
Mockito.when(fixture.fileStorageService.prepareRecoverableWrite(
|
||||||
|
Mockito.anyString(),
|
||||||
|
Mockito.anyString()))
|
||||||
|
.thenReturn(handle);
|
||||||
|
Mockito.when(fixture.fileStorageService.saveRecoverable(
|
||||||
|
file,
|
||||||
|
handle))
|
||||||
|
.thenReturn(new FileStorageWriteResult(
|
||||||
|
"/files/data.pdf",
|
||||||
|
handle.encodeLocator()));
|
||||||
|
Mockito.doNothing()
|
||||||
|
.doThrow(new IllegalStateException("Redis 写入失败"))
|
||||||
|
.when(fixture.uploadStore)
|
||||||
|
.save(Mockito.any(WorkflowApiUploadRecord.class));
|
||||||
|
|
||||||
|
IllegalStateException exception = Assert.assertThrows(
|
||||||
|
IllegalStateException.class,
|
||||||
|
() -> fixture.service.prepare(
|
||||||
|
"flow",
|
||||||
|
Map.of(),
|
||||||
|
Map.of("documents", List.of(file))));
|
||||||
|
|
||||||
|
Assert.assertTrue(exception.getMessage().contains("Redis"));
|
||||||
|
Mockito.verify(fixture.fileStorageService)
|
||||||
|
.deleteRecoverable(handle);
|
||||||
|
Mockito.verify(fixture.uploadStore)
|
||||||
|
.remove(Mockito.any(WorkflowApiUploadRecord.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证启动失败后的 abort 会幂等删除已保存文件和上传记录。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void abortShouldDeleteStoredFilesAndRecord() {
|
||||||
|
Fixture fixture = fixture();
|
||||||
|
WorkflowApiUploadRecord record = new WorkflowApiUploadRecord();
|
||||||
|
record.setRequestId("request-1");
|
||||||
|
FileStorageWriteHandle firstHandle = handle("a.pdf");
|
||||||
|
FileStorageWriteHandle secondHandle = handle("b.pdf");
|
||||||
|
record.setStoredFiles(List.of(
|
||||||
|
new WorkflowApiStoredFile(
|
||||||
|
"/files/a.pdf",
|
||||||
|
firstHandle.encodeLocator()),
|
||||||
|
new WorkflowApiStoredFile(
|
||||||
|
"/files/b.pdf",
|
||||||
|
secondHandle.encodeLocator())));
|
||||||
|
RedisLockExecutor.LockHandle handle =
|
||||||
|
Mockito.mock(RedisLockExecutor.LockHandle.class);
|
||||||
|
Mockito.when(fixture.redisLockExecutor.tryAcquire(
|
||||||
|
Mockito.anyString(),
|
||||||
|
Mockito.any(),
|
||||||
|
Mockito.any()))
|
||||||
|
.thenReturn(handle);
|
||||||
|
Mockito.when(fixture.uploadStore.find("request-1"))
|
||||||
|
.thenReturn(Optional.of(record));
|
||||||
|
|
||||||
|
fixture.service.abort("request-1");
|
||||||
|
|
||||||
|
Mockito.verify(fixture.fileStorageService)
|
||||||
|
.deleteRecoverable(firstHandle);
|
||||||
|
Mockito.verify(fixture.fileStorageService)
|
||||||
|
.deleteRecoverable(secondHandle);
|
||||||
|
Mockito.verify(fixture.uploadStore).remove(record);
|
||||||
|
Mockito.verify(handle).close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证单条清理失败不会阻塞后续到期记录。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void cleanupExpiredShouldContinueAfterFailedRecord() {
|
||||||
|
Fixture fixture = fixture();
|
||||||
|
FileStorageWriteHandle failedFile = handle("failed.pdf");
|
||||||
|
FileStorageWriteHandle goodFile = handle("good.pdf");
|
||||||
|
WorkflowApiUploadRecord failedRecord =
|
||||||
|
storedRecord("failed", failedFile);
|
||||||
|
WorkflowApiUploadRecord goodRecord =
|
||||||
|
storedRecord("good", goodFile);
|
||||||
|
RedisLockExecutor.LockHandle failedLock =
|
||||||
|
Mockito.mock(RedisLockExecutor.LockHandle.class);
|
||||||
|
RedisLockExecutor.LockHandle goodLock =
|
||||||
|
Mockito.mock(RedisLockExecutor.LockHandle.class);
|
||||||
|
Mockito.when(fixture.uploadStore.claimExpired(
|
||||||
|
Mockito.anyLong(),
|
||||||
|
Mockito.anyLong()))
|
||||||
|
.thenReturn(
|
||||||
|
Optional.of("failed"),
|
||||||
|
Optional.of("good"),
|
||||||
|
Optional.empty());
|
||||||
|
Mockito.when(fixture.redisLockExecutor.tryAcquire(
|
||||||
|
Mockito.anyString(),
|
||||||
|
Mockito.any(),
|
||||||
|
Mockito.any()))
|
||||||
|
.thenReturn(failedLock, goodLock);
|
||||||
|
Mockito.when(fixture.uploadStore.find("failed"))
|
||||||
|
.thenReturn(Optional.of(failedRecord));
|
||||||
|
Mockito.when(fixture.uploadStore.find("good"))
|
||||||
|
.thenReturn(Optional.of(goodRecord));
|
||||||
|
Mockito.doThrow(new IllegalStateException("对象存储不可用"))
|
||||||
|
.when(fixture.fileStorageService)
|
||||||
|
.deleteRecoverable(failedFile);
|
||||||
|
|
||||||
|
int processed = fixture.service.cleanupExpired(10);
|
||||||
|
|
||||||
|
Assert.assertEquals(2, processed);
|
||||||
|
Mockito.verify(fixture.fileStorageService)
|
||||||
|
.deleteRecoverable(goodFile);
|
||||||
|
Mockito.verify(fixture.uploadStore).remove(goodRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 multipart 文件桩。
|
||||||
|
*
|
||||||
|
* @param name 文件名
|
||||||
|
* @param contentType MIME 类型
|
||||||
|
* @param size 文件大小
|
||||||
|
* @return multipart 文件桩
|
||||||
|
*/
|
||||||
|
private MultipartFile file(
|
||||||
|
String name,
|
||||||
|
String contentType,
|
||||||
|
long size) {
|
||||||
|
MultipartFile file = Mockito.mock(MultipartFile.class);
|
||||||
|
Mockito.when(file.isEmpty()).thenReturn(false);
|
||||||
|
Mockito.when(file.getOriginalFilename()).thenReturn(name);
|
||||||
|
Mockito.when(file.getContentType()).thenReturn(contentType);
|
||||||
|
Mockito.when(file.getSize()).thenReturn(size);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建可恢复文件存储句柄。
|
||||||
|
*
|
||||||
|
* @param filename 固定文件名
|
||||||
|
* @return 测试句柄
|
||||||
|
*/
|
||||||
|
private FileStorageWriteHandle handle(String filename) {
|
||||||
|
return new FileStorageWriteHandle(
|
||||||
|
"localFileStorage",
|
||||||
|
"",
|
||||||
|
"/tmp/easyflow-test",
|
||||||
|
"workflow-api-upload/test",
|
||||||
|
filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建包含单个临时文件的上传记录。
|
||||||
|
*
|
||||||
|
* @param requestId 请求 ID
|
||||||
|
* @param handle 文件句柄
|
||||||
|
* @return 上传记录
|
||||||
|
*/
|
||||||
|
private WorkflowApiUploadRecord storedRecord(
|
||||||
|
String requestId,
|
||||||
|
FileStorageWriteHandle handle) {
|
||||||
|
WorkflowApiUploadRecord record =
|
||||||
|
new WorkflowApiUploadRecord();
|
||||||
|
record.setRequestId(requestId);
|
||||||
|
record.setStoredFiles(List.of(
|
||||||
|
new WorkflowApiStoredFile(
|
||||||
|
"/files/" + handle.getFilename(),
|
||||||
|
handle.encodeLocator())));
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建生命周期服务测试夹具。
|
||||||
|
*
|
||||||
|
* @return 测试夹具
|
||||||
|
*/
|
||||||
|
private Fixture fixture() {
|
||||||
|
WorkflowRunningParameterResolver parameterResolver =
|
||||||
|
Mockito.mock(WorkflowRunningParameterResolver.class);
|
||||||
|
FileStorageService fileStorageService =
|
||||||
|
Mockito.mock(FileStorageService.class);
|
||||||
|
WorkflowApiUploadStore uploadStore =
|
||||||
|
Mockito.mock(WorkflowApiUploadStore.class);
|
||||||
|
ChainStateRepository chainStateRepository =
|
||||||
|
Mockito.mock(ChainStateRepository.class);
|
||||||
|
RedisLockExecutor redisLockExecutor =
|
||||||
|
Mockito.mock(RedisLockExecutor.class);
|
||||||
|
return new Fixture(
|
||||||
|
new WorkflowApiUploadLifecycleService(
|
||||||
|
parameterResolver,
|
||||||
|
fileStorageService,
|
||||||
|
new WorkflowApiMultipartFileNormalizer(),
|
||||||
|
uploadStore,
|
||||||
|
chainStateRepository,
|
||||||
|
redisLockExecutor),
|
||||||
|
parameterResolver,
|
||||||
|
fileStorageService,
|
||||||
|
uploadStore,
|
||||||
|
redisLockExecutor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生命周期服务测试夹具。
|
||||||
|
*
|
||||||
|
* @param service 被测服务
|
||||||
|
* @param parameterResolver 参数解析器
|
||||||
|
* @param fileStorageService 文件存储
|
||||||
|
* @param uploadStore 上传记录存储
|
||||||
|
* @param redisLockExecutor 分布式锁执行器
|
||||||
|
*/
|
||||||
|
private record Fixture(
|
||||||
|
WorkflowApiUploadLifecycleService service,
|
||||||
|
WorkflowRunningParameterResolver parameterResolver,
|
||||||
|
FileStorageService fileStorageService,
|
||||||
|
WorkflowApiUploadStore uploadStore,
|
||||||
|
RedisLockExecutor redisLockExecutor) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.upload;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.ArgumentMatchers;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.data.redis.core.ValueOperations;
|
||||||
|
import org.springframework.data.redis.core.script.RedisScript;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link WorkflowApiUploadStore} Redis 原子性契约测试。
|
||||||
|
*/
|
||||||
|
public class WorkflowApiUploadStoreTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证记录与清理索引通过同槽 Lua 脚本原子创建。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||||
|
public void createShouldUseSameSlotAtomicScript() {
|
||||||
|
StringRedisTemplate redisTemplate =
|
||||||
|
Mockito.mock(StringRedisTemplate.class);
|
||||||
|
Mockito.doReturn(1L).when(redisTemplate).execute(
|
||||||
|
ArgumentMatchers.<RedisScript<Long>>any(),
|
||||||
|
ArgumentMatchers.<List<String>>any(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString());
|
||||||
|
WorkflowApiUploadStore store = new WorkflowApiUploadStore(
|
||||||
|
redisTemplate,
|
||||||
|
new ObjectMapper());
|
||||||
|
WorkflowApiUploadRecord record = record("request-1", null);
|
||||||
|
|
||||||
|
store.create(record);
|
||||||
|
|
||||||
|
ArgumentCaptor<RedisScript<Long>> scriptCaptor =
|
||||||
|
ArgumentCaptor.forClass((Class) RedisScript.class);
|
||||||
|
ArgumentCaptor<List<String>> keysCaptor =
|
||||||
|
ArgumentCaptor.forClass((Class) List.class);
|
||||||
|
Mockito.verify(redisTemplate).execute(
|
||||||
|
scriptCaptor.capture(),
|
||||||
|
keysCaptor.capture(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString());
|
||||||
|
Assert.assertTrue(
|
||||||
|
scriptCaptor.getValue().getScriptAsString()
|
||||||
|
.contains("redis.call('zadd'"));
|
||||||
|
Assert.assertEquals(3, keysCaptor.getValue().size());
|
||||||
|
Assert.assertTrue(keysCaptor.getValue().stream()
|
||||||
|
.allMatch(key -> key.contains("{api-upload}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证重新绑定执行 ID 时会在同一脚本中清除旧索引。
|
||||||
|
*
|
||||||
|
* @throws Exception 上传记录序列化失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||||
|
public void bindExecutionShouldReplacePreviousIndexAtomically()
|
||||||
|
throws Exception {
|
||||||
|
StringRedisTemplate redisTemplate =
|
||||||
|
Mockito.mock(StringRedisTemplate.class);
|
||||||
|
ValueOperations<String, String> valueOperations =
|
||||||
|
Mockito.mock(ValueOperations.class);
|
||||||
|
Mockito.when(redisTemplate.opsForValue())
|
||||||
|
.thenReturn(valueOperations);
|
||||||
|
ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
WorkflowApiUploadRecord record =
|
||||||
|
record("request-1", "execution-old");
|
||||||
|
Mockito.when(valueOperations.get(
|
||||||
|
"easyflow:workflow:{api-upload}:record:request-1"))
|
||||||
|
.thenReturn(objectMapper.writeValueAsString(record));
|
||||||
|
Mockito.doReturn(1L).when(redisTemplate).execute(
|
||||||
|
ArgumentMatchers.<RedisScript<Long>>any(),
|
||||||
|
ArgumentMatchers.<List<String>>any(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString());
|
||||||
|
WorkflowApiUploadStore store = new WorkflowApiUploadStore(
|
||||||
|
redisTemplate,
|
||||||
|
objectMapper);
|
||||||
|
|
||||||
|
store.bindExecution("request-1", "execution-new");
|
||||||
|
|
||||||
|
ArgumentCaptor<RedisScript<Long>> scriptCaptor =
|
||||||
|
ArgumentCaptor.forClass((Class) RedisScript.class);
|
||||||
|
ArgumentCaptor<List<String>> keysCaptor =
|
||||||
|
ArgumentCaptor.forClass((Class) List.class);
|
||||||
|
Mockito.verify(redisTemplate).execute(
|
||||||
|
scriptCaptor.capture(),
|
||||||
|
keysCaptor.capture(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString());
|
||||||
|
Assert.assertEquals(
|
||||||
|
"easyflow:workflow:{api-upload}:execution:execution-new",
|
||||||
|
keysCaptor.getValue().get(1));
|
||||||
|
Assert.assertEquals(
|
||||||
|
"easyflow:workflow:{api-upload}:execution:execution-old",
|
||||||
|
keysCaptor.getValue().get(2));
|
||||||
|
Assert.assertTrue(
|
||||||
|
scriptCaptor.getValue().getScriptAsString()
|
||||||
|
.contains("redis.call('del', KEYS[3])"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建测试上传记录。
|
||||||
|
*
|
||||||
|
* @param requestId 上传请求 ID
|
||||||
|
* @param executeId 执行 ID
|
||||||
|
* @return 上传记录
|
||||||
|
*/
|
||||||
|
private WorkflowApiUploadRecord record(
|
||||||
|
String requestId,
|
||||||
|
String executeId) {
|
||||||
|
WorkflowApiUploadRecord record = new WorkflowApiUploadRecord();
|
||||||
|
record.setRequestId(requestId);
|
||||||
|
record.setExecuteId(executeId);
|
||||||
|
record.setCleanupAt(1_000L);
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.upload;
|
||||||
|
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
import tech.easyflow.common.filestorage.FileStorageService;
|
||||||
|
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link WorkflowApiUploadedFileReader} 上传记录授权边界测试。
|
||||||
|
*/
|
||||||
|
public class WorkflowApiUploadedFileReaderTest {
|
||||||
|
|
||||||
|
private static final String REQUEST_ID =
|
||||||
|
"0123456789abcdef0123456789abcdef";
|
||||||
|
private static final String FILENAME =
|
||||||
|
"000-abcdefabcdefabcdefabcdefabcdefab.docx";
|
||||||
|
private static final String FILE_URL =
|
||||||
|
"http://127.0.0.1:39000/easyflow/attachment/"
|
||||||
|
+ "workflow-api-upload/" + REQUEST_ID + "/" + FILENAME;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 URL、上传记录与恢复句柄完全匹配后按固定后端读取。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试流读取失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldReadExactRecordedUploadByRecoverableHandle() throws Exception {
|
||||||
|
WorkflowApiUploadStore uploadStore = Mockito.mock(WorkflowApiUploadStore.class);
|
||||||
|
FileStorageService fileStorageService = Mockito.mock(FileStorageService.class);
|
||||||
|
WorkflowApiUploadedFileReader reader =
|
||||||
|
new WorkflowApiUploadedFileReader(uploadStore, fileStorageService);
|
||||||
|
FileStorageWriteHandle handle = handle();
|
||||||
|
WorkflowApiUploadRecord record = record(FILE_URL, handle);
|
||||||
|
byte[] content = "document-content".getBytes(StandardCharsets.UTF_8);
|
||||||
|
Mockito.when(uploadStore.find(REQUEST_ID)).thenReturn(Optional.of(record));
|
||||||
|
Mockito.when(fileStorageService.readRecoverable(handle))
|
||||||
|
.thenReturn(new ByteArrayInputStream(content));
|
||||||
|
|
||||||
|
Optional<InputStream> opened = reader.openVerified(FILE_URL);
|
||||||
|
|
||||||
|
Assert.assertTrue(opened.isPresent());
|
||||||
|
try (InputStream inputStream = opened.orElseThrow()) {
|
||||||
|
Assert.assertArrayEquals(content, inputStream.readAllBytes());
|
||||||
|
}
|
||||||
|
Mockito.verify(fileStorageService).readRecoverable(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证普通远端 URL 不访问上传记录,也不获得内部读取权限。
|
||||||
|
*
|
||||||
|
* @throws IOException 路径解析失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldIgnoreOrdinaryRemoteUrl() throws IOException {
|
||||||
|
WorkflowApiUploadStore uploadStore = Mockito.mock(WorkflowApiUploadStore.class);
|
||||||
|
FileStorageService fileStorageService = Mockito.mock(FileStorageService.class);
|
||||||
|
WorkflowApiUploadedFileReader reader =
|
||||||
|
new WorkflowApiUploadedFileReader(uploadStore, fileStorageService);
|
||||||
|
|
||||||
|
Optional<InputStream> opened = reader.openVerified(
|
||||||
|
"http://127.0.0.1:39000/easyflow/attachment/ordinary.docx");
|
||||||
|
|
||||||
|
Assert.assertTrue(opened.isEmpty());
|
||||||
|
Mockito.verifyNoInteractions(uploadStore, fileStorageService);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证看似系统目录的 URL 在 Redis 记录不存在时明确判定为失效。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldRejectManagedPathWhenUploadRecordExpired() {
|
||||||
|
WorkflowApiUploadStore uploadStore = Mockito.mock(WorkflowApiUploadStore.class);
|
||||||
|
FileStorageService fileStorageService = Mockito.mock(FileStorageService.class);
|
||||||
|
WorkflowApiUploadedFileReader reader =
|
||||||
|
new WorkflowApiUploadedFileReader(uploadStore, fileStorageService);
|
||||||
|
Mockito.when(uploadStore.find(REQUEST_ID)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
IOException exception = Assert.assertThrows(
|
||||||
|
IOException.class,
|
||||||
|
() -> reader.openVerified(FILE_URL));
|
||||||
|
|
||||||
|
Assert.assertTrue(exception.getMessage().contains("已失效"));
|
||||||
|
Mockito.verifyNoInteractions(fileStorageService);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证同一请求 ID 下未记录的 URL 不能复用其他文件的存储 locator。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldRejectUrlThatDoesNotExactlyMatchStoredFile() {
|
||||||
|
WorkflowApiUploadStore uploadStore = Mockito.mock(WorkflowApiUploadStore.class);
|
||||||
|
FileStorageService fileStorageService = Mockito.mock(FileStorageService.class);
|
||||||
|
WorkflowApiUploadedFileReader reader =
|
||||||
|
new WorkflowApiUploadedFileReader(uploadStore, fileStorageService);
|
||||||
|
Mockito.when(uploadStore.find(REQUEST_ID)).thenReturn(Optional.of(
|
||||||
|
record(FILE_URL + "?different=true", handle())));
|
||||||
|
|
||||||
|
IOException exception = Assert.assertThrows(
|
||||||
|
IOException.class,
|
||||||
|
() -> reader.openVerified(FILE_URL));
|
||||||
|
|
||||||
|
Assert.assertTrue(exception.getMessage().contains("不匹配"));
|
||||||
|
Mockito.verifyNoInteractions(fileStorageService);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建与系统上传目录一致的恢复句柄。
|
||||||
|
*
|
||||||
|
* @return 测试句柄
|
||||||
|
*/
|
||||||
|
private FileStorageWriteHandle handle() {
|
||||||
|
return new FileStorageWriteHandle(
|
||||||
|
"local",
|
||||||
|
"",
|
||||||
|
"/tmp/easyflow-test",
|
||||||
|
"workflow-api-upload/" + REQUEST_ID,
|
||||||
|
FILENAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建包含单个受管文件的上传记录。
|
||||||
|
*
|
||||||
|
* @param fileUrl 记录中的完整文件 URL
|
||||||
|
* @param handle 文件存储句柄
|
||||||
|
* @return 上传记录
|
||||||
|
*/
|
||||||
|
private WorkflowApiUploadRecord record(
|
||||||
|
String fileUrl,
|
||||||
|
FileStorageWriteHandle handle) {
|
||||||
|
WorkflowApiUploadRecord record = new WorkflowApiUploadRecord();
|
||||||
|
record.setRequestId(REQUEST_ID);
|
||||||
|
record.setStoredFiles(List.of(new WorkflowApiStoredFile(
|
||||||
|
fileUrl,
|
||||||
|
handle.encodeLocator())));
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package tech.easyflow.ai.node;
|
|||||||
|
|
||||||
import org.junit.Assert;
|
import org.junit.Assert;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadedFileReader;
|
||||||
import tech.easyflow.ai.document.model.DocumentParseTaskInfo;
|
import tech.easyflow.ai.document.model.DocumentParseTaskInfo;
|
||||||
import tech.easyflow.ai.document.model.DocumentParseTaskStatus;
|
import tech.easyflow.ai.document.model.DocumentParseTaskStatus;
|
||||||
import tech.easyflow.ai.document.model.DocumentParsedResult;
|
import tech.easyflow.ai.document.model.DocumentParsedResult;
|
||||||
@@ -14,13 +16,12 @@ import java.io.ByteArrayInputStream;
|
|||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.net.InetSocketAddress;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import com.sun.net.httpserver.HttpServer;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@link DocNodeFileContentExtractor} 单元测试。
|
* {@link DocNodeFileContentExtractor} 单元测试。
|
||||||
@@ -175,42 +176,63 @@ public class DocNodeFileContentExtractorTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证远端素材 URL 的非桥接文件不会误走本地存储读取。
|
* 验证普通远端素材 URL 的非桥接文件仍拒绝访问回环地址。
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
public void shouldReadRemoteUrlForUnsupportedType() {
|
public void shouldRejectLoopbackRemoteUrlForUnsupportedType() {
|
||||||
RecordingDocumentParseBridgeService bridgeService = new RecordingDocumentParseBridgeService();
|
RecordingDocumentParseBridgeService bridgeService = new RecordingDocumentParseBridgeService();
|
||||||
HttpServer server;
|
DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
|
||||||
try {
|
bridgeService,
|
||||||
server = HttpServer.create(new InetSocketAddress(0), 0);
|
new FailingFileStorageService(),
|
||||||
} catch (IOException e) {
|
new ReadingReaderManager()
|
||||||
throw new RuntimeException(e);
|
);
|
||||||
|
|
||||||
|
RuntimeException exception = Assert.assertThrows(
|
||||||
|
RuntimeException.class,
|
||||||
|
() -> extractor.extract(buildFileValue(
|
||||||
|
"note.txt",
|
||||||
|
"http://127.0.0.1:39000/note.txt",
|
||||||
|
"text/plain")));
|
||||||
|
|
||||||
|
Throwable cause = exception;
|
||||||
|
while (cause != null
|
||||||
|
&& !(cause instanceof java.net.UnknownHostException)) {
|
||||||
|
cause = cause.getCause();
|
||||||
}
|
}
|
||||||
byte[] body = "remote text".getBytes(StandardCharsets.UTF_8);
|
Assert.assertNotNull(cause);
|
||||||
server.createContext("/note.txt", exchange -> {
|
Assert.assertNull(bridgeService.lastSource);
|
||||||
exchange.sendResponseHeaders(200, body.length);
|
}
|
||||||
exchange.getResponseBody().write(body);
|
|
||||||
exchange.close();
|
/**
|
||||||
});
|
* 验证受管上传 URL 的非桥接文件通过记录校验后走内部存储读取。
|
||||||
server.start();
|
*
|
||||||
try {
|
* @throws IOException 测试流配置失败时抛出
|
||||||
DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldReadVerifiedManagedUploadForUnsupportedType() throws IOException {
|
||||||
|
RecordingDocumentParseBridgeService bridgeService = new RecordingDocumentParseBridgeService();
|
||||||
|
WorkflowApiUploadedFileReader uploadedFileReader =
|
||||||
|
Mockito.mock(WorkflowApiUploadedFileReader.class);
|
||||||
|
String fileUrl = "http://127.0.0.1:39000/easyflow/attachment/"
|
||||||
|
+ "workflow-api-upload/0123456789abcdef0123456789abcdef/note.txt";
|
||||||
|
byte[] body = "managed text".getBytes(StandardCharsets.UTF_8);
|
||||||
|
Mockito.when(uploadedFileReader.isManagedPathCandidate(fileUrl))
|
||||||
|
.thenReturn(true);
|
||||||
|
Mockito.when(uploadedFileReader.openVerified(fileUrl))
|
||||||
|
.thenReturn(Optional.of(new ByteArrayInputStream(body)));
|
||||||
|
DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
|
||||||
bridgeService,
|
bridgeService,
|
||||||
new FailingFileStorageService(),
|
new FailingFileStorageService(),
|
||||||
new ReadingReaderManager()
|
new ReadingReaderManager(),
|
||||||
);
|
uploadedFileReader);
|
||||||
|
|
||||||
String content = extractor.extract(buildFileValue(
|
String content = extractor.extract(buildFileValue(
|
||||||
"note.txt",
|
"note.txt",
|
||||||
"http://127.0.0.1:" + server.getAddress().getPort() + "/note.txt",
|
fileUrl,
|
||||||
"text/plain"
|
"text/plain"));
|
||||||
));
|
|
||||||
|
|
||||||
Assert.assertEquals("remote text", content);
|
Assert.assertEquals("managed text", content);
|
||||||
Assert.assertNull(bridgeService.lastSource);
|
Assert.assertNull(bridgeService.lastSource);
|
||||||
} finally {
|
|
||||||
server.stop(0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -511,16 +511,20 @@ function resolveApiFields(row: any): ApiFieldDoc[] {
|
|||||||
})
|
})
|
||||||
.filter(Boolean) as ApiFieldDoc[];
|
.filter(Boolean) as ApiFieldDoc[];
|
||||||
}
|
}
|
||||||
function buildExampleVariables(row: any) {
|
function isFileApiField(field: ApiFieldDoc) {
|
||||||
|
return (
|
||||||
|
field.type.trim().toLowerCase() === 'file' ||
|
||||||
|
field.type.trim().toLowerCase() === 'files'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function buildExampleVariables(row: any, includeFileReferences = true) {
|
||||||
const variables: Record<string, any> = {};
|
const variables: Record<string, any> = {};
|
||||||
for (const field of resolveApiFields(row)) {
|
for (const field of resolveApiFields(row)) {
|
||||||
if (field.type === 'file') {
|
if (isFileApiField(field)) {
|
||||||
variables[field.key] = [
|
if (!includeFileReferences) {
|
||||||
{
|
continue;
|
||||||
fileName: 'example.pdf',
|
}
|
||||||
filePath: 'https://example.com/example.pdf',
|
variables[field.key] = 'https://files.example.com/example.pdf';
|
||||||
},
|
|
||||||
];
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (field.type === 'checkbox') {
|
if (field.type === 'checkbox') {
|
||||||
@@ -541,12 +545,101 @@ function buildRunRequestExample(row: any) {
|
|||||||
2,
|
2,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function buildRunResponseExample() {
|
function quoteShellFormValue(value: string) {
|
||||||
|
return `'${value.replaceAll(`'`, String.raw`'"'"'`)}'`;
|
||||||
|
}
|
||||||
|
function buildMultipartCurlExample(row: any, url: string) {
|
||||||
|
const fileFields = resolveApiFields(row).filter((field) =>
|
||||||
|
isFileApiField(field),
|
||||||
|
);
|
||||||
|
const metadata = JSON.stringify({
|
||||||
|
id: row?.id,
|
||||||
|
variables: buildExampleVariables(row, false),
|
||||||
|
});
|
||||||
|
const parts = [
|
||||||
|
`curl --request POST '${url}'`,
|
||||||
|
` --header 'ApiKey: <your-api-key>'`,
|
||||||
|
` --form ${quoteShellFormValue(
|
||||||
|
`metadata=${metadata};type=application/json`,
|
||||||
|
)}`,
|
||||||
|
...fileFields.map(
|
||||||
|
(field) =>
|
||||||
|
` --form ${quoteShellFormValue(`files.${field.key}=@/path/to/example.pdf;type=application/pdf`)}`,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
return parts
|
||||||
|
.map((line, index) => (index < parts.length - 1 ? `${line} \\` : line))
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
function buildRunResponseExample(row: any) {
|
||||||
return JSON.stringify(
|
return JSON.stringify(
|
||||||
{
|
{
|
||||||
errorCode: 0,
|
errorCode: 0,
|
||||||
message: '成功',
|
message: '成功',
|
||||||
data: '执行ID',
|
data: '执行ID',
|
||||||
|
workflow: {
|
||||||
|
workflowId: String(row?.id ?? ''),
|
||||||
|
alias: row?.alias || null,
|
||||||
|
title: row?.title || null,
|
||||||
|
description: row?.description || null,
|
||||||
|
revision: row?.revision ?? null,
|
||||||
|
publishedAt: row?.publishedAt || null,
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
nodeId: '<节点 ID>',
|
||||||
|
nodeType: '<节点类型>',
|
||||||
|
nodeName: '<节点名称>',
|
||||||
|
description: null,
|
||||||
|
parentNodeId: null,
|
||||||
|
definitionIndex: 0,
|
||||||
|
topologyIndex: 0,
|
||||||
|
topologyLevel: 0,
|
||||||
|
inDegree: 0,
|
||||||
|
outDegree: 1,
|
||||||
|
startNode: true,
|
||||||
|
endNode: false,
|
||||||
|
predecessorNodeIds: [],
|
||||||
|
successorNodeIds: ['<后继节点 ID>'],
|
||||||
|
incomingEdgeIds: [],
|
||||||
|
outgoingEdgeIds: ['<边 ID>'],
|
||||||
|
inputParameters: [
|
||||||
|
{
|
||||||
|
parameterId: '<参数 ID>',
|
||||||
|
name: 'documents',
|
||||||
|
label: '文档',
|
||||||
|
dataType: 'File',
|
||||||
|
contentType: 'file',
|
||||||
|
required: true,
|
||||||
|
description: null,
|
||||||
|
multipartPartName: 'files.documents',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
outputParameters: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{
|
||||||
|
edgeId: '<边 ID>',
|
||||||
|
edgeType: '<边类型>',
|
||||||
|
label: null,
|
||||||
|
sourceNodeId: '<源节点 ID>',
|
||||||
|
targetNodeId: '<目标节点 ID>',
|
||||||
|
sourceHandle: null,
|
||||||
|
targetHandle: null,
|
||||||
|
parentNodeId: null,
|
||||||
|
definitionIndex: 0,
|
||||||
|
dangling: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
topologicalOrder: ['<开始节点 ID>', '<处理节点 ID>', '<结束节点 ID>'],
|
||||||
|
topologyLevels: [
|
||||||
|
['<开始节点 ID>'],
|
||||||
|
['<处理节点 ID>'],
|
||||||
|
['<结束节点 ID>'],
|
||||||
|
],
|
||||||
|
hasCycle: false,
|
||||||
|
unresolvedNodeIds: [],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
@@ -578,11 +671,18 @@ function handleApiDocClick(e: MouseEvent) {
|
|||||||
if (url) copyApiContent(url);
|
if (url) copyApiContent(url);
|
||||||
}
|
}
|
||||||
function apiUrlLine(method: string, url: string) {
|
function apiUrlLine(method: string, url: string) {
|
||||||
const escaped = url.replace(/"/g, '"');
|
const escaped = url.replaceAll('"', '"');
|
||||||
return `\`${method}\` \`${url}\` <button class="api-url-copy-btn" data-copy="${escaped}" title="复制">
|
return `\`${method}\` \`${url}\` <button type="button" class="api-url-copy-btn" data-copy="${escaped}" title="复制接口地址" aria-label="复制接口地址">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg></button>`;
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg></button>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeMarkdownTableCell(value: unknown) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.replaceAll('|', String.raw`\|`)
|
||||||
|
.replaceAll(/\r?\n/g, '<br>');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* eslint-disable unicorn/prefer-single-call -- 文档按接口章节顺序增量组装,可读性优先。 */
|
||||||
const apiDocMarkdown = computed(() => {
|
const apiDocMarkdown = computed(() => {
|
||||||
const row = apiInstructionRow.value;
|
const row = apiInstructionRow.value;
|
||||||
if (!row) return '';
|
if (!row) return '';
|
||||||
@@ -625,12 +725,36 @@ const apiDocMarkdown = computed(() => {
|
|||||||
`异步执行工作流,立即返回执行 ID。工作流必须已发布且存在发布快照。`,
|
`异步执行工作流,立即返回执行 ID。工作流必须已发布且存在发布快照。`,
|
||||||
);
|
);
|
||||||
lines.push(``);
|
lines.push(``);
|
||||||
lines.push(`### 请求体`);
|
lines.push(`### JSON 请求`);
|
||||||
|
lines.push(``);
|
||||||
|
lines.push(
|
||||||
|
`> 顶层 \`Content-Type\` 必须为 \`application/json\`。多数 HTTP 客户端在发送 JSON 对象时会自动设置;直接发送文本或文件内容时请显式声明。`,
|
||||||
|
);
|
||||||
lines.push(``);
|
lines.push(``);
|
||||||
lines.push('```json');
|
lines.push('```json');
|
||||||
lines.push(buildRunRequestExample(row));
|
lines.push(buildRunRequestExample(row));
|
||||||
lines.push('```');
|
lines.push('```');
|
||||||
lines.push(``);
|
lines.push(``);
|
||||||
|
if (fields.some((field) => isFileApiField(field))) {
|
||||||
|
lines.push(`### Multipart 文件直传`);
|
||||||
|
lines.push(``);
|
||||||
|
lines.push(
|
||||||
|
`文件参数可直接通过 \`multipart/form-data\` 上传,无需先获取文件地址。\`metadata\` 只承载 API 请求元数据;工作流文件统一使用 \`files.<开始节点文件参数名>\`,例如参数 \`documents\` 对应 Part \`files.documents\`。`,
|
||||||
|
);
|
||||||
|
lines.push(``);
|
||||||
|
lines.push(
|
||||||
|
`> 请勿手工添加顶层 \`Content-Type: multipart/form-data\`。由 cURL、Postman 或 Hoppscotch 根据请求体自动生成包含 boundary 的请求头;\`metadata\` Part 必须使用 \`application/json\`。`,
|
||||||
|
);
|
||||||
|
lines.push(``);
|
||||||
|
lines.push('```bash');
|
||||||
|
lines.push(buildMultipartCurlExample(row, `${baseUrl}/runAsync`));
|
||||||
|
lines.push('```');
|
||||||
|
lines.push(``);
|
||||||
|
lines.push(
|
||||||
|
`同一文件参数需要上传多个文件时,重复传入同名 \`files.<参数名>\` Part。文件 Part 可声明合法 MIME;客户端发送空值、\`Other\` 或非法值时,服务端会按扩展名推断,仍无法识别时使用 \`application/octet-stream\`。`,
|
||||||
|
);
|
||||||
|
lines.push(``);
|
||||||
|
}
|
||||||
|
|
||||||
// 入参说明
|
// 入参说明
|
||||||
lines.push(`### 入参说明`);
|
lines.push(`### 入参说明`);
|
||||||
@@ -649,9 +773,15 @@ const apiDocMarkdown = computed(() => {
|
|||||||
lines.push(`| 参数 | 类型 | 必填 | 说明 |`);
|
lines.push(`| 参数 | 类型 | 必填 | 说明 |`);
|
||||||
lines.push(`| --- | --- | --- | --- |`);
|
lines.push(`| --- | --- | --- | --- |`);
|
||||||
for (const f of fields) {
|
for (const f of fields) {
|
||||||
const desc = [f.label, f.description].filter(Boolean).join(' · ');
|
const fileDescription = isFileApiField(f)
|
||||||
|
? 'JSON 可直接填写 HTTP/HTTPS 文件 URL;多文件使用 URL 数组。URL 路径需包含文件名和扩展名;继续兼容 fileName + filePath 文件对象'
|
||||||
|
: '';
|
||||||
|
const desc = [f.label, f.description, fileDescription]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ');
|
||||||
|
const type = isFileApiField(f) ? 'string / string[]' : f.type;
|
||||||
lines.push(
|
lines.push(
|
||||||
`| ${f.key} | ${f.type} | ${f.required ? '是' : '否'} | ${desc} |`,
|
`| ${escapeMarkdownTableCell(f.key)} | ${escapeMarkdownTableCell(type)} | ${f.required ? '是' : '否'} | ${escapeMarkdownTableCell(desc)} |`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
lines.push(``);
|
lines.push(``);
|
||||||
@@ -660,13 +790,17 @@ const apiDocMarkdown = computed(() => {
|
|||||||
lines.push(`### 响应`);
|
lines.push(`### 响应`);
|
||||||
lines.push(``);
|
lines.push(``);
|
||||||
lines.push('```json');
|
lines.push('```json');
|
||||||
lines.push(buildRunResponseExample());
|
lines.push(buildRunResponseExample(row));
|
||||||
lines.push('```');
|
lines.push('```');
|
||||||
lines.push(``);
|
lines.push(``);
|
||||||
lines.push(
|
lines.push(
|
||||||
`\`data\` 为 **执行 ID**(executeId),后续查询和恢复均需使用此 ID。`,
|
`\`data\` 为 **执行 ID**(executeId),后续查询和恢复均需使用此 ID。`,
|
||||||
);
|
);
|
||||||
lines.push(``);
|
lines.push(``);
|
||||||
|
lines.push(
|
||||||
|
`\`workflow.nodes\` 提供全部公开节点信息;\`topologicalOrder\` 提供稳定的节点 ID 拓扑顺序;\`topologyLevels\` 按可并行层级分组。节点配置中的提示词、脚本和连接密钥等运行配置不会返回。`,
|
||||||
|
);
|
||||||
|
lines.push(``);
|
||||||
lines.push(`---`);
|
lines.push(`---`);
|
||||||
lines.push(``);
|
lines.push(``);
|
||||||
|
|
||||||
@@ -686,7 +820,7 @@ const apiDocMarkdown = computed(() => {
|
|||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
executeId: '<runAsync 返回的执行 ID>',
|
executeId: '<runAsync 返回的执行 ID>',
|
||||||
nodes: [{ nodeId: '<需要查询的节点 ID>' }],
|
nodes: [{ nodeId: '<runAsync.workflow.nodes[].nodeId>' }],
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
@@ -695,7 +829,7 @@ const apiDocMarkdown = computed(() => {
|
|||||||
lines.push('```');
|
lines.push('```');
|
||||||
lines.push(``);
|
lines.push(``);
|
||||||
lines.push(
|
lines.push(
|
||||||
`> \`nodes\` 参数可选。不传则只返回工作流整体状态;需要节点详情时传入对象数组,每项至少包含 \`nodeId\`。`,
|
`> \`nodes\` 参数可选。不传则只返回工作流整体状态;需要节点详情时,可将 \`runAsync.workflow.nodes\` 中的 \`nodeId\` 组成对象数组传入,服务端会从该执行实例的定义快照补齐 \`nodeName\`。`,
|
||||||
);
|
);
|
||||||
lines.push(``);
|
lines.push(``);
|
||||||
lines.push(`### 响应示例`);
|
lines.push(`### 响应示例`);
|
||||||
@@ -708,10 +842,25 @@ const apiDocMarkdown = computed(() => {
|
|||||||
message: '成功',
|
message: '成功',
|
||||||
data: {
|
data: {
|
||||||
executeId: 'abc5358c-a310-4caa-97ec-455062b2235e',
|
executeId: 'abc5358c-a310-4caa-97ec-455062b2235e',
|
||||||
status: 20,
|
status: 'failed',
|
||||||
message: null,
|
terminal: true,
|
||||||
result: { output: '工作流执行结果' },
|
message: '工作流执行失败,请检查输入或稍后重试',
|
||||||
nodes: {},
|
result: null,
|
||||||
|
nodes: {
|
||||||
|
'<节点 ID>': {
|
||||||
|
nodeId: '<节点 ID>',
|
||||||
|
nodeName: '文档解析',
|
||||||
|
status: 'failed',
|
||||||
|
message: '节点执行失败,请检查输入或稍后重试',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
code: 'WORKFLOW_EXECUTION_FAILED',
|
||||||
|
message: '工作流执行失败,请检查输入或稍后重试',
|
||||||
|
nodeId: '<节点 ID>',
|
||||||
|
nodeName: '文档解析',
|
||||||
|
retryable: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
@@ -722,17 +871,20 @@ const apiDocMarkdown = computed(() => {
|
|||||||
lines.push(``);
|
lines.push(``);
|
||||||
lines.push(`### 状态值说明`);
|
lines.push(`### 状态值说明`);
|
||||||
lines.push(``);
|
lines.push(``);
|
||||||
lines.push(`| 数值 | 状态 | 说明 |`);
|
lines.push(`| status | terminal | 说明 |`);
|
||||||
lines.push(`| --- | --- | --- |`);
|
lines.push(`| --- | --- | --- |`);
|
||||||
lines.push(`| 0 | READY | 就绪,尚未开始 |`);
|
lines.push(`| ready | false | 就绪,尚未开始 |`);
|
||||||
lines.push(`| 1 | RUNNING | 执行中 |`);
|
lines.push(`| running | false | 执行中 |`);
|
||||||
lines.push(`| 5 | SUSPEND | 挂起,等待确认节点恢复 |`);
|
lines.push(`| suspended | false | 已暂停,等待确认参数恢复 |`);
|
||||||
lines.push(`| 10 | ERROR | 执行异常,可能仍在重试 |`);
|
lines.push(`| error | false | 执行异常,运行时可能仍在处理 |`);
|
||||||
lines.push(`| 20 | SUCCEEDED | 执行成功,终态 |`);
|
lines.push(`| done | true | 执行成功 |`);
|
||||||
lines.push(`| 21 | FAILED | 执行失败,终态 |`);
|
lines.push(`| failed | true | 执行失败 |`);
|
||||||
lines.push(`| 22 | CANCELLED | 已取消,终态 |`);
|
lines.push(`| cancelled | true | 已取消 |`);
|
||||||
|
lines.push(`| unknown | false | 无法识别的兼容状态 |`);
|
||||||
lines.push(``);
|
lines.push(``);
|
||||||
lines.push(`轮询可在状态值为 \`20\`、\`21\` 或 \`22\` 时结束。`);
|
lines.push(
|
||||||
|
`调用方应优先根据 \`terminal\` 判断是否结束轮询;值为 \`true\` 时,状态为 \`done\`、\`failed\` 或 \`cancelled\`。`,
|
||||||
|
);
|
||||||
lines.push(``);
|
lines.push(``);
|
||||||
|
|
||||||
// ---- 3. 恢复执行 ----
|
// ---- 3. 恢复执行 ----
|
||||||
@@ -743,7 +895,7 @@ const apiDocMarkdown = computed(() => {
|
|||||||
lines.push(apiUrlLine('POST', `${baseUrl}/resume`));
|
lines.push(apiUrlLine('POST', `${baseUrl}/resume`));
|
||||||
lines.push(``);
|
lines.push(``);
|
||||||
lines.push(
|
lines.push(
|
||||||
`当工作流包含**确认节点**时,执行到该节点后状态变为 \`5(SUSPEND)\`,需要调用此接口传入确认参数后恢复执行。若工作流不包含确认节点则无需调用。`,
|
`当工作流包含**确认节点**时,执行到该节点后状态变为 \`suspended\`,需要调用此接口传入确认参数后恢复执行。仅暂停中的执行实例允许恢复;若工作流不包含确认节点则无需调用。`,
|
||||||
);
|
);
|
||||||
lines.push(``);
|
lines.push(``);
|
||||||
lines.push(`### 请求体`);
|
lines.push(`### 请求体`);
|
||||||
@@ -769,21 +921,43 @@ const apiDocMarkdown = computed(() => {
|
|||||||
lines.push(`## 错误处理`);
|
lines.push(`## 错误处理`);
|
||||||
lines.push(``);
|
lines.push(``);
|
||||||
lines.push(
|
lines.push(
|
||||||
`当请求失败时,\`errorCode\` 不为 0,\`message\` 包含错误原因。常见错误:`,
|
`请求尚未受理时会返回真实的 HTTP 4xx/5xx,\`errorCode\` 为稳定业务码,\`data.requestId\` 可用于服务端排查。请求已返回 executeId 后的节点失败通过 \`getChainStatus.data.error\` 获取。`,
|
||||||
);
|
);
|
||||||
lines.push(``);
|
lines.push(``);
|
||||||
lines.push(`| 场景 | 说明 |`);
|
lines.push(`| HTTP | errorCode | 场景与处理 |`);
|
||||||
lines.push(`| --- | --- |`);
|
lines.push(`| --- | --- | --- |`);
|
||||||
lines.push(`| ApiKey 无效或过期 | 检查访问令牌状态与有效期 |`);
|
|
||||||
lines.push(
|
lines.push(
|
||||||
`| 未授权工作流 API 调用 | 在访问令牌中开启「工作流 API 调用授权」 |`,
|
`| 400 | 40011 | JSON 请求体无效;文件直传请改用 multipart/form-data |`,
|
||||||
);
|
);
|
||||||
lines.push(
|
lines.push(
|
||||||
`| 工作流尚未发布 | 仅已发布且存在发布快照的工作流可通过 API 调用 |`,
|
`| 400 | 40012 | Multipart 缺少或无法解析 boundary;删除手工设置的顶层 Content-Type |`,
|
||||||
|
);
|
||||||
|
lines.push(`| 400 | 40013 | 缺少 metadata Part |`);
|
||||||
|
lines.push(`| 400 | 40014 | metadata Part 不是有效 JSON |`);
|
||||||
|
lines.push(`| 400 | 40015 | metadata.id 缺失 |`);
|
||||||
|
lines.push(`| 400 | 40016 | files.<参数名> 与开始节点文件参数不匹配 |`);
|
||||||
|
lines.push(
|
||||||
|
`| 400 | 40017 | 其他工作流运行参数不合法,例如文件 URL 无法识别文件名或扩展名 |`,
|
||||||
|
);
|
||||||
|
lines.push(`| 401 | 40101 | 缺少 ApiKey 请求头 |`);
|
||||||
|
lines.push(`| 401 | 40102 / 40103 | ApiKey 无效、禁用或过期 |`);
|
||||||
|
lines.push(`| 403 | 40301 / 40302 | 缺少接口权限或工作流调用权限 |`);
|
||||||
|
lines.push(`| 404 | 40401 | 工作流不存在、未发布或不可公开调用 |`);
|
||||||
|
lines.push(`| 404 | 40402 | 执行记录不存在、过期或不可访问 |`);
|
||||||
|
lines.push(`| 409 | 40901 | 当前执行状态不允许恢复 |`);
|
||||||
|
lines.push(`| 413 | 41301 | 文件、文件数量或请求总量超限 |`);
|
||||||
|
lines.push(`| 415 | 41501 | 顶层 Content-Type 缺失或不支持 |`);
|
||||||
|
lines.push(`| 415 | 41502 | metadata Part 未使用 application/json |`);
|
||||||
|
lines.push(`| 500 | 50001 | 服务端内部错误;携带 requestId 联系管理员 |`);
|
||||||
|
lines.push(`| 503 | 50301 | 文件存储暂时不可用,可稍后重试 |`);
|
||||||
|
lines.push(``);
|
||||||
|
lines.push(
|
||||||
|
`Hoppscotch 等工具若显示自动生成的 \`multipart/form-data\` 请求头,请保持自动模式,不要覆盖该请求头;覆盖后通常会丢失 boundary。`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
});
|
});
|
||||||
|
/* eslint-enable unicorn/prefer-single-call */
|
||||||
|
|
||||||
async function submitPublishAction(row: any) {
|
async function submitPublishAction(row: any) {
|
||||||
if (
|
if (
|
||||||
@@ -1189,11 +1363,26 @@ function handleHeaderButtonClick(data: any) {
|
|||||||
/>
|
/>
|
||||||
<ElDialog
|
<ElDialog
|
||||||
v-model="apiInstructionVisible"
|
v-model="apiInstructionVisible"
|
||||||
:title="$t('aiWorkflow.apiInstruction')"
|
width="min(960px, calc(100vw - 32px))"
|
||||||
width="780px"
|
|
||||||
class="workflow-api-dialog"
|
class="workflow-api-dialog"
|
||||||
:footer="false"
|
:footer="false"
|
||||||
>
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="workflow-api-dialog__header">
|
||||||
|
<h2 class="workflow-api-dialog__title">
|
||||||
|
{{ $t('aiWorkflow.apiInstruction') }}
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
v-if="apiInstructionRow?.title"
|
||||||
|
class="workflow-api-dialog__context"
|
||||||
|
>
|
||||||
|
{{ apiInstructionRow.title }}
|
||||||
|
<span v-if="apiInstructionRow.alias">
|
||||||
|
· {{ apiInstructionRow.alias }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<div
|
<div
|
||||||
v-if="apiInstructionRow"
|
v-if="apiInstructionRow"
|
||||||
class="workflow-api-markdown-wrap"
|
class="workflow-api-markdown-wrap"
|
||||||
@@ -1654,13 +1843,37 @@ button.workflow-scope-chip:disabled {
|
|||||||
box-shadow: 0 18px 34px -28px hsl(var(--foreground) / 20%);
|
box-shadow: 0 18px 34px -28px hsl(var(--foreground) / 20%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workflow-api-dialog__header {
|
||||||
|
min-width: 0;
|
||||||
|
padding-right: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-api-dialog__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: hsl(var(--text-strong));
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-api-dialog__context {
|
||||||
|
margin: var(--space-1) 0 0;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.workflow-api-markdown-wrap {
|
.workflow-api-markdown-wrap {
|
||||||
max-height: 65vh;
|
max-height: min(76vh, 760px);
|
||||||
padding: 0 4px;
|
padding: var(--space-5) 36px var(--space-8);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.7;
|
line-height: 1.75;
|
||||||
color: hsl(var(--foreground));
|
color: hsl(var(--foreground));
|
||||||
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-api-markdown-wrap::-webkit-scrollbar {
|
.workflow-api-markdown-wrap::-webkit-scrollbar {
|
||||||
@@ -1673,13 +1886,12 @@ button.workflow-scope-chip:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.workflow-api-markdown-wrap :deep(h2) {
|
.workflow-api-markdown-wrap :deep(h2) {
|
||||||
padding-bottom: 8px;
|
margin: 40px 0 14px;
|
||||||
margin-top: 28px;
|
font-size: 20px;
|
||||||
margin-bottom: 16px;
|
font-weight: 650;
|
||||||
font-size: 18px;
|
line-height: 1.4;
|
||||||
font-weight: 700;
|
color: hsl(var(--text-strong));
|
||||||
color: hsl(var(--foreground));
|
letter-spacing: -0.01em;
|
||||||
border-bottom: 1px solid hsl(var(--border));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-api-markdown-wrap :deep(h2:first-child) {
|
.workflow-api-markdown-wrap :deep(h2:first-child) {
|
||||||
@@ -1687,92 +1899,145 @@ button.workflow-scope-chip:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.workflow-api-markdown-wrap :deep(h3) {
|
.workflow-api-markdown-wrap :deep(h3) {
|
||||||
margin-top: 20px;
|
margin: 28px 0 10px;
|
||||||
margin-bottom: 10px;
|
font-size: 16px;
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: hsl(var(--foreground));
|
line-height: 1.5;
|
||||||
|
color: hsl(var(--text-strong));
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-api-markdown-wrap :deep(h4) {
|
.workflow-api-markdown-wrap :deep(h4) {
|
||||||
margin-top: 16px;
|
margin: 22px 0 8px;
|
||||||
margin-bottom: 8px;
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: hsl(var(--foreground));
|
color: hsl(var(--text-strong));
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-api-markdown-wrap :deep(hr) {
|
.workflow-api-markdown-wrap :deep(hr) {
|
||||||
margin: 24px 0;
|
display: none;
|
||||||
border: none;
|
|
||||||
border-top: 1px solid hsl(var(--border));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-api-markdown-wrap :deep(p) {
|
.workflow-api-markdown-wrap :deep(p) {
|
||||||
margin: 8px 0;
|
margin: 8px 0 12px;
|
||||||
|
color: hsl(var(--foreground) / 84%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-api-markdown-wrap :deep(ul),
|
||||||
|
.workflow-api-markdown-wrap :deep(ol) {
|
||||||
|
padding-left: 22px;
|
||||||
|
margin: 10px 0 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-api-markdown-wrap :deep(code) {
|
.workflow-api-markdown-wrap :deep(code) {
|
||||||
padding: 2px 6px;
|
padding: 2px 5px;
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
font-size: 13px;
|
font-size: 0.92em;
|
||||||
color: hsl(var(--foreground));
|
color: hsl(var(--foreground));
|
||||||
background: hsl(var(--muted) / 50%);
|
overflow-wrap: anywhere;
|
||||||
border-radius: 4px;
|
background: hsl(var(--surface-contrast-soft));
|
||||||
|
border-radius: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-api-markdown-wrap :deep(pre) {
|
.workflow-api-markdown-wrap :deep(pre) {
|
||||||
max-height: 280px;
|
max-height: 340px;
|
||||||
padding: 14px 16px;
|
padding: var(--space-4);
|
||||||
margin: 10px 0;
|
margin: 12px 0 20px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
background: hsl(220 14% 96%);
|
line-height: 1.65;
|
||||||
border: 1px solid hsl(var(--border));
|
background: hsl(var(--surface-subtle));
|
||||||
border-radius: 8px;
|
border: 0;
|
||||||
|
border-radius: var(--radius-toolbar);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-api-markdown-wrap :deep(pre code) {
|
.workflow-api-markdown-wrap :deep(pre code) {
|
||||||
padding: 0;
|
padding: 0 !important;
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
color: hsl(var(--foreground));
|
color: hsl(var(--foreground));
|
||||||
|
overflow-wrap: normal;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workflow-api-markdown-wrap :deep(.pre-md) {
|
||||||
|
background: transparent !important;
|
||||||
|
border: 0 !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-api-markdown-wrap :deep(.markdown-elxLanguage-header-div) {
|
||||||
|
padding: 0 0 var(--space-2) !important;
|
||||||
|
background: transparent !important;
|
||||||
|
border: 0 !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.workflow-api-markdown-wrap :deep(table) {
|
.workflow-api-markdown-wrap :deep(table) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 10px 0;
|
margin: 12px 0 22px;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
|
border: 0 !important;
|
||||||
|
border-top: 1px solid hsl(var(--table-row-border)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-api-markdown-wrap :deep(tr) {
|
||||||
|
background: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-api-markdown-wrap :deep(th),
|
.workflow-api-markdown-wrap :deep(th),
|
||||||
.workflow-api-markdown-wrap :deep(td) {
|
.workflow-api-markdown-wrap :deep(td) {
|
||||||
padding: 8px 12px;
|
padding: 10px 12px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
border: 1px solid hsl(var(--border));
|
vertical-align: top;
|
||||||
|
border: 0 !important;
|
||||||
|
border-bottom: 1px solid hsl(var(--table-row-border)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-api-markdown-wrap :deep(th) {
|
.workflow-api-markdown-wrap :deep(th) {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: hsl(var(--foreground));
|
color: hsl(var(--text-strong));
|
||||||
background: hsl(var(--muted) / 40%);
|
background: hsl(var(--table-header-bg));
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-api-markdown-wrap :deep(td) {
|
.workflow-api-markdown-wrap :deep(td) {
|
||||||
color: hsl(var(--foreground) / 85%);
|
color: hsl(var(--foreground) / 82%);
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-api-markdown-wrap :deep(th:nth-child(1)),
|
||||||
|
.workflow-api-markdown-wrap :deep(td:nth-child(1)) {
|
||||||
|
width: 22%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-api-markdown-wrap :deep(th:nth-child(2)),
|
||||||
|
.workflow-api-markdown-wrap :deep(td:nth-child(2)) {
|
||||||
|
width: 20%;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-api-markdown-wrap :deep(th:nth-child(3)),
|
||||||
|
.workflow-api-markdown-wrap :deep(td:nth-child(3)) {
|
||||||
|
width: 10%;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-api-markdown-wrap :deep(blockquote) {
|
.workflow-api-markdown-wrap :deep(blockquote) {
|
||||||
padding: 8px 16px;
|
padding: 2px 0 2px var(--space-4);
|
||||||
margin: 10px 0;
|
margin: 12px 0 20px;
|
||||||
color: hsl(var(--muted-foreground));
|
color: hsl(var(--text-muted));
|
||||||
border-left: 3px solid hsl(var(--primary) / 40%);
|
border-left: 2px solid hsl(var(--primary) / 55%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-api-markdown-wrap :deep(blockquote p) {
|
||||||
|
margin: 0;
|
||||||
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-api-markdown-wrap :deep(strong) {
|
.workflow-api-markdown-wrap :deep(strong) {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: hsl(var(--foreground));
|
color: hsl(var(--text-strong));
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-api-markdown-wrap :deep(.api-url-copy-btn) {
|
.workflow-api-markdown-wrap :deep(.api-url-copy-btn) {
|
||||||
@@ -1787,19 +2052,96 @@ button.workflow-scope-chip:disabled {
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid hsl(var(--border));
|
border: 0;
|
||||||
border-radius: 6px;
|
border-radius: var(--space-2);
|
||||||
transition: all 0.15s;
|
transition:
|
||||||
|
color var(--motion-duration-fast) var(--motion-ease-standard),
|
||||||
|
background-color var(--motion-duration-fast) var(--motion-ease-standard);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-api-markdown-wrap :deep(.api-url-copy-btn:hover) {
|
.workflow-api-markdown-wrap :deep(.api-url-copy-btn:hover) {
|
||||||
color: hsl(var(--primary));
|
color: hsl(var(--primary));
|
||||||
background: hsl(var(--muted) / 50%);
|
background: hsl(var(--surface-contrast-soft));
|
||||||
border-color: hsl(var(--primary) / 40%);
|
}
|
||||||
|
|
||||||
|
.workflow-api-markdown-wrap :deep(.api-url-copy-btn:focus-visible) {
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
outline: 2px solid hsl(var(--primary) / 45%);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.workflow-api-dialog.el-dialog) {
|
||||||
|
overflow: hidden;
|
||||||
|
background: hsl(var(--modal-surface));
|
||||||
|
border: 1px solid hsl(var(--modal-shell-border-soft));
|
||||||
|
border-radius: var(--radius-modal);
|
||||||
|
box-shadow: var(--modal-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.workflow-api-dialog .el-dialog__header) {
|
||||||
|
padding: var(--space-5) var(--space-6);
|
||||||
|
margin-right: 0;
|
||||||
|
border-bottom: 1px solid hsl(var(--modal-divider));
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.workflow-api-dialog .el-dialog__body) {
|
:global(.workflow-api-dialog .el-dialog__body) {
|
||||||
padding: 0 20px 20px;
|
max-height: none;
|
||||||
|
padding: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background: hsl(var(--modal-content-surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
:global(.workflow-api-dialog.el-dialog) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: calc(100vw - 16px) !important;
|
||||||
|
max-height: calc(
|
||||||
|
100vh - var(--easyflow-header-height, 0px) - var(--space-4)
|
||||||
|
);
|
||||||
|
margin: calc(var(--easyflow-header-height, 0px) + var(--space-2)) auto
|
||||||
|
var(--space-2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.workflow-api-dialog .el-dialog__header) {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: var(--space-4) 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.workflow-api-dialog .el-dialog__body) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-api-markdown-wrap {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
max-height: none;
|
||||||
|
padding: var(--space-4) 18px var(--space-6);
|
||||||
|
scrollbar-gutter: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-api-markdown-wrap :deep(h2) {
|
||||||
|
margin-top: var(--space-8);
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-api-markdown-wrap :deep(h3) {
|
||||||
|
margin-top: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-api-markdown-wrap :deep(pre) {
|
||||||
|
max-height: 300px;
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-api-markdown-wrap :deep(table) {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
2
pom.xml
2
pom.xml
@@ -46,7 +46,7 @@
|
|||||||
<snakeyaml.version>2.4</snakeyaml.version>
|
<snakeyaml.version>2.4</snakeyaml.version>
|
||||||
<x-file-storage.version>2.2.1</x-file-storage.version>
|
<x-file-storage.version>2.2.1</x-file-storage.version>
|
||||||
<aliyun-oss.version>3.16.1</aliyun-oss.version>
|
<aliyun-oss.version>3.16.1</aliyun-oss.version>
|
||||||
<minio.version>8.5.2</minio.version>
|
<minio.version>8.5.12</minio.version>
|
||||||
<jackson.version>2.19.4</jackson.version>
|
<jackson.version>2.19.4</jackson.version>
|
||||||
<netty.version>4.1.130.Final</netty.version>
|
<netty.version>4.1.130.Final</netty.version>
|
||||||
<proguard.version>7.9.1</proguard.version>
|
<proguard.version>7.9.1</proguard.version>
|
||||||
|
|||||||
Reference in New Issue
Block a user