diff --git a/backend/services/data-management-service/pom.xml b/backend/services/data-management-service/pom.xml
index 581635c..a085bc2 100644
--- a/backend/services/data-management-service/pom.xml
+++ b/backend/services/data-management-service/pom.xml
@@ -60,6 +60,16 @@
org.springframework.data
spring-data-commons
+
+ org.docx4j
+ docx4j-core
+ 11.4.9
+
+
+ org.docx4j
+ docx4j-export-fo
+ 11.4.9
+
diff --git a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/application/KnowledgeItemApplicationService.java b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/application/KnowledgeItemApplicationService.java
index 77b7d34..d98c9d8 100644
--- a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/application/KnowledgeItemApplicationService.java
+++ b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/application/KnowledgeItemApplicationService.java
@@ -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);
diff --git a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/application/KnowledgeItemPreviewAsyncService.java b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/application/KnowledgeItemPreviewAsyncService.java
new file mode 100644
index 0000000..94eb0fa
--- /dev/null
+++ b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/application/KnowledgeItemPreviewAsyncService.java
@@ -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 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 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);
+ }
+}
diff --git a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/application/KnowledgeItemPreviewMetadataHelper.java b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/application/KnowledgeItemPreviewMetadataHelper.java
new file mode 100644
index 0000000..c4e3d3f
--- /dev/null
+++ b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/application/KnowledgeItemPreviewMetadataHelper.java
@@ -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);
+ }
+ }
+}
diff --git a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/application/KnowledgeItemPreviewService.java b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/application/KnowledgeItemPreviewService.java
new file mode 100644
index 0000000..94c9d92
--- /dev/null
+++ b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/application/KnowledgeItemPreviewService.java
@@ -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 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) {
+ }
+}
diff --git a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/common/enums/KnowledgeItemPreviewStatus.java b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/common/enums/KnowledgeItemPreviewStatus.java
new file mode 100644
index 0000000..9d643e9
--- /dev/null
+++ b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/common/enums/KnowledgeItemPreviewStatus.java
@@ -0,0 +1,11 @@
+package com.datamate.datamanagement.common.enums;
+
+/**
+ * 知识条目预览转换状态
+ */
+public enum KnowledgeItemPreviewStatus {
+ PENDING,
+ PROCESSING,
+ READY,
+ FAILED
+}
diff --git a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/domain/model/knowledge/KnowledgeItem.java b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/domain/model/knowledge/KnowledgeItem.java
index f552300..f9ca16a 100644
--- a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/domain/model/knowledge/KnowledgeItem.java
+++ b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/domain/model/knowledge/KnowledgeItem.java
@@ -42,4 +42,8 @@ public class KnowledgeItem extends BaseEntity {
* 相对路径(用于目录展示)
*/
private String relativePath;
+ /**
+ * 扩展元数据
+ */
+ private String metadata;
}
diff --git a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/interfaces/dto/KnowledgeItemPreviewStatusResponse.java b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/interfaces/dto/KnowledgeItemPreviewStatusResponse.java
new file mode 100644
index 0000000..c7e637c
--- /dev/null
+++ b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/interfaces/dto/KnowledgeItemPreviewStatusResponse.java
@@ -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;
+}
diff --git a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/interfaces/dto/KnowledgeItemResponse.java b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/interfaces/dto/KnowledgeItemResponse.java
index 8afbfad..067b680 100644
--- a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/interfaces/dto/KnowledgeItemResponse.java
+++ b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/interfaces/dto/KnowledgeItemResponse.java
@@ -24,6 +24,10 @@ public class KnowledgeItemResponse {
* 相对路径(用于目录展示)
*/
private String relativePath;
+ /**
+ * 扩展元数据
+ */
+ private String metadata;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private String createdBy;
diff --git a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/interfaces/rest/KnowledgeItemController.java b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/interfaces/rest/KnowledgeItemController.java
index 34663f9..819299f 100644
--- a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/interfaces/rest/KnowledgeItemController.java
+++ b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/interfaces/rest/KnowledgeItemController.java
@@ -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 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) {
diff --git a/frontend/src/pages/KnowledgeManagement/Detail/KnowledgeSetDetail.tsx b/frontend/src/pages/KnowledgeManagement/Detail/KnowledgeSetDetail.tsx
index df122b1..dd77586 100644
--- a/frontend/src/pages/KnowledgeManagement/Detail/KnowledgeSetDetail.tsx
+++ b/frontend/src/pages/KnowledgeManagement/Detail/KnowledgeSetDetail.tsx
@@ -8,6 +8,7 @@ import {
Empty,
Input,
Modal,
+ Tag,
Table,
Tooltip,
} from "antd";
@@ -21,6 +22,7 @@ import {
deleteKnowledgeItemByIdUsingDelete,
deleteKnowledgeSetByIdUsingDelete,
downloadKnowledgeItemFileUsingGet,
+ convertKnowledgeItemPreviewUsingPost,
exportKnowledgeItemsUsingGet,
queryKnowledgeDirectoriesUsingGet,
queryKnowledgeItemsUsingGet,
@@ -61,6 +63,53 @@ const PREVIEW_MODAL_WIDTH = {
const PREVIEW_TEXT_FONT_SIZE = 12;
const PREVIEW_TEXT_PADDING = 12;
const PREVIEW_AUDIO_PADDING = 40;
+const OFFICE_FILE_EXTENSIONS = [".doc", ".docx"];
+
+type OfficePreviewStatus = "UNSET" | "PENDING" | "PROCESSING" | "READY" | "FAILED";
+
+const OFFICE_PREVIEW_STATUS_META: Record = {
+ UNSET: { label: "未转换", color: "default" },
+ PENDING: { label: "待转换", color: "default" },
+ PROCESSING: { label: "转换中", color: "processing" },
+ READY: { label: "可预览", color: "success" },
+ FAILED: { label: "转换失败", color: "error" },
+};
+
+type OfficePreviewMetadata = {
+ previewStatus?: string;
+ previewError?: string;
+ previewUpdatedAt?: string;
+ previewPdfPath?: string;
+};
+
+const isOfficeFileName = (fileName?: string) => {
+ const lowerName = (fileName || "").toLowerCase();
+ return OFFICE_FILE_EXTENSIONS.some((ext) => lowerName.endsWith(ext));
+};
+
+const parseOfficePreviewMetadata = (metadata?: string): OfficePreviewMetadata => {
+ if (!metadata) {
+ return {};
+ }
+ try {
+ const parsed = JSON.parse(metadata) as OfficePreviewMetadata;
+ return parsed || {};
+ } catch (error) {
+ console.warn("解析预览元数据失败", error);
+ return {};
+ }
+};
+
+const normalizeOfficePreviewStatus = (status?: string): OfficePreviewStatus => {
+ if (!status) {
+ return "UNSET";
+ }
+ const upper = status.toUpperCase();
+ if (upper === "PENDING" || upper === "PROCESSING" || upper === "READY" || upper === "FAILED") {
+ return upper as OfficePreviewStatus;
+ }
+ return "UNSET";
+};
type KnowledgeItemRow = KnowledgeItemView & {
isDirectory?: boolean;
@@ -284,20 +333,60 @@ const KnowledgeSetDetail = () => {
return "文件";
};
+ const resolveOfficePreviewDisplay = (record: KnowledgeItemView) => {
+ const fileName = resolvePreviewFileName(record);
+ if (!isOfficeFileName(fileName)) {
+ return null;
+ }
+ const previewMetadata = parseOfficePreviewMetadata(record.metadata);
+ const status = normalizeOfficePreviewStatus(previewMetadata.previewStatus);
+ const meta = OFFICE_PREVIEW_STATUS_META[status];
+ return {
+ status,
+ label: meta.label,
+ color: meta.color,
+ error: previewMetadata.previewError,
+ };
+ };
+
const handlePreviewItemFile = async (record: KnowledgeItemView) => {
if (!id) return;
const fileName = resolvePreviewFileName(record);
+ const previewUrl = `/api/data-management/knowledge-sets/${id}/items/${record.id}/preview`;
+ setPreviewFileName(fileName);
+ setPreviewContent("");
+ setPreviewMediaUrl("");
+
+ if (isOfficeFileName(fileName)) {
+ setPreviewFileType("pdf");
+ setPreviewLoadingItemId(record.id);
+ try {
+ const { data } = await convertKnowledgeItemPreviewUsingPost(id, record.id);
+ const status = normalizeOfficePreviewStatus(data?.status);
+ if (status === "READY") {
+ setPreviewMediaUrl(previewUrl);
+ setPreviewVisible(true);
+ } else if (status === "FAILED") {
+ message.error(data?.previewError || "转换失败,请稍后重试");
+ } else {
+ message.info("已开始转换,请稍后重试");
+ }
+ fetchItems();
+ } catch (error) {
+ console.error("触发预览转换失败", error);
+ message.error("触发预览转换失败");
+ } finally {
+ setPreviewLoadingItemId(null);
+ }
+ return;
+ }
+
const fileType = resolvePreviewFileType(fileName);
if (!fileType) {
message.warning("不支持预览该文件类型");
return;
}
-
- const previewUrl = `/api/data-management/knowledge-sets/${id}/items/${record.id}/preview`;
- setPreviewFileName(fileName);
setPreviewFileType(fileType);
- setPreviewContent("");
- setPreviewMediaUrl("");
if (fileType === "text") {
setPreviewLoadingItemId(record.id);
@@ -669,8 +758,20 @@ const KnowledgeSetDetail = () => {
const isFileRecord =
record.contentType === KnowledgeContentType.FILE ||
record.sourceType === KnowledgeSourceType.FILE_UPLOAD;
+ const officePreview = isFileRecord ? resolveOfficePreviewDisplay(record) : null;
return (
<>
+ {officePreview && (
+
+ {officePreview.label}
+
+ )}
{isFileRecord && (