Compare commits

38 Commits

Author SHA1 Message Date
9722bea701 feat: 支持工作流知识库多库检索 2026-09-04 17:41:30 +08:00
386cebf342 perf: 减少工作流设计器重复计算与序列化
- 缓存节点汇聚模式拓扑分析结果

- 草稿保存复用内容序列化结果
2026-09-04 14:57:12 +08:00
c7cac61ce8 feat: 完善用户确认节点选项与输出流转
- 重构确认节点单选多选配置及输出参数契约

- 统一管理端、用户中心、匿名分享和公共接口恢复流程

- 增加保存校验、错误契约及交互测试
2026-09-04 14:57:11 +08:00
b99e275371 fix: 补齐分块同步所需的锁续租能力
- 定向回补 develop 已有的 Redis 自动续租锁及回归测试
2026-09-04 11:42:15 +08:00
ebade41e40 feat: 支持知识库文档批量删除与分块内联编辑 2026-09-04 11:36:34 +08:00
cc7f0c1a43 feat: 异步同步知识库分块检索索引 2026-09-04 11:35:34 +08:00
c4799760cf fix: 优化自动导入异常状态展示 2026-09-04 11:35:31 +08:00
6cbc330f55 fix: 恢复服务重启后的自动导入任务 2026-09-04 11:35:28 +08:00
d7b0d442eb fix: 统一文档解析文件格式校验
- 统一知识库和工作流支持格式并增加前后端上传拦截

- 拒绝 XLS 与伪装 XLSX,避免空内容解析成功
2026-09-02 19:15:35 +08:00
198d592dd6 fix: 补齐知识库工作流文档标题 2026-09-02 18:50:23 +08:00
7257f41eb8 fix: 修复接口说明弹窗小窗口展示 2026-09-02 16:47:38 +08:00
e9fc0bd810 fix: 支持关闭自动导入状态提示 2026-09-02 16:47:33 +08:00
9ef20119a9 fix: 修复 Hash 路由跳转丢失部署基路径
- 修复懒加载分包失败后跳转到站点根路径的问题

- 统一管理端和用户中心新窗口路由解析

- 增加部署基路径与 Hash 路由回归测试
2026-08-31 18:48:01 +08:00
1bd9810518 feat: 支持工作流对话匿名分享
- 增加免登录公共接口、访客隔离、限流和匿名上传校验

- 分离 SSE 连接与运行生命周期,支持刷新恢复服务端权威状态

- 持久化分享页对话并优化时间线滚动与输入区交互
2026-08-31 16:45:48 +08:00
fa174f6c16 feat: M28 增加工作流汇聚可视化配置 2026-08-31 15:56:07 +08:00
e40bd9dc82 feat: M28 增加工作流汇聚安全校验 2026-08-31 15:56:06 +08:00
38078741f2 fix: 修复智能体聊天图片能力判断 2026-08-31 15:11:07 +08:00
1d3147e7bf fix: 统一工作流空媒体参数处理 2026-08-31 15:03:05 +08:00
a771affc5d fix: 补全前端容器公共 API 代理
- 将 /flow/public-api/ 请求转发到后端 /public-api/

- 保持工作流等公共接口的同源访问路径
2026-08-31 13:47:15 +08:00
be10eabb64 feat: 接入一库一工具知识检索
- 编译知识库英文运行名、描述、检索配置和独立 Registration

- 统一最终分数阈值并保持模型上下文、检索事件与引用一致

- 完善 AG-UI 知识库检索运行态与完成态展示
2026-08-29 15:44:54 +08:00
7e1490d5f8 fix: 支持可信内部文件引用读取
- 通过服务端文件记录和存储配置恢复可信物理读取句柄

- 文档解析与工作流文档节点优先读取已确认的内部存储对象

- 补齐路径校验、记录异常和读取边界测试
2026-08-29 15:44:53 +08:00
7aed4bcc37 fix: 避免空引用集合触发无效查询
- Agent 与 Skill 引用查询在空 ID 集合时直接返回

- 补充不调用 listByIds 的回归测试
2026-08-26 23:29:00 +08:00
6248e2c7b8 fix: 支持模型异常后继续文档对话
- 将本轮文档上下文纳入可持久化的 Agent 用户消息

=- 在异常提示中提供请重试动作并自动发送继续
2026-08-26 22:56:31 +08:00
4de8cc5bd0 feat: 优化工作流运行输入交互
- 合并运行参数与问题输入并锁定首轮参数

- 优化文件上传、参数摘要与十二小时草稿恢复

- 补充输入表单国际化与相关测试
2026-08-26 22:49:02 +08:00
71b3d3d620 fix: 修正工作流试运行参数名称展示
- 默认字段标题回退为实际配置的参数名称

- 保留显式展示标题并统一表单与引用口径
2026-08-26 22:49:01 +08:00
1870ac4028 fix: 修正自动导入完成提示
- 根据关联文档真实状态识别已完成的历史失败批次

