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> <groupId>org.springframework.data</groupId>
<artifactId>spring-data-commons</artifactId> <artifactId>spring-data-commons</artifactId>
</dependency> </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> </dependencies>
<build> <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_PREFIX = "knowledge_set_";
private static final String EXPORT_FILE_SUFFIX = ".zip"; private static final String EXPORT_FILE_SUFFIX = ".zip";
private static final String EXPORT_CONTENT_TYPE = "application/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_FILE_BASE_LENGTH = 120;
private static final int MAX_TITLE_LENGTH = 200; private static final int MAX_TITLE_LENGTH = 200;
private static final String KNOWLEDGE_ITEM_UPLOAD_DIR = "knowledge-items"; private static final String KNOWLEDGE_ITEM_UPLOAD_DIR = "knowledge-items";
@@ -88,6 +89,7 @@ public class KnowledgeItemApplicationService {
private final DatasetFileRepository datasetFileRepository; private final DatasetFileRepository datasetFileRepository;
private final DataManagementProperties dataManagementProperties; private final DataManagementProperties dataManagementProperties;
private final TagMapper tagMapper; private final TagMapper tagMapper;
private final KnowledgeItemPreviewService knowledgeItemPreviewService;
public KnowledgeItem createKnowledgeItem(String setId, CreateKnowledgeItemRequest request) { public KnowledgeItem createKnowledgeItem(String setId, CreateKnowledgeItemRequest request) {
KnowledgeSet knowledgeSet = requireKnowledgeSet(setId); KnowledgeSet knowledgeSet = requireKnowledgeSet(setId);
@@ -371,6 +373,26 @@ public class KnowledgeItemApplicationService {
? knowledgeItem.getSourceFileId() ? knowledgeItem.getSourceFileId()
: filePath.getFileName().toString(); : 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; String contentType = null;
try { try {
contentType = Files.probeContentType(filePath); contentType = Files.probeContentType(filePath);
@@ -443,7 +465,9 @@ public class KnowledgeItemApplicationService {
knowledgeItem.setSourceType(KnowledgeSourceType.FILE_UPLOAD); knowledgeItem.setSourceType(KnowledgeSourceType.FILE_UPLOAD);
knowledgeItem.setSourceFileId(sourceFileId); knowledgeItem.setSourceFileId(sourceFileId);
knowledgeItem.setRelativePath(resolveReplacedRelativePath(knowledgeItem.getRelativePath(), sourceFileId)); knowledgeItem.setRelativePath(resolveReplacedRelativePath(knowledgeItem.getRelativePath(), sourceFileId));
knowledgeItem.setMetadata(knowledgeItemPreviewService.clearPreviewMetadata(knowledgeItem.getMetadata()));
knowledgeItemRepository.updateById(knowledgeItem); knowledgeItemRepository.updateById(knowledgeItem);
knowledgeItemPreviewService.deletePreviewFileQuietly(setId, knowledgeItem.getId());
deleteFile(oldFilePath); deleteFile(oldFilePath);
} catch (Exception e) { } catch (Exception e) {
deleteFileQuietly(targetPath); 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 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 relativePath;
/**
* 扩展元数据
*/
private String metadata;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
private String createdBy; 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.infrastructure.common.IgnoreResponseWrap;
import com.datamate.common.interfaces.PagedResponse; import com.datamate.common.interfaces.PagedResponse;
import com.datamate.datamanagement.application.KnowledgeItemApplicationService; import com.datamate.datamanagement.application.KnowledgeItemApplicationService;
import com.datamate.datamanagement.application.KnowledgeItemPreviewService;
import com.datamate.datamanagement.domain.model.knowledge.KnowledgeItem; import com.datamate.datamanagement.domain.model.knowledge.KnowledgeItem;
import com.datamate.datamanagement.interfaces.converter.KnowledgeConverter; import com.datamate.datamanagement.interfaces.converter.KnowledgeConverter;
import com.datamate.datamanagement.interfaces.dto.CreateKnowledgeItemRequest; import com.datamate.datamanagement.interfaces.dto.CreateKnowledgeItemRequest;
import com.datamate.datamanagement.interfaces.dto.DeleteKnowledgeItemsRequest; import com.datamate.datamanagement.interfaces.dto.DeleteKnowledgeItemsRequest;
import com.datamate.datamanagement.interfaces.dto.ImportKnowledgeItemsRequest; import com.datamate.datamanagement.interfaces.dto.ImportKnowledgeItemsRequest;
import com.datamate.datamanagement.interfaces.dto.KnowledgeItemPagingQuery; 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.KnowledgeItemResponse;
import com.datamate.datamanagement.interfaces.dto.ReplaceKnowledgeItemFileRequest; import com.datamate.datamanagement.interfaces.dto.ReplaceKnowledgeItemFileRequest;
import com.datamate.datamanagement.interfaces.dto.UpdateKnowledgeItemRequest; import com.datamate.datamanagement.interfaces.dto.UpdateKnowledgeItemRequest;
@@ -31,6 +33,7 @@ import java.util.List;
@RequestMapping("/data-management/knowledge-sets/{setId}/items") @RequestMapping("/data-management/knowledge-sets/{setId}/items")
public class KnowledgeItemController { public class KnowledgeItemController {
private final KnowledgeItemApplicationService knowledgeItemApplicationService; private final KnowledgeItemApplicationService knowledgeItemApplicationService;
private final KnowledgeItemPreviewService knowledgeItemPreviewService;
@GetMapping @GetMapping
public PagedResponse<KnowledgeItemResponse> getKnowledgeItems(@PathVariable("setId") String setId, public PagedResponse<KnowledgeItemResponse> getKnowledgeItems(@PathVariable("setId") String setId,
@@ -81,6 +84,18 @@ public class KnowledgeItemController {
knowledgeItemApplicationService.previewKnowledgeItemFile(setId, itemId, response); 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}") @GetMapping("/{itemId}")
public KnowledgeItemResponse getKnowledgeItemById(@PathVariable("setId") String setId, public KnowledgeItemResponse getKnowledgeItemById(@PathVariable("setId") String setId,
@PathVariable("itemId") String itemId) { @PathVariable("itemId") String itemId) {

View File

@@ -8,6 +8,7 @@ import {
Empty, Empty,
Input, Input,
Modal, Modal,
Tag,
Table, Table,
Tooltip, Tooltip,
} from "antd"; } from "antd";
@@ -21,6 +22,7 @@ import {
deleteKnowledgeItemByIdUsingDelete, deleteKnowledgeItemByIdUsingDelete,
deleteKnowledgeSetByIdUsingDelete, deleteKnowledgeSetByIdUsingDelete,
downloadKnowledgeItemFileUsingGet, downloadKnowledgeItemFileUsingGet,
convertKnowledgeItemPreviewUsingPost,
exportKnowledgeItemsUsingGet, exportKnowledgeItemsUsingGet,
queryKnowledgeDirectoriesUsingGet, queryKnowledgeDirectoriesUsingGet,
queryKnowledgeItemsUsingGet, queryKnowledgeItemsUsingGet,
@@ -61,6 +63,53 @@ const PREVIEW_MODAL_WIDTH = {
const PREVIEW_TEXT_FONT_SIZE = 12; const PREVIEW_TEXT_FONT_SIZE = 12;
const PREVIEW_TEXT_PADDING = 12; const PREVIEW_TEXT_PADDING = 12;
const PREVIEW_AUDIO_PADDING = 40; const PREVIEW_AUDIO_PADDING = 40;
const OFFICE_FILE_EXTENSIONS = [".doc", ".docx"];
type OfficePreviewStatus = "UNSET" | "PENDING" | "PROCESSING" | "READY" | "FAILED";
const OFFICE_PREVIEW_STATUS_META: Record<OfficePreviewStatus, { label: string; color: string }> = {
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 & { type KnowledgeItemRow = KnowledgeItemView & {
isDirectory?: boolean; isDirectory?: boolean;
@@ -284,20 +333,60 @@ const KnowledgeSetDetail = () => {
return "文件"; 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) => { const handlePreviewItemFile = async (record: KnowledgeItemView) => {
if (!id) return; if (!id) return;
const fileName = resolvePreviewFileName(record); 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); const fileType = resolvePreviewFileType(fileName);
if (!fileType) { if (!fileType) {
message.warning("不支持预览该文件类型"); message.warning("不支持预览该文件类型");
return; return;
} }
const previewUrl = `/api/data-management/knowledge-sets/${id}/items/${record.id}/preview`;
setPreviewFileName(fileName);
setPreviewFileType(fileType); setPreviewFileType(fileType);
setPreviewContent("");
setPreviewMediaUrl("");
if (fileType === "text") { if (fileType === "text") {
setPreviewLoadingItemId(record.id); setPreviewLoadingItemId(record.id);
@@ -669,8 +758,20 @@ const KnowledgeSetDetail = () => {
const isFileRecord = const isFileRecord =
record.contentType === KnowledgeContentType.FILE || record.contentType === KnowledgeContentType.FILE ||
record.sourceType === KnowledgeSourceType.FILE_UPLOAD; record.sourceType === KnowledgeSourceType.FILE_UPLOAD;
const officePreview = isFileRecord ? resolveOfficePreviewDisplay(record) : null;
return ( return (
<> <>
{officePreview && (
<Tooltip
title={
officePreview.status === "FAILED"
? officePreview.error || officePreview.label
: officePreview.label
}
>
<Tag color={officePreview.color}>{officePreview.label}</Tag>
</Tooltip>
)}
{isFileRecord && ( {isFileRecord && (
<Tooltip title="预览"> <Tooltip title="预览">
<Button <Button

View File

@@ -101,6 +101,16 @@ export function downloadKnowledgeItemFileUsingGet(setId: string, itemId: string,
return download(`/api/data-management/knowledge-sets/${setId}/items/${itemId}/file`, null, fileName || ""); return download(`/api/data-management/knowledge-sets/${setId}/items/${itemId}/file`, null, fileName || "");
} }
// 知识条目预览状态
export function queryKnowledgeItemPreviewStatusUsingGet(setId: string, itemId: string) {
return get(`/api/data-management/knowledge-sets/${setId}/items/${itemId}/preview/status`);
}
// 触发知识条目预览转换
export function convertKnowledgeItemPreviewUsingPost(setId: string, itemId: string) {
return post(`/api/data-management/knowledge-sets/${setId}/items/${itemId}/preview/convert`, {});
}
// 导出知识条目 // 导出知识条目
export function exportKnowledgeItemsUsingGet(setId: string) { export function exportKnowledgeItemsUsingGet(setId: string) {
return download(`/api/data-management/knowledge-sets/${setId}/items/export`); return download(`/api/data-management/knowledge-sets/${setId}/items/export`);

View File

@@ -67,7 +67,7 @@ RUN if [ -f /etc/apt/sources.list.d/debian.sources ]; then \
sed -i 's/deb.debian.org/mirrors.aliyun.com/g; s/archive.ubuntu.com/mirrors.aliyun.com/g; s/security.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list; \ sed -i 's/deb.debian.org/mirrors.aliyun.com/g; s/archive.ubuntu.com/mirrors.aliyun.com/g; s/security.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list; \
fi && \ fi && \
apt-get update && \ apt-get update && \
apt-get install -y vim wget curl rsync python3 python3-pip python-is-python3 dos2unix && \ apt-get install -y vim wget curl rsync python3 python3-pip python-is-python3 dos2unix libreoffice fonts-noto-cjk && \
apt-get clean && \ apt-get clean && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*