Compare commits

...

7 Commits

Author SHA1 Message Date
2689adfa40 feat: 重构知识库文档导入任务化流程
- 新增上传建单、异步解析、分块处理与异步向量化闭环

- 收口分享页权限、完成态检索过滤与 SSE 局部状态刷新
2026-04-15 19:27:22 +08:00
a41b50959e feat: 完成S02桥接能力并接通M09工作流文档解析闭环
- 新增统一文档解析桥接子域,封装 easy-agents 文档解析门面

- 支持工作流开始节点文件上传与素材选择的单文件对象输入

- DocNode 改为文档解析节点,PDF 走统一解析,非 PDF 保持默认读取
2026-04-14 19:57:32 +08:00
855e93ecbf feat: 展示 AI 资源创建人信息
- 为 Bot、工作流、知识库、插件列表补充创建人名称回填

- 在卡片中展示创建者与创建时间

- 补充后端与前端对应测试
2026-04-13 14:58:14 +08:00
ae10383f17 fix: 补齐分享页分页请求客户端
- 让 PageData 支持注入自定义 requestClient

- 修复知识库分享页列表请求仍走默认 api 的问题
2026-04-13 14:55:11 +08:00
31a755a8bc feat: 收口知识库分享链路
- 新增 shareKey 单参数 URL 分享页与失效页

- 新增知识库分享后端鉴权、审计与迁移脚本

- 在访问令牌中增加知识库分享授权入口
2026-04-13 14:44:31 +08:00
8cfe5400fe feat: 优化工作流字段化参数配置
- 开始节点固定 user_input 并区分系统入口与自定义参数

- LLM 与知识库节点切换为字段值加上游引用配置

- 单节点调试改为字段预览与上游引用输入模式
2026-04-12 20:31:02 +08:00
47655a728b feat: 支持工作流插件复用与试运行
- 新增工作流插件类型、发布快照同步、实时可用性与下线影响检查

- 收口绑定候选、分类权限、间接环路校验与运行态优雅降级

- 补齐管理端工作流插件配置、详情与试运行界面及定向测试
2026-04-12 13:15:13 +08:00
219 changed files with 21208 additions and 2550 deletions

View File

@@ -36,5 +36,17 @@
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-common-captcha</artifactId>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.12.0</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -16,6 +16,7 @@ import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport;
import tech.easyflow.ai.easyagents.listener.PromptChoreChatStreamListener;
import tech.easyflow.ai.entity.*;
import tech.easyflow.ai.publish.BotPublishAppService;
@@ -73,6 +74,8 @@ public class BotController extends BaseCurdController<BotService, Bot> {
private BotPublishAppService botPublishAppService;
@Resource
private AiResourceApprovalStateService aiResourceApprovalStateService;
@Resource
private AiResourceCreatorNameSupport aiResourceCreatorNameSupport;
public BotController(BotService service, ModelService modelService, BotWorkflowService botWorkflowService,
BotDocumentCollectionService botDocumentCollectionService, BotMessageService botMessageService) {
@@ -305,6 +308,7 @@ public class BotController extends BaseCurdController<BotService, Bot> {
applyCategoryPermission(queryWrapper);
Page<Bot> result = super.queryPage(page, queryWrapper);
aiResourceApprovalStateService.fillBotApprovalState(result.getRecords());
aiResourceCreatorNameSupport.fillBotCreatorNames(result.getRecords());
return result;
}

View File

@@ -4,6 +4,7 @@ import org.springframework.web.bind.annotation.PostMapping;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.BotPlugin;
import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.enums.PluginType;
import tech.easyflow.common.annotation.UsePermission;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.tree.Tree;
@@ -58,7 +59,14 @@ public class BotPluginController extends BaseCurdController<BotPluginService, Bo
List<BotPlugin> visibleList = new ArrayList<>();
for (BotPlugin relation : botPlugins) {
Plugin plugin = relation.getAiPlugin();
if (plugin == null || pluginVisibilityService.canAccessPlugin(plugin.getCreatedBy(), plugin.getId())) {
if (plugin == null) {
visibleList.add(relation);
continue;
}
if (PluginType.isWorkflow(plugin.getType())) {
continue;
}
if (pluginVisibilityService.canAccessPlugin(plugin.getCreatedBy(), plugin.getId())) {
visibleList.add(relation);
}
}
@@ -73,7 +81,13 @@ public class BotPluginController extends BaseCurdController<BotPluginService, Bo
List<Plugin> plugins = botPluginService.getList(botId);
List<Plugin> visibleList = new ArrayList<>();
for (Plugin plugin : plugins) {
if (plugin == null || pluginVisibilityService.canAccessPlugin(plugin.getCreatedBy(), plugin.getId())) {
if (plugin == null) {
continue;
}
if (PluginType.isWorkflow(plugin.getType())) {
continue;
}
if (pluginVisibilityService.canAccessPlugin(plugin.getCreatedBy(), plugin.getId())) {
visibleList.add(plugin);
}
}
@@ -105,6 +119,9 @@ public class BotPluginController extends BaseCurdController<BotPluginService, Bo
if (pluginItem.getPluginId() != null) {
Plugin plugin = pluginService.getById(pluginItem.getPluginId());
if (plugin != null) {
if (PluginType.isWorkflow(plugin.getType())) {
throw new tech.easyflow.common.web.exceptions.BusinessException("当前版本暂不支持聊天助手绑定工作流插件");
}
pluginVisibilityService.assertPluginVisible(plugin.getCreatedBy(), plugin.getId(), "无权限绑定插件");
}
}

View File

@@ -13,6 +13,7 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport;
import tech.easyflow.ai.permission.KnowledgeVisibilityQueryHelper;
import tech.easyflow.ai.documentimport.DocumentImportDtos;
import tech.easyflow.ai.dto.KnowledgeSearchResultItem;
@@ -76,6 +77,8 @@ public class DocumentCollectionController extends BaseCurdController<DocumentCol
private KnowledgePublishAppService knowledgePublishAppService;
@Resource
private AiResourceApprovalStateService aiResourceApprovalStateService;
@Resource
private AiResourceCreatorNameSupport aiResourceCreatorNameSupport;
public DocumentCollectionController(DocumentCollectionService service, DocumentChunkService chunkService, ModelService llmService) {
super(service);
@@ -143,10 +146,14 @@ public class DocumentCollectionController extends BaseCurdController<DocumentCol
)
public Result<List<KnowledgeSearchResultItem>> search(@RequestParam BigInteger knowledgeId,
@RequestParam String keyword,
@RequestParam(required = false) String retrievalMode) {
@RequestParam(required = false) String retrievalMode,
@RequestParam(required = false) Integer docRecallMaxNum,
@RequestParam(required = false) Double simThreshold) {
KnowledgeRetrievalRequest request = new KnowledgeRetrievalRequest();
request.setKnowledgeId(knowledgeId);
request.setQuery(keyword);
request.setLimit(docRecallMaxNum);
request.setMinSimilarity(simThreshold);
request.setRetrievalMode(KnowledgeRetrievalModes.parse(retrievalMode));
request.setCallerType("API");
request.setCallerId(String.valueOf(knowledgeId));
@@ -310,6 +317,7 @@ public class DocumentCollectionController extends BaseCurdController<DocumentCol
applyPublishedOnlyFilter(queryWrapper);
Page<DocumentCollection> result = super.queryPage(page, queryWrapper);
aiResourceApprovalStateService.fillKnowledgeApprovalState(result.getRecords());
aiResourceCreatorNameSupport.fillDocumentCollectionCreatorNames(result.getRecords());
return result;
}

View File

@@ -8,9 +8,12 @@ import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.MediaType;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tech.easyflow.ai.documentimport.DocumentImportDtos;
import tech.easyflow.ai.documentimport.task.DocumentImportTaskStatusStreamService;
import tech.easyflow.ai.entity.Document;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.DocumentCollectionSplitParams;
@@ -77,6 +80,9 @@ public class DocumentController extends BaseCurdController<DocumentService, Docu
@Autowired
private ResourceAccessService resourceAccessService;
@Autowired
private DocumentImportTaskStatusStreamService documentImportTaskStatusStreamService;
@Value("${easyflow.storage.local.root:}")
private String fileUploadPath;
@@ -233,6 +239,79 @@ public class DocumentController extends BaseCurdController<DocumentService, Docu
return documentService.commitImport(request);
}
@PostMapping("import/task/create")
@SaCheckPermission("/api/v1/documentCollection/save")
public Result<DocumentImportDtos.TaskCreateResponse> createImportTask(@JsonBody DocumentImportDtos.TaskCreateRequest request) {
if (request.getKnowledgeId() == null) {
throw new BusinessException("知识库id不能为空");
}
getDocumentCollection(request.getKnowledgeId().toString(), ResourceAction.MANAGE, "无权限管理知识库");
return documentService.createImportTask(request);
}
@GetMapping("import/task/detail")
@SaCheckPermission("/api/v1/documentCollection/query")
public Result<DocumentImportDtos.TaskDetailResponse> getImportTaskDetail(@RequestParam BigInteger taskId) {
Result<DocumentImportDtos.TaskDetailResponse> result = documentService.getImportTaskDetail(taskId);
if (result.getData() != null && result.getData().getKnowledgeId() != null) {
getDocumentCollection(result.getData().getKnowledgeId().toString(), ResourceAction.READ, "无权限访问知识库");
}
return result;
}
/**
* 订阅知识库文档任务状态流。
*
* @param knowledgeId 知识库 ID
* @return SSE 推送连接
*/
@PostMapping(value = "import/task/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
@SaCheckPermission("/api/v1/documentCollection/query")
public SseEmitter streamImportTask(@JsonBody(value = "knowledgeId", required = true) BigInteger knowledgeId) {
getDocumentCollection(knowledgeId.toString(), ResourceAction.READ, "无权限访问知识库");
return documentImportTaskStatusStreamService.subscribe(knowledgeId);
}
@PostMapping("import/task/preview")
@SaCheckPermission("/api/v1/documentCollection/save")
public Result<DocumentImportDtos.PreviewResponse> previewImportTask(@JsonBody DocumentImportDtos.PreviewRequest request) {
if (request.getKnowledgeId() == null) {
throw new BusinessException("知识库id不能为空");
}
getDocumentCollection(request.getKnowledgeId().toString(), ResourceAction.MANAGE, "无权限管理知识库");
return documentService.previewImportTask(request);
}
@PostMapping("import/task/startIndex")
@SaCheckPermission("/api/v1/documentCollection/save")
public Result<DocumentImportDtos.TaskStartIndexResponse> startIndexTask(@JsonBody DocumentImportDtos.TaskStartIndexRequest request) {
if (request.getKnowledgeId() == null) {
throw new BusinessException("知识库id不能为空");
}
getDocumentCollection(request.getKnowledgeId().toString(), ResourceAction.MANAGE, "无权限管理知识库");
return documentService.startIndexTask(request);
}
@PostMapping("import/task/retryParse")
@SaCheckPermission("/api/v1/documentCollection/save")
public Result<DocumentImportDtos.TaskStartIndexResponse> retryParseTask(@JsonBody DocumentImportDtos.TaskRetryRequest request) {
if (request.getKnowledgeId() == null) {
throw new BusinessException("知识库id不能为空");
}
getDocumentCollection(request.getKnowledgeId().toString(), ResourceAction.MANAGE, "无权限管理知识库");
return documentService.retryParseTask(request);
}
@PostMapping("import/task/retryIndex")
@SaCheckPermission("/api/v1/documentCollection/save")
public Result<DocumentImportDtos.TaskStartIndexResponse> retryIndexTask(@JsonBody DocumentImportDtos.TaskRetryRequest request) {
if (request.getKnowledgeId() == null) {
throw new BusinessException("知识库id不能为空");
}
getDocumentCollection(request.getKnowledgeId().toString(), ResourceAction.MANAGE, "无权限管理知识库");
return documentService.retryIndexTask(request);
}
/**
* 更新 entity
*

View File

@@ -0,0 +1,240 @@
package tech.easyflow.admin.controller.ai;
import cn.dev33.satoken.annotation.SaCheckPermission;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.ai.dto.KnowledgeShareApiGrantRequest;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.enums.KnowledgeShareActionScope;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.ai.service.KnowledgeShareAuditService;
import tech.easyflow.ai.service.KnowledgeSharePermissionService;
import tech.easyflow.ai.service.KnowledgeShareService;
import tech.easyflow.ai.vo.KnowledgeShareUrlCreateResult;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.util.RequestUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.ResourceAccessService;
import javax.annotation.Resource;
import java.math.BigInteger;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Map;
/**
* 知识库分享管理接口。
*/
@RestController
@RequestMapping("/api/v1/knowledgeShare")
public class KnowledgeShareController {
@Resource
private KnowledgeShareService knowledgeShareService;
@Resource
private KnowledgeSharePermissionService knowledgeSharePermissionService;
@Resource
private KnowledgeShareAuditService knowledgeShareAuditService;
@Resource
private ResourceAccessService resourceAccessService;
@Resource
private DocumentCollectionService documentCollectionService;
/**
* 创建 URL 分享。
*
* @param request HTTP 请求
* @param knowledgeId 知识库 ID
* @return 创建结果
*/
@PostMapping("/url/create")
@SaCheckPermission("/api/v1/documentCollection/save")
public Result<KnowledgeShareUrlCreateResult> createUrlShare(HttpServletRequest request, @JsonBody("knowledgeId") BigInteger knowledgeId) {
assertManagePermission(knowledgeId);
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
KnowledgeShareUrlCreateResult result = knowledgeShareService.createUrlShare(
knowledgeId,
loginAccount.getTenantId(),
loginAccount.getDeptId(),
loginAccount.getId(),
buildShareBaseUrl(request),
KnowledgeShareActionScope.defaultUrlScopes()
);
knowledgeShareAuditService.log(
loginAccount.getId(),
"创建知识库 URL 分享",
"KNOWLEDGE_SHARE_CREATE",
request.getRequestURI(),
Map.of("knowledgeId", knowledgeId, "shareId", result.getId())
);
return Result.ok(result);
}
/**
* 为系统访问令牌配置知识库 API 分享授权。
*
* @param request 授权请求
* @return 结果
*/
@PostMapping("/api/grant")
@SaCheckPermission("/api/v1/documentCollection/save")
public Result<Void> grantApiShare(@JsonBody KnowledgeShareApiGrantRequest request) {
assertManagePermission(request.getKnowledgeId());
knowledgeSharePermissionService.grantApiShare(
request.getApiKeyId(),
request.getKnowledgeId(),
request.getActionScopes()
);
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
knowledgeShareAuditService.log(
loginAccount.getId(),
"配置知识库 API 分享授权",
"KNOWLEDGE_API_SHARE_GRANT",
"/api/v1/knowledgeShare/api/grant",
Map.of(
"knowledgeId", request.getKnowledgeId(),
"apiKeyId", request.getApiKeyId(),
"actionScopes", request.getActionScopes()
)
);
return Result.ok();
}
private void assertManagePermission(BigInteger knowledgeId) {
DocumentCollection knowledge = documentCollectionService.getById(knowledgeId);
if (knowledge == null) {
throw new BusinessException("知识库不存在");
}
resourceAccessService.assertAccess(
CategoryResourceType.KNOWLEDGE,
knowledge,
ResourceAction.MANAGE,
"无权限管理知识库"
);
}
private String buildShareBaseUrl(HttpServletRequest request) {
String referer = RequestUtil.getReferer(request);
String refererBaseUrl = extractFrontendBaseUrl(referer);
if (refererBaseUrl != null) {
return refererBaseUrl + "/share/knowledge";
}
String forwardedOrigin = buildForwardedOrigin(request);
if (forwardedOrigin != null) {
return forwardedOrigin + normalizeBasePath(firstHeaderValue(request.getHeader("X-Forwarded-Prefix"))) + "/share/knowledge";
}
String origin = normalizeOrigin(request.getHeader("Origin"));
if (origin != null) {
return origin + normalizeBasePath(request.getContextPath()) + "/share/knowledge";
}
StringBuilder builder = new StringBuilder();
builder.append(request.getScheme()).append("://").append(request.getServerName());
if (request.getServerPort() != 80 && request.getServerPort() != 443) {
builder.append(':').append(request.getServerPort());
}
builder.append(normalizeBasePath(request.getContextPath())).append("/share/knowledge");
return builder.toString();
}
private String extractFrontendBaseUrl(String url) {
if (url == null || url.isBlank()) {
return null;
}
try {
URI uri = new URI(url.trim());
if (uri.getScheme() == null || uri.getHost() == null) {
return null;
}
String origin = extractOrigin(url);
if (origin == null) {
return null;
}
return origin + inferFrontendBasePath(uri.getPath());
} catch (URISyntaxException e) {
return null;
}
}
private String inferFrontendBasePath(String path) {
if (path == null || path.isBlank() || "/".equals(path)) {
return "";
}
for (String marker : new String[]{"/ai/", "/auth/", "/share/"}) {
int markerIndex = path.indexOf(marker);
if (markerIndex > 0) {
return normalizeBasePath(path.substring(0, markerIndex));
}
if (markerIndex == 0) {
return "";
}
}
return "";
}
private String normalizeBasePath(String basePath) {
if (basePath == null || basePath.isBlank() || "/".equals(basePath.trim())) {
return "";
}
String normalized = basePath.trim();
if (!normalized.startsWith("/")) {
normalized = "/" + normalized;
}
while (normalized.endsWith("/") && normalized.length() > 1) {
normalized = normalized.substring(0, normalized.length() - 1);
}
return normalized;
}
private String buildForwardedOrigin(HttpServletRequest request) {
String proto = firstHeaderValue(request.getHeader("X-Forwarded-Proto"));
String host = firstHeaderValue(request.getHeader("X-Forwarded-Host"));
if (proto == null || host == null) {
return null;
}
return normalizeOrigin(proto + "://" + host);
}
private String extractOrigin(String url) {
if (url == null || url.isBlank()) {
return null;
}
try {
URI uri = new URI(url.trim());
if (uri.getScheme() == null || uri.getHost() == null) {
return null;
}
StringBuilder builder = new StringBuilder();
builder.append(uri.getScheme()).append("://").append(uri.getHost());
if (uri.getPort() != -1 && uri.getPort() != 80 && uri.getPort() != 443) {
builder.append(':').append(uri.getPort());
}
return builder.toString();
} catch (URISyntaxException e) {
return null;
}
}
private String normalizeOrigin(String origin) {
return extractOrigin(origin);
}
private String firstHeaderValue(String value) {
if (value == null || value.isBlank()) {
return null;
}
int commaIndex = value.indexOf(',');
String normalized = commaIndex >= 0 ? value.substring(0, commaIndex) : value;
normalized = normalized.trim();
return normalized.isEmpty() ? null : normalized;
}
}

View File

@@ -1,9 +1,11 @@
package tech.easyflow.admin.controller.ai;
import cn.dev33.satoken.annotation.SaCheckPermission;
import org.springframework.web.bind.annotation.*;
import tech.easyflow.ai.entity.PluginCategory;
import tech.easyflow.ai.entity.PluginCategoryMapping;
import tech.easyflow.ai.service.PluginCategoryMappingService;
import tech.easyflow.common.annotation.UsePermission;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.jsonbody.JsonBody;
@@ -21,6 +23,7 @@ import java.util.List;
*/
@RestController
@RequestMapping("/api/v1/pluginCategoryMapping")
@UsePermission(moduleName = "/api/v1/plugin")
public class PluginCategoryMappingController extends BaseCurdController<PluginCategoryMappingService, PluginCategoryMapping> {
public PluginCategoryMappingController(PluginCategoryMappingService service) {
super(service);
@@ -30,6 +33,7 @@ public class PluginCategoryMappingController extends BaseCurdController<PluginCa
private PluginCategoryMappingService relationService;
@PostMapping("/updateRelation")
@SaCheckPermission("/api/v1/plugin/save")
public Result<Boolean> updateRelation(
@JsonBody(value="pluginId") BigInteger pluginId,
@JsonBody(value="categoryIds") ArrayList<BigInteger> categoryIds

View File

@@ -6,21 +6,33 @@ import com.mybatisflex.core.query.QueryWrapper;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.ai.service.PluginVisibilityService;
import tech.easyflow.ai.permission.WorkflowVisibilityQueryHelper;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.ai.service.PluginService;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.ResourceAccessService;
import javax.annotation.Resource;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Set;
@@ -47,6 +59,16 @@ public class PluginController extends BaseCurdController<PluginService, Plugin>
private PluginVisibilityService pluginVisibilityService;
@Resource
private ModelService modelService;
@Resource
private WorkflowVisibilityQueryHelper workflowVisibilityQueryHelper;
@Resource
private WorkflowService workflowService;
@Resource
private ResourceAccessService resourceAccessService;
@Resource
private WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver;
@Resource
private AiResourceCreatorNameSupport aiResourceCreatorNameSupport;
@Override
protected Result<?> onSaveOrUpdateBefore(Plugin entity, boolean isSave) {
@@ -79,7 +101,8 @@ public class PluginController extends BaseCurdController<PluginService, Plugin>
public Result<List<Plugin>> getList(){
QueryWrapper queryWrapper = QueryWrapper.create().select();
applyCategoryPermission(queryWrapper);
return Result.ok(service.getMapper().selectListByQuery(queryWrapper));
List<Plugin> plugins = service.getMapper().selectListWithRelationsByQuery(queryWrapper);
return Result.ok(pluginService.preparePluginsForCurrentUser(plugins, true, false));
}
@GetMapping("/pageByCategory")
@@ -97,10 +120,31 @@ public class PluginController extends BaseCurdController<PluginService, Plugin>
queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy()));
return Result.ok(queryPage(new Page<>(pageNumber, pageSize), queryWrapper));
} else {
return pluginService.pageByCategory(pageNumber, pageSize, category);
Result<Page<Plugin>> result = pluginService.pageByCategory(pageNumber, pageSize, category);
if (result != null && result.getData() != null) {
aiResourceCreatorNameSupport.fillPluginCreatorNames(result.getData().getRecords());
}
return result;
}
}
@GetMapping("/workflowCandidates")
@SaCheckPermission("/api/v1/plugin/query")
public Result<List<Workflow>> workflowCandidates(String keyword) {
QueryWrapper queryWrapper = QueryWrapper.create();
workflowVisibilityQueryHelper.applyReadableAccess(queryWrapper);
queryWrapper.eq("publish_status", tech.easyflow.ai.enums.PublishStatus.PUBLISHED.getCode());
if (keyword != null && !keyword.isBlank()) {
queryWrapper.like("title", keyword.trim());
}
queryWrapper.orderBy("modified desc");
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
List<Workflow> workflows = workflowService.list(queryWrapper).stream()
.filter(workflow -> canBindWorkflowCandidate(workflow, loginAccount))
.collect(java.util.stream.Collectors.toCollection(ArrayList::new));
return Result.ok(workflows);
}
@GetMapping("/modelList")
@SaCheckPermission("/api/v1/plugin/query")
public Result<List<Model>> modelList(Model entity, Boolean asTree, String sortKey, String sortType) {
@@ -110,14 +154,25 @@ public class PluginController extends BaseCurdController<PluginService, Plugin>
@Override
protected Page<Plugin> queryPage(Page<Plugin> page, QueryWrapper queryWrapper) {
applyCategoryPermission(queryWrapper);
return service.getMapper().paginateWithRelations(page, queryWrapper);
List<Plugin> totalList = service.getMapper().selectListWithRelationsByQuery(queryWrapper);
boolean availableOnly = isAvailableOnly();
List<Plugin> prepared = pluginService.preparePluginsForCurrentUser(totalList, !availableOnly, availableOnly);
aiResourceCreatorNameSupport.fillPluginCreatorNames(prepared);
long total = prepared.size();
int fromIndex = Math.max(0, Math.toIntExact((page.getPageNumber() - 1) * page.getPageSize()));
if (fromIndex >= prepared.size()) {
return new Page<>(Collections.emptyList(), page.getPageNumber(), page.getPageSize(), total);
}
int toIndex = Math.min(prepared.size(), Math.toIntExact(fromIndex + page.getPageSize()));
return new Page<>(prepared.subList(fromIndex, toIndex), page.getPageNumber(), page.getPageSize(), total);
}
@Override
public Result<Plugin> detail(String id) {
Plugin plugin = service.getById(id);
Plugin plugin = service.getMapper().selectOneWithRelationsById(id);
if (plugin != null) {
pluginVisibilityService.assertPluginVisible(plugin.getCreatedBy(), plugin.getId(), "无权限访问插件");
pluginService.preparePluginForCurrentUser(plugin);
}
return Result.ok(plugin);
}
@@ -138,4 +193,30 @@ public class PluginController extends BaseCurdController<PluginService, Plugin>
}
queryWrapper.and(PLUGIN.CREATED_BY.eq(access.getAccountIdAsLong()).or(PLUGIN.ID.in(pluginIds)));
}
private boolean isAvailableOnly() {
HttpServletRequest request = currentHttpRequest();
return request != null && "true".equalsIgnoreCase(request.getParameter("availableOnly"));
}
private HttpServletRequest currentHttpRequest() {
org.springframework.web.context.request.ServletRequestAttributes attributes =
(org.springframework.web.context.request.ServletRequestAttributes)
org.springframework.web.context.request.RequestContextHolder.getRequestAttributes();
return attributes == null ? null : attributes.getRequest();
}
private boolean canBindWorkflowCandidate(Workflow workflow, LoginAccount loginAccount) {
if (workflow == null || loginAccount == null || loginAccount.getId() == null) {
return false;
}
if (workflow.getPublishedSnapshotJson() == null || workflow.getPublishedSnapshotJson().isEmpty()) {
return false;
}
Workflow publishedWorkflow = workflowService.toPublishedView(workflow);
if (!workflowPluginSnapshotResolver.isSupportedForWorkflowPlugin(publishedWorkflow)) {
return false;
}
return resourceAccessService.canAccess(loginAccount, CategoryResourceType.WORKFLOW, workflow, ResourceAction.USE);
}
}

View File

@@ -1,30 +1,49 @@
package tech.easyflow.admin.controller.ai;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.entity.BotPlugin;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.enums.PluginType;
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
import tech.easyflow.ai.service.BotPluginService;
import tech.easyflow.ai.service.PluginService;
import tech.easyflow.ai.service.PluginItemService;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.annotation.UsePermission;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody;
import javax.annotation.Resource;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 控制层。
@@ -45,6 +64,18 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
@Resource
private BotPluginService botPluginService;
@Resource
private PluginService pluginService;
@Resource
private WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver;
@Resource
private WorkflowService workflowService;
@Resource
private ChainExecutor chainExecutor;
@Resource
private TinyFlowService tinyFlowService;
@Resource
private WorkflowCheckService workflowCheckService;
@PostMapping("/tool/save")
@SaCheckPermission("/api/v1/plugin/save")
@@ -87,8 +118,18 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
if (record == null) {
return Result.ok(nodeData);
}
Plugin plugin = pluginService.getById(record.getPluginId());
nodeData.put("pluginId", record.getId().toString());
nodeData.put("pluginName", record.getName());
if (plugin != null) {
plugin = pluginService.preparePluginForCurrentUser(plugin);
nodeData.put("pluginType", plugin.getType());
nodeData.put("workflowId", plugin.getWorkflowId());
nodeData.put("workflowTitle", plugin.getWorkflowTitle());
nodeData.put("available", plugin.getAvailable());
nodeData.put("reasonCode", plugin.getReasonCode());
nodeData.put("reasonMessage", plugin.getReasonMessage());
}
JSONArray parameters = new JSONArray();
JSONArray outputDefs = new JSONArray();
@@ -104,6 +145,7 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
handleArray(array);
outputDefs = array;
}
nodeData.put("schemaHash", resolveSchemaHash(record, plugin));
nodeData.put("parameters", parameters);
nodeData.put("outputDefs", outputDefs);
return Result.ok(nodeData);
@@ -119,6 +161,71 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
return pluginItemService.pluginToolTest(inputData, pluginToolId);
}
/**
* 工作流插件异步试运行。
*
* @param inputData 输入参数 JSON
* @param pluginToolId 插件工具 ID
* @return 执行 ID
*/
@PostMapping("/testAsync")
@SaCheckPermission("/api/v1/plugin/query")
public Result<String> pluginToolTestAsync(@JsonBody(value = "inputData", required = true) String inputData,
@JsonBody(value = "pluginToolId", required = true) BigInteger pluginToolId) {
PluginItem pluginItem = pluginItemService.getById(pluginToolId);
Plugin plugin = requireWorkflowPlugin(pluginItem);
Plugin preparedPlugin = pluginService.preparePluginForCurrentUser(plugin);
if (Boolean.FALSE.equals(preparedPlugin.getAvailable())) {
throw new BusinessException(preparedPlugin.getReasonMessage());
}
Workflow workflow = workflowService.getPublishedById(preparedPlugin.getWorkflowId());
if (workflow == null) {
throw new BusinessException("未找到已发布工作流");
}
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
Map<String, Object> variables = JSON.parseObject(inputData, Map.class);
if (variables == null) {
variables = new HashMap<>();
}
if (StpUtil.isLogin()) {
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
}
String executeId = chainExecutor.executeAsync(
PublishedWorkflowDefinitionIds.published(String.valueOf(workflow.getId())),
variables
);
return Result.ok(executeId);
}
/**
* 获取工作流插件试运行状态。
*
* @param executeId 执行 ID
* @param nodes 节点列表
* @return 链路状态
*/
@PostMapping("/testChainStatus")
@SaCheckPermission("/api/v1/plugin/query")
public Result<ChainInfo> pluginToolTestChainStatus(@JsonBody(value = "executeId", required = true) String executeId,
@JsonBody("nodes") List<NodeInfo> nodes) {
return Result.ok(tinyFlowService.getChainStatus(executeId, nodes));
}
/**
* 恢复工作流插件试运行。
*
* @param executeId 执行 ID
* @param confirmParams 恢复参数
* @return 空结果
*/
@PostMapping("/testResume")
@SaCheckPermission("/api/v1/plugin/query")
public Result<Void> pluginToolTestResume(@JsonBody(value = "executeId", required = true) String executeId,
@JsonBody("confirmParams") Map<String, Object> confirmParams) {
chainExecutor.resumeAsync(executeId, confirmParams);
return Result.ok();
}
private void handleArray(JSONArray array) {
for (Object o : array) {
JSONObject obj = (JSONObject) o;
@@ -134,6 +241,40 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
}
}
private String resolveSchemaHash(PluginItem record, Plugin plugin) {
if (record == null) {
return null;
}
if (StrUtil.isNotBlank(record.getSchemaHash())) {
return record.getSchemaHash();
}
if (plugin == null || !PluginType.isWorkflow(plugin.getType()) || plugin.getWorkflowId() == null) {
return null;
}
tech.easyflow.ai.entity.Workflow workflow = workflowService.getPublishedById(plugin.getWorkflowId());
if (workflow == null) {
return null;
}
return workflowPluginSnapshotResolver.resolveSchemaHash(workflow);
}
private Plugin requireWorkflowPlugin(PluginItem pluginItem) {
if (pluginItem == null) {
throw new BusinessException("插件工具不存在");
}
Plugin plugin = pluginService.getById(pluginItem.getPluginId());
if (plugin == null) {
throw new BusinessException("插件不存在");
}
if (!PluginType.isWorkflow(plugin.getType())) {
throw new BusinessException("当前工具不是工作流插件");
}
if (plugin.getWorkflowId() == null) {
throw new BusinessException("插件未绑定工作流");
}
return plugin;
}
@Override
protected Result<?> onRemoveBefore(Collection<Serializable> ids) {
@@ -144,6 +285,15 @@ public class PluginItemController extends BaseCurdController<PluginItemService,
if (exists){
return Result.fail(1, "此工具还关联着bot请先取消关联");
}
if (ids.size() == 1) {
PluginItem pluginItem = pluginItemService.getById(ids.iterator().next());
if (pluginItem != null) {
Plugin plugin = pluginService.getById(pluginItem.getPluginId());
if (plugin != null && PluginType.isWorkflow(plugin.getType())) {
return Result.fail(1, "工作流插件工具由系统自动维护,不支持删除");
}
}
}
return null;
}

View File