- 完成后自动移除提示并低频复查失败或中断批次
2026-08-26 18:58:54 +08:00
2a383ef3f2 Merge pull request '发布 v1.10' (#5) from develop into main
Reviewed-on: #5
2026-08-20 11:36:26 +08:00
fd64073148 release: 发布v1.1.0 2026-08-20 11:34:00 +08:00
f63cd9be4d fix: 保留多次上下文整理记录
- 为同一轮多次整理分配独立状态键

- 防止后续整理覆盖已完成的历史记录
2026-08-20 11:24:55 +08:00
407b85c8a9 fix: 移除 Timeline 合成处理状态
- 仅依据真实思考、工具和状态事件展示处理过程

- 更新快速工具调用状态测试
2026-08-20 11:24:41 +08:00
9078ca163e fix: 保证工具调用开始态可见
- 工具开始事件后让出一次有界绘制机会

- 覆盖快速工具调用的状态可见性测试
2026-08-20 11:24:25 +08:00
7f083a9433 fix: 明确 Skill Git 访问策略注入构造器
- 标记生产构造器供 Spring 容器稳定选择

- 补充组件实例化回归测试
2026-08-20 11:24:09 +08:00
7d654c3302 feat: 优化 Agent Studio 试运行交互
- 将内置工具配置拆分为独立页签

- 支持试运行面板平滑居中展开和快捷收起

- 运行和审批期间禁用会话清理
2026-08-20 11:18:37 +08:00
fa07134cf8 feat: 支持 Agent 运行刷新恢复
- 解耦 Runtime 与浏览器 SSE 订阅并增加 Redis 游标日志

- 支持正式聊天与草稿试运行刷新重连和权威终态恢复

- 完善显式取消、owner 丢失、容量限制与故障测试
2026-08-20 11:18:10 +08:00
310fc1fb58 fix: 修复 Jcseg 可执行包资源加载
- 将 jcseg-core 标记为 Spring Boot 启动时解包依赖
2026-08-19 22:43:53 +08:00
c96f772f01 fix: 隔离 LibreOffice 并发转换配置
- 为每次 soffice 调用创建独立临时用户配置目录

- 转换结束后自动清理并保持无头安全启动参数
2026-08-19 22:42:25 +08:00
30b2cc36fd fix: 保留 Agent 手动压缩阈值
- 保存和试用时不再覆盖已配置的 Token 压缩门槛
2026-08-19 22:41:17 +08:00
34ff62d317 Merge pull request 'fix: 修复 ip 地址登录系统报错,ts 方法兼容旧版本浏览器' (#4) from hotfix/encrypt_compatibility into main
Reviewed-on: #4
2026-06-24 17:00:02 +08:00
333 changed files with 32227 additions and 4107 deletions

View File

@@ -129,8 +129,9 @@ RUN fc-cache -f && \
mkdir -p /app/logs /app/artifacts /app/data && \
chown -R easyflow:easyflow /app
COPY docker-soffice-wrapper.sh /usr/local/bin/soffice
COPY docker-entrypoint.sh /usr/local/bin/easyflow-entrypoint.sh
RUN chmod 755 /usr/local/bin/easyflow-entrypoint.sh
RUN chmod 755 /usr/local/bin/soffice /usr/local/bin/easyflow-entrypoint.sh
VOLUME ["/app/logs", "/app/data"]
EXPOSE 8111

21
docker-soffice-wrapper.sh Normal file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env sh
set -eu
profile_parent="${TMPDIR:-/tmp}"
profile_dir="$(mktemp -d "${profile_parent%/}/easyflow-soffice-XXXXXX")"
cleanup() {
rm -rf -- "$profile_dir"
}
trap cleanup EXIT HUP INT TERM
/usr/bin/soffice \
-env:UserInstallation="file://${profile_dir}" \
--headless \
--safe-mode \
--nologo \
--nodefault \
--nolockcheck \
--norestore \
"$@"

View File

@@ -31,6 +31,7 @@ import tech.easyflow.agent.runtime.AgentChatRequest;
import tech.easyflow.agent.runtime.AgentDraftChatRequest;
import tech.easyflow.agent.runtime.AgentRunService;
import tech.easyflow.agent.runtime.agui.AgentAguiHitlResolveRequest;
import tech.easyflow.agent.runtime.agui.AgentAguiRunStatusView;
import tech.easyflow.agent.runtime.composer.AgentComposerDraft;
import tech.easyflow.agent.runtime.composer.AgentComposerDraftService;
import tech.easyflow.agent.runtime.composer.AgentComposerSession;
@@ -319,6 +320,51 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
return agentRunService.chatDraftAgui(input);
}
/**
* 查询可重连 AG-UI 运行状态。
*
* @param runId 客户端运行 ID
* @return 运行状态
*/
@GetMapping("/agui/run/{runId}/status")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public Result<AgentAguiRunStatusView> getAguiRunStatus(@PathVariable String runId) {
return Result.ok(agentRunService.getAguiRunStatus(runId));
}
/**
* 从指定游标继续订阅 AG-UI 运行事件。
*
* @param runId 客户端运行 ID
* @param after 已消费的最后事件游标
* @return 增量重放 SSE
*/
@GetMapping("/agui/run/{runId}/events")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public SseEmitter subscribeAguiRun(@PathVariable String runId,
@RequestParam(defaultValue = "0") long after) {
return agentRunService.subscribeAguiRun(runId, after);
}
/**
* 显式取消单次 AG-UI 运行。
*
* @param runId 客户端运行 ID
* @return 操作结果
*/
@PostMapping("/agui/run/{runId}/cancel")
@SaCheckPermission(value = {
"/api/v1/agent/session/query", "/api/v1/agent/save"
}, mode = SaMode.OR)
public Result<Void> cancelAguiRun(@PathVariable String runId) {
agentRunService.cancelAguiRun(runId);
return Result.ok();
}
/**
* 处理 AG-UI 自定义 HITL 兼容桥审批。
*

View File

@@ -1,23 +1,18 @@
package tech.easyflow.admin.controller.ai;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.easyagents.core.model.embedding.EmbeddingModel;
import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryWrapper;
import tech.easyflow.ai.dto.DocumentChunkContentUpdateRequest;
import tech.easyflow.ai.dto.DocumentChunkSyncRetryRequest;
import tech.easyflow.ai.dto.DocumentChunkSyncStatus;
import tech.easyflow.ai.dto.DocumentChunkSyncStatusRequest;
import tech.easyflow.ai.entity.DocumentChunk;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.service.DocumentChunkService;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.ai.support.DocumentStoreLifecycleSupport;
import tech.easyflow.common.annotation.UsePermission;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.controller.BaseController;
import tech.easyflow.common.web.jsonbody.JsonBody;
import com.easyagents.core.document.Document;
import com.easyagents.core.store.DocumentStore;
import com.easyagents.core.store.StoreOptions;
import com.easyagents.core.store.StoreResult;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.GetMapping;
@@ -28,12 +23,8 @@ import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.enums.ResourceLookup;
import tech.easyflow.system.permission.resource.RequireResourceAccess;
import javax.annotation.Resource;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 控制层。
@@ -44,19 +35,12 @@ import java.util.Map;
@RestController
@RequestMapping("/api/v1/documentChunk")
@UsePermission(moduleName = "/api/v1/documentCollection")
public class DocumentChunkController extends BaseCurdController<DocumentChunkService, DocumentChunk> {
public class DocumentChunkController extends BaseController {
@Resource
DocumentCollectionService documentCollectionService;
@Resource
ModelService modelService;
@Resource
DocumentChunkService documentChunkService;
private final DocumentChunkService documentChunkService;
public DocumentChunkController(DocumentChunkService service) {
super(service);
this.documentChunkService = service;
}
@GetMapping("page")
@@ -68,9 +52,30 @@ public class DocumentChunkController extends BaseCurdController<DocumentChunkSer
idExpr = "#request.getParameter('documentId')",
denyMessage = "无权限访问知识库"
)
@Override
public Result<Page<DocumentChunk>> page(HttpServletRequest request, String sortKey, String sortType, Long pageNumber, Long pageSize) {
return super.page(request, sortKey, sortType, pageNumber, pageSize);
public Result<Page<DocumentChunk>> page(
HttpServletRequest request,
Long pageNumber,
Long pageSize
) {
String documentIdValue = request.getParameter("documentId");
if (documentIdValue == null || documentIdValue.isBlank()) {
return Result.<Page<DocumentChunk>>fail("documentId不能为空", null);
}
BigInteger documentId;
try {
documentId = new BigInteger(documentIdValue);
} catch (NumberFormatException e) {
return Result.<Page<DocumentChunk>>fail("documentId格式不正确", null);
}
long normalizedPageNumber = pageNumber == null || pageNumber < 1 ? 1 : pageNumber;
long normalizedPageSize = pageSize == null || pageSize < 1 ? 10 : pageSize;
QueryWrapper query = QueryWrapper.create()
.eq(DocumentChunk::getDocumentId, documentId)
.orderBy("sorting asc");
return Result.ok(documentChunkService.page(
new Page<>(normalizedPageNumber, normalizedPageSize),
query
));
}
@PostMapping("update")
@@ -79,43 +84,23 @@ public class DocumentChunkController extends BaseCurdController<DocumentChunkSer
resource = CategoryResourceType.KNOWLEDGE,
action = ResourceAction.MANAGE,
lookup = ResourceLookup.DOCUMENT_CHUNK_ID,
idExpr = "#documentChunk.id",
idExpr = "#request.id",
denyMessage = "无权限管理知识库"
)
public Result<?> update(@JsonBody DocumentChunk documentChunk) {
boolean success = service.updateById(documentChunk);
if (success){
DocumentChunk record = documentChunkService.getById(documentChunk.getId());
DocumentCollection knowledge = documentCollectionService.getById(record.getDocumentCollectionId());
if (knowledge == null) {
return Result.fail(1, "知识库不存在");
}
DocumentStore documentStore = knowledge.toDocumentStore();
if (documentStore == null) {
return Result.fail(2, "知识库没有配置向量库");
}
try {
// 设置向量模型
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
if (model == null) {
return Result.fail(3, "知识库没有配置向量模型");
}
EmbeddingModel embeddingModel = model.toEmbeddingModel();
documentStore.setEmbeddingModel(embeddingModel);
StoreOptions options = StoreOptions.ofCollectionName(knowledge.getVectorStoreCollection());
Document document = Document.of(documentChunk.getContent());
document.setId(documentChunk.getId());
Map<String, Object> metadata = new HashMap<>();
metadata.put("keywords", documentChunk.getMetadataKeyWords());
metadata.put("questions", documentChunk.getMetadataQuestions());
document.setMetadataMap(metadata);
StoreResult result = documentStore.update(document, options); // 更新已有记录
return Result.ok(result);
} finally {
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
}
public Result<?> update(
@JsonBody(required = true, skipConvertError = false)
DocumentChunkContentUpdateRequest request
) {
DocumentChunk current = documentChunkService.getById(request.getId());
if (current == null) {
return Result.fail(1, "记录不存在");
}
return Result.ok(false);
DocumentChunk updated = documentChunkService.updateContent(
current.getDocumentCollectionId(),
current.getId(),
request.getContent()
);
return Result.ok(updated);
}
@PostMapping("removeChunk")
@@ -127,36 +112,58 @@ public class DocumentChunkController extends BaseCurdController<DocumentChunkSer
idExpr = "#chunkId",
denyMessage = "无权限管理知识库"
)
public Result<?> remove(@JsonBody(value = "id", required = true) BigInteger chunkId) {
DocumentChunk docChunk = documentChunkService.getById(chunkId);
public Result<?> removeChunk(@JsonBody(value = "id", required = true) BigInteger chunkId) {
DocumentChunk docChunk = documentChunkService.getById(chunkId);
if (docChunk == null) {
return Result.fail(1, "记录不存在");
}
DocumentCollection knowledge = documentCollectionService.getById(docChunk.getDocumentCollectionId());
if (knowledge == null) {
return Result.fail(2, "知识库不存在");
}
DocumentStore documentStore = knowledge.toDocumentStore();
if (documentStore == null) {
return Result.fail(3, "知识库没有配置向量库");
}
try {
// 设置向量模型
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
if (model == null) {
return Result.fail(4, "知识库没有配置向量模型");
}
EmbeddingModel embeddingModel = model.toEmbeddingModel();
documentStore.setEmbeddingModel(embeddingModel);
StoreOptions options = StoreOptions.ofCollectionName(knowledge.getVectorStoreCollection());
List<BigInteger> deleteList = new ArrayList<>();
deleteList.add(chunkId);
documentStore.delete(deleteList, options);
documentChunkService.removeChunk(knowledge, chunkId);
return Result.ok(documentChunkService.deleteChunk(
docChunk.getDocumentCollectionId(),
chunkId
));
}
return super.remove(chunkId);
} finally {
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
@PostMapping("syncStatus")
@SaCheckPermission("/api/v1/documentCollection/query")
@RequireResourceAccess(
resource = CategoryResourceType.KNOWLEDGE,
action = ResourceAction.READ,
lookup = ResourceLookup.DOCUMENT_ID,
idExpr = "#request.documentId",
denyMessage = "无权限访问知识库"
)
public Result<List<DocumentChunkSyncStatus>> syncStatus(
@JsonBody(required = true, skipConvertError = false)
DocumentChunkSyncStatusRequest request
) {
return Result.ok(documentChunkService.listIndexSyncStatus(
null,
request.getDocumentId(),
request.getIds()
));
}
@PostMapping("retrySync")
@SaCheckPermission("/api/v1/documentCollection/save")
@RequireResourceAccess(
resource = CategoryResourceType.KNOWLEDGE,
action = ResourceAction.MANAGE,
lookup = ResourceLookup.DOCUMENT_CHUNK_ID,
idExpr = "#request.id",
denyMessage = "无权限管理知识库"
)
public Result<?> retrySync(
@JsonBody(required = true, skipConvertError = false)
DocumentChunkSyncRetryRequest request
) {
DocumentChunk current = documentChunkService.getById(request.getId());
if (current == null || request.getIndexSyncVersion() == null) {
return Result.fail(1, "记录不存在或同步版本缺失");
}
return Result.ok(documentChunkService.retryIndexSync(
current.getDocumentCollectionId(),
current.getId(),
request.getIndexSyncVersion()
));
}
}

View File

@@ -118,12 +118,10 @@ public class DocumentController extends BaseCurdController<DocumentService, Docu
List<Serializable> ids = Collections.singletonList(id);
Result<?> result = onRemoveBefore(ids);
if (result != null) return result;
boolean isSuccess = documentService.removeDoc(id);
if (!isSuccess){
return Result.ok(false);
boolean success = documentService.removeDoc(id);
if (success) {
onRemoveAfter(ids);
}
boolean success = service.removeById(id);
onRemoveAfter(ids);
return Result.ok(success);
}

View File

@@ -18,10 +18,12 @@ import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.entity.WorkflowExecResult;
import tech.easyflow.ai.enums.PluginType;
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
import tech.easyflow.ai.service.PluginService;
@@ -29,6 +31,7 @@ import tech.easyflow.ai.service.PluginItemService;
import tech.easyflow.ai.service.AgentResourceReferenceService;
import tech.easyflow.ai.service.PluginVisibilityService;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.service.WorkflowExecResultService;
import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.annotation.UsePermission;
import tech.easyflow.common.domain.Result;
@@ -91,11 +94,15 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
@Resource
private WorkflowService workflowService;
@Resource
private WorkflowExecResultService workflowExecResultService;
@Resource
private ChainExecutor chainExecutor;
@Resource
private TinyFlowService tinyFlowService;
@Resource
private WorkflowCheckService workflowCheckService;
@Resource
private WorkflowResumeService workflowResumeService;
@PostMapping("/tool/save")
@SaCheckPermission("/api/v1/plugin/save")
@@ -215,6 +222,7 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
@SaCheckPermission("/api/v1/plugin/query")
public Result<ChainInfo> pluginToolTestChainStatus(@JsonBody(value = "executeId", required = true) String executeId,
@JsonBody("nodes") List<NodeInfo> nodes) {
assertPluginTestExecutionOwnership(executeId);
return Result.ok(tinyFlowService.getChainStatus(executeId, nodes));
}
@@ -229,10 +237,33 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
@SaCheckPermission("/api/v1/plugin/query")
public Result<Void> pluginToolTestResume(@JsonBody(value = "executeId", required = true) String executeId,
@JsonBody("confirmParams") Map<String, Object> confirmParams) {
chainExecutor.resumeAsync(executeId, confirmParams);
assertPluginTestExecutionOwnership(executeId);
workflowResumeService.resume(executeId, confirmParams);
return Result.ok();
}
/**
* 校验插件试运行实例由当前登录用户发起。
*
* @param executeId 执行实例 ID
*/
private void assertPluginTestExecutionOwnership(String executeId) {
if (StrUtil.isBlank(executeId)) {
throw new BusinessException("执行ID不能为空");
}
WorkflowExecResult record = workflowExecResultService.getByExecKey(executeId);
if (record == null) {
throw new BusinessException(404, 404, "工作流执行记录不存在或已过期");
}
LoginAccount currentAccount = SaTokenUtil.getLoginAccount();
if (currentAccount == null
|| currentAccount.getId() == null
|| record.getCreatedBy() == null
|| !currentAccount.getId().toString().equals(record.getCreatedBy())) {
throw new BusinessException(403, 403, "无权限访问当前插件试运行实例");
}
}
private void handleArray(JSONArray array) {
for (Object o : array) {
JSONObject obj = (JSONObject) o;

View File

@@ -1,10 +1,6 @@
package tech.easyflow.admin.controller.ai;
import cn.hutool.core.io.IoUtil;
import com.easyagents.core.model.embedding.EmbeddingModel;
import com.easyagents.core.store.DocumentStore;
import com.easyagents.core.store.StoreOptions;
import com.easyagents.core.store.StoreResult;
import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryColumn;
import com.mybatisflex.core.query.QueryWrapper;
@@ -22,6 +18,11 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.ai.documentimport.DocumentImportDtos;
import tech.easyflow.ai.documentimport.task.DocumentImportTaskStatusStreamService;
import tech.easyflow.ai.dto.DocumentChunkContentUpdateRequest;
import tech.easyflow.ai.dto.DocumentChunkDeleteResult;
import tech.easyflow.ai.dto.DocumentChunkSyncRetryRequest;
import tech.easyflow.ai.dto.DocumentChunkSyncStatus;
import tech.easyflow.ai.dto.DocumentChunkSyncStatusRequest;
import tech.easyflow.ai.dto.KnowledgeShareLimitedConfigRequest;
import tech.easyflow.ai.dto.KnowledgeSearchResultItem;
import tech.easyflow.ai.entity.Document;
@@ -43,7 +44,6 @@ import tech.easyflow.ai.service.KnowledgeEmbeddingService;
import tech.easyflow.ai.service.KnowledgeShareAuditService;
import tech.easyflow.ai.service.KnowledgeShareService;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.ai.support.DocumentStoreLifecycleSupport;
import tech.easyflow.ai.vo.FaqImportResultVo;
import tech.easyflow.ai.vo.KnowledgeShareAuthContext;
import tech.easyflow.ai.vo.KnowledgeShareViewDetail;
@@ -62,7 +62,6 @@ import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
@@ -505,43 +504,27 @@ public class ShareKnowledgeController {
@PostMapping("/documentChunk/update")
public Result<?> updateDocumentChunk(
@RequestParam String shareKey,
@JsonBody DocumentChunk documentChunk
@JsonBody(required = true, skipConvertError = false)
DocumentChunkContentUpdateRequest request
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
null,
KnowledgeShareActionScope.CONTENT_UPDATE.name()
);
DocumentChunk current = documentChunkService.getById(documentChunk.getId());
DocumentChunk current = documentChunkService.getById(request.getId());
if (current == null || current.getDocumentCollectionId() == null
|| current.getDocumentCollectionId().compareTo(context.getKnowledge().getId()) != 0) {
throw new BusinessException("记录不存在");
}
boolean success = documentChunkService.updateById(documentChunk);
if (success) {
DocumentStore documentStore = context.getKnowledge().toDocumentStore();
if (documentStore == null) {
return Result.fail(2, "知识库没有配置向量库");
}
try {
Model model = modelService.getModelInstance(context.getKnowledge().getVectorEmbedModelId());
if (model == null) {
return Result.fail(3, "知识库没有配置向量模型");
}
EmbeddingModel embeddingModel = model.toEmbeddingModel();
documentStore.setEmbeddingModel(embeddingModel);
StoreOptions options = StoreOptions.ofCollectionName(context.getKnowledge().getVectorStoreCollection());
com.easyagents.core.document.Document doc = com.easyagents.core.document.Document.of(documentChunk.getContent());
doc.setId(documentChunk.getId());
StoreResult result = documentStore.update(doc, options);
audit(context, "更新分享文档 Chunk", "KNOWLEDGE_SHARE_URL_WRITE", true,
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", documentChunk.getId()));
return Result.ok(result);
} finally {
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
}
}
return Result.ok(false);
DocumentChunk updated = documentChunkService.updateContent(
context.getKnowledge().getId(),
current.getId(),
request.getContent()
);
audit(context, "更新分享文档 Chunk", "KNOWLEDGE_SHARE_URL_WRITE", true,
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", request.getId()));
return Result.ok(updated);
}
/**
@@ -562,25 +545,50 @@ public class ShareKnowledgeController {
|| current.getDocumentCollectionId().compareTo(context.getKnowledge().getId()) != 0) {
return Result.fail(1, "记录不存在");
}
DocumentStore documentStore = context.getKnowledge().toDocumentStore();
if (documentStore == null) {
return Result.fail(2, "知识库没有配置向量库");
DocumentChunkDeleteResult removed = documentChunkService.deleteChunk(
context.getKnowledge().getId(),
chunkId
);
audit(context, "删除分享文档 Chunk", "KNOWLEDGE_SHARE_URL_WRITE", true,
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", chunkId));
return Result.ok(removed);
}
@PostMapping("/documentChunk/syncStatus")
public Result<List<DocumentChunkSyncStatus>> documentChunkSyncStatus(
@RequestParam String shareKey,
@JsonBody DocumentChunkSyncStatusRequest request
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey, null, KnowledgeShareActionScope.VIEW.name()
);
Document document = documentService.getById(request.getDocumentId());
if (document == null || document.getCollectionId() == null
|| document.getCollectionId().compareTo(context.getKnowledge().getId()) != 0) {
throw new BusinessException("文档不存在");
}
try {
Model model = modelService.getModelInstance(context.getKnowledge().getVectorEmbedModelId());
if (model == null) {
return Result.fail(3, "知识库没有配置向量模型");
}
documentStore.setEmbeddingModel(model.toEmbeddingModel());
StoreOptions options = StoreOptions.ofCollectionName(context.getKnowledge().getVectorStoreCollection());
documentStore.delete(Collections.singletonList(chunkId), options);
documentChunkService.removeById(chunkId);
audit(context, "删除分享文档 Chunk", "KNOWLEDGE_SHARE_URL_WRITE", true,
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", chunkId));
return Result.ok(true);
} finally {
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
return Result.ok(documentChunkService.listIndexSyncStatus(
context.getKnowledge().getId(), request.getDocumentId(), request.getIds()
));
}
@PostMapping("/documentChunk/retrySync")
public Result<DocumentChunk> retryDocumentChunkSync(
@RequestParam String shareKey,
@JsonBody DocumentChunkSyncRetryRequest request
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey, null, KnowledgeShareActionScope.CONTENT_UPDATE.name()
);
if (request.getIndexSyncVersion() == null) {
throw new BusinessException("同步版本不能为空");
}
DocumentChunk chunk = documentChunkService.retryIndexSync(
context.getKnowledge().getId(), request.getId(), request.getIndexSyncVersion()
);
audit(context, "重试分享文档 Chunk 索引同步", "KNOWLEDGE_SHARE_URL_WRITE", true,
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", request.getId()));
return Result.ok(chunk);
}
/**

View File

@@ -1,6 +1,5 @@
package tech.easyflow.admin.controller.ai;
import com.easyagents.flow.core.chain.ChainStatus;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import com.mybatisflex.core.query.QueryWrapper;
import jakarta.servlet.http.HttpServletRequest;
@@ -14,6 +13,7 @@ import tech.easyflow.admin.service.ai.WorkflowChatEventStream;
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.service.WorkflowResumeService;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.entity.WorkflowExecResult;
@@ -64,6 +64,8 @@ public class WorkflowChatController {
@Resource
private ChainExecutor chainExecutor;
@Resource
private WorkflowResumeService workflowResumeService;
@Resource
private WorkflowExecResultService execResultService;
@Resource
private WorkflowExecStepService execStepService;
@@ -171,19 +173,8 @@ public class WorkflowChatController {
@JsonBody("confirmParams")
Map<String, Object> confirmParams
) {
WorkflowExecResult record = assertExecutionOwnership(executeId);
if (record.getStatus() != null
&& (record.getStatus() == ChainStatus.SUCCEEDED.getValue()
|| record.getStatus() == ChainStatus.FAILED.getValue()
|| record.getStatus() == ChainStatus.CANCELLED.getValue())) {
throw new BusinessException("当前工作流执行已结束");
}
chainExecutor.resumeAsync(
executeId,
confirmParams == null
? new LinkedHashMap<>()
: new LinkedHashMap<>(confirmParams)
);
assertExecutionOwnership(executeId);
workflowResumeService.resume(executeId, confirmParams);
return Result.ok();
}

View File

@@ -31,6 +31,7 @@ import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.publish.WorkflowPublishAppService;
@@ -94,6 +95,8 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
@Resource
private WorkflowRunningParameterResolver workflowRunningParameterResolver;
@Resource
private WorkflowResumeService workflowResumeService;
@Resource
private ResourceAccessService resourceAccessService;
@Resource
private WorkflowVisibilityQueryHelper workflowVisibilityQueryHelper;
@@ -324,12 +327,7 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
)
public Result<Void> resume(@JsonBody(value = "executeId", required = true) String executeId,
@JsonBody("confirmParams") Map<String, Object> confirmParams) {
if (!chainExecutor.resumeAsyncIfSuspended(executeId, confirmParams)) {
throw new BusinessException(
409,
40901,
"当前执行状态不可恢复,仅暂停中的工作流允许恢复");
}
workflowResumeService.resume(executeId, confirmParams);
return Result.ok();
}

View File

@@ -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);
}
}

View File

@@ -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()));
}

View File

@@ -52,11 +52,17 @@ public record WorkflowDesignerOptionsView(
* @param id 知识库 ID
* @param title 知识库标题
* @param description 知识库描述
* @param vectorEmbedModelId Embedding 模型 ID
* @param dimensionOfVectorModel 向量维度
* @param vectorStoreEnabled 是否可用于向量检索
*/
public record KnowledgeOption(
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
String title,
String description
String description,
@JsonSerialize(using = ToStringSerializer.class) BigInteger vectorEmbedModelId,
Integer dimensionOfVectorModel,
Boolean vectorStoreEnabled
) {
}

View File

@@ -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);
}
}
}

View File

@@ -14,6 +14,7 @@ import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Service;
import tech.easyflow.admin.model.ai.WorkflowDesignerOptionsView;
import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService;
import tech.easyflow.ai.easyagentsflow.knowledge.WorkflowKnowledgeContractService;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.entity.ModelProvider;
@@ -75,6 +76,7 @@ public class WorkflowDesignerOptionService {
private final DatacenterSourceService datacenterSourceService;
private final DatacenterDatasetRegistryService datacenterDatasetRegistryService;
private final DatacenterDatasetQueryService datacenterDatasetQueryService;
private final WorkflowKnowledgeContractService workflowKnowledgeContractService;
/**
* 创建工作流设计器选项服务。
@@ -93,6 +95,7 @@ public class WorkflowDesignerOptionService {
* @param datacenterSourceService 数据源服务
* @param datacenterDatasetRegistryService 数据集注册服务
* @param datacenterDatasetQueryService 数据集查询服务
* @param workflowKnowledgeContractService 工作流知识库契约服务
*/
public WorkflowDesignerOptionService(
ModelService modelService,
@@ -108,7 +111,8 @@ public class WorkflowDesignerOptionService {
ResourceAccessService resourceAccessService,
DatacenterSourceService datacenterSourceService,
DatacenterDatasetRegistryService datacenterDatasetRegistryService,
DatacenterDatasetQueryService datacenterDatasetQueryService) {
DatacenterDatasetQueryService datacenterDatasetQueryService,
WorkflowKnowledgeContractService workflowKnowledgeContractService) {
this.modelService = modelService;
this.documentCollectionService = documentCollectionService;
this.pluginService = pluginService;
@@ -123,6 +127,7 @@ public class WorkflowDesignerOptionService {
this.datacenterSourceService = datacenterSourceService;
this.datacenterDatasetRegistryService = datacenterDatasetRegistryService;
this.datacenterDatasetQueryService = datacenterDatasetQueryService;
this.workflowKnowledgeContractService = workflowKnowledgeContractService;
}
/**
@@ -163,6 +168,7 @@ public class WorkflowDesignerOptionService {
LoginAccount account = requireAccount();
Set<BigInteger> modelIds = new HashSet<>();
Set<BigInteger> knowledgeIds = new HashSet<>();
List<List<BigInteger>> knowledgeGroups = new ArrayList<>();
Set<BigInteger> checkedPluginItemIds = new HashSet<>();
Set<BigInteger> checkedWorkflowIds = new HashSet<>();
Set<BigInteger> checkedSourceIds = new HashSet<>();
@@ -177,14 +183,22 @@ public class WorkflowDesignerOptionService {
if (data == null) {
continue;
}
String nodeType = data.getString("type");
String nodeType = node.getString("type");
String dataType = data.getString("type");
if (nodeType != null && !nodeType.isBlank()
&& dataType != null && !dataType.isBlank()
&& !Objects.equals(nodeType, dataType)) {
throw new BusinessException("工作流节点类型与节点数据类型不一致");
}
if (nodeType == null || nodeType.isBlank()) {
nodeType = node.getString("type");
nodeType = dataType;
}
if ("llmNode".equals(nodeType)) {
addReferenceId(modelIds, readReferenceId(data, "llmId", "模型"));
} else if ("knowledgeNode".equals(nodeType)) {
addReferenceId(knowledgeIds, readReferenceId(data, "knowledgeId", "知识库"));
List<BigInteger> nodeKnowledgeIds = readKnowledgeReferenceIds(data);
knowledgeIds.addAll(nodeKnowledgeIds);
knowledgeGroups.add(nodeKnowledgeIds);
} else if ("plugin-node".equals(nodeType)) {
BigInteger pluginItemId = readReferenceId(data, "pluginId", "插件工具");
if (pluginItemId != null && checkedPluginItemIds.add(pluginItemId)) {
@@ -198,6 +212,8 @@ public class WorkflowDesignerOptionService {
}
assertModelReferences(modelIds, account);
assertKnowledgeReferences(knowledgeIds, account);
workflowKnowledgeContractService.assertMultiKnowledgeContracts(
knowledgeGroups, account.getTenantId());
}
/**
@@ -460,17 +476,55 @@ public class WorkflowDesignerOptionService {
}
private List<WorkflowDesignerOptionsView.KnowledgeOption> listKnowledgeOptions(LoginAccount account) {
return documentCollectionService.list(QueryWrapper.create()
List<DocumentCollection> collections = documentCollectionService.list(QueryWrapper.create()
.eq(DocumentCollection::getTenantId, account.getTenantId())
.orderBy(DocumentCollection::getModified, false))
.stream()
.orderBy(DocumentCollection::getModified, false));
Set<BigInteger> vectorReadyIds = workflowKnowledgeContractService
.findVectorReadyKnowledgeIds(collections, account.getTenantId());
return collections.stream()
.filter(item -> resourceAccessService.canAccess(
account, CategoryResourceType.KNOWLEDGE, item, ResourceAction.USE))
.map(item -> new WorkflowDesignerOptionsView.KnowledgeOption(
item.getId(), item.getTitle(), item.getDescription()))
item.getId(),
item.getTitle(),
item.getDescription(),
item.getVectorEmbedModelId(),
item.getDimensionOfVectorModel(),
vectorReadyIds.contains(item.getId())))
.toList();
}
private List<BigInteger> readKnowledgeReferenceIds(JSONObject data) {
if (data.containsKey("knowledgeIds")) {
Object rawIds = data.get("knowledgeIds");
if (!(rawIds instanceof JSONArray ids) || ids.isEmpty()) {
throw new BusinessException("知识库节点至少需要选择一个知识库");
}
List<BigInteger> result = new ArrayList<>();
for (Object id : ids) {
BigInteger parsed = parseReferenceId(id, "知识库");
if (result.contains(parsed)) {
throw new BusinessException("知识库节点不能重复选择同一知识库");
}
result.add(parsed);
}
return result;
}
BigInteger legacyId = readReferenceId(data, "knowledgeId", "知识库");
return legacyId == null ? List.of() : List.of(legacyId);
}
private BigInteger parseReferenceId(Object value, String resourceName) {
if (value == null || String.valueOf(value).isBlank()) {
throw new BusinessException(resourceName + "ID不能为空");
}
try {
return new BigInteger(String.valueOf(value));
} catch (NumberFormatException exception) {
throw new BusinessException(resourceName + "ID无效");
}
}
private void addReferenceId(Set<BigInteger> resourceIds, BigInteger resourceId) {
if (resourceId != null) {
resourceIds.add(resourceId);

View File

@@ -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
);
}
}

View File

@@ -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
) {
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,326 @@
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.service.WorkflowResumeService;
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 WorkflowResumeService workflowResumeService;
private final WorkflowExecResultService execResultService;
private final WorkflowExecStepService execStepService;
public WorkflowPublicChatService(
WorkflowPublicChatContextResolver contextResolver,
WorkflowCheckService workflowCheckService,
WorkflowRunningParameterResolver parameterResolver,
WorkflowPublicChatUploadService uploadService,
WorkflowPublicChatAccessGuard accessGuard,
WorkflowChatEventStream eventStream,
ChainExecutor chainExecutor,
WorkflowResumeService workflowResumeService,
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.workflowResumeService = workflowResumeService;
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);
assertExecutionOwnership(context, executeId);
workflowResumeService.resume(executeId, 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 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();
}
}
}

View File

@@ -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 + " 不属于当前分享访客"
);
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,120 @@
package tech.easyflow.admin.controller.ai;
import org.testng.Assert;
import org.testng.annotations.Test;
import org.springframework.web.bind.annotation.PostMapping;
import tech.easyflow.ai.dto.DocumentChunkContentUpdateRequest;
import tech.easyflow.ai.entity.DocumentChunk;
import tech.easyflow.ai.service.DocumentChunkService;
import tech.easyflow.common.web.controller.BaseController;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.system.permission.resource.RequireResourceAccess;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.math.BigInteger;
import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 文档分块维护接口契约测试。
*/
public class DocumentChunkControllerContractTest {
@Test
public void updateEndpointsShouldOnlyAcceptChunkIdAndContent() throws Exception {
Method adminUpdate = DocumentChunkController.class.getDeclaredMethod(
"update",
DocumentChunkContentUpdateRequest.class
);
Method shareUpdate = ShareKnowledgeController.class.getDeclaredMethod(
"updateDocumentChunk",
String.class,
DocumentChunkContentUpdateRequest.class
);
Assert.assertNotNull(adminUpdate);
Assert.assertNotNull(shareUpdate);
assertStrictJsonBody(adminUpdate.getParameters()[0]);
assertStrictJsonBody(shareUpdate.getParameters()[1]);
Assert.assertEquals(
adminUpdate.getAnnotation(RequireResourceAccess.class).idExpr(),
"#request.id"
);
Set<String> fields = Arrays.stream(
DocumentChunkContentUpdateRequest.class.getDeclaredFields()
)
.map(Field::getName)
.collect(Collectors.toSet());
Assert.assertEquals(Set.of("id", "content"), fields);
Assert.assertEquals(
DocumentChunkController.class.getMethod(
"update",
DocumentChunkContentUpdateRequest.class
).getDeclaringClass(),
DocumentChunkController.class
);
Assert.assertEquals(
Arrays.stream(DocumentChunkController.class.getDeclaredMethods())
.filter(method -> method.getName().equals("update"))
.filter(method -> !method.isBridge() && !method.isSynthetic())
.count(),
1L
);
}
@Test
public void controllerShouldNotExposeGenericWriteEndpoints() {
Assert.assertEquals(DocumentChunkController.class.getSuperclass(), BaseController.class);
Set<String> postMappings = Arrays.stream(DocumentChunkController.class.getMethods())
.map(method -> method.getAnnotation(PostMapping.class))
.filter(annotation -> annotation != null)
.flatMap(annotation -> Arrays.stream(annotation.value()))
.collect(Collectors.toSet());
Assert.assertEquals(
postMappings,
Set.of("update", "removeChunk", "syncStatus", "retrySync")
);
Assert.assertFalse(postMappings.contains("save"));
Assert.assertFalse(postMappings.contains("remove"));
Assert.assertFalse(postMappings.contains("removeBatch"));
}
@Test
public void adminUpdateAndDeleteShouldUseUnifiedMaintenanceService() {
DocumentChunkService service = mock(DocumentChunkService.class);
DocumentChunkController controller = new DocumentChunkController(service);
DocumentChunk current = new DocumentChunk();
current.setId(BigInteger.ONE);
current.setDocumentCollectionId(BigInteger.TWO);
when(service.getById(BigInteger.ONE)).thenReturn(current);
DocumentChunkContentUpdateRequest request = new DocumentChunkContentUpdateRequest();
request.setId(BigInteger.ONE);
request.setContent("updated");
controller.update(request);
controller.removeChunk(BigInteger.ONE);
verify(service).updateContent(BigInteger.TWO, BigInteger.ONE, "updated");
verify(service).deleteChunk(BigInteger.TWO, BigInteger.ONE);
verify(service, never()).updateById(any(DocumentChunk.class));
verify(service, never()).removeById(any());
}
private static void assertStrictJsonBody(Parameter parameter) {
JsonBody jsonBody = parameter.getAnnotation(JsonBody.class);
Assert.assertNotNull(jsonBody);
Assert.assertTrue(jsonBody.required());
Assert.assertFalse(jsonBody.skipConvertError());
}
}

View File

@@ -7,21 +7,27 @@ import org.testng.Assert;
import org.testng.annotations.Test;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.entity.WorkflowExecResult;
import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
import tech.easyflow.ai.service.AgentResourceReferenceService;
import tech.easyflow.ai.service.PluginItemService;
import tech.easyflow.ai.service.PluginService;
import tech.easyflow.ai.service.PluginVisibilityService;
import tech.easyflow.ai.service.WorkflowExecResultService;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
@@ -68,6 +74,61 @@ public class PluginItemControllerTest {
verify(visibilityService).assertPluginVisible(1L, BigInteger.TEN, "无权限删除该插件工具");
}
/**
* 验证当前用户不能恢复其他用户发起的插件试运行实例。
*/
@Test
public void testResumeShouldRejectAnotherUsersExecution() {
PluginItemService pluginItemService = mock(PluginItemService.class);
WorkflowExecResultService execResultService = mock(WorkflowExecResultService.class);
WorkflowResumeService resumeService = mock(WorkflowResumeService.class);
WorkflowExecResult record = new WorkflowExecResult();
record.setCreatedBy(BigInteger.ONE.toString());
when(execResultService.getByExecKey("execution-1")).thenReturn(record);
PluginItemController controller = new PluginItemController(pluginItemService);
setField(controller, "workflowExecResultService", execResultService);
setField(controller, "workflowResumeService", resumeService);
LoginAccount currentAccount = new LoginAccount();
currentAccount.setId(BigInteger.TWO);
try (MockedStatic<SaTokenUtil> login = mockStatic(SaTokenUtil.class)) {
login.when(SaTokenUtil::getLoginAccount).thenReturn(currentAccount);
BusinessException error = Assert.expectThrows(
BusinessException.class,
() -> controller.pluginToolTestResume("execution-1", Map.of())
);
Assert.assertEquals(error.getHttpStatus(), 403);
Assert.assertEquals(error.getErrorCode(), 403);
}
verifyNoInteractions(resumeService);
}
/**
* 验证当前用户可以恢复自己发起的插件试运行实例。
*/
@Test
public void testResumeShouldAllowExecutionOwner() {
PluginItemService pluginItemService = mock(PluginItemService.class);
WorkflowExecResultService execResultService = mock(WorkflowExecResultService.class);
WorkflowResumeService resumeService = mock(WorkflowResumeService.class);
WorkflowExecResult record = new WorkflowExecResult();
record.setCreatedBy(BigInteger.ONE.toString());
when(execResultService.getByExecKey("execution-1")).thenReturn(record);
PluginItemController controller = new PluginItemController(pluginItemService);
setField(controller, "workflowExecResultService", execResultService);
setField(controller, "workflowResumeService", resumeService);
LoginAccount currentAccount = new LoginAccount();
currentAccount.setId(BigInteger.ONE);
try (MockedStatic<SaTokenUtil> login = mockStatic(SaTokenUtil.class)) {
login.when(SaTokenUtil::getLoginAccount).thenReturn(currentAccount);
controller.pluginToolTestResume("execution-1", Map.of("choice", "A"));
}
verify(resumeService).resume("execution-1", Map.of("choice", "A"));
}
/**
* 创建插件工具。
*

View File

@@ -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);
}
}

View File

@@ -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();
}
}
}

View File

@@ -6,7 +6,10 @@ import org.mockito.MockedStatic;
import org.testng.Assert;
import org.testng.annotations.Test;
import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService;
import tech.easyflow.ai.easyagentsflow.knowledge.WorkflowKnowledgeContractService;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.entity.ModelProvider;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
import tech.easyflow.ai.service.DocumentCollectionService;
@@ -129,6 +132,96 @@ public class WorkflowDesignerOptionServiceTest {
}
}
@Test
public void shouldAcceptCompatibleMultiKnowledgeReferences() {
ModelService modelService = mock(ModelService.class);
DocumentCollectionService knowledgeService =
mock(DocumentCollectionService.class);
ResourceAccessService accessService = mock(ResourceAccessService.class);
when(accessService.canAccess(any(), any(), any(), any()))
.thenReturn(true);
when(knowledgeService.listByIds(any()))
.thenReturn(List.of(
knowledge(1, 7, 3),
knowledge(2, 7, 3)));
when(modelService.listModelInstances(any()))
.thenReturn(List.of(embeddingModel(7)));
WorkflowDesignerOptionService service = createService(
modelService,
knowledgeService,
mock(DatacenterSourceService.class),
mock(WorkflowService.class),
accessService);
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(loginAccount());
service.assertContentReferences("""
{"nodes":[{"type":"knowledgeNode","data":{
"knowledgeIds":["1","2"],"retrievalMode":"VECTOR"
}}]}
""");
}
}
@Test
public void shouldRejectIncompatibleMultiKnowledgeReferences() {
ModelService modelService = mock(ModelService.class);
DocumentCollectionService knowledgeService =
mock(DocumentCollectionService.class);
ResourceAccessService accessService = mock(ResourceAccessService.class);
when(accessService.canAccess(any(), any(), any(), any()))
.thenReturn(true);
when(knowledgeService.listByIds(any()))
.thenReturn(List.of(
knowledge(1, 7, 3),
knowledge(2, 8, 3)));
when(modelService.listModelInstances(any()))
.thenReturn(List.of(embeddingModel(7), embeddingModel(8)));
WorkflowDesignerOptionService service = createService(
modelService,
knowledgeService,
mock(DatacenterSourceService.class),
mock(WorkflowService.class),
accessService);
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(loginAccount());
BusinessException exception = Assert.expectThrows(
BusinessException.class,
() -> service.assertContentReferences("""
{"nodes":[{"type":"knowledgeNode","data":{
"knowledgeIds":["1","2"],"retrievalMode":"VECTOR"
}}]}
"""));
Assert.assertTrue(exception.getMessage().contains("Embedding"));
}
}
@Test
public void shouldRejectConflictingRootAndDataNodeTypes() {
WorkflowDesignerOptionService service = createService(
mock(ModelService.class),
mock(DocumentCollectionService.class),
mock(DatacenterSourceService.class));
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(loginAccount());
BusinessException exception = Assert.expectThrows(
BusinessException.class,
() -> service.assertContentReferences("""
{"nodes":[{"type":"knowledgeNode","data":{
"type":"llmNode","knowledgeId":"1"
}}]}
"""));
Assert.assertTrue(exception.getMessage().contains("类型"));
}
}
private WorkflowDesignerOptionService createService(
ModelService modelService,
DocumentCollectionService knowledgeService,
@@ -142,6 +235,20 @@ public class WorkflowDesignerOptionServiceTest {
DatacenterSourceService sourceService,
WorkflowService workflowService) {
ResourceAccessService resourceAccessService = mock(ResourceAccessService.class);
return createService(
modelService,
knowledgeService,
sourceService,
workflowService,
resourceAccessService);
}
private WorkflowDesignerOptionService createService(
ModelService modelService,
DocumentCollectionService knowledgeService,
DatacenterSourceService sourceService,
WorkflowService workflowService,
ResourceAccessService resourceAccessService) {
return new WorkflowDesignerOptionService(
modelService,
knowledgeService,
@@ -156,10 +263,40 @@ public class WorkflowDesignerOptionServiceTest {
resourceAccessService,
sourceService,
mock(DatacenterDatasetRegistryService.class),
mock(DatacenterDatasetQueryService.class)
mock(DatacenterDatasetQueryService.class),
new WorkflowKnowledgeContractService(
knowledgeService, modelService)
);
}
private DocumentCollection knowledge(
long id, long embeddingModelId, int dimension) {
DocumentCollection collection = new DocumentCollection();
collection.setId(BigInteger.valueOf(id));
collection.setTenantId(BigInteger.valueOf(100));
collection.setVectorEmbedModelId(BigInteger.valueOf(embeddingModelId));
collection.setDimensionOfVectorModel(dimension);
collection.setVectorStoreEnable(true);
collection.setVectorStoreCollection("collection_" + id);
return collection;
}
private Model embeddingModel(long id) {
Model model = new Model();
model.setId(BigInteger.valueOf(id));
model.setTenantId(BigInteger.valueOf(100));
model.setModelType(Model.MODEL_TYPES[1]);
model.setProviderId(BigInteger.ONE);
model.setModelName("embedding-" + id);
model.setEndpoint("https://embedding.example");
model.setRequestPath("/v1/embeddings");
ModelProvider provider = new ModelProvider();
provider.setId(BigInteger.ONE);
provider.setProviderType("openai");
model.setModelProvider(provider);
return model;
}
private LoginAccount loginAccount() {
LoginAccount account = new LoginAccount();
account.setId(BigInteger.ONE);

View File

@@ -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);
}
}

View File

@@ -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
) {
}
}

View File

@@ -0,0 +1,233 @@
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.service.WorkflowResumeService;
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);
WorkflowResumeService workflowResumeService =
mock(WorkflowResumeService.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,
workflowResumeService,
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
) {
}
}

View File

@@ -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
) {
}
}

View File

@@ -1,10 +1,6 @@
package tech.easyflow.publicapi.controller;
import cn.hutool.core.io.IoUtil;
import com.easyagents.core.model.embedding.EmbeddingModel;
import com.easyagents.core.store.DocumentStore;
import com.easyagents.core.store.StoreOptions;
import com.easyagents.core.store.StoreResult;
import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryWrapper;
import jakarta.servlet.http.HttpServletRequest;
@@ -18,11 +14,15 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.ai.documentimport.DocumentImportDtos;
import tech.easyflow.ai.dto.DocumentChunkDeleteResult;
import tech.easyflow.ai.dto.DocumentChunkAsyncUpdateResult;
import tech.easyflow.ai.dto.DocumentChunkSyncRetryRequest;
import tech.easyflow.ai.dto.DocumentChunkSyncStatus;
import tech.easyflow.ai.dto.DocumentChunkSyncStatusRequest;
import tech.easyflow.ai.entity.Document;
import tech.easyflow.ai.entity.DocumentChunk;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.FaqItem;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.enums.KnowledgeApiPermissionScope;
import tech.easyflow.ai.rag.KnowledgeRetrievalModes;
import tech.easyflow.ai.rag.KnowledgeRetrievalRequest;
@@ -33,9 +33,7 @@ import tech.easyflow.ai.service.FaqCategoryService;
import tech.easyflow.ai.service.FaqItemService;
import tech.easyflow.ai.service.KnowledgeShareAuditService;
import tech.easyflow.ai.service.KnowledgeSharePermissionService;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.ai.service.impl.KnowledgeSharePermissionServiceImpl;
import tech.easyflow.ai.support.DocumentStoreLifecycleSupport;
import tech.easyflow.ai.vo.FaqImportResultVo;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.filestorage.FileStorageService;
@@ -81,8 +79,6 @@ public class PublicKnowledgeShareController {
private FaqItemService faqItemService;
@Resource
private FaqCategoryService faqCategoryService;
@Resource
private ModelService modelService;
@Resource(name = "default")
private FileStorageService fileStorageService;
@@ -351,37 +347,20 @@ public class PublicKnowledgeShareController {
public Result<?> updateDocumentChunk(
@RequestHeader("ApiKey") String apiKey,
@JsonBody(value = "knowledgeId", required = true) BigInteger knowledgeId,
@JsonBody DocumentChunk documentChunk,
@JsonBody(required = true, skipConvertError = false)
DocumentChunk documentChunk,
HttpServletRequest request
) {
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeApiPermissionScope.KNOWLEDGE_MAINTENANCE.name());
requireDocumentKnowledge(knowledgeId);
DocumentChunk current = requireDocumentChunk(documentChunk.getId(), knowledgeId);
boolean success = documentChunkService.updateById(documentChunk);
if (success) {
DocumentCollection knowledge = documentCollectionService.getById(knowledgeId);
DocumentStore documentStore = knowledge.toDocumentStore();
if (documentStore == null) {
return Result.fail(2, "知识库没有配置向量库");
}
try {
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
if (model == null) {
return Result.fail(3, "知识库没有配置向量模型");
}
EmbeddingModel embeddingModel = model.toEmbeddingModel();
documentStore.setEmbeddingModel(embeddingModel);
StoreOptions options = StoreOptions.ofCollectionName(knowledge.getVectorStoreCollection());
com.easyagents.core.document.Document doc = com.easyagents.core.document.Document.of(documentChunk.getContent());
doc.setId(current.getId());
StoreResult result = documentStore.update(doc, options);
audit(apiKey, "API更新文档 Chunk", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", knowledgeId, "chunkId", documentChunk.getId()));
return Result.ok(result);
} finally {
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
}
}
return Result.ok(false);
DocumentChunk updated = documentChunkService.updateContent(
knowledgeId,
current.getId(),
documentChunk.getContent()
);
audit(apiKey, "API更新文档 Chunk", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", knowledgeId, "chunkId", documentChunk.getId()));
return Result.ok(DocumentChunkAsyncUpdateResult.from(updated));
}
/**
@@ -397,25 +376,51 @@ public class PublicKnowledgeShareController {
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeApiPermissionScope.KNOWLEDGE_MAINTENANCE.name());
requireDocumentKnowledge(knowledgeId);
requireDocumentChunk(chunkId, knowledgeId);
DocumentCollection knowledge = documentCollectionService.getById(knowledgeId);
DocumentStore documentStore = knowledge.toDocumentStore();
if (documentStore == null) {
return Result.fail(2, "知识库没有配置向量库");
}
try {
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
if (model == null) {
return Result.fail(3, "知识库没有配置向量模型");
}
documentStore.setEmbeddingModel(model.toEmbeddingModel());
StoreOptions options = StoreOptions.ofCollectionName(knowledge.getVectorStoreCollection());
documentStore.delete(Collections.singletonList(chunkId), options);
documentChunkService.removeById(chunkId);
audit(apiKey, "API删除文档 Chunk", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", knowledgeId, "chunkId", chunkId));
return Result.ok(true);
} finally {
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
DocumentChunkDeleteResult removed = documentChunkService.deleteChunk(
knowledgeId,
chunkId
);
audit(apiKey, "API删除文档 Chunk", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", knowledgeId, "chunkId", chunkId));
return Result.ok(removed != null);
}
@PostMapping("/documentChunk/syncStatus")
public Result<List<DocumentChunkSyncStatus>> documentChunkSyncStatus(
@RequestHeader("ApiKey") String apiKey,
@JsonBody(value = "knowledgeId", required = true) BigInteger knowledgeId,
@JsonBody(required = true, skipConvertError = false)
DocumentChunkSyncStatusRequest statusRequest,
HttpServletRequest request
) {
assertApiShare(apiKey, request.getRequestURI(), knowledgeId,
KnowledgeApiPermissionScope.KNOWLEDGE_READ.name());
requireDocumentKnowledge(knowledgeId);
requireDocument(statusRequest.getDocumentId(), knowledgeId);
return Result.ok(documentChunkService.listIndexSyncStatus(
knowledgeId, statusRequest.getDocumentId(), statusRequest.getIds()
));
}
@PostMapping("/documentChunk/retrySync")
public Result<DocumentChunk> retryDocumentChunkSync(
@RequestHeader("ApiKey") String apiKey,
@JsonBody(value = "knowledgeId", required = true) BigInteger knowledgeId,
@JsonBody(required = true, skipConvertError = false)
DocumentChunkSyncRetryRequest retryRequest,
HttpServletRequest request
) {
assertApiShare(apiKey, request.getRequestURI(), knowledgeId,
KnowledgeApiPermissionScope.KNOWLEDGE_MAINTENANCE.name());
requireDocumentKnowledge(knowledgeId);
if (retryRequest.getIndexSyncVersion() == null) {
throw new BusinessException("同步版本不能为空");
}
DocumentChunk chunk = documentChunkService.retryIndexSync(
knowledgeId, retryRequest.getId(), retryRequest.getIndexSyncVersion()
);
audit(apiKey, "API重试文档 Chunk 索引同步", "KNOWLEDGE_API_SHARE_WRITE",
request.getRequestURI(), Map.of("knowledgeId", knowledgeId, "chunkId", retryRequest.getId()));
return Result.ok(chunk);
}
/**

View File

@@ -18,6 +18,7 @@ import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiPreparedUpload;
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadLifecycleService;
@@ -70,6 +71,8 @@ public class PublicWorkflowController {
@Resource
private WorkflowRunningParameterResolver workflowRunningParameterResolver;
@Resource
private WorkflowResumeService workflowResumeService;
@Resource
private WorkflowApiPermissionService workflowApiPermissionService;
@Resource
private WorkflowExecResultService workflowExecResultService;
@@ -250,14 +253,7 @@ public class PublicWorkflowController {
SysApiKey apiKey = workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
WorkflowExecResult execResult = assertApiKeyExecutionOwnership(apiKey, executeId);
assertWorkflowExecutionResumable(execResult);
if (!chainExecutor.resumeAsyncIfSuspended(
executeId,
confirmParams)) {
throw new BusinessException(
409,
40901,
"当前执行状态不可恢复,仅暂停中的工作流允许恢复");
}
workflowResumeService.resume(executeId, confirmParams);
return Result.ok();
}

View File

@@ -394,6 +394,7 @@ public final class WorkflowRunAsyncErrorProfile
*/
private boolean isStableBusinessCode(int code) {
return (code >= 40011 && code <= 40017)
|| code == 40031
|| (code >= 40101 && code <= 40103)
|| (code >= 40301 && code <= 40302)
|| (code >= 40401 && code <= 40402)
@@ -412,7 +413,8 @@ public final class WorkflowRunAsyncErrorProfile
* @return 对外 HTTP 状态
*/
private int normalizeHttpStatus(int code, int fallback) {
if (code >= 40011 && code <= 40017) {
if ((code >= 40011 && code <= 40017)
|| code == 40031) {
return 400;
}
if (code >= 40101 && code <= 40103) {

View File

@@ -66,6 +66,8 @@ public class PublicKnowledgeShareControllerContractTest {
JsonBody chunkBody = chunk.getAnnotation(JsonBody.class);
Assert.assertNotNull(chunkBody);
Assert.assertEquals("", chunkBody.value());
Assert.assertTrue(chunkBody.required());
Assert.assertFalse(chunkBody.skipConvertError());
}
/**

View File

@@ -12,6 +12,7 @@ import org.springframework.test.util.ReflectionTestUtils;
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.entity.WorkflowExecResult;
import tech.easyflow.ai.enums.PublishStatus;
@@ -44,6 +45,7 @@ public class PublicWorkflowControllerBehaviorTest {
private PublicWorkflowController controller;
private ChainExecutor chainExecutor;
private WorkflowResumeService workflowResumeService;
private TinyFlowService tinyFlowService;
private HttpServletRequest request;
@@ -54,6 +56,7 @@ public class PublicWorkflowControllerBehaviorTest {
public void setUp() {
controller = new PublicWorkflowController();
chainExecutor = Mockito.mock(ChainExecutor.class);
workflowResumeService = Mockito.mock(WorkflowResumeService.class);
tinyFlowService = Mockito.mock(TinyFlowService.class);
WorkflowApiPermissionService permissionService =
Mockito.mock(WorkflowApiPermissionService.class);
@@ -79,6 +82,10 @@ public class PublicWorkflowControllerBehaviorTest {
controller,
"chainExecutor",
chainExecutor);
ReflectionTestUtils.setField(
controller,
"workflowResumeService",
workflowResumeService);
ReflectionTestUtils.setField(
controller,
"tinyFlowService",
@@ -108,10 +115,12 @@ public class PublicWorkflowControllerBehaviorTest {
public void resumeShouldRejectNonSuspendedExecution() {
when(request.getRequestURI()).thenReturn(
"/public-api/workflow/resume");
when(chainExecutor.resumeAsyncIfSuspended(
EXECUTE_ID,
Map.of("approved", true)))
.thenReturn(false);
Mockito.doThrow(new BusinessException(
409,
40901,
"当前执行状态不可恢复,仅暂停中的工作流允许恢复"))
.when(workflowResumeService)
.resume(EXECUTE_ID, Map.of("approved", true));
try {
controller.resume(
@@ -124,7 +133,7 @@ public class PublicWorkflowControllerBehaviorTest {
Assert.assertEquals(40901, exception.getErrorCode());
}
verify(chainExecutor).resumeAsyncIfSuspended(
verify(workflowResumeService).resume(
EXECUTE_ID,
Map.of("approved", true));
}

View File

@@ -167,6 +167,28 @@ public class WorkflowRunAsyncErrorProfileTest {
resolution.modelAndView.getModel().get("message"));
}
/**
* 验证确认节点恢复校验保留专用错误码,不回退为运行参数错误。
*/
@Test
public void shouldKeepResumeValidationCode() {
Resolution resolution = resolve(
"/public-api/workflow/resume",
MediaType.APPLICATION_JSON_VALUE,
new BusinessException(
400,
40031,
"确认参数[模板类型]包含未配置选项"));
Assert.assertEquals(400, resolution.response.getStatus());
Assert.assertEquals(
40031,
resolution.modelAndView.getModel().get("errorCode"));
Assert.assertEquals(
"确认参数[模板类型]包含未配置选项",
resolution.modelAndView.getModel().get("message"));
}
/**
* 验证 API Key 无效和两层权限错误保持可区分。
*/

View File

@@ -13,6 +13,7 @@ import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.common.annotation.UsePermission;
@@ -54,6 +55,8 @@ public class UcWorkflowController extends BaseCurdController<WorkflowService, Wo
@Resource
private WorkflowRunningParameterResolver workflowRunningParameterResolver;
@Resource
private WorkflowResumeService workflowResumeService;
@Resource
private WorkflowVisibilityQueryHelper workflowVisibilityQueryHelper;
public UcWorkflowController(WorkflowService service) {
@@ -163,12 +166,7 @@ public class UcWorkflowController extends BaseCurdController<WorkflowService, Wo
)
public Result<Void> resume(@JsonBody(value = "executeId", required = true) String executeId,
@JsonBody("confirmParams") Map<String, Object> confirmParams) {
if (!chainExecutor.resumeAsyncIfSuspended(executeId, confirmParams)) {
throw new BusinessException(
409,
40901,
"当前执行状态不可恢复,仅暂停中的工作流允许恢复");
}
workflowResumeService.resume(executeId, confirmParams);
return Result.ok();
}

View File

@@ -1,5 +1,6 @@
package tech.easyflow.common.cache;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
@@ -10,6 +11,11 @@ import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.Collections;
import java.util.UUID;
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.function.Supplier;
/**
@@ -27,6 +33,13 @@ public class RedisLockExecutor {
private static final DefaultRedisScript<Long> NEXT_FENCING_TOKEN_SCRIPT;
private static final DefaultRedisScript<Long> ACQUIRE_FENCED_LOCK_SCRIPT;
private final ScheduledExecutorService lockRenewalExecutor =
Executors.newSingleThreadScheduledExecutor(runnable -> {
Thread thread = new Thread(runnable, "easyflow-redis-lock-renewal");
thread.setDaemon(true);
return thread;
});
static {
RELEASE_LOCK_SCRIPT = new DefaultRedisScript<>();
RELEASE_LOCK_SCRIPT.setScriptText(
@@ -94,6 +107,66 @@ public class RedisLockExecutor {
}
}
/**
* 在自动续租的分布式锁保护下执行任务。
*
* <p>适用于包含数据库锁等待或外部持久化操作、无法由固定租约严格覆盖的管理命令。
* 若执行期间确认锁已丢失,则不向调用方返回成功。</p>
*/
public void executeWithRenewingLock(
String lockKey,
Duration waitTimeout,
Duration leaseTimeout,
Runnable task) {
executeWithRenewingLock(lockKey, waitTimeout, leaseTimeout, () -> {
task.run();
return null;
});
}
/**
* 在自动续租的分布式锁保护下执行有返回值任务。
*/
public <T> T executeWithRenewingLock(
String lockKey,
Duration waitTimeout,
Duration leaseTimeout,
Supplier<T> task) {
LockHandle handle = acquire(lockKey, waitTimeout, leaseTimeout);
AtomicBoolean lost = new AtomicBoolean();
long renewalIntervalMillis = Math.max(1L, leaseTimeout.toMillis() / 3L);
ScheduledFuture<?> renewal = lockRenewalExecutor.scheduleWithFixedDelay(
() -> {
try {
if (!handle.renew()) {
lost.set(true);
}
} catch (RuntimeException exception) {
lost.set(true);
log.warn("分布式锁续租失败,当前命令不得返回成功: lockKey={}",
lockKey, exception);
}
},
renewalIntervalMillis,
renewalIntervalMillis,
TimeUnit.MILLISECONDS);
try {
T result = task.get();
if (lost.get()) {
throw new IllegalStateException("执行期间分布式锁已丢失lockKey=" + lockKey);
}
return result;
} finally {
renewal.cancel(false);
handle.release();
}
}
@PreDestroy
public void shutdownLockRenewalExecutor() {
lockRenewalExecutor.shutdownNow();
}
/**
* 获取显式释放的分布式锁句柄。
*

View File

@@ -11,6 +11,9 @@ import org.springframework.data.redis.core.script.RedisScript;
import java.lang.reflect.Field;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/**
* {@link RedisLockExecutor} 回归测试。
@@ -147,6 +150,96 @@ public class RedisLockExecutorTest {
String.valueOf(Duration.ofDays(4).toMillis())));
}
@Test
public void renewingLockShouldRenewBeforeLongRunningCommandCompletes() throws Exception {
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
ValueOperations<String, String> valueOperations = mockValueOperations(true);
CountDownLatch renewed = new CountDownLatch(1);
Mockito.when(redisTemplate.opsForValue()).thenReturn(valueOperations);
Mockito.when(redisTemplate.execute(
ArgumentMatchers.<RedisScript<Long>>any(),
ArgumentMatchers.<List<String>>any(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString()
)).thenAnswer(invocation -> {
renewed.countDown();
return 1L;
});
RedisLockExecutor executor = new RedisLockExecutor();
setRedisTemplate(executor, redisTemplate);
try {
executor.executeWithRenewingLock(
"easyflow:test:renewing-lock",
Duration.ZERO,
Duration.ofMillis(60),
() -> {
try {
Assert.assertTrue(renewed.await(1, TimeUnit.SECONDS));
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new AssertionError("等待锁续租时被中断", exception);
}
});
} finally {
executor.shutdownLockRenewalExecutor();
}
Mockito.verify(redisTemplate, Mockito.atLeastOnce()).execute(
ArgumentMatchers.<RedisScript<Long>>any(),
ArgumentMatchers.eq(List.of("easyflow:test:renewing-lock")),
ArgumentMatchers.anyString(),
ArgumentMatchers.eq("60"));
}
@Test
public void renewingLockMustNotReturnSuccessAfterRenewalThrows() throws Exception {
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
ValueOperations<String, String> valueOperations = mockValueOperations(true);
CountDownLatch renewalAttempted = new CountDownLatch(1);
AtomicInteger scriptCalls = new AtomicInteger();
Mockito.when(redisTemplate.opsForValue()).thenReturn(valueOperations);
Mockito.when(redisTemplate.execute(
ArgumentMatchers.<RedisScript<Long>>any(),
ArgumentMatchers.<List<String>>any(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString()
)).thenAnswer(invocation -> {
if (scriptCalls.incrementAndGet() == 1) {
renewalAttempted.countDown();
throw new IllegalStateException("redis unavailable");
}
return 1L;
});
RedisLockExecutor executor = new RedisLockExecutor();
setRedisTemplate(executor, redisTemplate);
try {
try {
executor.executeWithRenewingLock(
"easyflow:test:renewal-failure",
Duration.ZERO,
Duration.ofMillis(60),
() -> {
try {
Assert.assertTrue(renewalAttempted.await(1, TimeUnit.SECONDS));
// 等待续租线程把失败结果发布到调用线程;业务任务与续租
// 同时完成时,锁仍处于原租约内且 callback 已结束。
Thread.sleep(50L);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new AssertionError(exception);
}
});
Assert.fail("续租异常后不应返回成功");
} catch (IllegalStateException exception) {
Assert.assertTrue(exception.getMessage().contains("分布式锁已丢失"));
}
} finally {
executor.shutdownLockRenewalExecutor();
}
}
@SuppressWarnings("unchecked")
private ValueOperations<String, String> mockValueOperations(boolean acquired) {
ValueOperations<String, String> valueOperations = Mockito.mock(ValueOperations.class);

View File

@@ -11,6 +11,7 @@ import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;
import java.util.function.Supplier;
@@ -151,6 +152,19 @@ public class FileStorageManager implements FileStorageService {
return serviceForHandle(handle).readRecoverable(handle);
}
/**
* 使用当前后端解析服务端可信文件引用。
*
* @param reference 文件 URL 或其他后端可识别引用
* @return 可信文件的物理读取句柄;引用无法确认时为空
* @throws IOException 文件记录无法安全解析时抛出
*/
@Override
public Optional<FileStorageWriteHandle> resolveTrustedFile(String reference)
throws IOException {
return currentService().resolveTrustedFile(reference);
}
/**
* 严格按句柄中的后端精确删除物理对象。
*

View File

@@ -5,6 +5,7 @@ import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.Optional;
/**
* EasyFlow 文件存储统一接口。
@@ -105,6 +106,21 @@ public interface FileStorageService {
throw unsupportedRecoverableOperation("readRecoverable");
}
/**
* 将服务端可信文件引用解析为物理读取句柄。
*
* <p>实现必须以服务端持久化记录或存储平台配置为信任来源,并要求外部引用与可信来源
* 精确匹配;不得仅根据客户端传入的 URL、路径或 locator 构造句柄。</p>
*
* @param reference 文件 URL 或其他后端可识别引用
* @return 可信文件的物理读取句柄;引用无法确认时为空
* @throws IOException 文件记录损坏或存储配置不兼容时抛出
*/
default Optional<FileStorageWriteHandle> resolveTrustedFile(String reference)
throws IOException {
return Optional.empty();
}
/**
* 精确且幂等地删除句柄对应的物理对象。
*

View File

@@ -21,6 +21,7 @@ import java.io.*;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Objects;
import java.util.Optional;
/**
* 基于 x-file-storage 的 EasyFlow 文件存储实现。
@@ -268,6 +269,47 @@ public class XFIleStorageServiceImpl implements FileStorageService {
}
}
/**
* 使用 x-file-storage 文件记录或服务端平台配置恢复可信物理读取句柄。
*
* <p>优先使用 recorder 的精确记录。记录不存在时,仅允许与已配置平台 domain、basePath
* 及重建后的完整 URL 完全一致的引用。其他 URL 返回空,由上层继续执行公网地址安全校验。</p>
*
* @param reference 文件 URL
* @return 可信文件的物理读取句柄;引用无法确认时为空
* @throws IOException 文件记录损坏或平台配置不兼容时抛出
*/
@Override
public Optional<FileStorageWriteHandle> resolveTrustedFile(String reference)
throws IOException {
if (!StringUtils.hasText(reference)) {
return Optional.empty();
}
FileInfo fileInfo = null;
try {
fileInfo = fileStorageService.getFileInfoByUrl(reference);
} catch (RuntimeException exception) {
// 存储平台配置本身仍可提供精确可信边界,记录器异常不应阻断内部对象读取。
LOG.warn("查询 x-file-storage 文件记录失败继续按服务端存储配置识别reference={}",
reference, exception);
}
if (fileInfo != null) {
if (!reference.equals(fileInfo.getUrl())) {
throw new IOException("x-file-storage 文件记录 URL 与请求引用不一致");
}
try {
FileStorageWriteHandle handle = handleFromFileInfo(fileInfo);
FileStorage storage = requireStorage(handle);
requirePersistedBasePathSupport(storage, handle);
verifyRecordedLocation(fileInfo, handle);
return Optional.of(handle);
} catch (RuntimeException exception) {
throw new IOException("x-file-storage 文件记录无法恢复为安全读取位置", exception);
}
}
return resolveConfiguredStorageReference(reference);
}
/**
* 直接调用句柄指定平台的物理删除与存在检查,绕过依赖 URL 记录的聚合删除路径。
*
@@ -391,6 +433,108 @@ public class XFIleStorageServiceImpl implements FileStorageService {
}
}
/**
* 校验 recorder 中的物理定位字段可由恢复句柄无损重建。
*
* @param fileInfo 服务端文件记录
* @param handle 恢复出的物理读取句柄
*/
private void verifyRecordedLocation(FileInfo fileInfo, FileStorageWriteHandle handle) {
String actualBasePath = fileInfo.getBasePath() == null ? "" : fileInfo.getBasePath();
String actualPath = fileInfo.getPath() == null ? "" : fileInfo.getPath();
if (!handle.getPlatform().equals(fileInfo.getPlatform())
|| !handle.getBasePath().equals(actualBasePath)
|| !physicalPath(handle).equals(actualPath)
|| !handle.getFilename().equals(fileInfo.getFilename())) {
throw new IllegalStateException("x-file-storage 文件记录包含非规范物理位置");
}
}
/**
* 从 FileInfo 的物理定位字段构造严格校验的读取句柄。
*
* @param fileInfo 服务端文件信息
* @return 可信物理读取句柄
*/
private FileStorageWriteHandle handleFromFileInfo(FileInfo fileInfo) {
String basePath = fileInfo.getBasePath() == null ? "" : fileInfo.getBasePath();
return new FileStorageWriteHandle(
RECOVERABLE_BACKEND,
fileInfo.getPlatform(),
basePath,
recordedRelativePath(basePath, fileInfo.getPath()),
fileInfo.getFilename());
}
/**
* 按服务端配置的平台 domain 与 basePath 识别内部存储 URL。
*
* <p>解析后会再次通过平台自身的 getFileKey 重建完整 URL 并进行精确比较,避免仅凭
* host 或字符串前缀放行其他私网目标。</p>
*
* @param reference 待识别 URL
* @return 精确匹配配置的读取句柄;不匹配任何平台时为空
* @throws IOException 匹配平台前缀但路径无法安全恢复时抛出
*/
private Optional<FileStorageWriteHandle> resolveConfiguredStorageReference(
String reference) throws IOException {
if (fileStorageService.getFileStorageList() == null) {
return Optional.empty();
}
for (FileStorage storage : fileStorageService.getFileStorageList()) {
String domain = readDomainBestEffort(storage);
if (!StringUtils.hasText(domain)) {
continue;
}
String basePath = readRequiredBasePath(storage);
String prefix = domain + basePath;
if (!reference.startsWith(prefix)) {
continue;
}
try {
String remainder = reference.substring(prefix.length());
int filenameIndex = remainder.lastIndexOf('/') + 1;
FileInfo fileInfo = new FileInfo()
.setUrl(reference)
.setPlatform(storage.getPlatform())
.setBasePath(basePath)
.setPath(remainder.substring(0, filenameIndex))
.setFilename(remainder.substring(filenameIndex));
FileStorageWriteHandle handle = handleFromFileInfo(fileInfo);
requirePersistedBasePathSupport(storage, handle);
verifyRecordedLocation(fileInfo, handle);
if (!reference.equals(deriveUrlBestEffort(storage, toFileInfo(handle)))) {
throw new IllegalArgumentException("重建 URL 与请求引用不一致");
}
return Optional.of(handle);
} catch (RuntimeException exception) {
throw new IOException("服务端存储 URL 无法恢复为安全读取位置", exception);
}
}
return Optional.empty();
}
/**
* 将 recorder 保存的 x-file-storage 物理路径还原为句柄相对路径。
*
* @param basePath 平台基础路径
* @param recordedPath recorder 中保存的物理目录
* @return 不带前导斜杠的相对目录
*/
private String recordedRelativePath(String basePath, String recordedPath) {
String path = recordedPath == null ? "" : recordedPath;
if (basePath.isEmpty() || basePath.endsWith("/")) {
if (path.startsWith("/")) {
throw new IllegalArgumentException("文件记录路径与平台基础路径格式不一致");
}
return path;
}
if (!path.startsWith("/")) {
throw new IllegalArgumentException("文件记录路径缺少必要的前导斜杠");
}
return path.substring(1);
}
/**
* 构造仅包含精确物理定位字段的 FileInfo。
*
@@ -487,16 +631,26 @@ public class XFIleStorageServiceImpl implements FileStorageService {
* @return 可推导 URL平台不支持时返回 null
*/
private String deriveUrlBestEffort(FileStorage storage, FileInfo fileInfo) {
String domain = readDomainBestEffort(storage);
if (domain == null) {
return null;
}
return domain + storage.getFileKey(fileInfo);
}
/**
* 使用平台公开的 getDomain 方法读取文件访问域名。
*
* @param storage 具体平台存储
* @return 平台访问域名;平台不支持时返回 null
*/
private String readDomainBestEffort(FileStorage storage) {
try {
Method method = storage.getClass().getMethod("getDomain");
if (!String.class.equals(method.getReturnType())) {
return null;
}
String domain = (String) method.invoke(storage);
if (domain == null) {
return null;
}
return domain + storage.getFileKey(fileInfo);
return (String) method.invoke(storage);
} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException | RuntimeException exception) {
LOG.debug("当前 x-file-storage 平台无法推导 recorder URL: {}", storage.getClass().getName());
return null;

View File

@@ -7,6 +7,7 @@ import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals;
@@ -49,6 +50,27 @@ public class FileStorageManagerTest {
assertFalse(exists);
}
/**
* 验证服务端文件记录解析使用当前配置的具体存储后端。
*
* @throws IOException 文件记录解析失败时抛出
*/
@Test
public void recordedFileResolutionUsesCurrentBackend() throws IOException {
RecordingStorage local = new RecordingStorage("local");
RecordingStorage xFile = new RecordingStorage("xFileStorage");
FileStorageManager manager = new FileStorageManager(
() -> "xFileStorage",
backend -> Map.of("local", local, "xFileStorage", xFile).get(backend));
Optional<FileStorageWriteHandle> resolved = manager.resolveTrustedFile(
"http://127.0.0.1:39000/easyflow/attachment/demo.pdf");
assertSame(xFile.recordedHandle, resolved.orElseThrow());
assertEquals(1, xFile.resolveCalls);
assertEquals(0, local.resolveCalls);
}
/**
* 可记录可恢复调用的存储测试替身。
*/
@@ -57,6 +79,8 @@ public class FileStorageManagerTest {
private final String backend;
/** 固定结果。 */
private final FileStorageWriteResult result;
/** 固定服务端文件记录句柄。 */
private final FileStorageWriteHandle recordedHandle;
/** 固定可恢复读取流。 */
private final InputStream recoverableInput = InputStream.nullInputStream();
/** prepare 调用次数。 */
@@ -69,6 +93,8 @@ public class FileStorageManagerTest {
private int deleteCalls;
/** exists 调用次数。 */
private int existsCalls;
/** 服务端文件记录解析调用次数。 */
private int resolveCalls;
/**
* 创建指定名称的存储替身。
@@ -80,6 +106,8 @@ public class FileStorageManagerTest {
FileStorageWriteHandle handle = new FileStorageWriteHandle(
backend, "", "/tmp/easyflow", "skill-content", "content.bin");
this.result = new FileStorageWriteResult("/files/content.bin", handle.encodeLocator());
this.recordedHandle = new FileStorageWriteHandle(
backend, "", "/tmp/easyflow", "attachment", "demo.pdf");
}
/** {@inheritDoc} */
@@ -114,6 +142,13 @@ public class FileStorageManagerTest {
return recoverableInput;
}
/** {@inheritDoc} */
@Override
public Optional<FileStorageWriteHandle> resolveTrustedFile(String reference) {
resolveCalls++;
return Optional.of(recordedHandle);
}
/** {@inheritDoc} */
@Override
public void deleteRecoverable(FileStorageWriteHandle handle) {

View File

@@ -212,6 +212,123 @@ public class XFIleStorageServiceImplTest {
client.lastArgs.object());
}
/**
* 验证 recorder 登记的回环地址附件可恢复为可信句柄并通过 MinIO 客户端直读。
*
* @throws Exception 测试替身配置或流读取失败
*/
@Test
public void recordedLoopbackUrlUsesExactMinioObject() throws Exception {
byte[] content = "workflow-content".getBytes(java.nio.charset.StandardCharsets.UTF_8);
RecordingMinioClient client = new RecordingMinioClient(content);
MinioFileStorage platform = new MinioFileStorage();
platform.setPlatform("minio-main");
platform.setBucketName("easyflow");
platform.setBasePath("attachment");
platform.setDomain("http://127.0.0.1:39000/easyflow/");
platform.setClientFactory(new FixedMinioClientFactory(client));
RecoverableStorageService delegate = new RecoverableStorageService(platform);
String fileUrl = "http://127.0.0.1:39000/easyflow/attachment/1/2026/8/26/"
+ "d6186b17-4ab7-4f99-9299-b19df7ff0a3b/投标文件否决(废标)违规事项汇总.pdf";
delegate.recordedFileInfo = new FileInfo()
.setUrl(fileUrl)
.setPlatform("minio-main")
.setBasePath("attachment")
.setPath("/1/2026/8/26/d6186b17-4ab7-4f99-9299-b19df7ff0a3b/")
.setFilename("投标文件否决(废标)违规事项汇总.pdf");
XFIleStorageServiceImpl service = createService(delegate);
FileStorageWriteHandle handle = service.resolveTrustedFile(fileUrl).orElseThrow();
byte[] actual;
try (InputStream inputStream = service.readRecoverable(handle)) {
actual = inputStream.readAllBytes();
}
assertEquals("attachment", handle.getBasePath());
assertEquals(
"1/2026/8/26/d6186b17-4ab7-4f99-9299-b19df7ff0a3b/",
handle.getPath());
assertArrayEquals(content, actual);
assertEquals("easyflow", client.lastArgs.bucket());
assertEquals(
"attachment/1/2026/8/26/d6186b17-4ab7-4f99-9299-b19df7ff0a3b/"
+ "投标文件否决(废标)违规事项汇总.pdf",
client.lastArgs.object());
}
/**
* 验证 recorder 没有记录时,服务端配置的存储 URL 仍可通过 MinIO 客户端直读。
*
* @throws Exception 测试替身配置或流读取失败
*/
@Test
public void configuredStorageUrlWithoutRecorderUsesExactMinioObject() throws Exception {
byte[] content = "configured-content".getBytes(java.nio.charset.StandardCharsets.UTF_8);
RecordingMinioClient client = new RecordingMinioClient(content);
MinioFileStorage platform = new MinioFileStorage();
platform.setPlatform("minio-main");
platform.setBucketName("easyflow");
platform.setBasePath("attachment");
platform.setDomain("http://127.0.0.1:39000/easyflow/");
platform.setClientFactory(new FixedMinioClientFactory(client));
XFIleStorageServiceImpl service = createService(new RecoverableStorageService(platform));
String fileUrl = "http://127.0.0.1:39000/easyflow/attachment/1/2026/8/26/"
+ "0f0db465-7fa0-46b1-9fae-4f5a8c85f881/投标文件否决(废标)违规事项汇总.pdf";
FileStorageWriteHandle handle = service.resolveTrustedFile(fileUrl).orElseThrow();
byte[] actual;
try (InputStream inputStream = service.readRecoverable(handle)) {
actual = inputStream.readAllBytes();
}
assertArrayEquals(content, actual);
assertEquals(
"attachment/1/2026/8/26/0f0db465-7fa0-46b1-9fae-4f5a8c85f881/"
+ "投标文件否决(废标)违规事项汇总.pdf",
client.lastArgs.object());
}
/**
* 验证未配置为存储地址的回环 URL 不会被识别为可信附件。
*
* @throws Exception 测试替身注入失败
*/
@Test
public void unconfiguredLoopbackUrlIsNotResolved() throws Exception {
RecoverablePlatform platform = new RecoverablePlatform(
"minio-main", "attachment", "http://127.0.0.1:39000/easyflow/");
XFIleStorageServiceImpl service = createService(new RecoverableStorageService(platform));
assertTrue(service.resolveTrustedFile(
"http://127.0.0.1:39000/other/unconfigured.pdf").isEmpty());
}
/**
* 验证 recorder 中非规范物理路径会失败关闭。
*
* @throws Exception 测试替身注入失败
*/
@Test
public void corruptedRecordedLocationIsRejected() throws Exception {
RecoverablePlatform platform = new RecoverablePlatform(
"minio-main", "attachment", "http://127.0.0.1:39000/easyflow/");
RecoverableStorageService delegate = new RecoverableStorageService(platform);
String fileUrl = "http://127.0.0.1:39000/easyflow/attachment/demo.pdf";
delegate.recordedFileInfo = new FileInfo()
.setUrl(fileUrl)
.setPlatform("minio-main")
.setBasePath("attachment")
.setPath("missing-leading-slash/")
.setFilename("demo.pdf");
XFIleStorageServiceImpl service = createService(delegate);
IOException exception = assertThrows(
IOException.class,
() -> service.resolveTrustedFile(fileUrl));
assertTrue(exception.getMessage().contains("无法恢复"));
}
/**
* 验证 recorder 完全缺失目标记录时,精确删除仍直接作用于物理平台并成功。
*
@@ -471,6 +588,8 @@ public class XFIleStorageServiceImplTest {
private int recorderDeleteCalls;
/** recorder 删除是否抛出异常。 */
private boolean recorderDeleteThrows;
/** recorder 返回的服务端文件记录。 */
private FileInfo recordedFileInfo;
/**
* 创建聚合服务替身。
@@ -479,10 +598,17 @@ public class XFIleStorageServiceImplTest {
*/
private RecoverableStorageService(FileStorage platform) {
this.platform = platform;
setFileStorageList(new java.util.concurrent.CopyOnWriteArrayList<>(
java.util.List.of(platform)));
setFileRecorder(new FileRecorder() {
@Override public boolean save(FileInfo fileInfo) { return true; }
@Override public void update(FileInfo fileInfo) { }
@Override public FileInfo getByUrl(String url) { return null; }
@Override public FileInfo getByUrl(String url) {
return recordedFileInfo != null
&& url.equals(recordedFileInfo.getUrl())
? recordedFileInfo
: null;
}
@Override public boolean delete(String url) {
recorderDeleteCalls++;
if (recorderDeleteThrows) {
@@ -506,6 +632,15 @@ public class XFIleStorageServiceImplTest {
return platform.getPlatform().equals(name) ? (T) platform : null;
}
/** {@inheritDoc} */
@Override
public FileInfo getFileInfoByUrl(String url) {
return recordedFileInfo != null
&& url.equals(recordedFileInfo.getUrl())
? recordedFileInfo
: null;
}
/** {@inheritDoc} */
@Override
public org.dromara.x.file.storage.core.upload.UploadPretreatment of(Object file) {

View File

@@ -72,6 +72,21 @@ public class AgentRuntimeProperties {
*/
private Duration asyncToolTaskTtl = Duration.ofHours(24);
/**
* AG-UI 可重连运行事件保留时间。
*/
private Duration aguiRunRetention = Duration.ofHours(24);
/**
* AG-UI 事件日志批量刷入 Redis 的间隔。
*/
private Duration aguiJournalFlushInterval = Duration.ofMillis(50);
/**
* AG-UI 重连订阅轮询 Redis 的间隔。
*/
private Duration aguiReplayPollInterval = Duration.ofMillis(100);
/**
* 获取 Redis 热态 session 缓存 TTL。
*
@@ -90,6 +105,60 @@ public class AgentRuntimeProperties {
this.sessionCacheTtl = sessionCacheTtl == null ? Duration.ofHours(24) : sessionCacheTtl;
}
/**
* 获取 AG-UI 可重连运行事件保留时间。
*
* @return 事件保留时间
*/
public Duration getAguiRunRetention() {
return aguiRunRetention;
}
/**
* 设置 AG-UI 可重连运行事件保留时间。
*
* @param aguiRunRetention 事件保留时间
*/
public void setAguiRunRetention(Duration aguiRunRetention) {
this.aguiRunRetention = positiveDuration(aguiRunRetention, Duration.ofHours(24));
}
/**
* 获取 AG-UI 事件日志批量刷入 Redis 的间隔。
*
* @return 刷盘间隔
*/
public Duration getAguiJournalFlushInterval() {
return aguiJournalFlushInterval;
}
/**
* 设置 AG-UI 事件日志批量刷入 Redis 的间隔。
*
* @param aguiJournalFlushInterval 刷盘间隔
*/
public void setAguiJournalFlushInterval(Duration aguiJournalFlushInterval) {
this.aguiJournalFlushInterval = positiveDuration(aguiJournalFlushInterval, Duration.ofMillis(50));
}
/**
* 获取 AG-UI 重连订阅轮询 Redis 的间隔。
*
* @return 轮询间隔
*/
public Duration getAguiReplayPollInterval() {
return aguiReplayPollInterval;
}
/**
* 设置 AG-UI 重连订阅轮询 Redis 的间隔。
*
* @param aguiReplayPollInterval 轮询间隔
*/
public void setAguiReplayPollInterval(Duration aguiReplayPollInterval) {
this.aguiReplayPollInterval = positiveDuration(aguiReplayPollInterval, Duration.ofMillis(100));
}
/**
* 获取当前 Agent 运行实例 ID。
*
@@ -281,6 +350,10 @@ public class AgentRuntimeProperties {
this.asyncToolTaskTtl = asyncToolTaskTtl == null ? Duration.ofHours(24) : asyncToolTaskTtl;
}
private static Duration positiveDuration(Duration value, Duration fallback) {
return value == null || value.isZero() || value.isNegative() ? fallback : value;
}
private static String defaultInstanceId() {
String envInstanceId = System.getenv("EASYFLOW_INSTANCE_ID");
if (StringUtils.hasText(envInstanceId)) {

View File

@@ -23,5 +23,10 @@ public enum AgentRuntimeCommandAction {
/**
* 取消指定 Agent 在目标节点上的全部运行。
*/
CANCEL_AGENT
CANCEL_AGENT,
/**
* 取消指定的单次运行。
*/
CANCEL_RUN
}

View File

@@ -108,6 +108,9 @@ public class AgentRuntimeCommandConsumer implements MQConsumerHandler {
command.getRequestId(), command.getResumeToken(), command.getReason());
} else if (command.getAction() == AgentRuntimeCommandAction.CANCEL_AGENT) {
agentRunService.cancelAgentLocal(command.getAgentId());
} else if (command.getAction() == AgentRuntimeCommandAction.CANCEL_RUN) {
agentRunService.cancelRunLocal(
command.getRequestId(), command.getUserId(), command.getReason());
} else {
markFailureQuietly(command, new IllegalArgumentException("不支持的 Agent 远程运行命令"));
LOG.warn("跳过不支持的 Agent 远程运行命令: messageId={}, commandId={}, action={}",

View File

@@ -175,6 +175,21 @@ public class AgentRuntimeCommandProducer {
);
}
/**
* 投递远程单次运行取消命令。
*
* @param targetNodeId 目标节点 ID
* @param requestId 内部请求 ID
* @param userId 当前用户 ID
* @param reason 取消原因
*/
public void sendCancelRun(String targetNodeId, String requestId, String userId, String reason) {
sendAndWait(
targetNodeId, requestId, null, null, null,
AgentRuntimeCommandAction.CANCEL_RUN, reason, null, userId
);
}
/**
* 投递远程运行命令并等待目标节点确认。
*

View File

@@ -93,9 +93,13 @@ public class AgentRunRegistry {
*/
public void bindSubscription(String requestId, Disposable subscription) {
AgentRunContext context = runs.get(requestId);
if (context != null) {
context.setSubscription(subscription);
if (context == null) {
if (subscription != null && !subscription.isDisposed()) {
subscription.dispose();
}
return;
}
context.setSubscription(subscription);
}
/**
@@ -152,6 +156,31 @@ public class AgentRunRegistry {
remove(requestId);
}
/**
* 显式取消当前节点上的单次运行,并通过原事件处理链生成规范取消终态。
*
* @param requestId 内部请求 ID
* @param userId 当前用户 ID
* @param reason 取消原因
*/
public void cancelRun(String requestId, String userId, String reason) {
if (requestId == null || requestId.isBlank()) {
throw new BusinessException("Agent 运行请求 ID 不能为空");
}
AgentRunContext context = runs.get(requestId);
if (context == null) {
// 取消命令允许因自然终态、重复投递或跨节点竞态而幂等到达。
return;
}
assertOwner(requestId, userId);
context.cancel();
AgentRuntimeEvent event = AgentRuntimeEvent.of(
com.easyagents.agent.runtime.event.AgentRuntimeEventType.CANCELLED);
event.getPayload().put("reason",
reason == null || reason.isBlank() ? "用户已停止生成" : reason);
context.eventConsumer().accept(event);
}
/**
* 取消当前节点上指定 Agent 的全部活跃运行。
*
@@ -314,34 +343,80 @@ public class AgentRunRegistry {
if (requestId == null) {
return;
}
RuntimeException cleanupFailure = null;
AgentRunContext context = runs.remove(requestId);
if (context != null) {
sessionRuns.remove(context.sessionId(), requestId);
context.releaseLock();
try {
context.releaseLock();
} catch (RuntimeException exception) {
cleanupFailure = mergeCleanupFailure(
cleanupFailure, exception, requestId, "release distributed lock");
}
// Redis 解锁失败也不能阻止底层模型、工具与订阅资源释放。
context.closeRuntime();
}
owners.remove(requestId);
Set<String> tokens = requestTokens.remove(requestId);
if (tokens != null) {
tokens.forEach(token -> {
for (String token : tokens) {
resumeTokenIndex.remove(token);
if (routeRegistry != null) {
routeRegistry.removeResumeToken(token);
try {
routeRegistry.removeResumeToken(token);
} catch (RuntimeException exception) {
cleanupFailure = mergeCleanupFailure(
cleanupFailure, exception, requestId, "remove resume token route");
}
}
});
}
}
Set<String> approvals = requestApprovals.remove(requestId);
if (approvals != null) {
approvals.forEach(approvalId -> {
for (String approvalId : approvals) {
approvalTargets.remove(approvalId);
if (routeRegistry != null) {
routeRegistry.removeApproval(approvalId);
try {
routeRegistry.removeApproval(approvalId);
} catch (RuntimeException exception) {
cleanupFailure = mergeCleanupFailure(
cleanupFailure, exception, requestId, "remove approval route");
}
}
});
}
}
if (routeRegistry != null) {
routeRegistry.removeRun(requestId);
try {
routeRegistry.removeRun(requestId);
} catch (RuntimeException exception) {
cleanupFailure = mergeCleanupFailure(
cleanupFailure, exception, requestId, "remove run route");
}
}
if (cleanupFailure != null) {
throw cleanupFailure;
}
}
/**
* 合并运行清理异常,同时保留第一个异常作为调用方可见的根因。
*
* @param current 当前已记录异常
* @param next 本次清理异常
* @param requestId 请求 ID
* @param phase 失败阶段
* @return 合并后的首异常
*/
private RuntimeException mergeCleanupFailure(RuntimeException current,
RuntimeException next,
String requestId,
String phase) {
LOG.warn("Agent runtime cleanup failed, requestId={}, phase={}", requestId, phase, next);
if (current == null) {
return next;
}
current.addSuppressed(next);
return current;
}
/**
@@ -684,10 +759,18 @@ public class AgentRunRegistry {
if (subscription == null) {
return;
}
if (finished.get()) {
subscription.dispose();
return;
}
Disposable previous = this.subscription.getAndSet(subscription);
if (previous != null && !previous.isDisposed()) {
previous.dispose();
}
if (finished.get() && this.subscription.compareAndSet(subscription, null)
&& !subscription.isDisposed()) {
subscription.dispose();
}
}
/**

View File

@@ -1,10 +1,12 @@
package tech.easyflow.agent.runtime;
import com.easyagents.agent.runtime.AgentDefinition;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetriever;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRegistration;
import com.easyagents.agent.runtime.tool.AgentToolInvoker;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
@@ -14,7 +16,7 @@ public class AgentRuntimeBundle {
private AgentDefinition definition;
private Map<String, AgentToolInvoker> toolInvokers = new LinkedHashMap<>();
private Map<String, AgentKnowledgeRetriever> knowledgeRetrievers = new LinkedHashMap<>();
private List<AgentKnowledgeRegistration> knowledgeRegistrations = new ArrayList<>();
/**
* 获取 Agent 定义。
@@ -57,16 +59,18 @@ public class AgentRuntimeBundle {
*
* @return 知识库检索器
*/
public Map<String, AgentKnowledgeRetriever> getKnowledgeRetrievers() {
return knowledgeRetrievers;
public List<AgentKnowledgeRegistration> getKnowledgeRegistrations() {
return knowledgeRegistrations;
}
/**
* 设置知识库检索器。
*
* @param knowledgeRetrievers 知识库检索器
* @param knowledgeRegistrations 知识库运行时绑定
*/
public void setKnowledgeRetrievers(Map<String, AgentKnowledgeRetriever> knowledgeRetrievers) {
this.knowledgeRetrievers = knowledgeRetrievers == null ? new LinkedHashMap<>() : knowledgeRetrievers;
public void setKnowledgeRegistrations(List<AgentKnowledgeRegistration> knowledgeRegistrations) {
this.knowledgeRegistrations = knowledgeRegistrations == null
? new ArrayList<>()
: new ArrayList<>(knowledgeRegistrations);
}
}

View File

@@ -6,9 +6,10 @@ import com.easyagents.agent.runtime.AgentRuntimeContext;
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeDocument;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgePolicy;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRegistration;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetrievalResult;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeSpec;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeToolNames;
import com.easyagents.agent.runtime.memory.AgentMemoryCompressionParameter;
import com.easyagents.agent.runtime.memory.AgentMemoryPolicy;
import com.easyagents.agent.runtime.memory.AgentMemoryType;
@@ -118,11 +119,11 @@ public class AgentRuntimeCompiler {
bundle.setDefinition(definition);
compileTools(agent, definition, bundle);
compileKnowledge(agent, definition, bundle);
if (agentBuiltinToolsConfigResolver != null) {
validateBuiltinTools(definition,
agentBuiltinToolsConfigResolver.resolvePublishedRuntime(agent.getExecutionConfigJson()));
}
compileKnowledge(agent, definition, bundle);
return bundle;
}
@@ -294,7 +295,7 @@ public class AgentRuntimeCompiler {
if (config.artifactPublish().enabled()) {
specs.add(buildArtifactPublishSpec(config.artifactPublish()));
}
assertToolBudget(specs, definition.getMcpSpecs());
assertToolBudget(specs, definition.getMcpSpecs(), definition.getKnowledgeSpecs().size());
}
private void attachBuiltinTools(Agent agent,
@@ -510,11 +511,21 @@ public class AgentRuntimeCompiler {
return names;
}
/**
* 校验内置工具与普通工具、知识库工具及 MCP 工具不存在运行名冲突。
*
* @param definition 已编译 Agent 定义
* @param builtinNames 待启用内置工具名称
* @throws BusinessException 工具名称冲突时抛出
*/
private void assertNoBuiltinNameConflict(AgentDefinition definition, Set<String> builtinNames) {
Set<String> existing = new LinkedHashSet<>();
for (AgentToolSpec spec : definition.getToolSpecs()) {
existing.add(spec.getName());
}
for (AgentKnowledgeSpec spec : definition.getKnowledgeSpecs()) {
existing.add(AgentKnowledgeToolNames.build(spec.getRuntimeName()));
}
for (McpSpec mcp : definition.getMcpSpecs()) {
if (mcp.getFrozenToolManifest() != null) {
mcp.getFrozenToolManifest().forEach(entry -> existing.add(entry.getName()));
@@ -540,6 +551,14 @@ public class AgentRuntimeCompiler {
assertToolBudget(toolSpecs, mcpSpecs, 0);
}
/**
* 校验最终工具数量和 Schema 大小预算。
*
* @param toolSpecs 静态 Tool 声明
* @param mcpSpecs MCP 声明
* @param additionalToolCount 知识库等额外工具数量
* @throws BusinessException 超出预算时抛出
*/
private void assertToolBudget(List<AgentToolSpec> toolSpecs,
List<McpSpec> mcpSpecs,
int additionalToolCount) {
@@ -570,7 +589,7 @@ public class AgentRuntimeCompiler {
}
}
if (toolCount > MAX_RUNTIME_TOOL_COUNT) {
throw new BusinessException("Agent Runtime Tool 数量超过 128 个,请减少直接工具或 Skill 绑定");
throw new BusinessException("Agent Runtime Tool 数量超过 128 个,请减少工具、知识库或 Skill 绑定");
}
if (schemaBytes > MAX_RUNTIME_SCHEMA_BYTES) {
throw new BusinessException("Agent Runtime Tool Schema 超过 2 MiB请减少工具或精简 Schema");
@@ -591,12 +610,27 @@ public class AgentRuntimeCompiler {
}
}
/**
* 将 EasyFlow 知识库绑定编译为一库一工具所需的声明和 Retriever 绑定。
*
* @param agent Agent 发布视图
* @param definition 中立 Agent 定义
* @param bundle 运行时编译结果
* @throws BusinessException 知识库不存在、英文运行名非法或工具名冲突时抛出
*/
private void compileKnowledge(Agent agent, AgentDefinition definition, AgentRuntimeBundle bundle) {
if (agent.getKnowledgeBindings() == null) {
return;
}
List<AgentKnowledgeSpec> specs = new ArrayList<>();
Map<String, com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetriever> retrievers = new LinkedHashMap<>();
List<AgentKnowledgeRegistration> registrations = new ArrayList<>();
Set<String> knowledgeToolNames = new LinkedHashSet<>();
Set<String> existingToolNames = new LinkedHashSet<>();
definition.getToolSpecs().stream()
.filter(Objects::nonNull)
.map(AgentToolSpec::getName)
.filter(Objects::nonNull)
.forEach(existingToolNames::add);
for (AgentKnowledgeBinding binding : agent.getKnowledgeBindings()) {
if (!Boolean.TRUE.equals(binding.getEnabled())) {
continue;
@@ -607,9 +641,9 @@ public class AgentRuntimeCompiler {
}
AgentKnowledgeSpec spec = new AgentKnowledgeSpec();
spec.setKnowledgeId(binding.getKnowledgeId().toString());
spec.setRuntimeName(requireKnowledgeRuntimeName(knowledge));
spec.setName(knowledge.getTitle());
spec.setDescription(knowledge.getDescription());
spec.setRetrievalMode(AgentKnowledgePolicy.AGENTIC);
spec.getMetadata().put("knowledgeType", knowledge.getCollectionType());
spec.getMetadata().put("faqCollection", knowledge.isFaqCollection());
Integer limit = intValue(binding.getOptionsJson(), "limit");
@@ -618,11 +652,37 @@ public class AgentRuntimeCompiler {
if (threshold != null) {
spec.setScoreThreshold(threshold);
}
String toolName = AgentKnowledgeToolNames.build(spec.getRuntimeName());
if (!knowledgeToolNames.add(toolName) || existingToolNames.contains(toolName)) {
throw new BusinessException("Agent 知识库工具运行名冲突:" + toolName);
}
specs.add(spec);
retrievers.put(spec.getKnowledgeId(), request -> retrieveKnowledge(binding, request.getQuery(), request.getLimit(), request.getScoreThreshold()));
registrations.add(new AgentKnowledgeRegistration(spec,
request -> retrieveKnowledge(binding, request.getQuery(), request.getLimit(), request.getScoreThreshold())));
}
definition.setKnowledgeSpecs(specs);
bundle.setKnowledgeRetrievers(retrievers);
bundle.setKnowledgeRegistrations(registrations);
}
/**
* 获取并校验知识库英文运行名。
*
* @param knowledge 知识库发布视图
* @return 合法英文运行名
* @throws BusinessException 英文运行名缺失或非法时抛出
*/
private String requireKnowledgeRuntimeName(DocumentCollection knowledge) {
String runtimeName = knowledge == null ? null : knowledge.getEnglishName();
try {
AgentKnowledgeToolNames.build(runtimeName);
return runtimeName.trim();
} catch (RuntimeException exception) {
String knowledgeName = knowledge == null || knowledge.getTitle() == null
? "未知知识库"
: knowledge.getTitle();
throw new BusinessException(400, 400, "知识库“" + knowledgeName
+ "”的英文名称不能为空,且只能包含字母、数字、下划线和连字符", exception);
}
}
private AgentKnowledgeRetrievalResult retrieveKnowledge(AgentKnowledgeBinding binding, String query, int limit, double scoreThreshold) {

View File

@@ -0,0 +1,32 @@
package tech.easyflow.agent.runtime.agui;
/**
* AG-UI 可重连运行的公开状态与内部路由描述。
*
* @param runId 客户端运行 ID
* @param requestId 服务端内部请求 ID
* @param threadId AG-UI thread ID
* @param agentId Agent ID
* @param sessionId 运行会话 ID
* @param userId 所属用户 ID
* @param tenantId 所属租户 ID
* @param draft 是否为草稿试运行
* @param status 当前运行状态
* @param lastCursor 已持久化的最后事件游标
* @param createdAt 创建时间戳
* @param updatedAt 最近更新时间戳
*/
public record AgentAguiRunDescriptor(
String runId,
String requestId,
String threadId,
String agentId,
String sessionId,
String userId,
String tenantId,
boolean draft,
AgentAguiRunStatus status,
long lastCursor,
long createdAt,
long updatedAt) {
}

View File

@@ -0,0 +1,330 @@
package tech.easyflow.agent.runtime.agui;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.easyagents.agui.AguiExtendedEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.ReentrantLock;
import java.util.UUID;
/**
* 将高频 AG-UI delta 在内存中短暂合并并批量写入 Redis。
*
* <p>该组件以每个 runId 的同步缓冲保证事件顺序Redis 故障时保留原批次等待下一次重试,
* 避免每个模型 token 都产生一次网络往返。</p>
*/
@Component
public class AgentAguiRunJournal {
private static final Logger LOG = LoggerFactory.getLogger(AgentAguiRunJournal.class);
private static final int MAX_BUFFERED_EVENTS_PER_RUN = 4096;
private static final long MAX_EVENT_BYTES = 512L * 1024L;
private static final long MAX_BUFFERED_BYTES_PER_RUN = 4L * 1024L * 1024L;
private static final long MAX_BUFFERED_BYTES_GLOBAL = 64L * 1024L * 1024L;
private static final long COMPLETED_RETRY_WINDOW_MILLIS = 5L * 60L * 1000L;
private final AgentAguiRunStore store;
private final ObjectMapper objectMapper;
private final Map<String, RunBuffer> buffers = new ConcurrentHashMap<>();
private final AtomicLong globalBufferedBytes = new AtomicLong();
/**
* 创建 AG-UI 事件日志协调器。
*
* @param store Redis 运行存储
* @param objectMapper JSON 映射器
*/
public AgentAguiRunJournal(AgentAguiRunStore store, ObjectMapper objectMapper) {
this.store = store;
this.objectMapper = objectMapper;
}
/**
* 追加一条已编码协议事件。
*
* @param runId 运行 ID
* @param data 协议事件 JSON
*/
public void append(String runId, String data) {
if (data == null || data.isBlank()) {
throw new IllegalArgumentException("AG-UI 事件不能为空");
}
long dataBytes = utf8Bytes(data);
if (dataBytes > MAX_EVENT_BYTES) {
throw new IllegalStateException("单条 AG-UI 事件超过 512 KiB 安全上限");
}
RunBuffer buffer = buffers.computeIfAbsent(runId, ignored -> new RunBuffer());
try {
synchronized (buffer) {
if (buffer.completed) {
throw new IllegalStateException("AG-UI 运行事件日志已经收口");
}
int pendingEventCount = buffer.pendingBatch == null ? 0 : buffer.pendingBatch.events().size();
if (pendingEventCount + buffer.tailEvents.size() >= MAX_BUFFERED_EVENTS_PER_RUN) {
throw new IllegalStateException("AG-UI 事件日志暂时不可用,请稍后重试");
}
String merged = buffer.tailEvents.isEmpty()
? null
: mergeDelta(buffer.tailEvents.get(buffer.tailEvents.size() - 1), data);
long previousBytes = 0L;
long nextBytes = dataBytes;
if (merged != null) {
String previous = buffer.tailEvents.get(buffer.tailEvents.size() - 1);
previousBytes = utf8Bytes(previous);
nextBytes = utf8Bytes(merged);
if (nextBytes > MAX_EVENT_BYTES) {
throw new IllegalStateException("合并后的 AG-UI 事件超过 512 KiB 安全上限");
}
}
long deltaBytes = nextBytes - previousBytes;
if (buffer.totalBytes() + deltaBytes > MAX_BUFFERED_BYTES_PER_RUN) {
throw new IllegalStateException("单次 AG-UI 运行待写日志超过 4 MiB 安全上限");
}
long globalBytes = globalBufferedBytes.addAndGet(deltaBytes);
if (globalBytes > MAX_BUFFERED_BYTES_GLOBAL) {
globalBufferedBytes.addAndGet(-deltaBytes);
throw new IllegalStateException("AG-UI 待写日志超过 64 MiB 全局安全上限");
}
if (merged == null) {
buffer.tailEvents.add(data);
} else {
buffer.tailEvents.set(buffer.tailEvents.size() - 1, merged);
}
buffer.tailBytes += deltaBytes;
}
} catch (RuntimeException exception) {
removeEmptyBuffer(runId, buffer);
throw exception;
}
}
/**
* 丢弃尚未确认持久化的普通事件,并以独立小批次记录日志故障终态。
*
* <p>该路径保留已经成功写入 Redis 的历史。即使前一批次处于结果不确定状态,
* 新终态也使用新的批次 ID避免幂等判重吞掉故障事件。</p>
*
* @param threadId AG-UI 线程 ID
* @param runId 运行 ID
* @param code 稳定错误码
* @param message 用户可理解的错误信息
*/
public void fail(String threadId, String runId, String code, String message) {
String terminalEvent;
try {
terminalEvent = objectMapper.writeValueAsString(new AguiExtendedEvent.RunError(
threadId, runId, message, code));
} catch (Exception exception) {
throw new IllegalStateException("AG-UI 日志故障终态编码失败", exception);
}
long terminalBytes = utf8Bytes(terminalEvent);
RunBuffer buffer = buffers.computeIfAbsent(runId, ignored -> new RunBuffer());
synchronized (buffer) {
long discardedBytes = buffer.totalBytes();
buffer.pendingBatch = null;
buffer.tailEvents.clear();
buffer.tailBytes = 0L;
if (discardedBytes > 0L) {
globalBufferedBytes.addAndGet(-discardedBytes);
}
buffer.pendingBatch = new PendingBatch(
UUID.randomUUID().toString(), List.of(terminalEvent), terminalBytes);
globalBufferedBytes.addAndGet(terminalBytes);
buffer.completed = true;
buffer.completedAt = System.currentTimeMillis();
}
flush(runId, buffer, true);
}
/**
* 在运行收口前同步尝试刷入该运行的剩余事件。
*
* @param runId 运行 ID
*/
public void flush(String runId) {
RunBuffer buffer = buffers.get(runId);
if (buffer != null) {
flush(runId, buffer, true);
}
}
/**
* 同步刷入终态事件,并在确认缓冲为空后释放本地运行缓冲。
*
* @param runId 运行 ID
*/
public void complete(String runId) {
RunBuffer buffer = buffers.get(runId);
if (buffer == null) {
return;
}
synchronized (buffer) {
buffer.completed = true;
buffer.completedAt = System.currentTimeMillis();
}
flush(runId, buffer, true);
}
/**
* 定期批量写入所有活动运行的待持久化事件。
*/
@Scheduled(fixedDelayString = "${easyflow.agent.runtime.agui-journal-flush-interval:50ms}")
public void flushPending() {
for (Map.Entry<String, RunBuffer> entry : buffers.entrySet()) {
flush(entry.getKey(), entry.getValue(), false);
}
}
private void flush(String runId, RunBuffer buffer, boolean propagateFailure) {
boolean acquired;
if (propagateFailure) {
buffer.flushLock.lock();
acquired = true;
} else {
acquired = buffer.flushLock.tryLock();
}
if (!acquired) {
return;
}
try {
while (true) {
PendingBatch batch;
synchronized (buffer) {
if (buffer.pendingBatch == null && !buffer.tailEvents.isEmpty()) {
buffer.pendingBatch = new PendingBatch(
UUID.randomUUID().toString(),
List.copyOf(buffer.tailEvents),
buffer.tailBytes);
buffer.tailEvents.clear();
buffer.tailBytes = 0L;
}
batch = buffer.pendingBatch;
if (batch == null) {
if (buffer.completed) {
removeBuffer(runId, buffer);
}
return;
}
}
try {
// Redis I/O 必须位于 RunBuffer 锁外,避免阻塞模型事件线程。
store.append(runId, batch.id(), batch.events());
synchronized (buffer) {
if (buffer.pendingBatch == batch) {
buffer.pendingBatch = null;
globalBufferedBytes.addAndGet(-batch.bytes());
}
}
} catch (RuntimeException exception) {
LOG.error("AG-UI 事件日志写入失败: runId={}, eventCount={}",
runId, batch.events().size(), exception);
boolean batchWasReplaced;
synchronized (buffer) {
batchWasReplaced = buffer.pendingBatch != batch;
if (!batchWasReplaced && buffer.completed && buffer.completedAt > 0L
&& System.currentTimeMillis() - buffer.completedAt
>= COMPLETED_RETRY_WINDOW_MILLIS) {
LOG.error("丢弃超过重试窗口的 AG-UI 终态日志缓冲: runId={}, eventCount={}, bytes={}",
runId, buffer.eventCount(), buffer.totalBytes());
removeBuffer(runId, buffer);
}
}
if (batchWasReplaced) {
continue;
}
if (propagateFailure) {
throw exception;
}
return;
}
}
} finally {
buffer.flushLock.unlock();
}
}
private void removeEmptyBuffer(String runId, RunBuffer buffer) {
synchronized (buffer) {
if (buffer.pendingBatch == null && buffer.tailEvents.isEmpty() && !buffer.flushLock.isLocked()) {
buffers.remove(runId, buffer);
}
}
}
private void removeBuffer(String runId, RunBuffer buffer) {
if (!buffers.remove(runId, buffer)) {
return;
}
long remainingBytes = buffer.totalBytes();
buffer.pendingBatch = null;
buffer.tailEvents.clear();
buffer.tailBytes = 0L;
if (remainingBytes > 0L) {
globalBufferedBytes.addAndGet(-remainingBytes);
}
}
private long utf8Bytes(String value) {
return value.getBytes(StandardCharsets.UTF_8).length;
}
private String mergeDelta(String previous, String current) {
try {
JsonNode previousNode = objectMapper.readTree(previous);
JsonNode currentNode = objectMapper.readTree(current);
String type = currentNode.path("type").asText();
if (!type.equals(previousNode.path("type").asText()) || !isMergeable(type)) {
return null;
}
String identityField = "TOOL_CALL_ARGS".equals(type) ? "toolCallId" : "messageId";
if (!currentNode.path(identityField).asText().equals(previousNode.path(identityField).asText())) {
return null;
}
if (!(previousNode instanceof ObjectNode previousObject)) {
return null;
}
previousObject.put("delta",
previousNode.path("delta").asText() + currentNode.path("delta").asText());
return objectMapper.writeValueAsString(previousObject);
} catch (Exception ignored) {
return null;
}
}
private boolean isMergeable(String type) {
return "TEXT_MESSAGE_CONTENT".equals(type)
|| "REASONING_MESSAGE_CONTENT".equals(type)
|| "TOOL_CALL_ARGS".equals(type);
}
private record PendingBatch(String id, List<String> events, long bytes) {
}
private static final class RunBuffer {
private final List<String> tailEvents = new ArrayList<>();
private final ReentrantLock flushLock = new ReentrantLock();
private PendingBatch pendingBatch;
private long tailBytes;
private boolean completed;
private long completedAt;
private int eventCount() {
return tailEvents.size() + (pendingBatch == null ? 0 : pendingBatch.events().size());
}
private long totalBytes() {
return tailBytes + (pendingBatch == null ? 0L : pendingBatch.bytes());
}
}
}

View File

@@ -0,0 +1,36 @@
package tech.easyflow.agent.runtime.agui;
/**
* AG-UI 可重连运行状态。
*/
public enum AgentAguiRunStatus {
/**
* 运行正在生成或等待人工审批。
*/
RUNNING,
/**
* 运行成功完成。
*/
COMPLETED,
/**
* 运行失败。
*/
FAILED,
/**
* 运行被用户显式取消。
*/
CANCELLED;
/**
* 判断当前状态是否已经终止。
*
* @return 已终止时为 true
*/
public boolean isTerminal() {
return this != RUNNING;
}
}

View File

@@ -0,0 +1,36 @@
package tech.easyflow.agent.runtime.agui;
/**
* 提供给前端刷新恢复判断的 AG-UI 运行状态。
*
* @param runId 运行 ID
* @param threadId AG-UI thread ID
* @param status 当前状态
* @param lastCursor 最后事件游标
* @param draft 是否为草稿试运行
* @param updatedAt 最近更新时间戳
*/
public record AgentAguiRunStatusView(
String runId,
String threadId,
AgentAguiRunStatus status,
long lastCursor,
boolean draft,
long updatedAt) {
/**
* 从内部运行描述生成公开状态。
*
* @param descriptor 内部运行描述
* @return 公开状态
*/
public static AgentAguiRunStatusView from(AgentAguiRunDescriptor descriptor) {
return new AgentAguiRunStatusView(
descriptor.runId(),
descriptor.threadId(),
descriptor.status(),
descriptor.lastCursor(),
descriptor.draft(),
descriptor.updatedAt());
}
}

View File

@@ -0,0 +1,369 @@
package tech.easyflow.agent.runtime.agui;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import com.easyagents.agui.AguiExtendedEvent;
import tech.easyflow.agent.config.AgentRuntimeProperties;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.time.Duration;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.regex.Pattern;
/**
* 基于 Redis 的 AG-UI 运行元数据与顺序事件日志。
*
* <p>运行 ID 只承担幂等查找作用,所有读取和取消仍必须校验登录用户与租户归属。</p>
*/
@Component
public class AgentAguiRunStore {
private static final String RUN_PREFIX = "easyflow:agent:agui:run:";
private static final String STATUS_FIELD = "status";
private static final int REPLAY_BATCH_SIZE = 256;
private static final Pattern RUN_ID_PATTERN = Pattern.compile("[A-Za-z0-9_-]{1,128}");
private static final long MAX_RUN_EVENT_BYTES = 16L * 1024L * 1024L;
private static final long TERMINAL_EVENT_RESERVE_BYTES = 64L * 1024L;
private static final DefaultRedisScript<Long> APPEND_SCRIPT = new DefaultRedisScript<>("""
local status = redis.call('HGET', KEYS[1], 'status')
if not status then
return -3
end
local lastCursor = tonumber(redis.call('HGET', KEYS[1], 'lastCursor') or '0')
if redis.call('HGET', KEYS[1], 'lastBatchId') == ARGV[1] then
return lastCursor
end
if status ~= 'RUNNING' then
return lastCursor
end
local currentBytes = tonumber(redis.call('HGET', KEYS[1], 'eventBytes') or '0')
local incomingBytes = tonumber(ARGV[5])
local byteLimit = tonumber(ARGV[6])
if ARGV[4] ~= '' then
byteLimit = byteLimit + tonumber(ARGV[7])
end
if currentBytes + incomingBytes > byteLimit then
return -1
end
local eventCount = tonumber(ARGV[8])
for index = 1, eventCount do
redis.call('RPUSH', KEYS[2], ARGV[8 + index])
end
local cursor = redis.call('LLEN', KEYS[2])
redis.call('HSET', KEYS[1],
'updatedAt', ARGV[2],
'lastBatchId', ARGV[1],
'eventBytes', currentBytes + incomingBytes,
'lastCursor', cursor)
if ARGV[4] ~= '' then
redis.call('HSET', KEYS[1], 'status', ARGV[4])
end
redis.call('PEXPIRE', KEYS[1], ARGV[3])
redis.call('PEXPIRE', KEYS[2], ARGV[3])
redis.call('PEXPIRE', KEYS[3], ARGV[3])
return cursor
""", Long.class);
private static final DefaultRedisScript<Long> REQUEST_CANCEL_SCRIPT = new DefaultRedisScript<>("""
if redis.call('HGET', KEYS[1], 'status') ~= 'RUNNING' then
return 0
end
redis.call('HSET', KEYS[1], 'cancelRequested', 'true', 'updatedAt', ARGV[1])
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return 1
""", Long.class);
private final StringRedisTemplate redisTemplate;
private final ObjectMapper objectMapper;
private final Duration retention;
/**
* 创建 AG-UI 运行存储。
*
* @param redisTemplate Redis 字符串模板
* @param objectMapper JSON 映射器
* @param properties Agent 运行配置
*/
public AgentAguiRunStore(StringRedisTemplate redisTemplate,
ObjectMapper objectMapper,
AgentRuntimeProperties properties) {
this.redisTemplate = redisTemplate;
this.objectMapper = objectMapper;
this.retention = properties.getAguiRunRetention();
}
/**
* 在模型运行启动前注册唯一运行元数据。
*
* @param descriptor 初始运行描述
* @throws BusinessException runId 已存在时抛出
*/
public void create(AgentAguiRunDescriptor descriptor) {
String runId = requireRunId(descriptor == null ? null : descriptor.runId());
Boolean reserved = redisTemplate.opsForValue().setIfAbsent(
reservationKey(runId), descriptor.userId(), retention);
if (!Boolean.TRUE.equals(reserved)) {
throw new BusinessException("当前 Agent 运行标识已存在,请勿重复提交");
}
try {
Map<String, String> values = new LinkedHashMap<>();
values.put("runId", runId);
values.put("requestId", value(descriptor.requestId()));
values.put("threadId", value(descriptor.threadId()));
values.put("agentId", value(descriptor.agentId()));
values.put("sessionId", value(descriptor.sessionId()));
values.put("userId", value(descriptor.userId()));
values.put("tenantId", value(descriptor.tenantId()));
values.put("draft", Boolean.toString(descriptor.draft()));
values.put(STATUS_FIELD, AgentAguiRunStatus.RUNNING.name());
values.put("cancelRequested", Boolean.FALSE.toString());
values.put("eventBytes", "0");
values.put("lastBatchId", "");
values.put("lastCursor", "0");
values.put("createdAt", Long.toString(descriptor.createdAt()));
values.put("updatedAt", Long.toString(descriptor.updatedAt()));
meta().putAll(metaKey(runId), values);
redisTemplate.expire(metaKey(runId), retention);
redisTemplate.expire(eventsKey(runId), retention);
} catch (RuntimeException exception) {
redisTemplate.delete(reservationKey(runId));
throw exception;
}
}
/**
* 批量追加严格有序的协议事件。
*
* @param runId 运行 ID
* @param events 已编码 AG-UI 事件
* @return 追加后的最后事件游标
*/
public long append(String runId, String batchId, List<String> events) {
requireRunId(runId);
if (events == null || events.isEmpty()) {
AgentAguiRunDescriptor descriptor = find(runId);
return descriptor == null ? 0L : descriptor.lastCursor();
}
if (batchId == null || batchId.isBlank()) {
throw new IllegalArgumentException("AG-UI 事件批次 ID 不能为空");
}
AgentAguiRunStatus terminalStatus = terminalStatus(events);
long eventBytes = events.stream()
.mapToLong(event -> event.getBytes(StandardCharsets.UTF_8).length)
.sum();
List<String> arguments = new java.util.ArrayList<>(8 + events.size());
arguments.add(batchId);
arguments.add(Long.toString(System.currentTimeMillis()));
arguments.add(Long.toString(retention.toMillis()));
arguments.add(terminalStatus == null ? "" : terminalStatus.name());
arguments.add(Long.toString(eventBytes));
arguments.add(Long.toString(MAX_RUN_EVENT_BYTES));
arguments.add(Long.toString(TERMINAL_EVENT_RESERVE_BYTES));
arguments.add(Integer.toString(events.size()));
arguments.addAll(events);
Long cursor = redisTemplate.execute(
APPEND_SCRIPT,
List.of(metaKey(runId), eventsKey(runId), reservationKey(runId)),
arguments.toArray());
if (cursor == null || cursor == -3L) {
throw new IllegalStateException("AG-UI 运行记录不存在或已过期");
}
if (cursor == -1L) {
throw new IllegalStateException("AG-UI 运行事件超过 16 MiB 安全上限");
}
return cursor;
}
/**
* 读取游标之后的一批事件。
*
* @param runId 运行 ID
* @param afterCursor 已消费的最后游标,零表示从头读取
* @return 按游标升序排列的事件
*/
public List<String> readAfter(String runId, long afterCursor) {
requireRunId(runId);
long start = Math.max(0L, afterCursor);
List<String> values = redisTemplate.opsForList().range(
eventsKey(runId), start, start + REPLAY_BATCH_SIZE - 1L);
return values == null ? List.of() : values;
}
/**
* 查询运行描述。
*
* @param runId 运行 ID
* @return 运行描述;不存在时为 null
*/
public AgentAguiRunDescriptor find(String runId) {
requireRunId(runId);
Map<Object, Object> values = meta().entries(metaKey(runId));
if (values == null || values.isEmpty()) {
return null;
}
return new AgentAguiRunDescriptor(
text(values, "runId"),
text(values, "requestId"),
text(values, "threadId"),
text(values, "agentId"),
text(values, "sessionId"),
text(values, "userId"),
text(values, "tenantId"),
Boolean.parseBoolean(text(values, "draft")),
parseStatus(text(values, STATUS_FIELD)),
number(values, "lastCursor"),
number(values, "createdAt"),
number(values, "updatedAt"));
}
/**
* 查询并校验当前登录账号对运行的访问权。
*
* @param runId 运行 ID
* @param account 当前登录账号
* @return 已授权运行描述
* @throws BusinessException 运行不存在或无访问权时抛出
*/
public AgentAguiRunDescriptor requireOwned(String runId, LoginAccount account) {
AgentAguiRunDescriptor descriptor = find(runId);
if (descriptor == null) {
throw new BusinessException("Agent 运行记录不存在或已过期");
}
String userId = account == null || account.getId() == null ? null : account.getId().toString();
String tenantId = account == null || account.getTenantId() == null ? null : account.getTenantId().toString();
if (!descriptor.userId().equals(userId) || !descriptor.tenantId().equals(tenantId)) {
throw new BusinessException("无权访问该 Agent 运行");
}
return descriptor;
}
/**
* 将无法继续的运行收口为失败状态。
*
* @param runId 运行 ID
*/
public void failOwnerLost(AgentAguiRunDescriptor descriptor) {
if (descriptor == null || descriptor.status().isTerminal()) {
return;
}
try {
String event = objectMapper.writeValueAsString(new AguiExtendedEvent.RunError(
descriptor.threadId(),
descriptor.runId(),
"Agent 运行节点已不可用",
"AGENT_RUN_OWNER_LOST"));
append(descriptor.runId(), "owner-lost-" + UUID.randomUUID(), List.of(event));
} catch (RuntimeException exception) {
throw exception;
} catch (Exception exception) {
throw new IllegalStateException("AG-UI owner-lost 终态编码失败", exception);
}
}
/**
* 记录显式取消意图,供 Runtime 注册前后的启动窗口检查。
*
* @param runId 运行 ID
* @return 本次是否首次接受取消意图
*/
public boolean requestCancellation(String runId) {
requireRunId(runId);
Long result = redisTemplate.execute(
REQUEST_CANCEL_SCRIPT,
List.of(metaKey(runId)),
Long.toString(System.currentTimeMillis()),
Long.toString(retention.toMillis()));
return Long.valueOf(1L).equals(result);
}
/**
* 判断运行是否收到显式取消意图。
*
* @param runId 运行 ID
* @return 已请求取消时为 true
*/
public boolean isCancellationRequested(String runId) {
Object value = meta().get(metaKey(requireRunId(runId)), "cancelRequested");
return Boolean.parseBoolean(value == null ? "false" : value.toString());
}
private HashOperations<String, Object, Object> meta() {
return redisTemplate.opsForHash();
}
private AgentAguiRunStatus terminalStatus(List<String> events) {
AgentAguiRunStatus result = null;
for (String event : events) {
try {
JsonNode value = objectMapper.readTree(event);
String type = value.path("type").asText();
if ("RUN_FINISHED".equals(type)) {
result = AgentAguiRunStatus.COMPLETED;
} else if ("RUN_ERROR".equals(type)) {
result = "RUN_CANCELLED".equals(value.path("code").asText())
? AgentAguiRunStatus.CANCELLED
: AgentAguiRunStatus.FAILED;
}
} catch (Exception exception) {
throw new IllegalStateException("AG-UI 运行事件格式不合法", exception);
}
}
return result;
}
private AgentAguiRunStatus parseStatus(String value) {
try {
return AgentAguiRunStatus.valueOf(value);
} catch (Exception ignored) {
return AgentAguiRunStatus.FAILED;
}
}
private String requireRunId(String runId) {
if (runId == null || !RUN_ID_PATTERN.matcher(runId).matches()) {
throw new BusinessException("Agent 运行 ID 不合法");
}
return runId;
}
private String reservationKey(String runId) {
return runKey(runId, "reserved");
}
private String metaKey(String runId) {
return runKey(runId, "meta");
}
private String eventsKey(String runId) {
return runKey(runId, "events");
}
private String runKey(String runId, String suffix) {
return RUN_PREFIX + "{" + runId + "}:" + suffix;
}
private String value(String value) {
return value == null ? "" : value;
}
private String text(Map<Object, Object> values, String key) {
Object value = values.get(key);
return value == null ? "" : value.toString();
}
private long number(Map<Object, Object> values, String key) {
try {
return Long.parseLong(text(values, key));
} catch (NumberFormatException ignored) {
return 0L;
}
}
}

View File

@@ -0,0 +1,160 @@
package tech.easyflow.agent.runtime.agui;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* 以共享调度器为本节点上的 AG-UI 重连订阅者增量重放 Redis 事件。
*
* <p>所有订阅共享一个短周期任务,不为每个浏览器连接创建阻塞线程。</p>
*/
@Component
public class AgentAguiRunSubscriptionService {
private static final Logger LOG = LoggerFactory.getLogger(AgentAguiRunSubscriptionService.class);
private static final long KEEP_ALIVE_INTERVAL_MILLIS = 15_000L;
private final AgentAguiRunStore store;
private final Map<String, CopyOnWriteArrayList<Subscriber>> subscribers = new ConcurrentHashMap<>();
/**
* 创建 AG-UI 重连订阅服务。
*
* @param store Redis 运行存储
*/
public AgentAguiRunSubscriptionService(AgentAguiRunStore store) {
this.store = store;
}
/**
* 注册一个从指定游标继续的 SSE 订阅。
*
* @param descriptor 已授权运行描述
* @param afterCursor 已消费的最后游标
* @return 重连 SSE 发射器
*/
public SseEmitter subscribe(AgentAguiRunDescriptor descriptor, long afterCursor) {
return subscribe(descriptor, afterCursor, new SseEmitter(0L));
}
/**
* 使用指定发射器注册订阅,供生命周期测试复用同一调度逻辑。
*
* @param descriptor 已授权运行描述
* @param afterCursor 已消费的最后游标
* @param emitter SSE 发射器
* @return 已注册发射器
*/
SseEmitter subscribe(AgentAguiRunDescriptor descriptor,
long afterCursor,
SseEmitter emitter) {
Subscriber subscriber = new Subscriber(emitter, Math.max(0L, afterCursor));
subscribers.computeIfAbsent(descriptor.runId(), ignored -> new CopyOnWriteArrayList<>())
.add(subscriber);
Runnable remove = () -> remove(descriptor.runId(), subscriber);
emitter.onCompletion(remove);
emitter.onTimeout(remove);
emitter.onError(error -> remove.run());
return emitter;
}
/**
* 周期性向全部本地订阅者重放新增事件并发送保活注释。
*/
@Scheduled(fixedDelayString = "${easyflow.agent.runtime.agui-replay-poll-interval:100ms}")
public void dispatch() {
for (Map.Entry<String, CopyOnWriteArrayList<Subscriber>> entry : subscribers.entrySet()) {
dispatch(entry.getKey(), entry.getValue());
}
}
private void dispatch(String runId, CopyOnWriteArrayList<Subscriber> runSubscribers) {
try {
AgentAguiRunDescriptor descriptor = store.find(runId);
if (descriptor == null) {
runSubscribers.forEach(subscriber -> removeAndComplete(runId, subscriber));
return;
}
Map<Long, List<Subscriber>> cursorGroups = new LinkedHashMap<>();
for (Subscriber subscriber : runSubscribers) {
cursorGroups.computeIfAbsent(subscriber.cursor, ignored -> new java.util.ArrayList<>())
.add(subscriber);
}
for (Map.Entry<Long, List<Subscriber>> cursorGroup : cursorGroups.entrySet()) {
long cursor = cursorGroup.getKey();
List<String> events = store.readAfter(runId, cursor);
for (Subscriber subscriber : cursorGroup.getValue()) {
dispatchSubscriber(runId, subscriber, cursor, events, descriptor);
}
}
} catch (RuntimeException exception) {
LOG.error("AG-UI 重连订阅调度失败: runId={}, subscriberCount={}",
runId, runSubscribers.size(), exception);
}
}
private void dispatchSubscriber(String runId,
Subscriber subscriber,
long afterCursor,
List<String> events,
AgentAguiRunDescriptor descriptor) {
try {
for (int index = 0; index < events.size(); index++) {
long cursor = afterCursor + index + 1L;
subscriber.emitter.send(SseEmitter.event().id(Long.toString(cursor)).data(events.get(index)));
subscriber.cursor = cursor;
subscriber.lastWriteAt = System.currentTimeMillis();
}
if (descriptor.status().isTerminal() && subscriber.cursor >= descriptor.lastCursor()) {
removeAndComplete(runId, subscriber);
return;
}
long now = System.currentTimeMillis();
if (events.isEmpty() && now - subscriber.lastWriteAt >= KEEP_ALIVE_INTERVAL_MILLIS) {
subscriber.emitter.send(SseEmitter.event().comment("keepalive"));
subscriber.lastWriteAt = now;
}
} catch (IOException | IllegalStateException exception) {
LOG.debug("移除已断开的 AG-UI 重连订阅: runId={}, cursor={}",
runId, subscriber.cursor, exception);
remove(runId, subscriber);
}
}
private void removeAndComplete(String runId, Subscriber subscriber) {
remove(runId, subscriber);
subscriber.emitter.complete();
}
private void remove(String runId, Subscriber subscriber) {
CopyOnWriteArrayList<Subscriber> values = subscribers.get(runId);
if (values == null) {
return;
}
values.remove(subscriber);
if (values.isEmpty()) {
subscribers.remove(runId, values);
}
}
private static final class Subscriber {
private final SseEmitter emitter;
private long cursor;
private long lastWriteAt = System.currentTimeMillis();
private Subscriber(SseEmitter emitter, long cursor) {
this.emitter = emitter;
this.cursor = cursor;
}
}
}

View File

@@ -4,7 +4,7 @@ package tech.easyflow.agent.runtime.agui;
* 单次 AG-UI 连接的客户端 wire 标识。
*
* @param threadId 客户端 thread ID
* @param runId 客户端 run ID用于协议输出
* @param runId 客户端 run ID用于协议输出、幂等查找与刷新重连
* @param userMessageId 客户端本轮用户消息 ID
* @param userMessageContent 客户端本轮用户消息正文
*/

View File

@@ -0,0 +1,144 @@
package tech.easyflow.agent.runtime.agui;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* 将 AG-UI 运行输出写入可重放日志,同时尽力推送到首次 HTTP 连接。
*
* <p>底层浏览器连接关闭后,本对象仍保持逻辑可写,模型运行只会因终态或显式取消而结束。</p>
*/
public final class ResumableAguiSseEmitter extends ChatSseEmitter {
private static final Logger LOG = LoggerFactory.getLogger(ResumableAguiSseEmitter.class);
private final String threadId;
private final String runId;
private final AgentAguiRunJournal journal;
private final SseEmitter subscriberEmitter;
private final AtomicBoolean completed = new AtomicBoolean(false);
private final AtomicBoolean journalFailed = new AtomicBoolean(false);
/**
* 创建可重连 AG-UI SSE 发射器。
*
* @param threadId AG-UI 线程 ID
* @param runId 运行 ID
* @param journal 可重放事件日志
* @param subscriberEmitter 从游标零开始的首次订阅
*/
public ResumableAguiSseEmitter(String threadId,
String runId,
AgentAguiRunJournal journal,
SseEmitter subscriberEmitter) {
super(0L);
this.threadId = threadId;
this.runId = runId;
this.journal = journal;
this.subscriberEmitter = java.util.Objects.requireNonNull(
subscriberEmitter, "subscriberEmitter cannot be null");
}
/**
* 返回与重连相同的 Redis 游标订阅,首次连接不走旁路直推。
*
* @return 首次订阅 SSE
*/
@Override
public SseEmitter getEmitter() {
return subscriberEmitter;
}
/**
* 先记录事件,再尽力发送给当前浏览器连接。
*
* @param data 已编码 AG-UI 事件
* @return 日志已接受事件时为 true
*/
@Override
public boolean sendData(String data) {
if (completed.get()) {
return false;
}
try {
journal.append(runId, data);
} catch (RuntimeException exception) {
LOG.error("AG-UI 事件日志拒绝写入: runId={}", runId, exception);
journalFailed.set(true);
completed.set(true);
failJournal(exception);
super.complete();
return false;
}
return true;
}
/**
* 刷入尾部事件并关闭首次 HTTP 连接。
*/
@Override
public void complete() {
if (!completed.compareAndSet(false, true)) {
return;
}
try {
journal.complete(runId);
} catch (RuntimeException exception) {
LOG.error("AG-UI 运行终态日志同步写入失败,改写为日志故障终态: runId={}", runId, exception);
journalFailed.set(true);
failJournal(exception);
} finally {
super.complete();
}
}
/**
* 刷入尾部事件并关闭首次 HTTP 连接。
*
* @param error 关闭原因
*/
@Override
public void completeWithError(Throwable error) {
complete();
}
/**
* 返回运行输出逻辑关闭状态,不受首次浏览器连接断开影响。
*
* @return 运行已经收口时为 true
*/
@Override
public boolean isClosed() {
return completed.get();
}
/**
* 判断本次运行是否以可持久恢复的方式完成。
*
* @return 事件日志没有发生写入故障时为 {@code true}
*/
public boolean isJournalCompletionSuccessful() {
return completed.get() && !journalFailed.get();
}
private void failJournal(RuntimeException exception) {
String detail = exception.getMessage() == null ? "" : exception.getMessage();
boolean capacityFailure = detail.contains("安全上限") || detail.contains("16 MiB");
String code = capacityFailure
? "AGENT_RUN_JOURNAL_LIMIT"
: "AGENT_RUN_JOURNAL_UNAVAILABLE";
String message = capacityFailure
? "Agent 输出超过可恢复日志容量,运行已停止"
: "Agent 输出日志暂时不可用,运行已停止";
try {
journal.fail(threadId, runId, code, message);
} catch (RuntimeException terminalException) {
LOG.error("AG-UI 日志故障终态暂未写入,将由后台继续重试: runId={}",
runId, terminalException);
}
}
}

View File

@@ -67,7 +67,9 @@ public abstract class AbstractAgentAsyncSubTools implements AsyncSubTools {
* @param arguments 调用参数
* @return 执行结果
*/
protected abstract AgentToolExecutionResult executeBusiness(Map<String, Object> arguments);
protected abstract AgentToolExecutionResult executeBusiness(
Map<String, Object> arguments,
AgentToolContext context);
/**
* {@inheritDoc}
@@ -92,7 +94,7 @@ public abstract class AbstractAgentAsyncSubTools implements AsyncSubTools {
record.getMetadata().put("toolDisplayName", displayName());
appendEvent(record, "SUBMITTED", displayName() + "任务已提交");
taskStore.create(record);
dispatch(sessionId, record.getTaskId(), record.getArguments());
dispatch(sessionId, record.getTaskId(), record.getArguments(), context);
AsyncToolSubmitResult result = new AsyncToolSubmitResult();
result.setTaskId(taskId);
@@ -157,16 +159,23 @@ public abstract class AbstractAgentAsyncSubTools implements AsyncSubTools {
return result;
}
private void dispatch(String sessionId, String taskId, Map<String, Object> arguments) {
private void dispatch(String sessionId,
String taskId,
Map<String, Object> arguments,
AgentToolContext context) {
try {
taskExecutor.execute(() -> executeTask(sessionId, taskId, arguments));
taskExecutor.execute(() -> executeTask(
sessionId, taskId, arguments, context));
} catch (Exception e) {
taskStore.update(sessionId, taskId, record -> fail(record, e));
throw new BusinessException("提交异步工具任务失败:" + safeMessage(e));
}
}
private void executeTask(String sessionId, String taskId, Map<String, Object> arguments) {
private void executeTask(String sessionId,
String taskId,
Map<String, Object> arguments,
AgentToolContext context) {
try {
taskStore.update(sessionId, taskId, record -> {
record.setStatus(AsyncToolTaskStatus.RUNNING);
@@ -174,7 +183,8 @@ public abstract class AbstractAgentAsyncSubTools implements AsyncSubTools {
appendEvent(record, "RUNNING", displayName() + "任务执行中");
return record;
});
AgentToolExecutionResult executionResult = executeBusiness(arguments);
AgentToolExecutionResult executionResult = executeBusiness(
arguments, context);
taskStore.update(sessionId, taskId, record -> {
record.setStatus(AsyncToolTaskStatus.SUCCEEDED);
record.setSummary(displayName() + "任务已完成");

View File

@@ -1,5 +1,6 @@
package tech.easyflow.agent.runtime.asynctool;
import com.easyagents.agent.runtime.tool.AgentToolContext;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import tech.easyflow.agent.enums.AgentToolType;
import tech.easyflow.agent.runtime.tool.AgentToolExecutionResult;
@@ -82,7 +83,9 @@ public class PluginAsyncSubTools extends AbstractAgentAsyncSubTools {
* {@inheritDoc}
*/
@Override
protected AgentToolExecutionResult executeBusiness(Map<String, Object> arguments) {
protected AgentToolExecutionResult executeBusiness(
Map<String, Object> arguments,
AgentToolContext context) {
return pluginToolExecutor.execute(pluginItem, plugin, arguments);
}
}

View File

@@ -1,5 +1,6 @@
package tech.easyflow.agent.runtime.asynctool;
import com.easyagents.agent.runtime.tool.AgentToolContext;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import tech.easyflow.agent.enums.AgentToolType;
import tech.easyflow.agent.runtime.tool.AgentToolExecutionResult;
@@ -77,7 +78,9 @@ public class WorkflowAsyncSubTools extends AbstractAgentAsyncSubTools {
* {@inheritDoc}
*/
@Override
protected AgentToolExecutionResult executeBusiness(Map<String, Object> arguments) {
return workflowToolExecutor.execute(workflow, arguments);
protected AgentToolExecutionResult executeBusiness(
Map<String, Object> arguments,
AgentToolContext context) {
return workflowToolExecutor.execute(workflow, arguments, context);
}
}

View File

@@ -46,6 +46,15 @@ public interface AgentRunOutput {
return true;
}
/**
* 判断浏览器 SSE 连接断开时是否应取消底层 Agent 运行。
*
* @return 连接断开需要取消运行时为 true
*/
default boolean cancelRunOnDisconnect() {
return true;
}
/**
* 发送协议终态并关闭连接。
*

View File

@@ -8,6 +8,7 @@ import com.easyagents.agui.AguiRuntimeEventProjector;
import io.agentscope.core.agui.event.AguiEvent;
import io.agentscope.core.agui.model.AguiMessage;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tech.easyflow.agent.runtime.agui.ResumableAguiSseEmitter;
import tech.easyflow.agent.runtime.hitl.ToolApprovalInputProjection;
import tech.easyflow.core.chat.protocol.ChatDomain;
import tech.easyflow.core.chat.protocol.ChatType;
@@ -17,6 +18,7 @@ import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
@@ -35,6 +37,7 @@ public final class AguiAgentRunOutput implements AgentRunOutput {
private final String clientUserMessageId;
private final String clientUserMessageContent;
private final ChatSseEmitter delegate;
private final boolean cancelRunOnDisconnect;
private final AguiRuntimeEventProjector projector;
private final AguiProtocolEventEncoder encoder = new AguiProtocolEventEncoder();
@@ -56,7 +59,7 @@ public final class AguiAgentRunOutput implements AgentRunOutput {
* @param clientUserMessageId 本轮客户端用户消息 ID
*/
public AguiAgentRunOutput(String threadId, String runId, String clientUserMessageId) {
this(threadId, runId, clientUserMessageId, null, new ChatSseEmitter());
this(threadId, runId, clientUserMessageId, null, new ChatSseEmitter(), true);
}
/**
@@ -72,7 +75,7 @@ public final class AguiAgentRunOutput implements AgentRunOutput {
String runId,
String clientUserMessageId,
String clientUserMessageContent) {
this(threadId, runId, clientUserMessageId, clientUserMessageContent, new ChatSseEmitter());
this(threadId, runId, clientUserMessageId, clientUserMessageContent, new ChatSseEmitter(), true);
}
/**
@@ -88,7 +91,7 @@ public final class AguiAgentRunOutput implements AgentRunOutput {
String runId,
String clientUserMessageId,
ChatSseEmitter delegate) {
this(threadId, runId, clientUserMessageId, null, delegate);
this(threadId, runId, clientUserMessageId, null, delegate, true);
}
/**
@@ -106,11 +109,32 @@ public final class AguiAgentRunOutput implements AgentRunOutput {
String clientUserMessageId,
String clientUserMessageContent,
ChatSseEmitter delegate) {
this(threadId, runId, clientUserMessageId, clientUserMessageContent, delegate, true);
}
/**
* 使用指定连接生命周期策略创建 AG-UI 输出。
*
* @param threadId 客户端 thread ID
* @param runId 客户端 run ID
* @param clientUserMessageId 本轮客户端用户消息 ID
* @param clientUserMessageContent 本轮客户端用户消息正文
* @param delegate SSE 发射器
* @param cancelRunOnDisconnect 浏览器连接断开时是否取消运行
*/
public AguiAgentRunOutput(
String threadId,
String runId,
String clientUserMessageId,
String clientUserMessageContent,
ChatSseEmitter delegate,
boolean cancelRunOnDisconnect) {
this.threadId = requireText(threadId, "threadId");
this.runId = requireText(runId, "runId");
this.clientUserMessageId = clientUserMessageId;
this.clientUserMessageContent = clientUserMessageContent;
this.delegate = java.util.Objects.requireNonNull(delegate, "delegate cannot be null");
this.cancelRunOnDisconnect = cancelRunOnDisconnect;
this.projector = new AguiRuntimeEventProjector(threadId, runId);
}
@@ -119,6 +143,15 @@ public final class AguiAgentRunOutput implements AgentRunOutput {
return delegate.getEmitter();
}
/**
* 获取公开 AG-UI 运行 ID。
*
* @return 运行 ID
*/
public String runId() {
return runId;
}
@Override
public synchronized boolean emitRuntimeEvent(AgentRuntimeEvent event) {
if (event == null || event.getEventType() == null || delegate.isClosed()) {
@@ -249,6 +282,11 @@ public final class AguiAgentRunOutput implements AgentRunOutput {
return pendingCompletedEvent != null;
}
@Override
public boolean cancelRunOnDisconnect() {
return cancelRunOnDisconnect;
}
@Override
public synchronized boolean finish(String finalText) {
if (delegate.isClosed()) {
@@ -277,6 +315,9 @@ public final class AguiAgentRunOutput implements AgentRunOutput {
}
}
delegate.complete();
if (delegate instanceof ResumableAguiSseEmitter resumableEmitter) {
return resumableEmitter.isJournalCompletionSuccessful();
}
return true;
}
@@ -523,7 +564,9 @@ public final class AguiAgentRunOutput implements AgentRunOutput {
}
private static boolean isHiddenToolName(String toolName) {
return "retrieve_knowledge".equalsIgnoreCase(toolName)
String normalizedName = toolName == null ? "" : toolName.trim().toLowerCase(Locale.ROOT);
return "retrieve_knowledge".equals(normalizedName)
|| normalizedName.startsWith("retrieve_knowledge_")
|| "context_reload".equalsIgnoreCase(toolName)
|| "__fragment__".equalsIgnoreCase(toolName);
}

View File

@@ -161,7 +161,7 @@ public class AgentToolRuntimeCompiler {
Tool tool = workflowToolExecutor.buildTool(workflow);
AgentToolSpec spec = toToolSpec(tool, binding);
AgentToolInvoker invoker = (arguments, context) -> invokeSafely(spec.getName(), binding, context,
() -> workflowToolExecutor.execute(workflow, arguments).getResult());
() -> workflowToolExecutor.execute(workflow, arguments, context).getResult());
return new CompiledSyncTool(spec, invoker);
}
if (type == AgentToolType.PLUGIN) {

View File

@@ -2,13 +2,19 @@ package tech.easyflow.agent.runtime.tool;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import com.easyagents.core.model.chat.tool.Tool;
import com.easyagents.agent.runtime.AgentRuntimeContext;
import com.easyagents.agent.runtime.tool.AgentToolContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import tech.easyflow.ai.easyagents.tool.WorkflowTool;
import tech.easyflow.ai.easyagentsflow.repository.FrozenWorkflowDefinitionRegistry;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.entity.LoginAccount;
import java.math.BigInteger;
import java.util.LinkedHashMap;
import java.util.Map;
/**
@@ -59,11 +65,63 @@ public class WorkflowToolExecutor {
* @return 执行结果
*/
public AgentToolExecutionResult execute(Workflow workflow, Map<String, Object> arguments) {
return execute(workflow, arguments, null);
}
/**
* 使用 Agent 调用身份执行 Workflow 工具。
*
* @param workflow 工作流
* @param arguments 执行参数
* @param context Agent 工具上下文
* @return 执行结果
*/
public AgentToolExecutionResult execute(Workflow workflow,
Map<String, Object> arguments,
AgentToolContext context) {
Map<String, Object> variables = arguments == null
? new LinkedHashMap<>()
: new LinkedHashMap<>(arguments);
variables.remove(Constants.LOGIN_USER_KEY);
LoginAccount account = toLoginAccount(context);
if (account != null) {
variables.put(Constants.LOGIN_USER_KEY, account);
}
Object result = chainExecutor.executeWithoutSuspension(
definitionId(workflow), arguments == null ? Map.of() : arguments);
definitionId(workflow), variables);
return new AgentToolExecutionResult(result, resolveBusinessExecutionId(result));
}
private LoginAccount toLoginAccount(AgentToolContext context) {
AgentRuntimeContext runtimeContext = context == null
? null
: context.getRuntimeContext();
BigInteger userId = positiveId(runtimeContext == null
? null
: runtimeContext.getUserId());
BigInteger tenantId = positiveId(runtimeContext == null
? null
: runtimeContext.getTenantId());
if (userId == null || tenantId == null) {
return null;
}
LoginAccount account = new LoginAccount();
account.setId(userId);
account.setTenantId(tenantId);
account.setLoginName(runtimeContext.getUserName());
account.setNickname(runtimeContext.getUserName());
return account;
}
private BigInteger positiveId(String value) {
try {
BigInteger id = new BigInteger(value);
return id.signum() > 0 ? id : null;
} catch (RuntimeException exception) {
return null;
}
}
private String definitionId(Workflow workflow) {
if (frozenDefinitionRegistry != null && workflow != null
&& workflow.getContent() != null && !workflow.getContent().isBlank()) {

View File

@@ -51,6 +51,9 @@ public class AgentSkillReferenceProvider implements SkillReferenceProvider {
ids.add(agent.getId());
}
}
if (ids.isEmpty()) {
return List.of();
}
List<String> result = new ArrayList<>();
for (Agent agent : agentService.listByIds(ids)) {
result.add("智能体“" + (agent.getName() == null ? "未命名智能体" : agent.getName()) + "");

View File

@@ -164,6 +164,31 @@ public class AgentRuntimeCommandConsumerTest {
Assert.assertEquals("cmd-cancel", resultRegistry.lastSuccessCommandId);
}
/**
* 验证跨节点精确取消命令保留请求、用户和原因。
*
* @throws Exception 消息序列化异常
*/
@Test
public void consumerShouldHandleCancelRunCommand() throws Exception {
AgentRuntimeProperties properties = new AgentRuntimeProperties();
properties.setInstanceId("node-a");
RecordingAgentRunService service = new RecordingAgentRunService();
RecordingCommandResultRegistry resultRegistry = new RecordingCommandResultRegistry();
AgentRuntimeCommandConsumer consumer = new AgentRuntimeCommandConsumer(
new ObjectMapper(), properties, new MQProperties(), service, resultRegistry);
AgentRuntimeCommandMessage command = command("cmd-cancel-run", "node-a");
command.setAction(AgentRuntimeCommandAction.CANCEL_RUN);
command.setReason("用户停止");
consumer.handle(List.of(message(command)));
Assert.assertEquals("request-cmd-cancel-run", service.lastCancelledRequestId);
Assert.assertEquals("1", service.lastCancelledUserId);
Assert.assertEquals("用户停止", service.lastReason);
Assert.assertEquals("cmd-cancel-run", resultRegistry.lastSuccessCommandId);
}
private AgentRuntimeCommandMessage command(String commandId, String targetNodeId) {
AgentRuntimeCommandMessage command = new AgentRuntimeCommandMessage();
command.setCommandId(commandId);
@@ -191,6 +216,8 @@ public class AgentRuntimeCommandConsumerTest {
private String lastRequestId;
private String lastReason;
private String lastCancelledAgentId;
private String lastCancelledRequestId;
private String lastCancelledUserId;
private String lastApprovalId;
@Override
@@ -221,6 +248,13 @@ public class AgentRuntimeCommandConsumerTest {
public void cancelAgentLocal(String agentId) {
lastCancelledAgentId = agentId;
}
@Override
public void cancelRunLocal(String requestId, String userId, String reason) {
lastCancelledRequestId = requestId;
lastCancelledUserId = userId;
lastReason = reason;
}
}
private static class RecordingCommandResultRegistry extends AgentRuntimeCommandResultRegistry {

View File

@@ -8,6 +8,7 @@ import org.junit.Test;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentToolBinding;
import tech.easyflow.agent.enums.AgentToolType;
import tech.easyflow.agent.runtime.skill.AgentSkillRuntimeCompiler;
import tech.easyflow.agent.runtime.tool.AgentToolRuntimeCompiler;
import tech.easyflow.ai.entity.Mcp;
import tech.easyflow.ai.entity.Model;
@@ -44,6 +45,8 @@ public class AgentDefinitionCompilerMcpTest {
setField(toolCompiler, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper());
setField(toolCompiler, "mcpService", mcpService(mcp));
setField(compiler, "agentToolRuntimeCompiler", toolCompiler);
setField(compiler, "agentSkillRuntimeCompiler", new AgentSkillRuntimeCompiler(
null, toolCompiler, new com.fasterxml.jackson.databind.ObjectMapper()));
Agent agent = agent(modelId, mcpId);

View File

@@ -4,9 +4,13 @@ import com.easyagents.agent.runtime.AgentInitRequest;
import com.easyagents.agent.runtime.AgentResumeRequest;
import com.easyagents.agent.runtime.AgentRuntime;
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
import org.junit.Assert;
import org.junit.Test;
import reactor.core.Disposable;
import reactor.core.publisher.Flux;
import tech.easyflow.agent.distributed.AgentRuntimeRouteRegistry;
import tech.easyflow.agent.runtime.lock.AgentRunLock;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.core.runtime.ChatAssistantAccumulator;
import tech.easyflow.core.runtime.ChatRuntimeContext;
@@ -14,6 +18,10 @@ import tech.easyflow.core.runtime.ChatRuntimeContext;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
/**
* Agent 运行态注册表测试。
*/
@@ -69,6 +77,39 @@ public class AgentRunRegistryTest {
Assert.assertThrows(BusinessException.class, () -> registry.approve(null, "token-3", "user-1"));
}
/**
* 验证分布式锁释放失败时仍会关闭 Runtime 并清理全部本地、远程索引。
*
* @throws Exception Runtime close 方法声明的受检异常
*/
@Test
public void removeShouldFinishCleanupWhenLockReleaseFails() throws Exception {
AgentRunRegistry registry = new AgentRunRegistry();
AgentRuntimeRouteRegistry routeRegistry = mock(AgentRuntimeRouteRegistry.class);
AgentRuntime runtime = mock(AgentRuntime.class);
AgentRunLock.Handle lockHandle = mock(AgentRunLock.Handle.class);
doThrow(new IllegalStateException("redis unavailable")).when(lockHandle).release();
registry.setRouteRegistry(routeRegistry);
AgentRunRegistry.AgentRunContext context = new AgentRunRegistry.AgentRunContext(
"request-cleanup", "session-cleanup", runtime, null,
new ChatRuntimeContext(), new StringBuilder(), new ChatAssistantAccumulator(),
new AtomicBoolean(false), false,
new AgentRunRegistry.RunOwner("agent-1", "session-cleanup", "user-1"),
lockHandle, event -> { }, error -> { }, () -> { });
registry.register(context);
registry.registerResumeToken("request-cleanup", "token-cleanup");
String approvalId = registry.registerApproval("request-cleanup", "token-cleanup");
Assert.assertThrows(IllegalStateException.class, () -> registry.remove("request-cleanup"));
verify(runtime).close();
verify(routeRegistry).removeResumeToken("token-cleanup");
verify(routeRegistry).removeApproval(approvalId);
verify(routeRegistry).removeRun("request-cleanup");
Assert.assertNull(registry.get("request-cleanup"));
Assert.assertThrows(BusinessException.class, () -> registry.resolveApproval(approvalId));
}
/**
* 验证运行审批只能由运行发起人处理。
*/
@@ -144,6 +185,85 @@ public class AgentRunRegistryTest {
Assert.assertNotNull(registry.get("request-10"));
}
/**
* 验证显式单次取消会校验归属并进入标准取消事件链。
*/
@Test
public void cancelRunShouldEmitNormalizedCancellationForOwner() {
AgentRunRegistry registry = new AgentRunRegistry();
AtomicReference<AgentRuntimeEvent> cancellation = new AtomicReference<>();
AgentRunRegistry.AgentRunContext context = new AgentRunRegistry.AgentRunContext(
"request-11",
"session-11",
new CapturingRuntime(),
null,
new ChatRuntimeContext(),
new StringBuilder(),
new ChatAssistantAccumulator(),
new AtomicBoolean(false),
false,
new AgentRunRegistry.RunOwner("agent-1", "session-11", "user-1"),
null,
cancellation::set,
error -> {
},
() -> {
});
registry.register(context);
Assert.assertThrows(BusinessException.class,
() -> registry.cancelRun("request-11", "user-2", "用户停止"));
registry.cancelRun("request-11", "user-1", "用户停止");
Assert.assertNotNull(cancellation.get());
Assert.assertEquals(AgentRuntimeEventType.CANCELLED, cancellation.get().getEventType());
Assert.assertEquals("用户停止", cancellation.get().getPayload().get("reason"));
}
/**
* 验证自然终态或重复远程命令到达后,显式取消保持幂等。
*/
@Test
public void cancelRunShouldBeIdempotentAfterContextRemoved() {
AgentRunRegistry registry = new AgentRunRegistry();
registry.cancelRun("request-finished", "user-1", "用户停止");
}
/**
* 验证订阅启动后上下文已经移除时,迟到的 Disposable 会立即释放。
*/
@Test
public void bindSubscriptionShouldDisposeWhenContextAlreadyRemoved() {
AgentRunRegistry registry = new AgentRunRegistry();
Disposable subscription = mock(Disposable.class);
registry.bindSubscription("request-removed", subscription);
verify(subscription).dispose();
}
/**
* 验证运行已收口时绑定订阅不会遗留后台模型流。
*/
@Test
public void bindSubscriptionShouldDisposeWhenContextAlreadyFinished() {
AgentRunRegistry registry = new AgentRunRegistry();
AtomicBoolean finished = new AtomicBoolean(true);
AgentRunRegistry.AgentRunContext context = new AgentRunRegistry.AgentRunContext(
"request-finished", "session-finished", new CapturingRuntime(), null,
new ChatRuntimeContext(), new StringBuilder(), new ChatAssistantAccumulator(),
finished, false,
new AgentRunRegistry.RunOwner("agent-1", "session-finished", "user-1"),
null, event -> { }, error -> { }, () -> { });
Disposable subscription = mock(Disposable.class);
registry.register(context);
registry.bindSubscription("request-finished", subscription);
verify(subscription).dispose();
}
private AgentRunRegistry.AgentRunContext context(String requestId,
String sessionId,
String userId,

View File

@@ -10,12 +10,14 @@ import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
import com.easyagents.agent.runtime.message.AgentKnowledgeReference;
import com.easyagents.agent.runtime.message.AgentMessage;
import com.easyagents.agent.runtime.message.AgentMessageRole;
import com.easyagents.agent.runtime.message.AgentTextBlock;
import com.easyagents.agent.runtime.persistence.session.AgentSessionStore;
import com.easyagents.agent.runtime.persistence.session.memory.InMemoryAgentSessionStore;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.Mockito;
import org.slf4j.LoggerFactory;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tech.easyflow.agent.entity.AgentHitlPending;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
@@ -24,6 +26,12 @@ import tech.easyflow.agent.distributed.AgentRuntimeCommandProducer;
import tech.easyflow.agent.distributed.AgentRuntimeRoute;
import tech.easyflow.agent.distributed.AgentRuntimeRouteRegistry;
import tech.easyflow.agent.runtime.event.AgentRunEventRecorder;
import tech.easyflow.agent.runtime.agui.AgentAguiRunDescriptor;
import tech.easyflow.agent.runtime.agui.AgentAguiRunJournal;
import tech.easyflow.agent.runtime.agui.AgentAguiRunStatus;
import tech.easyflow.agent.runtime.agui.AgentAguiRunStore;
import tech.easyflow.agent.runtime.agui.AgentAguiRunSubscriptionService;
import tech.easyflow.agent.runtime.agui.AgentAguiWireContext;
import tech.easyflow.agent.runtime.hitl.AgentHitlPendingService;
import tech.easyflow.agent.runtime.document.AgentDocumentContext;
import tech.easyflow.agent.runtime.lock.AgentRunLock;
@@ -50,6 +58,11 @@ import java.lang.reflect.Method;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
/**
@@ -57,6 +70,176 @@ import java.util.concurrent.atomic.AtomicBoolean;
*/
public class AgentRunServiceDraftAndHitlTest {
/**
* 验证文档上下文随用户消息进入可持久化 memory同时保持独立内容块边界。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void buildAgentMessageShouldIncludeDocumentContext() throws Exception {
AgentRunService service = new AgentRunService();
AgentDocumentContext documentContext = new AgentDocumentContext(
"\n<<<DOCUMENT name=\"demo.docx\">>>\n正文\n<<<END_DOCUMENT>>>", 8, List.of());
AgentMessage message = invoke(service, "buildAgentMessage",
new Class<?>[]{String.class, List.class, AgentDocumentContext.class},
"请介绍文档", List.of(), documentContext);
Assert.assertEquals(2, message.getContentBlocks().size());
Assert.assertEquals("请介绍文档",
((AgentTextBlock) message.getContentBlocks().get(0)).getText());
Assert.assertEquals(documentContext.text(),
((AgentTextBlock) message.getContentBlocks().get(1)).getText());
}
/**
* 创建用于 owner 恢复测试的运行描述。
*
* @param runId 运行 ID
* @param requestId 请求 ID
* @param updatedAt 最近更新时间
* @return 运行描述
*/
private static AgentAguiRunDescriptor descriptor(String runId, String requestId, long updatedAt) {
return new AgentAguiRunDescriptor(
runId, requestId, "thread-" + runId, "7", "session-" + runId,
"1", "2", false, AgentAguiRunStatus.RUNNING, 0L, updatedAt, updatedAt);
}
/**
* 验证可重连运行会在 Redis store 创建前注册临时 owner 路由。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void createRunOutputShouldRegisterProvisionalOwnerBeforeStore() throws Exception {
AgentRunService service = new AgentRunService();
AgentRuntimeRouteRegistry routeRegistry = Mockito.mock(AgentRuntimeRouteRegistry.class);
AgentAguiRunStore store = Mockito.mock(AgentAguiRunStore.class);
AgentAguiRunJournal journal = Mockito.mock(AgentAguiRunJournal.class);
AgentAguiRunSubscriptionService subscriptions = Mockito.mock(AgentAguiRunSubscriptionService.class);
Mockito.when(subscriptions.subscribe(Mockito.any(AgentAguiRunDescriptor.class), Mockito.eq(0L)))
.thenReturn(new SseEmitter());
setField(service, "agentRuntimeRouteRegistry", routeRegistry);
setField(service, "agentAguiRunStore", store);
setField(service, "agentAguiRunJournal", journal);
setField(service, "agentAguiRunSubscriptionService", subscriptions);
Agent agent = new Agent();
agent.setId(BigInteger.valueOf(7L));
LoginAccount account = new LoginAccount();
account.setId(BigInteger.ONE);
account.setTenantId(BigInteger.TWO);
AgentRunOutput output = invoke(service, "createRunOutput",
new Class<?>[]{AgentAguiWireContext.class, String.class, LoginAccount.class,
Agent.class, String.class, boolean.class},
new AgentAguiWireContext("thread-start", "run-start", "user-start", "启动"),
"request-start", account, agent, "session-start", false);
Assert.assertNotNull(output);
org.mockito.InOrder order = Mockito.inOrder(routeRegistry, store, subscriptions);
order.verify(routeRegistry).registerRun("request-start", "7");
order.verify(store).create(Mockito.any(AgentAguiRunDescriptor.class));
order.verify(subscriptions).subscribe(Mockito.any(AgentAguiRunDescriptor.class), Mockito.eq(0L));
}
/**
* 验证会话锁获取失败时会写入失败终态并移除临时 owner 路由。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void runAguiAwareShouldCloseProvisionalRunWhenLockAcquireFails() throws Exception {
AgentRunService service = new AgentRunService();
AgentRunLock runLock = Mockito.mock(AgentRunLock.class);
AgentRuntimeRouteRegistry routeRegistry = Mockito.mock(AgentRuntimeRouteRegistry.class);
AgentRunOutput output = Mockito.mock(AgentRunOutput.class);
Mockito.when(runLock.acquire(Mockito.any(), Mockito.eq("session-lock-failed")))
.thenThrow(new IllegalStateException("lock unavailable"));
Mockito.when(output.cancelRunOnDisconnect()).thenReturn(false);
setField(service, "agentRunLock", runLock);
setField(service, "agentRuntimeRouteRegistry", routeRegistry);
Agent agent = new Agent();
agent.setId(BigInteger.valueOf(8L));
try {
invoke(service, "runAguiAware",
new Class<?>[]{Agent.class, String.class, List.class, List.class,
LoginAccount.class, String.class, String.class, String.class,
String.class, ChatRuntimeContext.class, boolean.class,
AgentSessionStore.class, AgentRunOutput.class},
agent, "问题", List.of(), List.of(), new LoginAccount(),
"request-lock-failed", "trace-lock-failed", "session-lock-failed",
"AGENT", new ChatRuntimeContext(), true,
Mockito.mock(AgentSessionStore.class), output);
Assert.fail("expected lock failure");
} catch (Exception exception) {
Assert.assertTrue(rootCause(exception) instanceof IllegalStateException);
}
Mockito.verify(output).completeWithError(Mockito.any(IllegalStateException.class));
Mockito.verify(routeRegistry).removeRun("request-lock-failed");
}
/**
* 验证超过启动宽限期但 owner 节点仍存活时不会误判运行丢失。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void reconcileShouldKeepSlowStartingRunWhenOwnerBootIsAlive() throws Exception {
AgentRunService service = new AgentRunService();
AgentRuntimeRouteRegistry routeRegistry = Mockito.mock(AgentRuntimeRouteRegistry.class);
AgentAguiRunStore store = Mockito.mock(AgentAguiRunStore.class);
AgentRuntimeRoute route = new AgentRuntimeRoute();
route.setNodeId("node-alive");
route.setBootId("boot-alive");
Mockito.when(routeRegistry.findOwnerRoute("request-slow")).thenReturn(route);
Mockito.when(routeRegistry.currentNodeBootId("node-alive")).thenReturn("boot-alive");
setField(service, "agentRuntimeRouteRegistry", routeRegistry);
setField(service, "agentAguiRunStore", store);
long old = System.currentTimeMillis() - 120_000L;
AgentAguiRunDescriptor descriptor = descriptor("run-slow", "request-slow", old);
AgentAguiRunDescriptor result = invoke(service, "reconcileInterruptedRun",
new Class<?>[]{AgentAguiRunDescriptor.class}, descriptor);
Assert.assertSame(descriptor, result);
Mockito.verify(store, Mockito.never()).failOwnerLost(Mockito.any());
}
/**
* 验证 owner 启动代心跳消失且超过宽限期后写入明确失败终态。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void reconcileShouldFailRunWhenOwnerBootDisappears() throws Exception {
AgentRunService service = new AgentRunService();
AgentRuntimeRouteRegistry routeRegistry = Mockito.mock(AgentRuntimeRouteRegistry.class);
AgentAguiRunStore store = Mockito.mock(AgentAguiRunStore.class);
AgentRuntimeRoute route = new AgentRuntimeRoute();
route.setNodeId("node-lost");
route.setBootId("boot-lost");
Mockito.when(routeRegistry.findOwnerRoute("request-lost")).thenReturn(route);
Mockito.when(routeRegistry.currentNodeBootId("node-lost")).thenReturn(null);
long old = System.currentTimeMillis() - 120_000L;
AgentAguiRunDescriptor descriptor = descriptor("run-lost", "request-lost", old);
AgentAguiRunDescriptor failed = new AgentAguiRunDescriptor(
descriptor.runId(), descriptor.requestId(), descriptor.threadId(), descriptor.agentId(),
descriptor.sessionId(), descriptor.userId(), descriptor.tenantId(), descriptor.draft(),
AgentAguiRunStatus.FAILED, 1L, descriptor.createdAt(), System.currentTimeMillis());
Mockito.when(store.find("run-lost")).thenReturn(failed);
setField(service, "agentRuntimeRouteRegistry", routeRegistry);
setField(service, "agentAguiRunStore", store);
AgentAguiRunDescriptor result = invoke(service, "reconcileInterruptedRun",
new Class<?>[]{AgentAguiRunDescriptor.class}, descriptor);
Assert.assertSame(failed, result);
Mockito.verify(store).failOwnerLost(descriptor);
}
/**
* 验证工具 HITL 事件会映射为显式前端载荷。
*
@@ -293,18 +476,48 @@ public class AgentRunServiceDraftAndHitlTest {
}
/**
* 验证知识检索状态不会携带命中文档和内部 metadata
* 验证知识库工具开始事件会投影为脱敏的检索状态。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void handleRuntimeEventShouldWhitelistKnowledgeStatusPayload() throws Exception {
public void handleRuntimeEventShouldProjectKnowledgeToolCallAsRunningStatus() throws Exception {
AgentRunService service = new AgentRunService();
RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL);
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_CALL);
event.setToolCallId("knowledge-call-1");
event.getPayload().put("toolCallId", "knowledge-call-1");
event.getPayload().put("toolName", "retrieve_knowledge_homeinn_faq");
event.getPayload().put("toolCategory", "KNOWLEDGE");
event.getPayload().put("documents", List.of(Map.of("chunkContent", "private chunk")));
event.getPayload().put("metadata", Map.of("sourceUri", "private://document"));
invoke(service, "handleRuntimeEvent",
runtimeEventParameterTypes(),
event, "request-knowledge", legacyOutput(emitter), new StringBuilder(),
new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), false);
Assert.assertEquals(1, emitter.envelopes.size());
@SuppressWarnings("unchecked")
Map<String, Object> payload = (Map<String, Object>) emitter.envelopes.get(0).getPayload();
Assert.assertEquals(Map.of(
"label", "正在检索知识库",
"status", "running",
"statusKey", "knowledge-retrieval"), payload);
}
/**
* 验证知识库工具结果事件会投影为完成状态。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void handleRuntimeEventShouldProjectKnowledgeToolResultAsDoneStatus() throws Exception {
AgentRunService service = new AgentRunService();
RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
AgentRuntimeEvent event = knowledgeToolEvent(
AgentRuntimeEventType.TOOL_RESULT, "knowledge-call-1", true);
invoke(service, "handleRuntimeEvent",
runtimeEventParameterTypes(),
event, "request-knowledge", legacyOutput(emitter), new StringBuilder(),
@@ -319,6 +532,48 @@ public class AgentRunServiceDraftAndHitlTest {
"statusKey", "knowledge-retrieval"), payload);
}
/**
* 验证文档摘要事件不会抢先把知识库工具状态标记为完成。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void handleRuntimeEventShouldNotCompleteKnowledgeStatusFromDocumentEvent() throws Exception {
AgentRunService service = new AgentRunService();
RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL);
event.getPayload().put("documents", List.of(Map.of("chunkContent", "private chunk")));
invoke(service, "handleRuntimeEvent",
runtimeEventParameterTypes(),
event, "request-knowledge", legacyOutput(emitter), new StringBuilder(),
new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), false);
Assert.assertTrue(emitter.envelopes.isEmpty());
}
/**
* 验证并行知识库调用全部结束后才进入终态,并保留任一调用失败结果。
*/
@Test
public void knowledgeStatusTrackerShouldAggregateParallelToolCalls() {
AgentRunService.KnowledgeRetrievalStatusTracker tracker =
new AgentRunService.KnowledgeRetrievalStatusTracker();
AgentRuntimeEvent firstCall = knowledgeToolEvent(
AgentRuntimeEventType.TOOL_CALL, "knowledge-call-1", true);
AgentRuntimeEvent secondCall = knowledgeToolEvent(
AgentRuntimeEventType.TOOL_CALL, "knowledge-call-2", true);
AgentRuntimeEvent firstResult = knowledgeToolEvent(
AgentRuntimeEventType.TOOL_RESULT, "knowledge-call-1", false);
AgentRuntimeEvent secondResult = knowledgeToolEvent(
AgentRuntimeEventType.TOOL_RESULT, "knowledge-call-2", true);
Assert.assertEquals("running", tracker.update(firstCall));
Assert.assertEquals("running", tracker.update(secondCall));
Assert.assertEquals("running", tracker.update(firstResult));
Assert.assertEquals("error", tracker.update(secondResult));
}
/**
* 验证完成事件不会再次发送正文消息,只用于最终收口。
*
@@ -418,6 +673,78 @@ public class AgentRunServiceDraftAndHitlTest {
Mockito.verify(output).complete();
}
/**
* 验证可恢复终态写入失败时不会把聊天会话记录为完成。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void finishClaimedRunShouldRecordFailureWhenOutputCompletionFails() throws Exception {
AgentRunService service = new AgentRunService();
RecordingChatRuntimeManager chatRuntimeManager = new RecordingChatRuntimeManager();
setField(service, "agentRunRegistry", new AgentRunRegistry());
setField(service, "chatRuntimeManager", chatRuntimeManager);
AgentRunOutput output = Mockito.mock(AgentRunOutput.class);
Mockito.when(output.finish("最终正文")).thenReturn(false);
invoke(service, "finishClaimedRun",
new Class<?>[]{String.class, AgentRunOutput.class, ChatRuntimeContext.class,
StringBuilder.class, ChatAssistantAccumulator.class, boolean.class, List.class},
"request-output-failed", output, chatContext(), new StringBuilder("最终正文"),
new ChatAssistantAccumulator(), true, List.of());
Assert.assertEquals(1, chatRuntimeManager.recordAssistantCompletedCount);
Assert.assertEquals(1, chatRuntimeManager.recordFailureCount);
Assert.assertEquals(0, chatRuntimeManager.recordCompletedCount);
}
/**
* 验证终态回调内部抛错后强制释放 Registry 并写入失败状态。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void runtimeCallbackFailureShouldForceCleanupAfterTerminalClaim() throws Exception {
AgentRunService service = new AgentRunService();
AgentRunRegistry registry = new AgentRunRegistry();
RecordingChatRuntimeManager chatRuntimeManager = new RecordingChatRuntimeManager();
AgentRunOutput output = Mockito.mock(AgentRunOutput.class);
AtomicBoolean finished = new AtomicBoolean(true);
AgentRunRegistry.AgentRunContext context = new AgentRunRegistry.AgentRunContext(
"request-callback-failed",
"session-callback-failed",
new NoopRuntime(),
output,
chatContext(),
new StringBuilder(),
new ChatAssistantAccumulator(),
finished,
true,
new AgentRunRegistry.RunOwner("agent-1", "session-callback-failed", "user-1"),
null,
event -> {
},
error -> {
},
() -> {
}
);
registry.register(context);
setField(service, "agentRunRegistry", registry);
setField(service, "chatRuntimeManager", chatRuntimeManager);
invoke(service, "runRuntimeCallbackSafely",
new Class<?>[]{Runnable.class, String.class, AgentRunOutput.class,
ChatRuntimeContext.class, AtomicBoolean.class, boolean.class},
(Runnable) () -> {
throw new IllegalStateException("审计写入失败");
}, "request-callback-failed", output, chatContext(), finished, true);
Assert.assertNull(registry.get("request-callback-failed"));
Assert.assertEquals(1, chatRuntimeManager.recordFailureCount);
Mockito.verify(output).completeWithError(Mockito.any(IllegalStateException.class));
}
/**
* 验证取消事件作为业务状态收口,不按系统错误发送。
*
@@ -456,6 +783,73 @@ public class AgentRunServiceDraftAndHitlTest {
Assert.assertEquals(1, chatRuntimeManager.recordFailureCount);
}
/**
* 验证用户取消与自然完成并发到达时只允许一个终态完成协议投影和聊天记录收口。
*
* @throws Exception 并发任务或反射调用失败时抛出
*/
@Test
public void concurrentCancelAndCompleteShouldShareOneTerminalArbitration() throws Exception {
AgentRunService service = new AgentRunService();
RecordingChatRuntimeManager chatRuntimeManager = new RecordingChatRuntimeManager();
setField(service, "agentRunRegistry", new AgentRunRegistry());
setField(service, "chatRuntimeManager", chatRuntimeManager);
AgentRunOutput output = Mockito.mock(AgentRunOutput.class);
Mockito.when(output.emitRuntimeEvent(Mockito.any())).thenReturn(true);
Mockito.when(output.emitViewEvent(Mockito.any(), Mockito.any(), Mockito.any())).thenReturn(true);
Mockito.when(output.finish(Mockito.anyString())).thenReturn(true);
AtomicBoolean finished = new AtomicBoolean(false);
CountDownLatch start = new CountDownLatch(1);
ExecutorService executor = Executors.newFixedThreadPool(2);
AgentRuntimeEvent completed = AgentRuntimeEvent.of(AgentRuntimeEventType.COMPLETED);
completed.getPayload().put("text", "自然完成正文");
AgentRuntimeEvent cancelled = AgentRuntimeEvent.of(AgentRuntimeEventType.CANCELLED);
cancelled.getPayload().put("reason", "用户停止");
try {
Future<?> completeFuture = executor.submit(() -> invokeTerminalEvent(
service, start, completed, output, finished));
Future<?> cancelFuture = executor.submit(() -> invokeTerminalEvent(
service, start, cancelled, output, finished));
start.countDown();
completeFuture.get(5, TimeUnit.SECONDS);
cancelFuture.get(5, TimeUnit.SECONDS);
} finally {
executor.shutdownNow();
}
Assert.assertTrue(finished.get());
Assert.assertEquals(1,
chatRuntimeManager.recordCompletedCount + chatRuntimeManager.recordFailureCount);
Mockito.verify(output, Mockito.times(1)).emitRuntimeEvent(Mockito.any());
Mockito.verify(output, Mockito.times(1)).finish(Mockito.nullable(String.class));
}
/**
* 等待统一起跑信号后投递指定终态事件。
*
* @param service 被测服务
* @param start 并发起跑信号
* @param event 终态事件
* @param output 运行输出
* @param finished 共享终态标记
*/
private void invokeTerminalEvent(AgentRunService service,
CountDownLatch start,
AgentRuntimeEvent event,
AgentRunOutput output,
AtomicBoolean finished) {
try {
start.await(5, TimeUnit.SECONDS);
invoke(service, "handleRuntimeEvent",
runtimeEventParameterTypes(),
event, "request-terminal-race", output, new StringBuilder("部分正文"),
new ChatAssistantAccumulator(), chatContext(), finished, true);
} catch (Exception e) {
throw new IllegalStateException("并发终态测试执行失败", e);
}
}
/**
* 验证最终知识库引用会保留命中分片原文。
*
@@ -1257,6 +1651,29 @@ public class AgentRunServiceDraftAndHitlTest {
}
}
/**
* 创建知识库工具生命周期测试事件。
*
* @param eventType 工具开始或结果事件类型
* @param toolCallId 工具调用 ID
* @param success 工具结果是否成功
* @return 知识库工具事件
*/
private AgentRuntimeEvent knowledgeToolEvent(AgentRuntimeEventType eventType,
String toolCallId,
boolean success) {
AgentRuntimeEvent event = AgentRuntimeEvent.of(eventType);
event.setToolCallId(toolCallId);
event.getPayload().put("toolCallId", toolCallId);
event.getPayload().put("toolName", "retrieve_knowledge_homeinn_faq");
event.getPayload().put("toolCategory", "KNOWLEDGE");
if (eventType == AgentRuntimeEventType.TOOL_RESULT) {
event.getPayload().put("success", success);
event.getPayload().put("status", success ? "SUCCESS" : "FAILED");
}
return event;
}
private Class<?>[] runtimeEventParameterTypes() {
return new Class<?>[]{AgentRuntimeEvent.class, String.class, AgentRunOutput.class, StringBuilder.class,
ChatAssistantAccumulator.class,

View File

@@ -0,0 +1,268 @@
package tech.easyflow.agent.runtime;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeDocument;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRegistration;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetrievalRequest;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetrievalResult;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeSpec;
import com.easyagents.core.document.Document;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
import tech.easyflow.agent.runtime.skill.AgentSkillRuntimeCompiler;
import tech.easyflow.agent.runtime.tool.AgentToolRuntimeCompiler;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.entity.ModelProvider;
import tech.easyflow.ai.rag.KnowledgeRetrievalRequest;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.lang.reflect.Field;
import java.lang.reflect.Proxy;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
/**
* Agent 知识库一库一工具运行时编译测试。
*/
public class AgentRuntimeCompilerKnowledgeTest {
/**
* 验证知识库英文名称、描述和检索配置会编译到中立声明及独立 Retriever。
*
* @throws Exception 反射注入依赖失败时抛出
*/
@Test
public void compileShouldBuildOneKnowledgeRegistrationWithEnglishRuntimeName() throws Exception {
AtomicReference<KnowledgeRetrievalRequest> capturedRequest = new AtomicReference<>();
Document document = new Document("如家酒店通常在入住日 14:00 后办理入住。");
document.setId("chunk-1");
document.setTitle("如家 FAQ");
document.setScore(0.92D);
document.addMetadata("documentId", "faq-document-1");
document.addMetadata("chunkId", "faq-chunk-1");
AgentRuntimeCompiler compiler = compiler(capturedRequest, List.of(document));
Agent agent = agent(knowledgeBinding("homeinn_faq", BigInteger.valueOf(20L)));
AgentRuntimeBundle bundle = compiler.compile(agent);
Assert.assertEquals(1, bundle.getDefinition().getKnowledgeSpecs().size());
AgentKnowledgeSpec spec = bundle.getDefinition().getKnowledgeSpecs().get(0);
Assert.assertEquals("homeinn_faq", spec.getRuntimeName());
Assert.assertEquals("如家 FAQ", spec.getName());
Assert.assertTrue(spec.getDescription().contains("入住"));
Assert.assertEquals(7, spec.getLimit());
Assert.assertEquals(0.55D, spec.getScoreThreshold(), 0.0001D);
Assert.assertEquals(1, bundle.getKnowledgeRegistrations().size());
AgentKnowledgeRegistration registration = bundle.getKnowledgeRegistrations().get(0);
AgentKnowledgeRetrievalRequest retrievalRequest = new AgentKnowledgeRetrievalRequest();
retrievalRequest.setQuery("如家几点入住");
retrievalRequest.setLimit(spec.getLimit());
retrievalRequest.setScoreThreshold(spec.getScoreThreshold());
AgentKnowledgeRetrievalResult result = registration.getRetriever().retrieve(retrievalRequest);
Assert.assertEquals("如家几点入住", capturedRequest.get().getQuery());
Assert.assertEquals(Integer.valueOf(7), capturedRequest.get().getLimit());
Assert.assertEquals(Double.valueOf(0.55D), capturedRequest.get().getMinSimilarity());
Assert.assertEquals("AGENT_KNOWLEDGE", capturedRequest.get().getCallerType());
Assert.assertEquals(1, result.getDocuments().size());
AgentKnowledgeDocument mapped = result.getDocuments().get(0);
Assert.assertEquals("faq-document-1", mapped.getDocumentId());
Assert.assertEquals("faq-chunk-1", mapped.getChunkId());
Assert.assertEquals(0.92D, mapped.getScore(), 0.0001D);
}
/**
* 验证缺失知识库英文名称时在发布编译阶段明确失败。
*
* @throws Exception 反射注入依赖失败时抛出
*/
@Test
public void compileShouldRejectMissingKnowledgeEnglishName() throws Exception {
AgentRuntimeCompiler compiler = compiler(new AtomicReference<>(), List.of());
Agent agent = agent(knowledgeBinding(null, BigInteger.valueOf(20L)));
try {
compiler.compile(agent);
Assert.fail("缺失英文名称时应拒绝编译");
} catch (BusinessException expected) {
Assert.assertTrue(expected.getMessage().contains("英文名称不能为空"));
}
}
/**
* 验证多个知识库生成相同工具名时在编译阶段拒绝发布。
*
* @throws Exception 反射注入依赖失败时抛出
*/
@Test
public void compileShouldRejectDuplicateKnowledgeToolNames() throws Exception {
AgentRuntimeCompiler compiler = compiler(new AtomicReference<>(), List.of());
AgentKnowledgeBinding first = knowledgeBinding("homeinn_faq", BigInteger.valueOf(20L));
AgentKnowledgeBinding second = knowledgeBinding("homeinn_faq", BigInteger.valueOf(21L));
Agent agent = agent(first, second);
try {
compiler.compile(agent);
Assert.fail("重复知识库工具名时应拒绝编译");
} catch (BusinessException expected) {
Assert.assertTrue(expected.getMessage().contains("retrieve_knowledge_homeinn_faq"));
}
}
/**
* 创建仅含测试模型与知识库服务的运行时编译器。
*
* @param capturedRequest 检索请求捕获器
* @param documents 检索服务返回文档
* @return 已注入依赖的编译器
* @throws Exception 反射注入失败时抛出
*/
private AgentRuntimeCompiler compiler(AtomicReference<KnowledgeRetrievalRequest> capturedRequest,
List<Document> documents) throws Exception {
ObjectMapper objectMapper = new ObjectMapper();
AgentToolRuntimeCompiler toolCompiler = new AgentToolRuntimeCompiler();
AgentRuntimeCompiler compiler = new AgentRuntimeCompiler();
setField(compiler, "objectMapper", objectMapper);
setField(compiler, "modelService", modelService(model()));
setField(compiler, "documentCollectionService", documentCollectionService(capturedRequest, documents));
setField(compiler, "agentToolRuntimeCompiler", toolCompiler);
setField(compiler, "agentSkillRuntimeCompiler",
new AgentSkillRuntimeCompiler(null, toolCompiler, objectMapper));
return compiler;
}
/**
* 创建带知识库绑定的 Agent。
*
* @param bindings 知识库绑定
* @return Agent 测试对象
*/
private Agent agent(AgentKnowledgeBinding... bindings) {
Agent agent = new Agent();
agent.setId(BigInteger.ONE);
agent.setName("如家助手");
agent.setModelId(BigInteger.TEN);
agent.setKnowledgeBindings(List.of(bindings));
return agent;
}
/**
* 创建冻结知识库绑定。
*
* @param englishName 知识库英文名称
* @param knowledgeId 知识库 ID
* @return 知识库绑定
*/
private AgentKnowledgeBinding knowledgeBinding(String englishName, BigInteger knowledgeId) {
AgentKnowledgeBinding binding = new AgentKnowledgeBinding();
binding.setAgentId(BigInteger.ONE);
binding.setKnowledgeId(knowledgeId);
binding.setRetrievalMode("HYBRID");
binding.setEnabled(true);
binding.setOptionsJson(Map.of("limit", 7, "scoreThreshold", 0.55D));
binding.setResourceSnapshot(Map.of(
"id", knowledgeId,
"title", "如家 FAQ",
"description", "如家酒店入住、退房和会员服务常见问题",
"collectionType", "FAQ",
"englishName", englishName == null ? "" : englishName));
return binding;
}
/**
* 创建模型服务代理。
*
* @param model 测试模型
* @return 模型服务代理
*/
private ModelService modelService(Model model) {
return (ModelService) Proxy.newProxyInstance(
ModelService.class.getClassLoader(),
new Class<?>[]{ModelService.class},
(proxy, method, args) -> "getModelInstance".equals(method.getName())
? model
: defaultValue(method.getReturnType()));
}
/**
* 创建知识库服务代理。
*
* @param capturedRequest 检索请求捕获器
* @param documents 返回文档
* @return 知识库服务代理
*/
private DocumentCollectionService documentCollectionService(
AtomicReference<KnowledgeRetrievalRequest> capturedRequest,
List<Document> documents) {
return (DocumentCollectionService) Proxy.newProxyInstance(
DocumentCollectionService.class.getClassLoader(),
new Class<?>[]{DocumentCollectionService.class},
(proxy, method, args) -> {
if ("search".equals(method.getName()) && args != null && args.length == 1
&& args[0] instanceof KnowledgeRetrievalRequest request) {
capturedRequest.set(request);
return documents;
}
return defaultValue(method.getReturnType());
});
}
/**
* 创建可映射为 AgentScope 模型配置的测试模型。
*
* @return 测试模型
*/
private Model model() {
ModelProvider provider = new ModelProvider();
provider.setProviderType("openai");
provider.setProviderName("OpenAI");
Model model = new Model();
model.setId(BigInteger.TEN);
model.setModelProvider(provider);
model.setModelName("gpt-test");
model.setEndpoint("https://example.com");
model.setRequestPath("/v1/chat/completions");
model.setApiKey("test-key");
return model;
}
/**
* 返回代理方法所需的默认值。
*
* @param type 返回类型
* @return 对应默认值
*/
private Object defaultValue(Class<?> type) {
if (type == boolean.class) {
return false;
}
if (type == int.class || type == long.class || type == short.class || type == byte.class) {
return 0;
}
if (type == double.class || type == float.class) {
return 0D;
}
return null;
}
/**
* 反射注入测试依赖。
*
* @param target 目标对象
* @param fieldName 字段名称
* @param value 字段值
* @throws Exception 字段不存在或不可写时抛出
*/
private void setField(Object target, String fieldName, Object value) throws Exception {
Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
}
}

View File

@@ -0,0 +1,194 @@
package tech.easyflow.agent.runtime.agui;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
/**
* AG-UI 批量事件日志测试。
*/
public class AgentAguiRunJournalTest {
/**
* 验证相邻同消息 delta 会在一次 Redis 批次中合并且顺序不变。
*/
@Test
public void shouldMergeAdjacentDeltasBeforeRedisFlush() {
AgentAguiRunStore store = mock(AgentAguiRunStore.class);
AgentAguiRunJournal journal = new AgentAguiRunJournal(store, new ObjectMapper());
journal.append("run-1", "{\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"m1\",\"delta\":\"\"}");
journal.append("run-1", "{\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"m1\",\"delta\":\"\"}");
journal.append("run-1", "{\"type\":\"TEXT_MESSAGE_END\",\"messageId\":\"m1\"}");
journal.flush("run-1");
@SuppressWarnings("unchecked")
ArgumentCaptor<List<String>> events = ArgumentCaptor.forClass(List.class);
verify(store).append(eq("run-1"), anyString(), events.capture());
Assert.assertEquals(2, events.getValue().size());
Assert.assertTrue(events.getValue().get(0).contains("\"delta\":\"你好\""));
Assert.assertTrue(events.getValue().get(1).contains("TEXT_MESSAGE_END"));
}
/**
* 验证同步终态写入失败后,后台刷新仍会保留并重试原批次。
*/
@Test
public void shouldRetryTerminalBatchWithoutDroppingEvents() {
AgentAguiRunStore store = mock(AgentAguiRunStore.class);
AgentAguiRunJournal journal = new AgentAguiRunJournal(store, new ObjectMapper());
String terminal = "{\"type\":\"RUN_FINISHED\",\"runId\":\"run-1\",\"threadId\":\"1\"}";
journal.append("run-1", terminal);
doThrow(new IllegalStateException("redis down"))
.doReturn(1L)
.when(store).append(eq("run-1"), anyString(), org.mockito.ArgumentMatchers.anyList());
try {
journal.complete("run-1");
Assert.fail("首次同步写入应抛出异常");
} catch (IllegalStateException expected) {
Assert.assertEquals("redis down", expected.getMessage());
}
journal.flushPending();
ArgumentCaptor<String> batchIds = ArgumentCaptor.forClass(String.class);
verify(store, times(2)).append(
eq("run-1"), batchIds.capture(), org.mockito.ArgumentMatchers.anyList());
Assert.assertEquals(batchIds.getAllValues().get(0), batchIds.getAllValues().get(1));
}
/**
* 验证结果不确定的批次内容保持冻结,期间到达的新事件使用新批次写入。
*/
@Test
public void shouldKeepAmbiguousBatchImmutableAndFlushTailSeparately() {
AgentAguiRunStore store = mock(AgentAguiRunStore.class);
AgentAguiRunJournal journal = new AgentAguiRunJournal(store, new ObjectMapper());
doThrow(new IllegalStateException("ambiguous timeout"))
.doReturn(1L)
.doReturn(2L)
.when(store).append(eq("run-1"), anyString(), org.mockito.ArgumentMatchers.anyList());
String first = "{\"type\":\"RUN_STARTED\",\"runId\":\"run-1\"}";
String tail = "{\"type\":\"TEXT_MESSAGE_START\",\"messageId\":\"m1\"}";
journal.append("run-1", first);
Assert.assertThrows(IllegalStateException.class, () -> journal.flush("run-1"));
journal.append("run-1", tail);
journal.flushPending();
ArgumentCaptor<String> batchIds = ArgumentCaptor.forClass(String.class);
@SuppressWarnings("unchecked")
ArgumentCaptor<List<String>> batches = ArgumentCaptor.forClass(List.class);
verify(store, times(3)).append(eq("run-1"), batchIds.capture(), batches.capture());
Assert.assertEquals(batchIds.getAllValues().get(0), batchIds.getAllValues().get(1));
Assert.assertNotEquals(batchIds.getAllValues().get(1), batchIds.getAllValues().get(2));
Assert.assertEquals(List.of(first), batches.getAllValues().get(0));
Assert.assertEquals(List.of(first), batches.getAllValues().get(1));
Assert.assertEquals(List.of(tail), batches.getAllValues().get(2));
}
/**
* 验证终态完成会等待在途后台刷盘,并在其失败后接管重试全部剩余批次。
*
* @throws Exception 并发任务等待失败时抛出
*/
@Test
public void completeShouldWaitForBackgroundFlushAndPersistAllTailEvents() throws Exception {
AgentAguiRunStore store = mock(AgentAguiRunStore.class);
AgentAguiRunJournal journal = new AgentAguiRunJournal(store, new ObjectMapper());
CountDownLatch firstAppendStarted = new CountDownLatch(1);
CountDownLatch releaseFirstAppend = new CountDownLatch(1);
CountDownLatch completeStarted = new CountDownLatch(1);
AtomicInteger appendCalls = new AtomicInteger();
doAnswer(invocation -> {
int call = appendCalls.incrementAndGet();
if (call == 1) {
firstAppendStarted.countDown();
Assert.assertTrue(releaseFirstAppend.await(5, TimeUnit.SECONDS));
throw new IllegalStateException("first append failed");
}
return (long) call;
}).when(store).append(eq("run-1"), anyString(), org.mockito.ArgumentMatchers.anyList());
String started = "{\"type\":\"RUN_STARTED\",\"runId\":\"run-1\"}";
String finished = "{\"type\":\"RUN_FINISHED\",\"runId\":\"run-1\",\"threadId\":\"1\"}";
journal.append("run-1", started);
ExecutorService executor = Executors.newFixedThreadPool(2);
try {
Future<?> background = executor.submit(journal::flushPending);
Assert.assertTrue(firstAppendStarted.await(5, TimeUnit.SECONDS));
journal.append("run-1", finished);
Future<?> completion = executor.submit(() -> {
completeStarted.countDown();
journal.complete("run-1");
});
Assert.assertTrue(completeStarted.await(5, TimeUnit.SECONDS));
Assert.assertFalse(completion.isDone());
releaseFirstAppend.countDown();
background.get(5, TimeUnit.SECONDS);
completion.get(5, TimeUnit.SECONDS);
} finally {
releaseFirstAppend.countDown();
executor.shutdownNow();
}
@SuppressWarnings("unchecked")
ArgumentCaptor<List<String>> batches = ArgumentCaptor.forClass(List.class);
ArgumentCaptor<String> batchIds = ArgumentCaptor.forClass(String.class);
verify(store, times(3)).append(eq("run-1"), batchIds.capture(), batches.capture());
Assert.assertEquals(batchIds.getAllValues().get(0), batchIds.getAllValues().get(1));
Assert.assertEquals(List.of(started), batches.getAllValues().get(1));
Assert.assertEquals(List.of(finished), batches.getAllValues().get(2));
}
/**
* 验证容量故障会丢弃未确认普通事件并以独立小批次写入失败终态。
*/
@Test
public void shouldReplacePendingEventsWithJournalFailureTerminal() {
AgentAguiRunStore store = mock(AgentAguiRunStore.class);
AgentAguiRunJournal journal = new AgentAguiRunJournal(store, new ObjectMapper());
journal.append("run-1", "{\"type\":\"RUN_STARTED\"}");
journal.fail("thread-1", "run-1", "AGENT_RUN_JOURNAL_LIMIT", "输出过长");
@SuppressWarnings("unchecked")
ArgumentCaptor<List<String>> events = ArgumentCaptor.forClass(List.class);
verify(store).append(eq("run-1"), anyString(), events.capture());
Assert.assertEquals(1, events.getValue().size());
Assert.assertTrue(events.getValue().get(0).contains("AGENT_RUN_JOURNAL_LIMIT"));
Assert.assertFalse(events.getValue().get(0).contains("RUN_STARTED"));
}
/**
* 验证异常大的单事件在进入共享内存缓冲前即被拒绝。
*/
@Test
public void shouldRejectOversizedSingleEvent() {
AgentAguiRunJournal journal = new AgentAguiRunJournal(
mock(AgentAguiRunStore.class), new ObjectMapper());
String oversized = "x".repeat(512 * 1024 + 1);
Assert.assertThrows(IllegalStateException.class,
() -> journal.append("run-1", oversized));
}
}

View File

@@ -0,0 +1,124 @@
package tech.easyflow.agent.runtime.agui;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.ListOperations;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.data.redis.core.script.RedisScript;
import tech.easyflow.agent.config.AgentRuntimeProperties;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
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;
/**
* AG-UI Redis 运行存储测试。
*/
public class AgentAguiRunStoreTest {
/**
* 验证 runId 原子保留、24 小时 TTL 与完成终态写入。
*/
@Test
public void shouldReserveRunAndPersistTerminalStatusForTwentyFourHours() {
RedisMocks redis = redisMocks();
AgentRuntimeProperties properties = new AgentRuntimeProperties();
properties.setAguiRunRetention(Duration.ofHours(24));
AgentAguiRunStore store = new AgentAguiRunStore(
redis.template, new ObjectMapper(), properties);
long now = System.currentTimeMillis();
store.create(new AgentAguiRunDescriptor(
"run-1", "request-1", "thread-1", "agent-1", "session-1",
"user-1", "tenant-1", true, AgentAguiRunStatus.RUNNING, 0L, now, now));
when(redis.template.execute(any(RedisScript.class), anyList(), any(Object[].class)))
.thenReturn(2L);
store.append("run-1", "batch-1", List.of(
"{\"type\":\"RUN_STARTED\",\"runId\":\"run-1\",\"threadId\":\"thread-1\"}",
"{\"type\":\"RUN_FINISHED\",\"runId\":\"run-1\",\"threadId\":\"thread-1\"}"));
verify(redis.values).setIfAbsent(
eq("easyflow:agent:agui:run:{run-1}:reserved"), eq("user-1"), eq(Duration.ofHours(24)));
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> metadata = ArgumentCaptor.forClass(Map.class);
verify(redis.hashes).putAll(eq("easyflow:agent:agui:run:{run-1}:meta"), metadata.capture());
Assert.assertEquals("0", metadata.getValue().get("lastCursor"));
Assert.assertEquals("false", metadata.getValue().get("cancelRequested"));
@SuppressWarnings("unchecked")
ArgumentCaptor<RedisScript<Long>> script = ArgumentCaptor.forClass(RedisScript.class);
ArgumentCaptor<Object[]> arguments = ArgumentCaptor.forClass(Object[].class);
verify(redis.template).execute(script.capture(), anyList(), arguments.capture());
Assert.assertTrue(script.getValue().getScriptAsString().contains("lastBatchId"));
Assert.assertEquals("batch-1", arguments.getValue()[0]);
Assert.assertEquals(AgentAguiRunStatus.COMPLETED.name(), arguments.getValue()[3]);
}
/**
* 验证重复 runId 在模型启动前即被拒绝。
*/
@Test(expected = BusinessException.class)
public void shouldRejectDuplicateRunId() {
RedisMocks redis = redisMocks();
when(redis.values.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(false);
AgentAguiRunStore store = new AgentAguiRunStore(
redis.template, new ObjectMapper(), new AgentRuntimeProperties());
store.create(new AgentAguiRunDescriptor(
"run-1", "request-1", "thread-1", "agent-1", "session-1",
"user-1", "tenant-1", false, AgentAguiRunStatus.RUNNING, 0L, 1L, 1L));
}
/**
* 验证 owner 丢失通过同一原子批次追加明确失败终态。
*/
@Test
public void shouldPersistOwnerLostAsReplayableTerminalEvent() {
RedisMocks redis = redisMocks();
when(redis.template.execute(any(RedisScript.class), anyList(), any(Object[].class)))
.thenReturn(1L);
AgentAguiRunStore store = new AgentAguiRunStore(
redis.template, new ObjectMapper(), new AgentRuntimeProperties());
AgentAguiRunDescriptor descriptor = new AgentAguiRunDescriptor(
"run-1", "request-1", "thread-1", "agent-1", "session-1",
"user-1", "tenant-1", false, AgentAguiRunStatus.RUNNING, 0L, 1L, 1L);
ArgumentCaptor<Object[]> arguments = ArgumentCaptor.forClass(Object[].class);
store.failOwnerLost(descriptor);
verify(redis.template).execute(any(RedisScript.class), anyList(), arguments.capture());
Assert.assertEquals(AgentAguiRunStatus.FAILED.name(), arguments.getValue()[3]);
Assert.assertTrue(arguments.getValue()[8].toString().contains("AGENT_RUN_OWNER_LOST"));
}
@SuppressWarnings("unchecked")
private RedisMocks redisMocks() {
StringRedisTemplate template = mock(StringRedisTemplate.class);
ValueOperations<String, String> values = mock(ValueOperations.class);
HashOperations<String, Object, Object> hashes = mock(HashOperations.class);
ListOperations<String, String> lists = mock(ListOperations.class);
when(template.opsForValue()).thenReturn(values);
when(template.opsForHash()).thenReturn(hashes);
when(template.opsForList()).thenReturn(lists);
when(values.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(true);
return new RedisMocks(template, values, hashes, lists);
}
private record RedisMocks(
StringRedisTemplate template,
ValueOperations<String, String> values,
HashOperations<String, Object, Object> hashes,
ListOperations<String, String> lists) {
}
}

View File

@@ -0,0 +1,50 @@
package tech.easyflow.agent.runtime.agui;
import org.junit.Test;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* AG-UI 重连订阅共享调度测试。
*/
public class AgentAguiRunSubscriptionServiceTest {
/**
* 验证同一 run 的不同游标独立读取窗口,尾部实时订阅不受历史回放阻塞。
*
* @throws Exception SSE mock 发送异常
*/
@Test
public void shouldDispatchDistinctCursorWindowsIndependently() throws Exception {
AgentAguiRunStore store = mock(AgentAguiRunStore.class);
AgentAguiRunSubscriptionService service = new AgentAguiRunSubscriptionService(store);
AgentAguiRunDescriptor descriptor = new AgentAguiRunDescriptor(
"run-1", "request-1", "thread-1", "agent-1", "session-1",
"user-1", "tenant-1", false, AgentAguiRunStatus.RUNNING,
1000L, 1L, 2L);
SseEmitter fromStart = mock(SseEmitter.class);
SseEmitter fromTail = mock(SseEmitter.class);
service.subscribe(descriptor, 0L, fromStart);
service.subscribe(descriptor, 999L, fromTail);
when(store.readAfter("run-1", 0L)).thenReturn(java.util.Collections.nCopies(
256, "{\"type\":\"TEXT_MESSAGE_CONTENT\"}"));
when(store.readAfter("run-1", 999L)).thenReturn(List.of(
"{\"type\":\"TEXT_MESSAGE_CONTENT\",\"delta\":\"tail\"}"));
when(store.find("run-1")).thenReturn(descriptor);
service.dispatch();
verify(store).readAfter("run-1", 0L);
verify(store).readAfter("run-1", 999L);
verify(store).find("run-1");
verify(fromStart, times(256)).send(any(SseEmitter.SseEventBuilder.class));
verify(fromTail).send(any(SseEmitter.SseEventBuilder.class));
}
}

View File

@@ -0,0 +1,77 @@
package tech.easyflow.agent.runtime.agui;
import org.junit.Assert;
import org.junit.Test;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
/**
* 可重连 AG-UI SSE 发射器测试。
*/
public class ResumableAguiSseEmitterTest {
/**
* 验证协议事件先进入日志,且逻辑关闭只发生在运行终态。
*/
@Test
public void shouldKeepLogicalRunOpenUntilExplicitCompletion() {
AgentAguiRunJournal journal = mock(AgentAguiRunJournal.class);
SseEmitter subscriber = new SseEmitter();
ResumableAguiSseEmitter emitter = new ResumableAguiSseEmitter(
"thread-1", "run-1", journal, subscriber);
Assert.assertSame(subscriber, emitter.getEmitter());
Assert.assertTrue(emitter.sendData("{\"type\":\"RUN_STARTED\"}"));
Assert.assertFalse(emitter.isClosed());
verify(journal).append("run-1", "{\"type\":\"RUN_STARTED\"}");
emitter.complete();
Assert.assertTrue(emitter.isClosed());
Assert.assertTrue(emitter.isJournalCompletionSuccessful());
verify(journal).complete("run-1");
}
/**
* 验证日志拒绝普通事件时立即写入可重放故障终态并关闭逻辑输出。
*/
@Test
public void shouldFailRunWhenJournalRejectsEvent() {
AgentAguiRunJournal journal = mock(AgentAguiRunJournal.class);
doThrow(new IllegalStateException("超过 16 MiB 安全上限"))
.when(journal).append("run-1", "{\"type\":\"RUN_STARTED\"}");
ResumableAguiSseEmitter emitter = new ResumableAguiSseEmitter(
"thread-1", "run-1", journal, new SseEmitter());
Assert.assertFalse(emitter.sendData("{\"type\":\"RUN_STARTED\"}"));
Assert.assertTrue(emitter.isClosed());
Assert.assertFalse(emitter.isJournalCompletionSuccessful());
verify(journal).fail(
"thread-1", "run-1", "AGENT_RUN_JOURNAL_LIMIT",
"Agent 输出超过可恢复日志容量,运行已停止");
}
/**
* 验证终态刷盘失败会被调用方观察到,避免上层错误记录为完成态。
*/
@Test
public void shouldExposeTerminalJournalFailure() {
AgentAguiRunJournal journal = mock(AgentAguiRunJournal.class);
doThrow(new IllegalStateException("Redis unavailable"))
.when(journal).complete("run-1");
ResumableAguiSseEmitter emitter = new ResumableAguiSseEmitter(
"thread-1", "run-1", journal, new SseEmitter());
emitter.complete();
Assert.assertTrue(emitter.isClosed());
Assert.assertFalse(emitter.isJournalCompletionSuccessful());
verify(journal).fail(
"thread-1", "run-1", "AGENT_RUN_JOURNAL_UNAVAILABLE",
"Agent 输出日志暂时不可用,运行已停止");
}
}

View File

@@ -146,7 +146,9 @@ public class AbstractAgentAsyncSubToolsTest {
}
@Override
protected AgentToolExecutionResult executeBusiness(Map<String, Object> arguments) {
protected AgentToolExecutionResult executeBusiness(
Map<String, Object> arguments,
AgentToolContext context) {
return new AgentToolExecutionResult(Map.of("echo", arguments.get("keyword")), "business-run-1");
}
}

View File

@@ -153,7 +153,10 @@ public class WorkflowPluginAsyncSubToolsTest {
}
@Override
public AgentToolExecutionResult execute(Workflow workflow, Map<String, Object> arguments) {
public AgentToolExecutionResult execute(
Workflow workflow,
Map<String, Object> arguments,
AgentToolContext context) {
return new AgentToolExecutionResult(businessResult, "workflow-run-1");
}
}

View File

@@ -6,6 +6,8 @@ import org.junit.Assert;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tech.easyflow.agent.runtime.agui.AgentAguiRunJournal;
import tech.easyflow.agent.runtime.agui.ResumableAguiSseEmitter;
import tech.easyflow.core.chat.protocol.ChatDomain;
import tech.easyflow.core.chat.protocol.ChatType;
import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter;
@@ -51,6 +53,26 @@ public class AguiAgentRunOutputTest {
verify(emitter).complete();
}
/**
* 验证可恢复日志终态刷盘失败会沿输出边界返回失败。
*/
@Test
public void shouldReportResumableJournalCompletionFailure() {
AgentAguiRunJournal journal = mock(AgentAguiRunJournal.class);
doThrow(new IllegalStateException("Redis unavailable"))
.when(journal).complete("run-1");
ResumableAguiSseEmitter emitter = new ResumableAguiSseEmitter(
"123", "run-1", journal, new SseEmitter());
AguiAgentRunOutput output = new AguiAgentRunOutput(
"123", "run-1", "user-message-1", emitter);
Assert.assertTrue(output.emitRuntimeEvent(
AgentRuntimeEvent.of(AgentRuntimeEventType.COMPLETED)));
Assert.assertFalse(output.finish("final"));
Assert.assertFalse(emitter.isJournalCompletionSuccessful());
}
/**
* 验证审批 Custom Event 不泄漏恢复令牌。
*/

View File

@@ -145,7 +145,10 @@ public class AgentToolRuntimeCompilerTest {
}
@Override
public AgentToolExecutionResult execute(Workflow workflow, Map<String, Object> arguments) {
public AgentToolExecutionResult execute(
Workflow workflow,
Map<String, Object> arguments,
AgentToolContext context) {
throw new IllegalStateException("jdbc:mysql://internal:3306 secret-token");
}
});
@@ -262,7 +265,10 @@ public class AgentToolRuntimeCompilerTest {
}
@Override
public AgentToolExecutionResult execute(Workflow workflow, Map<String, Object> arguments) {
public AgentToolExecutionResult execute(
Workflow workflow,
Map<String, Object> arguments,
AgentToolContext context) {
return new AgentToolExecutionResult(Map.of("ok", true), "wf-run-1");
}
}

View File

@@ -0,0 +1,100 @@
package tech.easyflow.agent.runtime.tool;
import com.easyagents.agent.runtime.AgentRuntimeContext;
import com.easyagents.agent.runtime.tool.AgentToolContext;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import tech.easyflow.ai.easyagentsflow.repository.FrozenWorkflowDefinitionRegistry;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.entity.LoginAccount;
import java.math.BigInteger;
import java.util.LinkedHashMap;
import java.util.Map;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Agent Workflow 工具调用上下文测试。
*/
public class WorkflowToolExecutorTest {
@Test
@SuppressWarnings("unchecked")
public void shouldForwardAgentIdentityToFrozenWorkflowVariables() {
ChainExecutor chainExecutor = mock(ChainExecutor.class);
FrozenWorkflowDefinitionRegistry registry =
mock(FrozenWorkflowDefinitionRegistry.class);
Workflow workflow = new Workflow();
workflow.setId(BigInteger.valueOf(101));
workflow.setContent("{\"nodes\":[]}");
when(registry.register(workflow)).thenReturn("agent-frozen:101:hash");
when(chainExecutor.executeWithoutSuspension(anyString(), anyMap()))
.thenReturn(Map.of("ok", true));
WorkflowToolExecutor executor = new WorkflowToolExecutor(
chainExecutor, registry);
AgentRuntimeContext runtimeContext = new AgentRuntimeContext();
runtimeContext.setUserId("7");
runtimeContext.setTenantId("9");
runtimeContext.setUserName("测试用户");
AgentToolContext context = new AgentToolContext();
context.setRuntimeContext(runtimeContext);
Map<String, Object> arguments = new LinkedHashMap<>();
arguments.put("question", "问题");
LoginAccount forgedAccount = new LoginAccount();
forgedAccount.setId(BigInteger.valueOf(999));
forgedAccount.setTenantId(BigInteger.valueOf(999));
arguments.put(Constants.LOGIN_USER_KEY, forgedAccount);
executor.execute(workflow, arguments, context);
ArgumentCaptor<Map<String, Object>> variables =
ArgumentCaptor.forClass(Map.class);
verify(chainExecutor).executeWithoutSuspension(
org.mockito.ArgumentMatchers.eq("agent-frozen:101:hash"),
variables.capture());
LoginAccount account = (LoginAccount) variables.getValue()
.get(Constants.LOGIN_USER_KEY);
Assert.assertEquals(BigInteger.valueOf(7), account.getId());
Assert.assertEquals(BigInteger.valueOf(9), account.getTenantId());
Assert.assertEquals("测试用户", account.getLoginName());
Assert.assertSame(forgedAccount,
arguments.get(Constants.LOGIN_USER_KEY));
}
@Test
@SuppressWarnings("unchecked")
public void shouldDropReservedIdentityWithoutAgentContext() {
ChainExecutor chainExecutor = mock(ChainExecutor.class);
FrozenWorkflowDefinitionRegistry registry =
mock(FrozenWorkflowDefinitionRegistry.class);
Workflow workflow = new Workflow();
workflow.setId(BigInteger.valueOf(101));
workflow.setContent("{\"nodes\":[]}");
when(registry.register(workflow)).thenReturn("agent-frozen:101:hash");
when(chainExecutor.executeWithoutSuspension(anyString(), anyMap()))
.thenReturn(Map.of("ok", true));
WorkflowToolExecutor executor = new WorkflowToolExecutor(
chainExecutor, registry);
Map<String, Object> arguments = new LinkedHashMap<>();
arguments.put(Constants.LOGIN_USER_KEY, new LoginAccount());
executor.execute(workflow, arguments);
ArgumentCaptor<Map<String, Object>> variables =
ArgumentCaptor.forClass(Map.class);
verify(chainExecutor).executeWithoutSuspension(
anyString(), variables.capture());
Assert.assertFalse(variables.getValue()
.containsKey(Constants.LOGIN_USER_KEY));
Assert.assertTrue(arguments.containsKey(Constants.LOGIN_USER_KEY));
}
}

View File

@@ -50,6 +50,22 @@ public class AgentSkillReferenceProviderTest {
"智能体“线上引用智能体”"), references);
}
/**
* 没有 Agent 引用 Skill 时不应执行空主键集合查询。
*/
@Test
public void shouldSkipEntityQueryWhenSkillHasNoReferences() {
AgentService agentService = Mockito.mock(AgentService.class);
AgentSkillBindingService bindingService = Mockito.mock(AgentSkillBindingService.class);
Mockito.when(bindingService.list(Mockito.any(QueryWrapper.class))).thenReturn(List.of());
Mockito.when(agentService.list(Mockito.any(QueryWrapper.class))).thenReturn(List.of());
AgentSkillReferenceProvider provider = new AgentSkillReferenceProvider(
agentService, bindingService);
Assert.assertTrue(provider.listReferences(BigInteger.TEN).isEmpty());
Mockito.verify(agentService, Mockito.never()).listByIds(Mockito.anyCollection());
}
/**
* 创建 Agent 摘要。
*

View File

@@ -53,6 +53,10 @@
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>io.milvus</groupId>
<artifactId>milvus-sdk-java</artifactId>
</dependency>
<dependency>
<groupId>com.google.re2j</groupId>
<artifactId>re2j</artifactId>

View File

@@ -0,0 +1,23 @@
package tech.easyflow.ai.config;
import com.easyagents.store.milvus.MilvusClientManager;
import jakarta.annotation.PreDestroy;
import org.springframework.stereotype.Component;
import org.springframework.context.annotation.Lazy;
/**
* 应用级 Milvus 客户端池。
*/
@Component
@Lazy
public class AiMilvusClientManager extends MilvusClientManager {
public AiMilvusClientManager(AiMilvusConfig config) {
super(config);
}
@PreDestroy
public void destroy() {
close();
}
}

View File

@@ -17,6 +17,14 @@ public class AiMilvusConfig extends MilvusVectorStoreConfig {
config.setPassword(getPassword());
config.setAutoCreateCollection(isAutoCreateCollection());
config.setDefaultCollectionName(collectionName);
config.setPoolMaxTotal(getPoolMaxTotal());
config.setPoolMaxTotalPerKey(getPoolMaxTotalPerKey());
config.setPoolMaxIdlePerKey(getPoolMaxIdlePerKey());
config.setPoolMinIdlePerKey(getPoolMinIdlePerKey());
config.setPoolMaxWaitMillis(getPoolMaxWaitMillis());
config.setPoolEvictionIntervalMillis(getPoolEvictionIntervalMillis());
config.setPoolMinEvictableIdleMillis(getPoolMinEvictableIdleMillis());
config.setSearchTimeoutMillis(getSearchTimeoutMillis());
return config;
}
}

View File

@@ -14,7 +14,8 @@ import tech.easyflow.ai.documentimport.task.DocumentImportStatusBroadcastPropert
DocumentImportBulkProperties.class,
DocumentImportParseMonitorProperties.class,
DocumentImportStatusBroadcastProperties.class,
RagHealthProperties.class
RagHealthProperties.class,
MultiKnowledgeRetrievalProperties.class
})
@AutoConfiguration
public class AiModuleConfig {

View File

@@ -11,6 +11,7 @@ public class EasyFlowThreadPoolProperties {
private Pool sse = new Pool(4, 16, 2000, 30, true);
private Pool documentImport = new Pool(2, 4, 200, 60, true);
private Pool agentAsyncTool = new Pool(2, 8, 200, 60, true);
private Pool knowledgeRetrieval = new Pool(4, 8, 64, 30, true);
/**
* 获取 SSE 线程池配置。
@@ -66,6 +67,14 @@ public class EasyFlowThreadPoolProperties {
this.agentAsyncTool = agentAsyncTool;
}
public Pool getKnowledgeRetrieval() {
return knowledgeRetrieval;
}
public void setKnowledgeRetrieval(Pool knowledgeRetrieval) {
this.knowledgeRetrieval = knowledgeRetrieval;
}
/**
* 线程池配置项。
*/

View File

@@ -0,0 +1,108 @@
package tech.easyflow.ai.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
/**
* 工作流多知识库向量检索的资源与时限配置。
*/
@ConfigurationProperties(prefix = "easyflow.ai.knowledge.multi-retrieval")
public class MultiKnowledgeRetrievalProperties {
private int maxSources = 8;
private int candidateMultiplier = 5;
private int perSourceCandidateLimit = 50;
private int totalCandidateLimit = 400;
private double minVectorScore = 0.6D;
private Duration perSourceTimeout = Duration.ofSeconds(10);
private Duration totalTimeout = Duration.ofSeconds(20);
public int getMaxSources() {
return maxSources;
}
public void setMaxSources(int maxSources) {
this.maxSources = maxSources;
}
public int getCandidateMultiplier() {
return candidateMultiplier;
}
public void setCandidateMultiplier(int candidateMultiplier) {
this.candidateMultiplier = candidateMultiplier;
}
public int getPerSourceCandidateLimit() {
return perSourceCandidateLimit;
}
public void setPerSourceCandidateLimit(int perSourceCandidateLimit) {
this.perSourceCandidateLimit = perSourceCandidateLimit;
}
public int getTotalCandidateLimit() {
return totalCandidateLimit;
}
public void setTotalCandidateLimit(int totalCandidateLimit) {
this.totalCandidateLimit = totalCandidateLimit;
}
public double getMinVectorScore() {
return minVectorScore;
}
public void setMinVectorScore(double minVectorScore) {
this.minVectorScore = minVectorScore;
}
public Duration getPerSourceTimeout() {
return perSourceTimeout;
}
public void setPerSourceTimeout(Duration perSourceTimeout) {
this.perSourceTimeout = perSourceTimeout;
}
public Duration getTotalTimeout() {
return totalTimeout;
}
public void setTotalTimeout(Duration totalTimeout) {
this.totalTimeout = totalTimeout;
}
/**
* 启动期校验全部有界配置。
*/
public void validate() {
if (maxSources < 2 || maxSources > 64) {
throw new IllegalArgumentException("多知识库最大来源数必须在 2 到 64 之间");
}
if (candidateMultiplier < 1 || candidateMultiplier > 20) {
throw new IllegalArgumentException("多知识库候选倍率必须在 1 到 20 之间");
}
if (perSourceCandidateLimit < 1 || perSourceCandidateLimit > 1000) {
throw new IllegalArgumentException("单知识库候选上限必须在 1 到 1000 之间");
}
long derivedCandidateLimit = (long) maxSources
* perSourceCandidateLimit;
if (totalCandidateLimit < derivedCandidateLimit
|| totalCandidateLimit > 10000) {
throw new IllegalArgumentException(
"多知识库总候选上限不能小于最大来源数与单库候选上限的乘积");
}
if (!Double.isFinite(minVectorScore) || minVectorScore < 0D || minVectorScore > 1D) {
throw new IllegalArgumentException("多知识库向量阈值必须在 0 到 1 之间");
}
if (perSourceTimeout == null || perSourceTimeout.isZero() || perSourceTimeout.isNegative()) {
throw new IllegalArgumentException("单知识库超时时间必须大于 0");
}
if (totalTimeout == null || totalTimeout.isZero() || totalTimeout.isNegative()
|| totalTimeout.compareTo(perSourceTimeout) < 0) {
throw new IllegalArgumentException("节点总超时时间不能小于单知识库超时时间");
}
}
}

View File

@@ -25,16 +25,23 @@ public class RagHealthIndicator {
public static class RagMilvusHealthIndicator extends CachedHealthIndicatorSupport implements HealthIndicator {
private final AiMilvusConfig aiMilvusConfig;
private final AiMilvusClientManager milvusClientManager;
/**
* 创建 Milvus 健康检查器。
*
* @param aiMilvusConfig Milvus 配置
* @param milvusClientManager 应用级 Milvus 客户端池
* @param healthProperties RAG 健康检查配置
*/
public RagMilvusHealthIndicator(AiMilvusConfig aiMilvusConfig, RagHealthProperties healthProperties) {
public RagMilvusHealthIndicator(
AiMilvusConfig aiMilvusConfig,
AiMilvusClientManager milvusClientManager,
RagHealthProperties healthProperties
) {
super(healthProperties);
this.aiMilvusConfig = aiMilvusConfig;
this.milvusClientManager = milvusClientManager;
}
/**
@@ -51,8 +58,10 @@ public class RagHealthIndicator {
protected Health doHealthCheck() {
MilvusVectorStore vectorStore = null;
try {
milvusClientManager.reconfigureIfNeeded(aiMilvusConfig);
vectorStore = new MilvusVectorStore(
aiMilvusConfig.copyForCollection("__rag_health_probe__")
aiMilvusConfig.copyForCollection("__rag_health_probe__"),
milvusClientManager
);
if (vectorStore.checkAvailable()) {
return Health.up().withDetail("uri", aiMilvusConfig.getUri()).build();

View File

@@ -104,4 +104,28 @@ public class ThreadPoolConfig {
executor.initialize();
return executor;
}
/**
* 创建工作流多知识库检索线程池。
*
* @return 多知识库检索线程池
*/
@Bean(name = "knowledgeRetrievalExecutor")
public ThreadPoolTaskExecutor knowledgeRetrievalExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
EasyFlowThreadPoolProperties.Pool pool = properties.getKnowledgeRetrieval();
executor.setCorePoolSize(pool.getCoreSize());
executor.setMaxPoolSize(pool.getMaxSize());
executor.setQueueCapacity(pool.getQueueCapacity());
executor.setKeepAliveSeconds(pool.getKeepAliveSeconds());
executor.setAllowCoreThreadTimeOut(pool.isAllowCoreThreadTimeout());
executor.setThreadNamePrefix("knowledge-retrieval-");
executor.setRejectedExecutionHandler((runnable, executorService) -> {
log.error("多知识库检索线程池过载active={}, queue={}",
executorService.getActiveCount(), executorService.getQueue().size());
throw new BusinessException("知识库检索繁忙,请稍后重试");
});
executor.initialize();
return executor;
}
}

View File

@@ -75,6 +75,10 @@ public class DocumentParseBridgeException extends RuntimeException {
return new DocumentParseBridgeException("task_failed", message);
}
public static DocumentParseBridgeException taskNotFound(String message, Throwable cause) {
return new DocumentParseBridgeException("task_not_found", message, cause);
}
public static DocumentParseBridgeException resultFetchFailed(String message, Throwable cause) {
return new DocumentParseBridgeException("result_fetch_failed", message, cause);
}

View File

@@ -5,6 +5,7 @@ import com.easyagents.document.core.entity.ParseResponse;
import com.easyagents.document.core.entity.ParseResult;
import com.easyagents.document.core.entity.ParseTaskInfo;
import com.easyagents.document.core.entity.ParseTaskStatus;
import com.easyagents.document.core.exception.DocumentAsyncTaskNotFoundException;
import com.easyagents.document.pdf.PdfDocumentParseService;
import com.easyagents.document.pptx.PptxDocumentParseService;
import com.easyagents.document.xlsx.XlsxDocumentParseService;
@@ -140,6 +141,8 @@ public class DocumentParseBridgeServiceImpl implements DocumentParseBridgeServic
return parseResultMapper.map(taskStatus);
} catch (DocumentParseBridgeException e) {
throw e;
} catch (DocumentAsyncTaskNotFoundException e) {
throw DocumentParseBridgeException.taskNotFound("异步文档解析任务不存在", e);
} catch (Exception e) {
throw DocumentParseBridgeException.taskFailed("查询异步文档解析任务状态失败", e);
}
@@ -175,6 +178,9 @@ public class DocumentParseBridgeServiceImpl implements DocumentParseBridgeServic
} catch (DocumentParseBridgeException e) {
LOG.error("桥接服务获取异步解析结果失败: providerTaskId={}", taskId, e);
throw e;
} catch (DocumentAsyncTaskNotFoundException e) {
LOG.warn("桥接服务异步解析执行已丢失: providerTaskId={}", taskId);
throw DocumentParseBridgeException.taskNotFound("异步文档解析任务不存在", e);
} catch (Exception e) {
LOG.error("桥接服务获取异步解析结果异常: providerTaskId={}", taskId, e);
throw DocumentParseBridgeException.resultFetchFailed("获取异步文档解析结果失败", e);
@@ -212,6 +218,9 @@ public class DocumentParseBridgeServiceImpl implements DocumentParseBridgeServic
} catch (DocumentParseBridgeException e) {
LOG.error("桥接服务查询异步解析任务状态失败: providerTaskId={}", taskId, e);
throw e;
} catch (DocumentAsyncTaskNotFoundException e) {
LOG.warn("桥接服务异步解析执行已丢失: providerTaskId={}", taskId);
throw DocumentParseBridgeException.taskNotFound("异步文档解析任务不存在", e);
} catch (Exception e) {
LOG.error("桥接服务查询异步解析任务状态异常: providerTaskId={}", taskId, e);
throw DocumentParseBridgeException.taskFailed("聚合查询异步文档解析任务信息失败", e);

View File

@@ -0,0 +1,86 @@
package tech.easyflow.ai.document.support;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 知识库导入与工作流文档解析共用的文件格式策略。
*
* @author Codex
* @since 2026-09-02
*/
public final class DocumentParseFilePolicy {
private static final List<String> SUPPORTED_EXTENSION_ORDER =
List.of("txt", "pdf", "docx", "md", "pptx", "xlsx", "csv");
private static final Set<String> SUPPORTED_EXTENSIONS =
Set.copyOf(SUPPORTED_EXTENSION_ORDER);
private static final String SUPPORTED_TYPE_LABEL =
SUPPORTED_EXTENSION_ORDER.stream()
.map(extension -> extension.toUpperCase(Locale.ROOT))
.collect(Collectors.joining(""));
/**
* 禁止实例化格式策略工具类。
*/
private DocumentParseFilePolicy() {
}
/**
* 返回只读的支持格式集合。
*
* @return 支持的文件扩展名
*/
public static Set<String> supportedExtensions() {
return SUPPORTED_EXTENSIONS;
}
/**
* 返回面向用户展示的支持格式列表。
*
* @return 大写扩展名列表
*/
public static String supportedTypeLabel() {
return SUPPORTED_TYPE_LABEL;
}
/**
* 判断扩展名是否属于文档解析支持范围。
*
* @param extension 文件扩展名
* @return 支持时返回 {@code true}
*/
public static boolean isSupportedExtension(String extension) {
return SUPPORTED_EXTENSIONS.contains(normalizeExtension(extension));
}
/**
* 判断文件名是否属于文档解析支持范围。
*
* @param fileName 文件名
* @return 支持时返回 {@code true}
*/
public static boolean isSupportedFileName(String fileName) {
return isSupportedExtension(extensionOf(fileName));
}
/**
* 提取文件名中的小写扩展名。
*
* @param fileName 文件名
* @return 小写扩展名;无扩展名时返回空字符串
*/
public static String extensionOf(String fileName) {
String normalizedName = fileName == null ? "" : fileName.trim();
int dotIndex = normalizedName.lastIndexOf('.');
return dotIndex < 0 || dotIndex == normalizedName.length() - 1
? ""
: normalizeExtension(normalizedName.substring(dotIndex + 1));
}
private static String normalizeExtension(String extension) {
return extension == null ? "" : extension.trim().toLowerCase(Locale.ROOT);
}
}

View File

@@ -10,6 +10,7 @@ import tech.easyflow.ai.document.exception.DocumentParseBridgeException;
import tech.easyflow.ai.document.model.DocumentSourceRef;
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadedFileReader;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
import tech.easyflow.common.filestorage.utils.PathGeneratorUtil;
import java.io.IOException;
@@ -164,7 +165,7 @@ public class DocumentSourceLoader {
}
/**
* 优先打开经过上传记录验证的受管 URL再执行普通公网 URL 校验与下载。
* 优先打开经过上传记录验证的受管 URL 和服务端已登记附件,再执行普通公网 URL 校验与下载。
*
* @param remoteUrl 远端 URL
* @param maxBytes 最大允许读取字节数
@@ -176,6 +177,13 @@ public class DocumentSourceLoader {
if (managed.isPresent()) {
return DocumentInputStreamSupport.limit(managed.get(), maxBytes);
}
Optional<FileStorageWriteHandle> trusted =
fileStorageService.resolveTrustedFile(remoteUrl);
if (trusted.isPresent()) {
return DocumentInputStreamSupport.limit(
fileStorageService.readRecoverable(trusted.get()),
maxBytes);
}
return DocumentInputStreamSupport.openRemote(remoteUrl, maxBytes);
}

View File

@@ -0,0 +1,34 @@
package tech.easyflow.ai.documentchunk;
/**
* 分块检索索引同步状态常量。
*/
public final class DocumentChunkSyncState {
private static final String PARENT_LOCK_PREFIX =
"easyflow:lock:document-chunk-parent:";
private static final String SYNC_LOCK_PREFIX =
"easyflow:lock:document-chunk-sync:";
public static final String SYNCED = "SYNCED";
public static final String PENDING = "PENDING";
public static final String FAILED = "FAILED";
public static final String TASK_RUNNING = "RUNNING";
public static final String TASK_SUCCEEDED = "SUCCEEDED";
public static final String TASK_SUPERSEDED = "SUPERSEDED";
public static final String OPERATION_UPSERT = "UPSERT";
public static final String OPERATION_DELETE = "DELETE";
public static String parentLockKey(Object documentId) {
return PARENT_LOCK_PREFIX + documentId;
}
public static String syncLockKey(Object chunkId) {
return SYNC_LOCK_PREFIX + chunkId;
}
private DocumentChunkSyncState() {
}
}

View File

@@ -0,0 +1,414 @@
package tech.easyflow.ai.documentchunk;
import com.easyagents.core.model.embedding.EmbeddingModel;
import com.easyagents.core.model.embedding.EmbeddingOptions;
import com.easyagents.core.store.DocumentStore;
import com.easyagents.core.store.StoreOptions;
import com.easyagents.core.store.StoreResult;
import com.easyagents.search.engine.service.DocumentSearcher;
import com.easyagents.search.engine.service.KeywordSearchMetadataKeys;
import com.easyagents.store.milvus.MilvusVectorStore;
import com.easyagents.store.milvus.MilvusVectorStoreConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import tech.easyflow.ai.config.AiMilvusClientManager;
import tech.easyflow.ai.config.AiMilvusConfig;
import tech.easyflow.ai.config.SearcherFactory;
import tech.easyflow.ai.entity.DocumentChunk;
import tech.easyflow.ai.entity.DocumentChunkSyncTask;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.mapper.DocumentChunkMapper;
import tech.easyflow.ai.mapper.DocumentChunkSyncTaskMapper;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.ai.support.DocumentStoreLifecycleSupport;
import tech.easyflow.common.cache.RedisLockExecutor;
import java.math.BigInteger;
import java.time.Duration;
import java.util.Collections;
import java.util.Date;
import java.util.UUID;
/**
* 持久化分块索引同步任务的投递、执行和恢复。
*/
@Service
public class DocumentChunkSyncTaskAppService {
private static final Logger LOG = LoggerFactory.getLogger(DocumentChunkSyncTaskAppService.class);
private static final int MAX_ATTEMPTS = 5;
private static final int DISPATCH_LIMIT = 100;
private static final long REDISPATCH_MILLIS = 5_000L;
private static final long LEASE_MILLIS = 300_000L;
private final DocumentChunkSyncTaskMapper taskMapper;
private final DocumentChunkMapper chunkMapper;
private final DocumentCollectionService collectionService;
private final ModelService modelService;
private final SearcherFactory searcherFactory;
private final DocumentChunkSyncTaskProducer producer;
private final PlatformTransactionManager transactionManager;
private final RedisLockExecutor redisLockExecutor;
private final AiMilvusConfig milvusConfig;
private final ObjectProvider<AiMilvusClientManager> milvusClientManagerProvider;
public DocumentChunkSyncTaskAppService(
DocumentChunkSyncTaskMapper taskMapper,
DocumentChunkMapper chunkMapper,
DocumentCollectionService collectionService,
ModelService modelService,
SearcherFactory searcherFactory,
DocumentChunkSyncTaskProducer producer,
PlatformTransactionManager transactionManager,
RedisLockExecutor redisLockExecutor,
AiMilvusConfig milvusConfig,
ObjectProvider<AiMilvusClientManager> milvusClientManagerProvider
) {
this.taskMapper = taskMapper;
this.chunkMapper = chunkMapper;
this.collectionService = collectionService;
this.modelService = modelService;
this.searcherFactory = searcherFactory;
this.producer = producer;
this.transactionManager = transactionManager;
this.redisLockExecutor = redisLockExecutor;
this.milvusConfig = milvusConfig;
this.milvusClientManagerProvider = milvusClientManagerProvider;
}
public DocumentChunkSyncTask createTask(
DocumentChunk chunk,
DocumentCollection collection,
String operation,
long version,
Date now
) {
taskMapper.supersedeOlder(chunk.getId(), version, now);
DocumentChunkSyncTask task = new DocumentChunkSyncTask();
task.setChunkId(chunk.getId());
task.setDocumentId(chunk.getDocumentId());
task.setDocumentCollectionId(chunk.getDocumentCollectionId());
task.setVectorCollection(collection.getVectorStoreCollection());
task.setOperation(operation);
task.setSyncVersion(version);
task.setStatus(DocumentChunkSyncState.PENDING);
task.setAttemptCount(0);
task.setNextRetryAt(now);
task.setCreated(now);
task.setModified(now);
if (taskMapper.insert(task) <= 0) {
throw new IllegalStateException("创建分块索引同步任务失败");
}
return task;
}
public void dispatchBestEffort(BigInteger taskId) {
try {
producer.send(taskId);
} catch (RuntimeException exception) {
LOG.warn("分块索引同步消息投递失败,等待数据库补投: taskId={}", taskId, exception);
}
}
public void dispatchPendingTasks() {
Date now = new Date();
taskMapper.recoverExpired(now);
Date redispatchBefore = new Date(now.getTime() - REDISPATCH_MILLIS);
for (DocumentChunkSyncTask task : taskMapper.selectPendingDue(
now,
redispatchBefore,
DISPATCH_LIMIT
)) {
if (taskMapper.markDispatched(task.getId(), now, redispatchBefore) <= 0) {
continue;
}
try {
producer.send(task.getId());
} catch (RuntimeException exception) {
LOG.warn("补投分块索引同步消息失败: taskId={}", task.getId(), exception);
}
}
}
public void handleTask(BigInteger taskId) {
DocumentChunkSyncTask snapshot = taskMapper.selectOneById(taskId);
if (snapshot == null || !DocumentChunkSyncState.PENDING.equals(snapshot.getStatus())) {
return;
}
redisLockExecutor.executeWithRenewingLock(
DocumentChunkSyncState.syncLockKey(snapshot.getChunkId()),
Duration.ofSeconds(3),
Duration.ofMinutes(5),
() -> {
claimAndExecute(taskId, snapshot.getChunkId());
return null;
}
);
}
private void claimAndExecute(BigInteger taskId, BigInteger chunkId) {
Date now = new Date();
String token = UUID.randomUUID().toString();
if (taskMapper.claim(
taskId,
token,
new Date(now.getTime() + LEASE_MILLIS),
now
) <= 0) {
return;
}
new TransactionTemplate(transactionManager).executeWithoutResult(status ->
executeOwnedTask(taskId, chunkId, token)
);
}
private void executeOwnedTask(
BigInteger taskId,
BigInteger chunkId,
String token
) {
// FOR UPDATE 必须是本事务的第一次读取。MySQL REPEATABLE READ 下,
// 若先做普通查询会建立旧快照,使删除或新版本建单已经提交后仍读取旧分块。
taskMapper.lockChunkTasks(chunkId);
DocumentChunkSyncTask task = taskMapper.selectOneById(taskId);
if (task == null
|| !DocumentChunkSyncState.TASK_RUNNING.equals(task.getStatus())
|| !token.equals(task.getExecutionToken())) {
return;
}
if (isSuperseded(task)) {
finishTask(task, token, DocumentChunkSyncState.TASK_SUPERSEDED, null, null);
return;
}
try {
synchronizeIndexes(task);
finishSuccess(task, token);
} catch (IndexSyncException exception) {
LOG.warn("分块索引同步失败: taskId={}, chunkId={}, code={}",
task.getId(), task.getChunkId(), exception.code, exception.getCause());
finishFailure(task, token, exception.code, exception.getMessage());
} catch (RuntimeException exception) {
LOG.error("分块索引同步发生未分类异常: taskId={}, chunkId={}",
task.getId(), task.getChunkId(), exception);
finishFailure(task, token, "INDEX_SYNC_FAILED", "检索索引同步失败");
}
}
private boolean isSuperseded(DocumentChunkSyncTask task) {
DocumentChunk chunk = chunkMapper.selectOneById(task.getChunkId());
if (DocumentChunkSyncState.OPERATION_DELETE.equals(task.getOperation())) {
return chunk != null;
}
return chunk == null
|| chunk.getIndexSyncVersion() == null
|| chunk.getIndexSyncVersion().longValue() != task.getSyncVersion().longValue()
|| !DocumentChunkSyncState.PENDING.equals(chunk.getIndexSyncStatus());
}
private void synchronizeIndexes(DocumentChunkSyncTask task) {
if (DocumentChunkSyncState.OPERATION_DELETE.equals(task.getOperation())) {
synchronizeDelete(task);
return;
}
synchronizeUpsert(task);
}
private void synchronizeUpsert(DocumentChunkSyncTask task) {
DocumentChunk chunk = chunkMapper.selectOneById(task.getChunkId());
DocumentCollection collection = collectionService.getById(task.getDocumentCollectionId());
if (chunk == null || collection == null) {
throw new IndexSyncException("INDEX_SOURCE_MISSING", "分块或知识库不存在", null);
}
StoreContext context = prepareUpsertContext(collection, task.getVectorCollection());
try {
com.easyagents.core.document.Document document = toSearchDocument(chunk, task.getDocumentCollectionId());
StoreResult vectorResult = context.documentStore.update(
Collections.singletonList(document),
context.storeOptions
);
if (vectorResult == null || !vectorResult.isSuccess()) {
throw new IndexSyncException("VECTOR_UPSERT_FAILED", "向量索引更新失败", null);
}
if (context.searcher != null
&& !context.searcher.addDocuments(Collections.singletonList(document))) {
throw new IndexSyncException("KEYWORD_UPSERT_FAILED", "关键词索引更新失败", null);
}
} catch (IndexSyncException exception) {
throw exception;
} catch (RuntimeException exception) {
throw new IndexSyncException("INDEX_UPSERT_FAILED", "检索索引更新失败", exception);
} finally {
DocumentStoreLifecycleSupport.closeQuietly(context.documentStore);
}
}
private void synchronizeDelete(DocumentChunkSyncTask task) {
MilvusVectorStoreConfig storeConfig = milvusConfig.copyForCollection(task.getVectorCollection());
AiMilvusClientManager clientManager = milvusClientManagerProvider.getObject();
clientManager.reconfigureIfNeeded(milvusConfig);
DocumentStore documentStore = new MilvusVectorStore(
storeConfig,
clientManager
);
StoreOptions storeOptions = StoreOptions.ofCollectionName(task.getVectorCollection());
DocumentSearcher searcher = searcherFactory.getSearcher();
try {
StoreResult result = documentStore.delete(
Collections.singletonList(task.getChunkId().toString()),
storeOptions
);
if (result == null || !result.isSuccess()) {
throw new IndexSyncException("VECTOR_DELETE_FAILED", "向量索引删除失败", null);
}
if (searcher != null && !searcher.deleteDocument(task.getChunkId())) {
throw new IndexSyncException("KEYWORD_DELETE_FAILED", "关键词索引删除失败", null);
}
} catch (IndexSyncException exception) {
throw exception;
} catch (RuntimeException exception) {
throw new IndexSyncException("INDEX_DELETE_FAILED", "检索索引删除失败", exception);
} finally {
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
}
}
private StoreContext prepareUpsertContext(
DocumentCollection collection,
String vectorCollection
) {
DocumentStore documentStore = collection.toDocumentStore();
if (documentStore == null) {
throw new IndexSyncException("VECTOR_STORE_MISSING", "知识库没有配置向量库", null);
}
try {
Model model = modelService.getModelInstance(collection.getVectorEmbedModelId());
if (model == null) {
throw new IndexSyncException("EMBEDDING_MODEL_MISSING", "知识库没有配置向量模型", null);
}
EmbeddingModel embeddingModel = model.toEmbeddingModel();
documentStore.setEmbeddingModel(embeddingModel);
StoreOptions options = StoreOptions.ofCollectionName(vectorCollection);
EmbeddingOptions embeddingOptions = new EmbeddingOptions();
embeddingOptions.setModel(model.getModelName());
embeddingOptions.setDimensions(collection.getDimensionOfVectorModel());
options.setEmbeddingOptions(embeddingOptions);
options.setIndexName(vectorCollection);
return new StoreContext(documentStore, options, searcherFactory.getSearcher());
} catch (RuntimeException exception) {
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
throw exception;
}
}
private com.easyagents.core.document.Document toSearchDocument(
DocumentChunk chunk,
BigInteger knowledgeId
) {
com.easyagents.core.document.Document document =
com.easyagents.core.document.Document.of(chunk.getContent());
document.setId(chunk.getId());
document.addMetadata(KeywordSearchMetadataKeys.KNOWLEDGE_ID, knowledgeId.toString());
return document;
}
private void finishSuccess(DocumentChunkSyncTask task, String token) {
new TransactionTemplate(transactionManager).executeWithoutResult(status -> {
if (taskMapper.finishOwned(
task.getId(), token, DocumentChunkSyncState.TASK_SUCCEEDED, null, null, new Date()
) > 0 && DocumentChunkSyncState.OPERATION_UPSERT.equals(task.getOperation())) {
chunkMapper.updateSyncState(
task.getChunkId(), task.getSyncVersion(), DocumentChunkSyncState.SYNCED, null, null
);
}
});
}
private void finishTask(
DocumentChunkSyncTask task,
String token,
String status,
String errorCode,
String errorMessage
) {
taskMapper.finishOwned(task.getId(), token, status, errorCode, errorMessage, new Date());
}
private void finishFailure(
DocumentChunkSyncTask task,
String token,
String errorCode,
String errorMessage
) {
int attempts = task.getAttemptCount() == null ? 1 : task.getAttemptCount();
boolean deleteOperation = DocumentChunkSyncState.OPERATION_DELETE.equals(
task.getOperation()
);
// 删除后已没有页面实体承载手动重试入口,因此清理任务必须保留为持久化 tombstone。
boolean exhausted = !deleteOperation && attempts >= MAX_ATTEMPTS;
String nextStatus = exhausted ? DocumentChunkSyncState.FAILED : DocumentChunkSyncState.PENDING;
long delaySeconds = Math.min(1L << Math.min(attempts, 6), 60L);
Date now = new Date();
Date nextRetryAt = exhausted ? now : new Date(now.getTime() + delaySeconds * 1_000L);
new TransactionTemplate(transactionManager).executeWithoutResult(status -> {
if (taskMapper.failOrRetryOwned(
task.getId(), token, nextStatus, nextRetryAt, errorCode, errorMessage, now
) > 0 && exhausted && DocumentChunkSyncState.OPERATION_UPSERT.equals(task.getOperation())) {
chunkMapper.updateSyncState(
task.getChunkId(), task.getSyncVersion(), DocumentChunkSyncState.FAILED,
errorCode, errorMessage
);
}
});
}
public DocumentChunk retry(BigInteger chunkId, long syncVersion) {
Date now = new Date();
DocumentChunk chunk = chunkMapper.selectOneById(chunkId);
if (chunk == null || chunk.getIndexSyncVersion() == null
|| chunk.getIndexSyncVersion().longValue() != syncVersion
|| !DocumentChunkSyncState.FAILED.equals(chunk.getIndexSyncStatus())) {
throw new IllegalStateException("分块同步状态已变化,请刷新后重试");
}
DocumentChunkSyncTask task = taskMapper.selectFailed(chunkId, syncVersion);
if (task == null) {
throw new IllegalStateException("未找到可重试的索引同步任务");
}
new TransactionTemplate(transactionManager).executeWithoutResult(status -> {
if (taskMapper.retryFailed(task.getId(), chunkId, syncVersion, now) <= 0) {
throw new IllegalStateException("索引同步任务状态已变化");
}
if (chunkMapper.updateSyncState(
chunkId, syncVersion, DocumentChunkSyncState.PENDING, null, null
) <= 0) {
throw new IllegalStateException("分块同步状态已变化");
}
});
dispatchBestEffort(task.getId());
chunk.setIndexSyncStatus(DocumentChunkSyncState.PENDING);
chunk.setIndexSyncErrorCode(null);
chunk.setIndexSyncErrorMessage(null);
return chunk;
}
private record StoreContext(
DocumentStore documentStore,
StoreOptions storeOptions,
DocumentSearcher searcher
) {
}
private static final class IndexSyncException extends RuntimeException {
private final String code;
private IndexSyncException(String code, String message, Throwable cause) {
super(message, cause);
this.code = code;
}
}
}

View File

@@ -0,0 +1,66 @@
package tech.easyflow.ai.documentchunk;
import com.alibaba.fastjson2.JSON;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import tech.easyflow.common.mq.config.MQProperties;
import tech.easyflow.common.mq.core.MQConsumerHandler;
import tech.easyflow.common.mq.core.MQDeferException;
import tech.easyflow.common.mq.core.MQMessage;
import tech.easyflow.common.mq.core.MQSubscription;
import java.util.List;
/**
* 分块索引同步消息消费者。
*/
@Component
public class DocumentChunkSyncTaskConsumer implements MQConsumerHandler {
private static final Logger LOG = LoggerFactory.getLogger(DocumentChunkSyncTaskConsumer.class);
private final DocumentChunkSyncTaskAppService appService;
private final MQProperties mqProperties;
public DocumentChunkSyncTaskConsumer(
DocumentChunkSyncTaskAppService appService,
MQProperties mqProperties
) {
this.appService = appService;
this.mqProperties = mqProperties;
}
@Override
public MQSubscription subscription() {
MQSubscription subscription = new MQSubscription();
subscription.setTopic(DocumentChunkSyncTaskProducer.TOPIC);
subscription.setConsumerGroup(DocumentChunkSyncTaskProducer.GROUP);
subscription.setShardCount(Math.max(
mqProperties.getRedis().getChatPersistShardCount(),
1
));
subscription.setBatchEnabled(false);
return subscription;
}
@Override
public void handle(List<MQMessage> messages) {
for (MQMessage message : messages) {
DocumentChunkSyncTaskMessage event = JSON.parseObject(
message.getBody(),
DocumentChunkSyncTaskMessage.class
);
if (event == null || event.getTaskId() == null) {
LOG.warn("忽略非法分块索引同步消息: messageId={}", message.getMessageId());
continue;
}
try {
appService.handleTask(event.getTaskId());
} catch (RuntimeException exception) {
LOG.error("分块索引同步任务状态处理失败: taskId={}", event.getTaskId(), exception);
throw new MQDeferException("分块索引同步任务暂时无法处理", exception);
}
}
}
}

View File

@@ -0,0 +1,18 @@
package tech.easyflow.ai.documentchunk;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.Date;
/**
* 分块索引同步消息。
*/
public class DocumentChunkSyncTaskMessage implements Serializable {
private BigInteger taskId;
private Date occurredAt;
public BigInteger getTaskId() { return taskId; }
public void setTaskId(BigInteger taskId) { this.taskId = taskId; }
public Date getOccurredAt() { return occurredAt; }
public void setOccurredAt(Date occurredAt) { this.occurredAt = occurredAt; }
}

View File

@@ -0,0 +1,33 @@
package tech.easyflow.ai.documentchunk;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import tech.easyflow.common.cache.DistributedScheduledLock;
/**
* 补投待同步任务并回收过期租约。
*/
@Component
public class DocumentChunkSyncTaskMonitor {
private static final Logger LOG = LoggerFactory.getLogger(DocumentChunkSyncTaskMonitor.class);
private final DocumentChunkSyncTaskAppService appService;
public DocumentChunkSyncTaskMonitor(DocumentChunkSyncTaskAppService appService) {
this.appService = appService;
}
@Scheduled(fixedDelayString = "${easyflow.ai.document-chunk-sync.dispatch-interval:2s}",
initialDelayString = "${easyflow.ai.document-chunk-sync.dispatch-interval:2s}")
@DistributedScheduledLock(key = "easyflow:schedule:document-chunk-index-sync", leaseSeconds = 2L)
public void dispatchPendingTasks() {
try {
appService.dispatchPendingTasks();
} catch (RuntimeException exception) {
LOG.error("分块索引同步补投失败", exception);
}
}
}

View File

@@ -0,0 +1,39 @@
package tech.easyflow.ai.documentchunk;
import com.alibaba.fastjson2.JSON;
import org.springframework.stereotype.Service;
import tech.easyflow.common.mq.core.MQMessage;
import tech.easyflow.common.mq.core.MQProducer;
import java.math.BigInteger;
import java.util.Date;
/**
* 分块索引同步消息生产者。
*/
@Service
public class DocumentChunkSyncTaskProducer {
static final String TOPIC = "document-chunk-index-sync";
static final String GROUP = "document-chunk-index-sync-group";
private final MQProducer mqProducer;
public DocumentChunkSyncTaskProducer(MQProducer mqProducer) {
this.mqProducer = mqProducer;
}
public void send(BigInteger taskId) {
Date now = new Date();
DocumentChunkSyncTaskMessage event = new DocumentChunkSyncTaskMessage();
event.setTaskId(taskId);
event.setOccurredAt(now);
MQMessage message = new MQMessage();
message.setMessageId("chunk-sync-" + taskId);
message.setTopic(TOPIC);
message.setKey(String.valueOf(taskId));
message.setCreatedAt(now);
message.setBody(JSON.toJSONString(event));
mqProducer.send(message);
}
}

Some files were not shown because too many files have changed in this diff Show More