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,156 @@
package tech.easyflow.ai.easyagentsflow.llm;
import org.junit.Assert;
import org.junit.Test;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.web.exceptions.BusinessException;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.util.Map;
/**
* 工作流图片源解析器测试。
*/
public class WorkflowImageSourceResolverTest {
/**
* 验证存储中的 PNG 图片转换为完整 Data URI。
*
* @throws Exception 图片构造失败
*/
@Test
public void shouldResolveStoredPngToDataUri() throws Exception {
byte[] png = imageBytes("png");
WorkflowImageSourceResolver resolver = resolver(Map.of("/images/a.png", png));
String dataUri = resolver.resolve(Map.of(
"sourceType", "upload",
"fileName", "a.png",
"filePath", "/images/a.png"));
Assert.assertTrue(dataUri.startsWith("data:image/png;base64,"));
Assert.assertArrayEquals(
png,
java.util.Base64.getDecoder().decode(dataUri.substring(dataUri.indexOf(',') + 1)));
}
/**
* 验证 BMP 图片会在模型调用前规范化为 PNG。
*
* @throws Exception 图片构造失败
*/
@Test
public void shouldNormalizeBmpToPng() throws Exception {
WorkflowImageSourceResolver resolver =
resolver(Map.of("/images/a.bmp", imageBytes("bmp")));
String dataUri = resolver.resolve(Map.of(
"sourceType", "resource",
"fileName", "a.bmp",
"filePath", "/images/a.bmp"));
Assert.assertTrue(dataUri.startsWith("data:image/png;base64,"));
}
/**
* 验证旧版 Data URI 会经过真实图片校验后继续使用。
*
* @throws Exception 图片构造失败
*/
@Test
public void shouldValidateLegacyDataUri() throws Exception {
byte[] png = imageBytes("png");
String input = "data:image/png;base64,"
+ java.util.Base64.getEncoder().encodeToString(png);
String dataUri = resolver(Map.of()).resolve(input);
Assert.assertTrue(dataUri.startsWith("data:image/png;base64,"));
}
/**
* 验证本机、内网和云元数据地址会被拦截。
*/
@Test
public void shouldRejectUnsafeRemoteAddresses() {
WorkflowImageSourceResolver resolver = resolver(Map.of());
assertUnsafe(resolver, "http://127.0.0.1/image.png");
assertUnsafe(resolver, "http://192.168.1.2/image.png");
assertUnsafe(resolver, "http://168.63.129.16/metadata/instance");
assertUnsafe(resolver, "http://169.254.169.254/latest/meta-data");
assertUnsafe(resolver, "http://metadata.google.internal/image.png");
}
private static void assertUnsafe(WorkflowImageSourceResolver resolver, String value) {
try {
resolver.validateRemoteUri(URI.create(value));
Assert.fail("expected BusinessException for " + value);
} catch (BusinessException expected) {
Assert.assertTrue(expected.getMessage().contains("不能访问"));
}
}
private static WorkflowImageSourceResolver resolver(Map<String, byte[]> files) {
return new WorkflowImageSourceResolver(
new InMemoryStorage(files),
HttpClient.newHttpClient());
}
private static byte[] imageBytes(String format) throws Exception {
BufferedImage image = new BufferedImage(2, 2, BufferedImage.TYPE_INT_RGB);
ByteArrayOutputStream output = new ByteArrayOutputStream();
Assert.assertTrue(ImageIO.write(image, format, output));
return output.toByteArray();
}
/**
* 测试用内存文件存储。
*/
private static final class InMemoryStorage implements FileStorageService {
private final Map<String, byte[]> files;
private InMemoryStorage(Map<String, byte[]> files) {
this.files = files;
}
@Override
public String save(MultipartFile file) {
throw new UnsupportedOperationException();
}
@Override
public void delete(String path) {
throw new UnsupportedOperationException();
}
@Override
public String save(File file, String prePath) {
throw new UnsupportedOperationException();
}
@Override
public InputStream readStream(String path) {
byte[] bytes = files.get(path);
if (bytes == null) {
throw new IllegalArgumentException("missing file: " + path);
}
return new ByteArrayInputStream(bytes);
}
@Override
public long getFileSize(String path) {
byte[] bytes = files.get(path);
return bytes == null ? 0 : bytes.length;
}
}
}

View File

