fix: 强化工作流与插件运行状态处理

- 显式传播工作流缓存故障并补充仓储测试

- 防止状态轮询重入和过期响应,规范执行记录无权限响应
This commit is contained in:
2026-07-27 19:40:40 +08:00
parent 567fd12706
commit 2892a7eddc
6 changed files with 351 additions and 58 deletions

View File

@@ -1,27 +1,75 @@
package tech.easyflow.ai.easyagentsflow.repository; package tech.easyflow.ai.easyagentsflow.repository;
import com.alicp.jetcache.Cache; import com.alicp.jetcache.Cache;
import com.alicp.jetcache.CacheException;
import com.alicp.jetcache.CacheGetResult;
import com.alicp.jetcache.CacheResult;
import com.alicp.jetcache.CacheResultCode;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
/**
* 工作流运行状态缓存仓储基类。
*/
public class BaseRepository { public class BaseRepository {
@Resource(name = "defaultCache") @Resource(name = "defaultCache")
private Cache<String, Object> cache; private Cache<String, Object> cache;
/** /**
* chain 的相关状态缓存三天 * 保存工作流运行状态缓存有效期为三天
*
* @param key 缓存键
* @param value 缓存值
* @throws CacheException 缓存写入失败时抛出
*/ */
protected void putCache(String key, Object value) { protected void putCache(String key, Object value) {
cache.put(key, value, 3, TimeUnit.DAYS); CacheResult result = cache.PUT(key, value, 3, TimeUnit.DAYS);
if (!result.isSuccess()) {
throw cacheOperationException("写入", key, result);
}
} }
/**
* 读取并校验工作流运行状态缓存。
*
* @param key 缓存键
* @param clazz 期望的缓存值类型
* @param <T> 缓存值类型
* @return 命中的缓存值;缓存不存在或已过期时返回 null
* @throws CacheException 缓存读取失败时抛出
* @throws ClassCastException 缓存值类型与期望类型不一致时抛出
*/
protected <T> T getCache(String key, Class<T> clazz) { protected <T> T getCache(String key, Class<T> clazz) {
Object value = cache.get(key); CacheGetResult<Object> result = cache.GET(key);
CacheResultCode resultCode = result.getResultCode();
if (resultCode == CacheResultCode.NOT_EXISTS || resultCode == CacheResultCode.EXPIRED) {
return null;
}
if (!result.isSuccess()) {
throw cacheOperationException("读取", key, result);
}
Object value = result.getValue();
if (value == null) { if (value == null) {
return null; return null;
} }
return clazz.cast(value); return clazz.cast(value);
} }
/**
* 构建包含缓存操作上下文的异常。
*
* @param operation 操作名称
* @param key 缓存键
* @param result JetCache 操作结果
* @return 缓存操作异常
*/
private CacheException cacheOperationException(String operation, String key, CacheResult result) {
return new CacheException(
"工作流状态缓存" + operation + "失败key=" + key
+ "resultCode=" + result.getResultCode()
+ "message=" + result.getMessage()
);
}
} }

View File