@@ -0,0 +1,961 @@
package tech.easyflow.admin.controller.ai;
import cn.hutool.core.io.IoUtil;
import com.easyagents.core.model.embedding.EmbeddingModel;
import com.easyagents.core.store.DocumentStore;
import com.easyagents.core.store.StoreOptions;
import com.easyagents.core.store.StoreResult;
import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryWrapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.MediaType;
import org.springframework.util.StringUtils;
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.servlet.mvc.method.annotation.SseEmitter;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.ai.documentimport.DocumentImportDtos;
import tech.easyflow.ai.documentimport.task.DocumentImportTaskStatusStreamService;
import tech.easyflow.ai.dto.KnowledgeShareLimitedConfigRequest;
import tech.easyflow.ai.dto.KnowledgeSearchResultItem;
import tech.easyflow.ai.entity.Document;
import tech.easyflow.ai.entity.DocumentChunk;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.DocumentCollectionSplitParams;
import tech.easyflow.ai.entity.FaqCategory;
import tech.easyflow.ai.entity.FaqItem;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.enums.KnowledgeShareActionScope;
import tech.easyflow.ai.rag.KnowledgeRetrievalModes;
import tech.easyflow.ai.rag.KnowledgeRetrievalRequest;
import tech.easyflow.ai.service.DocumentChunkService;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.ai.service.DocumentService;
import tech.easyflow.ai.service.FaqCategoryService;
import tech.easyflow.ai.service.FaqItemService;
import tech.easyflow.ai.service.KnowledgeEmbeddingService;
import tech.easyflow.ai.service.KnowledgeShareAuditService;
import tech.easyflow.ai.service.KnowledgeShareService;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.ai.vo.FaqImportResultVo;
import tech.easyflow.ai.vo.KnowledgeShareAuthContext;
import tech.easyflow.ai.vo.KnowledgeShareViewDetail;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.vo.UploadResVo;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody;
import javax.annotation.Resource;
import java.io.InputStream;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 内部员工登录态下的知识库分享访问接口。
*/
@RestController
@RequestMapping("/api/v1/share/knowledge")
public class ShareKnowledgeController {
private static final long MAX_IMAGE_SIZE_BYTES = 5L * 1024L * 1024L;
private static final Set<String> ALLOWED_IMAGE_TYPES = new HashSet<>(Arrays.asList(
"image/jpeg",
"image/png",
"image/webp",
"image/gif"
));
@Resource
private KnowledgeShareService knowledgeShareService;
@Resource
private KnowledgeShareAuditService knowledgeShareAuditService;
@Resource
private DocumentCollectionService documentCollectionService;
@Resource
private DocumentService documentService;
@Resource
private DocumentChunkService documentChunkService;
@Resource
private ModelService modelService;
@Resource
private FaqItemService faqItemService;
@Resource
private FaqCategoryService faqCategoryService;
@Resource
private KnowledgeEmbeddingService knowledgeEmbeddingService;
@Resource(name = "default")
private FileStorageService fileStorageService;
@Resource
private DocumentImportTaskStatusStreamService documentImportTaskStatusStreamService;
/**
* 获取知识库详情。
*
* @param shareKey 分享访问密钥
* @return 知识库详情
*/
@GetMapping("/documentCollection/detail")
public Result<KnowledgeShareViewDetail> detail(@RequestParam String shareKey) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
null,
KnowledgeShareActionScope.VIEW.name()
);
audit(context, "访问知识库分享页", "KNOWLEDGE_SHARE_URL_ACCESS", false, auditDetail("knowledgeId", context.getKnowledge().getId()));
KnowledgeShareViewDetail detail = new KnowledgeShareViewDetail();
detail.setKnowledge(context.getKnowledge());
detail.setPermissionScopes(new java.util.ArrayList<String>(context.getShare().getPermissionScopeSet()));
return Result.ok(detail);
}
/**
* 获取模型列表。
*
* @param shareKey 分享访问密钥
* @param modelType 模型类型
* @return 模型列表
*/
@GetMapping("/documentCollection/modelList")
public Result<List<Model>> modelList(
@RequestParam String shareKey,
@RequestParam String modelType
) {
knowledgeShareService.assertUrlShareAccess(shareKey, null, KnowledgeShareActionScope.CONFIG_UPDATE.name());
Model entity = new Model();
entity.setModelType(modelType);
return Result.ok(modelService.listSelectableModels(entity, false, null, null));
}
/**
* 更新受限配置。
*
* @param request 更新请求
* @param shareKey 分享访问密钥
* @return 结果
*/
@PostMapping("/documentCollection/shareConfigUpdate")
public Result<Void> updateConfig(
@JsonBody KnowledgeShareLimitedConfigRequest request,
@RequestParam String shareKey
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
request == null ? null : request.getKnowledgeId(),
KnowledgeShareActionScope.CONFIG_UPDATE.name()
);
BigInteger knowledgeId = resolveKnowledgeId(context, request == null ? null : request.getKnowledgeId());
DocumentCollection current = context.getKnowledge();
DocumentCollection update = new DocumentCollection();
update.setId(current.getId());
Map<String, Object> options = current.getOptions() == null
? new HashMap<>()
: new HashMap<>(current.getOptions());
if (request.getVectorEmbedModelId() != null) {
update.setVectorEmbedModelId(request.getVectorEmbedModelId());
options.put(DocumentCollection.KEY_CAN_UPDATE_EMBEDDING_MODEL, true);
Model model = modelService.getModelInstance(request.getVectorEmbedModelId());
if (model != null) {
update.setDimensionOfVectorModel(Model.getEmbeddingDimension(model.toEmbeddingModel()));
}
}
if (request.getRerankModelId() != null || current.getRerankModelId() != null) {
update.setRerankModelId(request.getRerankModelId());
}
if (request.getRerankEnable() != null) {
options.put(DocumentCollection.KEY_RERANK_ENABLE, request.getRerankEnable());
}
if (request.getDocRecallMaxNum() != null) {
options.put(DocumentCollection.KEY_DOC_RECALL_MAX_NUM, request.getDocRecallMaxNum());
}
if (request.getSimThreshold() != null) {
options.put(DocumentCollection.KEY_SIMILARITY_THRESHOLD, BigDecimal.valueOf(request.getSimThreshold()));
}
update.setOptions(options);
documentCollectionService.updateById(update);
if (Boolean.TRUE.equals(request.getRebuildVectors())) {
knowledgeEmbeddingService.rebuildKnowledgeVectors(knowledgeId);
}
audit(context, "更新知识库分享受限配置", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", knowledgeId));
return Result.ok();
}
/**
* 检索知识库。
*
* @param shareKey 分享访问密钥
* @param keyword 检索关键词
* @param retrievalMode 检索模式
* @return 检索结果
*/
@GetMapping("/documentCollection/search")
public Result<List<KnowledgeSearchResultItem>> search(
@RequestParam String shareKey,
@RequestParam String keyword,
@RequestParam(required = false) String retrievalMode
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
null,
KnowledgeShareActionScope.SEARCH.name()
);
KnowledgeRetrievalRequest request = new KnowledgeRetrievalRequest();
request.setKnowledgeId(context.getKnowledge().getId());
request.setQuery(keyword);
request.setRetrievalMode(KnowledgeRetrievalModes.parse(retrievalMode));
request.setCallerType("SHARE_URL");
request.setCallerId(String.valueOf(context.getShare().getId()));
return Result.ok(toKnowledgeSearchResult(documentCollectionService.search(request)));
}
/**
* 文档分页。
*/
@GetMapping("/document/documentList")
public Result<Page<Document>> documentPage(
@RequestParam String shareKey,
@RequestParam(required = false) String title,
@RequestParam(defaultValue = "10") int pageSize,
@RequestParam(defaultValue = "1") int pageNumber
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
null,
KnowledgeShareActionScope.VIEW.name()
);
return Result.ok(documentService.getDocumentList(context.getKnowledge().getId().toString(), pageSize, pageNumber, title));
}
/**
* 订阅分享知识库的文档任务状态流。
*
* @param shareKey 分享访问密钥
* @param knowledgeId 知识库 ID
* @return SSE 推送连接
*/
@PostMapping(value = "/document/import/task/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamDocumentTask(
@RequestParam String shareKey,
@JsonBody(value = "knowledgeId", required = true) BigInteger knowledgeId
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
knowledgeId,
KnowledgeShareActionScope.VIEW.name()
);
return documentImportTaskStatusStreamService.subscribe(context.getKnowledge().getId());
}
/**
* 下载文档。
*/
@GetMapping("/document/download")
public void documentDownload(
@RequestParam String shareKey,
@RequestParam BigInteger documentId,
HttpServletResponse response
) throws Exception {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
null,
KnowledgeShareActionScope.VIEW.name()
);
Document document = documentService.getById(documentId);
if (document == null || document.getCollectionId() == null
|| document.getCollectionId().compareTo(context.getKnowledge().getId()) != 0) {
throw new BusinessException("文档不存在");
}
response.setContentType("application/octet-stream");
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
String fileName = URLEncoder.encode(document.getTitle(), StandardCharsets.UTF_8).replaceAll("\\+", "%20");
response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName);
try (InputStream inputStream = fileStorageService.readStream(document.getDocumentPath())) {
IoUtil.copy(inputStream, response.getOutputStream());
response.flushBuffer();
}
}
/**
* 删除文档。
*/
@PostMapping("/document/removeDoc")
public Result<?> removeDocument(
@RequestParam String shareKey,
@JsonBody("id") String id
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
null,
KnowledgeShareActionScope.CONTENT_DELETE.name()
);
Document document = documentService.getById(new BigInteger(id));
if (document == null || document.getCollectionId() == null
|| document.getCollectionId().compareTo(context.getKnowledge().getId()) != 0) {
throw new BusinessException("文档不存在");
}
boolean success = documentService.removeDoc(id);
audit(context, "删除分享文档", "KNOWLEDGE_SHARE_URL_WRITE", true,
auditDetail("knowledgeId", context.getKnowledge().getId(), "documentId", id));
return Result.ok(success);
}
/**
* 文档导入分析。
*/
@PostMapping("/document/import/analyze")
public Result<DocumentImportDtos.AnalyzeResponse> analyzeImport(
@RequestParam String shareKey,
@JsonBody DocumentImportDtos.AnalyzeRequest request
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
request == null ? null : request.getKnowledgeId(),
KnowledgeShareActionScope.CONTENT_CREATE.name()
);
BigInteger knowledgeId = resolveKnowledgeId(context, request == null ? null : request.getKnowledgeId());
request.setKnowledgeId(knowledgeId);
audit(context, "分析分享文档导入", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", knowledgeId));
return documentService.analyzeImport(request);
}
/**
* 文档导入预览。
*/
@PostMapping("/document/import/preview")
public Result<DocumentImportDtos.PreviewResponse> previewImport(
@RequestParam String shareKey,
@JsonBody DocumentImportDtos.PreviewRequest request
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
request == null ? null : request.getKnowledgeId(),
KnowledgeShareActionScope.CONTENT_CREATE.name()
);
BigInteger knowledgeId = resolveKnowledgeId(context, request == null ? null : request.getKnowledgeId());
request.setKnowledgeId(knowledgeId);
audit(context, "预览分享文档导入", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", knowledgeId));
return documentService.previewImport(request);
}
/**
* 提交文档导入。
*/
@PostMapping("/document/import/commit")
public Result<DocumentImportDtos.CommitResponse> commitImport(
@RequestParam String shareKey,
@JsonBody DocumentImportDtos.CommitRequest request
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
request == null ? null : request.getKnowledgeId(),
KnowledgeShareActionScope.CONTENT_CREATE.name()
);
BigInteger knowledgeId = resolveKnowledgeId(context, request == null ? null : request.getKnowledgeId());
request.setKnowledgeId(knowledgeId);
audit(context, "提交分享文档导入", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", knowledgeId));
return documentService.commitImport(request);
}
@PostMapping("/document/import/task/create")
public Result<DocumentImportDtos.TaskCreateResponse> createImportTask(
@RequestParam String shareKey,
@JsonBody DocumentImportDtos.TaskCreateRequest request
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
request == null ? null : request.getKnowledgeId(),
KnowledgeShareActionScope.CONTENT_CREATE.name()
);
BigInteger knowledgeId = resolveKnowledgeId(context, request == null ? null : request.getKnowledgeId());
request.setKnowledgeId(knowledgeId);
audit(context, "创建分享文档导入任务", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", knowledgeId));
return documentService.createImportTask(request);
}
@GetMapping("/document/import/task/detail")
public Result<DocumentImportDtos.TaskDetailResponse> getImportTaskDetail(
@RequestParam String shareKey,
@RequestParam BigInteger taskId
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
null,
KnowledgeShareActionScope.VIEW.name()
);
Result<DocumentImportDtos.TaskDetailResponse> result = documentService.getImportTaskDetail(taskId);
BigInteger knowledgeId = result.getData() == null ? null : result.getData().getKnowledgeId();
if (knowledgeId == null || knowledgeId.compareTo(context.getKnowledge().getId()) != 0) {
throw new BusinessException("任务不存在");
}
return result;
}
@PostMapping("/document/import/task/preview")
public Result<DocumentImportDtos.PreviewResponse> previewImportTask(
@RequestParam String shareKey,
@JsonBody DocumentImportDtos.PreviewRequest request
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
request == null ? null : request.getKnowledgeId(),
KnowledgeShareActionScope.CONTENT_CREATE.name()
);
BigInteger knowledgeId = resolveKnowledgeId(context, request == null ? null : request.getKnowledgeId());
request.setKnowledgeId(knowledgeId);
audit(context, "预览分享文档分块", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", knowledgeId));
return documentService.previewImportTask(request);
}
@PostMapping("/document/import/task/startIndex")
public Result<DocumentImportDtos.TaskStartIndexResponse> startIndexTask(
@RequestParam String shareKey,
@JsonBody DocumentImportDtos.TaskStartIndexRequest request
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
request == null ? null : request.getKnowledgeId(),
KnowledgeShareActionScope.CONTENT_CREATE.name()
);
BigInteger knowledgeId = resolveKnowledgeId(context, request == null ? null : request.getKnowledgeId());
request.setKnowledgeId(knowledgeId);
audit(context, "启动分享文档向量化", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", knowledgeId));
return documentService.startIndexTask(request);
}
@PostMapping("/document/import/task/retryParse")
public Result<DocumentImportDtos.TaskStartIndexResponse> retryParseTask(
@RequestParam String shareKey,
@JsonBody DocumentImportDtos.TaskRetryRequest request
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
request == null ? null : request.getKnowledgeId(),
KnowledgeShareActionScope.CONTENT_CREATE.name()
);
BigInteger knowledgeId = resolveKnowledgeId(context, request == null ? null : request.getKnowledgeId());
request.setKnowledgeId(knowledgeId);
audit(context, "重试分享文档解析", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", knowledgeId));
return documentService.retryParseTask(request);
}
@PostMapping("/document/import/task/retryIndex")
public Result<DocumentImportDtos.TaskStartIndexResponse> retryIndexTask(
@RequestParam String shareKey,
@JsonBody DocumentImportDtos.TaskRetryRequest request
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
request == null ? null : request.getKnowledgeId(),
KnowledgeShareActionScope.CONTENT_CREATE.name()
);
BigInteger knowledgeId = resolveKnowledgeId(context, request == null ? null : request.getKnowledgeId());
request.setKnowledgeId(knowledgeId);
audit(context, "重试分享文档向量化", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", knowledgeId));
return documentService.retryIndexTask(request);
}
/**
* Chunk 分页。
*/
@GetMapping("/documentChunk/page")
public Result<Page<DocumentChunk>> documentChunkPage(
@RequestParam String shareKey,
@RequestParam BigInteger documentId,
@RequestParam(defaultValue = "1") long pageNumber,
@RequestParam(defaultValue = "10") long pageSize
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
null,
KnowledgeShareActionScope.VIEW.name()
);
Document document = documentService.getById(documentId);
if (document == null || document.getCollectionId() == null
|| document.getCollectionId().compareTo(context.getKnowledge().getId()) != 0) {
throw new BusinessException("文档不存在");
}
QueryWrapper wrapper = QueryWrapper.create()
.eq(DocumentChunk::getDocumentId, documentId)
.orderBy("sorting asc");
return Result.ok(documentChunkService.page(new Page<>(pageNumber, pageSize), wrapper));
}
/**
* 更新 Chunk。
*/
@PostMapping("/documentChunk/update")
public Result<?> updateDocumentChunk(
@RequestParam String shareKey,
@JsonBody DocumentChunk documentChunk
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
null,
KnowledgeShareActionScope.CONTENT_UPDATE.name()
);
DocumentChunk current = documentChunkService.getById(documentChunk.getId());
if (current == null || current.getDocumentCollectionId() == null
|| current.getDocumentCollectionId().compareTo(context.getKnowledge().getId()) != 0) {
throw new BusinessException("记录不存在");
}
boolean success = documentChunkService.updateById(documentChunk);
if (success) {
DocumentStore documentStore = context.getKnowledge().toDocumentStore();
if (documentStore == null) {
return Result.fail(2, "知识库没有配置向量库");
}
Model model = modelService.getModelInstance(context.getKnowledge().getVectorEmbedModelId());
if (model == null) {
return Result.fail(3, "知识库没有配置向量模型");
}
EmbeddingModel embeddingModel = model.toEmbeddingModel();
documentStore.setEmbeddingModel(embeddingModel);
StoreOptions options = StoreOptions.ofCollectionName(context.getKnowledge().getVectorStoreCollection());
com.easyagents.core.document.Document doc = com.easyagents.core.document.Document.of(documentChunk.getContent());
doc.setId(documentChunk.getId());
StoreResult result = documentStore.update(doc, options);
audit(context, "更新分享文档 Chunk", "KNOWLEDGE_SHARE_URL_WRITE", true,
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", documentChunk.getId()));
return Result.ok(result);
}
return Result.ok(false);
}
/**
* 删除 Chunk。
*/
@PostMapping("/documentChunk/removeChunk")
public Result<?> removeDocumentChunk(
@RequestParam String shareKey,
@JsonBody("id") BigInteger chunkId
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
null,
KnowledgeShareActionScope.CONTENT_DELETE.name()
);
DocumentChunk current = documentChunkService.getById(chunkId);
if (current == null || current.getDocumentCollectionId() == null
|| current.getDocumentCollectionId().compareTo(context.getKnowledge().getId()) != 0) {
return Result.fail(1, "记录不存在");
}
DocumentStore documentStore = context.getKnowledge().toDocumentStore();
if (documentStore == null) {
return Result.fail(2, "知识库没有配置向量库");
}
Model model = modelService.getModelInstance(context.getKnowledge().getVectorEmbedModelId());
if (model == null) {
return Result.fail(3, "知识库没有配置向量模型");
}
documentStore.setEmbeddingModel(model.toEmbeddingModel());
StoreOptions options = StoreOptions.ofCollectionName(context.getKnowledge().getVectorStoreCollection());
documentStore.delete(Collections.singletonList(chunkId), options);
documentChunkService.removeById(chunkId);
audit(context, "删除分享文档 Chunk", "KNOWLEDGE_SHARE_URL_WRITE", true,
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", chunkId));
return Result.ok(true);
}
/**
* FAQ 分类列表。
*/
@GetMapping("/faqCategory/list")
public Result<List<FaqCategory>> faqCategoryList(
@RequestParam String shareKey,
@RequestParam(required = false) Boolean asTree
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
null,
KnowledgeShareActionScope.VIEW.name()
);
return Result.ok(faqCategoryService.listByCollection(context.getKnowledge().getId(), asTree));
}
/**
* 保存 FAQ 分类。
*/
@PostMapping("/faqCategory/save")
public Result<?> saveFaqCategory(
@RequestParam String shareKey,
@JsonBody FaqCategory entity
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
entity.getCollectionId(),
KnowledgeShareActionScope.CONTENT_CREATE.name()
);
audit(context, "新增 FAQ 分类", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", entity.getCollectionId()));
return Result.ok(faqCategoryService.saveCategory(entity));
}
/**
* 更新 FAQ 分类。
*/
@PostMapping("/faqCategory/update")
public Result<?> updateFaqCategory(
@RequestParam String shareKey,
@JsonBody FaqCategory entity
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
null,
KnowledgeShareActionScope.CONTENT_UPDATE.name()
);
audit(context, "更新 FAQ 分类", "KNOWLEDGE_SHARE_URL_WRITE", true,
auditDetail("knowledgeId", context.getKnowledge().getId(), "categoryId", entity.getId()));
return Result.ok(faqCategoryService.updateCategory(entity));
}
/**
* 删除 FAQ 分类。
*/
@PostMapping("/faqCategory/remove")
public Result<?> removeFaqCategory(
@RequestParam String shareKey,
@JsonBody("id") BigInteger id
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
null,
KnowledgeShareActionScope.CONTENT_DELETE.name()
);
audit(context, "删除 FAQ 分类", "KNOWLEDGE_SHARE_URL_WRITE", true,
auditDetail("knowledgeId", context.getKnowledge().getId(), "categoryId", id));
return Result.ok(faqCategoryService.removeCategory(id));
}
/**
* FAQ 分页。
*/
@GetMapping("/faqItem/page")
public Result<Page<FaqItem>> faqPage(
@RequestParam String shareKey,
HttpServletRequest request,
@RequestParam(defaultValue = "1") long pageNumber,
@RequestParam(defaultValue = "10") long pageSize
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
null,
KnowledgeShareActionScope.VIEW.name()
);
faqCategoryService.ensureDefaultCategory(context.getKnowledge().getId());
QueryWrapper queryWrapper = QueryWrapper.create()
.eq(FaqItem::getCollectionId, context.getKnowledge().getId());
String question = request.getParameter("question");
if (StringUtils.hasText(question)) {
queryWrapper.like(FaqItem::getQuestion, question.trim());
}
String categoryId = request.getParameter("categoryId");
if (StringUtils.hasText(categoryId)) {
List<BigInteger> descendantIds =
faqCategoryService.findDescendantIds(context.getKnowledge().getId(), new BigInteger(categoryId));
if (descendantIds.isEmpty()) {
queryWrapper.eq(FaqItem::getId, BigInteger.ZERO);
} else {
queryWrapper.in(FaqItem::getCategoryId, descendantIds);
}
}
queryWrapper.orderBy("order_no asc");
Page<FaqItem> page = faqItemService.page(new Page<>(pageNumber, pageSize), queryWrapper);
Map<BigInteger, String> pathMap = faqCategoryService.buildPathMap(context.getKnowledge().getId());
if (page.getRecords() != null) {
for (FaqItem record : page.getRecords()) {
record.setCategoryPath(pathMap.get(record.getCategoryId()));
}
}
return Result.ok(page);
}
/**
* FAQ 详情。
*/
@GetMapping("/faqItem/detail")
public Result<FaqItem> faqDetail(
@RequestParam String shareKey,
@RequestParam String id
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
null,
KnowledgeShareActionScope.VIEW.name()
);
FaqItem item = faqItemService.getById(id);
if (item == null || item.getCollectionId() == null
|| item.getCollectionId().compareTo(context.getKnowledge().getId()) != 0) {
throw new BusinessException("FAQ不存在");
}
return Result.ok(item);
}
/**
* 保存 FAQ。
*/
@PostMapping("/faqItem/save")
public Result<?> saveFaq(
@RequestParam String shareKey,
@JsonBody FaqItem entity
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
entity.getCollectionId(),
KnowledgeShareActionScope.CONTENT_CREATE.name()
);
audit(context, "新增 FAQ", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", entity.getCollectionId()));
return Result.ok(faqItemService.saveFaqItem(entity));
}
/**
* 更新 FAQ。
*/
@PostMapping("/faqItem/update")
public Result<?> updateFaq(
@RequestParam String shareKey,
@JsonBody FaqItem entity
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
null,
KnowledgeShareActionScope.CONTENT_UPDATE.name()
);
audit(context, "更新 FAQ", "KNOWLEDGE_SHARE_URL_WRITE", true,
auditDetail("knowledgeId", context.getKnowledge().getId(), "faqId", entity.getId()));
return Result.ok(faqItemService.updateFaqItem(entity));
}
/**
* 删除 FAQ。
*/
@PostMapping("/faqItem/remove")
public Result<?> removeFaq(
@RequestParam String shareKey,
@JsonBody("id") BigInteger id
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
null,
KnowledgeShareActionScope.CONTENT_DELETE.name()
);
audit(context, "删除 FAQ", "KNOWLEDGE_SHARE_URL_WRITE", true,
auditDetail("knowledgeId", context.getKnowledge().getId(), "faqId", id));
return Result.ok(faqItemService.removeFaqItem(id));
}
/**
* 上传 FAQ 图片。
*/
@PostMapping(value = "/faqItem/uploadImage", produces = MediaType.APPLICATION_JSON_VALUE)
public Result<UploadResVo> uploadFaqImage(
@RequestParam String shareKey,
MultipartFile file,
BigInteger collectionId
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
collectionId,
KnowledgeShareActionScope.CONTENT_UPDATE.name()
);
if (file == null || file.isEmpty()) {
throw new BusinessException("图片不能为空");
}
if (file.getSize() > MAX_IMAGE_SIZE_BYTES) {
throw new BusinessException("图片大小不能超过5MB");
}
if (!isAllowedImageType(file)) {
throw new BusinessException("仅支持 JPG/PNG/WEBP/GIF 图片");
}
String path = fileStorageService.save(file, "faq/" + collectionId);
UploadResVo result = new UploadResVo();
result.setPath(path);
audit(context, "上传 FAQ 图片", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", collectionId));
return Result.ok(result);
}
/**
* 导入 FAQ Excel。
*/
@PostMapping(value = "/faqItem/importExcel", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public Result<FaqImportResultVo> importFaqExcel(
@RequestParam String shareKey,
MultipartFile file,
BigInteger collectionId
) {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
collectionId,
KnowledgeShareActionScope.IMPORT_EXPORT.name()
);
audit(context, "导入 FAQ Excel", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", collectionId));
return Result.ok(faqItemService.importFromExcel(collectionId, file));
}
/**
* 下载 FAQ 导入模板。
*/
@GetMapping("/faqItem/downloadImportTemplate")
public void downloadFaqImportTemplate(
@RequestParam String shareKey,
@RequestParam BigInteger collectionId,
HttpServletResponse response
) throws Exception {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
collectionId,
KnowledgeShareActionScope.IMPORT_EXPORT.name()
);
response.setContentType("application/octet-stream");
response.setHeader(
"Content-disposition",
"attachment;filename*=utf-8''" + URLEncoder.encode("faq_import_template.xlsx", StandardCharsets.UTF_8)
);
faqItemService.writeImportTemplate(response.getOutputStream());
response.flushBuffer();
audit(context, "下载 FAQ 导入模板", "KNOWLEDGE_SHARE_URL_ACCESS", false, auditDetail("knowledgeId", collectionId));
}
/**
* 导出 FAQ Excel。
*/
@GetMapping("/faqItem/exportExcel")
public void exportFaqExcel(
@RequestParam String shareKey,
@RequestParam BigInteger collectionId,
HttpServletResponse response
) throws Exception {
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
shareKey,
collectionId,
KnowledgeShareActionScope.IMPORT_EXPORT.name()
);
String fileName = "faq_export_" + new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()) + ".xlsx";
response.setContentType("application/octet-stream");
response.setHeader(
"Content-disposition",
"attachment;filename*=utf-8''" + URLEncoder.encode(fileName, StandardCharsets.UTF_8)
);
faqItemService.exportToExcel(collectionId, response.getOutputStream());
response.flushBuffer();
audit(context, "导出 FAQ Excel", "KNOWLEDGE_SHARE_URL_ACCESS", false, auditDetail("knowledgeId", collectionId));
}
private boolean isAllowedImageType(MultipartFile file) {
String contentType = file.getContentType();
return contentType != null && ALLOWED_IMAGE_TYPES.contains(contentType.toLowerCase());
}
/**
* 校验分享请求中的知识库标识。
*
* @param knowledgeId 知识库 ID
* @return 已校验的知识库 ID
*/
private BigInteger resolveKnowledgeId(KnowledgeShareAuthContext context, BigInteger requestedKnowledgeId) {
if (requestedKnowledgeId != null) {
return requestedKnowledgeId;
}
if (context != null && context.getKnowledge() != null && context.getKnowledge().getId() != null) {
return context.getKnowledge().getId();
}
if (context == null || context.getKnowledge() == null) {
throw new BusinessException("知识库不能为空");
}
throw new BusinessException("知识库不存在");
}
/**
* 构造允许空值的审计详情。
*
* @param keyValues 键值对
* @return 审计详情
*/
private Map<String, Object> auditDetail(Object... keyValues) {
if (keyValues == null || keyValues.length == 0) {
return new HashMap<>();
}
if ((keyValues.length & 1) != 0) {
throw new IllegalArgumentException("审计详情参数必须成对出现");
}
Map<String, Object> detail = new HashMap<>(keyValues.length / 2);
for (int index = 0; index < keyValues.length; index += 2) {
Object key = keyValues[index];
if (!(key instanceof String detailKey) || !StringUtils.hasText(detailKey)) {
throw new IllegalArgumentException("审计详情 key 必须为非空字符串");
}
detail.put(detailKey, keyValues[index + 1]);
}
return detail;
}
/**
* 记录分享访问审计。
*
* @param context 分享鉴权上下文
* @param actionName 动作名称
* @param actionType 动作类型
* @param writeOperation 是否写操作
* @param detail 审计详情
*/
private void audit(KnowledgeShareAuthContext context, String actionName, String actionType, boolean writeOperation, Map<String, Object> detail) {
Map<String, Object> payload = new HashMap<>(detail);
payload.put("shareId", context.getShare().getId());
payload.put("writeOperation", writeOperation);
knowledgeShareAuditService.log(null, actionName, actionType, "/api/v1/share/knowledge", payload);
}
private List<KnowledgeSearchResultItem> toKnowledgeSearchResult(List<com.easyagents.core.document.Document> documents) {
List<KnowledgeSearchResultItem> result = new java.util.ArrayList<>();
if (documents == null) {
return result;
}
for (int index = 0; index < documents.size(); index++) {
com.easyagents.core.document.Document document = documents.get(index);
if (document == null) {
continue;
}
KnowledgeSearchResultItem item = new KnowledgeSearchResultItem();
item.setSorting(index + 1);
item.setContent(document.getContent());
item.setScore(document.getScore() == null ? null : document.getScore().doubleValue());
Object hitSource = document.getMetadata("hitSource");
item.setHitSource(hitSource == null ? null : String.valueOf(hitSource));
item.setVectorScore(asDouble(document.getMetadata("vectorScore")));
item.setKeywordScore(asDouble(document.getMetadata("keywordScore")));
result.add(item);
}
return result;
}
private Double asDouble(Object value) {
if (value == null) {
return null;
}
if (value instanceof Number number) {
return number.doubleValue();
}
if (value instanceof String text && StringUtils.hasText(text)) {
try {
return Double.parseDouble(text);
} catch (NumberFormatException ignore) {
return null;
}
}
return null;
}
}

View File

@@ -15,6 +15,7 @@ import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport;
import tech.easyflow.ai.permission.WorkflowVisibilityQueryHelper;
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
@@ -93,6 +94,8 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
private WorkflowPublishAppService workflowPublishAppService;
@Resource
private AiResourceApprovalStateService aiResourceApprovalStateService;
@Resource
private AiResourceCreatorNameSupport aiResourceCreatorNameSupport;
public WorkflowController(WorkflowService service, ModelService modelService) {
super(service);
@@ -120,6 +123,12 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
if (workflow == null) {
return Result.fail(1, "工作流不存在");
}
if (variables == null) {
variables = new HashMap<>();
}
if (StpUtil.isLogin()) {
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
}
Map<String, Object> res = chainExecutor.executeNode(workflowId.toString(), nodeId, variables);
return Result.ok(res);
}
@@ -428,6 +437,7 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
applyPublishedOnlyFilter(queryWrapper);
Page<Workflow> result = super.queryPage(page, queryWrapper);
aiResourceApprovalStateService.fillWorkflowApprovalState(result.getRecords());
aiResourceCreatorNameSupport.fillWorkflowCreatorNames(result.getRecords());
return result;
}

View File

@@ -0,0 +1,119 @@
package tech.easyflow.admin.controller.ai.support;
import org.springframework.stereotype.Component;
import tech.easyflow.ai.entity.Bot;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.system.service.SysAccountService;
import javax.annotation.Resource;
import java.math.BigInteger;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Objects;
import java.util.function.BiConsumer;
import java.util.function.Function;
/**
* 为 AI 资源批量补充创建人展示名称。
*
* <p>该组件只做展示字段填充,不参与权限或查询逻辑。</p>
*
* @author Codex
* @since 2026-04-12
*/
@Component
public class AiResourceCreatorNameSupport {
@Resource
private SysAccountService sysAccountService;
/**
* 批量填充工作流创建人名称。
*
* @param workflows 工作流集合
*/
public void fillWorkflowCreatorNames(Collection<Workflow> workflows) {
fillCreatorNames(workflows, Workflow::getCreatedBy, Workflow::setCreatedByName);
}
/**
* 批量填充聊天助手创建人名称。
*
* @param bots 聊天助手集合
*/
public void fillBotCreatorNames(Collection<Bot> bots) {
fillCreatorNames(bots, Bot::getCreatedBy, Bot::setCreatedByName);
}
/**
* 批量填充知识库创建人名称。
*
* @param collections 知识库集合
*/
public void fillDocumentCollectionCreatorNames(Collection<DocumentCollection> collections) {
fillCreatorNames(collections, DocumentCollection::getCreatedBy, DocumentCollection::setCreatedByName);
}
/**
* 批量填充插件创建人名称。
*
* @param plugins 插件集合
*/
public void fillPluginCreatorNames(Collection<Plugin> plugins) {
fillCreatorNames(plugins, Plugin::getCreatedBy, Plugin::setCreatedByName);
}
/**
* 通用的创建人名称填充逻辑。
*
* @param resources 资源集合
* @param createdByGetter 创建人 ID 提取函数
* @param createdByNameSetter 创建人名称回填函数
* @param <T> 资源类型
*/
private <T> void fillCreatorNames(
Collection<T> resources,
Function<T, Number> createdByGetter,
BiConsumer<T, String> createdByNameSetter
) {
if (resources == null || resources.isEmpty()) {
return;
}
LinkedHashSet<BigInteger> creatorIds = resources.stream()
.map(createdByGetter)
.map(this::toBigInteger)
.filter(Objects::nonNull)
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
if (creatorIds.isEmpty()) {
return;
}
Map<BigInteger, String> displayNameMap = sysAccountService.resolveDisplayNameMap(creatorIds);
for (T resource : resources) {
BigInteger creatorId = toBigInteger(createdByGetter.apply(resource));
if (creatorId == null) {
continue;
}
createdByNameSetter.accept(resource, displayNameMap.getOrDefault(creatorId, creatorId.toString()));
}
}
/**
* 统一把不同数值类型转换为 {@link BigInteger}。
*
* @param value 原始数值
* @return 归一化后的 {@link BigInteger}
*/
private BigInteger toBigInteger(Number value) {
if (value == null) {
return null;
}
if (value instanceof BigInteger) {
return (BigInteger) value;
}
return BigInteger.valueOf(value.longValue());
}
}

View File

@@ -10,6 +10,7 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.ai.service.KnowledgeSharePermissionService;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
@@ -43,6 +44,8 @@ public class SysApiKeyController extends BaseCurdController<SysApiKeyService, Sy
@Resource
private SysApiKeyResourceMappingService sysApiKeyResourceMappingService;
@Resource
private KnowledgeSharePermissionService knowledgeSharePermissionService;
/**
* 添加(保存)数据
*
@@ -79,10 +82,20 @@ public class SysApiKeyController extends BaseCurdController<SysApiKeyService, Sy
@Override
protected void onSaveOrUpdateAfter(SysApiKey entity, boolean isSave) {
if (!isSave && entity.getPermissionIds() != null && !entity.getPermissionIds().isEmpty()) {
// 修改的时候绑定授权接口
if (entity.getPermissionIds() != null) {
sysApiKeyResourceMappingService.authInterface(entity);
}
if (entity.getKnowledgeShareEnabled() != null) {
knowledgeSharePermissionService.replaceApiShareEnabled(entity.getId(), entity.getKnowledgeShareEnabled());
}
}
@Override
@GetMapping("/detail")
public Result<SysApiKey> detail(String id) {
Result<SysApiKey> result = super.detail(id);
fillApiKeyPermissions(result.getData());
return result;
}
@Override
@@ -91,11 +104,30 @@ public class SysApiKeyController extends BaseCurdController<SysApiKeyService, Sy
Result<Page<SysApiKey>> pageResult = (Result<Page<SysApiKey>>) super.page(request, sortKey, sortType, pageNumber, pageSize);
Page<SysApiKey> data = pageResult.getData();
List<SysApiKey> records = data.getRecords();
records.forEach(record -> {
QueryWrapper queryWrapper = QueryWrapper.create().select(SysApiKeyResourceMapping::getApiKeyResourceId).eq(SysApiKeyResourceMapping::getApiKeyId, record.getId());
List<BigInteger> resourceIds = sysApiKeyResourceMappingService.listAs(queryWrapper, BigInteger.class);
record.setPermissionIds(resourceIds);
});
records.forEach(this::fillApiKeyPermissions);
return pageResult;
}
/**
* 回填访问令牌的接口与知识库授权。
*
* @param entity 访问令牌
*/
private void fillApiKeyPermissions(SysApiKey entity) {
if (entity == null || entity.getId() == null) {
return;
}
QueryWrapper interfaceWrapper = QueryWrapper.create()
.select(SysApiKeyResourceMapping::getApiKeyResourceId)
.eq(SysApiKeyResourceMapping::getApiKeyId, entity.getId())
.isNull(SysApiKeyResourceMapping::getResourceType);
List<BigInteger> resourceIds = sysApiKeyResourceMappingService.listAs(interfaceWrapper, BigInteger.class);
entity.setPermissionIds(resourceIds);
QueryWrapper knowledgeWrapper = QueryWrapper.create()
.select(SysApiKeyResourceMapping::getId)
.eq(SysApiKeyResourceMapping::getApiKeyId, entity.getId())
.eq(SysApiKeyResourceMapping::getResourceType, "KNOWLEDGE");
entity.setKnowledgeShareEnabled(sysApiKeyResourceMappingService.count(knowledgeWrapper) > 0);
}
}

View File

@@ -0,0 +1,145 @@
package tech.easyflow.admin.controller.ai;
import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryWrapper;
import org.testng.Assert;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport;
import tech.easyflow.ai.entity.Bot;
import tech.easyflow.ai.service.AiResourceApprovalStateService;
import tech.easyflow.ai.service.BotDocumentCollectionService;
import tech.easyflow.ai.service.BotMessageService;
import tech.easyflow.ai.service.BotService;
import tech.easyflow.ai.service.BotWorkflowService;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.SysAccountService;
import java.math.BigInteger;
import java.util.Collections;
import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link BotController} 测试。
*/
public class BotControllerTest {
private BotService botService;
private ModelService modelService;
private BotWorkflowService botWorkflowService;
private BotDocumentCollectionService botDocumentCollectionService;
private BotMessageService botMessageService;
private CategoryPermissionService categoryPermissionService;
private AiResourceApprovalStateService aiResourceApprovalStateService;
private SysAccountService sysAccountService;
/**
* 初始化测试 mock。
*/
@BeforeMethod
public void setUp() {
botService = mock(BotService.class);
modelService = mock(ModelService.class);
botWorkflowService = mock(BotWorkflowService.class);
botDocumentCollectionService = mock(BotDocumentCollectionService.class);
botMessageService = mock(BotMessageService.class);
categoryPermissionService = mock(CategoryPermissionService.class);
aiResourceApprovalStateService = mock(AiResourceApprovalStateService.class);
sysAccountService = mock(SysAccountService.class);
}
/**
* 验证分页结果会补充创建人展示名称,且只做一次批量账号查询。
*/
@Test
public void shouldFillCreatedByNameForPageRecords() {
TestBotController controller = new TestBotController(
botService,
modelService,
botWorkflowService,
botDocumentCollectionService,
botMessageService
);
AiResourceCreatorNameSupport creatorNameSupport = new AiResourceCreatorNameSupport();
setField(creatorNameSupport, "sysAccountService", sysAccountService);
setField(controller, "categoryPermissionService", categoryPermissionService);
setField(controller, "aiResourceApprovalStateService", aiResourceApprovalStateService);
setField(controller, "aiResourceCreatorNameSupport", creatorNameSupport);
Bot bot = new Bot();
bot.setId(BigInteger.valueOf(101));
bot.setCreatedBy(BigInteger.valueOf(7));
Page<Bot> page = new Page<>(Collections.singletonList(bot), 1, 10, 1);
when(categoryPermissionService.getCurrentAccess("BOT"))
.thenReturn(new RoleCategoryAccessSnapshot("BOT", BigInteger.ONE, false, true, Collections.emptySet()));
when(botService.page(any(Page.class), any(QueryWrapper.class))).thenReturn(page);
when(sysAccountService.resolveDisplayNameMap(Collections.singleton(BigInteger.valueOf(7))))
.thenReturn(Map.of(BigInteger.valueOf(7), "管理员"));
doNothing().when(aiResourceApprovalStateService).fillBotApprovalState(page.getRecords());
Page<Bot> result = controller.invokeQueryPage(new Page<>(1, 10), QueryWrapper.create());
Assert.assertEquals(result.getRecords().get(0).getCreatedByName(), "管理员");
verify(sysAccountService).resolveDisplayNameMap(Collections.singleton(BigInteger.valueOf(7)));
}
/**
* 通过反射设置字段值。
*
* @param target 目标对象
* @param fieldName 字段名
* @param value 字段值
*/
private static void setField(Object target, String fieldName, Object value) {
Class<?> current = target.getClass();
while (current != null) {
try {
java.lang.reflect.Field field = current.getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
return;
} catch (NoSuchFieldException ignored) {
current = current.getSuperclass();
} catch (IllegalAccessException e) {
throw new IllegalStateException("设置测试字段失败: " + fieldName, e);
}
}
throw new IllegalArgumentException("未找到字段: " + fieldName);
}
/**
* 暴露受保护分页方法的测试控制器。
*/
private static class TestBotController extends BotController {
TestBotController(
BotService service,
ModelService modelService,
BotWorkflowService botWorkflowService,
BotDocumentCollectionService botDocumentCollectionService,
BotMessageService botMessageService
) {
super(service, modelService, botWorkflowService, botDocumentCollectionService, botMessageService);
}
/**
* 调用受保护的分页查询方法。
*
* @param page 分页对象
* @param queryWrapper 查询条件
* @return 查询结果
*/
Page<Bot> invokeQueryPage(Page<Bot> page, QueryWrapper queryWrapper) {
return super.queryPage(page, queryWrapper);
}
}
}

