feat: 收口知识库分享链路

- 新增 shareKey 单参数 URL 分享页与失效页

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

- 在访问令牌中增加知识库分享授权入口
This commit is contained in:
2026-04-13 14:44:31 +08:00
parent 8cfe5400fe
commit 31a755a8bc
57 changed files with 5158 additions and 143 deletions

View File

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

View File

@@ -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;
}
}

View File

@@ -10,6 +10,7 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.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);
}
} }

View File

@@ -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));
}
}

View File

@@ -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());

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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;
}
}

View File

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

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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) {

View File

@@ -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;
}
} }

View File

@@ -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;
}
} }

View File

@@ -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);
} }

View File

@@ -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);
} }

View File

@@ -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);
}
} }

View File

@@ -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无权限访问当前资源");
}
}
} }

View File

@@ -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`;

View File

@@ -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`;

View 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);
}

View File

@@ -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"

View File

@@ -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"
} }

View File

@@ -12,6 +12,12 @@
"save": "保存配置" "save": "保存配置"
}, },
"engineHint": "关键词检索引擎由平台全局配置决定,知识库侧不再单独配置。", "engineHint": "关键词检索引擎由平台全局配置决定,知识库侧不再单独配置。",
"retrievalModeTitle": "召回方式说明",
"retrievalModeDescriptions": {
"hybrid": "同时结合向量语义与关键词匹配,适合默认场景,结果更均衡。",
"vector": "优先按语义相似度召回,适合问法与原文表达差异较大的查询。",
"keyword": "优先按字面词项匹配,适合术语、编号、专有名词等精确查找。"
},
"message": { "message": {
"saveSuccess": "配置保存成功", "saveSuccess": "配置保存成功",
"saveFailed": "配置保存失败" "saveFailed": "配置保存失败"

View File

@@ -14,5 +14,6 @@
"failure": "已失效" "failure": "已失效"
}, },
"permissions": "授权接口", "permissions": "授权接口",
"knowledgeSharePermission": "知识库分享授权",
"addApiKeyNotice": "该操作会生成一个apiKey,请确认是否生成" "addApiKeyNotice": "该操作会生成一个apiKey,请确认是否生成"
} }

View File

@@ -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;

View 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;

View File

@@ -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兜底路由组成
* 无需走权限验证(会一直显示在菜单中) */ * 无需走权限验证(会一直显示在菜单中) */

View File

@@ -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 }">

View File

@@ -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>

View File

@@ -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',

View File

@@ -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 },
{}, {},
); );

View 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'));

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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%;
} }

View File

@@ -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}`;
}

View File

@@ -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 {

View File

@@ -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/, ''),