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.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import tech.easyflow.ai.service.KnowledgeSharePermissionService;
|
||||||
import tech.easyflow.common.domain.Result;
|
import tech.easyflow.common.domain.Result;
|
||||||
import tech.easyflow.common.entity.LoginAccount;
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||||
@@ -43,6 +44,8 @@ public class SysApiKeyController extends BaseCurdController<SysApiKeyService, Sy
|
|||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private SysApiKeyResourceMappingService sysApiKeyResourceMappingService;
|
private SysApiKeyResourceMappingService sysApiKeyResourceMappingService;
|
||||||
|
@Resource
|
||||||
|
private KnowledgeSharePermissionService knowledgeSharePermissionService;
|
||||||
/**
|
/**
|
||||||
* 添加(保存)数据
|
* 添加(保存)数据
|
||||||
*
|
*
|
||||||
@@ -79,10 +82,20 @@ public class SysApiKeyController extends BaseCurdController<SysApiKeyService, Sy
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onSaveOrUpdateAfter(SysApiKey entity, boolean isSave) {
|
protected void onSaveOrUpdateAfter(SysApiKey entity, boolean isSave) {
|
||||||
if (!isSave && entity.getPermissionIds() != null && !entity.getPermissionIds().isEmpty()) {
|
if (entity.getPermissionIds() != null) {
|
||||||
// 修改的时候绑定授权接口
|
|
||||||
sysApiKeyResourceMappingService.authInterface(entity);
|
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
|
@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);
|
Result<Page<SysApiKey>> pageResult = (Result<Page<SysApiKey>>) super.page(request, sortKey, sortType, pageNumber, pageSize);
|
||||||
Page<SysApiKey> data = pageResult.getData();
|
Page<SysApiKey> data = pageResult.getData();
|
||||||
List<SysApiKey> records = data.getRecords();
|
List<SysApiKey> records = data.getRecords();
|
||||||
records.forEach(record -> {
|
records.forEach(this::fillApiKeyPermissions);
|
||||||
QueryWrapper queryWrapper = QueryWrapper.create().select(SysApiKeyResourceMapping::getApiKeyResourceId).eq(SysApiKeyResourceMapping::getApiKeyId, record.getId());
|
|
||||||
List<BigInteger> resourceIds = sysApiKeyResourceMappingService.listAs(queryWrapper, BigInteger.class);
|
|
||||||
record.setPermissionIds(resourceIds);
|
|
||||||
});
|
|
||||||
return pageResult;
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,7 +34,14 @@ public class GlobalErrorResolver implements HandlerExceptionResolver {
|
|||||||
} else if (ex instanceof ConstraintViolationException) {
|
} else if (ex instanceof ConstraintViolationException) {
|
||||||
error = Result.fail(400, ex.getMessage());
|
error = Result.fail(400, ex.getMessage());
|
||||||
} else if (ex instanceof BusinessException) {
|
} 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 {
|
} else {
|
||||||
LOG.error(ex.toString(), ex);
|
LOG.error(ex.toString(), ex);
|
||||||
error = Result.fail(1, "错误信息:" + ex.getMessage());
|
error = Result.fail(1, "错误信息:" + ex.getMessage());
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package tech.easyflow.ai.dto;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 分享授权请求。
|
||||||
|
*/
|
||||||
|
public class KnowledgeShareApiGrantRequest implements Serializable {
|
||||||
|
|
||||||
|
private BigInteger apiKeyId;
|
||||||
|
private BigInteger knowledgeId;
|
||||||
|
private Set<String> actionScopes;
|
||||||
|
|
||||||
|
public BigInteger getApiKeyId() {
|
||||||
|
return apiKeyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setApiKeyId(BigInteger apiKeyId) {
|
||||||
|
this.apiKeyId = apiKeyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigInteger getKnowledgeId() {
|
||||||
|
return knowledgeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setKnowledgeId(BigInteger knowledgeId) {
|
||||||
|
this.knowledgeId = knowledgeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<String> getActionScopes() {
|
||||||
|
return actionScopes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setActionScopes(Set<String> actionScopes) {
|
||||||
|
this.actionScopes = actionScopes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package tech.easyflow.ai.entity;
|
||||||
|
|
||||||
|
import com.mybatisflex.annotation.Column;
|
||||||
|
import com.mybatisflex.annotation.Table;
|
||||||
|
import tech.easyflow.ai.entity.base.KnowledgeShareBase;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识库分享记录。
|
||||||
|
*/
|
||||||
|
@Table("tb_knowledge_share")
|
||||||
|
public class KnowledgeShare extends KnowledgeShareBase {
|
||||||
|
|
||||||
|
@Column(ignore = true)
|
||||||
|
private String shareUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析授权范围。
|
||||||
|
*
|
||||||
|
* @return 授权范围集合
|
||||||
|
*/
|
||||||
|
public Set<String> getPermissionScopeSet() {
|
||||||
|
if (getPermissionSet() == null || getPermissionSet().isBlank()) {
|
||||||
|
return Collections.emptySet();
|
||||||
|
}
|
||||||
|
Set<String> scopes = new LinkedHashSet<>();
|
||||||
|
String[] segments = getPermissionSet().split(",");
|
||||||
|
for (String segment : segments) {
|
||||||
|
if (segment == null || segment.isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
scopes.add(segment.trim().toUpperCase());
|
||||||
|
}
|
||||||
|
return scopes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入授权范围。
|
||||||
|
*
|
||||||
|
* @param scopes 授权范围集合
|
||||||
|
*/
|
||||||
|
public void setPermissionScopes(Iterable<String> scopes) {
|
||||||
|
StringBuilder builder = new StringBuilder();
|
||||||
|
if (scopes != null) {
|
||||||
|
for (String scope : scopes) {
|
||||||
|
if (scope == null || scope.isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (builder.length() > 0) {
|
||||||
|
builder.append(',');
|
||||||
|
}
|
||||||
|
builder.append(scope.trim().toUpperCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setPermissionSet(builder.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getShareUrl() {
|
||||||
|
return shareUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setShareUrl(String shareUrl) {
|
||||||
|
this.shareUrl = shareUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package tech.easyflow.ai.enums;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识库分享动作范围。
|
||||||
|
*/
|
||||||
|
public enum KnowledgeShareActionScope {
|
||||||
|
|
||||||
|
VIEW,
|
||||||
|
SEARCH,
|
||||||
|
CONTENT_CREATE,
|
||||||
|
CONTENT_UPDATE,
|
||||||
|
CONTENT_DELETE,
|
||||||
|
IMPORT_EXPORT,
|
||||||
|
CONFIG_UPDATE;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析前端提交的动作范围集合。
|
||||||
|
*
|
||||||
|
* @param values 原始动作范围
|
||||||
|
* @return 规范化后的动作范围集合
|
||||||
|
*/
|
||||||
|
public static Set<String> normalize(Iterable<String> values) {
|
||||||
|
Set<String> scopes = new LinkedHashSet<>();
|
||||||
|
if (values == null) {
|
||||||
|
return scopes;
|
||||||
|
}
|
||||||
|
for (String value : values) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
scopes.add(KnowledgeShareActionScope.valueOf(value.trim().toUpperCase()).name());
|
||||||
|
}
|
||||||
|
return scopes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取默认 URL 分享授权范围。
|
||||||
|
*
|
||||||
|
* @return 默认授权范围
|
||||||
|
*/
|
||||||
|
public static Set<String> defaultUrlScopes() {
|
||||||
|
return new LinkedHashSet<>(Arrays.asList(
|
||||||
|
VIEW.name(),
|
||||||
|
SEARCH.name(),
|
||||||
|
CONTENT_CREATE.name(),
|
||||||
|
CONTENT_UPDATE.name(),
|
||||||
|
CONTENT_DELETE.name(),
|
||||||
|
IMPORT_EXPORT.name(),
|
||||||
|
CONFIG_UPDATE.name()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取默认 API 分享授权范围。
|
||||||
|
*
|
||||||
|
* <p>产品上固定开放查看、检索、新增、更新、导入导出,不提供删除能力。</p>
|
||||||
|
*
|
||||||
|
* @return 默认 API 授权范围
|
||||||
|
*/
|
||||||
|
public static Set<String> defaultApiScopes() {
|
||||||
|
return new LinkedHashSet<>(Arrays.asList(
|
||||||
|
VIEW.name(),
|
||||||
|
SEARCH.name(),
|
||||||
|
CONTENT_CREATE.name(),
|
||||||
|
CONTENT_UPDATE.name(),
|
||||||
|
IMPORT_EXPORT.name()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package tech.easyflow.ai.enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识库分享状态。
|
||||||
|
*/
|
||||||
|
public enum KnowledgeShareStatus {
|
||||||
|
ENABLED,
|
||||||
|
DISABLED,
|
||||||
|
REVOKED
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package tech.easyflow.ai.enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识库分享类型。
|
||||||
|
*/
|
||||||
|
public enum KnowledgeShareType {
|
||||||
|
URL
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package tech.easyflow.ai.mapper;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.BaseMapper;
|
||||||
|
import tech.easyflow.ai.entity.KnowledgeShare;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识库分享记录映射层。
|
||||||
|
*/
|
||||||
|
public interface KnowledgeShareMapper extends BaseMapper<KnowledgeShare> {
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package tech.easyflow.ai.service;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识库向量重建服务。
|
||||||
|
*/
|
||||||
|
public interface KnowledgeEmbeddingService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按知识库重建向量数据。
|
||||||
|
*
|
||||||
|
* @param knowledgeId 知识库 ID
|
||||||
|
*/
|
||||||
|
void rebuildKnowledgeVectors(BigInteger knowledgeId);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package tech.easyflow.ai.service;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识库分享审计服务。
|
||||||
|
*/
|
||||||
|
public interface KnowledgeShareAuditService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录审计日志。
|
||||||
|
*
|
||||||
|
* @param accountId 操作人
|
||||||
|
* @param actionName 操作名称
|
||||||
|
* @param actionType 操作类型
|
||||||
|
* @param actionUrl 操作地址
|
||||||
|
* @param detail 扩展上下文
|
||||||
|
*/
|
||||||
|
void log(BigInteger accountId, String actionName, String actionType, String actionUrl, Map<String, Object> detail);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package tech.easyflow.ai.service;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识库分享权限服务。
|
||||||
|
*/
|
||||||
|
public interface KnowledgeSharePermissionService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为系统访问令牌授予知识库 API 分享权限。
|
||||||
|
*
|
||||||
|
* @param apiKeyId 系统访问令牌 ID
|
||||||
|
* @param knowledgeId 知识库 ID
|
||||||
|
* @param actionScopes 动作范围
|
||||||
|
*/
|
||||||
|
void grantApiShare(BigInteger apiKeyId, BigInteger knowledgeId, Set<String> actionScopes);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按访问令牌维度开启或关闭知识库 API 分享授权。
|
||||||
|
*
|
||||||
|
* <p>产品固定为全量知识库的非删除范围,不向前端暴露动作粒度。</p>
|
||||||
|
*
|
||||||
|
* @param apiKeyId 系统访问令牌 ID
|
||||||
|
* @param enabled 是否启用知识库分享授权
|
||||||
|
*/
|
||||||
|
void replaceApiShareEnabled(BigInteger apiKeyId, boolean enabled);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 断言当前令牌具备知识库分享权限。
|
||||||
|
*
|
||||||
|
* @param apiKeyId 系统访问令牌 ID
|
||||||
|
* @param requestUri 请求地址
|
||||||
|
* @param knowledgeId 知识库 ID
|
||||||
|
* @param actionScope 动作范围
|
||||||
|
*/
|
||||||
|
void assertApiShare(BigInteger apiKeyId, String requestUri, BigInteger knowledgeId, String actionScope);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package tech.easyflow.ai.service;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.service.IService;
|
||||||
|
import tech.easyflow.ai.entity.KnowledgeShare;
|
||||||
|
import tech.easyflow.ai.vo.KnowledgeShareAuthContext;
|
||||||
|
import tech.easyflow.ai.vo.KnowledgeShareUrlCreateResult;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识库分享服务。
|
||||||
|
*/
|
||||||
|
public interface KnowledgeShareService extends IService<KnowledgeShare> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 URL 分享。
|
||||||
|
*
|
||||||
|
* @param knowledgeId 目标知识库
|
||||||
|
* @param tenantId 租户 ID
|
||||||
|
* @param deptId 部门 ID
|
||||||
|
* @param operatorId 操作人
|
||||||
|
* @param baseUrl 分享页基础 URL
|
||||||
|
* @param permissionScopes 授权范围
|
||||||
|
* @return 创建结果
|
||||||
|
*/
|
||||||
|
KnowledgeShareUrlCreateResult createUrlShare(
|
||||||
|
BigInteger knowledgeId,
|
||||||
|
BigInteger tenantId,
|
||||||
|
BigInteger deptId,
|
||||||
|
BigInteger operatorId,
|
||||||
|
String baseUrl,
|
||||||
|
Set<String> permissionScopes
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验 URL 分享并返回上下文。
|
||||||
|
*
|
||||||
|
* @param shareKey 分享访问密钥
|
||||||
|
* @return 鉴权上下文
|
||||||
|
*/
|
||||||
|
KnowledgeShareAuthContext validateUrlShare(String shareKey);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 断言 URL 分享允许当前动作。
|
||||||
|
*
|
||||||
|
* @param shareKey 分享访问密钥
|
||||||
|
* @param knowledgeId 知识库 ID
|
||||||
|
* @param actionScope 动作范围
|
||||||
|
* @return 鉴权上下文
|
||||||
|
*/
|
||||||
|
KnowledgeShareAuthContext assertUrlShareAccess(
|
||||||
|
String shareKey,
|
||||||
|
BigInteger knowledgeId,
|
||||||
|
String actionScope
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package tech.easyflow.ai.service.impl;
|
||||||
|
|
||||||
|
import com.easyagents.core.model.embedding.EmbeddingModel;
|
||||||
|
import com.easyagents.core.store.DocumentStore;
|
||||||
|
import com.easyagents.core.store.StoreOptions;
|
||||||
|
import com.easyagents.core.store.StoreResult;
|
||||||
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import tech.easyflow.ai.entity.DocumentChunk;
|
||||||
|
import tech.easyflow.ai.entity.DocumentCollection;
|
||||||
|
import tech.easyflow.ai.entity.FaqItem;
|
||||||
|
import tech.easyflow.ai.entity.Model;
|
||||||
|
import tech.easyflow.ai.service.DocumentChunkService;
|
||||||
|
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||||
|
import tech.easyflow.ai.service.FaqItemService;
|
||||||
|
import tech.easyflow.ai.service.KnowledgeEmbeddingService;
|
||||||
|
import tech.easyflow.ai.service.ModelService;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识库向量重建服务实现。
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class KnowledgeEmbeddingServiceImpl implements KnowledgeEmbeddingService {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private DocumentCollectionService documentCollectionService;
|
||||||
|
@Resource
|
||||||
|
private DocumentChunkService documentChunkService;
|
||||||
|
@Resource
|
||||||
|
private FaqItemService faqItemService;
|
||||||
|
@Resource
|
||||||
|
private ModelService modelService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void rebuildKnowledgeVectors(BigInteger knowledgeId) {
|
||||||
|
DocumentCollection knowledge = documentCollectionService.getById(knowledgeId);
|
||||||
|
if (knowledge == null) {
|
||||||
|
throw new BusinessException("知识库不存在");
|
||||||
|
}
|
||||||
|
DocumentStore documentStore = knowledge.toDocumentStore();
|
||||||
|
if (documentStore == null) {
|
||||||
|
throw new BusinessException("知识库没有配置向量库");
|
||||||
|
}
|
||||||
|
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
|
||||||
|
if (model == null) {
|
||||||
|
throw new BusinessException("知识库没有配置向量模型");
|
||||||
|
}
|
||||||
|
EmbeddingModel embeddingModel = model.toEmbeddingModel();
|
||||||
|
documentStore.setEmbeddingModel(embeddingModel);
|
||||||
|
StoreOptions storeOptions = StoreOptions.ofCollectionName(knowledge.getVectorStoreCollection());
|
||||||
|
storeOptions.setIndexName(knowledge.getVectorStoreCollection());
|
||||||
|
|
||||||
|
if (knowledge.isFaqCollection()) {
|
||||||
|
rebuildFaqVectors(knowledge, documentStore, storeOptions, embeddingModel);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rebuildDocumentVectors(knowledge, documentStore, storeOptions, embeddingModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void rebuildDocumentVectors(
|
||||||
|
DocumentCollection knowledge,
|
||||||
|
DocumentStore documentStore,
|
||||||
|
StoreOptions storeOptions,
|
||||||
|
EmbeddingModel embeddingModel
|
||||||
|
) {
|
||||||
|
QueryWrapper wrapper = QueryWrapper.create()
|
||||||
|
.eq(DocumentChunk::getDocumentCollectionId, knowledge.getId())
|
||||||
|
.orderBy("sorting asc");
|
||||||
|
List<DocumentChunk> chunks = documentChunkService.list(wrapper);
|
||||||
|
List<BigInteger> ids = new ArrayList<>();
|
||||||
|
List<com.easyagents.core.document.Document> documents = new ArrayList<>();
|
||||||
|
for (DocumentChunk chunk : chunks) {
|
||||||
|
ids.add(chunk.getId());
|
||||||
|
com.easyagents.core.document.Document document = com.easyagents.core.document.Document.of(chunk.getContent());
|
||||||
|
document.setId(chunk.getId());
|
||||||
|
documents.add(document);
|
||||||
|
}
|
||||||
|
rewriteStore(documentStore, storeOptions, ids, documents);
|
||||||
|
updateKnowledgeEmbeddingState(knowledge, embeddingModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void rebuildFaqVectors(
|
||||||
|
DocumentCollection knowledge,
|
||||||
|
DocumentStore documentStore,
|
||||||
|
StoreOptions storeOptions,
|
||||||
|
EmbeddingModel embeddingModel
|
||||||
|
) {
|
||||||
|
QueryWrapper wrapper = QueryWrapper.create()
|
||||||
|
.eq(FaqItem::getCollectionId, knowledge.getId())
|
||||||
|
.orderBy("order_no asc");
|
||||||
|
List<FaqItem> faqItems = faqItemService.list(wrapper);
|
||||||
|
List<BigInteger> ids = new ArrayList<>();
|
||||||
|
List<com.easyagents.core.document.Document> documents = new ArrayList<>();
|
||||||
|
for (FaqItem faqItem : faqItems) {
|
||||||
|
ids.add(faqItem.getId());
|
||||||
|
StringBuilder content = new StringBuilder();
|
||||||
|
content.append("问题:").append(faqItem.getQuestion());
|
||||||
|
if (faqItem.getAnswerText() != null && !faqItem.getAnswerText().isBlank()) {
|
||||||
|
content.append("\n答案:").append(faqItem.getAnswerText());
|
||||||
|
}
|
||||||
|
com.easyagents.core.document.Document document =
|
||||||
|
com.easyagents.core.document.Document.of(content.toString());
|
||||||
|
document.setId(faqItem.getId());
|
||||||
|
Map<String, Object> metadata = new HashMap<>();
|
||||||
|
metadata.put("question", faqItem.getQuestion());
|
||||||
|
metadata.put("answerText", faqItem.getAnswerText());
|
||||||
|
metadata.put("categoryId", faqItem.getCategoryId());
|
||||||
|
document.setMetadataMap(metadata);
|
||||||
|
documents.add(document);
|
||||||
|
}
|
||||||
|
rewriteStore(documentStore, storeOptions, ids, documents);
|
||||||
|
updateKnowledgeEmbeddingState(knowledge, embeddingModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void rewriteStore(
|
||||||
|
DocumentStore documentStore,
|
||||||
|
StoreOptions storeOptions,
|
||||||
|
List<BigInteger> ids,
|
||||||
|
List<com.easyagents.core.document.Document> documents
|
||||||
|
) {
|
||||||
|
if (!ids.isEmpty()) {
|
||||||
|
documentStore.delete(ids, storeOptions);
|
||||||
|
}
|
||||||
|
if (documents.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
StoreResult result = documentStore.store(documents, storeOptions);
|
||||||
|
if (result == null || !result.isSuccess()) {
|
||||||
|
throw new BusinessException("知识库向量重建失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateKnowledgeEmbeddingState(DocumentCollection knowledge, EmbeddingModel embeddingModel) {
|
||||||
|
DocumentCollection update = new DocumentCollection();
|
||||||
|
update.setId(knowledge.getId());
|
||||||
|
Map<String, Object> options = knowledge.getOptions() == null
|
||||||
|
? new HashMap<>()
|
||||||
|
: new HashMap<>(knowledge.getOptions());
|
||||||
|
options.put(DocumentCollection.KEY_CAN_UPDATE_EMBEDDING_MODEL, false);
|
||||||
|
update.setOptions(options);
|
||||||
|
if (knowledge.getDimensionOfVectorModel() == null) {
|
||||||
|
update.setDimensionOfVectorModel(Model.getEmbeddingDimension(embeddingModel));
|
||||||
|
}
|
||||||
|
documentCollectionService.updateById(update);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package tech.easyflow.ai.service.impl;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSON;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.context.request.RequestContextHolder;
|
||||||
|
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||||
|
import tech.easyflow.ai.service.KnowledgeShareAuditService;
|
||||||
|
import tech.easyflow.common.util.RequestUtil;
|
||||||
|
import tech.easyflow.system.entity.SysLog;
|
||||||
|
import tech.easyflow.system.service.SysLogService;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识库分享审计服务实现。
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class KnowledgeShareAuditServiceImpl implements KnowledgeShareAuditService {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private SysLogService sysLogService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void log(BigInteger accountId, String actionName, String actionType, String actionUrl, Map<String, Object> detail) {
|
||||||
|
SysLog log = new SysLog();
|
||||||
|
log.setAccountId(accountId);
|
||||||
|
log.setActionName(actionName);
|
||||||
|
log.setActionType(actionType);
|
||||||
|
log.setActionUrl(actionUrl);
|
||||||
|
log.setActionBody(detail == null ? null : JSON.toJSONString(detail));
|
||||||
|
log.setCreated(new Date());
|
||||||
|
log.setStatus(0);
|
||||||
|
ServletRequestAttributes attributes =
|
||||||
|
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||||
|
if (attributes != null) {
|
||||||
|
HttpServletRequest request = attributes.getRequest();
|
||||||
|
log.setActionIp(RequestUtil.getIpAddress(request));
|
||||||
|
log.setActionParams(request.getQueryString());
|
||||||
|
}
|
||||||
|
sysLogService.save(log);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<String, List<String>> URI_SCOPE_MAPPING = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
static {
|
||||||
|
URI_SCOPE_MAPPING.put(KnowledgeShareActionScope.VIEW.name(), List.of(
|
||||||
|
"/public-api/knowledge-share/detail",
|
||||||
|
"/public-api/knowledge-share/document/page",
|
||||||
|
"/public-api/knowledge-share/document/download",
|
||||||
|
"/public-api/knowledge-share/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<String> actionScopes) {
|
||||||
|
if (apiKeyId == null) {
|
||||||
|
throw new BusinessException("系统访问令牌不能为空");
|
||||||
|
}
|
||||||
|
if (knowledgeId == null) {
|
||||||
|
throw new BusinessException("知识库不能为空");
|
||||||
|
}
|
||||||
|
SysApiKey apiKey = sysApiKeyService.getById(apiKeyId);
|
||||||
|
if (apiKey == null) {
|
||||||
|
throw new BusinessException("系统访问令牌不存在");
|
||||||
|
}
|
||||||
|
Set<String> normalizedScopes = KnowledgeShareActionScope.normalize(actionScopes);
|
||||||
|
if (normalizedScopes.isEmpty()) {
|
||||||
|
throw new BusinessException("动作范围不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
mappingService.removeScopedMappings(apiKeyId, RESOURCE_TYPE_KNOWLEDGE, knowledgeId);
|
||||||
|
List<SysApiKeyResourceMapping> rows = new ArrayList<>();
|
||||||
|
for (String scope : normalizedScopes) {
|
||||||
|
List<String> uris = URI_SCOPE_MAPPING.get(scope);
|
||||||
|
if (uris == null || uris.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (String uri : uris) {
|
||||||
|
SysApiKeyResource resource = ensureResource(uri);
|
||||||
|
SysApiKeyResourceMapping row = new SysApiKeyResourceMapping();
|
||||||
|
row.setApiKeyId(apiKeyId);
|
||||||
|
row.setApiKeyResourceId(resource.getId());
|
||||||
|
row.setResourceType(RESOURCE_TYPE_KNOWLEDGE);
|
||||||
|
row.setResourceTargetId(knowledgeId);
|
||||||
|
row.setActionScope(scope);
|
||||||
|
rows.add(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!rows.isEmpty()) {
|
||||||
|
mappingService.saveBatch(rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public void replaceApiShareEnabled(BigInteger apiKeyId, boolean enabled) {
|
||||||
|
if (apiKeyId == null) {
|
||||||
|
throw new BusinessException("系统访问令牌不能为空");
|
||||||
|
}
|
||||||
|
SysApiKey apiKey = sysApiKeyService.getById(apiKeyId);
|
||||||
|
if (apiKey == null) {
|
||||||
|
throw new BusinessException("系统访问令牌不存在");
|
||||||
|
}
|
||||||
|
mappingService.removeScopedMappings(apiKeyId, RESOURCE_TYPE_KNOWLEDGE);
|
||||||
|
if (!enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SysApiKeyResourceMapping> rows = new ArrayList<>();
|
||||||
|
for (String scope : KnowledgeShareActionScope.defaultApiScopes()) {
|
||||||
|
List<String> uris = URI_SCOPE_MAPPING.get(scope);
|
||||||
|
if (uris == null || uris.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (String uri : uris) {
|
||||||
|
SysApiKeyResource resource = ensureResource(uri);
|
||||||
|
SysApiKeyResourceMapping row = new SysApiKeyResourceMapping();
|
||||||
|
row.setApiKeyId(apiKeyId);
|
||||||
|
row.setApiKeyResourceId(resource.getId());
|
||||||
|
row.setResourceType(RESOURCE_TYPE_KNOWLEDGE);
|
||||||
|
row.setActionScope(scope);
|
||||||
|
rows.add(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!rows.isEmpty()) {
|
||||||
|
mappingService.saveBatch(rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void assertApiShare(BigInteger apiKeyId, String requestUri, BigInteger knowledgeId, String actionScope) {
|
||||||
|
if (apiKeyId == null || knowledgeId == null) {
|
||||||
|
throw new BusinessException("API 分享鉴权参数不完整");
|
||||||
|
}
|
||||||
|
sysApiKeyService.checkResourceScope(apiKeyId, requestUri, RESOURCE_TYPE_KNOWLEDGE, knowledgeId, actionScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SysApiKeyResource ensureResource(String requestInterface) {
|
||||||
|
QueryWrapper wrapper = QueryWrapper.create()
|
||||||
|
.eq(SysApiKeyResource::getRequestInterface, requestInterface);
|
||||||
|
SysApiKeyResource resource = resourceService.getOne(wrapper);
|
||||||
|
if (resource != null) {
|
||||||
|
return resource;
|
||||||
|
}
|
||||||
|
resource = new SysApiKeyResource();
|
||||||
|
resource.setRequestInterface(requestInterface);
|
||||||
|
resource.setTitle("知识库分享接口");
|
||||||
|
resourceService.save(resource);
|
||||||
|
return resource;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package tech.easyflow.ai.service.impl;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
|
import com.mybatisflex.spring.service.impl.ServiceImpl;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import tech.easyflow.ai.constants.KnowledgeShareErrorCode;
|
||||||
|
import tech.easyflow.ai.entity.DocumentCollection;
|
||||||
|
import tech.easyflow.ai.entity.KnowledgeShare;
|
||||||
|
import tech.easyflow.ai.enums.KnowledgeShareStatus;
|
||||||
|
import tech.easyflow.ai.enums.KnowledgeShareType;
|
||||||
|
import tech.easyflow.ai.mapper.KnowledgeShareMapper;
|
||||||
|
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||||
|
import tech.easyflow.ai.service.KnowledgeShareService;
|
||||||
|
import tech.easyflow.ai.vo.KnowledgeShareAuthContext;
|
||||||
|
import tech.easyflow.ai.vo.KnowledgeShareUrlCreateResult;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识库分享服务实现。
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class KnowledgeShareServiceImpl extends ServiceImpl<KnowledgeShareMapper, KnowledgeShare> implements KnowledgeShareService {
|
||||||
|
|
||||||
|
private static final Duration DEFAULT_EXPIRE_DURATION = Duration.ofMinutes(30);
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private DocumentCollectionService documentCollectionService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public KnowledgeShareUrlCreateResult createUrlShare(
|
||||||
|
BigInteger knowledgeId,
|
||||||
|
BigInteger tenantId,
|
||||||
|
BigInteger deptId,
|
||||||
|
BigInteger operatorId,
|
||||||
|
String baseUrl,
|
||||||
|
Set<String> permissionScopes
|
||||||
|
) {
|
||||||
|
DocumentCollection knowledge = documentCollectionService.getById(knowledgeId);
|
||||||
|
if (knowledge == null) {
|
||||||
|
throw new BusinessException("知识库不存在");
|
||||||
|
}
|
||||||
|
invalidateExistingUrlShares(knowledgeId, operatorId);
|
||||||
|
|
||||||
|
String shareKey = UUID.randomUUID().toString().replace("-", "");
|
||||||
|
Date now = new Date();
|
||||||
|
Date expiresAt = new Date(now.getTime() + DEFAULT_EXPIRE_DURATION.toMillis());
|
||||||
|
|
||||||
|
KnowledgeShare share = new KnowledgeShare();
|
||||||
|
share.setKnowledgeId(knowledgeId);
|
||||||
|
share.setShareType(KnowledgeShareType.URL.name());
|
||||||
|
share.setShareKeyHash(hashShareKey(shareKey));
|
||||||
|
share.setStatus(KnowledgeShareStatus.ENABLED.name());
|
||||||
|
share.setPermissionScopes(permissionScopes);
|
||||||
|
share.setExpiresAt(expiresAt);
|
||||||
|
share.setTenantId(tenantId);
|
||||||
|
share.setDeptId(deptId);
|
||||||
|
share.setCreated(now);
|
||||||
|
share.setCreatedBy(operatorId);
|
||||||
|
share.setModified(now);
|
||||||
|
share.setModifiedBy(operatorId);
|
||||||
|
save(share);
|
||||||
|
|
||||||
|
KnowledgeShareUrlCreateResult result = new KnowledgeShareUrlCreateResult();
|
||||||
|
result.setId(share.getId());
|
||||||
|
result.setShareKey(shareKey);
|
||||||
|
result.setExpiresAt(expiresAt);
|
||||||
|
result.setShareUrl(buildShareUrl(baseUrl, shareKey));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void invalidateExistingUrlShares(BigInteger knowledgeId, BigInteger operatorId) {
|
||||||
|
QueryWrapper wrapper = QueryWrapper.create()
|
||||||
|
.eq(KnowledgeShare::getKnowledgeId, knowledgeId)
|
||||||
|
.eq(KnowledgeShare::getShareType, KnowledgeShareType.URL.name())
|
||||||
|
.eq(KnowledgeShare::getStatus, KnowledgeShareStatus.ENABLED.name());
|
||||||
|
List<KnowledgeShare> activeShares = list(wrapper);
|
||||||
|
if (activeShares == null || activeShares.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Date now = new Date();
|
||||||
|
for (KnowledgeShare activeShare : activeShares) {
|
||||||
|
KnowledgeShare update = new KnowledgeShare();
|
||||||
|
update.setId(activeShare.getId());
|
||||||
|
update.setStatus(KnowledgeShareStatus.DISABLED.name());
|
||||||
|
update.setModified(now);
|
||||||
|
update.setModifiedBy(operatorId);
|
||||||
|
updateById(update);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public KnowledgeShareAuthContext validateUrlShare(String shareKey) {
|
||||||
|
if (shareKey == null || shareKey.isBlank()) {
|
||||||
|
throw invalidShare();
|
||||||
|
}
|
||||||
|
QueryWrapper wrapper = QueryWrapper.create()
|
||||||
|
.eq(KnowledgeShare::getShareKeyHash, hashShareKey(shareKey));
|
||||||
|
KnowledgeShare share = getOne(wrapper);
|
||||||
|
if (share == null) {
|
||||||
|
throw invalidShare();
|
||||||
|
}
|
||||||
|
if (KnowledgeShareStatus.REVOKED.name().equals(share.getStatus())
|
||||||
|
|| KnowledgeShareStatus.DISABLED.name().equals(share.getStatus())) {
|
||||||
|
throw invalidShare();
|
||||||
|
}
|
||||||
|
if (share.getExpiresAt() == null || share.getExpiresAt().before(new Date())) {
|
||||||
|
throw expiredShare();
|
||||||
|
}
|
||||||
|
DocumentCollection knowledge = documentCollectionService.getById(share.getKnowledgeId());
|
||||||
|
if (knowledge == null) {
|
||||||
|
throw invalidShare();
|
||||||
|
}
|
||||||
|
KnowledgeShareAuthContext context = new KnowledgeShareAuthContext();
|
||||||
|
context.setShare(share);
|
||||||
|
context.setKnowledge(knowledge);
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public KnowledgeShareAuthContext assertUrlShareAccess(
|
||||||
|
String shareKey,
|
||||||
|
BigInteger knowledgeId,
|
||||||
|
String actionScope
|
||||||
|
) {
|
||||||
|
KnowledgeShareAuthContext context = validateUrlShare(shareKey);
|
||||||
|
if (knowledgeId != null && context.getKnowledge() != null
|
||||||
|
&& context.getKnowledge().getId() != null
|
||||||
|
&& context.getKnowledge().getId().compareTo(knowledgeId) != 0) {
|
||||||
|
throw invalidShare();
|
||||||
|
}
|
||||||
|
if (actionScope != null && !actionScope.isBlank()
|
||||||
|
&& !context.getShare().getPermissionScopeSet().contains(actionScope.trim().toUpperCase())) {
|
||||||
|
throw forbiddenShare("当前分享不允许执行该操作");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildShareUrl(String baseUrl, String shareKey) {
|
||||||
|
if (baseUrl == null || baseUrl.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return baseUrl + (baseUrl.contains("?") ? "&" : "?")
|
||||||
|
+ "shareKey=" + shareKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String hashShareKey(String shareKey) {
|
||||||
|
try {
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
byte[] value = digest.digest(shareKey.getBytes(StandardCharsets.UTF_8));
|
||||||
|
return HexFormat.of().formatHex(value);
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException("SHA-256 unavailable", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private BusinessException expiredShare() {
|
||||||
|
return new BusinessException(KnowledgeShareErrorCode.KNOWLEDGE_SHARE_EXPIRED + ":链接已过期");
|
||||||
|
}
|
||||||
|
|
||||||
|
private BusinessException invalidShare() {
|
||||||
|
return new BusinessException(KnowledgeShareErrorCode.KNOWLEDGE_SHARE_INVALID + ":链接无效");
|
||||||
|
}
|
||||||
|
|
||||||
|
private BusinessException forbiddenShare(String message) {
|
||||||
|
return new BusinessException(KnowledgeShareErrorCode.KNOWLEDGE_SHARE_FORBIDDEN + ":" + message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,9 +21,20 @@ public class CurdInterceptor implements HandlerInterceptor {
|
|||||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
||||||
|
|
||||||
String requestURI = request.getRequestURI();
|
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);
|
log.info("进入 CurdInterceptor requestURI:{}", requestURI);
|
||||||
|
|
||||||
|
// 分享访问接口与公开 API 走各自的业务鉴权,不走后台通用 CRUD 权限码拦截。
|
||||||
|
if (StrUtil.startWith(normalizedRequestUri, "/api/v1/share/")
|
||||||
|
|| StrUtil.startWith(normalizedRequestUri, "/public-api/")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
String groupName = "";
|
String groupName = "";
|
||||||
// 检查handler是否是HandlerMethod类型
|
// 检查handler是否是HandlerMethod类型
|
||||||
if (handler instanceof HandlerMethod) {
|
if (handler instanceof HandlerMethod) {
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ public class SysApiKey extends SysApiKeyBase {
|
|||||||
@Column(ignore = true)
|
@Column(ignore = true)
|
||||||
List<BigInteger> permissionIds;
|
List<BigInteger> permissionIds;
|
||||||
|
|
||||||
|
@Column(ignore = true)
|
||||||
|
private Boolean knowledgeShareEnabled;
|
||||||
|
|
||||||
@RelationOneToMany(selfField = "id", targetField = "apiKeyId", targetTable = "tb_sys_api_key_resource_mapping")
|
@RelationOneToMany(selfField = "id", targetField = "apiKeyId", targetTable = "tb_sys_api_key_resource_mapping")
|
||||||
private List<SysApiKeyResourceMapping> resourcePermissions;
|
private List<SysApiKeyResourceMapping> resourcePermissions;
|
||||||
|
|
||||||
@@ -41,4 +44,12 @@ public class SysApiKey extends SysApiKeyBase {
|
|||||||
public void setPermissionIds(List<BigInteger> permissionIds) {
|
public void setPermissionIds(List<BigInteger> permissionIds) {
|
||||||
this.permissionIds = permissionIds;
|
this.permissionIds = permissionIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Boolean getKnowledgeShareEnabled() {
|
||||||
|
return knowledgeShareEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setKnowledgeShareEnabled(Boolean knowledgeShareEnabled) {
|
||||||
|
this.knowledgeShareEnabled = knowledgeShareEnabled;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,24 @@ public class SysApiKeyResourceMappingBase implements Serializable {
|
|||||||
@Column(comment = "请求接口资源访问id")
|
@Column(comment = "请求接口资源访问id")
|
||||||
private BigInteger apiKeyResourceId;
|
private BigInteger apiKeyResourceId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 资源类型
|
||||||
|
*/
|
||||||
|
@Column(comment = "资源类型")
|
||||||
|
private String resourceType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 资源目标ID
|
||||||
|
*/
|
||||||
|
@Column(comment = "资源目标ID")
|
||||||
|
private BigInteger resourceTargetId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 动作范围
|
||||||
|
*/
|
||||||
|
@Column(comment = "动作范围")
|
||||||
|
private String actionScope;
|
||||||
|
|
||||||
public BigInteger getId() {
|
public BigInteger getId() {
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
@@ -53,4 +71,28 @@ public class SysApiKeyResourceMappingBase implements Serializable {
|
|||||||
this.apiKeyResourceId = apiKeyResourceId;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import com.mybatisflex.core.service.IService;
|
|||||||
import tech.easyflow.system.entity.SysApiKey;
|
import tech.easyflow.system.entity.SysApiKey;
|
||||||
import tech.easyflow.system.entity.SysApiKeyResourceMapping;
|
import tech.easyflow.system.entity.SysApiKeyResourceMapping;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* apikey-请求接口表 服务层。
|
* apikey-请求接口表 服务层。
|
||||||
*
|
*
|
||||||
@@ -13,4 +15,21 @@ import tech.easyflow.system.entity.SysApiKeyResourceMapping;
|
|||||||
public interface SysApiKeyResourceMappingService extends IService<SysApiKeyResourceMapping> {
|
public interface SysApiKeyResourceMappingService extends IService<SysApiKeyResourceMapping> {
|
||||||
|
|
||||||
void authInterface(SysApiKey entity);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package tech.easyflow.system.service;
|
|||||||
import com.mybatisflex.core.service.IService;
|
import com.mybatisflex.core.service.IService;
|
||||||
import tech.easyflow.system.entity.SysApiKey;
|
import tech.easyflow.system.entity.SysApiKey;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 服务层。
|
* 服务层。
|
||||||
*
|
*
|
||||||
@@ -15,4 +17,15 @@ public interface SysApiKeyService extends IService<SysApiKey> {
|
|||||||
|
|
||||||
SysApiKey getSysApiKey(String apiKey);
|
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);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,10 @@ public class SysApiKeyResourceMappingServiceImpl extends ServiceImpl<SysApiKeyRe
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
redisLockExecutor.executeWithLock(API_KEY_MAPPING_LOCK_PREFIX + apiKeyId, LOCK_WAIT_TIMEOUT, LOCK_LEASE_TIMEOUT, () -> {
|
redisLockExecutor.executeWithLock(API_KEY_MAPPING_LOCK_PREFIX + apiKeyId, LOCK_WAIT_TIMEOUT, LOCK_LEASE_TIMEOUT, () -> {
|
||||||
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()) {
|
if (entity.getPermissionIds() == null || entity.getPermissionIds().isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -66,4 +69,29 @@ public class SysApiKeyResourceMappingServiceImpl extends ServiceImpl<SysApiKeyRe
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public void removeScopedMappings(BigInteger apiKeyId, String resourceType, BigInteger resourceTargetId) {
|
||||||
|
if (apiKeyId == null || resourceTargetId == null || resourceType == null || resourceType.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
QueryWrapper wrapper = QueryWrapper.create()
|
||||||
|
.eq(SysApiKeyResourceMapping::getApiKeyId, apiKeyId)
|
||||||
|
.eq(SysApiKeyResourceMapping::getResourceType, resourceType)
|
||||||
|
.eq(SysApiKeyResourceMapping::getResourceTargetId, resourceTargetId);
|
||||||
|
remove(wrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public void removeScopedMappings(BigInteger apiKeyId, String resourceType) {
|
||||||
|
if (apiKeyId == null || resourceType == null || resourceType.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
QueryWrapper wrapper = QueryWrapper.create()
|
||||||
|
.eq(SysApiKeyResourceMapping::getApiKeyId, apiKeyId)
|
||||||
|
.eq(SysApiKeyResourceMapping::getResourceType, resourceType);
|
||||||
|
remove(wrapper);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,4 +79,36 @@ public class SysApiKeyServiceImpl extends ServiceImpl<SysApiKeyMapper, SysApiKey
|
|||||||
return one;
|
return one;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void checkResourceScope(BigInteger apiKeyId, String requestURI, String resourceType, BigInteger resourceTargetId, String actionScope) {
|
||||||
|
List<String> candidateRequestUris = getCandidateRequestUris(requestURI);
|
||||||
|
QueryWrapper resourceWrapper = QueryWrapper.create();
|
||||||
|
resourceWrapper.in(SysApiKeyResource::getRequestInterface, candidateRequestUris);
|
||||||
|
List<SysApiKeyResource> resources = resourceService.list(resourceWrapper);
|
||||||
|
if (resources == null || resources.isEmpty()) {
|
||||||
|
throw new BusinessException("该接口不存在");
|
||||||
|
}
|
||||||
|
List<BigInteger> 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无权限访问当前资源");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`;
|
||||||
@@ -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`;
|
||||||
82
easyflow-ui-admin/app/src/api/knowledge-share.ts
Normal file
82
easyflow-ui-admin/app/src/api/knowledge-share.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { api } from '#/api/request';
|
||||||
|
|
||||||
|
const EXPIRED_ERROR_CODES = new Set([4601, 4602]);
|
||||||
|
const SHARE_ERROR_REASON: Record<number, string> = {
|
||||||
|
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<string, any>) {
|
||||||
|
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<string, any>) {
|
||||||
|
return api
|
||||||
|
.get(appendShareQuery(url, config?.params), {
|
||||||
|
...config,
|
||||||
|
params: undefined,
|
||||||
|
})
|
||||||
|
.then(redirectIfExpired);
|
||||||
|
},
|
||||||
|
post(url: string, data?: any, config?: Record<string, any>) {
|
||||||
|
return api
|
||||||
|
.post(appendShareQuery(url), data, config)
|
||||||
|
.then(redirectIfExpired);
|
||||||
|
},
|
||||||
|
postFile(url: string, data?: any, config?: Record<string, any>) {
|
||||||
|
return api
|
||||||
|
.postFile(appendShareQuery(url), data, config)
|
||||||
|
.then(redirectIfExpired);
|
||||||
|
},
|
||||||
|
upload(url: string, data?: any, config?: Record<string, any>) {
|
||||||
|
return api
|
||||||
|
.upload(appendShareQuery(url), data, config)
|
||||||
|
.then(redirectIfExpired);
|
||||||
|
},
|
||||||
|
download(url: string, config?: Record<string, any>) {
|
||||||
|
return api.download(appendShareQuery(url, config?.params), {
|
||||||
|
...config,
|
||||||
|
params: undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildKnowledgeShareUrl(
|
||||||
|
url: string,
|
||||||
|
extraParams?: Record<string, any>,
|
||||||
|
) {
|
||||||
|
return appendShareQuery(url, extraParams);
|
||||||
|
}
|
||||||
@@ -12,6 +12,12 @@
|
|||||||
"save": "Save Configuration"
|
"save": "Save Configuration"
|
||||||
},
|
},
|
||||||
"engineHint": "The keyword search engine is controlled by platform-level configuration and is no longer configured per knowledge base.",
|
"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": {
|
"message": {
|
||||||
"saveSuccess": "Configuration saved successfully",
|
"saveSuccess": "Configuration saved successfully",
|
||||||
"saveFailed": "Configuration saved failed"
|
"saveFailed": "Configuration saved failed"
|
||||||
|
|||||||
@@ -14,5 +14,6 @@
|
|||||||
"failure": "Failure"
|
"failure": "Failure"
|
||||||
},
|
},
|
||||||
"permissions": "AuthInterface",
|
"permissions": "AuthInterface",
|
||||||
|
"knowledgeSharePermission": "Knowledge Share",
|
||||||
"addApiKeyNotice": "This operation will generate an API key. Please confirm whether to proceed"
|
"addApiKeyNotice": "This operation will generate an API key. Please confirm whether to proceed"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,12 @@
|
|||||||
"save": "保存配置"
|
"save": "保存配置"
|
||||||
},
|
},
|
||||||
"engineHint": "关键词检索引擎由平台全局配置决定,知识库侧不再单独配置。",
|
"engineHint": "关键词检索引擎由平台全局配置决定,知识库侧不再单独配置。",
|
||||||
|
"retrievalModeTitle": "召回方式说明",
|
||||||
|
"retrievalModeDescriptions": {
|
||||||
|
"hybrid": "同时结合向量语义与关键词匹配,适合默认场景,结果更均衡。",
|
||||||
|
"vector": "优先按语义相似度召回,适合问法与原文表达差异较大的查询。",
|
||||||
|
"keyword": "优先按字面词项匹配,适合术语、编号、专有名词等精确查找。"
|
||||||
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"saveSuccess": "配置保存成功",
|
"saveSuccess": "配置保存成功",
|
||||||
"saveFailed": "配置保存失败"
|
"saveFailed": "配置保存失败"
|
||||||
|
|||||||
@@ -14,5 +14,6 @@
|
|||||||
"failure": "已失效"
|
"failure": "已失效"
|
||||||
},
|
},
|
||||||
"permissions": "授权接口",
|
"permissions": "授权接口",
|
||||||
|
"knowledgeSharePermission": "知识库分享授权",
|
||||||
"addApiKeyNotice": "该操作会生成一个apiKey,请确认是否生成"
|
"addApiKeyNotice": "该操作会生成一个apiKey,请确认是否生成"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ interface NetworkConnectionLike {
|
|||||||
|
|
||||||
const CHUNK_ERROR_RELOAD_KEY = '__easyflow_chunk_error_reload_path__';
|
const CHUNK_ERROR_RELOAD_KEY = '__easyflow_chunk_error_reload_path__';
|
||||||
|
|
||||||
|
function isKnowledgeShareRoute(path: string) {
|
||||||
|
return path === '/share/knowledge' || path === '/share/knowledge/expired';
|
||||||
|
}
|
||||||
|
|
||||||
function isSlowNetworkConnection() {
|
function isSlowNetworkConnection() {
|
||||||
if (typeof navigator === 'undefined') {
|
if (typeof navigator === 'undefined') {
|
||||||
return false;
|
return false;
|
||||||
@@ -238,6 +242,10 @@ function setupAccessGuard(router: Router) {
|
|||||||
return buildForcePasswordRoute();
|
return buildForcePasswordRoute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isKnowledgeShareRoute(to.path)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// 是否已经生成过动态路由
|
// 是否已经生成过动态路由
|
||||||
if (accessStore.isAccessChecked) {
|
if (accessStore.isAccessChecked) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
32
easyflow-ui-admin/app/src/router/routes/external/share.ts
vendored
Normal file
32
easyflow-ui-admin/app/src/router/routes/external/share.ts
vendored
Normal file
@@ -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;
|
||||||
@@ -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 staticRouteFiles = import.meta.glob('./static/**/*.ts', { eager: true });
|
||||||
|
|
||||||
/** 动态路由 */
|
/** 动态路由 */
|
||||||
const dynamicRoutes: RouteRecordRaw[] = mergeRouteModules(dynamicRouteFiles);
|
const dynamicRoutes: RouteRecordRaw[] = mergeRouteModules(dynamicRouteFiles);
|
||||||
|
|
||||||
/** 外部路由列表,访问这些页面可以不需要Layout,可能用于内嵌在别的系统(不会显示在菜单中) */
|
/** 外部路由列表,访问这些页面可以不需要Layout,可能用于内嵌在别的系统(不会显示在菜单中) */
|
||||||
// const externalRoutes: RouteRecordRaw[] = mergeRouteModules(externalRouteFiles);
|
const externalRoutes: RouteRecordRaw[] = mergeRouteModules(externalRouteFiles);
|
||||||
// const staticRoutes: RouteRecordRaw[] = mergeRouteModules(staticRouteFiles);
|
// const staticRoutes: RouteRecordRaw[] = mergeRouteModules(staticRouteFiles);
|
||||||
const staticRoutes: RouteRecordRaw[] = [];
|
const staticRoutes: RouteRecordRaw[] = [];
|
||||||
const externalRoutes: RouteRecordRaw[] = [];
|
|
||||||
|
|
||||||
/** 路由列表,由基本路由、外部路由和404兜底路由组成
|
/** 路由列表,由基本路由、外部路由和404兜底路由组成
|
||||||
* 无需走权限验证(会一直显示在菜单中) */
|
* 无需走权限验证(会一直显示在菜单中) */
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
|
|
||||||
import { api } from '#/api/request';
|
import { api } from '#/api/request';
|
||||||
import PageData from '#/components/page/PageData.vue';
|
import PageData from '#/components/page/PageData.vue';
|
||||||
|
import { buildKnowledgePath } from '#/views/ai/documentCollection/share-path';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
documentId: {
|
documentId: {
|
||||||
@@ -31,6 +32,14 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
|
requestClient: {
|
||||||
|
type: Object as any,
|
||||||
|
default: () => api,
|
||||||
|
},
|
||||||
|
endpointPrefix: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const dialogVisible = ref(false);
|
const dialogVisible = ref(false);
|
||||||
const pageDataRef = ref();
|
const pageDataRef = ref();
|
||||||
@@ -54,8 +63,14 @@ const handleDelete = (row: any) => {
|
|||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
btnLoading.value = true;
|
btnLoading.value = true;
|
||||||
api
|
props.requestClient
|
||||||
.post('/api/v1/documentChunk/removeChunk', { id: row.id })
|
.post(
|
||||||
|
buildKnowledgePath(
|
||||||
|
props.endpointPrefix,
|
||||||
|
'/api/v1/documentChunk/removeChunk',
|
||||||
|
),
|
||||||
|
{ id: row.id },
|
||||||
|
)
|
||||||
.then((res: any) => {
|
.then((res: any) => {
|
||||||
btnLoading.value = false;
|
btnLoading.value = false;
|
||||||
if (res.errorCode !== 0) {
|
if (res.errorCode !== 0) {
|
||||||
@@ -85,7 +100,12 @@ const save = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
btnLoading.value = true;
|
btnLoading.value = true;
|
||||||
api.post('/api/v1/documentChunk/update', form.value).then((res: any) => {
|
props.requestClient
|
||||||
|
.post(
|
||||||
|
buildKnowledgePath(props.endpointPrefix, '/api/v1/documentChunk/update'),
|
||||||
|
form.value,
|
||||||
|
)
|
||||||
|
.then((res: any) => {
|
||||||
btnLoading.value = false;
|
btnLoading.value = false;
|
||||||
if (res.errorCode !== 0) {
|
if (res.errorCode !== 0) {
|
||||||
ElMessage.error(res.message);
|
ElMessage.error(res.message);
|
||||||
@@ -107,9 +127,12 @@ const form = ref({
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<PageData
|
<PageData
|
||||||
page-url="/api/v1/documentChunk/page"
|
:page-url="
|
||||||
|
buildKnowledgePath(props.endpointPrefix, '/api/v1/documentChunk/page')
|
||||||
|
"
|
||||||
ref="pageDataRef"
|
ref="pageDataRef"
|
||||||
:page-size="10"
|
:page-size="10"
|
||||||
|
:request-client="props.requestClient"
|
||||||
:extra-query-params="queryParams"
|
:extra-query-params="queryParams"
|
||||||
>
|
>
|
||||||
<template #default="{ pageList }">
|
<template #default="{ pageList }">
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import FaqTable from '#/views/ai/documentCollection/FaqTable.vue';
|
|||||||
import ImportKnowledgeDocFile from '#/views/ai/documentCollection/ImportKnowledgeDocFile.vue';
|
import ImportKnowledgeDocFile from '#/views/ai/documentCollection/ImportKnowledgeDocFile.vue';
|
||||||
import KnowledgeSearch from '#/views/ai/documentCollection/KnowledgeSearch.vue';
|
import KnowledgeSearch from '#/views/ai/documentCollection/KnowledgeSearch.vue';
|
||||||
import KnowledgeSearchConfig from '#/views/ai/documentCollection/KnowledgeSearchConfig.vue';
|
import KnowledgeSearchConfig from '#/views/ai/documentCollection/KnowledgeSearchConfig.vue';
|
||||||
|
import KnowledgeShareManagement from '#/views/ai/documentCollection/KnowledgeShareManagement.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -73,8 +74,13 @@ const syncNavTitle = (title: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resolveDefaultMenu = (collectionType: string, menuKey: string) => {
|
const resolveDefaultMenu = (collectionType: string, menuKey: string) => {
|
||||||
const faqMenus = new Set(['config', 'faqList', 'knowledgeSearch']);
|
const faqMenus = new Set(['config', 'faqList', 'knowledgeSearch', 'share']);
|
||||||
const documentMenus = new Set(['config', 'documentList', 'knowledgeSearch']);
|
const documentMenus = new Set([
|
||||||
|
'config',
|
||||||
|
'documentList',
|
||||||
|
'knowledgeSearch',
|
||||||
|
'share',
|
||||||
|
]);
|
||||||
const fallbackMenu = collectionType === 'FAQ' ? 'faqList' : 'documentList';
|
const fallbackMenu = collectionType === 'FAQ' ? 'faqList' : 'documentList';
|
||||||
|
|
||||||
if (!menuKey) {
|
if (!menuKey) {
|
||||||
@@ -119,6 +125,7 @@ const categoryData = computed(() => {
|
|||||||
name: $t('documentCollection.knowledgeRetrieval'),
|
name: $t('documentCollection.knowledgeRetrieval'),
|
||||||
},
|
},
|
||||||
{ key: 'config', name: $t('documentCollection.config') },
|
{ key: 'config', name: $t('documentCollection.config') },
|
||||||
|
{ key: 'share', name: '分享' },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
@@ -128,6 +135,7 @@ const categoryData = computed(() => {
|
|||||||
name: $t('documentCollection.knowledgeRetrieval'),
|
name: $t('documentCollection.knowledgeRetrieval'),
|
||||||
},
|
},
|
||||||
{ key: 'config', name: $t('documentCollection.config') },
|
{ key: 'config', name: $t('documentCollection.config') },
|
||||||
|
{ key: 'share', name: '分享' },
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
const headerButtons = [
|
const headerButtons = [
|
||||||
@@ -257,6 +265,13 @@ const backDoc = () => {
|
|||||||
@reload="getKnowledge"
|
@reload="getKnowledge"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="selectedCategory === 'share'">
|
||||||
|
<KnowledgeShareManagement
|
||||||
|
:knowledge-id="String(knowledgeId)"
|
||||||
|
:collection-type="knowledgeInfo.collectionType || 'DOCUMENT'"
|
||||||
|
:manageable="canManageCurrentKnowledge"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import { api } from '#/api/request';
|
import { api } from '#/api/request';
|
||||||
import documentIcon from '#/assets/ai/knowledge/document.svg';
|
import documentIcon from '#/assets/ai/knowledge/document.svg';
|
||||||
import PageData from '#/components/page/PageData.vue';
|
import PageData from '#/components/page/PageData.vue';
|
||||||
|
import { buildKnowledgePath } from '#/views/ai/documentCollection/share-path';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
knowledgeId: {
|
knowledgeId: {
|
||||||
@@ -30,6 +31,14 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
|
requestClient: {
|
||||||
|
type: Object as any,
|
||||||
|
default: () => api,
|
||||||
|
},
|
||||||
|
endpointPrefix: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const emits = defineEmits(['viewDoc']);
|
const emits = defineEmits(['viewDoc']);
|
||||||
defineExpose({
|
defineExpose({
|
||||||
@@ -44,8 +53,11 @@ const handleView = (row: any) => {
|
|||||||
emits('viewDoc', row.id);
|
emits('viewDoc', row.id);
|
||||||
};
|
};
|
||||||
const handleDownload = async (row: any) => {
|
const handleDownload = async (row: any) => {
|
||||||
const blob = await api.download(
|
const blob = await props.requestClient.download(
|
||||||
|
buildKnowledgePath(
|
||||||
|
props.endpointPrefix,
|
||||||
`/api/v1/document/download?documentId=${row.id}`,
|
`/api/v1/document/download?documentId=${row.id}`,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
downloadFileFromBlob({
|
downloadFileFromBlob({
|
||||||
fileName: row.title || 'document',
|
fileName: row.title || 'document',
|
||||||
@@ -62,7 +74,12 @@ const handleDelete = (row: any) => {
|
|||||||
cancelButtonText: $t('button.cancel'),
|
cancelButtonText: $t('button.cancel'),
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
api.post('/api/v1/document/removeDoc', { id: row.id }).then((res) => {
|
props.requestClient
|
||||||
|
.post(
|
||||||
|
buildKnowledgePath(props.endpointPrefix, '/api/v1/document/removeDoc'),
|
||||||
|
{ id: row.id },
|
||||||
|
)
|
||||||
|
.then((res: any) => {
|
||||||
if (res.errorCode === 0) {
|
if (res.errorCode === 0) {
|
||||||
ElMessage.success($t('message.deleteOkMessage'));
|
ElMessage.success($t('message.deleteOkMessage'));
|
||||||
pageDataRef.value.setQuery({ id: props.knowledgeId });
|
pageDataRef.value.setQuery({ id: props.knowledgeId });
|
||||||
@@ -75,9 +92,12 @@ const handleDelete = (row: any) => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PageData
|
<PageData
|
||||||
page-url="/api/v1/document/documentList"
|
:page-url="
|
||||||
|
buildKnowledgePath(props.endpointPrefix, '/api/v1/document/documentList')
|
||||||
|
"
|
||||||
ref="pageDataRef"
|
ref="pageDataRef"
|
||||||
:page-size="10"
|
:page-size="10"
|
||||||
|
:request-client="props.requestClient"
|
||||||
:extra-query-params="{
|
:extra-query-params="{
|
||||||
id: props.knowledgeId,
|
id: props.knowledgeId,
|
||||||
sort: 'desc',
|
sort: 'desc',
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from 'element-plus';
|
} from 'element-plus';
|
||||||
|
|
||||||
import { api } from '#/api/request';
|
import { api } from '#/api/request';
|
||||||
|
import { buildKnowledgePath } from '#/views/ai/documentCollection/share-path';
|
||||||
|
|
||||||
import '@wangeditor/editor/dist/css/style.css';
|
import '@wangeditor/editor/dist/css/style.css';
|
||||||
|
|
||||||
@@ -33,6 +34,14 @@ const props = defineProps({
|
|||||||
type: Array as any,
|
type: Array as any,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
},
|
},
|
||||||
|
requestClient: {
|
||||||
|
type: Object as any,
|
||||||
|
default: () => api,
|
||||||
|
},
|
||||||
|
endpointPrefix: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['submit', 'update:modelValue']);
|
const emit = defineEmits(['submit', 'update:modelValue']);
|
||||||
@@ -97,8 +106,11 @@ const editorConfig: any = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api.upload(
|
const res = await props.requestClient.upload(
|
||||||
|
buildKnowledgePath(
|
||||||
|
props.endpointPrefix,
|
||||||
'/api/v1/faqItem/uploadImage',
|
'/api/v1/faqItem/uploadImage',
|
||||||
|
),
|
||||||
{ collectionId: form.value.collectionId, file },
|
{ collectionId: form.value.collectionId, file },
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
} from 'element-plus';
|
} from 'element-plus';
|
||||||
|
|
||||||
import { api } from '#/api/request';
|
import { api } from '#/api/request';
|
||||||
|
import { buildKnowledgePath } from '#/views/ai/documentCollection/share-path';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
@@ -36,6 +37,14 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
requestClient: {
|
||||||
|
type: Object as any,
|
||||||
|
default: () => api,
|
||||||
|
},
|
||||||
|
endpointPrefix: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'success']);
|
const emit = defineEmits(['update:modelValue', 'success']);
|
||||||
@@ -92,8 +101,11 @@ const downloadTemplate = async () => {
|
|||||||
}
|
}
|
||||||
downloadLoading.value = true;
|
downloadLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const blob = await api.download(
|
const blob = await props.requestClient.download(
|
||||||
|
buildKnowledgePath(
|
||||||
|
props.endpointPrefix,
|
||||||
`/api/v1/faqItem/downloadImportTemplate?collectionId=${props.knowledgeId}`,
|
`/api/v1/faqItem/downloadImportTemplate?collectionId=${props.knowledgeId}`,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
downloadFileFromBlob({
|
downloadFileFromBlob({
|
||||||
fileName: 'faq_import_template.xlsx',
|
fileName: 'faq_import_template.xlsx',
|
||||||
@@ -114,9 +126,13 @@ const handleImport = async () => {
|
|||||||
formData.append('collectionId', props.knowledgeId);
|
formData.append('collectionId', props.knowledgeId);
|
||||||
submitLoading.value = true;
|
submitLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await api.postFile('/api/v1/faqItem/importExcel', formData, {
|
const res = await props.requestClient.postFile(
|
||||||
|
buildKnowledgePath(props.endpointPrefix, '/api/v1/faqItem/importExcel'),
|
||||||
|
formData,
|
||||||
|
{
|
||||||
timeout: 10 * 60 * 1000,
|
timeout: 10 * 60 * 1000,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
if (res.errorCode === 0) {
|
if (res.errorCode === 0) {
|
||||||
importResult.value = res.data;
|
importResult.value = res.data;
|
||||||
ElMessage.success($t('documentCollection.faq.import.importFinished'));
|
ElMessage.success($t('documentCollection.faq.import.importFinished'));
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
|
|
||||||
import { api } from '#/api/request';
|
import { api } from '#/api/request';
|
||||||
import PageData from '#/components/page/PageData.vue';
|
import PageData from '#/components/page/PageData.vue';
|
||||||
|
import { buildKnowledgePath } from '#/views/ai/documentCollection/share-path';
|
||||||
|
|
||||||
import FaqCategoryDialog from './FaqCategoryDialog.vue';
|
import FaqCategoryDialog from './FaqCategoryDialog.vue';
|
||||||
import FaqEditDialog from './FaqEditDialog.vue';
|
import FaqEditDialog from './FaqEditDialog.vue';
|
||||||
@@ -47,6 +48,14 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
|
requestClient: {
|
||||||
|
type: Object as any,
|
||||||
|
default: () => api,
|
||||||
|
},
|
||||||
|
endpointPrefix: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const pageDataRef = ref();
|
const pageDataRef = ref();
|
||||||
@@ -91,12 +100,15 @@ const refreshList = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const reloadCategoryTree = async () => {
|
const reloadCategoryTree = async () => {
|
||||||
const res = await api.get('/api/v1/faqCategory/list', {
|
const res = await props.requestClient.get(
|
||||||
|
buildKnowledgePath(props.endpointPrefix, '/api/v1/faqCategory/list'),
|
||||||
|
{
|
||||||
params: {
|
params: {
|
||||||
collectionId: props.knowledgeId,
|
collectionId: props.knowledgeId,
|
||||||
asTree: true,
|
asTree: true,
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
);
|
||||||
if (res.errorCode === 0) {
|
if (res.errorCode === 0) {
|
||||||
categoryTree.value = normalizeCategoryTree(res.data || []);
|
categoryTree.value = normalizeCategoryTree(res.data || []);
|
||||||
|
|
||||||
@@ -167,8 +179,11 @@ const downloadImportTemplate = async () => {
|
|||||||
}
|
}
|
||||||
templateDownloadLoading.value = true;
|
templateDownloadLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const blob = await api.download(
|
const blob = await props.requestClient.download(
|
||||||
|
buildKnowledgePath(
|
||||||
|
props.endpointPrefix,
|
||||||
`/api/v1/faqItem/downloadImportTemplate?collectionId=${props.knowledgeId}`,
|
`/api/v1/faqItem/downloadImportTemplate?collectionId=${props.knowledgeId}`,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
downloadFileFromBlob({
|
downloadFileFromBlob({
|
||||||
fileName: 'faq_import_template.xlsx',
|
fileName: 'faq_import_template.xlsx',
|
||||||
@@ -185,8 +200,11 @@ const exportFaqExcel = async () => {
|
|||||||
}
|
}
|
||||||
exportLoading.value = true;
|
exportLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const blob = await api.download(
|
const blob = await props.requestClient.download(
|
||||||
|
buildKnowledgePath(
|
||||||
|
props.endpointPrefix,
|
||||||
`/api/v1/faqItem/exportExcel?collectionId=${props.knowledgeId}`,
|
`/api/v1/faqItem/exportExcel?collectionId=${props.knowledgeId}`,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
downloadFileFromBlob({
|
downloadFileFromBlob({
|
||||||
fileName: 'faq_export.xlsx',
|
fileName: 'faq_export.xlsx',
|
||||||
@@ -245,7 +263,10 @@ const saveFaq = async (payload: any) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const url = payload.id ? '/api/v1/faqItem/update' : '/api/v1/faqItem/save';
|
const url = payload.id ? '/api/v1/faqItem/update' : '/api/v1/faqItem/save';
|
||||||
const res = await api.post(url, payload);
|
const res = await props.requestClient.post(
|
||||||
|
buildKnowledgePath(props.endpointPrefix, url),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
if (res.errorCode === 0) {
|
if (res.errorCode === 0) {
|
||||||
ElMessage.success(
|
ElMessage.success(
|
||||||
payload.id ? $t('message.updateOkMessage') : $t('message.saveOkMessage'),
|
payload.id ? $t('message.updateOkMessage') : $t('message.saveOkMessage'),
|
||||||
@@ -267,7 +288,14 @@ const removeFaq = (row: any) => {
|
|||||||
cancelButtonText: $t('button.cancel'),
|
cancelButtonText: $t('button.cancel'),
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
api.post('/api/v1/faqItem/remove', { id: row.id }).then((res) => {
|
props.requestClient
|
||||||
|
.post(
|
||||||
|
buildKnowledgePath(props.endpointPrefix, '/api/v1/faqItem/remove'),
|
||||||
|
{
|
||||||
|
id: row.id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.then((res: any) => {
|
||||||
if (res.errorCode === 0) {
|
if (res.errorCode === 0) {
|
||||||
ElMessage.success($t('message.deleteOkMessage'));
|
ElMessage.success($t('message.deleteOkMessage'));
|
||||||
refreshList();
|
refreshList();
|
||||||
@@ -380,7 +408,10 @@ const removeCategory = (node: any) => {
|
|||||||
cancelButtonText: $t('button.cancel'),
|
cancelButtonText: $t('button.cancel'),
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
}).then(async () => {
|
}).then(async () => {
|
||||||
const res = await api.post('/api/v1/faqCategory/remove', { id: node.id });
|
const res = await props.requestClient.post(
|
||||||
|
buildKnowledgePath(props.endpointPrefix, '/api/v1/faqCategory/remove'),
|
||||||
|
{ id: node.id },
|
||||||
|
);
|
||||||
if (res.errorCode === 0) {
|
if (res.errorCode === 0) {
|
||||||
ElMessage.success($t('message.deleteOkMessage'));
|
ElMessage.success($t('message.deleteOkMessage'));
|
||||||
if (selectedCategoryId.value === String(node.id)) {
|
if (selectedCategoryId.value === String(node.id)) {
|
||||||
@@ -467,7 +498,10 @@ const getChildrenByParentId = (parentId: string): any[] => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updateCategoryRequest = async (payload: Record<string, any>) => {
|
const updateCategoryRequest = async (payload: Record<string, any>) => {
|
||||||
const res = await api.post('/api/v1/faqCategory/update', payload);
|
const res = await props.requestClient.post(
|
||||||
|
buildKnowledgePath(props.endpointPrefix, '/api/v1/faqCategory/update'),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
if (res.errorCode !== 0) {
|
if (res.errorCode !== 0) {
|
||||||
throw new Error(res.message || $t('message.getDataError'));
|
throw new Error(res.message || $t('message.getDataError'));
|
||||||
}
|
}
|
||||||
@@ -664,7 +698,10 @@ const saveCategory = async (payload: any) => {
|
|||||||
const url = payload.id
|
const url = payload.id
|
||||||
? '/api/v1/faqCategory/update'
|
? '/api/v1/faqCategory/update'
|
||||||
: '/api/v1/faqCategory/save';
|
: '/api/v1/faqCategory/save';
|
||||||
const res = await api.post(url, payload);
|
const res = await props.requestClient.post(
|
||||||
|
buildKnowledgePath(props.endpointPrefix, url),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
if (res.errorCode === 0) {
|
if (res.errorCode === 0) {
|
||||||
ElMessage.success(
|
ElMessage.success(
|
||||||
payload.id ? $t('message.updateOkMessage') : $t('message.saveOkMessage'),
|
payload.id ? $t('message.updateOkMessage') : $t('message.saveOkMessage'),
|
||||||
@@ -909,8 +946,11 @@ onMounted(() => {
|
|||||||
|
|
||||||
<PageData
|
<PageData
|
||||||
ref="pageDataRef"
|
ref="pageDataRef"
|
||||||
page-url="/api/v1/faqItem/page"
|
:page-url="
|
||||||
|
buildKnowledgePath(props.endpointPrefix, '/api/v1/faqItem/page')
|
||||||
|
"
|
||||||
:page-size="10"
|
:page-size="10"
|
||||||
|
:request-client="props.requestClient"
|
||||||
:extra-query-params="baseQueryParams"
|
:extra-query-params="baseQueryParams"
|
||||||
>
|
>
|
||||||
<template #default="{ pageList }">
|
<template #default="{ pageList }">
|
||||||
@@ -967,6 +1007,8 @@ onMounted(() => {
|
|||||||
v-model="dialogVisible"
|
v-model="dialogVisible"
|
||||||
:data="editData"
|
:data="editData"
|
||||||
:category-options="categoryTreeOptions"
|
:category-options="categoryTreeOptions"
|
||||||
|
:request-client="props.requestClient"
|
||||||
|
:endpoint-prefix="props.endpointPrefix"
|
||||||
@submit="saveFaq"
|
@submit="saveFaq"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -982,6 +1024,8 @@ onMounted(() => {
|
|||||||
<FaqImportDialog
|
<FaqImportDialog
|
||||||
v-model="importDialogVisible"
|
v-model="importDialogVisible"
|
||||||
:knowledge-id="knowledgeId"
|
:knowledge-id="knowledgeId"
|
||||||
|
:request-client="props.requestClient"
|
||||||
|
:endpoint-prefix="props.endpointPrefix"
|
||||||
@success="handleImportSuccess"
|
@success="handleImportSuccess"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { computed, onBeforeUnmount, ref } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
import { $t } from '@easyflow/locales';
|
import { $t } from '@easyflow/locales';
|
||||||
@@ -11,6 +11,7 @@ import { api } from '#/api/request';
|
|||||||
import ComfirmImportDocument from '#/views/ai/documentCollection/ComfirmImportDocument.vue';
|
import ComfirmImportDocument from '#/views/ai/documentCollection/ComfirmImportDocument.vue';
|
||||||
import ImportKnowledgeFileContainer from '#/views/ai/documentCollection/ImportKnowledgeFileContainer.vue';
|
import ImportKnowledgeFileContainer from '#/views/ai/documentCollection/ImportKnowledgeFileContainer.vue';
|
||||||
import SegmenterDoc from '#/views/ai/documentCollection/SegmenterDoc.vue';
|
import SegmenterDoc from '#/views/ai/documentCollection/SegmenterDoc.vue';
|
||||||
|
import { buildKnowledgePath } from '#/views/ai/documentCollection/share-path';
|
||||||
import SplitterDocPreview from '#/views/ai/documentCollection/SplitterDocPreview.vue';
|
import SplitterDocPreview from '#/views/ai/documentCollection/SplitterDocPreview.vue';
|
||||||
|
|
||||||
interface UploadFileItem {
|
interface UploadFileItem {
|
||||||
@@ -30,9 +31,26 @@ interface PreviewItem {
|
|||||||
totalChunks?: number;
|
totalChunks?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
requestClient: {
|
||||||
|
type: Object as any,
|
||||||
|
default: () => api,
|
||||||
|
},
|
||||||
|
endpointPrefix: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
knowledgeIdProp: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const emits = defineEmits(['importBack']);
|
const emits = defineEmits(['importBack']);
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const knowledgeId = computed(() => (route.query.id as string) || '');
|
const knowledgeId = computed(
|
||||||
|
() => props.knowledgeIdProp || (route.query.id as string) || '',
|
||||||
|
);
|
||||||
|
|
||||||
const fileUploadRef = ref<InstanceType<typeof ImportKnowledgeFileContainer>>();
|
const fileUploadRef = ref<InstanceType<typeof ImportKnowledgeFileContainer>>();
|
||||||
const segmenterDocRef = ref<InstanceType<typeof SegmenterDoc>>();
|
const segmenterDocRef = ref<InstanceType<typeof SegmenterDoc>>();
|
||||||
@@ -46,6 +64,7 @@ const commitResults = ref<any[]>([]);
|
|||||||
const analyzing = ref(false);
|
const analyzing = ref(false);
|
||||||
const previewing = ref(false);
|
const previewing = ref(false);
|
||||||
const committing = ref(false);
|
const committing = ref(false);
|
||||||
|
let autoBackTimer: null | ReturnType<typeof setTimeout> = null;
|
||||||
|
|
||||||
const canGoPrevious = computed(() => activeStep.value > 0 && !committing.value);
|
const canGoPrevious = computed(() => activeStep.value > 0 && !committing.value);
|
||||||
|
|
||||||
@@ -53,6 +72,15 @@ function back() {
|
|||||||
emits('importBack');
|
emits('importBack');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scheduleBackAfterSuccess() {
|
||||||
|
if (autoBackTimer) {
|
||||||
|
clearTimeout(autoBackTimer);
|
||||||
|
}
|
||||||
|
autoBackTimer = setTimeout(() => {
|
||||||
|
back();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
function getUploadedFiles() {
|
function getUploadedFiles() {
|
||||||
return fileUploadRef.value?.getFilesData?.() || [];
|
return fileUploadRef.value?.getFilesData?.() || [];
|
||||||
}
|
}
|
||||||
@@ -65,14 +93,18 @@ async function goToNextStep() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
files.value = currentFiles;
|
files.value = currentFiles;
|
||||||
await runAnalyze();
|
const analyzed = await runAnalyze();
|
||||||
|
if (analyzed) {
|
||||||
activeStep.value = 1;
|
activeStep.value = 1;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeStep.value === 1) {
|
if (activeStep.value === 1) {
|
||||||
await runPreview();
|
const previewed = await runPreview();
|
||||||
|
if (previewed) {
|
||||||
activeStep.value = 2;
|
activeStep.value = 2;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,14 +123,24 @@ function goToPreviousStep() {
|
|||||||
async function runAnalyze() {
|
async function runAnalyze() {
|
||||||
analyzing.value = true;
|
analyzing.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await api.post('/api/v1/document/import/analyze', {
|
const payload: Record<string, any> = {
|
||||||
files: files.value.map((item) => ({
|
files: files.value.map((item) => ({
|
||||||
fileName: item.fileName,
|
fileName: item.fileName,
|
||||||
filePath: item.filePath,
|
filePath: item.filePath,
|
||||||
})),
|
})),
|
||||||
knowledgeId: knowledgeId.value,
|
};
|
||||||
});
|
if (knowledgeId.value) {
|
||||||
|
payload.knowledgeId = knowledgeId.value;
|
||||||
|
}
|
||||||
|
const res = await props.requestClient.post(
|
||||||
|
buildKnowledgePath(
|
||||||
|
props.endpointPrefix,
|
||||||
|
'/api/v1/document/import/analyze',
|
||||||
|
),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
analysisItems.value = res.data?.items || [];
|
analysisItems.value = res.data?.items || [];
|
||||||
|
return true;
|
||||||
} finally {
|
} finally {
|
||||||
analyzing.value = false;
|
analyzing.value = false;
|
||||||
}
|
}
|
||||||
@@ -109,16 +151,26 @@ async function runPreview() {
|
|||||||
segmenterDocRef.value?.getPreviewRequestItems?.() || [];
|
segmenterDocRef.value?.getPreviewRequestItems?.() || [];
|
||||||
if (previewRequestItems.length === 0) {
|
if (previewRequestItems.length === 0) {
|
||||||
ElMessage.error($t('documentCollection.importDoc.previewEmpty'));
|
ElMessage.error($t('documentCollection.importDoc.previewEmpty'));
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
previewing.value = true;
|
previewing.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await api.post('/api/v1/document/import/preview', {
|
const payload: Record<string, any> = {
|
||||||
files: previewRequestItems,
|
files: previewRequestItems,
|
||||||
knowledgeId: knowledgeId.value,
|
};
|
||||||
});
|
if (knowledgeId.value) {
|
||||||
|
payload.knowledgeId = knowledgeId.value;
|
||||||
|
}
|
||||||
|
const res = await props.requestClient.post(
|
||||||
|
buildKnowledgePath(
|
||||||
|
props.endpointPrefix,
|
||||||
|
'/api/v1/document/import/preview',
|
||||||
|
),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
previewItems.value = res.data?.items || [];
|
previewItems.value = res.data?.items || [];
|
||||||
commitResults.value = [];
|
commitResults.value = [];
|
||||||
|
return true;
|
||||||
} finally {
|
} finally {
|
||||||
previewing.value = false;
|
previewing.value = false;
|
||||||
}
|
}
|
||||||
@@ -131,20 +183,36 @@ async function confirmImport() {
|
|||||||
}
|
}
|
||||||
committing.value = true;
|
committing.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await api.post('/api/v1/document/import/commit', {
|
const payload: Record<string, any> = {
|
||||||
knowledgeId: knowledgeId.value,
|
|
||||||
previewSessionIds: previewItems.value.map(
|
previewSessionIds: previewItems.value.map(
|
||||||
(item) => item.previewSessionId,
|
(item) => item.previewSessionId,
|
||||||
),
|
),
|
||||||
});
|
};
|
||||||
|
if (knowledgeId.value) {
|
||||||
|
payload.knowledgeId = knowledgeId.value;
|
||||||
|
}
|
||||||
|
const res = await props.requestClient.post(
|
||||||
|
buildKnowledgePath(
|
||||||
|
props.endpointPrefix,
|
||||||
|
'/api/v1/document/import/commit',
|
||||||
|
),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
commitResults.value = res.data?.results || [];
|
commitResults.value = res.data?.results || [];
|
||||||
if ((res.data?.errorCount || 0) === 0) {
|
if ((res.data?.errorCount || 0) === 0) {
|
||||||
ElMessage.success($t('documentCollection.splitterDoc.importSuccess'));
|
ElMessage.success($t('documentCollection.splitterDoc.importSuccess'));
|
||||||
|
scheduleBackAfterSuccess();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
committing.value = false;
|
committing.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (autoBackTimer) {
|
||||||
|
clearTimeout(autoBackTimer);
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
import { $t } from '@easyflow/locales';
|
import { $t } from '@easyflow/locales';
|
||||||
|
|
||||||
import { ElButton, ElInput, ElMessage, ElOption, ElSelect } from 'element-plus';
|
import { ElButton, ElInput, ElMessage } from 'element-plus';
|
||||||
|
|
||||||
import { api } from '#/api/request';
|
import { api } from '#/api/request';
|
||||||
import PreviewSearchKnowledge from '#/views/ai/documentCollection/PreviewSearchKnowledge.vue';
|
import PreviewSearchKnowledge from '#/views/ai/documentCollection/PreviewSearchKnowledge.vue';
|
||||||
|
import { buildKnowledgePath } from '#/views/ai/documentCollection/share-path';
|
||||||
|
|
||||||
type RetrievalMode = 'HYBRID' | 'KEYWORD' | 'VECTOR';
|
type RetrievalMode = 'HYBRID' | 'KEYWORD' | 'VECTOR';
|
||||||
|
|
||||||
@@ -24,6 +25,14 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
requestClient: {
|
||||||
|
type: Object as any,
|
||||||
|
default: () => api,
|
||||||
|
},
|
||||||
|
endpointPrefix: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const searchDataList = ref<SearchResultItem[]>([]);
|
const searchDataList = ref<SearchResultItem[]>([]);
|
||||||
@@ -31,11 +40,29 @@ const keyword = ref('');
|
|||||||
const retrievalMode = ref<RetrievalMode>('HYBRID');
|
const retrievalMode = ref<RetrievalMode>('HYBRID');
|
||||||
const previewSearchKnowledgeRef = ref();
|
const previewSearchKnowledgeRef = ref();
|
||||||
|
|
||||||
const retrievalModeOptions = [
|
const retrievalModeDescriptions = computed(() => [
|
||||||
{ label: $t('bot.retrievalModes.hybrid'), value: 'HYBRID' },
|
{
|
||||||
{ label: $t('bot.retrievalModes.vector'), value: 'VECTOR' },
|
key: 'HYBRID' as RetrievalMode,
|
||||||
{ label: $t('bot.retrievalModes.keyword'), value: 'KEYWORD' },
|
label: $t('bot.retrievalModes.hybrid'),
|
||||||
];
|
description: $t(
|
||||||
|
'documentCollectionSearch.retrievalModeDescriptions.hybrid',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'VECTOR' as RetrievalMode,
|
||||||
|
label: $t('bot.retrievalModes.vector'),
|
||||||
|
description: $t(
|
||||||
|
'documentCollectionSearch.retrievalModeDescriptions.vector',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'KEYWORD' as RetrievalMode,
|
||||||
|
label: $t('bot.retrievalModes.keyword'),
|
||||||
|
description: $t(
|
||||||
|
'documentCollectionSearch.retrievalModeDescriptions.keyword',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
if (!keyword.value) {
|
if (!keyword.value) {
|
||||||
@@ -43,14 +70,20 @@ const handleSearch = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
previewSearchKnowledgeRef.value.loadingContent(true);
|
previewSearchKnowledgeRef.value.loadingContent(true);
|
||||||
api
|
props.requestClient
|
||||||
.get('/api/v1/documentCollection/search', {
|
.get(
|
||||||
|
buildKnowledgePath(
|
||||||
|
props.endpointPrefix,
|
||||||
|
'/api/v1/documentCollection/search',
|
||||||
|
),
|
||||||
|
{
|
||||||
params: {
|
params: {
|
||||||
knowledgeId: props.knowledgeId,
|
knowledgeId: props.knowledgeId,
|
||||||
keyword: keyword.value,
|
keyword: keyword.value,
|
||||||
retrievalMode: retrievalMode.value,
|
retrievalMode: retrievalMode.value,
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
|
)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
searchDataList.value = res.data;
|
searchDataList.value = res.data;
|
||||||
})
|
})
|
||||||
@@ -62,6 +95,10 @@ const handleSearch = () => {
|
|||||||
previewSearchKnowledgeRef.value.loadingContent(false);
|
previewSearchKnowledgeRef.value.loadingContent(false);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRetrievalModeChange = (mode: RetrievalMode) => {
|
||||||
|
retrievalMode.value = mode;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -72,23 +109,35 @@ const handleSearch = () => {
|
|||||||
:placeholder="$t('common.searchPlaceholder')"
|
:placeholder="$t('common.searchPlaceholder')"
|
||||||
@keyup.enter="handleSearch"
|
@keyup.enter="handleSearch"
|
||||||
/>
|
/>
|
||||||
<ElSelect v-model="retrievalMode" class="retrieval-select">
|
|
||||||
<ElOption
|
|
||||||
v-for="item in retrievalModeOptions"
|
|
||||||
:key="item.value"
|
|
||||||
:label="item.label"
|
|
||||||
:value="item.value"
|
|
||||||
/>
|
|
||||||
</ElSelect>
|
|
||||||
<ElButton type="primary" @click="handleSearch">
|
<ElButton type="primary" @click="handleSearch">
|
||||||
{{ $t('button.query') }}
|
{{ $t('button.query') }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="search-hint">
|
||||||
|
<div class="search-hint__title">
|
||||||
|
{{ $t('documentCollectionSearch.retrievalModeTitle') }}
|
||||||
|
</div>
|
||||||
|
<div class="search-hint__list">
|
||||||
|
<button
|
||||||
|
v-for="item in retrievalModeDescriptions"
|
||||||
|
:key="item.key"
|
||||||
|
type="button"
|
||||||
|
class="search-hint__item"
|
||||||
|
:class="{ 'is-active': retrievalMode === item.key }"
|
||||||
|
@click="handleRetrievalModeChange(item.key)"
|
||||||
|
>
|
||||||
|
<div class="search-hint__label">{{ item.label }}</div>
|
||||||
|
<div class="search-hint__desc">{{ item.description }}</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="search-result">
|
<div class="search-result">
|
||||||
<PreviewSearchKnowledge
|
<PreviewSearchKnowledge
|
||||||
|
ref="previewSearchKnowledgeRef"
|
||||||
:data="searchDataList"
|
:data="searchDataList"
|
||||||
:retrieval-mode="retrievalMode"
|
:retrieval-mode="retrievalMode"
|
||||||
ref="previewSearchKnowledgeRef"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,7 +148,6 @@ const handleSearch = () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
|
||||||
padding: 0 0 20px;
|
padding: 0 0 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,16 +155,84 @@ const handleSearch = () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.retrieval-select {
|
.search-input :deep(.el-input) {
|
||||||
flex-shrink: 0;
|
flex: 1;
|
||||||
width: 180px;
|
}
|
||||||
|
|
||||||
|
.search-hint {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
background: var(--el-fill-color-blank);
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-hint__title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-hint__list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-hint__item {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 14px 16px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 14px;
|
||||||
|
transition:
|
||||||
|
border-color 0.2s ease,
|
||||||
|
background-color 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-hint__item.is-active {
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
border-color: var(--el-color-primary-light-7);
|
||||||
|
box-shadow: 0 6px 18px rgb(64 158 255 / 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-hint__item:focus-visible {
|
||||||
|
outline: 2px solid var(--el-color-primary-light-5);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-hint__label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-hint__desc {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-result {
|
.search-result {
|
||||||
flex: 1;
|
|
||||||
padding-top: 20px;
|
padding-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.search-hint__list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { $t } from '@easyflow/locales';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ElButton,
|
||||||
|
ElCard,
|
||||||
|
ElForm,
|
||||||
|
ElFormItem,
|
||||||
|
ElInputNumber,
|
||||||
|
ElMessage,
|
||||||
|
ElOption,
|
||||||
|
ElSelect,
|
||||||
|
ElSwitch,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
|
import { api } from '#/api/request';
|
||||||
|
import { buildKnowledgePath } from '#/views/ai/documentCollection/share-path';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
knowledgeId: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
detailData: {
|
||||||
|
type: Object as any,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
requestClient: {
|
||||||
|
type: Object as any,
|
||||||
|
default: () => api,
|
||||||
|
},
|
||||||
|
endpointPrefix: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['reload']);
|
||||||
|
|
||||||
|
const form = ref<any>({
|
||||||
|
knowledgeId: '',
|
||||||
|
vectorEmbedModelId: '',
|
||||||
|
rerankModelId: '',
|
||||||
|
rerankEnable: false,
|
||||||
|
docRecallMaxNum: 5,
|
||||||
|
simThreshold: 0.6,
|
||||||
|
rebuildVectors: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const embeddingModels = ref<any[]>([]);
|
||||||
|
const rerankModels = ref<any[]>([]);
|
||||||
|
const saving = ref(false);
|
||||||
|
|
||||||
|
const syncForm = () => {
|
||||||
|
const options = props.detailData?.options || {};
|
||||||
|
form.value = {
|
||||||
|
knowledgeId: props.knowledgeId,
|
||||||
|
vectorEmbedModelId: props.detailData?.vectorEmbedModelId || '',
|
||||||
|
rerankModelId: props.detailData?.rerankModelId || '',
|
||||||
|
rerankEnable:
|
||||||
|
options?.rerankEnable === undefined
|
||||||
|
? !!props.detailData?.rerankModelId
|
||||||
|
: !!options.rerankEnable,
|
||||||
|
docRecallMaxNum: Number(options?.docRecallMaxNum || 5),
|
||||||
|
simThreshold: Number(options?.simThreshold || 0.6),
|
||||||
|
rebuildVectors: false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.detailData, props.knowledgeId],
|
||||||
|
() => {
|
||||||
|
syncForm();
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadModels = async () => {
|
||||||
|
const [embeddingRes, rerankRes] = await Promise.all([
|
||||||
|
props.requestClient.get(
|
||||||
|
buildKnowledgePath(
|
||||||
|
props.endpointPrefix,
|
||||||
|
'/api/v1/documentCollection/modelList',
|
||||||
|
),
|
||||||
|
{ params: { modelType: 'embeddingModel' } },
|
||||||
|
),
|
||||||
|
props.requestClient.get(
|
||||||
|
buildKnowledgePath(
|
||||||
|
props.endpointPrefix,
|
||||||
|
'/api/v1/documentCollection/modelList',
|
||||||
|
),
|
||||||
|
{ params: { modelType: 'rerankModel' } },
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
embeddingModels.value = embeddingRes?.data || [];
|
||||||
|
rerankModels.value = rerankRes?.data || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
const res = await props.requestClient.post(
|
||||||
|
buildKnowledgePath(
|
||||||
|
props.endpointPrefix,
|
||||||
|
'/api/v1/documentCollection/shareConfigUpdate',
|
||||||
|
),
|
||||||
|
form.value,
|
||||||
|
);
|
||||||
|
if (res.errorCode === 0) {
|
||||||
|
ElMessage.success($t('message.saveOkMessage'));
|
||||||
|
emit('reload');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadModels();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElCard shadow="never" class="share-config-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="share-config-card__header">
|
||||||
|
<div>
|
||||||
|
<div class="share-config-card__title">受限配置</div>
|
||||||
|
<div class="share-config-card__desc">
|
||||||
|
仅开放模型切换、检索参数与向量重建
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ElButton type="primary" :loading="saving" @click="handleSave">
|
||||||
|
{{ $t('button.save') }}
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ElForm label-position="top" class="share-config-form">
|
||||||
|
<ElFormItem :label="$t('documentCollection.vectorEmbedLlmId')">
|
||||||
|
<ElSelect v-model="form.vectorEmbedModelId">
|
||||||
|
<ElOption
|
||||||
|
v-for="item in embeddingModels"
|
||||||
|
:key="item.id"
|
||||||
|
:label="item.title"
|
||||||
|
:value="item.id"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem :label="$t('documentCollection.rerankLlmId')">
|
||||||
|
<ElSelect v-model="form.rerankModelId" clearable>
|
||||||
|
<ElOption
|
||||||
|
v-for="item in rerankModels"
|
||||||
|
:key="item.id"
|
||||||
|
:label="item.title"
|
||||||
|
:value="item.id"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem :label="$t('documentCollection.rerankEnable')">
|
||||||
|
<ElSwitch v-model="form.rerankEnable" />
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem :label="$t('documentCollectionSearch.docRecallMaxNum.label')">
|
||||||
|
<ElInputNumber v-model="form.docRecallMaxNum" :min="1" :max="50" />
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem :label="$t('documentCollectionSearch.simThreshold.label')">
|
||||||
|
<ElInputNumber
|
||||||
|
v-model="form.simThreshold"
|
||||||
|
:min="0"
|
||||||
|
:max="1"
|
||||||
|
:step="0.01"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="保存后重建向量">
|
||||||
|
<ElSwitch v-model="form.rebuildVectors" />
|
||||||
|
</ElFormItem>
|
||||||
|
</ElForm>
|
||||||
|
</ElCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.share-config-card {
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-config-card__header {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-config-card__title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-config-card__desc {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-config-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.share-config-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-config-card__header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const isInvalid = computed(() => route.query.reason === 'invalid');
|
||||||
|
const title = computed(() => (isInvalid.value ? '链接无效' : '链接已过期'));
|
||||||
|
const description = computed(() =>
|
||||||
|
isInvalid.value
|
||||||
|
? '请确认链接完整无误,或联系分享人重新生成。'
|
||||||
|
: '请联系分享人重新生成可用链接。',
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="share-expired">
|
||||||
|
<div class="share-expired__card">
|
||||||
|
<div class="share-expired__title">{{ title }}</div>
|
||||||
|
<div class="share-expired__desc">{{ description }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.share-expired {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 24px;
|
||||||
|
background:
|
||||||
|
radial-gradient(
|
||||||
|
circle at top left,
|
||||||
|
rgb(255 240 240 / 88%),
|
||||||
|
transparent 28%
|
||||||
|
),
|
||||||
|
linear-gradient(180deg, #faf7f6 0%, #f3efed 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-expired__card {
|
||||||
|
width: min(460px, 100%);
|
||||||
|
padding: 36px 28px;
|
||||||
|
text-align: center;
|
||||||
|
background: rgb(255 255 255 / 92%);
|
||||||
|
border: 1px solid rgb(220 227 235 / 72%);
|
||||||
|
border-radius: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-expired__title {
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-expired__desc {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,531 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import { $t } from '@easyflow/locales';
|
||||||
|
|
||||||
|
import { CopyDocument } from '@element-plus/icons-vue';
|
||||||
|
import { ElButton, ElCard, ElIcon, ElInput, ElMessage } from 'element-plus';
|
||||||
|
|
||||||
|
import { api } from '#/api/request';
|
||||||
|
|
||||||
|
type EndpointParam = {
|
||||||
|
location: 'body' | 'query';
|
||||||
|
name: string;
|
||||||
|
note?: string;
|
||||||
|
required?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EndpointDoc = {
|
||||||
|
hint: string;
|
||||||
|
method: 'GET' | 'POST';
|
||||||
|
params: EndpointParam[];
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
knowledgeId: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
collectionType: {
|
||||||
|
type: String,
|
||||||
|
default: 'DOCUMENT',
|
||||||
|
},
|
||||||
|
manageable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createLoading = ref(false);
|
||||||
|
const generatedUrl = ref('');
|
||||||
|
const generatedExpireAt = ref('');
|
||||||
|
|
||||||
|
const apiBaseUrl = computed(() => {
|
||||||
|
const appBasePath = (import.meta.env.BASE_URL || '/').replace(/\/$/, '');
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return `${appBasePath}/public-api/knowledge-share`;
|
||||||
|
}
|
||||||
|
return `${window.location.origin}${appBasePath}/public-api/knowledge-share`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const detailExample = computed(() => {
|
||||||
|
return [
|
||||||
|
`curl -X GET '${apiBaseUrl.value}/detail?knowledgeId=${props.knowledgeId}' \\`,
|
||||||
|
" -H 'ApiKey: 你的访问令牌'",
|
||||||
|
].join('\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchExample = computed(() => {
|
||||||
|
return [
|
||||||
|
`curl -G '${apiBaseUrl.value}/search' \\`,
|
||||||
|
" -H 'ApiKey: 你的访问令牌' \\",
|
||||||
|
` --data-urlencode 'knowledgeId=${props.knowledgeId}' \\`,
|
||||||
|
" --data-urlencode 'keyword=测试问题'",
|
||||||
|
].join('\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
const endpointDocs = computed(() => {
|
||||||
|
const commonEndpoints: EndpointDoc[] = [
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/detail',
|
||||||
|
hint: '获取知识库详情',
|
||||||
|
params: [{ name: 'knowledgeId', location: 'query', required: true }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/search',
|
||||||
|
hint: '知识检索',
|
||||||
|
params: [
|
||||||
|
{ name: 'knowledgeId', location: 'query', required: true },
|
||||||
|
{ name: 'keyword', location: 'query', required: true },
|
||||||
|
{
|
||||||
|
name: 'retrievalMode',
|
||||||
|
location: 'query',
|
||||||
|
note: '召回方式',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const typeEndpoints: EndpointDoc[] =
|
||||||
|
props.collectionType === 'FAQ'
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/faq/page',
|
||||||
|
hint: 'FAQ 分页',
|
||||||
|
params: [
|
||||||
|
{ name: 'knowledgeId', location: 'query', required: true },
|
||||||
|
{ name: 'question', location: 'query', note: '问题关键字' },
|
||||||
|
{ name: 'categoryId', location: 'query', note: '分类 ID' },
|
||||||
|
{ name: 'pageNumber', location: 'query', note: '默认 1' },
|
||||||
|
{ name: 'pageSize', location: 'query', note: '默认 10' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/faq/save',
|
||||||
|
hint: '新增 FAQ',
|
||||||
|
params: [
|
||||||
|
{ name: 'collectionId', location: 'body', required: true },
|
||||||
|
{ name: 'question', location: 'body', required: true },
|
||||||
|
{ name: 'answerHtml', location: 'body', required: true },
|
||||||
|
{ name: 'categoryId', location: 'body', note: '分类 ID' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/document/page',
|
||||||
|
hint: '文档分页',
|
||||||
|
params: [
|
||||||
|
{ name: 'knowledgeId', location: 'query', required: true },
|
||||||
|
{ name: 'title', location: 'query', note: '文档标题' },
|
||||||
|
{ name: 'pageNumber', location: 'query', note: '默认 1' },
|
||||||
|
{ name: 'pageSize', location: 'query', note: '默认 10' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/document/import/commit',
|
||||||
|
hint: '导入文档',
|
||||||
|
params: [
|
||||||
|
{ name: 'knowledgeId', location: 'body', required: true },
|
||||||
|
{
|
||||||
|
name: 'previewSessionIds',
|
||||||
|
location: 'body',
|
||||||
|
required: true,
|
||||||
|
note: '预览接口返回的会话 ID 数组',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return [...commonEndpoints, ...typeEndpoints].map((item) => ({
|
||||||
|
...item,
|
||||||
|
url: `${apiBaseUrl.value}${item.path}`,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatEndpointParam = (param: EndpointParam) => {
|
||||||
|
const segments = [param.location, param.required ? '必填' : '可选'];
|
||||||
|
if (param.note) {
|
||||||
|
segments.push(param.note);
|
||||||
|
}
|
||||||
|
return `${param.name}(${segments.join(',')})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createShare = async () => {
|
||||||
|
if (!props.manageable) {
|
||||||
|
ElMessage.warning($t('documentCollection.managePermissionHint'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await api.post('/api/v1/knowledgeShare/url/create', {
|
||||||
|
knowledgeId: props.knowledgeId,
|
||||||
|
});
|
||||||
|
if (res.errorCode === 0) {
|
||||||
|
generatedUrl.value = res.data?.shareUrl || '';
|
||||||
|
generatedExpireAt.value = res.data?.expiresAt || '';
|
||||||
|
ElMessage.success('已创建分享链接');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
createLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyGeneratedUrl = async () => {
|
||||||
|
if (!generatedUrl.value) {
|
||||||
|
ElMessage.warning('请先生成分享链接');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await navigator.clipboard.writeText(generatedUrl.value);
|
||||||
|
ElMessage.success('已复制分享链接');
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyApiExample = async (content: string) => {
|
||||||
|
await navigator.clipboard.writeText(content);
|
||||||
|
ElMessage.success('已复制调用示例');
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyEndpointUrl = async (url: string) => {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
ElMessage.success('已复制接口地址');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="share-management">
|
||||||
|
<ElCard shadow="never" class="share-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="share-card__header">
|
||||||
|
<div>
|
||||||
|
<div class="share-card__title">当前分享链接</div>
|
||||||
|
<div class="share-card__desc">
|
||||||
|
默认有效期 30 分钟。重新生成后旧链接立即失效。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ElButton
|
||||||
|
type="primary"
|
||||||
|
:loading="createLoading"
|
||||||
|
:disabled="!props.manageable"
|
||||||
|
@click="createShare"
|
||||||
|
>
|
||||||
|
{{ generatedUrl ? '重新生成链接' : '生成分享链接' }}
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="generatedUrl" class="share-result">
|
||||||
|
<div class="share-result__row">
|
||||||
|
<ElInput v-model="generatedUrl" readonly />
|
||||||
|
<ElButton
|
||||||
|
text
|
||||||
|
circle
|
||||||
|
:icon="CopyDocument"
|
||||||
|
aria-label="复制分享链接"
|
||||||
|
title="复制分享链接"
|
||||||
|
@click="copyGeneratedUrl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="share-result__meta">过期时间:{{ generatedExpireAt }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="share-empty">暂未生成分享链接</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<ElCard shadow="never" class="share-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="share-card__header share-card__header--stack">
|
||||||
|
<div>
|
||||||
|
<div class="share-card__title">API 调用方式</div>
|
||||||
|
<div class="share-card__desc">
|
||||||
|
使用已开通知识库分享授权的访问令牌,通过 `ApiKey` 请求头调用。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="api-doc">
|
||||||
|
<div class="api-doc__meta">
|
||||||
|
<div class="api-doc__item">
|
||||||
|
<span class="api-doc__label">接口前缀</span>
|
||||||
|
<code class="api-doc__value">{{ apiBaseUrl }}</code>
|
||||||
|
</div>
|
||||||
|
<div class="api-doc__item">
|
||||||
|
<span class="api-doc__label">请求头</span>
|
||||||
|
<code class="api-doc__value">ApiKey: 你的访问令牌</code>
|
||||||
|
</div>
|
||||||
|
<div class="api-doc__item">
|
||||||
|
<span class="api-doc__label">当前知识库 ID</span>
|
||||||
|
<code class="api-doc__value">{{ props.knowledgeId }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="api-doc__section">
|
||||||
|
<div class="api-doc__section-title">常用接口</div>
|
||||||
|
<div class="api-doc__endpoint-list">
|
||||||
|
<div
|
||||||
|
v-for="endpoint in endpointDocs"
|
||||||
|
:key="endpoint.path"
|
||||||
|
class="api-doc__endpoint"
|
||||||
|
>
|
||||||
|
<div class="api-doc__endpoint-main">
|
||||||
|
<span class="api-doc__method">{{ endpoint.method }}</span>
|
||||||
|
<code>{{ endpoint.path }}</code>
|
||||||
|
<span class="api-doc__hint">{{ endpoint.hint }}</span>
|
||||||
|
<ElButton
|
||||||
|
text
|
||||||
|
circle
|
||||||
|
:icon="CopyDocument"
|
||||||
|
class="api-doc__copy"
|
||||||
|
:aria-label="`复制 ${endpoint.path}`"
|
||||||
|
:title="`复制 ${endpoint.url}`"
|
||||||
|
@click="copyEndpointUrl(endpoint.url)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="api-doc__endpoint-params">
|
||||||
|
<span class="api-doc__params-label">参数</span>
|
||||||
|
<span
|
||||||
|
v-for="param in endpoint.params"
|
||||||
|
:key="`${endpoint.path}-${param.name}`"
|
||||||
|
class="api-doc__param"
|
||||||
|
>
|
||||||
|
{{ formatEndpointParam(param) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="api-doc__section">
|
||||||
|
<div class="api-doc__section-title">详情示例</div>
|
||||||
|
<div class="api-doc__code-wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="api-doc__code-copy"
|
||||||
|
title="复制详情示例"
|
||||||
|
aria-label="复制详情示例"
|
||||||
|
@click="copyApiExample(detailExample)"
|
||||||
|
>
|
||||||
|
<ElIcon><CopyDocument /></ElIcon>
|
||||||
|
</button>
|
||||||
|
<pre class="api-doc__code">{{ detailExample }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="api-doc__section">
|
||||||
|
<div class="api-doc__section-title">检索示例</div>
|
||||||
|
<div class="api-doc__code-wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="api-doc__code-copy"
|
||||||
|
title="复制检索示例"
|
||||||
|
aria-label="复制检索示例"
|
||||||
|
@click="copyApiExample(searchExample)"
|
||||||
|
>
|
||||||
|
<ElIcon><CopyDocument /></ElIcon>
|
||||||
|
</button>
|
||||||
|
<pre class="api-doc__code">{{ searchExample }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.share-management {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-card {
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-card__header {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-card__header--stack {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-card__title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-card__desc,
|
||||||
|
.share-result__meta {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-result {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-result__row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-empty {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-doc {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-doc__meta {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-doc__item {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-doc__label {
|
||||||
|
min-width: 84px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-doc__value {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-doc__section {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-doc__section-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-doc__endpoint-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-doc__endpoint {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-doc__endpoint-main {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-doc__copy {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-doc__method {
|
||||||
|
min-width: 42px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-doc__hint {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-doc__endpoint-params {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-doc__params-label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-doc__param {
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-doc__code {
|
||||||
|
margin: 0;
|
||||||
|
padding: 14px 16px;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-doc__code-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-doc__code-copy {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
z-index: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-doc__code-copy:hover {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
background: var(--el-fill-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-doc__code-copy:focus-visible {
|
||||||
|
outline: 2px solid var(--el-color-primary-light-5);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-doc__code-wrap .api-doc__code {
|
||||||
|
padding-top: 40px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
import { $t } from '@easyflow/locales';
|
||||||
|
|
||||||
|
import { ElButton, ElCard, ElImage, ElTag } from 'element-plus';
|
||||||
|
|
||||||
|
import { knowledgeShareApi } from '#/api/knowledge-share';
|
||||||
|
import bookIcon from '#/assets/ai/knowledge/book.svg';
|
||||||
|
import ChunkDocumentTable from '#/views/ai/documentCollection/ChunkDocumentTable.vue';
|
||||||
|
import DocumentTable from '#/views/ai/documentCollection/DocumentTable.vue';
|
||||||
|
import FaqTable from '#/views/ai/documentCollection/FaqTable.vue';
|
||||||
|
import ImportKnowledgeDocFile from '#/views/ai/documentCollection/ImportKnowledgeDocFile.vue';
|
||||||
|
import KnowledgeSearch from '#/views/ai/documentCollection/KnowledgeSearch.vue';
|
||||||
|
import KnowledgeShareConfigPanel from '#/views/ai/documentCollection/KnowledgeShareConfigPanel.vue';
|
||||||
|
|
||||||
|
const endpointPrefix = '/api/v1/share/knowledge';
|
||||||
|
const knowledgeInfo = ref<any>({});
|
||||||
|
const loading = ref(false);
|
||||||
|
const selectedCategory = ref('');
|
||||||
|
const viewDocVisible = ref(false);
|
||||||
|
const documentId = ref('');
|
||||||
|
const importVisible = ref(false);
|
||||||
|
const documentTableRef = ref();
|
||||||
|
|
||||||
|
const isFaqCollection = computed(
|
||||||
|
() => knowledgeInfo.value.collectionType === 'FAQ',
|
||||||
|
);
|
||||||
|
const collectionTypeLabel = computed(() =>
|
||||||
|
isFaqCollection.value
|
||||||
|
? $t('documentCollection.collectionTypeFaq')
|
||||||
|
: $t('documentCollection.collectionTypeDocument'),
|
||||||
|
);
|
||||||
|
const knowledgeTitle = computed(
|
||||||
|
() =>
|
||||||
|
knowledgeInfo.value.title ||
|
||||||
|
knowledgeInfo.value.alias ||
|
||||||
|
knowledgeInfo.value.englishName ||
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
const knowledgeDescription = computed(
|
||||||
|
() => knowledgeInfo.value.description || '',
|
||||||
|
);
|
||||||
|
const categoryData = computed(() => {
|
||||||
|
if (isFaqCollection.value) {
|
||||||
|
return [
|
||||||
|
{ key: 'faqList', name: $t('documentCollection.faq.faqList') },
|
||||||
|
{
|
||||||
|
key: 'knowledgeSearch',
|
||||||
|
name: $t('documentCollection.knowledgeRetrieval'),
|
||||||
|
},
|
||||||
|
{ key: 'config', name: $t('documentCollection.config') },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ key: 'documentList', name: $t('documentCollection.documentList') },
|
||||||
|
{
|
||||||
|
key: 'knowledgeSearch',
|
||||||
|
name: $t('documentCollection.knowledgeRetrieval'),
|
||||||
|
},
|
||||||
|
{ key: 'config', name: $t('documentCollection.config') },
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadKnowledge = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await knowledgeShareApi.get(
|
||||||
|
`${endpointPrefix}/documentCollection/detail`,
|
||||||
|
);
|
||||||
|
knowledgeInfo.value = res.data || {};
|
||||||
|
selectedCategory.value =
|
||||||
|
knowledgeInfo.value.collectionType === 'FAQ' ? 'faqList' : 'documentList';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewDoc = (id: string) => {
|
||||||
|
documentId.value = id;
|
||||||
|
viewDocVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const backToDocumentList = () => {
|
||||||
|
viewDocVisible.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openImport = () => {
|
||||||
|
importVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadKnowledge();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="share-page" v-loading="loading">
|
||||||
|
<div v-if="!importVisible" class="share-shell">
|
||||||
|
<ElCard shadow="never" class="share-hero">
|
||||||
|
<div class="share-hero__main">
|
||||||
|
<div class="share-hero__meta">
|
||||||
|
<ElImage :src="bookIcon" class="share-hero__icon" />
|
||||||
|
<div class="share-hero__text">
|
||||||
|
<div class="share-hero__title-row">
|
||||||
|
<div class="share-hero__title">{{ knowledgeTitle }}</div>
|
||||||
|
<ElTag size="small" effect="plain" round>
|
||||||
|
{{ collectionTypeLabel }}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
<div v-if="knowledgeDescription" class="share-hero__desc">
|
||||||
|
{{ knowledgeDescription }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="share-hero__actions">
|
||||||
|
<ElButton
|
||||||
|
v-if="!isFaqCollection"
|
||||||
|
type="primary"
|
||||||
|
@click="openImport"
|
||||||
|
>
|
||||||
|
{{ $t('button.importFile') }}
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="share-tabs">
|
||||||
|
<button
|
||||||
|
v-for="item in categoryData"
|
||||||
|
:key="item.key"
|
||||||
|
class="share-tab"
|
||||||
|
:class="{ 'is-active': selectedCategory === item.key }"
|
||||||
|
@click="selectedCategory = item.key"
|
||||||
|
>
|
||||||
|
{{ item.name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<div class="share-body">
|
||||||
|
<div v-if="selectedCategory === 'documentList'" class="share-panel">
|
||||||
|
<DocumentTable
|
||||||
|
v-if="!viewDocVisible"
|
||||||
|
ref="documentTableRef"
|
||||||
|
:knowledge-id="knowledgeInfo.id"
|
||||||
|
:manageable="true"
|
||||||
|
:request-client="knowledgeShareApi"
|
||||||
|
:endpoint-prefix="endpointPrefix"
|
||||||
|
@view-doc="handleViewDoc"
|
||||||
|
/>
|
||||||
|
<ChunkDocumentTable
|
||||||
|
v-else
|
||||||
|
:document-id="documentId"
|
||||||
|
:manageable="true"
|
||||||
|
:request-client="knowledgeShareApi"
|
||||||
|
:endpoint-prefix="endpointPrefix"
|
||||||
|
/>
|
||||||
|
<div v-if="viewDocVisible" class="share-panel__footer">
|
||||||
|
<ElButton @click="backToDocumentList">
|
||||||
|
{{ $t('button.back') }}
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedCategory === 'faqList'" class="share-panel">
|
||||||
|
<FaqTable
|
||||||
|
:knowledge-id="knowledgeInfo.id"
|
||||||
|
:manageable="true"
|
||||||
|
:request-client="knowledgeShareApi"
|
||||||
|
:endpoint-prefix="endpointPrefix"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedCategory === 'knowledgeSearch'" class="share-search">
|
||||||
|
<KnowledgeSearch
|
||||||
|
:knowledge-id="knowledgeInfo.id"
|
||||||
|
:request-client="knowledgeShareApi"
|
||||||
|
:endpoint-prefix="endpointPrefix"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedCategory === 'config'" class="share-config">
|
||||||
|
<KnowledgeShareConfigPanel
|
||||||
|
:knowledge-id="String(knowledgeInfo.id || '')"
|
||||||
|
:detail-data="knowledgeInfo"
|
||||||
|
:request-client="knowledgeShareApi"
|
||||||
|
:endpoint-prefix="endpointPrefix"
|
||||||
|
@reload="loadKnowledge"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ImportKnowledgeDocFile
|
||||||
|
v-else
|
||||||
|
:request-client="knowledgeShareApi"
|
||||||
|
:endpoint-prefix="endpointPrefix"
|
||||||
|
:knowledge-id-prop="String(knowledgeInfo.id || '')"
|
||||||
|
@import-back="importVisible = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.share-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 32px 20px;
|
||||||
|
background:
|
||||||
|
radial-gradient(
|
||||||
|
circle at top left,
|
||||||
|
rgb(232 242 255 / 90%),
|
||||||
|
transparent 32%
|
||||||
|
),
|
||||||
|
linear-gradient(180deg, #f6f9fc 0%, #f2f5f9 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-shell {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-hero {
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-hero__main {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-hero__meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-hero__text {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-hero__icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-hero__title-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-hero__title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-hero__desc {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-tab {
|
||||||
|
padding: 10px 16px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-tab.is-active {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
background: rgb(64 158 255 / 8%);
|
||||||
|
border-color: rgb(64 158 255 / 18%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-body,
|
||||||
|
.share-panel,
|
||||||
|
.share-search,
|
||||||
|
.share-config {
|
||||||
|
min-height: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-panel__footer {
|
||||||
|
padding: 16px 20px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.share-page {
|
||||||
|
padding: 16px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-hero__main {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-hero__meta {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-hero__eyebrow {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-tabs {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-hero__title {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -74,6 +74,22 @@ const resolveHitSourceLabel = (hitSource?: HitSource) => {
|
|||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolveDisplayHitSource = (
|
||||||
|
hitSource: HitSource | undefined,
|
||||||
|
retrievalMode: RetrievalMode,
|
||||||
|
) => {
|
||||||
|
if (hitSource) {
|
||||||
|
return hitSource;
|
||||||
|
}
|
||||||
|
if (retrievalMode === 'VECTOR') {
|
||||||
|
return 'VECTOR' as HitSource;
|
||||||
|
}
|
||||||
|
if (retrievalMode === 'KEYWORD') {
|
||||||
|
return 'KEYWORD' as HitSource;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
const resolveHitSourceType = (hitSource?: HitSource) => {
|
const resolveHitSourceType = (hitSource?: HitSource) => {
|
||||||
if (hitSource === 'VECTOR') {
|
if (hitSource === 'VECTOR') {
|
||||||
return 'success';
|
return 'success';
|
||||||
@@ -130,15 +146,23 @@ defineExpose({
|
|||||||
</div>
|
</div>
|
||||||
<div class="content-desc">{{ item.content }}</div>
|
<div class="content-desc">{{ item.content }}</div>
|
||||||
<div
|
<div
|
||||||
v-if="retrievalMode === 'HYBRID' && item.hitSource"
|
v-if="resolveDisplayHitSource(item.hitSource, retrievalMode)"
|
||||||
class="hit-source-row"
|
class="hit-source-row"
|
||||||
>
|
>
|
||||||
<ElTag
|
<ElTag
|
||||||
size="small"
|
size="small"
|
||||||
effect="plain"
|
effect="plain"
|
||||||
:type="resolveHitSourceType(item.hitSource)"
|
:type="
|
||||||
|
resolveHitSourceType(
|
||||||
|
resolveDisplayHitSource(item.hitSource, retrievalMode),
|
||||||
|
)
|
||||||
|
"
|
||||||
>
|
>
|
||||||
{{ resolveHitSourceLabel(item.hitSource) }}
|
{{
|
||||||
|
resolveHitSourceLabel(
|
||||||
|
resolveDisplayHitSource(item.hitSource, retrievalMode),
|
||||||
|
)
|
||||||
|
}}
|
||||||
</ElTag>
|
</ElTag>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -172,8 +196,6 @@ defineExpose({
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.preview-container {
|
.preview-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
background-color: var(--el-bg-color);
|
background-color: var(--el-bg-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 2px 12px 0 rgb(0 0 0 / 8%);
|
box-shadow: 0 2px 12px 0 rgb(0 0 0 / 8%);
|
||||||
@@ -207,7 +229,6 @@ defineExpose({
|
|||||||
|
|
||||||
.preview-content {
|
.preview-content {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
overflow-y: auto;
|
|
||||||
|
|
||||||
.preview-list {
|
.preview-list {
|
||||||
.segment-badge {
|
.segment-badge {
|
||||||
@@ -257,6 +278,7 @@ defineExpose({
|
|||||||
|
|
||||||
.hit-source-row {
|
.hit-source-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-self: flex-end;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export function buildKnowledgePath(endpointPrefix = '', path = '') {
|
||||||
|
if (!endpointPrefix) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
const normalizedPrefix = endpointPrefix.endsWith('/')
|
||||||
|
? endpointPrefix.slice(0, -1)
|
||||||
|
: endpointPrefix;
|
||||||
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||||
|
if (normalizedPath.startsWith(`${normalizedPrefix}/`)) {
|
||||||
|
return normalizedPath;
|
||||||
|
}
|
||||||
|
if (normalizedPath.startsWith('/api/v1/')) {
|
||||||
|
return `${normalizedPrefix}${normalizedPath.slice('/api/v1'.length)}`;
|
||||||
|
}
|
||||||
|
return `${normalizedPrefix}${normalizedPath}`;
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ import { onMounted, ref } from 'vue';
|
|||||||
import { EasyFlowFormModal } from '@easyflow/common-ui';
|
import { EasyFlowFormModal } from '@easyflow/common-ui';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ElAlert,
|
|
||||||
ElCheckbox,
|
ElCheckbox,
|
||||||
ElCheckboxGroup,
|
ElCheckboxGroup,
|
||||||
ElDatePicker,
|
ElDatePicker,
|
||||||
@@ -33,6 +32,7 @@ interface Entity {
|
|||||||
deptId: number | string;
|
deptId: number | string;
|
||||||
expiredAt: Date | null | string;
|
expiredAt: Date | null | string;
|
||||||
permissionIds: (number | string)[]; // 绑定值:权限 ID 数组
|
permissionIds: (number | string)[]; // 绑定值:权限 ID 数组
|
||||||
|
knowledgeShareEnabled: boolean;
|
||||||
id?: number; // 编辑时的主键
|
id?: number; // 编辑时的主键
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +50,7 @@ const entity = ref<Entity>({
|
|||||||
deptId: '',
|
deptId: '',
|
||||||
expiredAt: null,
|
expiredAt: null,
|
||||||
permissionIds: [],
|
permissionIds: [],
|
||||||
|
knowledgeShareEnabled: false,
|
||||||
});
|
});
|
||||||
// 加载状态
|
// 加载状态
|
||||||
const btnLoading = ref(false);
|
const btnLoading = ref(false);
|
||||||
@@ -74,18 +75,30 @@ const rules = ref({
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
function openDialog(row: Partial<Entity> = {}) {
|
async function openDialog(row: Partial<Entity> = {}) {
|
||||||
saveForm.value?.resetFields();
|
saveForm.value?.resetFields();
|
||||||
entity.value = {
|
|
||||||
apiKey: '',
|
|
||||||
status: '',
|
|
||||||
deptId: '',
|
|
||||||
expiredAt: null,
|
|
||||||
permissionIds: [],
|
|
||||||
...row,
|
|
||||||
};
|
|
||||||
isAdd.value = !row.id;
|
isAdd.value = !row.id;
|
||||||
|
if (!row.id) {
|
||||||
|
entity.value = createDefaultEntity(row);
|
||||||
dialogVisible.value = true;
|
dialogVisible.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btnLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await api.get('/api/v1/sysApiKey/detail', {
|
||||||
|
params: { id: row.id },
|
||||||
|
});
|
||||||
|
if (res.errorCode !== 0) {
|
||||||
|
ElMessage.error(res.message || $t('message.getDataError'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entity.value = createDefaultEntity(res.data || row);
|
||||||
|
dialogVisible.value = true;
|
||||||
|
} catch {
|
||||||
|
ElMessage.error($t('message.getDataError'));
|
||||||
|
} finally {
|
||||||
|
btnLoading.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取资源权限列表
|
// 获取资源权限列表
|
||||||
@@ -104,6 +117,33 @@ function getResourcePermissionList() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createDefaultEntity(row: Partial<Entity> = {}): Entity {
|
||||||
|
const permissionIds = row.permissionIds || [];
|
||||||
|
const knowledgeShareEnabled = Boolean(row.knowledgeShareEnabled);
|
||||||
|
return {
|
||||||
|
apiKey: '',
|
||||||
|
status: '',
|
||||||
|
deptId: '',
|
||||||
|
expiredAt: null,
|
||||||
|
...row,
|
||||||
|
permissionIds,
|
||||||
|
knowledgeShareEnabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPermissionLabel(item: ResourcePermission) {
|
||||||
|
if (item.requestInterface === '/public-api/bot/chat') {
|
||||||
|
return '聊天助手调用';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
item.requestInterface === '/v1/chat/completions' ||
|
||||||
|
item.requestInterface === '/public-api/openai/v1/chat/completions'
|
||||||
|
) {
|
||||||
|
return '统一模型调用';
|
||||||
|
}
|
||||||
|
return item.title;
|
||||||
|
}
|
||||||
|
|
||||||
// 保存表单
|
// 保存表单
|
||||||
function save() {
|
function save() {
|
||||||
saveForm.value?.validate((valid) => {
|
saveForm.value?.validate((valid) => {
|
||||||
@@ -142,6 +182,7 @@ function closeDialog() {
|
|||||||
deptId: '',
|
deptId: '',
|
||||||
expiredAt: null,
|
expiredAt: null,
|
||||||
permissionIds: [],
|
permissionIds: [],
|
||||||
|
knowledgeShareEnabled: false,
|
||||||
};
|
};
|
||||||
isAdd.value = true;
|
isAdd.value = true;
|
||||||
dialogVisible.value = false;
|
dialogVisible.value = false;
|
||||||
@@ -200,10 +241,7 @@ defineExpose({
|
|||||||
:label="$t('sysApiKey.permissions')"
|
:label="$t('sysApiKey.permissions')"
|
||||||
class="permission-form-item"
|
class="permission-form-item"
|
||||||
>
|
>
|
||||||
<ElAlert type="info">
|
<div class="permission-section">
|
||||||
接口信息请运行:tech.easyflow.publicapi.SyncApis 的 main
|
|
||||||
方法同步到数据库。
|
|
||||||
</ElAlert>
|
|
||||||
<ElCheckboxGroup
|
<ElCheckboxGroup
|
||||||
v-model="entity.permissionIds"
|
v-model="entity.permissionIds"
|
||||||
class="permission-checkbox-group"
|
class="permission-checkbox-group"
|
||||||
@@ -214,9 +252,16 @@ defineExpose({
|
|||||||
:value="item.id"
|
:value="item.id"
|
||||||
class="permission-checkbox"
|
class="permission-checkbox"
|
||||||
>
|
>
|
||||||
{{ item.requestInterface }} - {{ item.title }}
|
{{ renderPermissionLabel(item) }}
|
||||||
</ElCheckbox>
|
</ElCheckbox>
|
||||||
</ElCheckboxGroup>
|
</ElCheckboxGroup>
|
||||||
|
<ElCheckbox
|
||||||
|
v-model="entity.knowledgeShareEnabled"
|
||||||
|
class="permission-checkbox"
|
||||||
|
>
|
||||||
|
{{ $t('sysApiKey.knowledgeSharePermission') }}
|
||||||
|
</ElCheckbox>
|
||||||
|
</div>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
</ElForm>
|
</ElForm>
|
||||||
</EasyFlowFormModal>
|
</EasyFlowFormModal>
|
||||||
@@ -230,15 +275,27 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.permission-form-item .el-form-item__content {
|
.permission-form-item .el-form-item__content {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-checkbox-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.permission-checkbox {
|
.permission-checkbox {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
margin: 4px 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-container::-webkit-scrollbar {
|
.form-container::-webkit-scrollbar {
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ export default defineConfig(async () => {
|
|||||||
target: 'http://127.0.0.1:8111',
|
target: 'http://127.0.0.1:8111',
|
||||||
ws: true,
|
ws: true,
|
||||||
},
|
},
|
||||||
|
'/flow/public-api': {
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/flow/, ''),
|
||||||
|
target: 'http://127.0.0.1:8111',
|
||||||
|
},
|
||||||
'/flow/userCenter': {
|
'/flow/userCenter': {
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (path) => path.replace(/^\/flow/, ''),
|
rewrite: (path) => path.replace(/^\/flow/, ''),
|
||||||
|
|||||||
Reference in New Issue
Block a user