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

@@ -18,7 +18,15 @@ public class LlmProviderImpl implements LlmProvider {
private static final Logger log = LoggerFactory.getLogger(LlmProviderImpl.class);
@Resource
private ModelService modelService;
@Resource
private WorkflowImageSourceResolver workflowImageSourceResolver;
/**
* 根据模型标识创建工作流聊天模型适配器。
*
* @param modelId 模型标识
* @return 工作流 LLM模型不存在时返回 {@code null}
*/
@Override
public Llm getChatModel(Object modelId) {
Model model = modelService.getModelInstance(new BigInteger(modelId.toString()));
@@ -28,6 +36,7 @@ public class LlmProviderImpl implements LlmProvider {
}
EasyAgentsLlm llm = new EasyAgentsLlm();
llm.setChatModel(model.toChatModel());
llm.setImageInputResolver(workflowImageSourceResolver);
return llm;
}
}

View File

@@ -0,0 +1,584 @@
package tech.easyflow.ai.easyagentsflow.llm;
import com.easyagents.core.util.ImageUtil;
import com.easyagents.flow.support.provider.ImageInputResolver;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.web.exceptions.BusinessException;
import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.stream.ImageInputStream;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.Inet4Address;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Base64;
import java.util.Iterator;
import java.util.Locale;
import java.util.Map;
/**
* 在模型调用前读取、校验并转换工作流图片输入。
*/
@Component
public class WorkflowImageSourceResolver implements ImageInputResolver {
static final long MAX_IMAGE_BYTES = 10L * 1024 * 1024;
static final long MAX_IMAGE_PIXELS = 40_000_000L;
private static final int MAX_REDIRECTS = 3;
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(8);
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(15);
private static final String DATA_URI_PREFIX = "data:image/";
private static final String BASE64_MARKER = ";base64,";
private final FileStorageService fileStorageService;
private final HttpClient httpClient;
/**
* 创建工作流图片解析器。
*
* @param fileStorageService 文件存储服务
*/
@Autowired
public WorkflowImageSourceResolver(
@Qualifier("default") FileStorageService fileStorageService) {
this(fileStorageService, HttpClient.newBuilder()
.connectTimeout(CONNECT_TIMEOUT)
.followRedirects(HttpClient.Redirect.NEVER)
.version(HttpClient.Version.HTTP_1_1)
.build());
}
/**
* 创建使用指定 HTTP 客户端的图片解析器,供隔离测试使用。
*
* @param fileStorageService 文件存储服务
* @param httpClient HTTP 客户端
*/
WorkflowImageSourceResolver(FileStorageService fileStorageService, HttpClient httpClient) {
this.fileStorageService = fileStorageService;
this.httpClient = httpClient;
}
/**
* 解析图片输入并返回带 MIME 的 Data URI。
*
* @param imageInput 图片 URL、文件或结构化图片描述
* @return 可供模型消费的 Data URI
* @throws BusinessException 图片不可读取、格式不支持或超过限制时抛出
*/
@Override
public String resolve(Object imageInput) {
if (imageInput instanceof File file) {
return process(readLocalFile(file));
}
if (imageInput instanceof String value) {
return resolveString(value);
}
if (imageInput instanceof Map<?, ?> imageMap) {
return resolveMap(imageMap);
}
throw new BusinessException("图片输入格式不受支持");
}
/**
* 解析字符串形式的旧版图片输入。
*
* @param value 图片 URL 或 Data URI
* @return 规范化 Data URI
*/
private String resolveString(String value) {
String normalized = trimToNull(value);
if (!StringUtils.hasText(normalized)) {
throw new BusinessException("图片输入不能为空");
}
if (normalized.startsWith(DATA_URI_PREFIX)) {
return process(decodeDataUri(normalized));
}
return process(download(normalized));
}
/**
* 解析结构化图片描述。
*
* @param imageMap 图片描述
* @return 规范化 Data URI
*/
private String resolveMap(Map<?, ?> imageMap) {
String sourceType = trimObjectToNull(imageMap.get("sourceType"));
String filePath = trimObjectToNull(imageMap.get("filePath"));
if (!StringUtils.hasText(sourceType)) {
sourceType = StringUtils.hasText(filePath) ? "upload" : "url";
}
if ("url".equals(sourceType)) {
return process(download(trimObjectToNull(imageMap.get("url"))));
}
if (!"upload".equals(sourceType) && !"resource".equals(sourceType)) {
throw new BusinessException("图片 sourceType 不受支持");
}
if (!StringUtils.hasText(filePath)) {
throw new BusinessException("图片缺少 filePath");
}
try (InputStream input = fileStorageService.readStream(filePath)) {
return process(readBounded(input));
} catch (BusinessException exception) {
throw exception;
} catch (Exception exception) {
throw new BusinessException(400, 1, "图片读取失败", exception);
}
}
/**
* 安全下载外部图片,并在每次重定向后重新校验目标地址。
*
* @param value 外部图片 URL
* @return 图片字节
*/
private byte[] download(String value) {
URI current = parseRemoteUri(value);
for (int redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount++) {
validateRemoteUri(current);
HttpRequest request = HttpRequest.newBuilder(current)
.timeout(REQUEST_TIMEOUT)
.header("Accept", "image/png,image/jpeg,image/webp,image/gif,image/bmp")
.GET()
.build();
try {
HttpResponse<InputStream> response =
httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
int status = response.statusCode();
if (status >= 300 && status < 400) {
closeQuietly(response.body());
if (redirectCount == MAX_REDIRECTS) {
throw new BusinessException("图片 URL 重定向次数超过限制");
}
String location = response.headers().firstValue("location")
.orElseThrow(() -> new BusinessException("图片 URL 重定向缺少目标地址"));
current = current.resolve(location);
continue;
}
if (status < 200 || status >= 300) {
closeQuietly(response.body());
throw new BusinessException("图片 URL 请求失败HTTP 状态码: " + status);
}
try (InputStream input = response.body()) {
return readBounded(input);
}
} catch (BusinessException exception) {
throw exception;
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new BusinessException(400, 1, "图片 URL 请求被中断", exception);
} catch (Exception exception) {
throw new BusinessException(400, 1, "图片 URL 请求失败", exception);
}
}
throw new BusinessException("图片 URL 请求失败");
}
/**
* 校验远程图片 URI阻止访问本机、内网和云元数据地址。
*
* @param uri 待访问 URI
* @throws BusinessException URI 不安全时抛出
*/
void validateRemoteUri(URI uri) {
if (uri == null
|| (!"http".equalsIgnoreCase(uri.getScheme())
&& !"https".equalsIgnoreCase(uri.getScheme()))) {
throw new BusinessException("图片 URL 仅支持 HTTP/HTTPS");
}
if (uri.getUserInfo() != null) {
throw new BusinessException("图片 URL 不能包含用户信息");
}
String host = trimToNull(uri.getHost());
if (!StringUtils.hasText(host)) {
throw new BusinessException("图片 URL 缺少有效主机");
}
String normalizedHost = host.toLowerCase(Locale.ROOT);
if ("localhost".equals(normalizedHost)
|| normalizedHost.endsWith(".localhost")
|| "metadata.google.internal".equals(normalizedHost)) {
throw new BusinessException("图片 URL 不能访问本机或云元数据地址");
}
try {
InetAddress[] addresses = InetAddress.getAllByName(host);
if (addresses.length == 0) {
throw new BusinessException("图片 URL 主机无法解析");
}
for (InetAddress address : addresses) {
if (!isPublicAddress(address)) {
throw new BusinessException("图片 URL 不能访问内网或保留地址");
}
}
} catch (BusinessException exception) {
throw exception;
} catch (Exception exception) {
throw new BusinessException(400, 1, "图片 URL 主机解析失败", exception);
}
}
/**
* 判断解析后的地址是否为允许访问的公网地址。
*
* @param address IP 地址
* @return 是否为公网地址
*/
private boolean isPublicAddress(InetAddress address) {
if (address.isAnyLocalAddress()
|| address.isLoopbackAddress()
|| address.isLinkLocalAddress()
|| address.isSiteLocalAddress()
|| address.isMulticastAddress()) {
return false;
}
byte[] bytes = address.getAddress();
if (address instanceof Inet4Address && bytes.length == 4) {
int first = bytes[0] & 0xff;
int second = bytes[1] & 0xff;
int third = bytes[2] & 0xff;
int fourth = bytes[3] & 0xff;
return first != 0
&& first != 10
&& first != 127
&& !(first == 168 && second == 63 && third == 129 && fourth == 16)
&& !(first == 169 && second == 254)
&& !(first == 100 && second >= 64 && second <= 127)
&& !(first == 172 && second >= 16 && second <= 31)
&& !(first == 192 && (second == 0 || second == 168))
&& !(first == 198 && (second == 18 || second == 19))
&& first < 224;
}
if (address instanceof Inet6Address && bytes.length == 16) {
int first = bytes[0] & 0xff;
return (first & 0xfe) != 0xfc;
}
return false;
}
/**
* 校验图片格式、尺寸并规范化不兼容格式。
*
* @param source 原始图片字节
* @return 带 MIME 的 Data URI
*/
private String process(byte[] source) {
if (source.length == 0) {
throw new BusinessException("图片内容为空");
}
ImageFormat format = detectFormat(source);
try {
Dimensions dimensions = format == ImageFormat.WEBP
? webpDimensions(source)
: imageIoDimensions(source);
validateDimensions(dimensions);
byte[] normalized = source;
String mimeType = format.mimeType;
if (format == ImageFormat.GIF || format == ImageFormat.BMP) {
BufferedImage image = ImageIO.read(new ByteArrayInputStream(source));
if (image == null) {
throw new BusinessException("图片内容无法解析");
}
ByteArrayOutputStream output = new ByteArrayOutputStream();
if (!ImageIO.write(image, "png", output)) {
throw new BusinessException("图片格式转换失败");
}
normalized = output.toByteArray();
mimeType = "image/png";
}
if (normalized.length > MAX_IMAGE_BYTES) {
throw new BusinessException("处理后的图片不能超过 10 MiB");
}
return ImageUtil.imageBytesToDataUri(normalized, mimeType);
} catch (BusinessException exception) {
throw exception;
} catch (Exception exception) {
throw new BusinessException(400, 1, "图片处理失败", exception);
}
}
/**
* 根据文件签名识别真实图片格式。
*
* @param bytes 图片字节
* @return 图片格式
*/
private ImageFormat detectFormat(byte[] bytes) {
if (bytes.length >= 8
&& bytes[0] == (byte) 0x89 && bytes[1] == 0x50
&& bytes[2] == 0x4e && bytes[3] == 0x47) {
return ImageFormat.PNG;
}
if (bytes.length >= 3
&& bytes[0] == (byte) 0xff && bytes[1] == (byte) 0xd8
&& bytes[2] == (byte) 0xff) {
return ImageFormat.JPEG;
}
if (bytes.length >= 6) {
String header = ascii(bytes, 0, 6);
if ("GIF87a".equals(header) || "GIF89a".equals(header)) {
return ImageFormat.GIF;
}
}
if (bytes.length >= 2 && bytes[0] == 'B' && bytes[1] == 'M') {
return ImageFormat.BMP;
}
if (bytes.length >= 12
&& "RIFF".equals(ascii(bytes, 0, 4))
&& "WEBP".equals(ascii(bytes, 8, 4))) {
return ImageFormat.WEBP;
}
throw new BusinessException("仅支持 PNG、JPG、JPEG、WebP、GIF、BMP 图片");
}
/**
* 使用 ImageIO 读取图片尺寸。
*
* @param bytes 图片字节
* @return 图片尺寸
* @throws IOException 无法读取图片时抛出
*/
private Dimensions imageIoDimensions(byte[] bytes) throws IOException {
try (ImageInputStream input =
ImageIO.createImageInputStream(new ByteArrayInputStream(bytes))) {
Iterator<ImageReader> readers = ImageIO.getImageReaders(input);
if (!readers.hasNext()) {
throw new BusinessException("图片内容无法解析");
}
ImageReader reader = readers.next();
try {
reader.setInput(input, true, true);
return new Dimensions(reader.getWidth(0), reader.getHeight(0));
} finally {
reader.dispose();
}
}
}
/**
* 读取 WebP 图片尺寸。
*
* @param bytes 图片字节
* @return 图片尺寸
*/
private Dimensions webpDimensions(byte[] bytes) {
int offset = 12;
while (offset + 8 <= bytes.length) {
String chunk = ascii(bytes, offset, 4);
long size = Integer.toUnsignedLong(littleEndianInt(bytes, offset + 4));
int data = offset + 8;
if (size > bytes.length - data) {
break;
}
int chunkSize = (int) size;
if ("VP8X".equals(chunk) && chunkSize >= 10) {
return new Dimensions(
1 + littleEndian24(bytes, data + 4),
1 + littleEndian24(bytes, data + 7));
}
if ("VP8 ".equals(chunk) && chunkSize >= 10
&& bytes[data + 3] == (byte) 0x9d
&& bytes[data + 4] == 0x01 && bytes[data + 5] == 0x2a) {
return new Dimensions(
littleEndian16(bytes, data + 6) & 0x3fff,
littleEndian16(bytes, data + 8) & 0x3fff);
}
if ("VP8L".equals(chunk) && chunkSize >= 5 && bytes[data] == 0x2f) {
int b1 = unsigned(bytes[data + 1]);
int b2 = unsigned(bytes[data + 2]);
int b3 = unsigned(bytes[data + 3]);
int b4 = unsigned(bytes[data + 4]);
return new Dimensions(
1 + ((b1 | b2 << 8) & 0x3fff),
1 + (((b2 >> 6) | b3 << 2 | b4 << 10) & 0x3fff));
}
long nextOffset = (long) data + chunkSize + (chunkSize & 1);
if (nextOffset > Integer.MAX_VALUE) {
break;
}
offset = (int) nextOffset;
}
throw new BusinessException("WebP 图片内容无法解析");
}
/**
* 校验图片像素数量。
*
* @param dimensions 图片尺寸
*/
private void validateDimensions(Dimensions dimensions) {
if (dimensions.width <= 0 || dimensions.height <= 0) {
throw new BusinessException("图片尺寸无效");
}
long pixels = (long) dimensions.width * dimensions.height;
if (pixels > MAX_IMAGE_PIXELS) {
throw new BusinessException("图片不能超过 4000 万像素");
}
}
/**
* 读取文件并应用大小上限。
*
* @param file 本地文件
* @return 文件字节
*/
private byte[] readLocalFile(File file) {
if (file == null || !file.isFile()) {
throw new BusinessException("图片文件不存在");
}
try (InputStream input = java.nio.file.Files.newInputStream(file.toPath())) {
return readBounded(input);
} catch (BusinessException exception) {
throw exception;
} catch (Exception exception) {
throw new BusinessException(400, 1, "图片文件读取失败", exception);
}
}
/**
* 流式读取图片,并拒绝超过 10 MiB 的内容。
*
* @param input 图片输入流
* @return 图片字节
* @throws IOException 读取失败时抛出
*/
private byte[] readBounded(InputStream input) throws IOException {
if (input == null) {
throw new BusinessException("图片读取失败");
}
ByteArrayOutputStream output = new ByteArrayOutputStream();
byte[] buffer = new byte[8192];
long total = 0L;
int read;
while ((read = input.read(buffer)) != -1) {
total += read;
if (total > MAX_IMAGE_BYTES) {
throw new BusinessException("单张图片不能超过 10 MiB");
}
output.write(buffer, 0, read);
}
return output.toByteArray();
}
/**
* 解码并校验图片 Data URI。
*
* @param dataUri 图片 Data URI
* @return 图片字节
*/
private byte[] decodeDataUri(String dataUri) {
int markerIndex = dataUri.indexOf(BASE64_MARKER);
if (markerIndex <= DATA_URI_PREFIX.length()) {
throw new BusinessException("图片 Data URI 格式不正确");
}
String encoded = dataUri.substring(markerIndex + BASE64_MARKER.length());
long maxEncodedLength = (MAX_IMAGE_BYTES + 2L) / 3L * 4L;
if (encoded.length() > maxEncodedLength + 2L) {
throw new BusinessException("单张图片不能超过 10 MiB");
}
try {
byte[] bytes = Base64.getDecoder().decode(encoded);
if (bytes.length > MAX_IMAGE_BYTES) {
throw new BusinessException("单张图片不能超过 10 MiB");
}
return bytes;
} catch (IllegalArgumentException exception) {
throw new BusinessException(400, 1, "图片 Data URI 编码无效", exception);
}
}
/**
* 解析远程 URI。
*
* @param value 原始 URL
* @return URI
*/
private URI parseRemoteUri(String value) {
if (!StringUtils.hasText(value)) {
throw new BusinessException("图片 URL 不能为空");
}
try {
return new URI(value.trim());
} catch (URISyntaxException exception) {
throw new BusinessException(400, 1, "图片 URL 格式不正确", exception);
}
}
private String trimObjectToNull(Object value) {
return trimToNull(value == null ? null : String.valueOf(value));
}
private String trimToNull(String value) {
return StringUtils.hasText(value) ? value.trim() : null;
}
private String ascii(byte[] bytes, int offset, int length) {
return new String(bytes, offset, length, StandardCharsets.US_ASCII);
}
private int littleEndian16(byte[] bytes, int offset) {
return unsigned(bytes[offset]) | unsigned(bytes[offset + 1]) << 8;
}
private int littleEndian24(byte[] bytes, int offset) {
return unsigned(bytes[offset])
| unsigned(bytes[offset + 1]) << 8
| unsigned(bytes[offset + 2]) << 16;
}
private int littleEndianInt(byte[] bytes, int offset) {
return unsigned(bytes[offset])
| unsigned(bytes[offset + 1]) << 8
| unsigned(bytes[offset + 2]) << 16
| unsigned(bytes[offset + 3]) << 24;
}
private int unsigned(byte value) {
return value & 0xff;
}
private void closeQuietly(InputStream input) {
if (input == null) {
return;
}
try {
input.close();
} catch (IOException ignored) {
// 响应已失败,关闭异常不覆盖原始业务错误。
}
}
private record Dimensions(int width, int height) {
}
private enum ImageFormat {
PNG("image/png"),
JPEG("image/jpeg"),
WEBP("image/webp"),
GIF("image/gif"),
BMP("image/bmp");
private final String mimeType;
ImageFormat(String mimeType) {
this.mimeType = mimeType;
}
}
}

