feat(knowledge): 添加知识库文件目录结构支持功能

- 在 KnowledgeItem 模型中增加 relativePath 字段存储相对路径
- 实现文件上传时的目录前缀处理和相对路径构建逻辑
- 添加批量删除知识条目的接口和实现方法
- 重构前端 KnowledgeSetDetail 组件以支持目录浏览和管理
- 实现文件夹创建、删除、导航等目录操作功能
- 更新数据查询逻辑以支持按相对路径进行搜索和过滤
- 添加前端文件夹图标显示和目录层级展示功能
This commit is contained in:
2026-01-31 17:45:43 +08:00
parent c1fb02b0f5
commit 310bc356b1
15 changed files with 664 additions and 123 deletions

View File

@@ -22,6 +22,7 @@ import com.datamate.datamanagement.infrastructure.persistence.repository.Knowled
import com.datamate.datamanagement.infrastructure.persistence.repository.KnowledgeSetRepository;
import com.datamate.datamanagement.interfaces.converter.KnowledgeConverter;
import com.datamate.datamanagement.interfaces.dto.CreateKnowledgeItemRequest;
import com.datamate.datamanagement.interfaces.dto.DeleteKnowledgeItemsRequest;
import com.datamate.datamanagement.interfaces.dto.ImportKnowledgeItemsRequest;
import com.datamate.datamanagement.interfaces.dto.KnowledgeItemPagingQuery;
import com.datamate.datamanagement.interfaces.dto.KnowledgeItemResponse;
@@ -78,6 +79,7 @@ public class KnowledgeItemApplicationService {
private static final int MAX_TITLE_LENGTH = 200;
private static final String KNOWLEDGE_ITEM_UPLOAD_DIR = "knowledge-items";
private static final String DEFAULT_FILE_EXTENSION = "bin";
private static final String PATH_SEPARATOR = "/";
private final KnowledgeItemRepository knowledgeItemRepository;
private final KnowledgeSetRepository knowledgeSetRepository;
@@ -112,6 +114,7 @@ public class KnowledgeItemApplicationService {
List<MultipartFile> files = request.getFiles();
BusinessAssert.isTrue(CollectionUtils.isNotEmpty(files), CommonErrorCode.PARAM_ERROR);
String parentPrefix = normalizeRelativePathPrefix(request.getParentPrefix());
Path uploadRoot = resolveUploadRootPath();
Path setDir = uploadRoot.resolve(KNOWLEDGE_ITEM_UPLOAD_DIR).resolve(setId).normalize();
@@ -145,6 +148,7 @@ public class KnowledgeItemApplicationService {
knowledgeItem.setContentType(KnowledgeContentType.FILE);
knowledgeItem.setSourceType(KnowledgeSourceType.FILE_UPLOAD);
knowledgeItem.setSourceFileId(trimToLength(safeOriginalName, MAX_TITLE_LENGTH));
knowledgeItem.setRelativePath(buildRelativePath(parentPrefix, safeOriginalName));
items.add(knowledgeItem);
}
@@ -182,6 +186,22 @@ public class KnowledgeItemApplicationService {
knowledgeItemRepository.removeById(itemId);
}
public void deleteKnowledgeItems(String setId, DeleteKnowledgeItemsRequest request) {
BusinessAssert.notNull(request, CommonErrorCode.PARAM_ERROR);
List<String> ids = request.getIds();
BusinessAssert.isTrue(CollectionUtils.isNotEmpty(ids), CommonErrorCode.PARAM_ERROR);
List<KnowledgeItem> items = knowledgeItemRepository.listByIds(ids);
BusinessAssert.isTrue(CollectionUtils.isNotEmpty(items), DataManagementErrorCode.KNOWLEDGE_ITEM_NOT_FOUND);
BusinessAssert.isTrue(items.size() == ids.size(), DataManagementErrorCode.KNOWLEDGE_ITEM_NOT_FOUND);
boolean allMatch = items.stream().allMatch(item -> Objects.equals(item.getSetId(), setId));
BusinessAssert.isTrue(allMatch, CommonErrorCode.PARAM_ERROR);
List<String> deleteIds = items.stream().map(KnowledgeItem::getId).toList();
knowledgeItemRepository.removeByIds(deleteIds);
}
@Transactional(readOnly = true)
public KnowledgeItem getKnowledgeItem(String setId, String itemId) {
KnowledgeItem knowledgeItem = knowledgeItemRepository.getById(itemId);
@@ -256,6 +276,7 @@ public class KnowledgeItemApplicationService {
knowledgeItem.setSourceType(KnowledgeSourceType.DATASET_FILE);
knowledgeItem.setSourceDatasetId(dataset.getId());
knowledgeItem.setSourceFileId(datasetFile.getId());
knowledgeItem.setRelativePath(resolveDatasetFileRelativePath(dataset, datasetFile));
items.add(knowledgeItem);
}
@@ -418,6 +439,7 @@ public class KnowledgeItemApplicationService {
knowledgeItem.setContentType(KnowledgeContentType.FILE);
knowledgeItem.setSourceType(KnowledgeSourceType.FILE_UPLOAD);
knowledgeItem.setSourceFileId(sourceFileId);
knowledgeItem.setRelativePath(resolveReplacedRelativePath(knowledgeItem.getRelativePath(), sourceFileId));
knowledgeItemRepository.updateById(knowledgeItem);
deleteFile(oldFilePath);
} catch (Exception e) {
@@ -540,6 +562,84 @@ public class KnowledgeItemApplicationService {
return relativePath.replace(File.separatorChar, '/');
}
private String buildRelativePath(String parentPrefix, String fileName) {
String safeName = sanitizeFileName(fileName);
if (StringUtils.isBlank(safeName)) {
safeName = "file";
}
String normalizedPrefix = normalizeRelativePathPrefix(parentPrefix);
return normalizedPrefix + safeName;
}
private String normalizeRelativePathPrefix(String prefix) {
if (StringUtils.isBlank(prefix)) {
return "";
}
String normalized = prefix.replace("\\", PATH_SEPARATOR).trim();
while (normalized.startsWith(PATH_SEPARATOR)) {
normalized = normalized.substring(1);
}
while (normalized.endsWith(PATH_SEPARATOR)) {
normalized = normalized.substring(0, normalized.length() - 1);
}
BusinessAssert.isTrue(!normalized.contains(".."), CommonErrorCode.PARAM_ERROR);
if (StringUtils.isBlank(normalized)) {
return "";
}
return normalized + PATH_SEPARATOR;
}
private String normalizeRelativePathValue(String relativePath) {
if (StringUtils.isBlank(relativePath)) {
return "";
}
String normalized = relativePath.replace("\\", PATH_SEPARATOR).trim();
while (normalized.startsWith(PATH_SEPARATOR)) {
normalized = normalized.substring(1);
}
while (normalized.endsWith(PATH_SEPARATOR)) {
normalized = normalized.substring(0, normalized.length() - 1);
}
return normalized;
}
private String resolveDatasetFileRelativePath(Dataset dataset, DatasetFile datasetFile) {
if (datasetFile == null) {
return "";
}
String fileName = StringUtils.defaultIfBlank(datasetFile.getFileName(), datasetFile.getId());
String datasetPath = dataset == null ? null : dataset.getPath();
String filePath = datasetFile.getFilePath();
if (StringUtils.isBlank(datasetPath) || StringUtils.isBlank(filePath)) {
return buildRelativePath("", fileName);
}
try {
Path datasetRoot = Paths.get(datasetPath).toAbsolutePath().normalize();
Path targetPath = Paths.get(filePath).toAbsolutePath().normalize();
if (targetPath.startsWith(datasetRoot)) {
Path relative = datasetRoot.relativize(targetPath);
String relativeValue = relative.toString().replace(File.separatorChar, '/');
String normalized = normalizeRelativePathValue(relativeValue);
if (!normalized.contains("..") && StringUtils.isNotBlank(normalized)) {
return normalized;
}
}
} catch (Exception e) {
log.warn("resolve dataset file relative path failed, fileId: {}", datasetFile.getId(), e);
}
return buildRelativePath("", fileName);
}
private String resolveReplacedRelativePath(String existingRelativePath, String newFileName) {
String normalized = normalizeRelativePathValue(existingRelativePath);
if (StringUtils.isBlank(normalized)) {
return buildRelativePath("", newFileName);
}
int lastIndex = normalized.lastIndexOf(PATH_SEPARATOR);
String parentPrefix = lastIndex >= 0 ? normalized.substring(0, lastIndex + 1) : "";
return buildRelativePath(parentPrefix, newFileName);
}
private void createDirectories(Path path) {
try {
Files.createDirectories(path);

View File

@@ -38,4 +38,8 @@ public class KnowledgeItem extends BaseEntity<String> {
* 来源文件ID
*/
private String sourceFileId;
/**
* 相对路径(用于目录展示)
*/
private String relativePath;
}

View File

@@ -28,13 +28,16 @@ public interface KnowledgeItemMapper extends BaseMapper<KnowledgeItem> {
WHEN ki.source_type = 'FILE_UPLOAD' THEN ki.content
ELSE NULL
END AS content,
ki.relative_path AS relativePath,
ki.created_at AS createdAt,
ki.updated_at AS updatedAt
FROM t_dm_knowledge_items ki
LEFT JOIN t_dm_knowledge_sets ks ON ki.set_id = ks.id
LEFT JOIN t_dm_dataset_files df ON ki.source_file_id = df.id AND ki.source_type = 'DATASET_FILE'
WHERE (ki.source_type = 'FILE_UPLOAD' AND ki.source_file_id LIKE CONCAT('%', #{keyword}, '%'))
OR (ki.source_type = 'DATASET_FILE' AND df.file_name LIKE CONCAT('%', #{keyword}, '%'))
WHERE (ki.source_type = 'FILE_UPLOAD' AND (ki.source_file_id LIKE CONCAT('%', #{keyword}, '%')
OR ki.relative_path LIKE CONCAT('%', #{keyword}, '%')))
OR (ki.source_type = 'DATASET_FILE' AND (df.file_name LIKE CONCAT('%', #{keyword}, '%')
OR ki.relative_path LIKE CONCAT('%', #{keyword}, '%')))
ORDER BY ki.created_at DESC
""")
IPage<KnowledgeItemSearchResponse> searchFileItems(IPage<?> page, @Param("keyword") String keyword);

View File

@@ -21,21 +21,26 @@ import java.util.List;
@Repository
@RequiredArgsConstructor
public class KnowledgeItemRepositoryImpl extends CrudRepository<KnowledgeItemMapper, KnowledgeItem> implements KnowledgeItemRepository {
private static final String PATH_SEPARATOR = "/";
private final KnowledgeItemMapper knowledgeItemMapper;
@Override
public IPage<KnowledgeItem> findByCriteria(IPage<KnowledgeItem> page, KnowledgeItemPagingQuery query) {
String relativePath = normalizeRelativePathPrefix(query.getRelativePath());
LambdaQueryWrapper<KnowledgeItem> wrapper = new LambdaQueryWrapper<KnowledgeItem>()
.eq(StringUtils.isNotBlank(query.getSetId()), KnowledgeItem::getSetId, query.getSetId())
.eq(query.getContentType() != null, KnowledgeItem::getContentType, query.getContentType())
.eq(query.getSourceType() != null, KnowledgeItem::getSourceType, query.getSourceType())
.eq(StringUtils.isNotBlank(query.getSourceDatasetId()), KnowledgeItem::getSourceDatasetId, query.getSourceDatasetId())
.eq(StringUtils.isNotBlank(query.getSourceFileId()), KnowledgeItem::getSourceFileId, query.getSourceFileId());
.eq(StringUtils.isNotBlank(query.getSourceFileId()), KnowledgeItem::getSourceFileId, query.getSourceFileId())
.likeRight(StringUtils.isNotBlank(relativePath), KnowledgeItem::getRelativePath, relativePath);
if (StringUtils.isNotBlank(query.getKeyword())) {
wrapper.and(w -> w.like(KnowledgeItem::getSourceFileId, query.getKeyword())
.or()
.like(KnowledgeItem::getContent, query.getKeyword()));
.like(KnowledgeItem::getContent, query.getKeyword())
.or()
.like(KnowledgeItem::getRelativePath, query.getKeyword()));
}
wrapper.orderByDesc(KnowledgeItem::getCreatedAt);
@@ -77,4 +82,21 @@ public class KnowledgeItemRepositoryImpl extends CrudRepository<KnowledgeItemMap
public Long sumDatasetFileSize() {
return knowledgeItemMapper.sumDatasetFileSize();
}
private String normalizeRelativePathPrefix(String relativePath) {
if (StringUtils.isBlank(relativePath)) {
return "";
}
String normalized = relativePath.replace("\\", PATH_SEPARATOR).trim();
while (normalized.startsWith(PATH_SEPARATOR)) {
normalized = normalized.substring(1);
}
if (StringUtils.isBlank(normalized)) {
return "";
}
if (!normalized.endsWith(PATH_SEPARATOR)) {
normalized = normalized + PATH_SEPARATOR;
}
return normalized;
}
}

View File

@@ -0,0 +1,20 @@
package com.datamate.datamanagement.interfaces.dto;
import jakarta.validation.constraints.NotEmpty;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
/**
* 批量删除知识条目请求
*/
@Getter
@Setter
public class DeleteKnowledgeItemsRequest {
/**
* 知识条目ID列表
*/
@NotEmpty(message = "知识条目ID不能为空")
private List<String> ids;
}

View File

@@ -41,4 +41,8 @@ public class KnowledgeItemPagingQuery extends PagingQuery {
* 来源文件ID
*/
private String sourceFileId;
/**
* 相对路径前缀
*/
private String relativePath;
}

View File

@@ -20,6 +20,10 @@ public class KnowledgeItemResponse {
private KnowledgeSourceType sourceType;
private String sourceDatasetId;
private String sourceFileId;
/**
* 相对路径(用于目录展示)
*/
private String relativePath;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private String createdBy;

View File

@@ -23,6 +23,10 @@ public class KnowledgeItemSearchResponse {
private String sourceFileId;
private String fileName;
private Long fileSize;
/**
* 相对路径(用于目录展示)
*/
private String relativePath;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;

View File

@@ -17,4 +17,8 @@ public class UploadKnowledgeItemsRequest {
*/
@NotEmpty(message = "文件列表不能为空")
private List<MultipartFile> files;
/**
* 目录前缀(用于目录上传)
*/
private String parentPrefix;
}

View File

@@ -6,6 +6,7 @@ import com.datamate.datamanagement.application.KnowledgeItemApplicationService;
import com.datamate.datamanagement.domain.model.knowledge.KnowledgeItem;
import com.datamate.datamanagement.interfaces.converter.KnowledgeConverter;
import com.datamate.datamanagement.interfaces.dto.CreateKnowledgeItemRequest;
import com.datamate.datamanagement.interfaces.dto.DeleteKnowledgeItemsRequest;
import com.datamate.datamanagement.interfaces.dto.ImportKnowledgeItemsRequest;
import com.datamate.datamanagement.interfaces.dto.KnowledgeItemPagingQuery;
import com.datamate.datamanagement.interfaces.dto.KnowledgeItemResponse;
@@ -108,4 +109,10 @@ public class KnowledgeItemController {
@PathVariable("itemId") String itemId) {
knowledgeItemApplicationService.deleteKnowledgeItem(setId, itemId);
}
@PostMapping("/batch-delete")
public void deleteKnowledgeItems(@PathVariable("setId") String setId,
@RequestBody @Valid DeleteKnowledgeItemsRequest request) {
knowledgeItemApplicationService.deleteKnowledgeItems(setId, request);
}
}