feat: 完善知识库批量导入与公共 API

- 新增批量异步导入、状态查询、失败重试与中断恢复链路

- 拆分知识库读取、导入、维护权限并完善 Public API 契约

- 补充数据库迁移、管理端交互、接口说明与相关测试
This commit is contained in:
2026-08-03 11:13:48 +08:00
parent 6df3dd9981
commit 51dbfd41b6
99 changed files with 16274 additions and 480 deletions

View File

@@ -0,0 +1,326 @@
package tech.easyflow.publicapi.controller;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.Part;
import org.springframework.http.InvalidMediaTypeException;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
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.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.ai.documentimport.ImportCallerContext;
import tech.easyflow.ai.documentimport.ImportCallerType;
import tech.easyflow.ai.documentimport.PublicDocumentImportDtos;
import tech.easyflow.ai.documentimport.task.KnowledgeImportBatchFacade;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.enums.KnowledgeApiPermissionScope;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.ai.service.KnowledgeShareAuditService;
import tech.easyflow.ai.service.KnowledgeSharePermissionService;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.publicapi.interceptor.PublicApiInterceptor;
import tech.easyflow.system.entity.SysApiKey;
import tech.easyflow.system.service.SysApiKeyService;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 知识库文档 Public API 批量异步导入接口。
*
* @author Codex
* @since 2026-08-02
*/
@RestController
@RequestMapping(
value = "/public-api/knowledge-share/document/import/batch",
produces = MediaType.APPLICATION_JSON_VALUE
)
public class PublicKnowledgeDocumentImportController {
private static final long MAX_METADATA_BYTES = 1024L * 1024L;
private final SysApiKeyService sysApiKeyService;
private final KnowledgeSharePermissionService permissionService;
private final KnowledgeShareAuditService auditService;
private final DocumentCollectionService documentCollectionService;
private final KnowledgeImportBatchFacade importFacade;
/**
* 创建 Public API 批量导入控制器。
*
* @param sysApiKeyService 访问令牌服务
* @param permissionService 知识库权限服务
* @param auditService 审计服务
* @param documentCollectionService 知识库服务
* @param importFacade 批量导入门面
*/
public PublicKnowledgeDocumentImportController(
SysApiKeyService sysApiKeyService,
KnowledgeSharePermissionService permissionService,
KnowledgeShareAuditService auditService,
DocumentCollectionService documentCollectionService,
KnowledgeImportBatchFacade importFacade) {
this.sysApiKeyService = sysApiKeyService;
this.permissionService = permissionService;
this.auditService = auditService;
this.documentCollectionService = documentCollectionService;
this.importFacade = importFacade;
}
/**
* 接收多文件并创建异步导入任务。
*
* @param apiKey 访问令牌
* @param metadataPart JSON 元数据 Part
* @param files 多个文件 Part
* @param servletRequest Servlet 请求
* @return HTTP 202 异步任务响应
*/
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<Result<PublicDocumentImportDtos.SubmitResponse>> submit(
@RequestHeader("ApiKey") String apiKey,
@RequestPart("metadata") Part metadataPart,
@RequestPart("files") List<MultipartFile> files,
HttpServletRequest servletRequest) {
PublicDocumentImportDtos.BatchMetadata metadata =
parseMetadata(metadataPart);
SysApiKey token = resolveAuthenticatedApiKey(servletRequest, apiKey);
assertImportPermission(
token,
servletRequest.getRequestURI(),
metadata.getKnowledgeId()
);
requireDocumentKnowledge(metadata.getKnowledgeId());
ImportCallerContext caller =
new ImportCallerContext(ImportCallerType.PUBLIC_API, token.getId());
PublicDocumentImportDtos.SubmitResponse response =
importFacade.submit(caller, metadata, files);
audit(
token,
"API批量导入文档",
servletRequest.getRequestURI(),
Map.of(
"knowledgeId", metadata.getKnowledgeId(),
"taskId", response.getTaskId(),
"totalCount", response.getTotalCount()
)
);
return ResponseEntity.status(HttpStatus.ACCEPTED).body(Result.ok(response));
}
/**
* 查询异步导入任务状态。
*
* @param apiKey 访问令牌
* @param taskId 批次任务 ID
* @param itemStatus 可选文件状态
* @param pageNumber 页码
* @param pageSize 每页数量
* @param servletRequest Servlet 请求
* @return 任务状态
*/
@GetMapping("/status")
public Result<PublicDocumentImportDtos.StatusResponse> status(
@RequestHeader("ApiKey") String apiKey,
@RequestParam BigInteger taskId,
@RequestParam(required = false) String itemStatus,
@RequestParam(defaultValue = "1") long pageNumber,
@RequestParam(defaultValue = "20") long pageSize,
HttpServletRequest servletRequest) {
SysApiKey token = resolveAuthenticatedApiKey(servletRequest, apiKey);
ImportCallerContext caller =
new ImportCallerContext(ImportCallerType.PUBLIC_API, token.getId());
BigInteger knowledgeId =
importFacade.getOwnedKnowledgeId(caller, taskId);
assertImportPermission(token, servletRequest.getRequestURI(), knowledgeId);
PublicDocumentImportDtos.StatusResponse response =
importFacade.getStatus(
caller,
taskId,
itemStatus,
pageNumber,
pageSize
);
return Result.ok(response);
}
/**
* 对异常任务执行断点重试。
*
* @param apiKey 访问令牌
* @param request 重试请求
* @param servletRequest Servlet 请求
* @return 重试结果
*/
@PostMapping(value = "/retry", consumes = MediaType.APPLICATION_JSON_VALUE)
public Result<PublicDocumentImportDtos.RetryResponse> retry(
@RequestHeader("ApiKey") String apiKey,
@JsonBody PublicDocumentImportDtos.RetryRequest request,
HttpServletRequest servletRequest) {
SysApiKey token = resolveAuthenticatedApiKey(servletRequest, apiKey);
ImportCallerContext caller =
new ImportCallerContext(ImportCallerType.PUBLIC_API, token.getId());
BigInteger taskId = request == null ? null : request.getTaskId();
if (taskId == null) {
throw new BusinessException("taskId 不能为空");
}
BigInteger knowledgeId =
importFacade.getOwnedKnowledgeId(caller, taskId);
assertImportPermission(token, servletRequest.getRequestURI(), knowledgeId);
PublicDocumentImportDtos.RetryResponse response =
importFacade.retry(caller, request);
audit(
token,
"API重试批量导入任务",
servletRequest.getRequestURI(),
Map.of(
"knowledgeId", knowledgeId,
"taskId", response.getTaskId(),
"retriedCount", response.getRetriedCount()
)
);
return Result.ok(response);
}
/**
* 解析并校验 metadata JSON Part。
*
* @param metadataPart metadata Part
* @return 批量元数据
*/
private PublicDocumentImportDtos.BatchMetadata parseMetadata(
Part metadataPart) {
if (metadataPart == null || metadataPart.getSize() <= 0) {
throw new BusinessException("metadata 不能为空");
}
if (metadataPart.getSize() > MAX_METADATA_BYTES) {
throw new BusinessException(413, 41304, "metadata 不能超过1MiB");
}
String contentType = metadataPart.getContentType();
try {
if (contentType == null
|| !MediaType.APPLICATION_JSON.includes(
MediaType.parseMediaType(contentType))) {
throw new BusinessException(415, 41503,
"metadata Part 必须使用 application/json");
}
} catch (InvalidMediaTypeException error) {
throw new BusinessException(415, 41503,
"metadata Part Content-Type 无效", error);
}
try {
String json = new String(
metadataPart.getInputStream().readAllBytes(),
StandardCharsets.UTF_8
);
PublicDocumentImportDtos.BatchMetadata metadata =
JSON.parseObject(
json,
PublicDocumentImportDtos.BatchMetadata.class
);
if (metadata == null) {
throw new BusinessException("metadata 不能为空");
}
return metadata;
} catch (JSONException error) {
throw new BusinessException(400, 40021,
"metadata JSON 格式无效", error);
} catch (java.io.IOException error) {
throw new BusinessException(500, 50023,
"读取 metadata 失败", error);
}
}
/**
* 断言访问令牌具有知识导入权限。
*
* @param token 访问令牌
* @param requestUri 请求 URI
* @param knowledgeId 知识库 ID
*/
private void assertImportPermission(SysApiKey token,
String requestUri,
BigInteger knowledgeId) {
permissionService.assertApiShare(
token.getId(),
requestUri,
knowledgeId,
KnowledgeApiPermissionScope.KNOWLEDGE_IMPORT.name()
);
}
/**
* 复用拦截器已经完成认证的访问令牌。
*
* @param request Servlet 请求
* @param apiKey 访问令牌明文
* @return 已认证访问令牌
*/
private SysApiKey resolveAuthenticatedApiKey(
HttpServletRequest request,
String apiKey) {
Object authenticated =
request.getAttribute(
PublicApiInterceptor.AUTHENTICATED_API_KEY_ATTRIBUTE
);
if (authenticated instanceof SysApiKey token) {
return token;
}
// 兼容控制器单测和绕过 MVC 拦截器的内部直接调用。
return sysApiKeyService.getSysApiKey(apiKey);
}
/**
* 断言知识库存在且为文档类型。
*
* @param knowledgeId 知识库 ID
*/
private void requireDocumentKnowledge(BigInteger knowledgeId) {
DocumentCollection knowledge =
knowledgeId == null ? null : documentCollectionService.getById(knowledgeId);
if (knowledge == null) {
throw new BusinessException(404, 404, "知识库不存在");
}
if (!knowledge.isDocumentCollection()) {
throw new BusinessException("当前知识库类型不支持文档导入");
}
}
/**
* 记录不含令牌明文的 Public API 审计。
*
* @param token 访问令牌
* @param actionName 操作名称
* @param actionUrl 操作 URI
* @param detail 业务详情
*/
private void audit(SysApiKey token,
String actionName,
String actionUrl,
Map<String, Object> detail) {
Map<String, Object> payload = new HashMap<>(detail);
payload.put("apiKeyId", token.getId());
payload.put("channel", "API");
auditService.log(
null,
actionName,
"KNOWLEDGE_API_SHARE_WRITE",
actionUrl,
payload
);
}
}

