Compare commits
47 Commits
7a5298c3fc
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 009f2d5f21 | |||
| cdeb1aa6b8 | |||
| dd14af2733 | |||
| e938ecda80 | |||
| 431fe8e707 | |||
| 0968e3bfa5 | |||
| 65c85180c2 | |||
| 0f126ad489 | |||
| 1e59063c37 | |||
| 788c8e5459 | |||
| c3673ece46 | |||
| 36acf37976 | |||
| 1dd0ac167c | |||
| 6498f1049a | |||
| 1f37b0a8ae | |||
| c00369f6b9 | |||
| 3e34b65bcb | |||
| 263f5f4b8b | |||
| 6daf805cd0 | |||
| 4386b2a1a6 | |||
| f28e3919ac | |||
| 0cedf85729 | |||
| 155af9989c | |||
| 8c174e5c02 | |||
| 17ef189862 | |||
| e6b2e2798f | |||
| 4823b0741f | |||
| 1c68e3582c | |||
| 619b60600d | |||
| 240b84063a | |||
| ec6e03587a | |||
| 1ccdafdb47 | |||
| e38821e48a | |||
| 3e79e99925 | |||
| 98b34bd4bb | |||
| c27e97bcc2 | |||
| 9068d42f4d | |||
| fd64073148 | |||
| f63cd9be4d | |||
| 407b85c8a9 | |||
| 9078ca163e | |||
| 7f083a9433 | |||
| 7d654c3302 | |||
| fa07134cf8 | |||
| 310fc1fb58 | |||
| c96f772f01 | |||
| 30b2cc36fd |
@@ -129,8 +129,9 @@ RUN fc-cache -f && \
|
|||||||
mkdir -p /app/logs /app/artifacts /app/data && \
|
mkdir -p /app/logs /app/artifacts /app/data && \
|
||||||
chown -R easyflow:easyflow /app
|
chown -R easyflow:easyflow /app
|
||||||
|
|
||||||
|
COPY docker-soffice-wrapper.sh /usr/local/bin/soffice
|
||||||
COPY docker-entrypoint.sh /usr/local/bin/easyflow-entrypoint.sh
|
COPY docker-entrypoint.sh /usr/local/bin/easyflow-entrypoint.sh
|
||||||
RUN chmod 755 /usr/local/bin/easyflow-entrypoint.sh
|
RUN chmod 755 /usr/local/bin/soffice /usr/local/bin/easyflow-entrypoint.sh
|
||||||
|
|
||||||
VOLUME ["/app/logs", "/app/data"]
|
VOLUME ["/app/logs", "/app/data"]
|
||||||
EXPOSE 8111
|
EXPOSE 8111
|
||||||
|
|||||||
21
docker-soffice-wrapper.sh
Normal file
21
docker-soffice-wrapper.sh
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
profile_parent="${TMPDIR:-/tmp}"
|
||||||
|
profile_dir="$(mktemp -d "${profile_parent%/}/easyflow-soffice-XXXXXX")"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf -- "$profile_dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT HUP INT TERM
|
||||||
|
|
||||||
|
/usr/bin/soffice \
|
||||||
|
-env:UserInstallation="file://${profile_dir}" \
|
||||||
|
--headless \
|
||||||
|
--safe-mode \
|
||||||
|
--nologo \
|
||||||
|
--nodefault \
|
||||||
|
--nolockcheck \
|
||||||
|
--norestore \
|
||||||
|
"$@"
|
||||||
@@ -40,6 +40,10 @@
|
|||||||
<groupId>tech.easyflow</groupId>
|
<groupId>tech.easyflow</groupId>
|
||||||
<artifactId>easyflow-module-job</artifactId>
|
<artifactId>easyflow-module-job</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>tech.easyflow</groupId>
|
||||||
|
<artifactId>easyflow-module-dataspace</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>tech.easyflow</groupId>
|
<groupId>tech.easyflow</groupId>
|
||||||
<artifactId>easyflow-common-captcha</artifactId>
|
<artifactId>easyflow-common-captcha</artifactId>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import tech.easyflow.agent.runtime.AgentChatRequest;
|
|||||||
import tech.easyflow.agent.runtime.AgentDraftChatRequest;
|
import tech.easyflow.agent.runtime.AgentDraftChatRequest;
|
||||||
import tech.easyflow.agent.runtime.AgentRunService;
|
import tech.easyflow.agent.runtime.AgentRunService;
|
||||||
import tech.easyflow.agent.runtime.agui.AgentAguiHitlResolveRequest;
|
import tech.easyflow.agent.runtime.agui.AgentAguiHitlResolveRequest;
|
||||||
|
import tech.easyflow.agent.runtime.agui.AgentAguiRunStatusView;
|
||||||
import tech.easyflow.agent.runtime.composer.AgentComposerDraft;
|
import tech.easyflow.agent.runtime.composer.AgentComposerDraft;
|
||||||
import tech.easyflow.agent.runtime.composer.AgentComposerDraftService;
|
import tech.easyflow.agent.runtime.composer.AgentComposerDraftService;
|
||||||
import tech.easyflow.agent.runtime.composer.AgentComposerSession;
|
import tech.easyflow.agent.runtime.composer.AgentComposerSession;
|
||||||
@@ -319,6 +320,51 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
|
|||||||
return agentRunService.chatDraftAgui(input);
|
return agentRunService.chatDraftAgui(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询可重连 AG-UI 运行状态。
|
||||||
|
*
|
||||||
|
* @param runId 客户端运行 ID
|
||||||
|
* @return 运行状态
|
||||||
|
*/
|
||||||
|
@GetMapping("/agui/run/{runId}/status")
|
||||||
|
@SaCheckPermission(value = {
|
||||||
|
"/api/v1/agent/session/query", "/api/v1/agent/save"
|
||||||
|
}, mode = SaMode.OR)
|
||||||
|
public Result<AgentAguiRunStatusView> getAguiRunStatus(@PathVariable String runId) {
|
||||||
|
return Result.ok(agentRunService.getAguiRunStatus(runId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从指定游标继续订阅 AG-UI 运行事件。
|
||||||
|
*
|
||||||
|
* @param runId 客户端运行 ID
|
||||||
|
* @param after 已消费的最后事件游标
|
||||||
|
* @return 增量重放 SSE
|
||||||
|
*/
|
||||||
|
@GetMapping("/agui/run/{runId}/events")
|
||||||
|
@SaCheckPermission(value = {
|
||||||
|
"/api/v1/agent/session/query", "/api/v1/agent/save"
|
||||||
|
}, mode = SaMode.OR)
|
||||||
|
public SseEmitter subscribeAguiRun(@PathVariable String runId,
|
||||||
|
@RequestParam(defaultValue = "0") long after) {
|
||||||
|
return agentRunService.subscribeAguiRun(runId, after);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显式取消单次 AG-UI 运行。
|
||||||
|
*
|
||||||
|
* @param runId 客户端运行 ID
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
@PostMapping("/agui/run/{runId}/cancel")
|
||||||
|
@SaCheckPermission(value = {
|
||||||
|
"/api/v1/agent/session/query", "/api/v1/agent/save"
|
||||||
|
}, mode = SaMode.OR)
|
||||||
|
public Result<Void> cancelAguiRun(@PathVariable String runId) {
|
||||||
|
agentRunService.cancelAguiRun(runId);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理 AG-UI 自定义 HITL 兼容桥审批。
|
* 处理 AG-UI 自定义 HITL 兼容桥审批。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,23 +1,18 @@
|
|||||||
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.BaseCurdController;
|
import tech.easyflow.common.web.controller.BaseController;
|
||||||
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;
|
||||||
@@ -28,12 +23,8 @@ 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;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 控制层。
|
* 控制层。
|
||||||
@@ -44,19 +35,12 @@ import java.util.Map;
|
|||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/documentChunk")
|
@RequestMapping("/api/v1/documentChunk")
|
||||||
@UsePermission(moduleName = "/api/v1/documentCollection")
|
@UsePermission(moduleName = "/api/v1/documentCollection")
|
||||||
public class DocumentChunkController extends BaseCurdController<DocumentChunkService, DocumentChunk> {
|
public class DocumentChunkController extends BaseController {
|
||||||
|
|
||||||
@Resource
|
private final DocumentChunkService documentChunkService;
|
||||||
DocumentCollectionService documentCollectionService;
|
|
||||||
|
|
||||||
@Resource
|
|
||||||
ModelService modelService;
|
|
||||||
|
|
||||||
@Resource
|
|
||||||
DocumentChunkService documentChunkService;
|
|
||||||
|
|
||||||
public DocumentChunkController(DocumentChunkService service) {
|
public DocumentChunkController(DocumentChunkService service) {
|
||||||
super(service);
|
this.documentChunkService = service;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("page")
|
@GetMapping("page")
|
||||||
@@ -68,9 +52,30 @@ public class DocumentChunkController extends BaseCurdController<DocumentChunkSer
|
|||||||
idExpr = "#request.getParameter('documentId')",
|
idExpr = "#request.getParameter('documentId')",
|
||||||
denyMessage = "无权限访问知识库"
|
denyMessage = "无权限访问知识库"
|
||||||
)
|
)
|
||||||
@Override
|
public Result<Page<DocumentChunk>> page(
|
||||||
public Result<Page<DocumentChunk>> page(HttpServletRequest request, String sortKey, String sortType, Long pageNumber, Long pageSize) {
|
HttpServletRequest request,
|
||||||
return super.page(request, sortKey, sortType, pageNumber, pageSize);
|
Long pageNumber,
|
||||||
|
Long pageSize
|
||||||
|
) {
|
||||||
|
String documentIdValue = request.getParameter("documentId");
|
||||||
|
if (documentIdValue == null || documentIdValue.isBlank()) {
|
||||||
|
return Result.<Page<DocumentChunk>>fail("documentId不能为空", null);
|
||||||
|
}
|
||||||
|
BigInteger documentId;
|
||||||
|
try {
|
||||||
|
documentId = new BigInteger(documentIdValue);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return Result.<Page<DocumentChunk>>fail("documentId格式不正确", null);
|
||||||
|
}
|
||||||
|
long normalizedPageNumber = pageNumber == null || pageNumber < 1 ? 1 : pageNumber;
|
||||||
|
long normalizedPageSize = pageSize == null || pageSize < 1 ? 10 : pageSize;
|
||||||
|
QueryWrapper query = QueryWrapper.create()
|
||||||
|
.eq(DocumentChunk::getDocumentId, documentId)
|
||||||
|
.orderBy("sorting asc");
|
||||||
|
return Result.ok(documentChunkService.page(
|
||||||
|
new Page<>(normalizedPageNumber, normalizedPageSize),
|
||||||
|
query
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("update")
|
@PostMapping("update")
|
||||||
@@ -79,43 +84,23 @@ public class DocumentChunkController extends BaseCurdController<DocumentChunkSer
|
|||||||
resource = CategoryResourceType.KNOWLEDGE,
|
resource = CategoryResourceType.KNOWLEDGE,
|
||||||
action = ResourceAction.MANAGE,
|
action = ResourceAction.MANAGE,
|
||||||
lookup = ResourceLookup.DOCUMENT_CHUNK_ID,
|
lookup = ResourceLookup.DOCUMENT_CHUNK_ID,
|
||||||
idExpr = "#documentChunk.id",
|
idExpr = "#request.id",
|
||||||
denyMessage = "无权限管理知识库"
|
denyMessage = "无权限管理知识库"
|
||||||
)
|
)
|
||||||
public Result<?> update(@JsonBody DocumentChunk documentChunk) {
|
public Result<?> update(
|
||||||
boolean success = service.updateById(documentChunk);
|
@JsonBody(required = true, skipConvertError = false)
|
||||||
if (success){
|
DocumentChunkContentUpdateRequest request
|
||||||
DocumentChunk record = documentChunkService.getById(documentChunk.getId());
|
) {
|
||||||
DocumentCollection knowledge = documentCollectionService.getById(record.getDocumentCollectionId());
|
DocumentChunk current = documentChunkService.getById(request.getId());
|
||||||
if (knowledge == null) {
|
if (current == null) {
|
||||||
return Result.fail(1, "知识库不存在");
|
return Result.fail(1, "记录不存在");
|
||||||
}
|
|
||||||
DocumentStore documentStore = knowledge.toDocumentStore();
|
|
||||||
if (documentStore == null) {
|
|
||||||
return Result.fail(2, "知识库没有配置向量库");
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
// 设置向量模型
|
|
||||||
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
|
|
||||||
if (model == null) {
|
|
||||||
return Result.fail(3, "知识库没有配置向量模型");
|
|
||||||
}
|
|
||||||
EmbeddingModel embeddingModel = model.toEmbeddingModel();
|
|
||||||
documentStore.setEmbeddingModel(embeddingModel);
|
|
||||||
StoreOptions options = StoreOptions.ofCollectionName(knowledge.getVectorStoreCollection());
|
|
||||||
Document document = Document.of(documentChunk.getContent());
|
|
||||||
document.setId(documentChunk.getId());
|
|
||||||
Map<String, Object> metadata = new HashMap<>();
|
|
||||||
metadata.put("keywords", documentChunk.getMetadataKeyWords());
|
|
||||||
metadata.put("questions", documentChunk.getMetadataQuestions());
|
|
||||||
document.setMetadataMap(metadata);
|
|
||||||
StoreResult result = documentStore.update(document, options); // 更新已有记录
|
|
||||||
return Result.ok(result);
|
|
||||||
} finally {
|
|
||||||
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return Result.ok(false);
|
DocumentChunk updated = documentChunkService.updateContent(
|
||||||
|
current.getDocumentCollectionId(),
|
||||||
|
current.getId(),
|
||||||
|
request.getContent()
|
||||||
|
);
|
||||||
|
return Result.ok(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("removeChunk")
|
@PostMapping("removeChunk")
|
||||||
@@ -127,36 +112,58 @@ public class DocumentChunkController extends BaseCurdController<DocumentChunkSer
|
|||||||
idExpr = "#chunkId",
|
idExpr = "#chunkId",
|
||||||
denyMessage = "无权限管理知识库"
|
denyMessage = "无权限管理知识库"
|
||||||
)
|
)
|
||||||
public Result<?> remove(@JsonBody(value = "id", required = true) BigInteger chunkId) {
|
public Result<?> removeChunk(@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, "记录不存在");
|
||||||
}
|
}
|
||||||
DocumentCollection knowledge = documentCollectionService.getById(docChunk.getDocumentCollectionId());
|
return Result.ok(documentChunkService.deleteChunk(
|
||||||
if (knowledge == null) {
|
docChunk.getDocumentCollectionId(),
|
||||||
return Result.fail(2, "知识库不存在");
|
chunkId
|
||||||
}
|
));
|
||||||
DocumentStore documentStore = knowledge.toDocumentStore();
|
}
|
||||||
if (documentStore == null) {
|
|
||||||
return Result.fail(3, "知识库没有配置向量库");
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
// 设置向量模型
|
|
||||||
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
|
|
||||||
if (model == null) {
|
|
||||||
return Result.fail(4, "知识库没有配置向量模型");
|
|
||||||
}
|
|
||||||
EmbeddingModel embeddingModel = model.toEmbeddingModel();
|
|
||||||
documentStore.setEmbeddingModel(embeddingModel);
|
|
||||||
StoreOptions options = StoreOptions.ofCollectionName(knowledge.getVectorStoreCollection());
|
|
||||||
List<BigInteger> deleteList = new ArrayList<>();
|
|
||||||
deleteList.add(chunkId);
|
|
||||||
documentStore.delete(deleteList, options);
|
|
||||||
documentChunkService.removeChunk(knowledge, chunkId);
|
|
||||||
|
|
||||||
return super.remove(chunkId);
|
@PostMapping("syncStatus")
|
||||||
} finally {
|
@SaCheckPermission("/api/v1/documentCollection/query")
|
||||||
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
|
@RequireResourceAccess(
|
||||||
|
resource = CategoryResourceType.KNOWLEDGE,
|
||||||
|
action = ResourceAction.READ,
|
||||||
|
lookup = ResourceLookup.DOCUMENT_ID,
|
||||||
|
idExpr = "#request.documentId",
|
||||||
|
denyMessage = "无权限访问知识库"
|
||||||
|
)
|
||||||
|
public Result<List<DocumentChunkSyncStatus>> syncStatus(
|
||||||
|
@JsonBody(required = true, skipConvertError = false)
|
||||||
|
DocumentChunkSyncStatusRequest request
|
||||||
|
) {
|
||||||
|
return Result.ok(documentChunkService.listIndexSyncStatus(
|
||||||
|
null,
|
||||||
|
request.getDocumentId(),
|
||||||
|
request.getIds()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("retrySync")
|
||||||
|
@SaCheckPermission("/api/v1/documentCollection/save")
|
||||||
|
@RequireResourceAccess(
|
||||||
|
resource = CategoryResourceType.KNOWLEDGE,
|
||||||
|
action = ResourceAction.MANAGE,
|
||||||
|
lookup = ResourceLookup.DOCUMENT_CHUNK_ID,
|
||||||
|
idExpr = "#request.id",
|
||||||
|
denyMessage = "无权限管理知识库"
|
||||||
|
)
|
||||||
|
public Result<?> retrySync(
|
||||||
|
@JsonBody(required = true, skipConvertError = false)
|
||||||
|
DocumentChunkSyncRetryRequest request
|
||||||
|
) {
|
||||||
|
DocumentChunk current = documentChunkService.getById(request.getId());
|
||||||
|
if (current == null || request.getIndexSyncVersion() == null) {
|
||||||
|
return Result.fail(1, "记录不存在或同步版本缺失");
|
||||||
}
|
}
|
||||||
|
return Result.ok(documentChunkService.retryIndexSync(
|
||||||
|
current.getDocumentCollectionId(),
|
||||||
|
current.getId(),
|
||||||
|
request.getIndexSyncVersion()
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,12 +118,10 @@ 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 isSuccess = documentService.removeDoc(id);
|
boolean success = documentService.removeDoc(id);
|
||||||
if (!isSuccess){
|
if (success) {
|
||||||
return Result.ok(false);
|
onRemoveAfter(ids);
|
||||||
}
|
}
|
||||||
boolean success = service.removeById(id);
|
|
||||||
onRemoveAfter(ids);
|
|
||||||
return Result.ok(success);
|
return Result.ok(success);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,10 +18,12 @@ 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;
|
||||||
@@ -29,6 +31,7 @@ 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;
|
||||||
@@ -91,11 +94,15 @@ 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")
|
||||||
@@ -215,6 +222,7 @@ 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));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,10 +237,33 @@ 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) {
|
||||||
chainExecutor.resumeAsync(executeId, confirmParams);
|
assertPluginTestExecutionOwnership(executeId);
|
||||||
|
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,10 +1,6 @@
|
|||||||
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;
|
||||||
@@ -22,6 +18,11 @@ 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;
|
||||||
@@ -43,7 +44,6 @@ 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,7 +62,6 @@ 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;
|
||||||
@@ -505,43 +504,27 @@ public class ShareKnowledgeController {
|
|||||||
@PostMapping("/documentChunk/update")
|
@PostMapping("/documentChunk/update")
|
||||||
public Result<?> updateDocumentChunk(
|
public Result<?> updateDocumentChunk(
|
||||||
@RequestParam String shareKey,
|
@RequestParam String shareKey,
|
||||||
@JsonBody DocumentChunk documentChunk
|
@JsonBody(required = true, skipConvertError = false)
|
||||||
|
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(documentChunk.getId());
|
DocumentChunk current = documentChunkService.getById(request.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("记录不存在");
|
||||||
}
|
}
|
||||||
boolean success = documentChunkService.updateById(documentChunk);
|
DocumentChunk updated = documentChunkService.updateContent(
|
||||||
if (success) {
|
context.getKnowledge().getId(),
|
||||||
DocumentStore documentStore = context.getKnowledge().toDocumentStore();
|
current.getId(),
|
||||||
if (documentStore == null) {
|
request.getContent()
|
||||||
return Result.fail(2, "知识库没有配置向量库");
|
);
|
||||||
}
|
audit(context, "更新分享文档 Chunk", "KNOWLEDGE_SHARE_URL_WRITE", true,
|
||||||
try {
|
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", request.getId()));
|
||||||
Model model = modelService.getModelInstance(context.getKnowledge().getVectorEmbedModelId());
|
return Result.ok(updated);
|
||||||
if (model == null) {
|
|
||||||
return Result.fail(3, "知识库没有配置向量模型");
|
|
||||||
}
|
|
||||||
EmbeddingModel embeddingModel = model.toEmbeddingModel();
|
|
||||||
documentStore.setEmbeddingModel(embeddingModel);
|
|
||||||
StoreOptions options = StoreOptions.ofCollectionName(context.getKnowledge().getVectorStoreCollection());
|
|
||||||
com.easyagents.core.document.Document doc = com.easyagents.core.document.Document.of(documentChunk.getContent());
|
|
||||||
doc.setId(documentChunk.getId());
|
|
||||||
StoreResult result = documentStore.update(doc, options);
|
|
||||||
audit(context, "更新分享文档 Chunk", "KNOWLEDGE_SHARE_URL_WRITE", true,
|
|
||||||
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", documentChunk.getId()));
|
|
||||||
return Result.ok(result);
|
|
||||||
} finally {
|
|
||||||
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Result.ok(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -562,25 +545,50 @@ 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, "记录不存在");
|
||||||
}
|
}
|
||||||
DocumentStore documentStore = context.getKnowledge().toDocumentStore();
|
DocumentChunkDeleteResult removed = documentChunkService.deleteChunk(
|
||||||
if (documentStore == null) {
|
context.getKnowledge().getId(),
|
||||||
return Result.fail(2, "知识库没有配置向量库");
|
chunkId
|
||||||
|
);
|
||||||
|
audit(context, "删除分享文档 Chunk", "KNOWLEDGE_SHARE_URL_WRITE", true,
|
||||||
|
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", chunkId));
|
||||||
|
return Result.ok(removed);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/documentChunk/syncStatus")
|
||||||
|
public Result<List<DocumentChunkSyncStatus>> documentChunkSyncStatus(
|
||||||
|
@RequestParam String shareKey,
|
||||||
|
@JsonBody DocumentChunkSyncStatusRequest request
|
||||||
|
) {
|
||||||
|
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
||||||
|
shareKey, null, KnowledgeShareActionScope.VIEW.name()
|
||||||
|
);
|
||||||
|
Document document = documentService.getById(request.getDocumentId());
|
||||||
|
if (document == null || document.getCollectionId() == null
|
||||||
|
|| document.getCollectionId().compareTo(context.getKnowledge().getId()) != 0) {
|
||||||
|
throw new BusinessException("文档不存在");
|
||||||
}
|
}
|
||||||
try {
|
return Result.ok(documentChunkService.listIndexSyncStatus(
|
||||||
Model model = modelService.getModelInstance(context.getKnowledge().getVectorEmbedModelId());
|
context.getKnowledge().getId(), request.getDocumentId(), request.getIds()
|
||||||
if (model == null) {
|
));
|
||||||
return Result.fail(3, "知识库没有配置向量模型");
|
}
|
||||||
}
|
|
||||||
documentStore.setEmbeddingModel(model.toEmbeddingModel());
|
@PostMapping("/documentChunk/retrySync")
|
||||||
StoreOptions options = StoreOptions.ofCollectionName(context.getKnowledge().getVectorStoreCollection());
|
public Result<DocumentChunk> retryDocumentChunkSync(
|
||||||
documentStore.delete(Collections.singletonList(chunkId), options);
|
@RequestParam String shareKey,
|
||||||
documentChunkService.removeById(chunkId);
|
@JsonBody DocumentChunkSyncRetryRequest request
|
||||||
audit(context, "删除分享文档 Chunk", "KNOWLEDGE_SHARE_URL_WRITE", true,
|
) {
|
||||||
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", chunkId));
|
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
||||||
return Result.ok(true);
|
shareKey, null, KnowledgeShareActionScope.CONTENT_UPDATE.name()
|
||||||
} finally {
|
);
|
||||||
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
|
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,6 +1,5 @@
|
|||||||
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;
|
||||||
@@ -14,6 +13,7 @@ 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,6 +64,8 @@ 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;
|
||||||
@@ -171,19 +173,8 @@ public class WorkflowChatController {
|
|||||||
@JsonBody("confirmParams")
|
@JsonBody("confirmParams")
|
||||||
Map<String, Object> confirmParams
|
Map<String, Object> confirmParams
|
||||||
) {
|
) {
|
||||||
WorkflowExecResult record = assertExecutionOwnership(executeId);
|
assertExecutionOwnership(executeId);
|
||||||
if (record.getStatus() != null
|
workflowResumeService.resume(executeId, confirmParams);
|
||||||
&& (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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,6 +224,7 @@ public class WorkflowChatController {
|
|||||||
Map<String, Object> detail = new LinkedHashMap<>();
|
Map<String, Object> detail = new LinkedHashMap<>();
|
||||||
detail.put("record", recordView);
|
detail.put("record", recordView);
|
||||||
detail.put("steps", stepViews);
|
detail.put("steps", stepViews);
|
||||||
|
detail.put("runtime", eventStream.runtimeView(executeId));
|
||||||
return Result.ok(detail);
|
return Result.ok(detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package tech.easyflow.admin.controller.ai;
|
package tech.easyflow.admin.controller.ai;
|
||||||
|
|
||||||
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowExecutionErrorMapper;
|
||||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||||
import cn.dev33.satoken.stp.StpUtil;
|
import cn.dev33.satoken.stp.StpUtil;
|
||||||
import cn.hutool.core.io.IoUtil;
|
import cn.hutool.core.io.IoUtil;
|
||||||
@@ -31,6 +32,7 @@ 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;
|
||||||
@@ -94,6 +96,8 @@ 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;
|
||||||
@@ -258,6 +262,7 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
|||||||
if (StpUtil.isLogin()) {
|
if (StpUtil.isLogin()) {
|
||||||
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
|
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
|
||||||
}
|
}
|
||||||
|
WorkflowExecutionErrorMapper.installRequestProfile();
|
||||||
Map<String, Object> res = chainExecutor.executeNode(workflowId.toString(), nodeId, variables);
|
Map<String, Object> res = chainExecutor.executeNode(workflowId.toString(), nodeId, variables);
|
||||||
return Result.ok(res);
|
return Result.ok(res);
|
||||||
}
|
}
|
||||||
@@ -324,12 +329,7 @@ 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) {
|
||||||
if (!chainExecutor.resumeAsyncIfSuspended(executeId, confirmParams)) {
|
workflowResumeService.resume(executeId, confirmParams);
|
||||||
throw new BusinessException(
|
|
||||||
409,
|
|
||||||
40901,
|
|
||||||
"当前执行状态不可恢复,仅暂停中的工作流允许恢复");
|
|
||||||
}
|
|
||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package tech.easyflow.admin.controller.ai;
|
||||||
|
|
||||||
|
import cn.dev33.satoken.annotation.SaIgnore;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
import tech.easyflow.admin.service.ai.WorkflowPublicChatService;
|
||||||
|
import tech.easyflow.ai.share.WorkflowSharePolicy;
|
||||||
|
import tech.easyflow.common.domain.Result;
|
||||||
|
import tech.easyflow.common.vo.UploadResVo;
|
||||||
|
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流对话匿名分享接口。
|
||||||
|
*/
|
||||||
|
@SaIgnore
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/workflowChat/public")
|
||||||
|
public class WorkflowPublicChatController {
|
||||||
|
|
||||||
|
private final WorkflowPublicChatService publicChatService;
|
||||||
|
|
||||||
|
public WorkflowPublicChatController(
|
||||||
|
WorkflowPublicChatService publicChatService
|
||||||
|
) {
|
||||||
|
this.publicChatService = publicChatService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/descriptor")
|
||||||
|
public Result<Map<String, Object>> descriptor(HttpServletRequest request) {
|
||||||
|
return Result.ok(publicChatService.descriptor(
|
||||||
|
shareKey(request),
|
||||||
|
visitorId(request)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/run", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||||
|
public SseEmitter run(
|
||||||
|
@JsonBody("variables") Map<String, Object> variables,
|
||||||
|
HttpServletRequest request
|
||||||
|
) {
|
||||||
|
return publicChatService.run(
|
||||||
|
shareKey(request),
|
||||||
|
visitorId(request),
|
||||||
|
variables
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/execution")
|
||||||
|
public Result<Map<String, Object>> execution(
|
||||||
|
@RequestParam String executeId,
|
||||||
|
HttpServletRequest request
|
||||||
|
) {
|
||||||
|
return Result.ok(publicChatService.detail(
|
||||||
|
shareKey(request),
|
||||||
|
visitorId(request),
|
||||||
|
executeId
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/cancel")
|
||||||
|
public Result<Boolean> cancel(
|
||||||
|
@JsonBody(value = "executeId", required = true) String executeId,
|
||||||
|
HttpServletRequest request
|
||||||
|
) {
|
||||||
|
return Result.ok(publicChatService.cancel(
|
||||||
|
shareKey(request),
|
||||||
|
visitorId(request),
|
||||||
|
executeId
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/resume")
|
||||||
|
public Result<Void> resume(
|
||||||
|
@JsonBody(value = "executeId", required = true) String executeId,
|
||||||
|
@JsonBody("confirmParams") Map<String, Object> confirmParams,
|
||||||
|
HttpServletRequest request
|
||||||
|
) {
|
||||||
|
publicChatService.resume(
|
||||||
|
shareKey(request),
|
||||||
|
visitorId(request),
|
||||||
|
executeId,
|
||||||
|
confirmParams
|
||||||
|
);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/upload", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||||
|
public Result<UploadResVo> upload(
|
||||||
|
@RequestParam("file") MultipartFile file,
|
||||||
|
@RequestParam("parameterName") String parameterName,
|
||||||
|
HttpServletRequest request
|
||||||
|
) {
|
||||||
|
return Result.ok(publicChatService.upload(
|
||||||
|
shareKey(request),
|
||||||
|
visitorId(request),
|
||||||
|
parameterName,
|
||||||
|
file
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String shareKey(HttpServletRequest request) {
|
||||||
|
return request.getHeader(WorkflowSharePolicy.CHAT_SHARE_KEY_HEADER);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String visitorId(HttpServletRequest request) {
|
||||||
|
return request.getHeader(WorkflowSharePolicy.CHAT_VISITOR_HEADER);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package tech.easyflow.admin.controller.ai;
|
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;
|
||||||
@@ -96,11 +97,10 @@ 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) {
|
||||||
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
|
WorkflowShare share = workflowShareService.resolvePublicChatShare(
|
||||||
WorkflowShare share = workflowShareService.resolveChatShare(
|
request.getHeader(WorkflowSharePolicy.CHAT_SHARE_KEY_HEADER)
|
||||||
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()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ import java.util.List;
|
|||||||
@RequestMapping("/api/v1/datacenterDataset")
|
@RequestMapping("/api/v1/datacenterDataset")
|
||||||
public class DatacenterDatasetController {
|
public class DatacenterDatasetController {
|
||||||
|
|
||||||
|
/** 对外 Schema 接口的默认字段页码。 */
|
||||||
|
private static final long DEFAULT_FIELD_PAGE_NUMBER = 1L;
|
||||||
|
/** 对外 Schema 接口的默认字段页大小。 */
|
||||||
|
private static final long DEFAULT_FIELD_PAGE_SIZE = 200L;
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private DatacenterDatasetQueryService queryService;
|
private DatacenterDatasetQueryService queryService;
|
||||||
@Resource
|
@Resource
|
||||||
@@ -32,13 +37,18 @@ public class DatacenterDatasetController {
|
|||||||
@PostMapping("/queryPage")
|
@PostMapping("/queryPage")
|
||||||
@SaCheckPermission("/api/v1/datacenterSource/query")
|
@SaCheckPermission("/api/v1/datacenterSource/query")
|
||||||
public Result<Page<Row>> queryPage(@RequestBody DatacenterQueryRequest request) {
|
public Result<Page<Row>> queryPage(@RequestBody DatacenterQueryRequest request) {
|
||||||
return Result.ok(queryService.queryPage(request));
|
return Result.ok(queryService.queryPage(
|
||||||
|
request, SaTokenUtil.getLoginAccount()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/schema")
|
@GetMapping("/schema")
|
||||||
@SaCheckPermission("/api/v1/datacenterSource/query")
|
@SaCheckPermission("/api/v1/datacenterSource/query")
|
||||||
public Result<DatacenterSchemaResponse> schema(DatasetRef datasetRef) {
|
public Result<DatacenterSchemaResponse> schema(
|
||||||
return Result.ok(queryService.getSchema(datasetRef));
|
DatasetRef datasetRef,
|
||||||
|
@RequestParam(defaultValue = "1") Long fieldPageNumber,
|
||||||
|
@RequestParam(defaultValue = "200") Long fieldPageSize) {
|
||||||
|
return Result.ok(queryService.getSchema(
|
||||||
|
datasetRef, fieldPageNumber, fieldPageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/managedTables")
|
@GetMapping("/managedTables")
|
||||||
@@ -63,6 +73,13 @@ public class DatacenterDatasetController {
|
|||||||
request == null ? List.of() : request.getFields(),
|
request == null ? List.of() : request.getFields(),
|
||||||
account
|
account
|
||||||
);
|
);
|
||||||
return Result.ok(queryService.getSchema(registryService.resolveDatasetRef(table.getId())));
|
return Result.ok(queryService.getSchema(
|
||||||
|
registryService.resolveDatasetRef(table.getId()),
|
||||||
|
request == null || request.getFieldPageNumber() == null
|
||||||
|
? DEFAULT_FIELD_PAGE_NUMBER
|
||||||
|
: request.getFieldPageNumber(),
|
||||||
|
request == null || request.getFieldPageSize() == null
|
||||||
|
? DEFAULT_FIELD_PAGE_SIZE
|
||||||
|
: request.getFieldPageSize()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package tech.easyflow.admin.controller.datacenter;
|
||||||
|
|
||||||
|
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import tech.easyflow.common.domain.Result;
|
||||||
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
|
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||||
|
import tech.easyflow.datacenter.execution.model.DatacenterSqlConsoleRequest;
|
||||||
|
import tech.easyflow.datacenter.execution.model.DatacenterSqlConsoleResult;
|
||||||
|
import tech.easyflow.datacenter.execution.model.DatacenterSqlCancelRequest;
|
||||||
|
import tech.easyflow.datacenter.federation.DatacenterFederationQueryService;
|
||||||
|
import tech.easyflow.datacenter.federation.DatacenterFederationQueryCancellationService;
|
||||||
|
import tech.easyflow.datacenter.meta.entity.DatacenterSource;
|
||||||
|
import tech.easyflow.datacenter.meta.service.DatacenterDatasetRegistryService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据中枢管理端只读 SQL 控制台。
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/datacenterQuery")
|
||||||
|
public class DatacenterQueryController {
|
||||||
|
|
||||||
|
private final DatacenterDatasetRegistryService registryService;
|
||||||
|
private final DatacenterFederationQueryService queryService;
|
||||||
|
private final DatacenterFederationQueryCancellationService cancellationService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建查询 Controller。
|
||||||
|
*
|
||||||
|
* @param registryService 数据集注册服务
|
||||||
|
* @param queryService Federation 查询服务
|
||||||
|
* @param cancellationService 跨节点查询取消服务
|
||||||
|
*/
|
||||||
|
public DatacenterQueryController(
|
||||||
|
DatacenterDatasetRegistryService registryService,
|
||||||
|
DatacenterFederationQueryService queryService,
|
||||||
|
DatacenterFederationQueryCancellationService cancellationService) {
|
||||||
|
this.registryService = registryService;
|
||||||
|
this.queryService = queryService;
|
||||||
|
this.cancellationService = cancellationService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行一条受 Calcite 与业务 Policy 校验的只读 SQL。
|
||||||
|
*
|
||||||
|
* @param request 查询请求
|
||||||
|
* @return 有界查询结果
|
||||||
|
*/
|
||||||
|
@PostMapping("/execute")
|
||||||
|
@SaCheckPermission("/api/v1/datacenterSource/query")
|
||||||
|
public Result<DatacenterSqlConsoleResult> execute(
|
||||||
|
@RequestBody DatacenterSqlConsoleRequest request) {
|
||||||
|
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||||
|
DatacenterSource source = registryService.getSourceRequired(
|
||||||
|
request == null ? null : request.sourceId());
|
||||||
|
return Result.ok(queryService.execute(
|
||||||
|
source,
|
||||||
|
request == null ? null : request.sql(),
|
||||||
|
java.util.List.of(),
|
||||||
|
request == null ? null : request.maxRows(),
|
||||||
|
account,
|
||||||
|
"MANUAL",
|
||||||
|
account == null || account.getId() == null
|
||||||
|
? null : account.getId().toString(),
|
||||||
|
request == null ? null : request.queryId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消当前租户在任一节点执行的 SQL 查询。
|
||||||
|
*
|
||||||
|
* @param request 取消请求
|
||||||
|
* @return 是否在本地或集群中接受取消提示
|
||||||
|
*/
|
||||||
|
@PostMapping("/cancel")
|
||||||
|
@SaCheckPermission("/api/v1/datacenterSource/query")
|
||||||
|
public Result<Boolean> cancel(@RequestBody DatacenterSqlCancelRequest request) {
|
||||||
|
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||||
|
return Result.ok(cancellationService.cancel(
|
||||||
|
request == null ? null : request.queryId(), account));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,10 +8,16 @@ import tech.easyflow.common.entity.LoginAccount;
|
|||||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||||
import tech.easyflow.datacenter.entity.DatacenterTable;
|
import tech.easyflow.datacenter.entity.DatacenterTable;
|
||||||
import tech.easyflow.datacenter.execution.model.DatacenterConnectionTestResult;
|
import tech.easyflow.datacenter.execution.model.DatacenterConnectionTestResult;
|
||||||
import tech.easyflow.datacenter.meta.entity.DatacenterSource;
|
|
||||||
import tech.easyflow.datacenter.meta.model.DatacenterBatchRegisterRequest;
|
import tech.easyflow.datacenter.meta.model.DatacenterBatchRegisterRequest;
|
||||||
import tech.easyflow.datacenter.meta.model.DatacenterCatalogMeta;
|
import tech.easyflow.datacenter.meta.model.DatacenterCatalogMeta;
|
||||||
|
import tech.easyflow.datacenter.meta.model.DatacenterMetadataPage;
|
||||||
import tech.easyflow.datacenter.meta.model.DatacenterRemoveSourceRequest;
|
import tech.easyflow.datacenter.meta.model.DatacenterRemoveSourceRequest;
|
||||||
|
import tech.easyflow.datacenter.meta.model.DatacenterSourceActivateRequest;
|
||||||
|
import tech.easyflow.datacenter.meta.model.DatacenterSourceCandidateMetadataRequest;
|
||||||
|
import tech.easyflow.datacenter.meta.model.DatacenterSourceCandidateCatalogRequest;
|
||||||
|
import tech.easyflow.datacenter.meta.model.DatacenterSourceDraftRequest;
|
||||||
|
import tech.easyflow.datacenter.meta.model.DatacenterSourceReconfigureRequest;
|
||||||
|
import tech.easyflow.datacenter.meta.model.DatacenterSourceView;
|
||||||
import tech.easyflow.datacenter.meta.model.DatacenterTableDetailMeta;
|
import tech.easyflow.datacenter.meta.model.DatacenterTableDetailMeta;
|
||||||
import tech.easyflow.datacenter.meta.service.DatacenterSourceService;
|
import tech.easyflow.datacenter.meta.service.DatacenterSourceService;
|
||||||
|
|
||||||
@@ -19,6 +25,9 @@ import javax.annotation.Resource;
|
|||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据源绑定、生命周期与元数据浏览接口。
|
||||||
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/datacenterSource")
|
@RequestMapping("/api/v1/datacenterSource")
|
||||||
public class DatacenterSourceController {
|
public class DatacenterSourceController {
|
||||||
@@ -26,23 +35,100 @@ public class DatacenterSourceController {
|
|||||||
@Resource
|
@Resource
|
||||||
private DatacenterSourceService sourceService;
|
private DatacenterSourceService sourceService;
|
||||||
|
|
||||||
@PostMapping("/testConnection")
|
@PostMapping("/draft")
|
||||||
@SaCheckPermission("/api/v1/datacenterSource/query")
|
@SaCheckPermission("/api/v1/datacenterSource/save")
|
||||||
public Result<DatacenterConnectionTestResult> testConnection(@RequestBody DatacenterSource source) {
|
public Result<DatacenterSourceView> saveDraft(@RequestBody DatacenterSourceDraftRequest request) {
|
||||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||||
return Result.ok(sourceService.testConnection(source, account));
|
return Result.ok(sourceService.saveDraft(request, account));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/save")
|
@PostMapping("/{sourceId}/probe")
|
||||||
@SaCheckPermission("/api/v1/datacenterSource/save")
|
@SaCheckPermission("/api/v1/datacenterSource/query")
|
||||||
public Result<DatacenterSource> save(@RequestBody DatacenterSource source) {
|
public Result<DatacenterConnectionTestResult> probe(@PathVariable BigInteger sourceId) {
|
||||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||||
return Result.ok(sourceService.saveSource(source, account));
|
return Result.ok(sourceService.probe(sourceId, account));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/activate")
|
||||||
|
@SaCheckPermission("/api/v1/datacenterSource/save")
|
||||||
|
public Result<DatacenterSourceView> activate(@RequestBody DatacenterSourceActivateRequest request) {
|
||||||
|
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||||
|
return Result.ok(sourceService.activate(request, account));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 探测活动数据源的未发布候选配置。
|
||||||
|
*
|
||||||
|
* @param request 候选连接配置
|
||||||
|
* @return 连接探测结果
|
||||||
|
*/
|
||||||
|
@PostMapping("/candidate/probe")
|
||||||
|
@SaCheckPermission("/api/v1/datacenterSource/save")
|
||||||
|
public Result<DatacenterConnectionTestResult> probeCandidate(
|
||||||
|
@RequestBody DatacenterSourceDraftRequest request) {
|
||||||
|
return Result.ok(sourceService.probeCandidate(
|
||||||
|
request, SaTokenUtil.getLoginAccount()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 浏览活动数据源候选配置可访问的命名空间。
|
||||||
|
*
|
||||||
|
* @param request 候选连接配置
|
||||||
|
* @return 命名空间列表
|
||||||
|
*/
|
||||||
|
@PostMapping("/candidate/catalogs")
|
||||||
|
@SaCheckPermission("/api/v1/datacenterSource/save")
|
||||||
|
public Result<List<DatacenterCatalogMeta>> candidateCatalogs(
|
||||||
|
@RequestBody DatacenterSourceDraftRequest request) {
|
||||||
|
return Result.ok(sourceService.listCandidateCatalogs(
|
||||||
|
request, SaTokenUtil.getLoginAccount()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页浏览候选配置可访问的命名空间。
|
||||||
|
*
|
||||||
|
* @param request 候选配置和分页条件
|
||||||
|
* @return 有界命名空间列表
|
||||||
|
*/
|
||||||
|
@PostMapping("/candidate/catalogs/page")
|
||||||
|
@SaCheckPermission("/api/v1/datacenterSource/save")
|
||||||
|
public Result<DatacenterMetadataPage<DatacenterCatalogMeta>> candidateCatalogsPage(
|
||||||
|
@RequestBody DatacenterSourceCandidateCatalogRequest request) {
|
||||||
|
return Result.ok(sourceService.listCandidateCatalogsPage(
|
||||||
|
request, SaTokenUtil.getLoginAccount()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页浏览活动数据源候选配置可访问的表。
|
||||||
|
*
|
||||||
|
* @param request 候选配置与分页条件
|
||||||
|
* @return 有界表列表
|
||||||
|
*/
|
||||||
|
@PostMapping("/candidate/tables")
|
||||||
|
@SaCheckPermission("/api/v1/datacenterSource/save")
|
||||||
|
public Result<DatacenterMetadataPage<DatacenterTable>> candidateTables(
|
||||||
|
@RequestBody DatacenterSourceCandidateMetadataRequest request) {
|
||||||
|
return Result.ok(sourceService.listCandidateTables(
|
||||||
|
request, SaTokenUtil.getLoginAccount()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原地发布活动数据源的新连接配置和纳管范围。
|
||||||
|
*
|
||||||
|
* @param request 重配置请求
|
||||||
|
* @return 发布后的数据源视图
|
||||||
|
*/
|
||||||
|
@PostMapping("/reconfigure")
|
||||||
|
@SaCheckPermission("/api/v1/datacenterSource/save")
|
||||||
|
public Result<DatacenterSourceView> reconfigure(
|
||||||
|
@RequestBody DatacenterSourceReconfigureRequest request) {
|
||||||
|
return Result.ok(sourceService.reconfigure(
|
||||||
|
request, SaTokenUtil.getLoginAccount()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/page")
|
@GetMapping("/page")
|
||||||
@SaCheckPermission("/api/v1/datacenterSource/query")
|
@SaCheckPermission("/api/v1/datacenterSource/query")
|
||||||
public Result<Page<DatacenterSource>> page(Long pageNumber, Long pageSize) {
|
public Result<Page<DatacenterSourceView>> page(Long pageNumber, Long pageSize) {
|
||||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||||
return Result.ok(sourceService.pageSources(pageNumber, pageSize, account));
|
return Result.ok(sourceService.pageSources(pageNumber, pageSize, account));
|
||||||
}
|
}
|
||||||
@@ -54,19 +140,53 @@ public class DatacenterSourceController {
|
|||||||
return Result.ok(sourceService.listCatalogs(sourceId, account));
|
return Result.ok(sourceService.listCatalogs(sourceId, account));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页浏览当前数据源的命名空间。
|
||||||
|
*
|
||||||
|
* @param sourceId 数据源 ID
|
||||||
|
* @param keyword 名称搜索词
|
||||||
|
* @param pageNumber 页码
|
||||||
|
* @param pageSize 每页大小
|
||||||
|
* @return 有界命名空间列表
|
||||||
|
*/
|
||||||
|
@GetMapping("/catalogs/page")
|
||||||
|
@SaCheckPermission("/api/v1/datacenterSource/query")
|
||||||
|
public Result<DatacenterMetadataPage<DatacenterCatalogMeta>> catalogsPage(
|
||||||
|
BigInteger sourceId,
|
||||||
|
String keyword,
|
||||||
|
Long pageNumber,
|
||||||
|
Long pageSize) {
|
||||||
|
return Result.ok(sourceService.listCatalogsPage(
|
||||||
|
sourceId, keyword, pageNumber, pageSize,
|
||||||
|
SaTokenUtil.getLoginAccount()));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/tables")
|
@GetMapping("/tables")
|
||||||
@SaCheckPermission("/api/v1/datacenterSource/query")
|
@SaCheckPermission("/api/v1/datacenterSource/query")
|
||||||
public Result<List<DatacenterTable>> tables(BigInteger sourceId, String catalogName) {
|
public Result<DatacenterMetadataPage<DatacenterTable>> tables(
|
||||||
|
BigInteger sourceId,
|
||||||
|
String catalogName,
|
||||||
|
String keyword,
|
||||||
|
Long pageNumber,
|
||||||
|
Long pageSize) {
|
||||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||||
return Result.ok(sourceService.listTables(sourceId, catalogName, account));
|
return Result.ok(sourceService.listTables(
|
||||||
|
sourceId, catalogName, keyword, pageNumber, pageSize, account));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/tableDetail")
|
@GetMapping("/tableDetail")
|
||||||
@SaCheckPermission("/api/v1/datacenterSource/query")
|
@SaCheckPermission("/api/v1/datacenterSource/query")
|
||||||
public Result<DatacenterTableDetailMeta> tableDetail(BigInteger sourceId, String catalogName, String tableName,
|
public Result<DatacenterTableDetailMeta> tableDetail(
|
||||||
@RequestParam(defaultValue = "false") boolean register) {
|
BigInteger sourceId,
|
||||||
|
String catalogName,
|
||||||
|
String tableName,
|
||||||
|
@RequestParam(defaultValue = "false") boolean register,
|
||||||
|
Long fieldPageNumber,
|
||||||
|
Long fieldPageSize) {
|
||||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||||
return Result.ok(sourceService.getTableDetail(sourceId, catalogName, tableName, register, account));
|
return Result.ok(sourceService.getTableDetail(
|
||||||
|
sourceId, catalogName, tableName, register,
|
||||||
|
fieldPageNumber, fieldPageSize, account));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/registerBatch")
|
@PostMapping("/registerBatch")
|
||||||
@@ -83,4 +203,44 @@ public class DatacenterSourceController {
|
|||||||
sourceService.removeSource(request == null ? null : request.getSourceId(), account);
|
sourceService.removeSource(request == null ? null : request.getSourceId(), account);
|
||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停用活动数据源。
|
||||||
|
*
|
||||||
|
* @param sourceId 数据源 ID
|
||||||
|
* @return 停用后的数据源视图
|
||||||
|
*/
|
||||||
|
@PostMapping("/{sourceId}/disable")
|
||||||
|
@SaCheckPermission("/api/v1/datacenterSource/save")
|
||||||
|
public Result<DatacenterSourceView> disable(@PathVariable BigInteger sourceId) {
|
||||||
|
return Result.ok(sourceService.disable(
|
||||||
|
sourceId, SaTokenUtil.getLoginAccount()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重新启用已停用数据源。
|
||||||
|
*
|
||||||
|
* @param sourceId 数据源 ID
|
||||||
|
* @return 启用后的数据源视图
|
||||||
|
*/
|
||||||
|
@PostMapping("/{sourceId}/enable")
|
||||||
|
@SaCheckPermission("/api/v1/datacenterSource/save")
|
||||||
|
public Result<DatacenterSourceView> enable(@PathVariable BigInteger sourceId) {
|
||||||
|
return Result.ok(sourceService.enable(
|
||||||
|
sourceId, SaTokenUtil.getLoginAccount()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新已纳管对象的 JDBC 元数据观测状态。
|
||||||
|
*
|
||||||
|
* @param sourceId 数据源 ID
|
||||||
|
* @return 刷新后的数据源视图
|
||||||
|
*/
|
||||||
|
@PostMapping("/{sourceId}/metadata/refresh")
|
||||||
|
@SaCheckPermission("/api/v1/datacenterSource/save")
|
||||||
|
public Result<DatacenterSourceView> refreshMetadata(
|
||||||
|
@PathVariable BigInteger sourceId) {
|
||||||
|
return Result.ok(sourceService.refreshMetadata(
|
||||||
|
sourceId, SaTokenUtil.getLoginAccount()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package tech.easyflow.admin.controller.dataspace;
|
||||||
|
|
||||||
|
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import tech.easyflow.common.domain.Result;
|
||||||
|
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
import tech.easyflow.dataspace.model.ConnectionDefinition;
|
||||||
|
import tech.easyflow.dataspace.model.ConnectionView;
|
||||||
|
import tech.easyflow.dataspace.model.ObjectView;
|
||||||
|
import tech.easyflow.dataspace.provider.DataspaceProbe;
|
||||||
|
import tech.easyflow.dataspace.service.DataspaceConnectionService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间物理连接管理端 API。
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/dataspaceConnection")
|
||||||
|
public class DataspaceConnectionController {
|
||||||
|
|
||||||
|
private final DataspaceConnectionService connectionService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建连接控制器。
|
||||||
|
*
|
||||||
|
* @param connectionService 连接服务
|
||||||
|
*/
|
||||||
|
public DataspaceConnectionController(DataspaceConnectionService connectionService) {
|
||||||
|
this.connectionService = connectionService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询当前租户连接列表。
|
||||||
|
*
|
||||||
|
* @param keyword 搜索关键词
|
||||||
|
* @return 连接列表
|
||||||
|
*/
|
||||||
|
@GetMapping("/list")
|
||||||
|
@SaCheckPermission("/api/v1/dataspaceConnection/query")
|
||||||
|
public Result<List<ConnectionView>> list(String keyword) {
|
||||||
|
return Result.ok(connectionService.list(keyword));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取连接详情。
|
||||||
|
*
|
||||||
|
* @param id 连接 ID
|
||||||
|
* @return 连接详情
|
||||||
|
*/
|
||||||
|
@GetMapping("/detail")
|
||||||
|
@SaCheckPermission("/api/v1/dataspaceConnection/query")
|
||||||
|
public Result<ConnectionView> detail(BigInteger id) {
|
||||||
|
return Result.ok(connectionService.detail(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试候选或已保存连接。
|
||||||
|
*
|
||||||
|
* @param definition 连接定义
|
||||||
|
* @return 测试结果
|
||||||
|
*/
|
||||||
|
@PostMapping("/test")
|
||||||
|
@SaCheckPermission("/api/v1/dataspaceConnection/test")
|
||||||
|
public Result<DataspaceProbe> test(
|
||||||
|
@JsonBody(required = true, skipConvertError = false) ConnectionDefinition definition) {
|
||||||
|
return Result.ok(connectionService.test(definition));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建或更新连接。
|
||||||
|
*
|
||||||
|
* @param definition 连接定义
|
||||||
|
* @return 保存后的连接
|
||||||
|
*/
|
||||||
|
@PostMapping("/save")
|
||||||
|
@SaCheckPermission("/api/v1/dataspaceConnection/save")
|
||||||
|
public Result<ConnectionView> save(
|
||||||
|
@JsonBody(required = true, skipConvertError = false) ConnectionDefinition definition) {
|
||||||
|
return Result.ok(connectionService.save(definition));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启用或禁用连接。
|
||||||
|
*
|
||||||
|
* @param request 状态变更请求
|
||||||
|
* @return 变更后的连接
|
||||||
|
*/
|
||||||
|
@PostMapping("/status")
|
||||||
|
@SaCheckPermission("/api/v1/dataspaceConnection/save")
|
||||||
|
public Result<ConnectionView> status(
|
||||||
|
@JsonBody(required = true, skipConvertError = false) StatusRequest request) {
|
||||||
|
if (request == null || request.enabled() == null) {
|
||||||
|
throw new BusinessException("连接状态不能为空");
|
||||||
|
}
|
||||||
|
return Result.ok(connectionService.setEnabled(request.id(), request.enabled()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询当前连接的对象树数据。
|
||||||
|
*
|
||||||
|
* @param connectionId 连接 ID
|
||||||
|
* @param keyword Schema 或表名关键词
|
||||||
|
* @return 对象列表
|
||||||
|
*/
|
||||||
|
@GetMapping("/objects")
|
||||||
|
@SaCheckPermission("/api/v1/dataspaceConnection/query")
|
||||||
|
public Result<List<ObjectView>> objects(BigInteger connectionId, String keyword) {
|
||||||
|
return Result.ok(connectionService.objects(connectionId, keyword));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新连接元数据。
|
||||||
|
*
|
||||||
|
* @param request 刷新请求
|
||||||
|
* @return 新 revision 对象列表
|
||||||
|
*/
|
||||||
|
@PostMapping("/refreshMetadata")
|
||||||
|
@SaCheckPermission("/api/v1/dataspaceConnection/metadata")
|
||||||
|
public Result<List<ObjectView>> refreshMetadata(
|
||||||
|
@JsonBody(required = true, skipConvertError = false) RefreshRequest request) {
|
||||||
|
return Result.ok(connectionService.refreshMetadata(
|
||||||
|
request.connectionId(), request.expectedRevision()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除未被引用的连接。
|
||||||
|
*
|
||||||
|
* @param id 连接 ID
|
||||||
|
* @return 成功结果
|
||||||
|
*/
|
||||||
|
@PostMapping("/remove")
|
||||||
|
@SaCheckPermission("/api/v1/dataspaceConnection/remove")
|
||||||
|
public Result<Void> remove(
|
||||||
|
@JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id) {
|
||||||
|
connectionService.remove(id);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 元数据刷新请求。
|
||||||
|
*
|
||||||
|
* @param connectionId 连接 ID
|
||||||
|
* @param expectedRevision 期望 revision
|
||||||
|
*/
|
||||||
|
public record RefreshRequest(BigInteger connectionId, long expectedRevision) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接状态变更请求。
|
||||||
|
*
|
||||||
|
* @param id 连接 ID
|
||||||
|
* @param enabled 是否启用
|
||||||
|
*/
|
||||||
|
public record StatusRequest(BigInteger id, Boolean enabled) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package tech.easyflow.admin.controller.dataspace;
|
||||||
|
|
||||||
|
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import tech.easyflow.common.domain.Result;
|
||||||
|
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
import tech.easyflow.dataspace.model.DataspaceDefinition;
|
||||||
|
import tech.easyflow.dataspace.model.DataspaceSummary;
|
||||||
|
import tech.easyflow.dataspace.model.DataspaceView;
|
||||||
|
import tech.easyflow.dataspace.service.DataspaceService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 虚拟数据空间管理端 API。
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/dataspace")
|
||||||
|
public class DataspaceController {
|
||||||
|
|
||||||
|
private final DataspaceService dataspaceService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建数据空间控制器。
|
||||||
|
*
|
||||||
|
* @param dataspaceService 数据空间服务
|
||||||
|
*/
|
||||||
|
public DataspaceController(DataspaceService dataspaceService) {
|
||||||
|
this.dataspaceService = dataspaceService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询数据空间列表。
|
||||||
|
*
|
||||||
|
* @param keyword 搜索关键词
|
||||||
|
* @return 数据空间摘要
|
||||||
|
*/
|
||||||
|
@GetMapping("/list")
|
||||||
|
@SaCheckPermission("/api/v1/dataspace/query")
|
||||||
|
public Result<List<DataspaceSummary>> list(String keyword) {
|
||||||
|
return Result.ok(dataspaceService.list(keyword));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取数据空间当前 revision 详情。
|
||||||
|
*
|
||||||
|
* @param id 数据空间 ID
|
||||||
|
* @return 数据空间详情
|
||||||
|
*/
|
||||||
|
@GetMapping("/detail")
|
||||||
|
@SaCheckPermission("/api/v1/dataspace/detail")
|
||||||
|
public Result<DataspaceView> detail(BigInteger id) {
|
||||||
|
return Result.ok(dataspaceService.detail(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存数据空间并生成新 revision。
|
||||||
|
*
|
||||||
|
* @param definition 数据空间定义
|
||||||
|
* @return 保存后的详情
|
||||||
|
*/
|
||||||
|
@PostMapping("/save")
|
||||||
|
@SaCheckPermission("/api/v1/dataspace/save")
|
||||||
|
public Result<DataspaceView> save(
|
||||||
|
@RequestBody DataspaceDefinition definition) {
|
||||||
|
return Result.ok(dataspaceService.save(definition));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启用或禁用数据空间。
|
||||||
|
*
|
||||||
|
* @param request 状态变更请求
|
||||||
|
* @return 成功结果
|
||||||
|
*/
|
||||||
|
@PostMapping("/status")
|
||||||
|
@SaCheckPermission("/api/v1/dataspace/save")
|
||||||
|
public Result<Void> status(
|
||||||
|
@JsonBody(required = true, skipConvertError = false) StatusRequest request) {
|
||||||
|
if (request == null || request.enabled() == null) {
|
||||||
|
throw new BusinessException("数据空间状态不能为空");
|
||||||
|
}
|
||||||
|
dataspaceService.setEnabled(request.id(), request.enabled());
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 逻辑删除数据空间。
|
||||||
|
*
|
||||||
|
* @param id 数据空间 ID
|
||||||
|
* @return 成功结果
|
||||||
|
*/
|
||||||
|
@PostMapping("/remove")
|
||||||
|
@SaCheckPermission("/api/v1/dataspace/remove")
|
||||||
|
public Result<Void> remove(
|
||||||
|
@JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id) {
|
||||||
|
dataspaceService.remove(id);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间状态变更请求。
|
||||||
|
*
|
||||||
|
* @param id 数据空间 ID
|
||||||
|
* @param enabled 是否启用
|
||||||
|
*/
|
||||||
|
public record StatusRequest(BigInteger id, Boolean enabled) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package tech.easyflow.admin.controller.dataspace;
|
||||||
|
|
||||||
|
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import tech.easyflow.common.domain.Result;
|
||||||
|
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||||
|
import tech.easyflow.dataspace.model.DataspaceExplainResult;
|
||||||
|
import tech.easyflow.dataspace.model.DataspaceQueryRequest;
|
||||||
|
import tech.easyflow.dataspace.model.DataspaceQueryResult;
|
||||||
|
import tech.easyflow.dataspace.model.DataspaceSqlCompletionRequest;
|
||||||
|
import tech.easyflow.dataspace.model.DataspaceSqlCompletionResult;
|
||||||
|
import tech.easyflow.dataspace.service.DataspaceQueryService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间 SQL 工作台 Query、Explain、Complete 与 Cancel API。
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/dataspaceSql")
|
||||||
|
public class DataspaceSqlController {
|
||||||
|
|
||||||
|
private final DataspaceQueryService queryService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 SQL 控制器。
|
||||||
|
*
|
||||||
|
* @param queryService 查询服务
|
||||||
|
*/
|
||||||
|
public DataspaceSqlController(DataspaceQueryService queryService) {
|
||||||
|
this.queryService = queryService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行只读 SQL。
|
||||||
|
*
|
||||||
|
* @param request 查询请求
|
||||||
|
* @return 查询结果与指标
|
||||||
|
*/
|
||||||
|
@PostMapping("/query")
|
||||||
|
@SaCheckPermission("/api/v1/dataspaceSql/query")
|
||||||
|
public Result<DataspaceQueryResult> query(
|
||||||
|
@JsonBody(required = true, skipConvertError = false) DataspaceQueryRequest request) {
|
||||||
|
return Result.ok(queryService.query(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显式执行非 ANALYZE Explain。
|
||||||
|
*
|
||||||
|
* @param request Explain 请求
|
||||||
|
* @return Explain 与索引信息
|
||||||
|
*/
|
||||||
|
@PostMapping("/explain")
|
||||||
|
@SaCheckPermission("/api/v1/dataspaceSql/explain")
|
||||||
|
public Result<DataspaceExplainResult> explain(
|
||||||
|
@JsonBody(required = true, skipConvertError = false) DataspaceQueryRequest request) {
|
||||||
|
return Result.ok(queryService.explain(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回当前数据空间内的 Calcite SQL 补全候选。
|
||||||
|
*
|
||||||
|
* @param request 补全请求
|
||||||
|
* @return 补全替换区间与候选
|
||||||
|
*/
|
||||||
|
@PostMapping("/complete")
|
||||||
|
@SaCheckPermission("/api/v1/dataspaceSql/query")
|
||||||
|
public Result<DataspaceSqlCompletionResult> complete(
|
||||||
|
@JsonBody(required = true, skipConvertError = false)
|
||||||
|
DataspaceSqlCompletionRequest request) {
|
||||||
|
return Result.ok(queryService.complete(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尝试取消当前节点查询。
|
||||||
|
*
|
||||||
|
* @param queryId 查询 ID
|
||||||
|
* @return 是否找到并发起取消
|
||||||
|
*/
|
||||||
|
@PostMapping("/cancel")
|
||||||
|
@SaCheckPermission("/api/v1/dataspaceSql/query")
|
||||||
|
public Result<Boolean> cancel(
|
||||||
|
@JsonBody(value = "queryId", required = true, skipConvertError = false) String queryId) {
|
||||||
|
return Result.ok(queryService.cancel(queryId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +1,31 @@
|
|||||||
package tech.easyflow.admin.controller.job;
|
package tech.easyflow.admin.controller.job;
|
||||||
|
|
||||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||||
import cn.hutool.core.date.DateUtil;
|
|
||||||
import com.easyagents.flow.core.chain.Parameter;
|
import com.easyagents.flow.core.chain.Parameter;
|
||||||
import com.mybatisflex.core.query.QueryWrapper;
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import org.quartz.CronExpression;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
||||||
import tech.easyflow.ai.entity.Workflow;
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
|
import tech.easyflow.ai.enums.PublishStatus;
|
||||||
import tech.easyflow.ai.service.WorkflowService;
|
import tech.easyflow.ai.service.WorkflowService;
|
||||||
import tech.easyflow.ai.service.WorkflowUsageAuthorizationService;
|
import tech.easyflow.ai.service.WorkflowUsageAuthorizationService;
|
||||||
import tech.easyflow.admin.model.SysJobWorkflowOptionView;
|
import tech.easyflow.admin.model.SysJobWorkflowOptionView;
|
||||||
import tech.easyflow.common.constant.enums.EnumDataStatus;
|
import tech.easyflow.common.constant.enums.EnumJobStatus;
|
||||||
import tech.easyflow.common.constant.enums.EnumJobType;
|
import tech.easyflow.common.constant.enums.EnumJobType;
|
||||||
|
import tech.easyflow.common.constant.enums.EnumMisfirePolicy;
|
||||||
import tech.easyflow.common.domain.Result;
|
import tech.easyflow.common.domain.Result;
|
||||||
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.controller.BaseCurdController;
|
import tech.easyflow.common.web.controller.BaseCurdController;
|
||||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||||
import tech.easyflow.job.entity.SysJob;
|
import tech.easyflow.job.entity.SysJob;
|
||||||
import tech.easyflow.job.job.JobConstant;
|
import tech.easyflow.job.job.JobConstant;
|
||||||
import tech.easyflow.job.service.SysJobService;
|
import tech.easyflow.job.service.SysJobService;
|
||||||
@@ -32,7 +37,8 @@ import tech.easyflow.system.service.ResourceAccessService;
|
|||||||
|
|
||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
import java.util.ArrayList;
|
import java.time.ZoneId;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -61,6 +67,9 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
|
|||||||
/** 工作流运行参数解析器。 */
|
/** 工作流运行参数解析器。 */
|
||||||
private final WorkflowRunningParameterResolver workflowRunningParameterResolver;
|
private final WorkflowRunningParameterResolver workflowRunningParameterResolver;
|
||||||
|
|
||||||
|
/** 与调度计算一致的 Cron 预览格式化器。 */
|
||||||
|
private final DateTimeFormatter jobTimeFormatter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建定时任务控制器。
|
* 创建定时任务控制器。
|
||||||
*
|
*
|
||||||
@@ -69,17 +78,21 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
|
|||||||
* @param workflowUsageAuthorizationService 工作流使用权限校验服务
|
* @param workflowUsageAuthorizationService 工作流使用权限校验服务
|
||||||
* @param resourceAccessService 资源访问控制服务
|
* @param resourceAccessService 资源访问控制服务
|
||||||
* @param workflowRunningParameterResolver 工作流运行参数解析器
|
* @param workflowRunningParameterResolver 工作流运行参数解析器
|
||||||
|
* @param jobTimezone 定时任务业务时区
|
||||||
*/
|
*/
|
||||||
public SysJobController(SysJobService service,
|
public SysJobController(SysJobService service,
|
||||||
WorkflowService workflowService,
|
WorkflowService workflowService,
|
||||||
WorkflowUsageAuthorizationService workflowUsageAuthorizationService,
|
WorkflowUsageAuthorizationService workflowUsageAuthorizationService,
|
||||||
ResourceAccessService resourceAccessService,
|
ResourceAccessService resourceAccessService,
|
||||||
WorkflowRunningParameterResolver workflowRunningParameterResolver) {
|
WorkflowRunningParameterResolver workflowRunningParameterResolver,
|
||||||
|
@Value("${easyflow.job.timezone:Asia/Shanghai}") String jobTimezone) {
|
||||||
super(service);
|
super(service);
|
||||||
this.workflowService = workflowService;
|
this.workflowService = workflowService;
|
||||||
this.workflowUsageAuthorizationService = workflowUsageAuthorizationService;
|
this.workflowUsageAuthorizationService = workflowUsageAuthorizationService;
|
||||||
this.resourceAccessService = resourceAccessService;
|
this.resourceAccessService = resourceAccessService;
|
||||||
this.workflowRunningParameterResolver = workflowRunningParameterResolver;
|
this.workflowRunningParameterResolver = workflowRunningParameterResolver;
|
||||||
|
this.jobTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||||
|
.withZone(ZoneId.of(jobTimezone));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -111,18 +124,43 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
|
|||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/trigger")
|
||||||
|
@SaCheckPermission("/api/v1/sysJob/save")
|
||||||
|
@LogRecord("立即执行定时任务")
|
||||||
|
public Result<String> trigger(BigInteger id) {
|
||||||
|
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||||
|
SysJob job = requireExistingJob(id);
|
||||||
|
validateWorkflowReference(job, account);
|
||||||
|
return Result.ok(service.triggerNow(id));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/getNextTimes")
|
@GetMapping("/getNextTimes")
|
||||||
@SaCheckPermission("/api/v1/sysJob/save")
|
@SaCheckPermission("/api/v1/sysJob/save")
|
||||||
public Result<List<String>> getNextTimes(String cronExpression) throws Exception{
|
public Result<List<String>> getNextTimes(String cronExpression) {
|
||||||
CronExpression ex = new CronExpression(cronExpression);
|
return Result.ok(service.nextFireTimes(cronExpression, 5).stream()
|
||||||
List<String> times = new ArrayList<>();
|
.map(Date::toInstant)
|
||||||
Date date = new Date();
|
.map(jobTimeFormatter::format)
|
||||||
for (int i = 0; i < 5; i++) {
|
.toList());
|
||||||
Date next = ex.getNextValidTimeAfter(date);
|
}
|
||||||
times.add(DateUtil.formatDateTime(next));
|
|
||||||
date = next;
|
@Override
|
||||||
|
@PostMapping("remove")
|
||||||
|
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||||
|
public Result<?> remove(@JsonBody(value = "id", required = true) Serializable id) {
|
||||||
|
service.deleteJob(List.of(id));
|
||||||
|
return Result.ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@PostMapping("removeBatch")
|
||||||
|
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||||
|
public Result<?> removeBatch(
|
||||||
|
@JsonBody(value = "ids", required = true) Collection<Serializable> ids) {
|
||||||
|
if (ids == null || ids.isEmpty()) {
|
||||||
|
return Result.fail("id不能为空");
|
||||||
}
|
}
|
||||||
return Result.ok(times);
|
service.deleteJob(ids);
|
||||||
|
return Result.ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -136,15 +174,19 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
|
|||||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||||
List<SysJobWorkflowOptionView> options = workflowService.list(QueryWrapper.create()
|
List<SysJobWorkflowOptionView> options = workflowService.list(QueryWrapper.create()
|
||||||
.eq(Workflow::getTenantId, account.getTenantId())
|
.eq(Workflow::getTenantId, account.getTenantId())
|
||||||
.eq(Workflow::getStatus, EnumDataStatus.AVAILABLE.getCode())
|
.eq(Workflow::getPublishStatus, PublishStatus.PUBLISHED.getCode())
|
||||||
.orderBy(Workflow::getModified, false))
|
.orderBy(Workflow::getModified, false))
|
||||||
.stream()
|
.stream()
|
||||||
.filter(workflow -> Objects.equals(workflow.getTenantId(), account.getTenantId()))
|
.filter(workflow -> Objects.equals(workflow.getTenantId(), account.getTenantId()))
|
||||||
|
.filter(workflow -> workflow.getPublishedSnapshotJson() != null
|
||||||
|
&& !workflow.getPublishedSnapshotJson().isEmpty())
|
||||||
.filter(workflow -> resourceAccessService.canAccess(
|
.filter(workflow -> resourceAccessService.canAccess(
|
||||||
account,
|
account,
|
||||||
CategoryResourceType.WORKFLOW,
|
CategoryResourceType.WORKFLOW,
|
||||||
workflow,
|
workflow,
|
||||||
ResourceAction.USE))
|
ResourceAction.USE))
|
||||||
|
.map(workflowService::toPublishedView)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
.map(workflow -> new SysJobWorkflowOptionView(
|
.map(workflow -> new SysJobWorkflowOptionView(
|
||||||
workflow.getId(),
|
workflow.getId(),
|
||||||
workflow.getTitle(),
|
workflow.getTitle(),
|
||||||
@@ -166,7 +208,7 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
|
|||||||
Workflow workflow = workflowUsageAuthorizationService.requireUsableWorkflow(
|
Workflow workflow = workflowUsageAuthorizationService.requireUsableWorkflow(
|
||||||
id,
|
id,
|
||||||
SaTokenUtil.getLoginAccount(),
|
SaTokenUtil.getLoginAccount(),
|
||||||
"工作流不存在、已禁用或无权运行");
|
"工作流不存在、未发布或无权运行");
|
||||||
Map<String, Object> result = workflowRunningParameterResolver.buildRunningParametersView(workflow);
|
Map<String, Object> result = workflowRunningParameterResolver.buildRunningParametersView(workflow);
|
||||||
if (result == null) {
|
if (result == null) {
|
||||||
throw new BusinessException("工作流参数配置无效,请检查工作流后重试");
|
throw new BusinessException("工作流参数配置无效,请检查工作流后重试");
|
||||||
@@ -182,6 +224,9 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
|
|||||||
LoginAccount loginUser = SaTokenUtil.getLoginAccount();
|
LoginAccount loginUser = SaTokenUtil.getLoginAccount();
|
||||||
SysJob effectiveEntity = entity;
|
SysJob effectiveEntity = entity;
|
||||||
if (isSave) {
|
if (isSave) {
|
||||||
|
// 新任务固定从 STOP 和第 0 代开始,禁止请求绕过启动协议。
|
||||||
|
entity.setStatus(EnumJobStatus.STOP.getCode());
|
||||||
|
entity.setScheduleGeneration(0L);
|
||||||
commonFiled(entity,loginUser.getId(),loginUser.getTenantId(), loginUser.getDeptId());
|
commonFiled(entity,loginUser.getId(),loginUser.getTenantId(), loginUser.getDeptId());
|
||||||
} else {
|
} else {
|
||||||
SysJob existing = requireExistingJob(entity.getId());
|
SysJob existing = requireExistingJob(entity.getId());
|
||||||
@@ -191,9 +236,25 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
|
|||||||
entity.setModifiedBy(loginUser.getId());
|
entity.setModifiedBy(loginUser.getId());
|
||||||
}
|
}
|
||||||
validateWorkflowReference(effectiveEntity, loginUser);
|
validateWorkflowReference(effectiveEntity, loginUser);
|
||||||
|
validateCronExpression(effectiveEntity.getCronExpression());
|
||||||
|
validateMisfirePolicy(effectiveEntity.getMisfirePolicy());
|
||||||
return super.onSaveOrUpdateBefore(entity, isSave);
|
return super.onSaveOrUpdateBefore(entity, isSave);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onSaveOrUpdateAfter(SysJob entity, boolean isSave) {
|
||||||
|
service.syncJob(entity.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@PostMapping("update")
|
||||||
|
public Result<?> update(@JsonBody SysJob entity) {
|
||||||
|
Result<?> result = onSaveOrUpdateBefore(entity, false);
|
||||||
|
if (result != null) return result;
|
||||||
|
service.updateJobDefinition(entity);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 校验工作流类型任务引用的工作流可被当前用户运行。
|
* 校验工作流类型任务引用的工作流可被当前用户运行。
|
||||||
*
|
*
|
||||||
@@ -210,7 +271,7 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
|
|||||||
Workflow workflow = workflowUsageAuthorizationService.requireUsableWorkflow(
|
Workflow workflow = workflowUsageAuthorizationService.requireUsableWorkflow(
|
||||||
workflowId,
|
workflowId,
|
||||||
account,
|
account,
|
||||||
"工作流不存在、已禁用或无权运行");
|
"工作流不存在、未发布或无权运行");
|
||||||
validateRequiredWorkflowParams(entity, workflow);
|
validateRequiredWorkflowParams(entity, workflow);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,6 +304,8 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
|
|||||||
entity.setDeptId(existing.getDeptId());
|
entity.setDeptId(existing.getDeptId());
|
||||||
entity.setCreated(existing.getCreated());
|
entity.setCreated(existing.getCreated());
|
||||||
entity.setCreatedBy(existing.getCreatedBy());
|
entity.setCreatedBy(existing.getCreatedBy());
|
||||||
|
entity.setStatus(existing.getStatus());
|
||||||
|
entity.setScheduleGeneration(existing.getScheduleGeneration());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -260,9 +323,30 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
|
|||||||
effective.setJobParams(entity.getJobParams() == null
|
effective.setJobParams(entity.getJobParams() == null
|
||||||
? existing.getJobParams()
|
? existing.getJobParams()
|
||||||
: entity.getJobParams());
|
: entity.getJobParams());
|
||||||
|
effective.setCronExpression(entity.getCronExpression() == null
|
||||||
|
? existing.getCronExpression()
|
||||||
|
: entity.getCronExpression());
|
||||||
|
effective.setMisfirePolicy(entity.getMisfirePolicy() == null
|
||||||
|
? existing.getMisfirePolicy()
|
||||||
|
: entity.getMisfirePolicy());
|
||||||
return effective;
|
return effective;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void validateMisfirePolicy(Integer misfirePolicy) {
|
||||||
|
if (!Integer.valueOf(EnumMisfirePolicy.FIRE_ONCE_NOW.getCode()).equals(misfirePolicy)
|
||||||
|
&& !Integer.valueOf(EnumMisfirePolicy.SKIP.getCode()).equals(misfirePolicy)) {
|
||||||
|
throw new BusinessException("错过策略只支持恢复后补执行一次或跳过本次");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateCronExpression(String cronExpression) {
|
||||||
|
try {
|
||||||
|
service.nextFireTimes(cronExpression, 1);
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
throw new BusinessException(400, 1, "Cron 表达式无效", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 校验定时任务已填写工作流的全部必填运行参数。
|
* 校验定时任务已填写工作流的全部必填运行参数。
|
||||||
*
|
*
|
||||||
@@ -322,9 +406,4 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
protected Result onRemoveBefore(Collection<Serializable> ids) {
|
|
||||||
service.deleteJob(ids);
|
|
||||||
return super.onRemoveBefore(ids);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,29 @@
|
|||||||
package tech.easyflow.admin.controller.job;
|
package tech.easyflow.admin.controller.job;
|
||||||
|
|
||||||
import tech.easyflow.common.annotation.UsePermission;
|
import com.mybatisflex.core.paginate.Page;
|
||||||
import tech.easyflow.common.domain.Result;
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import tech.easyflow.common.entity.LoginAccount;
|
import tech.easyflow.common.annotation.UsePermission;
|
||||||
|
import tech.easyflow.common.domain.Result;
|
||||||
|
import tech.easyflow.common.util.StringUtil;
|
||||||
import tech.easyflow.common.web.controller.BaseCurdController;
|
import tech.easyflow.common.web.controller.BaseCurdController;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
import tech.easyflow.job.entity.SysJobLog;
|
import tech.easyflow.job.entity.SysJobLog;
|
||||||
import tech.easyflow.job.service.SysJobLogService;
|
import tech.easyflow.job.service.SysJobLogService;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.time.format.DateTimeParseException;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 系统任务日志 控制层。
|
* 系统任务日志 控制层。
|
||||||
*
|
*
|
||||||
@@ -20,16 +34,112 @@ import tech.easyflow.job.service.SysJobLogService;
|
|||||||
@RequestMapping("/api/v1/sysJobLog")
|
@RequestMapping("/api/v1/sysJobLog")
|
||||||
@UsePermission(moduleName = "/api/v1/sysJob")
|
@UsePermission(moduleName = "/api/v1/sysJob")
|
||||||
public class SysJobLogController extends BaseCurdController<SysJobLogService, SysJobLog> {
|
public class SysJobLogController extends BaseCurdController<SysJobLogService, SysJobLog> {
|
||||||
public SysJobLogController(SysJobLogService service) {
|
private static final long DEFAULT_PAGE_SIZE = 10L;
|
||||||
|
private static final long MAX_PAGE_SIZE = 100L;
|
||||||
|
private static final DateTimeFormatter QUERY_TIME_FORMATTER =
|
||||||
|
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||||
|
|
||||||
|
private final ZoneId jobZoneId;
|
||||||
|
|
||||||
|
public SysJobLogController(
|
||||||
|
SysJobLogService service,
|
||||||
|
@Value("${easyflow.job.timezone:Asia/Shanghai}") String jobTimezone) {
|
||||||
super(service);
|
super(service);
|
||||||
|
this.jobZoneId = ZoneId.of(jobTimezone);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造日志筛选条件,并追加计划触发时间和实际触发时间范围。
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected QueryWrapper buildQueryWrapper(HttpServletRequest request) {
|
||||||
|
QueryWrapper queryWrapper = super.buildQueryWrapper(request);
|
||||||
|
Date scheduledStart = parseQueryTime(
|
||||||
|
request.getParameter("scheduledStart"), "计划触发开始时间");
|
||||||
|
Date scheduledEnd = parseQueryTime(
|
||||||
|
request.getParameter("scheduledEnd"), "计划触发结束时间");
|
||||||
|
Date actualStart = parseQueryTime(
|
||||||
|
request.getParameter("actualStart"), "实际触发开始时间");
|
||||||
|
Date actualEnd = parseQueryTime(
|
||||||
|
request.getParameter("actualEnd"), "实际触发结束时间");
|
||||||
|
|
||||||
|
validateTimeRange(scheduledStart, scheduledEnd, "计划触发时间");
|
||||||
|
validateTimeRange(actualStart, actualEnd, "实际触发时间");
|
||||||
|
if (scheduledStart != null) {
|
||||||
|
queryWrapper.ge(SysJobLog::getScheduledFireTime, scheduledStart);
|
||||||
|
}
|
||||||
|
if (scheduledEnd != null) {
|
||||||
|
queryWrapper.le(SysJobLog::getScheduledFireTime, scheduledEnd);
|
||||||
|
}
|
||||||
|
if (actualStart != null) {
|
||||||
|
queryWrapper.ge(SysJobLog::getActualFireTime, actualStart);
|
||||||
|
}
|
||||||
|
if (actualEnd != null) {
|
||||||
|
queryWrapper.le(SysJobLog::getActualFireTime, actualEnd);
|
||||||
|
}
|
||||||
|
return queryWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自动刷新只读取当前第一页,不执行分页总数统计。
|
||||||
|
*/
|
||||||
|
@GetMapping("refresh")
|
||||||
|
public Result<List<SysJobLog>> refresh(HttpServletRequest request, Long pageSize) {
|
||||||
|
QueryWrapper queryWrapper = buildQueryWrapper(request);
|
||||||
|
queryWrapper.orderBy(buildOrderBy(null, null, getDefaultOrderBy()));
|
||||||
|
queryWrapper.limit(resolvePageSize(pageSize));
|
||||||
|
return Result.ok(service.list(queryWrapper));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最新计划触发记录优先,并用主键保证毫秒时间相同时顺序稳定。
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected String getDefaultOrderBy() {
|
||||||
|
return "scheduled_fire_time desc, id desc";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Page<SysJobLog> queryPage(
|
||||||
|
Page<SysJobLog> page, QueryWrapper queryWrapper) {
|
||||||
|
page.setPageSize(resolvePageSize(page.getPageSize()));
|
||||||
|
return super.queryPage(page, queryWrapper);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected Result onSaveOrUpdateBefore(SysJobLog entity, boolean isSave) {
|
protected Result onSaveOrUpdateBefore(SysJobLog entity, boolean isSave) {
|
||||||
LoginAccount loginUser = SaTokenUtil.getLoginAccount();
|
throw new IllegalStateException("定时任务执行记录由系统维护,禁止外部写入");
|
||||||
if (isSave) {
|
|
||||||
commonFiled(entity,loginUser.getId(),loginUser.getTenantId(), loginUser.getDeptId());
|
|
||||||
}
|
|
||||||
return super.onSaveOrUpdateBefore(entity, isSave);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@Override
|
||||||
|
protected Result onRemoveBefore(Collection<Serializable> ids) {
|
||||||
|
service.requireTerminal(ids);
|
||||||
|
return super.onRemoveBefore(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long resolvePageSize(Long pageSize) {
|
||||||
|
if (pageSize == null || pageSize < 1) {
|
||||||
|
return DEFAULT_PAGE_SIZE;
|
||||||
|
}
|
||||||
|
return Math.min(pageSize, MAX_PAGE_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Date parseQueryTime(String value, String fieldName) {
|
||||||
|
if (!StringUtil.hasText(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
LocalDateTime dateTime = LocalDateTime.parse(value, QUERY_TIME_FORMATTER);
|
||||||
|
return Date.from(dateTime.atZone(jobZoneId).toInstant());
|
||||||
|
} catch (DateTimeParseException exception) {
|
||||||
|
throw new BusinessException(
|
||||||
|
400, 400, fieldName + "格式不正确", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateTimeRange(Date start, Date end, String fieldName) {
|
||||||
|
if (start != null && end != null && start.after(end)) {
|
||||||
|
throw new BusinessException(400, 400, fieldName + "范围不正确");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,11 +52,17 @@ 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
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +132,7 @@ public record WorkflowDesignerOptionsView(
|
|||||||
* 已接入数据集安全选项。
|
* 已接入数据集安全选项。
|
||||||
*
|
*
|
||||||
* @param id 数据集 ID
|
* @param id 数据集 ID
|
||||||
|
* @param tenantId 租户 ID
|
||||||
* @param sourceId 数据源 ID
|
* @param sourceId 数据源 ID
|
||||||
* @param catalogId 目录 ID
|
* @param catalogId 目录 ID
|
||||||
* @param tableName 数据表名称
|
* @param tableName 数据表名称
|
||||||
@@ -133,6 +140,7 @@ public record WorkflowDesignerOptionsView(
|
|||||||
*/
|
*/
|
||||||
public record DatasetOption(
|
public record DatasetOption(
|
||||||
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
|
@JsonSerialize(using = ToStringSerializer.class) BigInteger id,
|
||||||
|
@JsonSerialize(using = ToStringSerializer.class) BigInteger tenantId,
|
||||||
@JsonSerialize(using = ToStringSerializer.class) BigInteger sourceId,
|
@JsonSerialize(using = ToStringSerializer.class) BigInteger sourceId,
|
||||||
@JsonSerialize(using = ToStringSerializer.class) BigInteger catalogId,
|
@JsonSerialize(using = ToStringSerializer.class) BigInteger catalogId,
|
||||||
String tableName,
|
String tableName,
|
||||||
|
|||||||
@@ -4,9 +4,14 @@ import com.alibaba.fastjson.JSON;
|
|||||||
import com.easyagents.flow.core.chain.Chain;
|
import com.easyagents.flow.core.chain.Chain;
|
||||||
import com.easyagents.flow.core.chain.ChainConsts;
|
import com.easyagents.flow.core.chain.ChainConsts;
|
||||||
import com.easyagents.flow.core.chain.ChainStatus;
|
import com.easyagents.flow.core.chain.ChainStatus;
|
||||||
|
import com.easyagents.flow.core.chain.ChainState;
|
||||||
import com.easyagents.flow.core.chain.Edge;
|
import com.easyagents.flow.core.chain.Edge;
|
||||||
import com.easyagents.flow.core.chain.Event;
|
import com.easyagents.flow.core.chain.Event;
|
||||||
import com.easyagents.flow.core.chain.Node;
|
import com.easyagents.flow.core.chain.Node;
|
||||||
|
import com.easyagents.flow.core.chain.NodeStatus;
|
||||||
|
import com.easyagents.flow.core.chain.ExceptionSummary;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.entity.WorkflowExecutionError;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowExecutionErrorMapper;
|
||||||
import com.easyagents.flow.core.chain.event.ChainStatusChangeEvent;
|
import com.easyagents.flow.core.chain.event.ChainStatusChangeEvent;
|
||||||
import com.easyagents.flow.core.chain.event.EdgeConditionCheckFailedEvent;
|
import com.easyagents.flow.core.chain.event.EdgeConditionCheckFailedEvent;
|
||||||
import com.easyagents.flow.core.chain.event.EdgeTriggerEvent;
|
import com.easyagents.flow.core.chain.event.EdgeTriggerEvent;
|
||||||
@@ -19,11 +24,17 @@ 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;
|
||||||
|
|
||||||
@@ -33,6 +44,41 @@ import java.util.concurrent.atomic.AtomicLong;
|
|||||||
@Service
|
@Service
|
||||||
public class WorkflowChatEventStream {
|
public class WorkflowChatEventStream {
|
||||||
|
|
||||||
|
public 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());
|
||||||
|
WorkflowExecutionError error = WorkflowExecutionErrorMapper.chain(state.getError(), state.getStatus());
|
||||||
|
view.put("error", error);
|
||||||
|
view.put("message", state.getStatus() == ChainStatus.SUSPEND ? state.getMessage()
|
||||||
|
: WorkflowExecutionErrorMapper.summary(error));
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static final Logger log =
|
private static final Logger log =
|
||||||
LoggerFactory.getLogger(WorkflowChatEventStream.class);
|
LoggerFactory.getLogger(WorkflowChatEventStream.class);
|
||||||
private static final long SSE_TIMEOUT_MILLIS = 30L * 60L * 1000L;
|
private static final long SSE_TIMEOUT_MILLIS = 30L * 60L * 1000L;
|
||||||
@@ -40,6 +86,15 @@ 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;
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建工作流对话事件流服务。
|
* 创建工作流对话事件流服务。
|
||||||
@@ -59,6 +114,15 @@ public class WorkflowChatEventStream {
|
|||||||
chainExecutor.addErrorListener(this::onChainError);
|
chainExecutor.addErrorListener(this::onChainError);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭断开会话清理线程并释放残留外部资源。
|
||||||
|
*/
|
||||||
|
@PreDestroy
|
||||||
|
public void shutdown() {
|
||||||
|
sessions.values().forEach(this::removeSession);
|
||||||
|
detachedSessionCleaner.shutdownNow();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动工作流并返回其 SSE 连接。
|
* 启动工作流并返回其 SSE 连接。
|
||||||
*
|
*
|
||||||
@@ -67,11 +131,53 @@ 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) {
|
||||||
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MILLIS);
|
return start(definitionId, variables, () -> {
|
||||||
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(
|
||||||
@@ -79,6 +185,9 @@ 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
|
||||||
@@ -92,6 +201,13 @@ public class WorkflowChatEventStream {
|
|||||||
return emitter;
|
return emitter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 SSE 发送器,便于验证连接生命周期。
|
||||||
|
*/
|
||||||
|
SseEmitter createEmitter() {
|
||||||
|
return new SseEmitter(SSE_TIMEOUT_MILLIS);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将工作流事件转发到对应执行流。
|
* 将工作流事件转发到对应执行流。
|
||||||
*
|
*
|
||||||
@@ -135,9 +251,9 @@ public class WorkflowChatEventStream {
|
|||||||
StreamSession session = findSession(chain);
|
StreamSession session = findSession(chain);
|
||||||
if (session != null
|
if (session != null
|
||||||
&& Objects.equals(chain.getStateInstanceId(), session.executeId)) {
|
&& Objects.equals(chain.getStateInstanceId(), session.executeId)) {
|
||||||
session.send("execution_error", Map.of(
|
WorkflowExecutionError detail = WorkflowExecutionErrorMapper.map(
|
||||||
"message", safeErrorMessage(error)
|
chain.getState().getError(), true, null, null, false);
|
||||||
));
|
session.send("execution_error", Map.of("message", detail.getMessage(), "error", detail));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,20 +278,21 @@ public class WorkflowChatEventStream {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理 SSE 连接异常,并取消尚未结束的工作流。
|
* 分离已经断开的浏览器传输,不影响工作流 Runtime。
|
||||||
*
|
*
|
||||||
* @param session 流会话
|
* @param session 流会话
|
||||||
* @param message 取消原因
|
|
||||||
*/
|
*/
|
||||||
private void disconnect(StreamSession session, String message) {
|
private void detach(StreamSession session) {
|
||||||
if (session == null || session.terminal.get()) {
|
if (session == null || session.terminal.get()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String executeId = session.executeId;
|
session.detachTransport();
|
||||||
removeSession(session);
|
if (session.detachedRetention.isZero()
|
||||||
if (executeId != null) {
|
|| session.detachedRetention.isNegative()) {
|
||||||
chainExecutor.cancel(executeId, message);
|
removeSession(session);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
session.scheduleDetachedCleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -187,20 +304,9 @@ 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();
|
||||||
/**
|
|
||||||
* 读取适合返回给用户的异常信息。
|
|
||||||
*
|
|
||||||
* @param error 异常
|
|
||||||
* @return 非空异常信息
|
|
||||||
*/
|
|
||||||
private String safeErrorMessage(Throwable error) {
|
|
||||||
if (error == null || error.getMessage() == null
|
|
||||||
|| error.getMessage().isBlank()) {
|
|
||||||
return "工作流执行失败";
|
|
||||||
}
|
}
|
||||||
return error.getMessage();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -231,6 +337,11 @@ 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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -238,8 +349,36 @@ public class WorkflowChatEventStream {
|
|||||||
*
|
*
|
||||||
* @param emitter SSE 发送器
|
* @param emitter SSE 发送器
|
||||||
*/
|
*/
|
||||||
private StreamSession(SseEmitter emitter) {
|
private StreamSession(
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -251,6 +390,38 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理节点开始事件。
|
* 处理节点开始事件。
|
||||||
*
|
*
|
||||||
@@ -286,8 +457,13 @@ public class WorkflowChatEventStream {
|
|||||||
data.put("output", event.getResult() == null
|
data.put("output", event.getResult() == null
|
||||||
? Map.of()
|
? Map.of()
|
||||||
: event.getResult());
|
: event.getResult());
|
||||||
if (event.getError() != null) {
|
if (event.getErrorSummary() != null) {
|
||||||
data.put("error", safeErrorMessage(event.getError()));
|
WorkflowExecutionError detail = WorkflowExecutionErrorMapper.node(event.getErrorSummary(),
|
||||||
|
event.getStatus() == null ? NodeStatus.FAILED : event.getStatus(), node.getId(), node.getName());
|
||||||
|
if (detail != null) {
|
||||||
|
data.put("error", detail.getMessage());
|
||||||
|
data.put("errorDetail", detail);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
send("node_finished", nodePayload(node, data));
|
send("node_finished", nodePayload(node, data));
|
||||||
}
|
}
|
||||||
@@ -416,6 +592,11 @@ public class WorkflowChatEventStream {
|
|||||||
Map<String, Object> data = new LinkedHashMap<>();
|
Map<String, Object> data = new LinkedHashMap<>();
|
||||||
data.put("status", status.name());
|
data.put("status", status.name());
|
||||||
data.put("message", chain.getState().getMessage());
|
data.put("message", chain.getState().getMessage());
|
||||||
|
WorkflowExecutionError detail = WorkflowExecutionErrorMapper.chain(chain.getState().getError(), status);
|
||||||
|
if (detail != null) {
|
||||||
|
data.put("error", detail);
|
||||||
|
data.put("message", WorkflowExecutionErrorMapper.summary(detail));
|
||||||
|
}
|
||||||
if (status == ChainStatus.SUCCEEDED) {
|
if (status == ChainStatus.SUCCEEDED) {
|
||||||
data.put(
|
data.put(
|
||||||
"output",
|
"output",
|
||||||
@@ -424,7 +605,9 @@ public class WorkflowChatEventStream {
|
|||||||
}
|
}
|
||||||
send(eventType, data);
|
send(eventType, data);
|
||||||
removeSession(this);
|
removeSession(this);
|
||||||
emitter.complete();
|
if (connected.compareAndSet(true, false)) {
|
||||||
|
emitter.complete();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -434,6 +617,9 @@ 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);
|
||||||
@@ -453,7 +639,7 @@ public class WorkflowChatEventStream {
|
|||||||
executeId,
|
executeId,
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
disconnect(this, "运行连接已断开");
|
detach(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,11 +650,17 @@ public class WorkflowChatEventStream {
|
|||||||
*/
|
*/
|
||||||
private void fail(Throwable error) {
|
private void fail(Throwable error) {
|
||||||
if (terminal.compareAndSet(false, true)) {
|
if (terminal.compareAndSet(false, true)) {
|
||||||
|
WorkflowExecutionError detail = WorkflowExecutionErrorMapper.map(
|
||||||
|
error == null ? null : new ExceptionSummary(error), true, null, null, false);
|
||||||
send("execution_failed", Map.of(
|
send("execution_failed", Map.of(
|
||||||
"message", safeErrorMessage(error)
|
"status", ChainStatus.FAILED.name(),
|
||||||
|
"message", detail.getMessage(),
|
||||||
|
"error", detail
|
||||||
));
|
));
|
||||||
removeSession(this);
|
removeSession(this);
|
||||||
emitter.completeWithError(error);
|
if (connected.compareAndSet(true, false)) {
|
||||||
|
emitter.completeWithError(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ 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;
|
||||||
@@ -75,6 +76,7 @@ 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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建工作流设计器选项服务。
|
* 创建工作流设计器选项服务。
|
||||||
@@ -93,6 +95,7 @@ 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,
|
||||||
@@ -108,7 +111,8 @@ 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;
|
||||||
@@ -123,6 +127,7 @@ public class WorkflowDesignerOptionService {
|
|||||||
this.datacenterSourceService = datacenterSourceService;
|
this.datacenterSourceService = datacenterSourceService;
|
||||||
this.datacenterDatasetRegistryService = datacenterDatasetRegistryService;
|
this.datacenterDatasetRegistryService = datacenterDatasetRegistryService;
|
||||||
this.datacenterDatasetQueryService = datacenterDatasetQueryService;
|
this.datacenterDatasetQueryService = datacenterDatasetQueryService;
|
||||||
|
this.workflowKnowledgeContractService = workflowKnowledgeContractService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -163,6 +168,7 @@ 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<>();
|
||||||
@@ -177,14 +183,22 @@ public class WorkflowDesignerOptionService {
|
|||||||
if (data == null) {
|
if (data == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
String nodeType = data.getString("type");
|
String nodeType = node.getString("type");
|
||||||
|
String dataType = data.getString("type");
|
||||||
|
if (nodeType != null && !nodeType.isBlank()
|
||||||
|
&& dataType != null && !dataType.isBlank()
|
||||||
|
&& !Objects.equals(nodeType, dataType)) {
|
||||||
|
throw new BusinessException("工作流节点类型与节点数据类型不一致");
|
||||||
|
}
|
||||||
if (nodeType == null || nodeType.isBlank()) {
|
if (nodeType == null || nodeType.isBlank()) {
|
||||||
nodeType = node.getString("type");
|
nodeType = dataType;
|
||||||
}
|
}
|
||||||
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)) {
|
||||||
addReferenceId(knowledgeIds, readReferenceId(data, "knowledgeId", "知识库"));
|
List<BigInteger> nodeKnowledgeIds = readKnowledgeReferenceIds(data);
|
||||||
|
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)) {
|
||||||
@@ -198,6 +212,8 @@ public class WorkflowDesignerOptionService {
|
|||||||
}
|
}
|
||||||
assertModelReferences(modelIds, account);
|
assertModelReferences(modelIds, account);
|
||||||
assertKnowledgeReferences(knowledgeIds, account);
|
assertKnowledgeReferences(knowledgeIds, account);
|
||||||
|
workflowKnowledgeContractService.assertMultiKnowledgeContracts(
|
||||||
|
knowledgeGroups, account.getTenantId());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -318,7 +334,7 @@ public class WorkflowDesignerOptionService {
|
|||||||
Workflow workflow = workflowUsageAuthorizationService.requireUsableWorkflow(
|
Workflow workflow = workflowUsageAuthorizationService.requireUsableWorkflow(
|
||||||
childWorkflowId,
|
childWorkflowId,
|
||||||
account,
|
account,
|
||||||
"子流程不存在、已禁用或无权使用");
|
"子流程不存在、未发布或无权使用");
|
||||||
assertContentReferences(workflow.getContent());
|
assertContentReferences(workflow.getContent());
|
||||||
|
|
||||||
ChainDefinition definition = chainParser.parse(
|
ChainDefinition definition = chainParser.parse(
|
||||||
@@ -460,17 +476,55 @@ public class WorkflowDesignerOptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private List<WorkflowDesignerOptionsView.KnowledgeOption> listKnowledgeOptions(LoginAccount account) {
|
private List<WorkflowDesignerOptionsView.KnowledgeOption> listKnowledgeOptions(LoginAccount account) {
|
||||||
return documentCollectionService.list(QueryWrapper.create()
|
List<DocumentCollection> collections = documentCollectionService.list(QueryWrapper.create()
|
||||||
.eq(DocumentCollection::getTenantId, account.getTenantId())
|
.eq(DocumentCollection::getTenantId, account.getTenantId())
|
||||||
.orderBy(DocumentCollection::getModified, false))
|
.orderBy(DocumentCollection::getModified, false));
|
||||||
.stream()
|
Set<BigInteger> vectorReadyIds = workflowKnowledgeContractService
|
||||||
|
.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.getTitle(), item.getDescription()))
|
item.getId(),
|
||||||
|
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);
|
||||||
@@ -518,7 +572,7 @@ public class WorkflowDesignerOptionService {
|
|||||||
workflowUsageAuthorizationService.requireUsableWorkflow(
|
workflowUsageAuthorizationService.requireUsableWorkflow(
|
||||||
workflowId,
|
workflowId,
|
||||||
account,
|
account,
|
||||||
"子流程不存在、已禁用或无权使用");
|
"子流程不存在、未发布或无权使用");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void assertDatasetReference(
|
private void assertDatasetReference(
|
||||||
@@ -653,6 +707,7 @@ public class WorkflowDesignerOptionService {
|
|||||||
private WorkflowDesignerOptionsView.DatasetOption toDatasetOption(DatacenterTable table) {
|
private WorkflowDesignerOptionsView.DatasetOption toDatasetOption(DatacenterTable table) {
|
||||||
return new WorkflowDesignerOptionsView.DatasetOption(
|
return new WorkflowDesignerOptionsView.DatasetOption(
|
||||||
table.getId(),
|
table.getId(),
|
||||||
|
table.getTenantId(),
|
||||||
table.getSourceId(),
|
table.getSourceId(),
|
||||||
table.getCatalogId(),
|
table.getCatalogId(),
|
||||||
table.getTableName(),
|
table.getTableName(),
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package tech.easyflow.admin.service.ai;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.data.redis.core.script.DefaultRedisScript;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import tech.easyflow.common.cache.RedisLockExecutor;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流匿名分享的限流与活动执行互斥保护。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class WorkflowPublicChatAccessGuard {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(
|
||||||
|
WorkflowPublicChatAccessGuard.class);
|
||||||
|
private static final String KEY_PREFIX = "easyflow:workflow-public-share:";
|
||||||
|
private static final DefaultRedisScript<Long> RATE_LIMIT_SCRIPT;
|
||||||
|
|
||||||
|
static {
|
||||||
|
RATE_LIMIT_SCRIPT = new DefaultRedisScript<>();
|
||||||
|
RATE_LIMIT_SCRIPT.setScriptText(
|
||||||
|
"local visitor = redis.call('incr', KEYS[1]); "
|
||||||
|
+ "if visitor == 1 then redis.call('pexpire', KEYS[1], ARGV[3]); end; "
|
||||||
|
+ "local share = redis.call('incr', KEYS[2]); "
|
||||||
|
+ "if share == 1 then redis.call('pexpire', KEYS[2], ARGV[3]); end; "
|
||||||
|
+ "if visitor > tonumber(ARGV[1]) or share > tonumber(ARGV[2]) "
|
||||||
|
+ "then return 0 else return 1 end"
|
||||||
|
);
|
||||||
|
RATE_LIMIT_SCRIPT.setResultType(Long.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private final StringRedisTemplate redisTemplate;
|
||||||
|
private final RedisLockExecutor redisLockExecutor;
|
||||||
|
private final WorkflowPublicShareProperties properties;
|
||||||
|
|
||||||
|
public WorkflowPublicChatAccessGuard(
|
||||||
|
StringRedisTemplate redisTemplate,
|
||||||
|
RedisLockExecutor redisLockExecutor,
|
||||||
|
WorkflowPublicShareProperties properties
|
||||||
|
) {
|
||||||
|
this.redisTemplate = redisTemplate;
|
||||||
|
this.redisLockExecutor = redisLockExecutor;
|
||||||
|
this.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查匿名运行固定窗口限流。
|
||||||
|
*/
|
||||||
|
public void checkRun(BigInteger shareId, String visitorDigest) {
|
||||||
|
checkRate(
|
||||||
|
shareId,
|
||||||
|
visitorDigest,
|
||||||
|
"run",
|
||||||
|
properties.getRunVisitorLimit(),
|
||||||
|
properties.getRunShareLimit()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查匿名上传固定窗口限流。
|
||||||
|
*/
|
||||||
|
public void checkUpload(BigInteger shareId, String visitorDigest) {
|
||||||
|
checkRate(
|
||||||
|
shareId,
|
||||||
|
visitorDigest,
|
||||||
|
"upload",
|
||||||
|
properties.getUploadVisitorLimit(),
|
||||||
|
properties.getUploadShareLimit()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取同一分享访客的活动执行锁。
|
||||||
|
*
|
||||||
|
* @return 由 SSE 生命周期显式释放的锁句柄
|
||||||
|
*/
|
||||||
|
public RedisLockExecutor.LockHandle acquireActivity(
|
||||||
|
BigInteger shareId,
|
||||||
|
String visitorDigest
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
RedisLockExecutor.LockHandle handle = redisLockExecutor.tryAcquire(
|
||||||
|
KEY_PREFIX + "{" + shareId + "}:active:" + visitorDigest,
|
||||||
|
Duration.ZERO,
|
||||||
|
properties.getActiveLease()
|
||||||
|
);
|
||||||
|
if (handle == null) {
|
||||||
|
throw new BusinessException(
|
||||||
|
409,
|
||||||
|
40931,
|
||||||
|
"当前分享访客已有工作流正在运行"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return handle;
|
||||||
|
} catch (BusinessException exception) {
|
||||||
|
throw exception;
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
log.error("匿名工作流活动锁暂不可用,shareId={}", shareId, exception);
|
||||||
|
throw unavailable(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取匿名活动执行锁的租约,用作浏览器断开后的监听保留上限。
|
||||||
|
*/
|
||||||
|
public Duration activityLease() {
|
||||||
|
return properties.getActiveLease();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkRate(
|
||||||
|
BigInteger shareId,
|
||||||
|
String visitorDigest,
|
||||||
|
String action,
|
||||||
|
int visitorLimit,
|
||||||
|
int shareLimit
|
||||||
|
) {
|
||||||
|
String slot = "{" + shareId + "}";
|
||||||
|
List<String> keys = List.of(
|
||||||
|
KEY_PREFIX + slot + ":rate:" + action + ":visitor:" + visitorDigest,
|
||||||
|
KEY_PREFIX + slot + ":rate:" + action + ":share"
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
Long allowed = redisTemplate.execute(
|
||||||
|
RATE_LIMIT_SCRIPT,
|
||||||
|
keys,
|
||||||
|
String.valueOf(visitorLimit),
|
||||||
|
String.valueOf(shareLimit),
|
||||||
|
String.valueOf(properties.getRateWindow().toMillis())
|
||||||
|
);
|
||||||
|
if (allowed == null) {
|
||||||
|
throw unavailable(new IllegalStateException(
|
||||||
|
"Redis 未返回匿名工作流限流结果"));
|
||||||
|
}
|
||||||
|
if (!Long.valueOf(1L).equals(allowed)) {
|
||||||
|
throw new BusinessException(
|
||||||
|
429,
|
||||||
|
42931,
|
||||||
|
"匿名工作流请求过于频繁,请稍后重试"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (BusinessException exception) {
|
||||||
|
throw exception;
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
log.error("匿名工作流限流暂不可用,shareId={}, action={}",
|
||||||
|
shareId, action, exception);
|
||||||
|
throw unavailable(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private BusinessException unavailable(RuntimeException cause) {
|
||||||
|
return new BusinessException(
|
||||||
|
503,
|
||||||
|
50331,
|
||||||
|
"匿名工作流保护服务暂不可用,请稍后重试",
|
||||||
|
cause
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package tech.easyflow.admin.service.ai;
|
||||||
|
|
||||||
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
|
import tech.easyflow.ai.entity.WorkflowShare;
|
||||||
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完成匿名分享边界校验后的运行上下文。
|
||||||
|
*/
|
||||||
|
public record WorkflowPublicChatContext(
|
||||||
|
WorkflowShare share,
|
||||||
|
Workflow workflow,
|
||||||
|
LoginAccount creator,
|
||||||
|
String shareKey,
|
||||||
|
String visitorDigest
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package tech.easyflow.admin.service.ai;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.tenant.TenantManager;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
|
import tech.easyflow.ai.entity.WorkflowShare;
|
||||||
|
import tech.easyflow.ai.enums.PublishStatus;
|
||||||
|
import tech.easyflow.ai.service.WorkflowService;
|
||||||
|
import tech.easyflow.ai.service.WorkflowShareService;
|
||||||
|
import tech.easyflow.ai.share.WorkflowSharePolicy;
|
||||||
|
import tech.easyflow.common.constant.enums.EnumDataStatus;
|
||||||
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
import tech.easyflow.system.entity.SysAccount;
|
||||||
|
import tech.easyflow.system.service.SysAccountService;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析并校验工作流匿名分享上下文。
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class WorkflowPublicChatContextResolver {
|
||||||
|
|
||||||
|
private static final Pattern VISITOR_PATTERN = Pattern.compile("[a-f0-9]{32}");
|
||||||
|
|
||||||
|
private final WorkflowShareService shareService;
|
||||||
|
private final WorkflowService workflowService;
|
||||||
|
private final SysAccountService accountService;
|
||||||
|
|
||||||
|
public WorkflowPublicChatContextResolver(
|
||||||
|
WorkflowShareService shareService,
|
||||||
|
WorkflowService workflowService,
|
||||||
|
SysAccountService accountService
|
||||||
|
) {
|
||||||
|
this.shareService = shareService;
|
||||||
|
this.workflowService = workflowService;
|
||||||
|
this.accountService = accountService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析新运行、恢复与上传所需的当前有效上下文。
|
||||||
|
*/
|
||||||
|
public WorkflowPublicChatContext resolveActive(
|
||||||
|
String shareKey,
|
||||||
|
String visitorId
|
||||||
|
) {
|
||||||
|
String normalizedVisitor = requireVisitor(visitorId);
|
||||||
|
WorkflowShare share = shareService.resolvePublicChatShare(shareKey);
|
||||||
|
Workflow workflow = TenantManager.withoutTenantCondition(
|
||||||
|
() -> workflowService.getPublishedById(share.getWorkflowId()));
|
||||||
|
if (!isStrictlyPublished(workflow)
|
||||||
|
|| !Objects.equals(share.getTenantId(), workflow.getTenantId())) {
|
||||||
|
throw new BusinessException(409, 409, "工作流尚未发布或已下线");
|
||||||
|
}
|
||||||
|
SysAccount account = TenantManager.withoutTenantCondition(
|
||||||
|
() -> accountService.getById(share.getCreatedBy()));
|
||||||
|
if (account == null
|
||||||
|
|| !EnumDataStatus.AVAILABLE.getCode().equals(account.getStatus())
|
||||||
|
|| !Objects.equals(share.getTenantId(), account.getTenantId())) {
|
||||||
|
throw new BusinessException(
|
||||||
|
403,
|
||||||
|
40331,
|
||||||
|
"工作流分享创建者账号当前不可用"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
LoginAccount creator = account.toLoginAccount();
|
||||||
|
return new WorkflowPublicChatContext(
|
||||||
|
share,
|
||||||
|
workflow,
|
||||||
|
creator,
|
||||||
|
shareKey,
|
||||||
|
WorkflowSharePolicy.hashChatVisitor(
|
||||||
|
shareKey,
|
||||||
|
normalizedVisitor
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析已发起执行的详情与取消所需历史上下文。
|
||||||
|
*/
|
||||||
|
public WorkflowPublicChatContext resolveHistorical(
|
||||||
|
String shareKey,
|
||||||
|
String visitorId
|
||||||
|
) {
|
||||||
|
String normalizedVisitor = requireVisitor(visitorId);
|
||||||
|
WorkflowShare share = shareService.resolveHistoricalChatShare(shareKey);
|
||||||
|
return new WorkflowPublicChatContext(
|
||||||
|
share,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
shareKey,
|
||||||
|
WorkflowSharePolicy.hashChatVisitor(
|
||||||
|
shareKey,
|
||||||
|
normalizedVisitor
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String requireVisitor(String visitorId) {
|
||||||
|
String normalized = visitorId == null ? "" : visitorId.trim();
|
||||||
|
if (!VISITOR_PATTERN.matcher(normalized).matches()) {
|
||||||
|
throw new BusinessException(
|
||||||
|
400,
|
||||||
|
40031,
|
||||||
|
"工作流分享访客标识无效"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isStrictlyPublished(Workflow workflow) {
|
||||||
|
return workflow != null
|
||||||
|
&& PublishStatus.PUBLISHED.getCode().equals(
|
||||||
|
workflow.getPublishStatus())
|
||||||
|
&& workflow.getPublishedSnapshotJson() != null
|
||||||
|
&& !workflow.getPublishedSnapshotJson().isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
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, eventStream.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 视图。
|
||||||
|
*/
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
package tech.easyflow.admin.service.ai;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
||||||
|
import tech.easyflow.ai.share.WorkflowSharePolicy;
|
||||||
|
import tech.easyflow.common.filestorage.FileStorageService;
|
||||||
|
import tech.easyflow.common.vo.UploadResVo;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流匿名分享的隔离上传与运行引用校验。
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class WorkflowPublicChatUploadService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(
|
||||||
|
WorkflowPublicChatUploadService.class);
|
||||||
|
private static final long FILE_MAX_SIZE = 100L * 1024L * 1024L;
|
||||||
|
private static final long IMAGE_MAX_SIZE = 10L * 1024L * 1024L;
|
||||||
|
private static final Set<String> IMAGE_MIME_TYPES = Set.of(
|
||||||
|
"image/bmp", "image/gif", "image/jpeg", "image/png", "image/webp");
|
||||||
|
private static final Set<String> IMAGE_EXTENSIONS = Set.of(
|
||||||
|
"bmp", "gif", "jpeg", "jpg", "png", "webp");
|
||||||
|
private static final String GRANT_PREFIX = "easyflow:workflow-public-share:upload:";
|
||||||
|
|
||||||
|
private final WorkflowRunningParameterResolver parameterResolver;
|
||||||
|
private final WorkflowPublicChatAccessGuard accessGuard;
|
||||||
|
private final WorkflowPublicShareProperties properties;
|
||||||
|
private final StringRedisTemplate redisTemplate;
|
||||||
|
private final FileStorageService storageService;
|
||||||
|
|
||||||
|
public WorkflowPublicChatUploadService(
|
||||||
|
WorkflowRunningParameterResolver parameterResolver,
|
||||||
|
WorkflowPublicChatAccessGuard accessGuard,
|
||||||
|
WorkflowPublicShareProperties properties,
|
||||||
|
StringRedisTemplate redisTemplate,
|
||||||
|
@Qualifier("default") FileStorageService storageService
|
||||||
|
) {
|
||||||
|
this.parameterResolver = parameterResolver;
|
||||||
|
this.accessGuard = accessGuard;
|
||||||
|
this.properties = properties;
|
||||||
|
this.redisTemplate = redisTemplate;
|
||||||
|
this.storageService = storageService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传发布快照声明的文件或图片参数。
|
||||||
|
*/
|
||||||
|
public UploadResVo upload(
|
||||||
|
WorkflowPublicChatContext context,
|
||||||
|
String parameterName,
|
||||||
|
MultipartFile file
|
||||||
|
) {
|
||||||
|
String normalizedName = requireParameterName(parameterName);
|
||||||
|
String contentType = resolveUploadContentType(context, normalizedName);
|
||||||
|
validateFile(file, contentType);
|
||||||
|
accessGuard.checkUpload(
|
||||||
|
context.share().getId(),
|
||||||
|
context.visitorDigest()
|
||||||
|
);
|
||||||
|
|
||||||
|
String path = storageService.save(
|
||||||
|
file,
|
||||||
|
"workflow-chat-share/" + context.share().getId()
|
||||||
|
+ "/" + context.visitorDigest()
|
||||||
|
);
|
||||||
|
if (!StringUtils.hasText(path)) {
|
||||||
|
throw new BusinessException(503, 50332, "匿名文件上传失败,请稍后重试");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
redisTemplate.opsForValue().set(
|
||||||
|
grantKey(context, normalizedName, path),
|
||||||
|
contentType,
|
||||||
|
grantTtl(context).toMillis(),
|
||||||
|
TimeUnit.MILLISECONDS
|
||||||
|
);
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
try {
|
||||||
|
storageService.delete(path);
|
||||||
|
} catch (RuntimeException cleanupError) {
|
||||||
|
log.warn("匿名上传授权写入失败后清理文件失败,path={}",
|
||||||
|
path, cleanupError);
|
||||||
|
}
|
||||||
|
throw new BusinessException(
|
||||||
|
503,
|
||||||
|
50332,
|
||||||
|
"匿名上传保护服务暂不可用,请稍后重试",
|
||||||
|
exception
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
UploadResVo response = new UploadResVo();
|
||||||
|
response.setPath(path);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验公开运行引用的上传文件均属于当前分享访客和参数。
|
||||||
|
*/
|
||||||
|
public void assertOwnedUploads(
|
||||||
|
WorkflowPublicChatContext context,
|
||||||
|
Map<String, Object> variables
|
||||||
|
) {
|
||||||
|
Map<String, String> uploadFields = resolveUploadFields(context);
|
||||||
|
for (Map.Entry<String, String> entry : uploadFields.entrySet()) {
|
||||||
|
Object value = variables.get(entry.getKey());
|
||||||
|
if (value == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ("image".equals(entry.getValue())) {
|
||||||
|
assertOwnedImage(context, entry.getKey(), value);
|
||||||
|
} else {
|
||||||
|
assertOwnedFiles(context, entry.getKey(), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertOwnedImage(
|
||||||
|
WorkflowPublicChatContext context,
|
||||||
|
String parameterName,
|
||||||
|
Object value
|
||||||
|
) {
|
||||||
|
if (!(value instanceof Map<?, ?> image)) {
|
||||||
|
throw invalidUploadReference(parameterName);
|
||||||
|
}
|
||||||
|
String sourceType = trim(image.get("sourceType"));
|
||||||
|
if ("url".equals(sourceType)) {
|
||||||
|
String url = trim(image.get("url"));
|
||||||
|
if (isHttpUrl(url)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw invalidUploadReference(parameterName);
|
||||||
|
}
|
||||||
|
if (!"upload".equals(sourceType)) {
|
||||||
|
throw invalidUploadReference(parameterName);
|
||||||
|
}
|
||||||
|
assertGrant(
|
||||||
|
context,
|
||||||
|
parameterName,
|
||||||
|
trim(image.get("filePath")),
|
||||||
|
"image"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertOwnedFiles(
|
||||||
|
WorkflowPublicChatContext context,
|
||||||
|
String parameterName,
|
||||||
|
Object value
|
||||||
|
) {
|
||||||
|
if (!(value instanceof Collection<?> files)) {
|
||||||
|
throw invalidUploadReference(parameterName);
|
||||||
|
}
|
||||||
|
for (Object item : files) {
|
||||||
|
if (!(item instanceof Map<?, ?> file)) {
|
||||||
|
throw invalidUploadReference(parameterName);
|
||||||
|
}
|
||||||
|
assertGrant(
|
||||||
|
context,
|
||||||
|
parameterName,
|
||||||
|
trim(file.get("filePath")),
|
||||||
|
"file"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertGrant(
|
||||||
|
WorkflowPublicChatContext context,
|
||||||
|
String parameterName,
|
||||||
|
String path,
|
||||||
|
String expectedContentType
|
||||||
|
) {
|
||||||
|
if (!StringUtils.hasText(path)) {
|
||||||
|
throw invalidUploadReference(parameterName);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String grantedContentType = redisTemplate.opsForValue().get(
|
||||||
|
grantKey(context, parameterName, path));
|
||||||
|
if (!expectedContentType.equals(grantedContentType)) {
|
||||||
|
throw invalidUploadReference(parameterName);
|
||||||
|
}
|
||||||
|
} catch (BusinessException exception) {
|
||||||
|
throw exception;
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
throw new BusinessException(
|
||||||
|
503,
|
||||||
|
50332,
|
||||||
|
"匿名上传保护服务暂不可用,请稍后重试",
|
||||||
|
exception
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveUploadContentType(
|
||||||
|
WorkflowPublicChatContext context,
|
||||||
|
String parameterName
|
||||||
|
) {
|
||||||
|
String contentType = resolveUploadFields(context).get(parameterName);
|
||||||
|
if (contentType == null) {
|
||||||
|
throw new BusinessException(
|
||||||
|
400,
|
||||||
|
40032,
|
||||||
|
"当前发布工作流未声明该上传参数"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return contentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private Map<String, String> resolveUploadFields(
|
||||||
|
WorkflowPublicChatContext context
|
||||||
|
) {
|
||||||
|
Map<String, Object> descriptor = parameterResolver
|
||||||
|
.buildRunningParametersView(context.workflow());
|
||||||
|
if (descriptor == null) {
|
||||||
|
throw new BusinessException("工作流输入配置无法解析");
|
||||||
|
}
|
||||||
|
Map<String, String> fields = new java.util.LinkedHashMap<>();
|
||||||
|
Object rawSchema = descriptor.get("startFormSchema");
|
||||||
|
if (!(rawSchema instanceof Collection<?> schema)) {
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
for (Object item : schema) {
|
||||||
|
if (!(item instanceof Map<?, ?> field)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String name = trim(field.get("key"));
|
||||||
|
String contentType = trim(field.get("contentType"));
|
||||||
|
if (StringUtils.hasText(name)
|
||||||
|
&& ("file".equals(contentType)
|
||||||
|
|| "image".equals(contentType))) {
|
||||||
|
fields.put(name, contentType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateFile(MultipartFile file, String contentType) {
|
||||||
|
if (file == null || file.isEmpty()) {
|
||||||
|
throw new BusinessException("上传文件不能为空");
|
||||||
|
}
|
||||||
|
long maxSize = "image".equals(contentType)
|
||||||
|
? IMAGE_MAX_SIZE
|
||||||
|
: FILE_MAX_SIZE;
|
||||||
|
if (file.getSize() > maxSize) {
|
||||||
|
throw new BusinessException(
|
||||||
|
"image".equals(contentType)
|
||||||
|
? "单张图片不能超过 10 MiB"
|
||||||
|
: "单个文件不能超过 100 MiB"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!"image".equals(contentType)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String mimeType = trim(file.getContentType()).toLowerCase(Locale.ROOT);
|
||||||
|
String filename = trim(file.getOriginalFilename());
|
||||||
|
int dot = filename.lastIndexOf('.');
|
||||||
|
String extension = dot < 0
|
||||||
|
? ""
|
||||||
|
: filename.substring(dot + 1).toLowerCase(Locale.ROOT);
|
||||||
|
if (!IMAGE_MIME_TYPES.contains(mimeType)
|
||||||
|
&& !IMAGE_EXTENSIONS.contains(extension)) {
|
||||||
|
throw new BusinessException("仅支持 PNG、JPEG、WebP、GIF、BMP 图片");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Duration grantTtl(WorkflowPublicChatContext context) {
|
||||||
|
long expiresIn = context.share().getExpiresAt().getTime()
|
||||||
|
- System.currentTimeMillis();
|
||||||
|
long ttl = Math.min(
|
||||||
|
properties.getUploadGrantTtl().toMillis(),
|
||||||
|
expiresIn
|
||||||
|
);
|
||||||
|
return Duration.ofMillis(Math.max(1L, ttl));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String grantKey(
|
||||||
|
WorkflowPublicChatContext context,
|
||||||
|
String parameterName,
|
||||||
|
String path
|
||||||
|
) {
|
||||||
|
return GRANT_PREFIX + "{" + context.share().getId() + "}:"
|
||||||
|
+ context.visitorDigest() + ":"
|
||||||
|
+ WorkflowSharePolicy.hashShareKey(parameterName) + ":"
|
||||||
|
+ WorkflowSharePolicy.hashShareKey(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String requireParameterName(String value) {
|
||||||
|
String normalized = value == null ? "" : value.trim();
|
||||||
|
if (!StringUtils.hasText(normalized)) {
|
||||||
|
throw new BusinessException("上传参数名不能为空");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String trim(Object value) {
|
||||||
|
return value == null ? "" : String.valueOf(value).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isHttpUrl(String value) {
|
||||||
|
String normalized = value == null ? "" : value.toLowerCase(Locale.ROOT);
|
||||||
|
return normalized.startsWith("http://")
|
||||||
|
|| normalized.startsWith("https://");
|
||||||
|
}
|
||||||
|
|
||||||
|
private BusinessException invalidUploadReference(String parameterName) {
|
||||||
|
return new BusinessException(
|
||||||
|
403,
|
||||||
|
40332,
|
||||||
|
"上传参数 " + parameterName + " 不属于当前分享访客"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package tech.easyflow.admin.service.ai;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流匿名分享运行保护参数。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "easyflow.workflow.public-share")
|
||||||
|
public class WorkflowPublicShareProperties {
|
||||||
|
|
||||||
|
private Duration rateWindow = Duration.ofMinutes(1);
|
||||||
|
private int runVisitorLimit = 5;
|
||||||
|
private int runShareLimit = 60;
|
||||||
|
private int uploadVisitorLimit = 10;
|
||||||
|
private int uploadShareLimit = 60;
|
||||||
|
private Duration activeLease = Duration.ofMinutes(35);
|
||||||
|
private Duration uploadGrantTtl = Duration.ofDays(7);
|
||||||
|
|
||||||
|
public Duration getRateWindow() {
|
||||||
|
return rateWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRateWindow(Duration rateWindow) {
|
||||||
|
this.rateWindow = requirePositive(rateWindow, "rateWindow");
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getRunVisitorLimit() {
|
||||||
|
return runVisitorLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRunVisitorLimit(int runVisitorLimit) {
|
||||||
|
this.runVisitorLimit = requirePositive(runVisitorLimit, "runVisitorLimit");
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getRunShareLimit() {
|
||||||
|
return runShareLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRunShareLimit(int runShareLimit) {
|
||||||
|
this.runShareLimit = requirePositive(runShareLimit, "runShareLimit");
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getUploadVisitorLimit() {
|
||||||
|
return uploadVisitorLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUploadVisitorLimit(int uploadVisitorLimit) {
|
||||||
|
this.uploadVisitorLimit = requirePositive(uploadVisitorLimit, "uploadVisitorLimit");
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getUploadShareLimit() {
|
||||||
|
return uploadShareLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUploadShareLimit(int uploadShareLimit) {
|
||||||
|
this.uploadShareLimit = requirePositive(uploadShareLimit, "uploadShareLimit");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Duration getActiveLease() {
|
||||||
|
return activeLease;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setActiveLease(Duration activeLease) {
|
||||||
|
this.activeLease = requirePositive(activeLease, "activeLease");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Duration getUploadGrantTtl() {
|
||||||
|
return uploadGrantTtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUploadGrantTtl(Duration uploadGrantTtl) {
|
||||||
|
this.uploadGrantTtl = requirePositive(uploadGrantTtl, "uploadGrantTtl");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int requirePositive(int value, String name) {
|
||||||
|
if (value <= 0) {
|
||||||
|
throw new IllegalArgumentException(name + " 必须大于 0");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Duration requirePositive(Duration value, String name) {
|
||||||
|
if (value == null || value.isZero() || value.isNegative()) {
|
||||||
|
throw new IllegalArgumentException(name + " 必须大于 0");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package tech.easyflow.admin.controller.ai;
|
||||||
|
|
||||||
|
import org.testng.Assert;
|
||||||
|
import org.testng.annotations.Test;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import tech.easyflow.ai.dto.DocumentChunkContentUpdateRequest;
|
||||||
|
import tech.easyflow.ai.entity.DocumentChunk;
|
||||||
|
import tech.easyflow.ai.service.DocumentChunkService;
|
||||||
|
import tech.easyflow.common.web.controller.BaseController;
|
||||||
|
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||||
|
import tech.easyflow.system.permission.resource.RequireResourceAccess;
|
||||||
|
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.lang.reflect.Parameter;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档分块维护接口契约测试。
|
||||||
|
*/
|
||||||
|
public class DocumentChunkControllerContractTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void updateEndpointsShouldOnlyAcceptChunkIdAndContent() throws Exception {
|
||||||
|
Method adminUpdate = DocumentChunkController.class.getDeclaredMethod(
|
||||||
|
"update",
|
||||||
|
DocumentChunkContentUpdateRequest.class
|
||||||
|
);
|
||||||
|
Method shareUpdate = ShareKnowledgeController.class.getDeclaredMethod(
|
||||||
|
"updateDocumentChunk",
|
||||||
|
String.class,
|
||||||
|
DocumentChunkContentUpdateRequest.class
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.assertNotNull(adminUpdate);
|
||||||
|
Assert.assertNotNull(shareUpdate);
|
||||||
|
assertStrictJsonBody(adminUpdate.getParameters()[0]);
|
||||||
|
assertStrictJsonBody(shareUpdate.getParameters()[1]);
|
||||||
|
Assert.assertEquals(
|
||||||
|
adminUpdate.getAnnotation(RequireResourceAccess.class).idExpr(),
|
||||||
|
"#request.id"
|
||||||
|
);
|
||||||
|
Set<String> fields = Arrays.stream(
|
||||||
|
DocumentChunkContentUpdateRequest.class.getDeclaredFields()
|
||||||
|
)
|
||||||
|
.map(Field::getName)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
Assert.assertEquals(Set.of("id", "content"), fields);
|
||||||
|
Assert.assertEquals(
|
||||||
|
DocumentChunkController.class.getMethod(
|
||||||
|
"update",
|
||||||
|
DocumentChunkContentUpdateRequest.class
|
||||||
|
).getDeclaringClass(),
|
||||||
|
DocumentChunkController.class
|
||||||
|
);
|
||||||
|
Assert.assertEquals(
|
||||||
|
Arrays.stream(DocumentChunkController.class.getDeclaredMethods())
|
||||||
|
.filter(method -> method.getName().equals("update"))
|
||||||
|
.filter(method -> !method.isBridge() && !method.isSynthetic())
|
||||||
|
.count(),
|
||||||
|
1L
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void controllerShouldNotExposeGenericWriteEndpoints() {
|
||||||
|
Assert.assertEquals(DocumentChunkController.class.getSuperclass(), BaseController.class);
|
||||||
|
Set<String> postMappings = Arrays.stream(DocumentChunkController.class.getMethods())
|
||||||
|
.map(method -> method.getAnnotation(PostMapping.class))
|
||||||
|
.filter(annotation -> annotation != null)
|
||||||
|
.flatMap(annotation -> Arrays.stream(annotation.value()))
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
|
Assert.assertEquals(
|
||||||
|
postMappings,
|
||||||
|
Set.of("update", "removeChunk", "syncStatus", "retrySync")
|
||||||
|
);
|
||||||
|
Assert.assertFalse(postMappings.contains("save"));
|
||||||
|
Assert.assertFalse(postMappings.contains("remove"));
|
||||||
|
Assert.assertFalse(postMappings.contains("removeBatch"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void adminUpdateAndDeleteShouldUseUnifiedMaintenanceService() {
|
||||||
|
DocumentChunkService service = mock(DocumentChunkService.class);
|
||||||
|
DocumentChunkController controller = new DocumentChunkController(service);
|
||||||
|
|
||||||
|
DocumentChunk current = new DocumentChunk();
|
||||||
|
current.setId(BigInteger.ONE);
|
||||||
|
current.setDocumentCollectionId(BigInteger.TWO);
|
||||||
|
when(service.getById(BigInteger.ONE)).thenReturn(current);
|
||||||
|
|
||||||
|
DocumentChunkContentUpdateRequest request = new DocumentChunkContentUpdateRequest();
|
||||||
|
request.setId(BigInteger.ONE);
|
||||||
|
request.setContent("updated");
|
||||||
|
controller.update(request);
|
||||||
|
controller.removeChunk(BigInteger.ONE);
|
||||||
|
|
||||||
|
verify(service).updateContent(BigInteger.TWO, BigInteger.ONE, "updated");
|
||||||
|
verify(service).deleteChunk(BigInteger.TWO, BigInteger.ONE);
|
||||||
|
verify(service, never()).updateById(any(DocumentChunk.class));
|
||||||
|
verify(service, never()).removeById(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertStrictJsonBody(Parameter parameter) {
|
||||||
|
JsonBody jsonBody = parameter.getAnnotation(JsonBody.class);
|
||||||
|
Assert.assertNotNull(jsonBody);
|
||||||
|
Assert.assertTrue(jsonBody.required());
|
||||||
|
Assert.assertFalse(jsonBody.skipConvertError());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,21 +7,27 @@ 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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,6 +74,61 @@ 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,17 +3,50 @@ 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");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证分享地址保留前端部署基路径。
|
* 验证分享地址保留前端部署基路径。
|
||||||
*
|
*
|
||||||
@@ -120,4 +153,11 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package tech.easyflow.admin.controller.dataspace;
|
||||||
|
|
||||||
|
import static org.testng.Assert.assertEquals;
|
||||||
|
import static org.testng.Assert.assertNotNull;
|
||||||
|
import static org.testng.Assert.assertNull;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.lang.reflect.Parameter;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.testng.annotations.Test;
|
||||||
|
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||||
|
import tech.easyflow.dataspace.model.DataspaceDefinition;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间管理接口请求绑定契约测试。
|
||||||
|
*/
|
||||||
|
public class DataspaceControllerContractTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证保存接口使用 Jackson 请求体绑定,避免嵌套定义残留为 JSONObject。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射或 JSON 转换失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldBindNestedDataspaceDefinitionWithJackson() throws Exception {
|
||||||
|
Method method = DataspaceController.class.getMethod(
|
||||||
|
"save", DataspaceDefinition.class);
|
||||||
|
Parameter parameter = method.getParameters()[0];
|
||||||
|
assertNotNull(parameter.getAnnotation(RequestBody.class));
|
||||||
|
assertNull(parameter.getAnnotation(JsonBody.class));
|
||||||
|
|
||||||
|
String request = """
|
||||||
|
{
|
||||||
|
"name": "网点经营分析",
|
||||||
|
"tables": [
|
||||||
|
{
|
||||||
|
"clientKey": "table:outlet",
|
||||||
|
"objectId": "1001",
|
||||||
|
"sourceAlias": "MYSQL_1",
|
||||||
|
"schemaAlias": "MAIN",
|
||||||
|
"tableAlias": "outlet",
|
||||||
|
"positionX": 80,
|
||||||
|
"positionY": 120
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"clientKey": "table:region",
|
||||||
|
"objectId": "1002",
|
||||||
|
"sourceAlias": "PG_1",
|
||||||
|
"schemaAlias": "PUBLIC",
|
||||||
|
"tableAlias": "outlet_region",
|
||||||
|
"positionX": 420,
|
||||||
|
"positionY": 120
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"relations": [
|
||||||
|
{
|
||||||
|
"leftClientKey": "table:outlet",
|
||||||
|
"rightClientKey": "table:region",
|
||||||
|
"joinType": "INNER",
|
||||||
|
"leftColumn": "institution_id",
|
||||||
|
"rightColumn": "institution_id"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
DataspaceDefinition definition = new ObjectMapper().readValue(
|
||||||
|
request, DataspaceDefinition.class);
|
||||||
|
|
||||||
|
assertEquals(2, definition.tables().size());
|
||||||
|
assertEquals("outlet", definition.tables().get(0).tableAlias());
|
||||||
|
assertEquals("outlet_region", definition.tables().get(1).tableAlias());
|
||||||
|
assertEquals(1, definition.relations().size());
|
||||||
|
assertEquals("institution_id", definition.relations().get(0).leftColumn());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +1,39 @@
|
|||||||
package tech.easyflow.admin.controller.job;
|
package tech.easyflow.admin.controller.job;
|
||||||
|
|
||||||
import com.easyagents.flow.core.chain.Parameter;
|
import com.easyagents.flow.core.chain.Parameter;
|
||||||
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
import org.mockito.MockedStatic;
|
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.WorkflowRunningParameterResolver;
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
||||||
import tech.easyflow.ai.entity.Workflow;
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
|
import tech.easyflow.ai.enums.PublishStatus;
|
||||||
import tech.easyflow.ai.service.WorkflowService;
|
import tech.easyflow.ai.service.WorkflowService;
|
||||||
import tech.easyflow.ai.service.WorkflowUsageAuthorizationService;
|
import tech.easyflow.ai.service.WorkflowUsageAuthorizationService;
|
||||||
|
import tech.easyflow.admin.model.SysJobWorkflowOptionView;
|
||||||
import tech.easyflow.common.constant.enums.EnumJobType;
|
import tech.easyflow.common.constant.enums.EnumJobType;
|
||||||
|
import tech.easyflow.common.constant.enums.EnumJobStatus;
|
||||||
|
import tech.easyflow.common.constant.enums.EnumMisfirePolicy;
|
||||||
import tech.easyflow.common.entity.LoginAccount;
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
import tech.easyflow.job.entity.SysJob;
|
import tech.easyflow.job.entity.SysJob;
|
||||||
import tech.easyflow.job.job.JobConstant;
|
import tech.easyflow.job.job.JobConstant;
|
||||||
import tech.easyflow.job.service.SysJobService;
|
import tech.easyflow.job.service.SysJobService;
|
||||||
|
import tech.easyflow.system.enums.CategoryResourceType;
|
||||||
|
import tech.easyflow.system.enums.ResourceAction;
|
||||||
import tech.easyflow.system.service.ResourceAccessService;
|
import tech.easyflow.system.service.ResourceAccessService;
|
||||||
|
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Date;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.TimeZone;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.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;
|
||||||
@@ -31,6 +44,114 @@ import static org.mockito.Mockito.when;
|
|||||||
*/
|
*/
|
||||||
public class SysJobControllerTest {
|
public class SysJobControllerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldQueryPublishedWorkflowOptionsAndReturnPublishedMetadata() {
|
||||||
|
BigInteger workflowId = BigInteger.valueOf(501);
|
||||||
|
LoginAccount account = account();
|
||||||
|
Workflow raw = new Workflow();
|
||||||
|
raw.setId(workflowId);
|
||||||
|
raw.setTenantId(account.getTenantId());
|
||||||
|
raw.setPublishStatus(PublishStatus.PUBLISHED.getCode());
|
||||||
|
raw.setPublishedSnapshotJson(Map.of("title", "发布标题"));
|
||||||
|
raw.setTitle("草稿标题");
|
||||||
|
Workflow withoutSnapshot = new Workflow();
|
||||||
|
withoutSnapshot.setId(BigInteger.valueOf(502));
|
||||||
|
withoutSnapshot.setTenantId(account.getTenantId());
|
||||||
|
withoutSnapshot.setPublishStatus(PublishStatus.PUBLISHED.getCode());
|
||||||
|
Workflow published = new Workflow();
|
||||||
|
published.setId(workflowId);
|
||||||
|
published.setTitle("发布标题");
|
||||||
|
published.setDescription("发布描述");
|
||||||
|
WorkflowService workflowService = mock(WorkflowService.class);
|
||||||
|
ResourceAccessService resourceAccessService = mock(ResourceAccessService.class);
|
||||||
|
when(workflowService.list(any(QueryWrapper.class)))
|
||||||
|
.thenReturn(List.of(raw, withoutSnapshot));
|
||||||
|
when(resourceAccessService.canAccess(
|
||||||
|
account,
|
||||||
|
CategoryResourceType.WORKFLOW,
|
||||||
|
raw,
|
||||||
|
ResourceAction.USE)).thenReturn(true);
|
||||||
|
when(workflowService.toPublishedView(raw)).thenReturn(published);
|
||||||
|
SysJobController controller = new SysJobController(
|
||||||
|
mock(SysJobService.class),
|
||||||
|
workflowService,
|
||||||
|
mock(WorkflowUsageAuthorizationService.class),
|
||||||
|
resourceAccessService,
|
||||||
|
mock(WorkflowRunningParameterResolver.class),
|
||||||
|
"Asia/Shanghai");
|
||||||
|
|
||||||
|
List<SysJobWorkflowOptionView> options;
|
||||||
|
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||||
|
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
|
||||||
|
options = controller.workflowOptions().getData();
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.assertEquals(options.size(), 1);
|
||||||
|
Assert.assertEquals(options.get(0).title(), "发布标题");
|
||||||
|
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
|
||||||
|
verify(workflowService).list(queryCaptor.capture());
|
||||||
|
String sql = queryCaptor.getValue().toSQL().toLowerCase(Locale.ROOT);
|
||||||
|
Assert.assertTrue(sql.contains("publish_status"));
|
||||||
|
Assert.assertFalse(sql.replace("publish_status", "").matches("(?s).*\\bstatus\\b.*"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldFormatCronPreviewWithConfiguredTimezone() {
|
||||||
|
SysJobService service = mock(SysJobService.class);
|
||||||
|
when(service.nextFireTimes("0 0 9 * * ?", 5))
|
||||||
|
.thenReturn(List.of(Date.from(Instant.parse("2026-01-01T01:00:00Z"))));
|
||||||
|
TimeZone previous = TimeZone.getDefault();
|
||||||
|
try {
|
||||||
|
TimeZone.setDefault(TimeZone.getTimeZone("UTC"));
|
||||||
|
Assert.assertEquals(
|
||||||
|
controller(service).getNextTimes("0 0 9 * * ?").getData().get(0),
|
||||||
|
"2026-01-01 09:00:00");
|
||||||
|
} finally {
|
||||||
|
TimeZone.setDefault(previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldForceNewJobToStoppedGenerationZero() {
|
||||||
|
SysJobController controller = controller(mock(SysJobService.class));
|
||||||
|
SysJob job = validJavaJob();
|
||||||
|
job.setStatus(EnumJobStatus.RUNNING.getCode());
|
||||||
|
job.setScheduleGeneration(99L);
|
||||||
|
LoginAccount account = account();
|
||||||
|
|
||||||
|
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||||
|
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
|
||||||
|
controller.onSaveOrUpdateBefore(job, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.assertEquals(job.getStatus(), Integer.valueOf(EnumJobStatus.STOP.getCode()));
|
||||||
|
Assert.assertEquals(job.getScheduleGeneration(), Long.valueOf(0L));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldRejectStatusAndGenerationMutationThroughOrdinaryUpdate() {
|
||||||
|
BigInteger id = BigInteger.valueOf(401);
|
||||||
|
SysJobService service = mock(SysJobService.class);
|
||||||
|
SysJob existing = validJavaJob();
|
||||||
|
existing.setId(id);
|
||||||
|
existing.setStatus(EnumJobStatus.STOP.getCode());
|
||||||
|
existing.setScheduleGeneration(8L);
|
||||||
|
when(service.getById(id)).thenReturn(existing);
|
||||||
|
|
||||||
|
SysJob update = validJavaJob();
|
||||||
|
update.setId(id);
|
||||||
|
update.setStatus(EnumJobStatus.RUNNING.getCode());
|
||||||
|
update.setScheduleGeneration(100L);
|
||||||
|
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||||
|
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account());
|
||||||
|
controller(service).update(update);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.assertEquals(update.getStatus(), Integer.valueOf(EnumJobStatus.STOP.getCode()));
|
||||||
|
Assert.assertEquals(update.getScheduleGeneration(), Long.valueOf(8L));
|
||||||
|
verify(service).updateJobDefinition(update);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证缺少工作流必填参数时拒绝保存定时任务。
|
* 验证缺少工作流必填参数时拒绝保存定时任务。
|
||||||
*/
|
*/
|
||||||
@@ -63,7 +184,8 @@ public class SysJobControllerTest {
|
|||||||
workflowService,
|
workflowService,
|
||||||
workflowAuthorizationService,
|
workflowAuthorizationService,
|
||||||
resourceAccessService,
|
resourceAccessService,
|
||||||
parameterResolver
|
parameterResolver,
|
||||||
|
"Asia/Shanghai"
|
||||||
);
|
);
|
||||||
SysJob job = new SysJob();
|
SysJob job = new SysJob();
|
||||||
job.setJobType(EnumJobType.TINY_FLOW.getCode());
|
job.setJobType(EnumJobType.TINY_FLOW.getCode());
|
||||||
@@ -130,7 +252,8 @@ public class SysJobControllerTest {
|
|||||||
workflowService,
|
workflowService,
|
||||||
workflowAuthorizationService,
|
workflowAuthorizationService,
|
||||||
resourceAccessService,
|
resourceAccessService,
|
||||||
parameterResolver
|
parameterResolver,
|
||||||
|
"Asia/Shanghai"
|
||||||
);
|
);
|
||||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
|
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
|
||||||
@@ -147,4 +270,34 @@ public class SysJobControllerTest {
|
|||||||
org.mockito.ArgumentMatchers.anyString());
|
org.mockito.ArgumentMatchers.anyString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static SysJobController controller(SysJobService service) {
|
||||||
|
return new SysJobController(
|
||||||
|
service,
|
||||||
|
mock(WorkflowService.class),
|
||||||
|
mock(WorkflowUsageAuthorizationService.class),
|
||||||
|
mock(ResourceAccessService.class),
|
||||||
|
mock(WorkflowRunningParameterResolver.class),
|
||||||
|
"Asia/Shanghai");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SysJob validJavaJob() {
|
||||||
|
SysJob job = new SysJob();
|
||||||
|
job.setJobName("generation-test");
|
||||||
|
job.setJobType(EnumJobType.JAVA_CLASS.getCode());
|
||||||
|
job.setCronExpression("0 0 0 1 1 ? 2099");
|
||||||
|
job.setMisfirePolicy(EnumMisfirePolicy.SKIP.getCode());
|
||||||
|
job.setAllowConcurrent(0);
|
||||||
|
job.setJobParams(Map.of(JobConstant.JAVA_METHOD_KEY,
|
||||||
|
"tech.easyflow.job.util.JobUtil.test()"));
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LoginAccount account() {
|
||||||
|
LoginAccount account = new LoginAccount();
|
||||||
|
account.setId(BigInteger.ONE);
|
||||||
|
account.setTenantId(BigInteger.ONE);
|
||||||
|
account.setDeptId(BigInteger.ONE);
|
||||||
|
return account;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package tech.easyflow.admin.controller.job;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.paginate.Page;
|
||||||
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.testng.Assert;
|
||||||
|
import org.testng.annotations.Test;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
import tech.easyflow.job.entity.SysJobLog;
|
||||||
|
import tech.easyflow.job.service.SysJobLogService;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link SysJobLogController} 查询与轻量刷新边界测试。
|
||||||
|
*/
|
||||||
|
public class SysJobLogControllerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldBuildBothFireTimeRanges() {
|
||||||
|
SysJobLogController controller = controller(mock(SysJobLogService.class));
|
||||||
|
HttpServletRequest request = emptyRequest();
|
||||||
|
when(request.getParameter("scheduledStart")).thenReturn("2026-08-31 10:00:00");
|
||||||
|
when(request.getParameter("scheduledEnd")).thenReturn("2026-08-31 11:00:00");
|
||||||
|
when(request.getParameter("actualStart")).thenReturn("2026-08-31 10:00:01");
|
||||||
|
when(request.getParameter("actualEnd")).thenReturn("2026-08-31 11:00:01");
|
||||||
|
|
||||||
|
String sql = controller.buildQueryWrapper(request).toSQL().toLowerCase(Locale.ROOT);
|
||||||
|
|
||||||
|
Assert.assertEquals(countOccurrences(sql, "scheduled_fire_time"), 2);
|
||||||
|
Assert.assertEquals(countOccurrences(sql, "actual_fire_time"), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(expectedExceptions = BusinessException.class)
|
||||||
|
public void shouldRejectInvalidFireTime() {
|
||||||
|
SysJobLogController controller = controller(mock(SysJobLogService.class));
|
||||||
|
HttpServletRequest request = emptyRequest();
|
||||||
|
when(request.getParameter("scheduledStart")).thenReturn("2026/08/31 10:00:00");
|
||||||
|
|
||||||
|
controller.buildQueryWrapper(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(expectedExceptions = BusinessException.class)
|
||||||
|
public void shouldRejectReversedActualFireTimeRange() {
|
||||||
|
SysJobLogController controller = controller(mock(SysJobLogService.class));
|
||||||
|
HttpServletRequest request = emptyRequest();
|
||||||
|
when(request.getParameter("actualStart")).thenReturn("2026-08-31 11:00:00");
|
||||||
|
when(request.getParameter("actualEnd")).thenReturn("2026-08-31 10:00:00");
|
||||||
|
|
||||||
|
controller.buildQueryWrapper(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldClampRefreshAndPageSize() {
|
||||||
|
SysJobLogService service = mock(SysJobLogService.class);
|
||||||
|
when(service.list(any(QueryWrapper.class))).thenReturn(List.of());
|
||||||
|
when(service.page(any(Page.class), any(QueryWrapper.class)))
|
||||||
|
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
SysJobLogController controller = controller(service);
|
||||||
|
HttpServletRequest request = emptyRequest();
|
||||||
|
|
||||||
|
controller.refresh(request, 500L);
|
||||||
|
Page<SysJobLog> page = controller.queryPage(
|
||||||
|
new Page<>(1, 500), QueryWrapper.create());
|
||||||
|
|
||||||
|
ArgumentCaptor<QueryWrapper> queryCaptor =
|
||||||
|
ArgumentCaptor.forClass(QueryWrapper.class);
|
||||||
|
verify(service).list(queryCaptor.capture());
|
||||||
|
String refreshSql = queryCaptor.getValue().toSQL().toLowerCase(Locale.ROOT);
|
||||||
|
Assert.assertTrue(refreshSql.contains("limit 100"));
|
||||||
|
Assert.assertEquals(page.getPageSize(), 100L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldUseStableScheduledFireTimeOrder() {
|
||||||
|
Assert.assertEquals(
|
||||||
|
controller(mock(SysJobLogService.class)).getDefaultOrderBy(),
|
||||||
|
"scheduled_fire_time desc, id desc");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SysJobLogController controller(SysJobLogService service) {
|
||||||
|
return new SysJobLogController(service, "Asia/Shanghai");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpServletRequest emptyRequest() {
|
||||||
|
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||||
|
when(request.getParameterMap()).thenReturn(Collections.emptyMap());
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int countOccurrences(String source, String expected) {
|
||||||
|
return (source.length() - source.replace(expected, "").length())
|
||||||
|
/ expected.length();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,29 @@
|
|||||||
package tech.easyflow.admin.service.ai;
|
package tech.easyflow.admin.service.ai;
|
||||||
|
|
||||||
import com.easyagents.flow.core.chain.ChainConsts;
|
import com.easyagents.flow.core.chain.*;
|
||||||
|
import com.easyagents.flow.core.chain.repository.*;
|
||||||
|
import com.easyagents.flow.core.chain.runtime.InMemoryTriggerStore;
|
||||||
|
import com.easyagents.flow.core.chain.runtime.TriggerScheduler;
|
||||||
|
import com.easyagents.flow.core.node.StartNode;
|
||||||
|
import com.alibaba.fastjson.JSON;
|
||||||
|
import com.alibaba.fastjson.JSONObject;
|
||||||
|
import java.util.concurrent.*;
|
||||||
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;
|
||||||
|
|
||||||
@@ -69,4 +82,125 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void terminalMustCarryReasonAfterFailedNode() throws Exception {
|
||||||
|
ChainDefinition definition = new ChainDefinition(); definition.setId("sse-test");
|
||||||
|
StartNode start = new StartNode(); start.setId("start"); definition.addNode(start);
|
||||||
|
Node failed = new Node() {
|
||||||
|
@Override public Map<String, Object> execute(Chain chain) {
|
||||||
|
throw new WorkflowExecutionException(WorkflowErrorReason.MODEL_UNAVAILABLE, "PRIVATE_PROVIDER_BODY");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
failed.setId("llm"); failed.setName("模型分析"); definition.addNode(failed);
|
||||||
|
Edge edge = new Edge(); edge.setId("edge"); edge.setSource("start"); edge.setTarget("llm"); definition.addEdge(edge);
|
||||||
|
TriggerScheduler scheduler = new TriggerScheduler(new InMemoryTriggerStore(), Executors.newSingleThreadScheduledExecutor(), Executors.newFixedThreadPool(2), 1000);
|
||||||
|
ChainExecutor executor = new ChainExecutor(id -> definition, new InMemoryChainStateRepository(), new InMemoryNodeStateRepository(), scheduler);
|
||||||
|
List<JSONObject> events = new CopyOnWriteArrayList<>();
|
||||||
|
CountDownLatch complete = new CountDownLatch(1);
|
||||||
|
SseEmitter emitter = new SseEmitter() {
|
||||||
|
@Override public void send(SseEventBuilder event) {
|
||||||
|
event.build().forEach(data -> {
|
||||||
|
String value = String.valueOf(data.getData());
|
||||||
|
if (value.startsWith("{")) events.add(JSON.parseObject(value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
@Override public void complete() { complete.countDown(); }
|
||||||
|
};
|
||||||
|
WorkflowChatEventStream stream = new WorkflowChatEventStream(executor) {
|
||||||
|
@Override SseEmitter createEmitter() { return emitter; }
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
stream.registerListeners(); stream.start("sse-test", Map.of());
|
||||||
|
Assert.assertTrue(complete.await(5, TimeUnit.SECONDS));
|
||||||
|
List<JSONObject> terminals = events.stream().filter(e -> "execution_failed".equals(e.getString("type"))).toList();
|
||||||
|
Assert.assertEquals(terminals.size(), 1);
|
||||||
|
JSONObject detail = terminals.get(0).getJSONObject("data").getJSONObject("error");
|
||||||
|
Assert.assertEquals(detail.getString("reasonCode"), "MODEL_UNAVAILABLE");
|
||||||
|
Assert.assertEquals(detail.getString("nodeId"), "llm");
|
||||||
|
JSONObject ended = events.stream().filter(e -> "node_finished".equals(e.getString("type")) && "llm".equals(e.getJSONObject("data").getString("nodeId"))).findFirst().orElseThrow();
|
||||||
|
Assert.assertEquals(ended.getJSONObject("data").getString("status"), "FAILED");
|
||||||
|
Assert.assertTrue(events.indexOf(ended) < events.indexOf(terminals.get(0)));
|
||||||
|
Assert.assertTrue(ended.getJSONObject("data").get("error") instanceof String);
|
||||||
|
Assert.assertEquals(ended.getJSONObject("data").getJSONObject("errorDetail").getString("reasonCode"), "MODEL_UNAVAILABLE");
|
||||||
|
Assert.assertFalse(JSON.toJSONString(events).contains("PRIVATE_PROVIDER_BODY"));
|
||||||
|
} finally { stream.shutdown(); scheduler.shutdown(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
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,7 +6,10 @@ 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;
|
||||||
@@ -129,6 +132,96 @@ public class WorkflowDesignerOptionServiceTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldAcceptCompatibleMultiKnowledgeReferences() {
|
||||||
|
ModelService modelService = mock(ModelService.class);
|
||||||
|
DocumentCollectionService knowledgeService =
|
||||||
|
mock(DocumentCollectionService.class);
|
||||||
|
ResourceAccessService accessService = mock(ResourceAccessService.class);
|
||||||
|
when(accessService.canAccess(any(), any(), any(), any()))
|
||||||
|
.thenReturn(true);
|
||||||
|
when(knowledgeService.listByIds(any()))
|
||||||
|
.thenReturn(List.of(
|
||||||
|
knowledge(1, 7, 3),
|
||||||
|
knowledge(2, 7, 3)));
|
||||||
|
when(modelService.listModelInstances(any()))
|
||||||
|
.thenReturn(List.of(embeddingModel(7)));
|
||||||
|
WorkflowDesignerOptionService service = createService(
|
||||||
|
modelService,
|
||||||
|
knowledgeService,
|
||||||
|
mock(DatacenterSourceService.class),
|
||||||
|
mock(WorkflowService.class),
|
||||||
|
accessService);
|
||||||
|
|
||||||
|
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||||
|
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(loginAccount());
|
||||||
|
|
||||||
|
service.assertContentReferences("""
|
||||||
|
{"nodes":[{"type":"knowledgeNode","data":{
|
||||||
|
"knowledgeIds":["1","2"],"retrievalMode":"VECTOR"
|
||||||
|
}}]}
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldRejectIncompatibleMultiKnowledgeReferences() {
|
||||||
|
ModelService modelService = mock(ModelService.class);
|
||||||
|
DocumentCollectionService knowledgeService =
|
||||||
|
mock(DocumentCollectionService.class);
|
||||||
|
ResourceAccessService accessService = mock(ResourceAccessService.class);
|
||||||
|
when(accessService.canAccess(any(), any(), any(), any()))
|
||||||
|
.thenReturn(true);
|
||||||
|
when(knowledgeService.listByIds(any()))
|
||||||
|
.thenReturn(List.of(
|
||||||
|
knowledge(1, 7, 3),
|
||||||
|
knowledge(2, 8, 3)));
|
||||||
|
when(modelService.listModelInstances(any()))
|
||||||
|
.thenReturn(List.of(embeddingModel(7), embeddingModel(8)));
|
||||||
|
WorkflowDesignerOptionService service = createService(
|
||||||
|
modelService,
|
||||||
|
knowledgeService,
|
||||||
|
mock(DatacenterSourceService.class),
|
||||||
|
mock(WorkflowService.class),
|
||||||
|
accessService);
|
||||||
|
|
||||||
|
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||||
|
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(loginAccount());
|
||||||
|
|
||||||
|
BusinessException exception = Assert.expectThrows(
|
||||||
|
BusinessException.class,
|
||||||
|
() -> service.assertContentReferences("""
|
||||||
|
{"nodes":[{"type":"knowledgeNode","data":{
|
||||||
|
"knowledgeIds":["1","2"],"retrievalMode":"VECTOR"
|
||||||
|
}}]}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
Assert.assertTrue(exception.getMessage().contains("Embedding"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldRejectConflictingRootAndDataNodeTypes() {
|
||||||
|
WorkflowDesignerOptionService service = createService(
|
||||||
|
mock(ModelService.class),
|
||||||
|
mock(DocumentCollectionService.class),
|
||||||
|
mock(DatacenterSourceService.class));
|
||||||
|
|
||||||
|
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||||
|
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(loginAccount());
|
||||||
|
|
||||||
|
BusinessException exception = Assert.expectThrows(
|
||||||
|
BusinessException.class,
|
||||||
|
() -> service.assertContentReferences("""
|
||||||
|
{"nodes":[{"type":"knowledgeNode","data":{
|
||||||
|
"type":"llmNode","knowledgeId":"1"
|
||||||
|
}}]}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
Assert.assertTrue(exception.getMessage().contains("类型"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private WorkflowDesignerOptionService createService(
|
private WorkflowDesignerOptionService createService(
|
||||||
ModelService modelService,
|
ModelService modelService,
|
||||||
DocumentCollectionService knowledgeService,
|
DocumentCollectionService knowledgeService,
|
||||||
@@ -142,6 +235,20 @@ 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,
|
||||||
@@ -156,10 +263,40 @@ 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);
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package tech.easyflow.admin.service.ai;
|
||||||
|
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.data.redis.core.script.DefaultRedisScript;
|
||||||
|
import org.testng.Assert;
|
||||||
|
import org.testng.annotations.Test;
|
||||||
|
import tech.easyflow.common.cache.RedisLockExecutor;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyList;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link WorkflowPublicChatAccessGuard} Redis 失败关闭测试。
|
||||||
|
*/
|
||||||
|
public class WorkflowPublicChatAccessGuardTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldExposeDocumentedProtectionDefaults() {
|
||||||
|
WorkflowPublicShareProperties properties =
|
||||||
|
new WorkflowPublicShareProperties();
|
||||||
|
|
||||||
|
Assert.assertEquals(properties.getRunVisitorLimit(), 5);
|
||||||
|
Assert.assertEquals(properties.getRunShareLimit(), 60);
|
||||||
|
Assert.assertEquals(properties.getUploadVisitorLimit(), 10);
|
||||||
|
Assert.assertEquals(properties.getUploadShareLimit(), 60);
|
||||||
|
Assert.assertEquals(properties.getRateWindow(), Duration.ofMinutes(1));
|
||||||
|
Assert.assertEquals(properties.getActiveLease(), Duration.ofMinutes(35));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldReturn429WhenFixedWindowIsExceeded() {
|
||||||
|
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
|
||||||
|
RedisLockExecutor lockExecutor = mock(RedisLockExecutor.class);
|
||||||
|
when(redisTemplate.execute(
|
||||||
|
any(DefaultRedisScript.class),
|
||||||
|
anyList(),
|
||||||
|
anyString(),
|
||||||
|
anyString(),
|
||||||
|
anyString()
|
||||||
|
)).thenReturn(0L);
|
||||||
|
WorkflowPublicChatAccessGuard guard = new WorkflowPublicChatAccessGuard(
|
||||||
|
redisTemplate,
|
||||||
|
lockExecutor,
|
||||||
|
new WorkflowPublicShareProperties()
|
||||||
|
);
|
||||||
|
|
||||||
|
BusinessException error = Assert.expectThrows(
|
||||||
|
BusinessException.class,
|
||||||
|
() -> guard.checkRun(BigInteger.ONE, "visitor")
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.assertEquals(error.getHttpStatus(), 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldReturn503WhenRedisRateLimitIsUnavailable() {
|
||||||
|
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
|
||||||
|
RedisLockExecutor lockExecutor = mock(RedisLockExecutor.class);
|
||||||
|
when(redisTemplate.execute(
|
||||||
|
any(DefaultRedisScript.class),
|
||||||
|
anyList(),
|
||||||
|
anyString(),
|
||||||
|
anyString(),
|
||||||
|
anyString()
|
||||||
|
)).thenThrow(new IllegalStateException("redis unavailable"));
|
||||||
|
WorkflowPublicChatAccessGuard guard = new WorkflowPublicChatAccessGuard(
|
||||||
|
redisTemplate,
|
||||||
|
lockExecutor,
|
||||||
|
new WorkflowPublicShareProperties()
|
||||||
|
);
|
||||||
|
|
||||||
|
BusinessException error = Assert.expectThrows(
|
||||||
|
BusinessException.class,
|
||||||
|
() -> guard.checkUpload(BigInteger.ONE, "visitor")
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.assertEquals(error.getHttpStatus(), 503);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package tech.easyflow.admin.service.ai;
|
||||||
|
|
||||||
|
import org.testng.Assert;
|
||||||
|
import org.testng.annotations.Test;
|
||||||
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
|
import tech.easyflow.ai.entity.WorkflowShare;
|
||||||
|
import tech.easyflow.ai.enums.PublishStatus;
|
||||||
|
import tech.easyflow.ai.service.WorkflowService;
|
||||||
|
import tech.easyflow.ai.service.WorkflowShareService;
|
||||||
|
import tech.easyflow.ai.share.WorkflowSharePolicy;
|
||||||
|
import tech.easyflow.common.constant.enums.EnumDataStatus;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
import tech.easyflow.system.entity.SysAccount;
|
||||||
|
import tech.easyflow.system.service.SysAccountService;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link WorkflowPublicChatContextResolver} 匿名主体边界测试。
|
||||||
|
*/
|
||||||
|
public class WorkflowPublicChatContextResolverTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldUseCurrentShareCreatorAsPermissionSubject() {
|
||||||
|
Fixture fixture = fixture(EnumDataStatus.AVAILABLE.getCode());
|
||||||
|
|
||||||
|
WorkflowPublicChatContext context = fixture.resolver.resolveActive(
|
||||||
|
"share-key",
|
||||||
|
"00112233445566778899aabbccddeeff"
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.assertEquals(context.creator().getId(), BigInteger.TEN);
|
||||||
|
Assert.assertEquals(context.creator().getTenantId(), BigInteger.ONE);
|
||||||
|
Assert.assertEquals(
|
||||||
|
context.visitorDigest(),
|
||||||
|
WorkflowSharePolicy.hashChatVisitor(
|
||||||
|
"share-key",
|
||||||
|
"00112233445566778899aabbccddeeff"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldRejectDisabledShareCreator() {
|
||||||
|
Fixture fixture = fixture(EnumDataStatus.UNAVAILABLE.getCode());
|
||||||
|
|
||||||
|
BusinessException error = Assert.expectThrows(
|
||||||
|
BusinessException.class,
|
||||||
|
() -> fixture.resolver.resolveActive(
|
||||||
|
"share-key",
|
||||||
|
"00112233445566778899aabbccddeeff"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.assertEquals(error.getHttpStatus(), 403);
|
||||||
|
Assert.assertTrue(error.getMessage().contains("创建者账号"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldResolveHistoricalShareWithoutCurrentCreatorCheck() {
|
||||||
|
Fixture fixture = fixture(EnumDataStatus.UNAVAILABLE.getCode());
|
||||||
|
|
||||||
|
WorkflowPublicChatContext context = fixture.resolver.resolveHistorical(
|
||||||
|
"share-key",
|
||||||
|
"00112233445566778899aabbccddeeff"
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.assertNull(context.creator());
|
||||||
|
Assert.assertNull(context.workflow());
|
||||||
|
verify(fixture.accountService, never()).getById(BigInteger.TEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldRejectMalformedVisitorIdentity() {
|
||||||
|
Fixture fixture = fixture(EnumDataStatus.AVAILABLE.getCode());
|
||||||
|
|
||||||
|
BusinessException error = Assert.expectThrows(
|
||||||
|
BusinessException.class,
|
||||||
|
() -> fixture.resolver.resolveActive("share-key", "short")
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.assertEquals(error.getErrorCode(), 40031);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Fixture fixture(Integer accountStatus) {
|
||||||
|
WorkflowShareService shareService = mock(WorkflowShareService.class);
|
||||||
|
WorkflowService workflowService = mock(WorkflowService.class);
|
||||||
|
SysAccountService accountService = mock(SysAccountService.class);
|
||||||
|
|
||||||
|
WorkflowShare share = new WorkflowShare();
|
||||||
|
share.setId(BigInteger.valueOf(7));
|
||||||
|
share.setWorkflowId(BigInteger.valueOf(11));
|
||||||
|
share.setTenantId(BigInteger.ONE);
|
||||||
|
share.setCreatedBy(BigInteger.TEN);
|
||||||
|
|
||||||
|
Workflow workflow = new Workflow();
|
||||||
|
workflow.setId(BigInteger.valueOf(11));
|
||||||
|
workflow.setTenantId(BigInteger.ONE);
|
||||||
|
workflow.setPublishStatus(PublishStatus.PUBLISHED.getCode());
|
||||||
|
workflow.setPublishedSnapshotJson(Map.of("content", "{}"));
|
||||||
|
|
||||||
|
SysAccount account = new SysAccount();
|
||||||
|
account.setId(BigInteger.TEN);
|
||||||
|
account.setTenantId(BigInteger.ONE);
|
||||||
|
account.setStatus(accountStatus);
|
||||||
|
|
||||||
|
when(shareService.resolvePublicChatShare("share-key"))
|
||||||
|
.thenReturn(share);
|
||||||
|
when(shareService.resolveHistoricalChatShare("share-key"))
|
||||||
|
.thenReturn(share);
|
||||||
|
when(workflowService.getPublishedById(BigInteger.valueOf(11)))
|
||||||
|
.thenReturn(workflow);
|
||||||
|
when(accountService.getById(BigInteger.TEN)).thenReturn(account);
|
||||||
|
|
||||||
|
return new Fixture(
|
||||||
|
new WorkflowPublicChatContextResolver(
|
||||||
|
shareService,
|
||||||
|
workflowService,
|
||||||
|
accountService
|
||||||
|
),
|
||||||
|
accountService
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Fixture(
|
||||||
|
WorkflowPublicChatContextResolver resolver,
|
||||||
|
SysAccountService accountService
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
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> runtimeSnapshot = new WorkflowChatEventStream(fixture.chainExecutor).runtimeView("execution-1");
|
||||||
|
when(fixture.eventStream.runtimeView("execution-1")).thenReturn(runtimeSnapshot);
|
||||||
|
|
||||||
|
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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package tech.easyflow.admin.service.ai;
|
||||||
|
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.data.redis.core.ValueOperations;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import org.testng.Assert;
|
||||||
|
import org.testng.annotations.Test;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
||||||
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
|
import tech.easyflow.ai.entity.WorkflowShare;
|
||||||
|
import tech.easyflow.common.filestorage.FileStorageService;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link WorkflowPublicChatUploadService} 上传边界测试。
|
||||||
|
*/
|
||||||
|
public class WorkflowPublicChatUploadServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldStoreDeclaredFileUnderVisitorScope() {
|
||||||
|
Fixture fixture = fixture("file");
|
||||||
|
MultipartFile file = mock(MultipartFile.class);
|
||||||
|
when(file.isEmpty()).thenReturn(false);
|
||||||
|
when(file.getSize()).thenReturn(1024L);
|
||||||
|
when(file.getOriginalFilename()).thenReturn("input.pdf");
|
||||||
|
when(fixture.storageService.save(
|
||||||
|
eq(file), anyString())).thenReturn("/files/input.pdf");
|
||||||
|
|
||||||
|
fixture.service.upload(fixture.context, "attachment", file);
|
||||||
|
|
||||||
|
verify(fixture.accessGuard).checkUpload(
|
||||||
|
BigInteger.valueOf(7), "visitor-digest");
|
||||||
|
verify(fixture.storageService).save(
|
||||||
|
file,
|
||||||
|
"workflow-chat-share/7/visitor-digest"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldRejectReferenceWithoutCurrentVisitorGrant() {
|
||||||
|
Fixture fixture = fixture("file");
|
||||||
|
|
||||||
|
BusinessException error = Assert.expectThrows(
|
||||||
|
BusinessException.class,
|
||||||
|
() -> fixture.service.assertOwnedUploads(
|
||||||
|
fixture.context,
|
||||||
|
Map.of("attachment", List.of(Map.of(
|
||||||
|
"fileName", "input.pdf",
|
||||||
|
"filePath", "/files/other.pdf"
|
||||||
|
)))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.assertEquals(error.getHttpStatus(), 403);
|
||||||
|
Assert.assertEquals(error.getErrorCode(), 40332);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldRejectGrantCreatedForDifferentParameterType() {
|
||||||
|
Fixture fixture = fixture("image");
|
||||||
|
when(fixture.valueOperations.get(anyString())).thenReturn("file");
|
||||||
|
|
||||||
|
BusinessException error = Assert.expectThrows(
|
||||||
|
BusinessException.class,
|
||||||
|
() -> fixture.service.assertOwnedUploads(
|
||||||
|
fixture.context,
|
||||||
|
Map.of("attachment", Map.of(
|
||||||
|
"sourceType", "upload",
|
||||||
|
"filePath", "/files/input.png"
|
||||||
|
))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.assertEquals(error.getHttpStatus(), 403);
|
||||||
|
Assert.assertEquals(error.getErrorCode(), 40332);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldRejectUnsupportedImageType() {
|
||||||
|
Fixture fixture = fixture("image");
|
||||||
|
MultipartFile file = mock(MultipartFile.class);
|
||||||
|
when(file.isEmpty()).thenReturn(false);
|
||||||
|
when(file.getSize()).thenReturn(1024L);
|
||||||
|
when(file.getContentType()).thenReturn("image/svg+xml");
|
||||||
|
when(file.getOriginalFilename()).thenReturn("input.svg");
|
||||||
|
|
||||||
|
BusinessException error = Assert.expectThrows(
|
||||||
|
BusinessException.class,
|
||||||
|
() -> fixture.service.upload(
|
||||||
|
fixture.context, "attachment", file)
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.assertTrue(error.getMessage().contains("PNG"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private Fixture fixture(String contentType) {
|
||||||
|
WorkflowRunningParameterResolver parameterResolver = mock(
|
||||||
|
WorkflowRunningParameterResolver.class);
|
||||||
|
WorkflowPublicChatAccessGuard accessGuard = mock(
|
||||||
|
WorkflowPublicChatAccessGuard.class);
|
||||||
|
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
|
||||||
|
ValueOperations<String, String> valueOperations = mock(
|
||||||
|
ValueOperations.class);
|
||||||
|
FileStorageService storageService = mock(FileStorageService.class);
|
||||||
|
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||||
|
|
||||||
|
Workflow workflow = new Workflow();
|
||||||
|
workflow.setId(BigInteger.valueOf(11));
|
||||||
|
when(parameterResolver.buildRunningParametersView(workflow))
|
||||||
|
.thenReturn(Map.of(
|
||||||
|
"startFormSchema",
|
||||||
|
List.of(Map.of(
|
||||||
|
"key", "attachment",
|
||||||
|
"contentType", contentType
|
||||||
|
))
|
||||||
|
));
|
||||||
|
|
||||||
|
WorkflowShare share = new WorkflowShare();
|
||||||
|
share.setId(BigInteger.valueOf(7));
|
||||||
|
share.setExpiresAt(new Date(
|
||||||
|
System.currentTimeMillis() + 60_000L));
|
||||||
|
WorkflowPublicChatContext context = new WorkflowPublicChatContext(
|
||||||
|
share,
|
||||||
|
workflow,
|
||||||
|
null,
|
||||||
|
"share-key",
|
||||||
|
"visitor-digest"
|
||||||
|
);
|
||||||
|
WorkflowPublicChatUploadService service =
|
||||||
|
new WorkflowPublicChatUploadService(
|
||||||
|
parameterResolver,
|
||||||
|
accessGuard,
|
||||||
|
new WorkflowPublicShareProperties(),
|
||||||
|
redisTemplate,
|
||||||
|
storageService
|
||||||
|
);
|
||||||
|
return new Fixture(
|
||||||
|
service,
|
||||||
|
context,
|
||||||
|
accessGuard,
|
||||||
|
redisTemplate,
|
||||||
|
valueOperations,
|
||||||
|
storageService
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Fixture(
|
||||||
|
WorkflowPublicChatUploadService service,
|
||||||
|
WorkflowPublicChatContext context,
|
||||||
|
WorkflowPublicChatAccessGuard accessGuard,
|
||||||
|
StringRedisTemplate redisTemplate,
|
||||||
|
ValueOperations<String, String> valueOperations,
|
||||||
|
FileStorageService storageService
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
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;
|
||||||
@@ -18,11 +14,15 @@ 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,9 +33,7 @@ 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;
|
||||||
@@ -81,8 +79,6 @@ 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;
|
||||||
|
|
||||||
@@ -351,37 +347,20 @@ 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 DocumentChunk documentChunk,
|
@JsonBody(required = true, skipConvertError = false)
|
||||||
|
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);
|
||||||
boolean success = documentChunkService.updateById(documentChunk);
|
DocumentChunk updated = documentChunkService.updateContent(
|
||||||
if (success) {
|
knowledgeId,
|
||||||
DocumentCollection knowledge = documentCollectionService.getById(knowledgeId);
|
current.getId(),
|
||||||
DocumentStore documentStore = knowledge.toDocumentStore();
|
documentChunk.getContent()
|
||||||
if (documentStore == null) {
|
);
|
||||||
return Result.fail(2, "知识库没有配置向量库");
|
audit(apiKey, "API更新文档 Chunk", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", knowledgeId, "chunkId", documentChunk.getId()));
|
||||||
}
|
return Result.ok(DocumentChunkAsyncUpdateResult.from(updated));
|
||||||
try {
|
|
||||||
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
|
|
||||||
if (model == null) {
|
|
||||||
return Result.fail(3, "知识库没有配置向量模型");
|
|
||||||
}
|
|
||||||
EmbeddingModel embeddingModel = model.toEmbeddingModel();
|
|
||||||
documentStore.setEmbeddingModel(embeddingModel);
|
|
||||||
StoreOptions options = StoreOptions.ofCollectionName(knowledge.getVectorStoreCollection());
|
|
||||||
com.easyagents.core.document.Document doc = com.easyagents.core.document.Document.of(documentChunk.getContent());
|
|
||||||
doc.setId(current.getId());
|
|
||||||
StoreResult result = documentStore.update(doc, options);
|
|
||||||
audit(apiKey, "API更新文档 Chunk", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", knowledgeId, "chunkId", documentChunk.getId()));
|
|
||||||
return Result.ok(result);
|
|
||||||
} finally {
|
|
||||||
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Result.ok(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -397,25 +376,51 @@ 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);
|
||||||
DocumentCollection knowledge = documentCollectionService.getById(knowledgeId);
|
DocumentChunkDeleteResult removed = documentChunkService.deleteChunk(
|
||||||
DocumentStore documentStore = knowledge.toDocumentStore();
|
knowledgeId,
|
||||||
if (documentStore == null) {
|
chunkId
|
||||||
return Result.fail(2, "知识库没有配置向量库");
|
);
|
||||||
}
|
audit(apiKey, "API删除文档 Chunk", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", knowledgeId, "chunkId", chunkId));
|
||||||
try {
|
return Result.ok(removed != null);
|
||||||
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
|
}
|
||||||
if (model == null) {
|
|
||||||
return Result.fail(3, "知识库没有配置向量模型");
|
@PostMapping("/documentChunk/syncStatus")
|
||||||
}
|
public Result<List<DocumentChunkSyncStatus>> documentChunkSyncStatus(
|
||||||
documentStore.setEmbeddingModel(model.toEmbeddingModel());
|
@RequestHeader("ApiKey") String apiKey,
|
||||||
StoreOptions options = StoreOptions.ofCollectionName(knowledge.getVectorStoreCollection());
|
@JsonBody(value = "knowledgeId", required = true) BigInteger knowledgeId,
|
||||||
documentStore.delete(Collections.singletonList(chunkId), options);
|
@JsonBody(required = true, skipConvertError = false)
|
||||||
documentChunkService.removeById(chunkId);
|
DocumentChunkSyncStatusRequest statusRequest,
|
||||||
audit(apiKey, "API删除文档 Chunk", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", knowledgeId, "chunkId", chunkId));
|
HttpServletRequest request
|
||||||
return Result.ok(true);
|
) {
|
||||||
} finally {
|
assertApiShare(apiKey, request.getRequestURI(), knowledgeId,
|
||||||
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package tech.easyflow.publicapi.controller;
|
package tech.easyflow.publicapi.controller;
|
||||||
|
|
||||||
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowExecutionErrorMapper;
|
||||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||||
import cn.dev33.satoken.stp.StpUtil;
|
import cn.dev33.satoken.stp.StpUtil;
|
||||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||||
@@ -18,6 +19,7 @@ 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;
|
||||||
@@ -70,6 +72,8 @@ 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;
|
||||||
@@ -122,6 +126,7 @@ public class PublicWorkflowController {
|
|||||||
if (StpUtil.isLogin()) {
|
if (StpUtil.isLogin()) {
|
||||||
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
|
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
|
||||||
}
|
}
|
||||||
|
WorkflowExecutionErrorMapper.installRequestProfile();
|
||||||
Map<String, Object> res = chainExecutor.executeNode(workflowId.toString(), nodeId, variables);
|
Map<String, Object> res = chainExecutor.executeNode(workflowId.toString(), nodeId, variables);
|
||||||
return Result.ok(res);
|
return Result.ok(res);
|
||||||
}
|
}
|
||||||
@@ -250,14 +255,7 @@ 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);
|
||||||
if (!chainExecutor.resumeAsyncIfSuspended(
|
workflowResumeService.resume(executeId, confirmParams);
|
||||||
executeId,
|
|
||||||
confirmParams)) {
|
|
||||||
throw new BusinessException(
|
|
||||||
409,
|
|
||||||
40901,
|
|
||||||
"当前执行状态不可恢复,仅暂停中的工作流允许恢复");
|
|
||||||
}
|
|
||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,5 +22,10 @@ public record PublicWorkflowNodeStatus(
|
|||||||
PublicWorkflowExecutionStatus status,
|
PublicWorkflowExecutionStatus status,
|
||||||
String message,
|
String message,
|
||||||
Map<String, Object> result,
|
Map<String, Object> result,
|
||||||
List<Parameter> suspendForParameters) implements Serializable {
|
List<Parameter> suspendForParameters,
|
||||||
|
PublicWorkflowStatusError error) implements Serializable {
|
||||||
|
public PublicWorkflowNodeStatus(String nodeId, String nodeName, PublicWorkflowExecutionStatus status,
|
||||||
|
String message, Map<String, Object> result, List<Parameter> suspendForParameters) {
|
||||||
|
this(nodeId, nodeName, status, message, result, suspendForParameters, null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ public class PublicWorkflowStatusError implements Serializable {
|
|||||||
private static final long serialVersionUID = 1L;
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
private final String code;
|
private final String code;
|
||||||
|
private final String reasonCode;
|
||||||
private final String message;
|
private final String message;
|
||||||
private final String nodeId;
|
private final String nodeId;
|
||||||
private final String nodeName;
|
private final String nodeName;
|
||||||
@@ -30,7 +31,13 @@ public class PublicWorkflowStatusError implements Serializable {
|
|||||||
String nodeId,
|
String nodeId,
|
||||||
String nodeName,
|
String nodeName,
|
||||||
boolean retryable) {
|
boolean retryable) {
|
||||||
|
this(code, null, message, nodeId, nodeName, retryable);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PublicWorkflowStatusError(String code, String reasonCode, String message,
|
||||||
|
String nodeId, String nodeName, boolean retryable) {
|
||||||
this.code = code;
|
this.code = code;
|
||||||
|
this.reasonCode = reasonCode;
|
||||||
this.message = message;
|
this.message = message;
|
||||||
this.nodeId = nodeId;
|
this.nodeId = nodeId;
|
||||||
this.nodeName = nodeName;
|
this.nodeName = nodeName;
|
||||||
@@ -46,6 +53,8 @@ public class PublicWorkflowStatusError implements Serializable {
|
|||||||
return code;
|
return code;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getReasonCode() { return reasonCode; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取安全消息。
|
* 获取安全消息。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -394,6 +394,7 @@ 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)
|
||||||
@@ -412,7 +413,8 @@ 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) {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
package tech.easyflow.publicapi.service;
|
package tech.easyflow.publicapi.service;
|
||||||
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.util.StringUtils;
|
|
||||||
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.entity.WorkflowExecutionError;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowExecutionErrorMapper;
|
||||||
import tech.easyflow.publicapi.dto.PublicWorkflowChainStatus;
|
import tech.easyflow.publicapi.dto.PublicWorkflowChainStatus;
|
||||||
import tech.easyflow.publicapi.dto.PublicWorkflowExecutionStatus;
|
import tech.easyflow.publicapi.dto.PublicWorkflowExecutionStatus;
|
||||||
import tech.easyflow.publicapi.dto.PublicWorkflowNodeStatus;
|
import tech.easyflow.publicapi.dto.PublicWorkflowNodeStatus;
|
||||||
@@ -12,130 +13,44 @@ import tech.easyflow.publicapi.dto.PublicWorkflowStatusError;
|
|||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/** 复用公共错误规范,兼容没有结构化错误的旧状态。 */
|
||||||
* 将内部工作流执行错误转换为 Public API 安全状态。
|
|
||||||
*/
|
|
||||||
@Service
|
@Service
|
||||||
public class PublicWorkflowStatusSanitizer {
|
public class PublicWorkflowStatusSanitizer {
|
||||||
|
|
||||||
private static final String CHAIN_FAILED_MESSAGE =
|
|
||||||
"工作流执行失败,请检查输入或稍后重试";
|
|
||||||
private static final String NODE_FAILED_MESSAGE =
|
|
||||||
"节点执行失败,请检查输入或稍后重试";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 复制执行状态并移除异常类名、底层地址和内部错误详情。
|
|
||||||
*
|
|
||||||
* @param source 内部执行状态
|
|
||||||
* @return 可公开状态
|
|
||||||
*/
|
|
||||||
public PublicWorkflowChainStatus sanitize(ChainInfo source) {
|
public PublicWorkflowChainStatus sanitize(ChainInfo source) {
|
||||||
if (source == null) {
|
if (source == null) throw new IllegalArgumentException("source must not be null");
|
||||||
throw new IllegalArgumentException(
|
PublicWorkflowExecutionStatus status = PublicWorkflowExecutionStatus.fromChainStatus(source.getStatus());
|
||||||
"source must not be null");
|
Map<String, PublicWorkflowNodeStatus> nodes = new LinkedHashMap<>();
|
||||||
}
|
|
||||||
PublicWorkflowExecutionStatus chainStatus =
|
|
||||||
PublicWorkflowExecutionStatus.fromChainStatus(
|
|
||||||
source.getStatus());
|
|
||||||
|
|
||||||
Map<String, PublicWorkflowNodeStatus> safeNodes =
|
|
||||||
new LinkedHashMap<>();
|
|
||||||
PublicWorkflowStatusError firstNodeError = null;
|
PublicWorkflowStatusError firstNodeError = null;
|
||||||
if (source.getNodes() != null) {
|
if (source.getNodes() != null) {
|
||||||
for (Map.Entry<String, NodeInfo> entry
|
for (var entry : source.getNodes().entrySet()) {
|
||||||
: source.getNodes().entrySet()) {
|
NodeInfo node = entry.getValue();
|
||||||
PublicWorkflowNodeStatus safeNode = copyNode(
|
if (node == null) continue;
|
||||||
entry.getValue());
|
PublicWorkflowExecutionStatus nodeStatus = PublicWorkflowExecutionStatus.fromNodeStatus(node.getStatus());
|
||||||
safeNodes.put(entry.getKey(), safeNode);
|
PublicWorkflowStatusError error = copyError(node.getError(), false, nodeStatus, node.getNodeId(), node.getNodeName(), status.isTerminal());
|
||||||
if (firstNodeError == null
|
nodes.put(entry.getKey(), new PublicWorkflowNodeStatus(node.getNodeId(), node.getNodeName(), nodeStatus,
|
||||||
&& StringUtils.hasText(safeNode.message())) {
|
error == null ? null : error.getMessage(), node.getResult(), node.getSuspendForParameters(), error));
|
||||||
firstNodeError = new PublicWorkflowStatusError(
|
if (firstNodeError == null && error != null) firstNodeError = error;
|
||||||
"NODE_EXECUTION_FAILED",
|
|
||||||
safeNode.message(),
|
|
||||||
safeNode.nodeId(),
|
|
||||||
safeNode.nodeName(),
|
|
||||||
isRetryable(safeNode.status()));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
PublicWorkflowStatusError error = copyError(source.getError(), true, status,
|
||||||
String message = null;
|
firstNodeError == null ? null : firstNodeError.getNodeId(),
|
||||||
PublicWorkflowStatusError error = null;
|
firstNodeError == null ? null : firstNodeError.getNodeName(), status.isTerminal());
|
||||||
if (StringUtils.hasText(source.getMessage())) {
|
// 暂态节点错误仍可查询;成功、取消和挂起不携带旧错误。
|
||||||
message = chainMessage(chainStatus);
|
if (error == null && status == PublicWorkflowExecutionStatus.RUNNING) error = firstNodeError;
|
||||||
error = new PublicWorkflowStatusError(
|
return new PublicWorkflowChainStatus(source.getExecuteId(), status, status.isTerminal(),
|
||||||
"WORKFLOW_EXECUTION_FAILED",
|
error == null ? null : error.getMessage(), source.getResult(), nodes, error);
|
||||||
message,
|
|
||||||
firstNodeError == null
|
|
||||||
? null
|
|
||||||
: firstNodeError.getNodeId(),
|
|
||||||
firstNodeError == null
|
|
||||||
? null
|
|
||||||
: firstNodeError.getNodeName(),
|
|
||||||
isRetryable(chainStatus));
|
|
||||||
} else if (firstNodeError != null) {
|
|
||||||
error = firstNodeError;
|
|
||||||
}
|
|
||||||
return new PublicWorkflowChainStatus(
|
|
||||||
source.getExecuteId(),
|
|
||||||
chainStatus,
|
|
||||||
chainStatus.isTerminal(),
|
|
||||||
message,
|
|
||||||
source.getResult(),
|
|
||||||
safeNodes,
|
|
||||||
error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private PublicWorkflowStatusError copyError(WorkflowExecutionError source, boolean workflow,
|
||||||
* 复制并脱敏单个节点状态。
|
PublicWorkflowExecutionStatus status, String nodeId, String nodeName, boolean executionTerminal) {
|
||||||
*
|
if (status != PublicWorkflowExecutionStatus.FAILED && status != PublicWorkflowExecutionStatus.ERROR) return null;
|
||||||
* @param source 内部节点状态
|
if (source != null) {
|
||||||
* @return 安全节点状态
|
nodeId = source.getNodeId();
|
||||||
*/
|
nodeName = source.getNodeName();
|
||||||
private PublicWorkflowNodeStatus copyNode(NodeInfo source) {
|
|
||||||
if (source == null) {
|
|
||||||
return new PublicWorkflowNodeStatus(
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
PublicWorkflowExecutionStatus.UNKNOWN,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null);
|
|
||||||
}
|
}
|
||||||
return new PublicWorkflowNodeStatus(
|
WorkflowExecutionError safe = WorkflowExecutionErrorMapper.fromReason(source == null ? null : source.getReasonCode(), workflow, nodeId, nodeName,
|
||||||
source.getNodeId(),
|
status == PublicWorkflowExecutionStatus.ERROR && !executionTerminal);
|
||||||
source.getNodeName(),
|
return new PublicWorkflowStatusError(safe.getCode(), safe.getReasonCode(), safe.getMessage(),
|
||||||
PublicWorkflowExecutionStatus.fromNodeStatus(
|
safe.getNodeId(), safe.getNodeName(), safe.isRetryable());
|
||||||
source.getStatus()),
|
|
||||||
StringUtils.hasText(source.getMessage())
|
|
||||||
? NODE_FAILED_MESSAGE
|
|
||||||
: null,
|
|
||||||
source.getResult(),
|
|
||||||
source.getSuspendForParameters());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据工作流状态生成安全消息。
|
|
||||||
*
|
|
||||||
* @param status 可读状态
|
|
||||||
* @return 安全消息
|
|
||||||
*/
|
|
||||||
private String chainMessage(
|
|
||||||
PublicWorkflowExecutionStatus status) {
|
|
||||||
if (status == PublicWorkflowExecutionStatus.CANCELLED) {
|
|
||||||
return "工作流执行已取消";
|
|
||||||
}
|
|
||||||
return CHAIN_FAILED_MESSAGE;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断执行状态是否仍可能由运行时继续处理。
|
|
||||||
*
|
|
||||||
* @param status 可读状态
|
|
||||||
* @return 是否可重试
|
|
||||||
*/
|
|
||||||
private boolean isRetryable(
|
|
||||||
PublicWorkflowExecutionStatus status) {
|
|
||||||
return status == PublicWorkflowExecutionStatus.ERROR;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ 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,6 +12,7 @@ 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;
|
||||||
@@ -44,6 +45,7 @@ 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;
|
||||||
|
|
||||||
@@ -54,6 +56,7 @@ 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);
|
||||||
@@ -79,6 +82,10 @@ public class PublicWorkflowControllerBehaviorTest {
|
|||||||
controller,
|
controller,
|
||||||
"chainExecutor",
|
"chainExecutor",
|
||||||
chainExecutor);
|
chainExecutor);
|
||||||
|
ReflectionTestUtils.setField(
|
||||||
|
controller,
|
||||||
|
"workflowResumeService",
|
||||||
|
workflowResumeService);
|
||||||
ReflectionTestUtils.setField(
|
ReflectionTestUtils.setField(
|
||||||
controller,
|
controller,
|
||||||
"tinyFlowService",
|
"tinyFlowService",
|
||||||
@@ -108,10 +115,12 @@ 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");
|
||||||
when(chainExecutor.resumeAsyncIfSuspended(
|
Mockito.doThrow(new BusinessException(
|
||||||
EXECUTE_ID,
|
409,
|
||||||
Map.of("approved", true)))
|
40901,
|
||||||
.thenReturn(false);
|
"当前执行状态不可恢复,仅暂停中的工作流允许恢复"))
|
||||||
|
.when(workflowResumeService)
|
||||||
|
.resume(EXECUTE_ID, Map.of("approved", true));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
controller.resume(
|
controller.resume(
|
||||||
@@ -124,7 +133,7 @@ public class PublicWorkflowControllerBehaviorTest {
|
|||||||
Assert.assertEquals(40901, exception.getErrorCode());
|
Assert.assertEquals(40901, exception.getErrorCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
verify(chainExecutor).resumeAsyncIfSuspended(
|
verify(workflowResumeService).resume(
|
||||||
EXECUTE_ID,
|
EXECUTE_ID,
|
||||||
Map.of("approved", true));
|
Map.of("approved", true));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,6 +167,28 @@ 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 无效和两层权限错误保持可区分。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package tech.easyflow.publicapi.service;
|
package tech.easyflow.publicapi.service;
|
||||||
|
|
||||||
import com.easyagents.flow.core.chain.ChainStatus;
|
import com.easyagents.flow.core.chain.ChainStatus;
|
||||||
|
import com.easyagents.flow.core.chain.WorkflowErrorReason;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.entity.WorkflowExecutionError;
|
||||||
import com.easyagents.flow.core.chain.NodeStatus;
|
import com.easyagents.flow.core.chain.NodeStatus;
|
||||||
import org.junit.Assert;
|
import org.junit.Assert;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
@@ -39,11 +41,11 @@ public class PublicWorkflowStatusSanitizerTest {
|
|||||||
PublicWorkflowChainStatus result = sanitizer.sanitize(source);
|
PublicWorkflowChainStatus result = sanitizer.sanitize(source);
|
||||||
|
|
||||||
Assert.assertEquals(
|
Assert.assertEquals(
|
||||||
"工作流执行失败,请检查输入或稍后重试",
|
WorkflowErrorReason.NODE_EXECUTION_FAILED.getDefaultMessage(),
|
||||||
result.message());
|
result.message());
|
||||||
Assert.assertFalse(result.message().contains("minio"));
|
Assert.assertFalse(result.message().contains("minio"));
|
||||||
Assert.assertEquals(
|
Assert.assertEquals(
|
||||||
"节点执行失败,请检查输入或稍后重试",
|
WorkflowErrorReason.NODE_EXECUTION_FAILED.getDefaultMessage(),
|
||||||
result.nodes().get("node-1").message());
|
result.nodes().get("node-1").message());
|
||||||
Assert.assertEquals("node-1", result.error().getNodeId());
|
Assert.assertEquals("node-1", result.error().getNodeId());
|
||||||
Assert.assertFalse(result.error().isRetryable());
|
Assert.assertFalse(result.error().isRetryable());
|
||||||
@@ -101,4 +103,46 @@ public class PublicWorkflowStatusSanitizerTest {
|
|||||||
PublicWorkflowExecutionStatus.RUNNING,
|
PublicWorkflowExecutionStatus.RUNNING,
|
||||||
result.nodes().get("node-1").status());
|
result.nodes().get("node-1").status());
|
||||||
}
|
}
|
||||||
|
@Test
|
||||||
|
public void shouldReturnStructuredReasonWithoutRequestedNodes() {
|
||||||
|
for (WorkflowErrorReason reason : WorkflowErrorReason.values()) {
|
||||||
|
ChainInfo source = new ChainInfo();
|
||||||
|
source.setStatus(ChainStatus.FAILED.getValue());
|
||||||
|
source.setError(new WorkflowExecutionError("WORKFLOW_EXECUTION_FAILED", reason.getCode(),
|
||||||
|
"raw provider body must not escape", "llm", "分析", false));
|
||||||
|
var result = sanitizer.sanitize(source);
|
||||||
|
Assert.assertEquals(reason.getCode(), result.error().getReasonCode());
|
||||||
|
Assert.assertEquals(reason.getDefaultMessage(), result.message());
|
||||||
|
Assert.assertEquals("llm", result.error().getNodeId());
|
||||||
|
Assert.assertTrue(result.nodes().isEmpty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void successfulRetryMustNotExposeStaleErrors() {
|
||||||
|
ChainInfo source = new ChainInfo();
|
||||||
|
source.setStatus(ChainStatus.SUCCEEDED.getValue());
|
||||||
|
source.setMessage("stale error");
|
||||||
|
NodeInfo node = new NodeInfo();
|
||||||
|
node.setNodeId("llm"); node.setStatus(NodeStatus.SUCCEEDED.getValue()); node.setMessage("old failure");
|
||||||
|
source.setNodes(Map.of("llm", node));
|
||||||
|
Assert.assertNull(sanitizer.sanitize(source).error());
|
||||||
|
Assert.assertNull(sanitizer.sanitize(source).nodes().get("llm").message());
|
||||||
|
}
|
||||||
|
@Test
|
||||||
|
public void terminalWorkflowMustNotAdvertiseNodeRetry() {
|
||||||
|
for (ChainStatus status : new ChainStatus[]{ChainStatus.RUNNING, ChainStatus.FAILED, ChainStatus.CANCELLED}) {
|
||||||
|
ChainInfo source = new ChainInfo();
|
||||||
|
source.setStatus(status.getValue());
|
||||||
|
NodeInfo node = new NodeInfo();
|
||||||
|
node.setNodeId("llm");
|
||||||
|
node.setStatus(NodeStatus.ERROR.getValue());
|
||||||
|
node.setError(new WorkflowExecutionError("NODE_EXECUTION_FAILED", "MODEL_TIMEOUT",
|
||||||
|
"raw error", "llm", "模型分析", true));
|
||||||
|
source.setNodes(Map.of("llm", node));
|
||||||
|
var result = sanitizer.sanitize(source);
|
||||||
|
Assert.assertEquals(status == ChainStatus.RUNNING, result.nodes().get("llm").error().isRetryable());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ 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;
|
||||||
@@ -54,6 +55,8 @@ 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) {
|
||||||
@@ -163,12 +166,7 @@ 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) {
|
||||||
if (!chainExecutor.resumeAsyncIfSuspended(executeId, confirmParams)) {
|
workflowResumeService.resume(executeId, confirmParams);
|
||||||
throw new BusinessException(
|
|
||||||
409,
|
|
||||||
40901,
|
|
||||||
"当前执行状态不可恢复,仅暂停中的工作流允许恢复");
|
|
||||||
}
|
|
||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import tech.easyflow.common.annotation.DictDef;
|
|||||||
@DictDef(name = "任务执行结果", code = "jobResult", keyField = "code", labelField = "text")
|
@DictDef(name = "任务执行结果", code = "jobResult", keyField = "code", labelField = "text")
|
||||||
public enum EnumJobResult {
|
public enum EnumJobResult {
|
||||||
|
|
||||||
|
|
||||||
SUCCESS(1,"成功"),
|
SUCCESS(1,"成功"),
|
||||||
FAIL(0,"失败"),
|
FAIL(0,"失败"),
|
||||||
|
PENDING(2,"等待执行"),
|
||||||
|
RUNNING(3,"执行中"),
|
||||||
|
DEAD(4,"需人工处理"),
|
||||||
|
CANCELLED(5,"已取消"),
|
||||||
;
|
;
|
||||||
|
|
||||||
private final int code;
|
private final int code;
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ import tech.easyflow.common.annotation.DictDef;
|
|||||||
@DictDef(name = "错过策略", code = "misfirePolicy", keyField = "code", labelField = "text")
|
@DictDef(name = "错过策略", code = "misfirePolicy", keyField = "code", labelField = "text")
|
||||||
public enum EnumMisfirePolicy {
|
public enum EnumMisfirePolicy {
|
||||||
|
|
||||||
DEFAULT(0,"默认"),
|
FIRE_ONCE_NOW(2,"恢复后补执行一次"),
|
||||||
MISFIRE_IGNORE_MISFIRES(1,"立即触发"),
|
SKIP(3,"跳过本次");
|
||||||
MISFIRE_FIRE_AND_PROCEED(2,"立即触发一次"),
|
|
||||||
MISFIRE_DO_NOTHING(3,"忽略");
|
|
||||||
;
|
;
|
||||||
|
|
||||||
private final int code;
|
private final int code;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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;
|
||||||
@@ -10,6 +11,11 @@ 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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,6 +33,13 @@ 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(
|
||||||
@@ -94,6 +107,66 @@ public class RedisLockExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在自动续租的分布式锁保护下执行任务。
|
||||||
|
*
|
||||||
|
* <p>适用于包含数据库锁等待或外部持久化操作、无法由固定租约严格覆盖的管理命令。
|
||||||
|
* 若执行期间确认锁已丢失,则不向调用方返回成功。</p>
|
||||||
|
*/
|
||||||
|
public void executeWithRenewingLock(
|
||||||
|
String lockKey,
|
||||||
|
Duration waitTimeout,
|
||||||
|
Duration leaseTimeout,
|
||||||
|
Runnable task) {
|
||||||
|
executeWithRenewingLock(lockKey, waitTimeout, leaseTimeout, () -> {
|
||||||
|
task.run();
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在自动续租的分布式锁保护下执行有返回值任务。
|
||||||
|
*/
|
||||||
|
public <T> T executeWithRenewingLock(
|
||||||
|
String lockKey,
|
||||||
|
Duration waitTimeout,
|
||||||
|
Duration leaseTimeout,
|
||||||
|
Supplier<T> task) {
|
||||||
|
LockHandle handle = acquire(lockKey, waitTimeout, leaseTimeout);
|
||||||
|
AtomicBoolean lost = new AtomicBoolean();
|
||||||
|
long renewalIntervalMillis = Math.max(1L, leaseTimeout.toMillis() / 3L);
|
||||||
|
ScheduledFuture<?> renewal = lockRenewalExecutor.scheduleWithFixedDelay(
|
||||||
|
() -> {
|
||||||
|
try {
|
||||||
|
if (!handle.renew()) {
|
||||||
|
lost.set(true);
|
||||||
|
}
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
lost.set(true);
|
||||||
|
log.warn("分布式锁续租失败,当前命令不得返回成功: lockKey={}",
|
||||||
|
lockKey, exception);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
renewalIntervalMillis,
|
||||||
|
renewalIntervalMillis,
|
||||||
|
TimeUnit.MILLISECONDS);
|
||||||
|
try {
|
||||||
|
T result = task.get();
|
||||||
|
if (lost.get()) {
|
||||||
|
throw new IllegalStateException("执行期间分布式锁已丢失,lockKey=" + lockKey);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
renewal.cancel(false);
|
||||||
|
handle.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
public void shutdownLockRenewalExecutor() {
|
||||||
|
lockRenewalExecutor.shutdownNow();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取显式释放的分布式锁句柄。
|
* 获取显式释放的分布式锁句柄。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ 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} 回归测试。
|
||||||
@@ -147,6 +150,96 @@ 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,6 +11,7 @@ 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;
|
||||||
|
|
||||||
@@ -151,6 +152,19 @@ 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,6 +5,7 @@ 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 文件存储统一接口。
|
||||||
@@ -105,6 +106,21 @@ 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,6 +21,7 @@ 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 文件存储实现。
|
||||||
@@ -268,6 +269,47 @@ public class XFIleStorageServiceImpl implements FileStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 x-file-storage 文件记录或服务端平台配置恢复可信物理读取句柄。
|
||||||
|
*
|
||||||
|
* <p>优先使用 recorder 的精确记录。记录不存在时,仅允许与已配置平台 domain、basePath
|
||||||
|
* 及重建后的完整 URL 完全一致的引用。其他 URL 返回空,由上层继续执行公网地址安全校验。</p>
|
||||||
|
*
|
||||||
|
* @param reference 文件 URL
|
||||||
|
* @return 可信文件的物理读取句柄;引用无法确认时为空
|
||||||
|
* @throws IOException 文件记录损坏或平台配置不兼容时抛出
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Optional<FileStorageWriteHandle> resolveTrustedFile(String reference)
|
||||||
|
throws IOException {
|
||||||
|
if (!StringUtils.hasText(reference)) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
FileInfo fileInfo = null;
|
||||||
|
try {
|
||||||
|
fileInfo = fileStorageService.getFileInfoByUrl(reference);
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
// 存储平台配置本身仍可提供精确可信边界,记录器异常不应阻断内部对象读取。
|
||||||
|
LOG.warn("查询 x-file-storage 文件记录失败,继续按服务端存储配置识别,reference={}",
|
||||||
|
reference, exception);
|
||||||
|
}
|
||||||
|
if (fileInfo != null) {
|
||||||
|
if (!reference.equals(fileInfo.getUrl())) {
|
||||||
|
throw new IOException("x-file-storage 文件记录 URL 与请求引用不一致");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
FileStorageWriteHandle handle = handleFromFileInfo(fileInfo);
|
||||||
|
FileStorage storage = requireStorage(handle);
|
||||||
|
requirePersistedBasePathSupport(storage, handle);
|
||||||
|
verifyRecordedLocation(fileInfo, handle);
|
||||||
|
return Optional.of(handle);
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
throw new IOException("x-file-storage 文件记录无法恢复为安全读取位置", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resolveConfiguredStorageReference(reference);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 直接调用句柄指定平台的物理删除与存在检查,绕过依赖 URL 记录的聚合删除路径。
|
* 直接调用句柄指定平台的物理删除与存在检查,绕过依赖 URL 记录的聚合删除路径。
|
||||||
*
|
*
|
||||||
@@ -391,6 +433,108 @@ public class XFIleStorageServiceImpl implements FileStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验 recorder 中的物理定位字段可由恢复句柄无损重建。
|
||||||
|
*
|
||||||
|
* @param fileInfo 服务端文件记录
|
||||||
|
* @param handle 恢复出的物理读取句柄
|
||||||
|
*/
|
||||||
|
private void verifyRecordedLocation(FileInfo fileInfo, FileStorageWriteHandle handle) {
|
||||||
|
String actualBasePath = fileInfo.getBasePath() == null ? "" : fileInfo.getBasePath();
|
||||||
|
String actualPath = fileInfo.getPath() == null ? "" : fileInfo.getPath();
|
||||||
|
if (!handle.getPlatform().equals(fileInfo.getPlatform())
|
||||||
|
|| !handle.getBasePath().equals(actualBasePath)
|
||||||
|
|| !physicalPath(handle).equals(actualPath)
|
||||||
|
|| !handle.getFilename().equals(fileInfo.getFilename())) {
|
||||||
|
throw new IllegalStateException("x-file-storage 文件记录包含非规范物理位置");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 FileInfo 的物理定位字段构造严格校验的读取句柄。
|
||||||
|
*
|
||||||
|
* @param fileInfo 服务端文件信息
|
||||||
|
* @return 可信物理读取句柄
|
||||||
|
*/
|
||||||
|
private FileStorageWriteHandle handleFromFileInfo(FileInfo fileInfo) {
|
||||||
|
String basePath = fileInfo.getBasePath() == null ? "" : fileInfo.getBasePath();
|
||||||
|
return new FileStorageWriteHandle(
|
||||||
|
RECOVERABLE_BACKEND,
|
||||||
|
fileInfo.getPlatform(),
|
||||||
|
basePath,
|
||||||
|
recordedRelativePath(basePath, fileInfo.getPath()),
|
||||||
|
fileInfo.getFilename());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按服务端配置的平台 domain 与 basePath 识别内部存储 URL。
|
||||||
|
*
|
||||||
|
* <p>解析后会再次通过平台自身的 getFileKey 重建完整 URL 并进行精确比较,避免仅凭
|
||||||
|
* host 或字符串前缀放行其他私网目标。</p>
|
||||||
|
*
|
||||||
|
* @param reference 待识别 URL
|
||||||
|
* @return 精确匹配配置的读取句柄;不匹配任何平台时为空
|
||||||
|
* @throws IOException 匹配平台前缀但路径无法安全恢复时抛出
|
||||||
|
*/
|
||||||
|
private Optional<FileStorageWriteHandle> resolveConfiguredStorageReference(
|
||||||
|
String reference) throws IOException {
|
||||||
|
if (fileStorageService.getFileStorageList() == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
for (FileStorage storage : fileStorageService.getFileStorageList()) {
|
||||||
|
String domain = readDomainBestEffort(storage);
|
||||||
|
if (!StringUtils.hasText(domain)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String basePath = readRequiredBasePath(storage);
|
||||||
|
String prefix = domain + basePath;
|
||||||
|
if (!reference.startsWith(prefix)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String remainder = reference.substring(prefix.length());
|
||||||
|
int filenameIndex = remainder.lastIndexOf('/') + 1;
|
||||||
|
FileInfo fileInfo = new FileInfo()
|
||||||
|
.setUrl(reference)
|
||||||
|
.setPlatform(storage.getPlatform())
|
||||||
|
.setBasePath(basePath)
|
||||||
|
.setPath(remainder.substring(0, filenameIndex))
|
||||||
|
.setFilename(remainder.substring(filenameIndex));
|
||||||
|
FileStorageWriteHandle handle = handleFromFileInfo(fileInfo);
|
||||||
|
requirePersistedBasePathSupport(storage, handle);
|
||||||
|
verifyRecordedLocation(fileInfo, handle);
|
||||||
|
if (!reference.equals(deriveUrlBestEffort(storage, toFileInfo(handle)))) {
|
||||||
|
throw new IllegalArgumentException("重建 URL 与请求引用不一致");
|
||||||
|
}
|
||||||
|
return Optional.of(handle);
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
throw new IOException("服务端存储 URL 无法恢复为安全读取位置", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 recorder 保存的 x-file-storage 物理路径还原为句柄相对路径。
|
||||||
|
*
|
||||||
|
* @param basePath 平台基础路径
|
||||||
|
* @param recordedPath recorder 中保存的物理目录
|
||||||
|
* @return 不带前导斜杠的相对目录
|
||||||
|
*/
|
||||||
|
private String recordedRelativePath(String basePath, String recordedPath) {
|
||||||
|
String path = recordedPath == null ? "" : recordedPath;
|
||||||
|
if (basePath.isEmpty() || basePath.endsWith("/")) {
|
||||||
|
if (path.startsWith("/")) {
|
||||||
|
throw new IllegalArgumentException("文件记录路径与平台基础路径格式不一致");
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
if (!path.startsWith("/")) {
|
||||||
|
throw new IllegalArgumentException("文件记录路径缺少必要的前导斜杠");
|
||||||
|
}
|
||||||
|
return path.substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构造仅包含精确物理定位字段的 FileInfo。
|
* 构造仅包含精确物理定位字段的 FileInfo。
|
||||||
*
|
*
|
||||||
@@ -487,16 +631,26 @@ 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;
|
||||||
}
|
}
|
||||||
String domain = (String) method.invoke(storage);
|
return (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,6 +7,7 @@ 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;
|
||||||
@@ -49,6 +50,27 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 可记录可恢复调用的存储测试替身。
|
* 可记录可恢复调用的存储测试替身。
|
||||||
*/
|
*/
|
||||||
@@ -57,6 +79,8 @@ 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 调用次数。 */
|
||||||
@@ -69,6 +93,8 @@ public class FileStorageManagerTest {
|
|||||||
private int deleteCalls;
|
private int deleteCalls;
|
||||||
/** exists 调用次数。 */
|
/** exists 调用次数。 */
|
||||||
private int existsCalls;
|
private int existsCalls;
|
||||||
|
/** 服务端文件记录解析调用次数。 */
|
||||||
|
private int resolveCalls;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建指定名称的存储替身。
|
* 创建指定名称的存储替身。
|
||||||
@@ -80,6 +106,8 @@ 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} */
|
||||||
@@ -114,6 +142,13 @@ 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,6 +212,123 @@ 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 完全缺失目标记录时,精确删除仍直接作用于物理平台并成功。
|
||||||
*
|
*
|
||||||
@@ -471,6 +588,8 @@ public class XFIleStorageServiceImplTest {
|
|||||||
private int recorderDeleteCalls;
|
private int recorderDeleteCalls;
|
||||||
/** recorder 删除是否抛出异常。 */
|
/** recorder 删除是否抛出异常。 */
|
||||||
private boolean recorderDeleteThrows;
|
private boolean recorderDeleteThrows;
|
||||||
|
/** recorder 返回的服务端文件记录。 */
|
||||||
|
private FileInfo recordedFileInfo;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建聚合服务替身。
|
* 创建聚合服务替身。
|
||||||
@@ -479,10 +598,17 @@ 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) { return null; }
|
@Override public FileInfo getByUrl(String url) {
|
||||||
|
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) {
|
||||||
@@ -506,6 +632,15 @@ 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) {
|
||||||
|
|||||||
@@ -72,6 +72,21 @@ public class AgentRuntimeProperties {
|
|||||||
*/
|
*/
|
||||||
private Duration asyncToolTaskTtl = Duration.ofHours(24);
|
private Duration asyncToolTaskTtl = Duration.ofHours(24);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AG-UI 可重连运行事件保留时间。
|
||||||
|
*/
|
||||||
|
private Duration aguiRunRetention = Duration.ofHours(24);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AG-UI 事件日志批量刷入 Redis 的间隔。
|
||||||
|
*/
|
||||||
|
private Duration aguiJournalFlushInterval = Duration.ofMillis(50);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AG-UI 重连订阅轮询 Redis 的间隔。
|
||||||
|
*/
|
||||||
|
private Duration aguiReplayPollInterval = Duration.ofMillis(100);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取 Redis 热态 session 缓存 TTL。
|
* 获取 Redis 热态 session 缓存 TTL。
|
||||||
*
|
*
|
||||||
@@ -90,6 +105,60 @@ public class AgentRuntimeProperties {
|
|||||||
this.sessionCacheTtl = sessionCacheTtl == null ? Duration.ofHours(24) : sessionCacheTtl;
|
this.sessionCacheTtl = sessionCacheTtl == null ? Duration.ofHours(24) : sessionCacheTtl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 AG-UI 可重连运行事件保留时间。
|
||||||
|
*
|
||||||
|
* @return 事件保留时间
|
||||||
|
*/
|
||||||
|
public Duration getAguiRunRetention() {
|
||||||
|
return aguiRunRetention;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置 AG-UI 可重连运行事件保留时间。
|
||||||
|
*
|
||||||
|
* @param aguiRunRetention 事件保留时间
|
||||||
|
*/
|
||||||
|
public void setAguiRunRetention(Duration aguiRunRetention) {
|
||||||
|
this.aguiRunRetention = positiveDuration(aguiRunRetention, Duration.ofHours(24));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 AG-UI 事件日志批量刷入 Redis 的间隔。
|
||||||
|
*
|
||||||
|
* @return 刷盘间隔
|
||||||
|
*/
|
||||||
|
public Duration getAguiJournalFlushInterval() {
|
||||||
|
return aguiJournalFlushInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置 AG-UI 事件日志批量刷入 Redis 的间隔。
|
||||||
|
*
|
||||||
|
* @param aguiJournalFlushInterval 刷盘间隔
|
||||||
|
*/
|
||||||
|
public void setAguiJournalFlushInterval(Duration aguiJournalFlushInterval) {
|
||||||
|
this.aguiJournalFlushInterval = positiveDuration(aguiJournalFlushInterval, Duration.ofMillis(50));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 AG-UI 重连订阅轮询 Redis 的间隔。
|
||||||
|
*
|
||||||
|
* @return 轮询间隔
|
||||||
|
*/
|
||||||
|
public Duration getAguiReplayPollInterval() {
|
||||||
|
return aguiReplayPollInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置 AG-UI 重连订阅轮询 Redis 的间隔。
|
||||||
|
*
|
||||||
|
* @param aguiReplayPollInterval 轮询间隔
|
||||||
|
*/
|
||||||
|
public void setAguiReplayPollInterval(Duration aguiReplayPollInterval) {
|
||||||
|
this.aguiReplayPollInterval = positiveDuration(aguiReplayPollInterval, Duration.ofMillis(100));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取当前 Agent 运行实例 ID。
|
* 获取当前 Agent 运行实例 ID。
|
||||||
*
|
*
|
||||||
@@ -281,6 +350,10 @@ public class AgentRuntimeProperties {
|
|||||||
this.asyncToolTaskTtl = asyncToolTaskTtl == null ? Duration.ofHours(24) : asyncToolTaskTtl;
|
this.asyncToolTaskTtl = asyncToolTaskTtl == null ? Duration.ofHours(24) : asyncToolTaskTtl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Duration positiveDuration(Duration value, Duration fallback) {
|
||||||
|
return value == null || value.isZero() || value.isNegative() ? fallback : value;
|
||||||
|
}
|
||||||
|
|
||||||
private static String defaultInstanceId() {
|
private static String defaultInstanceId() {
|
||||||
String envInstanceId = System.getenv("EASYFLOW_INSTANCE_ID");
|
String envInstanceId = System.getenv("EASYFLOW_INSTANCE_ID");
|
||||||
if (StringUtils.hasText(envInstanceId)) {
|
if (StringUtils.hasText(envInstanceId)) {
|
||||||
|
|||||||
@@ -23,5 +23,10 @@ public enum AgentRuntimeCommandAction {
|
|||||||
/**
|
/**
|
||||||
* 取消指定 Agent 在目标节点上的全部运行。
|
* 取消指定 Agent 在目标节点上的全部运行。
|
||||||
*/
|
*/
|
||||||
CANCEL_AGENT
|
CANCEL_AGENT,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消指定的单次运行。
|
||||||
|
*/
|
||||||
|
CANCEL_RUN
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,9 @@ public class AgentRuntimeCommandConsumer implements MQConsumerHandler {
|
|||||||
command.getRequestId(), command.getResumeToken(), command.getReason());
|
command.getRequestId(), command.getResumeToken(), command.getReason());
|
||||||
} else if (command.getAction() == AgentRuntimeCommandAction.CANCEL_AGENT) {
|
} else if (command.getAction() == AgentRuntimeCommandAction.CANCEL_AGENT) {
|
||||||
agentRunService.cancelAgentLocal(command.getAgentId());
|
agentRunService.cancelAgentLocal(command.getAgentId());
|
||||||
|
} else if (command.getAction() == AgentRuntimeCommandAction.CANCEL_RUN) {
|
||||||
|
agentRunService.cancelRunLocal(
|
||||||
|
command.getRequestId(), command.getUserId(), command.getReason());
|
||||||
} else {
|
} else {
|
||||||
markFailureQuietly(command, new IllegalArgumentException("不支持的 Agent 远程运行命令"));
|
markFailureQuietly(command, new IllegalArgumentException("不支持的 Agent 远程运行命令"));
|
||||||
LOG.warn("跳过不支持的 Agent 远程运行命令: messageId={}, commandId={}, action={}",
|
LOG.warn("跳过不支持的 Agent 远程运行命令: messageId={}, commandId={}, action={}",
|
||||||
|
|||||||
@@ -175,6 +175,21 @@ public class AgentRuntimeCommandProducer {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 投递远程单次运行取消命令。
|
||||||
|
*
|
||||||
|
* @param targetNodeId 目标节点 ID
|
||||||
|
* @param requestId 内部请求 ID
|
||||||
|
* @param userId 当前用户 ID
|
||||||
|
* @param reason 取消原因
|
||||||
|
*/
|
||||||
|
public void sendCancelRun(String targetNodeId, String requestId, String userId, String reason) {
|
||||||
|
sendAndWait(
|
||||||
|
targetNodeId, requestId, null, null, null,
|
||||||
|
AgentRuntimeCommandAction.CANCEL_RUN, reason, null, userId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 投递远程运行命令并等待目标节点确认。
|
* 投递远程运行命令并等待目标节点确认。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -93,9 +93,13 @@ public class AgentRunRegistry {
|
|||||||
*/
|
*/
|
||||||
public void bindSubscription(String requestId, Disposable subscription) {
|
public void bindSubscription(String requestId, Disposable subscription) {
|
||||||
AgentRunContext context = runs.get(requestId);
|
AgentRunContext context = runs.get(requestId);
|
||||||
if (context != null) {
|
if (context == null) {
|
||||||
context.setSubscription(subscription);
|
if (subscription != null && !subscription.isDisposed()) {
|
||||||
|
subscription.dispose();
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
context.setSubscription(subscription);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -152,6 +156,31 @@ public class AgentRunRegistry {
|
|||||||
remove(requestId);
|
remove(requestId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显式取消当前节点上的单次运行,并通过原事件处理链生成规范取消终态。
|
||||||
|
*
|
||||||
|
* @param requestId 内部请求 ID
|
||||||
|
* @param userId 当前用户 ID
|
||||||
|
* @param reason 取消原因
|
||||||
|
*/
|
||||||
|
public void cancelRun(String requestId, String userId, String reason) {
|
||||||
|
if (requestId == null || requestId.isBlank()) {
|
||||||
|
throw new BusinessException("Agent 运行请求 ID 不能为空");
|
||||||
|
}
|
||||||
|
AgentRunContext context = runs.get(requestId);
|
||||||
|
if (context == null) {
|
||||||
|
// 取消命令允许因自然终态、重复投递或跨节点竞态而幂等到达。
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assertOwner(requestId, userId);
|
||||||
|
context.cancel();
|
||||||
|
AgentRuntimeEvent event = AgentRuntimeEvent.of(
|
||||||
|
com.easyagents.agent.runtime.event.AgentRuntimeEventType.CANCELLED);
|
||||||
|
event.getPayload().put("reason",
|
||||||
|
reason == null || reason.isBlank() ? "用户已停止生成" : reason);
|
||||||
|
context.eventConsumer().accept(event);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 取消当前节点上指定 Agent 的全部活跃运行。
|
* 取消当前节点上指定 Agent 的全部活跃运行。
|
||||||
*
|
*
|
||||||
@@ -314,34 +343,80 @@ public class AgentRunRegistry {
|
|||||||
if (requestId == null) {
|
if (requestId == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
RuntimeException cleanupFailure = null;
|
||||||
AgentRunContext context = runs.remove(requestId);
|
AgentRunContext context = runs.remove(requestId);
|
||||||
if (context != null) {
|
if (context != null) {
|
||||||
sessionRuns.remove(context.sessionId(), requestId);
|
sessionRuns.remove(context.sessionId(), requestId);
|
||||||
context.releaseLock();
|
try {
|
||||||
|
context.releaseLock();
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
cleanupFailure = mergeCleanupFailure(
|
||||||
|
cleanupFailure, exception, requestId, "release distributed lock");
|
||||||
|
}
|
||||||
|
// Redis 解锁失败也不能阻止底层模型、工具与订阅资源释放。
|
||||||
context.closeRuntime();
|
context.closeRuntime();
|
||||||
}
|
}
|
||||||
owners.remove(requestId);
|
owners.remove(requestId);
|
||||||
Set<String> tokens = requestTokens.remove(requestId);
|
Set<String> tokens = requestTokens.remove(requestId);
|
||||||
if (tokens != null) {
|
if (tokens != null) {
|
||||||
tokens.forEach(token -> {
|
for (String token : tokens) {
|
||||||
resumeTokenIndex.remove(token);
|
resumeTokenIndex.remove(token);
|
||||||
if (routeRegistry != null) {
|
if (routeRegistry != null) {
|
||||||
routeRegistry.removeResumeToken(token);
|
try {
|
||||||
|
routeRegistry.removeResumeToken(token);
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
cleanupFailure = mergeCleanupFailure(
|
||||||
|
cleanupFailure, exception, requestId, "remove resume token route");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
Set<String> approvals = requestApprovals.remove(requestId);
|
Set<String> approvals = requestApprovals.remove(requestId);
|
||||||
if (approvals != null) {
|
if (approvals != null) {
|
||||||
approvals.forEach(approvalId -> {
|
for (String approvalId : approvals) {
|
||||||
approvalTargets.remove(approvalId);
|
approvalTargets.remove(approvalId);
|
||||||
if (routeRegistry != null) {
|
if (routeRegistry != null) {
|
||||||
routeRegistry.removeApproval(approvalId);
|
try {
|
||||||
|
routeRegistry.removeApproval(approvalId);
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
cleanupFailure = mergeCleanupFailure(
|
||||||
|
cleanupFailure, exception, requestId, "remove approval route");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
if (routeRegistry != null) {
|
if (routeRegistry != null) {
|
||||||
routeRegistry.removeRun(requestId);
|
try {
|
||||||
|
routeRegistry.removeRun(requestId);
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
cleanupFailure = mergeCleanupFailure(
|
||||||
|
cleanupFailure, exception, requestId, "remove run route");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (cleanupFailure != null) {
|
||||||
|
throw cleanupFailure;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并运行清理异常,同时保留第一个异常作为调用方可见的根因。
|
||||||
|
*
|
||||||
|
* @param current 当前已记录异常
|
||||||
|
* @param next 本次清理异常
|
||||||
|
* @param requestId 请求 ID
|
||||||
|
* @param phase 失败阶段
|
||||||
|
* @return 合并后的首异常
|
||||||
|
*/
|
||||||
|
private RuntimeException mergeCleanupFailure(RuntimeException current,
|
||||||
|
RuntimeException next,
|
||||||
|
String requestId,
|
||||||
|
String phase) {
|
||||||
|
LOG.warn("Agent runtime cleanup failed, requestId={}, phase={}", requestId, phase, next);
|
||||||
|
if (current == null) {
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
current.addSuppressed(next);
|
||||||
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -684,10 +759,18 @@ public class AgentRunRegistry {
|
|||||||
if (subscription == null) {
|
if (subscription == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (finished.get()) {
|
||||||
|
subscription.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
Disposable previous = this.subscription.getAndSet(subscription);
|
Disposable previous = this.subscription.getAndSet(subscription);
|
||||||
if (previous != null && !previous.isDisposed()) {
|
if (previous != null && !previous.isDisposed()) {
|
||||||
previous.dispose();
|
previous.dispose();
|
||||||
}
|
}
|
||||||
|
if (finished.get() && this.subscription.compareAndSet(subscription, null)
|
||||||
|
&& !subscription.isDisposed()) {
|
||||||
|
subscription.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,12 @@
|
|||||||
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.AgentKnowledgeRetriever;
|
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRegistration;
|
||||||
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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,7 +16,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 Map<String, AgentKnowledgeRetriever> knowledgeRetrievers = new LinkedHashMap<>();
|
private List<AgentKnowledgeRegistration> knowledgeRegistrations = new ArrayList<>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取 Agent 定义。
|
* 获取 Agent 定义。
|
||||||
@@ -57,16 +59,18 @@ public class AgentRuntimeBundle {
|
|||||||
*
|
*
|
||||||
* @return 知识库检索器
|
* @return 知识库检索器
|
||||||
*/
|
*/
|
||||||
public Map<String, AgentKnowledgeRetriever> getKnowledgeRetrievers() {
|
public List<AgentKnowledgeRegistration> getKnowledgeRegistrations() {
|
||||||
return knowledgeRetrievers;
|
return knowledgeRegistrations;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置知识库检索器。
|
* 设置知识库检索器。
|
||||||
*
|
*
|
||||||
* @param knowledgeRetrievers 知识库检索器
|
* @param knowledgeRegistrations 知识库运行时绑定
|
||||||
*/
|
*/
|
||||||
public void setKnowledgeRetrievers(Map<String, AgentKnowledgeRetriever> knowledgeRetrievers) {
|
public void setKnowledgeRegistrations(List<AgentKnowledgeRegistration> knowledgeRegistrations) {
|
||||||
this.knowledgeRetrievers = knowledgeRetrievers == null ? new LinkedHashMap<>() : knowledgeRetrievers;
|
this.knowledgeRegistrations = knowledgeRegistrations == null
|
||||||
|
? new ArrayList<>()
|
||||||
|
: new ArrayList<>(knowledgeRegistrations);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ 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.AgentKnowledgePolicy;
|
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRegistration;
|
||||||
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;
|
||||||
@@ -118,11 +119,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,7 +295,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());
|
assertToolBudget(specs, definition.getMcpSpecs(), definition.getKnowledgeSpecs().size());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void attachBuiltinTools(Agent agent,
|
private void attachBuiltinTools(Agent agent,
|
||||||
@@ -510,11 +511,21 @@ 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()));
|
||||||
@@ -540,6 +551,14 @@ 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) {
|
||||||
@@ -570,7 +589,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");
|
||||||
@@ -591,12 +610,27 @@ public class AgentRuntimeCompiler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 EasyFlow 知识库绑定编译为一库一工具所需的声明和 Retriever 绑定。
|
||||||
|
*
|
||||||
|
* @param agent Agent 发布视图
|
||||||
|
* @param definition 中立 Agent 定义
|
||||||
|
* @param bundle 运行时编译结果
|
||||||
|
* @throws BusinessException 知识库不存在、英文运行名非法或工具名冲突时抛出
|
||||||
|
*/
|
||||||
private void compileKnowledge(Agent agent, AgentDefinition definition, AgentRuntimeBundle bundle) {
|
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<>();
|
||||||
Map<String, com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetriever> retrievers = new LinkedHashMap<>();
|
List<AgentKnowledgeRegistration> registrations = new ArrayList<>();
|
||||||
|
Set<String> knowledgeToolNames = new LinkedHashSet<>();
|
||||||
|
Set<String> existingToolNames = new LinkedHashSet<>();
|
||||||
|
definition.getToolSpecs().stream()
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.map(AgentToolSpec::getName)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.forEach(existingToolNames::add);
|
||||||
for (AgentKnowledgeBinding binding : agent.getKnowledgeBindings()) {
|
for (AgentKnowledgeBinding binding : agent.getKnowledgeBindings()) {
|
||||||
if (!Boolean.TRUE.equals(binding.getEnabled())) {
|
if (!Boolean.TRUE.equals(binding.getEnabled())) {
|
||||||
continue;
|
continue;
|
||||||
@@ -607,9 +641,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");
|
||||||
@@ -618,11 +652,37 @@ 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);
|
||||||
retrievers.put(spec.getKnowledgeId(), request -> retrieveKnowledge(binding, request.getQuery(), request.getLimit(), request.getScoreThreshold()));
|
registrations.add(new AgentKnowledgeRegistration(spec,
|
||||||
|
request -> retrieveKnowledge(binding, request.getQuery(), request.getLimit(), request.getScoreThreshold())));
|
||||||
}
|
}
|
||||||
definition.setKnowledgeSpecs(specs);
|
definition.setKnowledgeSpecs(specs);
|
||||||
bundle.setKnowledgeRetrievers(retrievers);
|
bundle.setKnowledgeRegistrations(registrations);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取并校验知识库英文运行名。
|
||||||
|
*
|
||||||
|
* @param knowledge 知识库发布视图
|
||||||
|
* @return 合法英文运行名
|
||||||
|
* @throws BusinessException 英文运行名缺失或非法时抛出
|
||||||
|
*/
|
||||||
|
private String requireKnowledgeRuntimeName(DocumentCollection knowledge) {
|
||||||
|
String runtimeName = knowledge == null ? null : knowledge.getEnglishName();
|
||||||
|
try {
|
||||||
|
AgentKnowledgeToolNames.build(runtimeName);
|
||||||
|
return runtimeName.trim();
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
String knowledgeName = knowledge == null || knowledge.getTitle() == null
|
||||||
|
? "未知知识库"
|
||||||
|
: knowledge.getTitle();
|
||||||
|
throw new BusinessException(400, 400, "知识库“" + knowledgeName
|
||||||
|
+ "”的英文名称不能为空,且只能包含字母、数字、下划线和连字符", exception);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private AgentKnowledgeRetrievalResult retrieveKnowledge(AgentKnowledgeBinding binding, String query, int limit, double scoreThreshold) {
|
private AgentKnowledgeRetrievalResult retrieveKnowledge(AgentKnowledgeBinding binding, String query, int limit, double scoreThreshold) {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package tech.easyflow.agent.runtime.agui;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AG-UI 可重连运行的公开状态与内部路由描述。
|
||||||
|
*
|
||||||
|
* @param runId 客户端运行 ID
|
||||||
|
* @param requestId 服务端内部请求 ID
|
||||||
|
* @param threadId AG-UI thread ID
|
||||||
|
* @param agentId Agent ID
|
||||||
|
* @param sessionId 运行会话 ID
|
||||||
|
* @param userId 所属用户 ID
|
||||||
|
* @param tenantId 所属租户 ID
|
||||||
|
* @param draft 是否为草稿试运行
|
||||||
|
* @param status 当前运行状态
|
||||||
|
* @param lastCursor 已持久化的最后事件游标
|
||||||
|
* @param createdAt 创建时间戳
|
||||||
|
* @param updatedAt 最近更新时间戳
|
||||||
|
*/
|
||||||
|
public record AgentAguiRunDescriptor(
|
||||||
|
String runId,
|
||||||
|
String requestId,
|
||||||
|
String threadId,
|
||||||
|
String agentId,
|
||||||
|
String sessionId,
|
||||||
|
String userId,
|
||||||
|
String tenantId,
|
||||||
|
boolean draft,
|
||||||
|
AgentAguiRunStatus status,
|
||||||
|
long lastCursor,
|
||||||
|
long createdAt,
|
||||||
|
long updatedAt) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
package tech.easyflow.agent.runtime.agui;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import com.easyagents.agui.AguiExtendedEvent;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
import java.util.concurrent.locks.ReentrantLock;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将高频 AG-UI delta 在内存中短暂合并并批量写入 Redis。
|
||||||
|
*
|
||||||
|
* <p>该组件以每个 runId 的同步缓冲保证事件顺序;Redis 故障时保留原批次等待下一次重试,
|
||||||
|
* 避免每个模型 token 都产生一次网络往返。</p>
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class AgentAguiRunJournal {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(AgentAguiRunJournal.class);
|
||||||
|
private static final int MAX_BUFFERED_EVENTS_PER_RUN = 4096;
|
||||||
|
private static final long MAX_EVENT_BYTES = 512L * 1024L;
|
||||||
|
private static final long MAX_BUFFERED_BYTES_PER_RUN = 4L * 1024L * 1024L;
|
||||||
|
private static final long MAX_BUFFERED_BYTES_GLOBAL = 64L * 1024L * 1024L;
|
||||||
|
private static final long COMPLETED_RETRY_WINDOW_MILLIS = 5L * 60L * 1000L;
|
||||||
|
|
||||||
|
private final AgentAguiRunStore store;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final Map<String, RunBuffer> buffers = new ConcurrentHashMap<>();
|
||||||
|
private final AtomicLong globalBufferedBytes = new AtomicLong();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 AG-UI 事件日志协调器。
|
||||||
|
*
|
||||||
|
* @param store Redis 运行存储
|
||||||
|
* @param objectMapper JSON 映射器
|
||||||
|
*/
|
||||||
|
public AgentAguiRunJournal(AgentAguiRunStore store, ObjectMapper objectMapper) {
|
||||||
|
this.store = store;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追加一条已编码协议事件。
|
||||||
|
*
|
||||||
|
* @param runId 运行 ID
|
||||||
|
* @param data 协议事件 JSON
|
||||||
|
*/
|
||||||
|
public void append(String runId, String data) {
|
||||||
|
if (data == null || data.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("AG-UI 事件不能为空");
|
||||||
|
}
|
||||||
|
long dataBytes = utf8Bytes(data);
|
||||||
|
if (dataBytes > MAX_EVENT_BYTES) {
|
||||||
|
throw new IllegalStateException("单条 AG-UI 事件超过 512 KiB 安全上限");
|
||||||
|
}
|
||||||
|
RunBuffer buffer = buffers.computeIfAbsent(runId, ignored -> new RunBuffer());
|
||||||
|
try {
|
||||||
|
synchronized (buffer) {
|
||||||
|
if (buffer.completed) {
|
||||||
|
throw new IllegalStateException("AG-UI 运行事件日志已经收口");
|
||||||
|
}
|
||||||
|
int pendingEventCount = buffer.pendingBatch == null ? 0 : buffer.pendingBatch.events().size();
|
||||||
|
if (pendingEventCount + buffer.tailEvents.size() >= MAX_BUFFERED_EVENTS_PER_RUN) {
|
||||||
|
throw new IllegalStateException("AG-UI 事件日志暂时不可用,请稍后重试");
|
||||||
|
}
|
||||||
|
String merged = buffer.tailEvents.isEmpty()
|
||||||
|
? null
|
||||||
|
: mergeDelta(buffer.tailEvents.get(buffer.tailEvents.size() - 1), data);
|
||||||
|
long previousBytes = 0L;
|
||||||
|
long nextBytes = dataBytes;
|
||||||
|
if (merged != null) {
|
||||||
|
String previous = buffer.tailEvents.get(buffer.tailEvents.size() - 1);
|
||||||
|
previousBytes = utf8Bytes(previous);
|
||||||
|
nextBytes = utf8Bytes(merged);
|
||||||
|
if (nextBytes > MAX_EVENT_BYTES) {
|
||||||
|
throw new IllegalStateException("合并后的 AG-UI 事件超过 512 KiB 安全上限");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
long deltaBytes = nextBytes - previousBytes;
|
||||||
|
if (buffer.totalBytes() + deltaBytes > MAX_BUFFERED_BYTES_PER_RUN) {
|
||||||
|
throw new IllegalStateException("单次 AG-UI 运行待写日志超过 4 MiB 安全上限");
|
||||||
|
}
|
||||||
|
long globalBytes = globalBufferedBytes.addAndGet(deltaBytes);
|
||||||
|
if (globalBytes > MAX_BUFFERED_BYTES_GLOBAL) {
|
||||||
|
globalBufferedBytes.addAndGet(-deltaBytes);
|
||||||
|
throw new IllegalStateException("AG-UI 待写日志超过 64 MiB 全局安全上限");
|
||||||
|
}
|
||||||
|
if (merged == null) {
|
||||||
|
buffer.tailEvents.add(data);
|
||||||
|
} else {
|
||||||
|
buffer.tailEvents.set(buffer.tailEvents.size() - 1, merged);
|
||||||
|
}
|
||||||
|
buffer.tailBytes += deltaBytes;
|
||||||
|
}
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
removeEmptyBuffer(runId, buffer);
|
||||||
|
throw exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 丢弃尚未确认持久化的普通事件,并以独立小批次记录日志故障终态。
|
||||||
|
*
|
||||||
|
* <p>该路径保留已经成功写入 Redis 的历史。即使前一批次处于结果不确定状态,
|
||||||
|
* 新终态也使用新的批次 ID,避免幂等判重吞掉故障事件。</p>
|
||||||
|
*
|
||||||
|
* @param threadId AG-UI 线程 ID
|
||||||
|
* @param runId 运行 ID
|
||||||
|
* @param code 稳定错误码
|
||||||
|
* @param message 用户可理解的错误信息
|
||||||
|
*/
|
||||||
|
public void fail(String threadId, String runId, String code, String message) {
|
||||||
|
String terminalEvent;
|
||||||
|
try {
|
||||||
|
terminalEvent = objectMapper.writeValueAsString(new AguiExtendedEvent.RunError(
|
||||||
|
threadId, runId, message, code));
|
||||||
|
} catch (Exception exception) {
|
||||||
|
throw new IllegalStateException("AG-UI 日志故障终态编码失败", exception);
|
||||||
|
}
|
||||||
|
long terminalBytes = utf8Bytes(terminalEvent);
|
||||||
|
RunBuffer buffer = buffers.computeIfAbsent(runId, ignored -> new RunBuffer());
|
||||||
|
synchronized (buffer) {
|
||||||
|
long discardedBytes = buffer.totalBytes();
|
||||||
|
buffer.pendingBatch = null;
|
||||||
|
buffer.tailEvents.clear();
|
||||||
|
buffer.tailBytes = 0L;
|
||||||
|
if (discardedBytes > 0L) {
|
||||||
|
globalBufferedBytes.addAndGet(-discardedBytes);
|
||||||
|
}
|
||||||
|
buffer.pendingBatch = new PendingBatch(
|
||||||
|
UUID.randomUUID().toString(), List.of(terminalEvent), terminalBytes);
|
||||||
|
globalBufferedBytes.addAndGet(terminalBytes);
|
||||||
|
buffer.completed = true;
|
||||||
|
buffer.completedAt = System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
flush(runId, buffer, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在运行收口前同步尝试刷入该运行的剩余事件。
|
||||||
|
*
|
||||||
|
* @param runId 运行 ID
|
||||||
|
*/
|
||||||
|
public void flush(String runId) {
|
||||||
|
RunBuffer buffer = buffers.get(runId);
|
||||||
|
if (buffer != null) {
|
||||||
|
flush(runId, buffer, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步刷入终态事件,并在确认缓冲为空后释放本地运行缓冲。
|
||||||
|
*
|
||||||
|
* @param runId 运行 ID
|
||||||
|
*/
|
||||||
|
public void complete(String runId) {
|
||||||
|
RunBuffer buffer = buffers.get(runId);
|
||||||
|
if (buffer == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
synchronized (buffer) {
|
||||||
|
buffer.completed = true;
|
||||||
|
buffer.completedAt = System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
flush(runId, buffer, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定期批量写入所有活动运行的待持久化事件。
|
||||||
|
*/
|
||||||
|
@Scheduled(fixedDelayString = "${easyflow.agent.runtime.agui-journal-flush-interval:50ms}")
|
||||||
|
public void flushPending() {
|
||||||
|
for (Map.Entry<String, RunBuffer> entry : buffers.entrySet()) {
|
||||||
|
flush(entry.getKey(), entry.getValue(), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void flush(String runId, RunBuffer buffer, boolean propagateFailure) {
|
||||||
|
boolean acquired;
|
||||||
|
if (propagateFailure) {
|
||||||
|
buffer.flushLock.lock();
|
||||||
|
acquired = true;
|
||||||
|
} else {
|
||||||
|
acquired = buffer.flushLock.tryLock();
|
||||||
|
}
|
||||||
|
if (!acquired) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
PendingBatch batch;
|
||||||
|
synchronized (buffer) {
|
||||||
|
if (buffer.pendingBatch == null && !buffer.tailEvents.isEmpty()) {
|
||||||
|
buffer.pendingBatch = new PendingBatch(
|
||||||
|
UUID.randomUUID().toString(),
|
||||||
|
List.copyOf(buffer.tailEvents),
|
||||||
|
buffer.tailBytes);
|
||||||
|
buffer.tailEvents.clear();
|
||||||
|
buffer.tailBytes = 0L;
|
||||||
|
}
|
||||||
|
batch = buffer.pendingBatch;
|
||||||
|
if (batch == null) {
|
||||||
|
if (buffer.completed) {
|
||||||
|
removeBuffer(runId, buffer);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Redis I/O 必须位于 RunBuffer 锁外,避免阻塞模型事件线程。
|
||||||
|
store.append(runId, batch.id(), batch.events());
|
||||||
|
synchronized (buffer) {
|
||||||
|
if (buffer.pendingBatch == batch) {
|
||||||
|
buffer.pendingBatch = null;
|
||||||
|
globalBufferedBytes.addAndGet(-batch.bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
LOG.error("AG-UI 事件日志写入失败: runId={}, eventCount={}",
|
||||||
|
runId, batch.events().size(), exception);
|
||||||
|
boolean batchWasReplaced;
|
||||||
|
synchronized (buffer) {
|
||||||
|
batchWasReplaced = buffer.pendingBatch != batch;
|
||||||
|
if (!batchWasReplaced && buffer.completed && buffer.completedAt > 0L
|
||||||
|
&& System.currentTimeMillis() - buffer.completedAt
|
||||||
|
>= COMPLETED_RETRY_WINDOW_MILLIS) {
|
||||||
|
LOG.error("丢弃超过重试窗口的 AG-UI 终态日志缓冲: runId={}, eventCount={}, bytes={}",
|
||||||
|
runId, buffer.eventCount(), buffer.totalBytes());
|
||||||
|
removeBuffer(runId, buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (batchWasReplaced) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (propagateFailure) {
|
||||||
|
throw exception;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
buffer.flushLock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void removeEmptyBuffer(String runId, RunBuffer buffer) {
|
||||||
|
synchronized (buffer) {
|
||||||
|
if (buffer.pendingBatch == null && buffer.tailEvents.isEmpty() && !buffer.flushLock.isLocked()) {
|
||||||
|
buffers.remove(runId, buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void removeBuffer(String runId, RunBuffer buffer) {
|
||||||
|
if (!buffers.remove(runId, buffer)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
long remainingBytes = buffer.totalBytes();
|
||||||
|
buffer.pendingBatch = null;
|
||||||
|
buffer.tailEvents.clear();
|
||||||
|
buffer.tailBytes = 0L;
|
||||||
|
if (remainingBytes > 0L) {
|
||||||
|
globalBufferedBytes.addAndGet(-remainingBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private long utf8Bytes(String value) {
|
||||||
|
return value.getBytes(StandardCharsets.UTF_8).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String mergeDelta(String previous, String current) {
|
||||||
|
try {
|
||||||
|
JsonNode previousNode = objectMapper.readTree(previous);
|
||||||
|
JsonNode currentNode = objectMapper.readTree(current);
|
||||||
|
String type = currentNode.path("type").asText();
|
||||||
|
if (!type.equals(previousNode.path("type").asText()) || !isMergeable(type)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String identityField = "TOOL_CALL_ARGS".equals(type) ? "toolCallId" : "messageId";
|
||||||
|
if (!currentNode.path(identityField).asText().equals(previousNode.path(identityField).asText())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!(previousNode instanceof ObjectNode previousObject)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
previousObject.put("delta",
|
||||||
|
previousNode.path("delta").asText() + currentNode.path("delta").asText());
|
||||||
|
return objectMapper.writeValueAsString(previousObject);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isMergeable(String type) {
|
||||||
|
return "TEXT_MESSAGE_CONTENT".equals(type)
|
||||||
|
|| "REASONING_MESSAGE_CONTENT".equals(type)
|
||||||
|
|| "TOOL_CALL_ARGS".equals(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record PendingBatch(String id, List<String> events, long bytes) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class RunBuffer {
|
||||||
|
private final List<String> tailEvents = new ArrayList<>();
|
||||||
|
private final ReentrantLock flushLock = new ReentrantLock();
|
||||||
|
private PendingBatch pendingBatch;
|
||||||
|
private long tailBytes;
|
||||||
|
private boolean completed;
|
||||||
|
private long completedAt;
|
||||||
|
|
||||||
|
private int eventCount() {
|
||||||
|
return tailEvents.size() + (pendingBatch == null ? 0 : pendingBatch.events().size());
|
||||||
|
}
|
||||||
|
|
||||||
|
private long totalBytes() {
|
||||||
|
return tailBytes + (pendingBatch == null ? 0L : pendingBatch.bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package tech.easyflow.agent.runtime.agui;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AG-UI 可重连运行状态。
|
||||||
|
*/
|
||||||
|
public enum AgentAguiRunStatus {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行正在生成或等待人工审批。
|
||||||
|
*/
|
||||||
|
RUNNING,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行成功完成。
|
||||||
|
*/
|
||||||
|
COMPLETED,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行失败。
|
||||||
|
*/
|
||||||
|
FAILED,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行被用户显式取消。
|
||||||
|
*/
|
||||||
|
CANCELLED;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断当前状态是否已经终止。
|
||||||
|
*
|
||||||
|
* @return 已终止时为 true
|
||||||
|
*/
|
||||||
|
public boolean isTerminal() {
|
||||||
|
return this != RUNNING;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package tech.easyflow.agent.runtime.agui;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提供给前端刷新恢复判断的 AG-UI 运行状态。
|
||||||
|
*
|
||||||
|
* @param runId 运行 ID
|
||||||
|
* @param threadId AG-UI thread ID
|
||||||
|
* @param status 当前状态
|
||||||
|
* @param lastCursor 最后事件游标
|
||||||
|
* @param draft 是否为草稿试运行
|
||||||
|
* @param updatedAt 最近更新时间戳
|
||||||
|
*/
|
||||||
|
public record AgentAguiRunStatusView(
|
||||||
|
String runId,
|
||||||
|
String threadId,
|
||||||
|
AgentAguiRunStatus status,
|
||||||
|
long lastCursor,
|
||||||
|
boolean draft,
|
||||||
|
long updatedAt) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从内部运行描述生成公开状态。
|
||||||
|
*
|
||||||
|
* @param descriptor 内部运行描述
|
||||||
|
* @return 公开状态
|
||||||
|
*/
|
||||||
|
public static AgentAguiRunStatusView from(AgentAguiRunDescriptor descriptor) {
|
||||||
|
return new AgentAguiRunStatusView(
|
||||||
|
descriptor.runId(),
|
||||||
|
descriptor.threadId(),
|
||||||
|
descriptor.status(),
|
||||||
|
descriptor.lastCursor(),
|
||||||
|
descriptor.draft(),
|
||||||
|
descriptor.updatedAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
package tech.easyflow.agent.runtime.agui;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.springframework.data.redis.core.HashOperations;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.data.redis.core.script.DefaultRedisScript;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import com.easyagents.agui.AguiExtendedEvent;
|
||||||
|
import tech.easyflow.agent.config.AgentRuntimeProperties;
|
||||||
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基于 Redis 的 AG-UI 运行元数据与顺序事件日志。
|
||||||
|
*
|
||||||
|
* <p>运行 ID 只承担幂等查找作用,所有读取和取消仍必须校验登录用户与租户归属。</p>
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class AgentAguiRunStore {
|
||||||
|
|
||||||
|
private static final String RUN_PREFIX = "easyflow:agent:agui:run:";
|
||||||
|
private static final String STATUS_FIELD = "status";
|
||||||
|
private static final int REPLAY_BATCH_SIZE = 256;
|
||||||
|
private static final Pattern RUN_ID_PATTERN = Pattern.compile("[A-Za-z0-9_-]{1,128}");
|
||||||
|
private static final long MAX_RUN_EVENT_BYTES = 16L * 1024L * 1024L;
|
||||||
|
private static final long TERMINAL_EVENT_RESERVE_BYTES = 64L * 1024L;
|
||||||
|
private static final DefaultRedisScript<Long> APPEND_SCRIPT = new DefaultRedisScript<>("""
|
||||||
|
local status = redis.call('HGET', KEYS[1], 'status')
|
||||||
|
if not status then
|
||||||
|
return -3
|
||||||
|
end
|
||||||
|
local lastCursor = tonumber(redis.call('HGET', KEYS[1], 'lastCursor') or '0')
|
||||||
|
if redis.call('HGET', KEYS[1], 'lastBatchId') == ARGV[1] then
|
||||||
|
return lastCursor
|
||||||
|
end
|
||||||
|
if status ~= 'RUNNING' then
|
||||||
|
return lastCursor
|
||||||
|
end
|
||||||
|
local currentBytes = tonumber(redis.call('HGET', KEYS[1], 'eventBytes') or '0')
|
||||||
|
local incomingBytes = tonumber(ARGV[5])
|
||||||
|
local byteLimit = tonumber(ARGV[6])
|
||||||
|
if ARGV[4] ~= '' then
|
||||||
|
byteLimit = byteLimit + tonumber(ARGV[7])
|
||||||
|
end
|
||||||
|
if currentBytes + incomingBytes > byteLimit then
|
||||||
|
return -1
|
||||||
|
end
|
||||||
|
local eventCount = tonumber(ARGV[8])
|
||||||
|
for index = 1, eventCount do
|
||||||
|
redis.call('RPUSH', KEYS[2], ARGV[8 + index])
|
||||||
|
end
|
||||||
|
local cursor = redis.call('LLEN', KEYS[2])
|
||||||
|
redis.call('HSET', KEYS[1],
|
||||||
|
'updatedAt', ARGV[2],
|
||||||
|
'lastBatchId', ARGV[1],
|
||||||
|
'eventBytes', currentBytes + incomingBytes,
|
||||||
|
'lastCursor', cursor)
|
||||||
|
if ARGV[4] ~= '' then
|
||||||
|
redis.call('HSET', KEYS[1], 'status', ARGV[4])
|
||||||
|
end
|
||||||
|
redis.call('PEXPIRE', KEYS[1], ARGV[3])
|
||||||
|
redis.call('PEXPIRE', KEYS[2], ARGV[3])
|
||||||
|
redis.call('PEXPIRE', KEYS[3], ARGV[3])
|
||||||
|
return cursor
|
||||||
|
""", Long.class);
|
||||||
|
private static final DefaultRedisScript<Long> REQUEST_CANCEL_SCRIPT = new DefaultRedisScript<>("""
|
||||||
|
if redis.call('HGET', KEYS[1], 'status') ~= 'RUNNING' then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
redis.call('HSET', KEYS[1], 'cancelRequested', 'true', 'updatedAt', ARGV[1])
|
||||||
|
redis.call('PEXPIRE', KEYS[1], ARGV[2])
|
||||||
|
return 1
|
||||||
|
""", Long.class);
|
||||||
|
|
||||||
|
private final StringRedisTemplate redisTemplate;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final Duration retention;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 AG-UI 运行存储。
|
||||||
|
*
|
||||||
|
* @param redisTemplate Redis 字符串模板
|
||||||
|
* @param objectMapper JSON 映射器
|
||||||
|
* @param properties Agent 运行配置
|
||||||
|
*/
|
||||||
|
public AgentAguiRunStore(StringRedisTemplate redisTemplate,
|
||||||
|
ObjectMapper objectMapper,
|
||||||
|
AgentRuntimeProperties properties) {
|
||||||
|
this.redisTemplate = redisTemplate;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
this.retention = properties.getAguiRunRetention();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在模型运行启动前注册唯一运行元数据。
|
||||||
|
*
|
||||||
|
* @param descriptor 初始运行描述
|
||||||
|
* @throws BusinessException runId 已存在时抛出
|
||||||
|
*/
|
||||||
|
public void create(AgentAguiRunDescriptor descriptor) {
|
||||||
|
String runId = requireRunId(descriptor == null ? null : descriptor.runId());
|
||||||
|
Boolean reserved = redisTemplate.opsForValue().setIfAbsent(
|
||||||
|
reservationKey(runId), descriptor.userId(), retention);
|
||||||
|
if (!Boolean.TRUE.equals(reserved)) {
|
||||||
|
throw new BusinessException("当前 Agent 运行标识已存在,请勿重复提交");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Map<String, String> values = new LinkedHashMap<>();
|
||||||
|
values.put("runId", runId);
|
||||||
|
values.put("requestId", value(descriptor.requestId()));
|
||||||
|
values.put("threadId", value(descriptor.threadId()));
|
||||||
|
values.put("agentId", value(descriptor.agentId()));
|
||||||
|
values.put("sessionId", value(descriptor.sessionId()));
|
||||||
|
values.put("userId", value(descriptor.userId()));
|
||||||
|
values.put("tenantId", value(descriptor.tenantId()));
|
||||||
|
values.put("draft", Boolean.toString(descriptor.draft()));
|
||||||
|
values.put(STATUS_FIELD, AgentAguiRunStatus.RUNNING.name());
|
||||||
|
values.put("cancelRequested", Boolean.FALSE.toString());
|
||||||
|
values.put("eventBytes", "0");
|
||||||
|
values.put("lastBatchId", "");
|
||||||
|
values.put("lastCursor", "0");
|
||||||
|
values.put("createdAt", Long.toString(descriptor.createdAt()));
|
||||||
|
values.put("updatedAt", Long.toString(descriptor.updatedAt()));
|
||||||
|
meta().putAll(metaKey(runId), values);
|
||||||
|
redisTemplate.expire(metaKey(runId), retention);
|
||||||
|
redisTemplate.expire(eventsKey(runId), retention);
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
redisTemplate.delete(reservationKey(runId));
|
||||||
|
throw exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量追加严格有序的协议事件。
|
||||||
|
*
|
||||||
|
* @param runId 运行 ID
|
||||||
|
* @param events 已编码 AG-UI 事件
|
||||||
|
* @return 追加后的最后事件游标
|
||||||
|
*/
|
||||||
|
public long append(String runId, String batchId, List<String> events) {
|
||||||
|
requireRunId(runId);
|
||||||
|
if (events == null || events.isEmpty()) {
|
||||||
|
AgentAguiRunDescriptor descriptor = find(runId);
|
||||||
|
return descriptor == null ? 0L : descriptor.lastCursor();
|
||||||
|
}
|
||||||
|
if (batchId == null || batchId.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("AG-UI 事件批次 ID 不能为空");
|
||||||
|
}
|
||||||
|
AgentAguiRunStatus terminalStatus = terminalStatus(events);
|
||||||
|
long eventBytes = events.stream()
|
||||||
|
.mapToLong(event -> event.getBytes(StandardCharsets.UTF_8).length)
|
||||||
|
.sum();
|
||||||
|
List<String> arguments = new java.util.ArrayList<>(8 + events.size());
|
||||||
|
arguments.add(batchId);
|
||||||
|
arguments.add(Long.toString(System.currentTimeMillis()));
|
||||||
|
arguments.add(Long.toString(retention.toMillis()));
|
||||||
|
arguments.add(terminalStatus == null ? "" : terminalStatus.name());
|
||||||
|
arguments.add(Long.toString(eventBytes));
|
||||||
|
arguments.add(Long.toString(MAX_RUN_EVENT_BYTES));
|
||||||
|
arguments.add(Long.toString(TERMINAL_EVENT_RESERVE_BYTES));
|
||||||
|
arguments.add(Integer.toString(events.size()));
|
||||||
|
arguments.addAll(events);
|
||||||
|
Long cursor = redisTemplate.execute(
|
||||||
|
APPEND_SCRIPT,
|
||||||
|
List.of(metaKey(runId), eventsKey(runId), reservationKey(runId)),
|
||||||
|
arguments.toArray());
|
||||||
|
if (cursor == null || cursor == -3L) {
|
||||||
|
throw new IllegalStateException("AG-UI 运行记录不存在或已过期");
|
||||||
|
}
|
||||||
|
if (cursor == -1L) {
|
||||||
|
throw new IllegalStateException("AG-UI 运行事件超过 16 MiB 安全上限");
|
||||||
|
}
|
||||||
|
return cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取游标之后的一批事件。
|
||||||
|
*
|
||||||
|
* @param runId 运行 ID
|
||||||
|
* @param afterCursor 已消费的最后游标,零表示从头读取
|
||||||
|
* @return 按游标升序排列的事件
|
||||||
|
*/
|
||||||
|
public List<String> readAfter(String runId, long afterCursor) {
|
||||||
|
requireRunId(runId);
|
||||||
|
long start = Math.max(0L, afterCursor);
|
||||||
|
List<String> values = redisTemplate.opsForList().range(
|
||||||
|
eventsKey(runId), start, start + REPLAY_BATCH_SIZE - 1L);
|
||||||
|
return values == null ? List.of() : values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询运行描述。
|
||||||
|
*
|
||||||
|
* @param runId 运行 ID
|
||||||
|
* @return 运行描述;不存在时为 null
|
||||||
|
*/
|
||||||
|
public AgentAguiRunDescriptor find(String runId) {
|
||||||
|
requireRunId(runId);
|
||||||
|
Map<Object, Object> values = meta().entries(metaKey(runId));
|
||||||
|
if (values == null || values.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new AgentAguiRunDescriptor(
|
||||||
|
text(values, "runId"),
|
||||||
|
text(values, "requestId"),
|
||||||
|
text(values, "threadId"),
|
||||||
|
text(values, "agentId"),
|
||||||
|
text(values, "sessionId"),
|
||||||
|
text(values, "userId"),
|
||||||
|
text(values, "tenantId"),
|
||||||
|
Boolean.parseBoolean(text(values, "draft")),
|
||||||
|
parseStatus(text(values, STATUS_FIELD)),
|
||||||
|
number(values, "lastCursor"),
|
||||||
|
number(values, "createdAt"),
|
||||||
|
number(values, "updatedAt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询并校验当前登录账号对运行的访问权。
|
||||||
|
*
|
||||||
|
* @param runId 运行 ID
|
||||||
|
* @param account 当前登录账号
|
||||||
|
* @return 已授权运行描述
|
||||||
|
* @throws BusinessException 运行不存在或无访问权时抛出
|
||||||
|
*/
|
||||||
|
public AgentAguiRunDescriptor requireOwned(String runId, LoginAccount account) {
|
||||||
|
AgentAguiRunDescriptor descriptor = find(runId);
|
||||||
|
if (descriptor == null) {
|
||||||
|
throw new BusinessException("Agent 运行记录不存在或已过期");
|
||||||
|
}
|
||||||
|
String userId = account == null || account.getId() == null ? null : account.getId().toString();
|
||||||
|
String tenantId = account == null || account.getTenantId() == null ? null : account.getTenantId().toString();
|
||||||
|
if (!descriptor.userId().equals(userId) || !descriptor.tenantId().equals(tenantId)) {
|
||||||
|
throw new BusinessException("无权访问该 Agent 运行");
|
||||||
|
}
|
||||||
|
return descriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将无法继续的运行收口为失败状态。
|
||||||
|
*
|
||||||
|
* @param runId 运行 ID
|
||||||
|
*/
|
||||||
|
public void failOwnerLost(AgentAguiRunDescriptor descriptor) {
|
||||||
|
if (descriptor == null || descriptor.status().isTerminal()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String event = objectMapper.writeValueAsString(new AguiExtendedEvent.RunError(
|
||||||
|
descriptor.threadId(),
|
||||||
|
descriptor.runId(),
|
||||||
|
"Agent 运行节点已不可用",
|
||||||
|
"AGENT_RUN_OWNER_LOST"));
|
||||||
|
append(descriptor.runId(), "owner-lost-" + UUID.randomUUID(), List.of(event));
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
throw exception;
|
||||||
|
} catch (Exception exception) {
|
||||||
|
throw new IllegalStateException("AG-UI owner-lost 终态编码失败", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录显式取消意图,供 Runtime 注册前后的启动窗口检查。
|
||||||
|
*
|
||||||
|
* @param runId 运行 ID
|
||||||
|
* @return 本次是否首次接受取消意图
|
||||||
|
*/
|
||||||
|
public boolean requestCancellation(String runId) {
|
||||||
|
requireRunId(runId);
|
||||||
|
Long result = redisTemplate.execute(
|
||||||
|
REQUEST_CANCEL_SCRIPT,
|
||||||
|
List.of(metaKey(runId)),
|
||||||
|
Long.toString(System.currentTimeMillis()),
|
||||||
|
Long.toString(retention.toMillis()));
|
||||||
|
return Long.valueOf(1L).equals(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断运行是否收到显式取消意图。
|
||||||
|
*
|
||||||
|
* @param runId 运行 ID
|
||||||
|
* @return 已请求取消时为 true
|
||||||
|
*/
|
||||||
|
public boolean isCancellationRequested(String runId) {
|
||||||
|
Object value = meta().get(metaKey(requireRunId(runId)), "cancelRequested");
|
||||||
|
return Boolean.parseBoolean(value == null ? "false" : value.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private HashOperations<String, Object, Object> meta() {
|
||||||
|
return redisTemplate.opsForHash();
|
||||||
|
}
|
||||||
|
|
||||||
|
private AgentAguiRunStatus terminalStatus(List<String> events) {
|
||||||
|
AgentAguiRunStatus result = null;
|
||||||
|
for (String event : events) {
|
||||||
|
try {
|
||||||
|
JsonNode value = objectMapper.readTree(event);
|
||||||
|
String type = value.path("type").asText();
|
||||||
|
if ("RUN_FINISHED".equals(type)) {
|
||||||
|
result = AgentAguiRunStatus.COMPLETED;
|
||||||
|
} else if ("RUN_ERROR".equals(type)) {
|
||||||
|
result = "RUN_CANCELLED".equals(value.path("code").asText())
|
||||||
|
? AgentAguiRunStatus.CANCELLED
|
||||||
|
: AgentAguiRunStatus.FAILED;
|
||||||
|
}
|
||||||
|
} catch (Exception exception) {
|
||||||
|
throw new IllegalStateException("AG-UI 运行事件格式不合法", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private AgentAguiRunStatus parseStatus(String value) {
|
||||||
|
try {
|
||||||
|
return AgentAguiRunStatus.valueOf(value);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
return AgentAguiRunStatus.FAILED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String requireRunId(String runId) {
|
||||||
|
if (runId == null || !RUN_ID_PATTERN.matcher(runId).matches()) {
|
||||||
|
throw new BusinessException("Agent 运行 ID 不合法");
|
||||||
|
}
|
||||||
|
return runId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String reservationKey(String runId) {
|
||||||
|
return runKey(runId, "reserved");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String metaKey(String runId) {
|
||||||
|
return runKey(runId, "meta");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String eventsKey(String runId) {
|
||||||
|
return runKey(runId, "events");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String runKey(String runId, String suffix) {
|
||||||
|
return RUN_PREFIX + "{" + runId + "}:" + suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String value(String value) {
|
||||||
|
return value == null ? "" : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String text(Map<Object, Object> values, String key) {
|
||||||
|
Object value = values.get(key);
|
||||||
|
return value == null ? "" : value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private long number(Map<Object, Object> values, String key) {
|
||||||
|
try {
|
||||||
|
return Long.parseLong(text(values, key));
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package tech.easyflow.agent.runtime.agui;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 以共享调度器为本节点上的 AG-UI 重连订阅者增量重放 Redis 事件。
|
||||||
|
*
|
||||||
|
* <p>所有订阅共享一个短周期任务,不为每个浏览器连接创建阻塞线程。</p>
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class AgentAguiRunSubscriptionService {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(AgentAguiRunSubscriptionService.class);
|
||||||
|
private static final long KEEP_ALIVE_INTERVAL_MILLIS = 15_000L;
|
||||||
|
|
||||||
|
private final AgentAguiRunStore store;
|
||||||
|
private final Map<String, CopyOnWriteArrayList<Subscriber>> subscribers = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 AG-UI 重连订阅服务。
|
||||||
|
*
|
||||||
|
* @param store Redis 运行存储
|
||||||
|
*/
|
||||||
|
public AgentAguiRunSubscriptionService(AgentAguiRunStore store) {
|
||||||
|
this.store = store;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册一个从指定游标继续的 SSE 订阅。
|
||||||
|
*
|
||||||
|
* @param descriptor 已授权运行描述
|
||||||
|
* @param afterCursor 已消费的最后游标
|
||||||
|
* @return 重连 SSE 发射器
|
||||||
|
*/
|
||||||
|
public SseEmitter subscribe(AgentAguiRunDescriptor descriptor, long afterCursor) {
|
||||||
|
return subscribe(descriptor, afterCursor, new SseEmitter(0L));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用指定发射器注册订阅,供生命周期测试复用同一调度逻辑。
|
||||||
|
*
|
||||||
|
* @param descriptor 已授权运行描述
|
||||||
|
* @param afterCursor 已消费的最后游标
|
||||||
|
* @param emitter SSE 发射器
|
||||||
|
* @return 已注册发射器
|
||||||
|
*/
|
||||||
|
SseEmitter subscribe(AgentAguiRunDescriptor descriptor,
|
||||||
|
long afterCursor,
|
||||||
|
SseEmitter emitter) {
|
||||||
|
Subscriber subscriber = new Subscriber(emitter, Math.max(0L, afterCursor));
|
||||||
|
subscribers.computeIfAbsent(descriptor.runId(), ignored -> new CopyOnWriteArrayList<>())
|
||||||
|
.add(subscriber);
|
||||||
|
Runnable remove = () -> remove(descriptor.runId(), subscriber);
|
||||||
|
emitter.onCompletion(remove);
|
||||||
|
emitter.onTimeout(remove);
|
||||||
|
emitter.onError(error -> remove.run());
|
||||||
|
return emitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 周期性向全部本地订阅者重放新增事件并发送保活注释。
|
||||||
|
*/
|
||||||
|
@Scheduled(fixedDelayString = "${easyflow.agent.runtime.agui-replay-poll-interval:100ms}")
|
||||||
|
public void dispatch() {
|
||||||
|
for (Map.Entry<String, CopyOnWriteArrayList<Subscriber>> entry : subscribers.entrySet()) {
|
||||||
|
dispatch(entry.getKey(), entry.getValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void dispatch(String runId, CopyOnWriteArrayList<Subscriber> runSubscribers) {
|
||||||
|
try {
|
||||||
|
AgentAguiRunDescriptor descriptor = store.find(runId);
|
||||||
|
if (descriptor == null) {
|
||||||
|
runSubscribers.forEach(subscriber -> removeAndComplete(runId, subscriber));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map<Long, List<Subscriber>> cursorGroups = new LinkedHashMap<>();
|
||||||
|
for (Subscriber subscriber : runSubscribers) {
|
||||||
|
cursorGroups.computeIfAbsent(subscriber.cursor, ignored -> new java.util.ArrayList<>())
|
||||||
|
.add(subscriber);
|
||||||
|
}
|
||||||
|
for (Map.Entry<Long, List<Subscriber>> cursorGroup : cursorGroups.entrySet()) {
|
||||||
|
long cursor = cursorGroup.getKey();
|
||||||
|
List<String> events = store.readAfter(runId, cursor);
|
||||||
|
for (Subscriber subscriber : cursorGroup.getValue()) {
|
||||||
|
dispatchSubscriber(runId, subscriber, cursor, events, descriptor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
LOG.error("AG-UI 重连订阅调度失败: runId={}, subscriberCount={}",
|
||||||
|
runId, runSubscribers.size(), exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void dispatchSubscriber(String runId,
|
||||||
|
Subscriber subscriber,
|
||||||
|
long afterCursor,
|
||||||
|
List<String> events,
|
||||||
|
AgentAguiRunDescriptor descriptor) {
|
||||||
|
try {
|
||||||
|
for (int index = 0; index < events.size(); index++) {
|
||||||
|
long cursor = afterCursor + index + 1L;
|
||||||
|
subscriber.emitter.send(SseEmitter.event().id(Long.toString(cursor)).data(events.get(index)));
|
||||||
|
subscriber.cursor = cursor;
|
||||||
|
subscriber.lastWriteAt = System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
if (descriptor.status().isTerminal() && subscriber.cursor >= descriptor.lastCursor()) {
|
||||||
|
removeAndComplete(runId, subscriber);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
if (events.isEmpty() && now - subscriber.lastWriteAt >= KEEP_ALIVE_INTERVAL_MILLIS) {
|
||||||
|
subscriber.emitter.send(SseEmitter.event().comment("keepalive"));
|
||||||
|
subscriber.lastWriteAt = now;
|
||||||
|
}
|
||||||
|
} catch (IOException | IllegalStateException exception) {
|
||||||
|
LOG.debug("移除已断开的 AG-UI 重连订阅: runId={}, cursor={}",
|
||||||
|
runId, subscriber.cursor, exception);
|
||||||
|
remove(runId, subscriber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void removeAndComplete(String runId, Subscriber subscriber) {
|
||||||
|
remove(runId, subscriber);
|
||||||
|
subscriber.emitter.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void remove(String runId, Subscriber subscriber) {
|
||||||
|
CopyOnWriteArrayList<Subscriber> values = subscribers.get(runId);
|
||||||
|
if (values == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
values.remove(subscriber);
|
||||||
|
if (values.isEmpty()) {
|
||||||
|
subscribers.remove(runId, values);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class Subscriber {
|
||||||
|
private final SseEmitter emitter;
|
||||||
|
private long cursor;
|
||||||
|
private long lastWriteAt = System.currentTimeMillis();
|
||||||
|
|
||||||
|
private Subscriber(SseEmitter emitter, long cursor) {
|
||||||
|
this.emitter = emitter;
|
||||||
|
this.cursor = cursor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ package tech.easyflow.agent.runtime.agui;
|
|||||||
* 单次 AG-UI 连接的客户端 wire 标识。
|
* 单次 AG-UI 连接的客户端 wire 标识。
|
||||||
*
|
*
|
||||||
* @param threadId 客户端 thread ID
|
* @param threadId 客户端 thread ID
|
||||||
* @param runId 客户端 run ID,仅用于协议输出
|
* @param runId 客户端 run ID,用于协议输出、幂等查找与刷新重连
|
||||||
* @param userMessageId 客户端本轮用户消息 ID
|
* @param userMessageId 客户端本轮用户消息 ID
|
||||||
* @param userMessageContent 客户端本轮用户消息正文
|
* @param userMessageContent 客户端本轮用户消息正文
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package tech.easyflow.agent.runtime.agui;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter;
|
||||||
|
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 AG-UI 运行输出写入可重放日志,同时尽力推送到首次 HTTP 连接。
|
||||||
|
*
|
||||||
|
* <p>底层浏览器连接关闭后,本对象仍保持逻辑可写,模型运行只会因终态或显式取消而结束。</p>
|
||||||
|
*/
|
||||||
|
public final class ResumableAguiSseEmitter extends ChatSseEmitter {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(ResumableAguiSseEmitter.class);
|
||||||
|
|
||||||
|
private final String threadId;
|
||||||
|
private final String runId;
|
||||||
|
private final AgentAguiRunJournal journal;
|
||||||
|
private final SseEmitter subscriberEmitter;
|
||||||
|
private final AtomicBoolean completed = new AtomicBoolean(false);
|
||||||
|
private final AtomicBoolean journalFailed = new AtomicBoolean(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建可重连 AG-UI SSE 发射器。
|
||||||
|
*
|
||||||
|
* @param threadId AG-UI 线程 ID
|
||||||
|
* @param runId 运行 ID
|
||||||
|
* @param journal 可重放事件日志
|
||||||
|
* @param subscriberEmitter 从游标零开始的首次订阅
|
||||||
|
*/
|
||||||
|
public ResumableAguiSseEmitter(String threadId,
|
||||||
|
String runId,
|
||||||
|
AgentAguiRunJournal journal,
|
||||||
|
SseEmitter subscriberEmitter) {
|
||||||
|
super(0L);
|
||||||
|
this.threadId = threadId;
|
||||||
|
this.runId = runId;
|
||||||
|
this.journal = journal;
|
||||||
|
this.subscriberEmitter = java.util.Objects.requireNonNull(
|
||||||
|
subscriberEmitter, "subscriberEmitter cannot be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回与重连相同的 Redis 游标订阅,首次连接不走旁路直推。
|
||||||
|
*
|
||||||
|
* @return 首次订阅 SSE
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public SseEmitter getEmitter() {
|
||||||
|
return subscriberEmitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 先记录事件,再尽力发送给当前浏览器连接。
|
||||||
|
*
|
||||||
|
* @param data 已编码 AG-UI 事件
|
||||||
|
* @return 日志已接受事件时为 true
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean sendData(String data) {
|
||||||
|
if (completed.get()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
journal.append(runId, data);
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
LOG.error("AG-UI 事件日志拒绝写入: runId={}", runId, exception);
|
||||||
|
journalFailed.set(true);
|
||||||
|
completed.set(true);
|
||||||
|
failJournal(exception);
|
||||||
|
super.complete();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷入尾部事件并关闭首次 HTTP 连接。
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void complete() {
|
||||||
|
if (!completed.compareAndSet(false, true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
journal.complete(runId);
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
LOG.error("AG-UI 运行终态日志同步写入失败,改写为日志故障终态: runId={}", runId, exception);
|
||||||
|
journalFailed.set(true);
|
||||||
|
failJournal(exception);
|
||||||
|
} finally {
|
||||||
|
super.complete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷入尾部事件并关闭首次 HTTP 连接。
|
||||||
|
*
|
||||||
|
* @param error 关闭原因
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void completeWithError(Throwable error) {
|
||||||
|
complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回运行输出逻辑关闭状态,不受首次浏览器连接断开影响。
|
||||||
|
*
|
||||||
|
* @return 运行已经收口时为 true
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean isClosed() {
|
||||||
|
return completed.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断本次运行是否以可持久恢复的方式完成。
|
||||||
|
*
|
||||||
|
* @return 事件日志没有发生写入故障时为 {@code true}
|
||||||
|
*/
|
||||||
|
public boolean isJournalCompletionSuccessful() {
|
||||||
|
return completed.get() && !journalFailed.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void failJournal(RuntimeException exception) {
|
||||||
|
String detail = exception.getMessage() == null ? "" : exception.getMessage();
|
||||||
|
boolean capacityFailure = detail.contains("安全上限") || detail.contains("16 MiB");
|
||||||
|
String code = capacityFailure
|
||||||
|
? "AGENT_RUN_JOURNAL_LIMIT"
|
||||||
|
: "AGENT_RUN_JOURNAL_UNAVAILABLE";
|
||||||
|
String message = capacityFailure
|
||||||
|
? "Agent 输出超过可恢复日志容量,运行已停止"
|
||||||
|
: "Agent 输出日志暂时不可用,运行已停止";
|
||||||
|
try {
|
||||||
|
journal.fail(threadId, runId, code, message);
|
||||||
|
} catch (RuntimeException terminalException) {
|
||||||
|
LOG.error("AG-UI 日志故障终态暂未写入,将由后台继续重试: runId={}",
|
||||||
|
runId, terminalException);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,7 +67,9 @@ public abstract class AbstractAgentAsyncSubTools implements AsyncSubTools {
|
|||||||
* @param arguments 调用参数
|
* @param arguments 调用参数
|
||||||
* @return 执行结果
|
* @return 执行结果
|
||||||
*/
|
*/
|
||||||
protected abstract AgentToolExecutionResult executeBusiness(Map<String, Object> arguments);
|
protected abstract AgentToolExecutionResult executeBusiness(
|
||||||
|
Map<String, Object> arguments,
|
||||||
|
AgentToolContext context);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@inheritDoc}
|
* {@inheritDoc}
|
||||||
@@ -92,7 +94,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());
|
dispatch(sessionId, record.getTaskId(), record.getArguments(), context);
|
||||||
|
|
||||||
AsyncToolSubmitResult result = new AsyncToolSubmitResult();
|
AsyncToolSubmitResult result = new AsyncToolSubmitResult();
|
||||||
result.setTaskId(taskId);
|
result.setTaskId(taskId);
|
||||||
@@ -157,16 +159,23 @@ public abstract class AbstractAgentAsyncSubTools implements AsyncSubTools {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void dispatch(String sessionId, String taskId, Map<String, Object> arguments) {
|
private void dispatch(String sessionId,
|
||||||
|
String taskId,
|
||||||
|
Map<String, Object> arguments,
|
||||||
|
AgentToolContext context) {
|
||||||
try {
|
try {
|
||||||
taskExecutor.execute(() -> executeTask(sessionId, taskId, arguments));
|
taskExecutor.execute(() -> executeTask(
|
||||||
|
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, String taskId, Map<String, Object> arguments) {
|
private void executeTask(String sessionId,
|
||||||
|
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);
|
||||||
@@ -174,7 +183,8 @@ public abstract class AbstractAgentAsyncSubTools implements AsyncSubTools {
|
|||||||
appendEvent(record, "RUNNING", displayName() + "任务执行中");
|
appendEvent(record, "RUNNING", displayName() + "任务执行中");
|
||||||
return record;
|
return record;
|
||||||
});
|
});
|
||||||
AgentToolExecutionResult executionResult = executeBusiness(arguments);
|
AgentToolExecutionResult executionResult = executeBusiness(
|
||||||
|
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,5 +1,6 @@
|
|||||||
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;
|
||||||
@@ -82,7 +83,9 @@ public class PluginAsyncSubTools extends AbstractAgentAsyncSubTools {
|
|||||||
* {@inheritDoc}
|
* {@inheritDoc}
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
protected AgentToolExecutionResult executeBusiness(Map<String, Object> arguments) {
|
protected AgentToolExecutionResult executeBusiness(
|
||||||
|
Map<String, Object> arguments,
|
||||||
|
AgentToolContext context) {
|
||||||
return pluginToolExecutor.execute(pluginItem, plugin, arguments);
|
return pluginToolExecutor.execute(pluginItem, plugin, arguments);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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;
|
||||||
@@ -77,7 +78,9 @@ public class WorkflowAsyncSubTools extends AbstractAgentAsyncSubTools {
|
|||||||
* {@inheritDoc}
|
* {@inheritDoc}
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
protected AgentToolExecutionResult executeBusiness(Map<String, Object> arguments) {
|
protected AgentToolExecutionResult executeBusiness(
|
||||||
return workflowToolExecutor.execute(workflow, arguments);
|
Map<String, Object> arguments,
|
||||||
|
AgentToolContext context) {
|
||||||
|
return workflowToolExecutor.execute(workflow, arguments, context);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,15 @@ public interface AgentRunOutput {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断浏览器 SSE 连接断开时是否应取消底层 Agent 运行。
|
||||||
|
*
|
||||||
|
* @return 连接断开需要取消运行时为 true
|
||||||
|
*/
|
||||||
|
default boolean cancelRunOnDisconnect() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送协议终态并关闭连接。
|
* 发送协议终态并关闭连接。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import com.easyagents.agui.AguiRuntimeEventProjector;
|
|||||||
import io.agentscope.core.agui.event.AguiEvent;
|
import io.agentscope.core.agui.event.AguiEvent;
|
||||||
import io.agentscope.core.agui.model.AguiMessage;
|
import io.agentscope.core.agui.model.AguiMessage;
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
import tech.easyflow.agent.runtime.agui.ResumableAguiSseEmitter;
|
||||||
import tech.easyflow.agent.runtime.hitl.ToolApprovalInputProjection;
|
import tech.easyflow.agent.runtime.hitl.ToolApprovalInputProjection;
|
||||||
import tech.easyflow.core.chat.protocol.ChatDomain;
|
import tech.easyflow.core.chat.protocol.ChatDomain;
|
||||||
import tech.easyflow.core.chat.protocol.ChatType;
|
import tech.easyflow.core.chat.protocol.ChatType;
|
||||||
@@ -17,6 +18,7 @@ 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;
|
||||||
|
|
||||||
@@ -35,6 +37,7 @@ public final class AguiAgentRunOutput implements AgentRunOutput {
|
|||||||
private final String clientUserMessageId;
|
private final String clientUserMessageId;
|
||||||
private final String clientUserMessageContent;
|
private final String clientUserMessageContent;
|
||||||
private final ChatSseEmitter delegate;
|
private final ChatSseEmitter delegate;
|
||||||
|
private final boolean cancelRunOnDisconnect;
|
||||||
private final AguiRuntimeEventProjector projector;
|
private final AguiRuntimeEventProjector projector;
|
||||||
private final AguiProtocolEventEncoder encoder = new AguiProtocolEventEncoder();
|
private final AguiProtocolEventEncoder encoder = new AguiProtocolEventEncoder();
|
||||||
|
|
||||||
@@ -56,7 +59,7 @@ public final class AguiAgentRunOutput implements AgentRunOutput {
|
|||||||
* @param clientUserMessageId 本轮客户端用户消息 ID
|
* @param clientUserMessageId 本轮客户端用户消息 ID
|
||||||
*/
|
*/
|
||||||
public AguiAgentRunOutput(String threadId, String runId, String clientUserMessageId) {
|
public AguiAgentRunOutput(String threadId, String runId, String clientUserMessageId) {
|
||||||
this(threadId, runId, clientUserMessageId, null, new ChatSseEmitter());
|
this(threadId, runId, clientUserMessageId, null, new ChatSseEmitter(), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,7 +75,7 @@ public final class AguiAgentRunOutput implements AgentRunOutput {
|
|||||||
String runId,
|
String runId,
|
||||||
String clientUserMessageId,
|
String clientUserMessageId,
|
||||||
String clientUserMessageContent) {
|
String clientUserMessageContent) {
|
||||||
this(threadId, runId, clientUserMessageId, clientUserMessageContent, new ChatSseEmitter());
|
this(threadId, runId, clientUserMessageId, clientUserMessageContent, new ChatSseEmitter(), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -88,7 +91,7 @@ public final class AguiAgentRunOutput implements AgentRunOutput {
|
|||||||
String runId,
|
String runId,
|
||||||
String clientUserMessageId,
|
String clientUserMessageId,
|
||||||
ChatSseEmitter delegate) {
|
ChatSseEmitter delegate) {
|
||||||
this(threadId, runId, clientUserMessageId, null, delegate);
|
this(threadId, runId, clientUserMessageId, null, delegate, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -106,11 +109,32 @@ public final class AguiAgentRunOutput implements AgentRunOutput {
|
|||||||
String clientUserMessageId,
|
String clientUserMessageId,
|
||||||
String clientUserMessageContent,
|
String clientUserMessageContent,
|
||||||
ChatSseEmitter delegate) {
|
ChatSseEmitter delegate) {
|
||||||
|
this(threadId, runId, clientUserMessageId, clientUserMessageContent, delegate, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用指定连接生命周期策略创建 AG-UI 输出。
|
||||||
|
*
|
||||||
|
* @param threadId 客户端 thread ID
|
||||||
|
* @param runId 客户端 run ID
|
||||||
|
* @param clientUserMessageId 本轮客户端用户消息 ID
|
||||||
|
* @param clientUserMessageContent 本轮客户端用户消息正文
|
||||||
|
* @param delegate SSE 发射器
|
||||||
|
* @param cancelRunOnDisconnect 浏览器连接断开时是否取消运行
|
||||||
|
*/
|
||||||
|
public AguiAgentRunOutput(
|
||||||
|
String threadId,
|
||||||
|
String runId,
|
||||||
|
String clientUserMessageId,
|
||||||
|
String clientUserMessageContent,
|
||||||
|
ChatSseEmitter delegate,
|
||||||
|
boolean cancelRunOnDisconnect) {
|
||||||
this.threadId = requireText(threadId, "threadId");
|
this.threadId = requireText(threadId, "threadId");
|
||||||
this.runId = requireText(runId, "runId");
|
this.runId = requireText(runId, "runId");
|
||||||
this.clientUserMessageId = clientUserMessageId;
|
this.clientUserMessageId = clientUserMessageId;
|
||||||
this.clientUserMessageContent = clientUserMessageContent;
|
this.clientUserMessageContent = clientUserMessageContent;
|
||||||
this.delegate = java.util.Objects.requireNonNull(delegate, "delegate cannot be null");
|
this.delegate = java.util.Objects.requireNonNull(delegate, "delegate cannot be null");
|
||||||
|
this.cancelRunOnDisconnect = cancelRunOnDisconnect;
|
||||||
this.projector = new AguiRuntimeEventProjector(threadId, runId);
|
this.projector = new AguiRuntimeEventProjector(threadId, runId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +143,15 @@ public final class AguiAgentRunOutput implements AgentRunOutput {
|
|||||||
return delegate.getEmitter();
|
return delegate.getEmitter();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取公开 AG-UI 运行 ID。
|
||||||
|
*
|
||||||
|
* @return 运行 ID
|
||||||
|
*/
|
||||||
|
public String runId() {
|
||||||
|
return runId;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public synchronized boolean emitRuntimeEvent(AgentRuntimeEvent event) {
|
public synchronized boolean emitRuntimeEvent(AgentRuntimeEvent event) {
|
||||||
if (event == null || event.getEventType() == null || delegate.isClosed()) {
|
if (event == null || event.getEventType() == null || delegate.isClosed()) {
|
||||||
@@ -249,6 +282,11 @@ public final class AguiAgentRunOutput implements AgentRunOutput {
|
|||||||
return pendingCompletedEvent != null;
|
return pendingCompletedEvent != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean cancelRunOnDisconnect() {
|
||||||
|
return cancelRunOnDisconnect;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public synchronized boolean finish(String finalText) {
|
public synchronized boolean finish(String finalText) {
|
||||||
if (delegate.isClosed()) {
|
if (delegate.isClosed()) {
|
||||||
@@ -277,6 +315,9 @@ public final class AguiAgentRunOutput implements AgentRunOutput {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
delegate.complete();
|
delegate.complete();
|
||||||
|
if (delegate instanceof ResumableAguiSseEmitter resumableEmitter) {
|
||||||
|
return resumableEmitter.isJournalCompletionSuccessful();
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,7 +564,9 @@ public final class AguiAgentRunOutput implements AgentRunOutput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isHiddenToolName(String toolName) {
|
private static boolean isHiddenToolName(String toolName) {
|
||||||
return "retrieve_knowledge".equalsIgnoreCase(toolName)
|
String normalizedName = toolName == null ? "" : toolName.trim().toLowerCase(Locale.ROOT);
|
||||||
|
return "retrieve_knowledge".equals(normalizedName)
|
||||||
|
|| normalizedName.startsWith("retrieve_knowledge_")
|
||||||
|| "context_reload".equalsIgnoreCase(toolName)
|
|| "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).getResult());
|
() -> workflowToolExecutor.execute(workflow, arguments, context).getResult());
|
||||||
return new CompiledSyncTool(spec, invoker);
|
return new CompiledSyncTool(spec, invoker);
|
||||||
}
|
}
|
||||||
if (type == AgentToolType.PLUGIN) {
|
if (type == AgentToolType.PLUGIN) {
|
||||||
|
|||||||
@@ -2,13 +2,19 @@ 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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,11 +65,63 @@ 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), arguments == null ? Map.of() : arguments);
|
definitionId(workflow), variables);
|
||||||
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()) {
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ public class AgentSkillReferenceProvider implements SkillReferenceProvider {
|
|||||||
ids.add(agent.getId());
|
ids.add(agent.getId());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
List<String> result = new ArrayList<>();
|
List<String> result = new ArrayList<>();
|
||||||
for (Agent agent : agentService.listByIds(ids)) {
|
for (Agent agent : agentService.listByIds(ids)) {
|
||||||
result.add("智能体“" + (agent.getName() == null ? "未命名智能体" : agent.getName()) + "”");
|
result.add("智能体“" + (agent.getName() == null ? "未命名智能体" : agent.getName()) + "”");
|
||||||
|
|||||||
@@ -164,6 +164,31 @@ public class AgentRuntimeCommandConsumerTest {
|
|||||||
Assert.assertEquals("cmd-cancel", resultRegistry.lastSuccessCommandId);
|
Assert.assertEquals("cmd-cancel", resultRegistry.lastSuccessCommandId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证跨节点精确取消命令保留请求、用户和原因。
|
||||||
|
*
|
||||||
|
* @throws Exception 消息序列化异常
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void consumerShouldHandleCancelRunCommand() throws Exception {
|
||||||
|
AgentRuntimeProperties properties = new AgentRuntimeProperties();
|
||||||
|
properties.setInstanceId("node-a");
|
||||||
|
RecordingAgentRunService service = new RecordingAgentRunService();
|
||||||
|
RecordingCommandResultRegistry resultRegistry = new RecordingCommandResultRegistry();
|
||||||
|
AgentRuntimeCommandConsumer consumer = new AgentRuntimeCommandConsumer(
|
||||||
|
new ObjectMapper(), properties, new MQProperties(), service, resultRegistry);
|
||||||
|
AgentRuntimeCommandMessage command = command("cmd-cancel-run", "node-a");
|
||||||
|
command.setAction(AgentRuntimeCommandAction.CANCEL_RUN);
|
||||||
|
command.setReason("用户停止");
|
||||||
|
|
||||||
|
consumer.handle(List.of(message(command)));
|
||||||
|
|
||||||
|
Assert.assertEquals("request-cmd-cancel-run", service.lastCancelledRequestId);
|
||||||
|
Assert.assertEquals("1", service.lastCancelledUserId);
|
||||||
|
Assert.assertEquals("用户停止", service.lastReason);
|
||||||
|
Assert.assertEquals("cmd-cancel-run", resultRegistry.lastSuccessCommandId);
|
||||||
|
}
|
||||||
|
|
||||||
private AgentRuntimeCommandMessage command(String commandId, String targetNodeId) {
|
private AgentRuntimeCommandMessage command(String commandId, String targetNodeId) {
|
||||||
AgentRuntimeCommandMessage command = new AgentRuntimeCommandMessage();
|
AgentRuntimeCommandMessage command = new AgentRuntimeCommandMessage();
|
||||||
command.setCommandId(commandId);
|
command.setCommandId(commandId);
|
||||||
@@ -191,6 +216,8 @@ public class AgentRuntimeCommandConsumerTest {
|
|||||||
private String lastRequestId;
|
private String lastRequestId;
|
||||||
private String lastReason;
|
private String lastReason;
|
||||||
private String lastCancelledAgentId;
|
private String lastCancelledAgentId;
|
||||||
|
private String lastCancelledRequestId;
|
||||||
|
private String lastCancelledUserId;
|
||||||
private String lastApprovalId;
|
private String lastApprovalId;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -221,6 +248,13 @@ public class AgentRuntimeCommandConsumerTest {
|
|||||||
public void cancelAgentLocal(String agentId) {
|
public void cancelAgentLocal(String agentId) {
|
||||||
lastCancelledAgentId = agentId;
|
lastCancelledAgentId = agentId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void cancelRunLocal(String requestId, String userId, String reason) {
|
||||||
|
lastCancelledRequestId = requestId;
|
||||||
|
lastCancelledUserId = userId;
|
||||||
|
lastReason = reason;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static class RecordingCommandResultRegistry extends AgentRuntimeCommandResultRegistry {
|
private static class RecordingCommandResultRegistry extends AgentRuntimeCommandResultRegistry {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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;
|
||||||
@@ -44,6 +45,8 @@ 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);
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,13 @@ import com.easyagents.agent.runtime.AgentInitRequest;
|
|||||||
import com.easyagents.agent.runtime.AgentResumeRequest;
|
import com.easyagents.agent.runtime.AgentResumeRequest;
|
||||||
import com.easyagents.agent.runtime.AgentRuntime;
|
import com.easyagents.agent.runtime.AgentRuntime;
|
||||||
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
|
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
|
||||||
|
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
|
||||||
import org.junit.Assert;
|
import org.junit.Assert;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
|
import reactor.core.Disposable;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
|
import tech.easyflow.agent.distributed.AgentRuntimeRouteRegistry;
|
||||||
|
import tech.easyflow.agent.runtime.lock.AgentRunLock;
|
||||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
import tech.easyflow.core.runtime.ChatAssistantAccumulator;
|
import tech.easyflow.core.runtime.ChatAssistantAccumulator;
|
||||||
import tech.easyflow.core.runtime.ChatRuntimeContext;
|
import tech.easyflow.core.runtime.ChatRuntimeContext;
|
||||||
@@ -14,6 +18,10 @@ import tech.easyflow.core.runtime.ChatRuntimeContext;
|
|||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent 运行态注册表测试。
|
* Agent 运行态注册表测试。
|
||||||
*/
|
*/
|
||||||
@@ -69,6 +77,39 @@ public class AgentRunRegistryTest {
|
|||||||
Assert.assertThrows(BusinessException.class, () -> registry.approve(null, "token-3", "user-1"));
|
Assert.assertThrows(BusinessException.class, () -> registry.approve(null, "token-3", "user-1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证分布式锁释放失败时仍会关闭 Runtime 并清理全部本地、远程索引。
|
||||||
|
*
|
||||||
|
* @throws Exception Runtime close 方法声明的受检异常
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void removeShouldFinishCleanupWhenLockReleaseFails() throws Exception {
|
||||||
|
AgentRunRegistry registry = new AgentRunRegistry();
|
||||||
|
AgentRuntimeRouteRegistry routeRegistry = mock(AgentRuntimeRouteRegistry.class);
|
||||||
|
AgentRuntime runtime = mock(AgentRuntime.class);
|
||||||
|
AgentRunLock.Handle lockHandle = mock(AgentRunLock.Handle.class);
|
||||||
|
doThrow(new IllegalStateException("redis unavailable")).when(lockHandle).release();
|
||||||
|
registry.setRouteRegistry(routeRegistry);
|
||||||
|
AgentRunRegistry.AgentRunContext context = new AgentRunRegistry.AgentRunContext(
|
||||||
|
"request-cleanup", "session-cleanup", runtime, null,
|
||||||
|
new ChatRuntimeContext(), new StringBuilder(), new ChatAssistantAccumulator(),
|
||||||
|
new AtomicBoolean(false), false,
|
||||||
|
new AgentRunRegistry.RunOwner("agent-1", "session-cleanup", "user-1"),
|
||||||
|
lockHandle, event -> { }, error -> { }, () -> { });
|
||||||
|
registry.register(context);
|
||||||
|
registry.registerResumeToken("request-cleanup", "token-cleanup");
|
||||||
|
String approvalId = registry.registerApproval("request-cleanup", "token-cleanup");
|
||||||
|
|
||||||
|
Assert.assertThrows(IllegalStateException.class, () -> registry.remove("request-cleanup"));
|
||||||
|
|
||||||
|
verify(runtime).close();
|
||||||
|
verify(routeRegistry).removeResumeToken("token-cleanup");
|
||||||
|
verify(routeRegistry).removeApproval(approvalId);
|
||||||
|
verify(routeRegistry).removeRun("request-cleanup");
|
||||||
|
Assert.assertNull(registry.get("request-cleanup"));
|
||||||
|
Assert.assertThrows(BusinessException.class, () -> registry.resolveApproval(approvalId));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证运行审批只能由运行发起人处理。
|
* 验证运行审批只能由运行发起人处理。
|
||||||
*/
|
*/
|
||||||
@@ -144,6 +185,85 @@ public class AgentRunRegistryTest {
|
|||||||
Assert.assertNotNull(registry.get("request-10"));
|
Assert.assertNotNull(registry.get("request-10"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证显式单次取消会校验归属并进入标准取消事件链。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void cancelRunShouldEmitNormalizedCancellationForOwner() {
|
||||||
|
AgentRunRegistry registry = new AgentRunRegistry();
|
||||||
|
AtomicReference<AgentRuntimeEvent> cancellation = new AtomicReference<>();
|
||||||
|
AgentRunRegistry.AgentRunContext context = new AgentRunRegistry.AgentRunContext(
|
||||||
|
"request-11",
|
||||||
|
"session-11",
|
||||||
|
new CapturingRuntime(),
|
||||||
|
null,
|
||||||
|
new ChatRuntimeContext(),
|
||||||
|
new StringBuilder(),
|
||||||
|
new ChatAssistantAccumulator(),
|
||||||
|
new AtomicBoolean(false),
|
||||||
|
false,
|
||||||
|
new AgentRunRegistry.RunOwner("agent-1", "session-11", "user-1"),
|
||||||
|
null,
|
||||||
|
cancellation::set,
|
||||||
|
error -> {
|
||||||
|
},
|
||||||
|
() -> {
|
||||||
|
});
|
||||||
|
registry.register(context);
|
||||||
|
|
||||||
|
Assert.assertThrows(BusinessException.class,
|
||||||
|
() -> registry.cancelRun("request-11", "user-2", "用户停止"));
|
||||||
|
registry.cancelRun("request-11", "user-1", "用户停止");
|
||||||
|
|
||||||
|
Assert.assertNotNull(cancellation.get());
|
||||||
|
Assert.assertEquals(AgentRuntimeEventType.CANCELLED, cancellation.get().getEventType());
|
||||||
|
Assert.assertEquals("用户停止", cancellation.get().getPayload().get("reason"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证自然终态或重复远程命令到达后,显式取消保持幂等。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void cancelRunShouldBeIdempotentAfterContextRemoved() {
|
||||||
|
AgentRunRegistry registry = new AgentRunRegistry();
|
||||||
|
|
||||||
|
registry.cancelRun("request-finished", "user-1", "用户停止");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证订阅启动后上下文已经移除时,迟到的 Disposable 会立即释放。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void bindSubscriptionShouldDisposeWhenContextAlreadyRemoved() {
|
||||||
|
AgentRunRegistry registry = new AgentRunRegistry();
|
||||||
|
Disposable subscription = mock(Disposable.class);
|
||||||
|
|
||||||
|
registry.bindSubscription("request-removed", subscription);
|
||||||
|
|
||||||
|
verify(subscription).dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证运行已收口时绑定订阅不会遗留后台模型流。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void bindSubscriptionShouldDisposeWhenContextAlreadyFinished() {
|
||||||
|
AgentRunRegistry registry = new AgentRunRegistry();
|
||||||
|
AtomicBoolean finished = new AtomicBoolean(true);
|
||||||
|
AgentRunRegistry.AgentRunContext context = new AgentRunRegistry.AgentRunContext(
|
||||||
|
"request-finished", "session-finished", new CapturingRuntime(), null,
|
||||||
|
new ChatRuntimeContext(), new StringBuilder(), new ChatAssistantAccumulator(),
|
||||||
|
finished, false,
|
||||||
|
new AgentRunRegistry.RunOwner("agent-1", "session-finished", "user-1"),
|
||||||
|
null, event -> { }, error -> { }, () -> { });
|
||||||
|
Disposable subscription = mock(Disposable.class);
|
||||||
|
registry.register(context);
|
||||||
|
|
||||||
|
registry.bindSubscription("request-finished", subscription);
|
||||||
|
|
||||||
|
verify(subscription).dispose();
|
||||||
|
}
|
||||||
|
|
||||||
private AgentRunRegistry.AgentRunContext context(String requestId,
|
private AgentRunRegistry.AgentRunContext context(String requestId,
|
||||||
String sessionId,
|
String sessionId,
|
||||||
String userId,
|
String userId,
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
|
|||||||
import com.easyagents.agent.runtime.message.AgentKnowledgeReference;
|
import com.easyagents.agent.runtime.message.AgentKnowledgeReference;
|
||||||
import com.easyagents.agent.runtime.message.AgentMessage;
|
import com.easyagents.agent.runtime.message.AgentMessage;
|
||||||
import com.easyagents.agent.runtime.message.AgentMessageRole;
|
import com.easyagents.agent.runtime.message.AgentMessageRole;
|
||||||
|
import com.easyagents.agent.runtime.message.AgentTextBlock;
|
||||||
import com.easyagents.agent.runtime.persistence.session.AgentSessionStore;
|
import com.easyagents.agent.runtime.persistence.session.AgentSessionStore;
|
||||||
import com.easyagents.agent.runtime.persistence.session.memory.InMemoryAgentSessionStore;
|
import com.easyagents.agent.runtime.persistence.session.memory.InMemoryAgentSessionStore;
|
||||||
import org.junit.Assert;
|
import org.junit.Assert;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
import org.mockito.Mockito;
|
import org.mockito.Mockito;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
import tech.easyflow.agent.entity.AgentHitlPending;
|
import tech.easyflow.agent.entity.AgentHitlPending;
|
||||||
import tech.easyflow.agent.entity.Agent;
|
import tech.easyflow.agent.entity.Agent;
|
||||||
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
|
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
|
||||||
@@ -24,6 +26,12 @@ import tech.easyflow.agent.distributed.AgentRuntimeCommandProducer;
|
|||||||
import tech.easyflow.agent.distributed.AgentRuntimeRoute;
|
import tech.easyflow.agent.distributed.AgentRuntimeRoute;
|
||||||
import tech.easyflow.agent.distributed.AgentRuntimeRouteRegistry;
|
import tech.easyflow.agent.distributed.AgentRuntimeRouteRegistry;
|
||||||
import tech.easyflow.agent.runtime.event.AgentRunEventRecorder;
|
import tech.easyflow.agent.runtime.event.AgentRunEventRecorder;
|
||||||
|
import tech.easyflow.agent.runtime.agui.AgentAguiRunDescriptor;
|
||||||
|
import tech.easyflow.agent.runtime.agui.AgentAguiRunJournal;
|
||||||
|
import tech.easyflow.agent.runtime.agui.AgentAguiRunStatus;
|
||||||
|
import tech.easyflow.agent.runtime.agui.AgentAguiRunStore;
|
||||||
|
import tech.easyflow.agent.runtime.agui.AgentAguiRunSubscriptionService;
|
||||||
|
import tech.easyflow.agent.runtime.agui.AgentAguiWireContext;
|
||||||
import tech.easyflow.agent.runtime.hitl.AgentHitlPendingService;
|
import tech.easyflow.agent.runtime.hitl.AgentHitlPendingService;
|
||||||
import tech.easyflow.agent.runtime.document.AgentDocumentContext;
|
import tech.easyflow.agent.runtime.document.AgentDocumentContext;
|
||||||
import tech.easyflow.agent.runtime.lock.AgentRunLock;
|
import tech.easyflow.agent.runtime.lock.AgentRunLock;
|
||||||
@@ -50,6 +58,11 @@ import java.lang.reflect.Method;
|
|||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.Future;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,6 +70,176 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
|||||||
*/
|
*/
|
||||||
public class AgentRunServiceDraftAndHitlTest {
|
public class AgentRunServiceDraftAndHitlTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证文档上下文随用户消息进入可持久化 memory,同时保持独立内容块边界。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射调用失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void buildAgentMessageShouldIncludeDocumentContext() throws Exception {
|
||||||
|
AgentRunService service = new AgentRunService();
|
||||||
|
AgentDocumentContext documentContext = new AgentDocumentContext(
|
||||||
|
"\n<<<DOCUMENT name=\"demo.docx\">>>\n正文\n<<<END_DOCUMENT>>>", 8, List.of());
|
||||||
|
|
||||||
|
AgentMessage message = invoke(service, "buildAgentMessage",
|
||||||
|
new Class<?>[]{String.class, List.class, AgentDocumentContext.class},
|
||||||
|
"请介绍文档", List.of(), documentContext);
|
||||||
|
|
||||||
|
Assert.assertEquals(2, message.getContentBlocks().size());
|
||||||
|
Assert.assertEquals("请介绍文档",
|
||||||
|
((AgentTextBlock) message.getContentBlocks().get(0)).getText());
|
||||||
|
Assert.assertEquals(documentContext.text(),
|
||||||
|
((AgentTextBlock) message.getContentBlocks().get(1)).getText());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建用于 owner 恢复测试的运行描述。
|
||||||
|
*
|
||||||
|
* @param runId 运行 ID
|
||||||
|
* @param requestId 请求 ID
|
||||||
|
* @param updatedAt 最近更新时间
|
||||||
|
* @return 运行描述
|
||||||
|
*/
|
||||||
|
private static AgentAguiRunDescriptor descriptor(String runId, String requestId, long updatedAt) {
|
||||||
|
return new AgentAguiRunDescriptor(
|
||||||
|
runId, requestId, "thread-" + runId, "7", "session-" + runId,
|
||||||
|
"1", "2", false, AgentAguiRunStatus.RUNNING, 0L, updatedAt, updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证可重连运行会在 Redis store 创建前注册临时 owner 路由。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射调用失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void createRunOutputShouldRegisterProvisionalOwnerBeforeStore() throws Exception {
|
||||||
|
AgentRunService service = new AgentRunService();
|
||||||
|
AgentRuntimeRouteRegistry routeRegistry = Mockito.mock(AgentRuntimeRouteRegistry.class);
|
||||||
|
AgentAguiRunStore store = Mockito.mock(AgentAguiRunStore.class);
|
||||||
|
AgentAguiRunJournal journal = Mockito.mock(AgentAguiRunJournal.class);
|
||||||
|
AgentAguiRunSubscriptionService subscriptions = Mockito.mock(AgentAguiRunSubscriptionService.class);
|
||||||
|
Mockito.when(subscriptions.subscribe(Mockito.any(AgentAguiRunDescriptor.class), Mockito.eq(0L)))
|
||||||
|
.thenReturn(new SseEmitter());
|
||||||
|
setField(service, "agentRuntimeRouteRegistry", routeRegistry);
|
||||||
|
setField(service, "agentAguiRunStore", store);
|
||||||
|
setField(service, "agentAguiRunJournal", journal);
|
||||||
|
setField(service, "agentAguiRunSubscriptionService", subscriptions);
|
||||||
|
Agent agent = new Agent();
|
||||||
|
agent.setId(BigInteger.valueOf(7L));
|
||||||
|
LoginAccount account = new LoginAccount();
|
||||||
|
account.setId(BigInteger.ONE);
|
||||||
|
account.setTenantId(BigInteger.TWO);
|
||||||
|
|
||||||
|
AgentRunOutput output = invoke(service, "createRunOutput",
|
||||||
|
new Class<?>[]{AgentAguiWireContext.class, String.class, LoginAccount.class,
|
||||||
|
Agent.class, String.class, boolean.class},
|
||||||
|
new AgentAguiWireContext("thread-start", "run-start", "user-start", "启动"),
|
||||||
|
"request-start", account, agent, "session-start", false);
|
||||||
|
|
||||||
|
Assert.assertNotNull(output);
|
||||||
|
org.mockito.InOrder order = Mockito.inOrder(routeRegistry, store, subscriptions);
|
||||||
|
order.verify(routeRegistry).registerRun("request-start", "7");
|
||||||
|
order.verify(store).create(Mockito.any(AgentAguiRunDescriptor.class));
|
||||||
|
order.verify(subscriptions).subscribe(Mockito.any(AgentAguiRunDescriptor.class), Mockito.eq(0L));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证会话锁获取失败时会写入失败终态并移除临时 owner 路由。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射调用失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void runAguiAwareShouldCloseProvisionalRunWhenLockAcquireFails() throws Exception {
|
||||||
|
AgentRunService service = new AgentRunService();
|
||||||
|
AgentRunLock runLock = Mockito.mock(AgentRunLock.class);
|
||||||
|
AgentRuntimeRouteRegistry routeRegistry = Mockito.mock(AgentRuntimeRouteRegistry.class);
|
||||||
|
AgentRunOutput output = Mockito.mock(AgentRunOutput.class);
|
||||||
|
Mockito.when(runLock.acquire(Mockito.any(), Mockito.eq("session-lock-failed")))
|
||||||
|
.thenThrow(new IllegalStateException("lock unavailable"));
|
||||||
|
Mockito.when(output.cancelRunOnDisconnect()).thenReturn(false);
|
||||||
|
setField(service, "agentRunLock", runLock);
|
||||||
|
setField(service, "agentRuntimeRouteRegistry", routeRegistry);
|
||||||
|
Agent agent = new Agent();
|
||||||
|
agent.setId(BigInteger.valueOf(8L));
|
||||||
|
|
||||||
|
try {
|
||||||
|
invoke(service, "runAguiAware",
|
||||||
|
new Class<?>[]{Agent.class, String.class, List.class, List.class,
|
||||||
|
LoginAccount.class, String.class, String.class, String.class,
|
||||||
|
String.class, ChatRuntimeContext.class, boolean.class,
|
||||||
|
AgentSessionStore.class, AgentRunOutput.class},
|
||||||
|
agent, "问题", List.of(), List.of(), new LoginAccount(),
|
||||||
|
"request-lock-failed", "trace-lock-failed", "session-lock-failed",
|
||||||
|
"AGENT", new ChatRuntimeContext(), true,
|
||||||
|
Mockito.mock(AgentSessionStore.class), output);
|
||||||
|
Assert.fail("expected lock failure");
|
||||||
|
} catch (Exception exception) {
|
||||||
|
Assert.assertTrue(rootCause(exception) instanceof IllegalStateException);
|
||||||
|
}
|
||||||
|
|
||||||
|
Mockito.verify(output).completeWithError(Mockito.any(IllegalStateException.class));
|
||||||
|
Mockito.verify(routeRegistry).removeRun("request-lock-failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证超过启动宽限期但 owner 节点仍存活时不会误判运行丢失。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射调用失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void reconcileShouldKeepSlowStartingRunWhenOwnerBootIsAlive() throws Exception {
|
||||||
|
AgentRunService service = new AgentRunService();
|
||||||
|
AgentRuntimeRouteRegistry routeRegistry = Mockito.mock(AgentRuntimeRouteRegistry.class);
|
||||||
|
AgentAguiRunStore store = Mockito.mock(AgentAguiRunStore.class);
|
||||||
|
AgentRuntimeRoute route = new AgentRuntimeRoute();
|
||||||
|
route.setNodeId("node-alive");
|
||||||
|
route.setBootId("boot-alive");
|
||||||
|
Mockito.when(routeRegistry.findOwnerRoute("request-slow")).thenReturn(route);
|
||||||
|
Mockito.when(routeRegistry.currentNodeBootId("node-alive")).thenReturn("boot-alive");
|
||||||
|
setField(service, "agentRuntimeRouteRegistry", routeRegistry);
|
||||||
|
setField(service, "agentAguiRunStore", store);
|
||||||
|
long old = System.currentTimeMillis() - 120_000L;
|
||||||
|
AgentAguiRunDescriptor descriptor = descriptor("run-slow", "request-slow", old);
|
||||||
|
|
||||||
|
AgentAguiRunDescriptor result = invoke(service, "reconcileInterruptedRun",
|
||||||
|
new Class<?>[]{AgentAguiRunDescriptor.class}, descriptor);
|
||||||
|
|
||||||
|
Assert.assertSame(descriptor, result);
|
||||||
|
Mockito.verify(store, Mockito.never()).failOwnerLost(Mockito.any());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 owner 启动代心跳消失且超过宽限期后写入明确失败终态。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射调用失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void reconcileShouldFailRunWhenOwnerBootDisappears() throws Exception {
|
||||||
|
AgentRunService service = new AgentRunService();
|
||||||
|
AgentRuntimeRouteRegistry routeRegistry = Mockito.mock(AgentRuntimeRouteRegistry.class);
|
||||||
|
AgentAguiRunStore store = Mockito.mock(AgentAguiRunStore.class);
|
||||||
|
AgentRuntimeRoute route = new AgentRuntimeRoute();
|
||||||
|
route.setNodeId("node-lost");
|
||||||
|
route.setBootId("boot-lost");
|
||||||
|
Mockito.when(routeRegistry.findOwnerRoute("request-lost")).thenReturn(route);
|
||||||
|
Mockito.when(routeRegistry.currentNodeBootId("node-lost")).thenReturn(null);
|
||||||
|
long old = System.currentTimeMillis() - 120_000L;
|
||||||
|
AgentAguiRunDescriptor descriptor = descriptor("run-lost", "request-lost", old);
|
||||||
|
AgentAguiRunDescriptor failed = new AgentAguiRunDescriptor(
|
||||||
|
descriptor.runId(), descriptor.requestId(), descriptor.threadId(), descriptor.agentId(),
|
||||||
|
descriptor.sessionId(), descriptor.userId(), descriptor.tenantId(), descriptor.draft(),
|
||||||
|
AgentAguiRunStatus.FAILED, 1L, descriptor.createdAt(), System.currentTimeMillis());
|
||||||
|
Mockito.when(store.find("run-lost")).thenReturn(failed);
|
||||||
|
setField(service, "agentRuntimeRouteRegistry", routeRegistry);
|
||||||
|
setField(service, "agentAguiRunStore", store);
|
||||||
|
|
||||||
|
AgentAguiRunDescriptor result = invoke(service, "reconcileInterruptedRun",
|
||||||
|
new Class<?>[]{AgentAguiRunDescriptor.class}, descriptor);
|
||||||
|
|
||||||
|
Assert.assertSame(failed, result);
|
||||||
|
Mockito.verify(store).failOwnerLost(descriptor);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证工具 HITL 事件会映射为显式前端载荷。
|
* 验证工具 HITL 事件会映射为显式前端载荷。
|
||||||
*
|
*
|
||||||
@@ -293,18 +476,48 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证知识检索状态不会携带命中文档和内部 metadata。
|
* 验证知识库工具开始事件会投影为脱敏的检索中状态。
|
||||||
*
|
*
|
||||||
* @throws Exception 反射调用失败时抛出
|
* @throws Exception 反射调用失败时抛出
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
public void handleRuntimeEventShouldWhitelistKnowledgeStatusPayload() throws Exception {
|
public void handleRuntimeEventShouldProjectKnowledgeToolCallAsRunningStatus() throws Exception {
|
||||||
AgentRunService service = new AgentRunService();
|
AgentRunService service = new AgentRunService();
|
||||||
RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
|
RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
|
||||||
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL);
|
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_CALL);
|
||||||
|
event.setToolCallId("knowledge-call-1");
|
||||||
|
event.getPayload().put("toolCallId", "knowledge-call-1");
|
||||||
|
event.getPayload().put("toolName", "retrieve_knowledge_homeinn_faq");
|
||||||
|
event.getPayload().put("toolCategory", "KNOWLEDGE");
|
||||||
event.getPayload().put("documents", List.of(Map.of("chunkContent", "private chunk")));
|
event.getPayload().put("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(),
|
||||||
@@ -319,6 +532,48 @@ 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));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证完成事件不会再次发送正文消息,只用于最终收口。
|
* 验证完成事件不会再次发送正文消息,只用于最终收口。
|
||||||
*
|
*
|
||||||
@@ -418,6 +673,78 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
Mockito.verify(output).complete();
|
Mockito.verify(output).complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证可恢复终态写入失败时不会把聊天会话记录为完成。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射调用失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void finishClaimedRunShouldRecordFailureWhenOutputCompletionFails() throws Exception {
|
||||||
|
AgentRunService service = new AgentRunService();
|
||||||
|
RecordingChatRuntimeManager chatRuntimeManager = new RecordingChatRuntimeManager();
|
||||||
|
setField(service, "agentRunRegistry", new AgentRunRegistry());
|
||||||
|
setField(service, "chatRuntimeManager", chatRuntimeManager);
|
||||||
|
AgentRunOutput output = Mockito.mock(AgentRunOutput.class);
|
||||||
|
Mockito.when(output.finish("最终正文")).thenReturn(false);
|
||||||
|
|
||||||
|
invoke(service, "finishClaimedRun",
|
||||||
|
new Class<?>[]{String.class, AgentRunOutput.class, ChatRuntimeContext.class,
|
||||||
|
StringBuilder.class, ChatAssistantAccumulator.class, boolean.class, List.class},
|
||||||
|
"request-output-failed", output, chatContext(), new StringBuilder("最终正文"),
|
||||||
|
new ChatAssistantAccumulator(), true, List.of());
|
||||||
|
|
||||||
|
Assert.assertEquals(1, chatRuntimeManager.recordAssistantCompletedCount);
|
||||||
|
Assert.assertEquals(1, chatRuntimeManager.recordFailureCount);
|
||||||
|
Assert.assertEquals(0, chatRuntimeManager.recordCompletedCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证终态回调内部抛错后强制释放 Registry 并写入失败状态。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射调用失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void runtimeCallbackFailureShouldForceCleanupAfterTerminalClaim() throws Exception {
|
||||||
|
AgentRunService service = new AgentRunService();
|
||||||
|
AgentRunRegistry registry = new AgentRunRegistry();
|
||||||
|
RecordingChatRuntimeManager chatRuntimeManager = new RecordingChatRuntimeManager();
|
||||||
|
AgentRunOutput output = Mockito.mock(AgentRunOutput.class);
|
||||||
|
AtomicBoolean finished = new AtomicBoolean(true);
|
||||||
|
AgentRunRegistry.AgentRunContext context = new AgentRunRegistry.AgentRunContext(
|
||||||
|
"request-callback-failed",
|
||||||
|
"session-callback-failed",
|
||||||
|
new NoopRuntime(),
|
||||||
|
output,
|
||||||
|
chatContext(),
|
||||||
|
new StringBuilder(),
|
||||||
|
new ChatAssistantAccumulator(),
|
||||||
|
finished,
|
||||||
|
true,
|
||||||
|
new AgentRunRegistry.RunOwner("agent-1", "session-callback-failed", "user-1"),
|
||||||
|
null,
|
||||||
|
event -> {
|
||||||
|
},
|
||||||
|
error -> {
|
||||||
|
},
|
||||||
|
() -> {
|
||||||
|
}
|
||||||
|
);
|
||||||
|
registry.register(context);
|
||||||
|
setField(service, "agentRunRegistry", registry);
|
||||||
|
setField(service, "chatRuntimeManager", chatRuntimeManager);
|
||||||
|
|
||||||
|
invoke(service, "runRuntimeCallbackSafely",
|
||||||
|
new Class<?>[]{Runnable.class, String.class, AgentRunOutput.class,
|
||||||
|
ChatRuntimeContext.class, AtomicBoolean.class, boolean.class},
|
||||||
|
(Runnable) () -> {
|
||||||
|
throw new IllegalStateException("审计写入失败");
|
||||||
|
}, "request-callback-failed", output, chatContext(), finished, true);
|
||||||
|
|
||||||
|
Assert.assertNull(registry.get("request-callback-failed"));
|
||||||
|
Assert.assertEquals(1, chatRuntimeManager.recordFailureCount);
|
||||||
|
Mockito.verify(output).completeWithError(Mockito.any(IllegalStateException.class));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证取消事件作为业务状态收口,不按系统错误发送。
|
* 验证取消事件作为业务状态收口,不按系统错误发送。
|
||||||
*
|
*
|
||||||
@@ -456,6 +783,73 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
Assert.assertEquals(1, chatRuntimeManager.recordFailureCount);
|
Assert.assertEquals(1, chatRuntimeManager.recordFailureCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证用户取消与自然完成并发到达时只允许一个终态完成协议投影和聊天记录收口。
|
||||||
|
*
|
||||||
|
* @throws Exception 并发任务或反射调用失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void concurrentCancelAndCompleteShouldShareOneTerminalArbitration() throws Exception {
|
||||||
|
AgentRunService service = new AgentRunService();
|
||||||
|
RecordingChatRuntimeManager chatRuntimeManager = new RecordingChatRuntimeManager();
|
||||||
|
setField(service, "agentRunRegistry", new AgentRunRegistry());
|
||||||
|
setField(service, "chatRuntimeManager", chatRuntimeManager);
|
||||||
|
AgentRunOutput output = Mockito.mock(AgentRunOutput.class);
|
||||||
|
Mockito.when(output.emitRuntimeEvent(Mockito.any())).thenReturn(true);
|
||||||
|
Mockito.when(output.emitViewEvent(Mockito.any(), Mockito.any(), Mockito.any())).thenReturn(true);
|
||||||
|
Mockito.when(output.finish(Mockito.anyString())).thenReturn(true);
|
||||||
|
AtomicBoolean finished = new AtomicBoolean(false);
|
||||||
|
CountDownLatch start = new CountDownLatch(1);
|
||||||
|
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||||
|
AgentRuntimeEvent completed = AgentRuntimeEvent.of(AgentRuntimeEventType.COMPLETED);
|
||||||
|
completed.getPayload().put("text", "自然完成正文");
|
||||||
|
AgentRuntimeEvent cancelled = AgentRuntimeEvent.of(AgentRuntimeEventType.CANCELLED);
|
||||||
|
cancelled.getPayload().put("reason", "用户停止");
|
||||||
|
|
||||||
|
try {
|
||||||
|
Future<?> completeFuture = executor.submit(() -> invokeTerminalEvent(
|
||||||
|
service, start, completed, output, finished));
|
||||||
|
Future<?> cancelFuture = executor.submit(() -> invokeTerminalEvent(
|
||||||
|
service, start, cancelled, output, finished));
|
||||||
|
start.countDown();
|
||||||
|
completeFuture.get(5, TimeUnit.SECONDS);
|
||||||
|
cancelFuture.get(5, TimeUnit.SECONDS);
|
||||||
|
} finally {
|
||||||
|
executor.shutdownNow();
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.assertTrue(finished.get());
|
||||||
|
Assert.assertEquals(1,
|
||||||
|
chatRuntimeManager.recordCompletedCount + chatRuntimeManager.recordFailureCount);
|
||||||
|
Mockito.verify(output, Mockito.times(1)).emitRuntimeEvent(Mockito.any());
|
||||||
|
Mockito.verify(output, Mockito.times(1)).finish(Mockito.nullable(String.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 等待统一起跑信号后投递指定终态事件。
|
||||||
|
*
|
||||||
|
* @param service 被测服务
|
||||||
|
* @param start 并发起跑信号
|
||||||
|
* @param event 终态事件
|
||||||
|
* @param output 运行输出
|
||||||
|
* @param finished 共享终态标记
|
||||||
|
*/
|
||||||
|
private void invokeTerminalEvent(AgentRunService service,
|
||||||
|
CountDownLatch start,
|
||||||
|
AgentRuntimeEvent event,
|
||||||
|
AgentRunOutput output,
|
||||||
|
AtomicBoolean finished) {
|
||||||
|
try {
|
||||||
|
start.await(5, TimeUnit.SECONDS);
|
||||||
|
invoke(service, "handleRuntimeEvent",
|
||||||
|
runtimeEventParameterTypes(),
|
||||||
|
event, "request-terminal-race", output, new StringBuilder("部分正文"),
|
||||||
|
new ChatAssistantAccumulator(), chatContext(), finished, true);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException("并发终态测试执行失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证最终知识库引用会保留命中分片原文。
|
* 验证最终知识库引用会保留命中分片原文。
|
||||||
*
|
*
|
||||||
@@ -1257,6 +1651,29 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建知识库工具生命周期测试事件。
|
||||||
|
*
|
||||||
|
* @param eventType 工具开始或结果事件类型
|
||||||
|
* @param toolCallId 工具调用 ID
|
||||||
|
* @param success 工具结果是否成功
|
||||||
|
* @return 知识库工具事件
|
||||||
|
*/
|
||||||
|
private AgentRuntimeEvent knowledgeToolEvent(AgentRuntimeEventType eventType,
|
||||||
|
String toolCallId,
|
||||||
|
boolean success) {
|
||||||
|
AgentRuntimeEvent event = AgentRuntimeEvent.of(eventType);
|
||||||
|
event.setToolCallId(toolCallId);
|
||||||
|
event.getPayload().put("toolCallId", toolCallId);
|
||||||
|
event.getPayload().put("toolName", "retrieve_knowledge_homeinn_faq");
|
||||||
|
event.getPayload().put("toolCategory", "KNOWLEDGE");
|
||||||
|
if (eventType == AgentRuntimeEventType.TOOL_RESULT) {
|
||||||
|
event.getPayload().put("success", success);
|
||||||
|
event.getPayload().put("status", success ? "SUCCESS" : "FAILED");
|
||||||
|
}
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
private Class<?>[] runtimeEventParameterTypes() {
|
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,
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
package tech.easyflow.agent.runtime;
|
||||||
|
|
||||||
|
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeDocument;
|
||||||
|
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRegistration;
|
||||||
|
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetrievalRequest;
|
||||||
|
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetrievalResult;
|
||||||
|
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeSpec;
|
||||||
|
import com.easyagents.core.document.Document;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import tech.easyflow.agent.entity.Agent;
|
||||||
|
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
|
||||||
|
import tech.easyflow.agent.runtime.skill.AgentSkillRuntimeCompiler;
|
||||||
|
import tech.easyflow.agent.runtime.tool.AgentToolRuntimeCompiler;
|
||||||
|
import tech.easyflow.ai.entity.Model;
|
||||||
|
import tech.easyflow.ai.entity.ModelProvider;
|
||||||
|
import tech.easyflow.ai.rag.KnowledgeRetrievalRequest;
|
||||||
|
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||||
|
import tech.easyflow.ai.service.ModelService;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.lang.reflect.Proxy;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent 知识库一库一工具运行时编译测试。
|
||||||
|
*/
|
||||||
|
public class AgentRuntimeCompilerKnowledgeTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证知识库英文名称、描述和检索配置会编译到中立声明及独立 Retriever。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射注入依赖失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void compileShouldBuildOneKnowledgeRegistrationWithEnglishRuntimeName() throws Exception {
|
||||||
|
AtomicReference<KnowledgeRetrievalRequest> capturedRequest = new AtomicReference<>();
|
||||||
|
Document document = new Document("如家酒店通常在入住日 14:00 后办理入住。");
|
||||||
|
document.setId("chunk-1");
|
||||||
|
document.setTitle("如家 FAQ");
|
||||||
|
document.setScore(0.92D);
|
||||||
|
document.addMetadata("documentId", "faq-document-1");
|
||||||
|
document.addMetadata("chunkId", "faq-chunk-1");
|
||||||
|
AgentRuntimeCompiler compiler = compiler(capturedRequest, List.of(document));
|
||||||
|
Agent agent = agent(knowledgeBinding("homeinn_faq", BigInteger.valueOf(20L)));
|
||||||
|
|
||||||
|
AgentRuntimeBundle bundle = compiler.compile(agent);
|
||||||
|
|
||||||
|
Assert.assertEquals(1, bundle.getDefinition().getKnowledgeSpecs().size());
|
||||||
|
AgentKnowledgeSpec spec = bundle.getDefinition().getKnowledgeSpecs().get(0);
|
||||||
|
Assert.assertEquals("homeinn_faq", spec.getRuntimeName());
|
||||||
|
Assert.assertEquals("如家 FAQ", spec.getName());
|
||||||
|
Assert.assertTrue(spec.getDescription().contains("入住"));
|
||||||
|
Assert.assertEquals(7, spec.getLimit());
|
||||||
|
Assert.assertEquals(0.55D, spec.getScoreThreshold(), 0.0001D);
|
||||||
|
Assert.assertEquals(1, bundle.getKnowledgeRegistrations().size());
|
||||||
|
|
||||||
|
AgentKnowledgeRegistration registration = bundle.getKnowledgeRegistrations().get(0);
|
||||||
|
AgentKnowledgeRetrievalRequest retrievalRequest = new AgentKnowledgeRetrievalRequest();
|
||||||
|
retrievalRequest.setQuery("如家几点入住");
|
||||||
|
retrievalRequest.setLimit(spec.getLimit());
|
||||||
|
retrievalRequest.setScoreThreshold(spec.getScoreThreshold());
|
||||||
|
AgentKnowledgeRetrievalResult result = registration.getRetriever().retrieve(retrievalRequest);
|
||||||
|
|
||||||
|
Assert.assertEquals("如家几点入住", capturedRequest.get().getQuery());
|
||||||
|
Assert.assertEquals(Integer.valueOf(7), capturedRequest.get().getLimit());
|
||||||
|
Assert.assertEquals(Double.valueOf(0.55D), capturedRequest.get().getMinSimilarity());
|
||||||
|
Assert.assertEquals("AGENT_KNOWLEDGE", capturedRequest.get().getCallerType());
|
||||||
|
Assert.assertEquals(1, result.getDocuments().size());
|
||||||
|
AgentKnowledgeDocument mapped = result.getDocuments().get(0);
|
||||||
|
Assert.assertEquals("faq-document-1", mapped.getDocumentId());
|
||||||
|
Assert.assertEquals("faq-chunk-1", mapped.getChunkId());
|
||||||
|
Assert.assertEquals(0.92D, mapped.getScore(), 0.0001D);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证缺失知识库英文名称时在发布编译阶段明确失败。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射注入依赖失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void compileShouldRejectMissingKnowledgeEnglishName() throws Exception {
|
||||||
|
AgentRuntimeCompiler compiler = compiler(new AtomicReference<>(), List.of());
|
||||||
|
Agent agent = agent(knowledgeBinding(null, BigInteger.valueOf(20L)));
|
||||||
|
|
||||||
|
try {
|
||||||
|
compiler.compile(agent);
|
||||||
|
Assert.fail("缺失英文名称时应拒绝编译");
|
||||||
|
} catch (BusinessException expected) {
|
||||||
|
Assert.assertTrue(expected.getMessage().contains("英文名称不能为空"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证多个知识库生成相同工具名时在编译阶段拒绝发布。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射注入依赖失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void compileShouldRejectDuplicateKnowledgeToolNames() throws Exception {
|
||||||
|
AgentRuntimeCompiler compiler = compiler(new AtomicReference<>(), List.of());
|
||||||
|
AgentKnowledgeBinding first = knowledgeBinding("homeinn_faq", BigInteger.valueOf(20L));
|
||||||
|
AgentKnowledgeBinding second = knowledgeBinding("homeinn_faq", BigInteger.valueOf(21L));
|
||||||
|
Agent agent = agent(first, second);
|
||||||
|
|
||||||
|
try {
|
||||||
|
compiler.compile(agent);
|
||||||
|
Assert.fail("重复知识库工具名时应拒绝编译");
|
||||||
|
} catch (BusinessException expected) {
|
||||||
|
Assert.assertTrue(expected.getMessage().contains("retrieve_knowledge_homeinn_faq"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建仅含测试模型与知识库服务的运行时编译器。
|
||||||
|
*
|
||||||
|
* @param capturedRequest 检索请求捕获器
|
||||||
|
* @param documents 检索服务返回文档
|
||||||
|
* @return 已注入依赖的编译器
|
||||||
|
* @throws Exception 反射注入失败时抛出
|
||||||
|
*/
|
||||||
|
private AgentRuntimeCompiler compiler(AtomicReference<KnowledgeRetrievalRequest> capturedRequest,
|
||||||
|
List<Document> documents) throws Exception {
|
||||||
|
ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
AgentToolRuntimeCompiler toolCompiler = new AgentToolRuntimeCompiler();
|
||||||
|
AgentRuntimeCompiler compiler = new AgentRuntimeCompiler();
|
||||||
|
setField(compiler, "objectMapper", objectMapper);
|
||||||
|
setField(compiler, "modelService", modelService(model()));
|
||||||
|
setField(compiler, "documentCollectionService", documentCollectionService(capturedRequest, documents));
|
||||||
|
setField(compiler, "agentToolRuntimeCompiler", toolCompiler);
|
||||||
|
setField(compiler, "agentSkillRuntimeCompiler",
|
||||||
|
new AgentSkillRuntimeCompiler(null, toolCompiler, objectMapper));
|
||||||
|
return compiler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建带知识库绑定的 Agent。
|
||||||
|
*
|
||||||
|
* @param bindings 知识库绑定
|
||||||
|
* @return Agent 测试对象
|
||||||
|
*/
|
||||||
|
private Agent agent(AgentKnowledgeBinding... bindings) {
|
||||||
|
Agent agent = new Agent();
|
||||||
|
agent.setId(BigInteger.ONE);
|
||||||
|
agent.setName("如家助手");
|
||||||
|
agent.setModelId(BigInteger.TEN);
|
||||||
|
agent.setKnowledgeBindings(List.of(bindings));
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建冻结知识库绑定。
|
||||||
|
*
|
||||||
|
* @param englishName 知识库英文名称
|
||||||
|
* @param knowledgeId 知识库 ID
|
||||||
|
* @return 知识库绑定
|
||||||
|
*/
|
||||||
|
private AgentKnowledgeBinding knowledgeBinding(String englishName, BigInteger knowledgeId) {
|
||||||
|
AgentKnowledgeBinding binding = new AgentKnowledgeBinding();
|
||||||
|
binding.setAgentId(BigInteger.ONE);
|
||||||
|
binding.setKnowledgeId(knowledgeId);
|
||||||
|
binding.setRetrievalMode("HYBRID");
|
||||||
|
binding.setEnabled(true);
|
||||||
|
binding.setOptionsJson(Map.of("limit", 7, "scoreThreshold", 0.55D));
|
||||||
|
binding.setResourceSnapshot(Map.of(
|
||||||
|
"id", knowledgeId,
|
||||||
|
"title", "如家 FAQ",
|
||||||
|
"description", "如家酒店入住、退房和会员服务常见问题",
|
||||||
|
"collectionType", "FAQ",
|
||||||
|
"englishName", englishName == null ? "" : englishName));
|
||||||
|
return binding;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建模型服务代理。
|
||||||
|
*
|
||||||
|
* @param model 测试模型
|
||||||
|
* @return 模型服务代理
|
||||||
|
*/
|
||||||
|
private ModelService modelService(Model model) {
|
||||||
|
return (ModelService) Proxy.newProxyInstance(
|
||||||
|
ModelService.class.getClassLoader(),
|
||||||
|
new Class<?>[]{ModelService.class},
|
||||||
|
(proxy, method, args) -> "getModelInstance".equals(method.getName())
|
||||||
|
? model
|
||||||
|
: defaultValue(method.getReturnType()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建知识库服务代理。
|
||||||
|
*
|
||||||
|
* @param capturedRequest 检索请求捕获器
|
||||||
|
* @param documents 返回文档
|
||||||
|
* @return 知识库服务代理
|
||||||
|
*/
|
||||||
|
private DocumentCollectionService documentCollectionService(
|
||||||
|
AtomicReference<KnowledgeRetrievalRequest> capturedRequest,
|
||||||
|
List<Document> documents) {
|
||||||
|
return (DocumentCollectionService) Proxy.newProxyInstance(
|
||||||
|
DocumentCollectionService.class.getClassLoader(),
|
||||||
|
new Class<?>[]{DocumentCollectionService.class},
|
||||||
|
(proxy, method, args) -> {
|
||||||
|
if ("search".equals(method.getName()) && args != null && args.length == 1
|
||||||
|
&& args[0] instanceof KnowledgeRetrievalRequest request) {
|
||||||
|
capturedRequest.set(request);
|
||||||
|
return documents;
|
||||||
|
}
|
||||||
|
return defaultValue(method.getReturnType());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建可映射为 AgentScope 模型配置的测试模型。
|
||||||
|
*
|
||||||
|
* @return 测试模型
|
||||||
|
*/
|
||||||
|
private Model model() {
|
||||||
|
ModelProvider provider = new ModelProvider();
|
||||||
|
provider.setProviderType("openai");
|
||||||
|
provider.setProviderName("OpenAI");
|
||||||
|
Model model = new Model();
|
||||||
|
model.setId(BigInteger.TEN);
|
||||||
|
model.setModelProvider(provider);
|
||||||
|
model.setModelName("gpt-test");
|
||||||
|
model.setEndpoint("https://example.com");
|
||||||
|
model.setRequestPath("/v1/chat/completions");
|
||||||
|
model.setApiKey("test-key");
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回代理方法所需的默认值。
|
||||||
|
*
|
||||||
|
* @param type 返回类型
|
||||||
|
* @return 对应默认值
|
||||||
|
*/
|
||||||
|
private Object defaultValue(Class<?> type) {
|
||||||
|
if (type == boolean.class) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (type == int.class || type == long.class || type == short.class || type == byte.class) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (type == double.class || type == float.class) {
|
||||||
|
return 0D;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 反射注入测试依赖。
|
||||||
|
*
|
||||||
|
* @param target 目标对象
|
||||||
|
* @param fieldName 字段名称
|
||||||
|
* @param value 字段值
|
||||||
|
* @throws Exception 字段不存在或不可写时抛出
|
||||||
|
*/
|
||||||
|
private void setField(Object target, String fieldName, Object value) throws Exception {
|
||||||
|
Field field = target.getClass().getDeclaredField(fieldName);
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(target, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
package tech.easyflow.agent.runtime.agui;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.Future;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.doReturn;
|
||||||
|
import static org.mockito.Mockito.doAnswer;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AG-UI 批量事件日志测试。
|
||||||
|
*/
|
||||||
|
public class AgentAguiRunJournalTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证相邻同消息 delta 会在一次 Redis 批次中合并且顺序不变。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldMergeAdjacentDeltasBeforeRedisFlush() {
|
||||||
|
AgentAguiRunStore store = mock(AgentAguiRunStore.class);
|
||||||
|
AgentAguiRunJournal journal = new AgentAguiRunJournal(store, new ObjectMapper());
|
||||||
|
journal.append("run-1", "{\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"m1\",\"delta\":\"你\"}");
|
||||||
|
journal.append("run-1", "{\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"m1\",\"delta\":\"好\"}");
|
||||||
|
journal.append("run-1", "{\"type\":\"TEXT_MESSAGE_END\",\"messageId\":\"m1\"}");
|
||||||
|
|
||||||
|
journal.flush("run-1");
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
ArgumentCaptor<List<String>> events = ArgumentCaptor.forClass(List.class);
|
||||||
|
verify(store).append(eq("run-1"), anyString(), events.capture());
|
||||||
|
Assert.assertEquals(2, events.getValue().size());
|
||||||
|
Assert.assertTrue(events.getValue().get(0).contains("\"delta\":\"你好\""));
|
||||||
|
Assert.assertTrue(events.getValue().get(1).contains("TEXT_MESSAGE_END"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证同步终态写入失败后,后台刷新仍会保留并重试原批次。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldRetryTerminalBatchWithoutDroppingEvents() {
|
||||||
|
AgentAguiRunStore store = mock(AgentAguiRunStore.class);
|
||||||
|
AgentAguiRunJournal journal = new AgentAguiRunJournal(store, new ObjectMapper());
|
||||||
|
String terminal = "{\"type\":\"RUN_FINISHED\",\"runId\":\"run-1\",\"threadId\":\"1\"}";
|
||||||
|
journal.append("run-1", terminal);
|
||||||
|
doThrow(new IllegalStateException("redis down"))
|
||||||
|
.doReturn(1L)
|
||||||
|
.when(store).append(eq("run-1"), anyString(), org.mockito.ArgumentMatchers.anyList());
|
||||||
|
|
||||||
|
try {
|
||||||
|
journal.complete("run-1");
|
||||||
|
Assert.fail("首次同步写入应抛出异常");
|
||||||
|
} catch (IllegalStateException expected) {
|
||||||
|
Assert.assertEquals("redis down", expected.getMessage());
|
||||||
|
}
|
||||||
|
journal.flushPending();
|
||||||
|
|
||||||
|
ArgumentCaptor<String> batchIds = ArgumentCaptor.forClass(String.class);
|
||||||
|
verify(store, times(2)).append(
|
||||||
|
eq("run-1"), batchIds.capture(), org.mockito.ArgumentMatchers.anyList());
|
||||||
|
Assert.assertEquals(batchIds.getAllValues().get(0), batchIds.getAllValues().get(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证结果不确定的批次内容保持冻结,期间到达的新事件使用新批次写入。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldKeepAmbiguousBatchImmutableAndFlushTailSeparately() {
|
||||||
|
AgentAguiRunStore store = mock(AgentAguiRunStore.class);
|
||||||
|
AgentAguiRunJournal journal = new AgentAguiRunJournal(store, new ObjectMapper());
|
||||||
|
doThrow(new IllegalStateException("ambiguous timeout"))
|
||||||
|
.doReturn(1L)
|
||||||
|
.doReturn(2L)
|
||||||
|
.when(store).append(eq("run-1"), anyString(), org.mockito.ArgumentMatchers.anyList());
|
||||||
|
String first = "{\"type\":\"RUN_STARTED\",\"runId\":\"run-1\"}";
|
||||||
|
String tail = "{\"type\":\"TEXT_MESSAGE_START\",\"messageId\":\"m1\"}";
|
||||||
|
journal.append("run-1", first);
|
||||||
|
|
||||||
|
Assert.assertThrows(IllegalStateException.class, () -> journal.flush("run-1"));
|
||||||
|
journal.append("run-1", tail);
|
||||||
|
journal.flushPending();
|
||||||
|
|
||||||
|
ArgumentCaptor<String> batchIds = ArgumentCaptor.forClass(String.class);
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
ArgumentCaptor<List<String>> batches = ArgumentCaptor.forClass(List.class);
|
||||||
|
verify(store, times(3)).append(eq("run-1"), batchIds.capture(), batches.capture());
|
||||||
|
Assert.assertEquals(batchIds.getAllValues().get(0), batchIds.getAllValues().get(1));
|
||||||
|
Assert.assertNotEquals(batchIds.getAllValues().get(1), batchIds.getAllValues().get(2));
|
||||||
|
Assert.assertEquals(List.of(first), batches.getAllValues().get(0));
|
||||||
|
Assert.assertEquals(List.of(first), batches.getAllValues().get(1));
|
||||||
|
Assert.assertEquals(List.of(tail), batches.getAllValues().get(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证终态完成会等待在途后台刷盘,并在其失败后接管重试全部剩余批次。
|
||||||
|
*
|
||||||
|
* @throws Exception 并发任务等待失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void completeShouldWaitForBackgroundFlushAndPersistAllTailEvents() throws Exception {
|
||||||
|
AgentAguiRunStore store = mock(AgentAguiRunStore.class);
|
||||||
|
AgentAguiRunJournal journal = new AgentAguiRunJournal(store, new ObjectMapper());
|
||||||
|
CountDownLatch firstAppendStarted = new CountDownLatch(1);
|
||||||
|
CountDownLatch releaseFirstAppend = new CountDownLatch(1);
|
||||||
|
CountDownLatch completeStarted = new CountDownLatch(1);
|
||||||
|
AtomicInteger appendCalls = new AtomicInteger();
|
||||||
|
doAnswer(invocation -> {
|
||||||
|
int call = appendCalls.incrementAndGet();
|
||||||
|
if (call == 1) {
|
||||||
|
firstAppendStarted.countDown();
|
||||||
|
Assert.assertTrue(releaseFirstAppend.await(5, TimeUnit.SECONDS));
|
||||||
|
throw new IllegalStateException("first append failed");
|
||||||
|
}
|
||||||
|
return (long) call;
|
||||||
|
}).when(store).append(eq("run-1"), anyString(), org.mockito.ArgumentMatchers.anyList());
|
||||||
|
String started = "{\"type\":\"RUN_STARTED\",\"runId\":\"run-1\"}";
|
||||||
|
String finished = "{\"type\":\"RUN_FINISHED\",\"runId\":\"run-1\",\"threadId\":\"1\"}";
|
||||||
|
journal.append("run-1", started);
|
||||||
|
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||||
|
|
||||||
|
try {
|
||||||
|
Future<?> background = executor.submit(journal::flushPending);
|
||||||
|
Assert.assertTrue(firstAppendStarted.await(5, TimeUnit.SECONDS));
|
||||||
|
journal.append("run-1", finished);
|
||||||
|
Future<?> completion = executor.submit(() -> {
|
||||||
|
completeStarted.countDown();
|
||||||
|
journal.complete("run-1");
|
||||||
|
});
|
||||||
|
Assert.assertTrue(completeStarted.await(5, TimeUnit.SECONDS));
|
||||||
|
Assert.assertFalse(completion.isDone());
|
||||||
|
|
||||||
|
releaseFirstAppend.countDown();
|
||||||
|
background.get(5, TimeUnit.SECONDS);
|
||||||
|
completion.get(5, TimeUnit.SECONDS);
|
||||||
|
} finally {
|
||||||
|
releaseFirstAppend.countDown();
|
||||||
|
executor.shutdownNow();
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
ArgumentCaptor<List<String>> batches = ArgumentCaptor.forClass(List.class);
|
||||||
|
ArgumentCaptor<String> batchIds = ArgumentCaptor.forClass(String.class);
|
||||||
|
verify(store, times(3)).append(eq("run-1"), batchIds.capture(), batches.capture());
|
||||||
|
Assert.assertEquals(batchIds.getAllValues().get(0), batchIds.getAllValues().get(1));
|
||||||
|
Assert.assertEquals(List.of(started), batches.getAllValues().get(1));
|
||||||
|
Assert.assertEquals(List.of(finished), batches.getAllValues().get(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证容量故障会丢弃未确认普通事件并以独立小批次写入失败终态。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldReplacePendingEventsWithJournalFailureTerminal() {
|
||||||
|
AgentAguiRunStore store = mock(AgentAguiRunStore.class);
|
||||||
|
AgentAguiRunJournal journal = new AgentAguiRunJournal(store, new ObjectMapper());
|
||||||
|
journal.append("run-1", "{\"type\":\"RUN_STARTED\"}");
|
||||||
|
|
||||||
|
journal.fail("thread-1", "run-1", "AGENT_RUN_JOURNAL_LIMIT", "输出过长");
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
ArgumentCaptor<List<String>> events = ArgumentCaptor.forClass(List.class);
|
||||||
|
verify(store).append(eq("run-1"), anyString(), events.capture());
|
||||||
|
Assert.assertEquals(1, events.getValue().size());
|
||||||
|
Assert.assertTrue(events.getValue().get(0).contains("AGENT_RUN_JOURNAL_LIMIT"));
|
||||||
|
Assert.assertFalse(events.getValue().get(0).contains("RUN_STARTED"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证异常大的单事件在进入共享内存缓冲前即被拒绝。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldRejectOversizedSingleEvent() {
|
||||||
|
AgentAguiRunJournal journal = new AgentAguiRunJournal(
|
||||||
|
mock(AgentAguiRunStore.class), new ObjectMapper());
|
||||||
|
String oversized = "x".repeat(512 * 1024 + 1);
|
||||||
|
|
||||||
|
Assert.assertThrows(IllegalStateException.class,
|
||||||
|
() -> journal.append("run-1", oversized));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package tech.easyflow.agent.runtime.agui;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.springframework.data.redis.core.HashOperations;
|
||||||
|
import org.springframework.data.redis.core.ListOperations;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.data.redis.core.ValueOperations;
|
||||||
|
import org.springframework.data.redis.core.script.RedisScript;
|
||||||
|
import tech.easyflow.agent.config.AgentRuntimeProperties;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyList;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AG-UI Redis 运行存储测试。
|
||||||
|
*/
|
||||||
|
public class AgentAguiRunStoreTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 runId 原子保留、24 小时 TTL 与完成终态写入。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldReserveRunAndPersistTerminalStatusForTwentyFourHours() {
|
||||||
|
RedisMocks redis = redisMocks();
|
||||||
|
AgentRuntimeProperties properties = new AgentRuntimeProperties();
|
||||||
|
properties.setAguiRunRetention(Duration.ofHours(24));
|
||||||
|
AgentAguiRunStore store = new AgentAguiRunStore(
|
||||||
|
redis.template, new ObjectMapper(), properties);
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
store.create(new AgentAguiRunDescriptor(
|
||||||
|
"run-1", "request-1", "thread-1", "agent-1", "session-1",
|
||||||
|
"user-1", "tenant-1", true, AgentAguiRunStatus.RUNNING, 0L, now, now));
|
||||||
|
|
||||||
|
when(redis.template.execute(any(RedisScript.class), anyList(), any(Object[].class)))
|
||||||
|
.thenReturn(2L);
|
||||||
|
store.append("run-1", "batch-1", List.of(
|
||||||
|
"{\"type\":\"RUN_STARTED\",\"runId\":\"run-1\",\"threadId\":\"thread-1\"}",
|
||||||
|
"{\"type\":\"RUN_FINISHED\",\"runId\":\"run-1\",\"threadId\":\"thread-1\"}"));
|
||||||
|
|
||||||
|
verify(redis.values).setIfAbsent(
|
||||||
|
eq("easyflow:agent:agui:run:{run-1}:reserved"), eq("user-1"), eq(Duration.ofHours(24)));
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
ArgumentCaptor<Map<String, String>> metadata = ArgumentCaptor.forClass(Map.class);
|
||||||
|
verify(redis.hashes).putAll(eq("easyflow:agent:agui:run:{run-1}:meta"), metadata.capture());
|
||||||
|
Assert.assertEquals("0", metadata.getValue().get("lastCursor"));
|
||||||
|
Assert.assertEquals("false", metadata.getValue().get("cancelRequested"));
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
ArgumentCaptor<RedisScript<Long>> script = ArgumentCaptor.forClass(RedisScript.class);
|
||||||
|
ArgumentCaptor<Object[]> arguments = ArgumentCaptor.forClass(Object[].class);
|
||||||
|
verify(redis.template).execute(script.capture(), anyList(), arguments.capture());
|
||||||
|
Assert.assertTrue(script.getValue().getScriptAsString().contains("lastBatchId"));
|
||||||
|
Assert.assertEquals("batch-1", arguments.getValue()[0]);
|
||||||
|
Assert.assertEquals(AgentAguiRunStatus.COMPLETED.name(), arguments.getValue()[3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证重复 runId 在模型启动前即被拒绝。
|
||||||
|
*/
|
||||||
|
@Test(expected = BusinessException.class)
|
||||||
|
public void shouldRejectDuplicateRunId() {
|
||||||
|
RedisMocks redis = redisMocks();
|
||||||
|
when(redis.values.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(false);
|
||||||
|
AgentAguiRunStore store = new AgentAguiRunStore(
|
||||||
|
redis.template, new ObjectMapper(), new AgentRuntimeProperties());
|
||||||
|
store.create(new AgentAguiRunDescriptor(
|
||||||
|
"run-1", "request-1", "thread-1", "agent-1", "session-1",
|
||||||
|
"user-1", "tenant-1", false, AgentAguiRunStatus.RUNNING, 0L, 1L, 1L));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 owner 丢失通过同一原子批次追加明确失败终态。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldPersistOwnerLostAsReplayableTerminalEvent() {
|
||||||
|
RedisMocks redis = redisMocks();
|
||||||
|
when(redis.template.execute(any(RedisScript.class), anyList(), any(Object[].class)))
|
||||||
|
.thenReturn(1L);
|
||||||
|
AgentAguiRunStore store = new AgentAguiRunStore(
|
||||||
|
redis.template, new ObjectMapper(), new AgentRuntimeProperties());
|
||||||
|
AgentAguiRunDescriptor descriptor = new AgentAguiRunDescriptor(
|
||||||
|
"run-1", "request-1", "thread-1", "agent-1", "session-1",
|
||||||
|
"user-1", "tenant-1", false, AgentAguiRunStatus.RUNNING, 0L, 1L, 1L);
|
||||||
|
ArgumentCaptor<Object[]> arguments = ArgumentCaptor.forClass(Object[].class);
|
||||||
|
|
||||||
|
store.failOwnerLost(descriptor);
|
||||||
|
|
||||||
|
verify(redis.template).execute(any(RedisScript.class), anyList(), arguments.capture());
|
||||||
|
Assert.assertEquals(AgentAguiRunStatus.FAILED.name(), arguments.getValue()[3]);
|
||||||
|
Assert.assertTrue(arguments.getValue()[8].toString().contains("AGENT_RUN_OWNER_LOST"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private RedisMocks redisMocks() {
|
||||||
|
StringRedisTemplate template = mock(StringRedisTemplate.class);
|
||||||
|
ValueOperations<String, String> values = mock(ValueOperations.class);
|
||||||
|
HashOperations<String, Object, Object> hashes = mock(HashOperations.class);
|
||||||
|
ListOperations<String, String> lists = mock(ListOperations.class);
|
||||||
|
when(template.opsForValue()).thenReturn(values);
|
||||||
|
when(template.opsForHash()).thenReturn(hashes);
|
||||||
|
when(template.opsForList()).thenReturn(lists);
|
||||||
|
when(values.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(true);
|
||||||
|
return new RedisMocks(template, values, hashes, lists);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record RedisMocks(
|
||||||
|
StringRedisTemplate template,
|
||||||
|
ValueOperations<String, String> values,
|
||||||
|
HashOperations<String, Object, Object> hashes,
|
||||||
|
ListOperations<String, String> lists) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package tech.easyflow.agent.runtime.agui;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AG-UI 重连订阅共享调度测试。
|
||||||
|
*/
|
||||||
|
public class AgentAguiRunSubscriptionServiceTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证同一 run 的不同游标独立读取窗口,尾部实时订阅不受历史回放阻塞。
|
||||||
|
*
|
||||||
|
* @throws Exception SSE mock 发送异常
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldDispatchDistinctCursorWindowsIndependently() throws Exception {
|
||||||
|
AgentAguiRunStore store = mock(AgentAguiRunStore.class);
|
||||||
|
AgentAguiRunSubscriptionService service = new AgentAguiRunSubscriptionService(store);
|
||||||
|
AgentAguiRunDescriptor descriptor = new AgentAguiRunDescriptor(
|
||||||
|
"run-1", "request-1", "thread-1", "agent-1", "session-1",
|
||||||
|
"user-1", "tenant-1", false, AgentAguiRunStatus.RUNNING,
|
||||||
|
1000L, 1L, 2L);
|
||||||
|
SseEmitter fromStart = mock(SseEmitter.class);
|
||||||
|
SseEmitter fromTail = mock(SseEmitter.class);
|
||||||
|
service.subscribe(descriptor, 0L, fromStart);
|
||||||
|
service.subscribe(descriptor, 999L, fromTail);
|
||||||
|
when(store.readAfter("run-1", 0L)).thenReturn(java.util.Collections.nCopies(
|
||||||
|
256, "{\"type\":\"TEXT_MESSAGE_CONTENT\"}"));
|
||||||
|
when(store.readAfter("run-1", 999L)).thenReturn(List.of(
|
||||||
|
"{\"type\":\"TEXT_MESSAGE_CONTENT\",\"delta\":\"tail\"}"));
|
||||||
|
when(store.find("run-1")).thenReturn(descriptor);
|
||||||
|
|
||||||
|
service.dispatch();
|
||||||
|
|
||||||
|
verify(store).readAfter("run-1", 0L);
|
||||||
|
verify(store).readAfter("run-1", 999L);
|
||||||
|
verify(store).find("run-1");
|
||||||
|
verify(fromStart, times(256)).send(any(SseEmitter.SseEventBuilder.class));
|
||||||
|
verify(fromTail).send(any(SseEmitter.SseEventBuilder.class));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package tech.easyflow.agent.runtime.agui;
|
||||||
|
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可重连 AG-UI SSE 发射器测试。
|
||||||
|
*/
|
||||||
|
public class ResumableAguiSseEmitterTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证协议事件先进入日志,且逻辑关闭只发生在运行终态。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldKeepLogicalRunOpenUntilExplicitCompletion() {
|
||||||
|
AgentAguiRunJournal journal = mock(AgentAguiRunJournal.class);
|
||||||
|
SseEmitter subscriber = new SseEmitter();
|
||||||
|
ResumableAguiSseEmitter emitter = new ResumableAguiSseEmitter(
|
||||||
|
"thread-1", "run-1", journal, subscriber);
|
||||||
|
|
||||||
|
Assert.assertSame(subscriber, emitter.getEmitter());
|
||||||
|
Assert.assertTrue(emitter.sendData("{\"type\":\"RUN_STARTED\"}"));
|
||||||
|
Assert.assertFalse(emitter.isClosed());
|
||||||
|
verify(journal).append("run-1", "{\"type\":\"RUN_STARTED\"}");
|
||||||
|
|
||||||
|
emitter.complete();
|
||||||
|
|
||||||
|
Assert.assertTrue(emitter.isClosed());
|
||||||
|
Assert.assertTrue(emitter.isJournalCompletionSuccessful());
|
||||||
|
verify(journal).complete("run-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证日志拒绝普通事件时立即写入可重放故障终态并关闭逻辑输出。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldFailRunWhenJournalRejectsEvent() {
|
||||||
|
AgentAguiRunJournal journal = mock(AgentAguiRunJournal.class);
|
||||||
|
doThrow(new IllegalStateException("超过 16 MiB 安全上限"))
|
||||||
|
.when(journal).append("run-1", "{\"type\":\"RUN_STARTED\"}");
|
||||||
|
ResumableAguiSseEmitter emitter = new ResumableAguiSseEmitter(
|
||||||
|
"thread-1", "run-1", journal, new SseEmitter());
|
||||||
|
|
||||||
|
Assert.assertFalse(emitter.sendData("{\"type\":\"RUN_STARTED\"}"));
|
||||||
|
|
||||||
|
Assert.assertTrue(emitter.isClosed());
|
||||||
|
Assert.assertFalse(emitter.isJournalCompletionSuccessful());
|
||||||
|
verify(journal).fail(
|
||||||
|
"thread-1", "run-1", "AGENT_RUN_JOURNAL_LIMIT",
|
||||||
|
"Agent 输出超过可恢复日志容量,运行已停止");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证终态刷盘失败会被调用方观察到,避免上层错误记录为完成态。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldExposeTerminalJournalFailure() {
|
||||||
|
AgentAguiRunJournal journal = mock(AgentAguiRunJournal.class);
|
||||||
|
doThrow(new IllegalStateException("Redis unavailable"))
|
||||||
|
.when(journal).complete("run-1");
|
||||||
|
ResumableAguiSseEmitter emitter = new ResumableAguiSseEmitter(
|
||||||
|
"thread-1", "run-1", journal, new SseEmitter());
|
||||||
|
|
||||||
|
emitter.complete();
|
||||||
|
|
||||||
|
Assert.assertTrue(emitter.isClosed());
|
||||||
|
Assert.assertFalse(emitter.isJournalCompletionSuccessful());
|
||||||
|
verify(journal).fail(
|
||||||
|
"thread-1", "run-1", "AGENT_RUN_JOURNAL_UNAVAILABLE",
|
||||||
|
"Agent 输出日志暂时不可用,运行已停止");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -146,7 +146,9 @@ public class AbstractAgentAsyncSubToolsTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected AgentToolExecutionResult executeBusiness(Map<String, Object> arguments) {
|
protected AgentToolExecutionResult executeBusiness(
|
||||||
|
Map<String, Object> arguments,
|
||||||
|
AgentToolContext context) {
|
||||||
return new AgentToolExecutionResult(Map.of("echo", arguments.get("keyword")), "business-run-1");
|
return new AgentToolExecutionResult(Map.of("echo", arguments.get("keyword")), "business-run-1");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,7 +153,10 @@ public class WorkflowPluginAsyncSubToolsTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public AgentToolExecutionResult execute(Workflow workflow, Map<String, Object> arguments) {
|
public AgentToolExecutionResult execute(
|
||||||
|
Workflow workflow,
|
||||||
|
Map<String, Object> arguments,
|
||||||
|
AgentToolContext context) {
|
||||||
return new AgentToolExecutionResult(businessResult, "workflow-run-1");
|
return new AgentToolExecutionResult(businessResult, "workflow-run-1");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import org.junit.Assert;
|
|||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
import tech.easyflow.agent.runtime.agui.AgentAguiRunJournal;
|
||||||
|
import tech.easyflow.agent.runtime.agui.ResumableAguiSseEmitter;
|
||||||
import tech.easyflow.core.chat.protocol.ChatDomain;
|
import tech.easyflow.core.chat.protocol.ChatDomain;
|
||||||
import tech.easyflow.core.chat.protocol.ChatType;
|
import tech.easyflow.core.chat.protocol.ChatType;
|
||||||
import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter;
|
import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter;
|
||||||
@@ -51,6 +53,26 @@ public class AguiAgentRunOutputTest {
|
|||||||
verify(emitter).complete();
|
verify(emitter).complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证可恢复日志终态刷盘失败会沿输出边界返回失败。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldReportResumableJournalCompletionFailure() {
|
||||||
|
AgentAguiRunJournal journal = mock(AgentAguiRunJournal.class);
|
||||||
|
doThrow(new IllegalStateException("Redis unavailable"))
|
||||||
|
.when(journal).complete("run-1");
|
||||||
|
ResumableAguiSseEmitter emitter = new ResumableAguiSseEmitter(
|
||||||
|
"123", "run-1", journal, new SseEmitter());
|
||||||
|
AguiAgentRunOutput output = new AguiAgentRunOutput(
|
||||||
|
"123", "run-1", "user-message-1", emitter);
|
||||||
|
|
||||||
|
Assert.assertTrue(output.emitRuntimeEvent(
|
||||||
|
AgentRuntimeEvent.of(AgentRuntimeEventType.COMPLETED)));
|
||||||
|
|
||||||
|
Assert.assertFalse(output.finish("final"));
|
||||||
|
Assert.assertFalse(emitter.isJournalCompletionSuccessful());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证审批 Custom Event 不泄漏恢复令牌。
|
* 验证审批 Custom Event 不泄漏恢复令牌。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -145,7 +145,10 @@ public class AgentToolRuntimeCompilerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public AgentToolExecutionResult execute(Workflow workflow, Map<String, Object> arguments) {
|
public AgentToolExecutionResult execute(
|
||||||
|
Workflow workflow,
|
||||||
|
Map<String, Object> arguments,
|
||||||
|
AgentToolContext context) {
|
||||||
throw new IllegalStateException("jdbc:mysql://internal:3306 secret-token");
|
throw new IllegalStateException("jdbc:mysql://internal:3306 secret-token");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -262,7 +265,10 @@ public class AgentToolRuntimeCompilerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public AgentToolExecutionResult execute(Workflow workflow, Map<String, Object> arguments) {
|
public AgentToolExecutionResult execute(
|
||||||
|
Workflow workflow,
|
||||||
|
Map<String, Object> arguments,
|
||||||
|
AgentToolContext context) {
|
||||||
return new AgentToolExecutionResult(Map.of("ok", true), "wf-run-1");
|
return new AgentToolExecutionResult(Map.of("ok", true), "wf-run-1");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package tech.easyflow.agent.runtime.tool;
|
||||||
|
|
||||||
|
import com.easyagents.agent.runtime.AgentRuntimeContext;
|
||||||
|
import com.easyagents.agent.runtime.tool.AgentToolContext;
|
||||||
|
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.repository.FrozenWorkflowDefinitionRegistry;
|
||||||
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
|
import tech.easyflow.common.constant.Constants;
|
||||||
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.anyMap;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent Workflow 工具调用上下文测试。
|
||||||
|
*/
|
||||||
|
public class WorkflowToolExecutorTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public void shouldForwardAgentIdentityToFrozenWorkflowVariables() {
|
||||||
|
ChainExecutor chainExecutor = mock(ChainExecutor.class);
|
||||||
|
FrozenWorkflowDefinitionRegistry registry =
|
||||||
|
mock(FrozenWorkflowDefinitionRegistry.class);
|
||||||
|
Workflow workflow = new Workflow();
|
||||||
|
workflow.setId(BigInteger.valueOf(101));
|
||||||
|
workflow.setContent("{\"nodes\":[]}");
|
||||||
|
when(registry.register(workflow)).thenReturn("agent-frozen:101:hash");
|
||||||
|
when(chainExecutor.executeWithoutSuspension(anyString(), anyMap()))
|
||||||
|
.thenReturn(Map.of("ok", true));
|
||||||
|
WorkflowToolExecutor executor = new WorkflowToolExecutor(
|
||||||
|
chainExecutor, registry);
|
||||||
|
|
||||||
|
AgentRuntimeContext runtimeContext = new AgentRuntimeContext();
|
||||||
|
runtimeContext.setUserId("7");
|
||||||
|
runtimeContext.setTenantId("9");
|
||||||
|
runtimeContext.setUserName("测试用户");
|
||||||
|
AgentToolContext context = new AgentToolContext();
|
||||||
|
context.setRuntimeContext(runtimeContext);
|
||||||
|
Map<String, Object> arguments = new LinkedHashMap<>();
|
||||||
|
arguments.put("question", "问题");
|
||||||
|
LoginAccount forgedAccount = new LoginAccount();
|
||||||
|
forgedAccount.setId(BigInteger.valueOf(999));
|
||||||
|
forgedAccount.setTenantId(BigInteger.valueOf(999));
|
||||||
|
arguments.put(Constants.LOGIN_USER_KEY, forgedAccount);
|
||||||
|
|
||||||
|
executor.execute(workflow, arguments, context);
|
||||||
|
|
||||||
|
ArgumentCaptor<Map<String, Object>> variables =
|
||||||
|
ArgumentCaptor.forClass(Map.class);
|
||||||
|
verify(chainExecutor).executeWithoutSuspension(
|
||||||
|
org.mockito.ArgumentMatchers.eq("agent-frozen:101:hash"),
|
||||||
|
variables.capture());
|
||||||
|
LoginAccount account = (LoginAccount) variables.getValue()
|
||||||
|
.get(Constants.LOGIN_USER_KEY);
|
||||||
|
Assert.assertEquals(BigInteger.valueOf(7), account.getId());
|
||||||
|
Assert.assertEquals(BigInteger.valueOf(9), account.getTenantId());
|
||||||
|
Assert.assertEquals("测试用户", account.getLoginName());
|
||||||
|
Assert.assertSame(forgedAccount,
|
||||||
|
arguments.get(Constants.LOGIN_USER_KEY));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public void shouldDropReservedIdentityWithoutAgentContext() {
|
||||||
|
ChainExecutor chainExecutor = mock(ChainExecutor.class);
|
||||||
|
FrozenWorkflowDefinitionRegistry registry =
|
||||||
|
mock(FrozenWorkflowDefinitionRegistry.class);
|
||||||
|
Workflow workflow = new Workflow();
|
||||||
|
workflow.setId(BigInteger.valueOf(101));
|
||||||
|
workflow.setContent("{\"nodes\":[]}");
|
||||||
|
when(registry.register(workflow)).thenReturn("agent-frozen:101:hash");
|
||||||
|
when(chainExecutor.executeWithoutSuspension(anyString(), anyMap()))
|
||||||
|
.thenReturn(Map.of("ok", true));
|
||||||
|
WorkflowToolExecutor executor = new WorkflowToolExecutor(
|
||||||
|
chainExecutor, registry);
|
||||||
|
Map<String, Object> arguments = new LinkedHashMap<>();
|
||||||
|
arguments.put(Constants.LOGIN_USER_KEY, new LoginAccount());
|
||||||
|
|
||||||
|
executor.execute(workflow, arguments);
|
||||||
|
|
||||||
|
ArgumentCaptor<Map<String, Object>> variables =
|
||||||
|
ArgumentCaptor.forClass(Map.class);
|
||||||
|
verify(chainExecutor).executeWithoutSuspension(
|
||||||
|
anyString(), variables.capture());
|
||||||
|
Assert.assertFalse(variables.getValue()
|
||||||
|
.containsKey(Constants.LOGIN_USER_KEY));
|
||||||
|
Assert.assertTrue(arguments.containsKey(Constants.LOGIN_USER_KEY));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,22 @@ public class AgentSkillReferenceProviderTest {
|
|||||||
"智能体“线上引用智能体”"), references);
|
"智能体“线上引用智能体”"), references);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 没有 Agent 引用 Skill 时不应执行空主键集合查询。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldSkipEntityQueryWhenSkillHasNoReferences() {
|
||||||
|
AgentService agentService = Mockito.mock(AgentService.class);
|
||||||
|
AgentSkillBindingService bindingService = Mockito.mock(AgentSkillBindingService.class);
|
||||||
|
Mockito.when(bindingService.list(Mockito.any(QueryWrapper.class))).thenReturn(List.of());
|
||||||
|
Mockito.when(agentService.list(Mockito.any(QueryWrapper.class))).thenReturn(List.of());
|
||||||
|
AgentSkillReferenceProvider provider = new AgentSkillReferenceProvider(
|
||||||
|
agentService, bindingService);
|
||||||
|
|
||||||
|
Assert.assertTrue(provider.listReferences(BigInteger.TEN).isEmpty());
|
||||||
|
Mockito.verify(agentService, Mockito.never()).listByIds(Mockito.anyCollection());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建 Agent 摘要。
|
* 创建 Agent 摘要。
|
||||||
*
|
*
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user