package jnpf.fileStorage.dms; import jakarta.servlet.http.HttpServletResponse; import jnpf.dms.DmsTicketLifecycleApi; import jnpf.dms.DmsTicketLifecycleChecks; import jnpf.dmsEntity.preview.enums.DmsWatermarkTargetType; import jnpf.dmsEntity.preview.model.DmsFilePreviewTicket; import jnpf.dmsEntity.preview.model.DmsWatermarkSnapshot; import jnpf.exception.DataException; import jnpf.util.TicketUtil; 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.factory.annotation.Autowired; import org.springframework.http.ContentDisposition; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.Objects; import java.util.UUID; import java.util.stream.Collectors; @Service public class DmsOnlyOfficeFileService { private static final String DMS_STORAGE_PLATFORM = "seaweedfs-1"; private static final long MAX_WATERMARK_SOURCE_BYTES = 100L * 1024L * 1024L; private final FileStorageService fileStorageService; private final TicketReader ticketReader; private final DmsFileWatermarkService watermarkService; private final DmsPersistentPdfRecoveryReporter recoveryReporter; @Autowired public DmsOnlyOfficeFileService(FileStorageService fileStorageService, DmsFileWatermarkService watermarkService, DmsPersistentPdfRecoveryReporter recoveryReporter, DmsTicketLifecycleApi lifecycleApi) { this(fileStorageService, ticket -> DmsTicketLifecycleChecks.preview(TicketUtil.parseTicket(ticket), lifecycleApi, "jnpf-file"), watermarkService, recoveryReporter); } DmsOnlyOfficeFileService(FileStorageService fileStorageService, TicketReader ticketReader, DmsFileWatermarkService watermarkService) { this(fileStorageService, ticketReader, watermarkService, null); } DmsOnlyOfficeFileService(FileStorageService fileStorageService, TicketReader ticketReader, DmsFileWatermarkService watermarkService, DmsPersistentPdfRecoveryReporter recoveryReporter) { this.fileStorageService = fileStorageService; this.ticketReader = ticketReader; this.watermarkService = watermarkService; this.recoveryReporter = recoveryReporter; } public void stream(UUID fileVersionId, String accessTicket, HttpServletResponse response) { stream(fileVersionId, accessTicket, false, response); } public void stream(UUID fileVersionId, String accessTicket, boolean printBase, HttpServletResponse response) { DmsFilePreviewTicket payload = parse(accessTicket); if (!fileVersionId.equals(payload.getFileVersionId())) { throw new DataException("DMS 预览票据与文件版本不匹配"); } if (!DMS_STORAGE_PLATFORM.equals(payload.getStoragePlatform())) { throw new DataException("DMS 预览票据的存储平台无效"); } if (printBase) { if (!DmsFilePreviewTicket.PRINT_BASE_SCOPE.equals(payload.getScope())) { throw new DataException("只有内部打印基础票据可以读取基础源文件"); } DmsFilePreviewTicket base = new DmsFilePreviewTicket(); org.springframework.beans.BeanUtils.copyProperties(payload, base); base.setWatermarks((payload.getWatermarks() == null ? Collections.emptyList() : payload.getWatermarks()).stream() .filter(mark -> mark != null && !Boolean.TRUE.equals(mark.getCustomBarcode())) .collect(Collectors.toList())); payload = base; } else if (DmsFilePreviewTicket.PRINT_BASE_SCOPE.equals(payload.getScope())) { throw new DataException("内部打印基础票据只能读取基础源文件"); } if (printBase && reusablePersistentPdf(payload)) { DmsFilePreviewTicket persistent = persistentPdf(payload); if (persistentPdfExists(persistent)) { payload = persistent; } else { reportInvalid(payload, "持久化 PDF 对象不存在或检查失败"); } } MinioFileStorage storage = fileStorageService.getFileStorageVerify(payload.getStoragePlatform()); if (!Objects.equals(storage.getBucketName(), payload.getBucketName())) { throw new DataException("DMS 预览票据的存储桶与当前配置不匹配"); } FileInfo fileInfo = new FileInfo() .setPlatform(payload.getStoragePlatform()) .setBasePath(empty(payload.getBasePath())) .setPath(payload.getObjectPath()) .setFilename(payload.getObjectName()) .setOriginalFilename(payload.getFileName()) .setContentType(payload.getMimeType()) .setSize(payload.getSizeBytes()); response.setContentType(isBlank(payload.getMimeType()) ? MediaType.APPLICATION_OCTET_STREAM_VALUE : payload.getMimeType()); response.setHeader(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline() .filename(payload.getFileName(), StandardCharsets.UTF_8).build().toString()); response.setHeader(HttpHeaders.CACHE_CONTROL, "private, no-store"); response.setHeader("X-Content-Type-Options", "nosniff"); try { if (payload.getWatermarks() == null || payload.getWatermarks().isEmpty()) { if (payload.getSizeBytes() != null && payload.getSizeBytes() >= 0) { response.setContentLengthLong(payload.getSizeBytes()); } fileStorageService.download(fileInfo).outputStream(response.getOutputStream()); return; } DmsWatermarkTargetType targetType = DmsWatermarkTargetType.from( payload.getFileName(), payload.getMimeType()); if (targetType == DmsWatermarkTargetType.UNSUPPORTED) { throw new DataException("该文件格式不支持动态水印"); } if (targetType == DmsWatermarkTargetType.IMAGE) { response.setContentType(DmsImageWatermarkService.contentType( payload.getFileName(), payload.getMimeType())); } long sourceLimit = targetType == DmsWatermarkTargetType.IMAGE ? DmsImageWatermarkService.MAX_SOURCE_BYTES : MAX_WATERMARK_SOURCE_BYTES; if (payload.getSizeBytes() != null && payload.getSizeBytes() > sourceLimit) { throw new DataException(targetType == DmsWatermarkTargetType.IMAGE ? "图片超过动态水印处理上限(50 MB)" : "DOCX 文件超过动态水印处理上限(100 MB)"); } byte[] result = watermarkService.loadAndApply(() -> { DmsBoundedByteArrayOutputStream source = new DmsBoundedByteArrayOutputStream(sourceLimit, targetType == DmsWatermarkTargetType.IMAGE ? "图片超过动态水印处理上限(50 MB)" : "DOCX 文件超过动态水印处理上限(100 MB)"); fileStorageService.download(fileInfo).outputStream(source); return source.toByteArrayAndRelease(); }, payload); response.setContentLengthLong(result.length); response.getOutputStream().write(result); } catch (DataException exception) { if (!response.isCommitted()) response.reset(); throw exception; } catch (Exception exception) { if (!response.isCommitted()) response.reset(); throw new DataException("读取 DMS 文件失败:" + exception.getMessage()); } } private void reportInvalid(DmsFilePreviewTicket ticket, String reason) { if (recoveryReporter != null) recoveryReporter.report(ticket, reason); } DmsFilePreviewTicket parse(String accessTicket) { if (isBlank(accessTicket)) { throw new DataException("缺少 DMS 预览票据"); } try { return DmsFilePreviewTicket.fromRaw(ticketReader.read(accessTicket)); } catch (RuntimeException exception) { throw new DataException("DMS 预览票据无效或已过期"); } } private boolean reusablePersistentPdf(DmsFilePreviewTicket ticket) { if (ticket.getPdfSource() == null || ticket.getPdfSource().getObjectKey() == null || !ticket.getPdfSource().getObjectKey().startsWith("dms-derived/pdf/") || ticket.getPdfSource().getObjectKey().contains("..")) return false; return ticket.getWatermarks() == null || ticket.getWatermarks().stream() .allMatch(mark -> mark != null && Boolean.TRUE.equals(mark.getCustomBarcode())); } 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(); org.springframework.beans.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(Collections.emptyList()); return result; } private boolean persistentPdfExists(DmsFilePreviewTicket ticket) { FileInfo file = new FileInfo() .setPlatform(ticket.getStoragePlatform()) .setBasePath(empty(ticket.getBasePath())) .setPath(ticket.getObjectPath()) .setFilename(ticket.getObjectName()); try { return fileStorageService.exists(file); } catch (RuntimeException exception) { return false; } } private String pdfName(String fileName) { int dot = fileName == null ? -1 : fileName.lastIndexOf('.'); return (dot > 0 ? fileName.substring(0, dot) : fileName) + ".pdf"; } private String empty(String value) { return value == null ? "" : value; } private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); } @FunctionalInterface interface TicketReader { Object read(String ticket); } }