View File

@@ -18,13 +18,12 @@ 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.enums.KnowledgeApiPermissionScope;
import tech.easyflow.ai.rag.KnowledgeRetrievalModes;
import tech.easyflow.ai.rag.KnowledgeRetrievalRequest;
import tech.easyflow.ai.service.DocumentChunkService;
@@ -42,6 +41,8 @@ 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.publicapi.dto.PublicKnowledgeDetailResponse;
import tech.easyflow.publicapi.dto.PublicKnowledgeSearchResultItem;
import tech.easyflow.system.entity.SysApiKey;
import tech.easyflow.system.service.SysApiKeyService;
@@ -89,28 +90,43 @@ public class PublicKnowledgeShareController {
* 获取知识库详情。
*/
@GetMapping("/detail")
public Result<DocumentCollection> detail(
public Result<PublicKnowledgeDetailResponse> detail(
@RequestHeader("ApiKey") String apiKey,
@RequestParam BigInteger knowledgeId,
@RequestParam(defaultValue = "1") int pageNumber,
@RequestParam(defaultValue = "50") int pageSize,
HttpServletRequest request
) {
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.VIEW.name());
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeApiPermissionScope.KNOWLEDGE_READ.name());
validateDocumentPage(pageNumber, pageSize);
DocumentCollection knowledge = documentCollectionService.getDetail(knowledgeId.toString());
if (knowledge == null) {
throw new BusinessException("知识库不存在");
}
Page<Document> documents = knowledge.isDocumentCollection()
? documentService.getDocumentList(
knowledgeId.toString(),
pageSize,
pageNumber,
null
)
: new Page<>(Collections.emptyList(), pageNumber, pageSize, 0L);
audit(apiKey, "API读取知识库详情", "KNOWLEDGE_API_SHARE_ACCESS", request.getRequestURI(), Map.of("knowledgeId", knowledgeId));
return Result.ok(documentCollectionService.getDetail(knowledgeId.toString()));
return Result.ok(new PublicKnowledgeDetailResponse(knowledge, documents));
}
/**
* 检索知识库。
*/
@GetMapping("/search")
public Result<List<KnowledgeSearchResultItem>> search(
public Result<List<PublicKnowledgeSearchResultItem>> 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());
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeApiPermissionScope.KNOWLEDGE_READ.name());
KnowledgeRetrievalRequest retrievalRequest = new KnowledgeRetrievalRequest();
retrievalRequest.setKnowledgeId(knowledgeId);
retrievalRequest.setQuery(keyword);
@@ -128,14 +144,19 @@ public class PublicKnowledgeShareController {
public Result<Page<Document>> documentPage(
@RequestHeader("ApiKey") String apiKey,
@RequestParam BigInteger knowledgeId,
@RequestParam(required = false) String title,
@RequestParam(required = false) BigInteger documentId,
@RequestParam(defaultValue = "10") int pageSize,
@RequestParam(defaultValue = "1") int pageNumber,
HttpServletRequest request
) {
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.VIEW.name());
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeApiPermissionScope.KNOWLEDGE_READ.name());
requireDocumentKnowledge(knowledgeId);
return Result.ok(documentService.getDocumentList(knowledgeId.toString(), pageSize, pageNumber, title));
return Result.ok(documentService.getDocumentListById(
knowledgeId.toString(),
pageSize,
pageNumber,
documentId
));
}
/**
@@ -149,7 +170,7 @@ public class PublicKnowledgeShareController {
HttpServletRequest request,
HttpServletResponse response
) throws Exception {
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.VIEW.name());
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeApiPermissionScope.KNOWLEDGE_READ.name());
requireDocumentKnowledge(knowledgeId);
Document document = requireDocument(documentId, knowledgeId);
response.setContentType("application/octet-stream");
@@ -169,11 +190,11 @@ public class PublicKnowledgeShareController {
@PostMapping("/document/remove")
public Result<?> removeDocument(
@RequestHeader("ApiKey") String apiKey,
@RequestParam BigInteger knowledgeId,
@JsonBody("id") String id,
@JsonBody(value = "knowledgeId", required = true) BigInteger knowledgeId,
@JsonBody(value = "id", required = true) String id,
HttpServletRequest request
) {
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.CONTENT_DELETE.name());
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeApiPermissionScope.KNOWLEDGE_MAINTENANCE.name());
requireDocumentKnowledge(knowledgeId);
requireDocument(new BigInteger(id), knowledgeId);
audit(apiKey, "API删除文档", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", knowledgeId, "documentId", id));
@@ -189,7 +210,7 @@ public class PublicKnowledgeShareController {
@JsonBody DocumentImportDtos.AnalyzeRequest request,
HttpServletRequest servletRequest
) {
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeShareActionScope.CONTENT_CREATE.name());
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeApiPermissionScope.KNOWLEDGE_IMPORT.name());
requireDocumentKnowledge(request.getKnowledgeId());
audit(apiKey, "API分析文档导入", "KNOWLEDGE_API_SHARE_WRITE", servletRequest.getRequestURI(), Map.of("knowledgeId", request.getKnowledgeId()));
return documentService.analyzeImport(request);
@@ -204,7 +225,7 @@ public class PublicKnowledgeShareController {
@JsonBody DocumentImportDtos.PreviewRequest request,
HttpServletRequest servletRequest
) {
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeShareActionScope.CONTENT_CREATE.name());
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeApiPermissionScope.KNOWLEDGE_IMPORT.name());
requireDocumentKnowledge(request.getKnowledgeId());
audit(apiKey, "API预览文档导入", "KNOWLEDGE_API_SHARE_WRITE", servletRequest.getRequestURI(), Map.of("knowledgeId", request.getKnowledgeId()));
return documentService.previewImport(request);
@@ -219,7 +240,7 @@ public class PublicKnowledgeShareController {
@JsonBody DocumentImportDtos.CommitRequest request,
HttpServletRequest servletRequest
) {
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeShareActionScope.CONTENT_CREATE.name());
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeApiPermissionScope.KNOWLEDGE_IMPORT.name());
requireDocumentKnowledge(request.getKnowledgeId());
audit(apiKey, "API提交文档导入", "KNOWLEDGE_API_SHARE_WRITE", servletRequest.getRequestURI(), Map.of("knowledgeId", request.getKnowledgeId()));
return documentService.commitImport(request);
@@ -231,7 +252,7 @@ public class PublicKnowledgeShareController {
@JsonBody DocumentImportDtos.TaskCreateRequest request,
HttpServletRequest servletRequest
) {
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeShareActionScope.CONTENT_CREATE.name());
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeApiPermissionScope.KNOWLEDGE_IMPORT.name());
requireDocumentKnowledge(request.getKnowledgeId());
audit(apiKey, "API创建文档导入任务", "KNOWLEDGE_API_SHARE_WRITE", servletRequest.getRequestURI(), Map.of("knowledgeId", request.getKnowledgeId()));
return documentService.createImportTask(request);
@@ -244,7 +265,7 @@ public class PublicKnowledgeShareController {
@RequestParam BigInteger taskId,
HttpServletRequest request
) {
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.VIEW.name());
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeApiPermissionScope.KNOWLEDGE_IMPORT.name());
requireDocumentKnowledge(knowledgeId);
Result<DocumentImportDtos.TaskDetailResponse> result = documentService.getImportTaskDetail(taskId);
if (result.getData() == null || result.getData().getKnowledgeId() == null
@@ -260,7 +281,7 @@ public class PublicKnowledgeShareController {
@JsonBody DocumentImportDtos.PreviewRequest request,
HttpServletRequest servletRequest
) {
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeShareActionScope.CONTENT_CREATE.name());
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeApiPermissionScope.KNOWLEDGE_IMPORT.name());
requireDocumentKnowledge(request.getKnowledgeId());
audit(apiKey, "API预览文档分块", "KNOWLEDGE_API_SHARE_WRITE", servletRequest.getRequestURI(), Map.of("knowledgeId", request.getKnowledgeId()));
return documentService.previewImportTask(request);
@@ -272,7 +293,7 @@ public class PublicKnowledgeShareController {
@JsonBody DocumentImportDtos.TaskStartIndexRequest request,
HttpServletRequest servletRequest
) {
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeShareActionScope.CONTENT_CREATE.name());
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeApiPermissionScope.KNOWLEDGE_IMPORT.name());
requireDocumentKnowledge(request.getKnowledgeId());
audit(apiKey, "API启动文档向量化", "KNOWLEDGE_API_SHARE_WRITE", servletRequest.getRequestURI(), Map.of("knowledgeId", request.getKnowledgeId()));
return documentService.startIndexTask(request);
@@ -284,7 +305,7 @@ public class PublicKnowledgeShareController {
@JsonBody DocumentImportDtos.TaskRetryRequest request,
HttpServletRequest servletRequest
) {
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeShareActionScope.CONTENT_CREATE.name());
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeApiPermissionScope.KNOWLEDGE_IMPORT.name());
requireDocumentKnowledge(request.getKnowledgeId());
audit(apiKey, "API重试文档解析", "KNOWLEDGE_API_SHARE_WRITE", servletRequest.getRequestURI(), Map.of("knowledgeId", request.getKnowledgeId()));
return documentService.retryParseTask(request);
@@ -296,7 +317,7 @@ public class PublicKnowledgeShareController {
@JsonBody DocumentImportDtos.TaskRetryRequest request,
HttpServletRequest servletRequest
) {
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeShareActionScope.CONTENT_CREATE.name());
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeApiPermissionScope.KNOWLEDGE_IMPORT.name());
requireDocumentKnowledge(request.getKnowledgeId());
audit(apiKey, "API重试文档向量化", "KNOWLEDGE_API_SHARE_WRITE", servletRequest.getRequestURI(), Map.of("knowledgeId", request.getKnowledgeId()));
return documentService.retryIndexTask(request);
@@ -314,7 +335,7 @@ public class PublicKnowledgeShareController {
@RequestParam(defaultValue = "10") long pageSize,
HttpServletRequest request
) {
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.VIEW.name());
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeApiPermissionScope.KNOWLEDGE_READ.name());
requireDocumentKnowledge(knowledgeId);
requireDocument(documentId, knowledgeId);
QueryWrapper wrapper = QueryWrapper.create()
@@ -329,11 +350,11 @@ public class PublicKnowledgeShareController {
@PostMapping("/documentChunk/update")
public Result<?> updateDocumentChunk(
@RequestHeader("ApiKey") String apiKey,
@RequestParam BigInteger knowledgeId,
@JsonBody(value = "knowledgeId", required = true) BigInteger knowledgeId,
@JsonBody DocumentChunk documentChunk,
HttpServletRequest request
) {
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.CONTENT_UPDATE.name());
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeApiPermissionScope.KNOWLEDGE_MAINTENANCE.name());
requireDocumentKnowledge(knowledgeId);
DocumentChunk current = requireDocumentChunk(documentChunk.getId(), knowledgeId);
boolean success = documentChunkService.updateById(documentChunk);
@@ -369,11 +390,11 @@ public class PublicKnowledgeShareController {
@PostMapping("/documentChunk/remove")
public Result<?> removeDocumentChunk(
@RequestHeader("ApiKey") String apiKey,
@RequestParam BigInteger knowledgeId,
@JsonBody("id") BigInteger chunkId,
@JsonBody(value = "knowledgeId", required = true) BigInteger knowledgeId,
@JsonBody(value = "id", required = true) BigInteger chunkId,
HttpServletRequest request
) {
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.CONTENT_DELETE.name());
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeApiPermissionScope.KNOWLEDGE_MAINTENANCE.name());
requireDocumentKnowledge(knowledgeId);
requireDocumentChunk(chunkId, knowledgeId);
DocumentCollection knowledge = documentCollectionService.getById(knowledgeId);
@@ -410,7 +431,7 @@ public class PublicKnowledgeShareController {
@RequestParam(defaultValue = "10") long pageSize,
HttpServletRequest request
) {
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.VIEW.name());
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeApiPermissionScope.KNOWLEDGE_READ.name());
requireFaqKnowledge(knowledgeId);
faqCategoryService.ensureDefaultCategory(knowledgeId);
QueryWrapper queryWrapper = QueryWrapper.create()
@@ -447,7 +468,7 @@ public class PublicKnowledgeShareController {
@RequestParam String id,
HttpServletRequest request
) {
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.VIEW.name());
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeApiPermissionScope.KNOWLEDGE_READ.name());
requireFaqKnowledge(knowledgeId);
FaqItem faqItem = requireFaq(new BigInteger(id), knowledgeId);
return Result.ok(faqItem);
@@ -462,7 +483,7 @@ public class PublicKnowledgeShareController {
@JsonBody FaqItem entity,
HttpServletRequest request
) {
assertApiShare(apiKey, request.getRequestURI(), entity.getCollectionId(), KnowledgeShareActionScope.CONTENT_CREATE.name());
assertApiShare(apiKey, request.getRequestURI(), entity.getCollectionId(), KnowledgeApiPermissionScope.KNOWLEDGE_MAINTENANCE.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));
@@ -478,7 +499,7 @@ public class PublicKnowledgeShareController {
@JsonBody FaqItem entity,
HttpServletRequest request
) {
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.CONTENT_UPDATE.name());
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeApiPermissionScope.KNOWLEDGE_MAINTENANCE.name());
requireFaqKnowledge(knowledgeId);
requireFaq(entity.getId(), knowledgeId);
audit(apiKey, "API更新FAQ", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", knowledgeId, "faqId", entity.getId()));
@@ -495,7 +516,7 @@ public class PublicKnowledgeShareController {
@JsonBody("id") BigInteger id,
HttpServletRequest request
) {
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.CONTENT_DELETE.name());
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeApiPermissionScope.KNOWLEDGE_MAINTENANCE.name());
requireFaqKnowledge(knowledgeId);
requireFaq(id, knowledgeId);
audit(apiKey, "API删除FAQ", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", knowledgeId, "faqId", id));
@@ -512,7 +533,7 @@ public class PublicKnowledgeShareController {
BigInteger collectionId,
HttpServletRequest request
) {
assertApiShare(apiKey, request.getRequestURI(), collectionId, KnowledgeShareActionScope.IMPORT_EXPORT.name());
assertApiShare(apiKey, request.getRequestURI(), collectionId, KnowledgeApiPermissionScope.KNOWLEDGE_IMPORT.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));
@@ -528,7 +549,7 @@ public class PublicKnowledgeShareController {
HttpServletRequest request,
HttpServletResponse response
) throws Exception {
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.IMPORT_EXPORT.name());
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeApiPermissionScope.KNOWLEDGE_IMPORT.name());
requireFaqKnowledge(knowledgeId);
response.setContentType("application/octet-stream");
response.setHeader(
@@ -550,7 +571,7 @@ public class PublicKnowledgeShareController {
HttpServletRequest request,
HttpServletResponse response
) throws Exception {
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.IMPORT_EXPORT.name());
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeApiPermissionScope.KNOWLEDGE_READ.name());
requireFaqKnowledge(knowledgeId);
String fileName = "faq_export_" + new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()) + ".xlsx";
response.setContentType("application/octet-stream");
@@ -610,6 +631,22 @@ public class PublicKnowledgeShareController {
return knowledge;
}
/**
* 校验详情接口中的文档分页参数。
*
* @param pageNumber 页码
* @param pageSize 每页条数
* @throws BusinessException 页码小于 1 或每页条数不在 1 到 100 之间时抛出
*/
private void validateDocumentPage(int pageNumber, int pageSize) {
if (pageNumber < 1) {
throw new BusinessException("pageNumber 必须大于等于 1");
}
if (pageSize < 1 || pageSize > 100) {
throw new BusinessException("pageSize 必须在 1 到 100 之间");
}
}
private Document requireDocument(BigInteger documentId, BigInteger knowledgeId) {
Document document = documentService.getById(documentId);
if (document == null || document.getCollectionId() == null || document.getCollectionId().compareTo(knowledgeId) != 0) {
@@ -642,15 +679,38 @@ public class PublicKnowledgeShareController {
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<>();
private List<PublicKnowledgeSearchResultItem> toKnowledgeSearchResult(
List<com.easyagents.core.document.Document> documents
) {
List<PublicKnowledgeSearchResultItem> result = new java.util.ArrayList<>();
for (com.easyagents.core.document.Document document : documents) {
KnowledgeSearchResultItem item = new KnowledgeSearchResultItem();
PublicKnowledgeSearchResultItem item =
new PublicKnowledgeSearchResultItem();
item.setContent(document.getContent());
String resultType =
asString(document.getMetadata("resultType"));
item.setResultType(resultType);
Object renderMarkdown = document.getMetadata("renderMarkdown");
item.setRenderMarkdown(renderMarkdown == null ? null : String.valueOf(renderMarkdown));
Object sourceFileName = document.getMetadata("sourceFileName");
item.setSourceFileName(sourceFileName == null ? null : String.valueOf(sourceFileName));
String documentName =
sourceFileName == null ? null : String.valueOf(sourceFileName);
item.setSourceFileName(documentName);
if (DocumentCollection.TYPE_FAQ.equalsIgnoreCase(resultType)) {
item.setFaqId(asBigInteger(document.getMetadata("faqId")));
item.setQuestion(asString(document.getMetadata("question")));
item.setAnswerText(
asString(document.getMetadata("answerText"))
);
item.setCategoryId(
asBigInteger(document.getMetadata("categoryId"))
);
} else {
item.setDocumentName(documentName);
item.setDocumentId(
asBigInteger(document.getMetadata("documentId"))
);
}
item.setScore(document.getScore());
Object hitSource = document.getMetadata("hitSource");
item.setHitSource(hitSource == null ? null : String.valueOf(hitSource));
@@ -661,6 +721,36 @@ public class PublicKnowledgeShareController {
return result;
}
/**
* 将检索元数据转换为字符串。
*
* @param value 元数据值
* @return 字符串值;原值为空时返回 {@code null}
*/
private String asString(Object value) {
return value == null ? null : String.valueOf(value);
}
/**
* 将检索元数据转换为大整数 ID。
*
* @param value 元数据值
* @return 大整数 ID原值为空或格式无效时返回 {@code null}
*/
private BigInteger asBigInteger(Object value) {
if (value == null) {
return null;
}
if (value instanceof BigInteger) {
return (BigInteger) value;
}
try {
return new BigInteger(String.valueOf(value));
} catch (NumberFormatException ignored) {
return null;
}
}
private Double asDouble(Object value) {
if (value == null) {
return null;

View File

@@ -0,0 +1,217 @@
package tech.easyflow.publicapi.dto;
import cn.hutool.core.bean.BeanUtil;
import com.mybatisflex.core.paginate.Page;
import tech.easyflow.ai.entity.Document;
import tech.easyflow.ai.entity.DocumentCollection;
import java.math.BigInteger;
import java.util.Collections;
import java.util.Date;
import java.util.List;
/**
* 公开知识库详情响应。
*
* <p>知识库原有字段保持在响应顶层,文档摘要通过 {@code documents} 分页返回。</p>
*/
public class PublicKnowledgeDetailResponse extends DocumentCollection {
/**
* 已上传文档的分页摘要。
*/
private final Page<DocumentSummary> documents;
/**
* 创建公开知识库详情响应。
*
* @param knowledge 知识库基本信息
* @param documentPage 文档实体分页FAQ 知识库可传 {@code null}
*/
public PublicKnowledgeDetailResponse(
DocumentCollection knowledge,
Page<Document> documentPage
) {
BeanUtil.copyProperties(knowledge, this);
this.documents = toDocumentSummaryPage(documentPage);
}
/**
* 获取文档分页摘要。
*
* @return 文档分页摘要
*/
public Page<DocumentSummary> getDocuments() {
return documents;
}
/**
* 将文档实体分页映射为公开摘要分页。
*
* @param source 文档实体分页
* @return 不包含内部路径、正文和配置的公开摘要分页
*/
private static Page<DocumentSummary> toDocumentSummaryPage(Page<Document> source) {
if (source == null) {
return new Page<>(Collections.emptyList(), 1, 50, 0L);
}
List<DocumentSummary> records = source.getRecords() == null
? Collections.emptyList()
: source.getRecords().stream().map(DocumentSummary::new).toList();
return new Page<>(
records,
source.getPageNumber(),
source.getPageSize(),
source.getTotalRow()
);
}
/**
* 公开文档摘要。
*/
public static class DocumentSummary {
/**
* 文档 ID。
*/
private final BigInteger id;
/**
* 文档标题。
*/
private final String title;
/**
* 文档类型。
*/
private final String documentType;
/**
* 文档内容类型。
*/
private final String contentType;
/**
* 文档处理状态。
*/
private final String processStatus;
/**
* 文档分块数。
*/
private final long chunkCount;
/**
* 处理进度百分比。
*/
private final Integer progressPercent;
/**
* 创建时间。
*/
private final Date created;
/**
* 最后修改时间。
*/
private final Date modified;
/**
* 从文档实体创建公开摘要。
*
* @param document 文档实体
*/
public DocumentSummary(Document document) {
this.id = document.getId();
this.title = document.getTitle();
this.documentType = document.getDocumentType();
this.contentType = document.getContentType();
this.processStatus = document.getProcessStatus();
this.chunkCount = document.getDisplayChunkCount();
this.progressPercent = document.getProgressPercent();
this.created = document.getCreated();
this.modified = document.getModified();
}
/**
* 获取文档 ID。
*
* @return 文档 ID
*/
public BigInteger getId() {
return id;
}
/**
* 获取文档标题。
*
* @return 文档标题
*/
public String getTitle() {
return title;
}
/**
* 获取文档类型。
*
* @return 文档类型
*/
public String getDocumentType() {
return documentType;
}
/**
* 获取文档内容类型。
*
* @return 文档内容类型
*/
public String getContentType() {
return contentType;
}
/**
* 获取文档处理状态。
*
* @return 文档处理状态
*/
public String getProcessStatus() {
return processStatus;
}
/**
* 获取文档分块数。
*
* @return 文档分块数
*/
public long getChunkCount() {
return chunkCount;
}
/**
* 获取处理进度百分比。
*
* @return 处理进度百分比
*/
public Integer getProgressPercent() {
return progressPercent;
}
/**
* 获取创建时间。
*
* @return 创建时间
*/
public Date getCreated() {
return created;
}
/**
* 获取最后修改时间。
*
* @return 最后修改时间
*/
public Date getModified() {
return modified;
}
}
}

View File

@@ -0,0 +1,176 @@
package tech.easyflow.publicapi.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import tech.easyflow.ai.dto.KnowledgeSearchResultItem;
import java.math.BigInteger;
/**
* 公开知识库检索结果。
*
* <p>根据命中来源补充文档或 FAQ 标识,便于调用方继续查询对应详情。</p>
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PublicKnowledgeSearchResultItem extends KnowledgeSearchResultItem {
/**
* 命中来源类型。
*/
private String resultType;
/**
* 来源文档 ID。
*/
private BigInteger documentId;
/**
* 来源文档名称。
*/
private String documentName;
/**
* 来源 FAQ ID。
*/
private BigInteger faqId;
/**
* 来源 FAQ 问题。
*/
private String question;
/**
* 来源 FAQ 纯文本答案。
*/
private String answerText;
/**
* 来源 FAQ 分类 ID。
*/
private BigInteger categoryId;
/**
* 获取命中来源类型。
*
* @return {@code DOCUMENT} 或 {@code FAQ}
*/
public String getResultType() {
return resultType;
}
/**
* 设置命中来源类型。
*
* @param resultType 命中来源类型
*/
public void setResultType(String resultType) {
this.resultType = resultType;
}
/**
* 获取来源文档 ID。
*
* @return 来源文档 ID
*/
public BigInteger getDocumentId() {
return documentId;
}
/**
* 设置来源文档 ID。
*
* @param documentId 来源文档 ID
*/
public void setDocumentId(BigInteger documentId) {
this.documentId = documentId;
}
/**
* 获取来源文档名称。
*
* @return 来源文档名称
*/
public String getDocumentName() {
return documentName;
}
/**
* 设置来源文档名称。
*
* @param documentName 来源文档名称
*/
public void setDocumentName(String documentName) {
this.documentName = documentName;
}
/**
* 获取来源 FAQ ID。
*
* @return 来源 FAQ ID
*/
public BigInteger getFaqId() {
return faqId;
}
/**
* 设置来源 FAQ ID。
*
* @param faqId 来源 FAQ ID
*/
public void setFaqId(BigInteger faqId) {
this.faqId = faqId;
}
/**
* 获取来源 FAQ 问题。
*
* @return 来源 FAQ 问题
*/
public String getQuestion() {
return question;
}
/**
* 设置来源 FAQ 问题。
*
* @param question 来源 FAQ 问题
*/
public void setQuestion(String question) {
this.question = question;
}
/**
* 获取来源 FAQ 纯文本答案。
*
* @return 来源 FAQ 纯文本答案
*/
public String getAnswerText() {
return answerText;
}
/**
* 设置来源 FAQ 纯文本答案。
*
* @param answerText 来源 FAQ 纯文本答案
*/
public void setAnswerText(String answerText) {
this.answerText = answerText;
}
/**
* 获取来源 FAQ 分类 ID。
*
* @return 来源 FAQ 分类 ID
*/
public BigInteger getCategoryId() {
return categoryId;
}
/**
* 设置来源 FAQ 分类 ID。
*
* @param categoryId 来源 FAQ 分类 ID
*/
public void setCategoryId(BigInteger categoryId) {
this.categoryId = categoryId;
}
}

View File

@@ -9,11 +9,21 @@ import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.util.ResponseUtil;
import tech.easyflow.system.entity.SysApiKey;
import tech.easyflow.system.service.SysApiKeyService;
/**
* Public API 访问令牌与接口权限拦截器。
*/
@Component
public class PublicApiInterceptor implements HandlerInterceptor {
/**
* 请求中已完成认证的访问令牌属性名,供后续资源级鉴权复用。
*/
public static final String AUTHENTICATED_API_KEY_ATTRIBUTE =
PublicApiInterceptor.class.getName() + ".authenticatedApiKey";
private static final Logger log = LoggerFactory.getLogger(PublicApiInterceptor.class);
@Resource
@@ -31,7 +41,12 @@ public class PublicApiInterceptor implements HandlerInterceptor {
ResponseUtil.renderJson(response, failed);
return false;
}
sysApiKeyService.checkApikeyPermission(apiKey, requestURI);
SysApiKey authenticatedApiKey =
sysApiKeyService.checkApikeyPermission(apiKey, requestURI);
request.setAttribute(
AUTHENTICATED_API_KEY_ATTRIBUTE,
authenticatedApiKey
);
return true;
}
}