feat: 收口知识库分享链路
- 新增 shareKey 单参数 URL 分享页与失效页 - 新增知识库分享后端鉴权、审计与迁移脚本 - 在访问令牌中增加知识库分享授权入口
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
package tech.easyflow.admin.controller.ai;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import tech.easyflow.ai.dto.KnowledgeShareApiGrantRequest;
|
||||
import tech.easyflow.ai.entity.DocumentCollection;
|
||||
import tech.easyflow.ai.enums.KnowledgeShareActionScope;
|
||||
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||
import tech.easyflow.ai.service.KnowledgeShareAuditService;
|
||||
import tech.easyflow.ai.service.KnowledgeSharePermissionService;
|
||||
import tech.easyflow.ai.service.KnowledgeShareService;
|
||||
import tech.easyflow.ai.vo.KnowledgeShareUrlCreateResult;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.util.RequestUtil;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
import tech.easyflow.system.enums.CategoryResourceType;
|
||||
import tech.easyflow.system.enums.ResourceAction;
|
||||
import tech.easyflow.system.service.ResourceAccessService;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.math.BigInteger;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 知识库分享管理接口。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/knowledgeShare")
|
||||
public class KnowledgeShareController {
|
||||
|
||||
@Resource
|
||||
private KnowledgeShareService knowledgeShareService;
|
||||
@Resource
|
||||
private KnowledgeSharePermissionService knowledgeSharePermissionService;
|
||||
@Resource
|
||||
private KnowledgeShareAuditService knowledgeShareAuditService;
|
||||
@Resource
|
||||
private ResourceAccessService resourceAccessService;
|
||||
@Resource
|
||||
private DocumentCollectionService documentCollectionService;
|
||||
|
||||
/**
|
||||
* 创建 URL 分享。
|
||||
*
|
||||
* @param request HTTP 请求
|
||||
* @param knowledgeId 知识库 ID
|
||||
* @return 创建结果
|
||||
*/
|
||||
@PostMapping("/url/create")
|
||||
@SaCheckPermission("/api/v1/documentCollection/save")
|
||||
public Result<KnowledgeShareUrlCreateResult> createUrlShare(HttpServletRequest request, @JsonBody("knowledgeId") BigInteger knowledgeId) {
|
||||
assertManagePermission(knowledgeId);
|
||||
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
|
||||
KnowledgeShareUrlCreateResult result = knowledgeShareService.createUrlShare(
|
||||
knowledgeId,
|
||||
loginAccount.getTenantId(),
|
||||
loginAccount.getDeptId(),
|
||||
loginAccount.getId(),
|
||||
buildShareBaseUrl(request),
|
||||
KnowledgeShareActionScope.defaultUrlScopes()
|
||||
);
|
||||
knowledgeShareAuditService.log(
|
||||
loginAccount.getId(),
|
||||
"创建知识库 URL 分享",
|
||||
"KNOWLEDGE_SHARE_CREATE",
|
||||
request.getRequestURI(),
|
||||
Map.of("knowledgeId", knowledgeId, "shareId", result.getId())
|
||||
);
|
||||
return Result.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为系统访问令牌配置知识库 API 分享授权。
|
||||
*
|
||||
* @param request 授权请求
|
||||
* @return 结果
|
||||
*/
|
||||
@PostMapping("/api/grant")
|
||||
@SaCheckPermission("/api/v1/documentCollection/save")
|
||||
public Result<Void> grantApiShare(@JsonBody KnowledgeShareApiGrantRequest request) {
|
||||
assertManagePermission(request.getKnowledgeId());
|
||||
knowledgeSharePermissionService.grantApiShare(
|
||||
request.getApiKeyId(),
|
||||
request.getKnowledgeId(),
|
||||
request.getActionScopes()
|
||||
);
|
||||
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
|
||||
knowledgeShareAuditService.log(
|
||||
loginAccount.getId(),
|
||||
"配置知识库 API 分享授权",
|
||||
"KNOWLEDGE_API_SHARE_GRANT",
|
||||
"/api/v1/knowledgeShare/api/grant",
|
||||
Map.of(
|
||||
"knowledgeId", request.getKnowledgeId(),
|
||||
"apiKeyId", request.getApiKeyId(),
|
||||
"actionScopes", request.getActionScopes()
|
||||
)
|
||||
);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
private void assertManagePermission(BigInteger knowledgeId) {
|
||||
DocumentCollection knowledge = documentCollectionService.getById(knowledgeId);
|
||||
if (knowledge == null) {
|
||||
throw new BusinessException("知识库不存在");
|
||||
}
|
||||
resourceAccessService.assertAccess(
|
||||
CategoryResourceType.KNOWLEDGE,
|
||||
knowledge,
|
||||
ResourceAction.MANAGE,
|
||||
"无权限管理知识库"
|
||||
);
|
||||
}
|
||||
|
||||
private String buildShareBaseUrl(HttpServletRequest request) {
|
||||
String referer = RequestUtil.getReferer(request);
|
||||
String refererBaseUrl = extractFrontendBaseUrl(referer);
|
||||
if (refererBaseUrl != null) {
|
||||
return refererBaseUrl + "/share/knowledge";
|
||||
}
|
||||
|
||||
String forwardedOrigin = buildForwardedOrigin(request);
|
||||
if (forwardedOrigin != null) {
|
||||
return forwardedOrigin + normalizeBasePath(firstHeaderValue(request.getHeader("X-Forwarded-Prefix"))) + "/share/knowledge";
|
||||
}
|
||||
|
||||
String origin = normalizeOrigin(request.getHeader("Origin"));
|
||||
if (origin != null) {
|
||||
return origin + normalizeBasePath(request.getContextPath()) + "/share/knowledge";
|
||||
}
|
||||
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append(request.getScheme()).append("://").append(request.getServerName());
|
||||
if (request.getServerPort() != 80 && request.getServerPort() != 443) {
|
||||
builder.append(':').append(request.getServerPort());
|
||||
}
|
||||
builder.append(normalizeBasePath(request.getContextPath())).append("/share/knowledge");
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private String extractFrontendBaseUrl(String url) {
|
||||
if (url == null || url.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
URI uri = new URI(url.trim());
|
||||
if (uri.getScheme() == null || uri.getHost() == null) {
|
||||
return null;
|
||||
}
|
||||
String origin = extractOrigin(url);
|
||||
if (origin == null) {
|
||||
return null;
|
||||
}
|
||||
return origin + inferFrontendBasePath(uri.getPath());
|
||||
} catch (URISyntaxException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String inferFrontendBasePath(String path) {
|
||||
if (path == null || path.isBlank() || "/".equals(path)) {
|
||||
return "";
|
||||
}
|
||||
for (String marker : new String[]{"/ai/", "/auth/", "/share/"}) {
|
||||
int markerIndex = path.indexOf(marker);
|
||||
if (markerIndex > 0) {
|
||||
return normalizeBasePath(path.substring(0, markerIndex));
|
||||
}
|
||||
if (markerIndex == 0) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private String normalizeBasePath(String basePath) {
|
||||
if (basePath == null || basePath.isBlank() || "/".equals(basePath.trim())) {
|
||||
return "";
|
||||
}
|
||||
String normalized = basePath.trim();
|
||||
if (!normalized.startsWith("/")) {
|
||||
normalized = "/" + normalized;
|
||||
}
|
||||
while (normalized.endsWith("/") && normalized.length() > 1) {
|
||||
normalized = normalized.substring(0, normalized.length() - 1);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String buildForwardedOrigin(HttpServletRequest request) {
|
||||
String proto = firstHeaderValue(request.getHeader("X-Forwarded-Proto"));
|
||||
String host = firstHeaderValue(request.getHeader("X-Forwarded-Host"));
|
||||
if (proto == null || host == null) {
|
||||
return null;
|
||||
}
|
||||
return normalizeOrigin(proto + "://" + host);
|
||||
}
|
||||
|
||||
private String extractOrigin(String url) {
|
||||
if (url == null || url.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
URI uri = new URI(url.trim());
|
||||
if (uri.getScheme() == null || uri.getHost() == null) {
|
||||
return null;
|
||||
}
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append(uri.getScheme()).append("://").append(uri.getHost());
|
||||
if (uri.getPort() != -1 && uri.getPort() != 80 && uri.getPort() != 443) {
|
||||
builder.append(':').append(uri.getPort());
|
||||
}
|
||||
return builder.toString();
|
||||
} catch (URISyntaxException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeOrigin(String origin) {
|
||||
return extractOrigin(origin);
|
||||
}
|
||||
|
||||
private String firstHeaderValue(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
int commaIndex = value.indexOf(',');
|
||||
String normalized = commaIndex >= 0 ? value.substring(0, commaIndex) : value;
|
||||
normalized = normalized.trim();
|
||||
return normalized.isEmpty() ? null : normalized;
|
||||
}
|
||||
}
|
||||
@@ -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<String> ALLOWED_IMAGE_TYPES = new HashSet<>(Arrays.asList(
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/gif"
|
||||
));
|
||||
|
||||
@Resource
|
||||
private KnowledgeShareService knowledgeShareService;
|
||||
@Resource
|
||||
private KnowledgeShareAuditService knowledgeShareAuditService;
|
||||
@Resource
|
||||
private DocumentCollectionService documentCollectionService;
|
||||
@Resource
|
||||
private DocumentService documentService;
|
||||
@Resource
|
||||
private DocumentChunkService documentChunkService;
|
||||
@Resource
|
||||
private ModelService modelService;
|
||||
@Resource
|
||||
private FaqItemService faqItemService;
|
||||
@Resource
|
||||
private FaqCategoryService faqCategoryService;
|
||||
@Resource
|
||||
private KnowledgeEmbeddingService knowledgeEmbeddingService;
|
||||
@Resource(name = "default")
|
||||
private FileStorageService fileStorageService;
|
||||
|
||||
/**
|
||||
* 获取知识库详情。
|
||||
*
|
||||
* @param shareKey 分享访问密钥
|
||||
* @return 知识库详情
|
||||
*/
|
||||
@GetMapping("/documentCollection/detail")
|
||||
public Result<DocumentCollection> 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<List<Model>> modelList(
|
||||
@RequestParam String shareKey,
|
||||
@RequestParam String modelType
|
||||
) {
|
||||
knowledgeShareService.assertUrlShareAccess(shareKey, null, KnowledgeShareActionScope.CONFIG_UPDATE.name());
|
||||
Model entity = new Model();
|
||||
entity.setModelType(modelType);
|
||||
return Result.ok(modelService.listSelectableModels(entity, false, null, null));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新受限配置。
|
||||
*
|
||||
* @param request 更新请求
|
||||
* @param shareKey 分享访问密钥
|
||||
* @return 结果
|
||||
*/
|
||||
@PostMapping("/documentCollection/shareConfigUpdate")
|
||||
public Result<Void> updateConfig(
|
||||
@JsonBody KnowledgeShareLimitedConfigRequest request,
|
||||
@RequestParam String shareKey
|
||||
) {
|
||||
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
||||
shareKey,
|
||||
request == null ? null : request.getKnowledgeId(),
|
||||
KnowledgeShareActionScope.CONFIG_UPDATE.name()
|
||||
);
|
||||
BigInteger knowledgeId = resolveKnowledgeId(context, request == null ? null : request.getKnowledgeId());
|
||||
DocumentCollection current = context.getKnowledge();
|
||||
DocumentCollection update = new DocumentCollection();
|
||||
update.setId(current.getId());
|
||||
Map<String, Object> options = current.getOptions() == null
|
||||
? new HashMap<>()
|
||||
: new HashMap<>(current.getOptions());
|
||||
if (request.getVectorEmbedModelId() != null) {
|
||||
update.setVectorEmbedModelId(request.getVectorEmbedModelId());
|
||||
options.put(DocumentCollection.KEY_CAN_UPDATE_EMBEDDING_MODEL, true);
|
||||
Model model = modelService.getModelInstance(request.getVectorEmbedModelId());
|
||||
if (model != null) {
|
||||
update.setDimensionOfVectorModel(Model.getEmbeddingDimension(model.toEmbeddingModel()));
|
||||
}
|
||||
}
|
||||
if (request.getRerankModelId() != null || current.getRerankModelId() != null) {
|
||||
update.setRerankModelId(request.getRerankModelId());
|
||||
}
|
||||
if (request.getRerankEnable() != null) {
|
||||
options.put(DocumentCollection.KEY_RERANK_ENABLE, request.getRerankEnable());
|
||||
}
|
||||
if (request.getDocRecallMaxNum() != null) {
|
||||
options.put(DocumentCollection.KEY_DOC_RECALL_MAX_NUM, request.getDocRecallMaxNum());
|
||||
}
|
||||
if (request.getSimThreshold() != null) {
|
||||
options.put(DocumentCollection.KEY_SIMILARITY_THRESHOLD, BigDecimal.valueOf(request.getSimThreshold()));
|
||||
}
|
||||
update.setOptions(options);
|
||||
documentCollectionService.updateById(update);
|
||||
if (Boolean.TRUE.equals(request.getRebuildVectors())) {
|
||||
knowledgeEmbeddingService.rebuildKnowledgeVectors(knowledgeId);
|
||||
}
|
||||
audit(context, "更新知识库分享受限配置", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", knowledgeId));
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检索知识库。
|
||||
*
|
||||
* @param shareKey 分享访问密钥
|
||||
* @param keyword 检索关键词
|
||||
* @param retrievalMode 检索模式
|
||||
* @return 检索结果
|
||||
*/
|
||||
@GetMapping("/documentCollection/search")
|
||||
public Result<List<KnowledgeSearchResultItem>> search(
|
||||
@RequestParam String shareKey,
|
||||
@RequestParam String keyword,
|
||||
@RequestParam(required = false) String retrievalMode
|
||||
) {
|
||||
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
||||
shareKey,
|
||||
null,
|
||||
KnowledgeShareActionScope.SEARCH.name()
|
||||
);
|
||||
KnowledgeRetrievalRequest request = new KnowledgeRetrievalRequest();
|
||||
request.setKnowledgeId(context.getKnowledge().getId());
|
||||
request.setQuery(keyword);
|
||||
request.setRetrievalMode(KnowledgeRetrievalModes.parse(retrievalMode));
|
||||
request.setCallerType("SHARE_URL");
|
||||
request.setCallerId(String.valueOf(context.getShare().getId()));
|
||||
return Result.ok(toKnowledgeSearchResult(documentCollectionService.search(request)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 文档分页。
|
||||
*/
|
||||
@GetMapping("/document/documentList")
|
||||
public Result<Page<Document>> documentPage(
|
||||
@RequestParam String shareKey,
|
||||
@RequestParam(required = false) String title,
|
||||
@RequestParam(defaultValue = "10") int pageSize,
|
||||
@RequestParam(defaultValue = "1") int pageNumber
|
||||
) {
|
||||
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
||||
shareKey,
|
||||
null,
|
||||
KnowledgeShareActionScope.VIEW.name()
|
||||
);
|
||||
return Result.ok(documentService.getDocumentList(context.getKnowledge().getId().toString(), pageSize, pageNumber, title));
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文档。
|
||||
*/
|
||||
@GetMapping("/document/download")
|
||||
public void documentDownload(
|
||||
@RequestParam String shareKey,
|
||||
@RequestParam BigInteger documentId,
|
||||
HttpServletResponse response
|
||||
) throws Exception {
|
||||
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
||||
shareKey,
|
||||
null,
|
||||
KnowledgeShareActionScope.VIEW.name()
|
||||
);
|
||||
Document document = documentService.getById(documentId);
|
||||
if (document == null || document.getCollectionId() == null
|
||||
|| document.getCollectionId().compareTo(context.getKnowledge().getId()) != 0) {
|
||||
throw new BusinessException("文档不存在");
|
||||
}
|
||||
response.setContentType("application/octet-stream");
|
||||
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||
String fileName = URLEncoder.encode(document.getTitle(), StandardCharsets.UTF_8).replaceAll("\\+", "%20");
|
||||
response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName);
|
||||
try (InputStream inputStream = fileStorageService.readStream(document.getDocumentPath())) {
|
||||
IoUtil.copy(inputStream, response.getOutputStream());
|
||||
response.flushBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文档。
|
||||
*/
|
||||
@PostMapping("/document/removeDoc")
|
||||
public Result<?> removeDocument(
|
||||
@RequestParam String shareKey,
|
||||
@JsonBody("id") String id
|
||||
) {
|
||||
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
||||
shareKey,
|
||||
null,
|
||||
KnowledgeShareActionScope.CONTENT_DELETE.name()
|
||||
);
|
||||
Document document = documentService.getById(new BigInteger(id));
|
||||
if (document == null || document.getCollectionId() == null
|
||||
|| document.getCollectionId().compareTo(context.getKnowledge().getId()) != 0) {
|
||||
throw new BusinessException("文档不存在");
|
||||
}
|
||||
boolean success = documentService.removeDoc(id);
|
||||
audit(context, "删除分享文档", "KNOWLEDGE_SHARE_URL_WRITE", true,
|
||||
auditDetail("knowledgeId", context.getKnowledge().getId(), "documentId", id));
|
||||
return Result.ok(success);
|
||||
}
|
||||
|
||||
/**
|
||||
* 文档导入分析。
|
||||
*/
|
||||
@PostMapping("/document/import/analyze")
|
||||
public Result<DocumentImportDtos.AnalyzeResponse> analyzeImport(
|
||||
@RequestParam String shareKey,
|
||||
@JsonBody DocumentImportDtos.AnalyzeRequest request
|
||||
) {
|
||||
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
||||
shareKey,
|
||||
request == null ? null : request.getKnowledgeId(),
|
||||
KnowledgeShareActionScope.CONTENT_CREATE.name()
|
||||
);
|
||||
BigInteger knowledgeId = resolveKnowledgeId(context, request == null ? null : request.getKnowledgeId());
|
||||
request.setKnowledgeId(knowledgeId);
|
||||
audit(context, "分析分享文档导入", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", knowledgeId));
|
||||
return documentService.analyzeImport(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 文档导入预览。
|
||||
*/
|
||||
@PostMapping("/document/import/preview")
|
||||
public Result<DocumentImportDtos.PreviewResponse> previewImport(
|
||||
@RequestParam String shareKey,
|
||||
@JsonBody DocumentImportDtos.PreviewRequest request
|
||||
) {
|
||||
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
||||
shareKey,
|
||||
request == null ? null : request.getKnowledgeId(),
|
||||
KnowledgeShareActionScope.CONTENT_CREATE.name()
|
||||
);
|
||||
BigInteger knowledgeId = resolveKnowledgeId(context, request == null ? null : request.getKnowledgeId());
|
||||
request.setKnowledgeId(knowledgeId);
|
||||
audit(context, "预览分享文档导入", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", knowledgeId));
|
||||
return documentService.previewImport(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交文档导入。
|
||||
*/
|
||||
@PostMapping("/document/import/commit")
|
||||
public Result<DocumentImportDtos.CommitResponse> commitImport(
|
||||
@RequestParam String shareKey,
|
||||
@JsonBody DocumentImportDtos.CommitRequest request
|
||||
) {
|
||||
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
||||
shareKey,
|
||||
request == null ? null : request.getKnowledgeId(),
|
||||
KnowledgeShareActionScope.CONTENT_CREATE.name()
|
||||
);
|
||||
BigInteger knowledgeId = resolveKnowledgeId(context, request == null ? null : request.getKnowledgeId());
|
||||
request.setKnowledgeId(knowledgeId);
|
||||
audit(context, "提交分享文档导入", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", knowledgeId));
|
||||
return documentService.commitImport(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunk 分页。
|
||||
*/
|
||||
@GetMapping("/documentChunk/page")
|
||||
public Result<Page<DocumentChunk>> documentChunkPage(
|
||||
@RequestParam String shareKey,
|
||||
@RequestParam BigInteger documentId,
|
||||
@RequestParam(defaultValue = "1") long pageNumber,
|
||||
@RequestParam(defaultValue = "10") long pageSize
|
||||
) {
|
||||
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
||||
shareKey,
|
||||
null,
|
||||
KnowledgeShareActionScope.VIEW.name()
|
||||
);
|
||||
Document document = documentService.getById(documentId);
|
||||
if (document == null || document.getCollectionId() == null
|
||||
|| document.getCollectionId().compareTo(context.getKnowledge().getId()) != 0) {
|
||||
throw new BusinessException("文档不存在");
|
||||
}
|
||||
QueryWrapper wrapper = QueryWrapper.create()
|
||||
.eq(DocumentChunk::getDocumentId, documentId)
|
||||
.orderBy("sorting asc");
|
||||
return Result.ok(documentChunkService.page(new Page<>(pageNumber, pageSize), wrapper));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 Chunk。
|
||||
*/
|
||||
@PostMapping("/documentChunk/update")
|
||||
public Result<?> updateDocumentChunk(
|
||||
@RequestParam String shareKey,
|
||||
@JsonBody DocumentChunk documentChunk
|
||||
) {
|
||||
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
||||
shareKey,
|
||||
null,
|
||||
KnowledgeShareActionScope.CONTENT_UPDATE.name()
|
||||
);
|
||||
DocumentChunk current = documentChunkService.getById(documentChunk.getId());
|
||||
if (current == null || current.getDocumentCollectionId() == null
|
||||
|| current.getDocumentCollectionId().compareTo(context.getKnowledge().getId()) != 0) {
|
||||
throw new BusinessException("记录不存在");
|
||||
}
|
||||
boolean success = documentChunkService.updateById(documentChunk);
|
||||
if (success) {
|
||||
DocumentStore documentStore = context.getKnowledge().toDocumentStore();
|
||||
if (documentStore == null) {
|
||||
return Result.fail(2, "知识库没有配置向量库");
|
||||
}
|
||||
Model model = modelService.getModelInstance(context.getKnowledge().getVectorEmbedModelId());
|
||||
if (model == null) {
|
||||
return Result.fail(3, "知识库没有配置向量模型");
|
||||
}
|
||||
EmbeddingModel embeddingModel = model.toEmbeddingModel();
|
||||
documentStore.setEmbeddingModel(embeddingModel);
|
||||
StoreOptions options = StoreOptions.ofCollectionName(context.getKnowledge().getVectorStoreCollection());
|
||||
com.easyagents.core.document.Document doc = com.easyagents.core.document.Document.of(documentChunk.getContent());
|
||||
doc.setId(documentChunk.getId());
|
||||
StoreResult result = documentStore.update(doc, options);
|
||||
audit(context, "更新分享文档 Chunk", "KNOWLEDGE_SHARE_URL_WRITE", true,
|
||||
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", documentChunk.getId()));
|
||||
return Result.ok(result);
|
||||
}
|
||||
return Result.ok(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 Chunk。
|
||||
*/
|
||||
@PostMapping("/documentChunk/removeChunk")
|
||||
public Result<?> removeDocumentChunk(
|
||||
@RequestParam String shareKey,
|
||||
@JsonBody("id") BigInteger chunkId
|
||||
) {
|
||||
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
||||
shareKey,
|
||||
null,
|
||||
KnowledgeShareActionScope.CONTENT_DELETE.name()
|
||||
);
|
||||
DocumentChunk current = documentChunkService.getById(chunkId);
|
||||
if (current == null || current.getDocumentCollectionId() == null
|
||||
|| current.getDocumentCollectionId().compareTo(context.getKnowledge().getId()) != 0) {
|
||||
return Result.fail(1, "记录不存在");
|
||||
}
|
||||
DocumentStore documentStore = context.getKnowledge().toDocumentStore();
|
||||
if (documentStore == null) {
|
||||
return Result.fail(2, "知识库没有配置向量库");
|
||||
}
|
||||
Model model = modelService.getModelInstance(context.getKnowledge().getVectorEmbedModelId());
|
||||
if (model == null) {
|
||||
return Result.fail(3, "知识库没有配置向量模型");
|
||||
}
|
||||
documentStore.setEmbeddingModel(model.toEmbeddingModel());
|
||||
StoreOptions options = StoreOptions.ofCollectionName(context.getKnowledge().getVectorStoreCollection());
|
||||
documentStore.delete(Collections.singletonList(chunkId), options);
|
||||
documentChunkService.removeById(chunkId);
|
||||
audit(context, "删除分享文档 Chunk", "KNOWLEDGE_SHARE_URL_WRITE", true,
|
||||
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", chunkId));
|
||||
return Result.ok(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* FAQ 分类列表。
|
||||
*/
|
||||
@GetMapping("/faqCategory/list")
|
||||
public Result<List<FaqCategory>> faqCategoryList(
|
||||
@RequestParam String shareKey,
|
||||
@RequestParam(required = false) Boolean asTree
|
||||
) {
|
||||
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
||||
shareKey,
|
||||
null,
|
||||
KnowledgeShareActionScope.VIEW.name()
|
||||
);
|
||||
return Result.ok(faqCategoryService.listByCollection(context.getKnowledge().getId(), asTree));
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 FAQ 分类。
|
||||
*/
|
||||
@PostMapping("/faqCategory/save")
|
||||
public Result<?> saveFaqCategory(
|
||||
@RequestParam String shareKey,
|
||||
@JsonBody FaqCategory entity
|
||||
) {
|
||||
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
||||
shareKey,
|
||||
entity.getCollectionId(),
|
||||
KnowledgeShareActionScope.CONTENT_CREATE.name()
|
||||
);
|
||||
audit(context, "新增 FAQ 分类", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", entity.getCollectionId()));
|
||||
return Result.ok(faqCategoryService.saveCategory(entity));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 FAQ 分类。
|
||||
*/
|
||||
@PostMapping("/faqCategory/update")
|
||||
public Result<?> updateFaqCategory(
|
||||
@RequestParam String shareKey,
|
||||
@JsonBody FaqCategory entity
|
||||
) {
|
||||
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
||||
shareKey,
|
||||
null,
|
||||
KnowledgeShareActionScope.CONTENT_UPDATE.name()
|
||||
);
|
||||
audit(context, "更新 FAQ 分类", "KNOWLEDGE_SHARE_URL_WRITE", true,
|
||||
auditDetail("knowledgeId", context.getKnowledge().getId(), "categoryId", entity.getId()));
|
||||
return Result.ok(faqCategoryService.updateCategory(entity));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 FAQ 分类。
|
||||
*/
|
||||
@PostMapping("/faqCategory/remove")
|
||||
public Result<?> removeFaqCategory(
|
||||
@RequestParam String shareKey,
|
||||
@JsonBody("id") BigInteger id
|
||||
) {
|
||||
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
||||
shareKey,
|
||||
null,
|
||||
KnowledgeShareActionScope.CONTENT_DELETE.name()
|
||||
);
|
||||
audit(context, "删除 FAQ 分类", "KNOWLEDGE_SHARE_URL_WRITE", true,
|
||||
auditDetail("knowledgeId", context.getKnowledge().getId(), "categoryId", id));
|
||||
return Result.ok(faqCategoryService.removeCategory(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* FAQ 分页。
|
||||
*/
|
||||
@GetMapping("/faqItem/page")
|
||||
public Result<Page<FaqItem>> faqPage(
|
||||
@RequestParam String shareKey,
|
||||
HttpServletRequest request,
|
||||
@RequestParam(defaultValue = "1") long pageNumber,
|
||||
@RequestParam(defaultValue = "10") long pageSize
|
||||
) {
|
||||
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
||||
shareKey,
|
||||
null,
|
||||
KnowledgeShareActionScope.VIEW.name()
|
||||
);
|
||||
faqCategoryService.ensureDefaultCategory(context.getKnowledge().getId());
|
||||
QueryWrapper queryWrapper = QueryWrapper.create()
|
||||
.eq(FaqItem::getCollectionId, context.getKnowledge().getId());
|
||||
String question = request.getParameter("question");
|
||||
if (StringUtils.hasText(question)) {
|
||||
queryWrapper.like(FaqItem::getQuestion, question.trim());
|
||||
}
|
||||
String categoryId = request.getParameter("categoryId");
|
||||
if (StringUtils.hasText(categoryId)) {
|
||||
List<BigInteger> descendantIds =
|
||||
faqCategoryService.findDescendantIds(context.getKnowledge().getId(), new BigInteger(categoryId));
|
||||
if (descendantIds.isEmpty()) {
|
||||
queryWrapper.eq(FaqItem::getId, BigInteger.ZERO);
|
||||
} else {
|
||||
queryWrapper.in(FaqItem::getCategoryId, descendantIds);
|
||||
}
|
||||
}
|
||||
queryWrapper.orderBy("order_no asc");
|
||||
Page<FaqItem> page = faqItemService.page(new Page<>(pageNumber, pageSize), queryWrapper);
|
||||
Map<BigInteger, String> pathMap = faqCategoryService.buildPathMap(context.getKnowledge().getId());
|
||||
if (page.getRecords() != null) {
|
||||
for (FaqItem record : page.getRecords()) {
|
||||
record.setCategoryPath(pathMap.get(record.getCategoryId()));
|
||||
}
|
||||
}
|
||||
return Result.ok(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* FAQ 详情。
|
||||
*/
|
||||
@GetMapping("/faqItem/detail")
|
||||
public Result<FaqItem> faqDetail(
|
||||
@RequestParam String shareKey,
|
||||
@RequestParam String id
|
||||
) {
|
||||
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
||||
shareKey,
|
||||
null,
|
||||
KnowledgeShareActionScope.VIEW.name()
|
||||
);
|
||||
FaqItem item = faqItemService.getById(id);
|
||||
if (item == null || item.getCollectionId() == null
|
||||
|| item.getCollectionId().compareTo(context.getKnowledge().getId()) != 0) {
|
||||
throw new BusinessException("FAQ不存在");
|
||||
}
|
||||
return Result.ok(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 FAQ。
|
||||
*/
|
||||
@PostMapping("/faqItem/save")
|
||||
public Result<?> saveFaq(
|
||||
@RequestParam String shareKey,
|
||||
@JsonBody FaqItem entity
|
||||
) {
|
||||
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
||||
shareKey,
|
||||
entity.getCollectionId(),
|
||||
KnowledgeShareActionScope.CONTENT_CREATE.name()
|
||||
);
|
||||
audit(context, "新增 FAQ", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", entity.getCollectionId()));
|
||||
return Result.ok(faqItemService.saveFaqItem(entity));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 FAQ。
|
||||
*/
|
||||
@PostMapping("/faqItem/update")
|
||||
public Result<?> updateFaq(
|
||||
@RequestParam String shareKey,
|
||||
@JsonBody FaqItem entity
|
||||
) {
|
||||
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
||||
shareKey,
|
||||
null,
|
||||
KnowledgeShareActionScope.CONTENT_UPDATE.name()
|
||||
);
|
||||
audit(context, "更新 FAQ", "KNOWLEDGE_SHARE_URL_WRITE", true,
|
||||
auditDetail("knowledgeId", context.getKnowledge().getId(), "faqId", entity.getId()));
|
||||
return Result.ok(faqItemService.updateFaqItem(entity));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 FAQ。
|
||||
*/
|
||||
@PostMapping("/faqItem/remove")
|
||||
public Result<?> removeFaq(
|
||||
@RequestParam String shareKey,
|
||||
@JsonBody("id") BigInteger id
|
||||
) {
|
||||
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
||||
shareKey,
|
||||
null,
|
||||
KnowledgeShareActionScope.CONTENT_DELETE.name()
|
||||
);
|
||||
audit(context, "删除 FAQ", "KNOWLEDGE_SHARE_URL_WRITE", true,
|
||||
auditDetail("knowledgeId", context.getKnowledge().getId(), "faqId", id));
|
||||
return Result.ok(faqItemService.removeFaqItem(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传 FAQ 图片。
|
||||
*/
|
||||
@PostMapping(value = "/faqItem/uploadImage", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public Result<UploadResVo> uploadFaqImage(
|
||||
@RequestParam String shareKey,
|
||||
MultipartFile file,
|
||||
BigInteger collectionId
|
||||
) {
|
||||
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
||||
shareKey,
|
||||
collectionId,
|
||||
KnowledgeShareActionScope.CONTENT_UPDATE.name()
|
||||
);
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new BusinessException("图片不能为空");
|
||||
}
|
||||
if (file.getSize() > MAX_IMAGE_SIZE_BYTES) {
|
||||
throw new BusinessException("图片大小不能超过5MB");
|
||||
}
|
||||
if (!isAllowedImageType(file)) {
|
||||
throw new BusinessException("仅支持 JPG/PNG/WEBP/GIF 图片");
|
||||
}
|
||||
String path = fileStorageService.save(file, "faq/" + collectionId);
|
||||
UploadResVo result = new UploadResVo();
|
||||
result.setPath(path);
|
||||
audit(context, "上传 FAQ 图片", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", collectionId));
|
||||
return Result.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入 FAQ Excel。
|
||||
*/
|
||||
@PostMapping(value = "/faqItem/importExcel", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public Result<FaqImportResultVo> importFaqExcel(
|
||||
@RequestParam String shareKey,
|
||||
MultipartFile file,
|
||||
BigInteger collectionId
|
||||
) {
|
||||
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
||||
shareKey,
|
||||
collectionId,
|
||||
KnowledgeShareActionScope.IMPORT_EXPORT.name()
|
||||
);
|
||||
audit(context, "导入 FAQ Excel", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", collectionId));
|
||||
return Result.ok(faqItemService.importFromExcel(collectionId, file));
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载 FAQ 导入模板。
|
||||
*/
|
||||
@GetMapping("/faqItem/downloadImportTemplate")
|
||||
public void downloadFaqImportTemplate(
|
||||
@RequestParam String shareKey,
|
||||
@RequestParam BigInteger collectionId,
|
||||
HttpServletResponse response
|
||||
) throws Exception {
|
||||
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
||||
shareKey,
|
||||
collectionId,
|
||||
KnowledgeShareActionScope.IMPORT_EXPORT.name()
|
||||
);
|
||||
response.setContentType("application/octet-stream");
|
||||
response.setHeader(
|
||||
"Content-disposition",
|
||||
"attachment;filename*=utf-8''" + URLEncoder.encode("faq_import_template.xlsx", StandardCharsets.UTF_8)
|
||||
);
|
||||
faqItemService.writeImportTemplate(response.getOutputStream());
|
||||
response.flushBuffer();
|
||||
audit(context, "下载 FAQ 导入模板", "KNOWLEDGE_SHARE_URL_ACCESS", false, auditDetail("knowledgeId", collectionId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出 FAQ Excel。
|
||||
*/
|
||||
@GetMapping("/faqItem/exportExcel")
|
||||
public void exportFaqExcel(
|
||||
@RequestParam String shareKey,
|
||||
@RequestParam BigInteger collectionId,
|
||||
HttpServletResponse response
|
||||
) throws Exception {
|
||||
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
||||
shareKey,
|
||||
collectionId,
|
||||
KnowledgeShareActionScope.IMPORT_EXPORT.name()
|
||||
);
|
||||
String fileName = "faq_export_" + new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()) + ".xlsx";
|
||||
response.setContentType("application/octet-stream");
|
||||
response.setHeader(
|
||||
"Content-disposition",
|
||||
"attachment;filename*=utf-8''" + URLEncoder.encode(fileName, StandardCharsets.UTF_8)
|
||||
);
|
||||
faqItemService.exportToExcel(collectionId, response.getOutputStream());
|
||||
response.flushBuffer();
|
||||
audit(context, "导出 FAQ Excel", "KNOWLEDGE_SHARE_URL_ACCESS", false, auditDetail("knowledgeId", collectionId));
|
||||
}
|
||||
|
||||
private boolean isAllowedImageType(MultipartFile file) {
|
||||
String contentType = file.getContentType();
|
||||
return contentType != null && ALLOWED_IMAGE_TYPES.contains(contentType.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验分享请求中的知识库标识。
|
||||
*
|
||||
* @param knowledgeId 知识库 ID
|
||||
* @return 已校验的知识库 ID
|
||||
*/
|
||||
private BigInteger resolveKnowledgeId(KnowledgeShareAuthContext context, BigInteger requestedKnowledgeId) {
|
||||
if (requestedKnowledgeId != null) {
|
||||
return requestedKnowledgeId;
|
||||
}
|
||||
if (context != null && context.getKnowledge() != null && context.getKnowledge().getId() != null) {
|
||||
return context.getKnowledge().getId();
|
||||
}
|
||||
if (context == null || context.getKnowledge() == null) {
|
||||
throw new BusinessException("知识库不能为空");
|
||||
}
|
||||
throw new BusinessException("知识库不存在");
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造允许空值的审计详情。
|
||||
*
|
||||
* @param keyValues 键值对
|
||||
* @return 审计详情
|
||||
*/
|
||||
private Map<String, Object> auditDetail(Object... keyValues) {
|
||||
if (keyValues == null || keyValues.length == 0) {
|
||||
return new HashMap<>();
|
||||
}
|
||||
if ((keyValues.length & 1) != 0) {
|
||||
throw new IllegalArgumentException("审计详情参数必须成对出现");
|
||||
}
|
||||
Map<String, Object> detail = new HashMap<>(keyValues.length / 2);
|
||||
for (int index = 0; index < keyValues.length; index += 2) {
|
||||
Object key = keyValues[index];
|
||||
if (!(key instanceof String detailKey) || !StringUtils.hasText(detailKey)) {
|
||||
throw new IllegalArgumentException("审计详情 key 必须为非空字符串");
|
||||
}
|
||||
detail.put(detailKey, keyValues[index + 1]);
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录分享访问审计。
|
||||
*
|
||||
* @param context 分享鉴权上下文
|
||||
* @param actionName 动作名称
|
||||
* @param actionType 动作类型
|
||||
* @param writeOperation 是否写操作
|
||||
* @param detail 审计详情
|
||||
*/
|
||||
private void audit(KnowledgeShareAuthContext context, String actionName, String actionType, boolean writeOperation, Map<String, Object> detail) {
|
||||
Map<String, Object> payload = new HashMap<>(detail);
|
||||
payload.put("shareId", context.getShare().getId());
|
||||
payload.put("writeOperation", writeOperation);
|
||||
knowledgeShareAuditService.log(null, actionName, actionType, "/api/v1/share/knowledge", payload);
|
||||
}
|
||||
|
||||
private List<KnowledgeSearchResultItem> toKnowledgeSearchResult(List<com.easyagents.core.document.Document> documents) {
|
||||
List<KnowledgeSearchResultItem> result = new java.util.ArrayList<>();
|
||||
if (documents == null) {
|
||||
return result;
|
||||
}
|
||||
for (int index = 0; index < documents.size(); index++) {
|
||||
com.easyagents.core.document.Document document = documents.get(index);
|
||||
if (document == null) {
|
||||
continue;
|
||||
}
|
||||
KnowledgeSearchResultItem item = new KnowledgeSearchResultItem();
|
||||
item.setSorting(index + 1);
|
||||
item.setContent(document.getContent());
|
||||
item.setScore(document.getScore() == null ? null : document.getScore().doubleValue());
|
||||
Object hitSource = document.getMetadata("hitSource");
|
||||
item.setHitSource(hitSource == null ? null : String.valueOf(hitSource));
|
||||
item.setVectorScore(asDouble(document.getMetadata("vectorScore")));
|
||||
item.setKeywordScore(asDouble(document.getMetadata("keywordScore")));
|
||||
result.add(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private Double asDouble(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Number number) {
|
||||
return number.doubleValue();
|
||||
}
|
||||
if (value instanceof String text && StringUtils.hasText(text)) {
|
||||
try {
|
||||
return Double.parseDouble(text);
|
||||
} catch (NumberFormatException ignore) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import tech.easyflow.ai.service.KnowledgeSharePermissionService;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
@@ -43,6 +44,8 @@ public class SysApiKeyController extends BaseCurdController<SysApiKeyService, Sy
|
||||
|
||||
@Resource
|
||||
private SysApiKeyResourceMappingService sysApiKeyResourceMappingService;
|
||||
@Resource
|
||||
private KnowledgeSharePermissionService knowledgeSharePermissionService;
|
||||
/**
|
||||
* 添加(保存)数据
|
||||
*
|
||||
@@ -79,10 +82,20 @@ public class SysApiKeyController extends BaseCurdController<SysApiKeyService, Sy
|
||||
|
||||
@Override
|
||||
protected void onSaveOrUpdateAfter(SysApiKey entity, boolean isSave) {
|
||||
if (!isSave && entity.getPermissionIds() != null && !entity.getPermissionIds().isEmpty()) {
|
||||
// 修改的时候绑定授权接口
|
||||
if (entity.getPermissionIds() != null) {
|
||||
sysApiKeyResourceMappingService.authInterface(entity);
|
||||
}
|
||||
if (entity.getKnowledgeShareEnabled() != null) {
|
||||
knowledgeSharePermissionService.replaceApiShareEnabled(entity.getId(), entity.getKnowledgeShareEnabled());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@GetMapping("/detail")
|
||||
public Result<SysApiKey> detail(String id) {
|
||||
Result<SysApiKey> result = super.detail(id);
|
||||
fillApiKeyPermissions(result.getData());
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -91,11 +104,30 @@ public class SysApiKeyController extends BaseCurdController<SysApiKeyService, Sy
|
||||
Result<Page<SysApiKey>> pageResult = (Result<Page<SysApiKey>>) super.page(request, sortKey, sortType, pageNumber, pageSize);
|
||||
Page<SysApiKey> data = pageResult.getData();
|
||||
List<SysApiKey> records = data.getRecords();
|
||||
records.forEach(record -> {
|
||||
QueryWrapper queryWrapper = QueryWrapper.create().select(SysApiKeyResourceMapping::getApiKeyResourceId).eq(SysApiKeyResourceMapping::getApiKeyId, record.getId());
|
||||
List<BigInteger> resourceIds = sysApiKeyResourceMappingService.listAs(queryWrapper, BigInteger.class);
|
||||
record.setPermissionIds(resourceIds);
|
||||
});
|
||||
records.forEach(this::fillApiKeyPermissions);
|
||||
return pageResult;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回填访问令牌的接口与知识库授权。
|
||||
*
|
||||
* @param entity 访问令牌
|
||||
*/
|
||||
private void fillApiKeyPermissions(SysApiKey entity) {
|
||||
if (entity == null || entity.getId() == null) {
|
||||
return;
|
||||
}
|
||||
QueryWrapper interfaceWrapper = QueryWrapper.create()
|
||||
.select(SysApiKeyResourceMapping::getApiKeyResourceId)
|
||||
.eq(SysApiKeyResourceMapping::getApiKeyId, entity.getId())
|
||||
.isNull(SysApiKeyResourceMapping::getResourceType);
|
||||
List<BigInteger> resourceIds = sysApiKeyResourceMappingService.listAs(interfaceWrapper, BigInteger.class);
|
||||
entity.setPermissionIds(resourceIds);
|
||||
|
||||
QueryWrapper knowledgeWrapper = QueryWrapper.create()
|
||||
.select(SysApiKeyResourceMapping::getId)
|
||||
.eq(SysApiKeyResourceMapping::getApiKeyId, entity.getId())
|
||||
.eq(SysApiKeyResourceMapping::getResourceType, "KNOWLEDGE");
|
||||
entity.setKnowledgeShareEnabled(sysApiKeyResourceMappingService.count(knowledgeWrapper) > 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<DocumentCollection> detail(
|
||||
@RequestHeader("ApiKey") String apiKey,
|
||||
@RequestParam BigInteger knowledgeId,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.VIEW.name());
|
||||
audit(apiKey, "API读取知识库详情", "KNOWLEDGE_API_SHARE_ACCESS", request.getRequestURI(), Map.of("knowledgeId", knowledgeId));
|
||||
return Result.ok(documentCollectionService.getDetail(knowledgeId.toString()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 检索知识库。
|
||||
*/
|
||||
@GetMapping("/search")
|
||||
public Result<List<KnowledgeSearchResultItem>> search(
|
||||
@RequestHeader("ApiKey") String apiKey,
|
||||
@RequestParam BigInteger knowledgeId,
|
||||
@RequestParam String keyword,
|
||||
@RequestParam(required = false) String retrievalMode,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.SEARCH.name());
|
||||
KnowledgeRetrievalRequest retrievalRequest = new KnowledgeRetrievalRequest();
|
||||
retrievalRequest.setKnowledgeId(knowledgeId);
|
||||
retrievalRequest.setQuery(keyword);
|
||||
retrievalRequest.setRetrievalMode(KnowledgeRetrievalModes.parse(retrievalMode));
|
||||
retrievalRequest.setCallerType("PUBLIC_API");
|
||||
retrievalRequest.setCallerId(String.valueOf(knowledgeId));
|
||||
audit(apiKey, "API检索知识库", "KNOWLEDGE_API_SHARE_ACCESS", request.getRequestURI(), Map.of("knowledgeId", knowledgeId));
|
||||
return Result.ok(toKnowledgeSearchResult(documentCollectionService.search(retrievalRequest)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 文档分页。
|
||||
*/
|
||||
@GetMapping("/document/page")
|
||||
public Result<Page<Document>> documentPage(
|
||||
@RequestHeader("ApiKey") String apiKey,
|
||||
@RequestParam BigInteger knowledgeId,
|
||||
@RequestParam(required = false) String title,
|
||||
@RequestParam(defaultValue = "10") int pageSize,
|
||||
@RequestParam(defaultValue = "1") int pageNumber,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.VIEW.name());
|
||||
requireDocumentKnowledge(knowledgeId);
|
||||
return Result.ok(documentService.getDocumentList(knowledgeId.toString(), pageSize, pageNumber, title));
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文档。
|
||||
*/
|
||||
@GetMapping(value = "/document/download", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
|
||||
public void documentDownload(
|
||||
@RequestHeader("ApiKey") String apiKey,
|
||||
@RequestParam BigInteger knowledgeId,
|
||||
@RequestParam BigInteger documentId,
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response
|
||||
) throws Exception {
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.VIEW.name());
|
||||
requireDocumentKnowledge(knowledgeId);
|
||||
Document document = requireDocument(documentId, knowledgeId);
|
||||
response.setContentType("application/octet-stream");
|
||||
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||
String fileName = URLEncoder.encode(document.getTitle(), StandardCharsets.UTF_8).replaceAll("\\+", "%20");
|
||||
response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName);
|
||||
try (InputStream inputStream = fileStorageService.readStream(document.getDocumentPath())) {
|
||||
IoUtil.copy(inputStream, response.getOutputStream());
|
||||
response.flushBuffer();
|
||||
}
|
||||
audit(apiKey, "API下载文档", "KNOWLEDGE_API_SHARE_ACCESS", request.getRequestURI(), Map.of("knowledgeId", knowledgeId, "documentId", documentId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文档。
|
||||
*/
|
||||
@PostMapping("/document/remove")
|
||||
public Result<?> removeDocument(
|
||||
@RequestHeader("ApiKey") String apiKey,
|
||||
@RequestParam BigInteger knowledgeId,
|
||||
@JsonBody("id") String id,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.CONTENT_DELETE.name());
|
||||
requireDocumentKnowledge(knowledgeId);
|
||||
requireDocument(new BigInteger(id), knowledgeId);
|
||||
audit(apiKey, "API删除文档", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", knowledgeId, "documentId", id));
|
||||
return Result.ok(documentService.removeDoc(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 文档导入分析。
|
||||
*/
|
||||
@PostMapping("/document/import/analyze")
|
||||
public Result<DocumentImportDtos.AnalyzeResponse> analyzeImport(
|
||||
@RequestHeader("ApiKey") String apiKey,
|
||||
@JsonBody DocumentImportDtos.AnalyzeRequest request,
|
||||
HttpServletRequest servletRequest
|
||||
) {
|
||||
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeShareActionScope.CONTENT_CREATE.name());
|
||||
requireDocumentKnowledge(request.getKnowledgeId());
|
||||
audit(apiKey, "API分析文档导入", "KNOWLEDGE_API_SHARE_WRITE", servletRequest.getRequestURI(), Map.of("knowledgeId", request.getKnowledgeId()));
|
||||
return documentService.analyzeImport(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 文档导入预览。
|
||||
*/
|
||||
@PostMapping("/document/import/preview")
|
||||
public Result<DocumentImportDtos.PreviewResponse> previewImport(
|
||||
@RequestHeader("ApiKey") String apiKey,
|
||||
@JsonBody DocumentImportDtos.PreviewRequest request,
|
||||
HttpServletRequest servletRequest
|
||||
) {
|
||||
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeShareActionScope.CONTENT_CREATE.name());
|
||||
requireDocumentKnowledge(request.getKnowledgeId());
|
||||
audit(apiKey, "API预览文档导入", "KNOWLEDGE_API_SHARE_WRITE", servletRequest.getRequestURI(), Map.of("knowledgeId", request.getKnowledgeId()));
|
||||
return documentService.previewImport(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 文档导入提交。
|
||||
*/
|
||||
@PostMapping("/document/import/commit")
|
||||
public Result<DocumentImportDtos.CommitResponse> commitImport(
|
||||
@RequestHeader("ApiKey") String apiKey,
|
||||
@JsonBody DocumentImportDtos.CommitRequest request,
|
||||
HttpServletRequest servletRequest
|
||||
) {
|
||||
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeShareActionScope.CONTENT_CREATE.name());
|
||||
requireDocumentKnowledge(request.getKnowledgeId());
|
||||
audit(apiKey, "API提交文档导入", "KNOWLEDGE_API_SHARE_WRITE", servletRequest.getRequestURI(), Map.of("knowledgeId", request.getKnowledgeId()));
|
||||
return documentService.commitImport(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunk 分页。
|
||||
*/
|
||||
@GetMapping("/documentChunk/page")
|
||||
public Result<Page<DocumentChunk>> documentChunkPage(
|
||||
@RequestHeader("ApiKey") String apiKey,
|
||||
@RequestParam BigInteger knowledgeId,
|
||||
@RequestParam BigInteger documentId,
|
||||
@RequestParam(defaultValue = "1") long pageNumber,
|
||||
@RequestParam(defaultValue = "10") long pageSize,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.VIEW.name());
|
||||
requireDocumentKnowledge(knowledgeId);
|
||||
requireDocument(documentId, knowledgeId);
|
||||
QueryWrapper wrapper = QueryWrapper.create()
|
||||
.eq(DocumentChunk::getDocumentId, documentId)
|
||||
.orderBy("sorting asc");
|
||||
return Result.ok(documentChunkService.page(new Page<>(pageNumber, pageSize), wrapper));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 Chunk。
|
||||
*/
|
||||
@PostMapping("/documentChunk/update")
|
||||
public Result<?> updateDocumentChunk(
|
||||
@RequestHeader("ApiKey") String apiKey,
|
||||
@RequestParam BigInteger knowledgeId,
|
||||
@JsonBody DocumentChunk documentChunk,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.CONTENT_UPDATE.name());
|
||||
requireDocumentKnowledge(knowledgeId);
|
||||
DocumentChunk current = requireDocumentChunk(documentChunk.getId(), knowledgeId);
|
||||
boolean success = documentChunkService.updateById(documentChunk);
|
||||
if (success) {
|
||||
DocumentCollection knowledge = documentCollectionService.getById(knowledgeId);
|
||||
DocumentStore documentStore = knowledge.toDocumentStore();
|
||||
if (documentStore == null) {
|
||||
return Result.fail(2, "知识库没有配置向量库");
|
||||
}
|
||||
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
|
||||
if (model == null) {
|
||||
return Result.fail(3, "知识库没有配置向量模型");
|
||||
}
|
||||
EmbeddingModel embeddingModel = model.toEmbeddingModel();
|
||||
documentStore.setEmbeddingModel(embeddingModel);
|
||||
StoreOptions options = StoreOptions.ofCollectionName(knowledge.getVectorStoreCollection());
|
||||
com.easyagents.core.document.Document doc = com.easyagents.core.document.Document.of(documentChunk.getContent());
|
||||
doc.setId(current.getId());
|
||||
StoreResult result = documentStore.update(doc, options);
|
||||
audit(apiKey, "API更新文档 Chunk", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", knowledgeId, "chunkId", documentChunk.getId()));
|
||||
return Result.ok(result);
|
||||
}
|
||||
return Result.ok(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 Chunk。
|
||||
*/
|
||||
@PostMapping("/documentChunk/remove")
|
||||
public Result<?> removeDocumentChunk(
|
||||
@RequestHeader("ApiKey") String apiKey,
|
||||
@RequestParam BigInteger knowledgeId,
|
||||
@JsonBody("id") BigInteger chunkId,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.CONTENT_DELETE.name());
|
||||
requireDocumentKnowledge(knowledgeId);
|
||||
requireDocumentChunk(chunkId, knowledgeId);
|
||||
DocumentCollection knowledge = documentCollectionService.getById(knowledgeId);
|
||||
DocumentStore documentStore = knowledge.toDocumentStore();
|
||||
if (documentStore == null) {
|
||||
return Result.fail(2, "知识库没有配置向量库");
|
||||
}
|
||||
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
|
||||
if (model == null) {
|
||||
return Result.fail(3, "知识库没有配置向量模型");
|
||||
}
|
||||
documentStore.setEmbeddingModel(model.toEmbeddingModel());
|
||||
StoreOptions options = StoreOptions.ofCollectionName(knowledge.getVectorStoreCollection());
|
||||
documentStore.delete(Collections.singletonList(chunkId), options);
|
||||
documentChunkService.removeById(chunkId);
|
||||
audit(apiKey, "API删除文档 Chunk", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", knowledgeId, "chunkId", chunkId));
|
||||
return Result.ok(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* FAQ 分页。
|
||||
*/
|
||||
@GetMapping("/faq/page")
|
||||
public Result<Page<FaqItem>> faqPage(
|
||||
@RequestHeader("ApiKey") String apiKey,
|
||||
@RequestParam BigInteger knowledgeId,
|
||||
@RequestParam(required = false) String question,
|
||||
@RequestParam(required = false) String categoryId,
|
||||
@RequestParam(defaultValue = "1") long pageNumber,
|
||||
@RequestParam(defaultValue = "10") long pageSize,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.VIEW.name());
|
||||
requireFaqKnowledge(knowledgeId);
|
||||
faqCategoryService.ensureDefaultCategory(knowledgeId);
|
||||
QueryWrapper queryWrapper = QueryWrapper.create()
|
||||
.eq(FaqItem::getCollectionId, knowledgeId);
|
||||
if (question != null && !question.isBlank()) {
|
||||
queryWrapper.like(FaqItem::getQuestion, question.trim());
|
||||
}
|
||||
if (categoryId != null && !categoryId.isBlank()) {
|
||||
List<BigInteger> descendantIds = faqCategoryService.findDescendantIds(knowledgeId, new BigInteger(categoryId));
|
||||
if (descendantIds.isEmpty()) {
|
||||
queryWrapper.eq(FaqItem::getId, BigInteger.ZERO);
|
||||
} else {
|
||||
queryWrapper.in(FaqItem::getCategoryId, descendantIds);
|
||||
}
|
||||
}
|
||||
queryWrapper.orderBy("order_no asc");
|
||||
Page<FaqItem> page = faqItemService.page(new Page<>(pageNumber, pageSize), queryWrapper);
|
||||
Map<BigInteger, String> pathMap = faqCategoryService.buildPathMap(knowledgeId);
|
||||
if (page.getRecords() != null) {
|
||||
for (FaqItem record : page.getRecords()) {
|
||||
record.setCategoryPath(pathMap.get(record.getCategoryId()));
|
||||
}
|
||||
}
|
||||
return Result.ok(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* FAQ 详情。
|
||||
*/
|
||||
@GetMapping("/faq/detail")
|
||||
public Result<FaqItem> faqDetail(
|
||||
@RequestHeader("ApiKey") String apiKey,
|
||||
@RequestParam BigInteger knowledgeId,
|
||||
@RequestParam String id,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.VIEW.name());
|
||||
requireFaqKnowledge(knowledgeId);
|
||||
FaqItem faqItem = requireFaq(new BigInteger(id), knowledgeId);
|
||||
return Result.ok(faqItem);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增 FAQ。
|
||||
*/
|
||||
@PostMapping("/faq/save")
|
||||
public Result<?> saveFaq(
|
||||
@RequestHeader("ApiKey") String apiKey,
|
||||
@JsonBody FaqItem entity,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
assertApiShare(apiKey, request.getRequestURI(), entity.getCollectionId(), KnowledgeShareActionScope.CONTENT_CREATE.name());
|
||||
requireFaqKnowledge(entity.getCollectionId());
|
||||
audit(apiKey, "API新增FAQ", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", entity.getCollectionId()));
|
||||
return Result.ok(faqItemService.saveFaqItem(entity));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 FAQ。
|
||||
*/
|
||||
@PostMapping("/faq/update")
|
||||
public Result<?> updateFaq(
|
||||
@RequestHeader("ApiKey") String apiKey,
|
||||
@RequestParam BigInteger knowledgeId,
|
||||
@JsonBody FaqItem entity,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.CONTENT_UPDATE.name());
|
||||
requireFaqKnowledge(knowledgeId);
|
||||
requireFaq(entity.getId(), knowledgeId);
|
||||
audit(apiKey, "API更新FAQ", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", knowledgeId, "faqId", entity.getId()));
|
||||
return Result.ok(faqItemService.updateFaqItem(entity));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 FAQ。
|
||||
*/
|
||||
@PostMapping("/faq/remove")
|
||||
public Result<?> removeFaq(
|
||||
@RequestHeader("ApiKey") String apiKey,
|
||||
@RequestParam BigInteger knowledgeId,
|
||||
@JsonBody("id") BigInteger id,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.CONTENT_DELETE.name());
|
||||
requireFaqKnowledge(knowledgeId);
|
||||
requireFaq(id, knowledgeId);
|
||||
audit(apiKey, "API删除FAQ", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", knowledgeId, "faqId", id));
|
||||
return Result.ok(faqItemService.removeFaqItem(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入 FAQ Excel。
|
||||
*/
|
||||
@PostMapping(value = "/faq/importExcel", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public Result<FaqImportResultVo> importFaqExcel(
|
||||
@RequestHeader("ApiKey") String apiKey,
|
||||
MultipartFile file,
|
||||
BigInteger collectionId,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
assertApiShare(apiKey, request.getRequestURI(), collectionId, KnowledgeShareActionScope.IMPORT_EXPORT.name());
|
||||
requireFaqKnowledge(collectionId);
|
||||
audit(apiKey, "API导入FAQ Excel", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", collectionId));
|
||||
return Result.ok(faqItemService.importFromExcel(collectionId, file));
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载 FAQ 导入模板。
|
||||
*/
|
||||
@GetMapping(value = "/faq/downloadImportTemplate", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
|
||||
public void downloadFaqImportTemplate(
|
||||
@RequestHeader("ApiKey") String apiKey,
|
||||
@RequestParam BigInteger knowledgeId,
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response
|
||||
) throws Exception {
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.IMPORT_EXPORT.name());
|
||||
requireFaqKnowledge(knowledgeId);
|
||||
response.setContentType("application/octet-stream");
|
||||
response.setHeader(
|
||||
"Content-disposition",
|
||||
"attachment;filename*=utf-8''" + URLEncoder.encode("faq_import_template.xlsx", StandardCharsets.UTF_8)
|
||||
);
|
||||
faqItemService.writeImportTemplate(response.getOutputStream());
|
||||
response.flushBuffer();
|
||||
audit(apiKey, "API下载FAQ导入模板", "KNOWLEDGE_API_SHARE_ACCESS", request.getRequestURI(), Map.of("knowledgeId", knowledgeId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出 FAQ Excel。
|
||||
*/
|
||||
@GetMapping(value = "/faq/exportExcel", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
|
||||
public void exportFaqExcel(
|
||||
@RequestHeader("ApiKey") String apiKey,
|
||||
@RequestParam BigInteger knowledgeId,
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response
|
||||
) throws Exception {
|
||||
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.IMPORT_EXPORT.name());
|
||||
requireFaqKnowledge(knowledgeId);
|
||||
String fileName = "faq_export_" + new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()) + ".xlsx";
|
||||
response.setContentType("application/octet-stream");
|
||||
response.setHeader(
|
||||
"Content-disposition",
|
||||
"attachment;filename*=utf-8''" + URLEncoder.encode(fileName, StandardCharsets.UTF_8)
|
||||
);
|
||||
faqItemService.exportToExcel(knowledgeId, response.getOutputStream());
|
||||
response.flushBuffer();
|
||||
audit(apiKey, "API导出FAQ Excel", "KNOWLEDGE_API_SHARE_ACCESS", request.getRequestURI(), Map.of("knowledgeId", knowledgeId));
|
||||
}
|
||||
|
||||
private void assertApiShare(String apiKey, String requestUri, BigInteger knowledgeId, String actionScope) {
|
||||
SysApiKey sysApiKey = sysApiKeyService.getSysApiKey(apiKey);
|
||||
knowledgeSharePermissionService.assertApiShare(sysApiKey.getId(), requestUri, knowledgeId, actionScope);
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言知识库为文档类型。
|
||||
*
|
||||
* @param knowledgeId 知识库ID
|
||||
* @return 知识库实体
|
||||
*/
|
||||
private DocumentCollection requireDocumentKnowledge(BigInteger knowledgeId) {
|
||||
DocumentCollection knowledge = requireKnowledge(knowledgeId);
|
||||
if (!knowledge.isDocumentCollection()) {
|
||||
throw new BusinessException("当前知识库类型不支持文档接口");
|
||||
}
|
||||
return knowledge;
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言知识库为 FAQ 类型。
|
||||
*
|
||||
* @param knowledgeId 知识库ID
|
||||
* @return 知识库实体
|
||||
*/
|
||||
private DocumentCollection requireFaqKnowledge(BigInteger knowledgeId) {
|
||||
DocumentCollection knowledge = requireKnowledge(knowledgeId);
|
||||
if (!knowledge.isFaqCollection()) {
|
||||
throw new BusinessException("当前知识库类型不支持FAQ接口");
|
||||
}
|
||||
return knowledge;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取知识库并保证存在。
|
||||
*
|
||||
* @param knowledgeId 知识库ID
|
||||
* @return 知识库实体
|
||||
*/
|
||||
private DocumentCollection requireKnowledge(BigInteger knowledgeId) {
|
||||
DocumentCollection knowledge = documentCollectionService.getById(knowledgeId);
|
||||
if (knowledge == null) {
|
||||
throw new BusinessException("知识库不存在");
|
||||
}
|
||||
return knowledge;
|
||||
}
|
||||
|
||||
private Document requireDocument(BigInteger documentId, BigInteger knowledgeId) {
|
||||
Document document = documentService.getById(documentId);
|
||||
if (document == null || document.getCollectionId() == null || document.getCollectionId().compareTo(knowledgeId) != 0) {
|
||||
throw new BusinessException("文档不存在");
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
private DocumentChunk requireDocumentChunk(BigInteger chunkId, BigInteger knowledgeId) {
|
||||
DocumentChunk chunk = documentChunkService.getById(chunkId);
|
||||
if (chunk == null || chunk.getDocumentCollectionId() == null || chunk.getDocumentCollectionId().compareTo(knowledgeId) != 0) {
|
||||
throw new BusinessException("记录不存在");
|
||||
}
|
||||
return chunk;
|
||||
}
|
||||
|
||||
private FaqItem requireFaq(BigInteger faqId, BigInteger knowledgeId) {
|
||||
FaqItem faqItem = faqItemService.getById(faqId);
|
||||
if (faqItem == null || faqItem.getCollectionId() == null || faqItem.getCollectionId().compareTo(knowledgeId) != 0) {
|
||||
throw new BusinessException("FAQ不存在");
|
||||
}
|
||||
return faqItem;
|
||||
}
|
||||
|
||||
private void audit(String apiKey, String actionName, String actionType, String actionUrl, Map<String, Object> detail) {
|
||||
SysApiKey sysApiKey = sysApiKeyService.getSysApiKey(apiKey);
|
||||
Map<String, Object> payload = new HashMap<>(detail);
|
||||
payload.put("apiKeyId", sysApiKey.getId());
|
||||
payload.put("channel", "API");
|
||||
knowledgeShareAuditService.log(null, actionName, actionType, actionUrl, payload);
|
||||
}
|
||||
|
||||
private List<KnowledgeSearchResultItem> toKnowledgeSearchResult(List<com.easyagents.core.document.Document> documents) {
|
||||
List<KnowledgeSearchResultItem> result = new java.util.ArrayList<>();
|
||||
for (com.easyagents.core.document.Document document : documents) {
|
||||
KnowledgeSearchResultItem item = new KnowledgeSearchResultItem();
|
||||
item.setContent(document.getContent());
|
||||
item.setScore(document.getScore());
|
||||
Object hitSource = document.getMetadata("hitSource");
|
||||
item.setHitSource(hitSource == null ? null : String.valueOf(hitSource));
|
||||
item.setVectorScore(asDouble(document.getMetadata("vectorScore")));
|
||||
item.setKeywordScore(asDouble(document.getMetadata("keywordScore")));
|
||||
result.add(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private Double asDouble(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Number number) {
|
||||
return number.doubleValue();
|
||||
}
|
||||
return Double.parseDouble(String.valueOf(value));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user