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

View File

@@ -0,0 +1,64 @@
package tech.easyflow.publicapi.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.ai.documentimport.PublicDocumentImportDtos;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.publicapi.interceptor.PublicApiInterceptor;
import tech.easyflow.system.entity.SysApiKey;
import java.lang.reflect.Proxy;
import java.math.BigInteger;
/**
* {@link PublicKnowledgeDocumentImportController} 请求边界测试。
*
* @author Codex
* @since 2026-08-02
*/
public class PublicKnowledgeDocumentImportControllerTest {
/**
* 验证重试请求缺少 taskId 时返回明确参数错误。
*/
@Test
public void retryShouldRejectMissingTaskId() {
SysApiKey token = new SysApiKey();
token.setId(BigInteger.ONE);
HttpServletRequest servletRequest = (HttpServletRequest) Proxy.newProxyInstance(
HttpServletRequest.class.getClassLoader(),
new Class<?>[]{HttpServletRequest.class},
(instance, method, args) -> {
if ("getAttribute".equals(method.getName())) {
Assert.assertEquals(
PublicApiInterceptor.AUTHENTICATED_API_KEY_ATTRIBUTE,
args[0]
);
return token;
}
throw new AssertionError(
"测试路径不应调用 HttpServletRequest." + method.getName()
);
}
);
PublicKnowledgeDocumentImportController controller =
new PublicKnowledgeDocumentImportController(
null,
null,
null,
null,
null
);
PublicDocumentImportDtos.RetryRequest request =
new PublicDocumentImportDtos.RetryRequest();
try {
controller.retry("test-key", request, servletRequest);
Assert.fail("Expected missing taskId rejection");
} catch (BusinessException expected) {
Assert.assertEquals(400, expected.getHttpStatus());
Assert.assertTrue(expected.getMessage().contains("taskId 不能为空"));
}
}
}

View File

