feat: 完善知识库批量导入与公共 API
- 新增批量异步导入、状态查询、失败重试与中断恢复链路 - 拆分知识库读取、导入、维护权限并完善 Public API 契约 - 补充数据库迁移、管理端交互、接口说明与相关测试
This commit is contained in:
@@ -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<DocumentService, Docu
|
||||
@Autowired
|
||||
private DocumentImportTaskStatusStreamService documentImportTaskStatusStreamService;
|
||||
|
||||
@Autowired
|
||||
private DocumentImportBatchAppService documentImportBatchAppService;
|
||||
|
||||
@Value("${easyflow.storage.local.root:}")
|
||||
private String fileUploadPath;
|
||||
|
||||
@@ -312,6 +318,142 @@ public class DocumentController extends BaseCurdController<DocumentService, Docu
|
||||
return documentService.retryIndexTask(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文档批量上传清单。
|
||||
*
|
||||
* @param request 文件清单
|
||||
* @return 批次与服务端文件项
|
||||
*/
|
||||
@PostMapping("import/batch/create")
|
||||
@SaCheckPermission("/api/v1/documentCollection/save")
|
||||
public Result<DocumentImportBatchDtos.CreateResponse> 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<DocumentImportBatchDtos.ItemResponse> 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<DocumentImportBatchDtos.StatusResponse> 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<Void> 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<DocumentImportBatchDtos.StatusResponse> 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<DocumentImportBatchDtos.StatusResponse> 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<DocumentImportBatchDtos.StatusResponse> 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<DocumentImportDtos.TaskStartIndexResponse> 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
|
||||
*
|
||||
|
||||
@@ -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<SysApiKeyService, Sy
|
||||
return Result.ok(new PkVo(pkArgs));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新访问令牌基础信息与授权。
|
||||
*
|
||||
* <p>权限开关不映射数据库列,权限更新请求可能只包含主键与权限字段。
|
||||
* 此时跳过主表更新,避免 MyBatis-Flex 生成空的 {@code SET} 子句。</p>
|
||||
*
|
||||
* @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<SysApiKeyService, Sy
|
||||
List<BigInteger> resourceIds = sysApiKeyResourceMappingService.listAs(interfaceWrapper, BigInteger.class);
|
||||
entity.setPermissionIds(resourceIds);
|
||||
|
||||
QueryWrapper knowledgeWrapper = QueryWrapper.create()
|
||||
.select(SysApiKeyResourceMapping::getId)
|
||||
.eq(SysApiKeyResourceMapping::getApiKeyId, entity.getId())
|
||||
.eq(SysApiKeyResourceMapping::getResourceType, "KNOWLEDGE");
|
||||
entity.setKnowledgeShareEnabled(sysApiKeyResourceMappingService.count(knowledgeWrapper) > 0);
|
||||
Set<String> 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<SysApiKeyService, Sy
|
||||
.eq(SysApiKeyResourceMapping::getResourceType, WorkflowApiPermissionService.RESOURCE_TYPE_WORKFLOW);
|
||||
entity.setWorkflowApiEnabled(sysApiKeyResourceMappingService.count(workflowWrapper) > 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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