fix: 修复工作流公共 API 调用问题
- 限制远程文档仅访问公网地址并校验重定向目标 - 统一访问令牌 401/403 与过期执行状态 404 语义 - 校正节点查询参数和工作流状态文档
This commit is contained in:
@@ -25,8 +25,9 @@ public class PublicApiInterceptor implements HandlerInterceptor {
|
|||||||
String requestURI = request.getRequestURI();
|
String requestURI = request.getRequestURI();
|
||||||
String apiKey = request.getHeader("ApiKey");
|
String apiKey = request.getHeader("ApiKey");
|
||||||
|
|
||||||
if (apiKey == null || apiKey.isEmpty()) {
|
if (apiKey == null || apiKey.isBlank()) {
|
||||||
Result<Void> failed = Result.fail(401, "密钥不正确");
|
Result<Void> failed = Result.fail(401, "密钥不正确");
|
||||||
|
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||||
ResponseUtil.renderJson(response, failed);
|
ResponseUtil.renderJson(response, failed);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package tech.easyflow.publicapi.interceptor;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import java.io.PrintWriter;
|
||||||
|
import java.io.StringWriter;
|
||||||
|
import java.lang.reflect.Proxy;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link PublicApiInterceptor} 鉴权响应测试。
|
||||||
|
*/
|
||||||
|
public class PublicApiInterceptorTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证缺少访问令牌时返回真实 HTTP 401 和统一错误体。
|
||||||
|
*
|
||||||
|
* @throws Exception 拦截器处理失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldReturnUnauthorizedWhenApiKeyMissing() throws Exception {
|
||||||
|
StringWriter body = new StringWriter();
|
||||||
|
AtomicInteger status = new AtomicInteger();
|
||||||
|
HttpServletRequest request = proxy(
|
||||||
|
HttpServletRequest.class,
|
||||||
|
(instance, method, args) -> {
|
||||||
|
if ("getRequestURI".equals(method.getName())) {
|
||||||
|
return "/public-api/workflow/runAsync";
|
||||||
|
}
|
||||||
|
if ("getHeader".equals(method.getName())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw new AssertionError(
|
||||||
|
"测试路径不应调用 HttpServletRequest."
|
||||||
|
+ method.getName());
|
||||||
|
});
|
||||||
|
HttpServletResponse response = proxy(
|
||||||
|
HttpServletResponse.class,
|
||||||
|
(instance, method, args) -> {
|
||||||
|
if ("setStatus".equals(method.getName())) {
|
||||||
|
status.set((Integer) args[0]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ("setContentType".equals(method.getName())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ("getWriter".equals(method.getName())) {
|
||||||
|
return new PrintWriter(body);
|
||||||
|
}
|
||||||
|
throw new AssertionError(
|
||||||
|
"测试路径不应调用 HttpServletResponse."
|
||||||
|
+ method.getName());
|
||||||
|
});
|
||||||
|
|
||||||
|
boolean allowed = new PublicApiInterceptor()
|
||||||
|
.preHandle(request, response, new Object());
|
||||||
|
|
||||||
|
Assert.assertFalse(allowed);
|
||||||
|
Assert.assertEquals(
|
||||||
|
HttpServletResponse.SC_UNAUTHORIZED,
|
||||||
|
status.get());
|
||||||
|
Assert.assertTrue(body.toString().contains("\"errorCode\":401"));
|
||||||
|
Assert.assertTrue(body.toString().contains("密钥不正确"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建接口代理。
|
||||||
|
*
|
||||||
|
* @param type 接口类型
|
||||||
|
* @param handler 调用处理器
|
||||||
|
* @param <T> 接口类型
|
||||||
|
* @return 代理实例
|
||||||
|
*/
|
||||||
|
private <T> T proxy(
|
||||||
|
Class<T> type,
|
||||||
|
java.lang.reflect.InvocationHandler handler) {
|
||||||
|
return type.cast(Proxy.newProxyInstance(
|
||||||
|
type.getClassLoader(),
|
||||||
|
new Class<?>[]{type},
|
||||||
|
handler));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
package tech.easyflow.ai.document.support;
|
||||||
|
|
||||||
|
import com.easyagents.flow.core.util.OkHttpClientUtil;
|
||||||
|
import okhttp3.Dns;
|
||||||
|
import okhttp3.HttpUrl;
|
||||||
|
import okhttp3.OkHttpClient;
|
||||||
|
import okhttp3.Request;
|
||||||
|
import okhttp3.Response;
|
||||||
|
import okhttp3.ResponseBody;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.io.FilterInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.net.InetAddress;
|
||||||
|
import java.net.Proxy;
|
||||||
|
import java.net.UnknownHostException;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档输入流的共享连接与实际字节数保护工具。
|
||||||
|
*/
|
||||||
|
public final class DocumentInputStreamSupport {
|
||||||
|
|
||||||
|
private static final Logger LOG =
|
||||||
|
LoggerFactory.getLogger(DocumentInputStreamSupport.class);
|
||||||
|
private static final String BLOCKED_REMOTE_ADDRESS_MESSAGE =
|
||||||
|
"远端文档地址不允许访问非公网目标";
|
||||||
|
|
||||||
|
private DocumentInputStreamSupport() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用共享 HTTP 客户端打开远端文档流。
|
||||||
|
*
|
||||||
|
* @param url 远端 URL
|
||||||
|
* @param maxBytes 最大允许读取字节数;小于等于 0 时不限制
|
||||||
|
* @return 关闭时会同步释放 HTTP 响应的输入流
|
||||||
|
* @throws IOException 请求失败、非成功状态或响应超过限制时抛出
|
||||||
|
*/
|
||||||
|
public static InputStream openRemote(String url, long maxBytes) throws IOException {
|
||||||
|
HttpUrl remoteUrl = parseRemoteUrl(url);
|
||||||
|
Response response = RemoteClientHolder.CLIENT
|
||||||
|
.newCall(new Request.Builder().url(remoteUrl).get().build())
|
||||||
|
.execute();
|
||||||
|
return openResponse(response, maxBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析并校验远端文档 URL 的协议。
|
||||||
|
*
|
||||||
|
* @param url 原始 URL
|
||||||
|
* @return 可用于请求的 HTTP URL
|
||||||
|
* @throws IOException URL 为空、格式错误或协议不受支持时抛出
|
||||||
|
*/
|
||||||
|
private static HttpUrl parseRemoteUrl(String url) throws IOException {
|
||||||
|
if (url == null || url.isBlank()) {
|
||||||
|
throw new IOException("远端文档 URL 不能为空");
|
||||||
|
}
|
||||||
|
HttpUrl remoteUrl = HttpUrl.parse(url);
|
||||||
|
if (remoteUrl == null
|
||||||
|
|| (!"http".equals(remoteUrl.scheme())
|
||||||
|
&& !"https".equals(remoteUrl.scheme()))) {
|
||||||
|
throw new IOException("远端文档仅支持 HTTP 或 HTTPS URL");
|
||||||
|
}
|
||||||
|
return remoteUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建只允许公网目标的远程文档客户端。
|
||||||
|
*
|
||||||
|
* <p>显式禁用代理,避免代理服务器重新解析目标域名后绕过本机 DNS 校验。
|
||||||
|
* OkHttp 的重定向请求会继续使用同一 DNS 实现,因此每个新目标都会重新校验。</p>
|
||||||
|
*
|
||||||
|
* @param delegateDns 实际执行域名解析的 DNS
|
||||||
|
* @return 带公网地址约束的 HTTP 客户端
|
||||||
|
*/
|
||||||
|
static OkHttpClient createRemoteClient(Dns delegateDns) {
|
||||||
|
if (delegateDns == null) {
|
||||||
|
throw new IllegalArgumentException("DNS 解析器不能为空");
|
||||||
|
}
|
||||||
|
return OkHttpClientUtil.buildDefaultClient()
|
||||||
|
.newBuilder()
|
||||||
|
.proxy(Proxy.NO_PROXY)
|
||||||
|
.dns(new PublicAddressDns(delegateDns))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断地址是否属于允许访问的公网范围。
|
||||||
|
*
|
||||||
|
* @param address 已解析的目标地址
|
||||||
|
* @return 公网单播地址返回 true
|
||||||
|
*/
|
||||||
|
static boolean isPublicAddress(InetAddress address) {
|
||||||
|
if (address == null
|
||||||
|
|| address.isAnyLocalAddress()
|
||||||
|
|| address.isLoopbackAddress()
|
||||||
|
|| address.isLinkLocalAddress()
|
||||||
|
|| address.isSiteLocalAddress()
|
||||||
|
|| address.isMulticastAddress()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
byte[] bytes = address.getAddress();
|
||||||
|
if (bytes.length == 4) {
|
||||||
|
return isPublicIpv4(bytes);
|
||||||
|
}
|
||||||
|
if (bytes.length == 16) {
|
||||||
|
return isPublicIpv6(bytes);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断 IPv4 地址是否为公网单播地址。
|
||||||
|
*
|
||||||
|
* @param bytes IPv4 地址字节
|
||||||
|
* @return 公网单播地址返回 true
|
||||||
|
*/
|
||||||
|
private static boolean isPublicIpv4(byte[] bytes) {
|
||||||
|
int first = bytes[0] & 0xff;
|
||||||
|
int second = bytes[1] & 0xff;
|
||||||
|
int third = bytes[2] & 0xff;
|
||||||
|
if (first == 0 || first == 10 || first == 127 || first >= 224) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (first == 100 && second >= 64 && second <= 127) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (first == 169 && second == 254) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (first == 172 && second >= 16 && second <= 31) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (first == 192 && second == 168) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (first == 192 && second == 0 && (third == 0 || third == 2)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (first == 192 && second == 88 && third == 99) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (first == 198 && (second == 18 || second == 19)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (first == 198 && second == 51 && third == 100) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !(first == 203 && second == 0 && third == 113);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断 IPv6 地址是否为公网单播地址。
|
||||||
|
*
|
||||||
|
* @param bytes IPv6 地址字节
|
||||||
|
* @return 公网单播地址返回 true
|
||||||
|
*/
|
||||||
|
private static boolean isPublicIpv6(byte[] bytes) {
|
||||||
|
int first = bytes[0] & 0xff;
|
||||||
|
int second = bytes[1] & 0xff;
|
||||||
|
if ((first & 0xfe) == 0xfc) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (first == 0x20 && second == 0x01) {
|
||||||
|
int third = bytes[2] & 0xff;
|
||||||
|
int fourth = bytes[3] & 0xff;
|
||||||
|
if ((third == 0x0d && fourth == 0xb8)
|
||||||
|
|| (third == 0x00 && fourth == 0x00)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (first == 0x20 && second == 0x02) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !(first == 0x00
|
||||||
|
&& second == 0x64
|
||||||
|
&& (bytes[2] & 0xff) == 0xff
|
||||||
|
&& (bytes[3] & 0xff) == 0x9b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 HTTP 响应转换为按实际读取字节数受限的输入流。
|
||||||
|
*
|
||||||
|
* <p>响应声明长度仅用于诊断。最终限制以流中实际读到的字节数为准,
|
||||||
|
* 避免代理或存储服务返回错误 Content-Length 时误判正常文件。</p>
|
||||||
|
*
|
||||||
|
* @param response HTTP 响应
|
||||||
|
* @param maxBytes 最大允许读取字节数;小于等于 0 时不限制
|
||||||
|
* @return 关闭时会同步释放 HTTP 响应的输入流
|
||||||
|
* @throws IOException 非成功状态或响应体为空时抛出
|
||||||
|
*/
|
||||||
|
static InputStream openResponse(Response response, long maxBytes)
|
||||||
|
throws IOException {
|
||||||
|
if (!response.isSuccessful()) {
|
||||||
|
int status = response.code();
|
||||||
|
response.close();
|
||||||
|
throw new IOException("Document download failed with HTTP status " + status);
|
||||||
|
}
|
||||||
|
ResponseBody body = response.body();
|
||||||
|
if (body == null) {
|
||||||
|
response.close();
|
||||||
|
throw new IOException("Document download response body is empty");
|
||||||
|
}
|
||||||
|
long contentLength = body.contentLength();
|
||||||
|
if (maxBytes > 0L && contentLength > maxBytes) {
|
||||||
|
LOG.warn(
|
||||||
|
"远端文档响应声明长度超过限制,将按实际读取量确认: "
|
||||||
|
+ "declaredBytes={}, maxBytes={}",
|
||||||
|
contentLength,
|
||||||
|
maxBytes);
|
||||||
|
}
|
||||||
|
return limit(new ResponseInputStream(body.byteStream(), response), maxBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为已有输入流增加实际读取字节数限制。
|
||||||
|
*
|
||||||
|
* @param inputStream 原始输入流
|
||||||
|
* @param maxBytes 最大允许读取字节数;小于等于 0 时直接返回原始流
|
||||||
|
* @return 受限输入流
|
||||||
|
*/
|
||||||
|
public static InputStream limit(InputStream inputStream, long maxBytes) {
|
||||||
|
if (inputStream == null || maxBytes <= 0L) {
|
||||||
|
return inputStream;
|
||||||
|
}
|
||||||
|
return new LimitedInputStream(inputStream, maxBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在实际字节数限制下读取完整内容。
|
||||||
|
*
|
||||||
|
* @param inputStream 文档输入流
|
||||||
|
* @param maxBytes 最大允许读取字节数;小于等于 0 时不限制
|
||||||
|
* @return 文档字节
|
||||||
|
* @throws IOException 读取失败或内容超过限制时抛出
|
||||||
|
*/
|
||||||
|
public static byte[] readBytes(InputStream inputStream, long maxBytes) throws IOException {
|
||||||
|
try (InputStream limited = limit(inputStream, maxBytes)) {
|
||||||
|
return limited.readAllBytes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档实际读取字节数超过保护值异常。
|
||||||
|
*/
|
||||||
|
public static final class SizeLimitExceededException extends IOException {
|
||||||
|
|
||||||
|
private final long maxBytes;
|
||||||
|
private final long actualBytes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建文档大小限制异常。
|
||||||
|
*
|
||||||
|
* @param maxBytes 最大允许字节数
|
||||||
|
*/
|
||||||
|
public SizeLimitExceededException(long maxBytes) {
|
||||||
|
this(maxBytes, -1L);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建包含实际读取量的文档大小限制异常。
|
||||||
|
*
|
||||||
|
* @param maxBytes 最大允许字节数
|
||||||
|
* @param actualBytes 已确认的实际字节数;未知时传入负数
|
||||||
|
*/
|
||||||
|
public SizeLimitExceededException(long maxBytes, long actualBytes) {
|
||||||
|
super("Document exceeds size limit: max=" + maxBytes
|
||||||
|
+ " bytes, actual=" + actualBytes + " bytes");
|
||||||
|
this.maxBytes = maxBytes;
|
||||||
|
this.actualBytes = actualBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取最大允许字节数。
|
||||||
|
*
|
||||||
|
* @return 最大允许字节数
|
||||||
|
*/
|
||||||
|
public long getMaxBytes() {
|
||||||
|
return maxBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取已确认的实际读取量。
|
||||||
|
*
|
||||||
|
* @return 实际字节数;未知时为负数
|
||||||
|
*/
|
||||||
|
public long getActualBytes() {
|
||||||
|
return actualBytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按实际读取量执行限制的输入流。
|
||||||
|
*/
|
||||||
|
private static final class LimitedInputStream extends FilterInputStream {
|
||||||
|
|
||||||
|
private final long maxBytes;
|
||||||
|
private long consumed;
|
||||||
|
|
||||||
|
private LimitedInputStream(InputStream inputStream, long maxBytes) {
|
||||||
|
super(inputStream);
|
||||||
|
this.maxBytes = maxBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int read() throws IOException {
|
||||||
|
int value = super.read();
|
||||||
|
if (value >= 0) {
|
||||||
|
recordRead(1L);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int read(byte[] buffer, int offset, int length) throws IOException {
|
||||||
|
if (length == 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
int allowed = (int) Math.min(
|
||||||
|
Math.max(0L, maxBytes - consumed + 1L),
|
||||||
|
(long) length);
|
||||||
|
if (allowed <= 0) {
|
||||||
|
throw new SizeLimitExceededException(maxBytes, consumed + 1L);
|
||||||
|
}
|
||||||
|
int count = super.read(buffer, offset, allowed);
|
||||||
|
if (count > 0) {
|
||||||
|
recordRead(count);
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void recordRead(long count) throws SizeLimitExceededException {
|
||||||
|
consumed += count;
|
||||||
|
if (consumed > maxBytes) {
|
||||||
|
throw new SizeLimitExceededException(maxBytes, consumed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭输入流时一并关闭 OkHttp 响应。
|
||||||
|
*/
|
||||||
|
private static final class ResponseInputStream extends FilterInputStream {
|
||||||
|
|
||||||
|
private final Response response;
|
||||||
|
|
||||||
|
private ResponseInputStream(InputStream inputStream, Response response) {
|
||||||
|
super(inputStream);
|
||||||
|
this.response = response;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
response.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 延迟创建远程文档客户端,避免本地流处理路径初始化网络资源。
|
||||||
|
*/
|
||||||
|
private static final class RemoteClientHolder {
|
||||||
|
|
||||||
|
private static final OkHttpClient CLIENT = createRemoteClient(Dns.SYSTEM);
|
||||||
|
|
||||||
|
private RemoteClientHolder() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对 DNS 解析结果执行公网地址约束。
|
||||||
|
*/
|
||||||
|
private static final class PublicAddressDns implements Dns {
|
||||||
|
|
||||||
|
private final Dns delegate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建安全 DNS 包装器。
|
||||||
|
*
|
||||||
|
* @param delegate 实际 DNS 解析器
|
||||||
|
*/
|
||||||
|
private PublicAddressDns(Dns delegate) {
|
||||||
|
this.delegate = delegate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析主机并拒绝任一非公网地址,避免连接回退到私有地址。
|
||||||
|
*
|
||||||
|
* @param hostname 目标主机名
|
||||||
|
* @return 全部通过校验的解析地址
|
||||||
|
* @throws UnknownHostException 解析失败或包含非公网地址时抛出
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<InetAddress> lookup(String hostname) throws UnknownHostException {
|
||||||
|
List<InetAddress> addresses = delegate.lookup(hostname);
|
||||||
|
if (addresses == null || addresses.isEmpty()) {
|
||||||
|
throw new UnknownHostException("远端文档域名没有可用地址");
|
||||||
|
}
|
||||||
|
for (InetAddress address : addresses) {
|
||||||
|
if (!isPublicAddress(address)) {
|
||||||
|
throw new UnknownHostException(BLOCKED_REMOTE_ADDRESS_MESSAGE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return addresses;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package tech.easyflow.ai.document.support;
|
package tech.easyflow.ai.document.support;
|
||||||
|
|
||||||
import cn.hutool.http.HttpUtil;
|
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
@@ -85,8 +84,9 @@ public class DocumentSourceLoader {
|
|||||||
|
|
||||||
private LoadedDocumentSource loadFromRemoteValue(DocumentSourceRef sourceRef, String remoteUrl) {
|
private LoadedDocumentSource loadFromRemoteValue(DocumentSourceRef sourceRef, String remoteUrl) {
|
||||||
String fileName = resolveFileName(sourceRef);
|
String fileName = resolveFileName(sourceRef);
|
||||||
try {
|
try (InputStream inputStream =
|
||||||
byte[] contentBytes = HttpUtil.downloadBytes(remoteUrl);
|
DocumentInputStreamSupport.openRemote(remoteUrl, 0L)) {
|
||||||
|
byte[] contentBytes = DocumentInputStreamSupport.readBytes(inputStream, 0L);
|
||||||
return buildLoadedSource(
|
return buildLoadedSource(
|
||||||
fileName,
|
fileName,
|
||||||
resolveContentType(sourceRef, fileName),
|
resolveContentType(sourceRef, fileName),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
|||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
|
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
|
||||||
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
|
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -40,6 +41,12 @@ public class TinyFlowService {
|
|||||||
NodeStateRepository nodeStateRepository = chainExecutor.getNodeStateRepository();
|
NodeStateRepository nodeStateRepository = chainExecutor.getNodeStateRepository();
|
||||||
|
|
||||||
ChainState chainState = chainStateRepository.load(executeId);
|
ChainState chainState = chainStateRepository.load(executeId);
|
||||||
|
if (chainState == null) {
|
||||||
|
throw new BusinessException(
|
||||||
|
404,
|
||||||
|
404,
|
||||||
|
"工作流执行状态不存在或已过期");
|
||||||
|
}
|
||||||
ChainInfo res = getChainInfo(executeId, chainState);
|
ChainInfo res = getChainInfo(executeId, chainState);
|
||||||
|
|
||||||
if (nodes != null) {
|
if (nodes != null) {
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ public class WorkflowApiPermissionServiceImpl implements WorkflowApiPermissionSe
|
|||||||
.isNull(SysApiKeyResourceMapping::getResourceTargetId)
|
.isNull(SysApiKeyResourceMapping::getResourceTargetId)
|
||||||
.eq(SysApiKeyResourceMapping::getActionScope, ACTION_SCOPE_INVOKE);
|
.eq(SysApiKeyResourceMapping::getActionScope, ACTION_SCOPE_INVOKE);
|
||||||
if (mappingService.count(wrapper) == 0) {
|
if (mappingService.count(wrapper) == 0) {
|
||||||
throw new BusinessException("该apiKey无权限调用工作流 API");
|
throw new BusinessException(403, 403, "该apiKey无权限调用工作流 API");
|
||||||
}
|
}
|
||||||
return sysApiKey;
|
return sysApiKey;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package tech.easyflow.ai.document.support;
|
||||||
|
|
||||||
|
import okhttp3.Dns;
|
||||||
|
import okhttp3.MediaType;
|
||||||
|
import okhttp3.OkHttpClient;
|
||||||
|
import okhttp3.Protocol;
|
||||||
|
import okhttp3.Request;
|
||||||
|
import okhttp3.Response;
|
||||||
|
import okhttp3.ResponseBody;
|
||||||
|
import okio.Buffer;
|
||||||
|
import okio.BufferedSource;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.net.InetAddress;
|
||||||
|
import java.net.Proxy;
|
||||||
|
import java.net.UnknownHostException;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link DocumentInputStreamSupport} 单元测试。
|
||||||
|
*/
|
||||||
|
public class DocumentInputStreamSupportTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证远程文档客户端禁用代理且保留重定向,并让每个目标经过安全 DNS。
|
||||||
|
*
|
||||||
|
* @throws Exception 地址构造失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldRejectPrivateAddressForRedirectAwareClient()
|
||||||
|
throws Exception {
|
||||||
|
Dns privateDns = hostname -> List.of(
|
||||||
|
InetAddress.getByName("127.0.0.1"));
|
||||||
|
OkHttpClient client =
|
||||||
|
DocumentInputStreamSupport.createRemoteClient(privateDns);
|
||||||
|
|
||||||
|
Assert.assertEquals(Proxy.NO_PROXY, client.proxy());
|
||||||
|
Assert.assertTrue(client.followRedirects());
|
||||||
|
try {
|
||||||
|
client.dns().lookup("redirect-target.example");
|
||||||
|
Assert.fail("expected private address rejection");
|
||||||
|
} catch (UnknownHostException error) {
|
||||||
|
Assert.assertTrue(error.getMessage().contains("非公网目标"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证正常公网地址仍可通过安全 DNS。
|
||||||
|
*
|
||||||
|
* @throws Exception 地址构造失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldAllowPublicAddress() throws Exception {
|
||||||
|
InetAddress publicAddress =
|
||||||
|
InetAddress.getByName("93.184.216.34");
|
||||||
|
Dns publicDns = hostname -> List.of(publicAddress);
|
||||||
|
OkHttpClient client =
|
||||||
|
DocumentInputStreamSupport.createRemoteClient(publicDns);
|
||||||
|
|
||||||
|
Assert.assertEquals(
|
||||||
|
List.of(publicAddress),
|
||||||
|
client.dns().lookup("public.example"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证常见内网、链路本地和保留地址均被拒绝。
|
||||||
|
*
|
||||||
|
* @throws Exception 地址构造失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldRejectNonPublicAddressRanges() throws Exception {
|
||||||
|
List<String> blockedAddresses = List.of(
|
||||||
|
"10.0.0.1",
|
||||||
|
"100.64.0.1",
|
||||||
|
"127.0.0.1",
|
||||||
|
"169.254.169.254",
|
||||||
|
"172.16.0.1",
|
||||||
|
"192.168.0.1",
|
||||||
|
"198.18.0.1",
|
||||||
|
"fd00::1",
|
||||||
|
"2001:db8::1");
|
||||||
|
|
||||||
|
for (String value : blockedAddresses) {
|
||||||
|
Assert.assertFalse(
|
||||||
|
value,
|
||||||
|
DocumentInputStreamSupport.isPublicAddress(
|
||||||
|
InetAddress.getByName(value)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证远端响应声明长度虚高时按实际读取量接受小文件。
|
||||||
|
*
|
||||||
|
* @throws IOException 响应读取失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldUseActualBytesWhenDeclaredLengthExceedsLimit()
|
||||||
|
throws IOException {
|
||||||
|
long maxBytes = 20L * 1024L * 1024L;
|
||||||
|
byte[] content = new byte[58 * 1024];
|
||||||
|
Buffer source = new Buffer().write(content);
|
||||||
|
ResponseBody responseBody = new ResponseBody() {
|
||||||
|
@Override
|
||||||
|
public MediaType contentType() {
|
||||||
|
return MediaType.parse(
|
||||||
|
"application/vnd.openxmlformats-officedocument"
|
||||||
|
+ ".wordprocessingml.document");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long contentLength() {
|
||||||
|
return maxBytes + 1L;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BufferedSource source() {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Response response = new Response.Builder()
|
||||||
|
.request(new Request.Builder()
|
||||||
|
.url("http://127.0.0.1/document.docx")
|
||||||
|
.build())
|
||||||
|
.protocol(Protocol.HTTP_1_1)
|
||||||
|
.code(200)
|
||||||
|
.message("OK")
|
||||||
|
.body(responseBody)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
try (InputStream inputStream =
|
||||||
|
DocumentInputStreamSupport.openResponse(
|
||||||
|
response,
|
||||||
|
maxBytes)) {
|
||||||
|
Assert.assertArrayEquals(
|
||||||
|
content,
|
||||||
|
DocumentInputStreamSupport.readBytes(
|
||||||
|
inputStream,
|
||||||
|
maxBytes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证真实读取量超过限制时携带实际字节数。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldRejectActualBytesBeyondLimit() {
|
||||||
|
long maxBytes = 1024L;
|
||||||
|
byte[] content = new byte[(int) maxBytes + 1];
|
||||||
|
|
||||||
|
try {
|
||||||
|
DocumentInputStreamSupport.readBytes(
|
||||||
|
new ByteArrayInputStream(content),
|
||||||
|
maxBytes);
|
||||||
|
Assert.fail("expected SizeLimitExceededException");
|
||||||
|
} catch (DocumentInputStreamSupport.SizeLimitExceededException error) {
|
||||||
|
Assert.assertEquals(maxBytes, error.getMaxBytes());
|
||||||
|
Assert.assertEquals(maxBytes + 1L, error.getActualBytes());
|
||||||
|
} catch (IOException error) {
|
||||||
|
Assert.fail("unexpected IOException: " + error.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证零长度读取符合 InputStream 约定。
|
||||||
|
*
|
||||||
|
* @throws IOException 读取失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldAllowZeroLengthRead() throws IOException {
|
||||||
|
try (InputStream inputStream = DocumentInputStreamSupport.limit(
|
||||||
|
new ByteArrayInputStream(new byte[0]),
|
||||||
|
1L)) {
|
||||||
|
Assert.assertEquals(0, inputStream.read(new byte[0]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import org.junit.Assert;
|
|||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
|
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
|
||||||
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
|
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
import java.lang.reflect.Field;
|
import java.lang.reflect.Field;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -28,6 +29,36 @@ public class TinyFlowServiceTest {
|
|||||||
private static final String EXECUTE_ID = "execution-1";
|
private static final String EXECUTE_ID = "execution-1";
|
||||||
private static final String NODE_ID = "node-1";
|
private static final String NODE_ID = "node-1";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证执行状态不存在或已过期时返回稳定的 HTTP 404 业务错误。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试依赖注入失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldReturnNotFoundWhenChainStateMissing()
|
||||||
|
throws Exception {
|
||||||
|
ChainExecutor chainExecutor = mock(ChainExecutor.class);
|
||||||
|
ChainStateRepository chainStateRepository =
|
||||||
|
mock(ChainStateRepository.class);
|
||||||
|
NodeStateRepository nodeStateRepository =
|
||||||
|
mock(NodeStateRepository.class);
|
||||||
|
when(chainExecutor.getChainStateRepository())
|
||||||
|
.thenReturn(chainStateRepository);
|
||||||
|
when(chainExecutor.getNodeStateRepository())
|
||||||
|
.thenReturn(nodeStateRepository);
|
||||||
|
when(chainStateRepository.load(EXECUTE_ID)).thenReturn(null);
|
||||||
|
TinyFlowService service = service(chainExecutor);
|
||||||
|
|
||||||
|
try {
|
||||||
|
service.getChainStatus(EXECUTE_ID, null);
|
||||||
|
Assert.fail("expected BusinessException");
|
||||||
|
} catch (BusinessException error) {
|
||||||
|
Assert.assertEquals(404, error.getHttpStatus());
|
||||||
|
Assert.assertEquals(404, error.getErrorCode());
|
||||||
|
Assert.assertTrue(error.getMessage().contains("不存在或已过期"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证尚未启动的节点返回 READY,且一次轮询只读取一次工作流状态。
|
* 验证尚未启动的节点返回 READY,且一次轮询只读取一次工作流状态。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package tech.easyflow.ai.service.impl;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
import tech.easyflow.system.entity.SysApiKey;
|
||||||
|
import tech.easyflow.system.entity.SysApiKeyResource;
|
||||||
|
import tech.easyflow.system.service.SysApiKeyResourceMappingService;
|
||||||
|
import tech.easyflow.system.service.SysApiKeyResourceService;
|
||||||
|
import tech.easyflow.system.service.SysApiKeyService;
|
||||||
|
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link WorkflowApiPermissionServiceImpl} 权限错误语义测试。
|
||||||
|
*/
|
||||||
|
public class WorkflowApiPermissionServiceImplTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证未开启工作流 API 授权时返回 HTTP 403。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试依赖注入失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldReturnForbiddenWhenWorkflowApiPermissionMissing()
|
||||||
|
throws Exception {
|
||||||
|
SysApiKeyService apiKeyService = mock(SysApiKeyService.class);
|
||||||
|
SysApiKeyResourceService resourceService =
|
||||||
|
mock(SysApiKeyResourceService.class);
|
||||||
|
SysApiKeyResourceMappingService mappingService =
|
||||||
|
mock(SysApiKeyResourceMappingService.class);
|
||||||
|
SysApiKey apiKey = new SysApiKey();
|
||||||
|
apiKey.setId(BigInteger.ONE);
|
||||||
|
SysApiKeyResource resource = new SysApiKeyResource();
|
||||||
|
resource.setId(BigInteger.TWO);
|
||||||
|
when(apiKeyService.getSysApiKey("test-key")).thenReturn(apiKey);
|
||||||
|
when(resourceService.getOne(any(QueryWrapper.class)))
|
||||||
|
.thenReturn(resource);
|
||||||
|
when(mappingService.count(any(QueryWrapper.class))).thenReturn(0L);
|
||||||
|
WorkflowApiPermissionServiceImpl service =
|
||||||
|
new WorkflowApiPermissionServiceImpl();
|
||||||
|
setField(service, "sysApiKeyService", apiKeyService);
|
||||||
|
setField(service, "resourceService", resourceService);
|
||||||
|
setField(service, "mappingService", mappingService);
|
||||||
|
|
||||||
|
try {
|
||||||
|
service.assertWorkflowApi(
|
||||||
|
"test-key",
|
||||||
|
"/public-api/workflow/runAsync");
|
||||||
|
Assert.fail("expected BusinessException");
|
||||||
|
} catch (BusinessException error) {
|
||||||
|
Assert.assertEquals(403, error.getHttpStatus());
|
||||||
|
Assert.assertEquals(403, error.getErrorCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注入测试依赖。
|
||||||
|
*
|
||||||
|
* @param target 目标对象
|
||||||
|
* @param name 字段名
|
||||||
|
* @param value 字段值
|
||||||
|
* @throws Exception 反射访问失败时抛出
|
||||||
|
*/
|
||||||
|
private void setField(Object target, String name, Object value)
|
||||||
|
throws Exception {
|
||||||
|
Field field = target.getClass().getDeclaredField(name);
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(target, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,7 +50,7 @@ public class SysApiKeyServiceImpl extends ServiceImpl<SysApiKeyMapper, SysApiKey
|
|||||||
wm.in(SysApiKeyResourceMapping::getApiKeyResourceId, resourceIds);
|
wm.in(SysApiKeyResourceMapping::getApiKeyResourceId, resourceIds);
|
||||||
long count = mappingService.count(wm);
|
long count = mappingService.count(wm);
|
||||||
if (count == 0) {
|
if (count == 0) {
|
||||||
throw new BusinessException("该apiKey无权限访问该接口");
|
throw new BusinessException(403, 403, "该apiKey无权限访问该接口");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,11 +70,11 @@ public class SysApiKeyServiceImpl extends ServiceImpl<SysApiKeyMapper, SysApiKey
|
|||||||
QueryWrapper w = QueryWrapper.create();
|
QueryWrapper w = QueryWrapper.create();
|
||||||
w.eq(SysApiKey::getApiKey, apiKey);
|
w.eq(SysApiKey::getApiKey, apiKey);
|
||||||
SysApiKey one = getOne(w);
|
SysApiKey one = getOne(w);
|
||||||
if (one == null || one.getStatus() == 0) {
|
if (one == null || !Integer.valueOf(1).equals(one.getStatus())) {
|
||||||
throw new BusinessException("apiKey 不存在或已禁用");
|
throw new BusinessException(401, 401, "apiKey 不存在或已禁用");
|
||||||
}
|
}
|
||||||
if (one.getExpiredAt() != null && one.getExpiredAt().getTime() < new Date().getTime()) {
|
if (one.getExpiredAt() != null && one.getExpiredAt().getTime() < new Date().getTime()) {
|
||||||
throw new BusinessException("apiKey 已过期");
|
throw new BusinessException(401, 401, "apiKey 已过期");
|
||||||
}
|
}
|
||||||
return one;
|
return one;
|
||||||
}
|
}
|
||||||
@@ -107,7 +107,7 @@ public class SysApiKeyServiceImpl extends ServiceImpl<SysApiKeyMapper, SysApiKey
|
|||||||
globalScopeWrapper.isNull(SysApiKeyResourceMapping::getResourceTargetId);
|
globalScopeWrapper.isNull(SysApiKeyResourceMapping::getResourceTargetId);
|
||||||
globalScopeWrapper.eq(SysApiKeyResourceMapping::getActionScope, actionScope);
|
globalScopeWrapper.eq(SysApiKeyResourceMapping::getActionScope, actionScope);
|
||||||
if (mappingService.count(globalScopeWrapper) == 0) {
|
if (mappingService.count(globalScopeWrapper) == 0) {
|
||||||
throw new BusinessException("该apiKey无权限访问当前资源");
|
throw new BusinessException(403, 403, "该apiKey无权限访问当前资源");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package tech.easyflow.system.service.impl;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
import tech.easyflow.system.entity.SysApiKey;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Key 认证状态错误语义测试。
|
||||||
|
*/
|
||||||
|
public class SysApiKeyAuthenticationStatusTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证不存在的 API Key 使用 HTTP 401 语义。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldReturnUnauthorizedWhenApiKeyMissing() {
|
||||||
|
TestSysApiKeyService service = new TestSysApiKeyService(null);
|
||||||
|
|
||||||
|
BusinessException error = expectBusinessException(
|
||||||
|
() -> service.getSysApiKey("missing"));
|
||||||
|
|
||||||
|
Assert.assertEquals(401, error.getHttpStatus());
|
||||||
|
Assert.assertEquals(401, error.getErrorCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证状态缺失的 API Key 按禁用处理并返回 HTTP 401。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldReturnUnauthorizedWhenApiKeyStatusMissing() {
|
||||||
|
SysApiKey apiKey = new SysApiKey();
|
||||||
|
TestSysApiKeyService service = new TestSysApiKeyService(apiKey);
|
||||||
|
|
||||||
|
BusinessException error = expectBusinessException(
|
||||||
|
() -> service.getSysApiKey("status-missing"));
|
||||||
|
|
||||||
|
Assert.assertEquals(401, error.getHttpStatus());
|
||||||
|
Assert.assertEquals(401, error.getErrorCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证过期的 API Key 使用 HTTP 401 语义。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldReturnUnauthorizedWhenApiKeyExpired() {
|
||||||
|
SysApiKey apiKey = new SysApiKey();
|
||||||
|
apiKey.setStatus(1);
|
||||||
|
apiKey.setExpiredAt(new Date(System.currentTimeMillis() - 1_000L));
|
||||||
|
TestSysApiKeyService service = new TestSysApiKeyService(apiKey);
|
||||||
|
|
||||||
|
BusinessException error = expectBusinessException(
|
||||||
|
() -> service.getSysApiKey("expired"));
|
||||||
|
|
||||||
|
Assert.assertEquals(401, error.getHttpStatus());
|
||||||
|
Assert.assertEquals(401, error.getErrorCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行调用并返回预期业务异常。
|
||||||
|
*
|
||||||
|
* @param action 待执行调用
|
||||||
|
* @return 捕获的业务异常
|
||||||
|
*/
|
||||||
|
private BusinessException expectBusinessException(Runnable action) {
|
||||||
|
try {
|
||||||
|
action.run();
|
||||||
|
Assert.fail("expected BusinessException");
|
||||||
|
return null;
|
||||||
|
} catch (BusinessException error) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 固定返回 API Key 的测试服务。
|
||||||
|
*/
|
||||||
|
private static final class TestSysApiKeyService
|
||||||
|
extends SysApiKeyServiceImpl {
|
||||||
|
|
||||||
|
private final SysApiKey apiKey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建测试服务。
|
||||||
|
*
|
||||||
|
* @param apiKey 查询时返回的 API Key
|
||||||
|
*/
|
||||||
|
private TestSysApiKeyService(SysApiKey apiKey) {
|
||||||
|
this.apiKey = apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回预设 API Key。
|
||||||
|
*
|
||||||
|
* @param queryWrapper 查询条件
|
||||||
|
* @return 预设 API Key
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public SysApiKey getOne(QueryWrapper queryWrapper) {
|
||||||
|
return apiKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -649,42 +649,64 @@ const apiDocMarkdown = computed(() => {
|
|||||||
lines.push(``);
|
lines.push(``);
|
||||||
lines.push(apiUrlLine('POST', `${baseUrl}/getChainStatus`));
|
lines.push(apiUrlLine('POST', `${baseUrl}/getChainStatus`));
|
||||||
lines.push(``);
|
lines.push(``);
|
||||||
lines.push(`查询工作流执行的整体状态与各节点执行详情。工作流为异步执行,建议**轮询**该接口直到状态为终态。`);
|
lines.push(
|
||||||
|
`查询工作流执行的整体状态与各节点执行详情。工作流为异步执行,建议**轮询**该接口直到状态为终态。`,
|
||||||
|
);
|
||||||
lines.push(``);
|
lines.push(``);
|
||||||
lines.push(`### 请求体`);
|
lines.push(`### 请求体`);
|
||||||
lines.push(``);
|
lines.push(``);
|
||||||
lines.push('```json');
|
lines.push('```json');
|
||||||
lines.push(JSON.stringify({ executeId: '<runAsync 返回的执行 ID>' }, null, 2));
|
lines.push(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
executeId: '<runAsync 返回的执行 ID>',
|
||||||
|
nodes: [{ nodeId: '<需要查询的节点 ID>' }],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
lines.push('```');
|
lines.push('```');
|
||||||
lines.push(``);
|
lines.push(``);
|
||||||
lines.push(`> \`nodes\` 参数可选。不传则只返回工作流整体状态;传入节点 ID 数组可额外获取对应节点的执行详情。`);
|
lines.push(
|
||||||
|
`> \`nodes\` 参数可选。不传则只返回工作流整体状态;需要节点详情时传入对象数组,每项至少包含 \`nodeId\`。`,
|
||||||
|
);
|
||||||
lines.push(``);
|
lines.push(``);
|
||||||
lines.push(`### 响应示例`);
|
lines.push(`### 响应示例`);
|
||||||
lines.push(``);
|
lines.push(``);
|
||||||
lines.push('```json');
|
lines.push('```json');
|
||||||
lines.push(JSON.stringify({
|
lines.push(
|
||||||
errorCode: 0,
|
JSON.stringify(
|
||||||
message: '成功',
|
{
|
||||||
data: {
|
errorCode: 0,
|
||||||
executeId: 'abc5358c-a310-4caa-97ec-455062b2235e',
|
message: '成功',
|
||||||
status: 'FINISHED',
|
data: {
|
||||||
message: null,
|
executeId: 'abc5358c-a310-4caa-97ec-455062b2235e',
|
||||||
result: { output: '工作流执行结果' },
|
status: 20,
|
||||||
nodes: {},
|
message: null,
|
||||||
},
|
result: { output: '工作流执行结果' },
|
||||||
}, null, 2));
|
nodes: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
lines.push('```');
|
lines.push('```');
|
||||||
lines.push(``);
|
lines.push(``);
|
||||||
lines.push(`### 状态值说明`);
|
lines.push(`### 状态值说明`);
|
||||||
lines.push(``);
|
lines.push(``);
|
||||||
lines.push(`| 状态 | 说明 |`);
|
lines.push(`| 数值 | 状态 | 说明 |`);
|
||||||
lines.push(`| --- | --- |`);
|
lines.push(`| --- | --- | --- |`);
|
||||||
lines.push(`| READY | 就绪,尚未开始 |`);
|
lines.push(`| 0 | READY | 就绪,尚未开始 |`);
|
||||||
lines.push(`| RUNNING | 执行中 |`);
|
lines.push(`| 1 | RUNNING | 执行中 |`);
|
||||||
lines.push(`| SUSPEND | 挂起,等待确认节点恢复 |`);
|
lines.push(`| 5 | SUSPEND | 挂起,等待确认节点恢复 |`);
|
||||||
lines.push(`| FINISHED | 执行完成 |`);
|
lines.push(`| 10 | ERROR | 执行异常,可能仍在重试 |`);
|
||||||
lines.push(`| FAILED | 执行失败 |`);
|
lines.push(`| 20 | SUCCEEDED | 执行成功,终态 |`);
|
||||||
lines.push(`| ERROR | 执行异常 |`);
|
lines.push(`| 21 | FAILED | 执行失败,终态 |`);
|
||||||
|
lines.push(`| 22 | CANCELLED | 已取消,终态 |`);
|
||||||
|
lines.push(``);
|
||||||
|
lines.push(`轮询可在状态值为 \`20\`、\`21\` 或 \`22\` 时结束。`);
|
||||||
lines.push(``);
|
lines.push(``);
|
||||||
|
|
||||||
// ---- 3. 恢复执行 ----
|
// ---- 3. 恢复执行 ----
|
||||||
@@ -694,7 +716,9 @@ const apiDocMarkdown = computed(() => {
|
|||||||
lines.push(``);
|
lines.push(``);
|
||||||
lines.push(apiUrlLine('POST', `${baseUrl}/resume`));
|
lines.push(apiUrlLine('POST', `${baseUrl}/resume`));
|
||||||
lines.push(``);
|
lines.push(``);
|
||||||
lines.push(`当工作流包含**确认节点**时,执行到该节点后状态变为 \`SUSPEND\`,需要调用此接口传入确认参数后恢复执行。若工作流不包含确认节点则无需调用。`);
|
lines.push(
|
||||||
|
`当工作流包含**确认节点**时,执行到该节点后状态变为 \`5(SUSPEND)\`,需要调用此接口传入确认参数后恢复执行。若工作流不包含确认节点则无需调用。`,
|
||||||
|
);
|
||||||
lines.push(``);
|
lines.push(``);
|
||||||
lines.push(`### 请求体`);
|
lines.push(`### 请求体`);
|
||||||
lines.push(``);
|
lines.push(``);
|
||||||
|
|||||||
Reference in New Issue
Block a user