feat: 完善用户确认节点选项与输出流转

- 重构确认节点单选多选配置及输出参数契约

- 统一管理端、用户中心、匿名分享和公共接口恢复流程

- 增加保存校验、错误契约及交互测试
This commit is contained in:
2026-09-04 14:55:55 +08:00
parent 65c85180c2
commit 0968e3bfa5
51 changed files with 2465 additions and 1591 deletions

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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,
"当前执行状态不可恢复,仅暂停中的工作流允许恢复");
}
}
}

View File

@@ -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 执行错误使用面向试运行用户的定位信息。
*

View File

@@ -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;
}
/**
* 创建显式循环节点数据。
*

View File

@@ -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());
}
}
}