feat: 支持工作流对话匿名分享
- 增加免登录公共接口、访客隔离、限流和匿名上传校验 - 分离 SSE 连接与运行生命周期,支持刷新恢复服务端权威状态 - 持久化分享页对话并优化时间线滚动与输入区交互
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
package tech.easyflow.admin.controller.ai;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaIgnore;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
import tech.easyflow.admin.service.ai.WorkflowPublicChatService;
|
||||
import tech.easyflow.ai.share.WorkflowSharePolicy;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.vo.UploadResVo;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 工作流对话匿名分享接口。
|
||||
*/
|
||||
@SaIgnore
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/workflowChat/public")
|
||||
public class WorkflowPublicChatController {
|
||||
|
||||
private final WorkflowPublicChatService publicChatService;
|
||||
|
||||
public WorkflowPublicChatController(
|
||||
WorkflowPublicChatService publicChatService
|
||||
) {
|
||||
this.publicChatService = publicChatService;
|
||||
}
|
||||
|
||||
@GetMapping("/descriptor")
|
||||
public Result<Map<String, Object>> descriptor(HttpServletRequest request) {
|
||||
return Result.ok(publicChatService.descriptor(
|
||||
shareKey(request),
|
||||
visitorId(request)
|
||||
));
|
||||
}
|
||||
|
||||
@PostMapping(value = "/run", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
public SseEmitter run(
|
||||
@JsonBody("variables") Map<String, Object> variables,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
return publicChatService.run(
|
||||
shareKey(request),
|
||||
visitorId(request),
|
||||
variables
|
||||
);
|
||||
}
|
||||
|
||||
@GetMapping("/execution")
|
||||
public Result<Map<String, Object>> execution(
|
||||
@RequestParam String executeId,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
return Result.ok(publicChatService.detail(
|
||||
shareKey(request),
|
||||
visitorId(request),
|
||||
executeId
|
||||
));
|
||||
}
|
||||
|
||||
@PostMapping("/cancel")
|
||||
public Result<Boolean> cancel(
|
||||
@JsonBody(value = "executeId", required = true) String executeId,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
return Result.ok(publicChatService.cancel(
|
||||
shareKey(request),
|
||||
visitorId(request),
|
||||
executeId
|
||||
));
|
||||
}
|
||||
|
||||
@PostMapping("/resume")
|
||||
public Result<Void> resume(
|
||||
@JsonBody(value = "executeId", required = true) String executeId,
|
||||
@JsonBody("confirmParams") Map<String, Object> confirmParams,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
publicChatService.resume(
|
||||
shareKey(request),
|
||||
visitorId(request),
|
||||
executeId,
|
||||
confirmParams
|
||||
);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@PostMapping(value = "/upload", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public Result<UploadResVo> upload(
|
||||
@RequestParam("file") MultipartFile file,
|
||||
@RequestParam("parameterName") String parameterName,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
return Result.ok(publicChatService.upload(
|
||||
shareKey(request),
|
||||
visitorId(request),
|
||||
parameterName,
|
||||
file
|
||||
));
|
||||
}
|
||||
|
||||
private String shareKey(HttpServletRequest request) {
|
||||
return request.getHeader(WorkflowSharePolicy.CHAT_SHARE_KEY_HEADER);
|
||||
}
|
||||
|
||||
private String visitorId(HttpServletRequest request) {
|
||||
return request.getHeader(WorkflowSharePolicy.CHAT_VISITOR_HEADER);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package tech.easyflow.admin.controller.ai;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import cn.dev33.satoken.annotation.SaIgnore;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -96,11 +97,10 @@ public class WorkflowShareController {
|
||||
* @return 工作流标识
|
||||
*/
|
||||
@GetMapping("/resolve")
|
||||
@SaIgnore
|
||||
public Result<Map<String, BigInteger>> resolveUrlShare(HttpServletRequest request) {
|
||||
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
|
||||
WorkflowShare share = workflowShareService.resolveChatShare(
|
||||
request.getHeader(WorkflowSharePolicy.CHAT_SHARE_KEY_HEADER),
|
||||
loginAccount.getTenantId()
|
||||
WorkflowShare share = workflowShareService.resolvePublicChatShare(
|
||||
request.getHeader(WorkflowSharePolicy.CHAT_SHARE_KEY_HEADER)
|
||||
);
|
||||
return Result.ok(Map.of("workflowId", share.getWorkflowId()));
|
||||
}
|
||||
|
||||
@@ -19,11 +19,17 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import javax.annotation.PostConstruct;
|
||||
import javax.annotation.PreDestroy;
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
@@ -40,6 +46,15 @@ public class WorkflowChatEventStream {
|
||||
private final ChainExecutor chainExecutor;
|
||||
private final Map<String, StreamSession> sessions =
|
||||
new ConcurrentHashMap<>();
|
||||
private final ScheduledExecutorService detachedSessionCleaner =
|
||||
Executors.newSingleThreadScheduledExecutor(task -> {
|
||||
Thread thread = new Thread(
|
||||
task,
|
||||
"workflow-chat-detached-session-cleaner"
|
||||
);
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
|
||||
/**
|
||||
* 创建工作流对话事件流服务。
|
||||
@@ -59,6 +74,15 @@ public class WorkflowChatEventStream {
|
||||
chainExecutor.addErrorListener(this::onChainError);
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭断开会话清理线程并释放残留外部资源。
|
||||
*/
|
||||
@PreDestroy
|
||||
public void shutdown() {
|
||||
sessions.values().forEach(this::removeSession);
|
||||
detachedSessionCleaner.shutdownNow();
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动工作流并返回其 SSE 连接。
|
||||
*
|
||||
@@ -67,11 +91,53 @@ public class WorkflowChatEventStream {
|
||||
* @return SSE 连接
|
||||
*/
|
||||
public SseEmitter start(String definitionId, Map<String, Object> variables) {
|
||||
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MILLIS);
|
||||
StreamSession session = new StreamSession(emitter);
|
||||
emitter.onTimeout(() -> disconnect(session, "运行连接超时"));
|
||||
emitter.onError(error -> disconnect(session, "运行连接已断开"));
|
||||
emitter.onCompletion(() -> removeSession(session));
|
||||
return start(definitionId, variables, () -> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动工作流并在流会话结束时执行清理回调。
|
||||
*
|
||||
* @param definitionId 工作流定义 ID
|
||||
* @param variables 运行变量
|
||||
* @param cleanup 终态、启动失败或连接断开后的幂等清理任务
|
||||
* @return SSE 连接
|
||||
*/
|
||||
public SseEmitter start(
|
||||
String definitionId,
|
||||
Map<String, Object> variables,
|
||||
Runnable cleanup
|
||||
) {
|
||||
return start(definitionId, variables, cleanup, Duration.ZERO);
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动工作流并将浏览器连接与 Runtime 生命周期分离。
|
||||
*
|
||||
* <p>浏览器断开后不取消工作流;在保留期内继续监听真实终态并执行清理,
|
||||
* 超过保留期时由租约兜底释放资源。</p>
|
||||
*
|
||||
* @param definitionId 工作流定义 ID
|
||||
* @param variables 运行变量
|
||||
* @param cleanup 终态、启动失败或保留期结束后的幂等清理任务
|
||||
* @param detachedRetention 浏览器断开后的监听保留时长
|
||||
* @return SSE 连接
|
||||
*/
|
||||
public SseEmitter start(
|
||||
String definitionId,
|
||||
Map<String, Object> variables,
|
||||
Runnable cleanup,
|
||||
Duration detachedRetention
|
||||
) {
|
||||
SseEmitter emitter = createEmitter();
|
||||
StreamSession session = new StreamSession(
|
||||
emitter,
|
||||
cleanup,
|
||||
detachedRetention
|
||||
);
|
||||
emitter.onTimeout(() -> detach(session));
|
||||
emitter.onError(error -> detach(session));
|
||||
emitter.onCompletion(() -> detach(session));
|
||||
|
||||
try {
|
||||
chainExecutor.executeAsync(
|
||||
@@ -79,6 +145,9 @@ public class WorkflowChatEventStream {
|
||||
variables,
|
||||
executeId -> {
|
||||
session.attach(executeId);
|
||||
if (session.cleaned.get()) {
|
||||
return;
|
||||
}
|
||||
sessions.put(executeId, session);
|
||||
session.send("execution_started", Map.of(
|
||||
"executeId", executeId
|
||||
@@ -92,6 +161,13 @@ public class WorkflowChatEventStream {
|
||||
return emitter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 SSE 发送器,便于验证连接生命周期。
|
||||
*/
|
||||
SseEmitter createEmitter() {
|
||||
return new SseEmitter(SSE_TIMEOUT_MILLIS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将工作流事件转发到对应执行流。
|
||||
*
|
||||
@@ -162,20 +238,21 @@ public class WorkflowChatEventStream {
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 SSE 连接异常,并取消尚未结束的工作流。
|
||||
* 分离已经断开的浏览器传输,不影响工作流 Runtime。
|
||||
*
|
||||
* @param session 流会话
|
||||
* @param message 取消原因
|
||||
*/
|
||||
private void disconnect(StreamSession session, String message) {
|
||||
private void detach(StreamSession session) {
|
||||
if (session == null || session.terminal.get()) {
|
||||
return;
|
||||
}
|
||||
String executeId = session.executeId;
|
||||
removeSession(session);
|
||||
if (executeId != null) {
|
||||
chainExecutor.cancel(executeId, message);
|
||||
session.detachTransport();
|
||||
if (session.detachedRetention.isZero()
|
||||
|| session.detachedRetention.isNegative()) {
|
||||
removeSession(session);
|
||||
return;
|
||||
}
|
||||
session.scheduleDetachedCleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -187,6 +264,9 @@ public class WorkflowChatEventStream {
|
||||
if (session != null && session.executeId != null) {
|
||||
sessions.remove(session.executeId, session);
|
||||
}
|
||||
if (session != null) {
|
||||
session.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -231,6 +311,11 @@ public class WorkflowChatEventStream {
|
||||
private final SseEmitter emitter;
|
||||
private final AtomicLong sequence = new AtomicLong();
|
||||
private final AtomicBoolean terminal = new AtomicBoolean(false);
|
||||
private final AtomicBoolean cleaned = new AtomicBoolean(false);
|
||||
private final AtomicBoolean connected = new AtomicBoolean(true);
|
||||
private final Runnable cleanup;
|
||||
private final Duration detachedRetention;
|
||||
private volatile ScheduledFuture<?> detachedCleanup;
|
||||
private volatile String executeId;
|
||||
|
||||
/**
|
||||
@@ -238,8 +323,36 @@ public class WorkflowChatEventStream {
|
||||
*
|
||||
* @param emitter SSE 发送器
|
||||
*/
|
||||
private StreamSession(SseEmitter emitter) {
|
||||
private StreamSession(
|
||||
SseEmitter emitter,
|
||||
Runnable cleanup,
|
||||
Duration detachedRetention
|
||||
) {
|
||||
this.emitter = emitter;
|
||||
this.cleanup = cleanup == null ? () -> {
|
||||
} : cleanup;
|
||||
this.detachedRetention = detachedRetention == null
|
||||
? Duration.ZERO
|
||||
: detachedRetention;
|
||||
}
|
||||
|
||||
/**
|
||||
* 幂等释放当前流持有的外部资源。
|
||||
*/
|
||||
private void cleanup() {
|
||||
if (!cleaned.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
cancelDetachedCleanup();
|
||||
try {
|
||||
cleanup.run();
|
||||
} catch (RuntimeException error) {
|
||||
log.warn(
|
||||
"workflow chat stream cleanup failed, executeId={}",
|
||||
executeId,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -251,6 +364,38 @@ public class WorkflowChatEventStream {
|
||||
this.executeId = executeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记浏览器传输已经断开,后续事件只推进 Runtime 清理。
|
||||
*/
|
||||
private void detachTransport() {
|
||||
connected.set(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 浏览器断开后按活动租约安排会话兜底清理。
|
||||
*/
|
||||
private synchronized void scheduleDetachedCleanup() {
|
||||
if (detachedCleanup != null || cleaned.get()) {
|
||||
return;
|
||||
}
|
||||
detachedCleanup = detachedSessionCleaner.schedule(
|
||||
() -> removeSession(this),
|
||||
Math.max(1L, detachedRetention.toMillis()),
|
||||
TimeUnit.MILLISECONDS
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消尚未触发的断开会话兜底任务。
|
||||
*/
|
||||
private synchronized void cancelDetachedCleanup() {
|
||||
if (detachedCleanup == null) {
|
||||
return;
|
||||
}
|
||||
detachedCleanup.cancel(false);
|
||||
detachedCleanup = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理节点开始事件。
|
||||
*
|
||||
@@ -424,7 +569,9 @@ public class WorkflowChatEventStream {
|
||||
}
|
||||
send(eventType, data);
|
||||
removeSession(this);
|
||||
emitter.complete();
|
||||
if (connected.compareAndSet(true, false)) {
|
||||
emitter.complete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -434,6 +581,9 @@ public class WorkflowChatEventStream {
|
||||
* @param data 事件数据
|
||||
*/
|
||||
private void send(String type, Map<String, ?> data) {
|
||||
if (!connected.get()) {
|
||||
return;
|
||||
}
|
||||
long nextSequence = sequence.incrementAndGet();
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("eventId", executeId + ":" + nextSequence);
|
||||
@@ -453,7 +603,7 @@ public class WorkflowChatEventStream {
|
||||
executeId,
|
||||
error
|
||||
);
|
||||
disconnect(this, "运行连接已断开");
|
||||
detach(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,7 +618,9 @@ public class WorkflowChatEventStream {
|
||||
"message", safeErrorMessage(error)
|
||||
));
|
||||
removeSession(this);
|
||||
emitter.completeWithError(error);
|
||||
if (connected.compareAndSet(true, false)) {
|
||||
emitter.completeWithError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package tech.easyflow.admin.service.ai;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.script.DefaultRedisScript;
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.common.cache.RedisLockExecutor;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 工作流匿名分享的限流与活动执行互斥保护。
|
||||
*/
|
||||
@Component
|
||||
public class WorkflowPublicChatAccessGuard {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(
|
||||
WorkflowPublicChatAccessGuard.class);
|
||||
private static final String KEY_PREFIX = "easyflow:workflow-public-share:";
|
||||
private static final DefaultRedisScript<Long> RATE_LIMIT_SCRIPT;
|
||||
|
||||
static {
|
||||
RATE_LIMIT_SCRIPT = new DefaultRedisScript<>();
|
||||
RATE_LIMIT_SCRIPT.setScriptText(
|
||||
"local visitor = redis.call('incr', KEYS[1]); "
|
||||
+ "if visitor == 1 then redis.call('pexpire', KEYS[1], ARGV[3]); end; "
|
||||
+ "local share = redis.call('incr', KEYS[2]); "
|
||||
+ "if share == 1 then redis.call('pexpire', KEYS[2], ARGV[3]); end; "
|
||||
+ "if visitor > tonumber(ARGV[1]) or share > tonumber(ARGV[2]) "
|
||||
+ "then return 0 else return 1 end"
|
||||
);
|
||||
RATE_LIMIT_SCRIPT.setResultType(Long.class);
|
||||
}
|
||||
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
private final RedisLockExecutor redisLockExecutor;
|
||||
private final WorkflowPublicShareProperties properties;
|
||||
|
||||
public WorkflowPublicChatAccessGuard(
|
||||
StringRedisTemplate redisTemplate,
|
||||
RedisLockExecutor redisLockExecutor,
|
||||
WorkflowPublicShareProperties properties
|
||||
) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.redisLockExecutor = redisLockExecutor;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查匿名运行固定窗口限流。
|
||||
*/
|
||||
public void checkRun(BigInteger shareId, String visitorDigest) {
|
||||
checkRate(
|
||||
shareId,
|
||||
visitorDigest,
|
||||
"run",
|
||||
properties.getRunVisitorLimit(),
|
||||
properties.getRunShareLimit()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查匿名上传固定窗口限流。
|
||||
*/
|
||||
public void checkUpload(BigInteger shareId, String visitorDigest) {
|
||||
checkRate(
|
||||
shareId,
|
||||
visitorDigest,
|
||||
"upload",
|
||||
properties.getUploadVisitorLimit(),
|
||||
properties.getUploadShareLimit()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取同一分享访客的活动执行锁。
|
||||
*
|
||||
* @return 由 SSE 生命周期显式释放的锁句柄
|
||||
*/
|
||||
public RedisLockExecutor.LockHandle acquireActivity(
|
||||
BigInteger shareId,
|
||||
String visitorDigest
|
||||
) {
|
||||
try {
|
||||
RedisLockExecutor.LockHandle handle = redisLockExecutor.tryAcquire(
|
||||
KEY_PREFIX + "{" + shareId + "}:active:" + visitorDigest,
|
||||
Duration.ZERO,
|
||||
properties.getActiveLease()
|
||||
);
|
||||
if (handle == null) {
|
||||
throw new BusinessException(
|
||||
409,
|
||||
40931,
|
||||
"当前分享访客已有工作流正在运行"
|
||||
);
|
||||
}
|
||||
return handle;
|
||||
} catch (BusinessException exception) {
|
||||
throw exception;
|
||||
} catch (RuntimeException exception) {
|
||||
log.error("匿名工作流活动锁暂不可用,shareId={}", shareId, exception);
|
||||
throw unavailable(exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取匿名活动执行锁的租约,用作浏览器断开后的监听保留上限。
|
||||
*/
|
||||
public Duration activityLease() {
|
||||
return properties.getActiveLease();
|
||||
}
|
||||
|
||||
private void checkRate(
|
||||
BigInteger shareId,
|
||||
String visitorDigest,
|
||||
String action,
|
||||
int visitorLimit,
|
||||
int shareLimit
|
||||
) {
|
||||
String slot = "{" + shareId + "}";
|
||||
List<String> keys = List.of(
|
||||
KEY_PREFIX + slot + ":rate:" + action + ":visitor:" + visitorDigest,
|
||||
KEY_PREFIX + slot + ":rate:" + action + ":share"
|
||||
);
|
||||
try {
|
||||
Long allowed = redisTemplate.execute(
|
||||
RATE_LIMIT_SCRIPT,
|
||||
keys,
|
||||
String.valueOf(visitorLimit),
|
||||
String.valueOf(shareLimit),
|
||||
String.valueOf(properties.getRateWindow().toMillis())
|
||||
);
|
||||
if (allowed == null) {
|
||||
throw unavailable(new IllegalStateException(
|
||||
"Redis 未返回匿名工作流限流结果"));
|
||||
}
|
||||
if (!Long.valueOf(1L).equals(allowed)) {
|
||||
throw new BusinessException(
|
||||
429,
|
||||
42931,
|
||||
"匿名工作流请求过于频繁,请稍后重试"
|
||||
);
|
||||
}
|
||||
} catch (BusinessException exception) {
|
||||
throw exception;
|
||||
} catch (RuntimeException exception) {
|
||||
log.error("匿名工作流限流暂不可用,shareId={}, action={}",
|
||||
shareId, action, exception);
|
||||
throw unavailable(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private BusinessException unavailable(RuntimeException cause) {
|
||||
return new BusinessException(
|
||||
503,
|
||||
50331,
|
||||
"匿名工作流保护服务暂不可用,请稍后重试",
|
||||
cause
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package tech.easyflow.admin.service.ai;
|
||||
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.ai.entity.WorkflowShare;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
|
||||
/**
|
||||
* 完成匿名分享边界校验后的运行上下文。
|
||||
*/
|
||||
public record WorkflowPublicChatContext(
|
||||
WorkflowShare share,
|
||||
Workflow workflow,
|
||||
LoginAccount creator,
|
||||
String shareKey,
|
||||
String visitorDigest
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package tech.easyflow.admin.service.ai;
|
||||
|
||||
import com.mybatisflex.core.tenant.TenantManager;
|
||||
import org.springframework.stereotype.Service;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.ai.entity.WorkflowShare;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
import tech.easyflow.ai.service.WorkflowService;
|
||||
import tech.easyflow.ai.service.WorkflowShareService;
|
||||
import tech.easyflow.ai.share.WorkflowSharePolicy;
|
||||
import tech.easyflow.common.constant.enums.EnumDataStatus;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.system.entity.SysAccount;
|
||||
import tech.easyflow.system.service.SysAccountService;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 解析并校验工作流匿名分享上下文。
|
||||
*/
|
||||
@Service
|
||||
public class WorkflowPublicChatContextResolver {
|
||||
|
||||
private static final Pattern VISITOR_PATTERN = Pattern.compile("[a-f0-9]{32}");
|
||||
|
||||
private final WorkflowShareService shareService;
|
||||
private final WorkflowService workflowService;
|
||||
private final SysAccountService accountService;
|
||||
|
||||
public WorkflowPublicChatContextResolver(
|
||||
WorkflowShareService shareService,
|
||||
WorkflowService workflowService,
|
||||
SysAccountService accountService
|
||||
) {
|
||||
this.shareService = shareService;
|
||||
this.workflowService = workflowService;
|
||||
this.accountService = accountService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析新运行、恢复与上传所需的当前有效上下文。
|
||||
*/
|
||||
public WorkflowPublicChatContext resolveActive(
|
||||
String shareKey,
|
||||
String visitorId
|
||||
) {
|
||||
String normalizedVisitor = requireVisitor(visitorId);
|
||||
WorkflowShare share = shareService.resolvePublicChatShare(shareKey);
|
||||
Workflow workflow = TenantManager.withoutTenantCondition(
|
||||
() -> workflowService.getPublishedById(share.getWorkflowId()));
|
||||
if (!isStrictlyPublished(workflow)
|
||||
|| !Objects.equals(share.getTenantId(), workflow.getTenantId())) {
|
||||
throw new BusinessException(409, 409, "工作流尚未发布或已下线");
|
||||
}
|
||||
SysAccount account = TenantManager.withoutTenantCondition(
|
||||
() -> accountService.getById(share.getCreatedBy()));
|
||||
if (account == null
|
||||
|| !EnumDataStatus.AVAILABLE.getCode().equals(account.getStatus())
|
||||
|| !Objects.equals(share.getTenantId(), account.getTenantId())) {
|
||||
throw new BusinessException(
|
||||
403,
|
||||
40331,
|
||||
"工作流分享创建者账号当前不可用"
|
||||
);
|
||||
}
|
||||
LoginAccount creator = account.toLoginAccount();
|
||||
return new WorkflowPublicChatContext(
|
||||
share,
|
||||
workflow,
|
||||
creator,
|
||||
shareKey,
|
||||
WorkflowSharePolicy.hashChatVisitor(
|
||||
shareKey,
|
||||
normalizedVisitor
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析已发起执行的详情与取消所需历史上下文。
|
||||
*/
|
||||
public WorkflowPublicChatContext resolveHistorical(
|
||||
String shareKey,
|
||||
String visitorId
|
||||
) {
|
||||
String normalizedVisitor = requireVisitor(visitorId);
|
||||
WorkflowShare share = shareService.resolveHistoricalChatShare(shareKey);
|
||||
return new WorkflowPublicChatContext(
|
||||
share,
|
||||
null,
|
||||
null,
|
||||
shareKey,
|
||||
WorkflowSharePolicy.hashChatVisitor(
|
||||
shareKey,
|
||||
normalizedVisitor
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private String requireVisitor(String visitorId) {
|
||||
String normalized = visitorId == null ? "" : visitorId.trim();
|
||||
if (!VISITOR_PATTERN.matcher(normalized).matches()) {
|
||||
throw new BusinessException(
|
||||
400,
|
||||
40031,
|
||||
"工作流分享访客标识无效"
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private boolean isStrictlyPublished(Workflow workflow) {
|
||||
return workflow != null
|
||||
&& PublishStatus.PUBLISHED.getCode().equals(
|
||||
workflow.getPublishStatus())
|
||||
&& workflow.getPublishedSnapshotJson() != null
|
||||
&& !workflow.getPublishedSnapshotJson().isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package tech.easyflow.admin.service.ai;
|
||||
|
||||
import com.easyagents.flow.core.chain.ChainStatus;
|
||||
import com.easyagents.flow.core.chain.ChainState;
|
||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import com.mybatisflex.core.tenant.TenantManager;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
||||
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
|
||||
import tech.easyflow.ai.entity.WorkflowExecResult;
|
||||
import tech.easyflow.ai.entity.WorkflowExecStep;
|
||||
import tech.easyflow.ai.service.WorkflowExecResultService;
|
||||
import tech.easyflow.ai.service.WorkflowExecStepService;
|
||||
import tech.easyflow.ai.utils.WorkFlowUtil;
|
||||
import tech.easyflow.common.cache.RedisLockExecutor;
|
||||
import tech.easyflow.common.constant.Constants;
|
||||
import tech.easyflow.common.vo.UploadResVo;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 工作流匿名分享对话应用服务。
|
||||
*/
|
||||
@Service
|
||||
public class WorkflowPublicChatService {
|
||||
|
||||
private static final Logger log =
|
||||
LoggerFactory.getLogger(WorkflowPublicChatService.class);
|
||||
|
||||
private final WorkflowPublicChatContextResolver contextResolver;
|
||||
private final WorkflowCheckService workflowCheckService;
|
||||
private final WorkflowRunningParameterResolver parameterResolver;
|
||||
private final WorkflowPublicChatUploadService uploadService;
|
||||
private final WorkflowPublicChatAccessGuard accessGuard;
|
||||
private final WorkflowChatEventStream eventStream;
|
||||
private final ChainExecutor chainExecutor;
|
||||
private final WorkflowExecResultService execResultService;
|
||||
private final WorkflowExecStepService execStepService;
|
||||
|
||||
public WorkflowPublicChatService(
|
||||
WorkflowPublicChatContextResolver contextResolver,
|
||||
WorkflowCheckService workflowCheckService,
|
||||
WorkflowRunningParameterResolver parameterResolver,
|
||||
WorkflowPublicChatUploadService uploadService,
|
||||
WorkflowPublicChatAccessGuard accessGuard,
|
||||
WorkflowChatEventStream eventStream,
|
||||
ChainExecutor chainExecutor,
|
||||
WorkflowExecResultService execResultService,
|
||||
WorkflowExecStepService execStepService
|
||||
) {
|
||||
this.contextResolver = contextResolver;
|
||||
this.workflowCheckService = workflowCheckService;
|
||||
this.parameterResolver = parameterResolver;
|
||||
this.uploadService = uploadService;
|
||||
this.accessGuard = accessGuard;
|
||||
this.eventStream = eventStream;
|
||||
this.chainExecutor = chainExecutor;
|
||||
this.execResultService = execResultService;
|
||||
this.execStepService = execStepService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取匿名分享的发布工作流描述。
|
||||
*/
|
||||
public Map<String, Object> descriptor(String shareKey, String visitorId) {
|
||||
WorkflowPublicChatContext context = contextResolver.resolveActive(
|
||||
shareKey, visitorId);
|
||||
checkWorkflow(context);
|
||||
Map<String, Object> descriptor = parameterResolver
|
||||
.buildRunningParametersView(context.workflow());
|
||||
if (descriptor == null) {
|
||||
throw new BusinessException("工作流输入配置无法解析");
|
||||
}
|
||||
descriptor.put("workflowId", context.workflow().getId());
|
||||
descriptor.put("publishStatus", context.workflow().getPublishStatus());
|
||||
descriptor.put("shareable", false);
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动匿名分享工作流并返回 SSE。
|
||||
*/
|
||||
public SseEmitter run(
|
||||
String shareKey,
|
||||
String visitorId,
|
||||
Map<String, Object> variables
|
||||
) {
|
||||
WorkflowPublicChatContext context = contextResolver.resolveActive(
|
||||
shareKey, visitorId);
|
||||
accessGuard.checkRun(
|
||||
context.share().getId(),
|
||||
context.visitorDigest()
|
||||
);
|
||||
checkWorkflow(context);
|
||||
Map<String, Object> normalized = parameterResolver
|
||||
.normalizeRuntimeVariables(
|
||||
context.workflow().getContent(),
|
||||
variables
|
||||
);
|
||||
uploadService.assertOwnedUploads(context, normalized);
|
||||
normalized.put(Constants.LOGIN_USER_KEY, context.creator());
|
||||
normalized.put(
|
||||
WorkFlowUtil.CREATED_KEY_MEMORY_KEY,
|
||||
WorkFlowUtil.publicChatShareCreatedKey(
|
||||
context.share().getId())
|
||||
);
|
||||
normalized.put(
|
||||
WorkFlowUtil.CREATED_BY_MEMORY_KEY,
|
||||
context.visitorDigest()
|
||||
);
|
||||
|
||||
RedisLockExecutor.LockHandle activity = accessGuard.acquireActivity(
|
||||
context.share().getId(),
|
||||
context.visitorDigest()
|
||||
);
|
||||
try {
|
||||
return eventStream.start(
|
||||
PublishedWorkflowDefinitionIds.published(
|
||||
context.workflow().getId().toString()),
|
||||
normalized,
|
||||
activity::release,
|
||||
accessGuard.activityLease()
|
||||
);
|
||||
} catch (RuntimeException | Error error) {
|
||||
activity.release();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前匿名访客发起的执行详情。
|
||||
*/
|
||||
public Map<String, Object> detail(
|
||||
String shareKey,
|
||||
String visitorId,
|
||||
String executeId
|
||||
) {
|
||||
WorkflowPublicChatContext context = contextResolver.resolveHistorical(
|
||||
shareKey, visitorId);
|
||||
WorkflowExecResult record = assertExecutionOwnership(
|
||||
context, executeId);
|
||||
List<WorkflowExecStep> steps = TenantManager.withoutTenantCondition(
|
||||
() -> execStepService.list(
|
||||
QueryWrapper.create()
|
||||
.eq(WorkflowExecStep::getRecordId, record.getId())
|
||||
.orderBy(WorkflowExecStep::getStartTime, true)
|
||||
));
|
||||
return buildExecutionDetail(record, steps, runtimeView(executeId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消当前匿名访客发起的执行。
|
||||
*/
|
||||
public boolean cancel(
|
||||
String shareKey,
|
||||
String visitorId,
|
||||
String executeId
|
||||
) {
|
||||
WorkflowPublicChatContext context = contextResolver.resolveHistorical(
|
||||
shareKey, visitorId);
|
||||
assertExecutionOwnership(context, executeId);
|
||||
return chainExecutor.cancel(executeId, "匿名访客已中止运行");
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复当前有效分享访客等待确认的执行。
|
||||
*/
|
||||
public void resume(
|
||||
String shareKey,
|
||||
String visitorId,
|
||||
String executeId,
|
||||
Map<String, Object> confirmParams
|
||||
) {
|
||||
WorkflowPublicChatContext context = contextResolver.resolveActive(
|
||||
shareKey, visitorId);
|
||||
WorkflowExecResult record = assertExecutionOwnership(
|
||||
context, executeId);
|
||||
if (isTerminal(record.getStatus())) {
|
||||
throw new BusinessException("当前工作流执行已结束");
|
||||
}
|
||||
chainExecutor.resumeAsync(
|
||||
executeId,
|
||||
confirmParams == null
|
||||
? new LinkedHashMap<>()
|
||||
: new LinkedHashMap<>(confirmParams)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传当前发布快照声明的匿名输入文件。
|
||||
*/
|
||||
public UploadResVo upload(
|
||||
String shareKey,
|
||||
String visitorId,
|
||||
String parameterName,
|
||||
MultipartFile file
|
||||
) {
|
||||
WorkflowPublicChatContext context = contextResolver.resolveActive(
|
||||
shareKey, visitorId);
|
||||
return uploadService.upload(context, parameterName, file);
|
||||
}
|
||||
|
||||
private void checkWorkflow(WorkflowPublicChatContext context) {
|
||||
TenantManager.withoutTenantCondition(() -> {
|
||||
workflowCheckService.checkOrThrow(
|
||||
context.workflow().getContent(),
|
||||
WorkflowCheckStage.PRE_EXECUTE,
|
||||
context.workflow().getId()
|
||||
);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
private WorkflowExecResult assertExecutionOwnership(
|
||||
WorkflowPublicChatContext context,
|
||||
String executeId
|
||||
) {
|
||||
if (executeId == null || executeId.isBlank()) {
|
||||
throw new BusinessException("执行ID不能为空");
|
||||
}
|
||||
WorkflowExecResult record = TenantManager.withoutTenantCondition(
|
||||
() -> execResultService.getByExecKey(executeId));
|
||||
if (record == null) {
|
||||
throw new BusinessException("工作流执行记录不存在,请稍后重试");
|
||||
}
|
||||
String expectedSource = WorkFlowUtil.publicChatShareCreatedKey(
|
||||
context.share().getId());
|
||||
if (!Objects.equals(expectedSource, record.getCreatedKey())
|
||||
|| !Objects.equals(
|
||||
context.visitorDigest(),
|
||||
record.getCreatedBy())
|
||||
|| !Objects.equals(
|
||||
context.share().getWorkflowId(),
|
||||
record.getWorkflowId())) {
|
||||
throw new BusinessException(
|
||||
403,
|
||||
40333,
|
||||
"无权限访问当前工作流执行记录"
|
||||
);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
private boolean isTerminal(Integer status) {
|
||||
return status != null
|
||||
&& (status == ChainStatus.SUCCEEDED.getValue()
|
||||
|| status == ChainStatus.FAILED.getValue()
|
||||
|| status == ChainStatus.CANCELLED.getValue());
|
||||
}
|
||||
|
||||
private Map<String, Object> buildExecutionDetail(
|
||||
WorkflowExecResult record,
|
||||
List<WorkflowExecStep> steps,
|
||||
Map<String, Object> runtime
|
||||
) {
|
||||
List<Map<String, Object>> stepViews = new ArrayList<>(steps.size());
|
||||
for (WorkflowExecStep step : steps) {
|
||||
Map<String, Object> view = new LinkedHashMap<>();
|
||||
view.put("id", step.getId());
|
||||
view.put("attemptKey", step.getExecKey());
|
||||
view.put("nodeId", step.getNodeId());
|
||||
view.put("nodeName", step.getNodeName());
|
||||
view.put("input", step.getInput());
|
||||
view.put("output", step.getOutput());
|
||||
view.put("status", step.getStatus());
|
||||
view.put("errorInfo", step.getErrorInfo());
|
||||
view.put("startTime", step.getStartTime());
|
||||
view.put("endTime", step.getEndTime());
|
||||
view.put("execTime", step.getExecTime());
|
||||
stepViews.add(view);
|
||||
}
|
||||
|
||||
Map<String, Object> recordView = new LinkedHashMap<>();
|
||||
recordView.put("executeId", record.getExecKey());
|
||||
recordView.put("workflowId", record.getWorkflowId());
|
||||
recordView.put("title", record.getTitle());
|
||||
recordView.put("status", record.getStatus());
|
||||
recordView.put("input", record.getInput());
|
||||
recordView.put("output", record.getOutput());
|
||||
recordView.put("errorInfo", record.getErrorInfo());
|
||||
recordView.put("startTime", record.getStartTime());
|
||||
recordView.put("endTime", record.getEndTime());
|
||||
recordView.put("execTime", record.getExecTime());
|
||||
|
||||
Map<String, Object> detail = new LinkedHashMap<>();
|
||||
detail.put("record", recordView);
|
||||
detail.put("steps", stepViews);
|
||||
detail.put("runtime", runtime);
|
||||
return detail;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建刷新恢复所需的最小 Runtime 视图。
|
||||
*/
|
||||
private Map<String, Object> runtimeView(String executeId) {
|
||||
try {
|
||||
ChainState state = chainExecutor.getChainStateRepository()
|
||||
.load(executeId);
|
||||
if (state == null || state.getStatus() == null) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<String, Object> view = new LinkedHashMap<>();
|
||||
view.put("status", state.getStatus().name());
|
||||
view.put("statusValue", state.getStatus().getValue());
|
||||
view.put("message", state.getMessage());
|
||||
if (state.getStatus() == ChainStatus.SUSPEND) {
|
||||
view.put("parameters", state.getSuspendForParameters());
|
||||
}
|
||||
if (state.getStatus() == ChainStatus.SUCCEEDED) {
|
||||
view.put(
|
||||
"output",
|
||||
WorkflowChatEventStream.visibleFinalOutput(
|
||||
state.getExecuteResult())
|
||||
);
|
||||
}
|
||||
return view;
|
||||
} catch (RuntimeException error) {
|
||||
log.warn(
|
||||
"failed to load public workflow runtime state, executeId={}",
|
||||
executeId,
|
||||
error
|
||||
);
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package tech.easyflow.admin.service.ai;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
||||
import tech.easyflow.ai.share.WorkflowSharePolicy;
|
||||
import tech.easyflow.common.filestorage.FileStorageService;
|
||||
import tech.easyflow.common.vo.UploadResVo;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Collection;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 工作流匿名分享的隔离上传与运行引用校验。
|
||||
*/
|
||||
@Service
|
||||
public class WorkflowPublicChatUploadService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(
|
||||
WorkflowPublicChatUploadService.class);
|
||||
private static final long FILE_MAX_SIZE = 100L * 1024L * 1024L;
|
||||
private static final long IMAGE_MAX_SIZE = 10L * 1024L * 1024L;
|
||||
private static final Set<String> IMAGE_MIME_TYPES = Set.of(
|
||||
"image/bmp", "image/gif", "image/jpeg", "image/png", "image/webp");
|
||||
private static final Set<String> IMAGE_EXTENSIONS = Set.of(
|
||||
"bmp", "gif", "jpeg", "jpg", "png", "webp");
|
||||
private static final String GRANT_PREFIX = "easyflow:workflow-public-share:upload:";
|
||||
|
||||
private final WorkflowRunningParameterResolver parameterResolver;
|
||||
private final WorkflowPublicChatAccessGuard accessGuard;
|
||||
private final WorkflowPublicShareProperties properties;
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
private final FileStorageService storageService;
|
||||
|
||||
public WorkflowPublicChatUploadService(
|
||||
WorkflowRunningParameterResolver parameterResolver,
|
||||
WorkflowPublicChatAccessGuard accessGuard,
|
||||
WorkflowPublicShareProperties properties,
|
||||
StringRedisTemplate redisTemplate,
|
||||
@Qualifier("default") FileStorageService storageService
|
||||
) {
|
||||
this.parameterResolver = parameterResolver;
|
||||
this.accessGuard = accessGuard;
|
||||
this.properties = properties;
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.storageService = storageService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传发布快照声明的文件或图片参数。
|
||||
*/
|
||||
public UploadResVo upload(
|
||||
WorkflowPublicChatContext context,
|
||||
String parameterName,
|
||||
MultipartFile file
|
||||
) {
|
||||
String normalizedName = requireParameterName(parameterName);
|
||||
String contentType = resolveUploadContentType(context, normalizedName);
|
||||
validateFile(file, contentType);
|
||||
accessGuard.checkUpload(
|
||||
context.share().getId(),
|
||||
context.visitorDigest()
|
||||
);
|
||||
|
||||
String path = storageService.save(
|
||||
file,
|
||||
"workflow-chat-share/" + context.share().getId()
|
||||
+ "/" + context.visitorDigest()
|
||||
);
|
||||
if (!StringUtils.hasText(path)) {
|
||||
throw new BusinessException(503, 50332, "匿名文件上传失败,请稍后重试");
|
||||
}
|
||||
try {
|
||||
redisTemplate.opsForValue().set(
|
||||
grantKey(context, normalizedName, path),
|
||||
contentType,
|
||||
grantTtl(context).toMillis(),
|
||||
TimeUnit.MILLISECONDS
|
||||
);
|
||||
} catch (RuntimeException exception) {
|
||||
try {
|
||||
storageService.delete(path);
|
||||
} catch (RuntimeException cleanupError) {
|
||||
log.warn("匿名上传授权写入失败后清理文件失败,path={}",
|
||||
path, cleanupError);
|
||||
}
|
||||
throw new BusinessException(
|
||||
503,
|
||||
50332,
|
||||
"匿名上传保护服务暂不可用,请稍后重试",
|
||||
exception
|
||||
);
|
||||
}
|
||||
|
||||
UploadResVo response = new UploadResVo();
|
||||
response.setPath(path);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验公开运行引用的上传文件均属于当前分享访客和参数。
|
||||
*/
|
||||
public void assertOwnedUploads(
|
||||
WorkflowPublicChatContext context,
|
||||
Map<String, Object> variables
|
||||
) {
|
||||
Map<String, String> uploadFields = resolveUploadFields(context);
|
||||
for (Map.Entry<String, String> entry : uploadFields.entrySet()) {
|
||||
Object value = variables.get(entry.getKey());
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
if ("image".equals(entry.getValue())) {
|
||||
assertOwnedImage(context, entry.getKey(), value);
|
||||
} else {
|
||||
assertOwnedFiles(context, entry.getKey(), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void assertOwnedImage(
|
||||
WorkflowPublicChatContext context,
|
||||
String parameterName,
|
||||
Object value
|
||||
) {
|
||||
if (!(value instanceof Map<?, ?> image)) {
|
||||
throw invalidUploadReference(parameterName);
|
||||
}
|
||||
String sourceType = trim(image.get("sourceType"));
|
||||
if ("url".equals(sourceType)) {
|
||||
String url = trim(image.get("url"));
|
||||
if (isHttpUrl(url)) {
|
||||
return;
|
||||
}
|
||||
throw invalidUploadReference(parameterName);
|
||||
}
|
||||
if (!"upload".equals(sourceType)) {
|
||||
throw invalidUploadReference(parameterName);
|
||||
}
|
||||
assertGrant(
|
||||
context,
|
||||
parameterName,
|
||||
trim(image.get("filePath")),
|
||||
"image"
|
||||
);
|
||||
}
|
||||
|
||||
private void assertOwnedFiles(
|
||||
WorkflowPublicChatContext context,
|
||||
String parameterName,
|
||||
Object value
|
||||
) {
|
||||
if (!(value instanceof Collection<?> files)) {
|
||||
throw invalidUploadReference(parameterName);
|
||||
}
|
||||
for (Object item : files) {
|
||||
if (!(item instanceof Map<?, ?> file)) {
|
||||
throw invalidUploadReference(parameterName);
|
||||
}
|
||||
assertGrant(
|
||||
context,
|
||||
parameterName,
|
||||
trim(file.get("filePath")),
|
||||
"file"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void assertGrant(
|
||||
WorkflowPublicChatContext context,
|
||||
String parameterName,
|
||||
String path,
|
||||
String expectedContentType
|
||||
) {
|
||||
if (!StringUtils.hasText(path)) {
|
||||
throw invalidUploadReference(parameterName);
|
||||
}
|
||||
try {
|
||||
String grantedContentType = redisTemplate.opsForValue().get(
|
||||
grantKey(context, parameterName, path));
|
||||
if (!expectedContentType.equals(grantedContentType)) {
|
||||
throw invalidUploadReference(parameterName);
|
||||
}
|
||||
} catch (BusinessException exception) {
|
||||
throw exception;
|
||||
} catch (RuntimeException exception) {
|
||||
throw new BusinessException(
|
||||
503,
|
||||
50332,
|
||||
"匿名上传保护服务暂不可用,请稍后重试",
|
||||
exception
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveUploadContentType(
|
||||
WorkflowPublicChatContext context,
|
||||
String parameterName
|
||||
) {
|
||||
String contentType = resolveUploadFields(context).get(parameterName);
|
||||
if (contentType == null) {
|
||||
throw new BusinessException(
|
||||
400,
|
||||
40032,
|
||||
"当前发布工作流未声明该上传参数"
|
||||
);
|
||||
}
|
||||
return contentType;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, String> resolveUploadFields(
|
||||
WorkflowPublicChatContext context
|
||||
) {
|
||||
Map<String, Object> descriptor = parameterResolver
|
||||
.buildRunningParametersView(context.workflow());
|
||||
if (descriptor == null) {
|
||||
throw new BusinessException("工作流输入配置无法解析");
|
||||
}
|
||||
Map<String, String> fields = new java.util.LinkedHashMap<>();
|
||||
Object rawSchema = descriptor.get("startFormSchema");
|
||||
if (!(rawSchema instanceof Collection<?> schema)) {
|
||||
return fields;
|
||||
}
|
||||
for (Object item : schema) {
|
||||
if (!(item instanceof Map<?, ?> field)) {
|
||||
continue;
|
||||
}
|
||||
String name = trim(field.get("key"));
|
||||
String contentType = trim(field.get("contentType"));
|
||||
if (StringUtils.hasText(name)
|
||||
&& ("file".equals(contentType)
|
||||
|| "image".equals(contentType))) {
|
||||
fields.put(name, contentType);
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
private void validateFile(MultipartFile file, String contentType) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new BusinessException("上传文件不能为空");
|
||||
}
|
||||
long maxSize = "image".equals(contentType)
|
||||
? IMAGE_MAX_SIZE
|
||||
: FILE_MAX_SIZE;
|
||||
if (file.getSize() > maxSize) {
|
||||
throw new BusinessException(
|
||||
"image".equals(contentType)
|
||||
? "单张图片不能超过 10 MiB"
|
||||
: "单个文件不能超过 100 MiB"
|
||||
);
|
||||
}
|
||||
if (!"image".equals(contentType)) {
|
||||
return;
|
||||
}
|
||||
String mimeType = trim(file.getContentType()).toLowerCase(Locale.ROOT);
|
||||
String filename = trim(file.getOriginalFilename());
|
||||
int dot = filename.lastIndexOf('.');
|
||||
String extension = dot < 0
|
||||
? ""
|
||||
: filename.substring(dot + 1).toLowerCase(Locale.ROOT);
|
||||
if (!IMAGE_MIME_TYPES.contains(mimeType)
|
||||
&& !IMAGE_EXTENSIONS.contains(extension)) {
|
||||
throw new BusinessException("仅支持 PNG、JPEG、WebP、GIF、BMP 图片");
|
||||
}
|
||||
}
|
||||
|
||||
private Duration grantTtl(WorkflowPublicChatContext context) {
|
||||
long expiresIn = context.share().getExpiresAt().getTime()
|
||||
- System.currentTimeMillis();
|
||||
long ttl = Math.min(
|
||||
properties.getUploadGrantTtl().toMillis(),
|
||||
expiresIn
|
||||
);
|
||||
return Duration.ofMillis(Math.max(1L, ttl));
|
||||
}
|
||||
|
||||
private String grantKey(
|
||||
WorkflowPublicChatContext context,
|
||||
String parameterName,
|
||||
String path
|
||||
) {
|
||||
return GRANT_PREFIX + "{" + context.share().getId() + "}:"
|
||||
+ context.visitorDigest() + ":"
|
||||
+ WorkflowSharePolicy.hashShareKey(parameterName) + ":"
|
||||
+ WorkflowSharePolicy.hashShareKey(path);
|
||||
}
|
||||
|
||||
private String requireParameterName(String value) {
|
||||
String normalized = value == null ? "" : value.trim();
|
||||
if (!StringUtils.hasText(normalized)) {
|
||||
throw new BusinessException("上传参数名不能为空");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String trim(Object value) {
|
||||
return value == null ? "" : String.valueOf(value).trim();
|
||||
}
|
||||
|
||||
private boolean isHttpUrl(String value) {
|
||||
String normalized = value == null ? "" : value.toLowerCase(Locale.ROOT);
|
||||
return normalized.startsWith("http://")
|
||||
|| normalized.startsWith("https://");
|
||||
}
|
||||
|
||||
private BusinessException invalidUploadReference(String parameterName) {
|
||||
return new BusinessException(
|
||||
403,
|
||||
40332,
|
||||
"上传参数 " + parameterName + " 不属于当前分享访客"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package tech.easyflow.admin.service.ai;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* 工作流匿名分享运行保护参数。
|
||||
*/
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "easyflow.workflow.public-share")
|
||||
public class WorkflowPublicShareProperties {
|
||||
|
||||
private Duration rateWindow = Duration.ofMinutes(1);
|
||||
private int runVisitorLimit = 5;
|
||||
private int runShareLimit = 60;
|
||||
private int uploadVisitorLimit = 10;
|
||||
private int uploadShareLimit = 60;
|
||||
private Duration activeLease = Duration.ofMinutes(35);
|
||||
private Duration uploadGrantTtl = Duration.ofDays(7);
|
||||
|
||||
public Duration getRateWindow() {
|
||||
return rateWindow;
|
||||
}
|
||||
|
||||
public void setRateWindow(Duration rateWindow) {
|
||||
this.rateWindow = requirePositive(rateWindow, "rateWindow");
|
||||
}
|
||||
|
||||
public int getRunVisitorLimit() {
|
||||
return runVisitorLimit;
|
||||
}
|
||||
|
||||
public void setRunVisitorLimit(int runVisitorLimit) {
|
||||
this.runVisitorLimit = requirePositive(runVisitorLimit, "runVisitorLimit");
|
||||
}
|
||||
|
||||
public int getRunShareLimit() {
|
||||
return runShareLimit;
|
||||
}
|
||||
|
||||
public void setRunShareLimit(int runShareLimit) {
|
||||
this.runShareLimit = requirePositive(runShareLimit, "runShareLimit");
|
||||
}
|
||||
|
||||
public int getUploadVisitorLimit() {
|
||||
return uploadVisitorLimit;
|
||||
}
|
||||
|
||||
public void setUploadVisitorLimit(int uploadVisitorLimit) {
|
||||
this.uploadVisitorLimit = requirePositive(uploadVisitorLimit, "uploadVisitorLimit");
|
||||
}
|
||||
|
||||
public int getUploadShareLimit() {
|
||||
return uploadShareLimit;
|
||||
}
|
||||
|
||||
public void setUploadShareLimit(int uploadShareLimit) {
|
||||
this.uploadShareLimit = requirePositive(uploadShareLimit, "uploadShareLimit");
|
||||
}
|
||||
|
||||
public Duration getActiveLease() {
|
||||
return activeLease;
|
||||
}
|
||||
|
||||
public void setActiveLease(Duration activeLease) {
|
||||
this.activeLease = requirePositive(activeLease, "activeLease");
|
||||
}
|
||||
|
||||
public Duration getUploadGrantTtl() {
|
||||
return uploadGrantTtl;
|
||||
}
|
||||
|
||||
public void setUploadGrantTtl(Duration uploadGrantTtl) {
|
||||
this.uploadGrantTtl = requirePositive(uploadGrantTtl, "uploadGrantTtl");
|
||||
}
|
||||
|
||||
private static int requirePositive(int value, String name) {
|
||||
if (value <= 0) {
|
||||
throw new IllegalArgumentException(name + " 必须大于 0");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static Duration requirePositive(Duration value, String name) {
|
||||
if (value == null || value.isZero() || value.isNegative()) {
|
||||
throw new IllegalArgumentException(name + " 必须大于 0");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,50 @@ package tech.easyflow.admin.controller.ai;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.testng.Assert;
|
||||
import org.testng.annotations.Test;
|
||||
import tech.easyflow.ai.entity.WorkflowShare;
|
||||
import tech.easyflow.ai.service.WorkflowShareService;
|
||||
import tech.easyflow.ai.share.WorkflowSharePolicy;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.math.BigInteger;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* {@link WorkflowShareController} 分享地址构建测试。
|
||||
*/
|
||||
public class WorkflowShareControllerTest {
|
||||
|
||||
/**
|
||||
* 验证分享解析仅依赖分享密钥,不读取当前浏览器登录租户。
|
||||
*/
|
||||
@Test
|
||||
public void shouldResolvePublicChatShareWithoutLoginContext()
|
||||
throws Exception {
|
||||
WorkflowShareService shareService = mock(WorkflowShareService.class);
|
||||
WorkflowShare share = new WorkflowShare();
|
||||
share.setWorkflowId(BigInteger.valueOf(11));
|
||||
when(shareService.resolvePublicChatShare("share-key"))
|
||||
.thenReturn(share);
|
||||
WorkflowShareController controller = new WorkflowShareController();
|
||||
setField(controller, "workflowShareService", shareService);
|
||||
|
||||
BigInteger workflowId = controller.resolveUrlShare(request(Map.of(
|
||||
WorkflowSharePolicy.CHAT_SHARE_KEY_HEADER
|
||||
.toLowerCase(Locale.ROOT),
|
||||
"share-key"
|
||||
))).getData().get("workflowId");
|
||||
|
||||
Assert.assertEquals(workflowId, BigInteger.valueOf(11));
|
||||
verify(shareService).resolvePublicChatShare("share-key");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证分享地址保留前端部署基路径。
|
||||
*
|
||||
@@ -120,4 +153,11 @@ public class WorkflowShareControllerTest {
|
||||
}
|
||||
return 0D;
|
||||
}
|
||||
|
||||
private void setField(Object target, String name, Object value)
|
||||
throws Exception {
|
||||
Field field = target.getClass().getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,19 @@ import com.easyagents.flow.core.chain.ChainConsts;
|
||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||
import org.testng.Assert;
|
||||
import org.testng.annotations.Test;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
@@ -69,4 +75,81 @@ public class WorkflowChatEventStreamTest {
|
||||
WorkflowChatEventStream.visibleFinalOutput(null).isEmpty()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证工作流启动异常也会释放匿名活动执行租约。
|
||||
*/
|
||||
@Test
|
||||
public void shouldCleanupExternalResourceWhenStartFails() {
|
||||
ChainExecutor chainExecutor = mock(ChainExecutor.class);
|
||||
doThrow(new IllegalStateException("start failed"))
|
||||
.when(chainExecutor)
|
||||
.executeAsync(any(), any(), any());
|
||||
WorkflowChatEventStream eventStream =
|
||||
new WorkflowChatEventStream(chainExecutor);
|
||||
AtomicInteger cleanupCount = new AtomicInteger();
|
||||
|
||||
Assert.expectThrows(
|
||||
IllegalStateException.class,
|
||||
() -> eventStream.start(
|
||||
"definition",
|
||||
Map.of(),
|
||||
cleanupCount::incrementAndGet
|
||||
)
|
||||
);
|
||||
|
||||
Assert.assertEquals(cleanupCount.get(), 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证浏览器断开只分离 SSE,不取消仍在运行的工作流。
|
||||
*/
|
||||
@Test
|
||||
public void shouldKeepRuntimeRunningWhenBrowserDisconnects() {
|
||||
ChainExecutor chainExecutor = mock(ChainExecutor.class);
|
||||
doAnswer(invocation -> {
|
||||
@SuppressWarnings("unchecked")
|
||||
Consumer<String> beforeStart = invocation.getArgument(2);
|
||||
beforeStart.accept("execution-1");
|
||||
return "execution-1";
|
||||
}).when(chainExecutor).executeAsync(any(), any(), any());
|
||||
CapturingSseEmitter emitter = new CapturingSseEmitter();
|
||||
WorkflowChatEventStream eventStream =
|
||||
new WorkflowChatEventStream(chainExecutor) {
|
||||
@Override
|
||||
SseEmitter createEmitter() {
|
||||
return emitter;
|
||||
}
|
||||
};
|
||||
AtomicInteger cleanupCount = new AtomicInteger();
|
||||
|
||||
eventStream.start(
|
||||
"definition",
|
||||
Map.of(),
|
||||
cleanupCount::incrementAndGet,
|
||||
Duration.ofMinutes(35)
|
||||
);
|
||||
emitter.disconnect();
|
||||
|
||||
verify(chainExecutor, never()).cancel(any(), any());
|
||||
Assert.assertEquals(cleanupCount.get(), 0);
|
||||
|
||||
eventStream.shutdown();
|
||||
Assert.assertEquals(cleanupCount.get(), 1);
|
||||
}
|
||||
|
||||
private static final class CapturingSseEmitter extends SseEmitter {
|
||||
|
||||
private Runnable completion;
|
||||
|
||||
@Override
|
||||
public synchronized void onCompletion(Runnable callback) {
|
||||
this.completion = callback;
|
||||
}
|
||||
|
||||
private void disconnect() {
|
||||
Assert.assertNotNull(completion);
|
||||
completion.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package tech.easyflow.admin.service.ai;
|
||||
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.script.DefaultRedisScript;
|
||||
import org.testng.Assert;
|
||||
import org.testng.annotations.Test;
|
||||
import tech.easyflow.common.cache.RedisLockExecutor;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* {@link WorkflowPublicChatAccessGuard} Redis 失败关闭测试。
|
||||
*/
|
||||
public class WorkflowPublicChatAccessGuardTest {
|
||||
|
||||
@Test
|
||||
public void shouldExposeDocumentedProtectionDefaults() {
|
||||
WorkflowPublicShareProperties properties =
|
||||
new WorkflowPublicShareProperties();
|
||||
|
||||
Assert.assertEquals(properties.getRunVisitorLimit(), 5);
|
||||
Assert.assertEquals(properties.getRunShareLimit(), 60);
|
||||
Assert.assertEquals(properties.getUploadVisitorLimit(), 10);
|
||||
Assert.assertEquals(properties.getUploadShareLimit(), 60);
|
||||
Assert.assertEquals(properties.getRateWindow(), Duration.ofMinutes(1));
|
||||
Assert.assertEquals(properties.getActiveLease(), Duration.ofMinutes(35));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldReturn429WhenFixedWindowIsExceeded() {
|
||||
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
|
||||
RedisLockExecutor lockExecutor = mock(RedisLockExecutor.class);
|
||||
when(redisTemplate.execute(
|
||||
any(DefaultRedisScript.class),
|
||||
anyList(),
|
||||
anyString(),
|
||||
anyString(),
|
||||
anyString()
|
||||
)).thenReturn(0L);
|
||||
WorkflowPublicChatAccessGuard guard = new WorkflowPublicChatAccessGuard(
|
||||
redisTemplate,
|
||||
lockExecutor,
|
||||
new WorkflowPublicShareProperties()
|
||||
);
|
||||
|
||||
BusinessException error = Assert.expectThrows(
|
||||
BusinessException.class,
|
||||
() -> guard.checkRun(BigInteger.ONE, "visitor")
|
||||
);
|
||||
|
||||
Assert.assertEquals(error.getHttpStatus(), 429);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldReturn503WhenRedisRateLimitIsUnavailable() {
|
||||
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
|
||||
RedisLockExecutor lockExecutor = mock(RedisLockExecutor.class);
|
||||
when(redisTemplate.execute(
|
||||
any(DefaultRedisScript.class),
|
||||
anyList(),
|
||||
anyString(),
|
||||
anyString(),
|
||||
anyString()
|
||||
)).thenThrow(new IllegalStateException("redis unavailable"));
|
||||
WorkflowPublicChatAccessGuard guard = new WorkflowPublicChatAccessGuard(
|
||||
redisTemplate,
|
||||
lockExecutor,
|
||||
new WorkflowPublicShareProperties()
|
||||
);
|
||||
|
||||
BusinessException error = Assert.expectThrows(
|
||||
BusinessException.class,
|
||||
() -> guard.checkUpload(BigInteger.ONE, "visitor")
|
||||
);
|
||||
|
||||
Assert.assertEquals(error.getHttpStatus(), 503);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package tech.easyflow.admin.service.ai;
|
||||
|
||||
import org.testng.Assert;
|
||||
import org.testng.annotations.Test;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.ai.entity.WorkflowShare;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
import tech.easyflow.ai.service.WorkflowService;
|
||||
import tech.easyflow.ai.service.WorkflowShareService;
|
||||
import tech.easyflow.ai.share.WorkflowSharePolicy;
|
||||
import tech.easyflow.common.constant.enums.EnumDataStatus;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.system.entity.SysAccount;
|
||||
import tech.easyflow.system.service.SysAccountService;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* {@link WorkflowPublicChatContextResolver} 匿名主体边界测试。
|
||||
*/
|
||||
public class WorkflowPublicChatContextResolverTest {
|
||||
|
||||
@Test
|
||||
public void shouldUseCurrentShareCreatorAsPermissionSubject() {
|
||||
Fixture fixture = fixture(EnumDataStatus.AVAILABLE.getCode());
|
||||
|
||||
WorkflowPublicChatContext context = fixture.resolver.resolveActive(
|
||||
"share-key",
|
||||
"00112233445566778899aabbccddeeff"
|
||||
);
|
||||
|
||||
Assert.assertEquals(context.creator().getId(), BigInteger.TEN);
|
||||
Assert.assertEquals(context.creator().getTenantId(), BigInteger.ONE);
|
||||
Assert.assertEquals(
|
||||
context.visitorDigest(),
|
||||
WorkflowSharePolicy.hashChatVisitor(
|
||||
"share-key",
|
||||
"00112233445566778899aabbccddeeff"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldRejectDisabledShareCreator() {
|
||||
Fixture fixture = fixture(EnumDataStatus.UNAVAILABLE.getCode());
|
||||
|
||||
BusinessException error = Assert.expectThrows(
|
||||
BusinessException.class,
|
||||
() -> fixture.resolver.resolveActive(
|
||||
"share-key",
|
||||
"00112233445566778899aabbccddeeff"
|
||||
)
|
||||
);
|
||||
|
||||
Assert.assertEquals(error.getHttpStatus(), 403);
|
||||
Assert.assertTrue(error.getMessage().contains("创建者账号"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldResolveHistoricalShareWithoutCurrentCreatorCheck() {
|
||||
Fixture fixture = fixture(EnumDataStatus.UNAVAILABLE.getCode());
|
||||
|
||||
WorkflowPublicChatContext context = fixture.resolver.resolveHistorical(
|
||||
"share-key",
|
||||
"00112233445566778899aabbccddeeff"
|
||||
);
|
||||
|
||||
Assert.assertNull(context.creator());
|
||||
Assert.assertNull(context.workflow());
|
||||
verify(fixture.accountService, never()).getById(BigInteger.TEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldRejectMalformedVisitorIdentity() {
|
||||
Fixture fixture = fixture(EnumDataStatus.AVAILABLE.getCode());
|
||||
|
||||
BusinessException error = Assert.expectThrows(
|
||||
BusinessException.class,
|
||||
() -> fixture.resolver.resolveActive("share-key", "short")
|
||||
);
|
||||
|
||||
Assert.assertEquals(error.getErrorCode(), 40031);
|
||||
}
|
||||
|
||||
private Fixture fixture(Integer accountStatus) {
|
||||
WorkflowShareService shareService = mock(WorkflowShareService.class);
|
||||
WorkflowService workflowService = mock(WorkflowService.class);
|
||||
SysAccountService accountService = mock(SysAccountService.class);
|
||||
|
||||
WorkflowShare share = new WorkflowShare();
|
||||
share.setId(BigInteger.valueOf(7));
|
||||
share.setWorkflowId(BigInteger.valueOf(11));
|
||||
share.setTenantId(BigInteger.ONE);
|
||||
share.setCreatedBy(BigInteger.TEN);
|
||||
|
||||
Workflow workflow = new Workflow();
|
||||
workflow.setId(BigInteger.valueOf(11));
|
||||
workflow.setTenantId(BigInteger.ONE);
|
||||
workflow.setPublishStatus(PublishStatus.PUBLISHED.getCode());
|
||||
workflow.setPublishedSnapshotJson(Map.of("content", "{}"));
|
||||
|
||||
SysAccount account = new SysAccount();
|
||||
account.setId(BigInteger.TEN);
|
||||
account.setTenantId(BigInteger.ONE);
|
||||
account.setStatus(accountStatus);
|
||||
|
||||
when(shareService.resolvePublicChatShare("share-key"))
|
||||
.thenReturn(share);
|
||||
when(shareService.resolveHistoricalChatShare("share-key"))
|
||||
.thenReturn(share);
|
||||
when(workflowService.getPublishedById(BigInteger.valueOf(11)))
|
||||
.thenReturn(workflow);
|
||||
when(accountService.getById(BigInteger.TEN)).thenReturn(account);
|
||||
|
||||
return new Fixture(
|
||||
new WorkflowPublicChatContextResolver(
|
||||
shareService,
|
||||
workflowService,
|
||||
accountService
|
||||
),
|
||||
accountService
|
||||
);
|
||||
}
|
||||
|
||||
private record Fixture(
|
||||
WorkflowPublicChatContextResolver resolver,
|
||||
SysAccountService accountService
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package tech.easyflow.admin.service.ai;
|
||||
|
||||
import com.easyagents.flow.core.chain.ChainState;
|
||||
import com.easyagents.flow.core.chain.ChainStatus;
|
||||
import com.easyagents.flow.core.chain.Parameter;
|
||||
import com.easyagents.flow.core.chain.repository.ChainStateRepository;
|
||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
import org.testng.Assert;
|
||||
import org.testng.annotations.Test;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
||||
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.ai.entity.WorkflowExecResult;
|
||||
import tech.easyflow.ai.entity.WorkflowShare;
|
||||
import tech.easyflow.ai.service.WorkflowExecResultService;
|
||||
import tech.easyflow.ai.service.WorkflowExecStepService;
|
||||
import tech.easyflow.ai.utils.WorkFlowUtil;
|
||||
import tech.easyflow.common.cache.RedisLockExecutor;
|
||||
import tech.easyflow.common.constant.Constants;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyMap;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* {@link WorkflowPublicChatService} 匿名执行归属测试。
|
||||
*/
|
||||
public class WorkflowPublicChatServiceTest {
|
||||
|
||||
@Test
|
||||
public void shouldSeparatePermissionSubjectFromExecutionOwner() {
|
||||
Fixture fixture = fixture();
|
||||
RedisLockExecutor.LockHandle activity = mock(
|
||||
RedisLockExecutor.LockHandle.class);
|
||||
when(fixture.parameterResolver.normalizeRuntimeVariables(
|
||||
eq("{}"), anyMap())).thenReturn(new LinkedHashMap<>());
|
||||
when(fixture.accessGuard.acquireActivity(
|
||||
BigInteger.valueOf(7), "visitor-digest"))
|
||||
.thenReturn(activity);
|
||||
when(fixture.accessGuard.activityLease())
|
||||
.thenReturn(Duration.ofMinutes(35));
|
||||
when(fixture.eventStream.start(
|
||||
eq(PublishedWorkflowDefinitionIds.published("11")),
|
||||
anyMap(),
|
||||
any(Runnable.class),
|
||||
eq(Duration.ofMinutes(35))
|
||||
)).thenReturn(new SseEmitter());
|
||||
|
||||
fixture.service.run("share-key", visitorId(), Map.of());
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Map<String, Object>> variables = ArgumentCaptor
|
||||
.forClass((Class) Map.class);
|
||||
verify(fixture.eventStream).start(
|
||||
eq(PublishedWorkflowDefinitionIds.published("11")),
|
||||
variables.capture(),
|
||||
any(Runnable.class),
|
||||
eq(Duration.ofMinutes(35))
|
||||
);
|
||||
Assert.assertSame(
|
||||
variables.getValue().get(Constants.LOGIN_USER_KEY),
|
||||
fixture.context.creator()
|
||||
);
|
||||
Assert.assertEquals(
|
||||
variables.getValue().get(WorkFlowUtil.CREATED_KEY_MEMORY_KEY),
|
||||
"WORKFLOW_CHAT_SHARE:7"
|
||||
);
|
||||
Assert.assertEquals(
|
||||
variables.getValue().get(WorkFlowUtil.CREATED_BY_MEMORY_KEY),
|
||||
"visitor-digest"
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldRejectExecutionOwnedByAnotherVisitor() {
|
||||
Fixture fixture = fixture();
|
||||
WorkflowExecResult record = new WorkflowExecResult();
|
||||
record.setWorkflowId(BigInteger.valueOf(11));
|
||||
record.setCreatedKey("WORKFLOW_CHAT_SHARE:7");
|
||||
record.setCreatedBy("another-visitor");
|
||||
when(fixture.execResultService.getByExecKey("execution-1"))
|
||||
.thenReturn(record);
|
||||
|
||||
BusinessException error = Assert.expectThrows(
|
||||
BusinessException.class,
|
||||
() -> fixture.service.detail(
|
||||
"share-key", visitorId(), "execution-1")
|
||||
);
|
||||
|
||||
Assert.assertEquals(error.getHttpStatus(), 403);
|
||||
Assert.assertEquals(error.getErrorCode(), 40333);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldExposeMinimalRuntimeStateForRefreshRecovery() {
|
||||
Fixture fixture = fixture();
|
||||
WorkflowExecResult record = ownedRecord();
|
||||
when(fixture.execResultService.getByExecKey("execution-1"))
|
||||
.thenReturn(record);
|
||||
when(fixture.execStepService.list(any(QueryWrapper.class)))
|
||||
.thenReturn(List.of());
|
||||
ChainStateRepository repository = mock(ChainStateRepository.class);
|
||||
ChainState state = new ChainState();
|
||||
state.setStatus(ChainStatus.SUSPEND);
|
||||
state.setMessage("请确认是否继续");
|
||||
state.setSuspendForParameters(List.of(new Parameter("approved")));
|
||||
when(fixture.chainExecutor.getChainStateRepository())
|
||||
.thenReturn(repository);
|
||||
when(repository.load("execution-1")).thenReturn(state);
|
||||
|
||||
Map<String, Object> detail = fixture.service.detail(
|
||||
"share-key", visitorId(), "execution-1");
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> runtime =
|
||||
(Map<String, Object>) detail.get("runtime");
|
||||
Assert.assertEquals(runtime.get("status"), "SUSPEND");
|
||||
Assert.assertEquals(runtime.get("statusValue"), 5);
|
||||
Assert.assertEquals(runtime.get("message"), "请确认是否继续");
|
||||
Assert.assertEquals(
|
||||
((List<?>) runtime.get("parameters")).size(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
private WorkflowExecResult ownedRecord() {
|
||||
WorkflowExecResult record = new WorkflowExecResult();
|
||||
record.setId(BigInteger.valueOf(31));
|
||||
record.setWorkflowId(BigInteger.valueOf(11));
|
||||
record.setExecKey("execution-1");
|
||||
record.setCreatedKey("WORKFLOW_CHAT_SHARE:7");
|
||||
record.setCreatedBy("visitor-digest");
|
||||
return record;
|
||||
}
|
||||
|
||||
private Fixture fixture() {
|
||||
WorkflowPublicChatContextResolver contextResolver = mock(
|
||||
WorkflowPublicChatContextResolver.class);
|
||||
WorkflowCheckService workflowCheckService = mock(
|
||||
WorkflowCheckService.class);
|
||||
WorkflowRunningParameterResolver parameterResolver = mock(
|
||||
WorkflowRunningParameterResolver.class);
|
||||
WorkflowPublicChatUploadService uploadService = mock(
|
||||
WorkflowPublicChatUploadService.class);
|
||||
WorkflowPublicChatAccessGuard accessGuard = mock(
|
||||
WorkflowPublicChatAccessGuard.class);
|
||||
WorkflowChatEventStream eventStream = mock(
|
||||
WorkflowChatEventStream.class);
|
||||
ChainExecutor chainExecutor = mock(ChainExecutor.class);
|
||||
WorkflowExecResultService execResultService = mock(
|
||||
WorkflowExecResultService.class);
|
||||
WorkflowExecStepService execStepService = mock(
|
||||
WorkflowExecStepService.class);
|
||||
|
||||
WorkflowShare share = new WorkflowShare();
|
||||
share.setId(BigInteger.valueOf(7));
|
||||
share.setWorkflowId(BigInteger.valueOf(11));
|
||||
Workflow workflow = new Workflow();
|
||||
workflow.setId(BigInteger.valueOf(11));
|
||||
workflow.setContent("{}");
|
||||
LoginAccount creator = new LoginAccount();
|
||||
creator.setId(BigInteger.TEN);
|
||||
creator.setTenantId(BigInteger.ONE);
|
||||
WorkflowPublicChatContext context = new WorkflowPublicChatContext(
|
||||
share,
|
||||
workflow,
|
||||
creator,
|
||||
"share-key",
|
||||
"visitor-digest"
|
||||
);
|
||||
when(contextResolver.resolveActive("share-key", visitorId()))
|
||||
.thenReturn(context);
|
||||
when(contextResolver.resolveHistorical("share-key", visitorId()))
|
||||
.thenReturn(context);
|
||||
|
||||
WorkflowPublicChatService service = new WorkflowPublicChatService(
|
||||
contextResolver,
|
||||
workflowCheckService,
|
||||
parameterResolver,
|
||||
uploadService,
|
||||
accessGuard,
|
||||
eventStream,
|
||||
chainExecutor,
|
||||
execResultService,
|
||||
execStepService
|
||||
);
|
||||
return new Fixture(
|
||||
service,
|
||||
context,
|
||||
parameterResolver,
|
||||
accessGuard,
|
||||
eventStream,
|
||||
chainExecutor,
|
||||
execResultService,
|
||||
execStepService
|
||||
);
|
||||
}
|
||||
|
||||
private String visitorId() {
|
||||
return "00112233445566778899aabbccddeeff";
|
||||
}
|
||||
|
||||
private record Fixture(
|
||||
WorkflowPublicChatService service,
|
||||
WorkflowPublicChatContext context,
|
||||
WorkflowRunningParameterResolver parameterResolver,
|
||||
WorkflowPublicChatAccessGuard accessGuard,
|
||||
WorkflowChatEventStream eventStream,
|
||||
ChainExecutor chainExecutor,
|
||||
WorkflowExecResultService execResultService,
|
||||
WorkflowExecStepService execStepService
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package tech.easyflow.admin.service.ai;
|
||||
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.testng.Assert;
|
||||
import org.testng.annotations.Test;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.ai.entity.WorkflowShare;
|
||||
import tech.easyflow.common.filestorage.FileStorageService;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* {@link WorkflowPublicChatUploadService} 上传边界测试。
|
||||
*/
|
||||
public class WorkflowPublicChatUploadServiceTest {
|
||||
|
||||
@Test
|
||||
public void shouldStoreDeclaredFileUnderVisitorScope() {
|
||||
Fixture fixture = fixture("file");
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.isEmpty()).thenReturn(false);
|
||||
when(file.getSize()).thenReturn(1024L);
|
||||
when(file.getOriginalFilename()).thenReturn("input.pdf");
|
||||
when(fixture.storageService.save(
|
||||
eq(file), anyString())).thenReturn("/files/input.pdf");
|
||||
|
||||
fixture.service.upload(fixture.context, "attachment", file);
|
||||
|
||||
verify(fixture.accessGuard).checkUpload(
|
||||
BigInteger.valueOf(7), "visitor-digest");
|
||||
verify(fixture.storageService).save(
|
||||
file,
|
||||
"workflow-chat-share/7/visitor-digest"
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldRejectReferenceWithoutCurrentVisitorGrant() {
|
||||
Fixture fixture = fixture("file");
|
||||
|
||||
BusinessException error = Assert.expectThrows(
|
||||
BusinessException.class,
|
||||
() -> fixture.service.assertOwnedUploads(
|
||||
fixture.context,
|
||||
Map.of("attachment", List.of(Map.of(
|
||||
"fileName", "input.pdf",
|
||||
"filePath", "/files/other.pdf"
|
||||
)))
|
||||
)
|
||||
);
|
||||
|
||||
Assert.assertEquals(error.getHttpStatus(), 403);
|
||||
Assert.assertEquals(error.getErrorCode(), 40332);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldRejectGrantCreatedForDifferentParameterType() {
|
||||
Fixture fixture = fixture("image");
|
||||
when(fixture.valueOperations.get(anyString())).thenReturn("file");
|
||||
|
||||
BusinessException error = Assert.expectThrows(
|
||||
BusinessException.class,
|
||||
() -> fixture.service.assertOwnedUploads(
|
||||
fixture.context,
|
||||
Map.of("attachment", Map.of(
|
||||
"sourceType", "upload",
|
||||
"filePath", "/files/input.png"
|
||||
))
|
||||
)
|
||||
);
|
||||
|
||||
Assert.assertEquals(error.getHttpStatus(), 403);
|
||||
Assert.assertEquals(error.getErrorCode(), 40332);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldRejectUnsupportedImageType() {
|
||||
Fixture fixture = fixture("image");
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.isEmpty()).thenReturn(false);
|
||||
when(file.getSize()).thenReturn(1024L);
|
||||
when(file.getContentType()).thenReturn("image/svg+xml");
|
||||
when(file.getOriginalFilename()).thenReturn("input.svg");
|
||||
|
||||
BusinessException error = Assert.expectThrows(
|
||||
BusinessException.class,
|
||||
() -> fixture.service.upload(
|
||||
fixture.context, "attachment", file)
|
||||
);
|
||||
|
||||
Assert.assertTrue(error.getMessage().contains("PNG"));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Fixture fixture(String contentType) {
|
||||
WorkflowRunningParameterResolver parameterResolver = mock(
|
||||
WorkflowRunningParameterResolver.class);
|
||||
WorkflowPublicChatAccessGuard accessGuard = mock(
|
||||
WorkflowPublicChatAccessGuard.class);
|
||||
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
|
||||
ValueOperations<String, String> valueOperations = mock(
|
||||
ValueOperations.class);
|
||||
FileStorageService storageService = mock(FileStorageService.class);
|
||||
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||
|
||||
Workflow workflow = new Workflow();
|
||||
workflow.setId(BigInteger.valueOf(11));
|
||||
when(parameterResolver.buildRunningParametersView(workflow))
|
||||
.thenReturn(Map.of(
|
||||
"startFormSchema",
|
||||
List.of(Map.of(
|
||||
"key", "attachment",
|
||||
"contentType", contentType
|
||||
))
|
||||
));
|
||||
|
||||
WorkflowShare share = new WorkflowShare();
|
||||
share.setId(BigInteger.valueOf(7));
|
||||
share.setExpiresAt(new Date(
|
||||
System.currentTimeMillis() + 60_000L));
|
||||
WorkflowPublicChatContext context = new WorkflowPublicChatContext(
|
||||
share,
|
||||
workflow,
|
||||
null,
|
||||
"share-key",
|
||||
"visitor-digest"
|
||||
);
|
||||
WorkflowPublicChatUploadService service =
|
||||
new WorkflowPublicChatUploadService(
|
||||
parameterResolver,
|
||||
accessGuard,
|
||||
new WorkflowPublicShareProperties(),
|
||||
redisTemplate,
|
||||
storageService
|
||||
);
|
||||
return new Fixture(
|
||||
service,
|
||||
context,
|
||||
accessGuard,
|
||||
redisTemplate,
|
||||
valueOperations,
|
||||
storageService
|
||||
);
|
||||
}
|
||||
|
||||
private record Fixture(
|
||||
WorkflowPublicChatUploadService service,
|
||||
WorkflowPublicChatContext context,
|
||||
WorkflowPublicChatAccessGuard accessGuard,
|
||||
StringRedisTemplate redisTemplate,
|
||||
ValueOperations<String, String> valueOperations,
|
||||
FileStorageService storageService
|
||||
) {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user