@@ -0,0 +1,185 @@
package tech.easyflow.ai.easyagentsflow.repository;
import com.alicp.jetcache.Cache;
import com.alicp.jetcache.CacheException;
import com.alicp.jetcache.CacheGetResult;
import com.alicp.jetcache.CacheResult;
import com.alicp.jetcache.CacheResultCode;
import com.alicp.jetcache.support.CacheEncodeException;
import com.easyagents.flow.core.chain.ChainState;
import org.junit.Assert;
import org.junit.Test;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.concurrent.TimeUnit;
/**
* {@link ChainStateRepositoryImpl} 缓存异常处理回归测试。
*/
public class ChainStateRepositoryImplTest {
/**
* 验证缓存解码失败时抛出异常且不创建空工作流状态。
*
* @throws Exception 缓存依赖注入失败时抛出
*/
@Test
public void loadShouldFailWithoutOverwritingStateWhenCacheDecodeFails() throws Exception {
String instanceId = "decode-failed-instance";
CacheGetResult<Object> failure = new CacheGetResult<>(new CacheEncodeException(
"decode error",
new ClassNotFoundException("com.alicp.jetcache.CacheValueHolder")
));
RecordingCache cache = new RecordingCache(failure, CacheResult.SUCCESS_WITHOUT_MSG);
ChainStateRepositoryImpl repository = repository(cache.asCache());
try {
repository.load(instanceId);
Assert.fail("cache decode failure should be propagated");
} catch (CacheException expected) {
Assert.assertTrue(expected.getMessage().contains("工作流状态缓存读取失败"));
Assert.assertTrue(expected.getMessage().contains(instanceId));
}
Assert.assertEquals(0, cache.getPutCount());
}
/**
* 验证缓存未命中时创建并持久化新的工作流状态。
*
* @throws Exception 缓存依赖注入失败时抛出
*/
@Test
public void loadShouldCreateStateWhenCacheDoesNotExist() throws Exception {
String instanceId = "new-instance";
RecordingCache cache = new RecordingCache(
new CacheGetResult<>(CacheResultCode.NOT_EXISTS, null, null),
CacheResult.SUCCESS_WITHOUT_MSG
);
ChainStateRepositoryImpl repository = repository(cache.asCache());
ChainState state = repository.load(instanceId);
Assert.assertEquals(instanceId, state.getInstanceId());
Assert.assertEquals(1, cache.getPutCount());
Assert.assertSame(state, cache.getLastPutValue());
}
/**
* 验证缓存写入失败时不会返回未持久化的工作流状态。
*
* @throws Exception 缓存依赖注入失败时抛出
*/
@Test
public void loadShouldFailWhenNewStateCannotBePersisted() throws Exception {
RecordingCache cache = new RecordingCache(
new CacheGetResult<>(CacheResultCode.NOT_EXISTS, null, null),
new CacheResult(new IllegalStateException("redis unavailable"))
);
ChainStateRepositoryImpl repository = repository(cache.asCache());
try {
repository.load("write-failed-instance");
Assert.fail("cache write failure should be propagated");
} catch (CacheException expected) {
Assert.assertTrue(expected.getMessage().contains("工作流状态缓存写入失败"));
}
Assert.assertEquals(1, cache.getPutCount());
}
/**
* 创建工作流状态仓储并注入缓存。
*
* @param cache 测试缓存
* @return 已完成依赖注入的工作流状态仓储
* @throws Exception 反射注入失败时抛出
*/
private ChainStateRepositoryImpl repository(Cache<String, Object> cache) throws Exception {
ChainStateRepositoryImpl repository = new ChainStateRepositoryImpl();
Field field = BaseRepository.class.getDeclaredField("cache");
field.setAccessible(true);
field.set(repository, cache);
return repository;
}
/**
* 仅实现当前仓储测试所需操作的 JetCache 调用记录器。
*/
private static final class RecordingCache implements InvocationHandler {
private final CacheGetResult<Object> getResult;
private final CacheResult putResult;
private int putCount;
private Object lastPutValue;
/**
* 创建缓存调用记录器。
*
* @param getResult 读取操作结果
* @param putResult 写入操作结果
*/
private RecordingCache(CacheGetResult<Object> getResult, CacheResult putResult) {
this.getResult = getResult;
this.putResult = putResult;
}
/**
* 创建实现 JetCache 接口的 JDK 动态代理。
*
* @return JetCache 测试代理
*/
@SuppressWarnings("unchecked")
private Cache<String, Object> asCache() {
return (Cache<String, Object>) Proxy.newProxyInstance(
Cache.class.getClassLoader(),
new Class<?>[]{Cache.class},
this
);
}
/**
* 处理仓储发起的缓存读写操作。
*
* @param proxy 代理对象
* @param method 被调用方法
* @param args 调用参数
* @return 预设的缓存操作结果
*/
@Override
public Object invoke(Object proxy, Method method, Object[] args) {
if ("GET".equals(method.getName())) {
return getResult;
}
if ("PUT".equals(method.getName()) && args != null && args.length == 4) {
putCount++;
lastPutValue = args[1];
Assert.assertEquals(3L, args[2]);
Assert.assertEquals(TimeUnit.DAYS, args[3]);
return putResult;
}
throw new UnsupportedOperationException("unsupported cache method: " + method.getName());
}
/**
* 获取写入调用次数。
*
* @return 写入调用次数
*/
private int getPutCount() {
return putCount;
}
/**
* 获取最后一次写入的缓存值。
*
* @return 最后一次写入的缓存值
*/
private Object getLastPutValue() {
return lastPutValue;
}
}
}

