刘光辉
3 小时以前 06f314545ac3514d7ce28939920866ae8188d31c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
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<byte[]> 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);
    }
}