@@ -0,0 +1,192 @@
package tech.easyflow.publicapi.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import org.junit.Assert;
import org.junit.Test;
import org.springframework.web.bind.annotation.RequestParam;
import tech.easyflow.ai.entity.DocumentChunk;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.publicapi.dto.PublicKnowledgeSearchResultItem;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.math.BigInteger;
import java.util.List;
/**
* {@link PublicKnowledgeShareController} 公开接口参数契约测试。
*/
public class PublicKnowledgeShareControllerContractTest {
/**
* 验证文档删除接口的 knowledgeId 和 id 均从 JSON 请求体读取。
*
* @throws Exception 反射失败
*/
@Test
public void documentRemoveShouldReadIdentifiersFromJsonBody()
throws Exception {
Method method = PublicKnowledgeShareController.class.getDeclaredMethod(
"removeDocument",
String.class,
BigInteger.class,
String.class,
HttpServletRequest.class
);
Parameter knowledgeId = method.getParameters()[1];
Parameter documentId = method.getParameters()[2];
assertRequiredJsonField(knowledgeId, "knowledgeId");
assertRequiredJsonField(documentId, "id");
Assert.assertNull(knowledgeId.getAnnotation(RequestParam.class));
}
/**
* 验证文档分块更新接口从 JSON 请求体读取 knowledgeId。
*
* @throws Exception 反射失败
*/
@Test
public void documentChunkUpdateShouldReadKnowledgeIdFromJsonBody()
throws Exception {
Method method = PublicKnowledgeShareController.class.getDeclaredMethod(
"updateDocumentChunk",
String.class,
BigInteger.class,
DocumentChunk.class,
HttpServletRequest.class
);
Parameter knowledgeId = method.getParameters()[1];
Parameter chunk = method.getParameters()[2];
assertRequiredJsonField(knowledgeId, "knowledgeId");
Assert.assertNull(knowledgeId.getAnnotation(RequestParam.class));
JsonBody chunkBody = chunk.getAnnotation(JsonBody.class);
Assert.assertNotNull(chunkBody);
Assert.assertEquals("", chunkBody.value());
}
/**
* 验证文档分块删除接口从 JSON 请求体读取 knowledgeId 和 id。
*
* @throws Exception 反射失败
*/
@Test
public void documentChunkRemoveShouldReadIdentifiersFromJsonBody()
throws Exception {
Method method = PublicKnowledgeShareController.class.getDeclaredMethod(
"removeDocumentChunk",
String.class,
BigInteger.class,
BigInteger.class,
HttpServletRequest.class
);
Parameter knowledgeId = method.getParameters()[1];
Parameter chunkId = method.getParameters()[2];
assertRequiredJsonField(knowledgeId, "knowledgeId");
assertRequiredJsonField(chunkId, "id");
Assert.assertNull(knowledgeId.getAnnotation(RequestParam.class));
}
/**
* 验证文档检索结果包含来源文档信息且不混入 FAQ 字段。
*
* @throws Exception 测试依赖注入失败
*/
@Test
public void searchShouldExposeDocumentIdentity() throws Exception {
BigInteger documentId = BigInteger.valueOf(2002);
com.easyagents.core.document.Document hit =
com.easyagents.core.document.Document.of("命中文本");
hit.addMetadata("resultType", DocumentCollection.TYPE_DOCUMENT);
hit.addMetadata("documentId", documentId);
hit.addMetadata("sourceFileName", "manual.pdf");
List<PublicKnowledgeSearchResultItem> results =
mapSearchResult(hit);
Assert.assertEquals(1, results.size());
PublicKnowledgeSearchResultItem item = results.get(0);
Assert.assertEquals(DocumentCollection.TYPE_DOCUMENT, item.getResultType());
Assert.assertEquals(documentId, item.getDocumentId());
Assert.assertEquals("manual.pdf", item.getDocumentName());
Assert.assertEquals("manual.pdf", item.getSourceFileName());
Assert.assertNull(item.getFaqId());
Assert.assertNull(item.getQuestion());
String json = new ObjectMapper().writeValueAsString(item);
Assert.assertFalse(json.contains("\"faqId\""));
Assert.assertFalse(json.contains("\"question\""));
}
/**
* 验证 FAQ 检索结果包含 FAQ 信息且不混入文档字段。
*
* @throws Exception 测试依赖注入失败
*/
@Test
public void searchShouldExposeFaqIdentity() throws Exception {
BigInteger faqId = BigInteger.valueOf(3003);
BigInteger categoryId = BigInteger.valueOf(4004);
com.easyagents.core.document.Document hit =
com.easyagents.core.document.Document.of("FAQ 命中文本");
hit.addMetadata("resultType", DocumentCollection.TYPE_FAQ);
hit.addMetadata("faqId", faqId);
hit.addMetadata("question", "如何申请账号?");
hit.addMetadata("answerText", "请联系管理员。");
hit.addMetadata("categoryId", categoryId);
List<PublicKnowledgeSearchResultItem> results =
mapSearchResult(hit);
Assert.assertEquals(1, results.size());
PublicKnowledgeSearchResultItem item = results.get(0);
Assert.assertEquals(DocumentCollection.TYPE_FAQ, item.getResultType());
Assert.assertEquals(faqId, item.getFaqId());
Assert.assertEquals("如何申请账号?", item.getQuestion());
Assert.assertEquals("请联系管理员。", item.getAnswerText());
Assert.assertEquals(categoryId, item.getCategoryId());
Assert.assertNull(item.getDocumentId());
Assert.assertNull(item.getDocumentName());
String json = new ObjectMapper().writeValueAsString(item);
Assert.assertFalse(json.contains("\"documentId\""));
Assert.assertFalse(json.contains("\"documentName\""));
Assert.assertFalse(json.contains("\"sourceFileName\""));
}
/**
* 调用公开检索结果映射。
*
* @param hit 检索命中
* @return 公开检索结果
* @throws Exception 反射调用失败
*/
@SuppressWarnings("unchecked")
private List<PublicKnowledgeSearchResultItem> mapSearchResult(
com.easyagents.core.document.Document hit
) throws Exception {
Method method = PublicKnowledgeShareController.class
.getDeclaredMethod(
"toKnowledgeSearchResult",
List.class
);
method.setAccessible(true);
return (List<PublicKnowledgeSearchResultItem>) method.invoke(
new PublicKnowledgeShareController(),
List.of(hit)
);
}
/**
* 断言参数绑定到指定的必填 JSON 字段。
*
* @param parameter 方法参数
* @param field JSON 字段名
*/
private void assertRequiredJsonField(Parameter parameter, String field) {
JsonBody jsonBody = parameter.getAnnotation(JsonBody.class);
Assert.assertNotNull(jsonBody);
Assert.assertEquals(field, jsonBody.value());
Assert.assertTrue(jsonBody.required());
}
}

View File