View File

@@ -80,7 +80,7 @@ public class RequireResourceAccessAspect {
LoginAccount loginAccount = SaTokenUtil.getLoginAccount(); LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
String accountId = loginAccount == null || loginAccount.getId() == null ? null : loginAccount.getId().toString(); String accountId = loginAccount == null || loginAccount.getId() == null ? null : loginAccount.getId().toString();
if (!executionOwnerKey.equals(accountId)) { if (!executionOwnerKey.equals(accountId)) {
throw new BusinessException("无权限访问该执行记录"); throw new BusinessException(403, 403, "无权限访问该执行记录");
} }
} }
} }

View File

@@ -44,7 +44,10 @@ const pollingNodes = ref<any[]>([]);
const executeId = ref(''); const executeId = ref('');
const pollingData = ref<any>({ nodes: {} }); const pollingData = ref<any>({ nodes: {} });
const initSignal = ref(false); const initSignal = ref(false);
const pollingTimer = ref<null | ReturnType<typeof setInterval>>(null); const POLLING_INTERVAL_MS = 1000;
const pollingTimer = ref<null | ReturnType<typeof setTimeout>>(null);
let pollingActive = false;
let pollingGeneration = 0;
const activeIndex = ref('1'); const activeIndex = ref('1');
const dialogContentKey = ref(0); const dialogContentKey = ref(0);
const dialogPreparing = ref(false); const dialogPreparing = ref(false);
@@ -237,28 +240,39 @@ function handleWorkflowSubmit(runParams: any) {
function startPolling(nextExecuteId: string) { function startPolling(nextExecuteId: string) {
stopPolling(); stopPolling();
pollingTimer.value = setInterval(() => { pollingActive = true;
executePolling(nextExecuteId); pollingGeneration += 1;
}, 1000); schedulePolling(nextExecuteId, pollingGeneration);
}
function schedulePolling(nextExecuteId: string, generation: number) {
pollingTimer.value = setTimeout(() => {
pollingTimer.value = null;
void executePolling(nextExecuteId, generation);
}, POLLING_INTERVAL_MS);
} }
function stopPolling() { function stopPolling() {
pollingActive = false;
pollingGeneration += 1;
if (pollingTimer.value) { if (pollingTimer.value) {
clearInterval(pollingTimer.value); clearTimeout(pollingTimer.value);
pollingTimer.value = null; pollingTimer.value = null;
} }
} }
function executePolling(nextExecuteId: string) { async function executePolling(nextExecuteId: string, generation: number) {
api try {
.post('/api/v1/pluginItem/testChainStatus', { const res = await api.post('/api/v1/pluginItem/testChainStatus', {
executeId: nextExecuteId, executeId: nextExecuteId,
nodes: pollingNodes.value, nodes: pollingNodes.value,
}) });
.then((res) => { if (!pollingActive || generation !== pollingGeneration) return;
if (res.errorCode !== 0) { if (res.errorCode !== 0) {
stopPolling();
return; return;
} }
const nextData = { const nextData = {
...res.data, ...res.data,
nodes: res.data?.nodes || {}, nodes: res.data?.nodes || {},
@@ -267,12 +281,14 @@ function executePolling(nextExecuteId: string) {
runResultResponse.value = nextData; runResultResponse.value = nextData;
if (nextData.status !== 1) { if (nextData.status !== 1) {
stopPolling(); stopPolling();
return;
} }
}) schedulePolling(nextExecuteId, generation);
.catch((error) => { } catch (error) {
if (!pollingActive || generation !== pollingGeneration) return;
stopPolling(); stopPolling();
runResultResponse.value = buildErrorResult(error); runResultResponse.value = buildErrorResult(error);
}); }
} }
function resumeChain(payload: any) { function resumeChain(payload: any) {

View File

@@ -137,7 +137,10 @@ function submitV2() {
} }
}); });
} }
const timer = ref(); const POLLING_INTERVAL_MS = 1000;
const timer = ref<null | ReturnType<typeof setTimeout>>(null);
let pollingActive = false;
let pollingGeneration = 0;
const nodes = ref( const nodes = ref(
props.tinyFlowData.nodes.map((node: any) => ({ props.tinyFlowData.nodes.map((node: any) => ({
nodeId: node.id, nodeId: node.id,
@@ -146,27 +149,46 @@ const nodes = ref(
); );
// 轮询执行结果 // 轮询执行结果
function startPolling(executeId: any) { function startPolling(executeId: any) {
if (timer.value) return; if (pollingActive) return;
timer.value = setInterval(() => executePolling(executeId), 1000); pollingActive = true;
pollingGeneration += 1;
schedulePolling(executeId, pollingGeneration);
} }
function executePolling(executeId: any) { function schedulePolling(executeId: any, generation: number) {
api timer.value = setTimeout(() => {
.post('/api/v1/workflow/getChainStatus', { timer.value = null;
void executePolling(executeId, generation);
}, POLLING_INTERVAL_MS);
}
async function executePolling(executeId: any, generation: number) {
try {
const res = await api.post('/api/v1/workflow/getChainStatus', {
executeId, executeId,
nodes: nodes.value, nodes: nodes.value,
}) });
.then((res) => { if (!pollingActive || generation !== pollingGeneration) return;
// 5 是挂起状态
if (res.data.status !== 1 || res.data.status === 5) { // 5 是挂起状态;所有非运行态都结束当前轮询。
if (res.data.status !== 1) {
stopPolling(); stopPolling();
} }
props.onAsyncExecute?.(res.data); props.onAsyncExecute?.(res.data);
});
if (pollingActive && generation === pollingGeneration) {
schedulePolling(executeId, generation);
}
} catch (error) {
if (!pollingActive || generation !== pollingGeneration) return;
stopPolling();
console.error('工作流状态轮询失败', error);
}
} }
function stopPolling() { function stopPolling() {
submitLoading.value = false; submitLoading.value = false;
pollingActive = false;
pollingGeneration += 1;
if (timer.value) { if (timer.value) {
clearInterval(timer.value); clearTimeout(timer.value);
timer.value = null; timer.value = null;
} }
} }

View File

@@ -136,7 +136,10 @@ function submitV2() {
} }
}); });
} }
const timer = ref(); const POLLING_INTERVAL_MS = 1000;
const timer = ref<null | ReturnType<typeof setTimeout>>(null);
let pollingActive = false;
let pollingGeneration = 0;
const nodes = ref( const nodes = ref(
props.tinyFlowData.nodes.map((node: any) => ({ props.tinyFlowData.nodes.map((node: any) => ({
nodeId: node.id, nodeId: node.id,
@@ -145,27 +148,46 @@ const nodes = ref(
); );
// 轮询执行结果 // 轮询执行结果
function startPolling(executeId: any) { function startPolling(executeId: any) {
if (timer.value) return; if (pollingActive) return;
timer.value = setInterval(() => executePolling(executeId), 1000); pollingActive = true;
pollingGeneration += 1;
schedulePolling(executeId, pollingGeneration);
} }
function executePolling(executeId: any) { function schedulePolling(executeId: any, generation: number) {
api timer.value = setTimeout(() => {
.post('/userCenter/workflow/getChainStatus', { timer.value = null;
void executePolling(executeId, generation);
}, POLLING_INTERVAL_MS);
}
async function executePolling(executeId: any, generation: number) {
try {
const res = await api.post('/userCenter/workflow/getChainStatus', {
executeId, executeId,
nodes: nodes.value, nodes: nodes.value,
}) });
.then((res) => { if (!pollingActive || generation !== pollingGeneration) return;
// 5 是挂起状态
if (res.data.status !== 1 || res.data.status === 5) { // 5 是挂起状态;所有非运行态都结束当前轮询。
if (res.data.status !== 1) {
stopPolling(); stopPolling();
} }
props.onAsyncExecute?.(res.data); props.onAsyncExecute?.(res.data);
});
if (pollingActive && generation === pollingGeneration) {
schedulePolling(executeId, generation);
}
} catch (error) {
if (!pollingActive || generation !== pollingGeneration) return;
stopPolling();
console.error('工作流状态轮询失败', error);
}
} }
function stopPolling() { function stopPolling() {
submitLoading.value = false; submitLoading.value = false;
pollingActive = false;
pollingGeneration += 1;
if (timer.value) { if (timer.value) {
clearInterval(timer.value); clearTimeout(timer.value);
timer.value = null; timer.value = null;
} }
} }