package jnpf.fileStorage.dms; import jakarta.servlet.http.HttpServletResponse; import jnpf.dms.DmsTicketLifecycleApi; import jnpf.dms.DmsTicketLifecycleChecks; import jnpf.dmsEntity.download.model.DmsFileDownloadTicket; import jnpf.dmsEntity.preview.enums.DmsWatermarkTargetType; import jnpf.exception.DataException; import jnpf.base.ActionResult; import jnpf.onlyoffice.DmsDownloadPdfApi; import jnpf.onlyoffice.DmsDownloadPdfRequest; 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.io.ByteArrayOutputStream; import java.nio.charset.StandardCharsets; import java.util.Locale; import java.util.Objects; import java.util.UUID; @Service public class DmsFileDownloadStorageService { 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 DmsDownloadPdfApi pdfRenderer; private final DmsPersistentPdfRecoveryReporter recoveryReporter; @Autowired public DmsFileDownloadStorageService(FileStorageService fileStorageService, DmsFileWatermarkService watermarkService, DmsDownloadPdfApi pdfRenderer, DmsPersistentPdfRecoveryReporter recoveryReporter, DmsTicketLifecycleApi lifecycleApi) { this(fileStorageService, ticket -> DmsTicketLifecycleChecks.download(TicketUtil.parseTicket(ticket), lifecycleApi, "jnpf-file"), watermarkService, pdfRenderer, recoveryReporter); } DmsFileDownloadStorageService(FileStorageService fileStorageService, TicketReader ticketReader, DmsFileWatermarkService watermarkService, DmsDownloadPdfApi pdfRenderer) { this(fileStorageService, ticketReader, watermarkService, pdfRenderer, null); } DmsFileDownloadStorageService(FileStorageService fileStorageService, TicketReader ticketReader, DmsFileWatermarkService watermarkService, DmsDownloadPdfApi pdfRenderer, DmsPersistentPdfRecoveryReporter recoveryReporter) { this.fileStorageService = fileStorageService; this.ticketReader = ticketReader; this.watermarkService = watermarkService; this.pdfRenderer = pdfRenderer; this.recoveryReporter = recoveryReporter; } public void stream(UUID fileVersionId, String accessTicket, HttpServletResponse response) { stream(fileVersionId, accessTicket, null, response); } public void stream(UUID fileVersionId, String accessTicket, String format, HttpServletResponse response) { DmsFileDownloadTicket payload = parse(accessTicket); if (!fileVersionId.equals(payload.getFileVersionId())) { throw new DataException("DMS 下载票据与文件版本不匹配"); } if (!DMS_STORAGE_PLATFORM.equals(payload.getStoragePlatform())) { throw new DataException("DMS 下载票据的存储平台无效"); } boolean pdf = !isBlank(format); if (pdf && !"pdf".equalsIgnoreCase(format.trim())) { throw new DataException("不支持的下载格式,format 仅支持 pdf"); } String lowerName = payload.getFileName().toLowerCase(Locale.ROOT); if (pdf && !lowerName.endsWith(".doc") && !lowerName.endsWith(".docx") && !lowerName.endsWith(".pdf")) { throw new DataException("仅支持将 Word 文件转换为 PDF 下载"); } MinioFileStorage storage = fileStorageService.getFileStorageVerify(payload.getStoragePlatform()); if (!Objects.equals(storage.getBucketName(), payload.getBucketName())) { throw new DataException("DMS 下载票据的存储桶与当前配置不匹配"); } if (pdf && (lowerName.endsWith(".doc") || lowerName.endsWith(".docx")) && reusablePersistentPdf(payload)) { if (streamPersistentPdf(payload, response)) return; } if (pdf && (lowerName.endsWith(".doc") || lowerName.endsWith(".docx"))) { streamPdf(payload, accessTicket, response); return; } 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(pdf ? MediaType.APPLICATION_PDF_VALUE : isBlank(payload.getMimeType()) ? MediaType.APPLICATION_OCTET_STREAM_VALUE : payload.getMimeType()); response.setHeader(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() .filename(payload.getFileName(), StandardCharsets.UTF_8).build().toString()); response.setHeader(HttpHeaders.CACHE_CONTROL, "no-store"); 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 streamPdf(DmsFileDownloadTicket payload, String accessTicket, HttpServletResponse response) { try { ActionResult result = pdfRenderer.render( new DmsDownloadPdfRequest(payload.getFileVersionId(), accessTicket)); if (result == null || result.getCode() != 200 || result.getData() == null || result.getData().length == 0) { throw new DataException(result == null || isBlank(result.getMsg()) ? "DOCX 转换为 PDF 失败" : result.getMsg()); } byte[] bytes = result.getData(); String fileName = payload.getFileName(); fileName = fileName.substring(0, fileName.lastIndexOf('.')) + ".pdf"; // Only commit download headers once conversion has completed successfully. response.setContentType(MediaType.APPLICATION_PDF_VALUE); response.setHeader(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() .filename(fileName, StandardCharsets.UTF_8).build().toString()); response.setHeader(HttpHeaders.CACHE_CONTROL, "no-store"); response.setContentLengthLong(bytes.length); response.getOutputStream().write(bytes); } catch (DataException exception) { throw exception; } catch (Exception exception) { throw new DataException("DOCX 转换为 PDF 下载失败,请稍后重试"); } } private boolean reusablePersistentPdf(DmsFileDownloadTicket payload) { return (payload.getWatermarks() == null || payload.getWatermarks().isEmpty()) && payload.getPdfSource() != null && payload.getPdfSource().getObjectKey() != null && payload.getPdfSource().getObjectKey().startsWith("dms-derived/pdf/") && !payload.getPdfSource().getObjectKey().contains("..") && payload.getPdfSource().getSizeBytes() != null && payload.getPdfSource().getSizeBytes() > 0; } private boolean streamPersistentPdf(DmsFileDownloadTicket payload, HttpServletResponse response) { String key = payload.getPdfSource().getObjectKey(); int slash = key.lastIndexOf('/'); if (slash < 0 || slash == key.length() - 1) return false; String original = payload.getFileName(); int dot = original.lastIndexOf('.'); String fileName = (dot > 0 ? original.substring(0, dot) : original) + ".pdf"; FileInfo fileInfo = new FileInfo() .setPlatform(payload.getStoragePlatform()) .setBasePath(empty(payload.getBasePath())) .setPath(key.substring(0, slash + 1)) .setFilename(key.substring(slash + 1)) .setOriginalFilename(fileName) .setContentType(MediaType.APPLICATION_PDF_VALUE) .setSize(payload.getPdfSource().getSizeBytes()); try { if (!fileStorageService.exists(fileInfo)) { reportInvalid(payload, "持久化 PDF 对象不存在"); return false; } ByteArrayOutputStream output = new ByteArrayOutputStream(); fileStorageService.download(fileInfo).outputStream(output); byte[] bytes = output.toByteArray(); if (bytes.length == 0 || bytes.length != payload.getPdfSource().getSizeBytes()) { reportInvalid(payload, "持久化 PDF 对象大小不匹配"); return false; } response.setContentType(MediaType.APPLICATION_PDF_VALUE); response.setHeader(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() .filename(fileName, StandardCharsets.UTF_8).build().toString()); response.setHeader(HttpHeaders.CACHE_CONTROL, "no-store"); response.setContentLengthLong(bytes.length); response.getOutputStream().write(bytes); return true; } catch (Exception exception) { if (!response.isCommitted()) response.reset(); reportInvalid(payload, "持久化 PDF 对象读取失败"); return false; } } private void reportInvalid(DmsFileDownloadTicket payload, String reason) { if (recoveryReporter != null) recoveryReporter.report(payload, reason); } DmsFileDownloadTicket parse(String accessTicket) { if (isBlank(accessTicket)) { throw new DataException("缺少 DMS 下载票据"); } try { return DmsFileDownloadTicket.fromRaw(ticketReader.read(accessTicket)); } catch (RuntimeException exception) { throw new DataException("DMS 下载票据无效或已过期"); } } 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); } }