diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/KnowledgeShareController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/KnowledgeShareController.java new file mode 100644 index 0000000..f262105 --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/KnowledgeShareController.java @@ -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 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 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; + } +} diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/ShareKnowledgeController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/ShareKnowledgeController.java new file mode 100644 index 0000000..e8d786d --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/ShareKnowledgeController.java @@ -0,0 +1,835 @@ +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.multipart.MultipartFile; +import tech.easyflow.ai.documentimport.DocumentImportDtos; +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.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 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; + + /** + * 获取知识库详情。 + * + * @param shareKey 分享访问密钥 + * @return 知识库详情 + */ + @GetMapping("/documentCollection/detail") + public Result 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())); + return Result.ok(context.getKnowledge()); + } + + /** + * 获取模型列表。 + * + * @param shareKey 分享访问密钥 + * @param modelType 模型类型 + * @return 模型列表 + */ + @GetMapping("/documentCollection/modelList") + public Result> 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 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 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> 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> 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)); + } + + /** + * 下载文档。 + */ + @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 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 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 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); + } + + /** + * Chunk 分页。 + */ + @GetMapping("/documentChunk/page") + public Result> 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> 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> 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 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 page = faqItemService.page(new Page<>(pageNumber, pageSize), queryWrapper); + Map 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 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 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 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 auditDetail(Object... keyValues) { + if (keyValues == null || keyValues.length == 0) { + return new HashMap<>(); + } + if ((keyValues.length & 1) != 0) { + throw new IllegalArgumentException("审计详情参数必须成对出现"); + } + Map 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 detail) { + Map 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 toKnowledgeSearchResult(List documents) { + List 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; + } +} diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysApiKeyController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysApiKeyController.java index f563df2..3a007c5 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysApiKeyController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysApiKeyController.java @@ -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 detail(String id) { + Result result = super.detail(id); + fillApiKeyPermissions(result.getData()); + return result; } @Override @@ -91,11 +104,30 @@ public class SysApiKeyController extends BaseCurdController> pageResult = (Result>) super.page(request, sortKey, sortType, pageNumber, pageSize); Page data = pageResult.getData(); List records = data.getRecords(); - records.forEach(record -> { - QueryWrapper queryWrapper = QueryWrapper.create().select(SysApiKeyResourceMapping::getApiKeyResourceId).eq(SysApiKeyResourceMapping::getApiKeyId, record.getId()); - List resourceIds = sysApiKeyResourceMappingService.listAs(queryWrapper, BigInteger.class); - record.setPermissionIds(resourceIds); - }); + records.forEach(this::fillApiKeyPermissions); return pageResult; } -} \ No newline at end of file + + /** + * 回填访问令牌的接口与知识库授权。 + * + * @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 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); + } +} diff --git a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/controller/PublicKnowledgeShareController.java b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/controller/PublicKnowledgeShareController.java new file mode 100644 index 0000000..84c4349 --- /dev/null +++ b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/controller/PublicKnowledgeShareController.java @@ -0,0 +1,583 @@ +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 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> 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> 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 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 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 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); + } + + /** + * Chunk 分页。 + */ + @GetMapping("/documentChunk/page") + public Result> 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> 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 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 page = faqItemService.page(new Page<>(pageNumber, pageSize), queryWrapper); + Map 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 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 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 detail) { + SysApiKey sysApiKey = sysApiKeyService.getSysApiKey(apiKey); + Map payload = new HashMap<>(detail); + payload.put("apiKeyId", sysApiKey.getId()); + payload.put("channel", "API"); + knowledgeShareAuditService.log(null, actionName, actionType, actionUrl, payload); + } + + private List toKnowledgeSearchResult(List documents) { + List 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)); + } +} diff --git a/easyflow-commons/easyflow-common-web/src/main/java/tech/easyflow/common/web/error/GlobalErrorResolver.java b/easyflow-commons/easyflow-common-web/src/main/java/tech/easyflow/common/web/error/GlobalErrorResolver.java index e520a4b..223b684 100644 --- a/easyflow-commons/easyflow-common-web/src/main/java/tech/easyflow/common/web/error/GlobalErrorResolver.java +++ b/easyflow-commons/easyflow-common-web/src/main/java/tech/easyflow/common/web/error/GlobalErrorResolver.java @@ -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()); diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/constants/KnowledgeShareErrorCode.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/constants/KnowledgeShareErrorCode.java new file mode 100644 index 0000000..67b98af --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/constants/KnowledgeShareErrorCode.java @@ -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() { + } +} + diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/dto/KnowledgeShareApiGrantRequest.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/dto/KnowledgeShareApiGrantRequest.java new file mode 100644 index 0000000..42d2e4e --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/dto/KnowledgeShareApiGrantRequest.java @@ -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 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 getActionScopes() { + return actionScopes; + } + + public void setActionScopes(Set actionScopes) { + this.actionScopes = actionScopes; + } +} + diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/dto/KnowledgeShareLimitedConfigRequest.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/dto/KnowledgeShareLimitedConfigRequest.java new file mode 100644 index 0000000..80431ae --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/dto/KnowledgeShareLimitedConfigRequest.java @@ -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; + } +} + diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/KnowledgeShare.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/KnowledgeShare.java new file mode 100644 index 0000000..cdfb967 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/KnowledgeShare.java @@ -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 getPermissionScopeSet() { + if (getPermissionSet() == null || getPermissionSet().isBlank()) { + return Collections.emptySet(); + } + Set 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 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; + } +} + diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/base/KnowledgeShareBase.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/base/KnowledgeShareBase.java new file mode 100644 index 0000000..7a15d5c --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/base/KnowledgeShareBase.java @@ -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; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/enums/KnowledgeShareActionScope.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/enums/KnowledgeShareActionScope.java new file mode 100644 index 0000000..5a921e4 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/enums/KnowledgeShareActionScope.java @@ -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 normalize(Iterable values) { + Set 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 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 分享授权范围。 + * + *

产品上固定开放查看、检索、新增、更新、导入导出,不提供删除能力。

+ * + * @return 默认 API 授权范围 + */ + public static Set defaultApiScopes() { + return new LinkedHashSet<>(Arrays.asList( + VIEW.name(), + SEARCH.name(), + CONTENT_CREATE.name(), + CONTENT_UPDATE.name(), + IMPORT_EXPORT.name() + )); + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/enums/KnowledgeShareStatus.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/enums/KnowledgeShareStatus.java new file mode 100644 index 0000000..9e91344 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/enums/KnowledgeShareStatus.java @@ -0,0 +1,11 @@ +package tech.easyflow.ai.enums; + +/** + * 知识库分享状态。 + */ +public enum KnowledgeShareStatus { + ENABLED, + DISABLED, + REVOKED +} + diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/enums/KnowledgeShareType.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/enums/KnowledgeShareType.java new file mode 100644 index 0000000..7bcc4d7 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/enums/KnowledgeShareType.java @@ -0,0 +1,9 @@ +package tech.easyflow.ai.enums; + +/** + * 知识库分享类型。 + */ +public enum KnowledgeShareType { + URL +} + diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/mapper/KnowledgeShareMapper.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/mapper/KnowledgeShareMapper.java new file mode 100644 index 0000000..81a5263 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/mapper/KnowledgeShareMapper.java @@ -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 { +} + diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/KnowledgeEmbeddingService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/KnowledgeEmbeddingService.java new file mode 100644 index 0000000..4c00b40 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/KnowledgeEmbeddingService.java @@ -0,0 +1,17 @@ +package tech.easyflow.ai.service; + +import java.math.BigInteger; + +/** + * 知识库向量重建服务。 + */ +public interface KnowledgeEmbeddingService { + + /** + * 按知识库重建向量数据。 + * + * @param knowledgeId 知识库 ID + */ + void rebuildKnowledgeVectors(BigInteger knowledgeId); +} + diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/KnowledgeShareAuditService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/KnowledgeShareAuditService.java new file mode 100644 index 0000000..d2df35e --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/KnowledgeShareAuditService.java @@ -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 detail); +} + diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/KnowledgeSharePermissionService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/KnowledgeSharePermissionService.java new file mode 100644 index 0000000..ceccdcf --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/KnowledgeSharePermissionService.java @@ -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 actionScopes); + + /** + * 按访问令牌维度开启或关闭知识库 API 分享授权。 + * + *

产品固定为全量知识库的非删除范围,不向前端暴露动作粒度。

+ * + * @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); +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/KnowledgeShareService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/KnowledgeShareService.java new file mode 100644 index 0000000..b5beb2b --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/KnowledgeShareService.java @@ -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 { + + /** + * 创建 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 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 + ); +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/KnowledgeEmbeddingServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/KnowledgeEmbeddingServiceImpl.java new file mode 100644 index 0000000..1eaf661 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/KnowledgeEmbeddingServiceImpl.java @@ -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 chunks = documentChunkService.list(wrapper); + List ids = new ArrayList<>(); + List 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 faqItems = faqItemService.list(wrapper); + List ids = new ArrayList<>(); + List 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 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 ids, + List 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 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); + } +} + diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/KnowledgeShareAuditServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/KnowledgeShareAuditServiceImpl.java new file mode 100644 index 0000000..5e7d822 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/KnowledgeShareAuditServiceImpl.java @@ -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 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); + } +} + diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/KnowledgeSharePermissionServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/KnowledgeSharePermissionServiceImpl.java new file mode 100644 index 0000000..12cc222 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/KnowledgeSharePermissionServiceImpl.java @@ -0,0 +1,174 @@ +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> 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/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/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 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 normalizedScopes = KnowledgeShareActionScope.normalize(actionScopes); + if (normalizedScopes.isEmpty()) { + throw new BusinessException("动作范围不能为空"); + } + + mappingService.removeScopedMappings(apiKeyId, RESOURCE_TYPE_KNOWLEDGE, knowledgeId); + List rows = new ArrayList<>(); + for (String scope : normalizedScopes) { + List 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 rows = new ArrayList<>(); + for (String scope : KnowledgeShareActionScope.defaultApiScopes()) { + List 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; + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/KnowledgeShareServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/KnowledgeShareServiceImpl.java new file mode 100644 index 0000000..8889da0 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/KnowledgeShareServiceImpl.java @@ -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 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 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 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); + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/vo/KnowledgeShareAuthContext.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/vo/KnowledgeShareAuthContext.java new file mode 100644 index 0000000..3780ad2 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/vo/KnowledgeShareAuthContext.java @@ -0,0 +1,32 @@ +package tech.easyflow.ai.vo; + +import tech.easyflow.ai.entity.DocumentCollection; +import tech.easyflow.ai.entity.KnowledgeShare; + +import java.io.Serializable; + +/** + * 知识库分享鉴权上下文。 + */ +public class KnowledgeShareAuthContext implements Serializable { + + private KnowledgeShare share; + private DocumentCollection knowledge; + + public KnowledgeShare getShare() { + return share; + } + + public void setShare(KnowledgeShare share) { + this.share = share; + } + + public DocumentCollection getKnowledge() { + return knowledge; + } + + public void setKnowledge(DocumentCollection knowledge) { + this.knowledge = knowledge; + } +} + diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/vo/KnowledgeShareUrlCreateResult.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/vo/KnowledgeShareUrlCreateResult.java new file mode 100644 index 0000000..26f56e3 --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/vo/KnowledgeShareUrlCreateResult.java @@ -0,0 +1,48 @@ +package tech.easyflow.ai.vo; + +import java.io.Serializable; +import java.math.BigInteger; +import java.util.Date; + +/** + * URL 分享创建结果。 + */ +public class KnowledgeShareUrlCreateResult implements Serializable { + + private BigInteger id; + private String shareKey; + private String shareUrl; + private Date expiresAt; + + public BigInteger getId() { + return id; + } + + public void setId(BigInteger id) { + this.id = id; + } + + public String getShareKey() { + return shareKey; + } + + public void setShareKey(String shareKey) { + this.shareKey = shareKey; + } + + public String getShareUrl() { + return shareUrl; + } + + public void setShareUrl(String shareUrl) { + this.shareUrl = shareUrl; + } + + public Date getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Date expiresAt) { + this.expiresAt = expiresAt; + } +} diff --git a/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/config/CurdInterceptor.java b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/config/CurdInterceptor.java index 0dab329..ff8d0ae 100644 --- a/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/config/CurdInterceptor.java +++ b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/config/CurdInterceptor.java @@ -21,9 +21,20 @@ public class CurdInterceptor implements HandlerInterceptor { public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String requestURI = request.getRequestURI(); + String contextPath = request.getContextPath(); + String normalizedRequestUri = requestURI; + if (StrUtil.isNotBlank(contextPath) && StrUtil.startWith(requestURI, contextPath)) { + normalizedRequestUri = requestURI.substring(contextPath.length()); + } log.info("进入 CurdInterceptor requestURI:{}", requestURI); + // 分享访问接口与公开 API 走各自的业务鉴权,不走后台通用 CRUD 权限码拦截。 + if (StrUtil.startWith(normalizedRequestUri, "/api/v1/share/") + || StrUtil.startWith(normalizedRequestUri, "/public-api/")) { + return true; + } + String groupName = ""; // 检查handler是否是HandlerMethod类型 if (handler instanceof HandlerMethod) { diff --git a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/entity/SysApiKey.java b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/entity/SysApiKey.java index ddabb27..0871f58 100644 --- a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/entity/SysApiKey.java +++ b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/entity/SysApiKey.java @@ -23,6 +23,9 @@ public class SysApiKey extends SysApiKeyBase { @Column(ignore = true) List permissionIds; + @Column(ignore = true) + private Boolean knowledgeShareEnabled; + @RelationOneToMany(selfField = "id", targetField = "apiKeyId", targetTable = "tb_sys_api_key_resource_mapping") private List resourcePermissions; @@ -41,4 +44,12 @@ public class SysApiKey extends SysApiKeyBase { public void setPermissionIds(List permissionIds) { this.permissionIds = permissionIds; } + + public Boolean getKnowledgeShareEnabled() { + return knowledgeShareEnabled; + } + + public void setKnowledgeShareEnabled(Boolean knowledgeShareEnabled) { + this.knowledgeShareEnabled = knowledgeShareEnabled; + } } diff --git a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/entity/base/SysApiKeyResourceMappingBase.java b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/entity/base/SysApiKeyResourceMappingBase.java index eba213e..b335baf 100644 --- a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/entity/base/SysApiKeyResourceMappingBase.java +++ b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/entity/base/SysApiKeyResourceMappingBase.java @@ -29,6 +29,24 @@ public class SysApiKeyResourceMappingBase implements Serializable { @Column(comment = "请求接口资源访问id") private BigInteger apiKeyResourceId; + /** + * 资源类型 + */ + @Column(comment = "资源类型") + private String resourceType; + + /** + * 资源目标ID + */ + @Column(comment = "资源目标ID") + private BigInteger resourceTargetId; + + /** + * 动作范围 + */ + @Column(comment = "动作范围") + private String actionScope; + public BigInteger getId() { return id; } @@ -53,4 +71,28 @@ public class SysApiKeyResourceMappingBase implements Serializable { this.apiKeyResourceId = apiKeyResourceId; } + public String getResourceType() { + return resourceType; + } + + public void setResourceType(String resourceType) { + this.resourceType = resourceType; + } + + public BigInteger getResourceTargetId() { + return resourceTargetId; + } + + public void setResourceTargetId(BigInteger resourceTargetId) { + this.resourceTargetId = resourceTargetId; + } + + public String getActionScope() { + return actionScope; + } + + public void setActionScope(String actionScope) { + this.actionScope = actionScope; + } + } diff --git a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/SysApiKeyResourceMappingService.java b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/SysApiKeyResourceMappingService.java index 7207f7a..2bc908e 100644 --- a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/SysApiKeyResourceMappingService.java +++ b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/SysApiKeyResourceMappingService.java @@ -4,6 +4,8 @@ import com.mybatisflex.core.service.IService; import tech.easyflow.system.entity.SysApiKey; import tech.easyflow.system.entity.SysApiKeyResourceMapping; +import java.math.BigInteger; + /** * apikey-请求接口表 服务层。 * @@ -13,4 +15,21 @@ import tech.easyflow.system.entity.SysApiKeyResourceMapping; public interface SysApiKeyResourceMappingService extends IService { void authInterface(SysApiKey entity); + + /** + * 移除指定 apiKey 下某个资源目标的 scope 授权。 + * + * @param apiKeyId apiKey ID + * @param resourceType 资源类型 + * @param resourceTargetId 资源目标 ID + */ + void removeScopedMappings(BigInteger apiKeyId, String resourceType, BigInteger resourceTargetId); + + /** + * 按资源类型移除指定 apiKey 下的全部资源级授权。 + * + * @param apiKeyId apiKey ID + * @param resourceType 资源类型 + */ + void removeScopedMappings(BigInteger apiKeyId, String resourceType); } diff --git a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/SysApiKeyService.java b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/SysApiKeyService.java index d64dc39..2f77e2d 100644 --- a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/SysApiKeyService.java +++ b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/SysApiKeyService.java @@ -3,6 +3,8 @@ package tech.easyflow.system.service; import com.mybatisflex.core.service.IService; import tech.easyflow.system.entity.SysApiKey; +import java.math.BigInteger; + /** * 服务层。 * @@ -15,4 +17,15 @@ public interface SysApiKeyService extends IService { SysApiKey getSysApiKey(String apiKey); + /** + * 校验资源级作用域权限。 + * + * @param apiKeyId apiKey 记录 ID + * @param requestURI 请求 URI + * @param resourceType 资源类型 + * @param resourceTargetId 资源目标 ID + * @param actionScope 动作范围 + */ + void checkResourceScope(BigInteger apiKeyId, String requestURI, String resourceType, BigInteger resourceTargetId, String actionScope); + } diff --git a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/SysApiKeyResourceMappingServiceImpl.java b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/SysApiKeyResourceMappingServiceImpl.java index 134c4f1..444ca46 100644 --- a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/SysApiKeyResourceMappingServiceImpl.java +++ b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/SysApiKeyResourceMappingServiceImpl.java @@ -46,7 +46,10 @@ public class SysApiKeyResourceMappingServiceImpl extends ServiceImpl { - this.remove(QueryWrapper.create().eq(SysApiKeyResourceMapping::getApiKeyId, apiKeyId)); + QueryWrapper removeWrapper = QueryWrapper.create() + .eq(SysApiKeyResourceMapping::getApiKeyId, apiKeyId) + .isNull(SysApiKeyResourceMapping::getResourceType); + this.remove(removeWrapper); if (entity.getPermissionIds() == null || entity.getPermissionIds().isEmpty()) { return; } @@ -66,4 +69,29 @@ public class SysApiKeyResourceMappingServiceImpl extends ServiceImpl candidateRequestUris = getCandidateRequestUris(requestURI); + QueryWrapper resourceWrapper = QueryWrapper.create(); + resourceWrapper.in(SysApiKeyResource::getRequestInterface, candidateRequestUris); + List resources = resourceService.list(resourceWrapper); + if (resources == null || resources.isEmpty()) { + throw new BusinessException("该接口不存在"); + } + List resourceIds = resources.stream() + .map(SysApiKeyResource::getId) + .toList(); + QueryWrapper exactScopeWrapper = QueryWrapper.create() + .eq(SysApiKeyResourceMapping::getApiKeyId, apiKeyId) + .in(SysApiKeyResourceMapping::getApiKeyResourceId, resourceIds) + .eq(SysApiKeyResourceMapping::getResourceType, resourceType) + .eq(SysApiKeyResourceMapping::getResourceTargetId, resourceTargetId) + .eq(SysApiKeyResourceMapping::getActionScope, actionScope); + if (mappingService.count(exactScopeWrapper) > 0) { + return; + } + QueryWrapper globalScopeWrapper = QueryWrapper.create(); + globalScopeWrapper.eq(SysApiKeyResourceMapping::getApiKeyId, apiKeyId); + globalScopeWrapper.in(SysApiKeyResourceMapping::getApiKeyResourceId, resourceIds); + globalScopeWrapper.eq(SysApiKeyResourceMapping::getResourceType, resourceType); + globalScopeWrapper.isNull(SysApiKeyResourceMapping::getResourceTargetId); + globalScopeWrapper.eq(SysApiKeyResourceMapping::getActionScope, actionScope); + if (mappingService.count(globalScopeWrapper) == 0) { + throw new BusinessException("该apiKey无权限访问当前资源"); + } + } + } diff --git a/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V11__mysql_knowledge_share_patch.sql b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V11__mysql_knowledge_share_patch.sql new file mode 100644 index 0000000..2565012 --- /dev/null +++ b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V11__mysql_knowledge_share_patch.sql @@ -0,0 +1,111 @@ +SET NAMES utf8mb4; + +DROP PROCEDURE IF EXISTS `sp_add_column_if_missing`; +DROP PROCEDURE IF EXISTS `sp_drop_index_if_exists`; +DROP PROCEDURE IF EXISTS `sp_add_index_if_missing`; + +DELIMITER $$ +CREATE PROCEDURE `sp_add_column_if_missing`( + IN in_table_name VARCHAR(128), + IN in_column_name VARCHAR(128), + IN in_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = in_table_name + AND COLUMN_NAME = in_column_name + ) THEN + SET @ddl = CONCAT('ALTER TABLE `', in_table_name, '` ADD COLUMN ', in_definition); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END $$ + +CREATE PROCEDURE `sp_drop_index_if_exists`( + IN in_table_name VARCHAR(128), + IN in_index_name VARCHAR(128) +) +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = in_table_name + AND INDEX_NAME = in_index_name + ) THEN + SET @ddl = CONCAT('ALTER TABLE `', in_table_name, '` DROP INDEX `', in_index_name, '`'); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END $$ + +CREATE PROCEDURE `sp_add_index_if_missing`( + IN in_table_name VARCHAR(128), + IN in_index_name VARCHAR(128), + IN in_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = in_table_name + AND INDEX_NAME = in_index_name + ) THEN + SET @ddl = CONCAT('ALTER TABLE `', in_table_name, '` ADD ', in_definition); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END $$ +DELIMITER ; + +CALL `sp_add_column_if_missing`('tb_sys_api_key_resource_mapping', 'resource_type', + '`resource_type` varchar(32) NULL DEFAULT NULL COMMENT ''资源类型'' AFTER `api_key_resource_id`'); +CALL `sp_add_column_if_missing`('tb_sys_api_key_resource_mapping', 'resource_target_id', + '`resource_target_id` bigint UNSIGNED NULL DEFAULT NULL COMMENT ''资源目标ID'' AFTER `resource_type`'); +CALL `sp_add_column_if_missing`('tb_sys_api_key_resource_mapping', 'action_scope', + '`action_scope` varchar(64) NULL DEFAULT NULL COMMENT ''动作范围'' AFTER `resource_target_id`'); + +CALL `sp_drop_index_if_exists`('tb_sys_api_key_resource_mapping', 'uni_api_key_resource'); +CALL `sp_add_index_if_missing`( + 'tb_sys_api_key_resource_mapping', + 'uni_api_key_resource_scope', + 'UNIQUE INDEX `uni_api_key_resource_scope`(`api_key_id`, `api_key_resource_id`, `resource_type`, `resource_target_id`, `action_scope`) USING BTREE' +); +CALL `sp_add_index_if_missing`( + 'tb_sys_api_key_resource_mapping', + 'idx_api_key_resource_scope_lookup', + 'INDEX `idx_api_key_resource_scope_lookup`(`api_key_id`, `resource_type`, `resource_target_id`, `action_scope`) USING BTREE' +); + +CREATE TABLE IF NOT EXISTS `tb_knowledge_share` +( + `id` bigint UNSIGNED NOT NULL COMMENT 'ID', + `knowledge_id` bigint UNSIGNED NOT NULL COMMENT '知识库ID', + `share_type` varchar(32) NOT NULL COMMENT '分享类型', + `share_code` varchar(64) NOT NULL COMMENT '分享码', + `share_token_hash` varchar(128) NOT NULL COMMENT '分享令牌哈希', + `status` varchar(32) NOT NULL COMMENT '分享状态', + `permission_set` text NULL COMMENT '授权范围', + `expires_at` datetime NOT NULL COMMENT '过期时间', + `tenant_id` bigint UNSIGNED NOT NULL COMMENT '租户ID', + `dept_id` bigint UNSIGNED NOT NULL COMMENT '部门ID', + `created` datetime NULL DEFAULT NULL COMMENT '创建时间', + `created_by` bigint UNSIGNED NULL DEFAULT NULL COMMENT '创建人', + `modified` datetime NULL DEFAULT NULL COMMENT '修改时间', + `modified_by` bigint UNSIGNED NULL DEFAULT NULL COMMENT '修改人', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uni_knowledge_share_code`(`share_code`) USING BTREE, + INDEX `idx_knowledge_share_knowledge_id`(`knowledge_id`) USING BTREE, + INDEX `idx_knowledge_share_expires_at`(`expires_at`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '知识库分享记录' ROW_FORMAT = Dynamic; + +DROP PROCEDURE IF EXISTS `sp_add_column_if_missing`; +DROP PROCEDURE IF EXISTS `sp_drop_index_if_exists`; +DROP PROCEDURE IF EXISTS `sp_add_index_if_missing`; diff --git a/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V12__mysql_knowledge_share_share_key_refactor.sql b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V12__mysql_knowledge_share_share_key_refactor.sql new file mode 100644 index 0000000..60f3db9 --- /dev/null +++ b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V12__mysql_knowledge_share_share_key_refactor.sql @@ -0,0 +1,121 @@ +SET NAMES utf8mb4; + +DROP PROCEDURE IF EXISTS `sp_drop_index_if_exists`; +DROP PROCEDURE IF EXISTS `sp_drop_column_if_exists`; +DROP PROCEDURE IF EXISTS `sp_rename_column_if_exists`; +DROP PROCEDURE IF EXISTS `sp_add_index_if_missing`; + +DELIMITER $$ +CREATE PROCEDURE `sp_drop_index_if_exists`( + IN in_table_name VARCHAR(128), + IN in_index_name VARCHAR(128) +) +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = in_table_name + AND INDEX_NAME = in_index_name + ) THEN + SET @ddl = CONCAT('ALTER TABLE `', in_table_name, '` DROP INDEX `', in_index_name, '`'); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END $$ + +CREATE PROCEDURE `sp_drop_column_if_exists`( + IN in_table_name VARCHAR(128), + IN in_column_name VARCHAR(128) +) +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = in_table_name + AND COLUMN_NAME = in_column_name + ) THEN + SET @ddl = CONCAT('ALTER TABLE `', in_table_name, '` DROP COLUMN `', in_column_name, '`'); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END $$ + +CREATE PROCEDURE `sp_rename_column_if_exists`( + IN in_table_name VARCHAR(128), + IN in_old_column VARCHAR(128), + IN in_new_column VARCHAR(128), + IN in_definition TEXT +) +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = in_table_name + AND COLUMN_NAME = in_old_column + ) AND NOT EXISTS ( + SELECT 1 + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = in_table_name + AND COLUMN_NAME = in_new_column + ) THEN + SET @ddl = CONCAT( + 'ALTER TABLE `', in_table_name, '` RENAME COLUMN `', in_old_column, '` TO `', in_new_column, '`' + ); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + + SET @ddl = CONCAT( + 'ALTER TABLE `', in_table_name, '` MODIFY COLUMN `', in_new_column, '` ', in_definition + ); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END $$ + +CREATE PROCEDURE `sp_add_index_if_missing`( + IN in_table_name VARCHAR(128), + IN in_index_name VARCHAR(128), + IN in_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = in_table_name + AND INDEX_NAME = in_index_name + ) THEN + SET @ddl = CONCAT('ALTER TABLE `', in_table_name, '` ADD ', in_definition); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END $$ +DELIMITER ; + +CALL `sp_drop_index_if_exists`('tb_knowledge_share', 'uni_knowledge_share_code'); +CALL `sp_rename_column_if_exists`( + 'tb_knowledge_share', + 'share_token_hash', + 'share_key_hash', + 'varchar(128) NOT NULL COMMENT ''分享访问密钥哈希''' +); +CALL `sp_drop_column_if_exists`('tb_knowledge_share', 'share_code'); +CALL `sp_add_index_if_missing`( + 'tb_knowledge_share', + 'uni_knowledge_share_key_hash', + 'UNIQUE INDEX `uni_knowledge_share_key_hash`(`share_key_hash`) USING BTREE' +); + +DROP PROCEDURE IF EXISTS `sp_drop_index_if_exists`; +DROP PROCEDURE IF EXISTS `sp_drop_column_if_exists`; +DROP PROCEDURE IF EXISTS `sp_rename_column_if_exists`; +DROP PROCEDURE IF EXISTS `sp_add_index_if_missing`; diff --git a/easyflow-ui-admin/app/src/api/knowledge-share.ts b/easyflow-ui-admin/app/src/api/knowledge-share.ts new file mode 100644 index 0000000..2474040 --- /dev/null +++ b/easyflow-ui-admin/app/src/api/knowledge-share.ts @@ -0,0 +1,82 @@ +import { api } from '#/api/request'; + +const EXPIRED_ERROR_CODES = new Set([4601, 4602]); +const SHARE_ERROR_REASON: Record = { + 4601: 'expired', + 4602: 'invalid', +}; +const APP_BASE_PATH = (import.meta.env.BASE_URL || '/').replace(/\/$/, ''); + +function getShareParams() { + const params = new URLSearchParams(window.location.search); + const shareKey = params.get('shareKey') || ''; + return { shareKey }; +} + +function appendShareQuery(url: string, extraParams?: Record) { + const base = + typeof window === 'undefined' ? 'http://localhost' : window.location.origin; + const target = new URL(url, base); + const { shareKey } = getShareParams(); + if (shareKey) { + target.searchParams.set('shareKey', shareKey); + } + Object.entries(extraParams || {}).forEach(([key, value]) => { + if (value === undefined || value === null || value === '') { + return; + } + target.searchParams.set(key, String(value)); + }); + return `${target.pathname}${target.search}`; +} + +function redirectIfExpired(response: any) { + const errorCode = Number(response?.errorCode); + if (!response || !EXPIRED_ERROR_CODES.has(errorCode)) { + return response; + } + const reason = SHARE_ERROR_REASON[errorCode] || 'expired'; + window.location.assign( + `${APP_BASE_PATH}/share/knowledge/expired?reason=${reason}`, + ); + return response; +} + +export const knowledgeShareApi = { + get(url: string, config?: Record) { + return api + .get(appendShareQuery(url, config?.params), { + ...config, + params: undefined, + }) + .then(redirectIfExpired); + }, + post(url: string, data?: any, config?: Record) { + return api + .post(appendShareQuery(url), data, config) + .then(redirectIfExpired); + }, + postFile(url: string, data?: any, config?: Record) { + return api + .postFile(appendShareQuery(url), data, config) + .then(redirectIfExpired); + }, + upload(url: string, data?: any, config?: Record) { + return api + .upload(appendShareQuery(url), data, config) + .then(redirectIfExpired); + }, + download(url: string, config?: Record) { + return api.download(appendShareQuery(url, config?.params), { + ...config, + params: undefined, + }); + }, +}; + +export function buildKnowledgeShareUrl( + url: string, + extraParams?: Record, +) { + return appendShareQuery(url, extraParams); +} diff --git a/easyflow-ui-admin/app/src/locales/langs/en-US/documentCollectionSearch.json b/easyflow-ui-admin/app/src/locales/langs/en-US/documentCollectionSearch.json index 152aa8d..dcb3c6d 100644 --- a/easyflow-ui-admin/app/src/locales/langs/en-US/documentCollectionSearch.json +++ b/easyflow-ui-admin/app/src/locales/langs/en-US/documentCollectionSearch.json @@ -12,6 +12,12 @@ "save": "Save Configuration" }, "engineHint": "The keyword search engine is controlled by platform-level configuration and is no longer configured per knowledge base.", + "retrievalModeTitle": "Retrieval Mode Guide", + "retrievalModeDescriptions": { + "hybrid": "Combines semantic vector recall with keyword matching for balanced default retrieval.", + "vector": "Focuses on semantic similarity and works better when query wording differs from the source text.", + "keyword": "Focuses on literal term matching and is suitable for precise lookups such as terms, IDs, and names." + }, "message": { "saveSuccess": "Configuration saved successfully", "saveFailed": "Configuration saved failed" diff --git a/easyflow-ui-admin/app/src/locales/langs/en-US/sysApiKey.json b/easyflow-ui-admin/app/src/locales/langs/en-US/sysApiKey.json index 19cc1ec..8f3f67b 100644 --- a/easyflow-ui-admin/app/src/locales/langs/en-US/sysApiKey.json +++ b/easyflow-ui-admin/app/src/locales/langs/en-US/sysApiKey.json @@ -14,5 +14,6 @@ "failure": "Failure" }, "permissions": "AuthInterface", + "knowledgeSharePermission": "Knowledge Share", "addApiKeyNotice": "This operation will generate an API key. Please confirm whether to proceed" } diff --git a/easyflow-ui-admin/app/src/locales/langs/zh-CN/documentCollectionSearch.json b/easyflow-ui-admin/app/src/locales/langs/zh-CN/documentCollectionSearch.json index 0185111..de08940 100644 --- a/easyflow-ui-admin/app/src/locales/langs/zh-CN/documentCollectionSearch.json +++ b/easyflow-ui-admin/app/src/locales/langs/zh-CN/documentCollectionSearch.json @@ -12,6 +12,12 @@ "save": "保存配置" }, "engineHint": "关键词检索引擎由平台全局配置决定,知识库侧不再单独配置。", + "retrievalModeTitle": "召回方式说明", + "retrievalModeDescriptions": { + "hybrid": "同时结合向量语义与关键词匹配,适合默认场景,结果更均衡。", + "vector": "优先按语义相似度召回,适合问法与原文表达差异较大的查询。", + "keyword": "优先按字面词项匹配,适合术语、编号、专有名词等精确查找。" + }, "message": { "saveSuccess": "配置保存成功", "saveFailed": "配置保存失败" diff --git a/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysApiKey.json b/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysApiKey.json index dac3fc0..fe21b23 100644 --- a/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysApiKey.json +++ b/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysApiKey.json @@ -14,5 +14,6 @@ "failure": "已失效" }, "permissions": "授权接口", + "knowledgeSharePermission": "知识库分享授权", "addApiKeyNotice": "该操作会生成一个apiKey,请确认是否生成" } diff --git a/easyflow-ui-admin/app/src/router/guard.ts b/easyflow-ui-admin/app/src/router/guard.ts index ae4224a..15301ff 100644 --- a/easyflow-ui-admin/app/src/router/guard.ts +++ b/easyflow-ui-admin/app/src/router/guard.ts @@ -28,6 +28,10 @@ interface NetworkConnectionLike { const CHUNK_ERROR_RELOAD_KEY = '__easyflow_chunk_error_reload_path__'; +function isKnowledgeShareRoute(path: string) { + return path === '/share/knowledge' || path === '/share/knowledge/expired'; +} + function isSlowNetworkConnection() { if (typeof navigator === 'undefined') { return false; @@ -238,6 +242,10 @@ function setupAccessGuard(router: Router) { return buildForcePasswordRoute(); } + if (isKnowledgeShareRoute(to.path)) { + return true; + } + // 是否已经生成过动态路由 if (accessStore.isAccessChecked) { return true; diff --git a/easyflow-ui-admin/app/src/router/routes/external/share.ts b/easyflow-ui-admin/app/src/router/routes/external/share.ts new file mode 100644 index 0000000..de77ed7 --- /dev/null +++ b/easyflow-ui-admin/app/src/router/routes/external/share.ts @@ -0,0 +1,32 @@ +import type { RouteRecordRaw } from 'vue-router'; + +const routes: RouteRecordRaw[] = [ + { + name: 'KnowledgeShare', + path: '/share/knowledge', + component: () => + import('#/views/ai/documentCollection/KnowledgeShareView.vue'), + meta: { + title: 'Knowledge Share', + noBasicLayout: true, + hideInMenu: true, + hideInBreadcrumb: true, + hideInTab: true, + }, + }, + { + name: 'KnowledgeShareExpired', + path: '/share/knowledge/expired', + component: () => + import('#/views/ai/documentCollection/KnowledgeShareExpired.vue'), + meta: { + title: 'Knowledge Share Expired', + noBasicLayout: true, + hideInMenu: true, + hideInBreadcrumb: true, + hideInTab: true, + }, + }, +]; + +export default routes; diff --git a/easyflow-ui-admin/app/src/router/routes/index.ts b/easyflow-ui-admin/app/src/router/routes/index.ts index a1cc113..8238b1a 100644 --- a/easyflow-ui-admin/app/src/router/routes/index.ts +++ b/easyflow-ui-admin/app/src/router/routes/index.ts @@ -9,17 +9,18 @@ const dynamicRouteFiles = import.meta.glob('./modules/**/*.ts', { }); // 有需要可以自行打开注释,并创建文件夹 -// const externalRouteFiles = import.meta.glob('./external/**/*.ts', { eager: true }); +const externalRouteFiles = import.meta.glob('./external/**/*.ts', { + eager: true, +}); // const staticRouteFiles = import.meta.glob('./static/**/*.ts', { eager: true }); /** 动态路由 */ const dynamicRoutes: RouteRecordRaw[] = mergeRouteModules(dynamicRouteFiles); /** 外部路由列表,访问这些页面可以不需要Layout,可能用于内嵌在别的系统(不会显示在菜单中) */ -// const externalRoutes: RouteRecordRaw[] = mergeRouteModules(externalRouteFiles); +const externalRoutes: RouteRecordRaw[] = mergeRouteModules(externalRouteFiles); // const staticRoutes: RouteRecordRaw[] = mergeRouteModules(staticRouteFiles); const staticRoutes: RouteRecordRaw[] = []; -const externalRoutes: RouteRecordRaw[] = []; /** 路由列表,由基本路由、外部路由和404兜底路由组成 * 无需走权限验证(会一直显示在菜单中) */ diff --git a/easyflow-ui-admin/app/src/views/ai/documentCollection/ChunkDocumentTable.vue b/easyflow-ui-admin/app/src/views/ai/documentCollection/ChunkDocumentTable.vue index 21c5a31..eb5ab86 100644 --- a/easyflow-ui-admin/app/src/views/ai/documentCollection/ChunkDocumentTable.vue +++ b/easyflow-ui-admin/app/src/views/ai/documentCollection/ChunkDocumentTable.vue @@ -21,6 +21,7 @@ import { import { api } from '#/api/request'; import PageData from '#/components/page/PageData.vue'; +import { buildKnowledgePath } from '#/views/ai/documentCollection/share-path'; const props = defineProps({ documentId: { @@ -31,6 +32,14 @@ const props = defineProps({ type: Boolean, default: true, }, + requestClient: { + type: Object as any, + default: () => api, + }, + endpointPrefix: { + type: String, + default: '', + }, }); const dialogVisible = ref(false); const pageDataRef = ref(); @@ -54,8 +63,14 @@ const handleDelete = (row: any) => { }) .then(() => { btnLoading.value = true; - api - .post('/api/v1/documentChunk/removeChunk', { id: row.id }) + props.requestClient + .post( + buildKnowledgePath( + props.endpointPrefix, + '/api/v1/documentChunk/removeChunk', + ), + { id: row.id }, + ) .then((res: any) => { btnLoading.value = false; if (res.errorCode !== 0) { @@ -85,16 +100,21 @@ const save = () => { return; } btnLoading.value = true; - api.post('/api/v1/documentChunk/update', form.value).then((res: any) => { - btnLoading.value = false; - if (res.errorCode !== 0) { - ElMessage.error(res.message); - return; - } - ElMessage.success($t('message.updateOkMessage')); - pageDataRef.value.setQuery(queryParams); - closeDialog(); - }); + props.requestClient + .post( + buildKnowledgePath(props.endpointPrefix, '/api/v1/documentChunk/update'), + form.value, + ) + .then((res: any) => { + btnLoading.value = false; + if (res.errorCode !== 0) { + ElMessage.error(res.message); + return; + } + ElMessage.success($t('message.updateOkMessage')); + pageDataRef.value.setQuery(queryParams); + closeDialog(); + }); }; const btnLoading = ref(false); const basicFormRef = ref(); @@ -107,9 +127,12 @@ const form = ref({