View File

@@ -0,0 +1,660 @@
package tech.easyflow.publicapi.controller;
import cn.hutool.core.io.IoUtil;
import com.easyagents.core.model.embedding.EmbeddingModel;
import com.easyagents.core.store.DocumentStore;
import com.easyagents.core.store.StoreOptions;
import com.easyagents.core.store.StoreResult;
import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryWrapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
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.RequestHeader;
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 tech.easyflow.ai.documentimport.DocumentImportDtos;
import tech.easyflow.ai.dto.KnowledgeSearchResultItem;
import tech.easyflow.ai.entity.Document;
import tech.easyflow.ai.entity.DocumentChunk;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.FaqItem;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.enums.KnowledgeShareActionScope;
import tech.easyflow.ai.rag.KnowledgeRetrievalModes;
import tech.easyflow.ai.rag.KnowledgeRetrievalRequest;
import tech.easyflow.ai.service.DocumentChunkService;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.ai.service.DocumentService;
import tech.easyflow.ai.service.FaqCategoryService;
import tech.easyflow.ai.service.FaqItemService;
import tech.easyflow.ai.service.KnowledgeShareAuditService;
import tech.easyflow.ai.service.KnowledgeSharePermissionService;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.ai.service.impl.KnowledgeSharePermissionServiceImpl;
import tech.easyflow.ai.vo.FaqImportResultVo;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.system.entity.SysApiKey;
import tech.easyflow.system.service.SysApiKeyService;
import javax.annotation.Resource;
import java.io.InputStream;
import java.math.BigInteger;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 知识库 API 分享接口。
*/
@RestController
@RequestMapping(value = "/public-api/knowledge-share", produces = MediaType.APPLICATION_JSON_VALUE)
public class PublicKnowledgeShareController {
@Resource
private SysApiKeyService sysApiKeyService;
@Resource
private KnowledgeSharePermissionService knowledgeSharePermissionService;
@Resource
private KnowledgeShareAuditService knowledgeShareAuditService;
@Resource
private DocumentCollectionService documentCollectionService;
@Resource
private DocumentService documentService;
@Resource
private DocumentChunkService documentChunkService;
@Resource
private FaqItemService faqItemService;
@Resource
private FaqCategoryService faqCategoryService;
@Resource
private ModelService modelService;
@Resource(name = "default")
private FileStorageService fileStorageService;
/**
* 获取知识库详情。
*/
@GetMapping("/detail")
public Result<DocumentCollection> detail(
@RequestHeader("ApiKey") String apiKey,
@RequestParam BigInteger knowledgeId,
HttpServletRequest request
) {
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.VIEW.name());
audit(apiKey, "API读取知识库详情", "KNOWLEDGE_API_SHARE_ACCESS", request.getRequestURI(), Map.of("knowledgeId", knowledgeId));
return Result.ok(documentCollectionService.getDetail(knowledgeId.toString()));
}
/**
* 检索知识库。
*/
@GetMapping("/search")
public Result<List<KnowledgeSearchResultItem>> search(
@RequestHeader("ApiKey") String apiKey,
@RequestParam BigInteger knowledgeId,
@RequestParam String keyword,
@RequestParam(required = false) String retrievalMode,
HttpServletRequest request
) {
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.SEARCH.name());
KnowledgeRetrievalRequest retrievalRequest = new KnowledgeRetrievalRequest();
retrievalRequest.setKnowledgeId(knowledgeId);
retrievalRequest.setQuery(keyword);
retrievalRequest.setRetrievalMode(KnowledgeRetrievalModes.parse(retrievalMode));
retrievalRequest.setCallerType("PUBLIC_API");
retrievalRequest.setCallerId(String.valueOf(knowledgeId));
audit(apiKey, "API检索知识库", "KNOWLEDGE_API_SHARE_ACCESS", request.getRequestURI(), Map.of("knowledgeId", knowledgeId));
return Result.ok(toKnowledgeSearchResult(documentCollectionService.search(retrievalRequest)));
}
/**
* 文档分页。
*/
@GetMapping("/document/page")
public Result<Page<Document>> documentPage(
@RequestHeader("ApiKey") String apiKey,
@RequestParam BigInteger knowledgeId,
@RequestParam(required = false) String title,
@RequestParam(defaultValue = "10") int pageSize,
@RequestParam(defaultValue = "1") int pageNumber,
HttpServletRequest request
) {
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.VIEW.name());
requireDocumentKnowledge(knowledgeId);
return Result.ok(documentService.getDocumentList(knowledgeId.toString(), pageSize, pageNumber, title));
}
/**
* 下载文档。
*/
@GetMapping(value = "/document/download", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public void documentDownload(
@RequestHeader("ApiKey") String apiKey,
@RequestParam BigInteger knowledgeId,
@RequestParam BigInteger documentId,
HttpServletRequest request,
HttpServletResponse response
) throws Exception {
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.VIEW.name());
requireDocumentKnowledge(knowledgeId);
Document document = requireDocument(documentId, knowledgeId);
response.setContentType("application/octet-stream");
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
String fileName = URLEncoder.encode(document.getTitle(), StandardCharsets.UTF_8).replaceAll("\\+", "%20");
response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName);
try (InputStream inputStream = fileStorageService.readStream(document.getDocumentPath())) {
IoUtil.copy(inputStream, response.getOutputStream());
response.flushBuffer();
}
audit(apiKey, "API下载文档", "KNOWLEDGE_API_SHARE_ACCESS", request.getRequestURI(), Map.of("knowledgeId", knowledgeId, "documentId", documentId));
}
/**
* 删除文档。
*/
@PostMapping("/document/remove")
public Result<?> removeDocument(
@RequestHeader("ApiKey") String apiKey,
@RequestParam BigInteger knowledgeId,
@JsonBody("id") String id,
HttpServletRequest request
) {
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.CONTENT_DELETE.name());
requireDocumentKnowledge(knowledgeId);
requireDocument(new BigInteger(id), knowledgeId);
audit(apiKey, "API删除文档", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", knowledgeId, "documentId", id));
return Result.ok(documentService.removeDoc(id));
}
/**
* 文档导入分析。
*/
@PostMapping("/document/import/analyze")
public Result<DocumentImportDtos.AnalyzeResponse> analyzeImport(
@RequestHeader("ApiKey") String apiKey,
@JsonBody DocumentImportDtos.AnalyzeRequest request,
HttpServletRequest servletRequest
) {
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeShareActionScope.CONTENT_CREATE.name());
requireDocumentKnowledge(request.getKnowledgeId());
audit(apiKey, "API分析文档导入", "KNOWLEDGE_API_SHARE_WRITE", servletRequest.getRequestURI(), Map.of("knowledgeId", request.getKnowledgeId()));
return documentService.analyzeImport(request);
}
/**
* 文档导入预览。
*/
@PostMapping("/document/import/preview")
public Result<DocumentImportDtos.PreviewResponse> previewImport(
@RequestHeader("ApiKey") String apiKey,
@JsonBody DocumentImportDtos.PreviewRequest request,
HttpServletRequest servletRequest
) {
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeShareActionScope.CONTENT_CREATE.name());
requireDocumentKnowledge(request.getKnowledgeId());
audit(apiKey, "API预览文档导入", "KNOWLEDGE_API_SHARE_WRITE", servletRequest.getRequestURI(), Map.of("knowledgeId", request.getKnowledgeId()));
return documentService.previewImport(request);
}
/**
* 文档导入提交。
*/
@PostMapping("/document/import/commit")
public Result<DocumentImportDtos.CommitResponse> commitImport(
@RequestHeader("ApiKey") String apiKey,
@JsonBody DocumentImportDtos.CommitRequest request,
HttpServletRequest servletRequest
) {
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeShareActionScope.CONTENT_CREATE.name());
requireDocumentKnowledge(request.getKnowledgeId());
audit(apiKey, "API提交文档导入", "KNOWLEDGE_API_SHARE_WRITE", servletRequest.getRequestURI(), Map.of("knowledgeId", request.getKnowledgeId()));
return documentService.commitImport(request);
}
@PostMapping("/document/import/task/create")
public Result<DocumentImportDtos.TaskCreateResponse> createImportTask(
@RequestHeader("ApiKey") String apiKey,
@JsonBody DocumentImportDtos.TaskCreateRequest request,
HttpServletRequest servletRequest
) {
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeShareActionScope.CONTENT_CREATE.name());
requireDocumentKnowledge(request.getKnowledgeId());
audit(apiKey, "API创建文档导入任务", "KNOWLEDGE_API_SHARE_WRITE", servletRequest.getRequestURI(), Map.of("knowledgeId", request.getKnowledgeId()));
return documentService.createImportTask(request);
}
@GetMapping("/document/import/task/detail")
public Result<DocumentImportDtos.TaskDetailResponse> getImportTaskDetail(
@RequestHeader("ApiKey") String apiKey,
@RequestParam BigInteger knowledgeId,
@RequestParam BigInteger taskId,
HttpServletRequest request
) {
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.VIEW.name());
requireDocumentKnowledge(knowledgeId);
Result<DocumentImportDtos.TaskDetailResponse> result = documentService.getImportTaskDetail(taskId);
if (result.getData() == null || result.getData().getKnowledgeId() == null
|| result.getData().getKnowledgeId().compareTo(knowledgeId) != 0) {
throw new BusinessException("任务不存在");
}
return result;
}
@PostMapping("/document/import/task/preview")
public Result<DocumentImportDtos.PreviewResponse> previewImportTask(
@RequestHeader("ApiKey") String apiKey,
@JsonBody DocumentImportDtos.PreviewRequest request,
HttpServletRequest servletRequest
) {
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeShareActionScope.CONTENT_CREATE.name());
requireDocumentKnowledge(request.getKnowledgeId());
audit(apiKey, "API预览文档分块", "KNOWLEDGE_API_SHARE_WRITE", servletRequest.getRequestURI(), Map.of("knowledgeId", request.getKnowledgeId()));
return documentService.previewImportTask(request);
}
@PostMapping("/document/import/task/startIndex")
public Result<DocumentImportDtos.TaskStartIndexResponse> startIndexTask(
@RequestHeader("ApiKey") String apiKey,
@JsonBody DocumentImportDtos.TaskStartIndexRequest request,
HttpServletRequest servletRequest
) {
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeShareActionScope.CONTENT_CREATE.name());
requireDocumentKnowledge(request.getKnowledgeId());
audit(apiKey, "API启动文档向量化", "KNOWLEDGE_API_SHARE_WRITE", servletRequest.getRequestURI(), Map.of("knowledgeId", request.getKnowledgeId()));
return documentService.startIndexTask(request);
}
@PostMapping("/document/import/task/retryParse")
public Result<DocumentImportDtos.TaskStartIndexResponse> retryParseTask(
@RequestHeader("ApiKey") String apiKey,
@JsonBody DocumentImportDtos.TaskRetryRequest request,
HttpServletRequest servletRequest
) {
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeShareActionScope.CONTENT_CREATE.name());
requireDocumentKnowledge(request.getKnowledgeId());
audit(apiKey, "API重试文档解析", "KNOWLEDGE_API_SHARE_WRITE", servletRequest.getRequestURI(), Map.of("knowledgeId", request.getKnowledgeId()));
return documentService.retryParseTask(request);
}
@PostMapping("/document/import/task/retryIndex")
public Result<DocumentImportDtos.TaskStartIndexResponse> retryIndexTask(
@RequestHeader("ApiKey") String apiKey,
@JsonBody DocumentImportDtos.TaskRetryRequest request,
HttpServletRequest servletRequest
) {
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeShareActionScope.CONTENT_CREATE.name());
requireDocumentKnowledge(request.getKnowledgeId());
audit(apiKey, "API重试文档向量化", "KNOWLEDGE_API_SHARE_WRITE", servletRequest.getRequestURI(), Map.of("knowledgeId", request.getKnowledgeId()));
return documentService.retryIndexTask(request);
}
/**
* Chunk 分页。
*/
@GetMapping("/documentChunk/page")
public Result<Page<DocumentChunk>> documentChunkPage(
@RequestHeader("ApiKey") String apiKey,
@RequestParam BigInteger knowledgeId,
@RequestParam BigInteger documentId,
@RequestParam(defaultValue = "1") long pageNumber,
@RequestParam(defaultValue = "10") long pageSize,
HttpServletRequest request
) {
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.VIEW.name());
requireDocumentKnowledge(knowledgeId);
requireDocument(documentId, knowledgeId);
QueryWrapper wrapper = QueryWrapper.create()
.eq(DocumentChunk::getDocumentId, documentId)
.orderBy("sorting asc");
return Result.ok(documentChunkService.page(new Page<>(pageNumber, pageSize), wrapper));
}
/**
* 更新 Chunk。
*/
@PostMapping("/documentChunk/update")
public Result<?> updateDocumentChunk(
@RequestHeader("ApiKey") String apiKey,
@RequestParam BigInteger knowledgeId,
@JsonBody DocumentChunk documentChunk,
HttpServletRequest request
) {
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.CONTENT_UPDATE.name());
requireDocumentKnowledge(knowledgeId);
DocumentChunk current = requireDocumentChunk(documentChunk.getId(), knowledgeId);
boolean success = documentChunkService.updateById(documentChunk);
if (success) {
DocumentCollection knowledge = documentCollectionService.getById(knowledgeId);
DocumentStore documentStore = knowledge.toDocumentStore();
if (documentStore == null) {
return Result.fail(2, "知识库没有配置向量库");
}
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);
}
return Result.ok(false);
}
/**
* 删除 Chunk。
*/
@PostMapping("/documentChunk/remove")
public Result<?> removeDocumentChunk(
@RequestHeader("ApiKey") String apiKey,
@RequestParam BigInteger knowledgeId,
@JsonBody("id") BigInteger chunkId,
HttpServletRequest request
) {
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.CONTENT_DELETE.name());
requireDocumentKnowledge(knowledgeId);
requireDocumentChunk(chunkId, knowledgeId);
DocumentCollection knowledge = documentCollectionService.getById(knowledgeId);
DocumentStore documentStore = knowledge.toDocumentStore();
if (documentStore == null) {
return Result.fail(2, "知识库没有配置向量库");
}
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
if (model == null) {
return Result.fail(3, "知识库没有配置向量模型");
}
documentStore.setEmbeddingModel(model.toEmbeddingModel());
StoreOptions options = StoreOptions.ofCollectionName(knowledge.getVectorStoreCollection());
documentStore.delete(Collections.singletonList(chunkId), options);
documentChunkService.removeById(chunkId);
audit(apiKey, "API删除文档 Chunk", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", knowledgeId, "chunkId", chunkId));
return Result.ok(true);
}
/**
* FAQ 分页。
*/
@GetMapping("/faq/page")
public Result<Page<FaqItem>> faqPage(
@RequestHeader("ApiKey") String apiKey,
@RequestParam BigInteger knowledgeId,
@RequestParam(required = false) String question,
@RequestParam(required = false) String categoryId,
@RequestParam(defaultValue = "1") long pageNumber,
@RequestParam(defaultValue = "10") long pageSize,
HttpServletRequest request
) {
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.VIEW.name());
requireFaqKnowledge(knowledgeId);
faqCategoryService.ensureDefaultCategory(knowledgeId);
QueryWrapper queryWrapper = QueryWrapper.create()
.eq(FaqItem::getCollectionId, knowledgeId);
if (question != null && !question.isBlank()) {
queryWrapper.like(FaqItem::getQuestion, question.trim());
}
if (categoryId != null && !categoryId.isBlank()) {
List<BigInteger> descendantIds = faqCategoryService.findDescendantIds(knowledgeId, new BigInteger(categoryId));
if (descendantIds.isEmpty()) {
queryWrapper.eq(FaqItem::getId, BigInteger.ZERO);
} else {
queryWrapper.in(FaqItem::getCategoryId, descendantIds);
}
}
queryWrapper.orderBy("order_no asc");
Page<FaqItem> page = faqItemService.page(new Page<>(pageNumber, pageSize), queryWrapper);
Map<BigInteger, String> pathMap = faqCategoryService.buildPathMap(knowledgeId);
if (page.getRecords() != null) {
for (FaqItem record : page.getRecords()) {
record.setCategoryPath(pathMap.get(record.getCategoryId()));
}
}
return Result.ok(page);
}
/**
* FAQ 详情。
*/
@GetMapping("/faq/detail")
public Result<FaqItem> faqDetail(
@RequestHeader("ApiKey") String apiKey,
@RequestParam BigInteger knowledgeId,
@RequestParam String id,
HttpServletRequest request
) {
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.VIEW.name());
requireFaqKnowledge(knowledgeId);
FaqItem faqItem = requireFaq(new BigInteger(id), knowledgeId);
return Result.ok(faqItem);
}
/**
* 新增 FAQ。
*/
@PostMapping("/faq/save")
public Result<?> saveFaq(
@RequestHeader("ApiKey") String apiKey,
@JsonBody FaqItem entity,
HttpServletRequest request
) {
assertApiShare(apiKey, request.getRequestURI(), entity.getCollectionId(), KnowledgeShareActionScope.CONTENT_CREATE.name());
requireFaqKnowledge(entity.getCollectionId());
audit(apiKey, "API新增FAQ", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", entity.getCollectionId()));
return Result.ok(faqItemService.saveFaqItem(entity));
}
/**
* 更新 FAQ。
*/
@PostMapping("/faq/update")
public Result<?> updateFaq(
@RequestHeader("ApiKey") String apiKey,
@RequestParam BigInteger knowledgeId,
@JsonBody FaqItem entity,
HttpServletRequest request
) {
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.CONTENT_UPDATE.name());
requireFaqKnowledge(knowledgeId);
requireFaq(entity.getId(), knowledgeId);
audit(apiKey, "API更新FAQ", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", knowledgeId, "faqId", entity.getId()));
return Result.ok(faqItemService.updateFaqItem(entity));
}
/**
* 删除 FAQ。
*/
@PostMapping("/faq/remove")
public Result<?> removeFaq(
@RequestHeader("ApiKey") String apiKey,
@RequestParam BigInteger knowledgeId,
@JsonBody("id") BigInteger id,
HttpServletRequest request
) {
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.CONTENT_DELETE.name());
requireFaqKnowledge(knowledgeId);
requireFaq(id, knowledgeId);
audit(apiKey, "API删除FAQ", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", knowledgeId, "faqId", id));
return Result.ok(faqItemService.removeFaqItem(id));
}
/**
* 导入 FAQ Excel。
*/
@PostMapping(value = "/faq/importExcel", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public Result<FaqImportResultVo> importFaqExcel(
@RequestHeader("ApiKey") String apiKey,
MultipartFile file,
BigInteger collectionId,
HttpServletRequest request
) {
assertApiShare(apiKey, request.getRequestURI(), collectionId, KnowledgeShareActionScope.IMPORT_EXPORT.name());
requireFaqKnowledge(collectionId);
audit(apiKey, "API导入FAQ Excel", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", collectionId));
return Result.ok(faqItemService.importFromExcel(collectionId, file));
}
/**
* 下载 FAQ 导入模板。
*/
@GetMapping(value = "/faq/downloadImportTemplate", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public void downloadFaqImportTemplate(
@RequestHeader("ApiKey") String apiKey,
@RequestParam BigInteger knowledgeId,
HttpServletRequest request,
HttpServletResponse response
) throws Exception {
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.IMPORT_EXPORT.name());
requireFaqKnowledge(knowledgeId);
response.setContentType("application/octet-stream");
response.setHeader(
"Content-disposition",
"attachment;filename*=utf-8''" + URLEncoder.encode("faq_import_template.xlsx", StandardCharsets.UTF_8)
);
faqItemService.writeImportTemplate(response.getOutputStream());
response.flushBuffer();
audit(apiKey, "API下载FAQ导入模板", "KNOWLEDGE_API_SHARE_ACCESS", request.getRequestURI(), Map.of("knowledgeId", knowledgeId));
}
/**
* 导出 FAQ Excel。
*/
@GetMapping(value = "/faq/exportExcel", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public void exportFaqExcel(
@RequestHeader("ApiKey") String apiKey,
@RequestParam BigInteger knowledgeId,
HttpServletRequest request,
HttpServletResponse response
) throws Exception {
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.IMPORT_EXPORT.name());
requireFaqKnowledge(knowledgeId);
String fileName = "faq_export_" + new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()) + ".xlsx";
response.setContentType("application/octet-stream");
response.setHeader(
"Content-disposition",
"attachment;filename*=utf-8''" + URLEncoder.encode(fileName, StandardCharsets.UTF_8)
);
faqItemService.exportToExcel(knowledgeId, response.getOutputStream());
response.flushBuffer();
audit(apiKey, "API导出FAQ Excel", "KNOWLEDGE_API_SHARE_ACCESS", request.getRequestURI(), Map.of("knowledgeId", knowledgeId));
}
private void assertApiShare(String apiKey, String requestUri, BigInteger knowledgeId, String actionScope) {
SysApiKey sysApiKey = sysApiKeyService.getSysApiKey(apiKey);
knowledgeSharePermissionService.assertApiShare(sysApiKey.getId(), requestUri, knowledgeId, actionScope);
}
/**
* 断言知识库为文档类型。
*
* @param knowledgeId 知识库ID
* @return 知识库实体
*/
private DocumentCollection requireDocumentKnowledge(BigInteger knowledgeId) {
DocumentCollection knowledge = requireKnowledge(knowledgeId);
if (!knowledge.isDocumentCollection()) {
throw new BusinessException("当前知识库类型不支持文档接口");
}
return knowledge;
}
/**
* 断言知识库为 FAQ 类型。
*
* @param knowledgeId 知识库ID
* @return 知识库实体
*/
private DocumentCollection requireFaqKnowledge(BigInteger knowledgeId) {
DocumentCollection knowledge = requireKnowledge(knowledgeId);
if (!knowledge.isFaqCollection()) {
throw new BusinessException("当前知识库类型不支持FAQ接口");
}
return knowledge;
}
/**
* 获取知识库并保证存在。
*
* @param knowledgeId 知识库ID
* @return 知识库实体
*/
private DocumentCollection requireKnowledge(BigInteger knowledgeId) {
DocumentCollection knowledge = documentCollectionService.getById(knowledgeId);
if (knowledge == null) {
throw new BusinessException("知识库不存在");
}
return knowledge;
}
private Document requireDocument(BigInteger documentId, BigInteger knowledgeId) {
Document document = documentService.getById(documentId);
if (document == null || document.getCollectionId() == null || document.getCollectionId().compareTo(knowledgeId) != 0) {
throw new BusinessException("文档不存在");
}
return document;
}
private DocumentChunk requireDocumentChunk(BigInteger chunkId, BigInteger knowledgeId) {
DocumentChunk chunk = documentChunkService.getById(chunkId);
if (chunk == null || chunk.getDocumentCollectionId() == null || chunk.getDocumentCollectionId().compareTo(knowledgeId) != 0) {
throw new BusinessException("记录不存在");
}
return chunk;
}
private FaqItem requireFaq(BigInteger faqId, BigInteger knowledgeId) {
FaqItem faqItem = faqItemService.getById(faqId);
if (faqItem == null || faqItem.getCollectionId() == null || faqItem.getCollectionId().compareTo(knowledgeId) != 0) {
throw new BusinessException("FAQ不存在");
}
return faqItem;
}
private void audit(String apiKey, String actionName, String actionType, String actionUrl, Map<String, Object> detail) {
SysApiKey sysApiKey = sysApiKeyService.getSysApiKey(apiKey);
Map<String, Object> payload = new HashMap<>(detail);
payload.put("apiKeyId", sysApiKey.getId());
payload.put("channel", "API");
knowledgeShareAuditService.log(null, actionName, actionType, actionUrl, payload);
}
private List<KnowledgeSearchResultItem> toKnowledgeSearchResult(List<com.easyagents.core.document.Document> documents) {
List<KnowledgeSearchResultItem> result = new java.util.ArrayList<>();
for (com.easyagents.core.document.Document document : documents) {
KnowledgeSearchResultItem item = new KnowledgeSearchResultItem();
item.setContent(document.getContent());
item.setScore(document.getScore());
Object hitSource = document.getMetadata("hitSource");
item.setHitSource(hitSource == null ? null : String.valueOf(hitSource));
item.setVectorScore(asDouble(document.getMetadata("vectorScore")));
item.setKeywordScore(asDouble(document.getMetadata("keywordScore")));
result.add(item);
}
return result;
}
private Double asDouble(Object value) {
if (value == null) {
return null;
}
if (value instanceof Number number) {
return number.doubleValue();
}
return Double.parseDouble(String.valueOf(value));
}
}

View File

@@ -1,6 +1,7 @@
package tech.easyflow.publicapi.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.stp.StpUtil;
import com.easyagents.flow.core.chain.ChainDefinition;
import com.easyagents.flow.core.chain.Parameter;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
@@ -18,7 +19,9 @@ import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.jsonbody.JsonBody;
import java.math.BigInteger;
@@ -75,6 +78,12 @@ public class PublicWorkflowController {
if (workflow == null) {
return Result.fail(1, "工作流不存在");
}
if (variables == null) {
variables = new HashMap<>();
}
if (StpUtil.isLogin()) {
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
}
Map<String, Object> res = chainExecutor.executeNode(workflowId.toString(), nodeId, variables);
return Result.ok(res);
}

View File

@@ -83,6 +83,12 @@ public class UcWorkflowController extends BaseCurdController<WorkflowService, Wo
if (workflow == null) {
return Result.fail(1, "工作流不存在");
}
if (variables == null) {
variables = new HashMap<>();
}
if (StpUtil.isLogin()) {
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
}
Map<String, Object> res = chainExecutor.executeNode(workflowId.toString(), nodeId, variables);
return Result.ok(res);
}

View File

