Compare commits

9 Commits

Author SHA1 Message Date
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
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
533 changed files with 4971 additions and 63186 deletions

View File

@@ -40,10 +40,6 @@
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-job</artifactId>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-dataspace</artifactId>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-common-captcha</artifactId>

View File

@@ -1,18 +1,23 @@
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.BaseController;
import tech.easyflow.common.web.controller.BaseCurdController;
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;
@@ -23,8 +28,12 @@ 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;
/**
* 控制层。
@@ -35,12 +44,19 @@ import java.util.List;
@RestController
@RequestMapping("/api/v1/documentChunk")
@UsePermission(moduleName = "/api/v1/documentCollection")
public class DocumentChunkController extends BaseController {
public class DocumentChunkController extends BaseCurdController<DocumentChunkService, DocumentChunk> {
private final DocumentChunkService documentChunkService;
@Resource
DocumentCollectionService documentCollectionService;
@Resource
ModelService modelService;
@Resource
DocumentChunkService documentChunkService;
public DocumentChunkController(DocumentChunkService service) {
this.documentChunkService = service;
super(service);
}
@GetMapping("page")
@@ -52,30 +68,9 @@ public class DocumentChunkController extends BaseController {
idExpr = "#request.getParameter('documentId')",
denyMessage = "无权限访问知识库"
)
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
));
@Override
public Result<Page<DocumentChunk>> page(HttpServletRequest request, String sortKey, String sortType, Long pageNumber, Long pageSize) {
return super.page(request, sortKey, sortType, pageNumber, pageSize);
}
@PostMapping("update")
@@ -84,23 +79,43 @@ public class DocumentChunkController extends BaseController {
resource = CategoryResourceType.KNOWLEDGE,
action = ResourceAction.MANAGE,
lookup = ResourceLookup.DOCUMENT_CHUNK_ID,
idExpr = "#request.id",
idExpr = "#documentChunk.id",
denyMessage = "无权限管理知识库"
)
public Result<?> update(
@JsonBody(required = true, skipConvertError = false)
DocumentChunkContentUpdateRequest request
) {
DocumentChunk current = documentChunkService.getById(request.getId());
if (current == null) {
return Result.fail(1, "记录不存在");
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, "知识库不存在");
}
DocumentChunk updated = documentChunkService.updateContent(
current.getDocumentCollectionId(),
current.getId(),
request.getContent()
);
return Result.ok(updated);
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);
}
}
return Result.ok(false);
}
@PostMapping("removeChunk")
@@ -112,58 +127,36 @@ public class DocumentChunkController extends BaseController {
idExpr = "#chunkId",
denyMessage = "无权限管理知识库"
)
public Result<?> removeChunk(@JsonBody(value = "id", required = true) BigInteger chunkId) {
public Result<?> remove(@JsonBody(value = "id", required = true) BigInteger chunkId) {
DocumentChunk docChunk = documentChunkService.getById(chunkId);
if (docChunk == null) {
return Result.fail(1, "记录不存在");
}
return Result.ok(documentChunkService.deleteChunk(
docChunk.getDocumentCollectionId(),
chunkId
));
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);
@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()
));
return super.remove(chunkId);
} finally {
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
}
}
}

View File

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

View File

@@ -18,12 +18,10 @@ 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;
@@ -31,7 +29,6 @@ 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;
@@ -94,15 +91,11 @@ 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")
@@ -222,7 +215,6 @@ 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));
}
@@ -237,33 +229,10 @@ 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) {
assertPluginTestExecutionOwnership(executeId);
workflowResumeService.resume(executeId, confirmParams);
chainExecutor.resumeAsync(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,6 +1,10 @@
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;
@@ -18,11 +22,6 @@ 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;
@@ -44,6 +43,7 @@ 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,6 +62,7 @@ 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;
@@ -504,27 +505,43 @@ public class ShareKnowledgeController {
@PostMapping("/documentChunk/update")
public Result<?> updateDocumentChunk(
@RequestParam String shareKey,
@JsonBody(required = true, skipConvertError = false)
DocumentChunkContentUpdateRequest request
@JsonBody DocumentChunk documentChunk
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
null,
KnowledgeShareActionScope.CONTENT_UPDATE.name()
);
DocumentChunk current = documentChunkService.getById(request.getId());
DocumentChunk current = documentChunkService.getById(documentChunk.getId());
if (current == null || current.getDocumentCollectionId() == null
|| current.getDocumentCollectionId().compareTo(context.getKnowledge().getId()) != 0) {
throw new BusinessException("记录不存在");
}
DocumentChunk updated = documentChunkService.updateContent(
context.getKnowledge().getId(),
current.getId(),
request.getContent()
);
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", request.getId()));
return Result.ok(updated);
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", documentChunk.getId()));
return Result.ok(result);
} finally {
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
}
}
return Result.ok(false);
}
/**
@@ -545,50 +562,25 @@ public class ShareKnowledgeController {
|| current.getDocumentCollectionId().compareTo(context.getKnowledge().getId()) != 0) {
return Result.fail(1, "记录不存在");
}
DocumentChunkDeleteResult removed = documentChunkService.deleteChunk(
context.getKnowledge().getId(),
chunkId
);
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, "知识库没有配置向量模型");
}
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(removed);
return Result.ok(true);
} finally {
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
}
@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("文档不存在");
}
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,5 +1,6 @@
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;
@@ -13,7 +14,6 @@ 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,8 +64,6 @@ public class WorkflowChatController {
@Resource
private ChainExecutor chainExecutor;
@Resource
private WorkflowResumeService workflowResumeService;
@Resource
private WorkflowExecResultService execResultService;
@Resource
private WorkflowExecStepService execStepService;
@@ -173,8 +171,19 @@ public class WorkflowChatController {
@JsonBody("confirmParams")
Map<String, Object> confirmParams
) {
assertExecutionOwnership(executeId);
workflowResumeService.resume(executeId, 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)
);
return Result.ok();
}

View File

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

View File

@@ -1,117 +0,0 @@
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,7 +1,6 @@
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;
@@ -97,10 +96,11 @@ public class WorkflowShareController {
* @return 工作流标识
*/
@GetMapping("/resolve")
@SaIgnore
public Result<Map<String, BigInteger>> resolveUrlShare(HttpServletRequest request) {
WorkflowShare share = workflowShareService.resolvePublicChatShare(
request.getHeader(WorkflowSharePolicy.CHAT_SHARE_KEY_HEADER)
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
WorkflowShare share = workflowShareService.resolveChatShare(
request.getHeader(WorkflowSharePolicy.CHAT_SHARE_KEY_HEADER),
loginAccount.getTenantId()
);
return Result.ok(Map.of("workflowId", share.getWorkflowId()));
}

View File

@@ -24,11 +24,6 @@ import java.util.List;
@RequestMapping("/api/v1/datacenterDataset")
public class DatacenterDatasetController {
/** 对外 Schema 接口的默认字段页码。 */
private static final long DEFAULT_FIELD_PAGE_NUMBER = 1L;
/** 对外 Schema 接口的默认字段页大小。 */
private static final long DEFAULT_FIELD_PAGE_SIZE = 200L;
@Resource
private DatacenterDatasetQueryService queryService;
@Resource
@@ -37,18 +32,13 @@ public class DatacenterDatasetController {
@PostMapping("/queryPage")
@SaCheckPermission("/api/v1/datacenterSource/query")
public Result<Page<Row>> queryPage(@RequestBody DatacenterQueryRequest request) {
return Result.ok(queryService.queryPage(
request, SaTokenUtil.getLoginAccount()));
return Result.ok(queryService.queryPage(request));
}
@GetMapping("/schema")
@SaCheckPermission("/api/v1/datacenterSource/query")
public Result<DatacenterSchemaResponse> schema(
DatasetRef datasetRef,
@RequestParam(defaultValue = "1") Long fieldPageNumber,
@RequestParam(defaultValue = "200") Long fieldPageSize) {
return Result.ok(queryService.getSchema(
datasetRef, fieldPageNumber, fieldPageSize));
public Result<DatacenterSchemaResponse> schema(DatasetRef datasetRef) {
return Result.ok(queryService.getSchema(datasetRef));
}
@GetMapping("/managedTables")
@@ -73,13 +63,6 @@ public class DatacenterDatasetController {
request == null ? List.of() : request.getFields(),
account
);
return Result.ok(queryService.getSchema(
registryService.resolveDatasetRef(table.getId()),
request == null || request.getFieldPageNumber() == null
? DEFAULT_FIELD_PAGE_NUMBER
: request.getFieldPageNumber(),
request == null || request.getFieldPageSize() == null
? DEFAULT_FIELD_PAGE_SIZE
: request.getFieldPageSize()));
return Result.ok(queryService.getSchema(registryService.resolveDatasetRef(table.getId())));
}
}

View File

@@ -1,84 +0,0 @@
package tech.easyflow.admin.controller.datacenter;
import cn.dev33.satoken.annotation.SaCheckPermission;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.datacenter.execution.model.DatacenterSqlConsoleRequest;
import tech.easyflow.datacenter.execution.model.DatacenterSqlConsoleResult;
import tech.easyflow.datacenter.execution.model.DatacenterSqlCancelRequest;
import tech.easyflow.datacenter.federation.DatacenterFederationQueryService;
import tech.easyflow.datacenter.federation.DatacenterFederationQueryCancellationService;
import tech.easyflow.datacenter.meta.entity.DatacenterSource;
import tech.easyflow.datacenter.meta.service.DatacenterDatasetRegistryService;
/**
* 数据中枢管理端只读 SQL 控制台。
*/
@RestController
@RequestMapping("/api/v1/datacenterQuery")
public class DatacenterQueryController {
private final DatacenterDatasetRegistryService registryService;
private final DatacenterFederationQueryService queryService;
private final DatacenterFederationQueryCancellationService cancellationService;
/**
* 创建查询 Controller。
*
* @param registryService 数据集注册服务
* @param queryService Federation 查询服务
* @param cancellationService 跨节点查询取消服务
*/
public DatacenterQueryController(
DatacenterDatasetRegistryService registryService,
DatacenterFederationQueryService queryService,
DatacenterFederationQueryCancellationService cancellationService) {
this.registryService = registryService;
this.queryService = queryService;
this.cancellationService = cancellationService;
}
/**
* 执行一条受 Calcite 与业务 Policy 校验的只读 SQL。
*
* @param request 查询请求
* @return 有界查询结果
*/
@PostMapping("/execute")
@SaCheckPermission("/api/v1/datacenterSource/query")
public Result<DatacenterSqlConsoleResult> execute(
@RequestBody DatacenterSqlConsoleRequest request) {
LoginAccount account = SaTokenUtil.getLoginAccount();
DatacenterSource source = registryService.getSourceRequired(
request == null ? null : request.sourceId());
return Result.ok(queryService.execute(
source,
request == null ? null : request.sql(),
java.util.List.of(),
request == null ? null : request.maxRows(),
account,
"MANUAL",
account == null || account.getId() == null
? null : account.getId().toString(),
request == null ? null : request.queryId()));
}
/**
* 取消当前租户在任一节点执行的 SQL 查询。
*
* @param request 取消请求
* @return 是否在本地或集群中接受取消提示
*/
@PostMapping("/cancel")
@SaCheckPermission("/api/v1/datacenterSource/query")
public Result<Boolean> cancel(@RequestBody DatacenterSqlCancelRequest request) {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(cancellationService.cancel(
request == null ? null : request.queryId(), account));
}
}

View File

@@ -8,16 +8,10 @@ import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.datacenter.entity.DatacenterTable;
import tech.easyflow.datacenter.execution.model.DatacenterConnectionTestResult;
import tech.easyflow.datacenter.meta.entity.DatacenterSource;
import tech.easyflow.datacenter.meta.model.DatacenterBatchRegisterRequest;
import tech.easyflow.datacenter.meta.model.DatacenterCatalogMeta;
import tech.easyflow.datacenter.meta.model.DatacenterMetadataPage;
import tech.easyflow.datacenter.meta.model.DatacenterRemoveSourceRequest;
import tech.easyflow.datacenter.meta.model.DatacenterSourceActivateRequest;
import tech.easyflow.datacenter.meta.model.DatacenterSourceCandidateMetadataRequest;
import tech.easyflow.datacenter.meta.model.DatacenterSourceCandidateCatalogRequest;
import tech.easyflow.datacenter.meta.model.DatacenterSourceDraftRequest;
import tech.easyflow.datacenter.meta.model.DatacenterSourceReconfigureRequest;
import tech.easyflow.datacenter.meta.model.DatacenterSourceView;
import tech.easyflow.datacenter.meta.model.DatacenterTableDetailMeta;
import tech.easyflow.datacenter.meta.service.DatacenterSourceService;
@@ -25,9 +19,6 @@ import javax.annotation.Resource;
import java.math.BigInteger;
import java.util.List;
/**
* 数据源绑定、生命周期与元数据浏览接口。
*/
@RestController
@RequestMapping("/api/v1/datacenterSource")
public class DatacenterSourceController {
@@ -35,100 +26,23 @@ public class DatacenterSourceController {
@Resource
private DatacenterSourceService sourceService;
@PostMapping("/draft")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<DatacenterSourceView> saveDraft(@RequestBody DatacenterSourceDraftRequest request) {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(sourceService.saveDraft(request, account));
}
@PostMapping("/{sourceId}/probe")
@PostMapping("/testConnection")
@SaCheckPermission("/api/v1/datacenterSource/query")
public Result<DatacenterConnectionTestResult> probe(@PathVariable BigInteger sourceId) {
public Result<DatacenterConnectionTestResult> testConnection(@RequestBody DatacenterSource source) {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(sourceService.probe(sourceId, account));
return Result.ok(sourceService.testConnection(source, account));
}
@PostMapping("/activate")
@PostMapping("/save")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<DatacenterSourceView> activate(@RequestBody DatacenterSourceActivateRequest request) {
public Result<DatacenterSource> save(@RequestBody DatacenterSource source) {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(sourceService.activate(request, account));
}
/**
* 探测活动数据源的未发布候选配置。
*
* @param request 候选连接配置
* @return 连接探测结果
*/
@PostMapping("/candidate/probe")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<DatacenterConnectionTestResult> probeCandidate(
@RequestBody DatacenterSourceDraftRequest request) {
return Result.ok(sourceService.probeCandidate(
request, SaTokenUtil.getLoginAccount()));
}
/**
* 浏览活动数据源候选配置可访问的命名空间。
*
* @param request 候选连接配置
* @return 命名空间列表
*/
@PostMapping("/candidate/catalogs")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<List<DatacenterCatalogMeta>> candidateCatalogs(
@RequestBody DatacenterSourceDraftRequest request) {
return Result.ok(sourceService.listCandidateCatalogs(
request, SaTokenUtil.getLoginAccount()));
}
/**
* 分页浏览候选配置可访问的命名空间。
*
* @param request 候选配置和分页条件
* @return 有界命名空间列表
*/
@PostMapping("/candidate/catalogs/page")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<DatacenterMetadataPage<DatacenterCatalogMeta>> candidateCatalogsPage(
@RequestBody DatacenterSourceCandidateCatalogRequest request) {
return Result.ok(sourceService.listCandidateCatalogsPage(
request, SaTokenUtil.getLoginAccount()));
}
/**
* 分页浏览活动数据源候选配置可访问的表。
*
* @param request 候选配置与分页条件
* @return 有界表列表
*/
@PostMapping("/candidate/tables")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<DatacenterMetadataPage<DatacenterTable>> candidateTables(
@RequestBody DatacenterSourceCandidateMetadataRequest request) {
return Result.ok(sourceService.listCandidateTables(
request, SaTokenUtil.getLoginAccount()));
}
/**
* 原地发布活动数据源的新连接配置和纳管范围。
*
* @param request 重配置请求
* @return 发布后的数据源视图
*/
@PostMapping("/reconfigure")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<DatacenterSourceView> reconfigure(
@RequestBody DatacenterSourceReconfigureRequest request) {
return Result.ok(sourceService.reconfigure(
request, SaTokenUtil.getLoginAccount()));
return Result.ok(sourceService.saveSource(source, account));
}
@GetMapping("/page")
@SaCheckPermission("/api/v1/datacenterSource/query")
public Result<Page<DatacenterSourceView>> page(Long pageNumber, Long pageSize) {
public Result<Page<DatacenterSource>> page(Long pageNumber, Long pageSize) {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(sourceService.pageSources(pageNumber, pageSize, account));
}
@@ -140,53 +54,19 @@ public class DatacenterSourceController {
return Result.ok(sourceService.listCatalogs(sourceId, account));
}
/**
* 分页浏览当前数据源的命名空间。
*
* @param sourceId 数据源 ID
* @param keyword 名称搜索词
* @param pageNumber 页码
* @param pageSize 每页大小
* @return 有界命名空间列表
*/
@GetMapping("/catalogs/page")
@SaCheckPermission("/api/v1/datacenterSource/query")
public Result<DatacenterMetadataPage<DatacenterCatalogMeta>> catalogsPage(
BigInteger sourceId,
String keyword,
Long pageNumber,
Long pageSize) {
return Result.ok(sourceService.listCatalogsPage(
sourceId, keyword, pageNumber, pageSize,
SaTokenUtil.getLoginAccount()));
}
@GetMapping("/tables")
@SaCheckPermission("/api/v1/datacenterSource/query")
public Result<DatacenterMetadataPage<DatacenterTable>> tables(
BigInteger sourceId,
String catalogName,
String keyword,
Long pageNumber,
Long pageSize) {
public Result<List<DatacenterTable>> tables(BigInteger sourceId, String catalogName) {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(sourceService.listTables(
sourceId, catalogName, keyword, pageNumber, pageSize, account));
return Result.ok(sourceService.listTables(sourceId, catalogName, account));
}
@GetMapping("/tableDetail")
@SaCheckPermission("/api/v1/datacenterSource/query")
public Result<DatacenterTableDetailMeta> tableDetail(
BigInteger sourceId,
String catalogName,
String tableName,
@RequestParam(defaultValue = "false") boolean register,
Long fieldPageNumber,
Long fieldPageSize) {
public Result<DatacenterTableDetailMeta> tableDetail(BigInteger sourceId, String catalogName, String tableName,
@RequestParam(defaultValue = "false") boolean register) {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(sourceService.getTableDetail(
sourceId, catalogName, tableName, register,
fieldPageNumber, fieldPageSize, account));
return Result.ok(sourceService.getTableDetail(sourceId, catalogName, tableName, register, account));
}
@PostMapping("/registerBatch")
@@ -203,44 +83,4 @@ public class DatacenterSourceController {
sourceService.removeSource(request == null ? null : request.getSourceId(), account);
return Result.ok();
}
/**
* 停用活动数据源。
*
* @param sourceId 数据源 ID
* @return 停用后的数据源视图
*/
@PostMapping("/{sourceId}/disable")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<DatacenterSourceView> disable(@PathVariable BigInteger sourceId) {
return Result.ok(sourceService.disable(
sourceId, SaTokenUtil.getLoginAccount()));
}
/**
* 重新启用已停用数据源。
*
* @param sourceId 数据源 ID
* @return 启用后的数据源视图
*/
@PostMapping("/{sourceId}/enable")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<DatacenterSourceView> enable(@PathVariable BigInteger sourceId) {
return Result.ok(sourceService.enable(
sourceId, SaTokenUtil.getLoginAccount()));
}
/**
* 刷新已纳管对象的 JDBC 元数据观测状态。
*
* @param sourceId 数据源 ID
* @return 刷新后的数据源视图
*/
@PostMapping("/{sourceId}/metadata/refresh")
@SaCheckPermission("/api/v1/datacenterSource/save")
public Result<DatacenterSourceView> refreshMetadata(
@PathVariable BigInteger sourceId) {
return Result.ok(sourceService.refreshMetadata(
sourceId, SaTokenUtil.getLoginAccount()));
}
}

View File

@@ -1,161 +0,0 @@
package tech.easyflow.admin.controller.dataspace;
import cn.dev33.satoken.annotation.SaCheckPermission;
import java.math.BigInteger;
import java.util.List;
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.RestController;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.dataspace.model.ConnectionDefinition;
import tech.easyflow.dataspace.model.ConnectionView;
import tech.easyflow.dataspace.model.ObjectView;
import tech.easyflow.dataspace.provider.DataspaceProbe;
import tech.easyflow.dataspace.service.DataspaceConnectionService;
/**
* 数据空间物理连接管理端 API。
*/
@RestController
@RequestMapping("/api/v1/dataspaceConnection")
public class DataspaceConnectionController {
private final DataspaceConnectionService connectionService;
/**
* 创建连接控制器。
*
* @param connectionService 连接服务
*/
public DataspaceConnectionController(DataspaceConnectionService connectionService) {
this.connectionService = connectionService;
}
/**
* 查询当前租户连接列表。
*
* @param keyword 搜索关键词
* @return 连接列表
*/
@GetMapping("/list")
@SaCheckPermission("/api/v1/dataspaceConnection/query")
public Result<List<ConnectionView>> list(String keyword) {
return Result.ok(connectionService.list(keyword));
}
/**
* 获取连接详情。
*
* @param id 连接 ID
* @return 连接详情
*/
@GetMapping("/detail")
@SaCheckPermission("/api/v1/dataspaceConnection/query")
public Result<ConnectionView> detail(BigInteger id) {
return Result.ok(connectionService.detail(id));
}
/**
* 测试候选或已保存连接。
*
* @param definition 连接定义
* @return 测试结果
*/
@PostMapping("/test")
@SaCheckPermission("/api/v1/dataspaceConnection/test")
public Result<DataspaceProbe> test(
@JsonBody(required = true, skipConvertError = false) ConnectionDefinition definition) {
return Result.ok(connectionService.test(definition));
}
/**
* 创建或更新连接。
*
* @param definition 连接定义
* @return 保存后的连接
*/
@PostMapping("/save")
@SaCheckPermission("/api/v1/dataspaceConnection/save")
public Result<ConnectionView> save(
@JsonBody(required = true, skipConvertError = false) ConnectionDefinition definition) {
return Result.ok(connectionService.save(definition));
}
/**
* 启用或禁用连接。
*
* @param request 状态变更请求
* @return 变更后的连接
*/
@PostMapping("/status")
@SaCheckPermission("/api/v1/dataspaceConnection/save")
public Result<ConnectionView> status(
@JsonBody(required = true, skipConvertError = false) StatusRequest request) {
if (request == null || request.enabled() == null) {
throw new BusinessException("连接状态不能为空");
}
return Result.ok(connectionService.setEnabled(request.id(), request.enabled()));
}
/**
* 查询当前连接的对象树数据。
*
* @param connectionId 连接 ID
* @param keyword Schema 或表名关键词
* @return 对象列表
*/
@GetMapping("/objects")
@SaCheckPermission("/api/v1/dataspaceConnection/query")
public Result<List<ObjectView>> objects(BigInteger connectionId, String keyword) {
return Result.ok(connectionService.objects(connectionId, keyword));
}
/**
* 刷新连接元数据。
*
* @param request 刷新请求
* @return 新 revision 对象列表
*/
@PostMapping("/refreshMetadata")
@SaCheckPermission("/api/v1/dataspaceConnection/metadata")
public Result<List<ObjectView>> refreshMetadata(
@JsonBody(required = true, skipConvertError = false) RefreshRequest request) {
return Result.ok(connectionService.refreshMetadata(
request.connectionId(), request.expectedRevision()));
}
/**
* 删除未被引用的连接。
*
* @param id 连接 ID
* @return 成功结果
*/
@PostMapping("/remove")
@SaCheckPermission("/api/v1/dataspaceConnection/remove")
public Result<Void> remove(
@JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id) {
connectionService.remove(id);
return Result.ok();
}
/**
* 元数据刷新请求。
*
* @param connectionId 连接 ID
* @param expectedRevision 期望 revision
*/
public record RefreshRequest(BigInteger connectionId, long expectedRevision) {
}
/**
* 连接状态变更请求。
*
* @param id 连接 ID
* @param enabled 是否启用
*/
public record StatusRequest(BigInteger id, Boolean enabled) {
}
}

View File

@@ -1,113 +0,0 @@
package tech.easyflow.admin.controller.dataspace;
import cn.dev33.satoken.annotation.SaCheckPermission;
import java.math.BigInteger;
import java.util.List;
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.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.dataspace.model.DataspaceDefinition;
import tech.easyflow.dataspace.model.DataspaceSummary;
import tech.easyflow.dataspace.model.DataspaceView;
import tech.easyflow.dataspace.service.DataspaceService;
/**
* 虚拟数据空间管理端 API。
*/
@RestController
@RequestMapping("/api/v1/dataspace")
public class DataspaceController {
private final DataspaceService dataspaceService;
/**
* 创建数据空间控制器。
*
* @param dataspaceService 数据空间服务
*/
public DataspaceController(DataspaceService dataspaceService) {
this.dataspaceService = dataspaceService;
}
/**
* 查询数据空间列表。
*
* @param keyword 搜索关键词
* @return 数据空间摘要
*/
@GetMapping("/list")
@SaCheckPermission("/api/v1/dataspace/query")
public Result<List<DataspaceSummary>> list(String keyword) {
return Result.ok(dataspaceService.list(keyword));
}
/**
* 获取数据空间当前 revision 详情。
*
* @param id 数据空间 ID
* @return 数据空间详情
*/
@GetMapping("/detail")
@SaCheckPermission("/api/v1/dataspace/detail")
public Result<DataspaceView> detail(BigInteger id) {
return Result.ok(dataspaceService.detail(id));
}
/**
* 保存数据空间并生成新 revision。
*
* @param definition 数据空间定义
* @return 保存后的详情
*/
@PostMapping("/save")
@SaCheckPermission("/api/v1/dataspace/save")
public Result<DataspaceView> save(
@RequestBody DataspaceDefinition definition) {
return Result.ok(dataspaceService.save(definition));
}
/**
* 启用或禁用数据空间。
*
* @param request 状态变更请求
* @return 成功结果
*/
@PostMapping("/status")
@SaCheckPermission("/api/v1/dataspace/save")
public Result<Void> status(
@JsonBody(required = true, skipConvertError = false) StatusRequest request) {
if (request == null || request.enabled() == null) {
throw new BusinessException("数据空间状态不能为空");
}
dataspaceService.setEnabled(request.id(), request.enabled());
return Result.ok();
}
/**
* 逻辑删除数据空间。
*
* @param id 数据空间 ID
* @return 成功结果
*/
@PostMapping("/remove")
@SaCheckPermission("/api/v1/dataspace/remove")
public Result<Void> remove(
@JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id) {
dataspaceService.remove(id);
return Result.ok();
}
/**
* 数据空间状态变更请求。
*
* @param id 数据空间 ID
* @param enabled 是否启用
*/
public record StatusRequest(BigInteger id, Boolean enabled) {
}
}

View File

@@ -1,86 +0,0 @@
package tech.easyflow.admin.controller.dataspace;
import cn.dev33.satoken.annotation.SaCheckPermission;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.dataspace.model.DataspaceExplainResult;
import tech.easyflow.dataspace.model.DataspaceQueryRequest;
import tech.easyflow.dataspace.model.DataspaceQueryResult;
import tech.easyflow.dataspace.model.DataspaceSqlCompletionRequest;
import tech.easyflow.dataspace.model.DataspaceSqlCompletionResult;
import tech.easyflow.dataspace.service.DataspaceQueryService;
/**
* 数据空间 SQL 工作台 Query、Explain、Complete 与 Cancel API。
*/
@RestController
@RequestMapping("/api/v1/dataspaceSql")
public class DataspaceSqlController {
private final DataspaceQueryService queryService;
/**
* 创建 SQL 控制器。
*
* @param queryService 查询服务
*/
public DataspaceSqlController(DataspaceQueryService queryService) {
this.queryService = queryService;
}
/**
* 执行只读 SQL。
*
* @param request 查询请求
* @return 查询结果与指标
*/
@PostMapping("/query")
@SaCheckPermission("/api/v1/dataspaceSql/query")
public Result<DataspaceQueryResult> query(
@JsonBody(required = true, skipConvertError = false) DataspaceQueryRequest request) {
return Result.ok(queryService.query(request));
}
/**
* 显式执行非 ANALYZE Explain。
*
* @param request Explain 请求
* @return Explain 与索引信息
*/
@PostMapping("/explain")
@SaCheckPermission("/api/v1/dataspaceSql/explain")
public Result<DataspaceExplainResult> explain(
@JsonBody(required = true, skipConvertError = false) DataspaceQueryRequest request) {
return Result.ok(queryService.explain(request));
}
/**
* 返回当前数据空间内的 Calcite SQL 补全候选。
*
* @param request 补全请求
* @return 补全替换区间与候选
*/
@PostMapping("/complete")
@SaCheckPermission("/api/v1/dataspaceSql/query")
public Result<DataspaceSqlCompletionResult> complete(
@JsonBody(required = true, skipConvertError = false)
DataspaceSqlCompletionRequest request) {
return Result.ok(queryService.complete(request));
}
/**
* 尝试取消当前节点查询。
*
* @param queryId 查询 ID
* @return 是否找到并发起取消
*/
@PostMapping("/cancel")
@SaCheckPermission("/api/v1/dataspaceSql/query")
public Result<Boolean> cancel(
@JsonBody(value = "queryId", required = true, skipConvertError = false) String queryId) {
return Result.ok(queryService.cancel(queryId));
}
}

View File

@@ -1,31 +1,26 @@
package tech.easyflow.admin.controller.job;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.date.DateUtil;
import com.easyagents.flow.core.chain.Parameter;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.beans.factory.annotation.Value;
import org.quartz.CronExpression;
import org.springframework.util.StringUtils;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
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.RestController;
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.service.WorkflowUsageAuthorizationService;
import tech.easyflow.admin.model.SysJobWorkflowOptionView;
import tech.easyflow.common.constant.enums.EnumJobStatus;
import tech.easyflow.common.constant.enums.EnumDataStatus;
import tech.easyflow.common.constant.enums.EnumJobType;
import tech.easyflow.common.constant.enums.EnumMisfirePolicy;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.job.entity.SysJob;
import tech.easyflow.job.job.JobConstant;
import tech.easyflow.job.service.SysJobService;
@@ -37,8 +32,7 @@ import tech.easyflow.system.service.ResourceAccessService;
import java.io.Serializable;
import java.math.BigInteger;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.List;
@@ -67,9 +61,6 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
/** 工作流运行参数解析器。 */
private final WorkflowRunningParameterResolver workflowRunningParameterResolver;
/** 与调度计算一致的 Cron 预览格式化器。 */
private final DateTimeFormatter jobTimeFormatter;
/**
* 创建定时任务控制器。
*
@@ -78,21 +69,17 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
* @param workflowUsageAuthorizationService 工作流使用权限校验服务
* @param resourceAccessService 资源访问控制服务
* @param workflowRunningParameterResolver 工作流运行参数解析器
* @param jobTimezone 定时任务业务时区
*/
public SysJobController(SysJobService service,
WorkflowService workflowService,
WorkflowUsageAuthorizationService workflowUsageAuthorizationService,
ResourceAccessService resourceAccessService,
WorkflowRunningParameterResolver workflowRunningParameterResolver,
@Value("${easyflow.job.timezone:Asia/Shanghai}") String jobTimezone) {
WorkflowRunningParameterResolver workflowRunningParameterResolver) {
super(service);
this.workflowService = workflowService;
this.workflowUsageAuthorizationService = workflowUsageAuthorizationService;
this.resourceAccessService = resourceAccessService;
this.workflowRunningParameterResolver = workflowRunningParameterResolver;
this.jobTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
.withZone(ZoneId.of(jobTimezone));
}
/**
@@ -124,43 +111,18 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
return Result.ok();
}
@GetMapping("/trigger")
@SaCheckPermission("/api/v1/sysJob/save")
@LogRecord("立即执行定时任务")
public Result<String> trigger(BigInteger id) {
LoginAccount account = SaTokenUtil.getLoginAccount();
SysJob job = requireExistingJob(id);
validateWorkflowReference(job, account);
return Result.ok(service.triggerNow(id));
}
@GetMapping("/getNextTimes")
@SaCheckPermission("/api/v1/sysJob/save")
public Result<List<String>> getNextTimes(String cronExpression) {
return Result.ok(service.nextFireTimes(cronExpression, 5).stream()
.map(Date::toInstant)
.map(jobTimeFormatter::format)
.toList());
public Result<List<String>> getNextTimes(String cronExpression) throws Exception{
CronExpression ex = new CronExpression(cronExpression);
List<String> times = new ArrayList<>();
Date date = new Date();
for (int i = 0; i < 5; i++) {
Date next = ex.getNextValidTimeAfter(date);
times.add(DateUtil.formatDateTime(next));
date = next;
}
@Override
@PostMapping("remove")
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public Result<?> remove(@JsonBody(value = "id", required = true) Serializable id) {
service.deleteJob(List.of(id));
return Result.ok(true);
}
@Override
@PostMapping("removeBatch")
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public Result<?> removeBatch(
@JsonBody(value = "ids", required = true) Collection<Serializable> ids) {
if (ids == null || ids.isEmpty()) {
return Result.fail("id不能为空");
}
service.deleteJob(ids);
return Result.ok(true);
return Result.ok(times);
}
/**
@@ -174,19 +136,15 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
LoginAccount account = SaTokenUtil.getLoginAccount();
List<SysJobWorkflowOptionView> options = workflowService.list(QueryWrapper.create()
.eq(Workflow::getTenantId, account.getTenantId())
.eq(Workflow::getPublishStatus, PublishStatus.PUBLISHED.getCode())
.eq(Workflow::getStatus, EnumDataStatus.AVAILABLE.getCode())
.orderBy(Workflow::getModified, false))
.stream()
.filter(workflow -> Objects.equals(workflow.getTenantId(), account.getTenantId()))
.filter(workflow -> workflow.getPublishedSnapshotJson() != null
&& !workflow.getPublishedSnapshotJson().isEmpty())
.filter(workflow -> resourceAccessService.canAccess(
account,
CategoryResourceType.WORKFLOW,
workflow,
ResourceAction.USE))
.map(workflowService::toPublishedView)
.filter(Objects::nonNull)
.map(workflow -> new SysJobWorkflowOptionView(
workflow.getId(),
workflow.getTitle(),
@@ -208,7 +166,7 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
Workflow workflow = workflowUsageAuthorizationService.requireUsableWorkflow(
id,
SaTokenUtil.getLoginAccount(),
"工作流不存在、未发布或无权运行");
"工作流不存在、已禁用或无权运行");
Map<String, Object> result = workflowRunningParameterResolver.buildRunningParametersView(workflow);
if (result == null) {
throw new BusinessException("工作流参数配置无效,请检查工作流后重试");
@@ -224,9 +182,6 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
LoginAccount loginUser = SaTokenUtil.getLoginAccount();
SysJob effectiveEntity = entity;
if (isSave) {
// 新任务固定从 STOP 和第 0 代开始,禁止请求绕过启动协议。
entity.setStatus(EnumJobStatus.STOP.getCode());
entity.setScheduleGeneration(0L);
commonFiled(entity,loginUser.getId(),loginUser.getTenantId(), loginUser.getDeptId());
} else {
SysJob existing = requireExistingJob(entity.getId());
@@ -236,25 +191,9 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
entity.setModifiedBy(loginUser.getId());
}
validateWorkflowReference(effectiveEntity, loginUser);
validateCronExpression(effectiveEntity.getCronExpression());
validateMisfirePolicy(effectiveEntity.getMisfirePolicy());
return super.onSaveOrUpdateBefore(entity, isSave);
}
@Override
protected void onSaveOrUpdateAfter(SysJob entity, boolean isSave) {
service.syncJob(entity.getId());
}
@Override
@PostMapping("update")
public Result<?> update(@JsonBody SysJob entity) {
Result<?> result = onSaveOrUpdateBefore(entity, false);
if (result != null) return result;
service.updateJobDefinition(entity);
return Result.ok();
}
/**
* 校验工作流类型任务引用的工作流可被当前用户运行。
*
@@ -271,7 +210,7 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
Workflow workflow = workflowUsageAuthorizationService.requireUsableWorkflow(
workflowId,
account,
"工作流不存在、未发布或无权运行");
"工作流不存在、已禁用或无权运行");
validateRequiredWorkflowParams(entity, workflow);
}
@@ -304,8 +243,6 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
entity.setDeptId(existing.getDeptId());
entity.setCreated(existing.getCreated());
entity.setCreatedBy(existing.getCreatedBy());
entity.setStatus(existing.getStatus());
entity.setScheduleGeneration(existing.getScheduleGeneration());
}
/**
@@ -323,30 +260,9 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
effective.setJobParams(entity.getJobParams() == null
? existing.getJobParams()
: entity.getJobParams());
effective.setCronExpression(entity.getCronExpression() == null
? existing.getCronExpression()
: entity.getCronExpression());
effective.setMisfirePolicy(entity.getMisfirePolicy() == null
? existing.getMisfirePolicy()
: entity.getMisfirePolicy());
return effective;
}
private void validateMisfirePolicy(Integer misfirePolicy) {
if (!Integer.valueOf(EnumMisfirePolicy.FIRE_ONCE_NOW.getCode()).equals(misfirePolicy)
&& !Integer.valueOf(EnumMisfirePolicy.SKIP.getCode()).equals(misfirePolicy)) {
throw new BusinessException("错过策略只支持恢复后补执行一次或跳过本次");
}
}
private void validateCronExpression(String cronExpression) {
try {
service.nextFireTimes(cronExpression, 1);
} catch (RuntimeException exception) {
throw new BusinessException(400, 1, "Cron 表达式无效", exception);
}
}
/**
* 校验定时任务已填写工作流的全部必填运行参数。
*
@@ -406,4 +322,9 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
return true;
}
@Override
protected Result onRemoveBefore(Collection<Serializable> ids) {
service.deleteJob(ids);
return super.onRemoveBefore(ids);
}
}

View File

@@ -1,29 +1,15 @@
package tech.easyflow.admin.controller.job;
import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryWrapper;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.common.annotation.UsePermission;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.util.StringUtil;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.job.entity.SysJobLog;
import tech.easyflow.job.service.SysJobLogService;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Collection;
import java.util.Date;
import java.util.List;
/**
* 系统任务日志 控制层。
*
@@ -34,112 +20,16 @@ import java.util.List;
@RequestMapping("/api/v1/sysJobLog")
@UsePermission(moduleName = "/api/v1/sysJob")
public class SysJobLogController extends BaseCurdController<SysJobLogService, SysJobLog> {
private static final long DEFAULT_PAGE_SIZE = 10L;
private static final long MAX_PAGE_SIZE = 100L;
private static final DateTimeFormatter QUERY_TIME_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final ZoneId jobZoneId;
public SysJobLogController(
SysJobLogService service,
@Value("${easyflow.job.timezone:Asia/Shanghai}") String jobTimezone) {
public SysJobLogController(SysJobLogService service) {
super(service);
this.jobZoneId = ZoneId.of(jobTimezone);
}
/**
* 构造日志筛选条件,并追加计划触发时间和实际触发时间范围。
*/
@Override
protected QueryWrapper buildQueryWrapper(HttpServletRequest request) {
QueryWrapper queryWrapper = super.buildQueryWrapper(request);
Date scheduledStart = parseQueryTime(
request.getParameter("scheduledStart"), "计划触发开始时间");
Date scheduledEnd = parseQueryTime(
request.getParameter("scheduledEnd"), "计划触发结束时间");
Date actualStart = parseQueryTime(
request.getParameter("actualStart"), "实际触发开始时间");
Date actualEnd = parseQueryTime(
request.getParameter("actualEnd"), "实际触发结束时间");
validateTimeRange(scheduledStart, scheduledEnd, "计划触发时间");
validateTimeRange(actualStart, actualEnd, "实际触发时间");
if (scheduledStart != null) {
queryWrapper.ge(SysJobLog::getScheduledFireTime, scheduledStart);
}
if (scheduledEnd != null) {
queryWrapper.le(SysJobLog::getScheduledFireTime, scheduledEnd);
}
if (actualStart != null) {
queryWrapper.ge(SysJobLog::getActualFireTime, actualStart);
}
if (actualEnd != null) {
queryWrapper.le(SysJobLog::getActualFireTime, actualEnd);
}
return queryWrapper;
}
/**
* 自动刷新只读取当前第一页,不执行分页总数统计。
*/
@GetMapping("refresh")
public Result<List<SysJobLog>> refresh(HttpServletRequest request, Long pageSize) {
QueryWrapper queryWrapper = buildQueryWrapper(request);
queryWrapper.orderBy(buildOrderBy(null, null, getDefaultOrderBy()));
queryWrapper.limit(resolvePageSize(pageSize));
return Result.ok(service.list(queryWrapper));
}
/**
* 最新计划触发记录优先,并用主键保证毫秒时间相同时顺序稳定。
*/
@Override
protected String getDefaultOrderBy() {
return "scheduled_fire_time desc, id desc";
}
@Override
protected Page<SysJobLog> queryPage(
Page<SysJobLog> page, QueryWrapper queryWrapper) {
page.setPageSize(resolvePageSize(page.getPageSize()));
return super.queryPage(page, queryWrapper);
}
@Override
protected Result onSaveOrUpdateBefore(SysJobLog entity, boolean isSave) {
throw new IllegalStateException("定时任务执行记录由系统维护,禁止外部写入");
}
@Override
protected Result onRemoveBefore(Collection<Serializable> ids) {
service.requireTerminal(ids);
return super.onRemoveBefore(ids);
}
private long resolvePageSize(Long pageSize) {
if (pageSize == null || pageSize < 1) {
return DEFAULT_PAGE_SIZE;
}
return Math.min(pageSize, MAX_PAGE_SIZE);
}
private Date parseQueryTime(String value, String fieldName) {
if (!StringUtil.hasText(value)) {
return null;
}
try {
LocalDateTime dateTime = LocalDateTime.parse(value, QUERY_TIME_FORMATTER);
return Date.from(dateTime.atZone(jobZoneId).toInstant());
} catch (DateTimeParseException exception) {
throw new BusinessException(
400, 400, fieldName + "格式不正确", exception);
}
}
private void validateTimeRange(Date start, Date end, String fieldName) {
if (start != null && end != null && start.after(end)) {
throw new BusinessException(400, 400, fieldName + "范围不正确");
LoginAccount loginUser = SaTokenUtil.getLoginAccount();
if (isSave) {
commonFiled(entity,loginUser.getId(),loginUser.getTenantId(), loginUser.getDeptId());
}
return super.onSaveOrUpdateBefore(entity, isSave);
}
}

View File

@@ -52,17 +52,11 @@ 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,
@JsonSerialize(using = ToStringSerializer.class) BigInteger vectorEmbedModelId,
Integer dimensionOfVectorModel,
Boolean vectorStoreEnabled
String description
) {
}
@@ -132,7 +126,6 @@ public record WorkflowDesignerOptionsView(
* 已接入数据集安全选项。
*
* @param id 数据集 ID
* @param tenantId 租户 ID
* @param sourceId 数据源 ID
* @param catalogId 目录 ID
* @param tableName 数据表名称
@@ -140,7 +133,6 @@ public record WorkflowDesignerOptionsView(
*/
public record DatasetOption(
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
@JsonSerialize(using = ToStringSerializer.class) BigInteger tenantId,
@JsonSerialize(using = ToStringSerializer.class) BigInteger sourceId,
@JsonSerialize(using = ToStringSerializer.class) BigInteger catalogId,
String tableName,

View File

@@ -19,17 +19,11 @@ 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;
@@ -46,15 +40,6 @@ 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;
});
/**
* 创建工作流对话事件流服务。
@@ -74,15 +59,6 @@ public class WorkflowChatEventStream {
chainExecutor.addErrorListener(this::onChainError);
}
/**
* 关闭断开会话清理线程并释放残留外部资源。
*/
@PreDestroy
public void shutdown() {
sessions.values().forEach(this::removeSession);
detachedSessionCleaner.shutdownNow();
}
/**
* 启动工作流并返回其 SSE 连接。
*
@@ -91,53 +67,11 @@ public class WorkflowChatEventStream {
* @return SSE 连接
*/
public SseEmitter start(String definitionId, Map<String, Object> variables) {
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));
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));
try {
chainExecutor.executeAsync(
@@ -145,9 +79,6 @@ 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
@@ -161,13 +92,6 @@ public class WorkflowChatEventStream {
return emitter;
}
/**
* 创建 SSE 发送器,便于验证连接生命周期。
*/
SseEmitter createEmitter() {
return new SseEmitter(SSE_TIMEOUT_MILLIS);
}
/**
* 将工作流事件转发到对应执行流。
*
@@ -238,21 +162,20 @@ public class WorkflowChatEventStream {
}
/**
* 分离已经断开的浏览器传输,不影响工作流 Runtime
* 处理 SSE 连接异常,并取消尚未结束的工作流
*
* @param session 流会话
* @param message 取消原因
*/
private void detach(StreamSession session) {
private void disconnect(StreamSession session, String message) {
if (session == null || session.terminal.get()) {
return;
}
session.detachTransport();
if (session.detachedRetention.isZero()
|| session.detachedRetention.isNegative()) {
String executeId = session.executeId;
removeSession(session);
return;
if (executeId != null) {
chainExecutor.cancel(executeId, message);
}
session.scheduleDetachedCleanup();
}
/**
@@ -264,9 +187,6 @@ public class WorkflowChatEventStream {
if (session != null && session.executeId != null) {
sessions.remove(session.executeId, session);
}
if (session != null) {
session.cleanup();
}
}
/**
@@ -311,11 +231,6 @@ 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;
/**
@@ -323,36 +238,8 @@ public class WorkflowChatEventStream {
*
* @param emitter SSE 发送器
*/
private StreamSession(
SseEmitter emitter,
Runnable cleanup,
Duration detachedRetention
) {
private StreamSession(SseEmitter emitter) {
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
);
}
}
/**
@@ -364,38 +251,6 @@ 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;
}
/**
* 处理节点开始事件。
*
@@ -569,10 +424,8 @@ public class WorkflowChatEventStream {
}
send(eventType, data);
removeSession(this);
if (connected.compareAndSet(true, false)) {
emitter.complete();
}
}
/**
* 发送 SSE 事件。
@@ -581,9 +434,6 @@ 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);
@@ -603,7 +453,7 @@ public class WorkflowChatEventStream {
executeId,
error
);
detach(this);
disconnect(this, "运行连接已断开");
}
}
@@ -618,11 +468,9 @@ public class WorkflowChatEventStream {
"message", safeErrorMessage(error)
));
removeSession(this);
if (connected.compareAndSet(true, false)) {
emitter.completeWithError(error);
}
}
}
/**
* 构建带节点信息的事件数据。

View File

@@ -14,7 +14,6 @@ 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;
@@ -76,7 +75,6 @@ public class WorkflowDesignerOptionService {
private final DatacenterSourceService datacenterSourceService;
private final DatacenterDatasetRegistryService datacenterDatasetRegistryService;
private final DatacenterDatasetQueryService datacenterDatasetQueryService;
private final WorkflowKnowledgeContractService workflowKnowledgeContractService;
/**
* 创建工作流设计器选项服务。
@@ -95,7 +93,6 @@ public class WorkflowDesignerOptionService {
* @param datacenterSourceService 数据源服务
* @param datacenterDatasetRegistryService 数据集注册服务
* @param datacenterDatasetQueryService 数据集查询服务
* @param workflowKnowledgeContractService 工作流知识库契约服务
*/
public WorkflowDesignerOptionService(
ModelService modelService,
@@ -111,8 +108,7 @@ public class WorkflowDesignerOptionService {
ResourceAccessService resourceAccessService,
DatacenterSourceService datacenterSourceService,
DatacenterDatasetRegistryService datacenterDatasetRegistryService,
DatacenterDatasetQueryService datacenterDatasetQueryService,
WorkflowKnowledgeContractService workflowKnowledgeContractService) {
DatacenterDatasetQueryService datacenterDatasetQueryService) {
this.modelService = modelService;
this.documentCollectionService = documentCollectionService;
this.pluginService = pluginService;
@@ -127,7 +123,6 @@ public class WorkflowDesignerOptionService {
this.datacenterSourceService = datacenterSourceService;
this.datacenterDatasetRegistryService = datacenterDatasetRegistryService;
this.datacenterDatasetQueryService = datacenterDatasetQueryService;
this.workflowKnowledgeContractService = workflowKnowledgeContractService;
}
/**
@@ -168,7 +163,6 @@ 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<>();
@@ -183,22 +177,14 @@ public class WorkflowDesignerOptionService {
if (data == null) {
continue;
}
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("工作流节点类型与节点数据类型不一致");
}
String nodeType = data.getString("type");
if (nodeType == null || nodeType.isBlank()) {
nodeType = dataType;
nodeType = node.getString("type");
}
if ("llmNode".equals(nodeType)) {
addReferenceId(modelIds, readReferenceId(data, "llmId", "模型"));
} else if ("knowledgeNode".equals(nodeType)) {
List<BigInteger> nodeKnowledgeIds = readKnowledgeReferenceIds(data);
knowledgeIds.addAll(nodeKnowledgeIds);
knowledgeGroups.add(nodeKnowledgeIds);
addReferenceId(knowledgeIds, readReferenceId(data, "knowledgeId", "知识库"));
} else if ("plugin-node".equals(nodeType)) {
BigInteger pluginItemId = readReferenceId(data, "pluginId", "插件工具");
if (pluginItemId != null && checkedPluginItemIds.add(pluginItemId)) {
@@ -212,8 +198,6 @@ public class WorkflowDesignerOptionService {
}
assertModelReferences(modelIds, account);
assertKnowledgeReferences(knowledgeIds, account);
workflowKnowledgeContractService.assertMultiKnowledgeContracts(
knowledgeGroups, account.getTenantId());
}
/**
@@ -334,7 +318,7 @@ public class WorkflowDesignerOptionService {
Workflow workflow = workflowUsageAuthorizationService.requireUsableWorkflow(
childWorkflowId,
account,
"子流程不存在、未发布或无权使用");
"子流程不存在、已禁用或无权使用");
assertContentReferences(workflow.getContent());
ChainDefinition definition = chainParser.parse(
@@ -476,55 +460,17 @@ public class WorkflowDesignerOptionService {
}
private List<WorkflowDesignerOptionsView.KnowledgeOption> listKnowledgeOptions(LoginAccount account) {
List<DocumentCollection> collections = documentCollectionService.list(QueryWrapper.create()
return documentCollectionService.list(QueryWrapper.create()
.eq(DocumentCollection::getTenantId, account.getTenantId())
.orderBy(DocumentCollection::getModified, false));
Set<BigInteger> vectorReadyIds = workflowKnowledgeContractService
.findVectorReadyKnowledgeIds(collections, account.getTenantId());
return collections.stream()
.orderBy(DocumentCollection::getModified, false))
.stream()
.filter(item -> resourceAccessService.canAccess(
account, CategoryResourceType.KNOWLEDGE, item, ResourceAction.USE))
.map(item -> new WorkflowDesignerOptionsView.KnowledgeOption(
item.getId(),
item.getTitle(),
item.getDescription(),
item.getVectorEmbedModelId(),
item.getDimensionOfVectorModel(),
vectorReadyIds.contains(item.getId())))
item.getId(), item.getTitle(), item.getDescription()))
.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);
@@ -572,7 +518,7 @@ public class WorkflowDesignerOptionService {
workflowUsageAuthorizationService.requireUsableWorkflow(
workflowId,
account,
"子流程不存在、未发布或无权使用");
"子流程不存在、已禁用或无权使用");
}
private void assertDatasetReference(
@@ -707,7 +653,6 @@ public class WorkflowDesignerOptionService {
private WorkflowDesignerOptionsView.DatasetOption toDatasetOption(DatacenterTable table) {
return new WorkflowDesignerOptionsView.DatasetOption(
table.getId(),
table.getTenantId(),
table.getSourceId(),
table.getCatalogId(),
table.getTableName(),

View File

@@ -1,165 +0,0 @@
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

@@ -1,17 +0,0 @@
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

@@ -1,121 +0,0 @@
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

@@ -1,326 +0,0 @@
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

@@ -1,325 +0,0 @@
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

@@ -1,92 +0,0 @@
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

@@ -1,120 +0,0 @@
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,27 +7,21 @@ 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;
/**
@@ -74,61 +68,6 @@ 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,50 +3,17 @@ 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");
}
/**
* 验证分享地址保留前端部署基路径。
*
@@ -153,11 +120,4 @@ 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

@@ -1,77 +0,0 @@
package tech.easyflow.admin.controller.dataspace;
import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertNotNull;
import static org.testng.Assert.assertNull;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import org.springframework.web.bind.annotation.RequestBody;
import org.testng.annotations.Test;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.dataspace.model.DataspaceDefinition;
/**
* 数据空间管理接口请求绑定契约测试。
*/
public class DataspaceControllerContractTest {
/**
* 验证保存接口使用 Jackson 请求体绑定,避免嵌套定义残留为 JSONObject。
*
* @throws Exception 反射或 JSON 转换失败时抛出
*/
@Test
public void shouldBindNestedDataspaceDefinitionWithJackson() throws Exception {
Method method = DataspaceController.class.getMethod(
"save", DataspaceDefinition.class);
Parameter parameter = method.getParameters()[0];
assertNotNull(parameter.getAnnotation(RequestBody.class));
assertNull(parameter.getAnnotation(JsonBody.class));
String request = """
{
"name": "网点经营分析",
"tables": [
{
"clientKey": "table:outlet",
"objectId": "1001",
"sourceAlias": "MYSQL_1",
"schemaAlias": "MAIN",
"tableAlias": "outlet",
"positionX": 80,
"positionY": 120
},
{
"clientKey": "table:region",
"objectId": "1002",
"sourceAlias": "PG_1",
"schemaAlias": "PUBLIC",
"tableAlias": "outlet_region",
"positionX": 420,
"positionY": 120
}
],
"relations": [
{
"leftClientKey": "table:outlet",
"rightClientKey": "table:region",
"joinType": "INNER",
"leftColumn": "institution_id",
"rightColumn": "institution_id"
}
]
}
""";
DataspaceDefinition definition = new ObjectMapper().readValue(
request, DataspaceDefinition.class);
assertEquals(2, definition.tables().size());
assertEquals("outlet", definition.tables().get(0).tableAlias());
assertEquals("outlet_region", definition.tables().get(1).tableAlias());
assertEquals(1, definition.relations().size());
assertEquals("institution_id", definition.relations().get(0).leftColumn());
}
}

View File

@@ -1,39 +1,26 @@
package tech.easyflow.admin.controller.job;
import com.easyagents.flow.core.chain.Parameter;
import com.mybatisflex.core.query.QueryWrapper;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
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.enums.PublishStatus;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.service.WorkflowUsageAuthorizationService;
import tech.easyflow.admin.model.SysJobWorkflowOptionView;
import tech.easyflow.common.constant.enums.EnumJobType;
import tech.easyflow.common.constant.enums.EnumJobStatus;
import tech.easyflow.common.constant.enums.EnumMisfirePolicy;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.job.entity.SysJob;
import tech.easyflow.job.job.JobConstant;
import tech.easyflow.job.service.SysJobService;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.ResourceAccessService;
import java.math.BigInteger;
import java.time.Instant;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TimeZone;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
@@ -44,114 +31,6 @@ import static org.mockito.Mockito.when;
*/
public class SysJobControllerTest {
@Test
public void shouldQueryPublishedWorkflowOptionsAndReturnPublishedMetadata() {
BigInteger workflowId = BigInteger.valueOf(501);
LoginAccount account = account();
Workflow raw = new Workflow();
raw.setId(workflowId);
raw.setTenantId(account.getTenantId());
raw.setPublishStatus(PublishStatus.PUBLISHED.getCode());
raw.setPublishedSnapshotJson(Map.of("title", "发布标题"));
raw.setTitle("草稿标题");
Workflow withoutSnapshot = new Workflow();
withoutSnapshot.setId(BigInteger.valueOf(502));
withoutSnapshot.setTenantId(account.getTenantId());
withoutSnapshot.setPublishStatus(PublishStatus.PUBLISHED.getCode());
Workflow published = new Workflow();
published.setId(workflowId);
published.setTitle("发布标题");
published.setDescription("发布描述");
WorkflowService workflowService = mock(WorkflowService.class);
ResourceAccessService resourceAccessService = mock(ResourceAccessService.class);
when(workflowService.list(any(QueryWrapper.class)))
.thenReturn(List.of(raw, withoutSnapshot));
when(resourceAccessService.canAccess(
account,
CategoryResourceType.WORKFLOW,
raw,
ResourceAction.USE)).thenReturn(true);
when(workflowService.toPublishedView(raw)).thenReturn(published);
SysJobController controller = new SysJobController(
mock(SysJobService.class),
workflowService,
mock(WorkflowUsageAuthorizationService.class),
resourceAccessService,
mock(WorkflowRunningParameterResolver.class),
"Asia/Shanghai");
List<SysJobWorkflowOptionView> options;
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
options = controller.workflowOptions().getData();
}
Assert.assertEquals(options.size(), 1);
Assert.assertEquals(options.get(0).title(), "发布标题");
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
verify(workflowService).list(queryCaptor.capture());
String sql = queryCaptor.getValue().toSQL().toLowerCase(Locale.ROOT);
Assert.assertTrue(sql.contains("publish_status"));
Assert.assertFalse(sql.replace("publish_status", "").matches("(?s).*\\bstatus\\b.*"));
}
@Test
public void shouldFormatCronPreviewWithConfiguredTimezone() {
SysJobService service = mock(SysJobService.class);
when(service.nextFireTimes("0 0 9 * * ?", 5))
.thenReturn(List.of(Date.from(Instant.parse("2026-01-01T01:00:00Z"))));
TimeZone previous = TimeZone.getDefault();
try {
TimeZone.setDefault(TimeZone.getTimeZone("UTC"));
Assert.assertEquals(
controller(service).getNextTimes("0 0 9 * * ?").getData().get(0),
"2026-01-01 09:00:00");
} finally {
TimeZone.setDefault(previous);
}
}
@Test
public void shouldForceNewJobToStoppedGenerationZero() {
SysJobController controller = controller(mock(SysJobService.class));
SysJob job = validJavaJob();
job.setStatus(EnumJobStatus.RUNNING.getCode());
job.setScheduleGeneration(99L);
LoginAccount account = account();
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
controller.onSaveOrUpdateBefore(job, true);
}
Assert.assertEquals(job.getStatus(), Integer.valueOf(EnumJobStatus.STOP.getCode()));
Assert.assertEquals(job.getScheduleGeneration(), Long.valueOf(0L));
}
@Test
public void shouldRejectStatusAndGenerationMutationThroughOrdinaryUpdate() {
BigInteger id = BigInteger.valueOf(401);
SysJobService service = mock(SysJobService.class);
SysJob existing = validJavaJob();
existing.setId(id);
existing.setStatus(EnumJobStatus.STOP.getCode());
existing.setScheduleGeneration(8L);
when(service.getById(id)).thenReturn(existing);
SysJob update = validJavaJob();
update.setId(id);
update.setStatus(EnumJobStatus.RUNNING.getCode());
update.setScheduleGeneration(100L);
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account());
controller(service).update(update);
}
Assert.assertEquals(update.getStatus(), Integer.valueOf(EnumJobStatus.STOP.getCode()));
Assert.assertEquals(update.getScheduleGeneration(), Long.valueOf(8L));
verify(service).updateJobDefinition(update);
}
/**
* 验证缺少工作流必填参数时拒绝保存定时任务。
*/
@@ -184,8 +63,7 @@ public class SysJobControllerTest {
workflowService,
workflowAuthorizationService,
resourceAccessService,
parameterResolver,
"Asia/Shanghai"
parameterResolver
);
SysJob job = new SysJob();
job.setJobType(EnumJobType.TINY_FLOW.getCode());
@@ -252,8 +130,7 @@ public class SysJobControllerTest {
workflowService,
workflowAuthorizationService,
resourceAccessService,
parameterResolver,
"Asia/Shanghai"
parameterResolver
);
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
@@ -270,34 +147,4 @@ public class SysJobControllerTest {
org.mockito.ArgumentMatchers.anyString());
}
}
private static SysJobController controller(SysJobService service) {
return new SysJobController(
service,
mock(WorkflowService.class),
mock(WorkflowUsageAuthorizationService.class),
mock(ResourceAccessService.class),
mock(WorkflowRunningParameterResolver.class),
"Asia/Shanghai");
}
private static SysJob validJavaJob() {
SysJob job = new SysJob();
job.setJobName("generation-test");
job.setJobType(EnumJobType.JAVA_CLASS.getCode());
job.setCronExpression("0 0 0 1 1 ? 2099");
job.setMisfirePolicy(EnumMisfirePolicy.SKIP.getCode());
job.setAllowConcurrent(0);
job.setJobParams(Map.of(JobConstant.JAVA_METHOD_KEY,
"tech.easyflow.job.util.JobUtil.test()"));
return job;
}
private static LoginAccount account() {
LoginAccount account = new LoginAccount();
account.setId(BigInteger.ONE);
account.setTenantId(BigInteger.ONE);
account.setDeptId(BigInteger.ONE);
return account;
}
}

View File

@@ -1,103 +0,0 @@
package tech.easyflow.admin.controller.job;
import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryWrapper;
import jakarta.servlet.http.HttpServletRequest;
import org.mockito.ArgumentCaptor;
import org.testng.Assert;
import org.testng.annotations.Test;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.job.entity.SysJobLog;
import tech.easyflow.job.service.SysJobLogService;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link SysJobLogController} 查询与轻量刷新边界测试。
*/
public class SysJobLogControllerTest {
@Test
public void shouldBuildBothFireTimeRanges() {
SysJobLogController controller = controller(mock(SysJobLogService.class));
HttpServletRequest request = emptyRequest();
when(request.getParameter("scheduledStart")).thenReturn("2026-08-31 10:00:00");
when(request.getParameter("scheduledEnd")).thenReturn("2026-08-31 11:00:00");
when(request.getParameter("actualStart")).thenReturn("2026-08-31 10:00:01");
when(request.getParameter("actualEnd")).thenReturn("2026-08-31 11:00:01");
String sql = controller.buildQueryWrapper(request).toSQL().toLowerCase(Locale.ROOT);
Assert.assertEquals(countOccurrences(sql, "scheduled_fire_time"), 2);
Assert.assertEquals(countOccurrences(sql, "actual_fire_time"), 2);
}
@Test(expectedExceptions = BusinessException.class)
public void shouldRejectInvalidFireTime() {
SysJobLogController controller = controller(mock(SysJobLogService.class));
HttpServletRequest request = emptyRequest();
when(request.getParameter("scheduledStart")).thenReturn("2026/08/31 10:00:00");
controller.buildQueryWrapper(request);
}
@Test(expectedExceptions = BusinessException.class)
public void shouldRejectReversedActualFireTimeRange() {
SysJobLogController controller = controller(mock(SysJobLogService.class));
HttpServletRequest request = emptyRequest();
when(request.getParameter("actualStart")).thenReturn("2026-08-31 11:00:00");
when(request.getParameter("actualEnd")).thenReturn("2026-08-31 10:00:00");
controller.buildQueryWrapper(request);
}
@Test
public void shouldClampRefreshAndPageSize() {
SysJobLogService service = mock(SysJobLogService.class);
when(service.list(any(QueryWrapper.class))).thenReturn(List.of());
when(service.page(any(Page.class), any(QueryWrapper.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
SysJobLogController controller = controller(service);
HttpServletRequest request = emptyRequest();
controller.refresh(request, 500L);
Page<SysJobLog> page = controller.queryPage(
new Page<>(1, 500), QueryWrapper.create());
ArgumentCaptor<QueryWrapper> queryCaptor =
ArgumentCaptor.forClass(QueryWrapper.class);
verify(service).list(queryCaptor.capture());
String refreshSql = queryCaptor.getValue().toSQL().toLowerCase(Locale.ROOT);
Assert.assertTrue(refreshSql.contains("limit 100"));
Assert.assertEquals(page.getPageSize(), 100L);
}
@Test
public void shouldUseStableScheduledFireTimeOrder() {
Assert.assertEquals(
controller(mock(SysJobLogService.class)).getDefaultOrderBy(),
"scheduled_fire_time desc, id desc");
}
private static SysJobLogController controller(SysJobLogService service) {
return new SysJobLogController(service, "Asia/Shanghai");
}
private static HttpServletRequest emptyRequest() {
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getParameterMap()).thenReturn(Collections.emptyMap());
return request;
}
private static int countOccurrences(String source, String expected) {
return (source.length() - source.replace(expected, "").length())
/ expected.length();
}
}

View File

@@ -4,19 +4,13 @@ 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;
@@ -75,81 +69,4 @@ 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,10 +6,7 @@ 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;
@@ -132,96 +129,6 @@ 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,
@@ -235,20 +142,6 @@ 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,
@@ -263,40 +156,10 @@ public class WorkflowDesignerOptionServiceTest {
resourceAccessService,
sourceService,
mock(DatacenterDatasetRegistryService.class),
mock(DatacenterDatasetQueryService.class),
new WorkflowKnowledgeContractService(
knowledgeService, modelService)
mock(DatacenterDatasetQueryService.class)
);
}
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

@@ -1,86 +0,0 @@
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

@@ -1,136 +0,0 @@
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

@@ -1,233 +0,0 @@
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

@@ -1,168 +0,0 @@
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,6 +1,10 @@
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;
@@ -14,15 +18,11 @@ 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,7 +33,9 @@ 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;
@@ -79,6 +81,8 @@ public class PublicKnowledgeShareController {
private FaqItemService faqItemService;
@Resource
private FaqCategoryService faqCategoryService;
@Resource
private ModelService modelService;
@Resource(name = "default")
private FileStorageService fileStorageService;
@@ -347,20 +351,37 @@ public class PublicKnowledgeShareController {
public Result<?> updateDocumentChunk(
@RequestHeader("ApiKey") String apiKey,
@JsonBody(value = "knowledgeId", required = true) BigInteger knowledgeId,
@JsonBody(required = true, skipConvertError = false)
DocumentChunk documentChunk,
@JsonBody DocumentChunk documentChunk,
HttpServletRequest request
) {
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeApiPermissionScope.KNOWLEDGE_MAINTENANCE.name());
requireDocumentKnowledge(knowledgeId);
DocumentChunk current = requireDocumentChunk(documentChunk.getId(), knowledgeId);
DocumentChunk updated = documentChunkService.updateContent(
knowledgeId,
current.getId(),
documentChunk.getContent()
);
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(DocumentChunkAsyncUpdateResult.from(updated));
return Result.ok(result);
} finally {
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
}
}
return Result.ok(false);
}
/**
@@ -376,51 +397,25 @@ public class PublicKnowledgeShareController {
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeApiPermissionScope.KNOWLEDGE_MAINTENANCE.name());
requireDocumentKnowledge(knowledgeId);
requireDocumentChunk(chunkId, knowledgeId);
DocumentChunkDeleteResult removed = documentChunkService.deleteChunk(
knowledgeId,
chunkId
);
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(removed != null);
return Result.ok(true);
} finally {
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
}
@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,7 +18,6 @@ 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;
@@ -71,8 +70,6 @@ public class PublicWorkflowController {
@Resource
private WorkflowRunningParameterResolver workflowRunningParameterResolver;
@Resource
private WorkflowResumeService workflowResumeService;
@Resource
private WorkflowApiPermissionService workflowApiPermissionService;
@Resource
private WorkflowExecResultService workflowExecResultService;
@@ -253,7 +250,14 @@ public class PublicWorkflowController {
SysApiKey apiKey = workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
WorkflowExecResult execResult = assertApiKeyExecutionOwnership(apiKey, executeId);
assertWorkflowExecutionResumable(execResult);
workflowResumeService.resume(executeId, confirmParams);
if (!chainExecutor.resumeAsyncIfSuspended(
executeId,
confirmParams)) {
throw new BusinessException(
409,
40901,
"当前执行状态不可恢复,仅暂停中的工作流允许恢复");
}
return Result.ok();
}

View File

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

View File

@@ -66,8 +66,6 @@ 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,7 +12,6 @@ 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;
@@ -45,7 +44,6 @@ public class PublicWorkflowControllerBehaviorTest {
private PublicWorkflowController controller;
private ChainExecutor chainExecutor;
private WorkflowResumeService workflowResumeService;
private TinyFlowService tinyFlowService;
private HttpServletRequest request;
@@ -56,7 +54,6 @@ 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);
@@ -82,10 +79,6 @@ public class PublicWorkflowControllerBehaviorTest {
controller,
"chainExecutor",
chainExecutor);
ReflectionTestUtils.setField(
controller,
"workflowResumeService",
workflowResumeService);
ReflectionTestUtils.setField(
controller,
"tinyFlowService",
@@ -115,12 +108,10 @@ public class PublicWorkflowControllerBehaviorTest {
public void resumeShouldRejectNonSuspendedExecution() {
when(request.getRequestURI()).thenReturn(
"/public-api/workflow/resume");
Mockito.doThrow(new BusinessException(
409,
40901,
"当前执行状态不可恢复,仅暂停中的工作流允许恢复"))
.when(workflowResumeService)
.resume(EXECUTE_ID, Map.of("approved", true));
when(chainExecutor.resumeAsyncIfSuspended(
EXECUTE_ID,
Map.of("approved", true)))
.thenReturn(false);
try {
controller.resume(
@@ -133,7 +124,7 @@ public class PublicWorkflowControllerBehaviorTest {
Assert.assertEquals(40901, exception.getErrorCode());
}
verify(workflowResumeService).resume(
verify(chainExecutor).resumeAsyncIfSuspended(
EXECUTE_ID,
Map.of("approved", true));
}

View File

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

View File

@@ -5,12 +5,9 @@ import tech.easyflow.common.annotation.DictDef;
@DictDef(name = "任务执行结果", code = "jobResult", keyField = "code", labelField = "text")
public enum EnumJobResult {
SUCCESS(1,"成功"),
FAIL(0,"失败"),
PENDING(2,"等待执行"),
RUNNING(3,"执行中"),
DEAD(4,"需人工处理"),
CANCELLED(5,"已取消"),
;
private final int code;

View File

@@ -5,8 +5,10 @@ import tech.easyflow.common.annotation.DictDef;
@DictDef(name = "错过策略", code = "misfirePolicy", keyField = "code", labelField = "text")
public enum EnumMisfirePolicy {
FIRE_ONCE_NOW(2,"恢复后补执行一次"),
SKIP(3,"跳过本次");
DEFAULT(0,"默认"),
MISFIRE_IGNORE_MISFIRES(1,"立即触发"),
MISFIRE_FIRE_AND_PROCEED(2,"立即触发一次"),
MISFIRE_DO_NOTHING(3,"忽略");
;
private final int code;

View File

@@ -1,6 +1,5 @@
package tech.easyflow.common.cache;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
@@ -11,11 +10,6 @@ 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;
/**
@@ -33,13 +27,6 @@ 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(
@@ -107,66 +94,6 @@ 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,9 +11,6 @@ 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} 回归测试。
@@ -150,96 +147,6 @@ 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

@@ -67,9 +67,7 @@ public abstract class AbstractAgentAsyncSubTools implements AsyncSubTools {
* @param arguments 调用参数
* @return 执行结果
*/
protected abstract AgentToolExecutionResult executeBusiness(
Map<String, Object> arguments,
AgentToolContext context);
protected abstract AgentToolExecutionResult executeBusiness(Map<String, Object> arguments);
/**
* {@inheritDoc}
@@ -94,7 +92,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(), context);
dispatch(sessionId, record.getTaskId(), record.getArguments());
AsyncToolSubmitResult result = new AsyncToolSubmitResult();
result.setTaskId(taskId);
@@ -159,23 +157,16 @@ public abstract class AbstractAgentAsyncSubTools implements AsyncSubTools {
return result;
}
private void dispatch(String sessionId,
String taskId,
Map<String, Object> arguments,
AgentToolContext context) {
private void dispatch(String sessionId, String taskId, Map<String, Object> arguments) {
try {
taskExecutor.execute(() -> executeTask(
sessionId, taskId, arguments, context));
taskExecutor.execute(() -> executeTask(sessionId, taskId, arguments));
} 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,
AgentToolContext context) {
private void executeTask(String sessionId, String taskId, Map<String, Object> arguments) {
try {
taskStore.update(sessionId, taskId, record -> {
record.setStatus(AsyncToolTaskStatus.RUNNING);
@@ -183,8 +174,7 @@ public abstract class AbstractAgentAsyncSubTools implements AsyncSubTools {
appendEvent(record, "RUNNING", displayName() + "任务执行中");
return record;
});
AgentToolExecutionResult executionResult = executeBusiness(
arguments, context);
AgentToolExecutionResult executionResult = executeBusiness(arguments);
taskStore.update(sessionId, taskId, record -> {
record.setStatus(AsyncToolTaskStatus.SUCCEEDED);
record.setSummary(displayName() + "任务已完成");

View File

@@ -1,6 +1,5 @@
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;
@@ -83,9 +82,7 @@ public class PluginAsyncSubTools extends AbstractAgentAsyncSubTools {
* {@inheritDoc}
*/
@Override
protected AgentToolExecutionResult executeBusiness(
Map<String, Object> arguments,
AgentToolContext context) {
protected AgentToolExecutionResult executeBusiness(Map<String, Object> arguments) {
return pluginToolExecutor.execute(pluginItem, plugin, arguments);
}
}

View File

@@ -1,6 +1,5 @@
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;
@@ -78,9 +77,7 @@ public class WorkflowAsyncSubTools extends AbstractAgentAsyncSubTools {
* {@inheritDoc}
*/
@Override
protected AgentToolExecutionResult executeBusiness(
Map<String, Object> arguments,
AgentToolContext context) {
return workflowToolExecutor.execute(workflow, arguments, context);
protected AgentToolExecutionResult executeBusiness(Map<String, Object> arguments) {
return workflowToolExecutor.execute(workflow, arguments);
}
}

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, context).getResult());
() -> workflowToolExecutor.execute(workflow, arguments).getResult());
return new CompiledSyncTool(spec, invoker);
}
if (type == AgentToolType.PLUGIN) {

View File

@@ -2,19 +2,13 @@ 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;
/**
@@ -65,63 +59,11 @@ 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), variables);
definitionId(workflow), arguments == null ? Map.of() : arguments);
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

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

View File

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

View File

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

View File

@@ -1,100 +0,0 @@
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

@@ -53,10 +53,6 @@
<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

@@ -1,23 +0,0 @@
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,14 +17,6 @@ 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,8 +14,7 @@ import tech.easyflow.ai.documentimport.task.DocumentImportStatusBroadcastPropert
DocumentImportBulkProperties.class,
DocumentImportParseMonitorProperties.class,
DocumentImportStatusBroadcastProperties.class,
RagHealthProperties.class,
MultiKnowledgeRetrievalProperties.class
RagHealthProperties.class
})
@AutoConfiguration
public class AiModuleConfig {

View File

@@ -11,7 +11,6 @@ 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 线程池配置。
@@ -67,14 +66,6 @@ public class EasyFlowThreadPoolProperties {
this.agentAsyncTool = agentAsyncTool;
}
public Pool getKnowledgeRetrieval() {
return knowledgeRetrieval;
}
public void setKnowledgeRetrieval(Pool knowledgeRetrieval) {
this.knowledgeRetrieval = knowledgeRetrieval;
}
/**
* 线程池配置项。
*/

View File

@@ -1,108 +0,0 @@
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,23 +25,16 @@ 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,
AiMilvusClientManager milvusClientManager,
RagHealthProperties healthProperties
) {
public RagMilvusHealthIndicator(AiMilvusConfig aiMilvusConfig, RagHealthProperties healthProperties) {
super(healthProperties);
this.aiMilvusConfig = aiMilvusConfig;
this.milvusClientManager = milvusClientManager;
}
/**
@@ -58,10 +51,8 @@ public class RagHealthIndicator {
protected Health doHealthCheck() {
MilvusVectorStore vectorStore = null;
try {
milvusClientManager.reconfigureIfNeeded(aiMilvusConfig);
vectorStore = new MilvusVectorStore(
aiMilvusConfig.copyForCollection("__rag_health_probe__"),
milvusClientManager
aiMilvusConfig.copyForCollection("__rag_health_probe__")
);
if (vectorStore.checkAvailable()) {
return Health.up().withDetail("uri", aiMilvusConfig.getUri()).build();

View File

@@ -104,28 +104,4 @@ 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,10 +75,6 @@ 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,7 +5,6 @@ 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;
@@ -141,8 +140,6 @@ 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);
}
@@ -178,9 +175,6 @@ 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);
@@ -218,9 +212,6 @@ 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

@@ -1,86 +0,0 @@
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

@@ -1,34 +0,0 @@
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

@@ -1,414 +0,0 @@
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

@@ -1,66 +0,0 @@
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

@@ -1,18 +0,0 @@
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

@@ -1,33 +0,0 @@
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

@@ -1,39 +0,0 @@
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);
}
}

View File

@@ -10,7 +10,6 @@ import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.ai.document.support.DocumentParseFilePolicy;
import tech.easyflow.ai.documentimport.DocumentImportBatchCreateContext;
import tech.easyflow.ai.documentimport.DocumentImportBatchDtos;
import tech.easyflow.ai.documentimport.DocumentImportBatchRetryResult;
@@ -64,7 +63,7 @@ public class DocumentImportBatchAppService {
private static final Logger LOG = LoggerFactory.getLogger(DocumentImportBatchAppService.class);
private static final Set<String> SUPPORTED_EXTENSIONS =
DocumentParseFilePolicy.supportedExtensions();
DocumentImportFormatPolicy.supportedExtensions();
private static final Duration BATCH_MUTATION_LOCK_LEASE = Duration.ofMinutes(30);
private static final Duration RECOVERY_DISPATCH_LEASE = Duration.ofMinutes(2);
private static final Duration RECOVERY_LEASE_RENEW_INTERVAL =
@@ -80,7 +79,6 @@ public class DocumentImportBatchAppService {
private final DocumentMapper documentMapper;
private final RedisLockExecutor redisLockExecutor;
private final DocumentImportBatchCircuitBreaker circuitBreaker;
private final DocumentImportRecoveryCoordinator recoveryCoordinator;
@Resource(name = "default")
private FileStorageService storageService;
@@ -98,7 +96,6 @@ public class DocumentImportBatchAppService {
* @param documentMapper 文档 Mapper
* @param redisLockExecutor 分布式锁执行器
* @param circuitBreaker 自动导入批次熔断器
* @param recoveryCoordinator 恢复令牌事务协调器
*/
public DocumentImportBatchAppService(DocumentImportBatchService batchService,
DocumentImportBatchItemService itemService,
@@ -109,8 +106,7 @@ public class DocumentImportBatchAppService {
DocumentImportBatchItemMapper itemMapper,
DocumentMapper documentMapper,
RedisLockExecutor redisLockExecutor,
DocumentImportBatchCircuitBreaker circuitBreaker,
DocumentImportRecoveryCoordinator recoveryCoordinator) {
DocumentImportBatchCircuitBreaker circuitBreaker) {
this.batchService = batchService;
this.itemService = itemService;
this.batchTracker = batchTracker;
@@ -121,7 +117,6 @@ public class DocumentImportBatchAppService {
this.documentMapper = documentMapper;
this.redisLockExecutor = redisLockExecutor;
this.circuitBreaker = circuitBreaker;
this.recoveryCoordinator = recoveryCoordinator;
}
/**
@@ -1250,10 +1245,13 @@ public class DocumentImportBatchAppService {
Date claimedAt = new Date();
Date leaseUntil = new Date(
claimedAt.getTime() + RECOVERY_DISPATCH_LEASE.toMillis());
if (batchMapper.claimRecoveryPending(
batchId, recoveryToken, leaseUntil, claimedAt) <= 0) {
return;
}
try {
DocumentImportBatch claimedBatch =
recoveryCoordinator.claim(
batchId, recoveryToken, leaseUntil, claimedAt);
batchMapper.selectClaimedRecovery(batchId, recoveryToken);
if (claimedBatch == null) {
LOG.info(
"批次恢复调度令牌已失效,旧持有者停止恢复: "
@@ -1285,8 +1283,9 @@ public class DocumentImportBatchAppService {
);
return;
}
if (!recoveryCoordinator.finalizeRecovery(
batchId, recoveryToken, new Date())) {
int finalized = batchMapper.finalizeRecoveryPending(
batchId, recoveryToken, new Date());
if (finalized <= 0) {
LOG.info(
"批次恢复待办已变更,当前实例跳过收尾: "
+ "batchId={}, recoveryToken={}",
@@ -1299,8 +1298,7 @@ public class DocumentImportBatchAppService {
if (!circuitBreaker.interruptRecoveryBatch(
batchId, recoveryToken, error)) {
LOG.info(
"批次恢复异常发生时令牌已失效或已有并发推进,"
+ "跳过当前持有者熔断: "
"批次恢复异常发生时令牌已失效,跳过旧持有者熔断: "
+ "batchId={}, recoveryToken={}",
batchId,
recoveryToken
@@ -1351,13 +1349,13 @@ public class DocumentImportBatchAppService {
Date renewedLeaseUntil = new Date(
nowMillis + RECOVERY_DISPATCH_LEASE.toMillis()
);
boolean renewed = recoveryCoordinator.renew(
int renewed = batchMapper.renewRecoveryPendingLease(
batchId,
recoveryToken,
renewedLeaseUntil,
now
);
if (!renewed) {
if (renewed <= 0) {
return false;
}
renewAfter.set(nowMillis + renewIntervalMillis);
@@ -1458,11 +1456,7 @@ public class DocumentImportBatchAppService {
int dotIndex = fileName == null ? -1 : fileName.lastIndexOf('.');
String extension = dotIndex < 0 ? "" : fileName.substring(dotIndex + 1).toLowerCase(Locale.ROOT);
if (!SUPPORTED_EXTENSIONS.contains(extension)) {
throw new BusinessException(
"暂不支持该文件格式,仅支持 "
+ DocumentParseFilePolicy.supportedTypeLabel()
+ " 文件"
);
throw new BusinessException("暂不支持该文件格式");
}
}

View File

@@ -9,9 +9,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import tech.easyflow.ai.entity.DocumentImportBatch;
import tech.easyflow.ai.entity.DocumentImportBatchItem;
import tech.easyflow.ai.entity.DocumentImportTask;
import tech.easyflow.ai.enums.DocumentImportBatchItemStatus;
import tech.easyflow.ai.enums.DocumentImportBatchStatus;
import tech.easyflow.ai.enums.DocumentImportMode;
import tech.easyflow.ai.enums.DocumentImportTaskStatus;
@@ -25,8 +23,6 @@ import java.sql.SQLRecoverableException;
import java.sql.SQLTimeoutException;
import java.sql.SQLTransientConnectionException;
import java.util.Date;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.RejectedExecutionException;
/**
@@ -139,18 +135,13 @@ public class DocumentImportBatchCircuitBreaker {
if (recoveryToken == null || recoveryToken.isBlank()) {
throw new IllegalArgumentException("恢复调度令牌不能为空");
}
DocumentImportRecoveryException recoveryError =
findCause(error, DocumentImportRecoveryException.class);
return interruptBatch(
batchId,
resolveReason(error),
null,
null,
error,
recoveryToken,
recoveryError == null
? List.of()
: recoveryError.getFailureFenceItemIds()
recoveryToken
);
}
@@ -220,7 +211,7 @@ public class DocumentImportBatchCircuitBreaker {
String phase,
Throwable error) {
return interruptBatch(
batchId, reason, taskId, phase, error, null, List.of());
batchId, reason, taskId, phase, error, null);
}
/**
@@ -232,7 +223,6 @@ public class DocumentImportBatchCircuitBreaker {
* @param phase 触发任务阶段,可为空
* @param error 原始异常
* @param recoveryToken 恢复调度令牌,可为空
* @param recoveryFailureItemIds 零重排时的初始失败项围栏
* @return 批次是否已经停止运行;围栏失效时返回 {@code false}
*/
private boolean interruptBatch(BigInteger batchId,
@@ -240,17 +230,11 @@ public class DocumentImportBatchCircuitBreaker {
BigInteger taskId,
String phase,
Throwable error,
String recoveryToken,
List<BigInteger> recoveryFailureItemIds) {
String recoveryToken) {
if (batchId == null) {
return false;
}
boolean fencedRecoveryFailure = recoveryToken != null
&& recoveryFailureItemIds != null
&& !recoveryFailureItemIds.isEmpty();
// 批次行是后续 task/item 状态收口的根锁;所有熔断路径先锁定
// batch避免多表围栏更新与任务完成事务形成 task -> batch 逆序。
DocumentImportBatch batch = batchMapper.selectForUpdate(batchId);
DocumentImportBatch batch = batchMapper.selectOneById(batchId);
if (batch == null) {
LOG.warn(
"忽略无法关联批次的自动导入熔断请求: taskId={}, batchId={}",
@@ -265,39 +249,6 @@ public class DocumentImportBatchCircuitBreaker {
}
Date now = new Date();
if (fencedRecoveryFailure) {
if (!Boolean.TRUE.equals(batch.getRecoveryPending())
|| !Objects.equals(recoveryToken, batch.getRecoveryToken())
|| batch.getRecoveryLeaseUntil() == null
|| !batch.getRecoveryLeaseUntil().after(now)) {
return false;
}
if (itemMapper.countActiveItems(batchId) > 0) {
LOG.info(
"恢复失败熔断发现批次已有活跃文件项,保留运行批次: "
+ "batchId={}",
batchId
);
return false;
}
for (BigInteger itemId : recoveryFailureItemIds) {
DocumentImportBatchItem item =
itemMapper.selectForUpdate(itemId);
if (item == null
|| !batchId.equals(item.getBatchId())
|| !DocumentImportBatchItemStatus.FAILED
.name().equals(item.getStatus())) {
LOG.info(
"恢复失败熔断发现等价并发推进,保留运行批次: "
+ "batchId={}, itemId={}, status={}",
batchId,
itemId,
item == null ? null : item.getStatus()
);
return false;
}
}
}
int interrupted;
if (recoveryToken != null) {
interrupted = batchMapper.interruptOwnedRecoveryBatch(
@@ -354,14 +305,6 @@ public class DocumentImportBatchCircuitBreaker {
* @return 稳定错误码与用户可见摘要
*/
private InterruptionReason resolveReason(Throwable error) {
DocumentImportRecoveryException recoveryError =
findCause(error, DocumentImportRecoveryException.class);
if (recoveryError != null) {
return new InterruptionReason(
recoveryError.getCode(),
recoveryError.getUserMessage()
);
}
if (containsRedisFailure(error)) {
return new InterruptionReason(
REDIS_UNAVAILABLE,
@@ -386,25 +329,6 @@ public class DocumentImportBatchCircuitBreaker {
);
}
/**
* 从异常链中查找指定类型。
*
* @param error 原始异常
* @param type 目标异常类型
* @param <T> 异常类型
* @return 匹配异常;不存在时返回 {@code null}
*/
private <T extends Throwable> T findCause(Throwable error, Class<T> type) {
Throwable current = error;
while (current != null) {
if (type.isInstance(current)) {
return type.cast(current);
}
current = current.getCause();
}
return null;
}
/**
* 判断异常链是否来自 Redis/Lettuce。
*

View File

@@ -27,18 +27,6 @@ import java.util.Date;
@Service
public class DocumentImportBatchTracker {
/**
* 单文件恢复失败原因的事务内处理结果。
*/
public enum FailedRetryErrorOutcome {
/** 失败原因已写回,文件项仍等待后续人工恢复。 */
ERROR_RECORDED,
/** 等价并发操作已把文件项推进出失败状态。 */
ALREADY_ADVANCED,
/** 批次或文件项已不再允许当前恢复请求写回。 */
REJECTED
}
private final DocumentImportBatchService batchService;
private final DocumentImportBatchItemService itemService;
private final DocumentImportBatchMapper batchMapper;
@@ -76,31 +64,6 @@ public class DocumentImportBatchTracker {
return batch;
}
/**
* 在任务行变更前锁定所属批次,统一 batch -> task 锁序。
*
* <p>自动导入的任务生命周期由运行批次驱动,批次结束后不允许
* 旧任务继续推进。手动导入的上传批次可以先结束,用户之后再手动
* 执行分块和索引,因此只参与锁序,不以批次终态阻断后续任务。</p>
*
* @param batchId 批次 ID无批次任务传入 {@code null}
* @return 批次存在且允许当前任务推进时为 {@code true}
*/
public boolean lockBatchForTaskMutation(BigInteger batchId) {
if (batchId == null) {
return true;
}
DocumentImportBatch batch = batchMapper.selectForUpdate(batchId);
if (batch == null) {
return false;
}
if (DocumentImportMode.AUTO.name().equals(batch.getImportMode())) {
return DocumentImportBatchStatus.RUNNING.name().equals(
batch.getStatus());
}
return DocumentImportMode.MANUAL.name().equals(batch.getImportMode());
}
/**
* 查询批次文件项。
*
@@ -144,8 +107,10 @@ public class DocumentImportBatchTracker {
if (itemId == null) {
return false;
}
DocumentImportBatchItem current = requireItem(itemId);
int attemptDelta = (status == DocumentImportBatchItemStatus.PENDING
|| status == DocumentImportBatchItemStatus.RUNNING)
&& DocumentImportBatchItemStatus.FAILED.name().equals(current.getStatus())
? 1
: 0;
return transitionItem(itemId, stage, status, errorSummary,
@@ -197,34 +162,28 @@ public class DocumentImportBatchTracker {
if (itemId == null) {
return false;
}
LockedBatchItem locked = lockBatchThenItem(itemId);
if (DocumentImportBatchStatus.INTERRUPTED.name()
.equals(locked.batch.getStatus())) {
return false;
}
DocumentImportBatchItem current = locked.item;
for (int attempt = 0; attempt < 3; attempt++) {
DocumentImportBatchItem current = requireItem(itemId);
DocumentImportBatchItemStatus currentStatus =
DocumentImportBatchItemStatus.valueOf(current.getStatus());
if (!isAllowedTransition(currentStatus, status)) {
return false;
}
String expectedStatus = current.getStatus();
Date now = new Date();
int effectiveAttemptDelta = currentStatus == DocumentImportBatchItemStatus.FAILED
? Math.max(0, attemptDelta)
: 0;
int updated = itemMapper.transitionStatus(
itemId,
current.getStatus(),
expectedStatus,
stage.name(),
status.name(),
errorSummary,
failureCode,
retryable,
effectiveAttemptDelta,
Math.max(0, attemptDelta),
now
);
if (updated <= 0) {
return false;
continue;
}
CounterDelta delta = CounterDelta.between(current, status, retryable);
if (!delta.isZero()) {
@@ -244,6 +203,8 @@ public class DocumentImportBatchTracker {
refreshBatch(current.getBatchId());
return true;
}
return false;
}
/**
* 校验文件项状态机,拒绝迟到任务覆盖终态。
@@ -282,12 +243,7 @@ public class DocumentImportBatchTracker {
*/
@org.springframework.transaction.annotation.Transactional
public void bindDocument(BigInteger itemId, BigInteger documentId) {
LockedBatchItem locked = lockBatchThenItem(itemId);
if (DocumentImportBatchStatus.INTERRUPTED.name()
.equals(locked.batch.getStatus())) {
throw new BusinessException("导入批次已中断,请继续批次后重试");
}
DocumentImportBatchItem item = locked.item;
DocumentImportBatchItem item = requireItem(itemId);
if (documentId.equals(item.getDocumentId())
&& DocumentImportBatchItemStatus.PENDING.name().equals(item.getStatus())) {
return;
@@ -296,11 +252,6 @@ public class DocumentImportBatchTracker {
boolean recoveringFailedItem =
DocumentImportBatchItemStatus.FAILED.name().equals(item.getStatus())
&& item.getDocumentId() == null;
if (recoveringFailedItem
&& !DocumentImportBatchStatus.RUNNING.name()
.equals(locked.batch.getStatus())) {
throw new BusinessException("导入批次状态已变化,请刷新后重试");
}
int updated = recoveringFailedItem
? itemMapper.bindFailedDocument(itemId, documentId, now)
: itemMapper.bindDocument(itemId, documentId, now);
@@ -317,91 +268,6 @@ public class DocumentImportBatchTracker {
refreshBatch(item.getBatchId());
}
/**
* 将引用已丢失文档的失败项重新绑定到恢复创建的新文档。
*
* @param itemId 文件项 ID
* @param missingDocumentId 已不存在的旧文档 ID
* @param documentId 恢复创建的新文档 ID
*/
@org.springframework.transaction.annotation.Transactional
public void replaceMissingDocument(BigInteger itemId,
BigInteger missingDocumentId,
BigInteger documentId) {
LockedBatchItem locked = lockBatchThenItem(itemId);
if (!DocumentImportBatchStatus.RUNNING.name()
.equals(locked.batch.getStatus())) {
throw new BusinessException("导入批次状态已变化,请刷新后重试");
}
DocumentImportBatchItem item = locked.item;
if (!DocumentImportBatchItemStatus.FAILED.name().equals(item.getStatus())
|| !java.util.Objects.equals(missingDocumentId, item.getDocumentId())) {
throw new BusinessException("导入文件状态已变化,请刷新后重试");
}
Date now = new Date();
if (itemMapper.replaceMissingDocument(
itemId, missingDocumentId, documentId, now) <= 0) {
throw new BusinessException("原文档状态已变化,请刷新后重试");
}
batchMapper.refreshCountersFromItems(item.getBatchId(), now);
refreshBatch(item.getBatchId());
}
/**
* 按批次到文件项的固定顺序取得失败项恢复权。
*
* @param itemId 文件项 ID
* @return 可恢复文件项;状态已变化时返回 null
*/
@org.springframework.transaction.annotation.Transactional
public DocumentImportBatchItem lockFailedItemForRetry(BigInteger itemId) {
if (itemId == null) {
return null;
}
LockedBatchItem locked = lockBatchThenItem(itemId);
if (!DocumentImportBatchStatus.RUNNING.name()
.equals(locked.batch.getStatus())
|| !DocumentImportBatchItemStatus.FAILED.name()
.equals(locked.item.getStatus())) {
return null;
}
return locked.item;
}
/**
* 在运行批次内保留单文件恢复失败原因。
*
* @param itemId 文件项 ID
* @param batchId 批次 ID
* @param errorSummary 错误摘要
* @return 失败原因写回、并发推进或拒绝结果
*/
@org.springframework.transaction.annotation.Transactional(
propagation = org.springframework.transaction.annotation.Propagation.REQUIRES_NEW)
public FailedRetryErrorOutcome updateFailedRetryError(
BigInteger itemId,
BigInteger batchId,
String errorSummary
) {
if (itemId == null || batchId == null) {
return FailedRetryErrorOutcome.REJECTED;
}
LockedBatchItem locked = lockBatchThenItem(itemId);
if (!batchId.equals(locked.batch.getId())
|| !DocumentImportBatchStatus.RUNNING.name()
.equals(locked.batch.getStatus())) {
return FailedRetryErrorOutcome.REJECTED;
}
if (!DocumentImportBatchItemStatus.FAILED.name()
.equals(locked.item.getStatus())) {
return FailedRetryErrorOutcome.ALREADY_ADVANCED;
}
return itemMapper.updateFailedRetryError(
itemId, batchId, errorSummary, new Date()) > 0
? FailedRetryErrorOutcome.ERROR_RECORDED
: FailedRetryErrorOutcome.REJECTED;
}
/**
* 原子完成文件上传并增量更新批次上传数。
*
@@ -414,12 +280,7 @@ public class DocumentImportBatchTracker {
public boolean completeUpload(BigInteger itemId,
String filePath,
String storageLocator) {
LockedBatchItem locked = lockBatchThenItem(itemId);
if (DocumentImportBatchStatus.INTERRUPTED.name()
.equals(locked.batch.getStatus())) {
return false;
}
DocumentImportBatchItem item = locked.item;
DocumentImportBatchItem item = requireItem(itemId);
if (DocumentImportBatchItemStatus.UPLOADED.name().equals(item.getStatus())) {
return filePath.equals(item.getFilePath())
&& storageLocator.equals(item.getStorageLocator());
@@ -443,34 +304,6 @@ public class DocumentImportBatchTracker {
return true;
}
/**
* 先锁批次再锁文件项,统一所有计数迁移路径的加锁顺序。
*
* @param itemId 文件项 ID
* @return 已锁定的批次与文件项
*/
private LockedBatchItem lockBatchThenItem(BigInteger itemId) {
DocumentImportBatchItem candidate = requireItem(itemId);
DocumentImportBatch batch = batchMapper.selectForUpdate(candidate.getBatchId());
if (batch == null) {
throw new BusinessException("导入批次不存在");
}
DocumentImportBatchItem item = itemMapper.selectForUpdate(itemId);
if (item == null) {
throw new BusinessException("导入文件不存在");
}
if (!java.util.Objects.equals(batch.getId(), item.getBatchId())) {
throw new IllegalStateException("导入文件批次归属已变化");
}
return new LockedBatchItem(batch, item);
}
private record LockedBatchItem(
DocumentImportBatch batch,
DocumentImportBatchItem item
) {
}
/**
* 记录文件项成功后需要清理的历史文档。
*

View File

@@ -0,0 +1,40 @@
package tech.easyflow.ai.documentimport.task;
import java.util.Set;
/**
* 知识库文档导入格式统一策略。
*
* @author Codex
* @since 2026-08-04
*/
public final class DocumentImportFormatPolicy {
private static final Set<String> SUPPORTED_EXTENSIONS =
Set.of("txt", "pdf", "docx", "md", "pptx", "xlsx", "csv");
/**
* 禁止实例化格式策略工具类。
*/
private DocumentImportFormatPolicy() {
}
/**
* 返回只读的支持格式集合。
*
* @return 支持的文件扩展名
*/
public static Set<String> supportedExtensions() {
return SUPPORTED_EXTENSIONS;
}
/**
* 判断扩展名是否属于知识库导入支持范围。
*
* @param extension 已转换为小写的文件扩展名
* @return 支持时返回 {@code true}
*/
public static boolean isSupported(String extension) {
return SUPPORTED_EXTENSIONS.contains(extension);
}
}

View File

@@ -1,75 +0,0 @@
package tech.easyflow.ai.documentimport.task;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import tech.easyflow.ai.entity.DocumentImportBatch;
import tech.easyflow.ai.mapper.DocumentImportBatchMapper;
import java.math.BigInteger;
import java.util.Date;
/**
* 在独立短事务中维护批次恢复令牌。
*
* <p>继续操作通常从原事务的 {@code afterCommit} 回调启动。将领取、续租和
* 收尾放入独立事务,可保证恢复令牌在单文件重试事务开始前已经提交,并避免
* 复用刚完成提交但尚未解绑的事务资源。</p>
*/
@Service
public class DocumentImportRecoveryCoordinator {
private final DocumentImportBatchMapper batchMapper;
public DocumentImportRecoveryCoordinator(DocumentImportBatchMapper batchMapper) {
this.batchMapper = batchMapper;
}
/**
* 领取恢复待办并读取本次令牌对应的恢复参数。
*
* @param batchId 批次 ID
* @param recoveryToken 恢复令牌
* @param leaseUntil 租约到期时间
* @param claimedAt 领取时间
* @return 已领取批次;领取失败时返回 null
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public DocumentImportBatch claim(BigInteger batchId,
String recoveryToken,
Date leaseUntil,
Date claimedAt) {
if (batchMapper.claimRecoveryPending(
batchId, recoveryToken, leaseUntil, claimedAt) <= 0) {
return null;
}
return batchMapper.selectClaimedRecovery(batchId, recoveryToken);
}
/**
* 续期当前恢复令牌。
*
* @return 当前令牌仍有效且续期成功时返回 true
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public boolean renew(BigInteger batchId,
String recoveryToken,
Date leaseUntil,
Date renewedAt) {
return batchMapper.renewRecoveryPendingLease(
batchId, recoveryToken, leaseUntil, renewedAt) > 0;
}
/**
* 按真实计数收尾并清除恢复待办。
*
* @return 当前令牌仍有效且收尾成功时返回 true
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public boolean finalizeRecovery(BigInteger batchId,
String recoveryToken,
Date finalizedAt) {
return batchMapper.finalizeRecoveryPending(
batchId, recoveryToken, finalizedAt) > 0;
}
}

View File

@@ -1,71 +0,0 @@
package tech.easyflow.ai.documentimport.task;
import java.math.BigInteger;
import java.util.Collection;
import java.util.List;
/**
* 批次恢复未能重新排队任何失败项。
*
* <p>该异常只携带稳定错误码和用户可见摘要,供恢复令牌持有者
* 中断批次,避免“继续”请求在没有启动任何新任务时表现为成功。</p>
*/
public class DocumentImportRecoveryException extends RuntimeException {
private static final String ERROR_CODE = "document_import_recovery_failed";
private static final String USER_MESSAGE =
"未能重新排队任何失败文件,请查看文件错误后继续";
private final List<BigInteger> failureFenceItemIds;
/**
* 创建批次恢复失败异常。
*/
public DocumentImportRecoveryException() {
this(List.of());
}
/**
* 创建带初始失败项快照的批次恢复失败异常。
*
* @param failureFenceItemIds 领取恢复时仍为失败的文件项 ID
*/
public DocumentImportRecoveryException(
Collection<BigInteger> failureFenceItemIds
) {
super(USER_MESSAGE);
this.failureFenceItemIds = failureFenceItemIds == null
? List.of()
: failureFenceItemIds.stream()
.filter(java.util.Objects::nonNull)
.distinct()
.sorted()
.toList();
}
/**
* 获取稳定错误码。
*
* @return 稳定错误码
*/
public String getCode() {
return ERROR_CODE;
}
/**
* 获取用户可见摘要。
*
* @return 用户可见摘要
*/
public String getUserMessage() {
return USER_MESSAGE;
}
/**
* 获取用于原子熔断围栏的初始失败项。
*
* @return 按 ID 排序的不可变列表
*/
public List<BigInteger> getFailureFenceItemIds() {
return failureFenceItemIds;
}
}

View File

@@ -8,7 +8,6 @@ import org.slf4j.LoggerFactory;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.ai.document.support.DocumentParseFilePolicy;
import tech.easyflow.ai.documentimport.DocumentImportBatchCreateContext;
import tech.easyflow.ai.documentimport.DocumentImportBatchDtos;
import tech.easyflow.ai.documentimport.DocumentImportBatchRetryResult;
@@ -61,7 +60,7 @@ public class KnowledgeImportBatchFacade {
private static final Duration INCOMPLETE_SUBMISSION_TIMEOUT =
Duration.ofMinutes(30);
private static final Set<String> SUPPORTED_EXTENSIONS =
DocumentParseFilePolicy.supportedExtensions();
DocumentImportFormatPolicy.supportedExtensions();
private final DocumentImportBatchAppService batchAppService;
private final DocumentImportBatchTracker batchTracker;
@@ -890,13 +889,7 @@ public class KnowledgeImportBatchFacade {
*/
private void assertSupportedExtension(String fileName) {
if (!SUPPORTED_EXTENSIONS.contains(extension(fileName))) {
throw new BusinessException(
415,
41502,
"暂不支持该文件格式,仅支持 "
+ DocumentParseFilePolicy.supportedTypeLabel()
+ " 文件"
);
throw new BusinessException(415, 41502, "暂不支持该文件格式");
}
}

View File

@@ -1,24 +0,0 @@
package tech.easyflow.ai.dto;
import tech.easyflow.ai.entity.DocumentChunk;
import java.math.BigInteger;
/**
* 兼容 success 字段的异步分块更新结果。
*/
public record DocumentChunkAsyncUpdateResult(
boolean success,
BigInteger id,
String indexSyncStatus,
Long indexSyncVersion
) {
public static DocumentChunkAsyncUpdateResult from(DocumentChunk chunk) {
return new DocumentChunkAsyncUpdateResult(
true,
chunk.getId(),
chunk.getIndexSyncStatus(),
chunk.getIndexSyncVersion()
);
}
}

View File

@@ -1,29 +0,0 @@
package tech.easyflow.ai.dto;
import java.io.Serializable;
import java.math.BigInteger;
/**
* 分块正文更新请求,只允许修改 Markdown 内容。
*/
public class DocumentChunkContentUpdateRequest implements Serializable {
private BigInteger id;
private String content;
public BigInteger getId() {
return id;
}
public void setId(BigInteger id) {
this.id = id;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
}

View File

@@ -1,14 +0,0 @@
package tech.easyflow.ai.dto;
import java.io.Serializable;
import java.math.BigInteger;
/**
* 分块删除结果。
*/
public record DocumentChunkDeleteResult(
BigInteger id,
BigInteger documentId,
long remainingChunkCount
) implements Serializable {
}

View File

@@ -1,16 +0,0 @@
package tech.easyflow.ai.dto;
import java.math.BigInteger;
/**
* 重试分块索引同步请求。
*/
public class DocumentChunkSyncRetryRequest {
private BigInteger id;
private Long indexSyncVersion;
public BigInteger getId() { return id; }
public void setId(BigInteger id) { this.id = id; }
public Long getIndexSyncVersion() { return indexSyncVersion; }
public void setIndexSyncVersion(Long indexSyncVersion) { this.indexSyncVersion = indexSyncVersion; }
}

View File

@@ -1,15 +0,0 @@
package tech.easyflow.ai.dto;
import java.math.BigInteger;
/**
* 分块索引同步状态。
*/
public record DocumentChunkSyncStatus(
BigInteger id,
String indexSyncStatus,
Long indexSyncVersion,
String indexSyncErrorCode,
String indexSyncErrorMessage
) {
}

View File

@@ -1,17 +0,0 @@
package tech.easyflow.ai.dto;
import java.math.BigInteger;
import java.util.List;
/**
* 批量查询分块索引同步状态。
*/
public class DocumentChunkSyncStatusRequest {
private BigInteger documentId;
private List<BigInteger> ids;
public BigInteger getDocumentId() { return documentId; }
public void setDocumentId(BigInteger documentId) { this.documentId = documentId; }
public List<BigInteger> getIds() { return ids; }
public void setIds(List<BigInteger> ids) { this.ids = ids; }
}

View File

@@ -1,42 +0,0 @@
package tech.easyflow.ai.easyagentsflow.cancellation;
import com.easyagents.flow.core.chain.Chain;
import com.easyagents.flow.core.chain.ChainStatus;
import com.easyagents.flow.core.chain.Event;
import com.easyagents.flow.core.chain.event.ChainStatusChangeEvent;
import com.easyagents.flow.core.chain.listener.ChainEventListener;
import org.springframework.stereotype.Component;
/**
* 将工作流取消终态桥接到活动数据集查询。
*/
@Component
public class WorkflowDatasetQueryCancellationListener
implements ChainEventListener {
private final WorkflowDatasetQueryCancellationRegistry registry;
/**
* 创建工作流查询取消监听器。
*
* @param registry 工作流查询取消登记表
*/
public WorkflowDatasetQueryCancellationListener(
WorkflowDatasetQueryCancellationRegistry registry) {
this.registry = registry;
}
/**
* 在工作流进入取消终态后取消该实例的活动查询。
*
* @param event 工作流事件
* @param chain 工作流实例
*/
@Override
public void onEvent(Event event, Chain chain) {
if (event instanceof ChainStatusChangeEvent statusChangeEvent
&& statusChangeEvent.getStatus() == ChainStatus.CANCELLED) {
registry.cancelExecution(chain.getStateInstanceId());
}
}
}

View File

@@ -1,279 +0,0 @@
package tech.easyflow.ai.easyagentsflow.cancellation;
import java.math.BigInteger;
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.atomic.AtomicBoolean;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.datacenter.federation.DatacenterFederationQueryCancellationService;
/**
* 维护工作流实例到活动数据集查询的跨节点取消映射。
*/
@Component
public class WorkflowDatasetQueryCancellationRegistry {
private static final String ACTIVE_KEY_PREFIX =
"easyflow:workflow:dataset-query:active:";
private static final String CANCELLED_KEY_PREFIX =
"easyflow:workflow:dataset-query:cancelled:";
private static final Duration STATE_TTL = Duration.ofHours(24);
private static final Logger log = LoggerFactory.getLogger(
WorkflowDatasetQueryCancellationRegistry.class);
private final DatacenterFederationQueryCancellationService cancellationService;
private final ObjectProvider<StringRedisTemplate> redisTemplateProvider;
private final ConcurrentHashMap<String, ConcurrentHashMap<String, BigInteger>>
localActive = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, Long> localCancelledUntil =
new ConcurrentHashMap<>();
/**
* 创建工作流查询取消登记表。
*
* @param cancellationService 数据中枢查询取消服务
* @param redisTemplateProvider 可选 Redis 模板
*/
public WorkflowDatasetQueryCancellationRegistry(
DatacenterFederationQueryCancellationService cancellationService,
ObjectProvider<StringRedisTemplate> redisTemplateProvider) {
this.cancellationService = cancellationService;
this.redisTemplateProvider = redisTemplateProvider;
}
/**
* 在数据库查询开始前登记工作流与 QueryId。
*
* @param stateInstanceId 工作流实例 ID
* @param account 执行账号
* @param queryId 查询 UUID
* @return 必须关闭的登记句柄
* @throws BusinessException 参数缺失时抛出
*/
public Registration register(
String stateInstanceId,
LoginAccount account,
String queryId) {
String instanceId = requireText(stateInstanceId, "工作流实例 ID 不能为空");
String normalizedQueryId = requireText(queryId, "queryId 不能为空");
BigInteger tenantId = requireTenantId(account);
localActive.computeIfAbsent(
instanceId,
ignored -> new ConcurrentHashMap<>())
.put(normalizedQueryId, tenantId);
persistActive(instanceId, normalizedQueryId, tenantId);
Registration registration = new Registration(
instanceId, normalizedQueryId, tenantId);
if (isExecutionCancelled(instanceId)) {
cancelQuery(normalizedQueryId, tenantId);
}
return registration;
}
/**
* 取消工作流当前登记的全部数据集查询。
*
* @param stateInstanceId 工作流实例 ID
* @return 是否发现至少一个活动查询
*/
public boolean cancelExecution(String stateInstanceId) {
String instanceId = requireText(stateInstanceId, "工作流实例 ID 不能为空");
long expiresAt = System.currentTimeMillis() + STATE_TTL.toMillis();
localCancelledUntil.put(instanceId, expiresAt);
persistCancellationMarker(instanceId);
Map<String, BigInteger> active = new LinkedHashMap<>();
ConcurrentHashMap<String, BigInteger> local = localActive.get(instanceId);
if (local != null) {
active.putAll(local);
}
loadPersistedActive(instanceId).forEach(active::putIfAbsent);
active.forEach((queryId, tenantId) -> {
try {
cancelQuery(queryId, tenantId);
} catch (RuntimeException exception) {
// 单个驱动取消失败不能阻断同一工作流的其他活动查询。
log.error("Failed to cancel workflow dataset query {} for {}",
queryId, instanceId, exception);
}
});
return !active.isEmpty();
}
/**
* 清理本机已过期的取消墓碑,避免不可达工作流持续占用内存。
*/
@Scheduled(fixedDelayString =
"${easyflow.workflow.dataset-query-cancel-cleanup-ms:60000}")
public void cleanupExpiredLocalMarkers() {
long now = System.currentTimeMillis();
localCancelledUntil.entrySet().removeIf(
entry -> entry.getValue() <= now);
}
private void cancelQuery(String queryId, BigInteger tenantId) {
LoginAccount account = new LoginAccount();
account.setTenantId(tenantId);
cancellationService.cancel(queryId, account);
}
private boolean isExecutionCancelled(String stateInstanceId) {
Long localDeadline = localCancelledUntil.get(stateInstanceId);
if (localDeadline != null && localDeadline > System.currentTimeMillis()) {
return true;
}
StringRedisTemplate redisTemplate = redisTemplateProvider.getIfAvailable();
if (redisTemplate == null) {
return false;
}
try {
return Boolean.TRUE.equals(redisTemplate.hasKey(
cancelledKey(stateInstanceId)));
} catch (RuntimeException exception) {
log.warn("Failed to read workflow dataset cancellation marker for {}",
stateInstanceId, exception);
return false;
}
}
private void persistActive(
String stateInstanceId,
String queryId,
BigInteger tenantId) {
StringRedisTemplate redisTemplate = redisTemplateProvider.getIfAvailable();
if (redisTemplate == null) {
return;
}
try {
String key = activeKey(stateInstanceId);
redisTemplate.opsForHash().put(key, queryId, tenantId.toString());
redisTemplate.expire(key, STATE_TTL);
} catch (RuntimeException exception) {
log.warn("Failed to persist workflow dataset query mapping for {}",
stateInstanceId, exception);
}
}
private Map<String, BigInteger> loadPersistedActive(String stateInstanceId) {
StringRedisTemplate redisTemplate = redisTemplateProvider.getIfAvailable();
if (redisTemplate == null) {
return Map.of();
}
try {
Map<Object, Object> entries = redisTemplate.opsForHash()
.entries(activeKey(stateInstanceId));
Map<String, BigInteger> active = new LinkedHashMap<>();
entries.forEach((queryId, tenantId) -> {
try {
active.put(String.valueOf(queryId),
new BigInteger(String.valueOf(tenantId)));
} catch (RuntimeException exception) {
log.warn("Ignored invalid workflow dataset query mapping for {}",
stateInstanceId, exception);
}
});
return active;
} catch (RuntimeException exception) {
log.warn("Failed to load workflow dataset query mappings for {}",
stateInstanceId, exception);
return Map.of();
}
}
private void persistCancellationMarker(String stateInstanceId) {
StringRedisTemplate redisTemplate = redisTemplateProvider.getIfAvailable();
if (redisTemplate == null) {
return;
}
try {
redisTemplate.opsForValue().set(
cancelledKey(stateInstanceId), "1", STATE_TTL);
} catch (RuntimeException exception) {
log.warn("Failed to persist workflow dataset cancellation marker for {}",
stateInstanceId, exception);
}
}
private void unregister(
String stateInstanceId,
String queryId,
BigInteger tenantId) {
localActive.computeIfPresent(stateInstanceId, (ignored, queries) -> {
queries.remove(queryId, tenantId);
return queries.isEmpty() ? null : queries;
});
StringRedisTemplate redisTemplate = redisTemplateProvider.getIfAvailable();
if (redisTemplate == null) {
return;
}
try {
redisTemplate.opsForHash().delete(
activeKey(stateInstanceId), queryId);
} catch (RuntimeException exception) {
log.warn("Failed to remove workflow dataset query mapping for {}",
stateInstanceId, exception);
}
}
private String activeKey(String stateInstanceId) {
return ACTIVE_KEY_PREFIX + stateInstanceId;
}
private String cancelledKey(String stateInstanceId) {
return CANCELLED_KEY_PREFIX + stateInstanceId;
}
private String requireText(String value, String message) {
if (value == null || value.isBlank()) {
throw new BusinessException(message);
}
return value.trim();
}
private BigInteger requireTenantId(LoginAccount account) {
if (account == null || account.getTenantId() == null) {
throw new BusinessException("工作流数据集查询缺少执行租户");
}
return account.getTenantId();
}
/**
* 单次工作流查询登记句柄。
*/
public final class Registration implements AutoCloseable {
private final String stateInstanceId;
private final String queryId;
private final BigInteger tenantId;
private final AtomicBoolean closed = new AtomicBoolean();
private Registration(
String stateInstanceId,
String queryId,
BigInteger tenantId) {
this.stateInstanceId = stateInstanceId;
this.queryId = queryId;
this.tenantId = tenantId;
}
/**
* 幂等注销当前工作流查询。
*/
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
unregister(stateInstanceId, queryId, tenantId);
}
}
}
}

View File

@@ -14,7 +14,6 @@ import org.springframework.context.annotation.Configuration;
import tech.easyflow.ai.easyagentsflow.listener.ChainErrorListenerForSave;
import tech.easyflow.ai.easyagentsflow.listener.ChainEventListenerForSave;
import tech.easyflow.ai.easyagentsflow.listener.NodeErrorListenerForSave;
import tech.easyflow.ai.easyagentsflow.cancellation.WorkflowDatasetQueryCancellationListener;
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadCleanupListener;
import javax.annotation.Resource;
@@ -41,9 +40,6 @@ public class ChainExecutorConfig {
@Resource
private WorkflowApiUploadCleanupListener workflowApiUploadCleanupListener;
@Resource
private WorkflowDatasetQueryCancellationListener
workflowDatasetQueryCancellationListener;
@Resource
private WorkflowExecutionBudgetProperties workflowExecutionBudgetProperties;
@Resource
private WorkflowRuntimeProperties workflowRuntimeProperties;
@@ -95,9 +91,6 @@ public class ChainExecutorConfig {
chainExecutor.addEventListener(
ChainStatusChangeEvent.class,
workflowApiUploadCleanupListener);
chainExecutor.addEventListener(
ChainStatusChangeEvent.class,
workflowDatasetQueryCancellationListener);
chainExecutor.addErrorListener(new ChainErrorListenerForSave());
chainExecutor.addNodeErrorListener(new NodeErrorListenerForSave());
}

View File

@@ -5,7 +5,6 @@ import com.alibaba.fastjson2.JSONObject;
import com.easyagents.flow.core.chain.Chain;
import com.easyagents.flow.core.knowledge.Knowledge;
import com.easyagents.flow.core.knowledge.KnowledgeProvider;
import com.easyagents.flow.core.knowledge.KnowledgeSearchRequest;
import com.easyagents.flow.core.node.KnowledgeNode;
import org.springframework.stereotype.Component;
import tech.easyflow.ai.rag.KnowledgeRetrievalRequest;
@@ -15,7 +14,6 @@ import tech.easyflow.ai.service.DocumentCollectionService;
import javax.annotation.Resource;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -28,9 +26,6 @@ public class KnowledgeProviderImpl implements KnowledgeProvider {
@Resource
private DocumentCollectionService documentCollectionService;
@Resource
private WorkflowMultiKnowledgeRetrievalService multiKnowledgeRetrievalService;
/**
* 获取知识库检索器。
*
@@ -49,86 +44,24 @@ public class KnowledgeProviderImpl implements KnowledgeProvider {
int limit,
KnowledgeNode knowledgeNode,
Chain chain) {
return searchSingle(
new BigInteger(id.toString()),
keyword,
limit,
knowledgeNode);
}
};
}
@Override
public Map<String, Object> search(KnowledgeSearchRequest request) {
if (request == null || request.getKnowledgeIds().isEmpty()) {
return null;
}
List<BigInteger> knowledgeIds = new ArrayList<>();
for (Object id : request.getKnowledgeIds()) {
try {
knowledgeIds.add(new BigInteger(String.valueOf(id)));
} catch (RuntimeException exception) {
throw new IllegalArgumentException("知识库 ID 无效: " + id, exception);
}
}
if (knowledgeIds.size() == 1) {
List<Map<String, Object>> documents = searchSingle(
knowledgeIds.get(0),
request.getKeyword(),
request.getLimit(),
request.getKnowledgeNode());
return buildOutputs(documents);
}
if (!"VECTOR".equalsIgnoreCase(request.getRetrievalMode())) {
throw new IllegalArgumentException("多知识库检索仅支持 VECTOR 模式");
}
MultiKnowledgeRetrievalResult result = multiKnowledgeRetrievalService.search(
knowledgeIds,
request.getKeyword(),
request.getLimit(),
request.getKnowledgeNode() == null
? null
: request.getKnowledgeNode().getId(),
request.getChain());
List<Map<String, Object>> documents = new ArrayList<>();
for (Document document : result.getDocuments()) {
documents.add(toWorkflowDocument(
document,
document.getMetadata("knowledgeId", null)));
}
return buildOutputs(documents);
}
private List<Map<String, Object>> searchSingle(
BigInteger knowledgeId,
String keyword,
int limit,
KnowledgeNode knowledgeNode) {
KnowledgeRetrievalRequest request = new KnowledgeRetrievalRequest();
request.setKnowledgeId(knowledgeId);
request.setKnowledgeId(new BigInteger(id.toString()));
request.setQuery(keyword);
request.setLimit(limit);
request.setRetrievalMode(KnowledgeRetrievalModes.parse(
knowledgeNode == null
? null
: knowledgeNode.getRetrievalMode()));
request.setRetrievalMode(KnowledgeRetrievalModes.parse(knowledgeNode.getRetrievalMode()));
request.setCallerType("WORKFLOW");
request.setCallerId(knowledgeNode == null ? null : knowledgeNode.getId());
request.setCallerId(knowledgeNode.getId());
List<Document> documents = documentCollectionService.search(request);
if (limit > 0 && documents.size() > limit) {
documents = new ArrayList<>(documents.subList(0, limit));
}
List<Map<String, Object>> result = new ArrayList<>();
List<Map<String, Object>> res = new ArrayList<>();
for (Document document : documents) {
result.add(toWorkflowDocument(document, knowledgeId));
res.add(toWorkflowDocument(document, id));
}
return result;
return res;
}
private Map<String, Object> buildOutputs(List<Map<String, Object>> documents) {
Map<String, Object> outputs = new LinkedHashMap<>();
outputs.put("documents", documents);
return outputs;
};
}
/**
@@ -141,7 +74,7 @@ public class KnowledgeProviderImpl implements KnowledgeProvider {
private Map<String, Object> toWorkflowDocument(
Document document, Object knowledgeId) {
JSONObject result = JSONObject.from(document);
result.put("title", resolveWorkflowTitle(document));
result.put("title", document.getTitle());
result.put("content", document.getContent());
result.put(
"documentId",
@@ -149,30 +82,4 @@ public class KnowledgeProviderImpl implements KnowledgeProvider {
result.put("knowledgeId", knowledgeId);
return result;
}
/**
* 获取工作流文档标题,兼容历史检索结果只在元数据中保存来源标题的情况。
*
* @param document 检索文档
* @return 工作流文档标题
*/
private String resolveWorkflowTitle(Document document) {
String title = trimToNull(document.getTitle());
if (title != null) {
return title;
}
title = trimToNull(document.getMetadata("sourceFileName"));
if (title != null) {
return title;
}
return trimToNull(document.getMetadata("question"));
}
private String trimToNull(Object value) {
if (value == null) {
return null;
}
String text = String.valueOf(value).trim();
return text.isEmpty() ? null : text;
}
}

View File

@@ -1,46 +0,0 @@
package tech.easyflow.ai.easyagentsflow.knowledge;
import com.easyagents.core.document.Document;
import java.util.Collections;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 多知识库检索结果及可观察状态。
*/
public class MultiKnowledgeRetrievalResult {
private final List<Document> documents;
private final Map<String, Object> summary;
private final List<Map<String, Object>> sourceStatuses;
public MultiKnowledgeRetrievalResult(
List<Document> documents,
Map<String, Object> summary,
List<Map<String, Object>> sourceStatuses) {
this.documents = documents == null
? Collections.emptyList()
: Collections.unmodifiableList(new ArrayList<>(documents));
this.summary = summary == null
? Collections.emptyMap()
: Collections.unmodifiableMap(new LinkedHashMap<>(summary));
this.sourceStatuses = sourceStatuses == null
? Collections.emptyList()
: Collections.unmodifiableList(new ArrayList<>(sourceStatuses));
}
public List<Document> getDocuments() {
return documents;
}
public Map<String, Object> getSummary() {
return summary;
}
public List<Map<String, Object>> getSourceStatuses() {
return sourceStatuses;
}
}

View File

@@ -1,579 +0,0 @@
package tech.easyflow.ai.easyagentsflow.knowledge;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import org.springframework.stereotype.Service;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* 工作流多知识库 Embedding 契约校验与发布快照服务。
*/
@Service
public class WorkflowKnowledgeContractService {
public static final String SNAPSHOT_KEY = "knowledgeContracts";
private final DocumentCollectionService documentCollectionService;
private final ModelService modelService;
public WorkflowKnowledgeContractService(
DocumentCollectionService documentCollectionService,
ModelService modelService) {
this.documentCollectionService = documentCollectionService;
this.modelService = modelService;
}
/**
* 批量计算设计器中真正具备向量检索条件的知识库。
*
* @param collections 候选知识库
* @param tenantId 当前租户
* @return 可用知识库 ID
*/
public Set<BigInteger> findVectorReadyKnowledgeIds(
List<DocumentCollection> collections,
BigInteger tenantId) {
if (collections == null || collections.isEmpty()) {
return Collections.emptySet();
}
Map<BigInteger, Model> models = loadModels(collections);
Set<BigInteger> result = new LinkedHashSet<>();
for (DocumentCollection collection : collections) {
if (isVectorReady(collection, models, tenantId)) {
result.add(collection.getId());
}
}
return result;
}
/**
* 校验保存阶段全部多知识库节点的 Embedding 契约。
*
* @param knowledgeGroups 各知识库节点的有序引用
* @param tenantId 当前租户
*/
public void assertMultiKnowledgeContracts(
List<List<BigInteger>> knowledgeGroups,
BigInteger tenantId) {
resolveContext(onlyMultiGroups(knowledgeGroups), tenantId);
}
/**
* 解析工作流引用的全部知识库,并校验存在性与租户归属。
*
* @param content 工作流内容
* @param tenantId 工作流租户
* @return 按工作流首次引用顺序排列的知识库
*/
public List<DocumentCollection> resolveReferencedCollections(
String content,
BigInteger tenantId) {
List<List<BigInteger>> groups = readKnowledgeGroups(content);
if (groups.isEmpty()) {
return List.of();
}
Set<BigInteger> ids = new LinkedHashSet<>();
groups.forEach(ids::addAll);
Map<BigInteger, DocumentCollection> collections = loadCollections(
ids, tenantId);
return ids.stream().map(collections::get).toList();
}
/**
* 校验工作流内容并生成不包含凭据的多知识库发布契约。
*
* @param content 工作流内容
* @param tenantId 工作流租户
* @return 稳定的发布契约列表
*/
public List<Map<String, Object>> buildSnapshotContracts(
String content,
BigInteger tenantId) {
List<List<BigInteger>> groups = readKnowledgeGroups(content).stream()
.filter(group -> group.size() > 1)
.toList();
ContractContext context = resolveContext(groups, tenantId);
if (groups.isEmpty()) {
return List.of();
}
Set<BigInteger> orderedIds = new LinkedHashSet<>();
groups.forEach(orderedIds::addAll);
List<Map<String, Object>> result = new ArrayList<>();
for (BigInteger id : orderedIds) {
result.add(toContract(
context.collections.get(id),
context.models,
tenantId));
}
return List.copyOf(result);
}
/**
* 审批真正发布前重新校验提交快照与当前知识库契约是否一致。
*
* @param resourceSnapshot 待发布工作流快照
*/
public void assertSnapshotCurrent(Map<String, Object> resourceSnapshot) {
if (resourceSnapshot == null) {
throw new BusinessException("工作流发布快照不能为空");
}
String content = text(resourceSnapshot.get("content"));
BigInteger tenantId = bigInteger(resourceSnapshot.get("tenantId"));
List<Map<String, Object>> current = buildSnapshotContracts(
content, tenantId);
List<Map<String, Object>> frozen = readSnapshotContracts(
resourceSnapshot.get(SNAPSHOT_KEY));
if (!current.equals(frozen)) {
throw new BusinessException("工作流引用的知识库 Embedding 配置已变化,请重新提交发布");
}
}
/**
* 已发布工作流运行时校验本节点引用仍与发布契约一致。
*
* @param publishedSnapshot 已发布工作流快照
* @param collections 当前节点知识库
*/
public Model assertPublishedContracts(
Map<String, Object> publishedSnapshot,
List<DocumentCollection> collections) {
if (collections == null || collections.size() < 2) {
throw new BusinessException("多知识库检索至少需要两个知识库");
}
if (publishedSnapshot == null || publishedSnapshot.isEmpty()) {
throw new BusinessException("已发布工作流缺少知识库契约");
}
BigInteger tenantId = bigInteger(publishedSnapshot.get("tenantId"));
ContractContext context = resolveLoadedContext(collections, tenantId);
Model embeddingModel = requireCompatibleEmbeddingModel(
collections, context, tenantId);
Map<String, Map<String, Object>> frozenById = new LinkedHashMap<>();
for (Map<String, Object> contract : readSnapshotContracts(
publishedSnapshot.get(SNAPSHOT_KEY))) {
frozenById.put(text(contract.get("knowledgeId")), contract);
}
for (DocumentCollection collection : collections) {
Map<String, Object> frozen = frozenById.get(
String.valueOf(collection.getId()));
Map<String, Object> current = toContract(
collection, context.models, tenantId);
if (!current.equals(frozen)) {
throw new BusinessException("已发布工作流的知识库 Embedding 配置已变化,请重新发布");
}
}
return embeddingModel;
}
/**
* 校验 Agent 冻结定义中的契约指纹与当前有效配置一致。
*
* @param expectedFingerprint 冻结定义中的契约指纹
* @param collections 定义引用的全部多知识库
* @param tenantId 冻结定义租户
*/
public Model assertFrozenContractFingerprint(
String expectedFingerprint,
List<DocumentCollection> allCollections,
List<DocumentCollection> currentCollections,
BigInteger tenantId) {
ContractContext context = resolveLoadedContext(
allCollections, tenantId);
Model embeddingModel = requireCompatibleEmbeddingModel(
currentCollections, context, tenantId);
List<Map<String, Object>> current = new ArrayList<>();
for (DocumentCollection collection : allCollections) {
current.add(toContract(collection, context.models, tenantId));
}
if (!Objects.equals(expectedFingerprint, fingerprint(current))) {
throw new BusinessException("Agent 冻结工作流的知识库 Embedding 配置已变化,请重新发布 Agent");
}
return embeddingModel;
}
/**
* 使用已加载的知识库快照校验当前多库契约,并返回同一次批量读取的模型快照。
*
* @param collections 当前节点知识库快照
* @param tenantId 执行租户
* @return 已校验的 Embedding 模型快照
*/
public Model requireCompatibleEmbeddingModel(
List<DocumentCollection> collections,
BigInteger tenantId) {
ContractContext context = resolveLoadedContext(collections, tenantId);
return requireCompatibleEmbeddingModel(
collections, context, tenantId);
}
/**
* 计算发布快照中规范知识库契约的稳定指纹。
*
* @param contracts 发布快照契约
* @return SHA-256 十六进制指纹
*/
public String fingerprintSnapshotContracts(Object contracts) {
return fingerprint(readSnapshotContracts(contracts));
}
private ContractContext resolveContext(
List<List<BigInteger>> groups,
BigInteger tenantId) {
if (groups == null || groups.isEmpty()) {
return ContractContext.empty();
}
if (tenantId == null) {
throw new BusinessException("工作流租户不能为空");
}
Set<BigInteger> ids = new LinkedHashSet<>();
groups.forEach(ids::addAll);
Map<BigInteger, DocumentCollection> collections = loadCollections(
ids, tenantId);
Map<BigInteger, Model> models = loadModels(collections.values());
for (List<BigInteger> group : groups) {
assertCompatibleGroup(group, collections, models, tenantId);
}
return new ContractContext(collections, models);
}
private ContractContext resolveLoadedContext(
List<DocumentCollection> collections,
BigInteger tenantId) {
if (collections == null || collections.isEmpty()) {
throw new BusinessException("工作流引用的知识库不存在或已失效");
}
if (tenantId == null) {
throw new BusinessException("工作流租户不能为空");
}
Map<BigInteger, DocumentCollection> byId = new LinkedHashMap<>();
for (DocumentCollection collection : collections) {
if (collection == null || collection.getId() == null) {
throw new BusinessException("工作流引用的知识库不存在或已失效");
}
if (!Objects.equals(tenantId, collection.getTenantId())) {
throw new BusinessException("工作流引用了其他租户的知识库");
}
if (byId.put(collection.getId(), collection) != null) {
throw new BusinessException("知识库节点不能重复选择同一知识库");
}
}
return new ContractContext(byId, loadModels(collections));
}
private Model requireCompatibleEmbeddingModel(
List<DocumentCollection> collections,
ContractContext context,
BigInteger tenantId) {
if (collections == null || collections.size() < 2) {
throw new BusinessException("多知识库检索至少需要两个知识库");
}
List<BigInteger> ids = collections.stream()
.map(DocumentCollection::getId)
.toList();
assertCompatibleGroup(
ids, context.collections, context.models, tenantId);
Model embeddingModel = context.models.get(
collections.get(0).getVectorEmbedModelId());
if (embeddingModel == null) {
throw new BusinessException("知识库 Embedding 模型不存在或已失效");
}
return embeddingModel;
}
private void assertCompatibleGroup(
List<BigInteger> group,
Map<BigInteger, DocumentCollection> collections,
Map<BigInteger, Model> models,
BigInteger tenantId) {
DocumentCollection first = collections.get(group.get(0));
if (!isVectorReady(first, models, tenantId)) {
throw new BusinessException("知识库未完成有效的向量检索配置: " + first.getTitle());
}
for (BigInteger id : group) {
DocumentCollection current = collections.get(id);
if (!isVectorReady(current, models, tenantId)) {
throw new BusinessException("知识库未完成有效的向量检索配置: " + current.getTitle());
}
if (!Objects.equals(
first.getVectorEmbedModelId(),
current.getVectorEmbedModelId())
|| !Objects.equals(
first.getDimensionOfVectorModel(),
current.getDimensionOfVectorModel())
|| !Objects.equals(
first.getTenantId(), current.getTenantId())) {
throw new BusinessException("多知识库检索要求使用相同的 Embedding 模型和向量维度");
}
}
}
private boolean isVectorReady(
DocumentCollection collection,
Map<BigInteger, Model> models,
BigInteger tenantId) {
if (collection == null
|| collection.getId() == null
|| !Objects.equals(collection.getTenantId(), tenantId)
|| !Boolean.TRUE.equals(collection.getVectorStoreEnable())
|| collection.getVectorEmbedModelId() == null
|| collection.getDimensionOfVectorModel() == null
|| collection.getDimensionOfVectorModel() <= 0
|| collection.getVectorStoreCollection() == null
|| collection.getVectorStoreCollection().isBlank()) {
return false;
}
Model model = models.get(collection.getVectorEmbedModelId());
return model != null
&& Model.MODEL_TYPES[1].equals(model.getModelType())
&& model.getModelProvider() != null
&& text(model.getModelProvider().getProviderType()) != null
&& Objects.equals(model.getTenantId(), tenantId);
}
private Map<BigInteger, Model> loadModels(
Collection<DocumentCollection> collections) {
Set<BigInteger> modelIds = new LinkedHashSet<>();
for (DocumentCollection collection : collections) {
if (collection != null && collection.getVectorEmbedModelId() != null) {
modelIds.add(collection.getVectorEmbedModelId());
}
}
if (modelIds.isEmpty()) {
return Collections.emptyMap();
}
List<Model> loaded = modelService.listModelInstances(modelIds);
Map<BigInteger, Model> result = new LinkedHashMap<>();
if (loaded != null) {
for (Model model : loaded) {
if (model != null && model.getId() != null) {
result.put(model.getId(), model);
}
}
}
return result;
}
private Map<String, Object> toContract(
DocumentCollection collection,
Map<BigInteger, Model> models,
BigInteger tenantId) {
if (!isVectorReady(collection, models, tenantId)) {
throw new BusinessException("知识库未完成有效的向量检索配置: "
+ (collection == null ? "" : collection.getTitle()));
}
Model model = models.get(collection.getVectorEmbedModelId());
Map<String, Object> contract = new LinkedHashMap<>();
contract.put("knowledgeId", String.valueOf(collection.getId()));
contract.put("tenantId", String.valueOf(collection.getTenantId()));
contract.put("embeddingModelId", String.valueOf(model.getId()));
contract.put("embeddingDimension", collection.getDimensionOfVectorModel());
contract.put("vectorStoreCollection", collection.getVectorStoreCollection());
contract.put("vectorStoreType", nullableText(collection.getVectorStoreType()));
contract.put("modelProviderId", nullableString(model.getProviderId()));
contract.put(
"modelProviderType",
nullableText(model.getModelProvider().getProviderType()));
contract.put("modelType", model.getModelType());
contract.put("modelName", nullableText(model.getModelName()));
contract.put("modelEndpoint", nullableText(model.getEndpoint()));
contract.put("modelRequestPath", nullableText(model.getRequestPath()));
return contract;
}
private Map<BigInteger, DocumentCollection> loadCollections(
Set<BigInteger> ids,
BigInteger tenantId) {
if (tenantId == null) {
throw new BusinessException("工作流租户不能为空");
}
List<DocumentCollection> loaded = documentCollectionService.listByIds(ids);
Map<BigInteger, DocumentCollection> collections = new LinkedHashMap<>();
if (loaded != null) {
for (DocumentCollection collection : loaded) {
if (collection != null && collection.getId() != null) {
collections.put(collection.getId(), collection);
}
}
}
if (collections.size() != ids.size()) {
throw new BusinessException("工作流引用的知识库不存在或已失效");
}
for (DocumentCollection collection : collections.values()) {
if (!Objects.equals(tenantId, collection.getTenantId())) {
throw new BusinessException("工作流引用了其他租户的知识库");
}
}
return collections;
}
private List<List<BigInteger>> readKnowledgeGroups(String content) {
if (content == null || content.isBlank()) {
return List.of();
}
JSONObject root;
try {
root = JSON.parseObject(content);
} catch (RuntimeException exception) {
throw new BusinessException("工作流内容不是合法JSON");
}
JSONArray nodes = root.getJSONArray("nodes");
if (nodes == null || nodes.isEmpty()) {
return List.of();
}
List<List<BigInteger>> groups = new ArrayList<>();
for (int index = 0; index < nodes.size(); index++) {
JSONObject node = nodes.getJSONObject(index);
JSONObject data = node == null ? null : node.getJSONObject("data");
if (data == null || !"knowledgeNode".equals(nodeType(node, data))) {
continue;
}
List<BigInteger> group = new ArrayList<>();
Set<BigInteger> unique = new LinkedHashSet<>();
Object rawIds = data.get("knowledgeIds");
if (rawIds instanceof JSONArray ids && !ids.isEmpty()) {
for (Object rawId : ids) {
BigInteger id = bigInteger(rawId);
if (!unique.add(id)) {
throw new BusinessException("知识库节点不能重复选择同一知识库");
}
group.add(id);
}
} else {
group.add(bigInteger(data.get("knowledgeId")));
}
groups.add(List.copyOf(group));
}
return List.copyOf(groups);
}
private List<List<BigInteger>> onlyMultiGroups(
List<List<BigInteger>> knowledgeGroups) {
if (knowledgeGroups == null || knowledgeGroups.isEmpty()) {
return List.of();
}
return knowledgeGroups.stream()
.filter(Objects::nonNull)
.filter(group -> group.size() > 1)
.map(List::copyOf)
.toList();
}
private List<Map<String, Object>> readSnapshotContracts(Object value) {
if (value == null) {
return List.of();
}
JSONArray array;
try {
array = value instanceof JSONArray jsonArray
? jsonArray
: JSON.parseArray(JSON.toJSONString(value));
} catch (RuntimeException exception) {
throw new BusinessException("工作流知识库发布契约无效");
}
List<Map<String, Object>> result = new ArrayList<>();
for (int index = 0; index < array.size(); index++) {
JSONObject object = array.getJSONObject(index);
if (object == null) {
throw new BusinessException("工作流知识库发布契约无效");
}
Map<String, Object> contract = new LinkedHashMap<>();
contract.put("knowledgeId", text(object.get("knowledgeId")));
contract.put("tenantId", text(object.get("tenantId")));
contract.put("embeddingModelId", text(object.get("embeddingModelId")));
contract.put("embeddingDimension", object.getInteger("embeddingDimension"));
contract.put("vectorStoreCollection", nullableText(object.getString("vectorStoreCollection")));
contract.put("vectorStoreType", nullableText(object.getString("vectorStoreType")));
contract.put("modelProviderId", nullableText(object.getString("modelProviderId")));
contract.put("modelProviderType", nullableText(object.getString("modelProviderType")));
contract.put("modelType", nullableText(object.getString("modelType")));
contract.put("modelName", nullableText(object.getString("modelName")));
contract.put("modelEndpoint", nullableText(object.getString("modelEndpoint")));
contract.put("modelRequestPath", nullableText(object.getString("modelRequestPath")));
result.add(contract);
}
return List.copyOf(result);
}
private String nodeType(JSONObject node, JSONObject data) {
String rootType = text(node.getString("type"));
String dataType = text(data.getString("type"));
if (rootType != null && dataType != null
&& !Objects.equals(rootType, dataType)) {
throw new BusinessException("工作流节点类型与节点数据类型不一致");
}
return rootType == null ? dataType : rootType;
}
private BigInteger bigInteger(Object value) {
String normalized = text(value);
if (normalized == null) {
throw new BusinessException("知识库或租户 ID 无效");
}
try {
BigInteger result = new BigInteger(normalized);
if (result.signum() <= 0) {
throw new NumberFormatException("non-positive");
}
return result;
} catch (NumberFormatException exception) {
throw new BusinessException("知识库或租户 ID 无效");
}
}
private String text(Object value) {
if (value == null) {
return null;
}
String result = String.valueOf(value).trim();
return result.isEmpty() ? null : result;
}
private String nullableText(String value) {
return text(value);
}
private String nullableString(Object value) {
return value == null ? null : String.valueOf(value);
}
private String fingerprint(Object value) {
try {
byte[] bytes = JSON.toJSONString(value)
.getBytes(StandardCharsets.UTF_8);
return HexFormat.of().formatHex(
MessageDigest.getInstance("SHA-256").digest(bytes));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 is unavailable", exception);
}
}
private record ContractContext(
Map<BigInteger, DocumentCollection> collections,
Map<BigInteger, Model> models) {
private static ContractContext empty() {
return new ContractContext(
Collections.emptyMap(),
Collections.emptyMap());
}
}
}

View File

@@ -85,7 +85,7 @@ public class ChainEventListenerForSave implements ChainEventListener {
record.setStartTime(new Date());
record.setStatus(state.getStatus().getValue());
record.setCreatedKey(WorkFlowUtil.getCreatedKey(chain));
record.setCreatedBy(WorkFlowUtil.getCreatedBy(chain));
record.setCreatedBy(WorkFlowUtil.getOperator(chain).getId().toString());
// 启动记录保留同步确认,避免执行接口返回后立即查询时记录尚不可见。
try {
workflowExecResultService.save(record);

View File

@@ -1,6 +1,5 @@
package tech.easyflow.ai.easyagentsflow.repository;
import com.alibaba.fastjson2.JSON;
import com.easyagents.flow.core.chain.ChainDefinition;
import com.easyagents.flow.core.node.ConfirmNode;
import com.easyagents.flow.core.parser.ChainParser;
@@ -85,35 +84,9 @@ public class AgentWorkflowSnapshotFactory {
snapshot.put("englishName", workflow.getEnglishName());
snapshot.put("revision", workflow.getRevision());
snapshot.put("content", prepared.content());
snapshot.put("tenantId", workflow.getTenantId());
snapshot.put(
"publishedSnapshotJson",
knowledgeRuntimeSnapshot(workflow));
return snapshot;
}
/**
* 提取冻结执行所需的知识库契约,不携带工作流快照中的其他字段。
*
* @param workflow 已发布工作流
* @return 租户与知识库契约白名单
*/
Map<String, Object> knowledgeRuntimeSnapshot(Workflow workflow) {
Map<String, Object> runtimeSnapshot = new LinkedHashMap<>();
runtimeSnapshot.put("tenantId", workflow.getTenantId());
Map<String, Object> publishedSnapshot =
workflow.getPublishedSnapshotJson();
Object contracts = publishedSnapshot == null
? null
: publishedSnapshot.get("knowledgeContracts");
runtimeSnapshot.put(
"knowledgeContracts",
contracts == null
? java.util.List.of()
: JSON.parse(JSON.toJSONString(contracts)));
return runtimeSnapshot;
}
/**
* Agent 可执行 Workflow 的准备结果。
*

View File

@@ -2,11 +2,8 @@ package tech.easyflow.ai.easyagentsflow.repository;
import com.easyagents.flow.core.chain.ChainDefinition;
import org.springframework.stereotype.Component;
import tech.easyflow.ai.easyagentsflow.knowledge.WorkflowKnowledgeContractService;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
@@ -27,7 +24,6 @@ public class FrozenWorkflowDefinitionRegistry {
private static final int MAX_ENTRIES = 512;
private final AgentWorkflowSnapshotFactory snapshotFactory;
private final WorkflowKnowledgeContractService workflowKnowledgeContractService;
private final Map<String, ChainDefinition> definitions =
new LinkedHashMap<>(32, 0.75F, true);
private final Map<String, Workflow> workflows =
@@ -37,13 +33,9 @@ public class FrozenWorkflowDefinitionRegistry {
* 创建冻结定义注册表。
*
* @param snapshotFactory Agent Workflow 冻结快照工厂
* @param workflowKnowledgeContractService 知识库契约服务
*/
public FrozenWorkflowDefinitionRegistry(
AgentWorkflowSnapshotFactory snapshotFactory,
WorkflowKnowledgeContractService workflowKnowledgeContractService) {
public FrozenWorkflowDefinitionRegistry(AgentWorkflowSnapshotFactory snapshotFactory) {
this.snapshotFactory = snapshotFactory;
this.workflowKnowledgeContractService = workflowKnowledgeContractService;
}
/**
@@ -56,19 +48,7 @@ public class FrozenWorkflowDefinitionRegistry {
public String register(Workflow workflow) {
AgentWorkflowSnapshotFactory.PreparedWorkflow prepared = snapshotFactory.prepare(workflow);
String preparedContent = prepared.content();
Map<String, Object> runtimeSnapshot =
snapshotFactory.knowledgeRuntimeSnapshot(workflow);
if (workflow.getTenantId() == null
|| workflow.getTenantId().signum() <= 0) {
throw new BusinessException("绑定工作流租户快照不完整,请重新发布工作流");
}
String contractFingerprint = workflowKnowledgeContractService
.fingerprintSnapshotContracts(
runtimeSnapshot.get("knowledgeContracts"));
String id = PREFIX + workflow.getId()
+ ":" + workflow.getTenantId()
+ ":" + contractFingerprint
+ ":" + sha256(preparedContent);
String id = PREFIX + workflow.getId() + ":" + sha256(preparedContent);
synchronized (definitions) {
if (definitions.containsKey(id)) {
definitions.get(id);
@@ -79,7 +59,7 @@ public class FrozenWorkflowDefinitionRegistry {
definition.setName(workflow.getEnglishName());
definition.setDescription(workflow.getDescription());
definitions.put(id, definition);
workflows.put(id, frozenWorkflow(workflow, runtimeSnapshot));
workflows.put(id, workflow);
while (definitions.size() > MAX_ENTRIES) {
String eldest = definitions.keySet().iterator().next();
definitions.remove(eldest);
@@ -120,66 +100,9 @@ public class FrozenWorkflowDefinitionRegistry {
* @return 是否冻结定义
*/
public boolean isFrozen(String definitionId) {
return isFrozenDefinitionId(definitionId);
}
/**
* 判断定义 ID 是否属于冻结 Agent 工作流,不触发注册表实例创建。
*
* @param definitionId 定义 ID
* @return 是否冻结定义
*/
public static boolean isFrozenDefinitionId(String definitionId) {
return definitionId != null && definitionId.startsWith(PREFIX);
}
/**
* 解析冻结定义中可独立校验的租户和知识库契约指纹。
*
* @param definitionId 冻结定义 ID
* @return 冻结定义身份
*/
public static FrozenDefinitionIdentity parseIdentity(String definitionId) {
if (!isFrozenDefinitionId(definitionId)) {
throw new IllegalArgumentException("Not a frozen workflow definition");
}
String[] parts = definitionId.substring(PREFIX.length())
.split(":", 4);
if (parts.length != 4
|| !parts[2].matches("[0-9a-f]{64}")
|| !parts[3].matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException("Invalid frozen workflow definition");
}
try {
BigInteger workflowId = new BigInteger(parts[0]);
BigInteger tenantId = new BigInteger(parts[1]);
if (workflowId.signum() <= 0 || tenantId.signum() <= 0) {
throw new NumberFormatException("non-positive");
}
return new FrozenDefinitionIdentity(
workflowId, tenantId, parts[2]);
} catch (NumberFormatException exception) {
throw new IllegalArgumentException(
"Invalid frozen workflow definition", exception);
}
}
private Workflow frozenWorkflow(
Workflow source,
Map<String, Object> runtimeSnapshot) {
Workflow frozen = new Workflow();
frozen.setId(source.getId());
frozen.setTenantId(source.getTenantId());
frozen.setTitle(source.getTitle());
frozen.setDescription(source.getDescription());
frozen.setEnglishName(source.getEnglishName());
frozen.setRevision(source.getRevision());
frozen.setContent(source.getContent());
frozen.setPublishedSnapshotJson(
new LinkedHashMap<>(runtimeSnapshot));
return frozen;
}
private String sha256(String content) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
@@ -188,17 +111,4 @@ public class FrozenWorkflowDefinitionRegistry {
throw new IllegalStateException("SHA-256 is unavailable", exception);
}
}
/**
* 冻结定义中不依赖进程内 LRU 状态的执行身份。
*
* @param workflowId 工作流 ID
* @param tenantId 工作流租户
* @param knowledgeContractFingerprint 知识库契约指纹
*/
public record FrozenDefinitionIdentity(
BigInteger workflowId,
BigInteger tenantId,
String knowledgeContractFingerprint) {
}
}

View File

@@ -1,6 +1,5 @@
package tech.easyflow.ai.easyagentsflow.service;
import com.easyagents.document.core.exception.DocumentParseException;
import com.easyagents.flow.core.chain.ChainState;
import com.easyagents.flow.core.chain.ExceptionSummary;
import com.easyagents.flow.core.chain.NodeState;
@@ -142,12 +141,9 @@ public class TinyFlowService {
node.setResult(resolved);
}
if (nodeState != null
&& nodeState.getStatus() == NodeStatus.SUSPEND
&& chainState.getSuspendForParameters() != null) {
// 只有当参数不为空时才覆盖
if (chainState.getSuspendForParameters() != null) {
node.setSuspendForParameters(chainState.getSuspendForParameters());
} else {
node.setSuspendForParameters(null);
}
}
@@ -169,10 +165,6 @@ public class TinyFlowService {
String rootMessage = StringUtil.hasText(error.getRootCauseMessage())
? error.getRootCauseMessage()
: error.getMessage();
if (DocumentParseException.class.getName().equals(rootClass)
&& StringUtil.hasText(rootMessage)) {
return rootMessage;
}
if (StringUtil.noText(rootClass)) {
return rootMessage;
}

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