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 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 sourceTicketIssuer; private final Consumer 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 sourceTicketIssuer, Consumer sourceTicketRevoker) { this(fileStorageService, previewPdfApi, null, cleanupExecutor, sourceTicketIssuer, sourceTicketRevoker); } DmsOnlyOfficePreparedFileService(FileStorageService fileStorageService, DmsPreviewPdfApi previewPdfApi, DmsPersistentPdfRecoveryReporter recoveryReporter, ScheduledExecutorService cleanupExecutor, Function sourceTicketIssuer, Consumer 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 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); } } }