Compare commits
4 Commits
main
...
885811a691
| Author | SHA1 | Date | |
|---|---|---|---|
| 885811a691 | |||
| b13280cf1f | |||
| 611119dea0 | |||
| 7a38518811 |
@@ -1,18 +1,23 @@
|
|||||||
package tech.easyflow.admin.controller.ai;
|
package tech.easyflow.admin.controller.ai;
|
||||||
|
|
||||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||||
|
import com.easyagents.core.model.embedding.EmbeddingModel;
|
||||||
import com.mybatisflex.core.paginate.Page;
|
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.DocumentChunk;
|
||||||
|
import tech.easyflow.ai.entity.DocumentCollection;
|
||||||
|
import tech.easyflow.ai.entity.Model;
|
||||||
import tech.easyflow.ai.service.DocumentChunkService;
|
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.annotation.UsePermission;
|
||||||
import tech.easyflow.common.domain.Result;
|
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 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 jakarta.servlet.http.HttpServletRequest;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
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.enums.ResourceLookup;
|
||||||
import tech.easyflow.system.permission.resource.RequireResourceAccess;
|
import tech.easyflow.system.permission.resource.RequireResourceAccess;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 控制层。
|
* 控制层。
|
||||||
@@ -35,12 +44,19 @@ import java.util.List;
|
|||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/documentChunk")
|
@RequestMapping("/api/v1/documentChunk")
|
||||||
@UsePermission(moduleName = "/api/v1/documentCollection")
|
@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) {
|
public DocumentChunkController(DocumentChunkService service) {
|
||||||
this.documentChunkService = service;
|
super(service);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("page")
|
@GetMapping("page")
|
||||||
@@ -52,30 +68,9 @@ public class DocumentChunkController extends BaseController {
|
|||||||
idExpr = "#request.getParameter('documentId')",
|
idExpr = "#request.getParameter('documentId')",
|
||||||
denyMessage = "无权限访问知识库"
|
denyMessage = "无权限访问知识库"
|
||||||
)
|
)
|
||||||
public Result<Page<DocumentChunk>> page(
|
@Override
|
||||||
HttpServletRequest request,
|
public Result<Page<DocumentChunk>> page(HttpServletRequest request, String sortKey, String sortType, Long pageNumber, Long pageSize) {
|
||||||
Long pageNumber,
|
return super.page(request, sortKey, sortType, pageNumber, pageSize);
|
||||||
Long pageSize
|
|
||||||
) {
|
|
||||||
String documentIdValue = request.getParameter("documentId");
|
|
||||||
if (documentIdValue == null || documentIdValue.isBlank()) {
|
|
||||||
return Result.<Page<DocumentChunk>>fail("documentId不能为空", null);
|
|
||||||
}
|
|
||||||
BigInteger documentId;
|
|
||||||
try {
|
|
||||||
documentId = new BigInteger(documentIdValue);
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
return Result.<Page<DocumentChunk>>fail("documentId格式不正确", null);
|
|
||||||
}
|
|
||||||
long normalizedPageNumber = pageNumber == null || pageNumber < 1 ? 1 : pageNumber;
|
|
||||||
long normalizedPageSize = pageSize == null || pageSize < 1 ? 10 : pageSize;
|
|
||||||
QueryWrapper query = QueryWrapper.create()
|
|
||||||
.eq(DocumentChunk::getDocumentId, documentId)
|
|
||||||
.orderBy("sorting asc");
|
|
||||||
return Result.ok(documentChunkService.page(
|
|
||||||
new Page<>(normalizedPageNumber, normalizedPageSize),
|
|
||||||
query
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("update")
|
@PostMapping("update")
|
||||||
@@ -84,23 +79,43 @@ public class DocumentChunkController extends BaseController {
|
|||||||
resource = CategoryResourceType.KNOWLEDGE,
|
resource = CategoryResourceType.KNOWLEDGE,
|
||||||
action = ResourceAction.MANAGE,
|
action = ResourceAction.MANAGE,
|
||||||
lookup = ResourceLookup.DOCUMENT_CHUNK_ID,
|
lookup = ResourceLookup.DOCUMENT_CHUNK_ID,
|
||||||
idExpr = "#request.id",
|
idExpr = "#documentChunk.id",
|
||||||
denyMessage = "无权限管理知识库"
|
denyMessage = "无权限管理知识库"
|
||||||
)
|
)
|
||||||
public Result<?> update(
|
public Result<?> update(@JsonBody DocumentChunk documentChunk) {
|
||||||
@JsonBody(required = true, skipConvertError = false)
|
boolean success = service.updateById(documentChunk);
|
||||||
DocumentChunkContentUpdateRequest request
|
if (success){
|
||||||
) {
|
DocumentChunk record = documentChunkService.getById(documentChunk.getId());
|
||||||
DocumentChunk current = documentChunkService.getById(request.getId());
|
DocumentCollection knowledge = documentCollectionService.getById(record.getDocumentCollectionId());
|
||||||
if (current == null) {
|
if (knowledge == null) {
|
||||||
return Result.fail(1, "记录不存在");
|
return Result.fail(1, "知识库不存在");
|
||||||
}
|
}
|
||||||
DocumentChunk updated = documentChunkService.updateContent(
|
DocumentStore documentStore = knowledge.toDocumentStore();
|
||||||
current.getDocumentCollectionId(),
|
if (documentStore == null) {
|
||||||
current.getId(),
|
return Result.fail(2, "知识库没有配置向量库");
|
||||||
request.getContent()
|
}
|
||||||
);
|
try {
|
||||||
return Result.ok(updated);
|
// 设置向量模型
|
||||||
|
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")
|
@PostMapping("removeChunk")
|
||||||
@@ -112,58 +127,36 @@ public class DocumentChunkController extends BaseController {
|
|||||||
idExpr = "#chunkId",
|
idExpr = "#chunkId",
|
||||||
denyMessage = "无权限管理知识库"
|
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);
|
DocumentChunk docChunk = documentChunkService.getById(chunkId);
|
||||||
if (docChunk == null) {
|
if (docChunk == null) {
|
||||||
return Result.fail(1, "记录不存在");
|
return Result.fail(1, "记录不存在");
|
||||||
}
|
}
|
||||||
return Result.ok(documentChunkService.deleteChunk(
|
DocumentCollection knowledge = documentCollectionService.getById(docChunk.getDocumentCollectionId());
|
||||||
docChunk.getDocumentCollectionId(),
|
if (knowledge == null) {
|
||||||
chunkId
|
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")
|
return super.remove(chunkId);
|
||||||
@SaCheckPermission("/api/v1/documentCollection/query")
|
} finally {
|
||||||
@RequireResourceAccess(
|
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
|
||||||
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()
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,10 +118,12 @@ public class DocumentController extends BaseCurdController<DocumentService, Docu
|
|||||||
List<Serializable> ids = Collections.singletonList(id);
|
List<Serializable> ids = Collections.singletonList(id);
|
||||||
Result<?> result = onRemoveBefore(ids);
|
Result<?> result = onRemoveBefore(ids);
|
||||||
if (result != null) return result;
|
if (result != null) return result;
|
||||||
boolean success = documentService.removeDoc(id);
|
boolean isSuccess = documentService.removeDoc(id);
|
||||||
if (success) {
|
if (!isSuccess){
|
||||||
onRemoveAfter(ids);
|
return Result.ok(false);
|
||||||
}
|
}
|
||||||
|
boolean success = service.removeById(id);
|
||||||
|
onRemoveAfter(ids);
|
||||||
return Result.ok(success);
|
return Result.ok(success);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,12 +18,10 @@ import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
|
|||||||
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
|
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
|
||||||
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
|
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
|
||||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
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.easyagentsflow.support.PublishedWorkflowDefinitionIds;
|
||||||
import tech.easyflow.ai.entity.Plugin;
|
import tech.easyflow.ai.entity.Plugin;
|
||||||
import tech.easyflow.ai.entity.PluginItem;
|
import tech.easyflow.ai.entity.PluginItem;
|
||||||
import tech.easyflow.ai.entity.Workflow;
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
import tech.easyflow.ai.entity.WorkflowExecResult;
|
|
||||||
import tech.easyflow.ai.enums.PluginType;
|
import tech.easyflow.ai.enums.PluginType;
|
||||||
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
|
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
|
||||||
import tech.easyflow.ai.service.PluginService;
|
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.AgentResourceReferenceService;
|
||||||
import tech.easyflow.ai.service.PluginVisibilityService;
|
import tech.easyflow.ai.service.PluginVisibilityService;
|
||||||
import tech.easyflow.ai.service.WorkflowService;
|
import tech.easyflow.ai.service.WorkflowService;
|
||||||
import tech.easyflow.ai.service.WorkflowExecResultService;
|
|
||||||
import tech.easyflow.common.constant.Constants;
|
import tech.easyflow.common.constant.Constants;
|
||||||
import tech.easyflow.common.annotation.UsePermission;
|
import tech.easyflow.common.annotation.UsePermission;
|
||||||
import tech.easyflow.common.domain.Result;
|
import tech.easyflow.common.domain.Result;
|
||||||
@@ -94,15 +91,11 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
|
|||||||
@Resource
|
@Resource
|
||||||
private WorkflowService workflowService;
|
private WorkflowService workflowService;
|
||||||
@Resource
|
@Resource
|
||||||
private WorkflowExecResultService workflowExecResultService;
|
|
||||||
@Resource
|
|
||||||
private ChainExecutor chainExecutor;
|
private ChainExecutor chainExecutor;
|
||||||
@Resource
|
@Resource
|
||||||
private TinyFlowService tinyFlowService;
|
private TinyFlowService tinyFlowService;
|
||||||
@Resource
|
@Resource
|
||||||
private WorkflowCheckService workflowCheckService;
|
private WorkflowCheckService workflowCheckService;
|
||||||
@Resource
|
|
||||||
private WorkflowResumeService workflowResumeService;
|
|
||||||
|
|
||||||
@PostMapping("/tool/save")
|
@PostMapping("/tool/save")
|
||||||
@SaCheckPermission("/api/v1/plugin/save")
|
@SaCheckPermission("/api/v1/plugin/save")
|
||||||
@@ -222,7 +215,6 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
|
|||||||
@SaCheckPermission("/api/v1/plugin/query")
|
@SaCheckPermission("/api/v1/plugin/query")
|
||||||
public Result<ChainInfo> pluginToolTestChainStatus(@JsonBody(value = "executeId", required = true) String executeId,
|
public Result<ChainInfo> pluginToolTestChainStatus(@JsonBody(value = "executeId", required = true) String executeId,
|
||||||
@JsonBody("nodes") List<NodeInfo> nodes) {
|
@JsonBody("nodes") List<NodeInfo> nodes) {
|
||||||
assertPluginTestExecutionOwnership(executeId);
|
|
||||||
return Result.ok(tinyFlowService.getChainStatus(executeId, nodes));
|
return Result.ok(tinyFlowService.getChainStatus(executeId, nodes));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,33 +229,10 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
|
|||||||
@SaCheckPermission("/api/v1/plugin/query")
|
@SaCheckPermission("/api/v1/plugin/query")
|
||||||
public Result<Void> pluginToolTestResume(@JsonBody(value = "executeId", required = true) String executeId,
|
public Result<Void> pluginToolTestResume(@JsonBody(value = "executeId", required = true) String executeId,
|
||||||
@JsonBody("confirmParams") Map<String, Object> confirmParams) {
|
@JsonBody("confirmParams") Map<String, Object> confirmParams) {
|
||||||
assertPluginTestExecutionOwnership(executeId);
|
chainExecutor.resumeAsync(executeId, confirmParams);
|
||||||
workflowResumeService.resume(executeId, confirmParams);
|
|
||||||
return Result.ok();
|
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) {
|
private void handleArray(JSONArray array) {
|
||||||
for (Object o : array) {
|
for (Object o : array) {
|
||||||
JSONObject obj = (JSONObject) o;
|
JSONObject obj = (JSONObject) o;
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package tech.easyflow.admin.controller.ai;
|
package tech.easyflow.admin.controller.ai;
|
||||||
|
|
||||||
import cn.hutool.core.io.IoUtil;
|
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.paginate.Page;
|
||||||
import com.mybatisflex.core.query.QueryColumn;
|
import com.mybatisflex.core.query.QueryColumn;
|
||||||
import com.mybatisflex.core.query.QueryWrapper;
|
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 org.springframework.web.multipart.MultipartFile;
|
||||||
import tech.easyflow.ai.documentimport.DocumentImportDtos;
|
import tech.easyflow.ai.documentimport.DocumentImportDtos;
|
||||||
import tech.easyflow.ai.documentimport.task.DocumentImportTaskStatusStreamService;
|
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.KnowledgeShareLimitedConfigRequest;
|
||||||
import tech.easyflow.ai.dto.KnowledgeSearchResultItem;
|
import tech.easyflow.ai.dto.KnowledgeSearchResultItem;
|
||||||
import tech.easyflow.ai.entity.Document;
|
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.KnowledgeShareAuditService;
|
||||||
import tech.easyflow.ai.service.KnowledgeShareService;
|
import tech.easyflow.ai.service.KnowledgeShareService;
|
||||||
import tech.easyflow.ai.service.ModelService;
|
import tech.easyflow.ai.service.ModelService;
|
||||||
|
import tech.easyflow.ai.support.DocumentStoreLifecycleSupport;
|
||||||
import tech.easyflow.ai.vo.FaqImportResultVo;
|
import tech.easyflow.ai.vo.FaqImportResultVo;
|
||||||
import tech.easyflow.ai.vo.KnowledgeShareAuthContext;
|
import tech.easyflow.ai.vo.KnowledgeShareAuthContext;
|
||||||
import tech.easyflow.ai.vo.KnowledgeShareViewDetail;
|
import tech.easyflow.ai.vo.KnowledgeShareViewDetail;
|
||||||
@@ -62,6 +62,7 @@ import java.net.URLEncoder;
|
|||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.text.SimpleDateFormat;
|
import java.text.SimpleDateFormat;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
@@ -504,27 +505,43 @@ public class ShareKnowledgeController {
|
|||||||
@PostMapping("/documentChunk/update")
|
@PostMapping("/documentChunk/update")
|
||||||
public Result<?> updateDocumentChunk(
|
public Result<?> updateDocumentChunk(
|
||||||
@RequestParam String shareKey,
|
@RequestParam String shareKey,
|
||||||
@JsonBody(required = true, skipConvertError = false)
|
@JsonBody DocumentChunk documentChunk
|
||||||
DocumentChunkContentUpdateRequest request
|
|
||||||
) {
|
) {
|
||||||
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
||||||
shareKey,
|
shareKey,
|
||||||
null,
|
null,
|
||||||
KnowledgeShareActionScope.CONTENT_UPDATE.name()
|
KnowledgeShareActionScope.CONTENT_UPDATE.name()
|
||||||
);
|
);
|
||||||
DocumentChunk current = documentChunkService.getById(request.getId());
|
DocumentChunk current = documentChunkService.getById(documentChunk.getId());
|
||||||
if (current == null || current.getDocumentCollectionId() == null
|
if (current == null || current.getDocumentCollectionId() == null
|
||||||
|| current.getDocumentCollectionId().compareTo(context.getKnowledge().getId()) != 0) {
|
|| current.getDocumentCollectionId().compareTo(context.getKnowledge().getId()) != 0) {
|
||||||
throw new BusinessException("记录不存在");
|
throw new BusinessException("记录不存在");
|
||||||
}
|
}
|
||||||
DocumentChunk updated = documentChunkService.updateContent(
|
boolean success = documentChunkService.updateById(documentChunk);
|
||||||
context.getKnowledge().getId(),
|
if (success) {
|
||||||
current.getId(),
|
DocumentStore documentStore = context.getKnowledge().toDocumentStore();
|
||||||
request.getContent()
|
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,
|
audit(context, "更新分享文档 Chunk", "KNOWLEDGE_SHARE_URL_WRITE", true,
|
||||||
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", request.getId()));
|
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", documentChunk.getId()));
|
||||||
return Result.ok(updated);
|
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) {
|
|| current.getDocumentCollectionId().compareTo(context.getKnowledge().getId()) != 0) {
|
||||||
return Result.fail(1, "记录不存在");
|
return Result.fail(1, "记录不存在");
|
||||||
}
|
}
|
||||||
DocumentChunkDeleteResult removed = documentChunkService.deleteChunk(
|
DocumentStore documentStore = context.getKnowledge().toDocumentStore();
|
||||||
context.getKnowledge().getId(),
|
if (documentStore == null) {
|
||||||
chunkId
|
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,
|
audit(context, "删除分享文档 Chunk", "KNOWLEDGE_SHARE_URL_WRITE", true,
|
||||||
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", chunkId));
|
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package tech.easyflow.admin.controller.ai;
|
package tech.easyflow.admin.controller.ai;
|
||||||
|
|
||||||
|
import com.easyagents.flow.core.chain.ChainStatus;
|
||||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||||
import com.mybatisflex.core.query.QueryWrapper;
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
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.entity.WorkflowCheckStage;
|
||||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
||||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
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.support.PublishedWorkflowDefinitionIds;
|
||||||
import tech.easyflow.ai.entity.Workflow;
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
import tech.easyflow.ai.entity.WorkflowExecResult;
|
import tech.easyflow.ai.entity.WorkflowExecResult;
|
||||||
@@ -64,8 +64,6 @@ public class WorkflowChatController {
|
|||||||
@Resource
|
@Resource
|
||||||
private ChainExecutor chainExecutor;
|
private ChainExecutor chainExecutor;
|
||||||
@Resource
|
@Resource
|
||||||
private WorkflowResumeService workflowResumeService;
|
|
||||||
@Resource
|
|
||||||
private WorkflowExecResultService execResultService;
|
private WorkflowExecResultService execResultService;
|
||||||
@Resource
|
@Resource
|
||||||
private WorkflowExecStepService execStepService;
|
private WorkflowExecStepService execStepService;
|
||||||
@@ -173,8 +171,19 @@ public class WorkflowChatController {
|
|||||||
@JsonBody("confirmParams")
|
@JsonBody("confirmParams")
|
||||||
Map<String, Object> confirmParams
|
Map<String, Object> confirmParams
|
||||||
) {
|
) {
|
||||||
assertExecutionOwnership(executeId);
|
WorkflowExecResult record = assertExecutionOwnership(executeId);
|
||||||
workflowResumeService.resume(executeId, confirmParams);
|
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();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
|
|||||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
||||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService;
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService;
|
||||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
||||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
|
|
||||||
import tech.easyflow.ai.entity.Workflow;
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
import tech.easyflow.ai.enums.PublishStatus;
|
import tech.easyflow.ai.enums.PublishStatus;
|
||||||
import tech.easyflow.ai.publish.WorkflowPublishAppService;
|
import tech.easyflow.ai.publish.WorkflowPublishAppService;
|
||||||
@@ -95,8 +94,6 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
|||||||
@Resource
|
@Resource
|
||||||
private WorkflowRunningParameterResolver workflowRunningParameterResolver;
|
private WorkflowRunningParameterResolver workflowRunningParameterResolver;
|
||||||
@Resource
|
@Resource
|
||||||
private WorkflowResumeService workflowResumeService;
|
|
||||||
@Resource
|
|
||||||
private ResourceAccessService resourceAccessService;
|
private ResourceAccessService resourceAccessService;
|
||||||
@Resource
|
@Resource
|
||||||
private WorkflowVisibilityQueryHelper workflowVisibilityQueryHelper;
|
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,
|
public Result<Void> resume(@JsonBody(value = "executeId", required = true) String executeId,
|
||||||
@JsonBody("confirmParams") Map<String, Object> confirmParams) {
|
@JsonBody("confirmParams") Map<String, Object> confirmParams) {
|
||||||
workflowResumeService.resume(executeId, confirmParams);
|
if (!chainExecutor.resumeAsyncIfSuspended(executeId, confirmParams)) {
|
||||||
|
throw new BusinessException(
|
||||||
|
409,
|
||||||
|
40901,
|
||||||
|
"当前执行状态不可恢复,仅暂停中的工作流允许恢复");
|
||||||
|
}
|
||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package tech.easyflow.admin.controller.ai;
|
package tech.easyflow.admin.controller.ai;
|
||||||
|
|
||||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||||
import cn.dev33.satoken.annotation.SaIgnore;
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
@@ -97,10 +96,11 @@ public class WorkflowShareController {
|
|||||||
* @return 工作流标识
|
* @return 工作流标识
|
||||||
*/
|
*/
|
||||||
@GetMapping("/resolve")
|
@GetMapping("/resolve")
|
||||||
@SaIgnore
|
|
||||||
public Result<Map<String, BigInteger>> resolveUrlShare(HttpServletRequest request) {
|
public Result<Map<String, BigInteger>> resolveUrlShare(HttpServletRequest request) {
|
||||||
WorkflowShare share = workflowShareService.resolvePublicChatShare(
|
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
|
||||||
request.getHeader(WorkflowSharePolicy.CHAT_SHARE_KEY_HEADER)
|
WorkflowShare share = workflowShareService.resolveChatShare(
|
||||||
|
request.getHeader(WorkflowSharePolicy.CHAT_SHARE_KEY_HEADER),
|
||||||
|
loginAccount.getTenantId()
|
||||||
);
|
);
|
||||||
return Result.ok(Map.of("workflowId", share.getWorkflowId()));
|
return Result.ok(Map.of("workflowId", share.getWorkflowId()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,17 +52,11 @@ public record WorkflowDesignerOptionsView(
|
|||||||
* @param id 知识库 ID
|
* @param id 知识库 ID
|
||||||
* @param title 知识库标题
|
* @param title 知识库标题
|
||||||
* @param description 知识库描述
|
* @param description 知识库描述
|
||||||
* @param vectorEmbedModelId Embedding 模型 ID
|
|
||||||
* @param dimensionOfVectorModel 向量维度
|
|
||||||
* @param vectorStoreEnabled 是否可用于向量检索
|
|
||||||
*/
|
*/
|
||||||
public record KnowledgeOption(
|
public record KnowledgeOption(
|
||||||
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
|
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
|
||||||
String title,
|
String title,
|
||||||
String description,
|
String description
|
||||||
@JsonSerialize(using = ToStringSerializer.class) BigInteger vectorEmbedModelId,
|
|
||||||
Integer dimensionOfVectorModel,
|
|
||||||
Boolean vectorStoreEnabled
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,17 +19,11 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
import javax.annotation.PostConstruct;
|
import javax.annotation.PostConstruct;
|
||||||
import javax.annotation.PreDestroy;
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.time.Duration;
|
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
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.AtomicBoolean;
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
@@ -46,15 +40,6 @@ public class WorkflowChatEventStream {
|
|||||||
private final ChainExecutor chainExecutor;
|
private final ChainExecutor chainExecutor;
|
||||||
private final Map<String, StreamSession> sessions =
|
private final Map<String, StreamSession> sessions =
|
||||||
new ConcurrentHashMap<>();
|
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);
|
chainExecutor.addErrorListener(this::onChainError);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 关闭断开会话清理线程并释放残留外部资源。
|
|
||||||
*/
|
|
||||||
@PreDestroy
|
|
||||||
public void shutdown() {
|
|
||||||
sessions.values().forEach(this::removeSession);
|
|
||||||
detachedSessionCleaner.shutdownNow();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动工作流并返回其 SSE 连接。
|
* 启动工作流并返回其 SSE 连接。
|
||||||
*
|
*
|
||||||
@@ -91,53 +67,11 @@ public class WorkflowChatEventStream {
|
|||||||
* @return SSE 连接
|
* @return SSE 连接
|
||||||
*/
|
*/
|
||||||
public SseEmitter start(String definitionId, Map<String, Object> variables) {
|
public SseEmitter start(String definitionId, Map<String, Object> variables) {
|
||||||
return start(definitionId, variables, () -> {
|
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MILLIS);
|
||||||
});
|
StreamSession session = new StreamSession(emitter);
|
||||||
}
|
emitter.onTimeout(() -> disconnect(session, "运行连接超时"));
|
||||||
|
emitter.onError(error -> disconnect(session, "运行连接已断开"));
|
||||||
/**
|
emitter.onCompletion(() -> removeSession(session));
|
||||||
* 启动工作流并在流会话结束时执行清理回调。
|
|
||||||
*
|
|
||||||
* @param definitionId 工作流定义 ID
|
|
||||||
* @param variables 运行变量
|
|
||||||
* @param cleanup 终态、启动失败或连接断开后的幂等清理任务
|
|
||||||
* @return SSE 连接
|
|
||||||
*/
|
|
||||||
public SseEmitter start(
|
|
||||||
String definitionId,
|
|
||||||
Map<String, Object> variables,
|
|
||||||
Runnable cleanup
|
|
||||||
) {
|
|
||||||
return start(definitionId, variables, cleanup, Duration.ZERO);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 启动工作流并将浏览器连接与 Runtime 生命周期分离。
|
|
||||||
*
|
|
||||||
* <p>浏览器断开后不取消工作流;在保留期内继续监听真实终态并执行清理,
|
|
||||||
* 超过保留期时由租约兜底释放资源。</p>
|
|
||||||
*
|
|
||||||
* @param definitionId 工作流定义 ID
|
|
||||||
* @param variables 运行变量
|
|
||||||
* @param cleanup 终态、启动失败或保留期结束后的幂等清理任务
|
|
||||||
* @param detachedRetention 浏览器断开后的监听保留时长
|
|
||||||
* @return SSE 连接
|
|
||||||
*/
|
|
||||||
public SseEmitter start(
|
|
||||||
String definitionId,
|
|
||||||
Map<String, Object> variables,
|
|
||||||
Runnable cleanup,
|
|
||||||
Duration detachedRetention
|
|
||||||
) {
|
|
||||||
SseEmitter emitter = createEmitter();
|
|
||||||
StreamSession session = new StreamSession(
|
|
||||||
emitter,
|
|
||||||
cleanup,
|
|
||||||
detachedRetention
|
|
||||||
);
|
|
||||||
emitter.onTimeout(() -> detach(session));
|
|
||||||
emitter.onError(error -> detach(session));
|
|
||||||
emitter.onCompletion(() -> detach(session));
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
chainExecutor.executeAsync(
|
chainExecutor.executeAsync(
|
||||||
@@ -145,9 +79,6 @@ public class WorkflowChatEventStream {
|
|||||||
variables,
|
variables,
|
||||||
executeId -> {
|
executeId -> {
|
||||||
session.attach(executeId);
|
session.attach(executeId);
|
||||||
if (session.cleaned.get()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
sessions.put(executeId, session);
|
sessions.put(executeId, session);
|
||||||
session.send("execution_started", Map.of(
|
session.send("execution_started", Map.of(
|
||||||
"executeId", executeId
|
"executeId", executeId
|
||||||
@@ -161,13 +92,6 @@ public class WorkflowChatEventStream {
|
|||||||
return emitter;
|
return emitter;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建 SSE 发送器,便于验证连接生命周期。
|
|
||||||
*/
|
|
||||||
SseEmitter createEmitter() {
|
|
||||||
return new SseEmitter(SSE_TIMEOUT_MILLIS);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将工作流事件转发到对应执行流。
|
* 将工作流事件转发到对应执行流。
|
||||||
*
|
*
|
||||||
@@ -238,21 +162,20 @@ public class WorkflowChatEventStream {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 分离已经断开的浏览器传输,不影响工作流 Runtime。
|
* 处理 SSE 连接异常,并取消尚未结束的工作流。
|
||||||
*
|
*
|
||||||
* @param session 流会话
|
* @param session 流会话
|
||||||
|
* @param message 取消原因
|
||||||
*/
|
*/
|
||||||
private void detach(StreamSession session) {
|
private void disconnect(StreamSession session, String message) {
|
||||||
if (session == null || session.terminal.get()) {
|
if (session == null || session.terminal.get()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
session.detachTransport();
|
String executeId = session.executeId;
|
||||||
if (session.detachedRetention.isZero()
|
|
||||||
|| session.detachedRetention.isNegative()) {
|
|
||||||
removeSession(session);
|
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) {
|
if (session != null && session.executeId != null) {
|
||||||
sessions.remove(session.executeId, session);
|
sessions.remove(session.executeId, session);
|
||||||
}
|
}
|
||||||
if (session != null) {
|
|
||||||
session.cleanup();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -311,11 +231,6 @@ public class WorkflowChatEventStream {
|
|||||||
private final SseEmitter emitter;
|
private final SseEmitter emitter;
|
||||||
private final AtomicLong sequence = new AtomicLong();
|
private final AtomicLong sequence = new AtomicLong();
|
||||||
private final AtomicBoolean terminal = new AtomicBoolean(false);
|
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;
|
private volatile String executeId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -323,36 +238,8 @@ public class WorkflowChatEventStream {
|
|||||||
*
|
*
|
||||||
* @param emitter SSE 发送器
|
* @param emitter SSE 发送器
|
||||||
*/
|
*/
|
||||||
private StreamSession(
|
private StreamSession(SseEmitter emitter) {
|
||||||
SseEmitter emitter,
|
|
||||||
Runnable cleanup,
|
|
||||||
Duration detachedRetention
|
|
||||||
) {
|
|
||||||
this.emitter = 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;
|
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);
|
send(eventType, data);
|
||||||
removeSession(this);
|
removeSession(this);
|
||||||
if (connected.compareAndSet(true, false)) {
|
|
||||||
emitter.complete();
|
emitter.complete();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送 SSE 事件。
|
* 发送 SSE 事件。
|
||||||
@@ -581,9 +434,6 @@ public class WorkflowChatEventStream {
|
|||||||
* @param data 事件数据
|
* @param data 事件数据
|
||||||
*/
|
*/
|
||||||
private void send(String type, Map<String, ?> data) {
|
private void send(String type, Map<String, ?> data) {
|
||||||
if (!connected.get()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
long nextSequence = sequence.incrementAndGet();
|
long nextSequence = sequence.incrementAndGet();
|
||||||
Map<String, Object> payload = new LinkedHashMap<>();
|
Map<String, Object> payload = new LinkedHashMap<>();
|
||||||
payload.put("eventId", executeId + ":" + nextSequence);
|
payload.put("eventId", executeId + ":" + nextSequence);
|
||||||
@@ -603,7 +453,7 @@ public class WorkflowChatEventStream {
|
|||||||
executeId,
|
executeId,
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
detach(this);
|
disconnect(this, "运行连接已断开");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -618,11 +468,9 @@ public class WorkflowChatEventStream {
|
|||||||
"message", safeErrorMessage(error)
|
"message", safeErrorMessage(error)
|
||||||
));
|
));
|
||||||
removeSession(this);
|
removeSession(this);
|
||||||
if (connected.compareAndSet(true, false)) {
|
|
||||||
emitter.completeWithError(error);
|
emitter.completeWithError(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建带节点信息的事件数据。
|
* 构建带节点信息的事件数据。
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import com.mybatisflex.core.query.QueryWrapper;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import tech.easyflow.admin.model.ai.WorkflowDesignerOptionsView;
|
import tech.easyflow.admin.model.ai.WorkflowDesignerOptionsView;
|
||||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService;
|
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.DocumentCollection;
|
||||||
import tech.easyflow.ai.entity.Model;
|
import tech.easyflow.ai.entity.Model;
|
||||||
import tech.easyflow.ai.entity.ModelProvider;
|
import tech.easyflow.ai.entity.ModelProvider;
|
||||||
@@ -76,7 +75,6 @@ public class WorkflowDesignerOptionService {
|
|||||||
private final DatacenterSourceService datacenterSourceService;
|
private final DatacenterSourceService datacenterSourceService;
|
||||||
private final DatacenterDatasetRegistryService datacenterDatasetRegistryService;
|
private final DatacenterDatasetRegistryService datacenterDatasetRegistryService;
|
||||||
private final DatacenterDatasetQueryService datacenterDatasetQueryService;
|
private final DatacenterDatasetQueryService datacenterDatasetQueryService;
|
||||||
private final WorkflowKnowledgeContractService workflowKnowledgeContractService;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建工作流设计器选项服务。
|
* 创建工作流设计器选项服务。
|
||||||
@@ -95,7 +93,6 @@ public class WorkflowDesignerOptionService {
|
|||||||
* @param datacenterSourceService 数据源服务
|
* @param datacenterSourceService 数据源服务
|
||||||
* @param datacenterDatasetRegistryService 数据集注册服务
|
* @param datacenterDatasetRegistryService 数据集注册服务
|
||||||
* @param datacenterDatasetQueryService 数据集查询服务
|
* @param datacenterDatasetQueryService 数据集查询服务
|
||||||
* @param workflowKnowledgeContractService 工作流知识库契约服务
|
|
||||||
*/
|
*/
|
||||||
public WorkflowDesignerOptionService(
|
public WorkflowDesignerOptionService(
|
||||||
ModelService modelService,
|
ModelService modelService,
|
||||||
@@ -111,8 +108,7 @@ public class WorkflowDesignerOptionService {
|
|||||||
ResourceAccessService resourceAccessService,
|
ResourceAccessService resourceAccessService,
|
||||||
DatacenterSourceService datacenterSourceService,
|
DatacenterSourceService datacenterSourceService,
|
||||||
DatacenterDatasetRegistryService datacenterDatasetRegistryService,
|
DatacenterDatasetRegistryService datacenterDatasetRegistryService,
|
||||||
DatacenterDatasetQueryService datacenterDatasetQueryService,
|
DatacenterDatasetQueryService datacenterDatasetQueryService) {
|
||||||
WorkflowKnowledgeContractService workflowKnowledgeContractService) {
|
|
||||||
this.modelService = modelService;
|
this.modelService = modelService;
|
||||||
this.documentCollectionService = documentCollectionService;
|
this.documentCollectionService = documentCollectionService;
|
||||||
this.pluginService = pluginService;
|
this.pluginService = pluginService;
|
||||||
@@ -127,7 +123,6 @@ public class WorkflowDesignerOptionService {
|
|||||||
this.datacenterSourceService = datacenterSourceService;
|
this.datacenterSourceService = datacenterSourceService;
|
||||||
this.datacenterDatasetRegistryService = datacenterDatasetRegistryService;
|
this.datacenterDatasetRegistryService = datacenterDatasetRegistryService;
|
||||||
this.datacenterDatasetQueryService = datacenterDatasetQueryService;
|
this.datacenterDatasetQueryService = datacenterDatasetQueryService;
|
||||||
this.workflowKnowledgeContractService = workflowKnowledgeContractService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -168,7 +163,6 @@ public class WorkflowDesignerOptionService {
|
|||||||
LoginAccount account = requireAccount();
|
LoginAccount account = requireAccount();
|
||||||
Set<BigInteger> modelIds = new HashSet<>();
|
Set<BigInteger> modelIds = new HashSet<>();
|
||||||
Set<BigInteger> knowledgeIds = new HashSet<>();
|
Set<BigInteger> knowledgeIds = new HashSet<>();
|
||||||
List<List<BigInteger>> knowledgeGroups = new ArrayList<>();
|
|
||||||
Set<BigInteger> checkedPluginItemIds = new HashSet<>();
|
Set<BigInteger> checkedPluginItemIds = new HashSet<>();
|
||||||
Set<BigInteger> checkedWorkflowIds = new HashSet<>();
|
Set<BigInteger> checkedWorkflowIds = new HashSet<>();
|
||||||
Set<BigInteger> checkedSourceIds = new HashSet<>();
|
Set<BigInteger> checkedSourceIds = new HashSet<>();
|
||||||
@@ -183,22 +177,14 @@ public class WorkflowDesignerOptionService {
|
|||||||
if (data == null) {
|
if (data == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
String nodeType = node.getString("type");
|
String nodeType = data.getString("type");
|
||||||
String dataType = data.getString("type");
|
|
||||||
if (nodeType != null && !nodeType.isBlank()
|
|
||||||
&& dataType != null && !dataType.isBlank()
|
|
||||||
&& !Objects.equals(nodeType, dataType)) {
|
|
||||||
throw new BusinessException("工作流节点类型与节点数据类型不一致");
|
|
||||||
}
|
|
||||||
if (nodeType == null || nodeType.isBlank()) {
|
if (nodeType == null || nodeType.isBlank()) {
|
||||||
nodeType = dataType;
|
nodeType = node.getString("type");
|
||||||
}
|
}
|
||||||
if ("llmNode".equals(nodeType)) {
|
if ("llmNode".equals(nodeType)) {
|
||||||
addReferenceId(modelIds, readReferenceId(data, "llmId", "模型"));
|
addReferenceId(modelIds, readReferenceId(data, "llmId", "模型"));
|
||||||
} else if ("knowledgeNode".equals(nodeType)) {
|
} else if ("knowledgeNode".equals(nodeType)) {
|
||||||
List<BigInteger> nodeKnowledgeIds = readKnowledgeReferenceIds(data);
|
addReferenceId(knowledgeIds, readReferenceId(data, "knowledgeId", "知识库"));
|
||||||
knowledgeIds.addAll(nodeKnowledgeIds);
|
|
||||||
knowledgeGroups.add(nodeKnowledgeIds);
|
|
||||||
} else if ("plugin-node".equals(nodeType)) {
|
} else if ("plugin-node".equals(nodeType)) {
|
||||||
BigInteger pluginItemId = readReferenceId(data, "pluginId", "插件工具");
|
BigInteger pluginItemId = readReferenceId(data, "pluginId", "插件工具");
|
||||||
if (pluginItemId != null && checkedPluginItemIds.add(pluginItemId)) {
|
if (pluginItemId != null && checkedPluginItemIds.add(pluginItemId)) {
|
||||||
@@ -212,8 +198,6 @@ public class WorkflowDesignerOptionService {
|
|||||||
}
|
}
|
||||||
assertModelReferences(modelIds, account);
|
assertModelReferences(modelIds, account);
|
||||||
assertKnowledgeReferences(knowledgeIds, account);
|
assertKnowledgeReferences(knowledgeIds, account);
|
||||||
workflowKnowledgeContractService.assertMultiKnowledgeContracts(
|
|
||||||
knowledgeGroups, account.getTenantId());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -476,55 +460,17 @@ public class WorkflowDesignerOptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private List<WorkflowDesignerOptionsView.KnowledgeOption> listKnowledgeOptions(LoginAccount account) {
|
private List<WorkflowDesignerOptionsView.KnowledgeOption> listKnowledgeOptions(LoginAccount account) {
|
||||||
List<DocumentCollection> collections = documentCollectionService.list(QueryWrapper.create()
|
return documentCollectionService.list(QueryWrapper.create()
|
||||||
.eq(DocumentCollection::getTenantId, account.getTenantId())
|
.eq(DocumentCollection::getTenantId, account.getTenantId())
|
||||||
.orderBy(DocumentCollection::getModified, false));
|
.orderBy(DocumentCollection::getModified, false))
|
||||||
Set<BigInteger> vectorReadyIds = workflowKnowledgeContractService
|
.stream()
|
||||||
.findVectorReadyKnowledgeIds(collections, account.getTenantId());
|
|
||||||
return collections.stream()
|
|
||||||
.filter(item -> resourceAccessService.canAccess(
|
.filter(item -> resourceAccessService.canAccess(
|
||||||
account, CategoryResourceType.KNOWLEDGE, item, ResourceAction.USE))
|
account, CategoryResourceType.KNOWLEDGE, item, ResourceAction.USE))
|
||||||
.map(item -> new WorkflowDesignerOptionsView.KnowledgeOption(
|
.map(item -> new WorkflowDesignerOptionsView.KnowledgeOption(
|
||||||
item.getId(),
|
item.getId(), item.getTitle(), item.getDescription()))
|
||||||
item.getTitle(),
|
|
||||||
item.getDescription(),
|
|
||||||
item.getVectorEmbedModelId(),
|
|
||||||
item.getDimensionOfVectorModel(),
|
|
||||||
vectorReadyIds.contains(item.getId())))
|
|
||||||
.toList();
|
.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) {
|
private void addReferenceId(Set<BigInteger> resourceIds, BigInteger resourceId) {
|
||||||
if (resourceId != null) {
|
if (resourceId != null) {
|
||||||
resourceIds.add(resourceId);
|
resourceIds.add(resourceId);
|
||||||
|
|||||||
@@ -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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 + " 不属于当前分享访客"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,27 +7,21 @@ import org.testng.Assert;
|
|||||||
import org.testng.annotations.Test;
|
import org.testng.annotations.Test;
|
||||||
import tech.easyflow.ai.entity.Plugin;
|
import tech.easyflow.ai.entity.Plugin;
|
||||||
import tech.easyflow.ai.entity.PluginItem;
|
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.AgentResourceReferenceService;
|
||||||
import tech.easyflow.ai.service.PluginItemService;
|
import tech.easyflow.ai.service.PluginItemService;
|
||||||
import tech.easyflow.ai.service.PluginService;
|
import tech.easyflow.ai.service.PluginService;
|
||||||
import tech.easyflow.ai.service.PluginVisibilityService;
|
import tech.easyflow.ai.service.PluginVisibilityService;
|
||||||
import tech.easyflow.ai.service.WorkflowExecResultService;
|
|
||||||
import tech.easyflow.common.entity.LoginAccount;
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
|
||||||
|
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.mockStatic;
|
import static org.mockito.Mockito.mockStatic;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.verifyNoInteractions;
|
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,61 +68,6 @@ public class PluginItemControllerTest {
|
|||||||
verify(visibilityService).assertPluginVisible(1L, BigInteger.TEN, "无权限删除该插件工具");
|
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"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建插件工具。
|
* 创建插件工具。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -3,50 +3,17 @@ package tech.easyflow.admin.controller.ai;
|
|||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import org.testng.Assert;
|
import org.testng.Assert;
|
||||||
import org.testng.annotations.Test;
|
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.Method;
|
||||||
import java.lang.reflect.Proxy;
|
import java.lang.reflect.Proxy;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
import static org.mockito.Mockito.mock;
|
|
||||||
import static org.mockito.Mockito.verify;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@link WorkflowShareController} 分享地址构建测试。
|
* {@link WorkflowShareController} 分享地址构建测试。
|
||||||
*/
|
*/
|
||||||
public class WorkflowShareControllerTest {
|
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;
|
return 0D;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setField(Object target, String name, Object value)
|
|
||||||
throws Exception {
|
|
||||||
Field field = target.getClass().getDeclaredField(name);
|
|
||||||
field.setAccessible(true);
|
|
||||||
field.set(target, value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,19 +4,13 @@ import com.easyagents.flow.core.chain.ChainConsts;
|
|||||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||||
import org.testng.Assert;
|
import org.testng.Assert;
|
||||||
import org.testng.annotations.Test;
|
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.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
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.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.doAnswer;
|
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.doThrow;
|
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
@@ -75,81 +69,4 @@ public class WorkflowChatEventStreamTest {
|
|||||||
WorkflowChatEventStream.visibleFinalOutput(null).isEmpty()
|
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,7 @@ import org.mockito.MockedStatic;
|
|||||||
import org.testng.Assert;
|
import org.testng.Assert;
|
||||||
import org.testng.annotations.Test;
|
import org.testng.annotations.Test;
|
||||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService;
|
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.Model;
|
||||||
import tech.easyflow.ai.entity.ModelProvider;
|
|
||||||
import tech.easyflow.ai.entity.DocumentCollection;
|
|
||||||
import tech.easyflow.ai.entity.Workflow;
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
|
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
|
||||||
import tech.easyflow.ai.service.DocumentCollectionService;
|
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(
|
private WorkflowDesignerOptionService createService(
|
||||||
ModelService modelService,
|
ModelService modelService,
|
||||||
DocumentCollectionService knowledgeService,
|
DocumentCollectionService knowledgeService,
|
||||||
@@ -235,20 +142,6 @@ public class WorkflowDesignerOptionServiceTest {
|
|||||||
DatacenterSourceService sourceService,
|
DatacenterSourceService sourceService,
|
||||||
WorkflowService workflowService) {
|
WorkflowService workflowService) {
|
||||||
ResourceAccessService resourceAccessService = mock(ResourceAccessService.class);
|
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(
|
return new WorkflowDesignerOptionService(
|
||||||
modelService,
|
modelService,
|
||||||
knowledgeService,
|
knowledgeService,
|
||||||
@@ -263,40 +156,10 @@ public class WorkflowDesignerOptionServiceTest {
|
|||||||
resourceAccessService,
|
resourceAccessService,
|
||||||
sourceService,
|
sourceService,
|
||||||
mock(DatacenterDatasetRegistryService.class),
|
mock(DatacenterDatasetRegistryService.class),
|
||||||
mock(DatacenterDatasetQueryService.class),
|
mock(DatacenterDatasetQueryService.class)
|
||||||
new WorkflowKnowledgeContractService(
|
|
||||||
knowledgeService, modelService)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private DocumentCollection knowledge(
|
|
||||||
long id, long embeddingModelId, int dimension) {
|
|
||||||
DocumentCollection collection = new DocumentCollection();
|
|
||||||
collection.setId(BigInteger.valueOf(id));
|
|
||||||
collection.setTenantId(BigInteger.valueOf(100));
|
|
||||||
collection.setVectorEmbedModelId(BigInteger.valueOf(embeddingModelId));
|
|
||||||
collection.setDimensionOfVectorModel(dimension);
|
|
||||||
collection.setVectorStoreEnable(true);
|
|
||||||
collection.setVectorStoreCollection("collection_" + id);
|
|
||||||
return collection;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Model embeddingModel(long id) {
|
|
||||||
Model model = new Model();
|
|
||||||
model.setId(BigInteger.valueOf(id));
|
|
||||||
model.setTenantId(BigInteger.valueOf(100));
|
|
||||||
model.setModelType(Model.MODEL_TYPES[1]);
|
|
||||||
model.setProviderId(BigInteger.ONE);
|
|
||||||
model.setModelName("embedding-" + id);
|
|
||||||
model.setEndpoint("https://embedding.example");
|
|
||||||
model.setRequestPath("/v1/embeddings");
|
|
||||||
ModelProvider provider = new ModelProvider();
|
|
||||||
provider.setId(BigInteger.ONE);
|
|
||||||
provider.setProviderType("openai");
|
|
||||||
model.setModelProvider(provider);
|
|
||||||
return model;
|
|
||||||
}
|
|
||||||
|
|
||||||
private LoginAccount loginAccount() {
|
private LoginAccount loginAccount() {
|
||||||
LoginAccount account = new LoginAccount();
|
LoginAccount account = new LoginAccount();
|
||||||
account.setId(BigInteger.ONE);
|
account.setId(BigInteger.ONE);
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
package tech.easyflow.publicapi.controller;
|
package tech.easyflow.publicapi.controller;
|
||||||
|
|
||||||
import cn.hutool.core.io.IoUtil;
|
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.paginate.Page;
|
||||||
import com.mybatisflex.core.query.QueryWrapper;
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
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.bind.annotation.RestController;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import tech.easyflow.ai.documentimport.DocumentImportDtos;
|
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.Document;
|
||||||
import tech.easyflow.ai.entity.DocumentChunk;
|
import tech.easyflow.ai.entity.DocumentChunk;
|
||||||
import tech.easyflow.ai.entity.DocumentCollection;
|
import tech.easyflow.ai.entity.DocumentCollection;
|
||||||
import tech.easyflow.ai.entity.FaqItem;
|
import tech.easyflow.ai.entity.FaqItem;
|
||||||
|
import tech.easyflow.ai.entity.Model;
|
||||||
import tech.easyflow.ai.enums.KnowledgeApiPermissionScope;
|
import tech.easyflow.ai.enums.KnowledgeApiPermissionScope;
|
||||||
import tech.easyflow.ai.rag.KnowledgeRetrievalModes;
|
import tech.easyflow.ai.rag.KnowledgeRetrievalModes;
|
||||||
import tech.easyflow.ai.rag.KnowledgeRetrievalRequest;
|
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.FaqItemService;
|
||||||
import tech.easyflow.ai.service.KnowledgeShareAuditService;
|
import tech.easyflow.ai.service.KnowledgeShareAuditService;
|
||||||
import tech.easyflow.ai.service.KnowledgeSharePermissionService;
|
import tech.easyflow.ai.service.KnowledgeSharePermissionService;
|
||||||
|
import tech.easyflow.ai.service.ModelService;
|
||||||
import tech.easyflow.ai.service.impl.KnowledgeSharePermissionServiceImpl;
|
import tech.easyflow.ai.service.impl.KnowledgeSharePermissionServiceImpl;
|
||||||
|
import tech.easyflow.ai.support.DocumentStoreLifecycleSupport;
|
||||||
import tech.easyflow.ai.vo.FaqImportResultVo;
|
import tech.easyflow.ai.vo.FaqImportResultVo;
|
||||||
import tech.easyflow.common.domain.Result;
|
import tech.easyflow.common.domain.Result;
|
||||||
import tech.easyflow.common.filestorage.FileStorageService;
|
import tech.easyflow.common.filestorage.FileStorageService;
|
||||||
@@ -79,6 +81,8 @@ public class PublicKnowledgeShareController {
|
|||||||
private FaqItemService faqItemService;
|
private FaqItemService faqItemService;
|
||||||
@Resource
|
@Resource
|
||||||
private FaqCategoryService faqCategoryService;
|
private FaqCategoryService faqCategoryService;
|
||||||
|
@Resource
|
||||||
|
private ModelService modelService;
|
||||||
@Resource(name = "default")
|
@Resource(name = "default")
|
||||||
private FileStorageService fileStorageService;
|
private FileStorageService fileStorageService;
|
||||||
|
|
||||||
@@ -347,20 +351,37 @@ public class PublicKnowledgeShareController {
|
|||||||
public Result<?> updateDocumentChunk(
|
public Result<?> updateDocumentChunk(
|
||||||
@RequestHeader("ApiKey") String apiKey,
|
@RequestHeader("ApiKey") String apiKey,
|
||||||
@JsonBody(value = "knowledgeId", required = true) BigInteger knowledgeId,
|
@JsonBody(value = "knowledgeId", required = true) BigInteger knowledgeId,
|
||||||
@JsonBody(required = true, skipConvertError = false)
|
@JsonBody DocumentChunk documentChunk,
|
||||||
DocumentChunk documentChunk,
|
|
||||||
HttpServletRequest request
|
HttpServletRequest request
|
||||||
) {
|
) {
|
||||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeApiPermissionScope.KNOWLEDGE_MAINTENANCE.name());
|
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeApiPermissionScope.KNOWLEDGE_MAINTENANCE.name());
|
||||||
requireDocumentKnowledge(knowledgeId);
|
requireDocumentKnowledge(knowledgeId);
|
||||||
DocumentChunk current = requireDocumentChunk(documentChunk.getId(), knowledgeId);
|
DocumentChunk current = requireDocumentChunk(documentChunk.getId(), knowledgeId);
|
||||||
DocumentChunk updated = documentChunkService.updateContent(
|
boolean success = documentChunkService.updateById(documentChunk);
|
||||||
knowledgeId,
|
if (success) {
|
||||||
current.getId(),
|
DocumentCollection knowledge = documentCollectionService.getById(knowledgeId);
|
||||||
documentChunk.getContent()
|
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()));
|
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());
|
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeApiPermissionScope.KNOWLEDGE_MAINTENANCE.name());
|
||||||
requireDocumentKnowledge(knowledgeId);
|
requireDocumentKnowledge(knowledgeId);
|
||||||
requireDocumentChunk(chunkId, knowledgeId);
|
requireDocumentChunk(chunkId, knowledgeId);
|
||||||
DocumentChunkDeleteResult removed = documentChunkService.deleteChunk(
|
DocumentCollection knowledge = documentCollectionService.getById(knowledgeId);
|
||||||
knowledgeId,
|
DocumentStore documentStore = knowledge.toDocumentStore();
|
||||||
chunkId
|
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));
|
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
|
|||||||
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
|
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
|
||||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
||||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
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.support.PublishedWorkflowDefinitionIds;
|
||||||
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiPreparedUpload;
|
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiPreparedUpload;
|
||||||
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadLifecycleService;
|
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadLifecycleService;
|
||||||
@@ -71,8 +70,6 @@ public class PublicWorkflowController {
|
|||||||
@Resource
|
@Resource
|
||||||
private WorkflowRunningParameterResolver workflowRunningParameterResolver;
|
private WorkflowRunningParameterResolver workflowRunningParameterResolver;
|
||||||
@Resource
|
@Resource
|
||||||
private WorkflowResumeService workflowResumeService;
|
|
||||||
@Resource
|
|
||||||
private WorkflowApiPermissionService workflowApiPermissionService;
|
private WorkflowApiPermissionService workflowApiPermissionService;
|
||||||
@Resource
|
@Resource
|
||||||
private WorkflowExecResultService workflowExecResultService;
|
private WorkflowExecResultService workflowExecResultService;
|
||||||
@@ -253,7 +250,14 @@ public class PublicWorkflowController {
|
|||||||
SysApiKey apiKey = workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
|
SysApiKey apiKey = workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
|
||||||
WorkflowExecResult execResult = assertApiKeyExecutionOwnership(apiKey, executeId);
|
WorkflowExecResult execResult = assertApiKeyExecutionOwnership(apiKey, executeId);
|
||||||
assertWorkflowExecutionResumable(execResult);
|
assertWorkflowExecutionResumable(execResult);
|
||||||
workflowResumeService.resume(executeId, confirmParams);
|
if (!chainExecutor.resumeAsyncIfSuspended(
|
||||||
|
executeId,
|
||||||
|
confirmParams)) {
|
||||||
|
throw new BusinessException(
|
||||||
|
409,
|
||||||
|
40901,
|
||||||
|
"当前执行状态不可恢复,仅暂停中的工作流允许恢复");
|
||||||
|
}
|
||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -394,7 +394,6 @@ public final class WorkflowRunAsyncErrorProfile
|
|||||||
*/
|
*/
|
||||||
private boolean isStableBusinessCode(int code) {
|
private boolean isStableBusinessCode(int code) {
|
||||||
return (code >= 40011 && code <= 40017)
|
return (code >= 40011 && code <= 40017)
|
||||||
|| code == 40031
|
|
||||||
|| (code >= 40101 && code <= 40103)
|
|| (code >= 40101 && code <= 40103)
|
||||||
|| (code >= 40301 && code <= 40302)
|
|| (code >= 40301 && code <= 40302)
|
||||||
|| (code >= 40401 && code <= 40402)
|
|| (code >= 40401 && code <= 40402)
|
||||||
@@ -413,8 +412,7 @@ public final class WorkflowRunAsyncErrorProfile
|
|||||||
* @return 对外 HTTP 状态
|
* @return 对外 HTTP 状态
|
||||||
*/
|
*/
|
||||||
private int normalizeHttpStatus(int code, int fallback) {
|
private int normalizeHttpStatus(int code, int fallback) {
|
||||||
if ((code >= 40011 && code <= 40017)
|
if (code >= 40011 && code <= 40017) {
|
||||||
|| code == 40031) {
|
|
||||||
return 400;
|
return 400;
|
||||||
}
|
}
|
||||||
if (code >= 40101 && code <= 40103) {
|
if (code >= 40101 && code <= 40103) {
|
||||||
|
|||||||
@@ -66,8 +66,6 @@ public class PublicKnowledgeShareControllerContractTest {
|
|||||||
JsonBody chunkBody = chunk.getAnnotation(JsonBody.class);
|
JsonBody chunkBody = chunk.getAnnotation(JsonBody.class);
|
||||||
Assert.assertNotNull(chunkBody);
|
Assert.assertNotNull(chunkBody);
|
||||||
Assert.assertEquals("", chunkBody.value());
|
Assert.assertEquals("", chunkBody.value());
|
||||||
Assert.assertTrue(chunkBody.required());
|
|
||||||
Assert.assertFalse(chunkBody.skipConvertError());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import org.springframework.test.util.ReflectionTestUtils;
|
|||||||
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
|
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
|
||||||
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
|
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
|
||||||
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
|
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.Workflow;
|
||||||
import tech.easyflow.ai.entity.WorkflowExecResult;
|
import tech.easyflow.ai.entity.WorkflowExecResult;
|
||||||
import tech.easyflow.ai.enums.PublishStatus;
|
import tech.easyflow.ai.enums.PublishStatus;
|
||||||
@@ -45,7 +44,6 @@ public class PublicWorkflowControllerBehaviorTest {
|
|||||||
|
|
||||||
private PublicWorkflowController controller;
|
private PublicWorkflowController controller;
|
||||||
private ChainExecutor chainExecutor;
|
private ChainExecutor chainExecutor;
|
||||||
private WorkflowResumeService workflowResumeService;
|
|
||||||
private TinyFlowService tinyFlowService;
|
private TinyFlowService tinyFlowService;
|
||||||
private HttpServletRequest request;
|
private HttpServletRequest request;
|
||||||
|
|
||||||
@@ -56,7 +54,6 @@ public class PublicWorkflowControllerBehaviorTest {
|
|||||||
public void setUp() {
|
public void setUp() {
|
||||||
controller = new PublicWorkflowController();
|
controller = new PublicWorkflowController();
|
||||||
chainExecutor = Mockito.mock(ChainExecutor.class);
|
chainExecutor = Mockito.mock(ChainExecutor.class);
|
||||||
workflowResumeService = Mockito.mock(WorkflowResumeService.class);
|
|
||||||
tinyFlowService = Mockito.mock(TinyFlowService.class);
|
tinyFlowService = Mockito.mock(TinyFlowService.class);
|
||||||
WorkflowApiPermissionService permissionService =
|
WorkflowApiPermissionService permissionService =
|
||||||
Mockito.mock(WorkflowApiPermissionService.class);
|
Mockito.mock(WorkflowApiPermissionService.class);
|
||||||
@@ -82,10 +79,6 @@ public class PublicWorkflowControllerBehaviorTest {
|
|||||||
controller,
|
controller,
|
||||||
"chainExecutor",
|
"chainExecutor",
|
||||||
chainExecutor);
|
chainExecutor);
|
||||||
ReflectionTestUtils.setField(
|
|
||||||
controller,
|
|
||||||
"workflowResumeService",
|
|
||||||
workflowResumeService);
|
|
||||||
ReflectionTestUtils.setField(
|
ReflectionTestUtils.setField(
|
||||||
controller,
|
controller,
|
||||||
"tinyFlowService",
|
"tinyFlowService",
|
||||||
@@ -115,12 +108,10 @@ public class PublicWorkflowControllerBehaviorTest {
|
|||||||
public void resumeShouldRejectNonSuspendedExecution() {
|
public void resumeShouldRejectNonSuspendedExecution() {
|
||||||
when(request.getRequestURI()).thenReturn(
|
when(request.getRequestURI()).thenReturn(
|
||||||
"/public-api/workflow/resume");
|
"/public-api/workflow/resume");
|
||||||
Mockito.doThrow(new BusinessException(
|
when(chainExecutor.resumeAsyncIfSuspended(
|
||||||
409,
|
EXECUTE_ID,
|
||||||
40901,
|
Map.of("approved", true)))
|
||||||
"当前执行状态不可恢复,仅暂停中的工作流允许恢复"))
|
.thenReturn(false);
|
||||||
.when(workflowResumeService)
|
|
||||||
.resume(EXECUTE_ID, Map.of("approved", true));
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
controller.resume(
|
controller.resume(
|
||||||
@@ -133,7 +124,7 @@ public class PublicWorkflowControllerBehaviorTest {
|
|||||||
Assert.assertEquals(40901, exception.getErrorCode());
|
Assert.assertEquals(40901, exception.getErrorCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
verify(workflowResumeService).resume(
|
verify(chainExecutor).resumeAsyncIfSuspended(
|
||||||
EXECUTE_ID,
|
EXECUTE_ID,
|
||||||
Map.of("approved", true));
|
Map.of("approved", true));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,28 +167,6 @@ public class WorkflowRunAsyncErrorProfileTest {
|
|||||||
resolution.modelAndView.getModel().get("message"));
|
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 无效和两层权限错误保持可区分。
|
* 验证 API Key 无效和两层权限错误保持可区分。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
|
|||||||
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
|
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
|
||||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
||||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
||||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowResumeService;
|
|
||||||
import tech.easyflow.ai.entity.Workflow;
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
import tech.easyflow.ai.service.WorkflowService;
|
import tech.easyflow.ai.service.WorkflowService;
|
||||||
import tech.easyflow.common.annotation.UsePermission;
|
import tech.easyflow.common.annotation.UsePermission;
|
||||||
@@ -55,8 +54,6 @@ public class UcWorkflowController extends BaseCurdController<WorkflowService, Wo
|
|||||||
@Resource
|
@Resource
|
||||||
private WorkflowRunningParameterResolver workflowRunningParameterResolver;
|
private WorkflowRunningParameterResolver workflowRunningParameterResolver;
|
||||||
@Resource
|
@Resource
|
||||||
private WorkflowResumeService workflowResumeService;
|
|
||||||
@Resource
|
|
||||||
private WorkflowVisibilityQueryHelper workflowVisibilityQueryHelper;
|
private WorkflowVisibilityQueryHelper workflowVisibilityQueryHelper;
|
||||||
|
|
||||||
public UcWorkflowController(WorkflowService service) {
|
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,
|
public Result<Void> resume(@JsonBody(value = "executeId", required = true) String executeId,
|
||||||
@JsonBody("confirmParams") Map<String, Object> confirmParams) {
|
@JsonBody("confirmParams") Map<String, Object> confirmParams) {
|
||||||
workflowResumeService.resume(executeId, confirmParams);
|
if (!chainExecutor.resumeAsyncIfSuspended(executeId, confirmParams)) {
|
||||||
|
throw new BusinessException(
|
||||||
|
409,
|
||||||
|
40901,
|
||||||
|
"当前执行状态不可恢复,仅暂停中的工作流允许恢复");
|
||||||
|
}
|
||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package tech.easyflow.common.cache;
|
package tech.easyflow.common.cache;
|
||||||
|
|
||||||
import jakarta.annotation.PreDestroy;
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
@@ -11,11 +10,6 @@ import org.springframework.stereotype.Component;
|
|||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.UUID;
|
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;
|
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> NEXT_FENCING_TOKEN_SCRIPT;
|
||||||
private static final DefaultRedisScript<Long> ACQUIRE_FENCED_LOCK_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 {
|
static {
|
||||||
RELEASE_LOCK_SCRIPT = new DefaultRedisScript<>();
|
RELEASE_LOCK_SCRIPT = new DefaultRedisScript<>();
|
||||||
RELEASE_LOCK_SCRIPT.setScriptText(
|
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取显式释放的分布式锁句柄。
|
* 获取显式释放的分布式锁句柄。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -11,9 +11,6 @@ import org.springframework.data.redis.core.script.RedisScript;
|
|||||||
import java.lang.reflect.Field;
|
import java.lang.reflect.Field;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.CountDownLatch;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@link RedisLockExecutor} 回归测试。
|
* {@link RedisLockExecutor} 回归测试。
|
||||||
@@ -150,96 +147,6 @@ public class RedisLockExecutorTest {
|
|||||||
String.valueOf(Duration.ofDays(4).toMillis())));
|
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")
|
@SuppressWarnings("unchecked")
|
||||||
private ValueOperations<String, String> mockValueOperations(boolean acquired) {
|
private ValueOperations<String, String> mockValueOperations(boolean acquired) {
|
||||||
ValueOperations<String, String> valueOperations = Mockito.mock(ValueOperations.class);
|
ValueOperations<String, String> valueOperations = Mockito.mock(ValueOperations.class);
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import java.io.File;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
@@ -152,19 +151,6 @@ public class FileStorageManager implements FileStorageService {
|
|||||||
return serviceForHandle(handle).readRecoverable(handle);
|
return serviceForHandle(handle).readRecoverable(handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 使用当前后端解析服务端可信文件引用。
|
|
||||||
*
|
|
||||||
* @param reference 文件 URL 或其他后端可识别引用
|
|
||||||
* @return 可信文件的物理读取句柄;引用无法确认时为空
|
|
||||||
* @throws IOException 文件记录无法安全解析时抛出
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
public Optional<FileStorageWriteHandle> resolveTrustedFile(String reference)
|
|
||||||
throws IOException {
|
|
||||||
return currentService().resolveTrustedFile(reference);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 严格按句柄中的后端精确删除物理对象。
|
* 严格按句柄中的后端精确删除物理对象。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import org.springframework.web.multipart.MultipartFile;
|
|||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EasyFlow 文件存储统一接口。
|
* EasyFlow 文件存储统一接口。
|
||||||
@@ -106,21 +105,6 @@ public interface FileStorageService {
|
|||||||
throw unsupportedRecoverableOperation("readRecoverable");
|
throw unsupportedRecoverableOperation("readRecoverable");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 将服务端可信文件引用解析为物理读取句柄。
|
|
||||||
*
|
|
||||||
* <p>实现必须以服务端持久化记录或存储平台配置为信任来源,并要求外部引用与可信来源
|
|
||||||
* 精确匹配;不得仅根据客户端传入的 URL、路径或 locator 构造句柄。</p>
|
|
||||||
*
|
|
||||||
* @param reference 文件 URL 或其他后端可识别引用
|
|
||||||
* @return 可信文件的物理读取句柄;引用无法确认时为空
|
|
||||||
* @throws IOException 文件记录损坏或存储配置不兼容时抛出
|
|
||||||
*/
|
|
||||||
default Optional<FileStorageWriteHandle> resolveTrustedFile(String reference)
|
|
||||||
throws IOException {
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 精确且幂等地删除句柄对应的物理对象。
|
* 精确且幂等地删除句柄对应的物理对象。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import java.io.*;
|
|||||||
import java.lang.reflect.InvocationTargetException;
|
import java.lang.reflect.InvocationTargetException;
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 基于 x-file-storage 的 EasyFlow 文件存储实现。
|
* 基于 x-file-storage 的 EasyFlow 文件存储实现。
|
||||||
@@ -269,47 +268,6 @@ public class XFIleStorageServiceImpl implements FileStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 使用 x-file-storage 文件记录或服务端平台配置恢复可信物理读取句柄。
|
|
||||||
*
|
|
||||||
* <p>优先使用 recorder 的精确记录。记录不存在时,仅允许与已配置平台 domain、basePath
|
|
||||||
* 及重建后的完整 URL 完全一致的引用。其他 URL 返回空,由上层继续执行公网地址安全校验。</p>
|
|
||||||
*
|
|
||||||
* @param reference 文件 URL
|
|
||||||
* @return 可信文件的物理读取句柄;引用无法确认时为空
|
|
||||||
* @throws IOException 文件记录损坏或平台配置不兼容时抛出
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
public Optional<FileStorageWriteHandle> resolveTrustedFile(String reference)
|
|
||||||
throws IOException {
|
|
||||||
if (!StringUtils.hasText(reference)) {
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
FileInfo fileInfo = null;
|
|
||||||
try {
|
|
||||||
fileInfo = fileStorageService.getFileInfoByUrl(reference);
|
|
||||||
} catch (RuntimeException exception) {
|
|
||||||
// 存储平台配置本身仍可提供精确可信边界,记录器异常不应阻断内部对象读取。
|
|
||||||
LOG.warn("查询 x-file-storage 文件记录失败,继续按服务端存储配置识别,reference={}",
|
|
||||||
reference, exception);
|
|
||||||
}
|
|
||||||
if (fileInfo != null) {
|
|
||||||
if (!reference.equals(fileInfo.getUrl())) {
|
|
||||||
throw new IOException("x-file-storage 文件记录 URL 与请求引用不一致");
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
FileStorageWriteHandle handle = handleFromFileInfo(fileInfo);
|
|
||||||
FileStorage storage = requireStorage(handle);
|
|
||||||
requirePersistedBasePathSupport(storage, handle);
|
|
||||||
verifyRecordedLocation(fileInfo, handle);
|
|
||||||
return Optional.of(handle);
|
|
||||||
} catch (RuntimeException exception) {
|
|
||||||
throw new IOException("x-file-storage 文件记录无法恢复为安全读取位置", exception);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return resolveConfiguredStorageReference(reference);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 直接调用句柄指定平台的物理删除与存在检查,绕过依赖 URL 记录的聚合删除路径。
|
* 直接调用句柄指定平台的物理删除与存在检查,绕过依赖 URL 记录的聚合删除路径。
|
||||||
*
|
*
|
||||||
@@ -433,108 +391,6 @@ public class XFIleStorageServiceImpl implements FileStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 校验 recorder 中的物理定位字段可由恢复句柄无损重建。
|
|
||||||
*
|
|
||||||
* @param fileInfo 服务端文件记录
|
|
||||||
* @param handle 恢复出的物理读取句柄
|
|
||||||
*/
|
|
||||||
private void verifyRecordedLocation(FileInfo fileInfo, FileStorageWriteHandle handle) {
|
|
||||||
String actualBasePath = fileInfo.getBasePath() == null ? "" : fileInfo.getBasePath();
|
|
||||||
String actualPath = fileInfo.getPath() == null ? "" : fileInfo.getPath();
|
|
||||||
if (!handle.getPlatform().equals(fileInfo.getPlatform())
|
|
||||||
|| !handle.getBasePath().equals(actualBasePath)
|
|
||||||
|| !physicalPath(handle).equals(actualPath)
|
|
||||||
|| !handle.getFilename().equals(fileInfo.getFilename())) {
|
|
||||||
throw new IllegalStateException("x-file-storage 文件记录包含非规范物理位置");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从 FileInfo 的物理定位字段构造严格校验的读取句柄。
|
|
||||||
*
|
|
||||||
* @param fileInfo 服务端文件信息
|
|
||||||
* @return 可信物理读取句柄
|
|
||||||
*/
|
|
||||||
private FileStorageWriteHandle handleFromFileInfo(FileInfo fileInfo) {
|
|
||||||
String basePath = fileInfo.getBasePath() == null ? "" : fileInfo.getBasePath();
|
|
||||||
return new FileStorageWriteHandle(
|
|
||||||
RECOVERABLE_BACKEND,
|
|
||||||
fileInfo.getPlatform(),
|
|
||||||
basePath,
|
|
||||||
recordedRelativePath(basePath, fileInfo.getPath()),
|
|
||||||
fileInfo.getFilename());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 按服务端配置的平台 domain 与 basePath 识别内部存储 URL。
|
|
||||||
*
|
|
||||||
* <p>解析后会再次通过平台自身的 getFileKey 重建完整 URL 并进行精确比较,避免仅凭
|
|
||||||
* host 或字符串前缀放行其他私网目标。</p>
|
|
||||||
*
|
|
||||||
* @param reference 待识别 URL
|
|
||||||
* @return 精确匹配配置的读取句柄;不匹配任何平台时为空
|
|
||||||
* @throws IOException 匹配平台前缀但路径无法安全恢复时抛出
|
|
||||||
*/
|
|
||||||
private Optional<FileStorageWriteHandle> resolveConfiguredStorageReference(
|
|
||||||
String reference) throws IOException {
|
|
||||||
if (fileStorageService.getFileStorageList() == null) {
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
for (FileStorage storage : fileStorageService.getFileStorageList()) {
|
|
||||||
String domain = readDomainBestEffort(storage);
|
|
||||||
if (!StringUtils.hasText(domain)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
String basePath = readRequiredBasePath(storage);
|
|
||||||
String prefix = domain + basePath;
|
|
||||||
if (!reference.startsWith(prefix)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
String remainder = reference.substring(prefix.length());
|
|
||||||
int filenameIndex = remainder.lastIndexOf('/') + 1;
|
|
||||||
FileInfo fileInfo = new FileInfo()
|
|
||||||
.setUrl(reference)
|
|
||||||
.setPlatform(storage.getPlatform())
|
|
||||||
.setBasePath(basePath)
|
|
||||||
.setPath(remainder.substring(0, filenameIndex))
|
|
||||||
.setFilename(remainder.substring(filenameIndex));
|
|
||||||
FileStorageWriteHandle handle = handleFromFileInfo(fileInfo);
|
|
||||||
requirePersistedBasePathSupport(storage, handle);
|
|
||||||
verifyRecordedLocation(fileInfo, handle);
|
|
||||||
if (!reference.equals(deriveUrlBestEffort(storage, toFileInfo(handle)))) {
|
|
||||||
throw new IllegalArgumentException("重建 URL 与请求引用不一致");
|
|
||||||
}
|
|
||||||
return Optional.of(handle);
|
|
||||||
} catch (RuntimeException exception) {
|
|
||||||
throw new IOException("服务端存储 URL 无法恢复为安全读取位置", exception);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将 recorder 保存的 x-file-storage 物理路径还原为句柄相对路径。
|
|
||||||
*
|
|
||||||
* @param basePath 平台基础路径
|
|
||||||
* @param recordedPath recorder 中保存的物理目录
|
|
||||||
* @return 不带前导斜杠的相对目录
|
|
||||||
*/
|
|
||||||
private String recordedRelativePath(String basePath, String recordedPath) {
|
|
||||||
String path = recordedPath == null ? "" : recordedPath;
|
|
||||||
if (basePath.isEmpty() || basePath.endsWith("/")) {
|
|
||||||
if (path.startsWith("/")) {
|
|
||||||
throw new IllegalArgumentException("文件记录路径与平台基础路径格式不一致");
|
|
||||||
}
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
if (!path.startsWith("/")) {
|
|
||||||
throw new IllegalArgumentException("文件记录路径缺少必要的前导斜杠");
|
|
||||||
}
|
|
||||||
return path.substring(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构造仅包含精确物理定位字段的 FileInfo。
|
* 构造仅包含精确物理定位字段的 FileInfo。
|
||||||
*
|
*
|
||||||
@@ -631,26 +487,16 @@ public class XFIleStorageServiceImpl implements FileStorageService {
|
|||||||
* @return 可推导 URL;平台不支持时返回 null
|
* @return 可推导 URL;平台不支持时返回 null
|
||||||
*/
|
*/
|
||||||
private String deriveUrlBestEffort(FileStorage storage, FileInfo fileInfo) {
|
private String deriveUrlBestEffort(FileStorage storage, FileInfo fileInfo) {
|
||||||
String domain = readDomainBestEffort(storage);
|
|
||||||
if (domain == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return domain + storage.getFileKey(fileInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 使用平台公开的 getDomain 方法读取文件访问域名。
|
|
||||||
*
|
|
||||||
* @param storage 具体平台存储
|
|
||||||
* @return 平台访问域名;平台不支持时返回 null
|
|
||||||
*/
|
|
||||||
private String readDomainBestEffort(FileStorage storage) {
|
|
||||||
try {
|
try {
|
||||||
Method method = storage.getClass().getMethod("getDomain");
|
Method method = storage.getClass().getMethod("getDomain");
|
||||||
if (!String.class.equals(method.getReturnType())) {
|
if (!String.class.equals(method.getReturnType())) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (String) method.invoke(storage);
|
String domain = (String) method.invoke(storage);
|
||||||
|
if (domain == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return domain + storage.getFileKey(fileInfo);
|
||||||
} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException | RuntimeException exception) {
|
} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException | RuntimeException exception) {
|
||||||
LOG.debug("当前 x-file-storage 平台无法推导 recorder URL: {}", storage.getClass().getName());
|
LOG.debug("当前 x-file-storage 平台无法推导 recorder URL: {}", storage.getClass().getName());
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import java.io.File;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
import static org.junit.Assert.assertEquals;
|
import static org.junit.Assert.assertEquals;
|
||||||
@@ -50,27 +49,6 @@ public class FileStorageManagerTest {
|
|||||||
assertFalse(exists);
|
assertFalse(exists);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 验证服务端文件记录解析使用当前配置的具体存储后端。
|
|
||||||
*
|
|
||||||
* @throws IOException 文件记录解析失败时抛出
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
public void recordedFileResolutionUsesCurrentBackend() throws IOException {
|
|
||||||
RecordingStorage local = new RecordingStorage("local");
|
|
||||||
RecordingStorage xFile = new RecordingStorage("xFileStorage");
|
|
||||||
FileStorageManager manager = new FileStorageManager(
|
|
||||||
() -> "xFileStorage",
|
|
||||||
backend -> Map.of("local", local, "xFileStorage", xFile).get(backend));
|
|
||||||
|
|
||||||
Optional<FileStorageWriteHandle> resolved = manager.resolveTrustedFile(
|
|
||||||
"http://127.0.0.1:39000/easyflow/attachment/demo.pdf");
|
|
||||||
|
|
||||||
assertSame(xFile.recordedHandle, resolved.orElseThrow());
|
|
||||||
assertEquals(1, xFile.resolveCalls);
|
|
||||||
assertEquals(0, local.resolveCalls);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 可记录可恢复调用的存储测试替身。
|
* 可记录可恢复调用的存储测试替身。
|
||||||
*/
|
*/
|
||||||
@@ -79,8 +57,6 @@ public class FileStorageManagerTest {
|
|||||||
private final String backend;
|
private final String backend;
|
||||||
/** 固定结果。 */
|
/** 固定结果。 */
|
||||||
private final FileStorageWriteResult result;
|
private final FileStorageWriteResult result;
|
||||||
/** 固定服务端文件记录句柄。 */
|
|
||||||
private final FileStorageWriteHandle recordedHandle;
|
|
||||||
/** 固定可恢复读取流。 */
|
/** 固定可恢复读取流。 */
|
||||||
private final InputStream recoverableInput = InputStream.nullInputStream();
|
private final InputStream recoverableInput = InputStream.nullInputStream();
|
||||||
/** prepare 调用次数。 */
|
/** prepare 调用次数。 */
|
||||||
@@ -93,8 +69,6 @@ public class FileStorageManagerTest {
|
|||||||
private int deleteCalls;
|
private int deleteCalls;
|
||||||
/** exists 调用次数。 */
|
/** exists 调用次数。 */
|
||||||
private int existsCalls;
|
private int existsCalls;
|
||||||
/** 服务端文件记录解析调用次数。 */
|
|
||||||
private int resolveCalls;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建指定名称的存储替身。
|
* 创建指定名称的存储替身。
|
||||||
@@ -106,8 +80,6 @@ public class FileStorageManagerTest {
|
|||||||
FileStorageWriteHandle handle = new FileStorageWriteHandle(
|
FileStorageWriteHandle handle = new FileStorageWriteHandle(
|
||||||
backend, "", "/tmp/easyflow", "skill-content", "content.bin");
|
backend, "", "/tmp/easyflow", "skill-content", "content.bin");
|
||||||
this.result = new FileStorageWriteResult("/files/content.bin", handle.encodeLocator());
|
this.result = new FileStorageWriteResult("/files/content.bin", handle.encodeLocator());
|
||||||
this.recordedHandle = new FileStorageWriteHandle(
|
|
||||||
backend, "", "/tmp/easyflow", "attachment", "demo.pdf");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** {@inheritDoc} */
|
/** {@inheritDoc} */
|
||||||
@@ -142,13 +114,6 @@ public class FileStorageManagerTest {
|
|||||||
return recoverableInput;
|
return recoverableInput;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** {@inheritDoc} */
|
|
||||||
@Override
|
|
||||||
public Optional<FileStorageWriteHandle> resolveTrustedFile(String reference) {
|
|
||||||
resolveCalls++;
|
|
||||||
return Optional.of(recordedHandle);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** {@inheritDoc} */
|
/** {@inheritDoc} */
|
||||||
@Override
|
@Override
|
||||||
public void deleteRecoverable(FileStorageWriteHandle handle) {
|
public void deleteRecoverable(FileStorageWriteHandle handle) {
|
||||||
|
|||||||
@@ -212,123 +212,6 @@ public class XFIleStorageServiceImplTest {
|
|||||||
client.lastArgs.object());
|
client.lastArgs.object());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 验证 recorder 登记的回环地址附件可恢复为可信句柄并通过 MinIO 客户端直读。
|
|
||||||
*
|
|
||||||
* @throws Exception 测试替身配置或流读取失败
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
public void recordedLoopbackUrlUsesExactMinioObject() throws Exception {
|
|
||||||
byte[] content = "workflow-content".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
|
||||||
RecordingMinioClient client = new RecordingMinioClient(content);
|
|
||||||
MinioFileStorage platform = new MinioFileStorage();
|
|
||||||
platform.setPlatform("minio-main");
|
|
||||||
platform.setBucketName("easyflow");
|
|
||||||
platform.setBasePath("attachment");
|
|
||||||
platform.setDomain("http://127.0.0.1:39000/easyflow/");
|
|
||||||
platform.setClientFactory(new FixedMinioClientFactory(client));
|
|
||||||
RecoverableStorageService delegate = new RecoverableStorageService(platform);
|
|
||||||
String fileUrl = "http://127.0.0.1:39000/easyflow/attachment/1/2026/8/26/"
|
|
||||||
+ "d6186b17-4ab7-4f99-9299-b19df7ff0a3b/投标文件否决(废标)违规事项汇总.pdf";
|
|
||||||
delegate.recordedFileInfo = new FileInfo()
|
|
||||||
.setUrl(fileUrl)
|
|
||||||
.setPlatform("minio-main")
|
|
||||||
.setBasePath("attachment")
|
|
||||||
.setPath("/1/2026/8/26/d6186b17-4ab7-4f99-9299-b19df7ff0a3b/")
|
|
||||||
.setFilename("投标文件否决(废标)违规事项汇总.pdf");
|
|
||||||
XFIleStorageServiceImpl service = createService(delegate);
|
|
||||||
|
|
||||||
FileStorageWriteHandle handle = service.resolveTrustedFile(fileUrl).orElseThrow();
|
|
||||||
byte[] actual;
|
|
||||||
try (InputStream inputStream = service.readRecoverable(handle)) {
|
|
||||||
actual = inputStream.readAllBytes();
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals("attachment", handle.getBasePath());
|
|
||||||
assertEquals(
|
|
||||||
"1/2026/8/26/d6186b17-4ab7-4f99-9299-b19df7ff0a3b/",
|
|
||||||
handle.getPath());
|
|
||||||
assertArrayEquals(content, actual);
|
|
||||||
assertEquals("easyflow", client.lastArgs.bucket());
|
|
||||||
assertEquals(
|
|
||||||
"attachment/1/2026/8/26/d6186b17-4ab7-4f99-9299-b19df7ff0a3b/"
|
|
||||||
+ "投标文件否决(废标)违规事项汇总.pdf",
|
|
||||||
client.lastArgs.object());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 验证 recorder 没有记录时,服务端配置的存储 URL 仍可通过 MinIO 客户端直读。
|
|
||||||
*
|
|
||||||
* @throws Exception 测试替身配置或流读取失败
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
public void configuredStorageUrlWithoutRecorderUsesExactMinioObject() throws Exception {
|
|
||||||
byte[] content = "configured-content".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
|
||||||
RecordingMinioClient client = new RecordingMinioClient(content);
|
|
||||||
MinioFileStorage platform = new MinioFileStorage();
|
|
||||||
platform.setPlatform("minio-main");
|
|
||||||
platform.setBucketName("easyflow");
|
|
||||||
platform.setBasePath("attachment");
|
|
||||||
platform.setDomain("http://127.0.0.1:39000/easyflow/");
|
|
||||||
platform.setClientFactory(new FixedMinioClientFactory(client));
|
|
||||||
XFIleStorageServiceImpl service = createService(new RecoverableStorageService(platform));
|
|
||||||
String fileUrl = "http://127.0.0.1:39000/easyflow/attachment/1/2026/8/26/"
|
|
||||||
+ "0f0db465-7fa0-46b1-9fae-4f5a8c85f881/投标文件否决(废标)违规事项汇总.pdf";
|
|
||||||
|
|
||||||
FileStorageWriteHandle handle = service.resolveTrustedFile(fileUrl).orElseThrow();
|
|
||||||
byte[] actual;
|
|
||||||
try (InputStream inputStream = service.readRecoverable(handle)) {
|
|
||||||
actual = inputStream.readAllBytes();
|
|
||||||
}
|
|
||||||
|
|
||||||
assertArrayEquals(content, actual);
|
|
||||||
assertEquals(
|
|
||||||
"attachment/1/2026/8/26/0f0db465-7fa0-46b1-9fae-4f5a8c85f881/"
|
|
||||||
+ "投标文件否决(废标)违规事项汇总.pdf",
|
|
||||||
client.lastArgs.object());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 验证未配置为存储地址的回环 URL 不会被识别为可信附件。
|
|
||||||
*
|
|
||||||
* @throws Exception 测试替身注入失败
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
public void unconfiguredLoopbackUrlIsNotResolved() throws Exception {
|
|
||||||
RecoverablePlatform platform = new RecoverablePlatform(
|
|
||||||
"minio-main", "attachment", "http://127.0.0.1:39000/easyflow/");
|
|
||||||
XFIleStorageServiceImpl service = createService(new RecoverableStorageService(platform));
|
|
||||||
|
|
||||||
assertTrue(service.resolveTrustedFile(
|
|
||||||
"http://127.0.0.1:39000/other/unconfigured.pdf").isEmpty());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 验证 recorder 中非规范物理路径会失败关闭。
|
|
||||||
*
|
|
||||||
* @throws Exception 测试替身注入失败
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
public void corruptedRecordedLocationIsRejected() throws Exception {
|
|
||||||
RecoverablePlatform platform = new RecoverablePlatform(
|
|
||||||
"minio-main", "attachment", "http://127.0.0.1:39000/easyflow/");
|
|
||||||
RecoverableStorageService delegate = new RecoverableStorageService(platform);
|
|
||||||
String fileUrl = "http://127.0.0.1:39000/easyflow/attachment/demo.pdf";
|
|
||||||
delegate.recordedFileInfo = new FileInfo()
|
|
||||||
.setUrl(fileUrl)
|
|
||||||
.setPlatform("minio-main")
|
|
||||||
.setBasePath("attachment")
|
|
||||||
.setPath("missing-leading-slash/")
|
|
||||||
.setFilename("demo.pdf");
|
|
||||||
XFIleStorageServiceImpl service = createService(delegate);
|
|
||||||
|
|
||||||
IOException exception = assertThrows(
|
|
||||||
IOException.class,
|
|
||||||
() -> service.resolveTrustedFile(fileUrl));
|
|
||||||
|
|
||||||
assertTrue(exception.getMessage().contains("无法恢复"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证 recorder 完全缺失目标记录时,精确删除仍直接作用于物理平台并成功。
|
* 验证 recorder 完全缺失目标记录时,精确删除仍直接作用于物理平台并成功。
|
||||||
*
|
*
|
||||||
@@ -588,8 +471,6 @@ public class XFIleStorageServiceImplTest {
|
|||||||
private int recorderDeleteCalls;
|
private int recorderDeleteCalls;
|
||||||
/** recorder 删除是否抛出异常。 */
|
/** recorder 删除是否抛出异常。 */
|
||||||
private boolean recorderDeleteThrows;
|
private boolean recorderDeleteThrows;
|
||||||
/** recorder 返回的服务端文件记录。 */
|
|
||||||
private FileInfo recordedFileInfo;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建聚合服务替身。
|
* 创建聚合服务替身。
|
||||||
@@ -598,17 +479,10 @@ public class XFIleStorageServiceImplTest {
|
|||||||
*/
|
*/
|
||||||
private RecoverableStorageService(FileStorage platform) {
|
private RecoverableStorageService(FileStorage platform) {
|
||||||
this.platform = platform;
|
this.platform = platform;
|
||||||
setFileStorageList(new java.util.concurrent.CopyOnWriteArrayList<>(
|
|
||||||
java.util.List.of(platform)));
|
|
||||||
setFileRecorder(new FileRecorder() {
|
setFileRecorder(new FileRecorder() {
|
||||||
@Override public boolean save(FileInfo fileInfo) { return true; }
|
@Override public boolean save(FileInfo fileInfo) { return true; }
|
||||||
@Override public void update(FileInfo fileInfo) { }
|
@Override public void update(FileInfo fileInfo) { }
|
||||||
@Override public FileInfo getByUrl(String url) {
|
@Override public FileInfo getByUrl(String url) { return null; }
|
||||||
return recordedFileInfo != null
|
|
||||||
&& url.equals(recordedFileInfo.getUrl())
|
|
||||||
? recordedFileInfo
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
@Override public boolean delete(String url) {
|
@Override public boolean delete(String url) {
|
||||||
recorderDeleteCalls++;
|
recorderDeleteCalls++;
|
||||||
if (recorderDeleteThrows) {
|
if (recorderDeleteThrows) {
|
||||||
@@ -632,15 +506,6 @@ public class XFIleStorageServiceImplTest {
|
|||||||
return platform.getPlatform().equals(name) ? (T) platform : null;
|
return platform.getPlatform().equals(name) ? (T) platform : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** {@inheritDoc} */
|
|
||||||
@Override
|
|
||||||
public FileInfo getFileInfoByUrl(String url) {
|
|
||||||
return recordedFileInfo != null
|
|
||||||
&& url.equals(recordedFileInfo.getUrl())
|
|
||||||
? recordedFileInfo
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** {@inheritDoc} */
|
/** {@inheritDoc} */
|
||||||
@Override
|
@Override
|
||||||
public org.dromara.x.file.storage.core.upload.UploadPretreatment of(Object file) {
|
public org.dromara.x.file.storage.core.upload.UploadPretreatment of(Object file) {
|
||||||
|
|||||||
@@ -1169,8 +1169,6 @@ public class AgentRunService {
|
|||||||
StringBuilder answer = new StringBuilder();
|
StringBuilder answer = new StringBuilder();
|
||||||
ChatAssistantAccumulator assistantAccumulator = new ChatAssistantAccumulator();
|
ChatAssistantAccumulator assistantAccumulator = new ChatAssistantAccumulator();
|
||||||
LegacyThinkingTagParser legacyThinkingTagParser = new LegacyThinkingTagParser();
|
LegacyThinkingTagParser legacyThinkingTagParser = new LegacyThinkingTagParser();
|
||||||
KnowledgeRetrievalStatusTracker knowledgeRetrievalStatusTracker =
|
|
||||||
new KnowledgeRetrievalStatusTracker();
|
|
||||||
// 注册 emit 服务
|
// 注册 emit 服务
|
||||||
registerEmitterCancellation(requestId, runOutput, chatContext, answer,
|
registerEmitterCancellation(requestId, runOutput, chatContext, answer,
|
||||||
assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog);
|
assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog);
|
||||||
@@ -1179,7 +1177,7 @@ public class AgentRunService {
|
|||||||
if (isAguiCancellationRequested(runOutput)) {
|
if (isAguiCancellationRequested(runOutput)) {
|
||||||
handleRuntimeEvent(cancellationEvent("用户已停止生成"), requestId, runOutput,
|
handleRuntimeEvent(cancellationEvent("用户已停止生成"), requestId, runOutput,
|
||||||
answer, assistantAccumulator, legacyThinkingTagParser,
|
answer, assistantAccumulator, legacyThinkingTagParser,
|
||||||
knowledgeRetrievalStatusTracker, chatContext, finished, persistChatlog);
|
chatContext, finished, persistChatlog);
|
||||||
if (lockHandle != null) {
|
if (lockHandle != null) {
|
||||||
releaseRunLockQuietly(lockHandle, requestId);
|
releaseRunLockQuietly(lockHandle, requestId);
|
||||||
}
|
}
|
||||||
@@ -1197,7 +1195,7 @@ public class AgentRunService {
|
|||||||
request.setAgentDefinition(bundle.getDefinition());
|
request.setAgentDefinition(bundle.getDefinition());
|
||||||
request.setRuntimeContext(runtimeContext);
|
request.setRuntimeContext(runtimeContext);
|
||||||
request.setToolInvokers(bundle.getToolInvokers());
|
request.setToolInvokers(bundle.getToolInvokers());
|
||||||
request.setKnowledgeRegistrations(bundle.getKnowledgeRegistrations());
|
request.setKnowledgeRetrievers(bundle.getKnowledgeRetrievers());
|
||||||
request.setSessionStore(runtimeSessionStore);
|
request.setSessionStore(runtimeSessionStore);
|
||||||
request.setMediaResolver(agentMediaService.runtimeResolver(account));
|
request.setMediaResolver(agentMediaService.runtimeResolver(account));
|
||||||
request.getMetadata().put("assistantCode", assistantCode);
|
request.getMetadata().put("assistantCode", assistantCode);
|
||||||
@@ -1226,7 +1224,6 @@ public class AgentRunService {
|
|||||||
runRuntimeCallbackSafely(
|
runRuntimeCallbackSafely(
|
||||||
() -> handleRuntimeEvent(event, requestId, runOutput, answer,
|
() -> handleRuntimeEvent(event, requestId, runOutput, answer,
|
||||||
assistantAccumulator, legacyThinkingTagParser,
|
assistantAccumulator, legacyThinkingTagParser,
|
||||||
knowledgeRetrievalStatusTracker,
|
|
||||||
chatContext, finished, persistChatlog),
|
chatContext, finished, persistChatlog),
|
||||||
requestId, runOutput, chatContext, finished, persistChatlog);
|
requestId, runOutput, chatContext, finished, persistChatlog);
|
||||||
}
|
}
|
||||||
@@ -1516,8 +1513,7 @@ public class AgentRunService {
|
|||||||
AtomicBoolean finished,
|
AtomicBoolean finished,
|
||||||
boolean persistChatlog) {
|
boolean persistChatlog) {
|
||||||
handleRuntimeEvent(event, requestId, runOutput, answer, assistantAccumulator,
|
handleRuntimeEvent(event, requestId, runOutput, answer, assistantAccumulator,
|
||||||
new LegacyThinkingTagParser(), new KnowledgeRetrievalStatusTracker(),
|
new LegacyThinkingTagParser(), chatContext, finished, persistChatlog);
|
||||||
chatContext, finished, persistChatlog);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleRuntimeEvent(AgentRuntimeEvent event,
|
private void handleRuntimeEvent(AgentRuntimeEvent event,
|
||||||
@@ -1529,35 +1525,6 @@ public class AgentRunService {
|
|||||||
ChatRuntimeContext chatContext,
|
ChatRuntimeContext chatContext,
|
||||||
AtomicBoolean finished,
|
AtomicBoolean finished,
|
||||||
boolean persistChatlog) {
|
boolean persistChatlog) {
|
||||||
handleRuntimeEvent(event, requestId, runOutput, answer, assistantAccumulator,
|
|
||||||
legacyThinkingTagParser, new KnowledgeRetrievalStatusTracker(),
|
|
||||||
chatContext, finished, persistChatlog);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将单个 Runtime 事件投影到聊天协议,并复用本轮知识库工具状态追踪器。
|
|
||||||
*
|
|
||||||
* @param event Runtime 事件
|
|
||||||
* @param requestId 请求 ID
|
|
||||||
* @param runOutput 运行输出
|
|
||||||
* @param answer 回答累积器
|
|
||||||
* @param assistantAccumulator Assistant 结构化累积器
|
|
||||||
* @param legacyThinkingTagParser 旧思考标签解析器
|
|
||||||
* @param knowledgeRetrievalStatusTracker 知识库工具状态追踪器
|
|
||||||
* @param chatContext 聊天上下文
|
|
||||||
* @param finished 终态仲裁标记
|
|
||||||
* @param persistChatlog 是否持久化聊天日志
|
|
||||||
*/
|
|
||||||
private void handleRuntimeEvent(AgentRuntimeEvent event,
|
|
||||||
String requestId,
|
|
||||||
AgentRunOutput runOutput,
|
|
||||||
StringBuilder answer,
|
|
||||||
ChatAssistantAccumulator assistantAccumulator,
|
|
||||||
LegacyThinkingTagParser legacyThinkingTagParser,
|
|
||||||
KnowledgeRetrievalStatusTracker knowledgeRetrievalStatusTracker,
|
|
||||||
ChatRuntimeContext chatContext,
|
|
||||||
AtomicBoolean finished,
|
|
||||||
boolean persistChatlog) {
|
|
||||||
if (event == null || event.getEventType() == null) {
|
if (event == null || event.getEventType() == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1675,17 +1642,6 @@ public class AgentRunService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Map<String, Object> toolPayload = toolStatus;
|
Map<String, Object> toolPayload = toolStatus;
|
||||||
if (isKnowledgeToolEvent(event)) {
|
|
||||||
Map<String, Object> statusPayload = buildKnowledgeRetrievalStatusPayload(
|
|
||||||
knowledgeRetrievalStatusTracker.update(event));
|
|
||||||
LOG.info("Agent runtime knowledge tool call, requestId={}, toolCallId={}, toolName={}",
|
|
||||||
requestId, event.getToolCallId(), stringValue(toolPayload, "toolName"));
|
|
||||||
if (!sendEnvelope(runOutput, ChatDomain.BUSINESS, ChatType.STATUS, statusPayload)) {
|
|
||||||
cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator,
|
|
||||||
legacyThinkingTagParser, finished, persistChatlog);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!runOutput.emitRuntimeEvent(publicRuntimeEvent(event, toolPayload))) {
|
if (!runOutput.emitRuntimeEvent(publicRuntimeEvent(event, toolPayload))) {
|
||||||
cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator,
|
cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator,
|
||||||
legacyThinkingTagParser, finished, persistChatlog);
|
legacyThinkingTagParser, finished, persistChatlog);
|
||||||
@@ -1708,20 +1664,6 @@ public class AgentRunService {
|
|||||||
}
|
}
|
||||||
if (event.getEventType() == AgentRuntimeEventType.TOOL_RESULT) {
|
if (event.getEventType() == AgentRuntimeEventType.TOOL_RESULT) {
|
||||||
Map<String, Object> toolPayload = toolStatus;
|
Map<String, Object> toolPayload = toolStatus;
|
||||||
if (isKnowledgeToolEvent(event)) {
|
|
||||||
Map<String, Object> statusPayload = buildKnowledgeRetrievalStatusPayload(
|
|
||||||
knowledgeRetrievalStatusTracker.update(event));
|
|
||||||
LOG.info("Agent runtime knowledge tool result, requestId={}, toolCallId={}, toolName={}, status={}",
|
|
||||||
requestId, event.getToolCallId(), stringValue(toolPayload, "toolName"),
|
|
||||||
stringValue(statusPayload, "status"));
|
|
||||||
if (!sendEnvelope(runOutput, ChatDomain.BUSINESS, ChatType.STATUS, statusPayload)) {
|
|
||||||
cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator,
|
|
||||||
legacyThinkingTagParser, finished, persistChatlog);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
legacyThinkingTagParser.reset();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
LOG.info("Agent runtime tool result, requestId={}, toolCallId={}, toolName={}, status={}",
|
LOG.info("Agent runtime tool result, requestId={}, toolCallId={}, toolName={}, status={}",
|
||||||
requestId, event.getToolCallId(), stringValue(toolPayload, "toolName"),
|
requestId, event.getToolCallId(), stringValue(toolPayload, "toolName"),
|
||||||
stringValue(toolPayload, "status"));
|
stringValue(toolPayload, "status"));
|
||||||
@@ -1747,7 +1689,10 @@ public class AgentRunService {
|
|||||||
if (event.getEventType() == AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL) {
|
if (event.getEventType() == AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL) {
|
||||||
LOG.info("Agent runtime knowledge retrieval, requestId={}, payload={}, metadata={}",
|
LOG.info("Agent runtime knowledge retrieval, requestId={}, payload={}, metadata={}",
|
||||||
requestId, event.getPayload(), event.getMetadata());
|
requestId, event.getPayload(), event.getMetadata());
|
||||||
// 文档摘要事件用于引用与监察;UI 完成态统一以 TOOL_RESULT 为准。
|
if (!sendEnvelope(runOutput, ChatDomain.BUSINESS, ChatType.STATUS, buildKnowledgeRetrievalStatusPayload(event))) {
|
||||||
|
cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator,
|
||||||
|
legacyThinkingTagParser, finished, persistChatlog);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.getEventType() == AgentRuntimeEventType.MEMORY_COMPRESSION_STARTED
|
if (event.getEventType() == AgentRuntimeEventType.MEMORY_COMPRESSION_STARTED
|
||||||
@@ -1805,10 +1750,6 @@ public class AgentRunService {
|
|||||||
if (event.getEventType() == AgentRuntimeEventType.FAILED) {
|
if (event.getEventType() == AgentRuntimeEventType.FAILED) {
|
||||||
emitAssistantSegments(legacyThinkingTagParser.finish(), requestId, runOutput, chatContext,
|
emitAssistantSegments(legacyThinkingTagParser.finish(), requestId, runOutput, chatContext,
|
||||||
answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog);
|
answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog);
|
||||||
if (knowledgeRetrievalStatusTracker.failActiveCalls()) {
|
|
||||||
sendEnvelope(runOutput, ChatDomain.BUSINESS, ChatType.STATUS,
|
|
||||||
buildKnowledgeRetrievalStatusPayload("error"));
|
|
||||||
}
|
|
||||||
runOutput.emitRuntimeEvent(event);
|
runOutput.emitRuntimeEvent(event);
|
||||||
assistantAccumulator.finalizePendingSkillInvocations("FAILED", "技能调用失败");
|
assistantAccumulator.finalizePendingSkillInvocations("FAILED", "技能调用失败");
|
||||||
if (persistChatlog) {
|
if (persistChatlog) {
|
||||||
@@ -2872,8 +2813,7 @@ public class AgentRunService {
|
|||||||
Map<String, Object> rawPayload = event.getPayload() == null ? Map.of() : event.getPayload();
|
Map<String, Object> rawPayload = event.getPayload() == null ? Map.of() : event.getPayload();
|
||||||
Map<String, Object> payload = selectPayload(rawPayload,
|
Map<String, Object> payload = selectPayload(rawPayload,
|
||||||
"name", "status", "success", "toolDisplayName", "toolName",
|
"name", "status", "success", "toolDisplayName", "toolName",
|
||||||
"skillDisplayName", "skillId", "toolCategory",
|
"skillDisplayName", "skillId");
|
||||||
"knowledgeId", "knowledgeName", "knowledgeRuntimeName");
|
|
||||||
String toolCallId = firstText(event.getToolCallId(), stringValue(rawPayload, "toolCallId"));
|
String toolCallId = firstText(event.getToolCallId(), stringValue(rawPayload, "toolCallId"));
|
||||||
if (toolCallId != null && !toolCallId.isBlank()) {
|
if (toolCallId != null && !toolCallId.isBlank()) {
|
||||||
payload.put("toolCallId", toolCallId);
|
payload.put("toolCallId", toolCallId);
|
||||||
@@ -3009,110 +2949,17 @@ public class AgentRunService {
|
|||||||
/**
|
/**
|
||||||
* 构建知识库检索状态载荷,确保前端可按稳定 key 合并同一轮状态行。
|
* 构建知识库检索状态载荷,确保前端可按稳定 key 合并同一轮状态行。
|
||||||
*
|
*
|
||||||
* @param status running、done 或 error
|
* @param event 知识库检索运行时事件
|
||||||
* @return 知识库检索状态载荷
|
* @return 知识库检索状态载荷
|
||||||
*/
|
*/
|
||||||
private Map<String, Object> buildKnowledgeRetrievalStatusPayload(String status) {
|
private Map<String, Object> buildKnowledgeRetrievalStatusPayload(AgentRuntimeEvent event) {
|
||||||
String normalizedStatus = "running".equals(status) || "error".equals(status)
|
|
||||||
? status : "done";
|
|
||||||
Map<String, Object> payload = new LinkedHashMap<>();
|
Map<String, Object> payload = new LinkedHashMap<>();
|
||||||
payload.put("statusKey", "knowledge-retrieval");
|
payload.put("statusKey", "knowledge-retrieval");
|
||||||
payload.put("status", normalizedStatus);
|
payload.put("status", "done");
|
||||||
payload.put("label", switch (normalizedStatus) {
|
payload.put("label", "已检索知识库");
|
||||||
case "running" -> "正在检索知识库";
|
|
||||||
case "error" -> "知识库检索失败";
|
|
||||||
default -> "已检索知识库";
|
|
||||||
});
|
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断标准工具生命周期事件是否属于知识库工具。
|
|
||||||
*
|
|
||||||
* @param event 运行时工具事件
|
|
||||||
* @return 知识库工具事件时为 true
|
|
||||||
*/
|
|
||||||
private boolean isKnowledgeToolEvent(AgentRuntimeEvent event) {
|
|
||||||
String category = stringPayload(event, "toolCategory");
|
|
||||||
if ("KNOWLEDGE".equalsIgnoreCase(category)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
String toolName = firstText(stringPayload(event, "toolName"), stringPayload(event, "name"));
|
|
||||||
if (toolName == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
String normalizedName = toolName.trim().toLowerCase(Locale.ROOT);
|
|
||||||
return "retrieve_knowledge".equals(normalizedName)
|
|
||||||
|| normalizedName.startsWith("retrieve_knowledge_");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 聚合同一批知识库工具调用,避免并行检索中首个结果提前结束 UI 状态。
|
|
||||||
*/
|
|
||||||
static final class KnowledgeRetrievalStatusTracker {
|
|
||||||
|
|
||||||
private final Set<String> activeToolCallIds = new LinkedHashSet<>();
|
|
||||||
private boolean failed;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 应用一次知识库工具生命周期事件。
|
|
||||||
*
|
|
||||||
* @param event TOOL_CALL 或 TOOL_RESULT 事件
|
|
||||||
* @return 聚合后的 running、done 或 error 状态
|
|
||||||
*/
|
|
||||||
String update(AgentRuntimeEvent event) {
|
|
||||||
String toolCallId = toolCallIdentity(event);
|
|
||||||
if (event.getEventType() == AgentRuntimeEventType.TOOL_CALL) {
|
|
||||||
if (activeToolCallIds.isEmpty()) {
|
|
||||||
failed = false;
|
|
||||||
}
|
|
||||||
activeToolCallIds.add(toolCallId);
|
|
||||||
return "running";
|
|
||||||
}
|
|
||||||
if (event.getEventType() == AgentRuntimeEventType.TOOL_RESULT) {
|
|
||||||
activeToolCallIds.remove(toolCallId);
|
|
||||||
failed = failed || !toolSucceeded(event);
|
|
||||||
if (!activeToolCallIds.isEmpty()) {
|
|
||||||
return "running";
|
|
||||||
}
|
|
||||||
return failed ? "error" : "done";
|
|
||||||
}
|
|
||||||
throw new IllegalArgumentException("Knowledge status only accepts TOOL_CALL or TOOL_RESULT events.");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将运行失败时仍未结束的知识库调用收口为失败。
|
|
||||||
*
|
|
||||||
* @return 存在未结束调用时为 true
|
|
||||||
*/
|
|
||||||
boolean failActiveCalls() {
|
|
||||||
if (activeToolCallIds.isEmpty()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
activeToolCallIds.clear();
|
|
||||||
failed = true;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String toolCallIdentity(AgentRuntimeEvent event) {
|
|
||||||
String toolCallId = event.getToolCallId();
|
|
||||||
if (toolCallId == null || toolCallId.isBlank()) {
|
|
||||||
Object payloadId = event.getPayload() == null ? null : event.getPayload().get("toolCallId");
|
|
||||||
toolCallId = payloadId == null ? event.getEventId() : String.valueOf(payloadId);
|
|
||||||
}
|
|
||||||
return toolCallId;
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean toolSucceeded(AgentRuntimeEvent event) {
|
|
||||||
Map<String, Object> payload = event.getPayload() == null ? Map.of() : event.getPayload();
|
|
||||||
if (Boolean.FALSE.equals(payload.get("success"))) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
Object status = payload.get("status");
|
|
||||||
return status == null || !"FAILED".equalsIgnoreCase(String.valueOf(status));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建不含 Runtime 原始上下文的内存压缩公开状态载荷。
|
* 构建不含 Runtime 原始上下文的内存压缩公开状态载荷。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
package tech.easyflow.agent.runtime;
|
package tech.easyflow.agent.runtime;
|
||||||
|
|
||||||
import com.easyagents.agent.runtime.AgentDefinition;
|
import com.easyagents.agent.runtime.AgentDefinition;
|
||||||
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRegistration;
|
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetriever;
|
||||||
import com.easyagents.agent.runtime.tool.AgentToolInvoker;
|
import com.easyagents.agent.runtime.tool.AgentToolInvoker;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,7 +14,7 @@ public class AgentRuntimeBundle {
|
|||||||
|
|
||||||
private AgentDefinition definition;
|
private AgentDefinition definition;
|
||||||
private Map<String, AgentToolInvoker> toolInvokers = new LinkedHashMap<>();
|
private Map<String, AgentToolInvoker> toolInvokers = new LinkedHashMap<>();
|
||||||
private List<AgentKnowledgeRegistration> knowledgeRegistrations = new ArrayList<>();
|
private Map<String, AgentKnowledgeRetriever> knowledgeRetrievers = new LinkedHashMap<>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取 Agent 定义。
|
* 获取 Agent 定义。
|
||||||
@@ -59,18 +57,16 @@ public class AgentRuntimeBundle {
|
|||||||
*
|
*
|
||||||
* @return 知识库检索器
|
* @return 知识库检索器
|
||||||
*/
|
*/
|
||||||
public List<AgentKnowledgeRegistration> getKnowledgeRegistrations() {
|
public Map<String, AgentKnowledgeRetriever> getKnowledgeRetrievers() {
|
||||||
return knowledgeRegistrations;
|
return knowledgeRetrievers;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置知识库检索器。
|
* 设置知识库检索器。
|
||||||
*
|
*
|
||||||
* @param knowledgeRegistrations 知识库运行时绑定
|
* @param knowledgeRetrievers 知识库检索器
|
||||||
*/
|
*/
|
||||||
public void setKnowledgeRegistrations(List<AgentKnowledgeRegistration> knowledgeRegistrations) {
|
public void setKnowledgeRetrievers(Map<String, AgentKnowledgeRetriever> knowledgeRetrievers) {
|
||||||
this.knowledgeRegistrations = knowledgeRegistrations == null
|
this.knowledgeRetrievers = knowledgeRetrievers == null ? new LinkedHashMap<>() : knowledgeRetrievers;
|
||||||
? new ArrayList<>()
|
|
||||||
: new ArrayList<>(knowledgeRegistrations);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,9 @@ import com.easyagents.agent.runtime.AgentRuntimeContext;
|
|||||||
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
|
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
|
||||||
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
|
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
|
||||||
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeDocument;
|
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeDocument;
|
||||||
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRegistration;
|
import com.easyagents.agent.runtime.knowledge.AgentKnowledgePolicy;
|
||||||
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetrievalResult;
|
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetrievalResult;
|
||||||
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeSpec;
|
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeSpec;
|
||||||
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeToolNames;
|
|
||||||
import com.easyagents.agent.runtime.memory.AgentMemoryCompressionParameter;
|
import com.easyagents.agent.runtime.memory.AgentMemoryCompressionParameter;
|
||||||
import com.easyagents.agent.runtime.memory.AgentMemoryPolicy;
|
import com.easyagents.agent.runtime.memory.AgentMemoryPolicy;
|
||||||
import com.easyagents.agent.runtime.memory.AgentMemoryType;
|
import com.easyagents.agent.runtime.memory.AgentMemoryType;
|
||||||
@@ -119,11 +118,11 @@ public class AgentRuntimeCompiler {
|
|||||||
bundle.setDefinition(definition);
|
bundle.setDefinition(definition);
|
||||||
|
|
||||||
compileTools(agent, definition, bundle);
|
compileTools(agent, definition, bundle);
|
||||||
compileKnowledge(agent, definition, bundle);
|
|
||||||
if (agentBuiltinToolsConfigResolver != null) {
|
if (agentBuiltinToolsConfigResolver != null) {
|
||||||
validateBuiltinTools(definition,
|
validateBuiltinTools(definition,
|
||||||
agentBuiltinToolsConfigResolver.resolvePublishedRuntime(agent.getExecutionConfigJson()));
|
agentBuiltinToolsConfigResolver.resolvePublishedRuntime(agent.getExecutionConfigJson()));
|
||||||
}
|
}
|
||||||
|
compileKnowledge(agent, definition, bundle);
|
||||||
return bundle;
|
return bundle;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,7 +294,7 @@ public class AgentRuntimeCompiler {
|
|||||||
if (config.artifactPublish().enabled()) {
|
if (config.artifactPublish().enabled()) {
|
||||||
specs.add(buildArtifactPublishSpec(config.artifactPublish()));
|
specs.add(buildArtifactPublishSpec(config.artifactPublish()));
|
||||||
}
|
}
|
||||||
assertToolBudget(specs, definition.getMcpSpecs(), definition.getKnowledgeSpecs().size());
|
assertToolBudget(specs, definition.getMcpSpecs());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void attachBuiltinTools(Agent agent,
|
private void attachBuiltinTools(Agent agent,
|
||||||
@@ -511,21 +510,11 @@ public class AgentRuntimeCompiler {
|
|||||||
return names;
|
return names;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 校验内置工具与普通工具、知识库工具及 MCP 工具不存在运行名冲突。
|
|
||||||
*
|
|
||||||
* @param definition 已编译 Agent 定义
|
|
||||||
* @param builtinNames 待启用内置工具名称
|
|
||||||
* @throws BusinessException 工具名称冲突时抛出
|
|
||||||
*/
|
|
||||||
private void assertNoBuiltinNameConflict(AgentDefinition definition, Set<String> builtinNames) {
|
private void assertNoBuiltinNameConflict(AgentDefinition definition, Set<String> builtinNames) {
|
||||||
Set<String> existing = new LinkedHashSet<>();
|
Set<String> existing = new LinkedHashSet<>();
|
||||||
for (AgentToolSpec spec : definition.getToolSpecs()) {
|
for (AgentToolSpec spec : definition.getToolSpecs()) {
|
||||||
existing.add(spec.getName());
|
existing.add(spec.getName());
|
||||||
}
|
}
|
||||||
for (AgentKnowledgeSpec spec : definition.getKnowledgeSpecs()) {
|
|
||||||
existing.add(AgentKnowledgeToolNames.build(spec.getRuntimeName()));
|
|
||||||
}
|
|
||||||
for (McpSpec mcp : definition.getMcpSpecs()) {
|
for (McpSpec mcp : definition.getMcpSpecs()) {
|
||||||
if (mcp.getFrozenToolManifest() != null) {
|
if (mcp.getFrozenToolManifest() != null) {
|
||||||
mcp.getFrozenToolManifest().forEach(entry -> existing.add(entry.getName()));
|
mcp.getFrozenToolManifest().forEach(entry -> existing.add(entry.getName()));
|
||||||
@@ -551,14 +540,6 @@ public class AgentRuntimeCompiler {
|
|||||||
assertToolBudget(toolSpecs, mcpSpecs, 0);
|
assertToolBudget(toolSpecs, mcpSpecs, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 校验最终工具数量和 Schema 大小预算。
|
|
||||||
*
|
|
||||||
* @param toolSpecs 静态 Tool 声明
|
|
||||||
* @param mcpSpecs MCP 声明
|
|
||||||
* @param additionalToolCount 知识库等额外工具数量
|
|
||||||
* @throws BusinessException 超出预算时抛出
|
|
||||||
*/
|
|
||||||
private void assertToolBudget(List<AgentToolSpec> toolSpecs,
|
private void assertToolBudget(List<AgentToolSpec> toolSpecs,
|
||||||
List<McpSpec> mcpSpecs,
|
List<McpSpec> mcpSpecs,
|
||||||
int additionalToolCount) {
|
int additionalToolCount) {
|
||||||
@@ -589,7 +570,7 @@ public class AgentRuntimeCompiler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (toolCount > MAX_RUNTIME_TOOL_COUNT) {
|
if (toolCount > MAX_RUNTIME_TOOL_COUNT) {
|
||||||
throw new BusinessException("Agent Runtime Tool 数量超过 128 个,请减少工具、知识库或 Skill 绑定");
|
throw new BusinessException("Agent Runtime Tool 数量超过 128 个,请减少直接工具或 Skill 绑定");
|
||||||
}
|
}
|
||||||
if (schemaBytes > MAX_RUNTIME_SCHEMA_BYTES) {
|
if (schemaBytes > MAX_RUNTIME_SCHEMA_BYTES) {
|
||||||
throw new BusinessException("Agent Runtime Tool Schema 超过 2 MiB,请减少工具或精简 Schema");
|
throw new BusinessException("Agent Runtime Tool Schema 超过 2 MiB,请减少工具或精简 Schema");
|
||||||
@@ -610,27 +591,12 @@ public class AgentRuntimeCompiler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 将 EasyFlow 知识库绑定编译为一库一工具所需的声明和 Retriever 绑定。
|
|
||||||
*
|
|
||||||
* @param agent Agent 发布视图
|
|
||||||
* @param definition 中立 Agent 定义
|
|
||||||
* @param bundle 运行时编译结果
|
|
||||||
* @throws BusinessException 知识库不存在、英文运行名非法或工具名冲突时抛出
|
|
||||||
*/
|
|
||||||
private void compileKnowledge(Agent agent, AgentDefinition definition, AgentRuntimeBundle bundle) {
|
private void compileKnowledge(Agent agent, AgentDefinition definition, AgentRuntimeBundle bundle) {
|
||||||
if (agent.getKnowledgeBindings() == null) {
|
if (agent.getKnowledgeBindings() == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
List<AgentKnowledgeSpec> specs = new ArrayList<>();
|
List<AgentKnowledgeSpec> specs = new ArrayList<>();
|
||||||
List<AgentKnowledgeRegistration> registrations = new ArrayList<>();
|
Map<String, com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetriever> retrievers = new LinkedHashMap<>();
|
||||||
Set<String> knowledgeToolNames = new LinkedHashSet<>();
|
|
||||||
Set<String> existingToolNames = new LinkedHashSet<>();
|
|
||||||
definition.getToolSpecs().stream()
|
|
||||||
.filter(Objects::nonNull)
|
|
||||||
.map(AgentToolSpec::getName)
|
|
||||||
.filter(Objects::nonNull)
|
|
||||||
.forEach(existingToolNames::add);
|
|
||||||
for (AgentKnowledgeBinding binding : agent.getKnowledgeBindings()) {
|
for (AgentKnowledgeBinding binding : agent.getKnowledgeBindings()) {
|
||||||
if (!Boolean.TRUE.equals(binding.getEnabled())) {
|
if (!Boolean.TRUE.equals(binding.getEnabled())) {
|
||||||
continue;
|
continue;
|
||||||
@@ -641,9 +607,9 @@ public class AgentRuntimeCompiler {
|
|||||||
}
|
}
|
||||||
AgentKnowledgeSpec spec = new AgentKnowledgeSpec();
|
AgentKnowledgeSpec spec = new AgentKnowledgeSpec();
|
||||||
spec.setKnowledgeId(binding.getKnowledgeId().toString());
|
spec.setKnowledgeId(binding.getKnowledgeId().toString());
|
||||||
spec.setRuntimeName(requireKnowledgeRuntimeName(knowledge));
|
|
||||||
spec.setName(knowledge.getTitle());
|
spec.setName(knowledge.getTitle());
|
||||||
spec.setDescription(knowledge.getDescription());
|
spec.setDescription(knowledge.getDescription());
|
||||||
|
spec.setRetrievalMode(AgentKnowledgePolicy.AGENTIC);
|
||||||
spec.getMetadata().put("knowledgeType", knowledge.getCollectionType());
|
spec.getMetadata().put("knowledgeType", knowledge.getCollectionType());
|
||||||
spec.getMetadata().put("faqCollection", knowledge.isFaqCollection());
|
spec.getMetadata().put("faqCollection", knowledge.isFaqCollection());
|
||||||
Integer limit = intValue(binding.getOptionsJson(), "limit");
|
Integer limit = intValue(binding.getOptionsJson(), "limit");
|
||||||
@@ -652,37 +618,11 @@ public class AgentRuntimeCompiler {
|
|||||||
if (threshold != null) {
|
if (threshold != null) {
|
||||||
spec.setScoreThreshold(threshold);
|
spec.setScoreThreshold(threshold);
|
||||||
}
|
}
|
||||||
String toolName = AgentKnowledgeToolNames.build(spec.getRuntimeName());
|
|
||||||
if (!knowledgeToolNames.add(toolName) || existingToolNames.contains(toolName)) {
|
|
||||||
throw new BusinessException("Agent 知识库工具运行名冲突:" + toolName);
|
|
||||||
}
|
|
||||||
specs.add(spec);
|
specs.add(spec);
|
||||||
registrations.add(new AgentKnowledgeRegistration(spec,
|
retrievers.put(spec.getKnowledgeId(), request -> retrieveKnowledge(binding, request.getQuery(), request.getLimit(), request.getScoreThreshold()));
|
||||||
request -> retrieveKnowledge(binding, request.getQuery(), request.getLimit(), request.getScoreThreshold())));
|
|
||||||
}
|
}
|
||||||
definition.setKnowledgeSpecs(specs);
|
definition.setKnowledgeSpecs(specs);
|
||||||
bundle.setKnowledgeRegistrations(registrations);
|
bundle.setKnowledgeRetrievers(retrievers);
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取并校验知识库英文运行名。
|
|
||||||
*
|
|
||||||
* @param knowledge 知识库发布视图
|
|
||||||
* @return 合法英文运行名
|
|
||||||
* @throws BusinessException 英文运行名缺失或非法时抛出
|
|
||||||
*/
|
|
||||||
private String requireKnowledgeRuntimeName(DocumentCollection knowledge) {
|
|
||||||
String runtimeName = knowledge == null ? null : knowledge.getEnglishName();
|
|
||||||
try {
|
|
||||||
AgentKnowledgeToolNames.build(runtimeName);
|
|
||||||
return runtimeName.trim();
|
|
||||||
} catch (RuntimeException exception) {
|
|
||||||
String knowledgeName = knowledge == null || knowledge.getTitle() == null
|
|
||||||
? "未知知识库"
|
|
||||||
: knowledge.getTitle();
|
|
||||||
throw new BusinessException(400, 400, "知识库“" + knowledgeName
|
|
||||||
+ "”的英文名称不能为空,且只能包含字母、数字、下划线和连字符", exception);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private AgentKnowledgeRetrievalResult retrieveKnowledge(AgentKnowledgeBinding binding, String query, int limit, double scoreThreshold) {
|
private AgentKnowledgeRetrievalResult retrieveKnowledge(AgentKnowledgeBinding binding, String query, int limit, double scoreThreshold) {
|
||||||
|
|||||||
@@ -67,9 +67,7 @@ public abstract class AbstractAgentAsyncSubTools implements AsyncSubTools {
|
|||||||
* @param arguments 调用参数
|
* @param arguments 调用参数
|
||||||
* @return 执行结果
|
* @return 执行结果
|
||||||
*/
|
*/
|
||||||
protected abstract AgentToolExecutionResult executeBusiness(
|
protected abstract AgentToolExecutionResult executeBusiness(Map<String, Object> arguments);
|
||||||
Map<String, Object> arguments,
|
|
||||||
AgentToolContext context);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@inheritDoc}
|
* {@inheritDoc}
|
||||||
@@ -94,7 +92,7 @@ public abstract class AbstractAgentAsyncSubTools implements AsyncSubTools {
|
|||||||
record.getMetadata().put("toolDisplayName", displayName());
|
record.getMetadata().put("toolDisplayName", displayName());
|
||||||
appendEvent(record, "SUBMITTED", displayName() + "任务已提交");
|
appendEvent(record, "SUBMITTED", displayName() + "任务已提交");
|
||||||
taskStore.create(record);
|
taskStore.create(record);
|
||||||
dispatch(sessionId, record.getTaskId(), record.getArguments(), context);
|
dispatch(sessionId, record.getTaskId(), record.getArguments());
|
||||||
|
|
||||||
AsyncToolSubmitResult result = new AsyncToolSubmitResult();
|
AsyncToolSubmitResult result = new AsyncToolSubmitResult();
|
||||||
result.setTaskId(taskId);
|
result.setTaskId(taskId);
|
||||||
@@ -159,23 +157,16 @@ public abstract class AbstractAgentAsyncSubTools implements AsyncSubTools {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void dispatch(String sessionId,
|
private void dispatch(String sessionId, String taskId, Map<String, Object> arguments) {
|
||||||
String taskId,
|
|
||||||
Map<String, Object> arguments,
|
|
||||||
AgentToolContext context) {
|
|
||||||
try {
|
try {
|
||||||
taskExecutor.execute(() -> executeTask(
|
taskExecutor.execute(() -> executeTask(sessionId, taskId, arguments));
|
||||||
sessionId, taskId, arguments, context));
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
taskStore.update(sessionId, taskId, record -> fail(record, e));
|
taskStore.update(sessionId, taskId, record -> fail(record, e));
|
||||||
throw new BusinessException("提交异步工具任务失败:" + safeMessage(e));
|
throw new BusinessException("提交异步工具任务失败:" + safeMessage(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void executeTask(String sessionId,
|
private void executeTask(String sessionId, String taskId, Map<String, Object> arguments) {
|
||||||
String taskId,
|
|
||||||
Map<String, Object> arguments,
|
|
||||||
AgentToolContext context) {
|
|
||||||
try {
|
try {
|
||||||
taskStore.update(sessionId, taskId, record -> {
|
taskStore.update(sessionId, taskId, record -> {
|
||||||
record.setStatus(AsyncToolTaskStatus.RUNNING);
|
record.setStatus(AsyncToolTaskStatus.RUNNING);
|
||||||
@@ -183,8 +174,7 @@ public abstract class AbstractAgentAsyncSubTools implements AsyncSubTools {
|
|||||||
appendEvent(record, "RUNNING", displayName() + "任务执行中");
|
appendEvent(record, "RUNNING", displayName() + "任务执行中");
|
||||||
return record;
|
return record;
|
||||||
});
|
});
|
||||||
AgentToolExecutionResult executionResult = executeBusiness(
|
AgentToolExecutionResult executionResult = executeBusiness(arguments);
|
||||||
arguments, context);
|
|
||||||
taskStore.update(sessionId, taskId, record -> {
|
taskStore.update(sessionId, taskId, record -> {
|
||||||
record.setStatus(AsyncToolTaskStatus.SUCCEEDED);
|
record.setStatus(AsyncToolTaskStatus.SUCCEEDED);
|
||||||
record.setSummary(displayName() + "任务已完成");
|
record.setSummary(displayName() + "任务已完成");
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package tech.easyflow.agent.runtime.asynctool;
|
package tech.easyflow.agent.runtime.asynctool;
|
||||||
|
|
||||||
import com.easyagents.agent.runtime.tool.AgentToolContext;
|
|
||||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||||
import tech.easyflow.agent.enums.AgentToolType;
|
import tech.easyflow.agent.enums.AgentToolType;
|
||||||
import tech.easyflow.agent.runtime.tool.AgentToolExecutionResult;
|
import tech.easyflow.agent.runtime.tool.AgentToolExecutionResult;
|
||||||
@@ -83,9 +82,7 @@ public class PluginAsyncSubTools extends AbstractAgentAsyncSubTools {
|
|||||||
* {@inheritDoc}
|
* {@inheritDoc}
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
protected AgentToolExecutionResult executeBusiness(
|
protected AgentToolExecutionResult executeBusiness(Map<String, Object> arguments) {
|
||||||
Map<String, Object> arguments,
|
|
||||||
AgentToolContext context) {
|
|
||||||
return pluginToolExecutor.execute(pluginItem, plugin, arguments);
|
return pluginToolExecutor.execute(pluginItem, plugin, arguments);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package tech.easyflow.agent.runtime.asynctool;
|
package tech.easyflow.agent.runtime.asynctool;
|
||||||
|
|
||||||
import com.easyagents.agent.runtime.tool.AgentToolContext;
|
|
||||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||||
import tech.easyflow.agent.enums.AgentToolType;
|
import tech.easyflow.agent.enums.AgentToolType;
|
||||||
import tech.easyflow.agent.runtime.tool.AgentToolExecutionResult;
|
import tech.easyflow.agent.runtime.tool.AgentToolExecutionResult;
|
||||||
@@ -78,9 +77,7 @@ public class WorkflowAsyncSubTools extends AbstractAgentAsyncSubTools {
|
|||||||
* {@inheritDoc}
|
* {@inheritDoc}
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
protected AgentToolExecutionResult executeBusiness(
|
protected AgentToolExecutionResult executeBusiness(Map<String, Object> arguments) {
|
||||||
Map<String, Object> arguments,
|
return workflowToolExecutor.execute(workflow, arguments);
|
||||||
AgentToolContext context) {
|
|
||||||
return workflowToolExecutor.execute(workflow, arguments, context);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import java.time.Instant;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -564,9 +563,7 @@ public final class AguiAgentRunOutput implements AgentRunOutput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isHiddenToolName(String toolName) {
|
private static boolean isHiddenToolName(String toolName) {
|
||||||
String normalizedName = toolName == null ? "" : toolName.trim().toLowerCase(Locale.ROOT);
|
return "retrieve_knowledge".equalsIgnoreCase(toolName)
|
||||||
return "retrieve_knowledge".equals(normalizedName)
|
|
||||||
|| normalizedName.startsWith("retrieve_knowledge_")
|
|
||||||
|| "context_reload".equalsIgnoreCase(toolName)
|
|| "context_reload".equalsIgnoreCase(toolName)
|
||||||
|| "__fragment__".equalsIgnoreCase(toolName);
|
|| "__fragment__".equalsIgnoreCase(toolName);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ public class AgentToolRuntimeCompiler {
|
|||||||
Tool tool = workflowToolExecutor.buildTool(workflow);
|
Tool tool = workflowToolExecutor.buildTool(workflow);
|
||||||
AgentToolSpec spec = toToolSpec(tool, binding);
|
AgentToolSpec spec = toToolSpec(tool, binding);
|
||||||
AgentToolInvoker invoker = (arguments, context) -> invokeSafely(spec.getName(), binding, context,
|
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);
|
return new CompiledSyncTool(spec, invoker);
|
||||||
}
|
}
|
||||||
if (type == AgentToolType.PLUGIN) {
|
if (type == AgentToolType.PLUGIN) {
|
||||||
|
|||||||
@@ -2,19 +2,13 @@ package tech.easyflow.agent.runtime.tool;
|
|||||||
|
|
||||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||||
import com.easyagents.core.model.chat.tool.Tool;
|
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.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import tech.easyflow.ai.easyagents.tool.WorkflowTool;
|
import tech.easyflow.ai.easyagents.tool.WorkflowTool;
|
||||||
import tech.easyflow.ai.easyagentsflow.repository.FrozenWorkflowDefinitionRegistry;
|
import tech.easyflow.ai.easyagentsflow.repository.FrozenWorkflowDefinitionRegistry;
|
||||||
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
|
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
|
||||||
import tech.easyflow.ai.entity.Workflow;
|
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 java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,63 +59,11 @@ public class WorkflowToolExecutor {
|
|||||||
* @return 执行结果
|
* @return 执行结果
|
||||||
*/
|
*/
|
||||||
public AgentToolExecutionResult execute(Workflow workflow, Map<String, Object> arguments) {
|
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(
|
Object result = chainExecutor.executeWithoutSuspension(
|
||||||
definitionId(workflow), variables);
|
definitionId(workflow), arguments == null ? Map.of() : arguments);
|
||||||
return new AgentToolExecutionResult(result, resolveBusinessExecutionId(result));
|
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) {
|
private String definitionId(Workflow workflow) {
|
||||||
if (frozenDefinitionRegistry != null && workflow != null
|
if (frozenDefinitionRegistry != null && workflow != null
|
||||||
&& workflow.getContent() != null && !workflow.getContent().isBlank()) {
|
&& workflow.getContent() != null && !workflow.getContent().isBlank()) {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import org.junit.Test;
|
|||||||
import tech.easyflow.agent.entity.Agent;
|
import tech.easyflow.agent.entity.Agent;
|
||||||
import tech.easyflow.agent.entity.AgentToolBinding;
|
import tech.easyflow.agent.entity.AgentToolBinding;
|
||||||
import tech.easyflow.agent.enums.AgentToolType;
|
import tech.easyflow.agent.enums.AgentToolType;
|
||||||
import tech.easyflow.agent.runtime.skill.AgentSkillRuntimeCompiler;
|
|
||||||
import tech.easyflow.agent.runtime.tool.AgentToolRuntimeCompiler;
|
import tech.easyflow.agent.runtime.tool.AgentToolRuntimeCompiler;
|
||||||
import tech.easyflow.ai.entity.Mcp;
|
import tech.easyflow.ai.entity.Mcp;
|
||||||
import tech.easyflow.ai.entity.Model;
|
import tech.easyflow.ai.entity.Model;
|
||||||
@@ -45,8 +44,6 @@ public class AgentDefinitionCompilerMcpTest {
|
|||||||
setField(toolCompiler, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper());
|
setField(toolCompiler, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper());
|
||||||
setField(toolCompiler, "mcpService", mcpService(mcp));
|
setField(toolCompiler, "mcpService", mcpService(mcp));
|
||||||
setField(compiler, "agentToolRuntimeCompiler", toolCompiler);
|
setField(compiler, "agentToolRuntimeCompiler", toolCompiler);
|
||||||
setField(compiler, "agentSkillRuntimeCompiler", new AgentSkillRuntimeCompiler(
|
|
||||||
null, toolCompiler, new com.fasterxml.jackson.databind.ObjectMapper()));
|
|
||||||
|
|
||||||
Agent agent = agent(modelId, mcpId);
|
Agent agent = agent(modelId, mcpId);
|
||||||
|
|
||||||
|
|||||||
@@ -476,48 +476,18 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证知识库工具开始事件会投影为脱敏的检索中状态。
|
* 验证知识检索状态不会携带命中文档和内部 metadata。
|
||||||
*
|
*
|
||||||
* @throws Exception 反射调用失败时抛出
|
* @throws Exception 反射调用失败时抛出
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
public void handleRuntimeEventShouldProjectKnowledgeToolCallAsRunningStatus() throws Exception {
|
public void handleRuntimeEventShouldWhitelistKnowledgeStatusPayload() throws Exception {
|
||||||
AgentRunService service = new AgentRunService();
|
AgentRunService service = new AgentRunService();
|
||||||
RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
|
RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
|
||||||
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_CALL);
|
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL);
|
||||||
event.setToolCallId("knowledge-call-1");
|
|
||||||
event.getPayload().put("toolCallId", "knowledge-call-1");
|
|
||||||
event.getPayload().put("toolName", "retrieve_knowledge_homeinn_faq");
|
|
||||||
event.getPayload().put("toolCategory", "KNOWLEDGE");
|
|
||||||
event.getPayload().put("documents", List.of(Map.of("chunkContent", "private chunk")));
|
event.getPayload().put("documents", List.of(Map.of("chunkContent", "private chunk")));
|
||||||
event.getPayload().put("metadata", Map.of("sourceUri", "private://document"));
|
event.getPayload().put("metadata", Map.of("sourceUri", "private://document"));
|
||||||
|
|
||||||
invoke(service, "handleRuntimeEvent",
|
|
||||||
runtimeEventParameterTypes(),
|
|
||||||
event, "request-knowledge", legacyOutput(emitter), new StringBuilder(),
|
|
||||||
new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), false);
|
|
||||||
|
|
||||||
Assert.assertEquals(1, emitter.envelopes.size());
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> payload = (Map<String, Object>) emitter.envelopes.get(0).getPayload();
|
|
||||||
Assert.assertEquals(Map.of(
|
|
||||||
"label", "正在检索知识库",
|
|
||||||
"status", "running",
|
|
||||||
"statusKey", "knowledge-retrieval"), payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 验证知识库工具结果事件会投影为完成状态。
|
|
||||||
*
|
|
||||||
* @throws Exception 反射调用失败时抛出
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
public void handleRuntimeEventShouldProjectKnowledgeToolResultAsDoneStatus() throws Exception {
|
|
||||||
AgentRunService service = new AgentRunService();
|
|
||||||
RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
|
|
||||||
AgentRuntimeEvent event = knowledgeToolEvent(
|
|
||||||
AgentRuntimeEventType.TOOL_RESULT, "knowledge-call-1", true);
|
|
||||||
|
|
||||||
invoke(service, "handleRuntimeEvent",
|
invoke(service, "handleRuntimeEvent",
|
||||||
runtimeEventParameterTypes(),
|
runtimeEventParameterTypes(),
|
||||||
event, "request-knowledge", legacyOutput(emitter), new StringBuilder(),
|
event, "request-knowledge", legacyOutput(emitter), new StringBuilder(),
|
||||||
@@ -532,48 +502,6 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
"statusKey", "knowledge-retrieval"), payload);
|
"statusKey", "knowledge-retrieval"), payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 验证文档摘要事件不会抢先把知识库工具状态标记为完成。
|
|
||||||
*
|
|
||||||
* @throws Exception 反射调用失败时抛出
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
public void handleRuntimeEventShouldNotCompleteKnowledgeStatusFromDocumentEvent() throws Exception {
|
|
||||||
AgentRunService service = new AgentRunService();
|
|
||||||
RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
|
|
||||||
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL);
|
|
||||||
event.getPayload().put("documents", List.of(Map.of("chunkContent", "private chunk")));
|
|
||||||
|
|
||||||
invoke(service, "handleRuntimeEvent",
|
|
||||||
runtimeEventParameterTypes(),
|
|
||||||
event, "request-knowledge", legacyOutput(emitter), new StringBuilder(),
|
|
||||||
new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), false);
|
|
||||||
|
|
||||||
Assert.assertTrue(emitter.envelopes.isEmpty());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 验证并行知识库调用全部结束后才进入终态,并保留任一调用失败结果。
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
public void knowledgeStatusTrackerShouldAggregateParallelToolCalls() {
|
|
||||||
AgentRunService.KnowledgeRetrievalStatusTracker tracker =
|
|
||||||
new AgentRunService.KnowledgeRetrievalStatusTracker();
|
|
||||||
AgentRuntimeEvent firstCall = knowledgeToolEvent(
|
|
||||||
AgentRuntimeEventType.TOOL_CALL, "knowledge-call-1", true);
|
|
||||||
AgentRuntimeEvent secondCall = knowledgeToolEvent(
|
|
||||||
AgentRuntimeEventType.TOOL_CALL, "knowledge-call-2", true);
|
|
||||||
AgentRuntimeEvent firstResult = knowledgeToolEvent(
|
|
||||||
AgentRuntimeEventType.TOOL_RESULT, "knowledge-call-1", false);
|
|
||||||
AgentRuntimeEvent secondResult = knowledgeToolEvent(
|
|
||||||
AgentRuntimeEventType.TOOL_RESULT, "knowledge-call-2", true);
|
|
||||||
|
|
||||||
Assert.assertEquals("running", tracker.update(firstCall));
|
|
||||||
Assert.assertEquals("running", tracker.update(secondCall));
|
|
||||||
Assert.assertEquals("running", tracker.update(firstResult));
|
|
||||||
Assert.assertEquals("error", tracker.update(secondResult));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证完成事件不会再次发送正文消息,只用于最终收口。
|
* 验证完成事件不会再次发送正文消息,只用于最终收口。
|
||||||
*
|
*
|
||||||
@@ -1651,29 +1579,6 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建知识库工具生命周期测试事件。
|
|
||||||
*
|
|
||||||
* @param eventType 工具开始或结果事件类型
|
|
||||||
* @param toolCallId 工具调用 ID
|
|
||||||
* @param success 工具结果是否成功
|
|
||||||
* @return 知识库工具事件
|
|
||||||
*/
|
|
||||||
private AgentRuntimeEvent knowledgeToolEvent(AgentRuntimeEventType eventType,
|
|
||||||
String toolCallId,
|
|
||||||
boolean success) {
|
|
||||||
AgentRuntimeEvent event = AgentRuntimeEvent.of(eventType);
|
|
||||||
event.setToolCallId(toolCallId);
|
|
||||||
event.getPayload().put("toolCallId", toolCallId);
|
|
||||||
event.getPayload().put("toolName", "retrieve_knowledge_homeinn_faq");
|
|
||||||
event.getPayload().put("toolCategory", "KNOWLEDGE");
|
|
||||||
if (eventType == AgentRuntimeEventType.TOOL_RESULT) {
|
|
||||||
event.getPayload().put("success", success);
|
|
||||||
event.getPayload().put("status", success ? "SUCCESS" : "FAILED");
|
|
||||||
}
|
|
||||||
return event;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Class<?>[] runtimeEventParameterTypes() {
|
private Class<?>[] runtimeEventParameterTypes() {
|
||||||
return new Class<?>[]{AgentRuntimeEvent.class, String.class, AgentRunOutput.class, StringBuilder.class,
|
return new Class<?>[]{AgentRuntimeEvent.class, String.class, AgentRunOutput.class, StringBuilder.class,
|
||||||
ChatAssistantAccumulator.class,
|
ChatAssistantAccumulator.class,
|
||||||
|
|||||||
@@ -1,268 +0,0 @@
|
|||||||
package tech.easyflow.agent.runtime;
|
|
||||||
|
|
||||||
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeDocument;
|
|
||||||
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRegistration;
|
|
||||||
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetrievalRequest;
|
|
||||||
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetrievalResult;
|
|
||||||
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeSpec;
|
|
||||||
import com.easyagents.core.document.Document;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import org.junit.Assert;
|
|
||||||
import org.junit.Test;
|
|
||||||
import tech.easyflow.agent.entity.Agent;
|
|
||||||
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
|
|
||||||
import tech.easyflow.agent.runtime.skill.AgentSkillRuntimeCompiler;
|
|
||||||
import tech.easyflow.agent.runtime.tool.AgentToolRuntimeCompiler;
|
|
||||||
import tech.easyflow.ai.entity.Model;
|
|
||||||
import tech.easyflow.ai.entity.ModelProvider;
|
|
||||||
import tech.easyflow.ai.rag.KnowledgeRetrievalRequest;
|
|
||||||
import tech.easyflow.ai.service.DocumentCollectionService;
|
|
||||||
import tech.easyflow.ai.service.ModelService;
|
|
||||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
|
||||||
|
|
||||||
import java.lang.reflect.Field;
|
|
||||||
import java.lang.reflect.Proxy;
|
|
||||||
import java.math.BigInteger;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Agent 知识库一库一工具运行时编译测试。
|
|
||||||
*/
|
|
||||||
public class AgentRuntimeCompilerKnowledgeTest {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 验证知识库英文名称、描述和检索配置会编译到中立声明及独立 Retriever。
|
|
||||||
*
|
|
||||||
* @throws Exception 反射注入依赖失败时抛出
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
public void compileShouldBuildOneKnowledgeRegistrationWithEnglishRuntimeName() throws Exception {
|
|
||||||
AtomicReference<KnowledgeRetrievalRequest> capturedRequest = new AtomicReference<>();
|
|
||||||
Document document = new Document("如家酒店通常在入住日 14:00 后办理入住。");
|
|
||||||
document.setId("chunk-1");
|
|
||||||
document.setTitle("如家 FAQ");
|
|
||||||
document.setScore(0.92D);
|
|
||||||
document.addMetadata("documentId", "faq-document-1");
|
|
||||||
document.addMetadata("chunkId", "faq-chunk-1");
|
|
||||||
AgentRuntimeCompiler compiler = compiler(capturedRequest, List.of(document));
|
|
||||||
Agent agent = agent(knowledgeBinding("homeinn_faq", BigInteger.valueOf(20L)));
|
|
||||||
|
|
||||||
AgentRuntimeBundle bundle = compiler.compile(agent);
|
|
||||||
|
|
||||||
Assert.assertEquals(1, bundle.getDefinition().getKnowledgeSpecs().size());
|
|
||||||
AgentKnowledgeSpec spec = bundle.getDefinition().getKnowledgeSpecs().get(0);
|
|
||||||
Assert.assertEquals("homeinn_faq", spec.getRuntimeName());
|
|
||||||
Assert.assertEquals("如家 FAQ", spec.getName());
|
|
||||||
Assert.assertTrue(spec.getDescription().contains("入住"));
|
|
||||||
Assert.assertEquals(7, spec.getLimit());
|
|
||||||
Assert.assertEquals(0.55D, spec.getScoreThreshold(), 0.0001D);
|
|
||||||
Assert.assertEquals(1, bundle.getKnowledgeRegistrations().size());
|
|
||||||
|
|
||||||
AgentKnowledgeRegistration registration = bundle.getKnowledgeRegistrations().get(0);
|
|
||||||
AgentKnowledgeRetrievalRequest retrievalRequest = new AgentKnowledgeRetrievalRequest();
|
|
||||||
retrievalRequest.setQuery("如家几点入住");
|
|
||||||
retrievalRequest.setLimit(spec.getLimit());
|
|
||||||
retrievalRequest.setScoreThreshold(spec.getScoreThreshold());
|
|
||||||
AgentKnowledgeRetrievalResult result = registration.getRetriever().retrieve(retrievalRequest);
|
|
||||||
|
|
||||||
Assert.assertEquals("如家几点入住", capturedRequest.get().getQuery());
|
|
||||||
Assert.assertEquals(Integer.valueOf(7), capturedRequest.get().getLimit());
|
|
||||||
Assert.assertEquals(Double.valueOf(0.55D), capturedRequest.get().getMinSimilarity());
|
|
||||||
Assert.assertEquals("AGENT_KNOWLEDGE", capturedRequest.get().getCallerType());
|
|
||||||
Assert.assertEquals(1, result.getDocuments().size());
|
|
||||||
AgentKnowledgeDocument mapped = result.getDocuments().get(0);
|
|
||||||
Assert.assertEquals("faq-document-1", mapped.getDocumentId());
|
|
||||||
Assert.assertEquals("faq-chunk-1", mapped.getChunkId());
|
|
||||||
Assert.assertEquals(0.92D, mapped.getScore(), 0.0001D);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 验证缺失知识库英文名称时在发布编译阶段明确失败。
|
|
||||||
*
|
|
||||||
* @throws Exception 反射注入依赖失败时抛出
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
public void compileShouldRejectMissingKnowledgeEnglishName() throws Exception {
|
|
||||||
AgentRuntimeCompiler compiler = compiler(new AtomicReference<>(), List.of());
|
|
||||||
Agent agent = agent(knowledgeBinding(null, BigInteger.valueOf(20L)));
|
|
||||||
|
|
||||||
try {
|
|
||||||
compiler.compile(agent);
|
|
||||||
Assert.fail("缺失英文名称时应拒绝编译");
|
|
||||||
} catch (BusinessException expected) {
|
|
||||||
Assert.assertTrue(expected.getMessage().contains("英文名称不能为空"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 验证多个知识库生成相同工具名时在编译阶段拒绝发布。
|
|
||||||
*
|
|
||||||
* @throws Exception 反射注入依赖失败时抛出
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
public void compileShouldRejectDuplicateKnowledgeToolNames() throws Exception {
|
|
||||||
AgentRuntimeCompiler compiler = compiler(new AtomicReference<>(), List.of());
|
|
||||||
AgentKnowledgeBinding first = knowledgeBinding("homeinn_faq", BigInteger.valueOf(20L));
|
|
||||||
AgentKnowledgeBinding second = knowledgeBinding("homeinn_faq", BigInteger.valueOf(21L));
|
|
||||||
Agent agent = agent(first, second);
|
|
||||||
|
|
||||||
try {
|
|
||||||
compiler.compile(agent);
|
|
||||||
Assert.fail("重复知识库工具名时应拒绝编译");
|
|
||||||
} catch (BusinessException expected) {
|
|
||||||
Assert.assertTrue(expected.getMessage().contains("retrieve_knowledge_homeinn_faq"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建仅含测试模型与知识库服务的运行时编译器。
|
|
||||||
*
|
|
||||||
* @param capturedRequest 检索请求捕获器
|
|
||||||
* @param documents 检索服务返回文档
|
|
||||||
* @return 已注入依赖的编译器
|
|
||||||
* @throws Exception 反射注入失败时抛出
|
|
||||||
*/
|
|
||||||
private AgentRuntimeCompiler compiler(AtomicReference<KnowledgeRetrievalRequest> capturedRequest,
|
|
||||||
List<Document> documents) throws Exception {
|
|
||||||
ObjectMapper objectMapper = new ObjectMapper();
|
|
||||||
AgentToolRuntimeCompiler toolCompiler = new AgentToolRuntimeCompiler();
|
|
||||||
AgentRuntimeCompiler compiler = new AgentRuntimeCompiler();
|
|
||||||
setField(compiler, "objectMapper", objectMapper);
|
|
||||||
setField(compiler, "modelService", modelService(model()));
|
|
||||||
setField(compiler, "documentCollectionService", documentCollectionService(capturedRequest, documents));
|
|
||||||
setField(compiler, "agentToolRuntimeCompiler", toolCompiler);
|
|
||||||
setField(compiler, "agentSkillRuntimeCompiler",
|
|
||||||
new AgentSkillRuntimeCompiler(null, toolCompiler, objectMapper));
|
|
||||||
return compiler;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建带知识库绑定的 Agent。
|
|
||||||
*
|
|
||||||
* @param bindings 知识库绑定
|
|
||||||
* @return Agent 测试对象
|
|
||||||
*/
|
|
||||||
private Agent agent(AgentKnowledgeBinding... bindings) {
|
|
||||||
Agent agent = new Agent();
|
|
||||||
agent.setId(BigInteger.ONE);
|
|
||||||
agent.setName("如家助手");
|
|
||||||
agent.setModelId(BigInteger.TEN);
|
|
||||||
agent.setKnowledgeBindings(List.of(bindings));
|
|
||||||
return agent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建冻结知识库绑定。
|
|
||||||
*
|
|
||||||
* @param englishName 知识库英文名称
|
|
||||||
* @param knowledgeId 知识库 ID
|
|
||||||
* @return 知识库绑定
|
|
||||||
*/
|
|
||||||
private AgentKnowledgeBinding knowledgeBinding(String englishName, BigInteger knowledgeId) {
|
|
||||||
AgentKnowledgeBinding binding = new AgentKnowledgeBinding();
|
|
||||||
binding.setAgentId(BigInteger.ONE);
|
|
||||||
binding.setKnowledgeId(knowledgeId);
|
|
||||||
binding.setRetrievalMode("HYBRID");
|
|
||||||
binding.setEnabled(true);
|
|
||||||
binding.setOptionsJson(Map.of("limit", 7, "scoreThreshold", 0.55D));
|
|
||||||
binding.setResourceSnapshot(Map.of(
|
|
||||||
"id", knowledgeId,
|
|
||||||
"title", "如家 FAQ",
|
|
||||||
"description", "如家酒店入住、退房和会员服务常见问题",
|
|
||||||
"collectionType", "FAQ",
|
|
||||||
"englishName", englishName == null ? "" : englishName));
|
|
||||||
return binding;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建模型服务代理。
|
|
||||||
*
|
|
||||||
* @param model 测试模型
|
|
||||||
* @return 模型服务代理
|
|
||||||
*/
|
|
||||||
private ModelService modelService(Model model) {
|
|
||||||
return (ModelService) Proxy.newProxyInstance(
|
|
||||||
ModelService.class.getClassLoader(),
|
|
||||||
new Class<?>[]{ModelService.class},
|
|
||||||
(proxy, method, args) -> "getModelInstance".equals(method.getName())
|
|
||||||
? model
|
|
||||||
: defaultValue(method.getReturnType()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建知识库服务代理。
|
|
||||||
*
|
|
||||||
* @param capturedRequest 检索请求捕获器
|
|
||||||
* @param documents 返回文档
|
|
||||||
* @return 知识库服务代理
|
|
||||||
*/
|
|
||||||
private DocumentCollectionService documentCollectionService(
|
|
||||||
AtomicReference<KnowledgeRetrievalRequest> capturedRequest,
|
|
||||||
List<Document> documents) {
|
|
||||||
return (DocumentCollectionService) Proxy.newProxyInstance(
|
|
||||||
DocumentCollectionService.class.getClassLoader(),
|
|
||||||
new Class<?>[]{DocumentCollectionService.class},
|
|
||||||
(proxy, method, args) -> {
|
|
||||||
if ("search".equals(method.getName()) && args != null && args.length == 1
|
|
||||||
&& args[0] instanceof KnowledgeRetrievalRequest request) {
|
|
||||||
capturedRequest.set(request);
|
|
||||||
return documents;
|
|
||||||
}
|
|
||||||
return defaultValue(method.getReturnType());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建可映射为 AgentScope 模型配置的测试模型。
|
|
||||||
*
|
|
||||||
* @return 测试模型
|
|
||||||
*/
|
|
||||||
private Model model() {
|
|
||||||
ModelProvider provider = new ModelProvider();
|
|
||||||
provider.setProviderType("openai");
|
|
||||||
provider.setProviderName("OpenAI");
|
|
||||||
Model model = new Model();
|
|
||||||
model.setId(BigInteger.TEN);
|
|
||||||
model.setModelProvider(provider);
|
|
||||||
model.setModelName("gpt-test");
|
|
||||||
model.setEndpoint("https://example.com");
|
|
||||||
model.setRequestPath("/v1/chat/completions");
|
|
||||||
model.setApiKey("test-key");
|
|
||||||
return model;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 返回代理方法所需的默认值。
|
|
||||||
*
|
|
||||||
* @param type 返回类型
|
|
||||||
* @return 对应默认值
|
|
||||||
*/
|
|
||||||
private Object defaultValue(Class<?> type) {
|
|
||||||
if (type == boolean.class) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (type == int.class || type == long.class || type == short.class || type == byte.class) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (type == double.class || type == float.class) {
|
|
||||||
return 0D;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 反射注入测试依赖。
|
|
||||||
*
|
|
||||||
* @param target 目标对象
|
|
||||||
* @param fieldName 字段名称
|
|
||||||
* @param value 字段值
|
|
||||||
* @throws Exception 字段不存在或不可写时抛出
|
|
||||||
*/
|
|
||||||
private void setField(Object target, String fieldName, Object value) throws Exception {
|
|
||||||
Field field = target.getClass().getDeclaredField(fieldName);
|
|
||||||
field.setAccessible(true);
|
|
||||||
field.set(target, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -146,9 +146,7 @@ public class AbstractAgentAsyncSubToolsTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected AgentToolExecutionResult executeBusiness(
|
protected AgentToolExecutionResult executeBusiness(Map<String, Object> arguments) {
|
||||||
Map<String, Object> arguments,
|
|
||||||
AgentToolContext context) {
|
|
||||||
return new AgentToolExecutionResult(Map.of("echo", arguments.get("keyword")), "business-run-1");
|
return new AgentToolExecutionResult(Map.of("echo", arguments.get("keyword")), "business-run-1");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,10 +153,7 @@ public class WorkflowPluginAsyncSubToolsTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public AgentToolExecutionResult execute(
|
public AgentToolExecutionResult execute(Workflow workflow, Map<String, Object> arguments) {
|
||||||
Workflow workflow,
|
|
||||||
Map<String, Object> arguments,
|
|
||||||
AgentToolContext context) {
|
|
||||||
return new AgentToolExecutionResult(businessResult, "workflow-run-1");
|
return new AgentToolExecutionResult(businessResult, "workflow-run-1");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,10 +145,7 @@ public class AgentToolRuntimeCompilerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public AgentToolExecutionResult execute(
|
public AgentToolExecutionResult execute(Workflow workflow, Map<String, Object> arguments) {
|
||||||
Workflow workflow,
|
|
||||||
Map<String, Object> arguments,
|
|
||||||
AgentToolContext context) {
|
|
||||||
throw new IllegalStateException("jdbc:mysql://internal:3306 secret-token");
|
throw new IllegalStateException("jdbc:mysql://internal:3306 secret-token");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -265,10 +262,7 @@ public class AgentToolRuntimeCompilerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public AgentToolExecutionResult execute(
|
public AgentToolExecutionResult execute(Workflow workflow, Map<String, Object> arguments) {
|
||||||
Workflow workflow,
|
|
||||||
Map<String, Object> arguments,
|
|
||||||
AgentToolContext context) {
|
|
||||||
return new AgentToolExecutionResult(Map.of("ok", true), "wf-run-1");
|
return new AgentToolExecutionResult(Map.of("ok", true), "wf-run-1");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -53,10 +53,6 @@
|
|||||||
<groupId>com.easyagents</groupId>
|
<groupId>com.easyagents</groupId>
|
||||||
<artifactId>easy-agents-spring-boot-starter</artifactId>
|
<artifactId>easy-agents-spring-boot-starter</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
|
||||||
<groupId>io.milvus</groupId>
|
|
||||||
<artifactId>milvus-sdk-java</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.google.re2j</groupId>
|
<groupId>com.google.re2j</groupId>
|
||||||
<artifactId>re2j</artifactId>
|
<artifactId>re2j</artifactId>
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -17,14 +17,6 @@ public class AiMilvusConfig extends MilvusVectorStoreConfig {
|
|||||||
config.setPassword(getPassword());
|
config.setPassword(getPassword());
|
||||||
config.setAutoCreateCollection(isAutoCreateCollection());
|
config.setAutoCreateCollection(isAutoCreateCollection());
|
||||||
config.setDefaultCollectionName(collectionName);
|
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;
|
return config;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ import tech.easyflow.ai.documentimport.task.DocumentImportStatusBroadcastPropert
|
|||||||
DocumentImportBulkProperties.class,
|
DocumentImportBulkProperties.class,
|
||||||
DocumentImportParseMonitorProperties.class,
|
DocumentImportParseMonitorProperties.class,
|
||||||
DocumentImportStatusBroadcastProperties.class,
|
DocumentImportStatusBroadcastProperties.class,
|
||||||
RagHealthProperties.class,
|
RagHealthProperties.class
|
||||||
MultiKnowledgeRetrievalProperties.class
|
|
||||||
})
|
})
|
||||||
@AutoConfiguration
|
@AutoConfiguration
|
||||||
public class AiModuleConfig {
|
public class AiModuleConfig {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ public class EasyFlowThreadPoolProperties {
|
|||||||
private Pool sse = new Pool(4, 16, 2000, 30, true);
|
private Pool sse = new Pool(4, 16, 2000, 30, true);
|
||||||
private Pool documentImport = new Pool(2, 4, 200, 60, true);
|
private Pool documentImport = new Pool(2, 4, 200, 60, true);
|
||||||
private Pool agentAsyncTool = new Pool(2, 8, 200, 60, true);
|
private Pool agentAsyncTool = new Pool(2, 8, 200, 60, true);
|
||||||
private Pool knowledgeRetrieval = new Pool(4, 8, 64, 30, true);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取 SSE 线程池配置。
|
* 获取 SSE 线程池配置。
|
||||||
@@ -67,14 +66,6 @@ public class EasyFlowThreadPoolProperties {
|
|||||||
this.agentAsyncTool = agentAsyncTool;
|
this.agentAsyncTool = agentAsyncTool;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Pool getKnowledgeRetrieval() {
|
|
||||||
return knowledgeRetrieval;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setKnowledgeRetrieval(Pool knowledgeRetrieval) {
|
|
||||||
this.knowledgeRetrieval = knowledgeRetrieval;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 线程池配置项。
|
* 线程池配置项。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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("节点总超时时间不能小于单知识库超时时间");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -25,23 +25,16 @@ public class RagHealthIndicator {
|
|||||||
public static class RagMilvusHealthIndicator extends CachedHealthIndicatorSupport implements HealthIndicator {
|
public static class RagMilvusHealthIndicator extends CachedHealthIndicatorSupport implements HealthIndicator {
|
||||||
|
|
||||||
private final AiMilvusConfig aiMilvusConfig;
|
private final AiMilvusConfig aiMilvusConfig;
|
||||||
private final AiMilvusClientManager milvusClientManager;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建 Milvus 健康检查器。
|
* 创建 Milvus 健康检查器。
|
||||||
*
|
*
|
||||||
* @param aiMilvusConfig Milvus 配置
|
* @param aiMilvusConfig Milvus 配置
|
||||||
* @param milvusClientManager 应用级 Milvus 客户端池
|
|
||||||
* @param healthProperties RAG 健康检查配置
|
* @param healthProperties RAG 健康检查配置
|
||||||
*/
|
*/
|
||||||
public RagMilvusHealthIndicator(
|
public RagMilvusHealthIndicator(AiMilvusConfig aiMilvusConfig, RagHealthProperties healthProperties) {
|
||||||
AiMilvusConfig aiMilvusConfig,
|
|
||||||
AiMilvusClientManager milvusClientManager,
|
|
||||||
RagHealthProperties healthProperties
|
|
||||||
) {
|
|
||||||
super(healthProperties);
|
super(healthProperties);
|
||||||
this.aiMilvusConfig = aiMilvusConfig;
|
this.aiMilvusConfig = aiMilvusConfig;
|
||||||
this.milvusClientManager = milvusClientManager;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,10 +51,8 @@ public class RagHealthIndicator {
|
|||||||
protected Health doHealthCheck() {
|
protected Health doHealthCheck() {
|
||||||
MilvusVectorStore vectorStore = null;
|
MilvusVectorStore vectorStore = null;
|
||||||
try {
|
try {
|
||||||
milvusClientManager.reconfigureIfNeeded(aiMilvusConfig);
|
|
||||||
vectorStore = new MilvusVectorStore(
|
vectorStore = new MilvusVectorStore(
|
||||||
aiMilvusConfig.copyForCollection("__rag_health_probe__"),
|
aiMilvusConfig.copyForCollection("__rag_health_probe__")
|
||||||
milvusClientManager
|
|
||||||
);
|
);
|
||||||
if (vectorStore.checkAvailable()) {
|
if (vectorStore.checkAvailable()) {
|
||||||
return Health.up().withDetail("uri", aiMilvusConfig.getUri()).build();
|
return Health.up().withDetail("uri", aiMilvusConfig.getUri()).build();
|
||||||
|
|||||||
@@ -104,28 +104,4 @@ public class ThreadPoolConfig {
|
|||||||
executor.initialize();
|
executor.initialize();
|
||||||
return executor;
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,10 +75,6 @@ public class DocumentParseBridgeException extends RuntimeException {
|
|||||||
return new DocumentParseBridgeException("task_failed", message);
|
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) {
|
public static DocumentParseBridgeException resultFetchFailed(String message, Throwable cause) {
|
||||||
return new DocumentParseBridgeException("result_fetch_failed", message, cause);
|
return new DocumentParseBridgeException("result_fetch_failed", message, cause);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import com.easyagents.document.core.entity.ParseResponse;
|
|||||||
import com.easyagents.document.core.entity.ParseResult;
|
import com.easyagents.document.core.entity.ParseResult;
|
||||||
import com.easyagents.document.core.entity.ParseTaskInfo;
|
import com.easyagents.document.core.entity.ParseTaskInfo;
|
||||||
import com.easyagents.document.core.entity.ParseTaskStatus;
|
import com.easyagents.document.core.entity.ParseTaskStatus;
|
||||||
import com.easyagents.document.core.exception.DocumentAsyncTaskNotFoundException;
|
|
||||||
import com.easyagents.document.pdf.PdfDocumentParseService;
|
import com.easyagents.document.pdf.PdfDocumentParseService;
|
||||||
import com.easyagents.document.pptx.PptxDocumentParseService;
|
import com.easyagents.document.pptx.PptxDocumentParseService;
|
||||||
import com.easyagents.document.xlsx.XlsxDocumentParseService;
|
import com.easyagents.document.xlsx.XlsxDocumentParseService;
|
||||||
@@ -141,8 +140,6 @@ public class DocumentParseBridgeServiceImpl implements DocumentParseBridgeServic
|
|||||||
return parseResultMapper.map(taskStatus);
|
return parseResultMapper.map(taskStatus);
|
||||||
} catch (DocumentParseBridgeException e) {
|
} catch (DocumentParseBridgeException e) {
|
||||||
throw e;
|
throw e;
|
||||||
} catch (DocumentAsyncTaskNotFoundException e) {
|
|
||||||
throw DocumentParseBridgeException.taskNotFound("异步文档解析任务不存在", e);
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw DocumentParseBridgeException.taskFailed("查询异步文档解析任务状态失败", e);
|
throw DocumentParseBridgeException.taskFailed("查询异步文档解析任务状态失败", e);
|
||||||
}
|
}
|
||||||
@@ -178,9 +175,6 @@ public class DocumentParseBridgeServiceImpl implements DocumentParseBridgeServic
|
|||||||
} catch (DocumentParseBridgeException e) {
|
} catch (DocumentParseBridgeException e) {
|
||||||
LOG.error("桥接服务获取异步解析结果失败: providerTaskId={}", taskId, e);
|
LOG.error("桥接服务获取异步解析结果失败: providerTaskId={}", taskId, e);
|
||||||
throw e;
|
throw e;
|
||||||
} catch (DocumentAsyncTaskNotFoundException e) {
|
|
||||||
LOG.warn("桥接服务异步解析执行已丢失: providerTaskId={}", taskId);
|
|
||||||
throw DocumentParseBridgeException.taskNotFound("异步文档解析任务不存在", e);
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOG.error("桥接服务获取异步解析结果异常: providerTaskId={}", taskId, e);
|
LOG.error("桥接服务获取异步解析结果异常: providerTaskId={}", taskId, e);
|
||||||
throw DocumentParseBridgeException.resultFetchFailed("获取异步文档解析结果失败", e);
|
throw DocumentParseBridgeException.resultFetchFailed("获取异步文档解析结果失败", e);
|
||||||
@@ -218,9 +212,6 @@ public class DocumentParseBridgeServiceImpl implements DocumentParseBridgeServic
|
|||||||
} catch (DocumentParseBridgeException e) {
|
} catch (DocumentParseBridgeException e) {
|
||||||
LOG.error("桥接服务查询异步解析任务状态失败: providerTaskId={}", taskId, e);
|
LOG.error("桥接服务查询异步解析任务状态失败: providerTaskId={}", taskId, e);
|
||||||
throw e;
|
throw e;
|
||||||
} catch (DocumentAsyncTaskNotFoundException e) {
|
|
||||||
LOG.warn("桥接服务异步解析执行已丢失: providerTaskId={}", taskId);
|
|
||||||
throw DocumentParseBridgeException.taskNotFound("异步文档解析任务不存在", e);
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOG.error("桥接服务查询异步解析任务状态异常: providerTaskId={}", taskId, e);
|
LOG.error("桥接服务查询异步解析任务状态异常: providerTaskId={}", taskId, e);
|
||||||
throw DocumentParseBridgeException.taskFailed("聚合查询异步文档解析任务信息失败", e);
|
throw DocumentParseBridgeException.taskFailed("聚合查询异步文档解析任务信息失败", e);
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ import tech.easyflow.ai.document.exception.DocumentParseBridgeException;
|
|||||||
import tech.easyflow.ai.document.model.DocumentSourceRef;
|
import tech.easyflow.ai.document.model.DocumentSourceRef;
|
||||||
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadedFileReader;
|
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadedFileReader;
|
||||||
import tech.easyflow.common.filestorage.FileStorageService;
|
import tech.easyflow.common.filestorage.FileStorageService;
|
||||||
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
|
|
||||||
import tech.easyflow.common.filestorage.utils.PathGeneratorUtil;
|
import tech.easyflow.common.filestorage.utils.PathGeneratorUtil;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -165,7 +164,7 @@ public class DocumentSourceLoader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 优先打开经过上传记录验证的受管 URL 和服务端已登记附件,再执行普通公网 URL 校验与下载。
|
* 优先打开经过上传记录验证的受管 URL,再执行普通公网 URL 校验与下载。
|
||||||
*
|
*
|
||||||
* @param remoteUrl 远端 URL
|
* @param remoteUrl 远端 URL
|
||||||
* @param maxBytes 最大允许读取字节数
|
* @param maxBytes 最大允许读取字节数
|
||||||
@@ -177,13 +176,6 @@ public class DocumentSourceLoader {
|
|||||||
if (managed.isPresent()) {
|
if (managed.isPresent()) {
|
||||||
return DocumentInputStreamSupport.limit(managed.get(), maxBytes);
|
return DocumentInputStreamSupport.limit(managed.get(), maxBytes);
|
||||||
}
|
}
|
||||||
Optional<FileStorageWriteHandle> trusted =
|
|
||||||
fileStorageService.resolveTrustedFile(remoteUrl);
|
|
||||||
if (trusted.isPresent()) {
|
|
||||||
return DocumentInputStreamSupport.limit(
|
|
||||||
fileStorageService.readRecoverable(trusted.get()),
|
|
||||||
maxBytes);
|
|
||||||
}
|
|
||||||
return DocumentInputStreamSupport.openRemote(remoteUrl, maxBytes);
|
return DocumentInputStreamSupport.openRemote(remoteUrl, maxBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ import org.springframework.transaction.support.TransactionSynchronization;
|
|||||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import tech.easyflow.ai.document.support.DocumentParseFilePolicy;
|
|
||||||
import tech.easyflow.ai.documentimport.DocumentImportBatchCreateContext;
|
import tech.easyflow.ai.documentimport.DocumentImportBatchCreateContext;
|
||||||
import tech.easyflow.ai.documentimport.DocumentImportBatchDtos;
|
import tech.easyflow.ai.documentimport.DocumentImportBatchDtos;
|
||||||
import tech.easyflow.ai.documentimport.DocumentImportBatchRetryResult;
|
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 Logger LOG = LoggerFactory.getLogger(DocumentImportBatchAppService.class);
|
||||||
private static final Set<String> SUPPORTED_EXTENSIONS =
|
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 BATCH_MUTATION_LOCK_LEASE = Duration.ofMinutes(30);
|
||||||
private static final Duration RECOVERY_DISPATCH_LEASE = Duration.ofMinutes(2);
|
private static final Duration RECOVERY_DISPATCH_LEASE = Duration.ofMinutes(2);
|
||||||
private static final Duration RECOVERY_LEASE_RENEW_INTERVAL =
|
private static final Duration RECOVERY_LEASE_RENEW_INTERVAL =
|
||||||
@@ -80,7 +79,6 @@ public class DocumentImportBatchAppService {
|
|||||||
private final DocumentMapper documentMapper;
|
private final DocumentMapper documentMapper;
|
||||||
private final RedisLockExecutor redisLockExecutor;
|
private final RedisLockExecutor redisLockExecutor;
|
||||||
private final DocumentImportBatchCircuitBreaker circuitBreaker;
|
private final DocumentImportBatchCircuitBreaker circuitBreaker;
|
||||||
private final DocumentImportRecoveryCoordinator recoveryCoordinator;
|
|
||||||
|
|
||||||
@Resource(name = "default")
|
@Resource(name = "default")
|
||||||
private FileStorageService storageService;
|
private FileStorageService storageService;
|
||||||
@@ -98,7 +96,6 @@ public class DocumentImportBatchAppService {
|
|||||||
* @param documentMapper 文档 Mapper
|
* @param documentMapper 文档 Mapper
|
||||||
* @param redisLockExecutor 分布式锁执行器
|
* @param redisLockExecutor 分布式锁执行器
|
||||||
* @param circuitBreaker 自动导入批次熔断器
|
* @param circuitBreaker 自动导入批次熔断器
|
||||||
* @param recoveryCoordinator 恢复令牌事务协调器
|
|
||||||
*/
|
*/
|
||||||
public DocumentImportBatchAppService(DocumentImportBatchService batchService,
|
public DocumentImportBatchAppService(DocumentImportBatchService batchService,
|
||||||
DocumentImportBatchItemService itemService,
|
DocumentImportBatchItemService itemService,
|
||||||
@@ -109,8 +106,7 @@ public class DocumentImportBatchAppService {
|
|||||||
DocumentImportBatchItemMapper itemMapper,
|
DocumentImportBatchItemMapper itemMapper,
|
||||||
DocumentMapper documentMapper,
|
DocumentMapper documentMapper,
|
||||||
RedisLockExecutor redisLockExecutor,
|
RedisLockExecutor redisLockExecutor,
|
||||||
DocumentImportBatchCircuitBreaker circuitBreaker,
|
DocumentImportBatchCircuitBreaker circuitBreaker) {
|
||||||
DocumentImportRecoveryCoordinator recoveryCoordinator) {
|
|
||||||
this.batchService = batchService;
|
this.batchService = batchService;
|
||||||
this.itemService = itemService;
|
this.itemService = itemService;
|
||||||
this.batchTracker = batchTracker;
|
this.batchTracker = batchTracker;
|
||||||
@@ -121,7 +117,6 @@ public class DocumentImportBatchAppService {
|
|||||||
this.documentMapper = documentMapper;
|
this.documentMapper = documentMapper;
|
||||||
this.redisLockExecutor = redisLockExecutor;
|
this.redisLockExecutor = redisLockExecutor;
|
||||||
this.circuitBreaker = circuitBreaker;
|
this.circuitBreaker = circuitBreaker;
|
||||||
this.recoveryCoordinator = recoveryCoordinator;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1250,10 +1245,13 @@ public class DocumentImportBatchAppService {
|
|||||||
Date claimedAt = new Date();
|
Date claimedAt = new Date();
|
||||||
Date leaseUntil = new Date(
|
Date leaseUntil = new Date(
|
||||||
claimedAt.getTime() + RECOVERY_DISPATCH_LEASE.toMillis());
|
claimedAt.getTime() + RECOVERY_DISPATCH_LEASE.toMillis());
|
||||||
|
if (batchMapper.claimRecoveryPending(
|
||||||
|
batchId, recoveryToken, leaseUntil, claimedAt) <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
DocumentImportBatch claimedBatch =
|
DocumentImportBatch claimedBatch =
|
||||||
recoveryCoordinator.claim(
|
batchMapper.selectClaimedRecovery(batchId, recoveryToken);
|
||||||
batchId, recoveryToken, leaseUntil, claimedAt);
|
|
||||||
if (claimedBatch == null) {
|
if (claimedBatch == null) {
|
||||||
LOG.info(
|
LOG.info(
|
||||||
"批次恢复调度令牌已失效,旧持有者停止恢复: "
|
"批次恢复调度令牌已失效,旧持有者停止恢复: "
|
||||||
@@ -1285,8 +1283,9 @@ public class DocumentImportBatchAppService {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!recoveryCoordinator.finalizeRecovery(
|
int finalized = batchMapper.finalizeRecoveryPending(
|
||||||
batchId, recoveryToken, new Date())) {
|
batchId, recoveryToken, new Date());
|
||||||
|
if (finalized <= 0) {
|
||||||
LOG.info(
|
LOG.info(
|
||||||
"批次恢复待办已变更,当前实例跳过收尾: "
|
"批次恢复待办已变更,当前实例跳过收尾: "
|
||||||
+ "batchId={}, recoveryToken={}",
|
+ "batchId={}, recoveryToken={}",
|
||||||
@@ -1299,8 +1298,7 @@ public class DocumentImportBatchAppService {
|
|||||||
if (!circuitBreaker.interruptRecoveryBatch(
|
if (!circuitBreaker.interruptRecoveryBatch(
|
||||||
batchId, recoveryToken, error)) {
|
batchId, recoveryToken, error)) {
|
||||||
LOG.info(
|
LOG.info(
|
||||||
"批次恢复异常发生时令牌已失效或已有并发推进,"
|
"批次恢复异常发生时令牌已失效,跳过旧持有者熔断: "
|
||||||
+ "跳过当前持有者熔断: "
|
|
||||||
+ "batchId={}, recoveryToken={}",
|
+ "batchId={}, recoveryToken={}",
|
||||||
batchId,
|
batchId,
|
||||||
recoveryToken
|
recoveryToken
|
||||||
@@ -1351,13 +1349,13 @@ public class DocumentImportBatchAppService {
|
|||||||
Date renewedLeaseUntil = new Date(
|
Date renewedLeaseUntil = new Date(
|
||||||
nowMillis + RECOVERY_DISPATCH_LEASE.toMillis()
|
nowMillis + RECOVERY_DISPATCH_LEASE.toMillis()
|
||||||
);
|
);
|
||||||
boolean renewed = recoveryCoordinator.renew(
|
int renewed = batchMapper.renewRecoveryPendingLease(
|
||||||
batchId,
|
batchId,
|
||||||
recoveryToken,
|
recoveryToken,
|
||||||
renewedLeaseUntil,
|
renewedLeaseUntil,
|
||||||
now
|
now
|
||||||
);
|
);
|
||||||
if (!renewed) {
|
if (renewed <= 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
renewAfter.set(nowMillis + renewIntervalMillis);
|
renewAfter.set(nowMillis + renewIntervalMillis);
|
||||||
@@ -1458,11 +1456,7 @@ public class DocumentImportBatchAppService {
|
|||||||
int dotIndex = fileName == null ? -1 : fileName.lastIndexOf('.');
|
int dotIndex = fileName == null ? -1 : fileName.lastIndexOf('.');
|
||||||
String extension = dotIndex < 0 ? "" : fileName.substring(dotIndex + 1).toLowerCase(Locale.ROOT);
|
String extension = dotIndex < 0 ? "" : fileName.substring(dotIndex + 1).toLowerCase(Locale.ROOT);
|
||||||
if (!SUPPORTED_EXTENSIONS.contains(extension)) {
|
if (!SUPPORTED_EXTENSIONS.contains(extension)) {
|
||||||
throw new BusinessException(
|
throw new BusinessException("暂不支持该文件格式");
|
||||||
"暂不支持该文件格式,仅支持 "
|
|
||||||
+ DocumentParseFilePolicy.supportedTypeLabel()
|
|
||||||
+ " 文件"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.transaction.annotation.Propagation;
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import tech.easyflow.ai.entity.DocumentImportBatch;
|
import tech.easyflow.ai.entity.DocumentImportBatch;
|
||||||
import tech.easyflow.ai.entity.DocumentImportBatchItem;
|
|
||||||
import tech.easyflow.ai.entity.DocumentImportTask;
|
import tech.easyflow.ai.entity.DocumentImportTask;
|
||||||
import tech.easyflow.ai.enums.DocumentImportBatchItemStatus;
|
|
||||||
import tech.easyflow.ai.enums.DocumentImportBatchStatus;
|
import tech.easyflow.ai.enums.DocumentImportBatchStatus;
|
||||||
import tech.easyflow.ai.enums.DocumentImportMode;
|
import tech.easyflow.ai.enums.DocumentImportMode;
|
||||||
import tech.easyflow.ai.enums.DocumentImportTaskStatus;
|
import tech.easyflow.ai.enums.DocumentImportTaskStatus;
|
||||||
@@ -25,8 +23,6 @@ import java.sql.SQLRecoverableException;
|
|||||||
import java.sql.SQLTimeoutException;
|
import java.sql.SQLTimeoutException;
|
||||||
import java.sql.SQLTransientConnectionException;
|
import java.sql.SQLTransientConnectionException;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.List;
|
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.concurrent.RejectedExecutionException;
|
import java.util.concurrent.RejectedExecutionException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -139,18 +135,13 @@ public class DocumentImportBatchCircuitBreaker {
|
|||||||
if (recoveryToken == null || recoveryToken.isBlank()) {
|
if (recoveryToken == null || recoveryToken.isBlank()) {
|
||||||
throw new IllegalArgumentException("恢复调度令牌不能为空");
|
throw new IllegalArgumentException("恢复调度令牌不能为空");
|
||||||
}
|
}
|
||||||
DocumentImportRecoveryException recoveryError =
|
|
||||||
findCause(error, DocumentImportRecoveryException.class);
|
|
||||||
return interruptBatch(
|
return interruptBatch(
|
||||||
batchId,
|
batchId,
|
||||||
resolveReason(error),
|
resolveReason(error),
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
error,
|
error,
|
||||||
recoveryToken,
|
recoveryToken
|
||||||
recoveryError == null
|
|
||||||
? List.of()
|
|
||||||
: recoveryError.getFailureFenceItemIds()
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,7 +211,7 @@ public class DocumentImportBatchCircuitBreaker {
|
|||||||
String phase,
|
String phase,
|
||||||
Throwable error) {
|
Throwable error) {
|
||||||
return interruptBatch(
|
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 phase 触发任务阶段,可为空
|
||||||
* @param error 原始异常
|
* @param error 原始异常
|
||||||
* @param recoveryToken 恢复调度令牌,可为空
|
* @param recoveryToken 恢复调度令牌,可为空
|
||||||
* @param recoveryFailureItemIds 零重排时的初始失败项围栏
|
|
||||||
* @return 批次是否已经停止运行;围栏失效时返回 {@code false}
|
* @return 批次是否已经停止运行;围栏失效时返回 {@code false}
|
||||||
*/
|
*/
|
||||||
private boolean interruptBatch(BigInteger batchId,
|
private boolean interruptBatch(BigInteger batchId,
|
||||||
@@ -240,17 +230,11 @@ public class DocumentImportBatchCircuitBreaker {
|
|||||||
BigInteger taskId,
|
BigInteger taskId,
|
||||||
String phase,
|
String phase,
|
||||||
Throwable error,
|
Throwable error,
|
||||||
String recoveryToken,
|
String recoveryToken) {
|
||||||
List<BigInteger> recoveryFailureItemIds) {
|
|
||||||
if (batchId == null) {
|
if (batchId == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
boolean fencedRecoveryFailure = recoveryToken != null
|
DocumentImportBatch batch = batchMapper.selectOneById(batchId);
|
||||||
&& recoveryFailureItemIds != null
|
|
||||||
&& !recoveryFailureItemIds.isEmpty();
|
|
||||||
// 批次行是后续 task/item 状态收口的根锁;所有熔断路径先锁定
|
|
||||||
// batch,避免多表围栏更新与任务完成事务形成 task -> batch 逆序。
|
|
||||||
DocumentImportBatch batch = batchMapper.selectForUpdate(batchId);
|
|
||||||
if (batch == null) {
|
if (batch == null) {
|
||||||
LOG.warn(
|
LOG.warn(
|
||||||
"忽略无法关联批次的自动导入熔断请求: taskId={}, batchId={}",
|
"忽略无法关联批次的自动导入熔断请求: taskId={}, batchId={}",
|
||||||
@@ -265,39 +249,6 @@ public class DocumentImportBatchCircuitBreaker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Date now = new Date();
|
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;
|
int interrupted;
|
||||||
if (recoveryToken != null) {
|
if (recoveryToken != null) {
|
||||||
interrupted = batchMapper.interruptOwnedRecoveryBatch(
|
interrupted = batchMapper.interruptOwnedRecoveryBatch(
|
||||||
@@ -354,14 +305,6 @@ public class DocumentImportBatchCircuitBreaker {
|
|||||||
* @return 稳定错误码与用户可见摘要
|
* @return 稳定错误码与用户可见摘要
|
||||||
*/
|
*/
|
||||||
private InterruptionReason resolveReason(Throwable error) {
|
private InterruptionReason resolveReason(Throwable error) {
|
||||||
DocumentImportRecoveryException recoveryError =
|
|
||||||
findCause(error, DocumentImportRecoveryException.class);
|
|
||||||
if (recoveryError != null) {
|
|
||||||
return new InterruptionReason(
|
|
||||||
recoveryError.getCode(),
|
|
||||||
recoveryError.getUserMessage()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (containsRedisFailure(error)) {
|
if (containsRedisFailure(error)) {
|
||||||
return new InterruptionReason(
|
return new InterruptionReason(
|
||||||
REDIS_UNAVAILABLE,
|
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。
|
* 判断异常链是否来自 Redis/Lettuce。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -27,18 +27,6 @@ import java.util.Date;
|
|||||||
@Service
|
@Service
|
||||||
public class DocumentImportBatchTracker {
|
public class DocumentImportBatchTracker {
|
||||||
|
|
||||||
/**
|
|
||||||
* 单文件恢复失败原因的事务内处理结果。
|
|
||||||
*/
|
|
||||||
public enum FailedRetryErrorOutcome {
|
|
||||||
/** 失败原因已写回,文件项仍等待后续人工恢复。 */
|
|
||||||
ERROR_RECORDED,
|
|
||||||
/** 等价并发操作已把文件项推进出失败状态。 */
|
|
||||||
ALREADY_ADVANCED,
|
|
||||||
/** 批次或文件项已不再允许当前恢复请求写回。 */
|
|
||||||
REJECTED
|
|
||||||
}
|
|
||||||
|
|
||||||
private final DocumentImportBatchService batchService;
|
private final DocumentImportBatchService batchService;
|
||||||
private final DocumentImportBatchItemService itemService;
|
private final DocumentImportBatchItemService itemService;
|
||||||
private final DocumentImportBatchMapper batchMapper;
|
private final DocumentImportBatchMapper batchMapper;
|
||||||
@@ -76,31 +64,6 @@ public class DocumentImportBatchTracker {
|
|||||||
return batch;
|
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) {
|
if (itemId == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
DocumentImportBatchItem current = requireItem(itemId);
|
||||||
int attemptDelta = (status == DocumentImportBatchItemStatus.PENDING
|
int attemptDelta = (status == DocumentImportBatchItemStatus.PENDING
|
||||||
|| status == DocumentImportBatchItemStatus.RUNNING)
|
|| status == DocumentImportBatchItemStatus.RUNNING)
|
||||||
|
&& DocumentImportBatchItemStatus.FAILED.name().equals(current.getStatus())
|
||||||
? 1
|
? 1
|
||||||
: 0;
|
: 0;
|
||||||
return transitionItem(itemId, stage, status, errorSummary,
|
return transitionItem(itemId, stage, status, errorSummary,
|
||||||
@@ -197,34 +162,28 @@ public class DocumentImportBatchTracker {
|
|||||||
if (itemId == null) {
|
if (itemId == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
LockedBatchItem locked = lockBatchThenItem(itemId);
|
for (int attempt = 0; attempt < 3; attempt++) {
|
||||||
if (DocumentImportBatchStatus.INTERRUPTED.name()
|
DocumentImportBatchItem current = requireItem(itemId);
|
||||||
.equals(locked.batch.getStatus())) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
DocumentImportBatchItem current = locked.item;
|
|
||||||
DocumentImportBatchItemStatus currentStatus =
|
DocumentImportBatchItemStatus currentStatus =
|
||||||
DocumentImportBatchItemStatus.valueOf(current.getStatus());
|
DocumentImportBatchItemStatus.valueOf(current.getStatus());
|
||||||
if (!isAllowedTransition(currentStatus, status)) {
|
if (!isAllowedTransition(currentStatus, status)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
String expectedStatus = current.getStatus();
|
||||||
Date now = new Date();
|
Date now = new Date();
|
||||||
int effectiveAttemptDelta = currentStatus == DocumentImportBatchItemStatus.FAILED
|
|
||||||
? Math.max(0, attemptDelta)
|
|
||||||
: 0;
|
|
||||||
int updated = itemMapper.transitionStatus(
|
int updated = itemMapper.transitionStatus(
|
||||||
itemId,
|
itemId,
|
||||||
current.getStatus(),
|
expectedStatus,
|
||||||
stage.name(),
|
stage.name(),
|
||||||
status.name(),
|
status.name(),
|
||||||
errorSummary,
|
errorSummary,
|
||||||
failureCode,
|
failureCode,
|
||||||
retryable,
|
retryable,
|
||||||
effectiveAttemptDelta,
|
Math.max(0, attemptDelta),
|
||||||
now
|
now
|
||||||
);
|
);
|
||||||
if (updated <= 0) {
|
if (updated <= 0) {
|
||||||
return false;
|
continue;
|
||||||
}
|
}
|
||||||
CounterDelta delta = CounterDelta.between(current, status, retryable);
|
CounterDelta delta = CounterDelta.between(current, status, retryable);
|
||||||
if (!delta.isZero()) {
|
if (!delta.isZero()) {
|
||||||
@@ -244,6 +203,8 @@ public class DocumentImportBatchTracker {
|
|||||||
refreshBatch(current.getBatchId());
|
refreshBatch(current.getBatchId());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 校验文件项状态机,拒绝迟到任务覆盖终态。
|
* 校验文件项状态机,拒绝迟到任务覆盖终态。
|
||||||
@@ -282,12 +243,7 @@ public class DocumentImportBatchTracker {
|
|||||||
*/
|
*/
|
||||||
@org.springframework.transaction.annotation.Transactional
|
@org.springframework.transaction.annotation.Transactional
|
||||||
public void bindDocument(BigInteger itemId, BigInteger documentId) {
|
public void bindDocument(BigInteger itemId, BigInteger documentId) {
|
||||||
LockedBatchItem locked = lockBatchThenItem(itemId);
|
DocumentImportBatchItem item = requireItem(itemId);
|
||||||
if (DocumentImportBatchStatus.INTERRUPTED.name()
|
|
||||||
.equals(locked.batch.getStatus())) {
|
|
||||||
throw new BusinessException("导入批次已中断,请继续批次后重试");
|
|
||||||
}
|
|
||||||
DocumentImportBatchItem item = locked.item;
|
|
||||||
if (documentId.equals(item.getDocumentId())
|
if (documentId.equals(item.getDocumentId())
|
||||||
&& DocumentImportBatchItemStatus.PENDING.name().equals(item.getStatus())) {
|
&& DocumentImportBatchItemStatus.PENDING.name().equals(item.getStatus())) {
|
||||||
return;
|
return;
|
||||||
@@ -296,11 +252,6 @@ public class DocumentImportBatchTracker {
|
|||||||
boolean recoveringFailedItem =
|
boolean recoveringFailedItem =
|
||||||
DocumentImportBatchItemStatus.FAILED.name().equals(item.getStatus())
|
DocumentImportBatchItemStatus.FAILED.name().equals(item.getStatus())
|
||||||
&& item.getDocumentId() == null;
|
&& item.getDocumentId() == null;
|
||||||
if (recoveringFailedItem
|
|
||||||
&& !DocumentImportBatchStatus.RUNNING.name()
|
|
||||||
.equals(locked.batch.getStatus())) {
|
|
||||||
throw new BusinessException("导入批次状态已变化,请刷新后重试");
|
|
||||||
}
|
|
||||||
int updated = recoveringFailedItem
|
int updated = recoveringFailedItem
|
||||||
? itemMapper.bindFailedDocument(itemId, documentId, now)
|
? itemMapper.bindFailedDocument(itemId, documentId, now)
|
||||||
: itemMapper.bindDocument(itemId, documentId, now);
|
: itemMapper.bindDocument(itemId, documentId, now);
|
||||||
@@ -317,91 +268,6 @@ public class DocumentImportBatchTracker {
|
|||||||
refreshBatch(item.getBatchId());
|
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,
|
public boolean completeUpload(BigInteger itemId,
|
||||||
String filePath,
|
String filePath,
|
||||||
String storageLocator) {
|
String storageLocator) {
|
||||||
LockedBatchItem locked = lockBatchThenItem(itemId);
|
DocumentImportBatchItem item = requireItem(itemId);
|
||||||
if (DocumentImportBatchStatus.INTERRUPTED.name()
|
|
||||||
.equals(locked.batch.getStatus())) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
DocumentImportBatchItem item = locked.item;
|
|
||||||
if (DocumentImportBatchItemStatus.UPLOADED.name().equals(item.getStatus())) {
|
if (DocumentImportBatchItemStatus.UPLOADED.name().equals(item.getStatus())) {
|
||||||
return filePath.equals(item.getFilePath())
|
return filePath.equals(item.getFilePath())
|
||||||
&& storageLocator.equals(item.getStorageLocator());
|
&& storageLocator.equals(item.getStorageLocator());
|
||||||
@@ -443,34 +304,6 @@ public class DocumentImportBatchTracker {
|
|||||||
return true;
|
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
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 记录文件项成功后需要清理的历史文档。
|
* 记录文件项成功后需要清理的历史文档。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,6 @@ import org.slf4j.LoggerFactory;
|
|||||||
import org.springframework.dao.DuplicateKeyException;
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import tech.easyflow.ai.document.support.DocumentParseFilePolicy;
|
|
||||||
import tech.easyflow.ai.documentimport.DocumentImportBatchCreateContext;
|
import tech.easyflow.ai.documentimport.DocumentImportBatchCreateContext;
|
||||||
import tech.easyflow.ai.documentimport.DocumentImportBatchDtos;
|
import tech.easyflow.ai.documentimport.DocumentImportBatchDtos;
|
||||||
import tech.easyflow.ai.documentimport.DocumentImportBatchRetryResult;
|
import tech.easyflow.ai.documentimport.DocumentImportBatchRetryResult;
|
||||||
@@ -61,7 +60,7 @@ public class KnowledgeImportBatchFacade {
|
|||||||
private static final Duration INCOMPLETE_SUBMISSION_TIMEOUT =
|
private static final Duration INCOMPLETE_SUBMISSION_TIMEOUT =
|
||||||
Duration.ofMinutes(30);
|
Duration.ofMinutes(30);
|
||||||
private static final Set<String> SUPPORTED_EXTENSIONS =
|
private static final Set<String> SUPPORTED_EXTENSIONS =
|
||||||
DocumentParseFilePolicy.supportedExtensions();
|
DocumentImportFormatPolicy.supportedExtensions();
|
||||||
|
|
||||||
private final DocumentImportBatchAppService batchAppService;
|
private final DocumentImportBatchAppService batchAppService;
|
||||||
private final DocumentImportBatchTracker batchTracker;
|
private final DocumentImportBatchTracker batchTracker;
|
||||||
@@ -890,13 +889,7 @@ public class KnowledgeImportBatchFacade {
|
|||||||
*/
|
*/
|
||||||
private void assertSupportedExtension(String fileName) {
|
private void assertSupportedExtension(String fileName) {
|
||||||
if (!SUPPORTED_EXTENSIONS.contains(extension(fileName))) {
|
if (!SUPPORTED_EXTENSIONS.contains(extension(fileName))) {
|
||||||
throw new BusinessException(
|
throw new BusinessException(415, 41502, "暂不支持该文件格式");
|
||||||
415,
|
|
||||||
41502,
|
|
||||||
"暂不支持该文件格式,仅支持 "
|
|
||||||
+ DocumentParseFilePolicy.supportedTypeLabel()
|
|
||||||
+ " 文件"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 {
|
|
||||||
}
|
|
||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import com.alibaba.fastjson2.JSONObject;
|
|||||||
import com.easyagents.flow.core.chain.Chain;
|
import com.easyagents.flow.core.chain.Chain;
|
||||||
import com.easyagents.flow.core.knowledge.Knowledge;
|
import com.easyagents.flow.core.knowledge.Knowledge;
|
||||||
import com.easyagents.flow.core.knowledge.KnowledgeProvider;
|
import com.easyagents.flow.core.knowledge.KnowledgeProvider;
|
||||||
import com.easyagents.flow.core.knowledge.KnowledgeSearchRequest;
|
|
||||||
import com.easyagents.flow.core.node.KnowledgeNode;
|
import com.easyagents.flow.core.node.KnowledgeNode;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import tech.easyflow.ai.rag.KnowledgeRetrievalRequest;
|
import tech.easyflow.ai.rag.KnowledgeRetrievalRequest;
|
||||||
@@ -15,7 +14,6 @@ import tech.easyflow.ai.service.DocumentCollectionService;
|
|||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -28,9 +26,6 @@ public class KnowledgeProviderImpl implements KnowledgeProvider {
|
|||||||
@Resource
|
@Resource
|
||||||
private DocumentCollectionService documentCollectionService;
|
private DocumentCollectionService documentCollectionService;
|
||||||
|
|
||||||
@Resource
|
|
||||||
private WorkflowMultiKnowledgeRetrievalService multiKnowledgeRetrievalService;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取知识库检索器。
|
* 获取知识库检索器。
|
||||||
*
|
*
|
||||||
@@ -49,86 +44,24 @@ public class KnowledgeProviderImpl implements KnowledgeProvider {
|
|||||||
int limit,
|
int limit,
|
||||||
KnowledgeNode knowledgeNode,
|
KnowledgeNode knowledgeNode,
|
||||||
Chain chain) {
|
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();
|
KnowledgeRetrievalRequest request = new KnowledgeRetrievalRequest();
|
||||||
request.setKnowledgeId(knowledgeId);
|
request.setKnowledgeId(new BigInteger(id.toString()));
|
||||||
request.setQuery(keyword);
|
request.setQuery(keyword);
|
||||||
request.setLimit(limit);
|
request.setLimit(limit);
|
||||||
request.setRetrievalMode(KnowledgeRetrievalModes.parse(
|
request.setRetrievalMode(KnowledgeRetrievalModes.parse(knowledgeNode.getRetrievalMode()));
|
||||||
knowledgeNode == null
|
|
||||||
? null
|
|
||||||
: knowledgeNode.getRetrievalMode()));
|
|
||||||
request.setCallerType("WORKFLOW");
|
request.setCallerType("WORKFLOW");
|
||||||
request.setCallerId(knowledgeNode == null ? null : knowledgeNode.getId());
|
request.setCallerId(knowledgeNode.getId());
|
||||||
List<Document> documents = documentCollectionService.search(request);
|
List<Document> documents = documentCollectionService.search(request);
|
||||||
if (limit > 0 && documents.size() > limit) {
|
if (limit > 0 && documents.size() > limit) {
|
||||||
documents = new ArrayList<>(documents.subList(0, 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) {
|
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(
|
private Map<String, Object> toWorkflowDocument(
|
||||||
Document document, Object knowledgeId) {
|
Document document, Object knowledgeId) {
|
||||||
JSONObject result = JSONObject.from(document);
|
JSONObject result = JSONObject.from(document);
|
||||||
result.put("title", resolveWorkflowTitle(document));
|
result.put("title", document.getTitle());
|
||||||
result.put("content", document.getContent());
|
result.put("content", document.getContent());
|
||||||
result.put(
|
result.put(
|
||||||
"documentId",
|
"documentId",
|
||||||
@@ -149,30 +82,4 @@ public class KnowledgeProviderImpl implements KnowledgeProvider {
|
|||||||
result.put("knowledgeId", knowledgeId);
|
result.put("knowledgeId", knowledgeId);
|
||||||
return result;
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -85,7 +85,7 @@ public class ChainEventListenerForSave implements ChainEventListener {
|
|||||||
record.setStartTime(new Date());
|
record.setStartTime(new Date());
|
||||||
record.setStatus(state.getStatus().getValue());
|
record.setStatus(state.getStatus().getValue());
|
||||||
record.setCreatedKey(WorkFlowUtil.getCreatedKey(chain));
|
record.setCreatedKey(WorkFlowUtil.getCreatedKey(chain));
|
||||||
record.setCreatedBy(WorkFlowUtil.getCreatedBy(chain));
|
record.setCreatedBy(WorkFlowUtil.getOperator(chain).getId().toString());
|
||||||
// 启动记录保留同步确认,避免执行接口返回后立即查询时记录尚不可见。
|
// 启动记录保留同步确认,避免执行接口返回后立即查询时记录尚不可见。
|
||||||
try {
|
try {
|
||||||
workflowExecResultService.save(record);
|
workflowExecResultService.save(record);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package tech.easyflow.ai.easyagentsflow.repository;
|
package tech.easyflow.ai.easyagentsflow.repository;
|
||||||
|
|
||||||
import com.alibaba.fastjson2.JSON;
|
|
||||||
import com.easyagents.flow.core.chain.ChainDefinition;
|
import com.easyagents.flow.core.chain.ChainDefinition;
|
||||||
import com.easyagents.flow.core.node.ConfirmNode;
|
import com.easyagents.flow.core.node.ConfirmNode;
|
||||||
import com.easyagents.flow.core.parser.ChainParser;
|
import com.easyagents.flow.core.parser.ChainParser;
|
||||||
@@ -85,35 +84,9 @@ public class AgentWorkflowSnapshotFactory {
|
|||||||
snapshot.put("englishName", workflow.getEnglishName());
|
snapshot.put("englishName", workflow.getEnglishName());
|
||||||
snapshot.put("revision", workflow.getRevision());
|
snapshot.put("revision", workflow.getRevision());
|
||||||
snapshot.put("content", prepared.content());
|
snapshot.put("content", prepared.content());
|
||||||
snapshot.put("tenantId", workflow.getTenantId());
|
|
||||||
snapshot.put(
|
|
||||||
"publishedSnapshotJson",
|
|
||||||
knowledgeRuntimeSnapshot(workflow));
|
|
||||||
return snapshot;
|
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 的准备结果。
|
* Agent 可执行 Workflow 的准备结果。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -2,11 +2,8 @@ package tech.easyflow.ai.easyagentsflow.repository;
|
|||||||
|
|
||||||
import com.easyagents.flow.core.chain.ChainDefinition;
|
import com.easyagents.flow.core.chain.ChainDefinition;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import tech.easyflow.ai.easyagentsflow.knowledge.WorkflowKnowledgeContractService;
|
|
||||||
import tech.easyflow.ai.entity.Workflow;
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
|
||||||
|
|
||||||
import java.math.BigInteger;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
@@ -27,7 +24,6 @@ public class FrozenWorkflowDefinitionRegistry {
|
|||||||
private static final int MAX_ENTRIES = 512;
|
private static final int MAX_ENTRIES = 512;
|
||||||
|
|
||||||
private final AgentWorkflowSnapshotFactory snapshotFactory;
|
private final AgentWorkflowSnapshotFactory snapshotFactory;
|
||||||
private final WorkflowKnowledgeContractService workflowKnowledgeContractService;
|
|
||||||
private final Map<String, ChainDefinition> definitions =
|
private final Map<String, ChainDefinition> definitions =
|
||||||
new LinkedHashMap<>(32, 0.75F, true);
|
new LinkedHashMap<>(32, 0.75F, true);
|
||||||
private final Map<String, Workflow> workflows =
|
private final Map<String, Workflow> workflows =
|
||||||
@@ -37,13 +33,9 @@ public class FrozenWorkflowDefinitionRegistry {
|
|||||||
* 创建冻结定义注册表。
|
* 创建冻结定义注册表。
|
||||||
*
|
*
|
||||||
* @param snapshotFactory Agent Workflow 冻结快照工厂
|
* @param snapshotFactory Agent Workflow 冻结快照工厂
|
||||||
* @param workflowKnowledgeContractService 知识库契约服务
|
|
||||||
*/
|
*/
|
||||||
public FrozenWorkflowDefinitionRegistry(
|
public FrozenWorkflowDefinitionRegistry(AgentWorkflowSnapshotFactory snapshotFactory) {
|
||||||
AgentWorkflowSnapshotFactory snapshotFactory,
|
|
||||||
WorkflowKnowledgeContractService workflowKnowledgeContractService) {
|
|
||||||
this.snapshotFactory = snapshotFactory;
|
this.snapshotFactory = snapshotFactory;
|
||||||
this.workflowKnowledgeContractService = workflowKnowledgeContractService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,19 +48,7 @@ public class FrozenWorkflowDefinitionRegistry {
|
|||||||
public String register(Workflow workflow) {
|
public String register(Workflow workflow) {
|
||||||
AgentWorkflowSnapshotFactory.PreparedWorkflow prepared = snapshotFactory.prepare(workflow);
|
AgentWorkflowSnapshotFactory.PreparedWorkflow prepared = snapshotFactory.prepare(workflow);
|
||||||
String preparedContent = prepared.content();
|
String preparedContent = prepared.content();
|
||||||
Map<String, Object> runtimeSnapshot =
|
String id = PREFIX + workflow.getId() + ":" + sha256(preparedContent);
|
||||||
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);
|
|
||||||
synchronized (definitions) {
|
synchronized (definitions) {
|
||||||
if (definitions.containsKey(id)) {
|
if (definitions.containsKey(id)) {
|
||||||
definitions.get(id);
|
definitions.get(id);
|
||||||
@@ -79,7 +59,7 @@ public class FrozenWorkflowDefinitionRegistry {
|
|||||||
definition.setName(workflow.getEnglishName());
|
definition.setName(workflow.getEnglishName());
|
||||||
definition.setDescription(workflow.getDescription());
|
definition.setDescription(workflow.getDescription());
|
||||||
definitions.put(id, definition);
|
definitions.put(id, definition);
|
||||||
workflows.put(id, frozenWorkflow(workflow, runtimeSnapshot));
|
workflows.put(id, workflow);
|
||||||
while (definitions.size() > MAX_ENTRIES) {
|
while (definitions.size() > MAX_ENTRIES) {
|
||||||
String eldest = definitions.keySet().iterator().next();
|
String eldest = definitions.keySet().iterator().next();
|
||||||
definitions.remove(eldest);
|
definitions.remove(eldest);
|
||||||
@@ -120,66 +100,9 @@ public class FrozenWorkflowDefinitionRegistry {
|
|||||||
* @return 是否冻结定义
|
* @return 是否冻结定义
|
||||||
*/
|
*/
|
||||||
public boolean isFrozen(String definitionId) {
|
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);
|
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) {
|
private String sha256(String content) {
|
||||||
try {
|
try {
|
||||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||||
@@ -188,17 +111,4 @@ public class FrozenWorkflowDefinitionRegistry {
|
|||||||
throw new IllegalStateException("SHA-256 is unavailable", exception);
|
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) {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package tech.easyflow.ai.easyagentsflow.service;
|
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.ChainState;
|
||||||
import com.easyagents.flow.core.chain.ExceptionSummary;
|
import com.easyagents.flow.core.chain.ExceptionSummary;
|
||||||
import com.easyagents.flow.core.chain.NodeState;
|
import com.easyagents.flow.core.chain.NodeState;
|
||||||
@@ -142,12 +141,9 @@ public class TinyFlowService {
|
|||||||
node.setResult(resolved);
|
node.setResult(resolved);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nodeState != null
|
// 只有当参数不为空时才覆盖
|
||||||
&& nodeState.getStatus() == NodeStatus.SUSPEND
|
if (chainState.getSuspendForParameters() != null) {
|
||||||
&& chainState.getSuspendForParameters() != null) {
|
|
||||||
node.setSuspendForParameters(chainState.getSuspendForParameters());
|
node.setSuspendForParameters(chainState.getSuspendForParameters());
|
||||||
} else {
|
|
||||||
node.setSuspendForParameters(null);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,10 +165,6 @@ public class TinyFlowService {
|
|||||||
String rootMessage = StringUtil.hasText(error.getRootCauseMessage())
|
String rootMessage = StringUtil.hasText(error.getRootCauseMessage())
|
||||||
? error.getRootCauseMessage()
|
? error.getRootCauseMessage()
|
||||||
: error.getMessage();
|
: error.getMessage();
|
||||||
if (DocumentParseException.class.getName().equals(rootClass)
|
|
||||||
&& StringUtil.hasText(rootMessage)) {
|
|
||||||
return rootMessage;
|
|
||||||
}
|
|
||||||
if (StringUtil.noText(rootClass)) {
|
if (StringUtil.noText(rootClass)) {
|
||||||
return rootMessage;
|
return rootMessage;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,9 @@ package tech.easyflow.ai.easyagentsflow.service;
|
|||||||
import com.alibaba.fastjson2.JSON;
|
import com.alibaba.fastjson2.JSON;
|
||||||
import com.alibaba.fastjson2.JSONArray;
|
import com.alibaba.fastjson2.JSONArray;
|
||||||
import com.alibaba.fastjson2.JSONObject;
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
import com.easyagents.flow.core.chain.DataType;
|
|
||||||
import com.easyagents.flow.core.node.ConfirmNode;
|
|
||||||
import com.easyagents.flow.core.parser.ChainParser;
|
import com.easyagents.flow.core.parser.ChainParser;
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
import tech.easyflow.ai.config.MultiKnowledgeRetrievalProperties;
|
|
||||||
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckIssue;
|
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckIssue;
|
||||||
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckResult;
|
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckResult;
|
||||||
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
|
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
|
||||||
@@ -45,7 +40,6 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.regex.Pattern;
|
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,31 +47,18 @@ import java.util.stream.Collectors;
|
|||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class WorkflowCheckService {
|
public class WorkflowCheckService {
|
||||||
private static final Logger LOGGER = LoggerFactory.getLogger(
|
|
||||||
WorkflowCheckService.class);
|
|
||||||
private static final long SLOW_CHECK_THRESHOLD_MS = 500L;
|
|
||||||
private static final String LEVEL_ERROR = "ERROR";
|
private static final String LEVEL_ERROR = "ERROR";
|
||||||
private static final String LEVEL_WARNING = "WARNING";
|
private static final String LEVEL_WARNING = "WARNING";
|
||||||
private static final String TYPE_START = "startNode";
|
private static final String TYPE_START = "startNode";
|
||||||
private static final String TYPE_END = "endNode";
|
private static final String TYPE_END = "endNode";
|
||||||
private static final String TYPE_LOOP = "loopNode";
|
private static final String TYPE_LOOP = "loopNode";
|
||||||
private static final String TYPE_CONDITION = "conditionNode";
|
private static final String TYPE_CONDITION = "conditionNode";
|
||||||
private static final String TYPE_CONFIRM = "confirmNode";
|
|
||||||
private static final String TYPE_KNOWLEDGE = "knowledgeNode";
|
|
||||||
private static final Set<String> CONFIRM_ARRAY_LEFT_OPERATORS = Set.of(
|
|
||||||
"contains", "notContains", "isEmpty", "isNotEmpty");
|
|
||||||
private static final String TYPE_WORKFLOW = "workflow-node";
|
private static final String TYPE_WORKFLOW = "workflow-node";
|
||||||
private static final String TYPE_PLUGIN = "plugin-node";
|
private static final String TYPE_PLUGIN = "plugin-node";
|
||||||
private static final String TYPE_MAKE_FILE = "make-file";
|
private static final String TYPE_MAKE_FILE = "make-file";
|
||||||
private static final String SYSTEM_START_PARAM_NAME = "user_input";
|
private static final String SYSTEM_START_PARAM_NAME = "user_input";
|
||||||
private static final int MIN_LOOP_COUNT = 1;
|
private static final int MIN_LOOP_COUNT = 1;
|
||||||
private static final int MAX_LOOP_COUNT = 300;
|
private static final int MAX_LOOP_COUNT = 300;
|
||||||
private static final int DEFAULT_MAX_KNOWLEDGE_SOURCES = 8;
|
|
||||||
private static final int DEFAULT_MAX_MULTI_KNOWLEDGE_LIMIT = 200;
|
|
||||||
private static final Pattern COMPLETE_VARIABLE_REFERENCE =
|
|
||||||
Pattern.compile("^\\{\\{\\s*[^\\s{}][^{}]*?\\s*}}$");
|
|
||||||
private static final String JOIN_MODE_ANY = "any";
|
|
||||||
private static final String JOIN_MODE_ALL = "all";
|
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private WorkflowService workflowService;
|
private WorkflowService workflowService;
|
||||||
@@ -91,8 +72,6 @@ public class WorkflowCheckService {
|
|||||||
private PluginItemService pluginItemService;
|
private PluginItemService pluginItemService;
|
||||||
@Resource
|
@Resource
|
||||||
private WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver;
|
private WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver;
|
||||||
@Resource
|
|
||||||
private MultiKnowledgeRetrievalProperties multiKnowledgeRetrievalProperties;
|
|
||||||
|
|
||||||
public WorkflowCheckResult checkWorkflow(BigInteger workflowId, WorkflowCheckStage stage) {
|
public WorkflowCheckResult checkWorkflow(BigInteger workflowId, WorkflowCheckStage stage) {
|
||||||
if (workflowId == null) {
|
if (workflowId == null) {
|
||||||
@@ -109,11 +88,9 @@ public class WorkflowCheckService {
|
|||||||
if (stage == null) {
|
if (stage == null) {
|
||||||
throw new BusinessException("校验阶段不能为空");
|
throw new BusinessException("校验阶段不能为空");
|
||||||
}
|
}
|
||||||
long checkStartedAt = System.nanoTime();
|
|
||||||
List<WorkflowCheckIssue> issues = new ArrayList<>();
|
List<WorkflowCheckIssue> issues = new ArrayList<>();
|
||||||
Set<String> issueKeys = new LinkedHashSet<>();
|
Set<String> issueKeys = new LinkedHashSet<>();
|
||||||
ParsedWorkflow parsedWorkflow = parseAndCheckBase(content, issues, issueKeys);
|
ParsedWorkflow parsedWorkflow = parseAndCheckBase(content, issues, issueKeys);
|
||||||
long baseFinishedAt = System.nanoTime();
|
|
||||||
if (parsedWorkflow != null) {
|
if (parsedWorkflow != null) {
|
||||||
List<NodeView> startNodes = parsedWorkflow.nodes.stream()
|
List<NodeView> startNodes = parsedWorkflow.nodes.stream()
|
||||||
.filter(node -> TYPE_START.equals(node.type))
|
.filter(node -> TYPE_START.equals(node.type))
|
||||||
@@ -121,22 +98,11 @@ public class WorkflowCheckService {
|
|||||||
checkStartFormSchema(startNodes, issues, issueKeys);
|
checkStartFormSchema(startNodes, issues, issueKeys);
|
||||||
checkPluginSchemaHashes(parsedWorkflow, issues, issueKeys);
|
checkPluginSchemaHashes(parsedWorkflow, issues, issueKeys);
|
||||||
}
|
}
|
||||||
long schemaFinishedAt = System.nanoTime();
|
|
||||||
|
|
||||||
if (stage == WorkflowCheckStage.PRE_EXECUTE && parsedWorkflow != null) {
|
if (stage == WorkflowCheckStage.PRE_EXECUTE && parsedWorkflow != null) {
|
||||||
runStrictChecks(content, parsedWorkflow, currentWorkflowId, issues, issueKeys);
|
runStrictChecks(content, parsedWorkflow, currentWorkflowId, issues, issueKeys);
|
||||||
}
|
}
|
||||||
WorkflowCheckResult result = buildResult(stage, issues);
|
return buildResult(stage, issues);
|
||||||
logSlowCheck(
|
|
||||||
stage,
|
|
||||||
currentWorkflowId,
|
|
||||||
parsedWorkflow,
|
|
||||||
issues.size(),
|
|
||||||
checkStartedAt,
|
|
||||||
baseFinishedAt,
|
|
||||||
schemaFinishedAt,
|
|
||||||
System.nanoTime());
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void checkOrThrow(String content, WorkflowCheckStage stage, BigInteger currentWorkflowId) {
|
public void checkOrThrow(String content, WorkflowCheckStage stage, BigInteger currentWorkflowId) {
|
||||||
@@ -201,21 +167,6 @@ public class WorkflowCheckService {
|
|||||||
if (!StringUtils.hasText(node.type) || !parserMap.containsKey(node.type)) {
|
if (!StringUtils.hasText(node.type) || !parserMap.containsKey(node.type)) {
|
||||||
addIssue(issues, issueKeys, "NODE_TYPE_UNKNOWN", "节点类型无法识别: " + safe(node.type), node.id, null, node.name);
|
addIssue(issues, issueKeys, "NODE_TYPE_UNKNOWN", "节点类型无法识别: " + safe(node.type), node.id, null, node.name);
|
||||||
}
|
}
|
||||||
String dataType = node.data == null
|
|
||||||
? null
|
|
||||||
: trimToNull(node.data.getString("type"));
|
|
||||||
if (StringUtils.hasText(node.type)
|
|
||||||
&& StringUtils.hasText(dataType)
|
|
||||||
&& !Objects.equals(node.type, dataType)) {
|
|
||||||
addIssue(
|
|
||||||
issues,
|
|
||||||
issueKeys,
|
|
||||||
"NODE_TYPE_MISMATCH",
|
|
||||||
"节点类型与节点数据类型不一致",
|
|
||||||
node.id,
|
|
||||||
null,
|
|
||||||
node.name);
|
|
||||||
}
|
|
||||||
if (StringUtils.hasText(node.parentId) && node.parentId.equals(node.id)) {
|
if (StringUtils.hasText(node.parentId) && node.parentId.equals(node.id)) {
|
||||||
addIssue(issues, issueKeys, "NODE_PARENT_SELF", "节点不能引用自己作为父节点", node.id, null, node.name);
|
addIssue(issues, issueKeys, "NODE_PARENT_SELF", "节点不能引用自己作为父节点", node.id, null, node.name);
|
||||||
}
|
}
|
||||||
@@ -231,9 +182,6 @@ public class WorkflowCheckService {
|
|||||||
}
|
}
|
||||||
checkLoopConfigurations(nodes, nodeMap, issues, issueKeys);
|
checkLoopConfigurations(nodes, nodeMap, issues, issueKeys);
|
||||||
checkConditionConfigurations(nodes, issues, issueKeys);
|
checkConditionConfigurations(nodes, issues, issueKeys);
|
||||||
checkKnowledgeConfigurations(nodes, issues, issueKeys);
|
|
||||||
checkConfirmConfigurations(nodes, issues, issueKeys);
|
|
||||||
checkConfirmOutputReferences(nodes, issues, issueKeys);
|
|
||||||
|
|
||||||
List<EdgeView> edges = new ArrayList<>();
|
List<EdgeView> edges = new ArrayList<>();
|
||||||
Set<String> edgeIds = new HashSet<>();
|
Set<String> edgeIds = new HashSet<>();
|
||||||
@@ -248,10 +196,6 @@ public class WorkflowCheckService {
|
|||||||
edge.id = trimToNull(edgeJson.getString("id"));
|
edge.id = trimToNull(edgeJson.getString("id"));
|
||||||
edge.source = trimToNull(edgeJson.getString("source"));
|
edge.source = trimToNull(edgeJson.getString("source"));
|
||||||
edge.target = trimToNull(edgeJson.getString("target"));
|
edge.target = trimToNull(edgeJson.getString("target"));
|
||||||
JSONObject edgeData = edgeJson.getJSONObject("data");
|
|
||||||
edge.condition = edgeData == null
|
|
||||||
? null
|
|
||||||
: trimToNull(edgeData.getString("condition"));
|
|
||||||
|
|
||||||
if (!StringUtils.hasText(edge.id)) {
|
if (!StringUtils.hasText(edge.id)) {
|
||||||
addIssue(issues, issueKeys, "EDGE_ID_EMPTY", "存在连线缺少 id", null, null, null);
|
addIssue(issues, issueKeys, "EDGE_ID_EMPTY", "存在连线缺少 id", null, null, null);
|
||||||
@@ -284,351 +228,10 @@ public class WorkflowCheckService {
|
|||||||
parsedWorkflow.nodes = nodes;
|
parsedWorkflow.nodes = nodes;
|
||||||
parsedWorkflow.edges = edges;
|
parsedWorkflow.edges = edges;
|
||||||
parsedWorkflow.nodeMap = nodeMap;
|
parsedWorkflow.nodeMap = nodeMap;
|
||||||
checkJoinModes(parsedWorkflow, issues, issueKeys);
|
|
||||||
checkDatacenterNodes(parsedWorkflow, issues, issueKeys);
|
checkDatacenterNodes(parsedWorkflow, issues, issueKeys);
|
||||||
return parsedWorkflow;
|
return parsedWorkflow;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 校验知识库节点的新旧引用字段和多库向量模式约束。
|
|
||||||
*/
|
|
||||||
private void checkKnowledgeConfigurations(
|
|
||||||
List<NodeView> nodes,
|
|
||||||
List<WorkflowCheckIssue> issues,
|
|
||||||
Set<String> issueKeys) {
|
|
||||||
for (NodeView node : nodes) {
|
|
||||||
if (!TYPE_KNOWLEDGE.equals(node.type) || node.data == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
checkKnowledgeOutputContract(node, issues, issueKeys);
|
|
||||||
List<String> knowledgeIds = new ArrayList<>();
|
|
||||||
if (node.data.containsKey("knowledgeIds")) {
|
|
||||||
Object rawIds = node.data.get("knowledgeIds");
|
|
||||||
if (!(rawIds instanceof JSONArray ids) || ids.isEmpty()) {
|
|
||||||
addIssue(
|
|
||||||
issues,
|
|
||||||
issueKeys,
|
|
||||||
"KNOWLEDGE_IDS_INVALID",
|
|
||||||
"知识库节点至少需要选择一个知识库",
|
|
||||||
node.id,
|
|
||||||
null,
|
|
||||||
node.name);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Set<String> unique = new LinkedHashSet<>();
|
|
||||||
for (Object rawId : ids) {
|
|
||||||
String id = trimToNull(rawId == null
|
|
||||||
? null
|
|
||||||
: String.valueOf(rawId));
|
|
||||||
if (id == null || !id.matches("[0-9]+")) {
|
|
||||||
addIssue(
|
|
||||||
issues,
|
|
||||||
issueKeys,
|
|
||||||
"KNOWLEDGE_IDS_INVALID",
|
|
||||||
"知识库节点包含无效的知识库ID",
|
|
||||||
node.id,
|
|
||||||
null,
|
|
||||||
node.name);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!unique.add(id)) {
|
|
||||||
addIssue(
|
|
||||||
issues,
|
|
||||||
issueKeys,
|
|
||||||
"KNOWLEDGE_IDS_DUPLICATE",
|
|
||||||
"知识库节点不能重复选择同一知识库",
|
|
||||||
node.id,
|
|
||||||
null,
|
|
||||||
node.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
knowledgeIds.addAll(unique);
|
|
||||||
if (knowledgeIds.size() > maxKnowledgeSources()) {
|
|
||||||
addIssue(
|
|
||||||
issues,
|
|
||||||
issueKeys,
|
|
||||||
"KNOWLEDGE_SOURCE_LIMIT_EXCEEDED",
|
|
||||||
"知识库节点选择数量超过平台上限",
|
|
||||||
node.id,
|
|
||||||
null,
|
|
||||||
node.name);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
String legacyId = trimToNull(node.data.getString("knowledgeId"));
|
|
||||||
if (legacyId == null || !legacyId.matches("[0-9]+")) {
|
|
||||||
addIssue(
|
|
||||||
issues,
|
|
||||||
issueKeys,
|
|
||||||
"KNOWLEDGE_ID_INVALID",
|
|
||||||
"知识库节点需要选择知识库",
|
|
||||||
node.id,
|
|
||||||
null,
|
|
||||||
node.name);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
knowledgeIds.add(legacyId);
|
|
||||||
}
|
|
||||||
|
|
||||||
String retrievalMode = trimToNull(
|
|
||||||
node.data.getString("retrievalMode"));
|
|
||||||
if (knowledgeIds.size() > 1
|
|
||||||
&& !"VECTOR".equalsIgnoreCase(retrievalMode)) {
|
|
||||||
addIssue(
|
|
||||||
issues,
|
|
||||||
issueKeys,
|
|
||||||
"MULTI_KNOWLEDGE_MODE_INVALID",
|
|
||||||
"多知识库检索仅支持向量检索",
|
|
||||||
node.id,
|
|
||||||
null,
|
|
||||||
node.name);
|
|
||||||
}
|
|
||||||
String limit = trimToNull(node.data.getString("limit"));
|
|
||||||
if (limit != null
|
|
||||||
&& !COMPLETE_VARIABLE_REFERENCE.matcher(limit).matches()) {
|
|
||||||
try {
|
|
||||||
int parsedLimit = Integer.parseInt(limit);
|
|
||||||
if (parsedLimit <= 0
|
|
||||||
|| (knowledgeIds.size() > 1
|
|
||||||
&& parsedLimit > maxMultiKnowledgeLimit())) {
|
|
||||||
throw new NumberFormatException("non-positive");
|
|
||||||
}
|
|
||||||
} catch (NumberFormatException exception) {
|
|
||||||
addIssue(
|
|
||||||
issues,
|
|
||||||
issueKeys,
|
|
||||||
"KNOWLEDGE_LIMIT_INVALID",
|
|
||||||
"知识库节点最终返回条数必须为正整数或有效变量引用",
|
|
||||||
node.id,
|
|
||||||
null,
|
|
||||||
node.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 知识库节点只允许暴露稳定的 documents 输出及四个历史子字段。
|
|
||||||
*/
|
|
||||||
private void checkKnowledgeOutputContract(
|
|
||||||
NodeView node,
|
|
||||||
List<WorkflowCheckIssue> issues,
|
|
||||||
Set<String> issueKeys) {
|
|
||||||
if (!node.data.containsKey("outputDefs")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Object rawOutputDefs = node.data.get("outputDefs");
|
|
||||||
if (rawOutputDefs instanceof JSONArray outputDefs
|
|
||||||
&& outputDefs.size() == 1
|
|
||||||
&& isCanonicalKnowledgeDocumentsOutput(
|
|
||||||
outputDefs.getJSONObject(0))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
addIssue(
|
|
||||||
issues,
|
|
||||||
issueKeys,
|
|
||||||
"KNOWLEDGE_OUTPUT_SCHEMA_INVALID",
|
|
||||||
"知识库节点输出参数必须为 documents,并仅包含 title、content、documentId、knowledgeId",
|
|
||||||
node.id,
|
|
||||||
null,
|
|
||||||
node.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isCanonicalKnowledgeDocumentsOutput(JSONObject output) {
|
|
||||||
if (output == null
|
|
||||||
|| !"documents".equals(output.getString("name"))
|
|
||||||
|| !"Array".equalsIgnoreCase(output.getString("dataType"))) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
JSONArray children = output.getJSONArray("children");
|
|
||||||
if (children == null || children.size() != 4) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
Map<String, String> expectedTypes = Map.of(
|
|
||||||
"title", "String",
|
|
||||||
"content", "String",
|
|
||||||
"documentId", "Number",
|
|
||||||
"knowledgeId", "Number");
|
|
||||||
Set<String> names = new LinkedHashSet<>();
|
|
||||||
for (int index = 0; index < children.size(); index++) {
|
|
||||||
JSONObject child = children.getJSONObject(index);
|
|
||||||
if (child == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
String name = trimToNull(child.getString("name"));
|
|
||||||
String expectedType = expectedTypes.get(name);
|
|
||||||
if (expectedType == null
|
|
||||||
|| !names.add(name)
|
|
||||||
|| !expectedType.equalsIgnoreCase(
|
|
||||||
child.getString("dataType"))) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return names.equals(expectedTypes.keySet());
|
|
||||||
}
|
|
||||||
|
|
||||||
private int maxKnowledgeSources() {
|
|
||||||
return multiKnowledgeRetrievalProperties == null
|
|
||||||
? DEFAULT_MAX_KNOWLEDGE_SOURCES
|
|
||||||
: multiKnowledgeRetrievalProperties.getMaxSources();
|
|
||||||
}
|
|
||||||
|
|
||||||
private int maxMultiKnowledgeLimit() {
|
|
||||||
return multiKnowledgeRetrievalProperties == null
|
|
||||||
? DEFAULT_MAX_MULTI_KNOWLEDGE_LIMIT
|
|
||||||
: multiKnowledgeRetrievalProperties.getTotalCandidateLimit();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 校验节点汇聚模式及其静态可证明的到达安全性。
|
|
||||||
*
|
|
||||||
* @param parsed 工作流视图
|
|
||||||
* @param issues 问题列表
|
|
||||||
* @param issueKeys 问题去重键
|
|
||||||
*/
|
|
||||||
private void checkJoinModes(
|
|
||||||
ParsedWorkflow parsed,
|
|
||||||
List<WorkflowCheckIssue> issues,
|
|
||||||
Set<String> issueKeys) {
|
|
||||||
Map<String, List<EdgeView>> inwardEdges = new LinkedHashMap<>();
|
|
||||||
for (EdgeView edge : parsed.edges) {
|
|
||||||
if (edge == null || !StringUtils.hasText(edge.target)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
inwardEdges.computeIfAbsent(
|
|
||||||
edge.target, ignored -> new ArrayList<>()).add(edge);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (NodeView node : parsed.nodes) {
|
|
||||||
String joinMode = resolveJoinMode(node);
|
|
||||||
if (joinMode == null) {
|
|
||||||
addIssue(
|
|
||||||
issues,
|
|
||||||
issueKeys,
|
|
||||||
"JOIN_MODE_INVALID",
|
|
||||||
"执行时机配置无效,joinMode 仅支持 any 或 all",
|
|
||||||
node.id,
|
|
||||||
null,
|
|
||||||
node.name);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (JOIN_MODE_ALL.equals(joinMode)
|
|
||||||
&& StringUtils.hasText(node.parentId)) {
|
|
||||||
addIssue(
|
|
||||||
issues,
|
|
||||||
issueKeys,
|
|
||||||
"JOIN_MODE_LOOP_CHILD_UNSUPPORTED",
|
|
||||||
"显式循环子图暂不支持“全部上游完成”,请改为“任一上游完成”",
|
|
||||||
node.id,
|
|
||||||
null,
|
|
||||||
node.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Set<String> guaranteedNodes = findGuaranteedNodes(
|
|
||||||
parsed, inwardEdges);
|
|
||||||
for (NodeView node : parsed.nodes) {
|
|
||||||
if (!JOIN_MODE_ALL.equals(resolveJoinMode(node))
|
|
||||||
|| StringUtils.hasText(node.parentId)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
List<EdgeView> directInward = inwardEdges.getOrDefault(
|
|
||||||
node.id, Collections.emptyList());
|
|
||||||
if (directInward.size() <= 1) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
boolean allGuaranteed = directInward.stream().allMatch(edge ->
|
|
||||||
!edge.hasCondition()
|
|
||||||
&& guaranteedNodes.contains(edge.source));
|
|
||||||
if (!allGuaranteed) {
|
|
||||||
addIssue(
|
|
||||||
issues,
|
|
||||||
issueKeys,
|
|
||||||
"JOIN_MODE_CONDITIONAL_PATH_UNSUPPORTED",
|
|
||||||
"“全部上游完成”可能永久等待:存在条件、互斥或无法证明必达的上游路径。"
|
|
||||||
+ "请改为“任一上游完成”或调整连线,确保所有直接入边都会到达",
|
|
||||||
node.id,
|
|
||||||
null,
|
|
||||||
node.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 使用保守固定点传播计算能够保证执行的根级节点。
|
|
||||||
*
|
|
||||||
* @param parsed 工作流视图
|
|
||||||
* @param inwardEdges 直接入边索引
|
|
||||||
* @return 保证执行的节点 ID
|
|
||||||
*/
|
|
||||||
private Set<String> findGuaranteedNodes(
|
|
||||||
ParsedWorkflow parsed,
|
|
||||||
Map<String, List<EdgeView>> inwardEdges) {
|
|
||||||
Set<String> guaranteed = parsed.nodes.stream()
|
|
||||||
.filter(NodeView::isRootLevel)
|
|
||||||
.filter(node -> TYPE_START.equals(node.type))
|
|
||||||
.map(node -> node.id)
|
|
||||||
.filter(StringUtils::hasText)
|
|
||||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
|
||||||
|
|
||||||
boolean changed;
|
|
||||||
do {
|
|
||||||
changed = false;
|
|
||||||
for (NodeView node : parsed.nodes) {
|
|
||||||
if (!node.isRootLevel()
|
|
||||||
|| guaranteed.contains(node.id)
|
|
||||||
|| hasAdvancedCondition(node)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
String joinMode = resolveJoinMode(node);
|
|
||||||
if (joinMode == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
List<EdgeView> directInward = inwardEdges.getOrDefault(
|
|
||||||
node.id, Collections.emptyList());
|
|
||||||
boolean isGuaranteed;
|
|
||||||
if (JOIN_MODE_ALL.equals(joinMode)) {
|
|
||||||
isGuaranteed = !directInward.isEmpty()
|
|
||||||
&& directInward.stream().allMatch(edge ->
|
|
||||||
!edge.hasCondition()
|
|
||||||
&& guaranteed.contains(edge.source));
|
|
||||||
} else {
|
|
||||||
isGuaranteed = directInward.stream().anyMatch(edge ->
|
|
||||||
!edge.hasCondition()
|
|
||||||
&& guaranteed.contains(edge.source));
|
|
||||||
}
|
|
||||||
if (isGuaranteed && guaranteed.add(node.id)) {
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} while (changed);
|
|
||||||
return guaranteed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 读取节点汇聚模式。字段缺失时兼容为 any,显式非法值返回 null。
|
|
||||||
*/
|
|
||||||
private String resolveJoinMode(NodeView node) {
|
|
||||||
if (node == null || node.data == null
|
|
||||||
|| !node.data.containsKey("joinMode")) {
|
|
||||||
return JOIN_MODE_ANY;
|
|
||||||
}
|
|
||||||
String value = trimToNull(node.data.getString("joinMode"));
|
|
||||||
if (JOIN_MODE_ANY.equalsIgnoreCase(value)) {
|
|
||||||
return JOIN_MODE_ANY;
|
|
||||||
}
|
|
||||||
if (JOIN_MODE_ALL.equalsIgnoreCase(value)) {
|
|
||||||
return JOIN_MODE_ALL;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean hasAdvancedCondition(NodeView node) {
|
|
||||||
return node != null
|
|
||||||
&& node.data != null
|
|
||||||
&& StringUtils.hasText(
|
|
||||||
trimToNull(node.data.getString("condition")));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 校验普通循环、显式循环和循环父子层级。
|
* 校验普通循环、显式循环和循环父子层级。
|
||||||
*
|
*
|
||||||
@@ -753,288 +356,6 @@ public class WorkflowCheckService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 校验用户确认节点配置及其对外输出定义。
|
|
||||||
*/
|
|
||||||
private void checkConfirmConfigurations(
|
|
||||||
List<NodeView> nodes,
|
|
||||||
List<WorkflowCheckIssue> issues,
|
|
||||||
Set<String> issueKeys) {
|
|
||||||
for (NodeView node : nodes) {
|
|
||||||
if (!TYPE_CONFIRM.equals(node.type)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (node.data == null) {
|
|
||||||
addIssue(
|
|
||||||
issues,
|
|
||||||
issueKeys,
|
|
||||||
"CONFIRM_CONFIGURATION_INVALID",
|
|
||||||
"用户确认节点配置不能为空",
|
|
||||||
node.id,
|
|
||||||
null,
|
|
||||||
node.name);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
ConfirmNode configuration;
|
|
||||||
try {
|
|
||||||
validateConfirmConfigurationTypes(node.data);
|
|
||||||
checkConfirmOutputDefinitions(
|
|
||||||
node,
|
|
||||||
Boolean.TRUE.equals(node.data.get("multiple")),
|
|
||||||
issues,
|
|
||||||
issueKeys);
|
|
||||||
configuration = node.data.toJavaObject(ConfirmNode.class);
|
|
||||||
configuration.validateConfiguration();
|
|
||||||
} catch (Exception exception) {
|
|
||||||
addIssue(
|
|
||||||
issues,
|
|
||||||
issueKeys,
|
|
||||||
"CONFIRM_CONFIGURATION_INVALID",
|
|
||||||
"用户确认节点配置无效: " + shortError(exception),
|
|
||||||
node.id,
|
|
||||||
null,
|
|
||||||
node.name);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void validateConfirmConfigurationTypes(JSONObject data) {
|
|
||||||
for (String key : data.keySet()) {
|
|
||||||
if (!ConfirmNode.SUPPORTED_CONFIGURATION_KEYS.contains(key)) {
|
|
||||||
throw new IllegalArgumentException(
|
|
||||||
"用户确认节点包含无效配置字段: " + key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!(data.get("message") instanceof String)) {
|
|
||||||
throw new IllegalArgumentException("用户确认节点提示内容必须为字符串");
|
|
||||||
}
|
|
||||||
if (!(data.get("multiple") instanceof Boolean)) {
|
|
||||||
throw new IllegalArgumentException("用户确认节点选择方式必须为布尔值");
|
|
||||||
}
|
|
||||||
|
|
||||||
Object optionsValue = data.get("options");
|
|
||||||
if (optionsValue == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!(optionsValue instanceof JSONArray options)) {
|
|
||||||
throw new IllegalArgumentException("用户确认节点选项必须为数组");
|
|
||||||
}
|
|
||||||
for (Object option : options) {
|
|
||||||
if (!(option instanceof String)) {
|
|
||||||
throw new IllegalArgumentException("用户确认节点选项内容必须为字符串");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void checkConfirmOutputDefinitions(
|
|
||||||
NodeView node,
|
|
||||||
boolean multiple,
|
|
||||||
List<WorkflowCheckIssue> issues,
|
|
||||||
Set<String> issueKeys) {
|
|
||||||
Object outputDefsValue = node.data.get("outputDefs");
|
|
||||||
if (!(outputDefsValue instanceof JSONArray outputDefs)
|
|
||||||
|| outputDefs.size() != 1) {
|
|
||||||
addConfirmOutputIssue(node, issues, issueKeys);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Object outputValue = outputDefs.get(0);
|
|
||||||
if (!(outputValue instanceof JSONObject output)) {
|
|
||||||
addConfirmOutputIssue(node, issues, issueKeys);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
String expectedType = multiple
|
|
||||||
? DataType.Array_String.toString()
|
|
||||||
: DataType.String.toString();
|
|
||||||
if (trimToNull(output.getString("name")) == null
|
|
||||||
|| !expectedType.equals(
|
|
||||||
trimToNull(output.getString("dataType")))) {
|
|
||||||
addConfirmOutputIssue(node, issues, issueKeys);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void addConfirmOutputIssue(
|
|
||||||
NodeView node,
|
|
||||||
List<WorkflowCheckIssue> issues,
|
|
||||||
Set<String> issueKeys) {
|
|
||||||
addIssue(
|
|
||||||
issues,
|
|
||||||
issueKeys,
|
|
||||||
"CONFIRM_OUTPUT_SCHEMA_INVALID",
|
|
||||||
"用户确认节点必须配置唯一非空输出参数,且类型与选择方式一致",
|
|
||||||
node.id,
|
|
||||||
null,
|
|
||||||
node.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 校验下游节点保存的确认输出引用仍然存在且类型一致。
|
|
||||||
*/
|
|
||||||
private void checkConfirmOutputReferences(
|
|
||||||
List<NodeView> nodes,
|
|
||||||
List<WorkflowCheckIssue> issues,
|
|
||||||
Set<String> issueKeys) {
|
|
||||||
ConfirmOutputIndex confirmOutputs = new ConfirmOutputIndex();
|
|
||||||
for (NodeView node : nodes) {
|
|
||||||
if (!TYPE_CONFIRM.equals(node.type) || node.data == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
ConfirmNode configuration = node.data.toJavaObject(ConfirmNode.class);
|
|
||||||
configuration.validateConfiguration();
|
|
||||||
confirmOutputs.put(
|
|
||||||
node.id,
|
|
||||||
configuration.resolveOutputName(),
|
|
||||||
configuration.isMultiple()
|
|
||||||
? DataType.Array_String.toString()
|
|
||||||
: DataType.String.toString());
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
// 配置错误已由 checkConfirmConfigurations 给出精确问题。
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (confirmOutputs.isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (NodeView node : nodes) {
|
|
||||||
if (node.data != null) {
|
|
||||||
checkConfirmOutputReferences(
|
|
||||||
node.data,
|
|
||||||
node,
|
|
||||||
confirmOutputs,
|
|
||||||
issues,
|
|
||||||
issueKeys);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void checkConfirmOutputReferences(
|
|
||||||
Object value,
|
|
||||||
NodeView consumer,
|
|
||||||
ConfirmOutputIndex confirmOutputs,
|
|
||||||
List<WorkflowCheckIssue> issues,
|
|
||||||
Set<String> issueKeys) {
|
|
||||||
if (value instanceof JSONObject object) {
|
|
||||||
if ("ref".equals(trimToNull(object.getString("refType")))) {
|
|
||||||
checkConfirmOutputReference(
|
|
||||||
object.getString("ref"),
|
|
||||||
object.getString("dataType"),
|
|
||||||
true,
|
|
||||||
consumer,
|
|
||||||
confirmOutputs,
|
|
||||||
issues,
|
|
||||||
issueKeys);
|
|
||||||
}
|
|
||||||
String leftType = checkConfirmOutputReference(
|
|
||||||
object.getString("leftRef"),
|
|
||||||
null,
|
|
||||||
false,
|
|
||||||
consumer,
|
|
||||||
confirmOutputs,
|
|
||||||
issues,
|
|
||||||
issueKeys);
|
|
||||||
String operator = trimToNull(object.getString("operator"));
|
|
||||||
if (DataType.Array_String.toString().equals(leftType)
|
|
||||||
&& !CONFIRM_ARRAY_LEFT_OPERATORS.contains(operator)) {
|
|
||||||
addConfirmConditionTypeIssue(
|
|
||||||
object.getString("leftRef"),
|
|
||||||
operator,
|
|
||||||
consumer,
|
|
||||||
issues,
|
|
||||||
issueKeys);
|
|
||||||
}
|
|
||||||
if ("ref".equals(trimToNull(object.getString("rightType")))) {
|
|
||||||
String rightType = checkConfirmOutputReference(
|
|
||||||
object.getString("rightRef"),
|
|
||||||
null,
|
|
||||||
false,
|
|
||||||
consumer,
|
|
||||||
confirmOutputs,
|
|
||||||
issues,
|
|
||||||
issueKeys);
|
|
||||||
if (DataType.Array_String.toString().equals(rightType)) {
|
|
||||||
addConfirmConditionTypeIssue(
|
|
||||||
object.getString("rightRef"),
|
|
||||||
operator,
|
|
||||||
consumer,
|
|
||||||
issues,
|
|
||||||
issueKeys);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (Object child : object.values()) {
|
|
||||||
checkConfirmOutputReferences(
|
|
||||||
child,
|
|
||||||
consumer,
|
|
||||||
confirmOutputs,
|
|
||||||
issues,
|
|
||||||
issueKeys);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (value instanceof JSONArray array) {
|
|
||||||
for (Object child : array) {
|
|
||||||
checkConfirmOutputReferences(
|
|
||||||
child,
|
|
||||||
consumer,
|
|
||||||
confirmOutputs,
|
|
||||||
issues,
|
|
||||||
issueKeys);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String checkConfirmOutputReference(
|
|
||||||
String rawReference,
|
|
||||||
String rawActualType,
|
|
||||||
boolean requireActualType,
|
|
||||||
NodeView consumer,
|
|
||||||
ConfirmOutputIndex confirmOutputs,
|
|
||||||
List<WorkflowCheckIssue> issues,
|
|
||||||
Set<String> issueKeys) {
|
|
||||||
String reference = trimToNull(rawReference);
|
|
||||||
if (reference == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
String expectedType = confirmOutputs.get(reference);
|
|
||||||
if (expectedType == null
|
|
||||||
&& !confirmOutputs.referencesConfirmNode(reference)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
String actualType = trimToNull(rawActualType);
|
|
||||||
boolean invalidType = actualType == null
|
|
||||||
? requireActualType
|
|
||||||
: !Objects.equals(expectedType, actualType);
|
|
||||||
if (expectedType == null || invalidType) {
|
|
||||||
addIssue(
|
|
||||||
issues,
|
|
||||||
issueKeys,
|
|
||||||
"CONFIRM_OUTPUT_REFERENCE_INVALID",
|
|
||||||
"用户确认输出引用不存在或类型已变化: "
|
|
||||||
+ reference,
|
|
||||||
consumer.id,
|
|
||||||
null,
|
|
||||||
consumer.name);
|
|
||||||
}
|
|
||||||
return expectedType;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void addConfirmConditionTypeIssue(
|
|
||||||
String reference,
|
|
||||||
String operator,
|
|
||||||
NodeView consumer,
|
|
||||||
List<WorkflowCheckIssue> issues,
|
|
||||||
Set<String> issueKeys) {
|
|
||||||
addIssue(
|
|
||||||
issues,
|
|
||||||
issueKeys,
|
|
||||||
"CONFIRM_OUTPUT_REFERENCE_INVALID",
|
|
||||||
"用户确认多选输出不支持当前条件操作符: "
|
|
||||||
+ trimToNull(reference) + " (" + operator + ")",
|
|
||||||
consumer.id,
|
|
||||||
null,
|
|
||||||
consumer.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将 JSON 条件规则转换为运行时规则对象。
|
* 将 JSON 条件规则转换为运行时规则对象。
|
||||||
*
|
*
|
||||||
@@ -2195,65 +1516,6 @@ public class WorkflowCheckService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void logSlowCheck(
|
|
||||||
WorkflowCheckStage stage,
|
|
||||||
BigInteger workflowId,
|
|
||||||
ParsedWorkflow parsedWorkflow,
|
|
||||||
int issueCount,
|
|
||||||
long checkStartedAt,
|
|
||||||
long baseFinishedAt,
|
|
||||||
long schemaFinishedAt,
|
|
||||||
long checkFinishedAt) {
|
|
||||||
long totalMs = elapsedMillis(checkStartedAt, checkFinishedAt);
|
|
||||||
if (totalMs < SLOW_CHECK_THRESHOLD_MS) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
LOGGER.warn(
|
|
||||||
"Workflow check is slow: stage={}, workflowId={}, nodes={}, "
|
|
||||||
+ "issues={}, baseMs={}, schemaMs={}, strictMs={}, totalMs={}",
|
|
||||||
stage,
|
|
||||||
workflowId,
|
|
||||||
parsedWorkflow == null ? 0 : parsedWorkflow.nodes.size(),
|
|
||||||
issueCount,
|
|
||||||
elapsedMillis(checkStartedAt, baseFinishedAt),
|
|
||||||
elapsedMillis(baseFinishedAt, schemaFinishedAt),
|
|
||||||
elapsedMillis(schemaFinishedAt, checkFinishedAt),
|
|
||||||
totalMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
private long elapsedMillis(long startedAt, long finishedAt) {
|
|
||||||
return Math.max(0L, (finishedAt - startedAt) / 1_000_000L);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static class ConfirmOutputIndex {
|
|
||||||
private final Map<String, String> outputTypes = new HashMap<>();
|
|
||||||
private final Set<String> nodeIds = new HashSet<>();
|
|
||||||
|
|
||||||
private void put(String nodeId, String outputName, String dataType) {
|
|
||||||
nodeIds.add(nodeId);
|
|
||||||
outputTypes.put(nodeId + "." + outputName, dataType);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String get(String reference) {
|
|
||||||
return outputTypes.get(reference);
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isEmpty() {
|
|
||||||
return outputTypes.isEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean referencesConfirmNode(String reference) {
|
|
||||||
int separator = reference.indexOf('.');
|
|
||||||
while (separator > 0) {
|
|
||||||
if (nodeIds.contains(reference.substring(0, separator))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
separator = reference.indexOf('.', separator + 1);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void throwIfFailed(WorkflowCheckResult result) {
|
private void throwIfFailed(WorkflowCheckResult result) {
|
||||||
if (result == null) {
|
if (result == null) {
|
||||||
return;
|
return;
|
||||||
@@ -2348,10 +1610,5 @@ public class WorkflowCheckService {
|
|||||||
private String id;
|
private String id;
|
||||||
private String source;
|
private String source;
|
||||||
private String target;
|
private String target;
|
||||||
private String condition;
|
|
||||||
|
|
||||||
private boolean hasCondition() {
|
|
||||||
return StringUtils.hasText(condition);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
package tech.easyflow.ai.easyagentsflow.service;
|
|
||||||
|
|
||||||
import com.easyagents.flow.core.chain.ChainResumeException;
|
|
||||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 统一校验并恢复暂停中的工作流。
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
public class WorkflowResumeService {
|
|
||||||
|
|
||||||
@Resource
|
|
||||||
private ChainExecutor chainExecutor;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 恢复暂停实例,并将引擎校验失败转换为稳定的接口错误。
|
|
||||||
*
|
|
||||||
* @param executeId 工作流实例 ID
|
|
||||||
* @param variables 用户提交的确认参数
|
|
||||||
*/
|
|
||||||
public void resume(String executeId, Map<String, Object> variables) {
|
|
||||||
Map<String, Object> submitted = variables == null
|
|
||||||
? new LinkedHashMap<>()
|
|
||||||
: new LinkedHashMap<>(variables);
|
|
||||||
final boolean resumed;
|
|
||||||
try {
|
|
||||||
resumed = chainExecutor.resumeAsyncIfSuspended(
|
|
||||||
executeId, submitted);
|
|
||||||
} catch (ChainResumeException exception) {
|
|
||||||
throw new BusinessException(
|
|
||||||
400,
|
|
||||||
40031,
|
|
||||||
exception.getMessage(),
|
|
||||||
exception);
|
|
||||||
}
|
|
||||||
if (!resumed) {
|
|
||||||
throw new BusinessException(
|
|
||||||
409,
|
|
||||||
40901,
|
|
||||||
"当前执行状态不可恢复,仅暂停中的工作流允许恢复");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -139,7 +139,7 @@ public class WorkflowRunningParameterResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 归一化工作流运行时变量,统一文件结构并移除空图片值。
|
* 归一化工作流运行时变量,确保文件参数统一为文件对象数组。
|
||||||
*
|
*
|
||||||
* @param content 工作流内容
|
* @param content 工作流内容
|
||||||
* @param variables 原始运行变量
|
* @param variables 原始运行变量
|
||||||
@@ -162,13 +162,7 @@ public class WorkflowRunningParameterResolver {
|
|||||||
if (isFileParameter(parameter)) {
|
if (isFileParameter(parameter)) {
|
||||||
normalized.put(name, normalizeFileVariableValue(normalized.get(name), name));
|
normalized.put(name, normalizeFileVariableValue(normalized.get(name), name));
|
||||||
} else if (isImageParameter(parameter)) {
|
} else if (isImageParameter(parameter)) {
|
||||||
Object imageValue = normalizeImageVariableValue(
|
normalized.put(name, normalizeImageVariableValue(normalized.get(name), name));
|
||||||
normalized.get(name), name);
|
|
||||||
if (imageValue == null) {
|
|
||||||
normalized.remove(name);
|
|
||||||
} else {
|
|
||||||
normalized.put(name, imageValue);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
@@ -689,10 +683,6 @@ public class WorkflowRunningParameterResolver {
|
|||||||
if (value == null) {
|
if (value == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (value instanceof String stringValue
|
|
||||||
&& !StringUtils.hasText(stringValue)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (value instanceof Collection<?> collection) {
|
if (value instanceof Collection<?> collection) {
|
||||||
for (Object item : collection) {
|
for (Object item : collection) {
|
||||||
collectFileValues(item, result);
|
collectFileValues(item, result);
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
package tech.easyflow.ai.entity;
|
|
||||||
|
|
||||||
import com.mybatisflex.annotation.Column;
|
|
||||||
import com.mybatisflex.annotation.Id;
|
|
||||||
import com.mybatisflex.annotation.KeyType;
|
|
||||||
import com.mybatisflex.annotation.Table;
|
|
||||||
import tech.easyflow.common.entity.DateEntity;
|
|
||||||
|
|
||||||
import java.io.Serializable;
|
|
||||||
import java.math.BigInteger;
|
|
||||||
import java.util.Date;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 文档分块检索索引同步任务。
|
|
||||||
*/
|
|
||||||
@Table("tb_document_chunk_sync_task")
|
|
||||||
public class DocumentChunkSyncTask extends DateEntity implements Serializable {
|
|
||||||
|
|
||||||
@Id(keyType = KeyType.Generator, value = "snowFlakeId")
|
|
||||||
private BigInteger id;
|
|
||||||
private BigInteger chunkId;
|
|
||||||
private BigInteger documentId;
|
|
||||||
private BigInteger documentCollectionId;
|
|
||||||
private String vectorCollection;
|
|
||||||
private String operation;
|
|
||||||
private Long syncVersion;
|
|
||||||
private String status;
|
|
||||||
private Integer attemptCount;
|
|
||||||
private Date nextRetryAt;
|
|
||||||
private Date lastDispatchedAt;
|
|
||||||
private String executionToken;
|
|
||||||
private Date leaseUntil;
|
|
||||||
private String errorCode;
|
|
||||||
private String errorMessage;
|
|
||||||
private Date created;
|
|
||||||
private BigInteger createdBy;
|
|
||||||
private Date modified;
|
|
||||||
private BigInteger modifiedBy;
|
|
||||||
|
|
||||||
public BigInteger getId() { return id; }
|
|
||||||
public void setId(BigInteger id) { this.id = id; }
|
|
||||||
public BigInteger getChunkId() { return chunkId; }
|
|
||||||
public void setChunkId(BigInteger chunkId) { this.chunkId = chunkId; }
|
|
||||||
public BigInteger getDocumentId() { return documentId; }
|
|
||||||
public void setDocumentId(BigInteger documentId) { this.documentId = documentId; }
|
|
||||||
public BigInteger getDocumentCollectionId() { return documentCollectionId; }
|
|
||||||
public void setDocumentCollectionId(BigInteger documentCollectionId) { this.documentCollectionId = documentCollectionId; }
|
|
||||||
public String getVectorCollection() { return vectorCollection; }
|
|
||||||
public void setVectorCollection(String vectorCollection) { this.vectorCollection = vectorCollection; }
|
|
||||||
public String getOperation() { return operation; }
|
|
||||||
public void setOperation(String operation) { this.operation = operation; }
|
|
||||||
public Long getSyncVersion() { return syncVersion; }
|
|
||||||
public void setSyncVersion(Long syncVersion) { this.syncVersion = syncVersion; }
|
|
||||||
public String getStatus() { return status; }
|
|
||||||
public void setStatus(String status) { this.status = status; }
|
|
||||||
public Integer getAttemptCount() { return attemptCount; }
|
|
||||||
public void setAttemptCount(Integer attemptCount) { this.attemptCount = attemptCount; }
|
|
||||||
public Date getNextRetryAt() { return nextRetryAt; }
|
|
||||||
public void setNextRetryAt(Date nextRetryAt) { this.nextRetryAt = nextRetryAt; }
|
|
||||||
public Date getLastDispatchedAt() { return lastDispatchedAt; }
|
|
||||||
public void setLastDispatchedAt(Date lastDispatchedAt) { this.lastDispatchedAt = lastDispatchedAt; }
|
|
||||||
public String getExecutionToken() { return executionToken; }
|
|
||||||
public void setExecutionToken(String executionToken) { this.executionToken = executionToken; }
|
|
||||||
public Date getLeaseUntil() { return leaseUntil; }
|
|
||||||
public void setLeaseUntil(Date leaseUntil) { this.leaseUntil = leaseUntil; }
|
|
||||||
public String getErrorCode() { return errorCode; }
|
|
||||||
public void setErrorCode(String errorCode) { this.errorCode = errorCode; }
|
|
||||||
public String getErrorMessage() { return errorMessage; }
|
|
||||||
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
|
|
||||||
@Override public Date getCreated() { return created; }
|
|
||||||
@Override public void setCreated(Date created) { this.created = created; }
|
|
||||||
public BigInteger getCreatedBy() { return createdBy; }
|
|
||||||
public void setCreatedBy(BigInteger createdBy) { this.createdBy = createdBy; }
|
|
||||||
@Override public Date getModified() { return modified; }
|
|
||||||
@Override public void setModified(Date modified) { this.modified = modified; }
|
|
||||||
public BigInteger getModifiedBy() { return modifiedBy; }
|
|
||||||
public void setModifiedBy(BigInteger modifiedBy) { this.modifiedBy = modifiedBy; }
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user