View File

@@ -17,6 +17,7 @@ import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
@@ -37,6 +38,7 @@ public class WorkflowRunningParameterResolver {
private static final int FILE_MAX_COUNT = 10;
private static final long FILE_MAX_SINGLE_SIZE = 20L * 1024 * 1024;
private static final long FILE_MAX_TOTAL_SIZE = 50L * 1024 * 1024;
private static final long IMAGE_MAX_SINGLE_SIZE = 10L * 1024 * 1024;
@Resource
private ChainParser chainParser;
@@ -103,14 +105,15 @@ public class WorkflowRunningParameterResolver {
return normalized;
}
for (Parameter parameter : startParameters) {
if (!isFileParameter(parameter)) {
continue;
}
String name = trimToNull(parameter.getName());
if (!StringUtils.hasText(name) || !normalized.containsKey(name)) {
continue;
}
normalized.put(name, normalizeFileVariableValue(normalized.get(name), name));
if (isFileParameter(parameter)) {
normalized.put(name, normalizeFileVariableValue(normalized.get(name), name));
} else if (isImageParameter(parameter)) {
normalized.put(name, normalizeImageVariableValue(normalized.get(name), name));
}
}
return normalized;
}
@@ -146,13 +149,21 @@ public class WorkflowRunningParameterResolver {
List<Map<String, Object>> schema = new ArrayList<>();
Set<String> seenKeys = new LinkedHashSet<>();
boolean hasExplicitSchema = rawSchema != null;
Map<String, Parameter> parameterByName = new LinkedHashMap<>();
for (Parameter parameter : parameters) {
String parameterName = trimToNull(parameter == null ? null : parameter.getName());
if (StringUtils.hasText(parameterName)) {
parameterByName.put(parameterName, parameter);
}
}
boolean hasSystemParameter = parameters.stream().anyMatch(parameter ->
SYSTEM_START_PARAM_NAME.equals(trimToNull(parameter == null ? null : parameter.getName()))
);
if (rawSchema != null && !rawSchema.isEmpty()) {
for (int i = 0; i < rawSchema.size(); i++) {
JSONObject field = rawSchema.getJSONObject(i);
Map<String, Object> normalized = normalizeStartFormField(field, null);
String fieldKey = trimToNull(field == null ? null : field.getString("key"));
Map<String, Object> normalized = normalizeStartFormField(field, parameterByName.get(fieldKey));
if (normalized == null) {
continue;
}
@@ -202,6 +213,10 @@ public class WorkflowRunningParameterResolver {
boolean systemReserved = SYSTEM_START_PARAM_NAME.equals(key)
|| (field != null && Boolean.TRUE.equals(field.getBoolean("systemReserved")));
String type = resolveStartFormFieldType(field == null ? null : field.getString("type"), parameter, systemReserved);
String contentType = resolveStartFormContentType(field, parameter, type, systemReserved);
if ("file".equals(contentType)) {
type = "file";
}
List<String> options = resolveFieldOptions(field, parameter, type);
Map<String, Object> normalized = new LinkedHashMap<>();
@@ -212,6 +227,7 @@ public class WorkflowRunningParameterResolver {
SYSTEM_START_PARAM_NAME.equals(key) ? "用户问题" : key
));
normalized.put("type", type);
normalized.put("contentType", contentType);
normalized.put("required", systemReserved || (field != null && Boolean.TRUE.equals(field.getBoolean("required")))
|| (parameter != null && parameter.isRequired()));
normalized.put("placeholder", trimToDefault(
@@ -230,6 +246,40 @@ public class WorkflowRunningParameterResolver {
return normalized;
}
/**
* 解析开始表单字段的数据内容类型,并兼容旧版仅通过字段类型表达文件输入的配置。
*
* @param field 字段 Schema
* @param parameter 旧版参数定义
* @param fieldType 表单字段类型
* @param systemReserved 是否系统入口字段
* @return 归一化后的数据内容类型
*/
private String resolveStartFormContentType(JSONObject field,
Parameter parameter,
String fieldType,
boolean systemReserved) {
if (systemReserved) {
return "text";
}
if ("file".equals(fieldType)) {
return "file";
}
String requested = trimToNull(field == null ? null : field.getString("contentType"));
String parameterContentType = parameter == null ? null : trimToNull(parameter.getContentType());
if ("image".equals(parameterContentType)
&& (!StringUtils.hasText(requested) || "text".equals(requested))) {
return "image";
}
if (!StringUtils.hasText(requested)) {
requested = parameterContentType;
}
return switch (requested == null ? "" : requested) {
case "image", "video", "audio", "file", "other" -> requested;
default -> "text";
};
}
private Object resolveDefaultValue(JSONObject field, Parameter parameter, String type) {
Object rawDefaultValue = field == null ? null : field.get("defaultValue");
if (rawDefaultValue != null) {
@@ -329,6 +379,117 @@ public class WorkflowRunningParameterResolver {
|| "file".equalsIgnoreCase(trimToNull(String.valueOf(parameter.getDataType())));
}
/**
* 判断参数是否为图片输入参数。
*
* @param parameter 参数定义
* @return 是否图片参数
*/
private boolean isImageParameter(Parameter parameter) {
return parameter != null && "image".equals(trimToNull(parameter.getContentType()));
}
/**
* 将图片运行值归一化为单图描述对象。
*
* @param value 原始图片值
* @param parameterName 参数名
* @return 归一化后的图片描述;空值返回 {@code null}
*/
private Object normalizeImageVariableValue(Object value, String parameterName) {
if (value == null
|| (value instanceof String stringValue && !StringUtils.hasText(stringValue))) {
return null;
}
if (value instanceof Collection<?>) {
throw new BusinessException("图片参数 " + parameterName + " 仅支持单张图片");
}
if (value instanceof String stringValue) {
String normalized = stringValue.trim();
if (isHttpUrl(normalized)) {
Map<String, Object> image = new LinkedHashMap<>();
image.put("sourceType", "url");
image.put("url", normalized);
return image;
}
throw new BusinessException("图片参数 " + parameterName + " 仅支持 HTTP/HTTPS 图片 URL");
}
if (!(value instanceof Map<?, ?> imageMap)) {
throw new BusinessException("图片参数 " + parameterName + " 的输入格式不正确");
}
String sourceType = trimObjectToNull(imageMap.get("sourceType"));
String filePath = trimObjectToNull(imageMap.get("filePath"));
String url = trimObjectToNull(imageMap.get("url"));
// 兼容旧版没有 sourceType 的文件对象和 URL 对象。
if (!StringUtils.hasText(sourceType)) {
sourceType = StringUtils.hasText(filePath) ? "upload" : "url";
}
if ("url".equals(sourceType)) {
if (!isHttpUrl(url)) {
throw new BusinessException("图片参数 " + parameterName + " 缺少有效的 HTTP/HTTPS URL");
}
Map<String, Object> normalized = new LinkedHashMap<>();
normalized.put("sourceType", "url");
normalized.put("url", url);
return normalized;
}
if (!"upload".equals(sourceType) && !"resource".equals(sourceType)) {
throw new BusinessException("图片参数 " + parameterName + " 的 sourceType 不受支持");
}
String fileName = trimObjectToNull(imageMap.get("fileName"));
if (!StringUtils.hasText(fileName)) {
throw new BusinessException("图片参数 " + parameterName + " 缺少 fileName");
}
if (!StringUtils.hasText(filePath)) {
throw new BusinessException("图片参数 " + parameterName + " 缺少 filePath");
}
Long size = parseLong(imageMap.get("size"));
if (size != null && size > IMAGE_MAX_SINGLE_SIZE) {
throw new BusinessException("图片参数 " + parameterName + " 中图片不能超过 10 MiB");
}
Map<String, Object> normalized = new LinkedHashMap<>();
normalized.put("sourceType", sourceType);
normalized.put("fileName", fileName);
normalized.put("filePath", filePath);
copyOptionalImageField(imageMap, normalized, "contentType");
if (size != null) {
normalized.put("size", size);
}
copyOptionalImageField(imageMap, normalized, "url");
return normalized;
}
/**
* 复制图片描述中的可选非空字段。
*
* @param source 原始图片描述
* @param target 归一化图片描述
* @param key 字段名
*/
private void copyOptionalImageField(Map<?, ?> source, Map<String, Object> target, String key) {
String value = trimObjectToNull(source.get(key));
if (StringUtils.hasText(value)) {
target.put(key, value);
}
}
/**
* 判断字符串是否为 HTTP 或 HTTPS URL。
*
* @param value 待判断值
* @return 是否为受支持 URL
*/
private boolean isHttpUrl(String value) {
if (!StringUtils.hasText(value)) {
return false;
}
String lowerValue = value.toLowerCase(Locale.ROOT);
return lowerValue.startsWith("http://") || lowerValue.startsWith("https://");
}
/**
* 将单文件或多文件运行值归一化为文件对象数组。
*

View File

@@ -23,6 +23,9 @@ public class WorkflowShare implements Serializable {
@Column(comment = "工作流ID")
private BigInteger workflowId;
@Column(comment = "分享用途")
private String sharePurpose;
@Column(comment = "分享密钥哈希")
private String shareKeyHash;
@@ -86,6 +89,24 @@ public class WorkflowShare implements Serializable {
this.workflowId = workflowId;
}
/**
* 获取分享用途。
*
* @return 分享用途
*/
public String getSharePurpose() {
return sharePurpose;
}
/**
* 设置分享用途。
*
* @param sharePurpose 分享用途
*/
public void setSharePurpose(String sharePurpose) {
this.sharePurpose = sharePurpose;
}
/**
* 获取分享密钥哈希。
*

View File

@@ -0,0 +1,17 @@
package tech.easyflow.ai.enums;
/**
* 工作流分享用途。
*/
public enum WorkflowSharePurpose {
/**
* 历史协作编辑分享。
*/
COLLABORATION,
/**
* 已发布工作流对话运行分享。
*/
CHAT
}

View File

@@ -29,6 +29,24 @@ public interface WorkflowShareService extends IService<WorkflowShare> {
String baseUrl
);
/**
* 创建或刷新工作流的唯一对话分享链接。
*
* @param workflowId 工作流 ID
* @param tenantId 租户 ID
* @param deptId 部门 ID
* @param operatorId 操作人账号 ID
* @param baseUrl 工作流对话分享基础 URL
* @return 创建结果
*/
WorkflowShareCreateResult createChatShare(
BigInteger workflowId,
BigInteger tenantId,
BigInteger deptId,
BigInteger operatorId,
String baseUrl
);
/**
* 校验分享密钥是否可访问指定工作流。
*
@@ -51,4 +69,27 @@ public interface WorkflowShareService extends IService<WorkflowShare> {
* @return 有效分享记录
*/
WorkflowShare resolveUrlShare(String shareKey, BigInteger tenantId);
/**
* 校验对话分享密钥是否可访问指定工作流。
*
* @param shareKey 原始分享密钥
* @param workflowId 工作流 ID
* @param tenantId 当前登录租户 ID
* @return 有效对话分享记录
*/
WorkflowShare assertChatShareAccess(
String shareKey,
BigInteger workflowId,
BigInteger tenantId
);
/**
* 校验对话分享密钥并解析目标工作流。
*
* @param shareKey 原始分享密钥
* @param tenantId 当前登录租户 ID
* @return 有效对话分享记录
*/
WorkflowShare resolveChatShare(String shareKey, BigInteger tenantId);
}

View File

@@ -8,6 +8,8 @@ import org.springframework.transaction.support.TransactionTemplate;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.entity.WorkflowShare;
import tech.easyflow.ai.enums.KnowledgeShareStatus;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.enums.WorkflowSharePurpose;
import tech.easyflow.ai.mapper.WorkflowShareMapper;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.service.WorkflowShareService;
@@ -53,9 +55,63 @@ public class WorkflowShareServiceImpl extends ServiceImpl<WorkflowShareMapper, W
BigInteger deptId,
BigInteger operatorId,
String baseUrl
) {
return createShare(
workflowId,
tenantId,
deptId,
operatorId,
baseUrl,
WorkflowSharePurpose.COLLABORATION,
false
);
}
/**
* {@inheritDoc}
*/
@Override
public WorkflowShareCreateResult createChatShare(
BigInteger workflowId,
BigInteger tenantId,
BigInteger deptId,
BigInteger operatorId,
String baseUrl
) {
return createShare(
workflowId,
tenantId,
deptId,
operatorId,
baseUrl,
WorkflowSharePurpose.CHAT,
true
);
}
/**
* 在用途级分布式锁内创建分享。
*
* @param workflowId 工作流 ID
* @param tenantId 租户 ID
* @param deptId 部门 ID
* @param operatorId 操作人账号 ID
* @param baseUrl 分享基础 URL
* @param purpose 分享用途
* @param requirePublished 是否要求严格发布态
* @return 创建结果
*/
private WorkflowShareCreateResult createShare(
BigInteger workflowId,
BigInteger tenantId,
BigInteger deptId,
BigInteger operatorId,
String baseUrl,
WorkflowSharePurpose purpose,
boolean requirePublished
) {
return redisLockExecutor.executeWithLock(
LOCK_KEY_PREFIX + workflowId,
LOCK_KEY_PREFIX + workflowId + ":" + purpose.name(),
LOCK_WAIT_TIMEOUT,
LOCK_LEASE_TIMEOUT,
() -> {
@@ -66,7 +122,9 @@ public class WorkflowShareServiceImpl extends ServiceImpl<WorkflowShareMapper, W
tenantId,
deptId,
operatorId,
baseUrl
baseUrl,
purpose,
requirePublished
));
}
);
@@ -96,11 +154,55 @@ public class WorkflowShareServiceImpl extends ServiceImpl<WorkflowShareMapper, W
*/
@Override
public WorkflowShare resolveUrlShare(String shareKey, BigInteger tenantId) {
return resolveShare(shareKey, tenantId, WorkflowSharePurpose.COLLABORATION);
}
/**
* {@inheritDoc}
*/
@Override
public WorkflowShare assertChatShareAccess(
String shareKey,
BigInteger workflowId,
BigInteger tenantId
) {
if (workflowId == null) {
throw invalidShare();
}
WorkflowShare share = resolveChatShare(shareKey, tenantId);
if (!workflowId.equals(share.getWorkflowId())) {
throw invalidShare();
}
return share;
}
/**
* {@inheritDoc}
*/
@Override
public WorkflowShare resolveChatShare(String shareKey, BigInteger tenantId) {
return resolveShare(shareKey, tenantId, WorkflowSharePurpose.CHAT);
}
/**
* 按用途校验并解析分享。
*
* @param shareKey 原始分享密钥
* @param tenantId 当前租户 ID
* @param purpose 分享用途
* @return 有效分享记录
*/
private WorkflowShare resolveShare(
String shareKey,
BigInteger tenantId,
WorkflowSharePurpose purpose
) {
if (shareKey == null || shareKey.isBlank() || tenantId == null) {
throw invalidShare();
}
WorkflowShare share = getOne(QueryWrapper.create()
.eq(WorkflowShare::getShareKeyHash, WorkflowSharePolicy.hashShareKey(shareKey))
.eq(WorkflowShare::getSharePurpose, purpose.name())
.eq(WorkflowShare::getStatus, KnowledgeShareStatus.ENABLED.name()));
if (share == null || !tenantId.equals(share.getTenantId())) {
throw invalidShare();
@@ -112,6 +214,9 @@ public class WorkflowShareServiceImpl extends ServiceImpl<WorkflowShareMapper, W
if (workflow == null || !tenantId.equals(workflow.getTenantId())) {
throw invalidShare();
}
if (purpose == WorkflowSharePurpose.CHAT && !isStrictlyPublished(workflow)) {
throw new BusinessException(409, 409, "工作流尚未发布或已下线");
}
return share;
}
@@ -123,6 +228,8 @@ public class WorkflowShareServiceImpl extends ServiceImpl<WorkflowShareMapper, W
* @param deptId 部门 ID
* @param operatorId 操作人账号 ID
* @param baseUrl 工作流分享基础 URL
* @param purpose 分享用途
* @param requirePublished 是否要求严格发布态
* @return 创建结果
*/
private WorkflowShareCreateResult createOrReplaceShare(
@@ -130,19 +237,27 @@ public class WorkflowShareServiceImpl extends ServiceImpl<WorkflowShareMapper, W
BigInteger tenantId,
BigInteger deptId,
BigInteger operatorId,
String baseUrl
String baseUrl,
WorkflowSharePurpose purpose,
boolean requirePublished
) {
Workflow workflow = workflowService.getById(workflowId);
if (workflow == null || tenantId == null || !tenantId.equals(workflow.getTenantId())) {
throw new BusinessException("工作流不存在");
}
if (requirePublished && !isStrictlyPublished(workflow)) {
throw new BusinessException(409, 409, "仅已发布工作流可创建对话分享");
}
String shareKey = UUID.randomUUID().toString().replace("-", "");
Date now = new Date();
Date expiresAt = WorkflowSharePolicy.defaultExpiresAt(now);
invalidateExistingShares(workflowId, operatorId, now);
Date expiresAt = purpose == WorkflowSharePurpose.CHAT
? WorkflowSharePolicy.defaultChatExpiresAt(now)
: WorkflowSharePolicy.defaultExpiresAt(now);
invalidateExistingShares(workflowId, purpose, operatorId, now);
WorkflowShare share = new WorkflowShare();
share.setWorkflowId(workflowId);
share.setSharePurpose(purpose.name());
share.setTenantId(tenantId);
share.setDeptId(deptId);
share.setShareKeyHash(WorkflowSharePolicy.hashShareKey(shareKey));
@@ -166,12 +281,19 @@ public class WorkflowShareServiceImpl extends ServiceImpl<WorkflowShareMapper, W
* 使工作流已有的有效分享记录失效。
*
* @param workflowId 工作流 ID
* @param purpose 分享用途
* @param operatorId 操作人账号 ID
* @param now 当前时间
*/
private void invalidateExistingShares(BigInteger workflowId, BigInteger operatorId, Date now) {
private void invalidateExistingShares(
BigInteger workflowId,
WorkflowSharePurpose purpose,
BigInteger operatorId,
Date now
) {
List<WorkflowShare> activeShares = list(QueryWrapper.create()
.eq(WorkflowShare::getWorkflowId, workflowId)
.eq(WorkflowShare::getSharePurpose, purpose.name())
.eq(WorkflowShare::getStatus, KnowledgeShareStatus.ENABLED.name()));
for (WorkflowShare activeShare : activeShares) {
WorkflowShare update = new WorkflowShare();
@@ -205,4 +327,17 @@ public class WorkflowShareServiceImpl extends ServiceImpl<WorkflowShareMapper, W
private BusinessException invalidShare() {
return new BusinessException(403, 403, "工作流分享链接无效");
}
/**
* 判断工作流是否处于严格发布态且存在发布快照。
*
* @param workflow 工作流
* @return 可按发布快照运行时返回 {@code true}
*/
private boolean isStrictlyPublished(Workflow workflow) {
return workflow != null
&& PublishStatus.PUBLISHED.getCode().equals(workflow.getPublishStatus())
&& workflow.getPublishedSnapshotJson() != null
&& !workflow.getPublishedSnapshotJson().isEmpty();
}
}

View File

@@ -20,7 +20,13 @@ public final class WorkflowSharePolicy {
*/
public static final String SHARE_KEY_HEADER = "X-Workflow-Share-Key";
/**
* 工作流对话分享请求头。
*/
public static final String CHAT_SHARE_KEY_HEADER = "X-Workflow-Chat-Share-Key";
private static final Duration DEFAULT_EXPIRE_DURATION = Duration.ofMinutes(30);
private static final Duration DEFAULT_CHAT_EXPIRE_DURATION = Duration.ofDays(7);
private static final Set<String> ALLOWED_REQUESTS = Set.of(
permissionKey("GET", "/api/v1/workflow/detail", ResourceAction.READ),
permissionKey("GET", "/api/v1/workflow/getRunningParameters", ResourceAction.READ),
@@ -66,6 +72,19 @@ public final class WorkflowSharePolicy {
return new Date(createdAt.getTime() + DEFAULT_EXPIRE_DURATION.toMillis());
}
/**
* 计算对话分享默认过期时间。
*
* @param createdAt 创建时间
* @return 创建后 7 天的时间
*/
public static Date defaultChatExpiresAt(Date createdAt) {
if (createdAt == null) {
throw new IllegalArgumentException("创建时间不能为空");
}
return new Date(createdAt.getTime() + DEFAULT_CHAT_EXPIRE_DURATION.toMillis());
}
/**
* 判断 HTTP 请求是否位于工作流协作授权白名单。
*

View File

@@ -13,6 +13,10 @@ public class WorkFlowUtil {
public final static String USER_KEY = "user";
public final static String API_KEY = "API_KEY";
/** 管理端工作流对话执行来源。 */
public final static String WORKFLOW_CHAT = "WORKFLOW_CHAT";
/** 工作流对话分享执行来源。 */
public final static String WORKFLOW_CHAT_SHARE = "WORKFLOW_CHAT_SHARE";
public final static String WORKFLOW_KEY = "workflow";
public final static String CREATED_KEY_MEMORY_KEY = "workflowCreatedKey";

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()
);
}
/**
* 验证分享授权仅覆盖编辑、运行和发布所需接口。
*/