feat(XL13): 归档工作流对话运行界面

- 接入发布快照优先与未发布草稿受控运行

- 支持文本和思考流式输出、循环多输出及实时运行详情

- 完成聊天分享、图片输入、中止与清空重来

- 补充后端与前端定向回归测试
This commit is contained in:
2026-07-31 09:40:54 +08:00
parent 048aa9bc1e
commit 615092f4f7
43 changed files with 6128 additions and 242 deletions

View File

@@ -0,0 +1,326 @@
package tech.easyflow.admin.controller.ai;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import jakarta.servlet.http.HttpServletRequest;
import org.mockito.MockedStatic;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import org.testng.Assert;
import org.testng.annotations.Test;
import tech.easyflow.admin.service.ai.WorkflowChatEventStream;
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.service.WorkflowExecResultService;
import tech.easyflow.ai.service.WorkflowExecStepService;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.service.WorkflowShareService;
import tech.easyflow.ai.share.WorkflowSharePolicy;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.service.ResourceAccessService;
import java.lang.reflect.Field;
import java.math.BigInteger;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link WorkflowChatController} 运行来源与分享边界测试。
*/
public class WorkflowChatControllerTest {
/**
* 验证登录用户可以进入并运行未发布工作流,同时描述信息禁止分享。
*/
@Test
public void shouldRunDraftForAuthenticatedRequestWithoutSharing() {
ControllerFixture fixture = fixture(workflow(PublishStatus.DRAFT, "draft-content", false));
when(fixture.parameterResolver.buildRunningParametersView(fixture.current))
.thenReturn(new LinkedHashMap<>());
when(fixture.parameterResolver.normalizeRuntimeVariables(
eq("draft-content"),
anyMap()
)).thenReturn(new LinkedHashMap<>());
when(fixture.eventStream.start(eq("1"), anyMap())).thenReturn(new SseEmitter());
try (MockedStatic<SaTokenUtil> login = login(fixture.account)) {
Result<Map<String, Object>> descriptor = fixture.controller.descriptor(
BigInteger.ONE,
request(Map.of())
);
fixture.controller.run(BigInteger.ONE, Map.of(), request(Map.of()));
Assert.assertEquals(descriptor.getData().get("shareable"), false);
Assert.assertEquals(
descriptor.getData().get("publishStatus"),
PublishStatus.DRAFT.getCode()
);
}
verify(fixture.parameterResolver).buildRunningParametersView(fixture.current);
verify(fixture.eventStream).start(eq("1"), anyMap());
}
/**
* 验证已发布工作流的管理端运行继续读取发布快照。
*/
@Test
public void shouldRunPublishedSnapshotForAuthenticatedRequest() {
Workflow current = workflow(PublishStatus.PUBLISHED, "draft-content", true);
Workflow published = workflow(PublishStatus.PUBLISHED, "published-content", true);
ControllerFixture fixture = fixture(current, published);
when(fixture.parameterResolver.buildRunningParametersView(published))
.thenReturn(new LinkedHashMap<>());
when(fixture.parameterResolver.normalizeRuntimeVariables(
eq("published-content"),
anyMap()
)).thenReturn(new LinkedHashMap<>());
when(fixture.eventStream.start(
eq(PublishedWorkflowDefinitionIds.published("1")),
anyMap()
)).thenReturn(new SseEmitter());
try (MockedStatic<SaTokenUtil> login = login(fixture.account)) {
Result<Map<String, Object>> descriptor = fixture.controller.descriptor(
BigInteger.ONE,
request(Map.of())
);
fixture.controller.run(BigInteger.ONE, Map.of(), request(Map.of()));
Assert.assertEquals(descriptor.getData().get("shareable"), true);
}
verify(fixture.parameterResolver).buildRunningParametersView(published);
verify(fixture.eventStream).start(
eq(PublishedWorkflowDefinitionIds.published("1")),
anyMap()
);
}
/**
* 验证分享访问仍拒绝未发布工作流。
*/
@Test
public void shouldRejectDraftWorkflowFromShareRequest() {
ControllerFixture fixture = fixture(workflow(PublishStatus.DRAFT, "draft-content", false));
HttpServletRequest request = request(Map.of(
WorkflowSharePolicy.CHAT_SHARE_KEY_HEADER.toLowerCase(Locale.ROOT),
"share-key"
));
try (MockedStatic<SaTokenUtil> login = login(fixture.account)) {
Assert.expectThrows(
BusinessException.class,
() -> fixture.controller.descriptor(BigInteger.ONE, request)
);
}
verify(fixture.workflowShareService).assertChatShareAccess(
"share-key",
BigInteger.ONE,
BigInteger.ONE
);
verify(fixture.resourceAccessService, never()).assertAccess(
org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.anyString()
);
}
/**
* 创建使用同一当前视图和发布视图的测试夹具。
*
* @param current 当前工作流
* @return 控制器测试夹具
*/
private ControllerFixture fixture(Workflow current) {
return fixture(current, current);
}
/**
* 创建控制器测试夹具。
*
* @param current 当前工作流
* @param published 发布工作流视图
* @return 控制器测试夹具
*/
private ControllerFixture fixture(Workflow current, Workflow published) {
WorkflowService workflowService = mock(WorkflowService.class);
WorkflowShareService workflowShareService = mock(WorkflowShareService.class);
WorkflowCheckService workflowCheckService = mock(WorkflowCheckService.class);
WorkflowRunningParameterResolver parameterResolver =
mock(WorkflowRunningParameterResolver.class);
ResourceAccessService resourceAccessService = mock(ResourceAccessService.class);
WorkflowChatEventStream eventStream = mock(WorkflowChatEventStream.class);
WorkflowChatController controller = new WorkflowChatController();
setField(controller, "workflowService", workflowService);
setField(controller, "workflowShareService", workflowShareService);
setField(controller, "workflowCheckService", workflowCheckService);
setField(controller, "parameterResolver", parameterResolver);
setField(controller, "resourceAccessService", resourceAccessService);
setField(controller, "eventStream", eventStream);
setField(controller, "chainExecutor", mock(ChainExecutor.class));
setField(controller, "execResultService", mock(WorkflowExecResultService.class));
setField(controller, "execStepService", mock(WorkflowExecStepService.class));
when(workflowService.getById(BigInteger.ONE)).thenReturn(current);
when(workflowService.getPublishedById(BigInteger.ONE)).thenReturn(published);
LoginAccount account = new LoginAccount();
account.setId(BigInteger.ONE);
account.setTenantId(BigInteger.ONE);
return new ControllerFixture(
controller,
current,
workflowService,
workflowShareService,
parameterResolver,
resourceAccessService,
eventStream,
account
);
}
/**
* 创建工作流测试视图。
*
* @param publishStatus 发布状态
* @param content 工作流内容
* @param withSnapshot 是否包含发布快照
* @return 工作流测试视图
*/
private Workflow workflow(
PublishStatus publishStatus,
String content,
boolean withSnapshot
) {
Workflow workflow = new Workflow();
workflow.setId(BigInteger.ONE);
workflow.setContent(content);
workflow.setPublishStatus(publishStatus.getCode());
if (withSnapshot) {
workflow.setPublishedSnapshotJson(Map.of("content", content));
}
return workflow;
}
/**
* 创建登录账号静态模拟。
*
* @param account 登录账号
* @return 静态模拟句柄
*/
private MockedStatic<SaTokenUtil> login(LoginAccount account) {
MockedStatic<SaTokenUtil> login = mockStatic(SaTokenUtil.class);
login.when(SaTokenUtil::getLoginAccount).thenReturn(account);
return login;
}
/**
* 创建仅提供请求头能力的轻量 Servlet 请求代理。
*
* @param headers 小写请求头映射
* @return HTTP 请求代理
*/
private HttpServletRequest request(Map<String, String> headers) {
return (HttpServletRequest) java.lang.reflect.Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{HttpServletRequest.class},
(proxy, method, args) -> {
if ("getHeader".equals(method.getName())) {
String name = String.valueOf(args[0]).toLowerCase(Locale.ROOT);
return headers.get(name);
}
return defaultValue(method.getReturnType());
}
);
}
/**
* 通过反射设置控制器依赖。
*
* @param target 目标对象
* @param fieldName 字段名
* @param value 字段值
*/
private void setField(Object target, String fieldName, Object value) {
try {
Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("设置测试字段失败: " + fieldName, exception);
}
}
/**
* 返回代理方法所需的基础类型默认值。
*
* @param returnType 返回类型
* @return 默认值
*/
private Object defaultValue(Class<?> returnType) {
if (!returnType.isPrimitive()) {
return null;
}
if (boolean.class == returnType) {
return false;
}
if (char.class == returnType) {
return '\0';
}
if (byte.class == returnType) {
return (byte) 0;
}
if (short.class == returnType) {
return (short) 0;
}
if (int.class == returnType) {
return 0;
}
if (long.class == returnType) {
return 0L;
}
if (float.class == returnType) {
return 0F;
}
return 0D;
}
/**
* 控制器及其测试依赖夹具。
*
* @param controller 控制器
* @param current 当前工作流
* @param workflowService 工作流服务
* @param workflowShareService 工作流分享服务
* @param parameterResolver 参数解析器
* @param resourceAccessService 资源权限服务
* @param eventStream 事件流服务
* @param account 登录账号
*/
private record ControllerFixture(
WorkflowChatController controller,
Workflow current,
WorkflowService workflowService,
WorkflowShareService workflowShareService,
WorkflowRunningParameterResolver parameterResolver,
ResourceAccessService resourceAccessService,
WorkflowChatEventStream eventStream,
LoginAccount account
) {
}
}