@@ -0,0 +1,78 @@
package tech.easyflow.publicapi.dto;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.mybatisflex.core.paginate.Page;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.ai.entity.Document;
import tech.easyflow.ai.entity.DocumentCollection;
import java.math.BigInteger;
import java.util.Collections;
import java.util.List;
/**
* {@link PublicKnowledgeDetailResponse} 响应结构测试。
*/
public class PublicKnowledgeDetailResponseTest {
/**
* 验证知识库字段保持在顶层,文档只暴露公开摘要。
*
*/
@Test
public void shouldKeepKnowledgeFieldsAtTopLevelAndExposeDocumentSummary() {
DocumentCollection knowledge = new DocumentCollection();
knowledge.setId(BigInteger.valueOf(100));
knowledge.setTitle("测试知识库");
knowledge.setCollectionType(DocumentCollection.TYPE_DOCUMENT);
Document document = new Document();
document.setId(BigInteger.valueOf(200));
document.setTitle("manual.pdf");
document.setDocumentType("pdf");
document.setContentType("application/pdf");
document.setDocumentPath("private/path/manual.pdf");
document.setContent("内部正文");
document.setProcessStatus("INDEXED");
document.setTotalChunks(12);
document.setProgressPercent(100);
Page<Document> source = new Page<>(List.of(document), 1, 10, 1L);
PublicKnowledgeDetailResponse response =
new PublicKnowledgeDetailResponse(knowledge, source);
JSONObject json = JSON.parseObject(JSON.toJSONString(response));
Assert.assertEquals("100", json.getString("id"));
Assert.assertEquals("测试知识库", json.getString("title"));
Assert.assertFalse(json.containsKey("knowledge"));
JSONObject summary = json.getJSONObject("documents")
.getJSONArray("records")
.getJSONObject(0);
Assert.assertEquals("manual.pdf", summary.getString("title"));
Assert.assertEquals(12L, summary.getLongValue("chunkCount"));
Assert.assertFalse(summary.containsKey("documentPath"));
Assert.assertFalse(summary.containsKey("content"));
Assert.assertFalse(summary.containsKey("options"));
}
/**
* 验证空文档分页保留调用方请求的分页参数。
*/
@Test
public void shouldKeepRequestedPaginationForEmptyDocumentPage() {
DocumentCollection knowledge = new DocumentCollection();
knowledge.setId(BigInteger.valueOf(300));
knowledge.setCollectionType(DocumentCollection.TYPE_FAQ);
Page<Document> source = new Page<>(Collections.emptyList(), 3, 7, 0L);
PublicKnowledgeDetailResponse response =
new PublicKnowledgeDetailResponse(knowledge, source);
Assert.assertEquals(3L, response.getDocuments().getPageNumber());
Assert.assertEquals(7L, response.getDocuments().getPageSize());
Assert.assertEquals(0L, response.getDocuments().getTotalRow());
Assert.assertTrue(response.getDocuments().getRecords().isEmpty());
}
}

View File

@@ -4,11 +4,15 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.system.entity.SysApiKey;
import tech.easyflow.system.service.SysApiKeyService;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.lang.reflect.Proxy;
import java.lang.reflect.Field;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
/**
* {@link PublicApiInterceptor} 鉴权响应测试。
@@ -66,6 +70,65 @@ public class PublicApiInterceptorTest {
Assert.assertTrue(body.toString().contains("密钥不正确"));
}
/**
* 验证通过接口权限校验的访问令牌会写入请求,供资源级鉴权复用。
*
* @throws Exception 拦截器处理失败时抛出
*/
@Test
public void shouldExposeAuthenticatedApiKeyToController() throws Exception {
SysApiKey authenticated = new SysApiKey();
AtomicReference<Object> requestAttribute = new AtomicReference<>();
HttpServletRequest request = proxy(
HttpServletRequest.class,
(instance, method, args) -> {
if ("getRequestURI".equals(method.getName())) {
return "/public-api/knowledge-share/detail";
}
if ("getHeader".equals(method.getName())) {
return "test-key";
}
if ("setAttribute".equals(method.getName())) {
Assert.assertEquals(
PublicApiInterceptor.AUTHENTICATED_API_KEY_ATTRIBUTE,
args[0]);
requestAttribute.set(args[1]);
return null;
}
throw new AssertionError(
"测试路径不应调用 HttpServletRequest."
+ method.getName());
});
HttpServletResponse response = proxy(
HttpServletResponse.class,
(instance, method, args) -> {
throw new AssertionError(
"测试路径不应调用 HttpServletResponse."
+ method.getName());
});
SysApiKeyService service = proxy(
SysApiKeyService.class,
(instance, method, args) -> {
if ("checkApikeyPermission".equals(method.getName())) {
return authenticated;
}
throw new AssertionError(
"测试路径不应调用 SysApiKeyService."
+ method.getName());
});
PublicApiInterceptor interceptor = new PublicApiInterceptor();
Field serviceField = PublicApiInterceptor.class
.getDeclaredField("sysApiKeyService");
serviceField.setAccessible(true);
serviceField.set(interceptor, service);
boolean allowed =
interceptor.preHandle(request, response, new Object());
Assert.assertTrue(allowed);
Assert.assertSame(authenticated, requestAttribute.get());
}
/**
* 创建接口代理。
*