feat(XL13): 归档工作流对话运行界面
- 接入发布快照优先与未发布草稿受控运行 - 支持文本和思考流式输出、循环多输出及实时运行详情 - 完成聊天分享、图片输入、中止与清空重来 - 补充后端与前端定向回归测试
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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://");
|
||||
}
|
||||
|
||||
/**
|
||||
* 将单文件或多文件运行值归一化为文件对象数组。
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分享密钥哈希。
|
||||
*
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package tech.easyflow.ai.enums;
|
||||
|
||||
/**
|
||||
* 工作流分享用途。
|
||||
*/
|
||||
public enum WorkflowSharePurpose {
|
||||
|
||||
/**
|
||||
* 历史协作编辑分享。
|
||||
*/
|
||||
COLLABORATION,
|
||||
|
||||
/**
|
||||
* 已发布工作流对话运行分享。
|
||||
*/
|
||||
CHAT
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 请求是否位于工作流协作授权白名单。
|
||||
*
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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