@@ -1,6 +1,8 @@
package tech.easyflow.common.mq.redis;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.SmartLifecycle;
import org.springframework.data.domain.Range;
import org.springframework.data.redis.connection.RedisConnection;
@@ -34,6 +36,8 @@ import java.util.concurrent.TimeUnit;
public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifecycle {
private static final Logger LOG = LoggerFactory.getLogger(RedisMQConsumerContainer.class);
private final RedisConnectionFactory redisConnectionFactory;
private final StringRedisTemplate stringRedisTemplate;
private final MQProperties properties;
@@ -71,6 +75,8 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
MQSubscription subscription = handler.subscription();
for (int shard = 0; shard < Math.max(subscription.getShardCount(), 1); shard++) {
int currentShard = shard;
LOG.info("启动 MQ 消费线程: topic={}, group={}, shard={}, handler={}",
subscription.getTopic(), subscription.getConsumerGroup(), currentShard, handler.getClass().getSimpleName());
executorService.submit(() -> consumeLoop(handler, subscription, currentShard));
}
}
@@ -106,6 +112,8 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
String streamKey = keySupport.streamKey(subscription.getTopic(), shard);
String consumerName = subscription.getConsumerGroup() + "-" + shard;
ensureConsumerGroup(streamKey, subscription.getConsumerGroup());
LOG.info("MQ 消费循环已启动: topic={}, group={}, shard={}, consumer={}, streamKey={}, handler={}",
subscription.getTopic(), subscription.getConsumerGroup(), shard, consumerName, streamKey, handler.getClass().getSimpleName());
while (running) {
try {
reclaimPending(streamKey, subscription.getConsumerGroup(), consumerName);
@@ -123,8 +131,18 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
if (messages.isEmpty()) {
continue;
}
LOG.info("MQ 收到消息批次: topic={}, group={}, shard={}, consumer={}, streamKey={}, count={}",
subscription.getTopic(), subscription.getConsumerGroup(), shard, consumerName, streamKey, messages.size());
handleMessages(handler, streamKey, subscription.getConsumerGroup(), messages);
} catch (Exception ignored) {
} catch (Exception exception) {
LOG.error("MQ 消费循环异常: topic={}, group={}, shard={}, consumer={}, streamKey={}, handler={}",
subscription.getTopic(),
subscription.getConsumerGroup(),
shard,
consumerName,
streamKey,
handler.getClass().getSimpleName(),
exception);
sleepSilently(1000L);
}
}
@@ -192,8 +210,12 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
message.setRetryCount(retryCount);
message.getHeaders().put("lastError", reason == null ? "" : reason);
if (retryCount > properties.getRedis().getMaxRetry()) {
LOG.error("MQ 消息超过最大重试次数,进入死信队列: topic={}, messageId={}, streamKey={}, retryCount={}, reason={}",
message.getTopic(), message.getMessageId(), message.getStreamKey(), retryCount, reason);
deadLetterService.deadLetter(message, reason);
} else {
LOG.warn("MQ 消息消费失败,准备重试: topic={}, messageId={}, streamKey={}, retryCount={}, reason={}",
message.getTopic(), message.getMessageId(), message.getStreamKey(), retryCount, reason);
stringRedisTemplate.opsForStream().add(
org.springframework.data.redis.connection.stream.StreamRecords.string(
Map.of("payload", messageConverter.serialize(message))
@@ -205,10 +227,16 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
private void handleMessages(MQConsumerHandler handler, String streamKey, String group, List<MQMessage> messages) throws Exception {
try {
LOG.info("MQ 开始批量处理消息: group={}, streamKey={}, count={}, handler={}",
group, streamKey, messages.size(), handler.getClass().getSimpleName());
handler.handle(messages);
acknowledge(streamKey, group, messages);
LOG.info("MQ 批量处理消息完成: group={}, streamKey={}, count={}, handler={}",
group, streamKey, messages.size(), handler.getClass().getSimpleName());
return;
} catch (Exception batchEx) {
LOG.error("MQ 批量处理消息失败,准备降级单条处理: group={}, streamKey={}, count={}, handler={}",
group, streamKey, messages.size(), handler.getClass().getSimpleName(), batchEx);
if (messages.size() == 1) {
retryOrDeadLetter(messages, resolveReason(batchEx));
acknowledge(streamKey, group, messages);
@@ -218,7 +246,11 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
for (MQMessage message : messages) {
try {
LOG.info("MQ 开始单条处理消息: group={}, streamKey={}, messageId={}, handler={}",
group, streamKey, message.getMessageId(), handler.getClass().getSimpleName());
handler.handle(List.of(message));
LOG.info("MQ 单条处理消息完成: group={}, streamKey={}, messageId={}, handler={}",
group, streamKey, message.getMessageId(), handler.getClass().getSimpleName());
} catch (Exception singleEx) {
retryOrDeadLetter(List.of(message), resolveReason(singleEx));
} finally {
@@ -240,6 +272,7 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
}
MQAcknowledger acknowledger = records -> stringRedisTemplate.opsForStream().acknowledge(streamKey, group, ids);
acknowledger.acknowledge(messages);
LOG.info("MQ 消息确认完成: group={}, streamKey={}, count={}", group, streamKey, ids.length);
}
private String resolveReason(Exception exception) {

View File

@@ -1,5 +1,7 @@
package tech.easyflow.common.mq.redis;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.connection.stream.RecordId;
import org.springframework.data.redis.connection.stream.StreamRecords;
import org.springframework.data.redis.core.StringRedisTemplate;
@@ -15,6 +17,8 @@ import java.util.UUID;
public class RedisMQProducer implements MQProducer {
private static final Logger LOG = LoggerFactory.getLogger(RedisMQProducer.class);
private final StringRedisTemplate stringRedisTemplate;
private final MQProperties properties;
private final MQMessageConverter messageConverter;
@@ -47,12 +51,16 @@ public class RedisMQProducer implements MQProducer {
int shardCount = Math.max(properties.getRedis().getChatPersistShardCount(), 1);
int shard = keySupport.resolveShard(message.getKey(), shardCount);
String streamKey = keySupport.streamKey(message.getTopic(), shard);
LOG.info("MQ 开始投递消息: topic={}, messageId={}, key={}, shard={}, streamKey={}",
message.getTopic(), message.getMessageId(), message.getKey(), shard, streamKey);
RecordId recordId = stringRedisTemplate.opsForStream().add(
StreamRecords.string(Map.of("payload", messageConverter.serialize(message))).withStreamKey(streamKey)
);
if (recordId == null) {
throw new MQException("MQ 消息投递失败");
}
LOG.info("MQ 消息投递完成: topic={}, messageId={}, key={}, shard={}, streamKey={}, recordId={}",
message.getTopic(), message.getMessageId(), message.getKey(), shard, streamKey, recordId.getValue());
return recordId.getValue();
}
}

View File

@@ -34,7 +34,14 @@ public class GlobalErrorResolver implements HandlerExceptionResolver {
} else if (ex instanceof ConstraintViolationException) {
error = Result.fail(400, ex.getMessage());
} else if (ex instanceof BusinessException) {
error = Result.fail(1, ex.getMessage());
String message = ex.getMessage();
if (message != null && message.matches("^\\d{4,}:.+$")) {
int delimiterIndex = message.indexOf(':');
int errorCode = Integer.parseInt(message.substring(0, delimiterIndex));
error = Result.fail(errorCode, message.substring(delimiterIndex + 1));
} else {
error = Result.fail(1, message);
}
} else {
LOG.error(ex.toString(), ex);
error = Result.fail(1, "错误信息:" + ex.getMessage());

View File

@@ -41,6 +41,10 @@
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-support</artifactId>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-document-core</artifactId>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-rag-retrieval</artifactId>
@@ -99,6 +103,10 @@
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-common-chat-protocol</artifactId>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-common-mq</artifactId>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>

View File

@@ -12,8 +12,9 @@ public class ThreadPoolConfig {
private static final Logger log = LoggerFactory.getLogger(ThreadPoolConfig.class);
/**
* SSE消息发送专用线程池
* 核心原则IO密集型任务网络推送线程数 = CPU核心数 * 2 + 1
* 创建 SSE 消息发送线程池
*
* @return SSE 推送线程池
*/
@Bean(name = "sseThreadPool")
public ThreadPoolTaskExecutor sseThreadPool() {
@@ -37,4 +38,29 @@ public class ThreadPoolConfig {
executor.initialize();
return executor;
}
/**
* 创建知识库文档导入任务线程池。
*
* @return 文档导入任务线程池
*/
@Bean(name = "documentImportTaskExecutor")
public ThreadPoolTaskExecutor documentImportTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
int cpuCoreNum = Runtime.getRuntime().availableProcessors();
executor.setCorePoolSize(Math.max(2, cpuCoreNum));
executor.setMaxPoolSize(Math.max(4, cpuCoreNum * 2));
executor.setQueueCapacity(200);
executor.setKeepAliveSeconds(60);
executor.setThreadNamePrefix("document-import-");
executor.setRejectedExecutionHandler((runnable, executorService) -> {
log.error("文档导入线程池过载!核心线程数:{},最大线程数:{},队列任务数:{}",
executorService.getCorePoolSize(),
executorService.getMaximumPoolSize(),
executorService.getQueue().size());
throw new BusinessException("文档导入任务繁忙,请稍后重试");
});
executor.initialize();
return executor;
}
}

View File

@@ -0,0 +1,15 @@
package tech.easyflow.ai.constants;
/**
* 知识库分享错误码定义。
*/
public final class KnowledgeShareErrorCode {
public static final int KNOWLEDGE_SHARE_EXPIRED = 4601;
public static final int KNOWLEDGE_SHARE_INVALID = 4602;
public static final int KNOWLEDGE_SHARE_FORBIDDEN = 4603;
private KnowledgeShareErrorCode() {
}
}

View File

@@ -0,0 +1,81 @@
package tech.easyflow.ai.document.exception;
/**
* 文档解析桥接层异常。
*
* <p>桥接层负责把底层文档服务异常和文件加载异常转换为稳定语义,
* 供上层业务按场景进行处理。</p>
*
* @author Codex
* @since 2026-04-14
*/
public class DocumentParseBridgeException extends RuntimeException {
private final String code;
public DocumentParseBridgeException(String code, String message) {
super(message);
this.code = code;
}
public DocumentParseBridgeException(String code, String message, Throwable cause) {
super(message, cause);
this.code = code;
}
/**
* 获取稳定错误码。
*
* @return 错误码
*/
public String getCode() {
return code;
}
public static DocumentParseBridgeException serviceNotEnabled() {
return new DocumentParseBridgeException(
"service_not_enabled",
"统一文档解析服务未启用,请先配置 easy-agents.document.pdf.provider"
);
}
public static DocumentParseBridgeException unsupportedSource(String message) {
return new DocumentParseBridgeException("unsupported_source", message);
}
public static DocumentParseBridgeException sourceLoadFailed(String message, Throwable cause) {
return new DocumentParseBridgeException("source_load_failed", message, cause);
}
public static DocumentParseBridgeException requestBuildFailed(String message, Throwable cause) {
return new DocumentParseBridgeException("request_build_failed", message, cause);
}
public static DocumentParseBridgeException requestBuildFailed(String message) {
return new DocumentParseBridgeException("request_build_failed", message);
}
public static DocumentParseBridgeException parseFailed(String message, Throwable cause) {
return new DocumentParseBridgeException("parse_failed", message, cause);
}
public static DocumentParseBridgeException parseFailed(String message) {
return new DocumentParseBridgeException("parse_failed", message);
}
public static DocumentParseBridgeException taskFailed(String message, Throwable cause) {
return new DocumentParseBridgeException("task_failed", message, cause);
}
public static DocumentParseBridgeException taskFailed(String message) {
return new DocumentParseBridgeException("task_failed", message);
}
public static DocumentParseBridgeException resultFetchFailed(String message, Throwable cause) {
return new DocumentParseBridgeException("result_fetch_failed", message, cause);
}
public static DocumentParseBridgeException resultFetchFailed(String message) {
return new DocumentParseBridgeException("result_fetch_failed", message);
}
}

View File

@@ -0,0 +1,63 @@
package tech.easyflow.ai.document.model;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 文档解析工件。
*
* @author Codex
* @since 2026-04-14
*/
public class DocumentParseArtifacts {
private Object middleJson;
private Object contentList;
private Object modelOutput;
private Map<String, Object> extraJsonArtifacts = new LinkedHashMap<String, Object>();
private Map<String, byte[]> extraBinaryArtifacts = new LinkedHashMap<String, byte[]>();
public Object getMiddleJson() {
return middleJson;
}
public void setMiddleJson(Object middleJson) {
this.middleJson = middleJson;
}
public Object getContentList() {
return contentList;
}
public void setContentList(Object contentList) {
this.contentList = contentList;
}
public Object getModelOutput() {
return modelOutput;
}
public void setModelOutput(Object modelOutput) {
this.modelOutput = modelOutput;
}
public Map<String, Object> getExtraJsonArtifacts() {
return extraJsonArtifacts;
}
public void setExtraJsonArtifacts(Map<String, Object> extraJsonArtifacts) {
this.extraJsonArtifacts = extraJsonArtifacts == null
? new LinkedHashMap<String, Object>()
: extraJsonArtifacts;
}
public Map<String, byte[]> getExtraBinaryArtifacts() {
return extraBinaryArtifacts;
}
public void setExtraBinaryArtifacts(Map<String, byte[]> extraBinaryArtifacts) {
this.extraBinaryArtifacts = extraBinaryArtifacts == null
? new LinkedHashMap<String, byte[]>()
: extraBinaryArtifacts;
}
}

View File

@@ -0,0 +1,28 @@
package tech.easyflow.ai.document.model;
/**
* 文档解析场景预设。
*
* <p>场景由 easyflow 业务层传入,桥接层负责将场景映射为底层解析请求参数,
* 避免业务模块直接感知多个布尔开关。</p>
*
* @author Codex
* @since 2026-04-14
*/
public enum DocumentParseScenario {
/**
* 工作流文本提取场景,仅要求尽快返回可直接消费的文本结果。
*/
WORKFLOW_TEXT,
/**
* 知识库导入场景,需要保留更多结构化工件供后续分块分析使用。
*/
KNOWLEDGE_IMPORT,
/**
* 尽可能返回完整工件,供后续高级消费场景使用。
*/
FULL_ARTIFACTS
}

View File

@@ -0,0 +1,33 @@
package tech.easyflow.ai.document.model;
/**
* 任务聚合查询结果。
*
* <p>该对象在任务状态基础上按需附带最终解析结果。
* 当任务尚未完成时仅返回状态信息;当任务已完成时可同时返回标准化结果。</p>
*
* @author Codex
* @since 2026-04-14
*/
public class DocumentParseTaskInfo extends DocumentParseTaskStatus {
private DocumentParsedResult result;
/**
* 获取标准化解析结果。
*
* @return 解析结果;任务未完成时可能为空
*/
public DocumentParsedResult getResult() {
return result;
}
/**
* 设置标准化解析结果。
*
* @param result 解析结果
*/
public void setResult(DocumentParsedResult result) {
this.result = result;
}
}

View File

@@ -0,0 +1,113 @@
package tech.easyflow.ai.document.model;
import java.util.ArrayList;
import java.util.List;
/**
* 标准化异步解析任务状态。
*
* @author Codex
* @since 2026-04-14
*/
public class DocumentParseTaskStatus {
private String taskId;
private String status;
private String backend;
private List<String> fileNames = new ArrayList<String>();
private String createdAt;
private String startedAt;
private String completedAt;
private String error;
private String statusUrl;
private String resultUrl;
private Integer queuedAhead;
public String getTaskId() {
return taskId;
}
public void setTaskId(String taskId) {
this.taskId = taskId;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getBackend() {
return backend;
}
public void setBackend(String backend) {
this.backend = backend;
}
public List<String> getFileNames() {
return fileNames;
}
public void setFileNames(List<String> fileNames) {
this.fileNames = fileNames == null ? new ArrayList<String>() : fileNames;
}
public String getCreatedAt() {
return createdAt;
}
public void setCreatedAt(String createdAt) {
this.createdAt = createdAt;
}
public String getStartedAt() {
return startedAt;
}
public void setStartedAt(String startedAt) {
this.startedAt = startedAt;
}
public String getCompletedAt() {
return completedAt;
}
public void setCompletedAt(String completedAt) {
this.completedAt = completedAt;
}
public String getError() {
return error;
}
public void setError(String error) {
this.error = error;
}
public String getStatusUrl() {
return statusUrl;
}
public void setStatusUrl(String statusUrl) {
this.statusUrl = statusUrl;
}
public String getResultUrl() {
return resultUrl;
}
public void setResultUrl(String resultUrl) {
this.resultUrl = resultUrl;
}
public Integer getQueuedAhead() {
return queuedAhead;
}
public void setQueuedAhead(Integer queuedAhead) {
this.queuedAhead = queuedAhead;
}
}

View File

@@ -0,0 +1,120 @@
package tech.easyflow.ai.document.model;
import com.easyagents.document.core.model.DocumentBlock;
import com.easyagents.document.core.model.DocumentImage;
import com.easyagents.document.core.model.DocumentPage;
import com.easyagents.document.core.model.DocumentTable;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 标准化单文档解析结果。
*
* @author Codex
* @since 2026-04-14
*/
public class DocumentParsedResult {
private String fileName;
private String preferredText;
private String markdown;
private String plainText;
private List<DocumentPage> pages = new ArrayList<DocumentPage>();
private List<DocumentBlock> blocks = new ArrayList<DocumentBlock>();
private List<DocumentTable> tables = new ArrayList<DocumentTable>();
private List<DocumentImage> images = new ArrayList<DocumentImage>();
private List<String> warnings = new ArrayList<String>();
private Map<String, Object> metadata = new LinkedHashMap<String, Object>();
private DocumentParseArtifacts artifacts = new DocumentParseArtifacts();
public String getFileName() {
return fileName;
}
public void setFileName(String fileName) {
this.fileName = fileName;
}
public String getPreferredText() {
return preferredText;
}
public void setPreferredText(String preferredText) {
this.preferredText = preferredText;
}
public String getMarkdown() {
return markdown;
}
public void setMarkdown(String markdown) {
this.markdown = markdown;
}
public String getPlainText() {
return plainText;
}
public void setPlainText(String plainText) {
this.plainText = plainText;
}
public List<DocumentPage> getPages() {
return pages;
}
public void setPages(List<DocumentPage> pages) {
this.pages = pages == null ? new ArrayList<DocumentPage>() : pages;
}
public List<DocumentBlock> getBlocks() {
return blocks;
}
public void setBlocks(List<DocumentBlock> blocks) {
this.blocks = blocks == null ? new ArrayList<DocumentBlock>() : blocks;
}
public List<DocumentTable> getTables() {
return tables;
}
public void setTables(List<DocumentTable> tables) {
this.tables = tables == null ? new ArrayList<DocumentTable>() : tables;
}
public List<DocumentImage> getImages() {
return images;
}
public void setImages(List<DocumentImage> images) {
this.images = images == null ? new ArrayList<DocumentImage>() : images;
}
public List<String> getWarnings() {
return warnings;
}
public void setWarnings(List<String> warnings) {
this.warnings = warnings == null ? new ArrayList<String>() : warnings;
}
public Map<String, Object> getMetadata() {
return metadata;
}
public void setMetadata(Map<String, Object> metadata) {
this.metadata = metadata == null ? new LinkedHashMap<String, Object>() : metadata;
}
public DocumentParseArtifacts getArtifacts() {
return artifacts;
}
public void setArtifacts(DocumentParseArtifacts artifacts) {
this.artifacts = artifacts == null ? new DocumentParseArtifacts() : artifacts;
}
}

View File

@@ -0,0 +1,106 @@
package tech.easyflow.ai.document.model;
/**
* 统一文档源引用。
*
* <p>该模型用于屏蔽业务模块和底层解析框架的文件输入差异,业务方只需要描述文件来自哪里,
* 具体由桥接层负责加载字节内容并转成统一解析请求。</p>
*
* @author Codex
* @since 2026-04-14
*/
public class DocumentSourceRef {
private String fileName;
private String filePath;
private String contentType;
private Long size;
private String url;
private byte[] contentBytes;
/**
* 创建基于文件存储路径的文档源。
*
* @param filePath 存储路径
* @return 文档源
*/
public static DocumentSourceRef ofPath(String filePath) {
DocumentSourceRef sourceRef = new DocumentSourceRef();
sourceRef.setFilePath(filePath);
return sourceRef;
}
/**
* 创建基于内存字节的文档源。
*
* @param fileName 文件名
* @param contentBytes 文件字节
* @return 文档源
*/
public static DocumentSourceRef ofBytes(String fileName, byte[] contentBytes) {
DocumentSourceRef sourceRef = new DocumentSourceRef();
sourceRef.setFileName(fileName);
sourceRef.setContentBytes(contentBytes);
return sourceRef;
}
/**
* 创建基于 URL 的文档源。
*
* @param url 文件 URL
* @return 文档源
*/
public static DocumentSourceRef ofUrl(String url) {
DocumentSourceRef sourceRef = new DocumentSourceRef();
sourceRef.setUrl(url);
return sourceRef;
}
public String getFileName() {
return fileName;
}
public void setFileName(String fileName) {
this.fileName = fileName;
}
public String getFilePath() {
return filePath;
}
public void setFilePath(String filePath) {
this.filePath = filePath;
}
public String getContentType() {
return contentType;
}
public void setContentType(String contentType) {
this.contentType = contentType;
}
public Long getSize() {
return size;
}
public void setSize(Long size) {
this.size = size;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public byte[] getContentBytes() {
return contentBytes;
}
public void setContentBytes(byte[] contentBytes) {
this.contentBytes = contentBytes;
}
}

View File

@@ -0,0 +1,67 @@
package tech.easyflow.ai.document.service;
import tech.easyflow.ai.document.model.DocumentParseScenario;
import tech.easyflow.ai.document.model.DocumentSourceRef;
import tech.easyflow.ai.document.model.DocumentParseTaskInfo;
import tech.easyflow.ai.document.model.DocumentParseTaskStatus;
import tech.easyflow.ai.document.model.DocumentParsedResult;
/**
* 统一文档解析桥接门面。
*
* <p>业务模块通过该门面使用文档解析能力,而不是直接依赖 easy-agents 的原始请求和结果模型。</p>
*
* @author Codex
* @since 2026-04-14
*/
public interface DocumentParseBridgeService {
/**
* 同步解析单文档。
*
* @param source 文档源
* @param scenario 解析场景
* @return 标准化解析结果
*/
DocumentParsedResult parse(DocumentSourceRef source, DocumentParseScenario scenario);
/**
* 异步提交单文档解析任务。
*
* @param source 文档源
* @param scenario 解析场景
* @return 异步任务状态
*/
DocumentParseTaskStatus submit(DocumentSourceRef source, DocumentParseScenario scenario);
/**
* 查询异步任务状态。
*
* @param taskId 任务 ID
* @return 异步任务状态
*/
DocumentParseTaskStatus queryTask(String taskId);
/**
* 获取异步任务最终结果。
*
* <p>该方法面向“结果读取”语义,底层 provider 可能在内部等待任务完成后再返回最终结果,
* 因此不适合直接作为轻量状态轮询接口;如果业务需要统一查看“当前状态 + 已完成结果”,
* 应优先使用 {@link #queryTaskInfo(String)}。</p>
*
* @param taskId 任务 ID
* @return 标准化解析结果
*/
DocumentParsedResult queryResult(String taskId);
/**
* 聚合查询异步任务信息。
*
* <p>当任务仍在处理中时仅返回状态;当任务已完成时会附带标准化结果。
* 该方法适合用于页面或业务侧统一读取“当前状态 + 可用结果”。</p>
*
* @param taskId 任务 ID
* @return 聚合任务信息
*/
DocumentParseTaskInfo queryTaskInfo(String taskId);
}

View File

@@ -0,0 +1,224 @@
package tech.easyflow.ai.document.service.impl;
import com.easyagents.document.core.DocumentParseService;
import com.easyagents.document.core.model.ParseResponse;
import com.easyagents.document.core.model.ParseResult;
import com.easyagents.document.core.model.ParseTaskInfo;
import com.easyagents.document.core.model.ParseTaskStatus;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import tech.easyflow.ai.document.exception.DocumentParseBridgeException;
import tech.easyflow.ai.document.model.DocumentParseScenario;
import tech.easyflow.ai.document.model.DocumentSourceRef;
import tech.easyflow.ai.document.model.DocumentParseTaskInfo;
import tech.easyflow.ai.document.model.DocumentParseTaskStatus;
import tech.easyflow.ai.document.model.DocumentParsedResult;
import tech.easyflow.ai.document.service.DocumentParseBridgeService;
import tech.easyflow.ai.document.support.DocumentSourceLoader;
import tech.easyflow.ai.document.support.DocumentParseRequestFactory;
import tech.easyflow.ai.document.support.DocumentParseResultMapper;
import tech.easyflow.ai.document.support.LoadedDocumentSource;
import tech.easyflow.ai.utils.DocUtil;
/**
* 统一文档解析桥接门面默认实现。
*
* @author Codex
* @since 2026-04-14
*/
@Service
public class DocumentParseBridgeServiceImpl implements DocumentParseBridgeService {
private static final Logger LOG = LoggerFactory.getLogger(DocumentParseBridgeServiceImpl.class);
@Nullable
private final DocumentParseService documentParseService;
private final DocumentSourceLoader documentSourceLoader;
private final DocumentParseRequestFactory parseRequestFactory;
private final DocumentParseResultMapper parseResultMapper;
public DocumentParseBridgeServiceImpl(@Nullable DocumentParseService documentParseService,
DocumentSourceLoader documentSourceLoader,
DocumentParseRequestFactory parseRequestFactory,
DocumentParseResultMapper parseResultMapper) {
this.documentParseService = documentParseService;
this.documentSourceLoader = documentSourceLoader;
this.parseRequestFactory = parseRequestFactory;
this.parseResultMapper = parseResultMapper;
}
/**
* {@inheritDoc}
*/
@Override
public DocumentParsedResult parse(DocumentSourceRef source, DocumentParseScenario scenario) {
try {
LoadedDocumentSource loadedSource = prepareSupportedSource(source);
LOG.info("桥接服务开始同步解析文档: fileName={}, contentType={}, scenario={}",
loadedSource.getFileName(), loadedSource.getContentType(), scenario);
ParseResponse response = requireService().parse(parseRequestFactory.build(loadedSource, scenario));
DocumentParsedResult result = parseResultMapper.map(extractSingleResult(response, false));
LOG.info("桥接服务同步解析完成: fileName={}, scenario={}, preferredTextLength={}",
loadedSource.getFileName(), scenario, resolveTextLength(result));
return result;
} catch (DocumentParseBridgeException e) {
LOG.error("桥接服务同步解析失败: fileName={}, scenario={}",
source == null ? null : source.getFileName(), scenario, e);
throw e;
} catch (Exception e) {
LOG.error("桥接服务同步解析异常: fileName={}, scenario={}",
source == null ? null : source.getFileName(), scenario, e);
throw DocumentParseBridgeException.parseFailed("同步文档解析失败", e);
}
}
/**
* {@inheritDoc}
*/
@Override
public DocumentParseTaskStatus submit(DocumentSourceRef source, DocumentParseScenario scenario) {
try {
LoadedDocumentSource loadedSource = prepareSupportedSource(source);
LOG.info("桥接服务开始提交异步解析任务: fileName={}, contentType={}, scenario={}",
loadedSource.getFileName(), loadedSource.getContentType(), scenario);
ParseTaskStatus taskStatus = requireService().submit(parseRequestFactory.build(loadedSource, scenario));
DocumentParseTaskStatus mappedStatus = parseResultMapper.map(taskStatus);
LOG.info("桥接服务异步解析任务提交完成: fileName={}, scenario={}, providerTaskId={}, status={}",
loadedSource.getFileName(), scenario, mappedStatus.getTaskId(), mappedStatus.getStatus());
return mappedStatus;
} catch (DocumentParseBridgeException e) {
LOG.error("桥接服务提交异步解析任务失败: fileName={}, scenario={}",
source == null ? null : source.getFileName(), scenario, e);
throw e;
} catch (Exception e) {
LOG.error("桥接服务提交异步解析任务异常: fileName={}, scenario={}",
source == null ? null : source.getFileName(), scenario, e);
throw DocumentParseBridgeException.taskFailed("提交异步文档解析任务失败", e);
}
}
/**
* {@inheritDoc}
*/
@Override
public DocumentParseTaskStatus queryTask(String taskId) {
if (!StringUtils.hasText(taskId)) {
throw DocumentParseBridgeException.taskFailed("taskId 不能为空");
}
try {
return parseResultMapper.map(requireService().queryTask(taskId));
} catch (DocumentParseBridgeException e) {
throw e;
} catch (Exception e) {
throw DocumentParseBridgeException.taskFailed("查询异步文档解析任务状态失败", e);
}
}
/**
* {@inheritDoc}
*/
@Override
public DocumentParsedResult queryResult(String taskId) {
if (!StringUtils.hasText(taskId)) {
throw DocumentParseBridgeException.resultFetchFailed("taskId 不能为空");
}
try {
LOG.info("桥接服务开始获取异步解析结果: providerTaskId={}", taskId);
ParseResponse response = requireService().queryResult(taskId);
DocumentParsedResult result = parseResultMapper.map(extractSingleResult(response, true));
LOG.info("桥接服务获取异步解析结果完成: providerTaskId={}, preferredTextLength={}",
taskId, resolveTextLength(result));
return result;
} catch (DocumentParseBridgeException e) {
LOG.error("桥接服务获取异步解析结果失败: providerTaskId={}", taskId, e);
throw e;
} catch (Exception e) {
LOG.error("桥接服务获取异步解析结果异常: providerTaskId={}", taskId, e);
throw DocumentParseBridgeException.resultFetchFailed("获取异步文档解析结果失败", e);
}
}
/**
* {@inheritDoc}
*/
@Override
public DocumentParseTaskInfo queryTaskInfo(String taskId) {
if (!StringUtils.hasText(taskId)) {
throw DocumentParseBridgeException.taskFailed("taskId 不能为空");
}
try {
ParseTaskInfo taskInfo = requireService().queryTaskInfo(taskId);
DocumentParseTaskInfo mappedTaskInfo = parseResultMapper.map(taskInfo);
LOG.info("桥接服务查询异步解析任务状态: providerTaskId={}, status={}, hasResult={}",
taskId,
mappedTaskInfo == null ? null : mappedTaskInfo.getStatus(),
mappedTaskInfo != null && mappedTaskInfo.getResult() != null);
return mappedTaskInfo;
} catch (DocumentParseBridgeException e) {
LOG.error("桥接服务查询异步解析任务状态失败: providerTaskId={}", taskId, e);
throw e;
} catch (Exception e) {
LOG.error("桥接服务查询异步解析任务状态异常: providerTaskId={}", taskId, e);
throw DocumentParseBridgeException.taskFailed("聚合查询异步文档解析任务信息失败", e);
}
}
private int resolveTextLength(DocumentParsedResult result) {
String text = result == null ? null : result.getPreferredText();
if (!StringUtils.hasText(text) && result != null) {
text = result.getMarkdown();
}
if (!StringUtils.hasText(text) && result != null) {
text = result.getPlainText();
}
return text == null ? 0 : text.length();
}
private DocumentParseService requireService() {
if (documentParseService == null) {
throw DocumentParseBridgeException.serviceNotEnabled();
}
return documentParseService;
}
private LoadedDocumentSource prepareSupportedSource(DocumentSourceRef source) {
LoadedDocumentSource loadedSource = documentSourceLoader.load(source);
if (!isSupportedByBridge(loadedSource)) {
throw DocumentParseBridgeException.unsupportedSource("统一文档解析桥接当前仅支持 PDF、DOCX 文件");
}
return loadedSource;
}
private boolean isSupportedByBridge(LoadedDocumentSource loadedSource) {
String contentType = loadedSource.getContentType();
if (StringUtils.hasText(contentType)) {
String normalizedContentType = contentType.toLowerCase();
if (normalizedContentType.contains("pdf")
|| normalizedContentType.contains("wordprocessingml.document")) {
return true;
}
}
String fileName = loadedSource.getFileName();
if (!StringUtils.hasText(fileName) || !fileName.contains(".")) {
return false;
}
String suffix = DocUtil.normalizeSuffix(DocUtil.getSuffix(fileName));
if ("pdf".equals(suffix) || "docx".equals(suffix)) {
return true;
}
return false;
}
private ParseResult extractSingleResult(ParseResponse response, boolean resultFetchPhase) {
if (response == null || response.getResults() == null || response.getResults().isEmpty()) {
if (resultFetchPhase) {
throw DocumentParseBridgeException.resultFetchFailed("异步文档解析结果为空");
}
throw DocumentParseBridgeException.parseFailed("同步文档解析结果为空");
}
return response.getResults().get(0);
}
}

View File

@@ -0,0 +1,71 @@
package tech.easyflow.ai.document.support;
import com.easyagents.document.core.model.ParseFile;
import com.easyagents.document.core.model.ParseRequest;
import org.springframework.stereotype.Component;
import tech.easyflow.ai.document.exception.DocumentParseBridgeException;
import tech.easyflow.ai.document.model.DocumentParseScenario;
/**
* easy-agents 解析请求工厂。
*
* <p>负责把 easyflow 业务场景预设映射为底层统一解析请求。</p>
*
* @author Codex
* @since 2026-04-14
*/
@Component
public class DocumentParseRequestFactory {
/**
* 构建统一解析请求。
*
* @param source 已加载文档源
* @param scenario 解析场景
* @return 统一解析请求
*/
public ParseRequest build(LoadedDocumentSource source, DocumentParseScenario scenario) {
if (source == null || source.getContentBytes() == null || source.getContentBytes().length == 0) {
throw DocumentParseBridgeException.requestBuildFailed("文档源内容为空,无法构建解析请求");
}
if (scenario == null) {
throw DocumentParseBridgeException.requestBuildFailed("解析场景不能为空");
}
ParseRequest request = new ParseRequest();
// 保持为空,交由 easy-agents provider 按环境配置回填默认值。
request.setParseMethod(null);
request.setFormulaEnabled(null);
request.setTableEnabled(null);
request.addFile(ParseFile.of(source.getFileName(), source.getContentBytes(), source.getContentType()));
applyScenario(request, scenario);
return request;
}
private void applyScenario(ParseRequest request, DocumentParseScenario scenario) {
switch (scenario) {
case WORKFLOW_TEXT:
request.setReturnMarkdown(Boolean.TRUE);
request.setReturnMiddleJson(Boolean.FALSE);
request.setReturnContentList(Boolean.FALSE);
request.setReturnModelOutput(Boolean.FALSE);
request.setReturnImages(Boolean.FALSE);
break;
case KNOWLEDGE_IMPORT:
request.setReturnMarkdown(Boolean.TRUE);
request.setReturnMiddleJson(Boolean.TRUE);
request.setReturnContentList(Boolean.TRUE);
request.setReturnModelOutput(Boolean.FALSE);
request.setReturnImages(Boolean.TRUE);
break;
case FULL_ARTIFACTS:
request.setReturnMarkdown(Boolean.TRUE);
request.setReturnMiddleJson(Boolean.TRUE);
request.setReturnContentList(Boolean.TRUE);
request.setReturnModelOutput(Boolean.TRUE);
request.setReturnImages(Boolean.TRUE);
break;
default:
throw DocumentParseBridgeException.requestBuildFailed("不支持的文档解析场景: " + scenario);
}
}
}

View File

@@ -0,0 +1,128 @@
package tech.easyflow.ai.document.support;
import com.easyagents.document.core.model.ParseArtifacts;
import com.easyagents.document.core.model.ParseResult;
import com.easyagents.document.core.model.ParseTaskInfo;
import com.easyagents.document.core.model.ParseTaskStatus;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import tech.easyflow.ai.document.model.DocumentParseArtifacts;
import tech.easyflow.ai.document.model.DocumentParseTaskInfo;
import tech.easyflow.ai.document.model.DocumentParseTaskStatus;
import tech.easyflow.ai.document.model.DocumentParsedResult;
/**
* easy-agents 结果映射器。
*
* <p>负责把底层解析结果转换为 easyflow 侧稳定 DTO并统一 preferredText 规则。</p>
*
* @author Codex
* @since 2026-04-14
*/
@Component
public class DocumentParseResultMapper {
/**
* 映射单文件解析结果。
*
* @param parseResult 底层结果
* @return easyflow 结果
*/
public DocumentParsedResult map(ParseResult parseResult) {
DocumentParsedResult document = new DocumentParsedResult();
if (parseResult == null) {
return document;
}
document.setFileName(parseResult.getFileName());
document.setMarkdown(parseResult.getMarkdown());
document.setPlainText(parseResult.getPlainText());
document.setPreferredText(resolvePreferredText(parseResult));
document.setPages(parseResult.getPages());
document.setBlocks(parseResult.getBlocks());
document.setTables(parseResult.getTables());
document.setImages(parseResult.getImages());
document.setWarnings(parseResult.getWarnings());
document.setMetadata(parseResult.getMetadata());
document.setArtifacts(mapArtifacts(parseResult.getArtifacts()));
return document;
}
/**
* 映射异步任务状态。
*
* @param taskStatus 底层任务状态
* @return easyflow 任务状态
*/
public DocumentParseTaskStatus map(ParseTaskStatus taskStatus) {
DocumentParseTaskStatus status = new DocumentParseTaskStatus();
if (taskStatus == null) {
return status;
}
status.setTaskId(taskStatus.getTaskId());
status.setStatus(taskStatus.getStatus());
status.setBackend(taskStatus.getBackend());
status.setFileNames(taskStatus.getFileNames());
status.setCreatedAt(taskStatus.getCreatedAt());
status.setStartedAt(taskStatus.getStartedAt());
status.setCompletedAt(taskStatus.getCompletedAt());
status.setError(taskStatus.getError());
status.setStatusUrl(taskStatus.getStatusUrl());
status.setResultUrl(taskStatus.getResultUrl());
status.setQueuedAhead(taskStatus.getQueuedAhead());
return status;
}
/**
* 映射任务聚合查询结果。
*
* @param taskInfo 底层任务聚合结果
* @return easyflow 聚合任务结果
*/
public DocumentParseTaskInfo map(ParseTaskInfo taskInfo) {
DocumentParseTaskInfo mapped = new DocumentParseTaskInfo();
if (taskInfo == null) {
return mapped;
}
fillTaskStatus(mapped, taskInfo);
if (taskInfo.getResult() != null
&& taskInfo.getResult().getResults() != null
&& !taskInfo.getResult().getResults().isEmpty()) {
mapped.setResult(map(taskInfo.getResult().getResults().get(0)));
}
return mapped;
}
private void fillTaskStatus(DocumentParseTaskStatus status, ParseTaskStatus taskStatus) {
status.setTaskId(taskStatus.getTaskId());
status.setStatus(taskStatus.getStatus());
status.setBackend(taskStatus.getBackend());
status.setFileNames(taskStatus.getFileNames());
status.setCreatedAt(taskStatus.getCreatedAt());
status.setStartedAt(taskStatus.getStartedAt());
status.setCompletedAt(taskStatus.getCompletedAt());
status.setError(taskStatus.getError());
status.setStatusUrl(taskStatus.getStatusUrl());
status.setResultUrl(taskStatus.getResultUrl());
status.setQueuedAhead(taskStatus.getQueuedAhead());
}
private String resolvePreferredText(ParseResult parseResult) {
if (StringUtils.hasText(parseResult.getMarkdown())) {
return parseResult.getMarkdown();
}
return parseResult.getPlainText();
}
private DocumentParseArtifacts mapArtifacts(ParseArtifacts artifacts) {
DocumentParseArtifacts mappedArtifacts = new DocumentParseArtifacts();
if (artifacts == null) {
return mappedArtifacts;
}
mappedArtifacts.setMiddleJson(artifacts.getMiddleJson());
mappedArtifacts.setContentList(artifacts.getContentList());
mappedArtifacts.setModelOutput(artifacts.getModelOutput());
mappedArtifacts.setExtraJsonArtifacts(artifacts.getExtraJsonArtifacts());
mappedArtifacts.setExtraBinaryArtifacts(artifacts.getExtraBinaryArtifacts());
return mappedArtifacts;
}
}

View File

@@ -0,0 +1,146 @@
package tech.easyflow.ai.document.support;
import cn.hutool.http.HttpUtil;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import tech.easyflow.ai.document.exception.DocumentParseBridgeException;
import tech.easyflow.ai.document.model.DocumentSourceRef;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.filestorage.utils.PathGeneratorUtil;
import java.io.IOException;
import java.io.InputStream;
import java.net.URLConnection;
/**
* 文档源加载器。
*
* <p>负责把不同来源的文件引用统一转换为内存字节和标准文件元数据。</p>
*
* @author Codex
* @since 2026-04-14
*/
@Component
public class DocumentSourceLoader {
private final FileStorageService fileStorageService;
public DocumentSourceLoader(@Qualifier("default") FileStorageService fileStorageService) {
this.fileStorageService = fileStorageService;
}
/**
* 加载文档源。
*
* @param sourceRef easyflow 文档源
* @return 内部已加载文档对象
*/
public LoadedDocumentSource load(DocumentSourceRef sourceRef) {
if (sourceRef == null) {
throw DocumentParseBridgeException.unsupportedSource("文档源不能为空");
}
if (hasContentBytes(sourceRef)) {
return buildLoadedSource(
resolveFileName(sourceRef),
resolveContentType(sourceRef, resolveFileName(sourceRef)),
resolveSize(sourceRef, sourceRef.getContentBytes().length),
sourceRef.getContentBytes()
);
}
if (StringUtils.hasText(sourceRef.getFilePath())) {
if (isRemoteUrl(sourceRef.getFilePath())) {
return loadFromRemoteValue(sourceRef, sourceRef.getFilePath());
}
return loadFromFilePath(sourceRef);
}
if (StringUtils.hasText(sourceRef.getUrl())) {
return loadFromUrl(sourceRef);
}
throw DocumentParseBridgeException.unsupportedSource("文档源缺少 filePath、url 或 contentBytes");
}
private LoadedDocumentSource loadFromFilePath(DocumentSourceRef sourceRef) {
String fileName = resolveFileName(sourceRef);
try (InputStream inputStream = fileStorageService.readStream(sourceRef.getFilePath())) {
byte[] contentBytes = inputStream.readAllBytes();
long actualSize = sourceRef.getSize() != null ? sourceRef.getSize() : fileStorageService.getFileSize(sourceRef.getFilePath());
return buildLoadedSource(
fileName,
resolveContentType(sourceRef, fileName),
resolveSize(sourceRef, actualSize),
contentBytes
);
} catch (IOException e) {
throw DocumentParseBridgeException.sourceLoadFailed(
"读取文档存储文件失败: " + sourceRef.getFilePath(),
e
);
}
}
private LoadedDocumentSource loadFromUrl(DocumentSourceRef sourceRef) {
return loadFromRemoteValue(sourceRef, sourceRef.getUrl());
}
private LoadedDocumentSource loadFromRemoteValue(DocumentSourceRef sourceRef, String remoteUrl) {
String fileName = resolveFileName(sourceRef);
try {
byte[] contentBytes = HttpUtil.downloadBytes(remoteUrl);
return buildLoadedSource(
fileName,
resolveContentType(sourceRef, fileName),
resolveSize(sourceRef, contentBytes.length),
contentBytes
);
} catch (Exception e) {
throw DocumentParseBridgeException.sourceLoadFailed(
"下载文档 URL 失败: " + remoteUrl,
e
);
}
}
private LoadedDocumentSource buildLoadedSource(String fileName, String contentType, Long size, byte[] contentBytes) {
LoadedDocumentSource loadedSource = new LoadedDocumentSource();
loadedSource.setFileName(fileName);
loadedSource.setContentType(contentType);
loadedSource.setSize(size);
loadedSource.setContentBytes(contentBytes);
return loadedSource;
}
private String resolveFileName(DocumentSourceRef sourceRef) {
if (StringUtils.hasText(sourceRef.getFileName())) {
return PathGeneratorUtil.getPureFileName(sourceRef.getFileName());
}
if (StringUtils.hasText(sourceRef.getFilePath())) {
return PathGeneratorUtil.getPureFileName(sourceRef.getFilePath());
}
if (StringUtils.hasText(sourceRef.getUrl())) {
String pureName = PathGeneratorUtil.getPureFileName(sourceRef.getUrl());
int queryIndex = pureName.indexOf('?');
return queryIndex >= 0 ? pureName.substring(0, queryIndex) : pureName;
}
throw DocumentParseBridgeException.unsupportedSource("文档源缺少可用文件名");
}
private String resolveContentType(DocumentSourceRef sourceRef, String fileName) {
if (StringUtils.hasText(sourceRef.getContentType())) {
return sourceRef.getContentType();
}
return URLConnection.guessContentTypeFromName(fileName);
}
private Long resolveSize(DocumentSourceRef sourceRef, long fallbackSize) {
return sourceRef.getSize() != null ? sourceRef.getSize() : fallbackSize;
}
private boolean hasContentBytes(DocumentSourceRef sourceRef) {
return sourceRef.getContentBytes() != null && sourceRef.getContentBytes().length > 0;
}
private boolean isRemoteUrl(String value) {
return value.startsWith("http://") || value.startsWith("https://");
}
}

View File

@@ -0,0 +1,49 @@
package tech.easyflow.ai.document.support;
/**
* 桥接层内部已加载文档源。
*
* <p>该对象只在桥接层内部流转,用于承接已解析出的文件名、类型和字节内容。</p>
*
* @author Codex
* @since 2026-04-14
*/
public class LoadedDocumentSource {
private String fileName;
private String contentType;
private Long size;
private byte[] contentBytes;
public String getFileName() {
return fileName;
}
public void setFileName(String fileName) {
this.fileName = fileName;
}
public String getContentType() {
return contentType;
}
public void setContentType(String contentType) {
this.contentType = contentType;
}
public Long getSize() {
return size;
}
public void setSize(Long size) {
this.size = size;
}
public byte[] getContentBytes() {
return contentBytes;
}
public void setContentBytes(byte[] contentBytes) {
this.contentBytes = contentBytes;
}
}

View File

@@ -93,6 +93,7 @@ public final class DocumentImportDtos {
public static class PreviewRequest implements Serializable {
private BigInteger knowledgeId;
private BigInteger documentId;
private List<PreviewFileRequest> files = new ArrayList<PreviewFileRequest>();
public BigInteger getKnowledgeId() {
@@ -103,6 +104,14 @@ public final class DocumentImportDtos {
this.knowledgeId = knowledgeId;
}
public BigInteger getDocumentId() {
return documentId;
}
public void setDocumentId(BigInteger documentId) {
this.documentId = documentId;
}
public List<PreviewFileRequest> getFiles() {
return files;
}
@@ -114,6 +123,7 @@ public final class DocumentImportDtos {
public static class CommitRequest implements Serializable {
private BigInteger knowledgeId;
private BigInteger documentId;
private List<String> previewSessionIds = new ArrayList<String>();
public BigInteger getKnowledgeId() {
@@ -124,6 +134,14 @@ public final class DocumentImportDtos {
this.knowledgeId = knowledgeId;
}
public BigInteger getDocumentId() {
return documentId;
}
public void setDocumentId(BigInteger documentId) {
this.documentId = documentId;
}
public List<String> getPreviewSessionIds() {
return previewSessionIds;
}
@@ -241,16 +259,158 @@ public final class DocumentImportDtos {
}
}
public static class PreviewSourceRange implements Serializable {
private Integer start;
private Integer end;
public Integer getStart() {
return start;
}
public void setStart(Integer start) {
this.start = start;
}
public Integer getEnd() {
return end;
}
public void setEnd(Integer end) {
this.end = end;
}
}
public static class PreviewChunkResult implements Serializable {
private String answer;
private Integer charCount;
private String chunkId;
private String chunkType;
private String content;
private List<String> headingPath = new ArrayList<String>();
private Integer partNo;
private Integer partTotal;
private String question;
private String sourceLabel;
private Integer tokenEstimate;
private List<String> warnings = new ArrayList<String>();
private List<PreviewSourceRange> sourceRanges = new ArrayList<PreviewSourceRange>();
public String getAnswer() {
return answer;
}
public void setAnswer(String answer) {
this.answer = answer;
}
public Integer getCharCount() {
return charCount;
}
public void setCharCount(Integer charCount) {
this.charCount = charCount;
}
public String getChunkId() {
return chunkId;
}
public void setChunkId(String chunkId) {
this.chunkId = chunkId;
}
public String getChunkType() {
return chunkType;
}
public void setChunkType(String chunkType) {
this.chunkType = chunkType;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public List<String> getHeadingPath() {
return headingPath;
}
public void setHeadingPath(List<String> headingPath) {
this.headingPath = headingPath;
}
public Integer getPartNo() {
return partNo;
}
public void setPartNo(Integer partNo) {
this.partNo = partNo;
}
public Integer getPartTotal() {
return partTotal;
}
public void setPartTotal(Integer partTotal) {
this.partTotal = partTotal;
}
public String getQuestion() {
return question;
}
public void setQuestion(String question) {
this.question = question;
}
public String getSourceLabel() {
return sourceLabel;
}
public void setSourceLabel(String sourceLabel) {
this.sourceLabel = sourceLabel;
}
public Integer getTokenEstimate() {
return tokenEstimate;
}
public void setTokenEstimate(Integer tokenEstimate) {
this.tokenEstimate = tokenEstimate;
}
public List<String> getWarnings() {
return warnings;
}
public void setWarnings(List<String> warnings) {
this.warnings = warnings;
}
public List<PreviewSourceRange> getSourceRanges() {
return sourceRanges;
}
public void setSourceRanges(List<PreviewSourceRange> sourceRanges) {
this.sourceRanges = sourceRanges;
}
}
public static class PreviewFileResult implements Serializable {
private String previewSessionId;
private String filePath;
private String fileName;
private String normalizedContent;
private String strategyCode;
private String strategyLabel;
private AnalysisResult analysis;
private Integer totalChunks;
private Integer totalWarnings;
private List<RagChunk> chunks = new ArrayList<RagChunk>();
private List<PreviewChunkResult> chunks = new ArrayList<PreviewChunkResult>();
public String getPreviewSessionId() {
return previewSessionId;
@@ -276,6 +436,14 @@ public final class DocumentImportDtos {
this.fileName = fileName;
}
public String getNormalizedContent() {
return normalizedContent;
}
public void setNormalizedContent(String normalizedContent) {
this.normalizedContent = normalizedContent;
}
public String getStrategyCode() {
return strategyCode;
}
@@ -316,11 +484,11 @@ public final class DocumentImportDtos {
this.totalWarnings = totalWarnings;
}
public List<RagChunk> getChunks() {
public List<PreviewChunkResult> getChunks() {
return chunks;
}
public void setChunks(List<RagChunk> chunks) {
public void setChunks(List<PreviewChunkResult> chunks) {
this.chunks = chunks;
}
}
@@ -454,6 +622,7 @@ public final class DocumentImportDtos {
public static class PreviewSession implements Serializable {
private String sessionId;
private BigInteger knowledgeId;
private BigInteger documentId;
private String filePath;
private String fileName;
private String sourceFormat;
@@ -480,6 +649,14 @@ public final class DocumentImportDtos {
this.knowledgeId = knowledgeId;
}
public BigInteger getDocumentId() {
return documentId;
}
public void setDocumentId(BigInteger documentId) {
this.documentId = documentId;
}
public String getFilePath() {
return filePath;
}
@@ -552,4 +729,265 @@ public final class DocumentImportDtos {
this.createdAt = createdAt;
}
}
public static class TaskCreateRequest implements Serializable {
private BigInteger knowledgeId;
private String filePath;
private String fileName;
public BigInteger getKnowledgeId() {
return knowledgeId;
}
public void setKnowledgeId(BigInteger knowledgeId) {
this.knowledgeId = knowledgeId;
}
public String getFilePath() {
return filePath;
}
public void setFilePath(String filePath) {
this.filePath = filePath;
}
public String getFileName() {
return fileName;
}
public void setFileName(String fileName) {
this.fileName = fileName;
}
}
public static class TaskCreateResponse implements Serializable {
private BigInteger documentId;
private BigInteger taskId;
private String processStatus;
public BigInteger getDocumentId() {
return documentId;
}
public void setDocumentId(BigInteger documentId) {
this.documentId = documentId;
}
public BigInteger getTaskId() {
return taskId;
}
public void setTaskId(BigInteger taskId) {
this.taskId = taskId;
}
public String getProcessStatus() {
return processStatus;
}
public void setProcessStatus(String processStatus) {
this.processStatus = processStatus;
}
}
public static class TaskDetailResponse implements Serializable {
private BigInteger taskId;
private BigInteger documentId;
private BigInteger knowledgeId;
private String phase;
private String status;
private String processStatus;
private Integer progressPercent;
private Integer totalChunks;
private Integer completedChunks;
private Integer failedChunks;
private String providerTaskId;
private String errorSummary;
private Date startedAt;
private Date finishedAt;
public BigInteger getTaskId() {
return taskId;
}
public void setTaskId(BigInteger taskId) {
this.taskId = taskId;
}
public BigInteger getDocumentId() {
return documentId;
}
public void setDocumentId(BigInteger documentId) {
this.documentId = documentId;
}
public BigInteger getKnowledgeId() {
return knowledgeId;
}
public void setKnowledgeId(BigInteger knowledgeId) {
this.knowledgeId = knowledgeId;
}
public String getPhase() {
return phase;
}
public void setPhase(String phase) {
this.phase = phase;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getProcessStatus() {
return processStatus;
}
public void setProcessStatus(String processStatus) {
this.processStatus = processStatus;
}
public Integer getProgressPercent() {
return progressPercent;
}
public void setProgressPercent(Integer progressPercent) {
this.progressPercent = progressPercent;
}
public Integer getTotalChunks() {
return totalChunks;
}
public void setTotalChunks(Integer totalChunks) {
this.totalChunks = totalChunks;
}
public Integer getCompletedChunks() {
return completedChunks;
}
public void setCompletedChunks(Integer completedChunks) {
this.completedChunks = completedChunks;
}
public Integer getFailedChunks() {
return failedChunks;
}
public void setFailedChunks(Integer failedChunks) {
this.failedChunks = failedChunks;
}
public String getProviderTaskId() {
return providerTaskId;
}
public void setProviderTaskId(String providerTaskId) {
this.providerTaskId = providerTaskId;
}
public String getErrorSummary() {
return errorSummary;
}
public void setErrorSummary(String errorSummary) {
this.errorSummary = errorSummary;
}
public Date getStartedAt() {
return startedAt;
}
public void setStartedAt(Date startedAt) {
this.startedAt = startedAt;
}
public Date getFinishedAt() {
return finishedAt;
}
public void setFinishedAt(Date finishedAt) {
this.finishedAt = finishedAt;
}
}
public static class TaskStartIndexRequest implements Serializable {
private BigInteger knowledgeId;
private BigInteger documentId;
private String previewSessionId;
public BigInteger getKnowledgeId() {
return knowledgeId;
}
public void setKnowledgeId(BigInteger knowledgeId) {
this.knowledgeId = knowledgeId;
}
public BigInteger getDocumentId() {
return documentId;
}
public void setDocumentId(BigInteger documentId) {
this.documentId = documentId;
}
public String getPreviewSessionId() {
return previewSessionId;
}
public void setPreviewSessionId(String previewSessionId) {
this.previewSessionId = previewSessionId;
}
}
public static class TaskStartIndexResponse implements Serializable {
private BigInteger taskId;
private String processStatus;
public BigInteger getTaskId() {
return taskId;
}
public void setTaskId(BigInteger taskId) {
this.taskId = taskId;
}
public String getProcessStatus() {
return processStatus;
}
public void setProcessStatus(String processStatus) {
this.processStatus = processStatus;
}
}
public static class TaskRetryRequest implements Serializable {
private BigInteger knowledgeId;
private BigInteger documentId;
public BigInteger getKnowledgeId() {
return knowledgeId;
}
public void setKnowledgeId(BigInteger knowledgeId) {
this.knowledgeId = knowledgeId;
}
public BigInteger getDocumentId() {
return documentId;
}
public void setDocumentId(BigInteger documentId) {
this.documentId = documentId;
}
}
}

View File

@@ -18,4 +18,8 @@ public final class DocumentImportKeys {
public static final String KEY_DOCUMENT_ANALYSIS_SUMMARY = "splitter.analysisSummary";
public static final String KEY_DOCUMENT_SOURCE_FILE_EXT = "splitter.sourceFileExt";
public static final String KEY_DOCUMENT_PREVIEW_VERSION = "splitter.previewVersion";
public static final String KEY_DOCUMENT_PARSE_BACKEND = "parse.backend";
public static final String KEY_DOCUMENT_PARSE_METADATA = "parse.metadata";
public static final String KEY_DOCUMENT_PARSE_WARNINGS = "parse.warnings";
public static final String KEY_DOCUMENT_PROVIDER_TASK_ID = "parse.providerTaskId";
}

View File

@@ -0,0 +1,76 @@
package tech.easyflow.ai.documentimport.task;
import com.alibaba.fastjson2.JSON;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import tech.easyflow.common.mq.config.MQProperties;
import tech.easyflow.common.mq.core.MQConsumerHandler;
import tech.easyflow.common.mq.core.MQMessage;
import tech.easyflow.common.mq.core.MQSubscription;
import java.util.List;
/**
* 文档向量化任务消费者。
*
* @author Codex
* @since 2026-04-14
*/
@Component
public class DocumentImportIndexTaskConsumer implements MQConsumerHandler {
private static final Logger LOG = LoggerFactory.getLogger(DocumentImportIndexTaskConsumer.class);
private final KnowledgeDocumentImportTaskAppService appService;
private final MQProperties mqProperties;
public DocumentImportIndexTaskConsumer(KnowledgeDocumentImportTaskAppService appService,
MQProperties mqProperties) {
this.appService = appService;
this.mqProperties = mqProperties;
}
@Override
public MQSubscription subscription() {
MQSubscription subscription = new MQSubscription();
subscription.setTopic(DocumentImportTaskMqConstants.INDEX_TOPIC);
subscription.setConsumerGroup(DocumentImportTaskMqConstants.INDEX_GROUP);
subscription.setShardCount(resolveShardCount());
return subscription;
}
@Override
public void handle(List<MQMessage> messages) {
LOG.info("文档向量化消费者收到消息批次: count={}", messages == null ? 0 : messages.size());
for (MQMessage message : messages) {
DocumentImportTaskMessage event = JSON.parseObject(message.getBody(), DocumentImportTaskMessage.class);
if (event == null || event.getTaskId() == null) {
LOG.warn("文档向量化消费者跳过非法消息: streamMessageId={}, messageId={}",
message == null ? null : message.getStreamMessageId(),
message == null ? null : message.getMessageId());
continue;
}
LOG.info("文档向量化消费者开始处理消息: taskId={}, messageId={}, streamMessageId={}",
event.getTaskId(), message.getMessageId(), message.getStreamMessageId());
try {
appService.handleIndexTask(event.getTaskId());
LOG.info("文档向量化消费者处理完成: taskId={}, messageId={}, streamMessageId={}",
event.getTaskId(), message.getMessageId(), message.getStreamMessageId());
} catch (Exception exception) {
LOG.error("文档向量化消费者处理失败: taskId={}, messageId={}, streamMessageId={}",
event.getTaskId(), message.getMessageId(), message.getStreamMessageId(), exception);
throw exception;
}
}
}
/**
* 向量化消费者需覆盖生产端的所有分片,避免消息落入未订阅分片。
*
* @return 当前 Redis Stream 分片数
*/
private int resolveShardCount() {
return Math.max(mqProperties.getRedis().getChatPersistShardCount(), 1);
}
}

View File

@@ -0,0 +1,52 @@
package tech.easyflow.ai.documentimport.task;
import com.alibaba.fastjson2.JSON;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import tech.easyflow.common.mq.core.MQMessage;
import tech.easyflow.common.mq.core.MQProducer;
import java.math.BigInteger;
import java.util.Date;
/**
* 文档向量化任务消息生产者。
*
* @author Codex
* @since 2026-04-14
*/
@Service
public class DocumentImportIndexTaskProducer {
private static final Logger LOG = LoggerFactory.getLogger(DocumentImportIndexTaskProducer.class);
private final MQProducer mqProducer;
public DocumentImportIndexTaskProducer(MQProducer mqProducer) {
this.mqProducer = mqProducer;
}
/**
* 发送向量化任务消息。
*
* @param taskId 任务 ID
*/
public void send(BigInteger taskId) {
DocumentImportTaskMessage event = new DocumentImportTaskMessage();
event.setTaskId(taskId);
event.setOccurredAt(new Date());
MQMessage message = new MQMessage();
message.setMessageId("index-" + taskId);
message.setTopic(DocumentImportTaskMqConstants.INDEX_TOPIC);
message.setKey(String.valueOf(taskId));
message.setCreatedAt(event.getOccurredAt());
message.setBody(JSON.toJSONString(event));
LOG.info("准备投递文档向量化 MQ 消息: topic={}, taskId={}, messageId={}",
message.getTopic(), taskId, message.getMessageId());
String recordId = mqProducer.send(message);
LOG.info("文档向量化 MQ 消息投递完成: topic={}, taskId={}, messageId={}, recordId={}",
message.getTopic(), taskId, message.getMessageId(), recordId);
}
}

View File

@@ -0,0 +1,33 @@
package tech.easyflow.ai.documentimport.task;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 知识库文档解析任务收敛器。
*
* <p>该调度器只负责轮询运行中的桥接解析任务,不承担提交任务职责。</p>
*
* @author Codex
* @since 2026-04-15
*/
@Component
public class DocumentImportParseMonitor {
private final KnowledgeDocumentImportTaskAppService appService;
public DocumentImportParseMonitor(KnowledgeDocumentImportTaskAppService appService) {
this.appService = appService;
}
/**
* 定时收敛运行中的桥接解析任务状态。
*/
@Scheduled(
fixedDelayString = "${easyflow.ai.document-import.parse-monitor.fixed-delay:3000}",
initialDelayString = "${easyflow.ai.document-import.parse-monitor.initial-delay:5000}"
)
public void reconcileRunningParseTasks() {
appService.monitorRunningParseTasks();
}
}

View File

@@ -0,0 +1,76 @@
package tech.easyflow.ai.documentimport.task;
import com.alibaba.fastjson2.JSON;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import tech.easyflow.common.mq.config.MQProperties;
import tech.easyflow.common.mq.core.MQConsumerHandler;
import tech.easyflow.common.mq.core.MQMessage;
import tech.easyflow.common.mq.core.MQSubscription;
import java.util.List;
/**
* 文档解析任务消费者。
*
* @author Codex
* @since 2026-04-14
*/
@Component
public class DocumentImportParseTaskConsumer implements MQConsumerHandler {
private static final Logger LOG = LoggerFactory.getLogger(DocumentImportParseTaskConsumer.class);
private final KnowledgeDocumentImportTaskAppService appService;
private final MQProperties mqProperties;
public DocumentImportParseTaskConsumer(KnowledgeDocumentImportTaskAppService appService,
MQProperties mqProperties) {
this.appService = appService;
this.mqProperties = mqProperties;
}
@Override
public MQSubscription subscription() {
MQSubscription subscription = new MQSubscription();
subscription.setTopic(DocumentImportTaskMqConstants.PARSE_TOPIC);
subscription.setConsumerGroup(DocumentImportTaskMqConstants.PARSE_GROUP);
subscription.setShardCount(resolveShardCount());
return subscription;
}
@Override
public void handle(List<MQMessage> messages) {
LOG.info("文档解析消费者收到消息批次: count={}", messages == null ? 0 : messages.size());
for (MQMessage message : messages) {
DocumentImportTaskMessage event = JSON.parseObject(message.getBody(), DocumentImportTaskMessage.class);
if (event == null || event.getTaskId() == null) {
LOG.warn("文档解析消费者跳过非法消息: streamMessageId={}, messageId={}",
message == null ? null : message.getStreamMessageId(),
message == null ? null : message.getMessageId());
continue;
}
LOG.info("文档解析消费者开始处理消息: taskId={}, messageId={}, streamMessageId={}",
event.getTaskId(), message.getMessageId(), message.getStreamMessageId());
try {
appService.handleParseTask(event.getTaskId());
LOG.info("文档解析消费者处理完成: taskId={}, messageId={}, streamMessageId={}",
event.getTaskId(), message.getMessageId(), message.getStreamMessageId());
} catch (Exception exception) {
LOG.error("文档解析消费者处理失败: taskId={}, messageId={}, streamMessageId={}",
event.getTaskId(), message.getMessageId(), message.getStreamMessageId(), exception);
throw exception;
}
}
}
/**
* 解析消费者需覆盖生产端的所有分片,避免消息落入未订阅分片。
*
* @return 当前 Redis Stream 分片数
*/
private int resolveShardCount() {
return Math.max(mqProperties.getRedis().getChatPersistShardCount(), 1);
}
}

View File

@@ -0,0 +1,52 @@
package tech.easyflow.ai.documentimport.task;
import com.alibaba.fastjson2.JSON;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import tech.easyflow.common.mq.core.MQMessage;
import tech.easyflow.common.mq.core.MQProducer;
import java.math.BigInteger;
import java.util.Date;
/**
* 文档解析任务消息生产者。
*
* @author Codex
* @since 2026-04-14
*/
@Service
public class DocumentImportParseTaskProducer {
private static final Logger LOG = LoggerFactory.getLogger(DocumentImportParseTaskProducer.class);
private final MQProducer mqProducer;
public DocumentImportParseTaskProducer(MQProducer mqProducer) {
this.mqProducer = mqProducer;
}
/**
* 发送解析任务消息。
*
* @param taskId 任务 ID
*/
public void send(BigInteger taskId) {
DocumentImportTaskMessage event = new DocumentImportTaskMessage();
event.setTaskId(taskId);
event.setOccurredAt(new Date());
MQMessage message = new MQMessage();
message.setMessageId("parse-" + taskId);
message.setTopic(DocumentImportTaskMqConstants.PARSE_TOPIC);
message.setKey(String.valueOf(taskId));
message.setCreatedAt(event.getOccurredAt());
message.setBody(JSON.toJSONString(event));
LOG.info("准备投递文档解析 MQ 消息: topic={}, taskId={}, messageId={}",
message.getTopic(), taskId, message.getMessageId());
String recordId = mqProducer.send(message);
LOG.info("文档解析 MQ 消息投递完成: topic={}, taskId={}, messageId={}, recordId={}",
message.getTopic(), taskId, message.getMessageId(), recordId);
}
}

View File

@@ -0,0 +1,33 @@
package tech.easyflow.ai.documentimport.task;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.Date;
/**
* 文档导入任务消息。
*
* @author Codex
* @since 2026-04-14
*/
public class DocumentImportTaskMessage implements Serializable {
private BigInteger taskId;
private Date occurredAt;
public BigInteger getTaskId() {
return taskId;
}
public void setTaskId(BigInteger taskId) {
this.taskId = taskId;
}
public Date getOccurredAt() {
return occurredAt;
}
public void setOccurredAt(Date occurredAt) {
this.occurredAt = occurredAt;
}
}

View File

@@ -0,0 +1,18 @@
package tech.easyflow.ai.documentimport.task;
/**
* 文档导入任务 MQ 常量。
*
* @author Codex
* @since 2026-04-14
*/
public final class DocumentImportTaskMqConstants {
private DocumentImportTaskMqConstants() {
}
public static final String PARSE_TOPIC = "knowledge-document-parse";
public static final String PARSE_GROUP = "knowledge-document-parse-group";
public static final String INDEX_TOPIC = "knowledge-document-index";
public static final String INDEX_GROUP = "knowledge-document-index-group";
}

View File

@@ -0,0 +1,156 @@
package tech.easyflow.ai.documentimport.task;
import org.springframework.http.MediaType;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import tech.easyflow.ai.entity.Document;
import tech.easyflow.ai.mapper.DocumentMapper;
import tech.easyflow.common.web.exceptions.BusinessException;
import javax.annotation.Resource;
import java.math.BigInteger;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* 知识库文档任务状态 SSE 推送服务。
*
* @author Codex
* @since 2026-04-15
*/
@Service
public class DocumentImportTaskStatusStreamService {
private static final long SSE_TIMEOUT_MS = Duration.ofMinutes(30).toMillis();
private final Map<String, Set<SseEmitter>> knowledgeEmitters = new ConcurrentHashMap<String, Set<SseEmitter>>();
@Resource
private DocumentMapper documentMapper;
@Resource(name = "sseThreadPool")
private ThreadPoolTaskExecutor sseThreadPool;
/**
* 订阅知识库文档任务状态流。
*
* @param knowledgeId 知识库 ID
* @return SSE 连接
*/
public SseEmitter subscribe(BigInteger knowledgeId) {
if (knowledgeId == null) {
throw new BusinessException("知识库id不能为空");
}
String topicKey = toTopicKey(knowledgeId);
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS);
knowledgeEmitters.computeIfAbsent(topicKey, key -> ConcurrentHashMap.newKeySet()).add(emitter);
emitter.onCompletion(() -> removeEmitter(topicKey, emitter));
emitter.onTimeout(() -> {
removeEmitter(topicKey, emitter);
emitter.complete();
});
emitter.onError(error -> removeEmitter(topicKey, emitter));
sendAsync(topicKey, emitter, "connected", buildConnectedPayload(knowledgeId));
return emitter;
}
/**
* 在事务提交后推送文档任务状态变更。
*
* @param documentId 文档 ID
*/
public void publishAfterCommit(BigInteger documentId) {
if (documentId == null) {
return;
}
Runnable publishAction = () -> publishNow(documentId);
if (TransactionSynchronizationManager.isSynchronizationActive()
&& TransactionSynchronizationManager.isActualTransactionActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
publishAction.run();
}
});
return;
}
publishAction.run();
}
private void publishNow(BigInteger documentId) {
Document document = documentMapper.selectOneById(documentId);
if (document == null || document.getCollectionId() == null) {
return;
}
String topicKey = toTopicKey(document.getCollectionId());
Set<SseEmitter> emitters = knowledgeEmitters.get(topicKey);
if (emitters == null || emitters.isEmpty()) {
return;
}
Map<String, Object> payload = buildDocumentPayload(document);
for (SseEmitter emitter : emitters) {
sendAsync(topicKey, emitter, "document-status", payload);
}
}
private Map<String, Object> buildConnectedPayload(BigInteger knowledgeId) {
Map<String, Object> payload = new LinkedHashMap<String, Object>();
payload.put("knowledgeId", knowledgeId.toString());
payload.put("type", "connected");
return payload;
}
private Map<String, Object> buildDocumentPayload(Document document) {
Map<String, Object> payload = new LinkedHashMap<String, Object>();
payload.put("type", "document-status");
payload.put("knowledgeId", document.getCollectionId() == null ? null : document.getCollectionId().toString());
payload.put("documentId", document.getId() == null ? null : document.getId().toString());
payload.put("processStatus", document.getProcessStatus());
payload.put("progressPercent", document.getProgressPercent());
payload.put("totalChunks", document.getTotalChunks());
payload.put("completedChunks", document.getCompletedChunks());
payload.put("failedChunks", document.getFailedChunks());
payload.put("lastTaskError", document.getLastTaskError());
payload.put("taskModifiedAt", document.getTaskModifiedAt());
return payload;
}
private void sendAsync(String topicKey, SseEmitter emitter, String eventName, Map<String, Object> payload) {
sseThreadPool.execute(() -> {
try {
emitter.send(
SseEmitter.event()
.name(eventName)
.data(payload, MediaType.APPLICATION_JSON)
);
} catch (Exception e) {
removeEmitter(topicKey, emitter);
try {
emitter.completeWithError(e);
} catch (Exception ignored) {
}
}
});
}
private void removeEmitter(String topicKey, SseEmitter emitter) {
Set<SseEmitter> emitters = knowledgeEmitters.get(topicKey);
if (emitters == null) {
return;
}
emitters.remove(emitter);
if (emitters.isEmpty()) {
knowledgeEmitters.remove(topicKey);
}
}
private String toTopicKey(BigInteger knowledgeId) {
return String.valueOf(knowledgeId);
}
}

View File

@@ -0,0 +1,40 @@
package tech.easyflow.ai.dto;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.Set;
/**
* API 分享授权请求。
*/
public class KnowledgeShareApiGrantRequest implements Serializable {
private BigInteger apiKeyId;
private BigInteger knowledgeId;
private Set<String> actionScopes;
public BigInteger getApiKeyId() {
return apiKeyId;
}
public void setApiKeyId(BigInteger apiKeyId) {
this.apiKeyId = apiKeyId;
}
public BigInteger getKnowledgeId() {
return knowledgeId;
}
public void setKnowledgeId(BigInteger knowledgeId) {
this.knowledgeId = knowledgeId;
}
public Set<String> getActionScopes() {
return actionScopes;
}
public void setActionScopes(Set<String> actionScopes) {
this.actionScopes = actionScopes;
}
}

View File

@@ -0,0 +1,75 @@
package tech.easyflow.ai.dto;
import java.io.Serializable;
import java.math.BigInteger;
/**
* 分享页受限配置更新请求。
*/
public class KnowledgeShareLimitedConfigRequest implements Serializable {
private BigInteger knowledgeId;
private BigInteger vectorEmbedModelId;
private BigInteger rerankModelId;
private Boolean rerankEnable;
private Integer docRecallMaxNum;
private Double simThreshold;
private Boolean rebuildVectors;
public BigInteger getKnowledgeId() {
return knowledgeId;
}
public void setKnowledgeId(BigInteger knowledgeId) {
this.knowledgeId = knowledgeId;
}
public BigInteger getVectorEmbedModelId() {
return vectorEmbedModelId;
}
public void setVectorEmbedModelId(BigInteger vectorEmbedModelId) {
this.vectorEmbedModelId = vectorEmbedModelId;
}
public BigInteger getRerankModelId() {
return rerankModelId;
}
public void setRerankModelId(BigInteger rerankModelId) {
this.rerankModelId = rerankModelId;
}
public Boolean getRerankEnable() {
return rerankEnable;
}
public void setRerankEnable(Boolean rerankEnable) {
this.rerankEnable = rerankEnable;
}
public Integer getDocRecallMaxNum() {
return docRecallMaxNum;
}
public void setDocRecallMaxNum(Integer docRecallMaxNum) {
this.docRecallMaxNum = docRecallMaxNum;
}
public Double getSimThreshold() {
return simThreshold;
}
public void setSimThreshold(Double simThreshold) {
this.simThreshold = simThreshold;
}
public Boolean getRebuildVectors() {
return rebuildVectors;
}
public void setRebuildVectors(Boolean rebuildVectors) {
this.rebuildVectors = rebuildVectors;
}
}

View File

@@ -11,6 +11,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Component;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.entity.WorkflowExecResult;
import tech.easyflow.ai.entity.WorkflowExecStep;
@@ -62,7 +63,11 @@ public class ChainEventListenerForSave implements ChainEventListener {
log.info("ChainStartEvent: {}", event);
ChainDefinition definition = chain.getDefinition();
ChainState state = chain.getState();
Workflow workflow = workflowService.getById(definition.getId());
Workflow workflow = resolveWorkflow(definition);
if (workflow == null) {
log.error("ChainStartEvent: workflow not found, definitionId={}", definition.getId());
return;
}
String instanceId = state.getInstanceId();
WorkflowExecResult existed = workflowExecResultService.getByExecKey(instanceId);
if (existed != null) {
@@ -176,4 +181,26 @@ public class ChainEventListenerForSave implements ChainEventListener {
ChainState chainState = chain.getChainStateRepository().load(parentInstanceId);
return findAncestorState(chainState, chain);
}
/**
* 根据定义 ID 解析当前执行所对应的工作流。
* 已发布快照执行会使用 published 前缀,需要先还原为真实工作流 ID。
*/
private Workflow resolveWorkflow(ChainDefinition definition) {
if (definition == null || StrUtil.isBlank(definition.getId())) {
return null;
}
String definitionId = definition.getId();
String workflowId = PublishedWorkflowDefinitionIds.unwrap(definitionId);
try {
java.math.BigInteger id = new java.math.BigInteger(workflowId);
if (PublishedWorkflowDefinitionIds.isPublished(definitionId)) {
return workflowService.getPublishedById(id);
}
return workflowService.getById(id);
} catch (NumberFormatException ex) {
log.error("Unsupported workflow definition id: {}", definitionId, ex);
return null;
}
}
}

View File

@@ -9,7 +9,11 @@ import org.springframework.util.StringUtils;
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckIssue;
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckResult;
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.plugin.workflow.dependency.WorkflowPluginDependencyService;
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
import tech.easyflow.ai.service.PluginItemService;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.datacenter.execution.model.DatacenterSchemaResponse;
@@ -40,6 +44,7 @@ public class WorkflowCheckService {
private static final String TYPE_END = "endNode";
private static final String TYPE_LOOP = "loopNode";
private static final String TYPE_WORKFLOW = "workflow-node";
private static final String TYPE_PLUGIN = "plugin-node";
@Resource
private WorkflowService workflowService;
@@ -47,6 +52,12 @@ public class WorkflowCheckService {
private ChainParser chainParser;
@Resource
private WorkflowDatacenterContentService workflowDatacenterContentService;
@Resource
private WorkflowPluginDependencyService workflowPluginDependencyService;
@Resource
private PluginItemService pluginItemService;
@Resource
private WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver;
public WorkflowCheckResult checkWorkflow(BigInteger workflowId, WorkflowCheckStage stage) {
if (workflowId == null) {
@@ -66,6 +77,9 @@ public class WorkflowCheckService {
List<WorkflowCheckIssue> issues = new ArrayList<>();
Set<String> issueKeys = new LinkedHashSet<>();
ParsedWorkflow parsedWorkflow = parseAndCheckBase(content, issues, issueKeys);
if (parsedWorkflow != null) {
checkPluginSchemaHashes(parsedWorkflow, issues, issueKeys);
}
if (stage == WorkflowCheckStage.PRE_EXECUTE && parsedWorkflow != null) {
runStrictChecks(content, parsedWorkflow, currentWorkflowId, issues, issueKeys);
@@ -394,6 +408,10 @@ public class WorkflowCheckService {
for (NodeView node : parsed.nodes) {
if (!TYPE_WORKFLOW.equals(node.type)) {
if (!TYPE_PLUGIN.equals(node.type)) {
continue;
}
checkPluginWorkflowReference(node, currentWorkflowIdString, currentContent, contentCache, issues, issueKeys);
continue;
}
String workflowId = getWorkflowIdInNode(node);
@@ -510,12 +528,86 @@ public class WorkflowCheckService {
refs.add(workflowId);
}
}
refs.addAll(workflowPluginDependencyService.extractWorkflowIdsFromPluginNodes(content));
} catch (Exception ignored) {
// ignore
}
return refs;
}
private void checkPluginWorkflowReference(NodeView node,
String currentWorkflowIdString,
String currentContent,
Map<String, String> contentCache,
List<WorkflowCheckIssue> issues,
Set<String> issueKeys) {
String pluginWorkflowId = getWorkflowIdInPluginNode(node);
if (!StringUtils.hasText(pluginWorkflowId)) {
addIssue(issues, issueKeys, "PLUGIN_WORKFLOW_REF_NOT_FOUND",
"插件节点未绑定有效工作流插件", node.id, null, node.name);
return;
}
if (StringUtils.hasText(currentWorkflowIdString) && currentWorkflowIdString.equals(pluginWorkflowId)) {
addIssue(issues, issueKeys, "PLUGIN_WORKFLOW_REF_CYCLE",
"插件递归引用:工作流不能通过插件引用自身", node.id, null, node.name);
return;
}
String workflowContent = loadWorkflowContent(pluginWorkflowId, currentWorkflowIdString, currentContent, contentCache);
if (!StringUtils.hasText(workflowContent)) {
addIssue(issues, issueKeys, "PLUGIN_WORKFLOW_REF_NOT_FOUND",
"插件绑定工作流不存在: " + pluginWorkflowId, node.id, null, node.name);
}
}
private void checkPluginSchemaHashes(ParsedWorkflow parsed,
List<WorkflowCheckIssue> issues,
Set<String> issueKeys) {
for (NodeView node : parsed.nodes) {
if (!TYPE_PLUGIN.equals(node.type) || node.data == null) {
continue;
}
String pluginItemId = trimToNull(node.data.getString("pluginId"));
if (!StringUtils.hasText(pluginItemId)) {
continue;
}
String workflowId = workflowPluginDependencyService.resolveWorkflowIdByPluginItemId(pluginItemId);
if (!StringUtils.hasText(workflowId)) {
continue;
}
String latestSchemaHash = resolveLatestPluginSchemaHash(pluginItemId, workflowId);
if (!StringUtils.hasText(latestSchemaHash)) {
continue;
}
String currentSchemaHash = trimToNull(node.data.getString("schemaHash"));
if (!latestSchemaHash.equals(currentSchemaHash)) {
addIssue(issues, issueKeys, "PLUGIN_SCHEMA_OUTDATED",
"当前插件节点绑定工作流的已发布参数契约已更新,请重新选择插件同步节点定义",
node.id, null, node.name);
}
}
}
private String getWorkflowIdInPluginNode(NodeView node) {
if (node == null || node.data == null) {
return null;
}
return workflowPluginDependencyService.resolveWorkflowIdByPluginItemId(
trimToNull(node.data.getString("pluginId"))
);
}
private String resolveLatestPluginSchemaHash(String pluginItemId, String workflowId) {
PluginItem pluginItem = pluginItemService.getById(pluginItemId);
if (pluginItem != null && StringUtils.hasText(pluginItem.getSchemaHash())) {
return pluginItem.getSchemaHash();
}
Workflow workflow = workflowService.getPublishedById(new BigInteger(workflowId));
if (workflow == null) {
return null;
}
return workflowPluginSnapshotResolver.resolveSchemaHash(workflow);
}
private String formatCyclePath(LinkedHashSet<String> visiting, String cycleStart) {
List<String> chain = new ArrayList<>();
boolean started = false;

View File

@@ -29,6 +29,9 @@ public class Bot extends BotBase {
@Column(ignore = true)
private String displayPublishStatus;
@Column(ignore = true)
private String createdByName;
public boolean isAnonymousEnabled() {
Map<String, Object> options = getOptions();
if (options == null) {
@@ -62,4 +65,22 @@ public class Bot extends BotBase {
this.displayPublishStatus = displayPublishStatus;
}
/**
* 获取创建人展示名称。
*
* @return 创建人展示名称
*/
public String getCreatedByName() {
return createdByName;
}
/**
* 设置创建人展示名称。
*
* @param createdByName 创建人展示名称
*/
public void setCreatedByName(String createdByName) {
this.createdByName = createdByName;
}
}

View File

@@ -55,4 +55,16 @@ public class Document extends DocumentBase {
public void setOverlapSize(int overlapSize) {
this.overlapSize = overlapSize;
}
/**
* 获取列表展示时的优先分块数。
*
* @return 分块数
*/
public long getDisplayChunkCount() {
if (getTotalChunks() != null && getTotalChunks() > 0) {
return getTotalChunks();
}
return chunkCount == null ? 0L : chunkCount.longValue();
}
}

View File

@@ -37,6 +37,9 @@ public class DocumentCollection extends DocumentCollectionBase implements Visibi
@Column(ignore = true)
private String displayPublishStatus;
@Column(ignore = true)
private String createdByName;
public static final String TYPE_DOCUMENT = "DOCUMENT";
public static final String TYPE_FAQ = "FAQ";
@@ -169,4 +172,22 @@ public class DocumentCollection extends DocumentCollectionBase implements Visibi
public void setDisplayPublishStatus(String displayPublishStatus) {
this.displayPublishStatus = displayPublishStatus;
}
/**
* 获取创建人展示名称。
*
* @return 创建人展示名称
*/
public String getCreatedByName() {
return createdByName;
}
/**
* 设置创建人展示名称。
*
* @param createdByName 创建人展示名称
*/
public void setCreatedByName(String createdByName) {
this.createdByName = createdByName;
}
}

View File

@@ -0,0 +1,182 @@
package tech.easyflow.ai.entity;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Id;
import com.mybatisflex.annotation.KeyType;
import com.mybatisflex.annotation.Table;
import com.mybatisflex.core.handler.FastjsonTypeHandler;
import tech.easyflow.common.entity.DateEntity;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 知识库文档导入任务。
*
* @author Codex
* @since 2026-04-14
*/
@Table(value = "tb_document_import_task", comment = "知识库文档导入任务")
public class DocumentImportTask extends DateEntity implements Serializable {
@Id(keyType = KeyType.Generator, value = "snowFlakeId")
private BigInteger id;
@Column(comment = "文档ID")
private BigInteger documentId;
@Column(comment = "知识库ID")
private BigInteger knowledgeId;
@Column(comment = "任务阶段")
private String phase;
@Column(comment = "任务状态")
private String status;
@Column(comment = "底层任务ID")
private String providerTaskId;
@Column(typeHandler = FastjsonTypeHandler.class, comment = "任务载荷")
private Map<String, Object> payloadJson;
@Column(comment = "错误摘要")
private String errorSummary;
@Column(comment = "开始时间")
private Date startedAt;
@Column(comment = "结束时间")
private Date finishedAt;
@Column(comment = "创建时间")
private Date created;
@Column(comment = "创建人")
private BigInteger createdBy;
@Column(comment = "修改时间")
private Date modified;
@Column(comment = "修改人")
private BigInteger modifiedBy;
public BigInteger getId() {
return id;
}
public void setId(BigInteger id) {
this.id = id;
}
public BigInteger getDocumentId() {
return documentId;
}
public void setDocumentId(BigInteger documentId) {
this.documentId = documentId;
}
public BigInteger getKnowledgeId() {
return knowledgeId;
}
public void setKnowledgeId(BigInteger knowledgeId) {
this.knowledgeId = knowledgeId;
}
public String getPhase() {
return phase;
}
public void setPhase(String phase) {
this.phase = phase;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getProviderTaskId() {
return providerTaskId;
}
public void setProviderTaskId(String providerTaskId) {
this.providerTaskId = providerTaskId;
}
public Map<String, Object> getPayloadJson() {
return payloadJson == null ? new LinkedHashMap<String, Object>() : payloadJson;
}
public void setPayloadJson(Map<String, Object> payloadJson) {
this.payloadJson = payloadJson == null ? new LinkedHashMap<String, Object>() : payloadJson;
}
public String getErrorSummary() {
return errorSummary;
}
public void setErrorSummary(String errorSummary) {
this.errorSummary = errorSummary;
}
public Date getStartedAt() {
return startedAt;
}
public void setStartedAt(Date startedAt) {
this.startedAt = startedAt;
}
public Date getFinishedAt() {
return finishedAt;
}
public void setFinishedAt(Date finishedAt) {
this.finishedAt = finishedAt;
}
@Override
public Date getCreated() {
return created;
}
@Override
public void setCreated(Date created) {
this.created = created;
}
public BigInteger getCreatedBy() {
return createdBy;
}
public void setCreatedBy(BigInteger createdBy) {
this.createdBy = createdBy;
}
@Override
public Date getModified() {
return modified;
}
@Override
public void setModified(Date modified) {
this.modified = modified;
}
public BigInteger getModifiedBy() {
return modifiedBy;
}
public void setModifiedBy(BigInteger modifiedBy) {
this.modifiedBy = modifiedBy;
}
}

View File

@@ -0,0 +1,69 @@
package tech.easyflow.ai.entity;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Table;
import tech.easyflow.ai.entity.base.KnowledgeShareBase;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Set;
/**
* 知识库分享记录。
*/
@Table("tb_knowledge_share")
public class KnowledgeShare extends KnowledgeShareBase {
@Column(ignore = true)
private String shareUrl;
/**
* 解析授权范围。
*
* @return 授权范围集合
*/
public Set<String> getPermissionScopeSet() {
if (getPermissionSet() == null || getPermissionSet().isBlank()) {
return Collections.emptySet();
}
Set<String> scopes = new LinkedHashSet<>();
String[] segments = getPermissionSet().split(",");
for (String segment : segments) {
if (segment == null || segment.isBlank()) {
continue;
}
scopes.add(segment.trim().toUpperCase());
}
return scopes;
}
/**
* 写入授权范围。
*
* @param scopes 授权范围集合
*/
public void setPermissionScopes(Iterable<String> scopes) {
StringBuilder builder = new StringBuilder();
if (scopes != null) {
for (String scope : scopes) {
if (scope == null || scope.isBlank()) {
continue;
}
if (builder.length() > 0) {
builder.append(',');
}
builder.append(scope.trim().toUpperCase());
}
}
setPermissionSet(builder.toString());
}
public String getShareUrl() {
return shareUrl;
}
public void setShareUrl(String shareUrl) {
this.shareUrl = shareUrl;
}
}

View File

@@ -19,6 +19,21 @@ public class Plugin extends PluginBase {
@RelationOneToMany(selfField = "id", targetField = "pluginId", targetTable = "tb_plugin_item")
private List<PluginItem> tools;
@com.mybatisflex.annotation.Column(ignore = true)
private String workflowTitle;
@com.mybatisflex.annotation.Column(ignore = true)
private Boolean available;
@com.mybatisflex.annotation.Column(ignore = true)
private String reasonCode;
@com.mybatisflex.annotation.Column(ignore = true)
private String reasonMessage;
@com.mybatisflex.annotation.Column(ignore = true)
private String createdByName;
public String getTitle() {
return this.getName();
}
@@ -30,4 +45,54 @@ public class Plugin extends PluginBase {
public void setTools(List<PluginItem> tools) {
this.tools = tools;
}
public String getWorkflowTitle() {
return workflowTitle;
}
public void setWorkflowTitle(String workflowTitle) {
this.workflowTitle = workflowTitle;
}
public Boolean getAvailable() {
return available;
}
public void setAvailable(Boolean available) {
this.available = available;
}
public String getReasonCode() {
return reasonCode;
}
public void setReasonCode(String reasonCode) {
this.reasonCode = reasonCode;
}
public String getReasonMessage() {
return reasonMessage;
}
public void setReasonMessage(String reasonMessage) {
this.reasonMessage = reasonMessage;
}
/**
* 获取创建人展示名称。
*
* @return 创建人展示名称
*/
public String getCreatedByName() {
return createdByName;
}
/**
* 设置创建人展示名称。
*
* @param createdByName 创建人展示名称
*/
public void setCreatedByName(String createdByName) {
this.createdByName = createdByName;
}
}

View File

@@ -26,6 +26,9 @@ public class Workflow extends WorkflowBase implements VisibilityResource {
@Column(ignore = true)
private String displayPublishStatus;
@Column(ignore = true)
private String createdByName;
public Tool toFunction(boolean needEnglishName) {
return new WorkflowTool(this, needEnglishName);
}
@@ -57,4 +60,22 @@ public class Workflow extends WorkflowBase implements VisibilityResource {
public void setDisplayPublishStatus(String displayPublishStatus) {
this.displayPublishStatus = displayPublishStatus;
}
/**
* 获取创建人展示名称。
*
* @return 创建人展示名称
*/
public String getCreatedByName() {
return createdByName;
}
/**
* 设置创建人展示名称。
*
* @param createdByName 创建人展示名称
*/
public void setCreatedByName(String createdByName) {
this.createdByName = createdByName;
}
}

View File

@@ -72,6 +72,48 @@ public class DocumentBase extends DateEntity implements Serializable {
@Column(typeHandler = FastjsonTypeHandler.class, comment = "其他配置项")
private Map<String, Object> options;
/**
* 处理状态
*/
@Column(comment = "处理状态")
private String processStatus;
/**
* 总分块数
*/
@Column(comment = "总分块数")
private Integer totalChunks;
/**
* 已完成分块数
*/
@Column(comment = "已完成分块数")
private Integer completedChunks;
/**
* 失败分块数
*/
@Column(comment = "失败分块数")
private Integer failedChunks;
/**
* 处理进度百分比
*/
@Column(comment = "处理进度百分比")
private Integer progressPercent;
/**
* 最近任务错误摘要
*/
@Column(comment = "最近任务错误摘要")
private String lastTaskError;
/**
* 任务状态更新时间
*/
@Column(comment = "任务状态更新时间")
private Date taskModifiedAt;
/**
* 创建时间
*/
@@ -176,6 +218,62 @@ public class DocumentBase extends DateEntity implements Serializable {
this.options = options;
}
public String getProcessStatus() {
return processStatus;
}
public void setProcessStatus(String processStatus) {
this.processStatus = processStatus;
}
public Integer getTotalChunks() {
return totalChunks;
}
public void setTotalChunks(Integer totalChunks) {
this.totalChunks = totalChunks;
}
public Integer getCompletedChunks() {
return completedChunks;
}
public void setCompletedChunks(Integer completedChunks) {
this.completedChunks = completedChunks;
}
public Integer getFailedChunks() {
return failedChunks;
}
public void setFailedChunks(Integer failedChunks) {
this.failedChunks = failedChunks;
}
public Integer getProgressPercent() {
return progressPercent;
}
public void setProgressPercent(Integer progressPercent) {
this.progressPercent = progressPercent;
}
public String getLastTaskError() {
return lastTaskError;
}
public void setLastTaskError(String lastTaskError) {
this.lastTaskError = lastTaskError;
}
public Date getTaskModifiedAt() {
return taskModifiedAt;
}
public void setTaskModifiedAt(Date taskModifiedAt) {
this.taskModifiedAt = taskModifiedAt;
}
public Date getCreated() {
return created;
}

View File

@@ -0,0 +1,160 @@
package tech.easyflow.ai.entity.base;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Id;
import com.mybatisflex.annotation.KeyType;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.Date;
/**
* 知识库分享记录基础字段。
*/
public class KnowledgeShareBase implements Serializable {
private static final long serialVersionUID = 1L;
@Id(keyType = KeyType.Generator, value = "snowFlakeId", comment = "ID")
private BigInteger id;
@Column(comment = "知识库ID")
private BigInteger knowledgeId;
@Column(comment = "分享类型")
private String shareType;
@Column(comment = "分享访问密钥哈希")
private String shareKeyHash;
@Column(comment = "分享状态")
private String status;
@Column(comment = "授权范围")
private String permissionSet;
@Column(comment = "过期时间")
private Date expiresAt;
@Column(tenantId = true, comment = "租户ID")
private BigInteger tenantId;
@Column(comment = "部门ID")
private BigInteger deptId;
@Column(comment = "创建时间")
private Date created;
@Column(comment = "创建人")
private BigInteger createdBy;
@Column(comment = "修改时间")
private Date modified;
@Column(comment = "修改人")
private BigInteger modifiedBy;
public BigInteger getId() {
return id;
}
public void setId(BigInteger id) {
this.id = id;
}
public BigInteger getKnowledgeId() {
return knowledgeId;
}
public void setKnowledgeId(BigInteger knowledgeId) {
this.knowledgeId = knowledgeId;
}
public String getShareType() {
return shareType;
}
public void setShareType(String shareType) {
this.shareType = shareType;
}
public String getShareKeyHash() {
return shareKeyHash;
}
public void setShareKeyHash(String shareKeyHash) {
this.shareKeyHash = shareKeyHash;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getPermissionSet() {
return permissionSet;
}
public void setPermissionSet(String permissionSet) {
this.permissionSet = permissionSet;
}
public Date getExpiresAt() {
return expiresAt;
}
public void setExpiresAt(Date expiresAt) {
this.expiresAt = expiresAt;
}
public BigInteger getTenantId() {
return tenantId;
}
public void setTenantId(BigInteger tenantId) {
this.tenantId = tenantId;
}
public BigInteger getDeptId() {
return deptId;
}
public void setDeptId(BigInteger deptId) {
this.deptId = deptId;
}
public Date getCreated() {
return created;
}
public void setCreated(Date created) {
this.created = created;
}
public BigInteger getCreatedBy() {
return createdBy;
}
public void setCreatedBy(BigInteger createdBy) {
this.createdBy = createdBy;
}
public Date getModified() {
return modified;
}
public void setModified(Date modified) {
this.modified = modified;
}
public BigInteger getModifiedBy() {
return modifiedBy;
}
public void setModifiedBy(BigInteger modifiedBy) {
this.modifiedBy = modifiedBy;
}
}

View File

@@ -42,6 +42,12 @@ public class PluginBase implements Serializable {
@Column(comment = "类型")
private Integer type;
/**
* 绑定工作流ID
*/
@Column(comment = "绑定工作流ID")
private BigInteger workflowId;
/**
* 基础URL
*/
@@ -148,6 +154,14 @@ public class PluginBase implements Serializable {
this.type = type;
}
public BigInteger getWorkflowId() {
return workflowId;
}
public void setWorkflowId(BigInteger workflowId) {
this.workflowId = workflowId;
}
public String getBaseUrl() {
return baseUrl;
}

View File

@@ -90,6 +90,12 @@ public class PluginItemBase implements Serializable {
@Column(comment = "英文名称")
private String englishName;
/**
* 工作流插件输入输出契约哈希
*/
@Column(comment = "工作流插件输入输出契约哈希")
private String schemaHash;
public BigInteger getId() {
return id;
}
@@ -194,4 +200,12 @@ public class PluginItemBase implements Serializable {
this.englishName = englishName;
}
public String getSchemaHash() {
return schemaHash;
}
public void setSchemaHash(String schemaHash) {
this.schemaHash = schemaHash;
}
}

View File

@@ -0,0 +1,20 @@
package tech.easyflow.ai.enums;
/**
* 文档导入任务阶段。
*
* @author Codex
* @since 2026-04-14
*/
public enum DocumentImportTaskPhase {
/**
* 文档解析阶段。
*/
PARSE,
/**
* 向量化阶段。
*/
INDEX
}

View File

@@ -0,0 +1,30 @@
package tech.easyflow.ai.enums;
/**
* 文档导入任务状态。
*
* @author Codex
* @since 2026-04-14
*/
public enum DocumentImportTaskStatus {
/**
* 已创建,等待执行。
*/
PENDING,
/**
* 正在执行。
*/
RUNNING,
/**
* 执行失败。
*/
FAILED,
/**
* 执行完成。
*/
COMPLETED
}

View File

@@ -0,0 +1,59 @@
package tech.easyflow.ai.enums;
/**
* 文档处理状态。
*
* @author Codex
* @since 2026-04-14
*/
public enum DocumentProcessStatus {
/**
* 已上传,尚未进入异步处理。
*/
UPLOADED,
/**
* 解析中。
*/
PARSING,
/**
* 解析失败。
*/
PARSE_FAILED,
/**
* 可继续配置分块。
*/
READY_FOR_SEGMENT,
/**
* 已确认分块,可开始向量化。
*/
READY_FOR_INDEX,
/**
* 向量化处理中。
*/
INDEXING,
/**
* 向量化失败。
*/
INDEX_FAILED,
/**
* 全流程完成。
*/
COMPLETED;
/**
* 判断当前状态是否属于运行中状态。
*
* @return 是否运行中
*/
public boolean isProcessing() {
return this == PARSING || this == INDEXING;
}
}

View File

@@ -0,0 +1,73 @@
package tech.easyflow.ai.enums;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.Set;
/**
* 知识库分享动作范围。
*/
public enum KnowledgeShareActionScope {
VIEW,
SEARCH,
CONTENT_CREATE,
CONTENT_UPDATE,
CONTENT_DELETE,
IMPORT_EXPORT,
CONFIG_UPDATE;
/**
* 解析前端提交的动作范围集合。
*
* @param values 原始动作范围
* @return 规范化后的动作范围集合
*/
public static Set<String> normalize(Iterable<String> values) {
Set<String> scopes = new LinkedHashSet<>();
if (values == null) {
return scopes;
}
for (String value : values) {
if (value == null || value.isBlank()) {
continue;
}
scopes.add(KnowledgeShareActionScope.valueOf(value.trim().toUpperCase()).name());
}
return scopes;
}
/**
* 获取默认 URL 分享授权范围。
*
* @return 默认授权范围
*/
public static Set<String> defaultUrlScopes() {
return new LinkedHashSet<>(Arrays.asList(
VIEW.name(),
SEARCH.name(),
CONTENT_CREATE.name(),
CONTENT_UPDATE.name(),
CONTENT_DELETE.name(),
IMPORT_EXPORT.name(),
CONFIG_UPDATE.name()
));
}
/**
* 获取默认 API 分享授权范围。
*
* <p>产品上固定开放查看、检索、新增、更新、导入导出,不提供删除能力。</p>
*
* @return 默认 API 授权范围
*/
public static Set<String> defaultApiScopes() {
return new LinkedHashSet<>(Arrays.asList(
VIEW.name(),
SEARCH.name(),
CONTENT_CREATE.name(),
CONTENT_UPDATE.name(),
IMPORT_EXPORT.name()
));
}
}

View File

@@ -0,0 +1,11 @@
package tech.easyflow.ai.enums;
/**
* 知识库分享状态。
*/
public enum KnowledgeShareStatus {
ENABLED,
DISABLED,
REVOKED
}

View File

@@ -0,0 +1,9 @@
package tech.easyflow.ai.enums;
/**
* 知识库分享类型。
*/
public enum KnowledgeShareType {
URL
}

View File

@@ -0,0 +1,52 @@
package tech.easyflow.ai.enums;
/**
* 插件类型枚举。
*/
public enum PluginType {
HTTP(1),
WORKFLOW(2);
private final int code;
PluginType(int code) {
this.code = code;
}
/**
* 获取类型编码。
*
* @return 类型编码
*/
public int getCode() {
return code;
}
/**
* 根据编码解析类型,空值或未知值均按 HTTP 处理,兼容历史数据。
*
* @param code 类型编码
* @return 插件类型
*/
public static PluginType from(Integer code) {
if (code != null) {
for (PluginType value : values()) {
if (value.code == code) {
return value;
}
}
}
return HTTP;
}
/**
* 判断是否为工作流插件。
*
* @param code 类型编码
* @return 是否为工作流插件
*/
public static boolean isWorkflow(Integer code) {
return from(code) == WORKFLOW;
}
}

View File

@@ -0,0 +1,13 @@
package tech.easyflow.ai.mapper;
import com.mybatisflex.core.BaseMapper;
import tech.easyflow.ai.entity.DocumentImportTask;
/**
* 文档导入任务映射层。
*
* @author Codex
* @since 2026-04-14
*/
public interface DocumentImportTaskMapper extends BaseMapper<DocumentImportTask> {
}

View File

@@ -0,0 +1,11 @@
package tech.easyflow.ai.mapper;
import com.mybatisflex.core.BaseMapper;
import tech.easyflow.ai.entity.KnowledgeShare;
/**
* 知识库分享记录映射层。
*/
public interface KnowledgeShareMapper extends BaseMapper<KnowledgeShare> {
}

View File

@@ -4,28 +4,35 @@ import com.easyagents.core.util.StringUtil;
import com.easyagents.flow.core.chain.Chain;
import com.easyagents.flow.core.chain.Parameter;
import com.easyagents.flow.core.node.BaseNode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import tech.easyflow.ai.utils.DocUtil;
import tech.easyflow.common.util.SpringContextUtil;
import java.io.ByteArrayInputStream;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 工作流文件内容提取节点。
*
* <p>节点输入为统一文件对象PDF 交给统一文档解析桥接服务,
* 其他类型继续走默认文档读取器。</p>
*
* @author Codex
* @since 2026-04-14
*/
public class DocNode extends BaseNode {
private static final Logger log = LoggerFactory.getLogger(DocNode.class);
/**
* 执行文件内容提取。
*
* @param chain 当前流程链
* @return 节点输出
*/
@Override
public Map<String, Object> execute(Chain chain) {
Map<String, Object> map = chain.getState().resolveParameters(this);
Map<String, Object> res = new HashMap<>();
String url = map.get("fileUrl").toString();
byte[] bytes = DocUtil.downloadFile(url);
ReaderManager manager = SpringContextUtil.getBean(ReaderManager.class);
String docContent = manager.getReader().read(DocUtil.getFileNameByUrl(url), new ByteArrayInputStream(bytes));
DocNodeFileContentExtractor extractor = SpringContextUtil.getBean(DocNodeFileContentExtractor.class);
String docContent = extractor.extract(map.get("file"));
String key = "content";
List<Parameter> outputDefs = getOutputDefs();

View File

@@ -0,0 +1,170 @@
package tech.easyflow.ai.node;
import cn.hutool.http.HttpUtil;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
import tech.easyflow.ai.document.model.DocumentParseScenario;
import tech.easyflow.ai.document.model.DocumentParsedResult;
import tech.easyflow.ai.document.model.DocumentSourceRef;
import tech.easyflow.ai.document.service.DocumentParseBridgeService;
import tech.easyflow.ai.utils.DocUtil;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.util.StringUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.io.IOException;
import java.io.InputStream;
import java.util.Map;
/**
* {@link DocNode} 文件内容提取器。
*
* <p>负责把工作流运行态中的文件对象转换为统一文档源,并根据文件类型选择
* 统一文档解析桥接服务或默认读取器。</p>
*
* @author Codex
* @since 2026-04-14
*/
@Component
public class DocNodeFileContentExtractor {
private final DocumentParseBridgeService documentParseBridgeService;
private final FileStorageService fileStorageService;
private final ReaderManager readerManager;
/**
* 创建文件内容提取器。
*
* @param documentParseBridgeService 统一文档解析桥接服务
* @param fileStorageService 文件存储服务
* @param readerManager 默认读取器管理器
*/
public DocNodeFileContentExtractor(DocumentParseBridgeService documentParseBridgeService,
@Qualifier("default") FileStorageService fileStorageService,
ReaderManager readerManager) {
this.documentParseBridgeService = documentParseBridgeService;
this.fileStorageService = fileStorageService;
this.readerManager = readerManager;
}
/**
* 提取文件文本内容。
*
* @param fileValue 工作流运行态中的文件对象
* @return 可供下游节点直接消费的文本
*/
public String extract(Object fileValue) {
DocumentSourceRef sourceRef = toDocumentSourceRef(fileValue);
validateSourceRef(sourceRef);
if (isPdf(sourceRef)) {
return extractPdfContent(sourceRef);
}
return extractDefaultContent(sourceRef);
}
/**
* 将运行时文件值转换为统一文档源。
*
* @param fileValue 运行时文件值
* @return 文档源
*/
DocumentSourceRef toDocumentSourceRef(Object fileValue) {
if (fileValue instanceof DocumentSourceRef sourceRef) {
return sourceRef;
}
if (!(fileValue instanceof Map<?, ?> fileMap)) {
throw new BusinessException("文件输入格式不正确,必须为文件对象");
}
DocumentSourceRef sourceRef = new DocumentSourceRef();
sourceRef.setFileName(asText(fileMap.get("fileName")));
sourceRef.setFilePath(asText(fileMap.get("filePath")));
sourceRef.setContentType(asText(fileMap.get("contentType")));
sourceRef.setUrl(asText(fileMap.get("url")));
sourceRef.setSize(asLong(fileMap.get("size")));
return sourceRef;
}
private void validateSourceRef(DocumentSourceRef sourceRef) {
if (sourceRef == null) {
throw new BusinessException("文件输入不能为空");
}
if (!StringUtil.hasText(sourceRef.getFileName())) {
throw new BusinessException("文件输入缺少 fileName");
}
if (!StringUtil.hasText(sourceRef.getFilePath())) {
throw new BusinessException("文件输入缺少 filePath");
}
}
private boolean isPdf(DocumentSourceRef sourceRef) {
if (StringUtil.hasText(sourceRef.getContentType())
&& sourceRef.getContentType().toLowerCase().contains("pdf")) {
return true;
}
String fileName = sourceRef.getFileName();
if (!StringUtil.hasText(fileName) || !fileName.contains(".")) {
return false;
}
return "pdf".equals(DocUtil.normalizeSuffix(DocUtil.getSuffix(fileName)));
}
private String extractPdfContent(DocumentSourceRef sourceRef) {
DocumentParsedResult parsedResult = documentParseBridgeService.parse(sourceRef, DocumentParseScenario.WORKFLOW_TEXT);
String preferredText = parsedResult == null ? null : parsedResult.getPreferredText();
if (StringUtil.hasText(preferredText)) {
return preferredText;
}
if (parsedResult != null && StringUtil.hasText(parsedResult.getMarkdown())) {
return parsedResult.getMarkdown();
}
if (parsedResult != null && StringUtil.hasText(parsedResult.getPlainText())) {
return parsedResult.getPlainText();
}
throw new BusinessException("PDF 文档解析结果为空");
}
private String extractDefaultContent(DocumentSourceRef sourceRef) {
try (InputStream inputStream = openInputStream(sourceRef)) {
return readerManager.getReader().read(sourceRef.getFileName(), inputStream);
} catch (IOException e) {
throw new RuntimeException("读取文件内容失败: " + sourceRef.getFilePath(), e);
}
}
private InputStream openInputStream(DocumentSourceRef sourceRef) throws IOException {
String filePath = sourceRef.getFilePath();
if (StringUtil.hasText(filePath) && isRemoteUrl(filePath)) {
byte[] bytes = HttpUtil.downloadBytes(filePath);
return new java.io.ByteArrayInputStream(bytes);
}
if (StringUtil.hasText(filePath)) {
return fileStorageService.readStream(filePath);
}
if (StringUtil.hasText(sourceRef.getUrl())) {
byte[] bytes = HttpUtil.downloadBytes(sourceRef.getUrl());
return new java.io.ByteArrayInputStream(bytes);
}
throw new IOException("文件输入缺少可读取路径");
}
private boolean isRemoteUrl(String value) {
return value.startsWith("http://") || value.startsWith("https://");
}
private String asText(Object value) {
return value == null ? null : String.valueOf(value).trim();
}
private Long asLong(Object value) {
if (value == null) {
return null;
}
if (value instanceof Number number) {
return number.longValue();
}
if (value instanceof String text && StringUtil.hasText(text)) {
return Long.parseLong(text.trim());
}
return null;
}
}

View File

@@ -4,11 +4,23 @@ import com.easyagents.core.model.chat.tool.Tool;
import com.alibaba.fastjson.JSON;
import com.easyagents.flow.core.chain.Chain;
import com.easyagents.flow.core.node.BaseNode;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.enums.PluginType;
import tech.easyflow.ai.plugin.workflow.availability.WorkflowPluginAvailabilityDecision;
import tech.easyflow.ai.plugin.workflow.availability.WorkflowPluginAvailabilityService;
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
import tech.easyflow.ai.service.PluginService;
import tech.easyflow.ai.service.PluginItemService;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.utils.WorkFlowUtil;
import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.util.SpringContextUtil;
import java.math.BigInteger;
import java.util.LinkedHashMap;
import java.util.Collections;
import java.util.Map;
@@ -32,6 +44,11 @@ public class PluginToolNode extends BaseNode {
if (tool == null) {
return Collections.emptyMap();
}
PluginService pluginService = SpringContextUtil.getBean(PluginService.class);
Plugin plugin = pluginService.getById(tool.getPluginId());
if (plugin != null && PluginType.isWorkflow(plugin.getType())) {
return executeWorkflowPlugin(chain, map, plugin);
}
Tool function = tool.toFunction();
if (function == null) {
return Collections.emptyMap();
@@ -49,6 +66,43 @@ public class PluginToolNode extends BaseNode {
return JSON.parseObject(JSON.toJSONString(result), Map.class);
}
private Map<String, Object> executeWorkflowPlugin(Chain chain, Map<String, Object> map, Plugin plugin) {
WorkflowPluginAvailabilityService availabilityService =
SpringContextUtil.getBean(WorkflowPluginAvailabilityService.class);
LoginAccount operator = WorkFlowUtil.getOperator(chain);
WorkflowPluginAvailabilityDecision decision = availabilityService.evaluate(plugin, operator);
if (!decision.isAvailable()) {
return buildSkippedResult(decision);
}
WorkflowService workflowService = SpringContextUtil.getBean(WorkflowService.class);
Workflow workflow = workflowService.getPublishedById(plugin.getWorkflowId());
if (workflow == null) {
return buildSkippedResult(decision);
}
WorkflowPluginSnapshotResolver snapshotResolver = SpringContextUtil.getBean(WorkflowPluginSnapshotResolver.class);
Map<String, Object> workflowVariables = new LinkedHashMap<>();
workflowVariables.put(Constants.LOGIN_USER_KEY, operator);
if (map != null && !map.isEmpty()) {
workflowVariables.putAll(map);
}
Object result = snapshotResolver.buildWorkflowTool(workflow).invoke(workflowVariables);
if (result == null) {
return Collections.emptyMap();
}
if (result instanceof Map<?, ?> resultMap) {
return (Map<String, Object>) resultMap;
}
return JSON.parseObject(JSON.toJSONString(result), Map.class);
}
private Map<String, Object> buildSkippedResult(WorkflowPluginAvailabilityDecision decision) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("skipped", true);
result.put("reasonCode", decision.getReasonCode());
result.put("reasonMessage", decision.getReasonMessage());
return result;
}
public BigInteger getPluginId() {
return pluginId;
}

View File

@@ -0,0 +1,67 @@
package tech.easyflow.ai.plugin.workflow.availability;
/**
* 工作流插件实时可用性判定结果。
*/
public class WorkflowPluginAvailabilityDecision {
private boolean visible;
private boolean available;
private boolean snapshotPresent;
private String reasonCode;
private String reasonMessage;
private String workflowTitle;
public boolean isVisible() {
return visible;
}
public void setVisible(boolean visible) {
this.visible = visible;
}
public boolean isAvailable() {
return available;
}
public void setAvailable(boolean available) {
this.available = available;
}
public boolean isSnapshotPresent() {
return snapshotPresent;
}
public void setSnapshotPresent(boolean snapshotPresent) {
this.snapshotPresent = snapshotPresent;
}
public String getReasonCode() {
return reasonCode;
}
public void setReasonCode(String reasonCode) {
this.reasonCode = reasonCode;
}
public String getReasonMessage() {
return reasonMessage;
}
public void setReasonMessage(String reasonMessage) {
this.reasonMessage = reasonMessage;
}
public String getWorkflowTitle() {
return workflowTitle;
}
public void setWorkflowTitle(String workflowTitle) {
this.workflowTitle = workflowTitle;
}
}

View File

@@ -0,0 +1,28 @@
package tech.easyflow.ai.plugin.workflow.availability;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.common.entity.LoginAccount;
/**
* 工作流插件可用性判定服务。
*/
public interface WorkflowPluginAvailabilityService {
/**
* 计算当前登录用户视角下的工作流插件可见性与可用性。
*
* @param plugin 插件
* @return 判定结果
*/
WorkflowPluginAvailabilityDecision evaluateForCurrentUser(Plugin plugin);
WorkflowPluginAvailabilityDecision evaluate(Plugin plugin, LoginAccount loginAccount);
/**
* 判断当前用户是否可在管理页继续看到不可用插件。
*
* @param plugin 插件
* @return 是否允许在管理视角保留展示
*/
boolean canViewUnavailableInManagement(Plugin plugin);
}

View File

@@ -0,0 +1,126 @@
package tech.easyflow.ai.plugin.workflow.availability;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.enums.PluginType;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.ResourceAccessService;
import java.math.BigInteger;
import java.util.Map;
/**
* 工作流插件实时可用性判定实现。
*/
@Service
public class WorkflowPluginAvailabilityServiceImpl implements WorkflowPluginAvailabilityService {
private final WorkflowService workflowService;
private final ResourceAccessService resourceAccessService;
private final CategoryPermissionService categoryPermissionService;
private final WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver;
public WorkflowPluginAvailabilityServiceImpl(WorkflowService workflowService,
ResourceAccessService resourceAccessService,
CategoryPermissionService categoryPermissionService,
WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver) {
this.workflowService = workflowService;
this.resourceAccessService = resourceAccessService;
this.categoryPermissionService = categoryPermissionService;
this.workflowPluginSnapshotResolver = workflowPluginSnapshotResolver;
}
/**
* {@inheritDoc}
*/
@Override
public WorkflowPluginAvailabilityDecision evaluateForCurrentUser(Plugin plugin) {
return evaluate(plugin, SaTokenUtil.getLoginAccount());
}
@Override
public WorkflowPluginAvailabilityDecision evaluate(Plugin plugin, LoginAccount loginAccount) {
WorkflowPluginAvailabilityDecision decision = new WorkflowPluginAvailabilityDecision();
decision.setVisible(true);
decision.setAvailable(true);
if (plugin == null || !PluginType.isWorkflow(plugin.getType())) {
return decision;
}
BigInteger workflowId = plugin.getWorkflowId();
if (workflowId == null) {
return unavailable(decision, "WORKFLOW_BINDING_MISSING", "当前插件未绑定工作流");
}
Workflow workflow = workflowService.getById(workflowId);
if (workflow == null) {
return unavailable(decision, "WORKFLOW_NOT_FOUND", "当前插件绑定的工作流不存在");
}
decision.setWorkflowTitle(workflow.getTitle());
Map<String, Object> snapshot = workflow.getPublishedSnapshotJson();
boolean snapshotPresent = !CollectionUtils.isEmpty(snapshot);
decision.setSnapshotPresent(snapshotPresent);
if (!snapshotPresent) {
return unavailable(decision, "WORKFLOW_SNAPSHOT_MISSING", "当前插件绑定工作流没有可用发布快照");
}
PublishStatus publishStatus = PublishStatus.from(workflow.getPublishStatus());
if (!publishStatus.isExternallyVisible()) {
return unavailable(decision, "WORKFLOW_OFFLINE", "当前节点绑定工作流已下线");
}
Workflow publishedWorkflow = workflowService.toPublishedView(workflow);
if (!workflowPluginSnapshotResolver.isSupportedForWorkflowPlugin(publishedWorkflow)) {
return unavailable(decision, "WORKFLOW_MULTI_END_UNSUPPORTED", "当前节点绑定工作流包含多个结束节点,暂不支持作为插件使用");
}
if (!resourceAccessService.canAccess(loginAccount, CategoryResourceType.WORKFLOW, workflow, ResourceAction.USE)) {
return unavailable(decision, "WORKFLOW_NO_PERMISSION", "当前用户无权使用目标工作流");
}
return decision;
}
/**
* {@inheritDoc}
*/
@Override
public boolean canViewUnavailableInManagement(Plugin plugin) {
if (plugin == null) {
return false;
}
if (categoryPermissionService.isCurrentSuperAdmin()) {
return true;
}
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
if (loginAccount == null || loginAccount.getId() == null || plugin.getCreatedBy() == null) {
return false;
}
return loginAccount.getId().equals(BigInteger.valueOf(plugin.getCreatedBy()));
}
/**
* 标记为不可见且不可用。
*
* @param decision 判定结果
* @param reasonCode 原因编码
* @param reasonMessage 原因说明
* @return 判定结果
*/
private WorkflowPluginAvailabilityDecision unavailable(WorkflowPluginAvailabilityDecision decision,
String reasonCode,
String reasonMessage) {
decision.setVisible(false);
decision.setAvailable(false);
decision.setReasonCode(reasonCode);
decision.setReasonMessage(reasonMessage);
return decision;
}
}

View File

@@ -0,0 +1,34 @@
package tech.easyflow.ai.plugin.workflow.binding;
import tech.easyflow.ai.entity.Plugin;
import java.math.BigInteger;
/**
* 工作流插件绑定服务。
*/
public interface WorkflowPluginBindingService {
/**
* 创建工作流插件并生成系统维护工具。
*
* @param plugin 插件
* @return 已保存插件
*/
Plugin saveWorkflowPlugin(Plugin plugin);
/**
* 更新工作流插件并刷新系统维护工具。
*
* @param plugin 插件
* @return 是否更新成功
*/
boolean updateWorkflowPlugin(Plugin plugin);
/**
* 同步某个工作流关联的所有工作流插件。
*
* @param workflowId 工作流 ID
*/
void syncByWorkflowId(BigInteger workflowId);
}

View File

@@ -0,0 +1,227 @@
package tech.easyflow.ai.plugin.workflow.binding;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.enums.PluginType;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.mapper.PluginItemMapper;
import tech.easyflow.ai.mapper.PluginMapper;
import tech.easyflow.ai.plugin.workflow.dependency.WorkflowPluginDependencyService;
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.ResourceAccessService;
import java.math.BigInteger;
import java.util.Date;
import java.util.List;
/**
* 工作流插件绑定服务实现。
*/
@Service
public class WorkflowPluginBindingServiceImpl implements WorkflowPluginBindingService {
private final PluginMapper pluginMapper;
private final PluginItemMapper pluginItemMapper;
private final WorkflowService workflowService;
private final ResourceAccessService resourceAccessService;
private final WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver;
private final WorkflowPluginDependencyService workflowPluginDependencyService;
public WorkflowPluginBindingServiceImpl(PluginMapper pluginMapper,
PluginItemMapper pluginItemMapper,
WorkflowService workflowService,
ResourceAccessService resourceAccessService,
WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver,
WorkflowPluginDependencyService workflowPluginDependencyService) {
this.pluginMapper = pluginMapper;
this.pluginItemMapper = pluginItemMapper;
this.workflowService = workflowService;
this.resourceAccessService = resourceAccessService;
this.workflowPluginSnapshotResolver = workflowPluginSnapshotResolver;
this.workflowPluginDependencyService = workflowPluginDependencyService;
}
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Plugin saveWorkflowPlugin(Plugin plugin) {
LoginAccount loginAccount = requireLogin();
Workflow workflow = requirePublishedWorkflow(plugin.getWorkflowId(), "无权限绑定工作流");
normalizeWorkflowPlugin(plugin, loginAccount);
int insert = pluginMapper.insert(plugin);
if (insert <= 0) {
throw new BusinessException("保存失败");
}
PluginItem pluginItem = new PluginItem();
pluginItem.setCreated(new Date());
workflowPluginSnapshotResolver.syncPluginItemFromPublishedWorkflow(plugin, pluginItem, workflow.getId());
if (pluginItemMapper.insert(pluginItem) <= 0) {
throw new BusinessException("保存工作流插件工具失败");
}
return plugin;
}
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public boolean updateWorkflowPlugin(Plugin plugin) {
if (plugin.getId() == null) {
throw new BusinessException("插件ID不能为空");
}
Plugin existed = pluginMapper.selectOneById(plugin.getId());
if (existed == null) {
throw new BusinessException("插件不存在");
}
if (PluginType.from(existed.getType()) != PluginType.WORKFLOW) {
throw new BusinessException("暂不支持在现有 HTTP 插件与工作流插件之间切换类型");
}
Workflow workflow = requirePublishedWorkflow(plugin.getWorkflowId(), "无权限绑定工作流");
if (workflowPluginDependencyService.containsPluginReferenceTransitivelyInPublishedSnapshot(workflow.getId(), existed.getId())) {
throw new BusinessException("目标工作流已通过子流程或插件链路引用当前插件,无法形成递归绑定");
}
normalizeWorkflowPlugin(plugin, null);
int updated = pluginMapper.update(plugin);
if (updated <= 0) {
throw new BusinessException("更新失败");
}
syncSinglePlugin(existed.getId(), workflow.getId());
return true;
}
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void syncByWorkflowId(BigInteger workflowId) {
if (workflowId == null) {
return;
}
QueryWrapper wrapper = QueryWrapper.create()
.eq(Plugin::getWorkflowId, workflowId)
.eq(Plugin::getType, PluginType.WORKFLOW.getCode());
List<Plugin> plugins = pluginMapper.selectListByQuery(wrapper);
for (Plugin plugin : plugins) {
syncSinglePlugin(plugin.getId(), workflowId);
}
}
private void syncSinglePlugin(BigInteger pluginId, BigInteger workflowId) {
Plugin plugin = pluginMapper.selectOneById(pluginId);
if (plugin == null) {
return;
}
Workflow workflow = requirePublishedWorkflowForSync(workflowId);
PluginItem pluginItem = getOrCreateSystemTool(pluginId);
workflowPluginSnapshotResolver.syncPluginItemFromPublishedWorkflow(plugin, pluginItem, workflow.getId());
if (pluginItem.getId() == null) {
pluginItem.setCreated(new Date());
if (pluginItemMapper.insert(pluginItem) <= 0) {
throw new BusinessException("同步工作流插件工具失败");
}
} else if (pluginItemMapper.update(pluginItem) <= 0) {
throw new BusinessException("同步工作流插件工具失败");
}
}
private PluginItem getOrCreateSystemTool(BigInteger pluginId) {
QueryWrapper wrapper = QueryWrapper.create().eq(PluginItem::getPluginId, pluginId);
List<PluginItem> pluginItems = pluginItemMapper.selectListByQuery(wrapper);
if (pluginItems == null || pluginItems.isEmpty()) {
PluginItem pluginItem = new PluginItem();
pluginItem.setPluginId(pluginId);
return pluginItem;
}
PluginItem pluginItem = pluginItems.get(0);
if (pluginItems.size() > 1) {
for (int i = 1; i < pluginItems.size(); i++) {
pluginItemMapper.deleteById(pluginItems.get(i).getId());
}
}
return pluginItem;
}
private Workflow requirePublishedWorkflow(BigInteger workflowId, String denyMessage) {
if (workflowId == null) {
throw new BusinessException("请选择已发布工作流");
}
Workflow workflow = workflowService.getById(workflowId);
if (workflow == null) {
throw new BusinessException("工作流不存在");
}
resourceAccessService.assertAccess(CategoryResourceType.WORKFLOW, workflow, ResourceAction.USE, denyMessage);
PublishStatus publishStatus = PublishStatus.from(workflow.getPublishStatus());
if (publishStatus != PublishStatus.PUBLISHED) {
throw new BusinessException("仅已发布工作流可被封装为插件");
}
if (workflow.getPublishedSnapshotJson() == null || workflow.getPublishedSnapshotJson().isEmpty()) {
throw new BusinessException("目标工作流缺少已发布快照");
}
workflowPluginSnapshotResolver.assertSupportedForWorkflowPlugin(workflowService.toPublishedView(workflow));
return workflow;
}
private Workflow requirePublishedWorkflowForSync(BigInteger workflowId) {
if (workflowId == null) {
throw new BusinessException("目标工作流不存在");
}
Workflow workflow = workflowService.getById(workflowId);
if (workflow == null) {
throw new BusinessException("目标工作流不存在");
}
PublishStatus publishStatus = PublishStatus.from(workflow.getPublishStatus());
if (publishStatus != PublishStatus.PUBLISHED) {
throw new BusinessException("仅已发布工作流可同步到工作流插件");
}
if (workflow.getPublishedSnapshotJson() == null || workflow.getPublishedSnapshotJson().isEmpty()) {
throw new BusinessException("目标工作流缺少已发布快照");
}
workflowPluginSnapshotResolver.assertSupportedForWorkflowPlugin(workflowService.toPublishedView(workflow));
return workflow;
}
private void normalizeWorkflowPlugin(Plugin plugin, LoginAccount loginAccount) {
plugin.setType(PluginType.WORKFLOW.getCode());
plugin.setBaseUrl(null);
plugin.setAuthType(null);
plugin.setPosition(null);
plugin.setHeaders(null);
plugin.setTokenKey(null);
plugin.setTokenValue(null);
if (plugin.getCreated() == null) {
plugin.setCreated(new Date());
}
if (loginAccount != null) {
plugin.setCreatedBy(loginAccount.getId().longValue());
plugin.setDeptId(loginAccount.getDeptId() == null ? null : loginAccount.getDeptId().longValue());
plugin.setTenantId(loginAccount.getTenantId() == null ? null : loginAccount.getTenantId().longValue());
}
if (!StringUtils.hasText(plugin.getAlias())) {
plugin.setAlias(null);
}
}
private LoginAccount requireLogin() {
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
if (loginAccount == null || loginAccount.getId() == null) {
throw new BusinessException("当前未登录");
}
return loginAccount;
}
}

View File

@@ -0,0 +1,73 @@
package tech.easyflow.ai.plugin.workflow.dependency;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.vo.OfflineImpactBindingVo;
import java.math.BigInteger;
import java.util.List;
import java.util.Set;
/**
* 工作流插件依赖分析服务。
*/
public interface WorkflowPluginDependencyService {
/**
* 查询被某个工作流引用的插件列表。
*
* @param workflowId 工作流 ID
* @return 插件引用列表
*/
List<OfflineImpactBindingVo> listPluginsByWorkflowId(BigInteger workflowId);
/**
* 解析工作流内容中通过插件间接引用到的工作流 ID。
*
* @param content 工作流内容
* @return 引用到的工作流 ID 集合
*/
Set<String> extractWorkflowIdsFromPluginNodes(String content);
/**
* 判断工作流内容是否引用了指定插件。
*
* @param content 工作流内容
* @param pluginId 插件 ID
* @return 是否引用
*/
boolean containsPluginReference(String content, BigInteger pluginId);
/**
* 判断某个工作流是否经由子流程/工作流插件链路递归引用了指定插件。
*
* @param workflowId 工作流 ID
* @param pluginId 插件 ID
* @return 是否存在递归引用
*/
boolean containsPluginReferenceTransitively(BigInteger workflowId, BigInteger pluginId);
/**
* 判断某个工作流的已发布快照是否经由子流程/工作流插件链路递归引用了指定插件。
*
* @param workflowId 工作流 ID
* @param pluginId 插件 ID
* @return 是否存在递归引用
*/
boolean containsPluginReferenceTransitivelyInPublishedSnapshot(BigInteger workflowId, BigInteger pluginId);
/**
* 查询工作流插件。
*
* @param pluginId 插件 ID
* @return 插件
*/
Plugin getWorkflowPlugin(BigInteger pluginId);
/**
* 根据插件工具 ID 解析目标工作流 ID。
*
* @param pluginItemId 插件工具 ID
* @return 工作流 ID
*/
String resolveWorkflowIdByPluginItemId(String pluginItemId);
}

View File

@@ -0,0 +1,334 @@
package tech.easyflow.ai.plugin.workflow.dependency;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.enums.PluginType;
import tech.easyflow.ai.mapper.PluginItemMapper;
import tech.easyflow.ai.mapper.PluginMapper;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.vo.OfflineImpactBindingVo;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 工作流插件依赖分析实现。
*/
@Service
public class WorkflowPluginDependencyServiceImpl implements WorkflowPluginDependencyService {
private static final String TYPE_PLUGIN = "plugin-node";
private static final String TYPE_WORKFLOW = "workflow-node";
private final PluginMapper pluginMapper;
private final PluginItemMapper pluginItemMapper;
private final WorkflowService workflowService;
public WorkflowPluginDependencyServiceImpl(PluginMapper pluginMapper,
PluginItemMapper pluginItemMapper,
WorkflowService workflowService) {
this.pluginMapper = pluginMapper;
this.pluginItemMapper = pluginItemMapper;
this.workflowService = workflowService;
}
/**
* {@inheritDoc}
*/
@Override
public List<OfflineImpactBindingVo> listPluginsByWorkflowId(BigInteger workflowId) {
if (workflowId == null) {
return Collections.emptyList();
}
QueryWrapper wrapper = QueryWrapper.create().eq(Plugin::getWorkflowId, workflowId);
List<Plugin> plugins = pluginMapper.selectListByQuery(wrapper);
if (plugins == null || plugins.isEmpty()) {
return Collections.emptyList();
}
List<OfflineImpactBindingVo> result = new ArrayList<>(plugins.size());
for (Plugin plugin : plugins) {
if (!PluginType.isWorkflow(plugin.getType())) {
continue;
}
OfflineImpactBindingVo vo = new OfflineImpactBindingVo();
vo.setId(plugin.getId());
vo.setTitle(plugin.getName());
result.add(vo);
}
return result;
}
/**
* {@inheritDoc}
*/
@Override
public Set<String> extractWorkflowIdsFromPluginNodes(String content) {
if (!StringUtils.hasText(content)) {
return Collections.emptySet();
}
Set<String> workflowIds = new LinkedHashSet<>();
try {
Object parsed = JSON.parse(content);
if (!(parsed instanceof JSONObject root)) {
return workflowIds;
}
JSONArray nodes = root.getJSONArray("nodes");
if (nodes == null) {
return workflowIds;
}
for (int i = 0; i < nodes.size(); i++) {
JSONObject node = nodes.getJSONObject(i);
if (node == null || !TYPE_PLUGIN.equals(node.getString("type"))) {
continue;
}
JSONObject data = node.getJSONObject("data");
String pluginItemId = data == null ? null : data.getString("pluginId");
if (!StringUtils.hasText(pluginItemId)) {
continue;
}
Plugin plugin = getPluginByPluginItemId(pluginItemId);
if (plugin == null || !PluginType.isWorkflow(plugin.getType()) || plugin.getWorkflowId() == null) {
continue;
}
workflowIds.add(String.valueOf(plugin.getWorkflowId()));
}
} catch (Exception ignored) {
// ignore
}
return workflowIds;
}
/**
* {@inheritDoc}
*/
@Override
public boolean containsPluginReference(String content, BigInteger pluginId) {
if (!StringUtils.hasText(content) || pluginId == null) {
return false;
}
try {
Object parsed = JSON.parse(content);
if (!(parsed instanceof JSONObject root)) {
return false;
}
JSONArray nodes = root.getJSONArray("nodes");
if (nodes == null) {
return false;
}
String expected = String.valueOf(pluginId);
for (int i = 0; i < nodes.size(); i++) {
JSONObject node = nodes.getJSONObject(i);
if (node == null || !TYPE_PLUGIN.equals(node.getString("type"))) {
continue;
}
JSONObject data = node.getJSONObject("data");
String pluginItemId = data == null ? null : data.getString("pluginId");
Plugin plugin = getPluginByPluginItemId(pluginItemId);
if (plugin != null && expected.equals(String.valueOf(plugin.getId()))) {
return true;
}
}
} catch (Exception ignored) {
// ignore
}
return false;
}
/**
* {@inheritDoc}
*/
@Override
public boolean containsPluginReferenceTransitively(BigInteger workflowId, BigInteger pluginId) {
if (workflowId == null || pluginId == null) {
return false;
}
return containsPluginReferenceTransitively(
String.valueOf(workflowId),
pluginId,
false,
new LinkedHashSet<>(),
new HashMap<>()
);
}
/**
* {@inheritDoc}
*/
@Override
public boolean containsPluginReferenceTransitivelyInPublishedSnapshot(BigInteger workflowId, BigInteger pluginId) {
if (workflowId == null || pluginId == null) {
return false;
}
return containsPluginReferenceTransitively(
String.valueOf(workflowId),
pluginId,
true,
new LinkedHashSet<>(),
new HashMap<>()
);
}
/**
* {@inheritDoc}
*/
@Override
public Plugin getWorkflowPlugin(BigInteger pluginId) {
if (pluginId == null) {
return null;
}
Plugin plugin = pluginMapper.selectOneById(pluginId);
if (plugin == null || !PluginType.isWorkflow(plugin.getType())) {
return null;
}
return plugin;
}
/**
* {@inheritDoc}
*/
@Override
public String resolveWorkflowIdByPluginItemId(String pluginItemId) {
Plugin plugin = getPluginByPluginItemId(pluginItemId);
if (plugin == null || !PluginType.isWorkflow(plugin.getType()) || plugin.getWorkflowId() == null) {
return null;
}
return String.valueOf(plugin.getWorkflowId());
}
private Plugin getPluginByPluginItemId(String pluginItemId) {
PluginItem pluginItem = pluginItemMapper.selectOneById(pluginItemId);
if (pluginItem == null || pluginItem.getPluginId() == null) {
return null;
}
return pluginMapper.selectOneById(pluginItem.getPluginId());
}
private boolean containsPluginReferenceTransitively(String workflowId,
BigInteger pluginId,
boolean publishedOnly,
Set<String> visitingWorkflowIds,
Map<String, Boolean> cache) {
if (!StringUtils.hasText(workflowId)) {
return false;
}
Boolean cached = cache.get(workflowId);
if (cached != null) {
return cached;
}
if (!visitingWorkflowIds.add(workflowId)) {
return false;
}
boolean result = false;
try {
String workflowContent = resolveWorkflowContent(workflowId, publishedOnly);
if (!StringUtils.hasText(workflowContent)) {
cache.put(workflowId, false);
return false;
}
result = containsPluginReferenceTransitivelyInContent(
workflowContent,
pluginId,
publishedOnly,
visitingWorkflowIds,
cache
);
cache.put(workflowId, result);
return result;
} finally {
visitingWorkflowIds.remove(workflowId);
}
}
private boolean containsPluginReferenceTransitivelyInContent(String content,
BigInteger pluginId,
boolean publishedOnly,
Set<String> visitingWorkflowIds,
Map<String, Boolean> cache) {
if (!StringUtils.hasText(content) || pluginId == null) {
return false;
}
try {
Object parsed = JSON.parse(content);
if (!(parsed instanceof JSONObject root)) {
return false;
}
JSONArray nodes = root.getJSONArray("nodes");
if (nodes == null || nodes.isEmpty()) {
return false;
}
String expectedPluginId = String.valueOf(pluginId);
for (int i = 0; i < nodes.size(); i++) {
JSONObject node = nodes.getJSONObject(i);
if (node == null) {
continue;
}
String nodeType = node.getString("type");
JSONObject data = node.getJSONObject("data");
if (TYPE_PLUGIN.equals(nodeType)) {
String pluginItemId = data == null ? null : data.getString("pluginId");
Plugin plugin = getPluginByPluginItemId(pluginItemId);
if (plugin == null) {
continue;
}
if (expectedPluginId.equals(String.valueOf(plugin.getId()))) {
return true;
}
if (PluginType.isWorkflow(plugin.getType())
&& plugin.getWorkflowId() != null
&& containsPluginReferenceTransitively(
String.valueOf(plugin.getWorkflowId()),
pluginId,
publishedOnly,
visitingWorkflowIds,
cache
)) {
return true;
}
continue;
}
if (TYPE_WORKFLOW.equals(nodeType)) {
String refWorkflowId = data == null ? null : data.getString("workflowId");
if (containsPluginReferenceTransitively(
refWorkflowId,
pluginId,
publishedOnly,
visitingWorkflowIds,
cache
)) {
return true;
}
}
}
} catch (Exception ignored) {
// ignore
}
return false;
}
private String resolveWorkflowContent(String workflowId, boolean publishedOnly) {
tech.easyflow.ai.entity.Workflow workflow = publishedOnly
? getPublishedWorkflow(workflowId)
: workflowService.getById(workflowId);
return workflow == null ? null : workflow.getContent();
}
private tech.easyflow.ai.entity.Workflow getPublishedWorkflow(String workflowId) {
try {
return workflowService.getPublishedById(new BigInteger(workflowId));
} catch (Exception ignored) {
return null;
}
}
}

View File

@@ -0,0 +1,231 @@
package tech.easyflow.ai.plugin.workflow.snapshot;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.easyagents.flow.core.chain.ChainDefinition;
import com.easyagents.flow.core.chain.Node;
import com.easyagents.flow.core.node.EndNode;
import org.springframework.stereotype.Service;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService;
import tech.easyflow.ai.easyagents.tool.WorkflowTool;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.service.WorkflowService;
import com.easyagents.flow.core.parser.ChainParser;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.util.List;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
/**
* 工作流插件快照解析服务。
*/
@Service
public class WorkflowPluginSnapshotResolver {
private final WorkflowService workflowService;
private final ChainParser chainParser;
private final WorkflowDatacenterContentService workflowDatacenterContentService;
public WorkflowPluginSnapshotResolver(WorkflowService workflowService,
ChainParser chainParser,
WorkflowDatacenterContentService workflowDatacenterContentService) {
this.workflowService = workflowService;
this.chainParser = chainParser;
this.workflowDatacenterContentService = workflowDatacenterContentService;
}
/**
* 使用已发布视图刷新工作流插件工具定义。
*
* @param plugin 插件
* @param pluginItem 插件工具
* @param workflowId 工作流 ID
*/
public void syncPluginItemFromPublishedWorkflow(Plugin plugin, PluginItem pluginItem, java.math.BigInteger workflowId) {
Workflow workflow = workflowService.getPublishedById(workflowId);
if (workflow == null) {
throw new BusinessException("工作流不存在");
}
assertSupportedForWorkflowPlugin(workflow);
ChainDefinition definition = parseDefinition(workflow);
pluginItem.setPluginId(plugin.getId());
pluginItem.setName(plugin.getName());
pluginItem.setEnglishName(workflow.getEnglishName());
pluginItem.setDescription(resolveDescription(plugin, workflow));
pluginItem.setBasePath(null);
pluginItem.setRequestMethod("WORKFLOW");
JSONArray inputDefinitions = resolveInputDefinitions(definition);
JSONArray outputDefinitions = resolveOutputDefinitions(definition);
pluginItem.setInputData(JSON.toJSONString(inputDefinitions));
pluginItem.setOutputData(JSON.toJSONString(outputDefinitions));
pluginItem.setSchemaHash(resolveSchemaHash(inputDefinitions, outputDefinitions));
pluginItem.setServiceStatus(1);
pluginItem.setStatus(1);
}
/**
* 构建工作流插件运行工具。
*
* @param workflow 工作流已发布视图
* @return 工作流工具
*/
public WorkflowTool buildWorkflowTool(Workflow workflow) {
return new WorkflowTool(
workflow,
false,
PublishedWorkflowDefinitionIds.published(String.valueOf(workflow.getId()))
);
}
/**
* 解析输入参数定义。
*
* @param definition 流程定义
* @return 输入参数数组
*/
public JSONArray resolveInputDefinitions(ChainDefinition definition) {
JSONArray inputs = JSON.parseArray(JSON.toJSONString(definition.getStartParameters()));
markWorkflowPluginInput(inputs);
return inputs == null ? new JSONArray() : inputs;
}
/**
* 解析输出参数定义。
*
* @param definition 流程定义
* @return 输出参数数组
*/
public JSONArray resolveOutputDefinitions(ChainDefinition definition) {
JSONArray outputs = new JSONArray();
List<Node> nodes = definition.getNodes();
if (nodes == null) {
return outputs;
}
for (Node node : nodes) {
if (node instanceof EndNode endNode) {
outputs = JSON.parseArray(JSON.toJSONString(endNode.getOutputDefs()));
}
}
return outputs == null ? new JSONArray() : outputs;
}
/**
* 解析工作流插件契约哈希。
*
* @param workflow 工作流
* @return 哈希值
*/
public String resolveSchemaHash(Workflow workflow) {
ChainDefinition definition = parseDefinition(workflow);
return resolveSchemaHash(resolveInputDefinitions(definition), resolveOutputDefinitions(definition));
}
/**
* 解析工作流插件契约哈希。
*
* @param inputDefinitions 输入定义
* @param outputDefinitions 输出定义
* @return 哈希值
*/
public String resolveSchemaHash(JSONArray inputDefinitions, JSONArray outputDefinitions) {
JSONObject payload = new JSONObject();
payload.put("inputs", inputDefinitions == null ? new JSONArray() : inputDefinitions);
payload.put("outputs", outputDefinitions == null ? new JSONArray() : outputDefinitions);
return sha256Hex(JSON.toJSONString(payload));
}
/**
* 校验工作流是否支持被封装为工作流插件。
*
* @param workflow 工作流
*/
public void assertSupportedForWorkflowPlugin(Workflow workflow) {
ChainDefinition definition = parseDefinition(workflow);
int endNodeCount = countEndNodes(definition);
// 一期先收敛为“单结束节点才能封装为插件”,后续若要支持多结束节点,
// 需要先补齐统一输出契约、父流程节点 schema 同步和结果展示策略。
if (endNodeCount != 1) {
throw new BusinessException("工作流插件仅支持单一结束节点,当前工作流不可封装为插件");
}
}
/**
* 判断工作流是否为单结束节点结构。
*
* @param workflow 工作流
* @return 单结束节点返回 true
*/
public boolean isSupportedForWorkflowPlugin(Workflow workflow) {
ChainDefinition definition = parseDefinition(workflow);
return countEndNodes(definition) == 1;
}
/**
* 解析已发布工作流定义。
*
* @param workflow 工作流已发布视图
* @return 流程定义
*/
public ChainDefinition parseDefinition(Workflow workflow) {
String preparedContent = workflowDatacenterContentService.prepareContent(workflow.getContent());
return chainParser.parse(preparedContent);
}
private String resolveDescription(Plugin plugin, Workflow workflow) {
if (plugin.getDescription() != null && !plugin.getDescription().isBlank()) {
return plugin.getDescription();
}
return workflow.getDescription();
}
private void markWorkflowPluginInput(JSONArray parameters) {
if (parameters == null) {
return;
}
for (Object parameter : parameters) {
if (!(parameter instanceof com.alibaba.fastjson2.JSONObject obj)) {
continue;
}
obj.put("refType", "ref");
JSONArray children = obj.getJSONArray("children");
if (children != null) {
markWorkflowPluginInput(children);
}
}
}
private int countEndNodes(ChainDefinition definition) {
List<Node> nodes = definition == null ? null : definition.getNodes();
if (nodes == null || nodes.isEmpty()) {
return 0;
}
int count = 0;
for (Node node : nodes) {
if (node instanceof EndNode) {
count++;
}
}
return count;
}
private String sha256Hex(String source) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] bytes = digest.digest(source.getBytes(StandardCharsets.UTF_8));
StringBuilder builder = new StringBuilder(bytes.length * 2);
for (byte current : bytes) {
builder.append(Character.forDigit((current >> 4) & 0xF, 16));
builder.append(Character.forDigit(current & 0xF, 16));
}
return builder.toString();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 algorithm unavailable", e);
}
}
}

View File

@@ -6,6 +6,8 @@ import org.springframework.stereotype.Component;
import tech.easyflow.ai.entity.BotWorkflow;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.plugin.workflow.binding.WorkflowPluginBindingService;
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
import tech.easyflow.ai.service.BotWorkflowService;
import tech.easyflow.ai.service.ResourceOfflineImpactService;
import tech.easyflow.ai.service.WorkflowService;
@@ -31,18 +33,24 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH
private final ResourceAccessService resourceAccessService;
private final BotWorkflowService botWorkflowService;
private final ResourceOfflineImpactService resourceOfflineImpactService;
private final WorkflowPluginBindingService workflowPluginBindingService;
private final WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver;
public WorkflowApprovalSubjectHandler(WorkflowService workflowService,
ResourceAccessService resourceAccessService,
ApprovalInstanceService approvalInstanceService,
BotWorkflowService botWorkflowService,
ResourceOfflineImpactService resourceOfflineImpactService,
WorkflowPluginBindingService workflowPluginBindingService,
WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver,
ObjectMapper objectMapper) {
super(approvalInstanceService, objectMapper);
this.workflowService = workflowService;
this.resourceAccessService = resourceAccessService;
this.botWorkflowService = botWorkflowService;
this.resourceOfflineImpactService = resourceOfflineImpactService;
this.workflowPluginBindingService = workflowPluginBindingService;
this.workflowPluginSnapshotResolver = workflowPluginSnapshotResolver;
}
@Override
@@ -116,6 +124,16 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH
return snapshot;
}
@Override
protected Map<String, Object> buildPublishSnapshot(Workflow resource, PublishStatus currentStatus) {
Map<String, Object> snapshot = super.buildPublishSnapshot(resource, currentStatus);
OfflineImpactCheckVo impact = resourceOfflineImpactService.checkWorkflowImpact(resource.getId());
if (impact.isHasPluginBindings()) {
workflowPluginSnapshotResolver.assertSupportedForWorkflowPlugin(resource);
}
return snapshot;
}
@Override
protected void persistResourceState(BigInteger resourceId, PublishStatus publishStatus, BigInteger currentApprovalInstanceId) {
Workflow update = new Workflow();
@@ -135,6 +153,7 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH
update.setPublishedAt(new java.util.Date());
update.setPublishedBy(operatorId);
workflowService.updateById(update);
workflowPluginBindingService.syncByWorkflowId(resourceId);
}
@Override
@@ -162,6 +181,9 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH
if (impact.isHasBotBindings()) {
snapshot.put("botBindings", impact.getBotBindings());
}
if (impact.isHasPluginBindings()) {
snapshot.put("pluginBindings", impact.getPluginBindings());
}
}
@Override

View File

@@ -9,6 +9,7 @@ public class KnowledgeRetrievalRequest {
private BigInteger knowledgeId;
private String query;
private Integer limit;
private Double minSimilarity;
private RetrievalMode retrievalMode = RetrievalMode.HYBRID;
private String callerType;
private String callerId;
@@ -37,6 +38,24 @@ public class KnowledgeRetrievalRequest {
this.limit = limit;
}
/**
* 返回检索时使用的最小相似度阈值。
*
* @return 最小相似度阈值
*/
public Double getMinSimilarity() {
return minSimilarity;
}
/**
* 设置检索时使用的最小相似度阈值。
*
* @param minSimilarity 最小相似度阈值
*/
public void setMinSimilarity(Double minSimilarity) {
this.minSimilarity = minSimilarity;
}
public RetrievalMode getRetrievalMode() {
return retrievalMode;
}

View File

@@ -0,0 +1,13 @@
package tech.easyflow.ai.service;
import com.mybatisflex.core.service.IService;
import tech.easyflow.ai.entity.DocumentImportTask;
/**
* 文档导入任务服务。
*
* @author Codex
* @since 2026-04-14
*/
public interface DocumentImportTaskService extends IService<DocumentImportTask> {
}

View File

@@ -32,4 +32,16 @@ public interface DocumentService extends IService<Document> {
Result<DocumentImportDtos.PreviewResponse> previewImport(DocumentImportDtos.PreviewRequest request);
Result<DocumentImportDtos.CommitResponse> commitImport(DocumentImportDtos.CommitRequest request);
Result<DocumentImportDtos.TaskCreateResponse> createImportTask(DocumentImportDtos.TaskCreateRequest request);
Result<DocumentImportDtos.TaskDetailResponse> getImportTaskDetail(BigInteger taskId);
Result<DocumentImportDtos.PreviewResponse> previewImportTask(DocumentImportDtos.PreviewRequest request);
Result<DocumentImportDtos.TaskStartIndexResponse> startIndexTask(DocumentImportDtos.TaskStartIndexRequest request);
Result<DocumentImportDtos.TaskStartIndexResponse> retryParseTask(DocumentImportDtos.TaskRetryRequest request);
Result<DocumentImportDtos.TaskStartIndexResponse> retryIndexTask(DocumentImportDtos.TaskRetryRequest request);
}

View File

@@ -0,0 +1,17 @@
package tech.easyflow.ai.service;
import java.math.BigInteger;
/**
* 知识库向量重建服务。
*/
public interface KnowledgeEmbeddingService {
/**
* 按知识库重建向量数据。
*
* @param knowledgeId 知识库 ID
*/
void rebuildKnowledgeVectors(BigInteger knowledgeId);
}

View File

@@ -0,0 +1,22 @@
package tech.easyflow.ai.service;
import java.math.BigInteger;
import java.util.Map;
/**
* 知识库分享审计服务。
*/
public interface KnowledgeShareAuditService {
/**
* 记录审计日志。
*
* @param accountId 操作人
* @param actionName 操作名称
* @param actionType 操作类型
* @param actionUrl 操作地址
* @param detail 扩展上下文
*/
void log(BigInteger accountId, String actionName, String actionType, String actionUrl, Map<String, Object> detail);
}

View File

@@ -0,0 +1,39 @@
package tech.easyflow.ai.service;
import java.math.BigInteger;
import java.util.Set;
/**
* 知识库分享权限服务。
*/
public interface KnowledgeSharePermissionService {
/**
* 为系统访问令牌授予知识库 API 分享权限。
*
* @param apiKeyId 系统访问令牌 ID
* @param knowledgeId 知识库 ID
* @param actionScopes 动作范围
*/
void grantApiShare(BigInteger apiKeyId, BigInteger knowledgeId, Set<String> actionScopes);
/**
* 按访问令牌维度开启或关闭知识库 API 分享授权。
*
* <p>产品固定为全量知识库的非删除范围,不向前端暴露动作粒度。</p>
*
* @param apiKeyId 系统访问令牌 ID
* @param enabled 是否启用知识库分享授权
*/
void replaceApiShareEnabled(BigInteger apiKeyId, boolean enabled);
/**
* 断言当前令牌具备知识库分享权限。
*
* @param apiKeyId 系统访问令牌 ID
* @param requestUri 请求地址
* @param knowledgeId 知识库 ID
* @param actionScope 动作范围
*/
void assertApiShare(BigInteger apiKeyId, String requestUri, BigInteger knowledgeId, String actionScope);
}

View File

@@ -0,0 +1,57 @@
package tech.easyflow.ai.service;
import com.mybatisflex.core.service.IService;
import tech.easyflow.ai.entity.KnowledgeShare;
import tech.easyflow.ai.vo.KnowledgeShareAuthContext;
import tech.easyflow.ai.vo.KnowledgeShareUrlCreateResult;
import java.math.BigInteger;
import java.util.Set;
/**
* 知识库分享服务。
*/
public interface KnowledgeShareService extends IService<KnowledgeShare> {
/**
* 创建 URL 分享。
*
* @param knowledgeId 目标知识库
* @param tenantId 租户 ID
* @param deptId 部门 ID
* @param operatorId 操作人
* @param baseUrl 分享页基础 URL
* @param permissionScopes 授权范围
* @return 创建结果
*/
KnowledgeShareUrlCreateResult createUrlShare(
BigInteger knowledgeId,
BigInteger tenantId,
BigInteger deptId,
BigInteger operatorId,
String baseUrl,
Set<String> permissionScopes
);
/**
* 校验 URL 分享并返回上下文。
*
* @param shareKey 分享访问密钥
* @return 鉴权上下文
*/
KnowledgeShareAuthContext validateUrlShare(String shareKey);
/**
* 断言 URL 分享允许当前动作。
*
* @param shareKey 分享访问密钥
* @param knowledgeId 知识库 ID
* @param actionScope 动作范围
* @return 鉴权上下文
*/
KnowledgeShareAuthContext assertUrlShareAccess(
String shareKey,
BigInteger knowledgeId,
String actionScope
);
}

View File

@@ -28,4 +28,12 @@ public interface PluginItemService extends IService<PluginItem> {
Result pluginToolTest(String inputData, BigInteger pluginToolId);
List<PluginItem> getByPluginId(String id);
/**
* 获取某个插件的系统维护工具。
*
* @param pluginId 插件 ID
* @return 工具
*/
PluginItem getSingleByPluginId(BigInteger pluginId);
}

View File

@@ -23,4 +23,22 @@ public interface PluginService extends IService<Plugin> {
Result pageByCategory(Long pageNumber, Long pageSize, int category);
boolean updatePlugin(Plugin plugin);
/**
* 按当前用户视角过滤并补充工作流插件可用性信息。
*
* @param plugins 插件列表
* @param managementView 是否为管理视角
* @param availableOnly 是否仅保留当前可用插件
* @return 过滤后的插件列表
*/
List<Plugin> preparePluginsForCurrentUser(List<Plugin> plugins, boolean managementView, boolean availableOnly);
/**
* 补充单个插件的工作流可用性信息。
*
* @param plugin 插件
* @return 原插件
*/
Plugin preparePluginForCurrentUser(Plugin plugin);
}

View File

@@ -31,6 +31,7 @@ import tech.easyflow.ai.easyagents.memory.RuntimeChatMemory;
import tech.easyflow.ai.easyagents.tool.WorkflowTool;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.entity.*;
import tech.easyflow.ai.enums.PluginType;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.mapper.BotMapper;
import tech.easyflow.ai.service.*;
@@ -117,6 +118,8 @@ public class BotServiceImpl extends ServiceImpl<BotMapper, Bot> implements BotSe
@Resource
private BotPluginService botPluginService;
@Resource
private PluginService pluginService;
@Resource
private PluginItemService pluginItemService;
@Resource
private BotMcpService botMcpService;
@@ -508,6 +511,12 @@ public class BotServiceImpl extends ServiceImpl<BotMapper, Bot> implements BotSe
List<PluginItem> pluginItems = pluginItemService.getMapper().selectListWithRelationsByQuery(queryTool);
if (pluginItems != null && !pluginItems.isEmpty()) {
for (PluginItem pluginItem : pluginItems) {
if (pluginItem.getPluginId() != null) {
Plugin plugin = pluginService.getById(pluginItem.getPluginId());
if (plugin != null && PluginType.isWorkflow(plugin.getType())) {
continue;
}
}
functionList.add(pluginItem.toFunction());
}
}

View File

@@ -30,9 +30,11 @@ import tech.easyflow.ai.entity.DocumentChunk;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.FaqItem;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.enums.DocumentProcessStatus;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.mapper.DocumentChunkMapper;
import tech.easyflow.ai.mapper.DocumentCollectionMapper;
import tech.easyflow.ai.mapper.DocumentMapper;
import tech.easyflow.ai.mapper.FaqItemMapper;
import tech.easyflow.ai.rag.KnowledgeRetrievalRequest;
import tech.easyflow.ai.service.DocumentCollectionService;
@@ -71,6 +73,8 @@ public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectio
private static final Logger LOG = LoggerFactory.getLogger(DocumentCollectionServiceImpl.class);
private static final int MAX_FAQ_IMAGES_IN_PROMPT = 3;
private static final int INTERNAL_RECALL_MULTIPLIER = 5;
private static final int MAX_INTERNAL_RECALL_LIMIT = 100;
@Resource
private ModelService llmService;
@@ -81,6 +85,9 @@ public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectio
@Autowired
private DocumentChunkMapper documentChunkMapper;
@Autowired
private DocumentMapper documentMapper;
@Autowired
private FaqItemMapper faqItemMapper;
@@ -111,24 +118,27 @@ public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectio
throw new BusinessException("知识库不存在");
}
int docRecallMaxNum = readIntegerOption(documentCollection, KEY_DOC_RECALL_MAX_NUM, 5);
float minSimilarity = readFloatOption(documentCollection, KEY_SIMILARITY_THRESHOLD, 0.6F);
int docRecallMaxNum = resolveDocRecallMaxNum(request, documentCollection);
int internalRecallLimit = resolveInternalRecallLimit(docRecallMaxNum);
float minSimilarity = resolveMinSimilarity(request, documentCollection);
RagQuery ragQuery = new RagQuery();
ragQuery.setQuery(keyword);
ragQuery.setRetrievalMode(retrievalMode);
ragQuery.setTopK(docRecallMaxNum);
ragQuery.setTopK(internalRecallLimit);
ragQuery.setMinScore((double) minSimilarity);
RagRetrievalExecutor retrievalExecutor = new RagRetrievalExecutor(
buildVectorRetriever(documentCollection, docRecallMaxNum, retrievalMode == RetrievalMode.VECTOR ? minSimilarity : null),
buildKeywordRetriever(documentCollection, docRecallMaxNum),
buildVectorRetriever(documentCollection, internalRecallLimit, retrievalMode == RetrievalMode.VECTOR ? minSimilarity : null),
buildKeywordRetriever(documentCollection, internalRecallLimit),
new RrfFusionStrategy()
);
RagRetrievalResult retrievalResult = retrievalExecutor.retrieve(ragQuery);
List<Document> searchDocuments = toDocuments(retrievalResult.getHits());
fillSearchContent(documentCollection, searchDocuments);
List<Document> searchDocuments = prepareSearchDocuments(
documentCollection,
toDocuments(retrievalResult.getHits())
);
if (searchDocuments.isEmpty()) {
return Collections.emptyList();
}
@@ -138,7 +148,10 @@ public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectio
if (rerankModel != null) {
try {
RagRetrievalResult rerankResult = retrievalExecutor.rerank(keyword, toRagHits(searchDocuments), rerankModel, docRecallMaxNum);
searchDocuments = toDocuments(rerankResult.getHits());
searchDocuments = prepareSearchDocuments(
documentCollection,
toDocuments(rerankResult.getHits())
);
reranked = true;
} catch (RerankException e) {
LOG.warn("Rerank failed for collectionId={}, modelId={}, fallback to retrieved results. message={}",
@@ -320,6 +333,84 @@ public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectio
return !reranked && retrievalMode == RetrievalMode.VECTOR;
}
/**
* 解析本次查询使用的召回上限,优先采用请求参数,其次回退到知识库默认配置。
*
* @param request 查询请求
* @param documentCollection 知识库实体
* @return 规范化后的召回上限
*/
private int resolveDocRecallMaxNum(KnowledgeRetrievalRequest request, DocumentCollection documentCollection) {
Integer requestLimit = request == null ? null : request.getLimit();
if (requestLimit != null) {
return Math.max(requestLimit, 1);
}
return readIntegerOption(documentCollection, KEY_DOC_RECALL_MAX_NUM, 5);
}
/**
* 解析本次查询使用的最小相似度阈值,优先采用请求参数,其次回退到知识库默认配置。
*
* @param request 查询请求
* @param documentCollection 知识库实体
* @return 规范化后的最小相似度
*/
private float resolveMinSimilarity(KnowledgeRetrievalRequest request, DocumentCollection documentCollection) {
Double requestMinSimilarity = request == null ? null : request.getMinSimilarity();
if (requestMinSimilarity != null) {
double normalizedValue = Math.max(0D, Math.min(requestMinSimilarity, 1D));
return (float) normalizedValue;
}
return readFloatOption(documentCollection, KEY_SIMILARITY_THRESHOLD, 0.6F);
}
/**
* 计算内部候选召回上限。
*
* @param docRecallMaxNum 业务召回上限
* @return 内部候选集上限
*/
private int resolveInternalRecallLimit(int docRecallMaxNum) {
int normalizedLimit = Math.max(docRecallMaxNum, 1);
return Math.min(normalizedLimit * INTERNAL_RECALL_MULTIPLIER, MAX_INTERNAL_RECALL_LIMIT);
}
/**
* 在重排前过滤掉未完成文档对应的 chunk 命中,避免进行中的文档占用正式召回名额。
*
* @param documentCollection 知识库
* @param searchDocuments 当前召回结果
* @return 仅保留完成态文档命中的结果
*/
private List<Document> prepareSearchDocuments(DocumentCollection documentCollection, List<Document> searchDocuments) {
if (searchDocuments == null || searchDocuments.isEmpty()) {
return Collections.emptyList();
}
if (documentCollection == null) {
return searchDocuments;
}
if (documentCollection.isFaqCollection()) {
fillSearchContent(documentCollection, searchDocuments);
return searchDocuments;
}
DocumentHitSnapshot hitSnapshot = loadDocumentHitSnapshot(documentCollection, searchDocuments);
if (hitSnapshot.isEmpty()) {
return Collections.emptyList();
}
return searchDocuments.stream()
.filter(Objects::nonNull)
.filter(item -> {
String content = hitSnapshot.findChunkContent(item.getId());
if (!StringUtil.hasText(content)) {
return false;
}
item.setContent(content);
return true;
})
.collect(Collectors.toList());
}
@Override
public DocumentCollection getDetail(String idOrAlias) {
DocumentCollection knowledge = null;
@@ -418,18 +509,93 @@ public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectio
return;
}
QueryWrapper queryWrapper = QueryWrapper.create();
queryWrapper.in(DocumentChunk::getId, ids);
queryWrapper.eq(DocumentChunk::getDocumentCollectionId, documentCollection.getId());
Map<String, DocumentChunk> chunkMap = documentChunkMapper.selectListByQuery(queryWrapper).stream()
.collect(Collectors.toMap(item -> item.getId().toString(), item -> item, (a, b) -> a));
searchDocuments.removeIf(item -> !chunkMap.containsKey(String.valueOf(item.getId())));
DocumentHitSnapshot hitSnapshot = loadDocumentHitSnapshot(documentCollection, searchDocuments);
searchDocuments.forEach(item -> {
DocumentChunk documentChunk = chunkMap.get(String.valueOf(item.getId()));
if (documentChunk != null && !StringUtil.noText(documentChunk.getContent())) {
item.setContent(documentChunk.getContent());
}
item.setContent(hitSnapshot.findChunkContent(item.getId()));
});
searchDocuments.removeIf(item -> !StringUtil.hasText(item.getContent()));
}
/**
* 批量加载命中 chunk 及其完成态父文档,供过滤和内容填充复用。
*
* @param documentCollection 知识库
* @param searchDocuments 检索命中
* @return 命中快照
*/
private DocumentHitSnapshot loadDocumentHitSnapshot(DocumentCollection documentCollection, List<Document> searchDocuments) {
if (documentCollection == null || searchDocuments == null || searchDocuments.isEmpty()) {
return DocumentHitSnapshot.empty();
}
List<Serializable> chunkIds = searchDocuments.stream()
.map(Document::getId)
.filter(Objects::nonNull)
.map(item -> (Serializable) item)
.collect(Collectors.toList());
if (chunkIds.isEmpty()) {
return DocumentHitSnapshot.empty();
}
QueryWrapper chunkWrapper = QueryWrapper.create();
chunkWrapper.in(DocumentChunk::getId, chunkIds);
chunkWrapper.eq(DocumentChunk::getDocumentCollectionId, documentCollection.getId());
Map<String, DocumentChunk> chunkMap = documentChunkMapper.selectListByQuery(chunkWrapper).stream()
.collect(Collectors.toMap(item -> item.getId().toString(), item -> item, (a, b) -> a));
if (chunkMap.isEmpty()) {
return DocumentHitSnapshot.empty();
}
List<Serializable> documentIds = chunkMap.values().stream()
.map(DocumentChunk::getDocumentId)
.filter(Objects::nonNull)
.distinct()
.map(item -> (Serializable) item)
.collect(Collectors.toList());
if (documentIds.isEmpty()) {
return DocumentHitSnapshot.empty();
}
QueryWrapper documentWrapper = QueryWrapper.create();
documentWrapper.in(tech.easyflow.ai.entity.Document::getId, documentIds);
documentWrapper.eq(tech.easyflow.ai.entity.Document::getCollectionId, documentCollection.getId());
documentWrapper.eq(tech.easyflow.ai.entity.Document::getProcessStatus, DocumentProcessStatus.COMPLETED.name());
Map<String, tech.easyflow.ai.entity.Document> documentMap = documentMapper.selectListByQuery(documentWrapper).stream()
.collect(Collectors.toMap(item -> item.getId().toString(), item -> item, (a, b) -> a));
return new DocumentHitSnapshot(chunkMap, documentMap);
}
/**
* 文档检索命中的批量快照,避免过滤和填充阶段重复查询。
*/
private static class DocumentHitSnapshot {
private final Map<String, DocumentChunk> chunkMap;
private final Map<String, tech.easyflow.ai.entity.Document> documentMap;
private DocumentHitSnapshot(Map<String, DocumentChunk> chunkMap,
Map<String, tech.easyflow.ai.entity.Document> documentMap) {
this.chunkMap = chunkMap == null ? Collections.emptyMap() : chunkMap;
this.documentMap = documentMap == null ? Collections.emptyMap() : documentMap;
}
private static DocumentHitSnapshot empty() {
return new DocumentHitSnapshot(Collections.emptyMap(), Collections.emptyMap());
}
private boolean isEmpty() {
return chunkMap.isEmpty() || documentMap.isEmpty();
}
private String findChunkContent(Object chunkId) {
DocumentChunk documentChunk = chunkMap.get(String.valueOf(chunkId));
if (documentChunk == null || documentChunk.getDocumentId() == null) {
return null;
}
if (!documentMap.containsKey(String.valueOf(documentChunk.getDocumentId()))) {
return null;
}
return StringUtil.noText(documentChunk.getContent()) ? null : documentChunk.getContent();
}
}
private String buildFaqPromptContent(FaqItem faqItem, List<Map<String, String>> images) {

View File

@@ -0,0 +1,18 @@
package tech.easyflow.ai.service.impl;
import com.mybatisflex.spring.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
import tech.easyflow.ai.entity.DocumentImportTask;
import tech.easyflow.ai.mapper.DocumentImportTaskMapper;
import tech.easyflow.ai.service.DocumentImportTaskService;
/**
* 文档导入任务服务实现。
*
* @author Codex
* @since 2026-04-14
*/
@Service
public class DocumentImportTaskServiceImpl extends ServiceImpl<DocumentImportTaskMapper, DocumentImportTask>
implements DocumentImportTaskService {
}

View File

@@ -34,7 +34,9 @@ import tech.easyflow.ai.config.SearcherFactory;
import tech.easyflow.ai.documentimport.DocumentImportDtos;
import tech.easyflow.ai.documentimport.DocumentImportKeys;
import tech.easyflow.ai.documentimport.DocumentImportPreviewService;
import tech.easyflow.ai.documentimport.task.KnowledgeDocumentImportTaskAppService;
import tech.easyflow.ai.entity.*;
import tech.easyflow.ai.enums.DocumentProcessStatus;
import tech.easyflow.ai.mapper.DocumentChunkMapper;
import tech.easyflow.ai.mapper.DocumentMapper;
import tech.easyflow.ai.service.DocumentChunkService;
@@ -69,6 +71,7 @@ import static tech.easyflow.ai.entity.table.DocumentTableDef.DOCUMENT;
@Service("AiService")
public class DocumentServiceImpl extends ServiceImpl<DocumentMapper, Document> implements DocumentService {
protected Logger Log = LoggerFactory.getLogger(DocumentServiceImpl.class);
private static final String SOURCE_RANGES_KEY = "sourceRanges";
@Resource
private DocumentMapper documentMapper;
@@ -97,6 +100,9 @@ public class DocumentServiceImpl extends ServiceImpl<DocumentMapper, Document> i
@Autowired
private DocumentImportPreviewService documentImportPreviewService;
@Autowired
private KnowledgeDocumentImportTaskAppService importTaskAppService;
@Override
public Page<Document> getDocumentList(String knowledgeId, int pageSize, int pageNum, String fileName) {
QueryWrapper queryWrapper=QueryWrapper.create()
@@ -130,6 +136,13 @@ public class DocumentServiceImpl extends ServiceImpl<DocumentMapper, Document> i
// 查询该文档对应哪些分割的字段,先删除
QueryWrapper queryWrapperDocument = QueryWrapper.create().eq(Document::getId, id);
Document oneByQuery = documentMapper.selectOneByQuery(queryWrapperDocument);
if (oneByQuery == null) {
return false;
}
if (DocumentProcessStatus.PARSING.name().equals(oneByQuery.getProcessStatus())
|| DocumentProcessStatus.INDEXING.name().equals(oneByQuery.getProcessStatus())) {
throw new BusinessException("文档处理中,暂不允许删除");
}
DocumentCollection knowledge = knowledgeService.getById(oneByQuery.getCollectionId());
if (knowledge == null) {
return false;
@@ -209,12 +222,18 @@ public class DocumentServiceImpl extends ServiceImpl<DocumentMapper, Document> i
aiDocument.setDocumentPath(filePath);
aiDocument.setCreated(new Date());
aiDocument.setModifiedBy(BigInteger.valueOf(StpUtil.getLoginIdAsLong()));
aiDocument.setModified(new Date());
aiDocument.setContent(document.getContent());
aiDocument.setChunkSize(documentCollectionSplitParams.getChunkSize());
aiDocument.setOverlapSize(documentCollectionSplitParams.getOverlapSize());
aiDocument.setTitle(fileOriginName);
Map<String, Object> res = new HashMap<>();
aiDocument.setModified(new Date());
aiDocument.setContent(document.getContent());
aiDocument.setChunkSize(documentCollectionSplitParams.getChunkSize());
aiDocument.setOverlapSize(documentCollectionSplitParams.getOverlapSize());
aiDocument.setTitle(fileOriginName);
aiDocument.setProcessStatus(DocumentProcessStatus.COMPLETED.name());
aiDocument.setTotalChunks(previewList.size());
aiDocument.setCompletedChunks(previewList.size());
aiDocument.setFailedChunks(0);
aiDocument.setProgressPercent(100);
aiDocument.setTaskModifiedAt(new Date());
Map<String, Object> res = new HashMap<>();
List<DocumentChunk> documentChunks = null;
String operation = documentCollectionSplitParams.getOperation();
@@ -334,10 +353,11 @@ public class DocumentServiceImpl extends ServiceImpl<DocumentMapper, Document> i
item.setPreviewSessionId(sessionId);
item.setFilePath(file.getFilePath());
item.setFileName(file.getFileName());
item.setNormalizedContent(session.getAnalysis() == null ? null : session.getAnalysis().getNormalizedContent());
item.setStrategyCode(session.getStrategyConfig().getStrategyCode());
item.setStrategyLabel(ragIngestionService.toStrategyLabel(session.getStrategyConfig().getStrategyCode()));
item.setAnalysis(session.getAnalysis());
item.setChunks(session.getPreviewChunks());
item.setChunks(toPreviewChunkResults(session.getPreviewChunks()));
item.setTotalChunks(session.getPreviewChunks().size());
item.setTotalWarnings(countWarnings(session.getPreviewChunks()));
items.add(item);
@@ -398,6 +418,12 @@ public class DocumentServiceImpl extends ServiceImpl<DocumentMapper, Document> i
document.setModified(new Date());
document.setCreatedBy(BigInteger.valueOf(StpUtil.getLoginIdAsLong()));
document.setModifiedBy(BigInteger.valueOf(StpUtil.getLoginIdAsLong()));
document.setProcessStatus(DocumentProcessStatus.COMPLETED.name());
document.setTotalChunks(session.getDocumentChunks().size());
document.setCompletedChunks(session.getDocumentChunks().size());
document.setFailedChunks(0);
document.setProgressPercent(100);
document.setTaskModifiedAt(new Date());
for (DocumentChunk chunk : session.getDocumentChunks()) {
chunk.setDocumentId(document.getId());
chunk.setDocumentCollectionId(document.getCollectionId());
@@ -430,6 +456,7 @@ public class DocumentServiceImpl extends ServiceImpl<DocumentMapper, Document> i
DocumentImportDtos.PreviewSession session = new DocumentImportDtos.PreviewSession();
session.setKnowledgeId(knowledge.getId());
session.setDocumentId(document.getId());
session.setFilePath(fileRequest.getFilePath());
session.setFileName(fileRequest.getFileName());
session.setSourceFormat(analysis.getSourceFormat());
@@ -656,6 +683,55 @@ public class DocumentServiceImpl extends ServiceImpl<DocumentMapper, Document> i
return total;
}
private List<DocumentImportDtos.PreviewChunkResult> toPreviewChunkResults(List<RagChunk> chunks) {
List<DocumentImportDtos.PreviewChunkResult> result = new ArrayList<>();
if (chunks == null) {
return result;
}
for (RagChunk chunk : chunks) {
DocumentImportDtos.PreviewChunkResult item = new DocumentImportDtos.PreviewChunkResult();
item.setAnswer(chunk.getAnswer());
item.setCharCount(chunk.getCharCount());
item.setChunkId(chunk.getChunkId());
item.setChunkType(chunk.getChunkType());
item.setContent(chunk.getContent());
item.setHeadingPath(chunk.getHeadingPath() == null ? new ArrayList<>() : new ArrayList<>(chunk.getHeadingPath()));
item.setPartNo(chunk.getPartNo());
item.setPartTotal(chunk.getPartTotal());
item.setQuestion(chunk.getQuestion());
item.setSourceLabel(chunk.getSourceLabel());
item.setTokenEstimate(chunk.getTokenEstimate());
item.setWarnings(chunk.getWarnings() == null ? new ArrayList<>() : new ArrayList<>(chunk.getWarnings()));
item.setSourceRanges(copySourceRanges(chunk));
result.add(item);
}
return result;
}
@SuppressWarnings("unchecked")
private List<DocumentImportDtos.PreviewSourceRange> copySourceRanges(RagChunk chunk) {
List<DocumentImportDtos.PreviewSourceRange> result = new ArrayList<>();
if (chunk == null || chunk.getOptions() == null) {
return result;
}
Object rawRanges = chunk.getOptions().get(SOURCE_RANGES_KEY);
if (!(rawRanges instanceof List<?> rangeList)) {
return result;
}
for (Object item : rangeList) {
if (!(item instanceof Map<?, ?> rangeMap)) {
continue;
}
DocumentImportDtos.PreviewSourceRange range = new DocumentImportDtos.PreviewSourceRange();
range.setStart(asInteger(rangeMap.get("start"), null));
range.setEnd(asInteger(rangeMap.get("end"), null));
if (range.getStart() != null && range.getEnd() != null) {
result.add(range);
}
}
return result;
}
private StoreExecutionContext prepareStoreContext(Document entity) {
DocumentCollection knowledge = knowledgeService.getById(entity.getCollectionId());
if (knowledge == null) {
@@ -882,4 +958,34 @@ public class DocumentServiceImpl extends ServiceImpl<DocumentMapper, Document> i
}
return null;
}
@Override
public Result<DocumentImportDtos.TaskCreateResponse> createImportTask(DocumentImportDtos.TaskCreateRequest request) {
return importTaskAppService.createImportTask(request);
}
@Override
public Result<DocumentImportDtos.TaskDetailResponse> getImportTaskDetail(BigInteger taskId) {
return importTaskAppService.getImportTaskDetail(taskId);
}
@Override
public Result<DocumentImportDtos.PreviewResponse> previewImportTask(DocumentImportDtos.PreviewRequest request) {
return importTaskAppService.previewImportTask(request);
}
@Override
public Result<DocumentImportDtos.TaskStartIndexResponse> startIndexTask(DocumentImportDtos.TaskStartIndexRequest request) {
return importTaskAppService.startIndexTask(request);
}
@Override
public Result<DocumentImportDtos.TaskStartIndexResponse> retryParseTask(DocumentImportDtos.TaskRetryRequest request) {
return importTaskAppService.retryParseTask(request);
}
@Override
public Result<DocumentImportDtos.TaskStartIndexResponse> retryIndexTask(DocumentImportDtos.TaskRetryRequest request) {
return importTaskAppService.retryIndexTask(request);
}
}

View File

@@ -0,0 +1,156 @@
package tech.easyflow.ai.service.impl;
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.query.QueryWrapper;
import org.springframework.stereotype.Service;
import tech.easyflow.ai.entity.DocumentChunk;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.FaqItem;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.service.DocumentChunkService;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.ai.service.FaqItemService;
import tech.easyflow.ai.service.KnowledgeEmbeddingService;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.common.web.exceptions.BusinessException;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 知识库向量重建服务实现。
*/
@Service
public class KnowledgeEmbeddingServiceImpl implements KnowledgeEmbeddingService {
@Resource
private DocumentCollectionService documentCollectionService;
@Resource
private DocumentChunkService documentChunkService;
@Resource
private FaqItemService faqItemService;
@Resource
private ModelService modelService;
@Override
public void rebuildKnowledgeVectors(BigInteger knowledgeId) {
DocumentCollection knowledge = documentCollectionService.getById(knowledgeId);
if (knowledge == null) {
throw new BusinessException("知识库不存在");
}
DocumentStore documentStore = knowledge.toDocumentStore();
if (documentStore == null) {
throw new BusinessException("知识库没有配置向量库");
}
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
if (model == null) {
throw new BusinessException("知识库没有配置向量模型");
}
EmbeddingModel embeddingModel = model.toEmbeddingModel();
documentStore.setEmbeddingModel(embeddingModel);
StoreOptions storeOptions = StoreOptions.ofCollectionName(knowledge.getVectorStoreCollection());
storeOptions.setIndexName(knowledge.getVectorStoreCollection());
if (knowledge.isFaqCollection()) {
rebuildFaqVectors(knowledge, documentStore, storeOptions, embeddingModel);
return;
}
rebuildDocumentVectors(knowledge, documentStore, storeOptions, embeddingModel);
}
private void rebuildDocumentVectors(
DocumentCollection knowledge,
DocumentStore documentStore,
StoreOptions storeOptions,
EmbeddingModel embeddingModel
) {
QueryWrapper wrapper = QueryWrapper.create()
.eq(DocumentChunk::getDocumentCollectionId, knowledge.getId())
.orderBy("sorting asc");
List<DocumentChunk> chunks = documentChunkService.list(wrapper);
List<BigInteger> ids = new ArrayList<>();
List<com.easyagents.core.document.Document> documents = new ArrayList<>();
for (DocumentChunk chunk : chunks) {
ids.add(chunk.getId());
com.easyagents.core.document.Document document = com.easyagents.core.document.Document.of(chunk.getContent());
document.setId(chunk.getId());
documents.add(document);
}
rewriteStore(documentStore, storeOptions, ids, documents);
updateKnowledgeEmbeddingState(knowledge, embeddingModel);
}
private void rebuildFaqVectors(
DocumentCollection knowledge,
DocumentStore documentStore,
StoreOptions storeOptions,
EmbeddingModel embeddingModel
) {
QueryWrapper wrapper = QueryWrapper.create()
.eq(FaqItem::getCollectionId, knowledge.getId())
.orderBy("order_no asc");
List<FaqItem> faqItems = faqItemService.list(wrapper);
List<BigInteger> ids = new ArrayList<>();
List<com.easyagents.core.document.Document> documents = new ArrayList<>();
for (FaqItem faqItem : faqItems) {
ids.add(faqItem.getId());
StringBuilder content = new StringBuilder();
content.append("问题:").append(faqItem.getQuestion());
if (faqItem.getAnswerText() != null && !faqItem.getAnswerText().isBlank()) {
content.append("\n答案").append(faqItem.getAnswerText());
}
com.easyagents.core.document.Document document =
com.easyagents.core.document.Document.of(content.toString());
document.setId(faqItem.getId());
Map<String, Object> metadata = new HashMap<>();
metadata.put("question", faqItem.getQuestion());
metadata.put("answerText", faqItem.getAnswerText());
metadata.put("categoryId", faqItem.getCategoryId());
document.setMetadataMap(metadata);
documents.add(document);
}
rewriteStore(documentStore, storeOptions, ids, documents);
updateKnowledgeEmbeddingState(knowledge, embeddingModel);
}
private void rewriteStore(
DocumentStore documentStore,
StoreOptions storeOptions,
List<BigInteger> ids,
List<com.easyagents.core.document.Document> documents
) {
if (!ids.isEmpty()) {
documentStore.delete(ids, storeOptions);
}
if (documents.isEmpty()) {
return;
}
StoreResult result = documentStore.store(documents, storeOptions);
if (result == null || !result.isSuccess()) {
throw new BusinessException("知识库向量重建失败");
}
}
private void updateKnowledgeEmbeddingState(DocumentCollection knowledge, EmbeddingModel embeddingModel) {
DocumentCollection update = new DocumentCollection();
update.setId(knowledge.getId());
Map<String, Object> options = knowledge.getOptions() == null
? new HashMap<>()
: new HashMap<>(knowledge.getOptions());
options.put(DocumentCollection.KEY_CAN_UPDATE_EMBEDDING_MODEL, false);
update.setOptions(options);
if (knowledge.getDimensionOfVectorModel() == null) {
update.setDimensionOfVectorModel(Model.getEmbeddingDimension(embeddingModel));
}
documentCollectionService.updateById(update);
}
}

View File

@@ -0,0 +1,47 @@
package tech.easyflow.ai.service.impl;
import com.alibaba.fastjson2.JSON;
import org.springframework.stereotype.Service;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import tech.easyflow.ai.service.KnowledgeShareAuditService;
import tech.easyflow.common.util.RequestUtil;
import tech.easyflow.system.entity.SysLog;
import tech.easyflow.system.service.SysLogService;
import javax.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import java.math.BigInteger;
import java.util.Date;
import java.util.Map;
/**
* 知识库分享审计服务实现。
*/
@Service
public class KnowledgeShareAuditServiceImpl implements KnowledgeShareAuditService {
@Resource
private SysLogService sysLogService;
@Override
public void log(BigInteger accountId, String actionName, String actionType, String actionUrl, Map<String, Object> detail) {
SysLog log = new SysLog();
log.setAccountId(accountId);
log.setActionName(actionName);
log.setActionType(actionType);
log.setActionUrl(actionUrl);
log.setActionBody(detail == null ? null : JSON.toJSONString(detail));
log.setCreated(new Date());
log.setStatus(0);
ServletRequestAttributes attributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
HttpServletRequest request = attributes.getRequest();
log.setActionIp(RequestUtil.getIpAddress(request));
log.setActionParams(request.getQueryString());
}
sysLogService.save(log);
}
}

View File

@@ -0,0 +1,180 @@
package tech.easyflow.ai.service.impl;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import tech.easyflow.ai.enums.KnowledgeShareActionScope;
import tech.easyflow.ai.service.KnowledgeSharePermissionService;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.entity.SysApiKey;
import tech.easyflow.system.entity.SysApiKeyResource;
import tech.easyflow.system.entity.SysApiKeyResourceMapping;
import tech.easyflow.system.service.SysApiKeyResourceMappingService;
import tech.easyflow.system.service.SysApiKeyResourceService;
import tech.easyflow.system.service.SysApiKeyService;
import javax.annotation.Resource;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 知识库分享权限服务实现。
*/
@Service
public class KnowledgeSharePermissionServiceImpl implements KnowledgeSharePermissionService {
public static final String RESOURCE_TYPE_KNOWLEDGE = "KNOWLEDGE";
private static final Map<String, List<String>> URI_SCOPE_MAPPING = new LinkedHashMap<>();
static {
URI_SCOPE_MAPPING.put(KnowledgeShareActionScope.VIEW.name(), List.of(
"/public-api/knowledge-share/detail",
"/public-api/knowledge-share/document/page",
"/public-api/knowledge-share/document/download",
"/public-api/knowledge-share/document/import/task/detail",
"/public-api/knowledge-share/documentChunk/page",
"/public-api/knowledge-share/faq/page",
"/public-api/knowledge-share/faq/detail"
));
URI_SCOPE_MAPPING.put(KnowledgeShareActionScope.SEARCH.name(), List.of(
"/public-api/knowledge-share/search"
));
URI_SCOPE_MAPPING.put(KnowledgeShareActionScope.CONTENT_CREATE.name(), List.of(
"/public-api/knowledge-share/document/import/analyze",
"/public-api/knowledge-share/document/import/preview",
"/public-api/knowledge-share/document/import/commit",
"/public-api/knowledge-share/document/import/task/create",
"/public-api/knowledge-share/document/import/task/preview",
"/public-api/knowledge-share/document/import/task/startIndex",
"/public-api/knowledge-share/document/import/task/retryParse",
"/public-api/knowledge-share/document/import/task/retryIndex",
"/public-api/knowledge-share/faq/save"
));
URI_SCOPE_MAPPING.put(KnowledgeShareActionScope.CONTENT_UPDATE.name(), List.of(
"/public-api/knowledge-share/documentChunk/update",
"/public-api/knowledge-share/faq/update"
));
URI_SCOPE_MAPPING.put(KnowledgeShareActionScope.CONTENT_DELETE.name(), List.of(
"/public-api/knowledge-share/document/remove",
"/public-api/knowledge-share/documentChunk/remove",
"/public-api/knowledge-share/faq/remove"
));
URI_SCOPE_MAPPING.put(KnowledgeShareActionScope.IMPORT_EXPORT.name(), List.of(
"/public-api/knowledge-share/faq/importExcel",
"/public-api/knowledge-share/faq/exportExcel",
"/public-api/knowledge-share/faq/downloadImportTemplate"
));
}
@Resource
private SysApiKeyService sysApiKeyService;
@Resource
private SysApiKeyResourceService resourceService;
@Resource
private SysApiKeyResourceMappingService mappingService;
@Override
@Transactional(rollbackFor = Exception.class)
public void grantApiShare(BigInteger apiKeyId, BigInteger knowledgeId, Set<String> actionScopes) {
if (apiKeyId == null) {
throw new BusinessException("系统访问令牌不能为空");
}
if (knowledgeId == null) {
throw new BusinessException("知识库不能为空");
}
SysApiKey apiKey = sysApiKeyService.getById(apiKeyId);
if (apiKey == null) {
throw new BusinessException("系统访问令牌不存在");
}
Set<String> normalizedScopes = KnowledgeShareActionScope.normalize(actionScopes);
if (normalizedScopes.isEmpty()) {
throw new BusinessException("动作范围不能为空");
}
mappingService.removeScopedMappings(apiKeyId, RESOURCE_TYPE_KNOWLEDGE, knowledgeId);
List<SysApiKeyResourceMapping> rows = new ArrayList<>();
for (String scope : normalizedScopes) {
List<String> uris = URI_SCOPE_MAPPING.get(scope);
if (uris == null || uris.isEmpty()) {
continue;
}
for (String uri : uris) {
SysApiKeyResource resource = ensureResource(uri);
SysApiKeyResourceMapping row = new SysApiKeyResourceMapping();
row.setApiKeyId(apiKeyId);
row.setApiKeyResourceId(resource.getId());
row.setResourceType(RESOURCE_TYPE_KNOWLEDGE);
row.setResourceTargetId(knowledgeId);
row.setActionScope(scope);
rows.add(row);
}
}
if (!rows.isEmpty()) {
mappingService.saveBatch(rows);
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void replaceApiShareEnabled(BigInteger apiKeyId, boolean enabled) {
if (apiKeyId == null) {
throw new BusinessException("系统访问令牌不能为空");
}
SysApiKey apiKey = sysApiKeyService.getById(apiKeyId);
if (apiKey == null) {
throw new BusinessException("系统访问令牌不存在");
}
mappingService.removeScopedMappings(apiKeyId, RESOURCE_TYPE_KNOWLEDGE);
if (!enabled) {
return;
}
List<SysApiKeyResourceMapping> rows = new ArrayList<>();
for (String scope : KnowledgeShareActionScope.defaultApiScopes()) {
List<String> uris = URI_SCOPE_MAPPING.get(scope);
if (uris == null || uris.isEmpty()) {
continue;
}
for (String uri : uris) {
SysApiKeyResource resource = ensureResource(uri);
SysApiKeyResourceMapping row = new SysApiKeyResourceMapping();
row.setApiKeyId(apiKeyId);
row.setApiKeyResourceId(resource.getId());
row.setResourceType(RESOURCE_TYPE_KNOWLEDGE);
row.setActionScope(scope);
rows.add(row);
}
}
if (!rows.isEmpty()) {
mappingService.saveBatch(rows);
}
}
@Override
public void assertApiShare(BigInteger apiKeyId, String requestUri, BigInteger knowledgeId, String actionScope) {
if (apiKeyId == null || knowledgeId == null) {
throw new BusinessException("API 分享鉴权参数不完整");
}
sysApiKeyService.checkResourceScope(apiKeyId, requestUri, RESOURCE_TYPE_KNOWLEDGE, knowledgeId, actionScope);
}
private SysApiKeyResource ensureResource(String requestInterface) {
QueryWrapper wrapper = QueryWrapper.create()
.eq(SysApiKeyResource::getRequestInterface, requestInterface);
SysApiKeyResource resource = resourceService.getOne(wrapper);
if (resource != null) {
return resource;
}
resource = new SysApiKeyResource();
resource.setRequestInterface(requestInterface);
resource.setTitle("知识库分享接口");
resourceService.save(resource);
return resource;
}
}

View File

@@ -0,0 +1,179 @@
package tech.easyflow.ai.service.impl;
import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.spring.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
import tech.easyflow.ai.constants.KnowledgeShareErrorCode;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.KnowledgeShare;
import tech.easyflow.ai.enums.KnowledgeShareStatus;
import tech.easyflow.ai.enums.KnowledgeShareType;
import tech.easyflow.ai.mapper.KnowledgeShareMapper;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.ai.service.KnowledgeShareService;
import tech.easyflow.ai.vo.KnowledgeShareAuthContext;
import tech.easyflow.ai.vo.KnowledgeShareUrlCreateResult;
import tech.easyflow.common.web.exceptions.BusinessException;
import javax.annotation.Resource;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.util.Date;
import java.util.HexFormat;
import java.util.List;
import java.util.Set;
import java.util.UUID;
/**
* 知识库分享服务实现。
*/
@Service
public class KnowledgeShareServiceImpl extends ServiceImpl<KnowledgeShareMapper, KnowledgeShare> implements KnowledgeShareService {
private static final Duration DEFAULT_EXPIRE_DURATION = Duration.ofMinutes(30);
@Resource
private DocumentCollectionService documentCollectionService;
@Override
public KnowledgeShareUrlCreateResult createUrlShare(
BigInteger knowledgeId,
BigInteger tenantId,
BigInteger deptId,
BigInteger operatorId,
String baseUrl,
Set<String> permissionScopes
) {
DocumentCollection knowledge = documentCollectionService.getById(knowledgeId);
if (knowledge == null) {
throw new BusinessException("知识库不存在");
}
invalidateExistingUrlShares(knowledgeId, operatorId);
String shareKey = UUID.randomUUID().toString().replace("-", "");
Date now = new Date();
Date expiresAt = new Date(now.getTime() + DEFAULT_EXPIRE_DURATION.toMillis());
KnowledgeShare share = new KnowledgeShare();
share.setKnowledgeId(knowledgeId);
share.setShareType(KnowledgeShareType.URL.name());
share.setShareKeyHash(hashShareKey(shareKey));
share.setStatus(KnowledgeShareStatus.ENABLED.name());
share.setPermissionScopes(permissionScopes);
share.setExpiresAt(expiresAt);
share.setTenantId(tenantId);
share.setDeptId(deptId);
share.setCreated(now);
share.setCreatedBy(operatorId);
share.setModified(now);
share.setModifiedBy(operatorId);
save(share);
KnowledgeShareUrlCreateResult result = new KnowledgeShareUrlCreateResult();
result.setId(share.getId());
result.setShareKey(shareKey);
result.setExpiresAt(expiresAt);
result.setShareUrl(buildShareUrl(baseUrl, shareKey));
return result;
}
private void invalidateExistingUrlShares(BigInteger knowledgeId, BigInteger operatorId) {
QueryWrapper wrapper = QueryWrapper.create()
.eq(KnowledgeShare::getKnowledgeId, knowledgeId)
.eq(KnowledgeShare::getShareType, KnowledgeShareType.URL.name())
.eq(KnowledgeShare::getStatus, KnowledgeShareStatus.ENABLED.name());
List<KnowledgeShare> activeShares = list(wrapper);
if (activeShares == null || activeShares.isEmpty()) {
return;
}
Date now = new Date();
for (KnowledgeShare activeShare : activeShares) {
KnowledgeShare update = new KnowledgeShare();
update.setId(activeShare.getId());
update.setStatus(KnowledgeShareStatus.DISABLED.name());
update.setModified(now);
update.setModifiedBy(operatorId);
updateById(update);
}
}
@Override
public KnowledgeShareAuthContext validateUrlShare(String shareKey) {
if (shareKey == null || shareKey.isBlank()) {
throw invalidShare();
}
QueryWrapper wrapper = QueryWrapper.create()
.eq(KnowledgeShare::getShareKeyHash, hashShareKey(shareKey));
KnowledgeShare share = getOne(wrapper);
if (share == null) {
throw invalidShare();
}
if (KnowledgeShareStatus.REVOKED.name().equals(share.getStatus())
|| KnowledgeShareStatus.DISABLED.name().equals(share.getStatus())) {
throw invalidShare();
}
if (share.getExpiresAt() == null || share.getExpiresAt().before(new Date())) {
throw expiredShare();
}
DocumentCollection knowledge = documentCollectionService.getById(share.getKnowledgeId());
if (knowledge == null) {
throw invalidShare();
}
KnowledgeShareAuthContext context = new KnowledgeShareAuthContext();
context.setShare(share);
context.setKnowledge(knowledge);
return context;
}
@Override
public KnowledgeShareAuthContext assertUrlShareAccess(
String shareKey,
BigInteger knowledgeId,
String actionScope
) {
KnowledgeShareAuthContext context = validateUrlShare(shareKey);
if (knowledgeId != null && context.getKnowledge() != null
&& context.getKnowledge().getId() != null
&& context.getKnowledge().getId().compareTo(knowledgeId) != 0) {
throw invalidShare();
}
if (actionScope != null && !actionScope.isBlank()
&& !context.getShare().getPermissionScopeSet().contains(actionScope.trim().toUpperCase())) {
throw forbiddenShare("当前分享不允许执行该操作");
}
return context;
}
private String buildShareUrl(String baseUrl, String shareKey) {
if (baseUrl == null || baseUrl.isBlank()) {
return null;
}
return baseUrl + (baseUrl.contains("?") ? "&" : "?")
+ "shareKey=" + shareKey;
}
private String hashShareKey(String shareKey) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] value = digest.digest(shareKey.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(value);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 unavailable", e);
}
}
private BusinessException expiredShare() {
return new BusinessException(KnowledgeShareErrorCode.KNOWLEDGE_SHARE_EXPIRED + ":链接已过期");
}
private BusinessException invalidShare() {
return new BusinessException(KnowledgeShareErrorCode.KNOWLEDGE_SHARE_INVALID + ":链接无效");
}
private BusinessException forbiddenShare(String message) {
return new BusinessException(KnowledgeShareErrorCode.KNOWLEDGE_SHARE_FORBIDDEN + ":" + message);
}
}

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