feat(knowledge): 添加知识库条目预览功能

- 集成 docx4j 和 LibreOffice 实现 Office 文档转 PDF 预览
- 新增 KnowledgeItemPreviewService 处理预览转换逻辑
- 添加异步任务 KnowledgeItemPreviewAsyncService 进行文档转换
- 实现预览状态管理包括待转换、转换中、就绪和失败状态
- 在前端界面添加 Office 文档预览状态标签显示
- 支持 DOC/DOCX 文件在线预览功能
- 添加预览元数据存储和管理机制
This commit is contained in:
2026-02-01 20:05:25 +08:00
parent 551248ec76
commit 40889baacc
13 changed files with 854 additions and 6 deletions

View File

@@ -60,6 +60,16 @@
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-commons</artifactId>
</dependency>
<dependency>
<groupId>org.docx4j</groupId>
<artifactId>docx4j-core</artifactId>
<version>11.4.9</version>
</dependency>
<dependency>
<groupId>org.docx4j</groupId>
<artifactId>docx4j-export-fo</artifactId>
<version>11.4.9</version>
</dependency>
</dependencies>
<build>

View File

@@ -76,6 +76,7 @@ public class KnowledgeItemApplicationService {
private static final String EXPORT_FILE_PREFIX = "knowledge_set_";
private static final String EXPORT_FILE_SUFFIX = ".zip";
private static final String EXPORT_CONTENT_TYPE = "application/zip";
private static final String PREVIEW_PDF_CONTENT_TYPE = "application/pdf";
private static final int MAX_FILE_BASE_LENGTH = 120;
private static final int MAX_TITLE_LENGTH = 200;
private static final String KNOWLEDGE_ITEM_UPLOAD_DIR = "knowledge-items";
@@ -88,6 +89,7 @@ public class KnowledgeItemApplicationService {
private final DatasetFileRepository datasetFileRepository;
private final DataManagementProperties dataManagementProperties;
private final TagMapper tagMapper;
private final KnowledgeItemPreviewService knowledgeItemPreviewService;
public KnowledgeItem createKnowledgeItem(String setId, CreateKnowledgeItemRequest request) {
KnowledgeSet knowledgeSet = requireKnowledgeSet(setId);
@@ -371,6 +373,26 @@ public class KnowledgeItemApplicationService {
? knowledgeItem.getSourceFileId()
: filePath.getFileName().toString();
if (knowledgeItemPreviewService.isOfficeDocument(previewName)) {
KnowledgeItemPreviewService.PreviewFile previewFile = knowledgeItemPreviewService.resolveReadyPreviewFile(setId, knowledgeItem);
if (previewFile == null) {
response.setStatus(HttpServletResponse.SC_CONFLICT);
return;
}
response.setContentType(PREVIEW_PDF_CONTENT_TYPE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
"inline; filename=\"" + URLEncoder.encode(previewFile.fileName(), StandardCharsets.UTF_8) + "\"");
try (InputStream inputStream = Files.newInputStream(previewFile.filePath())) {
inputStream.transferTo(response.getOutputStream());
response.flushBuffer();
} catch (IOException e) {
log.error("preview knowledge item pdf error, itemId: {}", itemId, e);
throw BusinessException.of(SystemErrorCode.FILE_SYSTEM_ERROR);
}
return;
}
String contentType = null;
try {
contentType = Files.probeContentType(filePath);
@@ -443,7 +465,9 @@ public class KnowledgeItemApplicationService {
knowledgeItem.setSourceType(KnowledgeSourceType.FILE_UPLOAD);
knowledgeItem.setSourceFileId(sourceFileId);
knowledgeItem.setRelativePath(resolveReplacedRelativePath(knowledgeItem.getRelativePath(), sourceFileId));
knowledgeItem.setMetadata(knowledgeItemPreviewService.clearPreviewMetadata(knowledgeItem.getMetadata()));
knowledgeItemRepository.updateById(knowledgeItem);
knowledgeItemPreviewService.deletePreviewFileQuietly(setId, knowledgeItem.getId());
deleteFile(oldFilePath);
} catch (Exception e) {
deleteFileQuietly(targetPath);

View File

@@ -0,0 +1,275 @@
package com.datamate.datamanagement.application;
import com.datamate.datamanagement.common.enums.KnowledgeItemPreviewStatus;
import com.datamate.datamanagement.domain.model.knowledge.KnowledgeItem;
import com.datamate.datamanagement.infrastructure.config.DataManagementProperties;
import com.datamate.datamanagement.infrastructure.persistence.repository.KnowledgeItemRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.docx4j.Docx4J;
import org.docx4j.openpackaging.packages.WordprocessingMLPackage;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Set;
/**
* 知识条目预览转换异步任务
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class KnowledgeItemPreviewAsyncService {
private static final Set<String> OFFICE_EXTENSIONS = Set.of("doc", "docx");
private static final String KNOWLEDGE_ITEM_UPLOAD_DIR = "knowledge-items";
private static final String PREVIEW_SUB_DIR = "preview";
private static final String PREVIEW_FILE_SUFFIX = ".pdf";
private static final String PATH_SEPARATOR = "/";
private static final String LIBREOFFICE_COMMAND = "soffice";
private static final Duration CONVERT_TIMEOUT = Duration.ofMinutes(5);
private static final int MAX_ERROR_LENGTH = 500;
private static final DateTimeFormatter PREVIEW_TIME_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
private final KnowledgeItemRepository knowledgeItemRepository;
private final DataManagementProperties dataManagementProperties;
private final ObjectMapper objectMapper = new ObjectMapper();
@Async
public void convertPreviewAsync(String itemId) {
if (StringUtils.isBlank(itemId)) {
return;
}
KnowledgeItem item = knowledgeItemRepository.getById(itemId);
if (item == null) {
return;
}
String extension = resolveFileExtension(resolveOriginalName(item));
if (!OFFICE_EXTENSIONS.contains(extension)) {
updatePreviewStatus(item, KnowledgeItemPreviewStatus.FAILED, null, "仅支持 DOC/DOCX 转换");
return;
}
if (StringUtils.isBlank(item.getContent())) {
updatePreviewStatus(item, KnowledgeItemPreviewStatus.FAILED, null, "源文件路径为空");
return;
}
Path sourcePath = resolveKnowledgeItemStoragePath(item.getContent());
if (!Files.exists(sourcePath) || !Files.isRegularFile(sourcePath)) {
updatePreviewStatus(item, KnowledgeItemPreviewStatus.FAILED, null, "源文件不存在");
return;
}
KnowledgeItemPreviewMetadataHelper.PreviewInfo previewInfo = KnowledgeItemPreviewMetadataHelper
.readPreviewInfo(item.getMetadata(), objectMapper);
String previewRelativePath = StringUtils.defaultIfBlank(
previewInfo.pdfPath(),
resolvePreviewRelativePath(item.getSetId(), item.getId())
);
Path targetPath = resolvePreviewStoragePath(previewRelativePath);
ensureParentDirectory(targetPath);
try {
if ("docx".equals(extension)) {
convertDocxToPdf(sourcePath, targetPath);
} else {
convertDocToPdfByLibreOffice(sourcePath, targetPath);
}
updatePreviewStatus(item, KnowledgeItemPreviewStatus.READY, previewRelativePath, null);
} catch (Exception e) {
log.error("preview convert failed, itemId: {}", item.getId(), e);
updatePreviewStatus(item, KnowledgeItemPreviewStatus.FAILED, previewRelativePath, trimError(e.getMessage()));
}
}
private void convertDocxToPdf(Path sourcePath, Path targetPath) throws Exception {
WordprocessingMLPackage wordMLPackage = WordprocessingMLPackage.load(sourcePath.toFile());
try (OutputStream outputStream = Files.newOutputStream(targetPath)) {
Docx4J.toPDF(wordMLPackage, outputStream);
}
}
private void convertDocToPdfByLibreOffice(Path sourcePath, Path targetPath) throws Exception {
Path outputDir = targetPath.getParent();
ensureParentDirectory(targetPath);
List<String> command = List.of(
LIBREOFFICE_COMMAND,
"--headless",
"--nologo",
"--nolockcheck",
"--nodefault",
"--nofirststartwizard",
"--convert-to",
"pdf",
"--outdir",
outputDir.toString(),
sourcePath.toString()
);
ProcessBuilder processBuilder = new ProcessBuilder(command);
processBuilder.redirectErrorStream(true);
Process process = processBuilder.start();
boolean finished = process.waitFor(CONVERT_TIMEOUT.toMillis(), java.util.concurrent.TimeUnit.MILLISECONDS);
String output = readProcessOutput(process.getInputStream());
if (!finished) {
process.destroyForcibly();
throw new IllegalStateException("LibreOffice 转换超时");
}
if (process.exitValue() != 0) {
throw new IllegalStateException("LibreOffice 转换失败: " + output);
}
Path generated = outputDir.resolve(stripExtension(sourcePath.getFileName().toString()) + PREVIEW_FILE_SUFFIX);
if (!Files.exists(generated)) {
throw new IllegalStateException("LibreOffice 输出文件不存在");
}
if (!generated.equals(targetPath)) {
Files.move(generated, targetPath, StandardCopyOption.REPLACE_EXISTING);
}
}
private String readProcessOutput(InputStream inputStream) throws IOException {
if (inputStream == null) {
return "";
}
byte[] buffer = new byte[1024];
StringBuilder builder = new StringBuilder();
int total = 0;
int read;
while ((read = inputStream.read(buffer)) >= 0) {
if (read == 0) {
continue;
}
int remaining = MAX_ERROR_LENGTH - total;
if (remaining <= 0) {
break;
}
int toAppend = Math.min(remaining, read);
builder.append(new String(buffer, 0, toAppend, StandardCharsets.UTF_8));
total += toAppend;
if (total >= MAX_ERROR_LENGTH) {
break;
}
}
return builder.toString();
}
private void updatePreviewStatus(
KnowledgeItem item,
KnowledgeItemPreviewStatus status,
String previewRelativePath,
String error
) {
if (item == null) {
return;
}
String updatedMetadata = KnowledgeItemPreviewMetadataHelper.applyPreviewInfo(
item.getMetadata(),
objectMapper,
status,
previewRelativePath,
error,
nowText()
);
item.setMetadata(updatedMetadata);
knowledgeItemRepository.updateById(item);
}
private String resolveOriginalName(KnowledgeItem item) {
if (item == null) {
return "";
}
if (StringUtils.isNotBlank(item.getSourceFileId())) {
return item.getSourceFileId();
}
if (StringUtils.isNotBlank(item.getContent())) {
return Paths.get(item.getContent()).getFileName().toString();
}
return "";
}
private String resolveFileExtension(String fileName) {
if (StringUtils.isBlank(fileName)) {
return "";
}
int dotIndex = fileName.lastIndexOf('.');
if (dotIndex <= 0 || dotIndex >= fileName.length() - 1) {
return "";
}
return fileName.substring(dotIndex + 1).toLowerCase();
}
private String stripExtension(String fileName) {
if (StringUtils.isBlank(fileName)) {
return "preview";
}
int dotIndex = fileName.lastIndexOf('.');
return dotIndex <= 0 ? fileName : fileName.substring(0, dotIndex);
}
private String resolvePreviewRelativePath(String setId, String itemId) {
String relativePath = Paths.get(KNOWLEDGE_ITEM_UPLOAD_DIR, setId, PREVIEW_SUB_DIR, itemId + PREVIEW_FILE_SUFFIX)
.toString();
return relativePath.replace("\\", PATH_SEPARATOR);
}
private Path resolvePreviewStoragePath(String relativePath) {
String normalizedRelativePath = StringUtils.defaultString(relativePath).replace("/", java.io.File.separator);
Path root = resolveUploadRootPath();
Path target = root.resolve(normalizedRelativePath).toAbsolutePath().normalize();
if (!target.startsWith(root)) {
throw new IllegalArgumentException("invalid preview path");
}
return target;
}
private Path resolveKnowledgeItemStoragePath(String relativePath) {
String normalizedRelativePath = StringUtils.defaultString(relativePath).replace("/", java.io.File.separator);
Path root = resolveUploadRootPath();
Path target = root.resolve(normalizedRelativePath).toAbsolutePath().normalize();
if (!target.startsWith(root)) {
throw new IllegalArgumentException("invalid knowledge item path");
}
return target;
}
private Path resolveUploadRootPath() {
String uploadDir = dataManagementProperties.getFileStorage().getUploadDir();
return Paths.get(uploadDir).toAbsolutePath().normalize();
}
private void ensureParentDirectory(Path targetPath) {
try {
Path parent = targetPath.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
} catch (IOException e) {
throw new IllegalStateException("创建预览目录失败", e);
}
}
private String trimError(String error) {
if (StringUtils.isBlank(error)) {
return "";
}
if (error.length() <= MAX_ERROR_LENGTH) {
return error;
}
return error.substring(0, MAX_ERROR_LENGTH);
}
private String nowText() {
return LocalDateTime.now().format(PREVIEW_TIME_FORMATTER);
}
}

View File

@@ -0,0 +1,134 @@
package com.datamate.datamanagement.application;
import com.datamate.datamanagement.common.enums.KnowledgeItemPreviewStatus;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.apache.commons.lang3.StringUtils;
/**
* 知识条目预览元数据解析与写入辅助类
*/
public final class KnowledgeItemPreviewMetadataHelper {
public static final String PREVIEW_STATUS_KEY = "previewStatus";
public static final String PREVIEW_PDF_PATH_KEY = "previewPdfPath";
public static final String PREVIEW_ERROR_KEY = "previewError";
public static final String PREVIEW_UPDATED_AT_KEY = "previewUpdatedAt";
private KnowledgeItemPreviewMetadataHelper() {
}
public static PreviewInfo readPreviewInfo(String metadata, ObjectMapper objectMapper) {
if (StringUtils.isBlank(metadata) || objectMapper == null) {
return PreviewInfo.empty();
}
try {
JsonNode node = objectMapper.readTree(metadata);
if (node == null || !node.isObject()) {
return PreviewInfo.empty();
}
String statusText = textValue(node, PREVIEW_STATUS_KEY);
KnowledgeItemPreviewStatus status = parseStatus(statusText);
return new PreviewInfo(
status,
textValue(node, PREVIEW_PDF_PATH_KEY),
textValue(node, PREVIEW_ERROR_KEY),
textValue(node, PREVIEW_UPDATED_AT_KEY)
);
} catch (Exception ignore) {
return PreviewInfo.empty();
}
}
public static String applyPreviewInfo(
String metadata,
ObjectMapper objectMapper,
KnowledgeItemPreviewStatus status,
String pdfPath,
String error,
String updatedAt
) {
if (objectMapper == null) {
return metadata;
}
ObjectNode root = parseRoot(metadata, objectMapper);
if (status == null) {
root.remove(PREVIEW_STATUS_KEY);
} else {
root.put(PREVIEW_STATUS_KEY, status.name());
}
if (StringUtils.isBlank(pdfPath)) {
root.remove(PREVIEW_PDF_PATH_KEY);
} else {
root.put(PREVIEW_PDF_PATH_KEY, pdfPath);
}
if (StringUtils.isBlank(error)) {
root.remove(PREVIEW_ERROR_KEY);
} else {
root.put(PREVIEW_ERROR_KEY, error);
}
if (StringUtils.isBlank(updatedAt)) {
root.remove(PREVIEW_UPDATED_AT_KEY);
} else {
root.put(PREVIEW_UPDATED_AT_KEY, updatedAt);
}
return root.size() == 0 ? null : root.toString();
}
public static String clearPreviewInfo(String metadata, ObjectMapper objectMapper) {
if (objectMapper == null) {
return metadata;
}
ObjectNode root = parseRoot(metadata, objectMapper);
root.remove(PREVIEW_STATUS_KEY);
root.remove(PREVIEW_PDF_PATH_KEY);
root.remove(PREVIEW_ERROR_KEY);
root.remove(PREVIEW_UPDATED_AT_KEY);
return root.size() == 0 ? null : root.toString();
}
private static ObjectNode parseRoot(String metadata, ObjectMapper objectMapper) {
if (StringUtils.isBlank(metadata)) {
return objectMapper.createObjectNode();
}
try {
JsonNode node = objectMapper.readTree(metadata);
if (node instanceof ObjectNode objectNode) {
return objectNode;
}
} catch (Exception ignore) {
return objectMapper.createObjectNode();
}
return objectMapper.createObjectNode();
}
private static String textValue(JsonNode node, String key) {
if (node == null || StringUtils.isBlank(key)) {
return null;
}
JsonNode value = node.get(key);
return value == null || value.isNull() ? null : value.asText();
}
private static KnowledgeItemPreviewStatus parseStatus(String statusText) {
if (StringUtils.isBlank(statusText)) {
return null;
}
try {
return KnowledgeItemPreviewStatus.valueOf(statusText);
} catch (Exception ignore) {
return null;
}
}
public record PreviewInfo(
KnowledgeItemPreviewStatus status,
String pdfPath,
String error,
String updatedAt
) {
public static PreviewInfo empty() {
return new PreviewInfo(null, null, null, null);
}
}
}

View File

@@ -0,0 +1,244 @@
package com.datamate.datamanagement.application;
import com.datamate.common.infrastructure.exception.BusinessAssert;
import com.datamate.common.infrastructure.exception.CommonErrorCode;
import com.datamate.datamanagement.common.enums.KnowledgeContentType;
import com.datamate.datamanagement.common.enums.KnowledgeItemPreviewStatus;
import com.datamate.datamanagement.common.enums.KnowledgeSourceType;
import com.datamate.datamanagement.domain.model.knowledge.KnowledgeItem;
import com.datamate.datamanagement.infrastructure.config.DataManagementProperties;
import com.datamate.datamanagement.infrastructure.persistence.repository.KnowledgeItemRepository;
import com.datamate.datamanagement.interfaces.dto.KnowledgeItemPreviewStatusResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Objects;
import java.util.Set;
/**
* 知识条目预览转换服务
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class KnowledgeItemPreviewService {
private static final Set<String> OFFICE_EXTENSIONS = Set.of("doc", "docx");
private static final String KNOWLEDGE_ITEM_UPLOAD_DIR = "knowledge-items";
private static final String PREVIEW_SUB_DIR = "preview";
private static final String PREVIEW_FILE_SUFFIX = ".pdf";
private static final String PATH_SEPARATOR = "/";
private static final DateTimeFormatter PREVIEW_TIME_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
private final KnowledgeItemRepository knowledgeItemRepository;
private final DataManagementProperties dataManagementProperties;
private final KnowledgeItemPreviewAsyncService knowledgeItemPreviewAsyncService;
private final ObjectMapper objectMapper = new ObjectMapper();
public KnowledgeItemPreviewStatusResponse getPreviewStatus(String setId, String itemId) {
KnowledgeItem item = requireKnowledgeItem(setId, itemId);
assertOfficeDocument(item);
KnowledgeItemPreviewMetadataHelper.PreviewInfo previewInfo = KnowledgeItemPreviewMetadataHelper
.readPreviewInfo(item.getMetadata(), objectMapper);
if (previewInfo.status() == KnowledgeItemPreviewStatus.READY && !previewPdfExists(item, previewInfo)) {
previewInfo = markPreviewFailed(item, previewInfo, "预览文件不存在");
}
return buildResponse(previewInfo);
}
public KnowledgeItemPreviewStatusResponse ensurePreview(String setId, String itemId) {
KnowledgeItem item = requireKnowledgeItem(setId, itemId);
assertOfficeDocument(item);
KnowledgeItemPreviewMetadataHelper.PreviewInfo previewInfo = KnowledgeItemPreviewMetadataHelper
.readPreviewInfo(item.getMetadata(), objectMapper);
if (previewInfo.status() == KnowledgeItemPreviewStatus.READY && previewPdfExists(item, previewInfo)) {
return buildResponse(previewInfo);
}
if (previewInfo.status() == KnowledgeItemPreviewStatus.PROCESSING) {
return buildResponse(previewInfo);
}
String previewRelativePath = resolvePreviewRelativePath(item.getSetId(), item.getId());
String updatedMetadata = KnowledgeItemPreviewMetadataHelper.applyPreviewInfo(
item.getMetadata(),
objectMapper,
KnowledgeItemPreviewStatus.PROCESSING,
previewRelativePath,
null,
nowText()
);
item.setMetadata(updatedMetadata);
knowledgeItemRepository.updateById(item);
knowledgeItemPreviewAsyncService.convertPreviewAsync(item.getId());
KnowledgeItemPreviewMetadataHelper.PreviewInfo refreshed = KnowledgeItemPreviewMetadataHelper
.readPreviewInfo(updatedMetadata, objectMapper);
return buildResponse(refreshed);
}
public boolean isOfficeDocument(String fileName) {
String extension = resolveFileExtension(fileName);
return StringUtils.isNotBlank(extension) && OFFICE_EXTENSIONS.contains(extension.toLowerCase());
}
public PreviewFile resolveReadyPreviewFile(String setId, KnowledgeItem item) {
if (item == null) {
return null;
}
KnowledgeItemPreviewMetadataHelper.PreviewInfo previewInfo = KnowledgeItemPreviewMetadataHelper
.readPreviewInfo(item.getMetadata(), objectMapper);
if (previewInfo.status() != KnowledgeItemPreviewStatus.READY) {
return null;
}
String relativePath = StringUtils.defaultIfBlank(previewInfo.pdfPath(), resolvePreviewRelativePath(setId, item.getId()));
Path filePath = resolvePreviewStoragePath(relativePath);
if (!Files.exists(filePath) || !Files.isRegularFile(filePath)) {
markPreviewFailed(item, previewInfo, "预览文件不存在");
return null;
}
String previewName = resolvePreviewPdfName(item);
return new PreviewFile(filePath, previewName);
}
public String clearPreviewMetadata(String metadata) {
return KnowledgeItemPreviewMetadataHelper.clearPreviewInfo(metadata, objectMapper);
}
public void deletePreviewFileQuietly(String setId, String itemId) {
String relativePath = resolvePreviewRelativePath(setId, itemId);
Path filePath = resolvePreviewStoragePath(relativePath);
try {
Files.deleteIfExists(filePath);
} catch (Exception e) {
log.warn("delete preview pdf error, itemId: {}", itemId, e);
}
}
private KnowledgeItemPreviewStatusResponse buildResponse(KnowledgeItemPreviewMetadataHelper.PreviewInfo previewInfo) {
KnowledgeItemPreviewStatusResponse response = new KnowledgeItemPreviewStatusResponse();
KnowledgeItemPreviewStatus status = previewInfo.status() == null
? KnowledgeItemPreviewStatus.PENDING
: previewInfo.status();
response.setStatus(status);
response.setPreviewError(previewInfo.error());
response.setUpdatedAt(previewInfo.updatedAt());
return response;
}
private KnowledgeItem requireKnowledgeItem(String setId, String itemId) {
BusinessAssert.isTrue(StringUtils.isNotBlank(setId), CommonErrorCode.PARAM_ERROR);
BusinessAssert.isTrue(StringUtils.isNotBlank(itemId), CommonErrorCode.PARAM_ERROR);
KnowledgeItem knowledgeItem = knowledgeItemRepository.getById(itemId);
BusinessAssert.notNull(knowledgeItem, CommonErrorCode.PARAM_ERROR);
BusinessAssert.isTrue(Objects.equals(knowledgeItem.getSetId(), setId), CommonErrorCode.PARAM_ERROR);
return knowledgeItem;
}
private void assertOfficeDocument(KnowledgeItem item) {
BusinessAssert.notNull(item, CommonErrorCode.PARAM_ERROR);
BusinessAssert.isTrue(
item.getContentType() == KnowledgeContentType.FILE || item.getSourceType() == KnowledgeSourceType.FILE_UPLOAD,
CommonErrorCode.PARAM_ERROR
);
String extension = resolveFileExtension(resolveOriginalName(item));
BusinessAssert.isTrue(OFFICE_EXTENSIONS.contains(extension), CommonErrorCode.PARAM_ERROR);
}
private String resolveOriginalName(KnowledgeItem item) {
if (item == null) {
return "";
}
if (StringUtils.isNotBlank(item.getSourceFileId())) {
return item.getSourceFileId();
}
if (StringUtils.isNotBlank(item.getContent())) {
return Paths.get(item.getContent()).getFileName().toString();
}
return "";
}
private String resolveFileExtension(String fileName) {
if (StringUtils.isBlank(fileName)) {
return "";
}
int dotIndex = fileName.lastIndexOf('.');
if (dotIndex <= 0 || dotIndex >= fileName.length() - 1) {
return "";
}
return fileName.substring(dotIndex + 1).toLowerCase();
}
private String resolvePreviewPdfName(KnowledgeItem item) {
String originalName = resolveOriginalName(item);
if (StringUtils.isBlank(originalName)) {
return "预览.pdf";
}
int dotIndex = originalName.lastIndexOf('.');
if (dotIndex <= 0) {
return originalName + PREVIEW_FILE_SUFFIX;
}
return originalName.substring(0, dotIndex) + PREVIEW_FILE_SUFFIX;
}
private boolean previewPdfExists(KnowledgeItem item, KnowledgeItemPreviewMetadataHelper.PreviewInfo previewInfo) {
String relativePath = StringUtils.defaultIfBlank(previewInfo.pdfPath(), resolvePreviewRelativePath(item.getSetId(), item.getId()));
Path filePath = resolvePreviewStoragePath(relativePath);
return Files.exists(filePath) && Files.isRegularFile(filePath);
}
private KnowledgeItemPreviewMetadataHelper.PreviewInfo markPreviewFailed(
KnowledgeItem item,
KnowledgeItemPreviewMetadataHelper.PreviewInfo previewInfo,
String error
) {
String relativePath = StringUtils.defaultIfBlank(previewInfo.pdfPath(), resolvePreviewRelativePath(item.getSetId(), item.getId()));
String updatedMetadata = KnowledgeItemPreviewMetadataHelper.applyPreviewInfo(
item.getMetadata(),
objectMapper,
KnowledgeItemPreviewStatus.FAILED,
relativePath,
error,
nowText()
);
item.setMetadata(updatedMetadata);
knowledgeItemRepository.updateById(item);
return KnowledgeItemPreviewMetadataHelper.readPreviewInfo(updatedMetadata, objectMapper);
}
private String resolvePreviewRelativePath(String setId, String itemId) {
String relativePath = Paths.get(KNOWLEDGE_ITEM_UPLOAD_DIR, setId, PREVIEW_SUB_DIR, itemId + PREVIEW_FILE_SUFFIX)
.toString();
return relativePath.replace("\\", PATH_SEPARATOR);
}
private Path resolvePreviewStoragePath(String relativePath) {
String normalizedRelativePath = StringUtils.defaultString(relativePath).replace("/", java.io.File.separator);
Path root = resolveUploadRootPath();
Path target = root.resolve(normalizedRelativePath).toAbsolutePath().normalize();
BusinessAssert.isTrue(target.startsWith(root), CommonErrorCode.PARAM_ERROR);
return target;
}
private Path resolveUploadRootPath() {
String uploadDir = dataManagementProperties.getFileStorage().getUploadDir();
BusinessAssert.isTrue(StringUtils.isNotBlank(uploadDir), CommonErrorCode.PARAM_ERROR);
return Paths.get(uploadDir).toAbsolutePath().normalize();
}
private String nowText() {
return LocalDateTime.now().format(PREVIEW_TIME_FORMATTER);
}
public record PreviewFile(Path filePath, String fileName) {
}
}

View File

@@ -0,0 +1,11 @@
package com.datamate.datamanagement.common.enums;
/**
* 知识条目预览转换状态
*/
public enum KnowledgeItemPreviewStatus {
PENDING,
PROCESSING,
READY,
FAILED
}

View File

@@ -42,4 +42,8 @@ public class KnowledgeItem extends BaseEntity<String> {
* 相对路径(用于目录展示)
*/
private String relativePath;
/**
* 扩展元数据
*/
private String metadata;
}

View File

@@ -0,0 +1,16 @@
package com.datamate.datamanagement.interfaces.dto;
import com.datamate.datamanagement.common.enums.KnowledgeItemPreviewStatus;
import lombok.Getter;
import lombok.Setter;
/**
* 知识条目预览状态响应
*/
@Getter
@Setter
public class KnowledgeItemPreviewStatusResponse {
private KnowledgeItemPreviewStatus status;
private String previewError;
private String updatedAt;
}

View File

@@ -24,6 +24,10 @@ public class KnowledgeItemResponse {
* 相对路径(用于目录展示)
*/
private String relativePath;
/**
* 扩展元数据
*/
private String metadata;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private String createdBy;

View File

@@ -3,12 +3,14 @@ package com.datamate.datamanagement.interfaces.rest;
import com.datamate.common.infrastructure.common.IgnoreResponseWrap;
import com.datamate.common.interfaces.PagedResponse;
import com.datamate.datamanagement.application.KnowledgeItemApplicationService;
import com.datamate.datamanagement.application.KnowledgeItemPreviewService;
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.KnowledgeItemPreviewStatusResponse;
import com.datamate.datamanagement.interfaces.dto.KnowledgeItemResponse;
import com.datamate.datamanagement.interfaces.dto.ReplaceKnowledgeItemFileRequest;
import com.datamate.datamanagement.interfaces.dto.UpdateKnowledgeItemRequest;
@@ -31,6 +33,7 @@ import java.util.List;
@RequestMapping("/data-management/knowledge-sets/{setId}/items")
public class KnowledgeItemController {
private final KnowledgeItemApplicationService knowledgeItemApplicationService;
private final KnowledgeItemPreviewService knowledgeItemPreviewService;
@GetMapping
public PagedResponse<KnowledgeItemResponse> getKnowledgeItems(@PathVariable("setId") String setId,
@@ -81,6 +84,18 @@ public class KnowledgeItemController {
knowledgeItemApplicationService.previewKnowledgeItemFile(setId, itemId, response);
}
@GetMapping("/{itemId}/preview/status")
public KnowledgeItemPreviewStatusResponse getKnowledgeItemPreviewStatus(@PathVariable("setId") String setId,
@PathVariable("itemId") String itemId) {
return knowledgeItemPreviewService.getPreviewStatus(setId, itemId);
}
@PostMapping("/{itemId}/preview/convert")
public KnowledgeItemPreviewStatusResponse convertKnowledgeItemPreview(@PathVariable("setId") String setId,
@PathVariable("itemId") String itemId) {
return knowledgeItemPreviewService.ensurePreview(setId, itemId);
}
@GetMapping("/{itemId}")
public KnowledgeItemResponse getKnowledgeItemById(@PathVariable("setId") String setId,
@PathVariable("itemId") String itemId) {