feat: 完善用户确认节点选项与输出流转
- 重构确认节点单选多选配置及输出参数契约 - 统一管理端、用户中心、匿名分享和公共接口恢复流程 - 增加保存校验、错误契约及交互测试
This commit is contained in:
@@ -142,9 +142,12 @@ public class TinyFlowService {
|
||||
node.setResult(resolved);
|
||||
}
|
||||
|
||||
// 只有当参数不为空时才覆盖
|
||||
if (chainState.getSuspendForParameters() != null) {
|
||||
if (nodeState != null
|
||||
&& nodeState.getStatus() == NodeStatus.SUSPEND
|
||||
&& chainState.getSuspendForParameters() != null) {
|
||||
node.setSuspendForParameters(chainState.getSuspendForParameters());
|
||||
} else {
|
||||
node.setSuspendForParameters(null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ package tech.easyflow.ai.easyagentsflow.service;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONArray;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.easyagents.flow.core.chain.DataType;
|
||||
import com.easyagents.flow.core.node.ConfirmNode;
|
||||
import com.easyagents.flow.core.parser.ChainParser;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
@@ -53,6 +55,9 @@ public class WorkflowCheckService {
|
||||
private static final String TYPE_END = "endNode";
|
||||
private static final String TYPE_LOOP = "loopNode";
|
||||
private static final String TYPE_CONDITION = "conditionNode";
|
||||
private static final String TYPE_CONFIRM = "confirmNode";
|
||||
private static final Set<String> CONFIRM_ARRAY_LEFT_OPERATORS = Set.of(
|
||||
"contains", "notContains", "isEmpty", "isNotEmpty");
|
||||
private static final String TYPE_WORKFLOW = "workflow-node";
|
||||
private static final String TYPE_PLUGIN = "plugin-node";
|
||||
private static final String TYPE_MAKE_FILE = "make-file";
|
||||
@@ -184,6 +189,8 @@ public class WorkflowCheckService {
|
||||
}
|
||||
checkLoopConfigurations(nodes, nodeMap, issues, issueKeys);
|
||||
checkConditionConfigurations(nodes, issues, issueKeys);
|
||||
checkConfirmConfigurations(nodes, issues, issueKeys);
|
||||
checkConfirmOutputReferences(nodes, issues, issueKeys);
|
||||
|
||||
List<EdgeView> edges = new ArrayList<>();
|
||||
Set<String> edgeIds = new HashSet<>();
|
||||
@@ -514,6 +521,288 @@ public class WorkflowCheckService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验用户确认节点配置及其对外输出定义。
|
||||
*/
|
||||
private void checkConfirmConfigurations(
|
||||
List<NodeView> nodes,
|
||||
List<WorkflowCheckIssue> issues,
|
||||
Set<String> issueKeys) {
|
||||
for (NodeView node : nodes) {
|
||||
if (!TYPE_CONFIRM.equals(node.type)) {
|
||||
continue;
|
||||
}
|
||||
if (node.data == null) {
|
||||
addIssue(
|
||||
issues,
|
||||
issueKeys,
|
||||
"CONFIRM_CONFIGURATION_INVALID",
|
||||
"用户确认节点配置不能为空",
|
||||
node.id,
|
||||
null,
|
||||
node.name);
|
||||
continue;
|
||||
}
|
||||
|
||||
ConfirmNode configuration;
|
||||
try {
|
||||
validateConfirmConfigurationTypes(node.data);
|
||||
checkConfirmOutputDefinitions(
|
||||
node,
|
||||
Boolean.TRUE.equals(node.data.get("multiple")),
|
||||
issues,
|
||||
issueKeys);
|
||||
configuration = node.data.toJavaObject(ConfirmNode.class);
|
||||
configuration.validateConfiguration();
|
||||
} catch (Exception exception) {
|
||||
addIssue(
|
||||
issues,
|
||||
issueKeys,
|
||||
"CONFIRM_CONFIGURATION_INVALID",
|
||||
"用户确认节点配置无效: " + shortError(exception),
|
||||
node.id,
|
||||
null,
|
||||
node.name);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validateConfirmConfigurationTypes(JSONObject data) {
|
||||
for (String key : data.keySet()) {
|
||||
if (!ConfirmNode.SUPPORTED_CONFIGURATION_KEYS.contains(key)) {
|
||||
throw new IllegalArgumentException(
|
||||
"用户确认节点包含无效配置字段: " + key);
|
||||
}
|
||||
}
|
||||
if (!(data.get("message") instanceof String)) {
|
||||
throw new IllegalArgumentException("用户确认节点提示内容必须为字符串");
|
||||
}
|
||||
if (!(data.get("multiple") instanceof Boolean)) {
|
||||
throw new IllegalArgumentException("用户确认节点选择方式必须为布尔值");
|
||||
}
|
||||
|
||||
Object optionsValue = data.get("options");
|
||||
if (optionsValue == null) {
|
||||
return;
|
||||
}
|
||||
if (!(optionsValue instanceof JSONArray options)) {
|
||||
throw new IllegalArgumentException("用户确认节点选项必须为数组");
|
||||
}
|
||||
for (Object option : options) {
|
||||
if (!(option instanceof String)) {
|
||||
throw new IllegalArgumentException("用户确认节点选项内容必须为字符串");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void checkConfirmOutputDefinitions(
|
||||
NodeView node,
|
||||
boolean multiple,
|
||||
List<WorkflowCheckIssue> issues,
|
||||
Set<String> issueKeys) {
|
||||
Object outputDefsValue = node.data.get("outputDefs");
|
||||
if (!(outputDefsValue instanceof JSONArray outputDefs)
|
||||
|| outputDefs.size() != 1) {
|
||||
addConfirmOutputIssue(node, issues, issueKeys);
|
||||
return;
|
||||
}
|
||||
Object outputValue = outputDefs.get(0);
|
||||
if (!(outputValue instanceof JSONObject output)) {
|
||||
addConfirmOutputIssue(node, issues, issueKeys);
|
||||
return;
|
||||
}
|
||||
String expectedType = multiple
|
||||
? DataType.Array_String.toString()
|
||||
: DataType.String.toString();
|
||||
if (trimToNull(output.getString("name")) == null
|
||||
|| !expectedType.equals(
|
||||
trimToNull(output.getString("dataType")))) {
|
||||
addConfirmOutputIssue(node, issues, issueKeys);
|
||||
}
|
||||
}
|
||||
|
||||
private void addConfirmOutputIssue(
|
||||
NodeView node,
|
||||
List<WorkflowCheckIssue> issues,
|
||||
Set<String> issueKeys) {
|
||||
addIssue(
|
||||
issues,
|
||||
issueKeys,
|
||||
"CONFIRM_OUTPUT_SCHEMA_INVALID",
|
||||
"用户确认节点必须配置唯一非空输出参数,且类型与选择方式一致",
|
||||
node.id,
|
||||
null,
|
||||
node.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验下游节点保存的确认输出引用仍然存在且类型一致。
|
||||
*/
|
||||
private void checkConfirmOutputReferences(
|
||||
List<NodeView> nodes,
|
||||
List<WorkflowCheckIssue> issues,
|
||||
Set<String> issueKeys) {
|
||||
ConfirmOutputIndex confirmOutputs = new ConfirmOutputIndex();
|
||||
for (NodeView node : nodes) {
|
||||
if (!TYPE_CONFIRM.equals(node.type) || node.data == null) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
ConfirmNode configuration = node.data.toJavaObject(ConfirmNode.class);
|
||||
configuration.validateConfiguration();
|
||||
confirmOutputs.put(
|
||||
node.id,
|
||||
configuration.resolveOutputName(),
|
||||
configuration.isMultiple()
|
||||
? DataType.Array_String.toString()
|
||||
: DataType.String.toString());
|
||||
} catch (Exception ignored) {
|
||||
// 配置错误已由 checkConfirmConfigurations 给出精确问题。
|
||||
}
|
||||
}
|
||||
if (confirmOutputs.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (NodeView node : nodes) {
|
||||
if (node.data != null) {
|
||||
checkConfirmOutputReferences(
|
||||
node.data,
|
||||
node,
|
||||
confirmOutputs,
|
||||
issues,
|
||||
issueKeys);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void checkConfirmOutputReferences(
|
||||
Object value,
|
||||
NodeView consumer,
|
||||
ConfirmOutputIndex confirmOutputs,
|
||||
List<WorkflowCheckIssue> issues,
|
||||
Set<String> issueKeys) {
|
||||
if (value instanceof JSONObject object) {
|
||||
if ("ref".equals(trimToNull(object.getString("refType")))) {
|
||||
checkConfirmOutputReference(
|
||||
object.getString("ref"),
|
||||
object.getString("dataType"),
|
||||
true,
|
||||
consumer,
|
||||
confirmOutputs,
|
||||
issues,
|
||||
issueKeys);
|
||||
}
|
||||
String leftType = checkConfirmOutputReference(
|
||||
object.getString("leftRef"),
|
||||
null,
|
||||
false,
|
||||
consumer,
|
||||
confirmOutputs,
|
||||
issues,
|
||||
issueKeys);
|
||||
String operator = trimToNull(object.getString("operator"));
|
||||
if (DataType.Array_String.toString().equals(leftType)
|
||||
&& !CONFIRM_ARRAY_LEFT_OPERATORS.contains(operator)) {
|
||||
addConfirmConditionTypeIssue(
|
||||
object.getString("leftRef"),
|
||||
operator,
|
||||
consumer,
|
||||
issues,
|
||||
issueKeys);
|
||||
}
|
||||
if ("ref".equals(trimToNull(object.getString("rightType")))) {
|
||||
String rightType = checkConfirmOutputReference(
|
||||
object.getString("rightRef"),
|
||||
null,
|
||||
false,
|
||||
consumer,
|
||||
confirmOutputs,
|
||||
issues,
|
||||
issueKeys);
|
||||
if (DataType.Array_String.toString().equals(rightType)) {
|
||||
addConfirmConditionTypeIssue(
|
||||
object.getString("rightRef"),
|
||||
operator,
|
||||
consumer,
|
||||
issues,
|
||||
issueKeys);
|
||||
}
|
||||
}
|
||||
for (Object child : object.values()) {
|
||||
checkConfirmOutputReferences(
|
||||
child,
|
||||
consumer,
|
||||
confirmOutputs,
|
||||
issues,
|
||||
issueKeys);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (value instanceof JSONArray array) {
|
||||
for (Object child : array) {
|
||||
checkConfirmOutputReferences(
|
||||
child,
|
||||
consumer,
|
||||
confirmOutputs,
|
||||
issues,
|
||||
issueKeys);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String checkConfirmOutputReference(
|
||||
String rawReference,
|
||||
String rawActualType,
|
||||
boolean requireActualType,
|
||||
NodeView consumer,
|
||||
ConfirmOutputIndex confirmOutputs,
|
||||
List<WorkflowCheckIssue> issues,
|
||||
Set<String> issueKeys) {
|
||||
String reference = trimToNull(rawReference);
|
||||
if (reference == null) {
|
||||
return null;
|
||||
}
|
||||
String expectedType = confirmOutputs.get(reference);
|
||||
if (expectedType == null
|
||||
&& !confirmOutputs.referencesConfirmNode(reference)) {
|
||||
return null;
|
||||
}
|
||||
String actualType = trimToNull(rawActualType);
|
||||
boolean invalidType = actualType == null
|
||||
? requireActualType
|
||||
: !Objects.equals(expectedType, actualType);
|
||||
if (expectedType == null || invalidType) {
|
||||
addIssue(
|
||||
issues,
|
||||
issueKeys,
|
||||
"CONFIRM_OUTPUT_REFERENCE_INVALID",
|
||||
"用户确认输出引用不存在或类型已变化: "
|
||||
+ reference,
|
||||
consumer.id,
|
||||
null,
|
||||
consumer.name);
|
||||
}
|
||||
return expectedType;
|
||||
}
|
||||
|
||||
private void addConfirmConditionTypeIssue(
|
||||
String reference,
|
||||
String operator,
|
||||
NodeView consumer,
|
||||
List<WorkflowCheckIssue> issues,
|
||||
Set<String> issueKeys) {
|
||||
addIssue(
|
||||
issues,
|
||||
issueKeys,
|
||||
"CONFIRM_OUTPUT_REFERENCE_INVALID",
|
||||
"用户确认多选输出不支持当前条件操作符: "
|
||||
+ trimToNull(reference) + " (" + operator + ")",
|
||||
consumer.id,
|
||||
null,
|
||||
consumer.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 JSON 条件规则转换为运行时规则对象。
|
||||
*
|
||||
@@ -1674,6 +1963,35 @@ public class WorkflowCheckService {
|
||||
return result;
|
||||
}
|
||||
|
||||
private static class ConfirmOutputIndex {
|
||||
private final Map<String, String> outputTypes = new HashMap<>();
|
||||
private final Set<String> nodeIds = new HashSet<>();
|
||||
|
||||
private void put(String nodeId, String outputName, String dataType) {
|
||||
nodeIds.add(nodeId);
|
||||
outputTypes.put(nodeId + "." + outputName, dataType);
|
||||
}
|
||||
|
||||
private String get(String reference) {
|
||||
return outputTypes.get(reference);
|
||||
}
|
||||
|
||||
private boolean isEmpty() {
|
||||
return outputTypes.isEmpty();
|
||||
}
|
||||
|
||||
private boolean referencesConfirmNode(String reference) {
|
||||
int separator = reference.indexOf('.');
|
||||
while (separator > 0) {
|
||||
if (nodeIds.contains(reference.substring(0, separator))) {
|
||||
return true;
|
||||
}
|
||||
separator = reference.indexOf('.', separator + 1);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void throwIfFailed(WorkflowCheckResult result) {
|
||||
if (result == null) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package tech.easyflow.ai.easyagentsflow.service;
|
||||
|
||||
import com.easyagents.flow.core.chain.ChainResumeException;
|
||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 统一校验并恢复暂停中的工作流。
|
||||
*/
|
||||
@Service
|
||||
public class WorkflowResumeService {
|
||||
|
||||
@Resource
|
||||
private ChainExecutor chainExecutor;
|
||||
|
||||
/**
|
||||
* 恢复暂停实例,并将引擎校验失败转换为稳定的接口错误。
|
||||
*
|
||||
* @param executeId 工作流实例 ID
|
||||
* @param variables 用户提交的确认参数
|
||||
*/
|
||||
public void resume(String executeId, Map<String, Object> variables) {
|
||||
Map<String, Object> submitted = variables == null
|
||||
? new LinkedHashMap<>()
|
||||
: new LinkedHashMap<>(variables);
|
||||
final boolean resumed;
|
||||
try {
|
||||
resumed = chainExecutor.resumeAsyncIfSuspended(
|
||||
executeId, submitted);
|
||||
} catch (ChainResumeException exception) {
|
||||
throw new BusinessException(
|
||||
400,
|
||||
40031,
|
||||
exception.getMessage(),
|
||||
exception);
|
||||
}
|
||||
if (!resumed) {
|
||||
throw new BusinessException(
|
||||
409,
|
||||
40901,
|
||||
"当前执行状态不可恢复,仅暂停中的工作流允许恢复");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import com.easyagents.flow.core.chain.ChainStatus;
|
||||
import com.easyagents.flow.core.chain.ExceptionSummary;
|
||||
import com.easyagents.flow.core.chain.NodeState;
|
||||
import com.easyagents.flow.core.chain.NodeStatus;
|
||||
import com.easyagents.flow.core.chain.Parameter;
|
||||
import com.easyagents.flow.core.chain.repository.ChainStateRepository;
|
||||
import com.easyagents.flow.core.chain.repository.NodeStateRepository;
|
||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||
@@ -148,6 +149,56 @@ public class TinyFlowServiceTest {
|
||||
.load(EXECUTE_ID, NODE_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证链级挂起参数只返回给真正暂停的节点。
|
||||
*
|
||||
* @throws Exception 测试依赖注入失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void shouldAttachSuspendParametersOnlyToSuspendedNode()
|
||||
throws Exception {
|
||||
ChainExecutor chainExecutor = mock(ChainExecutor.class);
|
||||
ChainStateRepository chainStateRepository =
|
||||
mock(ChainStateRepository.class);
|
||||
NodeStateRepository nodeStateRepository =
|
||||
mock(NodeStateRepository.class);
|
||||
ChainState chainState = new ChainState();
|
||||
chainState.setStatus(ChainStatus.SUSPEND);
|
||||
Parameter parameter = new Parameter();
|
||||
parameter.setName("selection__confirm-1");
|
||||
chainState.setSuspendForParameters(List.of(parameter));
|
||||
NodeState suspendedState = new NodeState();
|
||||
suspendedState.setStatus(NodeStatus.SUSPEND);
|
||||
NodeState readyState = new NodeState();
|
||||
readyState.setStatus(NodeStatus.READY);
|
||||
when(chainExecutor.getChainStateRepository())
|
||||
.thenReturn(chainStateRepository);
|
||||
when(chainExecutor.getNodeStateRepository())
|
||||
.thenReturn(nodeStateRepository);
|
||||
when(chainStateRepository.load(EXECUTE_ID))
|
||||
.thenReturn(chainState);
|
||||
when(nodeStateRepository.load(EXECUTE_ID, "confirm-1"))
|
||||
.thenReturn(suspendedState);
|
||||
when(nodeStateRepository.load(EXECUTE_ID, "confirm-2"))
|
||||
.thenReturn(readyState);
|
||||
TinyFlowService service = service(chainExecutor);
|
||||
NodeInfo first = new NodeInfo();
|
||||
first.setNodeId("confirm-1");
|
||||
NodeInfo second = new NodeInfo();
|
||||
second.setNodeId("confirm-2");
|
||||
second.setSuspendForParameters(List.of(parameter));
|
||||
|
||||
ChainInfo result = service.getChainStatus(
|
||||
EXECUTE_ID, List.of(first, second));
|
||||
|
||||
Assert.assertEquals(
|
||||
List.of(parameter),
|
||||
result.getNodes().get("confirm-1")
|
||||
.getSuspendForParameters());
|
||||
Assert.assertNull(result.getNodes().get("confirm-2")
|
||||
.getSuspendForParameters());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 JavaScript 执行错误使用面向试运行用户的定位信息。
|
||||
*
|
||||
|
||||
@@ -525,6 +525,374 @@ public class WorkflowCheckServiceTest {
|
||||
assertHasCode(result, "LOOP_PARENT_CYCLE");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证用户确认选项与输出定义一致时可以保存。
|
||||
*/
|
||||
@Test
|
||||
public void testSaveShouldPassValidConfirmContract() throws Exception {
|
||||
WorkflowCheckService service = newService(new HashMap<>());
|
||||
String content = workflowJson(
|
||||
array(node(
|
||||
"confirm-1",
|
||||
"confirmNode",
|
||||
null,
|
||||
confirmData(false, "String"))),
|
||||
new JSONArray());
|
||||
|
||||
WorkflowCheckResult result = service.checkContent(
|
||||
content, WorkflowCheckStage.SAVE, null);
|
||||
|
||||
Assert.assertTrue(result.isPassed());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证用户确认节点拒绝重复的选项内容。
|
||||
*/
|
||||
@Test
|
||||
public void testSaveShouldBlockDuplicateConfirmOptionContents()
|
||||
throws Exception {
|
||||
WorkflowCheckService service = newService(new HashMap<>());
|
||||
JSONObject confirm = confirmData(false, "String");
|
||||
confirm.getJSONArray("options").add("第一议题");
|
||||
String content = workflowJson(
|
||||
array(node("confirm-1", "confirmNode", null, confirm)),
|
||||
new JSONArray());
|
||||
|
||||
WorkflowCheckResult result = service.checkContent(
|
||||
content, WorkflowCheckStage.SAVE, null);
|
||||
|
||||
Assert.assertFalse(result.isPassed());
|
||||
assertHasCode(result, "CONFIRM_CONFIGURATION_INVALID");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证用户确认节点拒绝非字符串选项。
|
||||
*/
|
||||
@Test
|
||||
public void testSaveShouldBlockNonStringConfirmOption()
|
||||
throws Exception {
|
||||
WorkflowCheckService service = newService(new HashMap<>());
|
||||
JSONObject confirm = confirmData(false, "String");
|
||||
confirm.getJSONArray("options").add(1);
|
||||
String content = workflowJson(
|
||||
array(node("confirm-1", "confirmNode", null, confirm)),
|
||||
new JSONArray());
|
||||
|
||||
WorkflowCheckResult result = service.checkContent(
|
||||
content, WorkflowCheckStage.SAVE, null);
|
||||
|
||||
Assert.assertFalse(result.isPassed());
|
||||
assertHasCode(result, "CONFIRM_CONFIGURATION_INVALID");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证选择方式不能依赖 JSON 隐式类型转换。
|
||||
*/
|
||||
@Test
|
||||
public void testSaveShouldBlockNonBooleanConfirmMode()
|
||||
throws Exception {
|
||||
WorkflowCheckService service = newService(new HashMap<>());
|
||||
JSONObject confirm = confirmData(false, "String");
|
||||
confirm.put("multiple", "false");
|
||||
String content = workflowJson(
|
||||
array(node("confirm-1", "confirmNode", null, confirm)),
|
||||
new JSONArray());
|
||||
|
||||
WorkflowCheckResult result = service.checkContent(
|
||||
content, WorkflowCheckStage.SAVE, null);
|
||||
|
||||
Assert.assertFalse(result.isPassed());
|
||||
assertHasCode(result, "CONFIRM_CONFIGURATION_INVALID");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证最终确认契约拒绝继续保存已废弃配置。
|
||||
*/
|
||||
@Test
|
||||
public void testSaveShouldBlockRetiredConfirmData()
|
||||
throws Exception {
|
||||
WorkflowCheckService service = newService(new HashMap<>());
|
||||
JSONObject confirm = confirmData(false, "String");
|
||||
confirm.put("async", true);
|
||||
confirm.put("fields", new JSONArray());
|
||||
confirm.put("confirms", new JSONArray());
|
||||
confirm.put("schemaVersion", 1);
|
||||
confirm.put("unknownLegacySetting", true);
|
||||
String content = workflowJson(
|
||||
array(node("confirm-1", "confirmNode", null, confirm)),
|
||||
new JSONArray());
|
||||
|
||||
WorkflowCheckResult result = service.checkContent(
|
||||
content, WorkflowCheckStage.SAVE, null);
|
||||
|
||||
Assert.assertFalse(result.isPassed());
|
||||
assertHasCode(result, "CONFIRM_CONFIGURATION_INVALID");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证多选模式必须声明数组输出。
|
||||
*/
|
||||
@Test
|
||||
public void testSaveShouldBlockMismatchedConfirmOutputType()
|
||||
throws Exception {
|
||||
WorkflowCheckService service = newService(new HashMap<>());
|
||||
String content = workflowJson(
|
||||
array(node(
|
||||
"confirm-1",
|
||||
"confirmNode",
|
||||
null,
|
||||
confirmData(true, "String"))),
|
||||
new JSONArray());
|
||||
|
||||
WorkflowCheckResult result = service.checkContent(
|
||||
content, WorkflowCheckStage.SAVE, null);
|
||||
|
||||
Assert.assertFalse(result.isPassed());
|
||||
assertHasCode(result, "CONFIRM_OUTPUT_SCHEMA_INVALID");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证确认节点可以使用用户配置的输出参数名称及下游引用。
|
||||
*/
|
||||
@Test
|
||||
public void testSaveShouldPassConfiguredConfirmOutputName()
|
||||
throws Exception {
|
||||
WorkflowCheckService service = newService(new HashMap<>());
|
||||
JSONObject confirm = confirmData(
|
||||
false, "String", "templateChoice");
|
||||
JSONObject consumer = data("下游节点");
|
||||
consumer.put("parameters", array(refParameter(
|
||||
"input", "confirm-1.templateChoice", "String")));
|
||||
String content = workflowJson(
|
||||
array(
|
||||
node("confirm-1", "confirmNode", null, confirm),
|
||||
node("code-1", "codeNode", null, consumer)),
|
||||
new JSONArray());
|
||||
|
||||
WorkflowCheckResult result = service.checkContent(
|
||||
content, WorkflowCheckStage.SAVE, null);
|
||||
|
||||
Assert.assertTrue(result.isPassed());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证空输出定义不能利用空类型绕过字段契约校验。
|
||||
*/
|
||||
@Test
|
||||
public void testSaveShouldBlockEmptyConfirmOutputDefinition()
|
||||
throws Exception {
|
||||
WorkflowCheckService service = newService(new HashMap<>());
|
||||
JSONObject confirm = confirmData(false, "String");
|
||||
confirm.put("outputDefs", array(new JSONObject()));
|
||||
String content = workflowJson(
|
||||
array(node("confirm-1", "confirmNode", null, confirm)),
|
||||
new JSONArray());
|
||||
|
||||
WorkflowCheckResult result = service.checkContent(
|
||||
content, WorkflowCheckStage.SAVE, null);
|
||||
|
||||
Assert.assertFalse(result.isPassed());
|
||||
assertHasCode(result, "CONFIRM_OUTPUT_SCHEMA_INVALID");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证字符串编码的输出定义不能通过 JSON 隐式类型转换。
|
||||
*/
|
||||
@Test
|
||||
public void testSaveShouldBlockStringEncodedConfirmOutputDefinition()
|
||||
throws Exception {
|
||||
WorkflowCheckService service = newService(new HashMap<>());
|
||||
JSONObject confirm = confirmData(false, "String");
|
||||
confirm.put(
|
||||
"outputDefs",
|
||||
"[{\"name\":\"selection\",\"dataType\":\"String\"}]");
|
||||
String content = workflowJson(
|
||||
array(node("confirm-1", "confirmNode", null, confirm)),
|
||||
new JSONArray());
|
||||
|
||||
WorkflowCheckResult result = service.checkContent(
|
||||
content, WorkflowCheckStage.SAVE, null);
|
||||
|
||||
Assert.assertFalse(result.isPassed());
|
||||
assertHasCode(result, "CONFIRM_OUTPUT_SCHEMA_INVALID");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证输出定义数组中的字符串元素不能被隐式解析为对象。
|
||||
*/
|
||||
@Test
|
||||
public void testSaveShouldBlockStringElementConfirmOutputDefinition()
|
||||
throws Exception {
|
||||
WorkflowCheckService service = newService(new HashMap<>());
|
||||
JSONObject confirm = confirmData(false, "String");
|
||||
JSONArray outputDefs = new JSONArray();
|
||||
outputDefs.add("{\"name\":\"selection\",\"dataType\":\"String\"}");
|
||||
confirm.put("outputDefs", outputDefs);
|
||||
String content = workflowJson(
|
||||
array(node("confirm-1", "confirmNode", null, confirm)),
|
||||
new JSONArray());
|
||||
|
||||
WorkflowCheckResult result = service.checkContent(
|
||||
content, WorkflowCheckStage.SAVE, null);
|
||||
|
||||
Assert.assertFalse(result.isPassed());
|
||||
assertHasCode(result, "CONFIRM_OUTPUT_SCHEMA_INVALID");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证不存在的确认输出会阻止下游引用。
|
||||
*/
|
||||
@Test
|
||||
public void testSaveShouldBlockMissingConfirmOutputReference()
|
||||
throws Exception {
|
||||
WorkflowCheckService service = newService(new HashMap<>());
|
||||
JSONObject consumer = data("下游节点");
|
||||
consumer.put("parameters", array(refParameter(
|
||||
"input", "confirm-1.removed", "String")));
|
||||
String content = workflowJson(
|
||||
array(
|
||||
node("confirm-1", "confirmNode", null,
|
||||
confirmData(false, "String")),
|
||||
node("code-1", "codeNode", null, consumer)),
|
||||
new JSONArray());
|
||||
|
||||
WorkflowCheckResult result = service.checkContent(
|
||||
content, WorkflowCheckStage.SAVE, null);
|
||||
|
||||
Assert.assertFalse(result.isPassed());
|
||||
assertHasCode(result, "CONFIRM_OUTPUT_REFERENCE_INVALID");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证手工导入的带点节点 ID 也能识别失效确认输出引用。
|
||||
*/
|
||||
@Test
|
||||
public void testSaveShouldBlockMissingConfirmOutputForDottedNodeId()
|
||||
throws Exception {
|
||||
WorkflowCheckService service = newService(new HashMap<>());
|
||||
JSONObject consumer = data("下游节点");
|
||||
consumer.put("parameters", array(refParameter(
|
||||
"input", "confirm.group-1.removed", "String")));
|
||||
String content = workflowJson(
|
||||
array(
|
||||
node("confirm.group-1", "confirmNode", null,
|
||||
confirmData(false, "String")),
|
||||
node("code-1", "codeNode", null, consumer)),
|
||||
new JSONArray());
|
||||
|
||||
WorkflowCheckResult result = service.checkContent(
|
||||
content, WorkflowCheckStage.SAVE, null);
|
||||
|
||||
Assert.assertFalse(result.isPassed());
|
||||
assertHasCode(result, "CONFIRM_OUTPUT_REFERENCE_INVALID");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证切换选择方式后,保存会拦截仍声明旧类型的下游引用。
|
||||
*/
|
||||
@Test
|
||||
public void testSaveShouldBlockMismatchedConfirmOutputReferenceType()
|
||||
throws Exception {
|
||||
WorkflowCheckService service = newService(new HashMap<>());
|
||||
JSONObject consumer = data("下游节点");
|
||||
consumer.put("parameters", array(refParameter(
|
||||
"input", "confirm-1.selection", "Array<String>")));
|
||||
String content = workflowJson(
|
||||
array(
|
||||
node("confirm-1", "confirmNode", null,
|
||||
confirmData(false, "String")),
|
||||
node("code-1", "codeNode", null, consumer)),
|
||||
new JSONArray());
|
||||
|
||||
WorkflowCheckResult result = service.checkContent(
|
||||
content, WorkflowCheckStage.SAVE, null);
|
||||
|
||||
Assert.assertFalse(result.isPassed());
|
||||
assertHasCode(result, "CONFIRM_OUTPUT_REFERENCE_INVALID");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证普通参数引用不能通过省略类型绕过确认输出契约。
|
||||
*/
|
||||
@Test
|
||||
public void testSaveShouldBlockConfirmOutputReferenceWithoutType()
|
||||
throws Exception {
|
||||
WorkflowCheckService service = newService(new HashMap<>());
|
||||
JSONObject reference = refParameter(
|
||||
"input", "confirm-1.selection", "String");
|
||||
reference.remove("dataType");
|
||||
JSONObject consumer = data("下游节点");
|
||||
consumer.put("parameters", array(reference));
|
||||
String content = workflowJson(
|
||||
array(
|
||||
node("confirm-1", "confirmNode", null,
|
||||
confirmData(false, "String")),
|
||||
node("code-1", "codeNode", null, consumer)),
|
||||
new JSONArray());
|
||||
|
||||
WorkflowCheckResult result = service.checkContent(
|
||||
content, WorkflowCheckStage.SAVE, null);
|
||||
|
||||
Assert.assertFalse(result.isPassed());
|
||||
assertHasCode(result, "CONFIRM_OUTPUT_REFERENCE_INVALID");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证条件节点直接保存的左右引用也受确认输出契约约束。
|
||||
*/
|
||||
@Test
|
||||
public void testSaveShouldBlockMissingConfirmOutputConditionReference()
|
||||
throws Exception {
|
||||
WorkflowCheckService service = newService(new HashMap<>());
|
||||
JSONObject condition = conditionData(
|
||||
"equals", "ref", "confirm-1.removed");
|
||||
JSONObject rule = condition.getJSONArray("branches")
|
||||
.getJSONObject(0)
|
||||
.getJSONArray("rules")
|
||||
.getJSONObject(0);
|
||||
rule.put("leftRef", "confirm-1.selection");
|
||||
String content = workflowJson(
|
||||
array(
|
||||
node("confirm-1", "confirmNode", null,
|
||||
confirmData(false, "String")),
|
||||
node("condition-1", "conditionNode", null, condition)),
|
||||
new JSONArray());
|
||||
|
||||
WorkflowCheckResult result = service.checkContent(
|
||||
content, WorkflowCheckStage.SAVE, null);
|
||||
|
||||
Assert.assertFalse(result.isPassed());
|
||||
assertHasCode(result, "CONFIRM_OUTPUT_REFERENCE_INVALID");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证确认字段切成多选后,不兼容的条件操作符会在保存阶段被阻止。
|
||||
*/
|
||||
@Test
|
||||
public void testSaveShouldBlockIncompatibleConfirmArrayConditionReference()
|
||||
throws Exception {
|
||||
WorkflowCheckService service = newService(new HashMap<>());
|
||||
JSONObject condition = conditionData(
|
||||
"regexMatch", "fixed", "^AGENDA$");
|
||||
condition.getJSONArray("branches")
|
||||
.getJSONObject(0)
|
||||
.getJSONArray("rules")
|
||||
.getJSONObject(0)
|
||||
.put("leftRef", "confirm-1.selection");
|
||||
String content = workflowJson(
|
||||
array(
|
||||
node("confirm-1", "confirmNode", null,
|
||||
confirmData(true, "Array<String>")),
|
||||
node("condition-1", "conditionNode", null, condition)),
|
||||
new JSONArray());
|
||||
|
||||
WorkflowCheckResult result = service.checkContent(
|
||||
content, WorkflowCheckStage.SAVE, null);
|
||||
|
||||
Assert.assertFalse(result.isPassed());
|
||||
assertHasCode(result, "CONFIRM_OUTPUT_REFERENCE_INVALID");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSaveShouldPassForValidDraft() throws Exception {
|
||||
WorkflowCheckService service = newService(new HashMap<>());
|
||||
@@ -1011,6 +1379,14 @@ public class WorkflowCheckServiceTest {
|
||||
return array;
|
||||
}
|
||||
|
||||
private static JSONArray stringArray(String... values) {
|
||||
JSONArray array = new JSONArray();
|
||||
for (String value : values) {
|
||||
array.add(value);
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
private static JSONObject node(String id, String type, String parentId, JSONObject data) {
|
||||
JSONObject node = new JSONObject();
|
||||
node.put("id", id);
|
||||
@@ -1080,6 +1456,25 @@ public class WorkflowCheckServiceTest {
|
||||
return data;
|
||||
}
|
||||
|
||||
private static JSONObject confirmData(
|
||||
boolean multiple, String outputType) {
|
||||
return confirmData(multiple, outputType, "selection");
|
||||
}
|
||||
|
||||
private static JSONObject confirmData(
|
||||
boolean multiple, String outputType, String outputName) {
|
||||
JSONObject output = new JSONObject();
|
||||
output.put("name", outputName);
|
||||
output.put("dataType", outputType);
|
||||
|
||||
JSONObject data = data("用户确认");
|
||||
data.put("message", "请选择会议纪要模板");
|
||||
data.put("multiple", multiple);
|
||||
data.put("options", stringArray("第一议题", "审议类", "听取类"));
|
||||
data.put("outputDefs", array(output));
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建显式循环节点数据。
|
||||
*
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package tech.easyflow.ai.easyagentsflow.service;
|
||||
|
||||
import com.easyagents.flow.core.chain.ChainResumeException;
|
||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.mockito.Mockito;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.util.Map;
|
||||
import java.lang.reflect.Field;
|
||||
|
||||
/**
|
||||
* {@link WorkflowResumeService} 恢复错误契约测试。
|
||||
*/
|
||||
public class WorkflowResumeServiceTest {
|
||||
|
||||
private WorkflowResumeService service;
|
||||
private ChainExecutor chainExecutor;
|
||||
|
||||
@Before
|
||||
public void setUp() throws Exception {
|
||||
service = new WorkflowResumeService();
|
||||
chainExecutor = Mockito.mock(ChainExecutor.class);
|
||||
Field field = WorkflowResumeService.class.getDeclaredField(
|
||||
"chainExecutor");
|
||||
field.setAccessible(true);
|
||||
field.set(service, chainExecutor);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldMapInvalidConfirmationToBadRequest() {
|
||||
Mockito.when(chainExecutor.resumeAsyncIfSuspended(
|
||||
"execute-1", Map.of("choice", "UNKNOWN")))
|
||||
.thenThrow(new ChainResumeException("包含未配置选项"));
|
||||
|
||||
try {
|
||||
service.resume("execute-1", Map.of("choice", "UNKNOWN"));
|
||||
Assert.fail("invalid option must be rejected");
|
||||
} catch (BusinessException exception) {
|
||||
Assert.assertEquals(400, exception.getHttpStatus());
|
||||
Assert.assertEquals(40031, exception.getErrorCode());
|
||||
Assert.assertEquals("包含未配置选项", exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldMapNonSuspendedInstanceToConflict() {
|
||||
Mockito.when(chainExecutor.resumeAsyncIfSuspended(
|
||||
"execute-1", Map.of("choice", "AGENDA")))
|
||||
.thenReturn(false);
|
||||
|
||||
try {
|
||||
service.resume("execute-1", Map.of("choice", "AGENDA"));
|
||||
Assert.fail("non-suspended instance must be rejected");
|
||||
} catch (BusinessException exception) {
|
||||
Assert.assertEquals(409, exception.getHttpStatus());
|
||||
Assert.assertEquals(40901, exception.getErrorCode());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user