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>
|
<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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 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 relativePath;
|
||||||
|
/**
|
||||||
|
* 扩展元数据
|
||||||
|
*/
|
||||||
|
private String metadata;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
private String createdBy;
|
private String createdBy;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
|||||||
@@ -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/*
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user