package jnpf.fileStorage.dms;
|
|
import jakarta.annotation.PreDestroy;
|
import jnpf.base.ActionResult;
|
import jnpf.base.ActionResultCode;
|
import jnpf.dmsEntity.preview.enums.DmsPreviewFormat;
|
import jnpf.dmsEntity.preview.model.DmsFilePreviewTicket;
|
import jnpf.exception.DataException;
|
import jnpf.onlyoffice.DmsPreviewPdfApi;
|
import jnpf.onlyoffice.DmsPreviewPdfRequest;
|
import jnpf.util.JsonUtil;
|
import jnpf.util.TicketUtil;
|
import lombok.extern.slf4j.Slf4j;
|
import org.dromara.x.file.storage.core.FileInfo;
|
import org.dromara.x.file.storage.core.FileStorageService;
|
import org.dromara.x.file.storage.core.platform.MinioFileStorage;
|
import org.springframework.beans.BeanUtils;
|
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.http.MediaType;
|
import org.springframework.stereotype.Service;
|
import org.springframework.web.multipart.MultipartFile;
|
|
import java.io.ByteArrayInputStream;
|
import java.io.File;
|
import java.io.IOException;
|
import java.io.InputStream;
|
import java.nio.file.Files;
|
import java.time.LocalDate;
|
import java.time.format.DateTimeFormatter;
|
import java.util.ArrayList;
|
import java.util.Locale;
|
import java.util.Objects;
|
import java.util.Set;
|
import java.util.UUID;
|
import java.util.concurrent.Executors;
|
import java.util.concurrent.ScheduledExecutorService;
|
import java.util.concurrent.TimeUnit;
|
import java.util.function.Consumer;
|
import java.util.function.Function;
|
|
@Slf4j
|
@Service
|
public class DmsOnlyOfficePreparedFileService {
|
private static final long MAX_SOURCE_BYTES = 100L * 1024L * 1024L;
|
private static final long CLEANUP_GRACE_MILLIS = 60_000L;
|
private static final Set<String> PDF_CONVERTIBLE_TYPES = Set.of(
|
"doc", "docm", "docx", "dot", "dotx", "odp", "ods", "odt",
|
"otp", "ots", "ott", "pot", "potx", "pps", "ppsx", "ppt",
|
"pptm", "pptx", "rtf", "txt", "xls", "xlsm", "xlsx", "xlt", "xltx");
|
|
private final FileStorageService fileStorageService;
|
private final DmsPreviewPdfApi previewPdfApi;
|
private final DmsPersistentPdfRecoveryReporter recoveryReporter;
|
private final ScheduledExecutorService cleanupExecutor;
|
private final Function<DmsFilePreviewTicket, String> sourceTicketIssuer;
|
private final Consumer<String> sourceTicketRevoker;
|
|
@Autowired
|
public DmsOnlyOfficePreparedFileService(FileStorageService fileStorageService,
|
DmsPreviewPdfApi previewPdfApi,
|
DmsPersistentPdfRecoveryReporter recoveryReporter) {
|
this(fileStorageService, previewPdfApi, recoveryReporter,
|
Executors.newSingleThreadScheduledExecutor(runnable -> {
|
Thread thread = new Thread(runnable, "dms-preview-temp-cleanup");
|
thread.setDaemon(true);
|
return thread;
|
}), ticket -> TicketUtil.createTicket(JsonUtil.getObjectToString(ticket), 300L),
|
TicketUtil::deleteTicket);
|
}
|
|
DmsOnlyOfficePreparedFileService(FileStorageService fileStorageService,
|
DmsPreviewPdfApi previewPdfApi,
|
ScheduledExecutorService cleanupExecutor,
|
Function<DmsFilePreviewTicket, String> sourceTicketIssuer,
|
Consumer<String> sourceTicketRevoker) {
|
this(fileStorageService, previewPdfApi, null, cleanupExecutor, sourceTicketIssuer,
|
sourceTicketRevoker);
|
}
|
|
DmsOnlyOfficePreparedFileService(FileStorageService fileStorageService,
|
DmsPreviewPdfApi previewPdfApi,
|
DmsPersistentPdfRecoveryReporter recoveryReporter,
|
ScheduledExecutorService cleanupExecutor,
|
Function<DmsFilePreviewTicket, String> sourceTicketIssuer,
|
Consumer<String> sourceTicketRevoker) {
|
this.fileStorageService = fileStorageService;
|
this.previewPdfApi = previewPdfApi;
|
this.recoveryReporter = recoveryReporter;
|
this.cleanupExecutor = cleanupExecutor;
|
this.sourceTicketIssuer = sourceTicketIssuer;
|
this.sourceTicketRevoker = sourceTicketRevoker;
|
}
|
|
public DmsFilePreviewTicket prepare(DmsFilePreviewTicket ticket) {
|
validate(ticket);
|
if (DmsPreviewFormat.PDF == ticket.getPreviewFormat()
|
&& reusablePersistentPdf(ticket) && noWatermarks(ticket)
|
&& persistentPdfExists(ticket)) {
|
return persistentPdf(ticket);
|
}
|
FileInfo stored = null;
|
String sourceTicket = null;
|
try {
|
DmsFilePreviewTicket delegated = new DmsFilePreviewTicket();
|
BeanUtils.copyProperties(ticket, delegated);
|
delegated.setExpiresAt(Math.min(ticket.getExpiresAt(),
|
System.currentTimeMillis() + 300_000L));
|
sourceTicket = sourceTicketIssuer.apply(delegated);
|
ActionResult<byte[]> rendered = previewPdfApi.render(
|
new DmsPreviewPdfRequest(ticket.getFileVersionId(), sourceTicket));
|
if (rendered == null || !ActionResultCode.SUCCESS.getCode().equals(rendered.getCode())
|
|| rendered.getData() == null || rendered.getData().length == 0) {
|
throw new DataException(rendered == null || rendered.getMsg() == null
|
? "预览 PDF 生成失败" : rendered.getMsg());
|
}
|
String previewName = pdfName(ticket.getFileName());
|
MultipartFile upload = new GeneratedMultipartFile(previewName, rendered.getData());
|
stored = fileStorageService.of(upload)
|
.setPlatform(ticket.getStoragePlatform())
|
.setPath("dms-preview-temp/" + LocalDate.now()
|
.format(DateTimeFormatter.BASIC_ISO_DATE) + "/")
|
.setSaveFilename(UUID.randomUUID() + ".pdf")
|
.setOriginalFilename(previewName)
|
.upload();
|
if (stored == null || stored.getSize() == null || stored.getSize() <= 0) {
|
throw new DataException("临时预览 PDF 保存失败");
|
}
|
|
MinioFileStorage storage = fileStorageService.getFileStorageVerify(stored.getPlatform());
|
DmsFilePreviewTicket result = new DmsFilePreviewTicket();
|
BeanUtils.copyProperties(ticket, result);
|
result.setFileName(previewName);
|
result.setMimeType(MediaType.APPLICATION_PDF_VALUE);
|
result.setStoragePlatform(stored.getPlatform());
|
result.setBucketName(storage.getBucketName());
|
result.setBasePath(empty(stored.getBasePath()));
|
result.setObjectPath(stored.getPath());
|
result.setObjectName(stored.getFilename());
|
result.setSizeBytes(stored.getSize());
|
result.setWatermarks(new ArrayList<>());
|
scheduleCleanup(stored, ticket.getExpiresAt());
|
return result;
|
} catch (DataException exception) {
|
deleteQuietly(stored);
|
throw exception;
|
} catch (Exception exception) {
|
deleteQuietly(stored);
|
throw new DataException("预览 PDF 生成失败");
|
} finally {
|
if (sourceTicket != null) sourceTicketRevoker.accept(sourceTicket);
|
}
|
}
|
|
private void validate(DmsFilePreviewTicket ticket) {
|
boolean customBarcode = ticket != null && ticket.getWatermarks() != null
|
&& ticket.getWatermarks().stream()
|
.anyMatch(mark -> mark != null && Boolean.TRUE.equals(mark.getCustomBarcode()));
|
boolean pdfRequested = ticket != null && DmsPreviewFormat.PDF == ticket.getPreviewFormat();
|
if (ticket == null || ticket.getFileVersionId() == null
|
|| blank(ticket.getTenantId()) || blank(ticket.getUserId())
|
|| blank(ticket.getFileName())
|
|| !PDF_CONVERTIBLE_TYPES.contains(extension(ticket.getFileName()))
|
|| blank(ticket.getStoragePlatform()) || blank(ticket.getBucketName())
|
|| blank(ticket.getObjectPath()) || blank(ticket.getObjectName())
|
|| ticket.getExpiresAt() <= System.currentTimeMillis()
|
|| ticket.getSizeBytes() != null && ticket.getSizeBytes() > MAX_SOURCE_BYTES
|
|| !pdfRequested && !customBarcode
|
|| customBarcode && !"docx".equals(extension(ticket.getFileName()))) {
|
throw new DataException("预览 PDF 生成参数无效");
|
}
|
MinioFileStorage storage = fileStorageService.getFileStorageVerify(ticket.getStoragePlatform());
|
if (!Objects.equals(storage.getBucketName(), ticket.getBucketName())) {
|
throw new DataException("指定页水印文件存储桶无效");
|
}
|
}
|
|
private void scheduleCleanup(FileInfo stored, long expiresAt) {
|
long delay = Math.max(CLEANUP_GRACE_MILLIS,
|
expiresAt - System.currentTimeMillis() + CLEANUP_GRACE_MILLIS);
|
cleanupExecutor.schedule(() -> deleteQuietly(stored), delay, TimeUnit.MILLISECONDS);
|
}
|
|
private boolean reusablePersistentPdf(DmsFilePreviewTicket ticket) {
|
return ticket.getPdfSource() != null
|
&& !blank(ticket.getPdfSource().getObjectKey())
|
&& ticket.getPdfSource().getObjectKey().startsWith("dms-derived/pdf/")
|
&& !ticket.getPdfSource().getObjectKey().contains("..")
|
&& ticket.getPdfSource().getSizeBytes() != null
|
&& ticket.getPdfSource().getSizeBytes() > 0;
|
}
|
|
private boolean noWatermarks(DmsFilePreviewTicket ticket) {
|
return ticket.getWatermarks() == null || ticket.getWatermarks().isEmpty();
|
}
|
|
private boolean persistentPdfExists(DmsFilePreviewTicket ticket) {
|
String key = ticket.getPdfSource().getObjectKey();
|
int slash = key.lastIndexOf('/');
|
if (slash < 0 || slash == key.length() - 1) return false;
|
FileInfo file = new FileInfo()
|
.setPlatform(ticket.getStoragePlatform())
|
.setBasePath(empty(ticket.getBasePath()))
|
.setPath(key.substring(0, slash + 1))
|
.setFilename(key.substring(slash + 1));
|
try {
|
boolean exists = fileStorageService.exists(file);
|
if (!exists) reportInvalid(ticket, "持久化 PDF 对象不存在");
|
return exists;
|
} catch (RuntimeException exception) {
|
log.warn("检查 DMS 持久化 PDF 失败,回退临时转换: fileVersionId={}, objectKey={}",
|
ticket.getFileVersionId(), key, exception);
|
reportInvalid(ticket, "持久化 PDF 对象检查失败");
|
return false;
|
}
|
}
|
|
private void reportInvalid(DmsFilePreviewTicket ticket, String reason) {
|
if (recoveryReporter != null) recoveryReporter.report(ticket, reason);
|
}
|
|
private DmsFilePreviewTicket persistentPdf(DmsFilePreviewTicket ticket) {
|
String key = ticket.getPdfSource().getObjectKey();
|
int slash = key.lastIndexOf('/');
|
if (slash < 0 || slash == key.length() - 1) throw new DataException("持久化 PDF 对象键无效");
|
DmsFilePreviewTicket result = new DmsFilePreviewTicket();
|
BeanUtils.copyProperties(ticket, result);
|
result.setFileName(pdfName(ticket.getFileName()));
|
result.setMimeType(MediaType.APPLICATION_PDF_VALUE);
|
result.setSizeBytes(ticket.getPdfSource().getSizeBytes());
|
result.setObjectPath(key.substring(0, slash + 1));
|
result.setObjectName(key.substring(slash + 1));
|
result.setPdfSource(null);
|
result.setWatermarks(new ArrayList<>());
|
return result;
|
}
|
|
private void deleteQuietly(FileInfo stored) {
|
if (stored == null) return;
|
try {
|
if (!fileStorageService.delete(stored)) {
|
log.warn("DMS 临时预览文件删除失败: platform={}, path={}, filename={}",
|
stored.getPlatform(), stored.getPath(), stored.getFilename());
|
}
|
} catch (Exception exception) {
|
log.warn("DMS 临时预览文件删除异常: platform={}, path={}, filename={}",
|
stored.getPlatform(), stored.getPath(), stored.getFilename(), exception);
|
}
|
}
|
|
@PreDestroy
|
void shutdown() {
|
cleanupExecutor.shutdownNow();
|
}
|
|
private String empty(String value) { return value == null ? "" : value; }
|
private boolean blank(String value) { return value == null || value.trim().isEmpty(); }
|
private String extension(String fileName) {
|
int dot = fileName == null ? -1 : fileName.lastIndexOf('.');
|
return dot < 0 ? "" : fileName.substring(dot + 1).toLowerCase(Locale.ROOT);
|
}
|
private String pdfName(String fileName) {
|
int dot = fileName.lastIndexOf('.');
|
return (dot > 0 ? fileName.substring(0, dot) : fileName) + ".pdf";
|
}
|
|
private static final class GeneratedMultipartFile implements MultipartFile {
|
private final String originalFilename;
|
private final byte[] content;
|
|
private GeneratedMultipartFile(String originalFilename, byte[] content) {
|
this.originalFilename = originalFilename;
|
this.content = content;
|
}
|
|
@Override public String getName() { return "file"; }
|
@Override public String getOriginalFilename() { return originalFilename; }
|
@Override public String getContentType() { return MediaType.APPLICATION_PDF_VALUE; }
|
@Override public boolean isEmpty() { return content.length == 0; }
|
@Override public long getSize() { return content.length; }
|
@Override public byte[] getBytes() { return content.clone(); }
|
@Override public InputStream getInputStream() { return new ByteArrayInputStream(content); }
|
@Override public void transferTo(File destination) throws IOException {
|
Files.write(destination.toPath(), content);
|
}
|
}
|
}
|