You've already forked DataMate
feat(knowledge): 添加知识库条目预览功能
- 集成 docx4j 和 LibreOffice 实现 Office 文档转 PDF 预览 - 新增 KnowledgeItemPreviewService 处理预览转换逻辑 - 添加异步任务 KnowledgeItemPreviewAsyncService 进行文档转换 - 实现预览状态管理包括待转换、转换中、就绪和失败状态 - 在前端界面添加 Office 文档预览状态标签显示 - 支持 DOC/DOCX 文件在线预览功能 - 添加预览元数据存储和管理机制
This commit is contained in:
@@ -60,6 +60,16 @@
|
||||
<groupId>org.springframework.data</groupId>
|
||||
<artifactId>spring-data-commons</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.docx4j</groupId>
|
||||
<artifactId>docx4j-core</artifactId>
|
||||
<version>11.4.9</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.docx4j</groupId>
|
||||
<artifactId>docx4j-export-fo</artifactId>
|
||||
<version>11.4.9</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.datamate.datamanagement.common.enums;
|
||||
|
||||
/**
|
||||
* 知识条目预览转换状态
|
||||
*/
|
||||
public enum KnowledgeItemPreviewStatus {
|
||||
PENDING,
|
||||
PROCESSING,
|
||||
READY,
|
||||
FAILED
|
||||
}
|
||||
@@ -42,4 +42,8 @@ public class KnowledgeItem extends BaseEntity<String> {
|
||||
* 相对路径(用于目录展示)
|
||||
*/
|
||||
private String relativePath;
|
||||
/**
|
||||
* 扩展元数据
|
||||
*/
|
||||
private String metadata;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -24,6 +24,10 @@ public class KnowledgeItemResponse {
|
||||
* 相对路径(用于目录展示)
|
||||
*/
|
||||
private String relativePath;
|
||||
/**
|
||||
* 扩展元数据
|
||||
*/
|
||||
private String metadata;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
private String createdBy;
|
||||
|
||||
@@ -3,12 +3,14 @@ package com.datamate.datamanagement.interfaces.rest;
|
||||
import com.datamate.common.infrastructure.common.IgnoreResponseWrap;
|
||||
import com.datamate.common.interfaces.PagedResponse;
|
||||
import com.datamate.datamanagement.application.KnowledgeItemApplicationService;
|
||||
import com.datamate.datamanagement.application.KnowledgeItemPreviewService;
|
||||
import com.datamate.datamanagement.domain.model.knowledge.KnowledgeItem;
|
||||
import com.datamate.datamanagement.interfaces.converter.KnowledgeConverter;
|
||||
import com.datamate.datamanagement.interfaces.dto.CreateKnowledgeItemRequest;
|
||||
import com.datamate.datamanagement.interfaces.dto.DeleteKnowledgeItemsRequest;
|
||||
import com.datamate.datamanagement.interfaces.dto.ImportKnowledgeItemsRequest;
|
||||
import com.datamate.datamanagement.interfaces.dto.KnowledgeItemPagingQuery;
|
||||
import com.datamate.datamanagement.interfaces.dto.KnowledgeItemPreviewStatusResponse;
|
||||
import com.datamate.datamanagement.interfaces.dto.KnowledgeItemResponse;
|
||||
import com.datamate.datamanagement.interfaces.dto.ReplaceKnowledgeItemFileRequest;
|
||||
import com.datamate.datamanagement.interfaces.dto.UpdateKnowledgeItemRequest;
|
||||
@@ -31,6 +33,7 @@ import java.util.List;
|
||||
@RequestMapping("/data-management/knowledge-sets/{setId}/items")
|
||||
public class KnowledgeItemController {
|
||||
private final KnowledgeItemApplicationService knowledgeItemApplicationService;
|
||||
private final KnowledgeItemPreviewService knowledgeItemPreviewService;
|
||||
|
||||
@GetMapping
|
||||
public PagedResponse<KnowledgeItemResponse> getKnowledgeItems(@PathVariable("setId") String setId,
|
||||
@@ -81,6 +84,18 @@ public class KnowledgeItemController {
|
||||
knowledgeItemApplicationService.previewKnowledgeItemFile(setId, itemId, response);
|
||||
}
|
||||
|
||||
@GetMapping("/{itemId}/preview/status")
|
||||
public KnowledgeItemPreviewStatusResponse getKnowledgeItemPreviewStatus(@PathVariable("setId") String setId,
|
||||
@PathVariable("itemId") String itemId) {
|
||||
return knowledgeItemPreviewService.getPreviewStatus(setId, itemId);
|
||||
}
|
||||
|
||||
@PostMapping("/{itemId}/preview/convert")
|
||||
public KnowledgeItemPreviewStatusResponse convertKnowledgeItemPreview(@PathVariable("setId") String setId,
|
||||
@PathVariable("itemId") String itemId) {
|
||||
return knowledgeItemPreviewService.ensurePreview(setId, itemId);
|
||||
}
|
||||
|
||||
@GetMapping("/{itemId}")
|
||||
public KnowledgeItemResponse getKnowledgeItemById(@PathVariable("setId") String setId,
|
||||
@PathVariable("itemId") String itemId) {
|
||||
|
||||
Reference in New Issue
Block a user