刘光辉
118 分钟以前 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
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.<DmsWatermarkSnapshot>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);
    }
}