feat(XL13): 归档工作流对话运行界面
- 接入发布快照优先与未发布草稿受控运行 - 支持文本和思考流式输出、循环多输出及实时运行详情 - 完成聊天分享、图片输入、中止与清空重来 - 补充后端与前端定向回归测试
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证分享授权仅覆盖编辑、运行和发布所需接口。
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user