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 && (