刘光辉
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
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
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);
        }
    }
}