feat: 完善知识库批量导入与公共 API
- 新增批量异步导入、状态查询、失败重试与中断恢复链路 - 拆分知识库读取、导入、维护权限并完善 Public API 契约 - 补充数据库迁移、管理端交互、接口说明与相关测试
This commit is contained in:
@@ -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 不能为空"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建接口代理。
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user