diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/DocumentController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/DocumentController.java
index 694ca72f..2d81c131 100644
--- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/DocumentController.java
+++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/DocumentController.java
@@ -11,8 +11,11 @@ import org.springframework.core.io.ClassPathResource;
import org.springframework.http.MediaType;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+import tech.easyflow.ai.documentimport.DocumentImportBatchDtos;
import tech.easyflow.ai.documentimport.DocumentImportDtos;
+import tech.easyflow.ai.documentimport.task.DocumentImportBatchAppService;
import tech.easyflow.ai.documentimport.task.DocumentImportTaskStatusStreamService;
import tech.easyflow.ai.entity.Document;
import tech.easyflow.ai.entity.DocumentCollection;
@@ -83,6 +86,9 @@ public class DocumentController extends BaseCurdController createImportBatch(
+ @JsonBody DocumentImportBatchDtos.CreateRequest request) {
+ if (request == null || request.getKnowledgeId() == null) {
+ throw new BusinessException("知识库id不能为空");
+ }
+ getDocumentCollection(request.getKnowledgeId().toString(), ResourceAction.MANAGE, "无权限管理知识库");
+ return Result.ok(documentImportBatchAppService.createBatch(request));
+ }
+
+ /**
+ * 上传一个批次文件。
+ *
+ * @param batchId 批次 ID
+ * @param itemId 文件项 ID
+ * @param knowledgeId 知识库 ID
+ * @param file 上传文件
+ * @return 文件项状态
+ */
+ @PostMapping(value = "import/batch/{batchId}/item/{itemId}/upload",
+ consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ @SaCheckPermission("/api/v1/documentCollection/save")
+ public Result uploadImportBatchItem(
+ @PathVariable BigInteger batchId,
+ @PathVariable BigInteger itemId,
+ @RequestParam BigInteger knowledgeId,
+ @RequestPart("file") MultipartFile file) {
+ getDocumentCollection(knowledgeId.toString(), ResourceAction.MANAGE, "无权限管理知识库");
+ return Result.ok(documentImportBatchAppService.uploadItem(
+ knowledgeId, batchId, itemId, file));
+ }
+
+ /**
+ * 启动手动或自动批量导入。
+ *
+ * @param request 启动请求
+ * @return 批次状态
+ */
+ @PostMapping("import/batch/start")
+ @SaCheckPermission("/api/v1/documentCollection/save")
+ public Result startImportBatch(
+ @JsonBody DocumentImportBatchDtos.StartRequest request) {
+ if (request == null || request.getKnowledgeId() == null) {
+ throw new BusinessException("知识库id不能为空");
+ }
+ getDocumentCollection(request.getKnowledgeId().toString(), ResourceAction.MANAGE, "无权限管理知识库");
+ return Result.ok(documentImportBatchAppService.startBatch(request));
+ }
+
+ /**
+ * 取消一个尚未启动的上传批次。
+ *
+ * @param knowledgeId 知识库 ID
+ * @param batchId 批次 ID
+ * @return 空结果
+ */
+ @PostMapping("import/batch/cancel")
+ @SaCheckPermission("/api/v1/documentCollection/save")
+ public Result cancelImportBatch(
+ @JsonBody(value = "knowledgeId", required = true) BigInteger knowledgeId,
+ @JsonBody(value = "batchId", required = true) BigInteger batchId) {
+ getDocumentCollection(knowledgeId.toString(), ResourceAction.MANAGE, "无权限管理知识库");
+ documentImportBatchAppService.cancelBatch(knowledgeId, batchId);
+ return Result.ok();
+ }
+
+ /**
+ * 查询批次状态。
+ *
+ * @param knowledgeId 知识库 ID
+ * @param batchId 批次 ID
+ * @return 批次状态
+ */
+ @GetMapping("import/batch/status")
+ @SaCheckPermission("/api/v1/documentCollection/query")
+ public Result getImportBatchStatus(
+ @RequestParam BigInteger knowledgeId,
+ @RequestParam BigInteger batchId) {
+ getDocumentCollection(knowledgeId.toString(), ResourceAction.READ, "无权限访问知识库");
+ return Result.ok(documentImportBatchAppService.getBatchStatus(knowledgeId, batchId));
+ }
+
+ /**
+ * 查询知识库最近一个自动导入批次。
+ *
+ * @param knowledgeId 知识库 ID
+ * @return 最近批次状态
+ */
+ @GetMapping("import/batch/current")
+ @SaCheckPermission("/api/v1/documentCollection/query")
+ public Result getCurrentImportBatch(
+ @RequestParam BigInteger knowledgeId) {
+ getDocumentCollection(knowledgeId.toString(), ResourceAction.READ, "无权限访问知识库");
+ return Result.ok(documentImportBatchAppService.getLatestAutoBatch(knowledgeId));
+ }
+
+ /**
+ * 继续中断或部分失败的自动导入批次。
+ *
+ * @param knowledgeId 知识库 ID
+ * @param batchId 批次 ID
+ * @return 继续后的批次状态
+ */
+ @PostMapping("import/batch/continue")
+ @SaCheckPermission("/api/v1/documentCollection/save")
+ public Result continueImportBatch(
+ @JsonBody(value = "knowledgeId", required = true) BigInteger knowledgeId,
+ @JsonBody(value = "batchId", required = true) BigInteger batchId) {
+ getDocumentCollection(knowledgeId.toString(), ResourceAction.MANAGE, "无权限管理知识库");
+ return Result.ok(documentImportBatchAppService.continueBatch(knowledgeId, batchId));
+ }
+
+ /**
+ * 根据解析、分块或向量化失败阶段统一重试。
+ *
+ * @param request 重试请求
+ * @return 重试任务状态
+ */
+ @PostMapping("import/task/retry")
+ @SaCheckPermission("/api/v1/documentCollection/save")
+ public Result retryImportTask(
+ @JsonBody DocumentImportDtos.TaskRetryRequest request) {
+ if (request == null || request.getKnowledgeId() == null || request.getDocumentId() == null) {
+ throw new BusinessException("重试信息不完整");
+ }
+ getDocumentCollection(request.getKnowledgeId().toString(), ResourceAction.MANAGE, "无权限管理知识库");
+ return documentService.retryFailedTask(request);
+ }
+
/**
* 更新 entity
*
diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysApiKeyController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysApiKeyController.java
index 6f9fa8e8..3042b4b6 100644
--- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysApiKeyController.java
+++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysApiKeyController.java
@@ -6,18 +6,21 @@ import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.core.table.TableInfo;
import com.mybatisflex.core.table.TableInfoFactory;
import jakarta.servlet.http.HttpServletRequest;
+import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.ai.service.KnowledgeSharePermissionService;
import tech.easyflow.ai.service.WorkflowApiPermissionService;
+import tech.easyflow.ai.enums.KnowledgeApiPermissionScope;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.util.IdUtil;
import tech.easyflow.common.vo.PkVo;
import tech.easyflow.common.web.controller.BaseCurdController;
+import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.system.entity.SysApiKey;
import tech.easyflow.system.entity.SysApiKeyResourceMapping;
import tech.easyflow.system.service.SysApiKeyResourceMappingService;
@@ -29,6 +32,7 @@ import java.time.LocalDate;
import java.time.ZoneId;
import java.util.Date;
import java.util.List;
+import java.util.Set;
/**
* 控制层。
@@ -83,12 +87,57 @@ public class SysApiKeyController extends BaseCurdController权限开关不映射数据库列,权限更新请求可能只包含主键与权限字段。
+ * 此时跳过主表更新,避免 MyBatis-Flex 生成空的 {@code SET} 子句。
+ *
+ * @param entity 待更新的访问令牌
+ * @return 更新结果
+ */
+ @Override
+ @PostMapping("/update")
+ @Transactional(rollbackFor = Exception.class)
+ public Result> update(@JsonBody SysApiKey entity) {
+ if (entity == null || entity.getId() == null) {
+ return Result.fail("访问令牌 ID 不能为空");
+ }
+ if (!hasPersistentUpdateFields(entity) && !hasPermissionUpdateFields(entity)) {
+ return Result.fail("没有可更新的访问令牌字段");
+ }
+ if (hasNewKnowledgePermissionFields(entity)
+ && !hasCompleteKnowledgePermissionFields(entity)) {
+ return Result.fail("知识库读取、导入、维护权限必须同时提交");
+ }
+ if (service.getById(entity.getId()) == null) {
+ return Result.fail("访问令牌不存在");
+ }
+ Result> beforeResult = onSaveOrUpdateBefore(entity, false);
+ if (beforeResult != null) {
+ return beforeResult;
+ }
+ if (hasPersistentUpdateFields(entity)) {
+ service.updateById(entity);
+ }
+ onSaveOrUpdateAfter(entity, false);
+ return Result.ok();
+ }
+
@Override
protected void onSaveOrUpdateAfter(SysApiKey entity, boolean isSave) {
if (entity.getPermissionIds() != null) {
sysApiKeyResourceMappingService.authInterface(entity);
}
- if (entity.getKnowledgeShareEnabled() != null) {
+ if (hasNewKnowledgePermissionFields(entity)) {
+ knowledgeSharePermissionService.replaceApiPermissions(
+ entity.getId(),
+ Boolean.TRUE.equals(entity.getKnowledgeReadEnabled()),
+ Boolean.TRUE.equals(entity.getKnowledgeImportEnabled()),
+ Boolean.TRUE.equals(entity.getKnowledgeMaintenanceEnabled())
+ );
+ } else if (entity.getKnowledgeShareEnabled() != null) {
+ // 兼容旧客户端:开启旧总开关只授予读取和导入,维护权限保持关闭。
knowledgeSharePermissionService.replaceApiShareEnabled(entity.getId(), entity.getKnowledgeShareEnabled());
}
if (entity.getWorkflowApiEnabled() != null) {
@@ -130,11 +179,18 @@ public class SysApiKeyController extends BaseCurdController resourceIds = sysApiKeyResourceMappingService.listAs(interfaceWrapper, BigInteger.class);
entity.setPermissionIds(resourceIds);
- QueryWrapper knowledgeWrapper = QueryWrapper.create()
- .select(SysApiKeyResourceMapping::getId)
- .eq(SysApiKeyResourceMapping::getApiKeyId, entity.getId())
- .eq(SysApiKeyResourceMapping::getResourceType, "KNOWLEDGE");
- entity.setKnowledgeShareEnabled(sysApiKeyResourceMappingService.count(knowledgeWrapper) > 0);
+ Set knowledgeScopes =
+ knowledgeSharePermissionService.getApiPermissionScopes(entity.getId());
+ boolean readEnabled =
+ knowledgeScopes.contains(KnowledgeApiPermissionScope.KNOWLEDGE_READ.name());
+ boolean importEnabled =
+ knowledgeScopes.contains(KnowledgeApiPermissionScope.KNOWLEDGE_IMPORT.name());
+ boolean maintenanceEnabled =
+ knowledgeScopes.contains(KnowledgeApiPermissionScope.KNOWLEDGE_MAINTENANCE.name());
+ entity.setKnowledgeReadEnabled(readEnabled);
+ entity.setKnowledgeImportEnabled(importEnabled);
+ entity.setKnowledgeMaintenanceEnabled(maintenanceEnabled);
+ entity.setKnowledgeShareEnabled(readEnabled || importEnabled || maintenanceEnabled);
QueryWrapper workflowWrapper = QueryWrapper.create()
.select(SysApiKeyResourceMapping::getId)
@@ -142,4 +198,57 @@ public class SysApiKeyController extends BaseCurdController 0);
}
+
+ /**
+ * 判断请求是否提交了任一新版知识库权限字段。
+ *
+ * @param entity 访问令牌
+ * @return 是否提交新版字段
+ */
+ private boolean hasNewKnowledgePermissionFields(SysApiKey entity) {
+ return entity.getKnowledgeReadEnabled() != null
+ || entity.getKnowledgeImportEnabled() != null
+ || entity.getKnowledgeMaintenanceEnabled() != null;
+ }
+
+ /**
+ * 判断请求是否完整提交三个新版知识库权限字段。
+ *
+ * @param entity 访问令牌
+ * @return 三个字段是否均已提交
+ */
+ private boolean hasCompleteKnowledgePermissionFields(SysApiKey entity) {
+ return entity.getKnowledgeReadEnabled() != null
+ && entity.getKnowledgeImportEnabled() != null
+ && entity.getKnowledgeMaintenanceEnabled() != null;
+ }
+
+ /**
+ * 判断请求是否包含主表可持久化字段。
+ *
+ * @param entity 访问令牌
+ * @return 是否需要更新访问令牌主表
+ */
+ private boolean hasPersistentUpdateFields(SysApiKey entity) {
+ return entity.getApiKey() != null
+ || entity.getCreated() != null
+ || entity.getStatus() != null
+ || entity.getDeptId() != null
+ || entity.getTenantId() != null
+ || entity.getExpiredAt() != null
+ || entity.getCreatedBy() != null;
+ }
+
+ /**
+ * 判断请求是否包含任一非主表权限字段。
+ *
+ * @param entity 访问令牌
+ * @return 是否需要更新权限映射
+ */
+ private boolean hasPermissionUpdateFields(SysApiKey entity) {
+ return entity.getPermissionIds() != null
+ || entity.getKnowledgeShareEnabled() != null
+ || hasNewKnowledgePermissionFields(entity)
+ || entity.getWorkflowApiEnabled() != null;
+ }
}
diff --git a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/system/SysApiKeyControllerTest.java b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/system/SysApiKeyControllerTest.java
new file mode 100644
index 00000000..ed1b0777
--- /dev/null
+++ b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/system/SysApiKeyControllerTest.java
@@ -0,0 +1,142 @@
+package tech.easyflow.admin.controller.system;
+
+import org.testng.annotations.Test;
+import tech.easyflow.ai.service.KnowledgeSharePermissionService;
+import tech.easyflow.ai.service.WorkflowApiPermissionService;
+import tech.easyflow.common.domain.Result;
+import tech.easyflow.system.entity.SysApiKey;
+import tech.easyflow.system.service.SysApiKeyResourceMappingService;
+import tech.easyflow.system.service.SysApiKeyService;
+
+import java.lang.reflect.Field;
+import java.math.BigInteger;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNotEquals;
+
+/**
+ * {@link SysApiKeyController} 更新访问令牌测试。
+ */
+public class SysApiKeyControllerTest {
+
+ /**
+ * 验证只更新权限时不会执行缺少主表更新字段的 SQL。
+ */
+ @Test
+ public void updateShouldSkipMainTableForPermissionOnlyRequest() {
+ BigInteger apiKeyId = BigInteger.valueOf(100);
+ SysApiKeyService apiKeyService = mock(SysApiKeyService.class);
+ KnowledgeSharePermissionService knowledgePermissionService =
+ mock(KnowledgeSharePermissionService.class);
+ SysApiKeyController controller = controller(apiKeyService, knowledgePermissionService);
+ SysApiKey existing = new SysApiKey();
+ existing.setId(apiKeyId);
+ when(apiKeyService.getById(apiKeyId)).thenReturn(existing);
+
+ SysApiKey request = new SysApiKey();
+ request.setId(apiKeyId);
+ request.setKnowledgeReadEnabled(true);
+ request.setKnowledgeImportEnabled(false);
+ request.setKnowledgeMaintenanceEnabled(false);
+
+ Result> result = controller.update(request);
+
+ assertEquals(result.getErrorCode(), 0);
+ verify(apiKeyService, never()).updateById(request);
+ verify(knowledgePermissionService).replaceApiPermissions(apiKeyId, true, false, false);
+ }
+
+ /**
+ * 验证基础字段与权限同时更新时,两类数据都被保存。
+ */
+ @Test
+ public void updateShouldPersistMainTableAndPermissionsTogether() {
+ BigInteger apiKeyId = BigInteger.valueOf(101);
+ SysApiKeyService apiKeyService = mock(SysApiKeyService.class);
+ KnowledgeSharePermissionService knowledgePermissionService =
+ mock(KnowledgeSharePermissionService.class);
+ SysApiKeyController controller = controller(apiKeyService, knowledgePermissionService);
+ SysApiKey existing = new SysApiKey();
+ existing.setId(apiKeyId);
+ when(apiKeyService.getById(apiKeyId)).thenReturn(existing);
+
+ SysApiKey request = new SysApiKey();
+ request.setId(apiKeyId);
+ request.setStatus(1);
+ request.setKnowledgeReadEnabled(true);
+ request.setKnowledgeImportEnabled(true);
+ request.setKnowledgeMaintenanceEnabled(true);
+
+ Result> result = controller.update(request);
+
+ assertEquals(result.getErrorCode(), 0);
+ verify(apiKeyService).updateById(request);
+ verify(knowledgePermissionService).replaceApiPermissions(apiKeyId, true, true, true);
+ }
+
+ /**
+ * 验证新版知识库权限缺少字段时拒绝更新,避免遗漏字段被隐式关闭。
+ */
+ @Test
+ public void updateShouldRejectPartialKnowledgePermissions() {
+ SysApiKeyService apiKeyService = mock(SysApiKeyService.class);
+ KnowledgeSharePermissionService knowledgePermissionService =
+ mock(KnowledgeSharePermissionService.class);
+ SysApiKeyController controller = controller(apiKeyService, knowledgePermissionService);
+ SysApiKey request = new SysApiKey();
+ request.setId(BigInteger.valueOf(102));
+ request.setKnowledgeReadEnabled(true);
+
+ Result> result = controller.update(request);
+
+ assertNotEquals(result.getErrorCode(), 0);
+ verifyNoInteractions(apiKeyService, knowledgePermissionService);
+ }
+
+ /**
+ * 创建注入模拟依赖的控制器。
+ *
+ * @param apiKeyService 访问令牌服务
+ * @param knowledgePermissionService 知识库权限服务
+ * @return 测试控制器
+ */
+ private SysApiKeyController controller(
+ SysApiKeyService apiKeyService,
+ KnowledgeSharePermissionService knowledgePermissionService
+ ) {
+ SysApiKeyController controller = new SysApiKeyController(apiKeyService);
+ setField(controller, "sysApiKeyResourceMappingService", mock(SysApiKeyResourceMappingService.class));
+ setField(controller, "knowledgeSharePermissionService", knowledgePermissionService);
+ setField(controller, "workflowApiPermissionService", mock(WorkflowApiPermissionService.class));
+ return controller;
+ }
+
+ /**
+ * 通过反射注入控制器字段。
+ *
+ * @param target 目标对象
+ * @param fieldName 字段名
+ * @param value 字段值
+ */
+ private void setField(Object target, String fieldName, Object value) {
+ Class> current = target.getClass();
+ while (current != null) {
+ try {
+ Field field = current.getDeclaredField(fieldName);
+ field.setAccessible(true);
+ field.set(target, value);
+ return;
+ } catch (NoSuchFieldException ignored) {
+ current = current.getSuperclass();
+ } catch (IllegalAccessException e) {
+ throw new IllegalStateException("设置测试字段失败: " + fieldName, e);
+ }
+ }
+ throw new IllegalArgumentException("未找到字段: " + fieldName);
+ }
+}
diff --git a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/controller/PublicKnowledgeDocumentImportController.java b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/controller/PublicKnowledgeDocumentImportController.java
new file mode 100644
index 00000000..b8cfa521
--- /dev/null
+++ b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/controller/PublicKnowledgeDocumentImportController.java
@@ -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> submit(
+ @RequestHeader("ApiKey") String apiKey,
+ @RequestPart("metadata") Part metadataPart,
+ @RequestPart("files") List 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 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 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 detail) {
+ Map payload = new HashMap<>(detail);
+ payload.put("apiKeyId", token.getId());
+ payload.put("channel", "API");
+ auditService.log(
+ null,
+ actionName,
+ "KNOWLEDGE_API_SHARE_WRITE",
+ actionUrl,
+ payload
+ );
+ }
+}
diff --git a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/controller/PublicKnowledgeShareController.java b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/controller/PublicKnowledgeShareController.java
index 2ebd87b5..a459034e 100644
--- a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/controller/PublicKnowledgeShareController.java
+++ b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/controller/PublicKnowledgeShareController.java
@@ -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 detail(
+ public Result 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 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> search(
+ public Result> 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> 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 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 toKnowledgeSearchResult(List documents) {
- List result = new java.util.ArrayList<>();
+ private List toKnowledgeSearchResult(
+ List documents
+ ) {
+ List 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;
diff --git a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/dto/PublicKnowledgeDetailResponse.java b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/dto/PublicKnowledgeDetailResponse.java
new file mode 100644
index 00000000..dee4a6c1
--- /dev/null
+++ b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/dto/PublicKnowledgeDetailResponse.java
@@ -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;
+
+/**
+ * 公开知识库详情响应。
+ *
+ * 知识库原有字段保持在响应顶层,文档摘要通过 {@code documents} 分页返回。
+ */
+public class PublicKnowledgeDetailResponse extends DocumentCollection {
+
+ /**
+ * 已上传文档的分页摘要。
+ */
+ private final Page documents;
+
+ /**
+ * 创建公开知识库详情响应。
+ *
+ * @param knowledge 知识库基本信息
+ * @param documentPage 文档实体分页;FAQ 知识库可传 {@code null}
+ */
+ public PublicKnowledgeDetailResponse(
+ DocumentCollection knowledge,
+ Page documentPage
+ ) {
+ BeanUtil.copyProperties(knowledge, this);
+ this.documents = toDocumentSummaryPage(documentPage);
+ }
+
+ /**
+ * 获取文档分页摘要。
+ *
+ * @return 文档分页摘要
+ */
+ public Page getDocuments() {
+ return documents;
+ }
+
+ /**
+ * 将文档实体分页映射为公开摘要分页。
+ *
+ * @param source 文档实体分页
+ * @return 不包含内部路径、正文和配置的公开摘要分页
+ */
+ private static Page toDocumentSummaryPage(Page source) {
+ if (source == null) {
+ return new Page<>(Collections.emptyList(), 1, 50, 0L);
+ }
+ List 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;
+ }
+ }
+}
diff --git a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/dto/PublicKnowledgeSearchResultItem.java b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/dto/PublicKnowledgeSearchResultItem.java
new file mode 100644
index 00000000..5062e02f
--- /dev/null
+++ b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/dto/PublicKnowledgeSearchResultItem.java
@@ -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;
+
+/**
+ * 公开知识库检索结果。
+ *
+ * 根据命中来源补充文档或 FAQ 标识,便于调用方继续查询对应详情。
+ */
+@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;
+ }
+}
diff --git a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/interceptor/PublicApiInterceptor.java b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/interceptor/PublicApiInterceptor.java
index 24f001c2..7825c4ff 100644
--- a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/interceptor/PublicApiInterceptor.java
+++ b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/interceptor/PublicApiInterceptor.java
@@ -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;
}
}
diff --git a/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/controller/PublicKnowledgeDocumentImportControllerTest.java b/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/controller/PublicKnowledgeDocumentImportControllerTest.java
new file mode 100644
index 00000000..ca06da62
--- /dev/null
+++ b/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/controller/PublicKnowledgeDocumentImportControllerTest.java
@@ -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 不能为空"));
+ }
+ }
+}
diff --git a/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/controller/PublicKnowledgeShareControllerContractTest.java b/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/controller/PublicKnowledgeShareControllerContractTest.java
new file mode 100644
index 00000000..245aefa3
--- /dev/null
+++ b/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/controller/PublicKnowledgeShareControllerContractTest.java
@@ -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 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 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 mapSearchResult(
+ com.easyagents.core.document.Document hit
+ ) throws Exception {
+ Method method = PublicKnowledgeShareController.class
+ .getDeclaredMethod(
+ "toKnowledgeSearchResult",
+ List.class
+ );
+ method.setAccessible(true);
+ return (List) 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());
+ }
+}
diff --git a/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/dto/PublicKnowledgeDetailResponseTest.java b/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/dto/PublicKnowledgeDetailResponseTest.java
new file mode 100644
index 00000000..dbba5f0f
--- /dev/null
+++ b/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/dto/PublicKnowledgeDetailResponseTest.java
@@ -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 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 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());
+ }
+}
diff --git a/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/interceptor/PublicApiInterceptorTest.java b/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/interceptor/PublicApiInterceptorTest.java
index 53fcd352..d5fe4489 100644
--- a/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/interceptor/PublicApiInterceptorTest.java
+++ b/easyflow-api/easyflow-api-public/src/test/java/tech/easyflow/publicapi/interceptor/PublicApiInterceptorTest.java
@@ -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