@@ -43,16 +43,34 @@ public class WorkflowRunningParameterResolverTest {
fileField.put("key", "attachments");
fileField.put("label", "附件");
fileField.put("type", "file");
fileField.put("contentType", "file");
fileField.put("required", false);
schema.add(fileField);
JSONObject imageField = new JSONObject();
imageField.put("key", "preview_image");
imageField.put("label", "预览图");
imageField.put("type", "text");
imageField.put("contentType", "text");
imageField.put("required", false);
schema.add(imageField);
JSONObject meta = new JSONObject();
meta.put("title", "问答入口");
meta.put("description", "请先填写信息");
meta.put("submitText", "立即开始");
startData.put("startFormMeta", meta);
startData.put("startFormSchema", schema);
startData.put("parameters", startParameters());
JSONArray parameters = startParameters();
JSONObject imageParameter = new JSONObject();
imageParameter.put("name", "preview_image");
imageParameter.put("dataType", "Object");
imageParameter.put("refType", "input");
imageParameter.put("contentType", "image");
imageParameter.put("formType", "input");
imageParameter.put("formLabel", "预览图");
parameters.add(imageParameter);
startData.put("parameters", parameters);
Workflow workflow = workflow(
workflowJson(
@@ -68,11 +86,16 @@ public class WorkflowRunningParameterResolverTest {
Assert.assertNotNull(result);
Assert.assertEquals("问答入口", ((Map<?, ?>) result.get("startFormMeta")).get("title"));
List<Map<String, Object>> fields = (List<Map<String, Object>>) result.get("startFormSchema");
Assert.assertEquals(2, fields.size());
Assert.assertEquals(3, fields.size());
Assert.assertEquals("user_input", fields.get(0).get("key"));
Assert.assertEquals("text", fields.get(0).get("type"));
Assert.assertEquals("text", fields.get(0).get("contentType"));
Assert.assertEquals("attachments", fields.get(1).get("key"));
Assert.assertEquals("file", fields.get(1).get("type"));
Assert.assertEquals("file", fields.get(1).get("contentType"));
Assert.assertEquals("preview_image", fields.get(2).get("key"));
Assert.assertEquals("text", fields.get(2).get("type"));
Assert.assertEquals("image", fields.get(2).get("contentType"));
}
/**
@@ -213,6 +236,77 @@ public class WorkflowRunningParameterResolverTest {
}
}
/**
* 旧版图片 URL 应归一化为 URL 图片描述。
*
* @throws Exception 反射注入失败
*/
@Test
public void testNormalizeRuntimeVariablesShouldNormalizeLegacyImageUrl() throws Exception {
WorkflowRunningParameterResolver resolver = newResolver();
Map<String, Object> variables = new LinkedHashMap<>();
variables.put("image_input", "https://example.com/image.png");
Map<String, Object> normalized = resolver.normalizeRuntimeVariables(
workflowContentWithImageStartParameter(),
variables
);
Assert.assertTrue(normalized.get("image_input") instanceof Map<?, ?>);
Map<?, ?> image = (Map<?, ?>) normalized.get("image_input");
Assert.assertEquals("url", image.get("sourceType"));
Assert.assertEquals("https://example.com/image.png", image.get("url"));
}
/**
* 运行入口不应接收 Data URI避免 Base64 写入工作流状态和审计参数。
*
* @throws Exception 反射注入失败
*/
@Test
public void testNormalizeRuntimeVariablesShouldRejectImageDataUri() throws Exception {
WorkflowRunningParameterResolver resolver = newResolver();
Map<String, Object> variables = new LinkedHashMap<>();
variables.put("image_input", "data:image/png;base64,AQID");
try {
resolver.normalizeRuntimeVariables(workflowContentWithImageStartParameter(), variables);
Assert.fail("expected BusinessException");
} catch (BusinessException exception) {
Assert.assertEquals(
"图片参数 image_input 仅支持 HTTP/HTTPS 图片 URL",
exception.getMessage());
}
}
/**
* 图片参数应允许 10 MiB 边界并拒绝更大的声明值。
*
* @throws Exception 反射注入失败
*/
@Test
public void testNormalizeRuntimeVariablesShouldEnforceImageLimit() throws Exception {
WorkflowRunningParameterResolver resolver = newResolver();
Map<String, Object> variables = new LinkedHashMap<>();
variables.put("image_input", imageValue(10L * 1024L * 1024L));
Map<String, Object> normalized = resolver.normalizeRuntimeVariables(
workflowContentWithImageStartParameter(),
variables
);
Assert.assertEquals("upload", ((Map<?, ?>) normalized.get("image_input")).get("sourceType"));
variables.put("image_input", imageValue(10L * 1024L * 1024L + 1L));
try {
resolver.normalizeRuntimeVariables(workflowContentWithImageStartParameter(), variables);
Assert.fail("expected BusinessException");
} catch (BusinessException exception) {
Assert.assertEquals(
"图片参数 image_input 中图片不能超过 10 MiB",
exception.getMessage());
}
}
private static WorkflowRunningParameterResolver newResolver() throws Exception {
WorkflowRunningParameterResolver resolver = new WorkflowRunningParameterResolver();
ChainParser parser = ChainParser.builder()
@@ -247,6 +341,26 @@ public class WorkflowRunningParameterResolverTest {
);
}
private static String workflowContentWithImageStartParameter() {
JSONObject startData = data("开始");
JSONArray parameters = new JSONArray();
JSONObject imageField = new JSONObject();
imageField.put("name", "image_input");
imageField.put("dataType", "Object");
imageField.put("refType", "input");
imageField.put("contentType", "image");
imageField.put("formType", "input");
parameters.add(imageField);
startData.put("parameters", parameters);
return workflowJson(
array(
node("s1", "startNode", null, startData),
node("e1", "endNode", null, data("结束"))
),
array(edge("e1", "s1", "e1"))
);
}
private static JSONArray startParameters() {
JSONArray parameters = new JSONArray();
@@ -298,6 +412,16 @@ public class WorkflowRunningParameterResolverTest {
return value;
}
private static Map<String, Object> imageValue(long size) {
Map<String, Object> value = new LinkedHashMap<>();
value.put("sourceType", "upload");
value.put("fileName", "image.png");
value.put("filePath", "/files/image.png");
value.put("size", size);
value.put("contentType", "image/png");
return value;
}
private static void setField(Object target, String fieldName, Object value) throws Exception {
Field field = WorkflowRunningParameterResolver.class.getDeclaredField(fieldName);
field.setAccessible(true);

View File

@@ -20,7 +20,9 @@ public class WorkflowShareMigrationContractTest {
*/
@Test
public void migrationShouldCreateWorkflowShareContracts() throws Exception {
String sql = migrationSql();
String sql = migrationSql(
"V34__mysql_workflow_share_and_approval_reason.sql"
);
assertTrue(sql.contains("ADD COLUMN `application_reason` VARCHAR(500)"));
assertTrue(sql.contains("ADD COLUMN `revision` INT NOT NULL DEFAULT 0"));
@@ -34,22 +36,41 @@ public class WorkflowShareMigrationContractTest {
}
/**
* 读取工作区中的 V34 MySQL 迁移
* 验证 V39 为协作分享和对话分享建立用途隔离
*
* @throws Exception 迁移文件不可读时抛出
*/
@Test
public void migrationShouldSeparateChatSharePurpose() throws Exception {
String sql = migrationSql("V39__mysql_workflow_chat_share.sql");
assertTrue(sql.contains(
"ADD COLUMN `share_purpose` VARCHAR(32) NOT NULL DEFAULT 'COLLABORATION'"
));
assertTrue(sql.contains("`idx_workflow_share_purpose_status`"));
assertTrue(sql.contains(
"(`workflow_id`, `share_purpose`, `status`)"
));
}
/**
* 读取工作区中的指定 MySQL 迁移。
*
* @param fileName 迁移文件名
* @return 迁移 SQL
* @throws Exception 迁移文件不存在或不可读时抛出
*/
private String migrationSql() throws Exception {
private String migrationSql(String fileName) throws Exception {
Path root = Path.of(System.getProperty("maven.multiModuleProjectDirectory",
Path.of(System.getProperty("user.dir")).toAbsolutePath().toString()));
while (root != null) {
Path migration = root.resolve("easyflow-starter/easyflow-starter-all/src/main/resources/"
+ "db/migration/mysql/V34__mysql_workflow_share_and_approval_reason.sql");
+ "db/migration/mysql/" + fileName);
if (Files.isRegularFile(migration)) {
return Files.readString(migration, StandardCharsets.UTF_8);
}
root = root.getParent();
}
throw new IllegalStateException("找不到 V34 工作流分享与审批说明迁移");
throw new IllegalStateException("找不到工作流分享迁移: " + fileName);
}
}

View File

@@ -36,6 +36,21 @@ public class WorkflowSharePolicyTest {
Assert.assertEquals(30 * 60 * 1_000L, expiresAt.getTime() - createdAt.getTime());
}
/**
* 验证对话分享默认在创建七天后过期。
*/
@Test
public void shouldExpireChatShareSevenDaysAfterCreation() {
Date createdAt = new Date(1_000L);
Date expiresAt = WorkflowSharePolicy.defaultChatExpiresAt(createdAt);
Assert.assertEquals(
7L * 24L * 60L * 60L * 1_000L,
expiresAt.getTime() - createdAt.getTime()
);
}
/**
* 验证分享授权仅覆盖编辑、运行和发布所需接口。
*/